Skip to content

Commit 17f354a

Browse files
sgwannabeclaude
andauthored
feat(workflow): H1->SpecDD auto-advance — sentinel hook + dispatch script + M3 imperatives (Phase 1) (#103)
* feat(scripts): dispatch-spec-cycle.sh — H1 lock validator (Phase 1) Validates chosen_preview.json.lock + design-approved.json + idea.spec.json in <run_dir>; emits dispatch JSON for SPEC_LEAD on success (exit 0), exit 2 + stderr on missing/empty artifact, exit 1 on bad arg. Mirrors scripts/h1-modal-helper.sh contract style (single-line JSON via python3 for fail-closed encoding). Phase 1 of 2 — addresses user-reported gap where /pf:new did not auto-advance into SpecDD after H1 freeze. * feat(hooks): post-h1-signal.py — sentinel writer on design-approved.json (Phase 1) PostToolUse hook on Write tool: when runs/<id>/design-approved.json is written and chosen_preview.json.lock already exists, drops a .h1-frozen-signal sentinel and appends a Blackboard h1.frozen row so M1 run-supervisor's standup loop can dispatch SpecDD without user re-prompting. Bounded stdin read (4MiB cap, MemoryError catch — W1.3 hardening). Always exits 0 (advisory). Registered in hooks.json PostToolUse with matcher Write; existing hook entries preserved. * docs(agents,commands): explicit H1->SpecDD auto-advance imperatives (Phase 1) - chief-engineer-pm.md §3.9: imperative Task block for auto-dispatching SPEC_LEAD immediately after design-approved.json lock; references scripts/dispatch-spec-cycle.sh for verification. - run-supervisor.md §1: standup-tick polling rule for runs/*/.h1-frozen-signal sentinel with idempotent runs/.last-spec-dispatch marker. - commands/new.md L11: replaces vague 'after design approval' phrasing with explicit immediate-dispatch contract (zero-input, verifiable). LLM trust hardening — three places that previously implied auto-advance now state it imperatively, matching the README 'human clicks twice' promise. * fix(run-supervisor): seed .last-spec-dispatch watermark before first poll (codex P1) Without an initial 'runs/.last-spec-dispatch' file, 'find -newer' returns no matches (and exits 1) on fresh installs, making the first H1 signal invisible to the standup polling loop. Document an epoch-0 touch guard so the find always starts with a discoverable baseline. Addresses codex P1 from Phase 1 review. * fix(workflow): apply PR #103 review feedback (codex) - chief-engineer-pm.md §3.9: replace bare `scripts/dispatch-spec-cycle.sh` with `${CLAUDE_PLUGIN_ROOT}/../../scripts/dispatch-spec-cycle.sh` so the H1->SpecDD auto-advance validator works in user workspaces (where the plugin repo's `scripts/` directory is not present), matching the form already used by generate-gallery.sh / open-browser.sh / h1-modal-helper.sh / generate-spec-anchor-audit.py in this same file. Refs PR #103 review comments Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 86053b0 commit 17f354a

6 files changed

Lines changed: 289 additions & 1 deletion

File tree

plugins/preview-forge/agents/meta/chief-engineer-pm.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,30 @@ esac
104104
`mode=inline`은 정상 분기이며 helper는 exit 0을 반환한다 (swap은 에러가 아니라 기대된 alternative path). `mode=error`만 helper 자체가 비-0 exit code로 propagate한다. 회귀 테스트: `tests/fixtures/h1-modal-swap/verify.sh`가 PATH-stripped 환경(`open`/`xdg-open`/`powershell.exe`/`pwsh` 부재)에서 byte-equal `{"mode":"inline",...}` 출력을, 가짜 `open` shim 환경에서 byte-equal `{"mode":"browser",...}` 출력을 어설션한다.
105105
<!-- end A-5 -->
106106

107+
<!-- H1→SpecDD auto-advance (PR Phase 1, addresses user-reported gap) -->
108+
#### §3.9 — H1 잠금 직후 SpecDD 자동 dispatch (필수, 자동, 사용자 입력 없음)
109+
110+
`design-approved.json`이 잠금된 직후 (= `chosen_preview.json.lock` + `design-approved.json` 모두 존재), M3는 **사용자 추가 입력 없이 즉시** SpecDD 사이클을 dispatch한다. 이는 README의 "human clicks twice" 약속의 핵심 — H1과 H2 외에는 자동 진행이어야 한다.
111+
112+
검증 스크립트 (다른 §3 helper와 동일한 plugin-root 절대 경로 형태 — 사용자 workspace에서 `scripts/`가 없을 때도 동작):
113+
```bash
114+
bash "${CLAUDE_PLUGIN_ROOT}/../../scripts/dispatch-spec-cycle.sh" runs/<id>/
115+
# exit 0 + JSON {"action":"dispatch",...} → 즉시 다음 단계
116+
# exit 2 → 락 산출물 누락; 사용자에게 H1 미완료 보고
117+
```
118+
119+
dispatch JSON이 출력되면 M3는 즉시 다음 Task를 호출한다:
120+
```
121+
Task({
122+
subagent_type: "pf:spec:spec-lead",
123+
description: "SpecDD cycle start (post-H1 auto)",
124+
prompt: "runs/<id>/ 락 산출물(chosen_preview.json.lock + design-approved.json + idea.spec.json)을 입력으로 OpenAPI v1을 작성한다. SC1-SC7 7개 critic을 순차 dispatch하여 합의된 spec을 specs/openapi.yaml + .lock으로 잠근다."
125+
})
126+
```
127+
128+
이 dispatch는 markdown 지시가 아니라 **명령형 imperative** — LLM trust 줄이기 위해 의도적으로 명시적 Task block.
129+
<!-- end H1→SpecDD auto-advance -->
130+
107131
### 4. Memory 파일 관리 (쓰기 권한 독점)
108132

109133
**Rule 3**에 따라 당신만 `memory/{CLAUDE,PROGRESS,LESSONS}.md`에 쓸 수 있습니다. 다른 agent는 Blackboard에 `memory.request.{file}` 키로 요청 → 당신이 검토 후 batch 반영.

plugins/preview-forge/agents/meta/run-supervisor.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ CLI에서 `scripts/pre-flight.sh` 또는 `pf check`가 동일 검증을 수동
8080
- 각 사이클 완료 조건(산출물 해시·잠금 파일) 검증 후 다음 진입
8181
- Profile에 따라 Advocate 수 (9/18/26), Engineering 팀 수 (2×5/3×5/5×5), SCC iter (3/4/5), Panel 모드 자동 설정
8282

83+
<!-- H1 sentinel polling (PR Phase 1) -->
84+
#### Sentinel-driven 자동 cycle 진행 (run-supervisor → M3 dispatch)
85+
86+
매 standup tick마다 다음 두 sentinel 파일을 polling한다:
87+
88+
1. `runs/*/.h1-frozen-signal``post-h1-signal.py` hook이 작성. SpecDD 시작 시그널.
89+
- 발견 시: M3에 `dispatch_spec_cycle(run_id)` 알림 → M3가 §3.9 절차 수행.
90+
- 처리 후 `runs/.last-spec-dispatch` touch로 idempotent 보장.
91+
92+
2. `runs/*/.h2-frozen-signal` — (Phase 2에서 추가 예정. 현재는 placeholder.)
93+
94+
polling 명령:
95+
```bash
96+
# 첫 polling 전에 watermark 파일을 epoch=0으로 초기 시드 (fresh install 시
97+
# `runs/.last-spec-dispatch`가 없으면 `find -newer`가 0 result + exit 1을
98+
# 반환해 첫 H1 signal을 invisible하게 만든다 — codex P1 수정).
99+
[ -f runs/.last-spec-dispatch ] || { mkdir -p runs && touch -t 197001010000 runs/.last-spec-dispatch; }
100+
find runs/ -maxdepth 2 -name '.h1-frozen-signal' -newer runs/.last-spec-dispatch 2>/dev/null
101+
```
102+
<!-- end H1 sentinel polling -->
103+
83104
### 2. Memory pre-load
84105
새 run 시작 시 관련 LESSONS 항목을 추출하여 각 department lead의 system prompt에 동적으로 주입:
85106
- I_LEAD에게는 PreviewDD·Mockup 관련 LESSONS

plugins/preview-forge/commands/new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ CLI 환경에서는 `scripts/pre-flight.sh` 또는 `pf check`로 동일 검증
118118
- escalation: advocate vote dispersion > confidence_threshold → 자동으로 full panel로 복귀
119119
10. Mitigation Designer가 dissent → action items 변환
120120
11. Gate H1(`/pf:design`) 자동 호출: `scripts/generate-gallery.sh` + `scripts/open-browser.sh`가 먼저 뜨며 `runs/<id>/mockups/gallery.html`을 브라우저에 띄우고, 동시에 AskUserQuestion으로 preview 선택 수집 → `chosen_preview.json` 잠금
121-
12. 사용자 디자인 승인 후 SpecDD cycle 시작 (이후 `idea-drift-detector.py` 훅이 모든 spec write를 containment 검사 — v1.6.0에서도 Rule 9 anchor는 `chosen_preview`만 유지한다. `idea.spec.json`은 advocate ground truth + PreviewDD cache key에만 사용되며, 기술 spec 파일에 business vocab이 없을 때 false-positive를 피하기 위해 drift 계산 대상에서 제외됨.)
121+
12. 사용자가 H1에서 디자인을 승인하면 (`design-approved.json` 잠금) M3는 **즉시** SpecDD 사이클을 자동 시작한다 (사용자 추가 입력 0). 이는 `scripts/dispatch-spec-cycle.sh`로 검증 가능하며, `chief-engineer-pm.md` §3.9에 명령형으로 기술. 이후 `idea-drift-detector.py` 훅이 spec 작성 단계에서 chosen_preview에 anchored 되어 있는지 강제.
122122
123123
사용자는 Gate H1, Gate H2 두 번만 개입합니다. 이외 모든 결정은 143-agent 조직이 자율 처리 (the 143-agent organization runs autonomously between the two human gates).
124124

plugins/preview-forge/hooks/hooks.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/auto-retro-trigger.py"
4343
}
4444
]
45+
},
46+
{
47+
"matcher": "Write",
48+
"hooks": [
49+
{
50+
"type": "command",
51+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/post-h1-signal.py"
52+
}
53+
]
4554
}
4655
]
4756
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env python3
2+
"""Preview Forge — Post-H1 sentinel writer (PostToolUse hook).
3+
4+
Watches Write tool calls. When `runs/<id>/design-approved.json` is
5+
written AND `runs/<id>/chosen_preview.json.lock` already exists (i.e.
6+
H1 has truly frozen — both lock artifacts present), this hook drops
7+
`runs/<id>/.h1-frozen-signal` so M1 Run Supervisor's standup polling
8+
loop can see "H1 done → kick SpecDD cycle" without M3 needing to be
9+
re-prompted by the user.
10+
11+
Also appends a Blackboard SQLite row (matches `auto-retro-trigger.py`
12+
convention — local repo standard) so the signal is observable in the
13+
existing trace tooling.
14+
15+
Exit 0 always — advisory hook, never blocks the Write tool.
16+
17+
Hardening (W1.3 pattern):
18+
- Bounded stdin read (4 MiB) with MemoryError catch, so a runaway
19+
payload can't OOM the hook process.
20+
"""
21+
from __future__ import annotations
22+
23+
import json
24+
import os
25+
import re
26+
import sqlite3
27+
import sys
28+
import time
29+
from pathlib import Path
30+
31+
PLUGIN_ROOT = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", ""))
32+
CLAUDE_MD = PLUGIN_ROOT / "memory" / "CLAUDE.md"
33+
34+
# Same run_id charset as auto-retro-trigger.py — see that file's S-6
35+
# comment for rationale (path traversal defense in depth).
36+
_RUN_ID = r"r-[A-Za-z0-9][A-Za-z0-9_-]{0,63}"
37+
DESIGN_APPROVED = re.compile(rf"runs/({_RUN_ID})/design-approved\.json$")
38+
39+
STDIN_CAP_BYTES = 4 * 1024 * 1024 # 4 MiB
40+
41+
42+
def is_active() -> bool:
43+
return CLAUDE_MD.exists()
44+
45+
46+
def read_hook_input() -> dict:
47+
try:
48+
raw = sys.stdin.read(STDIN_CAP_BYTES + 1)
49+
if len(raw) > STDIN_CAP_BYTES:
50+
print(
51+
"[preview-forge/post-h1-signal] warn: stdin exceeds 4MiB cap",
52+
file=sys.stderr,
53+
)
54+
return {}
55+
return json.loads(raw) if raw.strip() else {}
56+
except (json.JSONDecodeError, MemoryError):
57+
return {}
58+
59+
60+
def write_sentinel(run_dir: Path) -> None:
61+
sentinel = run_dir / ".h1-frozen-signal"
62+
sentinel.write_text(
63+
json.dumps(
64+
{"ts": int(time.time()), "run_id": run_dir.name},
65+
ensure_ascii=False,
66+
)
67+
+ "\n",
68+
encoding="utf-8",
69+
)
70+
71+
72+
def append_blackboard(run_dir: Path) -> None:
73+
bb_path = run_dir / "blackboard.db"
74+
if not bb_path.parent.exists():
75+
return
76+
conn = sqlite3.connect(str(bb_path))
77+
try:
78+
conn.execute(
79+
"""
80+
CREATE TABLE IF NOT EXISTS blackboard (
81+
id INTEGER PRIMARY KEY AUTOINCREMENT,
82+
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
83+
agent_id TEXT NOT NULL,
84+
key TEXT NOT NULL,
85+
value TEXT,
86+
tier INTEGER,
87+
dept TEXT
88+
)
89+
"""
90+
)
91+
conn.execute(
92+
"""
93+
INSERT INTO blackboard (agent_id, key, value, tier, dept)
94+
VALUES (?, ?, ?, ?, ?)
95+
""",
96+
(
97+
"post-h1-signal",
98+
"h1.frozen",
99+
json.dumps(
100+
{"run_id": run_dir.name, "ts": int(time.time())},
101+
ensure_ascii=False,
102+
),
103+
1,
104+
"meta",
105+
),
106+
)
107+
conn.commit()
108+
finally:
109+
conn.close()
110+
111+
112+
def main() -> int:
113+
if not is_active():
114+
return 0
115+
116+
payload = read_hook_input()
117+
tool = payload.get("tool_name") or payload.get("tool") or ""
118+
if tool != "Write":
119+
return 0
120+
121+
tool_input = payload.get("tool_input") or payload.get("input") or {}
122+
path = tool_input.get("file_path") or tool_input.get("path") or ""
123+
if not path:
124+
return 0
125+
126+
m = DESIGN_APPROVED.search(path)
127+
if not m:
128+
return 0
129+
130+
run_id = m.group(1)
131+
cwd = Path.cwd()
132+
run_dir = cwd / "runs" / run_id
133+
134+
# Both lock artifacts must already be present — `design-approved.json`
135+
# alone is not sufficient (it could be an in-progress draft write).
136+
if not (run_dir / "chosen_preview.json.lock").exists():
137+
return 0
138+
if not run_dir.exists():
139+
return 0
140+
141+
try:
142+
write_sentinel(run_dir)
143+
except Exception as e: # noqa: BLE001
144+
print(
145+
f"[preview-forge/post-h1-signal] sentinel warn: {e}",
146+
file=sys.stderr,
147+
)
148+
149+
try:
150+
append_blackboard(run_dir)
151+
except Exception as e: # noqa: BLE001
152+
print(
153+
f"[preview-forge/post-h1-signal] blackboard warn: {e}",
154+
file=sys.stderr,
155+
)
156+
157+
print(
158+
f"[preview-forge/post-h1-signal] H1 frozen signal written for run={run_id}",
159+
file=sys.stderr,
160+
)
161+
return 0
162+
163+
164+
if __name__ == "__main__":
165+
sys.exit(main())

scripts/dispatch-spec-cycle.sh

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env bash
2+
# Preview Forge — Phase 1 H1→SpecDD auto-advance dispatch validator.
3+
#
4+
# After Gate H1 freezes (`chosen_preview.json.lock` + `design-approved.json`
5+
# present + `idea.spec.json` from Socratic), M3 must immediately dispatch
6+
# the SpecDD cycle (SPEC_LEAD → OpenAPI v1) without an extra user input.
7+
# This script is the machine-verifiable side of that contract: it ONLY
8+
# validates the run_dir's lock artifacts and emits a JSON line describing
9+
# the dispatch the M3 caller must perform. The Task call itself stays in
10+
# M3 markdown (chief-engineer-pm.md §3.9) — the script never invokes the
11+
# LLM or shells out to claude.
12+
#
13+
# Contract (mirrors scripts/h1-modal-helper.sh style):
14+
# - all 3 files exist and are non-empty
15+
# → stdout: {"action":"dispatch","agent":"SPEC_LEAD",
16+
# "input_dir":"<run_dir>","run_id":"<id>"}
17+
# → exit: 0
18+
# - any file missing or empty
19+
# → stderr: "post-h1 dispatch precondition failed: <missing>"
20+
# → exit: 2
21+
# - bad arg
22+
# → exit: 1
23+
#
24+
# Determinism: stdout is a single JSON line, no trailing whitespace —
25+
# fixtures can byte-equal compare without `jq` round-tripping.
26+
27+
set -u
28+
29+
run_dir="${1:-}"
30+
if [ -z "$run_dir" ]; then
31+
echo "usage: dispatch-spec-cycle.sh <run_dir>" >&2
32+
exit 1
33+
fi
34+
35+
# Strip trailing slash for clean run_id derivation but keep a copy with
36+
# trailing slash for input_dir (callers expect dir form).
37+
run_dir_norm="${run_dir%/}"
38+
run_id="$(basename "$run_dir_norm")"
39+
40+
required=(
41+
"$run_dir_norm/chosen_preview.json.lock"
42+
"$run_dir_norm/design-approved.json"
43+
"$run_dir_norm/idea.spec.json"
44+
)
45+
46+
for f in "${required[@]}"; do
47+
if [ ! -s "$f" ]; then
48+
echo "post-h1 dispatch precondition failed: $f" >&2
49+
exit 2
50+
fi
51+
done
52+
53+
# Emit JSON via python3 to avoid shell-escaping bugs on paths with quotes.
54+
# Fail-closed: if python3 is missing we MUST NOT emit a malformed payload.
55+
python3 -c '
56+
import json, sys
57+
sys.stdout.write(json.dumps({
58+
"action": "dispatch",
59+
"agent": "SPEC_LEAD",
60+
"input_dir": sys.argv[1],
61+
"run_id": sys.argv[2],
62+
}, ensure_ascii=False))
63+
sys.stdout.write("\n")
64+
' "$run_dir_norm/" "$run_id" || {
65+
echo "dispatch-spec-cycle.sh: python3 unavailable — cannot encode JSON" >&2
66+
exit 1
67+
}
68+
69+
exit 0

0 commit comments

Comments
 (0)