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

28 KiB
Raw Blame History

description
Apply documented hygiene findings to project docs. Loads the current machine report, gates confirm-tier entries, applies deterministic ops via the patch applier, dispatches generative ops to a Sonnet subagent, stages precisely, and produces exactly one git commit. Invoked by `/os-doc-hygiene:clean [--scope <glob-or-path>] [--category <class|subtype>]`.

Hygiene Clean Skill

Orchestrates one documentation-hygiene cleanup run: load → validate → filter → gate → preflight → 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.

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.

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.

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:

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:

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:

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:

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).

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:

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.

cd "$PROJECT_ROOT"
git status --porcelain

Clean tree (git status --porcelain outputs nothing):

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:

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:

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.

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):

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:

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.

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:

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:

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:

# 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:

    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:

    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:

# 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:

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:

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.

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:

COMMIT_TS=$(git show -s --format=%cI "$COMMIT_SHA")
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):

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 closedextract-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.