-
Notifications
You must be signed in to change notification settings - Fork 0
feat(workflow): H1->SpecDD auto-advance — sentinel hook + dispatch script + M3 imperatives (Phase 1) #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(workflow): H1->SpecDD auto-advance — sentinel hook + dispatch script + M3 imperatives (Phase 1) #103
Changes from 4 commits
38e2d9d
828c78c
c999c98
06eedbd
3d2c75b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| #!/usr/bin/env python3 | ||
| """Preview Forge — Post-H1 sentinel writer (PostToolUse hook). | ||
|
|
||
| Watches Write tool calls. When `runs/<id>/design-approved.json` is | ||
| written AND `runs/<id>/chosen_preview.json.lock` already exists (i.e. | ||
| H1 has truly frozen — both lock artifacts present), this hook drops | ||
| `runs/<id>/.h1-frozen-signal` so M1 Run Supervisor's standup polling | ||
| loop can see "H1 done → kick SpecDD cycle" without M3 needing to be | ||
| re-prompted by the user. | ||
|
|
||
| Also appends a Blackboard SQLite row (matches `auto-retro-trigger.py` | ||
| convention — local repo standard) so the signal is observable in the | ||
| existing trace tooling. | ||
|
|
||
| Exit 0 always — advisory hook, never blocks the Write tool. | ||
|
|
||
| Hardening (W1.3 pattern): | ||
| - Bounded stdin read (4 MiB) with MemoryError catch, so a runaway | ||
| payload can't OOM the hook process. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import re | ||
| import sqlite3 | ||
| import sys | ||
| import time | ||
| from pathlib import Path | ||
|
|
||
| PLUGIN_ROOT = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) | ||
| CLAUDE_MD = PLUGIN_ROOT / "memory" / "CLAUDE.md" | ||
|
|
||
| # Same run_id charset as auto-retro-trigger.py — see that file's S-6 | ||
| # comment for rationale (path traversal defense in depth). | ||
| _RUN_ID = r"r-[A-Za-z0-9][A-Za-z0-9_-]{0,63}" | ||
| DESIGN_APPROVED = re.compile(rf"runs/({_RUN_ID})/design-approved\.json$") | ||
|
|
||
| STDIN_CAP_BYTES = 4 * 1024 * 1024 # 4 MiB | ||
|
|
||
|
|
||
| def is_active() -> bool: | ||
| return CLAUDE_MD.exists() | ||
|
|
||
|
|
||
| def read_hook_input() -> dict: | ||
| try: | ||
| raw = sys.stdin.read(STDIN_CAP_BYTES + 1) | ||
| if len(raw) > STDIN_CAP_BYTES: | ||
| print( | ||
| "[preview-forge/post-h1-signal] warn: stdin exceeds 4MiB cap", | ||
| file=sys.stderr, | ||
| ) | ||
| return {} | ||
| return json.loads(raw) if raw.strip() else {} | ||
| except (json.JSONDecodeError, MemoryError): | ||
| return {} | ||
|
|
||
|
|
||
| def write_sentinel(run_dir: Path) -> None: | ||
| sentinel = run_dir / ".h1-frozen-signal" | ||
| sentinel.write_text( | ||
| json.dumps( | ||
| {"ts": int(time.time()), "run_id": run_dir.name}, | ||
| ensure_ascii=False, | ||
| ) | ||
| + "\n", | ||
| encoding="utf-8", | ||
| ) | ||
|
|
||
|
|
||
| def append_blackboard(run_dir: Path) -> None: | ||
| bb_path = run_dir / "blackboard.db" | ||
| if not bb_path.parent.exists(): | ||
| return | ||
| conn = sqlite3.connect(str(bb_path)) | ||
| try: | ||
| conn.execute( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS blackboard ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||
| agent_id TEXT NOT NULL, | ||
| key TEXT NOT NULL, | ||
| value TEXT, | ||
| tier INTEGER, | ||
| dept TEXT | ||
| ) | ||
| """ | ||
| ) | ||
| conn.execute( | ||
| """ | ||
| INSERT INTO blackboard (agent_id, key, value, tier, dept) | ||
| VALUES (?, ?, ?, ?, ?) | ||
| """, | ||
| ( | ||
| "post-h1-signal", | ||
| "h1.frozen", | ||
| json.dumps( | ||
| {"run_id": run_dir.name, "ts": int(time.time())}, | ||
| ensure_ascii=False, | ||
| ), | ||
| 1, | ||
| "meta", | ||
| ), | ||
| ) | ||
| conn.commit() | ||
| finally: | ||
| conn.close() | ||
|
|
||
|
|
||
| def main() -> int: | ||
| if not is_active(): | ||
| return 0 | ||
|
|
||
| payload = read_hook_input() | ||
| tool = payload.get("tool_name") or payload.get("tool") or "" | ||
| if tool != "Write": | ||
| return 0 | ||
|
Comment on lines
+117
to
+119
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new hook only handles Useful? React with 👍 / 👎. |
||
|
|
||
| tool_input = payload.get("tool_input") or payload.get("input") or {} | ||
| path = tool_input.get("file_path") or tool_input.get("path") or "" | ||
| if not path: | ||
| return 0 | ||
|
|
||
| m = DESIGN_APPROVED.search(path) | ||
| if not m: | ||
| return 0 | ||
|
|
||
| run_id = m.group(1) | ||
| cwd = Path.cwd() | ||
| run_dir = cwd / "runs" / run_id | ||
|
|
||
| # Both lock artifacts must already be present — `design-approved.json` | ||
| # alone is not sufficient (it could be an in-progress draft write). | ||
| if not (run_dir / "chosen_preview.json.lock").exists(): | ||
| return 0 | ||
| if not run_dir.exists(): | ||
| return 0 | ||
|
|
||
| try: | ||
| write_sentinel(run_dir) | ||
| except Exception as e: # noqa: BLE001 | ||
| print( | ||
| f"[preview-forge/post-h1-signal] sentinel warn: {e}", | ||
| file=sys.stderr, | ||
| ) | ||
|
|
||
| try: | ||
| append_blackboard(run_dir) | ||
| except Exception as e: # noqa: BLE001 | ||
| print( | ||
| f"[preview-forge/post-h1-signal] blackboard warn: {e}", | ||
| file=sys.stderr, | ||
| ) | ||
|
|
||
| print( | ||
| f"[preview-forge/post-h1-signal] H1 frozen signal written for run={run_id}", | ||
| file=sys.stderr, | ||
| ) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| #!/usr/bin/env bash | ||
| # Preview Forge — Phase 1 H1→SpecDD auto-advance dispatch validator. | ||
| # | ||
| # After Gate H1 freezes (`chosen_preview.json.lock` + `design-approved.json` | ||
| # present + `idea.spec.json` from Socratic), M3 must immediately dispatch | ||
| # the SpecDD cycle (SPEC_LEAD → OpenAPI v1) without an extra user input. | ||
| # This script is the machine-verifiable side of that contract: it ONLY | ||
| # validates the run_dir's lock artifacts and emits a JSON line describing | ||
| # the dispatch the M3 caller must perform. The Task call itself stays in | ||
| # M3 markdown (chief-engineer-pm.md §3.9) — the script never invokes the | ||
| # LLM or shells out to claude. | ||
| # | ||
| # Contract (mirrors scripts/h1-modal-helper.sh style): | ||
| # - all 3 files exist and are non-empty | ||
| # → stdout: {"action":"dispatch","agent":"SPEC_LEAD", | ||
| # "input_dir":"<run_dir>","run_id":"<id>"} | ||
| # → exit: 0 | ||
| # - any file missing or empty | ||
| # → stderr: "post-h1 dispatch precondition failed: <missing>" | ||
| # → exit: 2 | ||
| # - bad arg | ||
| # → exit: 1 | ||
| # | ||
| # Determinism: stdout is a single JSON line, no trailing whitespace — | ||
| # fixtures can byte-equal compare without `jq` round-tripping. | ||
|
|
||
| set -u | ||
|
|
||
| run_dir="${1:-}" | ||
| if [ -z "$run_dir" ]; then | ||
| echo "usage: dispatch-spec-cycle.sh <run_dir>" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Strip trailing slash for clean run_id derivation but keep a copy with | ||
| # trailing slash for input_dir (callers expect dir form). | ||
| run_dir_norm="${run_dir%/}" | ||
| run_id="$(basename "$run_dir_norm")" | ||
|
|
||
| required=( | ||
| "$run_dir_norm/chosen_preview.json.lock" | ||
| "$run_dir_norm/design-approved.json" | ||
| "$run_dir_norm/idea.spec.json" | ||
| ) | ||
|
|
||
| for f in "${required[@]}"; do | ||
| if [ ! -s "$f" ]; then | ||
| echo "post-h1 dispatch precondition failed: $f" >&2 | ||
| exit 2 | ||
| fi | ||
| done | ||
|
|
||
| # Emit JSON via python3 to avoid shell-escaping bugs on paths with quotes. | ||
| # Fail-closed: if python3 is missing we MUST NOT emit a malformed payload. | ||
| python3 -c ' | ||
| import json, sys | ||
| sys.stdout.write(json.dumps({ | ||
| "action": "dispatch", | ||
| "agent": "SPEC_LEAD", | ||
| "input_dir": sys.argv[1], | ||
| "run_id": sys.argv[2], | ||
| }, ensure_ascii=False)) | ||
| sys.stdout.write("\n") | ||
| ' "$run_dir_norm/" "$run_id" || { | ||
| echo "dispatch-spec-cycle.sh: python3 unavailable — cannot encode JSON" >&2 | ||
| exit 1 | ||
| } | ||
|
|
||
| exit 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new imperative uses
bash scripts/dispatch-spec-cycle.sh runs/<id>/, but Preview Forge sessions run from user workspaces (created bypf init) wherescripts/is not present, and M1 pre-flight explicitly avoids operating inside the plugin repo. In that normal runtime context this command becomesNo such file or directory, so H1→SpecDD auto-advance cannot produce the dispatch JSON. Use the same${CLAUDE_PLUGIN_ROOT}/../../scripts/...form already used in this file for other scripts.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
지적 정확합니다. 사용자 workspace(
pf init로 생성된 환경)에는 plugin repo의scripts/디렉터리가 없으므로 barescripts/dispatch-spec-cycle.sh호출은No such file or directory로 실패합니다.§3.9 검증 스크립트 호출을 같은 파일의 다른 §3 helper들(
generate-gallery.sh,open-browser.sh,h1-modal-helper.sh,generate-spec-anchor-audit.py)과 동일한${CLAUDE_PLUGIN_ROOT}/../../scripts/...절대 경로 형태로 통일했습니다 (commit 3d2c75b).전체 회귀 suite (verify-plugin / security / e2e standard·pro·max / advocate-boilerplate / lesson07 / cache-concurrency 3종 / filled-ratio-gating / h1-modal-swap / spec-anchor-convergence) 모두 그린 확인했습니다.