Disk hygiene
Audit first; mutate only after a fresh deterministic preview and explicit approval of one tier. A filename pattern is a discovery hint, never proof that an entry is junk. Read the safety model before the optional execution lane.
Arguments and boundaries
Parse $ARGUMENTS as optional --execute, optional --policy <file>, and one target directory.
--execute means "deletion may be offered" on every platform — the gated engine lane where the
platform supports it, the manual handoff elsewhere; it is not approval. (Deliberate semantic
unification, not a restatement: the flag previously read as engine-lane-only, which left the
manual lane's gate ambiguous — consumer sessions read it both ways.) With no target, ask once. Reject an
OS-managed root, a non-root mount target, a protected shell-folder root or descendant, a missing
directory, a symlink, or a Windows reparse point. A whole-volume root that is not OS-managed (a
Windows Dev Drive) is no longer rejected outright — it is a valid target, but as a known-large root
it is gated like a home target (see step 1): the scan returns large-target-confirmation-required
unless bounded with --max-depth or confirmed with --confirmed-large-scan.
- Use
/repo-hygiene:cleanfor one repository's caches, build output, Git metadata, or tree reset. - For git worktree checkouts (e.g. under a
.worktrees/directory), hand off to/source-control:worktree status/cleanup(if installed), run from the checkout's own main repository — those actions manage the current repository's worktrees and take no target path. The engine already protects tracked content and.gitmetadata, but owns no worktree lifecycle. - For state owned by a package manager, plugin manager, browser, IDE, cloud-sync client, or similar product, research its documented dry-run/prune/GC command and report the handoff. Managed state is never eligible for this engine, even when a native dry-run calls it eligible.
- Never elevate, trigger UAC/sudo, install a dependency, close another process's handle, or disable a
retention mechanism. Report
needs-elevationorhandle-state-unverifiedand stop that tier. - If the
disk_hygiene_enableduserConfig option isfalse(its value here is${user_config.disk_hygiene_enabled}), audit only and explain why execution is disabled. A literal unexpanded token is not evidence the toggle is unset — resolve it deterministically by running the bundled probe (the guard allows exactly this argument-free shape):"<hook-python>" "${CLAUDE_PLUGIN_ROOT}/skills/setup/scripts/kill_switch_probe.py"and honor theeffectivevalue it reports; ondegraded: trueproceed as enabled but say the configured value could not be read. The guard now enforces this independently: it resolves the samedisk_hygiene_enabledtoggle by reading it straight from your usersettings.json(the read is shared with this probe, and the settings file is located from the tamper-resistant${CLAUDE_PLUGIN_ROOT}— not the environment), so in audit-only mode it denies both mutation lanes it gates outright — the Bash engineapplyand the PowerShell deletion belt alike. Running the probe still matters so you can state the configured value accurately and stop before proposing work the guard would deny; the guard is the backstop, not the sole enforcer. The hook runs in shell-free exec form and reports its absolute Python interpreter and the authorized--data-rootvalue in denial guidance. Use that exact interpreter path as<hook-python>for every engine call; barepython/python3is rejected because Bash aliases and functions can replace them. If either value is not known yet, submit the otherwise exact scan shape once with barepython: the guard must deny it and report both, after which retry the scan with the absolute interpreter and the reported--data-root. If the reported interpreter is older than the engine's declared floor (theMIN_PYTHONconstant inhygiene.py, the floor's single origin), stop with the declared prerequisite instead of improvising a different scanner or deletion path. - Automated, scheduled, remote, unattended, or no-human-in-loop sessions always audit and stop.
Confirmation gate
Every question this skill asks passes this gate — the no-target prompt above, the large-scan confirmation in §1, the removal approval in §5, and the unsupported-platform handoff in §6. One surface rule and one floor cover all four. What a valid answer must name is per question, because a target prompt has no tier or path list to name and cannot be held to a bar built for one.
Question surface. Prefer AskUserQuestion: its answer is the user's own and cannot be
fabricated. It is not always usable, in two distinct ways — a bare-name permissions.deny rule or a
disallowed-tools entry removes it from context entirely, while permission mode dontAsk denies it
even when an allow rule names it, leaving it visible and every call failing. Fall back to the same
question asked inline as a numbered choice whenever the tool is absent, denied, or otherwise
unusable — including a denial discovered only by calling it; a denied call is an unanswered
question, never an answer. Then wait for the reply.
The floor — every question. Take the user's own answer, given in this interactive session. Never
supply, infer, or fabricate it: a prior general request, --execute, "clean everything", approval of
another tier, or silence is not an answer. On rejection, stop.
What the answer must name — per question. Where a row requires the answer to name something the skill itself produced — the resolved target, the tier, the path list — show it in the question; a bar naming what the question never presented cannot be met.
| Question | Accept only an answer naming |
|---|---|
| Target selection (no target given) | one directory, which must then clear every rejection in "Arguments and boundaries" |
| Scan scope (--confirmed-large-scan, §1) | that target and a deliberate unbounded full walk of it |
| Removal approval (§5) and manual handoff (§6) | exactly the one tier and the exact path list just shown |
1. Create a read-only snapshot
Create a unique run directory under ${CLAUDE_PLUGIN_DATA}/runs/; snapshots, plans, and reports must
stay there, never in the target or ${CLAUDE_PLUGIN_ROOT}. Run:
"<hook-python>" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" scan \
--target "<target>" --output "<run-dir>/snapshot.json" [--policy "<policy.json>"] \
--project-dir "${CLAUDE_PROJECT_DIR}" --data-root "${CLAUDE_PLUGIN_DATA}" \
[--max-depth <N>] [--confirmed-large-scan]
The guard validates --data-root against the plugin data directory it derives from
${CLAUDE_PLUGIN_ROOT} (passed to the guard as --plugin-root, the only substitution a
skill-frontmatter hook receives), confining generated state to the plugin data directory even when
the guard's own environment lacks CLAUDE_PLUGIN_DATA. If the guard cannot recognize the install
layout it derives no authority and denies --data-root engine calls rather than trusting a guessed
path, so re-run reporting a denial is a coverage gap, not a clean result.
For a large root (a home directory, anything whose recursive walk could exceed the engine's entry
cap), start with a bounded pass: add --max-depth 1 to inventory the target's loose files and
immediate children, then fan out deeper scans per subtree that the evidence justifies. The engine
backs this with a deterministic gate: a scan whose target resolves to the user home directory or a
non-OS volume root (a Windows Dev Drive — an OS-managed root is denied outright and never reaches
this gate) and carries neither --max-depth nor --confirmed-large-scan returns
large-target-confirmation-required (after a cheap top-level probe, not a full walk) instead of the
unbounded traversal, so a forgotten bound never becomes an accidental whole-volume scan. --max-depth
is the preferred bounded response.
Reserve --confirmed-large-scan for a deliberate full walk the human has confirmed — pass the
confirmation gate's scan-scope row first, the same standing before an
expensive step that the apply lane demands before a destructive one; a general "clean my home
directory" is not that confirmation. Every
directory whose descendants were not walked — cut off by --max-depth, a protected root, or a VCS
boundary — is recorded in truncated_paths; report them as coverage gaps, never as clean, and
never plan them for removal (the preview blocks them as truncated-not-inventoried and skips the
live re-verification checks a candidate with no live-I/O value left to give would otherwise still
pay for). Each fan-out worker receives a bounded subtree and returns evidence only. The parent owns
classification, the single report, every approval, preview, and all execution. Do not let workers
delete or prepare approvals.
The bundled baseline policy contains cross-platform candidate hints
and protected names. Without --policy, the engine also layers standing policy files when present:
~/.claude/disk-hygiene.json (user-global), then <project>/.claude/disk-hygiene.json via
--project-dir. An explicit --policy is the invocation-specific choice and replaces both standing
layers. Every overlay can only disable/add hints and add protected globs; none can weaken hard guards.
The scan output names its policy_sources. Treat scan errors and unvisited protected roots as
coverage gaps, not clean results.
The scan output may also carry an os_autoclean advisory when the target overlaps a zone an OS
mechanism (Windows Storage Sense, systemd-tmpfiles) should own. Surface its recommendation in the
report; prefer enabling the OS mechanism over hand-cleaning that zone, mirroring the managed-state
rule below.
2. Establish evidence and ownership
A hint annotation is not the only trigger for triage: at a user-home target, treat any loose
root-level entry whose protected_reasons is empty and that does not belong to a recognizable
app/config convention as suspicious too — the snapshot already carries it (every walked entry is
recorded with a possibly-empty hints list), so nothing further needs discovering, only judging.
Read the entry's own protected_reasons, never one policy field: protection also comes from name
patterns and from live filesystem state, and an entry that names a single field as its filter will
step straight past a cloud-sync root whose name embeds a tenant.
This positional read is how session-state droppings that share no common name (a runner-controller
status snapshot, a one-off data export) surface for ownership triage even without a matching hint.
For each hinted or suspicious entry, inspect enough neighboring content and metadata to answer:
- What created it? Prefer a manifest, log, documented naming contract, sibling structure, or owning tool over an age/name guess.
- Is the owner active? Check current process/tool state without killing, pausing, or modifying it.
- Does the owning system provide cleanup or retention? Its dry-run result is authoritative.
- Could this be real work product, a resumable download, a backup, a dependency pinned by constraints, or a shell/cloud-sync folder? If uncertain, keep it.
- Is the evidence current for this exact path? Re-resolve every sibling independently; never interpolate names from one batch member.
3. Classify and report
Confidence is report priority, not permission:
| Tier | Minimum evidence | Default outcome | |---|---|---| | High | Explicit disposable provenance plus a second independent signal; owner inactive; work-product question resolved | Offer exact-path approval | | Medium | Likely disposable, but one ownership/provenance fact is indirect | Review, then optionally offer its own approval | | Low | Name/age-only, conflicting signals, resumable or user-content possibility | Keep unless the human separately reviews and approves exact paths |
Report every finding with path, logical bytes, tier, evidence, owner/native-GC result, why it is not work product, and disposition. Separately list protected, locked, needs-elevation, unverified, and coverage-gap entries. Empty directories are not inherently junk.
An entry's logical_size is reclaimable local bytes only when its size_qualifiers is empty.
Exclude every qualified entry from any reclaimable-bytes total and state the qualified bytes
separately with their reasons — a cloud-placeholder carries its REMOTE size while occupying
roughly nothing locally; a hardlinked name shares one object with other names; a sparse file's
logical size overstates local allocation; and not-walked means the subtree was never inventoried,
so logical_size is null rather than 0 — except on the target's own record, which keeps its
partial walked sum alongside a not-walked qualifier, so read that number as a floor. Prefer the
snapshot's target_reclaimable_local_bytes (and preview/apply reclaimable_local_bytes*) over summing
logical_size yourself — folding qualified or unknown sizes into a total claims space that
deleting the path would never return.
4. Build one exact-tier plan
Only when --execute was requested, write <run-dir>/plan-<tier>.json; never mix tiers:
{
"version": 1,
"tier": "high",
"candidates": [
{
"path": "relative/exact.tmp",
"tier": "high",
"reason": "failed atomic-write staging file",
"evidence": ["documented name shape", "owner process absent"],
"why_not_work_product": "generated staging bytes with no durable consumer",
"owner": "unmanaged"
}
]
}
For managed state, report the documented native command and its current dry-run result, but do not add the path to an engine plan. Paths in an engine plan are unmanaged, snapshot-relative, exact, non-overlapping, and never globs.
5. Preview, then ask
Run the deterministic gate:
"<hook-python>" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" preview \
--snapshot "<run-dir>/snapshot.json" --plan "<run-dir>/plan-<tier>.json" \
--data-root "${CLAUDE_PLUGIN_DATA}"
It rechecks containment, identity and full descendant set, hard protections, Git's index, and live
handles from current state rather than trusting snapshot annotations. It also proves Linux mount and
directory-descriptor prerequisites. Windows and macOS return execution-platform-unsupported. Any
blocker means no approval prompt and no deletion. Fix nothing behind the gate; rescan.
When status is ready-for-explicit-approval, show a table naming every path, the single tier, logical
bytes, and the preview's approval token, then pass the confirmation gate — the
approval must name exactly that tier and list. Process another tier only with a new plan, preview,
and question.
6. Apply only the confirmed preview
After an affirmative answer in this interactive session, run only:
"<hook-python>" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" apply --execute \
--snapshot "<run-dir>/snapshot.json" --plan "<run-dir>/plan-<tier>.json" \
--confirm-tier "<tier>" --approval-token "<token>" --report "<run-dir>/report-<tier>.json" \
--data-root "${CLAUDE_PLUGIN_DATA}"
Never use rm, rmdir, Remove-Item, del, find -delete, or an ad-hoc Python deletion call. The
skill-scoped hook blocks those bypasses and forces one final permission prompt for the exact engine
apply command; confirm it only when it matches the tier and paths just approved. If the plan, snapshot,
path identity, descendant set, VCS state, or handle state changed, re-scan and re-ask; never reuse a
token.
Unsupported-platform handoff (Windows, macOS)
Preview reports execution-platform-unsupported as a per-candidate blocker on these platforms, so
the engine never deletes there. The default outcome is the report. The manual lane is gated by
--execute exactly as the engine lane is — without it, no deletion lane may be offered on any
platform. If — and only if — --execute was requested and the human reviews the report and approves
an exact path list drawn from one tier in this interactive session (the §3 report spans every tier, so
narrow it to a single tier and show that tier's paths before asking — the
confirmation gate's removal row is the same exact-tier-and-list bar the engine
lane clears; a general "clean it up" is still not approval), removal is a manual handoff, not an
engine plan:
-
Write the approved exact paths to
<run-dir>/handoff-paths.jsonas{"version": 1, "paths": ["relative/exact.tmp"]}(snapshot-relative, exact, non-overlapping, never globs), then run the engine's deterministic revalidation immediately before deletion:"<hook-python>" "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py" handoff-verify \ --snapshot "<run-dir>/snapshot.json" --paths "<run-dir>/handoff-paths.json" \ --data-root "${CLAUDE_PLUGIN_DATA}"It reruns the engine's identity/reparse/protection/descendant/VCS/handle checks per path against live state and emits one verdict each —
clear,drifted(identity or descendant set changed since the snapshot),gone(no longer present), orcontested(protection, VCS state, a live handle, elevation, or unverifiable state) — and never deletes anything. Act only on verdict-clearpaths. Additionally confirm any owner process named in the audit evidence is still absent — that evidence is report-level, outside the engine's checks.Verify one path per deletion, not one batch for all. In a multi-path run, the first path's check ages while every later path is still being walked and probed, so its
clearis already stale at emission — and staler after each intervening deletion. Pair each deletion with its own fresh single-path handoff-verify run (verify one → delete that one → next); reserve the multi-path form for reporting. A clear verdict is valid only at emission time: delete immediately, and re-run handoff-verify after any delay or interruption. -
Prefer reversible removal (Windows Recycle Bin / macOS Trash) over permanent deletion, and say which was used. That reversibility is conditional, not guaranteed: bin size caps, a policy-disabled bin, or a non-NTFS/network volume can silently make the same operation permanent — disclose when a target's volume or policy may turn "reversible" removal permanent.
-
Container-wide deletion commands (
Clear-RecycleBin, emptying the Trash, or any "delete everything in this container" spelling) are forbidden in the manual lane — they execute against the live container, so items arriving between approval (or even re-enumeration) and execution die under an approval that never saw them. Satisfy "empty the container" by enumerating the container and deleting per item under steps 1, 2, and 4; items that arrive after enumeration are simply not deleted. This is the engine lane's changed-since-scan threat in the manual lane, where no snapshot token protects execution. -
Skip and report any path whose verdict is not
clear; never substitute a sibling, retry around a lock, or delete under a stale verdict.
The PowerShell guard lane turns deletion spellings into a final human permission prompt (the same
bar as the engine apply prompt); confirm that prompt only when the command matches the exact
approved list. Engine invocations from PowerShell stay hard-denied. The plugin-level engine gate
(hooks/hooks.json) now registers unconditionally and resolves the kill switch itself by reading
disk_hygiene_enabled from your user settings.json; it no longer carries a ${user_config.*}
argument, so the unset-default hook-drop that once made it inert on a default install is gone.
Bash|PowerShell PreToolUse hooks fire for the PowerShell tool. See reference/safety-model.md.
Summarize removed paths, logical bytes removed, observed free-space delta, and every skip grouped by
locked, changed-or-link, protected, needs-elevation, handle-state-unverified, or
delete-failed. Do not claim the observed free-space delta is exact: concurrent disk activity,
sparse files, hard links, compression, and delayed allocation affect it.
Gotchas
- POSIX permits unlinking an open file, so successful deletion is not a live-handle check. Linux
execution requires an authoritative
lsofresult and fails closed on diagnostics or missing access. - Python 3.11 has no
os.path.isjunction; the engine reads the Windows reparse attribute fromlstatand treats every reparse point as protected. Windows execution remains disabled. os.path.ismountcannot reliably identify same-filesystem bind mounts. Linux execution therefore parses/proc/self/mountinfoand fails closed if that namespace view is unavailable.- Apply opens every Linux parent with
O_NOFOLLOWrelative to the already-open target descriptor, verifies the descriptor identity, and removes only by descriptor-relativeunlink/rmdir. A directory is reopened without following links, matched by device/inode/type, and proven empty after its captured children are removed. - A directory's contents can change after preview. Apply revalidates each captured entry and removes bottom-up; it never follows a new link or recursively discovers new entries. The manual-handoff lane's container re-enumeration rule applies this same changed-since-scan discipline where no snapshot token exists.
allowed-toolswould pre-approve rather than restrict tools, so this destructive skill intentionally grants none. Consumer permission policy remains authoritative.- The Bash hook denies unknown commands rather than trying to enumerate deletion spellings. Supporting research uses non-Bash read-only tools; only literal-word bundled scan, preview, handoff-verify, and apply shapes using the hook runtime's same absolute executable pass. Shell expansions, globs, splitting/escape forms, operators, redirections, aliases, and exported functions fail closed.
- The guard registers twice: a plugin-level engine gate (
hooks/hooks.json,--mode engine-gate) that receives the data root by plugin-hook substitution and defers instantly on any command not referencing the engine; and this skill's frontmatter belt, which adds the deny-by-default Bash and deletion-spelling PowerShell discipline while cleanup is the active work. Both resolve the kill switch the same single way — readingdisk_hygiene_enabledfrom user-scopepluginConfigsinsettings.json, located from the${CLAUDE_PLUGIN_ROOT}both receive — so both honor a configuredfalse, register unconditionally, and fail closed to enabled when the value is absent or unreadable. Verdicts are idempotent where both fire. - The guard hook launches in exec form via
python3, resolved onPATHwith no shell (python3, not barepython, because stock macOS and many Linux distros ship onlypython3and a legacypython2.x would crash the guard on modern syntax). Enforcement is therefore only as strong as that resolution: on a host wherepython3does not resolve to an interpreter meeting the engine'sMIN_PYTHONfloor the PreToolUse launch fails, and Claude Code treats a failed hook launch as a non-blocking error, so the guard does not intercept there. Concretely, the exposure is the manual PowerShell deletion lane: engineapplyis unsupported on Windows and macOS and elsewhere runs only behind the guard's ownask, so no silent auto-delete path opens, but the guard's PowerShell belt that turns a deletion spelling into a final human prompt is lost. The backstops that remain are the per-path human approval the manual-handoff lane already requires and the consumer's baseline permission policy — defense-in-depth lost, not preserved./disk-hygiene:setup checkreports whether the interpreter resolves on this machine. - The kill switch is delivered by reading user settings, not by a hook argument (since 0.9.0). Earlier
versions passed a bare
${user_config.disk_hygiene_enabled}inhooks/hooks.json; a declared userConfigdefaultis not implemented upstream (#46477 / #39455 / #39827), so an unset-but-defaulted token was neither substituted nor exported toCLAUDE_PLUGIN_OPTION_*, and its presence dropped the whole hook entry — making the engine gate inert for any consumer who never set the key. The gate no longer carries a${user_config.*}token; both the gate and the belt resolvedisk_hygiene_enabledby reading it frompluginConfigsinsettings.json. Claude Code honors that key only from user, managed, and--settingsscope since 2.1.207 (a project/localsettings.jsonis ignored), so a hostile repo cannot forge it. The reader reads the user file (located from${CLAUDE_PLUGIN_ROOT}rather than repo-redirectable environment) and the managed enterprise file (highest precedence — a value there wins, so an org can enforce audit-only, with itsmanaged-settings.d/drop-in dir merged over it); a session--settingsfile is the one honored source a hook cannot read. Absent or unreadable settings fail closed to enabled. - PreToolUse hooks DO fire for the PowerShell tool (2.1.218; payload
tool_nameis literallyPowerShell, confirmed by a live block through that tool). ABash|PowerShellmatcher is correct and there is no harness firing divergence — readtool_namefrom the stdin payload, not from an env var (CLAUDE_TOOL_NAMEdoes not exist). - The PowerShell lane is the inverse tradeoff: it stays open for read-only support work (git, gh,
metadata probes) and instead hard-denies engine invocations and turns known deletion spellings
into a final human permission prompt. It is a raised bar, not a fail-closed lane — move, rename,
overwrite, and volume-format spellings are not flagged at all (
reference/safety-model.md); the engine's own containment and the Bash lane remain the deletion authority. - The guard rejects
~anywhere in a Bash command as a shell-expansion character, which includes Windows 8.3 short names (SOMEUS~1). Always pass long-form paths; the guard's own disclosures are already long-form.