20 KiB
| 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_ROOTenvironment 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*) matchespathagainst the glob; a bare path filters entries whosepathstarts with<path>/.--category <class|subtype>— filter entries bycategory.class(stale/bloat) orcategory.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
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:checkfirst to generate one." STOP. - If
status == "ok"→ recordPROJECT_ROOTandREPORT_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.
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:checkto 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 — 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_detandconfirm_genentry (notauto_det— those apply silently). - Visually distinguish
delete-rangeentries (mark⚠ IRREVERSIBLE); group them under a warning header. - Show: path,
category.subtype,op_type/kind,token_estimate.raw_tokens, andgloss(oropifglossis absent). - 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
.dochygiene/is not yet gitignored, it will appear as untracked ingit status --porcelainand may trigger a needless WIP checkpoint. Before runninggit status, check:git -C "$PROJECT_ROOT" check-ignore -q .dochygieneExit
0→ already ignored; proceed. Exit1→ 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, .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 -Ais 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 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)
If approved_det is empty, skip this step.
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:
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):
-
Confirm the file still exists. A preceding
move-to-archivein Step 7 may have removed it. If the file is gone:- Record as skipped (reason:
source-moved-during-run). - Continue to the next entry.
- Record as skipped (reason:
-
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") -
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). -
Receive the subagent result. Expected return (see
distill.md):- For
distill: a singlenew_contentstring. - For
split:new_primary_content+archived_content+archive_dest_path(project-root-relative). - On error/unable:
{"status": "error", "reason": "..."}→ skip entry, report, continue.
- For
-
Write the result:
- For
distill: overwrite the file withnew_content. - For
split: overwrite the file withnew_primary_content; create the archive file atarchive_dest_pathwitharchived_content.
- For
-
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. -
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-frontmatterkind → display asfreezedistill,splitgenerative entries → display bycategory.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. |
| 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):
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_cleanis 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.