735 lines
29 KiB
Markdown
735 lines
29 KiB
Markdown
---
|
||
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 → extract → apply → commit → stamp**. The load, validate, filter,
|
||
partition, git ops, stage, commit, and stamp are deterministic (invariant #6 — no
|
||
model). Only generative distillation and lifecycle extraction are model steps,
|
||
dispatched to a **Sonnet** subagent (or `/os-vault:write` for cross-repo
|
||
extraction) per the LOOP-GUARD pattern.
|
||
|
||
**Lifecycle ops (ADR-0039):** `delete` and `extract-then-delete` are two more
|
||
`op_type: "deterministic"` kinds alongside the existing five. They partition
|
||
into the SAME `(safety_tier, op_type)` buckets as every other deterministic
|
||
op (Step 4) — `auto` applies without prompt, `confirm` escalates — **but their
|
||
tier was derived with lifecycle-aware rules** (`validate_report.py`'s
|
||
`derive_safety_tier`): `auto` only when the lifecycle evidence was
|
||
`scanner-proven` (a `served_when_path` hit or a temporary-tier age/
|
||
retain-recent computation) **and** the file was tracked+clean at report-build
|
||
time. A `classifier-judged` (`served_when` free text) verdict, or any
|
||
dirty/untracked file, is `confirm` — no exception. Because time passes
|
||
between `check` and `clean`, `patch_applier.py` **re-verifies git state at
|
||
apply time** for every `auto`-tier lifecycle entry — even one applied
|
||
silently here may be downgraded to skip (`git-state-changed-since-check`) if
|
||
the path is no longer tracked+clean.
|
||
|
||
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
|
||
# Report path may still be the legacy .dochygiene/ location if it has not
|
||
# been migrated yet (migration happens lazily, on the next write) — ADR-027.
|
||
canonical = root / ".cc-os" / "dochygiene" / "report.json"
|
||
legacy = root / ".dochygiene" / "report.json"
|
||
report_path = canonical if canonical.is_file() else legacy
|
||
print(json.dumps({"status": "ok", "root": str(root), "report_path": str(report_path)}))
|
||
'
|
||
```
|
||
|
||
- 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
|
||
`.cc-os/dochygiene/report.json`, or the legacy `.dochygiene/report.json` if
|
||
not yet migrated — the next `clean`/`check` write auto-migrates it, per
|
||
ADR-027) 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/delete/extract-then-delete — content
|
||
will be permanently removed or removed after extraction):
|
||
[1] docs/stale-notes.md (orphaned / delete-range · confirm) — ~120 tokens
|
||
"Orphaned after 2025 refactor; no references remain."
|
||
[2] docs/plans/old-plan.md (delete-once-served / delete · confirm) — ~80 tokens
|
||
"served_when: classifier-judged — always confirm regardless of age/rule."
|
||
|
||
Reversible confirm entries:
|
||
[3] 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`, `delete`, and `extract-then-delete`
|
||
entries (mark `⚠ IRREVERSIBLE`); group them under a warning header —
|
||
`delete`/`extract-then-delete` land here at `confirm` tier precisely
|
||
because either the lifecycle evidence was classifier-judged (a
|
||
`served_when` verdict is NEVER auto, no matter how confident) or the file
|
||
was dirty/untracked at report-build time. A `scanner-proven` +
|
||
tracked+clean lifecycle delete is `auto` and is never shown here at all.
|
||
- Show: path, `category.subtype`, `op_type`/`kind`, `token_estimate.raw_tokens`,
|
||
and `gloss` (or `op` if `gloss` is absent). For lifecycle entries, surface
|
||
the `lifecycle.served_when` or `served_when_path` text so the human can see
|
||
what was (or wasn't) proven.
|
||
- 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 `.cc-os/` 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 .cc-os
|
||
> ```
|
||
> 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, .cc-os/ 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 6.5 — (M) Lifecycle extraction — orchestrated BEFORE the applier runs
|
||
|
||
Skip this step entirely if no `approved_det` entry has
|
||
`exact_edit.kind == "extract-then-delete"`.
|
||
|
||
`patch_applier.py` never performs extraction itself — it only checks
|
||
`entry.extraction_complete` and fails closed (skip,
|
||
`extraction-not-confirmed`) when it is not `true` (design.md Decision 3).
|
||
This skill is the one that runs extraction, and it MUST do so before
|
||
invoking the applier for these entries, so the source deletion and the
|
||
extraction land in the **same single hygiene commit**.
|
||
|
||
For each `approved_det` entry with `exact_edit.kind ==
|
||
"extract-then-delete"` (in order):
|
||
|
||
1. **Confirm the file still exists** (a preceding op in this run may have
|
||
already moved/deleted it). If gone, record as skipped
|
||
(`source-already-gone`) and continue to the next entry — do not attempt
|
||
extraction against a missing file.
|
||
2. **Live-read the file contents now** (same freshness rule as Step 8's
|
||
generative path — never trust the report's cached text).
|
||
3. **Branch on `exact_edit.extraction_target`:**
|
||
- **`"repo-durable"`** — dispatch the SAME generative Sonnet-subagent
|
||
path `doc-clean` already uses (LOOP-GUARD: point it at
|
||
`skills/clean/workflows/extract.md`, NEVER this SKILL.md). Give it the
|
||
live file contents, the entry's `op`/`gloss`/lifecycle fields, and any
|
||
target doc hint the classifier supplied. It returns one of
|
||
`{"status": "extracted", "target_path", "target_action", "content"}`,
|
||
`{"status": "nothing-to-extract", "reason"}`, or
|
||
`{"status": "error", "reason"}`. For `"extracted"`, write/append
|
||
`content` to `target_path` per `target_action` (an ADR file under
|
||
`docs/adr/`, `CLAUDE.md`, or a `docs/` file — the model's proposal, not
|
||
a fixed contract) and `git add -- "$PROJECT_ROOT/<target_path>"`
|
||
immediately. `"nothing-to-extract"` is treated as extraction success
|
||
(the judgment call was made; there's simply nothing durable to write) —
|
||
proceed to step 4 below. `"error"` is treated as extraction failure —
|
||
proceed to step 5 below.
|
||
- **`"cross-repo"`** — invoke `/os-vault:write` (the skill, not the
|
||
applier) with the extracted content and enough context (project name,
|
||
source path, why it's evergreen) for it to choose vault frontmatter.
|
||
`/os-vault:write` owns its own destination — this step supplies content
|
||
and context only, per spec §1 ("no new destinations" for cross-repo
|
||
extraction). On success, capture the pointer metadata the applier needs
|
||
for the `extracted.md` index entry (lifecycle-spec §1): the vault note
|
||
name `/os-vault:write` reports, a one-line reason a future reader would
|
||
follow the link, and today's date (`YYYY-MM-DD`).
|
||
4. **On extraction success:** record the entry's original report index in
|
||
`extraction_confirmed_indices` for Step 7 below; for `"cross-repo"`
|
||
entries also record the captured pointer metadata in
|
||
`extraction_pointers[idx]`. Without it the applier fails that entry
|
||
closed (`extraction-pointer-invalid`) and skips the deletion.
|
||
5. **On extraction failure** (subagent errors, or `/os-vault:write` fails):
|
||
do NOT add the index to `extraction_confirmed_indices` — leave the entry
|
||
unconfirmed. This is a per-entry fails-closed skip (patch_applier.py will
|
||
report `extraction-not-confirmed`), not a hard failure — continue
|
||
processing remaining entries. Only an `OSError` on write or a `git add`
|
||
failure is a hard failure → rollback per Step 7's Trap E procedure.
|
||
|
||
Build the scratch report copy the applier will read, marking confirmed
|
||
entries `extraction_complete: true` (the canonical `$REPORT_PATH` on disk is
|
||
NEVER mutated by this skill — only this scratch copy):
|
||
|
||
```python
|
||
import json
|
||
from pathlib import Path
|
||
|
||
report = json.loads(Path(REPORT_PATH).read_text())
|
||
for idx in extraction_confirmed_indices:
|
||
report["entries"][idx]["extraction_complete"] = True
|
||
for idx, pointer in extraction_pointers.items():
|
||
# cross-repo entries only: {"vault_note": ..., "reason": ..., "date": ...}
|
||
report["entries"][idx]["extraction_pointer"] = pointer
|
||
|
||
Path(SCRATCH, "report_with_extraction.json").write_text(json.dumps(report))
|
||
```
|
||
|
||
If this step was skipped (no `extract-then-delete` entries at all), Step 7
|
||
below reads `$REPORT_PATH` directly instead of the scratch copy.
|
||
|
||
---
|
||
|
||
### 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)
|
||
```
|
||
|
||
Use `$SCRATCH/report_with_extraction.json` as `--report` if Step 6.5 ran
|
||
(any `extraction_complete` markings need to reach the applier); otherwise use
|
||
`$REPORT_PATH` unchanged. Report indices are unaffected either way — the
|
||
scratch copy carries the same `entries[]` array positions as the original.
|
||
|
||
If `approved_det` is empty, skip this step.
|
||
|
||
```bash
|
||
APPLIER_REPORT="$REPORT_PATH"
|
||
[ -f "$SCRATCH/report_with_extraction.json" ] && APPLIER_REPORT="$SCRATCH/report_with_extraction.json"
|
||
|
||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/patch_applier.py" \
|
||
--report "$APPLIER_REPORT" \
|
||
--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. |
|
||
| Extraction subagent/`os-vault:write` error in Step 6.5 | Per-entry fails-closed skip — do NOT mark `extraction_complete`; applier reports `extraction-not-confirmed` for that index at Step 7. Continue processing other entries. NOT a hard failure. |
|
||
| Write/git-add error during extraction (Step 6.5) | Rollback to baseline, abort (same as Step 8's write-error rule — an `OSError`/git failure is hard, a judgment error is not). |
|
||
| `git-state-changed-since-check` skip (Step 7, applier) | Not a hard failure — the applier already downgraded this `auto`-tier lifecycle entry to skip because the path is no longer tracked+clean since `check` ran. Report with re-analysis note (re-run `/os-doc-hygiene:check`). |
|
||
| All entries skipped (Steps 6.5+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 6.5 (lifecycle extraction), 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
|
||
AND lifecycle extraction (Step 6.5 only ever touches entries already in
|
||
`approved_det`, i.e. already past the Step 5 gate).
|
||
- **#8** — the applier enforces the content-hash guard; the skill trusts
|
||
`failed[]` / `skipped[]` in the result. Lifecycle deletes additionally
|
||
re-verify git tracked/clean state at apply time (never the report's cached
|
||
`git_state`) — an entry cached as `auto` can still be downgraded to skip at
|
||
apply time (ADR-0039).
|
||
- **extraction fails closed** — `extract-then-delete` only proceeds to
|
||
delete the source once `extraction_complete: true` is set on a per-entry
|
||
basis by Step 6.5; a failed or skipped extraction withholds the delete for
|
||
that entry only, never blocks the rest of the run.
|
||
|
||
## LOOP GUARD
|
||
|
||
The generative subagent prompt MUST point to
|
||
`skills/clean/workflows/distill.md`, NEVER to this SKILL.md (prevents
|
||
recursive skill invocation). The `repo-durable` extraction subagent prompt
|
||
MUST point to `skills/clean/workflows/extract.md`, likewise never this
|
||
SKILL.md.
|
||
|
||
**SUBAGENT AUTHORIZATION:** the distill and extract subagents are the
|
||
executor; they MUST NOT block waiting for approval. If either objects,
|
||
REPORT-AND-EXIT — the orchestrator adjudicates. The confirm gate (Step 5)
|
||
never lives inside a subagent.
|