Skip to content

Docs Starlight Migration — Phase 3 (Cutover) 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: Replace the MkDocs Material builder with Astro Starlight — isolated website/ subproject reading repo-root docs/ through a custom loader — preserving the Cloudflare Pages + Access deployment, wiring starlight-links-validator and a custom llms.txt coverage gate as permanent CI checks, and deleting mkdocs.yml. One big-bang PR, verified on a preview deploy.

Architecture: website/ holds all Node tooling (pnpm). A thin externalDocsLoader wraps Astro’s glob() with base ../docs + docsSchema() (ADR hl-6iz). The sidebar autogenerates per top-level directory, so every page is reachable by construction — the orphan check retires. A frontmatter-injection script (reusing Phase 1’s frontmatter.first_h1) gives all 129 files the title: Starlight requires, stripping the now-duplicate body H1. CI builds with pnpm and deploys website/dist to the same cluster-docs Pages project (Access policy untouched).

Tech Stack (pins grounded against npm this session): astro 6.4.4 · @astrojs/starlight 0.39.3 · starlight-links-validator 0.24.0 · starlight-llms-txt 0.10.0 · pnpm.

Design bead: hl-bit · Spec: docs/engineering/specs/2026-06-07-docs-starlight-migration-design.md · ADRs: hl-6iz, hl-25z

Model labels: Tasks 1, 4, 5, 6 → model:sonnet (config judgment / harness surgery). Tasks 2, 3, 7, 8 → model:haiku (mechanical).


Inputs (grounded this session against the groomed corpus)

Section titled “Inputs (grounded this session against the groomed corpus)”
SignalValueConsequence
Corpus129 files, orphans 0, internal links 0migrate everything; nothing to skip
Frontmatter0/129 files have any; all have clean H1s (audit title_attention = 0)Task 2 injects title: from H1 + strips the H1 — fully scripted
MkDocs-specific syntax3 admonitions, 0 tabs/snippets/details/keys (all heavy syntax died with the archive)Task 3 is three hand-edits using :::note directives; no MDX conversion anywhere
Admonition sitesarchitecture/decisions/index.md:39 (note) · plans/index.md:34 (tip) · plans/archive/index.md:13 (info→note)exact edit list
LoaderdocsLoader() base hardwired; custom glob() wrapper is the supported path (ADR hl-6iz)Task 1 builds it and verifies fidelity FIRST — it gates everything
Action SHAspnpm/action-setup v6.0.8 d15e628c…, setup-node v6.4.0 48b55a01… (resolved via gh api)Task 6 workflow

PathResponsibility
website/package.json, website/pnpm-lock.yamlNode deps, pinned; packageManager field
website/astro.config.mjssite URL, Starlight config: sidebar, editLink, plugins
website/src/loaders/docs.tsexternalDocsLoader — glob() with ../docs base
website/src/content.config.tsdocs collection: custom loader + docsSchema()
website/tsconfig.jsonAstro strict preset
website/scripts/check-llms-coverage.py → no — tools/docs-audit/llms_coverage.pycustom coverage gate (Python, reuses docs_audit)
tools/docs-audit/add_frontmatter.pyone-shot title injection (reuses frontmatter module)
tools/docs-audit/audit.py, docs_audit/orphans.py testsorphan check retires; title check inverts (must HAVE fm title)
.github/workflows/docs.ymlpnpm build, website/dist deploy, trigger paths
mkdocs.yml, tools/docs-audit/orphan-allowlist.txt, tools/docs-audit/groom.py (CLI), docs/README.mddeleted
CLAUDE.mdpointer updates (build command, llms.txt)

Task 1: Scaffold website/ + loader fidelity spike (GATES the rest)

Section titled “Task 1: Scaffold website/ + loader fidelity spike (GATES the rest)”

Files:

  • Create: website/package.json
  • Create: website/astro.config.mjs
  • Create: website/src/loaders/docs.ts
  • Create: website/src/content.config.ts
  • Create: website/tsconfig.json
  • Create: website/.gitignore
  • Delete: docs/README.md (contributor stub — superseded by docs/index.md; deleting beats fighting any-depth glob negation)
  • Step 1: Write the scaffold files (and delete docs/README.md)

Create website/package.json:

{
"name": "cluster-docs-website",
"private": true,
"type": "module",
"packageManager": "pnpm@10.12.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/starlight": "0.39.3",
"astro": "6.4.4",
"sharp": "^0.34.0",
"starlight-links-validator": "0.24.0",
"starlight-llms-txt": "0.10.0"
}
}

Create website/src/loaders/docs.ts:

import { glob } from 'astro/loaders';
import type { Loader, LoaderContext } from 'astro/loaders';
/**
* Starlight's docsLoader() hardwires its base to srcDir/content/<collection>,
* so it cannot read the repo-root docs/ tree. This thin wrapper points
* Astro's glob() loader at an external base instead (ADR hl-6iz).
*
* No exclusions: bare-basename negations like `!README.md` match at ANY
* depth in tinyglobby/picomatch (would silently drop adr/README.md, the
* ADR index). The top-level docs/README.md contributor stub is DELETED in
* this task instead. adr/README.md becomes a regular page (raw glob has no
* docsLoader index-aliasing, so its slug is /adr/readme — acceptable: it is
* a listing page inside the autogenerated ADRs group, not a group index).
*/
export function externalDocsLoader(externalPath: string): Loader {
return {
name: 'external-docs-loader',
load: (ctx: LoaderContext) =>
glob({
base: new URL(externalPath, ctx.config.root),
pattern: '**/*.{md,mdx}',
}).load(ctx),
};
}

Create website/src/content.config.ts:

import { defineCollection } from 'astro:content';
import { docsSchema } from '@astrojs/starlight/schema';
import { externalDocsLoader } from './loaders/docs';
export const collections = {
docs: defineCollection({ loader: externalDocsLoader('../docs/'), schema: docsSchema() }),
};

Create website/astro.config.mjs (plugins arrive in Task 4 — keep the spike minimal):

import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
site: 'https://cluster-docs.docs.fzymgc.house',
integrations: [
starlight({
title: 'fzymgc-house Cluster Docs',
editLink: {
baseUrl: 'https://github.com/fzymgc-house/selfhosted-cluster/edit/main/docs/',
},
sidebar: [
{ label: 'Getting Started', autogenerate: { directory: 'getting-started' } },
{ label: 'Operations', autogenerate: { directory: 'operations' } },
{ label: 'Reference', autogenerate: { directory: 'reference' } },
{ label: 'Architecture', autogenerate: { directory: 'architecture' } },
{ label: 'ADRs', autogenerate: { directory: 'adr' } },
{ label: 'Design Plans', autogenerate: { directory: 'plans' } },
{ label: 'Engineering', autogenerate: { directory: 'superpowers' } },
],
}),
],
});

Create website/tsconfig.json:

{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "src/**/*"],
"exclude": ["dist"]
}

Create website/.gitignore:

node_modules/
dist/
.astro/

Also delete the contributor stub: rm docs/README.md (jj auto-tracks the deletion; corpus is now 129 files — 130 incl. this plan, minus the README). All page-count expectations below assume 129; if docs changed since planning, recompute the reference count once with fd -e md . docs | wc -l and substitute it consistently.

  • Step 2: Install + run the SPIKE build

Run: cd website && pnpm install && pnpm build 2>&1 | tail -15 Expected at this point: the build STARTS and ingests ../docs but fails on missing title frontmatter (docsSchema requires it; 0/129 files have it). That failure IS the spike’s positive signal — it proves the external loader resolved and parsed the corpus. A failure like “no entries / collection empty / cannot resolve base” instead means the loader didn’t fire: STOP and fall back to Approach A (Astro at repo root) per ADR hl-6iz.

  • Step 3: Fidelity probe with two temp titles

Pick two files, temporarily add minimal frontmatter (---\ntitle: Probe\n---) to docs/index.md and docs/operations/vault.md, then run: pnpm build 2>&1 | rg -c 'error' ; ls dist/operations/vault/index.html 2>/dev/null && echo "slug fidelity ✓" Expected: dist/operations/vault/index.html exists → slugs map docs/operations/vault.md → /operations/vault/. Open dist/operations/vault/index.html and rg -o 'edit/main/docs/[^"]+' dist/operations/vault/index.htmledit/main/docs/operations/vault.md (edit-link fidelity ✓). Revert the two temp frontmatter edits (jj restore docs/index.md docs/operations/vault.md).

(Once Task 2 lands, additionally confirm ls dist/adr/readme/index.html — the ADR index page made it through the unfiltered glob.)

  • Step 4: Commit the scaffold

jj commit -m "feat(website): Starlight scaffold + externalDocsLoader spike passes [hl-bit]"


Files:

  • Create: tools/docs-audit/add_frontmatter.py
  • Modify: docs/**/*.md (all 129)
  • Step 1: Write the injection script

Create tools/docs-audit/add_frontmatter.py:

"""One-shot: give every docs/**/*.md a frontmatter title from its first H1.
Starlight's docsSchema requires `title`. The body H1 is REMOVED after
promotion — Starlight renders the title as the page H1, so leaving it
would duplicate every heading. Idempotent: files that already have
frontmatter are skipped.
"""
from pathlib import Path
from docs_audit.frontmatter import first_h1, split_frontmatter
REPO = Path(__file__).resolve().parents[2]
DOCS = REPO / "docs"
def inject(path: Path) -> str | None:
text = path.read_text(encoding="utf-8")
fm, body = split_frontmatter(text)
if fm is not None:
return None # already has frontmatter; leave alone
title = first_h1(body)
if title is None:
return f"SKIP (no H1): {path}"
# drop the first H1 line only
lines = body.splitlines(keepends=True)
for i, line in enumerate(lines):
if line.lstrip().startswith("# "):
del lines[i]
# also drop one following blank line for tidy output
if i < len(lines) and lines[i].strip() == "":
del lines[i]
break
safe_title = title.replace('"', "'")
path.write_text(f'---\ntitle: "{safe_title}"\n---\n\n' + "".join(lines), encoding="utf-8")
return f"OK: {path.relative_to(REPO)}"
def main() -> None:
# docs/README.md was deleted in Task 1; every remaining file is a page.
results = [inject(p) for p in sorted(DOCS.rglob("*.md"))]
done = [r for r in results if r and r.startswith("OK")]
skipped = [r for r in results if r and r.startswith("SKIP")]
print(f"injected: {len(done)}, skipped: {len(skipped)}")
for s in skipped:
print(s)
if __name__ == "__main__":
main()

Add "tools/docs-audit/add_frontmatter.py" = ["T201", "INP001"] to the pyproject per-file-ignores (same convention as audit/groom).

  • Step 2: Run it + verify

Run: uv run python tools/docs-audit/add_frontmatter.py Expected: injected: 129, skipped: 0. Spot-check one file: head -5 docs/operations/vault.md shows ---, title: "...", --- and NO body H1. Re-run the script → injected: 0 (idempotency check).

  • Step 3: Full Starlight build must now pass

Run: cd website && pnpm build 2>&1 | tail -5 Expected: build completes; ls dist/llms.txt 2>/dev/null || echo "(llms arrives in Task 4)"; find dist -name index.html | wc -l → 129 (one page per md file; also run the Task 1 Step 3 adr check: ls dist/adr/readme/index.html).

  • Step 4: Commit (script + all 129 file edits as one mechanical change)

jj commit -m "feat(docs): inject frontmatter titles from H1s (129 files, scripted) [hl-bit]"


Files:

  • Modify: docs/architecture/decisions/index.md:39
  • Modify: docs/plans/index.md:34
  • Modify: docs/plans/archive/index.md:13
  • Step 1: Convert the three MkDocs admonitions to Starlight asides

MkDocs !!! type "Title" + indented body → Starlight :::type[Title] + unindented body + :::. The three exact conversions (info has no Starlight equivalent → note):

docs/architecture/decisions/index.md!!! note "Coming Soon"

:::note[Coming Soon]
<the previously-indented body, unindented>
:::

docs/plans/index.md!!! tip "Creating New Plans":::tip[Creating New Plans]:::

docs/plans/archive/index.md!!! info "Looking for an old plan?":::note[Looking for an old plan?]:::

  • Step 2: Verify no admonitions remain + build renders asides

Run: rg -n '^\s*!!!' docs || echo "no MkDocs admonitions ✓" then cd website && pnpm build 2>&1 | tail -3 && rg -c 'starlight-aside' dist/plans/index.html Expected: no matches; build clean; at least 1 aside in the rendered page.

  • Step 3: Commit

jj commit -m "docs: convert 3 MkDocs admonitions to Starlight asides [hl-bit]"


Section titled “Task 4: Plugins — links-validator + llms-txt + coverage gate”

Files:

  • Modify: website/astro.config.mjs
  • Create: tools/docs-audit/llms_coverage.py
  • Test: tools/docs-audit/tests/test_llms_coverage.py
  • Step 1: Wire the plugins

In website/astro.config.mjs, add imports and a plugins array inside the starlight({ ... }) options:

import starlightLinksValidator from 'starlight-links-validator';
import starlightLlmsTxt from 'starlight-llms-txt';
plugins: [
starlightLinksValidator(),
starlightLlmsTxt({
projectName: 'fzymgc-house cluster',
description:
'Operations, reference, and architecture documentation for the fzymgc-house self-hosted Kubernetes cluster.',
customSets: [
{ label: 'Operations', description: 'runbooks and operational guides', paths: ['operations/**'] },
{ label: 'Reference', description: 'services, network, secrets, technologies', paths: ['reference/**'] },
],
}),
],

Run: cd website && pnpm build 2>&1 | tail -5 && ls dist/llms.txt dist/llms-full.txt dist/llms-small.txt Expected: build passes (validator found 0 broken links — the corpus was already clean) and all three llms artifacts exist.

  • Step 2: Write the coverage-gate test (TDD the pure part)

Create tools/docs-audit/tests/test_llms_coverage.py:

from llms_coverage import fm_title, missing_titles
def test_fm_title_extracts_quoted_title():
assert fm_title('---\ntitle: "Vault Operations"\n---\nbody') == "Vault Operations"
assert fm_title("no frontmatter") is None
def test_missing_titles():
full = "# Cluster docs\n\n# Vault Operations\ncontent\n"
assert missing_titles(["Vault Operations", "Ghost Page"], full) == ["Ghost Page"]
  • Step 3: Implement the gate

Create tools/docs-audit/llms_coverage.py:

"""Custom llms-full.txt coverage gate (ADR hl-25z): every docs page title
must appear in the generated artifact. NOT a starlight-llms-txt feature —
this is our assertion layered on the build output."""
import re
import sys
from pathlib import Path
from docs_audit.frontmatter import split_frontmatter
REPO = Path(__file__).resolve().parents[2]
DOCS = REPO / "docs"
LLMS_FULL = REPO / "website" / "dist" / "llms-full.txt"
_TITLE = re.compile(r'^title:\s*"?(.*?)"?\s*$', re.MULTILINE)
def fm_title(text: str) -> str | None:
fm, _ = split_frontmatter(text)
if fm is None:
return None
m = _TITLE.search(fm)
return m.group(1) if m else None
def missing_titles(titles: list[str], llms_full: str) -> list[str]:
return [t for t in titles if t not in llms_full]
def main() -> None:
titles = []
for p in sorted(DOCS.rglob("*.md")):
if p.name == "README.md" and p.parent == DOCS:
continue
t = fm_title(p.read_text(encoding="utf-8"))
if t:
titles.append(t)
full = LLMS_FULL.read_text(encoding="utf-8")
missing = missing_titles(titles, full)
if missing:
print(f"COVERAGE FAIL: {len(missing)} page(s) absent from llms-full.txt:")
for t in missing:
print(f" - {t}")
sys.exit(1)
print(f"coverage OK: {len(titles)} pages present in llms-full.txt")
if __name__ == "__main__":
main()

Add the pyproject per-file-ignores entry for it (T201, INP001).

  • Step 4: Run tests + the gate against the real build

Run: uv run pytest tools/docs-audit/tests/test_llms_coverage.py -q → 2 passed. Run: uv run python tools/docs-audit/llms_coverage.pycoverage OK: 129 pages present in llms-full.txt.

  • Step 5: Commit

jj commit -m "feat(website): links-validator + llms-txt plugins + custom coverage gate [hl-bit]"


Task 5: Audit harness adaptation (post-MkDocs)

Section titled “Task 5: Audit harness adaptation (post-MkDocs)”

Files:

  • Modify: tools/docs-audit/audit.py
  • Modify: tools/docs-audit/docs_audit/report.py
  • Modify: tools/docs-audit/tests/test_report.py, tools/docs-audit/tests/test_audit_e2e.py (and any other test touching orphans/nav)
  • Delete: tools/docs-audit/groom.py (CLI), tools/docs-audit/orphan-allowlist.txt
  • Modify: pyproject.toml (drop the groom.py per-file-ignores entry)
  • Step 1: Retire the nav-coupled orphan machinery

With the glob loader + autogenerated sidebar, every docs/**/*.md is a page and a sidebar entry by construction — orphans are impossible, and mkdocs.yml (which nav.parse_nav reads) is deleted in Task 6. Precisely:

  • report.py: change render_report’s orphans_by_dir: dict[str, list[str]] keyword param to orphan_note: str; the section renders as ## Orphans + the note line (no counts). Update tests/test_report.py’s direct call to pass orphan_note="n/a — Starlight autogenerate covers all files by construction" and assert the note appears.
  • audit.py: remove the reachable/orphans computation, the nav/orphans imports, and the MKDOCS constant (ruff will flag them as F401/unused otherwise); pass the static orphan_note to render_report. Invert the title check: title_attention now lists files where the frontmatter is missing or lacks title: — reuse llms_coverage.fm_title. Drop the load_allowlist call site and delete orphan-allowlist.txt (module function + its test stay).
  • groom.py CLI: DELETE it (and its pyproject per-file-ignores entry). Its gather() reads mkdocs.yml, so it dies with Task 6 anyway — a dead tool is worse than no tool. The committed groom-manifest.md stays as the Phase 2 audit trail; the pure docs_audit/groom.py + collectors.py modules and their tests stay (tested, dependency-free, reusable if a future re-triage tool emerges).
  • tests/test_audit_e2e.py: update its ## Orphans (1)-style assertion to the new note format.
  • Step 2: Run the suite + ruff

Run: uv run pytest tools/docs-audit/tests -q → all pass. Run: uv run ruff check tools/ → clean (no unused-import stragglers).

  • Step 3: Regenerate the committed baseline

Run: uv run python tools/docs-audit/audit.py --check offline --out tools/docs-audit/docs-audit.md && rg '^## ' tools/docs-audit/docs-audit.md Expected: internal links 0; title section 0 (all injected in Task 2); orphans n/a.

  • Step 4: Commit

jj commit -m "feat(docs-audit): retire nav-coupled orphan check; title check now requires frontmatter [hl-bit]"


Files:

  • Modify: .github/workflows/docs.yml
  • Delete: mkdocs.yml
  • Step 1: Rewire the workflow

In .github/workflows/docs.yml:

  • Triggers: paths becomes docs/**, website/**, .github/workflows/docs.yml (drop mkdocs.yml).
  • Build steps: replace the setup-uv + uvx mkdocs build pair with:
- uses: pnpm/action-setup@d15e628ca66d93ee5f352c71671a7bc6a97af5c9 # v6.0.8
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- name: Build docs
run: pnpm --dir website install --frozen-lockfile && pnpm --dir website build
- name: llms.txt coverage gate
run: uv run python tools/docs-audit/llms_coverage.py

(Retain the existing astral-sh/setup-uv step — do not replace it with the Node setup; the coverage gate runs via uv. No ad-hoc install fallbacks: the gate MUST run and fail the job loudly on exit 1, so a missing uv should fail the workflow, never be masked.)

  • Deploy steps: change both wrangler commands’ output dir from site to website/dist; --project-name=cluster-docs and the preview-branch logic stay identical (Cloudflare Access untouched).
  • fetch-depth: 0 is no longer needed (it served the git-revision-date plugin) — drop it for faster checkouts.
  • Step 2: Delete mkdocs.yml + lint

Run: jj file untrack mkdocs.yml 2>/dev/null; rm mkdocs.yml — actually just rm mkdocs.yml (jj auto-tracks the deletion). Then yamllint -c .yamllint.yaml .github/workflows/docs.yml → clean. Verify nothing else references it: rg -l 'mkdocs' --glob '!docs/**' --glob '!tools/docs-audit/*.md' . → expect only this plan/spec + CLAUDE.md (fixed in Task 7).

  • Step 3: Commit

jj commit -m "ci(docs): build with pnpm/Starlight, deploy website/dist; delete mkdocs.yml [hl-bit]"


Task 7: Pointer updates (CLAUDE.md, docs README)

Section titled “Task 7: Pointer updates (CLAUDE.md, docs README)”

Files:

  • Modify: CLAUDE.md
  • Step 1: Update the build/serve pointers
  • CLAUDE.md line 5 area: replace (or run \uvx —with mkdocs-material mkdocs serve` locally)with(or run `pnpm —dir website dev` locally). Add one line under Documentation: AI agents: the deployed site serves /llms.txt, /llms-full.txt, /llms-small.txt (Cloudflare Access service token required).`
  • (docs/README.md needs nothing — Task 1 deleted it.)
  • Sweep: rg -n 'mkdocs' CLAUDE.md ansible/CLAUDE.md tf/CLAUDE.md argocd/CLAUDE.md docs/getting-started/ 2>/dev/null → fix every hit (spec/plan files under docs/engineering/ are historical records; leave them).
  • Step 2: Lint + commit

rumdl check CLAUDE.md → clean. jj commit -m "docs: update build pointers MkDocs→Starlight; advertise llms.txt [hl-bit]"


Files:

  • None (verification + PR)
  • Step 1: Full local gate

Run, in order, all green:

Terminal window
cd website && pnpm build && cd ..
uv run python tools/docs-audit/llms_coverage.py
uv run pytest tools/docs-audit/tests -q
uv run python tools/docs-audit/audit.py --check offline --out tools/docs-audit/docs-audit.md
rumdl check --config docs/.rumdl.toml docs/ 2>&1 | tail -2
  • Step 2: Push branch, open PR, verify the preview deploy

Push the bookmark and open the PR: jj bookmark create feat/docs-starlight-cutover -r @ && jj git push -b feat/docs-starlight-cutover, then run gh pr create from the primary checkout (/Volumes/Code/github.com/fzymgc-house/selfhosted-cluster) — jj workspaces aren’t git-colocated, so gh cannot discover the repo from inside one. The docs.yml preview deploy comments a *.cluster-docs.pages.dev URL. On the preview: spot-check the homepage, one operations page (slug + sidebar + edit link), the ADRs group, an aside rendering, and /llms-full.txt.

  • Step 3: Human review gate

The PR is the cutover — production flips when it merges. Reviewer checklist in the PR body: preview URL checks above + “rollback = revert the PR (mkdocs.yml comes back with it)”.


Rule 7 Grounding (executed during planning)

Section titled “Rule 7 Grounding (executed during planning)”
  • Loader pattern + symlink rejection: ADR hl-6iz; deepwiki-grounded reference implementation; glob() base accepts a URL (verified by design-reviewer round 2 against Astro docs). Task 1 is still the fidelity gate (slugs/edit-links), with Approach A as the documented fallback.
  • Versions: astro 6.4.4, @astrojs/starlight 0.39.3, starlight-links-validator 0.24.0, starlight-llms-txt 0.10.0 — npm view this session. Action SHAs pnpm/action-setup v6.0.8 + setup-node v6.4.0 resolved live via gh api (Phase 1 lesson: never write SHAs from memory).
  • Syntax inventory: 3 admonitions (exact file:line recorded), 0 tabs/snippets/details/keys — rg against the groomed corpus this session. Asides :::type[Title] and Tabs import path re-verified via context7 (Tabs unneeded).
  • Frontmatter: 0/129 files have frontmatter (rg --multiline '\A---'); audit title_attention = 0 confirms every file has a usable H1.
  • llms-txt config keys (projectName/description/customSets) grounded via the plugin’s published docs (firecrawl, v0.10.0, recorded on hl-bit). The coverage gate is OUR check (ADR hl-25z), not a plugin feature.
  • Workflow: current docs.yml read this session (trigger paths, wrangler site arg, preview-branch logic, fetch-depth: 0 rationale).
  • Spec coverage: Phase 3 spec steps 1–6 map to Tasks 1 (scaffold+loader), 2 (frontmatter), 3 (syntax), 6 (workflow rewire incl. deploy dir + triggers), 4 (permanent gates), 6+7 (mkdocs.yml deletion + CLAUDE.md pointers). The llms.txt section maps to Task 4; preview-deploy verification to Task 8. Audit-harness continuity (not in the spec’s numbered list but required — audit.py reads mkdocs.yml) is Task 5.
  • Placeholders: none — every config/script is complete; the three admonition edits are enumerated with exact locations.
  • Type consistency: externalDocsLoader signature matches ADR hl-6iz and its use in content.config.ts; fm_title/missing_titles (Task 4) match their test imports; add_frontmatter.py reuses split_frontmatter/first_h1 with Phase 1 signatures.