Imported from bionic-coding/crux (
crux/skills/audit-docs/SKILL.md). Install upstream withnpx skills add bionic-coding/crux --skill audit-docs. Copyright stays with the author.
Audit Docs
Execution context: this skill runs in a forked subagent and returns a summary to the caller. The fork does not see the main-thread conversation, so pass any needed context explicitly at invocation.
Runtime compatibility
This skill is portable across Claude Code, Codex, and OpenCode. This section overrides platform-specific labels below.
- Before running a command that uses
CRUX_PLUGIN_ROOT, set it to the installed plugin root. In Claude Code, use the value ofCLAUDE_PLUGIN_ROOT. In Codex and OpenCode, derive it from the absolute path of this selectedSKILL.md: the plugin root is the parent of itsskills/directory. In a source checkout, use the checkoutcrux/directory. - For project-local skills, use
.claude/skillsin Claude Code,.agents/skillsin Codex, and.opencode/skillsin OpenCode, which also reads the singular.opencode/skill. SetCRUX_LOCAL_SKILLS_DIRto that path before following any command below that uses it. - Translate Claude Code tool labels such as
Agent,Read,Write,Bash,WebSearch, andWebFetchto the matching capability in the current session. Codex names its own capabilities; OpenCode uses the lowercase formssubagent,read,edit,shell,websearch, andwebfetch, whereeditcovers bothEditandWrite. Do not attempt to invoke the Claude Code labels as literal commands on another host. - Install the generated role agents before delegating:
install-codex-agentsin Codex,install-opencode-agentsin OpenCode. Codex names themcrux_architect,crux_brainstormer,crux_commander,crux_dev_lead,crux_developer,crux_historian,crux_librarian,crux_night_gardener,crux_reviewer, andcrux_wayfinder; OpenCode uses the bare role namesarchitect,brainstormer,commander,dev-lead,developer,historian,librarian,night-gardener,reviewer, andwayfinder. If a required role or capability is unavailable, report that truthfully instead of claiming it ran. - Argument placeholders such as
$adrand$bookbind only in Claude Code. On a host without argument binding they are unset — take the value from the user's phrase. The "Fields OpenCode ignores" section ofOPENCODE_GUIDE.mdnames the invocation-control fields OpenCode ignores.
Overview
The integrity check for the entire docs/ tree. Mirrors the samples audit-vault pattern but spans seven concerns: code, research, ADRs, briefs, journal, promptbooks, and invariants — plus the derived arch spine. Detects every form of drift between docs/index.md, the per-concern indexes, the files on disk, and the cross-concern reference graph (ADR ↔ brief, ADR ↔ research, journal ↔ ADR/promptbook, promptbook ↔ run-snapshot). For the two other regenerated artifacts (docs/code/, the skill catalog) it detects drift and recommends the regenerator; for the arch spine it regenerates in place (CHK-ARCH-1) — arch's inputs are all committed in-tree, so a regenerate can never capture in-flight uncommitted source.
The audit is mostly read-only. Findings are applied in three phases: DRIFT auto-fixes happen with notification; safe-BROKEN fixes happen with notification; destructive-BROKEN fixes require explicit user confirmation. Never auto-deletes a file. Never auto-rewrites an ADR body, a synthesis page's prose, a run-snapshot body, or a journal entry. Never edits past log.md entries.
Around 97 checks across eighteen concern groups plus the schema/inbox layer. Run on every ~10 writes per concern; proactively recommend after a refresh-research-sources batch updating >5 sources, after a mix docs-driving refactor, before any release, and after an init-docs --force or schema migration.
When to use
- User says: "audit", "audit docs", "audit the docs", "check docs", "verify integrity", "find drift", "is the docs tree clean?".
- Proactively after every ~10 writes per concern. Track in
log.md— when the count since the last audit reaches ~10 for a concern, mention it. - Immediately after a
refresh-research-sourcesbatch updating >5 sources. - Before any release or external publication.
- After a schema migration (
audit-docs --migrateruns the audit afterward as part of migration verification — see "The--migratemode" below).
Do not use this skill for:
- Routine per-write verification — each writing skill has its own checklist.
- Regenerating
docs/code/— that'sextract-code-docs. The audit only callsextract-code-docs --dry-runto detect drift. - Repairing destructive findings without user confirmation — surface them, don't act.
Severity tiers
| Tier | Definition | Default action |
|---|---|---|
| BROKEN | A schema or cross-reference contract is violated. Cannot be ignored. | Block clean-audit. Auto-fix if additive and safe; ask if destructive. |
| DRIFT | Counts, dates, indexes, or rollups out of sync with reality. Recoverable bookkeeping. | Auto-fix with notification. |
| WARNING | Informational. May or may not be a problem. | Report only. |
The pipeline
1. Decide walk strategy
Count total .md files under docs/:
find docs -name '*.md' | wc -l
- Fewer than ~100 files: walk inline in the main session using Read/Glob/Grep.
- 100 or more: spawn an Explore agent with the walk prompt below. The agent returns a structured findings report; the main session applies fixes.
Also count per-concern files for the rollup numbers used in docs/index.md.
2. Walk and collect findings
The walk produces a categorized findings list across eleven concern groups (plus the 1.A schema/inbox layer). Each check is named so it can be reported individually.
Concern 0 — Per-repo configuration (the repo-root .crux file, per docs/CLAUDE.md §14)
Runs FIRST — the resolved docs_dir is the docs root every later check walks. This is the repo-root .crux file (per-project config, designed to be committed so the team shares it — works identically untracked), not the user-home ~/.crux/ directory. All ADR-NNNN-* / PB-NNNN-* filename globs and id regexes throughout this skill accept an optional artifact prefix per docs/CLAUDE.md §14.3 (dual-form: ([A-Z][A-Z0-9]{1,9}-)?(PB|ADR)-(\d{4})).
- CHK-CFG-1 Validate the repo-root layout config via the loader semantics: invoke
python3 "${CRUX_PLUGIN_ROOT}/scripts/bionic-config.py"(the retainedcrux-config.pyis a back-compat delegator to the same entrypoint; either works). Delegate — do NOT re-implement the textual/containment validation or read the YAML ad hoc. Per thedocs/CLAUDE.md§14 config contract, the CLI resolves the precedence.bionic.yml> legacy.crux> content-validated bare-directory discovery > default, reporting which won viasource(".bionic.yml" | ".crux" | "discovery:<dir>", with combined forms like".bionic.yml+discovery:<dir>"when a keyless config falls through to discovery). Exit 0 → clean; hold the resolveddocs_dir/artifact_prefixfor the rest of the walk. Exit 1 → BROKEN, reporting the{"error": ...}message verbatim (a malformed/invalid config never falls back to defaults or to the lower-precedence file — per thedocs/CLAUDE.md§14.1 fail-loud rule). Both files absent is clean (discovery resolves the tree;source: "discovery:<dir>"when no config exists —"defaults"is not emitted). A.bionic.ymlcoexisting with a legacy.cruxis EXPECTED during migration and is NOT a finding (the CLI just reportssource: ".bionic.yml"). The invariants concern and the tree relocation both shipped;docs_dirnames the tree and the check suite lives inside it (see §14/§15). - CHK-CFG-2 Prefix consistency. Dual-form scan of artifact ids across the tree with
([A-Z][A-Z0-9]{1,9}-)?(PB|ADR)-(\d{4}); collect the distinct prefix spellings (capture 1, with "bare" counting as one). More than one distinct prefix in the tree, OR the dominant prefix ≠ the current.cruxartifact_prefixvalue → WARNING (mixed trees are valid perdocs/CLAUDE.md§14.3 — ids are immutable history — but one repo SHOULD converge on one prefix; this is where a typo'd prefix likeCRUX-vsCRX-surfaces). - CHK-CFG-3 Split-tree backstop. A crux-shaped docs tree (a directory containing a
manifest.ymlwith a readableschema_version) exists at any location other than the configureddocs_dir— including the default./docs/in a repo whose.cruxrelocates the tree (the silent second tree a pre-.cruxtoolchain creates by ignoring the file) → BROKEN. Surface both paths and recommend consolidation; never auto-delete or auto-merge. - CHK-CFG-4
(type, NNNN)uniqueness across spellings (thedocs/CLAUDE.md§14.3 allocation invariant, asserted mechanically). Dual-form scan([A-Z][A-Z0-9]{1,9}-)?(PB|ADR)-(\d{4})over the tree's artifact filenames and ids; group by (capture 2, capture 3); any pair duplicated across bare/prefixed spellings (e.g. bothPB-0040andCRX-PB-0040, or two different prefixes on the same number) → BROKEN (numbers are never reused, including across prefixes; any max-derivation likewise takes the integer max over capture 3 across bare AND prefixed artifacts).
Concern 1 — Master index and log (mostly DRIFT)
- CHK-MI-1 Every concern enabled in
manifest.yml'sconcerns_enabledhas a corresponding section indocs/index.md. Missing section → DRIFT (auto-add header + rollup). Section for a concern NOT inconcerns_enabled→ WARNING (concern was probably disabled but section wasn't removed; ask).docs/inbox/is NOT a concern — it is cross-concern staging (perdocs/CLAUDE.md§11 "inbox is staging") and is deliberately absent fromconcerns_enabled, so CHK-MI-1 must NOT demand a## Inboxsection indocs/index.md. The inbox is excluded from the concern-section walk exactly likedocs/CLAUDE.md/docs/README.md(see the non-concern excluded-paths list); its only audit coverage is CHK-SCHEMA-1 + CHK-INBOX-1/2/3.archis a concern but is likewise exempt from this master-section requirement — it is derived and regenerated (perdocs/CLAUDE.md§4 "arch"), its catalog is its own<docs_dir>/arch/index.mdplus the spine hash, and a dated master-index rollup row would violate arch's no-timestamp rule. So CHK-MI-1 must NOT demand or auto-add an## Archsection indocs/index.md; arch's coverage is CHK-ARCH-1/2 (Concern 8). - CHK-MI-2 Each concern section's file count in
docs/index.mdmatches the actual file count for that concern. Mismatch → DRIFT (auto-fix). The ADRs rollup has a vendored regenerator:${CRUX_PLUGIN_ROOT}/scripts/generate-index-rollup.pyrebuilds the## ADRs (N)section ofdocs/index.mdas a pure function of ADR frontmatter (--dry-runis its drift gate, exit 1 on drift). For that one section prefer running the regenerator over hand-fixing, and wire its--dry-runinto whatever suite this repo gates on. Every other concern rollup (research, briefs, journal, promptbooks, invariants, code) is prose-checked here only — a passing ADR-rollup gate says nothing about them. - CHK-MI-3
docs/index.mdhas a top-of-file_Last updated: YYYY-MM-DD_line. Missing or stale (older than today after a fix) → DRIFT (set to today on any audit fix; otherwise leave). - CHK-LOG-1 Every
##heading indocs/log.mdmatches the historical-reader op-enum regex defined canonically indocs/CLAUDE.md§6 (the single source of truth — do NOT restate the enum here; read §6). The historical-reader form accepts bothcleanup-campsiteand the legacycleanup(frozen history predating the rename). Malformed → DRIFT-surface (do NOT auto-edit; surface for user review). - CHK-LOG-2 Log entries are in reverse-chronological order (newest first). Out-of-order entries → WARNING (file may have been hand-edited).
- CHK-LOG-3
docs/log.mdexists and has at least the bootstrap entry frominit-docs. Missing → BROKEN (ask; never auto-regenerate). - CHK-LOG-4 Every operation logged with
<op>outside the enum → BROKEN (typo or unauthorized op; surface).
Concern 1.A — Schema version and inbox (see docs/CLAUDE.md §7)
The supported-range predicate is the single-value check schema_version == "5" (the "4"→"5" bump unified the invariants concern and moved the tree; NOT a range): the upgraded plugin operates only on "5" and recognizes "4", "3" and the older "2" solely to report a distinct "needs migration" state. All rules below read manifest.yml's schema_version (a quoted string) with a real YAML parse. CHK-INBOX-1/2/3 are conditional on schema_version == "5" (the supported version — the inbox concern persists at v5) — on a "3"/"2" tree they are silent (CHK-SCHEMA-1 carries the migration signal instead).
- CHK-SCHEMA-1
manifest.yml'sschema_version == "5"(the supported-range predicate). Any other value → BROKEN with the remediationaudit-docs --migrate(a"4"tree needs the 4→5 migration — unify the invariants concern and, for a conventionally-located tree, relocate it tobionic/; a"3"tree needs 3→4 then 4→5; a"2"tree needs all three; a value> "5"or unrecognized means the tree was written by a newer/unknown plugin — surface "tree schema is newer/older than this plugin supports; runaudit-docs --migrateafter aligning plugin versions"). Never auto-bump the value outside--migrate. - CHK-INBOX-1 When
schema_version == "5":docs/inbox/exists. Missing → BROKEN (remediation:audit-docs --migrate). Do NOT auto-create it during a plain audit; the migrate ladder owns that. - CHK-INBOX-2 When
schema_version == "5":docs/research/new/is ABSENT. Present → DRIFT (recommend re-runningaudit-docs --migrateto relocate its contents and remove it; never auto-delete — its contents may be unprocessed drops). - CHK-INBOX-3 When
schema_version == "5": count non-.gitkeepentries indocs/inbox/excluding the_dispatched/subtree (a partially-drainedurls.mdlegitimately counts). Non-empty → WARNING"N items pending in docs/inbox/"(NOT BROKEN — pending drops are normal; the user runsprocess-inboxto triage). Empty → no finding.
Concern 1.B — Invariants (per docs/CLAUDE.md §15; conditional on schema_version == "5" AND invariants in concerns_enabled)
The invariants concern has a ledger under <docs_dir>/invariants/ (a walked concern, gets a docs/index.md section) AND an executable check suite at <docs_dir>/invariants/checks/ reconciled via the .bionic.yml-rooted manifest (NOT a docs concern section — the surface the rules below reconcile). All CHK-INV rules read the canonical schema in docs/CLAUDE.md §15 (do NOT restate it here). When the concern is disabled (or the tree is pre-v4), these rules are silent. Pin-level last_result is the §15.4 aggregate (fail>stale>pass>none). The rules are reference-implemented and tested in ${CRUX_PLUGIN_ROOT}/scripts/check_invariants.py (exit 0 clean / 1 findings, JSON on stdout) — CHK-INV MAY delegate to it, the same pattern CHK-CFG-1 uses for crux-config.py and CHK-CAT-3 for validate-catalog.py.
- CHK-INV-BIJECTION pin↔ledger-page is 1:1: a ledger page with no parseable
id, a duplicateid, or a reconciliation entry naming a pin with no ledger page → BROKEN. - CHK-INV-ORPHAN-CHECK every check in
<docs_dir>/invariants/checks/resolves via the reconciliation to exactly one existing pin; a check tracing to no/missing pin, or acheck_idabsent from the suite → BROKEN. - CHK-INV-DECORATION a
ratifiedpin with no resolvable check OR aggregatelast_result: none→ BROKEN (a trusted pin nothing proves). Anobservedpin with zero checks is NOT flagged (expected known-unknown). - CHK-INV-FAILING a
ratifiedpin whose aggregatelast_resultisfail→ BROKEN;stale→ WARNING. - CHK-INV-DANGEROUS an
observedpin with aggregatelast_result: pass→ WARNING (a green check that may be cementing unratified behavior). Plus survey-debt: surface the count ofobservedpins (never as a coverage percentage).
Concern 1.C — Observations (per docs/CLAUDE.md §17; conditional on observations in concerns_enabled)
The observations concern has a set of records under <docs_dir>/observations/ (a walked concern, gets a docs/index.md section) plus <docs_dir>/observations/index.md, which is hand-maintained by propose-observation / transition-observation — not a regenerated artifact. An index row naming a record with no file, or a file with no index row, is therefore a real finding, never something to silently rewrite. All CHK-OBS rules read the canonical schema in docs/CLAUDE.md §17.1 (do NOT restate it here) and the lifecycle in §17.2. They are reference-implemented and tested in ${CRUX_PLUGIN_ROOT}/scripts/check_observations.py (exit 0 clean / 1 findings, JSON on stdout) — CHK-OBS MAY delegate to it, the same pattern CHK-INV uses for check_invariants.py. When the concern is disabled, these rules are silent, and so is the checker.
<docs_dir>/observations/_surveys/is EXCLUDED from every CHK-OBS record walk. It is the frozen holding area for signed review sheets and their receipts, on thedocs/promptbooks/legacy/model: present on disk, invisible to the concern walk, and carrying nodocs/index.mdsection. The record walk is*.md-scoped and the live sheet is<docs_dir>/observations/survey-SVY-NNNN.yml, so neither the live sheet nor anything under_surveys/is a record — a.ymlbeside the records is never a stray-file finding. Do not glob_surveys/for CHK-OBS-BIJECTION (record↔file), CHK-OBS-ANCHOR, CHK-OBS-EVIDENCE, CHK-OBS-DECIDED, or CHK-OBS-STALE. Its only coverage is the four CHK-OBS-SURVEY-* rules below, which read it as batch state rather than as records.
- CHK-OBS-BIJECTION record↔file is 1:1: a file under
<docs_dir>/observations/with no parseableid, a duplicateid, anidthat disagrees with its filename, an index row naming a record with no file, or a record file whoseidappears in no index row → BROKEN. The last direction is the reverse of the one before it, and both run only whenindex.mdexists. - CHK-OBS-ANCHOR
anchor_idis present on every record and matches^[0-9a-f]{16}$→ BROKEN when it is absent or malformed. No two records inobservedorratifiedstatus share ananchor_id→ BROKEN on a collision. This is what makes a re-mine recognize a fact already recorded (§17.2); a record missing the field is silently skipped by the re-mine, which then proposes a duplicate. - CHK-OBS-EVIDENCE every
evidenceentry matches thepath:line-rangegrammar, and aratifiedrecord resolves every path it cites → BROKEN on an unresolvable path, an unparseable entry, or an emptyevidencelist. A cited path must also resolve to a location inside the repository root: an absolute path, a~-prefixed path, a..traversal, or a path reaching outside the root through a symlink is the same finding. - CHK-OBS-DECIDED a
decidedrecord names adecided_byADR that exists → BROKEN when it does not. A non-decidedrecord carrying a non-nulldecided_by, or adecidedrecord carryingnull, is the same finding. - CHK-OBS-STALE an
observedrecord whoseobserved_dateis older thanobservation.stale_daysinmanifest.yml(absent falls back to 90) → WARNING. Report survey-debt as the count ofobservedrecords, never as a coverage percentage: the denominator would be the facts nobody has written down, which nobody knows.
Four more rules read the batch surfaces contracted in docs/CLAUDE.md §17.5 (do NOT restate that protocol here). Each derives batch state through the one shared function §17.5 names, so a gate never disagrees with the writer.
- CHK-OBS-SURVEY-STUB a receipt under
<docs_dir>/observations/_surveys/SVY-NNNN/recording no completion → WARNING insideobservation.survey_stub_daysinmanifest.yml(absent falls back to 1), which is what a sign-off still in progress looks like, and BROKEN past that window. The remedy is re-running the sign-off, which finishes the publish. - CHK-OBS-SURVEY-VISIBLE a record visible under
<docs_dir>/observations/whose receipt records no completion → BROKEN at any age. No healthy in-flight state produces one, because §17.5 lands completion before visibility. - CHK-OBS-SURVEY-DIGEST a receipt whose recomputed digest no longer matches the sheet archived beside it → BROKEN.
- CHK-OBS-SURVEY-RECORD a
ratifiedrecord whoseanchor_idno receipt covers and behind which noobservationlog op stands → BROKEN. The rule is a disjunction and stays one: the single-record path writes no receipt, so a conjunction would report BROKEN on every record that path wrote. It detects an unrecorded batch and never a forgery, because an actor with write access can write every surface.
Concern 2 — Code (mostly BROKEN if wrong, DRIFT via dispatcher)
- CHK-CODE-1 If
codeis inconcerns_enabled,docs/code/_meta/manifest.jsonexists. Missing → BROKEN (recommendextract-code-docs, do not invent). - CHK-CODE-2 Every row in
_meta/manifest.jsonhas an existing.mdfile atdoc_path. Missing files → BROKEN (likely deleted hand or by a failed write; recommend re-extraction). - CHK-CODE-3 Every
.mdfile underdocs/code/(other thanindex.mdand_meta/manifest.json) is listed in the manifest. Orphan files → BROKEN (these are hand-edits that survived a regenerate, OR a stale file from before the prune step landed). Recommend re-runextract-code-docsto clean (it will delete them). - CHK-CODE-4 Drift detection: invoke
${CRUX_PLUGIN_ROOT}/scripts/extract-code-docs.py --dry-run --config docs/manifest.ymlfrom the repo root. Parse stdout as JSON regardless of exit code (the same exit-code semantics asverify-code-docs: exit 0 + JSON = clean, exit 1 + JSON = drift, non-zero + empty/unparseable stdout = crash). On drift, surface counts as DRIFT and recommend a real run. DO NOT auto-run a real extraction during the audit — that'sextract-code-docs's job, and is destructive enough to warrant explicit user invocation. - CHK-CODE-5
docs/code/index.mdexists and has one section per language namespace underdocs/code/. Missing → DRIFT (will regenerate on next extract; flag).
Concern 3 — Research (mirrors samples audit-vault)
- CHK-RES-1 Every
docs/research/sources/<slug>.mdhas required frontmatter:title,slug,type: source,source_url,source_date,author,captured_at,last_source_check,raw_path,previous_captures,static,tags(the canonical field list —ingest-researchwrites it, this check enforces it). Missing → BROKEN. - CHK-RES-2
slugin frontmatter matches the filename. Mismatch → BROKEN. - CHK-RES-3
raw_pathresolves to an existing directory underdocs/research/raw/. Broken path → BROKEN. - CHK-RES-4 Every entry in
previous_capturesresolves to an existing directory. Broken → BROKEN. - CHK-RES-5 Every directory
docs/research/raw/<YYYY-MM-DD>/<slug>/is referenced by exactly one source page viaraw_pathorprevious_captures. Orphan raws (no reference) → BROKEN. Shared raws (multiple references) → BROKEN. - CHK-RES-6 Every synthesis page under
docs/research/<category>/has required frontmatter:title,slug,type(matches dir),tags,sources,last_reviewed(the canonical synthesis field list —ingest-researchwrites it, this check enforces it). Missing → BROKEN. - CHK-RES-7 Every slug in any synthesis page's
sources:list exists indocs/research/sources/. Dangling → BROKEN. - CHK-RES-8
docs/research/sources.mdexists at the research root with 9 columns matching samples. Missing → BROKEN (do NOT auto-regenerate; ask). Wrong column set → BROKEN. Per-row column mismatches against frontmatter → DRIFT (auto-fix from frontmatter; frontmatter is source of truth). - CHK-RES-9
docs/research/updates.mdexists. Missing → DRIFT (create stub header only — no entries). Every##heading matches## \[\d{4}-\d{2}-\d{2}\] <slug>. Malformed → WARNING (surface, don't edit). - CHK-RES-10 Every
[[research/sources/<slug>]]and[[research/<category>/<slug>]]link resolves. Dangling → BROKEN. - CHK-RES-11 Count
> [contradiction],> [unresolved],> [source updated],> [refresh failed]markers and## Capture gapssections across research pages. → WARNING (report counts).
Concern 4 — ADRs and briefs
-
CHK-ADR-1 Every
docs/adrs/ADR-NNNN-*.mdhas the required frontmatter keyset defined canonically indocs/CLAUDE.md§11.A "Canonical ADR frontmatter schema" (the single source of truth — do NOT restate the field list here; read §11.A). Any field §11.A marksrequiredthat is missing → BROKEN. Theamends:field isoptionalper §11.A (see CHK-ADR-6a for resolution semantics). -
CHK-ADR-1a The frontmatter keyset of
${CRUX_PLUGIN_ROOT}/templates/ADR-template.mdMUST equal the canonical keyset indocs/CLAUDE.md§11.A. Mechanical comparison — set equality, order-independent (the template orders fields differently from §11.A; that is fine): (1) extract the backticked tokens from §11.A's first table column — every entry in that column uses backtick markup, so a row whose field is not backticked is itself a §11.A formatting bug and should be flagged; (2) parse the template's YAML frontmatter for its top-level keys (use a real YAML parse via Bashpython3 -c 'import yaml,sys; print("\n".join(yaml.safe_load(...)))'rather than a line-grep — a grep would miss a key whose value shares the line, e.g.amends: [] # ...); (3) compare the two SETS. Any key in one but not the other → WARNING ("ADR template and §11.A schema have drifted; reconcile against §11.A"). This closes the loop so the template itself cannot silently drift from the schema. A stdlib unit test (${CRUX_PLUGIN_ROOT}/scripts/tests/test_schema_invariants.py) enforces the same invariant in CI. -
CHK-ADR-2
idmatches filename prefix. Mismatch → BROKEN. -
CHK-ADR-3
status∈ {Proposed, Accepted, Deprecated, Superseded}. Invalid → BROKEN. -
CHK-ADR-4 ADR numbering is monotonic and contiguous from 0000 with no gaps and no duplicates. Gap → WARNING; Duplicate → BROKEN. The "ask" on a gap is concrete — distinguish two dispositions by whether the deleted number is still referenced anywhere: scan the tree (ADR
supersedes:/superseded_by:/amends:frontmatter, anyadrs/ADR-NNNNwiki-link, and thedocs/adrs/index.mdrows) for the gap'sADR-NNNN. (1) Gap with NO surviving references → the number is almost certainly a deleted Proposed ADR that was never cited; it is renumberable — recommend the user either reuse the gap by renumbering the next allocation down to close it, or leave it as a permanent hole (numbers are never reused once cited, but an uncited deleted-Proposed hole may be reclaimed). (2) Gap WITH surviving references → some artifact still points at the missingADR-NNNN; it is NOT renumberable — surface every referencing path and recommend either restoring the ADR or fixing the dangling references; do not propose reusing the number. (This is a generic reference scan — crux has no separate id-validation machinery; do not cite one.) -
CHK-ADR-5 State-machine date consistency:
proposed_dateis non-null for every ADR (every ADR was proposed at some point).accepted_dateis non-null iffstatus∈ {Accepted, Deprecated, Superseded} AND the ADR ever passed through Accepted (Proposed → Deprecated is allowed; an ADR with status=Deprecated and accepted_date=null is the "abandoned proposal" case).deprecated_dateis non-null iffstatus == Deprecated.superseded_dateis non-null iffstatus == Superseded.- Any violation → BROKEN (surface; never auto-set a date).
-
CHK-ADR-6 Supersession bidirectionality: for every
superseded_by: ADR-X,ADR-X.supersedescontains this ADR. For every entry in anysupersedes: [...], the target hassuperseded_bypointing back. Mismatch → BROKEN (auto-fixable viatransition-adr --repairon user approval; never silent). -
CHK-ADR-6a Optional top-level
amends:field on an ADR is a YAML list of ADR ids (e.g.,[ADR-NNNN, ADR-MMMM]). When present: every entry must resolve to an existing ADR file underdocs/adrs/. Dangling → BROKEN. Unlikesuperseded_by:/supersedes:,amends:is one-way — the amending ADR records the relationship; the amended ADRs' frontmatter is NOT back-populated and noamended_by:field is enforced. It records narrow-scope decision refinements to predecessor ADRs without overwriting theirsuperseded_by:chain. Distinct fromsupersedes:(which IS bidirectional and replaces the predecessor'ssuperseded_by:). -
CHK-ADR-7
manifest.yml'sadr.next_number> max existing ADR number. Equal-or-less → BROKEN (next allocation would collide). Way greater than max+1 → WARNING (gaps suggest deletions; ask). -
CHK-ADR-8 Every entry in
related_briefs:corresponds to an existingdocs/briefs/BRIEF-<slug>.md. Dangling → BROKEN. -
CHK-ADR-9 Every entry in
related_research:resolves underdocs/research/, behind a fail-closed grammar gate (the entry is author-controlled frontmatter that gets path-joined, so it inherits the §14.2 containment posture — reject, never resolve, any entry with.., a leading/,~, or more than one/): a bare slug matching^[a-z0-9-]+$→docs/research/sources/<slug>.md(backward-compatible); a single-segment path matching^[a-z0-9-]+/[a-z0-9-]+$→docs/research/<category>/<slug>.md(a synthesis page; the explicitsources/<slug>form falls out for free). Any entry matching neither grammar (a traversing entry, or any other shape — trailing slash, uppercase, underscore, etc.) → BROKEN (malformed). No file at the grammar-valid resolved path → BROKEN (dangling). One-way: the target research page is NOT back-populated. -
CHK-ADR-10 Brief-side bidirectionality: every brief listed in any ADR's
related_briefs:has its slug in its ownrelated_adrs:frontmatter. Missing back-ref → DRIFT (auto-fix on briefs; briefs are draft material so back-population is safe). -
CHK-ADR-11 Briefs cited with
status: draft→ WARNING ("ADR-X references brief Y still in draft; decision may need a follow-up"). -
CHK-ADR-12
docs/adrs/index.mdexists with one row per ADR (columns: id, title, status, date, supersedes, superseded_by). Missing rows → DRIFT (auto-add from frontmatter). Orphan rows → BROKEN (ask). -
CHK-ADR-SPEC The ADR body content rule — an ADR body states requirements and postconditions rather than implementation recipe — is defined canonically in
docs/CLAUDE.md§11.D "ADR body content rule" (the single source of truth — do NOT restate the rule or the budget's value here; read §11.D). This rule REPORTS ONLY. It never repairs, never proposes an edit, and never asks: an ADR body pastProposedis frozen, so there is nothing here to fix even when the finding is correct. Severity is WARNING on every finding, including a clear one.Cohort gate — evaluate this FIRST and skip the whole rule when it does not pass. Read
docs/manifest.ymladr.spec_rule_from— that key inside theadr:block, addressed by a real YAML parse and not by a line-grep, which would also match the same key name under a different block. Absent,null, or not an integer ADR number → the rule is inert: emit no findings and no warnings, and say so in the report's ADR section in one line. A tree that never adopted the rule must produce silence, not noise. Otherwise the cohort is every ADR whose number is ≥ that value, across both the active and archived tiers; every ADR below it is exempt permanently.Two findings, per §11.D:
- Over budget without a declaration. Measure the span from the body's
## Contextline through the last non-blank line before## References, per §11.D's rule 3 (## Referencesand any footnote block are outside the span). Over §11.D's stated budget → WARNING, unless the body carries §11.D's justification blockquote immediately under## Context, in which case it is not a finding. Also not a finding when the ADR is itself a source of truth for a shape (§11.D rule 4's exemption). Report the measured span and the budget, so the reader can see the margin. - Body content shaped like a recipe. Heuristic, and stated as one: a fenced code block outside
## References; a call signature or function definition in prose (name(arg=…),def,class); a command recipe with flags; a| field | type |shape table restating a schema the body could have named instead. Each hit → WARNING naming the line, phrased as a prompt to look rather than a verdict.
State the heuristic's error rate in the report, every run. This rule reports bodies that are fine and misses bodies that are not. A reader who mistakes a WARNING here for a defect will try to edit a frozen body, which is the one action the rule forbids.
- Over budget without a declaration. Measure the span from the body's
Concern 4.A — Objectives (per docs/CLAUDE.md §5.B)
<docs_dir>/objectives.md is a top-level, hand-authored file: the mission and goals a decision review measures the decision set against. It belongs to no concern and has no docs/index.md section, so no concerns_enabled entry gates these rules. They run on every audit of a schema_version == "5" tree. A missing file is CHK-OBJ-1's own finding, never a skip. All five rules read the canonical contract in docs/CLAUDE.md §5.B — the frontmatter keyset, the maturity ladder, the body shape, and the Shifts grammar (do NOT restate any of them here). The audit never edits the file. Humans own its content, and reviewed_at moves only after a human read.
- CHK-OBJ-1 the file exists; its frontmatter carries exactly the §5.B keyset;
maturityis in the §5.B enum;reviewed_atis an ISO date not in the future;review_every_daysis a positive integer → BROKEN on any miss. - CHK-OBJ-2 the body carries §5.B's three H2 headings, in §5.B's order, and no other H2 → BROKEN.
- CHK-OBJ-3 when
maturityis notplaceholder: the Mission holds no bracketed template text; at least one goal exists; every goal carries §5.B's four fields with in-enumkindandstatus; goal ids are contiguous from 1 and unique → BROKEN. Silent atplaceholder— an untouched template is thecleanup-campsiteCLN-OBJ-1nudge's concern, not this rule's. - CHK-OBJ-4 every Shifts row carries an ISO date and a
shiftedvalue that ismissionor an existingOBJ-N; everydroppedgoal has a Shifts row naming it → BROKEN. - CHK-OBJ-5
reviewed_atis older thanreview_every_days→ WARNING: the file is due for a human read. Report the age in days and the threshold.
An OBJ-N token in a review report, a brief, or an ADR is a reference to this file. Resolve it against the goals here; a dangling one → BROKEN.
Concern 5 — Promptbooks
docs/promptbooks/legacy/is EXCLUDED from every CHK-PB- walk.* It is the preserved-originals holding area populated bymigrate-promptbooks— a frozen archive of superseded pre-migration.mdsource files, not part of the live promptbook graph. Treat it likedocs/inbox//docs/CLAUDE.md: present on disk, invisible to the concern walk. Do not glob it for CHK-PB-1 (book shape), CHK-PB-5/6 (run-dir walks — itsrun-*.mdare NOT orphan run dirs), CHK-PB-9/ABANDON/RADIUS/SCHEMA (immutable-history shape), CHK-PB-BIND (binding), or CHK-PB-11 (index rebuild — its books are NOT enumerated). Its only coverage is CHK-PB-LEGACY below.
- CHK-PB-1 Glob both
docs/promptbooks/active/PB-NNNN-*.yamlanddocs/promptbooks/{active,archive}/PB-NNNN-*.md(format coexistence). Route by (extension,format_version-present) — the two-key detection table:.yamlwith top-levelformat_version→ new-format. The JSON Schema (${CRUX_PLUGIN_ROOT}/schemas/promptbook.schema.json) is the SSOT for single-document shape (the same indirection CHK-ADR-1/CHK-PB-SCHEMA already use — reference the schema by name, don't restate it). Delegate the shape check: invoke${CRUX_PLUGIN_ROOT}/scripts/validate-promptbook.py --kind promptbook <path>. Same exit-code/JSON contract as CHK-CAT-3 (exit 0 = clean; exit 1 +{"errors":[…]}on stdout = invalid; non-zero + empty/unparseable stdout = crash, surface stderr). Non-zero exit → BROKEN, reporting the validator's JSONerrors(each carriesinstance_path/schema_path)..yamllackingformat_version→ BROKEN (a malformed new-format book; do NOT route to the legacy keyset check)..md→ legacy-markdown: keep the existing required-frontmatter-keyset check (the keyset stated here is canonical):id,title,status,created_at,total_prompts,current_run,current_prompt,forked_from,tags. Missing → BROKEN. (The new-format top-level keys are a superset of this keyset, so the schema enforces the same requirement on the.yamlside.)- Canonical valid example:
${CRUX_PLUGIN_ROOT}/scripts/tests/fixtures/promptbook-valid.yaml(round-trips clean through the validator).
- CHK-PB-2
idmatches filename prefix. Mismatch → BROKEN. - CHK-PB-3
total_promptsagreement, format-detected:- New-format
.yamlbook:total_prompts == len(prompts)(the structured array). Mismatch → DRIFT. Do NOT auto-fix by counting### Prompt Nheadings — a.yamlbook has none, so the heading-count source does not exist; counting it would auto-corrupttotal_promptsto 0. The safe auto-fix istotal_prompts := len(prompts). - Legacy
.mdbook (active):total_promptsmatches the count of### Prompt Nheadings in the body. Mismatch → DRIFT (auto-fixtotal_promptsfrom the body count). Missingtotal_prompts→ DRIFT (set to count from body).
- New-format
- CHK-PB-4
current_prompt ≤ total_promptswhen non-null. Violation → BROKEN. - CHK-PB-5 If
current_runis non-null, the snapshotdocs/promptbooks/runs/<id>-<slug>/run-<current_run>.{yaml,md}exists (check BOTH extensions — a.yamlbook has a.yamlrun, a.mdbook a.mdrun; format symmetry). Neither present → BROKEN (dangling pointer). A non-nullcurrent_runwhose pointed-to run is TERMINAL (status: completedorstatus: abandoned) is EXPECTED — the pointer stays untilarchive-promptbooknulls it, so this is the normal delivered-but-unarchived state, NOT a dangling pointer. The BROKEN condition is unchanged: the pointed-to snapshot file does not exist. - CHK-PB-6 Every
docs/promptbooks/runs/<id>-<slug>/subdir has a parent book inactive/ORarchive/. Orphan run dir (no parent) → BROKEN (ask before deleting). - CHK-PB-7 Every run snapshot has the run-level keys
run_id,book_id,started_at,completed_at,status,current_prompt— for a.yamlrun these are top-level YAML keys (a.yamlrun also carriesformat_version+book_content_hash, covered by CHK-PB-SCHEMA/CHK-PB-BIND); for a.mdrun they are frontmatter.status∈ {in_progress, completed, abandoned}. Missing → BROKEN. - CHK-PB-8 For
status: completedruns:completed_atis non-null AND every prompt state is terminal per the state-enum notes indocs/CLAUDE.md§11.B "Run-snapshot per-prompt shape" (the single source of truth — do NOT restate the enum here; read §11.B). Violation → BROKEN. - CHK-PB-9 Archived books (
docs/promptbooks/archive/) havestatus: archivedAND their FINAL run is archive-eligible by one of the two paths indocs/CLAUDE.md§11.B: eitherstatus: completedwith every prompt terminal (done | skipped | blocked), orstatus: abandonedwithabandonment.kind: deliberate. Violation → BROKEN. (Eligibility is a run-level property; there is no per-prompt flag.) - CHK-PB-10
forked_from: PB-MMMMreferences an existing book (active or archive). Dangling → BROKEN. Note that nothing writes this field any more — it is an accepted, always-null vestige, and the mid-run fork it recorded is deleted (a plan change mid-run is an abandonment plus a successor book that cites its predecessor in prose). The check is retained for the historical corpus; a non-null value on a newly authored book is itself suspect. - CHK-PB-11
docs/promptbooks/index.mdexists with active table + recent-runs list + archive section. Stale counts / missing rows → DRIFT (rebuild from directory walk). One exemption: theprogresscell of an ACTIVE book whosecurrent_runis non-null — whether the pointed-to run isin_progressor already terminal. Advancing a prompt deliberately writes two surfaces and no third — the run snapshot and the book pointer, never this index (seedocs/CLAUDE.md§6 under thepromptbookop). So theprogresscell lags its snapshot by design for the whole life of the run, and reporting that lag would emit a DRIFT finding on every audit run during every live run. The advance that COMPLETES a run writes those same two surfaces and no third, and the book'scurrent_runsurvives that completion. So the lag persists through the delivered-but-unarchived window too, exempt there for the same reason rather than a new one. A laggingprogresscell on any unarchived run is NOT a finding; the next boundary op (archive) rebuilds it. Every other cell, row, and count in the file is checked as stated. - CHK-PB-12
manifest.yml'spromptbook.next_number> max existing PB number. Equal-or-less → BROKEN. - CHK-PB-ABANDON (replaces the retired CHK-PB-13, whose whole subject was the per-prompt archive-eligibility flag that
docs/CLAUDE.md§11.B retired.) For an archived book whose final run hasstatus: abandoned: that run MUST carryabandonment.kind: deliberate. An archived book whose final run isabandonedwithkind: superseded, or with noabandonmentmapping at all, → BROKEN — it was archived on a signal that confers no eligibility (asupersededvalue marks a stale run that a later run's start rolled over). Adeliberateabandonment on a run underactive/is legitimate and unflagged: it means the book is eligible but not yet archived. - CHK-PB-RADIUS For an archived book with
cycle_kind: patch:blast_radiusis present and non-empty, every entry passes the declared-path grammar, and the book's final run carries a non-nullbase_commit. The grammar has one implementation —invalid_blast_radius_entryin${CRUX_PLUGIN_ROOT}/scripts/validate-promptbook.py, the same function the authoring validator and the archive check call, so all three agree by construction. Its rejections are not restated here; read the function. (Restating them produced a partial copy that read as complete and omitted one of them.) Violation → BROKEN. Audit does NOT re-run the git comparison. A historical diff is not reproducible from an archived tree — the base commit may be unreachable, and the working tree has moved on — so re-deriving it would produce a verdict the archive already settled. The mechanical containment check isarchive-promptbook's precondition (check-blast-radius.py), run once at archival against the live tree; this rule audits only that the declaration and its commit boundary are present and well-formed. Immutable-history demotion applies as for CHK-PB-SCHEMA. - CHK-PB-SCHEMA Run-snapshot per-prompt shape, format-detected. Route by (extension,
format_version-present), exactly as CHK-PB-1 does:- New-format
.yamlrun (format_versionpresent) → delegate to the run validator: invoke${CRUX_PLUGIN_ROOT}/scripts/validate-promptbook.py --kind run <path>(same engine, second schema${CRUX_PLUGIN_ROOT}/schemas/run.schema.json— the per-prompt-shape SSOT thatdocs/CLAUDE.md§11.B references by name). Exit-code contract: exit 1 → finding(s) from the validator's JSONerrors; any other non-zero with empty stdout (e.g. the exit-2 YAML-capability error (PyYAML missing; seedocs/CLAUDE.md§10.A)) = crash, surface stderr and NEVER report it as a document finding. The validator covers the per-prompt shape AND the contiguity (n == index+1) post-schema pass; the cross-field rules it cannot express stay in audit-docs (see §11.B-referencing rules + CHK-PB-BIND). A.yamlrun lackingformat_version→ BROKEN (malformed new-format run; do NOT route to the legacy regex path). Canonical valid example:${CRUX_PLUGIN_ROOT}/scripts/tests/fixtures/run-valid.yaml. - Legacy
.mdrun → keep today's regex-against-§11.B path. Validate per-prompt blocks against the canonical schema indocs/CLAUDE.md§11.B (single source of truth — read §11.B for the field list +Stateenum; do NOT restate them here). Block scope: only blocks whose heading matches^## Prompt (\d+)are validated;## Notes/## Summary/## PR Draftand any nested###/####content are ignored, and field-looking lines inside nested subsections are NOT treated as per-prompt fields. Field parse: accept both the bulleted- **Field:**form and the bare**Field:**form. Findings: a §11.B-required field (State/Started/Completed/Result/Artifacts) missing from a block → DRIFT-surface (do NOT auto-edit a snapshot body);Statevalue ∉ the §11.B enum → BROKEN; an unknown**Field:**key → WARNING.blocked-confirmedis a RETIRED field (seedocs/CLAUDE.md§11.B) and is IGNORED, not reported. A frozen.mdsnapshot may carry it; those bodies are immutable and cannot be cleanly retro-fixed, so flagging it as an unknown field would generate a finding nobody may act on. - Immutable-history demotion (applies to BOTH paths): for archived (
docs/promptbooks/archive/parent) ORstatus: completedsnapshots, demote every finding to WARNING (frozen history can't be cleanly retro-fixed) — this includes findings the run validator returns on a.yamlsnapshot.statuswins over path in both directions —status: completedunderactive/is demoted;status: in_progressunderarchive/keeps full severity. Full severity applies only to active/in-progress runs. CHK-PB-SCHEMA does NOT own duplicate-## Prompt Ndetection (that'scleanup-campsiteCLN-PB-4). A stdlib CI test (test_schema_invariants.py) guards the §11.B field-token extraction.
- New-format
- CHK-PB-BIND Run↔book binding integrity for new-format
.yamlruns only. For each.yamlrun snapshot (format_versionpresent,book_idresolvable to an active-or-archived.yamlbook), recompute the bound book's frozen-plan hash with the validator'scompute_book_hash(i.e."sha256:" + sha256(canonical_json(frozen_plan_subset(book)))— the canonical serialization of the book's plan-bearing fields only, excluding the mutable run-state fieldscurrent_run/current_prompt/status; reuse${CRUX_PLUGIN_ROOT}/scripts/validate-promptbook.py'scompute_book_hashso the bytes match the writer's) and compare to the run's storedbook_content_hash. Mismatch → DRIFT, NEVER auto-fixed — recomputing/overwriting the stored hash would erase the evidence that the run-freeze invariant broke. Remediation cites the §4 abandon rule indocs/CLAUDE.md: the plan was edited in place instead of being changed the sanctioned way, which is to abandon the run and author a successor book that cites its predecessor in prose. Surface the mismatch (stored vs. recomputed) and recommend that path. Notes: (1) this is distinct fromCHK-SCHEMA-1(manifestschema_version == "3") — different rule, different concern. (2) An unresolvablebook_id(no active-or-archived book exists) is NOT CHK-PB-BIND — that is the existing CHK-PB-6 "run dir has a parent book" graph rule (a missing book, vs. CHK-PB-BIND's found-but-plan-hash-differs). (3) Legacy.mdruns have nobook_content_hashand are not subject to this rule. (4) Immutable-history note: a mismatch on an archived/completedrun still surfaces as evidence (it documents a past in-place edit) — it is never auto-fixed regardless of run status. - CHK-PB-CYCLE Cycle-coverage invariants for cycle-kind books — the machine-enforced replacement for
dev-cycle's formerly prose-only formula/invariant contract, covering all three cycle kinds —dev-cycle(adr),iterate(verify), andpatch-cycle(patch). Delegated, not restated: the invariants live invalidate-promptbook.py'scycle_coverage_pass, which runs inside the--kind promptbookdelegation CHK-PB-1 already performs — so for a new-format.yamlbook, CHK-PB-1's non-zero exit already surfaces any cycle-coverage finding (foradr/verify: the4N+4M+3K+2formula ⟷modules⟷module_tag-count agreement, the per-module prompt-count "ordinal contract",adr/verifymutual exclusion, the degenerate-cycle andcycle_kind↔shape cross-checks, and the ≥13 floor — all unchanged; forpatch: the constant count of 5, the fixedverify, plan, implement, review, summaryphase sequence, the absence ofmodule_tag/modules, and a non-empty repo-relativeblast_radius). Thepatchtier is a third branch, not a relaxation of the other two — seedocs/CLAUDE.md§11.C. This rule exists to make that coverage explicit and to pin the two scope rules the validator encodes: (a) grandfathered books are skipped — a book withcycle_grandfathered: true(+grandfather_reason) is exempt from the pass (it remains schema-validated); this is the sanctioned migration path for cycle books predating cycle-coverage validation (cycle_kind/cycle_grandfatheredare deliberately OUTSIDE the frozen-plan hash, so adding either never breaks a run's CHK-PB-BIND). (b) legacy.mdcycle books are NOT cycle-coverage-checked — the pass is.yaml-only (it runs only on the--kind promptbookpath); a legacy.mdcycle book keeps the CHK-PB-1 legacy-keyset path and is out of scope (it predates the structuredmodules/module_tagform). Immutable-history demotion (consistent with CHK-PB-SCHEMA): a cycle-coverage finding on an archived (docs/promptbooks/archive/parent) orstatus: completedbook demotes to WARNING (statuswins over path in both directions); full severity (BROKEN, via CHK-PB-1's exit) applies only to active/in-progress cycle books. No separate test fixture is needed here — the invariants are covered bytest_validate_promptbook.py's cycle-coverage cases (theCycleCoveragePassTestsclass); this rule is the audit-side pointer to that engine. - CHK-PB-LEGACY Preservation integrity for the migration holding area. When
docs/promptbooks/legacy/exists: every preserved original under it SHOULD correspond to a live migrated.yaml— i.e. forlegacy/archive/<id>-<slug>.mdthere isdocs/promptbooks/archive/<id>-<slug>.yaml, and forlegacy/runs/<id>-<slug>/run-<RUN-NNN>.mdthere is adocs/promptbooks/runs/<id>-<slug>/run-RUN-NNN.yaml. A preserved original with no corresponding live.yaml→ WARNING (a half-migration; the live.yamlwas reverted or never written — surface, do not auto-fix). The converse (a live.yamlwhose.mdoriginal is missing fromlegacy/) is the half-statemigrate-promptbooksitself refuses to create; if observed → WARNING. This is the ONLY rule that reads underlegacy/; it never validates the preserved.mdagainst any schema (they are frozen pre-migration artifacts).
Concern 6 — Journal
- CHK-JR-1 Every entry in
docs/journal/YYYY-MM.mdfiles has a heading matching## \[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] (decision|implementation|bug|learning|blocker|refactor|meeting|review|misc|release) \|. Malformed → WARNING (surface; never auto-edit). - CHK-JR-2 Entry dates inside a
YYYY-MM.mdfall within that month. Outside-of-month → WARNING (likely a file-naming mistake; ask). - CHK-JR-3
docs/journal/index.mdexists with one row per month file (newest first) listing entry count and the top three category names. Invokegenerate-journal-index.py --dry-runand report its verdict — exit 0 with no drift payload is clean; exit 1 with a drift payload is DRIFT (recommendgenerate-journal-index.py, do not rebuild by hand); a non-emptyvalidation_errorspayload is BROKEN. - CHK-JR-4 Every wiki-link in any journal entry (
adrs/ADR-NNNN,promptbooks/...,research/...targets) resolves. Promptbook book-links resolve lifecycle-agnostically per the resolver defined indocs/CLAUDE.md§11: thePB-NNNNid (§14.3 dual-form regex) is the sole authoritative match key — the slug AND theactive/|archive/path segment are non-authoritative hints — so[[promptbooks/(active|archive)/PB-NNNN-<slug>]],[[promptbooks/PB-NNNN-<slug>]], and[[promptbooks/PB-NNNN]]all resolve iff a book with that id exists underdocs/promptbooks/active/ORarchive/(legacy/excluded). Run-links (promptbooks/runs/<book>/run-RUN-NNN) keep exact-path resolution (runs/never moves). Dangling → WARNING (journal references are historical; user decides whether to update or leave). - CHK-JR-5 Entries are reverse-chronological within each
YYYY-MM.mdfile. Out-of-order → WARNING.
Concern 7 — Catalog
- CHK-CAT-1 Catalog inventory, in two limbs. An inventory alone is strictly weaker than the rule it replaces — a check that walks whatever is present cannot notice that something expected is absent — so the required-file floor rides with it.
- Limb (a) — inventory. Every entry in
${CRUX_PLUGIN_ROOT}/catalog/parses, by extension:json.loadfor.json, the shared minimal-YAML loader's strict catalog path for.yml. Malformed → BROKEN (recommend re-installing the plugin via the marketplace flow; do not auto-regenerate from inside the audit). - Limb (b) — floor. Every required catalog file exists. Absent → BROKEN. The floor is derived, not a third hand-maintained list, and both enumerations ship inside the plugin: the
.jsonfiles are named byvalidate-catalog.py's module-levelREQUIRED_CATALOG_JSONconstant, and the.ymlfiles by itsCATALOG_TARGETSconstant. Both exist for other reasons and are separately gated, so adding a fifth catalog file costs no edit here — it costs the validator target or roster entry it was always going to need. Both sources must travel with the plugin. An earlier version of this rule derived the.jsonhalf from the repo-rootCLAUDE.mdregenerative-outputs roster, which exists in the crux source repo and in no repo the plugin is installed into: downstream the list resolved EMPTY and the floor silently checked nothing. A rule whose input is absent must not degrade to a pass — if neither constant can be read, report BROKEN rather than an empty floor. - Disposition of every other entry.
${CRUX_PLUGIN_ROOT}/catalog/holds regular files with suffix.jsonor.yml, and nothing else. A directory, a symlink, an extensionless file, or any other suffix is BROKEN under limb (a) — reported as a misfiled entry, never parsed and never followed. - Parser-safety mitigations. This rule points a parser at a directory listing rather than at named files, so the widening is bounded explicitly. Trust boundary first:
catalog/is a repo-controlled directory whose every change arrives through code review; it is not an upload target and takes no runtime input, so the bounds below are defense-in-depth against a hostile pull request and against accident, not a perimeter around untrusted data.- No unsafe construction. YAML is read only through the shared loader, whose real-parser path is pinned to safe loading. Arbitrary-object construction is out of contract on both parse paths, and neither path may be swapped for a loader that permits it.
- Forbidden constructs are refused, not merely unused. Safe loading means no arbitrary object construction; it does not mean no expansion, and a real parser expands an anchor happily. So the loader rejects five constructs before accepting a document: a leading
&anchor, a*aliasreference, a<<:merge key, an explicit!tagor%TAGdirective, and a second document in one file. That is what puts quadratic expansion out of reach — a grammar the loader enforces, not a convention the authors observe. The list is six, not five: a flow collection ({...}/[...]) is refused at the start of any key or value token as well. It was originally left out on the theory that it "fails loudly on the fallback path" — it does not.a: {b: &x 1, c: *x}was accepted by both paths, and the two then disagreed silently: the real parser expanded the alias, the fallback kept the literal string. So the flow form was simultaneously an expansion bypass around the other five refusals and a two-path divergence. The two EMPTY forms{}and[]remain legal, because the hazard is a flow collection's contents and an empty one has none — and block YAML has no other spelling for an empty list. - Regular files only; symlinks refused. The walk is non-recursive and tests
is_symlink()beforeis_file(). A symlinked entry is BROKEN and is never followed, so the rule cannot be turned into an arbitrary-file read by adding a link to the directory. - Size caps before read — two of them, at two different scopes. This rule's own ceiling is 1 MiB per entry, and it covers EVERY entry in
catalog/, the.jsonfiles included: an entry over 1 MiB is BROKEN atstat, without being parsed. The shared YAML loader enforces a second, tighter ceiling of 64 KiB (CATALOG_MAX_BYTESin${CRUX_PLUGIN_ROOT}/scripts/_yaml_min.py), checked before the construct scan and before any parse — but only on the.ymlread path, because the.jsonfiles never reach that loader. The two are not a contradiction and neither is redundant: 64 KiB is the binding limit for a hand-authored.yml, and 1 MiB is what stops a 200 MiBskills.jsonfrom being handed tojson.load. Today's largest entry isskills.jsonat roughly 44 KiB and the largest.ymlismodels.ymlat roughly 6 KiB, so both caps sit well clear of live growth. Both bound the source bytes only; neither bounds an expanded object graph, which is why the construct refusal above — not either cap — answers expansion attacks.
- Limb (a) — inventory. Every entry in
- CHK-CAT-2 Every skill listed in
${CRUX_PLUGIN_ROOT}/plugin.jsonskills[]has a corresponding entry incatalog/skills.json(matched by directory-nameid), and vice versa. Orphans in either direction → BROKEN. - CHK-CAT-3 Drift detection: invoke
${CRUX_PLUGIN_ROOT}/scripts/validate-catalog.py --dry-runfrom the repo root. Parse stdout as JSON regardless of exit code (same exit-code semantics asverify-code-docsand CHK-CODE-4: exit 0 + JSON = clean, exit 1 + JSON = drift, non-zero + empty/unparseable stdout = crash). A"surface_absent": truepayload is N/A, never clean — the catalog is the plugin's OWN derived output, so a run outside the plugin's authoring checkout validated the plugin against itself and established nothing about this project. Report it N/A with the payload'sreason, and recommend no regenerator: there is no output here for one to write. Exit 1 with non-emptyadded/changed/removed→ DRIFT (surface counts; recommend runningvalidate-catalog.pywithout--dry-runto regenerate). Validation errors (non-emptyvalidation_errors) → BROKEN. **Read the `warnin
Truncated - read the full file at https://github.com/bionic-coding/crux/blob/2d94f138d210c27faf56220357bdb9ea5d36b2b0/crux/skills/audit-docs/SKILL.md.