claude-slim 2.12.1 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -148,6 +148,7 @@ Then just type `/claude-slim` in any session.
148
148
  /claude-slim scan # Report only, no changes
149
149
  /claude-slim scan --json # Machine-readable JSON output
150
150
  /claude-slim scan --lookback-days 30 # Treat skills idle for 30+ days as unused
151
+ /claude-slim scan --project-dir PATH # Count PATH's project memory (default: cwd)
151
152
  /claude-slim doctor # Check scanner prerequisites and data fidelity
152
153
  /claude-slim check-update # Is a newer version published?
153
154
  /claude-slim restore # Bring back anything you disabled
@@ -248,12 +249,15 @@ Token counts come from [js-tiktoken](https://github.com/nicolo-ribaudo/js-tiktok
248
249
 
249
250
  ---
250
251
 
251
- ## v2.12.1 — What's new
252
+ ## v2.13.0 — What's new
252
253
 
253
- - **Fixed: the `/claude-slim` skill reported project memory as zero.** It invoked the CLI with `cd "${CLAUDE_PLUGIN_ROOT}"`, making `cwd` the plugin cache directory; the project slug resolved there, matched nothing, and every project-memory token silently left the startup total **108,570** on the machine where this surfaced. `SKILL.md` no longer `cd`s. This hit the tool's primary entry point, and quietly: a zero reads as a clean result.
254
- - **`--project-dir <path>`** for callers that cannot run from the project directory, plus a warning when the CLI notices it is running from its own install without one.
254
+ - **Changed: the startup estimate no longer counts disabled plugins.** Their skills are not in the session catalog, so they are not a startup cost but they were being added to a number labelled "tokens at session start". Verified against a live session rather than assumed: skills from every disabled plugin were absent from the prompt, while every enabled one's were present. Measured here: **12,504 9,836**, a 21% correction.
255
+ - **Added `disabledPluginSkillTokens`**, shown under the overhead line and in `--json` what re-enabling everything would cost. A headline number that drops by a fifth with no explanation reads like a bug.
256
+ - **Fixed: plugin skills are attributed to their plugin, not their marketplace.** One marketplace can host several plugins with different enabled states, so the two had to be told apart.
255
257
 
256
- Tests: 392 401 (+9).
258
+ Only plugins *explicitly reported disabled* are excluded. `claude plugin list` has a third state — `✘ failed to load` — and a plugin in it still loads its skills, so anything unrecognised keeps counting.
259
+
260
+ Tests: 452 → 461 (+9).
257
261
 
258
262
  For older release notes, see [CHANGELOG.md](CHANGELOG.md).
259
263
 
package/dist/cleaner.js CHANGED
@@ -3,6 +3,16 @@ import { join, dirname, resolve, sep } from 'node:path';
3
3
  import { appendManifest, ensureDisabledDir, removeEntry, recordDisabledPlugin, removeDisabledPlugin } from './manifest.js';
4
4
  import { assertInsideAgentRoot, getAgentRoot, getAgentDisabledDir, getSkillsDir, getProjectsDir } from './paths.js';
5
5
  import { disablePlugin, enablePlugin, isClaudeCliAvailable, ClaudeCliMissingError } from './plugin-runtime.js';
6
+ /**
7
+ * Issue types whose cleanup consumes a filesystem path — the ones where two
8
+ * findings on the same path cannot both be executed. Types absent here are
9
+ * either report-only (`oversized_memory`, `disabled_plugin`) or addressed by
10
+ * name through the `claude` CLI rather than by path (`unused_plugin`).
11
+ */
12
+ const MOVES_A_PATH = new Set([
13
+ 'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
14
+ 'backup_artifact', 'broken_symlink', 'temp_cache', 'stale_project',
15
+ ]);
6
16
  // Restrict a restore target to a specific subtree of ~/.claude/. Complements
7
17
  // assertInsideClaudeDir: a tampered manifest could still name a legal
8
18
  // ~/.claude/ path that belongs to a different type of asset (e.g. redirect a
@@ -51,7 +61,25 @@ export async function cleanIssues(issues) {
51
61
  if (wantsPluginOps && !(await isClaudeCliAvailable())) {
52
62
  claudeCliMissing = true;
53
63
  }
64
+ // The detectors run independently, so one skill routinely earns several
65
+ // findings at once — `skillify` shows up as duplicate + oversized_skill +
66
+ // unused_skill. Selecting "all" then tried to rename the same directory three
67
+ // times: the first succeeded and the rest surfaced raw ENOENT rows, telling
68
+ // the user a cleanup had failed when it had in fact worked. Collapse them
69
+ // here, keeping the first (issues arrive tier-ordered, so that is the
70
+ // most-confident finding for the path).
71
+ const deduped = [];
72
+ const claimedPaths = new Set();
54
73
  for (const issue of issues) {
74
+ if (MOVES_A_PATH.has(issue.type)) {
75
+ const key = `${issue.agent ?? 'claude'}:${issue.path}`;
76
+ if (claimedPaths.has(key))
77
+ continue;
78
+ claimedPaths.add(key);
79
+ }
80
+ deduped.push(issue);
81
+ }
82
+ for (const issue of deduped) {
55
83
  // Scope every path decision to the issue's own agent, so a Codex issue can
56
84
  // never resolve into ~/.claude/ (or the reverse).
57
85
  const agent = issue.agent ?? 'claude';
package/dist/cli.js CHANGED
@@ -10,13 +10,13 @@ import { cleanIssues, restoreItem } from './cleaner.js';
10
10
  import { readManifest } from './manifest.js';
11
11
  import { formatScanSummary, formatReportBox, calculateReport, } from './report.js';
12
12
  import { collectDoctorReport, formatDoctorReport } from './doctor.js';
13
- import { looksLikeToolInstallDir } from './paths.js';
13
+ import { looksLikeToolInstallDir, projectDirError } from './paths.js';
14
14
  import { checkForUpdate, formatUpdateNotice } from './update-check.js';
15
15
  import { confirmDecision, planUpdate, renderStep, runUpdate } from './update-run.js';
16
16
  import { scanCodex } from './codex/index.js';
17
17
  import { formatCodexSummary } from './codex/report.js';
18
18
  import { classifyCodexIssues } from './codex/detectors.js';
19
- import { resolveSelection, resolveRestoreSelection } from './selection.js';
19
+ import { resolveSelection, resolveRestoreSelection, parseSelection } from './selection.js';
20
20
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
21
21
  const { version: PKG_VERSION } = JSON.parse(readFileSync(pkgPath, 'utf-8'));
22
22
  // Parse a non-negative-integer CLI option, keeping explicit 0 distinct from an
@@ -254,7 +254,8 @@ program
254
254
  return;
255
255
  }
256
256
  console.log('');
257
- const selection = await askUser(' Restore (all / numbers / none): ');
257
+ const selection = await askUser(' Restore (all / numbers / ranges / none): ');
258
+ warnIgnoredSelection(selection, restorable.length);
258
259
  const indices = resolveRestoreSelection(selection, restorable.length);
259
260
  if (indices.length === 0) {
260
261
  console.log('\n Cancelled.\n');
@@ -284,9 +285,17 @@ program
284
285
  .description('Show savings report from last clean')
285
286
  .option('--sessions-per-day <n>', 'Sessions per day for savings estimate', '2')
286
287
  .option('--lookback-days <n>', 'Days of session history for skill-usage analysis', '60')
288
+ .option('--project-dir <path>', 'Directory whose project memory counts toward the total (default: cwd)')
287
289
  .action(async (opts) => {
288
290
  await initTokenizer();
289
- const result = await scan({ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60) });
291
+ // Must resolve the same way `scan` and `clean` do. Skipping this left
292
+ // `report` as the one command that would neither honour --project-dir nor
293
+ // warn from an install directory, so its before/after pair could be
294
+ // computed against a different project than the clean it is reporting on.
295
+ const result = await scan({
296
+ lookbackDays: parseNonNegativeInt(opts.lookbackDays, 60),
297
+ projectDir: resolveProjectDir(opts.projectDir),
298
+ });
290
299
  const allEntries = await readManifest();
291
300
  // Filter to legacy-style entries only (those with tokenCount/name/from fields)
292
301
  const entries = allEntries.filter((e) => !('plugin' in e && 'marketplace' in e));
@@ -358,6 +367,24 @@ program.action(async () => {
358
367
  }
359
368
  await runCleanPipeline({ dryRun: false, auto: false, sessionsPerDay: 2, lookbackDays: 60, codex: true });
360
369
  });
370
+ /**
371
+ * Name the fragments of a selection that were thrown away.
372
+ *
373
+ * Silence here is dangerous in one direction only: the user believes they
374
+ * selected more than they did, sees a short confirmation, and assumes the rest
375
+ * is coming. Keywords (`all`, `none`, empty) are handled by the resolvers and
376
+ * are not fragments, so they are not reported as invalid.
377
+ */
378
+ function warnIgnoredSelection(input, count) {
379
+ const trimmed = input.trim().toLowerCase();
380
+ if (trimmed === '' || ['all', 'a', 'none', 'n', 'enter'].includes(trimmed))
381
+ return;
382
+ const { invalid } = parseSelection(trimmed, count);
383
+ if (invalid.length === 0)
384
+ return;
385
+ console.log(` \x1b[33m!\x1b[0m Ignored ${invalid.length} unrecognised entr${invalid.length === 1 ? 'y' : 'ies'}: ` +
386
+ `${invalid.join(', ')} \x1b[90m(valid: 1-${count}, e.g. "3", "5-12")\x1b[0m`);
387
+ }
361
388
  /**
362
389
  * Resolve which directory's project memory counts toward the startup estimate.
363
390
  *
@@ -365,10 +392,24 @@ program.action(async () => {
365
392
  * directory was given: the slug would resolve to the plugin cache, match no
366
393
  * project, and silently zero out every project-memory token. Warning beats
367
394
  * guessing — we cannot know which project the user meant.
395
+ *
396
+ * A `--project-dir` that does not exist is a hard error rather than a warning.
397
+ * The slug is derived from the path string, so a typo happily resolves to a
398
+ * slug that matches nothing and reports 0 — reintroducing, through the very
399
+ * flag added to prevent it, the silent zero of v2.12.1. An explicit flag with a
400
+ * bad value is unambiguously a mistake, so fail instead of guessing.
368
401
  */
369
402
  function resolveProjectDir(explicit) {
370
- if (explicit)
403
+ if (explicit) {
404
+ const err = projectDirError(explicit);
405
+ if (err) {
406
+ // Exit rather than fall back to cwd: continuing would print a full,
407
+ // confident report built on the wrong project.
408
+ console.error(`error: ${err}`);
409
+ process.exit(1);
410
+ }
371
411
  return explicit;
412
+ }
372
413
  if (looksLikeToolInstallDir()) {
373
414
  console.error(' \x1b[33m!\x1b[0m Running from claude-slim\'s own install directory, so project memory\n' +
374
415
  ' cannot be attributed and is reported as 0. Pass --project-dir <path>\n' +
@@ -400,10 +441,12 @@ async function runCleanPipeline(opts) {
400
441
  console.log(' Actions:');
401
442
  console.log(' Enter → accept pre-selected (Tier 1 only)');
402
443
  console.log(' 1,3,5 → select specific items');
444
+ console.log(' 1-9 → select a range');
403
445
  console.log(' all → select everything');
404
446
  console.log(' none → cancel');
405
447
  console.log('');
406
448
  const selection = await askUser(' Your choice: ');
449
+ warnIgnoredSelection(selection, result.issues.length);
407
450
  selectedIssues = resolveSelection(selection, result.issues);
408
451
  }
409
452
  else if (opts.auto) {
package/dist/paths.d.ts CHANGED
@@ -23,6 +23,19 @@ export declare function getCurrentProjectSlug(cwd?: string): string;
23
23
  * `--project-dir` instead of quietly reporting zero.
24
24
  */
25
25
  export declare function looksLikeToolInstallDir(cwd?: string): boolean;
26
+ /**
27
+ * Why an explicit `--project-dir` is unusable, or null when it is fine.
28
+ *
29
+ * The slug is a pure string transform of the path, so a typo resolves to a
30
+ * perfectly well-formed slug that matches no project on disk and reports zero
31
+ * project memory. That is the identical silent zero v2.12.1 was cut to fix,
32
+ * reintroduced through the flag added to fix it. A non-existent explicit path
33
+ * is unambiguously a mistake — worth an error, not a guess.
34
+ *
35
+ * A directory that exists but holds no memory is NOT an error: that is a real,
36
+ * correctly-measured zero.
37
+ */
38
+ export declare function projectDirError(p: string): string | null;
26
39
  export declare function getManifestPath(): string;
27
40
  export declare function getLegacyManifestPath(): string;
28
41
  /** The agents claude-slim is allowed to touch. Adding one widens what every
package/dist/paths.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
+ import { statSync } from 'node:fs';
2
3
  import { join, resolve, sep } from 'node:path';
3
4
  export function getClaudeDir() {
4
5
  return join(homedir(), '.claude');
@@ -42,6 +43,30 @@ export function looksLikeToolInstallDir(cwd = process.cwd()) {
42
43
  p.includes('/_npx/') ||
43
44
  /\/node_modules\/claude-slim(\/|$)/.test(p));
44
45
  }
46
+ /**
47
+ * Why an explicit `--project-dir` is unusable, or null when it is fine.
48
+ *
49
+ * The slug is a pure string transform of the path, so a typo resolves to a
50
+ * perfectly well-formed slug that matches no project on disk and reports zero
51
+ * project memory. That is the identical silent zero v2.12.1 was cut to fix,
52
+ * reintroduced through the flag added to fix it. A non-existent explicit path
53
+ * is unambiguously a mistake — worth an error, not a guess.
54
+ *
55
+ * A directory that exists but holds no memory is NOT an error: that is a real,
56
+ * correctly-measured zero.
57
+ */
58
+ export function projectDirError(p) {
59
+ let st;
60
+ try {
61
+ st = statSync(p);
62
+ }
63
+ catch {
64
+ return `--project-dir is not an existing directory: ${p}`;
65
+ }
66
+ if (!st.isDirectory())
67
+ return `--project-dir is not a directory: ${p}`;
68
+ return null;
69
+ }
45
70
  export function getManifestPath() {
46
71
  return join(getDisabledDir(), 'manifest.json');
47
72
  }
package/dist/report.js CHANGED
@@ -74,8 +74,11 @@ export function formatReportBox(data) {
74
74
  const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
75
75
  return s + ' '.repeat(Math.max(0, W - 2 - visible.length));
76
76
  };
77
- const top = '\u256d' + '\u2500'.repeat(W) + '\u256e';
78
- const bot = '\u2570' + '\u2500'.repeat(W) + '\u256f';
77
+ // W is the total line width, so the run between the corners is W - 2 \u2014 the
78
+ // same interior width `pad()` and `blank` use. Repeating W here made every
79
+ // border overhang its own body by two columns.
80
+ const top = '\u256d' + '\u2500'.repeat(W - 2) + '\u256e';
81
+ const bot = '\u2570' + '\u2500'.repeat(W - 2) + '\u256f';
79
82
  const blank = '\u2502' + ' '.repeat(W - 2) + '\u2502';
80
83
  lines.push(top);
81
84
  lines.push(`\u2502${pad(' claude-slim report')}\u2502`);
@@ -203,6 +206,16 @@ export function formatScanSummary(result) {
203
206
  `${result.currentProjectMemoryTokens.toLocaleString()} tok ` +
204
207
  `\x1b[90m(${result.allProjectsMemoryTokens.toLocaleString()} tok across all projects, ` +
205
208
  `not a per-session cost)\x1b[0m`);
209
+ // Zero reads as "clean", so name the reason for it. Deliberately does not
210
+ // claim the memory is unattributed: a directory Claude has simply never
211
+ // opened has no memory, and that 0 is correct. What is worth surfacing is
212
+ // that no project state backs this path, so if the user meant a different
213
+ // project, this total is answering the wrong question.
214
+ if (!result.currentProjectKnown) {
215
+ lines.push(` \x1b[33m!\x1b[0m No Claude project state for \x1b[90m${result.currentProjectSlug}\x1b[0m — ` +
216
+ `nothing to attribute here.\n` +
217
+ ` \x1b[90mIf you meant a different project, pass --project-dir <path>.\x1b[0m`);
218
+ }
206
219
  }
207
220
  // --- MCP SERVERS ---
208
221
  lines.push('');
@@ -238,6 +251,20 @@ export function formatScanSummary(result) {
238
251
  // --- SUMMARY ---
239
252
  lines.push('');
240
253
  lines.push(`\x1b[1m ESTIMATED OVERHEAD\x1b[0m: ~${result.totalTokensBefore.toLocaleString()} tokens at session start`);
254
+ if (result.disabledPluginSkillTokens > 0) {
255
+ // Stated rather than silently omitted: the figure moved out of the total in
256
+ // 2.13.0, and a total that drops with no explanation reads like a bug.
257
+ lines.push(` \x1b[90mexcludes ~${result.disabledPluginSkillTokens.toLocaleString()} tokens of disabled-plugin skills — ` +
258
+ `not loaded, so not a startup cost\x1b[0m`);
259
+ }
260
+ if (result.issues.length > 0) {
261
+ // Stated next to the total it is a fraction of, because the numbers on the
262
+ // issue rows below are body sizes, not startup cost, and adding them up
263
+ // yields a "saving" many times larger than the whole startup budget.
264
+ lines.push(` \x1b[1mRECOVERABLE\x1b[0m: ~${result.recoverableStartupTokens.toLocaleString()} tokens ` +
265
+ `\x1b[90mif every issue below is acted on (startup cost only — the per-issue\n` +
266
+ ` figures below are full file sizes, paid when a skill runs, not at startup)\x1b[0m`);
267
+ }
241
268
  if (isUsingFallback()) {
242
269
  lines.push(` \x1b[33m\u26a0 Using bytes/4 approximation (js-tiktoken unavailable)\x1b[0m`);
243
270
  }
@@ -1,6 +1,9 @@
1
+ import type { Stats } from 'node:fs';
1
2
  export declare function safeReadFile(p: string): Promise<string | null>;
2
3
  export declare function safeReaddir(p: string): Promise<string[]>;
3
4
  export declare function isDirectory(p: string): Promise<boolean>;
5
+ /** Stats `p`, or null when it cannot be read. Follows symlinks, like isDirectory. */
6
+ export declare function safeStat(p: string): Promise<Stats | null>;
4
7
  export declare function isBrokenSymlink(p: string): Promise<boolean>;
5
8
  export declare function resolveRealPath(p: string): Promise<string>;
6
9
  export declare function getDirSize(dir: string): Promise<number>;
@@ -24,6 +24,15 @@ export async function isDirectory(p) {
24
24
  return false;
25
25
  }
26
26
  }
27
+ /** Stats `p`, or null when it cannot be read. Follows symlinks, like isDirectory. */
28
+ export async function safeStat(p) {
29
+ try {
30
+ return await stat(p);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
27
36
  export async function isBrokenSymlink(p) {
28
37
  try {
29
38
  const lstats = await lstat(p);
@@ -1,4 +1,4 @@
1
- import type { ScanResult } from '../types.js';
1
+ import type { Issue, ScanResult, SkillInfo } from '../types.js';
2
2
  export interface ScanOptions {
3
3
  lookbackDays?: number;
4
4
  /**
@@ -12,3 +12,26 @@ export interface ScanOptions {
12
12
  projectDir?: string;
13
13
  }
14
14
  export declare function scan(opts?: ScanOptions): Promise<ScanResult>;
15
+ /**
16
+ * What acting on every issue would actually save at session start.
17
+ *
18
+ * Two corrections over a naive `sum(issues.tokens)`, both of which inflate:
19
+ *
20
+ * - **Per-path, not per-issue.** The detectors are independent, so one skill
21
+ * routinely earns several findings at once (`duplicate` + `oversized_skill` +
22
+ * `unused_skill`). Removing it once collects the saving once.
23
+ * - **Listing tokens, not body tokens.** `Issue.tokens` is the whole SKILL.md,
24
+ * which is loaded only when the skill runs. Startup pays for the catalog
25
+ * line alone. Conflating the two overstated savings ~80× in practice.
26
+ *
27
+ * Memory issues count only when they belong to the current project — the same
28
+ * per-project rule `totalTokensBefore` follows. Deletions that free disk but no
29
+ * context (`broken_symlink`, `temp_cache`) contribute nothing here by design.
30
+ *
31
+ * Three separate overlaps have to be collapsed, since every one of them inflates:
32
+ * the same skill path, the same plugin across cached versions, and a memory file
33
+ * that its own stale project already accounts for.
34
+ */
35
+ export declare function sumRecoverableStartupTokens(issues: Issue[], skills: SkillInfo[], currentProjectSlug: string,
36
+ /** Plugin name → its skill-listing tokens. See the `unused_plugin` branch. */
37
+ pluginSkillListingTokens?: Map<string, number>): number;
@@ -4,8 +4,9 @@
4
4
  // Route diagnostics through console.error. Enforced by
5
5
  // src/__tests__/scan-stdout-invariant.test.ts.
6
6
  import { join } from 'node:path';
7
+ import { access } from 'node:fs/promises';
7
8
  import { countTokensCached } from '../tokenizer.js';
8
- import { getClaudeDir, getCurrentProjectSlug } from '../paths.js';
9
+ import { getClaudeDir, getCurrentProjectSlug, getProjectsDir } from '../paths.js';
9
10
  import { safeReadFile } from './fs-walk.js';
10
11
  import { scanLocalSkills } from './local-skills.js';
11
12
  import { scanPluginSkills } from './plugin-skills.js';
@@ -89,7 +90,39 @@ export async function scan(opts = {}) {
89
90
  // measured from each file's frontmatter description rather than assumed
90
91
  // (see scanner/skill-listing.ts) — the real spread is 30–500+ tokens apiece.
91
92
  const sumListing = (entries) => entries.reduce((sum, e) => sum + e.listingTokens, 0);
92
- const skillListingTokens = sumListing(localSkills) + sumListing(pluginSkills);
93
+ // A disabled plugin's skills are not in the session catalog, so they cost
94
+ // nothing at startup. Verified against a live session: skills from every
95
+ // disabled plugin here (document-skills, superpowers, telegram, …) were
96
+ // absent from the prompt, while enabled ones were present. Counting them put
97
+ // 3,397 tokens — 27% of the total — into a number labelled "at session start".
98
+ //
99
+ // Only names reported *explicitly disabled* are dropped. `claude plugin list`
100
+ // also emits `failed to load`, which the parser matches as neither enabled nor
101
+ // disabled: railway reports it (a hook clash) and its twelve skills still
102
+ // load. Treating anything unrecognised as disabled would have silently
103
+ // deleted those from the total, so anything not known-disabled still counts.
104
+ // Keyed on `<plugin>@<marketplace>`, not the bare name: the same plugin name
105
+ // can be installed from two marketplaces in different states, and a name-only
106
+ // set would drop the enabled copy along with the disabled one. `pluginName`
107
+ // is the cache directory, which is the marketplace.
108
+ //
109
+ // An identity enabled anywhere is treated as enabled. `claude plugin list`
110
+ // emits one row per scope, so the same identity legitimately appears twice —
111
+ // and counting a live plugin is the safe error to make, not dropping it.
112
+ const pluginIdentity = (plugin, marketplace) => `${plugin}@${marketplace}`;
113
+ const enabledIdentities = new Set(installed.filter((p) => p.enabled).map((p) => pluginIdentity(p.name, p.marketplace)));
114
+ const disabledIdentities = new Set(installed
115
+ .filter((p) => !p.enabled)
116
+ .map((p) => pluginIdentity(p.name, p.marketplace))
117
+ .filter((id) => !enabledIdentities.has(id)));
118
+ const isLoadedAtStartup = (s) => {
119
+ if (s.plugin === undefined || s.pluginName === undefined)
120
+ return true;
121
+ return !disabledIdentities.has(pluginIdentity(s.plugin, s.pluginName));
122
+ };
123
+ const activePluginSkills = pluginSkills.filter(isLoadedAtStartup);
124
+ const disabledPluginSkillTokens = sumListing(pluginSkills.filter((s) => !isLoadedAtStartup(s)));
125
+ const skillListingTokens = sumListing(localSkills) + sumListing(activePluginSkills);
93
126
  const agentListingTokens = sumListing(userSurfaces.agents);
94
127
  const commandListingTokens = sumListing(userSurfaces.commands);
95
128
  // Memory is per-project: a session loads ~/.claude/projects/<slug>/memory/
@@ -107,6 +140,15 @@ export async function scan(opts = {}) {
107
140
  commandListingTokens +
108
141
  claudeMdTokens +
109
142
  currentProjectMemoryTokens;
143
+ const currentProjectKnown = await pathExists(join(getProjectsDir(), currentProjectSlug));
144
+ // Only the skill-listing slice of a plugin's cost is recoverable startup
145
+ // context — see sumRecoverableStartupTokens. Aggregated by plugin name the
146
+ // same way `pluginCosts` is, so the two stay comparable.
147
+ const pluginSkillListingTokens = new Map();
148
+ for (const c of pluginCostBreakdowns) {
149
+ pluginSkillListingTokens.set(c.pluginName, (pluginSkillListingTokens.get(c.pluginName) ?? 0) + c.skillTokens);
150
+ }
151
+ const recoverableStartupTokens = sumRecoverableStartupTokens(issues, [...localSkills, ...pluginSkills], currentProjectSlug, pluginSkillListingTokens);
110
152
  return {
111
153
  localSkills,
112
154
  pluginSkills,
@@ -124,7 +166,107 @@ export async function scan(opts = {}) {
124
166
  userAgents: userSurfaces.agents,
125
167
  userCommands: userSurfaces.commands,
126
168
  currentProjectSlug,
169
+ currentProjectKnown,
127
170
  currentProjectMemoryTokens,
128
171
  allProjectsMemoryTokens,
172
+ recoverableStartupTokens,
173
+ disabledPluginSkillTokens,
129
174
  };
130
175
  }
176
+ async function pathExists(p) {
177
+ try {
178
+ await access(p);
179
+ return true;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ /** Issue types whose cleanup moves a skill directory out of the listing. */
186
+ const SKILL_MOVE_TYPES = new Set([
187
+ 'template', 'duplicate', 'skill_dup', 'oversized_skill', 'unused_skill',
188
+ 'backup_artifact',
189
+ ]);
190
+ /**
191
+ * What acting on every issue would actually save at session start.
192
+ *
193
+ * Two corrections over a naive `sum(issues.tokens)`, both of which inflate:
194
+ *
195
+ * - **Per-path, not per-issue.** The detectors are independent, so one skill
196
+ * routinely earns several findings at once (`duplicate` + `oversized_skill` +
197
+ * `unused_skill`). Removing it once collects the saving once.
198
+ * - **Listing tokens, not body tokens.** `Issue.tokens` is the whole SKILL.md,
199
+ * which is loaded only when the skill runs. Startup pays for the catalog
200
+ * line alone. Conflating the two overstated savings ~80× in practice.
201
+ *
202
+ * Memory issues count only when they belong to the current project — the same
203
+ * per-project rule `totalTokensBefore` follows. Deletions that free disk but no
204
+ * context (`broken_symlink`, `temp_cache`) contribute nothing here by design.
205
+ *
206
+ * Three separate overlaps have to be collapsed, since every one of them inflates:
207
+ * the same skill path, the same plugin across cached versions, and a memory file
208
+ * that its own stale project already accounts for.
209
+ */
210
+ export function sumRecoverableStartupTokens(issues, skills, currentProjectSlug,
211
+ /** Plugin name → its skill-listing tokens. See the `unused_plugin` branch. */
212
+ pluginSkillListingTokens = new Map()) {
213
+ const listingByPath = new Map(skills.map((s) => [s.path, s.listingTokens]));
214
+ const countedPaths = new Set();
215
+ const countedPlugins = new Set();
216
+ let total = 0;
217
+ // `stale_project` names the slug alone; `oversized_memory` names
218
+ // `<slug>/<file>`. Match both without letting a sibling slug through — plain
219
+ // startsWith would count `-Users-me-app2` as part of `-Users-me-app`.
220
+ const isCurrentProject = (name) => name === currentProjectSlug || name.startsWith(currentProjectSlug + '/');
221
+ // Stale projects first: `stale_project.tokens` is the sum of every memory file
222
+ // in that project, so counting it settles the per-file findings inside it too.
223
+ // Charging both billed an oversized file twice and could claim more than the
224
+ // project's entire memory.
225
+ let currentProjectIsStale = false;
226
+ for (const issue of issues) {
227
+ if (issue.type !== 'stale_project' || !isCurrentProject(issue.name))
228
+ continue;
229
+ if (currentProjectIsStale)
230
+ continue;
231
+ currentProjectIsStale = true;
232
+ total += issue.tokens;
233
+ }
234
+ for (const issue of issues) {
235
+ if (SKILL_MOVE_TYPES.has(issue.type)) {
236
+ if (countedPaths.has(issue.path))
237
+ continue;
238
+ countedPaths.add(issue.path);
239
+ // A skill missing from the listing map costs nothing at startup.
240
+ total += listingByPath.get(issue.path) ?? 0;
241
+ }
242
+ else if (issue.type === 'unused_plugin') {
243
+ // Deliberately NOT `issue.tokens`. That is the plugin's full estimated
244
+ // cost from computePluginCosts — CLAUDE.md section + skills + MCP tools +
245
+ // commands — and two of those parts do not belong in a recovery figure
246
+ // presented against `totalTokensBefore`:
247
+ // - the matched CLAUDE.md section is in the baseline but survives the
248
+ // cleanup, since disabling a plugin does not edit the user's
249
+ // CLAUDE.md (and this tool never modifies it at all);
250
+ // - the MCP-tool and command estimates are genuinely freed, but are not
251
+ // in the baseline, so counting them measures against a total that
252
+ // never included them.
253
+ // The skill listings are the one component that is both. Deduped by name
254
+ // because the surface scan walks version directories, so a plugin with
255
+ // two cached versions raises two findings.
256
+ if (countedPlugins.has(issue.name))
257
+ continue;
258
+ countedPlugins.add(issue.name);
259
+ total += pluginSkillListingTokens.get(issue.name) ?? 0;
260
+ }
261
+ else if (issue.type === 'oversized_memory') {
262
+ // Another project's memory never loads here, so trimming it saves this
263
+ // session nothing.
264
+ if (!isCurrentProject(issue.name))
265
+ continue;
266
+ if (currentProjectIsStale)
267
+ continue; // already inside the stale-project total
268
+ total += issue.tokens;
269
+ }
270
+ }
271
+ return total;
272
+ }
@@ -1,8 +1,46 @@
1
1
  import { join } from 'node:path';
2
2
  import { countTokensCached } from '../tokenizer.js';
3
3
  import { getPluginsDir } from '../paths.js';
4
- import { safeReadFile, safeReaddir, isDirectory, getDirSize } from './fs-walk.js';
4
+ import { safeReadFile, safeReaddir, isDirectory, getDirSize, safeStat } from './fs-walk.js';
5
+ import { pickActiveVersion } from './plugin-versions.js';
5
6
  import { listingTokensFromContent } from './skill-listing.js';
7
+ async function resolveContentRoots(pluginBaseDir) {
8
+ const entries = await safeReaddir(pluginBaseDir);
9
+ // Flat layout: the cache entry holds content directly, with no plugin or
10
+ // version level (`<cache-entry>/skills/<skill>/`). Without this check the
11
+ // loop below reads `skills` as a plugin directory and each skill inside it as
12
+ // a candidate version, then picks exactly one — which is not a miscount but a
13
+ // silent disappearance: the whole entry reported zero skills.
14
+ if (entries.includes('skills'))
15
+ return [{ dir: pluginBaseDir }];
16
+ const roots = [];
17
+ for (const entry of entries) {
18
+ const pluginDir = join(pluginBaseDir, entry);
19
+ if (!(await isDirectory(pluginDir)))
20
+ continue;
21
+ const children = await safeReaddir(pluginDir);
22
+ if (children.includes('skills')) {
23
+ // Content root, not a version container.
24
+ roots.push({ dir: pluginDir, plugin: entry });
25
+ continue;
26
+ }
27
+ const versions = [];
28
+ for (const child of children) {
29
+ const versionDir = join(pluginDir, child);
30
+ if (!(await isDirectory(versionDir)))
31
+ continue;
32
+ const stats = await safeStat(versionDir);
33
+ versions.push({ version: child, installedAt: stats?.mtimeMs ?? 0, dir: versionDir });
34
+ }
35
+ const active = pickActiveVersion(versions);
36
+ // No subdirectories at all: hand back the plugin dir so the walk behaves
37
+ // exactly as it did before rather than silently dropping the plugin.
38
+ roots.push({ dir: active ? active.dir : pluginDir, plugin: entry });
39
+ }
40
+ // A marketplace with no plugin subdirectories still needs walking — some
41
+ // caches put content directly under the top level.
42
+ return roots.length > 0 ? roots : [{ dir: pluginBaseDir }];
43
+ }
6
44
  export async function scanPluginSkills() {
7
45
  const skills = [];
8
46
  const plugins = [];
@@ -20,7 +58,11 @@ export async function scanPluginSkills() {
20
58
  return;
21
59
  }
22
60
  const pluginSkillNames = [];
23
- const walkDir = async (dir) => {
61
+ const walkDir = async (dir, plugin) => {
62
+ // Kept generic: a plugin's content root is normally
63
+ // `<plugin>/<version>/`, but the walk also has to reach skills nested
64
+ // deeper. Version selection happens before we get here — see
65
+ // resolveContentRoots below — so this only ever descends one install.
24
66
  const entries = await safeReaddir(dir);
25
67
  for (const entry of entries) {
26
68
  const entryPath = join(dir, entry);
@@ -44,16 +86,19 @@ export async function scanPluginSkills() {
44
86
  listingTokens: listingTokensFromContent(skillDir, content),
45
87
  source: 'plugin',
46
88
  pluginName,
89
+ plugin,
47
90
  });
48
91
  }
49
92
  }
50
93
  }
51
94
  else {
52
- await walkDir(entryPath);
95
+ await walkDir(entryPath, plugin);
53
96
  }
54
97
  }
55
98
  };
56
- await walkDir(pluginDir);
99
+ for (const root of await resolveContentRoots(pluginDir)) {
100
+ await walkDir(root.dir, root.plugin);
101
+ }
57
102
  if (pluginSkillNames.length > 0) {
58
103
  plugins.push({
59
104
  name: pluginName,
@@ -2,6 +2,7 @@ import { join } from 'node:path';
2
2
  import { statSync } from 'node:fs';
3
3
  import { readFileSync, readdirSync } from 'node:fs';
4
4
  import { getPluginsDir } from '../paths.js';
5
+ import { pickActiveVersion } from './plugin-versions.js';
5
6
  import { listingTokensFromContent } from './skill-listing.js';
6
7
  import { SKILL_PROMPT_OVERHEAD_TOKENS } from './constants.js';
7
8
  function safeReaddir(p) {
@@ -108,13 +109,21 @@ export function scanPluginSurfaces() {
108
109
  const pluginBaseDir = join(marketplaceDir, pluginName);
109
110
  if (!isDir(pluginBaseDir))
110
111
  continue;
111
- // Each plugin may have version subdirectories
112
- for (const version of safeReaddir(pluginBaseDir)) {
113
- const installDir = join(pluginBaseDir, version);
114
- if (!isDir(installDir))
115
- continue;
116
- const dirStat = safeStat(installDir);
117
- const installedAt = dirStat ? Number(dirStat.mtimeMs) : 0;
112
+ // A plugin may have several version subdirectories, but a session loads
113
+ // exactly one. Emitting a surface per version made every per-plugin cost
114
+ // a multiple of the truth, because computePluginCosts sums across
115
+ // surfaces while computePluginBreakdown picks a single one — the two
116
+ // disagreed, and the summed figure is what reached the user.
117
+ const versions = safeReaddir(pluginBaseDir)
118
+ .map((version) => ({ version, installDir: join(pluginBaseDir, version) }))
119
+ .filter((v) => isDir(v.installDir))
120
+ .map((v) => {
121
+ const dirStat = safeStat(v.installDir);
122
+ return { ...v, installedAt: dirStat ? Number(dirStat.mtimeMs) : 0 };
123
+ });
124
+ const active = pickActiveVersion(versions);
125
+ if (active) {
126
+ const { version, installDir, installedAt } = active;
118
127
  const { names: skills, listingTokens: skillListingTokens } = scanSkills(installDir);
119
128
  const mcpServerKeys = parseMcpServerKeys(installDir);
120
129
  const mcpToolPrefixes = mcpServerKeys.map((key) => `plugin_${pluginName}_${key}`);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which cached version of a plugin a session actually loads.
3
+ *
4
+ * `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/` can hold more than
5
+ * one version directory. `claude plugin update` leaves the old one behind as a
6
+ * symlink to the new one (`4.9.1 -> 4.15.4`), and `stat` follows it, so a naive
7
+ * walk sees two complete installs and counts every skill twice. Only one is ever
8
+ * loaded, so counting both inflated `totalTokensBefore` — the number the whole
9
+ * tool is built to report — by 14.5% on the machine where this was measured.
10
+ */
11
+ export interface VersionedDir {
12
+ /** Directory name, normally a semver string. */
13
+ version: string;
14
+ /** Directory mtime in ms, the same signal plugin-surfaces calls `installedAt`. */
15
+ installedAt: number;
16
+ }
17
+ /**
18
+ * Compare two version directory names, newest last (sort-compatible).
19
+ *
20
+ * Numeric segment by segment, so `4.15.4` beats `4.9.1` — the lexicographic
21
+ * comparison that a plain string sort would do gets that backwards. Non-numeric
22
+ * segments fall back to string order, which keeps prereleases deterministic
23
+ * without pretending to implement full semver precedence.
24
+ */
25
+ export declare function compareVersions(a: string, b: string): number;
26
+ /**
27
+ * Pick the version a session would load, or null when there are none.
28
+ *
29
+ * Newest mtime wins, matching how `computePluginBreakdown` already chooses among
30
+ * surfaces. An update-symlink and its target report the *same* mtime — `stat`
31
+ * resolves the link — so the tie-break carries the real weight here: highest
32
+ * version. Without it the choice would depend on readdir order.
33
+ */
34
+ export declare function pickActiveVersion<T extends VersionedDir>(candidates: T[]): T | null;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Which cached version of a plugin a session actually loads.
3
+ *
4
+ * `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/` can hold more than
5
+ * one version directory. `claude plugin update` leaves the old one behind as a
6
+ * symlink to the new one (`4.9.1 -> 4.15.4`), and `stat` follows it, so a naive
7
+ * walk sees two complete installs and counts every skill twice. Only one is ever
8
+ * loaded, so counting both inflated `totalTokensBefore` — the number the whole
9
+ * tool is built to report — by 14.5% on the machine where this was measured.
10
+ */
11
+ /**
12
+ * Compare two version directory names, newest last (sort-compatible).
13
+ *
14
+ * Numeric segment by segment, so `4.15.4` beats `4.9.1` — the lexicographic
15
+ * comparison that a plain string sort would do gets that backwards. Non-numeric
16
+ * segments fall back to string order, which keeps prereleases deterministic
17
+ * without pretending to implement full semver precedence.
18
+ */
19
+ export function compareVersions(a, b) {
20
+ const pa = a.split(/[.\-+]/);
21
+ const pb = b.split(/[.\-+]/);
22
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
23
+ const sa = pa[i] ?? '';
24
+ const sb = pb[i] ?? '';
25
+ const na = Number(sa);
26
+ const nb = Number(sb);
27
+ if (Number.isInteger(na) && Number.isInteger(nb) && sa !== '' && sb !== '') {
28
+ if (na !== nb)
29
+ return na - nb;
30
+ }
31
+ else if (sa !== sb) {
32
+ return sa < sb ? -1 : 1;
33
+ }
34
+ }
35
+ return 0;
36
+ }
37
+ /**
38
+ * Pick the version a session would load, or null when there are none.
39
+ *
40
+ * Newest mtime wins, matching how `computePluginBreakdown` already chooses among
41
+ * surfaces. An update-symlink and its target report the *same* mtime — `stat`
42
+ * resolves the link — so the tie-break carries the real weight here: highest
43
+ * version. Without it the choice would depend on readdir order.
44
+ */
45
+ export function pickActiveVersion(candidates) {
46
+ if (candidates.length === 0)
47
+ return null;
48
+ return candidates.reduce((best, c) => {
49
+ if (c.installedAt !== best.installedAt)
50
+ return c.installedAt > best.installedAt ? c : best;
51
+ return compareVersions(c.version, best.version) > 0 ? c : best;
52
+ });
53
+ }
@@ -1,3 +1,16 @@
1
1
  import type { Issue } from './types.js';
2
+ /**
3
+ * Parsed selection input, including whatever could not be understood.
4
+ *
5
+ * Unparseable fragments used to be dropped in silence. On a list that routinely
6
+ * runs to 70+ numbered items, the natural way to pick a span is `1-20` — and
7
+ * `parseInt('1-20')` is `1`, so the user asked for twenty items, got one, and
8
+ * was told nothing. Surfacing `invalid` lets the caller say what it ignored.
9
+ */
10
+ export interface ParsedSelection {
11
+ indices: number[];
12
+ invalid: string[];
13
+ }
14
+ export declare function parseSelection(input: string, count: number): ParsedSelection;
2
15
  export declare function resolveSelection(input: string, issues: Issue[]): Issue[];
3
16
  export declare function resolveRestoreSelection(input: string, count: number): number[];
package/dist/selection.js CHANGED
@@ -1,3 +1,42 @@
1
+ /** Expand a `N` or `N-M` fragment into 1-based indices, or null if malformed. */
2
+ function parseFragment(part, count) {
3
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(part);
4
+ if (range) {
5
+ const lo = parseInt(range[1], 10);
6
+ const hi = parseInt(range[2], 10);
7
+ if (lo < 1 || hi < 1 || lo > count || hi > count || lo > hi)
8
+ return null;
9
+ return Array.from({ length: hi - lo + 1 }, (_, k) => lo + k);
10
+ }
11
+ if (!/^\d+$/.test(part))
12
+ return null;
13
+ const num = parseInt(part, 10);
14
+ if (num < 1 || num > count)
15
+ return null;
16
+ return [num];
17
+ }
18
+ export function parseSelection(input, count) {
19
+ const indices = [];
20
+ const invalid = [];
21
+ const seen = new Set();
22
+ for (const raw of input.trim().toLowerCase().split(',')) {
23
+ const part = raw.trim();
24
+ if (part === '')
25
+ continue;
26
+ const nums = parseFragment(part, count);
27
+ if (nums === null) {
28
+ invalid.push(part);
29
+ continue;
30
+ }
31
+ for (const num of nums) {
32
+ if (seen.has(num))
33
+ continue;
34
+ seen.add(num);
35
+ indices.push(num);
36
+ }
37
+ }
38
+ return { indices, invalid };
39
+ }
1
40
  export function resolveSelection(input, issues) {
2
41
  const trimmed = input.trim().toLowerCase();
3
42
  if (trimmed === 'none' || trimmed === 'n')
@@ -8,17 +47,7 @@ export function resolveSelection(input, issues) {
8
47
  // Default: tier 1 only
9
48
  return issues.filter((i) => i.tier === 1);
10
49
  }
11
- // Parse comma-separated numbers select exactly those items
12
- const result = [];
13
- const seen = new Set();
14
- for (const part of trimmed.split(',')) {
15
- const num = parseInt(part.trim(), 10);
16
- if (!isNaN(num) && num >= 1 && num <= issues.length && !seen.has(num)) {
17
- seen.add(num);
18
- result.push(issues[num - 1]);
19
- }
20
- }
21
- return result;
50
+ return parseSelection(trimmed, issues.length).indices.map((n) => issues[n - 1]);
22
51
  }
23
52
  export function resolveRestoreSelection(input, count) {
24
53
  const trimmed = input.trim().toLowerCase();
@@ -27,14 +56,6 @@ export function resolveRestoreSelection(input, count) {
27
56
  if (trimmed === 'all' || trimmed === 'a') {
28
57
  return Array.from({ length: count }, (_, i) => i);
29
58
  }
30
- const indices = [];
31
- const seen = new Set();
32
- for (const part of trimmed.split(',')) {
33
- const num = parseInt(part.trim(), 10);
34
- if (!isNaN(num) && num >= 1 && num <= count && !seen.has(num)) {
35
- seen.add(num);
36
- indices.push(num - 1);
37
- }
38
- }
39
- return indices;
59
+ // 0-based, unlike resolveSelection — restore indexes into the manifest array.
60
+ return parseSelection(trimmed, count).indices.map((n) => n - 1);
40
61
  }
package/dist/types.d.ts CHANGED
@@ -14,7 +14,20 @@ export interface SkillInfo {
14
14
  */
15
15
  listingTokens: number;
16
16
  source: 'local' | 'plugin';
17
+ /**
18
+ * Cache directory this skill was found under — the *marketplace*, not the
19
+ * plugin. Kept as-is because `plugins[]` and the `disabled_plugin` detector
20
+ * match cache directories by this name (see parseDisabledPlugins).
21
+ */
17
22
  pluginName?: string;
23
+ /**
24
+ * The actual plugin, e.g. `oh-my-claudecode` where `pluginName` is `omc`.
25
+ *
26
+ * Needed because enabled/disabled state is reported per plugin, not per
27
+ * marketplace, and one marketplace can host both. Absent when the cache entry
28
+ * has no plugin level to read it from.
29
+ */
30
+ plugin?: string;
18
31
  }
19
32
  export interface BrokenSymlink {
20
33
  name: string;
@@ -81,10 +94,42 @@ export interface ScanResult {
81
94
  userCommands: UserSurfaceEntry[];
82
95
  /** Project slug (cwd with `/` → `-`) whose memory a session here would load. */
83
96
  currentProjectSlug: string;
97
+ /**
98
+ * Whether `~/.claude/projects/<currentProjectSlug>/` exists — i.e. whether
99
+ * Claude Code holds any state for this project.
100
+ *
101
+ * When false, `currentProjectMemoryTokens` is 0 because nothing is stored
102
+ * under this slug. That 0 is arithmetically correct either way; what the flag
103
+ * adds is *why*. A directory Claude has never opened genuinely has no memory,
104
+ * while a slug pointing somewhere the user did not mean — the plugin cache, a
105
+ * git worktree — produces the same 0 for a very different reason. Consumers
106
+ * of `--json` had no way to tell a scanned project from a mis-aimed one.
107
+ */
108
+ currentProjectKnown: boolean;
84
109
  /** Memory tokens actually loaded at startup — current project only. */
85
110
  currentProjectMemoryTokens: number;
86
111
  /** Memory tokens across every project on disk. Not a per-session cost. */
87
112
  allProjectsMemoryTokens: number;
113
+ /**
114
+ * Startup tokens that acting on every issue would actually recover.
115
+ *
116
+ * Deliberately NOT `sum(issues.tokens)`. Two things make that sum wrong:
117
+ * 1. Issues are per-finding, not per-path — one skill can be flagged
118
+ * `duplicate` + `oversized_skill` + `unused_skill` and get counted 3×.
119
+ * 2. `Issue.tokens` is the full SKILL.md body, which is only paid when the
120
+ * skill is invoked. Startup pays `listingTokens` — often ~80× smaller.
121
+ * Summing raw issue tokens on a real machine produced 215,535 "savings"
122
+ * against a 13,434-token startup total. This field is the honest number.
123
+ */
124
+ recoverableStartupTokens: number;
125
+ /**
126
+ * Skill-listing tokens belonging to plugins reported as disabled.
127
+ *
128
+ * Excluded from `totalTokensBefore` because a disabled plugin's skills are
129
+ * not in the session catalog. Reported separately so the number does not just
130
+ * vanish: it is what re-enabling everything would cost.
131
+ */
132
+ disabledPluginSkillTokens: number;
88
133
  }
89
134
  export interface ManifestEntry {
90
135
  date: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slim",
3
- "version": "2.12.1",
3
+ "version": "2.13.0",
4
4
  "description": "Audit and shrink your Claude Code startup context. Measures what every skill, plugin, agent, command, and memory file costs in the system prompt, then reversibly disables the dead weight. Non-destructive scan, tiered proposals, one-command restore — no proxy, no compression.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,6 +72,24 @@ After getting the scan JSON, YOU must interpret and present results to the user.
72
72
 
73
73
  > **Templates below are shown in English for readability. Always translate headers, labels, and prompts into the user's detected language when rendering.**
74
74
 
75
+ ### 2-0. Two numbers that are not interchangeable
76
+
77
+ The JSON reports token counts in two different units, and mixing them produces
78
+ savings figures larger than the entire thing being saved:
79
+
80
+ - **`listingTokens`** (and `totalTokensBefore`, `recoverableStartupTokens`) —
81
+ what a session pays at startup for the catalog line.
82
+ - **`tokens`** on a skill or an issue — the whole SKILL.md body, paid only when
83
+ that skill is actually invoked.
84
+
85
+ `Issue.tokens` is the second kind. **Never sum it and call the result savings.**
86
+ On a real machine that sum was 215,535 against a 13,434-token startup total —
87
+ 16× the whole budget. Use `recoverableStartupTokens`, which the CLI already
88
+ computes with duplicates collapsed and the right unit.
89
+
90
+ Quote body tokens only when explaining what one skill costs *per invocation*,
91
+ and label them that way.
92
+
75
93
  ### 2-1. Environment Snapshot Table
76
94
 
77
95
  Show a summary table:
@@ -82,7 +100,15 @@ Show a summary table:
82
100
  | Plugins | N (M skills) | ~X tok |
83
101
  | CLAUDE.md | XKB | X tok |
84
102
  | Memory files | N (XKB) | ~X tok |
85
- | **Session startup overhead** | | **~X tok** |
103
+ | **Session startup overhead** | | **~X tok** (`totalTokensBefore`) |
104
+
105
+ **Check `currentProjectKnown` before presenting memory numbers.** When it is
106
+ `false`, Claude Code holds no state for that slug. The `0` is correct, but it
107
+ may be answering the wrong question — the slug can point at a plugin cache or a
108
+ git worktree rather than the project the user meant. Say that no project state
109
+ backs this path and offer `--project-dir <path>`, instead of presenting the 0 as
110
+ a clean result. Do not assert the memory was lost: a directory Claude has never
111
+ opened really does have none.
86
112
 
87
113
  ### 2-2. Plugin Detail Table
88
114
 
@@ -123,7 +149,10 @@ End with a numbered action list, ordered by impact:
123
149
  2. What to consider
124
150
  3. What to leave alone and why
125
151
 
126
- Show estimated total token savings if all recommended actions are taken.
152
+ For total savings, quote **`recoverableStartupTokens`** and state it against
153
+ `totalTokensBefore` ("~2,700 of ~13,400 startup tokens"). Do not add up the
154
+ per-issue numbers — see 2-0. A skill flagged three times is one cleanup, and
155
+ its body size is not a startup cost.
127
156
 
128
157
  If subcommand is `scan`, stop here. Ask a localized equivalent of "Proceed with cleanup?" only for the full pipeline.
129
158