claude-token-saver 3.5.2 → 3.6.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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
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": {
7
7
  "claude-token-saver": "bin/cli.js"
8
8
  },
9
9
  "scripts": {
10
+ "test": "node --test test/",
10
11
  "postinstall": "node bin/cli.js install || true"
11
12
  },
12
13
  "files": [
@@ -0,0 +1,51 @@
1
+ /**
2
+ * argv accessors shared by every subcommand.
3
+ *
4
+ * `createArgs(argv)` binds them to one argument list, so a subcommand module
5
+ * takes them as a parameter instead of reaching for a module-level global —
6
+ * which also makes them directly testable.
7
+ */
8
+
9
+ export function createArgs(argv) {
10
+ const args = argv;
11
+
12
+ function getArg(name) {
13
+ const idx = args.indexOf(name);
14
+ if (idx !== -1) return args[idx + 1];
15
+ const prefix = `${name}=`;
16
+ const eq = args.find((a) => a.startsWith(prefix));
17
+ if (eq) return eq.slice(prefix.length);
18
+ return undefined;
19
+ }
20
+
21
+ function hasFlag(name) {
22
+ return args.includes(name);
23
+ }
24
+
25
+ /**
26
+ * Read a numeric option, rejecting garbage instead of letting NaN flow into
27
+ * the report window. `--days abc` used to render as `NaNd` on the statusline
28
+ * and as a bare "no session data" in the table view — a typo that looked
29
+ * exactly like "you have no logs".
30
+ *
31
+ * @param {string} name flag name, e.g. '--days'
32
+ * @param {object} [opts]
33
+ * @param {number} [opts.dflt] value when the flag is absent
34
+ * @param {number} [opts.min] inclusive lower bound
35
+ * @param {number} [opts.max] inclusive upper bound
36
+ * @throws {Error} on a non-numeric or out-of-range value
37
+ */
38
+ function numArg(name, { dflt, min, max } = {}) {
39
+ const raw = getArg(name);
40
+ if (raw === undefined) return dflt;
41
+ const n = parseFloat(raw);
42
+ if (!Number.isFinite(n)) {
43
+ throw new Error(`${name} expects a number, got "${raw}"`);
44
+ }
45
+ if (min !== undefined && n < min) throw new Error(`${name} must be >= ${min}, got ${n}`);
46
+ if (max !== undefined && n > max) throw new Error(`${name} must be <= ${max}, got ${n}`);
47
+ return n;
48
+ }
49
+
50
+ return { args, getArg, hasFlag, numArg };
51
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Subcommand: route-scan — detect recurring easy work on expensive models
3
+ * and propose model-delegation ratchet rules. Zero token cost, fully local.
4
+ * claude-token-saver route-scan # scan (24h cache) + print candidates
5
+ * claude-token-saver route-scan --refresh # force rescan
6
+ * claude-token-saver route-scan --days 30 # wider lookback
7
+ * claude-token-saver route-scan --hook # SessionStart hook mode (context injection)
8
+ * claude-token-saver route-scan dismiss <N> # mute candidate R<N>
9
+ * Promote a candidate to a ratchet rule (scope is always explicit):
10
+ * claude-token-saver harness promote R<N> --project|--global
11
+ * brief --hook — UserPromptSubmit hook mode: per-session, change-triggered
12
+ * briefing of state the statusline can only chip (ctx tier crossings,
13
+ * mid-session route/rule-health changes). Silent when nothing changed.
14
+ */
15
+
16
+ import { readStdinJson } from '../stdin-payload.js';
17
+ import { debug } from '../debug.js';
18
+
19
+ export async function run({ hasFlag }) {
20
+ if (!hasFlag('--hook')) {
21
+ console.error('Usage: claude-token-saver brief --hook (UserPromptSubmit hook mode)');
22
+ process.exit(1);
23
+ }
24
+ const ctx = readStdinJson() || {};
25
+ try {
26
+ const { runBrief } = await import('../brief.js');
27
+ const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path });
28
+ if (out) console.log(out);
29
+ } catch (e) { debug('brief:hook', e); /* briefing is best-effort — never block a prompt */ }
30
+ return;
31
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Subcommand: handoff — write a HANDOFF-YYYY-MM-DD-HHMM.md template in cwd
3
+ * capturing git status + the latest cap snapshot, so a fresh Claude Code
4
+ * session can pick up where this one stopped. Pairs with the cap-warn chip:
5
+ * when statusline shows 🚨 5H 90%+, run this to back up state before the cap
6
+ * hits.
7
+ * claude-token-saver handoff # write to cwd
8
+ * claude-token-saver handoff --cwd PATH # custom directory
9
+ */
10
+
11
+ import { readStdinJson, extractCaps } from '../stdin-payload.js';
12
+ import { debug } from '../debug.js';
13
+
14
+ export async function run({ getArg }) {
15
+ const { writeHandoff } = await import('../handoff.js');
16
+ const { recordHandoff } = await import('../history.js');
17
+ const cwd = getArg('--cwd') || process.cwd();
18
+ // Cap data only flows in via stdin (Claude Code statusline contract).
19
+ // Direct CLI invocations won't have it — that's fine, the template will
20
+ // note the gap.
21
+ const stdinJson = readStdinJson();
22
+ const caps = extractCaps(stdinJson);
23
+ const { path, git } = writeHandoff({ cwd, caps });
24
+ try { recordHandoff(path); } catch (e) { debug('handoff:record', e); }
25
+ console.log(`Handoff written: ${path}`);
26
+ if (git) {
27
+ console.log(` git: ${git.branch}${git.head ? ` @ ${git.head}` : ''}${git.status ? ' (dirty)' : ' (clean)'}`);
28
+ }
29
+ console.log('');
30
+ console.log('Fill in the empty sections, then start a new Claude Code session with:');
31
+ console.log(' Read the most recent HANDOFF-*.md in this directory and continue the work.');
32
+ return;
33
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Subcommand: harness — manage the project's CLAUDE.md harness rules.
3
+ * claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
4
+ * claude-token-saver harness uninit # remove harness block from CLAUDE.md (backup kept)
5
+ * claude-token-saver harness check # show 🅷 N/5 + which sections are missing
6
+ * claude-token-saver harness promote "<rule>" # append a rule to ratchet.md
7
+ * claude-token-saver harness pull [--global|--project] # register the package's curated preset rules (default global)
8
+ * claude-token-saver harness off | on # toggle the statusline 🅷 segment
9
+ */
10
+
11
+ import { debug } from '../debug.js';
12
+
13
+ export async function run({ args, hasFlag }) {
14
+ const sub = args[1];
15
+ // Scope flags for init/uninit/check (same convention as promote/list/rm):
16
+ // --global | --project | --scope=global|project | --scope global|project
17
+ const parseHarnessScope = (argv, dflt) => {
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const a = argv[i];
20
+ if (a === '--global') return 'global';
21
+ if (a === '--project') return 'project';
22
+ if (a === '--scope' && (argv[i + 1] === 'global' || argv[i + 1] === 'project')) return argv[i + 1];
23
+ if (a.startsWith('--scope=')) {
24
+ const v = a.slice('--scope='.length);
25
+ if (v === 'global' || v === 'project') return v;
26
+ }
27
+ }
28
+ return dflt;
29
+ };
30
+ const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, findProjectRoot } =
31
+ await import('../harness.js');
32
+ const { HARNESS_SECTIONS } = await import('../harness-templates.js');
33
+ const { loadConfig, saveConfig, userLanguage } = await import('../config.js');
34
+ const lang = userLanguage();
35
+
36
+ if (!sub || sub === 'check') {
37
+ const scope = parseHarnessScope(args.slice(2), 'auto'); // auto = project, else global fallback
38
+ const root = findProjectRoot();
39
+ const s = harnessStatus(root, { scope });
40
+ // Only call out "covered by global" when we *fell back* to it (auto), not
41
+ // when the user explicitly asked for the global scope.
42
+ const via = (scope === 'auto' && s.source === 'global') ? ' (covered by global ~/.claude/CLAUDE.md)' : '';
43
+ console.log(`🅷 ${s.configured}/${s.total} — ${s.file}${via}`);
44
+ console.log(`CLAUDE.md: ${s.hasFile ? 'present' : 'missing'}` +
45
+ (s.hasFile ? `, harness block: ${s.hasBlock ? 'yes' : 'no'}` : '') + ` [${s.source}]`);
46
+ if (s.missing.length) {
47
+ console.log('Missing sections:');
48
+ for (const id of s.missing) {
49
+ const sec = HARNESS_SECTIONS.find((x) => x.id === id);
50
+ console.log(` - ${id}: ${sec ? sec.heading.replace(/^#+\s*/, '') : ''}`);
51
+ }
52
+ console.log('\nRun: claude-token-saver harness init (this project)');
53
+ console.log(' or: claude-token-saver harness init --global (all projects, ~/.claude/CLAUDE.md)');
54
+ } else {
55
+ console.log('All 5 harness sections present. ✅');
56
+ }
57
+ return;
58
+ }
59
+
60
+ if (sub === 'init') {
61
+ const scope = parseHarnessScope(args.slice(2), 'project'); // default project (back-compat)
62
+ const force = hasFlag('--force');
63
+ const r = harnessInit({ force, scope });
64
+ console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md — applies to all projects)' : ` (${r.root})`}`);
65
+ for (const p of r.backedUp) console.log(`Backed up: ${p}`);
66
+ for (const p of r.wrote) console.log(`Wrote: ${p}`);
67
+ for (const p of r.skipped) console.log(`Skipped: ${p}`);
68
+ console.log('\n🅷 Harness initialized. Statusline will show 🅷 5/5 on next refresh.');
69
+ return;
70
+ }
71
+
72
+ if (sub === 'promote') {
73
+ // Parse scope flags before stripping. Accepts: --global, --project,
74
+ // --scope=global|project, --scope global|project
75
+ const promoteArgs = args.slice(2);
76
+ let scope = null;
77
+ const scopeFlags = new Set();
78
+ for (let i = 0; i < promoteArgs.length; i++) {
79
+ const a = promoteArgs[i];
80
+ if (a === '--global') { scope = 'global'; scopeFlags.add(i); }
81
+ else if (a === '--project') { scope = 'project'; scopeFlags.add(i); }
82
+ else if (a === '--scope' && promoteArgs[i + 1]) {
83
+ const v = promoteArgs[i + 1];
84
+ if (v !== 'global' && v !== 'project') {
85
+ console.error(`Invalid --scope value: ${v} (expected "global" or "project")`);
86
+ process.exit(1);
87
+ }
88
+ scope = v; scopeFlags.add(i); scopeFlags.add(i + 1); i++;
89
+ } else if (a.startsWith('--scope=')) {
90
+ const v = a.slice('--scope='.length);
91
+ if (v !== 'global' && v !== 'project') {
92
+ console.error(`Invalid --scope value: ${v} (expected "global" or "project")`);
93
+ process.exit(1);
94
+ }
95
+ scope = v; scopeFlags.add(i);
96
+ }
97
+ }
98
+ const raw = promoteArgs.filter((_, i) => !scopeFlags.has(i)).join(' ').trim();
99
+ if (!raw) {
100
+ console.error('Usage: claude-token-saver harness promote [--global|--project] <N> # from statusline 🅷⚠ ratchet? #N');
101
+ console.error(' or: claude-token-saver harness promote [--global|--project] "<rule text>"');
102
+ process.exit(1);
103
+ }
104
+ let rule = raw;
105
+ // Numeric arg → look up candidate #N from analyzer state and turn its
106
+ // detected error pattern into a starter ratchet rule. Saves the user
107
+ // from retyping the error; they can edit ratchet.md afterward.
108
+ if (/^\d+$/.test(raw)) {
109
+ const n = parseInt(raw, 10);
110
+ const analyzer = await import('../harness-analyzer.cjs');
111
+ const a = analyzer.default || analyzer;
112
+ const state = a.readState();
113
+ const list = (state && state.ratchetCandidates) || [];
114
+ const cand = list.find((c) => c.id === n);
115
+ if (!cand) {
116
+ console.error(`No ratchet candidate #${n} in state. Run \`harness analyze\` or wait for the hook to populate it.`);
117
+ if (list.length) {
118
+ console.error('Available candidates:');
119
+ for (const c of list) console.error(` #${c.id} (×${c.count}): ${c.pattern}`);
120
+ }
121
+ process.exit(1);
122
+ }
123
+ rule = `반복 감지 ×${cand.count}: ${cand.pattern} — TODO: 원인·예방책 한 줄로`;
124
+ }
125
+ // R-prefixed arg → route-scan delegation candidate (statusline `route? R<N>`).
126
+ // The rule text is pre-generated by the scan; promoting also resolves the
127
+ // candidate so the chip stops and rescans don't resurface it.
128
+ let routeCandidateId = null;
129
+ let routeCandidate = null;
130
+ if (/^[Rr]\d+$/.test(raw)) {
131
+ const n = parseInt(raw.slice(1), 10);
132
+ const rs = await import('../route-scan.js');
133
+ const cand = (rs.openCandidates(rs.readRouteScan()) || []).find((c) => c.id === n);
134
+ if (!cand) {
135
+ console.error(`No open route candidate R${n}. Run: claude-token-saver route-scan`);
136
+ process.exit(1);
137
+ }
138
+ rule = lang === 'ko' ? cand.rule : (cand.ruleEn || cand.rule);
139
+ routeCandidateId = n;
140
+ routeCandidate = cand;
141
+ if (!scope) {
142
+ console.error(`Route candidate R${n} requires an explicit scope (suggested: --${cand.suggestedScope}).`);
143
+ console.error('Ask the user, then pass --project or --global.');
144
+ process.exit(1);
145
+ }
146
+ }
147
+ // Scope resolution: explicit flag wins. Otherwise prompt interactively
148
+ // when running on a TTY; in non-TTY (CI/scripts) require an explicit
149
+ // flag so the choice is never silently made for the caller.
150
+ if (!scope) {
151
+ if (process.stdin.isTTY && process.stdout.isTTY) {
152
+ const readline = await import('node:readline');
153
+ const { homedir: hd } = await import('node:os');
154
+ const { findProjectRoot: fpr } = await import('../harness.js');
155
+ const projPath = `${fpr()}/.claude/ratchet.md`;
156
+ const globPath = `${hd()}/.claude/ratchet.md`;
157
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
158
+ const ask = (q) => new Promise((res) => rl.question(q, res));
159
+ console.log('Where should this rule live?');
160
+ console.log(` [1] project (${projPath})`);
161
+ console.log(` [2] global (${globPath})`);
162
+ const ans = (await ask('Choose [1/2] (default 1): ')).trim();
163
+ rl.close();
164
+ scope = (ans === '2' || ans.toLowerCase() === 'global' || ans.toLowerCase() === 'g')
165
+ ? 'global' : 'project';
166
+ } else {
167
+ console.error('Scope required in non-interactive mode.');
168
+ console.error('Pass --project or --global (or --scope=project|global).');
169
+ process.exit(1);
170
+ }
171
+ }
172
+ // Route candidates become MODEL-FITTING rules: they live in a
173
+ // tool-managed block (separate from user-authored ratchet rules) and
174
+ // keep updating from subsequent logs — recurrence counts, error rates,
175
+ // rule-health — on every rescan.
176
+ if (routeCandidate) {
177
+ const rs = await import('../route-scan.js');
178
+ const mr = await import('../model-rules.js');
179
+ // A --project rule must land in THE project the pattern was detected
180
+ // in, not the cwd's. Old caches without projectPath: verify cwd match.
181
+ let targetRoot = null;
182
+ if (scope === 'project') {
183
+ if (routeCandidate.projectPath) {
184
+ targetRoot = findProjectRoot(routeCandidate.projectPath);
185
+ } else if (rs.mungeProjectPath(findProjectRoot()) === routeCandidate.project) {
186
+ targetRoot = findProjectRoot();
187
+ } else {
188
+ console.error(`Route candidate R${routeCandidateId} was detected in another project (${routeCandidate.project}),`);
189
+ console.error('but this cached scan predates project-path tracking.');
190
+ console.error('Re-scan to capture it, then promote again:');
191
+ console.error(' claude-token-saver route-scan --refresh');
192
+ process.exit(1);
193
+ }
194
+ }
195
+ const entry = mr.addModelRule({
196
+ signature: routeCandidate.signature,
197
+ tier: routeCandidate.tier || 'T2',
198
+ category: routeCandidate.category,
199
+ label: routeCandidate.label,
200
+ labelEn: routeCandidate.labelEn,
201
+ agent: routeCandidate.agent,
202
+ scope,
203
+ targetRoot,
204
+ project: routeCandidate.project,
205
+ rule: lang === 'ko' ? routeCandidate.rule : (routeCandidate.ruleEn || routeCandidate.rule),
206
+ example: routeCandidate.example,
207
+ count: routeCandidate.count,
208
+ promotedAt: new Date().toISOString().slice(0, 10),
209
+ lastSeen: new Date().toISOString().slice(0, 10),
210
+ });
211
+ const written = mr.syncAllFiles();
212
+ rs.resolveCandidate(routeCandidateId);
213
+ console.log(`Model-fitting rule registered [${scope}${targetRoot ? ` → ${targetRoot}` : ''}] (tier ${entry.tier}):`);
214
+ console.log(` - ${entry.rule}`);
215
+ for (const p of written) console.log(` ratchet-model.md updated: ${p}`);
216
+ console.log(lang === 'ko'
217
+ ? `(route candidate R${routeCandidateId} resolved — 다음 세션부터 자동 위임, 이후 스캔마다 로그 기반 갱신됩니다)`
218
+ : `(route candidate R${routeCandidateId} resolved — delegation applies from the next session, refreshed from logs on every rescan)`);
219
+ console.log(lang === 'ko'
220
+ ? '룰 목록/제거: claude-token-saver route-scan rules [rm <N>]'
221
+ : 'List / remove: claude-token-saver route-scan rules [rm <N>]');
222
+ // Event-triggered refresh: establish the new rule's stat baseline
223
+ // right away instead of waiting for the next data-gated rescan.
224
+ try {
225
+ const { spawn } = await import('node:child_process');
226
+ spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
227
+ { detached: true, stdio: 'ignore' }).unref();
228
+ } catch (e) { debug('promote:spawn-refresh', e); /* baseline arrives on the next gated rescan */ }
229
+ return;
230
+ }
231
+ const r = harnessPromote(rule, { scope });
232
+ console.log(`Appended to ${r.path} [${r.scope}]:`);
233
+ console.log(` - ${rule}`);
234
+ if (/^\d+$/.test(raw)) {
235
+ console.log(lang === 'ko'
236
+ ? '\n👉 ratchet.md를 열어 TODO 부분을 실제 룰로 다듬어주세요.'
237
+ : '\n👉 Open ratchet.md and turn the TODO into the actual rule.');
238
+ }
239
+ return;
240
+ }
241
+
242
+ if (sub === 'analyze') {
243
+ // Run the analyzer once against the most recent session JSONL under
244
+ // ~/.claude/projects/ and dump the resulting state. Useful for users
245
+ // who don't have the hook installed but want to see warnings.
246
+ const analyzer = await import('../harness-analyzer.cjs');
247
+ const { analyzeTranscript, writeState } = analyzer.default || analyzer;
248
+ const { readdirSync, statSync } = await import('node:fs');
249
+ const { join: pj } = await import('node:path');
250
+ const { homedir } = await import('node:os');
251
+ const dir = pj(homedir(), '.claude', 'projects');
252
+ let latest = null;
253
+ let latestMtime = 0;
254
+ try {
255
+ for (const subdir of readdirSync(dir)) {
256
+ const full = pj(dir, subdir);
257
+ if (!statSync(full).isDirectory()) continue;
258
+ for (const f of readdirSync(full)) {
259
+ if (!f.endsWith('.jsonl')) continue;
260
+ const fp = pj(full, f);
261
+ const m = statSync(fp).mtimeMs;
262
+ if (m > latestMtime) { latestMtime = m; latest = fp; }
263
+ }
264
+ }
265
+ } catch (e) { debug('harness:analyze-scan', e); }
266
+ if (!latest) {
267
+ console.error('No session transcripts found under ~/.claude/projects/');
268
+ process.exit(1);
269
+ }
270
+ const state = analyzeTranscript(latest, { cwd: process.cwd() });
271
+ if (state) writeState(state);
272
+ console.log(JSON.stringify(state, null, 2));
273
+ return;
274
+ }
275
+
276
+ if (sub === 'uninit' || sub === 'remove') {
277
+ const scope = parseHarnessScope(args.slice(2), 'project');
278
+ const purgeRatchet = args.includes('--purge-ratchet');
279
+ const r = harnessUninit({ purgeRatchet, scope });
280
+ console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md)' : ` (${r.root})`}`);
281
+ r.removed.forEach((f) => console.log(` removed: ${f}`));
282
+ r.backedUp.forEach((f) => console.log(` backup: ${f}`));
283
+ r.skipped.forEach((f) => console.log(` skip: ${f}`));
284
+ if (r.removed.length === 0) console.log('Nothing to remove.');
285
+ return;
286
+ }
287
+
288
+ if (sub === 'pull') {
289
+ // Register the package's curated preset rules (presets/ratchet-rules.md)
290
+ // into the user's ratchet — global by default (they're tool/environment
291
+ // rules, and a project ratchet inherits global anyway). Strictly opt-in:
292
+ // install/init never auto-injects rules.
293
+ const scope = parseHarnessScope(args.slice(2), 'global');
294
+ const r = harnessPull({ scope });
295
+ console.log(`Curated preset rules → ${r.path} [${r.scope}]`);
296
+ if (r.added.length) {
297
+ console.log(`✅ ${r.added.length}/${r.presets} rule(s) registered:`);
298
+ for (const t of r.added) console.log(` - ${t}`);
299
+ } else {
300
+ console.log(`No new rules — all ${r.presets} presets already registered.`);
301
+ }
302
+ if (r.skippedRules && r.added.length) console.log(` (${r.skippedRules} already present — skipped)`);
303
+ console.log(lang === 'ko'
304
+ ? '\n필요 없는 룰은 언제든: claude-token-saver harness list / rm <N>'
305
+ : '\nDrop any rule you do not want: claude-token-saver harness list / rm <N>');
306
+ return;
307
+ }
308
+
309
+ if (sub === 'list' || sub === 'ls') {
310
+ const wantGlobal = hasFlag('--global');
311
+ const wantProject = hasFlag('--project') || !wantGlobal;
312
+ const print = (scope) => {
313
+ const { path, rules } = harnessListRules({ scope });
314
+ if (!rules.length) {
315
+ console.log(`No ratchet rules in ${path} [${scope}]`);
316
+ return;
317
+ }
318
+ console.log(`📋 Ratchet rules [${scope}] — ${path}\n`);
319
+ for (const r of rules) console.log(` #${r.index} ${r.text}`);
320
+ console.log('');
321
+ };
322
+ if (wantProject) print('project');
323
+ if (wantGlobal) print('global');
324
+ console.log('Remove with: claude-token-saver harness rm [--global|--project] <N>');
325
+ return;
326
+ }
327
+
328
+ if (sub === 'rm') {
329
+ const rmScope = hasFlag('--global') ? 'global' : 'project';
330
+ const rmArgs = args.slice(2).filter((a) => a !== '--global' && a !== '--project');
331
+ const raw = (rmArgs[0] || '').trim();
332
+ if (!/^\d+$/.test(raw)) {
333
+ console.error('Usage: claude-token-saver harness rm [--global|--project] <N> # N from `harness list`');
334
+ process.exit(1);
335
+ }
336
+ const n = parseInt(raw, 10);
337
+ // ⚠️ Heads-up before deletion. Ratchet's value is one-way accumulation —
338
+ // dropping a rule is sometimes right, but more often the rule is just
339
+ // too narrow. Surface the alternative loudly here.
340
+ if (lang === 'ko') {
341
+ console.log('⚠️ 주의: ratchet 룰 삭제는 신중하게.');
342
+ console.log(' 같은 실수가 또 발생할 가능성이 큽니다. 보통은 "조건이 너무 좁아서"');
343
+ console.log(' 문제가 되는 경우가 많아요. 지우기 전에 한 번 더 검토하세요:');
344
+ console.log(' - 룰이 너무 광범위해서 정상 케이스도 막나? → 조건을 좁혀서 다듬기');
345
+ console.log(' - 룰이 너무 좁아서 거의 발동 안 되나? → 그냥 두기 (비용 0)');
346
+ console.log(' - 정말 잘못된 룰이라 확신? → 그때만 삭제');
347
+ } else {
348
+ console.log('⚠️ Careful: removing a ratchet rule is rarely the fix.');
349
+ console.log(' The mistake it guards against tends to come back. Usually the');
350
+ console.log(' problem is that the rule is worded too narrowly. Check first:');
351
+ console.log(' - Too broad, blocking legitimate cases? → tighten the condition');
352
+ console.log(' - Too narrow, almost never fires? → leave it (it costs nothing)');
353
+ console.log(' - Genuinely wrong? → only then delete it');
354
+ }
355
+ console.log('');
356
+ const r = harnessRmRule(n, { scope: rmScope });
357
+ if (!r.ok) {
358
+ console.error(`❌ ${r.error}`);
359
+ if (r.rules) {
360
+ console.error('Available:');
361
+ for (const x of r.rules) console.error(` #${x.index} ${x.text}`);
362
+ }
363
+ process.exit(1);
364
+ }
365
+ console.log(`✅ Removed #${n}: ${r.removed.text}`);
366
+ console.log(` Backup: ${r.backup}`);
367
+ console.log(` 복구: cp "${r.backup}" "${r.path}"`);
368
+ return;
369
+ }
370
+
371
+ if (sub === 'off' || sub === 'on') {
372
+ const cfg = loadConfig();
373
+ cfg.harness = cfg.harness || {};
374
+ cfg.harness.enabled = sub === 'on';
375
+ saveConfig(cfg);
376
+ console.log(`Statusline 🅷 segment: ${sub}`);
377
+ return;
378
+ }
379
+
380
+ 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]');
382
+ process.exit(1);
383
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Subcommand: history — print recent warning transitions captured by the
3
+ * statusline. One markdown file per day, persisted under the platform-
4
+ * specific user-data dir.
5
+ * claude-token-saver history # last 7 days
6
+ * claude-token-saver history --days 30 # custom window
7
+ * claude-token-saver history --list # just list available dates
8
+ */
9
+
10
+
11
+ export async function run({ hasFlag, numArg }) {
12
+ const { readRecent, listDates, historyDir, formatHistoryForLanguage } =
13
+ await import('../history.js');
14
+ const { userLanguage } = await import('../config.js');
15
+ const lang = userLanguage();
16
+ if (hasFlag('--list')) {
17
+ const dates = listDates();
18
+ if (dates.length === 0) {
19
+ console.log(lang === 'ko'
20
+ ? `히스토리가 아직 없습니다. 파일은 다음 위치에 생성됩니다: ${historyDir()}`
21
+ : `No history yet. Files will appear under: ${historyDir()}`);
22
+ return;
23
+ }
24
+ console.log(lang === 'ko' ? `히스토리 (${historyDir()}):` : `History (${historyDir()}):`);
25
+ for (const d of dates) console.log(` ${d}`);
26
+ return;
27
+ }
28
+ const days = numArg('--days', { dflt: 7, min: 0 });
29
+ const recent = readRecent(days);
30
+ if (recent.length === 0) {
31
+ if (lang === 'ko') {
32
+ console.log(`최근 ${days}일 내 경고 히스토리가 없습니다.`);
33
+ console.log(`(파일이 생성될 위치: ${historyDir()})`);
34
+ } else {
35
+ console.log(`No warning history in the last ${days} day${days === 1 ? '' : 's'}.`);
36
+ console.log(`(Files would be written to: ${historyDir()})`);
37
+ }
38
+ return;
39
+ }
40
+ for (const { content } of recent) {
41
+ const filtered = formatHistoryForLanguage(content, lang);
42
+ console.log(filtered.replace(/\n+$/, ''));
43
+ console.log('');
44
+ }
45
+ return;
46
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Subcommand: install — write the Claude Code auto-trigger skill so the
3
+ * user can just mention chip wording and Claude responds. v2.6.0 dropped
4
+ * the redundant /token-monitor slash command in favor of the skill alone;
5
+ * a legacy command file is removed automatically. Cross-platform.
6
+ * claude-token-saver install # install/update the skill
7
+ * claude-token-saver install --force # overwrite existing skill file
8
+ */
9
+
10
+ import { debug } from '../debug.js';
11
+
12
+ export async function run({ hasFlag }) {
13
+ const { installAll } = await import('../installer.js');
14
+ const { userLanguage } = await import('../config.js');
15
+ const lang = userLanguage();
16
+ const force = hasFlag('--force');
17
+ const print = (kind, r) => {
18
+ const verb = r.action === 'exists' ? 'already exists' : r.action;
19
+ console.log(` ${kind}: ${r.path} (${verb})`);
20
+ };
21
+ const r = installAll({ force });
22
+ print('skill', r.skill);
23
+ print('SessionStart hook (route-scan)', r.sessionStartHook);
24
+ print('UserPromptSubmit hook (brief)', r.briefHook);
25
+ {
26
+ const s = r.statusline;
27
+ const verb = s.action === 'exists' ? 'already configured (refreshInterval=5)'
28
+ : s.action === 'skipped' ? `skipped — ${s.reason}`
29
+ : s.reason ? `${s.action} — ${s.reason}`
30
+ : s.action;
31
+ console.log(` statusline: ${s.path} (${verb})`);
32
+ }
33
+ if (r.legacy.action === 'removed') {
34
+ print('legacy /token-monitor', r.legacy);
35
+ console.log(' (consolidated into the skill — same workflow, triggered by intent)');
36
+ }
37
+ // First-time setup: analyze existing session logs right away so the
38
+ // very first session already sees delegation candidates — without this,
39
+ // the initial scan would only start from the first session's hook and
40
+ // its results would surface one session late. Runs inline (a few
41
+ // seconds on a typical 14-day history): a detached child can be reaped
42
+ // by sandboxed installers before it finishes, and postinstall carries
43
+ // `|| true` so a failure here never breaks the install.
44
+ {
45
+ const rs = await import('../route-scan.js');
46
+ if (!rs.readRouteScan()) {
47
+ try {
48
+ console.log('');
49
+ console.log(lang === 'ko'
50
+ ? ' route-scan: 기존 세션 로그의 사용 패턴을 분석하는 중...'
51
+ : ' route-scan: analyzing usage patterns in your existing session logs...');
52
+ const cache = await rs.runRouteScan({ days: 14 });
53
+ console.log(lang === 'ko'
54
+ ? ` route-scan: 에피소드 ${cache.totalEpisodes}건 분석 완료 — 위임 후보 ${cache.candidates.length}건.`
55
+ : ` route-scan: analyzed ${cache.totalEpisodes} episodes — ${cache.candidates.length} delegation candidate(s).`);
56
+ console.log(lang === 'ko'
57
+ ? ' (다음 Claude Code 세션에서 티어 위임 후보가 표시됩니다)'
58
+ : ' (candidates surface in your next Claude Code session)');
59
+ } catch (e) { debug('install:route-scan-seed', e); /* hook-triggered scan covers it on the first session instead */ }
60
+ }
61
+ }
62
+ console.log('');
63
+ console.log('Open Claude Code in any directory and just mention:');
64
+ console.log(' "cache hit rate" / "1M context" / "5H cap" — the skill auto-activates.');
65
+ if (!force) {
66
+ console.log('');
67
+ console.log('Tip: re-run with --force to overwrite the existing skill file.');
68
+ }
69
+ return;
70
+ }