Skip to content

Docs site: the docs/ tree, compiled and hosted ​

The human-facing docs (docs/, organized by Diataxis) are published as a static site — searchable, dark/light, with mermaid diagrams rendered — without ever ceasing to be the markdown that renders on GitHub. There is one tree, one source of truth, two surfaces: a reader on github.com follows a relative .md link to the file, and a reader on the site follows the same link to a clean URL, because the compiler rewrites it.

The mechanical half of the reference pages is not written by hand at all: every command table comes from the command registry (command-registry.md) through a generator, and CI fails when the committed tables and the code disagree. That is what "docs change as the code changes" means here — not a reminder, a check.

The site is an assets-only Cloudflare Worker, deliberately not part of the bot's Worker: a docs typo must never require a container rollover (the deploy hazard in slack-channel.md items 7–8), so docs deploy from CI in seconds on every docs push, while the four runtime Workers deploy on the release — preflighted, in order, only the ones the release changed (release-and-deploy.md).

Hosts ​

HostWhatAuth
The host of project.json's docsThe compiled site (<name>-docs, assets only) — the project's website, deployed by the project's own CI; never a Worker an installation runsPublic: the project's site is world-readable on its product domain, and workers_dev is disabled. The retired hostname stays attached solely to provide DNS for its edge redirect.
<bot host>/docs…A 302 to the host above, subpath preserved — the same on every installationPublic: it discloses only the docs hostname

Behavior ​

  1. One tree, two surfaces. The site is built in place from docs/ (docs/.vitepress/), never from a copy. A relative in-tree link (reference/cli.md) resolves on GitHub to the file and on the site to /reference/cli. A relative link that leaves the tree (root README.md, AGENTS.md, a source file) resolves on GitHub to the file and is rewritten at build time to that file's page in the repository, because the site has no page for it; the lint (src/docs/links.test.ts) requires the target to exist, since the site's dead-link check never sees it. An exact registered frozen-record citation may instead resolve on the compiled site to the full historical commit stated in that record; decisions:check requires its source blob to exist. Other missing targets still fail. The frozen relative text on GitHub stays unchanged and may no longer resolve after source retirement. Plans under docs/plans/ are excluded from the build entirely (item 12), so the same lint holds their links: a relative link in a plan that resolves inside docs/ must name a file that exists — a renamed decision record is a defect in the tree, not in the frozen plan — while a plan's links that climb out of docs/ stay exempt, since code does move after a plan freezes. Absolute URLs are linted too: a malformed separator (https:///host) is invisible to a URL parser — for http(s) the WHATWG parser collapses the extra slashes, so such a link still reaches the right page — which leaves a literal check as the only thing that can hold the line; code spans are quoted text and are not linted. Inline code is literal on the site (v-pre), so a {{placeholder}} in a code span is text, never a Vue interpolation.
  2. README.md is the directory index on both surfaces. GitHub renders a directory's README.md when you browse to the directory; the compiler's rewrites map every README.md to that directory's index.html, so a bare [Tutorials](tutorials/) link works in both places. Each of the four Diataxis directories carries one, and it says what that kind of page is for; the specs directory beneath reference carries one too, two levels deep, and the rewrite covers both depths (check:site requires its built index — the dev server resolves a directory's README on its own, so only the built tree shows a missing rule).
  3. docs/plans/** is not published. Dated implementation plans are working documents for the repo, excluded by srcExclude; they stay readable on GitHub.
  4. A dead internal link fails the build (ignoreDeadLinks: false), so a renamed or deleted page cannot merge while links still point at it.
  5. Mermaid renders on both surfaces. A ```mermaid fence is a diagram on GitHub and a rendered diagram on the site: the plugin's fence rule turns it into a component, the theme's own renderer draws it (item 19), and the library is bundled — the page fetches nothing from a CDN at runtime.
  6. The mechanical reference tables are generated, and the markers say so. A region between <!-- generated:<name> · … --> and <!-- /generated:<name> --> is written by npm run docs:gen from the command registry and by nothing else. Today: cli-commands in reference/cli.md (every command in CLI form, with the surfaces it reaches), chat-commands in reference/slack-commands.md (the chat-reachable subset, with who may run each), api-routes in reference/dashboard-routes.md (/api/<group>.<verb>, methods from the effect, scope), capability-commands in how-to/turn-features-on-and-off.md (one row per capability axis with the commands whose enabledWhen depends on it, read off the registry through dependsOn — capabilities.md item 3; the prose matrix around it is hand-written), and catalogue in reference/specs/command-registry.md (every registered command with its derived form, action, the resource the policy table decides on, effect, blast radius and surfaces — the table the conformance suite checks against the registry, command-registry.md items 25 and 29).
  7. The generator writes only between the markers and is idempotent: regenerating identical content reports no change. A missing marker, an unclosed region, or a marker no renderer owns is a reported problem — never a silent no-op, which is the drift this mechanism exists to prevent.
  8. npm run docs:check is the drift gate. It re-renders every region and exits non-zero, naming the file, when the committed text differs — the same discipline skills:check applies to vendored skills. It needs no network, no config, and no deps beyond the registry, so it runs from a bare worktree.
  9. Table-safe rendering. describe texts are code, not prose written for a table: a | is escaped, newlines are flattened, and a bare <agent> is escaped to an entity — but never inside an inline code span, where an entity would render literally. (An unescaped <agent> is an invisible HTML tag on GitHub and an unknown component to the compiler's Vue pass.)
  10. /docs is the stable in-product path. GET /docs → 302 to the docs site's home; GET /docs/<path> → 302 to <path> on the docs site, so a deep link can be typed, bookmarked, or shared. The target is the project's published docs — PROJECT_DOCS_URL in src/core/docsLink.ts, held equal to project.json's docs by check:project-facts — on every installation, and never taken from the request or from the environment: there is no open redirect and no per-installation docs site to point at (the site is the project's, item 12). The path is not in the Access-gated set: the redirect is public, and so is the destination. The startup log states where /docs points.
  11. Every dashboard page links to the docs. The shared header (/runs, /runs/:id, /runs/scheduled, /residents, /residents/:owner/:name, /costs) carries a docs icon button that opens /docs in a new tab — the app knows the path, never the docs hostname. No capability gates it: the site is the project's and every installation has it (capabilities.md has no docs axis). It is not a fourth section in the site nav (it leaves the app), and below sm it folds into the hamburger menu as its own group above the sections.
  12. CI deploys the docs; nothing else does. On every PR and push, CI checks that generated tables match the registry (docs:check, part of verify:root). When site inputs change, it also proves the site builds with no dead links (verify -w docs), and the deploy itself would be accepted — the built tree carries every page the site must have (index, 404, the four Diataxis indexes, the specs index nested under reference, a reference page, the vocabulary page; check:site) and none it must not (plans/), and wrangler deploy --dry-run (verify -w deploy/cloudflare-docs) validates wrangler.jsonc and resolves assets.directory against that tree. The dry-run needs no credentials, uploads nothing, and touches no account; without it, nothing would read the Worker's config until the push to main, after review is over. The docs-deploy job runs only on main, only when the complete push range touched docs/, deploy/cloudflare-docs/ or project.json, and builds before uploading so a stale dist cannot ship. With no CLOUDFLARE_API_TOKEN repository secret it logs a warning and skips rather than failing the build; when the changed-file diff cannot be computed it deploys (fails open toward freshness). The site is the project's website, not a Worker of an installation: its script name is <name>-docs and its hostname the host of docs, both from project.json (siteView in src/deploy/wranglerTemplate.ts), so no deployment profile names a docs Worker (WORKER_KINDS has none, the profile schema has no workers.docs, deploy plan has no docs step) and an installation never deploys a copy of the site — its /docs sends people to the published one (item 10). The one value the profile contributes is the account: the job renders the site's wrangler.jsonc from the project's own production profile first (SWITCHBOARD_DEPLOY_PROFILE names it in the installation's private configuration repository, read with a read-only App token as CONFIG_REPO_TOKEN), so the upload can never carry the example account; CLOUDFLARE_ACCOUNT_ID is never set in CI — the account comes from the profile and the env var would override it. deploy init renders the site's config beside the Workers' from the same facts, so verify -w deploy/cloudflare-docs dry-runs the real name and host on every PR.

The retired openswitchboard.dev custom domain stays in that deployment config while Cloudflare's edge redirect uses its Worker-managed DNS record. A docs deployment must preserve both custom domains; Cloudflare refuses a standalone DNS placeholder on the retired hostname until the Worker binding is removed, which would leave the redirect without DNS during the handoff. 13. The docs Worker cannot disturb a run. It has no main, no Durable Object, and no container, so it has no rollout, no drain, and no preflight; deploying it while a run is in flight is a no-op for the bot. 14. AGENTS.md is an index under a budget, and its command table is generated. AGENTS.md is read by every agent on every task, so it is held under 16 KiB (agents:check, part of check:consistency) and its Commands table — every root package.json script with what it does and when to run it — is written by npm run agents:gen into a <!-- generated:commands --> region from package.json (which scripts exist) and project.json (the prose). A script with no { does, when } entry fails the check, so a script cannot ship undocumented; an entry for a script that no longer exists fails it too. The module-by-module map, operations runbook, sizing notes and known limits that used to sit in AGENTS.md are pages under docs/ that it links. 15. One statement of project facts. project.json names the project twice — name, the identifier (package names, the CLI, the bot's mention, config keys, Worker script names, the repository), and displayName, the name a reader sees (the README's first heading, the site's title and hero, the subject of the GitHub description) — plus its one-sentence description, its organization, repository, docs URL, npmPackage (the package it publishes, @<scope>/<name>), topics, contact address and steward. npm run check:project-facts (part of check:consistency) reads every hand-written copy — the community files, the README (its first # heading must be displayName) and its badge row (github.com paths, the Scorecard URL, the shields.io release badge), docs/README.md, this spec, the /docs redirect constant, the docs Worker's route — and fails naming each copy that disagrees (a stale contact address, a docs URL on another host, the repository under another owner, package.json's five identity fields, a package mentioned under the project's npm scope that is not npmPackage, the README headed with another name). npmPackage must be a scoped npm name whose unscoped part is name, so npm install <npmPackage> and the identifier agree. The description and topics have no copy in the tree: gh repo edit reads them from the file (Configure the repository), so the check holds them to what GitHub accepts — a non-blank description of at most 350 characters, 1–20 topics of [a-z0-9-]{1,50}. The docs site has no copy either: docs/.vitepress/config.ts reads displayName for the site title, and the landing hero reads it back as site.title, so the name is proven on the built artifact (check:site, item 18) rather than on a source copy. Changing a fact is one edit plus the list the check prints. In prose, the product is displayName; the bot's mention, the CLI and the package are name, in a code span — identifiers are never renamed for the display name. 16. Records are frozen by a check, not by discipline. A record — a decision record under docs/decisions/ or a dated plan under docs/plans/ — carries frontmatter status from the closed set proposed | accepted | implemented | superseded and a date; a superseded one names superseded_by, a path that resolves. npm run decisions:check (in check:consistency) fails the build when any of that is missing, and diffs every record that was already accepted, implemented or superseded at the merge-base of HEAD and origin/main — the commit the branch was cut from, never origin/main's tip, so a record accepted, amended or added on main after the cut is not the branch's edit or deletion (measured from the tip it read as one); DECISIONS_BASE=<ref> names another ref to merge-base against, e.g. the branch a stacked PR targets — its body and every frontmatter key but status / superseded_by must be byte-identical, the record must still exist (a deletion or rename fails the same way), and a status never moves backwards. The one body change allowed is growth by an appended, dated ## Amended <date> section carrying a re-evaluation (the documentation rule's post-acceptance amendment, isAppendedAmendment): everything above the first such heading stays byte-identical, and a section under any other heading, or one without a re-evaluation, is an edit. The remedy the message names is to write a new record and supersede the old one, or to append such an amendment. Without a reachable base (a shallow clone: the ref does not resolve, or shares no ancestor with HEAD) the immutability half is skipped and the reason said; CI's checkout fetches the full history for it. Decision-number uniqueness is judged separately against the current origin/main tip: a pull request may add at most one docs/decisions/NNNN-* file per number, and a newly added number must not already exist on main. Inside a coding child (SWITCHBOARD_RUN_ID present), SWITCHBOARD_DECISION_RECORD=NNNN from the runner requires exactly one added record under that number; an added record without a reservation or under another number fails too. CI and a human checkout still enforce the main/PR uniqueness rule without inventing a reservation. 17. The Design decisions index is generated. docs/explanation/design-decisions.md carries a decision-records region that npm run docs:gen renders from the records' own frontmatter — id, title, pattern, status (a superseded record links forward, whichever superseded_by form the gate accepted), date — so a new record or a status change reaches the site by regeneration, never by hand; docs:check guards it like the command tables. 18. The root route is the landing page. docs/README.md stays the tree's hub on GitHub; on the site the same route renders as a home page — the compiler marks the source file layout: home in transformPageData, so no frontmatter reaches GitHub, and the tab reads the site's name rather than the hub's heading. The site's name is project.json's displayName, read by config.ts at build time — the title, the home page's tab, the nav entry that names the product — never a copy typed into the theme (item 15). The landing (theme/LandingPage.vue) comes first, and nothing on it is a paragraph — every line of text is under twenty words: the eyebrow naming the product (an element of class product reading site.title, so it follows displayName too), a two-line headline in the text face set large, one sentence, two doors in (get started, the repository — project.json's repository, imported, not typed) and the install line (npx <npmPackage> init, read from project.json, with a copy button and no clipboard library); then the run page in a full-measure window frame; then a statement beside each of three further pictures (item 20: the runs index, the residents, the spend page — each in the theme's SHOTS list, cropped from the top to the ratio its content fills, right–left–right and stacked on a phone), the statement two-tone — its first clause in ink, its second in the faint grey — with the page that proves it; then a thread drawn in CSS from the fixture's made-up workspace (a mention of name followed by a plain sentence and no directive, a status card whose title carries the routed: line the product's card does, three ticked steps and one in progress, the reply with the pull request; the in-progress ring stops under prefers-reduced-motion: reduce), never a capture of a real conversation; the four seams as one row, each term with its implementations from theme/seams.mjs (item 21) and its page; and the three commands (init, ask, deploy all) with one closing line. Every picture carries its 2880×1800 dimensions so the layout never moves. The run page is one eager image (fetchpriority="high", so the preload scanner starts the largest paint from the HTML) inside a <picture> whose source picks the dark file under prefers-color-scheme: dark; a reader whose appearance differs from the OS scheme gets one swap after mount, and a toggle swaps again — everyone else fetches exactly one hero file. The three section pictures are lazy light/dark pairs, so a hidden appearance's copy is never fetched; the README's own content follows as the docs map, and the footer names the license and Discussions. The landing's links and image sources are component attributes the dead-link build never sees, so src/docs/links.test.ts resolves each internal path to a page in this tree, each fragment to a heading on it, and each image to a file under docs/public/. 19. One visual system, offline. theme/product.css is the whole look, and the look is one neutral system in two modes with no hue in the chrome: Tailwind's neutral greys at zero chroma — a #fafafa canvas in light, pure #000000 in dark with the text ladder inverted — structure drawn with 1px alpha hairlines and fills of 3–6% alpha rather than opaque panels, and an action colour that is the inverted neutral (near-black on light, white on dark), so VitePress's brand tokens and its brand button map to ink and no hue is in the chrome; colour appears only as data — the landing thread's ticked steps and running ring, a warning callout — in the system's muted saturation. Two faces, both bundled from the docs workspace's own @fontsource-variable dependencies: Instrument Sans for display and text alike (500 is the house weight; a heading is the same face set larger, letters tightened to -0.025em, leading closed; two-tone headlines put the claim in ink and the qualifier in the faint grey) and JetBrains Mono for the site name, the eyebrows and code (13px on 1.7 in a hairline frame at the 12px frame radius, Shiki's github-light / vesper pair). The two latin files above the fold are preloaded (PRELOADED_FONTS in config.ts names the built woff2 files), so the site builds and serves with no request to a third party for a font or a script. Prose is 17px on 1.85 in a 48rem measure, a link is strong text with a permanent faint underline, a table is hairlines only, the sidebar's current page is the house weight beside a 1px rail, and the landing's frames are a hairline at the frame radius on the canvas — no shadow, dimmed to 0.88 at rest; no image, texture or gradient is ever a background. The dashboard wears the same system — web/src/assets/main.css sets the same two faces from the same packages, the same canvases, hairlines, text ladder and inverted-neutral action colour (live-view.md, its Rendering paragraph) — so its pictures on the landing (item 20) sit in the frames as one product. Diagrams are part of the system: theme/MermaidDiagram.vue draws every fence with the --sb-diagram-* tokens read from the page at draw time and redraws on an appearance change, waits for the faces before measuring labels, and imports the library only on a page that has a diagram — the plugin's own renderer would have put it in every page's entry chunk, and knew only mermaid's built-in dark theme. The home page scores at least 95 in each of Lighthouse's four categories. One mark, three copies of one drawing: three isometric planes with one message entering the top plane and routed to two lanes on the middle one — docs/public/logo-light.svg / logo-dark.svg beside the site name (themeConfig.logo, one file per scheme because an <img> cannot inherit currentColor), docs/public/favicon.svg for the site's tab plus docs/public/favicon.ico — the same mark as three PNG frames (16, 32, 48) for every client that asks for /favicon.ico by convention without reading the page, both named in the site's head — the dashboard's BrandMark.vue (inline, ink from the text color, the top face from the page ground, the route in the dashboard's green) in every page header and its FAVICON_MARK_SVG for the tabs that claim no state, and the README's title lockup. 20. The dashboard's pictures are rendered, not taken, and their inputs are pinned. The screenshots the README and landing page (item 18) show — the runs index, a finished run's page, the residents, the spend page and its By user tab, and the scheduled tab, each in light and dark — come from npm run screenshots:gen: it builds the dashboard, serves the fixture preview (scripts/web-preview.ts) and the browser at one fixed clock, viewport (1440×900 at 2×), locale and timezone, with motion off, and writes docs/public/screenshots/<surface>-<theme>.png plus one manifest per surface, manifest/<surface>.json: the viewport and the clock the surface was rendered at, and the sha256 of every file that surface is rendered from — shared executable capture inputs (the fixture, the shell, the renderer, capture mechanics, viewport and clock, the app entry, the router, the stylesheets), that surface's resolved literal registration, and the import closure of its page component under web/src/. Another surface's literal registration does not change this manifest. gen skips a surface whose recorded inputs equal the tree's and whose pictures are present. When source inputs change, it builds and compares the actual resources and fixture responses from the last capture; matching rendered inputs refresh the manifest without browser capture (Screenshot capture). --force re-renders all, so unrelated pictures never move and two branches touching different pages never collide on one manifest. Two renders of one tree are byte-identical. The fixture carries only made-up names (acme/web, alice), so a picture can carry no real workspace. npm run screenshots:check (in check:consistency, and the same rule in the unit suite) needs no browser: it hashes the tree per surface and fails naming each surface whose inputs changed, appeared or vanished since it was rendered, a manifest rendered at another viewport or clock than the module now pins, each picture missing, and any stray picture or manifest — a dashboard change ships with its pictures or not at all. gen is not part of fix because it needs the pinned browser (npx playwright-core install chromium). The one hand-taken set is docs/public/slack/ — a real review run that routed itself, as the Slack thread pane shows it: the request as a plain sentence with no directive (mention.png), the card whose label carries the routed: line (status-card.png), the verdict (verdict.png), and run.gif, the pane scrolled from the request to the verdict at 2 fps — cropped to the pane at 395×640 with the requester's avatar and name masked and the reply composer cut off, so no workspace name, sidebar, member or channel name is in frame. The README shows the recording. Nothing hashes these — a Slack capture has no source to render from — and they are re-taken by hand when the request's shape, the card or the verdict format changes. 21. The diagrams share one visual system, and the shared ones have one source. Every diagram outside the records is a ```mermaid fence that carries structure and words only: a flowchart runs LR for a path across the system or TB for a stack, never TD, RL or BT; every node and edge label is quoted; the shapes say what a thing is (["…"] a component, {"…"} the dispatcher, [["…"]] a Worker, [("…")] where state lives, (["…"]) a system or person outside the tree, {{"…"}} a human gate); a label carries no HTML but <br/> and no #; and no style, classDef, linkStyle or %% line sets a colour — the theme's tokens (item 19) draw the site and GitHub draws its defaults. Two diagrams are drawn in more than one place, so each is a generated region rather than copies: four-seams (the root README.md, explanation/architecture.md) is rendered from the seams' one statement in theme/seams.mjs — the file the landing page's seam row reads too (item 18) — and deploy-order (explanation/worker-topology.md, how-to/ship-a-release.md) from DEPLOY_ORDER in src/deploy/plan.ts; docs:gen writes them and docs:check guards them like the tables (items 6–8). The unit suite holds the rules over every fence in the README and the docs tree and parses each with mermaid, since the site build compiles a page without drawing its diagrams. Records under docs/decisions/ and docs/plans/ are immutable (item 16) and exempt: a record's diagram stays as it was accepted. 22. Every page has a social card, drawn at build time. A link to any page unfurls — in Slack, on X, in a chat — to a 1200×630 picture of that page: the head of every built page carries og:type, og:title, og:description, og:url, og:image with its og:image:width / og:image:height, and the large-image Twitter card (twitter:card, twitter:title, twitter:description, twitter:image), every URL absolute under project.json's docs origin (item 15) — never a host typed into the theme — so an unfurler that reads the tags fetches the picture from where they say. The card is the page's: its title is the page's H1 (a code span unwrapped, cut at 90 characters with an ellipsis), its one muted line the page's description — the site's sentence unless the page's frontmatter has one — and its eyebrow the page's section (tutorials, how-to, reference, explanation; a spec reads spec); the home card uses the supplied docs/public/og-image.png artwork, and the not-found page shares it. The other pages use the following composition. It is the site's visual system (item 19) on the dark canvas: pure #000000, a 1px hairline frame inset 24px at the 16px frame radius, the mark (docs/public/logo-dark.svg, rasterized) and the product's name in the mono face along the top with the section as an uppercase, tracked mono eyebrow opposite, the title in the text face at the house weight set at 66 / 56 / 48px as it lengthens with the letters tightened to -0.025em, the muted line at 27px, and the docs host and the page's route in the mono face along the bottom — no hue but the mark's own, nothing behind the type. config.ts collects each page's card while writing its head (transformHead) and draws the whole set once the pages are rendered (buildEnd) into og/<route>.png under the build output, so the cards drawn are exactly the cards the pages name and page-specific cards are not committed. The supplied brand SVGs and their PNG/ICO exports live in docs/public/. The renderer lays the card out with satori and rasterizes it with resvg, both build-time devDependencies of the docs workspace pinned to exact versions, from the static 500 cuts of the site's two faces (@fontsource/instrument-sans, @fontsource/jetbrains-mono — the layout engine reads TrueType tables and cannot open the woff2 the variable packages ship); the pipeline is deterministic, so two builds of one tree write byte-identical PNGs, and it fetches nothing — the site still builds offline. check:site (item 12) proves it on the artifact: every required page names an og:image that is a PNG under <docs>/og/, and that PNG is in the tree. 23. The vocabulary's noun table is generated from typed rows. docs/reference/vocabulary.md's twelve-noun table is a vocabulary-nouns region that npm run docs:gen renders from VOCABULARY_ROWS in src/docs/vocabulary.ts — one row per noun in the record's schema order, each with its anchor, meaning, holds, belongs-to, carrying type and printing surfaces. Every "Carried by" cell names its type through a CarrierName key of an interface built from import type references, so renaming or deleting a carrying type fails the typecheck at the rows rather than leaving the page describing a type that is gone; a stale or hand-edited table fails docs:check naming the file, like the command tables (items 6–8). The prose around the table — the requester line, the collapse, the internal words — stays hand-written.

  1. Two paths for first-time readers. The public Get started tutorial is for someone running a new installation. It links immediately to Use an existing deployment for a teammate whose operator already runs one. The docs hub and Tutorials index name both paths, and the sidebar links to the existing-deployment tutorial. The latter reaches one request and one follow-up without local setup or deployment-specific names.

Validation criteria ​

CriterionEvidence
A reader can choose the existing-deployment path from Get started, the docs hub, the Tutorials index, and the sidebar; the tutorial reaches a request without installation steps (item 24)[agent] Build the docs, open these four entry points and follow each link to the existing-deployment tutorial; check its request and follow-up steps.
/docs and /docs/ redirect to the docs site's home[unit] src/core/docsLink.test.ts::docsRedirectTarget::sends /docs to the docs site's home
A docs deployment retains the retired hostname's custom domain, so its DNS record remains available to the edge redirect (item 12)[unit] src/deploy/wranglerTemplate.test.ts::siteView / renderSiteConfig::keeps the retired docs host attached so its edge redirect retains DNS after a docs deploy
A docs deep link keeps its subpath[unit] ::carries a deep link across unchanged, so a docs URL can be shared
No path outside /docs is claimed (/docsomething, /runs, /healthz)[unit] ::claims no path outside /docs — including one that merely starts with the word
The redirect cannot be steered off the configured origin[unit] ::cannot be steered off the configured origin
A generated region is replaced in place, every byte outside the markers untouched[unit] src/docs/regions.test.ts::replaceRegion::replaces only the region body and leaves every byte outside the markers alone
Regenerating identical content reports no change (idempotent)[unit] ::is idempotent — regenerating identical content reports no change
A region whose marker note is older is still found, and the note is rewritten[unit] ::still finds a region whose opening marker carries an older note, and rewrites the note
A missing region is refused, not appended; an unclosed region is refused[unit] ::refuses when the region is missing, rather than appending or silently doing nothing, ::refuses an unclosed region
Every region a page declares is listed, ignoring ordinary comments[unit] src/docs/regions.test.ts::declaredRegions::lists every generated region a page declares, in order, ::ignores ordinary HTML comments and closing markers
An enum positional prints its values (config set <me|channel>)[unit] src/docs/reference.test.ts::usageFor::keeps the argument's name when its schema is not an enum, and brackets optional flags
Pipes, newlines, and prose angle brackets are table-safe; code spans are left alone[unit] src/docs/reference.test.ts::cell::* (four cases)
The CLI table sections by group in registration order and names each command's surfaces[unit] src/docs/reference.test.ts::renderCliCommands::*
The chat table omits chat-opted-out commands and states who may run each[unit] src/docs/reference.test.ts::renderChatCommands::*
/api routes give a write POST only and a read either verb; non-HTTP commands are absent[unit] src/docs/reference.test.ts::renderApiRoutes::*
The capability matrix's command column has one row per axis in the contract's order, names a gated command in its axis's row in chat form, reads — for an axis that turns nothing on, and counts the always-on commands (item 6)[unit] src/docs/reference.test.ts::renderCapabilityCommands::*
On the real catalogue that column is exactly what dependsOn derives per axis — a list nobody typed[unit] src/docs/reference.test.ts::the real catalogue::the capability column lists, per axis, exactly the commands dependsOn derives from the registry — a list nobody typed
Every registered command reaches the CLI table — including groups a hand table had gone stale on (mcp, deploy restart)[unit] src/docs/reference.test.ts::the real catalogue::renders every registered command into the CLI table — including the groups a hand-written table had gone stale on
No rendered table row can break its column count[unit] ::emits no unescaped pipe inside a table row (each row must have the column count its header declares)
The catalogue table has one row per registered command with the resource the table decides on and the blast radius blastRadius(def) derives — read, exec, write, destructive — and on the real catalogue the exec-class writes are exactly credentials inspect, repo test and repo build (item 6; command-registry.md item 29)[unit] src/docs/reference.test.ts::renderCatalogue::*, ::the real catalogue::renders every registered command into the catalogue table…
The declared regions and the renderers cannot drift apart[unit] src/docs/reference.test.ts::GENERATED_REGIONS::matches the markers actually present in each docs page…, src/docs/vocabulary.test.ts::VOCABULARY_REGIONS::matches the markers actually present in the vocabulary page…
The vocabulary table renders one row per noun in schema order, and each row names its carrying type through an import-checked reference (item 23)[unit] src/docs/vocabulary.test.ts::renderVocabularyTable::renders one row per noun in schema order, ::a row names the carrying type it imports, ::states each type's file where the row gives one
The shell links to the server-owned /docs destination in a protected new tab on every installation[unit] web/src/components/AppNav.test.ts::Global shell navigation::uses the server-owned docs destination in a protected new tab on every installation
Records: a status outside the set, a missing status or date, a decision without a title, a superseded record without a resolving superseded_by (or one that supersedes itself), and a superseded_by on a non-superseded record are each named (item 16)[unit] src/docs/records.test.ts::statusProblems::*, ::parseFrontmatter::*
Records: an accepted / implemented / superseded record's body and frozen frontmatter cannot change against the base, and the record cannot vanish; status-only changes (accepted → implemented → superseded with a pointer) pass; a proposed record may change; a status never moves backwards; an appended dated ## Amended section carrying a re-evaluation passes, while one under another heading, one without a re-evaluation, or any change above it fails with a remedy naming both roads (item 16)[unit] src/docs/records.test.ts::immutabilityProblems::*
Records: the base copies are read at the merge-base of HEAD and the base ref, never the ref's tip — a branch behind a record that main accepted or amended since the branch was cut passes, the same tree measured from the tip would have failed, a branch that itself edits a frozen record still fails, and a ref that does not resolve or shares no ancestor with HEAD is unreachable by name (item 16)[unit] src/docs/records.test.ts::baseRecords::*
Decision numbers: a reserved child must add exactly one record under its runner reservation (none, another number, or multiple fail); any pull request fails when it adds two files for one number or a number current main already owns (item 16)[unit] src/core/decisionRecordReservation.test.ts::decisionRecordNumberProblems::*
Every record in this tree passes the status half[unit] src/docs/records.test.ts::the records in this tree::*
The decisions index: one row per record in id order linking the file, pattern / status / date from the frontmatter, a superseded record linking forward, table-safe titles; the directory README, plans and frontmatter-less files ignored (item 17)[unit] src/docs/decisions.test.ts::renderDecisionIndex::*
The phone navigation exposes docs and labelled appearance controls[unit] web/src/components/AppNav.test.ts::Global shell navigation::exposes the phone menu and appearance controls with accessible labels, web/src/components/AppNav.test.ts::Global shell navigation::uses the server-owned docs destination in a protected new tab on every installation
Every absolute URL in a docs page is scheme://host — https:///host is refused[unit] src/docs/links.test.ts::absolute URLs in docs/::every absolute URL is \scheme://host` — not `https:///host` (which browsers silently repair) and not a hostless one`
A relative link that leaves the tree names a current file or an exact registered frozen-record citation to a verified historical blob. The compiler preserves the recorded snapshot, living links stay on main, and other missing targets still fail[unit] src/docs/links.test.ts::absolute URLs in docs/::a relative link that leaves the tree names a current file or an exact verified historical citation — other missing sources fail
Frozen records retain their retired source citations through the compiler’s exact record/path mapping; decisions:check verifies each full commit and blob with the full-history checkout, while shallow test shards render the real records without fetching history[unit] src/docs/links.test.ts::historical source citations::renders retired citations from unchanged records at their stated source snapshots while living sources stay on main; ::refuses unregistered missing sources without lending another record’s historical citation; [agent] npm run decisions:check verifies registered historical source blobs
A relative link in a plan that resolves inside docs/ names a file that exists — plans are excluded from the site build, so its dead-link check never sees them (item 1)[unit] src/docs/links.test.ts::absolute URLs in docs/::a relative link in a plan that resolves inside docs/ names a file that exists…
Every internal path in a theme component or its data module (seams.mjs) names a page in this tree, and every fragment a heading on it (item 18)[unit] src/docs/links.test.ts::site links in the theme's components::every internal path names a page, and every fragment one of its headings
Every fence outside the records keeps the visual system — LR or TB, quoted node and edge labels, no style or %% line, no HTML but <br/>, no # — and each is named with its file, line and the rule it breaks (item 21)[unit] src/docs/diagrams.test.ts::diagramProblems::*, ::mermaidFences::*, ::the diagrams in this tree::every fence outside the records keeps the visual system
Every fence outside the records parses with mermaid (item 21)[unit] src/docs/diagrams.test.ts::the diagrams in this tree::every fence outside the records parses with mermaid
The four-seam diagram is rendered from the seams' one statement and the deploy order from DEPLOY_ORDER, both keeping the system they ask of every other fence, and every copy in the tree is the generator's output (item 21)[unit] src/docs/diagrams.test.ts::renderFourSeams::*, ::renderDeployOrder::*, ::the diagrams in this tree::the four seams and the deploy order are the generator's output wherever they are drawn, and each file declares exactly its regions
Every image a theme component draws — the landing's /screenshots/<name>-<theme>.png included — is a file under docs/public/ (items 18, 20)[unit] src/docs/links.test.ts::site links in the theme's components::every image a component draws is a file under docs/public
The screenshot inputs are the fixture, the shell, the renderer, shared capture and output mechanics, each surface's registration and page sources under web/src/; tests, generated declarations and builds are not (item 20)[unit] src/docs/screenshotManifest.test.ts::isScreenshotInput::*, ::listInputs::finds the dashboard's sources in this tree, repository-relative, tests left out
A surface's manifest hashes shared executable inputs, its own resolved literal registration and page import closure by content, in path order; another surface's literal registration leaves it unchanged (item 20)[unit] src/docs/screenshotManifest.test.ts::hashInputs::*, ::renderManifest::*, ::expectedFiles::*, ::isSharedInput::*, ::importSpecifiers::*, ::surfaceInputs::*, ::surface isolation::adding an isolated fixture and profile registration leaves existing hashes unchanged
The check names, per surface, a changed, new or removed input — shared executable pinning mechanics included — a manifest rendered at another viewport or clock, a missing picture or manifest, refuses a manifest of another shape, and names stray files (item 20)[unit] src/docs/screenshotManifest.test.ts::manifestProblems::* (six cases), ::strayFiles::*
The committed manifests equal this tree and every picture is present (item 20)[unit] src/docs/screenshotManifest.test.ts::the repository's screenshots::each surface's manifest inputs equal the tree's, and every picture is present; [agent] npm run screenshots:check → screenshots:check ok — N picture(s) current across M surface(s), exit 0. Red step: change one byte of a component under web/src/ and re-run — it must name the surfaces that render it and that file, and exit 1.
The pictures are deterministic and show no real workspace (item 20)[agent] npm run screenshots:gen twice; shasum docs/public/screenshots/*.png is identical across the two runs. Open each picture: the repositories are acme/…, the people alice and sam, every timestamp relative to the manifest's now; light and dark carry the same content.
The landing and README show the pictures without moving the layout (items 18, 20)[agent] after npm run docs:build, serve docs/.vitepress/dist and open / at 1440 wide in each appearance: the hero frame spans the measure and shows the run page in that appearance under its window bar; the runs index, the residents and the spend page each sit beside their statement, cropped from the top, right–left–right; with the appearance following the OS scheme the resource log shows one run-page-<theme>.png fetched, not both, and one file per section picture; no placeholder bars. On GitHub the README's <picture> shows the run page for the viewer's color scheme.
The root route is the landing page with the hub beneath it, titled with the site's name (item 18)[agent] after npm run docs:build, docs/.vitepress/dist/index.html contains class="VPHome", <title>Switchboard</title> and the hub's Pick your surface heading; docs/README.md carries no frontmatter.
The built home page reads displayName in its <title> and in the hero's product element; a missing page, another title, another hero name, or a hero without the element is named (items 15, 18)[unit] src/checkSite.test.ts::siteProblems::*; [agent] npm run docs:build && npm run check:site → check:site ok — 9 required page(s) present in docs/.vitepress/dist, home page titled "Switchboard", every page's social card under https://switchboard.space/og/, exit 0. Red step: change displayName in project.json without rebuilding and re-run — it must name the title and the hero and exit 1.
A page's card file follows the compiler's rewritten path (a README is its directory's index card) and the not-found page shares the home card; the eyebrow is the section, spec under the specs directory, none on the home card (item 22)[unit] src/docs/og.test.ts::ogFile::*, ::ogEyebrow::*
Every card URL is absolute under the docs origin in the site's clean-URL form, whatever the origin's trailing slash (item 22)[unit] src/docs/og.test.ts::ogPageUrl and ogImageUrl::build absolute URLs under the docs origin in the site's clean-URL form, whatever the origin's trailing slash
The card's text unwraps code spans, collapses whitespace and cuts at the cap with an ellipsis; the title steps 66 → 56 → 48px as it lengthens (item 22)[unit] src/docs/og.test.ts::ogText::*, ::ogTitleSize::*
A page's card says its own title and description under its section; the home and not-found cards say the product's name and sentence with no eyebrow (item 22)[unit] src/docs/og.test.ts::ogSubject::*
The head carries the eleven Open Graph and Twitter tags with the picture's true dimensions, every URL under the docs origin (item 22)[unit] src/docs/og.test.ts::ogHead::*
The drawn card sets the wordmark, eyebrow, title, line, host and route in reading order on a 1200×630 black box, cuts long text, sizes the title by length, keeps the eyebrow's box on the home card, and gives every multi-child box the flex display the layout engine requires (item 22)[unit] src/docs/og.test.ts::ogCard::*
check:site names a page with no og:image, one whose og:image is relative, on another host or not a PNG, and one whose card the tree lacks; a docs URL with a trailing slash is the same origin (items 12, 22)[unit] src/checkSite.test.ts::siteProblems::names a page that names no og:image, ::siteProblems::names an og:image that is not a PNG under the docs origin's og/ — a relative path, another host, another format, ::siteProblems::names a card a page points at that the tree does not carry, ::siteProblems::accepts a docs URL with a trailing slash as the same origin
Every page gets a card, nothing is committed, and two builds draw the same bytes (item 22)[agent] npm run docs:build → social cards: N drawn into …/docs/.vitepress/dist/og with N the number of pages built; find docs/.vitepress/dist/og -name '*.png' | wc -l equals N; test ! -d docs/public/og; shasum docs/.vitepress/dist/og/**/*.png > a; npm run docs:build; shasum docs/.vitepress/dist/og/**/*.png > b; cmp a b is silent. Red step: rm docs/.vitepress/dist/og/index.png && npm run check:site must print index.html: og:image names og/index.png, which is not in the built site and exit 1.
The cards read as one system with the site: open dist/og/index.png, dist/og/tutorials/get-started.png and dist/og/reference/cli.png — black canvas, hairline frame, the mark and the name top-left in the mono face, the section top-right in uppercase, the title large in the text face, one grey line, the host and the route along the bottom; the spec card cuts nothing of a 46-character title (item 22)[agent] visual, on the three files after a build; a Slack unfurl of a deployed page shows the card at 1200×630 (summary_large_image).
The README's first heading is displayName, and displayName itself is a non-blank string (item 15)[unit] src/projectFacts.test.ts::factsProblems::the display name::*
npmPackage is a scoped npm name whose unscoped part is name, and every package a checked file mentions under that scope is npmPackage (item 15)[unit] src/projectFacts.test.ts::factsProblems::the npm package::*
Reduced motion stops the thread's in-progress ring (item 18)[agent] on the home page with the browser's prefers-reduced-motion emulated as reduce: the thread card's ring under Writing the review does not turn; without the emulation it turns.
The home page scores ≥ 95 in every Lighthouse category (item 19)[agent] npm run docs:build && npx vitepress preview docs --port <port>, then npx lighthouse http://localhost:<port>/ --preset=desktop --only-categories=performance,accessibility,best-practices,seo --quiet --chrome-flags="--headless" → each of the four categories ≥ 95.
The site fetches nothing from a third party, and a page without a diagram never loads mermaid (item 19)[agent] after a build: grep -rl "fonts.googleapis|cdn\." docs/.vitepress/dist/*.html is empty; docs/.vitepress/dist/index.html preloads one instrument-sans-latin-wght-normal.*.woff2 and one jetbrains-mono-latin-wght-normal.*.woff2 from /assets/; the entry docs/.vitepress/dist/assets/app.*.js does not contain mermaid; the home page's network log shows only same-origin requests.
Diagrams wear the page's palette in both modes (item 19)[agent] open /explanation/worker-topology; in each appearance the rendered SVG's node fill equals the page's --sb-diagram-node token and toggling redraws it.
The committed reference tables match the registry, the shared diagrams and the vocabulary table their sources[agent] npm ci && npm run docs:check → docs:check ok — 11 file(s) match the command registry, the decision records, the diagram sources and the vocabulary rows, exit 0. Red step: edit a row inside a generated region — a vocabulary row, or a node label inside the README's four-seams region — and re-run: it must name that file and exit 1.
The site builds with no dead links, and docs/plans/** is not published[agent] npm --prefix docs ci && npm --prefix docs run build → build complete, exit 0; test ! -d docs/.vitepress/dist/plans. Red step: add [x](./nope.md) to any page and rebuild — the build must fail naming the dead link.
A PR proves the built site is complete: every required page present, plans/ absent[agent] after npm --prefix docs run build, the docs job's "the built site is complete" step exits 0. Red step: mv docs/.vitepress/dist/tutorials/index.html /tmp/ and re-run — it must print missing: tutorials/index.html and exit 1.
A PR proves the docs Worker's config would deploy, with no credentials[agent] npx wrangler deploy --dry-run in deploy/cloudflare-docs/ → Read <n> files from the assets directory …/docs/.vitepress/dist, exit 0. Red step: point assets.directory at a path that does not exist and re-run — it must fail with The directory specified by the "assets.directory" field … does not exist and exit 1.
Every Diataxis directory is browsable on both surfaces[agent] after a build, each of docs/.vitepress/dist/{tutorials,how-to,reference,explanation}/index.html exists; on GitHub, opening docs/tutorials/ renders its README.
A mermaid fence renders as a diagram on the deployed site[agent] open https://<docs host>/explanation/worker-topology and confirm an SVG diagram (not a code block) in both light and dark theme.
The deployed /docs redirect works and the docs host answers[agent] curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://<bot host>/docs/reference/cli → 302 https://<docs host>/reference/cli; curl -sS -o /dev/null -w '%{http_code}\n' https://<docs host>/ → 200 (the project's public site).
A push that touches the site's inputs — docs/, the docs Worker, project.json — deploys the site; one that does not skips the job, using the full push range[unit] src/ciPlan.test.ts::selective CI::runs the docs build, but no runtime or package build, for a docs page, src/ciWorkflow.test.ts::the required status checks and their gates::selects costly jobs from the change plan; [agent] on a docs-touching push to main, docs-deploy builds and deploys; on a code-only push it is skipped.
The docs host is never a destination of the bot's Access application (an application spanning two hostnames routes every login callback through one of them, so a retired docs host would break the dashboard's login); the site itself has no Access application — it is public (item 12)[agent] Zero Trust → Access → Applications: the bot's application lists only bot-host paths, and no application names the docs host.
A docs deploy cannot interrupt a live run[agent] start a long run, deploy the docs Worker (npm --prefix deploy/cloudflare-docs run deploy), confirm /healthz never reports draining and the run finishes normally.
The AGENTS.md command table renders every script in package.json order, escapes pipes, and a script without a description or a description without a script is a finding (item 14)[unit] src/agentsGen.test.ts::renderCommandsTable::renders one row per script in package.json order, escaping pipes, ::commandDocProblems::a script without a description, and a description without a script, are both problems, ::commandDocProblems::ignores $-prefixed keys and requires both fields
AGENTS.md stays under its budget, declares exactly the commands region, and its table is current (item 14)[unit] src/agentsGen.test.ts::budgetProblem::passes at the budget and names the overage past it, ::the repository's AGENTS.md::declares exactly the commands region, and its table is current, ::the repository's AGENTS.md::describes every root script and no phantom ones, ::the repository's AGENTS.md::stays under the budget
Every copy of a project fact agrees with project.json, and a stale contact, docs URL, route, or repository owner is named (item 15)[unit] src/projectFacts.test.ts::factsProblems::is silent when every copy agrees, ::factsProblems::names a stale contact address, docs URL, route pattern, and repository owner, ::factsProblems::leaves other repositories under the org alone, and checks package.json's five fields, ::the repository's own facts::every checked file exists and agrees with project.json
The README's badge row follows a repository move: a github.com path or a shields.io GitHub badge under another owner is named (item 15)[unit] src/projectFacts.test.ts::factsProblems::the README's badges::*
The description and topics are what GitHub accepts — a non-blank description of at most 350 characters, 1–20 topics of [a-z0-9-]{1,50} — and package.json's description is the same sentence (item 15)[unit] src/projectFacts.test.ts::factsProblems::the description and topics::*
Per-PR docs previews[gap] CI builds the site when a PR changes docs-site inputs, but publishes nothing; other PRs skip the build. A reviewer reads the markdown diff. A preview deployment per PR (a --name switchboard-docs-pr<N> Worker, or Cloudflare's preview URLs) is not built.
The sidebar and site use the patch-cable logo in both themes. The home Open Graph and Twitter card uses the supplied 1200×630 artwork; other page cards retain their titles with the new mark. The public assets include a 1280×640 GitHub preview, a 180×180 Apple touch icon, and a three-frame favicon.[agent] Run npm run build -w web, npm run screenshots:gen, and npm run docs:build. Inspect both sidebar themes, the site navigation, og/index.png, github-social-preview.png, and apple-touch-icon.png. Check the home HTML image tags and favicon frame sizes.