From ba9f82f95232a581d4472fcd03fd6dc763b5cb66 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 18:17:55 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 2 +- README.md | 4 +-- commands/tokens.md | 7 +++-- scanners/lib/humanizer-data.mjs | 5 ++++ scanners/lib/humanizer.mjs | 14 ++++++++- scanners/token-hotspots.mjs | 36 ++++++++++++++++++++++ tests/lib/humanizer.test.mjs | 21 +++++++++++++ tests/scanners/token-hotspots.test.mjs | 41 ++++++++++++++++++++++++++ 8 files changed, 124 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f9e5474..647cdda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** | diff --git a/README.md b/README.md index 628407e..11ee166 100644 --- a/README.md +++ b/README.md @@ -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 [--json] [--global] [--full-machine] [--output-file path]` | | **Fix** | `node scanners/fix-cli.mjs [--apply] [--json] [--global]` | | **Drift** | `node scanners/drift-cli.mjs [--save] [--baseline name] [--json]` | -| **Tokens** | `node scanners/token-hotspots-cli.mjs [--json] [--global] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` | +| **Tokens** | `node scanners/token-hotspots-cli.mjs [--json] [--global] [--no-exclude-cache] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` | | **Manifest** | `node scanners/manifest.mjs [--json]` — ranked component-level source table with per-source load pattern + always-loaded subtotal | | **What's active** | `node scanners/whats-active.mjs [--json] [--verbose] [--suggest-disables]` | | **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` | diff --git a/commands/tokens.md b/commands/tokens.md index ffce6ca..8a54540 100644 --- a/commands/tokens.md +++ b/commands/tokens.md @@ -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 --output-file "$TMPFILE" [--global] $RAW_FLAG 2>/dev/null; echo $? +node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs --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 diff --git a/scanners/lib/humanizer-data.mjs b/scanners/lib/humanizer-data.mjs index 05ee84f..2356547 100644 --- a/scanners/lib/humanizer-data.mjs +++ b/scanners/lib/humanizer-data.mjs @@ -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: [ { diff --git a/scanners/lib/humanizer.mjs b/scanners/lib/humanizer.mjs index 283ccd7..be4f788 100644 --- a/scanners/lib/humanizer.mjs +++ b/scanners/lib/humanizer.mjs @@ -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); diff --git a/scanners/token-hotspots.mjs b/scanners/token-hotspots.mjs index 0ac42b0..fc90e7f 100644 --- a/scanners/token-hotspots.mjs +++ b/scanners/token-hotspots.mjs @@ -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); diff --git a/tests/lib/humanizer.test.mjs b/tests/lib/humanizer.test.mjs index 8263e30..c049a70 100644 --- a/tests/lib/humanizer.test.mjs +++ b/tests/lib/humanizer.test.mjs @@ -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'); diff --git a/tests/scanners/token-hotspots.test.mjs b/tests/scanners/token-hotspots.test.mjs index 4b19523..039103b 100644 --- a/tests/scanners/token-hotspots.test.mjs +++ b/tests/scanners/token-hotspots.test.mjs @@ -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 || ''))); + }); +});