392 lines
19 KiB
Markdown
392 lines
19 KiB
Markdown
---
|
||
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.
|