carlos / platform Public

plan: stage 1 — the docsite gates and the website pipeline

Twelve tasks: the parser, six gates, a two-page corpus that turns them
on for real, then the Eleventy build, the skin, the rendered-output
check and both PRs.

Stage 1 deliberately ships something that works rather than scaffolding
— carlosframework.com/docs exists at the end of it, with an index and
the CLI reference. The other thirteen pages and all of Carloku are the
next two plans.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 37969ea58c24e8b966c291c32a3c0c10063de4d3 parent 104cbbe
1 file changed, +1608 −0
  • docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md +1608 −0
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..57d3be9e1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md
@@ -0,0 +1,1608 @@
+# 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); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site")
+ }
+```
+
+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"`.
+
+- [ ] **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); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site")
+ }
+ 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); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site")
+ }
+ 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 on a missing corpus: `anchors.json` is created by this task, so from here on `docs/site/` exists. Tasks 2, 3 and 5–7 keep their skips until Task 8, because they need the `.md` pages, not just the fixture.
+
+- [ ] **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); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site")
+ }
+ 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)
+ }
+ }
+ }
+}
+
+// TestFenceParsingHandlesNesting proves the parser does not close an
+// outer fence on an inner one, which would split one block into two and
+// hide the second half from every gate.
+func TestFenceParsingHandlesNesting(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); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site")
+ }
+ 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); os.IsNotExist(err) {
+ t.Skip("no corpus yet — Task 8 creates docs/site")
+ }
+ 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 {
+ if f != "carlos" || 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` — the path segment is one field, so `carlos` matches only the binary name. That is the behaviour we want, and the test pins it.
+
+- [ ] **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
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go run ./scripts/... 2>/dev/null || \
+ go doc ./internal/docsite TopLevelCommands
+```
+
+Simpler: 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.