sprag-cli 3.63.0 → 3.64.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
  }
@@ -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);
@@ -0,0 +1,37 @@
1
+ import { updateStatus, maybeSpawnUpdateCheck, readUpdateState, updateCheckDisabled, SESSION_START_CHECK_INTERVAL_MS } from './update-check.js';
2
+ import { debug } from './debug.js';
3
+
4
+ /** Shared by terminal renders; registry I/O stays in the detached checker. */
5
+ export function readUpdateChip(version, { refresh = true, ...opts } = {}) {
6
+ try {
7
+ if (refresh) maybeSpawnUpdateCheck(version, opts);
8
+ const status = updateStatus(version, opts);
9
+ const checkedAt = updateCheckDisabled() ? null : (Number(readUpdateState().checkedAt) || null);
10
+ return { ...status, checkedAt };
11
+ } catch (e) {
12
+ debug('update-check:chip', e);
13
+ return null;
14
+ }
15
+ }
16
+
17
+ export function upgradeInstructions(agent = 'claude') {
18
+ return agent === 'codex'
19
+ ? ['npm i -g sprag-cli --ignore-scripts', 'sprag install --agent codex']
20
+ : ['sprag upgrade'];
21
+ }
22
+
23
+ /** The model must obtain consent; a hook never installs a package itself. */
24
+ export function updateOffer(version, { agent = 'claude', lang = 'en', ...opts } = {}) {
25
+ const u = readUpdateChip(version, { intervalMs: SESSION_START_CHECK_INTERVAL_MS, ...opts });
26
+ if (!u?.available || u.dismissed) return null;
27
+ const ko = lang === 'ko';
28
+ return [
29
+ ko ? `[sprag update] 새 버전이 나와 있습니다: v${u.current} → ${u.latest}.`
30
+ : `[sprag update] A newer version is available: v${u.current} → ${u.latest}.`,
31
+ ...(u.highlights || []).slice(0, 3).map((note) => ` - ${note}`),
32
+ ko ? '사용자에게 변경 내용을 알리고 업그레이드할지 물어보십시오. 승낙한 경우에만 아래 명령을 실행하십시오.'
33
+ : 'Show the changes and ask the user whether to upgrade. Run these commands only if they agree; do not install without asking.',
34
+ ...upgradeInstructions(agent).map((command) => ` ${command}`),
35
+ ` sprag update-check --dismiss${agent === 'codex' ? ' --agent codex' : ''}`,
36
+ ].join('\n');
37
+ }