claude-token-saver 3.5.3 → 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/bin/cli.js CHANGED
@@ -27,104 +27,12 @@ import { readFileSync } from 'node:fs';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { dirname, isAbsolute, join } from 'node:path';
29
29
 
30
- /**
31
- * Read the JSON blob Claude Code feeds the statusline command on stdin.
32
- * Returns null when stdin is a TTY or empty (e.g. user invokes `--statusline`
33
- * by hand) so callers can fall back to flag/env config.
34
- *
35
- * The blob shape (subset we consume):
36
- * {
37
- * "transcript_path": "...",
38
- * "rate_limits": {
39
- * "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
40
- * "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
41
- * }
42
- * }
43
- *
44
- * extractCaps treats `rate_limits` as a generic object so any future window
45
- * Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
46
- * changes — known keys get curated labels, unknowns get derived ones.
47
- */
48
- function readStdinJson() {
49
- if (process.stdin.isTTY) return null;
50
- try {
51
- const raw = readFileSync(0, 'utf8');
52
- if (!raw || !raw.trim()) return null;
53
- return JSON.parse(raw);
54
- } catch {
55
- return null;
56
- }
57
- }
58
-
59
- function extractCaps(stdinJson) {
60
- if (!stdinJson || !stdinJson.rate_limits || typeof stdinJson.rate_limits !== 'object') return null;
61
- const windows = [];
62
- for (const [key, value] of Object.entries(stdinJson.rate_limits)) {
63
- if (!value || typeof value !== 'object') continue;
64
- const usedPct = Number(value.used_percentage);
65
- if (!Number.isFinite(usedPct)) continue;
66
- const resetsAt = Number(value.resets_at);
67
- windows.push({
68
- key,
69
- usedPct,
70
- resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
71
- });
72
- }
73
- return windows.length ? { windows } : null;
74
- }
75
-
76
- /**
77
- * Pull the human-friendly model name out of Claude Code's stdin payload.
78
- * `model.display_name` is the contract; fall back to `model.id` when it's
79
- * absent. Returns null when nothing usable is in the JSON.
80
- */
81
- // Bedrock/litellm proxies pass model IDs like
82
- // global.anthropic.claude-opus-4-7-20251001-v1:0
83
- // anthropic.claude-sonnet-4-6-20250930-v1:0
84
- // bedrock/anthropic.claude-haiku-4-5
85
- // while Claude Code's `display_name` collapses these to a generic family
86
- // label ("Opus 4", "Sonnet 4") that hides the actual minor version. Pull the
87
- // version out of the id when we can spot it so the statusline shows the real
88
- // model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
89
- function bedrockDisplayFromId(id) {
90
- if (typeof id !== 'string') return null;
91
- const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)[-_](\d+)/i);
92
- if (!m) return null;
93
- const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
94
- return `${family} ${m[2]}.${m[3]}`;
95
- }
96
-
97
- /**
98
- * Live context usage from Claude Code's stdin payload (`context_window`).
99
- * More accurate than inferring from transcripts: it's the CURRENT session's
100
- * real fill level, updated every refresh. Shape (subset):
101
- * "context_window": { "context_window_size": 200000, "used_percentage": 68 }
102
- */
103
- function extractContextUsage(stdinJson) {
104
- const cw = stdinJson && stdinJson.context_window;
105
- if (!cw || typeof cw !== 'object') return null;
106
- const usedPct = Number(cw.used_percentage);
107
- const size = Number(cw.context_window_size);
108
- if (!Number.isFinite(usedPct)) return null;
109
- return {
110
- usedPct,
111
- size: Number.isFinite(size) && size > 0 ? size : null,
112
- };
113
- }
114
-
115
- function extractModel(stdinJson) {
116
- if (!stdinJson || !stdinJson.model) return null;
117
- const m = stdinJson.model;
118
- if (typeof m === 'string') return bedrockDisplayFromId(m) || m;
119
- // Prefer the id when it carries a precise version (e.g. Bedrock IDs); fall
120
- // back to display_name for the standard Claude Code path where display_name
121
- // already says "Claude Opus 4.7".
122
- const idDerived = bedrockDisplayFromId(m.id);
123
- if (idDerived) return idDerived;
124
- if (typeof m.display_name === 'string' && m.display_name) return m.display_name;
125
- if (typeof m.id === 'string' && m.id) return m.id;
126
- return null;
127
- }
30
+ import {
31
+ readStdinJson,
32
+ extractCaps,
33
+ extractContextUsage,
34
+ extractModel,
35
+ } from '../src/stdin-payload.js';
128
36
 
129
37
  import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
130
38
  import {
@@ -139,6 +47,8 @@ import {
139
47
  } from '../src/stats.js';
140
48
  import { estimateCost } from '../src/cost.js';
141
49
  import { chipForIssues } from '../src/advice.js';
50
+ import { debug } from '../src/debug.js';
51
+ import { createArgs } from '../src/cli-args.js';
142
52
 
143
53
  const args = process.argv.slice(2);
144
54
 
@@ -151,93 +61,7 @@ const PKG_VERSION = (() => {
151
61
  }
152
62
  })();
153
63
 
154
- function getArg(name) {
155
- const idx = args.indexOf(name);
156
- if (idx !== -1) return args[idx + 1];
157
- const prefix = `${name}=`;
158
- const eq = args.find((a) => a.startsWith(prefix));
159
- if (eq) return eq.slice(prefix.length);
160
- return undefined;
161
- }
162
-
163
- function hasFlag(name) {
164
- return args.includes(name);
165
- }
166
-
167
- /**
168
- * Scan recent history file contents (newest day first) and return the most
169
- * recent warning event — `{ time, chip, detail, codes, isCap, capLabel, capPct }`.
170
- * Returns null when no warning is found in the window.
171
- *
172
- * Recognized event lines (from history.js appendDayLine output):
173
- * - HH:MM:SS ⚠ Cache miss — session abc1: LOW_HIT_RATE
174
- * - HH:MM:SS ⚠ A → ⚠ B — detail
175
- * - HH:MM:SS 🚨 5H 94% cap warning (resets in ...)
176
- * - HH:MM:SS ✓ resolved (was ...) ← skip
177
- * - HH:MM:SS ✓ 5H cap warning resolved ← skip
178
- * - HH:MM:SS 📝 handoff written: ... ← skip
179
- */
180
- function findLatestWarning(historyEntries, chipToCodes) {
181
- const warnings = [];
182
- for (const { date, content } of historyEntries) {
183
- const lines = content.split('\n');
184
- for (const line of lines) {
185
- // Skip non-event lines
186
- const m = line.match(/^- (\d{2}:\d{2}:\d{2})\s+(.+)$/);
187
- if (!m) continue;
188
- const time = m[1];
189
- const rest = m[2];
190
- // Skip resolutions and handoff entries
191
- if (rest.startsWith('✓ ') || rest.startsWith('📝 ')) continue;
192
- // Cap-warn line: `🚨 5H 94% cap warning (...)`
193
- const cap = rest.match(/^🚨\s+(\S+)\s+(\d+)%\s+cap warning(?:\s*\((.+)\))?$/);
194
- if (cap) {
195
- warnings.push({
196
- date,
197
- time,
198
- chip: `🚨 ${cap[1]} ${cap[2]}%`,
199
- isCap: true,
200
- capLabel: cap[1],
201
- capPct: parseInt(cap[2], 10),
202
- capReset: cap[3] || null,
203
- codes: [],
204
- detail: null,
205
- });
206
- continue;
207
- }
208
- // Chip line — last token after the chip is `— detail` (optional). The
209
- // chip itself can be a plain `⚠ X` or a `⚠ A → ⚠ B` transition; we want
210
- // the *current* chip (right side of the arrow if present).
211
- const arrowMatch = rest.match(/^(.+?)\s+→\s+(.+?)(?:\s+—\s+(.+))?$/);
212
- let chip;
213
- let detail = null;
214
- if (arrowMatch) {
215
- chip = arrowMatch[2].trim();
216
- detail = arrowMatch[3] || null;
217
- } else {
218
- const plain = rest.match(/^(\S+(?:\s+\S+)*?)(?:\s+—\s+(.+))?$/);
219
- if (!plain) continue;
220
- chip = plain[1].trim();
221
- detail = plain[2] || null;
222
- }
223
- // Resolve codes: detail "session ID: A, B" → codes; else CHIP_TO_CODES.
224
- const codes = [];
225
- if (detail) {
226
- const dm = detail.match(/^session [^:]+:\s*(.+)$/);
227
- if (dm) {
228
- for (const c of dm[1].split(',').map((s) => s.trim()).filter(Boolean)) {
229
- if (!codes.includes(c)) codes.push(c);
230
- }
231
- }
232
- }
233
- if (chipToCodes[chip]) {
234
- for (const c of chipToCodes[chip]) if (!codes.includes(c)) codes.push(c);
235
- }
236
- warnings.push({ date, time, chip, isCap: false, codes, detail });
237
- }
238
- }
239
- return warnings.length ? warnings[warnings.length - 1] : null;
240
- }
64
+ const { getArg, hasFlag, numArg } = createArgs(args);
241
65
 
242
66
  async function main() {
243
67
  // Subcommand: last — print the most recent warning + how to handle it.
@@ -247,70 +71,7 @@ async function main() {
247
71
  // claude-token-saver last # search last 1 day
248
72
  // claude-token-saver last --days 7 # widen the lookback
249
73
  if (args[0] === 'last') {
250
- const { readRecent, historyDir } = await import('../src/history.js');
251
- const { ISSUE_MESSAGES, CHIP_TO_CODES, CAP_TIPS } = await import('../src/advice.js');
252
- const { userLanguage } = await import('../src/config.js');
253
- const lang = userLanguage();
254
- const days = parseFloat(getArg('--days') || '1');
255
- const recent = readRecent(days);
256
- const latest = findLatestWarning(recent, CHIP_TO_CODES);
257
- if (!latest) {
258
- if (lang === 'ko') {
259
- console.log(`최근 ${days}일 내 경고가 없습니다.`);
260
- console.log(`(히스토리 디렉터리: ${historyDir()})`);
261
- } else {
262
- console.log(`No warnings in the last ${days} day${days === 1 ? '' : 's'}.`);
263
- console.log(`(History dir: ${historyDir()})`);
264
- }
265
- return;
266
- }
267
- // Header
268
- console.log(`Most recent warning — ${latest.date} ${latest.time}`);
269
- console.log(` ${latest.chip}${latest.detail ? ` — ${latest.detail}` : ''}`);
270
- console.log('');
271
- // Cap-warn path: handoff is the recommendation. Print the bilingual tip
272
- // and a one-line "how to back up" pointer.
273
- if (latest.isCap) {
274
- if (latest.capReset) console.log(` Cap window: ${latest.capReset}`);
275
- console.log('');
276
- console.log('💡 ' + (lang === 'ko' ? CAP_TIPS.ko : CAP_TIPS.en));
277
- console.log('');
278
- console.log(lang === 'ko' ? '실행:' : 'Run:');
279
- console.log(' claude-token-saver handoff');
280
- return;
281
- }
282
- // Chip warning path: render full ISSUE_MESSAGES advice for each code,
283
- // bilingual (English first, `└ Korean` continuation per line — matches
284
- // the history.md format).
285
- if (latest.codes.length === 0) {
286
- console.log(lang === 'ko'
287
- ? '(진단 코드 없음 — 표 뷰를 열어보세요: `claude-token-saver --days 1`)'
288
- : '(No diagnostic code attached — open the table view: `claude-token-saver --days 1`)');
289
- return;
290
- }
291
- // Pick a single language per field; fall back to EN when KO is missing.
292
- const pick = (en, ko) => (lang === 'ko' && ko ? ko : en);
293
- for (const code of latest.codes) {
294
- const msg = ISSUE_MESSAGES[code];
295
- if (!msg) {
296
- console.log(`Code: ${code} (no advice registered)`);
297
- continue;
298
- }
299
- console.log(`▎ ${pick(msg.title, msg.titleKo)}`);
300
- console.log(` ${pick(msg.explain, msg.explainKo)}`);
301
- const actions = typeof msg.actions === 'function' ? msg.actions() : msg.actions || [];
302
- for (const a of actions) {
303
- console.log('');
304
- console.log(` ${pick(a.label, a.labelKo)}:`);
305
- const cmds = a.commands || [];
306
- const cmdsKo = a.commandsKo || [];
307
- for (let i = 0; i < cmds.length; i++) {
308
- console.log(` - ${pick(cmds[i], cmdsKo[i])}`);
309
- }
310
- }
311
- console.log('');
312
- }
313
- return;
74
+ return (await import('../src/commands/last.js')).run({ numArg });
314
75
  }
315
76
 
316
77
  // Subcommand: history — print recent warning transitions captured by the
@@ -320,40 +81,7 @@ async function main() {
320
81
  // claude-token-saver history --days 30 # custom window
321
82
  // claude-token-saver history --list # just list available dates
322
83
  if (args[0] === 'history') {
323
- const { readRecent, listDates, historyDir, formatHistoryForLanguage } =
324
- await import('../src/history.js');
325
- const { userLanguage } = await import('../src/config.js');
326
- const lang = userLanguage();
327
- if (hasFlag('--list')) {
328
- const dates = listDates();
329
- if (dates.length === 0) {
330
- console.log(lang === 'ko'
331
- ? `히스토리가 아직 없습니다. 파일은 다음 위치에 생성됩니다: ${historyDir()}`
332
- : `No history yet. Files will appear under: ${historyDir()}`);
333
- return;
334
- }
335
- console.log(lang === 'ko' ? `히스토리 (${historyDir()}):` : `History (${historyDir()}):`);
336
- for (const d of dates) console.log(` ${d}`);
337
- return;
338
- }
339
- const days = parseFloat(getArg('--days') || '7');
340
- const recent = readRecent(days);
341
- if (recent.length === 0) {
342
- if (lang === 'ko') {
343
- console.log(`최근 ${days}일 내 경고 히스토리가 없습니다.`);
344
- console.log(`(파일이 생성될 위치: ${historyDir()})`);
345
- } else {
346
- console.log(`No warning history in the last ${days} day${days === 1 ? '' : 's'}.`);
347
- console.log(`(Files would be written to: ${historyDir()})`);
348
- }
349
- return;
350
- }
351
- for (const { content } of recent) {
352
- const filtered = formatHistoryForLanguage(content, lang);
353
- console.log(filtered.replace(/\n+$/, ''));
354
- console.log('');
355
- }
356
- return;
84
+ return (await import('../src/commands/history.js')).run({ hasFlag, numArg });
357
85
  }
358
86
 
359
87
  // Subcommand: handoff — write a HANDOFF-YYYY-MM-DD-HHMM.md template in cwd
@@ -364,24 +92,7 @@ async function main() {
364
92
  // claude-token-saver handoff # write to cwd
365
93
  // claude-token-saver handoff --cwd PATH # custom directory
366
94
  if (args[0] === 'handoff') {
367
- const { writeHandoff } = await import('../src/handoff.js');
368
- const { recordHandoff } = await import('../src/history.js');
369
- const cwd = getArg('--cwd') || process.cwd();
370
- // Cap data only flows in via stdin (Claude Code statusline contract).
371
- // Direct CLI invocations won't have it — that's fine, the template will
372
- // note the gap.
373
- const stdinJson = readStdinJson();
374
- const caps = extractCaps(stdinJson);
375
- const { path, git } = writeHandoff({ cwd, caps });
376
- try { recordHandoff(path); } catch { /* non-critical */ }
377
- console.log(`Handoff written: ${path}`);
378
- if (git) {
379
- console.log(` git: ${git.branch}${git.head ? ` @ ${git.head}` : ''}${git.status ? ' (dirty)' : ' (clean)'}`);
380
- }
381
- console.log('');
382
- console.log('Fill in the empty sections, then start a new Claude Code session with:');
383
- console.log(' Read the most recent HANDOFF-*.md in this directory and continue the work.');
384
- return;
95
+ return (await import('../src/commands/handoff.js')).run({ getArg });
385
96
  }
386
97
 
387
98
  // Subcommand: install — write the Claude Code auto-trigger skill so the
@@ -391,55 +102,7 @@ async function main() {
391
102
  // claude-token-saver install # install/update the skill
392
103
  // claude-token-saver install --force # overwrite existing skill file
393
104
  if (args[0] === 'install') {
394
- const { installAll } = await import('../src/installer.js');
395
- const force = hasFlag('--force');
396
- const print = (kind, r) => {
397
- const verb = r.action === 'exists' ? 'already exists' : r.action;
398
- console.log(` ${kind}: ${r.path} (${verb})`);
399
- };
400
- const r = installAll({ force });
401
- print('skill', r.skill);
402
- print('SessionStart hook (route-scan)', r.sessionStartHook);
403
- print('UserPromptSubmit hook (brief)', r.briefHook);
404
- {
405
- const s = r.statusline;
406
- const verb = s.action === 'exists' ? 'already configured (refreshInterval=5)'
407
- : s.action === 'skipped' ? `skipped — ${s.reason}`
408
- : s.reason ? `${s.action} — ${s.reason}`
409
- : s.action;
410
- console.log(` statusline: ${s.path} (${verb})`);
411
- }
412
- if (r.legacy.action === 'removed') {
413
- print('legacy /token-monitor', r.legacy);
414
- console.log(' (consolidated into the skill — same workflow, triggered by intent)');
415
- }
416
- // First-time setup: analyze existing session logs right away so the
417
- // very first session already sees delegation candidates — without this,
418
- // the initial scan would only start from the first session's hook and
419
- // its results would surface one session late. Runs inline (a few
420
- // seconds on a typical 14-day history): a detached child can be reaped
421
- // by sandboxed installers before it finishes, and postinstall carries
422
- // `|| true` so a failure here never breaks the install.
423
- {
424
- const rs = await import('../src/route-scan.js');
425
- if (!rs.readRouteScan()) {
426
- try {
427
- console.log('');
428
- console.log(' route-scan: 기존 세션 로그의 사용 패턴을 분석하는 중...');
429
- const cache = await rs.runRouteScan({ days: 14 });
430
- console.log(` route-scan: 에피소드 ${cache.totalEpisodes}건 분석 완료 — 위임 후보 ${cache.candidates.length}건.`);
431
- console.log(' (다음 Claude Code 세션에서 티어 위임 후보가 표시됩니다)');
432
- } catch { /* hook-triggered scan covers it on first session instead */ }
433
- }
434
- }
435
- console.log('');
436
- console.log('Open Claude Code in any directory and just mention:');
437
- console.log(' "cache hit rate" / "1M context" / "5H cap" — the skill auto-activates.');
438
- if (!force) {
439
- console.log('');
440
- console.log('Tip: re-run with --force to overwrite the existing skill file.');
441
- }
442
- return;
105
+ return (await import('../src/commands/install.js')).run({ hasFlag });
443
106
  }
444
107
 
445
108
  // Subcommand: mode — persist statusline preferences so future runs pick
@@ -448,40 +111,7 @@ async function main() {
448
111
  // claude-token-saver mode icon verbose # set icon + verbose
449
112
  // claude-token-saver mode reset # clear back to defaults
450
113
  if (args[0] === 'mode') {
451
- const { applyMode, loadConfig, configPath, statuslineDefaults, userLanguage, VALID_KEYWORDS } =
452
- await import('../src/config.js');
453
- const words = args.slice(1);
454
- if (words.length === 0) {
455
- const eff = statuslineDefaults();
456
- const raw = loadConfig();
457
- console.log('Statusline (effective):');
458
- console.log(` icon: ${eff.icon}`);
459
- console.log(` verbose: ${eff.verbose}`);
460
- console.log(` timer: ${eff.timer}`);
461
- console.log(` color: ${eff.color}`);
462
- console.log(` window: ${eff.windowLabel} (${eff.windowHours}h)`);
463
- console.log('');
464
- console.log('Output language (advice / history / last):');
465
- console.log(` language: ${userLanguage()}`);
466
- console.log('');
467
- console.log(`Stored config file (${configPath()}):`);
468
- console.log(` ${Object.keys(raw).length === 0 ? '(none — using defaults)' : JSON.stringify(raw)}`);
469
- console.log('');
470
- console.log('Change with: claude-token-saver mode <keywords...>');
471
- console.log(`Keywords: ${VALID_KEYWORDS.join(', ')}`);
472
- return;
473
- }
474
- const { applied, unknown } = applyMode(words);
475
- if (unknown.length) {
476
- console.error(`Unknown keyword${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
477
- console.error(`Valid: ${VALID_KEYWORDS.join(', ')}`);
478
- process.exit(1);
479
- }
480
- const eff = statuslineDefaults();
481
- console.log(`Updated: ${applied.join(', ')}`);
482
- console.log(`Now: icon=${eff.icon} verbose=${eff.verbose} timer=${eff.timer} color=${eff.color} window=${eff.windowLabel} language=${userLanguage()}`);
483
- console.log('Statusline picks up the change on the next refresh (~1s).');
484
- return;
114
+ return (await import('../src/commands/mode.js')).run({ args });
485
115
  }
486
116
 
487
117
  // Subcommand: route-scan — detect recurring easy work on expensive models
@@ -497,179 +127,11 @@ async function main() {
497
127
  // briefing of state the statusline can only chip (ctx tier crossings,
498
128
  // mid-session route/rule-health changes). Silent when nothing changed.
499
129
  if (args[0] === 'brief') {
500
- if (!hasFlag('--hook')) {
501
- console.error('Usage: claude-token-saver brief --hook (UserPromptSubmit hook mode)');
502
- process.exit(1);
503
- }
504
- const ctx = readStdinJson() || {};
505
- try {
506
- const { runBrief } = await import('../src/brief.js');
507
- const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path });
508
- if (out) console.log(out);
509
- } catch { /* briefing is best-effort — never block a prompt */ }
510
- return;
130
+ return (await import('../src/commands/brief.js')).run({ hasFlag });
511
131
  }
512
132
 
513
133
  if (args[0] === 'route-scan') {
514
- const rs = await import('../src/route-scan.js');
515
- const { userLanguage } = await import('../src/config.js');
516
- const lang = userLanguage();
517
-
518
- // route-scan rules [rm <N>] — the model-fitting rule registry (rules
519
- // promoted from candidates; auto-refreshed from logs on every rescan).
520
- if (args[1] === 'rules') {
521
- const mr = await import('../src/model-rules.js');
522
- if (args[2] === 'rm') {
523
- const n = parseInt(args[3], 10);
524
- const removed = Number.isFinite(n) ? mr.removeModelRule(n) : null;
525
- if (!removed) {
526
- console.error('Usage: claude-token-saver route-scan rules rm <N> # N from `route-scan rules`');
527
- process.exit(1);
528
- }
529
- // A target whose last rule was removed gets its (tool-owned) file deleted.
530
- mr.syncAllFiles({ previousPaths: [mr.modelRatchetPathFor(removed.scope, removed.targetRoot)] });
531
- console.log(`Removed model-fitting rule #${n}: ${removed.rule}`);
532
- return;
533
- }
534
- const { rules } = mr.loadModelRules();
535
- if (rules.length === 0) {
536
- console.log(lang === 'ko' ? '등록된 모델 피팅 룰 없음.' : 'No model-fitting rules registered.');
537
- return;
538
- }
539
- console.log(lang === 'ko' ? '📐 모델 피팅 룰 (로그 기반 자동 갱신):' : '📐 Model-fitting rules (auto-refreshed from logs):');
540
- rules.forEach((r, i) => {
541
- const health = r.status === 'review'
542
- ? (lang === 'ko' ? ' ⚠ 에러율 초과 — 재검토 필요' : ' ⚠ error rate over threshold — needs review')
543
- : '';
544
- const stat = lang === 'ko'
545
- ? `${r.tier} (${rs.tierLabel(r.tier)}) · ${rs.scopeLabel(r.scope)} · 반복 ${r.count || 0}회 · 에러율 ${Math.round((r.errRate || 0) * 100)}%`
546
- : `${r.tier} (${rs.tierLabel(r.tier, 'en')}) · ${rs.scopeLabel(r.scope, 'en')} · seen ×${r.count || 0} · err ${Math.round((r.errRate || 0) * 100)}%`;
547
- console.log(` #${i + 1} ${stat}${health}`);
548
- console.log(` ${r.rule}`);
549
- });
550
- console.log('\n제거: claude-token-saver route-scan rules rm <N>');
551
- return;
552
- }
553
-
554
- if (args[1] === 'dismiss') {
555
- const n = parseInt(args[2], 10);
556
- if (!Number.isFinite(n)) {
557
- console.error('Usage: claude-token-saver route-scan dismiss <N> # N from `route? R<N>`');
558
- process.exit(1);
559
- }
560
- const cand = rs.resolveCandidate(n);
561
- if (!cand) {
562
- console.error(`No route candidate R${n}. Run: claude-token-saver route-scan`);
563
- process.exit(1);
564
- }
565
- console.log(lang === 'ko'
566
- ? `R${n} 무시 처리: ${cand.label} (${cand.project}) — 재스캔에도 다시 뜨지 않습니다.`
567
- : `Dismissed R${n}: ${cand.label} (${cand.project}) — won't resurface on rescans.`);
568
- return;
569
- }
570
-
571
- // --hook: SessionStart hook mode. Never scans inline (session start must
572
- // stay fast) — reads the cache, kicks a detached refresh when stale, and
573
- // prints delegation-candidate context for the new session.
574
- if (hasFlag('--hook')) {
575
- const hookCtx = readStdinJson() || {};
576
- let cache = rs.readRouteScan();
577
- if (await rs.shouldRescan(cache)) {
578
- try {
579
- const { spawn } = await import('node:child_process');
580
- spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
581
- { detached: true, stdio: 'ignore' }).unref();
582
- } catch { /* refresh is best-effort; stale cache still usable below */ }
583
- }
584
- const open = rs.openCandidates(cache);
585
- // Registered rules whose delegated-category error rate crossed the
586
- // health threshold since promotion — the user approved these, so a
587
- // status change must be briefed, not just written into the md file.
588
- let reviewRules = [];
589
- try {
590
- const mr = await import('../src/model-rules.js');
591
- reviewRules = mr.loadModelRules().rules
592
- .map((r, i) => ({ ...r, n: i + 1 }))
593
- .filter((r) => r.status === 'review');
594
- } catch { /* registry unreadable — candidate briefing still goes out */ }
595
- if (open.length === 0 && reviewRules.length === 0) return; // silent — nothing to inject
596
- const lines = [];
597
- if (open.length > 0) {
598
- lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 비싼 모델(opus/fable)이 반복 처리해 온, 더 싼 모델로 넘겨도 되는 작업이 감지되었습니다.`);
599
- lines.push('(R<N>은 후보 번호, T2/T1은 난이도 등급입니다 — 사용자에게 전달할 때는 코드가 아니라 아래 풀어쓴 설명으로 브리핑하세요)');
600
- for (const c of open) {
601
- const tier = c.tier || 'T2';
602
- lines.push(` 후보 R${c.id} — "${c.label}" 유형, ${c.count}회 반복 (프로젝트: ${c.project})`);
603
- lines.push(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 서브에이전트 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
604
- lines.push(` 예시 요청: "${c.example}"`);
605
- lines.push(` 등록 시 ratchet-model.md에 기록될 룰: "${c.rule}"`);
606
- }
607
- lines.push('등록하면 다음 세션부터 자동 위임됩니다. 사용자에게 등록 여부를 물을 때 위 룰 원문을 그대로 보여주고, 적용 범위까지 확인한 뒤 실행하세요:');
608
- lines.push(' claude-token-saver harness promote R<N> --project|--global # 적용 범위는 반드시 사용자에게 확인');
609
- lines.push(' claude-token-saver route-scan dismiss <N> # 사용자가 원치 않으면');
610
- }
611
- if (reviewRules.length > 0) {
612
- lines.push(`[claude-token-saver rule-health] 사용자가 승인한 위임 룰 중, 위임 대상 유형의 최근 에러율이 기준(20%)을 넘어 재검토가 필요한 룰이 있습니다 — 사용자에게 브리핑하고 조건 좁히기/제거를 상의하세요:`);
613
- for (const r of reviewRules) {
614
- lines.push(` 룰 #${r.n} (${r.tier} ${rs.tierLabel(r.tier)} · ${rs.scopeLabel(r.scope)}) — 최근 에러율 ${Math.round((r.errRate || 0) * 100)}%`);
615
- lines.push(` "${r.rule}"`);
616
- }
617
- lines.push(' 제거: claude-token-saver route-scan rules rm <N>');
618
- }
619
- lines.push('위 내용은 사용자의 첫 메시지 주제와 무관하더라도(단순 인사여도) 첫 응답 말미에 `※ [claude-token-saver]` 라벨을 달아 요약 브리핑하세요 — 사용자가 물어볼 때까지 기다리지 말 것.');
620
- console.log(lines.join('\n'));
621
- // Record what was actually briefed so the UserPromptSubmit brief hook
622
- // suppresses exactly these — a candidate landing after this read (e.g.
623
- // the detached rescan above finishing) still gets briefed next prompt.
624
- try {
625
- const { seedSessionBriefed } = await import('../src/brief.js');
626
- seedSessionBriefed(hookCtx.session_id, [
627
- ...open.map((c) => `route|${c.signature}`),
628
- ...reviewRules.map((r) => `health|${r.signature}|${r.scope}`),
629
- ]);
630
- } catch { /* best-effort — worst case is one duplicate brief */ }
631
- return;
632
- }
633
-
634
- const days = parseFloat(getArg('--days') || '14');
635
- let cache = rs.readRouteScan();
636
- if (hasFlag('--refresh') || (cache && cache.days !== days) || await rs.shouldRescan(cache, { days })) {
637
- cache = await rs.runRouteScan({ days });
638
- }
639
- if (hasFlag('--quiet')) return;
640
- if (hasFlag('--json')) {
641
- console.log(JSON.stringify(cache, null, 2));
642
- return;
643
- }
644
- const easyPct = cache.totalEpisodes ? Math.round(cache.easyEpisodes / cache.totalEpisodes * 100) : 0;
645
- console.log(lang === 'ko'
646
- ? `route-scan — 최근 ${cache.days}일: 에피소드 ${cache.totalEpisodes}건 중 easy ${cache.easyEpisodes}건 (${easyPct}%) [스캔: ${cache.scannedAt}]`
647
- : `route-scan — last ${cache.days}d: ${cache.easyEpisodes}/${cache.totalEpisodes} episodes easy (${easyPct}%) [scanned: ${cache.scannedAt}]`);
648
- const open = rs.openCandidates(cache);
649
- if (open.length === 0) {
650
- console.log(lang === 'ko'
651
- ? '위임 후보 없음 (반복 3회 미만이거나 이미 처리됨).'
652
- : 'No delegation candidates (below recurrence threshold or already resolved).');
653
- return;
654
- }
655
- console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1 = difficulty tier):');
656
- for (const c of open) {
657
- const tier = c.tier || 'T2';
658
- if (lang === 'ko') {
659
- console.log(` R${c.id} "${c.label}" ×${c.count}회 [${c.project}]`);
660
- console.log(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
661
- } else {
662
- console.log(` R${c.id} "${c.label}" ×${c.count} [${c.project}]`);
663
- console.log(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to ${c.agent} · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
664
- }
665
- console.log(` ${lang === 'ko' ? '예시' : 'example'}: "${c.example}"`);
666
- console.log(` ${lang === 'ko' ? '룰' : 'rule'}: ${c.rule}`);
667
- }
668
- console.log('');
669
- console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
670
- console.log(' claude-token-saver harness promote R<N> --project|--global');
671
- console.log(' claude-token-saver route-scan dismiss <N>');
672
- return;
134
+ return (await import('../src/commands/route-scan.js')).run({ args, hasFlag, numArg });
673
135
  }
674
136
 
675
137
  // Subcommand: harness — manage the project's CLAUDE.md harness rules.
@@ -680,362 +142,13 @@ async function main() {
680
142
  // claude-token-saver harness pull [--global|--project] # register the package's curated preset rules (default global)
681
143
  // claude-token-saver harness off | on # toggle the statusline 🅷 segment
682
144
  if (args[0] === 'harness') {
683
- const sub = args[1];
684
- // Scope flags for init/uninit/check (same convention as promote/list/rm):
685
- // --global | --project | --scope=global|project | --scope global|project
686
- const parseHarnessScope = (argv, dflt) => {
687
- for (let i = 0; i < argv.length; i++) {
688
- const a = argv[i];
689
- if (a === '--global') return 'global';
690
- if (a === '--project') return 'project';
691
- if (a === '--scope' && (argv[i + 1] === 'global' || argv[i + 1] === 'project')) return argv[i + 1];
692
- if (a.startsWith('--scope=')) {
693
- const v = a.slice('--scope='.length);
694
- if (v === 'global' || v === 'project') return v;
695
- }
696
- }
697
- return dflt;
698
- };
699
- const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, findProjectRoot } =
700
- await import('../src/harness.js');
701
- const { HARNESS_SECTIONS } = await import('../src/harness-templates.js');
702
- const { loadConfig, saveConfig } = await import('../src/config.js');
703
-
704
- if (!sub || sub === 'check') {
705
- const scope = parseHarnessScope(args.slice(2), 'auto'); // auto = project, else global fallback
706
- const root = findProjectRoot();
707
- const s = harnessStatus(root, { scope });
708
- // Only call out "covered by global" when we *fell back* to it (auto), not
709
- // when the user explicitly asked for the global scope.
710
- const via = (scope === 'auto' && s.source === 'global') ? ' (covered by global ~/.claude/CLAUDE.md)' : '';
711
- console.log(`🅷 ${s.configured}/${s.total} — ${s.file}${via}`);
712
- console.log(`CLAUDE.md: ${s.hasFile ? 'present' : 'missing'}` +
713
- (s.hasFile ? `, harness block: ${s.hasBlock ? 'yes' : 'no'}` : '') + ` [${s.source}]`);
714
- if (s.missing.length) {
715
- console.log('Missing sections:');
716
- for (const id of s.missing) {
717
- const sec = HARNESS_SECTIONS.find((x) => x.id === id);
718
- console.log(` - ${id}: ${sec ? sec.heading.replace(/^#+\s*/, '') : ''}`);
719
- }
720
- console.log('\nRun: claude-token-saver harness init (this project)');
721
- console.log(' or: claude-token-saver harness init --global (all projects, ~/.claude/CLAUDE.md)');
722
- } else {
723
- console.log('All 5 harness sections present. ✅');
724
- }
725
- return;
726
- }
727
-
728
- if (sub === 'init') {
729
- const scope = parseHarnessScope(args.slice(2), 'project'); // default project (back-compat)
730
- const force = hasFlag('--force');
731
- const r = harnessInit({ force, scope });
732
- console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md — applies to all projects)' : ` (${r.root})`}`);
733
- for (const p of r.backedUp) console.log(`Backed up: ${p}`);
734
- for (const p of r.wrote) console.log(`Wrote: ${p}`);
735
- for (const p of r.skipped) console.log(`Skipped: ${p}`);
736
- console.log('\n🅷 Harness initialized. Statusline will show 🅷 5/5 on next refresh.');
737
- return;
738
- }
739
-
740
- if (sub === 'promote') {
741
- // Parse scope flags before stripping. Accepts: --global, --project,
742
- // --scope=global|project, --scope global|project
743
- const promoteArgs = args.slice(2);
744
- let scope = null;
745
- const scopeFlags = new Set();
746
- for (let i = 0; i < promoteArgs.length; i++) {
747
- const a = promoteArgs[i];
748
- if (a === '--global') { scope = 'global'; scopeFlags.add(i); }
749
- else if (a === '--project') { scope = 'project'; scopeFlags.add(i); }
750
- else if (a === '--scope' && promoteArgs[i + 1]) {
751
- const v = promoteArgs[i + 1];
752
- if (v !== 'global' && v !== 'project') {
753
- console.error(`Invalid --scope value: ${v} (expected "global" or "project")`);
754
- process.exit(1);
755
- }
756
- scope = v; scopeFlags.add(i); scopeFlags.add(i + 1); i++;
757
- } else if (a.startsWith('--scope=')) {
758
- const v = a.slice('--scope='.length);
759
- if (v !== 'global' && v !== 'project') {
760
- console.error(`Invalid --scope value: ${v} (expected "global" or "project")`);
761
- process.exit(1);
762
- }
763
- scope = v; scopeFlags.add(i);
764
- }
765
- }
766
- const raw = promoteArgs.filter((_, i) => !scopeFlags.has(i)).join(' ').trim();
767
- if (!raw) {
768
- console.error('Usage: claude-token-saver harness promote [--global|--project] <N> # from statusline 🅷⚠ ratchet? #N');
769
- console.error(' or: claude-token-saver harness promote [--global|--project] "<rule text>"');
770
- process.exit(1);
771
- }
772
- let rule = raw;
773
- // Numeric arg → look up candidate #N from analyzer state and turn its
774
- // detected error pattern into a starter ratchet rule. Saves the user
775
- // from retyping the error; they can edit ratchet.md afterward.
776
- if (/^\d+$/.test(raw)) {
777
- const n = parseInt(raw, 10);
778
- const analyzer = await import('../src/harness-analyzer.cjs');
779
- const a = analyzer.default || analyzer;
780
- const state = a.readState();
781
- const list = (state && state.ratchetCandidates) || [];
782
- const cand = list.find((c) => c.id === n);
783
- if (!cand) {
784
- console.error(`No ratchet candidate #${n} in state. Run \`harness analyze\` or wait for the hook to populate it.`);
785
- if (list.length) {
786
- console.error('Available candidates:');
787
- for (const c of list) console.error(` #${c.id} (×${c.count}): ${c.pattern}`);
788
- }
789
- process.exit(1);
790
- }
791
- rule = `반복 감지 ×${cand.count}: ${cand.pattern} — TODO: 원인·예방책 한 줄로`;
792
- }
793
- // R-prefixed arg → route-scan delegation candidate (statusline `route? R<N>`).
794
- // The rule text is pre-generated by the scan; promoting also resolves the
795
- // candidate so the chip stops and rescans don't resurface it.
796
- let routeCandidateId = null;
797
- let routeCandidate = null;
798
- if (/^[Rr]\d+$/.test(raw)) {
799
- const n = parseInt(raw.slice(1), 10);
800
- const rs = await import('../src/route-scan.js');
801
- const cand = (rs.openCandidates(rs.readRouteScan()) || []).find((c) => c.id === n);
802
- if (!cand) {
803
- console.error(`No open route candidate R${n}. Run: claude-token-saver route-scan`);
804
- process.exit(1);
805
- }
806
- rule = cand.rule;
807
- routeCandidateId = n;
808
- routeCandidate = cand;
809
- if (!scope) {
810
- console.error(`Route candidate R${n} requires an explicit scope (suggested: --${cand.suggestedScope}).`);
811
- console.error('Ask the user, then pass --project or --global.');
812
- process.exit(1);
813
- }
814
- }
815
- // Scope resolution: explicit flag wins. Otherwise prompt interactively
816
- // when running on a TTY; in non-TTY (CI/scripts) require an explicit
817
- // flag so the choice is never silently made for the caller.
818
- if (!scope) {
819
- if (process.stdin.isTTY && process.stdout.isTTY) {
820
- const readline = await import('node:readline');
821
- const { homedir: hd } = await import('node:os');
822
- const { findProjectRoot: fpr } = await import('../src/harness.js');
823
- const projPath = `${fpr()}/.claude/ratchet.md`;
824
- const globPath = `${hd()}/.claude/ratchet.md`;
825
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
826
- const ask = (q) => new Promise((res) => rl.question(q, res));
827
- console.log('Where should this rule live?');
828
- console.log(` [1] project (${projPath})`);
829
- console.log(` [2] global (${globPath})`);
830
- const ans = (await ask('Choose [1/2] (default 1): ')).trim();
831
- rl.close();
832
- scope = (ans === '2' || ans.toLowerCase() === 'global' || ans.toLowerCase() === 'g')
833
- ? 'global' : 'project';
834
- } else {
835
- console.error('Scope required in non-interactive mode.');
836
- console.error('Pass --project or --global (or --scope=project|global).');
837
- process.exit(1);
838
- }
839
- }
840
- // Route candidates become MODEL-FITTING rules: they live in a
841
- // tool-managed block (separate from user-authored ratchet rules) and
842
- // keep updating from subsequent logs — recurrence counts, error rates,
843
- // rule-health — on every rescan.
844
- if (routeCandidate) {
845
- const rs = await import('../src/route-scan.js');
846
- const mr = await import('../src/model-rules.js');
847
- // A --project rule must land in THE project the pattern was detected
848
- // in, not the cwd's. Old caches without projectPath: verify cwd match.
849
- let targetRoot = null;
850
- if (scope === 'project') {
851
- if (routeCandidate.projectPath) {
852
- targetRoot = findProjectRoot(routeCandidate.projectPath);
853
- } else if (rs.mungeProjectPath(findProjectRoot()) === routeCandidate.project) {
854
- targetRoot = findProjectRoot();
855
- } else {
856
- console.error(`Route candidate R${routeCandidateId} was detected in another project (${routeCandidate.project}),`);
857
- console.error('but this cached scan predates project-path tracking.');
858
- console.error('Re-scan to capture it, then promote again:');
859
- console.error(' claude-token-saver route-scan --refresh');
860
- process.exit(1);
861
- }
862
- }
863
- const entry = mr.addModelRule({
864
- signature: routeCandidate.signature,
865
- tier: routeCandidate.tier || 'T2',
866
- category: routeCandidate.category,
867
- label: routeCandidate.label,
868
- agent: routeCandidate.agent,
869
- scope,
870
- targetRoot,
871
- project: routeCandidate.project,
872
- rule: routeCandidate.rule,
873
- example: routeCandidate.example,
874
- count: routeCandidate.count,
875
- promotedAt: new Date().toISOString().slice(0, 10),
876
- lastSeen: new Date().toISOString().slice(0, 10),
877
- });
878
- const written = mr.syncAllFiles();
879
- rs.resolveCandidate(routeCandidateId);
880
- console.log(`Model-fitting rule registered [${scope}${targetRoot ? ` → ${targetRoot}` : ''}] (tier ${entry.tier}):`);
881
- console.log(` - ${entry.rule}`);
882
- for (const p of written) console.log(` ratchet-model.md updated: ${p}`);
883
- console.log('(route candidate R' + routeCandidateId + ' resolved — 다음 세션부터 자동 위임, 이후 스캔마다 로그 기반 갱신됩니다)');
884
- console.log('룰 목록/제거: claude-token-saver route-scan rules [rm <N>]');
885
- // Event-triggered refresh: establish the new rule's stat baseline
886
- // right away instead of waiting for the next data-gated rescan.
887
- try {
888
- const { spawn } = await import('node:child_process');
889
- spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
890
- { detached: true, stdio: 'ignore' }).unref();
891
- } catch { /* baseline arrives on the next gated rescan instead */ }
892
- return;
893
- }
894
- const r = harnessPromote(rule, { scope });
895
- console.log(`Appended to ${r.path} [${r.scope}]:`);
896
- console.log(` - ${rule}`);
897
- if (/^\d+$/.test(raw)) {
898
- console.log('\n👉 ratchet.md를 열어 TODO 부분을 실제 룰로 다듬어주세요.');
899
- }
900
- return;
901
- }
902
-
903
- if (sub === 'analyze') {
904
- // Run the analyzer once against the most recent session JSONL under
905
- // ~/.claude/projects/ and dump the resulting state. Useful for users
906
- // who don't have the hook installed but want to see warnings.
907
- const analyzer = await import('../src/harness-analyzer.cjs');
908
- const { analyzeTranscript, writeState } = analyzer.default || analyzer;
909
- const { readdirSync, statSync } = await import('node:fs');
910
- const { join: pj } = await import('node:path');
911
- const { homedir } = await import('node:os');
912
- const dir = pj(homedir(), '.claude', 'projects');
913
- let latest = null;
914
- let latestMtime = 0;
915
- try {
916
- for (const subdir of readdirSync(dir)) {
917
- const full = pj(dir, subdir);
918
- if (!statSync(full).isDirectory()) continue;
919
- for (const f of readdirSync(full)) {
920
- if (!f.endsWith('.jsonl')) continue;
921
- const fp = pj(full, f);
922
- const m = statSync(fp).mtimeMs;
923
- if (m > latestMtime) { latestMtime = m; latest = fp; }
924
- }
925
- }
926
- } catch {}
927
- if (!latest) {
928
- console.error('No session transcripts found under ~/.claude/projects/');
929
- process.exit(1);
930
- }
931
- const state = analyzeTranscript(latest, { cwd: process.cwd() });
932
- if (state) writeState(state);
933
- console.log(JSON.stringify(state, null, 2));
934
- return;
935
- }
936
-
937
- if (sub === 'uninit' || sub === 'remove') {
938
- const scope = parseHarnessScope(args.slice(2), 'project');
939
- const purgeRatchet = args.includes('--purge-ratchet');
940
- const r = harnessUninit({ purgeRatchet, scope });
941
- console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md)' : ` (${r.root})`}`);
942
- r.removed.forEach((f) => console.log(` removed: ${f}`));
943
- r.backedUp.forEach((f) => console.log(` backup: ${f}`));
944
- r.skipped.forEach((f) => console.log(` skip: ${f}`));
945
- if (r.removed.length === 0) console.log('Nothing to remove.');
946
- return;
947
- }
948
-
949
- if (sub === 'pull') {
950
- // Register the package's curated preset rules (presets/ratchet-rules.md)
951
- // into the user's ratchet — global by default (they're tool/environment
952
- // rules, and a project ratchet inherits global anyway). Strictly opt-in:
953
- // install/init never auto-injects rules.
954
- const scope = parseHarnessScope(args.slice(2), 'global');
955
- const r = harnessPull({ scope });
956
- console.log(`Curated preset rules → ${r.path} [${r.scope}]`);
957
- if (r.added.length) {
958
- console.log(`✅ ${r.added.length}/${r.presets} rule(s) registered:`);
959
- for (const t of r.added) console.log(` - ${t}`);
960
- } else {
961
- console.log(`No new rules — all ${r.presets} presets already registered.`);
962
- }
963
- if (r.skippedRules && r.added.length) console.log(` (${r.skippedRules} already present — skipped)`);
964
- console.log('\n필요 없는 룰은 언제든: claude-token-saver harness list / rm <N>');
965
- return;
966
- }
967
-
968
- if (sub === 'list' || sub === 'ls') {
969
- const wantGlobal = hasFlag('--global');
970
- const wantProject = hasFlag('--project') || !wantGlobal;
971
- const print = (scope) => {
972
- const { path, rules } = harnessListRules({ scope });
973
- if (!rules.length) {
974
- console.log(`No ratchet rules in ${path} [${scope}]`);
975
- return;
976
- }
977
- console.log(`📋 Ratchet rules [${scope}] — ${path}\n`);
978
- for (const r of rules) console.log(` #${r.index} ${r.text}`);
979
- console.log('');
980
- };
981
- if (wantProject) print('project');
982
- if (wantGlobal) print('global');
983
- console.log('Remove with: claude-token-saver harness rm [--global|--project] <N>');
984
- return;
985
- }
986
-
987
- if (sub === 'rm') {
988
- const rmScope = hasFlag('--global') ? 'global' : 'project';
989
- const rmArgs = args.slice(2).filter((a) => a !== '--global' && a !== '--project');
990
- const raw = (rmArgs[0] || '').trim();
991
- if (!/^\d+$/.test(raw)) {
992
- console.error('Usage: claude-token-saver harness rm [--global|--project] <N> # N from `harness list`');
993
- process.exit(1);
994
- }
995
- const n = parseInt(raw, 10);
996
- // ⚠️ Heads-up before deletion. Ratchet's value is one-way accumulation —
997
- // dropping a rule is sometimes right, but more often the rule is just
998
- // too narrow. Surface the alternative loudly here.
999
- console.log('⚠️ 주의: ratchet 룰 삭제는 신중하게.');
1000
- console.log(' 같은 실수가 또 발생할 가능성이 큽니다. 보통은 "조건이 너무 좁아서"');
1001
- console.log(' 문제가 되는 경우가 많아요. 지우기 전에 한 번 더 검토하세요:');
1002
- console.log(' - 룰이 너무 광범위해서 정상 케이스도 막나? → 조건을 좁혀서 다듬기');
1003
- console.log(' - 룰이 너무 좁아서 거의 발동 안 되나? → 그냥 두기 (비용 0)');
1004
- console.log(' - 정말 잘못된 룰이라 확신? → 그때만 삭제');
1005
- console.log('');
1006
- const r = harnessRmRule(n, { scope: rmScope });
1007
- if (!r.ok) {
1008
- console.error(`❌ ${r.error}`);
1009
- if (r.rules) {
1010
- console.error('Available:');
1011
- for (const x of r.rules) console.error(` #${x.index} ${x.text}`);
1012
- }
1013
- process.exit(1);
1014
- }
1015
- console.log(`✅ Removed #${n}: ${r.removed.text}`);
1016
- console.log(` Backup: ${r.backup}`);
1017
- console.log(` 복구: cp "${r.backup}" "${r.path}"`);
1018
- return;
1019
- }
1020
-
1021
- if (sub === 'off' || sub === 'on') {
1022
- const cfg = loadConfig();
1023
- cfg.harness = cfg.harness || {};
1024
- cfg.harness.enabled = sub === 'on';
1025
- saveConfig(cfg);
1026
- console.log(`Statusline 🅷 segment: ${sub}`);
1027
- return;
1028
- }
1029
-
1030
- console.error(`Unknown harness subcommand: ${sub}`);
1031
- console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|pull [--global|--project]|list|rm <N>|off|on]');
1032
- process.exit(1);
145
+ return (await import('../src/commands/harness.js')).run({ args, hasFlag });
1033
146
  }
1034
147
 
1035
148
  // Hook management
1036
149
  if (hasFlag('--install-hook')) {
1037
150
  const { installHook } = await import('../src/hook-manager.js');
1038
- const threshold = parseFloat(getArg('--threshold') || '0.7');
151
+ const threshold = numArg('--threshold', { dflt: 0.7, min: 0, max: 1 });
1039
152
  await installHook({ threshold });
1040
153
  return;
1041
154
  }
@@ -1074,7 +187,7 @@ async function main() {
1074
187
  const { buildScenarioData, listScenarios } = await import('../src/demo.js');
1075
188
  const { statuslineDefaults } = await import('../src/config.js');
1076
189
  const cfg = statuslineDefaults();
1077
- const cycleSeconds = parseFloat(getArg('--demo-cycle-sec') || '3');
190
+ const cycleSeconds = numArg('--demo-cycle-sec', { dflt: 3, min: 0.1 });
1078
191
  const data = buildScenarioData(demoArg, {
1079
192
  cycleSeconds,
1080
193
  windowHours: cfg.windowHours,
@@ -1133,14 +246,14 @@ async function main() {
1133
246
  windowLabel = d.windowLabel;
1134
247
  }
1135
248
  // CLI overrides: --hours wins over --days; both win over config.
1136
- const hoursArg = getArg('--hours');
1137
- const daysArg = getArg('--days') || getArg('-d');
249
+ const hoursArg = numArg('--hours', { min: 0 });
250
+ const daysArg = numArg('--days', { min: 0 }) ?? numArg('-d', { min: 0 });
1138
251
  if (hoursArg !== undefined) {
1139
- windowHours = parseFloat(hoursArg);
252
+ windowHours = hoursArg;
1140
253
  windowLabel = `${windowHours}h`;
1141
254
  } else if (daysArg !== undefined) {
1142
- windowHours = parseFloat(daysArg) * 24;
1143
- windowLabel = `${parseFloat(daysArg)}d`;
255
+ windowHours = daysArg * 24;
256
+ windowLabel = `${daysArg}d`;
1144
257
  }
1145
258
  const days = windowHours / 24;
1146
259
  const format = isStatusline ? 'statusline' : (getArg('--format') || getArg('-f') || 'table');
@@ -1181,7 +294,7 @@ async function main() {
1181
294
  try {
1182
295
  const { persistSnapshot } = await import('../src/caps-cache.js');
1183
296
  persistSnapshot({ caps, model });
1184
- } catch { /* non-critical */ }
297
+ } catch (e) { debug('caps-cache:persist', e); }
1185
298
  }
1186
299
  console.log(formatNoSession(
1187
300
  { caps, model, windowLabel },
@@ -1223,8 +336,8 @@ async function main() {
1223
336
  try {
1224
337
  const { persistSnapshot } = await import('../src/caps-cache.js');
1225
338
  persistSnapshot({ caps, model });
1226
- } catch {
1227
- // non-critical
339
+ } catch (e) {
340
+ debug('caps-cache:persist', e);
1228
341
  }
1229
342
  }
1230
343
  if (!isStatusline && (!caps || !model)) {
@@ -1235,8 +348,8 @@ async function main() {
1235
348
  if (!caps && snap.caps) caps = snap.caps;
1236
349
  if (!model && snap.model) model = snap.model;
1237
350
  }
1238
- } catch {
1239
- // ignore
351
+ } catch (e) {
352
+ debug('caps-cache:load', e);
1240
353
  }
1241
354
  }
1242
355
 
@@ -1277,8 +390,8 @@ async function main() {
1277
390
  if (caps && Array.isArray(caps.windows)) {
1278
391
  for (const win of caps.windows) recordCapTransition(win);
1279
392
  }
1280
- } catch {
1281
- // history is non-critical — don't let it break the statusline render
393
+ } catch (e) {
394
+ debug('history:record', e); // never let history break the statusline render
1282
395
  }
1283
396
  }
1284
397
 
@@ -1303,8 +416,8 @@ async function main() {
1303
416
  try {
1304
417
  const t = await getLastUserMessageTime(excludeSessionPath);
1305
418
  if (t) currentSessionLastUser = t.getTime();
1306
- } catch {
1307
- // ignore — keep 0 so it doesn't raise the max
419
+ } catch (e) {
420
+ debug('parser:last-user-message', e); // keep 0 so it doesn't raise the max
1308
421
  }
1309
422
  }
1310
423
  const lastActivity = Math.max(otherLastActivity, currentSessionLastUser);
@@ -1385,7 +498,11 @@ main().catch((err) => {
1385
498
  const colorOk = !process.argv.includes('--no-color') && !process.env.NO_COLOR;
1386
499
  const red = colorOk ? '\x1b[31m' : '';
1387
500
  const reset = colorOk ? '\x1b[0m' : '';
1388
- console.log(`${red}🧠 error${reset}`);
501
+ // Still exactly one line, but name the problem — a bad flag in a
502
+ // statusline wrapper is otherwise invisible ("🧠 error" for a typo the
503
+ // user cannot see the source of). Collapse newlines and cap the length.
504
+ const msg = String(err?.message || 'error').split('\n')[0].slice(0, 80);
505
+ console.log(`${red}🧠 ${msg}${reset}`);
1389
506
  process.exit(0);
1390
507
  }
1391
508
  console.error('Error:', err.message);