From b95c4472f1188085d2bbc2d92f36ed47506525b6 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Sat, 12 Sep 2026 12:13:43 -0700 Subject: [PATCH 01/13] cat-mode: inject on every enabled prompt --- .../cat-mode-default/claude_prompt_submit.py | 2 +- engine/hooks/cat-mode-default/detect.py | 38 ++------------- .../tests/fixtures/silent_ack_ok.json | 4 +- .../cat-mode-default/tests/test_hooks.py | 47 +++++++++---------- 4 files changed, 28 insertions(+), 63 deletions(-) diff --git a/engine/hooks/cat-mode-default/claude_prompt_submit.py b/engine/hooks/cat-mode-default/claude_prompt_submit.py index f6da8f15..d94e6b1d 100644 --- a/engine/hooks/cat-mode-default/claude_prompt_submit.py +++ b/engine/hooks/cat-mode-default/claude_prompt_submit.py @@ -2,7 +2,7 @@ """Claude Code UserPromptSubmit: inject the cat-mode default context. Fail-open. No LLM. Never denies. Silent unless CATSTACK_CAT_MODE_DEFAULT -resolves to on and the prompt is an investigation or execution. +resolves to on and the prompt does not contain a typed /cat-mode. """ from __future__ import annotations diff --git a/engine/hooks/cat-mode-default/detect.py b/engine/hooks/cat-mode-default/detect.py index 397f4f43..e921529f 100644 --- a/engine/hooks/cat-mode-default/detect.py +++ b/engine/hooks/cat-mode-default/detect.py @@ -11,10 +11,8 @@ Files are parsed as plain `KEY=VALUE` lines. They are never sourced, and no key other than the flag is read back or printed. -2. Is the prompt an investigation or execution? A bare slash command, a - one-word acknowledgement ("ok", "thanks"), or a prompt that already - invokes /cat-mode is not. Anything longer than a short phrase, or that - carries a work verb (why, how, fix, build, run, ...), is. +2. Does the prompt already contain a typed /cat-mode? Every other prompt gets + the context when the flag is on. The text injected names the installed cat-mode SKILL.md so the model reads the real file rather than a summary. When the skill is not installed the @@ -31,19 +29,6 @@ SKILL_RELPATH = os.path.join(".claude", "skills", "cat-mode", "SKILL.md") TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) -MIN_WORK_LENGTH = 12 -ACKS = frozenset({ - "ok", "okay", "k", "kk", "yes", "y", "yep", "yup", "no", "nope", "sure", - "thanks", "thank you", "thx", "ty", "cool", "great", "nice", "good", - "done", "go", "continue", "proceed", "got it", "lgtm", "np", "fine", -}) -WORK_VERBS = ( - "why", "how", "what", "where", "fix", "build", "run", "land", "make", - "investigate", "check", "debug", "test", "repro", "find", "explain", - "add", "remove", "delete", "refactor", "write", "implement", "deploy", - "ship", "merge", "open", "update", "review", "compare", "verify", -) -WORK_VERB_RE = re.compile(r"\b(?:" + "|".join(WORK_VERBS) + r")\b", re.IGNORECASE) CAT_MODE_COMMAND_RE = re.compile(r"(?:^|\s)/cat-mode\b") ENV_LINE_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$") @@ -140,23 +125,6 @@ def typed_cat_mode(prompt: str) -> bool: return bool(CAT_MODE_COMMAND_RE.search(prompt or "")) -def is_work_prompt(prompt: str) -> bool: - text = (prompt or "").strip() - if not text: - return False - if typed_cat_mode(text): - return False - tokens = text.split() - if text.startswith("/") and len(tokens) == 1: - return False - normalized = re.sub(r"[^a-z ]", "", text.lower()).strip() - if normalized in ACKS: - return False - if len(text) > MIN_WORK_LENGTH: - return True - return bool(WORK_VERB_RE.search(text)) - - def installed_skill_path(home: str | None = None) -> str | None: home_dir = home or os.path.expanduser("~") path = os.path.join(home_dir, SKILL_RELPATH) @@ -176,7 +144,7 @@ def decide(payload: dict, environ: dict | None = None, home: str | None = None) """Return the additionalContext to inject, or None to stay silent.""" env = os.environ if environ is None else environ prompt = extract_prompt_text(payload if isinstance(payload, dict) else {}) - if not is_work_prompt(prompt): + if typed_cat_mode(prompt): return None cwd = payload.get("cwd") if isinstance(payload, dict) else None if not flag_on(env, cwd or os.getcwd(), home): diff --git a/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json b/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json index 4641ae3b..983087d7 100644 --- a/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json +++ b/engine/hooks/cat-mode-default/tests/fixtures/silent_ack_ok.json @@ -1,6 +1,6 @@ { - "why": "flag on, but the prompt is a one-word acknowledgement, not work", - "expect": "silent", + "why": "flag on, so the prompt receives the default even though it is a one-word acknowledgement", + "expect": "fires", "environ": {"CATSTACK_CAT_MODE_DEFAULT": "1"}, "env_file": null, "payload": {"hook_event_name": "UserPromptSubmit", "prompt": "ok"} diff --git a/engine/hooks/cat-mode-default/tests/test_hooks.py b/engine/hooks/cat-mode-default/tests/test_hooks.py index af7031ba..97b0ff69 100644 --- a/engine/hooks/cat-mode-default/tests/test_hooks.py +++ b/engine/hooks/cat-mode-default/tests/test_hooks.py @@ -130,21 +130,33 @@ def test_silent_when_flag_off(self) -> None: self.assertEqual(fixture["payload"]["prompt"], REAL_PROMPT) self.assertIsNone(context) - def test_silent_on_one_word_ack(self) -> None: + def test_fires_on_one_word_ack(self) -> None: _fixture, context = self.run_fixture("silent_ack_ok.json") - self.assertIsNone(context) + self.assertIsNotNone(context) def test_silent_when_user_typed_cat_mode(self) -> None: _fixture, context = self.run_fixture("silent_typed_cat_mode.json") self.assertIsNone(context) - def test_effectiveness_same_prompt_flag_on_vs_off(self) -> None: - payload = {"prompt": REAL_PROMPT, "cwd": self.box.cwd} - on = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "1"}), self.box.home)) - off = injected_context(run_entrypoint(payload, self.box.environ(), self.box.home)) - self.assertIsNotNone(on) - self.assertIn("read and apply", on) - self.assertIsNone(off) + def test_effectiveness_prompt_matrix(self) -> None: + prompts = ( + "ok", + "thanks", + "yes do it", + "go ahead", + "investigate why the build fails on main", + "/cat-mode fix this", + ) + for prompt in prompts: + payload = {"prompt": prompt, "cwd": self.box.cwd} + on = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "1"}), self.box.home)) + off = injected_context(run_entrypoint(payload, self.box.environ({detect.FLAG: "0"}), self.box.home)) + if prompt == "/cat-mode fix this": + self.assertIsNone(on, prompt) + else: + self.assertIsNotNone(on, prompt) + self.assertIn("read and apply", on) + self.assertIsNone(off, prompt) class FlagResolutionCase(unittest.TestCase): @@ -219,22 +231,7 @@ def test_true_values_turn_on(self) -> None: self.assertTrue(detect.flag_on(self.box.environ({detect.FLAG: value}), self.box.cwd, self.box.home), value) -class PromptClassificationCase(unittest.TestCase): - def test_fires_on_work_prompts(self) -> None: - for prompt in ( - REAL_PROMPT, - "fix it", - "run tests", - "how do I land this stack", - "/loop 5m check the PR queue and repair failures", - "Investigate the flaky e2e on main", - ): - self.assertTrue(detect.is_work_prompt(prompt), prompt) - - def test_silent_on_acks_and_bare_commands(self) -> None: - for prompt in ("ok", "OK!", "yes", "thanks", "Thank you.", "/clear", "/cat-mode", "", " "): - self.assertFalse(detect.is_work_prompt(prompt), prompt) - +class PromptCommandCase(unittest.TestCase): def test_silent_when_cat_mode_typed_anywhere(self) -> None: self.assertTrue(detect.typed_cat_mode("/cat-mode fix it")) self.assertTrue(detect.typed_cat_mode("please /cat-mode fix it")) From fd29a8e55003fa4cb028bda22a16d36f2de49995 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Sat, 12 Sep 2026 12:14:18 -0700 Subject: [PATCH 02/13] =?UTF-8?q?invoker:=20wf-1789240296417-25/cat-mode-e?= =?UTF-8?q?very-prompt=20=E2=80=94=20Review=20claim:=20cat-mode-default=20?= =?UTF-8?q?injects=20on=20every=20prompt=20when=20the=20flag=20is=20on,=20?= =?UTF-8?q?except=20a=20prompt=20containing=20a=20typed=20/cat-mode,=20and?= =?UTF-8?q?=20the=20regex=20classifier=20is=20gone.=20Review=20lane:=20beh?= =?UTF-8?q?avior=20Safety=20invariant:=20With=20the=20flag=20on,=20every?= =?UTF-8?q?=20prompt=20gets=20cat-mode=20unless=20it=20already=20contains?= =?UTF-8?q?=20`/cat-mode`;=20with=20the=20flag=20off,=20no=20prompt=20gets?= =?UTF-8?q?=20it;=20nothing=20guesses=20meaning=20with=20regex.=20Effectiv?= =?UTF-8?q?eness=20measurement:=20Entrypoint=20tests=20on=20six=20prompts?= =?UTF-8?q?=20with=20the=20flag=20on=20and=20off=20assert=20inject=20or=20?= =?UTF-8?q?silent=20for=20each.=20Slice=20rationale:=20A=20single=20hook's?= =?UTF-8?q?=20decision=20rule,=20reviewable=20alone.=20Architectural=20eff?= =?UTF-8?q?ect:=20cat-mode=20context=20reaches=20acknowledgement=20and=20s?= =?UTF-8?q?hort=20execution=20turns=20too;=20the=20Agent-tool=20companion?= =?UTF-8?q?=20is=20unchanged.=20Goal:=20Remove=20ACKS,=20WORK=5FVERBS,=20W?= =?UTF-8?q?ORK=5FVERB=5FRE,=20MIN=5FWORK=5FLENGTH=20and=20is=5Fwork=5Fprom?= =?UTF-8?q?pt,=20and=20update=20the=20tests.=20Motivation:=20The=20regex?= =?UTF-8?q?=20skips=20execution=20turns=20like=20"yes=20do=20it".=20Altern?= =?UTF-8?q?ative=20considerations:=20A=20background=20llm-judge=20was=20re?= =?UTF-8?q?jected=20because=20its=20verdict=20arrives=20on=20the=20next=20?= =?UTF-8?q?prompt;=20a=20blocking=20judge=20was=20rejected=20because=20the?= =?UTF-8?q?=20user's=20standing=20rule=20is=20never=20block=20on=20the=20j?= =?UTF-8?q?udge.=20Implementation=20details:=20decide()=20returns=20the=20?= =?UTF-8?q?context=20whenever=20the=20flag=20is=20on,=20apart=20from=20a?= =?UTF-8?q?=20typed=20/cat-mode,=20which=20stays=20because=20a=20slash=20c?= =?UTF-8?q?ommand=20is=20a=20fixed=20machine=20format.=20Non-goals:=20No?= =?UTF-8?q?=20change=20to=20the=20Agent-tool=20companion=20(agent=5Fupdate?= =?UTF-8?q?d=5Finput,=20mentions=5Fcat=5Fmode),=20flag=20resolution,=20or?= =?UTF-8?q?=20the=20injected=20text;=20no=20corpus/=20skill=20edits.=20Lay?= =?UTF-8?q?er:=20domain=20Feature=20state:=20active=20Files:=20-=20engine/?= =?UTF-8?q?hooks/cat-mode-default/detect.py=20-=20engine/hooks/cat-mode-de?= =?UTF-8?q?fault/tests/test=5Fhooks.py=20-=20engine/hooks/cat-mode-default?= =?UTF-8?q?/tests/fixtures/=20Change=20types:=20-=20engine/hooks/cat-mode-?= =?UTF-8?q?default/detect.py:=20modify=20-=20engine/hooks/cat-mode-default?= =?UTF-8?q?/tests/test=5Fhooks.py:=20modify=20-=20engine/hooks/cat-mode-de?= =?UTF-8?q?fault/tests/fixtures/:=20modify=20Acceptance=20criteria:=20-=20?= =?UTF-8?q?`python3=20-m=20unittest=20discover=20-s=20engine/hooks/cat-mod?= =?UTF-8?q?e-default/tests=20-v`=20exits=200.=20-=20`python3=20scripts/che?= =?UTF-8?q?ck=5Fhook=5Ftest=5Fcoverage.py=20engine/hooks/cat-mode-default`?= =?UTF-8?q?=20exits=200.=20-=20`git=20grep=20-n=20-e=20is=5Fwork=5Fprompt?= =?UTF-8?q?=20-e=20WORK=5FVERB=20-e=20MIN=5FWORK=5FLENGTH=20--=20engine=20?= =?UTF-8?q?tests`=20prints=20nothing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 From 21fdacd36c9b926248ee69e6ea2dc4338fefe9e1 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 12 Sep 2026 19:16:49 +0000 Subject: [PATCH 03/13] docs: describe cat-mode every-prompt default --- docs/ecosystem.md | 2 +- engine/hooks/cat-mode-default/README.md | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 13380747..dea86601 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -69,7 +69,7 @@ again. | `bug-complaint-leak` | hook | | `publish-act-guard` | hook | | `categorical-scope-guard` | hook (PreToolUse on `Bash`; blocks a status-narrowed mutation when the live turn said all/every/each) | -| `cat-mode-default` | hook (UserPromptSubmit + PreToolUse on `Agent`; applies `cat-mode` on work turns and subagent prompts when `CATSTACK_CAT_MODE_DEFAULT=1`) | +| `cat-mode-default` | hook (UserPromptSubmit + PreToolUse on `Agent`; applies `cat-mode` on every prompt and on subagent prompts when `CATSTACK_CAT_MODE_DEFAULT=1`) | | `demo-freeze` | hook | | `explicit-failures` | hook (advisory; always on) | | `external-claim-gate` | hook (PreToolUse on `Bash`; blocks a gh issue/comment/release/api write whose body claims a cause or fix with no evidence; blocks as UNCHECKED when the body cannot be read) | diff --git a/engine/hooks/cat-mode-default/README.md b/engine/hooks/cat-mode-default/README.md index aa0230f9..d0710b15 100644 --- a/engine/hooks/cat-mode-default/README.md +++ b/engine/hooks/cat-mode-default/README.md @@ -1,12 +1,12 @@ # cat-mode-default -Claude Code `UserPromptSubmit` hook. When the flag is on and the prompt is -an investigation or execution, it injects one line of context telling the -model to read and apply the installed `cat-mode` skill for that turn. +Claude Code `UserPromptSubmit` hook. When the flag is on, it injects one line +of context telling the model to read and apply the installed `cat-mode` skill +for that turn. `cat-mode` ships with `disable-model-invocation: true`, so on its own it -applies when typed as `/cat-mode`. This hook makes it the default for -work turns without flipping that frontmatter flag. +applies when typed as `/cat-mode`. This hook makes it the default for every +prompt without flipping that frontmatter flag. ## Turning it on @@ -30,11 +30,10 @@ tolerated). They are never sourced, and no other key is read or printed. ## When it fires -Prompt is treated as work when it is longer than a short phrase or carries a -work verb (why, how, fix, build, run, land, make, investigate, check, debug, -...). It stays silent for a bare slash command (`/clear`), a one-word -acknowledgement (`ok`, `thanks`), or any prompt that already contains -`/cat-mode`, so the skill is not applied twice in one turn. +With the flag on, every prompt gets the context unless it already contains a +typed `/cat-mode`, so the skill is not applied twice in one turn. Other slash +commands, acknowledgements, and short execution prompts all get the same +default context. Injected text names the installed file (`~/.claude/skills/cat-mode/SKILL.md`) so the model reads the real skill. If that file is missing the line says @@ -54,7 +53,7 @@ second copy). Same flag resolution as the prompt hook. ## Files -- `detect.py`: flag resolution, prompt classification, context text. +- `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` @@ -66,4 +65,5 @@ second copy). Same flag resolution as the prompt hook. Related but different: `CAT_MODE_AUTO_INVOKE=true` in catstack's own `.env` makes `install.sh` materialize a cat-mode copy with model invocation enabled, which leaves the choice to the model each turn. This hook is deterministic: -flag on plus a work prompt means the context is injected. +flag on means the context is injected unless the prompt already contains a +typed `/cat-mode`. From 6554fefb5cb87c82813a613af59bc8e7ac0c694e Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:17:46 +0000 Subject: [PATCH 04/13] =?UTF-8?q?invoker:=20wf-1789240296417-25/describe-e?= =?UTF-8?q?very-prompt=20=E2=80=94=20Review=20claim:=20engine/hooks/cat-mo?= =?UTF-8?q?de-default/README.md=20and=20the=20docs/ecosystem.md=20row=20sa?= =?UTF-8?q?y=20cat-mode=20applies=20on=20every=20prompt=20when=20the=20fla?= =?UTF-8?q?g=20is=20on.=20Review=20lane:=20docs=20Safety=20invariant:=20On?= =?UTF-8?q?ly=20the=20two=20Markdown=20files=20change;=20no=20code,=20test?= =?UTF-8?q?,=20or=20config=20file=20is=20edited.=20Effectiveness=20measure?= =?UTF-8?q?ment:=20`git=20grep=20-n=20"work=20turns"=20--=20docs=20engine/?= =?UTF-8?q?hooks/cat-mode-default`=20prints=20nothing=20after=20the=20chan?= =?UTF-8?q?ge.=20Slice=20rationale:=20Prose=20in=20its=20own=20commit=20so?= =?UTF-8?q?=20the=20code=20commit=20stays=20one=20claim.=20Architectural?= =?UTF-8?q?=20effect:=20None;=20prose=20only.=20Goal:=20Replace=20the=20wo?= =?UTF-8?q?rk-prompt=20wording=20with=20the=20every-prompt=20rule.=20Motiv?= =?UTF-8?q?ation:=20The=20README=20and=20ecosystem=20table=20would=20other?= =?UTF-8?q?wise=20describe=20the=20old=20classifier.=20Alternative=20consi?= =?UTF-8?q?derations:=20Code=20comments=20were=20rejected;=20the=20repo=20?= =?UTF-8?q?forbids=20new=20comments.=20Implementation=20details:=20Two=20M?= =?UTF-8?q?arkdown=20edits.=20Non-goals:=20No=20code,=20test,=20or=20confi?= =?UTF-8?q?g=20edits;=20nothing=20under=20corpus/.=20Layer:=20docs=20Featu?= =?UTF-8?q?re=20state:=20active=20Files:=20-=20engine/hooks/cat-mode-defau?= =?UTF-8?q?lt/README.md=20-=20docs/ecosystem.md=20Change=20types:=20-=20en?= =?UTF-8?q?gine/hooks/cat-mode-default/README.md:=20docs-only=20-=20docs/e?= =?UTF-8?q?cosystem.md:=20docs-only=20Acceptance=20criteria:=20-=20`git=20?= =?UTF-8?q?grep=20-n=20"work=20turns"=20--=20docs=20engine/hooks/cat-mode-?= =?UTF-8?q?default`=20prints=20nothing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 5442b6e5-293e-443f-84aa-52f0fe0d0135 From 7db957be358936aaeee2d2f107b2c40807c8be70 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:23:22 +0000 Subject: [PATCH 05/13] =?UTF-8?q?invoker:=20wf-1789240296417-25/verify-cat?= =?UTF-8?q?-mode-every-prompt=20=E2=80=94=20Review=20claim:=20The=20cat-mo?= =?UTF-8?q?de-default=20tests,=20its=20coverage=20gate,=20and=20the=20repo?= =?UTF-8?q?=20suite=20pass,=20and=20no=20classifier=20symbol=20remains.=20?= =?UTF-8?q?Review=20lane:=20proof=20Safety=20invariant:=20Verification=20i?= =?UTF-8?q?s=20read-only=20and=20does=20not=20alter=20any=20repository=20f?= =?UTF-8?q?ile.=20Effectiveness=20measurement:=20The=20commands=20are=20th?= =?UTF-8?q?e=20direct=20measurement.=20Slice=20rationale:=20One=20focused?= =?UTF-8?q?=20proof=20before=20review.=20Architectural=20effect:=20None;?= =?UTF-8?q?=20verification=20only.=20Goal:=20Prove=20every-prompt=20inject?= =?UTF-8?q?ion=20and=20flag-off=20silence.=20Motivation:=20Running=20the?= =?UTF-8?q?=20tests=20is=20the=20proof.=20Alternative=20considerations:=20?= =?UTF-8?q?The=20full=20suite=20catches=20consumers=20of=20deleted=20symbo?= =?UTF-8?q?ls=20elsewhere.=20Implementation=20details:=20Run=20the=20cover?= =?UTF-8?q?age=20gate,=20the=20suite=20(which=20discovers=20the=20hook=20t?= =?UTF-8?q?ests),=20and=20the=20grep.=20Non-goals:=20No=20mutations.=20Lay?= =?UTF-8?q?er:=20app=5Fregression=20Feature=20state:=20active=20Acceptance?= =?UTF-8?q?=20criteria:=20-=20Exits=200=20only=20when=20all=20pass=20and?= =?UTF-8?q?=20the=20grep=20finds=20nothing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: fdb51ec8-9875-493c-8a43-052107f958b5 From 3f4e97cee67859f8a88fbf7cd059f091345533cd Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:24:19 +0000 Subject: [PATCH 06/13] =?UTF-8?q?invoker:=20wf-1789240296417-25/scrub-hand?= =?UTF-8?q?off-artifacts=20=E2=80=94=20Review=20claim:=20No=20ephemeral=20?= =?UTF-8?q?inter-task=20handoff=20files=20remain=20in=20the=20worktree=20b?= =?UTF-8?q?efore=20the=20merge=20gate.=20Review=20lane:=20cleanup=20Safety?= =?UTF-8?q?=20invariant:=20The=20scrub=20script=20only=20checks=20for=20kn?= =?UTF-8?q?own=20handoff=20artifact=20names=20and=20never=20touches=20sour?= =?UTF-8?q?ce,=20tests,=20or=20other=20repository=20files.=20Effectiveness?= =?UTF-8?q?=20measurement:=20The=20script=20exits=20non-zero=20if=20any=20?= =?UTF-8?q?handoff=20artifact=20remains.=20Slice=20rationale:=20Required?= =?UTF-8?q?=20terminal=20scrub=20for=20every=20implementation=20workflow.?= =?UTF-8?q?=20Architectural=20effect:=20None;=20hygiene=20only.=20Goal:=20?= =?UTF-8?q?Leave=20the=20branch=20free=20of=20handoff=20artifacts.=20Motiv?= =?UTF-8?q?ation:=20Handoff=20files=20must=20not=20reach=20the=20PR.=20Alt?= =?UTF-8?q?ernative=20considerations:=20Manual=20cleanup=20was=20rejected?= =?UTF-8?q?=20as=20non-deterministic.=20Implementation=20details:=20Run=20?= =?UTF-8?q?scripts/scrub-handoff-artifacts.sh.=20Layer=20exception:=20allo?= =?UTF-8?q?wed=20--=20the=20terminal=20scrub=20must=20run=20after=20every?= =?UTF-8?q?=20task=20in=20the=20workflow,=20including=20the=20docs=20task.?= =?UTF-8?q?=20Non-goals:=20No=20product=20edits.=20Layer:=20app=5Fregressi?= =?UTF-8?q?on=20Feature=20state:=20active=20Acceptance=20criteria:=20-=20`?= =?UTF-8?q?bash=20scripts/scrub-handoff-artifacts.sh`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 03cb1c94-1cac-4310-af56-316f3d1707f6 From 90b25516118f531d7e2d65ab83c44285af289c9c Mon Sep 17 00:00:00 2001 From: Invoker Date: Sat, 12 Sep 2026 19:26:10 +0000 Subject: [PATCH 07/13] Add dormant hook runner metrics --- engine/hooks/_runner/outcome.py | 49 ++++++ engine/hooks/_runner/run.py | 147 ++++++++++++++++++ engine/hooks/_runner/tests/test_outcome.py | 56 +++++++ engine/hooks/_runner/tests/test_run.py | 169 +++++++++++++++++++++ 4 files changed, 421 insertions(+) create mode 100644 engine/hooks/_runner/outcome.py create mode 100644 engine/hooks/_runner/run.py create mode 100644 engine/hooks/_runner/tests/test_outcome.py create mode 100644 engine/hooks/_runner/tests/test_run.py diff --git a/engine/hooks/_runner/outcome.py b/engine/hooks/_runner/outcome.py new file mode 100644 index 00000000..f4e7fed8 --- /dev/null +++ b/engine/hooks/_runner/outcome.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json + +OUTCOMES = { + "timed_out", + "crashed", + "blocked", + "caught_error", + "spoke", + "silent", +} + + +def _stderr_has_hook_error(stderr: bytes) -> bool: + return any(line.startswith(b"catstack-hook-error ") for line in stderr.splitlines()) + + +def _stdout_blocks(stdout: bytes) -> bool: + try: + payload = json.loads(stdout.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return False + if not isinstance(payload, dict): + return False + if payload.get("decision") == "block": + return True + if payload.get("continue") is False: + return True + hook_output = payload.get("hookSpecificOutput") + if isinstance(hook_output, dict) and hook_output.get("permissionDecision") == "deny": + return True + return payload.get("permission") == "deny" + + +def classify(exit_code: int | None, stdout: bytes, stderr: bytes, timed_out: bool) -> str: + if timed_out: + return "timed_out" + if exit_code == 2: + return "blocked" + if exit_code not in (0, None): + return "crashed" + if _stderr_has_hook_error(stderr): + return "caught_error" + if _stdout_blocks(stdout): + return "blocked" + if stdout.strip(): + return "spoke" + return "silent" diff --git a/engine/hooks/_runner/run.py b/engine/hooks/_runner/run.py new file mode 100644 index 00000000..1cdf2776 --- /dev/null +++ b/engine/hooks/_runner/run.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import argparse +import datetime +import json +import os +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from outcome import classify + + +def _hooks_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _harness(hooks_root: str) -> str: + for name in ("claude", "cursor", "codex"): + if f"/.{name}/" in hooks_root: + return name + return "unknown" + + +def _stdin_fields(stdin: bytes) -> tuple[str | None, str | None]: + try: + payload = json.loads(stdin.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None, None + if not isinstance(payload, dict): + return None, None + session_id = payload.get("session_id") + if session_id is None: + session_id = payload.get("conversation_id") + return payload.get("hook_event_name"), session_id + + +def _metrics_path() -> str: + root = os.environ.get("CATSTACK_HOOK_METRICS_DIR") + if not root: + root = os.path.expanduser(os.path.join("~", ".cache", "catstack-hook-metrics")) + return os.path.join(root, "runs.jsonl") + + +def _write_metrics(row: dict[str, object], path: str) -> bytes: + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n") + except OSError as exc: + return f"catstack-hook-metrics: could not write row to {path}: {exc}\n".encode() + return b"" + + +def _format_timeout(seconds: float) -> str: + if seconds == int(seconds): + return str(int(seconds)) + return str(seconds) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--timeout", type=float) + parser.add_argument("hook_script") + parser.add_argument("args", nargs=argparse.REMAINDER) + return parser.parse_args(argv) + + +def _row( + hooks_root: str, + hook: str, + script: str, + stdin: bytes, + outcome: str, + exit_code: int | None, + started: float, + stdout: bytes, + stderr: bytes, +) -> dict[str, object]: + event, session_id = _stdin_fields(stdin) + return { + "ts": datetime.datetime.now(datetime.UTC).isoformat(), + "harness": _harness(hooks_root), + "hook": hook, + "script": script, + "event": event, + "session_id": session_id, + "outcome": outcome, + "exit_code": exit_code, + "duration_ms": int((time.monotonic() - started) * 1000), + "stdout_bytes": len(stdout), + "stderr_tail": stderr.decode("utf-8", errors="replace")[-500:], + } + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(sys.argv[1:] if argv is None else argv) + started = time.monotonic() + stdin = sys.stdin.buffer.read() + hooks_root = _hooks_root() + hook, script = args.hook_script.split("/", 1) if "/" in args.hook_script else (args.hook_script, "") + script_path = os.path.join(hooks_root, hook, script) + stdout = b"" + stderr = b"" + exit_code = 1 + timed_out = False + + if not script or not os.path.isfile(script_path): + stderr = f"catstack-hook-runner: no such hook script: {script_path}\n".encode() + else: + proc = subprocess.Popen( + [sys.executable, script_path, *args.args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=os.getcwd(), + env=os.environ.copy(), + ) + try: + stdout, stderr = proc.communicate(stdin, timeout=args.timeout) + exit_code = proc.returncode + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + timed_out = True + stdout = b"" + stderr = ( + f"catstack-hook-runner: {args.hook_script} timed out after " + f"{_format_timeout(args.timeout)}s\n" + ).encode() + exit_code = 1 + + outcome = classify(exit_code, stdout, stderr, timed_out) + row = _row(hooks_root, hook, script, stdin, outcome, exit_code, started, stdout, stderr) + metrics_error = _write_metrics(row, _metrics_path()) + sys.stdout.buffer.write(stdout) + sys.stdout.buffer.flush() + sys.stderr.buffer.write(stderr) + sys.stderr.buffer.write(metrics_error) + sys.stderr.buffer.flush() + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/engine/hooks/_runner/tests/test_outcome.py b/engine/hooks/_runner/tests/test_outcome.py new file mode 100644 index 00000000..2eb68d97 --- /dev/null +++ b/engine/hooks/_runner/tests/test_outcome.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import os +import sys +import unittest + +RUNNER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RUNNER_DIR) + +import outcome + + +class ClassifyOutcomes(unittest.TestCase): + def test_timed_out_wins(self): + self.assertEqual(outcome.classify(2, b'{"decision":"block"}', b"", True), "timed_out") + + def test_exit_two_blocks_before_crash(self): + self.assertEqual(outcome.classify(2, b"", b"", False), "blocked") + + def test_other_nonzero_exit_crashes(self): + self.assertEqual(outcome.classify(1, b'{"decision":"block"}', b"", False), "crashed") + + def test_stderr_hook_error_is_caught_error(self): + self.assertEqual(outcome.classify(0, b"", b"catstack-hook-error x\n", False), "caught_error") + + def test_stderr_hook_error_requires_line_start(self): + self.assertEqual(outcome.classify(0, b"", b"x catstack-hook-error y\n", False), "silent") + + def test_json_decision_block_blocks(self): + self.assertEqual(outcome.classify(0, b'{"decision":"block"}', b"", False), "blocked") + + def test_json_continue_false_blocks(self): + self.assertEqual(outcome.classify(0, b'{"continue":false}', b"", False), "blocked") + + def test_json_permission_decision_deny_blocks(self): + data = b'{"hookSpecificOutput":{"permissionDecision":"deny"}}' + self.assertEqual(outcome.classify(0, data, b"", False), "blocked") + + def test_json_permission_deny_blocks(self): + self.assertEqual(outcome.classify(0, b'{"permission":"deny"}', b"", False), "blocked") + + def test_non_json_stdout_speaks(self): + self.assertEqual(outcome.classify(0, b"{not json", b"", False), "spoke") + + def test_json_array_stdout_speaks(self): + self.assertEqual(outcome.classify(0, b"[1]", b"", False), "spoke") + + def test_whitespace_stdout_is_silent(self): + self.assertEqual(outcome.classify(0, b" \n\t", b"", False), "silent") + + def test_empty_stdout_is_silent(self): + self.assertEqual(outcome.classify(0, b"", b"", False), "silent") + + +if __name__ == "__main__": + unittest.main() diff --git a/engine/hooks/_runner/tests/test_run.py b/engine/hooks/_runner/tests/test_run.py new file mode 100644 index 00000000..7fbc4893 --- /dev/null +++ b/engine/hooks/_runner/tests/test_run.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +RUNNER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +class RunnerCLI(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.home = os.path.join(self.tmp.name, "home") + self.hooks_root = os.path.join(self.home, ".claude", "hooks") + self.runner_dir = os.path.join(self.hooks_root, "_runner") + self.fixture_dir = os.path.join(self.hooks_root, "fixture") + self.metrics_dir = os.path.join(self.tmp.name, "metrics") + os.makedirs(self.runner_dir) + os.makedirs(self.fixture_dir) + shutil.copy2(os.path.join(RUNNER_DIR, "run.py"), os.path.join(self.runner_dir, "run.py")) + shutil.copy2(os.path.join(RUNNER_DIR, "outcome.py"), os.path.join(self.runner_dir, "outcome.py")) + self._write_fixture("silent.py", "") + self._write_fixture("spoke.py", "import json\nprint(json.dumps({'hookSpecificOutput': {'additionalContext': 'hi'}}))\n") + self._write_fixture("block_exit2.py", "import sys\nsys.stderr.write('blocked\\n')\nsys.exit(2)\n") + self._write_fixture("block_json.py", "print('{\"decision\":\"block\",\"reason\":\"x\"}')\n") + self._write_fixture("crash.py", "raise RuntimeError('boom')\n") + self._write_fixture("slow.py", "import time\ntime.sleep(5)\n") + self._write_fixture("caught.py", "import sys\nsys.stderr.write('catstack-hook-error fixture: ValueError: x\\n')\n") + + def _write_fixture(self, name: str, body: str) -> None: + with open(os.path.join(self.fixture_dir, name), "w", encoding="utf-8") as handle: + handle.write(body) + + def _env(self, metrics_dir: str | None = None) -> dict[str, str]: + env = os.environ.copy() + env["CATSTACK_HOOK_METRICS_DIR"] = self.metrics_dir if metrics_dir is None else metrics_dir + return env + + def _stdin(self) -> bytes: + return json.dumps({"hook_event_name": "PromptSubmit", "session_id": "s1"}).encode() + + def _direct(self, script: str) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + [sys.executable, os.path.join(self.fixture_dir, script)], + input=self._stdin(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(), + ) + + def _runner(self, script: str, *args: str, metrics_dir: str | None = None) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + [sys.executable, os.path.join(self.runner_dir, "run.py"), *args, f"fixture/{script}"], + input=self._stdin(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(metrics_dir), + ) + + def _row(self) -> dict[str, object]: + with open(os.path.join(self.metrics_dir, "runs.jsonl"), encoding="utf-8") as handle: + rows = [json.loads(line) for line in handle] + self.assertEqual(len(rows), 1) + return rows[0] + + def _assert_run_matches_direct(self, script: str, outcome: str) -> None: + direct = self._direct(script) + wrapped = self._runner(script) + self.assertEqual(wrapped.stdout, direct.stdout) + self.assertEqual(wrapped.stderr, direct.stderr) + self.assertEqual(wrapped.returncode, direct.returncode) + row = self._row() + self.assertEqual(row["outcome"], outcome) + self.assertEqual(row["harness"], "claude") + self.assertEqual(row["hook"], "fixture") + self.assertEqual(row["script"], script) + self.assertEqual(row["event"], "PromptSubmit") + self.assertEqual(row["session_id"], "s1") + self.assertEqual(row["exit_code"], direct.returncode) + self.assertEqual(row["stdout_bytes"], len(direct.stdout)) + + def test_silent_hook_stays_silent(self): + self._assert_run_matches_direct("silent.py", "silent") + + def test_spoke_hook_keeps_stdout_bytes(self): + self._assert_run_matches_direct("spoke.py", "spoke") + + def test_exit_two_hook_blocks(self): + self._assert_run_matches_direct("block_exit2.py", "blocked") + + def test_block_json_hook_blocks(self): + self._assert_run_matches_direct("block_json.py", "blocked") + + def test_crash_hook_crashes(self): + self._assert_run_matches_direct("crash.py", "crashed") + + def test_caught_error_hook_is_caught(self): + self._assert_run_matches_direct("caught.py", "caught_error") + + def test_slow_hook_times_out(self): + wrapped = self._runner("slow.py", "--timeout", "1") + self.assertEqual(wrapped.stdout, b"") + self.assertIn(b"catstack-hook-runner: fixture/slow.py timed out after 1s\n", wrapped.stderr) + self.assertEqual(wrapped.returncode, 1) + row = self._row() + self.assertEqual(row["outcome"], "timed_out") + self.assertEqual(row["harness"], "claude") + self.assertEqual(row["hook"], "fixture") + self.assertEqual(row["script"], "slow.py") + self.assertEqual(row["event"], "PromptSubmit") + self.assertEqual(row["exit_code"], 1) + + def test_missing_hook_script_records_crash(self): + wrapped = self._runner("missing.py") + self.assertEqual(wrapped.stdout, b"") + self.assertEqual(wrapped.returncode, 1) + self.assertIn(b"catstack-hook-runner: no such hook script:", wrapped.stderr) + row = self._row() + self.assertEqual(row["outcome"], "crashed") + self.assertEqual(row["hook"], "fixture") + self.assertEqual(row["script"], "missing.py") + self.assertEqual(row["exit_code"], 1) + + def test_metrics_write_failure_adds_one_stderr_line(self): + direct = self._direct("spoke.py") + metrics_file = os.path.join(self.tmp.name, "metrics-file") + with open(metrics_file, "w", encoding="utf-8") as handle: + handle.write("") + wrapped = self._runner("spoke.py", metrics_dir=metrics_file) + self.assertEqual(wrapped.stdout, direct.stdout) + self.assertEqual(wrapped.returncode, direct.returncode) + self.assertEqual(direct.stderr, b"") + self.assertIn(b"catstack-hook-metrics: could not write row", wrapped.stderr) + self.assertEqual(len([line for line in wrapped.stderr.splitlines() if line]), 1) + + def test_non_json_stdin_records_null_event_and_conversation_id_fallback(self): + wrapped = subprocess.run( + [sys.executable, os.path.join(self.runner_dir, "run.py"), "fixture/silent.py"], + input=b"not json", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(), + ) + self.assertEqual(wrapped.returncode, 0) + row = self._row() + self.assertIsNone(row["event"]) + self.assertIsNone(row["session_id"]) + + def test_conversation_id_records_session_id_when_session_id_absent(self): + wrapped = subprocess.run( + [sys.executable, os.path.join(self.runner_dir, "run.py"), "fixture/silent.py"], + input=json.dumps({"hook_event_name": "Stop", "conversation_id": "c1"}).encode(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self._env(), + ) + self.assertEqual(wrapped.returncode, 0) + row = self._row() + self.assertEqual(row["event"], "Stop") + self.assertEqual(row["session_id"], "c1") + + +if __name__ == "__main__": + unittest.main() From 0ff3ce7ff3b97cd83d94cf44aecba751ac140775 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:26:54 +0000 Subject: [PATCH 08/13] =?UTF-8?q?invoker:=20wf-1789240290960-24/add-hook-r?= =?UTF-8?q?unner=20=E2=80=94=20Review=20claim:=20engine/hooks/=5Frunner/ru?= =?UTF-8?q?n.py=20runs=20one=20hook=20script,=20passes=20its=20stdout,=20s?= =?UTF-8?q?tderr=20and=20exit=20code=20through=20unchanged,=20and=20append?= =?UTF-8?q?s=20one=20metrics=20row=20with=20a=20classified=20outcome.=20Re?= =?UTF-8?q?view=20lane:=20behavior=20Safety=20invariant:=20A=20hook=20run?= =?UTF-8?q?=20through=20the=20runner=20produces=20byte-identical=20stdout?= =?UTF-8?q?=20and=20the=20same=20exit=20code=20as=20running=20it=20directl?= =?UTF-8?q?y;=20a=20failed=20metrics=20write=20changes=20neither=20and=20a?= =?UTF-8?q?dds=20one=20stderr=20line.=20Effectiveness=20measurement:=20Fix?= =?UTF-8?q?ture=20hooks=20run=20directly=20and=20through=20the=20runner=20?= =?UTF-8?q?give=20identical=20stdout=20bytes=20and=20exit=20codes,=20and?= =?UTF-8?q?=20each=20row=20carries=20the=20expected=20outcome.=20Slice=20r?= =?UTF-8?q?ationale:=20The=20runner=20and=20its=20outcome=20rules=20are=20?= =?UTF-8?q?one=20claim,=20reviewable=20before=20any=20install=20wiring.=20?= =?UTF-8?q?Architectural=20effect:=20Adds=20a=20shared,=20harness-agnostic?= =?UTF-8?q?=20hook=20runner=20under=20engine/hooks/=5Frunner/=20next=20to?= =?UTF-8?q?=20engine/hooks/=5Fmarkers/.=20Dormant=20until=20installed.=20G?= =?UTF-8?q?oal:=20Create=20the=20runner,=20the=20pure=20outcome=20classifi?= =?UTF-8?q?er,=20and=20tests.=20Motivation:=20No=20record=20exists=20today?= =?UTF-8?q?=20of=20which=20hooks=20fire,=20stay=20silent,=20or=20crash.=20?= =?UTF-8?q?Alternative=20considerations:=20Editing=20all=2082=20entrypoint?= =?UTF-8?q?s=20to=20import=20a=20logging=20decorator=20was=20rejected:=20i?= =?UTF-8?q?t=20cannot=20record=20import=20errors,=20syntax=20errors,=20or?= =?UTF-8?q?=20timeouts,=20and=20touches=20every=20hook.=20An=20in-process?= =?UTF-8?q?=20runpy=20runner=20was=20rejected=20because=20a=20harness-kill?= =?UTF-8?q?ed=20or=20hanging=20hook=20would=20take=20the=20recorder=20down?= =?UTF-8?q?=20with=20it.=20Implementation=20details:=20A=20subprocess=20wr?= =?UTF-8?q?apper=20plus=20a=20pure=20classifier=20in=20outcome.py.=20Non-g?= =?UTF-8?q?oals:=20No=20install.sh,=20settings,=20or=20hook=20fragment=20c?= =?UTF-8?q?hange;=20no=20report=20CLI;=20no=20change=20to=20any=20existing?= =?UTF-8?q?=20hook.=20Layer:=20domain=20Feature=20state:=20dormant=20Files?= =?UTF-8?q?:=20-=20engine/hooks/=5Frunner/run.py=20-=20engine/hooks/=5Frun?= =?UTF-8?q?ner/outcome.py=20-=20engine/hooks/=5Frunner/tests/test=5Foutcom?= =?UTF-8?q?e.py=20-=20engine/hooks/=5Frunner/tests/test=5Frun.py=20-=20eng?= =?UTF-8?q?ine/hooks/=5Frunner/tests/fixtures/=20Change=20types:=20-=20eng?= =?UTF-8?q?ine/hooks/=5Frunner/run.py:=20create=20-=20engine/hooks/=5Frunn?= =?UTF-8?q?er/outcome.py:=20create=20-=20engine/hooks/=5Frunner/tests/test?= =?UTF-8?q?=5Foutcome.py:=20create=20-=20engine/hooks/=5Frunner/tests/test?= =?UTF-8?q?=5Frun.py:=20create=20-=20engine/hooks/=5Frunner/tests/fixtures?= =?UTF-8?q?/:=20create=20Acceptance=20criteria:=20-=20`python3=20-m=20unit?= =?UTF-8?q?test=20discover=20-s=20engine/hooks/=5Frunner/tests=20-v`=20exi?= =?UTF-8?q?ts=200.=20-=20`bash=20scripts/run=5Fall=5Ftests.sh`=20exits=200?= =?UTF-8?q?.=20-=20`python3=20scripts/check=5Fhook=5Ftest=5Fcoverage.py`?= =?UTF-8?q?=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: e5ba51e8-de9e-430f-8672-e0eef3e3bafc From 5d4815896c26efc24416883982aa3e50944cd116 Mon Sep 17 00:00:00 2001 From: Invoker Date: Sat, 12 Sep 2026 19:28:02 +0000 Subject: [PATCH 09/13] Describe hook runner metrics --- engine/hooks/_runner/README.md | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 engine/hooks/_runner/README.md diff --git a/engine/hooks/_runner/README.md b/engine/hooks/_runner/README.md new file mode 100644 index 00000000..92f705de --- /dev/null +++ b/engine/hooks/_runner/README.md @@ -0,0 +1,48 @@ +# Hook runner + +Usage: + +```sh +python3 engine/hooks/_runner/run.py [--timeout SECONDS] / [args...] +``` + +The runner reads stdin, runs the hook script in a subprocess with that stdin, +passes through the hook's stdout, stderr, and exit code, then appends one JSONL +metrics row. + +Rows are written to `~/.cache/catstack-hook-metrics/runs.jsonl` by default. Set +`CATSTACK_HOOK_METRICS_DIR` to write `runs.jsonl` under a different directory. + +Each row contains: + +- `ts`: UTC timestamp for the recorded run. +- `harness`: `claude`, `cursor`, `codex`, or `unknown`, inferred from the hooks path. +- `hook`: the first path segment from `/`. +- `script`: the rest of the hook script path after `hook`. +- `event`: `hook_event_name` from JSON stdin, or `null`. +- `session_id`: `session_id` from JSON stdin, falling back to `conversation_id`, or `null`. +- `outcome`: classified result for the run. +- `exit_code`: hook process exit code recorded by the runner. +- `duration_ms`: elapsed runner time in milliseconds. +- `stdout_bytes`: number of stdout bytes emitted by the hook. +- `stderr_tail`: final 500 decoded stderr characters, with invalid UTF-8 replaced. + +Outcome precedence is: + +1. `timed_out` when the runner timeout kills the hook. +2. `blocked` when `exit_code` is `2`. +3. `crashed` when `exit_code` is any other nonzero value. +4. `caught_error` when any stderr line starts with `catstack-hook-error `. +5. `blocked` when stdout is a JSON object with `decision: "block"`, `continue: false`, + `hookSpecificOutput.permissionDecision: "deny"`, or `permission: "deny"`. +6. `spoke` when stdout has non-whitespace bytes. +7. `silent` otherwise. + +If a metrics row cannot be written, the runner appends one stderr line after the +hook stderr: + +```text +catstack-hook-metrics: could not write row to : +``` + +Nothing calls this runner until install wiring lands. From f65a4f303ec27a4b508ff022456a57fa4b62ed9f Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:28:23 +0000 Subject: [PATCH 10/13] =?UTF-8?q?invoker:=20wf-1789240290960-24/describe-h?= =?UTF-8?q?ook-runner=20=E2=80=94=20Review=20claim:=20engine/hooks/=5Frunn?= =?UTF-8?q?er/README.md=20states=20what=20the=20runner=20records,=20where?= =?UTF-8?q?=20the=20rows=20go,=20and=20the=20outcome=20precedence.=20Revie?= =?UTF-8?q?w=20lane:=20docs=20Safety=20invariant:=20Only=20engine/hooks/?= =?UTF-8?q?=5Frunner/README.md=20changes;=20no=20code,=20test,=20or=20conf?= =?UTF-8?q?ig=20file=20is=20edited.=20Effectiveness=20measurement:=20Every?= =?UTF-8?q?=20row=20field=20and=20outcome=20named=20in=20the=20README=20ap?= =?UTF-8?q?pears=20in=20run.py=20and=20outcome.py,=20checked=20by=20readin?= =?UTF-8?q?g=20both.=20Slice=20rationale:=20Prose=20in=20its=20own=20commi?= =?UTF-8?q?t=20so=20the=20code=20commit=20stays=20one=20claim.=20Architect?= =?UTF-8?q?ural=20effect:=20None;=20prose=20only.=20Goal:=20Create=20engin?= =?UTF-8?q?e/hooks/=5Frunner/README.md.=20Motivation:=20Readers=20of=20the?= =?UTF-8?q?=20hook=20directory=20need=20the=20row=20format=20without=20rea?= =?UTF-8?q?ding=20code.=20Alternative=20considerations:=20Code=20comments?= =?UTF-8?q?=20were=20rejected;=20the=20repo=20forbids=20new=20comments.=20?= =?UTF-8?q?Implementation=20details:=20One=20new=20Markdown=20file.=20Non-?= =?UTF-8?q?goals:=20No=20code,=20test,=20or=20config=20edits.=20Layer:=20d?= =?UTF-8?q?ocs=20Feature=20state:=20dormant=20Files:=20-=20engine/hooks/?= =?UTF-8?q?=5Frunner/README.md=20Change=20types:=20-=20engine/hooks/=5Frun?= =?UTF-8?q?ner/README.md:=20create=20Acceptance=20criteria:=20-=20`test=20?= =?UTF-8?q?-f=20engine/hooks/=5Frunner/README.md`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 917f28d4-7b67-447f-bfd1-7a26a20061da From ed0d35ec79c24811d951b238d879d813f6ce6b29 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:38:26 +0000 Subject: [PATCH 11/13] =?UTF-8?q?invoker:=20wf-1789240290960-24/verify-hoo?= =?UTF-8?q?k-runner=20=E2=80=94=20Review=20claim:=20The=20runner=20tests,?= =?UTF-8?q?=20the=20repo=20test=20suite,=20and=20the=20hook=20coverage=20g?= =?UTF-8?q?ate=20pass.=20Review=20lane:=20proof=20Safety=20invariant:=20Ve?= =?UTF-8?q?rification=20is=20read-only=20and=20does=20not=20alter=20any=20?= =?UTF-8?q?repository=20file.=20Effectiveness=20measurement:=20The=20three?= =?UTF-8?q?=20commands=20are=20the=20direct=20measurement.=20Slice=20ratio?= =?UTF-8?q?nale:=20One=20focused=20proof=20before=20review.=20Architectura?= =?UTF-8?q?l=20effect:=20None;=20verification=20only.=20Goal:=20Prove=20pa?= =?UTF-8?q?ss-through=20and=20outcome=20classification.=20Motivation:=20Ru?= =?UTF-8?q?nning=20the=20tests=20is=20the=20proof.=20Alternative=20conside?= =?UTF-8?q?rations:=20A=20live-harness=20run=20is=20deferred=20to=20step?= =?UTF-8?q?=202,=20where=20the=20runner=20is=20installed.=20Implementation?= =?UTF-8?q?=20details:=20Run=20the=20three=20commands.=20Non-goals:=20No?= =?UTF-8?q?=20mutations.=20Layer:=20app=5Fregression=20Feature=20state:=20?= =?UTF-8?q?active=20Acceptance=20criteria:=20-=20Exits=200=20only=20when?= =?UTF-8?q?=20all=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: e73029a9-e2ea-4d00-8c66-f727763900a1 From 6d256c4fdc58403652993486920efd69faf861d5 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 19:41:32 +0000 Subject: [PATCH 12/13] =?UTF-8?q?invoker:=20wf-1789240290960-24/scrub-hand?= =?UTF-8?q?off-artifacts=20=E2=80=94=20Review=20claim:=20No=20ephemeral=20?= =?UTF-8?q?inter-task=20handoff=20files=20remain=20in=20the=20worktree=20b?= =?UTF-8?q?efore=20the=20merge=20gate.=20Review=20lane:=20cleanup=20Safety?= =?UTF-8?q?=20invariant:=20The=20scrub=20script=20only=20checks=20for=20kn?= =?UTF-8?q?own=20handoff=20artifact=20names=20and=20never=20touches=20sour?= =?UTF-8?q?ce,=20tests,=20or=20other=20repository=20files.=20Effectiveness?= =?UTF-8?q?=20measurement:=20The=20script=20exits=20non-zero=20if=20any=20?= =?UTF-8?q?handoff=20artifact=20remains.=20Slice=20rationale:=20Required?= =?UTF-8?q?=20terminal=20scrub=20for=20every=20implementation=20workflow.?= =?UTF-8?q?=20Architectural=20effect:=20None;=20hygiene=20only.=20Goal:=20?= =?UTF-8?q?Leave=20the=20branch=20free=20of=20handoff=20artifacts.=20Motiv?= =?UTF-8?q?ation:=20Handoff=20files=20must=20not=20reach=20the=20PR.=20Alt?= =?UTF-8?q?ernative=20considerations:=20Manual=20cleanup=20was=20rejected?= =?UTF-8?q?=20as=20non-deterministic.=20Implementation=20details:=20Run=20?= =?UTF-8?q?scripts/scrub-handoff-artifacts.sh.=20Layer=20exception:=20allo?= =?UTF-8?q?wed=20--=20the=20terminal=20scrub=20must=20run=20after=20every?= =?UTF-8?q?=20task=20in=20the=20workflow,=20including=20the=20docs=20task.?= =?UTF-8?q?=20Non-goals:=20No=20product=20edits.=20Layer:=20app=5Fregressi?= =?UTF-8?q?on=20Feature=20state:=20active=20Acceptance=20criteria:=20-=20`?= =?UTF-8?q?bash=20scripts/scrub-handoff-artifacts.sh`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: cb09f6f2-66d1-4564-adb3-38e63423dbfa From 6a832645529337c24259963a167619c89e3596ed Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Sat, 12 Sep 2026 12:56:15 -0700 Subject: [PATCH 13/13] fix(hooks/_runner): run on Python 3.9 and forward the hook result when recording fails datetime.UTC exists only on 3.11+, so on CI's Python 3.9 the runner raised after the hook ran and dropped the hook's stdout and exit code. Use datetime.timezone.utc, and catch any failure while classifying or writing the metrics row so the hook's output is still forwarded, with one stderr line naming the error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G95BG4NxDsW4NA6fcipHrv Change-Id: Ie823ca268e85e83dd57d3b6f1b026946daeb4c99 --- engine/hooks/_runner/run.py | 11 +++++++---- engine/hooks/_runner/tests/test_run.py | 9 +++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/engine/hooks/_runner/run.py b/engine/hooks/_runner/run.py index 1cdf2776..bcc2fd7a 100644 --- a/engine/hooks/_runner/run.py +++ b/engine/hooks/_runner/run.py @@ -81,7 +81,7 @@ def _row( ) -> dict[str, object]: event, session_id = _stdin_fields(stdin) return { - "ts": datetime.datetime.now(datetime.UTC).isoformat(), + "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(), "harness": _harness(hooks_root), "hook": hook, "script": script, @@ -132,9 +132,12 @@ def main(argv: list[str] | None = None) -> int: ).encode() exit_code = 1 - outcome = classify(exit_code, stdout, stderr, timed_out) - row = _row(hooks_root, hook, script, stdin, outcome, exit_code, started, stdout, stderr) - metrics_error = _write_metrics(row, _metrics_path()) + try: + outcome = classify(exit_code, stdout, stderr, timed_out) + row = _row(hooks_root, hook, script, stdin, outcome, exit_code, started, stdout, stderr) + metrics_error = _write_metrics(row, _metrics_path()) + except Exception as exc: + metrics_error = f"catstack-hook-metrics: could not record run: {type(exc).__name__}: {exc}\n".encode() sys.stdout.buffer.write(stdout) sys.stdout.buffer.flush() sys.stderr.buffer.write(stderr) diff --git a/engine/hooks/_runner/tests/test_run.py b/engine/hooks/_runner/tests/test_run.py index 7fbc4893..cc5b2186 100644 --- a/engine/hooks/_runner/tests/test_run.py +++ b/engine/hooks/_runner/tests/test_run.py @@ -68,6 +68,15 @@ def _row(self) -> dict[str, object]: self.assertEqual(len(rows), 1) return rows[0] + def test_recording_failure_still_forwards_hook_result(self) -> None: + with open(os.path.join(self.runner_dir, "outcome.py"), "w", encoding="utf-8") as handle: + handle.write("def classify(*args, **kwargs):\n raise RuntimeError('classify broke')\n") + direct = self._direct("spoke.py") + wrapped = self._runner("spoke.py") + self.assertEqual(wrapped.stdout, direct.stdout) + self.assertEqual(wrapped.returncode, direct.returncode) + self.assertIn(b"catstack-hook-metrics: could not record run: RuntimeError: classify broke", wrapped.stderr) + def _assert_run_matches_direct(self, script: str, outcome: str) -> None: direct = self._direct(script) wrapped = self._runner(script)