cc-os/plugins/os-doc-hygiene/skills/check/SKILL.md

19 KiB
Raw Permalink Blame History

description
Scan the project for stale and bloated documentation and write a hygiene report. Runs the deterministic scanner, dispatches a Sonnet subagent to classify only the signal-bearing candidates, finalizes/validates the machine report deterministically, then writes the report pair and stamps `last_check`. Invoked by `/os-doc-hygiene:check [--scope <glob-or-path>] [--category <class|subtype>]`.

Hygiene Check Skill

Orchestrates one documentation-hygiene check: load rulebook → scan → classify → finalize → validate → write → stamp. The rulebook load, scan, finalize, validation, write, and stamp are deterministic scripts (invariant #6 — no model). Only the per-file classification is a model step, dispatched to a Sonnet subagent.

Lifecycle awareness (ADR-0039/-0041): the scanner now consumes the lifecycle rulebook (global ${CLAUDE_PLUGIN_ROOT}/rulebook.json plus any project .dochygiene-rules.json override, per rulebook.py) while it walks. A directory-rule match prunes the walk and emits one aggregate shortlist entry for that directory; a file-rule match attaches a lifecycle signal (rule_ref, lifetime, served/served_when/served_when_path, age/tier fields) to that file's existing signals. Lifecycle signals flow into the classification subagent as a new signal class alongside stale/bloat — the classifier judges free-text served_when conditions and MAY propose delete or extract-then-delete ops (with an extraction_dest classification: repo-durable vs cross-repo). The classifier never authors git_state or safety_tier for a lifecycle entry — same as every other guardrail field, those stay deterministic, owned by report_builder.py/validate_report.py. The finalize pass also computes the report's promotion_candidates section (deterministic, from conventions.json — no model call) for every classifier-judged lifecycle entry with an applicable, not-yet-adopted convention (archive-bucket, status-frontmatter).

All scripts live under ${CLAUDE_PLUGIN_ROOT}/scripts/. Run them with python3 from the user's project directory (cwd), which is where the project root is resolved. Use the session scratchpad directory for all intermediate artifacts — never write to .cc-os/dochygiene/ until the validated write step.

Precondition: this skill requires the CLAUDE_PLUGIN_ROOT environment variable to be set (Claude Code sets it at runtime). Every script path and the Step 6/7 python3 -c invocations resolve against it; if it is unset, abort the run rather than guessing a path.

Pick a scratch dir once and reuse it for the whole run, e.g. SCRATCH="$(mktemp -d)". The scan artifact, the subagent proposals, and the unvalidated report pair all live there.

Arguments

Passed through from /os-doc-hygiene:check:

  • --scope <glob-or-path> — narrow the scan. A glob (contains *) maps to the scanner's --globs. A bare path does NOT map cleanly to a --globs value (the scanner's glob matcher is unreliable for mid-pattern **), so for a bare path run the scanner unscoped (default **/*.md) and then drop shortlist and signals entries whose path does not start with <path>/ before Step 2. Record the effective scope in your Step 8 summary.
  • --category <class|subtype> — filter which entries are produced. The scanner is category-agnostic — a signal like version_skew can map to several classes/subtypes — so this filter is applied after classification, at the entry stage (Step 3.5), NEVER at candidate selection. class is stale or bloat; subtype is one of the closed enum values below.

If no arguments are given, the scan uses defaults (**/*.md, default excludes).

Workflow

Step 0 — (D / M-GATE) Gitignore preflight

Check whether .cc-os/ is already git-ignored, and offer to add it if not. Per ADR-027, .cc-os/dochygiene/ is the canonical state dir and .cc-os/ is the single shared gitignore entry for all cc-os per-project state (not just doc-hygiene's).

ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT=""

Three cases:

  • No git root (ROOT empty — project is not a git repo): skip silently. resolve_project_root falls back to cwd; no .gitignore offer is meaningful.

  • Already ignored (git -C "$ROOT" check-ignore -q .cc-os exits 0): silent no-op. Proceed to Step 1.

  • Not ignored (exit 1): present the one-line offer:

    doc-hygiene stores its state and report under .cc-os/dochygiene/ at the project root. Per invariant #3 / ADR-027, this directory should be gitignored so it doesn't appear as untracked in your repo. Shall I append .cc-os/ to <ROOT>/.gitignore? (yes/no)

    Only on explicit confirmation ("yes"): append as follows — never reorder or rewrite existing entries:

    if [ -s "$ROOT/.gitignore" ] && [ -n "$(tail -c1 "$ROOT/.gitignore")" ]; then
        printf '\n' >> "$ROOT/.gitignore"
    fi
    printf '.cc-os/\n' >> "$ROOT/.gitignore"
    

    (Creates .gitignore if absent; appends with a leading newline only when the file is non-empty and doesn't already end in one.)

    If the user declines: proceed without editing. Note that .cc-os/dochygiene/ may appear as untracked/dirty in git status until ignored.

Do NOT append without explicit user confirmation. (Invariant #3.)

Step 0.5 — (D) Load the lifecycle rulebook, then scan

The plain scanner.py CLI does not wire up rulebook consumption on its own (Scanner(rulebook=...) is an optional constructor argument) — load the rulebook and construct the scanner in one python3 -c, mirroring the state_store invocation pattern used at Steps 6/7:

export SCRATCH
python3 -c '
import json, os, sys
from pathlib import Path
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from rulebook import load_rulebook, RulebookLoadError
from scanner import Scanner, _resolve_project_root, _git_log_real, _git_commit_time_real

root = _resolve_project_root(Path.cwd())
project_rules = root / ".dochygiene-rules.json"
try:
    rulebook = load_rulebook(project_path=project_rules if project_rules.is_file() else None)
except RulebookLoadError as exc:
    print(json.dumps({"error": "rulebook-load-failed", "detail": str(exc)}))
    sys.exit(2)

scanner = Scanner(
    root=root,
    rulebook=rulebook,
    git_log_fn=_git_log_real,
    git_commit_time_fn=_git_commit_time_real,
)
artifact = scanner.run()
Path(os.environ["SCRATCH"] + "/scan.json").write_text(json.dumps(artifact, indent=2))
print("scan written")
'
  • Exit 2 / {"error": "rulebook-load-failed", ...}hard failure, per spec: unparseable JSON or an unknown schema_version in either rulebook file. STOP here and report the rulebook error to the user before running the scanner at all — do NOT proceed with lifecycle signals silently disabled.
  • Per-rule warnings (an invalid rule, or one missing confirmed_by) are skip-and-warn, not a hard failure — load_rulebook already drops those rules; nothing further to do here.
  • If --scope maps to explicit --globs, pass scope_globs=[...] to the Scanner(...) constructor above instead of the CLI's --globs flag (same scope semantics as before — just via the constructor since this step no longer shells out to the bare CLI).
  • The scanner auto-resolves the project root from cwd and applies default excludes (incl. .cc-os/ and legacy .dochygiene/). Do not pass --root.

Step 1 — (D) Scan

The scan already ran as part of Step 0.5 (rulebook load and scan are one script invocation so the rulebook is never stale relative to the walk). The artifact is at "$SCRATCH/scan.json"; proceed to Step 2.

The artifact is { project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals }. signals is an object keyed by project-root-relative path: { "<path>": [ { "name": "<signal>", "detail": "<text>" }, ... ] }.

Step 2 — (D / logic) Select candidates

Candidates = the keys of signals (signal-bearing paths only). Paths that are in shortlist but absent from signals have zero signals: they are presumptively cleared — they are NOT read by the model and produce no entries.

  • Do NOT filter candidates by --category here. The scanner is category-agnostic; you cannot know a file's class/subtype until the model has read it. --category is applied later, at Step 3.5.
  • (If a bare-path --scope was given, the shortlist/signals were already narrowed to that prefix in the Arguments step.)
  • If there are zero signal-bearing candidates, skip the model step (Step 3) entirely. Set the proposals array to [] and go straight to Step 4 — an empty-entries report is still written and last_check is still stamped.

Step 3 — (M) Classify candidates — Sonnet subagent

Dispatch ONE subagent (Agent tool) to classify all signal-bearing candidates. Use Sonnet (model: sonnet). The subagent reads each candidate file and its scanner signals and returns a SLIM proposal per file (judgment only — no computed fields).

Agent tool parameters:
- subagent_type: "general-purpose"
- model: sonnet
- description: "Classify doc-hygiene candidates"
- prompt: |
    Read and follow the workflow at:
    ${CLAUDE_PLUGIN_ROOT}/skills/check/workflows/classify-candidates.md

    Project root: <scan.project_root>

    Classify exactly these candidates (path → scanner signals, verbatim):
    <candidates>
    [For each signal-bearing path, paste:
       - path: <project-root-relative path>
         signals: <the JSON array from scan.json["signals"][path]>
    ]
    </candidates>

    Return ONLY the JSON array of proposals specified in the workflow.

LOOP GUARD: the subagent prompt MUST point to workflows/classify-candidates.md, NEVER to this SKILL.md (prevents recursive skill invocation, per the commit skill precedent).

SUBAGENT AUTHORIZATION: the subagent is the executor — authorization is terminal. It MUST NOT re-ask for approval or wait for a confirmation that cannot arrive. If it believes it should not proceed, it MUST return its objection as its final result and stop immediately (REPORT-AND-EXIT). The human confirm gate lives upstream in the orchestrator, never inside the subagent.

Wait for the subagent's JSON array. Write it verbatim to "$SCRATCH/proposals.json".

Model escalation: if the subagent flags a file as low-confidence on a hard distinction (stale-vs-bloat; destructive delete-range vs a generative rewrite of the same contradicted/superseded content), re-dispatch only that file to an Opus subagent (model: opus) with the same workflow, and substitute its proposal. Do not escalate the whole batch.

Step 3.5 — (logic) Apply --category filter — entry stage

If --category was given, drop every proposal whose category does not match, BEFORE finalizing. This is deterministic orchestrator logic (no model, no script):

  • --category stale / --category bloat → keep proposals whose category.class equals it.
  • --category <subtype> (e.g. superseded, distill) → keep proposals whose category.subtype equals it.

Rewrite "$SCRATCH/proposals.json" with the filtered array. Files removed here are not errors — they simply produce no entry and will appear under "Cleared" in the human report (cleared = shortlist entries). report_builder.py has no --category flag; the filter lives here. With no --category, pass all proposals through unchanged.

Step 4 — (D) Finalize via report_builder.py

Hand the scan artifact and the proposals to the model-free assembler. It fills the four guardrail fields the model must not author (expected_sha256, safety_tier, is_destructive/is_reversible, raw_tokens) and emits a schema-valid machine report plus a human-report skeleton, writing both to the scratch dir:

python3 "${CLAUDE_PLUGIN_ROOT}/scripts/report_builder.py" \
  --scan "$SCRATCH/scan.json" \
  --proposals "$SCRATCH/proposals.json" \
  --out-json "$SCRATCH/report.json" \
  --out-md "$SCRATCH/report.md"
  • Exit 0 — built. (--out-json/--out-md write files and suppress the stdout bundle, which is exactly what we want for scratch validation.)
  • Exit 1 — a malformed proposal. A structured error is on stderr: {"error":"malformed proposal","detail":{"index":I,"field":F,"message":M}}. Map index back to the offending candidate, re-prompt the subagent (Step 3) to fix only that proposal (or drop it), rewrite proposals.json, and re-run Step 4.
  • Exit 2 — usage / IO error (bad input path or unreadable JSON). Internal bug: stop and report.

For an empty proposals array ([]), this still produces a valid empty-entries report — proceed normally.

Step 5 — (D) Validate BEFORE writing — on the SCRATCH path

StateStore.write_report deletes the prior report pair first, so validating after a write would destroy the last good report (invariant #4). Validate the scratch machine report first:

python3 "${CLAUDE_PLUGIN_ROOT}/scripts/validate_report.py" "$SCRATCH/report.json"
  • Exit 0 — valid. Proceed to Step 6.
  • Exit 1 — invalid. The validator prints all violations (each with a field path like entries[2].exact_edit.anchor). Map each violation back to its entry index, re-prompt the classification subagent (Step 3) to fix only the offending proposals — or drop an unfixable entry — rewrite proposals.json, re-run Step 4 (finalize) and Step 5 (validate). NEVER write an invalid report. Repeat until exit 0.
  • Exit 2 — usage error (internal bug, e.g. the report file is missing or not JSON). Stop and report.

Step 6 + 7 — (D) Write report pair (rollover) AND stamp last_check

Only after Step 5 returns exit 0. StateStore has no CLI; do the write and the stamp in one python3 -c so the last_check timestamp is the report's own envelope generated_at (design step 7 — same run instant, read back from the validated report, not a fresh now()):

python3 -c '
import sys, os, json
from datetime import datetime
from pathlib import Path
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from state_store import StateStore, resolve_project_root

scratch = os.environ["SCRATCH"]
json_blob = Path(scratch + "/report.json").read_text()
md_blob   = Path(scratch + "/report.md").read_text()
report    = json.loads(json_blob)

store = StateStore(resolve_project_root(Path(os.getcwd())))
store.write_report(json_blob, md_blob)            # rollover: keeps exactly one pair
store.set_last_check(datetime.fromisoformat(report["generated_at"]))
print("wrote .cc-os/dochygiene/report.json + report.md; last_check=" + report["generated_at"])
'

(SCRATCH must be exported so the -c process can read it.) This writes .cc-os/dochygiene/report.json and .cc-os/dochygiene/report.md (atomic, one pair) and stamps last_check.

Step 8 — Surface the result

Print the human-report summary plus the two report paths. Read the written human report and show its header + group summary:

doc-hygiene check complete
  scope:    <effective scope, e.g. **/*.md or the bare-path prefix>
  category: <the --category filter, or "all">

<contents of .cc-os/dochygiene/report.md, or its header + per-group bullet lines>

Reports written:
  <project-root>/.cc-os/dochygiene/report.json
  <project-root>/.cc-os/dochygiene/report.md

Run /os-doc-hygiene:clean to act on these (Phase 4), or /os-doc-hygiene:status for timestamps.

The human report header renders scope_globs but has no category field (the frozen report_builder.py does not take one), so surface the active --category here in the skill output rather than expecting it in the report. The human report itself now always includes a Promotion Candidates section (a ## Promotion Candidates heading, (none) when empty), and any entry carrying lifecycle evidence shows a lifecycle: rule=... · lifetime=... · served_when(_path)=... line beneath it — both rendered deterministically by report_builder.py, not authored by the classification subagent.

Closed enums (for reference — the subagent enforces them)

  • category.class ∈ { stale, bloat }
  • stale subtype ∈ { contradicted, orphaned, superseded, provisional, completed-in-place, duplicated }
  • bloat subtype ∈ { distill, split, freeze }
  • op_type ∈ { deterministic, generative }
  • exact_edit.kind ∈ { delete-range, move-to-archive, insert-frontmatter, replace-text, dedupe, delete, extract-then-delete } — the last two are lifecycle ops (ADR-0039): the classifier may propose them for a candidate carrying a lifecycle signal, additionally supplying an extraction_dest (repo-durable | cross-repo) for extract-then-delete. The classifier NEVER authors git_state or safety_tier for these — report_builder.py/validate_report.py derive them per the lifecycle tier matrix (scanner-proven + tracked+clean ⇒ auto; everything else, including any classifier-judged served_when, ⇒ confirm).

Invariants

  • Step 0 check is deterministic (git check-ignore); the offer/confirm is a user gate (M-GATE). The append is deterministic and runs only on explicit confirmation.
  • Step 0.5 (rulebook load + scan), Steps 2, 4, 5, 6, 7 are deterministic scripts — no model (invariant #6). A rulebook load failure is a hard failure (exit 2) — the check stops before scanning, it never proceeds with lifecycle signals silently disabled.
  • Classification = Sonnet; single-file Opus escalation only on low confidence for hard distinctions.
  • The subagent supplies judgment only. It never authors expected_sha256, safety_tier, is_destructive, is_reversible, raw_tokens, or (for lifecycle entries) git_state — those are owned by report_builder.py. For a lifecycle-signal candidate the subagent MAY judge the free-text served_when condition and propose delete/extract-then-delete plus extraction_dest, but the resulting safety_tier is still computed downstream, never asserted by the subagent.
  • promotion_candidates is computed by report_builder.py from conventions.json with no model call — it is not something the classification subagent produces or is asked about.
  • Validate on a scratch path BEFORE write_report (write_report is destructive-first; invariant #4). Never write an invalid report.
  • last_check = the validated report's envelope generated_at (same run instant), not a fresh clock read.
  • Empty shortlist / zero signal-bearing candidates → still write an empty-entries report and still stamp last_check.
  • LOOP GUARD: the classification subagent prompt MUST point to workflows/classify-candidates.md, NEVER to this SKILL.md.
  • SUBAGENT AUTHORIZATION: the classify subagent is the executor; it MUST NOT block waiting for approval. If it objects, REPORT-AND-EXIT — the orchestrator adjudicates. The confirm gate never lives inside the subagent.