From 7662a3daeefe3402cfd629b2bdaa743c6510dfd5 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 23:14:27 -0700 Subject: [PATCH 1/9] Search saved memory both ways before a deep dive (#740) An answer already written down costs one read. Add the learned rule and a test that pins it. Change-Id: I8831f9085cdb5fe672f820dbe96186ca5dc74b29 Co-authored-by: Claude Opus 5 (1M context) --- corpus/CLAUDE.learned.md | 1 + tests/test_check_memory_first_rule.py | 39 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/test_check_memory_first_rule.py diff --git a/corpus/CLAUDE.learned.md b/corpus/CLAUDE.learned.md index a0915658..3ba236dd 100644 --- a/corpus/CLAUDE.learned.md +++ b/corpus/CLAUDE.learned.md @@ -45,3 +45,4 @@ Engine-only install drops this file; reflect Accepted global rules land here. - Before asking me whether to run a script, read its whole body, not its header comment, usage text, or `--dry-run` output, and name in the question every process it kills, file it deletes, and service it restarts. A header states what the author meant; the body is what runs, so a consent question built from the header asks me to approve something other than what will happen. No known prior art. - When finishing a merge or rebase, reconcile three things before calling it resolved: what each side declared (its commit messages and PR description: the intent, and the tests it names), what each side's code actually does, and what the resolved result does. All three must agree: every declared intent is still visible in `git diff -- `, and every test either side named still passes on the result. A merge with no textual conflict can still break one side's declared behavior, so a clean merge is checked the same way. Never take one side's whole file (`git checkout --theirs` / `--ours `, or copying the file from one branch) unless the other side's changes to that file are provably empty; taking a whole side silently reverts what the other branch added, including hunks that never conflicted (git-checkout(1), `--ours`/`--theirs`: https://git-scm.com/docs/git-checkout#Documentation/git-checkout.txt---ours). When a declaration and its code disagree, or the two sides' intents contradict, stop and flag it to me instead of choosing: run `why` on the conflicting lines (`git log -L` or `git log -S` plus each commit's PR body) and apply `principle-prove-it`, so the flag names the two commits or PRs, quotes the declared intent next to the code, and pastes the failing test output. Brun, Holmes, Ernst and Notkin, "Proactive Detection of Collaboration Conflicts" (ESEC/FSE 2011, https://doi.org/10.1145/2025113.2025139), across Git, Perl5 and Voldemort, found "that 33% of merges that were reported to contain no textual conflicts by the VCS in fact contained higher-order conflicts" (build or test failures), so a clean textual merge is not evidence the two intentions still hold. - An item I approved as "fix it properly later" stays in every status update and every final summary, marked open, until it is done or I drop it. A recap that lists only what finished makes the deferred half disappear, and the next session never learns it was owed. No known prior art. +- Before spending more than a couple of tool calls on an infrastructure, security, or "does X exist" question, search saved memory for the topic and for its reverse framing: the fix as well as the symptom, "X is off" as well as "X is missing". An answer already written down costs one read; rediscovering it costs the whole investigation. No known prior art. diff --git a/tests/test_check_memory_first_rule.py b/tests/test_check_memory_first_rule.py new file mode 100644 index 00000000..15712b92 --- /dev/null +++ b/tests/test_check_memory_first_rule.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Pin the learned rule: search saved memory, both ways, before a deep dive. + +A detector cannot judge this reliably, so the test proves the rule is present +with its trigger, required action, and reason; deleting or hollowing it fails here. +""" +import os +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LEARNED = os.path.join(REPO_ROOT, "corpus", "CLAUDE.learned.md") + + +def working_style_section(): + with open(LEARNED, encoding="utf-8") as handle: + text = handle.read() + start = text.index("# Working style") + end = text.find("\n# ", start + 1) + return text[start:] if end == -1 else text[start:end] + + +def bullet_starting(prefix): + for line in working_style_section().splitlines(): + if line.startswith(prefix): + return line + raise AssertionError(f"no working-style rule starts with {prefix!r}") + + +class TestCheckMemoryFirst(unittest.TestCase): + PREFIX = "- Before spending more than a couple of tool calls" + + def test_rule_searches_memory_both_ways(self): + rule = bullet_starting(self.PREFIX) + self.assertIn("search saved memory", rule) + self.assertIn("reverse framing", rule) + + +if __name__ == "__main__": + unittest.main() From 4c4fcd58d6f744f5d7c07fc2cd7631d5717f191c Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 23:21:05 -0700 Subject: [PATCH 2/9] [Hook architecture](15) Put the categorical-scope-guard hook onto the shared hook code (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * invoker: wf-1789406883560-32/implement-hook-build-the-lever — 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. 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 * invoker: wf-1789406883560-32/verify-hook-build-the-lever — Run the deterministic proof for put the build-the-lever hook onto the shared hook code. Review claim: The proof exits 0 only when put the build-the-lever hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/build-the-lever/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the build-the-lever hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 3fe2349d-7d5a-4cf2-a6ab-f4c370ef2d0f * invoker: wf-1789406883560-32/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 70147de4-ee65-46de-90da-48ab84cce9d0 * invoker: wf-1789406886887-33/implement-hook-cat-mode-default — 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. 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 * invoker: wf-1789406886887-33/verify-hook-cat-mode-default — Run the deterministic proof for put the cat-mode-default hook onto the shared hook code. Review claim: The proof exits 0 only when put the cat-mode-default hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/cat-mode-default/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the cat-mode-default hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 8132f734-9bf7-4d89-8c3c-56bc4b969d79 * invoker: wf-1789406886887-33/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 74c962cc-f188-4db3-be0c-c3da68c20e9a * invoker: wf-1789406891093-34/implement-hook-categorical-scope-guard — 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. Solution: Put the categorical-scope-guard hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The categorical-scope-guard entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a change that covers only part of an all request. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/categorical-scope-guard/claude_pretooluse.py, engine/hooks/categorical-scope-guard/detect.py, engine/hooks/categorical-scope-guard/install_claude_hook.py, engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/categorical-scope-guard/claude_pretooluse.py: modify - engine/hooks/categorical-scope-guard/detect.py: modify - engine/hooks/categorical-scope-guard/install_claude_hook.py: modify - engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0. - With CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: 1540cec2-0149-477b-b36f-03c02bb58574 * invoker: wf-1789406891093-34/verify-hook-categorical-scope-guard — Run the deterministic proof for put the categorical-scope-guard hook onto the shared hook code. Review claim: The proof exits 0 only when put the categorical-scope-guard hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the categorical-scope-guard hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 30fc01e9-dbf4-4302-9c20-a786ccb160ef * invoker: wf-1789406891093-34/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 85155944-670a-40b9-a845-1252af44dacd * categorical-scope-guard: log the detector failure before returning UNCHECKED check_no_silent_hook_except flagged the broad handler in detect() because it returned a finding without writing the error to stderr. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I10e84179b9915593bd69889b7ebe3ebdaed05060 --------- Co-authored-by: Invoker Bot Co-authored-by: Claude Opus 5 (1M context) --- engine/hooks/_sdk/runtime.py | 2 +- .../hooks/categorical-scope-guard/README.md | 13 ++- .../claude_pretooluse.py | 48 +------- .../codex_pretooluse.py | 20 ++++ .../cursor_pretooluse.py | 20 ++++ .../hooks/categorical-scope-guard/detect.py | 61 ++++++++++ .../tests/test_hooks_sdk_mode.py | 105 ++++++++++++++++++ 7 files changed, 222 insertions(+), 47 deletions(-) create mode 100644 engine/hooks/categorical-scope-guard/codex_pretooluse.py create mode 100644 engine/hooks/categorical-scope-guard/cursor_pretooluse.py create mode 100644 engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/_sdk/runtime.py b/engine/hooks/_sdk/runtime.py index d1ea4351..d4db3c5c 100644 --- a/engine/hooks/_sdk/runtime.py +++ b/engine/hooks/_sdk/runtime.py @@ -25,7 +25,7 @@ def run_hook( event = json.load(sys.stdin) except json.JSONDecodeError as exc: _write_findings_file([]) - print(f"catstack-hook-error {hook}: JSONDecodeError: {exc}", file=sys.stderr) + print(f"catstack-hook-error {hook}: JSONDecodeError: hook payload is not JSON: {exc}", file=sys.stderr) stdout_text, _stderr_text, _exit_code = render( harness, hook_event_name or "", diff --git a/engine/hooks/categorical-scope-guard/README.md b/engine/hooks/categorical-scope-guard/README.md index 3eb5b2dc..2aabdff3 100644 --- a/engine/hooks/categorical-scope-guard/README.md +++ b/engine/hooks/categorical-scope-guard/README.md @@ -91,9 +91,12 @@ Do one of two things: ## Three outcomes, and the fail direction -- **HIT** — exit 2 with the message above. +- **HIT** — finding `categorical-scope-guard.partial-status-filter`. The + registry default is `stop`, so the shared runtime exits 2 with the + message above unless a local mode override lowers it to a warning. - **CLEAN** — exit 0. -- **UNCHECKED** — exit 2. **This hook fails closed.** It blocks, and says +- **UNCHECKED** — finding `categorical-scope-guard.unchecked`. **This hook + fails closed.** With the default `stop` mode it blocks, and says `UNCHECKED`, when: - the transcript path is absent, the file is missing, a line is malformed JSON (a torn final line is tolerated), the window runs past the 64 MB @@ -132,8 +135,10 @@ reason and let the user answer. ## Files -- `detect.py` — command parser, human-turn reader, `decide()`. -- `claude_pretooluse.py` — the entrypoint. +- `detect.py` — command parser, human-turn reader, `decide()`, and + `detect(event)`. +- `claude_pretooluse.py`, `cursor_pretooluse.py`, `codex_pretooluse.py` — + thin runtime entrypoints. - `claude.hook.json`, `install_claude_hook.py` — the settings merge that `install.sh` runs. - `tests/test_hooks.py`, `tests/fixtures/` — the fixtures are real commands diff --git a/engine/hooks/categorical-scope-guard/claude_pretooluse.py b/engine/hooks/categorical-scope-guard/claude_pretooluse.py index 673c734c..a3724044 100644 --- a/engine/hooks/categorical-scope-guard/claude_pretooluse.py +++ b/engine/hooks/categorical-scope-guard/claude_pretooluse.py @@ -1,55 +1,19 @@ #!/usr/bin/env python3 -"""Claude PreToolUse on Bash: block a status-narrowed mutation of a target the -live human turn quantified with all / every / each. Exits 2 with the reason on -stderr for HIT and for UNCHECKED; exits 0 for CLEAN. -""" +"""Claude Code PreToolUse entrypoint for categorical-scope-guard.""" from __future__ import annotations -import json import os import sys -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) -from detect import CLEAN, decide_payload - -SHELL_LIKE_TOOL_NAMES = ( - "Bash", "bash", "shell", "Shell", "exec", "exec_command", - "run_terminal_cmd", "local_shell", "run_command", "shell_call", -) - - -def _tool_name(payload: dict) -> str: - return str( - payload.get("tool_name") - or payload.get("toolName") - or payload.get("tool") - or payload.get("name") - or "" - ) +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 def main() -> None: - raw = sys.stdin.read() - try: - payload = json.loads(raw) - except (json.JSONDecodeError, OSError) as exc: - sys.stderr.write(f"categorical-scope-guard: hook payload is not JSON, nothing to classify: {exc}\n") - return - if not isinstance(payload, dict) or _tool_name(payload) not in SHELL_LIKE_TOOL_NAMES: - return - try: - verdict = decide_payload(payload) - except Exception as exc: - sys.stderr.write( - f"categorical-scope-guard: UNCHECKED -- the detector failed ({exc!r}) while classifying a " - "status-filtered mutation. Blocked rather than passed; drop the status filter or rephrase the command.\n" - ) - sys.exit(2) - if verdict.outcome == CLEAN: - return - sys.stderr.write(verdict.message + "\n") - sys.exit(2) + run_hook("categorical-scope-guard", "claude", detect, "PreToolUse") if __name__ == "__main__": diff --git a/engine/hooks/categorical-scope-guard/codex_pretooluse.py b/engine/hooks/categorical-scope-guard/codex_pretooluse.py new file mode 100644 index 00000000..e2f2a14d --- /dev/null +++ b/engine/hooks/categorical-scope-guard/codex_pretooluse.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Codex PreToolUse entrypoint for categorical-scope-guard.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + + +def main() -> None: + run_hook("categorical-scope-guard", "codex", detect, "PreToolUse") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/categorical-scope-guard/cursor_pretooluse.py b/engine/hooks/categorical-scope-guard/cursor_pretooluse.py new file mode 100644 index 00000000..bf6714e8 --- /dev/null +++ b/engine/hooks/categorical-scope-guard/cursor_pretooluse.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Cursor PreToolUse entrypoint for categorical-scope-guard.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from detect import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + + +def main() -> None: + run_hook("categorical-scope-guard", "cursor", detect, "PreToolUse") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/categorical-scope-guard/detect.py b/engine/hooks/categorical-scope-guard/detect.py index bc0f1912..b37566b4 100644 --- a/engine/hooks/categorical-scope-guard/detect.py +++ b/engine/hooks/categorical-scope-guard/detect.py @@ -14,14 +14,24 @@ from __future__ import annotations import sys +import hashlib import json import os import re from dataclasses import dataclass, field +from pathlib import Path + +SDK_DIR = Path(__file__).resolve().parents[1] / "_sdk" +if str(SDK_DIR) not in sys.path: + sys.path.insert(0, str(SDK_DIR)) + +from finding import Finding # noqa: E402 HIT = "hit" CLEAN = "clean" UNCHECKED = "unchecked" +RULE_PARTIAL_STATUS_FILTER = "categorical-scope-guard.partial-status-filter" +RULE_UNCHECKED = "categorical-scope-guard.unchecked" LIVE_TURNS = 4 MAX_SCAN_BYTES = 64 * 1024 * 1024 @@ -872,3 +882,54 @@ def decide_payload(payload: dict) -> Verdict: return Verdict(CLEAN) path = payload.get("transcript_path") or payload.get("transcriptPath") or "" return decide(command, lambda: read_live_window(path if isinstance(path, str) else "")) + + +SHELL_LIKE_TOOL_NAMES = ( + "Bash", "bash", "shell", "Shell", "exec", "exec_command", + "run_terminal_cmd", "local_shell", "run_command", "shell_call", +) + + +def _tool_name(payload: dict) -> str: + return str( + payload.get("tool_name") + or payload.get("toolName") + or payload.get("tool") + or payload.get("name") + or "" + ) + + +def _command_subject(payload: dict) -> str: + tool_input = payload.get("tool_input") or payload.get("toolInput") or {} + command = tool_input.get("command") if isinstance(tool_input, dict) else "" + if not isinstance(command, str): + command = "" + return "command:" + hashlib.sha256(command.encode("utf-8")).hexdigest()[:16] + + +def _finding(rule_id: str, payload: dict, message: str) -> Finding: + return Finding( + rule_id=rule_id, + subject=_command_subject(payload), + message=message, + evidence=message, + ) + + +def detect(event: dict) -> list[Finding]: + if _tool_name(event) not in SHELL_LIKE_TOOL_NAMES: + return [] + try: + verdict = decide_payload(event) + except Exception as exc: + print(f"catstack-hook-error categorical-scope-guard: {type(exc).__name__}: {exc}", file=sys.stderr) + message = ( + f"categorical-scope-guard: UNCHECKED -- the detector failed ({exc!r}) while classifying a " + "status-filtered mutation. Blocked rather than passed; drop the status filter or rephrase the command." + ) + return [_finding(RULE_UNCHECKED, event, message)] + if verdict.outcome == CLEAN: + return [] + rule_id = RULE_PARTIAL_STATUS_FILTER if verdict.outcome == HIT else RULE_UNCHECKED + return [_finding(rule_id, event, verdict.message)] diff --git a/engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py b/engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..b61c0c3b --- /dev/null +++ b/engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +FIXTURES = os.path.join(HERE, "fixtures") +ENTRYPOINT = os.path.join(HOOK_DIR, "claude_pretooluse.py") + + +def fixture(name: str) -> str: + with open(os.path.join(FIXTURES, name), encoding="utf-8") as handle: + return handle.read() + + +def human(text: str) -> dict[str, object]: + return {"type": "user", "message": {"role": "user", "content": text}} + + +def write_transcript(entries: list[dict[str, object]]) -> str: + handle = tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False, encoding="utf-8") + with handle: + for entry in entries: + handle.write(json.dumps(entry) + "\n") + return handle.name + + +def run_entrypoint(payload: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + merged_env.update(env) + return subprocess.run( + [sys.executable, ENTRYPOINT], + input=json.dumps(payload), + capture_output=True, + text=True, + env=merged_env, + ) + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + path = write_transcript([human("can you make all tasks use claude and local executor")]) + try: + result = run_entrypoint( + { + "tool_name": "Bash", + "hook_event_name": "PreToolUse", + "transcript_path": path, + "tool_input": {"command": fixture("update_tasks_status_in_pending_queued.txt")}, + }, + {"CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD": "warn"}, + ) + finally: + os.unlink(path) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + rendered = json.loads(result.stdout) + self.assertIn( + "categorical-scope-guard", + rendered["hookSpecificOutput"]["additionalContext"], + ) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + path = write_transcript([human("can you make all tasks use claude and local executor")]) + try: + with tempfile.TemporaryDirectory() as tmp: + result = run_entrypoint( + { + "tool_name": "Bash", + "hook_event_name": "PreToolUse", + "session_id": "categorical-scope-guard-sdk-mode", + "transcript_path": path, + "tool_input": {"command": fixture("update_tasks_status_in_pending_queued.txt")}, + }, + { + "CATSTACK_HOOK_METRICS_DIR": tmp, + "CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD": "warn", + }, + ) + rows = [ + json.loads(line) + for file in Path(tmp).glob("events-*.jsonl") + for line in file.read_text(encoding="utf-8").splitlines() + ] + finally: + os.unlink(path) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(rows)) + self.assertEqual("categorical-scope-guard", rows[0]["hook"]) + self.assertEqual("categorical-scope-guard.partial-status-filter", rows[0]["rule_id"]) + self.assertEqual("warn", rows[0]["mode"]) + self.assertEqual("override", rows[0]["mode_source"]) + self.assertEqual("warned", rows[0]["action"]) + + +if __name__ == "__main__": + unittest.main() From 4432e10373cf59a2adc60b2e4834829b396ec643 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 23:35:52 -0700 Subject: [PATCH 3/9] [Hook architecture](16) Put the demo-freeze hook onto the shared hook code (#731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * invoker: wf-1789406883560-32/implement-hook-build-the-lever — 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. 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 * invoker: wf-1789406883560-32/verify-hook-build-the-lever — Run the deterministic proof for put the build-the-lever hook onto the shared hook code. Review claim: The proof exits 0 only when put the build-the-lever hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/build-the-lever/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the build-the-lever hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 3fe2349d-7d5a-4cf2-a6ab-f4c370ef2d0f * invoker: wf-1789406883560-32/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 70147de4-ee65-46de-90da-48ab84cce9d0 * invoker: wf-1789406886887-33/implement-hook-cat-mode-default — 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. 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 * invoker: wf-1789406886887-33/verify-hook-cat-mode-default — Run the deterministic proof for put the cat-mode-default hook onto the shared hook code. Review claim: The proof exits 0 only when put the cat-mode-default hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/cat-mode-default/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the cat-mode-default hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 8132f734-9bf7-4d89-8c3c-56bc4b969d79 * invoker: wf-1789406886887-33/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 74c962cc-f188-4db3-be0c-c3da68c20e9a * invoker: wf-1789406891093-34/implement-hook-categorical-scope-guard — 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. Solution: Put the categorical-scope-guard hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The categorical-scope-guard entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a change that covers only part of an all request. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/categorical-scope-guard/claude_pretooluse.py, engine/hooks/categorical-scope-guard/detect.py, engine/hooks/categorical-scope-guard/install_claude_hook.py, engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/categorical-scope-guard/claude_pretooluse.py: modify - engine/hooks/categorical-scope-guard/detect.py: modify - engine/hooks/categorical-scope-guard/install_claude_hook.py: modify - engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0. - With CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: 1540cec2-0149-477b-b36f-03c02bb58574 * invoker: wf-1789406891093-34/verify-hook-categorical-scope-guard — Run the deterministic proof for put the categorical-scope-guard hook onto the shared hook code. Review claim: The proof exits 0 only when put the categorical-scope-guard hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the categorical-scope-guard hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 30fc01e9-dbf4-4302-9c20-a786ccb160ef * invoker: wf-1789406891093-34/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 85155944-670a-40b9-a845-1252af44dacd * invoker: wf-1789406894793-35/implement-hook-demo-freeze — Put the demo-freeze hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The demo-freeze entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops edits to what the user is demoing. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/demo-freeze/claude_pretooluse_check.py, engine/hooks/demo-freeze/install_claude_hook.py, engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/demo-freeze/claude_pretooluse_check.py: modify - engine/hooks/demo-freeze/install_claude_hook.py: modify - engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0. - With CATSTACK_HOOK_MODE_DEMO_FREEZE set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Exit code: 1 Invoker-Finalize-Id: 4a3989de-6a6e-4011-9900-c8aa481e9c27 * invoker: wf-1789406894793-35/implement-hook-demo-freeze — Put the demo-freeze hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The demo-freeze entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops edits to what the user is demoing. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/demo-freeze/claude_pretooluse_check.py, engine/hooks/demo-freeze/install_claude_hook.py, engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/demo-freeze/claude_pretooluse_check.py: modify - engine/hooks/demo-freeze/install_claude_hook.py: modify - engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0. - With CATSTACK_HOOK_MODE_DEMO_FREEZE set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Solution: Put the demo-freeze hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The demo-freeze entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops edits to what the user is demoing. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/demo-freeze/claude_pretooluse_check.py, engine/hooks/demo-freeze/install_claude_hook.py, engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/demo-freeze/claude_pretooluse_check.py: modify - engine/hooks/demo-freeze/install_claude_hook.py: modify - engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0. - With CATSTACK_HOOK_MODE_DEMO_FREEZE set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: publish-approved-fix * invoker: wf-1789406894793-35/verify-hook-demo-freeze — Run the deterministic proof for put the demo-freeze hook onto the shared hook code. Review claim: The proof exits 0 only when put the demo-freeze hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the demo-freeze hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: d36e6bb9-501d-4015-bb29-c6ec478f7a78 * invoker: wf-1789406894793-35/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 58d2877b-64ef-434d-8479-4faf4996f91f --------- Co-authored-by: Invoker Bot --- .../demo-freeze/claude_pretooluse_check.py | 37 +++++-- engine/hooks/demo-freeze/tests/test_hooks.py | 5 +- .../demo-freeze/tests/test_hooks_sdk_mode.py | 104 ++++++++++++++++++ 3 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/demo-freeze/claude_pretooluse_check.py b/engine/hooks/demo-freeze/claude_pretooluse_check.py index 3ccc1f1d..4d94563d 100755 --- a/engine/hooks/demo-freeze/claude_pretooluse_check.py +++ b/engine/hooks/demo-freeze/claude_pretooluse_check.py @@ -16,13 +16,19 @@ not haunt tomorrow's session), and any parse/read error fails open. """ import fnmatch -import json import os import sys import time +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 +from runtime import run_hook # noqa: E402 + MARKER = os.environ.get("DEMO_FREEZE_FILE", "/tmp/.demo-freeze") MAX_AGE_SECS = 2 * 3600 +RULE_FROZEN_PATH = "demo-freeze.frozen-path" def frozen_patterns(): @@ -48,28 +54,35 @@ def matches(target, pattern): return target_abs == os.path.abspath(pattern) -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - return - tool_input = data.get("tool_input") or {} - target = ( +def _tool_target(event): + tool_input = event.get("tool_input") or event.get("toolInput") or {} + if not isinstance(tool_input, dict): + return None + return ( tool_input.get("file_path") or tool_input.get("path") or tool_input.get("notebook_path") ) + + +def detect(event): + target = _tool_target(event) if not target: - return + return [] for pattern in frozen_patterns(): if matches(target, pattern): - sys.stderr.write( + message = ( f"Demo surface frozen: {target} matches {pattern!r} in {MARKER}. " "The user is mid-test — don't change what they're looking at unless " "they asked or the test is failing. Remove the marker file to " - "unfreeze once the live window ends.\n" + "unfreeze once the live window ends." ) - sys.exit(2) + return [Finding(rule_id=RULE_FROZEN_PATH, subject=target, message=message, evidence=message)] + return [] + + +def main(): + run_hook("demo-freeze", "claude", detect, "PreToolUse") if __name__ == "__main__": diff --git a/engine/hooks/demo-freeze/tests/test_hooks.py b/engine/hooks/demo-freeze/tests/test_hooks.py index ded538ab..56b60110 100755 --- a/engine/hooks/demo-freeze/tests/test_hooks.py +++ b/engine/hooks/demo-freeze/tests/test_hooks.py @@ -74,7 +74,10 @@ def test_no_marker_fails_open(self): with patch.object(claude_pretooluse_check, "MARKER", "/nonexistent/.demo-freeze"): with patch.object(sys, "stdin", io.StringIO(json.dumps(payload))): with redirect_stderr(err): - claude_pretooluse_check.main() + try: + claude_pretooluse_check.main() + except SystemExit: + pass self.assertEqual(err.getvalue(), "") def test_non_file_tool_input_passes(self): diff --git a/engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py b/engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..11eabc11 --- /dev/null +++ b/engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +ENTRYPOINT = os.path.join(HOOK_DIR, "claude_pretooluse_check.py") + + +def write_marker(lines: list[str]) -> str: + handle = tempfile.NamedTemporaryFile("w", suffix=".demo-freeze", delete=False, encoding="utf-8") + with handle: + handle.write("\n".join(lines) + "\n") + return handle.name + + +def run_entrypoint(payload: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + merged_env.update(env) + return subprocess.run( + [sys.executable, ENTRYPOINT], + input=json.dumps(payload), + capture_output=True, + text=True, + env=merged_env, + ) + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + marker = write_marker(["/tmp/demo/call.html"]) + try: + result = run_entrypoint( + {"tool_name": "Edit", "tool_input": {"file_path": "/tmp/demo/call.html"}}, + { + "DEMO_FREEZE_FILE": marker, + "CATSTACK_HOOK_MODE_DEMO_FREEZE": "warn", + }, + ) + finally: + os.unlink(marker) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + rendered = json.loads(result.stdout) + self.assertIn( + "Demo surface frozen", + rendered["hookSpecificOutput"]["additionalContext"], + ) + + def test_mode_stop_still_blocks_by_default(self) -> None: + marker = write_marker(["/tmp/demo/call.html"]) + try: + result = run_entrypoint( + {"tool_name": "Edit", "tool_input": {"file_path": "/tmp/demo/call.html"}}, + {"DEMO_FREEZE_FILE": marker}, + ) + finally: + os.unlink(marker) + + self.assertEqual(2, result.returncode) + self.assertIn("Demo surface frozen", result.stderr) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + marker = write_marker(["/tmp/demo/call.html"]) + try: + with tempfile.TemporaryDirectory() as tmp: + result = run_entrypoint( + { + "tool_name": "Edit", + "session_id": "demo-freeze-sdk-mode", + "tool_input": {"file_path": "/tmp/demo/call.html"}, + }, + { + "DEMO_FREEZE_FILE": marker, + "CATSTACK_HOOK_METRICS_DIR": tmp, + "CATSTACK_HOOK_MODE_DEMO_FREEZE": "warn", + }, + ) + rows = [ + json.loads(line) + for file in Path(tmp).glob("events-*.jsonl") + for line in file.read_text(encoding="utf-8").splitlines() + ] + finally: + os.unlink(marker) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(rows)) + self.assertEqual("demo-freeze", rows[0]["hook"]) + self.assertEqual("demo-freeze.frozen-path", rows[0]["rule_id"]) + self.assertEqual("warn", rows[0]["mode"]) + self.assertEqual("override", rows[0]["mode_source"]) + self.assertEqual("warned", rows[0]["action"]) + + +if __name__ == "__main__": + unittest.main() From 2a9208f8af8f8d9921b9fd1a28d0d742482e7247 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 23:49:38 -0700 Subject: [PATCH 4/9] [Hook architecture](17) Put the diu-stop hook onto the shared hook code (#732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * invoker: wf-1789406883560-32/implement-hook-build-the-lever — 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. 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 * invoker: wf-1789406883560-32/verify-hook-build-the-lever — Run the deterministic proof for put the build-the-lever hook onto the shared hook code. Review claim: The proof exits 0 only when put the build-the-lever hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/build-the-lever/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the build-the-lever hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 3fe2349d-7d5a-4cf2-a6ab-f4c370ef2d0f * invoker: wf-1789406883560-32/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 70147de4-ee65-46de-90da-48ab84cce9d0 * invoker: wf-1789406886887-33/implement-hook-cat-mode-default — 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. 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 * invoker: wf-1789406886887-33/verify-hook-cat-mode-default — Run the deterministic proof for put the cat-mode-default hook onto the shared hook code. Review claim: The proof exits 0 only when put the cat-mode-default hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/cat-mode-default/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the cat-mode-default hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 8132f734-9bf7-4d89-8c3c-56bc4b969d79 * invoker: wf-1789406886887-33/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 74c962cc-f188-4db3-be0c-c3da68c20e9a * invoker: wf-1789406891093-34/implement-hook-categorical-scope-guard — 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. Solution: Put the categorical-scope-guard hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The categorical-scope-guard entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a change that covers only part of an all request. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/categorical-scope-guard/claude_pretooluse.py, engine/hooks/categorical-scope-guard/detect.py, engine/hooks/categorical-scope-guard/install_claude_hook.py, engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/categorical-scope-guard/claude_pretooluse.py: modify - engine/hooks/categorical-scope-guard/detect.py: modify - engine/hooks/categorical-scope-guard/install_claude_hook.py: modify - engine/hooks/categorical-scope-guard/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` exits 0. - With CATSTACK_HOOK_MODE_CATEGORICAL_SCOPE_GUARD set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: 1540cec2-0149-477b-b36f-03c02bb58574 * invoker: wf-1789406891093-34/verify-hook-categorical-scope-guard — Run the deterministic proof for put the categorical-scope-guard hook onto the shared hook code. Review claim: The proof exits 0 only when put the categorical-scope-guard hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/categorical-scope-guard/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the categorical-scope-guard hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 30fc01e9-dbf4-4302-9c20-a786ccb160ef * invoker: wf-1789406891093-34/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 85155944-670a-40b9-a845-1252af44dacd * invoker: wf-1789406894793-35/implement-hook-demo-freeze — Put the demo-freeze hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The demo-freeze entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops edits to what the user is demoing. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/demo-freeze/claude_pretooluse_check.py, engine/hooks/demo-freeze/install_claude_hook.py, engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/demo-freeze/claude_pretooluse_check.py: modify - engine/hooks/demo-freeze/install_claude_hook.py: modify - engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0. - With CATSTACK_HOOK_MODE_DEMO_FREEZE set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Exit code: 1 Invoker-Finalize-Id: 4a3989de-6a6e-4011-9900-c8aa481e9c27 * invoker: wf-1789406894793-35/implement-hook-demo-freeze — Put the demo-freeze hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The demo-freeze entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops edits to what the user is demoing. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/demo-freeze/claude_pretooluse_check.py, engine/hooks/demo-freeze/install_claude_hook.py, engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/demo-freeze/claude_pretooluse_check.py: modify - engine/hooks/demo-freeze/install_claude_hook.py: modify - engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0. - With CATSTACK_HOOK_MODE_DEMO_FREEZE set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Solution: Put the demo-freeze hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The demo-freeze entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops edits to what the user is demoing. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/demo-freeze/claude_pretooluse_check.py, engine/hooks/demo-freeze/install_claude_hook.py, engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/demo-freeze/claude_pretooluse_check.py: modify - engine/hooks/demo-freeze/install_claude_hook.py: modify - engine/hooks/demo-freeze/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` exits 0. - With CATSTACK_HOOK_MODE_DEMO_FREEZE set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: publish-approved-fix * invoker: wf-1789406894793-35/verify-hook-demo-freeze — Run the deterministic proof for put the demo-freeze hook onto the shared hook code. Review claim: The proof exits 0 only when put the demo-freeze hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/demo-freeze/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the demo-freeze hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: d36e6bb9-501d-4015-bb29-c6ec478f7a78 * invoker: wf-1789406894793-35/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 58d2877b-64ef-434d-8479-4faf4996f91f * invoker: wf-1789406897817-36/implement-hook-diu-stop — Put the diu-stop hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The diu-stop entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a reply that is too long or unproven. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/diu-stop/claude_prompt_reminder.py, engine/hooks/diu-stop/claude_stop_check.py, engine/hooks/diu-stop/codex_notify.py, engine/hooks/diu-stop/diu_limit.py, engine/hooks/diu-stop/install_claude_hook.py, engine/hooks/diu-stop/install_codex_notify.py, engine/hooks/diu-stop/plain_words.py, engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/diu-stop/claude_prompt_reminder.py: modify - engine/hooks/diu-stop/claude_stop_check.py: modify - engine/hooks/diu-stop/codex_notify.py: modify - engine/hooks/diu-stop/diu_limit.py: modify - engine/hooks/diu-stop/install_claude_hook.py: modify - engine/hooks/diu-stop/install_codex_notify.py: modify - engine/hooks/diu-stop/plain_words.py: modify - engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0. - With CATSTACK_HOOK_MODE_DIU_STOP set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Exit code: 1 Invoker-Finalize-Id: cc582581-b6f8-4f71-8dd9-cf16d79f1628 * invoker: wf-1789406897817-36/implement-hook-diu-stop — Put the diu-stop hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The diu-stop entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a reply that is too long or unproven. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/diu-stop/claude_prompt_reminder.py, engine/hooks/diu-stop/claude_stop_check.py, engine/hooks/diu-stop/codex_notify.py, engine/hooks/diu-stop/diu_limit.py, engine/hooks/diu-stop/install_claude_hook.py, engine/hooks/diu-stop/install_codex_notify.py, engine/hooks/diu-stop/plain_words.py, engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/diu-stop/claude_prompt_reminder.py: modify - engine/hooks/diu-stop/claude_stop_check.py: modify - engine/hooks/diu-stop/codex_notify.py: modify - engine/hooks/diu-stop/diu_limit.py: modify - engine/hooks/diu-stop/install_claude_hook.py: modify - engine/hooks/diu-stop/install_codex_notify.py: modify - engine/hooks/diu-stop/plain_words.py: modify - engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0. - With CATSTACK_HOOK_MODE_DIU_STOP set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Solution: Put the diu-stop hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The diu-stop entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a reply that is too long or unproven. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/diu-stop/claude_prompt_reminder.py, engine/hooks/diu-stop/claude_stop_check.py, engine/hooks/diu-stop/codex_notify.py, engine/hooks/diu-stop/diu_limit.py, engine/hooks/diu-stop/install_claude_hook.py, engine/hooks/diu-stop/install_codex_notify.py, engine/hooks/diu-stop/plain_words.py, engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/diu-stop/claude_prompt_reminder.py: modify - engine/hooks/diu-stop/claude_stop_check.py: modify - engine/hooks/diu-stop/codex_notify.py: modify - engine/hooks/diu-stop/diu_limit.py: modify - engine/hooks/diu-stop/install_claude_hook.py: modify - engine/hooks/diu-stop/install_codex_notify.py: modify - engine/hooks/diu-stop/plain_words.py: modify - engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/diu-stop/tests` exits 0. - With CATSTACK_HOOK_MODE_DIU_STOP set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: publish-approved-fix * invoker: wf-1789406897817-36/verify-hook-diu-stop — Run the deterministic proof for put the diu-stop hook onto the shared hook code. Review claim: The proof exits 0 only when put the diu-stop hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/diu-stop/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the diu-stop hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 26d6f94a-7646-4bba-a249-4e63e35f3a4c * invoker: wf-1789406897817-36/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: d41f4da8-d2ba-4eec-847c-65460155a619 --------- Co-authored-by: Invoker Bot --- .../hooks/diu-stop/claude_prompt_reminder.py | 26 +++--- engine/hooks/diu-stop/claude_stop_check.py | 62 +++++++++------ engine/hooks/diu-stop/codex_notify.py | 44 ++++++++--- engine/hooks/diu-stop/tests/test_hooks.py | 15 +++- .../diu-stop/tests/test_hooks_sdk_mode.py | 79 +++++++++++++++++++ 5 files changed, 180 insertions(+), 46 deletions(-) create mode 100644 engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/diu-stop/claude_prompt_reminder.py b/engine/hooks/diu-stop/claude_prompt_reminder.py index 68bd3079..e0c07a10 100644 --- a/engine/hooks/diu-stop/claude_prompt_reminder.py +++ b/engine/hooks/diu-stop/claude_prompt_reminder.py @@ -16,11 +16,19 @@ reminder repeated every turn is exactly the kind of thing this skill tells the model to cut. """ -import json +import os import sys from diu_limit import rule_text +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 +from runtime import run_hook # noqa: E402 + +RULE_REMINDER = "diu-stop.prompt-reminder" + REMINDER = ( "diu reminder: lead with the outcome, no preamble or closing " f"pleasantries, ELI5 {rule_text()}, unless this turn needs technical " @@ -29,17 +37,13 @@ ) +def detect(event): + subject = event.get("session_id") or "" + return [Finding(rule_id=RULE_REMINDER, subject=subject, message=REMINDER, evidence=REMINDER)] + + def main(): - try: - json.load(sys.stdin) - except json.JSONDecodeError: - return - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "UserPromptSubmit", - "additionalContext": REMINDER, - } - })) + run_hook("diu-stop", "claude", detect, "UserPromptSubmit") if __name__ == "__main__": diff --git a/engine/hooks/diu-stop/claude_stop_check.py b/engine/hooks/diu-stop/claude_stop_check.py index 4eeff269..125ec520 100755 --- a/engine/hooks/diu-stop/claude_stop_check.py +++ b/engine/hooks/diu-stop/claude_stop_check.py @@ -45,7 +45,6 @@ Every block names every flagged sentence, so one rewrite that fixes them all gets through. """ -import json import os import re import sys @@ -55,8 +54,18 @@ sys.path.insert(0, os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_markers")) +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) import markers # noqa: E402 +from finding import Finding # noqa: E402 +from runtime import run_hook # noqa: E402 + +RULE_WORD_LIMIT = "diu-stop.word-limit" +RULE_UNVERIFIED_CLAIM = "diu-stop.unverified-claim" +RULE_MALFORMED_MARKER_TAG = "diu-stop.malformed-marker-tag" +RULE_LEGACY_MARKER = "diu-stop.legacy-marker" +RULE_PLAIN_WORDS = "diu-stop.plain-words" # Phrases banned outright (from this user's global CLAUDE.md evidence # rules) -- rarely legitimate even mid-sentence, so no opener restriction. @@ -219,31 +228,28 @@ def find_unverified_claim(message): return claims[0][0] if claims else None -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - return - - if data.get("agent_id"): - return - retry = bool(data.get("stop_hook_active")) +def detect(event): + if event.get("agent_id"): + return [] + retry = bool(event.get("stop_hook_active")) - message = data.get("last_assistant_message") or "" + message = event.get("last_assistant_message") or "" - plain_words_note = try_check_reply(data) + plain_words_note = try_check_reply(event) word_count = counted_words(message) over_limit = word_count > WORD_LIMIT and not retry claims = find_unverified_claims(message) marker_problems = find_marker_problems(message) - if not over_limit and not claims and not marker_problems and not plain_words_note: - return - - parts = [] + findings = [] if plain_words_note: - parts.append(plain_words_note) + findings.append(Finding( + rule_id=RULE_PLAIN_WORDS, + subject=message, + message=plain_words_note, + evidence=plain_words_note, + )) if claims: lines = [ "This message makes an unverified-shaped claim with no adjacent " @@ -259,18 +265,30 @@ def main(): "of what was actually run/checked in its paragraph, or -- only if " "the check cannot run -- tag the claim there and say why." ) - parts.append("\n".join(lines)) - parts.extend(marker_problems) + claim_message = "\n".join(lines) + findings.append(Finding( + rule_id=RULE_UNVERIFIED_CLAIM, + subject=claim_message, + message=claim_message, + evidence=claim_message, + )) + for problem in marker_problems: + rule_id = RULE_MALFORMED_MARKER_TAG if problem == markers.MALFORMED_TAG_MESSAGE else RULE_LEGACY_MARKER + findings.append(Finding(rule_id=rule_id, subject=message, message=problem, evidence=problem)) if over_limit: - parts.append( + over_message = ( f"Apply diu: {word_count} words, over the {WORD_LIMIT}-word " f"guideline. Cut at least {word_count - WORD_LIMIT} words by " "dropping a whole section or list, not by trimming words. " "Unless this turn genuinely asked for full technical detail " "or a specific long format." ) - sys.stderr.write("\n".join(parts) + "\n") - sys.exit(2) + findings.append(Finding(rule_id=RULE_WORD_LIMIT, subject=message, message=over_message, evidence=over_message)) + return findings + + +def main(): + run_hook("diu-stop", "claude", detect, "Stop") if __name__ == "__main__": diff --git a/engine/hooks/diu-stop/codex_notify.py b/engine/hooks/diu-stop/codex_notify.py index fd88a3d6..c763cb23 100755 --- a/engine/hooks/diu-stop/codex_notify.py +++ b/engine/hooks/diu-stop/codex_notify.py @@ -17,10 +17,34 @@ becomes `old-notify-binary some-arg ` when this fires. """ import json +import os import subprocess import sys +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from events import write_events # noqa: E402 +from finding import Finding # noqa: E402 +from modes import effective_mode # noqa: E402 + WORD_LIMIT = 150 +RULE_WORD_LIMIT = "diu-stop.word-limit" + + +def detect(payload): + if payload.get("type") != "agent-turn-complete": + return [] + message = payload.get("last-assistant-message") or "" + word_count = len(message.split()) + if word_count <= WORD_LIMIT: + return [] + message_text = ( + f"diu-stop: last response was {word_count} words (over the " + f"{WORD_LIMIT}-word diu guideline). Codex can't be forced to redo " + "it -- check by hand whether it should have been ELI5." + ) + return [Finding(rule_id=RULE_WORD_LIMIT, subject=message, message=message_text, evidence=message_text)] def main(): @@ -40,18 +64,18 @@ def main(): except json.JSONDecodeError: return - if payload.get("type") != "agent-turn-complete": + findings = detect(payload) + if not findings: return - message = payload.get("last-assistant-message") or "" - word_count = len(message.split()) - if word_count > WORD_LIMIT: - print( - f"diu-stop: last response was {word_count} words (over the " - f"{WORD_LIMIT}-word diu guideline). Codex can't be forced to redo " - "it -- check by hand whether it should have been ELI5.", - file=sys.stderr, - ) + mode, mode_source = effective_mode("diu-stop", payload) + if mode == "off": + return + + for finding in findings: + print(finding.message, file=sys.stderr) + + write_events("diu-stop", "codex", payload, findings, mode, mode_source, 0) if __name__ == "__main__": diff --git a/engine/hooks/diu-stop/tests/test_hooks.py b/engine/hooks/diu-stop/tests/test_hooks.py index 69570da5..0e6dd831 100644 --- a/engine/hooks/diu-stop/tests/test_hooks.py +++ b/engine/hooks/diu-stop/tests/test_hooks.py @@ -56,7 +56,10 @@ def run_prompt_reminder(stdin_obj): buf = io.StringIO() with patch.object(sys, "stdin", io.StringIO(json.dumps(stdin_obj))): with redirect_stdout(buf): - claude_prompt_reminder.main() + try: + claude_prompt_reminder.main() + except SystemExit: + pass return buf.getvalue() @@ -140,7 +143,10 @@ def test_malformed_stdin_json_does_not_crash(self): buf = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not json")): with redirect_stdout(buf): - claude_stop_check.main() # must not raise + try: + claude_stop_check.main() + except SystemExit: + pass self.assertEqual(buf.getvalue(), "") @@ -176,7 +182,10 @@ def test_malformed_stdin_json_does_not_crash(self): buf = io.StringIO() with patch.object(sys, "stdin", io.StringIO("not json")): with redirect_stdout(buf): - claude_prompt_reminder.main() # must not raise + try: + claude_prompt_reminder.main() + except SystemExit: + pass self.assertEqual(buf.getvalue(), "") diff --git a/engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py b/engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py new file mode 100644 index 00000000..51c808ea --- /dev/null +++ b/engine/hooks/diu-stop/tests/test_hooks_sdk_mode.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +HOOK_DIR = os.path.dirname(HERE) +ENTRYPOINT = os.path.join(HOOK_DIR, "claude_stop_check.py") + +LONG_MESSAGE = " ".join(["word"] * 200) + + +def run_entrypoint(payload: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + merged_env.update(env) + return subprocess.run( + [sys.executable, ENTRYPOINT], + input=json.dumps(payload), + capture_output=True, + text=True, + env=merged_env, + ) + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_changes_block_to_warning(self) -> None: + result = run_entrypoint( + {"last_assistant_message": LONG_MESSAGE}, + {"CATSTACK_HOOK_MODE_DIU_STOP": "warn"}, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + rendered = json.loads(result.stdout) + self.assertIn( + "Apply diu", + rendered["hookSpecificOutput"]["additionalContext"], + ) + + def test_mode_stop_still_blocks_by_default(self) -> None: + result = run_entrypoint({"last_assistant_message": LONG_MESSAGE}, {}) + + self.assertEqual(2, result.returncode) + self.assertIn("Apply diu", result.stderr) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = run_entrypoint( + { + "session_id": "diu-stop-sdk-mode", + "last_assistant_message": LONG_MESSAGE, + }, + { + "CATSTACK_HOOK_METRICS_DIR": tmp, + "CATSTACK_HOOK_MODE_DIU_STOP": "warn", + }, + ) + rows = [ + json.loads(line) + for file in Path(tmp).glob("events-*.jsonl") + for line in file.read_text(encoding="utf-8").splitlines() + ] + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(rows)) + self.assertEqual("diu-stop", rows[0]["hook"]) + self.assertEqual("diu-stop.word-limit", rows[0]["rule_id"]) + self.assertEqual("warn", rows[0]["mode"]) + self.assertEqual("override", rows[0]["mode_source"]) + self.assertEqual("warned", rows[0]["action"]) + + +if __name__ == "__main__": + unittest.main() From f660fa7a7493cdedb02888aecf9bc68d01f0c325 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Thu, 17 Sep 2026 03:02:59 -0700 Subject: [PATCH 5/9] [Hook architecture](14) Put the cat-mode-default hook onto the shared hook code (#766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * invoker: wf-1789406883560-32/implement-hook-build-the-lever — 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. 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 * invoker: wf-1789406883560-32/verify-hook-build-the-lever — Run the deterministic proof for put the build-the-lever hook onto the shared hook code. Review claim: The proof exits 0 only when put the build-the-lever hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/build-the-lever/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the build-the-lever hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 3fe2349d-7d5a-4cf2-a6ab-f4c370ef2d0f * invoker: wf-1789406883560-32/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 70147de4-ee65-46de-90da-48ab84cce9d0 * invoker: wf-1789406886887-33/implement-hook-cat-mode-default — 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. 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 * invoker: wf-1789406886887-33/verify-hook-cat-mode-default — Run the deterministic proof for put the cat-mode-default hook onto the shared hook code. Review claim: The proof exits 0 only when put the cat-mode-default hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/cat-mode-default/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the cat-mode-default hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 8132f734-9bf7-4d89-8c3c-56bc4b969d79 * invoker: wf-1789406886887-33/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 74c962cc-f188-4db3-be0c-c3da68c20e9a * invoker: wf-1789406886887-33/implement-hook-cat-mode-default — 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. Exit code: 1 Invoker-Finalize-Id: d1bf500d-c8f0-47fc-8c0d-c636c7506b4c * invoker: wf-1789406886887-33/implement-hook-cat-mode-default — 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. 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 * invoker: wf-1789406886887-33/verify-hook-cat-mode-default — Run the deterministic proof for put the cat-mode-default hook onto the shared hook code. Review claim: The proof exits 0 only when put the cat-mode-default hook onto the shared hook code holds. Review lane: proof Safety invariant: Proof only; it changes no product behavior. Effectiveness measurement: The exit status of `python3 -m unittest discover -s engine/hooks/cat-mode-default/tests` is the signal for this slice. Slice rationale: One proof unit for this workflow. Architectural effect: None; verification only. Goal: Prove put the cat-mode-default hook onto the shared hook code with one deterministic run. Motivation: Each workflow carries its own proof so a reviewer can trust the slice alone. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Execute the proof as a terminal gate. Non-goals: No product edits here. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 5699f69c-cb1c-4890-b373-d374ac8be49f * invoker: wf-1789406886887-33/scrub-handoff-artifacts — Terminal read-only gate confirming no ephemeral handoff files were left behind. Review claim: The workflow leaves no ephemeral handoff files in the tree. Review lane: proof Safety invariant: Read-only; it never deletes files, alters the index, or commits caller work. Effectiveness measurement: A non-zero exit when ephemeral handoff files remain is the signal. Slice rationale: One unit: the hygiene gate. Architectural effect: None. Goal: Confirm no ephemeral handoff files remain after every other task finishes. Motivation: Ephemeral inter-task files leak into the diff and read as part of the change. Alternative considerations: Manual inspection was set aside as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh in check mode. Non-goals: No deletion, no index changes, no commits. Layer: e2e_regression Feature state: active Exit code: 0 Invoker-Finalize-Id: 946f5c14-9399-42ca-a370-bbe5a0fedc02 --------- Co-authored-by: Invoker Bot Co-authored-by: Claude Opus 5 (1M context) --- engine/hooks/cat-mode-default/README.md | 19 ++++++++++++++----- .../cat-mode-default/tests/test_agent_hook.py | 14 ++++++++++++++ .../cat-mode-default/tests/test_hooks.py | 13 +++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/engine/hooks/cat-mode-default/README.md b/engine/hooks/cat-mode-default/README.md index dc571635..d293dfc9 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 finding carries + `output={"updatedInput": ...}`, which the shared renderer emits as + `updatedInput` rather than its 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/tests/test_agent_hook.py b/engine/hooks/cat-mode-default/tests/test_agent_hook.py index c87cfafd..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 @@ -150,6 +151,19 @@ 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")): diff --git a/engine/hooks/cat-mode-default/tests/test_hooks.py b/engine/hooks/cat-mode-default/tests/test_hooks.py index 9a080562..61b0a782 100644 --- a/engine/hooks/cat-mode-default/tests/test_hooks.py +++ b/engine/hooks/cat-mode-default/tests/test_hooks.py @@ -260,6 +260,19 @@ 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")): From 988295da0826efa89524a337868b358712b8a166 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 10:08:34 +0000 Subject: [PATCH 6/9] =?UTF-8?q?invoker:=20wf-1789406891093-34/implement-ho?= =?UTF-8?q?ok-categorical-scope-guard=20=E2=80=94=20Put=20the=20categorica?= =?UTF-8?q?l-scope-guard=20hook=20onto=20the=20shared=20hook=20code.=20Rev?= =?UTF-8?q?iew=20claim:=20This=20hook=20reports=20findings=20to=20the=20sh?= =?UTF-8?q?ared=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: c99c0492-b180-456b-bfa1-0dc12286babf From 8a0076dd41c4eda7859607ad3ecd43f902343527 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 10:10:13 +0000 Subject: [PATCH 7/9] =?UTF-8?q?invoker:=20wf-1789406891093-34/implement-ho?= =?UTF-8?q?ok-categorical-scope-guard=20=E2=80=94=20Put=20the=20categorica?= =?UTF-8?q?l-scope-guard=20hook=20onto=20the=20shared=20hook=20code.=20Rev?= =?UTF-8?q?iew=20claim:=20This=20hook=20reports=20findings=20to=20the=20sh?= =?UTF-8?q?ared=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: 0 Invoker-Finalize-Id: publish-approved-fix From 1d0cf8784b796397690e75df050ef6647ddaafb7 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 10:10:55 +0000 Subject: [PATCH 8/9] =?UTF-8?q?invoker:=20wf-1789406891093-34/verify-hook-?= =?UTF-8?q?categorical-scope-guard=20=E2=80=94=20Run=20the=20deterministic?= =?UTF-8?q?=20proof=20for=20put=20the=20categorical-scope-guard=20hook=20o?= =?UTF-8?q?nto=20the=20shared=20hook=20code.=20Review=20claim:=20The=20pro?= =?UTF-8?q?of=20exits=200=20only=20when=20put=20the=20categorical-scope-gu?= =?UTF-8?q?ard=20hook=20onto=20the=20shared=20hook=20code=20holds.=20Revie?= =?UTF-8?q?w=20lane:=20proof=20Safety=20invariant:=20Proof=20only;=20it=20?= =?UTF-8?q?changes=20no=20product=20behavior.=20Effectiveness=20measuremen?= =?UTF-8?q?t:=20The=20exit=20status=20of=20`python3=20-m=20unittest=20disc?= =?UTF-8?q?over=20-s=20engine/hooks/categorical-scope-guard/tests`=20is=20?= =?UTF-8?q?the=20signal=20for=20this=20slice.=20Slice=20rationale:=20One?= =?UTF-8?q?=20proof=20unit=20for=20this=20workflow.=20Architectural=20effe?= =?UTF-8?q?ct:=20None;=20verification=20only.=20Goal:=20Prove=20put=20the?= =?UTF-8?q?=20categorical-scope-guard=20hook=20onto=20the=20shared=20hook?= =?UTF-8?q?=20code=20with=20one=20deterministic=20run.=20Motivation:=20Eac?= =?UTF-8?q?h=20workflow=20carries=20its=20own=20proof=20so=20a=20reviewer?= =?UTF-8?q?=20can=20trust=20the=20slice=20alone.=20Alternative=20considera?= =?UTF-8?q?tions:=20Manual=20inspection=20was=20set=20aside=20as=20non-det?= =?UTF-8?q?erministic.=20Implementation=20details:=20Execute=20the=20proof?= =?UTF-8?q?=20as=20a=20terminal=20gate.=20Non-goals:=20No=20product=20edit?= =?UTF-8?q?s=20here.=20Layer:=20e2e=5Fregression=20Feature=20state:=20acti?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 1520f16c-25dd-46cb-9c23-704b47674955 From dc8bf40b21a7385ba3fdda0ee847a0889a523c1a Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Thu, 17 Sep 2026 10:11:36 +0000 Subject: [PATCH 9/9] =?UTF-8?q?invoker:=20wf-1789406891093-34/scrub-handof?= =?UTF-8?q?f-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confirm?= =?UTF-8?q?ing=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: 5e36be9b-5d81-43c2-b960-7e5dde433045