Skip to content
Merged
2 changes: 1 addition & 1 deletion plugins/disk-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "disk-hygiene",
"version": "0.34.3",
"version": "0.34.4",
"description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.",
"author": {
"name": "Melodic Software",
Expand Down
16 changes: 16 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
All notable changes to the `disk-hygiene` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.34.4] - 2026-09-30

### Fixed

- **`scan --sizes-only` asks the large-scan question and keeps no per-path entries**
([#4009](https://github.com/melodic-software/claude-code-plugins/issues/4009)). `--sizes-only`
no longer skips the `--confirmed-large-scan` gate: a large root without `--max-depth` or the flag
returns `large-target-confirmation-required`, as an ordinary unbounded walk does, on both the
plain-target and `--root-children` paths. The walk still enters VCS and protected directories
(exact totals need it) and now sums sizes straight into the per-child rollup and the target total
without retaining one entry per path; the empty-directory count stays exact and the
`empty_directory_paths` sample stays capped and sorted. The payload, `inventory_mode: sizes-only` and
`rollup_precision` markers are unchanged. The skill, `scan-flags.md`, `safety-model.md`, the README
and the fan-out worker brief state the gated behavior, superseding the earlier lines below that say
`--sizes-only` skips the question.

## [0.34.3] - 2026-09-30

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,9 @@ walking the rest of the home. Every volume root, OS-managed or a Windows Dev Dri
strict child ladder described under Volume-root coverage; only a target that is not a volume root
gets the relaxed directory listing.

`--sizes-only` writes per-child byte totals and no entries. As implemented it skips the
large-scan confirmation, sums through VCS and protected directories read-only, and has no entry
cap.
`--sizes-only` writes per-child byte totals and no entries. It goes through the same large-scan
confirmation as an unbounded walk, sums through VCS and protected directories read-only, and has no
entry cap.

The skill stores snapshots, plans, and reports under `${CLAUDE_PLUGIN_DATA}`. It never writes generated
state into the installed plugin directory or the audited target.
Expand Down
10 changes: 5 additions & 5 deletions plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ address an OS-managed volume root (for example `C:\` or `/`): it never walks tha
recursively. With explicit `--root-child <name>` flags, after the human clears the confirmation
gate's root-children row, it audits only those admitted children into one snapshot; without names
the engine returns `root-children-selection-required`. A general "clean everything" is not
selection. `--sizes-only` skips the large-scan question. What each of the three flags does
selection. `--sizes-only` goes through the same large-scan gate as an unbounded walk. What each of the three flags does
exactly, including the admission ladder, is in [scan-flags.md](reference/scan-flags.md). With no
target, ask once. Reject an OS-managed root (unless `--root-children` on the volume root itself), a
non-root mount target, a protected shell-folder root or descendant (the refusal carries a `hint`: a child of a shell folder is refused too, so name a directory whose path holds no protected name), a virtual-disk image file by name (`*.vhd`, `*.vhdx`, `*.avhd`, `*.avhdx`, `*.vmdk`, `*.vdi`, `*.qcow2`, `*.img`, which includes WSL's `ext4.vhdx`), a missing directory, a symlink,
Expand Down Expand Up @@ -158,9 +158,9 @@ naming what the question never presented cannot be met.
| Root-children selection (`--root-children`, §1) | one or more admitted immediate children just listed (directories, or regular files on a volume root), never "everything" or the scan target itself |
| Removal approval (§5) and manual handoff (§6) | exactly the one tier and the exact path list just shown |

**`--sizes-only`** does not ask the large-scan question, so a known-large root walks without
`--max-depth` or `--confirmed-large-scan`; it sums through VCS and protected directories, read-only,
and has no entry cap. Detail:
**`--sizes-only`** goes through the same large-scan question as an ordinary unbounded walk, so a
known-large root needs `--max-depth` or `--confirmed-large-scan`; it sums through VCS and protected
directories, read-only, keeps no per-path entries, and has no entry cap. Detail:
[scan-flags.md](reference/scan-flags.md#--sizes-only).

## 1. Create a read-only snapshot
Expand All @@ -178,7 +178,7 @@ or `${CLAUDE_PLUGIN_ROOT}`. Run:
```

For exact per-child byte totals without paying for a per-entry inventory (or the entry cap), add
`--sizes-only`. The snapshot carries `inventory_mode: sizes-only` and `rollup_precision: exact`
`--sizes-only` (a known-large target still needs `--confirmed-large-scan` or `--max-depth`). The snapshot carries `inventory_mode: sizes-only` and `rollup_precision: exact`
when every subtree was walked; a depth cut, a directory that failed to scan, or a mount-state error
marks `rollup_precision: partial`. Entry-cap error and next steps: [scan-flags.md](reference/scan-flags.md).
Pasteable fan-out worker instructions: [fan-out-worker-brief.md](reference/fan-out-worker-brief.md).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ mutates the target. Do not wrap the engine in compound shells (`;`, `&&`, `|`).
"<hook-python>" "<engine>" scan \
--target "<subtree-path>" --output "<run-dir>/sizes.json" \
--data-root "<data-root>" [--project-dir "<project-dir>"] \
--sizes-only
--sizes-only [--confirmed-large-scan]
```

Add `--confirmed-large-scan` only when the parent confirmed a large target; without it a
known-large root returns `large-target-confirmation-required`.

Read `inventory_mode: sizes-only` and `rollup_precision` on stdout. `partial` means a subtree was
cut or failed to scan. `children_rollup` rows with `walked: true` are exact totals, not depth-cut
floors.
Expand Down
6 changes: 3 additions & 3 deletions plugins/disk-hygiene/skills/clean/reference/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -704,9 +704,9 @@ unbounded traversal, so an unauthenticated whole-volume walk cannot begin by omi
scan-cost gating (time and resources), distinct from the hard rejection of an OS-managed root as an
invalid target.

`--sizes-only`, as implemented, bypasses that gate. It does not ask the large-scan question, does
not stop at VCS or protected directories (it sums through them, read-only, and emits no entries),
and has no entry cap. Its snapshot is refused by disposition.
`--sizes-only` goes through that same gate: a known-large root needs `--max-depth` or
`--confirmed-large-scan`. It does not stop at VCS or protected directories (it sums through them,
read-only, for exact totals), keeps no per-path entries, and has no entry cap. Its snapshot is refused by disposition.

Managed state is engine-ineligible. Even current native dry-run evidence is recorded only as a
report-only handoff because this engine cannot independently authenticate the owning product's state
Expand Down
7 changes: 4 additions & 3 deletions plugins/disk-hygiene/skills/clean/reference/scan-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ everything" is not selection.

## `--sizes-only`

`--sizes-only` as implemented: it does not ask the large-scan question, so a known-large root walks
without `--max-depth` or `--confirmed-large-scan`. It does not stop at VCS or protected
directories: it sums through them, read-only, and writes no entries. It has no entry cap.
`--sizes-only` goes through the same large-scan gate as an ordinary unbounded walk: a known-large
root returns `large-target-confirmation-required` without `--max-depth` or `--confirmed-large-scan`.
It does not stop at VCS or protected directories: it sums through them, read-only, for exact totals,
and keeps no per-path entries. It has no entry cap.

When an inventory scan hits the entry cap, the error lists the top five top-level children by entry
count so far. The child still being walked is a lower bound, and children not yet reached are not
Expand Down
153 changes: 103 additions & 50 deletions plugins/disk-hygiene/skills/clean/scripts/hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,51 @@ def children_rollup(
unknown with no more specific cause falls back to the bare ``not-walked``,
the same qualifier the flat entry carries.
"""
totals: dict[str, dict[str, Any]] = {}
for entry in entries:
accumulate_child_rollup(totals, entry)
return child_rollup_rows(totals, unknown_paths, unwalked_reasons)


def accumulate_child_rollup(
totals: dict[str, dict[str, Any]], entry: dict[str, Any]
) -> None:
"""Fold one inventory record into its immediate child's accumulator.

Keeps only the child's own kind and logical size, never the record, so a
caller that feeds records one at a time need not retain them.
"""
relative = entry.get("path")
# `.` is the target itself, never one of its children: `scan_tree` keeps
# the target's record out of the inventory, and the same skip in the gap
# loop keeps a target-level truncation from inventing a `.` child row.
if not isinstance(relative, str) or not relative or relative == ".":
return
name = child_rollup_name(relative)
bucket = totals.setdefault(name, empty_child_rollup_bucket())
if relative == name:
bucket["self"] = {
"kind": entry.get("kind"),
"logical_size": entry.get("logical_size"),
}
else:
bucket["descendants"] = bucket["descendants"] + 1
mtime = entry.get("mtime_ns")
if isinstance(mtime, int):
newest = bucket["newest"]
bucket["newest"] = mtime if newest is None else max(newest, mtime)
bucket["qualifiers"].update(entry.get("size_qualifiers") or ())
local = entry_reclaimable_local_bytes(entry)
if local is not None:
bucket["reclaimable"] = bucket["reclaimable"] + local


def child_rollup_rows(
totals: dict[str, dict[str, Any]],
unknown_paths: Iterable[str] = (),
unwalked_reasons: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
"""The per-child rows ``children_rollup`` documents, from accumulated totals."""
reasons = unwalked_reasons or {}
gaps: dict[str, set[str]] = {}
for path in unknown_paths:
Expand All @@ -1616,28 +1661,6 @@ def children_rollup(
gaps.setdefault(name, set()).add(
reasons.get(path, "not-walked") if path == name else "descendant-not-walked"
)
totals: dict[str, dict[str, Any]] = {}
for entry in entries:
relative = entry.get("path")
# `.` is the target itself, never one of its children: `scan_tree` keeps
# the target's record out of `entries`, and the same skip in the gap loop
# above keeps a target-level truncation from inventing a `.` child row.
if not isinstance(relative, str) or not relative or relative == ".":
continue
name = child_rollup_name(relative)
bucket = totals.setdefault(name, empty_child_rollup_bucket())
if relative == name:
bucket["self"] = entry
else:
bucket["descendants"] = bucket["descendants"] + 1
mtime = entry.get("mtime_ns")
if isinstance(mtime, int):
newest = bucket["newest"]
bucket["newest"] = mtime if newest is None else max(newest, mtime)
bucket["qualifiers"].update(entry.get("size_qualifiers") or ())
local = entry_reclaimable_local_bytes(entry)
if local is not None:
bucket["reclaimable"] = bucket["reclaimable"] + local
rows: list[dict[str, Any]] = []
for name in sorted(set(totals) | set(gaps)):
bucket = totals.get(name) or empty_child_rollup_bucket()
Expand Down Expand Up @@ -2384,6 +2407,17 @@ def scan_tree(
sizes_only: bool = False,
) -> dict[str, Any]:
entries: list[dict[str, Any]] = []
# A sizes-only walk keeps no per-path record: each path folds straight into
# these running totals, so memory follows the immediate children rather than
# the tree.
child_totals: dict[str, dict[str, Any]] = {}
# Only the count and a bounded sorted sample of empty directories are kept.
empty_directory_total = 0
empty_directory_sample: list[str] = []
inventoried = 0
reclaimable_total = 0
empty_files = 0
not_walked_paths: set[str] = set()
errors: list[dict[str, str]] = []
truncated: list[str] = []
# Why each uninventoried path is uninventoried, so the per-child roll-up can
Expand All @@ -2403,6 +2437,7 @@ def scan_tree(
allowed_root_children = {name.casefold() for name in root_children}

def visit(directory: Path, depth: int = 1) -> int | None:
nonlocal inventoried, reclaimable_total, empty_files, empty_directory_total
total = 0
try:
with os.scandir(directory) as iterator:
Expand Down Expand Up @@ -2436,6 +2471,7 @@ def visit(directory: Path, depth: int = 1) -> int | None:
)
if consumer_matches:
protections.append("consumer-protected-path")
descendants = 0
try:
if is_linkish(path):
kind = "link"
Expand Down Expand Up @@ -2476,7 +2512,9 @@ def visit(directory: Path, depth: int = 1) -> int | None:
else:
subtotal = 0
else:
before = inventoried
subtotal = visit(path, depth + 1)
descendants = inventoried - before
if subtotal is None:
# scandir failed inside this child: unknown, not empty.
walked = False
Expand Down Expand Up @@ -2512,8 +2550,26 @@ def visit(directory: Path, depth: int = 1) -> int | None:
"entry cap), then rerun with --root-children --root-child <name> "
"on bounded children or with --max-depth"
)
inventoried += 1
if "not-walked" in data["size_qualifiers"]:
not_walked_paths.add(relative)
if sizes_only:
entries.append({"path": relative, **data})
record = {"path": relative, **data}
accumulate_child_rollup(child_totals, record)
reclaimable_total += entry_reclaimable_local_bytes(record) or 0
if kind == "file" and data["logical_size"] == 0:
empty_files += 1
if (
descendants == 0
and kind == "directory"
and entry_is_empty_directory(record, parents_with_children=set())
):
empty_directory_total += 1
empty_directory_sample.append(relative)
if len(empty_directory_sample) >= 2 * MAX_EMPTY_DIRECTORY_PATHS:
empty_directory_sample[:] = sorted(empty_directory_sample)[
:MAX_EMPTY_DIRECTORY_PATHS
]
else:
entries.append(
{
Expand All @@ -2524,8 +2580,8 @@ def visit(directory: Path, depth: int = 1) -> int | None:
**protection_matches_field(consumer_matches),
}
)
if len(entries) % 25_000 == 0:
print(f"scanned {len(entries)} entries...", file=sys.stderr)
if inventoried % 25_000 == 0:
print(f"scanned {inventoried} entries...", file=sys.stderr)
return total

total_size = visit(target)
Expand All @@ -2547,7 +2603,6 @@ def visit(directory: Path, depth: int = 1) -> int | None:
},
)
stdlib_shadowing = annotate_stdlib_shadowing(entries, target)
reclaimable = reclaimable_local_bytes(entries)
target_identity = metadata(target, "directory", total_size)
# The target itself was walked, but any truncated child means the target's
# byte roll-up is incomplete. Keep the known walked sum in logical_size and
Expand All @@ -2564,16 +2619,22 @@ def visit(directory: Path, depth: int = 1) -> int | None:
# Everything the walk could not fully account for, from all three places it
# can be recorded: an explicit truncation, a scan error (which never adds a
# truncation and can leave no entry at all), and a `not-walked` record.
unknown_paths = (
set(truncated)
| error_paths
| {
entry["path"]
for entry in entries
if "not-walked" in (entry.get("size_qualifiers") or [])
}
)
empty_directories = empty_directory_paths(entries, error_paths=error_paths)
unknown_paths = set(truncated) | error_paths | not_walked_paths
if sizes_only:
reclaimable = reclaimable_total
empty_directories_total = empty_directory_total
empty_directories = sorted(empty_directory_sample)[:MAX_EMPTY_DIRECTORY_PATHS]
empty_files_total = empty_files
rollup_rows = child_rollup_rows(child_totals, unknown_paths, unwalked_reasons)
else:
reclaimable = reclaimable_local_bytes(entries)
all_empty_directories = empty_directory_paths(entries, error_paths=error_paths)
empty_directories_total = len(all_empty_directories)
empty_directories = all_empty_directories[:MAX_EMPTY_DIRECTORY_PATHS]
empty_files_total = empty_file_count(entries)
rollup_rows = children_rollup(
entries, unknown_paths=unknown_paths, unwalked_reasons=unwalked_reasons
)
payload: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"engine": "disk-hygiene-python-1",
Expand All @@ -2584,11 +2645,11 @@ def visit(directory: Path, depth: int = 1) -> int | None:
"target_identity": target_identity,
"target_logical_bytes": total_size,
"target_reclaimable_local_bytes": reclaimable,
"empty_directory_count": len(empty_directories),
"empty_directory_paths": empty_directories[:MAX_EMPTY_DIRECTORY_PATHS],
"empty_directory_paths_truncated": len(empty_directories)
"empty_directory_count": empty_directories_total,
"empty_directory_paths": empty_directories,
"empty_directory_paths_truncated": empty_directories_total
> MAX_EMPTY_DIRECTORY_PATHS,
"empty_file_count": empty_file_count(entries),
"empty_file_count": empty_files_total,
"policy": policy,
"repositories": [str(repo) for repo in repositories],
"repository_errors": repo_errors,
Expand All @@ -2603,14 +2664,8 @@ def visit(directory: Path, depth: int = 1) -> int | None:
truncated or unwalked_reasons or root_children is not None
),
"stdlib_shadowing": stdlib_shadowing,
"children_rollup": children_rollup(
entries,
unknown_paths=unknown_paths,
unwalked_reasons=unwalked_reasons,
),
"entries": []
if sizes_only
else sorted(entries, key=lambda entry: entry["path"]),
"children_rollup": rollup_rows,
"entries": sorted(entries, key=lambda entry: entry["path"]),
}
if sizes_only:
payload["inventory_mode"] = "sizes-only"
Expand Down Expand Up @@ -4948,7 +5003,6 @@ def main(argv: list[str] | None = None) -> int:
child_large_reasons
and args.max_depth is None
and not args.confirmed_large_scan
and not sizes_only
):
return emit(
{
Expand Down Expand Up @@ -5023,7 +5077,6 @@ def main(argv: list[str] | None = None) -> int:
large_reasons
and args.max_depth is None
and not args.confirmed_large_scan
and not sizes_only
):
immediate_entries, probe_error = top_level_entry_count(target)
return emit(
Expand Down
Loading
Loading