Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.31.0",
"version": "0.31.1",
"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
9 changes: 9 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
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.31.1] - 2026-09-30

### Fixed

- **The snapshot entry-cap error names the largest top-level children**
([#5517](https://github.com/melodic-software/claude-code-plugins/issues/5517)). The error now lists
the top five top-level children by entry count so far, marks the one still being walked as a lower
bound, and points at `--sizes-only` and then `--root-children --root-child <name>` or `--max-depth`.

## [0.31.0] - 2026-09-30

### Added
Expand Down
6 changes: 3 additions & 3 deletions plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ stay there, never in the target 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`
when every subtree was walked; a depth cut, a directory that failed to scan, or a mount-state
error marks `rollup_precision: partial`. Pasteable
fan-out worker instructions: [fan-out-worker-brief.md](reference/fan-out-worker-brief.md).
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).

The guard validates `--data-root` against the plugin data directory it derives itself, and denies
the call outright when it cannot recognize the install layout, so a run reporting that denial is a
Expand Down
5 changes: 5 additions & 0 deletions plugins/disk-hygiene/skills/clean/reference/scan-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ everything" is not selection.
`--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.

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
counted. Size candidates with `--sizes-only`, then rerun with `--root-children --root-child <name>`
on bounded children or with `--max-depth`.
15 changes: 13 additions & 2 deletions plugins/disk-hygiene/skills/clean/scripts/hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import sys
import tempfile
import time
from collections import Counter
from collections.abc import Iterable
from pathlib import Path, PurePosixPath
from typing import Any
Expand Down Expand Up @@ -2302,9 +2303,19 @@ def visit(directory: Path, depth: int = 1) -> int | None:
unwalked_reasons[relative] = "scan-error"
continue
if not sizes_only and len(entries) >= MAX_SNAPSHOT_ENTRIES:
by_child = Counter(e["path"].split("/", 1)[0] for e in entries)
in_progress = relative.split("/", 1)[0]
largest = ", ".join(
f"{name} ({count}{', walk in progress' if name == in_progress else ''})"
for name, count in by_child.most_common(5)
)
raise HygieneError(
f"snapshot exceeds {MAX_SNAPSHOT_ENTRIES} entries; rerun with "
"--max-depth or split the audit into bounded subtrees"
f"snapshot exceeds {MAX_SNAPSHOT_ENTRIES} entries; "
f"largest top-level children by entries so far: {largest}. "
"The child still being walked is a lower bound; children not yet "
"reached are not counted. Size candidates with --sizes-only (no "
"entry cap), then rerun with --root-children --root-child <name> "
"on bounded children or with --max-depth"
)
if sizes_only:
entries.append({"path": relative, **data})
Expand Down
33 changes: 33 additions & 0 deletions plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -3921,6 +3921,39 @@ def test_entry_cap_rejects_one_more_than_the_configured_maximum(self) -> None:
):
hygiene.scan_tree(root.resolve(), hygiene.load_policy(None))

def test_entry_cap_error_names_the_child_being_walked(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) / "target"
(root / "small").mkdir(parents=True)
(root / "big").mkdir()
for index in range(6):
(root / "big" / f"file-{index}.txt").write_text("x", encoding="utf-8")
with (
mock.patch.object(hygiene, "MAX_SNAPSHOT_ENTRIES", 3),
self.assertRaisesRegex(hygiene.HygieneError, "exceeds 3 entries") as raised,
):
hygiene.scan_tree(root.resolve(), hygiene.load_policy(None))
message = str(raised.exception)
self.assertRegex(message, r"big \(\d+, walk in progress\)")
self.assertNotRegex(message, r"small \(\d+, walk in progress\)")
self.assertIn("--sizes-only", message)
self.assertIn("--root-children --root-child <name>", message)

def test_entry_cap_error_does_not_mark_a_finished_child_as_in_progress(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) / "target"
(root / "aaa").mkdir(parents=True)
(root / "aaa" / "one.txt").write_text("x", encoding="utf-8")
(root / "bbb.txt").write_text("x", encoding="utf-8")
with (
mock.patch.object(hygiene, "MAX_SNAPSHOT_ENTRIES", 2),
self.assertRaisesRegex(hygiene.HygieneError, "exceeds 2 entries") as raised,
):
hygiene.scan_tree(root.resolve(), hygiene.load_policy(None))
message = str(raised.exception)
self.assertIn("aaa (2)", message)
self.assertNotIn("aaa (2, walk in progress)", message)

def test_sizes_only_bypasses_inventory_entry_cap(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) / "target"
Expand Down