14 KiB
| 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: scan → classify → finalize → validate → write → stamp. The 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.
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_ROOTenvironment variable to be set (Claude Code sets it at runtime). Every script path and the Step 6/7python3 -cinvocations 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--globsvalue (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 andsignalsentries 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 likeversion_skewcan map to several classes/subtypes — so this filter is applied after classification, at the entry stage (Step 3.5), NEVER at candidate selection.classisstaleorbloat;subtypeis 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 (
ROOTempty — project is not a git repo): skip silently.resolve_project_rootfalls back to cwd; no.gitignoreoffer is meaningful. -
Already ignored (
git -C "$ROOT" check-ignore -q .cc-osexits0): silent no-op. Proceed to Step 1. -
Not ignored (exit
1): present the one-line offer:doc-hygienestores 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
.gitignoreif 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 ingit statusuntil ignored.
Do NOT append without explicit user confirmation. (Invariant #3.)
Step 1 — (D) Scan
Run the scanner, capturing its stdout artifact to the scratch dir:
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/scanner.py" [--globs <glob> ...] > "$SCRATCH/scan.json"
- Omit
--globswhen there is no--scope. - The scanner auto-resolves the project root from
cwdand applies default excludes (incl..cc-os/and legacy.dochygiene/). Do not pass--root.
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
--categoryhere. The scanner is category-agnostic; you cannot know a file's class/subtype until the model has read it.--categoryis applied later, at Step 3.5. - (If a bare-path
--scopewas given, the shortlist/signalswere 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 andlast_checkis 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 whosecategory.classequals it.--category <subtype>(e.g.superseded,distill) → keep proposals whosecategory.subtypeequals 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-mdwrite 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}}. Mapindexback to the offending candidate, re-prompt the subagent (Step 3) to fix only that proposal (or drop it), rewriteproposals.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 afieldpath likeentries[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 — rewriteproposals.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.
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}
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. - Steps 1, 2, 4, 5, 6, 7 are deterministic scripts — no model (invariant #6).
- 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, orraw_tokens— those are owned byreport_builder.py. - 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 envelopegenerated_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.