From 027cc3572674f59e7f3c815341bd8ea880fb0dfc Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:07:32 +0000 Subject: [PATCH 01/17] =?UTF-8?q?invoker:=20wf-1789406883560-32/implement-?= =?UTF-8?q?hook-build-the-lever=20=E2=80=94=20Put=20the=20build-the-lever?= =?UTF-8?q?=20hook=20onto=20the=20shared=20hook=20code.=20Review=20claim:?= =?UTF-8?q?=20This=20hook=20reports=20findings=20to=20the=20shared=20hook?= =?UTF-8?q?=20code,=20which=20applies=20its=20registry=20mode=20and=20writ?= =?UTF-8?q?es=20event=20rows.=20It=20keeps=20mode=20warn.=20Review=20lane:?= =?UTF-8?q?=20behavior=20Safety=20invariant:=20The=20hook=20gives=20the=20?= =?UTF-8?q?same=20stop,=20warn,=20or=20silent=20result=20on=20every=20case?= =?UTF-8?q?=20in=20its=20current=20test=20folder,=20except=20the=20mode=20?= =?UTF-8?q?change=20named=20in=20this=20claim,=20and=20its=20test=20folder?= =?UTF-8?q?=20keeps=20exiting=200.=20Effectiveness=20measurement:=20`pytho?= =?UTF-8?q?n3=20-m=20unittest=20discover=20-s=20engine/hooks/build-the-lev?= =?UTF-8?q?er/tests`=20exits=200,=20and=20the=20new=20mode-override=20case?= =?UTF-8?q?=20fails=20before=20this=20change.=20Slice=20rationale:=20One?= =?UTF-8?q?=20hook=20per=20workflow,=20as=20the=20user=20asked,=20so=20eac?= =?UTF-8?q?h=20migration=20is=20reviewed=20on=20its=20own.=20Architectural?= =?UTF-8?q?=20effect:=20The=20build-the-lever=20entry=20scripts=20become?= =?UTF-8?q?=20thin=20calls=20into=20the=20shared=20runtime;=20its=20detect?= =?UTF-8?q?ion=20returns=20findings.=20Goal:=20Suggests=20a=20script=20whe?= =?UTF-8?q?n=20many=20files=20are=20hand-edited.=20Keep=20that=20behavior?= =?UTF-8?q?=20while=20its=20mode=20moves=20into=20the=20registry.=20Motiva?= =?UTF-8?q?tion:=20Mode=20and=20output=20shape=20live=20inside=20each=20ho?= =?UTF-8?q?ok=20today;=20the=20shared=20code=20makes=20a=20mode=20change?= =?UTF-8?q?=20a=20one-line=20registry=20edit.=20Alternative=20consideratio?= =?UTF-8?q?ns:=20Migrating=20several=20hooks=20per=20workflow=20was=20set?= =?UTF-8?q?=20aside=20because=20the=20user=20asked=20for=20one=20hook=20pe?= =?UTF-8?q?r=20workflow.=20Implementation=20details:=20Turn=20this=20hook'?= =?UTF-8?q?s=20detection=20into=20detect(event)=20returning=20Finding=20ob?= =?UTF-8?q?jects=20with=20stable=20rule=20ids,=20and=20make=20each=20harne?= =?UTF-8?q?ss=20entry=20script=20call=20run=5Fhook=20from=20engine/hooks/?= =?UTF-8?q?=5Fsdk/runtime.py.=20It=20keeps=20mode=20warn.=20Non-goals:=20N?= =?UTF-8?q?o=20change=20to=20what=20the=20hook=20detects.=20No=20other=20h?= =?UTF-8?q?ook=20changes.=20Layer:=20domain=20Feature=20state:=20active=20?= =?UTF-8?q?Files:=20engine/hooks/build-the-lever/claude=5Fposttooluse.py,?= =?UTF-8?q?=20engine/hooks/build-the-lever/claude=5Fprompt=5Fsubmit.py,=20?= =?UTF-8?q?engine/hooks/build-the-lever/codex=5Fposttooluse.py,=20engine/h?= =?UTF-8?q?ooks/build-the-lever/codex=5Fprompt=5Fsubmit.py,=20engine/hooks?= =?UTF-8?q?/build-the-lever/cursor=5Fbefore=5Fsubmit.py,=20engine/hooks/bu?= =?UTF-8?q?ild-the-lever/cursor=5Fpost=5Ftool=5Fuse.py,=20engine/hooks/bui?= =?UTF-8?q?ld-the-lever/detect.py,=20engine/hooks/build-the-lever/install?= =?UTF-8?q?=5Fclaude=5Fhook.py,=20engine/hooks/build-the-lever/install=5Fc?= =?UTF-8?q?odex=5Fhook.py,=20engine/hooks/build-the-lever/install=5Fcursor?= =?UTF-8?q?=5Fhook.py,=20engine/hooks/build-the-lever/state.py,=20engine/h?= =?UTF-8?q?ooks/build-the-lever/tests/test=5Fhooks=5Fsdk=5Fmode.py=20Chang?= =?UTF-8?q?e=20types:=20-=20engine/hooks/build-the-lever/claude=5Fposttool?= =?UTF-8?q?use.py:=20modify=20-=20engine/hooks/build-the-lever/claude=5Fpr?= =?UTF-8?q?ompt=5Fsubmit.py:=20modify=20-=20engine/hooks/build-the-lever/c?= =?UTF-8?q?odex=5Fposttooluse.py:=20modify=20-=20engine/hooks/build-the-le?= =?UTF-8?q?ver/codex=5Fprompt=5Fsubmit.py:=20modify=20-=20engine/hooks/bui?= =?UTF-8?q?ld-the-lever/cursor=5Fbefore=5Fsubmit.py:=20modify=20-=20engine?= =?UTF-8?q?/hooks/build-the-lever/cursor=5Fpost=5Ftool=5Fuse.py:=20modify?= =?UTF-8?q?=20-=20engine/hooks/build-the-lever/detect.py:=20modify=20-=20e?= =?UTF-8?q?ngine/hooks/build-the-lever/install=5Fclaude=5Fhook.py:=20modif?= =?UTF-8?q?y=20-=20engine/hooks/build-the-lever/install=5Fcodex=5Fhook.py:?= =?UTF-8?q?=20modify=20-=20engine/hooks/build-the-lever/install=5Fcursor?= =?UTF-8?q?=5Fhook.py:=20modify=20-=20engine/hooks/build-the-lever/state.p?= =?UTF-8?q?y:=20modify=20-=20engine/hooks/build-the-lever/tests/test=5Fhoo?= =?UTF-8?q?ks=5Fsdk=5Fmode.py:=20create=20Acceptance=20criteria:=20-=20`py?= =?UTF-8?q?thon3=20-m=20unittest=20discover=20-s=20engine/hooks/build-the-?= =?UTF-8?q?lever/tests`=20exits=200.=20-=20With=20CATSTACK=5FHOOK=5FMODE?= =?UTF-8?q?=5FBUILD=5FTHE=5FLEVER=20set=20to=20warn,=20a=20case=20that=20s?= =?UTF-8?q?tops=20today=20produces=20a=20warning=20instead,=20proving=20th?= =?UTF-8?q?e=20registry=20mode=20drives=20the=20response.=20-=20Each=20fin?= =?UTF-8?q?ding=20writes=20one=20event=20row=20with=20the=20hook's=20rule?= =?UTF-8?q?=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Put the build-the-lever hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode warn. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/build-the-lever/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The build-the-lever entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Suggests a script when many files are hand-edited. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode warn. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/build-the-lever/claude_posttooluse.py, engine/hooks/build-the-lever/claude_prompt_submit.py, engine/hooks/build-the-lever/codex_posttooluse.py, engine/hooks/build-the-lever/codex_prompt_submit.py, engine/hooks/build-the-lever/cursor_before_submit.py, engine/hooks/build-the-lever/cursor_post_tool_use.py, engine/hooks/build-the-lever/detect.py, engine/hooks/build-the-lever/install_claude_hook.py, engine/hooks/build-the-lever/install_codex_hook.py, engine/hooks/build-the-lever/install_cursor_hook.py, engine/hooks/build-the-lever/state.py, engine/hooks/build-the-lever/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/build-the-lever/claude_posttooluse.py: modify - engine/hooks/build-the-lever/claude_prompt_submit.py: modify - engine/hooks/build-the-lever/codex_posttooluse.py: modify - engine/hooks/build-the-lever/codex_prompt_submit.py: modify - engine/hooks/build-the-lever/cursor_before_submit.py: modify - engine/hooks/build-the-lever/cursor_post_tool_use.py: modify - engine/hooks/build-the-lever/detect.py: modify - engine/hooks/build-the-lever/install_claude_hook.py: modify - engine/hooks/build-the-lever/install_codex_hook.py: modify - engine/hooks/build-the-lever/install_cursor_hook.py: modify - engine/hooks/build-the-lever/state.py: modify - engine/hooks/build-the-lever/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/build-the-lever/tests` exits 0. - With CATSTACK_HOOK_MODE_BUILD_THE_LEVER set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: 35d0555b-3f55-47f9-9e41-97f12e84dc55 --- engine/hooks/_sdk/render.py | 6 +- engine/hooks/_sdk/runtime.py | 17 ++- engine/hooks/build-the-lever/README.md | 10 +- .../build-the-lever/claude_posttooluse.py | 35 ++--- .../build-the-lever/claude_prompt_submit.py | 35 ++--- .../build-the-lever/codex_posttooluse.py | 35 ++--- .../build-the-lever/codex_prompt_submit.py | 35 ++--- .../build-the-lever/cursor_before_submit.py | 26 ++-- .../build-the-lever/cursor_post_tool_use.py | 30 ++--- engine/hooks/build-the-lever/detect.py | 64 +++++++++ .../hooks/build-the-lever/tests/test_hooks.py | 7 + .../tests/test_hooks_sdk_mode.py | 127 ++++++++++++++++++ 12 files changed, 274 insertions(+), 153 deletions(-) create mode 100644 engine/hooks/build-the-lever/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/_sdk/render.py b/engine/hooks/_sdk/render.py index 57ef64e9..24eac7d8 100644 --- a/engine/hooks/_sdk/render.py +++ b/engine/hooks/_sdk/render.py @@ -12,7 +12,11 @@ def render( mode: str, findings: Sequence[Finding], ) -> tuple[str, str, int]: - if mode == "off" or not findings: + if mode == "off": + return "", "", 0 + if not findings: + if harness == "cursor" and hook_event_name == "beforeSubmitPrompt": + return _json({"continue": True}), "", 0 return "", "", 0 message = _message(findings) diff --git a/engine/hooks/_sdk/runtime.py b/engine/hooks/_sdk/runtime.py index 04e5bd38..d1ea4351 100644 --- a/engine/hooks/_sdk/runtime.py +++ b/engine/hooks/_sdk/runtime.py @@ -14,16 +14,31 @@ from render import render -def run_hook(hook: str, harness: str, detect: Callable[[dict[str, object]], list[Finding]]) -> NoReturn: +def run_hook( + hook: str, + harness: str, + detect: Callable[[dict[str, object]], list[Finding]], + hook_event_name: str | None = None, +) -> NoReturn: started = time.monotonic() try: event = json.load(sys.stdin) except json.JSONDecodeError as exc: _write_findings_file([]) print(f"catstack-hook-error {hook}: JSONDecodeError: {exc}", file=sys.stderr) + stdout_text, _stderr_text, _exit_code = render( + harness, + hook_event_name or "", + "warn", + [], + ) + if stdout_text: + sys.stdout.write(stdout_text) sys.exit(0) if not isinstance(event, dict): event = {} + if hook_event_name and not _hook_event_name(event): + event["hook_event_name"] = hook_event_name hook_event_name = _hook_event_name(event) try: diff --git a/engine/hooks/build-the-lever/README.md b/engine/hooks/build-the-lever/README.md index 8f1c80c5..8645047a 100644 --- a/engine/hooks/build-the-lever/README.md +++ b/engine/hooks/build-the-lever/README.md @@ -8,14 +8,18 @@ command or this hook. Fail-open. Inject-only. Never blocks tools. Stays silent on one-file typo or "add a comment" asks. +The shared registry keeps this hook in `warn` mode. Set +`CATSTACK_HOOK_MODE_BUILD_THE_LEVER=off|warn|stop` for a machine-local +override. Warnings use the `build-the-lever:` reminder text; detection or +metrics failures allow the harness action. + ## Files -- `detect.py` — bulk-prompt regex + per-session file-mutation count +- `detect.py` — bulk-prompt/file-mutation detection returning SDK findings - `state.py` — session cache under `~/.cache/catstack-build-the-lever` -- `claude_prompt_submit.py` / `claude_posttooluse.py` — Claude inject +- harness entry scripts — thin calls into `engine/hooks/_sdk/runtime.py` - `cursor_before_submit.py` / `cursor_post_tool_use.py` — Cursor parity (`beforeSubmitPrompt` cannot inject; reminder arrives on first `postToolUse`) -- `codex_prompt_submit.py` / `codex_posttooluse.py` — Codex inject - `install_claude_hook.py` / `install_cursor_hook.py` / `install_codex_hook.py` ## Install diff --git a/engine/hooks/build-the-lever/claude_posttooluse.py b/engine/hooks/build-the-lever/claude_posttooluse.py index 53866ca8..aea489dd 100644 --- a/engine/hooks/build-the-lever/claude_posttooluse.py +++ b/engine/hooks/build-the-lever/claude_posttooluse.py @@ -1,38 +1,19 @@ #!/usr/bin/env python3 -"""Claude Code PostToolUse: inject once after four distinct file mutations. - -Fail-open. Never denies. -""" +"""Claude Code PostToolUse entrypoint for build-the-lever.""" from __future__ import annotations -import json +import os import sys -from detect import record_file_mutation, reminder_text, should_inject_for_edits +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - return - try: - record_file_mutation(payload if isinstance(payload, dict) else {}) - if not should_inject_for_edits(payload): - return - print( - json.dumps( - { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": reminder_text(), - } - } - ) - ) - except Exception as exc: - print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) - return + run_hook("build-the-lever", "claude", detect, "PostToolUse") if __name__ == "__main__": diff --git a/engine/hooks/build-the-lever/claude_prompt_submit.py b/engine/hooks/build-the-lever/claude_prompt_submit.py index 01fcbbf5..994bf434 100644 --- a/engine/hooks/build-the-lever/claude_prompt_submit.py +++ b/engine/hooks/build-the-lever/claude_prompt_submit.py @@ -1,38 +1,19 @@ #!/usr/bin/env python3 -"""Claude Code UserPromptSubmit: inject build-the-lever on bulk work. - -Fail-open. No LLM. Never denies. -""" +"""Claude Code UserPromptSubmit entrypoint for build-the-lever.""" from __future__ import annotations -import json +import os import sys -from detect import extract_prompt_text, is_bulk_work, mark_injected, reminder_text +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - return - try: - if not is_bulk_work(extract_prompt_text(payload if isinstance(payload, dict) else {})): - return - mark_injected(payload) - print( - json.dumps( - { - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": reminder_text(), - } - } - ) - ) - except Exception as exc: - print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) - return + run_hook("build-the-lever", "claude", detect, "UserPromptSubmit") if __name__ == "__main__": diff --git a/engine/hooks/build-the-lever/codex_posttooluse.py b/engine/hooks/build-the-lever/codex_posttooluse.py index d4028f04..ec388a37 100644 --- a/engine/hooks/build-the-lever/codex_posttooluse.py +++ b/engine/hooks/build-the-lever/codex_posttooluse.py @@ -1,38 +1,19 @@ #!/usr/bin/env python3 -"""Codex PostToolUse: inject once after four distinct file mutations. - -Fail-open. Never denies. -""" +"""Codex PostToolUse entrypoint for build-the-lever.""" from __future__ import annotations -import json +import os import sys -from detect import record_file_mutation, reminder_text, should_inject_for_edits +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - return - try: - record_file_mutation(payload if isinstance(payload, dict) else {}) - if not should_inject_for_edits(payload): - return - print( - json.dumps( - { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": reminder_text(), - } - } - ) - ) - except Exception as exc: - print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) - return + run_hook("build-the-lever", "codex", detect, "PostToolUse") if __name__ == "__main__": diff --git a/engine/hooks/build-the-lever/codex_prompt_submit.py b/engine/hooks/build-the-lever/codex_prompt_submit.py index 23c68fb8..3e748025 100644 --- a/engine/hooks/build-the-lever/codex_prompt_submit.py +++ b/engine/hooks/build-the-lever/codex_prompt_submit.py @@ -1,38 +1,19 @@ #!/usr/bin/env python3 -"""Codex UserPromptSubmit: inject build-the-lever on bulk work. - -Fail-open. Never denies. -""" +"""Codex UserPromptSubmit entrypoint for build-the-lever.""" from __future__ import annotations -import json +import os import sys -from detect import extract_prompt_text, is_bulk_work, mark_injected, reminder_text +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - return - try: - if not is_bulk_work(extract_prompt_text(payload if isinstance(payload, dict) else {})): - return - mark_injected(payload) - print( - json.dumps( - { - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": reminder_text(), - } - } - ) - ) - except Exception as exc: - print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) - return + run_hook("build-the-lever", "codex", detect, "UserPromptSubmit") if __name__ == "__main__": diff --git a/engine/hooks/build-the-lever/cursor_before_submit.py b/engine/hooks/build-the-lever/cursor_before_submit.py index f22df18c..4ea28f92 100644 --- a/engine/hooks/build-the-lever/cursor_before_submit.py +++ b/engine/hooks/build-the-lever/cursor_before_submit.py @@ -1,29 +1,19 @@ #!/usr/bin/env python3 -"""Cursor beforeSubmitPrompt: remember bulk work for the next postToolUse inject. - -Cursor cannot inject context here. Always continue. Fail-open. -""" +"""Cursor beforeSubmitPrompt entrypoint for build-the-lever.""" from __future__ import annotations -import json +import os import sys -from detect import extract_prompt_text, is_bulk_work, remember_bulk_prompt +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - print(json.dumps({"continue": True})) - return - try: - if is_bulk_work(extract_prompt_text(payload if isinstance(payload, dict) else {})): - remember_bulk_prompt(payload) - print(json.dumps({"continue": True})) - except Exception as exc: - print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) - print(json.dumps({"continue": True})) + run_hook("build-the-lever", "cursor", detect, "beforeSubmitPrompt") if __name__ == "__main__": diff --git a/engine/hooks/build-the-lever/cursor_post_tool_use.py b/engine/hooks/build-the-lever/cursor_post_tool_use.py index 6d02a1c0..b70da46e 100644 --- a/engine/hooks/build-the-lever/cursor_post_tool_use.py +++ b/engine/hooks/build-the-lever/cursor_post_tool_use.py @@ -1,33 +1,19 @@ #!/usr/bin/env python3 -"""Cursor postToolUse: inject pending prompt reminder or four-edit reminder once. - -Fail-open. Never continue false. -""" +"""Cursor postToolUse entrypoint for build-the-lever.""" from __future__ import annotations -import json +import os import sys -from detect import ( - consume_prompt_pending, - record_file_mutation, - reminder_text, - should_inject_for_edits, -) +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - return - try: - record_file_mutation(payload if isinstance(payload, dict) else {}) - if consume_prompt_pending(payload) or should_inject_for_edits(payload): - print(json.dumps({"additional_context": reminder_text()})) - except Exception as exc: - print(f"catstack-hook-error build-the-lever: {type(exc).__name__}: {exc}", file=sys.stderr) - return + run_hook("build-the-lever", "cursor", detect, "postToolUse") if __name__ == "__main__": diff --git a/engine/hooks/build-the-lever/detect.py b/engine/hooks/build-the-lever/detect.py index 461de343..c50f8735 100644 --- a/engine/hooks/build-the-lever/detect.py +++ b/engine/hooks/build-the-lever/detect.py @@ -4,9 +4,16 @@ """ from __future__ import annotations +import hashlib +import os import re +import sys from typing import Any +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 from state import load_state, save_state REMINDER = ( @@ -34,6 +41,8 @@ } LEVER_SUFFIXES = (".py", ".sh", ".mjs", ".js", ".ts") EDIT_THRESHOLD = 4 +RULE_BULK_PROMPT = "build-the-lever.bulk-prompt" +RULE_MANY_FILE_EDITS = "build-the-lever.many-file-edits" def reminder_text() -> str: @@ -116,6 +125,7 @@ def _is_lever_script(path: str) -> bool: def remember_bulk_prompt(payload: dict) -> None: state = load_state(payload) state["cursor_prompt_pending"] = True + state["cursor_prompt"] = extract_prompt_text(payload) save_state(payload, state) @@ -163,3 +173,57 @@ def mark_injected(payload: dict) -> None: state["injected"] = True state["cursor_prompt_pending"] = False save_state(payload, state) + + +def _event_name(event: dict) -> str: + for key in ("hook_event_name", "hookEventName", "event"): + value = event.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def _subject_hash(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + + +def _prompt_finding(prompt: str) -> Finding: + return Finding( + rule_id=RULE_BULK_PROMPT, + subject=f"prompt:{_subject_hash(prompt)}", + message=reminder_text(), + evidence=prompt, + ) + + +def _edit_finding(event: dict, current_state: dict[str, Any]) -> Finding: + path = _mutation_path(event) or str((current_state.get("mutated_paths") or [""])[-1]) + return Finding( + rule_id=RULE_MANY_FILE_EDITS, + subject=path, + message=reminder_text(), + evidence=", ".join(str(item) for item in current_state.get("mutated_paths") or []), + ) + + +def detect(event: dict) -> list[Finding]: + name = _event_name(event) + prompt = extract_prompt_text(event) + if name in {"beforeSubmitPrompt", "BeforeSubmitPrompt"}: + if is_bulk_work(prompt): + remember_bulk_prompt(event) + return [] + + if name in {"UserPromptSubmit", "userPromptSubmit"} or (prompt and not _tool_name(event)): + if not is_bulk_work(prompt): + return [] + mark_injected(event) + return [_prompt_finding(prompt)] + + current_state = record_file_mutation(event) + if consume_prompt_pending(event): + pending_prompt = str(current_state.get("cursor_prompt") or "bulk prompt") + return [_prompt_finding(pending_prompt)] + if should_inject_for_edits(event): + return [_edit_finding(event, current_state)] + return [] diff --git a/engine/hooks/build-the-lever/tests/test_hooks.py b/engine/hooks/build-the-lever/tests/test_hooks.py index 4d944a0a..bf473960 100644 --- a/engine/hooks/build-the-lever/tests/test_hooks.py +++ b/engine/hooks/build-the-lever/tests/test_hooks.py @@ -55,8 +55,15 @@ def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() detect.STATE_DIR = self.tmp.name state.STATE_DIR = self.tmp.name + self.metrics_env = patch.dict( + os.environ, + {"CATSTACK_HOOK_METRICS_DIR": self.tmp.name}, + clear=False, + ) + self.metrics_env.start() def tearDown(self) -> None: + self.metrics_env.stop() self.tmp.cleanup() diff --git a/engine/hooks/build-the-lever/tests/test_hooks_sdk_mode.py b/engine/hooks/build-the-lever/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..b7cf997c --- /dev/null +++ b/engine/hooks/build-the-lever/tests/test_hooks_sdk_mode.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +HOOKS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, HOOKS_DIR) + +import claude_prompt_submit # noqa: E402 +import cursor_post_tool_use # noqa: E402 +import detect # noqa: E402 +import state # noqa: E402 + + +BULK_RENAME = "rename this config key across 40 files, and update every call site that reads the old name." + + +def run_main(main, payload: dict[str, object]) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + with patch.object(sys, "stdin", io.StringIO(json.dumps(payload))): + with redirect_stdout(stdout), redirect_stderr(stderr): + try: + main() + except SystemExit as exc: + return int(exc.code or 0), stdout.getvalue(), stderr.getvalue() + return 0, stdout.getvalue(), stderr.getvalue() + + +class HooksSdkModeTest(unittest.TestCase): + def setUp(self) -> None: + self.state_tmp = tempfile.TemporaryDirectory() + self.metrics_tmp = tempfile.TemporaryDirectory() + detect.STATE_DIR = self.state_tmp.name + state.STATE_DIR = self.state_tmp.name + self.env = patch.dict( + os.environ, + {"CATSTACK_HOOK_METRICS_DIR": self.metrics_tmp.name}, + clear=False, + ) + self.env.start() + + def tearDown(self) -> None: + self.env.stop() + self.metrics_tmp.cleanup() + self.state_tmp.cleanup() + + def _fourth_edit_payload(self, session_id: str) -> dict[str, object]: + payload: dict[str, object] = { + "session_id": session_id, + "hook_event_name": "postToolUse", + "tool_name": "Write", + "tool_input": {"path": "d.ts"}, + } + for path in ("a.ts", "b.ts", "c.ts"): + detect.record_file_mutation(payload, path) + return payload + + def test_warn_override_changes_stop_response_to_warning(self) -> None: + with patch.dict(os.environ, {"CATSTACK_HOOK_MODE_BUILD_THE_LEVER": "stop"}): + stop_code, stop_out, stop_err = run_main( + cursor_post_tool_use.main, + self._fourth_edit_payload("sdk-stop"), + ) + with patch.dict(os.environ, {"CATSTACK_HOOK_MODE_BUILD_THE_LEVER": "warn"}): + warn_code, warn_out, warn_err = run_main( + cursor_post_tool_use.main, + self._fourth_edit_payload("sdk-warn"), + ) + + self.assertEqual(0, stop_code) + self.assertEqual("", stop_err) + stopped = json.loads(stop_out) + self.assertFalse(stopped["continue"]) + self.assertEqual("deny", stopped["permission"]) + self.assertIn("build-the-lever", stopped["user_message"]) + self.assertEqual(0, warn_code) + self.assertEqual("", warn_err) + warning = json.loads(warn_out) + self.assertNotIn("permission", warning) + self.assertIn("build-the-lever", warning["additional_context"]) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + payload = { + "prompt": BULK_RENAME, + "session_id": "sdk-events", + "hook_event_name": "UserPromptSubmit", + } + with patch.dict(os.environ, {"CATSTACK_HOOK_MODE_BUILD_THE_LEVER": "warn"}): + code, out, err = run_main(claude_prompt_submit.main, payload) + edit_code, edit_out, edit_err = run_main( + cursor_post_tool_use.main, + self._fourth_edit_payload("sdk-edit-events"), + ) + + today = datetime.now(timezone.utc).date().isoformat() + event_file = Path(self.metrics_tmp.name) / f"events-{today}.jsonl" + self.assertTrue(event_file.exists()) + rows = [json.loads(line) for line in event_file.read_text(encoding="utf-8").splitlines()] + finding_rows = [row for row in rows if row["rule_id"]] + self.assertEqual(0, code) + self.assertEqual("", err) + self.assertIn("build-the-lever", out) + self.assertEqual(0, edit_code) + self.assertEqual("", edit_err) + self.assertIn("build-the-lever", edit_out) + self.assertEqual(2, len(finding_rows)) + self.assertEqual( + ["build-the-lever.bulk-prompt", "build-the-lever.many-file-edits"], + sorted(row["rule_id"] for row in finding_rows), + ) + self.assertTrue(all(row["hook"] == "build-the-lever" for row in finding_rows)) + self.assertTrue(all(row["mode"] == "warn" for row in finding_rows)) + self.assertTrue(all(row["mode_source"] == "override" for row in finding_rows)) + + +if __name__ == "__main__": + unittest.main() From bbebd6ef5b68de335b98871cc3eef38c5dfa605d Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:08:39 +0000 Subject: [PATCH 02/17] =?UTF-8?q?invoker:=20wf-1789406883560-32/verify-hoo?= =?UTF-8?q?k-build-the-lever=20=E2=80=94=20Run=20the=20deterministic=20pro?= =?UTF-8?q?of=20for=20put=20the=20build-the-lever=20hook=20onto=20the=20sh?= =?UTF-8?q?ared=20hook=20code.=20Review=20claim:=20The=20proof=20exits=200?= =?UTF-8?q?=20only=20when=20put=20the=20build-the-lever=20hook=20onto=20th?= =?UTF-8?q?e=20shared=20hook=20code=20holds.=20Review=20lane:=20proof=20Sa?= =?UTF-8?q?fety=20invariant:=20Proof=20only;=20it=20changes=20no=20product?= =?UTF-8?q?=20behavior.=20Effectiveness=20measurement:=20The=20exit=20stat?= =?UTF-8?q?us=20of=20`python3=20-m=20unittest=20discover=20-s=20engine/hoo?= =?UTF-8?q?ks/build-the-lever/tests`=20is=20the=20signal=20for=20this=20sl?= =?UTF-8?q?ice.=20Slice=20rationale:=20One=20proof=20unit=20for=20this=20w?= =?UTF-8?q?orkflow.=20Architectural=20effect:=20None;=20verification=20onl?= =?UTF-8?q?y.=20Goal:=20Prove=20put=20the=20build-the-lever=20hook=20onto?= =?UTF-8?q?=20the=20shared=20hook=20code=20with=20one=20deterministic=20ru?= =?UTF-8?q?n.=20Motivation:=20Each=20workflow=20carries=20its=20own=20proo?= =?UTF-8?q?f=20so=20a=20reviewer=20can=20trust=20the=20slice=20alone.=20Al?= =?UTF-8?q?ternative=20considerations:=20Manual=20inspection=20was=20set?= =?UTF-8?q?=20aside=20as=20non-deterministic.=20Implementation=20details:?= =?UTF-8?q?=20Execute=20the=20proof=20as=20a=20terminal=20gate.=20Non-goal?= =?UTF-8?q?s:=20No=20product=20edits=20here.=20Layer:=20e2e=5Fregression?= =?UTF-8?q?=20Feature=20state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 3fe2349d-7d5a-4cf2-a6ab-f4c370ef2d0f From 5c0096bb425a4a0dc4b20a42b6e53d562304991b Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:09:35 +0000 Subject: [PATCH 03/17] =?UTF-8?q?invoker:=20wf-1789406883560-32/scrub-hand?= =?UTF-8?q?off-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confi?= =?UTF-8?q?rming=20no=20ephemeral=20handoff=20files=20were=20left=20behind?= =?UTF-8?q?.=20Review=20claim:=20The=20workflow=20leaves=20no=20ephemeral?= =?UTF-8?q?=20handoff=20files=20in=20the=20tree.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Read-only;=20it=20never=20deletes=20fi?= =?UTF-8?q?les,=20alters=20the=20index,=20or=20commits=20caller=20work.=20?= =?UTF-8?q?Effectiveness=20measurement:=20A=20non-zero=20exit=20when=20eph?= =?UTF-8?q?emeral=20handoff=20files=20remain=20is=20the=20signal.=20Slice?= =?UTF-8?q?=20rationale:=20One=20unit:=20the=20hygiene=20gate.=20Architect?= =?UTF-8?q?ural=20effect:=20None.=20Goal:=20Confirm=20no=20ephemeral=20han?= =?UTF-8?q?doff=20files=20remain=20after=20every=20other=20task=20finishes?= =?UTF-8?q?.=20Motivation:=20Ephemeral=20inter-task=20files=20leak=20into?= =?UTF-8?q?=20the=20diff=20and=20read=20as=20part=20of=20the=20change.=20A?= =?UTF-8?q?lternative=20considerations:=20Manual=20inspection=20was=20set?= =?UTF-8?q?=20aside=20as=20non-deterministic.=20Implementation=20details:?= =?UTF-8?q?=20Run=20scripts/scrub-handoff-artifacts.sh=20in=20check=20mode?= =?UTF-8?q?.=20Non-goals:=20No=20deletion,=20no=20index=20changes,=20no=20?= =?UTF-8?q?commits.=20Layer:=20e2e=5Fregression=20Feature=20state:=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 70147de4-ee65-46de-90da-48ab84cce9d0 From 77573965bbf298f61894a06f986dcf4cc8b2fea0 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:34:35 +0000 Subject: [PATCH 04/17] =?UTF-8?q?invoker:=20wf-1789406886887-33/implement-?= =?UTF-8?q?hook-cat-mode-default=20=E2=80=94=20Put=20the=20cat-mode-defaul?= =?UTF-8?q?t=20hook=20onto=20the=20shared=20hook=20code.=20Review=20claim:?= =?UTF-8?q?=20This=20hook=20reports=20findings=20to=20the=20shared=20hook?= =?UTF-8?q?=20code,=20which=20applies=20its=20registry=20mode=20and=20writ?= =?UTF-8?q?es=20event=20rows.=20It=20keeps=20mode=20warn.=20Review=20lane:?= =?UTF-8?q?=20behavior=20Safety=20invariant:=20The=20hook=20gives=20the=20?= =?UTF-8?q?same=20stop,=20warn,=20or=20silent=20result=20on=20every=20case?= =?UTF-8?q?=20in=20its=20current=20test=20folder,=20except=20the=20mode=20?= =?UTF-8?q?change=20named=20in=20this=20claim,=20and=20its=20test=20folder?= =?UTF-8?q?=20keeps=20exiting=200.=20Effectiveness=20measurement:=20`pytho?= =?UTF-8?q?n3=20-m=20unittest=20discover=20-s=20engine/hooks/cat-mode-defa?= =?UTF-8?q?ult/tests`=20exits=200,=20and=20the=20new=20mode-override=20cas?= =?UTF-8?q?e=20fails=20before=20this=20change.=20Slice=20rationale:=20One?= =?UTF-8?q?=20hook=20per=20workflow,=20as=20the=20user=20asked,=20so=20eac?= =?UTF-8?q?h=20migration=20is=20reviewed=20on=20its=20own.=20Architectural?= =?UTF-8?q?=20effect:=20The=20cat-mode-default=20entry=20scripts=20become?= =?UTF-8?q?=20thin=20calls=20into=20the=20shared=20runtime;=20its=20detect?= =?UTF-8?q?ion=20returns=20findings.=20Goal:=20Applies=20the=20user's=20wo?= =?UTF-8?q?rking=20style=20each=20turn.=20Keep=20that=20behavior=20while?= =?UTF-8?q?=20its=20mode=20moves=20into=20the=20registry.=20Motivation:=20?= =?UTF-8?q?Mode=20and=20output=20shape=20live=20inside=20each=20hook=20tod?= =?UTF-8?q?ay;=20the=20shared=20code=20makes=20a=20mode=20change=20a=20one?= =?UTF-8?q?-line=20registry=20edit.=20Alternative=20considerations:=20Migr?= =?UTF-8?q?ating=20several=20hooks=20per=20workflow=20was=20set=20aside=20?= =?UTF-8?q?because=20the=20user=20asked=20for=20one=20hook=20per=20workflo?= =?UTF-8?q?w.=20Implementation=20details:=20Turn=20this=20hook's=20detecti?= =?UTF-8?q?on=20into=20detect(event)=20returning=20Finding=20objects=20wit?= =?UTF-8?q?h=20stable=20rule=20ids,=20and=20make=20each=20harness=20entry?= =?UTF-8?q?=20script=20call=20run=5Fhook=20from=20engine/hooks/=5Fsdk/runt?= =?UTF-8?q?ime.py.=20It=20keeps=20mode=20warn.=20Non-goals:=20No=20change?= =?UTF-8?q?=20to=20what=20the=20hook=20detects.=20No=20other=20hook=20chan?= =?UTF-8?q?ges.=20Layer:=20domain=20Feature=20state:=20active=20Files:=20e?= =?UTF-8?q?ngine/hooks/cat-mode-default/claude=5Fpretooluse=5Fagent.py,=20?= =?UTF-8?q?engine/hooks/cat-mode-default/claude=5Fprompt=5Fsubmit.py,=20en?= =?UTF-8?q?gine/hooks/cat-mode-default/detect.py,=20engine/hooks/cat-mode-?= =?UTF-8?q?default/install=5Fclaude=5Fhook.py,=20engine/hooks/cat-mode-def?= =?UTF-8?q?ault/tests/test=5Fhooks=5Fsdk=5Fmode.py=20Change=20types:=20-?= =?UTF-8?q?=20engine/hooks/cat-mode-default/claude=5Fpretooluse=5Fagent.py?= =?UTF-8?q?:=20modify=20-=20engine/hooks/cat-mode-default/claude=5Fprompt?= =?UTF-8?q?=5Fsubmit.py:=20modify=20-=20engine/hooks/cat-mode-default/dete?= =?UTF-8?q?ct.py:=20modify=20-=20engine/hooks/cat-mode-default/install=5Fc?= =?UTF-8?q?laude=5Fhook.py:=20modify=20-=20engine/hooks/cat-mode-default/t?= =?UTF-8?q?ests/test=5Fhooks=5Fsdk=5Fmode.py:=20create=20Acceptance=20crit?= =?UTF-8?q?eria:=20-=20`python3=20-m=20unittest=20discover=20-s=20engine/h?= =?UTF-8?q?ooks/cat-mode-default/tests`=20exits=200.=20-=20With=20CATSTACK?= =?UTF-8?q?=5FHOOK=5FMODE=5FCAT=5FMODE=5FDEFAULT=20set=20to=20warn,=20a=20?= =?UTF-8?q?case=20that=20stops=20today=20produces=20a=20warning=20instead,?= =?UTF-8?q?=20proving=20the=20registry=20mode=20drives=20the=20response.?= =?UTF-8?q?=20-=20Each=20finding=20writes=20one=20event=20row=20with=20the?= =?UTF-8?q?=20hook's=20rule=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Put the cat-mode-default hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode warn. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/cat-mode-default/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The cat-mode-default entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Applies the user's working style each turn. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode warn. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/cat-mode-default/claude_pretooluse_agent.py, engine/hooks/cat-mode-default/claude_prompt_submit.py, engine/hooks/cat-mode-default/detect.py, engine/hooks/cat-mode-default/install_claude_hook.py, engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/cat-mode-default/claude_pretooluse_agent.py: modify - engine/hooks/cat-mode-default/claude_prompt_submit.py: modify - engine/hooks/cat-mode-default/detect.py: modify - engine/hooks/cat-mode-default/install_claude_hook.py: modify - engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/cat-mode-default/tests` exits 0. - With CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: a0c6917e-a7c5-46f3-a6a1-b370f2d14108 --- engine/hooks/_sdk/finding.py | 2 + engine/hooks/_sdk/render.py | 25 +++- .../claude_pretooluse_agent.py | 37 ++---- .../cat-mode-default/claude_prompt_submit.py | 32 ++--- engine/hooks/cat-mode-default/detect.py | 70 ++++++++++ .../cat-mode-default/tests/test_agent_hook.py | 17 ++- .../cat-mode-default/tests/test_hooks.py | 23 +++- .../tests/test_hooks_sdk_mode.py | 122 ++++++++++++++++++ 8 files changed, 264 insertions(+), 64 deletions(-) create mode 100644 engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/_sdk/finding.py b/engine/hooks/_sdk/finding.py index f3f3c957..247dbe40 100644 --- a/engine/hooks/_sdk/finding.py +++ b/engine/hooks/_sdk/finding.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any @dataclass(frozen=True) @@ -9,3 +10,4 @@ class Finding: subject: str message: str evidence: str + output: dict[str, Any] | None = None diff --git a/engine/hooks/_sdk/render.py b/engine/hooks/_sdk/render.py index 24eac7d8..13ad4a71 100644 --- a/engine/hooks/_sdk/render.py +++ b/engine/hooks/_sdk/render.py @@ -21,7 +21,7 @@ def render( message = _message(findings) if harness == "claude": - return _render_claude(hook_event_name, mode, message) + return _render_claude(hook_event_name, mode, message, findings) if harness == "cursor": return _render_cursor(mode, message) if harness == "codex": @@ -29,9 +29,22 @@ def render( raise ValueError(f"unknown hook harness {harness!r}") -def _render_claude(hook_event_name: str, mode: str, message: str) -> tuple[str, str, int]: +def _render_claude( + hook_event_name: str, + mode: str, + message: str, + findings: Sequence[Finding], +) -> tuple[str, str, int]: if mode == "stop" and hook_event_name in {"Stop", "PreToolUse"}: return "", message + "\n", 2 + updated_input = _updated_input(findings) + if hook_event_name == "PreToolUse" and updated_input is not None: + return _json({ + "hookSpecificOutput": { + "hookEventName": hook_event_name, + "updatedInput": updated_input, + } + }), "", 0 return _json({ "hookSpecificOutput": { "hookEventName": hook_event_name, @@ -76,5 +89,13 @@ def _message(findings: Sequence[Finding]) -> str: return "\n".join(finding.message for finding in findings) +def _updated_input(findings: Sequence[Finding]) -> dict[str, object] | None: + for finding in findings: + output = finding.output + if isinstance(output, dict) and isinstance(output.get("updatedInput"), dict): + return output["updatedInput"] + return None + + def _json(value: dict[str, object]) -> str: return json.dumps(value) + "\n" diff --git a/engine/hooks/cat-mode-default/claude_pretooluse_agent.py b/engine/hooks/cat-mode-default/claude_pretooluse_agent.py index 28731a8d..5a4b12b5 100644 --- a/engine/hooks/cat-mode-default/claude_pretooluse_agent.py +++ b/engine/hooks/cat-mode-default/claude_pretooluse_agent.py @@ -1,40 +1,19 @@ #!/usr/bin/env python3 -"""Claude Code PreToolUse (Agent): carry the cat-mode default into subagents. - -A subagent's prompt arrives through the Agent tool, so the UserPromptSubmit -hook next to this file never sees it. When CATSTACK_CAT_MODE_DEFAULT -resolves to on and the prompt does not already mention cat-mode, this -returns `hookSpecificOutput.updatedInput` with the same tool_input and the -prompt prefixed by one line naming the installed SKILL.md. - -Fail-open. No LLM. Never denies or asks; it only rewrites the prompt. -""" +"""Claude Code PreToolUse (Agent) entrypoint for cat-mode-default.""" from __future__ import annotations -import json +import os import sys -from detect import agent_updated_input +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - return - try: - updated = agent_updated_input(payload if isinstance(payload, dict) else {}) - except Exception as exc: - print(f"catstack-hook-error cat-mode-default: {type(exc).__name__}: {exc}", file=sys.stderr) - return - if updated is None: - return - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "updatedInput": updated, - } - })) + run_hook("cat-mode-default", "claude", detect, "PreToolUse") if __name__ == "__main__": diff --git a/engine/hooks/cat-mode-default/claude_prompt_submit.py b/engine/hooks/cat-mode-default/claude_prompt_submit.py index ed0911d4..1a5b5ac9 100644 --- a/engine/hooks/cat-mode-default/claude_prompt_submit.py +++ b/engine/hooks/cat-mode-default/claude_prompt_submit.py @@ -1,35 +1,19 @@ #!/usr/bin/env python3 -"""Claude Code UserPromptSubmit: inject the cat-mode default context. - -Fail-open. No LLM. Never denies. Silent unless CATSTACK_CAT_MODE_DEFAULT -resolves to on and the prompt does not contain a typed /cat-mode. -""" +"""Claude Code UserPromptSubmit entrypoint for cat-mode-default.""" from __future__ import annotations -import json +import os import sys -from detect import decide +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - return - try: - context = decide(payload if isinstance(payload, dict) else {}) - except Exception as exc: - print(f"catstack-hook-error cat-mode-default: {type(exc).__name__}: {exc}", file=sys.stderr) - return - if context is None: - return - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": context, - } - })) + run_hook("cat-mode-default", "claude", detect, "UserPromptSubmit") if __name__ == "__main__": diff --git a/engine/hooks/cat-mode-default/detect.py b/engine/hooks/cat-mode-default/detect.py index e921529f..cc7451cd 100644 --- a/engine/hooks/cat-mode-default/detect.py +++ b/engine/hooks/cat-mode-default/detect.py @@ -22,11 +22,19 @@ import os import re +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 FLAG = "CATSTACK_CAT_MODE_DEFAULT" ENV_FILE_VAR = "CATSTACK_ENV_FILE" HOME_ENV_FILE = "~/.catstack.env" SKILL_RELPATH = os.path.join(".claude", "skills", "cat-mode", "SKILL.md") +RULE_PROMPT = "cat-mode-default.prompt" +RULE_AGENT_PROMPT = "cat-mode-default.agent-prompt" TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) CAT_MODE_COMMAND_RE = re.compile(r"(?:^|\s)/cat-mode\b") @@ -193,3 +201,65 @@ def agent_updated_input(payload: dict, environ: dict | None = None, home: str | updated = dict(tool_input) updated["prompt"] = agent_prefix_line(installed_skill_path(home)) + "\n\n" + prompt return updated + + +def detect(event: dict) -> list[Finding]: + """Return findings for the shared hook runtime.""" + if not isinstance(event, dict): + return [] + if event.get("hook_event_name") == "PreToolUse" or event.get("tool_name") in AGENT_TOOL_NAMES: + return _agent_findings(event) + return _prompt_findings(event) + + +def _prompt_findings(event: dict) -> list[Finding]: + context = decide(event) + if context is None: + return [] + prompt = extract_prompt_text(event) + return [ + Finding( + rule_id=RULE_PROMPT, + subject=_prompt_subject(prompt), + message=context, + evidence=prompt, + ) + ] + + +def _agent_findings(event: dict) -> list[Finding]: + updated = agent_updated_input(event) + if updated is None: + return [] + tool_input = event.get("tool_input") + original_prompt = "" + if isinstance(tool_input, dict) and isinstance(tool_input.get("prompt"), str): + original_prompt = tool_input["prompt"] + first_line = str(updated.get("prompt") or "").splitlines()[0] + return [ + Finding( + rule_id=RULE_AGENT_PROMPT, + subject=_agent_subject(event, original_prompt), + message=first_line, + evidence=original_prompt, + output={"updatedInput": updated}, + ) + ] + + +def _prompt_subject(prompt: str) -> str: + return f"prompt:{_hash_text(prompt)}" + + +def _agent_subject(event: dict, prompt: str) -> str: + for key in ("tool_call_id", "toolCallId", "id"): + value = event.get(key) + if isinstance(value, str) and value: + return f"tool-call:{value}" + return f"agent-prompt:{_hash_text(prompt)}" + + +def _hash_text(text: str) -> str: + import hashlib + + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] diff --git a/engine/hooks/cat-mode-default/tests/test_agent_hook.py b/engine/hooks/cat-mode-default/tests/test_agent_hook.py index ac540d10..c87cfafd 100644 --- a/engine/hooks/cat-mode-default/tests/test_agent_hook.py +++ b/engine/hooks/cat-mode-default/tests/test_agent_hook.py @@ -41,7 +41,10 @@ def run_entrypoint(payload: dict, environ: dict, home: str) -> str: with patch.dict(os.environ, environ, clear=True): with patch.object(os.path, "expanduser", lambda p: p.replace("~", home, 1)): with redirect_stdout(out), redirect_stderr(err): - claude_pretooluse_agent.main() + try: + claude_pretooluse_agent.main() + except SystemExit: + pass return out.getvalue() @@ -151,16 +154,22 @@ def test_malformed_stdin_prints_nothing(self) -> None: out = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not json")): with redirect_stdout(out): - claude_pretooluse_agent.main() + try: + claude_pretooluse_agent.main() + except SystemExit: + pass self.assertEqual(out.getvalue(), "") def test_detect_exception_prints_nothing(self) -> None: out = io.StringIO() payload = {"tool_name": "Agent", "tool_input": {"prompt": "x"}} with patch.object(sys, "stdin", io.StringIO(json.dumps(payload))): - with patch.object(claude_pretooluse_agent, "agent_updated_input", side_effect=RuntimeError("boom")): + with patch.object(claude_pretooluse_agent, "detect", side_effect=RuntimeError("boom")): with redirect_stdout(out): - claude_pretooluse_agent.main() + try: + claude_pretooluse_agent.main() + except SystemExit: + pass self.assertEqual(out.getvalue(), "") diff --git a/engine/hooks/cat-mode-default/tests/test_hooks.py b/engine/hooks/cat-mode-default/tests/test_hooks.py index 97b0ff69..0621b007 100644 --- a/engine/hooks/cat-mode-default/tests/test_hooks.py +++ b/engine/hooks/cat-mode-default/tests/test_hooks.py @@ -66,7 +66,11 @@ def write_home_env(self, text: str) -> str: return path def environ(self, extra: dict | None = None) -> dict: - env = {"HOME": self.home, "PATH": os.environ.get("PATH", "")} + env = { + "HOME": self.home, + "PATH": os.environ.get("PATH", ""), + "CATSTACK_HOOK_METRICS_DIR": os.path.join(self.tmp.name, "metrics"), + } env.update(extra or {}) return env @@ -81,7 +85,10 @@ def run_entrypoint(payload: dict, environ: dict, home: str) -> str: with patch.dict(os.environ, environ, clear=True): with patch.object(os.path, "expanduser", lambda p: p.replace("~", home, 1)): with redirect_stdout(out), redirect_stderr(err): - claude_prompt_submit.main() + try: + claude_prompt_submit.main() + except SystemExit: + pass return out.getvalue() @@ -257,15 +264,21 @@ def test_malformed_stdin_prints_nothing(self) -> None: out = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not json")): with redirect_stdout(out): - claude_prompt_submit.main() + try: + claude_prompt_submit.main() + except SystemExit: + pass self.assertEqual(out.getvalue(), "") def test_detect_exception_prints_nothing(self) -> None: out = io.StringIO() with patch.object(sys, "stdin", io.StringIO(json.dumps({"prompt": REAL_PROMPT}))): - with patch.object(claude_prompt_submit, "decide", side_effect=RuntimeError("boom")): + with patch.object(claude_prompt_submit, "detect", side_effect=RuntimeError("boom")): with redirect_stdout(out): - claude_prompt_submit.main() + try: + claude_prompt_submit.main() + except SystemExit: + pass self.assertEqual(out.getvalue(), "") diff --git a/engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py b/engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..266cebaa --- /dev/null +++ b/engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +from io import StringIO +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + + +HOOK_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(HOOK_DIR)) + +import claude_pretooluse_agent # noqa: E402 +import claude_prompt_submit # noqa: E402 +import detect # noqa: E402 +from test_hooks import Sandbox, REAL_PROMPT # noqa: E402 + + +def run_main(main, payload: dict[str, object], env: dict[str, str]) -> tuple[int, str, str]: + stdout = StringIO() + stderr = StringIO() + with patch.object(sys, "stdin", StringIO(json.dumps(payload))): + with patch.dict(os.environ, env, clear=True): + with redirect_stdout(stdout), redirect_stderr(stderr): + try: + main() + except SystemExit as exc: + return int(exc.code or 0), stdout.getvalue(), stderr.getvalue() + return 0, stdout.getvalue(), stderr.getvalue() + + +def event_rows(directory: Path) -> list[dict[str, object]]: + today = datetime.now(timezone.utc).date().isoformat() + path = directory / f"events-{today}.jsonl" + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +class CatModeDefaultSdkModeTest(unittest.TestCase): + def setUp(self) -> None: + self.box = Sandbox() + self.metrics_tmp = tempfile.TemporaryDirectory() + + def tearDown(self) -> None: + self.metrics_tmp.cleanup() + self.box.cleanup() + + def env(self, **updates: str) -> dict[str, str]: + env = self.box.environ({ + detect.FLAG: "1", + "CATSTACK_HOOK_METRICS_DIR": self.metrics_tmp.name, + }) + env.update(updates) + return env + + def agent_payload(self, session_id: str) -> dict[str, object]: + return { + "hook_event_name": "PreToolUse", + "session_id": session_id, + "tool_name": "Agent", + "cwd": self.box.cwd, + "tool_input": { + "description": "Apply a task", + "prompt": "Investigate the failing test.", + }, + } + + def test_warn_override_changes_stop_response_to_warning(self) -> None: + stop_code, stop_out, stop_err = run_main( + claude_pretooluse_agent.main, + self.agent_payload("cat-mode-stop"), + self.env(CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT="stop"), + ) + warn_code, warn_out, warn_err = run_main( + claude_pretooluse_agent.main, + self.agent_payload("cat-mode-warn"), + self.env(CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT="warn"), + ) + + self.assertEqual(2, stop_code) + self.assertEqual("", stop_out) + self.assertIn("cat-mode default is on", stop_err) + self.assertEqual(0, warn_code) + self.assertEqual("", warn_err) + warning = json.loads(warn_out) + output = warning["hookSpecificOutput"] + self.assertEqual("PreToolUse", output["hookEventName"]) + self.assertIn("updatedInput", output) + self.assertTrue(output["updatedInput"]["prompt"].startswith("cat-mode default is on: read and apply ")) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + payload = { + "hook_event_name": "UserPromptSubmit", + "session_id": "cat-mode-events", + "prompt": REAL_PROMPT, + "cwd": self.box.cwd, + } + code, out, err = run_main( + claude_prompt_submit.main, + payload, + self.env(CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT="warn"), + ) + rows = event_rows(Path(self.metrics_tmp.name)) + + finding_rows = [row for row in rows if row["rule_id"]] + self.assertEqual(0, code) + self.assertEqual("", err) + self.assertIn("cat-mode default is on", out) + self.assertEqual(1, len(finding_rows)) + self.assertEqual("cat-mode-default.prompt", finding_rows[0]["rule_id"]) + self.assertEqual("cat-mode-default", finding_rows[0]["hook"]) + self.assertEqual("warn", finding_rows[0]["mode"]) + self.assertEqual("override", finding_rows[0]["mode_source"]) + + +if __name__ == "__main__": + unittest.main() From 13ec88c57308f65afb62deb2877b07efd9916983 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:35:25 +0000 Subject: [PATCH 05/17] =?UTF-8?q?invoker:=20wf-1789406886887-33/verify-hoo?= =?UTF-8?q?k-cat-mode-default=20=E2=80=94=20Run=20the=20deterministic=20pr?= =?UTF-8?q?oof=20for=20put=20the=20cat-mode-default=20hook=20onto=20the=20?= =?UTF-8?q?shared=20hook=20code.=20Review=20claim:=20The=20proof=20exits?= =?UTF-8?q?=200=20only=20when=20put=20the=20cat-mode-default=20hook=20onto?= =?UTF-8?q?=20the=20shared=20hook=20code=20holds.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Proof=20only;=20it=20changes=20no=20pr?= =?UTF-8?q?oduct=20behavior.=20Effectiveness=20measurement:=20The=20exit?= =?UTF-8?q?=20status=20of=20`python3=20-m=20unittest=20discover=20-s=20eng?= =?UTF-8?q?ine/hooks/cat-mode-default/tests`=20is=20the=20signal=20for=20t?= =?UTF-8?q?his=20slice.=20Slice=20rationale:=20One=20proof=20unit=20for=20?= =?UTF-8?q?this=20workflow.=20Architectural=20effect:=20None;=20verificati?= =?UTF-8?q?on=20only.=20Goal:=20Prove=20put=20the=20cat-mode-default=20hoo?= =?UTF-8?q?k=20onto=20the=20shared=20hook=20code=20with=20one=20determinis?= =?UTF-8?q?tic=20run.=20Motivation:=20Each=20workflow=20carries=20its=20ow?= =?UTF-8?q?n=20proof=20so=20a=20reviewer=20can=20trust=20the=20slice=20alo?= =?UTF-8?q?ne.=20Alternative=20considerations:=20Manual=20inspection=20was?= =?UTF-8?q?=20set=20aside=20as=20non-deterministic.=20Implementation=20det?= =?UTF-8?q?ails:=20Execute=20the=20proof=20as=20a=20terminal=20gate.=20Non?= =?UTF-8?q?-goals:=20No=20product=20edits=20here.=20Layer:=20e2e=5Fregress?= =?UTF-8?q?ion=20Feature=20state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 8132f734-9bf7-4d89-8c3c-56bc4b969d79 From 163d9e86d61906cb99ff875342fcbadd715b58e3 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:36:09 +0000 Subject: [PATCH 06/17] =?UTF-8?q?invoker:=20wf-1789406886887-33/scrub-hand?= =?UTF-8?q?off-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confi?= =?UTF-8?q?rming=20no=20ephemeral=20handoff=20files=20were=20left=20behind?= =?UTF-8?q?.=20Review=20claim:=20The=20workflow=20leaves=20no=20ephemeral?= =?UTF-8?q?=20handoff=20files=20in=20the=20tree.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Read-only;=20it=20never=20deletes=20fi?= =?UTF-8?q?les,=20alters=20the=20index,=20or=20commits=20caller=20work.=20?= =?UTF-8?q?Effectiveness=20measurement:=20A=20non-zero=20exit=20when=20eph?= =?UTF-8?q?emeral=20handoff=20files=20remain=20is=20the=20signal.=20Slice?= =?UTF-8?q?=20rationale:=20One=20unit:=20the=20hygiene=20gate.=20Architect?= =?UTF-8?q?ural=20effect:=20None.=20Goal:=20Confirm=20no=20ephemeral=20han?= =?UTF-8?q?doff=20files=20remain=20after=20every=20other=20task=20finishes?= =?UTF-8?q?.=20Motivation:=20Ephemeral=20inter-task=20files=20leak=20into?= =?UTF-8?q?=20the=20diff=20and=20read=20as=20part=20of=20the=20change.=20A?= =?UTF-8?q?lternative=20considerations:=20Manual=20inspection=20was=20set?= =?UTF-8?q?=20aside=20as=20non-deterministic.=20Implementation=20details:?= =?UTF-8?q?=20Run=20scripts/scrub-handoff-artifacts.sh=20in=20check=20mode?= =?UTF-8?q?.=20Non-goals:=20No=20deletion,=20no=20index=20changes,=20no=20?= =?UTF-8?q?commits.=20Layer:=20e2e=5Fregression=20Feature=20state:=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 74c962cc-f188-4db3-be0c-c3da68c20e9a From 49b348f0f903b21b699398e4678d33ce98fbeb62 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:58:55 +0000 Subject: [PATCH 07/17] =?UTF-8?q?invoker:=20wf-1789406891093-34/implement-?= =?UTF-8?q?hook-categorical-scope-guard=20=E2=80=94=20Put=20the=20categori?= =?UTF-8?q?cal-scope-guard=20hook=20onto=20the=20shared=20hook=20code.=20R?= =?UTF-8?q?eview=20claim:=20This=20hook=20reports=20findings=20to=20the=20?= =?UTF-8?q?shared=20hook=20code,=20which=20applies=20its=20registry=20mode?= =?UTF-8?q?=20and=20writes=20event=20rows.=20It=20keeps=20mode=20stop.=20R?= =?UTF-8?q?eview=20lane:=20behavior=20Safety=20invariant:=20The=20hook=20g?= =?UTF-8?q?ives=20the=20same=20stop,=20warn,=20or=20silent=20result=20on?= =?UTF-8?q?=20every=20case=20in=20its=20current=20test=20folder,=20except?= =?UTF-8?q?=20the=20mode=20change=20named=20in=20this=20claim,=20and=20its?= =?UTF-8?q?=20test=20folder=20keeps=20exiting=200.=20Effectiveness=20measu?= =?UTF-8?q?rement:=20`python3=20-m=20unittest=20discover=20-s=20engine/hoo?= =?UTF-8?q?ks/categorical-scope-guard/tests`=20exits=200,=20and=20the=20ne?= =?UTF-8?q?w=20mode-override=20case=20fails=20before=20this=20change.=20Sl?= =?UTF-8?q?ice=20rationale:=20One=20hook=20per=20workflow,=20as=20the=20us?= =?UTF-8?q?er=20asked,=20so=20each=20migration=20is=20reviewed=20on=20its?= =?UTF-8?q?=20own.=20Architectural=20effect:=20The=20categorical-scope-gua?= =?UTF-8?q?rd=20entry=20scripts=20become=20thin=20calls=20into=20the=20sha?= =?UTF-8?q?red=20runtime;=20its=20detection=20returns=20findings.=20Goal:?= =?UTF-8?q?=20Stops=20a=20change=20that=20covers=20only=20part=20of=20an?= =?UTF-8?q?=20all=20request.=20Keep=20that=20behavior=20while=20its=20mode?= =?UTF-8?q?=20moves=20into=20the=20registry.=20Motivation:=20Mode=20and=20?= =?UTF-8?q?output=20shape=20live=20inside=20each=20hook=20today;=20the=20s?= =?UTF-8?q?hared=20code=20makes=20a=20mode=20change=20a=20one-line=20regis?= =?UTF-8?q?try=20edit.=20Alternative=20considerations:=20Migrating=20sever?= =?UTF-8?q?al=20hooks=20per=20workflow=20was=20set=20aside=20because=20the?= =?UTF-8?q?=20user=20asked=20for=20one=20hook=20per=20workflow.=20Implemen?= =?UTF-8?q?tation=20details:=20Turn=20this=20hook's=20detection=20into=20d?= =?UTF-8?q?etect(event)=20returning=20Finding=20objects=20with=20stable=20?= =?UTF-8?q?rule=20ids,=20and=20make=20each=20harness=20entry=20script=20ca?= =?UTF-8?q?ll=20run=5Fhook=20from=20engine/hooks/=5Fsdk/runtime.py.=20It?= =?UTF-8?q?=20keeps=20mode=20stop.=20Non-goals:=20No=20change=20to=20what?= =?UTF-8?q?=20the=20hook=20detects.=20No=20other=20hook=20changes.=20Layer?= =?UTF-8?q?:=20domain=20Feature=20state:=20active=20Files:=20engine/hooks/?= =?UTF-8?q?categorical-scope-guard/claude=5Fpretooluse.py,=20engine/hooks/?= =?UTF-8?q?categorical-scope-guard/detect.py,=20engine/hooks/categorical-s?= =?UTF-8?q?cope-guard/install=5Fclaude=5Fhook.py,=20engine/hooks/categoric?= =?UTF-8?q?al-scope-guard/tests/test=5Fhooks=5Fsdk=5Fmode.py=20Change=20ty?= =?UTF-8?q?pes:=20-=20engine/hooks/categorical-scope-guard/claude=5Fpretoo?= =?UTF-8?q?luse.py:=20modify=20-=20engine/hooks/categorical-scope-guard/de?= =?UTF-8?q?tect.py:=20modify=20-=20engine/hooks/categorical-scope-guard/in?= =?UTF-8?q?stall=5Fclaude=5Fhook.py:=20modify=20-=20engine/hooks/categoric?= =?UTF-8?q?al-scope-guard/tests/test=5Fhooks=5Fsdk=5Fmode.py:=20create=20A?= =?UTF-8?q?cceptance=20criteria:=20-=20`python3=20-m=20unittest=20discover?= =?UTF-8?q?=20-s=20engine/hooks/categorical-scope-guard/tests`=20exits=200?= =?UTF-8?q?.=20-=20With=20CATSTACK=5FHOOK=5FMODE=5FCATEGORICAL=5FSCOPE=5FG?= =?UTF-8?q?UARD=20set=20to=20warn,=20a=20case=20that=20stops=20today=20pro?= =?UTF-8?q?duces=20a=20warning=20instead,=20proving=20the=20registry=20mod?= =?UTF-8?q?e=20drives=20the=20response.=20-=20Each=20finding=20writes=20on?= =?UTF-8?q?e=20event=20row=20with=20the=20hook's=20rule=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Put the categorical-scope-guard hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The categorical-scope-guard entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a change that covers only part of an all request. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/categorical-scope-guard/claude_pretooluse.py, engine/hooks/categorical-scope-guard/detect.py, engine/hooks/categorical-scope-guard/install_claude_hook.py, engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/categorical-scope-guard/claude_pretooluse.py: modify - engine/hooks/categorical-scope-guard/detect.py: modify - engine/hooks/categorical-scope-guard/install_claude_hook.py: modify - engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0. - With CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: 1540cec2-0149-477b-b36f-03c02bb58574 --- engine/hooks/_sdk/runtime.py | 2 +- .../hooks/categorical-scope-guard/README.md | 13 ++- .../claude_pretooluse.py | 48 +------- .../codex_pretooluse.py | 20 ++++ .../cursor_pretooluse.py | 20 ++++ .../hooks/categorical-scope-guard/detect.py | 60 ++++++++++ .../tests/test_hooks_sdk_mode.py | 105 ++++++++++++++++++ 7 files changed, 221 insertions(+), 47 deletions(-) create mode 100644 engine/hooks/categorical-scope-guard/codex_pretooluse.py create mode 100644 engine/hooks/categorical-scope-guard/cursor_pretooluse.py create mode 100644 engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/_sdk/runtime.py b/engine/hooks/_sdk/runtime.py index d1ea4351..2ded39d9 100644 --- a/engine/hooks/_sdk/runtime.py +++ b/engine/hooks/_sdk/runtime.py @@ -25,7 +25,7 @@ def run_hook( event = json.load(sys.stdin) except json.JSONDecodeError as exc: _write_findings_file([]) - print(f"catstack-hook-error {hook}: JSONDecodeError: {exc}", file=sys.stderr) + print(f"catstack-hook-error {hook}: hook payload is not JSON: {exc}", file=sys.stderr) stdout_text, _stderr_text, _exit_code = render( harness, hook_event_name or "", diff --git a/engine/hooks/categorical-scope-guard/README.md b/engine/hooks/categorical-scope-guard/README.md index 3eb5b2dc..2aabdff3 100644 --- a/engine/hooks/categorical-scope-guard/README.md +++ b/engine/hooks/categorical-scope-guard/README.md @@ -91,9 +91,12 @@ Do one of two things: ## Three outcomes, and the fail direction -- **HIT** — exit 2 with the message above. +- **HIT** — finding `categorical-scope-guard.partial-status-filter`. The + registry default is `stop`, so the shared runtime exits 2 with the + message above unless a local mode override lowers it to a warning. - **CLEAN** — exit 0. -- **UNCHECKED** — exit 2. **This hook fails closed.** It blocks, and says +- **UNCHECKED** — finding `categorical-scope-guard.unchecked`. **This hook + fails closed.** With the default `stop` mode it blocks, and says `UNCHECKED`, when: - the transcript path is absent, the file is missing, a line is malformed JSON (a torn final line is tolerated), the window runs past the 64 MB @@ -132,8 +135,10 @@ reason and let the user answer. ## Files -- `detect.py` — command parser, human-turn reader, `decide()`. -- `claude_pretooluse.py` — the entrypoint. +- `detect.py` — command parser, human-turn reader, `decide()`, and + `detect(event)`. +- `claude_pretooluse.py`, `cursor_pretooluse.py`, `codex_pretooluse.py` — + thin runtime entrypoints. - `claude.hook.json`, `install_claude_hook.py` — the settings merge that `install.sh` runs. - `tests/test_hooks.py`, `tests/fixtures/` — the fixtures are real commands diff --git a/engine/hooks/categorical-scope-guard/claude_pretooluse.py b/engine/hooks/categorical-scope-guard/claude_pretooluse.py index 673c734c..a3724044 100644 --- a/engine/hooks/categorical-scope-guard/claude_pretooluse.py +++ b/engine/hooks/categorical-scope-guard/claude_pretooluse.py @@ -1,55 +1,19 @@ #!/usr/bin/env python3 -"""Claude PreToolUse on Bash: block a status-narrowed mutation of a target the -live human turn quantified with all / every / each. Exits 2 with the reason on -stderr for HIT and for UNCHECKED; exits 0 for CLEAN. -""" +"""Claude Code PreToolUse entrypoint for categorical-scope-guard.""" from __future__ import annotations -import json import os import sys -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) -from detect import CLEAN, decide_payload - -SHELL_LIKE_TOOL_NAMES = ( - "Bash", "bash", "shell", "Shell", "exec", "exec_command", - "run_terminal_cmd", "local_shell", "run_command", "shell_call", -) - - -def _tool_name(payload: dict) -> str: - return str( - payload.get("tool_name") - or payload.get("toolName") - or payload.get("tool") - or payload.get("name") - or "" - ) +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - raw = sys.stdin.read() - try: - payload = json.loads(raw) - except (json.JSONDecodeError, OSError) as exc: - sys.stderr.write(f"categorical-scope-guard: hook payload is not JSON, nothing to classify: {exc}\n") - return - if not isinstance(payload, dict) or _tool_name(payload) not in SHELL_LIKE_TOOL_NAMES: - return - try: - verdict = decide_payload(payload) - except Exception as exc: - sys.stderr.write( - f"categorical-scope-guard: UNCHECKED -- the detector failed ({exc!r}) while classifying a " - "status-filtered mutation. Blocked rather than passed; drop the status filter or rephrase the command.\n" - ) - sys.exit(2) - if verdict.outcome == CLEAN: - return - sys.stderr.write(verdict.message + "\n") - sys.exit(2) + run_hook("categorical-scope-guard", "claude", detect, "PreToolUse") if __name__ == "__main__": diff --git a/engine/hooks/categorical-scope-guard/codex_pretooluse.py b/engine/hooks/categorical-scope-guard/codex_pretooluse.py new file mode 100644 index 00000000..e2f2a14d --- /dev/null +++ b/engine/hooks/categorical-scope-guard/codex_pretooluse.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Codex PreToolUse entrypoint for categorical-scope-guard.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + + +def main() -> None: + run_hook("categorical-scope-guard", "codex", detect, "PreToolUse") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/categorical-scope-guard/cursor_pretooluse.py b/engine/hooks/categorical-scope-guard/cursor_pretooluse.py new file mode 100644 index 00000000..bf6714e8 --- /dev/null +++ b/engine/hooks/categorical-scope-guard/cursor_pretooluse.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Cursor PreToolUse entrypoint for categorical-scope-guard.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + + +def main() -> None: + run_hook("categorical-scope-guard", "cursor", detect, "PreToolUse") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/categorical-scope-guard/detect.py b/engine/hooks/categorical-scope-guard/detect.py index bc0f1912..37f288eb 100644 --- a/engine/hooks/categorical-scope-guard/detect.py +++ b/engine/hooks/categorical-scope-guard/detect.py @@ -14,14 +14,24 @@ from __future__ import annotations import sys +import hashlib import json import os import re from dataclasses import dataclass, field +from pathlib import Path + +SDK_DIR = Path(__file__).resolve().parents[1] / "_sdk" +if str(SDK_DIR) not in sys.path: + sys.path.insert(0, str(SDK_DIR)) + +from finding import Finding # noqa: E402 HIT = "hit" CLEAN = "clean" UNCHECKED = "unchecked" +RULE_PARTIAL_STATUS_FILTER = "categorical-scope-guard.partial-status-filter" +RULE_UNCHECKED = "categorical-scope-guard.unchecked" LIVE_TURNS = 4 MAX_SCAN_BYTES = 64 * 1024 * 1024 @@ -872,3 +882,53 @@ def decide_payload(payload: dict) -> Verdict: return Verdict(CLEAN) path = payload.get("transcript_path") or payload.get("transcriptPath") or "" return decide(command, lambda: read_live_window(path if isinstance(path, str) else "")) + + +SHELL_LIKE_TOOL_NAMES = ( + "Bash", "bash", "shell", "Shell", "exec", "exec_command", + "run_terminal_cmd", "local_shell", "run_command", "shell_call", +) + + +def _tool_name(payload: dict) -> str: + return str( + payload.get("tool_name") + or payload.get("toolName") + or payload.get("tool") + or payload.get("name") + or "" + ) + + +def _command_subject(payload: dict) -> str: + tool_input = payload.get("tool_input") or payload.get("toolInput") or {} + command = tool_input.get("command") if isinstance(tool_input, dict) else "" + if not isinstance(command, str): + command = "" + return "command:" + hashlib.sha256(command.encode("utf-8")).hexdigest()[:16] + + +def _finding(rule_id: str, payload: dict, message: str) -> Finding: + return Finding( + rule_id=rule_id, + subject=_command_subject(payload), + message=message, + evidence=message, + ) + + +def detect(event: dict) -> list[Finding]: + if _tool_name(event) not in SHELL_LIKE_TOOL_NAMES: + return [] + try: + verdict = decide_payload(event) + except Exception as exc: + message = ( + f"categorical-scope-guard: UNCHECKED -- the detector failed ({exc!r}) while classifying a " + "status-filtered mutation. Blocked rather than passed; drop the status filter or rephrase the command." + ) + return [_finding(RULE_UNCHECKED, event, message)] + if verdict.outcome == CLEAN: + return [] + rule_id = RULE_PARTIAL_STATUS_FILTER if verdict.outcome == HIT else RULE_UNCHECKED + return [_finding(rule_id, event, verdict.message)] diff --git a/engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py b/engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..b61c0c3b --- /dev/null +++ b/engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +FIXTURES = os.path.join(HERE, "fixtures") +ENTRYPOINT = os.path.join(HOOK_DIR, "claude_pretooluse.py") + + +def fixture(name: str) -> str: + with open(os.path.join(FIXTURES, name), encoding="utf-8") as handle: + return handle.read() + + +def human(text: str) -> dict[str, object]: + return {"type": "user", "message": {"role": "user", "content": text}} + + +def write_transcript(entries: list[dict[str, object]]) -> str: + handle = tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False, encoding="utf-8") + with handle: + for entry in entries: + handle.write(json.dumps(entry) + "\n") + return handle.name + + +def run_entrypoint(payload: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + merged_env.update(env) + return subprocess.run( + [sys.executable, ENTRYPOINT], + input=json.dumps(payload), + capture_output=True, + text=True, + env=merged_env, + ) + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + path = write_transcript([human("can you make all tasks use claude and local executor")]) + try: + result = run_entrypoint( + { + "tool_name": "Bash", + "hook_event_name": "PreToolUse", + "transcript_path": path, + "tool_input": {"command": fixture("update_tasks_status_in_pending_queued.txt")}, + }, + {"CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD": "warn"}, + ) + finally: + os.unlink(path) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + rendered = json.loads(result.stdout) + self.assertIn( + "categorical-scope-guard", + rendered["hookSpecificOutput"]["additionalContext"], + ) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + path = write_transcript([human("can you make all tasks use claude and local executor")]) + try: + with tempfile.TemporaryDirectory() as tmp: + result = run_entrypoint( + { + "tool_name": "Bash", + "hook_event_name": "PreToolUse", + "session_id": "categorical-scope-guard-sdk-mode", + "transcript_path": path, + "tool_input": {"command": fixture("update_tasks_status_in_pending_queued.txt")}, + }, + { + "CATSTACK_HOOK_METRICS_DIR": tmp, + "CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD": "warn", + }, + ) + rows = [ + json.loads(line) + for file in Path(tmp).glob("events-*.jsonl") + for line in file.read_text(encoding="utf-8").splitlines() + ] + finally: + os.unlink(path) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(rows)) + self.assertEqual("categorical-scope-guard", rows[0]["hook"]) + self.assertEqual("categorical-scope-guard.partial-status-filter", rows[0]["rule_id"]) + self.assertEqual("warn", rows[0]["mode"]) + self.assertEqual("override", rows[0]["mode_source"]) + self.assertEqual("warned", rows[0]["action"]) + + +if __name__ == "__main__": + unittest.main() From fcfbe21ec86547f05221c3699c8b936fadd1850e Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:59:42 +0000 Subject: [PATCH 08/17] =?UTF-8?q?invoker:=20wf-1789406891093-34/verify-hoo?= =?UTF-8?q?k-categorical-scope-guard=20=E2=80=94=20Run=20the=20determinist?= =?UTF-8?q?ic=20proof=20for=20put=20the=20categorical-scope-guard=20hook?= =?UTF-8?q?=20onto=20the=20shared=20hook=20code.=20Review=20claim:=20The?= =?UTF-8?q?=20proof=20exits=200=20only=20when=20put=20the=20categorical-sc?= =?UTF-8?q?ope-guard=20hook=20onto=20the=20shared=20hook=20code=20holds.?= =?UTF-8?q?=20Review=20lane:=20proof=20Safety=20invariant:=20Proof=20only;?= =?UTF-8?q?=20it=20changes=20no=20product=20behavior.=20Effectiveness=20me?= =?UTF-8?q?asurement:=20The=20exit=20status=20of=20`python3=20-m=20unittes?= =?UTF-8?q?t=20discover=20-s=20engine/hooks/categorical-scope-guard/tests`?= =?UTF-8?q?=20is=20the=20signal=20for=20this=20slice.=20Slice=20rationale:?= =?UTF-8?q?=20One=20proof=20unit=20for=20this=20workflow.=20Architectural?= =?UTF-8?q?=20effect:=20None;=20verification=20only.=20Goal:=20Prove=20put?= =?UTF-8?q?=20the=20categorical-scope-guard=20hook=20onto=20the=20shared?= =?UTF-8?q?=20hook=20code=20with=20one=20deterministic=20run.=20Motivation?= =?UTF-8?q?:=20Each=20workflow=20carries=20its=20own=20proof=20so=20a=20re?= =?UTF-8?q?viewer=20can=20trust=20the=20slice=20alone.=20Alternative=20con?= =?UTF-8?q?siderations:=20Manual=20inspection=20was=20set=20aside=20as=20n?= =?UTF-8?q?on-deterministic.=20Implementation=20details:=20Execute=20the?= =?UTF-8?q?=20proof=20as=20a=20terminal=20gate.=20Non-goals:=20No=20produc?= =?UTF-8?q?t=20edits=20here.=20Layer:=20e2e=5Fregression=20Feature=20state?= =?UTF-8?q?:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 30fc01e9-dbf4-4302-9c20-a786ccb160ef From 0cc83065ae90633f26259c24c3ae846093908424 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 08:00:25 +0000 Subject: [PATCH 09/17] =?UTF-8?q?invoker:=20wf-1789406891093-34/scrub-hand?= =?UTF-8?q?off-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confi?= =?UTF-8?q?rming=20no=20ephemeral=20handoff=20files=20were=20left=20behind?= =?UTF-8?q?.=20Review=20claim:=20The=20workflow=20leaves=20no=20ephemeral?= =?UTF-8?q?=20handoff=20files=20in=20the=20tree.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Read-only;=20it=20never=20deletes=20fi?= =?UTF-8?q?les,=20alters=20the=20index,=20or=20commits=20caller=20work.=20?= =?UTF-8?q?Effectiveness=20measurement:=20A=20non-zero=20exit=20when=20eph?= =?UTF-8?q?emeral=20handoff=20files=20remain=20is=20the=20signal.=20Slice?= =?UTF-8?q?=20rationale:=20One=20unit:=20the=20hygiene=20gate.=20Architect?= =?UTF-8?q?ural=20effect:=20None.=20Goal:=20Confirm=20no=20ephemeral=20han?= =?UTF-8?q?doff=20files=20remain=20after=20every=20other=20task=20finishes?= =?UTF-8?q?.=20Motivation:=20Ephemeral=20inter-task=20files=20leak=20into?= =?UTF-8?q?=20the=20diff=20and=20read=20as=20part=20of=20the=20change.=20A?= =?UTF-8?q?lternative=20considerations:=20Manual=20inspection=20was=20set?= =?UTF-8?q?=20aside=20as=20non-deterministic.=20Implementation=20details:?= =?UTF-8?q?=20Run=20scripts/scrub-handoff-artifacts.sh=20in=20check=20mode?= =?UTF-8?q?.=20Non-goals:=20No=20deletion,=20no=20index=20changes,=20no=20?= =?UTF-8?q?commits.=20Layer:=20e2e=5Fregression=20Feature=20state:=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 85155944-670a-40b9-a845-1252af44dacd From c67ac17bfd780971a9c758ebfe7aa704807c655a Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:15:03 +0000 Subject: [PATCH 10/17] =?UTF-8?q?invoker:=20wf-1789406894793-35/implement-?= =?UTF-8?q?hook-demo-freeze=20=E2=80=94=20Put=20the=20demo-freeze=20hook?= =?UTF-8?q?=20onto=20the=20shared=20hook=20code.=20Review=20claim:=20This?= =?UTF-8?q?=20hook=20reports=20findings=20to=20the=20shared=20hook=20code,?= =?UTF-8?q?=20which=20applies=20its=20registry=20mode=20and=20writes=20eve?= =?UTF-8?q?nt=20rows.=20It=20keeps=20mode=20stop.=20Review=20lane:=20behav?= =?UTF-8?q?ior=20Safety=20invariant:=20The=20hook=20gives=20the=20same=20s?= =?UTF-8?q?top,=20warn,=20or=20silent=20result=20on=20every=20case=20in=20?= =?UTF-8?q?its=20current=20test=20folder,=20except=20the=20mode=20change?= =?UTF-8?q?=20named=20in=20this=20claim,=20and=20its=20test=20folder=20kee?= =?UTF-8?q?ps=20exiting=200.=20Effectiveness=20measurement:=20`python3=20-?= =?UTF-8?q?m=20unittest=20discover=20-s=20engine/hooks/demo-freeze/tests`?= =?UTF-8?q?=20exits=200,=20and=20the=20new=20mode-override=20case=20fails?= =?UTF-8?q?=20before=20this=20change.=20Slice=20rationale:=20One=20hook=20?= =?UTF-8?q?per=20workflow,=20as=20the=20user=20asked,=20so=20each=20migrat?= =?UTF-8?q?ion=20is=20reviewed=20on=20its=20own.=20Architectural=20effect:?= =?UTF-8?q?=20The=20demo-freeze=20entry=20scripts=20become=20thin=20calls?= =?UTF-8?q?=20into=20the=20shared=20runtime;=20its=20detection=20returns?= =?UTF-8?q?=20findings.=20Goal:=20Stops=20edits=20to=20what=20the=20user?= =?UTF-8?q?=20is=20demoing.=20Keep=20that=20behavior=20while=20its=20mode?= =?UTF-8?q?=20moves=20into=20the=20registry.=20Motivation:=20Mode=20and=20?= =?UTF-8?q?output=20shape=20live=20inside=20each=20hook=20today;=20the=20s?= =?UTF-8?q?hared=20code=20makes=20a=20mode=20change=20a=20one-line=20regis?= =?UTF-8?q?try=20edit.=20Alternative=20considerations:=20Migrating=20sever?= =?UTF-8?q?al=20hooks=20per=20workflow=20was=20set=20aside=20because=20the?= =?UTF-8?q?=20user=20asked=20for=20one=20hook=20per=20workflow.=20Implemen?= =?UTF-8?q?tation=20details:=20Turn=20this=20hook's=20detection=20into=20d?= =?UTF-8?q?etect(event)=20returning=20Finding=20objects=20with=20stable=20?= =?UTF-8?q?rule=20ids,=20and=20make=20each=20harness=20entry=20script=20ca?= =?UTF-8?q?ll=20run=5Fhook=20from=20engine/hooks/=5Fsdk/runtime.py.=20It?= =?UTF-8?q?=20keeps=20mode=20stop.=20Non-goals:=20No=20change=20to=20what?= =?UTF-8?q?=20the=20hook=20detects.=20No=20other=20hook=20changes.=20Layer?= =?UTF-8?q?:=20domain=20Feature=20state:=20active=20Files:=20engine/hooks/?= =?UTF-8?q?demo-freeze/claude=5Fpretooluse=5Fcheck.py,=20engine/hooks/demo?= =?UTF-8?q?-freeze/install=5Fclaude=5Fhook.py,=20engine/hooks/demo-freeze/?= =?UTF-8?q?tests/test=5Fhooks=5Fsdk=5Fmode.py=20Change=20types:=20-=20engi?= =?UTF-8?q?ne/hooks/demo-freeze/claude=5Fpretooluse=5Fcheck.py:=20modify?= =?UTF-8?q?=20-=20engine/hooks/demo-freeze/install=5Fclaude=5Fhook.py:=20m?= =?UTF-8?q?odify=20-=20engine/hooks/demo-freeze/tests/test=5Fhooks=5Fsdk?= =?UTF-8?q?=5Fmode.py:=20create=20Acceptance=20criteria:=20-=20`python3=20?= =?UTF-8?q?-m=20unittest=20discover=20-s=20engine/hooks/demo-freeze/tests`?= =?UTF-8?q?=20exits=200.=20-=20With=20CATSTACK=5FHOOK=5FMODE=5FDEMO=5FFREE?= =?UTF-8?q?ZE=20set=20to=20warn,=20a=20case=20that=20stops=20today=20produ?= =?UTF-8?q?ces=20a=20warning=20instead,=20proving=20the=20registry=20mode?= =?UTF-8?q?=20drives=20the=20response.=20-=20Each=20finding=20writes=20one?= =?UTF-8?q?=20event=20row=20with=20the=20hook's=20rule=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 1 Invoker-Finalize-Id: 4a3989de-6a6e-4011-9900-c8aa481e9c27 From 60c89ff5c9ce5bc62222068f05419fb8e0cea74c Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:22:48 +0000 Subject: [PATCH 11/17] =?UTF-8?q?invoker:=20wf-1789406894793-35/implement-?= =?UTF-8?q?hook-demo-freeze=20=E2=80=94=20Put=20the=20demo-freeze=20hook?= =?UTF-8?q?=20onto=20the=20shared=20hook=20code.=20Review=20claim:=20This?= =?UTF-8?q?=20hook=20reports=20findings=20to=20the=20shared=20hook=20code,?= =?UTF-8?q?=20which=20applies=20its=20registry=20mode=20and=20writes=20eve?= =?UTF-8?q?nt=20rows.=20It=20keeps=20mode=20stop.=20Review=20lane:=20behav?= =?UTF-8?q?ior=20Safety=20invariant:=20The=20hook=20gives=20the=20same=20s?= =?UTF-8?q?top,=20warn,=20or=20silent=20result=20on=20every=20case=20in=20?= =?UTF-8?q?its=20current=20test=20folder,=20except=20the=20mode=20change?= =?UTF-8?q?=20named=20in=20this=20claim,=20and=20its=20test=20folder=20kee?= =?UTF-8?q?ps=20exiting=200.=20Effectiveness=20measurement:=20`python3=20-?= =?UTF-8?q?m=20unittest=20discover=20-s=20engine/hooks/demo-freeze/tests`?= =?UTF-8?q?=20exits=200,=20and=20the=20new=20mode-override=20case=20fails?= =?UTF-8?q?=20before=20this=20change.=20Slice=20rationale:=20One=20hook=20?= =?UTF-8?q?per=20workflow,=20as=20the=20user=20asked,=20so=20each=20migrat?= =?UTF-8?q?ion=20is=20reviewed=20on=20its=20own.=20Architectural=20effect:?= =?UTF-8?q?=20The=20demo-freeze=20entry=20scripts=20become=20thin=20calls?= =?UTF-8?q?=20into=20the=20shared=20runtime;=20its=20detection=20returns?= =?UTF-8?q?=20findings.=20Goal:=20Stops=20edits=20to=20what=20the=20user?= =?UTF-8?q?=20is=20demoing.=20Keep=20that=20behavior=20while=20its=20mode?= =?UTF-8?q?=20moves=20into=20the=20registry.=20Motivation:=20Mode=20and=20?= =?UTF-8?q?output=20shape=20live=20inside=20each=20hook=20today;=20the=20s?= =?UTF-8?q?hared=20code=20makes=20a=20mode=20change=20a=20one-line=20regis?= =?UTF-8?q?try=20edit.=20Alternative=20considerations:=20Migrating=20sever?= =?UTF-8?q?al=20hooks=20per=20workflow=20was=20set=20aside=20because=20the?= =?UTF-8?q?=20user=20asked=20for=20one=20hook=20per=20workflow.=20Implemen?= =?UTF-8?q?tation=20details:=20Turn=20this=20hook's=20detection=20into=20d?= =?UTF-8?q?etect(event)=20returning=20Finding=20objects=20with=20stable=20?= =?UTF-8?q?rule=20ids,=20and=20make=20each=20harness=20entry=20script=20ca?= =?UTF-8?q?ll=20run=5Fhook=20from=20engine/hooks/=5Fsdk/runtime.py.=20It?= =?UTF-8?q?=20keeps=20mode=20stop.=20Non-goals:=20No=20change=20to=20what?= =?UTF-8?q?=20the=20hook=20detects.=20No=20other=20hook=20changes.=20Layer?= =?UTF-8?q?:=20domain=20Feature=20state:=20active=20Files:=20engine/hooks/?= =?UTF-8?q?demo-freeze/claude=5Fpretooluse=5Fcheck.py,=20engine/hooks/demo?= =?UTF-8?q?-freeze/install=5Fclaude=5Fhook.py,=20engine/hooks/demo-freeze/?= =?UTF-8?q?tests/test=5Fhooks=5Fsdk=5Fmode.py=20Change=20types:=20-=20engi?= =?UTF-8?q?ne/hooks/demo-freeze/claude=5Fpretooluse=5Fcheck.py:=20modify?= =?UTF-8?q?=20-=20engine/hooks/demo-freeze/install=5Fclaude=5Fhook.py:=20m?= =?UTF-8?q?odify=20-=20engine/hooks/demo-freeze/tests/test=5Fhooks=5Fsdk?= =?UTF-8?q?=5Fmode.py:=20create=20Acceptance=20criteria:=20-=20`python3=20?= =?UTF-8?q?-m=20unittest=20discover=20-s=20engine/hooks/demo-freeze/tests`?= =?UTF-8?q?=20exits=200.=20-=20With=20CATSTACK=5FHOOK=5FMODE=5FDEMO=5FFREE?= =?UTF-8?q?ZE=20set=20to=20warn,=20a=20case=20that=20stops=20today=20produ?= =?UTF-8?q?ces=20a=20warning=20instead,=20proving=20the=20registry=20mode?= =?UTF-8?q?=20drives=20the=20response.=20-=20Each=20finding=20writes=20one?= =?UTF-8?q?=20event=20row=20with=20the=20hook's=20rule=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Put the demo-freeze hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The demo-freeze entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops edits to what the user is demoing. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/demo-freeze/claude_pretooluse_check.py, engine/hooks/demo-freeze/install_claude_hook.py, engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/demo-freeze/claude_pretooluse_check.py: modify - engine/hooks/demo-freeze/install_claude_hook.py: modify - engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0. - With CATSTACK_HOOK_MODE_DEMO_FREEZE set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: publish-approved-fix --- .../demo-freeze/claude_pretooluse_check.py | 37 +++++-- engine/hooks/demo-freeze/tests/test_hooks.py | 5 +- .../demo-freeze/tests/test_hooks_sdk_mode.py | 104 ++++++++++++++++++ 3 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/demo-freeze/claude_pretooluse_check.py b/engine/hooks/demo-freeze/claude_pretooluse_check.py index 3ccc1f1d..4d94563d 100755 --- a/engine/hooks/demo-freeze/claude_pretooluse_check.py +++ b/engine/hooks/demo-freeze/claude_pretooluse_check.py @@ -16,13 +16,19 @@ not haunt tomorrow's session), and any parse/read error fails open. """ import fnmatch -import json import os import sys import time +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 +from runtime import run_hook # noqa: E402 + MARKER = os.environ.get("DEMO_FREEZE_FILE", "/tmp/.demo-freeze") MAX_AGE_SECS = 2 * 3600 +RULE_FROZEN_PATH = "demo-freeze.frozen-path" def frozen_patterns(): @@ -48,28 +54,35 @@ def matches(target, pattern): return target_abs == os.path.abspath(pattern) -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - return - tool_input = data.get("tool_input") or {} - target = ( +def _tool_target(event): + tool_input = event.get("tool_input") or event.get("toolInput") or {} + if not isinstance(tool_input, dict): + return None + return ( tool_input.get("file_path") or tool_input.get("path") or tool_input.get("notebook_path") ) + + +def detect(event): + target = _tool_target(event) if not target: - return + return [] for pattern in frozen_patterns(): if matches(target, pattern): - sys.stderr.write( + message = ( f"Demo surface frozen: {target} matches {pattern!r} in {MARKER}. " "The user is mid-test — don't change what they're looking at unless " "they asked or the test is failing. Remove the marker file to " - "unfreeze once the live window ends.\n" + "unfreeze once the live window ends." ) - sys.exit(2) + return [Finding(rule_id=RULE_FROZEN_PATH, subject=target, message=message, evidence=message)] + return [] + + +def main(): + run_hook("demo-freeze", "claude", detect, "PreToolUse") if __name__ == "__main__": diff --git a/engine/hooks/demo-freeze/tests/test_hooks.py b/engine/hooks/demo-freeze/tests/test_hooks.py index ded538ab..56b60110 100755 --- a/engine/hooks/demo-freeze/tests/test_hooks.py +++ b/engine/hooks/demo-freeze/tests/test_hooks.py @@ -74,7 +74,10 @@ def test_no_marker_fails_open(self): with patch.object(claude_pretooluse_check, "MARKER", "/nonexistent/.demo-freeze"): with patch.object(sys, "stdin", io.StringIO(json.dumps(payload))): with redirect_stderr(err): - claude_pretooluse_check.main() + try: + claude_pretooluse_check.main() + except SystemExit: + pass self.assertEqual(err.getvalue(), "") def test_non_file_tool_input_passes(self): diff --git a/engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py b/engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..11eabc11 --- /dev/null +++ b/engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +ENTRYPOINT = os.path.join(HOOK_DIR, "claude_pretooluse_check.py") + + +def write_marker(lines: list[str]) -> str: + handle = tempfile.NamedTemporaryFile("w", suffix=".demo-freeze", delete=False, encoding="utf-8") + with handle: + handle.write("\n".join(lines) + "\n") + return handle.name + + +def run_entrypoint(payload: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + merged_env.update(env) + return subprocess.run( + [sys.executable, ENTRYPOINT], + input=json.dumps(payload), + capture_output=True, + text=True, + env=merged_env, + ) + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + marker = write_marker(["/tmp/demo/call.html"]) + try: + result = run_entrypoint( + {"tool_name": "Edit", "tool_input": {"file_path": "/tmp/demo/call.html"}}, + { + "DEMO_FREEZE_FILE": marker, + "CATSTACK_HOOK_MODE_DEMO_FREEZE": "warn", + }, + ) + finally: + os.unlink(marker) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + rendered = json.loads(result.stdout) + self.assertIn( + "Demo surface frozen", + rendered["hookSpecificOutput"]["additionalContext"], + ) + + def test_mode_stop_still_blocks_by_default(self) -> None: + marker = write_marker(["/tmp/demo/call.html"]) + try: + result = run_entrypoint( + {"tool_name": "Edit", "tool_input": {"file_path": "/tmp/demo/call.html"}}, + {"DEMO_FREEZE_FILE": marker}, + ) + finally: + os.unlink(marker) + + self.assertEqual(2, result.returncode) + self.assertIn("Demo surface frozen", result.stderr) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + marker = write_marker(["/tmp/demo/call.html"]) + try: + with tempfile.TemporaryDirectory() as tmp: + result = run_entrypoint( + { + "tool_name": "Edit", + "session_id": "demo-freeze-sdk-mode", + "tool_input": {"file_path": "/tmp/demo/call.html"}, + }, + { + "DEMO_FREEZE_FILE": marker, + "CATSTACK_HOOK_METRICS_DIR": tmp, + "CATSTACK_HOOK_MODE_DEMO_FREEZE": "warn", + }, + ) + rows = [ + json.loads(line) + for file in Path(tmp).glob("events-*.jsonl") + for line in file.read_text(encoding="utf-8").splitlines() + ] + finally: + os.unlink(marker) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(rows)) + self.assertEqual("demo-freeze", rows[0]["hook"]) + self.assertEqual("demo-freeze.frozen-path", rows[0]["rule_id"]) + self.assertEqual("warn", rows[0]["mode"]) + self.assertEqual("override", rows[0]["mode_source"]) + self.assertEqual("warned", rows[0]["action"]) + + +if __name__ == "__main__": + unittest.main() From b9e01d79ba1c0bba4b1050e31749b0b0631487cc Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:23:29 +0000 Subject: [PATCH 12/17] =?UTF-8?q?invoker:=20wf-1789406894793-35/verify-hoo?= =?UTF-8?q?k-demo-freeze=20=E2=80=94=20Run=20the=20deterministic=20proof?= =?UTF-8?q?=20for=20put=20the=20demo-freeze=20hook=20onto=20the=20shared?= =?UTF-8?q?=20hook=20code.=20Review=20claim:=20The=20proof=20exits=200=20o?= =?UTF-8?q?nly=20when=20put=20the=20demo-freeze=20hook=20onto=20the=20shar?= =?UTF-8?q?ed=20hook=20code=20holds.=20Review=20lane:=20proof=20Safety=20i?= =?UTF-8?q?nvariant:=20Proof=20only;=20it=20changes=20no=20product=20behav?= =?UTF-8?q?ior.=20Effectiveness=20measurement:=20The=20exit=20status=20of?= =?UTF-8?q?=20`python3=20-m=20unittest=20discover=20-s=20engine/hooks/demo?= =?UTF-8?q?-freeze/tests`=20is=20the=20signal=20for=20this=20slice.=20Slic?= =?UTF-8?q?e=20rationale:=20One=20proof=20unit=20for=20this=20workflow.=20?= =?UTF-8?q?Architectural=20effect:=20None;=20verification=20only.=20Goal:?= =?UTF-8?q?=20Prove=20put=20the=20demo-freeze=20hook=20onto=20the=20shared?= =?UTF-8?q?=20hook=20code=20with=20one=20deterministic=20run.=20Motivation?= =?UTF-8?q?:=20Each=20workflow=20carries=20its=20own=20proof=20so=20a=20re?= =?UTF-8?q?viewer=20can=20trust=20the=20slice=20alone.=20Alternative=20con?= =?UTF-8?q?siderations:=20Manual=20inspection=20was=20set=20aside=20as=20n?= =?UTF-8?q?on-deterministic.=20Implementation=20details:=20Execute=20the?= =?UTF-8?q?=20proof=20as=20a=20terminal=20gate.=20Non-goals:=20No=20produc?= =?UTF-8?q?t=20edits=20here.=20Layer:=20e2e=5Fregression=20Feature=20state?= =?UTF-8?q?:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: d36e6bb9-501d-4015-bb29-c6ec478f7a78 From 63cf3ddc1f0ca37365241964093f0fee5bfaa9d9 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:24:11 +0000 Subject: [PATCH 13/17] =?UTF-8?q?invoker:=20wf-1789406894793-35/scrub-hand?= =?UTF-8?q?off-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confi?= =?UTF-8?q?rming=20no=20ephemeral=20handoff=20files=20were=20left=20behind?= =?UTF-8?q?.=20Review=20claim:=20The=20workflow=20leaves=20no=20ephemeral?= =?UTF-8?q?=20handoff=20files=20in=20the=20tree.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Read-only;=20it=20never=20deletes=20fi?= =?UTF-8?q?les,=20alters=20the=20index,=20or=20commits=20caller=20work.=20?= =?UTF-8?q?Effectiveness=20measurement:=20A=20non-zero=20exit=20when=20eph?= =?UTF-8?q?emeral=20handoff=20files=20remain=20is=20the=20signal.=20Slice?= =?UTF-8?q?=20rationale:=20One=20unit:=20the=20hygiene=20gate.=20Architect?= =?UTF-8?q?ural=20effect:=20None.=20Goal:=20Confirm=20no=20ephemeral=20han?= =?UTF-8?q?doff=20files=20remain=20after=20every=20other=20task=20finishes?= =?UTF-8?q?.=20Motivation:=20Ephemeral=20inter-task=20files=20leak=20into?= =?UTF-8?q?=20the=20diff=20and=20read=20as=20part=20of=20the=20change.=20A?= =?UTF-8?q?lternative=20considerations:=20Manual=20inspection=20was=20set?= =?UTF-8?q?=20aside=20as=20non-deterministic.=20Implementation=20details:?= =?UTF-8?q?=20Run=20scripts/scrub-handoff-artifacts.sh=20in=20check=20mode?= =?UTF-8?q?.=20Non-goals:=20No=20deletion,=20no=20index=20changes,=20no=20?= =?UTF-8?q?commits.=20Layer:=20e2e=5Fregression=20Feature=20state:=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 58d2877b-64ef-434d-8479-4faf4996f91f From bec4b7a5e776879df1490d1c6d8c5ca0f1df7a9b Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:31:39 +0000 Subject: [PATCH 14/17] =?UTF-8?q?invoker:=20wf-1789406897817-36/implement-?= =?UTF-8?q?hook-diu-stop=20=E2=80=94=20Put=20the=20diu-stop=20hook=20onto?= =?UTF-8?q?=20the=20shared=20hook=20code.=20Review=20claim:=20This=20hook?= =?UTF-8?q?=20reports=20findings=20to=20the=20shared=20hook=20code,=20whic?= =?UTF-8?q?h=20applies=20its=20registry=20mode=20and=20writes=20event=20ro?= =?UTF-8?q?ws.=20It=20keeps=20mode=20stop.=20Review=20lane:=20behavior=20S?= =?UTF-8?q?afety=20invariant:=20The=20hook=20gives=20the=20same=20stop,=20?= =?UTF-8?q?warn,=20or=20silent=20result=20on=20every=20case=20in=20its=20c?= =?UTF-8?q?urrent=20test=20folder,=20except=20the=20mode=20change=20named?= =?UTF-8?q?=20in=20this=20claim,=20and=20its=20test=20folder=20keeps=20exi?= =?UTF-8?q?ting=200.=20Effectiveness=20measurement:=20`python3=20-m=20unit?= =?UTF-8?q?test=20discover=20-s=20engine/hooks/diu-stop/tests`=20exits=200?= =?UTF-8?q?,=20and=20the=20new=20mode-override=20case=20fails=20before=20t?= =?UTF-8?q?his=20change.=20Slice=20rationale:=20One=20hook=20per=20workflo?= =?UTF-8?q?w,=20as=20the=20user=20asked,=20so=20each=20migration=20is=20re?= =?UTF-8?q?viewed=20on=20its=20own.=20Architectural=20effect:=20The=20diu-?= =?UTF-8?q?stop=20entry=20scripts=20become=20thin=20calls=20into=20the=20s?= =?UTF-8?q?hared=20runtime;=20its=20detection=20returns=20findings.=20Goal?= =?UTF-8?q?:=20Stops=20a=20reply=20that=20is=20too=20long=20or=20unproven.?= =?UTF-8?q?=20Keep=20that=20behavior=20while=20its=20mode=20moves=20into?= =?UTF-8?q?=20the=20registry.=20Motivation:=20Mode=20and=20output=20shape?= =?UTF-8?q?=20live=20inside=20each=20hook=20today;=20the=20shared=20code?= =?UTF-8?q?=20makes=20a=20mode=20change=20a=20one-line=20registry=20edit.?= =?UTF-8?q?=20Alternative=20considerations:=20Migrating=20several=20hooks?= =?UTF-8?q?=20per=20workflow=20was=20set=20aside=20because=20the=20user=20?= =?UTF-8?q?asked=20for=20one=20hook=20per=20workflow.=20Implementation=20d?= =?UTF-8?q?etails:=20Turn=20this=20hook's=20detection=20into=20detect(even?= =?UTF-8?q?t)=20returning=20Finding=20objects=20with=20stable=20rule=20ids?= =?UTF-8?q?,=20and=20make=20each=20harness=20entry=20script=20call=20run?= =?UTF-8?q?=5Fhook=20from=20engine/hooks/=5Fsdk/runtime.py.=20It=20keeps?= =?UTF-8?q?=20mode=20stop.=20Non-goals:=20No=20change=20to=20what=20the=20?= =?UTF-8?q?hook=20detects.=20No=20other=20hook=20changes.=20Layer:=20domai?= =?UTF-8?q?n=20Feature=20state:=20active=20Files:=20engine/hooks/diu-stop/?= =?UTF-8?q?claude=5Fprompt=5Freminder.py,=20engine/hooks/diu-stop/claude?= =?UTF-8?q?=5Fstop=5Fcheck.py,=20engine/hooks/diu-stop/codex=5Fnotify.py,?= =?UTF-8?q?=20engine/hooks/diu-stop/diu=5Flimit.py,=20engine/hooks/diu-sto?= =?UTF-8?q?p/install=5Fclaude=5Fhook.py,=20engine/hooks/diu-stop/install?= =?UTF-8?q?=5Fcodex=5Fnotify.py,=20engine/hooks/diu-stop/plain=5Fwords.py,?= =?UTF-8?q?=20engine/hooks/diu-stop/tests/test=5Fhooks=5Fsdk=5Fmode.py=20C?= =?UTF-8?q?hange=20types:=20-=20engine/hooks/diu-stop/claude=5Fprompt=5Fre?= =?UTF-8?q?minder.py:=20modify=20-=20engine/hooks/diu-stop/claude=5Fstop?= =?UTF-8?q?=5Fcheck.py:=20modify=20-=20engine/hooks/diu-stop/codex=5Fnotif?= =?UTF-8?q?y.py:=20modify=20-=20engine/hooks/diu-stop/diu=5Flimit.py:=20mo?= =?UTF-8?q?dify=20-=20engine/hooks/diu-stop/install=5Fclaude=5Fhook.py:=20?= =?UTF-8?q?modify=20-=20engine/hooks/diu-stop/install=5Fcodex=5Fnotify.py:?= =?UTF-8?q?=20modify=20-=20engine/hooks/diu-stop/plain=5Fwords.py:=20modif?= =?UTF-8?q?y=20-=20engine/hooks/diu-stop/tests/test=5Fhooks=5Fsdk=5Fmode.p?= =?UTF-8?q?y:=20create=20Acceptance=20criteria:=20-=20`python3=20-m=20unit?= =?UTF-8?q?test=20discover=20-s=20engine/hooks/diu-stop/tests`=20exits=200?= =?UTF-8?q?.=20-=20With=20CATSTACK=5FHOOK=5FMODE=5FDIU=5FSTOP=20set=20to?= =?UTF-8?q?=20warn,=20a=20case=20that=20stops=20today=20produces=20a=20war?= =?UTF-8?q?ning=20instead,=20proving=20the=20registry=20mode=20drives=20th?= =?UTF-8?q?e=20response.=20-=20Each=20finding=20writes=20one=20event=20row?= =?UTF-8?q?=20with=20the=20hook's=20rule=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 1 Invoker-Finalize-Id: cc582581-b6f8-4f71-8dd9-cf16d79f1628 From 9883c5eb4ef5c83f380911a39307cff1d8e56d35 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:41:41 +0000 Subject: [PATCH 15/17] =?UTF-8?q?invoker:=20wf-1789406897817-36/implement-?= =?UTF-8?q?hook-diu-stop=20=E2=80=94=20Put=20the=20diu-stop=20hook=20onto?= =?UTF-8?q?=20the=20shared=20hook=20code.=20Review=20claim:=20This=20hook?= =?UTF-8?q?=20reports=20findings=20to=20the=20shared=20hook=20code,=20whic?= =?UTF-8?q?h=20applies=20its=20registry=20mode=20and=20writes=20event=20ro?= =?UTF-8?q?ws.=20It=20keeps=20mode=20stop.=20Review=20lane:=20behavior=20S?= =?UTF-8?q?afety=20invariant:=20The=20hook=20gives=20the=20same=20stop,=20?= =?UTF-8?q?warn,=20or=20silent=20result=20on=20every=20case=20in=20its=20c?= =?UTF-8?q?urrent=20test=20folder,=20except=20the=20mode=20change=20named?= =?UTF-8?q?=20in=20this=20claim,=20and=20its=20test=20folder=20keeps=20exi?= =?UTF-8?q?ting=200.=20Effectiveness=20measurement:=20`python3=20-m=20unit?= =?UTF-8?q?test=20discover=20-s=20engine/hooks/diu-stop/tests`=20exits=200?= =?UTF-8?q?,=20and=20the=20new=20mode-override=20case=20fails=20before=20t?= =?UTF-8?q?his=20change.=20Slice=20rationale:=20One=20hook=20per=20workflo?= =?UTF-8?q?w,=20as=20the=20user=20asked,=20so=20each=20migration=20is=20re?= =?UTF-8?q?viewed=20on=20its=20own.=20Architectural=20effect:=20The=20diu-?= =?UTF-8?q?stop=20entry=20scripts=20become=20thin=20calls=20into=20the=20s?= =?UTF-8?q?hared=20runtime;=20its=20detection=20returns=20findings.=20Goal?= =?UTF-8?q?:=20Stops=20a=20reply=20that=20is=20too=20long=20or=20unproven.?= =?UTF-8?q?=20Keep=20that=20behavior=20while=20its=20mode=20moves=20into?= =?UTF-8?q?=20the=20registry.=20Motivation:=20Mode=20and=20output=20shape?= =?UTF-8?q?=20live=20inside=20each=20hook=20today;=20the=20shared=20code?= =?UTF-8?q?=20makes=20a=20mode=20change=20a=20one-line=20registry=20edit.?= =?UTF-8?q?=20Alternative=20considerations:=20Migrating=20several=20hooks?= =?UTF-8?q?=20per=20workflow=20was=20set=20aside=20because=20the=20user=20?= =?UTF-8?q?asked=20for=20one=20hook=20per=20workflow.=20Implementation=20d?= =?UTF-8?q?etails:=20Turn=20this=20hook's=20detection=20into=20detect(even?= =?UTF-8?q?t)=20returning=20Finding=20objects=20with=20stable=20rule=20ids?= =?UTF-8?q?,=20and=20make=20each=20harness=20entry=20script=20call=20run?= =?UTF-8?q?=5Fhook=20from=20engine/hooks/=5Fsdk/runtime.py.=20It=20keeps?= =?UTF-8?q?=20mode=20stop.=20Non-goals:=20No=20change=20to=20what=20the=20?= =?UTF-8?q?hook=20detects.=20No=20other=20hook=20changes.=20Layer:=20domai?= =?UTF-8?q?n=20Feature=20state:=20active=20Files:=20engine/hooks/diu-stop/?= =?UTF-8?q?claude=5Fprompt=5Freminder.py,=20engine/hooks/diu-stop/claude?= =?UTF-8?q?=5Fstop=5Fcheck.py,=20engine/hooks/diu-stop/codex=5Fnotify.py,?= =?UTF-8?q?=20engine/hooks/diu-stop/diu=5Flimit.py,=20engine/hooks/diu-sto?= =?UTF-8?q?p/install=5Fclaude=5Fhook.py,=20engine/hooks/diu-stop/install?= =?UTF-8?q?=5Fcodex=5Fnotify.py,=20engine/hooks/diu-stop/plain=5Fwords.py,?= =?UTF-8?q?=20engine/hooks/diu-stop/tests/test=5Fhooks=5Fsdk=5Fmode.py=20C?= =?UTF-8?q?hange=20types:=20-=20engine/hooks/diu-stop/claude=5Fprompt=5Fre?= =?UTF-8?q?minder.py:=20modify=20-=20engine/hooks/diu-stop/claude=5Fstop?= =?UTF-8?q?=5Fcheck.py:=20modify=20-=20engine/hooks/diu-stop/codex=5Fnotif?= =?UTF-8?q?y.py:=20modify=20-=20engine/hooks/diu-stop/diu=5Flimit.py:=20mo?= =?UTF-8?q?dify=20-=20engine/hooks/diu-stop/install=5Fclaude=5Fhook.py:=20?= =?UTF-8?q?modify=20-=20engine/hooks/diu-stop/install=5Fcodex=5Fnotify.py:?= =?UTF-8?q?=20modify=20-=20engine/hooks/diu-stop/plain=5Fwords.py:=20modif?= =?UTF-8?q?y=20-=20engine/hooks/diu-stop/tests/test=5Fhooks=5Fsdk=5Fmode.p?= =?UTF-8?q?y:=20create=20Acceptance=20criteria:=20-=20`python3=20-m=20unit?= =?UTF-8?q?test=20discover=20-s=20engine/hooks/diu-stop/tests`=20exits=200?= =?UTF-8?q?.=20-=20With=20CATSTACK=5FHOOK=5FMODE=5FDIU=5FSTOP=20set=20to?= =?UTF-8?q?=20warn,=20a=20case=20that=20stops=20today=20produces=20a=20war?= =?UTF-8?q?ning=20instead,=20proving=20the=20registry=20mode=20drives=20th?= =?UTF-8?q?e=20response.=20-=20Each=20finding=20writes=20one=20event=20row?= =?UTF-8?q?=20with=20the=20hook's=20rule=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Put the diu-stop hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The diu-stop entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a reply that is too long or unproven. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/diu-stop/claude_prompt_reminder.py, engine/hooks/diu-stop/claude_stop_check.py, engine/hooks/diu-stop/codex_notify.py, engine/hooks/diu-stop/diu_limit.py, engine/hooks/diu-stop/install_claude_hook.py, engine/hooks/diu-stop/install_codex_notify.py, engine/hooks/diu-stop/plain_words.py, engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/diu-stop/claude_prompt_reminder.py: modify - engine/hooks/diu-stop/claude_stop_check.py: modify - engine/hooks/diu-stop/codex_notify.py: modify - engine/hooks/diu-stop/diu_limit.py: modify - engine/hooks/diu-stop/install_claude_hook.py: modify - engine/hooks/diu-stop/install_codex_notify.py: modify - engine/hooks/diu-stop/plain_words.py: modify - engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0. - With CATSTACK_HOOK_MODE_DIU_STOP set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: publish-approved-fix --- .../hooks/diu-stop/claude_prompt_reminder.py | 26 +++--- engine/hooks/diu-stop/claude_stop_check.py | 62 +++++++++------ engine/hooks/diu-stop/codex_notify.py | 44 ++++++++--- engine/hooks/diu-stop/tests/test_hooks.py | 15 +++- .../diu-stop/tests/test_hooks_sdk_mode.py | 79 +++++++++++++++++++ 5 files changed, 180 insertions(+), 46 deletions(-) create mode 100644 engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/diu-stop/claude_prompt_reminder.py b/engine/hooks/diu-stop/claude_prompt_reminder.py index 68bd3079..e0c07a10 100644 --- a/engine/hooks/diu-stop/claude_prompt_reminder.py +++ b/engine/hooks/diu-stop/claude_prompt_reminder.py @@ -16,11 +16,19 @@ reminder repeated every turn is exactly the kind of thing this skill tells the model to cut. """ -import json +import os import sys from diu_limit import rule_text +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 +from runtime import run_hook # noqa: E402 + +RULE_REMINDER = "diu-stop.prompt-reminder" + REMINDER = ( "diu reminder: lead with the outcome, no preamble or closing " f"pleasantries, ELI5 {rule_text()}, unless this turn needs technical " @@ -29,17 +37,13 @@ ) +def detect(event): + subject = event.get("session_id") or "" + return [Finding(rule_id=RULE_REMINDER, subject=subject, message=REMINDER, evidence=REMINDER)] + + def main(): - try: - json.load(sys.stdin) - except json.JSONDecodeError: - return - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": REMINDER, - } - })) + run_hook("diu-stop", "claude", detect, "UserPromptSubmit") if __name__ == "__main__": diff --git a/engine/hooks/diu-stop/claude_stop_check.py b/engine/hooks/diu-stop/claude_stop_check.py index 4eeff269..125ec520 100755 --- a/engine/hooks/diu-stop/claude_stop_check.py +++ b/engine/hooks/diu-stop/claude_stop_check.py @@ -45,7 +45,6 @@ Every block names every flagged sentence, so one rewrite that fixes them all gets through. """ -import json import os import re import sys @@ -55,8 +54,18 @@ sys.path.insert(0, os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_markers")) +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) import markers # noqa: E402 +from finding import Finding # noqa: E402 +from runtime import run_hook # noqa: E402 + +RULE_WORD_LIMIT = "diu-stop.word-limit" +RULE_UNVERIFIED_CLAIM = "diu-stop.unverified-claim" +RULE_MALFORMED_MARKER_TAG = "diu-stop.malformed-marker-tag" +RULE_LEGACY_MARKER = "diu-stop.legacy-marker" +RULE_PLAIN_WORDS = "diu-stop.plain-words" # Phrases banned outright (from this user's global CLAUDE.md evidence # rules) -- rarely legitimate even mid-sentence, so no opener restriction. @@ -219,31 +228,28 @@ def find_unverified_claim(message): return claims[0][0] if claims else None -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - return - - if data.get("agent_id"): - return - retry = bool(data.get("stop_hook_active")) +def detect(event): + if event.get("agent_id"): + return [] + retry = bool(event.get("stop_hook_active")) - message = data.get("last_assistant_message") or "" + message = event.get("last_assistant_message") or "" - plain_words_note = try_check_reply(data) + plain_words_note = try_check_reply(event) word_count = counted_words(message) over_limit = word_count > WORD_LIMIT and not retry claims = find_unverified_claims(message) marker_problems = find_marker_problems(message) - if not over_limit and not claims and not marker_problems and not plain_words_note: - return - - parts = [] + findings = [] if plain_words_note: - parts.append(plain_words_note) + findings.append(Finding( + rule_id=RULE_PLAIN_WORDS, + subject=message, + message=plain_words_note, + evidence=plain_words_note, + )) if claims: lines = [ "This message makes an unverified-shaped claim with no adjacent " @@ -259,18 +265,30 @@ def main(): "of what was actually run/checked in its paragraph, or -- only if " "the check cannot run -- tag the claim there and say why." ) - parts.append("\n".join(lines)) - parts.extend(marker_problems) + claim_message = "\n".join(lines) + findings.append(Finding( + rule_id=RULE_UNVERIFIED_CLAIM, + subject=claim_message, + message=claim_message, + evidence=claim_message, + )) + for problem in marker_problems: + rule_id = RULE_MALFORMED_MARKER_TAG if problem == markers.MALFORMED_TAG_MESSAGE else RULE_LEGACY_MARKER + findings.append(Finding(rule_id=rule_id, subject=message, message=problem, evidence=problem)) if over_limit: - parts.append( + over_message = ( f"Apply diu: {word_count} words, over the {WORD_LIMIT}-word " f"guideline. Cut at least {word_count - WORD_LIMIT} words by " "dropping a whole section or list, not by trimming words. " "Unless this turn genuinely asked for full technical detail " "or a specific long format." ) - sys.stderr.write("\n".join(parts) + "\n") - sys.exit(2) + findings.append(Finding(rule_id=RULE_WORD_LIMIT, subject=message, message=over_message, evidence=over_message)) + return findings + + +def main(): + run_hook("diu-stop", "claude", detect, "Stop") if __name__ == "__main__": diff --git a/engine/hooks/diu-stop/codex_notify.py b/engine/hooks/diu-stop/codex_notify.py index fd88a3d6..c763cb23 100755 --- a/engine/hooks/diu-stop/codex_notify.py +++ b/engine/hooks/diu-stop/codex_notify.py @@ -17,10 +17,34 @@ becomes `old-notify-binary some-arg ` when this fires. """ import json +import os import subprocess import sys +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from events import write_events # noqa: E402 +from finding import Finding # noqa: E402 +from modes import effective_mode # noqa: E402 + WORD_LIMIT = 150 +RULE_WORD_LIMIT = "diu-stop.word-limit" + + +def detect(payload): + if payload.get("type") != "agent-turn-complete": + return [] + message = payload.get("last-assistant-message") or "" + word_count = len(message.split()) + if word_count <= WORD_LIMIT: + return [] + message_text = ( + f"diu-stop: last response was {word_count} words (over the " + f"{WORD_LIMIT}-word diu guideline). Codex can't be forced to redo " + "it -- check by hand whether it should have been ELI5." + ) + return [Finding(rule_id=RULE_WORD_LIMIT, subject=message, message=message_text, evidence=message_text)] def main(): @@ -40,18 +64,18 @@ def main(): except json.JSONDecodeError: return - if payload.get("type") != "agent-turn-complete": + findings = detect(payload) + if not findings: return - message = payload.get("last-assistant-message") or "" - word_count = len(message.split()) - if word_count > WORD_LIMIT: - print( - f"diu-stop: last response was {word_count} words (over the " - f"{WORD_LIMIT}-word diu guideline). Codex can't be forced to redo " - "it -- check by hand whether it should have been ELI5.", - file=sys.stderr, - ) + mode, mode_source = effective_mode("diu-stop", payload) + if mode == "off": + return + + for finding in findings: + print(finding.message, file=sys.stderr) + + write_events("diu-stop", "codex", payload, findings, mode, mode_source, 0) if __name__ == "__main__": diff --git a/engine/hooks/diu-stop/tests/test_hooks.py b/engine/hooks/diu-stop/tests/test_hooks.py index 69570da5..0e6dd831 100644 --- a/engine/hooks/diu-stop/tests/test_hooks.py +++ b/engine/hooks/diu-stop/tests/test_hooks.py @@ -56,7 +56,10 @@ def run_prompt_reminder(stdin_obj): buf = io.StringIO() with patch.object(sys, "stdin", io.StringIO(json.dumps(stdin_obj))): with redirect_stdout(buf): - claude_prompt_reminder.main() + try: + claude_prompt_reminder.main() + except SystemExit: + pass return buf.getvalue() @@ -140,7 +143,10 @@ def test_malformed_stdin_json_does_not_crash(self): buf = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not json")): with redirect_stdout(buf): - claude_stop_check.main() # must not raise + try: + claude_stop_check.main() + except SystemExit: + pass self.assertEqual(buf.getvalue(), "") @@ -176,7 +182,10 @@ def test_malformed_stdin_json_does_not_crash(self): buf = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not json")): with redirect_stdout(buf): - claude_prompt_reminder.main() # must not raise + try: + claude_prompt_reminder.main() + except SystemExit: + pass self.assertEqual(buf.getvalue(), "") diff --git a/engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py b/engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..51c808ea --- /dev/null +++ b/engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +ENTRYPOINT = os.path.join(HOOK_DIR, "claude_stop_check.py") + +LONG_MESSAGE = " ".join(["word"] * 200) + + +def run_entrypoint(payload: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + merged_env.update(env) + return subprocess.run( + [sys.executable, ENTRYPOINT], + input=json.dumps(payload), + capture_output=True, + text=True, + env=merged_env, + ) + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + result = run_entrypoint( + {"last_assistant_message": LONG_MESSAGE}, + {"CATSTACK_HOOK_MODE_DIU_STOP": "warn"}, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + rendered = json.loads(result.stdout) + self.assertIn( + "Apply diu", + rendered["hookSpecificOutput"]["additionalContext"], + ) + + def test_mode_stop_still_blocks_by_default(self) -> None: + result = run_entrypoint({"last_assistant_message": LONG_MESSAGE}, {}) + + self.assertEqual(2, result.returncode) + self.assertIn("Apply diu", result.stderr) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = run_entrypoint( + { + "session_id": "diu-stop-sdk-mode", + "last_assistant_message": LONG_MESSAGE, + }, + { + "CATSTACK_HOOK_METRICS_DIR": tmp, + "CATSTACK_HOOK_MODE_DIU_STOP": "warn", + }, + ) + rows = [ + json.loads(line) + for file in Path(tmp).glob("events-*.jsonl") + for line in file.read_text(encoding="utf-8").splitlines() + ] + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(rows)) + self.assertEqual("diu-stop", rows[0]["hook"]) + self.assertEqual("diu-stop.word-limit", rows[0]["rule_id"]) + self.assertEqual("warn", rows[0]["mode"]) + self.assertEqual("override", rows[0]["mode_source"]) + self.assertEqual("warned", rows[0]["action"]) + + +if __name__ == "__main__": + unittest.main() From e7e865bad323449e6f48b5b2efd03cbc51efdb7e Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:42:23 +0000 Subject: [PATCH 16/17] =?UTF-8?q?invoker:=20wf-1789406897817-36/verify-hoo?= =?UTF-8?q?k-diu-stop=20=E2=80=94=20Run=20the=20deterministic=20proof=20fo?= =?UTF-8?q?r=20put=20the=20diu-stop=20hook=20onto=20the=20shared=20hook=20?= =?UTF-8?q?code.=20Review=20claim:=20The=20proof=20exits=200=20only=20when?= =?UTF-8?q?=20put=20the=20diu-stop=20hook=20onto=20the=20shared=20hook=20c?= =?UTF-8?q?ode=20holds.=20Review=20lane:=20proof=20Safety=20invariant:=20P?= =?UTF-8?q?roof=20only;=20it=20changes=20no=20product=20behavior.=20Effect?= =?UTF-8?q?iveness=20measurement:=20The=20exit=20status=20of=20`python3=20?= =?UTF-8?q?-m=20unittest=20discover=20-s=20engine/hooks/diu-stop/tests`=20?= =?UTF-8?q?is=20the=20signal=20for=20this=20slice.=20Slice=20rationale:=20?= =?UTF-8?q?One=20proof=20unit=20for=20this=20workflow.=20Architectural=20e?= =?UTF-8?q?ffect:=20None;=20verification=20only.=20Goal:=20Prove=20put=20t?= =?UTF-8?q?he=20diu-stop=20hook=20onto=20the=20shared=20hook=20code=20with?= =?UTF-8?q?=20one=20deterministic=20run.=20Motivation:=20Each=20workflow?= =?UTF-8?q?=20carries=20its=20own=20proof=20so=20a=20reviewer=20can=20trus?= =?UTF-8?q?t=20the=20slice=20alone.=20Alternative=20considerations:=20Manu?= =?UTF-8?q?al=20inspection=20was=20set=20aside=20as=20non-deterministic.?= =?UTF-8?q?=20Implementation=20details:=20Execute=20the=20proof=20as=20a?= =?UTF-8?q?=20terminal=20gate.=20Non-goals:=20No=20product=20edits=20here.?= =?UTF-8?q?=20Layer:=20e2e=5Fregression=20Feature=20state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 26d6f94a-7646-4bba-a249-4e63e35f3a4c From 0343ab734386a71e2c2db0b56a43f2d92e05fded Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 23:43:05 +0000 Subject: [PATCH 17/17] =?UTF-8?q?invoker:=20wf-1789406897817-36/scrub-hand?= =?UTF-8?q?off-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confi?= =?UTF-8?q?rming=20no=20ephemeral=20handoff=20files=20were=20left=20behind?= =?UTF-8?q?.=20Review=20claim:=20The=20workflow=20leaves=20no=20ephemeral?= =?UTF-8?q?=20handoff=20files=20in=20the=20tree.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Read-only;=20it=20never=20deletes=20fi?= =?UTF-8?q?les,=20alters=20the=20index,=20or=20commits=20caller=20work.=20?= =?UTF-8?q?Effectiveness=20measurement:=20A=20non-zero=20exit=20when=20eph?= =?UTF-8?q?emeral=20handoff=20files=20remain=20is=20the=20signal.=20Slice?= =?UTF-8?q?=20rationale:=20One=20unit:=20the=20hygiene=20gate.=20Architect?= =?UTF-8?q?ural=20effect:=20None.=20Goal:=20Confirm=20no=20ephemeral=20han?= =?UTF-8?q?doff=20files=20remain=20after=20every=20other=20task=20finishes?= =?UTF-8?q?.=20Motivation:=20Ephemeral=20inter-task=20files=20leak=20into?= =?UTF-8?q?=20the=20diff=20and=20read=20as=20part=20of=20the=20change.=20A?= =?UTF-8?q?lternative=20considerations:=20Manual=20inspection=20was=20set?= =?UTF-8?q?=20aside=20as=20non-deterministic.=20Implementation=20details:?= =?UTF-8?q?=20Run=20scripts/scrub-handoff-artifacts.sh=20in=20check=20mode?= =?UTF-8?q?.=20Non-goals:=20No=20deletion,=20no=20index=20changes,=20no=20?= =?UTF-8?q?commits.=20Layer:=20e2e=5Fregression=20Feature=20state:=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: d41f4da8-d2ba-4eec-847c-65460155a619