Skip to content

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)”
SignalValueConsequence
Orphans by dirplans/ 94 · superpowers/ 45 · adr/ 18 · operations/ 5Per-directory disposition policy (below)
Status markersonly 27 files carry Status: superseded/completed/…Age + inbound-refs must carry the triage
Bulk lint sweepsreset 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 workspacefails (workspace not git-colocated)age collector tries git, falls back to jj log (template verified)
Cluster driftalloy+merlin doc’d-but-gone; ~12 ns undocumented; litellm ns lingers post-decommissionTask 5 reconciliation list
External links47 failures, almost all inside plans/archive/corroborates archive deletion; no separate link-fix task

Disposition policy (the core Phase 2 rule):

DirectoryPolicy
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)

PathResponsibility
tools/docs-audit/docs_audit/groom.pyPure triage rule engine: FileFacts, classify(), manifest rendering
tools/docs-audit/docs_audit/collectors.pyThin seams: per-file age (git→jj fallback), repo-wide inbound-ref counts (rg)
tools/docs-audit/groom.pyCLI: --manifest (write triage manifest) / --apply (delete + emit bd commands)
tools/docs-audit/groom-manifest.mdGenerated, committed triage manifest (audit trail for the deletions)
tools/docs-audit/orphan-allowlist.txtDeliberately-unlisted files (active specs/plans); consumed by audit.py
tools/docs-audit/docs_audit/orphans.pyModify: find_orphans honors the allowlist
tools/docs-audit/docs_audit/cluster.pyModify: system-namespace allowlist for the ns diff
tools/docs-audit/tests/test_groom.py, test_collectors.pyUnit tests for the pure logic
docs/reference/services.mdDrift reconciliation (Task 5)
mkdocs.ymlNav rewrite covering all kept files (Task 7)

Task 1: groom.pyclassify() 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 re
from dataclasses import dataclass
from 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})-")
@dataclass
class 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 re
import subprocess
from datetime import date
from 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 argparse
from datetime import date
from 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 bead row

For each BEAD NEEDED line, run (filling title/path):

Terminal window
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 (alloy ns gone — replaced by the clickstack/vector pipeline; cross-check docs/operations/clickstack.md for the current shape) and Merlin rows (merlin ns 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 litellm OUT 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)
Terminal window
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 unlisted
superpowers/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 keep file

In mkdocs.yml nav:, working from the manifest’s keep rows:

  • Add an ADRs section under Architecture: Architecture → Decisions (ADRs) listing adr/README.md first (it is the index) and each adr/*.md file. (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: keep plans/index.md + plans/archive/index.md entries 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.yml nav structure read this session; docs/reference/services.md drift 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=%cs confirmed 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).
  • 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 the audit.py call; SYSTEM_NAMESPACES (Task 5) matches the audit.py filter expression.