19 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: 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_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 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 unknownschema_versionin 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_rulebookalready drops those rules; nothing further to do here. - If
--scopemaps to explicit--globs, passscope_globs=[...]to theScanner(...)constructor above instead of the CLI's--globsflag (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
cwdand 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
--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. 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 anextraction_dest(repo-durable|cross-repo) forextract-then-delete. The classifier NEVER authorsgit_stateorsafety_tierfor these —report_builder.py/validate_report.pyderive them per the lifecycle tier matrix (scanner-proven + tracked+clean ⇒ auto; everything else, including any classifier-judgedserved_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 byreport_builder.py. For a lifecycle-signal candidate the subagent MAY judge the free-textserved_whencondition and proposedelete/extract-then-deleteplusextraction_dest, but the resultingsafety_tieris still computed downstream, never asserted by the subagent. promotion_candidatesis computed byreport_builder.pyfromconventions.jsonwith 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 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.