diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index fc210d9aca..f14fae4533 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index b9f6c53043..b3da343613 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -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 ` or `--max-depth`. + ## [0.31.0] - 2026-09-30 ### Added diff --git a/plugins/disk-hygiene/skills/clean/SKILL.md b/plugins/disk-hygiene/skills/clean/SKILL.md index f277f1c926..c2a1dd5c31 100644 --- a/plugins/disk-hygiene/skills/clean/SKILL.md +++ b/plugins/disk-hygiene/skills/clean/SKILL.md @@ -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 diff --git a/plugins/disk-hygiene/skills/clean/reference/scan-flags.md b/plugins/disk-hygiene/skills/clean/reference/scan-flags.md index 80da0df13a..a793e958e3 100644 --- a/plugins/disk-hygiene/skills/clean/reference/scan-flags.md +++ b/plugins/disk-hygiene/skills/clean/reference/scan-flags.md @@ -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 ` +on bounded children or with `--max-depth`. diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index 64da8155d9..3d92998029 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -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 @@ -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 " + "on bounded children or with --max-depth" ) if sizes_only: entries.append({"path": relative, **data}) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 8476816a81..9c246156fa 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -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 ", 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"