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 +9 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/commands/harness.js +57 -2
- package/src/harness-templates.js +44 -2
- package/src/harness.js +123 -10
- package/src/model-rules.js +4 -2
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
package/src/commands/harness.js
CHANGED
|
@@ -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
|
}
|
package/src/harness-templates.js
CHANGED
|
@@ -17,7 +17,36 @@ export const HARNESS_SECTIONS = [
|
|
|
17
17
|
{ id: 'safe-path', heading: '### 5. Default Safe Path โ ํ๊ดด์ ๋ช
๋ น ํญ์ ํ์ธ' },
|
|
18
18
|
];
|
|
19
19
|
|
|
20
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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.
|
package/src/model-rules.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* ratchet.md.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync
|
|
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 {
|
|
212
|
+
try { writeFileSync(p, renderModelRatchet([])); } catch { /* stale content; regenerated next sync */ }
|
|
211
213
|
}
|
|
212
214
|
}
|
|
213
215
|
return written;
|