Hook latency¶
These are the Node hooks. The core ah-engine answers most calls without a Node start-up; its pre-release replay figures are in AH-ENGINE.md and were measured on a different harness, so do not mix them with the tables here.
How long anti-hall's hooks take, measured with scripts/hook-latency.js (node scripts/hook-latency.js [-n 20] [--json] [--only name,name] [--grouped]). --grouped skips the per-hook passes and, for each event scenario, starts the whole matching hook set in parallel (as Claude Code does) and reports group wall and summed CPU. It starts every command registered in plugins/anti-hall/hooks/hooks.json the way Claude Code does (command string through a shell, ${CLAUDE_PLUGIN_ROOT} expanded, node flags kept, JSON payload on stdin, CLAUDE_CODE_ENTRYPOINT=cli, HOME set to a temp dir, cwd a small git repo) and feeds it a realistic payload for each scenario.
Caveat: this was measured while other work ran on the machine (load average about 20 to 30 on a 16-logical-CPU machine, see below), so absolute numbers are inflated and the p95 column carries scheduler noise. Re-run it on a quiet machine before quoting a figure as a baseline.
What the numbers mean¶
- Wall is spawn to exit. p50/p95 are nearest-rank over 19 runs (20 run, first dropped). With 19 samples p95 is effectively the slowest run.
- CPU is the hook process's own user+system time from
process.resourceUsage(), written at exit by a--requireprobe in a second pass (so the probe never inflates the wall numbers). I chose it over/usr/bin/timebecause that tool prints different formats on macOS and GNU, rounds to 10 ms, and is missing on some systems. It does not count grandchildren: a hook that shells out togitis charged for itself only. - Total per tool call: Claude Code runs all matching hooks in parallel (hooks docs, code.claude.com/docs/en/hooks: "All matching hooks run in parallel.";
docs/KB-claude-codex.mddoes not say), so the wall total is the slowest hook, not the sum. The "parallel, measured" column starts the matching hooks together and times the group. "max" is the derived lower bound and "sum" is what a sequential runner would cost. CPU adds up, so the CPU total is the sum. - Scenario hook sets follow the matchers of the dispatch table (
hooks/hooks.registry.jsonis its per-hook form;hooks.jsonitself is one thin trigger per event) (for exampleBashmatches 9 PreToolUse hooks and 6 PostToolUse hooks;Agentmatches 5 PreToolUse and 1 PostToolUse;Editmatches 4; a prompt runs 8; Stop runs 11; SessionStart runs 16 on 0.201.0. The Codex port runs Bash 7 + 4, prompt 8, Stop 10, SessionStart 15).edit-guardexits 2 (blocks) in the Edit/Write scenarios because the fixture is a coordinator session; that is its normal path. - The benchmark covers SessionStart, UserPromptSubmit, PreToolUse (Bash, Edit, Write, Agent), PostToolUse (Bash, Agent) and Stop. PostToolUse
TaskCreate|TaskUpdate, PreToolUseRead/SendMessage/AskUserQuestion/TaskStopand the other events are not measured.
Results¶
Date: 2026-10-03T17:49:25.700Z
Machine: Apple M4 Max, darwin 25.6.0, Node v24.14.0
Load average (1/5/15 min) at start: 28.67 / 29.79 / 23.68; at end: 27.75 / 27.26 / 23.36
N=20 runs per hook, first dropped (19 samples), nearest-rank percentiles. All times in ms.
| Event | Scenario | Hook | Wall p50 | Wall p95 | CPU p50 | CPU p95 | Load (1 min) |
|---|---|---|---|---|---|---|---|
| SessionStart | startup | verify-first-full | 23.4 | 26.1 | 22.7 | 25.1 | 26.94 |
| SessionStart | startup | verify-first-orch | 27.2 | 29.3 | 26.7 | 28.4 | 26.94 |
| SessionStart | startup | devswarm-child-role | 48.4 | 53.6 | 51.5 | 53.8 | 26.94 |
| SessionStart | startup | version-alert | 25.2 | 26.8 | 25.0 | 25.9 | 26.94 |
| SessionStart | startup | fable-availability | 22.3 | 26.4 | 19.9 | 21.7 | 25.66 |
| SessionStart | startup | codex-availability | 25.9 | 29.6 | 25.0 | 27.5 | 25.66 |
| SessionStart | startup | devswarm-version | 24.1 | 25.2 | 23.6 | 24.6 | 25.66 |
| SessionStart | startup | claude-cli-version | 23.2 | 24.8 | 22.9 | 24.1 | 25.66 |
| SessionStart | startup | repo-self-drift | 24.6 | 28.8 | 24.0 | 25.3 | 25.66 |
| SessionStart | startup | progress-prune | 27.0 | 28.9 | 26.4 | 27.3 | 24.09 |
| SessionStart | startup | handover-resume | 26.9 | 30.3 | 26.7 | 28.1 | 24.09 |
| SessionStart | startup | jev-weekly-scorecard | 23.4 | 27.0 | 21.9 | 22.7 | 24.09 |
| SessionStart | startup | jev-review-reminder | 23.4 | 24.5 | 23.8 | 24.7 | 24.09 |
| SessionStart | startup | emit-dedupe-reset | 25.5 | 27.9 | 24.9 | 25.8 | 24.09 |
| SessionStart | startup | defect-nudge | 25.1 | 27.6 | 24.5 | 25.6 | 22.8 |
| SessionStart | startup | repair-on-reload | 24.3 | 26.5 | 23.6 | 26.7 | 22.8 |
| UserPromptSubmit | prompt | verify-first | 34.5 | 41.6 | 28.6 | 29.8 | 22.8 |
| UserPromptSubmit | prompt | task-tracker | 39.4 | 43.6 | 37.3 | 38.8 | 21.45 |
| UserPromptSubmit | prompt | limit-conserve-inject | 23.4 | 25.1 | 21.7 | 23.3 | 21.45 |
| UserPromptSubmit | prompt | devswarm-parent-inbox | 55.1 | 59.2 | 52.9 | 56.2 | 21.45 |
| UserPromptSubmit | prompt | devswarm-child-turn | 32.9 | 35.0 | 31.1 | 32.1 | 20.29 |
| UserPromptSubmit | prompt | repair-on-reload | 24.8 | 28.0 | 25.4 | 29.7 | 20.29 |
| UserPromptSubmit | prompt | auto-handover | 30.2 | 34.3 | 26.2 | 27.2 | 20.29 |
| PreToolUse | Bash: git status | compact-declaration-guard | 28.0 | 33.7 | 23.6 | 25.7 | 19.31 |
| PreToolUse | Bash: git status | git-guard | 25.2 | 27.8 | 23.6 | 24.8 | 19.31 |
| PreToolUse | Bash: git status | command-guard | 29.8 | 34.3 | 28.6 | 30.4 | 19.31 |
| PreToolUse | Bash: git status | merge-gate | 23.3 | 27.7 | 22.2 | 24.4 | 19.31 |
| PreToolUse | Bash: git status | scan-throttle | 22.7 | 23.7 | 21.7 | 22.4 | 19.31 |
| PreToolUse | Bash: git commit | compact-declaration-guard | 26.6 | 32.6 | 25.0 | 30.8 | 18.64 |
| PreToolUse | Bash: git commit | git-guard | 57.9 | 65.1 | 34.4 | 36.6 | 18.64 |
| PreToolUse | Bash: git commit | command-guard | 33.6 | 47.4 | 30.9 | 32.2 | 17.47 |
| PreToolUse | Bash: git commit | merge-gate | 33.1 | 109.8 | 29.2 | 41.7 | 17.47 |
| PreToolUse | Bash: git commit | scan-throttle | 26.9 | 30.1 | 27.5 | 34.9 | 17.47 |
| PreToolUse | Edit: src file | compact-declaration-guard | 40.2 | 51.6 | 33.9 | 42.9 | 18.87 |
| PreToolUse | Edit: src file | api-guard | 34.1 | 50.1 | 35.3 | 45.8 | 18.87 |
| PreToolUse | Edit: src file | ship-it-guard | 37.5 | 50.4 | 26.6 | 33.1 | 18 |
| PreToolUse | Edit: src file | edit-guard | 50.0 | 131.8 | 44.7 | 54.7 | 18 |
| PreToolUse | Write: src file | compact-declaration-guard | 41.6 | 51.8 | 33.4 | 40.0 | 18.08 |
| PreToolUse | Write: src file | api-guard | 42.6 | 66.0 | 39.5 | 43.2 | 18.08 |
| PreToolUse | Write: src file | ship-it-guard | 41.8 | 52.0 | 36.3 | 39.2 | 18.08 |
| PreToolUse | Write: src file | edit-guard | 64.8 | 83.1 | 57.8 | 63.2 | 18.08 |
| PreToolUse | Agent: spawn | compact-declaration-guard | 46.8 | 53.3 | 37.2 | 41.7 | 20.47 |
| PreToolUse | Agent: spawn | model-routing-guard | 45.3 | 63.0 | 40.0 | 42.3 | 20.47 |
| PreToolUse | Agent: spawn | swarm-guard | 75.3 | 100.2 | 46.7 | 55.1 | 20.99 |
| PreToolUse | Agent: spawn | phase-tracker | 42.5 | 56.8 | 35.8 | 39.0 | 20.99 |
| PostToolUse | Bash: git status | git-guard --audit | 51.8 | 81.7 | 39.1 | 55.1 | 21.07 |
| PostToolUse | Bash: git status | output-verify-guard | 47.0 | 66.0 | 37.2 | 46.1 | 21.07 |
| PostToolUse | Bash: git status | devswarm-parent-reply-tracker | 43.3 | 57.4 | 40.7 | 47.6 | 21.07 |
| PostToolUse | Bash: git status | devswarm-child-drain | 47.6 | 68.0 | 45.0 | 57.6 | 20.99 |
| PostToolUse | Agent: spawn | codex-quota-detect | 45.3 | 50.1 | 35.0 | 42.5 | 20.75 |
| Stop | short transcript | task-guard | 50.8 | 67.0 | 40.7 | 48.0 | 20.75 |
| Stop | short transcript | tasklist-guard | 59.5 | 74.6 | 46.7 | 56.0 | 20.85 |
| Stop | short transcript | speculation-guard | 57.9 | 91.7 | 45.8 | 52.1 | 20.85 |
| Stop | short transcript | speculation-judge | 47.0 | 55.1 | 47.5 | 53.1 | 20.85 |
| Stop | short transcript | claim-ledger | 45.8 | 54.1 | 40.4 | 44.2 | 20.7 |
| Stop | short transcript | codex-nudge | 53.1 | 69.4 | 43.7 | 46.7 | 20.7 |
| Stop | short transcript | devswarm-parent-gate | 150.4 | 240.5 | 109.5 | 116.0 | 21.51 |
| Stop | short transcript | devswarm-child-gate | 53.9 | 83.3 | 49.7 | 52.1 | 21.51 |
| Stop | short transcript | auto-handover-pause-nag | 52.7 | 60.3 | 45.1 | 48.0 | 21.51 |
| Stop | short transcript | silent-agent-nudge | 50.6 | 85.8 | 39.9 | 44.2 | 24.51 |
| Stop | short transcript | compact-advice-guard | 57.5 | 67.1 | 42.7 | 44.7 | 24.51 |
Per-tool-call totals. Wall (parallel, measured) starts every matching hook together and times the group, which is what Claude Code does. Wall (max) and Wall (sum) are derived from the per-hook numbers: max is the parallel lower bound, sum is what a sequential runner would cost. CPU (sum) is the work all hooks do together.
| Event | Scenario | Hooks | Wall parallel p50 | Wall parallel p95 | Wall max p50 | Wall max p95 | Wall sum p50 | CPU sum p50 |
|---|---|---|---|---|---|---|---|---|
| SessionStart | startup | 16 | 75.5 | 99.0 | 48.4 | 53.6 | 419.9 | 413.1 |
| UserPromptSubmit | prompt | 7 | 61.0 | 75.2 | 55.1 | 59.2 | 240.3 | 223.3 |
| PreToolUse | Bash: git status | 5 | 31.3 | 35.3 | 29.8 | 34.3 | 128.9 | 119.8 |
| PreToolUse | Bash: git commit | 5 | 107.0 | 182.6 | 57.9 | 109.8 | 178.1 | 147.0 |
| PreToolUse | Edit: src file | 4 | 64.4 | 78.2 | 50.0 | 131.8 | 161.8 | 140.5 |
| PreToolUse | Write: src file | 4 | 75.9 | 100.5 | 64.8 | 83.1 | 190.7 | 167.0 |
| PreToolUse | Agent: spawn | 4 | 78.0 | 94.6 | 75.3 | 100.2 | 209.9 | 159.7 |
| PostToolUse | Bash: git status | 4 | 66.3 | 116.4 | 51.8 | 81.7 | 189.7 | 161.9 |
| PostToolUse | Agent: spawn | 1 | 44.5 | 53.6 | 45.3 | 50.1 | 45.3 | 35.0 |
| Stop | short transcript | 11 | 202.9 | 335.1 | 150.4 | 240.5 | 679.3 | 551.7 |
Parallel vs sequential: Claude Code hooks docs (code.claude.com/docs/en/hooks): "All matching hooks run in parallel." docs/KB-claude-codex.md does not state it. CPU method: process.resourceUsage() of the hook process via a --require probe (excludes grandchildren).
Addendum 2026-10-04: coordinator-work-guard and the Node compile cache¶
The table above predates coordinator-work-guard (PreToolUse and PostToolUse Bash), so the Bash scenarios there list 5 PreToolUse and 4 PostToolUse hooks. On 0.201.0 they run 9 and 6, after coordinator-work-guard, compact-declaration-guard, api-guard and ship-it-guard added Bash.
Conditions: the machine was far more loaded than for the table above. Load average (1 min) was 220 to 257 at the start of each run below (the table above ran at 17 to 29). CPU time is not load-immune (a bare node -e 0 costs more CPU when the machine is oversubscribed), so compare only numbers from the same run. These runs interleave the variants hook by hook (base, then changed, then base...) so load drift hits both equally. Same fixture and payloads as scripts/hook-latency.js, CPU from the same --require probe, 29 samples (30 runs, first dropped), p50 only, ms.
| Event | Scenario | Hook | CPU p50 before | CPU p50 after | Load (1 min) |
|---|---|---|---|---|---|
| PreToolUse | Bash: git status | coordinator-work-guard | 53.4 | 45.0 | 220 |
| PostToolUse | Bash: git status | coordinator-work-guard --post | 44.2 | 45.6 | 220 |
What changed: for a plain read-only command (git status, ls, cat | head, ...) the hook no longer loads command-guard.js (237 KB) at all; lib/coordinator-work.js provablyNotWork recognises a closed vocabulary and answers "not work". Any other command still goes through classifyBashWork unchanged, so a state-changing or unknown command (git commit) costs what it did before. --post is unchanged because it reuses the verdict stored at Pre by tool_use_id and does not classify.
Node compile cache (NODE_COMPILE_CACHE, Node 22.1+) was measured and not adopted: it moved CPU p50 by 0 to 6 percent, below the 15 percent bar. Same method, base vs NODE_COMPILE_CACHE=<dir> (warm cache), load 220 to 257:
| Hook | CPU p50 no cache | CPU p50 compile cache |
|---|---|---|
| compact-declaration-guard | 40.9 | 41.0 |
| git-guard | 45.0 | 42.3 |
| command-guard | 60.2 | 56.8 |
| coordinator-work-guard | 57.9 | 55.6 |
| merge-gate | 39.2 | 40.3 |
| scan-throttle | 38.5 | 38.4 |
| edit-guard | 61.6 | 61.6 |
| ship-it-guard | 39.3 | 38.1 |
| task-guard | 45.1 | 44.9 |
| speculation-guard | 49.3 | 50.0 |
Most of a hook's CPU is process startup, not compiling its own modules; loading command-guard.js alone measured about 8 ms CPU on this machine.
Addendum 2026-10-04 (second): what was cut, and what the numbers really are¶
Two corrections to the figures above. (1) The table was measured with the benchmark inheriting the developer's own DEVSWARM_* variables, so the DevSwarm hooks (devswarm-parent-gate 109.5 ms, devswarm-parent-inbox 52.9, devswarm-child-role 51.5) ran their full DevSwarm logic. A session that is not a DevSwarm Primary or child, the common case, never pays that. (2) On a quiet machine (load 3 to 6, 14 samples, median of 3 runs) the per-hook CPU floor is a bare node -e 0 at about 16 to 18 ms and most hooks sit at 22 to 35 ms; the 40 to 60 ms rows above are partly load. Do not read the table's absolute figures as a baseline.
What was cut (decisions unchanged):
devswarm-parent-gate,-parent-inbox,-child-turn,-child-gate,-child-drainloaded 70 to 187 KB of source plus a dozen companion libs before reaching their "not DevSwarm / wrong role" early return.hooks/lib/devswarm-primary-gate.jsrepeats the same payload-independent checks first and exits. Verified by require-cache probe (the heavy lib is absent from a non-DevSwarm run and present for the right role,tests/hooks/devswarm-primary-gate-fastexit.test.js) and by measurement.crypto/child_processload on first use (hooks/lib/lazy-node.js) in the hooks that rarely hash or spawn.
Paired and interleaved (base and patched alternate per sample), DevSwarm env stripped, 30 samples, CPU p50 ms, load 33 to 40 (so the floor reads about 20 here), bare node -e 0 about 16.5 on a quiet machine:
| Event | Hook | Before | After | Delta |
|---|---|---|---|---|
| Stop | devswarm-parent-gate | 36.1 | 20.7 | -15.3 |
| UserPromptSubmit | devswarm-parent-inbox | 36.5 | 20.8 | -15.7 |
| UserPromptSubmit | devswarm-child-turn | 29.2 | 20.2 | -9.0 |
| Stop | devswarm-child-gate | 25.8 | 20.4 | -5.4 |
| Stop | task-guard | 24.3 | 21.8 | -2.5 |
| Stop | compact-advice-guard | 23.9 | 21.9 | -2.0 |
| PreToolUse Agent | phase-tracker | 28.1 | 25.3 | -2.7 |
Per call for a non-DevSwarm session: Stop about 20 ms CPU less, UserPromptSubmit about 25 ms less. The other hooks in the lazy-require change moved by -4 to +1.4 ms (noise level at this load) and are not claimed.
Checked and not changed (no gain, or a decision would change):
- Node compile cache: command-guard 32.2 to 30.5 ms, within noise (see the earlier addendum); not adopted.
command-guardandcoordinator-work-guard: profiled, about 3 ms is module compile and the rest is runtime startup;coordinator-work-guardalready skipscommand-guard.jsfor read-only commands.git commitcosts it about 5 ms more because the classifier is genuinely needed.git-guardongit commit -a: wall 58 ms vs CPU 35 ms is two serialgit diff --name-onlyspawns (staged, then unstaged) for the handover-commit check. They cannot be merged into onegit diffwithout changing which paths are reported, so they stay.tasklist-guard/silent-agent-nudgescale with transcript size: both already read a bounded tail (512 KB, with a 16 MB presence-only fallback; 64 MB for silent-agent-nudge). Shrinking those windows changes what they can see, so they are unchanged.devswarm-parent-gatein a real DevSwarm Primary still costs about 51 ms CPU (full logic, aspawnSync); unchanged.