claude-token-saver 3.6.2 โ†’ 3.6.4

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.en.md CHANGED
@@ -243,6 +243,15 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
243
243
 
244
244
  ## Release notes
245
245
 
246
+ ### v3.6.4 (2026-07-29)
247
+ - **Fixed: false `๐Ÿ…ทโš  ratchet-unloaded`** โ€” the import check looked at a single CLAUDE.md. When the project file carried the harness block, only that file was inspected, so the common layout of block-in-project + `@` imports-in-global raised the warning even though the rules loaded fine. Claude Code loads both files, so the flags are now the union of the two, and `importSource` (`project`/`global`/`both`) says which file carries them. The warning fires only when neither file imports.
248
+
249
+ ### v3.6.3 (2026-07-27)
250
+ - **Fixed: approved ratchet rules never reached the session** โ€” `harness promote` appended rules to `ratchet.md`, but nothing ever read that file. Claude Code loads `CLAUDE.md` (plus whatever it imports) and the harness block carried no import line, so "approved rules apply automatically from the next session" was unimplemented. The block now imports `@.claude/ratchet.md` (project) / `@~/.claude/ratchet.md` (global). Re-running `harness init` upgrades an existing block in place.
251
+ - **`ratchet-model.md` is imported explicitly too** โ€” delegation rules now travel the same declared path instead of relying on a host that happens to pick the file up. To keep the import from dangling, `harness init` seeds an empty file and `syncAllFiles` empties rather than deletes a target that loses its last rule.
252
+ - **New `๐Ÿ…ทโš  ratchet-unloaded` warning** โ€” all five sections can be present while the rules are dead weight, so a missing import is reported separately from `N/5`. `harness check` says the same thing and prints the fix.
253
+ - **New `harness prune`, plus ratchet size in `check`** โ€” an imported ratchet costs tokens on every request. `harness check` now reports rule count and per-request tokens and warns past ~2,000. Trim with `harness prune [--tag <t>] [--older-than <months>] [--dry-run]`, which moves rules to `ratchet-archive.md` rather than deleting them. Prefix a rule with `[tags]` (`- 2026-05-08: [video] ...`) to prune by topic. (`@` imports are static, so load-time filtering is not possible โ€” the only lever is fewer rules.)
254
+
246
255
  ### v3.6.2 (2026-07-26)
247
256
  - Docs-only release โ€” restores the missing v3.4.0โ€“v3.5.3 release notes below. Published because the npm package page renders the README of the published version. No code changes.
248
257
 
package/README.md CHANGED
@@ -200,6 +200,15 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
200
200
 
201
201
  ## ๋ฆด๋ฆฌ์Šค ๋…ธํŠธ
202
202
 
203
+ ### v3.6.4 (2026-07-29)
204
+ - **`๐Ÿ…ทโš  ratchet-unloaded`๊ฐ€ ์ž˜๋ชป ๋œจ๋˜ ๋ฒ„๊ทธ ์ˆ˜์ •** โ€” import ์—ฌ๋ถ€๋ฅผ CLAUDE.md ํ•œ ํŒŒ์ผ์—์„œ๋งŒ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค. ํ”„๋กœ์ ํŠธ `CLAUDE.md`์— harness ๋ธ”๋ก์ด ์žˆ์œผ๋ฉด ๊ทธ ํŒŒ์ผ๋งŒ ๋ณด๊ณ  ํŒ์ •ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, `@` import๊ฐ€ ๊ธ€๋กœ๋ฒŒ `~/.claude/CLAUDE.md`์— ์žˆ๋Š” ํ”ํ•œ ์กฐํ•ฉ์—์„œ๋Š” ๋ฃฐ์ด ์ •์ƒ ๋กœ๋“œ๋˜๋Š”๋ฐ๋„ ๊ฒฝ๊ณ ๊ฐ€ ๋–ด์Šต๋‹ˆ๋‹ค. Claude Code๋Š” ๋‘ ํŒŒ์ผ์„ ๋ชจ๋‘ ๋กœ๋“œํ•˜๋ฏ€๋กœ ์ด์ œ ์–‘์ชฝ์˜ import๋ฅผ ํ•ฉ์ณ์„œ ํŒ์ •ํ•˜๊ณ , ์–ด๋А ์ชฝ์ด ๋“ค๊ณ  ์žˆ๋Š”์ง€๋Š” `importSource`(`project`/`global`/`both`)๋กœ ์•Œ๋ ค์ค๋‹ˆ๋‹ค. ๋‘ ํŒŒ์ผ ๋‹ค import๊ฐ€ ์—†์„ ๋•Œ๋งŒ ๊ฒฝ๊ณ ํ•ฉ๋‹ˆ๋‹ค.
205
+
206
+ ### v3.6.3 (2026-07-27)
207
+ - **์Šน์ธํ•œ ratchet ๋ฃฐ์ด ์„ธ์…˜์— ์ „๋‹ฌ๋˜์ง€ ์•Š๋˜ ๋ฒ„๊ทธ ์ˆ˜์ •** โ€” `harness promote`๋Š” ๋ฃฐ์„ `ratchet.md`์— appendํ–ˆ์ง€๋งŒ, ๊ทธ ํŒŒ์ผ์„ ์ฝ๋Š” ์†Œ๋น„์ž๊ฐ€ ์–ด๋””์—๋„ ์—†์—ˆ์Šต๋‹ˆ๋‹ค. Claude Code๋Š” `CLAUDE.md`(์™€ ๊ทธ๊ฒƒ์ด importํ•˜๋Š” ํŒŒ์ผ)๋งŒ ๋กœ๋“œํ•˜๋Š”๋ฐ harness ๋ธ”๋ก์— import ๋ผ์ธ์ด ์—†์—ˆ๊ธฐ ๋•Œ๋ฌธ์—, "์Šน์ธ๋œ ๋ฃฐ์€ ๋‹ค์Œ ์„ธ์…˜๋ถ€ํ„ฐ ์ž๋™ ์ ์šฉ"์€ ์‚ฌ์‹ค์ƒ ๋ฏธ๊ตฌํ˜„ ์ƒํƒœ์˜€์Šต๋‹ˆ๋‹ค. ์ด์ œ harness ๋ธ”๋ก์ด `@.claude/ratchet.md`(project) / `@~/.claude/ratchet.md`(global)๋ฅผ importํ•ฉ๋‹ˆ๋‹ค. `harness init`์„ ๋‹ค์‹œ ๋Œ๋ฆฌ๋ฉด ๊ธฐ์กด ๋ธ”๋ก๋„ ์ œ์ž๋ฆฌ์—์„œ ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค.
208
+ - **`ratchet-model.md`๋„ ๋ช…์‹œ์ ์œผ๋กœ import** โ€” ๋ชจ๋ธ ํ”ผํŒ… ๋ฃฐ๋„ ํ˜ธ์ŠคํŠธ๊ฐ€ ์•Œ์•„์„œ ์ฝ์–ด์ฃผ๊ธธ ๊ธฐ๋Œ€ํ•˜์ง€ ์•Š๊ณ  ๊ฐ™์€ ๊ฒฝ๋กœ๋กœ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค. import๊ฐ€ ๋Š๊ธฐ์ง€ ์•Š๋„๋ก `harness init`์ด ๋นˆ ํŒŒ์ผ์„ ๋ฏธ๋ฆฌ ๋งŒ๋“ค๊ณ , ๋งˆ์ง€๋ง‰ ๋ฃฐ์ด ์‚ฌ๋ผ์ ธ๋„ `syncAllFiles`๊ฐ€ ํŒŒ์ผ์„ ์ง€์šฐ๋Š” ๋Œ€์‹  ๋น„์›๋‹ˆ๋‹ค.
209
+ - **`๐Ÿ…ทโš  ratchet-unloaded` ๊ฒฝ๊ณ  ์ถ”๊ฐ€** โ€” 5๊ฐœ ์„น์…˜์ด ๋‹ค ์žˆ์–ด๋„ import ๋ผ์ธ์ด ์—†์œผ๋ฉด ๋ฃฐ์ด ์ฃฝ์–ด ์žˆ๋Š” ์ƒํƒœ๋ผ, `N/5`์™€ ๋ณ„๊ฐœ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. `harness check`๋„ ๊ฐ™์€ ๋‚ด์šฉ์„ ์ˆ˜์ • ๋ช…๋ น๊ณผ ํ•จ๊ป˜ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค.
210
+ - **`harness prune` ์ถ”๊ฐ€ + ratchet ํฌ๊ธฐ ํ‘œ์‹œ** โ€” import๋œ ratchet์€ ๋งค ์š”์ฒญ๋งˆ๋‹ค ํ† ํฐ์„ ์”๋‹ˆ๋‹ค. `harness check`๊ฐ€ ๋ฃฐ ์ˆ˜์™€ ์š”์ฒญ๋‹น ํ† ํฐ์„ ๋ณด์—ฌ์ฃผ๊ณ  ~2,000 ํ† ํฐ์„ ๋„˜์œผ๋ฉด ๊ฒฝ๊ณ ํ•ฉ๋‹ˆ๋‹ค. ์ •๋ฆฌ๋Š” `harness prune [--tag <t>] [--older-than <months>] [--dry-run]` โ€” ์‚ญ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ `ratchet-archive.md`๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค. ๋ฃฐ ์•ž์— `[ํƒœ๊ทธ]`๋ฅผ ๋ถ™์—ฌ๋‘๋ฉด(`- 2026-05-08: [video] ...`) ๋ฌถ์–ด์„œ ์ •๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. (`@` import๋Š” ์ •์ ์ด๋ผ ๋กœ๋“œ ์‹œ์  ํ•„ํ„ฐ๋ง์€ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค โ€” ์ค„์ผ ๋ฐฉ๋ฒ•์€ ๋ฃฐ ์ž์ฒด๋ฅผ ์ค„์ด๋Š” ๊ฒƒ๋ฟ)
211
+
203
212
  ### v3.6.2 (2026-07-26)
204
213
  - ๋ฌธ์„œ ์ „์šฉ ๋ฆด๋ฆฌ์Šค โ€” ์•„๋ž˜ v3.4.0~v3.5.3 ๋ฆด๋ฆฌ์Šค ๋…ธํŠธ๊ฐ€ ๋ˆ„๋ฝ๋ผ ์žˆ๋˜ ๊ฒƒ์„ ๋ณต์›ํ–ˆ๊ณ , npm ํŒจํ‚ค์ง€ ํŽ˜์ด์ง€๋Š” ๋ฐœํ–‰๋œ ๋ฒ„์ „์˜ README๋ฅผ ๋ณด์—ฌ์ฃผ๋ฏ€๋กœ ์ด๋ฅผ ๋ฐ˜์˜ํ•˜๊ธฐ ์œ„ํ•ด ์˜ฌ๋ฆฝ๋‹ˆ๋‹ค. ์ฝ”๋“œ ๋ณ€๊ฒฝ ์—†์Œ.
205
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.6.2",
3
+ "version": "3.6.4",
4
4
  "description": "Save tokens on Claude Code โ€” spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@ export async function run({ args, hasFlag }) {
27
27
  }
28
28
  return dflt;
29
29
  };
30
- const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, findProjectRoot } =
30
+ const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, harnessPrune, ratchetSizeStatus, RATCHET_TOKEN_BUDGET, findProjectRoot } =
31
31
  await import('../harness.js');
32
32
  const { HARNESS_SECTIONS } = await import('../harness-templates.js');
33
33
  const { loadConfig, saveConfig, userLanguage } = await import('../config.js');
@@ -54,6 +54,29 @@ export async function run({ args, hasFlag }) {
54
54
  } else {
55
55
  console.log('All 5 harness sections present. โœ…');
56
56
  }
57
+ // Sections can all be present while ratchet.md still never reaches the
58
+ // model โ€” blocks written before v3.6.3 have no `@` import line.
59
+ if (s.hasBlock && !(s.hasRatchetImport && s.hasModelRatchetImport)) {
60
+ const dead = [!s.hasRatchetImport && 'ratchet.md', !s.hasModelRatchetImport && 'ratchet-model.md'].filter(Boolean).join(' + ');
61
+ console.log(`\nโš  ${dead} is NOT loaded into sessions โ€” the harness block has no \`@\` import line for it.`);
62
+ console.log(' Those rules are being written to a file nothing reads.');
63
+ console.log(` Fix: claude-token-saver harness init${s.source === 'global' ? ' --global' : ''} (updates the block in place)`);
64
+ }
65
+ // Imported ratchets are charged on every request, so their size matters.
66
+ // Static `@` imports cannot be filtered at load time โ€” the only lever is
67
+ // fewer rules, hence the prune pointer rather than a "filter" suggestion.
68
+ for (const sc of ['project', 'global']) {
69
+ const size = ratchetSizeStatus({ scope: sc });
70
+ if (!size.count) continue;
71
+ const line = `ratchet.md [${sc}]: ${size.count} rules, ~${size.tokens} tok/request`;
72
+ if (size.overBudget) {
73
+ console.log(`\nโš  ${line} โ€” over the ~${RATCHET_TOKEN_BUDGET} token budget.`);
74
+ console.log(` Trim: claude-token-saver harness prune${sc === 'global' ? ' --global' : ''} --older-than 6 --dry-run`);
75
+ console.log(' (project-specific rules belong in --project scope, not global.)');
76
+ } else {
77
+ console.log(`${line}`);
78
+ }
79
+ }
57
80
  return;
58
81
  }
59
82
 
@@ -322,6 +345,38 @@ export async function run({ args, hasFlag }) {
322
345
  if (wantProject) print('project');
323
346
  if (wantGlobal) print('global');
324
347
  console.log('Remove with: claude-token-saver harness rm [--global|--project] <N>');
348
+ console.log('Archive in bulk: claude-token-saver harness prune [--global] [--tag <t>] [--older-than <months>] [--dry-run]');
349
+ return;
350
+ }
351
+
352
+ if (sub === 'prune') {
353
+ const pruneScope = parseHarnessScope(args.slice(2), 'project');
354
+ const argv = args.slice(2);
355
+ const valueOf = (flag) => {
356
+ const i = argv.indexOf(flag);
357
+ if (i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1];
358
+ const eq = argv.find((a) => a.startsWith(flag + '='));
359
+ return eq ? eq.slice(flag.length + 1) : null;
360
+ };
361
+ const months = valueOf('--older-than');
362
+ if (months !== null && !/^\d+$/.test(months)) {
363
+ console.error(`Invalid --older-than value: ${months} (expected a number of months)`);
364
+ process.exit(1);
365
+ }
366
+ const r = harnessPrune({
367
+ scope: pruneScope,
368
+ tag: valueOf('--tag'),
369
+ olderThanMonths: months ? parseInt(months, 10) : null,
370
+ dryRun: hasFlag('--dry-run'),
371
+ });
372
+ if (!r.ok) { console.error(r.error); process.exit(1); }
373
+ if (!r.pruned.length) { console.log(`Nothing matched โ€” ${r.path} unchanged.`); return; }
374
+ console.log(`${r.dryRun ? 'Would prune' : 'Pruned'} ${r.pruned.length} rule(s) from ${r.path}:`);
375
+ for (const p of r.pruned) console.log(` #${p.index} ${p.text.slice(0, 100)}`);
376
+ if (!r.dryRun) {
377
+ console.log(`\nArchived to: ${r.archive} (backup: ${r.backup})`);
378
+ console.log('Archived rules are NOT loaded into sessions โ€” paste one back into ratchet.md to restore it.');
379
+ }
325
380
  return;
326
381
  }
327
382
 
@@ -378,6 +433,6 @@ export async function run({ args, hasFlag }) {
378
433
  }
379
434
 
380
435
  console.error(`Unknown harness subcommand: ${sub}`);
381
- console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|pull [--global|--project]|list|rm <N>|off|on]');
436
+ console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|pull [--global|--project]|list|rm <N>|prune [--tag <t>] [--older-than <months>] [--dry-run]|off|on]');
382
437
  process.exit(1);
383
438
  }
@@ -17,7 +17,36 @@ export const HARNESS_SECTIONS = [
17
17
  { id: 'safe-path', heading: '### 5. Default Safe Path โ€” ํŒŒ๊ดด์  ๋ช…๋ น ํ•ญ์ƒ ํ™•์ธ' },
18
18
  ];
19
19
 
20
- export function harnessClaudeMdBlock() {
20
+ /**
21
+ * The `@` import that actually loads ratchet.md into the session.
22
+ *
23
+ * Without this line the promoted rules live in a file nobody reads: Claude Code
24
+ * only loads CLAUDE.md (plus whatever it imports), so `harness promote` was a
25
+ * write-only operation before v3.6.3. Project scope imports the repo-local
26
+ * ratchet, global scope the user-level one โ€” mirroring resolveRatchetPath().
27
+ */
28
+ export function ratchetImportLine(scope = 'project') {
29
+ return scope === 'global' ? '@~/.claude/ratchet.md' : '@.claude/ratchet.md';
30
+ }
31
+
32
+ /**
33
+ * The model-fitting ratchet is imported too, so the delegation rules reach the
34
+ * model through the same declared path as everything else instead of relying on
35
+ * a host that happens to pick the file up. harnessInit() seeds an empty
36
+ * ratchet-model.md and syncAllFiles() empties rather than deletes it, so this
37
+ * import never dangles.
38
+ */
39
+ export function modelRatchetImportLine(scope = 'project') {
40
+ return scope === 'global' ? '@~/.claude/ratchet-model.md' : '@.claude/ratchet-model.md';
41
+ }
42
+
43
+ // Matches either scope's import line, so completeness checks don't need to know
44
+ // which scope wrote the block. `(?!-)` keeps the ratchet-model import from
45
+ // counting as the ratchet.md one.
46
+ export const RATCHET_IMPORT_RE = /^@(?:~\/\.claude|\.claude)\/ratchet\.md\s*$/m;
47
+ export const MODEL_RATCHET_IMPORT_RE = /^@(?:~\/\.claude|\.claude)\/ratchet-model\.md\s*$/m;
48
+
49
+ export function harnessClaudeMdBlock(scope = 'project') {
21
50
  const sections = HARNESS_SECTIONS.map((s) => s.heading).join('\n\n... (see full block below)');
22
51
  return `${HARNESS_BLOCK_BEGIN}
23
52
  ## ๐Ÿ…ท Harness Rules (claude-token-saver)
@@ -36,7 +65,8 @@ ${HARNESS_SECTIONS[0].heading}
36
65
  ๋„๊ตฌยทํ™˜๊ฒฝ ์ผ๋ฐ˜ ๋ฃฐ์ด๋ฉด \`--global\`(\`~/.claude/ratchet.md\`). Bash ํ™˜๊ฒฝ์€
37
66
  non-TTY๋ผ CLI์˜ readline ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์•ˆ ๋œจ๋ฏ€๋กœ, ํ˜ธ์ถœ์ž(LLM)๊ฐ€ ์ง์ ‘ ๋ฌป๊ณ 
38
67
  ํ”Œ๋ž˜๊ทธ๋ฅผ ๋ช…์‹œํ•ด์•ผ ํ•จ. ๋ฌป์ง€ ์•Š๊ณ  ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋“ฑ๋กํ•˜์ง€ ๋ง ๊ฒƒ.
39
- - ์Šน์ธ๋œ ๋ฃฐ์€ ๋‹ค์Œ ์„ธ์…˜๋ถ€ํ„ฐ ์ž๋™ ์ ์šฉ.
68
+ - ์Šน์ธ๋œ ๋ฃฐ์€ ์ด ๋ธ”๋ก ๋งจ ์•„๋ž˜ \`@\` import๋กœ ๋งค ์„ธ์…˜ ๋กœ๋“œ๋œ๋‹ค โ€” ๊ทธ import ๋ผ์ธ์„
69
+ ์ง€์šฐ๋ฉด ratchet.md๋Š” ์ปจํ…์ŠคํŠธ์— ๋“ค์–ด์˜ค์ง€ ์•Š์œผ๋‹ˆ ์ง€์šฐ์ง€ ๋ง ๊ฒƒ.
40
70
  - **๋ชจ๋ธ ํ”ผํŒ… ๋žซ์ณ‡**: \`.claude/ratchet-model.md\`(ํ”„๋กœ์ ํŠธ)์™€
41
71
  \`~/.claude/ratchet-model.md\`(๊ธ€๋กœ๋ฒŒ)์— ์žˆ๋Š” ํ‹ฐ์–ด ์œ„์ž„ ๋ฃฐ๋„ ratchet.md์™€
42
72
  ๋™์ผํ•˜๊ฒŒ ๋”ฐ๋ฅผ ๊ฒƒ. ์ด ํŒŒ์ผ์€ claude-token-saver๊ฐ€ ๋กœ๊ทธ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž๋™
@@ -81,6 +111,13 @@ ${HARNESS_SECTIONS[4].heading}
81
111
  - \`claude-token-saver harness check\` โ€” ํ˜„์žฌ ์…‹์—… ์ ์ˆ˜
82
112
  - \`claude-token-saver harness promote "<๋ฃฐ>" --project|--global\` โ€” ratchet์— ๋ฃฐ ์ถ”๊ฐ€ (scope๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ๋จผ์ € ๋ฌผ์–ด๋ณผ ๊ฒƒ)
83
113
  - \`claude-token-saver harness off\` โ€” statusline ํ‘œ์‹œ ๋„๊ธฐ
114
+
115
+ ---
116
+
117
+ ๐Ÿ“ฅ ratchet ๋ฃฐ ๋กœ๋“œ (์ด ์ค„๋“ค์„ ์ง€์šฐ๋ฉด ๋ฃฐ์ด ์ ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค):
118
+
119
+ ${ratchetImportLine(scope)}
120
+ ${modelRatchetImportLine(scope)}
84
121
  ${HARNESS_BLOCK_END}
85
122
  `;
86
123
  }
@@ -93,6 +130,11 @@ export function harnessRatchetMdInitial() {
93
130
  \`claude-token-saver harness promote "<rule>"\`๋กœ ๋ฃฐ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ์ž๋™์œผ๋กœ
94
131
  ์ด ํŒŒ์ผ์— append ๋ฉ๋‹ˆ๋‹ค.
95
132
 
133
+ ์ด ํŒŒ์ผ์€ CLAUDE.md์˜ \`@\` import๋กœ ๋งค ์„ธ์…˜ ๋กœ๋“œ๋ฉ๋‹ˆ๋‹ค โ€” ์ฆ‰ ์—ฌ๊ธฐ ์žˆ๋Š” ๋ชจ๋“  ์ค„์ด
134
+ ๋งค ์š”์ฒญ๋งˆ๋‹ค ํ† ํฐ์„ ์”๋‹ˆ๋‹ค. ๋‚ ์งœ ๋’ค์— \`[ํƒœ๊ทธ]\`๋ฅผ ๋ถ™์—ฌ๋‘๋ฉด ๋‚˜์ค‘์— ๋ฌถ์–ด์„œ ์ •๋ฆฌํ•  ์ˆ˜
135
+ ์žˆ์Šต๋‹ˆ๋‹ค: \`- 2026-05-08: [video,tts] ...\` โ†’
136
+ \`claude-token-saver harness prune --tag video\` (์‚ญ์ œ ์•„๋‹ˆ๋ผ ratchet-archive.md๋กœ ์ด๋™).
137
+
96
138
  ## Rules
97
139
 
98
140
  `;
package/src/harness.js CHANGED
@@ -18,9 +18,11 @@ import {
18
18
  harnessClaudeMdBlock,
19
19
  harnessRatchetMdInitial,
20
20
  appendRatchetRule,
21
+ RATCHET_IMPORT_RE,
22
+ MODEL_RATCHET_IMPORT_RE,
21
23
  } from './harness-templates.js';
22
24
  import { routeWarningForStatusline } from './route-scan.js';
23
- import { ruleHealthWarningForStatusline } from './model-rules.js';
25
+ import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
24
26
 
25
27
  const require = createRequire(import.meta.url);
26
28
  function readHarnessState() {
@@ -96,6 +98,8 @@ function statusForFile(filePath) {
96
98
  hasFile: false,
97
99
  optOut: false,
98
100
  custom: false,
101
+ hasRatchetImport: false,
102
+ hasModelRatchetImport: false,
99
103
  file: filePath,
100
104
  };
101
105
  }
@@ -105,7 +109,7 @@ function statusForFile(filePath) {
105
109
  } catch {
106
110
  // Unreadable file (permissions, etc.) โ€” report every section missing so
107
111
  // `harness check` can't print "All 5 sections present โœ…" over a 0/5.
108
- return { configured: 0, total: HARNESS_SECTIONS.length, missing: HARNESS_SECTIONS.map((s) => s.id), hasBlock: false, hasFile: true, optOut: false, custom: false, file: filePath };
112
+ return { configured: 0, total: HARNESS_SECTIONS.length, missing: HARNESS_SECTIONS.map((s) => s.id), hasBlock: false, hasFile: true, optOut: false, custom: false, hasRatchetImport: false, hasModelRatchetImport: false, file: filePath };
109
113
  }
110
114
  const hasBlock = content.includes(HARNESS_BLOCK_BEGIN);
111
115
  // Opt-out marker โ€” when the user intentionally customizes the harness block
@@ -131,6 +135,11 @@ function statusForFile(filePath) {
131
135
  hasFile: true,
132
136
  optOut,
133
137
  custom,
138
+ // Whether the promoted ratchet rules actually reach the model. Blocks
139
+ // written before v3.6.3 have all 5 sections but no import, so the rules
140
+ // sat in a file nothing read โ€” worth flagging separately from N/5.
141
+ hasRatchetImport: RATCHET_IMPORT_RE.test(content),
142
+ hasModelRatchetImport: MODEL_RATCHET_IMPORT_RE.test(content),
134
143
  file: filePath,
135
144
  };
136
145
  }
@@ -144,15 +153,37 @@ function statusForFile(filePath) {
144
153
  * relies on a globally-installed harness report ๐Ÿ…ท 5/5 (covered by global),
145
154
  * matching reality: Claude Code loads ~/.claude/CLAUDE.md for every project.
146
155
  * The returned `source` ('project'|'global') tells callers which file was used.
156
+ *
157
+ * The `@` import flags are the union of both files, not just the source one:
158
+ * Claude Code loads ~/.claude/CLAUDE.md for every project *and* the project
159
+ * CLAUDE.md, so a project-scope block with the imports living in the global
160
+ * file still gets the ratchet rules. Checking only the source file made that
161
+ * layout report a false `ratchet-unloaded`. `importSource` says which file
162
+ * actually carries them ('project' | 'global' | 'both' | null).
147
163
  */
148
164
  export function harnessStatus(root = findProjectRoot(), { scope = 'auto' } = {}) {
149
- if (scope === 'project') return { ...statusForFile(claudeMdPath(root)), root, source: 'project' };
150
- if (scope === 'global') return { ...statusForFile(globalClaudeMdPath()), root, source: 'global' };
151
165
  const project = statusForFile(claudeMdPath(root));
152
- if (project.hasBlock) return { ...project, root, source: 'project' };
153
166
  const global = statusForFile(globalClaudeMdPath());
154
- if (global.hasBlock) return { ...global, root, source: 'global' };
155
- return { ...project, root, source: 'project' };
167
+ const pick = (s, source) => ({ ...s, ...unionImports(project, global), root, source });
168
+ if (scope === 'project') return pick(project, 'project');
169
+ if (scope === 'global') return pick(global, 'global');
170
+ if (project.hasBlock) return pick(project, 'project');
171
+ if (global.hasBlock) return pick(global, 'global');
172
+ return pick(project, 'project');
173
+ }
174
+
175
+ // Union the two files' import flags. Same file read twice (project root === ~)
176
+ // is harmless โ€” OR is idempotent.
177
+ function unionImports(project, global) {
178
+ const samePath = project.file === global.file;
179
+ const g = samePath ? { hasRatchetImport: false, hasModelRatchetImport: false } : global;
180
+ const inProject = project.hasRatchetImport || project.hasModelRatchetImport;
181
+ const inGlobal = g.hasRatchetImport || g.hasModelRatchetImport;
182
+ return {
183
+ hasRatchetImport: project.hasRatchetImport || g.hasRatchetImport,
184
+ hasModelRatchetImport: project.hasModelRatchetImport || g.hasModelRatchetImport,
185
+ importSource: inProject && inGlobal ? 'both' : inProject ? 'project' : inGlobal ? 'global' : null,
186
+ };
156
187
  }
157
188
 
158
189
  /**
@@ -168,7 +199,7 @@ export function harnessInit({ root = findProjectRoot(), force = false, scope = '
168
199
  const result = { wrote: [], backedUp: [], skipped: [], root, scope };
169
200
 
170
201
  // CLAUDE.md
171
- const block = harnessClaudeMdBlock();
202
+ const block = harnessClaudeMdBlock(scope);
172
203
  if (existsSync(cmPath)) {
173
204
  const existing = readFileSync(cmPath, 'utf8');
174
205
  if (existing.includes(HARNESS_BLOCK_BEGIN) && !force) {
@@ -209,6 +240,20 @@ export function harnessInit({ root = findProjectRoot(), force = false, scope = '
209
240
  result.skipped.push(rmPath + ' (already exists)');
210
241
  }
211
242
 
243
+ // ratchet-model.md โ€” tool-owned, normally written by route-scan. The block
244
+ // imports it, so seed an empty one now rather than ship a dangling import
245
+ // into every project that has not been scanned yet.
246
+ const mrPath = modelRatchetPathFor(scope, root);
247
+ if (!existsSync(mrPath)) {
248
+ try {
249
+ mkdirSync(dirname(mrPath), { recursive: true });
250
+ writeFileSync(mrPath, renderModelRatchet([]));
251
+ result.wrote.push(mrPath);
252
+ } catch { /* unwritable โ€” route-scan will retry on its next sync */ }
253
+ } else {
254
+ result.skipped.push(mrPath + ' (already exists)');
255
+ }
256
+
212
257
  return result;
213
258
  }
214
259
 
@@ -348,10 +393,74 @@ export function harnessListRules({ root = findProjectRoot(), scope = 'project' }
348
393
  // and the "## Rules" anchor are ignored. (Model-fitting rules live in a
349
394
  // separate tool-owned file, ratchet-model.md โ€” never listed here.)
350
395
  if (/^\s*-\s+/.test(line)) {
351
- rules.push({ index: rules.length + 1, lineNo: i, text: line.replace(/^\s*-\s+/, '') });
396
+ const text = line.replace(/^\s*-\s+/, '');
397
+ rules.push({ index: rules.length + 1, lineNo: i, text, ...parseRuleMeta(text) });
352
398
  }
353
399
  }
354
- return { path: rmPath, rules };
400
+ return { path: rmPath, rules, bytes: Buffer.byteLength(readFileSync(rmPath, 'utf8'), 'utf8') };
401
+ }
402
+
403
+ /**
404
+ * Rule metadata, parsed from the line the user already writes:
405
+ * "- 2026-05-08: [video,tts] ์ž๋ง‰ ๋ Œ๋” ์‹œ ..."
406
+ * Both parts are optional โ€” `date` is null on undated rules, `tags` empty on
407
+ * untagged ones. Tags exist to make pruning targeted (`prune --tag video`);
408
+ * they deliberately do NOT filter what gets loaded, because CLAUDE.md `@`
409
+ * imports are static โ€” the file that is imported is the file that is read, so
410
+ * the only way to spend fewer tokens is to have fewer rules in it.
411
+ */
412
+ export function parseRuleMeta(text) {
413
+ const dateM = text.match(/^(\d{4})-(\d{2})-(\d{2})\s*:/);
414
+ // Tags must lead the rule body (right after the date, or at the very start),
415
+ // so a bracketed aside later in the sentence is not mistaken for a tag list.
416
+ const tagM = text.match(/^(?:\d{4}-\d{2}-\d{2}\s*:\s*)?\[([^\]]+)\]/);
417
+ return {
418
+ date: dateM ? dateM[0].replace(/\s*:$/, '') : null,
419
+ tags: tagM ? tagM[1].split(',').map((t) => t.trim()).filter(Boolean) : [],
420
+ };
421
+ }
422
+
423
+ // Rough token cost of the imported ratchet, charged on every single request of
424
+ // every session. 4 bytes/token is the usual mixed ko/en approximation.
425
+ export const RATCHET_TOKEN_BUDGET = 2000;
426
+
427
+ export function ratchetSizeStatus({ root = findProjectRoot(), scope = 'project' } = {}) {
428
+ const { path, rules, bytes = 0 } = harnessListRules({ root, scope });
429
+ const tokens = Math.round(bytes / 4);
430
+ return { path, count: rules.length, bytes, tokens, overBudget: tokens > RATCHET_TOKEN_BUDGET };
431
+ }
432
+
433
+ /**
434
+ * harness prune โ€” move rules out of ratchet.md into ratchet-archive.md next to
435
+ * it. Selection is by tag and/or age; nothing is deleted, so a pruned rule can
436
+ * be pasted back. Returns the pruned rules for the CLI to echo.
437
+ */
438
+ export function harnessPrune({ root = findProjectRoot(), scope = 'project', tag = null, olderThanMonths = null, dryRun = false } = {}) {
439
+ const { path: rmPath, rules } = harnessListRules({ root, scope });
440
+ if (!existsSync(rmPath)) return { ok: false, error: `ratchet.md not found at ${rmPath}` };
441
+ if (!tag && !olderThanMonths) return { ok: false, error: 'Nothing selected โ€” pass --tag <t> and/or --older-than <months>' };
442
+ const cutoff = olderThanMonths ? Date.now() - olderThanMonths * 30 * 24 * 60 * 60 * 1000 : null;
443
+ const doomed = rules.filter((r) => {
444
+ if (tag && !r.tags.includes(tag)) return false;
445
+ // An undated rule has no age to judge, so age-based pruning leaves it be.
446
+ if (cutoff !== null) {
447
+ const t = r.date ? Date.parse(r.date) : NaN;
448
+ if (!Number.isFinite(t) || t >= cutoff) return false;
449
+ }
450
+ return true;
451
+ });
452
+ if (dryRun || doomed.length === 0) return { ok: true, path: rmPath, pruned: doomed, dryRun: true };
453
+ const content = readFileSync(rmPath, 'utf8');
454
+ writeFileSync(rmPath + '.bak', content);
455
+ const drop = new Set(doomed.map((r) => r.lineNo));
456
+ const kept = content.split('\n').filter((_, i) => !drop.has(i));
457
+ writeFileSync(rmPath, kept.join('\n'));
458
+ const archivePath = join(dirname(rmPath), 'ratchet-archive.md');
459
+ const header = existsSync(archivePath) ? '' : '# Ratchet Archive (pruned rules โ€” not loaded into sessions)\n\n';
460
+ const stamp = new Date().toISOString().slice(0, 10);
461
+ const body = doomed.map((r) => `- ${r.text} <!-- pruned ${stamp} -->`).join('\n') + '\n';
462
+ writeFileSync(archivePath, (existsSync(archivePath) ? readFileSync(archivePath, 'utf8').replace(/\n*$/, '\n') : header) + body);
463
+ return { ok: true, path: rmPath, backup: rmPath + '.bak', archive: archivePath, pruned: doomed };
355
464
  }
356
465
 
357
466
  /**
@@ -420,6 +529,10 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
420
529
  else if (state.pevSkip) warning = 'PEV-skip';
421
530
  }
422
531
  }
532
+ // Config defect, above the optimization nudges: the harness block is there
533
+ // but carries no `@` import, so every promoted ratchet rule is dead weight.
534
+ // One `harness init` re-run fixes it and the warning goes away for good.
535
+ if (!warning && status.hasBlock && !(status.hasRatchetImport && status.hasModelRatchetImport)) warning = 'ratchet-unloaded';
423
536
  // Below session-quality warnings: a promoted delegation rule whose
424
537
  // category started failing (`rule-health R<N>`) โ€” the user approved that
425
538
  // rule, so its degradation outranks a mere new-candidate nudge.
@@ -28,7 +28,7 @@
28
28
  * ratchet.md.
29
29
  */
30
30
 
31
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
31
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
32
32
  import { join, dirname } from 'node:path';
33
33
  import { homedir } from 'node:os';
34
34
  import { userDataDir } from './paths.js';
@@ -205,9 +205,11 @@ export function syncAllFiles({ previousPaths = [] } = {}) {
205
205
  written.push(p);
206
206
  } catch { /* unwritable target โ€” skip, registry stays authoritative */ }
207
207
  }
208
+ // A target that lost its last rule is emptied, NOT deleted: CLAUDE.md
209
+ // imports this path, and a dangling `@` import is worse than an empty file.
208
210
  for (const p of previousPaths) {
209
211
  if (!byPath.has(p) && existsSync(p)) {
210
- try { unlinkSync(p); } catch { /* leave stale file; regenerated next sync */ }
212
+ try { writeFileSync(p, renderModelRatchet([])); } catch { /* stale content; regenerated next sync */ }
211
213
  }
212
214
  }
213
215
  return written;