feat(gates): docs.skill-refs --all-docs — widen the 6-file skill-ref gate to live docs + SKILL-ROUTER sync (age-gate-the-ungated-egwt.5)

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.
This commit is contained in:
boshu
2026-07-01 17:51:23 -04:00
parent e8b0c82f0d
commit 4a7c6a0701
18 changed files with 449 additions and 57 deletions
+12 -4
View File
@@ -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"}
// 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,
+6 -3
View File
@@ -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 {
+2 -2
View File
@@ -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 <topic>` | 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
@@ -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
+1 -1
View File
@@ -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/..."
+1 -1
View File
@@ -22,7 +22,7 @@ Add issue tracking with beads for structured work.
| `/plan <goal>` | Decompose goal into beads issues |
| `/research <topic>` | Same as L2 |
| `/implement [id]` | Execute specific issue, then close it |
| `/retro [topic]` | Same as L2 |
| `/post-mortem [topic]` | Same as L2 |
## Beads Commands
+2 -2
View File
@@ -23,7 +23,7 @@ Execute independent tasks in parallel with wave-based execution using the swarm
| `/plan <goal>` | Same as L3 |
| `/research <topic>` | 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
@@ -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
+2 -2
View File
@@ -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 <goal>` | Same as L3 |
| `/research <topic>` | Same as L2 |
| `/implement [id]` | Same as L3 |
| `/retro [topic]` | Same as L2 |
| `/post-mortem [topic]` | Same as L2 |
## Key Concepts
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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.
+3 -3
View File
@@ -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 <epic-id>` |
| Existing tracked backlog | `ao reconcile --json`, then `BEADS_DIR="$(ao beads dir)" br ready` | `/implement <issue-id>` or `/crank <epic-id>` |
| 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 "<topic>"`, then drive the operating loop and `ao gate check --fast --scope head` |
## Decision Tree
+1 -1
View File
@@ -130,4 +130,4 @@ Low Impact, High Effort → SKIP
---
**Start continuous improvement with:** `/retro --retrospective`
**Start continuous improvement with:** `/post-mortem --retrospective`
+1 -1
View File
@@ -109,4 +109,4 @@ Read CLAUDE.md
---
**Start knowledge synthesis with:** `Read CLAUDE.md` + `/research-multi`
**Start knowledge synthesis with:** `Read CLAUDE.md` + `/research`
+1 -1
View File
@@ -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
+99
View File
@@ -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/<dir>). 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
+191 -25
View File
@@ -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/<name>/ (default: <repo>/skills)
# --docs-root directory the doc paths are resolved against (default: <repo>)
# --baseline baseline file for --all-docs (default: <repo>/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/<dir>. 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/<name>/ (default: <repo>/skills)
--docs-root directory the doc paths are resolved against (default: <repo>)
--baseline baseline file for --all-docs (default: <repo>/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 `/<skill>`?" 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
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/<dir>, 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/<dir>, 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
@@ -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 ]
}