Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d55f636
Route restated existing rules to already-covered, not automate-me.
EdbertChan Oct 11, 2026
90c66e2
Drop the lesson-router comments that failed the comment gate.
EdbertChan Oct 11, 2026
083c513
Stamp an event_uid on every runner metrics row.
EdbertChan Oct 11, 2026
1e585e3
invoker: wf-1791728211949-11/publish-event-uid — Review claim: Every …
Oct 11, 2026
4072085
Merge experiment/wf-1791728211949-11/publish-event-uid/g0.t0.a-a839f4…
EdbertChan Oct 11, 2026
5a9e5c1
Let the install tests read either hook layout.
Oct 11, 2026
1b46a91
invoker: wf-1791728053338-10/publish-both-layout-install-tests — Revi…
Oct 11, 2026
0e6a50c
Merge experiment/wf-1791728053338-10/publish-both-layout-install-test…
EdbertChan Oct 11, 2026
af5796b
Print a before/after hook scorecard from the metrics ledger
Oct 11, 2026
923250d
invoker: wf-1791728212537-13/publish-scorecard — Review claim: report…
Oct 11, 2026
f74048a
Merge experiment/wf-1791728212537-13/publish-scorecard/g0.t0.a-aad9ca…
EdbertChan Oct 11, 2026
005c476
Run the SubagentStop hook fleet through one dispatcher process.
Oct 1, 2026
6a3c5a1
invoker: wf-1791728212829-14/publish-pilot — Review claim: SubagentSt…
Oct 11, 2026
67ed846
Merge experiment/wf-1791728212829-14/publish-pilot/g0.t0.a-a268cef98-…
EdbertChan Oct 11, 2026
cfbdf1a
Merge pull request #1330 from EdbertChan/stack/EdbertChan/reflect/les…
EdbertChan Oct 11, 2026
7090519
Merge of #1324
mergify[bot] Oct 11, 2026
29cb03c
Merge of #1331
mergify[bot] Oct 11, 2026
26f5659
Merge of #1334
mergify[bot] Oct 11, 2026
669b5ef
Merge of #1335
mergify[bot] Oct 11, 2026
8ee7bc0
Merge of #1332
mergify[bot] Oct 11, 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
207 changes: 198 additions & 9 deletions engine/hooks/_runner/dispatch.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

import argparse
import concurrent.futures
import glob
import importlib.util
import json
import os
import re
import signal
import subprocess
import sys
import time
Expand All @@ -16,7 +17,11 @@
import run

DEFAULT_BUDGET = 59.5
DEFAULT_SUBAGENT_STOP_EVENT_BUDGET = 9.0
DEFAULT_SUBAGENT_STOP_DETECTOR_BUDGET = 4.0
OPT_OUT_KEY = "subagent_stop"
DISPATCH_KEY = "dispatch"
DEFAULT_DISPATCH_ENTRY = "detect:detect"
MIRRORED_EVENTS = {"SubagentStop": "Stop"}
DIRECT_RE = re.compile(r"^python3 \$HOME/\.(claude|cursor|codex)/hooks/([^/\s]+)/([^/\s]+\.py)((?:\s+.*)?)$")
MESSAGE_KEYS = ("reason", "message", "additionalContext", "additional_context", "user_message")
Expand Down Expand Up @@ -157,6 +162,11 @@ def _load_manifest_hooks(
opt_out = manifest.get(OPT_OUT_KEY)
if isinstance(opt_out, dict) and opt_out.get("inherit") is False:
continue
dispatch_entry = _dispatch_entry(manifest)
if dispatch_entry is None:
continue
else:
dispatch_entry = None
for entry in entries:
matcher = None
if not mirrored and isinstance(entry, dict):
Expand All @@ -178,11 +188,26 @@ def _load_manifest_hooks(
"args": list(args),
"timeout": hook.get("timeout"),
"matcher": matcher,
"dispatch_entry": dispatch_entry,
}
)
return records, warnings


def _dispatch_entry(manifest: dict) -> str | None:
config = manifest.get(OPT_OUT_KEY)
if not isinstance(config, dict):
return None
raw = config.get(DISPATCH_KEY)
if raw is True:
return DEFAULT_DISPATCH_ENTRY
if isinstance(raw, dict):
entry = raw.get("entry")
if isinstance(entry, str) and entry.strip():
return entry.strip()
return None


def _parse_command(command: str, harness: str) -> tuple[str, str, str] | None:
match = DIRECT_RE.fullmatch(command)
if not match or match.group(1) != harness:
Expand Down Expand Up @@ -211,7 +236,22 @@ def _payload(stdin: bytes) -> dict[str, object]:
return parsed if isinstance(parsed, dict) else {}


def _run_one(hooks_root: str, python: str, record: dict, stdin: bytes, budget: float) -> dict:
def _run_one(
hooks_root: str,
python: str,
record: dict,
stdin: bytes,
budget: float,
parsed_event: dict[str, object] | None = None,
) -> dict:
if record.get("dispatch_entry") and _entrypoint_exists(hooks_root, record["hook"], str(record["dispatch_entry"])):
return _run_sdk_one(hooks_root, record, stdin, budget, parsed_event)
return _run_subprocess_one(hooks_root, python, record, stdin, budget, fallback=True)


def _run_subprocess_one(
hooks_root: str, python: str, record: dict, stdin: bytes, budget: float, fallback: bool = False
) -> dict:
hook, script = record["hook"], record["script"]
started = time.monotonic()
script_path = os.path.join(hooks_root, hook, script)
Expand Down Expand Up @@ -249,6 +289,8 @@ def _run_one(hooks_root: str, python: str, record: dict, stdin: bytes, budget: f
run._delete_findings_file(findings_path)
result_outcome = outcome.classify(exit_code, stdout, stderr, timed_out)
row = run._row(hooks_root, hook, script, stdin, result_outcome, exit_code, started, stdout, stderr, rule_ids)
if fallback:
row["dispatch_path"] = "subprocess_fallback"
return {
"hook": hook,
"script": script,
Expand All @@ -260,12 +302,158 @@ def _run_one(hooks_root: str, python: str, record: dict, stdin: bytes, budget: f
}


def _sdk_dirs(hooks_root: str) -> tuple[str, str]:
engine_hooks = os.path.abspath(hooks_root)
sdk_dir = os.path.join(engine_hooks, "_sdk")
return engine_hooks, sdk_dir


def _prefer_import_path(path: str, index: int) -> None:
try:
sys.path.remove(path)
except ValueError:
pass
sys.path.insert(index, path)


def _load_entrypoint(hooks_root: str, hook: str, entry: str):
module_name, sep, function_name = entry.partition(":")
if not sep or not module_name or not function_name:
raise ValueError(f"dispatch entry must be MODULE:FUNCTION, got {entry!r}")
module_path = _entrypoint_path(hooks_root, hook, module_name)
if not os.path.isfile(module_path):
raise FileNotFoundError(module_path)
_engine_hooks, sdk_dir = _sdk_dirs(hooks_root)
_prefer_import_path(sdk_dir, 0)
_prefer_import_path(os.path.dirname(module_path), 1)
unique_name = f"_catstack_dispatch_{hook.replace('-', '_')}_{abs(hash(module_path))}"
spec = importlib.util.spec_from_file_location(unique_name, module_path)
if spec is None or spec.loader is None:
raise ImportError(f"could not load {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
target = getattr(module, function_name)
if not callable(target):
raise TypeError(f"{entry!r} did not resolve to a callable")
return target


def _entrypoint_path(hooks_root: str, hook: str, module_name: str) -> str:
return os.path.join(hooks_root, hook, *module_name.split(".")) + ".py"


def _entrypoint_exists(hooks_root: str, hook: str, entry: str) -> bool:
module_name, sep, function_name = entry.partition(":")
return bool(sep and module_name and function_name and os.path.isfile(_entrypoint_path(hooks_root, hook, module_name)))


def _event(stdin: bytes, harness: str, event_name: str) -> dict[str, object]:
raw = stdin.decode("utf-8", errors="replace")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
parsed = {
"_raw_payload": raw,
"_payload_error": f"the hook payload is not JSON ({exc})",
"_payload_error_type": type(exc).__name__,
"_payload_error_detail": str(exc),
}
if not isinstance(parsed, dict):
parsed = {"_raw_payload": raw, "_payload_error": "the hook payload is not a JSON object"}
parsed.setdefault("_raw_payload", raw)
parsed.setdefault("_catstack_harness", harness)
parsed.setdefault("hook_event_name", event_name)
return parsed


def _render_findings(hooks_root: str, hook: str, harness: str, event_name: str, event: dict[str, object], findings: list):
_engine_hooks, sdk_dir = _sdk_dirs(hooks_root)
_prefer_import_path(sdk_dir, 0)
from modes import effective_finding_modes
from render import render
from runtime import _renderable_findings

mode, _mode_source, finding_modes = effective_finding_modes(hook, event, findings)
rendered_mode, rendered_findings = _renderable_findings(finding_modes, findings, mode)
stdout_text, stderr_text, exit_code = render(harness, event_name, rendered_mode, rendered_findings)
return stdout_text.encode(), stderr_text.encode(), exit_code, [finding.rule_id for finding in findings]


def _run_sdk_one(
hooks_root: str,
record: dict,
stdin: bytes,
budget: float,
parsed_event: dict[str, object] | None = None,
) -> dict:
hook, script = record["hook"], record["script"]
started = time.monotonic()
stdout = b""
stderr = b""
exit_code = 0
timed_out = False
rule_ids: list[str] = []
harness = run._harness(hooks_root)
payload = dict(parsed_event) if parsed_event is not None else _event(stdin, harness, "SubagentStop")
previous_handler = None
try:
detect = _load_entrypoint(hooks_root, hook, str(record["dispatch_entry"]))
if hasattr(signal, "SIGALRM"):
previous_handler = signal.getsignal(signal.SIGALRM)

def _timeout(_signum, _frame):
raise TimeoutError(f"{hook}/{record['dispatch_entry']} timed out after {budget}s")

signal.signal(signal.SIGALRM, _timeout)
signal.setitimer(signal.ITIMER_REAL, budget)
findings = detect(payload)
if not isinstance(findings, list):
raise TypeError("detect(event) must return a list")
stdout, stderr, exit_code, rule_ids = _render_findings(hooks_root, hook, harness, "SubagentStop", payload, findings)
lines = payload.get("_catstack_stderr_lines")
if isinstance(lines, list):
stderr += "".join(f"{line}\n" for line in lines if isinstance(line, str) and line).encode()
except TimeoutError:
timed_out = True
exit_code = 1
stdout = b""
stderr = f"catstack-hook-runner: {hook}/{script} timed out after {budget}s\n".encode()
except BaseException as exc:
if isinstance(exc, KeyboardInterrupt):
raise
if isinstance(exc, SystemExit):
exit_code = exc.code if isinstance(exc.code, int) else 1
else:
exit_code = 0
stdout = b""
stderr = f"catstack-hook-error {hook}: {type(exc).__name__}: {exc}\n".encode()
finally:
if hasattr(signal, "SIGALRM"):
signal.setitimer(signal.ITIMER_REAL, 0)
if previous_handler is not None:
signal.signal(signal.SIGALRM, previous_handler)
result_outcome = outcome.classify(exit_code, stdout, stderr, timed_out)
row = run._row(hooks_root, hook, script, stdin, result_outcome, exit_code, started, stdout, stderr, rule_ids)
row["dispatch_path"] = "sdk" if not timed_out and not stderr.startswith(b"catstack-hook-error ") else "sdk_error"
return {
"hook": hook,
"script": script,
"outcome": result_outcome,
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
"row": row,
}


def _merge_stdout(results: list[dict]) -> tuple[bytes, bytes, int]:
blocked = [r for r in results if r["outcome"] == "blocked"]
spoke = [r for r in results if r["stdout"].strip()]
if blocked:
return _merge_blocks(blocked)
if spoke:
if len(spoke) == 1:
return spoke[0]["stdout"], b"", spoke[0]["exit_code"]
contexts = [note for r in spoke for note in (_note(r),) if note]
payload: dict[str, object] = {"continue": True}
if contexts:
Expand Down Expand Up @@ -340,11 +528,9 @@ def run_dispatch(
).encode()
return 1, b"", stderr, []

results: list[dict] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(records)) as pool:
futures = [pool.submit(_run_one, hooks_root, python, record, stdin, budget) for record in records]
for future in futures:
results.append(future.result())
detector_budget = min(budget, DEFAULT_SUBAGENT_STOP_DETECTOR_BUDGET) if event == "SubagentStop" else budget
parsed_event = _event(stdin, harness, event) if event == "SubagentStop" else None
results = [_run_one(hooks_root, python, record, stdin, detector_budget, parsed_event) for record in records]

stdout, block_notes, exit_code = _merge_stdout(results)
stderr_lines = [r["stderr"] for r in results if r["stderr"]]
Expand All @@ -355,7 +541,7 @@ def run_dispatch(
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--event", required=True)
parser.add_argument("--timeout", type=float, default=DEFAULT_BUDGET)
parser.add_argument("--timeout", type=float)
return parser.parse_args(argv)


Expand All @@ -364,7 +550,10 @@ def main(argv: list[str] | None = None) -> int:
stdin = sys.stdin.buffer.read()
hooks_root = run._hooks_root()
harness = run._harness(hooks_root)
exit_code, stdout, stderr, rows = run_dispatch(hooks_root, harness, args.event, stdin, args.timeout)
budget = args.timeout
if budget is None:
budget = DEFAULT_SUBAGENT_STOP_EVENT_BUDGET if args.event == "SubagentStop" else DEFAULT_BUDGET
exit_code, stdout, stderr, rows = run_dispatch(hooks_root, harness, args.event, stdin, budget)
metrics_path = run._metrics_path()
metrics_errors = b"".join(run._write_metrics(row, metrics_path) for row in rows)
sys.stdout.buffer.write(stdout)
Expand Down
Loading
Loading