2026-07-03 15:12:31 +00:00
---
2026-07-03 15:23:24 +00:00
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>]` .
2026-07-03 15:12:31 +00:00
---
# 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 `.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
2026-07-03 15:23:24 +00:00
Passed through from `/os-doc-hygiene:check` :
2026-07-03 15:12:31 +00:00
- `--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 `.dochygiene/` is already git-ignored, and offer to add it if not.
```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 .dochygiene` 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 `.dochygiene/` at the project root. Per invariant #3, this directory should be gitignored so it doesn't appear as untracked in your repo. Shall I append `.dochygiene/` 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 '.dochygiene/\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 `.dochygiene/` may appear as untracked/dirty in `git status` until 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:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/scanner.py" [--globs < glob > ...] > "$SCRATCH/scan.json"
```
- Omit `--globs` when there is no `--scope` .
- The scanner auto-resolves the project root from `cwd` and applies default
excludes (incl. `.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 `--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:
2026-07-03 15:23:24 +00:00
${CLAUDE_PLUGIN_ROOT}/skills/check/workflows/classify-candidates.md
2026-07-03 15:12:31 +00:00
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 .dochygiene/report.json + report.md; last_check=" + report["generated_at"])
'
```
(`SCRATCH` must be exported so the `-c` process can read it.) This writes
`.dochygiene/report.json` and `.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 . dochygiene / report . md , or its header + per-group bullet lines >
Reports written:
< project-root > /.dochygiene/report.json
< project-root > /.dochygiene/report.md
2026-07-03 15:23:24 +00:00
Run /os-doc-hygiene:clean to act on these (Phase 4), or /os-doc-hygiene:status for timestamps.
2026-07-03 15:12:31 +00:00
```
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` , or `raw_tokens` — those are
owned by `report_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 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.