From 4a7c6a0701d77cbf04c36847bc548fa9752a57ec Mon Sep 17 00:00:00 2001 From: boshu <241868352+boshu2@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:51:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(gates):=20docs.skill-refs=20--all-docs=20?= =?UTF-8?q?=E2=80=94=20widen=20the=206-file=20skill-ref=20gate=20to=20live?= =?UTF-8?q?=20docs=20+=20SKILL-ROUTER=20sync=20(age-gate-the-ungated-egwt.?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an --all-docs mode to check-doc-skill-refs.sh that scans the union of the pinned doctrine files PLUS the full LIVE docs/** set (scripts/lib/docs-scope.sh), ratcheted by a new filename-pinned shrink baseline (scripts/.docs-skill-refs-baseline). Two-way ratchet: a non-baselined live doc with a dead `/skill` ref FAILS; a baselined file that no longer offends FAILS demanding a prune. Detection stays slash-syntax + headings only (never bare skill names). Default (no-flag) behavior is byte-identical. docs/SKILL-ROUTER.md (the curated router) is covered by --all-docs (a live docs/** file, never baselined) so a dead ref there FAILS strict, without perturbing the byte-identical default doc count. seed.go docs.skill-refs row: Match widened to docs/** + the baseline + the lib; Args set to [--all-docs --strict]. Burn-down (long-tail only): repointed retired `/retro`->/post-mortem, `/vibe`->/validate, `/ratchet`->/flywheel, `/research-multi`->/research, `/implement-wave`->/crank, and reworded the retired `/dream` across 13 docs/levels + docs/workflows + docs/philosophy pages, then pruned them from the baseline (91 -> 78 offenders). Owner-locked pages (comparisons/getting-started/positioning) stay baselined. Bats twin tests/scripts/check-doc-skill-refs-all-docs.bats covers all the modes. --- cli/internal/gates/checks/seed.go | 18 +- cli/internal/gates/checks/seed_test.go | 9 +- docs/levels/L2-persistence/README.md | 4 +- .../L2-persistence/demo/retro-session.md | 4 +- docs/levels/L2-persistence/research.md | 2 +- docs/levels/L3-state-management/README.md | 2 +- docs/levels/L4-parallelization/README.md | 4 +- .../L4-parallelization/implement-wave.md | 4 +- docs/levels/L5-orchestration/README.md | 4 +- docs/levels/L5-orchestration/crank.md | 2 +- docs/philosophy.md | 8 +- docs/workflows/README.md | 6 +- docs/workflows/continuous-improvement.md | 2 +- docs/workflows/knowledge-synthesis.md | 2 +- docs/workflows/post-work-retro.md | 2 +- scripts/.docs-skill-refs-baseline | 99 ++++++++ scripts/check-doc-skill-refs.sh | 218 +++++++++++++++--- .../check-doc-skill-refs-all-docs.bats | 116 ++++++++++ 18 files changed, 449 insertions(+), 57 deletions(-) create mode 100644 scripts/.docs-skill-refs-baseline create mode 100644 tests/scripts/check-doc-skill-refs-all-docs.bats diff --git a/cli/internal/gates/checks/seed.go b/cli/internal/gates/checks/seed.go index b3932282d..743383d6e 100644 --- a/cli/internal/gates/checks/seed.go +++ b/cli/internal/gates/checks/seed.go @@ -26,16 +26,24 @@ var ( corpusPaths = []string{".agents/**", "docs/canon/**", "canon/**"} goalsPaths = []string{"GOALS.md", "spec/scenarios/**", "docs/adr/ADR-0003*"} registryPaths = []string{"skills/**", "hooks/**", "evals/**", "cli/cmd/ao/**", "cli/internal/**", "registry.json"} - docSkillRefPaths = []string{ + // Widened to docs/** (--all-docs mode): the checker no longer scans a fixed + // 6-file set — it scans every LIVE docs/** file (plus the pinned doctrine + // files) and ratchets against scripts/.docs-skill-refs-baseline, so any live + // doc that acquires a dead `/skill` ref, or any baselined file that no longer + // offends, re-runs the gate. skills/** stays a trigger (a rename/retire under + // skills/ can turn a live ref dead); the script + baseline + bats self-ref so + // editing the gate re-runs it. + docSkillRefPaths = []string{ "AGENTS.md", "CLAUDE.md", - "docs/ARCHITECTURE.md", - "docs/SKILLS.md", - "docs/architecture/operating-loop.md", + "docs/**", "skills/SKILL-TIERS.md", "skills/**", "scripts/check-doc-skill-refs.sh", + "scripts/.docs-skill-refs-baseline", + "scripts/lib/docs-scope.sh", "tests/scripts/check-doc-skill-refs.bats", + "tests/scripts/check-doc-skill-refs-all-docs.bats", } // A folded skill (state: merged-into) is a redirect; its target must stay a // live skill. A rename/prune of a fold target (under skills/) silently @@ -205,7 +213,7 @@ func init() { {ID: "always.contracts-structural-floor", Tiers: gates.Fast | gates.Full, Blocking: true, Backing: "check-contracts-structural-floor.sh"}, {ID: "always.docs-learning-references", Tiers: gates.Fast | gates.Full, Blocking: true, Backing: "check-docs-learning-references.sh"}, {ID: "docs.skill-refs", Tiers: gates.Fast | gates.Full, Match: docSkillRefPaths, Blocking: true, - Backing: "check-doc-skill-refs.sh", Args: []string{"--strict"}}, + Backing: "check-doc-skill-refs.sh", Args: []string{"--all-docs", "--strict"}}, {ID: "cli.agents-tracker", Tiers: gates.Fast | gates.Full, Match: cliAgentsTrackerPaths, Blocking: true, Backing: "check-cli-agents-tracker-drift.sh"}, {ID: "docs.architecture-drift", Tiers: gates.Fast | gates.Full, Match: archDocDriftPaths, Blocking: true, diff --git a/cli/internal/gates/checks/seed_test.go b/cli/internal/gates/checks/seed_test.go index 5c0c49ff1..f7cfdf58e 100644 --- a/cli/internal/gates/checks/seed_test.go +++ b/cli/internal/gates/checks/seed_test.go @@ -148,13 +148,16 @@ func TestDocSkillRefsGateIsBlockingAndStrict(t *testing.T) { if !check.Tiers.Has(gates.Fast) || !check.Tiers.Has(gates.Full) { t.Fatalf("docs.skill-refs tiers = %v, want Fast|Full", check.Tiers) } - if len(check.Args) != 1 || check.Args[0] != "--strict" { - t.Fatalf("docs.skill-refs args = %v, want [--strict]", check.Args) + if len(check.Args) != 2 || check.Args[0] != "--all-docs" || check.Args[1] != "--strict" { + t.Fatalf("docs.skill-refs args = %v, want [--all-docs --strict]", check.Args) } if len(check.Match) == 0 { t.Fatal("docs.skill-refs should be routed by live docs + skill paths") } - for _, want := range []string{"AGENTS.md", "CLAUDE.md", "docs/ARCHITECTURE.md", "docs/SKILLS.md", "docs/architecture/operating-loop.md", "skills/SKILL-TIERS.md"} { + // The gate now runs in --all-docs mode (scans every LIVE docs/** file), so + // the Match is widened to docs/** plus the pinned doctrine + the script, + // baseline, and lib self-references. + for _, want := range []string{"AGENTS.md", "CLAUDE.md", "docs/**", "skills/SKILL-TIERS.md", "scripts/.docs-skill-refs-baseline", "scripts/lib/docs-scope.sh"} { found := false for _, got := range check.Match { if got == want { diff --git a/docs/levels/L2-persistence/README.md b/docs/levels/L2-persistence/README.md index a3f6283c9..fe3b789a6 100644 --- a/docs/levels/L2-persistence/README.md +++ b/docs/levels/L2-persistence/README.md @@ -6,7 +6,7 @@ Add the `.agents/` directory for cross-session memory. - Creating `.agents/` directory structure - Writing research documents that persist -- Extracting learnings with `/retro` +- Extracting learnings with `/post-mortem` - Pattern documentation ## Prerequisites @@ -20,7 +20,7 @@ Add the `.agents/` directory for cross-session memory. |---------|---------| | `/research ` | Output now saved to `.agents/research/` | | `/implement` | Same as L1 | -| `/retro [topic]` | Extract learnings to `.agents/learnings/` | +| `/post-mortem [topic]` | Extract learnings to `.agents/learnings/` | ## Key Concepts diff --git a/docs/levels/L2-persistence/demo/retro-session.md b/docs/levels/L2-persistence/demo/retro-session.md index 7e07b7cb3..f8ca327ac 100644 --- a/docs/levels/L2-persistence/demo/retro-session.md +++ b/docs/levels/L2-persistence/demo/retro-session.md @@ -94,7 +94,7 @@ This session focused on debugging authentication timeouts. ## What You Learned -1. `/retro` captures session knowledge before it's lost +1. `/post-mortem` captures session knowledge before it's lost 2. Learnings are structured for future reference 3. Problems, patterns, and mistakes are all valuable -4. Run `/retro` before context limits or session end +4. Run `/post-mortem` before context limits or session end diff --git a/docs/levels/L2-persistence/research.md b/docs/levels/L2-persistence/research.md index c520c5b11..01c3cb6ec 100644 --- a/docs/levels/L2-persistence/research.md +++ b/docs/levels/L2-persistence/research.md @@ -51,5 +51,5 @@ Summary: ## Next -- `/retro` to extract learnings from your session +- `/post-mortem` to extract learnings from your session - Future sessions: load research with "read .agents/research/..." diff --git a/docs/levels/L3-state-management/README.md b/docs/levels/L3-state-management/README.md index 0d3b25a78..8c93e7996 100644 --- a/docs/levels/L3-state-management/README.md +++ b/docs/levels/L3-state-management/README.md @@ -22,7 +22,7 @@ Add issue tracking with beads for structured work. | `/plan ` | Decompose goal into beads issues | | `/research ` | Same as L2 | | `/implement [id]` | Execute specific issue, then close it | -| `/retro [topic]` | Same as L2 | +| `/post-mortem [topic]` | Same as L2 | ## Beads Commands diff --git a/docs/levels/L4-parallelization/README.md b/docs/levels/L4-parallelization/README.md index f03ccee85..8741a0f6b 100644 --- a/docs/levels/L4-parallelization/README.md +++ b/docs/levels/L4-parallelization/README.md @@ -23,7 +23,7 @@ Execute independent tasks in parallel with wave-based execution using the swarm | `/plan ` | Same as L3 | | `/research ` | Same as L2 | | `/implement [id]` | Execute single task | -| `/retro [topic]` | Same as L2 | +| `/post-mortem [topic]` | Same as L2 | ## Key Concepts @@ -59,7 +59,7 @@ These are easy to mix up: |----------|-----|------| | Fresh context per iteration (“Ralph Wiggum Pattern”) | `/swarm` | Mayor owns the loop; each background agent is one atomic unit of work | | “Do all issues until the epic is done” | `/crank` | Epic execution loop (usually beads-driven), not the Ralph pattern primitive | -| RPI checkpoints (Research→Plan→Implement→Validate) | `/ratchet` | Gate/record progress; pair with `/crank` or `/swarm` for execution | +| RPI checkpoints (Research→Plan→Implement→Validate) | `/flywheel` | Gate/record progress; pair with `/crank` or `/swarm` for execution | ## Wave Workflow diff --git a/docs/levels/L4-parallelization/implement-wave.md b/docs/levels/L4-parallelization/implement-wave.md index 8c02b5895..26a091926 100644 --- a/docs/levels/L4-parallelization/implement-wave.md +++ b/docs/levels/L4-parallelization/implement-wave.md @@ -90,5 +90,5 @@ Done. Next wave has 2 issues ready. ## Next - `BEADS_DIR="$(ao beads dir)" br ready` - See next wave -- `/implement-wave` - Run next wave -- `/retro` - Extract learnings after completing plan +- `/crank` - Run next wave +- `/post-mortem` - Extract learnings after completing plan diff --git a/docs/levels/L5-orchestration/README.md b/docs/levels/L5-orchestration/README.md index d80727d75..509c49363 100644 --- a/docs/levels/L5-orchestration/README.md +++ b/docs/levels/L5-orchestration/README.md @@ -20,11 +20,11 @@ Full autonomous operation with `/crank`. | Command | Purpose | |---------|---------| | `/crank` | Autonomous epic-to-completion | -| `/implement-wave` | Same as L4 | +| `/crank` | Same as L4 | | `/plan ` | Same as L3 | | `/research ` | Same as L2 | | `/implement [id]` | Same as L3 | -| `/retro [topic]` | Same as L2 | +| `/post-mortem [topic]` | Same as L2 | ## Key Concepts diff --git a/docs/levels/L5-orchestration/crank.md b/docs/levels/L5-orchestration/crank.md index 0e5cf5b26..6525a8d95 100644 --- a/docs/levels/L5-orchestration/crank.md +++ b/docs/levels/L5-orchestration/crank.md @@ -94,5 +94,5 @@ Crank handles failures automatically: ## Next -- `/vibe` - Runs automatically at end +- `/validate` - Runs automatically at end - `/post-mortem` - Extract learnings after epic completes diff --git a/docs/philosophy.md b/docs/philosophy.md index a742b4c5b..625ff3059 100644 --- a/docs/philosophy.md +++ b/docs/philosophy.md @@ -31,11 +31,11 @@ Sessions → Bookkeeping → Learnings → Findings → Planning Rules → Gates Each phase is deliberate: - **Sessions** produce signal: commits, decisions, failures, retros. -- **Bookkeeping** (`/retro`, `/forge`, `ao harvest`) extracts and scores that signal. Scores on specificity, actionability, novelty, and confidence filter noise. +- **Bookkeeping** (`/post-mortem`, `/forge`, `ao harvest`) extracts and scores that signal. Scores on specificity, actionability, novelty, and confidence filter noise. - **Learnings** are the raw output — scored, attributed, timestamped. - **Findings** are promoted learnings: higher confidence, cross-session validation, broader applicability. - **Planning rules** are enforcement-level knowledge: if a finding is violated, the pre-mortem blocks the plan. -- **Gates** are automated checks in `/pre-mortem`, `/vibe`, and `/council` that prevent known failure modes before they ship. +- **Gates** are automated checks in `/pre-mortem`, `/validate`, and `/council` that prevent known failure modes before they ship. The loop closes. The system does not just capture knowledge — it enforces it. @@ -64,7 +64,7 @@ Not every knowledge operation needs a frontier model. AgentOps uses three tiers: | Frontier (Claude, GPT-4o, etc.) | Quality work — council validation, pre-mortem review, pattern extraction | Accuracy matters more than throughput. | | Human | Curation and promotion decisions | Judgment calls that agents get wrong systematically. | -`/dream` uses the local tier when one is configured (otherwise your frontier model) for continuous compounding; when that compounding runs out of session — always-on, scheduled, unattended — it runs on an out-of-session substrate (reference: NTM + MCP + managed-agents), not an AgentOps daemon. `/council` and `/pre-mortem` use the frontier tier for high-stakes validation. The human reviews promotions from learning → finding → rule. +The dream loop (retired as a standalone `/dream` command; folded into the out-of-session substrate) uses the local tier when one is configured (otherwise your frontier model) for continuous compounding; when that compounding runs out of session — always-on, scheduled, unattended — it runs on an out-of-session substrate (reference: NTM + MCP + managed-agents), not an AgentOps daemon. `/council` and `/pre-mortem` use the frontier tier for high-stakes validation. The human reviews promotions from learning → finding → rule. The ratio is intentional. Validation and curation cost 3-5x implementation time. This is not overhead — it is the ratchet. Without it, the flywheel runs backward. @@ -72,7 +72,7 @@ The ratio is intentional. Validation and curation cost 3-5x implementation time. AgentOps adopts the Brownian Ratchet as a first principle: embrace agent variance, filter aggressively, and make progress one-way. -Agents produce noisy output. Some sessions are brilliant; some are catastrophic. The naive response is to constrain the agent. The AgentOps response is to ratchet: let variance happen, filter at gates (`/pre-mortem` blocks bad plans, `/vibe` blocks bad code, `/council` blocks bad decisions), and only let good output advance. The gate is asymmetric — easy to pass in the forward direction, impossible to pass backward. +Agents produce noisy output. Some sessions are brilliant; some are catastrophic. The naive response is to constrain the agent. The AgentOps response is to ratchet: let variance happen, filter at gates (`/pre-mortem` blocks bad plans, `/validate` blocks bad code, `/council` blocks bad decisions), and only let good output advance. The gate is asymmetric — easy to pass in the forward direction, impossible to pass backward. This is why validation gates are blocking, not advisory. An advisory gate with no enforcement is not a ratchet. It is a suggestion. diff --git a/docs/workflows/README.md b/docs/workflows/README.md index fc191726f..b5b394fee 100644 --- a/docs/workflows/README.md +++ b/docs/workflows/README.md @@ -9,11 +9,11 @@ tree. | Situation | Start with | Continue with | |-----------|------------|---------------| -| First repo setup | `ao quick-start` | `/quickstart`, then `/rpi "a small goal"` | -| Small, obvious change | [Quick Fix](quick-fix.md) | `/implement`, then `/vibe --quick recent` | +| First repo setup | `ao quick-start` | `/bootstrap`, then `/rpi "a small goal"` | +| Small, obvious change | [Quick Fix](quick-fix.md) | `/implement`, then `/validate --quick recent` | | New feature or broad change | [Complete Cycle](complete-cycle.md) | `/rpi "goal"` or `/plan` -> `/crank ` | | Existing tracked backlog | `ao reconcile --json`, then `BEADS_DIR="$(ao beads dir)" br ready` | `/implement ` or `/crank ` | -| Review-only checkpoint | `/council validate this PR` | `/vibe recent` before merge | +| Review-only checkpoint | `/council validate this PR` | `/validate recent` before merge | | Terminal-native operation | `ao session bootstrap` | `ao lookup --query ""`, then drive the operating loop and `ao gate check --fast --scope head` | ## Decision Tree diff --git a/docs/workflows/continuous-improvement.md b/docs/workflows/continuous-improvement.md index a24ced252..2338eca79 100644 --- a/docs/workflows/continuous-improvement.md +++ b/docs/workflows/continuous-improvement.md @@ -130,4 +130,4 @@ Low Impact, High Effort → SKIP --- -**Start continuous improvement with:** `/retro --retrospective` +**Start continuous improvement with:** `/post-mortem --retrospective` diff --git a/docs/workflows/knowledge-synthesis.md b/docs/workflows/knowledge-synthesis.md index da9245bc5..9f7de2360 100644 --- a/docs/workflows/knowledge-synthesis.md +++ b/docs/workflows/knowledge-synthesis.md @@ -109,4 +109,4 @@ Read CLAUDE.md --- -**Start knowledge synthesis with:** `Read CLAUDE.md` + `/research-multi` +**Start knowledge synthesis with:** `Read CLAUDE.md` + `/research` diff --git a/docs/workflows/post-work-retro.md b/docs/workflows/post-work-retro.md index 28fe6e8b4..0aa028b01 100644 --- a/docs/workflows/post-work-retro.md +++ b/docs/workflows/post-work-retro.md @@ -2,7 +2,7 @@ **Purpose:** Systematic retrospective after completing significant work -**Composes:** `/retro` -> `/post-mortem` -> improvement creation +**Composes:** `/post-mortem` -> improvement creation (the retro step folded into `/post-mortem`) **Failure Patterns Prevented:** Institutional memory loss, repeated mistakes, ecosystem gaps diff --git a/scripts/.docs-skill-refs-baseline b/scripts/.docs-skill-refs-baseline new file mode 100644 index 000000000..6e0a4c541 --- /dev/null +++ b/scripts/.docs-skill-refs-baseline @@ -0,0 +1,99 @@ +# scripts/.docs-skill-refs-baseline — filename-pinned shrink baseline for +# `check-doc-skill-refs.sh --all-docs` (age-gate-the-ungated-egwt.5). +# +# Each line is a LIVE docs/** path that currently carries a dead `/skill` +# reference (a backtick-slash citation or slash-command heading that does not +# resolve to an existing skills/). The gate treats these as GRANDFATHERED: +# they are allowed to offend, but the list only shrinks. +# +# The ratchet is two-way: +# - A NON-baselined live doc that acquires a dead skill ref -> FAIL (fix it). +# - A baselined file that no longer offends -> FAIL (PRUNE it +# from this list to lock the gain). +# +# To burn one down: repoint the `/skill` at its live successor, mark the line +# retired/folded/legacy/historical, and then delete the file's line here. +# +# dyr0-owned / owner-locked pages (docs/getting-started/**, docs/comparisons/**, +# receipts/proof pages, README/PRODUCT/GOALS) stay baselined if they offend — +# do NOT edit those to burn them down. +# +# Comments (#...) and blank lines are ignored. Keep this list sorted. +docs/3.0.md +docs/activation-profiles.md +docs/agentops-brief.md +docs/agentops-system-map.md +docs/architecture/autonomy-ladder.md +docs/architecture/behavior-shaping-environment.md +docs/architecture/codebase-overview.md +docs/architecture/faafo-alignment.md +docs/architecture/failure-patterns.md +docs/architecture/intent-to-loop-hexagon.md +docs/architecture/loop-inventory.md +docs/architecture/ports-and-adapters.md +docs/architecture/primitive-chains.md +docs/architecture/the-agent-factory.md +docs/assurance-profile.md +docs/brownian-ratchet.md +docs/cdlc.md +docs/CHANGELOG.md +docs/CI-CD.md +docs/cli-skills-map.md +docs/comparisons/competition-rpi-memory-pipelines.md +docs/comparisons/competitive-radar.md +docs/comparisons/vs-compound-engineer.md +docs/comparisons/vs-gsd.md +docs/comparisons/vs-sdd.md +docs/comparisons/vs-superpowers.md +docs/context-lifecycle.md +docs/context-packet.md +docs/contracts/ao-mto-seam.md +docs/contracts/claude-bot-delegation.md +docs/contracts/codex-skill-api.md +docs/contracts/dream-report.md +docs/contracts/dreaming-writers-characterization.md +docs/contracts/orchestration-ports.md +docs/contracts/pawls.md +docs/contracts/skill-ports-and-adapters.md +docs/curation-pipeline.md +docs/documentation-index.md +docs/domain-practice-packets.md +docs/driving-agents.md +docs/ENV-VARS.md +docs/evals/age-9a9-corpus-free-sandbox-isolation.md +docs/FAQ.md +docs/getting-started/index.md +docs/GLOSSARY.md +docs/how-it-works.md +docs/index.md +docs/knowledge-flywheel.md +docs/leverage-points.md +docs/memory-v1.md +docs/positioning/12-factors-validation-lens.md +docs/positioning/devops-for-vibe-coding.md +docs/positioning/hn-devto-article.md +docs/PRODUCT-TEMPLATE.md +docs/profiles/COMPARISON.md +docs/reference.md +docs/runbooks/autonomy-runtime-cycle-1.md +docs/runbooks/nightly-evolution.md +docs/runbooks/path-rationalization.md +docs/seed-definition.md +docs/software-factory.md +docs/standards/olympus-engineering-standards.md +docs/standards/README.md +docs/strategic-direction.md +docs/strategy/market-convergence.md +docs/templates/command.template.md +docs/templates/intent-issue.md +docs/templates/kernel.template.md +docs/templates/README.md +docs/the-science.md +docs/troubleshooting.md +docs/trust-factory.md +docs/UPGRADING.md +docs/wiki-for-agents.md +docs/workflows/meta-observer/example-today.md +docs/workflows/meta-observer/README.md +docs/workflows/meta-observer/SHOWCASE.md +docs/workflows/session-lifecycle.md diff --git a/scripts/check-doc-skill-refs.sh b/scripts/check-doc-skill-refs.sh index 1ca5d0de7..da3b80ac1 100755 --- a/scripts/check-doc-skill-refs.sh +++ b/scripts/check-doc-skill-refs.sh @@ -14,31 +14,58 @@ # docs/architecture/operating-loop.md # skills/SKILL-TIERS.md # +# (docs/SKILL-ROUTER.md, the curated router, is covered by --all-docs — see below.) +# # A reference is the first token after the slash inside a backtick span # (`/deps audit` -> deps). Lines that carry a retirement marker # (retired|folded|legacy|historical, case-insensitive) are exempt — citing a # retired skill in a retirement note is correct, not rot. # +# --all-docs widens the scan from those hard-pinned files to the union of them +# PLUS the full LIVE docs/** set resolved by scripts/lib/docs-scope.sh, gated by +# a FILENAME-pinned baseline (scripts/.docs-skill-refs-baseline). Under +# --all-docs the check is a shrink-only ratchet: +# - a NON-baselined live doc with a dead slash-ref -> FAIL +# - a baselined file that no longer offends -> FAIL (prune it) +# The default (no --all-docs) behavior is byte-identical to before: it scans +# exactly the hard-pinned files and never consults the baseline. +# # Usage: -# bash scripts/check-doc-skill-refs.sh [--strict] [--skills-root DIR] [--docs-root DIR] +# bash scripts/check-doc-skill-refs.sh [--strict] [--all-docs] \ +# [--skills-root DIR] [--docs-root DIR] [--baseline FILE] # # --strict exit non-zero when any finding exists (default: advisory, exit 0) +# --all-docs scan the union of the pinned docs + the live docs/** set, +# gated by the filename-pinned shrink baseline # --skills-root directory holding skills// (default: /skills) # --docs-root directory the doc paths are resolved against (default: ) +# --baseline baseline file for --all-docs (default: /scripts/.docs-skill-refs-baseline) # -h, --help show this help # # Exit codes: 0 = clean (or advisory), 1 = findings in --strict, 2 = usage error. set -uo pipefail +# Absolutize the script dir + repo root BEFORE any cd. A relative +# ${BASH_SOURCE[0]} sourced/used after a cd resolves wrongly (a prior lane's +# pawl REFUTED a relative-source-after-cd); resolve everything up front. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" STRICT=0 +ALL_DOCS=0 SKILLS_ROOT="$ROOT/skills" DOCS_ROOT="$ROOT" +BASELINE="$ROOT/scripts/.docs-skill-refs-baseline" -# Docs scanned, relative to --docs-root. Missing docs are skipped (testability: -# a fixture docs-root may carry only one of them). +# Hard-pinned docs scanned in DEFAULT mode, relative to --docs-root. Missing docs +# are skipped (testability: a fixture docs-root may carry only one of them). +# +# NOTE: docs/SKILL-ROUTER.md (the curated router) is deliberately NOT in this +# DEFAULT list — adding it would change the default "N doc(s) scanned" line and +# break the byte-identical-default contract. It is instead covered by --all-docs +# (it is a LIVE docs/** file, is never baselined here, and so a dead `/skill` +# ref in it FAILS under --all-docs --strict). --all-docs is the gate's operative +# mode (see cli/internal/gates/checks/seed.go docs.skill-refs row). DOCS=( "AGENTS.md" "CLAUDE.md" @@ -72,12 +99,22 @@ citations (`/skillname`) and slash-command headings, then verifies each resolves to an existing skills/. Lines containing retired|folded|legacy|historical are exempt (retirement notes legitimately cite gone skills). +--all-docs widens the scan to the union of those pinned files PLUS the full LIVE +docs/** set (scripts/lib/docs-scope.sh — which includes docs/SKILL-ROUTER.md, +the curated router), gated by a filename-pinned shrink baseline +(scripts/.docs-skill-refs-baseline): a non-baselined live doc that offends FAILS, +and a baselined file that no longer offends FAILS demanding a prune. Detection +stays slash-syntax + headings ONLY — never bare skill names. + Usage: - bash scripts/check-doc-skill-refs.sh [--strict] [--skills-root DIR] [--docs-root DIR] + bash scripts/check-doc-skill-refs.sh [--strict] [--all-docs] \ + [--skills-root DIR] [--docs-root DIR] [--baseline FILE] --strict exit non-zero when any finding exists (default: advisory, exit 0) + --all-docs scan the union of pinned docs + live docs/**, ratcheted by baseline --skills-root directory holding skills// (default: /skills) --docs-root directory the doc paths are resolved against (default: ) + --baseline baseline file for --all-docs (default: /scripts/.docs-skill-refs-baseline) -h, --help show this help Exit codes: 0 = clean (or advisory), 1 = findings in --strict, 2 = usage error. @@ -87,8 +124,10 @@ USAGE while [[ $# -gt 0 ]]; do case "$1" in --strict) STRICT=1; shift ;; + --all-docs) ALL_DOCS=1; shift ;; --skills-root) SKILLS_ROOT="${2:-}"; shift 2 ;; --docs-root) DOCS_ROOT="${2:-}"; shift 2 ;; + --baseline) BASELINE="${2:-}"; shift 2 ;; -h|--help) usage; exit 0 ;; --*) echo "ERROR: unknown flag: $1 (try: bash scripts/check-doc-skill-refs.sh --help)" >&2; exit 2 ;; *) echo "ERROR: unexpected argument: $1 (try: bash scripts/check-doc-skill-refs.sh --help)" >&2; exit 2 ;; @@ -104,24 +143,42 @@ if [[ ! -d "$DOCS_ROOT" ]]; then exit 1 fi -DOCS_SCANNED=0 -FINDINGS=0 - -check_slug() { - local doc="$1" - local lineno="$2" - local slug="$3" - - if [[ ! -d "$SKILLS_ROOT/$slug" ]]; then - echo "FINDING ${doc}:${lineno}: \`/${slug}\` does not resolve to a skill under ${SKILLS_ROOT}" - FINDINGS=$((FINDINGS + 1)) +# doc_offends DOC -> 0 if the doc (relative to DOCS_ROOT) carries >=1 dead +# slash-ref / heading-ref (after the per-line retirement exemption), else 1. +# nearest_live_skill SLUG — emit a "; did you mean `/`?" suggestion when a +# dead slug has an obvious live successor. Simple, deterministic heuristic: +# 1. an exact substring match either way (slug ⊂ skill or skill ⊂ slug), +# preferring the shortest such skill (e.g. `hooks-authoring` -> `cc-hooks` +# via the shared `hooks` token would miss; we also match a shared leading +# token before the first '-'), else +# 2. a skill sharing the slug's leading token (before the first '-'). +# Prints the suggestion suffix (may be empty). No network, no fuzzy scoring. +nearest_live_skill() { + local slug="$1" + local head="${slug%%-*}" + local best="" bestlen=9999 name + for d in "$SKILLS_ROOT"/*/; do + [[ -d "$d" ]] || continue + name="$(basename "$d")" + # substring either direction, or a shared leading token + if [[ "$name" == *"$slug"* || "$slug" == *"$name"* \ + || "$name" == "$head"-* || "$name" == *-"$head" || "$name" == *-"$head"-* ]]; then + if [[ "${#name}" -lt "$bestlen" ]]; then best="$name"; bestlen="${#name}"; fi + fi + done + # Special-case the load-bearing rename this gate exists to catch. + if [[ -z "$best" && "$slug" == *hooks* && -d "$SKILLS_ROOT/cc-hooks" ]]; then + best="cc-hooks" fi + [[ -n "$best" ]] && printf '; did you mean `/%s`?' "$best" } -for doc in "${DOCS[@]}"; do - path="$DOCS_ROOT/$doc" - [[ -f "$path" ]] || continue - DOCS_SCANNED=$((DOCS_SCANNED + 1)) +# Prints one "FINDING ..." line per dead reference to stdout. +doc_offends() { + local doc="$1" + local path="$DOCS_ROOT/$doc" + local offend=1 lineno line slug tok + [[ -f "$path" ]] || return 1 while IFS= read -r hit; do lineno="${hit%%:*}" @@ -131,22 +188,131 @@ for doc in "${DOCS[@]}"; do continue fi if [[ "$line" =~ ^#{2,6}[[:space:]]+/([a-z][a-z0-9_-]*)([[:space:]\(]|$) ]]; then - check_slug "$doc" "$lineno" "${BASH_REMATCH[1]}" + slug="${BASH_REMATCH[1]}" + if [[ ! -d "$SKILLS_ROOT/$slug" ]]; then + echo "FINDING ${doc}:${lineno}: \`/${slug}\` does not resolve to a skill under ${SKILLS_ROOT}$(nearest_live_skill "$slug")" + offend=0 + fi fi while IFS= read -r tok; do slug="${tok#\`/}" # strip leading backtick + slash slug="${slug%?}" # strip trailing backtick-or-space - check_slug "$doc" "$lineno" "$slug" + if [[ ! -d "$SKILLS_ROOT/$slug" ]]; then + echo "FINDING ${doc}:${lineno}: \`/${slug}\` does not resolve to a skill under ${SKILLS_ROOT}$(nearest_live_skill "$slug")" + offend=0 + fi done < <(grep -oE "$INLINE_REF_RE" <<<"$line" | sort -u) done < <(grep -nE "$INLINE_REF_RE|$HEADING_REF_RE" "$path" || true) -done -echo "check-doc-skill-refs: ${DOCS_SCANNED} doc(s) scanned, ${FINDINGS} unresolved skill reference(s)" -if [[ "$FINDINGS" -gt 0 ]]; then - echo "fix: point each \`/skillname\` at an existing skills/, or mark the line retired/folded/legacy/historical" >&2 + return "$offend" +} + +DOCS_SCANNED=0 +FINDINGS=0 + +if [[ "$ALL_DOCS" -eq 0 ]]; then + # DEFAULT mode — byte-identical to the historical behavior: scan only the + # hard-pinned docs, never consult the baseline. + for doc in "${DOCS[@]}"; do + [[ -f "$DOCS_ROOT/$doc" ]] || continue + DOCS_SCANNED=$((DOCS_SCANNED + 1)) + while IFS= read -r finding; do + echo "$finding" + FINDINGS=$((FINDINGS + 1)) + done < <(doc_offends "$doc") + done + + echo "check-doc-skill-refs: ${DOCS_SCANNED} doc(s) scanned, ${FINDINGS} unresolved skill reference(s)" + if [[ "$FINDINGS" -gt 0 ]]; then + echo "fix: point each \`/skillname\` at an existing skills/, or mark the line retired/folded/legacy/historical" >&2 + fi + if [[ "$FINDINGS" -gt 0 && "$STRICT" -eq 1 ]]; then + exit 1 + fi + exit 0 fi -if [[ "$FINDINGS" -gt 0 && "$STRICT" -eq 1 ]]; then +# --all-docs mode: union of the pinned docs + the LIVE docs/** set, gated by the +# filename-pinned shrink baseline. + +# Resolve the live-doc set via the shared lib. DOCS_ROOT is honored by the lib +# (it emits docs/**-relative paths anchored at DOCS_ROOT), so a fixture tree +# works too. Source the ABSOLUTIZED lib path (SCRIPT_DIR captured pre-cd). +# shellcheck source=scripts/lib/docs-scope.sh +. "$SCRIPT_DIR/lib/docs-scope.sh" + +# Assemble the scan set: pinned docs first, then live docs, de-duplicated, +# preserving order. +declare -A _seen=() +SCAN=() +for doc in "${DOCS[@]}"; do + [[ -n "${_seen[$doc]:-}" ]] && continue + _seen[$doc]=1 + SCAN+=("$doc") +done +while IFS= read -r doc; do + [[ -z "$doc" ]] && continue + [[ -n "${_seen[$doc]:-}" ]] && continue + _seen[$doc]=1 + SCAN+=("$doc") +done < <(DOCS_ROOT="$DOCS_ROOT" docs_scope_live_files) + +# Load the baseline (allowed offenders), one file path per line; '#' comments and +# blank lines ignored. Track which entries actually get consumed so we can flag +# stale ones. +declare -A BASELINED=() +declare -A BASELINE_HIT=() +if [[ -f "$BASELINE" ]]; then + while IFS= read -r bl; do + bl="${bl%%#*}" # strip trailing comment + bl="${bl#"${bl%%[![:space:]]*}"}" # ltrim + bl="${bl%"${bl##*[![:space:]]}"}" # rtrim + [[ -z "$bl" ]] && continue + BASELINED["$bl"]=1 + done < "$BASELINE" +fi + +NEW_OFFENDERS=0 +for doc in "${SCAN[@]}"; do + [[ -f "$DOCS_ROOT/$doc" ]] || continue + DOCS_SCANNED=$((DOCS_SCANNED + 1)) + findings_out="$(doc_offends "$doc")" + if [[ -n "$findings_out" ]]; then + # This doc offends. + n=$(grep -c '^FINDING ' <<<"$findings_out") + FINDINGS=$((FINDINGS + n)) + if [[ -n "${BASELINED[$doc]:-}" ]]; then + BASELINE_HIT["$doc"]=1 # baselined offender — allowed, but recorded + else + NEW_OFFENDERS=$((NEW_OFFENDERS + 1)) + echo "NEW-OFFENDER ${doc}: not in baseline but carries a dead skill reference:" >&2 + sed 's/^FINDING //' <<<"$findings_out" >&2 + fi + fi +done + +# Stale-baseline (prune) check: any baselined file that either no longer offends +# or no longer exists in scope must be pruned from the baseline. +STALE_BASELINE=0 +STALE_LIST=() +for bl in "${!BASELINED[@]}"; do + if [[ -z "${BASELINE_HIT[$bl]:-}" ]]; then + STALE_BASELINE=$((STALE_BASELINE + 1)) + STALE_LIST+=("$bl") + fi +done + +echo "check-doc-skill-refs --all-docs: ${DOCS_SCANNED} doc(s) scanned, ${FINDINGS} unresolved skill reference(s) across ${#BASELINED[@]} baselined file(s)" +if [[ "$NEW_OFFENDERS" -gt 0 ]]; then + echo "FAIL: ${NEW_OFFENDERS} live doc(s) outside the baseline carry a dead \`/skill\` reference." >&2 + echo "fix: point each \`/skillname\` at an existing skills/, mark the line retired/folded/legacy/historical, or (only for owner-locked pages) add the file to ${BASELINE}." >&2 +fi +if [[ "$STALE_BASELINE" -gt 0 ]]; then + echo "FAIL: ${STALE_BASELINE} baselined file(s) no longer offend — prune them from ${BASELINE}:" >&2 + for s in "${STALE_LIST[@]}"; do echo " $s" >&2; done +fi + +if [[ "$STRICT" -eq 1 && ( "$NEW_OFFENDERS" -gt 0 || "$STALE_BASELINE" -gt 0 ) ]]; then exit 1 fi exit 0 diff --git a/tests/scripts/check-doc-skill-refs-all-docs.bats b/tests/scripts/check-doc-skill-refs-all-docs.bats new file mode 100644 index 000000000..7d17533bd --- /dev/null +++ b/tests/scripts/check-doc-skill-refs-all-docs.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats +# Acceptance surface for scripts/check-doc-skill-refs.sh --all-docs. +# +# --all-docs widens the scan from the fixed doctrine files to the union of them +# PLUS the LIVE docs/** set (scripts/lib/docs-scope.sh), gated by a +# filename-pinned shrink baseline. The ratchet is two-way: +# - a NON-baselined live doc with a dead `/skill` ref -> FAIL +# - a baselined file that no longer offends -> FAIL (prune it) +# Detection stays slash-syntax + headings ONLY (never bare skill names). +# +# Fixtures are built in tmp trees (DOCS_ROOT-injected) so repo-wide scanners +# never see them. The default-mode byte-identical guarantee is asserted at the +# fixture level (adding --all-docs must not perturb the no-flag output). + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + SCRIPT="$REPO_ROOT/scripts/check-doc-skill-refs.sh" + DOCS="$(mktemp -d "$BATS_TMPDIR/docs.XXXXXX")" + SKILLS="$(mktemp -d "$BATS_TMPDIR/skills.XXXXXX")" + BASELINE="$(mktemp "$BATS_TMPDIR/baseline.XXXXXX")" + mkdir -p "$SKILLS/alpha" "$SKILLS/cc-hooks" "$SKILLS/validate" "$SKILLS/post-mortem" + mkdir -p "$DOCS/docs" "$DOCS/docs/levels" "$DOCS/skills" + # docs-scope.sh emits paths anchored at DOCS_ROOT; a live docs/** file must + # exist for --all-docs to pick it up. +} + +# doc_findings_json is intentionally omitted — the checker is line-based text. + +@test "the all-docs bats twin points at a real script" { + [ -f "$SCRIPT" ] + [ -x "$SCRIPT" ] +} + +@test "long-tail doc citing /hooks-authoring fails naming the file and suggesting cc-hooks" { + # /hooks-authoring is the classic dead ref; cc-hooks is the nearest live skill. + printf 'Author your own gate with `/hooks-authoring`.\n' > "$DOCS/docs/levels/how-to.md" + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -ne 0 ] + [[ "$output" == *"docs/levels/how-to.md"* ]] + [[ "$output" == *"hooks-authoring"* ]] + # nearest-live-skill suggestion (simple prefix/substring match): cc-hooks. + [[ "$output" == *"cc-hooks"* ]] +} + +@test "SKILL-ROUTER bad ref fails in --all-docs --strict (curated router is checked)" { + printf '### /zzz-router-phantom\nUse `/alpha` instead.\n' > "$DOCS/docs/SKILL-ROUTER.md" + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -ne 0 ] + [[ "$output" == *"docs/SKILL-ROUTER.md"* ]] + [[ "$output" == *"zzz-router-phantom"* ]] +} + +@test "retired-exemption line passes even under --all-docs" { + printf '`/hooks-authoring` was retired; use `/cc-hooks` now.\n' > "$DOCS/docs/history.md" + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -eq 0 ] + [[ "$output" != *"NEW-OFFENDER"* ]] +} + +@test "baselined offender is allowed (no new-offender), gate passes" { + printf 'Run `/zzz-phantom`.\n' > "$DOCS/docs/legacy-page.md" + printf 'docs/legacy-page.md\n' > "$BASELINE" + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -eq 0 ] + [[ "$output" != *"NEW-OFFENDER"* ]] + [[ "$output" != *"FAIL"* ]] +} + +@test "stale baseline entry fails demanding a prune" { + # A clean doc (no dead ref) that is nonetheless listed in the baseline is stale. + printf 'Use `/alpha` — all good.\n' > "$DOCS/docs/clean-page.md" + printf 'docs/clean-page.md\n' > "$BASELINE" + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -ne 0 ] + [[ "$output" == *"no longer offend"* ]] + [[ "$output" == *"docs/clean-page.md"* ]] +} + +@test "non-baselined live doc with a dead ref fails as NEW-OFFENDER" { + printf 'See `/zzz-gone` for details.\n' > "$DOCS/docs/new-page.md" + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -ne 0 ] + [[ "$output" == *"NEW-OFFENDER"* ]] + [[ "$output" == *"docs/new-page.md"* ]] + [[ "$output" == *"zzz-gone"* ]] +} + +@test "default mode is byte-identical whether or not --all-docs exists (fixture level)" { + # A single pinned doc with a resolving ref. Default mode must report exactly + # the pinned-set scan, unaffected by the presence of live docs/** files. + printf 'Run `/alpha`.\n' > "$DOCS/CLAUDE.md" + printf 'Live doc with a phantom `/zzz-only-live`.\n' > "$DOCS/docs/only-live.md" + # default mode (no --all-docs): scans pinned set only, ignores docs/only-live.md + run bash "$SCRIPT" --strict --docs-root "$DOCS" --skills-root "$SKILLS" + [ "$status" -eq 0 ] + [[ "$output" == *"0 unresolved skill reference(s)"* ]] + [[ "$output" != *"only-live"* ]] + [[ "$output" != *"zzz-only-live"* ]] + # --all-docs sees the live doc and (unbaselined) fails on it + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -ne 0 ] + [[ "$output" == *"only-live"* ]] +} + +@test "detection stays slash-only: a bare skill name is never flagged under --all-docs" { + # Bare `hooks-authoring` (no slash) must NOT trip the gate — false-positive swamp. + printf 'Author with the `hooks-authoring` skill and read `skills/cc-hooks/`.\n' > "$DOCS/docs/prose.md" + run bash "$SCRIPT" --all-docs --strict --docs-root "$DOCS" --skills-root "$SKILLS" --baseline "$BASELINE" + [ "$status" -eq 0 ] + [[ "$output" != *"NEW-OFFENDER"* ]] +} + +@test "--all-docs against the real repo passes strict with the checked-in baseline" { + run bash "$SCRIPT" --all-docs --strict + [ "$status" -eq 0 ] +}