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)”| Signal | Value | Consequence |
|---|---|---|
| Corpus | 129 files, orphans 0, internal links 0 | migrate everything; nothing to skip |
| Frontmatter | 0/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 syntax | 3 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 sites | architecture/decisions/index.md:39 (note) · plans/index.md:34 (tip) · plans/archive/index.md:13 (info→note) | exact edit list |
| Loader | docsLoader() base hardwired; custom glob() wrapper is the supported path (ADR hl-6iz) | Task 1 builds it and verifies fidelity FIRST — it gates everything |
| Action SHAs | pnpm/action-setup v6.0.8 d15e628c…, setup-node v6.4.0 48b55a01… (resolved via gh api) | Task 6 workflow |
File Structure
Section titled “File Structure”| Path | Responsibility |
|---|---|
website/package.json, website/pnpm-lock.yaml | Node deps, pinned; packageManager field |
website/astro.config.mjs | site URL, Starlight config: sidebar, editLink, plugins |
website/src/loaders/docs.ts | externalDocsLoader — glob() with ../docs base |
website/src/content.config.ts | docs collection: custom loader + docsSchema() |
website/tsconfig.json | Astro strict preset |
website/scripts/check-llms-coverage.py → no — tools/docs-audit/llms_coverage.py | custom coverage gate (Python, reuses docs_audit) |
tools/docs-audit/add_frontmatter.py | one-shot title injection (reuses frontmatter module) |
tools/docs-audit/audit.py, docs_audit/orphans.py tests | orphan check retires; title check inverts (must HAVE fm title) |
.github/workflows/docs.yml | pnpm build, website/dist deploy, trigger paths |
mkdocs.yml, tools/docs-audit/orphan-allowlist.txt, tools/docs-audit/groom.py (CLI), docs/README.md | deleted |
CLAUDE.md | pointer 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 bydocs/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.html → edit/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]"
Task 2: Frontmatter injection (129 files)
Section titled “Task 2: Frontmatter injection (129 files)”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 afterpromotion — Starlight renders the title as the page H1, so leaving itwould duplicate every heading. Idempotent: files that already havefrontmatter 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]"
Task 3: Admonition conversions (3 edits)
Section titled “Task 3: Admonition conversions (3 edits)”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]"
Task 4: Plugins — links-validator + llms-txt + coverage gate
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 titlemust appear in the generated artifact. NOT a starlight-llms-txt feature —this is our assertion layered on the build output."""import reimport sysfrom 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.py → coverage 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: changerender_report’sorphans_by_dir: dict[str, list[str]]keyword param toorphan_note: str; the section renders as## Orphans+ the note line (no counts). Updatetests/test_report.py’s direct call to passorphan_note="n/a — Starlight autogenerate covers all files by construction"and assert the note appears.audit.py: remove thereachable/orphanscomputation, thenav/orphansimports, and theMKDOCSconstant (ruff will flag them as F401/unused otherwise); pass the staticorphan_notetorender_report. Invert the title check:title_attentionnow lists files where the frontmatter is missing or lackstitle:— reusellms_coverage.fm_title. Drop theload_allowlistcall site and deleteorphan-allowlist.txt(module function + its test stay).groom.pyCLI: DELETE it (and its pyproject per-file-ignores entry). Itsgather()readsmkdocs.yml, so it dies with Task 6 anyway — a dead tool is worse than no tool. The committedgroom-manifest.mdstays as the Phase 2 audit trail; the puredocs_audit/groom.py+collectors.pymodules 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]"
Task 6: CI rewire + delete mkdocs.yml
Section titled “Task 6: CI rewire + delete mkdocs.yml”Files:
- Modify:
.github/workflows/docs.yml - Delete:
mkdocs.yml - Step 1: Rewire the workflow
In .github/workflows/docs.yml:
- Triggers:
pathsbecomesdocs/**,website/**,.github/workflows/docs.yml(dropmkdocs.yml). - Build steps: replace the
setup-uv+uvx mkdocs buildpair 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
sitetowebsite/dist;--project-name=cluster-docsand the preview-branch logic stay identical (Cloudflare Access untouched). fetch-depth: 0is 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.mdline 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.mdneeds 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]"
Task 8: Preview verification + land
Section titled “Task 8: Preview verification + land”Files:
- None (verification + PR)
- Step 1: Full local gate
Run, in order, all green:
cd website && pnpm build && cd ..uv run python tools/docs-audit/llms_coverage.pyuv run pytest tools/docs-audit/tests -quv run python tools/docs-audit/audit.py --check offline --out tools/docs-audit/docs-audit.mdrumdl 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()baseaccepts 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 viewthis session. Action SHAs pnpm/action-setup v6.0.8 + setup-node v6.4.0 resolved live viagh api(Phase 1 lesson: never write SHAs from memory). - Syntax inventory: 3 admonitions (exact file:line recorded), 0 tabs/snippets/details/keys —
rgagainst 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---'); audittitle_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.ymlread this session (trigger paths, wranglersitearg, preview-branch logic,fetch-depth: 0rationale).
Self-Review
Section titled “Self-Review”- 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:
externalDocsLoadersignature matches ADR hl-6iz and its use incontent.config.ts;fm_title/missing_titles(Task 4) match their test imports;add_frontmatter.pyreusessplit_frontmatter/first_h1with Phase 1 signatures.