Architecture
One toolchain, three places for state, and a strict split between who writes and who judges. This page names every part; the next one goes deep on the hooks and the ledger.
The shape in one picture
The machine is Node throughout, so the predicates import the same libraries the site build uses. There is no daemon, no database, no network service of its own. Everything is a file on disk plus a command that reads it.
State lives in three places and never mixes. The three diagrams below name every file in each; the tree after them is the same machine directory as a listing. The complete picture, with the gate cycle and the pipeline, is architecture.svg.
site-machine/ the plugin — code only
├── workflow.yaml the 15 steps as a DAG (source of truth)
├── tools/
│ ├── site-step.mjs the gate: status · next · start · done · block · sign
│ ├── spec.mjs reads workflow.yaml, evaluates predicates, owns halts
│ ├── ledger.mjs append/read run-log.jsonl (stdlib only)
│ ├── site-log.mjs PostToolUse hook → writes the ledger
│ ├── site-guard.mjs PreToolUse + SessionStart hook → denies deploys in a halt
│ ├── doctor.mjs · attest.mjs preflight and the yearly operator attestation
│ ├── site-context.mjs sole writer of site-context.json (declarations)
│ ├── mwg-install.mjs vendors + pins Modern Web Guidance into a site
│ ├── measure.mjs · jury.mjs authors of steps 12 and 11
│ ├── publish.mjs · reverify.mjs live-header probe; monthly drift check
│ ├── chrome.mjs · routes.mjs headless Chrome over CDP; serves dist/ honouring _headers
│ ├── predicates/ 23 yes/no programs, one per concern
│ └── tests/ 63 test files; npm run verify
├── scripts/verify-receipt.mjs the only writer of a receipt
├── hooks/hooks.json the three hook registrations
├── patterns/ 656 UX checks in 22 files
├── budgets.json the quality bar
├── .claude/skills/ the seven actor skills
├── .agents/skills/modern-web-guidance/ vendored, Apache-2.0, never hand-edited
└── .claude-plugin/plugin.json declares skills — and deliberately NOT hooks
Writers and judges
The governing rule: the model decides as little as possible. Ordering, verification, verdicts and assembly are code. The model writes content and implementation, and it never certifies its own work.
Skills and the agent: brief.md, tokens.css, the Astro pages, ux-review.json, the flow scripts, the ballots. Tools that produce evidence: measure.mjs, jury.mjs, verify-receipt.mjs, publish.mjs probe.
The model. It cannot mark a step done, cannot write a receipt, cannot record a jury median. Every one of those is computed by code from evidence on disk, and the evidence is checked for freshness, shape and provenance.
The seven actor skills are prose. Tests pin that prose to the code: a skill that names a tool by the wrong path, or references ${MACHINE} instead of ${CLAUDE_PLUGIN_ROOT}, fails npm run verify.
The gate: site-step.mjs and spec.mjs
site-step.mjs is a thin command. All the logic is in spec.mjs, which:
- loads
workflow.yamland refuses at load time if a predicate kind is unknown or the DAG has a cycle; - reads and writes
run-state.json, and computes which steps are open (all prerequisitesdoneorblocked); - evaluates the eight predicate kinds —
tool,file,json_field,ledger,http,measurement,jury,signoff; - raises and clears halts, and validates blockers against their closed vocabulary.
A tool predicate is a command run in the site directory with a ten-minute timeout; exit 0 is yes. That is why any predicate can be dry-run by hand: node tools/predicates/contrast.mjs <site-dir> prints exactly what the gate would see and writes no state.
The predicates
Twenty-three files, about 12,000 lines, each answering one question and printing what it saw. They share two helpers: chrome.mjs drives a headless Chrome over the DevTools protocol with no dependency, and dom-reach.mjs harvests the DOM across open shadow roots and same-origin frames. Closed shadow roots are read only through an instrument installed before the page loads; without it they are refused, not guessed.
Grouped by what they look at:
| Looks at | Predicates |
|---|---|
| Records at the site root | brief-complete, repo-private, design-project, ux-review, assets |
The built files in dist/ | markup, seo --build, forms, fonts, content, security, contrast |
| The site running in Chrome | axe-routes, a11y, console-clean, bfcache, flows-replay, legal |
| The dependency tree | deps, supply-chain |
| The live origin | live, seo, dns |
Two wording rules run through all of them. A predicate reports what it verified — "all 19 defences in place — declared, not proven live", "required elements present" — and never "secure" or "compliant". And a check that cannot decide refuses rather than passes: an unreadable closed shadow root, an unconvertible colour, a Lighthouse run from the wrong version.
Where state lives
Three places, chosen so that nothing about a run depends on which laptop it ran on and nothing is lost when the plugin updates.
| Place | Holds | Why there |
|---|---|---|
| The site's root | run-state.json, run-log.jsonl, site-context.json, preflight.json, ux-review.json, skills-lock.json, brief.md, flows/ | Travels with the repo. Two sites with the same name cannot collide. |
<site>/.claude/state/ | receipt.json, reports/ (raw Lighthouse JSON + HTML), reverify/ | Same repo, but deliberately not watched by the freshness rule, so writing a receipt does not make the site look edited. |
| The operator's directory | active-run.json, operator-attestation.json | Belongs to a person, not a site: $SITE_MACHINE_STATE, else $XDG_STATE_HOME/site-machine/, else ~/.local/state/site-machine/. Paths must be absolute. |
| The plugin | Nothing | An install is a cache replaced on update. A test copies the tree and asserts a first run writes nothing under it. |
Roots and placeholders (for contributors)
Skip this section unless you change the machine's own code.
Two roots, both configuration rather than convention:
- The plugin root. Hooks get it as
${CLAUDE_PLUGIN_ROOT}, substituted by Claude Code. Tools callmachineRoot():SITE_MACHINE_ROOTif set, else the directory abovetools/. Insideworkflow.yamlthe same thing is spelled${MACHINE}, expanded byspec.mjs— it is not an environment variable, and skills must not use it. - The sites root. Has no default. Only
reverify.mjsneeds it, via--sites-rootorSITE_SITES_ROOT. Every gate command takes an explicit site directory instead.
One subtle piece: tools/entry.mjs. On macOS every plugin install lives under /private/var behind a symlink, and a naive "am I the main module?" check compared the wrong paths and silently exited 0 — the tool ran nothing and reported success. entry.mjs compares real paths, and it is the only file allowed to look at process.argv[1].
Pins
"This passed" means nothing if the checker can change underneath it. package.json carries a pins block: Lighthouse 12.2.1, axe-core 4.10.2, html-validate 11.9.0, retire 5.7.0, cspell 10.1.0, Astro 7.2.0, Modern Web Guidance 2026_05_16-c5e78707, the pattern catalogue at 656 items. A test refuses any tool fetched unpinned; measure.mjs refuses a Lighthouse report whose version is not the pin.
Patterns and budgets
patterns/ holds 656 checks in 22 JSON files, mirrored by hand from the site-audit project and attributed in PROVENANCE.md. Two files are mandatory and frozen: dark-patterns.json (16) and legal-patterns.json (15). Their 31 ids are hashed into a fingerprint that every ux-review.json must carry — adding a pattern would invalidate every filed review, which is why additions are a decision, not a commit.
budgets.json is graded by verify-receipt.mjs: Lighthouse performance ≥ 0.90, accessibility 1.0, best practices ≥ 0.95, SEO 1.0; LCP ≤ 2500 ms, CLS ≤ 0.1, TBT ≤ 200 ms, Speed Index ≤ 3400 ms; ≤ 500 kB transferred, ≤ 150 kB of it script. A site may ship its own budgets.json, but it only affects step 12's measurement: measure.mjs reads it, and the receipt for publish-live is always graded against the plugin's file. An override can therefore tighten what you measure; it cannot lower what you ship.
Self-check: before reading on
Without looking back: which three places hold run state, and which one place is guaranteed to hold none? Why is <site>/.claude/state/ excluded from the freshness rule?