sprag-cli 3.68.0 → 3.70.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.
@@ -28,6 +28,7 @@ import { MIN_VOTES } from '../model-alias.js';
28
28
  import { bedrockDisplayFromId } from '../stdin-payload.js';
29
29
  import { modelFamily } from '../savings-ledger.js';
30
30
  import { shareLegend } from '../model-share.js';
31
+ import { STAR_URL } from '../star-note.js';
31
32
 
32
33
  // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
33
34
  // next to each other — terminal palettes set them with unbalanced perceptual
@@ -1032,6 +1033,13 @@ function buildDelegatedSeg(data, singleLine, c, isIcon, verbose, g, color) {
1032
1033
  };
1033
1034
  }
1034
1035
 
1036
+ /** The one-time star ask (see star-note.js); null unless the CLI passed a chip. */
1037
+ function buildStarSeg(star, c, isIcon) {
1038
+ if (!star || typeof star !== 'object' || !Number.isFinite(star.usd)) return null;
1039
+ const text = `${isIcon ? '⭐ ' : ''}${formatMoney(star.usd)} saved · star ${STAR_URL}`;
1040
+ return `${c(YELLOW)}${text}${c(RESET)}`;
1041
+ }
1042
+
1035
1043
  /**
1036
1044
  * Doc2md chip and headline line. As with routing, they are alternatives: money
1037
1045
  * earns the headline line, and the chip is for what a line would be all frame
@@ -1142,6 +1150,8 @@ export const DEFAULT_SEGMENT_ORDER = [
1142
1150
  // Lifetime, from the savings ledgers. Delegation stays ahead of the cache
1143
1151
  // figure: routing is what the tool is for, the cache number is a brag stat.
1144
1152
  'delegated', 'doc2md',
1153
+ // One-time star ask; renders only while readStarChip returns an object.
1154
+ 'star',
1145
1155
  // Analysis window. These two are the only chips measured over it, so the label
1146
1156
  // sits with them and the three render as one group.
1147
1157
  'hit', 'saved', 'period',
@@ -1182,6 +1192,7 @@ function segmentsNamed(name, s, usageByKey) {
1182
1192
  // the session's own subagent runs.
1183
1193
  case 'delegated': return [...s.delegated, s.subagents];
1184
1194
  case 'doc2md': return s.doc2md;
1195
+ case 'star': return [s.star];
1185
1196
  case 'hit': return [s.hit];
1186
1197
  case 'ttl': return [s.ttl];
1187
1198
  case 'month': return [s.month];
@@ -1287,7 +1298,7 @@ function buildHeadlines(delegated, doc2md, want, eol) {
1287
1298
  * @param {boolean} [opts.verbose=false] - longer layout with labels
1288
1299
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
1289
1300
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
1290
- * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness (a full score with no warning draws only when named here), korean (draws only when named here), model, effort, hit, ttl, month, saved, delegated, doc2md, ctx, period, `subagents` (this session's subagent runs; also rides with `delegated`), `rules` (only when named here), `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
1301
+ * @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness (a full score with no warning draws only when named here), korean (draws only when named here), model, effort, hit, ttl, month, saved, delegated, doc2md, star, ctx, period, `subagents` (this session's subagent runs; also rides with `delegated`), `rules` (only when named here), `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
1291
1302
  * @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
1292
1303
  */
1293
1304
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
@@ -1344,6 +1355,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
1344
1355
  delegated: delegated.chips,
1345
1356
  // The inline chip steps aside when a headline line owns the story.
1346
1357
  doc2md: doc2md.headline ? [] : [doc2md.chip],
1358
+ star: buildStarSeg(data.star, c, isIcon),
1347
1359
  // This session's subagent runs by model family. It is a session figure, so
1348
1360
  // it sits with the routing chips rather than with the lifetime totals.
1349
1361
  subagents: buildSessionDelegationSeg(data.sessionDelegation, c, isIcon, verbose, g),
@@ -181,17 +181,48 @@ function renderCapWarnSection(caps) {
181
181
  return lines;
182
182
  }
183
183
 
184
- export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }, { color } = {}) {
184
+ // Opens the report with what the tool is for, before any warning. A first run
185
+ // (`npx sprag-cli`) has nothing installed yet, so the pitch has to be on screen.
186
+ function renderRoutingSection(routing) {
187
+ if (!routing || typeof routing !== 'object') return [];
188
+ const lines = [];
189
+ lines.push(b(` 🔀 Routing — last ${routing.days}d`));
190
+ lines.push(` ${'─'.repeat(50)}`);
191
+ const total = Number(routing.total) || 0;
192
+ if (total === 0) {
193
+ lines.push(` No Claude Code sessions in the last ${routing.days} days to scan.`);
194
+ lines.push('');
195
+ return lines;
196
+ }
197
+ const easy = Number(routing.easy) || 0;
198
+ const pct = Math.round(easy / total * 100);
199
+ lines.push(` ${easy} of ${total} requests (${pct}%) were lookups, checks or test runs a cheaper model handles.`);
200
+ const cands = Array.isArray(routing.candidates) ? routing.candidates : [];
201
+ if (cands.length === 0) {
202
+ lines.push(' No recurring delegable work yet (a pattern needs 3 repeats). Approved rules apply from the next session.');
203
+ } else {
204
+ for (const c of cands) {
205
+ lines.push(` R${c.id} "${c.labelEn || c.label}" ×${c.count} → delegate to ${c.agent}`);
206
+ }
207
+ lines.push(` Approve one: ${b(`${CLI_NAME} harness promote R${cands[0].id} --global`)} All candidates: ${b(`${CLI_NAME} route-scan`)}`);
208
+ }
209
+ lines.push('');
210
+ return lines;
211
+ }
212
+
213
+ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps, routing }, { color } = {}) {
185
214
  colorOk = color ?? !process.env.NO_COLOR;
186
215
  const lines = [];
187
216
 
188
217
  // Header
189
218
  lines.push('');
190
- lines.push(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
219
+ lines.push(` Sprag — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
191
220
  lines.push(` (${CLI_NAME} v${options.version || ''})`.trimEnd());
192
221
  lines.push(` ${'═'.repeat(50)}`);
193
222
  lines.push('');
194
223
 
224
+ lines.push(...renderRoutingSection(routing));
225
+
195
226
  // Cap warning leads — it's the most time-sensitive signal we can show.
196
227
  if (caps) {
197
228
  lines.push(...renderCapWarnSection(caps));
@@ -52,6 +52,16 @@ export function presetRatchetImportLine() {
52
52
  }
53
53
  export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
54
54
 
55
+ /**
56
+ * The team ratchet: rules the user's team approved, cached by `sprag team sync`
57
+ * and rendered into a tool-owned file. Unlike the others its import is not in
58
+ * the rendered block; it is added only for members, when the file exists.
59
+ */
60
+ export function teamRatchetImportLine() {
61
+ return '@~/.claude/ratchet-team.md';
62
+ }
63
+ export const TEAM_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-team\.md\s*$/m;
64
+
55
65
  /**
56
66
  * Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
57
67
  * file is loaded into every session, so the header stays short.
@@ -72,7 +72,8 @@ export function wilsonLowerBound(errs, n) {
72
72
  // route-scan's pre-calibration defaults. Kept as local constants rather than
73
73
  // imported: route-scan imports this module, and a static back-import would
74
74
  // close the cycle.
75
- const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 } };
75
+ const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 },
76
+ T1H: { calls: null, out: 30000 } };
76
77
 
77
78
  /** The budget a rule is held to: its calibrated one, or the default of its tier. */
78
79
  export function ruleBudget(rule) {
@@ -132,20 +133,27 @@ function shortExample(text) {
132
133
  return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
133
134
  }
134
135
 
135
- // T1 is named through its delegation agent only when that file exists; without
136
- // it the literal `model: sonnet` stays so output on such machines is unchanged.
137
- function t1Target(root, ko) {
138
- if (resolveAgentName('sonnet', root) === null) return null;
139
- return (ko ? agentPhrase : agentPhraseEn)('sonnet', { root });
136
+ // T1 (and T1H) is named through its delegation agent only when that file
137
+ // exists; without it the literal `model: sonnet` / `model: opus` stays so
138
+ // output on such machines is unchanged.
139
+ function t1Target(root, ko, tier = 'sonnet') {
140
+ if (resolveAgentName(tier, root) === null) return null;
141
+ return (ko ? agentPhrase : agentPhraseEn)(tier, { root });
140
142
  }
141
143
 
142
144
  export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
143
145
  if (lang === 'ko') {
146
+ if (g.tier === 'T1H') {
147
+ return `"${g.label}" 고난도 구현 요청(예: "${shortExample(g.example)}")은 Fable·Mythos 세션에 한해 ${t1Target(root, true, 'opus') ?? 'model: opus'}에 위임`;
148
+ }
144
149
  return g.tier === 'T2'
145
150
  ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
146
151
  : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
147
152
  }
148
153
  const example = g.exampleEn || g.example;
154
+ if (g.tier === 'T1H') {
155
+ return `Heavy "${g.labelEn}" implementation requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false, 'opus') ?? 'a model: opus subagent'}, in Fable/Mythos sessions only`;
156
+ }
149
157
  return g.tier === 'T2'
150
158
  ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
151
159
  : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
@@ -272,13 +280,27 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
272
280
  ' 알려 주는 작업은 하위 티어가 안전하다. 검증 게이트가 품질 하한이 된다.',
273
281
  ' 틀려도 조용한 작업(진단, 근본 원인 판정, 보안, 설계)은 티어를 내리지',
274
282
  ' 않는다. 싼 모델은 결정을 옮겨 적을 뿐, 결정을 내리지 못한다.',
283
+ '- **effort**: 모델 티어와 다른 축이다. 설치된 서브에이전트는 검증 형태에 따라',
284
+ ' effort가 고정되어 있다. 명령 출력이나 파일 대조로 검증되는 위임(조회·명령',
285
+ ' 실행·정형 변환을 맡는 haiku 에이전트)은 low, 확정된 설계를 테스트에 맞춰',
286
+ ' 구현하는 위임(sonnet 워커)은 medium, 범위가 큰 구현·디버깅 위임(opus 워커)은',
287
+ ' high다. 메인 세션의 effort는 바꾸지 않고,',
288
+ ' CLAUDE_CODE_EFFORT_LEVEL은 서브에이전트의 effort를 덮어쓰므로 설정하지 않는다.',
289
+ '- **고난도 구현(T1H)**: 설계는 메인이 확정하고, 범위가 크거나 sonnet 이 틀린',
290
+ ' 적 있는 구현·디버깅은 opus 에 위임한다. Fable·Mythos 세션에서만 적용되며,',
291
+ ' 설계 판단과 되돌릴 수 없는 작업은 메인 모델에 남긴다.',
275
292
  '- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
276
293
  ' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
277
294
  ' 보내고, 읽는 영역이 다른 작업만 나눈다.',
278
295
  implementationDelegationRule('claude', lang),
279
296
  '- **에스컬레이션**: 같은 에러 2회 반복, 답이 복수 후보로 갈림, 서브에이전트가',
280
- ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 대응 순서는',
281
- ' ① 재지시 후 재시도 → ② 한 티어 상향 → ③ 메인 모델이 직접 수습.',
297
+ ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 어느 축을 올릴지는 실패',
298
+ ' 종류로 정한다. 파일을 읽지 않았거나 테스트를 돌리지 않았거나 상한에 걸려',
299
+ ' 멈춘 누락형 실패는 같은 모델에서 effort를 한 단계 올려 한 번만 재시도하고,',
300
+ ' 컨텍스트를 다 보고도 틀린 오판형 실패는 모델을 한 티어 올린다. 순서는',
301
+ ' ① 재지시 후 재시도(누락형이면 effort 한 단계 위) → ② 한 티어 상향(오판형)',
302
+ ' → ③ 메인 모델이 직접 수습. effort를 올릴 때는 에이전트 파일을 고치지 않고',
303
+ ' Agent 도구 호출의 effort 인자에 한 단계 위 값(low → medium → high)을 넘긴다.',
282
304
  '',
283
305
  '## Rules',
284
306
  '',
@@ -323,14 +345,30 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
323
345
  ' the verification gate is the quality floor. Never drop the tier on work',
324
346
  ' that fails silently (diagnosis, root-cause calls, security posture,',
325
347
  ' design). Cheap models transcribe decisions; they do not make them.',
348
+ '- **Effort**: a separate axis from the tier. The installed subagents pin it',
349
+ ' by how their work is verified: low where the check is command output or a',
350
+ ' file comparison (the haiku agents for lookups, command runs and mechanical',
351
+ ' conversion), medium where a fixed design is implemented against tests (the',
352
+ ' sonnet worker), high for large implementation or debugging (the opus',
353
+ ' worker). Leave the main session\'s effort alone, and never set',
354
+ ' CLAUDE_CODE_EFFORT_LEVEL: it overrides every subagent\'s own value.',
355
+ '- **Heavy implementation (T1H)**: the main model fixes the design; large-scope',
356
+ ' implementation or debugging, or work sonnet has already got wrong, goes to',
357
+ ' opus. Applies in Fable/Mythos sessions only; design judgement and',
358
+ ' irreversible work stay on the main model.',
326
359
  '- **Batching**: a subagent\'s dominant cost is context ingestion, not',
327
360
  ' generation. Bundle tasks that read the same files into one agent; split',
328
361
  ' only when the areas they read differ.',
329
362
  implementationDelegationRule('claude', lang),
330
363
  '- **Escalation**: hand off immediately on the same error twice, an answer',
331
364
  ' splitting into multiple candidates, or a subagent spinning without asking',
332
- ' questions. Order: ① re-instruct and retry → ② move up one tier → ③ the',
333
- ' main model takes over directly.',
365
+ ' questions. Which axis moves depends on the failure: an omission (a file',
366
+ ' not read, tests not run, a cap hit mid-task) retries once on the same',
367
+ ' model one effort step up; a wrong answer with the full context in hand',
368
+ ' moves up one tier. Order: ① re-instruct and retry (effort +1 on an',
369
+ ' omission) → ② move up one tier (on a wrong answer) → ③ the main model',
370
+ ' takes over directly. To raise effort, pass the next level (low → medium →',
371
+ ' high) in the Agent call\'s effort argument instead of editing the agent file.',
334
372
  '',
335
373
  '## Rules',
336
374
  '',
@@ -363,13 +401,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
363
401
  const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
364
402
  return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
365
403
  };
366
- // Merged T2+T1 rules carry two caps, so they state both once rather than
404
+ // Merged rules carry one cap per tier, so they state them once rather than
367
405
  // repeating the whole stop-condition per tier.
368
- const mergedBudget = (t2, t1) => ko
369
- ? `상한 haiku ${budgetCapPhrase(t2, 'ko')} / sonnet ${budgetCapPhrase(t1, 'ko')}`
370
- : `cap haiku ${budgetCapPhrase(t2, 'en')} / sonnet ${budgetCapPhrase(t1, 'en')}`;
406
+ const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
407
+ const mergedBudget = (tiers) => (ko ? '상한 ' : 'cap ') + tiers
408
+ .map((r) => `${TIER_MODEL[r.tier]} ${budgetCapPhrase(r, ko ? 'ko' : 'en')}`).join(' / ');
371
409
 
372
- // A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
410
+ // A category can carry a T2 (haiku), a T1 (sonnet) and a T1H (opus) rule. Tier is
373
411
  // only known after an episode finishes, so two separate bullets give the
374
412
  // reading LLM no way to pick one at request time — merge such pairs into a
375
413
  // single conditional rule with explicit request-time criteria (default to
@@ -384,38 +422,50 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
384
422
  // text stored on the rule is whatever wording and language were current when
385
423
  // it was promoted, which is how an English line sat in a Korean file. A rule
386
424
  // missing what the template needs still falls back to its stored text.
387
- const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1') && r.label && (ko || r.labelEn)
425
+ const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1' || r.tier === 'T1H') && r.label && (ko || r.labelEn)
388
426
  ? modelRuleBaseText(r, lang, { root }) : r.rule);
427
+ const DELEGATION_TIERS = ['T2', 'T1', 'T1H'];
389
428
  for (const group of byCategory.values()) {
390
- const t2 = group.find((r) => r.tier === 'T2');
391
- const t1 = group.find((r) => r.tier === 'T1');
429
+ const picked = DELEGATION_TIERS.map((t) => group.find((r) => r.tier === t)).filter(Boolean);
392
430
  // A second rule of a tier the line already covers (two global rules of one
393
431
  // category, promoted from different projects) says nothing the line does
394
432
  // not, so it adds no line of its own. Its review flag and its numbers stay
395
433
  // visible on the line that speaks for it.
396
- const extras = t2 || t1 ? group.filter((r) => r !== t2 && r !== t1 && (r.tier === 'T2' || r.tier === 'T1')) : [];
397
- const unknown = group.filter((r) => r !== t2 && r !== t1 && !extras.includes(r));
434
+ const extras = picked.length ? group.filter((r) => !picked.includes(r) && DELEGATION_TIERS.includes(r.tier)) : [];
435
+ const unknown = group.filter((r) => !picked.includes(r) && !extras.includes(r));
398
436
  const extraHealth = extras.map(healthOf).join('');
399
437
  const extraStats = extras.map((r) => ` / +${r.tier} ${statsOf(r)}`).join('');
400
- if (t2 && t1) {
438
+ if (picked.length >= 2) {
401
439
  // Seeded rules carry a localized example; scan-produced ones only have the
402
440
  // user's own prompt, so exampleEn is a preference, not a requirement.
403
441
  const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
404
- // One line per category, stating only what differs between the two tiers.
442
+ const target = (r) => {
443
+ if (r.tier === 'T2') return (ko ? agentPhrase : agentPhraseEn)(r.agent, { root });
444
+ const name = r.tier === 'T1H' ? 'opus' : 'sonnet';
445
+ return t1Target(root, ko, name) ?? `model: ${name}`;
446
+ };
447
+ // One line per category, stating only what differs between the tiers.
405
448
  // The exceptions this used to spell out (design judgement, irreversible
406
449
  // work) are identical in every rule and now sit in the header's shared
407
450
  // clauses, so a rule the model re-reads on every request is short enough to
408
451
  // match at a glance.
452
+ const when = (r, first) => {
453
+ if (first) return ko ? '기본' : 'by default';
454
+ if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
455
+ return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
456
+ };
457
+ const parts = picked.map((r, i) => (ko
458
+ ? `${when(r, i === 0)} ${target(r)}(예: "${ex(r)}")`
459
+ : `${i === 0 ? `${target(r)} ${when(r, true)}` : `${target(r)} ${when(r, false)}`} (e.g. "${ex(r)}")`));
460
+ const head = picked[0];
409
461
  const rule = ko
410
- ? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
411
- `여러 단계·여러 파일이 얽히면 ${t1Target(root, true) ?? 'model: sonnet'}(예: "${ex(t1)}"). ` +
412
- mergedBudget(t2, t1)
413
- : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
414
- `${t1Target(root, false) ?? 'model: sonnet'} when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
415
- mergedBudget(t2, t1);
416
- lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
417
- } else if (t2 || t1) {
418
- const r = t2 || t1;
462
+ ? `"${head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`
463
+ : `"${head.labelEn || head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`;
464
+ const health = picked.map(healthOf).join('');
465
+ const stats = picked.map((r) => `${r.tier} ${statsOf(r)}`).join(' / ');
466
+ lines.push(`- ${rule}${health}${extraHealth} <!-- ${stats}${extraStats} -->`);
467
+ } else if (picked.length === 1) {
468
+ const r = picked[0];
419
469
  lines.push(`- ${composeRuleText(baseTextOf(r), r, lang)}${healthOf(r)}${extraHealth} <!-- ${statsOf(r)}${extraStats} -->`);
420
470
  }
421
471
  for (const r of unknown) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
package/src/route-caps.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * rule clears it, so a cap never outlives the turn it was stated for.
14
14
  *
15
15
  * State: <stateDir>/route-caps.json
16
- * { sessions: { [session_id]: { ts, T2?: { calls, out }, T1?: { calls, out } } } }
16
+ * { sessions: { [session_id]: { ts, T2?: { calls, out }, T1?: { calls, out }, T1H?: { calls, out } } } }
17
17
  */
18
18
 
19
19
  import { readFileSync } from 'node:fs';
@@ -49,7 +49,7 @@ export function recordRouteCaps(sessionId, caps, now = Date.now()) {
49
49
  if (typeof sessionId !== 'string' || sessionId === '') return false;
50
50
  const state = load();
51
51
  const next = {};
52
- for (const tier of ['T2', 'T1']) {
52
+ for (const tier of ['T2', 'T1', 'T1H']) {
53
53
  if (validBudget(caps?.[tier])) next[tier] = { calls: caps[tier].calls ?? null, out: caps[tier].out };
54
54
  }
55
55
  const has = Object.keys(next).length > 0;
@@ -69,6 +69,6 @@ export function routeCapsFor(sessionId, now = Date.now()) {
69
69
  const s = load().sessions[sessionId];
70
70
  if (!s || !Number.isFinite(s.ts) || now - s.ts > TTL_MS) return null;
71
71
  const out = {};
72
- for (const tier of ['T2', 'T1']) if (validBudget(s[tier])) out[tier] = s[tier];
72
+ for (const tier of ['T2', 'T1', 'T1H']) if (validBudget(s[tier])) out[tier] = s[tier];
73
73
  return Object.keys(out).length ? out : null;
74
74
  }
@@ -126,8 +126,8 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
126
126
  * and saves nothing. Outside such a host an unset variable selects Claude
127
127
  * Code's built-in default for the tier, so nothing is reported unreachable.
128
128
  */
129
- const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL' };
130
- const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet' };
129
+ const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL', T1H: 'ANTHROPIC_DEFAULT_OPUS_MODEL' };
130
+ const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
131
131
 
132
132
  export function unreachableTiers(env = process.env) {
133
133
  if (env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST !== '1') return [];
@@ -205,7 +205,8 @@ export function routeMatch(text, { rules, sessionRank = null, root, env = proces
205
205
  const nearest = [...reachable].sort((a, b) => scopeRank(b) - scopeRank(a));
206
206
  const t2 = nearest.find((r) => r.tier === 'T2');
207
207
  const t1 = nearest.find((r) => r.tier === 'T1');
208
- return t2 || t1 ? { cat, t2, t1 } : null;
208
+ const t1h = nearest.find((r) => r.tier === 'T1H');
209
+ return t2 || t1 || t1h ? { cat, t2, t1, t1h } : null;
209
210
  }
210
211
 
211
212
  /** The budgets a match states, by tier: what the delegation guard repeats to the subagent. */
@@ -214,6 +215,7 @@ export function matchCaps(match) {
214
215
  const caps = {};
215
216
  if (match.t2) caps.T2 = ruleBudget(match.t2);
216
217
  if (match.t1) caps.T1 = ruleBudget(match.t1);
218
+ if (match.t1h) caps.T1H = ruleBudget(match.t1h);
217
219
  return caps;
218
220
  }
219
221
 
@@ -248,10 +250,10 @@ function unreachableNotice(label, tiers, ko) {
248
250
  export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match, env } = {}) {
249
251
  const found = match === undefined ? routeMatch(text, { rules, sessionRank, root, env }) : match;
250
252
  if (!found) return null;
251
- const { cat, t2, t1 } = found;
253
+ const { cat, t2, t1, t1h } = found;
252
254
  const ko = lang === 'ko';
253
255
  const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
254
- if (!t2 && !t1 && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
256
+ if (!t2 && !t1 && !t1h && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
255
257
  // `root` is threaded through to agentPhrase, which names the subagent only
256
258
  // when its .md actually exists on this machine. A caller that pins it can
257
259
  // therefore get a deterministic phrase; a test that does not pin it reads
@@ -262,21 +264,28 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
262
264
  // T1 is named through its delegation agent only when that file exists here;
263
265
  // otherwise the literal tier string stays, so output without agents is unchanged.
264
266
  const t1Root = root === undefined ? undefined : root;
265
- const t1Named = resolveAgentName('sonnet', t1Root) !== null;
266
- const t1Phrase = t1Named ? phrase('sonnet') : 'model: sonnet';
267
+ const tierPhrase = (name) => (resolveAgentName(name, t1Root) !== null ? phrase(name) : `model: ${name}`);
268
+ const picked = [t2, t1, t1h].filter(Boolean);
267
269
  let target;
268
- if (t2 && t1) {
270
+ if (picked.length >= 2) {
271
+ const named = (r) => (r.tier === 'T2' ? phrase(r.agent) : tierPhrase(r.tier === 'T1H' ? 'opus' : 'sonnet'));
272
+ const when = (r, i) => {
273
+ if (i === 0) return null;
274
+ if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
275
+ return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
276
+ };
269
277
  target = ko
270
- ? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 ${t1Phrase}`
271
- : `${phrase(t2.agent)} by default, ${t1Phrase} when it spans multiple steps or file edits`;
278
+ ? picked.map((r, i) => `${when(r, i) ?? '기본'} ${named(r)}`).join(', ')
279
+ : picked.map((r, i) => (i === 0 ? `${named(r)} by default` : `${named(r)} ${when(r, i)}`)).join(', ');
272
280
  } else {
273
- const only = t2 || t1;
274
- target = ko ? phrase(only.agent) : phrase(only.agent);
281
+ const only = picked[0];
282
+ target = only.tier === 'T1H' ? tierPhrase('opus') : phrase(only.agent);
275
283
  }
276
284
 
277
- const caps = [t2, t1].filter(Boolean).map((r) => budgetCapPhrase(r, lang));
278
- const capText = caps.length === 2
279
- ? (ko ? `상한 haiku ${caps[0]} / sonnet ${caps[1]}` : `cap haiku ${caps[0]} / sonnet ${caps[1]}`)
285
+ const MODEL_OF = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
286
+ const caps = picked.map((r) => budgetCapPhrase(r, lang));
287
+ const capText = picked.length >= 2
288
+ ? (ko ? '상한 ' : 'cap ') + picked.map((r, i) => `${MODEL_OF[r.tier]} ${caps[i]}`).join(' / ')
280
289
  : (ko ? `상한 ${caps[0]}` : `cap ${caps[0]}`);
281
290
 
282
291
  // The marker line is the one in ratchet-model.md, so the user sees the same
package/src/route-scan.js CHANGED
@@ -44,6 +44,10 @@ export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
44
44
  export const T1_MAX_MUTATING = 6;
45
45
  export const T1_MAX_ERRORS = 1;
46
46
  export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
47
+ // T1H (opus, Fable sessions only): too heavy for sonnet but still bounded.
48
+ export const T1H_MAX_ERRORS = 3;
49
+ export const T1H_MAX_MUTATING = 20;
50
+ export const T1H_OUT_CLAMP = [15000, 60000]; // default 30000 pre-calibration
47
51
  export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
48
52
  export const T0_MIN_MUTATING = 7;
49
53
  // Episodes below this output size carry no delegable work (conversational
@@ -65,7 +69,14 @@ export const MIN_DELEGABLE_OUT = 100;
65
69
  // questions that merely contained "statusline". None gained a hint. Bare
66
70
  // '리뷰' and '보안' are left out on purpose: they are product nouns in ordinary
67
71
  // status checks ("리뷰 허브 배치 확인"), so only the request-shaped forms count.
68
- export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인\s*(?:분석|파악|확인|규명|조사)|원인을?\s*찾|버그|개선할|개선점|개선\s*(?:사항|방안)|검토해보|검수|코드\s*리뷰|리뷰\s*해|취약|보안\s*(?:점검|검토|감사|리뷰|이슈)|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge|root cause|code review|security (?:review|audit)|vulnerabilit/i;
72
+ //
73
+ // 2026-10-18: production-database work and a user's go-ahead ("운영디비 승인할게
74
+ // 너가 진행해줘", "승인할게 너가 직접 실행해줘") read as light run episodes and
75
+ // became Opus (T1H) candidates. Measured on 6,473 local prompts over 90 days:
76
+ // the two term groups catch 7 + 12 prompts, of which 4 were not already
77
+ // escalated, all of them approvals or production-DB asks. Bare '승인' is left
78
+ // out: "승인된 룰" is a product noun in sprag's own ratchet flow.
79
+ export const ESCALATE_RE = /운영\s*(?:db|디비|데이터베이스|서버|환경)|프로덕션|\bprod(?:uction)?\s*(?:db|database|server|env)|승인\s*(?:할게|했어|했으니|했습니다|합니다|해줄게|완료)|\bapproved?\b|설계|아키텍처|리팩토링|원인\s*(?:분석|파악|확인|규명|조사)|원인을?\s*찾|버그|개선할|개선점|개선\s*(?:사항|방안)|검토해보|검수|코드\s*리뷰|리뷰\s*해|취약|보안\s*(?:점검|검토|감사|리뷰|이슈)|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge|root cause|code review|security (?:review|audit)|vulnerabilit/i;
69
80
 
70
81
  // Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
71
82
  // 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
@@ -412,32 +423,30 @@ const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(
412
423
  * Falls back to mid-clamp defaults when the sample is too small to trust.
413
424
  */
414
425
  export function calibrateThresholds(episodeOuts) {
415
- if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
426
+ if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, t1hOut: 30000, calibrated: false };
416
427
  const sorted = [...episodeOuts].sort((a, b) => a - b);
417
428
  return {
418
429
  t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
419
430
  t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
431
+ t1hOut: clamp(percentile(sorted, 0.95), T1H_OUT_CLAMP),
420
432
  calibrated: true,
421
433
  };
422
434
  }
423
435
 
424
436
  /**
425
- * Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
437
+ * Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1H'|'T1'|'T2',
426
438
  * or null when the episode carries nothing delegable. Order matters: hard
427
439
  * evidence (errors, heavy mutation, big output, judgement keywords) wins
428
440
  * before any cheap-band check.
429
441
  */
430
442
  export function tierOf(ep, category, th) {
431
443
  if (ep.out < MIN_DELEGABLE_OUT) return null;
432
- if (
433
- ep.errors >= T0_MIN_ERRORS ||
434
- ep.mutating >= T0_MIN_MUTATING ||
435
- ep.out > th.t1Out ||
436
- ESCALATE_RE.test(ep.text)
437
- ) return 'T0';
444
+ if (ESCALATE_RE.test(ep.text)) return 'T0';
438
445
  if (!category || ep.delegated > 0) return 'T0';
439
446
  if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
440
447
  if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
448
+ const t1hOut = th.t1hOut ?? 30000;
449
+ if (ep.errors <= T1H_MAX_ERRORS && ep.mutating <= T1H_MAX_MUTATING && ep.out <= t1hOut) return 'T1H';
441
450
  return 'T0';
442
451
  }
443
452
 
@@ -611,13 +620,13 @@ export async function runRouteScan({ days = 14 } = {}) {
611
620
  const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
612
621
  // Same rank gate as the candidate path below — a denominator counting
613
622
  // episodes that can't produce a rule would skew that rule's error rate.
614
- if ((shapeTier === 'T1' || shapeTier === 'T2') && worthDelegating(shapeTier, epRank)) {
623
+ if ((shapeTier === 'T1' || shapeTier === 'T2' || shapeTier === 'T1H') && worthDelegating(shapeTier, epRank)) {
615
624
  bumpStats(`${shapeTier}|${cat.id}|${projectDir}`, ep);
616
625
  bumpStats(`${shapeTier}|${cat.id}|*`, ep);
617
626
  }
618
627
  }
619
628
  const tier = tierOf(ep, cat, thresholds);
620
- if (tier !== 'T1' && tier !== 'T2') continue;
629
+ if (tier !== 'T1' && tier !== 'T2' && tier !== 'T1H') continue;
621
630
  if (!worthDelegating(tier, epRank)) continue;
622
631
  tieredEpisodes += 1;
623
632
  const key = `${tier}|${cat.id}|${projectDir}`;
@@ -626,7 +635,7 @@ export async function runRouteScan({ days = 14 } = {}) {
626
635
  category: cat.id,
627
636
  label: cat.label,
628
637
  labelEn: cat.labelEn,
629
- agent: tier === 'T2' ? cat.agent : 'sonnet',
638
+ agent: tier === 'T2' ? cat.agent : tier === 'T1H' ? 'opus' : 'sonnet',
630
639
  project: projectDir,
631
640
  projectPath: '',
632
641
  count: 0,
@@ -653,7 +662,7 @@ export async function runRouteScan({ days = 14 } = {}) {
653
662
  // where a promoted rule's real success rate lives, so they get their own
654
663
  // pass: join each episode to the subagent transcripts it spawned, and file
655
664
  // the outcome under the tier that run's model represents — a haiku run is a
656
- // T2 rule firing, a sonnet run a T1 one. Runs that were not a downgrade
665
+ // T2 rule firing, a sonnet run a T1 one, an opus run (Fable session) a T1H one. Runs that were not a downgrade
657
666
  // (same tier or higher) carry no delegation saving and are skipped.
658
667
  const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
659
668
  let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
@@ -774,7 +783,7 @@ export async function runRouteScan({ days = 14 } = {}) {
774
783
  // time rather than hardcoded downstream.
775
784
  const budgetOf = (g) => ({
776
785
  calls: g.tier === 'T2' ? T2_MAX_CALLS + 2 : null,
777
- out: g.tier === 'T2' ? thresholds.t2Out : thresholds.t1Out,
786
+ out: g.tier === 'T2' ? thresholds.t2Out : g.tier === 'T1H' ? thresholds.t1hOut : thresholds.t1Out,
778
787
  });
779
788
  // Shared with the seed presets (src/seed-rules.js) through model-rules.js —
780
789
  // one template, so a reworded rule cannot mean two different things
@@ -945,7 +954,7 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
945
954
  }
946
955
 
947
956
  /**
948
- * Human-readable labels for the T0/T1/T2 codes and scope values — used by
957
+ * Human-readable labels for the T0/T1H/T1/T2 codes and scope values — used by
949
958
  * every user-facing listing so a bare code never appears without its meaning
950
959
  * (first-time users can't be expected to know the tier vocabulary).
951
960
  */
@@ -953,11 +962,13 @@ export function tierLabel(tier, lang = 'ko') {
953
962
  const ko = {
954
963
  T2: '단순 작업, haiku급이면 충분',
955
964
  T1: '중간 난도, sonnet급이면 충분',
965
+ T1H: '고난도 구현, opus급이면 충분 (Fable 세션 전용)',
956
966
  T0: '고난도, 지금 모델 유지',
957
967
  };
958
968
  const en = {
959
969
  T2: 'simple — haiku-class is enough',
960
970
  T1: 'moderate — sonnet-class is enough',
971
+ T1H: 'heavy, opus-class is enough (Fable sessions only)',
961
972
  T0: 'hard — stays on the session model',
962
973
  };
963
974
  return (lang === 'ko' ? ko : en)[tier] || tier;
@@ -3,7 +3,7 @@ import { isAbsolute, resolve } from 'node:path';
3
3
  import { loadModelRules, ruleBudget } from './model-rules.js';
4
4
  import { findProjectRoot } from './harness.js';
5
5
 
6
- export const SHARED_RULE_TIERS = ['T2', 'T1'];
6
+ export const SHARED_RULE_TIERS = ['T2', 'T1', 'T1H'];
7
7
  const CATEGORIES = new Set(['paste', 'translate', 'explore', 'read', 'check', 'run']);
8
8
 
9
9
  /** A root as a real path, so a symlinked checkout or macOS /var names one directory once. */
@@ -0,0 +1,62 @@
1
+ /**
2
+ * star-note — a one-time "star the repo" ask in the statusline.
3
+ *
4
+ * The package is downloaded thousands of times a month and starred a handful
5
+ * of times: all of the value lands in the statusline, so users never see the
6
+ * repository. This asks once, at the moment the value is visible — when the
7
+ * savings sprag itself produced (routing + doc2md) first reach $10.
8
+ *
9
+ * Once, because a chip that keeps asking is an advertisement. For 24 hours,
10
+ * which is long enough to be seen across a day of sessions and short enough
11
+ * not to linger. CTS_NO_NOTE=1 suppresses it, the same opt-out as every
12
+ * other one-time note.
13
+ */
14
+
15
+ import { createRequire } from 'node:module';
16
+ import { delegationSavedTotals } from './savings-ledger.js';
17
+ import { noteShownAt, markNoteShown } from './first-run-note.js';
18
+ import { userDataDir } from './paths.js';
19
+ import { debug } from './debug.js';
20
+
21
+ const require = createRequire(import.meta.url);
22
+
23
+ export const STAR_THRESHOLD_USD = 10;
24
+ export const STAR_WINDOW_MS = 24 * 60 * 60 * 1000;
25
+ export const STAR_URL = 'github.com/rootstudioyaml/sprag';
26
+
27
+ /** Lifetime dollars saved by routing and doc2md; an unreadable ledger counts as 0. */
28
+ export function toolSavedUsd(now = Date.now(), dir = userDataDir()) {
29
+ let total = 0;
30
+ try {
31
+ total += Number(delegationSavedTotals(now, dir).total) || 0;
32
+ } catch (e) {
33
+ debug('star-note:routing', e);
34
+ }
35
+ try {
36
+ total += Number(require('./doc2md-ledger.cjs').doc2mdSavedTotals(dir).total) || 0;
37
+ } catch (e) {
38
+ debug('star-note:doc2md', e);
39
+ }
40
+ return total;
41
+ }
42
+
43
+ /** `{ usd, shownAt }` while the chip should show, else null. Never throws. */
44
+ export function readStarChip({ now = Date.now(), dir = userDataDir(), savedUsd } = {}) {
45
+ try {
46
+ if (process.env.CTS_NO_NOTE === '1') return null;
47
+ const shownAt = noteShownAt('star');
48
+ if (shownAt === null) {
49
+ const usd = savedUsd ?? toolSavedUsd(now, dir);
50
+ if (!(usd >= STAR_THRESHOLD_USD)) return null;
51
+ markNoteShown('star', now);
52
+ return { usd, shownAt: now };
53
+ }
54
+ if (now - shownAt < STAR_WINDOW_MS) {
55
+ return { usd: savedUsd ?? toolSavedUsd(now, dir), shownAt };
56
+ }
57
+ return null;
58
+ } catch (e) {
59
+ debug('star-note', e);
60
+ return null;
61
+ }
62
+ }