From 027cc3572674f59e7f3c815341bd8ea880fb0dfc Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Wed, 16 Sep 2026 07:07:32 +0000 Subject: [PATCH 01/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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/19] =?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 83cce91a204e3a7b969e6ea75872f14b7e15076f Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 05:49:19 +0000 Subject: [PATCH 07/19] =?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 Exit code: 1 Invoker-Finalize-Id: d1bf500d-c8f0-47fc-8c0d-c636c7506b4c From d70c1f2910ea1b629af102e85939a32b55938d08 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:07:46 +0000 Subject: [PATCH 08/19] =?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: publish-approved-fix --- engine/hooks/_sdk/runtime.py | 5 +- engine/hooks/cat-mode-default/README.md | 19 ++- .../claude_pretooluse_agent.py | 56 ++++---- .../cat-mode-default/claude_prompt_submit.py | 32 ++--- engine/hooks/cat-mode-default/detect.py | 66 +++++++++ .../cat-mode-default/tests/test_agent_hook.py | 31 ++++- .../cat-mode-default/tests/test_hooks.py | 36 ++++- .../tests/test_hooks_sdk_mode.py | 129 ++++++++++++++++++ 8 files changed, 308 insertions(+), 66 deletions(-) create mode 100644 engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/_sdk/runtime.py b/engine/hooks/_sdk/runtime.py index d1ea4351..9d7b35b8 100644 --- a/engine/hooks/_sdk/runtime.py +++ b/engine/hooks/_sdk/runtime.py @@ -19,6 +19,7 @@ def run_hook( harness: str, detect: Callable[[dict[str, object]], list[Finding]], hook_event_name: str | None = None, + render_fn: Callable[[str, str, str, list[Finding]], tuple[str, str, int]] = render, ) -> NoReturn: started = time.monotonic() try: @@ -26,7 +27,7 @@ def run_hook( 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( + stdout_text, _stderr_text, _exit_code = render_fn( harness, hook_event_name or "", "warn", @@ -56,7 +57,7 @@ def run_hook( event_rows = write_events(hook, harness, event, findings, mode, mode_source, duration_ms) if event_rows: followup.update_followups(hook, harness, event, event_rows, mode, mode_source, sys.stderr) - stdout_text, stderr_text, exit_code = render(harness, hook_event_name, mode, findings) + stdout_text, stderr_text, exit_code = render_fn(harness, hook_event_name, mode, findings) if stdout_text: sys.stdout.write(stdout_text) if stderr_text: diff --git a/engine/hooks/cat-mode-default/README.md b/engine/hooks/cat-mode-default/README.md index dc571635..d35e1ce6 100644 --- a/engine/hooks/cat-mode-default/README.md +++ b/engine/hooks/cat-mode-default/README.md @@ -60,13 +60,22 @@ It stays silent when the flag is off or when the prompt already mentions cat-mode anywhere (a parent that told the subagent to read it gets no second copy). Same flag resolution as the prompt hook. +The shared registry keeps this hook in `warn` mode. Set +`CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT=off|warn|stop` for a machine-local +override; `stop` turns the `PreToolUse` (Agent) companion into a real block +(exit 2) instead of rewriting the subagent's prompt. Detection or metrics +failures allow the harness action. + ## Files -- `detect.py`: flag resolution, typed `/cat-mode` detection, context text. -- `claude_prompt_submit.py`: the Claude entrypoint; fail-open, never denies. -- `claude.prompt.hook.json`: settings fragment `install_claude_hook.py` merges. -- `claude_pretooluse_agent.py` + `claude.agent.hook.json`: the `PreToolUse` - (`Agent`) companion that carries the default into subagent prompts. +- `detect.py`: flag resolution, typed `/cat-mode` detection, context text, + and `detect(event)`, the SDK entrypoint returning `Finding` objects. +- `claude_prompt_submit.py` / `claude_pretooluse_agent.py`: thin calls into + `engine/hooks/_sdk/runtime.py`. The agent entrypoint supplies its own + render function so a fired finding still becomes `updatedInput` rather + than the shared renderer's generic `additionalContext`. +- `claude.prompt.hook.json` / `claude.agent.hook.json`: settings fragments + `install_claude_hook.py` merges. - `tests/fixtures/*.json`: one scenario each (fires / silent) with the environment, optional `.env` content, and payload. - `tests/fixtures/agent_*.json`: the same for the Agent-tool companion. diff --git a/engine/hooks/cat-mode-default/claude_pretooluse_agent.py b/engine/hooks/cat-mode-default/claude_pretooluse_agent.py index 28731a8d..e56d07b3 100644 --- a/engine/hooks/cat-mode-default/claude_pretooluse_agent.py +++ b/engine/hooks/cat-mode-default/claude_pretooluse_agent.py @@ -1,40 +1,44 @@ #!/usr/bin/env python3 -"""Claude Code PreToolUse (Agent): carry the cat-mode default into subagents. +"""Claude Code PreToolUse (Agent) entrypoint for cat-mode-default. -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. +Renders differently from the shared SDK shape: a fired finding here replaces +the whole tool_input via `hookSpecificOutput.updatedInput` (the finding's +evidence is the JSON-encoded replacement tool_input), not the generic +`additionalContext`. Off, silent, and stop still use the shared renderer, so +a registry override to stop blocks the subagent's tool call the normal way. """ 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 render import render as default_render # noqa: E402 +from runtime import run_hook # noqa: E402 + + +def _render_agent_prompt(harness, hook_event_name, mode, findings): + if mode == "off" or mode == "stop" or not findings: + return default_render(harness, hook_event_name, mode, findings) + updated_input = json.loads(findings[0].evidence) + return ( + json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": updated_input, + } + }) + "\n", + "", + 0, + ) 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", render_fn=_render_agent_prompt) 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 49fb2d5a..032d341e 100644 --- a/engine/hooks/cat-mode-default/detect.py +++ b/engine/hooks/cat-mode-default/detect.py @@ -23,8 +23,16 @@ """ from __future__ import annotations +import hashlib +import json 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" @@ -196,3 +204,61 @@ 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 + + +RULE_PROMPT_CONTEXT = "cat-mode-default.prompt-context" +RULE_AGENT_PROMPT = "cat-mode-default.agent-prompt" + + +def _subject_hash(text: str) -> str: + return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:16] + + +def _is_tool_event(event: dict) -> bool: + return "tool_input" in event or event.get("tool_name") is not None + + +def _detect_prompt(event: dict) -> list[Finding]: + context = decide(event) + if context is None: + return [] + prompt = extract_prompt_text(event) + return [ + Finding( + rule_id=RULE_PROMPT_CONTEXT, + subject=f"prompt:{_subject_hash(prompt)}", + message=context, + evidence=prompt, + ) + ] + + +def _detect_agent(event: dict) -> list[Finding]: + if event.get("tool_name") not in AGENT_TOOL_NAMES: + return [] + updated = agent_updated_input(event) + if updated is None: + return [] + tool_input = event.get("tool_input") + original_prompt = tool_input.get("prompt") if isinstance(tool_input, dict) else None + return [ + Finding( + rule_id=RULE_AGENT_PROMPT, + subject=f"agent-prompt:{_subject_hash(str(original_prompt or ''))}", + message=agent_prefix_line(installed_skill_path()), + evidence=json.dumps(updated), + ) + ] + + +def detect(event: dict) -> list[Finding]: + """Shared detector for both the UserPromptSubmit and the PreToolUse + (Agent) entrypoints. A PreToolUse-shaped event (any payload carrying + tool_name/tool_input) only ever goes through the agent branch -- it + never falls through to prompt detection, even for a tool other than + Agent, since a tool_input's nested prompt is not a user prompt.""" + if not isinstance(event, dict): + return [] + if _is_tool_event(event): + return _detect_agent(event) + return _detect_prompt(event) 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..c4427043 100644 --- a/engine/hooks/cat-mode-default/tests/test_agent_hook.py +++ b/engine/hooks/cat-mode-default/tests/test_agent_hook.py @@ -13,6 +13,7 @@ import json import os import sys +import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from unittest.mock import patch @@ -41,7 +42,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() @@ -147,20 +151,39 @@ def test_unrelated_prompt_does_not_count(self) -> None: class FailOpenCase(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + 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() + 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 dbd86e5d..61b0a782 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() @@ -253,19 +260,38 @@ def test_flags_missing_install_instead_of_dead_path(self) -> None: class FailOpenCase(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + 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() + 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..ebdac875 --- /dev/null +++ b/engine/hooks/cat-mode-default/tests/test_hooks_sdk_mode.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""SDK migration proof: registry mode drives the response, and every finding +writes one event row with the hook's rule_id. + +Run: python3 -m unittest discover -s engine/hooks/cat-mode-default/tests -v +""" +from __future__ import annotations + +import io +import json +import os +import sys +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_pretooluse_agent # noqa: E402 +import claude_prompt_submit # noqa: E402 +from test_hooks import REAL_PROMPT, Sandbox # noqa: E402 + +AGENT_TOOL_INPUT = { + "description": "Reflect: hidden_stock sessions", + "subagent_type": "general-purpose", + "prompt": "Run reflect steps 1-4 over the last three hidden_stock sessions and report Accepted / Backlog / Rejected.", +} + + +def run_main(main, payload: dict, environ: dict, home: str) -> tuple[int, str, str]: + out = io.StringIO() + err = io.StringIO() + with patch.object(sys, "stdin", io.StringIO(json.dumps(payload))): + 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): + try: + main() + except SystemExit as exc: + return int(exc.code or 0), out.getvalue(), err.getvalue() + return 0, out.getvalue(), err.getvalue() + + +class HooksSdkModeTest(unittest.TestCase): + def setUp(self) -> None: + self.box = Sandbox() + + def tearDown(self) -> None: + self.box.cleanup() + + def test_warn_override_changes_stop_response_to_warning(self) -> None: + payload = { + "hook_event_name": "PreToolUse", + "tool_name": "Agent", + "cwd": self.box.cwd, + "tool_input": dict(AGENT_TOOL_INPUT), + } + stop_code, stop_out, stop_err = run_main( + claude_pretooluse_agent.main, + payload, + self.box.environ({"CATSTACK_CAT_MODE_DEFAULT": "1", "CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT": "stop"}), + self.box.home, + ) + warn_code, warn_out, warn_err = run_main( + claude_pretooluse_agent.main, + payload, + self.box.environ({"CATSTACK_CAT_MODE_DEFAULT": "1", "CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT": "warn"}), + self.box.home, + ) + + 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) + updated = json.loads(warn_out)["hookSpecificOutput"]["updatedInput"] + self.assertTrue(updated["prompt"].startswith("cat-mode default is on: read and apply ")) + self.assertTrue(updated["prompt"].endswith(AGENT_TOOL_INPUT["prompt"])) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + prompt_environ = self.box.environ({"CATSTACK_CAT_MODE_DEFAULT": "1", "CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT": "warn"}) + prompt_code, prompt_out, prompt_err = run_main( + claude_prompt_submit.main, + {"hook_event_name": "UserPromptSubmit", "prompt": REAL_PROMPT, "cwd": self.box.cwd}, + prompt_environ, + self.box.home, + ) + agent_code, agent_out, agent_err = run_main( + claude_pretooluse_agent.main, + { + "hook_event_name": "PreToolUse", + "tool_name": "Agent", + "cwd": self.box.cwd, + "tool_input": dict(AGENT_TOOL_INPUT), + }, + self.box.environ({"CATSTACK_CAT_MODE_DEFAULT": "1", "CATSTACK_HOOK_MODE_CAT_MODE_DEFAULT": "warn"}), + self.box.home, + ) + + self.assertEqual(0, prompt_code) + self.assertEqual("", prompt_err) + self.assertIn("cat-mode default is on", prompt_out) + self.assertEqual(0, agent_code) + self.assertEqual("", agent_err) + self.assertIn("updatedInput", agent_out) + + metrics_dir = Path(prompt_environ["CATSTACK_HOOK_METRICS_DIR"]) + today = datetime.now(timezone.utc).date().isoformat() + event_file = metrics_dir / 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(2, len(finding_rows)) + self.assertEqual( + ["cat-mode-default.agent-prompt", "cat-mode-default.prompt-context"], + sorted(row["rule_id"] for row in finding_rows), + ) + self.assertTrue(all(row["hook"] == "cat-mode-default" 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)) + self.assertTrue(all(row["action"] == "warned" for row in finding_rows)) + + +if __name__ == "__main__": + unittest.main() From 1dd88c492017f6abc460eed49fb0e3dca0d37449 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:08:30 +0000 Subject: [PATCH 09/19] =?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: 5699f69c-cb1c-4890-b373-d374ac8be49f From 2d6cb5124b963575cc3fd9e61969a2d10cb244cd Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:09:16 +0000 Subject: [PATCH 10/19] =?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: 946f5c14-9399-42ca-a370-bbe5a0fedc02 From 8ef7563d2633559267d1ac452a708f8516bfc923 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:16:42 +0000 Subject: [PATCH 11/19] =?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 Exit code: 1 Invoker-Finalize-Id: 1e2b1721-154a-454f-a751-5d3992277030 From 4a433b4f67cc9e924821c976a7ecdbdfa0e6740b Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:25:45 +0000 Subject: [PATCH 12/19] =?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: publish-approved-fix --- .../claude_pretooluse.py | 49 ++------ .../hooks/categorical-scope-guard/detect.py | 62 +++++++++ .../tests/test_hooks.py | 2 +- .../tests/test_hooks_sdk_mode.py | 119 ++++++++++++++++++ 4 files changed, 189 insertions(+), 43 deletions(-) create mode 100644 engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/categorical-scope-guard/claude_pretooluse.py b/engine/hooks/categorical-scope-guard/claude_pretooluse.py index 673c734c..5b4228cb 100644 --- a/engine/hooks/categorical-scope-guard/claude_pretooluse.py +++ b/engine/hooks/categorical-scope-guard/claude_pretooluse.py @@ -1,55 +1,20 @@ #!/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 PreToolUse entrypoint for categorical-scope-guard: a thin call into +the shared hook runtime, which applies the registry mode and writes events.""" 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/detect.py b/engine/hooks/categorical-scope-guard/detect.py index bc0f1912..8f5cf4a2 100644 --- a/engine/hooks/categorical-scope-guard/detect.py +++ b/engine/hooks/categorical-scope-guard/detect.py @@ -14,15 +14,29 @@ from __future__ import annotations import sys +import hashlib import json import os import re from dataclasses import dataclass, field +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 + HIT = "hit" CLEAN = "clean" UNCHECKED = "unchecked" +SHELL_LIKE_TOOL_NAMES = ( + "Bash", "bash", "shell", "Shell", "exec", "exec_command", + "run_terminal_cmd", "local_shell", "run_command", "shell_call", +) + +RULE_NARROWED_MUTATION = "categorical-scope-guard.narrowed-mutation" +RULE_UNREADABLE = "categorical-scope-guard.unreadable" + LIVE_TURNS = 4 MAX_SCAN_BYTES = 64 * 1024 * 1024 CHUNK_BYTES = 1024 * 1024 @@ -872,3 +886,51 @@ 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 "")) + + +def _event_tool_name(event: dict) -> str: + return str( + event.get("tool_name") + or event.get("toolName") + or event.get("tool") + or event.get("name") + or "" + ) + + +def _event_command(event: dict) -> str: + tool_input = event.get("tool_input") or event.get("toolInput") or {} + command = tool_input.get("command") if isinstance(tool_input, dict) else None + return command if isinstance(command, str) else "" + + +def _finding_subject(event: dict, command: str) -> str: + for key in ("tool_call_id", "toolCallId", "tool_use_id", "toolUseId", "id"): + value = event.get(key) + if isinstance(value, str) and value: + return f"tool-call:{value}" + return f"command:{hashlib.sha256(command.encode('utf-8')).hexdigest()[:16]}" + + +def detect(event: dict) -> list[Finding]: + """Return findings for the shared hook runtime. + + Only a shell-like tool call is classified, matching the guard the old + entrypoint applied before ever calling `decide_payload`. HIT and + UNCHECKED both become a finding -- this hook fails closed, so an + unreadable case still blocks in `stop` mode.""" + if not isinstance(event, dict) or _event_tool_name(event) not in SHELL_LIKE_TOOL_NAMES: + return [] + verdict = decide_payload(event) + if verdict.outcome == CLEAN: + return [] + rule_id = RULE_NARROWED_MUTATION if verdict.outcome == HIT else RULE_UNREADABLE + command = _event_command(event) + return [ + Finding( + rule_id=rule_id, + subject=_finding_subject(event, command), + message=verdict.message, + evidence=command, + ) + ] diff --git a/engine/hooks/categorical-scope-guard/tests/test_hooks.py b/engine/hooks/categorical-scope-guard/tests/test_hooks.py index bd53c461..cb2ebff3 100644 --- a/engine/hooks/categorical-scope-guard/tests/test_hooks.py +++ b/engine/hooks/categorical-scope-guard/tests/test_hooks.py @@ -381,7 +381,7 @@ def test_non_shell_tool_is_ignored(self): def test_garbage_payload_fails_open_with_log(self): result = subprocess.run([sys.executable, ENTRYPOINT], input="not json", capture_output=True, text=True) self.assertEqual(result.returncode, 0) - self.assertIn("not JSON", result.stderr) + self.assertIn("catstack-hook-error categorical-scope-guard: JSONDecodeError", result.stderr) if __name__ == "__main__": 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..9f028f68 --- /dev/null +++ b/engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from contextlib import contextmanager, 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 + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +FIXTURES = os.path.join(HERE, "fixtures") +sys.path.insert(0, HOOK_DIR) + +import claude_pretooluse # noqa: E402 + +ALL_TASKS_FIRST = "can you make all tasks use claude and local executor" + + +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: + return {"type": "user", "message": {"role": "user", "content": text}} + + +def write_transcript(entries: list[dict]) -> 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 + + +@contextmanager +def isolated_hook_env(**updates: str): + old = dict(os.environ) + try: + os.environ.update(updates) + yield + finally: + os.environ.clear() + os.environ.update(old) + + +def run_claude_pretooluse(payload: dict) -> tuple[int, str, str]: + stdin = StringIO(json.dumps(payload)) + stdout = StringIO() + stderr = StringIO() + with patch.object(sys, "stdin", stdin), redirect_stdout(stdout), redirect_stderr(stderr): + try: + claude_pretooluse.main() + except SystemExit as exc: + return int(exc.code or 0), stdout.getvalue(), stderr.getvalue() + return 0, stdout.getvalue(), stderr.getvalue() + + +def event_rows(metrics_dir: Path) -> list[dict[str, object]]: + today = datetime.now(timezone.utc).date().isoformat() + path = metrics_dir / f"events-{today}.jsonl" + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def blocking_payload(transcript_path: str) -> dict[str, object]: + return { + "hook_event_name": "PreToolUse", + "session_id": "cat-scope-sdk-mode", + "tool_name": "Bash", + "transcript_path": transcript_path, + "tool_input": {"command": fixture("update_tasks_status_in_pending_queued.txt")}, + } + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + path = write_transcript([human(ALL_TASKS_FIRST)]) + try: + with tempfile.TemporaryDirectory() as tmp: + with isolated_hook_env( + CATSTACK_HOOK_METRICS_DIR=tmp, + CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD="warn", + ): + code, stdout, stderr = run_claude_pretooluse(blocking_payload(path)) + finally: + os.unlink(path) + + self.assertEqual(0, code) + self.assertEqual("", stderr) + body = json.loads(stdout) + output = body["hookSpecificOutput"] + self.assertEqual("PreToolUse", output["hookEventName"]) + self.assertIn(ALL_TASKS_FIRST, output["additionalContext"]) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + path = write_transcript([human(ALL_TASKS_FIRST)]) + try: + with tempfile.TemporaryDirectory() as tmp: + with isolated_hook_env(CATSTACK_HOOK_METRICS_DIR=tmp): + code, _stdout, stderr = run_claude_pretooluse(blocking_payload(path)) + rows = event_rows(Path(tmp)) + finally: + os.unlink(path) + + self.assertEqual(2, code) + self.assertIn(ALL_TASKS_FIRST, stderr) + finding_rows = [row for row in rows if row["action"] == "stopped"] + self.assertEqual(1, len(finding_rows)) + self.assertEqual("categorical-scope-guard.narrowed-mutation", finding_rows[0]["rule_id"]) + self.assertTrue(all(row["hook"] == "categorical-scope-guard" for row in finding_rows)) + + +if __name__ == "__main__": + unittest.main() From 73966db11f1285734f278766735d7e59f8c6f102 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:26:28 +0000 Subject: [PATCH 13/19] =?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: 01877aec-305b-4f1e-a159-7e7dceb4eb8a From 35fc0ae52a0c10bb6a576e5364c14fbe604d3d05 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:27:10 +0000 Subject: [PATCH 14/19] =?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: 497f3e6c-8be6-461a-a001-72f4932e75bf From 266591b8f34d33b1747ad1f5862d151fa6763c96 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:31:35 +0000 Subject: [PATCH 15/19] =?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: 04f511ed-29f7-452a-8036-8f716b1ef99c From 1706a52a35c05ce337fc1cf9e98a61abc4b04207 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 17 Sep 2026 06:36:24 +0000 Subject: [PATCH 16/19] Put the demo-freeze hook onto the shared hook code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit (266591b) recorded this task as done but its diff was empty — the remote Codex run hit a usage-limit error before writing any code, so claude_pretooluse_check.py never actually moved onto engine/hooks/_sdk/runtime.py. claude_pretooluse_check.py now exposes detect(event), which returns a Finding (rule_id demo-freeze.frozen-path) for the first frozen-path match, and main() is a thin call to run_hook("demo-freeze", "claude", detect, "PreToolUse"), so the registry's mode (still "stop") and event writing are handled by the shared runtime instead of hardcoded here. tests/test_hooks.py: run_hook always calls sys.exit, so test_no_marker_fails_open now wraps its main() call in try/except SystemExit, matching the pattern already used elsewhere in this test file's run_hook() helper. tests/test_hooks_sdk_mode.py: new, asserts CATSTACK_HOOK_MODE_DEMO_FREEZE=warn turns a stop into a warning, and that a stop writes one event row tagged with the hook's rule_id. --- .../demo-freeze/claude_pretooluse_check.py | 35 ++++-- engine/hooks/demo-freeze/tests/test_hooks.py | 5 +- .../demo-freeze/tests/test_hooks_sdk_mode.py | 106 ++++++++++++++++++ 3 files changed, 134 insertions(+), 12 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..399c7985 100755 --- a/engine/hooks/demo-freeze/claude_pretooluse_check.py +++ b/engine/hooks/demo-freeze/claude_pretooluse_check.py @@ -16,14 +16,21 @@ 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(): try: @@ -48,28 +55,34 @@ 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 {} +def detect(event): + tool_input = event.get("tool_input") or {} target = ( tool_input.get("file_path") or tool_input.get("path") or tool_input.get("notebook_path") ) 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=pattern, + )] + 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..7f29969c --- /dev/null +++ b/engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from io import StringIO +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from unittest.mock import patch + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +sys.path.insert(0, HOOK_DIR) + +import claude_pretooluse_check # noqa: E402 + +TARGET = "/tmp/demo/call.html" + + +@contextmanager +def isolated_hook_env(**updates: str): + old = dict(os.environ) + try: + os.environ.update(updates) + yield + finally: + os.environ.clear() + os.environ.update(old) + + +@contextmanager +def frozen_marker(*lines: str): + marker = tempfile.NamedTemporaryFile(mode="w", suffix=".freeze", delete=False) + marker.write("\n".join(lines) + "\n") + marker.close() + try: + with patch.object(claude_pretooluse_check, "MARKER", marker.name): + yield + finally: + os.unlink(marker.name) + + +def run_claude_pretooluse(payload: dict) -> tuple[int, str, str]: + stdin = StringIO(json.dumps(payload)) + stdout = StringIO() + stderr = StringIO() + with patch.object(sys, "stdin", stdin), redirect_stdout(stdout), redirect_stderr(stderr): + try: + claude_pretooluse_check.main() + except SystemExit as exc: + return int(exc.code or 0), stdout.getvalue(), stderr.getvalue() + return 0, stdout.getvalue(), stderr.getvalue() + + +def event_rows(metrics_dir: Path) -> list[dict[str, object]]: + today = datetime.now(timezone.utc).date().isoformat() + path = metrics_dir / f"events-{today}.jsonl" + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def blocking_payload() -> dict[str, object]: + return { + "hook_event_name": "PreToolUse", + "session_id": "demo-freeze-sdk-mode", + "tool_name": "Edit", + "tool_input": {"file_path": TARGET}, + } + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + with frozen_marker("/tmp/demo/"): + with tempfile.TemporaryDirectory() as tmp: + with isolated_hook_env( + CATSTACK_HOOK_METRICS_DIR=tmp, + CATSTACK_HOOK_MODE_DEMO_FREEZE="warn", + ): + code, stdout, stderr = run_claude_pretooluse(blocking_payload()) + + self.assertEqual(0, code) + self.assertEqual("", stderr) + body = json.loads(stdout) + output = body["hookSpecificOutput"] + self.assertEqual("PreToolUse", output["hookEventName"]) + self.assertIn("Demo surface frozen", output["additionalContext"]) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + with frozen_marker("/tmp/demo/"): + with tempfile.TemporaryDirectory() as tmp: + with isolated_hook_env(CATSTACK_HOOK_METRICS_DIR=tmp): + code, _stdout, stderr = run_claude_pretooluse(blocking_payload()) + rows = event_rows(Path(tmp)) + + self.assertEqual(2, code) + self.assertIn("Demo surface frozen", stderr) + finding_rows = [row for row in rows if row["action"] == "stopped"] + self.assertEqual(1, len(finding_rows)) + self.assertEqual("demo-freeze.frozen-path", finding_rows[0]["rule_id"]) + self.assertTrue(all(row["hook"] == "demo-freeze" for row in finding_rows)) + + +if __name__ == "__main__": + unittest.main() From eef1433e894b56af92781e604509cfd0eff5c4bf Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:39:17 +0000 Subject: [PATCH 17/19] =?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: 0 Invoker-Finalize-Id: publish-approved-fix From 3e2864d6d312011c97b56dea6ba8357602891de9 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:40:00 +0000 Subject: [PATCH 18/19] =?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: 6fc62b2e-ec42-4f6e-bf06-8d3cff19523a From 3030ad91ff5f4ba1db6a4c2a678a78150a056d19 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 06:40:44 +0000 Subject: [PATCH 19/19] =?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: fe00f104-876a-4ea0-af6a-06704d7b70c3