---
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).

```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 `<ROOT>/.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:
`{ "<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:

```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:    <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.
