carlos / platform Public

Merge pull request #296 from carlosframework/docs

docs: carlosframework.com/docs — the corpus, six gates, and the CLI reference
Paul Campbell pushed by paul@keymail.dev e9628b7d2d86516d345a15f0c46cb4afa556cd89 parent da9a7eeparent c154467
24 files changed, +3646 −0
  • AGENTS.md +8 −0
  • docs/site/anchors.json +15 −0
  • docs/site/cli.md +714 −0
  • docs/site/index.md +17 −0
  • docs/site/nav.json +14 −0
  • docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md +1615 −0
  • docs/superpowers/specs/2026-08-24-docs-sites-design.md +248 −0
  • internal/docsite/cli_test.go +77 −0
  • internal/docsite/cliscan.go +92 −0
  • internal/docsite/docsite.go +331 −0
  • internal/docsite/fences_test.go +187 −0
  • internal/docsite/links_test.go +125 −0
  • internal/docsite/nav_test.go +80 −0
  • internal/docsite/parse_test.go +74 −0
  • internal/docsite/testdata/corpus/getting-started.md +14 −0
  • internal/docsite/testdata/corpus/index.md +4 −0
  • internal/docsite/testdata/corpus/nav.json +16 −0
  • internal/docsite/testdata/corpus/reference/cli.md +9 −0
  • internal/docsite/testdata/missing/index.md +1 −0
  • internal/docsite/testdata/missing/nav.json +1 −0
  • internal/docsite/testdata/orphan/index.md +1 −0
  • internal/docsite/testdata/orphan/kept.md +1 −0
  • internal/docsite/testdata/orphan/nav.json +1 −0
  • internal/docsite/testdata/orphan/stranded.md +1 −0
diff --git a/AGENTS.md b/AGENTS.md
index 78b2f8c32..ab281fd3c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -112,6 +112,14 @@ than commit by commit, while the change can still be turned away.
- **When a storage shape changes, the consumer list is everything that PARSES
the shape** — not everything that calls the API. (Learned three times.)
+- **A new `carlos` command is not done until `docs/site/cli.md` names it.**
+ `internal/docsite` reads main.go's dispatch switch and fails the gate over a
+ command with no heading on the CLI page, over a fenced example running a verb
+ that no longer exists, and over a `/docs` link whose page or `#fragment` does
+ not resolve. The check is a heading and not a substring on purpose: `route` is
+ a prefix of `routes`, and a substring check let a deleted section pass. The
+ corpus is rendered by `carlosframework/website`, which vendors a copy — so a
+ change here reaches the site only after that repo re-syncs and ships.
- **Wire-shape changes are additive**: live pointers/manifests exist in
production buckets; anything written before your change must still parse.
- **Production channel names are pinned in tests**
diff --git a/docs/site/anchors.json b/docs/site/anchors.json
new file mode 100644
index 000000000..d2f9211bb
--- /dev/null
+++ b/docs/site/anchors.json
@@ -0,0 +1,15 @@
+{
+ "Getting started": "getting-started",
+ "The `carlos ship` contract": "the-carlos-ship-contract",
+ "404, never 403": "404-never-403",
+ "Routes & instances": "routes-instances",
+ " Leading and trailing ": "leading-and-trailing",
+ "Hyphen-joined words": "hyphen-joined-words",
+ "`-app` and `--app`": "app-and-app",
+ "canary/rehearsal": "canary-rehearsal",
+ "X-Carlos-Version": "x-carlos-version",
+ "-app": "app",
+ "--app": "app",
+ "Ship — then promote": "ship-then-promote",
+ "Montréal edge": "montr-al-edge"
+}
diff --git a/docs/site/cli.md b/docs/site/cli.md
new file mode 100644
index 000000000..ee9a45caf
--- /dev/null
+++ b/docs/site/cli.md
@@ -0,0 +1,714 @@
+# The CLI
+
+`carlos` is one binary that plays three roles, picked by the first word you
+type. Most days you are in the first role: you have an app, and you want it
+running somewhere. The second is for people who stand a deployment up and
+administer it. The third is what systemd starts on a box.
+
+Run `carlos help` for the same grouping at the terminal, and
+`carlos <command> -h` for a command's own flags. This page says what each
+command is for and when you would reach for it. It does not list every flag,
+because `-h` already does.
+
+Three flags turn up almost everywhere, and the sections below do not repeat
+them. `-app` names the app. `-account` names the account it belongs to, or
+`-account-id` when you want a sqid matched as a sqid and nothing else; leave
+both off if you belong to exactly one account. `-console` picks which
+logged-in console to act through, and you need it only when this terminal is
+logged in to more than one deployment.
+
+Most app commands work two ways. Signed in with `carlos auth login`, they go
+through a console's API, which is how someone with no box access and no cloud
+credentials gets their work done. With `CARLOS_DEPLOYMENT_BUCKET` or
+`CARLOS_DEPLOYMENT_DIR` set, they write to the bucket directly. That is the
+operator's path. Where a command only works one way, its section says so.
+
+## Your apps
+
+Everything from claiming a name to watching a deploy land. None of it needs
+access to a box, a cloud console, or the deployment's bucket; that is a rule
+the platform holds itself to, and a command that broke it would be a bug.
+
+### carlos auth
+
+Logs this terminal in to a console. `carlos auth login` prints a short code
+that you approve in a browser already signed in to that console, and the
+browser does not have to be on this machine, so this works over SSH. The
+token lands in `~/.carlos/credentials` at mode 0600, one entry per console,
+so a laptop can hold credentials for the flagship and your own deployment at
+once.
+
+```sh
+carlos auth login
+carlos auth whoami
+```
+
+Do not do this on a box. A box has no browser and no human, and the
+long-lived token you would leave behind is a standing credential nobody is
+watching; pass `-owner <address>` to
+[carlos accounts](/docs/cli#carlos-accounts) instead. `carlos auth default`
+shows the console this machine talks to by default, and sets it when you give
+it a URL. With `-project` it writes `./.carlos/config`, which you can commit
+so a checkout pins its own deployment.
+
+### carlos apps
+
+Claims an app name, moves an app onto a customer fleet, and deletes or
+restores one. Creating goes through the same door the console's New app form
+uses, so a name that form would refuse is refused here too.
+
+```sh
+carlos apps create -app hello
+```
+
+`carlos apps delete` moves an app to the Trash. Every route drops, custom
+domains included, the name stays reserved, and you have thirty days to
+`carlos apps restore` it. Restoring brings back releases, channels and the
+production flag exactly as they were, but no routes at all — not even the
+platform apex — so plan on adding those again. Delete is refused while the
+app is flagged production; clear the flag first.
+
+`-place fleet/<name>` at create, or `carlos apps place -target fleet/<name>`
+later, puts the app's routes and instances on a fleet your account owns.
+`-target ""` brings them back to platform-served. `-target` is mandatory even
+in that empty spelling: leaving it off is a usage error, never a silent
+clear.
+
+### carlos ship
+
+Publishes a release. The artifact is hashed, stored under its content
+address, and recorded in a manifest that never changes afterwards. Shipping
+does not change what anything serves — for that, see
+[carlos promote](/docs/cli#carlos-promote).
+
+```sh
+carlos ship -app hello -label "fixing modals" ./hello
+```
+
+`-version` is optional once the app has a version target set, and leaving it
+off is the point of having one: ship mints the next iteration under it. Use
+`-kind static` with a directory to ship a site instead of a binary. `-label`
+is one line for humans and shows up in `carlos releases` and in the console,
+so write it for the person doing a rollback at 2am.
+
+### carlos promote
+
+Points a channel at a version you have already shipped. The ladder is
+`canary → edge → beta → stable`: beta is entered from edge and stable from
+beta, so "it was signed off on beta" means something, and reaching stable
+cuts the release's semver tag. Channels named `canary/<something>` are never
+refused, because that is what the fleet itself runs on.
+
+```sh
+carlos promote -app hello a1b2c3d edge
+```
+
+Promoting the same version twice succeeds and does nothing. `-hotfix` skips
+the ladder and is an operator act: it works on the direct-bucket path only,
+and the console refuses it by name, so the flag never quietly does nothing.
+
+### carlos deploy
+
+Ship, promote, and then wait until the app's URL actually serves the new
+build. It watches the `X-Carlos-Version` header on the live host and does not
+report success until that header names what you shipped, over a 2xx. Reach
+for this one by default; ship and promote separately when you want the gap
+between them.
+
+```sh
+carlos deploy -app hello ./hello
+```
+
+Signed-in only. With saved project defaults you can run `carlos deploy` with
+no arguments and no questions. A first run at a terminal fills the blanks by
+asking. If your account owns no apps yet, it offers to claim one named after
+the directory you are in, through the same door as `carlos apps create`. It
+then asks which artifact to ship, works out from what you point it at whether
+that is a binary or a site, and offers to save both answers as the project's
+defaults. With neither arguments nor defaults and no terminal to ask at — CI,
+usually — it refuses instead of guessing. `-channel` overrides the channel it
+picks (normally the one the app's instances already follow), and `-host`
+scopes the watch to a single instance. A static site needs that second flag,
+having no instances to resolve a channel from.
+
+```sh
+carlos deploy -app website -kind static -host www.example.com ./dist
+```
+
+### carlos restart
+
+Cycles an app's processes with no new version and no config change. This is
+the answer to a wedged instance.
+
+```sh
+carlos restart -app hello
+```
+
+It reports the restart as *requested*, which is the honest word: the console
+touches one small object and every box serving the app notices within
+seconds. Long-running tenants come back within seconds. Exec-backed instances
+stop within seconds and respawn on their next request, which for an idle app
+may be a while. Nothing is stuck: the instance comes back with the next
+request that needs it. Hibernating instances are left asleep.
+
+### carlos schedule
+
+Gives an app a timetable. A schedule is a time and a path: at each fire the
+app's own instance gets a POST at that path, so the work is a route your app
+already serves rather than a separate worker. The sub-verbs are `ls`, `set`,
+`rm` and `run`.
+
+```sh
+carlos schedule set -app hello -name nightly -every 6h -path /jobs/nightly
+```
+
+`-every` takes whole minutes, from `1m` up to `720h`; `-cron` takes a
+five-field expression instead, and you give one or the other, never both.
+`carlos schedule ls` prints the declared schedules alongside what each
+instance reports it will do next and what it did last. `carlos schedule run
+-app hello -name nightly` asks for one out-of-band run on top of the normal
+timetable, and every instance fires within about fifteen seconds.
+
+The wording is deliberate: `set` records, `rm` removes, `run` requests. The
+console writes one small object and every box serving the app notices on its
+own within seconds; nothing is pushed at a box. The write itself wakes
+nothing, and a hibernating instance is woken by its own runner when a tick
+falls due. Schedules need a logged-in console; there is no direct-bucket
+form.
+
+### carlos rollback
+
+Points a channel back at the version it was serving before.
+
+```sh
+carlos rollback -app hello stable
+```
+
+### carlos channels
+
+What each of the app's channels is serving right now, with the tag if the
+release earned one at stable.
+
+```sh
+carlos channels -app hello
+```
+
+### carlos pipeline
+
+Shows an app's release channels and the rules for moving a version through
+them, and shapes that ladder. Bare `carlos pipeline` is the `show` verb: the
+channels in order, plus any change still waiting on confirmations. `init`,
+`add`, `set` and `remove` edit it.
+
+```sh
+carlos pipeline init -app hello -template edge-production
+```
+
+Two starter templates exist, `edge-production` and `full-ladder`. After
+that, `-bake <dur>` holds a version for a while before the channel may adopt
+it, and `-passkey`, `-promote-approvals N` and `-change-approvals N` set how
+much human agreement a promotion into the channel, or an edit to the channel
+itself, has to collect. `set` leaves any rule flag you did not type exactly
+as it was, so two people shaping different rules do not overwrite each other.
+
+A fresh app has one channel, `edge`, and prints as a single line rather than
+a one-row table. Shaping a pipeline wants a logged-in console: there is no
+bucket-direct editor for it. Promoting and rolling back are unaffected and
+work in either mode.
+
+### carlos releases
+
+Every version shipped, newest first, with the label its author typed and the
+tag it earned. Channels answer "what is running"; this answers "what is there
+to run".
+
+```sh
+carlos releases -app hello
+```
+
+There is one sub-verb. `carlos releases retention -keep 20` sets an ambient
+prune policy, and `-dry-run` shows you what it would remove before you commit
+to it. The safety list always wins over the number: channel pointers,
+rollback history, tagged releases, anything inside the bake window, and each
+box's own adopted versions are never pruned, so nothing promoted or recent
+disappears by policy. `-off` goes back to keeping everything.
+
+### carlos version
+
+`carlos version` prints the build id of the binary in front of you. It is the
+first thing to check when a command behaves unlike this page describes.
+
+`carlos version target` is a separate thing wearing the same word: it sets
+the semver your ships count iterations under, so `carlos ship` can mint the
+next one for you.
+
+```sh
+carlos version target -app hello 0.5.0
+```
+
+### carlos env
+
+Plain per-app config vars: `set`, `unset`, `list`.
+
+```sh
+carlos env set -app hello LOG_LEVEL=debug
+```
+
+Values land in the app's default bundle. `-environment <name>` writes a named
+bundle instead, layered on top of the default when config is materialized,
+and `carlos route -environment` is what binds a route to one.
+`carlos env environments -app hello` lists the bundle names an app has.
+
+### carlos secrets
+
+The same shape as `carlos env`, sealed. `list` prints key names and never
+values.
+
+```sh
+carlos secrets set -app hello STRIPE_KEY=sk_live_example
+```
+
+Sealing uses a public key; the private half lives on the box that decrypts
+them and never leaves it, in either mode. `carlos secrets genkey` mints the
+pair, and it is always local to your terminal.
+
+### carlos instances
+
+An app's instances are the places it actually runs. `carlos instances enable`
+is the app's opt-in to provisioning, and until it is set the API refuses to
+create anything. After that, `create`, `list`, `delete` and `set-upstreams`
+work on records through the console, and the box's reconcile pass turns a
+record into a live route.
+
+```sh
+carlos instances enable -app hello
+carlos instances list -app hello
+```
+
+`-health <slug>` narrows the listing to one state: `running`, `asleep`,
+`not-responding` and the rest. That is usually what you want when something
+is wrong.
+
+Typed on a box with no sub-verb, `carlos instances` does something different:
+it lists that box's own registry routes, backing and owning unit included.
+Off the box, [carlos routes](/docs/cli#carlos-routes) answers the same
+question through the console.
+
+### carlos steering
+
+Decides whether one instance's DNS answer varies by where the request comes
+from. `latency` opts the host into a Route53 latency record per armed edge,
+so the nearest one answers; `off` clears the opt-in and returns the host to
+the single static answer every requester shares.
+
+```sh
+carlos steering -app hello -host hello.example.com latency
+```
+
+The opt-in is recorded immediately and reaches the box's registry row at the
+next converge tick, but no DNS answer changes until the deployment itself
+has armed its Route53 steering converger. In v1 only instances on the shared
+pool can be steered; a pool-scoped route is refused, and the refusal names
+that as the reason.
+
+### carlos routes
+
+The app's routes on this deployment: where each one sends traffic, which
+channel it follows, and which config environment it is bound to. That last
+column is the one worth knowing about, because it decides which sealed
+secrets the route's process is delivered.
+
+```sh
+carlos routes -app hello
+```
+
+It reads the console's own box, so on a multi-box fleet a short answer is not
+necessarily a complete one. The command says so every time it prints.
+
+### carlos domains
+
+Attaches a customer's own hostname to one of the app's routes, detaches it,
+or lists what is claimed.
+
+```sh
+carlos domains attach -app hello www.example.com
+```
+
+`-route` picks which route to point at, and you can leave it off when the app
+has exactly one instance record. The hostname comes live on the box owning
+that route at its next domains pass, certificate and all. The `list` verb
+joins the claims to the fleet's own readings sweep, so DNS state, certificate
+expiry and delegation are readable without leaving the terminal.
+
+### carlos logs
+
+The app's own stdout and stderr, merged with the platform's events about it:
+wakes, restarts, and failures with the reason. A site served from files has
+no app stream, but its platform events still show up.
+
+```sh
+carlos logs -app hello -since 1h -f
+```
+
+`-f` follows, polling every two seconds until you interrupt it. `-grep` takes
+an RE2 pattern, `-stream app` drops the platform commentary, and `-host`
+narrows to one instance.
+
+### carlos store
+
+Object storage for an app. `create` declares that the app wants a bucket,
+which is a request and not a provision — nothing exists in any cloud account
+until an operator runs `grant`. `status` shows what is declared, granted and
+delivered, including a rotation that stopped half way.
+
+```sh
+carlos store create -app hello
+carlos store status -app hello
+```
+
+`carlos store rotate` mints a fresh credential and leaves the old one live
+until you run it again with `-finish`, so the app has a window to pick the
+new one up. Rotating, declaring and reading status are member verbs and want
+nothing but your bearer token. `grant` is the one that spends money in the
+deployment's own cloud account, so it also wants an address on
+`CARLOS_STORE_OPERATORS`; that check runs after the account gate, so a
+stranger still meets the same 404 as everyone else rather than a 403 that
+would confirm the app exists.
+
+### carlos email
+
+Tenant email sending. `carlos email enable` is the whole path in one
+command: it declares the From address the app sends as, ensures a sending
+domain, waits for SES to verify it, and delivers SMTP credentials to the app
+as env vars under a prefix — `CARLOS_SMTP` unless `-env-prefix` names
+another.
+
+```sh
+carlos email enable -app hello
+carlos email test -app hello -to you@example.com
+```
+
+On a custom domain, `enable` prints the DNS records to publish and then
+keeps polling until SES confirms them, giving up after ten minutes unless
+`-timeout` says otherwise; a wait that gives up exits non-zero and names what
+SES never confirmed. `carlos email domains add` is that same half on its own,
+for a second domain on an app already declared.
+
+`carlos email test` sends a real message and reports what SES said about it.
+It sends through a throwaway standalone credential it mints and revokes
+around the send, so it never needs the delivered credential's password, and
+it proves that mail leaves the building rather than that the console believes
+it should.
+
+`status` shows every domain's per-region verification state, the day's count
+against the cap, and whether sending is paused. `credentials create` mints a
+standalone SMTP credential for something not running on CARLOS — a laptop, a
+cron box — printed once on that command's output and nowhere else. `rotate`
+mints a fresh delivered credential and leaves the old one live until you run
+it again with `-finish`. `pause` and `resume` are operator verbs. None of it
+has a direct-bucket form; all of it wants a logged-in console.
+
+### carlos ledger
+
+Append-only hash-chained ledgers an app can publish. `append` adds one JSON
+entry to a chain, `head` prints that chain's current head, `list` shows the
+app's ledgers and what it publishes, and `publish` decides which of them are
+served publicly.
+
+```sh
+carlos ledger append -app hello carbon entry.json
+```
+
+`blob` uploads files to a chain as content-addressed blobs and prints each
+one's sha, so an entry can point at it. Uploads are create-only and capped at
+4 MB a file, and re-uploading identical bytes does nothing.
+
+Ledgers have no direct-bucket mode at all: every sub-verb except `verify`
+goes through the console, because a contributor's machine is never given
+bucket credentials. `carlos ledger verify` walks a published chain over plain
+HTTPS and re-hashes every entry. It needs no credentials at all: anybody can
+check a ledger you publish, including you, from a machine that has never been
+logged in.
+
+### carlos vet
+
+Checks a shipped release against the platform contract before anyone promotes
+it. The manifest has to exist and parse, and every artifact's stored bytes
+have to match the sha256 and size it claims.
+
+```sh
+carlos vet -app hello -version a1b2c3d -boot
+```
+
+`-boot` goes further and runs the binary: it must accept `-socket <path>`,
+serve HTTP on that socket, and answer `GET /healthz` with a 200 within ten
+seconds. That is the entire app contract, and this is the cheapest place to
+find out you have broken it.
+
+### carlos update
+
+Replaces the `carlos` binary on your workstation with the latest published
+release, checking the signed checksums before it swaps anything. Where a
+package manager owns the install, it prints the `brew` or `apt` command
+instead of fighting it.
+
+```sh
+carlos update
+```
+
+It refuses to run as root, and refuses on a box: box binaries are rolled by
+the platform, and a self-updating box would fight that machinery. `-y` skips
+the confirmation, though it still wants a terminal.
+
+## Your deployment
+
+The verbs for the person who owns the boxes. Some of these run against a
+console like the app commands above; others open a box's registry directly
+and only make sense while you are standing on it.
+
+### carlos bootstrap
+
+Prepares a host to be a CARLOS box: the service user, the data directories,
+the systemd units. Run it once per host, as root.
+
+```sh
+carlos bootstrap
+```
+
+`-root <dir>` writes the files into a staging directory instead, creating no
+users and running no systemctl, so you can read what it would do before it
+does it. `-offcloud` is for a box outside EC2, where credentials come from a
+staged 0600 file instead of an instance profile.
+
+### carlos accounts
+
+Accounts are the tenancy primitive: apps, fleets, credentials and bucket
+prefixes all hang off one. `create` mints an account and prints the sqid
+every object underneath it is keyed by.
+
+```sh
+carlos accounts create -name acme -owner someone@example.com
+```
+
+`-owner` is the path for a box or a CI job, where there is no browser to log
+in with. It needs no identity of its own and takes precedence over any
+logged-in one.
+
+`carlos accounts migrate` copies an app's objects to an account-qualified
+prefix and re-stamps its routes. Run it with `-dry-run` first; it prints the
+plan and touches nothing.
+
+### carlos fleets
+
+A fleet is a group of remote boxes an account owns, dialing in to this
+console over a channel named `<fleet>/<label>`. Create one against the
+customer's own data bucket, then register each box.
+
+```sh
+carlos fleets create -bucket acme-data acme
+carlos fleets add-box acme pi-1
+```
+
+The box's bearer token prints exactly once, at `add-box`. The console keeps
+only its hash, so losing that output means `rotate-token`; a second `add-box`
+will only refuse the label. Put it straight into the box's credential store.
+
+Rotation is not revocation. A fresh token refuses the box's next dial-in with
+the old one, but it does not evict a channel the box is already holding. If a
+credential may be compromised, `detach-box` is the command that actually
+stops it.
+
+### carlos services
+
+Credentials a server holds rather than a person: a CI job that ships, a
+sidecar that reads instances. Reach is fixed at mint, so adding you to
+another account later does not widen it, and it has its own rate-limit budget.
+
+```sh
+carlos services create -role publish -app hello ci-shipper
+```
+
+Pick the narrowest role that works. `publish` ships releases, `instance` is
+what a box-side reader wants, `operate` administers, and `admin` includes
+`store grant`, which mints a path-scoped IAM user; treat that one as a real
+handover. `-app` binds the credential to one app, which also means it cannot
+create apps.
+
+The secret prints once, same as a fleet token, and the same distinction
+applies at the other end: `rotate` refuses the credential's next call with
+the old secret, `revoke` stops it whatever secret it holds.
+
+### carlos add
+
+Writes a route straight into a box's registry: this hostname, served by this
+app, following this channel. It is the box-local, operator-side counterpart
+to an instance record.
+
+```sh
+carlos add -app hello -socket /run/hello.sock hello.example.com
+```
+
+`-channel` sets what the route follows, `-kind` picks instance, service or
+static, and `-addr` takes a TCP upstream where the app is not a socket
+tenant. `-unit console.service` names the systemd unit that owns the route's
+process, which is what lets adoption cycle it; leave it off for an ordinary
+exec child.
+
+### carlos remove
+
+Drops one of this box's routes. It stops routing the hostname and never
+touches the instance's database.
+
+```sh
+carlos remove hello.example.com
+```
+
+### carlos route
+
+Changes one thing about a route that already exists. Each axis is its own
+operation and the command refuses to combine them, because a capability grant
+and a repoint are different acts with different safety rails, and a command
+that did both would commit them together.
+
+```sh
+carlos route -host hello.example.com -channel edge
+```
+
+`-channel` and `-environment` repoint, and those two may be given together.
+`-grant` and `-revoke` change a capability. `-hibernate` lets the route sleep
+when idle. `-backing` and `-unit` are two answers to one question — who owns
+this route's process — so the command will not let you write both.
+
+Repointing the channel is the half to be careful with. Adoption converges a
+route onto whatever its channel currently points at, so moving the channel
+changes what production serves on the next pass; the command resolves the
+target pointer first and refuses a version change unless you pass
+`-allow-version-change`; `-dry-run` runs the same rails and prints the plan
+without writing. And once a route is `Provisioned`, its channel and
+environment belong to its instance record: `carlos route` refuses both axes
+on such a row and names the record, because the reconciler would revert you
+within seconds anyway.
+
+### carlos release-keygen
+
+Mints the deployment's root signing key, or a scoped key that can sign only
+certain rungs of the ladder.
+
+```sh
+carlos release-keygen -scope canary,edge -name builder
+```
+
+With no flags it prints a fresh root pair: the key goes into your secret
+manager, the public half into host config. With `-scope` it prints a key and
+a grant. The grant carries no secret material but has to travel with the key,
+because a scoped key on its own signs pointers nothing will accept. Only the
+root key can mint a grant, which is what stops a scoped key widening itself.
+
+### carlos economics
+
+Records one month's AWS bill so the console's economics dashboard has a real
+number to divide by.
+
+```sh
+carlos economics bill -month 2026-08 -total 412.55 -line AmazonEC2=55.00
+```
+
+Operator only, and the console answers everyone else with the same bare 404
+it gives an unknown route. A "not found" here almost always means your
+account lacks the fleet-operator bit, not that you typed the path wrong.
+
+### carlos status
+
+`-live` prints what every box in the fleet last reported about itself:
+release, tunnel state, memory, load, free disk. A box that has stopped
+reporting shows as UNREACHABLE with how long it has been dark, and none of
+its stale readings.
+
+```sh
+carlos status -live
+```
+
+`-dry-run <dir>` is the self-hoster's leak check. It builds a throwaway fleet
+stuffed with fixture secrets, runs it through the real publish path into a
+scratch directory, reads every published object back, and fails if any
+fixture escaped. Run it before you ever point this binary at a real bucket.
+Neither mode publishes anything: the public status page is written by each
+box's own agent tick.
+
+## On the host
+
+These are what systemd starts. You will rarely type them, but knowing what
+they do explains most of what happens between a promote and a URL changing.
+
+### carlos edge
+
+The front door. TLS and ACME, and the proxy that maps a hostname to whatever
+is serving it. One per box, started by `carlos-edge.service`.
+
+```sh
+carlos edge -dev -http :8080
+```
+
+`-dev` serves plain HTTP with no TLS or ACME, which is how you run it on a
+laptop. `-fallback` redirects unknown hostnames somewhere (an apex marketing
+site, usually) instead of returning the plain 404.
+
+### carlos agent
+
+Edge, the hibernation activator, release adoption and the status tick, in one
+process per host. This is what a box actually runs; `carlos edge` alone is
+the proxy without any of the rest.
+
+### carlos adopt
+
+One release-adoption pass: read each route's channel, resolve what that
+channel points at, fetch it if the box does not have it, and swap the
+symlink. The hourly timer runs this verb.
+
+```sh
+carlos adopt
+```
+
+Adoption swaps a symlink, and on its own it does not restart anything. A
+long-running unit keeps serving the old build until something cycles it, and
+a hibernating tenant picks the new one up when it next wakes — so the order
+that works is promote, then adopt, then restart. A route that names its
+owning unit is the exception: adoption cycles that one itself, and rolls the
+adoption back if the restart fails, so the version header never claims a
+build the process is not serving.
+
+`-restart-only` and `-config-only` are the narrower passes the agent asks a
+root-side unit to run for it, since the agent can request a pass but cannot
+write to `/etc` or restart units itself.
+
+### carlos ops
+
+Box operations, run by a timer or by an operator standing on the box.
+`litestream` regenerates the replication config from the registry and the
+data directory, printing `changed` or `unchanged` so the caller knows whether
+to restart replication. `restore-verify` restores every configured replica
+from S3 and integrity-checks it. `hibernate-verify` does the same drill for
+the replicas the config does not name — hibernating instances, whose
+replication belongs to the activator — and publishes a per-host verdict to
+the deployment bucket so you can read it off-box.
+
+```sh
+carlos ops restore-verify
+```
+
+## Everything else
+
+`carlos help` belongs to all three roles above; `carlos canary` belongs
+to none of them yet.
+
+### carlos help
+
+Prints the command list, grouped the way this page is. It goes to stdout and
+exits 0, so `carlos help | less` works. A bare `carlos` with no arguments
+prints the same text on stderr and exits 2, because that one is a mistake.
+
+### carlos canary
+
+Reserved, and not implemented: typing it exits with `not implemented`.
+Canary releases today are ordinary channels named `canary/<something>`,
+promoted with [carlos promote](/docs/cli#carlos-promote) like any other rung.
diff --git a/docs/site/index.md b/docs/site/index.md
new file mode 100644
index 000000000..fe9fd6eca
--- /dev/null
+++ b/docs/site/index.md
@@ -0,0 +1,17 @@
+# CARLOS
+
+CARLOS runs your apps on hardware you control. A deployment is one
+object-store bucket, one or more Linux hosts running the `carlos` binary,
+and a DNS zone; you publish a release with `carlos ship`, and the platform
+places it on instances, routes to it through a shared edge, and keeps TLS,
+config, secrets and replication working underneath it.
+
+These pages are the reference for the platform itself — the commands you
+type, and what happens on a box after you type them. They assume you are
+either running a deployment or shipping onto one. They do not teach Go, and
+they are not a tutorial.
+
+If you are a Carloku customer, the docs you want are at
+[carloku.com](https://carloku.com). Carloku is CARLOS run for you, and it
+covers the parts you actually touch without the box-side machinery these
+pages spend most of their time on.
diff --git a/docs/site/nav.json b/docs/site/nav.json
new file mode 100644
index 000000000..616eb25f0
--- /dev/null
+++ b/docs/site/nav.json
@@ -0,0 +1,14 @@
+{
+ "sections": [
+ {
+ "title": "Reference",
+ "pages": [
+ {
+ "slug": "cli",
+ "label": "The CLI",
+ "blurb": "Every carlos command, grouped by whether you are shipping an app, running a deployment, or on a box."
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md b/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md
new file mode 100644
index 000000000..fa9425d55
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md
@@ -0,0 +1,1615 @@
+# Docs Pipeline and Gates Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stand up `carlosframework.com/docs` end to end with two real pages, and the CI gate that fails a platform PR whose docs stop describing the code.
+
+**Architecture:** A markdown corpus plus `nav.json` lives in the platform repo at `docs/site/`. `internal/docsite` parses it and its tests are the gate. The `carlosframework/website` repo vendors the corpus and renders it with Eleventy at authoring time; `_site/` is gitignored and shipped. This is the split rastrillo already runs — Go parses and gates, JavaScript renders.
+
+**Tech Stack:** Go 1.26 (stdlib only — `go/ast`, `go/parser`, `encoding/json`); Eleventy 3, `markdown-it` 14, `markdown-it-anchor` 9 as devDependencies in the website repo.
+
+**Spec:** `docs/superpowers/specs/2026-08-24-docs-sites-design.md`
+
+## Global Constraints
+
+- **Reference implementation:** `~/github.com/rastrilloorg/rastrillo/internal/docsite/` and `~/github.com/carlosframework/rastrillo-website/`. Read the file named in a task before porting it. Port; do not reinvent.
+- **No new Go dependencies.** `go.mod` does not change in this plan. Everything here is stdlib.
+- **Gate before every commit:** `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1`, and `gofmt -l` empty on touched files. Disable the sandbox if the toolchain reports "read-only file system".
+- **Every change lands through a PR.** Work on branch `docs` (platform) and `docs-site` (website). Never merge to `main` locally.
+- **Corpus root is `docs/site`**, addressed from the package as `../../docs/site`.
+- **Anchor rule, both languages:** lowercase ASCII alphanumerics kept; every other run becomes a single `-`; no leading or trailing `-`.
+- **Prose in the corpus is written against the `humane-docs` skill.** rastrillo.org is the structural model and explicitly not the prose model.
+- **Commit trailer:** `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
+
+---
+
+## File Structure
+
+**Platform repo (branch `docs`):**
+
+| File | Responsibility |
+|---|---|
+| `internal/docsite/docsite.go` | Parse a corpus: `Load`, `Page`, `Site`, `Anchor`, `MarkdownFiles`. No rendering, no gates. |
+| `internal/docsite/nav_test.go` | Nav and files agree both ways; every page has a title, label, blurb. |
+| `internal/docsite/links_test.go` | Internal links and fragments resolve; anchors unique; `Anchor` matches the fixture. |
+| `internal/docsite/fences_test.go` | Fence languages declared; every `carlos` invocation in a fence names a real command. |
+| `internal/docsite/cli_test.go` | Every `main.go` dispatch case appears on the CLI reference page. |
+| `internal/docsite/cliscan.go` | AST scan of `main.go` for dispatched command names. Non-test so `fences_test.go` can share it. |
+| `internal/docsite/testdata/corpus/` | A small valid corpus the parser tests run against. |
+| `docs/site/anchors.json` | The shared slug fixture. Vendored to the website; both languages assert against this file. |
+| `docs/site/nav.json`, `index.md`, `cli.md` | The real corpus, two pages to start. |
+
+**Website repo (branch `docs-site`):**
+
+| File | Responsibility |
+|---|---|
+| `package.json`, `eleventy.config.js` | Build config, `slugify`, the docs collections and filters. |
+| `src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk` | Page, index and raw-markdown outputs. |
+| `src/_includes/docs.njk` | The shell: breadcrumb, sidebar, sub-nav. |
+| `src/docs.css` | The docs skin. Loaded alongside the existing `site.css`. |
+| `hack/sync-docs.mjs` | Vendor the corpus from a platform checkout, record its sha. |
+| `hack/check-docs.mjs` | Built pages exist, hrefs resolve, ids exist, slugify agrees with the fixture. |
+
+---
+
+### Task 1: The corpus parser
+
+**Files:**
+- Create: `internal/docsite/docsite.go`
+- Create: `internal/docsite/testdata/corpus/nav.json`, `index.md`, `getting-started.md`, `reference/cli.md`
+- Test: `internal/docsite/parse_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `Load(root string) (*Site, error)`; `Site{Index *Page; Sections []Section; BySlug map[string]*Page}` with method `Pages() []*Page`; `Section{Title string; Pages []*Page}`; `Page{Slug, Label, Blurb, Title, Path, Body string; Headings []Heading; Links []Link; Fences []Fence}`; `Heading{Level int; Text, Anchor string}`; `Link{Target, Fragment string; Line int}`; `Fence{Lang, Body string; Line int}`; `Anchor(text string) string`; `MarkdownFiles(root string) ([]string, error)`.
+
+- [ ] **Step 1: Read the reference implementation**
+
+Read `~/github.com/rastrilloorg/rastrillo/internal/docsite/docsite.go` in full. You are porting it. Three deliberate differences:
+
+1. Drop `Page.Ignores` and `ignoreMarker`. Those serve rastrillo's exported-symbol gate, which CARLOS has no analogue for — the platform is an application, not a library.
+2. Keep everything else byte-for-byte where you can, including the fence-matching rules and `parseDocsHref`.
+3. Package comment describes CARLOS, not rastrillo. Write it against `humane-docs`: say what the package is for and what it deliberately is not.
+
+- [ ] **Step 2: Write the testdata corpus**
+
+`internal/docsite/testdata/corpus/nav.json`:
+
+```json
+{
+ "sections": [
+ {
+ "title": "Start here",
+ "pages": [
+ { "slug": "getting-started", "label": "Getting started", "blurb": "Install it and deploy something." }
+ ]
+ },
+ {
+ "title": "Reference",
+ "pages": [
+ { "slug": "reference/cli", "label": "The CLI", "blurb": "Every command." }
+ ]
+ }
+ ]
+}
+```
+
+`internal/docsite/testdata/corpus/index.md`:
+
+```text
+# Documentation
+
+Start with [getting started](/docs/getting-started), or jump to
+[the CLI](/docs/reference/cli#carlos-ship).
+```
+
+`internal/docsite/testdata/corpus/getting-started.md`:
+
+```text
+# Getting started
+
+## Install the CLI
+
+Run it:
+
+```sh
+carlos auth login
+```
+
+## Deploy something
+
+See [the CLI reference](/docs/reference/cli).
+```
+
+`internal/docsite/testdata/corpus/reference/cli.md`:
+
+```text
+# The CLI
+
+## carlos ship
+
+Publish a release.
+
+```sh
+carlos ship -app hello -version 1.0.0 ./dist
+```
+```
+
+Note the nested fence in `getting-started.md` and `cli.md`: when writing those files, the outer block above is this plan's formatting. The file content is the markdown itself, with a single ```` ```sh ```` fence inside.
+
+- [ ] **Step 3: Write the failing parser test**
+
+`internal/docsite/parse_test.go`:
+
+```go
+package docsite
+
+import "testing"
+
+const testCorpus = "testdata/corpus"
+
+func TestLoadReadsNavAndPages(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if site.Index == nil || site.Index.Title != "Documentation" {
+ t.Fatalf("index not loaded: %+v", site.Index)
+ }
+ if len(site.Sections) != 2 {
+ t.Fatalf("got %d sections, want 2", len(site.Sections))
+ }
+ if got := site.Sections[1].Pages[0].Slug; got != "reference/cli" {
+ t.Errorf("nested slug = %q, want reference/cli", got)
+ }
+ if got := len(site.Pages()); got != 3 {
+ t.Errorf("Pages() = %d, want 3 (index + two)", got)
+ }
+}
+
+func TestParsePageFindsHeadingsLinksAndFences(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ p := site.BySlug["getting-started"]
+ if p == nil {
+ t.Fatal("no getting-started page")
+ }
+ if len(p.Headings) != 3 {
+ t.Fatalf("got %d headings, want 3", len(p.Headings))
+ }
+ if p.Headings[1].Anchor != "install-the-cli" {
+ t.Errorf("anchor = %q, want install-the-cli", p.Headings[1].Anchor)
+ }
+ if len(p.Fences) != 1 || p.Fences[0].Lang != "sh" {
+ t.Fatalf("fences = %+v, want one sh fence", p.Fences)
+ }
+ if len(p.Links) != 1 || p.Links[0].Target != "reference/cli" {
+ t.Fatalf("links = %+v, want one to reference/cli", p.Links)
+ }
+}
+
+func TestLinksInsideFencesAreNotLinks(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ for _, l := range p.Links {
+ if l.Target == "" {
+ t.Errorf("%s: empty link target at line %d", p.Slug, l.Line)
+ }
+ }
+ }
+}
+
+func TestIndexLinkResolvesToIndexSlug(t *testing.T) {
+ l := parseDocsHref("/docs")
+ if l.Target != "index" {
+ t.Errorf("parseDocsHref(\"/docs\") target = %q, want index", l.Target)
+ }
+ l = parseDocsHref("/docs/reference/cli#carlos-ship")
+ if l.Target != "reference/cli" || l.Fragment != "carlos-ship" {
+ t.Errorf("got %+v, want reference/cli #carlos-ship", l)
+ }
+}
+
+func TestMarkdownFilesWalksNested(t *testing.T) {
+ files, err := MarkdownFiles(testCorpus)
+ if err != nil {
+ t.Fatalf("walk: %v", err)
+ }
+ want := map[string]bool{"index": true, "getting-started": true, "reference/cli": true}
+ if len(files) != len(want) {
+ t.Fatalf("got %v, want %d files", files, len(want))
+ }
+ for _, f := range files {
+ if !want[f] {
+ t.Errorf("unexpected file %q", f)
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they fail**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run 'TestLoad|TestParse|TestLinks|TestIndex|TestMarkdown' -count=1
+```
+
+Expected: build failure — `undefined: Load`.
+
+- [ ] **Step 5: Write `docsite.go`**
+
+Port from the reference read in Step 1, applying the three differences. `Anchor` verbatim:
+
+```go
+// Anchor is the heading anchor the rendered page will carry.
+//
+// The website configures markdown-it-anchor with a slugify that matches
+// this function, and both sides assert against docs/site/anchors.json.
+// Both are deliberately plain — lowercase alphanumerics, everything else
+// a separator — because markdown-it-anchor's own default URI-encodes
+// punctuation, which would anchor a heading naming `carlos ship -app`
+// differently on each side.
+func Anchor(text string) string {
+ var b strings.Builder
+ pendingSep := false
+ for _, r := range strings.ToLower(text) {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ if pendingSep && b.Len() > 0 {
+ b.WriteByte('-')
+ }
+ pendingSep = false
+ b.WriteRune(r)
+ default:
+ pendingSep = true
+ }
+ }
+ return b.String()
+}
+```
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Same command as Step 4. Expected: PASS, five tests.
+
+- [ ] **Step 7: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add internal/docsite/
+git commit -m "docsite: parse a markdown corpus and its nav
+
+Ported from rastrillo's internal/docsite, minus the docs:ignore markers
+that serve its exported-symbol gate. The platform is an application, not
+a library, so there is no symbol surface to hold docs against — the CLI
+is the surface, and that gate arrives in a later commit.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: The nav gate
+
+**Files:**
+- Create: `internal/docsite/nav_test.go`
+- Create: `internal/docsite/testdata/orphan/` (nav.json naming one page, two `.md` files on disk)
+
+**Interfaces:**
+- Consumes: `Load`, `MarkdownFiles`, `Site.Pages()` from Task 1.
+- Produces: `const root = "../../docs/site"` — every later gate test uses this constant.
+
+- [ ] **Step 1: Write the orphan fixture**
+
+`internal/docsite/testdata/orphan/nav.json`:
+
+```json
+{ "sections": [ { "title": "Only", "pages": [ { "slug": "kept", "label": "Kept", "blurb": "Reachable." } ] } ] }
+```
+
+`internal/docsite/testdata/orphan/index.md`:
+
+```text
+# Documentation
+```
+
+`internal/docsite/testdata/orphan/kept.md`:
+
+```text
+# Kept
+```
+
+`internal/docsite/testdata/orphan/stranded.md`:
+
+```text
+# Stranded
+```
+
+- [ ] **Step 2: Write the failing test**
+
+```go
+package docsite
+
+import "testing"
+
+// root is the real corpus, relative to this package. Every gate below
+// runs against it.
+const root = "../../docs/site"
+
+// TestNavAndFilesAgreeBothWays keeps the table of contents honest in
+// both directions. A page nav.json names must exist — Load fails on
+// that one — and a page on disk that no nav entry reaches must not. An
+// unreachable page is the worse failure: it looks written, and nobody
+// ever reads it.
+func TestNavAndFilesAgreeBothWays(t *testing.T) {
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+
+ seen := map[string]bool{}
+ for _, p := range site.Pages() {
+ if seen[p.Slug] {
+ t.Errorf("%s: appears in nav.json more than once", p.Slug)
+ }
+ seen[p.Slug] = true
+
+ if p.Title == "" {
+ t.Errorf("%s: no `# ` title", p.Slug)
+ }
+ if p != site.Index && p.Blurb == "" {
+ t.Errorf("%s: no blurb in nav.json (it renders on the /docs index)", p.Slug)
+ }
+ if p != site.Index && p.Label == "" {
+ t.Errorf("%s: no label in nav.json", p.Slug)
+ }
+ }
+
+ files, err := MarkdownFiles(root)
+ if err != nil {
+ t.Fatalf("walk %s: %v", root, err)
+ }
+ for _, slug := range files {
+ if !seen[slug] {
+ t.Errorf("docs/site/%s.md exists but no nav.json entry reaches it", slug)
+ }
+ }
+}
+
+// TestOrphanPagesAreCaught proves the gate above can actually fail. The
+// check that never fires is the check nobody notices is broken.
+func TestOrphanPagesAreCaught(t *testing.T) {
+ site, err := Load("testdata/orphan")
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ seen := map[string]bool{}
+ for _, p := range site.Pages() {
+ seen[p.Slug] = true
+ }
+ files, err := MarkdownFiles("testdata/orphan")
+ if err != nil {
+ t.Fatalf("walk: %v", err)
+ }
+ var orphans []string
+ for _, slug := range files {
+ if !seen[slug] {
+ orphans = append(orphans, slug)
+ }
+ }
+ if len(orphans) != 1 || orphans[0] != "stranded" {
+ t.Errorf("orphans = %v, want [stranded]", orphans)
+ }
+}
+
+// TestNavNamingAMissingPageFails is the other direction.
+func TestNavNamingAMissingPageFails(t *testing.T) {
+ if _, err := Load("testdata/missing"); err == nil {
+ t.Fatal("Load succeeded on a nav.json naming a page that does not exist")
+ }
+}
+```
+
+- [ ] **Step 3: Write the `missing` fixture**
+
+`internal/docsite/testdata/missing/nav.json`:
+
+```json
+{ "sections": [ { "title": "Only", "pages": [ { "slug": "nowhere", "label": "Nowhere", "blurb": "Not on disk." } ] } ] }
+```
+
+`internal/docsite/testdata/missing/index.md`:
+
+```text
+# Documentation
+```
+
+- [ ] **Step 4: Run to verify it fails**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestNav -count=1
+```
+
+Expected: `TestNavAndFilesAgreeBothWays` FAILS with `load: read nav.json: ... docs/site/nav.json: no such file`. The other two pass. That failure is correct — the real corpus arrives in Task 8, and this test is what makes Task 8 mandatory rather than optional.
+
+- [ ] **Step 5: Skip the real-corpus gate until the corpus exists**
+
+Add to the top of `TestNavAndFilesAgreeBothWays` only:
+
+```go
+ if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site/nav.json")
+ }
+```
+
+Add the same three lines to every gate test that reads `root` in Tasks 3–7. Task 8 deletes all of them in one commit. Import `"os"`.
+
+The condition tests `nav.json`, not the directory. Task 4 creates `docs/site/anchors.json`, so a bare directory test would stop skipping one task early and every gate would then fail on a corpus that does not exist until Task 8.
+
+- [ ] **Step 6: Run to verify all pass**
+
+Same command. Expected: PASS (one skipped).
+
+- [ ] **Step 7: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add internal/docsite/
+git commit -m "docsite: gate the nav against the files, both directions
+
+A nav entry with no file fails in Load. A file no nav entry reaches
+fails here — that is the worse one, because the page looks written and
+is never read. Both directions have a fixture that proves the check can
+fire.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Links, fragments and anchor uniqueness
+
+**Files:**
+- Create: `internal/docsite/links_test.go`
+
+**Interfaces:**
+- Consumes: `Load`, `root`, `Page.Links`, `Page.Headings`.
+- Produces: `hasAnchor(p *Page, anchor string) bool`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package docsite
+
+import (
+ "os"
+ "testing"
+)
+
+// TestInternalLinksResolve stops a docs link 404ing.
+//
+// It checks the fragment as well as the page, because a link to a
+// renamed heading is the failure that actually happens: the page still
+// exists, the link still looks right in review, and the reader lands at
+// the top of a long page wondering what they missed.
+func TestInternalLinksResolve(t *testing.T) {
+ if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site/nav.json")
+ }
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ for _, l := range p.Links {
+ target, ok := site.BySlug[l.Target]
+ if !ok {
+ t.Errorf("%s.md:%d: links to /docs/%s, which is not a page",
+ p.Slug, l.Line, l.Target)
+ continue
+ }
+ if l.Fragment == "" {
+ continue
+ }
+ if !hasAnchor(target, l.Fragment) {
+ t.Errorf("%s.md:%d: links to /docs/%s#%s, but %s has no heading anchoring %q",
+ p.Slug, l.Line, l.Target, l.Fragment, l.Target, l.Fragment)
+ }
+ }
+ }
+}
+
+func hasAnchor(p *Page, anchor string) bool {
+ for _, h := range p.Headings {
+ if h.Anchor == anchor {
+ return true
+ }
+ }
+ return false
+}
+
+// TestLinkCheckCatchesADeadFragment proves the fragment half fires.
+func TestLinkCheckCatchesADeadFragment(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ cli := site.BySlug["reference/cli"]
+ if cli == nil {
+ t.Fatal("no reference/cli page")
+ }
+ if !hasAnchor(cli, "carlos-ship") {
+ t.Error("expected reference/cli to anchor carlos-ship")
+ }
+ if hasAnchor(cli, "carlos-teleport") {
+ t.Error("hasAnchor invented an anchor that is not there")
+ }
+}
+
+// TestAnchorsAreUnique matters because a duplicate anchor makes a link
+// to it ambiguous, and the renderer resolves that by silently suffixing
+// the second one. The link then goes to the wrong section rather than
+// nowhere, which is harder to notice.
+func TestAnchorsAreUnique(t *testing.T) {
+ if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site/nav.json")
+ }
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ seen := map[string]string{}
+ for _, h := range p.Headings {
+ if h.Level == 1 {
+ continue
+ }
+ if prev, dup := seen[h.Anchor]; dup {
+ t.Errorf("%s.md: headings %q and %q both anchor to #%s",
+ p.Slug, prev, h.Text, h.Anchor)
+ }
+ seen[h.Anchor] = h.Text
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run 'TestInternalLinks|TestLinkCheck|TestAnchorsAreUnique' -count=1
+```
+
+Expected: PASS — two skipped, `TestLinkCheckCatchesADeadFragment` passing against the testdata corpus.
+
+- [ ] **Step 3: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add internal/docsite/links_test.go
+git commit -m "docsite: gate internal links, their fragments, and anchor uniqueness
+
+The fragment half is the one that earns its keep: a link to a renamed
+heading still looks right in review and still lands the reader at the
+top of the wrong page.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: The shared anchor fixture
+
+**Files:**
+- Create: `docs/site/anchors.json`
+- Modify: `internal/docsite/links_test.go` (append)
+
+**Interfaces:**
+- Consumes: `Anchor` from Task 1.
+- Produces: `docs/site/anchors.json` — a flat `{heading: anchor}` object. `hack/check-docs.mjs` in Task 11 reads this same file. It is the contract between the two languages.
+
+- [ ] **Step 1: Write the fixture**
+
+`docs/site/anchors.json`:
+
+```json
+{
+ "Getting started": "getting-started",
+ "The `carlos ship` contract": "the-carlos-ship-contract",
+ "404, never 403": "404-never-403",
+ "Routes & instances": "routes-instances",
+ " Leading and trailing ": "leading-and-trailing",
+ "Hyphen-joined words": "hyphen-joined-words",
+ "`-app` and `--app`": "app-and-app",
+ "canary/rehearsal": "canary-rehearsal",
+ "X-Carlos-Version": "x-carlos-version"
+}
+```
+
+Every case is a shape that actually occurs in CARLOS headings: backticked flags, a slashed channel name, a hyphenated HTTP header. `"`-app` and `--app`"` collapsing to `app-and-app` is the one to keep — it is the collision the uniqueness gate exists to catch.
+
+- [ ] **Step 2: Write the failing test**
+
+Append to `internal/docsite/links_test.go`:
+
+```go
+// TestAnchorMatchesFixture pins the anchor rule itself. The website's
+// slugify reads the same file, so the two implementations assert
+// against a shared fixture rather than against each other.
+func TestAnchorMatchesFixture(t *testing.T) {
+ raw, err := os.ReadFile(filepath.Join(root, "anchors.json"))
+ if err != nil {
+ t.Fatalf("read fixture: %v", err)
+ }
+ var cases map[string]string
+ if err := json.Unmarshal(raw, &cases); err != nil {
+ t.Fatalf("parse fixture: %v", err)
+ }
+ if len(cases) < 5 {
+ t.Fatalf("fixture has %d cases — too few to pin anything", len(cases))
+ }
+ for in, want := range cases {
+ if got := Anchor(in); got != want {
+ t.Errorf("Anchor(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+```
+
+Add `"encoding/json"` and `"path/filepath"` to the imports.
+
+Note this test does **not** skip: `anchors.json` is created by this task, so it is always there to read. The other gates keep skipping until Task 8 because they need the `.md` pages — which is why their condition tests `nav.json` rather than the `docs/site` directory this task brings into existence.
+
+- [ ] **Step 3: Run to verify it passes**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestAnchorMatchesFixture -count=1 -v
+```
+
+Expected: PASS. If any case fails, the fixture is wrong, not `Anchor` — work out by hand what the rule in Global Constraints produces and fix the JSON.
+
+- [ ] **Step 4: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add docs/site/anchors.json internal/docsite/links_test.go
+git commit -m "docsite: pin the anchor rule in a fixture both languages read
+
+The website's slugify and docsite.Anchor have to agree exactly or a
+fragment link passes the Go gate and 404s in a browser. They now assert
+against docs/site/anchors.json rather than against each other, and the
+cases are shapes CARLOS headings actually take: backticked flags, a
+slashed channel name, an HTTP header.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Fence languages
+
+**Files:**
+- Create: `internal/docsite/fences_test.go`
+
+**Interfaces:**
+- Consumes: `Load`, `root`, `Page.Fences`.
+- Produces: `knownFenceLangs` — a package-level map Task 7 does not use but a later corpus task may extend.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package docsite
+
+import (
+ "os"
+ "testing"
+)
+
+// knownFenceLangs is what a fence in this corpus may declare. Adding a
+// language here is a deliberate act: an undeclared or unknown fence is
+// invisible to every content gate that reads fences, so it is a way to
+// smuggle a wrong command past review.
+var knownFenceLangs = map[string]bool{
+ "sh": true, "bash": true, "console": true, "json": true,
+ "go": true, "yaml": true, "toml": true, "ini": true,
+ "text": true, "http": true,
+}
+
+func TestFenceLanguagesAreDeclared(t *testing.T) {
+ if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site/nav.json")
+ }
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ for _, f := range p.Fences {
+ if f.Lang == "" {
+ t.Errorf("%s.md:%d: fence declares no language", p.Slug, f.Line)
+ continue
+ }
+ if !knownFenceLangs[f.Lang] {
+ t.Errorf("%s.md:%d: unknown fence language %q — add it to knownFenceLangs if it is real",
+ p.Slug, f.Line, f.Lang)
+ }
+ }
+ }
+}
+
+// TestTestdataFencesDeclareLanguages runs the same rule against the
+// testdata corpus, so the gate above is proven to work on a corpus that
+// exists before docs/site does.
+func TestTestdataFencesDeclareLanguages(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ for _, f := range p.Fences {
+ if f.Lang == "" {
+ t.Errorf("%s: testdata corpus has an undeclared fence at line %d", p.Slug, f.Line)
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestFence -count=1
+```
+
+Expected: PASS — one skipped, one passing.
+
+- [ ] **Step 3: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add internal/docsite/fences_test.go
+git commit -m "docsite: require every fence to declare a language
+
+An undeclared fence is invisible to the content gates that read fences,
+so it is the way a wrong command gets past review. The allowed list is
+short and extending it is deliberate.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Every command is documented
+
+**Files:**
+- Create: `internal/docsite/cliscan.go`
+- Create: `internal/docsite/cli_test.go`
+
+**Interfaces:**
+- Consumes: `Load`, `root`.
+- Produces: `TopLevelCommands(mainFile string) ([]string, error)` — the dispatch cases in `main.go`, sorted, deduped. Task 7 calls it.
+
+- [ ] **Step 1: Confirm what the scanner must find**
+
+```bash
+grep -n '^\s*case "' main.go | head -50
+```
+
+You should see `case "help", "-h", "--help":` through `case "canary":`. The scanner keeps bare lowercase words and drops anything starting with `-`, so `help` survives and `-h` does not.
+
+- [ ] **Step 2: Write the failing test**
+
+`internal/docsite/cli_test.go`:
+
+```go
+package docsite
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// mainFile is the CLI's dispatch, relative to this package.
+const mainFile = "../../main.go"
+
+// cliPage is the page that must document all of it.
+const cliPage = "cli"
+
+// TestTopLevelCommandsReadsTheDispatch checks the scanner before the
+// gate that depends on it. A scanner that silently returns nothing
+// turns the gate below into a test that always passes.
+func TestTopLevelCommandsReadsTheDispatch(t *testing.T) {
+ cmds, err := TopLevelCommands(mainFile)
+ if err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if len(cmds) < 20 {
+ t.Fatalf("found %d commands (%v) — the scanner is broken, not the docs", len(cmds), cmds)
+ }
+ for _, want := range []string{"ship", "promote", "deploy", "domains", "logs", "fleets"} {
+ if !contains(cmds, want) {
+ t.Errorf("scanner missed the %q command", want)
+ }
+ }
+ for _, unwanted := range []string{"-h", "--help", ""} {
+ if contains(cmds, unwanted) {
+ t.Errorf("scanner kept %q, which is not a command", unwanted)
+ }
+ }
+}
+
+func contains(xs []string, s string) bool {
+ for _, x := range xs {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
+
+// TestCLICommandsAreDocumented is the gate. It reads the dispatch
+// switch rather than the usage text on purpose: usage text is prose
+// someone can forget to update, so gating against it would only prove
+// the docs match a second copy of the docs. The switch is the CLI.
+func TestCLICommandsAreDocumented(t *testing.T) {
+ if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site/nav.json")
+ }
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ page, ok := site.BySlug[cliPage]
+ if !ok {
+ t.Fatalf("no %s page — every command has to be documented somewhere", cliPage)
+ }
+ cmds, err := TopLevelCommands(mainFile)
+ if err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ for _, cmd := range cmds {
+ if !strings.Contains(page.Body, "carlos "+cmd) {
+ t.Errorf("%s.md does not document %q (looked for %q)", cliPage, cmd, "carlos "+cmd)
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Run to verify it fails**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestTopLevel -count=1
+```
+
+Expected: build failure — `undefined: TopLevelCommands`.
+
+- [ ] **Step 4: Write the scanner**
+
+`internal/docsite/cliscan.go`:
+
+```go
+package docsite
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "sort"
+ "strconv"
+)
+
+// TopLevelCommands returns every subcommand main.go dispatches on.
+//
+// It reads the switch statement rather than the usage text because the
+// switch is the CLI and the usage text is prose about it. A gate
+// against the prose would prove only that the docs agree with a second
+// copy of the docs, which is exactly the drift worth catching.
+//
+// Flags are deliberately not scanned. rastrillo's equivalent gate does
+// scan them, but its CLI is one small package; CARLOS declares flags
+// across the whole root package and the list would run to hundreds,
+// most of them things no reader ever types.
+func TopLevelCommands(mainFile string) ([]string, error) {
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, mainFile, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return nil, err
+ }
+
+ seen := map[string]bool{}
+ var out []string
+ ast.Inspect(file, func(n ast.Node) bool {
+ sw, ok := n.(*ast.SwitchStmt)
+ if !ok {
+ return true
+ }
+ for _, stmt := range sw.Body.List {
+ clause, ok := stmt.(*ast.CaseClause)
+ if !ok {
+ continue
+ }
+ for _, expr := range clause.List {
+ s, ok := stringLit(expr)
+ if !ok || !isCommandWord(s) {
+ continue
+ }
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ }
+ return true
+ })
+ sort.Strings(out)
+ return out, nil
+}
+
+// isCommandWord filters case arms down to real commands: flag aliases
+// like "-h" are documented as part of the command they belong to, and a
+// case arm is only a command if it looks like one.
+func isCommandWord(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if (r < 'a' || r > 'z') && r != '-' {
+ return false
+ }
+ }
+ return s[0] != '-'
+}
+
+func stringLit(e ast.Expr) (string, bool) {
+ lit, ok := e.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return "", false
+ }
+ s, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ return "", false
+ }
+ return s, true
+}
+```
+
+- [ ] **Step 5: Run to verify it passes**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run 'TestTopLevel|TestCLICommands' -count=1 -v
+```
+
+Expected: `TestTopLevelCommandsReadsTheDispatch` PASS, `TestCLICommandsAreDocumented` SKIP.
+
+If the scanner returns fewer than 20, check whether `main.go` dispatches from a helper rather than a top-level switch, and widen `ast.Inspect` to that function — do not lower the threshold.
+
+- [ ] **Step 6: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add internal/docsite/cliscan.go internal/docsite/cli_test.go
+git commit -m "docsite: fail the build when a command has no docs
+
+Reads main.go's dispatch switch as an AST, not its usage text — gating
+against the prose would only prove the docs match a second copy of the
+docs. Flags stay out of scope: rastrillo can scan them because its CLI
+is one small package, and CARLOS's root package would produce hundreds.
+
+The scanner has its own test, because a scanner that silently finds
+nothing turns the gate into one that always passes.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: Every `carlos` invocation in a fence is real
+
+**Files:**
+- Modify: `internal/docsite/fences_test.go` (append)
+
+**Interfaces:**
+- Consumes: `TopLevelCommands` from Task 6, `Page.Fences` from Task 1.
+- Produces: nothing later tasks use.
+
+This is the gate rastrillo does not have. A renamed verb currently breaks nothing until a reader types it, and docs full of copy-pasteable examples make that worse.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/docsite/fences_test.go`:
+
+```go
+// TestFencedCarlosCommandsAreReal checks the examples, not just the
+// prose. A renamed verb leaves every fence containing it copy-pasteable
+// and wrong, and nothing else in this package would notice.
+func TestFencedCarlosCommandsAreReal(t *testing.T) {
+ if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site/nav.json")
+ }
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ cmds, err := TopLevelCommands(mainFile)
+ if err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ real := map[string]bool{}
+ for _, c := range cmds {
+ real[c] = true
+ }
+
+ for _, p := range site.Pages() {
+ for _, f := range p.Fences {
+ for _, verb := range fencedCarlosVerbs(f.Body) {
+ if !real[verb] {
+ t.Errorf("%s.md:%d: example runs `carlos %s`, which is not a command",
+ p.Slug, f.Line, verb)
+ }
+ }
+ }
+ }
+}
+
+// fencedCarlosVerbs returns the word after each "carlos" in a fence
+// body. It skips lines where "carlos" is not the command being run —
+// a path like /opt/carlos/carlos, or prose inside a comment.
+func fencedCarlosVerbs(body string) []string {
+ var out []string
+ for _, line := range strings.Split(body, "\n") {
+ line = strings.TrimSpace(line)
+ line = strings.TrimPrefix(line, "$ ")
+ if i := strings.Index(line, "#"); i >= 0 {
+ line = strings.TrimSpace(line[:i])
+ }
+ fields := strings.Fields(line)
+ for i, f := range fields {
+ // Bare "carlos", or the binary called by an absolute path —
+ // runbooks on a box invoke /opt/carlos/carlos, and those
+ // examples go stale the same way.
+ if f != "carlos" && !strings.HasSuffix(f, "/carlos") {
+ continue
+ }
+ if i+1 >= len(fields) {
+ continue
+ }
+ next := fields[i+1]
+ if strings.HasPrefix(next, "-") {
+ break
+ }
+ out = append(out, next)
+ break
+ }
+ }
+ return out
+}
+
+// TestFencedCarlosVerbsIgnoresNoise pins the parsing, because a
+// permissive extractor turns the gate above into one that finds
+// nothing.
+func TestFencedCarlosVerbsIgnoresNoise(t *testing.T) {
+ body := strings.Join([]string{
+ "carlos ship -app hello ./dist",
+ "$ carlos promote -app hello abc123 edge",
+ "/opt/carlos/carlos adopt",
+ "# run carlos deploy afterwards",
+ "carlos -h",
+ "echo carlos",
+ }, "\n")
+ got := fencedCarlosVerbs(body)
+ want := []string{"ship", "promote", "adopt"}
+ if len(got) != len(want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ }
+}
+```
+
+Add `"strings"` to the imports.
+
+- [ ] **Step 2: Run to verify**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestFenced -count=1 -v
+```
+
+Expected: `TestFencedCarlosCommandsAreReal` SKIP, `TestFencedCarlosVerbsIgnoresNoise` PASS.
+
+The `/opt/carlos/carlos adopt` case yields `adopt`: a field ending in `/carlos` is the binary called by absolute path, which is how the on-box runbooks invoke it, and those examples go stale the same way bare ones do. `echo carlos` yields nothing because no word follows, and `carlos -h` yields nothing because a flag is not a verb. The test pins all three.
+
+- [ ] **Step 3: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add internal/docsite/fences_test.go
+git commit -m "docsite: check the examples, not just the prose
+
+Every \`carlos <verb>\` inside a fence has to name a real command. A
+renamed verb otherwise leaves every example containing it copy-pasteable
+and wrong, and no other gate here would notice. The extractor has its
+own test — a permissive one would find nothing and pass forever.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: The real corpus, and the gates go live
+
+**Files:**
+- Create: `docs/site/nav.json`, `docs/site/index.md`, `docs/site/cli.md`
+- Modify: every `_test.go` in `internal/docsite/` — delete the skips
+- Modify: `AGENTS.md`
+
+**Interfaces:**
+- Consumes: every gate from Tasks 2–7.
+- Produces: a corpus the website repo vendors in Task 9.
+
+- [ ] **Step 1: Get the command list**
+
+```bash
+grep -o 'case "[a-z-]*"' main.go | sort -u
+```
+
+That is the list the gate will hold you to. Then read `main.go:138-204`, the `usage` function. It already groups every command by role — your apps, your deployment, on the host — with a one-line description each. That grouping is the CLI page's structure. Do not copy its wording; it is terse to fit a terminal, and the page has room to say what a command is for.
+
+- [ ] **Step 2: Write `docs/site/nav.json`**
+
+```json
+{
+ "sections": [
+ {
+ "title": "Reference",
+ "pages": [
+ {
+ "slug": "cli",
+ "label": "The CLI",
+ "blurb": "Every carlos command, grouped by whether you are shipping an app, running a deployment, or on a box."
+ }
+ ]
+ }
+ ]
+}
+```
+
+The remaining thirteen pages arrive in the next plan. One section with one page is a complete, valid corpus, and it is what lets this plan ship something that works.
+
+- [ ] **Step 3: Write `docs/site/index.md`**
+
+Apply `humane-docs`. Open with what CARLOS is in two plain sentences — no "powerful", no "flexible". Say what is on this page set and what is not (Carloku customers want carloku.com/docs). Keep it under a screen. The index's own prose renders above the nav cards, so it does not need to list the pages.
+
+- [ ] **Step 4: Write `docs/site/cli.md`**
+
+One `##` heading per command, titled `carlos <command>`, in the three role groups `main.go` uses. For each: what it does in one or two sentences, the common invocation as one `sh` fence, and the one or two flags people actually change. Not every flag — `carlos <command> -h` prints those, and an exhaustive dump is the failure mode `humane-docs` names.
+
+Requirements the gates enforce, so check them before running:
+
+- Every command from `TopLevelCommands` appears as the literal string `carlos <command>`.
+- Every fence declares `sh`.
+- Every `carlos <verb>` inside a fence is a real command.
+- No two `##` headings anchor the same — `carlos route` and `carlos routes` are fine (`carlos-route` and `carlos-routes`), but check anything with punctuation.
+
+- [ ] **Step 5: Delete every skip**
+
+```bash
+grep -rn 'Task 8 creates docs/site' internal/docsite/
+```
+
+Remove those three-line blocks from all six tests. Drop the now-unused `"os"` import where nothing else uses it.
+
+- [ ] **Step 6: Run the whole package**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -count=1 -v
+```
+
+Expected: every test PASS, none skipped. Failures here are real — a missing command, a dead fragment, a duplicate anchor. Fix the markdown, not the test.
+
+- [ ] **Step 7: Run the full gate**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1
+```
+
+Expected: all packages pass.
+
+- [ ] **Step 8: Document the corpus in AGENTS.md**
+
+Add to "Rules this repo has paid for":
+
+```text
+- **A new `carlos` command is not done until `docs/site/cli.md` names it.**
+ `internal/docsite` reads main.go's dispatch switch and fails the build over
+ a command with no docs, over a fenced example running a verb that no longer
+ exists, and over a `/docs` link whose page or `#fragment` does not resolve.
+ The corpus is rendered by `carlosframework/website`, which vendors a copy —
+ so a change here reaches the site only after that repo re-syncs and ships.
+```
+
+- [ ] **Step 9: Commit**
+
+```bash
+gofmt -l internal/docsite/
+git add docs/site/ internal/docsite/ AGENTS.md
+git commit -m "docs: the corpus, and the gates stop skipping
+
+Two pages — the index and the CLI reference — which is a complete corpus
+and enough to turn every gate on for real. The other thirteen pages come
+next.
+
+The CLI page is written from main.go's grouping but not its wording: the
+usage text is terse because it has to fit a terminal, and a reference
+page has room to say what a command is for.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 9: The website build
+
+**Files (all in `~/github.com/carlosframework/website`, branch `docs-site`):**
+- Create: `package.json`, `eleventy.config.js`, `.gitignore`
+- Create: `src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk`, `src/_includes/docs.njk`
+- Create: `hack/sync-docs.mjs`
+
+**Interfaces:**
+- Consumes: `docs/site/` from Task 8 in a platform checkout.
+- Produces: `src/docs/**.md`, `src/_data/docsnav.json`, `src/_data/docsanchors.json`, `src/_data/docsversion.json` — all vendored, all gitignored from hand-editing by convention and by a header comment.
+
+- [ ] **Step 1: Read the reference**
+
+Read all of `~/github.com/carlosframework/rastrillo-website/`: `eleventy.config.js`, `src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk`, `src/_includes/docs.njk`, `hack/sync-docs.mjs`. You are porting them.
+
+Two things in `eleventy.config.js` are load-bearing and easy to lose:
+
+- `slugify` must match the rule in Global Constraints exactly. Task 11 asserts it against `docs/site/anchors.json`.
+- The `docsRawFor` filter must not be routed through `eleventyComputed`. A computed value in Nunjucks front matter is a string interpolation, which HTML-escapes it — an escaped apostrophe turns `carlos's` into `carlos-39-s` in every anchor derived from it, and the sub-nav ends up linking to ids no page has. The reference file carries this comment; keep it.
+
+- [ ] **Step 2: Write `package.json`**
+
+```json
+{
+ "name": "carlosframework-website",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "eleventy",
+ "serve": "eleventy --serve",
+ "check": "eleventy && node hack/check-docs.mjs",
+ "sync-docs": "node hack/sync-docs.mjs"
+ },
+ "devDependencies": {
+ "@11ty/eleventy": "^3.0.0",
+ "markdown-it": "^14.1.0",
+ "markdown-it-anchor": "^9.2.0"
+ }
+}
+```
+
+- [ ] **Step 3: Write `.gitignore`**
+
+```text
+node_modules/
+_site/
+```
+
+- [ ] **Step 4: Move the hand-written pages under `src/`**
+
+Eleventy's input directory is `src`. The existing `index.html`, `platform/index.html`, `site.css` and `favicon.svg` move there, and pass through unchanged:
+
+```bash
+mkdir -p src
+git mv index.html platform site.css favicon.svg src/
+```
+
+Add to `eleventy.config.js`:
+
+```js
+ eleventyConfig.addPassthroughCopy({ "src/site.css": "site.css" });
+ eleventyConfig.addPassthroughCopy({ "src/docs.css": "docs.css" });
+ eleventyConfig.addPassthroughCopy({ "src/favicon.svg": "favicon.svg" });
+ eleventyConfig.addPassthroughCopy({ "src/index.html": "index.html" });
+ eleventyConfig.addPassthroughCopy({ "src/platform": "platform" });
+```
+
+Verify after the first build that `_site/index.html` is byte-identical to the old `index.html`:
+
+```bash
+npx eleventy && diff <(git show HEAD:index.html) _site/index.html && echo "landing page unchanged"
+```
+
+- [ ] **Step 5: Port the templates and the sync script**
+
+`src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk` port verbatim from the reference — they contain no rastrillo-specific content beyond the layout name.
+
+`src/_includes/docs.njk` needs the breadcrumb changed from `CARLOS / rastrillo / docs` to `CARLOS / platform / docs`, and its `<head>` aligned with the existing `src/index.html` (same meta, same `site.css` link, plus `docs.css`).
+
+`hack/sync-docs.mjs` ports with three changes: source is `<checkout>/docs/site`, and it copies `anchors.json` to `src/_data/docsanchors.json` as well as `nav.json` to `src/_data/docsnav.json`.
+
+- [ ] **Step 6: Sync and build**
+
+```bash
+npm install
+node hack/sync-docs.mjs ~/.herdr/worktrees/platform/docs
+npx eleventy
+ls _site/docs/ _site/docs/cli/
+```
+
+Expected: `_site/docs/index.html`, `_site/docs/cli/index.html`, `_site/docs/cli.md`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "🤖 Eleventy, and /docs renders
+
+The pipeline rastrillo.org has run since its docs landed, ported here.
+The hand-written pages move under src/ and pass through untouched — the
+built index.html is byte-identical to the committed one, which the build
+step checks.
+
+The corpus is vendored from the platform repo by hack/sync-docs.mjs,
+which records the sha it came from, so this build never needs a Go
+checkout present.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 10: The docs skin
+
+**Files:**
+- Create: `src/docs.css` (website repo)
+
+**Interfaces:**
+- Consumes: the class names `src/_includes/docs.njk` emits.
+- Produces: nothing later tasks use.
+
+- [ ] **Step 1: Read the house style**
+
+Read `AGENTS.md` "House style" and `src/site.css`. The world is: type from Modern Font Stacks with Neo-Grotesque carrying the voice and Monospace Code carrying identifiers and measurement only; platform blue `#0d5f88` light / `#59bfe8` dark; `--wrap: 72rem`, `--prose: 40rem`, body copy near 36rem; hairline rules divide, and nothing is a card unless being a card is a choice.
+
+The docs index's nav entries **are** cards — one per page with a label and a blurb, which is a deliberate use, not page structure.
+
+- [ ] **Step 2: Write `src/docs.css`**
+
+A sidebar layout at `--wrap`, collapsing to a single column under about 60rem with the sidebar above the content. Reuse `site.css`'s tokens; define nothing it already defines. Both `prefers-color-scheme` and the `[data-theme]` overrides that `site.css` already handles must keep working — check both.
+
+- [ ] **Step 3: Look at it**
+
+```bash
+npm run serve
+```
+
+Open `http://localhost:8080/docs/` and `http://localhost:8080/docs/cli/`. Check: the sidebar marks the current page; the sub-nav lists the page's `##` headings and its links work; long fences scroll inside their own box rather than the page scrolling sideways; light and dark both read; a narrow viewport is not broken. Compare against `/` — sibling pages, visibly related, not identical.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/docs.css
+git commit -m "🤖 The docs skin
+
+Sidebar at --wrap, prose at --prose, platform blue, hairline rules. The
+index's nav entries are cards because a label-plus-blurb list is what a
+card is for; nothing else here is one.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 11: The rendered-output gate and the ship runbook
+
+**Files:**
+- Create: `hack/check-docs.mjs` (website repo)
+- Modify: `AGENTS.md` (website repo)
+
+**Interfaces:**
+- Consumes: `_site/`, `src/_data/docsnav.json`, `src/_data/docsanchors.json`, and the exported `slugify` from `eleventy.config.js`.
+- Produces: `npm run check`, the command the ship runbook depends on.
+
+- [ ] **Step 1: Port `check-docs.mjs`**
+
+Port from `~/github.com/carlosframework/rastrillo-website/hack/check-docs.mjs`, with one change: the anchor fixture is not inlined. Read it from the vendored file, so the Go and JS sides assert against one artifact:
+
+```js
+// 3. The anchor rule agrees on both sides. The Go gate accepted these
+// fragments; if the renderer slugifies differently they are dead
+// links no earlier check can see. The fixture is the platform repo's
+// docs/site/anchors.json, vendored — both languages read this file
+// rather than each other's implementation.
+const { slugify } = await import("../eleventy.config.js");
+const fixture = JSON.parse(readFileSync("src/_data/docsanchors.json", "utf8"));
+if (Object.keys(fixture).length < 5) {
+ fail("anchor fixture has too few cases to pin anything — did sync-docs run?");
+}
+for (const [input, want] of Object.entries(fixture)) {
+ const got = slugify(input);
+ if (got !== want) {
+ fail(`slugify(${JSON.stringify(input)}) = ${JSON.stringify(got)}, want ${JSON.stringify(want)} — the renderer and internal/docsite.Anchor disagree`);
+ }
+}
+```
+
+- [ ] **Step 2: Prove the gate fires**
+
+Temporarily break `slugify` in `eleventy.config.js` — make it return `text.toLowerCase()` unchanged — then:
+
+```bash
+npm run check
+```
+
+Expected: exit 1, with `the renderer and internal/docsite.Anchor disagree` naming a case. Revert the change and re-run; expected `docs ok`.
+
+Do the same for a dead link: add `[nope](/docs/nowhere)` to a vendored page, rebuild, confirm `check-docs.mjs` names it, then remove it.
+
+- [ ] **Step 3: Write the ship runbook**
+
+In the website repo's `AGENTS.md`, under "Building the docs" — the section the earlier commit already promises:
+
+````markdown
+## Building the docs
+
+`/docs` is built from markdown that lives in the **platform** repo at
+`docs/site/`, where its Go tests gate it against the CLI. Nothing under
+`src/docs/` or `src/_data/docs*.json` is edited here — `sync-docs.mjs`
+overwrites all of it.
+
+```
+npm install
+node hack/sync-docs.mjs ~/github.com/carlosframework/platform
+npm run check # builds, then checks links, ids and the anchor rule
+npm run serve # localhost:8080
+```
+
+`node hack/sync-docs.mjs <checkout> --check` reports whether the vendored
+copy is behind without writing anything.
+
+## Deploying
+
+**This repo has a build step, so never ship the repo tree.** A tree ship
+has no `_site/` in it and takes /docs off the internet. Ship the built
+output of a clean export:
+
+```
+mkdir -p /tmp/website-ship
+git archive <sha> --prefix=export/ | tar -x -C /tmp/website-ship
+cd /tmp/website-ship/export && npm ci && npm run check
+
+export AWS_PROFILE=keymail AWS_REGION=eu-west-1 \
+ CARLOS_DEPLOYMENT_BUCKET=carlos-flagship-271376211898
+carlos ship -app website -kind static -version <sha> _site
+CARLOS_RELEASE_KEY=$(aws ssm get-parameter --name /carlos/release-key \
+ --with-decryption --query Parameter.Value --output text) \
+ carlos promote -app website <sha> <channel>
+```
+
+Verify by content, and against the docs specifically — the landing page
+will look right whether or not /docs made it into the artifact:
+
+```
+curl -s https://carlosframework.com/docs/ | grep -i "the cli"
+```
+````
+
+Replace `<app>` and `<channel>` with the values the existing runbook in this file already uses; read it rather than assuming. If the existing runbook says to ship the tree, delete that wording — leaving both is how someone ships the wrong one.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add hack/check-docs.mjs AGENTS.md
+git commit -m "🤖 Check the rendered output, and never ship the tree again
+
+check-docs.mjs covers what the Go gate cannot: that rendering produced
+the pages and ids the links point at, and that slugify agrees with
+docsite.Anchor. The fixture is the vendored docs/site/anchors.json, so
+both languages assert against one artifact instead of two copies.
+
+The ship runbook now says to ship _site/ of a clean export, and the old
+ship-the-tree wording is gone rather than sitting alongside it — a tree
+ship would take /docs off the internet.
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 12: Open both PRs
+
+- [ ] **Step 1: Full platform gate**
+
+```bash
+cd ~/.herdr/worktrees/platform/docs
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1
+gofmt -l $(git diff --name-only origin/main...HEAD | grep '\.go$')
+```
+
+Expected: all packages pass, `gofmt -l` empty.
+
+- [ ] **Step 2: Full website gate**
+
+```bash
+cd ~/github.com/carlosframework/website && npm ci && npm run check
+```
+
+Expected: `docs ok`.
+
+- [ ] **Step 3: Push and open the platform PR first**
+
+The website's sync script vendors from the platform repo, so that PR is the one that has to exist first.
+
+```bash
+cd ~/.herdr/worktrees/platform/docs
+git push -u origin docs
+gh pr create --title "docs: the corpus, its gates, and the CLI reference" --body "$(cat <<'BODY'
+Adds `internal/docsite` and the first two pages of `docs/site/`.
+
+The gate that matters: a PR adding a `carlos` command now fails until
+`docs/site/cli.md` names it. It reads main.go's dispatch switch as an
+AST rather than its usage text, because gating against the prose would
+only prove the docs match a second copy of the docs. Fenced examples are
+checked too — every `carlos <verb>` in a code block has to be real.
+
+Also in here: AGENTS.md replaces CLAUDE.md as the canonical agent file,
+per Paul's project-wide rule, with a pointer left behind.
+
+Spec: `docs/superpowers/specs/2026-08-24-docs-sites-design.md`
+Plan: `docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md`
+
+The remaining thirteen pages, and carloku.com/docs, are the next two plans.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+BODY
+)"
+```
+
+- [ ] **Step 4: Push and open the website PR**
+
+```bash
+cd ~/github.com/carlosframework/website
+git push -u origin docs-site
+gh pr create --title "🤖 /docs: Eleventy, the docs skin, and a ship runbook that does not lose them" --body "$(cat <<'BODY'
+Renders carlosframework.com/docs from the platform repo's corpus, using
+the pipeline rastrillo.org already runs.
+
+**This repo now has a build step**, which means shipping the repo tree
+would put a site with no /docs in it on the internet. The runbook in
+AGENTS.md is rewritten to ship `_site/` of a clean export, and the old
+wording is deleted rather than left alongside it.
+
+The hand-written pages move under `src/` and pass through untouched — the
+built `index.html` is byte-identical to the committed one.
+
+Depends on carlosframework/platform#<N>, which carries the corpus.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+BODY
+)"
+```
+
+- [ ] **Step 5: Do not merge**
+
+Both PRs wait for Paul. The website one changes how the site is deployed, which is exactly the diff the PR rule exists to put in front of a person.
+
+---
+
+## Self-Review
+
+**Spec coverage.** Corpus location, Task 8. Eleventy pipeline and the three templates, Task 9. Skin, Task 10. Sync script, Task 9. `docsmd.njk` raw-markdown output, Task 9. Nav gate, Task 2. Link and fragment gate, Task 3. Anchor uniqueness, Task 3. Shared fixture, Tasks 4 and 11. Fence languages, Task 5. Command-documented gate, Task 6. Fenced-invocation gate, Task 7. Ship runbook, Task 11. No search, no versioning, no console API reference — all absent by design.
+
+Two spec items are deliberately **not** in this plan and belong to the next two: the remaining thirteen carlosframework.com pages, and everything Carloku. The five-line CI workflow the spec leaves as a separate decision is also not here.
+
+**Placeholders.** One remains and is marked: `<app>` and `<channel>` in the website ship runbook, Task 11 Step 3, which instructs the implementer to read the existing runbook rather than guess. The PR body in Task 12 Step 4 carries `#<N>` for the platform PR number, which does not exist until Step 3 runs.
+
+**Type consistency.** `Load`, `Site`, `Page`, `Heading`, `Link`, `Fence`, `Anchor`, `MarkdownFiles` defined in Task 1 and used unchanged in 2–8. `TopLevelCommands(mainFile string) ([]string, error)` defined in Task 6, called in 6 and 7. `root` defined in Task 2, used in 3–8. `testCorpus` defined in Task 1, used in 3 and 5. `hasAnchor` defined in Task 3, used only there. `stringLit` and `isCommandWord` defined once, in `cliscan.go`. `knownFenceLangs` defined in Task 5, not used elsewhere — noted in that task's Interfaces block so nobody looks for a consumer.
diff --git a/docs/superpowers/specs/2026-08-24-docs-sites-design.md b/docs/superpowers/specs/2026-08-24-docs-sites-design.md
new file mode 100644
index 000000000..1544d4d66
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-docs-sites-design.md
@@ -0,0 +1,248 @@
+# Two docs sites: carlosframework.com/docs and carloku.com/docs
+
+Date: 2026-08-24
+Status: approved design, not implemented
+
+CARLOS has no documentation. There is a CLI with thirty-odd commands, a
+console, a bucket format, a channel ladder and a self-hosting story, and the
+only way to learn any of it is to read `main.go`'s usage text, ask an agent, or
+find the one paragraph in a spec that mentions it. This builds the two docs
+sites that fix that, and the CI gate that stops them going stale.
+
+Two sites, because there are two audiences and they want different things. A
+Carloku customer wants to know how to attach a domain. Someone evaluating
+CARLOS wants to know what a bucket-as-control-plane actually means before they
+trust it with anything.
+
+## The split
+
+`carloku.com/docs` teaches the jobs a customer does — deploy, domains, config,
+storage, logs, the console, their account. It is complete enough that a paying
+customer never has to leave the site they bought from to do ordinary work.
+
+`carlosframework.com/docs` is the platform itself — how it works, how to run
+your own, and the full CLI reference. Carloku links out to it for reference and
+internals, and for nothing else.
+
+The overlap is real and the boundary is the reader's intent, not the subject.
+"Attach a custom domain to your app" is a Carloku page. "How the edge resolves
+a hostname to a route" is a carlosframework.com page. Both mention domains.
+
+## Where things live
+
+```
+carlosframework/platform
+ docs/site/**.md, docs/site/nav.json the OSS corpus
+ internal/docsite/ parser + the gates, Go tests
+
+carlosframework/website carlosframework.com
+ src/docs/**.md vendored from platform, never edited here
+ src/_data/docsnav.json vendored
+ src/docs.njk, docs-index.njk, docsmd.njk, _includes/docs.njk
+ hack/sync-docs.mjs, check-docs.mjs, check-anchors.mjs
+ _site/ gitignored, shipped
+
+carlosframework/carloku carloku.com
+ src/docs/**.md, src/_data/docsnav.json the Carloku corpus, authored here
+ (same layouts, same hack/ scripts minus sync-docs)
+ _site/ gitignored, shipped
+```
+
+The OSS corpus lives in the platform repo so that CI can hold it to the code:
+a PR that adds `carlos foo` fails until `foo` appears in the CLI reference.
+That gate is the single most valuable thing in this design, and it only works
+if the markdown and the code change in the same pull request.
+
+The Carloku corpus lives in the carloku repo. It is product copy about a hosted
+service — sign-in, the console, limits, support — and it has no Go code to be
+held against. It gets the JavaScript half of the gates instead.
+
+## The pipeline
+
+Eleventy with `markdown-it` and `markdown-it-anchor`, copied from
+`carlosframework/rastrillo-website`, which has run this exact pipeline since
+rastrillo.org's docs landed. Three page templates and one layout, all of which
+already exist there and need skinning rather than writing:
+
+`docs.njk` paginates the corpus, one output page per markdown file, at
+`/docs/<slug>/`. `docs-index.njk` renders `/docs/` — the index page's own prose
+followed by a card per nav entry, showing its label and blurb. `docsmd.njk`
+publishes each page's raw markdown at `/docs/<slug>.md`, which costs nothing
+and is the version an agent should fetch.
+
+`_includes/docs.njk` is the shell: breadcrumb, sidebar of sections and pages,
+and a sub-nav of the current page's `##` headings. The sidebar is the whole
+navigation model — there is no search. Amadan ships client-side search for its
+docs; that costs served JavaScript, which both of these sites refuse, and
+fourteen pages with a visible sidebar do not need it.
+
+Each site gets its own `docs.css` alongside its existing `site.css`.
+carlosframework.com is Neo-Grotesque, platform blue, hairline rules, `--prose:
+40rem`. Carloku is Geist, its own accent, its own header. Same HTML, two skins;
+they should look like siblings and not like the same page.
+
+`hack/sync-docs.mjs` vendors the platform corpus into the website repo and
+records the sha it came from, so the site build never needs a Go checkout
+present and never fails because someone's platform clone is missing. Run it
+with `--check` to find out whether the vendored copy is behind.
+
+### Deploying
+
+Both sites gain a build step, so neither may ship its repo tree again. The
+sequence becomes the one rastrillo-website already documents: export a clean
+`git archive`, `npm ci && npm run check`, then `carlos ship -kind static` the
+`_site/` directory and promote it. `_site/` is gitignored. Both repos' AGENTS.md
+get the runbook, and the old "ship the tree" wording goes.
+
+This is the one genuinely disruptive part of the design. A person who has
+shipped carlosframework.com by muscle memory will ship a tree without a `/docs`
+directory in it and take the docs off the internet. The runbook change is not
+optional, and the ship command should be copied from AGENTS.md rather than
+recalled.
+
+## The gates
+
+Go, in `internal/docsite`, over the platform corpus:
+
+- **Nav and files agree both ways.** A nav entry with no file, or a file no nav
+ entry reaches, fails.
+- **Internal links resolve.** Every `/docs/...` link in the corpus names a page
+ that exists, and every `#fragment` names a heading that exists on it.
+- **Anchors are unique** within a page, and the Go slug rule matches
+ `markdown-it-anchor`'s configured `slugify`. The fixture is a JSON list of
+ heading-to-anchor pairs committed in the platform repo next to the corpus and
+ vendored with it, so both sides assert against the same file rather than
+ against each other's implementation. Both are lowercase alphanumerics with
+ everything else a separator, because the library's default URI-encodes
+ punctuation and would anchor a heading naming `carlos ship -app` differently
+ on each side.
+- **Fence languages are declared.** No bare triple-backticks.
+- **Every top-level command is documented.** The list comes from `main.go`'s
+ dispatch switch, read as a Go AST — not from its usage text. Gating against
+ the usage prose would only prove the docs match a second copy of the docs;
+ the switch statement is the CLI. Each case must appear in the CLI reference
+ page. Flags are deliberately not gated: rastrillo can, because its CLI is one
+ small package, but CARLOS declares flags across forty files in the root
+ package and the list would be hundreds long.
+- **Every `carlos` invocation in a fence names a real command.** This is the
+ gate rastrillo does not have and CARLOS needs: a renamed verb currently
+ breaks nothing until a reader types it.
+
+JavaScript, in both site repos, run by `npm run check`: Eleventy builds, then
+`check-docs.mjs` checks what the Go gate cannot — that rendering actually
+produced the pages and the ids those links point at, and that the renderer's
+`slugify` agrees with `docsite.Anchor` on the shared fixture. A link can be
+correct in the source and still 404 in a browser if the two disagree about how
+a heading becomes an anchor. For carloku this is the only gate, so it carries
+the nav and link checks on its own as well.
+
+Neither site repo has CI today and Actions minutes have been tight, so
+`npm run check` is a local step in the ship runbook. Adding a five-line
+workflow to each is cheap and I would rather have it, but it is a separate
+decision and the runbook does not depend on it.
+
+## carlosframework.com/docs
+
+**Start here** — what CARLOS is: one binary, a bucket and DNS, and what
+"bucket as control plane" buys you · installing the CLI: brew, apt, the direct
+download, and the Windows zip · your first deploy, end to end, against
+whichever CARLOS deployment you have access to.
+
+**How it works** — the bucket as control plane: what is under `apps/`,
+`control/` and `releases/`, and how a serial bump reaches a box in seconds ·
+releases and channels: immutable artifacts, the `canary → edge → beta → stable`
+ladder, what promoting does, what rollback does, and where the semver tag gets
+cut · routes and instances: what each one is, which fields the instance record
+owns once a route is `Provisioned`, and why `carlos route -channel` refuses
+them · sleeping and waking: hibernation, wake on demand, and what a visitor
+sees during one · the app contract: what a release must do to be servable,
+what `carlos vet` checks, and how static, exec and unit kinds differ.
+
+**Run your own** — self-hosting: bootstrap a box, the bucket, DNS, certificates
+and the console · fleets: account-owned boxes that dial a console, and the
+token rules that come with them · signing keys: the root key and scoped keys,
+and why the key never touches a terminal · operations: litestream,
+restore-verify, `carlos status -live`, and upgrading the platform binary.
+
+**Reference** — the CLI: every command grouped by role, generated against
+`main.go`'s usage and held there by the gate · bucket layout: the on-disk
+shape, for anyone writing a tool against it.
+
+Fourteen pages plus the index. Two I considered and cut: a contributing guide, which the repo
+does not want yet, and an architecture-decisions page, which is what
+`docs/superpowers/specs/` already is.
+
+## carloku.com/docs
+
+**Start here** — the index, pointing at the existing `/get-started` rather than
+restating it. `/get-started` stays where it is; it does marketing work as much
+as documentation work, and it is current.
+
+**Running your app** — deploying: `ship`, `promote`, `deploy`, and what "live"
+means · channels: how a release reaches production and how to put it back ·
+custom domains: attaching a hostname, the DNS you add, and how the certificate
+happens · env and secrets: the two stores, and which one a value belongs in ·
+object storage: creating a store, granting access, rotating · logs: the merged
+timeline, and following it live · restarting and sleeping: cycling an app, and
+what hibernation means for a customer.
+
+**Your account** — the console: what each page shows · members and service
+credentials: who can do what, and credentials a server holds rather than a
+person · limits and getting help: the ship rate limit, and where to ask.
+
+Ten pages plus the index. No pricing language anywhere — billing does not
+exist yet, the site says nothing about it, and #226 is unmerged.
+
+## Voice
+
+Both corpora are written against the `humane-docs` skill, and the revision pass
+it describes is a required step, not a nicety.
+
+Worth saying plainly, because the obvious move is to imitate it: rastrillo.org
+is the structural model and not the prose model. Its nav blurbs are a catalogue
+of what that skill tells you to cut — "its two callbacks", "the three
+middlewares", "the six refusals", "the one seam every owned query goes
+through", "— and why there is no Down". Every one of those is a sentence shape
+repeated until it stops carrying meaning. Take the sidebar and the card index;
+write the sentences fresh.
+
+Each page opens with what the thing is and why you would care, in plain
+sentences, then the common case as one runnable example. Configuration and edge
+cases come after, or not at all where `-h` already covers them.
+
+## What this does not do
+
+No search. No versioned docs — the platform ships from `main` and the docs
+describe what is live. No API reference for the console HTTP API; it is not a
+public interface yet and documenting it would imply a stability promise nobody
+has made. No generated CLI pages: the reference page is written by hand and
+gated against `main.go`, because generated usage dumps are the "exhaustive
+option dump" failure mode and read like one.
+
+## Risks
+
+**The Carloku corpus has a weaker gate than the platform corpus.** It gets the
+JavaScript link and nav checks and nothing that holds it to the console's
+behaviour. A console change that renames a page will not fail anything. Accepted:
+the alternative is a Go toolchain in a static site repo.
+
+**Docs describing unmerged work.** Several things a customer would look for are
+built but not rolled — `carlos instances set-channel` needs a console roll,
+`carlos email test` is broken as shipped (#265). Every page states what is live
+on the day it is written, and anything unrolled is left out rather than
+described in the future tense.
+
+**The ship runbook change can take the docs offline** if someone ships a repo
+tree out of habit. Covered above; the mitigation is the runbook and nothing
+cleverer.
+
+## Rollout
+
+1. `internal/docsite` and its gates, against an empty-but-valid corpus.
+2. The platform corpus: fifteen pages, written and gated.
+3. The website pipeline: layouts, skin, sync script, checks; then the runbook.
+4. The Carloku corpus and pipeline, same shape.
+5. Ship both, verify by content against the live URLs.
+
+Each of the three repos lands as its own pull request. The platform PR goes
+first, because the website's sync script vendors from it.
diff --git a/internal/docsite/cli_test.go b/internal/docsite/cli_test.go
new file mode 100644
index 000000000..3a5b7a393
--- /dev/null
+++ b/internal/docsite/cli_test.go
@@ -0,0 +1,77 @@
+package docsite
+
+import "testing"
+
+// mainFile is the CLI's dispatch, relative to this package.
+const mainFile = "../../main.go"
+
+// cliPage is the page that must document all of it.
+const cliPage = "cli"
+
+// TestTopLevelCommandsReadsTheDispatch checks the scanner before the
+// gate that depends on it. A scanner that silently returns nothing
+// turns the gate below into a test that always passes.
+func TestTopLevelCommandsReadsTheDispatch(t *testing.T) {
+ cmds, err := TopLevelCommands(mainFile)
+ if err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if len(cmds) < 20 {
+ t.Fatalf("found %d commands (%v) — the scanner is broken, not the docs", len(cmds), cmds)
+ }
+ for _, want := range []string{"ship", "promote", "deploy", "domains", "logs", "fleets"} {
+ if !contains(cmds, want) {
+ t.Errorf("scanner missed the %q command", want)
+ }
+ }
+ for _, unwanted := range []string{"-h", "--help", ""} {
+ if contains(cmds, unwanted) {
+ t.Errorf("scanner kept %q, which is not a command", unwanted)
+ }
+ }
+}
+
+func contains(xs []string, s string) bool {
+ for _, x := range xs {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
+
+// TestCLICommandsAreDocumented is the gate. It reads the dispatch
+// switch rather than the usage text on purpose: usage text is prose
+// someone can forget to update, so gating against it would only prove
+// the docs match a second copy of the docs. The switch is the CLI.
+//
+// It asks for a HEADING, not a substring of the body, and that is not
+// a stylistic preference. A substring check is defeated by prefixes:
+// "route" is a prefix of "routes", so the literal text "carlos routes"
+// anywhere on the page satisfies a Contains check for "route" — the
+// whole `### carlos route` section can be deleted with every test
+// still green (a reviewer did exactly that, 2026-08-25). The same
+// check also counts a mention inside another command's example fence,
+// or in a link, as documentation. A heading is the thing a reader can
+// actually land on, and `#carlos-route` and `#carlos-routes` are
+// distinct anchors. Do not "simplify" this back to Contains.
+func TestCLICommandsAreDocumented(t *testing.T) {
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ page, ok := site.BySlug[cliPage]
+ if !ok {
+ t.Fatalf("no %s page — every command has to be documented somewhere", cliPage)
+ }
+ cmds, err := TopLevelCommands(mainFile)
+ if err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ for _, cmd := range cmds {
+ if !hasAnchor(page, Anchor("carlos "+cmd)) {
+ t.Errorf("%s.md has no heading for %q (expected a heading anchoring #%s)",
+ cliPage, cmd, Anchor("carlos "+cmd))
+ }
+ }
+}
diff --git a/internal/docsite/cliscan.go b/internal/docsite/cliscan.go
new file mode 100644
index 000000000..56fe105e5
--- /dev/null
+++ b/internal/docsite/cliscan.go
@@ -0,0 +1,92 @@
+package docsite
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "sort"
+ "strconv"
+)
+
+// TopLevelCommands returns every subcommand main.go dispatches on.
+//
+// It reads the switch statement rather than the usage text because the
+// switch is the CLI and the usage text is prose about it. A gate
+// against the prose would prove only that the docs agree with a second
+// copy of the docs, which is exactly the drift worth catching.
+//
+// Flags are deliberately not scanned. rastrillo's equivalent gate does
+// scan them, but its CLI is one small package; CARLOS declares flags
+// across the whole root package and the list would run to hundreds,
+// most of them things no reader ever types.
+func TopLevelCommands(mainFile string) ([]string, error) {
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, mainFile, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return nil, err
+ }
+
+ seen := map[string]bool{}
+ var out []string
+ ast.Inspect(file, func(n ast.Node) bool {
+ sw, ok := n.(*ast.SwitchStmt)
+ if !ok {
+ return true
+ }
+ // Only the dispatch switch — the one on `cmd`. main.go has a
+ // single string switch today, but a second one added later
+ // would feed non-commands into the gate, and the obvious way
+ // to make that gate pass again is to document things that are
+ // not commands.
+ tag, ok := sw.Tag.(*ast.Ident)
+ if !ok || tag.Name != "cmd" {
+ return true
+ }
+ for _, stmt := range sw.Body.List {
+ clause, ok := stmt.(*ast.CaseClause)
+ if !ok {
+ continue
+ }
+ for _, expr := range clause.List {
+ s, ok := stringLit(expr)
+ if !ok || !isCommandWord(s) {
+ continue
+ }
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ }
+ return true
+ })
+ sort.Strings(out)
+ return out, nil
+}
+
+// isCommandWord filters case arms down to real commands: flag aliases
+// like "-h" are documented as part of the command they belong to, and a
+// case arm is only a command if it looks like one.
+func isCommandWord(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if (r < 'a' || r > 'z') && r != '-' {
+ return false
+ }
+ }
+ return s[0] != '-'
+}
+
+func stringLit(e ast.Expr) (string, bool) {
+ lit, ok := e.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return "", false
+ }
+ s, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ return "", false
+ }
+ return s, true
+}
diff --git a/internal/docsite/docsite.go b/internal/docsite/docsite.go
new file mode 100644
index 000000000..f16ae1e53
--- /dev/null
+++ b/internal/docsite/docsite.go
@@ -0,0 +1,331 @@
+// Package docsite loads the documentation corpus under docs/site and
+// exposes it in the shape the gates need.
+//
+// The corpus is markdown plus one nav.json holding the table of
+// contents. Everything here is parsing: the tests beside this file are
+// the point, and they are what stop the docs and the code drifting
+// apart. Nothing in this package ships in a binary — docs/site is
+// rendered by the website, which vendors a copy. This package exists so
+// CARLOS's own CI can fail a pull request whose docs no longer describe
+// the platform.
+//
+// The markdown parsing is deliberately small and deliberately
+// fence-aware. It is not a markdown implementation and must never grow
+// into one: it finds ATX headings, fenced code blocks and internal
+// links, and it knows that a "#" inside a shell fence is a comment
+// rather than a heading. Anything more belongs to the renderer.
+package docsite
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+)
+
+// Heading is one ATX heading, with the anchor the renderer will give
+// it. Anchor equivalence with the website's markdown-it-anchor
+// configuration is pinned from the website side; see Anchor.
+type Heading struct {
+ Level int
+ Text string
+ Anchor string
+}
+
+// Link is one internal /docs link found in a page's source, with the
+// line it sits on so a failure can name it.
+type Link struct {
+ Target string // page slug, e.g. "migrations" or "reference/db"
+ Fragment string // heading anchor, or "" for a whole-page link
+ Line int
+}
+
+// Fence is one fenced code block and the language on its info string.
+type Fence struct {
+ Lang string
+ Body string
+ Line int
+}
+
+// Page is one documentation page: its nav metadata, its source, and
+// everything the gates read out of it.
+type Page struct {
+ Slug string // "migrations", "reference/db"
+ Label string // nav label, usually shorter than Title
+ Blurb string // one line, rendered on the /docs index
+ Title string // the page's own "# " heading
+ Path string // path on disk
+
+ Body string
+ Headings []Heading
+ Links []Link
+ Fences []Fence
+}
+
+// Section is one group in the sidebar.
+type Section struct {
+ Title string
+ Pages []*Page
+}
+
+// Site is the whole corpus, in reading order.
+type Site struct {
+ // Index is docs/site/index.md, the /docs overview. It sits outside
+ // Sections because it is the page the sidebar's sections are listed
+ // on: an entry for itself would render as a card linking to the page
+ // you are already reading.
+ Index *Page
+ Sections []Section
+ BySlug map[string]*Page
+}
+
+// Pages returns every page in reading order, the index first.
+func (s *Site) Pages() []*Page {
+ var out []*Page
+ if s.Index != nil {
+ out = append(out, s.Index)
+ }
+ for _, sec := range s.Sections {
+ out = append(out, sec.Pages...)
+ }
+ return out
+}
+
+// navFile is nav.json's shape.
+type navFile struct {
+ Sections []struct {
+ Title string `json:"title"`
+ Pages []struct {
+ Slug string `json:"slug"`
+ Label string `json:"label"`
+ Blurb string `json:"blurb"`
+ } `json:"pages"`
+ } `json:"sections"`
+}
+
+// Load reads nav.json and every page it names. A page named by nav.json
+// that does not exist is an error here; a page that exists and is not
+// named is not — that asymmetry is deliberate, because the nav test
+// reports it with a better message than a load failure could.
+func Load(root string) (*Site, error) {
+ raw, err := os.ReadFile(filepath.Join(root, "nav.json"))
+ if err != nil {
+ return nil, fmt.Errorf("read nav.json: %w", err)
+ }
+ var nav navFile
+ if err := json.Unmarshal(raw, &nav); err != nil {
+ return nil, fmt.Errorf("parse nav.json: %w", err)
+ }
+
+ site := &Site{BySlug: map[string]*Page{}}
+ index, err := loadPage(root, "index", "Documentation", "")
+ if err != nil {
+ return nil, err
+ }
+ site.Index = index
+ site.BySlug[index.Slug] = index
+
+ for _, sec := range nav.Sections {
+ out := Section{Title: sec.Title}
+ for _, entry := range sec.Pages {
+ p, err := loadPage(root, entry.Slug, entry.Label, entry.Blurb)
+ if err != nil {
+ return nil, err
+ }
+ out.Pages = append(out.Pages, p)
+ site.BySlug[p.Slug] = p
+ }
+ site.Sections = append(site.Sections, out)
+ }
+ return site, nil
+}
+
+// loadPage reads one page off disk and parses it.
+func loadPage(root, slug, label, blurb string) (*Page, error) {
+ p := &Page{
+ Slug: slug,
+ Label: label,
+ Blurb: blurb,
+ Path: filepath.Join(root, filepath.FromSlash(slug)+".md"),
+ }
+ body, err := os.ReadFile(p.Path)
+ if err != nil {
+ return nil, fmt.Errorf("nav.json names %q: %w", slug, err)
+ }
+ p.Body = string(body)
+ parsePage(p)
+ return p, nil
+}
+
+// parsePage fills in everything derived from a page's source.
+func parsePage(p *Page) {
+ lines := strings.Split(p.Body, "\n")
+
+ inFence := false
+ fenceMark := ""
+ var fence Fence
+
+ for i, line := range lines {
+ lineNo := i + 1
+ trimmed := strings.TrimSpace(line)
+
+ // Fence open/close. The marker must match in kind and be at
+ // least as long as the opener, so a ``` inside a ~~~~ block is
+ // content rather than a close.
+ if mark, info, ok := fenceDelim(trimmed); ok {
+ switch {
+ case !inFence:
+ inFence, fenceMark = true, mark
+ fence = Fence{Lang: info, Line: lineNo}
+ continue
+ case sameFence(mark, fenceMark):
+ inFence = false
+ p.Fences = append(p.Fences, fence)
+ continue
+ }
+ }
+ if inFence {
+ fence.Body += line + "\n"
+ continue
+ }
+
+ if level, text, ok := heading(trimmed); ok {
+ h := Heading{Level: level, Text: text, Anchor: Anchor(text)}
+ p.Headings = append(p.Headings, h)
+ if level == 1 && p.Title == "" {
+ p.Title = text
+ }
+ }
+
+ for _, l := range docsLinks(line) {
+ l.Line = lineNo
+ p.Links = append(p.Links, l)
+ }
+ }
+}
+
+// fenceDelim reports whether a trimmed line opens or closes a fence,
+// returning the marker run and the info string.
+func fenceDelim(line string) (mark, info string, ok bool) {
+ var r byte
+ switch {
+ case strings.HasPrefix(line, "```"):
+ r = '`'
+ case strings.HasPrefix(line, "~~~"):
+ r = '~'
+ default:
+ return "", "", false
+ }
+ n := 0
+ for n < len(line) && line[n] == r {
+ n++
+ }
+ return line[:n], strings.TrimSpace(line[n:]), true
+}
+
+// sameFence reports whether a closing marker can close an opener: same
+// character, and at least as long.
+func sameFence(closing, opening string) bool {
+ if closing == "" || opening == "" || closing[0] != opening[0] {
+ return false
+ }
+ return len(closing) >= len(opening)
+}
+
+// heading parses an ATX heading.
+func heading(line string) (level int, text string, ok bool) {
+ n := 0
+ for n < len(line) && line[n] == '#' {
+ n++
+ }
+ if n == 0 || n > 6 || n >= len(line) || line[n] != ' ' {
+ return 0, "", false
+ }
+ return n, strings.TrimSpace(strings.TrimRight(line[n+1:], " #")), true
+}
+
+// docsLinks finds every markdown link whose target starts with /docs.
+func docsLinks(line string) []Link {
+ var out []Link
+ rest := line
+ for {
+ i := strings.Index(rest, "](/docs")
+ if i < 0 {
+ return out
+ }
+ rest = rest[i+2:]
+ end := strings.IndexByte(rest, ')')
+ if end < 0 {
+ return out
+ }
+ out = append(out, parseDocsHref(rest[:end]))
+ rest = rest[end:]
+ }
+}
+
+// parseDocsHref turns "/docs/reference/db#open" into its slug and
+// fragment. A bare "/docs" is the index, whose slug is "index".
+func parseDocsHref(href string) Link {
+ var l Link
+ if i := strings.IndexByte(href, '#'); i >= 0 {
+ l.Fragment, href = href[i+1:], href[:i]
+ }
+ href = strings.TrimSuffix(href, "/")
+ slug := strings.TrimPrefix(href, "/docs")
+ slug = strings.TrimPrefix(slug, "/")
+ if slug == "" {
+ slug = "index"
+ }
+ l.Target = path.Clean(slug)
+ return l
+}
+
+// Anchor is the heading anchor the rendered page will carry.
+//
+// The website configures markdown-it-anchor with a slugify that matches
+// this function, and both sides assert against docs/site/anchors.json.
+// Both are deliberately plain — lowercase alphanumerics, everything else
+// a separator — because markdown-it-anchor's own default URI-encodes
+// punctuation, which would anchor a heading naming `carlos ship -app`
+// differently on each side.
+func Anchor(text string) string {
+ var b strings.Builder
+ pendingSep := false
+ for _, r := range strings.ToLower(text) {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ if pendingSep && b.Len() > 0 {
+ b.WriteByte('-')
+ }
+ pendingSep = false
+ b.WriteRune(r)
+ default:
+ pendingSep = true
+ }
+ }
+ return b.String()
+}
+
+// MarkdownFiles walks root and returns every page slug on disk, with
+// "reference/" prefixes preserved and ".md" stripped.
+func MarkdownFiles(root string) ([]string, error) {
+ var out []string
+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() || !strings.HasSuffix(p, ".md") {
+ return nil
+ }
+ rel, err := filepath.Rel(root, p)
+ if err != nil {
+ return err
+ }
+ out = append(out, strings.TrimSuffix(filepath.ToSlash(rel), ".md"))
+ return nil
+ })
+ return out, err
+}
diff --git a/internal/docsite/fences_test.go b/internal/docsite/fences_test.go
new file mode 100644
index 000000000..f8a4f1be3
--- /dev/null
+++ b/internal/docsite/fences_test.go
@@ -0,0 +1,187 @@
+package docsite
+
+import (
+ "strings"
+ "testing"
+)
+
+// knownFenceLangs is what a fence in this corpus may declare. Adding a
+// language here is a deliberate act: an undeclared or unknown fence is
+// invisible to every content gate that reads fences, so it is a way to
+// smuggle a wrong command past review.
+var knownFenceLangs = map[string]bool{
+ "sh": true, "bash": true, "console": true, "json": true,
+ "go": true, "yaml": true, "toml": true, "ini": true,
+ "text": true, "http": true,
+}
+
+func TestFenceLanguagesAreDeclared(t *testing.T) {
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ for _, f := range p.Fences {
+ if f.Lang == "" {
+ t.Errorf("%s.md:%d: fence declares no language", p.Slug, f.Line)
+ continue
+ }
+ if !knownFenceLangs[f.Lang] {
+ t.Errorf("%s.md:%d: unknown fence language %q — add it to knownFenceLangs if it is real",
+ p.Slug, f.Line, f.Lang)
+ }
+ }
+ }
+}
+
+// TestTestdataFencesDeclareLanguages runs the same rule against the
+// testdata corpus, so the gate above is proven to work on a corpus that
+// exists before docs/site does.
+func TestTestdataFencesDeclareLanguages(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ for _, f := range p.Fences {
+ if f.Lang == "" {
+ t.Errorf("%s: testdata corpus has an undeclared fence at line %d", p.Slug, f.Line)
+ }
+ }
+ }
+}
+
+// TestFencedCarlosCommandsAreReal checks the examples, not just the
+// prose. A renamed verb leaves every fence containing it copy-pasteable
+// and wrong, and nothing else in this package would notice.
+func TestFencedCarlosCommandsAreReal(t *testing.T) {
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ cmds, err := TopLevelCommands(mainFile)
+ if err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ real := map[string]bool{}
+ for _, c := range cmds {
+ real[c] = true
+ }
+
+ for _, p := range site.Pages() {
+ for _, f := range p.Fences {
+ for _, verb := range fencedCarlosVerbs(f.Body) {
+ if !real[verb] {
+ t.Errorf("%s.md:%d: example runs `carlos %s`, which is not a command",
+ p.Slug, f.Line, verb)
+ }
+ }
+ }
+ }
+}
+
+// fencedCarlosVerbs returns the word after each "carlos" in a fence
+// body. It skips lines where "carlos" is not the command being run —
+// a path like /opt/carlos/carlos, or prose inside a comment.
+func fencedCarlosVerbs(body string) []string {
+ var out []string
+ for _, line := range strings.Split(body, "\n") {
+ line = strings.TrimSpace(line)
+ line = strings.TrimPrefix(line, "$ ")
+ // Treat # as starting a comment only when it is at index 0 or
+ // preceded by a space or tab. A # with a non-space before it
+ // (like a URL fragment) is not a comment.
+ if i := strings.Index(line, "#"); i >= 0 && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t') {
+ line = strings.TrimSpace(line[:i])
+ }
+ fields := strings.Fields(line)
+ for i, f := range fields {
+ // Bare "carlos", or the binary called by an absolute path —
+ // runbooks on a box invoke /opt/carlos/carlos, and those
+ // examples go stale the same way.
+ if f != "carlos" && !strings.HasSuffix(f, "/carlos") {
+ continue
+ }
+ if i+1 >= len(fields) {
+ continue
+ }
+ next := fields[i+1]
+ if strings.HasPrefix(next, "-") {
+ continue
+ }
+ if next == "\\" {
+ continue
+ }
+ out = append(out, next)
+ }
+ }
+ return out
+}
+
+// TestFencedCarlosVerbsIgnoresNoise pins the parsing, because a
+// permissive extractor turns the gate above into one that finds
+// nothing.
+func TestFencedCarlosVerbsIgnoresNoise(t *testing.T) {
+ body := strings.Join([]string{
+ "carlos ship -app hello ./dist",
+ "$ carlos promote -app hello abc123 edge",
+ "/opt/carlos/carlos adopt",
+ "# run carlos deploy afterwards",
+ "carlos -h",
+ "echo carlos",
+ }, "\n")
+ got := fencedCarlosVerbs(body)
+ want := []string{"ship", "promote", "adopt"}
+ if len(got) != len(want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ }
+}
+
+// TestFencedCarlosVerbsCompoundLines verifies the extractor finds
+// multiple carlos invocations on a single line (Finding 1 fix).
+func TestFencedCarlosVerbsCompoundLines(t *testing.T) {
+ body := "carlos ship -app hello ./dist && carlos promote -app hello abc123 edge"
+ got := fencedCarlosVerbs(body)
+ want := []string{"ship", "promote"}
+ if len(got) != len(want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ }
+}
+
+// TestFencedCarlosVerbsURLFragment verifies the extractor does not
+// mistake URL fragments for comments (Finding 2 fix).
+func TestFencedCarlosVerbsURLFragment(t *testing.T) {
+ body := "curl -s https://carlosframework.com/docs/#cli && carlos ship -app website ./dist"
+ got := fencedCarlosVerbs(body)
+ want := []string{"ship"}
+ if len(got) != len(want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ }
+}
+
+// TestFencedCarlosVerbsBackslashContinuation verifies the extractor
+// does not extract backslash as a verb in line-continued invocations
+// (Finding 3 fix).
+func TestFencedCarlosVerbsBackslashContinuation(t *testing.T) {
+ body := "carlos \\"
+ got := fencedCarlosVerbs(body)
+ want := []string{}
+ if len(got) != len(want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
diff --git a/internal/docsite/links_test.go b/internal/docsite/links_test.go
new file mode 100644
index 000000000..0bb99e500
--- /dev/null
+++ b/internal/docsite/links_test.go
@@ -0,0 +1,125 @@
+package docsite
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestInternalLinksResolve stops a docs link 404ing.
+//
+// It checks the fragment as well as the page, because a link to a
+// renamed heading is the failure that actually happens: the page still
+// exists, the link still looks right in review, and the reader lands at
+// the top of a long page wondering what they missed.
+func TestInternalLinksResolve(t *testing.T) {
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ for _, l := range p.Links {
+ target, ok := site.BySlug[l.Target]
+ if !ok {
+ t.Errorf("%s.md:%d: links to /docs/%s, which is not a page",
+ p.Slug, l.Line, l.Target)
+ continue
+ }
+ if l.Fragment == "" {
+ continue
+ }
+ if !hasAnchor(target, l.Fragment) {
+ t.Errorf("%s.md:%d: links to /docs/%s#%s, but %s has no heading anchoring %q",
+ p.Slug, l.Line, l.Target, l.Fragment, l.Target, l.Fragment)
+ }
+ }
+ }
+}
+
+func hasAnchor(p *Page, anchor string) bool {
+ for _, h := range p.Headings {
+ if h.Anchor == anchor {
+ return true
+ }
+ }
+ return false
+}
+
+// TestLinkCheckCatchesADeadFragment proves the fragment half fires.
+func TestLinkCheckCatchesADeadFragment(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ cli := site.BySlug["reference/cli"]
+ if cli == nil {
+ t.Fatal("no reference/cli page")
+ }
+ if !hasAnchor(cli, "carlos-ship") {
+ t.Error("expected reference/cli to anchor carlos-ship")
+ }
+ if hasAnchor(cli, "carlos-teleport") {
+ t.Error("hasAnchor invented an anchor that is not there")
+ }
+}
+
+// TestAnchorsAreUnique matters because a duplicate anchor makes a link
+// to it ambiguous, and the renderer resolves that by silently suffixing
+// the second one. The link then goes to the wrong section rather than
+// nowhere, which is harder to notice.
+func TestAnchorsAreUnique(t *testing.T) {
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, p := range site.Pages() {
+ seen := map[string]string{}
+ for _, h := range p.Headings {
+ if h.Level == 1 {
+ continue
+ }
+ if prev, dup := seen[h.Anchor]; dup {
+ t.Errorf("%s.md: headings %q and %q both anchor to #%s",
+ p.Slug, prev, h.Text, h.Anchor)
+ }
+ seen[h.Anchor] = h.Text
+ }
+ }
+}
+
+// TestAnchorMatchesFixture pins the anchor rule itself. The website's
+// slugify reads the same file, so the two implementations assert
+// against a shared fixture rather than against each other.
+//
+// One direction of that sharing is not symmetric: the website's
+// hack/sync-docs.mjs injects a "//" notice key into its vendored copy,
+// saying the file came from here and must not be edited there, and its
+// checker skips that key. This file must never carry one — Anchor("//")
+// is "", so a "//" key here fails the test below rather than being
+// ignored.
+//
+// The non-ASCII cases are the ones worth keeping: Go's strings.ToLower
+// and JavaScript's String.prototype.toLowerCase do not agree on every
+// code point, and both implementations DROP non-ASCII runes rather than
+// transliterating them — "Montréal" anchors as montr-al in both. Pin
+// anything with an em dash or an accented letter here before it reaches
+// a heading.
+func TestAnchorMatchesFixture(t *testing.T) {
+ raw, err := os.ReadFile(filepath.Join(root, "anchors.json"))
+ if err != nil {
+ t.Fatalf("read fixture: %v", err)
+ }
+ var cases map[string]string
+ if err := json.Unmarshal(raw, &cases); err != nil {
+ t.Fatalf("parse fixture: %v", err)
+ }
+ if len(cases) < 5 {
+ t.Fatalf("fixture has %d cases — too few to pin anything", len(cases))
+ }
+ for in, want := range cases {
+ if got := Anchor(in); got != want {
+ t.Errorf("Anchor(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/docsite/nav_test.go b/internal/docsite/nav_test.go
new file mode 100644
index 000000000..8f2d5d9b3
--- /dev/null
+++ b/internal/docsite/nav_test.go
@@ -0,0 +1,80 @@
+package docsite
+
+import "testing"
+
+// root is the real corpus, relative to this package. Every gate below
+// runs against it.
+const root = "../../docs/site"
+
+// TestNavAndFilesAgreeBothWays keeps the table of contents honest in
+// both directions. A page nav.json names must exist — Load fails on
+// that one — and a page on disk that no nav entry reaches must not. An
+// unreachable page is the worse failure: it looks written, and nobody
+// ever reads it.
+func TestNavAndFilesAgreeBothWays(t *testing.T) {
+ site, err := Load(root)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+
+ seen := map[string]bool{}
+ for _, p := range site.Pages() {
+ if seen[p.Slug] {
+ t.Errorf("%s: appears in nav.json more than once", p.Slug)
+ }
+ seen[p.Slug] = true
+
+ if p.Title == "" {
+ t.Errorf("%s: no `# ` title", p.Slug)
+ }
+ if p != site.Index && p.Blurb == "" {
+ t.Errorf("%s: no blurb in nav.json (it renders on the /docs index)", p.Slug)
+ }
+ if p != site.Index && p.Label == "" {
+ t.Errorf("%s: no label in nav.json", p.Slug)
+ }
+ }
+
+ files, err := MarkdownFiles(root)
+ if err != nil {
+ t.Fatalf("walk %s: %v", root, err)
+ }
+ for _, slug := range files {
+ if !seen[slug] {
+ t.Errorf("docs/site/%s.md exists but no nav.json entry reaches it", slug)
+ }
+ }
+}
+
+// TestOrphanPagesAreCaught proves the gate above can actually fail. The
+// check that never fires is the check nobody notices is broken.
+func TestOrphanPagesAreCaught(t *testing.T) {
+ site, err := Load("testdata/orphan")
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ seen := map[string]bool{}
+ for _, p := range site.Pages() {
+ seen[p.Slug] = true
+ }
+ files, err := MarkdownFiles("testdata/orphan")
+ if err != nil {
+ t.Fatalf("walk: %v", err)
+ }
+ var orphans []string
+ for _, slug := range files {
+ if !seen[slug] {
+ orphans = append(orphans, slug)
+ }
+ }
+ if len(orphans) != 1 || orphans[0] != "stranded" {
+ t.Errorf("orphans = %v, want [stranded]", orphans)
+ }
+}
+
+// TestNavNamingAMissingPageFails is the other direction.
+func TestNavNamingAMissingPageFails(t *testing.T) {
+ if _, err := Load("testdata/missing"); err == nil {
+ t.Fatal("Load succeeded on a nav.json naming a page that does not exist")
+ }
+}
diff --git a/internal/docsite/parse_test.go b/internal/docsite/parse_test.go
new file mode 100644
index 000000000..120b054bf
--- /dev/null
+++ b/internal/docsite/parse_test.go
@@ -0,0 +1,74 @@
+package docsite
+
+import "testing"
+
+const testCorpus = "testdata/corpus"
+
+func TestLoadReadsNavAndPages(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if site.Index == nil || site.Index.Title != "Documentation" {
+ t.Fatalf("index not loaded: %+v", site.Index)
+ }
+ if len(site.Sections) != 2 {
+ t.Fatalf("got %d sections, want 2", len(site.Sections))
+ }
+ if got := site.Sections[1].Pages[0].Slug; got != "reference/cli" {
+ t.Errorf("nested slug = %q, want reference/cli", got)
+ }
+ if got := len(site.Pages()); got != 3 {
+ t.Errorf("Pages() = %d, want 3 (index + two)", got)
+ }
+}
+
+func TestParsePageFindsHeadingsLinksAndFences(t *testing.T) {
+ site, err := Load(testCorpus)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ p := site.BySlug["getting-started"]
+ if p == nil {
+ t.Fatal("no getting-started page")
+ }
+ if len(p.Headings) != 3 {
+ t.Fatalf("got %d headings, want 3", len(p.Headings))
+ }
+ if p.Headings[1].Anchor != "install-the-cli" {
+ t.Errorf("anchor = %q, want install-the-cli", p.Headings[1].Anchor)
+ }
+ if len(p.Fences) != 1 || p.Fences[0].Lang != "sh" {
+ t.Fatalf("fences = %+v, want one sh fence", p.Fences)
+ }
+ if len(p.Links) != 1 || p.Links[0].Target != "reference/cli" {
+ t.Fatalf("links = %+v, want one to reference/cli", p.Links)
+ }
+}
+
+func TestIndexLinkResolvesToIndexSlug(t *testing.T) {
+ l := parseDocsHref("/docs")
+ if l.Target != "index" {
+ t.Errorf("parseDocsHref(\"/docs\") target = %q, want index", l.Target)
+ }
+ l = parseDocsHref("/docs/reference/cli#carlos-ship")
+ if l.Target != "reference/cli" || l.Fragment != "carlos-ship" {
+ t.Errorf("got %+v, want reference/cli #carlos-ship", l)
+ }
+}
+
+func TestMarkdownFilesWalksNested(t *testing.T) {
+ files, err := MarkdownFiles(testCorpus)
+ if err != nil {
+ t.Fatalf("walk: %v", err)
+ }
+ want := map[string]bool{"index": true, "getting-started": true, "reference/cli": true}
+ if len(files) != len(want) {
+ t.Fatalf("got %v, want %d files", files, len(want))
+ }
+ for _, f := range files {
+ if !want[f] {
+ t.Errorf("unexpected file %q", f)
+ }
+ }
+}
diff --git a/internal/docsite/testdata/corpus/getting-started.md b/internal/docsite/testdata/corpus/getting-started.md
new file mode 100644
index 000000000..af298324a
--- /dev/null
+++ b/internal/docsite/testdata/corpus/getting-started.md
@@ -0,0 +1,14 @@
+# Getting started
+
+## Install the CLI
+
+Run it:
+
+```sh
+carlos auth login
+# see [nothing](/docs/does-not-exist) — inside a fence, so not a link
+```
+
+## Deploy something
+
+See [the CLI reference](/docs/reference/cli).
diff --git a/internal/docsite/testdata/corpus/index.md b/internal/docsite/testdata/corpus/index.md
new file mode 100644
index 000000000..42c6d985e
--- /dev/null
+++ b/internal/docsite/testdata/corpus/index.md
@@ -0,0 +1,4 @@
+# Documentation
+
+Start with [getting started](/docs/getting-started), or jump to
+[the CLI](/docs/reference/cli#carlos-ship).
diff --git a/internal/docsite/testdata/corpus/nav.json b/internal/docsite/testdata/corpus/nav.json
new file mode 100644
index 000000000..96c4844f1
--- /dev/null
+++ b/internal/docsite/testdata/corpus/nav.json
@@ -0,0 +1,16 @@
+{
+ "sections": [
+ {
+ "title": "Start here",
+ "pages": [
+ { "slug": "getting-started", "label": "Getting started", "blurb": "Install it and deploy something." }
+ ]
+ },
+ {
+ "title": "Reference",
+ "pages": [
+ { "slug": "reference/cli", "label": "The CLI", "blurb": "Every command." }
+ ]
+ }
+ ]
+}
diff --git a/internal/docsite/testdata/corpus/reference/cli.md b/internal/docsite/testdata/corpus/reference/cli.md
new file mode 100644
index 000000000..65756ba51
--- /dev/null
+++ b/internal/docsite/testdata/corpus/reference/cli.md
@@ -0,0 +1,9 @@
+# The CLI
+
+## carlos ship
+
+Publish a release.
+
+```sh
+carlos ship -app hello -version 1.0.0 ./dist
+```
diff --git a/internal/docsite/testdata/missing/index.md b/internal/docsite/testdata/missing/index.md
new file mode 100644
index 000000000..25f8d4564
--- /dev/null
+++ b/internal/docsite/testdata/missing/index.md
@@ -0,0 +1 @@
+# Documentation
diff --git a/internal/docsite/testdata/missing/nav.json b/internal/docsite/testdata/missing/nav.json
new file mode 100644
index 000000000..6115d4411
--- /dev/null
+++ b/internal/docsite/testdata/missing/nav.json
@@ -0,0 +1 @@
+{ "sections": [ { "title": "Only", "pages": [ { "slug": "nowhere", "label": "Nowhere", "blurb": "Not on disk." } ] } ] }
diff --git a/internal/docsite/testdata/orphan/index.md b/internal/docsite/testdata/orphan/index.md
new file mode 100644
index 000000000..25f8d4564
--- /dev/null
+++ b/internal/docsite/testdata/orphan/index.md
@@ -0,0 +1 @@
+# Documentation
diff --git a/internal/docsite/testdata/orphan/kept.md b/internal/docsite/testdata/orphan/kept.md
new file mode 100644
index 000000000..8ecc772c4
--- /dev/null
+++ b/internal/docsite/testdata/orphan/kept.md
@@ -0,0 +1 @@
+# Kept
diff --git a/internal/docsite/testdata/orphan/nav.json b/internal/docsite/testdata/orphan/nav.json
new file mode 100644
index 000000000..11a0de71c
--- /dev/null
+++ b/internal/docsite/testdata/orphan/nav.json
@@ -0,0 +1 @@
+{ "sections": [ { "title": "Only", "pages": [ { "slug": "kept", "label": "Kept", "blurb": "Reachable." } ] } ] }
diff --git a/internal/docsite/testdata/orphan/stranded.md b/internal/docsite/testdata/orphan/stranded.md
new file mode 100644
index 000000000..d6547a55a
--- /dev/null
+++ b/internal/docsite/testdata/orphan/stranded.md
@@ -0,0 +1 @@
+# Stranded