599 lines
20 KiB
Markdown
599 lines
20 KiB
Markdown
---
|
||
name: 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 `/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.
|