Does name match a hook's declared tool list, under the SAME semantics as the
matcher the compiler emits for it?
🔴 IT DID NOT, AND THE DISAGREEMENT WAS SILENT. hookRouting joins a react's
tools with | and emits that as the harness matcher, and a Claude Code matcher
is a REGEX — Edit|Write|MultiEdit only works because it is one. The runtime
meanwhile compared with Array.includes, i.e. exact string equality. So a hook
declaring a tool FAMILY compiled fine, was wired up fine, was routed to by the
harness fine, and was then dropped by vigiles' own filter without a word.
MEASURED 2026-08-12 against the real runtime, before the fix:
$ echo '{"tool_name":"mcp__4f54037d-0499__list_events",…}'
| vigiles hook-runtime run-program mcp-family.hook.mjs
exit=0 # silence — react() never ran
$ echo '{"tool_name":"mcp__.",…}' | …
FIRED on mcp__. # fires only for a tool LITERALLY named "mcp__.*"
That is the false-confidence class this whole subsystem exists to eliminate,
living inside the subsystem. The live evidence that the harness really does
route these: the knowledge base has shipped "matcher": "mcp__.*" in
.claude/settings.json for months and its stamp file was last written the
morning this was measured. The MCP server's id changes per session, so an exact
list cannot be written down — a family matcher is the only correct spelling.
Anchored ^(…)$ so a pattern cannot match a longer tool name by accident, and
identical to includes for ordinary names, which contain no metacharacters.
An unparseable pattern is rejected at COMPILE (invalidToolPatterns), so
the fallback here is unreachable in a compiled hook and exists only so that a
hand-constructed one degrades to exact matching rather than throwing mid-event.
vigiles/hook— the closed vocabulary for authoring a compiled hook.A hook today is opaque shell (
bash guard.sh): un-analyzable, and the author hand-writes the fragile parts (exit code, JSON field, agrepmatcher) that cause the #1 verified hook pain — FALSE CONFIDENCE (a guardrail that looks like it blocks but silently doesn't). Invert it: author a pure typed function(event) => Decisionagainst THIS surface, andvigiles compileemits the harness protocol for you. The whole false-confidence class becomes UNREPRESENTABLE — you never write the exit code or the field.The roles, each with its own output type so a category mistake is a
tscerror, not a silent no-op:experimental_defineHook/experimental_defineFileGate— a gate returns aDecision(allow/deny/ask);denyis the only thing that blocks.experimental_definePromptGate— a prompt gate (UserPromptSubmit) sees the prompt TEXT and maydenyto block it (a security filter).experimental_defineStopGate— a stop gate (Stop/SubagentStop) maydenyto keep the agent going (gate-until-tests-pass).experimental_defineInject— an inject returns anInjection(context text); it has nodeny, so "block on a SessionStart hook" won't compile.experimental_defineReact— a react (PostToolUse) returns aReaction; it sees the tool RESPONSE, itsrun(cmd)is effect-classified at construction, and it can't block (the tool already ran).Every gate takes a
mode:enforce(default, blocks) orobserve(the shadow/rollout mode — records what it WOULD block, never blocks).observeis harness-neutral (it just exits 0 + writes a local record).Matching is AST-backed (
command.runs("git push", { force })), so it catchescd x && git push -fthat the nativeBash(git:*)glob misses.A gate may also decide on EXTERNAL STATE by declaring
needs(e.g.needs: ["git.branch"]): the trusted runtime gathers those read-only facts and hands them in ase.ctx— the hook still does zero I/O, and reading an undeclared fact is atscerror. Built-ins:git.branch/git.isDirty/git.root/cwd/os.platform/env.isCI. For a one-off off-catalog fact, the lightweight opt-out is an inlineprovide(name, cmd)(read-only) ordangerously(name, cmd)(the loud escape) right inneeds. Seeresearch/hook-context-providers.md.⚠️ Honest scope: compile/verify fix the hook's AUTHORING + LOGIC, not the harness's DELIVERY. The delivery floor MOVED — #34692 (a subagent's tool calls never reaching PreToolUse) is FIXED as of Claude Code 2.1.241, measured against a stock registry install and pinned by src/subagent-delivery.test.ts, which goes red if it regresses. SCOPE of that measurement: headless
claude -ponly — interactive is unmeasured, and depth-2 subagent nesting does not occur there at all. What has NOT changed: a model can still route around a tool entirely (#45427 / #32376 — a Bash heredoc instead ofWrite), so a gate is a strong default and is NEVER an unbypassable wall. Seedocs/compiled-hooks.md.