cc-os/plugins/os-doc-hygiene/skills/hygiene-check/SKILL.md

304 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: hygiene-check
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 `/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 `.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 `/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 `.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:
${CLAUDE_PLUGIN_ROOT}/skills/hygiene-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 .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
Run /hygiene clean to act on these (Phase 4), or /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`, 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.