Hooks and the ledger
The hooks are the machine's only eyes on what the agent does between gate commands. Two small scripts, three registrations, one append-only file — and a proof that would be impossible without them.
Why they exist
Most predicates inspect a result: a file, a header, a rendered page. One thing cannot be inspected afterwards: whether the guidance was consulted before a component was written. The first house rule in the plugin's CLAUDE.md says Modern Web Guidance runs first for any HTML/CSS/JS work, because training data is full of obsolete patterns. A result check cannot tell a consulted component from a guessed one that happens to look fine.
So the machine records the order of events as they happen, and step build proves the claim from the record. The record is the ledger, and the hooks write it.
The three registrations
| Event | Matcher | Script | Does |
|---|---|---|---|
PostToolUse | * | site-log.mjs | Appends one JSON line per tool call to the run's run-log.jsonl. |
PreToolUse | Bash | site-guard.mjs | Denies a deploy command while the run is halted. |
SessionStart | — | site-guard.mjs | Announces open runs, their halt or their ready steps, and the status command. |
Both scripts exit 0 on any error, silently. A logging or guard failure must never block a tool call — the gate is where things fail closed, and the hooks fail open. Their only dependency is the Node standard library; ledger.mjs is separate from spec.mjs precisely so the hook never loads the YAML parser.
site-log: what gets recorded
Claude Code passes the tool call as JSON on stdin. The hook first decides which run the call belongs to, in order:
- the directory the written file landed in — the nearest ancestor holding
run-state.json; - the current working directory, by the same rule;
- the
active-run.jsonpointer thatsite-step startwrote, re-checked against disk.
Then it classifies the call and appends one line:
| Tool call | Record type | Fields |
|---|---|---|
| Write, Edit, MultiEdit, NotebookEdit, create_file, str_replace | write | path, firstWrite (did the file exist before?) |
A Bash command containing modern-web-guidance and search, list or retrieve | mwg_search | query, command |
| Anything else | tool | the tool name |
Paths that climb out of the site (../) are dropped. The mwg_search pattern is narrow on purpose: reading the guidance any other way — opening the file in an editor, say — is not a recorded search, and step build will not count it.
{"at":"2026-08-29T09:12:41.118Z","event":"tool_call","type":"mwg_search","query":"dialog modal","command":"npx -y modern-web-guidance@latest search \"dialog modal\" --skill-version 2026_05_16-c5e78707"}
{"at":"2026-08-29T09:13:05.402Z","event":"tool_call","type":"write","path":"src/components/Dialog.astro","firstWrite":true}
Two consecutive ledger lines that satisfy the claim for Dialog.astro: a search, then the first write.
The proof: mwg_precedes_component
Step build carries a ledger predicate with the claim mwg_precedes_component. It holds when, for every component file under src/ (.astro, .vue, .svelte, .jsx, .tsx), an mwg_search record precedes that file's first write record.
The original version had a hole. With no records at all, "no component was written without a search" was vacuously true — a pass on the absence of bad news. A session opened without the hooks produced an empty ledger and a clean pass. The predicate now also takes the components on disk: any component the ledger never saw written is a violation, and the message names the uninstalled hook as the likely cause.
This is the direction every check in the machine leans. Missing evidence is never a pass. It is inconvenient — components written in a hookless session must be written again — but the alternative is a gate that passes exactly when it is blind.
site-guard: the narrow deny
Before each Bash command, the guard looks for a deploy verb: wrangler … deploy|publish, netlify … deploy, vercel … deploy|--prod, gh-pages. If one matches and the run above the working directory (or the active run) has a halt, it answers with permissionDecision: "deny", quoting the halt reason and the command that would repair it.
Everything else passes. Build, test and edit keep working during a halt; only shipping is stopped. The list is deliberately short, and it is also the guard's limit: an npm run deploy wrapper is not seen. Decision 43 in docs/machine-spec.md (the numbered design record) records exactly such a bypass — the answer was to make the gate stronger, not the guard broader.
What the hooks need from you
- A session started after the install. Hooks load at session start. A change to
hooks/hooks.jsonmid-session does nothing until restart. - A consumer session, not the plugin repo. Opening
site-machine/itself as a project loads none of its plugin hooks. Only an install or--plugin-dirdoes. - A started run. Attribution needs
run-state.jsonabove the file, or an active-run pointer. Writes beforesite-step startgo nowhere. - The manifest left alone.
plugin.jsonmust not declarehooks; the auto-discovered file plus a declaration reads as a duplicate and the plugin refuses to load.
Why in the plugin at all: ${CLAUDE_PLUGIN_ROOT} is substituted only for plugin-provided hooks. When these three lived in a project's .claude/settings.json as absolute paths, they fired on exactly one laptop. Moving them into the plugin was the only portable option, and the test file hooks.test.mjs keeps them there.