Files
zernie__vigiles/docs/compiled-hooks.md
zernie 45a7690ec2 feat: cheap hook-runtime startup, a docs-drift gate, and claims that were measured (#215)
* fix(site): replace unmeasured claims about other tools with a measurement

The hero opened with "Nothing checks that the tools, events, files and rules
your skills and hooks name actually exist." Nobody had ever run the other tool.
Two things were wrong with the sentence: it is a universal negative over every
tool that exists, which is unmeasurable, and on its own terms it is false for
one of its four nouns.

Measured instead, and the probe is committed as tools/measure-validate-overlap.mjs
so the next reader reruns it rather than trusting prose. Against Claude Code
2.1.263, seven planted defects, both harness shapes:

  repo-local .claude/   0 of 7 flagged, default AND --strict
  packaged plugin       1 of 7 — "hooks.PreToolUze: unknown hook event"

The silence is real silence: the same binary errors on invalid manifest JSON
and on a hooks.json missing its root key, and warns on an agent with no
description, so it does read these files.

That measurement moved claims in BOTH directions, which is the point:

- Hero drops the universal negative and describes only what vigiles does.
- Wedge credits `claude plugin validate` with the hook-event check it really
  makes, scoped to plugin form, and stops listing that check among the ones it
  misses. A draft of this paragraph had it in the wrong column.
- docs/rules/hook-script-exists.md said the rule "matches Anthropic's own
  `claude plugin validate`, making vigiles a superset on the hook surface".
  Measured false — validate does not check hook-script existence at all. We
  were under-crediting our own rule. Corrected in place rather than deleted, so
  it does not read as a wording change and get added back.
- Same false credit removed from the subagent-frontmatter keyFile: validate
  flags neither `model: sonnnet` nor `color: purpel`, and cclint was never run.

Every claim of exclusivity, scope or comparison now carries a comment above it
with the claim, the command that backs it and the version, what the earlier
version said and why it was wrong, and what would invalidate it. The record of
the mistake is the part that prevents the repeat.

Two live falsehoods found on the page while verifying the above:

- The first failure card and the `audit` example both used `event: "Setup"` as
  a hook event that does not exist. `Setup` IS a real Claude Code event, with
  its own docs sections; this repo's own catalog learned that on 2026-08-17 and
  the site never followed. The page's opening example of "config that parses
  and does the wrong thing" was config that works. Now `PreToolUze`, verified
  wrong in both directions against the real binary.
- The `eval` row quoted the debunked claim as 65%. Three places in this repo
  and the linked post all say 65%. A debunk that misquotes its own target is
  the one number a reader checks in ten seconds.

Also: the hero's language chip said "Any language · 11 linter catalogs" — an
overclaim wrapped around a term a cold visitor cannot gloss, which never told a
Python developer that Ruff and Pylint are covered. It now names the languages,
still derived from BUILTIN_LINTERS, with a test that fails if a shipped linter
has no language decision or a covered language is missing from the display
order. Both directions proved by mutation.

The landing-site skill is committed in this public repo and carried a "backend
(roadmap)" design for an unbuilt capability plus a growth-loop framing, which
no-product-strategy-here forbids here. The technical guidance stays; the
roadmap and the growth narrative are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: replace the unmeasured exclusivity claim with the mechanism

"No other tool resolves rules across 11 catalog APIs" stood in five places.
It rested on a competitor matrix whose cells were checked against
DOCUMENTATION rather than against a run — the same matrix that put a false
claim on the landing page, corrected in the previous commit after measuring
the real binary.

Two independent reasons to drop it, not one:

1. It was never measured. Verifying it means enumerating 11 catalog APIs
   across every comparable tool, which nobody did.

2. This repository is public, and its own `no-product-strategy-here` rule
   forbids competitive positioning here outright. "The core moat" and "no
   other tool" are positioning, so those lines were a rule violation
   independent of whether they happened to be true.

Replaced with what the engine actually does, which a reader can check by
running it: the rule name is resolved against the linter's own catalog
rather than matched as a string, and the enabled state is read from the
project's config.

The removal is recorded in the source rather than performed silently. A
silent deletion reads as a wording change and invites the claim back; the
comment in linters.ts says what stood there and why it went.

Sites: docs/linter-support.md, docs/what-vigiles-catches.md,
src/core/linters.ts, CLAUDE.md.spec.ts, examples/CLAUDE.md.spec.ts (the two
CLAUDE.md artifacts recompiled).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compile): the section size guard measured the wrong unit

`DEFAULT_MAX_SECTION_LINES = 200` reads as "a section may not sprawl" and
never fired on the section that actually sprawled. Two reasons, both
measured on this repo 2026-09-08:

1. The cap counts LINES. A `keyFiles` entry is ONE line of any length, so
   `## Key Files` reached 173 KB — 59% of the compiled CLAUDE.md — while
   staying far under 200 lines. Longest single description: 4782 chars.

2. `keyFiles` never reached the check at all. `compileKeyFilesSection` is a
   separate path that calls `validateFileRef` and nothing else, so the one
   section with no length control was the one that grew.

Both closed: a character budget beside the line budget (whichever runs out
first reports), and a per-entry cap on a key-file description, since an
entry is a pointer and the reasoning belongs in that file's own header
where a reader meets it on opening the file.

RATCHETED, not bare. Introduced bare these caps open with 60 findings and
get switched off the same day. `.vigiles/keyfile-debt.json` records the
existing oversize once; the caps report only what is NEW or has GROWN, and
a repo without the file gets every finding, which is the right default for
everyone else. Lowering a number is the fix; raising one is the edit the
file exists to make visible in review.

Verified in three directions, not one: silent on the recorded debt, fires
on an entry whose recorded size was lowered, fires on an entry removed from
the debt entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(docs): withdraw the unmeasured #30519 matcher claim, and record the probe

`docs/compiled-hooks.md` and CLAUDE.md both said the native `if:` matcher
misses a compound `cd repo && git push -f`, citing #30519, and offered that
as a reason to prefer the AST-backed `runs()`. Measured against Claude Code
2.1.263: the native matcher CAUGHT the compound command, in both spellings.
Either the issue was fixed the way #34692 was, or the claim was wrong when
written. Withdrawn rather than repeated; a focused re-measure decides.

What the same run DID establish is a real gap, and a sharper one:

  form     input                                     hook spawned
  prefix   git push --force origin main              YES
  prefix   /usr/bin/git push --force origin main     NO
  glob     git push --force origin main              YES
  glob     /usr/bin/git push --force origin main     NO

An ABSOLUTE program head is matched by neither spelling. That holds for
`Bash(git push:*)`, the form Anthropic ships in its own security-guidance
plugin, as much as for the glob form — so a guard written either way does
not run on `/usr/bin/git push --force`, which is the whole guard when the
hook body is unconditional.

The probe is committed as tools/measure-if-matcher-forms.mjs so the next
person re-runs it after a Claude Code minor instead of trusting this note.
It measures someone else's matcher, and prose cannot notice when that moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(claude-md): key files are pointers again — 73k tokens to 43k

`## Key Files` was 59% of the compiled CLAUDE.md. Its 266 entries had grown
from index lines into paragraphs, median 371 characters, longest 4782 — and
every one of them is loaded on every turn of every session in this repo.

Measured before cutting anything: 220 of the 266 files ALREADY carry the
same reasoning in their own header comment, usually in a fuller form. So
this is mostly not a move, it is deletion of a duplicate that had drifted
from the copy people actually read when they open the file. For the 17
entries whose file had no header, the prose was moved into one rather than
dropped; 29 short entries were left alone.

  compiled CLAUDE.md   291579 -> 173682 chars   ~72900 -> ~43400 tokens

AND THE RATCHET IS GONE. The cap landed an hour earlier with a
`.vigiles/keyfile-debt.json` recording 60 existing violations so it would
not open loud enough to be switched off. That was the wrong order of work:
a mechanism to tolerate the problem, invented instead of spending one pass
fixing it. With the entries pruned the debt file is empty, so both it and
the code reading it are deleted and the cap stands bare — 0 findings on a
clean tree, fires on a lengthened entry.

The section CHARACTER cap goes with it, for the same reason it should never
have shipped ratcheted: `positioning` is 24877 characters and would fail it
today, and a guard that cannot fire is the defect this product is about.
That section is a real and separate piece of work, named here rather than
recorded as debt.

Verified in both directions on a clean tree: silent at 0 findings, and a
mutation that lengthens one description fails on its own assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tools): record the WIDENED if: forms, which are the ones that are sound

The probe measured the two `if:` spellings found in the wild and stopped
there, leaving the useful half unmeasured: what a native `if:` would have to
look like for a compiler to be allowed to emit one.

Measured on Claude Code 2.1.263:

  emitted if:                 plain   absolute   unrelated
  Bash(git push *--force*)    fires   NO         no
  Bash(git push:*)            fires   NO         no
  Bash(*git*)                 fires   fires      no
  Bash(*)                     fires   fires      fires

The natural translation of a typed predicate is strictly NARROWER than the
body it guards — it misses an absolute program head — so emitting it turns a
guard off on exactly the input a careful author wrote it for. Widening to the
PROGRAM NAME alone keeps every case the body would catch and still filters
unrelated commands, which is the property an emission needs: it may only ever
add spawns, never remove one.

Flags and subcommand refinement are dropped on purpose. Including them is what
makes the natural translation too narrow, and the body re-checks them anyway.

Left explicitly unmeasured so nobody assumes it: the widening for `touches()`
and `pipesToShell()`. Either may have none, and the honest answer for such a
primitive is to emit nothing rather than something that looks like a filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(claude-md): the two "layer" paragraphs point at their guides again

`## Positioning` was 24891 characters, and two of its twelve paragraphs
were 64% of it:

  Second layer — testing the harness   7859 chars
  Third layer — COMPILED HOOKS         8108 chars

Both are summaries of a document that already exists, is far richer, and
was already linked from the same paragraph:

  docs/harness-testing.md   37114 chars   4.8x the summary
  docs/compiled-hooks.md    62167 chars   7.8x the summary

Same shape as the key-files entries in the previous commit: a copy that
duplicates the text people actually read, drifts from it, and is loaded
into every session's context whether or not the turn is about hooks.

What was KEPT in each is the part that constrains what gets built or
claimed, because that has to be in context and cannot live behind a link:
the tier discipline (push a question down a tier; only firing and
behaviour-movement touch a real model), that the mock-model tier is table
stakes and must not be sold as distinctive, the `experimental_*` marking,
that compile and verify fix authoring and logic and never delivery, and
that a gate is a strong default and never an unbypassable wall. What went
to the guides is the mechanics: the tier APIs, the role family, the
vocabulary, the stamp.

  ## Positioning   24891 -> 11361 chars
  CLAUDE.md        43420 -> 40038 tokens   (72894 before this branch)

The section CHARACTER cap is still not restored, and this says why rather
than leaving it looking forgotten: `## Rules` is 82586 characters and would
fail any cap that means anything. Rules are a different problem — measured
across 53 of them, median 1449 characters, and unlike a key-files entry or
a layer summary there is no richer copy elsewhere to point at, because a
rule's text IS the instruction. Trimming it loses information rather than
removing a duplicate, so it is judgement, not a mechanical pass, and a
500-char cap would open on 40 of 53 and be switched off the same day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(hook)!: a bash gate is Bash by construction — delete its match field

`HookProgram.match: { tool: string }` was a representable illegal state, and
the same false-confidence class the module header says this subsystem exists
to eliminate:

    if (rawEvent.tool_name !== program.match.tool) return allow();

`match: tool("Edit")` type-checked, compiled, wired a PreToolUse matcher
`Edit`, fired on edits, built `commandView("")` from a missing
`tool_input.command`, found every predicate false and returned allow() — a
silently dead guard. The comparison was `!==` while every sibling role routes
through `matchesTool`; that runtime/emit disagreement is the one MEASURED and
fixed for reacts on 2026-08-12 and documented in this very file's header. It
survived on the flagship role.

The field carried no information either: `BashToolEvent.tool` is the literal
"Bash", and all 34 call sites across both repos wrote `match: tool("Bash")`.

So the fix is deletion rather than unification: the matcher moves to
`hookRouting`, the runtime keeps a defensive `!== "Bash"` on a literal nobody
can mis-set, and the author's file loses a line it could only get wrong.

BREAKING CHANGE: `tool` is no longer exported from `vigiles/hook`, and
`HookProgram` no longer accepts `match`. Both are part of the compiled-hook
vocabulary, whose entry points are `experimental_*`; taken now precisely
because the vocabulary is still young. `tools()` — the plural, for file gates
and reacts — is untouched, and so is the `tool()` CHECK in `vigiles` (a
different function that happens to share the name).

Found by an architecture review that was asked to break a different proposal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(hook): restore the CHECK `tool()` a careless sweep took with it

The previous commit removed `tool` from the compiled-hook vocabulary, and the
regex that cleared its call sites also stripped `tool,` from `src/test.ts` —
the `vigiles` ROOT barrel, where a DIFFERENT `tool()` lives: the check from
`src/check.ts`, used as `assertChecks(run, [tool("Bash")])`. Two unrelated
functions that happen to share a name, and the sweep could not tell them
apart.

That silently removed a stable public export. Nothing in the change was
about it, the commit message did not mention it, and the api-surface diff
that would have shown it was regenerated only afterwards.

Caught by `docs-imports`, which type-checks the code blocks in `docs/` against
the real surface and failed on `docs/testing-api.md:210` importing `tool` from
`"vigiles"`. Worth noting which gate did it: the unit tests stayed green,
because no test imports that symbol from the root barrel. A doc example was
the only consumer standing in for every external one.

Also completes the docs half of the previous commit, which shipped without
it: `docs/compiled-hooks.md` still imported `tool` from `vigiles/hook` in
three places, wrote `match: tool("Bash")` in four, and listed `tool(name)` in
the vocabulary — all naming a function that no longer exists. And
`api-surface/vigiles-hook.api.md` is regenerated, so the removal is visible
as a reviewed diff rather than a surprise.

Gates: `npm run check` — the only failure is `cli-lint`, whose 480 errors are
all inside other sessions' gitignored worktrees (0 outside; CI never sees
them). `docs-imports` 0 findings. `src/hook.test.ts` +
`src/core/hook-program.test.ts` 120/120.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(examples): drop the `tool` import the sweep left behind

Two example hooks kept importing `tool` from the hook vocabulary after the
sweep removed their `match: tool("Bash")` line — and `tool` no longer exists,
so the module failed to load:

    HookCompileError: Cannot load hook "examples/harness/protect-main.hook.mjs":
    Named export 'tool' not found.

`compiled-hook-inprocess.harness.mjs` loads that file BY PATH and asserts over
its decisions, so it went red. The runtime behaved correctly — a hook that
cannot load must not pass traffic — which is why the failure surfaced as a
refusal rather than a silent skip.

HOW IT REACHED CI, which is the part worth recording: `npm run check` prints
the CI jobs it does not cover, and `harness` was on that list. The change
touched hooks throughout. I read the list, decided the harness tier was the
known-environment-red one (`claude` 2.1.187 pinned vs 2.1.263 installed) and
skipped it — while the note that records that redness says in as many words
that redness IN CI is no longer environmental. The printed list did its job;
I did not do mine with it.

The unit tiers could not have caught this either: `src/hook.test.ts` writes
its fixtures as source text and was updated with the sweep, so 120/120 stayed
green while a shipped example was unloadable.

Verified: `node examples/harness/compiled-hook-inprocess.harness.mjs` → 6/6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): the `tool` import inside a fixture STRING, which no sweep reached

`hook-state-store.test.ts:387` expected a throttled gate to stay quiet on a
ten-minute-old fact and got `blocked: true`. The gate was not misbehaving —
it could not load:

    const THROTTLED_GATE = `import { experimental_defineHook, tool, state, … }`

`tool` was removed two commits ago, so the module threw on import and the
runtime failed closed, which is correct behaviour for a gate that cannot run.
Same in `hook-load-wedge.test.ts` and `core/hook-program.test.ts`.

WHY THIS TOOK THREE ROUNDS TO FINISH, since that is the reusable part. The
reference lived in four forms and a regex over source reaches one:

  real import in an example .mjs   caught by the CI `harness` job
  re-export in a barrel            caught by `docs-imports`
  import inside a template STRING  caught by the CI `test` job
  code block in a doc              caught by `docs-imports`

That is verbatim the class already written down in this project's own notes
after `.claude/pipeline/` was relocated — "four forms of a reference, of which
a path search sees ONE" — including its conclusion that the only check which
covers all four is one that RESOLVES the reference, not one that greps for it.
I could have quoted the rule and still edited by regex and verified by a
subset, three times running.

Verified this time by the whole suite rather than the files I expected to
break.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): a FIFTH spelling of the same import — built by concatenation

`cli-coverage-record.test.ts` builds its fixture's import line with string
concatenation:

    `import { experimental_defineHook, tool, deny, allow } from ` +
      JSON.stringify(HOOK_ENTRY) + …

so no search for `from "…"` reaches it. Two tests failed because the fixture
hook could not load and the runtime failed closed — correct behaviour, stale
fixture.

That is the fifth form this one removed symbol lived in. The running tally,
because the tally is the finding:

  real import in an example .mjs        caught by the CI `harness` job
  re-export in a barrel                 caught by `docs-imports`
  import inside a fixture template      caught by the CI `test` job
  code block in a doc                   caught by `docs-imports`
  import assembled by concatenation     caught by the full local suite

Five spellings, four different gates, and every one of them found by a gate
rather than by the sweep that created the problem. The lesson already written
down in this project — that a reference search sees one form and only
RESOLVING the reference sees all of them — held on every round.

Verified: `npx vitest run src/cli-coverage-record.test.ts` 16/16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say why the emitted hook block has a matcher and no native `if:`

Claude Code lets a hook action carry an `if:` precondition, checked before the
hook process spawns. `vigiles compile` does not emit one, and the guide never
said why — the first question a reader who knows Claude Code will ask, with no
public home. That fails `document-the-why`.

The rule the decision rests on: a prefilter may only ever be a proven SUPERSET
of the typed predicate. Narrower and the process never starts, nothing is
logged, and the guard reads as passing — the silent-guard bug this whole page
exists to make unwritable.

The safe superset is not a useful filter: `command.runs("git push", {force})`
is AST-backed (compound, subshell, pipeline, absolute program head), and the
narrowest string condition provably covering that is about `Bash(*git*)`. Same
measurement as the "Matcher bypass" row (tools/measure-if-matcher-forms.mjs,
2.1.263).

Two smaller reasons: `if:` is Claude Code only (Codex has no equivalent field,
so emitting it would make one typed source mean different scopes per harness),
and it would not buy the latency it appears to — same-event hooks run
concurrently, so a filtered-out hook does not shorten the turn by its own
duration. Per-event cost is the runtime's startup, which is where it belongs.

Also cross-referenced from the "Runtime cost" limitation, which previously left
`if:` looking like the obvious escape hatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: `tool` is no longer in the hook vocabulary — the Status list still named it

`tool("Bash")` was removed from `vigiles/hook` earlier today: a bash gate is
Bash BY CONSTRUCTION, the matcher is emitted for the author, and every call site
in both repos wrote the same literal. The removal swept the imports, the
`match: tool("Bash")` lines and the vocabulary bullet — and missed this one.

The Status/pending section lists the vocabulary as prose to explain why only the
entry points carry the `experimental_` prefix. `tool` sat in that list, naming a
symbol that no longer exists, in a section about a different subject.

Measured limit this exposes: nothing catches it. self-command-refs resolves
`vigiles <cmd>` invocations; doc-refs resolves marks inside fenced ts blocks.
A vocabulary word named in PROSE is a form neither resolves, and a grep for the
symbol also matches the CHECK `tool()` in docs/testing-api.md, which is a
different, live function — so the sweep that would have found it also had to
tell two same-named symbols apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: reading an `if:` and emitting one are different acts — say so

The new "why no native `if:`" section explained the refusal to EMIT a condition
and left a reader free to conclude vigiles ignores the field entirely. That
inference is wrong in a way that costs trust in a different feature: the test
tiers already honour a hook's own `if:` when measuring it (`decideHookCondition`,
src/core/hook-condition.ts, on main since #211), precisely so a guard is scored
as the harness would really run it rather than as an unconditional stand-in that
flatters it.

One paragraph, stated as the asymmetry it is: we read yours, we do not generate
one for you, because a generated filter can be narrower than the program behind
it and a hand-written one is the author's own declared intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: cut a paragraph that took five lines to carry one fact

"None of this means vigiles ignores `if:`. Reading one and emitting one are
different acts." — an aphorism standing in for a point, opening on a negation of
an objection nobody raised, then five lines to say one thing. It broke this
repo's own prose-clarity rule at the first sentence: lead with the point.

Two lines now, the fact first: an `if:` you wrote is honoured, `runHook` reads
it, only generating one is refused.

Also dropped "a bad trade at any price" from the paragraph above — the preceding
sentence already establishes that the failure is silent, so the flourish added
emphasis to a point that was already made.

Correction to 9898a76, which this supersedes in content: that commit's message
describes only my paragraph, but the commit also carried two edits another agent
made to the same file (the 650→80 ms measurement and the rewritten Runtime cost
bullet). `git commit <pathspec>` separates files, not authors; a shared file
cannot be split that way. Left in history rather than rewritten — the content is
right, and force-pushing a branch with live writers on it is the worse trade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(skills): hold the contributor skills to the trigger-rate bar

Of the 14 skills under .claude/skills/, 9 were model-invocable and only 2
(ship-a-feature, generate-logo) had a trigger-rate eval; 7 could auto-fire
with no measurement. The first question per skill was not "write an eval"
but "should this auto-fire at all" — a skill a human always reaches for by
name costs nothing to disable and leaves the trigger surface entirely.

Disabled (disable-model-invocation: true):
- dogfood-cli — a deliberate multi-agent find-and-fix session a human
  starts on purpose; its own body says "NOT for a single known bug".
- verify-docs-findable — a scheduled, cost-bearing docs audit with an
  argument-hint; already shaped as a command. Disabling it also dissolves
  its overlap with review-docs without an eval.
- deep-research — both of its save targets (research/, the startup/
  vault) no longer exist in this repo; the skill is stale here and its
  description invites competitive/market research this repo does not
  hold. (Despite the CLAUDE.md label, neither it nor review-docs is
  vendored: both were authored in-repo, 39b729a and a0dddb7.)

Kept model-invocable, each with a trigger eval on the ship-a-feature
template (>=10 recall + >=10 precision prompts, stubSkillBodies, drawn
from this repo's real work):
- code-quality — "clean this up" never carries the skill's name.
- landing-site — a design bar that must arrive BEFORE site/ is touched.
- screenshot — a miss makes the model reach for the blocked
  `playwright install` or capture scroll-reveal sections blank.
- review-docs — authored as auto-triggering ("review the readme").

Two genuine prompt-space overlaps are named in the eval headers and
deliberately left out of every precision bank rather than picked around:
code-quality vs ship-a-feature on "new public export", and landing-site
vs screenshot on "screenshot the landing page". Those are
measureSelectionMatrix questions over the whole .claude/skills/.

The evals were authored, not run (keyless env; a real-model eval spends
money). The thresholds are the template's defaults, uncalibrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): deep-research routed strategy findings into a public repo

Its "Where to save" section named two directories, and NEITHER exists — both
were removed in #68. So the routing has not worked for some time, and the way it
fails is the problem:

- `research/<topic>.md` — gone.
- "Sensitive: competitive / VC / strategy / fundraising → the encrypted
  `startup/` vault (see `startup/CLAUDE.md`)" — gone.

Two defects, and the second is not cosmetic. The line DISCLOSES that a private
strategy vault exists, in a public repository. And because the vault it names is
deleted, an agent following the instruction today has nowhere to put competitive
or fundraising material except this repo — the skill's own safety valve had
become a funnel toward the exact content `no-product-strategy-here` forbids.

Rewritten to say what is true now: technical findings go to a `docs/` page or a
contributor note cited from keyFiles (the tier decided by public-vs-internal-docs
and doc-tiers); strategy, market, pricing, GTM and roadmap research goes into no
directory here at all, because the rule is unconditional and a deleted file
survives in history and on other branches. Findings come back in the answer
instead, for a human to place privately.

Found while verifying a subagent's report rather than accepting it — the skill
was in scope for a frontmatter change and this is a body defect it flagged and
correctly declined to patch under its brief.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(hook): a decision no longer loads the CLI to decide allow/deny

A compiled hook runs on every matching tool call, and `dist/cli.js` is the bin —
so `vigiles hook-runtime run-program` resolved ~85 top-level imports (compile,
lint, audit, eval, init, the report template loader) before argv was parsed, to
answer a question that touches none of them. CommonJS gives no way to defer that
from inside the file; the entrypoint had to split.

MEASURED, Node 22.22.2, median of 20 spawns, `tools/measure-hook-startup.mjs`,
before = 04f2c2a:

  layer                               before -> after
  bare `node -e ''`                       42 ->  41 ms
  require dist/core/hook-program.js      170 ->  64 ms
  require dist/hook.js                   171 ->  64 ms

  end-to-end run-program              before -> after
  bash gate (safe-bash-guard.mjs)        661 -> 199 ms   3.3x
  file gate                              628 ->  80 ms   7.9x
  inject                                 610 ->  83 ms   7.3x

For scale: a shell hook doing the same job is ~12 ms and a python one ~35 ms.
Roughly 40 ms of what remains is Node itself starting.

Four lazy boundaries, each with its measurement at the call site:

  src/cli.ts            reduced to a dispatcher shim with NO top-level imports;
                        the verbs moved verbatim to src/cli-main.ts
  src/hook-runtime.ts   the run-program runtime, lifted out of the verb barrel;
                        the adapter registry (279 ms) is required only by react
  core/hook-program.ts  @iarna/toml (56 ms) at its one serialize site
  hook-install.ts       the same, and it is the edge that put toml back into
                        every decision through hook-state-store's normalizeHookRef
  core/bash-effects.ts  mvdan-sh (~100 ms) on first parse, not at module load

The parser deferral is clean rather than forced: `decideProgram` returns allow()
before building a commandView when the tool is not Bash, and every entry point in
bash-effects begins by parsing — so no path reaches the parser without needing
it. A bash gate still pays, and that is the work it asked for.

THE EMITTED COMMAND IS BYTE-IDENTICAL, which outranks the win: `npx vigiles
hook-runtime run-program <file>` is baked into every already-emitted settings
block and covered by the SHA stamp beside each hook. Verified by compiling the
same gate and inject with both trees and diffing the artifacts — `.claude/
settings.json`, `.codex/config.toml` and both stamp sidecars are identical.

The invariant is asserted as a MODULE GRAPH, not a duration (#216 asks for this:
a timing threshold on a shared runner is flaky and gets quarantined first).
src/hook-runtime-graph.test.ts dumps require.cache from the real spawned CLI, so
it sees the lazy requires a static scan would miss, and it asserts both
directions — a bash gate DOES load mvdan-sh, `--help` DOES load the barrel.
Proven to fire: hoisting each of the three imports back turns it red on its own
assertion, and the tree is green again with them restored.

Docs updated where the old claim was now wrong: the public runtime-cost bullet
said "tens to hundreds of ms" and now states the measured per-role figures.

Closes #216.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(config)!: rename the `testGlobs` rule option to `include`

One config file, two words for "the set to consider". `orphans` took `include`
(`options.include ?? DEFAULT_INCLUDE`, its own doc calling them "tsconfig-style
glob arrays") while the `untested-*` rules took `testGlobs` for the same job with
the same replace semantics. Both already had `exclude`. Now both read
`include`/`exclude` and mean the same thing by them.

Semantics are UNCHANGED — `include` replaces the defaults, as `orphans.include`
does, as tsconfig and vitest do. No alias and no fallback: a stale `testGlobs`
key is simply not read.

WHAT THIS EXPOSED, which outlasts the rename: `.vigilesrc.json` is an UNGUARDED
public surface. `testGlobs` ships in v26.0.0 through v26.1.1 and is documented
with a copy-paste example, so a user can have it committed today — yet every one
of the eighteen checks passes on this diff. `api-surface/*.api.md` guards TS
exports and `TestCoverageOptions` is not one; nothing at all watches config keys.
The property that makes a rename consequential is precisely the one no gate sees.

Renamed across 14 files: the option in core/types.ts, its consumers
(test-coverage, coverage-evidence, audit-score, cli-main), three test suites, and
five docs plus HANDOFF.md. The eight `.claude/worktrees/agent-*/` copies are
other sessions' gitignored trees and were deliberately not touched.

Gates: format RC=0 (`prettier --check .`, clean) · targeted vitest RC=0
(test-coverage, cli-untested-options, cli-nudge-config — 80 tests) · `npm run
check` RC=1 on `cli-lint` ONLY, whose 480 errors are 480-of-480 inside
`.claude/worktrees/`, zero in tracked files — the same pre-existing condition
recorded on this PR when it was 18 errors. `api-surface` passed (14.5s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(lint): "A `include` entry" — the rename left bad grammar in a user-facing message

The `testGlobs` → `include` rename produced "A `include` entry WITHOUT
{surface}…" in the untested-surface advisory. It shipped to a place I could see
it: the `vigiles lint` output posted as this PR's sticky comment.

Two passes had already fixed the same a/an break in docs/rules/untested-skill.md,
src/test-coverage.test.ts and src/coverage-evidence.ts — and both missed this
one, because the string is assembled from template literals with ESCAPED
backticks (A \`include\` entry). A grep for "A `include`" cannot match it. That
form is on this repo's own list of reference spellings a search does not reach,
alongside a path built by concatenation and an import inside a fixture string.

Found by reading the tool's actual output rather than the source, which is the
only view where the assembled sentence exists at all.

Gates: prettier --check on the file RC=0 · vitest src/test-coverage.test.ts RC=0
(69 passed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(harness): nudge when product code moves and no doc follows

`doc-consistency` says: ship, rename or remove a capability and update its docs
in the SAME change, across BOTH tiers — public `docs/` + README, and the
contributor record in `CLAUDE.md`. That rule is prose, and this evening measured
what prose is worth:

  · `tool()` was deleted from the hook vocabulary while docs/compiled-hooks.md
    kept listing it as vocabulary — through TWO sweeps of that same file.
  · An exclusivity claim outlived the measurement that withdrew it.
  · deep-research routed findings to a `startup/` vault deleted in #68.

No check caught any of them. self-command-refs resolves `vigiles <cmd>`;
doc-refs resolves marks in fenced ts blocks; orphan-docs finds unreferenced
files. Prose describing a capability whose BEHAVIOUR changed is invisible to all
three — nothing about it is broken, it is merely no longer true.

WHY A HOOK, NOT A LINT RULE. `lint` reads a checkout; "the code changed and the
docs did not" is a property of a DIFF. A tree-level rule would have to guess
which prose belongs to which symbol, which is undecidable, and by
lint-rule-calibration a heuristic can never gate. A hook sees edits accumulate
across a session, which is the only place the fact exists.

WHY NOT ship-a-feature. That skill covers this for a NEW export, with executable
DOCUMENTED/FINDABLE checks. Its own trigger eval asserts it stays QUIET on
"Rename the internal helper posixly to toPosix" and "Tighten the wording in
docs/compiled-hooks.md" — firing there is a false positive against
maxFalsePositive: 0.2. Widening it would break its own precision assertion, so
this covers what that skill is measured NOT to.

TWO FACTS, NOT ONE, and the test asserts them apart. `docs.followed` is recorded
on any doc edit, so a session already maintaining its docs stays silent; that
silence is SUCCESS. `docs.nudged` is the rate limit; that silence is a CAP. One
fact could not tell them apart, and the hook would then go quiet for two hours
after a single doc edit.

Tests are excluded from the trigger deliberately: a test states what the code
already does, so a test-only edit changes no documented behaviour. Including
them would make it fire through ordinary red-green work, which is how a nudge
gets muted.

Gates: vitest src/docs-drift-nudge.test.ts + src/test-tier-nudge.test.ts RC=0
(34 passed) · prettier --check . RC=0 · `vigiles compile` wired it into
.claude/settings.json and wrote its SHA stamp.

MUTATION, patch proven landed before the run: deleting the `docs.followed`
branch (collapsing the two silences) fails exactly one test — "QUIET because
docs are being edited (success)" — and restoring it returns 11/11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(coverage): credit the tests this repo already had — 25 untested surfaces to 14

vigiles ships `include` with a `{surface}` placeholder for exactly the case where
a test lives somewhere other than beside its surface, and this repo had never
configured it. So its own audit called eleven tested surfaces untested, including
four trigger evals written today and both compiled hooks.

MEASURED, each step run rather than reasoned:

  25  baseline
  16  `examples/harness/dogfood/{surface}.trigger.eval.mjs` on untested-skill
      — credits fable's four new evals plus generate-logo, strengthen,
        test-harness, edit-spec, debug-my-harness
  25  adding a SECOND, narrower list to untested-hook — the count went BACK UP
  16  one identical list on both rules
  15  renaming src/docs-drift-nudge.test.ts → .hook.test.ts
  14  the same for test-tier-nudge

TWO THINGS THAT SURPRISED ME, both worth knowing before touching this config:

1. THE OPTIONS ARE SHARED ACROSS THE `untested-*` RULES. Setting `include` on
   untested-hook REPLACED what untested-skill had, and nine credited surfaces
   silently went back to untested. docs/rules/untested-subagent.md does say the
   options are shared; the failure mode it produces — a second config UNDOING the
   first, with the count moving in the alarming direction — is not obvious from
   that sentence. The lists are identical on both rules deliberately.
2. A HOOK'S SURFACE NAME CARRIES `.hook`. `{surface}` for
   `.vigiles/hooks/docs-drift-nudge.hook.mjs` is `docs-drift-nudge.hook`, not
   `docs-drift-nudge` — so `src/docs-drift-nudge.test.ts` did not bind and
   `src/docs-drift-nudge.hook.test.ts` does. Established by renaming and watching
   the count, not inferred from the suggestion string.

The first two globs restate DEFAULT_TEST_GLOBS on purpose: `include` REPLACES the
defaults rather than extending them, so omitting them would strip credit from
every `*.harness.*` and `*.eval.*` in the repo while the number looked better.

The 14 that remain are honest: five shell hooks under `hooks/` and nine skills
with no test of any kind.

Gates: vitest both renamed suites RC=0 (34 passed) · prettier --check . RC=0 ·
`node dist/cli.js lint` re-run after every step above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: two committed artifacts drifted from the files they index

CI went red twice, both mine, both from the same shape — an artifact that is
GENERATED but COMMITTED, so adding or renaming a source file silently invalidates
it until it is regenerated in the same change.

1. `generated-types:committed` on ed9eaf1. `.vigiles/generated.d.ts` enumerates
   every project file; `src/docs-drift-nudge.hook.test.ts` was not in it. The
   gate is `git diff --exit-code .vigiles/generated.d.ts`, so the artifact must
   be regenerated AND committed — regenerating alone leaves it red.

2. The SPEC REFERENCE check on 6ce464a:

     ✗ CLAUDE.md: File not found: "src/test-tier-nudge.test.ts"

   Renaming that test to `.hook.test.ts` (so its `{surface}` name binds) left a
   `file()` keyFile in CLAUDE.md.spec.ts pointing at a path that no longer
   exists. This one is worth naming plainly: it is the cross-referencing engine —
   the thing this product IS — catching its own author's stale reference, minutes
   after the rename, exactly as designed.

Also indexes what the rename left undocumented: `.vigiles/hooks/docs-drift-nudge.hook.mjs`
and its test now have keyFiles, and the tier-nudge entry says WHY the file is
named `<surface>.hook.test.ts` — a hook's `{surface}` name carries `.hook`, which
is what binds it under the `include` globs in .vigilesrc.json. Without that
sentence the next rename repeats this.

HOW BOTH GOT THROUGH, since the cause is procedural rather than technical: after
adding the hook I ran targeted vitest and prettier instead of `npm run check`,
and the re-run after that was taken from a tree I was still editing — a gate on a
moving tree measures a state that never existed. Both are the `run-the-gates`
rule, failed in two different ways within twenty minutes.

Gates on a settled tree: build · types:root · types:test · types:site · jest ·
api-surface · prettier — all RC=0. `cli-lint` red with every finding inside
`.claude/worktrees/` (gitignored, CI never sees it). `generated-types:committed`
red only because the regenerated artifact was uncommitted at the time — this
commit is what makes it green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(hook): reject a tool matcher that names no real tool

`compileHookProgram` cross-references the `on:` event against the harness
catalog, so a typo'd EVENT fails the build. It did not do the same for the tool
matcher, and the existing check there validates REGEX SYNTAX only — so
`tools("Edt")` parses fine, compiles, wires a matcher `Edt`, and the hook never
fires. Same defect the event check exists to kill, on the axis it did not cover.

WHY THIS IS NOT A UNION TYPE, which is the obvious answer and the wrong one: a
tool matcher IS a regex — that is why `tools("Edit|Write")` works, and
`tools("mcp__github__.*")` is documented-correct. A literal union would reject
both. So the check reads only entries with NO regex metacharacter as literal
NAMES, which is exactly the spelling a typo produces. A fully-spelled MCP tool is
skipped inside verifyToolContract itself (dialect.mcpToolPattern), so it passes
on both counts.

Reuses verifyToolContract + authoringIssues — the same detector the subagent
tool-contract rule runs, so the hook matcher and the subagent contract cannot
drift into two vocabularies (one-detector-no-drift).

Both directions tested, and MUTATION-PROVEN with the patch grepped as landed:
disabling the throw fails exactly one test ("a typo'd tool NAME in match does NOT
compile"); restoring returns 98/98. The second test pins what must KEEP
compiling — a real name beside `mcp__github__.*` and a spelled-out MCP tool.

🔴 AND THE GATE CAUGHT A REGRESSION THE TESTS COULD NOT. `api-surface` failed
with one line — `// @public` → `// @public (undocumented)`. Inserting the
REGEX_META const put it BETWEEN `invalidToolPatterns`'s JSDoc and its
declaration, so the comment documented my constant and the real function went
bare. That is verbatim the failure `ship-a-feature` was written after
("inserted between verifyGuardrail's JSDoc and its declaration; the diff said
so, nobody read it"). Fixed by moving the comment back, NOT by regenerating —
`api:report` would have committed the degraded surface and called it green.
api:check now passes with the surface UNCHANGED, which is the correct outcome
for adding an internal const.

Gates: build RC=0 · tsc --noEmit RC=0 · api:check RC=0 (surface unchanged) ·
vitest hook-program 98 passed · prettier RC=0 · `npm run check` red only on
`cli-lint`, entirely inside `.claude/worktrees/`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(site): the landing page showed a hook that no longer compiles

The Guard section's headline sample — the first compiled hook a visitor ever
sees — opened with a symbol deleted from the vocabulary earlier today:

    import { experimental_defineHook, tool, deny, allow } from "vigiles/hook";
    export default experimental_defineHook({
      on: "PreToolUse",
      match: tool("Bash"),

`tool()` is gone. A bash gate is Bash BY CONSTRUCTION — `BashToolEvent.tool` is
the literal "Bash", the matcher is emitted for the author, and the field existed
only to be got wrong (`match: tool("Edit")` type-checked, compiled, wired an Edit
matcher, and never fired). Removing it is the fix; the site kept advertising it.

So a reader copying the landing page's own example got a build error on line 1,
and the file's header claims the snippet is REAL — "node dist/cli.js hook-runtime
run-program <this file> # via verifyGuardrail" — which makes it a claim about
executed code rather than an illustration.

🔴 WHAT THIS SAYS ABOUT THE HOOK SHIPPED TWO HOURS AGO. `docs-drift-nudge` exists
to catch exactly this: product code changed, the prose describing it did not. It
would NOT have fired here. It watches `src/**` for the trigger and treats
`docs/**`, README and CLAUDE.md.spec.ts as the docs — a `.tsx` under `site/` is
neither, so the site is invisible to it in both directions. The landing page is a
front door under `cohesive-feature-delivery`, and the nudge does not cover it.
Recorded here rather than widened in the same commit: the trigger set is a
calibration question (a `site/` edit is often styling, not a claim), and widening
a nudge on a hunch is how a nudge gets muted.

Gates: prettier --check RC=0 · `npm --prefix site run test:browser` RC=0 —
7 files, 49 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 02:42:28 +05:00

64 KiB
Raw Permalink Blame History

Compiled hooks — author a hook that can't be wrong

⚠️ Compiled hooks are experimental. They compile, they run in production in two repos, and every property below is tested — but the authoring vocabulary is not settled, and it is not covered by semver. The six entry points are named experimental_defineHook, experimental_defineFileGate, experimental_definePromptGate, experimental_defineStopGate, experimental_defineInject and experimental_defineReact. Write them out at every call site — do not alias the prefix away at the import. This notice used to advise exactly that, and the advice defeated the mechanism it was attached to: measured 2026-08-21, with the alias in place the marker survived at 0 of 5 call sites in the only user-facing example, because a reader 200 lines down sees defineHook(...) and cannot tell it is provisional. A prefix stripped on import is a subpath with extra steps. What would have to be true to drop the prefix.

Stable alternative: a hand-written shell hook wired in settings.json. Same events, same protocol, none of the guarantees this page is about.

A hook is the harness's deterministic gate: the one place that can stop the agent before it does something irreversible. But a hook today is opaque shell (bash guard.sh), and the parts you hand-write are exactly the parts that fail silently. The README has the pitch; this is the full guide.

vigiles lets you author a hook as a pure typed function (event) => Decision against a closed vocabulary (vigiles/hook), then compiles it to the harness protocol. The point isn't ergonomics — it's that whole classes of hook bugs become unrepresentable: you can't write the bug because the API has no way to express it.

Contents

The bug classes it eliminates

A compiled hook eliminates an entire class of bugs by construction — not by catching them after the fact, but by making them impossible to write. Each row below is a verified, common failure of hand-written hooks (sources linked):

Bug class Why it can't happen
False confidence — the #1 pain: exit 1 instead of exit 2, the wrong JSON field (decision vs permissionDecision), a wrong jq path → a guard that looks like it blocks and silently allows (RFC #45427, #24327). You never write the protocol. You return deny(reason); the compiler emits the correct exit code / JSON field for the event. The bug has no place to live.
Matcher bypass — neither Bash(git push:*) nor Bash(git push *--force*) matches an ABSOLUTE program head, so /usr/bin/git push --force never spawns the hook (measured on 2.1.263, tools/measure-if-matcher-forms.mjs; it holds for the prefix spelling Anthropic ships in its own security-guidance plugin). A hand-written grep misses its own set. ⚠️ This row used to cite #30519 and claim the native matcher misses cd repo && git push -f; the same run caught that compound command in both spellings, so the claim is withdrawn pending a focused re-measure rather than repeated. command.runs("git push", { force: true }) is AST-backed — it sees the real git push leaf however it's wrapped (compound, subshell, pipeline). A grep false-positive is gone too.
Capability creep / supply chain — a hook is arbitrary code; a copied or edited one can read secrets or phone home (CVE-2025-59536). Capability = API surface. An import of anything but vigiles/hook (or eval/Function) does not compile. The compiled artifact is stamped (SHA-256) — a later hand-edit breaks the stamp and the runtime refuses it.
Category mistakes — "block on a SessionStart/PostToolUse hook", whose decision is a documented no-op (#4362). Each role has its own return type. An inject/react hook has no deny in its vocabulary, so the mistake is a tsc type error, not a silent no-op.

The unifying idea: a hook is a tiny program, and a closed, typed vocabulary shrinks its state space until the bad states are simply not expressible.

The roles

A hook's output depends on when it fires. vigiles gives each role its own builder and its own return type, so the wrong output for an event won't type-check:

  • experimental_defineHook / experimental_defineFileGate — a tool gate (PreToolUse). Returns a Decision: allow(), deny(reason), or ask(reason). deny is the only thing that blocks; the compiler maps it to exit 2. experimental_defineHook sees the Bash command (e.command); experimental_defineFileGate sees the file path (e.path).
  • experimental_definePromptGate — a prompt gate (UserPromptSubmit). Sees the prompt text (e.prompt) and returns a Decision. deny blocks/erases the prompt — useful as a security filter that refuses a prompt leaking a secret or carrying an injection.
  • experimental_defineStopGate — a stop gate (Stop / SubagentStop). Returns a Decision. deny keeps the agent going (gate-until-tests-pass; the reason is fed back to the model). Honour e.stopHookActive (the loop guard): allow when it's set, or you can wedge the agent in a stop→continue loop.
  • experimental_defineInject — an inject (SessionStart / UserPromptSubmit). Returns an Injection (inject(text)) — context added to the model. It has no deny: a no-decision event can't block.
  • experimental_defineReact — a react (PostToolUse). Returns a Reaction — run(cmd), notice(msg), or nothing(). The tool already ran, so it can't block. It sees the tool's response (e.response, e.g. react only on a failure), and run(cmd)'s effect is classified at construction (read-only / side-effecting), so every reaction is auditable without running it. A notice(msg) reaches the agent as injected context — see Where a notice arrives.

Every gate (tool / prompt / stop) takes an optional mode — see Observe mode.

The vocabulary

The entire surface a hook may touch (that's the safety guarantee):

import { experimental_defineHook, deny, allow } from "vigiles/hook";

// Block any force-push to a protected branch — including one hidden in a
// compound command, which a glob/grep matcher misses.
export default experimental_defineHook({
  on: "PreToolUse",
  decide: (e) =>
    e.command.runs("git push", { force: true })
      ? deny("no force-push to a protected branch")
      : allow(),
});
  • tools(...names) — which tools a file gate or react matches. A bash gate needs none: a BashToolEvent is Bash by construction, so the matcher is emitted for you and there is no field to get wrong.

  • e.command (Bash) — an AST-backed CommandView:

    • runs(program, { force? }) — a leaf runs program (e.g. "git reset --hard"), optionally forced.
    • touches(prefixes) — a leaf mentions a path under one of prefixes (e.g. ["~/.ssh", ".env"]) — secret reads.
    • writesTo(prefixes) — a leaf creates or modifies a file under one of prefixes: redirection targets (cmd > f, >>, >|, &>) plus the writing argv position of sed -i, cp / mv / install (destination), tee, dd of=, truncate, shred.
    • writeTargets(prefixes) — the same write targets as the list of files rather than a boolean, for a gate that has to tell which file (writeTargets(PAPER_SOURCES).some(isPaperSource)); writesTo is exactly writeTargets(prefixes).length > 0. Spelling is as-written after normalization (quotes removed by the parser, $HOME canonicalized, a leaf's own env -C / sudo -D directory resolved), duplicates collapsed — so filter by basename or suffix. prefixes is required and there is no unfiltered form: a consumer holding the raw list has to re-implement prefix matching by hand, which is where every measured gate bypass came from.
    • pipesToShell() — pipes into a bare shell (curl … | sh) — remote-code execution. A shell with a script file (sh deploy.sh) is not flagged.
    • isSideEffecting() — the deterministic Bash-effect classifier's verdict.

    touches ≠ writesTo — conflating them is the trap. touches answers mentioned: grep -c x notes/S.md matches, and so does rm -rf notes. isSideEffecting() does not rescue it, because it classifies the whole command line — grep -c x notes/S.md 2>/dev/null is side-effecting (there's a redirection), so a touches() && isSideEffecting() gate blocks a plain read. Use writesTo to gate writes and touches for "don't even look at this path". Deletion is reported by neither — pair with runs("rm") if a gate needs it.

    Both are DENYLISTS, so they over-block on purpose — the opposite bias from under below. A miss here is an allow, not silence, so an answer the matcher cannot prove is reported as a match. Concretely: a token spelled absolutely (/home/u/repo/notes/x.md) matches touches(["notes"]) whether or not a project root is available, and a token in a sibling checkout matches too, because a repo-relative prefix cannot rule it out. Prefix spelling is forgiving in the same direction — "notes", "notes/" and "notes/**" all name the same directory.

    Bounded, though: the prefix's path segments must actually occur in the token, so ordinary commands stay untouched. Two cases still miss, and both belong to the parser rather than the matcher — cd notes && cat x.md (a leaf's argument is not resolved against a preceding cd), and an absolute prefix against a relative token when no root is available at all.

    Until 2026-08-12 this bias did not exist: touches compared a repo-relative prefix against a raw token with no root anywhere in decideProgram, so spelling a path absolutely walked straight past any gate written with a relative prefix. Measured against a real shipped guard, sed -i s/a/b/ <abs>/paper.tex and cp /tmp/a <abs>/paper.tex both exited 0 while their relative spellings exited 2.

  • e.path (Edit/Write) — a PathView with under(prefixes) for path confinement, plus raw (the path as the tool sent it) and rel (the same path repo-relative, or undefined — see below).

    Write your prefixes repo-relative; the runtime handles the rest. Claude Code's Edit/Write/MultiEdit tools send an absolute file_path, so under(["src"]) has to reconcile the two. It does that with the project root — $CLAUDE_PROJECT_DIR, else the event's own cwd, never the hook process's working directory (under a git worktree that can be a different checkout). An absolute prefix (under(["/etc"])) is matched against the absolute path instead, so both spellings work.

    under is the allowlist / coverage primitive, and an unprovable answer is false: a path in another checkout, or an absolute path with no root in sight, matches no repo-relative prefix. A confinement gate (under(ok) ? allow() : deny()) therefore fails closed, and a nudge stays quiet. The reverse shape — under(secret) ? deny() : allow() — reads that false as allow, so do not build a denylist on it. When there is genuinely no root, the runtime says so on stderr rather than deciding silently.

    Testing a file hook? Build the event with fileToolEvents(path) (from vigiles), which returns both spellings. Hand-written relative events are what let three shipped hooks stay dead while their tests passed.

  • e.prompt (UserPromptSubmit) — the user's prompt text (a plain string).

  • e.stopHookActive (Stop) — the loop guard; allow() when it's true.

  • e.response (PostToolUse react) — a ResponseView: isError() (the tool failed) and contains(needle).

  • allow() / deny(reason) / ask(reason) — the gate decision (every gate role).

  • inject(text) — inject output. run(cmd) / notice(msg) / nothing() — react output.

  • state(name) in needs, record(name, value?) in a return — runtime-owned named memory, so a hook can remember a fact without reaching for the filesystem. See below.

Remembering a fact — state() and record()

A hook that should speak at most once an hour, or only after something else actually happened, has to remember something. By hand that means a stamp file: a path, a format, and two hooks racing for the same one — and it means the hook can reach the filesystem at all, which is the capability compiling took away.

So the hook does not write. It declares a fact, and the runtime writes it:

export default experimental_defineReact({
  on: "Stop",
  needs: [state("calendar.synced")],
  react: (e) =>
    e.ctx["calendar.synced"].olderThan("1h")
      ? run("./bin/remind.sh") // meanwhile whoever DID the sync returns
      : nothing(), //   record("calendar.synced")
});

⚠️ Two shapes worth pinning: the role is experimental_defineReact (a react is what returns run()/nothing()), and needs is an array. The fact lands on e.ctx under the key you named — e.ctx["calendar.synced"] — not under a label of your choosing.

A key is a name, not a path — the runtime decides where it lives, so two hooks cannot disagree about a file. Reading one gives a StateFact:

field meaning
recorded has this key ever been written — the only way to tell "never" from "long ago"
value the recorded string ("" when never)
at ISO-8601 instant ("" when never)
ageSeconds seconds since — Infinity when never recorded, never null
fresherThan(within) / olderThan(within) "90s", "30m", "1h", "7d"

🔴 Infinity rather than null is a safety property, not a style choice. The natural throttle is if (ageSeconds < LIMIT) return nothing(). In JavaScript null < 3600 is true, so a key that was never recorded would read as fresh, and a newly installed hook would go silent forever — quietest exactly when it was just added. With Infinity every "younger than X" test is false on a missing key, so the hook fires. An unparseable timestamp is treated the same way, as never-recorded: that fails toward noise instead of toward a silence nobody can notice.

Throttling is not a separate feature — it is this one, read as "when did I last speak". And record is a declaration in the return value, not a call the hook makes, so a hook cannot record that something happened while doing nothing: the two cannot drift apart.

Testing a hook that remembers

A throttle only does anything interesting against an old fact — and "old" is not something a test can produce by waiting. So the store is seedable, through one handle on the vigiles test surface:

import { experimental_hookState, runHook } from "vigiles";

const st = experimental_hookState(hookFile, { cwd: projectRoot });
st.clear(); // start from "never recorded"

st.seed("retro.nagged", { ago: "4d" }); //  → the hook speaks
st.seed("retro.nagged", { ago: "10m" }); // → the hook stays quiet
st.seed("retro.nagged", { value: "main", at: someDate }); // exact instant

st.read("retro.nagged"); // the same StateFact the hook's e.ctx[key] gets

// …then run the hook for real and assert what it did with that fact.
const r = runHook(`npx vigiles hook-runtime run-program ${hookFile}`, event, {
  cwd: projectRoot,
});
on the handle what it does
seed(key, { ago, at, value }) write a fact as if the hook had recorded it, then read it back; ago backdates
read(key) the fact exactly as e.ctx[key] will see it — Infinity when never recorded, no throw
clear() forget the facts this hook recorded — a co-located hook's are left alone
dir where they live — for a failure message, not a path to build on

Three things to know:

  • Pass the same cwd the hook will run under. The store's location is derived from the hook's path and the project root, so a handle built with a different root points at a different (empty) store.
  • It writes through the runtime's own writer, so a seeded fact and a recorded one are the same file. That is the point: a seeder with its own copy of the path rule agrees with the runtime only until someone edits the derivation, and then fails green.
  • The store is shared per DIRECTORY, and clear() is scoped to this hook's own writes. That sharing is the feature — it is how one hook reads a fact another recorded — so hooks shipped side by side keep their facts in one place. clear() therefore forgets only what this hook wrote, and leaves a co-located hook's facts (and any entry with no recorded owner) in place. When a test wants the store completely empty rather than this hook's share of it, give it a throwaway cwd: the store hangs off that root, so a fresh root is a fresh store.

It is on vigiles, not on vigiles/hook. vigiles/hook is the only import a compiled hook may have, and its guarantee is that it hands out no writer — so the seeding handle lives where a test can reach it and a hook cannot.

A prompt gate and a stop gate read like any other gate — they just decide over a different event:

import { experimental_definePromptGate, deny, allow } from "vigiles/hook";

// Refuse a prompt that looks like it's pasting a secret key.
export default experimental_definePromptGate({
  on: "UserPromptSubmit",
  decide: (e) =>
    /sk-[a-z0-9]{20}/i.test(e.prompt)
      ? deny("your prompt looks like it contains a secret key")
      : allow(),
});
import { experimental_defineStopGate, deny, allow } from "vigiles/hook";

// Don't let the agent stop while the tests are red.
export default experimental_defineStopGate({
  on: "Stop",
  decide: (e) =>
    e.stopHookActive // a prior block already fired — let it stop now (loop guard)
      ? allow()
      : deny("keep going until `npm test` passes"),
});

Observe mode (shadow rollout)

Switching a new gate straight to blocking is risky. What if it's too aggressive and stops work you wanted? Every gate takes a mode:

  • enforce (default) — block on deny.
  • observe — the shadow / rollout mode: compute the same decision, record what it would have blocked, and let everything through. Watch the log, confirm it only flags the bad stuff, then promote to enforce.
export default experimental_defineHook({
  on: "PreToolUse",
  mode: "observe", // ← shadow: record, don't block
  decide: (e) =>
    e.command.runs("git push", { force: true })
      ? deny("force-push to a protected branch")
      : allow(),
});

In observe mode the runtime exits 0 (never blocks) and appends a record to .vigiles/hook-observations.jsonl ({ ts, hook, event, would, reason }) plus a one-line ⚠ [vigiles observe] note. It's harness-neutral — exit 0 + a local record behaves identically on Claude Code and Codex, no harness-specific field names involved.

Deciding on external state (context providers)

A pure gate can only see the command/path/prompt/response. But a real guard often needs external state to decide — "is this a push to main?", "is the tree dirty?". A hand-written hook would shell out ($(git branch --show-current)); a compiled hook can't do I/O (that's the capability guarantee).

The fix is the same one Cedar/OPA/Gatekeeper use: the hook never fetches — it declares what it needs, and the trusted runtime gathers those read-only facts and hands them in as e.ctx.

import { experimental_defineHook, deny, allow } from "vigiles/hook";

export default experimental_defineHook({
  on: "PreToolUse",
  needs: ["git.branch"], //                ← declared; gathered by the runtime
  decide: (e) =>
    e.ctx["git.branch"] === "main" && e.command.runs("git push")
      ? deny("no direct pushes to main")
      : allow(),
});

The guarantee is intact and stronger: the hook still does zero I/O. The runtime runs git branch --show-current (provably read-only) and injects the result. needs is typed — reading an undeclared fact (e.ctx["cwd"] here) is a tsc error, and an unknown provider name won't compile — so the capability surface stays explicit and auditable.

Built-in providers: git.branch, git.isDirty, git.root, cwd, os.platform, env.isCI — zero-arg, read-only. A provider that can't resolve (e.g. not a git repo) yields its default, never an error. The set is deliberately small. A 20+ OSS survey found most facts a hook reads are already event data, and the long tail belongs in the opt-out, not a growing catalog. (env.isCI uses the ci-info library — the one fact where a maintained lib beat hand-rolling. Git facts stay read-only shell commands; platform is process.platform.)

The lightweight opt-out — provide / dangerously. For a one-off, off-catalog fact you don't want to register a whole provider for, declare an inline command right in needs:

import { experimental_defineHook, deny, allow, provide } from "vigiles/hook";

export default experimental_defineHook({
  on: "PreToolUse",
  needs: [provide("k8sCtx", "kubectl config current-context")], // read-only, inline
  decide: (e) =>
    e.ctx.k8sCtx === "prod" && e.command.runs("kubectl delete")
      ? deny("no kubectl delete against prod")
      : allow(),
});

The trusted runtime runs the command (so decide still does zero I/O) and hands its stdout in as e.ctx[name]. provide(name, cmd) requires a provably read-only command (compile rejects it otherwise). dangerously(name, cmd) is the loud, greppable escape for a command that isn't — the dangerouslySetInnerHTML / unsafe convention, so a security review can search for that one word.

Reusable registered providers. For a fact several hooks share, register it once in .vigiles/providers/<name>.{mjs,ts} and reference it by name:

// .vigiles/providers/k8sCtx.mjs
import { defineProvider } from "vigiles/hook";
export default defineProvider({
  name: "k8sCtx",
  run: "kubectl config current-context",
});

// any hook:  needs: [provider("k8sCtx")]  →  e.ctx.k8sCtx

vigiles compile validates each provider is read-only (or dangerous: true) and that every provider() ref resolves to a registered file.

The opt-out ladder (you're never trapped): built-in providers → inline provide / dangerously → registered defineProvider (reusable, named) → the shell lane (a hand-written .sh for arbitrary in-decision logic, verified with the disaster battery). You always know which rung you're on.

Compile and wire

Put your hook source in .vigiles/hooks/. The typed program is harness-neutral — it imports vigiles/hook and compiles to whichever harness you target — so it lives in vigiles's own dir, not a harness's .claude/. Then:

npx vigiles compile                                  # discovers .vigiles/hooks/* (and your specs)
npx vigiles compile .vigiles/hooks/safe-bash-guard.mjs   # …or target one file

There is no separate compile-hook verb — compiling a typed authoring artifact into the harness's native format is one verb, whatever the artifact (a .spec.ts → markdown, a hook → a wired config). compile:

  1. Runs the capability check (an out-of-vocabulary import fails the build).
  2. Merges the hook block into the active harness's config — .claude/settings.json (JSON) or .codex/config.toml (TOML) — idempotently, keyed by the hook path. Recompiling updates in place and never clobbers your own hand-written hooks.
  3. Writes a tamper-evident stamp beside the source.

The merged block routes the live event to vigiles hook-runtime run-program <file>:

// .claude/settings.json — written (not pasted) by `vigiles compile`
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "npx vigiles hook-runtime run-program .vigiles/hooks/safe-bash-guard.mjs",
          },
        ],
      },
    ],
  },
}

Why the emitted block has a matcher and no if:

Claude Code lets a hook action carry an if: condition — a precondition checked before the hook process spawns, so a non-matching call costs nothing. vigiles compile does not emit one. That is a decision, not an omission, and it rests on one rule:

A prefilter may only ever be a proven SUPERSET of the typed predicate.

Get that backwards and the failure is silent. If the if: is narrower than what your decide() would have denied, the process never starts, nothing is logged, and the guard reads as passing. A gate that stops firing without saying so is the exact bug this whole page exists to make unwritable.

The superset that would actually be safe is not a useful filter. A predicate like command.runs("git push", { force: true }) is AST-backed: it sees the real git push leaf inside a compound, a subshell, a pipeline, or behind an absolute path. The narrowest string condition provably covering all of that is about Bash(*git*) — which excludes almost nothing, because the forms that would be excluded are the ones a string matcher gets wrong anyway (measured on 2.1.263, tools/measure-if-matcher-forms.mjs; the same measurement is the "Matcher bypass" row above).

Two smaller reasons point the same way:

  • if: is Claude Code only. Codex's hook config has no equivalent field, so emitting one would make the gate's effective scope differ per harness from a single typed source — a CC-only path of exactly the kind Compile and wire avoids elsewhere.
  • It would not buy the latency it appears to. Hooks registered on the same event run concurrently, not one after another, so a filtered-out hook does not shorten the turn by its own duration. Per-event cost is a property of the runtime's startup, and that is where it belongs — not routed around by a filter that can silently narrow your guard. That is also where it was fixed: the runtime no longer loads the CLI's verbs to make a decision, which took a hook from roughly 650 ms to 80 ms (200 ms for a Bash gate). A prefilter would have bought less, later, and at the cost of a guard that can go quiet.

An if: you wrote is still honoured: runHook reads it and measures your guard the way the harness would really run it. Only generating one is refused.

The matcher we do emit is safe under the same rule: it is derived from the event type the role already implies (a bash gate matches Bash), so it cannot be narrower than the program behind it.

hook-runtime run-program is a hidden runtime entrypoint — invoked by the harness on every matching event, never typed by hand. It loads your typed program, verifies the stamp (a hand-edited artifact is refused — fail closed), and dispatches by role: a gate exit 2s on deny, an inject prints additionalContext, a react runs its classified command. You wrote none of that protocol. (compile is the one-time wiring step; hook-runtime is the per-event executor the wiring points at — see the CLI surface for why they're distinct.)

Hooks can be authored in JavaScript (.mjs) or TypeScript (run under tsx / Node ≥ 23.6). vigiles/hook is the only import a compiled hook may use.

Multi-harness. The typed program is harness-neutral; only the merged config differs. vigiles compile --harness=codex merges a Codex config.toml [[hooks.<event>]] block (an anchored-regex matcher) instead of Claude Code's settings.json JSON. A repo that declares both harnesses (harness: ["claude-code", "codex"] in .vigilesrc.json) gets the SAME hook installed into both configs — vigiles compile (no flag) fans out to every declared harness, so a hook is never silently wired on one and missing on the other. The gate runtime is shared, since Codex vetoes a tool call via exit 2 exactly as Claude Code does. Inject output (additionalContext) is confirmed shared with Codex too, so an inject hook compiles for both — compile --harness=codex only warns if you target an event that harness doesn't honor for injection (each harness declares its injectableEvents). React output is the one piece still Claude-Code-confirmed only, so compile --harness=codex warns loudly on a react hook rather than ship a maybe-no-op.

Where things live

Everything is committed, and the source is adapter-agnostic — one hook compiles to any harness you target:

Artifact Location Why
Hook source .vigiles/hooks/*.{mjs,ts} harness-neutral — one source, fans out to any harness
Provider source .vigiles/providers/*.{mjs,ts} registered context providers (defineProvider), referenced by name
Tamper stamp .vigiles/hooks/<name>.json the runtime verifies it before running the hook
Wiring .claude/settings.json / .codex/config.toml compile merges it in per harness (a multi-harness repo gets both)

Hooks are not auto-discovered by sitting just anywhere — they must be under .vigiles/hooks/ (or named explicitly to compile). Because they share one dir, basenames are unique, so the stamp keys safely on the basename.

Editing a compiled hook (the stamp, and the way out)

The stamp makes the runtime refuse a hook whose source no longer matches what was compiled. That's the point — but it also applies while you are editing the hook, and a stale PreToolUse Bash gate blocks every Bash command, vigiles compile included. That would wedge the repo: you couldn't recompile, because the stale gate refused to let you.

The way out is a file write, not a command. A Bash gate never gated file tools, so both of these work while every command is refused:

  • edit the hook's source back to what was compiled; or
  • clear its stamp — write {} into .vigiles/hooks/<name>.json (or delete it). The hook then loads and runs unstamped, and — this is the part that matters — it goes back to enforcing. You are not disarming the gate, you are un-sticking it. Then recompile through the normal gate:
npx vigiles compile .vigiles/hooks/guard.mjs

The refusal prints the exact sidecar path, so you don't have to work it out:

vigiles: hook guard.mjs does not match its compiled stamp (tampered).
vigiles: if YOU edited it, the way out is a FILE WRITE, not a command — this
  refusal blocks the recompile too. Either edit guard.mjs back to what was
  compiled, or clear its stamp by writing `{}` into
  /repo/.vigiles/hooks/guard.mjs.json. The hook then runs UNSTAMPED but still
  ENFORCES, so `vigiles compile guard.mjs` goes through the normal gate.

If a hook stops loading entirely (a conflicted package.json so Node can't resolve vigiles/hook, a typo mid-edit), the same fail-closed refusal applies — and the way out is again a file write. Writes to the hook's own source, its stamp sidecar, or package.json / .vigilesrc.json are allowed while every command is refused. That set is complete rather than a guess: a compiled hook may import nothing but vigiles/hook, so its load path is the hook file plus the config that resolves that specifier. Fix whichever is broken and the hook loads again; the gate then decides normally, and git merge --abort is an ordinary allowed command — through the gate rather than around it.

A write, under a tool that writes. The escape admits Write, Edit and MultiEdit — the tools measured to write a file and carry file_path. A Read of the same path is refused: a read repairs nothing, and while the wedged hook is registered for Read it would otherwise walk straight out through the escape. An unrecognised tool name is refused as well; the list does not need to be complete, only non-empty, because a new writing tool wedges nothing — you still repair with Write. (NotebookEdit is absent on purpose: it writes, but its input field is notebook_path, so it never carries the path this door reads.)

In this repository only. The allowed write is the file the runtime itself derived, not a path that merely ends the same way: both the event's path and the runtime's are resolved against the project root before they are compared, so /repo/package.json and package.json are the same file while /home/another-project/package.json is refused. That path could not repair the failure anyway, and a wedge in one checkout has no business handing out a write in another. Node walks the package.json chain upward, so a monorepo package's own package.json is on the list; a sibling directory's is not.

Why no command is allowed, including git. git merge --abort, git rebase --abort and git checkout -- <path> used to be, on the reasoning that they only move the tree to states git already holds and execute no repo code. Measured against git 2.43.0 with hooks installed in .git/hooks/, that is false for all three: git checkout -- <path> runs post-checkout, and both aborts run reference-transaction (it fires on any ref update, which each of them performs). .git/hooks/* is writable by exactly the actor this door assumes, so the whitelist was an arbitrary-execution path standing open precisely while the gate enforced nothing — the same mistake as the vigiles compile escape, one layer down: a command believed inert because of what it means rather than what it does. git -c core.hooksPath=<empty> does suppress them (measured), but it puts free-form structure back into the accepted string and buys nothing, because a file write already un-wedges the repo.

Why vigiles compile is not in that list. It used to be. Two things settled it, both measured rather than argued. During a load wedge vigiles compile exits 1 (Cannot load hook …: Invalid package config) — it loads the hook through the very resolver that just failed, so it could never repair that state. And it was the only escape that ran code: over five review rounds it was found to admit a payload through composition (curl … | sh && vigiles compile), through its operand (vigiles compile /tmp/payload.spec.ts, which the CLI dynamically imports), through its executable path (/tmp/vigiles compile), and through the working directory (cd /tmp/evil && vigiles compile). Recognising a trusted action from an untrusted string has unbounded degrees of freedom — argv, cwd, PATH, node_modules resolution, config discovery — so the primitive was removed rather than constrained a fifth time.

Everything else still fails closed. This doesn't soften the tamper guarantee that matters: while the stamp is stale the hook is enforcing nothing (it refuses every call), and anyone able to rewrite a hook's source can equally rewrite .claude/settings.json. What the stamp buys is that a smuggled capability can never run silently — unchanged.

Testing a compiled hook

Because the decision is a pure function, you test it deterministically — no model, and (cheapest) no subprocess. Three levels, by cost:

1. In-process (cheapest). Load the hook FILE with loadHook(path) and pass it plus a raw event to the assertions — no node spawn, no CLI, milliseconds:

import { it } from "vitest";
import { loadHook, assertHookDenies, assertHookAllows } from "vigiles";

const guard = await loadHook(".vigiles/hooks/guard.mjs");

it("blocks a force-push, even hidden in a compound command", () => {
  assertHookDenies(guard, {
    tool_name: "Bash",
    tool_input: { command: "cd x && git push -f" },
  });
  assertHookAllows(guard, {
    tool_name: "Bash",
    tool_input: { command: "git status" },
  });
});

loadHook is the same loader the runtime uses (so a hook that loads in a test loads identically in production) and it is what makes this tier reachable from a .harness.mjs file, which has a hook's PATH and not its object. A TypeScript hook (guard.hook.ts) loads the same way under tsx / Node ≥ 23.6. If you already have the object — a static import guard from "./guard.mjs" — pass it directly.

React hooks get their own pair. A react can't block, so the gate assertions don't apply; use assertHookNotices(hook, event, matcher?) and assertHookSilent(hook, event):

import { assertHookNotices, assertHookSilent } from "vigiles";

const warn = await loadHook(".vigiles/hooks/warn-on-failure.mjs");
const after = (isError) => ({
  tool_name: "Bash",
  tool_input: { command: "npm test" },
  tool_response: isError ? { error: "boom" } : { stdout: "ok" },
});

assertHookNotices(warn, after(true), /read the error/);
assertHookSilent(warn, after(false));

Where a notice arrives

A notice(msg) is delivered as hookSpecificOutput.additionalContext on stdout — the same mechanism vigiles's own shipped nudges use — so the agent reads it. It is still not a block: a react has no deny, and it always exits 0.

Delivery is per event and per harness, and vigiles will not pretend otherwise:

event the harness injects (e.g. PostToolUse) the notice is delivered as additionalContext
event it does not (e.g. Stop) nothing is emitted on stdout, and vigiles compile warns at install time, naming the events that would work

The event set is the adapter's injectableEvents, not a hard-coded list, so a Codex hook is gated on Codex's answer.

🔴 This was a real defect, found by dogfooding. Until 2026-09-07 a notice went to stderr and nowhere else. Per Claude Code's hook documentation, stderr from a hook that exits 0 goes to the debug log only — "Claude never sees it" — and a react always exits 0. So a notice reached nobody: not the model, not the user, not the transcript. The docs stated the mechanic ("writes to stderr") and never the consequence, which is exactly the false-confidence shape compiled hooks exist to remove. It was caught when this repo wired up its own first compiled hook.

⚠️ Don't test a react hook by reading a stream. A notice(…) goes to stderr (the debug log) and — on an event this harness injects — to stdout as hookSpecificOutput.additionalContext. So a stream probe gets a different answer depending on the event, and a probe built on execFileSync once reported a perfectly healthy react hook as dead. These assertions read the reaction itself, so no stream enters into it.

Runnable end to end: examples/harness/compiled-hook-inprocess.harness.mjs (with its two hook files) is exactly this pattern, and runs in CI.

runHookProgram(hook, event) is the underlying primitive — it returns a normalized outcome ({ kind: "decision" | "injection" | "reaction", … }) dispatched by role, so an inject or react hook is just as testable as a gate.

2. Through the real runtime. runHook("node … hook-runtime run-program guard.mjs", event) drives the actual compiled CLI (stamp check + dispatch) — proves the wired artifact behaves, still no model. Pair it with the disaster battery: assertBlocksDisasters("node … hook-runtime run-program guard.mjs") proves the gate blocks every textbook disaster.

3. Does it fire in the assembled harness? runHarnessTest (a scripted mock model emits the tool call; assert the hook blocked) — the delivery question, key-free, and capped by how the harness delivers events to hooks.

See Testing your harness for the tiers in full.

Proof: the OSS dogfood

We pointed the DISASTER_CATALOG battery (force-push, compound force-push, reset --hard, rm -rf, --no-verify, private-SSH-key read, curl | sh) at the widely-copied disler/claude-code-hooks-mastery safety hook: it blocks 2 of 7, silently missing the other five. The compiled equivalent (examples/harness/safe-bash-guard.mjs) blocks 7 of 7 — same intent, no blind spots, no protocol bug. The contrast is a runnable, model-free regression test (src/hook-dogfood.test.ts).

One command, many spellings — experimental_alternateSpellings()

experimental_alternateSpellings(events) takes a list of hook test cases (shell commands wrapped as PreToolUse events — the battery above) and returns more test cases: the same commands re-spelled every way the shell runs identically. It is a test-input generator for a safety hook. It does not check anything and it does not touch your hook.

What breaks without it. Your guard's rule says "block a command containing --force". The seven battery commands are each written one way, so the battery is green. Someone types git push "--force" origin main. The shell strips the quotes and force-pushes; your guard sees a different string and allows it. Nothing fails, so nothing tells you.

That is not hypothetical. Before this function existed, we tried 30 hand-written re-spellings of the seven battery commands against the guard behind the "7 of 7" above. It blocked 8 of 30. Three that got through:

git push "--force" origin main           # the flag in quotes
sudo git push --force origin main        # a wrapper in front
/usr/bin/git push --force origin main    # the full path to git

None of these is an evasion trick. Quoting a flag is ordinary typing. The "7 of 7" was true, and it had been measured on the only seven spellings anyone had written down. (8 of 30 is the before number — the shipped guard now blocks every generated spelling, see below.)

What it generates. For each command, the rewrites the shell reads identically:

  • the flag in quotes: "--force", '--force'
  • the short and long form of a flag: -f / --force, -n / --no-verify
  • the full or backslash-escaped command path: /usr/bin/git, /bin/git, \git
  • a pass-through wrapper in front: sudo, env, command, nice -n 5, timeout 30
  • a per-command environment assignment in front: FOO=1 git push --force (the shell strips the assignment and runs the rest)
  • a quote pair inside or around the command name: g""it, "git", gi"t"
  • ANSI-C quoting of the name: $'git'
  • a backslash before an ordinary letter: g\it (the shell drops it — sh -c 'g\it --version' prints git's version)
  • runs of spaces, or tabs, between words: git push --force

Seven commands become 136 (today's count; it grows as rewrite rules are added). The last four are the shell's own obfuscations: sh removes them before the command runs, so a guard that matches the source text (a grep, a substring) sees a different string while the shell sees the same command. Against a guard that greps for each of the seven command strings, most of them get through. Which ones depends on how that guard matches, and the difference is sharp: an ANCHORED matcher (case "$cmd" in "git push --force"*, a startsWith) blocks 0 of the 14 environment-assignment spellings, because the prefix moves the start of the string. An unanchored grep blocks 12 of those 14 and falls to the quoting ones instead.

It returns only the rewrites, never the originals — so spread both into the same checker:

import {
  DISASTER_CATALOG,
  experimental_alternateSpellings,
  assertBlocksDisasters,
} from "vigiles";

const guard = "node … hook-runtime run-program guard.mjs";
assertBlocksDisasters(guard, {
  events: [
    ...DISASTER_CATALOG,
    ...experimental_alternateSpellings(DISASTER_CATALOG),
  ],
});

Passing the generator alone would test the seven originals zero times. Each rewrite keeps the original's id with a suffix (force-push~4), so a failure names the spelling that got through.

On its first run the generated set found a miss the 30 hand-written cases had not: git commit -n -m 'skip hooks'. The guard asked for the literal --no-verify, and -n is git's short form of it. The matcher behind runs() was fixed to recognise both spellings, and the shipped guard now blocks all 136. src/hook-dogfood.test.ts keeps it that way in CI.

Nobody has to label the new cases. Two questions decide whether a rewrite belongs in the battery, and both are already answered:

Question Answered by
Is it dangerous? The original command a human put in the battery. The rewrite inherits that.
Is it the same command? The shell parser vigiles already uses to read commands (the one behind runs() and touches()).

If a rewrite fails the second check, the generator throws instead of quietly dropping it. A silent drop would shrink the battery while it still looked like it ran.

If you know promptfoo — feature parity. Each rewrite rule here is what promptfoo calls a strategy: a transform that turns test cases into more test cases, applied on top of the base set. The table maps the two.

promptfoo vigiles Note
basic (include the un-transformed cases) the [...DISASTER_CATALOG, ...] spread Same question, answered explicitly at the call site.
base64, rot13, hex, leetspeak, homoglyph, morse, piglatin, … quoted head, ANSI-C quoting, escaped letter, whitespace Same axis — an encoding the target decodes. A model may decode base64; a shell does not, so those are not emitted (they would blame a correct guard). The shell decodes quotes, $'…' and backslashes, so those are.
jailbreak, crescendo, goat, … (a model rewrites the attack) none Deliberately absent. It costs money and is non-deterministic; every spelling here is produced and checked with no model.
retry (re-run previously failed cases) none The battery is deterministic; a failed case fails the same way every run.
— the equivalence check (a rewrite that is not the same command throws) promptfoo strategies are not required to preserve meaning. Here every emitted spelling is one the shell provably runs identically, so a miss is always a guard bug, never an ambiguous input.

What it cannot find. The generator decides "same command" with the same shell parser the compiled guard's runs() uses. That makes every spelling it emits trustworthy, and it sets the limit: it can only produce spellings that parser already understands. It tests whether your guard matches the operation rather than one way of typing it; it cannot find a blind spot in the parser itself, because it would share it. g\it is the example: a reader tried it, the shell ran git, the parser read g\it, and the guard behind "7 of 7" let it through — while the 73-case battery of the day could not have produced it. The parser was fixed, the spelling joined the battery, and the shipped guard now blocks all 136. A spelling the parser does not understand still has to come from a person.

What it deliberately does not generate. eval "$(…)", sh -c "…", a command name held in a variable ($CMD push --force), a command decoded from base64. The parser cannot tell what those will run, so they are never emitted. That is on purpose: a guard built on runs() genuinely cannot see through eval "$(echo … | base64 -d)" either. Emitting such cases would mark a correct guard as broken, and a check that flags correct guards gets switched off.

⚠️ Also check that ordinary commands still pass. This measures blocking only, so a guard that denies everything scores 100%. Run a few everyday commands (git push origin main, git commit -m fix, cat README.md) through verifyGuardrail and assert none is blocked. The dogfood test does both halves.

Beyond the headline number, the structural wins over hand-written guards are each isolated in their own CI test (src/hook-oss-comparison.test.ts), so the headline is not the only evidence:

  • Evasion — the AST catches the compound cd … && git push -f a substring/glob misses.
  • Precision — no grep false-positive on a benign echo.
  • Protocol — a mis-wired exit 1 is false confidence; the compiled exit code can't be wrong.

The honest other side: stateful guards, broad I/O, and delivery are NOT compiled-hook wins. (The delivery floor is not what it was — see Limitations for what moved and what did not.)

Limitations & trade-offs (the cons)

Compiled hooks are neither free nor magic. The honest downsides:

  • ❌ Delivery floor — a gate is a strong default, not an unbypassable wall. Compiling fixes a hook's authoring and logic, not how the harness delivers events to it.

    What changed (2026-08-24). Claude Code's #34692 — a PreToolUse hook not firing for a subagent's tool calls — was closed not-planned and quoted here for months as a standing limit. It is fixed: measured against a stock @anthropic-ai/claude-code@2.1.241 from the registry, a subagent's own Bash reaches the hook and an exit-2 deny stops it (the parent's identical command in the same run is the control). The event even carries agent_type, naming which subagent made the call. src/subagent-delivery.test.ts pins this in both directions, so a regression goes red instead of quietly returning to the old behaviour.

    What did NOT change. A model can still route around a tool entirely (#45427 / #32376 — a Bash heredoc instead of Write), and a compiled hook removes the bugs that are yours, not the harness's. The most robust claim remains about logic, not live enforcement, and guardrail verification is still the companion that survives any delivery gap. Never call a gate "unbypassable."

    Scope of the measurement, stated plainly: it drives claude -p (headless). Interactive sessions are unmeasured, and subagent nesting (depth 2) does not occur there at all.

  • ⚠️ Runtime cost. Every matching event spawns node and dynamic-imports your program. Measured on Node 22 (median of 20 spawns, one machine — yours will differ): about 80 ms for a file gate, an inject or a stop gate, and about 200 ms for a Bash gate, which additionally parses the command. Roughly 40 ms of either is Node itself starting. Fine for a PreToolUse gate. Think twice before a hot-path PostToolUse react that fires on every edit. Claude Code's native if: prefilter is not the way out of this, and Why the emitted block has a matcher and no if: says why: a prefilter narrower than your predicate silences the guard without a word.

  • ⚠️ Buy-in. It's a dependency plus a build step, and you author in JS/TS, not a 3-line inline bash hook. For a trivial one-liner the compiled path is heavier — the payoff is on the guards that actually have to be correct.

  • ⚠️ A bounded vocabulary is a ceiling, by design — but be precise about which bound. (1) What a hook can do: checkHookImports forbids any import but vigiles/hook (no fs/net/child_process), so a hook that must call a service, read a file, or hold cross-invocation state to decide can't be expressed. That is the deliberate ceiling — it is the safety guarantee, and such hooks stay hand-written (keep a plain shell hook and verify it with the disaster battery). (2) What a hook can see: the AST matchers (runs/touches/pipesToShell/under) are a soft, extensible limit, not a fundamental one — if you need to match a shape they don't expose yet, the fix is a new matcher, not a redesign.

  • ⚠️ Compiling proves the protocol, not your policy. A compiled hook can't have the wrong exit code — but it can still deny the wrong thing. Compiling is necessary, not sufficient. Test the logic with guardrail verification.

  • ℹ️ Codex inject/ask output is unconfirmed. The gate (deny → exit 2) path is cross-harness today. An inject/react hook's output shape is Claude-Code-confirmed only, so compile --harness=codex warns loudly on those rather than ship a maybe-no-op (see Compile and wire).

Status / pending

The vocabulary's six entry points carry the experimental_ prefix. This section says what that prefix is claiming and what would retire it — the roadmap for the feature lives with the feature, not on a separate stability page.

They are experimental_defineHook, experimental_defineFileGate, experimental_definePromptGate, experimental_defineStopGate, experimental_defineInject and experimental_defineReact — everything this page has been about.

Only the entry points carry the prefix, and that placement is the whole point: every other name in vigiles/hook — allow, deny, tools, pathView, commandView, state, record, notice, run — is reachable ONLY from inside a define* call. Prefixing the chokepoint makes the marking structural for the whole vocabulary; prefixing thirty names could not, because nothing would stop the thirty-first from shipping unmarked. Same reasoning as experimental_skill.input() (see skills.md), applied to a larger surface.

Why it is not settled, stated as gaps rather than as a disclaimer:

  • The vocabulary grew a whole new axis in one release. Runtime-owned named state (record/state) landed 2026-08-12 to close a measured hole — seven advisory hooks in the dogfood repo were still hand-written shell for one uniform reason, every one of them both read and wrote a stamp file, and throttling was inexpressible. An API that gained a dimension that recently has not been pressure-tested by anyone but its author.

  • Consumers are still few, and all of them belong to the author. A vocabulary nobody else has had to live with is a vocabulary whose sharp edges are unknown.

    What this bullet said until 2026-09-07 — "neither is this repository's own harness, which still wires its hooks as plain shell" — is no longer true. .vigiles/hooks/test-tier-nudge.hook.mjs is this repo's own first compiled hook: a react that reminds a contributor which test tier a file they just edited belongs to, throttled with record/state so it speaks at most hourly and again whenever the tier changes. It was written to use the least-used corner of the vocabulary on purpose. Its tests are src/test-tier-nudge.test.ts (pure classification in-process, then both throttle directions against the real runtime via experimental_hookState).

What would have to be true to drop the prefix: a supported way to seed and read named state in a test (closed — see below), and at least one consumer who did not write the API.

The state-seeding item is closed as of 2026-09-07. It read: "Testing a hook that uses named state is archaeology today. The runtime derives the store's path from the hook's own location and validates the key charset, so a test that wants to seed 'this fact was recorded four days ago' must reconstruct a private path. The dogfood repo does exactly that, hard-coded, and it broke when the facts were renamed." experimental_hookState on the vigiles test surface is that API — Testing a hook that remembers. It is a thin handle over the store functions the live runtime calls, not a second copy of the path rule, and both directions are pinned end to end against the real runtime: a gate that FIRES on a four-day-old seeded fact and STAYS QUIET on a ten-minute-old one, plus the reverse direction — a fact the RUNTIME recorded, read back through the handle.

What that leaves is the second half: consumers. One consumer who did not write the API, living with a stateful hook long enough to find its edges. vigiles's own harness now counts as a stateful consumer (above), which closes the narrower "the product does not use the feature it ships" version of this — but the author grading his own API is not the evidence the prefix is waiting on.

An earlier version of this list also demanded an idempotent compile. That was already false when it was written: the merge is keyed by the hook's canonicalized path (mergeHooksJson, src/hook-install.ts), so recompiling replaces the entry in place — across five spellings of the same path, and beside a hand-wired block that used $CLAUDE_PROJECT_DIR and quotes. Fixed in #168 and pinned by two regression tests in src/hook-install.test.ts; this page kept saying otherwise for two weeks. Re-measured 2026-09-07: five consecutive compiles, byte-identical settings.json, one entry.

Stable alternative: a hand-written shell hook wired in settings.json. The events and the protocol are the harness's, not ours — nothing about them is experimental. What you give up is the typed vocabulary and everything it makes unrepresentable.

See also