Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
90b2551
Add dormant hook runner metrics
Sep 12, 2026
0ff3ce7
invoker: wf-1789240290960-24/add-hook-runner — Review claim: engine/h…
Sep 12, 2026
5d48158
Describe hook runner metrics
Sep 12, 2026
f65a4f3
invoker: wf-1789240290960-24/describe-hook-runner — Review claim: eng…
Sep 12, 2026
ed0d35e
invoker: wf-1789240290960-24/verify-hook-runner — Review claim: The r…
Sep 12, 2026
39c4939
Invoker: merge experiment/wf-1789240290960-24/describe-hook-runner/g0…
Sep 12, 2026
6d256c4
invoker: wf-1789240290960-24/scrub-handoff-artifacts — Review claim: …
Sep 12, 2026
6ae30ce
Merge experiment/wf-1789240290960-24/scrub-handoff-artifacts/g0.t0.a-…
EdbertChan Sep 12, 2026
6a83264
fix(hooks/_runner): run on Python 3.9 and forward the hook result whe…
EdbertChan Sep 12, 2026
d1a6fb5
Wrap installed hooks with runner
Sep 12, 2026
db5412e
invoker: wf-1789240443754-26/wrap-installed-hooks — Review claim: ./i…
Sep 12, 2026
86cc3b0
Report unwrapped installed hooks
Sep 12, 2026
a6918e5
invoker: wf-1789240443754-26/install-check-flags-bypass — Review clai…
Sep 12, 2026
f7ecc41
docs: describe hook runner install wrapping
Sep 12, 2026
f994b87
invoker: wf-1789240443754-26/describe-wrap-install — Review claim: en…
Sep 12, 2026
14a255b
invoker: wf-1789240443754-26/verify-wrap-installed — Review claim: Th…
Sep 12, 2026
1c42f81
invoker: wf-1789240443754-26/verify-wrap-installed — Review claim: Th…
Sep 12, 2026
1676bcc
Invoker: merge experiment/wf-1789240443754-26/describe-wrap-install/g…
Sep 12, 2026
607810d
invoker: wf-1789240443754-26/scrub-handoff-artifacts — Review claim: …
Sep 12, 2026
e842221
Merge experiment/wf-1789240443754-26/scrub-handoff-artifacts/g0.t1.a-…
EdbertChan Sep 12, 2026
36ef709
fix(install): prune dead hook entries after they are wrapped by the r…
EdbertChan Sep 12, 2026
d6e69fd
Merge remote-tracking branch 'origin/main' into m512
EdbertChan Sep 13, 2026
c22622e
Merge of #512
mergify[bot] Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions engine/hooks/_runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hook>/<script.py> [args...]
```

to the runner form:

```text
python3 $HOME/.claude/hooks/_runner/run.py --timeout <seconds> <hook>/<script.py> [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 <timeout - 0.5>` to the runner. Entries without a numeric `timeout`
use `--timeout 59.5`.

`wrap_installed.py` prints one status line per config:

- `skip: <path> missing` when a harness config file is absent.
- `unchecked: <path>: <error>` when a config file cannot be read as JSON.
- `unwrapped: <path>: <command>` for a hook command that references a catstack
hooks directory but does not match the direct command form.
- `wrapped <count> entr(ies) in <path>` when it rewrites any entries.
- `already up to date: <path>` 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: <command>
```

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.

Expand Down Expand Up @@ -44,5 +92,3 @@ hook stderr:
```text
catstack-hook-metrics: could not write row to <path>: <error>
```

Nothing calls this runner until install wiring lands.
215 changes: 215 additions & 0 deletions engine/hooks/_runner/tests/test_wrap_installed.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading