From 2f33c0de3d9641e20704e6ffb20b1be75937df7b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:08:55 -0400 Subject: [PATCH 1/4] refactor(disk-hygiene): share the unreferenced plugin-cache-version reader with claude-ops Refs: #5221 Move registry parsing, installPath resolution and the .orphaned_at marker age into one lib/plugin_cache_versions.py, carried byte-identical by claude-ops and disk-hygiene and registered in the cross-plugin source registry. Each caller keeps its own row shape and passes its own reader; disk-hygiene now reads through a guard that refuses a symlinked marker, as claude-ops does. Co-Authored-By: Claude Opus 5.5 --- .../claude-ops/lib/plugin_cache_versions.py | 110 ++++++++++++++++++ .../scripts/install_state.py | 80 +++++-------- .../disk-hygiene/lib/plugin_cache_versions.py | 110 ++++++++++++++++++ .../skills/clean/scripts/deep_inventory.py | 87 ++++++-------- .../clean/scripts/test_deep_inventory.py | 9 ++ scripts/cross-plugin-source-registry.txt | 7 ++ 6 files changed, 299 insertions(+), 104 deletions(-) create mode 100644 plugins/claude-ops/lib/plugin_cache_versions.py create mode 100644 plugins/disk-hygiene/lib/plugin_cache_versions.py diff --git a/plugins/claude-ops/lib/plugin_cache_versions.py b/plugins/claude-ops/lib/plugin_cache_versions.py new file mode 100644 index 0000000000..1bb33df469 --- /dev/null +++ b/plugins/claude-ops/lib/plugin_cache_versions.py @@ -0,0 +1,110 @@ +"""Which plugin cache version directories does no installPath reference? + +Claude Code records every installed plugin in ``plugins/installed_plugins.json`` +and keeps each version under ``plugins/cache///``. +A version no ``installPath`` names is orphaned: Claude Code stamps it with a +``.orphaned_at`` marker (epoch milliseconds) and removes it after a window. + +This module reads the registry and the markers and answers that question. It is +pure and read-only: every read goes through a caller-supplied ``read`` function +taking a path relative to the Claude directory, so each caller keeps its own +read guard. Its callers shape the answer into their own rows. + +Python 3.11+, standard library only. This file is carried byte-identical by +every plugin that uses it (scripts/cross-plugin-source-registry.txt). +""" + +from __future__ import annotations + +import datetime as dt +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any, NamedTuple + +INSTALLED_PLUGINS = "plugins/installed_plugins.json" + +# Days after an update or uninstall that Claude Code removes an orphaned plugin +# version, counted from its `.orphaned_at` marker. Basis: +# https://code.claude.com/docs/en/plugins/loading.md ("Cleanup of previous versions"), +# verified 2026-09-30; recheck when that section or a Claude Code changelog entry +# changes the window. +ORPHAN_SWEEP_DAYS = 14 + +_DAY = 86400.0 + +Reader = Callable[[str], str] + + +class Registry(NamedTuple): + """What the registry says about this cache. + + ``doubt`` is empty when the registry can vouch for the cache. Otherwise it + says why not, and no version may be called unreferenced: a missing registry + is not evidence that every directory is orphaned. ``foreign`` marks the case + where the registry parsed but none of its paths lies under this cache. + """ + + referenced: frozenset[Path] + installs: bool + doubt: str + foreign: bool + + +def install_paths(data: object) -> list[str]: + """Every `installPath` in the parsed registry, whatever scope or project entry holds it.""" + if isinstance(data, dict): + own = data.get("installPath") + found = [own] if isinstance(own, str) else [] + return found + [p for v in data.values() for p in install_paths(v)] + if isinstance(data, list): + return [p for v in data for p in install_paths(v)] + return [] + + +def resolve(path: str | Path) -> Path | None: + try: + return Path(path).expanduser().resolve() + except (OSError, RuntimeError): + return None + + +def load_registry(read: Reader, root: Path, label: str = INSTALLED_PLUGINS) -> Registry: + """Read the registry under ``root`` and resolve the paths it references. + + ``label`` names the registry in ``doubt``. + """ + cache = root / "plugins" / "cache" + try: + data: Any = json.loads(read(INSTALLED_PLUGINS)) + except (OSError, ValueError) as exc: + return Registry( + frozenset(), False, f"{label} unreadable ({type(exc).__name__})", False + ) + if not isinstance(data, dict) or not isinstance(data.get("plugins"), dict): + return Registry(frozenset(), False, f"{label} has no `plugins` object", False) + referenced = frozenset( + p for p in map(resolve, install_paths(data)) if p is not None + ) + installs = bool(data["plugins"]) + cache_resolved = resolve(cache) + if installs and not any(cache_resolved in p.parents for p in referenced): + doubt = f"no installPath in {label} lies under {cache}" + return Registry(referenced, installs, doubt, True) + return Registry(referenced, installs, "", False) + + +def orphan_marker(read: Reader, version_rel: str, now: float) -> dict[str, Any] | None: + """The age of a version's ``.orphaned_at`` marker, or None when it is missing or unparsable.""" + try: + epoch = int(read(f"{version_rel}/.orphaned_at").strip()) / 1000 + age = (now - epoch) / _DAY + return { + "orphaned_at": dt.datetime.fromtimestamp(epoch, dt.timezone.utc).isoformat( + timespec="seconds" + ), + "marker_age_days": round(age, 1), + "past_sweep_window": age >= ORPHAN_SWEEP_DAYS, + } + except (OSError, ValueError, OverflowError): + return None diff --git a/plugins/claude-ops/skills/audit-install-state/scripts/install_state.py b/plugins/claude-ops/skills/audit-install-state/scripts/install_state.py index 745258901d..b79edbc183 100755 --- a/plugins/claude-ops/skills/audit-install-state/scripts/install_state.py +++ b/plugins/claude-ops/skills/audit-install-state/scripts/install_state.py @@ -44,6 +44,18 @@ from pathlib import Path, PurePosixPath from typing import Callable +_LIB_DIR = Path(__file__).resolve().parents[3] / "lib" +if str(_LIB_DIR) not in sys.path: + sys.path.insert(0, str(_LIB_DIR)) + +from plugin_cache_versions import ( # noqa: E402 (path set above; plugin-bundled module) + INSTALLED_PLUGINS, + ORPHAN_SWEEP_DAYS, + load_registry, + orphan_marker, + resolve, +) + MIN_PYTHON = (3, 11) # -------------------------------------------------------------------------- @@ -166,9 +178,6 @@ def read_text_guarded(root: Path, relpath: str, limit: int = 2_000_000) -> str: # them"), read as raw markdown, verified 2026-08-11. See reference/surfaces.md. # -------------------------------------------------------------------------- -# Days after update or uninstall that Claude Code removes an orphaned plugin version directory. -ORPHAN_SWEEP_DAYS = 14 - SWEPT = "product-managed-swept" # deleted at startup once older than cleanupPeriodDays KEPT = "product-managed-kept" # documented as never age-swept SESSION_SCOPED = ( @@ -1630,27 +1639,6 @@ def node_modules_bucket(rows: list[FileRow]) -> dict: } -INSTALLED_PLUGINS = "plugins/installed_plugins.json" - - -def _install_paths(data: object) -> list[str]: - """Every `installPath` in the parsed registry, whatever scope or project entry holds it.""" - if isinstance(data, dict): - own = data.get("installPath") - found = [own] if isinstance(own, str) else [] - return found + [p for v in data.values() for p in _install_paths(v)] - if isinstance(data, list): - return [p for v in data for p in _install_paths(v)] - return [] - - -def _resolved(path: str | Path) -> Path | None: - try: - return Path(path).expanduser().resolve() - except (OSError, RuntimeError): - return None - - def unreferenced_versions( root: Path, rows: list[FileRow], @@ -1675,38 +1663,32 @@ def unreferenced_versions( sizes[key] = sizes.get(key, 0) + row.bytes if not sizes: return [], None - try: - text = read_text_guarded(root, INSTALLED_PLUGINS) - opened.add(INSTALLED_PLUGINS) - data = json.loads(text) - except (OSError, ValueError) as exc: + + def read(relpath: str) -> str: + text = read_text_guarded(root, relpath) + opened.add(relpath) + return text + + registry = load_registry(read, root) + if registry.foreign: return ( [], - f"{INSTALLED_PLUGINS} unreadable ({type(exc).__name__}); nothing reported", + f"{registry.doubt}; the registry may belong to another root, so nothing is reported", ) - if not isinstance(data, dict) or not isinstance(data.get("plugins"), dict): - return [], f"{INSTALLED_PLUGINS} has no `plugins` object; nothing reported" + if registry.doubt: + return [], f"{registry.doubt}; nothing reported" cache = root / "plugins" / "cache" - cache_resolved = _resolved(cache) - referenced = {p for p in map(_resolved, _install_paths(data)) if p is not None} - if data["plugins"] and not any(cache_resolved in p.parents for p in referenced): - return [], ( - f"no installPath in {INSTALLED_PLUGINS} lies under {cache}; the registry may belong " - "to another root, so nothing is reported" - ) now = time.time() if now is None else now found: list[dict] = [] for (marketplace, plugin, version), size in sizes.items(): - if _resolved(cache / marketplace / plugin / version) in referenced: + if resolve(cache / marketplace / plugin / version) in registry.referenced: continue rel = f"plugins/cache/{marketplace}/{plugin}/{version}" - try: - marker = read_text_guarded(root, f"{rel}/.orphaned_at") - opened.add(f"{rel}/.orphaned_at") - epoch_ms = int(marker.strip()) - orphaned_at, age = iso(epoch_ms / 1000), (now - epoch_ms / 1000) / 86400 - except (OSError, ValueError, OverflowError): - orphaned_at, age = None, None + marker = orphan_marker(read, rel, now) or { + "orphaned_at": None, + "marker_age_days": None, + "past_sweep_window": False, + } found.append( { "marketplace": marketplace, @@ -1714,9 +1696,7 @@ def unreferenced_versions( "version": version, "path": rel, "bytes": size, - "orphaned_at": orphaned_at, - "marker_age_days": None if age is None else round(age, 1), - "past_sweep_window": age is not None and age >= ORPHAN_SWEEP_DAYS, + **marker, "evidence": MEASURED, } ) diff --git a/plugins/disk-hygiene/lib/plugin_cache_versions.py b/plugins/disk-hygiene/lib/plugin_cache_versions.py new file mode 100644 index 0000000000..1bb33df469 --- /dev/null +++ b/plugins/disk-hygiene/lib/plugin_cache_versions.py @@ -0,0 +1,110 @@ +"""Which plugin cache version directories does no installPath reference? + +Claude Code records every installed plugin in ``plugins/installed_plugins.json`` +and keeps each version under ``plugins/cache///``. +A version no ``installPath`` names is orphaned: Claude Code stamps it with a +``.orphaned_at`` marker (epoch milliseconds) and removes it after a window. + +This module reads the registry and the markers and answers that question. It is +pure and read-only: every read goes through a caller-supplied ``read`` function +taking a path relative to the Claude directory, so each caller keeps its own +read guard. Its callers shape the answer into their own rows. + +Python 3.11+, standard library only. This file is carried byte-identical by +every plugin that uses it (scripts/cross-plugin-source-registry.txt). +""" + +from __future__ import annotations + +import datetime as dt +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any, NamedTuple + +INSTALLED_PLUGINS = "plugins/installed_plugins.json" + +# Days after an update or uninstall that Claude Code removes an orphaned plugin +# version, counted from its `.orphaned_at` marker. Basis: +# https://code.claude.com/docs/en/plugins/loading.md ("Cleanup of previous versions"), +# verified 2026-09-30; recheck when that section or a Claude Code changelog entry +# changes the window. +ORPHAN_SWEEP_DAYS = 14 + +_DAY = 86400.0 + +Reader = Callable[[str], str] + + +class Registry(NamedTuple): + """What the registry says about this cache. + + ``doubt`` is empty when the registry can vouch for the cache. Otherwise it + says why not, and no version may be called unreferenced: a missing registry + is not evidence that every directory is orphaned. ``foreign`` marks the case + where the registry parsed but none of its paths lies under this cache. + """ + + referenced: frozenset[Path] + installs: bool + doubt: str + foreign: bool + + +def install_paths(data: object) -> list[str]: + """Every `installPath` in the parsed registry, whatever scope or project entry holds it.""" + if isinstance(data, dict): + own = data.get("installPath") + found = [own] if isinstance(own, str) else [] + return found + [p for v in data.values() for p in install_paths(v)] + if isinstance(data, list): + return [p for v in data for p in install_paths(v)] + return [] + + +def resolve(path: str | Path) -> Path | None: + try: + return Path(path).expanduser().resolve() + except (OSError, RuntimeError): + return None + + +def load_registry(read: Reader, root: Path, label: str = INSTALLED_PLUGINS) -> Registry: + """Read the registry under ``root`` and resolve the paths it references. + + ``label`` names the registry in ``doubt``. + """ + cache = root / "plugins" / "cache" + try: + data: Any = json.loads(read(INSTALLED_PLUGINS)) + except (OSError, ValueError) as exc: + return Registry( + frozenset(), False, f"{label} unreadable ({type(exc).__name__})", False + ) + if not isinstance(data, dict) or not isinstance(data.get("plugins"), dict): + return Registry(frozenset(), False, f"{label} has no `plugins` object", False) + referenced = frozenset( + p for p in map(resolve, install_paths(data)) if p is not None + ) + installs = bool(data["plugins"]) + cache_resolved = resolve(cache) + if installs and not any(cache_resolved in p.parents for p in referenced): + doubt = f"no installPath in {label} lies under {cache}" + return Registry(referenced, installs, doubt, True) + return Registry(referenced, installs, "", False) + + +def orphan_marker(read: Reader, version_rel: str, now: float) -> dict[str, Any] | None: + """The age of a version's ``.orphaned_at`` marker, or None when it is missing or unparsable.""" + try: + epoch = int(read(f"{version_rel}/.orphaned_at").strip()) / 1000 + age = (now - epoch) / _DAY + return { + "orphaned_at": dt.datetime.fromtimestamp(epoch, dt.timezone.utc).isoformat( + timespec="seconds" + ), + "marker_age_days": round(age, 1), + "past_sweep_window": age >= ORPHAN_SWEEP_DAYS, + } + except (OSError, ValueError, OverflowError): + return None diff --git a/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py b/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py index f87c971397..b23a24375c 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py +++ b/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py @@ -29,15 +29,27 @@ import datetime as dt import functools -import json import os import re import stat +import sys import tempfile from collections.abc import Iterable from pathlib import Path from typing import Any +_LIB_DIR = Path(__file__).resolve().parents[3] / "lib" +if str(_LIB_DIR) not in sys.path: + sys.path.insert(0, str(_LIB_DIR)) + +from plugin_cache_versions import ( # noqa: E402 (path set above; plugin-bundled module) + INSTALLED_PLUGINS, + ORPHAN_SWEEP_DAYS, + load_registry, + orphan_marker, + resolve, +) + try: import pwd except ImportError: # Windows @@ -91,12 +103,6 @@ ) # Group 1 is the numeric part, group 2 the "-" of a prerelease or the "+" of build metadata. VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)(?:([-+])[\w.+-]+)?$") -# Days after an update or uninstall that Claude Code removes an orphaned plugin -# version, counted from its `.orphaned_at` marker. Basis: -# https://code.claude.com/docs/en/plugins/loading.md ("Cleanup of previous versions"), -# verified 2026-09-30; recheck when that section or a Claude Code changelog entry -# changes the window. -ORPHAN_SWEEP_DAYS = 14 _TOKEN = r"[\w.@/+-]+" _NAMED_TOOL = re.compile( rf"(?:managed|owned) by (?:the )?({_TOKEN}(?: {_TOKEN}){{0,2}})" @@ -340,35 +346,17 @@ def superseded_versions( return rows -def _install_paths(data: object) -> list[str]: - if isinstance(data, dict): - own = data.get("installPath") - found = [own] if isinstance(own, str) else [] - return found + [p for v in data.values() for p in _install_paths(v)] - if isinstance(data, list): - return [p for v in data for p in _install_paths(v)] - return [] - - -def _resolve(path: str | Path) -> Path | None: - try: - return Path(path).expanduser().resolve() - except (OSError, RuntimeError): - return None - +def _read_guarded(root: Path, relpath: str, limit: int = 2_000_000) -> str: + """Read a bounded amount of text from a regular file under ``root``. -def _orphan_marker(version: Path, now: float) -> dict[str, Any] | None: - """The age of a version's ``.orphaned_at`` marker (epoch milliseconds), or None.""" - try: - epoch = int((version / ".orphaned_at").read_text(encoding="utf-8")) / 1000 - age = (now - epoch) / DAY - return { - "orphaned_at": _iso(epoch), - "marker_age_days": round(age, 1), - "past_sweep_window": age >= ORPHAN_SWEEP_DAYS, - } - except (OSError, ValueError, OverflowError): - return None + A cached plugin controls the paths below the cache, so a link is never + followed out of ``root``. The registry itself may be a link. + """ + path = root / relpath + if relpath != INSTALLED_PLUGINS and (path.is_symlink() or not path.is_file()): + raise OSError(f"{relpath} is not a regular file") + with path.open(encoding="utf-8", errors="replace") as fh: + return fh.read(limit) def _orphan_reason(registry: str, marker: dict[str, Any] | None, sweeping: bool) -> str: @@ -413,22 +401,13 @@ def plugin_cache_versions(claude_dir: Path, now: float) -> list[dict[str, Any]]: ] if not paths: return [] - registry = claude_dir / "plugins" / "installed_plugins.json" - referenced: set[Path] = set() - doubt = "" - try: - data = json.loads(registry.read_text(encoding="utf-8")) - except (OSError, ValueError) as exc: - data, doubt = None, f"{registry.name} unreadable ({type(exc).__name__})" - if not doubt and not ( - isinstance(data, dict) and isinstance(data.get("plugins"), dict) - ): - doubt = f"{registry.name} has no `plugins` object" - if not doubt: - referenced = {p for p in map(_resolve, _install_paths(data)) if p is not None} - cache_resolved = _resolve(cache) - if data["plugins"] and not any(cache_resolved in p.parents for p in referenced): - doubt = f"no installPath in {registry.name} lies under {cache}" + registry = claude_dir / INSTALLED_PLUGINS + + def read(rel: str) -> str: + return _read_guarded(claude_dir, rel) + + known = load_registry(read, claude_dir, registry.name) + doubt = known.doubt rows = [] for path in paths: producer = path.parent.name @@ -442,7 +421,7 @@ def plugin_cache_versions(claude_dir: Path, now: float) -> list[dict[str, Any]]: **common, ) ) - elif _resolve(path) in referenced: + elif resolve(path) in known.referenced: rows.append( make_row( path, @@ -453,12 +432,12 @@ def plugin_cache_versions(claude_dir: Path, now: float) -> list[dict[str, Any]]: ) ) else: - marker = _orphan_marker(path, now) + marker = orphan_marker(read, path.relative_to(claude_dir).as_posix(), now) rows.append( make_row( path, disposition="CANDIDATE", - reason=_orphan_reason(registry.name, marker, bool(data["plugins"])), + reason=_orphan_reason(registry.name, marker, known.installs), evidence=marker, **common, ) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py b/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py index e23237ecc1..f7d3c8e981 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py @@ -415,6 +415,15 @@ def test_unparsable_marker_is_the_same_as_none(self) -> None: row = di.plugin_cache_versions(self.root, NOW)[0] self.assertNotIn("evidence", row) + def test_a_symlinked_marker_is_not_followed(self) -> None: + self.cache("mkt/alpha/1.0.0") + self.registry([], {}) + outside = self.root / "outside.txt" + outside.write_text(str(int(NOW * 1000)), encoding="utf-8") + (self.root / "plugins/cache/mkt/alpha/1.0.0/.orphaned_at").symlink_to(outside) + row = di.plugin_cache_versions(self.root, NOW)[0] + self.assertNotIn("evidence", row) + def test_an_empty_registry_means_the_removal_does_not_run(self) -> None: self.cache("mkt/alpha/1.0.0") self.registry([], {}) diff --git a/scripts/cross-plugin-source-registry.txt b/scripts/cross-plugin-source-registry.txt index ba573bc733..de97592fce 100644 --- a/scripts/cross-plugin-source-registry.txt +++ b/scripts/cross-plugin-source-registry.txt @@ -178,3 +178,10 @@ hooks/exec-bash.mjs # Dedicated check: scripts/sync-exec-bash.sh --check. Cluster line for the # duplication audit: the root canonical and the copies. lib/exec-bash.mjs -> plugins/*/hooks/exec-bash.mjs + +# No dedicated check: the unreferenced plugin-cache-version reader carried by the +# claude-ops audit-install-state and disk-hygiene deep-inventory engines. This +# script's own --check is the gate. Canonical copy: +# plugins/claude-ops/lib/plugin_cache_versions.py; copy it into +# plugins/disk-hygiene/lib/ to fix a drift failure. +lib/plugin_cache_versions.py From f0eb4fe5e189542135dcb0c2ac513f78618b0cca Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:10:51 -0400 Subject: [PATCH 2/4] chore(disk-hygiene): version and changelog for the shared plugin-cache-version module Co-Authored-By: Claude Opus 5.5 --- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 6 ++++++ plugins/disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 6 ++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index f18e622100..cf7b9825c6 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.77.2", + "version": "0.77.3", "description": "Claude Code operations toolkit. Thirteen skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used: a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface: every built-in CLI command with aliases and hidden/gated status, every bundled skill, every built-in subagent and tool, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json: full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the unparsable-settings pause, which warns in /status, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labeled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces, namely built-in CLI commands, bundled skills, plugin-backed built-ins, and session-provided skills, against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry from the OTEL store, the collector, the per-session hook event log and hook-event JSONL, and ccusage, with trend reports, a per-session report of what fired, what was blocked and the event timeline, and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and turn them into decisions: apply executes those in scope one PR per owner plugin and hands larger ones off as work items, then re-extract the native surface and file its drift as work items), prerequisites (read-only table of external binaries declared by enabled plugins; never installs), plugins (bring a machine's plugin fleet current on demand: marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view: queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action, an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures. The last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 526b854f3a..628479b42f 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.77.3] - 2026-09-30 + +### Changed + +- **`audit-install-state` reads unreferenced plugin-cache versions through a shared `lib/plugin_cache_versions.py`.** The module is byte-identical with the copy in `disk-hygiene`, and `scripts/check-cross-plugin-source-drift.sh` fails if the copies diverge. The report is unchanged. + ## [0.77.2] - 2026-09-30 ### Fixed diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 7d474e10dd..80da733377 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.40.0", + "version": "0.40.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 e6d7a72616..6d6236ad5d 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,12 @@ 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.40.1] - 2026-09-30 + +### Changed + +- **The deep inventory's `plugin-cache-version` rows come from a shared `lib/plugin_cache_versions.py`.** The module is byte-identical with the copy in `claude-ops`, so the install-state audit and the deep inventory apply one rule for which cache versions are unreferenced, including the guarded read of `installed_plugins.json`. `scripts/check-cross-plugin-source-drift.sh` fails if the copies diverge. + ## [0.40.0] - 2026-09-30 ### Added From 78028854a864e58c19d26cd2fd2f1700f602c80a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:25:53 -0400 Subject: [PATCH 3/4] fix(disk-hygiene): decode the plugin registry strictly and point the sweep-window notes at the shared module Co-Authored-By: Claude Opus 5.5 --- plugins/claude-ops/skills/audit-install-state/SKILL.md | 3 ++- .../disk-hygiene/skills/clean/reference/scan-flags.md | 2 +- .../disk-hygiene/skills/clean/scripts/deep_inventory.py | 9 ++++++--- .../skills/clean/scripts/test_deep_inventory.py | 8 ++++++++ 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/plugins/claude-ops/skills/audit-install-state/SKILL.md b/plugins/claude-ops/skills/audit-install-state/SKILL.md index 295585f5ec..014aab259d 100644 --- a/plugins/claude-ops/skills/audit-install-state/SKILL.md +++ b/plugins/claude-ops/skills/audit-install-state/SKILL.md @@ -178,7 +178,8 @@ section of ("removes that dire background cleanup 14 days later, so a session that already loaded the old version keeps running"). Verified 2026-09-29 against Claude Code 2.1.285 and that page as fetched that day. Recheck when the page changes the window, the marker name, or the sweep condition, or a release note names plugin -cache cleanup; then update `ORPHAN_SWEEP_DAYS` in `scripts/install_state.py`. +cache cleanup; then update `ORPHAN_SWEEP_DAYS` in `lib/plugin_cache_versions.py` and its byte-identical copy in +`disk-hygiene`. ## Phase 4. Numeric names and liveness diff --git a/plugins/disk-hygiene/skills/clean/reference/scan-flags.md b/plugins/disk-hygiene/skills/clean/reference/scan-flags.md index ffdce85c1e..335d0d16c9 100644 --- a/plugins/disk-hygiene/skills/clean/reference/scan-flags.md +++ b/plugins/disk-hygiene/skills/clean/reference/scan-flags.md @@ -70,7 +70,7 @@ rather than a symlink (an nvm alias, `.tool-versions`) is not seen, so its row c `CANDIDATE` for a version that is in use. `plugin-cache-version` candidates carry the `.orphaned_at` marker age and whether it is past the -sweep window (`ORPHAN_SWEEP_DAYS` in `scripts/deep_inventory.py`), in `evidence` and in the reason, +sweep window (`ORPHAN_SWEEP_DAYS` in `lib/plugin_cache_versions.py`), in `evidence` and in the reason, so a version Claude Code removes itself reads differently from one it has not. `tmp-producer` covers the entries of `/tmp` itself, not `$TMPDIR`, and produces rows only when the diff --git a/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py b/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py index b23a24375c..8be385537c 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py +++ b/plugins/disk-hygiene/skills/clean/scripts/deep_inventory.py @@ -350,12 +350,15 @@ def _read_guarded(root: Path, relpath: str, limit: int = 2_000_000) -> str: """Read a bounded amount of text from a regular file under ``root``. A cached plugin controls the paths below the cache, so a link is never - followed out of ``root``. The registry itself may be a link. + followed out of ``root``. The registry itself may be a link, and it is decoded + strictly: replacement characters in an ``installPath`` would name a path the + registry never held. """ path = root / relpath - if relpath != INSTALLED_PLUGINS and (path.is_symlink() or not path.is_file()): + registry = relpath == INSTALLED_PLUGINS + if not registry and (path.is_symlink() or not path.is_file()): raise OSError(f"{relpath} is not a regular file") - with path.open(encoding="utf-8", errors="replace") as fh: + with path.open(encoding="utf-8", errors="strict" if registry else "replace") as fh: return fh.read(limit) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py b/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py index f7d3c8e981..48eda60626 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_deep_inventory.py @@ -372,6 +372,14 @@ def test_registry_that_cannot_vouch_leaves_every_version_unknown(self) -> None: rows = di.plugin_cache_versions(self.root, NOW) self.assertEqual([r["disposition"] for r in rows], ["UNKNOWN"]) + def test_registry_with_invalid_utf8_leaves_every_version_unknown(self) -> None: + self.cache("mkt/alpha/1.0.0") + (self.root / "plugins" / "installed_plugins.json").write_bytes( + b'{"plugins": {"a@mkt": [{"installPath": "/x/\xff"}]}}' + ) + rows = di.plugin_cache_versions(self.root, NOW) + self.assertEqual([r["disposition"] for r in rows], ["UNKNOWN"]) + def test_empty_registry_leaves_every_version_a_candidate(self) -> None: self.cache("mkt/alpha/1.0.0") self.registry([], {}) From 6b997540a316e320f0a63157f8624ca9bf4691c0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:37:08 -0400 Subject: [PATCH 4/4] fix(disk-hygiene): restore the blank line before the 0.41.0 changelog heading Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index bd18f48fb4..21f2bf97db 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol ### Changed - **The deep inventory's `plugin-cache-version` rows come from a shared `lib/plugin_cache_versions.py`.** The module is byte-identical with the copy in `claude-ops`, so the install-state audit and the deep inventory apply one rule for which cache versions are unreferenced, including the guarded read of `installed_plugins.json`. `scripts/check-cross-plugin-source-drift.sh` fails if the copies diverge. + ## [0.41.0] - 2026-09-30 ### Added