---
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_dest`:**
   - **`"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).
4. **On extraction success:** record the entry's original report index in
   `extraction_confirmed_indices` for Step 7 below.
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

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.
