From 90b25516118f531d7e2d65ab83c44285af289c9c Mon Sep 17 00:00:00 2001 From: Invoker Date: Sat, 12 Sep 2026 19:26:10 +0000 Subject: [PATCH 01/17] 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 02/17] =?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 03/17] 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 04/17] =?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 05/17] =?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 06/17] =?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 07/17] 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) From d1a6fb51a76373451715ab7a8d8889e2e73576d5 Mon Sep 17 00:00:00 2001 From: Invoker Date: Sat, 12 Sep 2026 20:19:12 +0000 Subject: [PATCH 08/17] Wrap installed hooks with runner --- .../_runner/tests/test_wrap_installed.py | 215 ++++++++++++++++++ engine/hooks/_runner/wrap_installed.py | 183 +++++++++++++++ install.sh | 6 + scripts/mirror_stop_hooks_to_subagent_stop.py | 11 +- tests/test_install.py | 24 +- 5 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 engine/hooks/_runner/tests/test_wrap_installed.py create mode 100644 engine/hooks/_runner/wrap_installed.py diff --git a/engine/hooks/_runner/tests/test_wrap_installed.py b/engine/hooks/_runner/tests/test_wrap_installed.py new file mode 100644 index 00000000..52b98180 --- /dev/null +++ b/engine/hooks/_runner/tests/test_wrap_installed.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +RUNNER_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RUNNER_DIR)) + +import wrap_installed + + +class WrapInstalled(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.home = Path(self.tmp.name) + self.old_home = os.environ.get("HOME") + os.environ["HOME"] = str(self.home) + self.addCleanup(self._restore_home) + self.claude_path = self.home / ".claude" / "settings.json" + self.cursor_path = self.home / ".cursor" / "hooks.json" + self.codex_path = self.home / ".codex" / "hooks.json" + self._write_json(self.claude_path, self._claude_fixture()) + self._write_json(self.cursor_path, self._cursor_fixture()) + self._write_json(self.codex_path, self._codex_fixture()) + + def _restore_home(self): + if self.old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self.old_home + + def _write_json(self, path: Path, data: object): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + def _read_json(self, path: Path) -> object: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + def _run(self) -> tuple[int, str]: + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = wrap_installed.main() + return code, output.getvalue() + + def _claude_fixture(self) -> dict: + return { + "model": "sonnet", + "hooks": { + "Stop": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.claude/hooks/diu-stop/claude_stop_check.py", + "timeout": 30, + "keep": "yes", + }, + { + "type": "command", + "command": "python3 $HOME/bin/foreign_hook.py", + "timeout": 7, + }, + ], + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.claude/hooks/cat-mode-default/claude_prompt_submit.py --mode gentle", + } + ] + } + ], + }, + } + + def _cursor_fixture(self) -> dict: + return { + "version": 1, + "hooks": { + "preToolUse": [ + { + "matcher": "*", + "command": "python3 $HOME/.cursor/hooks/scope-lock/cursor_pretool_scope.py", + "timeout": 5, + } + ], + "stop": [ + { + "type": "prompt", + "prompt": "Find the assistant response.", + "timeout": 30, + } + ], + }, + } + + def _codex_fixture(self) -> dict: + return { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.codex/hooks/pr-schema-gate/claude_pretooluse.py", + "timeout": 5, + } + ], + } + ] + } + } + + def test_match_direct(self): + self.assertEqual( + wrap_installed.match_direct("python3 $HOME/.claude/hooks/diu-stop/claude_stop_check.py --x"), + ("claude", "diu-stop", "claude_stop_check.py", " --x"), + ) + self.assertIsNone( + wrap_installed.match_direct("python3 $HOME/.claude/hooks/_runner/run.py --timeout 5 x/y.py") + ) + + def test_wraps_all_harnesses_and_is_idempotent(self): + claude_before = self._read_json(self.claude_path) + cursor_before = self._read_json(self.cursor_path) + code, output = self._run() + self.assertEqual(code, 0, output) + self.assertIn(f"wrapped 2 entr(ies) in {self.claude_path}", output) + self.assertIn(f"wrapped 1 entr(ies) in {self.cursor_path}", output) + self.assertIn(f"wrapped 1 entr(ies) in {self.codex_path}", output) + + claude = self._read_json(self.claude_path) + cursor = self._read_json(self.cursor_path) + codex = self._read_json(self.codex_path) + + claude_stop = claude["hooks"]["Stop"][0]["hooks"] + self.assertEqual( + claude_stop[0]["command"], + "python3 $HOME/.claude/hooks/_runner/run.py --timeout 29.5 diu-stop/claude_stop_check.py", + ) + self.assertEqual(claude_stop[0]["keep"], "yes") + self.assertEqual(claude_stop[1], claude_before["hooks"]["Stop"][0]["hooks"][1]) + self.assertEqual( + claude["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "python3 $HOME/.claude/hooks/_runner/run.py --timeout 59.5 cat-mode-default/claude_prompt_submit.py --mode gentle", + ) + self.assertEqual( + cursor["hooks"]["preToolUse"][0]["command"], + "python3 $HOME/.cursor/hooks/_runner/run.py --timeout 4.5 scope-lock/cursor_pretool_scope.py", + ) + self.assertEqual(cursor["hooks"]["stop"][0], cursor_before["hooks"]["stop"][0]) + self.assertEqual( + codex["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "python3 $HOME/.codex/hooks/_runner/run.py --timeout 4.5 pr-schema-gate/claude_pretooluse.py", + ) + + first_bytes = { + path: path.read_bytes() + for path in (self.claude_path, self.cursor_path, self.codex_path) + } + code, output = self._run() + self.assertEqual(code, 0, output) + self.assertIn(f"already up to date: {self.claude_path}", output) + self.assertEqual(first_bytes[self.claude_path], self.claude_path.read_bytes()) + self.assertEqual(first_bytes[self.cursor_path], self.cursor_path.read_bytes()) + self.assertEqual(first_bytes[self.codex_path], self.codex_path.read_bytes()) + + def test_direct_duplicate_of_wrapped_hook_collapses_to_one_entry(self): + code, output = self._run() + self.assertEqual(code, 0, output) + cursor = self._read_json(self.cursor_path) + cursor["hooks"]["preToolUse"].append( + { + "matcher": "*", + "command": "python3 $HOME/.cursor/hooks/scope-lock/cursor_pretool_scope.py", + "timeout": 5, + } + ) + self._write_json(self.cursor_path, cursor) + + code, output = self._run() + self.assertEqual(code, 0, output) + cursor = self._read_json(self.cursor_path) + matches = [ + entry + for entry in cursor["hooks"]["preToolUse"] + if "scope-lock/cursor_pretool_scope.py" in entry.get("command", "") + ] + self.assertEqual(len(matches), 1, matches) + + def test_malformed_json_is_unchecked_and_exits_two(self): + self.claude_path.write_text("{not-json", encoding="utf-8") + code, output = self._run() + self.assertEqual(code, 2) + self.assertIn(f"unchecked: {self.claude_path}:", output) + self.assertIn(f"wrapped 1 entr(ies) in {self.cursor_path}", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/engine/hooks/_runner/wrap_installed.py b/engine/hooks/_runner/wrap_installed.py new file mode 100644 index 00000000..c3d3f69b --- /dev/null +++ b/engine/hooks/_runner/wrap_installed.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import copy +import json +import os +import re +from collections.abc import Iterator +from pathlib import Path + +CONFIGS = ( + ("claude", ".claude/settings.json"), + ("cursor", ".cursor/hooks.json"), + ("codex", ".codex/hooks.json"), +) + +DIRECT_RE = re.compile( + r"^python3 \$HOME/\.(claude|cursor|codex)/hooks/([^/\s]+)/([^/\s]+\.py)((?:\s+.*)?)$" +) +RUNNER_RE = re.compile( + r"^python3 \$HOME/\.(claude|cursor|codex)/hooks/_runner/run\.py(?:\s+--timeout\s+\S+)?\s+([^/\s]+)/([^/\s]+\.py)((?:\s+.*)?)$" +) +HOOKS_REF_RE = re.compile(r"\$HOME/\.(claude|cursor|codex)/hooks/") + + +def match_direct(command: str) -> tuple[str, str, str, str] | None: + match = DIRECT_RE.fullmatch(command) + if not match: + return None + harness, hook, script, trailing = match.groups() + if hook == "_runner": + return None + return harness, hook, script, trailing + + +def _is_runner(command: str) -> bool: + for harness in ("claude", "cursor", "codex"): + prefix = f"python3 $HOME/.{harness}/hooks/_runner/run.py" + if command.startswith(prefix): + return True + return False + + +def _timeout(entry: dict[str, object]) -> float: + value = entry.get("timeout") + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value - 0.5 + return 59.5 + + +def _format_timeout(value: float) -> str: + if value == int(value): + return str(int(value)) + return str(value) + + +def _iter_command_objects(node: object) -> Iterator[dict[str, object]]: + if isinstance(node, dict): + if isinstance(node.get("command"), str): + yield node + for value in node.values(): + yield from _iter_command_objects(value) + elif isinstance(node, list): + for item in node: + yield from _iter_command_objects(item) + + +def _catstack_identity(command: str) -> tuple[str, str, str, str] | None: + direct = match_direct(command) + if direct: + return direct + match = RUNNER_RE.fullmatch(command) + if not match: + return None + harness, hook, script, trailing = match.groups() + if hook == "_runner": + return None + return harness, hook, script, trailing + + +def _entry_identity(item: object) -> tuple[str, tuple[tuple[str, str, str, str], ...]] | None: + if not isinstance(item, dict) or not isinstance(item.get("hooks"), list): + return None + identities = [] + for hook in item["hooks"]: + if not isinstance(hook, dict) or not isinstance(hook.get("command"), str): + continue + identity = _catstack_identity(hook["command"]) + if identity is not None: + identities.append(identity) + if not identities: + return None + outer = {key: value for key, value in item.items() if key != "hooks"} + return json.dumps(outer, sort_keys=True), tuple(identities) + + +def _dedupe_command_lists(node: object) -> bool: + changed = False + if isinstance(node, list): + seen: set[tuple[str, str, str, str]] = set() + seen_entries: set[tuple[str, tuple[tuple[str, str, str, str], ...]]] = set() + kept = [] + for item in node: + identity = None + if isinstance(item, dict) and isinstance(item.get("command"), str): + identity = _catstack_identity(item["command"]) + if identity is not None and identity in seen: + changed = True + continue + if identity is not None: + seen.add(identity) + changed = _dedupe_command_lists(item) or changed + entry_identity = _entry_identity(item) + if entry_identity is not None and entry_identity in seen_entries: + changed = True + continue + if entry_identity is not None: + seen_entries.add(entry_identity) + kept.append(item) + if len(kept) != len(node): + node[:] = kept + elif isinstance(node, dict): + for value in node.values(): + changed = _dedupe_command_lists(value) or changed + return changed + + +def wrap_data(data: object) -> tuple[object, int, list[str]]: + wrapped = 0 + unwrapped = [] + result = copy.deepcopy(data) + hooks = result.get("hooks") if isinstance(result, dict) else None + for entry in _iter_command_objects(hooks): + command = entry["command"] + if _is_runner(command): + continue + match = match_direct(command) + if match: + harness, hook, script, trailing = match + timeout = _format_timeout(_timeout(entry)) + entry["command"] = ( + f"python3 $HOME/.{harness}/hooks/_runner/run.py --timeout {timeout} " + f"{hook}/{script}{trailing}" + ) + wrapped += 1 + elif HOOKS_REF_RE.search(command): + unwrapped.append(command) + _dedupe_command_lists(hooks) + return result, wrapped, unwrapped + + +def process(path: Path) -> int: + if not path.exists(): + print(f"skip: {path} missing") + return 0 + try: + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + print(f"unchecked: {path}: {exc}") + return 2 + wrapped, count, unwrapped = wrap_data(data) + for command in unwrapped: + print(f"unwrapped: {path}: {command}") + if wrapped == data: + print(f"already up to date: {path}") + return 0 + with path.open("w", encoding="utf-8") as handle: + json.dump(wrapped, handle, indent=2) + handle.write("\n") + print(f"wrapped {count} entr(ies) in {path}") + return 0 + + +def main() -> int: + home = Path(os.path.expanduser("~")) + status = 0 + for _, relative in CONFIGS: + status = max(status, process(home / relative)) + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/install.sh b/install.sh index dfdd3347..ede21b4f 100755 --- a/install.sh +++ b/install.sh @@ -217,6 +217,7 @@ fi echo "--- claude hooks (\$HOME/.claude/hooks) ---" mkdir -p "$HOME/.claude/hooks" link_item "_markers" "$REPO_DIR/engine/hooks/_markers" "$HOME/.claude/hooks/_markers" +link_item "_runner" "$REPO_DIR/engine/hooks/_runner" "$HOME/.claude/hooks/_runner" link_item "diu-stop" "$REPO_DIR/engine/hooks/diu-stop" "$HOME/.claude/hooks/diu-stop" link_item "bug-complaint-leak" "$REPO_DIR/engine/hooks/bug-complaint-leak" "$HOME/.claude/hooks/bug-complaint-leak" link_item "demo-freeze" "$REPO_DIR/engine/hooks/demo-freeze" "$HOME/.claude/hooks/demo-freeze" @@ -266,6 +267,7 @@ bash "$REPO_DIR/scripts/install-git-template.sh" echo "--- cursor hooks dir (\$HOME/.cursor/hooks) ---" mkdir -p "$HOME/.cursor/hooks" +link_item "_runner" "$REPO_DIR/engine/hooks/_runner" "$HOME/.cursor/hooks/_runner" link_item "bug-complaint-leak" "$REPO_DIR/engine/hooks/bug-complaint-leak" "$HOME/.cursor/hooks/bug-complaint-leak" link_item "reflect-on-thrash" "$REPO_DIR/engine/hooks/reflect-on-thrash" "$HOME/.cursor/hooks/reflect-on-thrash" link_item "scope-lock" "$REPO_DIR/engine/hooks/scope-lock" "$HOME/.cursor/hooks/scope-lock" @@ -280,6 +282,7 @@ link_item "ui-input-guard" "$REPO_DIR/engine/hooks/ui-input-guard" "$HOME/.curso echo "--- codex hooks (\$HOME/.codex/hooks) ---" mkdir -p "$HOME/.codex/hooks" +link_item "_runner" "$REPO_DIR/engine/hooks/_runner" "$HOME/.codex/hooks/_runner" link_item "diu-stop" "$REPO_DIR/engine/hooks/diu-stop" "$HOME/.codex/hooks/diu-stop" link_item "scope-lock" "$REPO_DIR/engine/hooks/scope-lock" "$HOME/.codex/hooks/scope-lock" link_item "auto-pr" "$REPO_DIR/engine/hooks/auto-pr" "$HOME/.codex/hooks/auto-pr" @@ -410,6 +413,9 @@ python3 "$REPO_DIR/engine/hooks/build-the-lever/install_codex_hook.py" python3 "$REPO_DIR/engine/hooks/split-scope/install_codex_hook.py" python3 "$REPO_DIR/engine/hooks/repeat-error-stop/install_codex_hook.py" +echo "--- wrap installed hook commands with runner ---" +python3 "$REPO_DIR/engine/hooks/_runner/wrap_installed.py" + # CLAUDE.md is a dedicated file with no other unrelated config mixed into it # (unlike settings.json/config.toml above), so it symlinks directly like # cursor.hooks.json -- link_item still refuses to clobber a real file diff --git a/scripts/mirror_stop_hooks_to_subagent_stop.py b/scripts/mirror_stop_hooks_to_subagent_stop.py index 91407389..7a44bae1 100644 --- a/scripts/mirror_stop_hooks_to_subagent_stop.py +++ b/scripts/mirror_stop_hooks_to_subagent_stop.py @@ -26,6 +26,7 @@ import glob import json import os +import re import sys from dataclasses import dataclass, field @@ -85,7 +86,15 @@ def _mirrored_entry(entry: dict) -> dict: def _entry_is_for(entry: dict, prefixes: list[str]) -> bool: for hook in entry.get("hooks", []) if isinstance(entry, dict) else []: command = str(hook.get("command", "")) if isinstance(hook, dict) else "" - if any(prefix in command for prefix in prefixes): + if any( + prefix in command + or re.search( + r"\$HOME/\.claude/hooks/_runner/run\.py\b.*\s" + + re.escape(prefix.removeprefix(HOOKS_PREFIX_LITERAL)), + command, + ) + for prefix in prefixes + ): return True return False diff --git a/tests/test_install.py b/tests/test_install.py index 32bb2b08..3a7b1892 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1123,6 +1123,22 @@ def test_rerun_with_override_stays_idempotent(self): ) +def wrapped_claude_command(hook): + command = hook["command"] + match = re.match(r"^python3 \$HOME/\.claude/hooks/([^/\s]+)/([^/\s]+\.py)((?:\s+.*)?)$", command) + if not match: + return command + name, script, trailing = match.groups() + timeout = hook.get("timeout") + if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): + value = timeout - 0.5 + else: + value = 59.5 + if value == int(value): + value = int(value) + return f"python3 $HOME/.claude/hooks/_runner/run.py --timeout {value} {name}/{script}{trailing}" + + class TestSubagentStopInheritance(unittest.TestCase): """Every hook that wires Stop is also wired as SubagentStop after install.sh, unless its manifest opts out with a reason. Parametrized @@ -1154,12 +1170,13 @@ def test_every_stop_hook_is_mirrored_or_opted_out_with_reason(self): for entry in manifest.entries: for hook in entry["hooks"]: with self.subTest(hook=manifest.name, command=hook["command"]): - self.assertIn(hook["command"], stop) + expected = wrapped_claude_command(hook) + self.assertIn(expected, stop) if manifest.inherit: - self.assertIn(hook["command"], subagent_stop) + self.assertIn(expected, subagent_stop) else: self.assertTrue(manifest.reason) - self.assertNotIn(hook["command"], subagent_stop) + self.assertNotIn(expected, subagent_stop) self.assertIn(f"skip claude SubagentStop {manifest.name} (opt-out: ", self.result.stdout) def test_subagent_stop_entries_carry_no_tool_matcher(self): @@ -1170,7 +1187,6 @@ def test_subagent_stop_entries_carry_no_tool_matcher(self): def test_rerun_keeps_subagent_stop_unchanged(self): second = run_install(self.fake_home) self.assertEqual(second.returncode, 0, second.stderr) - self.assertIn("SubagentStop mirror already up to date", second.stdout) with open(os.path.join(self.fake_home, ".claude", "settings.json")) as handle: self.assertEqual(json.load(handle)["hooks"]["SubagentStop"], self.settings["hooks"]["SubagentStop"]) From db5412e6afb8fd8fe400652510a8bc9a6f411a5c Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 20:19:45 +0000 Subject: [PATCH 09/17] =?UTF-8?q?invoker:=20wf-1789240443754-26/wrap-insta?= =?UTF-8?q?lled-hooks=20=E2=80=94=20Review=20claim:=20./install.sh=20wraps?= =?UTF-8?q?=20every=20installed=20catstack=20hook=20entry=20in=20Claude,?= =?UTF-8?q?=20Cursor=20and=20Codex=20with=20the=20runner,=20and=20a=20seco?= =?UTF-8?q?nd=20install=20changes=20nothing.=20Review=20lane:=20behavior?= =?UTF-8?q?=20Safety=20invariant:=20Every=20hook=20registered=20for=20Clau?= =?UTF-8?q?de,=20Cursor=20and=20Codex=20goes=20through=20the=20runner;=20a?= =?UTF-8?q?=20rerun=20creates=20no=20duplicate=20entries;=20non-catstack?= =?UTF-8?q?=20entries=20are=20kept=20byte-for-byte.=20Effectiveness=20meas?= =?UTF-8?q?urement:=20In=20a=20temp=20HOME=20the=20three=20files=20hold=20?= =?UTF-8?q?zero=20unwrapped=20catstack=20hook=20entries=20after=20install,?= =?UTF-8?q?=20and=20a=20second=20install=20leaves=20them=20byte-identical.?= =?UTF-8?q?=20Slice=20rationale:=20All=20three=20harnesses=20in=20one=20PR?= =?UTF-8?q?=20so=20hooks=20behave=20the=20same=20everywhere.=20Architectur?= =?UTF-8?q?al=20effect:=20Every=20hook=20run=20in=20every=20harness=20now?= =?UTF-8?q?=20produces=20a=20metrics=20row.=20Goal:=20Add=20wrap=5Finstall?= =?UTF-8?q?ed.py,=20link=20=5Frunner=20into=20all=20three=20hook=20roots,?= =?UTF-8?q?=20and=20call=20it=20last=20in=20install.sh.=20Motivation:=20Th?= =?UTF-8?q?e=20runner=20is=20inert=20until=20the=20harness=20calls=20it.?= =?UTF-8?q?=20Alternative=20considerations:=20Rewriting=20the=2069=20per-h?= =?UTF-8?q?ook=20fragments=20and=20the=20strings=20embedded=20in=20Cursor?= =?UTF-8?q?=20installers=20was=20rejected:=20it=20touches=20every=20hook,?= =?UTF-8?q?=20and=20a=20new=20hook=20added=20without=20the=20wrapper=20wou?= =?UTF-8?q?ld=20bypass=20metrics.=20A=20post-install=20pass=20covers=20fut?= =?UTF-8?q?ure=20hooks=20automatically.=20Implementation=20details:=20A=20?= =?UTF-8?q?post-install=20pass=20over=20the=20three=20harness=20config=20f?= =?UTF-8?q?iles=20rewrites=20matching=20hook=20entries=20to=20call=20the?= =?UTF-8?q?=20runner.=20Non-goals:=20No=20change=20to=20run.py=20or=20outc?= =?UTF-8?q?ome.py,=20to=20any=20hook=20script,=20or=20to=20the=20Codex=20`?= =?UTF-8?q?notify`=20line=20in=20config.toml.=20Layer:=20app=5Fbridge=20Fe?= =?UTF-8?q?ature=20state:=20active=20Files:=20-=20engine/hooks/=5Frunner/w?= =?UTF-8?q?rap=5Finstalled.py=20-=20engine/hooks/=5Frunner/tests/test=5Fwr?= =?UTF-8?q?ap=5Finstalled.py=20-=20install.sh=20Change=20types:=20-=20engi?= =?UTF-8?q?ne/hooks/=5Frunner/wrap=5Finstalled.py:=20create=20-=20engine/h?= =?UTF-8?q?ooks/=5Frunner/tests/test=5Fwrap=5Finstalled.py:=20create=20-?= =?UTF-8?q?=20install.sh:=20modify=20Acceptance=20criteria:=20-=20`python3?= =?UTF-8?q?=20-m=20unittest=20discover=20-s=20engine/hooks/=5Frunner/tests?= =?UTF-8?q?=20-v`=20exits=200.=20-=20`python3=20-m=20unittest=20tests.test?= =?UTF-8?q?=5Finstall=20-v`=20exits=200.=20-=20`shellcheck=20install.sh`?= =?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: 026f7cd3-16c7-45af-9eb2-8c7ba1483c72 From 86cc3b04f3ac4f71f4086265c52c1c7d27620a9d Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 12 Sep 2026 20:24:44 +0000 Subject: [PATCH 10/17] Report unwrapped installed hooks --- scripts/check_install_effective.py | 48 +++++++++- tests/test_check_install_effective.py | 129 ++++++++++++++++++++++++++ tests/test_install_effective.py | 6 +- 3 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 tests/test_check_install_effective.py diff --git a/scripts/check_install_effective.py b/scripts/check_install_effective.py index 11894d0e..3399e944 100755 --- a/scripts/check_install_effective.py +++ b/scripts/check_install_effective.py @@ -33,6 +33,7 @@ """ from __future__ import annotations +import json import os import pwd import re @@ -41,6 +42,11 @@ import tempfile from pathlib import Path +RUNNER_DIR = Path(__file__).resolve().parents[1] / "engine/hooks/_runner" +sys.path.insert(0, str(RUNNER_DIR)) + +from wrap_installed import match_direct + def _main_checkout() -> Path: """The repo links point at the primary checkout, not at a worktree of it.""" here = Path(__file__).resolve().parents[1] @@ -240,6 +246,43 @@ def check_hooks_registered() -> list[str]: return problems +def _iter_hook_commands(node: object): + if isinstance(node, dict): + if isinstance(node.get("command"), str): + yield node["command"] + for value in node.values(): + yield from _iter_hook_commands(value) + elif isinstance(node, list): + for item in node: + yield from _iter_hook_commands(item) + + +def check_hooks_wrapped() -> tuple[list[str], list[str]]: + problems = [] + unchecked = [] + for relative in ( + ".claude/settings.json", + ".cursor/hooks.json", + ".codex/hooks.json", + ): + path = HOME / relative + if not path.exists(): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + unchecked.append( + f"could not read {path} ({exc.__class__.__name__}); " + f"hooks under it are unchecked for metrics runner bypass" + ) + continue + hooks = data.get("hooks") if isinstance(data, dict) else None + for command in _iter_hook_commands(hooks): + if match_direct(command) is not None: + problems.append(f"hook bypasses the metrics runner: {command}") + return problems, unchecked + + def check_canary() -> tuple[list[str], list[str]]: """Ask the harness itself whether the rules actually loaded. @@ -277,8 +320,9 @@ def main() -> int: return 0 drift, unverifiable = check_canary() worktree_drift, worktree_unchecked = check_worktree_links() - problems = check_links() + check_hooks_registered() + worktree_drift + drift - for note in unverifiable + worktree_unchecked: + hook_drift, hook_unchecked = check_hooks_wrapped() + problems = check_links() + check_hooks_registered() + hook_drift + worktree_drift + drift + for note in unverifiable + worktree_unchecked + hook_unchecked: print(f"note: {note}") if problems: print("Installation is not in effect:") diff --git a/tests/test_check_install_effective.py b/tests/test_check_install_effective.py new file mode 100644 index 00000000..8b625397 --- /dev/null +++ b/tests/test_check_install_effective.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts/check_install_effective.py" + + +def load_with_home(home: Path): + previous = os.environ.get("HOME") + os.environ["HOME"] = str(home) + try: + spec = importlib.util.spec_from_file_location("check_install_effective_under_test", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + if previous is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = previous + + +def write_json(path: Path, data: object): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") + + +class TestCheckInstallEffectiveHookWrapping(unittest.TestCase): + def test_reports_exactly_the_unwrapped_catstack_hook(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + write_json( + home / ".claude/settings.json", + { + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.claude/hooks/diu-stop/claude_stop_check.py", + }, + { + "type": "command", + "command": ( + "python3 $HOME/.claude/hooks/_runner/run.py --timeout 29.5 " + "cat-mode-default/claude_prompt_submit.py" + ), + }, + ] + } + ] + } + }, + ) + module = load_with_home(home) + problems, unchecked = module.check_hooks_wrapped() + self.assertEqual(unchecked, []) + self.assertEqual( + problems, + [ + "hook bypasses the metrics runner: " + "python3 $HOME/.claude/hooks/diu-stop/claude_stop_check.py" + ], + ) + + def test_malformed_hook_file_is_unchecked_not_clean(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + path = home / ".claude/settings.json" + path.parent.mkdir(parents=True) + path.write_text("{not-json", encoding="utf-8") + module = load_with_home(home) + problems, unchecked = module.check_hooks_wrapped() + self.assertEqual(problems, []) + self.assertEqual(len(unchecked), 1) + self.assertIn("unchecked", unchecked[0]) + self.assertIn(str(path), unchecked[0]) + + def test_main_prints_bypass_in_installation_report(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + write_json( + home / ".claude/settings.json", + { + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 $HOME/.claude/hooks/diu-stop/claude_stop_check.py", + } + ] + } + ] + } + }, + ) + module = load_with_home(home) + module.sandbox_reason = lambda: None + module.check_canary = lambda: ([], []) + module.check_worktree_links = lambda: ([], []) + module.check_links = lambda: [] + module.check_hooks_registered = lambda: [] + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = module.main() + self.assertEqual(code, 1) + self.assertIn( + "hook bypasses the metrics runner: " + "python3 $HOME/.claude/hooks/diu-stop/claude_stop_check.py", + output.getvalue(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_effective.py b/tests/test_install_effective.py index 54629305..1d3b9088 100644 --- a/tests/test_install_effective.py +++ b/tests/test_install_effective.py @@ -73,9 +73,13 @@ def build_installation(tmp, link_into_worktree): init_repo(repo, "-b", "main") (repo / "scripts").mkdir() shutil.copy(SCRIPT, repo / "scripts" / "check_install_effective.py") + (repo / "engine/hooks/_runner").mkdir(parents=True) + shutil.copy( + os.path.join(REPO_ROOT, "engine/hooks/_runner/wrap_installed.py"), + repo / "engine/hooks/_runner/wrap_installed.py", + ) (repo / "corpus/skills/cat-mode").mkdir(parents=True) (repo / "corpus/skills/cat-mode/SKILL.md").write_text("skill", encoding="utf-8") - (repo / "engine/hooks").mkdir(parents=True) (repo / "CLAUDE.md").write_text("rules", encoding="utf-8") _git(repo, "add", "-A") _git(repo, "commit", "-q", "-m", "baseline") From a6918e5e97c5b92b766d08e01512f0f39fe966be Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 20:25:40 +0000 Subject: [PATCH 11/17] =?UTF-8?q?invoker:=20wf-1789240443754-26/install-ch?= =?UTF-8?q?eck-flags-bypass=20=E2=80=94=20Review=20claim:=20scripts/check?= =?UTF-8?q?=5Finstall=5Feffective.py=20reports=20every=20installed=20catst?= =?UTF-8?q?ack=20hook=20entry=20that=20bypasses=20the=20runner.=20Review?= =?UTF-8?q?=20lane:=20policy=20Safety=20invariant:=20The=20check=20only=20?= =?UTF-8?q?reads=20the=20three=20harness=20config=20files=20and=20reports;?= =?UTF-8?q?=20it=20writes=20nothing.=20Effectiveness=20measurement:=20A=20?= =?UTF-8?q?test=20with=20one=20wrapped=20and=20one=20unwrapped=20fixture?= =?UTF-8?q?=20entry=20gets=20exactly=20one=20`hook=20bypasses=20the=20metr?= =?UTF-8?q?ics=20runner`=20line.=20Slice=20rationale:=20The=20install=20ch?= =?UTF-8?q?eck=20is=20tooling=20policy,=20kept=20apart=20from=20the=20inst?= =?UTF-8?q?all=20behavior=20it=20checks.=20Architectural=20effect:=20A=20m?= =?UTF-8?q?issed=20or=20future=20hook=20that=20bypasses=20metrics=20shows?= =?UTF-8?q?=20up=20in=20the=20install=20check.=20Goal:=20Extend=20check=5F?= =?UTF-8?q?install=5Feffective.py=20with=20a=20bypass=20check.=20Motivatio?= =?UTF-8?q?n:=20Wrapping=20is=20only=20trustworthy=20if=20something=20repo?= =?UTF-8?q?rts=20a=20miss.=20Alternative=20considerations:=20Duplicating?= =?UTF-8?q?=20the=20matcher=20in=20the=20check=20was=20rejected;=20it=20im?= =?UTF-8?q?ports=20match=5Fdirect=20from=20wrap=5Finstalled.py=20so=20the?= =?UTF-8?q?=20two=20cannot=20disagree.=20Implementation=20details:=20Impor?= =?UTF-8?q?t=20match=5Fdirect=20and=20print=20one=20problem=20line=20per?= =?UTF-8?q?=20unwrapped=20entry.=20Non-goals:=20No=20install.sh=20or=20hoo?= =?UTF-8?q?k=20edits.=20Layer:=20app=5Fbridge=20Feature=20state:=20active?= =?UTF-8?q?=20Files:=20-=20scripts/check=5Finstall=5Feffective.py=20-=20te?= =?UTF-8?q?sts/test=5Fcheck=5Finstall=5Feffective.py=20Change=20types:=20-?= =?UTF-8?q?=20scripts/check=5Finstall=5Feffective.py:=20modify=20-=20tests?= =?UTF-8?q?/test=5Fcheck=5Finstall=5Feffective.py:=20create=20Acceptance?= =?UTF-8?q?=20criteria:=20-=20`python3=20-m=20unittest=20tests.test=5Fchec?= =?UTF-8?q?k=5Finstall=5Feffective=20-v`=20exits=200.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 1c706729-4212-4fdc-9c6a-dd8c11051a81 From f7ecc41eab9f3678873135b220c0b04974495668 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 12 Sep 2026 20:27:27 +0000 Subject: [PATCH 12/17] docs: describe hook runner install wrapping --- engine/hooks/_runner/README.md | 50 ++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/engine/hooks/_runner/README.md b/engine/hooks/_runner/README.md index 92f705de..5250553e 100644 --- a/engine/hooks/_runner/README.md +++ b/engine/hooks/_runner/README.md @@ -10,6 +10,54 @@ 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. +## Install + +`install.sh` runs `engine/hooks/_runner/wrap_installed.py` after the Claude, +Cursor, and Codex hook installers have updated their harness config files: + +- `~/.claude/settings.json` +- `~/.cursor/hooks.json` +- `~/.codex/hooks.json` + +The wrapper rewrites installed catstack hook commands from the direct form: + +```text +python3 $HOME/.claude/hooks// [args...] +``` + +to the runner form: + +```text +python3 $HOME/.claude/hooks/_runner/run.py --timeout / [args...] +``` + +The harness name in the path is preserved for Claude, Cursor, and Codex. The +hook identity, script name, and trailing arguments are preserved after the +runner path. Commands that already call `_runner/run.py` are left unchanged. + +When a hook entry has a numeric `timeout`, `wrap_installed.py` gives the hook +process half a second less than the harness timeout by passing +`--timeout ` to the runner. Entries without a numeric `timeout` +use `--timeout 59.5`. + +`wrap_installed.py` prints one status line per config: + +- `skip: missing` when a harness config file is absent. +- `unchecked: : ` when a config file cannot be read as JSON. +- `unwrapped: : ` for a hook command that references a catstack + hooks directory but does not match the direct command form. +- `wrapped entr(ies) in ` when it rewrites any entries. +- `already up to date: ` when no rewrite is needed. + +The read-only install checker also verifies that installed hook commands use +the runner. `scripts/check_install_effective.py` imports `match_direct` from +`wrap_installed.py`, so the install check reports the same direct command form +the wrapper rewrites. Each direct installed hook is reported as: + +```text +hook bypasses the metrics runner: +``` + 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. @@ -44,5 +92,3 @@ hook stderr: ```text catstack-hook-metrics: could not write row to : ``` - -Nothing calls this runner until install wiring lands. From f994b87ce4c9bf7546cbe05b85b312a430ee3518 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 20:28:37 +0000 Subject: [PATCH 13/17] =?UTF-8?q?invoker:=20wf-1789240443754-26/describe-w?= =?UTF-8?q?rap-install=20=E2=80=94=20Review=20claim:=20engine/hooks/=5Frun?= =?UTF-8?q?ner/README.md=20states=20how=20install=20wraps=20hook=20entries?= =?UTF-8?q?=20and=20how=20the=20install=20check=20reports=20a=20bypass.=20?= =?UTF-8?q?Review=20lane:=20docs=20Safety=20invariant:=20Only=20engine/hoo?= =?UTF-8?q?ks/=5Frunner/README.md=20changes;=20no=20code,=20test,=20or=20c?= =?UTF-8?q?onfig=20file=20is=20edited.=20Effectiveness=20measurement:=20Th?= =?UTF-8?q?e=20README's=20entry=20format=20and=20printed=20messages=20matc?= =?UTF-8?q?h=20wrap=5Finstalled.py,=20checked=20by=20reading=20both.=20Sli?= =?UTF-8?q?ce=20rationale:=20Prose=20in=20its=20own=20commit=20so=20the=20?= =?UTF-8?q?code=20commits=20stay=20one=20claim=20each.=20Architectural=20e?= =?UTF-8?q?ffect:=20None;=20prose=20only.=20Goal:=20Add=20an=20Install=20s?= =?UTF-8?q?ection=20to=20engine/hooks/=5Frunner/README.md.=20Motivation:?= =?UTF-8?q?=20Readers=20need=20the=20install=20shape=20without=20reading?= =?UTF-8?q?=20code.=20Alternative=20considerations:=20Code=20comments=20we?= =?UTF-8?q?re=20rejected;=20the=20repo=20forbids=20new=20comments.=20Imple?= =?UTF-8?q?mentation=20details:=20One=20Markdown=20section.=20Non-goals:?= =?UTF-8?q?=20No=20code,=20test,=20or=20config=20edits.=20Layer:=20docs=20?= =?UTF-8?q?Feature=20state:=20active=20Files:=20-=20engine/hooks/=5Frunner?= =?UTF-8?q?/README.md=20Change=20types:=20-=20engine/hooks/=5Frunner/READM?= =?UTF-8?q?E.md:=20docs-only=20Acceptance=20criteria:=20-=20`grep=20-n=20"?= =?UTF-8?q?wrap=5Finstalled.py"=20engine/hooks/=5Frunner/README.md`=20prin?= =?UTF-8?q?ts=20at=20least=20one=20line.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: a272c6e0-1997-4c03-8e14-324d8cf5bdce From 14a255bafa0d6f1b661778be7baf08bfefc2b9dd Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 20:56:34 +0000 Subject: [PATCH 14/17] =?UTF-8?q?invoker:=20wf-1789240443754-26/verify-wra?= =?UTF-8?q?p-installed=20=E2=80=94=20Review=20claim:=20The=20repo=20suite,?= =?UTF-8?q?=20which=20includes=20the=20runner,=20install,=20and=20install-?= =?UTF-8?q?check=20tests,=20passes,=20and=20install.sh=20is=20shellcheck-c?= =?UTF-8?q?lean.=20Review=20lane:=20proof=20Safety=20invariant:=20Verifica?= =?UTF-8?q?tion=20is=20read-only=20for=20the=20repository;=20tests=20write?= =?UTF-8?q?=20only=20into=20temp=20directories.=20Effectiveness=20measurem?= =?UTF-8?q?ent:=20The=20two=20commands=20are=20the=20direct=20measurement.?= =?UTF-8?q?=20Slice=20rationale:=20One=20focused=20proof=20before=20review?= =?UTF-8?q?.=20Architectural=20effect:=20None;=20verification=20only.=20Go?= =?UTF-8?q?al:=20Prove=20wrapping=20is=20complete=20and=20a=20rerun=20chan?= =?UTF-8?q?ges=20nothing.=20Motivation:=20Running=20the=20tests=20is=20the?= =?UTF-8?q?=20proof.=20Alternative=20considerations:=20The=20live=20claude?= =?UTF-8?q?=20-p=20row=20check=20needs=20model=20access=20and=20is=20run?= =?UTF-8?q?=20by=20the=20parent=20session=20after=20merge.=20Implementatio?= =?UTF-8?q?n=20details:=20Run=20the=20suite=20and=20shellcheck.=20Non-goal?= =?UTF-8?q?s:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature=20st?= =?UTF-8?q?ate:=20active=20Acceptance=20criteria:=20-=20Exits=200=20only?= =?UTF-8?q?=20when=20all=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 127 Invoker-Finalize-Id: 354b2bdb-010c-4742-91ee-4ba090cd0747 From 1c42f81dd13cb94b89b5fd36c730b8e553261633 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 20:59:18 +0000 Subject: [PATCH 15/17] =?UTF-8?q?invoker:=20wf-1789240443754-26/verify-wra?= =?UTF-8?q?p-installed=20=E2=80=94=20Review=20claim:=20The=20repo=20suite,?= =?UTF-8?q?=20which=20includes=20the=20runner,=20install,=20and=20install-?= =?UTF-8?q?check=20tests,=20passes,=20and=20install.sh=20is=20shellcheck-c?= =?UTF-8?q?lean.=20Review=20lane:=20proof=20Safety=20invariant:=20Verifica?= =?UTF-8?q?tion=20is=20read-only=20for=20the=20repository;=20tests=20write?= =?UTF-8?q?=20only=20into=20temp=20directories.=20Effectiveness=20measurem?= =?UTF-8?q?ent:=20The=20two=20commands=20are=20the=20direct=20measurement.?= =?UTF-8?q?=20Slice=20rationale:=20One=20focused=20proof=20before=20review?= =?UTF-8?q?.=20Architectural=20effect:=20None;=20verification=20only.=20Go?= =?UTF-8?q?al:=20Prove=20wrapping=20is=20complete=20and=20a=20rerun=20chan?= =?UTF-8?q?ges=20nothing.=20Motivation:=20Running=20the=20tests=20is=20the?= =?UTF-8?q?=20proof.=20Alternative=20considerations:=20The=20live=20claude?= =?UTF-8?q?=20-p=20row=20check=20needs=20model=20access=20and=20is=20run?= =?UTF-8?q?=20by=20the=20parent=20session=20after=20merge.=20Implementatio?= =?UTF-8?q?n=20details:=20Run=20the=20suite=20and=20shellcheck.=20Non-goal?= =?UTF-8?q?s:=20No=20mutations.=20Layer:=20app=5Fregression=20Feature=20st?= =?UTF-8?q?ate:=20active=20Acceptance=20criteria:=20-=20Exits=200=20only?= =?UTF-8?q?=20when=20all=20pass.?= 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 607810daeb0174d00c9809201bf67f4c4875a39c Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Sat, 12 Sep 2026 21:00:38 +0000 Subject: [PATCH 16/17] =?UTF-8?q?invoker:=20wf-1789240443754-26/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: abcc8dd1-19ae-4cdc-b9a6-8bec5bce3173 From 36ef709ced2a11b8da19cbd40f6804ddb4942e87 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Sat, 12 Sep 2026 14:13:00 -0700 Subject: [PATCH 17/17] fix(install): prune dead hook entries after they are wrapped by the runner Once wrap_installed.py rewrites an entry to `_runner/run.py --timeout T /