599 lines
20 KiB
Markdown
599 lines
20 KiB
Markdown
|
|
---
|
|||
|
|
name: hygiene-clean
|
|||
|
|
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 `/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 `/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 `/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
|
|||
|
|
`/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 `/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 `/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/hygiene-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 /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 /hygiene check>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
If a `move-to-archive` was applied, append:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Note: move-to-archive ops create link orphans. The next /hygiene check will
|
|||
|
|
flag any new broken_reference signals in files that pointed to the moved doc.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
If entries were skipped, append:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Run /hygiene check to refresh the report and pick up any remaining issues.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Failure handling
|
|||
|
|
|
|||
|
|
| Condition | Action |
|
|||
|
|
|-----------|--------|
|
|||
|
|
| No report (Step 1) | Tell user to run `/hygiene check`. STOP. No mutation. |
|
|||
|
|
| Invalid report (Step 2) | Show violations. Tell user to re-run `/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/hygiene-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.
|