How it is built · 3

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.

The machine directory: workflow.yaml, site-step.mjs, spec and ledger, the 23 predicates, the evidence writers, patterns, budgets, the hooks and the seven actor skills.
The machine: code only. It writes nothing into itself at run time.
The site directory: brief.md, site-context.json, run-state.json and the ledger, the source and build, the testimony files, and the state folder with receipt, reports, jury screenshots and reverify reports.
The site: its own private repo, carrying its own run state and every record about it.
The operator's state directory with the attestation and the active-run pointer, and the outside services: Chrome, GitHub, Cloudflare Pages, Claude Design, Modern Web Guidance.
The operator's state, and the outside services the machine talks to but never owns.
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.

Who writes

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.

Who never writes a verdict

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:

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 atPredicates
Records at the site rootbrief-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 Chromeaxe-routes, a11y, console-clean, bfcache, flows-replay, legal
The dependency treedeps, supply-chain
The live originlive, 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.

PlaceHoldsWhy there
The site's rootrun-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 directoryactive-run.json, operator-attestation.jsonBelongs 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 pluginNothingAn 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:

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?