sprag-cli 3.63.0 → 3.65.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.
@@ -2,13 +2,14 @@ import { loadConfig, saveConfig } from '../config.js';
2
2
  import { configureCodexHooks } from '../codex-installer.js';
3
3
  import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule,
4
4
  validateCodexSharedTarget, codexSharedTargets, codexDelegateMinContext, DEFAULT_DELEGATE_MIN_CONTEXT,
5
- autoCodexSharedTargets, codexDelegateEnabled } from '../codex-delegation.js';
5
+ autoCodexSharedTargets, codexDelegateEnabled, codexImplementationHint } from '../codex-delegation.js';
6
6
  import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
7
7
  import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
8
- import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
8
+ import { codexRuleHealth, codexRulesInReview, codexSharedRuleHealth } from '../codex-rule-health.js';
9
9
  import { signedUsd } from '../money.js';
10
10
  import { codexProviderName, codexModelName } from '../agent.js';
11
11
  import { loadSharedModelRules, sharedModelRulesForProject, SHARED_RULE_TIERS } from '../shared-model-rules.js';
12
+ import { codexDelegationDiagnostics } from '../codex-delegation-diagnostics.js';
12
13
 
13
14
  // Starts from the working directory, as the hooks do, so a project policy
14
15
  // stored under either agent's project root is counted.
@@ -22,6 +23,10 @@ function printShared(cfg, { details = false } = {}) {
22
23
  if (details) {
23
24
  for (const r of policies) console.log(`shared ${r.tier} ${r.category} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}`);
24
25
  for (const t of targets) console.log(`${t.provider} | ${t.from} | ${t.tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}`);
26
+ for (const { rule, health, provider: source, from, model } of codexSharedRuleHealth({ root: process.cwd() })) {
27
+ console.log(`shared ${rule.tier} ${rule.category} | ${source} | ${from} -> ${model} | measured x${health.runs}, err ${Math.round(health.rate * 100)}%, saved ${signedUsd(health.saved, 4)}`
28
+ + (health.status === 'review' ? ` | rule-health: ${health.errs}/${health.runs} runs failed; review the mapping or pause with sprag delegate shared off --agent codex` : ''));
29
+ }
25
30
  }
26
31
  // A mapping routes only sessions on the parent model it names, so a tier
27
32
  // mapped for another model still leaves the configured model unrouted. A tier
@@ -57,6 +62,15 @@ export function run({ args, getArg, hasFlag, root }) {
57
62
  }
58
63
  }
59
64
  const cfg = loadConfig();
65
+ if (sub === 'doctor') {
66
+ const report = codexDelegationDiagnostics({ sessionId: getArg('--session'), model: getArg('--from') || undefined });
67
+ console.log(JSON.stringify(report, null, 2));
68
+ return;
69
+ }
70
+ if (sub === 'implementation') {
71
+ console.log(codexImplementationHint({ model: getArg('--from'), provider: getArg('--provider'), sessionId: getArg('--session'), cfg }));
72
+ return;
73
+ }
60
74
  if (sub === 'shared') {
61
75
  const action = args[2] || 'status';
62
76
  if (!['status', 'on', 'off', 'map', 'unmap', 'min-context', 'auto'].includes(action)) {
@@ -25,7 +25,9 @@ Usage: sprag <command> --agent codex [options]
25
25
  handoff [--cwd path] Write Git state and Codex usage to HANDOFF-*.md
26
26
  saved [reset|undo] Show, reset or restore the saved-money counters
27
27
  delegate on|off|status Native model routing and SubagentStart guidance
28
+ delegate doctor Local CLI features, model catalog and matched child activity
28
29
  delegate model <model> Default target for spawns without an explicit model
30
+ delegate implementation After design: resolve T1 with --from, --provider, --session
29
31
  delegate rules Codex rules and shared delegation policies
30
32
  delegate shared on|off|status Reuse approved policies; keep original scopes
31
33
  delegate shared map T1|T2 Map a tier: --from <parent> --model <target>
@@ -42,6 +44,7 @@ Usage: sprag <command> --agent codex [options]
42
44
  harness init|uninit|check|promote|list|rm|prune
43
45
  Rules in Codex AGENTS.md and ratchet files
44
46
  korean / cohesion / doc2md Writing guidance and document conversion
47
+ doc2md savings Codex document estimates; --format json for reasons and totals
45
48
  mode Shared label/color preferences
46
49
  install [--no-panel] Register hooks and global harness; trust via /hooks
47
50
  uninstall Remove global integration; keep rules and shared state
@@ -141,6 +144,10 @@ export async function run({ args, version }) {
141
144
  console.log(`Hooks: ${report.hooks.error || `${report.hooks.events.filter((hook) => hook.registered).length}/${report.hooks.events.length} registered; trust unknown`}`);
142
145
  for (const hook of report.hooks.events) console.log(` ${hook.event}: ${hook.registered ? 'registered' : 'missing/outdated; run sprag install --agent codex'}`);
143
146
  if (report.config.hooksDisabled) console.log('WARNING: user config disables hooks. Review features.hooks in Codex config.');
147
+ if (report.config.agentsDisabled) console.log('WARNING: user config disables agent tools. Review agents.enabled and features.multi_agent in Codex config.');
148
+ console.log(`Delegation: ${report.delegation.settled} completed, ${report.delegation.bound} bound, ${report.delegation.pending} pending, ${report.delegation.unclaimed} unclaimed offers (session)`);
149
+ console.log(`Agent tools: ${report.delegation.tools}`);
150
+ if (report.delegation.offers && !report.delegation.settled && !report.delegation.bound) console.log('Routing hints have no matched child. Check native subagent tool availability in the active session; preference and hook registration do not prove execution.');
144
151
  for (const [scope, harness] of Object.entries(report.harness)) console.log(`Harness ${scope}: ${harness.error ? terminalText(harness.error) : `${harness.configured}/${harness.total}`}`);
145
152
  console.log(`Session: ${report.session ? `${terminalText(report.session.id)} (${terminalText(report.session.model)})` : 'no matching rollout log'}`);
146
153
  console.log(`Panel: ${report.panel.live ? 'recent rendered frame observed' : 'no recent rendered frame'}`);
@@ -263,7 +270,7 @@ export async function run({ args, version }) {
263
270
  const statusline = hasFlag('--statusline') || format === 'statusline';
264
271
  if (statusline && (!cmd || cmd.startsWith('-'))) {
265
272
  const { createPanelReader, formatCodexPanel } = await import('../codex-panel.js');
266
- const data = await createPanelReader({ root: selectedRoot, sessionId: getArg('--session'), days: numArg('--days', { dflt: 7, min: 0 }), hasFlag })();
273
+ const data = await createPanelReader({ root: selectedRoot, sessionId: getArg('--session'), days: numArg('--days', { dflt: 7, min: 0 }), hasFlag, version })();
267
274
  const columns = numArg('--columns', { dflt: process.stdout.columns || 100, min: 1, max: 10000 });
268
275
  const text = formatCodexPanel(data, { compact: true, columns: hasFlag('--single-line') ? 100000 : columns,
269
276
  rows: 10000, version, color: !hasFlag('--no-color') && !process.env.NO_COLOR && !!process.stdout.isTTY && data.color });
@@ -354,7 +361,7 @@ export async function run({ args, version }) {
354
361
  return runCodexPanel({
355
362
  read: createPanelReader({ root: getArg('--project') ? resolve(getArg('--project')) : root,
356
363
  sessionId: getArg('--session'), sessionFile: getArg('--session-file'), days: numArg('--days', { dflt: 7, min: 0 }), readBudget: readBudget.read,
357
- readCachePolicy: hasFlag('--once') ? async (session) => {
364
+ version, refreshUpdate: !hasFlag('--once'), readCachePolicy: hasFlag('--once') ? async (session) => {
358
365
  const { readCodexCachePolicy } = await import('../codex-cache-policy.js');
359
366
  try { return readCodexCachePolicy(session) || await refreshCodexCachePolicy(session); }
360
367
  catch (e) { debug('codex:cache-policy', e); return null; }
@@ -364,7 +371,7 @@ export async function run({ args, version }) {
364
371
  fitRows: createInlinePanelResizer(),
365
372
  onFrame: (data) => {
366
373
  renderedSessionId = data.sessionId || data.session?.sessionId;
367
- recordPanelState(panelRoot, 'frame', { status: 'rendered', pid: process.pid, sessionId: renderedSessionId });
374
+ recordPanelState(panelRoot, 'frame', { status: 'rendered', pid: process.pid, version, sessionId: renderedSessionId });
368
375
  },
369
376
  onClose: () => recordPanelState(panelRoot, 'frame', { status: 'closed', pid: process.pid, sessionId: renderedSessionId }),
370
377
  });
@@ -392,7 +399,7 @@ export async function run({ args, version }) {
392
399
  }
393
400
  return;
394
401
  }
395
- const out = await codexHookOutput(getArg('--event'), payload);
402
+ const out = await codexHookOutput(getArg('--event'), payload, { version });
396
403
  if (out) console.log(JSON.stringify(out));
397
404
  } catch (e) { debug('codex:hook', e); /* Hook failures must not break the agent loop. */ }
398
405
  return;
@@ -440,8 +447,11 @@ export async function run({ args, version }) {
440
447
  if (hasFlag('--purge')) throw new Error('Codex uninstall keeps shared Sprag state; --purge is not supported.');
441
448
  const hook = configureCodexHooks({ remove: true });
442
449
  const h = uninitCodexHarness({ root, scope: 'global' });
450
+ const { installPanelShell } = await import('../codex-panel-shell.js');
451
+ const shellFile = installPanelShell({ remove: true });
443
452
  console.log(`Codex hooks: ${hook.file} (${hook.action})`);
444
453
  console.log(`Codex harness: ${h.file} (${h.removed ? 'removed' : 'absent'})`);
454
+ console.log(`Codex shell wrapper removed: ${shellFile}. Open a new shell to use the standalone codex command.`);
445
455
  console.log('Ratchet rules, project harnesses, backups, and shared Sprag state kept.');
446
456
  return;
447
457
  }
@@ -547,9 +557,27 @@ export async function run({ args, version }) {
547
557
  console.log('Prompt conversion, supported reads, and document/cache write guards require trusted hooks. Review in Codex /hooks.');
548
558
  return;
549
559
  }
560
+ if (cmd === 'doc2md' && args[1] === 'savings') {
561
+ const { codexDocumentTotals } = await import('../codex-doc2md-ledger.js');
562
+ const totals = codexDocumentTotals();
563
+ if (format === 'json') console.log(JSON.stringify({ agent: 'codex', ...totals }, null, 2));
564
+ else {
565
+ console.log(`Doc2md saved ${totals.savings.priced ? `${signedUsd(totals.savings.total, 4)} estimated` : 'n/a'} | ${totals.docs} docs, ${totals.savings.unpriced} unpriced`);
566
+ console.log('Estimate: Office XML versus prepared Markdown at UTF-8 bytes/4, priced at the recorded Codex model input rate. Not billed savings.');
567
+ for (const [reason, count] of Object.entries(totals.savings.reasons)) console.log(`${reason}: ${count}`);
568
+ }
569
+ return;
570
+ }
550
571
  if (cmd === 'doc2md' && args[1] && !args[1].startsWith('-') && args[1] !== 'install-converter') {
551
572
  const { convertCodexDocument } = await import('../codex-doc2md.js');
552
- const result = convertCodexDocument(resolve(args[1]));
573
+ const sessionId = getArg('--session') || process.env.CODEX_THREAD_ID;
574
+ let identity = {};
575
+ if (sessionId) {
576
+ const { createPanelReader } = await import('../codex-panel.js');
577
+ const { session } = await createPanelReader({ root, sessionId })();
578
+ if (session) identity = { model: session.model, provider: session.provider };
579
+ }
580
+ const result = convertCodexDocument(resolve(args[1]), identity);
553
581
  if (!result.ok) {
554
582
  console.error(`Doc2md failed: ${result.reason}`);
555
583
  if (result.reason === 'no-markitdown') console.error('Run sprag doc2md install-converter --agent codex, then retry.');
@@ -560,21 +588,25 @@ export async function run({ args, version }) {
560
588
  console.log(`Source ${result.meta.size} bytes; Markdown ${result.meta.markdownBytes} bytes.`);
561
589
  if (result.meta.clipped || result.meta.truncated) console.log('Text extraction is incomplete; inspect the source for omitted content.');
562
590
  if (result.meta.note) console.log(result.meta.note);
591
+ const saving = result.meta.codexSaving;
592
+ console.log(`Doc2md saved ${Number.isFinite(saving?.usd) ? `${signedUsd(saving.usd, 4)} estimated (Office XML vs Markdown, bytes/4)` : `n/a (${saving?.reason || 'unknown'})`}`);
563
593
  return;
564
594
  }
565
595
  if (cmd === 'upgrade') {
566
- const command = 'npm i -g sprag-cli --ignore-scripts';
596
+ const { upgradeInstructions } = await import('../update-notice.js');
597
+ const [command, setup] = upgradeInstructions('codex');
567
598
  if (!hasFlag('--print')) throw new Error(`For a Codex-only upgrade, run ${command}, then sprag install --agent codex.`);
568
599
  console.log(command);
569
- console.log('sprag install --agent codex');
600
+ console.log(setup);
570
601
  return;
571
602
  }
572
603
  const shared = new Set(['korean', 'cohesion', 'doc2md', 'mode', 'feedback', 'update-check']);
573
604
  if (shared.has(cmd)) {
574
605
  if (args.some((a) => a.startsWith('--hook'))) throw new Error('Use codex-hook for Codex hook payloads.');
575
606
  if (cmd === 'cohesion' && args[1] === 'on') configureCodexHooks();
607
+ if (cmd === 'korean' && args[1] === 'reply' && args[2] === 'on') configureCodexHooks();
576
608
  const { userDataDir } = await import('../paths.js');
577
- const result = await (await import(`./${cmd}.js`)).run({ args, getArg, hasFlag, numArg, version, dataDir: userDataDir() });
609
+ const result = await (await import(`./${cmd}.js`)).run({ args, getArg, hasFlag, numArg, version, agent: 'codex', dataDir: userDataDir() });
578
610
  if (cmd === 'cohesion' && args[1] === 'on') console.log('Review/trust the Sprag hooks in Codex /hooks.');
579
611
  return result;
580
612
  }
@@ -360,48 +360,14 @@ export async function run({ args, hasFlag, numArg }) {
360
360
  // — the refresh runs detached off the statusline path, never here.
361
361
  let updateBlock = null;
362
362
  try {
363
- const { updateStatus, maybeSpawnUpdateCheck, SESSION_START_CHECK_INTERVAL_MS } = await import('../update-check.js');
363
+ const { updateOffer } = await import('../update-notice.js');
364
364
  const pkgVersion = readPackageVersion();
365
365
  // Session start is the one moment where a stale cache matters most, so
366
366
  // kick the detached refresh here too, on a shorter interval than the
367
367
  // statusline's 24h: releases can land several times a day, and a
368
368
  // session opened that afternoon should hear about them. It lands in
369
369
  // time for the statusline and for the next session, not for this line.
370
- if (pkgVersion) maybeSpawnUpdateCheck(pkgVersion, { intervalMs: SESSION_START_CHECK_INTERVAL_MS });
371
- const u = pkgVersion ? updateStatus(pkgVersion) : null;
372
- // `dismissed` is the whole point of asking once: a user who said no to
373
- // this version must not be asked again every `/clear`.
374
- if (u && u.available && !u.dismissed) {
375
- // What the release adds, when we managed to read it. "A newer version
376
- // exists" is not a reason to upgrade; the reason is in this list, and
377
- // without it the user is being asked to decide on no information.
378
- const notes = Array.isArray(u.highlights) ? u.highlights : [];
379
- const koNotes = notes.length
380
- ? [' 이 버전에서 새로 생긴 것:', ...notes.map((n) => ` · ${n}`)]
381
- : [];
382
- const enNotes = notes.length
383
- ? [' What this release adds:', ...notes.map((n) => ` · ${n}`)]
384
- : [];
385
- updateBlock = lang === 'ko'
386
- ? [
387
- `[sprag update] 새 버전이 나와 있습니다: v${u.current} → ${u.latest}.`,
388
- ...koNotes,
389
- notes.length
390
- ? ' 위 내용을 사용자에게 보여 주고 업그레이드할지 물어보십시오. 승낙하면 아래 명령을 실행하고, 묻지 않고 설치하지는 마십시오.'
391
- : ' 사용자에게 지금 업그레이드할지 물어보고, 승낙하면 아래 명령을 실행하십시오. 묻지 않고 설치하지는 마십시오.',
392
- ' sprag upgrade # 업그레이드 실행',
393
- ' sprag update-check --dismiss # 사용자가 원치 않으면 (다음 버전이 나올 때까지 다시 묻지 않습니다)',
394
- ].join('\n')
395
- : [
396
- `[sprag update] A newer version is available: v${u.current} → ${u.latest}.`,
397
- ...enNotes,
398
- notes.length
399
- ? ' Show that list to the user and ask whether to upgrade. Run the command below only if they agree; do not install without asking.'
400
- : 'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
401
- ' sprag upgrade # perform the upgrade',
402
- ' sprag update-check --dismiss # if they decline (stays quiet until a newer release)',
403
- ].join('\n');
404
- }
370
+ if (pkgVersion) updateBlock = updateOffer(pkgVersion, { lang });
405
371
  } catch (e) { debug('route-scan:update-check', e); /* the offer is optional */ }
406
372
 
407
373
  if (open.length === 0 && reviewRules.length === 0) {
@@ -22,7 +22,7 @@ const SCOPE_SETS = {
22
22
  codex: ['codex-routing', 'codex-docs'],
23
23
  };
24
24
 
25
- /** Totals since each scope's mark: { usd } for money counters, { docs } for the Codex document counter. */
25
+ /** Totals since each scope's mark; Codex keeps unavailable estimates explicit. */
26
26
  async function currentTotals(scope, dir) {
27
27
  if (scope === 'routing') {
28
28
  const { delegationSavedTotals } = await import('../savings-ledger.js');
@@ -34,10 +34,12 @@ async function currentTotals(scope, dir) {
34
34
  }
35
35
  if (scope === 'codex-routing') {
36
36
  const { codexRoutingSavedTotals } = await import('../codex-ledger.js');
37
- return { usd: codexRoutingSavedTotals({ dir }).total };
37
+ const totals = codexRoutingSavedTotals({ dir });
38
+ return { usd: totals.priced ? totals.total : null, unpriced: totals.runs - totals.priced };
38
39
  }
39
40
  const { codexDocumentTotals } = await import('../codex-doc2md-ledger.js');
40
- return { docs: codexDocumentTotals({ dir }).docs };
41
+ const totals = codexDocumentTotals({ dir });
42
+ return { docs: totals.docs, usd: totals.savings.priced ? totals.savings.total : null, unpriced: totals.savings.unpriced };
41
43
  }
42
44
 
43
45
  /**
@@ -75,7 +77,11 @@ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg, rescan
75
77
  const suffix = agent === 'codex' ? ' --agent codex' : '';
76
78
 
77
79
  const money = (v) => (v < 0 ? `-$${(-v).toFixed(2)}` : `$${v.toFixed(2)}`);
78
- const fmtTotal = (t) => (t.docs !== undefined ? (ko ? `${t.docs}건` : `${t.docs} doc(s)`) : money(t.usd));
80
+ const fmtTotal = (t) => {
81
+ const value = Number.isFinite(t.usd) ? money(t.usd) : 'n/a';
82
+ return t.docs !== undefined ? `${ko ? `${t.docs}건` : `${t.docs} doc(s)`}, ${value} (${ko ? 'XML 대비 추정' : 'XML estimate'}; ${t.unpriced} unpriced)`
83
+ : `${value}${t.unpriced ? ` +${t.unpriced} unpriced` : ''}`;
84
+ };
79
85
  const fmtTime = (ms) => new Date(ms).toLocaleString(ko ? 'ko-KR' : 'en-US');
80
86
  const label = (scope) => ({
81
87
  routing: ko ? '라우팅 절감' : 'Routing saved',
@@ -11,7 +11,7 @@
11
11
  * command detached so the render itself stays offline and instant.
12
12
  */
13
13
 
14
- export async function run({ hasFlag, version }) {
14
+ export async function run({ hasFlag, version, agent = 'claude' }) {
15
15
  const {
16
16
  updateStatus,
17
17
  refreshUpdateState,
@@ -56,7 +56,10 @@ export async function run({ hasFlag, version }) {
56
56
  console.log(lang === 'ko'
57
57
  ? `새 버전이 있습니다: v${version} → ${s.latest}`
58
58
  : `Update available: v${version} → ${s.latest}`);
59
- console.log(lang === 'ko'
59
+ if (agent === 'codex') {
60
+ const { upgradeInstructions } = await import('../update-notice.js');
61
+ for (const command of upgradeInstructions(agent)) console.log(command);
62
+ } else console.log(lang === 'ko'
60
63
  ? `업그레이드: ${upgradeCommand()} (또는 sprag upgrade)`
61
64
  : `Upgrade with: ${upgradeCommand()} (or: sprag upgrade)`);
62
65
  if (s.dismissed) {
@@ -71,7 +74,7 @@ export async function run({ hasFlag, version }) {
71
74
  : `Up to date (v${version}).`);
72
75
  if (!s.latest) {
73
76
  console.log(lang === 'ko'
74
- ? `아직 확인된 기록이 없습니다. 지금 확인하려면: sprag update-check --refresh (기록 파일: ${updateStatePath()})`
75
- : `No check has completed yet. Run: sprag update-check --refresh (state: ${updateStatePath()})`);
77
+ ? `아직 확인된 기록이 없습니다. 지금 확인하려면: sprag update-check --refresh${agent === 'codex' ? ' --agent codex' : ''} (기록 파일: ${updateStatePath()})`
78
+ : `No check has completed yet. Run: sprag update-check --refresh${agent === 'codex' ? ' --agent codex' : ''} (state: ${updateStatePath()})`);
76
79
  }
77
80
  }
@@ -396,10 +396,16 @@ function buildRulesSeg(stats, c, g) {
396
396
  }
397
397
 
398
398
  /**
399
- * This session's subagent runs: `3 (haiku 2 · sonnet 1)`. Counts every
400
- * subagent the session started, not only the ones a routing rule sent, so it
401
- * answers "how much did this session fan out" rather than "how many rules
402
- * fired". Null when the session ran none, which is the common case.
399
+ * This session's subagent runs: `3 (haiku 2 · sonnet 1) spent $0.42`. Counts
400
+ * every subagent the session started, not only the ones a routing rule sent,
401
+ * so it answers "how much did this session fan out" rather than "how many
402
+ * rules fired", and what that fan-out cost. Null when the session ran none,
403
+ * which is the common case.
404
+ *
405
+ * The cost uses the routing headline's word, `spent`, and its shape: runs a
406
+ * gateway could not price are counted (`+1 unpriced`) rather than read as
407
+ * free, and a session whose every run went unpriced reads `n/a`. A summary
408
+ * without `usd` (an older caller) draws no cost at all.
403
409
  */
404
410
  function buildSessionDelegationSeg(sd, c, isIcon, verbose, g) {
405
411
  const total = Number(sd && sd.total) || 0;
@@ -411,7 +417,14 @@ function buildSessionDelegationSeg(sd, c, isIcon, verbose, g) {
411
417
  ? `${g.routing} ${verbose ? 'Subagents this session' : 'this session'}`
412
418
  : 'Subagents this session';
413
419
  const split = families.length ? ` ${c(GRAY)}(${families.join(' · ')})${c(RESET)}` : '';
414
- return `${c(CYAN)}${label} ${total}${c(RESET)}${split}`;
420
+ const usd = Number(sd.usd);
421
+ const unpriced = Number(sd.unpriced) || 0;
422
+ let spent = '';
423
+ if (sd.usd != null && Number.isFinite(usd)) {
424
+ const amt = usd > 0 || unpriced === 0 ? formatMoney(usd) : 'n/a';
425
+ spent = ` ${c(GRAY)}spent ${amt}${unpriced > 0 ? ` +${unpriced} unpriced` : ''}${c(RESET)}`;
426
+ }
427
+ return `${c(CYAN)}${label} ${total}${c(RESET)}${split}${spent}`;
415
428
  }
416
429
 
417
430
  /**
@@ -634,9 +647,23 @@ export function formatReport(data, { color = true, verbose = false, timer = true
634
647
  ? ` ${c(GRAY)}${verbose ? `last scan ${scanAge} ago` : `scan ${scanAge}`}${c(RESET)}`
635
648
  : '';
636
649
 
650
+ // What the same delegated runs cost on the models they ran on, read beside
651
+ // the saving so the reader sees what it was bought with: "saved $14.7 ·
652
+ // spent $5.2". Gray, since it is context for the saving rather than a result.
653
+ // Shown once any run carries a cost; runs whose event has none (recorded
654
+ // before costs were kept and not rescanned since, or unpriced) are counted
655
+ // instead of being read as free.
656
+ // text: "Routing saved $14.7 · spent $5.2 since 10/9"
657
+ // icon: "🔀 $14.7 · spent $5.2 (10/9~)"
658
+ const spentUsd = Number(totals && totals.spent) || 0;
659
+ const spentMissing = Number(totals && totals.spentMissing) || 0;
660
+ const spentTag = spentUsd > 0
661
+ ? ` ${c(GRAY)}· spent ${formatMoney(spentUsd)}${spentMissing > 0 ? ` +${spentMissing} unpriced` : ''}${c(RESET)}`
662
+ : '';
663
+
637
664
  const savedTone = delegationSaved < 0 ? RED : delegationSaved > 0 ? GREEN : GRAY;
638
665
  const savingsChip = delegationSaved !== 0 || routingSince
639
- ? `${c(savedTone)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}${sinceInline(routingSince)}${scanTag}`
666
+ ? `${c(savedTone)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}${spentTag}${sinceInline(routingSince)}${scanTag}`
640
667
  : null;
641
668
  // The unresolved count stands in only while there are no savings to show, so
642
669
  // a dated zero keeps both: the reset and the dropped runs are separate facts.
@@ -707,8 +734,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
707
734
  // a row of rolling windows it read as a breakdown of whichever one it
708
735
  // touched. A single lifetime total makes the whole line one timeframe with
709
736
  // nothing to mismatch.
710
- // icon: "🔀 Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
711
- // text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
737
+ // icon: "🔀 Routing saved $9.8 · spent $3.1 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
738
+ // text: "Routing saved $9.8 · spent $3.1 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
712
739
  let totalsLine = null;
713
740
  const totalsNum = Number(totals && totals.total);
714
741
  // A zero total gets its line only when a reset mark explains it; see the
@@ -737,7 +764,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
737
764
  totalsLine =
738
765
  `${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
739
766
  `${c(tone)}${formatMoney(totalsNum)}${c(RESET)}` +
740
- sinceWord(routingSince) + scanTag +
767
+ spentTag + sinceWord(routingSince) + scanTag +
741
768
  (pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
742
769
  }
743
770
 
@@ -0,0 +1,34 @@
1
+ /** Shared policy, rendered with each client's native model selector. */
2
+ export function implementationDelegationRule(agent = 'claude', lang = 'en') {
3
+ const target = agent === 'codex'
4
+ ? 'the resolved T1 model for the current parent model and provider (explicit mapping first, automatic resolution otherwise)'
5
+ : 'a model: sonnet subagent';
6
+ if (lang === 'ko') return [
7
+ '- **구현 분업**: 여러 파일을 고칠 때 설계와 스펙 확정은 메인 모델이 맡습니다.',
8
+ ` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' : 'model: sonnet 서브에이전트'}에 위임합니다.`,
9
+ ' Claude는 sonnet을 쓰고, Codex는 명시적 T1 매핑을 우선하며 없으면 자동 해석한 T1을 씁니다.',
10
+ ' 두 모델의 성능이 같다고 가정하지 않으며 haiku나 T2에 구현을 맡기지 않습니다.',
11
+ ' 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산과',
12
+ ' 권장 모델 및 선택 이유를 적어 다른 문맥 없이 실행할 수 있게 합니다.',
13
+ ' 이미 읽은 파일의 약 50줄 이하 변경이나 의존 단계가 하나인 짧은 구현은',
14
+ ' 분업 비용을 고려해 메인 모델이 직접 처리합니다. Codex에서 T1을 해석하지 못하면',
15
+ ' 메인이 이어받습니다. 위임이 꺼져 있거나 하위 모델 또는 위임 도구를 사용할 수',
16
+ ' 없어도 메인이 처리하고 그 이유를 알립니다.',
17
+ ' 메인 모델은 위임 완료를 기다리고 같은 구현을 중복 실행하지 않습니다.',
18
+ ' 반환된 diff와 테스트 결과를 직접 검증한 뒤 완료로 보고합니다.',
19
+ ].join('\n');
20
+ return [
21
+ '- **Implementation split**: the main model fixes the design and specification,',
22
+ ` then delegates multi-file implementation to ${target}.`,
23
+ ' Claude uses sonnet; Codex uses its resolved T1, not a presumed Sonnet equivalent.',
24
+ ' Never use haiku or T2 for implementation. Give a self-contained specification',
25
+ ' with owned files, constraints, completion criteria, test commands, a budget,',
26
+ ' and the recommended model with a one-line reason. Changes of roughly',
27
+ ' 50 lines or less in files already in context, or one short dependency chain,',
28
+ ' stay on the main model. Codex also stays on the main model if T1 is unresolved.',
29
+ ' If delegation is off, or the model or subagent tools are unavailable,',
30
+ ' stay on the main model and say why.',
31
+ ' Wait for the child instead of duplicating its implementation. The main model',
32
+ ' verifies the diff and actual test results before reporting completion.',
33
+ ].join('\n');
34
+ }
@@ -36,6 +36,7 @@ import { userLanguage } from './config.js';
36
36
  import { agentPhrase, agentPhraseEn } from './agents.js';
37
37
  import { writeStateFile } from './state-file.js';
38
38
  import { signedUsd } from './money.js';
39
+ import { implementationDelegationRule } from './implementation-delegation.js';
39
40
 
40
41
  // Post-promotion delegated-category error rate above this flags the rule
41
42
  // for review (rule-health). Calibrated against local T0 avg error incidence.
@@ -267,15 +268,7 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
267
268
  '- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
268
269
  ' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
269
270
  ' 보내고, 읽는 영역이 다른 작업만 나눈다.',
270
- '- **구현 분업**: 여러 파일에 걸친 구현은 설계·스펙 확정은 메인 모델이 하고,',
271
- ' 출력 토큰이 많은 실제 코드 작성은 스펙을 자기완결적으로(참조 없이 실행',
272
- ' 가능하게) 적어 model: sonnet 서브에이전트에 위임한다. haiku에는 맡기지',
273
- ' 않는다. 위임 프롬프트에는 작업별 권장 모델과 그 이유를 한 줄로 적는다.',
274
- ' 스펙을 쓴 쪽이 난도를 제일 잘 알기 때문이다. 단, 이미 컨텍스트에 있는',
275
- ' 파일의 약 50줄 이하 변경이나 의존 단계가 하나뿐인 짧은 구현은 분해',
276
- ' 오버헤드가 더 크므로 메인 모델이 직접 한다. 위임한 구현이 돌아오면 메인',
277
- ' 모델이 diff와 테스트 결과를 검증하고 나서 완료로 보고한다. 이 검증',
278
- ' 비용까지가 위임 비용이다.',
271
+ implementationDelegationRule('claude', lang),
279
272
  '- **에스컬레이션**: 같은 에러 2회 반복, 답이 복수 후보로 갈림, 서브에이전트가',
280
273
  ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 대응 순서는',
281
274
  ' ① 재지시 후 재시도 → ② 한 티어 상향 → ③ 메인 모델이 직접 수습.',
@@ -326,17 +319,7 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
326
319
  '- **Batching**: a subagent\'s dominant cost is context ingestion, not',
327
320
  ' generation. Bundle tasks that read the same files into one agent; split',
328
321
  ' only when the areas they read differ.',
329
- '- **Implementation split**: for multi-file implementation, the main model',
330
- ' fixes the design and writes a self-contained spec (executable without',
331
- ' back-references), then delegates the output-heavy code writing to a',
332
- ' model: sonnet subagent — never haiku. The delegation prompt names the',
333
- ' recommended model per task with a one-line why: whoever wrote the spec',
334
- ' knows the difficulty best. A change of roughly 50 lines or less in files',
335
- ' already in context, or a short implementation with a single dependency',
336
- ' chain, stays on the main model: decomposition overhead outweighs the',
337
- ' saving. When a delegated implementation comes back, the',
338
- ' main model verifies the diff and test results before reporting done —',
339
- ' that verification is part of the delegation cost.',
322
+ implementationDelegationRule('claude', lang),
340
323
  '- **Escalation**: hand off immediately on the same error twice, an answer',
341
324
  ' splitting into multiple candidates, or a subagent spinning without asking',
342
325
  ' questions. Order: ① re-instruct and retry → ② move up one tier → ③ the',
@@ -149,11 +149,12 @@ function readTailLines(path) {
149
149
  * null when the reply is fine (or cannot be judged), otherwise the measured
150
150
  * ratios so a caller can report them. Never throws.
151
151
  */
152
- function checkReplyLanguage(transcriptPath) {
152
+ function checkReplyLanguage(transcriptPath, { parseTurn = lastTurn, reply } = {}) {
153
153
  try {
154
154
  if (!transcriptPath) return null;
155
- const turn = lastTurn(readTailLines(transcriptPath));
155
+ const turn = parseTurn(readTailLines(transcriptPath));
156
156
  if (!turn) return null;
157
+ if (typeof reply === 'string') turn.reply = reply;
157
158
  if (ENGLISH_REQUESTED.test(turn.prompt)) return null;
158
159
  if (!isKoreanPrompt(turn.prompt) || !isEnglishReply(turn.reply)) return null;
159
160
  const u = letterCounts(turn.prompt);
package/src/route-scan.js CHANGED
@@ -374,7 +374,26 @@ export function worthDelegating(tier, rank) {
374
374
  */
375
375
  export function runSaving(run, mainModel) {
376
376
  if (!run.model || !mainModel) return null;
377
- const totals = {
377
+ const totals = runTotals(run);
378
+ const actual = sessionCost(totals, run.model);
379
+ const counterfactual = sessionCost(totals, mainModel);
380
+ if (!actual || !counterfactual) return null;
381
+ return counterfactual.actual - actual.actual;
382
+ }
383
+
384
+ /**
385
+ * USD a delegated run cost on the model it ran on: the price side of
386
+ * runSaving, recorded beside the saving so the statusline can show what the
387
+ * saving was bought with. null when the model has no price.
388
+ */
389
+ export function runCost(run) {
390
+ if (!run.model) return null;
391
+ const actual = sessionCost(runTotals(run), run.model);
392
+ return actual ? actual.actual : null;
393
+ }
394
+
395
+ function runTotals(run) {
396
+ return {
378
397
  input: run.input,
379
398
  cacheCreation: run.cacheCreation,
380
399
  cacheRead: run.cacheRead,
@@ -383,10 +402,6 @@ export function runSaving(run, mainModel) {
383
402
  output: run.out,
384
403
  ...(run.long ? { long: run.long } : {}),
385
404
  };
386
- const actual = sessionCost(totals, run.model);
387
- const counterfactual = sessionCost(totals, mainModel);
388
- if (!actual || !counterfactual) return null;
389
- return counterfactual.actual - actual.actual;
390
405
  }
391
406
 
392
407
  const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
@@ -865,6 +880,7 @@ export async function runRouteScan({ days = 14 } = {}) {
865
880
  key: e.run.path,
866
881
  ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
867
882
  usd,
883
+ cost: runCost(e.run),
868
884
  rule: rule.signature,
869
885
  from: baseline,
870
886
  to: e.run.model,
@@ -10,10 +10,13 @@
10
10
  *
11
11
  * File: <userDataDir>/delegation-ledger.json
12
12
  * { "version": 2,
13
- * "events": { "<run path>": { "ts", "usd", "rule", "from", "to" } } }
13
+ * "events": { "<run path>": { "ts", "usd", "cost", "rule", "from", "to" } } }
14
14
  *
15
15
  * `from`/`to` are the models the routing decision moved between, and `usd` is
16
- * the price difference between them for this run's tokens. Version 1 priced
16
+ * the price difference between them for this run's tokens. `cost` is what the
17
+ * run itself cost on the model it ran on, so the statusline can show what the
18
+ * saving was bought with. Events written before `cost` existed lack it until a
19
+ * scan that still covers the run fills it in. Version 1 priced
17
20
  * every downgraded subagent run against the session's priciest model, which
18
21
  * credited the tool for runs no rule of its own had routed; those events are
19
22
  * discarded rather than migrated, since the number they carry cannot be
@@ -81,11 +84,12 @@ export function loadLedger() {
81
84
 
82
85
  /**
83
86
  * Upsert saving events. `events` is an array of
84
- * { key, ts, usd, rule, from, to } where `key` is the run's transcript path
85
- * (unique per subagent run) and `from`/`to` name the models the routing
87
+ * { key, ts, usd, cost, rule, from, to } where `key` is the run's transcript
88
+ * path (unique per subagent run) and `from`/`to` name the models the routing
86
89
  * decision moved between. `usd` is signed: a run on a pricier model is a loss
87
90
  * and is recorded as a negative amount so it reduces the totals. Only
88
- * non-finite amounts (unpriced runs) are skipped.
91
+ * non-finite amounts (unpriced runs) are skipped. A non-finite `cost` is left
92
+ * out of the event while its saving is still recorded.
89
93
  */
90
94
  export function recordDelegationEvents(events) {
91
95
  if (!Array.isArray(events) || events.length === 0) return;
@@ -101,12 +105,16 @@ export function recordDelegationEvents(events) {
101
105
  const prevKey = keyOf.get(id) ?? e.key;
102
106
  const prev = data.events[prevKey];
103
107
  const usd = roundUsd(e.usd);
104
- if (prev && prev.ts === e.ts && prev.usd === usd) continue;
108
+ const cost = Number.isFinite(e.cost) ? roundUsd(e.cost) : undefined;
109
+ // An event recorded before `cost` existed compares unequal here, so the
110
+ // first scan that still covers the run fills its cost in.
111
+ if (prev && prev.ts === e.ts && prev.usd === usd && prev.cost === cost) continue;
105
112
  if (prevKey !== e.key) delete data.events[prevKey];
106
113
  keyOf.set(id, e.key);
107
114
  data.events[e.key] = {
108
115
  ts: e.ts,
109
116
  usd,
117
+ ...(cost !== undefined ? { cost } : {}),
110
118
  ...(e.rule ? { rule: e.rule } : {}),
111
119
  ...(e.from ? { from: e.from } : {}),
112
120
  ...(e.to ? { to: e.to } : {}),
@@ -143,13 +151,17 @@ export function modelFamily(model) {
143
151
  * lifetime rollup by family-level model change, priciest first. `now` is
144
152
  * injectable for tests. Never throws — an unreadable ledger yields zeros.
145
153
  *
154
+ * `spent` is the lifetime sum of what the same runs cost on the models they
155
+ * ran on, and `spentMissing` counts the runs whose event carries no `cost`
156
+ * (written before the field existed, or unpriced), which `spent` leaves out.
157
+ *
146
158
  * Events before the `routing` reset mark (`sprag saved reset`) are skipped
147
159
  * everywhere, and `since` reports the mark (null when never reset). `dir` is
148
160
  * the user data directory the mark is read from.
149
161
  */
150
162
  export function delegationSavedTotals(now = Date.now(), dir = userDataDir()) {
151
163
  const since = readResetMarks(dir).routing;
152
- const empty = () => ({ week: 0, month: 0, total: 0, pairs: [], since });
164
+ const empty = () => ({ week: 0, month: 0, total: 0, spent: 0, spentMissing: 0, pairs: [], since });
153
165
  const totals = empty();
154
166
  const byPair = new Map();
155
167
  try {
@@ -158,6 +170,8 @@ export function delegationSavedTotals(now = Date.now(), dir = userDataDir()) {
158
170
  const usd = Number(e.usd);
159
171
  if (!Number.isFinite(usd)) continue;
160
172
  totals.total += usd;
173
+ if (Number.isFinite(e.cost)) totals.spent += e.cost;
174
+ else totals.spentMissing += 1;
161
175
  if (Number.isFinite(e.ts)) {
162
176
  if (now - e.ts <= WEEK_MS) totals.week += usd;
163
177
  if (now - e.ts <= MONTH_MS) totals.month += usd;