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,832 @@
1
+ /**
2
+ * route-scan — detect recurring "easy" work running on expensive models and
3
+ * propose model-delegation ratchet rules.
4
+ *
5
+ * Difficulty is judged at the EPISODE level (one user request = the
6
+ * consecutive API calls it triggered), because per-call scoring saturates on
7
+ * Claude Code's large session contexts — prompt size carries no signal when
8
+ * every call ships a 100k+ cached prefix. An episode is easy when the whole
9
+ * request finished in few calls with little generation — exactly the work a
10
+ * haiku subagent could take.
11
+ *
12
+ * Fully local, zero token cost. Results are cached (24h) so the SessionStart
13
+ * hook can read them without re-parsing a month of transcripts.
14
+ *
15
+ * Pipeline position (per design discussion): this scan is NOT a real-time
16
+ * router — it is a session-boundary calibrator that feeds the existing
17
+ * ratchet promote flow (`harness promote R<N> --project|--global`).
18
+ */
19
+
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { userDataDir } from './paths.js';
23
+ import { discoverSessionFiles } from './parser.js';
24
+ import { collectSessionRecords } from './session-records.js';
25
+ import {
26
+ collectSubagentRuns, indexRuns, exactRunsForEpisode, fallbackRunsForEpisode,
27
+ } from './subagent-records.js';
28
+ import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
29
+ import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
30
+ import { modelRuleBaseText } from './model-rules.js';
31
+
32
+ // ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
33
+ // T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
34
+ // errors. T1 (sonnet): moderate output/mutation, at most one tool error.
35
+ // T0: everything else stays on the session model. Output-token thresholds
36
+ // are calibrated per-user from their own 14-day distribution (fixed
37
+ // thresholds drift with workload — RouteLLM's stated limitation), clamped
38
+ // to sane ranges so a skewed window can't stretch them absurdly.
39
+ export const T2_MAX_CALLS = 6;
40
+ export const T2_MAX_MUTATING = 2;
41
+ export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
42
+ export const T1_MAX_MUTATING = 6;
43
+ export const T1_MAX_ERRORS = 1;
44
+ export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
45
+ export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
46
+ export const T0_MIN_MUTATING = 7;
47
+ // Episodes below this output size carry no delegable work (conversational
48
+ // acks, feedback) — skip entirely.
49
+ export const MIN_DELEGABLE_OUT = 100;
50
+ // Escalation keywords: design/analysis judgement stays on the top tier.
51
+ // Irreversible/external actions (store submission, deploy, release, merge)
52
+ // are included — they may look like light "run" episodes in the logs, but
53
+ // delegating them defeats the harness's default-safe-path rule.
54
+ export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜 |제출|배포|출시|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge/i;
55
+ // A pattern must recur this often before we nag about it.
56
+ export const MIN_RECURRENCE = 3;
57
+
58
+ // ── Rescan gate (data-driven, not time-driven) ───────────────────────────
59
+ // A scan over unchanged transcripts is deterministic — identical output —
60
+ // so time alone is a bad trigger: it wastes scans on idle days and lags a
61
+ // full day behind heavy ones. Instead we rescan when enough NEW transcript
62
+ // data accumulated (~5MB ≈ 20-40 episodes on measured data — enough for a
63
+ // pattern to newly cross MIN_RECURRENCE), with guardrails: a minimum
64
+ // interval against session-churn thrash, a daily fallback so small trickles
65
+ // still refresh rule-health, and a hard skip when nothing changed at all.
66
+ export const RESCAN_MIN_INTERVAL_MS = 60 * 60 * 1000; // never more than hourly
67
+ export const RESCAN_BIG_DELTA_BYTES = 5 * 1024 * 1024; // this much new data → rescan now
68
+ export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a day old → rescan
69
+
70
+ // Categories → recommended subagent. Classification is behavior-first with
71
+ // weighted keyword scoring as fallback — see categorize().
72
+ const PASTE_MIN_LEN = 400;
73
+ const CATEGORIES = [
74
+ {
75
+ id: 'paste',
76
+ label: '붙여넣은 화면·로그 질문',
77
+ labelEn: 'questions about pasted screens/logs',
78
+ agent: 'haiku-explore',
79
+ kw: null, // matched by length, see categorize()
80
+ },
81
+ {
82
+ id: 'translate',
83
+ label: '배치 번역·정형 텍스트 변환',
84
+ labelEn: 'batch translation / mechanical text transforms',
85
+ agent: 'haiku-translate',
86
+ kw: [[/번역|translate/i, 2], [/변환해|표로 정리|포맷팅/i, 1]],
87
+ },
88
+ {
89
+ id: 'explore',
90
+ label: '탐색·조회 (파일/값 찾기)',
91
+ labelEn: 'lookup (finding files/values)',
92
+ agent: 'haiku-explore',
93
+ kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1]],
94
+ },
95
+ {
96
+ id: 'read',
97
+ label: '읽기·요약·설명',
98
+ labelEn: 'reading / summarizing / explaining',
99
+ agent: 'haiku-explore',
100
+ kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|what/i, 1]],
101
+ },
102
+ {
103
+ id: 'check',
104
+ label: '상태 확인·검증',
105
+ labelEn: 'status checks / verification',
106
+ agent: 'haiku-explore',
107
+ kw: [[/확인|검증|verify|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|check|status/i, 1]],
108
+ },
109
+ {
110
+ id: 'run',
111
+ label: '명령 실행 (빌드·테스트·git)',
112
+ labelEn: 'running commands (build/test/git)',
113
+ agent: 'haiku-runner',
114
+ kw: [[/git |commit|push|npm |pip|빌드해|빌드 돌/i, 2], [/실행|돌려|run |build|빌드|테스트|설치/i, 1]],
115
+ },
116
+ ];
117
+
118
+ // ── Behavior signal (1st) — what the episode actually DID ────────────────
119
+ // The tool-call histogram is ground truth the prompt's wording is not:
120
+ // "테스트 통과했는지 확인해줘" that actually ran `npx playwright test` IS a
121
+ // run episode regardless of phrasing. Keyword scores only pick within (or,
122
+ // when behavior is inconclusive, across) the plausible pool.
123
+ const RUN_TOOLS = new Set(['Bash']);
124
+ const LOOKUP_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch']);
125
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
126
+ const MIN_BEHAVIOR_CALLS = 2; // fewer calls than this → too little behavior to trust
127
+
128
+ /**
129
+ * Narrow the candidate categories from the episode's tool mix.
130
+ * Returns { ids, fallback } — ids are the candidate categories, fallback is
131
+ * the id to use when keywords stay silent (null = keywords are REQUIRED, an
132
+ * id-less episode is not a delegation candidate). Returns null when behavior
133
+ * is inconclusive.
134
+ */
135
+ export function behaviorPool(toolCounts) {
136
+ let run = 0, lookup = 0, write = 0, total = 0;
137
+ for (const [name, n] of Object.entries(toolCounts || {})) {
138
+ total += n;
139
+ if (RUN_TOOLS.has(name)) run += n;
140
+ else if (LOOKUP_TOOLS.has(name)) lookup += n;
141
+ else if (WRITE_TOOLS.has(name)) write += n;
142
+ }
143
+ if (total < MIN_BEHAVIOR_CALLS) return null;
144
+ if (run > lookup + write) return { ids: ['run'], fallback: 'run' };
145
+ if (lookup > run + write) return { ids: ['explore', 'read', 'check', 'translate'], fallback: 'explore' };
146
+ // Write-dominant episodes are EDITING work, not delegable lookups — without
147
+ // this they leak into read/explore via generic keywords ("설명이 필요해보이는데"
148
+ // + Edit×5 landed in read/T1). Only translate legitimately writes, so it is
149
+ // the sole candidate — and only with explicit translate keywords (no
150
+ // silent fallback: an editing episode is not a delegation candidate).
151
+ if (write > run + lookup) return { ids: ['translate'], fallback: null };
152
+ return null; // mixed — no reliable verdict, keywords decide
153
+ }
154
+
155
+ /** Weighted keyword score for one category (0 when it has no kw table). */
156
+ function keywordScore(cat, text) {
157
+ if (!cat.kw) return 0;
158
+ let score = 0;
159
+ for (const [re, w] of cat.kw) if (re.test(text)) score += w;
160
+ return score;
161
+ }
162
+
163
+ // Episodes that are not user-delegable requests: bare continuations, injected
164
+ // notifications, image pastes. These are easy but there is nothing to route.
165
+ const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)\b/i;
166
+ const SKIP_PREFIX = ['<task-notification', '<system', '[Image:', '<local-command'];
167
+
168
+ // Single source of truth for the state dir (paths.js). A local copy used to
169
+ // live here and honored XDG_CONFIG_HOME on Linux only, so on macOS/Windows an
170
+ // XDG override split this cache away from config.json and the session cache.
171
+ const stateDir = userDataDir;
172
+
173
+ export function routeScanCachePath() {
174
+ return join(stateDir(), 'route-scan.json');
175
+ }
176
+
177
+ /** Munge an absolute path the way Claude Code names project dirs. */
178
+ export function mungeProjectPath(p) {
179
+ return String(p).replace(/[^a-zA-Z0-9-]/g, '-');
180
+ }
181
+
182
+ /**
183
+ * Classify an episode. Gates (paste by length) run first, then the tool-mix
184
+ * behavior signal narrows the candidate pool, then weighted keyword scores
185
+ * pick within it (highest score wins; ties fall back to CATEGORIES order).
186
+ * A behavior verdict without any keyword hit still classifies (pool's first
187
+ * id); no behavior AND no keyword hit → null (nothing delegable to name).
188
+ */
189
+ export function categorize(text, toolCounts) {
190
+ if (text.length >= PASTE_MIN_LEN) return CATEGORIES.find((c) => c.id === 'paste');
191
+ const pool = behaviorPool(toolCounts);
192
+ const eligible = pool
193
+ ? pool.ids.map((id) => CATEGORIES.find((c) => c.id === id))
194
+ : CATEGORIES.filter((c) => c.kw);
195
+ let best = null;
196
+ let bestScore = 0;
197
+ for (const c of eligible) {
198
+ const s = keywordScore(c, text);
199
+ if (s > bestScore) { best = c; bestScore = s; }
200
+ }
201
+ if (best) return best;
202
+ if (pool?.fallback) return CATEGORIES.find((c) => c.id === pool.fallback);
203
+ return null;
204
+ }
205
+
206
+ function isSkippable(text) {
207
+ if (!text) return true;
208
+ if (SKIP_RE.test(text.trim())) return true;
209
+ return SKIP_PREFIX.some((p) => text.startsWith(p));
210
+ }
211
+
212
+ /** Group a session's records into episodes (consecutive same trigger prompt). */
213
+ function toEpisodes(records) {
214
+ const episodes = [];
215
+ let cur = null;
216
+ for (const r of records) {
217
+ const text = (r.userText || '').trim();
218
+ if (!cur || cur.text !== text) {
219
+ cur = {
220
+ text, calls: 0, out: 0, mutating: 0, errors: 0, delegated: 0,
221
+ models: new Set(), cwd: '', tools: {},
222
+ // Delegation attribution (subagent-records): exact join key, plus the
223
+ // episode's time span for the fallback when a run has no meta file.
224
+ delegationToolUseIds: [], startedAt: null, endedAt: null,
225
+ };
226
+ episodes.push(cur);
227
+ }
228
+ cur.calls += 1;
229
+ cur.out += r.completion_tokens;
230
+ cur.mutating += r.mutatingToolCalls || 0;
231
+ cur.errors += r.toolErrors || 0;
232
+ cur.delegated += r.delegationCalls || 0;
233
+ for (const [name, n] of Object.entries(r.toolCounts || {})) {
234
+ cur.tools[name] = (cur.tools[name] || 0) + n;
235
+ }
236
+ for (const id of r.delegationToolUseIds || []) cur.delegationToolUseIds.push(id);
237
+ if (r.timestamp) {
238
+ const t = Date.parse(r.timestamp);
239
+ if (Number.isFinite(t)) {
240
+ if (cur.startedAt === null || t < cur.startedAt) cur.startedAt = t;
241
+ if (cur.endedAt === null || t > cur.endedAt) cur.endedAt = t;
242
+ }
243
+ }
244
+ cur.models.add(r.model);
245
+ if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
246
+ }
247
+ return episodes;
248
+ }
249
+
250
+ /**
251
+ * Price rank of the model that actually handled the episode (the most
252
+ * expensive one, when a session switched models mid-episode).
253
+ */
254
+ export function episodeRank(ep) {
255
+ let rank = -1;
256
+ for (const m of ep.models) rank = Math.max(rank, modelRank(m));
257
+ return rank;
258
+ }
259
+
260
+ /**
261
+ * Delegation only pays when the target tier is strictly cheaper than what ran
262
+ * the work. Without this a Sonnet session produced "delegate to sonnet" T1
263
+ * rules — a subagent rebuilding context for zero price difference, which is a
264
+ * net loss. (Replaces the old boolean "is it haiku?" test, which could not
265
+ * tell a Sonnet session from a Fable one.)
266
+ */
267
+ /**
268
+ * The model a category was mostly handled by, from a { model → episodes } map.
269
+ * Ties break toward the pricier model: with no majority either way, the more
270
+ * expensive reading of "what this used to cost" is the one worth stating.
271
+ * Returns null for an empty map, which callers read as "no baseline yet".
272
+ */
273
+ export function dominantModel(counts) {
274
+ let best = null;
275
+ let bestN = 0;
276
+ for (const [model, n] of Object.entries(counts || {})) {
277
+ if (n > bestN || (n === bestN && best && modelRank(model) > modelRank(best))) {
278
+ best = model;
279
+ bestN = n;
280
+ }
281
+ }
282
+ return best;
283
+ }
284
+
285
+ // A delegated run counts as failed on error DENSITY, not on the presence of a
286
+ // single is_error. Binary counting scored a 218-call run that finished its task
287
+ // identically to one that died on its first call. The floor keeps very short
288
+ // runs honest: 1 error in 3 calls is still a failure.
289
+ export const DELEGATED_ERR_DENSITY = 0.1;
290
+
291
+ export function isFailedRun(run) {
292
+ if (!run || !(run.toolErrors > 0)) return false;
293
+ const calls = run.calls > 0 ? run.calls : 1;
294
+ return run.toolErrors / calls > DELEGATED_ERR_DENSITY;
295
+ }
296
+
297
+ export function worthDelegating(tier, rank) {
298
+ const target = TIER_TARGET_RANK[tier];
299
+ return target !== undefined && rank > target;
300
+ }
301
+
302
+ /**
303
+ * USD a delegated run saved versus the session model doing the same work.
304
+ * Approximation, deliberately stated as one: it holds token counts constant,
305
+ * which a cheaper model would not reproduce exactly. Directionally right and
306
+ * enough to rank rules by value, so it is rendered as "~$X".
307
+ */
308
+ export function runSaving(run, mainModel) {
309
+ if (!run.model || !mainModel) return 0;
310
+ const totals = {
311
+ input: run.input,
312
+ cacheCreation: run.cacheCreation,
313
+ cacheRead: run.cacheRead,
314
+ ephemeral5m: run.ephemeral5m,
315
+ ephemeral1h: run.ephemeral1h,
316
+ output: run.out,
317
+ };
318
+ const actual = estimateCost(totals, run.model).actual;
319
+ const counterfactual = estimateCost(totals, mainModel).actual;
320
+ return Math.max(0, counterfactual - actual);
321
+ }
322
+
323
+ const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
324
+ const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
325
+
326
+ /**
327
+ * Per-user output-token thresholds from this window's episode distribution.
328
+ * Falls back to mid-clamp defaults when the sample is too small to trust.
329
+ */
330
+ export function calibrateThresholds(episodeOuts) {
331
+ if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
332
+ const sorted = [...episodeOuts].sort((a, b) => a - b);
333
+ return {
334
+ t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
335
+ t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
336
+ calibrated: true,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
342
+ * or null when the episode carries nothing delegable. Order matters: hard
343
+ * evidence (errors, heavy mutation, big output, judgement keywords) wins
344
+ * before any cheap-band check.
345
+ */
346
+ export function tierOf(ep, category, th) {
347
+ if (ep.out < MIN_DELEGABLE_OUT) return null;
348
+ if (
349
+ ep.errors >= T0_MIN_ERRORS ||
350
+ ep.mutating >= T0_MIN_MUTATING ||
351
+ ep.out > th.t1Out ||
352
+ ESCALATE_RE.test(ep.text)
353
+ ) return 'T0';
354
+ if (!category || ep.delegated > 0) return 'T0';
355
+ if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
356
+ if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
357
+ return 'T0';
358
+ }
359
+
360
+ /**
361
+ * Scan transcripts and build delegation candidates.
362
+ * Returns the cache object (also written to disk).
363
+ */
364
+ export async function runRouteScan({ days = 14 } = {}) {
365
+ const files = await discoverSessionFiles({ days });
366
+
367
+ // Behind a Bedrock/LiteLLM gateway the transcripts carry an inference-profile
368
+ // ARN where the model id belongs, which reads as Sonnet and rejects every T1
369
+ // rule. Refresh the profile→role mapping before parsing so this scan resolves
370
+ // those ids; on a direct-API machine it finds nothing and writes nothing.
371
+ resetModelAliasCache();
372
+ try {
373
+ await learnProfileMapping({ sessionPaths: files.map((f) => f.path) });
374
+ } catch { /* learning is an optimization — the scan still runs without it */ }
375
+ resetModelAliasCache();
376
+
377
+ // Pass 1 — collect episodes (needed up front: thresholds are calibrated
378
+ // from the full window's output distribution before any tiering).
379
+ const all = []; // { ep, projectDir, sessionPath }
380
+ let dataBytes = 0; // window size snapshot — the rescan gate diffs against it
381
+ const runIndexBySession = new Map(); // sessionPath → indexRuns() result
382
+ for (const f of files) {
383
+ let records;
384
+ try {
385
+ dataBytes += statSync(f.path).size;
386
+ records = await collectSessionRecords(f.path, { includeContent: true });
387
+ } catch {
388
+ continue;
389
+ }
390
+ for (const ep of toEpisodes(records)) {
391
+ if (!ep.text) continue;
392
+ all.push({ ep, projectDir: f.projectDir, sessionPath: f.path });
393
+ }
394
+ // Subagent transcripts of this session — the real outcome of every
395
+ // delegation it made. Best-effort: sessions that never delegated have no
396
+ // directory and cost one failed readdir.
397
+ const runs = await collectSubagentRuns(f.path);
398
+ if (runs.length > 0) {
399
+ runIndexBySession.set(f.path, indexRuns(runs));
400
+ // Subagent bytes count toward the window size so the rescan gate stays
401
+ // accurate for delegation-heavy workloads.
402
+ for (const r of runs) dataBytes += r.bytes || 0;
403
+ }
404
+ }
405
+ const totalEpisodes = all.length;
406
+ const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
407
+
408
+ // Pass 2 — tier, group by tier×category×project, and accumulate the
409
+ // per-category outcome stats that keep promoted model rules fresh.
410
+ const groups = new Map(); // "tier|category|project" → aggregate
411
+ const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
412
+ let tieredEpisodes = 0;
413
+ const bumpStats = (key, ep) => {
414
+ const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null, modelCounts: {} };
415
+ s.count += 1;
416
+ s.epCount += 1;
417
+ if (ep.errors > 0) s.errCount += 1;
418
+ // Baseline model: what actually handled this category BEFORE any rule sent
419
+ // it elsewhere. This is the only honest counterfactual for "routing saved
420
+ // money" — the session's priciest model is not, since it may never have
421
+ // touched work of this shape.
422
+ //
423
+ // Counted, not maxed. A transcript routinely carries more than one model
424
+ // (the user switches mid-session, a compaction pass runs elsewhere), and
425
+ // taking the priciest of them would let a single Fable record set the
426
+ // baseline for a category that Opus handled thirty times — inflating every
427
+ // later saving. The model that handled the most episodes is the one the
428
+ // rule actually replaced; price breaks a tie.
429
+ for (const m of ep.models) {
430
+ // Only ids the pricing table really recognizes may become a baseline —
431
+ // an unresolved gateway id or a house alias would be priced as Sonnet
432
+ // and quietly rewrite every saving computed against it.
433
+ if (!isRecognizedModelId(m)) continue;
434
+ s.modelCounts[m] = (s.modelCounts[m] || 0) + 1;
435
+ }
436
+ s.baselineModel = dominantModel(s.modelCounts);
437
+ episodeStats.set(key, s);
438
+ };
439
+
440
+ for (const { ep, projectDir } of all) {
441
+ if (isSkippable(ep.text)) continue;
442
+ const epRank = episodeRank(ep);
443
+ if (!worthDelegating('T2', epRank)) continue; // already at the cheapest tier
444
+ const cat = categorize(ep.text, ep.tools);
445
+ if (cat) {
446
+ // rule-health denominator: episodes that LOOK delegable by shape
447
+ // (tier judged with the error signal zeroed — using real errors here
448
+ // would be circular, since T2 requires errors=0 by definition). The
449
+ // numerator is those that still hit errors: exactly the "light-looking
450
+ // work in this category keeps failing" risk a delegation rule cares about.
451
+ // Keyed by tier as well: a category can carry both a T2 and a T1 rule,
452
+ // and sharing one category-wide stat would double-count every episode
453
+ // into both rules (identical ×N / err% on unrelated tiers).
454
+ const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
455
+ // Same rank gate as the candidate path below — a denominator counting
456
+ // episodes that can't produce a rule would skew that rule's error rate.
457
+ if ((shapeTier === 'T1' || shapeTier === 'T2') && worthDelegating(shapeTier, epRank)) {
458
+ bumpStats(`${shapeTier}|${cat.id}|${projectDir}`, ep);
459
+ bumpStats(`${shapeTier}|${cat.id}|*`, ep);
460
+ }
461
+ }
462
+ const tier = tierOf(ep, cat, thresholds);
463
+ if (tier !== 'T1' && tier !== 'T2') continue;
464
+ if (!worthDelegating(tier, epRank)) continue;
465
+ tieredEpisodes += 1;
466
+ const key = `${tier}|${cat.id}|${projectDir}`;
467
+ const g = groups.get(key) || {
468
+ tier,
469
+ category: cat.id,
470
+ label: cat.label,
471
+ labelEn: cat.labelEn,
472
+ agent: tier === 'T2' ? cat.agent : 'sonnet',
473
+ project: projectDir,
474
+ projectPath: '',
475
+ count: 0,
476
+ models: new Set(),
477
+ example: '',
478
+ };
479
+ g.count += 1;
480
+ for (const m of ep.models) g.models.add(m);
481
+ if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
482
+ if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
483
+ g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
484
+ }
485
+ groups.set(key, g);
486
+ }
487
+
488
+ // Pass 3 — measured delegation outcomes (rule-health v2). Episodes that
489
+ // DID delegate are excluded from tiering by design (tierOf returns T0 when
490
+ // ep.delegated > 0: there is nothing left to route). But they are exactly
491
+ // where a promoted rule's real success rate lives, so they get their own
492
+ // pass: join each episode to the subagent transcripts it spawned, and file
493
+ // the outcome under the tier that run's model represents — a haiku run is a
494
+ // T2 rule firing, a sonnet run a T1 one. Runs that were not a downgrade
495
+ // (same tier or higher) carry no delegation saving and are skipped.
496
+ const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
497
+ let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
498
+ const unresolvedModels = new Set();
499
+ const bumpDelegated = (key, run, saved) => {
500
+ const d = delegatedStats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
501
+ d.runs += 1;
502
+ if (isFailedRun(run)) d.errRuns += 1;
503
+ d.outTokens += run.out || 0;
504
+ d.savedUsd += saved;
505
+ delegatedStats.set(key, d);
506
+ };
507
+ // Ledger events feed the statusline's weekly/monthly "Routing saved"
508
+ // totals. Keyed by run transcript path so overlapping re-scans upsert the
509
+ // same event instead of double-counting it.
510
+ //
511
+ // What counts as a routing saving is narrower than what counts for
512
+ // rule-health above. A saving is the price difference a REGISTERED RULE
513
+ // caused: the model that used to handle this category (the rule's baseline,
514
+ // learned from episodes an expensive model handled directly) versus the
515
+ // model the run actually used. Subagent runs that no rule covers —
516
+ // Explore, a hand-written agent, a plugin's own subagent — would have gone
517
+ // to the same cheap model with or without this tool, so attributing their
518
+ // savings here would credit the tool for work it did not route.
519
+ const ledgerEvents = [];
520
+ let ledgerRules = [];
521
+ try {
522
+ const { loadModelRules } = await import('./model-rules.js');
523
+ ledgerRules = loadModelRules().rules.filter((r) => r.status !== 'off');
524
+ } catch { /* no registry → no attributable savings, which is the honest zero */ }
525
+ // A rule's baseline: what it stored at promotion, else what this scan still
526
+ // observes handling the category directly (a rule promoted before baselines
527
+ // existed backfills on the next refresh).
528
+ const baselineFor = (rule, tier, catId, projectDir) => {
529
+ if (rule.baselineModel) return rule.baselineModel;
530
+ const s = episodeStats.get(`${tier}|${catId}|${projectDir}`)
531
+ || (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
532
+ return s?.baselineModel || null;
533
+ };
534
+ const ruleForRun = (tier, catId, projectDir) => ledgerRules.find((r) =>
535
+ r.tier === tier && r.category === catId &&
536
+ (r.scope === 'global' || r.project === projectDir));
537
+ for (const [sessionPath, index] of runIndexBySession) {
538
+ const used = new Set();
539
+ const eps = all.filter((x) => x.sessionPath === sessionPath);
540
+ // Two passes over the session, not one per episode: every exact tool_use
541
+ // join is settled first, so the timestamp fallback can only ever claim a
542
+ // run that no episode was able to prove was its own.
543
+ const runsByEp = new Map();
544
+ for (const item of eps) runsByEp.set(item, exactRunsForEpisode(index, item.ep, used));
545
+ for (const item of eps) {
546
+ runsByEp.get(item).push(...fallbackRunsForEpisode(index, item.ep, used));
547
+ }
548
+ for (const item of eps) {
549
+ const { ep, projectDir } = item;
550
+ const runs = runsByEp.get(item);
551
+ if (runs.length === 0) continue;
552
+ const cat = categorize(ep.text, ep.tools);
553
+ if (!cat) continue;
554
+ // Counterfactual = the priciest model on the episode, i.e. what would
555
+ // have done the work had it not been handed off.
556
+ let mainModel = null;
557
+ let mainRank = -1;
558
+ for (const m of ep.models) {
559
+ const r = modelRank(m);
560
+ if (r > mainRank) { mainRank = r; mainModel = m; }
561
+ }
562
+ for (const run of runs) {
563
+ const runTier = tierForRank(modelRank(run.model));
564
+ // Dropping unresolved model ids is deliberate (see cost.js) — pricing
565
+ // a gateway id as Sonnet would poison every number here. But dropping
566
+ // them SILENTLY is what made a whole tier of rules report zero
567
+ // delegations with no way to tell why, so keep a count to surface.
568
+ if (!isRecognizedModelId(run.model)) {
569
+ unresolvedRuns += 1;
570
+ if (run.model) unresolvedModels.add(run.model);
571
+ }
572
+ if (!runTier || !worthDelegating(runTier, mainRank)) continue;
573
+ const saved = runSaving(run, mainModel);
574
+ bumpDelegated(`${runTier}|${cat.id}|${projectDir}`, run, saved);
575
+ bumpDelegated(`${runTier}|${cat.id}|*`, run, saved);
576
+ // Routing saving: priced against the rule's baseline (before → after),
577
+ // not against whatever the session's priciest model happened to be.
578
+ const rule = ruleForRun(runTier, cat.id, projectDir);
579
+ if (!rule) continue; // no rule routed this run — not our saving to claim
580
+ // Priced below, after this scan's baselines have been written back to
581
+ // the registry — see the note at the ledger write.
582
+ if (!isRecognizedModelId(run.model)) continue;
583
+ ledgerEvents.push({ run, rule, tier: runTier, catId: cat.id, projectDir });
584
+ }
585
+ }
586
+ }
587
+
588
+ // Keep prior dismissed/promoted signatures across rescans.
589
+ const prev = readRouteScan();
590
+ const resolved = new Set(prev?.resolved || []);
591
+
592
+ // Never re-propose a pattern that already has a registered model-fitting
593
+ // rule (a global rule covers the category in every project). Without this,
594
+ // rules that entered the registry outside the promote flow — migrations,
595
+ // future imports — would resurface as candidates forever.
596
+ let registered = [];
597
+ try {
598
+ const { loadModelRules } = await import('./model-rules.js');
599
+ registered = loadModelRules().rules;
600
+ } catch { /* registry unreadable — candidates may repeat until promote */ }
601
+ const hasRule = (g) => registered.some((r) =>
602
+ r.tier === g.tier && r.category === g.category &&
603
+ (r.scope === 'global' || r.project === g.project));
604
+
605
+ // Both languages are computed at scan time and stored on the candidate, so
606
+ // switching `language` later re-renders (and promotes) correctly without
607
+ // waiting for a rescan.
608
+ // The probe-then-commit budget is deliberately NOT baked into this text:
609
+ // model-rules composes it on (composeRuleText), so rules promoted before
610
+ // budgets existed gain the clause too, and the promote preview can never
611
+ // drift from what lands in the file. What the candidate carries is the
612
+ // calibrated budget itself — the user's own thresholds, snapshotted at scan
613
+ // time rather than hardcoded downstream.
614
+ const budgetOf = (g) => ({
615
+ calls: g.tier === 'T2' ? T2_MAX_CALLS + 2 : null,
616
+ out: g.tier === 'T2' ? thresholds.t2Out : thresholds.t1Out,
617
+ });
618
+ // Shared with the seed presets (src/seed-rules.js) through model-rules.js —
619
+ // one template, so a reworded rule cannot mean two different things
620
+ // depending on which producer wrote it.
621
+ const ruleText = (g) => modelRuleBaseText(g, 'ko');
622
+ const ruleTextEn = (g) => modelRuleBaseText(g, 'en');
623
+
624
+ const candidates = [...groups.values()]
625
+ .filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
626
+ .sort((a, b) => b.count - a.count)
627
+ .slice(0, 8)
628
+ .map((g, i) => ({
629
+ id: i + 1,
630
+ signature: `${g.tier}|${g.category}|${g.project}`,
631
+ tier: g.tier,
632
+ category: g.category,
633
+ label: g.label,
634
+ labelEn: g.labelEn,
635
+ agent: g.agent,
636
+ project: g.project,
637
+ // Real session cwd for the project (munged `project` is lossy) — lets
638
+ // `harness promote R<N> --project` write the rule into the project the
639
+ // pattern was detected in, not whatever directory the CLI runs from.
640
+ projectPath: g.projectPath || null,
641
+ count: g.count,
642
+ models: [...g.models],
643
+ example: g.example,
644
+ // Snapshot of the calibrated budget this rule was written against, so
645
+ // the merged T2+T1 rendering in ratchet-model.md can restate it without
646
+ // re-running a scan.
647
+ budget: budgetOf(g),
648
+ // Concentrated in one project dir → project rule; the scan groups by
649
+ // project already, so scope suggestion is per-candidate 'project' unless
650
+ // the same category recurs across 2+ projects (then 'global').
651
+ suggestedScope: 'project',
652
+ rule: ruleText(g),
653
+ ruleEn: ruleTextEn(g),
654
+ }));
655
+
656
+ // Same category appearing in 2+ projects → suggest global for each.
657
+ const catProjects = new Map();
658
+ for (const c of candidates) {
659
+ catProjects.set(c.category, (catProjects.get(c.category) || 0) + 1);
660
+ }
661
+ for (const c of candidates) {
662
+ if ((catProjects.get(c.category) || 0) >= 2) c.suggestedScope = 'global';
663
+ }
664
+
665
+ const cache = {
666
+ scannedAt: new Date().toISOString(),
667
+ days,
668
+ totalEpisodes,
669
+ dataBytes,
670
+ // kept as `easyEpisodes` for statusline/back-compat; now counts T1+T2.
671
+ easyEpisodes: tieredEpisodes,
672
+ thresholds,
673
+ candidates,
674
+ resolved: [...resolved],
675
+ unresolvedRuns,
676
+ unresolvedModels: [...unresolvedModels].slice(0, 5),
677
+ };
678
+ try {
679
+ const dir = stateDir();
680
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
681
+ writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
682
+ } catch {
683
+ // best-effort — scan results are still returned
684
+ }
685
+
686
+ // Continuous update (user requirement): every rescan refreshes promoted
687
+ // model-fitting rules from the new window — recurrence counts, error
688
+ // rates, and rule-health flags — and rewrites their managed blocks.
689
+ try {
690
+ const { refreshModelRules } = await import('./model-rules.js');
691
+ refreshModelRules(episodeStats, delegatedStats, { now: cache.scannedAt });
692
+ } catch { /* registry unwritable — scan result still valid */ }
693
+
694
+ // Ledger last, so savings are priced against the baselines this scan just
695
+ // wrote. Pricing before the refresh made a changed baseline take two scans
696
+ // to show up: the first wrote the new baseline but billed against the old
697
+ // one, and the totals only settled on the second.
698
+ try {
699
+ const { recordDelegationEvents } = await import('./savings-ledger.js');
700
+ const { loadModelRules } = await import('./model-rules.js');
701
+ const fresh = loadModelRules().rules;
702
+ const priced = [];
703
+ for (const e of ledgerEvents) {
704
+ const rule = fresh.find((r) => r.signature === e.rule.signature) || e.rule;
705
+ const baseline = baselineFor(rule, e.tier, e.catId, e.projectDir);
706
+ // Both sides of the comparison must be ids the pricing table really
707
+ // recognizes. A house alias from a company gateway prices as Sonnet by
708
+ // default, which would fabricate a saving against a cheap baseline or
709
+ // erase a real one — worse than showing nothing. Map such ids in
710
+ // profile-map.json's `modelAliases` to bring these runs back in.
711
+ if (!baseline || !isRecognizedModelId(baseline)) continue;
712
+ const usd = runSaving(e.run, baseline);
713
+ if (usd <= 0) continue;
714
+ priced.push({
715
+ key: e.run.path,
716
+ ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
717
+ usd,
718
+ rule: rule.signature,
719
+ from: baseline,
720
+ to: e.run.model,
721
+ });
722
+ }
723
+ recordDelegationEvents(priced);
724
+ } catch {
725
+ // ledger write failure only delays the statusline totals, never the scan
726
+ }
727
+
728
+ return cache;
729
+ }
730
+
731
+ /** Read the cached scan (null when absent/corrupt). */
732
+ export function readRouteScan() {
733
+ try {
734
+ return JSON.parse(readFileSync(routeScanCachePath(), 'utf8'));
735
+ } catch {
736
+ return null;
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Data-driven rescan gate (see constants above). Cheap: one stat() per
742
+ * transcript file (~32 files on measured data) — a few milliseconds.
743
+ */
744
+ export async function shouldRescan(cache, { days = 14 } = {}) {
745
+ if (!cache?.scannedAt) return true;
746
+ const ts = Date.parse(cache.scannedAt);
747
+ if (!Number.isFinite(ts)) return true;
748
+ const age = Date.now() - ts;
749
+ if (age < RESCAN_MIN_INTERVAL_MS) return false;
750
+
751
+ let total = 0;
752
+ let anyNew = false;
753
+ try {
754
+ for (const f of await discoverSessionFiles({ days })) {
755
+ const s = statSync(f.path);
756
+ total += s.size;
757
+ if (s.mtimeMs > ts) anyNew = true;
758
+ }
759
+ } catch {
760
+ return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
761
+ }
762
+ if (!anyNew) return false; // nothing changed → identical scan, skip forever
763
+ // Append-only transcripts: window growth ≈ new data. Files aging out of
764
+ // the window shrink the total, making this estimate conservative.
765
+ const newBytes = Math.max(0, total - (cache.dataBytes || 0));
766
+ if (newBytes >= RESCAN_BIG_DELTA_BYTES) return true;
767
+ return age >= RESCAN_MAX_AGE_MS;
768
+ }
769
+
770
+ /**
771
+ * Human-readable labels for the T0/T1/T2 codes and scope values — used by
772
+ * every user-facing listing so a bare code never appears without its meaning
773
+ * (first-time users can't be expected to know the tier vocabulary).
774
+ */
775
+ export function tierLabel(tier, lang = 'ko') {
776
+ const ko = {
777
+ T2: '단순 작업 — haiku급이면 충분',
778
+ T1: '중간 난도 — sonnet급이면 충분',
779
+ T0: '고난도 — 지금 모델 유지',
780
+ };
781
+ const en = {
782
+ T2: 'simple — haiku-class is enough',
783
+ T1: 'moderate — sonnet-class is enough',
784
+ T0: 'hard — stays on the session model',
785
+ };
786
+ return (lang === 'ko' ? ko : en)[tier] || tier;
787
+ }
788
+
789
+ export function scopeLabel(scope, lang = 'ko') {
790
+ if (lang === 'ko') return scope === 'global' ? '모든 프로젝트(글로벌)' : '이 프로젝트만';
791
+ return scope === 'global' ? 'all projects (global)' : 'this project only';
792
+ }
793
+
794
+ /** Candidates not yet promoted/dismissed. */
795
+ export function openCandidates(cache) {
796
+ if (!cache?.candidates) return [];
797
+ const resolved = new Set(cache.resolved || []);
798
+ return cache.candidates.filter((c) => !resolved.has(c.signature));
799
+ }
800
+
801
+ /**
802
+ * Mark a candidate resolved (promoted or dismissed) so the chip stops and
803
+ * rescans don't resurface it. Returns the candidate or null.
804
+ */
805
+ export function resolveCandidate(id) {
806
+ const cache = readRouteScan();
807
+ if (!cache) return null;
808
+ const cand = (cache.candidates || []).find((c) => c.id === id);
809
+ if (!cand) return null;
810
+ cache.resolved = [...new Set([...(cache.resolved || []), cand.signature])];
811
+ try {
812
+ writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
813
+ } catch {
814
+ return null;
815
+ }
816
+ return cand;
817
+ }
818
+
819
+ /**
820
+ * Statusline helper — cheapest possible check (one small JSON read).
821
+ * Returns `route? #N` for the top open candidate relevant to this project
822
+ * (its own project dir, or a global-scoped suggestion), else null.
823
+ */
824
+ export function routeWarningForStatusline(projectRoot) {
825
+ const cache = readRouteScan();
826
+ if (!cache) return null;
827
+ const open = openCandidates(cache);
828
+ if (open.length === 0) return null;
829
+ const munged = mungeProjectPath(projectRoot || '');
830
+ const hit = open.find((c) => c.project === munged || c.suggestedScope === 'global');
831
+ return hit ? `route? R${hit.id}` : null;
832
+ }