Docs Starlight Migration — Phase 2 (Aggressive Grooming) 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: Drive the orphan count from 162 toward ~0 by classifying every orphan with a deterministic per-directory policy (delete clear-dead, bead the ambiguous, re-home the living), reconcile the reference docs against the cluster-drift findings, and rewrite the MkDocs nav so Phase 3 migrates only living content.
Architecture: A groom.py triage tool joins the audit harness in tools/docs-audit/ — a pure classify() rule engine (TDD) fed by two thin collectors (per-file age, repo-wide inbound references). It emits a committed manifest, then --apply executes deletions and files beads for ambiguous files. Separate content tasks reconcile services.md drift and rewrite the mkdocs.yml nav (orphan allowlist for active specs/plans). The site stays on MkDocs throughout; every PR keeps it building.
Tech Stack: Python 3.13 + uv (extends Phase 1’s docs_audit package), jj/git for file ages, rg for inbound refs, bd for triage beads.
Design bead: hl-bit · Spec: docs/engineering/specs/2026-06-07-docs-starlight-migration-design.md · Phase 1 baseline: tools/docs-audit/docs-audit.md
Model labels: Tasks 1–4 and 6 are mechanical TDD work → model:haiku. Tasks 5 and 7 involve content judgment → model:sonnet.
Inputs (from the Phase 1 audit + cluster cross-check)
Section titled “Inputs (from the Phase 1 audit + cluster cross-check)”| Signal | Value | Consequence |
|---|---|---|
| Orphans by dir | plans/ 94 · superpowers/ 45 · adr/ 18 · operations/ 5 | Per-directory disposition policy (below) |
| Status markers | only 27 files carry Status: superseded/completed/… | Age + inbound-refs must carry the triage |
| Bulk lint sweeps | reset git/jj last-touch (a 2025-12 plan shows 2026-06-06) | Filename date (YYYY-MM-DD-slug.md) is the primary age signal; VCS last-touch is fallback only |
git log in jj workspace | fails (workspace not git-colocated) | age collector tries git, falls back to jj log (template verified) |
| Cluster drift | alloy+merlin doc’d-but-gone; ~12 ns undocumented; litellm ns lingers post-decommission | Task 5 reconciliation list |
| External links | 47 failures, almost all inside plans/archive/ | corroborates archive deletion; no separate link-fix task |
Disposition policy (the core Phase 2 rule):
| Directory | Policy |
|---|---|
adr/, architecture/, reference/, operations/, getting-started/ | keep — living dirs; orphans here are navigation gaps, re-homed in Task 7 |
plans/archive/** | delete — self-declared archive |
plans/**, superpowers/** | triage: status-marker → delete; stale + zero inbound refs → delete; stale + referenced → bead; fresh → keep-active (orphan allowlist) |
File Structure
Section titled “File Structure”| Path | Responsibility |
|---|---|
tools/docs-audit/docs_audit/groom.py | Pure triage rule engine: FileFacts, classify(), manifest rendering |
tools/docs-audit/docs_audit/collectors.py | Thin seams: per-file age (git→jj fallback), repo-wide inbound-ref counts (rg) |
tools/docs-audit/groom.py | CLI: --manifest (write triage manifest) / --apply (delete + emit bd commands) |
tools/docs-audit/groom-manifest.md | Generated, committed triage manifest (audit trail for the deletions) |
tools/docs-audit/orphan-allowlist.txt | Deliberately-unlisted files (active specs/plans); consumed by audit.py |
tools/docs-audit/docs_audit/orphans.py | Modify: find_orphans honors the allowlist |
tools/docs-audit/docs_audit/cluster.py | Modify: system-namespace allowlist for the ns diff |
tools/docs-audit/tests/test_groom.py, test_collectors.py | Unit tests for the pure logic |
docs/reference/services.md | Drift reconciliation (Task 5) |
mkdocs.yml | Nav rewrite covering all kept files (Task 7) |
Task 1: groom.py — classify() rule engine
Section titled “Task 1: groom.py — classify() rule engine”Files:
- Create:
tools/docs-audit/docs_audit/groom.py - Test:
tools/docs-audit/tests/test_groom.py - Step 1: Write the failing test
Create tools/docs-audit/tests/test_groom.py:
from datetime import date
from docs_audit.groom import FileFacts, classify, filename_date
TODAY = date(2026, 6, 7)
def facts(path, *, last_touch=None, inbound=0, marker=None): return FileFacts(path=path, last_touch=last_touch, inbound_refs=inbound, status_marker=marker)
def test_filename_date_parses_convention(): assert filename_date("plans/2025-12-30-router-design.md") == date(2025, 12, 30) assert filename_date("plans/archive/migrations/windmill-s3-setup.md") is None
def test_living_dirs_always_keep(): d, reason = classify(facts("adr/hl-6iz-something.md"), today=TODAY) assert d == "keep" d, _ = classify(facts("operations/hubble-ui.md"), today=TODAY) assert d == "keep"
def test_archive_always_deletes(): d, reason = classify(facts("plans/archive/2025-12-09-upgrade-status.md"), today=TODAY) assert d == "delete" assert "archive" in reason
def test_status_marker_deletes(): d, reason = classify( facts("plans/2026-01-04-windmill-removal-plan.md", marker="completed"), today=TODAY ) assert d == "delete" assert "completed" in reason
def test_stale_unreferenced_deletes_via_filename_date(): # filename date 2025-12-07 is 182 days before TODAY (2026-06-07) — > 180 d, _ = classify(facts("plans/2025-12-07-router-design.md"), today=TODAY) assert d == "delete"
def test_stale_but_referenced_beads(): d, _ = classify(facts("plans/2025-12-07-router-design.md", inbound=2), today=TODAY) assert d == "bead"
def test_fresh_keeps_active(): d, reason = classify(facts("superpowers/specs/2026-06-07-docs-starlight-migration-design.md"), today=TODAY) assert d == "keep-active"
def test_undated_falls_back_to_last_touch(): d, _ = classify( facts("superpowers/plans/old-undated-plan.md", last_touch=date(2025, 11, 1)), today=TODAY ) assert d == "delete" # undated AND no last_touch -> can't prove staleness -> bead d, _ = classify(facts("superpowers/plans/old-undated-plan.md"), today=TODAY) assert d == "bead"- Step 2: Run test to verify it fails
Run: uv run pytest tools/docs-audit/tests/test_groom.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'docs_audit.groom'.
- Step 3: Write minimal implementation
Create tools/docs-audit/docs_audit/groom.py:
import refrom dataclasses import dataclassfrom datetime import date
LIVING_DIRS = {"adr", "architecture", "reference", "operations", "getting-started"}STALE_DAYS = 180
_FNAME_DATE = re.compile(r"(\d{4})-(\d{2})-(\d{2})-")
@dataclassclass FileFacts: path: str # relative to docs/ last_touch: date | None = None # VCS fallback signal (lint sweeps pollute it) inbound_refs: int = 0 # repo-wide references from OTHER files status_marker: str | None = None # superseded|completed|abandoned|implemented|done
def filename_date(path: str) -> date | None: """Authoring date from the YYYY-MM-DD-slug.md convention (sweep-immune).""" m = _FNAME_DATE.search(path.rsplit("/", 1)[-1]) if not m: return None try: return date(int(m.group(1)), int(m.group(2)), int(m.group(3))) except ValueError: return None
def classify(f: FileFacts, *, today: date, stale_days: int = STALE_DAYS) -> tuple[str, str]: """Return (disposition, reason). Dispositions: delete | keep | keep-active | bead.""" top = f.path.split("/", 1)[0] if top in LIVING_DIRS: return "keep", f"living dir '{top}': re-home into nav" if f.path.startswith("plans/archive/"): return "delete", "self-declared archive" if f.status_marker: return "delete", f"status marker: {f.status_marker}"
age_basis = filename_date(f.path) or f.last_touch if age_basis is None: return "bead", "undated and no VCS date: cannot prove staleness" if (today - age_basis).days <= stale_days: return "keep-active", f"fresh ({age_basis}): active work, add to orphan allowlist" if f.inbound_refs == 0: return "delete", f"stale ({age_basis}) with zero inbound refs" return "bead", f"stale ({age_basis}) but referenced by {f.inbound_refs} file(s)"- Step 4: Run test to verify it passes
Run: uv run pytest tools/docs-audit/tests/test_groom.py -q
Expected: PASS (8 passed).
- Step 5: Commit
jj commit -m "feat(docs-groom): triage rule engine with per-dir disposition policy [hl-bit]"
Task 2: collectors.py — age + inbound-ref collectors
Section titled “Task 2: collectors.py — age + inbound-ref collectors”Files:
- Create:
tools/docs-audit/docs_audit/collectors.py - Test:
tools/docs-audit/tests/test_collectors.py - Step 1: Write the failing test (pure parsing parts only)
Create tools/docs-audit/tests/test_collectors.py:
from datetime import date
from docs_audit.collectors import parse_vcs_date, status_marker
def test_parse_vcs_date(): assert parse_vcs_date("2026-06-06\n") == date(2026, 6, 6) assert parse_vcs_date("") is None assert parse_vcs_date("fatal: not a git repository") is None
def test_status_marker_normalizes(): assert status_marker("**Status:** Completed\n") == "completed" assert status_marker("status: SUPERSEDED by X") == "superseded" assert status_marker("# Just a doc\n") is None # 'complete' (without d) also counts assert status_marker("Status: complete") == "complete"- Step 2: Run test to verify it fails
Run: uv run pytest tools/docs-audit/tests/test_collectors.py -q
Expected: FAIL with ModuleNotFoundError: No module named 'docs_audit.collectors'.
- Step 3: Write minimal implementation
Create tools/docs-audit/docs_audit/collectors.py:
import reimport subprocessfrom datetime import datefrom pathlib import Path
_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")_STATUS_RE = re.compile( r"status[^a-z0-9]{0,6}(superseded|completed|complete|abandoned|implemented|done)", re.IGNORECASE,)
def parse_vcs_date(raw: str) -> date | None: m = _DATE_RE.match(raw.strip()) if not m: return None return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
def status_marker(text: str) -> str | None: """Normalized dead-status marker from the first match in the file text.""" m = _STATUS_RE.search(text) return m.group(1).lower() if m else None
def last_touch(repo_root: Path, rel_path: str) -> date | None: """Last-commit date for a file. Tries git (colocated checkouts, CI), falls back to jj (non-colocated jj workspaces, where `git log` fails).""" git = subprocess.run( ["git", "log", "-1", "--format=%cs", "--", rel_path], cwd=repo_root, capture_output=True, text=True, ) d = parse_vcs_date(git.stdout) if d: return d jj = subprocess.run( ["jj", "--no-pager", "log", "-n1", "--no-graph", "-T", 'committer.timestamp().format("%Y-%m-%d")', rel_path], cwd=repo_root, capture_output=True, text=True, ) return parse_vcs_date(jj.stdout)
def inbound_refs(repo_root: Path, rel_doc_path: str) -> int: """Count files (repo-wide) referencing this doc by filename, excluding the file itself and generated audit outputs. Filenames are dated slugs, so basename matching is precise for plans/specs.""" basename = rel_doc_path.rsplit("/", 1)[-1] rg = subprocess.run( ["rg", "-l", "--fixed-strings", basename, "--glob", "!.worktrees/**", "--glob", "!tools/docs-audit/*.md", "--glob", "!*.lock"], cwd=repo_root, capture_output=True, text=True, ) hits = [h for h in rg.stdout.splitlines() if h and not h.endswith(rel_doc_path)] return len(hits)- Step 4: Run test to verify it passes
Run: uv run pytest tools/docs-audit/tests/test_collectors.py -q
Expected: PASS (2 passed).
- Step 5: Smoke the integration seams against the real repo
Run: PYTHONPATH=tools/docs-audit uv run python -c "from pathlib import Path; from docs_audit.collectors import last_touch, inbound_refs; r=Path('.'); print(last_touch(r,'docs/index.md'), inbound_refs(r,'docs/plans/archive/2025-12-09-upgrade-status.md'))"
Expected: a real date and a small integer (no exception). Works in both the jj workspace (jj fallback) and a git checkout.
- Step 6: Commit
jj commit -m "feat(docs-groom): age (git→jj fallback) + inbound-ref collectors [hl-bit]"
Task 3: groom CLI --manifest + committed triage manifest
Section titled “Task 3: groom CLI --manifest + committed triage manifest”Files:
- Create:
tools/docs-audit/groom.py - Create:
tools/docs-audit/groom-manifest.md(generated, committed) - Modify:
pyproject.toml(ruff per-file-ignores) - Step 1: Write the CLI
Create tools/docs-audit/groom.py:
import argparsefrom datetime import datefrom pathlib import Path
from docs_audit import collectors, groom, nav, orphans
REPO = Path(__file__).resolve().parents[2]DOCS = REPO / "docs"MKDOCS = REPO / "mkdocs.yml"MANIFEST = Path(__file__).resolve().parent / "groom-manifest.md"
def gather() -> list[tuple[groom.FileFacts, str, str]]: reachable = nav.reachable_docs(nav.parse_nav(MKDOCS)) all_md = orphans.all_markdown(DOCS) rows = [] for rel in orphans.find_orphans(all_md, reachable, exclude={"README.md"}): text = (DOCS / rel).read_text(encoding="utf-8") facts = groom.FileFacts( path=rel, last_touch=collectors.last_touch(REPO, f"docs/{rel}"), inbound_refs=collectors.inbound_refs(REPO, f"docs/{rel}"), status_marker=collectors.status_marker(text), ) disposition, reason = groom.classify(facts, today=date.today()) rows.append((facts, disposition, reason)) return rows
def render(rows) -> str: counts: dict[str, int] = {} for _, d, _ in rows: counts[d] = counts.get(d, 0) + 1 lines = ["# Groom Triage Manifest", "", "| Disposition | Count |", "|---|---|"] lines += [f"| {d} | {n} |" for d, n in sorted(counts.items())] lines.append("") for wanted in ("delete", "bead", "keep-active", "keep"): lines.append(f"## {wanted}") lines += [f"- `{f.path}` — {r}" for f, d, r in rows if d == wanted] lines.append("") return "\n".join(lines) + "\n"
def apply(rows) -> None: for facts, disposition, _ in rows: if disposition == "delete": (DOCS / facts.path).unlink() print(f"deleted docs/{facts.path}") elif disposition == "bead": print(f"BEAD NEEDED: docs/{facts.path} # file via bd create (see plan Task 4)")
def main() -> None: parser = argparse.ArgumentParser(description="Triage docs orphans.") parser.add_argument("--apply", action="store_true", help="delete 'delete'-classified files; list bead candidates") args = parser.parse_args() rows = gather() MANIFEST.write_text(render(rows), encoding="utf-8") print(f"wrote {MANIFEST} ({len(rows)} orphans triaged)") if args.apply: apply(rows)
if __name__ == "__main__": main()In pyproject.toml [tool.ruff.lint.per-file-ignores], mirror the existing audit.py entry for the new CLI (it prints by design):
"tools/docs-audit/groom.py" = ["T201", "INP001"]Then run uv run ruff check tools/ — clean.
- Step 2: Generate the manifest (no —apply yet)
Run: uv run python tools/docs-audit/groom.py
Expected: wrote .../groom-manifest.md (162 orphans triaged). The summary table shows keep = 23 (adr 18 + operations 5), a large delete bucket (≥ the 60 archive files), a small bead bucket, and keep-active covering the fresh docs-starlight spec/plans.
- Step 3: Eyeball the manifest for sanity
Run: rg -c '^- ' tools/docs-audit/groom-manifest.md && sed -n '1,12p' tools/docs-audit/groom-manifest.md
Expected: 162 rows total; counts table matches. Spot-check 3 delete rows and confirm each is genuinely dead (this is the human gate before —apply).
- Step 4: Lint + commit the manifest
Run: rumdl check tools/docs-audit/groom-manifest.md || rumdl fmt tools/docs-audit/groom-manifest.md
jj commit -m "feat(docs-groom): CLI + committed triage manifest (162 orphans) [hl-bit]"
Task 4: Execute deletions + file triage beads
Section titled “Task 4: Execute deletions + file triage beads”Files:
- Modify:
docs/plans/**,docs/engineering/**(deletions per manifest) - Step 1: Apply deletions
Run: uv run python tools/docs-audit/groom.py --apply
Expected: one deleted docs/... line per delete row; BEAD NEEDED: lines for the bead bucket. Git history preserves every deleted file.
- Step 2: Verify the site still builds
Run: uvx --with mkdocs-material --with mkdocs-git-revision-date-localized-plugin mkdocs build --strict 2>&1 | tail -3
Expected: clean build. --strict is essential: without it MkDocs only WARNS (exit 0) on broken internal links, so a kept page linking to a just-deleted orphan would slip through (lychee’s pre-deletion “0 failures” can’t catch this — the orphan still existed then). If the build errors, a page references a deleted file: STOP, restore it (jj restore <path>), reclassify it keep (the inbound-ref count was wrong or the referencer needs editing), and re-run from Task 3.
- Step 3: File one triage bead per
beadrow
For each BEAD NEEDED line, run (filling title/path):
bd create --title "docs-groom triage: <path>" \ --description "Goal: human-triage this stale-but-referenced orphan: docs/<path>. Decide keep (re-home into nav + update referencers) or delete (update/remove the inbound references first). Referencers: rg --fixed-strings '<basename>' to list.Plan: docs/engineering/plans/2026-06-07-docs-starlight-phase2-grooming.md#task-4." \ --acceptance "File is either in nav (or allowlist) with live references, or deleted with referencers updated" \ --parent hl-bit --type=task --priority 3 --labels model:sonnet- Step 4: Commit
jj commit -m "feat(docs-groom): prune clear-dead orphans per manifest [hl-bit]"
(The deletion commit is intentionally separate from the manifest commit so the PR shows triage-then-execute.)
Task 5: Reference-doc drift reconciliation
Section titled “Task 5: Reference-doc drift reconciliation”Files:
- Modify:
docs/reference/services.md - Modify:
tools/docs-audit/docs_audit/cluster.py - Test:
tools/docs-audit/tests/test_cluster.py - Step 1: Add a system-namespace allowlist to the ns diff (TDD)
Add to tools/docs-audit/tests/test_cluster.py:
def test_diff_sets_ignores_system_namespaces(): from docs_audit.cluster import SYSTEM_NAMESPACES, diff_sets live = {"vault", "kube-system", "default", "cert-manager"} # Without the filter these three system namespaces would be only_live noise only_doc, only_live = diff_sets({"vault"}, live - SYSTEM_NAMESPACES) assert only_doc == [] assert only_live == []Run to see it fail, then add to tools/docs-audit/docs_audit/cluster.py:
# Infrastructure namespaces that never get services.md entries.SYSTEM_NAMESPACES = { "default", "kube-system", "kube-public", "kube-node-lease", "cert-manager", "cilium-secrets", "external-secrets", "metallb", "cnpg-system", "sysadm",}and filter in audit.py’s _cluster_section: cluster.diff_sets(doc_ns, cluster.live_namespaces() - cluster.SYSTEM_NAMESPACES).
Run: uv run pytest tools/docs-audit/tests/test_cluster.py -q → PASS.
Known gap (accepted): the unit test verifies the set-filter pattern, not that audit.py’s call site applies it — the call-site edit is verified by the live cross-check in Step 4, not by a unit test.
- Step 2: Fix services.md drift (from the recorded findings)
In docs/reference/services.md:
- Remove or update the Alloy row/section (
alloyns gone — replaced by the clickstack/vector pipeline; cross-checkdocs/operations/clickstack.mdfor the current shape) and Merlin rows (merlinns gone). - Add Quick Reference rows (+ short sections) for the running-but-undocumented services:
agent-memory,agentgateway-system(vs the existing agentgateway entry — verify which ns the entry should name),arc-runners/arc-systems(GitHub Actions runner controller),clickstack-operators,cloudflared,hcp-terraform,otel-scraper,rustdesk,vector. - Leave
litellmOUT of the catalog: it is decommissioned per ADR hl-hv3 — instead file the infra-cleanup bead in Step 3. - Step 3: File the litellm infra-drift bead (standalone, NOT under hl-bit)
bd create --title "cleanup: litellm namespace lingers post-agentgateway-decommission" \ --description "Goal: the litellm namespace still exists in the cluster but LiteLLM was replaced by agentgateway (ADR hl-hv3). Verify nothing live remains (argocd app, secrets, PVCs), then remove the namespace + any ArgoCD app-config remnants. Found by docs-audit cluster cross-check during hl-bit Phase 2." \ --acceptance "litellm namespace absent; no ArgoCD app references; docs-audit cluster check no longer lists it" \ --type=task --priority 2 --labels model:sonnet- Step 4: Verify + commit
Run: kubectl get ns -o json | jq -r '.items[].metadata.name' > /tmp/live-ns.txt then re-run the manual cross-check (Task 7 re-runs the full audit); doc’d-but-gone should be 0 and only-in-cluster should be litellm (pending its bead) — everything else documented or allowlisted.
jj commit -m "docs(reference): reconcile services.md with live cluster + system-ns allowlist [hl-bit]"
Task 6: Orphan allowlist support in the audit harness
Section titled “Task 6: Orphan allowlist support in the audit harness”Files:
- Modify:
tools/docs-audit/docs_audit/orphans.py - Create:
tools/docs-audit/orphan-allowlist.txt - Modify:
tools/docs-audit/audit.py - Test:
tools/docs-audit/tests/test_orphans.py - Step 1: Write the failing test
Add to tools/docs-audit/tests/test_orphans.py:
def test_load_allowlist_skips_comments_and_blanks(tmp_path): from docs_audit.orphans import load_allowlist f = tmp_path / "allow.txt" f.write_text("# active specs\nsuperpowers/specs/a.md\n\nplans/index-draft.md\n") assert load_allowlist(f) == {"superpowers/specs/a.md", "plans/index-draft.md"} assert load_allowlist(tmp_path / "missing.txt") == set()- Step 2: Run to verify it fails, then implement
Add to tools/docs-audit/docs_audit/orphans.py:
def load_allowlist(path: Path) -> set[str]: """Deliberately-unlisted docs (active specs/plans). Comments (#) and blank lines ignored; missing file = empty set.""" path = Path(path) if not path.exists(): return set() return { line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") }In tools/docs-audit/audit.py _offline_sections, extend the exclude set:
exclude={"README.md"} | orphans.load_allowlist(Path(__file__).resolve().parent / "orphan-allowlist.txt").
- Step 3: Populate the allowlist from the manifest’s keep-active AND bead rows
Create tools/docs-audit/orphan-allowlist.txt with two commented sections:
# Active specs/plans (keep-active per groom-manifest.md) — deliberately unlistedsuperpowers/specs/2026-06-07-docs-starlight-migration-design.md...
# Pending triage (bead per groom-manifest.md) — remove each line when its bead resolves...Every keep-active path and every bead path from the manifest goes in (one per line). bead files stay on disk until their triage bead resolves — without allowlisting them, Task 7’s Orphans (0) target is unreachable.
- Step 4: Run tests + commit
Run: uv run pytest tools/docs-audit/tests -q
Expected: all pass (Phase 1’s 29 + new tests).
jj commit -m "feat(docs-audit): orphan allowlist for deliberately-unlisted active docs [hl-bit]"
Task 7: Nav rewrite + final audit baseline
Section titled “Task 7: Nav rewrite + final audit baseline”Files:
- Modify:
mkdocs.yml - Modify:
tools/docs-audit/docs-audit.md(regenerated) - Step 1: Rewrite the nav to cover every
keepfile
In mkdocs.yml nav:, working from the manifest’s keep rows:
- Add an ADRs section under Architecture:
Architecture → Decisions (ADRs)listingadr/README.mdfirst (it is the index) and eachadr/*.mdfile. (18 files today — list explicitly; MkDocs has no autogen.) - Re-home the 5 orphaned
operations/files into the Operations section. - Ensure surviving
plans//superpowers/content is reachable: keepplans/index.md+plans/archive/index.mdentries only if those index pages survive grooming; active specs/plans stay out of nav via the allowlist. - Step 2: Build + audit must converge
Run: uvx --with mkdocs-material --with mkdocs-git-revision-date-localized-plugin mkdocs build --strict 2>&1 | tail -3
Expected: clean build, no “page exists but not in nav” INFO lines for kept files.
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: ## Orphans (0) (everything reachable or allowlisted), ## Internal link failures (0) (deletions removed no linked content — Task 4 Step 2 guarded this), ## Files needing a title (0) (both offenders lived in plans/archive, now deleted).
- Step 3: Commit + verify the whole suite
Run: uv run pytest tools/docs-audit/tests -q → all pass.
jj commit -m "docs(nav): surface ADRs + re-home operations; orphans → 0 [hl-bit]"
Rule 7 Grounding (executed during planning)
Section titled “Rule 7 Grounding (executed during planning)”- Paths:
tools/docs-audit/modules + tests exist on main (Phase 1, PR #1213);mkdocs.ymlnav structure read this session;docs/reference/services.mddrift findings recorded on hl-bit from the live cross-check. - jj last-touch template verified live in the workspace:
jj --no-pager log -n1 --no-graph -T 'committer.timestamp().format("%Y-%m-%d")' <path>→2026-06-06.git log -1 --format=%csconfirmed to FAIL in the non-colocated workspace (drives the fallback design). - Filename-date over VCS date: verified that a 2025-12 plan shows VCS last-touch 2026-06-06 (bulk lint sweep), so
filename_date()is primary. - Status markers: 27 files match the dead-status regex (verified via rg this session) — minority signal, as designed.
- Cluster findings (alloy/merlin gone; 12 undocumented; litellm lingering; system-ns noise list) taken from the live kubectl + extractor run recorded in hl-bit notes. Vault path findings are excluded from Phase 2 scope — inconclusive until hl-bit.11 (403-vs-404) lands.
- Dependency note: Tasks 1–4 are sequential; Task 5 and Task 6 depend only on Task 3’s manifest; Task 7 depends on Tasks 4–6. Harness-bug beads hl-bit.10/.11 are independent of this plan (cluster check rerun in Task 5 Step 4 uses kubectl directly, not the broken Python client).
Self-Review
Section titled “Self-Review”- Spec coverage: Phase 2 spec bullets → auto-delete clear-dead (Tasks 1–4), beads for ambiguous (Task 4 Step 3), reconcile cross-check drift (Task 5), define target IA (Task 7). The spec’s candidate dead-criteria are all encoded in
classify(), with the filename-date refinement justified by grounding. - Placeholders: none; every code step is complete; content tasks (5, 7) enumerate their exact edits from recorded findings.
- Type consistency:
FileFacts/classify/filename_date(Task 1) match their uses in Task 3’s CLI;load_allowlist(Task 6) matches theaudit.pycall;SYSTEM_NAMESPACES(Task 5) matches theaudit.pyfilter expression.