sprag-cli 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.ko.md +17 -0
  2. package/README.md +23 -0
  3. package/bin/cli.js +51 -3
  4. package/docs/CODEX.ko.md +69 -6
  5. package/docs/CODEX.md +84 -12
  6. package/package.json +1 -1
  7. package/src/agent.js +9 -0
  8. package/src/caps-cache.js +3 -3
  9. package/src/codex-brief.js +4 -5
  10. package/src/codex-cache-policy.js +3 -3
  11. package/src/codex-delegation.js +114 -15
  12. package/src/codex-doc2md-ledger.js +3 -3
  13. package/src/codex-doctor.js +2 -2
  14. package/src/codex-hooks.js +4 -5
  15. package/src/codex-installer.js +3 -3
  16. package/src/codex-ledger.js +25 -10
  17. package/src/codex-panel-session.js +3 -3
  18. package/src/codex-panel-state.js +51 -5
  19. package/src/codex-route-scan.js +12 -7
  20. package/src/commands/brief.js +1 -1
  21. package/src/commands/codex-delegate.js +81 -6
  22. package/src/commands/codex.js +8 -2
  23. package/src/commands/compact-window.js +6 -2
  24. package/src/commands/install.js +4 -1
  25. package/src/commands/korean.js +69 -3
  26. package/src/commands/route-scan.js +6 -4
  27. package/src/commands/saved.js +40 -1
  28. package/src/ctx-window-cache.js +14 -0
  29. package/src/formatters/statusline.js +167 -18
  30. package/src/harness-analyzer.cjs +8 -2
  31. package/src/harness.js +32 -23
  32. package/src/installer.js +52 -0
  33. package/src/model-alias.js +3 -3
  34. package/src/model-rules.js +14 -9
  35. package/src/preset-ratchet.js +3 -3
  36. package/src/reply-language.cjs +167 -0
  37. package/src/route-inject.js +57 -8
  38. package/src/route-scan.js +51 -11
  39. package/src/savings-ledger.js +3 -2
  40. package/src/seed-rules.js +32 -23
  41. package/src/session-cache.js +12 -4
  42. package/src/shared-model-rules.js +73 -0
  43. package/src/state-file.js +99 -2
  44. package/src/statusline-data.js +97 -0
  45. package/src/update-check.js +46 -6
@@ -1,20 +1,94 @@
1
1
  import { loadConfig, saveConfig } from '../config.js';
2
2
  import { configureCodexHooks } from '../codex-installer.js';
3
- import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
3
+ import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule,
4
+ validateCodexSharedTarget, codexSharedTargets, codexDelegateMinContext, DEFAULT_DELEGATE_MIN_CONTEXT } from '../codex-delegation.js';
4
5
  import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
5
6
  import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
6
7
  import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
7
8
  import { signedUsd } from '../money.js';
8
- import { codexProviderName } from '../agent.js';
9
+ import { codexProviderName, codexModelName } from '../agent.js';
10
+ import { loadSharedModelRules, sharedModelRulesForProject, SHARED_RULE_TIERS } from '../shared-model-rules.js';
11
+
12
+ // Starts from the working directory, as the hooks do, so a project policy
13
+ // stored under either agent's project root is counted.
14
+ function printShared(cfg, { details = false } = {}) {
15
+ const policies = sharedModelRulesForProject(loadSharedModelRules(), process.cwd());
16
+ const targets = codexSharedTargets(cfg);
17
+ const provider = codexProviderName();
18
+ const parent = codexModelName();
19
+ console.log(`Shared delegation policies: ${policies.length} in this project (${cfg.codex?.sharedRules?.enabled === false ? 'off' : 'on'}); Codex tier mappings: ${targets.length}`);
20
+ if (!policies.length) console.log('First use: sprag seed --agent codex. Approve policies with seed accept <id>|all --global|--project; no Claude installation or history is required.');
21
+ if (details) {
22
+ for (const r of policies) console.log(`shared ${r.tier} ${r.category} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}`);
23
+ for (const t of targets) console.log(`${t.provider} | ${t.from} | ${t.tier} -> ${t.model}${t.effort ? ` (${t.effort})` : ''}`);
24
+ }
25
+ // A mapping routes only sessions on the parent model it names, so a tier
26
+ // mapped for another model still leaves the configured model unrouted.
27
+ const mapped = (tier) => targets.some((t) => t.provider === provider && t.tier === tier && (!parent || t.from === parent));
28
+ const missing = [...new Set(policies.map((r) => r.tier))].filter((tier) => !mapped(tier));
29
+ if (missing.length) {
30
+ console.log(`Unmapped tiers for ${parent || 'any parent model'} on ${provider || 'unknown provider'}: ${missing.join(', ')}. `
31
+ + `Set: sprag delegate shared map <T1|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
32
+ }
33
+ if (!parent && targets.length) console.log('Codex config names no default model; each mapping routes only sessions on its --from model.');
34
+ const minimum = codexDelegateMinContext(cfg);
35
+ console.log(`Shared prompt guidance starts at ${minimum} parent input tokens${cfg.codex?.delegateMinContext === minimum ? '' : ' (default)'}. `
36
+ + 'Change: sprag delegate shared min-context <tokens|default> --agent codex');
37
+ console.log('Shared policies keep their original scope and bounds. Models require an exact parent/provider mapping; agent-specific roles and usage totals are not imported.');
38
+ }
9
39
 
10
40
  export function run({ args, getArg, hasFlag, root }) {
11
41
  const sub = args[1] || 'status';
12
42
  if (args.some((arg) => arg.startsWith('--hook'))) throw new Error('Use codex-hook for Codex hook payloads.');
13
- for (const flag of ['--model', '--effort', '--from']) {
43
+ for (const flag of ['--model', '--effort', '--from', '--provider']) {
14
44
  if (args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)) && (!getArg(flag) || getArg(flag).startsWith('-'))) {
15
45
  throw new Error(`${flag} requires a value.`);
16
46
  }
17
47
  }
48
+ const cfg = loadConfig();
49
+ if (sub === 'shared') {
50
+ const action = args[2] || 'status';
51
+ if (!['status', 'on', 'off', 'map', 'unmap', 'min-context'].includes(action)) {
52
+ throw new Error('Usage: delegate shared status|on|off|map <T1|T2>|unmap <T1|T2>|min-context <tokens|default> --agent codex');
53
+ }
54
+ if (hasFlag('--global') || hasFlag('--project')) throw new Error('Shared policies retain their approved scope; mappings do not change it.');
55
+ const provided = (flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
56
+ if (!['map', 'unmap'].includes(action) && ['--model', '--effort', '--from', '--provider'].some(provided)) {
57
+ throw new Error('Model options require delegate shared map or unmap.');
58
+ }
59
+ const targets = codexSharedTargets(cfg);
60
+ if (action === 'map' || action === 'unmap') {
61
+ const tier = args[3];
62
+ const from = getArg('--from');
63
+ const provider = getArg('--provider') ?? codexProviderName();
64
+ if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
65
+ const same = (t) => t.tier === tier && t.from === from && t.provider === provider;
66
+ if (action === 'map') {
67
+ const target = validateCodexSharedTarget({ tier, from, provider, model: getArg('--model'), effort: getArg('--effort') });
68
+ cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, targets: [...targets.filter((t) => !same(t)), target] } };
69
+ } else {
70
+ if (provided('--model') || provided('--effort')) throw new Error('unmap accepts only tier, --from and --provider.');
71
+ if (!targets.some(same)) throw new Error('No matching shared tier mapping; specify --from and the correct provider.');
72
+ cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, targets: targets.filter((t) => !same(t)) } };
73
+ }
74
+ } else if (action === 'min-context') {
75
+ const value = args[3];
76
+ if (value === 'default') {
77
+ cfg.codex = { ...cfg.codex };
78
+ delete cfg.codex.delegateMinContext;
79
+ } else if (/^\d+$/.test(value ?? '') && Number.isSafeInteger(Number(value))) {
80
+ cfg.codex = { ...cfg.codex, delegateMinContext: Number(value) };
81
+ } else {
82
+ throw new Error(`min-context takes a whole number of parent input tokens, or default (${DEFAULT_DELEGATE_MIN_CONTEXT}).`);
83
+ }
84
+ } else if (action !== 'status') {
85
+ cfg.codex = { ...cfg.codex, sharedRules: { ...cfg.codex?.sharedRules, enabled: action === 'on' } };
86
+ }
87
+ if (action !== 'status') saveConfig(cfg);
88
+ printShared(cfg, { details: true });
89
+ console.log(`Codex delegation is ${cfg.codex?.delegate === true ? 'on' : 'off; enable with sprag delegate on --agent codex'}. Target availability and lower cost must be verified with your provider.`);
90
+ return;
91
+ }
18
92
  if (sub === 'rules') {
19
93
  if (args[2] === 'add') {
20
94
  if (hasFlag('--global') === hasFlag('--project')) throw new Error('Choose --global or --project for the rule.');
@@ -31,7 +105,7 @@ export function run({ args, getArg, hasFlag, root }) {
31
105
  } else if (args[2] === 'rm') removeCodexModelRule(Number(args[3]));
32
106
  else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
33
107
  const rules = loadCodexModelRules();
34
- if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
108
+ if (!rules.length) console.log('No Codex-only model rules.');
35
109
  let events = [];
36
110
  try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
37
111
  const inReview = new Set(codexRulesInReview({ rules, events, root: null }).map((x) => x.index));
@@ -41,10 +115,10 @@ export function run({ args, getArg, hasFlag, root }) {
41
115
  const warn = inReview.has(i + 1) ? ` | rule-health: ${h.errs}/${h.runs} runs failed, narrow the condition or remove (delegate rules rm ${i + 1} --agent codex)` : '';
42
116
  console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
43
117
  });
118
+ printShared(cfg, { details: true });
44
119
  return;
45
120
  }
46
- if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules --agent codex');
47
- const cfg = loadConfig();
121
+ if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules|shared --agent codex');
48
122
  let target;
49
123
  if (sub === 'model') target = args[2] === 'off' ? null : validateCodexTarget(args[2], getArg('--effort'));
50
124
  else if (getArg('--model') !== undefined) target = validateCodexTarget(getArg('--model'), getArg('--effort'));
@@ -63,6 +137,7 @@ export function run({ args, getArg, hasFlag, root }) {
63
137
  let ruleCount;
64
138
  try { ruleCount = String(loadCodexModelRules().length); } catch (e) { ruleCount = `unreadable (${e.message})`; }
65
139
  console.log(`Codex rules: ${ruleCount}`);
140
+ printShared(cfg);
66
141
  console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
67
142
  const saved = codexRoutingSavedTotals();
68
143
  console.log(saved.priced
@@ -26,11 +26,16 @@ Usage: sprag <command> --agent codex [options]
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
28
  delegate model <model> Default target for spawns without an explicit model
29
- delegate rules Codex-only category rules; add/rm with explicit scope
29
+ delegate rules Codex rules and shared delegation policies
30
+ delegate shared on|off|status Reuse approved policies; keep original scopes
31
+ delegate shared map T1|T2 Map a tier: --from <parent> --model <target>
32
+ Optional --provider <id> and --effort <level>
33
+ delegate shared unmap T1|T2 Remove a mapping: --from <parent> [--provider <id>]
34
+ delegate shared min-context N Parent input tokens before shared guidance; default resets
30
35
  route-scan [--refresh] Discover recurring simple turns in local logs
31
36
  route-scan dismiss R<N> Dismiss a candidate without registering a rule
32
37
  route-scan savings Attributed routing estimates; --refresh or --format json
33
- seed [accept|skip|reset] Bundled Codex ratchet presets; acceptance needs a scope
38
+ seed [accept|skip|reset] Shared delegation and Codex ratchet presets; explicit scope
34
39
  cache [auto|off|<duration>] Display timer policy (e.g. 30m); never changes retention
35
40
  cache status --refresh Resolve the session's gateway deployment and cache policy
36
41
  harness init|uninit|check|promote|list|rm|prune
@@ -413,6 +418,7 @@ export async function run({ args, version }) {
413
418
  console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
414
419
  if (panel.mode === 'window') console.log('Some Codex versions defer SessionStart until the first prompt. For immediate opening on typing codex: sprag panel shell install --agent codex (optional zsh integration).');
415
420
  console.log('Existing config.toml, Claude Code settings, and ratchet rules are preserved.');
421
+ console.log('First-use delegation: sprag seed --agent codex, then sprag delegate shared status --agent codex. Approve a scope and map the tiers before enabling delegation. No Claude installation is required.');
416
422
  console.log('Korean guidance is opt-in: sprag korean on --agent codex');
417
423
  return;
418
424
  }
@@ -58,9 +58,13 @@ export async function run({ args, hasFlag }) {
58
58
  }
59
59
 
60
60
  if (!sub || sub === 'status' || sub === 'check') {
61
- const s = cw.compactWindowStatus({ root });
61
+ // The statusline knows the real window; the settings model id often does
62
+ // not (a bare `opus` carries no `[1m]`), so the last reported size decides.
63
+ let liveWindow = null;
64
+ try { liveWindow = (await import('../ctx-window-cache.js')).latestLiveWindow(); } catch { /* id-only fallback */ }
65
+ const s = cw.compactWindowStatus({ root, liveWindow });
62
66
  console.log(`model: ${s.model || '(not set — Claude Code default)'}${s.modelSource ? ` [${s.modelSource}]` : ''}`);
63
- console.log(`window: ${s.is1m ? '1M context' : '200k context'}`);
67
+ console.log(`window: ${s.is1m ? '1M context' : '200k context'}${liveWindow ? ` [statusline: ${liveWindow >= 1e6 ? `${liveWindow / 1e6}M` : fmt(liveWindow)}]` : ''}`);
64
68
  console.log(`autoCompactWindow: ${s.window === null ? '(unset)' : `${s.window} (${fmt(s.window)})`}${s.windowSource ? ` [${s.windowSource}${s.windowPath ? ` → ${s.windowPath}` : ''}]` : ''}`);
65
69
  if (s.ok && s.reason === 'not-1m') {
66
70
  console.log(ko
@@ -251,8 +251,11 @@ export async function run({ hasFlag }) {
251
251
  // good, and upgrading would silently leave shell-written files
252
252
  // unchecked. installKoreanLintHook widens in place and is idempotent.
253
253
  if (ks.koreanStyleEnabled()) {
254
- const { installKoreanLintHook } = await import('../installer.js');
254
+ const { installKoreanLintHook, installReplyLanguageHook } = await import('../installer.js');
255
255
  const migrated = installKoreanLintHook();
256
+ // Older installs have the lint hook but no Stop hook; add it here so
257
+ // an upgrade closes the English-reply gap without a manual `korean on`.
258
+ installReplyLanguageHook();
256
259
  if (migrated.action === 'updated') {
257
260
  console.log(lang === 'ko'
258
261
  ? ' 검사 범위 갱신: Bash 로 쓴 파일도 이제 확인합니다'
@@ -20,6 +20,10 @@
20
20
  * wrote:
21
21
  * sprag korean lint block|warn|off # how findings are handled
22
22
  * sprag korean lint <file...> # check files on disk
23
+ *
24
+ * The third half is a Stop hook for the one slip a write-time check cannot
25
+ * see: an English reply to a Korean prompt, which writes no file at all.
26
+ * sprag korean reply on|off # toggle it (default on)
23
27
  */
24
28
 
25
29
  // Enforcement is only useful if it is on by default — a check the user has to
@@ -39,6 +43,12 @@ function koreanLintScope(cfg) {
39
43
  return cfg?.koreanStyle?.lintScope === 'prose' ? 'prose' : 'all';
40
44
  }
41
45
 
46
+ // Default on, like the lint: a check the user has to discover is the same
47
+ // hole in a different shape. Only an explicit false turns it off.
48
+ function koreanReplyCheckOn(cfg) {
49
+ return cfg?.koreanReplyCheck !== false;
50
+ }
51
+
42
52
  export async function run({ args, hasFlag }) {
43
53
  const sub = args[1] || 'status';
44
54
  const ks = await import('../korean-style.js');
@@ -72,6 +82,55 @@ export async function run({ args, hasFlag }) {
72
82
  return;
73
83
  }
74
84
 
85
+ // Stop hook. Fires when the model finishes a turn; if the reply is English
86
+ // to a Korean prompt, block the stop so the model answers again in Korean.
87
+ // Imports stay minimal on purpose: this runs after every turn.
88
+ if (hasFlag?.('--reply-hook') || sub === '--reply-hook') {
89
+ const { readStdinJson } = await import('../stdin-payload.js');
90
+ const payload = readStdinJson();
91
+ if (!payload) return;
92
+ // Claude Code sets stop_hook_active while the model is already continuing
93
+ // because of a Stop hook. Blocking again would loop forever.
94
+ if (payload.stop_hook_active) return;
95
+ if (!ks.koreanStyleEnabled()) return;
96
+ if (!koreanReplyCheckOn(loadConfig())) return;
97
+ const { createRequire } = await import('node:module');
98
+ const found = createRequire(import.meta.url)('../reply-language.cjs').checkReplyLanguage(payload.transcript_path);
99
+ if (!found) return;
100
+ console.log(JSON.stringify({
101
+ decision: 'block',
102
+ reason: '사용자는 한국어로 질문했는데 마지막 답변이 영어로 작성됐습니다. 같은 내용을 한국어로 다시 답하십시오. 코드, 명령어, 오류 원문, 커밋 메시지처럼 원문을 유지해야 하는 부분은 그대로 둡니다.',
103
+ }));
104
+ return;
105
+ }
106
+
107
+ // Toggle: `korean reply on|off`. Off keeps the hook installed and makes it
108
+ // a no-op, the same way lint mode `off` works; `korean off` removes it.
109
+ if (sub === 'reply') {
110
+ const cfg = loadConfig();
111
+ const next = args[2];
112
+ if (!next) {
113
+ console.log(lang === 'ko'
114
+ ? `답변 언어 검사: ${koreanReplyCheckOn(cfg) ? '켜짐' : '꺼짐'}`
115
+ : `Reply-language check: ${koreanReplyCheckOn(cfg) ? 'on' : 'off'}`);
116
+ return;
117
+ }
118
+ if (!['on', 'off'].includes(next)) {
119
+ console.error('Usage: sprag korean reply [on|off]');
120
+ process.exit(1);
121
+ }
122
+ cfg.koreanReplyCheck = next === 'on';
123
+ saveConfig(cfg);
124
+ console.log(next === 'on'
125
+ ? (lang === 'ko'
126
+ ? '답변 언어 검사를 켰습니다. 한국어 질문에 영어로 답하면 모델에게 되돌려 보내 다시 답하게 합니다.'
127
+ : 'Reply-language check is on. An English reply to a Korean prompt is sent back for a Korean answer.')
128
+ : (lang === 'ko'
129
+ ? '답변 언어 검사를 껐습니다. 훅은 남아 있지만 아무 일도 하지 않습니다.'
130
+ : 'Reply-language check is off. The hook stays installed and does nothing.'));
131
+ return;
132
+ }
133
+
75
134
  // Manual run over files already on disk: `korean lint docs/*.md`.
76
135
  if (sub === 'lint' && args.length > 2 && !['on', 'off', 'warn', 'block', 'scope'].includes(args[2])) {
77
136
  const { readFileSync } = await import('node:fs');
@@ -116,8 +175,8 @@ export async function run({ args, hasFlag }) {
116
175
  saveConfig(cfg);
117
176
  console.log(lang === 'ko'
118
177
  ? (next === 'all'
119
- ? '검사 범위: all — 문서와 코드 주석, UI 문자열까지 세션이 쓴 모든 텍스트 파일을 검사합니다.'
120
- : '검사 범위: prose — 마크다운과 텍스트 문서만 검사합니다.')
178
+ ? '검사 범위를 all 로 바꿨습니다. 문서와 코드 주석, UI 문자열까지 세션이 쓴 모든 텍스트 파일을 검사합니다.'
179
+ : '검사 범위를 prose 로 좁혔습니다. 마크다운과 텍스트 문서만 검사합니다.')
121
180
  : (next === 'all'
122
181
  ? 'Check scope: all — every text file the session writes, comments and UI strings included.'
123
182
  : 'Check scope: prose — documents only.'));
@@ -163,8 +222,13 @@ export async function run({ args, hasFlag }) {
163
222
  if (sub === 'on' || sub === 'off') {
164
223
  const enabled = sub === 'on';
165
224
  ks.setKoreanStyleEnabled(enabled);
166
- const { installKoreanLintHook, removeKoreanLintHook } = await import('../installer.js');
225
+ const {
226
+ installKoreanLintHook, removeKoreanLintHook,
227
+ installReplyLanguageHook, removeReplyLanguageHook,
228
+ } = await import('../installer.js');
167
229
  const hook = enabled ? installKoreanLintHook() : removeKoreanLintHook();
230
+ if (enabled) installReplyLanguageHook();
231
+ else removeReplyLanguageHook();
168
232
  if (enabled) {
169
233
  console.log(lang === 'ko'
170
234
  ? '한국어 문체 지침을 켰습니다. 다음 세션부터 모든 프로젝트에 적용됩니다.'
@@ -202,6 +266,7 @@ export async function run({ args, hasFlag }) {
202
266
  console.log(`한국어 문체 지침: ${on ? '켜짐' : '꺼짐'}`);
203
267
  console.log(` 비용: 세션당 약 ${tokens} 토큰 (세션 시작 1회 주입)`);
204
268
  console.log(` 쓰기 시점 검사: ${koreanLintMode(loadConfig())}, 범위 ${koreanLintScope(loadConfig())} (바꾸려면 sprag korean lint block|warn|off, korean lint scope all|prose)`);
269
+ console.log(` 답변 언어 검사: ${koreanReplyCheckOn(loadConfig()) ? '켜짐' : '꺼짐'} (바꾸려면 sprag korean reply on|off)`);
205
270
  console.log(` 출처: ${ks.KOREAN_STYLE_SOURCE}`);
206
271
  console.log(` 라이선스 전문: ${ks.KOREAN_STYLE_LICENSE_PATH}`);
207
272
  console.log(on
@@ -211,6 +276,7 @@ export async function run({ args, hasFlag }) {
211
276
  console.log(`Korean writing guidance: ${on ? 'on' : 'off'}`);
212
277
  console.log(` Cost: ~${tokens} tokens per session (injected once at session start)`);
213
278
  console.log(` Write-time check: ${koreanLintMode(loadConfig())}, scope ${koreanLintScope(loadConfig())} (change with: sprag korean lint block|warn|off, korean lint scope all|prose)`);
279
+ console.log(` Reply-language check: ${koreanReplyCheckOn(loadConfig()) ? 'on' : 'off'} (change with: sprag korean reply on|off)`);
214
280
  console.log(` Source: ${ks.KOREAN_STYLE_SOURCE}`);
215
281
  console.log(` License text: ${ks.KOREAN_STYLE_LICENSE_PATH}`);
216
282
  console.log(on
@@ -360,12 +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 } = await import('../update-check.js');
363
+ const { updateStatus, maybeSpawnUpdateCheck, SESSION_START_CHECK_INTERVAL_MS } = await import('../update-check.js');
364
364
  const pkgVersion = readPackageVersion();
365
365
  // Session start is the one moment where a stale cache matters most, so
366
- // kick the detached refresh here too. It lands in time for the
367
- // statusline and for the next session, not for this line.
368
- if (pkgVersion) maybeSpawnUpdateCheck(pkgVersion);
366
+ // kick the detached refresh here too, on a shorter interval than the
367
+ // statusline's 24h: releases can land several times a day, and a
368
+ // session opened that afternoon should hear about them. It lands in
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 });
369
371
  const u = pkgVersion ? updateStatus(pkgVersion) : null;
370
372
  // `dismissed` is the whole point of asking once: a user who said no to
371
373
  // this version must not be asked again every `/clear`.
@@ -8,8 +8,15 @@
8
8
  *
9
9
  * Nothing is deleted. A reset only records a timestamp, and the totals count
10
10
  * events from that moment on; see src/saved-reset.cjs.
11
+ *
12
+ * The routing mark also bounds the per-rule `saved ~$` figures in
13
+ * model-rules.json and ratchet-model.md, which are rebuilt by a scan rather than
14
+ * summed from the ledger. So a reset or undo that touches `routing` finishes by
15
+ * running that scan; see refreshRuleSavings.
11
16
  */
12
17
 
18
+ import { debug } from '../debug.js';
19
+
13
20
  const SCOPE_SETS = {
14
21
  claude: ['routing', 'doc2md'],
15
22
  codex: ['codex-routing', 'codex-docs'],
@@ -33,7 +40,32 @@ async function currentTotals(scope, dir) {
33
40
  return { docs: codexDocumentTotals({ dir }).docs };
34
41
  }
35
42
 
36
- export async function run({ args, hasFlag, agent = 'claude', dir: dirArg }) {
43
+ /**
44
+ * Rebuild each rule's savedUsd and re-render ratchet-model.md after the routing
45
+ * mark moved. The 14-day scan does both (it reads the mark, refreshes the
46
+ * registry, writes the managed files) in about half a second, so it is reused
47
+ * rather than duplicated here.
48
+ *
49
+ * Best-effort by design: the mark is already saved, and a failed rescan must not
50
+ * turn a successful reset into an error. It says so in one line instead, since
51
+ * the figures then stay stale until the next scan.
52
+ *
53
+ * @param {boolean} ko
54
+ * @param {Function} [rescan] injected by tests; defaults to runRouteScan
55
+ */
56
+ async function refreshRuleSavings(ko, rescan) {
57
+ try {
58
+ const scan = rescan || (await import('../route-scan.js')).runRouteScan;
59
+ await scan({ days: 14 });
60
+ } catch (e) {
61
+ debug('saved:rule-savings', e);
62
+ console.log(ko
63
+ ? '룰별 절감액은 다음 route-scan 때 다시 계산됩니다.'
64
+ : 'Per-rule savings will be recalculated at the next route-scan.');
65
+ }
66
+ }
67
+
68
+ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg, rescan }) {
37
69
  const { userLanguage } = await import('../config.js');
38
70
  const { userDataDir } = await import('../paths.js');
39
71
  const { readResetMarks, resetSaved, undoReset } = await import('../saved-reset.cjs');
@@ -90,6 +122,9 @@ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg }) {
90
122
  console.log(ko
91
123
  ? `원장 기록은 지우지 않았습니다. \`sprag saved undo${suffix}\`로 되돌릴 수 있습니다.`
92
124
  : `No ledger data was deleted. Run \`sprag saved undo${suffix}\` to restore the previous counters.`);
125
+ // Codex keeps no per-rule figure (its rules are a separate store with no
126
+ // savedUsd), so only the Claude `routing` scope has anything to recompute.
127
+ if (targets.includes('routing')) await refreshRuleSavings(ko, rescan);
93
128
  return;
94
129
  }
95
130
 
@@ -105,6 +140,10 @@ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg }) {
105
140
  ? `${label(s)} 카운터를 되돌렸습니다. 현재 합계는 ${fmtTotal(t)}입니다.`
106
141
  : `Restored the ${label(s)} counter. The total is now ${fmtTotal(t)}.`);
107
142
  }
143
+ // Keyed on what was undone, not on --agent: undo pops the latest reset
144
+ // whichever agent made it, so a bare `undo --agent codex` can restore
145
+ // the Claude routing mark and the rule figures must follow it.
146
+ if (undone.scopes.includes('routing')) await refreshRuleSavings(ko, rescan);
108
147
  return;
109
148
  }
110
149
 
@@ -59,3 +59,17 @@ export function liveWindowFor(sessionId) {
59
59
  const s = load().sessions[sessionId];
60
60
  return s && validSize(s.size) ? s.size : null;
61
61
  }
62
+
63
+ /**
64
+ * The window most recently reported by any session's statusline, or null.
65
+ * `sprag compact-window` runs outside a session and has no payload of its own;
66
+ * judged by the settings model id alone, a bare `opus` (1M by default on Opus
67
+ * 5.5, with no `[1m]` suffix) read as 200k while the statusline said 1M.
68
+ */
69
+ export function latestLiveWindow() {
70
+ let best = null;
71
+ for (const s of Object.values(load().sessions)) {
72
+ if (s && validSize(s.size) && Number.isFinite(s.ts) && (!best || s.ts > best.ts)) best = s;
73
+ }
74
+ return best ? best.size : null;
75
+ }