--- 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 ] [--category ]`. --- # 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 ` — 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 `/`** before Step 2. Record the effective scope in your Step 8 summary. - `--category ` — 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). ```bash 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 `/.gitignore`? (yes/no) **Only on explicit confirmation ("yes"):** append as follows — never reorder or rewrite existing entries: ```bash 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: ```bash 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: `{ "": [ { "name": "", "detail": "" }, ... ] }`. ### 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: Classify exactly these candidates (path → scanner signals, verbatim): [For each signal-bearing path, paste: - path: signals: ] 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 ` (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: ```bash 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: ```bash 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()`): ```bash 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: category: Reports written: /.cc-os/dochygiene/report.json /.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.