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.41.3",
"version": "0.42.0",
"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
26 changes: 26 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
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.42.0] - 2026-09-30

### Added

- **Catalog scope and an `uncataloged` report**
([#4008](https://github.com/melodic-software/claude-code-plugins/issues/4008)). `catalog` now
accounts for every immediate child of the target, every hinted or empty entry at any depth, and,
at a user-home or `--root-children` target, every out-of-place immediate child. Entries with no
record and no owner-level ancestor are listed under `uncataloged`; a record marked
`owner_level` covers everything below its path while its identity holds.
- **Required ownership investigation** (`reference/ownership-investigation.md`). Each entry is
checked against nine local sources, each evidence item names its source, `/discovery:research`
is used only when no owner is found and only when it resolves, and the report ends with one
question per entry whose owner is unknown, which stays `keep` until answered.

### Changed

- **Operator answers follow the entry, not the scan target.** A `source: human` record whose identity
and descendant set still hold is reused when another scan target reaches the same entry, so it is
not asked again, even where this target holds an engine record with an open question. The scan
sets `target_prior_disposition` when the scan target itself was answered, and the `catalog`
report lists a reused entry under `unchanged` as `answered under <target>`. A new answer replaces
older answers for the same entry recorded under other targets, an owner-level answer reached from
another target covers its descendants, and `catalog` refuses a `--sizes-only` snapshot, which has
no entries to account for.

## [0.41.3] - 2026-09-30

### Changed
Expand Down
5 changes: 3 additions & 2 deletions plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ module name. The file's entry carries a `stdlib-module-shadow` advisory, and the
advisory is not a hint and adds no tier. When a shadowing file has a `bytecode_cache`, recommend
renaming or moving the source file, since deleting the cache alone is undone by the next import.

For each hinted or suspicious entry, inspect enough neighboring content and metadata to answer:
For each hinted or suspicious entry, and each entry the catalog lists as `uncataloged`, first run the required local procedure in [ownership investigation](reference/ownership-investigation.md) (its sources, how evidence is recorded, and the `/discovery:research` escalation when no owner is found).
Then inspect enough neighboring content and metadata to answer:

1. What created it? Prefer a manifest, log, documented naming contract, sibling structure, or owning
tool over an age/name guess.
Expand Down Expand Up @@ -339,7 +340,7 @@ was never inventoried, so `logical_size` is `null` rather than `0`, except on th
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. Never treat a
low or zero reclaimable-byte figure as a reason to skip a finding that otherwise clears the evidence bar. A `prior_disposition` or `prior_unresolved` is a hint, never approval; report and record answers per the [investigated catalog](reference/safety-model.md#investigated-catalog).
low or zero reclaimable-byte figure as a reason to skip a finding that otherwise clears the evidence bar. A `prior_disposition`, `target_prior_disposition` or `prior_unresolved` is a hint, never approval, and an operator answer recorded under another scan target is not asked again while the entry's identity holds; report and record answers per the [investigated catalog](reference/safety-model.md#investigated-catalog).

## 4. Build one exact-tier plan

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Ownership investigation

Run this procedure for every entry the [investigated catalog](safety-model.md#investigated-catalog)
has to account for (hinted, suspicious, or listed under `uncataloged`) before classifying it. It
answers section 2 question 1 (what created it) and question 2 (is the owner active) from evidence on
this machine. Every command is read-only; none kills, pauses or modifies anything.

## Local sources

Check each source that applies to the platform. A source that does not apply or turns up nothing is
recorded as checked with no result, not skipped.

1. **Manifests and READMEs.** `package.json`, `pyproject.toml`, `Cargo.toml`, `*.csproj`, `README*`,
`LICENSE` and similar files in the entry or its nearest parent directories.
2. **Config file contents.** Open the entry's own config files and the owning tool's config
(`~/.config/<tool>`, the tool's folder under `%APPDATA%`, `~/Library/Application Support/<tool>`). Look for the
entry's path or name.
3. **Command resolution.** Whether a command named like the entry resolves: `Get-Command <name>` on
Windows, `command -v <name>` or `which <name>` on Linux and macOS.
4. **Running processes.** A process whose command line, working directory or open files name the
entry (`Get-Process`, `ps`, `/proc/<pid>/cwd`, `lsof`).
5. **Scheduled tasks.** Task Scheduler (`Get-ScheduledTask`), cron (`crontab -l`, `/etc/cron.*`) and
systemd timers (`systemctl list-timers --all`, `systemctl --user list-timers --all`) whose action
names the entry.
6. **PATH.** Whether the entry sits on, or is referenced from, the user `PATH` and the machine
`PATH` (on Windows read both scopes, not only the session value).
7. **Installed programs.** The package manager or installer inventory (`winget list`, the Uninstall
registry keys, `dpkg -S`, `rpm -qf`, `brew list`) for a program that owns the path.
8. **Git remotes and status.** When the entry is or contains a repository: `git remote -v`,
`git status --short`, `git log -1`. A remote names the owner; unpushed or dirty state makes the
entry real work.
9. **Dotfile and settings references.** The shell profiles, editor settings and dotfile manager
sources that mention the entry's path or name.

## Recording evidence

Each evidence item is one line with its source: the file path it was read from, the exact command that
produced it, or the URL. An item with no source is not evidence. The finding's `owner` and
`provenance` state the conclusion, and each evidence item's source goes into its `evidence` list as
`{"source": "<source>"}`, so the catalog record keeps what the investigation found. The report shows
the same lines beside the conclusion.

## When no owner is found

Escalate to `/discovery:research` only when every source above found no owner, and only when that
skill resolves in this session. When it does not resolve, skip the escalation and go to the question
below. `/discovery:explore` applies only to a stray inside a repository, never to a home-directory or
volume-root entry, and only when that skill resolves in this session. When it does not resolve, read
the repository's own files with the local sources above.

End the report with one question per entry whose owner is still unknown: name the entry, list the
sources checked, and ask who or what owns it. Until the operator answers, that entry stays `keep`.
28 changes: 24 additions & 4 deletions plugins/disk-hygiene/skills/clean/reference/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -874,15 +874,35 @@ file (`{"answers": [...]}`, `source: human`) into the catalog. Both take snapsho
- A record is a hint. It records a conclusion, never an approval. Preview and apply do not read it,
so a catalogued `remove` still needs the same preview, approval token, and revalidation as an
entry that was never catalogued.
- A record belongs to one scan target: the same entry reached from another target is not annotated
and is asked again.
- A changed identity (device, inode, kind) or descendant set invalidates the record. It is replaced
by an unresolved `keep` with its question, and the next scan stops annotating it.
- An operator answer follows the entry, not the scan target. A record with `source: human` whose
identity (device, inode, kind) and descendant set still hold matches the same entry when another
scan target reaches it, whatever path that scan gives it, so the answer is not asked again. The
scan annotates the entry, or sets `target_prior_disposition` when the scan target itself is the
answered entry. A record under this target and path whose identity holds decides, unless it still
has an open question: then an answer recorded under another target replaces it. An engine record
is reused only under its own target and path.
A matched answer is not copied: the next answer recorded under this target becomes its own record
and wins here. The `catalog` report lists a matched entry under `unchanged` as `<path> |
<disposition> | <owner> | answered under <target>`.
- A changed identity (device, inode, kind) or descendant set invalidates the record. Under its own
target it is replaced by an unresolved `keep` with its question, and the next scan stops
annotating it. An entry reached from another target whose identity or descendant set differs
from the answer is treated as never answered and is asked.
- An engine finding with no owner is not a conclusion: the record stays `keep` and the report asks
who owns it. Unknown stays visibly unknown, and `prior_unresolved` marks it on the next scan.
- An operator answer clears the question with or without an owner, so `{"path": "<name>",
"disposition": "keep"}` is a "keep, don't re-raise" answer. While identity holds, later engine
findings do not overwrite it and the entry is not asked again.
- The catalog accounts for an entry when it is in scope: every immediate child of the target; every
entry at any depth that is hinted, or empty (`logical_size` 0 with empty `size_qualifiers`); and,
at a user-home or `--root-children` target, every immediate child with empty `protected_reasons`
and no hints (`out-of-place`, the section 2 positional read). Any other deeper entry is ordinary.
Give one record per owning tool or product instead of one per file: a finding or answer with an
`owner` and `"owner_level": true` covers every entry below its path while its identity holds. The
`catalog` output lists each in-scope entry with no record and no owner-level ancestor under
`uncataloged`, with its `reasons`; report every one, so nothing that looks out of place is skipped.
A `--sizes-only` snapshot has no entries, so `catalog` refuses it. The catalog reads only snapshot
fields and walks nothing.
- The scan sets `prior_disposition` on an entry whose record still holds. Report new or changed
entries first, one line for each unchanged entry, and end with the questions. Records for entries
the snapshot did not inventory are kept unchanged.
Expand Down
27 changes: 25 additions & 2 deletions plugins/disk-hygiene/skills/clean/scripts/hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,23 @@ def annotate_investigated_catalog(snapshot: dict[str, Any]) -> None:
snapshot["catalog_unreadable"] = True


def catalog_target_is_positional(snapshot: dict[str, Any]) -> bool:
"""True for a root-children scan or a scan of the user home directory.

Only there does "no protection and no hint" mark a loose root-level entry
as out of place.
"""
if snapshot.get("root_children_mode"):
return True
home = user_home()
if home is None:
return False
try:
return os.path.samefile(snapshot["target"], home)
except OSError:
return False


def write_text_atomic(path: Path, text: str) -> None:
"""Replace ``path`` whole or leave it as it was."""
temporary = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
Expand Down Expand Up @@ -5754,6 +5771,11 @@ def main(argv: list[str] | None = None) -> int:
raise HygieneError(
"catalog needs a scan snapshot whose entries each have a path"
)
if snapshot.get("inventory_mode") == "sizes-only":
raise HygieneError(
"sizes-only snapshot has no entries to catalog; scan without "
"--sizes-only"
)
json_path, markdown_path = catalog_paths()
json_path.parent.mkdir(parents=True, exist_ok=True)
findings = (
Expand All @@ -5774,6 +5796,7 @@ def main(argv: list[str] | None = None) -> int:
findings,
answers,
args.run_id,
positional=catalog_target_is_positional(snapshot),
)
write_text_atomic(
json_path, json.dumps(merged, indent=2, sort_keys=True) + "\n"
Expand All @@ -5791,8 +5814,8 @@ def main(argv: list[str] | None = None) -> int:
"note": (
"A catalog record is a hint. It does not authorize deletion, "
"skip a preview, or shorten approval. Report new_or_changed "
"first, then one line per unchanged entry, and end with the "
"questions."
"first, then one line per unchanged entry, then every "
"uncataloged in-scope entry, and end with the questions."
),
}
)
Expand Down
Loading
Loading