sprag-cli 3.40.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Subcommand: last — print the most recent warning + how to handle it.
3
+ * Designed for the auto-trigger skill so the user immediately sees
4
+ * "what just fired and how to fix it" without having to read the whole
5
+ * history file.
6
+ * claude-token-saver last # search last 1 day
7
+ * claude-token-saver last --days 7 # widen the lookback
8
+ */
9
+
10
+
11
+ /**
12
+ * Scan recent history file contents (newest day first) and return the most
13
+ * recent warning event — `{ time, chip, detail, codes, isCap, capLabel, capPct }`.
14
+ * Returns null when no warning is found in the window.
15
+ *
16
+ * Recognized event lines (from history.js appendDayLine output):
17
+ * - HH:MM:SS ⚠ Cache miss — session abc1: LOW_HIT_RATE
18
+ * - HH:MM:SS ⚠ A → ⚠ B — detail
19
+ * - HH:MM:SS 🚨 5H 94% cap warning (resets in ...)
20
+ * - HH:MM:SS ✓ resolved (was ...) ← skip
21
+ * - HH:MM:SS ✓ 5H cap warning resolved ← skip
22
+ * - HH:MM:SS 📝 handoff written: ... ← skip
23
+ */
24
+ function findLatestWarning(historyEntries, chipToCodes) {
25
+ const warnings = [];
26
+ for (const { date, content } of historyEntries) {
27
+ const lines = content.split('\n');
28
+ for (const line of lines) {
29
+ // Skip non-event lines
30
+ const m = line.match(/^- (\d{2}:\d{2}:\d{2})\s+(.+)$/);
31
+ if (!m) continue;
32
+ const time = m[1];
33
+ const rest = m[2];
34
+ // Skip resolutions and handoff entries
35
+ if (rest.startsWith('✓ ') || rest.startsWith('📝 ')) continue;
36
+ // Cap-warn line: `🚨 5H 94% cap warning (...)`
37
+ const cap = rest.match(/^🚨\s+(\S+)\s+(\d+)%\s+cap warning(?:\s*\((.+)\))?$/);
38
+ if (cap) {
39
+ warnings.push({
40
+ date,
41
+ time,
42
+ chip: `🚨 ${cap[1]} ${cap[2]}%`,
43
+ isCap: true,
44
+ capLabel: cap[1],
45
+ capPct: parseInt(cap[2], 10),
46
+ capReset: cap[3] || null,
47
+ codes: [],
48
+ detail: null,
49
+ });
50
+ continue;
51
+ }
52
+ // Chip line — last token after the chip is `— detail` (optional). The
53
+ // chip itself can be a plain `⚠ X` or a `⚠ A → ⚠ B` transition; we want
54
+ // the *current* chip (right side of the arrow if present).
55
+ const arrowMatch = rest.match(/^(.+?)\s+→\s+(.+?)(?:\s+—\s+(.+))?$/);
56
+ let chip;
57
+ let detail = null;
58
+ if (arrowMatch) {
59
+ chip = arrowMatch[2].trim();
60
+ detail = arrowMatch[3] || null;
61
+ } else {
62
+ const plain = rest.match(/^(\S+(?:\s+\S+)*?)(?:\s+—\s+(.+))?$/);
63
+ if (!plain) continue;
64
+ chip = plain[1].trim();
65
+ detail = plain[2] || null;
66
+ }
67
+ // Resolve codes: detail "session ID: A, B" → codes; else CHIP_TO_CODES.
68
+ const codes = [];
69
+ if (detail) {
70
+ const dm = detail.match(/^session [^:]+:\s*(.+)$/);
71
+ if (dm) {
72
+ for (const c of dm[1].split(',').map((s) => s.trim()).filter(Boolean)) {
73
+ if (!codes.includes(c)) codes.push(c);
74
+ }
75
+ }
76
+ }
77
+ if (chipToCodes[chip]) {
78
+ for (const c of chipToCodes[chip]) if (!codes.includes(c)) codes.push(c);
79
+ }
80
+ warnings.push({ date, time, chip, isCap: false, codes, detail });
81
+ }
82
+ }
83
+ return warnings.length ? warnings[warnings.length - 1] : null;
84
+ }
85
+
86
+ export async function run({ numArg }) {
87
+ const { readRecent, historyDir } = await import('../history.js');
88
+ const { ISSUE_MESSAGES, CHIP_TO_CODES, CAP_TIPS } = await import('../advice.js');
89
+ const { userLanguage } = await import('../config.js');
90
+ const lang = userLanguage();
91
+ const days = numArg('--days', { dflt: 1, min: 0 });
92
+ const recent = readRecent(days);
93
+ const latest = findLatestWarning(recent, CHIP_TO_CODES);
94
+ if (!latest) {
95
+ if (lang === 'ko') {
96
+ console.log(`최근 ${days}일 내 경고가 없습니다.`);
97
+ console.log(`(히스토리 디렉터리: ${historyDir()})`);
98
+ } else {
99
+ console.log(`No warnings in the last ${days} day${days === 1 ? '' : 's'}.`);
100
+ console.log(`(History dir: ${historyDir()})`);
101
+ }
102
+ return;
103
+ }
104
+ // Header
105
+ console.log(`Most recent warning — ${latest.date} ${latest.time}`);
106
+ console.log(` ${latest.chip}${latest.detail ? ` — ${latest.detail}` : ''}`);
107
+ console.log('');
108
+ // Cap-warn path: handoff is the recommendation. Print the bilingual tip
109
+ // and a one-line "how to back up" pointer.
110
+ if (latest.isCap) {
111
+ if (latest.capReset) console.log(` Cap window: ${latest.capReset}`);
112
+ console.log('');
113
+ console.log('💡 ' + (lang === 'ko' ? CAP_TIPS.ko : CAP_TIPS.en));
114
+ console.log('');
115
+ console.log(lang === 'ko' ? '실행:' : 'Run:');
116
+ console.log(' claude-token-saver handoff');
117
+ return;
118
+ }
119
+ // Chip warning path: render full ISSUE_MESSAGES advice for each code,
120
+ // bilingual (English first, `└ Korean` continuation per line — matches
121
+ // the history.md format).
122
+ if (latest.codes.length === 0) {
123
+ console.log(lang === 'ko'
124
+ ? '(진단 코드 없음 — 표 뷰를 열어보세요: `claude-token-saver --days 1`)'
125
+ : '(No diagnostic code attached — open the table view: `claude-token-saver --days 1`)');
126
+ return;
127
+ }
128
+ // Pick a single language per field; fall back to EN when KO is missing.
129
+ const pick = (en, ko) => (lang === 'ko' && ko ? ko : en);
130
+ for (const code of latest.codes) {
131
+ const msg = ISSUE_MESSAGES[code];
132
+ if (!msg) {
133
+ console.log(`Code: ${code} (no advice registered)`);
134
+ continue;
135
+ }
136
+ console.log(`▎ ${pick(msg.title, msg.titleKo)}`);
137
+ console.log(` ${pick(msg.explain, msg.explainKo)}`);
138
+ const actions = typeof msg.actions === 'function' ? msg.actions() : msg.actions || [];
139
+ for (const a of actions) {
140
+ console.log('');
141
+ console.log(` ${pick(a.label, a.labelKo)}:`);
142
+ const cmds = a.commands || [];
143
+ const cmdsKo = a.commandsKo || [];
144
+ for (let i = 0; i < cmds.length; i++) {
145
+ console.log(` - ${pick(cmds[i], cmdsKo[i])}`);
146
+ }
147
+ }
148
+ console.log('');
149
+ }
150
+ return;
151
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Subcommand: mode — persist statusline preferences so future runs pick
3
+ * them up without flags or wrapper edits.
4
+ * claude-token-saver mode # show current config
5
+ * claude-token-saver mode icon verbose # set icon + verbose
6
+ * claude-token-saver mode reset # clear back to defaults
7
+ */
8
+
9
+
10
+ export async function run({ args }) {
11
+ const { applyMode, loadConfig, configPath, statuslineDefaults, userLanguage, VALID_KEYWORDS } =
12
+ await import('../config.js');
13
+ const words = args.slice(1);
14
+ if (words.length === 0) {
15
+ const eff = statuslineDefaults();
16
+ const raw = loadConfig();
17
+ console.log('Statusline (effective):');
18
+ console.log(` icon: ${eff.icon}`);
19
+ console.log(` verbose: ${eff.verbose}`);
20
+ console.log(` timer: ${eff.timer}`);
21
+ console.log(` color: ${eff.color}`);
22
+ console.log(` window: ${eff.windowLabel} (${eff.windowHours}h)`);
23
+ console.log(` ttl: ${eff.ttlBucket}${eff.ttlBucket === 'auto' ? ' (measured split, else gateway detection)' : ' (pinned)'}`);
24
+ console.log('');
25
+ console.log('Output language (advice / history / last):');
26
+ console.log(` language: ${userLanguage()}`);
27
+ console.log('');
28
+ console.log(`Stored config file (${configPath()}):`);
29
+ console.log(` ${Object.keys(raw).length === 0 ? '(none — using defaults)' : JSON.stringify(raw)}`);
30
+ console.log('');
31
+ console.log('Change with: claude-token-saver mode <keywords...>');
32
+ console.log(`Keywords: ${VALID_KEYWORDS.join(', ')}`);
33
+ return;
34
+ }
35
+ const { applied, unknown } = applyMode(words);
36
+ if (unknown.length) {
37
+ console.error(`Unknown keyword${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
38
+ console.error(`Valid: ${VALID_KEYWORDS.join(', ')}`);
39
+ process.exit(1);
40
+ }
41
+ const eff = statuslineDefaults();
42
+ console.log(`Updated: ${applied.join(', ')}`);
43
+ console.log(`Now: icon=${eff.icon} verbose=${eff.verbose} timer=${eff.timer} color=${eff.color} window=${eff.windowLabel} language=${userLanguage()}`);
44
+ console.log('Statusline picks up the change on the next refresh (~1s).');
45
+ return;
46
+ }
@@ -0,0 +1,454 @@
1
+ /**
2
+
3
+ */
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, join } from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ import { readStdinJson } from '../stdin-payload.js';
12
+ import { debug } from '../debug.js';
13
+
14
+ /**
15
+ * Whether the user turned doc2md on.
16
+ *
17
+ * Read from settings.json rather than from a config flag because the hook
18
+ * registration *is* the switch: a note telling the model how to convert
19
+ * documents is noise in a session where nothing will convert them.
20
+ */
21
+ function doc2mdHookRegistered() {
22
+ try {
23
+ const { homedir } = require('node:os');
24
+ const settings = JSON.parse(readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'));
25
+ return Object.values(settings?.hooks || {}).some((matchers) =>
26
+ (matchers || []).some((m) =>
27
+ (m.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes('doc2md --hook')),
28
+ ),
29
+ );
30
+ } catch (e) {
31
+ debug('route-scan:doc2md-registered', e);
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /** This package's own version — the baseline the upgrade offer compares against. */
37
+ function readPackageVersion() {
38
+ try {
39
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
40
+ return JSON.parse(readFileSync(pkg, 'utf8')).version || null;
41
+ } catch (e) {
42
+ debug('route-scan:pkg-version', e);
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export async function run({ args, hasFlag, numArg }) {
48
+ const rs = await import('../route-scan.js');
49
+ const { userLanguage } = await import('../config.js');
50
+ const lang = userLanguage();
51
+
52
+ // route-scan savings — the delegation ledger behind the statusline's
53
+ // "Routing saved" headline. The headline is one number; this is the
54
+ // evidence for it: which rule fired, which model the work moved off, and
55
+ // which model actually ran it.
56
+ if (args[1] === 'savings') {
57
+ const { loadLedger, delegationSavedTotals } = await import('../savings-ledger.js');
58
+ const events = Object.entries(loadLedger().events)
59
+ .map(([key, e]) => ({ key, ...e }))
60
+ .sort((a, b) => b.ts - a.ts);
61
+ if (events.length === 0) {
62
+ console.log(lang === 'ko'
63
+ ? '기록된 라우팅 절감 없음. 승격된 룰이 실제로 위임을 일으킨 뒤 `route-scan --refresh` 를 돌리면 채워집니다.'
64
+ : 'No routing savings recorded yet. Promote a rule, let it delegate, then run `route-scan --refresh`.');
65
+ return;
66
+ }
67
+ const t = delegationSavedTotals();
68
+ const money = (v) => `$${v.toFixed(2)}`;
69
+ // Lifetime leads (it is what the breakdown below adds up to); the
70
+ // rolling windows follow as context rather than as competing headlines.
71
+ console.log(lang === 'ko'
72
+ ? `🔀 라우팅 절감 누적 ${money(t.total)} (최근 7일 ${money(t.week)} · 30일 ${money(t.month)})`
73
+ : `🔀 Routing saved, lifetime ${money(t.total)} (last 7d ${money(t.week)} · 30d ${money(t.month)})`);
74
+
75
+ // Per model-pair rollup first: the "what moved where" question is what
76
+ // this view exists to answer, and it is easier to read than the log.
77
+ const pairs = new Map();
78
+ for (const e of events) {
79
+ const k = `${e.from || '?'} → ${e.to || '?'}`;
80
+ const p = pairs.get(k) || { runs: 0, usd: 0 };
81
+ p.runs += 1;
82
+ p.usd += Number(e.usd) || 0;
83
+ pairs.set(k, p);
84
+ }
85
+ console.log('');
86
+ console.log(lang === 'ko' ? '모델 이동별:' : 'By model change:');
87
+ for (const [k, p] of [...pairs].sort((a, b) => b[1].usd - a[1].usd)) {
88
+ const runs = lang === 'ko' ? `${p.runs}회` : `${p.runs} run${p.runs === 1 ? '' : 's'}`;
89
+ console.log(` ${k} — ${runs}, ${money(p.usd)}`);
90
+ }
91
+
92
+ console.log('');
93
+ console.log(lang === 'ko' ? '실행별 (최근순):' : 'By run (newest first):');
94
+ for (const e of events) {
95
+ const when = new Date(e.ts).toISOString().slice(0, 10);
96
+ console.log(` ${when} ${money(Number(e.usd) || 0).padStart(7)} ${e.from || '?'} → ${e.to || '?'}`);
97
+ console.log(` ${lang === 'ko' ? '룰' : 'rule'}: ${e.rule || '(unattributed)'}`);
98
+ }
99
+ console.log('');
100
+ console.log(lang === 'ko'
101
+ ? '금액은 "룰 승격 전 그 유형을 처리하던 모델"과 실제 실행 모델의 가격 차이입니다 (토큰 수는 고정 가정).'
102
+ : 'Each amount is the price gap between the model that handled this category before the rule and the model that actually ran it (token counts held constant).');
103
+ return;
104
+ }
105
+
106
+ // route-scan rules [rm <N>] — the model-fitting rule registry (rules
107
+ // promoted from candidates; auto-refreshed from logs on every rescan).
108
+ if (args[1] === 'rules') {
109
+ const mr = await import('../model-rules.js');
110
+ if (args[2] === 'rm') {
111
+ const n = parseInt(args[3], 10);
112
+ const removed = Number.isFinite(n) ? mr.removeModelRule(n) : null;
113
+ if (!removed) {
114
+ console.error('Usage: claude-token-saver route-scan rules rm <N> # N from `route-scan rules`');
115
+ process.exit(1);
116
+ }
117
+ // A target whose last rule was removed gets its (tool-owned) file deleted.
118
+ mr.syncAllFiles({ previousPaths: [mr.modelRatchetPathFor(removed.scope, removed.targetRoot)] });
119
+ console.log(`Removed model-fitting rule #${n}: ${removed.rule}`);
120
+ return;
121
+ }
122
+ const { rules } = mr.loadModelRules();
123
+ if (rules.length === 0) {
124
+ console.log(lang === 'ko' ? '등록된 모델 피팅 룰 없음.' : 'No model-fitting rules registered.');
125
+ return;
126
+ }
127
+ console.log(lang === 'ko' ? '📐 모델 피팅 룰 (로그 기반 자동 갱신):' : '📐 Model-fitting rules (auto-refreshed from logs):');
128
+ // A T1 rule delegates to sonnet, so in a session whose own model is
129
+ // already sonnet it can never save anything. That is correct behaviour,
130
+ // but listed as "measured delegations — (none yet)" it reads as a
131
+ // promise of savings that will never arrive.
132
+ const { aliasForRole } = await import('../model-alias.js');
133
+ const { modelRank } = await import('../cost.js');
134
+ const sessionModel = aliasForRole('main');
135
+ const sessionRank = sessionModel ? modelRank(sessionModel) : null;
136
+ rules.forEach((r, i) => {
137
+ const health = r.status === 'review'
138
+ ? (lang === 'ko' ? ' ⚠ 에러율 초과 — 재검토 필요' : ' ⚠ error rate over threshold — needs review')
139
+ : '';
140
+ // A seeded rule has no recurrence of its own until a scan measures one,
141
+ // so "반복 0회 / seen ×0" would misreport a curated preset as work that
142
+ // never happens.
143
+ const evidence = r.origin === 'preset' && !r.count
144
+ ? (lang === 'ko' ? '동봉 프리셋 (로그 누적 전)' : 'bundled preset (no local history yet)')
145
+ : (lang === 'ko'
146
+ ? `반복 ${r.count || 0}회 · 에러율 ${Math.round((r.errRate || 0) * 100)}%`
147
+ : `seen ×${r.count || 0} · err ${Math.round((r.errRate || 0) * 100)}%`);
148
+ const stat = lang === 'ko'
149
+ ? `${r.tier} (${rs.tierLabel(r.tier)}) · ${rs.scopeLabel(r.scope)} · ${evidence}`
150
+ : `${r.tier} (${rs.tierLabel(r.tier, 'en')}) · ${rs.scopeLabel(r.scope, 'en')} · ${evidence}`;
151
+ console.log(` #${i + 1} ${stat}${health}`);
152
+ // Measured outcome of the rule actually firing, plus what it saved.
153
+ // A rule with no measured delegations shows "—", never "$0.00": the
154
+ // two mean opposite things (no data vs. data showing no value).
155
+ const measured = r.delegatedRuns
156
+ ? (lang === 'ko'
157
+ ? `실제 위임 ${r.delegatedRuns}건 · 에러율 ${Math.round((r.delegatedErrRate || 0) * 100)}% · 절감 ~$${(r.savedUsd || 0).toFixed(2)}`
158
+ : `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved ~$${(r.savedUsd || 0).toFixed(2)}`)
159
+ : (sessionRank !== null && !rs.worthDelegating(r.tier, sessionRank)
160
+ ? (lang === 'ko'
161
+ ? `이 규칙은 현재 기본 모델(${sessionModel}) 기준으로는 적용되지 않습니다. 세션 모델이 이미 위임 목표와 같은 급이어서 절감이 발생하지 않습니다.`
162
+ : `not in effect for the current default model (${sessionModel}) — the session already runs at the delegation target's tier`)
163
+ : (lang === 'ko'
164
+ ? '실제 위임 기록이 아직 없습니다.'
165
+ : 'measured delegations — (none yet)'));
166
+ console.log(` ${measured}`);
167
+ // Same composer the md file uses, so what is listed here is exactly
168
+ // what the model reads.
169
+ console.log(` ${mr.composeRuleText(r.rule, r, lang)}`);
170
+ });
171
+ // Runs on a model id we cannot price never reach the aggregate, so a
172
+ // rule whose tier runs entirely on such an id reads as "never fired".
173
+ // Say so here rather than leaving the zero unexplained.
174
+ const scan = rs.readRouteScan();
175
+ if (scan?.unresolvedRuns > 0) {
176
+ const ids = (scan.unresolvedModels || []).join(', ');
177
+ console.log(lang === 'ko'
178
+ ? `\n⚠ 해석되지 않은 모델 ID 때문에 위임 ${scan.unresolvedRuns}건이 집계에서 제외됐습니다${ids ? ` (${ids})` : ''}.`
179
+ + '\n profile-map.json 의 modelAliases 에 해당 ID 를 매핑한 뒤 route-scan --refresh 를 실행하십시오.'
180
+ : `\n⚠ ${scan.unresolvedRuns} delegated run(s) were excluded — unpriceable model id${ids ? ` (${ids})` : ''}.`
181
+ + '\n Map it under modelAliases in profile-map.json, then run route-scan --refresh.');
182
+ }
183
+ console.log(lang === 'ko'
184
+ ? '\n제거: claude-token-saver route-scan rules rm <N>'
185
+ : '\nRemove with: claude-token-saver route-scan rules rm <N>');
186
+ return;
187
+ }
188
+
189
+ if (args[1] === 'dismiss') {
190
+ const n = parseInt(args[2], 10);
191
+ if (!Number.isFinite(n)) {
192
+ console.error('Usage: claude-token-saver route-scan dismiss <N> # N from `route? R<N>`');
193
+ process.exit(1);
194
+ }
195
+ const cand = rs.resolveCandidate(n);
196
+ if (!cand) {
197
+ console.error(`No route candidate R${n}. Run: claude-token-saver route-scan`);
198
+ process.exit(1);
199
+ }
200
+ console.log(lang === 'ko'
201
+ ? `R${n} 무시 처리: ${cand.label} (${cand.project}) — 재스캔에도 다시 뜨지 않습니다.`
202
+ : `Dismissed R${n}: ${cand.label} (${cand.project}) — won't resurface on rescans.`);
203
+ return;
204
+ }
205
+
206
+ // --hook: SessionStart hook mode. Never scans inline (session start must
207
+ // stay fast) — reads the cache, kicks a detached refresh when stale, and
208
+ // prints delegation-candidate context for the new session.
209
+ if (hasFlag('--hook')) {
210
+ const hookCtx = readStdinJson() || {};
211
+ let cache = rs.readRouteScan();
212
+ if (await rs.shouldRescan(cache)) {
213
+ try {
214
+ const { spawn } = await import('node:child_process');
215
+ spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
216
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
217
+ } catch (e) { debug('route-scan:spawn-refresh', e); /* stale cache is still usable below */ }
218
+ }
219
+ const open = rs.openCandidates(cache);
220
+ // Rule text shown to the model must be composed the same way the md file
221
+ // composes it, budget clause included — otherwise the briefing promises
222
+ // one rule and the file carries another.
223
+ const mrHook = await import('../model-rules.js');
224
+ const composed = (base, c) => mrHook.composeRuleText(base, c, lang);
225
+ // Re-render ratchet-model.md from THIS version's template. Rule text is
226
+ // composed in code, so an upgrade that rewords it leaves every existing
227
+ // file stale, and the rewrite used to ride along with refreshModelRules
228
+ // — which only runs on a rescan. A session that starts on a warm cache
229
+ // would keep reading the old wording indefinitely. The call writes only
230
+ // when the rendering differs, so the common case is a read and a string
231
+ // compare.
232
+ try { mrHook.syncAllFiles(); } catch (e) { debug('route-scan:sync-ratchet', e); }
233
+ // Registered rules whose delegated-category error rate crossed the
234
+ // health threshold since promotion — the user approved these, so a
235
+ // status change must be briefed, not just written into the md file.
236
+ let reviewRules = [];
237
+ try {
238
+ const mr = mrHook;
239
+ reviewRules = mr.loadModelRules().rules
240
+ .map((r, i) => ({ ...r, n: i + 1 }))
241
+ .filter((r) => r.status === 'review');
242
+ } catch (e) { debug('route-scan:load-rules', e); /* candidate briefing still goes out */ }
243
+ // Korean writing guidance, when the user enabled it. Printed before the
244
+ // route-scan briefing and independently of it: the style has to reach a
245
+ // session even when there is no candidate to report, which is the usual
246
+ // case. Injecting here rather than through a separate hook keeps it on
247
+ // one SessionStart round-trip and one cached prefix.
248
+ let koreanBlock = null;
249
+ try {
250
+ const { koreanStyleInjection } = await import('../korean-style.js');
251
+ koreanBlock = koreanStyleInjection();
252
+ } catch (e) { debug('route-scan:korean-style', e); /* style is optional */ }
253
+
254
+ // English cohesion guidance, when enabled. Same round-trip; skipped
255
+ // internally when the Korean guidance already carries the same rules.
256
+ let cohesionBlock = null;
257
+ try {
258
+ const { cohesionInjection } = await import('../cohesion.js');
259
+ cohesionBlock = await cohesionInjection();
260
+ } catch (e) { debug('route-scan:cohesion', e); /* the guidance is optional */ }
261
+
262
+ // doc2md's standing note, only when the user turned the feature on.
263
+ // Rides the same SessionStart round-trip as the style block for the same
264
+ // reason: one injection, one cached prefix. It carries the two things the
265
+ // model cannot work out for itself — that a binary-file refusal has a
266
+ // one-command answer, and that an attached document costs far more than
267
+ // the path to it.
268
+ let doc2mdBlock = null;
269
+ try {
270
+ if (doc2mdHookRegistered()) doc2mdBlock = require('../doc2md.cjs').sessionNote(lang);
271
+ } catch (e) { debug('route-scan:doc2md-note', e); /* the note is optional */ }
272
+
273
+ // Starter rules that the user has not answered yet (bundled presets, both
274
+ // model-fitting and ratchet). A fresh install has an empty model ratchet
275
+ // and an empty ratchet.md, so without this the tool delegates nothing
276
+ // until the user's own history is long enough to propose a candidate.
277
+ // The asking happens here because a CLI prompt cannot reach a user whose
278
+ // only interface is the chat: the model gets the list and asks one rule
279
+ // at a time. Silent — and free — once everything has an answer.
280
+ let seedBlock = null;
281
+ try {
282
+ const { seedOfferBlock } = await import('../seed-rules.js');
283
+ seedBlock = seedOfferBlock({ lang });
284
+ } catch (e) { debug('route-scan:seed-offer', e); /* the offer is optional */ }
285
+
286
+ // Upgrade offer. A statusline cannot open a dialog, so session start is
287
+ // where the *asking* happens: the model gets one line telling it a newer
288
+ // version exists and to ask before installing anything. Cached read only
289
+ // — the refresh runs detached off the statusline path, never here.
290
+ let updateBlock = null;
291
+ try {
292
+ const { updateStatus, maybeSpawnUpdateCheck } = await import('../update-check.js');
293
+ const pkgVersion = readPackageVersion();
294
+ // Session start is the one moment where a stale cache matters most, so
295
+ // kick the detached refresh here too. It lands in time for the
296
+ // statusline and for the next session, not for this line.
297
+ if (pkgVersion) maybeSpawnUpdateCheck(pkgVersion);
298
+ const u = pkgVersion ? updateStatus(pkgVersion) : null;
299
+ // `dismissed` is the whole point of asking once: a user who said no to
300
+ // this version must not be asked again every `/clear`.
301
+ if (u && u.available && !u.dismissed) {
302
+ updateBlock = lang === 'ko'
303
+ ? [
304
+ `[claude-token-saver update] 새 버전이 나와 있습니다: v${u.current} → ${u.latest}.`,
305
+ '사용자에게 지금 업그레이드할지 물어보고, 승낙하면 아래 명령을 실행하십시오. 묻지 않고 설치하지는 마십시오.',
306
+ ' claude-token-saver upgrade # 업그레이드 실행',
307
+ ' claude-token-saver update-check --dismiss # 사용자가 원치 않으면 (다음 버전이 나올 때까지 다시 묻지 않습니다)',
308
+ ].join('\n')
309
+ : [
310
+ `[claude-token-saver update] A newer version is available: v${u.current} → ${u.latest}.`,
311
+ 'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
312
+ ' claude-token-saver upgrade # perform the upgrade',
313
+ ' claude-token-saver update-check --dismiss # if they decline (stays quiet until a newer release)',
314
+ ].join('\n');
315
+ }
316
+ } catch (e) { debug('route-scan:update-check', e); /* the offer is optional */ }
317
+
318
+ if (open.length === 0 && reviewRules.length === 0) {
319
+ if (updateBlock) console.log(updateBlock);
320
+ if (seedBlock) console.log(seedBlock);
321
+ if (doc2mdBlock) console.log(doc2mdBlock);
322
+ if (koreanBlock) console.log(koreanBlock);
323
+ if (cohesionBlock) console.log(cohesionBlock);
324
+ return; // nothing else to inject
325
+ }
326
+ if (updateBlock) console.log(updateBlock);
327
+ if (seedBlock) console.log(seedBlock);
328
+ if (doc2mdBlock) console.log(doc2mdBlock);
329
+ // This text is injected straight into the model's context, so it must
330
+ // follow the user's configured language — a Korean-only briefing in an
331
+ // English session steers the whole first response into Korean.
332
+ const lines = [];
333
+ if (open.length > 0) {
334
+ if (lang === 'ko') {
335
+ lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 비싼 모델(opus/fable)이 반복 처리해 온, 더 싼 모델로 넘겨도 되는 작업이 감지되었습니다.`);
336
+ lines.push('(R<N>은 후보 번호, T2/T1은 난이도 등급입니다 — 사용자에게 전달할 때는 코드가 아니라 아래 풀어쓴 설명으로 브리핑하세요)');
337
+ } else {
338
+ lines.push(`[claude-token-saver route-scan] Over the last ${cache.days} days, expensive models (opus/fable) repeatedly handled work that a cheaper model could take.`);
339
+ lines.push('(R<N> is the candidate id, T2/T1 the difficulty tier — brief the user with the spelled-out wording below, not the codes.)');
340
+ }
341
+ for (const c of open) {
342
+ const tier = c.tier || 'T2';
343
+ const label = lang === 'ko' ? c.label : (c.labelEn || c.label);
344
+ const rule = composed(lang === 'ko' ? c.rule : (c.ruleEn || c.rule), c);
345
+ if (lang === 'ko') {
346
+ lines.push(` 후보 R${c.id} — "${label}" 유형, ${c.count}회 반복 (프로젝트: ${c.project})`);
347
+ lines.push(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 서브에이전트 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
348
+ lines.push(` 예시 요청: "${c.example}"`);
349
+ lines.push(` 등록 시 ratchet-model.md에 기록될 룰: "${rule}"`);
350
+ } else {
351
+ lines.push(` Candidate R${c.id} — "${label}", seen ×${c.count} (project: ${c.project})`);
352
+ lines.push(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to the ${c.agent} subagent · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
353
+ lines.push(` example request: "${c.example}"`);
354
+ lines.push(` rule that would be written to ratchet-model.md: "${rule}"`);
355
+ }
356
+ }
357
+ if (lang === 'ko') {
358
+ lines.push('등록하면 다음 세션부터 자동 위임됩니다. 사용자에게 등록 여부를 물을 때 위 룰 원문을 그대로 보여주고, 적용 범위까지 확인한 뒤 실행하세요:');
359
+ lines.push(' claude-token-saver harness promote R<N> --project|--global # 적용 범위는 반드시 사용자에게 확인');
360
+ lines.push(' claude-token-saver route-scan dismiss <N> # 사용자가 원치 않으면');
361
+ } else {
362
+ lines.push('Once registered, delegation happens automatically from the next session. Show the user the rule text verbatim, confirm the scope with them, then run:');
363
+ lines.push(' claude-token-saver harness promote R<N> --project|--global # ALWAYS confirm the scope with the user first');
364
+ lines.push(' claude-token-saver route-scan dismiss <N> # if they do not want it');
365
+ }
366
+ }
367
+ if (reviewRules.length > 0) {
368
+ lines.push(lang === 'ko'
369
+ ? '[claude-token-saver rule-health] 사용자가 승인한 위임 룰 중, 위임 대상 유형의 최근 에러율이 기준(20%)을 넘어 재검토가 필요한 룰이 있습니다 — 사용자에게 브리핑하고 조건 좁히기/제거를 상의하세요:'
370
+ : '[claude-token-saver rule-health] Some user-approved delegation rules now exceed the 20% error-rate threshold for their delegated category — brief the user and discuss narrowing or removing them:');
371
+ for (const r of reviewRules) {
372
+ // Say WHICH signal tripped: a measured delegation failure rate is a
373
+ // much stronger claim than the shape-based proxy, and the user's
374
+ // decision (narrow vs. remove) depends on knowing which it is.
375
+ const measured = r.healthSource === 'delegated';
376
+ const rate = Math.round(((measured ? r.delegatedErrRate : r.errRate) || 0) * 100);
377
+ const evidence = lang === 'ko'
378
+ ? (measured ? `실제 위임 ${r.delegatedRuns}건 실측 에러율 ${rate}%` : `유형 에러율 ${rate}% (형태 기반 추정)`)
379
+ : (measured ? `${rate}% measured across ${r.delegatedRuns} real delegations` : `${rate}% for the category (shape-based proxy)`);
380
+ lines.push(lang === 'ko'
381
+ ? ` 룰 #${r.n} (${r.tier} ${rs.tierLabel(r.tier)} · ${rs.scopeLabel(r.scope)}) — ${evidence}`
382
+ : ` rule #${r.n} (${r.tier} ${rs.tierLabel(r.tier, 'en')} · ${rs.scopeLabel(r.scope, 'en')}) — ${evidence}`);
383
+ lines.push(` "${r.rule}"`);
384
+ }
385
+ lines.push(lang === 'ko'
386
+ ? ' 제거: claude-token-saver route-scan rules rm <N>'
387
+ : ' remove with: claude-token-saver route-scan rules rm <N>');
388
+ }
389
+ lines.push(lang === 'ko'
390
+ ? '위 내용은 사용자의 첫 메시지 주제와 무관하더라도(단순 인사여도) 첫 응답 말미에 `※ [claude-token-saver]` 라벨을 달아 요약 브리핑하세요 — 사용자가 물어볼 때까지 기다리지 말 것.'
391
+ : 'Summarize the above at the end of your first response under a `※ [claude-token-saver]` label, even if the user\'s first message is unrelated (a bare greeting counts) — do not wait to be asked.');
392
+ // Style first, briefing second: the briefing is Korean prose too, so the
393
+ // guidance has to be in context before the model reads it.
394
+ if (koreanBlock) console.log(koreanBlock + '\n');
395
+ if (cohesionBlock) console.log(cohesionBlock + '\n');
396
+ console.log(lines.join('\n'));
397
+ // Record what was actually briefed so the UserPromptSubmit brief hook
398
+ // suppresses exactly these — a candidate landing after this read (e.g.
399
+ // the detached rescan above finishing) still gets briefed next prompt.
400
+ try {
401
+ const { seedSessionBriefed } = await import('../brief.js');
402
+ seedSessionBriefed(hookCtx.session_id, [
403
+ ...open.map((c) => `route|${c.signature}`),
404
+ ...reviewRules.map((r) => `health|${r.signature}|${r.scope}`),
405
+ ]);
406
+ } catch (e) { debug('route-scan:seed-briefed', e); /* worst case is one duplicate brief */ }
407
+ return;
408
+ }
409
+
410
+ const days = numArg('--days', { dflt: 14, min: 0 });
411
+ let cache = rs.readRouteScan();
412
+ if (hasFlag('--refresh') || (cache && cache.days !== days) || await rs.shouldRescan(cache, { days })) {
413
+ cache = await rs.runRouteScan({ days });
414
+ }
415
+ if (hasFlag('--quiet')) return;
416
+ if (hasFlag('--json')) {
417
+ console.log(JSON.stringify(cache, null, 2));
418
+ return;
419
+ }
420
+ const easyPct = cache.totalEpisodes ? Math.round(cache.easyEpisodes / cache.totalEpisodes * 100) : 0;
421
+ console.log(lang === 'ko'
422
+ ? `route-scan — 최근 ${cache.days}일: 에피소드 ${cache.totalEpisodes}건 중 easy ${cache.easyEpisodes}건 (${easyPct}%) [스캔: ${cache.scannedAt}]`
423
+ : `route-scan — last ${cache.days}d: ${cache.easyEpisodes}/${cache.totalEpisodes} episodes easy (${easyPct}%) [scanned: ${cache.scannedAt}]`);
424
+ const open = rs.openCandidates(cache);
425
+ if (open.length === 0) {
426
+ console.log(lang === 'ko'
427
+ ? '위임 후보 없음 (반복 3회 미만이거나 이미 처리됨).'
428
+ : 'No delegation candidates (below recurrence threshold or already resolved).');
429
+ (await import('../first-run-note.js')).printOnce('route-scan', lang);
430
+ return;
431
+ }
432
+ console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1 = difficulty tier):');
433
+ for (const c of open) {
434
+ const tier = c.tier || 'T2';
435
+ if (lang === 'ko') {
436
+ console.log(` R${c.id} "${c.label}" ×${c.count}회 [${c.project}]`);
437
+ console.log(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
438
+ } else {
439
+ console.log(` R${c.id} "${c.labelEn || c.label}" ×${c.count} [${c.project}]`);
440
+ console.log(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to ${c.agent} · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
441
+ }
442
+ console.log(` ${lang === 'ko' ? '예시' : 'example'}: "${c.example}"`);
443
+ const mrList = await import('../model-rules.js');
444
+ const base = lang === 'ko' ? c.rule : (c.ruleEn || c.rule);
445
+ console.log(` ${lang === 'ko' ? '룰' : 'rule'}: ${mrList.composeRuleText(base, c, lang)}`);
446
+ }
447
+ console.log('');
448
+ console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
449
+ console.log(' claude-token-saver harness promote R<N> --project|--global');
450
+ console.log(' claude-token-saver route-scan dismiss <N>');
451
+ // 최초 1회만 — 매번 찍으면 도구가 광고판이 된다 (CTS_NO_NOTE=1 로 끔)
452
+ (await import('../first-run-note.js')).printOnce('route-scan', lang);
453
+ return;
454
+ }