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.
- package/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- 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
|
+
}
|