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

598 lines
20 KiB
Markdown
Raw Normal View History

---
description: Apply documented hygiene findings to project docs. Loads the current machine report, gates confirm-tier entries, applies deterministic ops via the patch applier, dispatches generative ops to a Sonnet subagent, stages precisely, and produces exactly one git commit. Invoked by `/os-doc-hygiene:clean [--scope <glob-or-path>] [--category <class|subtype>]`.
---
# Hygiene Clean Skill
Orchestrates one documentation-hygiene cleanup run: **load → validate → filter →
gate → preflight → apply → commit → stamp**. The load, validate, filter,
partition, git ops, stage, commit, and stamp are deterministic (invariant #6 — no
model). Only generative distillation is a model step, dispatched to a **Sonnet**
subagent per the LOOP-GUARD pattern.
All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them from the user's
project directory (`cwd`).
> **Precondition:** this skill requires the `CLAUDE_PLUGIN_ROOT` environment
> variable to be set (Claude Code sets it at runtime). If it is unset, abort
> rather than guessing a path.
> **Scratch dir:** pick a scratch dir once at run start and reuse it for all
> intermediate artifacts — the applier JSON result, generative outputs, and the
> baseline ref all live there.
> ```bash
> SCRATCH="$(mktemp -d)"
> ```
## Arguments
Passed through from `/os-doc-hygiene:clean`:
- `--scope <glob-or-path>` — narrow which entries to act on. Applied at **Step 3**
(entry stage), same semantics as the check skill: a glob (contains `*`) matches
`path` against the glob; a bare path filters entries whose `path` starts with
`<path>/`.
- `--category <class|subtype>` — filter entries by `category.class` (`stale` /
`bloat`) or `category.subtype` (e.g., `superseded`, `distill`). Applied at
**Step 3** (entry stage), never at load time.
If no arguments are given, act on all entries in the report.
## Workflow
---
### Step 1 — (D) Load report via StateStore
Resolve the project root and read the current report. If no report exists, stop
immediately and tell the user to run `/os-doc-hygiene:check` first.
```bash
python3 -c '
import os, sys, json
from pathlib import Path
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from state_store import StateStore, resolve_project_root
root = resolve_project_root(Path(os.getcwd()))
store = StateStore(root)
result = store.read_report()
if result is None:
print(json.dumps({"status": "no_report", "root": str(root)}))
else:
json_blob, md_blob = result
print(json.dumps({"status": "ok", "root": str(root), "report_path": str(root / ".dochygiene" / "report.json")}))
'
```
- If `status == "no_report"` → tell the user: "No hygiene report found. Run
`/os-doc-hygiene:check` first to generate one." **STOP.**
- If `status == "ok"` → record `PROJECT_ROOT` and `REPORT_PATH` (the canonical
`.dochygiene/report.json`) for use in subsequent steps.
---
### Step 2 — (D) Re-validate the loaded report
Re-validate the report on disk before doing anything else. An invalid report
indicates a state corruption; do not attempt to apply it.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/validate_report.py" "$REPORT_PATH"
```
- Exit `0` → valid. Proceed to Step 3.
- Exit `1` → invalid. Show the violation list from stdout and stop: "The hygiene
report is invalid. Re-run `/os-doc-hygiene:check` to regenerate it." **STOP.**
- Exit `2` → usage error (file missing after Step 1 succeeded = internal bug).
Stop and report.
---
### Step 3 — (D / logic) Apply scope/category filter — entry stage
Load `report.entries[]` from `$REPORT_PATH`. Apply the optional filters to
produce the **in-scope** entry list, carrying original report indices for
provenance:
```python
import json, fnmatch
from pathlib import Path
report = json.loads(Path(REPORT_PATH).read_text())
entries = report.get("entries", [])
in_scope = [] # list of (original_index, entry)
for i, entry in enumerate(entries):
path = entry["path"]
# scope filter
if SCOPE_ARG:
if "*" in SCOPE_ARG:
if not fnmatch.fnmatch(path, SCOPE_ARG):
continue
else:
if not (path == SCOPE_ARG or path.startswith(SCOPE_ARG.rstrip("/") + "/")):
continue
# category filter
if CATEGORY_ARG:
cat = entry.get("category", {})
if CATEGORY_ARG not in (cat.get("class", ""), cat.get("subtype", "")):
continue
in_scope.append((i, entry))
```
If `in_scope` is empty → report "No in-scope entries. Nothing to clean." **STOP
(no git ops, no commit, no stamp).**
---
### Step 4 — (D / logic) Incompatible-pair detection and partition
Before building the confirm gate list, check for files that carry BOTH a
generative and a deterministic entry in the in-scope set — the applier never
sees generative entries, so this conflict must be caught here:
```python
from collections import defaultdict
by_path = defaultdict(list)
for idx, entry in in_scope:
by_path[entry["path"]].append((idx, entry))
incompatible_files = set()
for path, batch in by_path.items():
has_gen = any(e["op_type"] == "generative" for _, e in batch)
has_det = any(e["op_type"] == "deterministic" for _, e in batch)
if has_gen and has_det:
incompatible_files.add(path)
# Exclude incompatible files entirely; they go into the skipped report
in_scope_clean = [(i, e) for i, e in in_scope
if e["path"] not in incompatible_files]
```
Record `incompatible_files` for the final summary (re-analysis recommended).
Now partition `in_scope_clean` by `(safety_tier, op_type)` — values come
**from the report**, never recomputed:
```python
auto_det = [(i, e) for i, e in in_scope_clean
if e["safety_tier"] == "auto" and e["op_type"] == "deterministic"]
confirm_det = [(i, e) for i, e in in_scope_clean
if e["safety_tier"] == "confirm" and e["op_type"] == "deterministic"]
confirm_gen = [(i, e) for i, e in in_scope_clean
if e["op_type"] == "generative"] # always confirm-tier
```
---
### Step 5 — (M-GATE) Confirm gate — BEFORE any mutation or git op
> **Invariant #7:** this gate must run before the first file write or git
> operation. It runs identically under `/os-doc-hygiene:sweep`.
Only present the gate if `confirm_det` or `confirm_gen` is non-empty. If both
are empty (all entries are `auto`), skip directly to Step 6 — no prompt.
When present, show a single batch-confirm list:
```
Hygiene clean — confirm required before any changes are made:
The following entries require your approval. Auto-tier entries will be
applied silently after you respond.
⚠ IRREVERSIBLE DELETES (delete-range — content will be permanently removed):
[1] docs/stale-notes.md (orphaned / delete-range · confirm) — ~120 tokens
"Orphaned after 2025 refactor; no references remain."
Reversible confirm entries:
[2] CHANGELOG.md (distill / generative-distill · confirm) — ~400 tokens
"Changelog is true but bloated; condense into summary."
Enter the numbers to approve (e.g. "1,2"), "all", or "none" / leave blank to skip all:
```
Rules for display:
- List every `confirm_det` and `confirm_gen` entry (not `auto_det` — those apply
silently).
- Visually distinguish `delete-range` entries (mark `⚠ IRREVERSIBLE`); group
them under a warning header.
- Show: path, `category.subtype`, `op_type`/`kind`, `token_estimate.raw_tokens`,
and `gloss` (or `op` if `gloss` is absent).
- Per-entry opt-out: the user may approve a subset by number, "all", or "none".
After the user responds:
```python
approved_confirm = [entries the user selected]
rejected_confirm = [entries the user did not select]
```
Approved set (goes forward to apply): `auto_det` + `approved_confirm`
(split further below into approved deterministic and approved generative).
```python
approved_det = auto_det + [e for e in approved_confirm if e[1]["op_type"] == "deterministic"]
approved_gen = [e for e in approved_confirm if e[1]["op_type"] == "generative"]
```
If `approved_det` and `approved_gen` are both empty after the user declines all
→ report "No entries approved. Nothing to clean." **STOP (no git ops, no
commit, no stamp).**
---
### Step 6 — (D) Git preflight
> **Gitignore offer (resolve before dirty-tree check):** If `.dochygiene/` is not
> yet gitignored, it will appear as untracked in `git status --porcelain` and may
> trigger a needless WIP checkpoint. Before running `git status`, check:
> ```bash
> git -C "$PROJECT_ROOT" check-ignore -q .dochygiene
> ```
> Exit `0` → already ignored; proceed. Exit `1` → present the same one-line
> confirmation offer described in the check skill's **Step 0**. Resolve it (or let
> the user decline) before evaluating the dirty-tree state below. Do NOT duplicate
> the append logic here — apply it identically to Step 0.
Now perform the first git operation. Record the baseline ref for rollback.
```bash
cd "$PROJECT_ROOT"
git status --porcelain
```
**Clean tree** (`git status --porcelain` outputs nothing):
```bash
BASELINE=$(git rev-parse HEAD)
```
**Dirty tree** (any output from `git status --porcelain`):
Auto-create a WIP checkpoint commit of the user's existing work:
```bash
git add -A # OK here: the cleanup commit uses precise staging; this is user-work, .dochygiene/ is gitignored
git commit -m "wip: checkpoint before hygiene cleanup"
BASELINE=$(git rev-parse HEAD~1) # baseline is BEFORE the checkpoint
```
> The WIP checkpoint is the only place `git add -A` is permitted in this skill.
> The subsequent cleanup commit NEVER uses `-A`.
Record `BASELINE` to the scratch dir for rollback:
```bash
echo "$BASELINE" > "$SCRATCH/baseline.ref"
```
**Untracked filter:** identify untracked candidate files and remove them from
`approved_det` and `approved_gen`. Git cannot stage or revert untracked files.
```bash
git ls-files --others --exclude-standard
```
Compare the output against entry paths. Any entry whose path appears in the
untracked list → move to `skipped_untracked` and remove from the approved sets.
Report them at Step 11 with reason "untracked — add the file to git first."
---
### Step 7 — (D) Apply approved deterministic entries via `patch_applier.py`
Build the comma-separated index list from `approved_det` original report indices:
```python
det_indices_str = ",".join(str(i) for i, _ in approved_det)
```
If `approved_det` is empty, skip this step.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/patch_applier.py" \
--report "$REPORT_PATH" \
--apply-indices "$DET_INDICES" \
> "$SCRATCH/applier_result.json"
APPLIER_EXIT=$?
```
Read `$SCRATCH/applier_result.json`:
```python
result = json.loads(Path(SCRATCH + "/applier_result.json").read_text())
applied = result["applied"] # [{path, kind, entry_index}]
skipped = result["skipped"] # [{path, kind, entry_index, reason, recommend}]
failed = result["failed"] # [{path, kind, entry_index, error}]
staged_paths = result["staged_paths"] # paths to git add (incl. move-to-archive dests as info only)
```
**Rollback check (Trap E):**
Rollback iff `APPLIER_EXIT == 2` OR `failed` is non-empty:
```bash
if [ "$APPLIER_EXIT" -eq 2 ] || python3 -c "import json,sys; r=json.load(open('$SCRATCH/applier_result.json')); sys.exit(0 if r['failed'] else 1)"; then
git reset --hard "$BASELINE"
# Report: "Hard failure during patch application. Rolled back to baseline. No commit created."
# Show failed[] entries.
STOP
fi
```
Partial success (exit 1, `failed[]` empty, some `skipped[]`) is NOT a rollback —
commit what applied and report skipped files with "re-analysis recommended."
**Stage non-move applied files:** use `applied[]` to derive the exact add-set —
do NOT use `staged_paths` directly to avoid re-adding move-to-archive dests:
```bash
# For each entry in applied[] where kind != "move-to-archive":
# git add -- <that entry's path>
# move-to-archive: git mv was already called by the applier (both sides staged);
# do NOT git add the dest again.
for rec in applied:
if rec["kind"] != "move-to-archive":
git add -- "$PROJECT_ROOT/${rec['path']}"
```
---
### Step 8 — (M) Apply approved generative entries — Sonnet subagent
Skip this step if `approved_gen` is empty.
For each approved generative entry (in order):
1. **Confirm the file still exists.** A preceding `move-to-archive` in Step 7
may have removed it. If the file is gone:
- Record as skipped (reason: `source-moved-during-run`).
- Continue to the next entry.
2. **Live-read the file contents now** (not from the report). Generative entries
carry no `expected_sha256`; freshness is guaranteed only by reading
immediately before dispatch:
```python
live_contents = Path(PROJECT_ROOT, entry["path"]).read_text(encoding="utf-8")
```
3. **Dispatch a Sonnet subagent** (LOOP-GUARD — the subagent reads
`workflows/distill.md`, not this SKILL.md):
```
Agent tool parameters:
- subagent_type: "general-purpose"
- model: sonnet
- description: "Distill doc-hygiene generative entry: <entry.path>"
- prompt: |
Read and follow the workflow at:
${CLAUDE_PLUGIN_ROOT}/skills/clean/workflows/distill.md
File path (project-root-relative): <entry.path>
Category: <entry.category.class> / <entry.category.subtype>
Op: <entry.op>
Signals: <entry.signals as JSON>
Live file contents (read NOW — do NOT re-read from disk):
<live_contents>
AUTHORIZATION: you are the executor — authorization is terminal. The
human confirm gate ran upstream; do NOT re-ask for approval or wait for
confirmation. If you believe you should not proceed, return your objection
as your final result and stop immediately (REPORT-AND-EXIT, never block).
```
4. **Receive the subagent result.** Expected return (see `distill.md`):
- For `distill`: a single `new_content` string.
- For `split`: `new_primary_content` + `archived_content` + `archive_dest_path`
(project-root-relative).
- On error/unable: `{"status": "error", "reason": "..."}` → skip entry,
report, continue.
5. **Write the result:**
- For `distill`: overwrite the file with `new_content`.
- For `split`: overwrite the file with `new_primary_content`; create the
archive file at `archive_dest_path` with `archived_content`.
6. **Stage immediately after writing:**
```bash
git add -- "$PROJECT_ROOT/<entry.path>"
# For split, also:
git add -- "$PROJECT_ROOT/<archive_dest_path>"
```
Stage immediately so that a later rollback (`git reset --hard`) reverts it.
7. Record the entry as successfully applied (for Step 11 summary and the commit
message).
**If the subagent errors:** log as skipped (not a hard failure → no rollback).
Continue processing remaining generative entries. Only an `OSError` on write or
a `git add` failure is a hard failure → rollback and STOP (same rollback
procedure as Step 7).
---
### Step 9 — (D) Produce exactly one cleanup commit
Only proceed here if at least one entry was applied (deterministic or
generative). If everything was skipped, go directly to Step 11 (no commit, no
stamp).
Build the commit message:
```python
# Count breakdown for the commit body
auto_count = len([r for r in det_applied if ... safety_tier == "auto"])
confirmed_count = len([r for r in det_applied if ... safety_tier == "confirm"]) + len(gen_applied)
skipped_count = len(all_skipped_entries)
# Op breakdown
from collections import Counter
auto_ops = Counter(r["kind"] for r in auto_applied_records)
confirmed_ops = Counter(
r["kind"] for r in confirmed_det_applied_records
) + Counter(
entry["op"] for _, entry in applied_gen_entries # use category.subtype as label
)
def fmt_ops(counter):
return ", ".join(f"{k} ×{v}" for k, v in sorted(counter.items())) or "none"
total_applied = auto_count + confirmed_count
affected_paths = len({r["path"] for r in all_applied_records})
message = f"""docs: hygiene cleanup ({total_applied} edits across {affected_paths} files)
Auto: {auto_count} ({fmt_ops(auto_ops)})
Confirmed: {confirmed_count} ({fmt_ops(confirmed_ops)})
Skipped (re-analysis recommended): {skipped_count}"""
```
Label mapping for commit body (for clarity):
- `insert-frontmatter` kind → display as `freeze`
- `distill`, `split` generative entries → display by `category.subtype`
Commit via stdin. Capture stdout to extract the SHA:
```bash
COMMIT_OUTPUT=$(git-context commit-apply --message-stdin <<EOF
$COMMIT_MESSAGE
EOF
)
COMMIT_EXIT=$?
```
On success the command prints `✓ Created commit <SHA>`. Extract the SHA:
```bash
COMMIT_SHA=$(echo "$COMMIT_OUTPUT" | grep -oP '(?<=Created commit )[0-9a-f]+')
```
**If `COMMIT_EXIT` is non-zero:** rollback to baseline and abort with a
structured error. The commit did not happen; no stamp.
```bash
if [ "$COMMIT_EXIT" -ne 0 ]; then
git reset --hard "$BASELINE"
echo "Commit failed. Rolled back to $(git rev-parse --short HEAD). No commit created."
STOP
fi
```
---
### Step 10 — (D) Stamp `last_clean` to the commit instant
Only after the commit succeeds. Stamp to the commit's own timestamp, not the
run-start time:
```bash
COMMIT_TS=$(git show -s --format=%cI "$COMMIT_SHA")
```
```bash
export COMMIT_TS
python3 -c '
import os, sys
from pathlib import Path
from datetime import datetime
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from state_store import StateStore, resolve_project_root
root = resolve_project_root(Path(os.getcwd()))
store = StateStore(root)
ts = datetime.fromisoformat(os.environ["COMMIT_TS"])
store.set_last_clean(ts)
print("last_clean stamped to " + ts.isoformat())
'
```
---
### Step 11 — Surface the result
Print a structured summary to the user:
```
doc-hygiene clean complete (commit: <COMMIT_SHA>)
Applied: <N> edits across <M> files
Auto (deterministic): <count>
Confirmed (deterministic): <count>
Confirmed (generative): <count>
Skipped: <count> (re-analysis recommended — re-run /os-doc-hygiene:check)
<for each skipped: path · kind · reason>
Incompatible-ops (skipped): <count>
<for each: path reason: generative+deterministic on same file>
Untracked (skipped): <count>
<for each: path run `git add <path>` then re-run /os-doc-hygiene:check>
```
If a `move-to-archive` was applied, append:
```
Note: move-to-archive ops create link orphans. The next /os-doc-hygiene:check will
flag any new broken_reference signals in files that pointed to the moved doc.
```
If entries were skipped, append:
```
Run /os-doc-hygiene:check to refresh the report and pick up any remaining issues.
```
---
## Failure handling
| Condition | Action |
|-----------|--------|
| No report (Step 1) | Tell user to run `/os-doc-hygiene:check`. STOP. No mutation. |
| Invalid report (Step 2) | Show violations. Tell user to re-run `/os-doc-hygiene:check`. STOP. |
| Zero in-scope entries (Step 3) | Report no-op. STOP. No git ops. |
| Approved set empty (Step 5) | Report "nothing approved". STOP. No git ops. |
| Applier exit 2 (Step 7) | Rollback to baseline, abort with structured error. |
| `failed[]` non-empty (Step 7) | Rollback to baseline, abort with structured error. |
| Write/git error in Step 8 | Rollback to baseline, abort. |
| Applier exit 1, `failed[]` empty (Step 7) | Commit what applied; report skipped with re-analysis note. |
| Subagent error in Step 8 | Skip that generative entry; continue. |
| All entries skipped (Steps 7+8) | No commit, no stamp. Report all-skipped. |
| Commit failure (Step 9) | Rollback to baseline, abort. No stamp. |
Rollback procedure (used by multiple failure paths):
```bash
git reset --hard "$BASELINE"
echo "Rolled back to $(git rev-parse --short HEAD). No commit was created."
```
---
## Invariants upheld
- **#4** — no new state artifacts; `last_clean` is the only new state entry.
- **#5** — exactly one cleanup commit per run (plus an optional WIP checkpoint
if the tree was dirty).
- **#6** — only Steps 8 (generative distillation) and 5 (the confirm prompt
rendered to the user) involve a model. All other steps are deterministic
scripts or logic.
- **#7** — confirm gate precedes every mutation, including the git preflight.
- **#8** — the applier enforces the content-hash guard; the skill trusts
`failed[]` / `skipped[]` in the result.
## LOOP GUARD
The generative subagent prompt MUST point to
`skills/clean/workflows/distill.md`, NEVER to this SKILL.md (prevents
recursive skill invocation).
**SUBAGENT AUTHORIZATION:** the distill subagent is the executor; it MUST NOT
block waiting for approval. If it objects, REPORT-AND-EXIT — the orchestrator
adjudicates. The confirm gate (Step 5) never lives inside the subagent.