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, a grep matcher) 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) => Decision against THIS surface, and vigiles compile emits 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 tsc
error, not a silent no-op:
experimental_defineHook / experimental_defineFileGate — a gate returns a Decision
(allow/deny/ask); deny is the only thing that blocks.
experimental_definePromptGate — a prompt gate (UserPromptSubmit) sees the prompt
TEXT and may deny to block it (a security filter).
experimental_defineStopGate — a stop gate (Stop/SubagentStop) may deny to keep
the agent going (gate-until-tests-pass).
experimental_defineInject — an inject returns an Injection (context text); it
has no deny, so "block on a SessionStart hook" won't compile.
experimental_defineReact — a react (PostToolUse) returns a Reaction; it sees the
tool RESPONSE, its run(cmd) is effect-classified at construction, and it
can't block (the tool already ran).
Every gate takes a mode: enforce (default, blocks) or observe (the
shadow/rollout mode — records what it WOULD block, never blocks). observe is
harness-neutral (it just exits 0 + writes a local record).
Matching is AST-backed (command.runs("git push", { force })), so it catches
cd x && git push -f that the native Bash(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 as e.ctx — the hook still does zero I/O, and reading an
undeclared fact is a tsc error. 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
inline provide(name, cmd) (read-only) or dangerously(name, cmd) (the loud
escape) right in needs. See research/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 -p only —
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 of Write), so a gate is a
strong default and is NEVER an unbypassable wall. See docs/compiled-hooks.md.
stampHook(source:string):SHA256Hash
Stamp a hook's source (the integrity.ts pattern, applied to a hook artifact).
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.