feat(tokens): stale plugin-cache disk-cleanup finding, honestly labelled Dead config (v5.9 B3b)

The B3a cache filter already exposes discovery.staleCacheVersions; surface them
as a finding so the user knows the superseded plugin versions are safe to delete.

Honesty (Verifiseringsplikt): the finding loads on ZERO turns, so it must NOT
read as a per-turn token cost. TOK normally humanizes to "Wasted tokens"; a new
per-finding category override ('plugin-cache-hygiene' -> "Dead config") plus a
dedicated humanizer translation ("Old plugin versions are sitting on disk (safe
to delete) ... cost zero tokens per turn ... housekeeping, not a performance
problem") keep the prose accurate instead of the generic "using more space"
default. evidence carries the explicit "zero live-context impact" note.

- token-hotspots: Pattern H emits CA-TOK (low) from discovery.staleCacheVersions
  when stale versions exist (`--global`); silent otherwise.
- humanizer: CATEGORY_TO_IMPACT lets a finding's category override the
  scanner-default impact label (raw `category` field unchanged -> --json/--raw
  byte-stable). humanizer-data: honest static translation for the finding.
- Tests: finding fires/severity/category, lists stale keys + zero-impact note,
  silent when none; humanizer override -> Dead config; honest translation locked.
- Docs: tokens command render note (disk-cleanup, not a token problem) +
  --no-exclude-cache flag; README + CLAUDE.md rows (7 patterns, cache-aware).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 18:17:55 +02:00
commit ba9f82f952
8 changed files with 124 additions and 6 deletions

View file

@ -17,7 +17,7 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|---------|-------------|
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
| `/config-audit posture` | Quick health scorecard (A-F grades, 10 quality areas incl. Token Efficiency, Plugin Hygiene) |
| `/config-audit tokens` | prompt-cache-aware token hotspots (6 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget), each ranked hotspot tagged with its load pattern (always / on-demand / external) — optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer |
| `/config-audit tokens` | prompt-cache-aware token hotspots (7 patterns: cache-breaking, redundant perms, deep imports, oversized cascade, bloated SKILL.md desc, MCP tool-schema budget, stale plugin-cache disk-cleanup), each ranked hotspot tagged with its load pattern (always / on-demand / external) — **cache-aware** (stale `~/.claude/plugins/cache` versions excluded by default; only each plugin's active version counts; `--no-exclude-cache` for the full walk), optional `--accurate-tokens` API calibration, `--with-telemetry-recipe` cache-hit recipe pointer |
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens, each tagged with its load pattern (always-loaded / on-demand / external) + an always-loaded subtotal ("tokens that enter context every turn") |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact (incl. a conditional `disableBundledSkills` lever when the active skill listing is over budget — remediation companion to SKL `CA-SKL-002`) |
| `/config-audit optimize` | Optimization lens (mechanism-fit) — config that works but fits a better mechanism: procedure→skill (CA-OPT-001, deterministic), lifecycle→hook / unscoped path→rule / "never"→permission (prose-judgment via opus `optimization-lens-agent`). Hybrid motor; every finding cites a best-practices-register rule. Agent-driven, **not byte-stable** |

View file

@ -275,7 +275,7 @@ Your team configuration changes over time. Track it:
|---------|-------------|
| `/config-audit` | Full audit with auto-scope detection (no setup needed) |
| `/config-audit posture` | Quick health scorecard: A-F grades across 10 quality areas (incl. Token Efficiency, Plugin Hygiene) |
| `/config-audit tokens` | prompt-cache-aware token hotspots — ranked by estimated waste, each tagged with its load pattern (always / on-demand / external); 6 patterns + optional `--accurate-tokens` API calibration |
| `/config-audit tokens` | prompt-cache-aware token hotspots — ranked by estimated waste, each tagged with its load pattern (always / on-demand / external); 7 patterns + optional `--accurate-tokens` API calibration. **Cache-aware:** stale `~/.claude/plugins/cache` versions (superseded installs that load on zero turns) are excluded from the ranking by default — only each plugin's active version is counted; `--no-exclude-cache` restores the full walk. Stale versions surface as a separate **Dead config** disk-cleanup finding |
| `/config-audit manifest` | Ranked table of every token source (CLAUDE.md, rules, agents, skills, output styles, MCP, hooks) sorted by estimated tokens — each tagged with its **load pattern** (always-loaded / on-demand / external) plus an **always-loaded subtotal** ("≈X tokens enter context every turn before you type"). Component-level: no coarse plugin roll-up (it would double-count) |
| `/config-audit feature-gap` | Context-aware feature recommendations grouped by impact |
| `/config-audit optimize` | Optimization lens (mechanism-fit): config that works but fits a better mechanism — procedure→skill, lifecycle→hook, unscoped path→rule, "never"→permission. Hybrid motor (deterministic pre-filter + opus precision gate), every finding cites a best-practices-register rule |
@ -415,7 +415,7 @@ All tools work standalone — no Claude Code session needed:
| **Posture** | `node scanners/posture.mjs <path> [--json] [--global] [--full-machine] [--output-file path]` |
| **Fix** | `node scanners/fix-cli.mjs <path> [--apply] [--json] [--global]` |
| **Drift** | `node scanners/drift-cli.mjs <path> [--save] [--baseline name] [--json]` |
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--no-exclude-cache] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked component-level source table with per-source load pattern + always-loaded subtotal |
| **What's active** | `node scanners/whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` |
| **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` |

View file

@ -28,6 +28,7 @@ Complementary to `/config-audit whats-active`:
Split `$ARGUMENTS` into a path and flags. Path is the first non-flag argument. Default to `.` (current working directory). Recognized flags:
- `--global` — also include the user-level `~/.claude/` cascade
- `--no-exclude-cache` — include stale `~/.claude/plugins/cache` versions in the ranking. **By default they are excluded** (cache-aware filtering, default ON): the cache holds superseded plugin versions that load on *zero* turns, and counting them used to crowd the top-10 with dead config. The active version of each plugin (per `installed_plugins.json`) is always kept — only stale versions are filtered. Use `--no-exclude-cache` to see the full on-disk walk.
- `--json` — emit raw JSON instead of rendered tables (power-user mode; bypasses the humanizer for byte-stable v5.0.0 output)
- `--raw` — pass-through to the scanner; produces v5.0.0 verbatim JSON (bypasses the humanizer). Use when piping into v5.0.0-baseline diff tooling.
- `--with-telemetry-recipe` — include `telemetry_recipe_path` in the JSON output, pointing to `knowledge/cache-telemetry-recipe.md`. Use this when you want to verify a structural fix actually improved cache hit rate (manual jq recipe, opt-in)
@ -42,7 +43,7 @@ Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each fin
TMPFILE="/tmp/config-audit-tokens-$$.json"
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] $RAW_FLAG 2>/dev/null; echo $?
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] [--no-exclude-cache] $RAW_FLAG 2>/dev/null; echo $?
```
**Exit code handling:**
@ -63,11 +64,13 @@ Use the Read tool on `$TMPFILE`. Extract:
- `total_estimated_tokens` — top-line number
- `hotspots[]` — top 10 ranked sources; each carries a **load pattern** (`loadPattern` ∈ always / on-demand / external, plus `survivesCompaction` / `derivationConfidence`)
- `findings[]` — prompt-cache pattern findings (CA-TOK-001..003); each finding in default mode carries humanizer fields (`userImpactCategory`, `userActionLanguage`, `relevanceContext`) alongside the v5.0.0 fields
- `findings[]` — prompt-cache pattern findings; each finding in default mode carries humanizer fields (`userImpactCategory`, `userActionLanguage`, `relevanceContext`) alongside the v5.0.0 fields
- `counts` — severity breakdown
A hotspot's **load pattern** matters as much as its size: an **always**-loaded source (CLAUDE.md, MCP tool schemas) is paid on *every* turn, an **on-demand** one (skill body, path-scoped rule) only when invoked/matched, and an **external** one (hooks, harness-config files like settings.json/.mcp.json) costs no per-turn context tokens at all. A big always-loaded hotspot is the most worth trimming.
**The stale plugin-cache finding is different from the rest.** All other TOK findings are about per-turn token cost. The *"Old plugin versions are sitting on disk"* finding (category `plugin-cache-hygiene`, impact **Dead config**, `--global` only) is a pure **disk-cleanup** item with **zero live-context impact** — the listed versions are never loaded. Render it as housekeeping, not a token problem: don't conflate its disk bytes with the per-turn token numbers above it.
Render as markdown. Group findings by `userImpactCategory` (e.g., "Wasted tokens" vs "Configuration mistake") rather than re-deriving severity prose; lead each line with `userActionLanguage` ("Fix this now", "Fix soon", "Optional cleanup", etc.) so the urgency phrasing stays consistent with the rest of the toolchain. The humanizer already replaced jargon-heavy `title`/`description`/`recommendation` strings with plain-language equivalents — render them verbatim.
```markdown

View file

@ -569,6 +569,11 @@ export const TRANSLATIONS = {
description: 'Skill descriptions load on every turn whether you use the skill or not. Long descriptions add up.',
recommendation: 'Trim the description to one short sentence and move details into the skill body.',
},
'Stale plugin-cache versions (disk cleanup, zero live-context impact)': {
title: 'Old plugin versions are sitting on disk (safe to delete)',
description: 'Your plugin cache holds older versions that newer installs have replaced. They take up disk space but are never loaded into a conversation — so they cost zero tokens per turn. This is housekeeping, not a performance problem.',
recommendation: 'Delete the old version folders to reclaim disk. The details list exactly which ones; the active version of each plugin stays untouched.',
},
},
patterns: [
{

View file

@ -45,6 +45,17 @@ const SCANNER_TO_CATEGORY = {
OPT: 'Missed opportunity',
};
/**
* Per-finding `category` values that override the scanner-default impact label.
* Needed when one finding inside a scanner means something different from the
* scanner's usual bucket e.g. stale plugin-cache versions are emitted by TOK
* (normally "Wasted tokens") but load on ZERO turns, so their honest impact is
* "Dead config" (present on disk, never loaded), not wasted per-turn tokens.
*/
const CATEGORY_TO_IMPACT = {
'plugin-cache-hygiene': 'Dead config',
};
/**
* Map severity to one-line action-language phrase (research/02 line 134).
*/
@ -125,7 +136,8 @@ export function humanizeFinding(finding) {
}
const translation = lookupTranslation(finding.scanner, finding.title);
const category = SCANNER_TO_CATEGORY[finding.scanner] || 'Other';
const category =
CATEGORY_TO_IMPACT[finding.category] || SCANNER_TO_CATEGORY[finding.scanner] || 'Other';
const action = SEVERITY_TO_ACTION[finding.severity] || 'FYI';
const relevance = computeRelevanceContext(finding.file);

View file

@ -544,6 +544,42 @@ export async function scan(targetPath, discovery) {
}));
}
// ── Pattern H: stale plugin-cache versions (v5.9 B3 — disk hygiene) ──
// file-discovery populates discovery.staleCacheVersions when walking
// ~/.claude/plugins/cache: version dirs on disk NOT referenced by
// installed_plugins.json (superseded installs). They occupy disk but load on
// ZERO turns — already excluded from the hotspot ranking + CNF by
// --exclude-cache. Honest framing (category 'plugin-cache-hygiene' →
// "Dead config", not "Wasted tokens"): a disk-cleanup opportunity, NOT a
// per-turn token cost.
const stale = Array.isArray(discovery.staleCacheVersions) ? discovery.staleCacheVersions : [];
if (stale.length > 0) {
const totalFiles = stale.reduce((s, v) => s + (v.fileCount || 0), 0);
const totalBytes = stale.reduce((s, v) => s + (v.estimatedBytes || 0), 0);
const keys = stale.map(v => v.key);
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Stale plugin-cache versions (disk cleanup, zero live-context impact)',
description:
`${stale.length} plugin version${stale.length === 1 ? '' : 's'} under ` +
`~/.claude/plugins/cache ${stale.length === 1 ? 'is' : 'are'} not referenced by ` +
'installed_plugins.json — superseded by newer installs. They occupy disk but load on ' +
'ZERO turns: excluded from the hotspot ranking and conflict detection by default ' +
'(--exclude-cache). This is disk hygiene, not a per-turn token cost.',
file: null,
evidence:
`stale_versions=${keys.join(', ')}; config_files=${totalFiles}; ` +
`approx_disk_bytes=${totalBytes}; note=zero live-context impact ` +
'(installed_plugins.json points at newer versions)',
recommendation:
'Delete the listed stale version directories under ~/.claude/plugins/cache to reclaim ' +
'disk (reinstall/prune via the plugin manager, or remove the dirs directly). Re-run ' +
'with --no-exclude-cache to include cached versions in the token/conflict scan.',
category: 'plugin-cache-hygiene',
}));
}
// ── Hotspots ranking ──
const hotspots = await buildHotspots(discovery, targetPath, activeConfig);

View file

@ -95,6 +95,19 @@ test('humanizeFinding rewrites title for known static title', () => {
`humanized title should mention instructions or claude, got: ${out.title}`);
});
test('humanizeFinding: stale plugin-cache title humanizes to honest disk-cleanup prose, not the generic TOK default (B3)', () => {
const input = makeFinding({
scanner: 'TOK',
title: 'Stale plugin-cache versions (disk cleanup, zero live-context impact)',
category: 'plugin-cache-hygiene',
});
const out = humanizeFinding(input);
assert.match(out.title, /disk/i, 'humanized title must name disk, not generic "space"');
assert.match(out.description, /zero tokens per turn|never loaded/i,
'humanized description must keep the zero-context-impact honesty');
assert.equal(out.userImpactCategory, 'Dead config');
});
test('humanizeFinding falls back to _default when title unknown', () => {
const input = makeFinding({ scanner: 'CML', title: 'Unrecognized brand-new finding title' });
const out = humanizeFinding(input);
@ -183,6 +196,14 @@ test('humanizeFinding sets category Dead config for DIS', () => {
assert.equal(out.userImpactCategory, 'Dead config');
});
test('humanizeFinding: per-finding category plugin-cache-hygiene overrides scanner default to Dead config (B3)', () => {
// TOK normally maps to "Wasted tokens", but a stale-cache finding loads on
// zero turns — its honest impact is "Dead config", not wasted per-turn tokens.
const out = humanizeFinding(makeFinding({ scanner: 'TOK', category: 'plugin-cache-hygiene' }));
assert.equal(out.userImpactCategory, 'Dead config');
assert.equal(out.category, 'plugin-cache-hygiene', 'raw category field preserved unchanged');
});
test('humanizeFinding sets category Missed opportunity for GAP', () => {
const out = humanizeFinding(makeFinding({ scanner: 'GAP', title: 'No CLAUDE.md file' }));
assert.equal(out.userImpactCategory, 'Missed opportunity');

View file

@ -354,3 +354,44 @@ describe('TOK scanner — F7 severity recalibration (v5)', () => {
});
}
});
// ── Pattern H: stale plugin-cache versions (v5.9 B3) ──────────────────────
describe('TOK scanner — H stale plugin-cache versions (v5.9 B3)', () => {
const staleDiscovery = {
files: [],
staleCacheVersions: [
{ key: 'mkt/voyage/5.1.1', fileCount: 35, estimatedBytes: 416237 },
{ key: 'mkt/config-audit/5.6.0', fileCount: 149, estimatedBytes: 301330 },
],
};
it('emits a low-severity stale-cache finding when stale versions exist', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', staleDiscovery));
const f = result.findings.find(x => /stale plugin-cache/i.test(x.title || ''));
assert.ok(f, 'expected a stale plugin-cache finding');
assert.equal(f.severity, 'low');
assert.equal(f.category, 'plugin-cache-hygiene');
});
it('states zero live-context impact and lists the stale keys', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', staleDiscovery));
const f = result.findings.find(x => /stale plugin-cache/i.test(x.title || ''));
assert.match(f.evidence, /zero live-context impact/i);
assert.match(f.evidence, /mkt\/voyage\/5\.1\.1/);
assert.match(f.evidence, /mkt\/config-audit\/5\.6\.0/);
});
it('does NOT fire when there are no stale versions', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', { files: [], staleCacheVersions: [] }));
assert.ok(!result.findings.some(x => /stale plugin-cache/i.test(x.title || '')));
});
it('does NOT fire when discovery omits staleCacheVersions entirely', async () => {
resetCounter();
const result = await withHermeticHome(() => scan('/tmp/x', { files: [] }));
assert.ok(!result.findings.some(x => /stale plugin-cache/i.test(x.title || '')));
});
});