Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b95c447
cat-mode: inject on every enabled prompt
EdbertChan Sep 12, 2026
fd29a8e
invoker: wf-1789240296417-25/cat-mode-every-prompt — Review claim: ca…
EdbertChan Sep 12, 2026
21fdacd
docs: describe cat-mode every-prompt default
Sep 12, 2026
6554fef
invoker: wf-1789240296417-25/describe-every-prompt — Review claim: en…
Sep 12, 2026
7db957b
invoker: wf-1789240296417-25/verify-cat-mode-every-prompt — Review cl…
Sep 12, 2026
ac3f995
Invoker: merge experiment/wf-1789240296417-25/describe-every-prompt/g…
Sep 12, 2026
3f4e97c
invoker: wf-1789240296417-25/scrub-handoff-artifacts — Review claim: …
Sep 12, 2026
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
9c44345
Merge experiment/wf-1789240296417-25/scrub-handoff-artifacts/g0.t0.a-…
EdbertChan 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
f048859
Merge of #502
mergify[bot] Sep 13, 2026
42167c5
Merge of #503
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
2 changes: 1 addition & 1 deletion docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
48 changes: 48 additions & 0 deletions engine/hooks/_runner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Hook runner

Usage:

```sh
python3 engine/hooks/_runner/run.py [--timeout SECONDS] <hook>/<script.py> [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 `<hook>/<script.py>`.
- `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 <path>: <error>
```

Nothing calls this runner until install wiring lands.
49 changes: 49 additions & 0 deletions engine/hooks/_runner/outcome.py
Original file line number Diff line number Diff line change
@@ -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"
150 changes: 150 additions & 0 deletions engine/hooks/_runner/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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.timezone.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

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)
sys.stderr.buffer.write(metrics_error)
sys.stderr.buffer.flush()
return exit_code


if __name__ == "__main__":
raise SystemExit(main())
56 changes: 56 additions & 0 deletions engine/hooks/_runner/tests/test_outcome.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading