How it is built · 4

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

EventMatcherScriptDoes
PostToolUse*site-log.mjsAppends one JSON line per tool call to the run's run-log.jsonl.
PreToolUseBashsite-guard.mjsDenies a deploy command while the run is halted.
SessionStart—site-guard.mjsAnnounces 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:

  1. the directory the written file landed in — the nearest ancestor holding run-state.json;
  2. the current working directory, by the same rule;
  3. the active-run.json pointer that site-step start wrote, re-checked against disk.

Then it classifies the call and appends one line:

Tool callRecord typeFields
Write, Edit, MultiEdit, NotebookEdit, create_file, str_replacewritepath, firstWrite (did the file exist before?)
A Bash command containing modern-web-guidance and search, list or retrievemwg_searchquery, command
Anything elsetoolthe 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.

An empty ledger reads as unproven

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

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.