sprag-cli 3.62.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.
@@ -1,6 +1,6 @@
1
1
  import { createArgs } from '../cli-args.js';
2
2
  import { configureCodexHooks } from '../codex-installer.js';
3
- import { initCodexHarness, uninitCodexHarness, codexHarnessStatus } from '../codex-harness.js';
3
+ import { initCodexHarness, uninitCodexHarness, codexHarnessStatus, syncCodexKoreanBlock } from '../codex-harness.js';
4
4
  import { findProjectRoot, harnessPromote, harnessListRules, harnessRmRule, harnessPrune } from '../harness.js';
5
5
  import { loadConfig, saveConfig } from '../config.js';
6
6
  import { resolve } from 'node:path';
@@ -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;
@@ -408,6 +415,10 @@ export async function run({ args, version }) {
408
415
  if (process.env.CTS_NO_HARNESS !== '1') {
409
416
  const h = initCodexHarness({ root, scope: 'global' });
410
417
  console.log(`Codex harness: ${h.file}`);
418
+ try {
419
+ const k = syncCodexKoreanBlock({ cfg });
420
+ console.log(`Codex korean-style: ${k.file} (${k.action})`);
421
+ } catch (e) { debug('codex:korean-sync', e); }
411
422
  }
412
423
  try {
413
424
  const { preparePresetRatchet } = await import('../preset-ratchet.js');
@@ -426,9 +437,9 @@ export async function run({ args, version }) {
426
437
  const auto = parent ? autoCodexSharedTargets({ model: parent, provider }) : { targets: [], reason: 'Codex config names no default model' };
427
438
  console.log(auto.targets.length
428
439
  ? `Delegation tiers for ${parent} on ${provider} (auto): ${auto.targets.map((t) => `${t.tier} -> ${t.model}`).join(', ')}. Change: sprag delegate shared status --agent codex`
429
- : `Delegation tiers: not resolved yet (${auto.reason}). They resolve once a cheaper priced model has run in Codex, or map one: sprag delegate shared map T2 --from <parent> --model <target> --agent codex`);
440
+ : `Delegation tiers: not resolved yet (${auto.reason}). They resolve once Codex lists or has run a cheaper priced model, or map one: sprag delegate shared map T2 --from <parent> --model <target> --agent codex`);
430
441
  } catch (e) { debug('codex:auto-tiers', e); }
431
- console.log('First-use delegation: sprag seed --agent codex to approve policies, then sprag delegate on --agent codex. No Claude installation is required.');
442
+ console.log('First-use delegation: sprag seed --agent codex to approve policies; delegation is on by default (turn off: sprag delegate off --agent codex). No Claude installation is required.');
432
443
  console.log('Korean guidance is opt-in: sprag korean on --agent codex');
433
444
  return;
434
445
  }
@@ -436,8 +447,11 @@ export async function run({ args, version }) {
436
447
  if (hasFlag('--purge')) throw new Error('Codex uninstall keeps shared Sprag state; --purge is not supported.');
437
448
  const hook = configureCodexHooks({ remove: true });
438
449
  const h = uninitCodexHarness({ root, scope: 'global' });
450
+ const { installPanelShell } = await import('../codex-panel-shell.js');
451
+ const shellFile = installPanelShell({ remove: true });
439
452
  console.log(`Codex hooks: ${hook.file} (${hook.action})`);
440
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.`);
441
455
  console.log('Ratchet rules, project harnesses, backups, and shared Sprag state kept.');
442
456
  return;
443
457
  }
@@ -513,6 +527,7 @@ export async function run({ args, version }) {
513
527
  const { setKoreanStyleEnabled } = await import('../korean-style.js');
514
528
  if (args[1] === 'on') configureCodexHooks();
515
529
  setKoreanStyleEnabled(args[1] === 'on');
530
+ try { syncCodexKoreanBlock({ cfg: loadConfig() }); } catch (e) { debug('codex:korean-sync', e); }
516
531
  console.log(`Korean guidance: ${args[1]} (shared Sprag preference). Review/trust the Sprag hooks in Codex /hooks.`);
517
532
  return;
518
533
  }
@@ -542,9 +557,27 @@ export async function run({ args, version }) {
542
557
  console.log('Prompt conversion, supported reads, and document/cache write guards require trusted hooks. Review in Codex /hooks.');
543
558
  return;
544
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
+ }
545
571
  if (cmd === 'doc2md' && args[1] && !args[1].startsWith('-') && args[1] !== 'install-converter') {
546
572
  const { convertCodexDocument } = await import('../codex-doc2md.js');
547
- 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);
548
581
  if (!result.ok) {
549
582
  console.error(`Doc2md failed: ${result.reason}`);
550
583
  if (result.reason === 'no-markitdown') console.error('Run sprag doc2md install-converter --agent codex, then retry.');
@@ -555,21 +588,25 @@ export async function run({ args, version }) {
555
588
  console.log(`Source ${result.meta.size} bytes; Markdown ${result.meta.markdownBytes} bytes.`);
556
589
  if (result.meta.clipped || result.meta.truncated) console.log('Text extraction is incomplete; inspect the source for omitted content.');
557
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'})`}`);
558
593
  return;
559
594
  }
560
595
  if (cmd === 'upgrade') {
561
- const command = 'npm i -g sprag-cli --ignore-scripts';
596
+ const { upgradeInstructions } = await import('../update-notice.js');
597
+ const [command, setup] = upgradeInstructions('codex');
562
598
  if (!hasFlag('--print')) throw new Error(`For a Codex-only upgrade, run ${command}, then sprag install --agent codex.`);
563
599
  console.log(command);
564
- console.log('sprag install --agent codex');
600
+ console.log(setup);
565
601
  return;
566
602
  }
567
603
  const shared = new Set(['korean', 'cohesion', 'doc2md', 'mode', 'feedback', 'update-check']);
568
604
  if (shared.has(cmd)) {
569
605
  if (args.some((a) => a.startsWith('--hook'))) throw new Error('Use codex-hook for Codex hook payloads.');
570
606
  if (cmd === 'cohesion' && args[1] === 'on') configureCodexHooks();
607
+ if (cmd === 'korean' && args[1] === 'reply' && args[2] === 'on') configureCodexHooks();
571
608
  const { userDataDir } = await import('../paths.js');
572
- 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() });
573
610
  if (cmd === 'cohesion' && args[1] === 'on') console.log('Review/trust the Sprag hooks in Codex /hooks.');
574
611
  return result;
575
612
  }
@@ -104,6 +104,7 @@ export async function run({ args, hasFlag }) {
104
104
  console.log(res.action === 'skipped'
105
105
  ? `✗ ${res.reason}`
106
106
  : `✓ Read hook ${res.action} (${res.path})`);
107
+ if (res.modReason) console.log(` mod: not registered — ${res.modReason}`);
107
108
  // A registered hook with no converter behind it does nothing at all, and
108
109
  // says nothing about it either, which reads as a broken feature. Offer the
109
110
  // one command that closes the gap right where the gap is visible.
@@ -160,6 +161,17 @@ export async function run({ args, hasFlag }) {
160
161
  console.log(` converter: ${python ? `markitdown via ${python}` : `not installed — ${doc2md.INSTALL_HINT}`}`);
161
162
  console.log(` cache: ${dir} (${cached} file(s))`);
162
163
  console.log(` hook: ${hookRegistered() ? 'registered on Read' : 'not registered'}`);
164
+ // The mod has the same two halves as the hook: `doc2md on` can leave it out
165
+ // (a shell value of the variable, a /plugin copy) while the hook goes in.
166
+ // A path the user added to their own shell variable counts as registered.
167
+ {
168
+ const { modDirsState, modDir, DOC2MD_MOD } = await import('../installer.js');
169
+ const sep = process.platform === 'win32' ? ';' : ':';
170
+ const inShell = (process.env.CLAUDE_CODE_PLUGIN_DIRS || '').split(sep).includes(modDir(DOC2MD_MOD));
171
+ console.log(` mod: ${modDirsState()[DOC2MD_MOD] || inShell
172
+ ? 'registered (Claude Code 2.1.287+ reads the Markdown in the same Read)'
173
+ : 'not registered — `doc2md on` registers it or says why not'}`);
174
+ }
163
175
  console.log('');
164
176
  if (!python) console.log(`Install the converter: ${doc2md.INSTALL_HINT}`);
165
177
  console.log('Enable with: sprag doc2md on');
@@ -85,6 +85,8 @@ export async function run({ hasFlag }) {
85
85
  print('skill', r.skill);
86
86
  print('SessionStart hook (route-scan)', r.sessionStartHook);
87
87
  print('PostToolUse hook (delegation rescan)', r.delegationHook);
88
+ print('mod (delegation meter, Claude Code 2.1.287+)', r.delegationMeterMod);
89
+ if (r.delegationMeterMod.reason) console.log(` ${r.delegationMeterMod.reason}`);
88
90
  print('UserPromptSubmit hook (brief)', r.briefHook);
89
91
  {
90
92
  let s = r.statusline;
@@ -333,6 +335,7 @@ export async function run({ hasFlag }) {
333
335
  console.log(res.action === 'skipped'
334
336
  ? ` doc2md: ${res.reason}`
335
337
  : ` doc2md: Read/Write hook ${res.action} (${res.path})`);
338
+ if (res.modReason) console.log(` doc2md: mod not registered — ${res.modReason}`);
336
339
  console.log(lang === 'ko'
337
340
  ? ` 대상 형식: ${doc2md.TARGET_EXTENSIONS.join(' ')} — 모델이 읽기 전에 Markdown으로 변환합니다.`
338
341
  : ` formats: ${doc2md.TARGET_EXTENSIONS.join(' ')} — converted to Markdown before the model reads them.`);
@@ -449,6 +452,11 @@ export async function run({ hasFlag }) {
449
452
  if (process.env.CTS_NO_HARNESS !== '1') {
450
453
  const h = initCodexHarness({ scope: 'global' });
451
454
  console.log(` codex: harness ${h.file}`);
455
+ try {
456
+ const { syncCodexKoreanBlock } = await import('../codex-harness.js');
457
+ const k = syncCodexKoreanBlock({ cfg: config });
458
+ console.log(` codex: korean-style ${k.action} (${k.file})`);
459
+ } catch (e) { debug('install:codex-korean', e); }
452
460
  }
453
461
  try { (await import('../preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('install:codex-preset', e); }
454
462
  if (!manual) {
@@ -53,6 +53,7 @@ export async function run({ args, hasFlag }) {
53
53
  const sub = args[1] || 'status';
54
54
  const ks = await import('../korean-style.js');
55
55
  const { userLanguage, loadConfig, saveConfig } = await import('../config.js');
56
+ const { debug } = await import('../debug.js');
56
57
  const lang = userLanguage();
57
58
 
58
59
  // PostToolUse hook. Claude Code feeds the tool-call payload on stdin; we
@@ -222,6 +223,8 @@ export async function run({ args, hasFlag }) {
222
223
  if (sub === 'on' || sub === 'off') {
223
224
  const enabled = sub === 'on';
224
225
  ks.setKoreanStyleEnabled(enabled);
226
+ // Codex reads the guide from its global AGENTS.md; keep that block in step.
227
+ try { (await import('../codex-harness.js')).syncCodexKoreanBlock({ cfg: loadConfig() }); } catch (e) { debug('korean:codex-sync', e); }
225
228
  const {
226
229
  installKoreanLintHook, removeKoreanLintHook,
227
230
  installReplyLanguageHook, removeReplyLanguageHook,
@@ -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
+ }
package/src/installer.js CHANGED
@@ -16,7 +16,8 @@
16
16
  import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync, copyFileSync, renameSync, realpathSync, statSync } from 'node:fs';
17
17
  import { loadConfig, saveConfig } from './config.js';
18
18
  import { CLI_NAME, LEGACY_CLI_NAME } from './cli-name.js';
19
- import { join } from 'node:path';
19
+ import { join, dirname } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
20
21
  import { claudeUserDir, userDataDir } from './paths.js';
21
22
 
22
23
  /**
@@ -692,6 +693,122 @@ export function removeReplyLanguageHook() {
692
693
  return { path: file, action: 'removed' };
693
694
  }
694
695
 
696
+ // Mods: plugin folders shipped in this package's mod/ directory, which Claude
697
+ // Code (2.1.287 and later) runs inside its own process. They are loaded through
698
+ // CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json, the one
699
+ // way to load a plugin folder that needs neither a marketplace nor the `claude`
700
+ // CLI at install time, so `npm i -g` stays the whole setup. An older Claude
701
+ // Code ignores the variable, and the settings hooks keep doing the work alone.
702
+ //
703
+ // The variable holds absolute paths, so an entry goes stale when the package
704
+ // moves (a Node version switch under nvm puts it in another global root). An
705
+ // entry counts as ours by its last two segments, `mod/<a name below>`, and
706
+ // adding a mod first drops every older path to it, so a reinstall repairs the
707
+ // list instead of growing it. Other folders in the variable are left alone.
708
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
709
+ const PLUGIN_DIRS_ENV = 'CLAUDE_CODE_PLUGIN_DIRS';
710
+ export const DOC2MD_MOD = 'sprag-doc2md';
711
+ export const DELEGATION_METER_MOD = 'sprag-delegation-meter';
712
+ export const MOD_NAMES = [DOC2MD_MOD, DELEGATION_METER_MOD];
713
+
714
+ export function modDir(name) {
715
+ return join(PACKAGE_ROOT, 'mod', name);
716
+ }
717
+
718
+ const pluginDirsSeparator = () => (process.platform === 'win32' ? ';' : ':');
719
+
720
+ function isOurModEntry(entry, names) {
721
+ const parts = String(entry).split(/[\\/]/).filter(Boolean);
722
+ return parts.length >= 2 && parts[parts.length - 2] === 'mod' && names.includes(parts[parts.length - 1]);
723
+ }
724
+
725
+ function pluginDirEntries(settings) {
726
+ const value = isPlainObject(settings.env) ? settings.env[PLUGIN_DIRS_ENV] : undefined;
727
+ return typeof value === 'string' ? value.split(pluginDirsSeparator()).filter(Boolean) : [];
728
+ }
729
+
730
+ /**
731
+ * Point CLAUDE_CODE_PLUGIN_DIRS at this package's copy of one mod, in place on
732
+ * a settings object read by the caller. Returns true when the object changed,
733
+ * false when it already held exactly that, or a reason string when the mod is
734
+ * better left out: `env` is not something this can safely extend, or the user
735
+ * already loads mods in a way a write here would break or duplicate.
736
+ *
737
+ * `inherited` is the variable as this process received it. When settings.json
738
+ * has no value of its own, that is the user's shell value. Claude Code's docs
739
+ * say a settings `env` entry replaces the inherited value in most sessions, so
740
+ * a value written here would stop the user's own mods from loading; were this
741
+ * variable one of the exceptions, ours would be ignored instead. Either way
742
+ * the write is wrong, so the case is refused with the path to add by hand.
743
+ */
744
+ function addModDir(settings, name, inherited = process.env[PLUGIN_DIRS_ENV]) {
745
+ if (settings.env !== undefined && !isPlainObject(settings.env)) return 'env is not an object — fix settings.json manually';
746
+ const current = settings.env?.[PLUGIN_DIRS_ENV];
747
+ if (current !== undefined && typeof current !== 'string') return `env.${PLUGIN_DIRS_ENV} is not a string — fix settings.json manually`;
748
+ // A copy installed through /plugin would run beside this one, every hook twice.
749
+ const installed = isPlainObject(settings.enabledPlugins)
750
+ ? Object.keys(settings.enabledPlugins).find((id) => id.split('@')[0] === name && settings.enabledPlugins[id])
751
+ : undefined;
752
+ if (installed) return `${name} is already installed as the plugin ${installed} — that copy stays the one that runs`;
753
+ const target = modDir(name);
754
+ const sep = pluginDirsSeparator();
755
+ if (current === undefined && typeof inherited === 'string') {
756
+ const theirs = inherited.split(sep).filter((e) => e && !isOurModEntry(e, MOD_NAMES));
757
+ if (theirs.length) {
758
+ return `${PLUGIN_DIRS_ENV} is set in your shell (${theirs.join(sep)}), and a value in settings.json would replace it — add ${target} to that variable yourself`;
759
+ }
760
+ }
761
+ const before = pluginDirEntries(settings);
762
+ const stale = before.filter((e) => e !== target && isOurModEntry(e, [name]));
763
+ if (before.includes(target) && stale.length === 0) return false;
764
+ const entries = [...before.filter((e) => !isOurModEntry(e, [name])), target];
765
+ settings.env = { ...(settings.env || {}), [PLUGIN_DIRS_ENV]: entries.join(sep) };
766
+ return true;
767
+ }
768
+
769
+ /**
770
+ * Drop every entry of the named mods, wherever it points. The variable goes
771
+ * when nothing is left in it, and `env` when this emptied it. Returns whether
772
+ * the object changed.
773
+ */
774
+ function dropModDirs(settings, names) {
775
+ const before = pluginDirEntries(settings);
776
+ const kept = before.filter((e) => !isOurModEntry(e, names));
777
+ if (kept.length === before.length) return false;
778
+ if (kept.length) settings.env[PLUGIN_DIRS_ENV] = kept.join(pluginDirsSeparator());
779
+ else delete settings.env[PLUGIN_DIRS_ENV];
780
+ if (Object.keys(settings.env).length === 0) delete settings.env;
781
+ return true;
782
+ }
783
+
784
+ /** Which mods settings.json currently loads from this package's own copy. */
785
+ export function modDirsState() {
786
+ const read = readSettings(join(claudeUserDir(), 'settings.json'));
787
+ const entries = read.state === 'ok' ? pluginDirEntries(read.settings) : [];
788
+ return Object.fromEntries(MOD_NAMES.map((name) => [name, entries.includes(modDir(name))]));
789
+ }
790
+
791
+ // The delegation meter only observes (see mod/sprag-delegation-meter), so it
792
+ // ships on with the rest of installAll(), beside the PostToolUse hook that
793
+ // feeds the transcript-based ledger it is measured against. uninstallAll()
794
+ // takes it out with every other mod.
795
+ export function installDelegationMeterMod() {
796
+ const dir = claudeUserDir();
797
+ const file = join(dir, 'settings.json');
798
+ mkdirSync(dir, { recursive: true });
799
+ const read = readSettings(file);
800
+ if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
801
+ const settings = read.settings;
802
+ const changed = addModDir(settings, DELEGATION_METER_MOD);
803
+ if (typeof changed === 'string') return { path: file, action: 'skipped', reason: changed };
804
+ if (!changed) return { path: file, action: 'exists' };
805
+ const writeProblem = writeSettings(file, settings);
806
+ if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
807
+ // The action names what happened to the file, as installDelegationGuardHook's
808
+ // does: one entry added to a settings.json already there is an update.
809
+ return { path: file, action: read.state === 'absent' ? 'created' : 'updated' };
810
+ }
811
+
695
812
  // Registers the PreToolUse hook that converts attached documents to Markdown
696
813
  // before the model reads them. PreToolUse rather than PostToolUse because the
697
814
  // point is to intervene before a pptx lands in the context window; afterwards
@@ -775,14 +892,22 @@ export function installDoc2mdHook() {
775
892
  }
776
893
  }
777
894
 
778
- if (!hasReadHook || addedPrompt || addedWriteGuard) {
895
+ // The mod answers a document Read with the converted Markdown in the same
896
+ // call, where the hook above can only refuse it (mod/sprag-doc2md). It rides
897
+ // this hook's on and off so the two never disagree about doc2md being on. An
898
+ // `env` this cannot extend costs only the mod: the hooks still go in.
899
+ const modChange = addModDir(settings, DOC2MD_MOD);
900
+ const addedMod = modChange === true;
901
+ const modNote = typeof modChange === 'string' ? { modReason: modChange } : {};
902
+
903
+ if (!hasReadHook || addedPrompt || addedWriteGuard || addedMod) {
779
904
  {
780
905
  const writeProblem = writeSettings(file, settings);
781
906
  if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
782
907
  }
783
- return { path: file, action: hasReadHook ? 'updated' : 'created' };
908
+ return { path: file, action: hasReadHook ? 'updated' : 'created', ...modNote };
784
909
  }
785
- return { path: file, action: 'exists' };
910
+ return { path: file, action: 'exists', ...modNote };
786
911
  }
787
912
 
788
913
  export function removeDoc2mdHook() {
@@ -805,6 +930,7 @@ export function removeDoc2mdHook() {
805
930
  if (kept.length === 0) delete settings.hooks[event];
806
931
  else settings.hooks[event] = kept;
807
932
  }
933
+ if (dropModDirs(settings, [DOC2MD_MOD])) touched = true;
808
934
  if (!touched) return { path: file, action: 'absent' };
809
935
  {
810
936
  const writeProblem = writeSettings(file, settings);
@@ -1022,6 +1148,10 @@ export function uninstallAll({ purge = false } = {}) {
1022
1148
  }
1023
1149
  if (isPlainObject(settings.hooks) && Object.keys(settings.hooks).length === 0) delete settings.hooks;
1024
1150
 
1151
+ // Mods are found by their folder name, so a path left by an older install
1152
+ // location goes with the current one.
1153
+ if (dropModDirs(settings, MOD_NAMES)) result.removed.push(`env.${PLUGIN_DIRS_ENV} (mods)`);
1154
+
1025
1155
  // Nothing of ours in the file means nothing to rewrite (and no backup to churn).
1026
1156
  if (result.removed.length > 0) {
1027
1157
  const writeProblem = writeSettings(file, settings);
@@ -1116,6 +1246,7 @@ export function installAll({ force = false } = {}) {
1116
1246
  statusline: installStatusline({ force }),
1117
1247
  sessionStartHook: installSessionStartHook(),
1118
1248
  delegationHook: installDelegationHook(),
1249
+ delegationMeterMod: installDelegationMeterMod(),
1119
1250
  briefHook: installBriefHook(),
1120
1251
  cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
1121
1252
  legacy: removeLegacyCommand(),
@@ -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',