sprag-cli 3.56.0 → 3.58.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 (53) hide show
  1. package/README.ko.md +60 -40
  2. package/README.md +63 -41
  3. package/bin/cli.js +65 -19
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +5 -1
  6. package/docs/CODEX.md +5 -0
  7. package/package.json +14 -26
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +23 -2
  11. package/src/codex-brief.js +35 -2
  12. package/src/codex-delegation.js +12 -4
  13. package/src/codex-harness.js +43 -9
  14. package/src/codex-hooks.js +24 -3
  15. package/src/codex-ledger.js +61 -5
  16. package/src/codex-panel.js +42 -22
  17. package/src/codex-rule-health.js +24 -1
  18. package/src/commands/brief.js +14 -3
  19. package/src/commands/codex-delegate.js +7 -4
  20. package/src/commands/codex.js +2 -0
  21. package/src/commands/harness.js +6 -0
  22. package/src/commands/install.js +24 -13
  23. package/src/compact-window.js +12 -4
  24. package/src/config.js +3 -3
  25. package/src/cost.js +5 -2
  26. package/src/ctx-window-cache.js +61 -0
  27. package/src/delegation-guard.js +15 -9
  28. package/src/doc2md.cjs +22 -8
  29. package/src/fig2md.cjs +7 -1
  30. package/src/formatters/statusline.js +4 -4
  31. package/src/formatters/table.js +18 -10
  32. package/src/gateway-auth.js +31 -3
  33. package/src/harness.js +43 -12
  34. package/src/history.js +6 -2
  35. package/src/hook-manager.js +54 -32
  36. package/src/installer.js +315 -158
  37. package/src/korean-lint.cjs +40 -7
  38. package/src/litellm-budget.js +10 -1
  39. package/src/model-rules.js +88 -41
  40. package/src/month-spend.js +17 -11
  41. package/src/parser.js +232 -56
  42. package/src/postinstall.js +32 -0
  43. package/src/prompt.js +28 -0
  44. package/src/route-caps.js +74 -0
  45. package/src/route-inject.js +31 -7
  46. package/src/route-scan.js +78 -26
  47. package/src/savings-ledger.js +33 -5
  48. package/src/seed-rules.js +3 -4
  49. package/src/session-cache.js +35 -1
  50. package/src/state-file.js +43 -0
  51. package/src/stats.js +20 -5
  52. package/src/statusline-base.js +37 -0
  53. package/src/stdin-payload.js +36 -5
@@ -73,8 +73,21 @@ const METAPHOR_LEXICON = [
73
73
  // `자리` is the single most common substitution for a plain noun. Physical
74
74
  // seating is the rare literal use, so those modifiers are excluded and
75
75
  // everything else is raised for confirmation.
76
- { re: /(?<!빈 |좌석 |앞 |뒤 |옆 )(?:^|(?<=\s))자리(?:에서|에서는|에|로|가|는|다|입니다|였습니다)?(?=\s|$|[.,·)])/, fix: "'지점'·'상황'·'부분'처럼 뜻을 그대로 담은 명사로 바꿉니다" },
77
- { re: /[가-힣]+의\s*흐름/, fix: "'방향'·'순서'·'경과'로 바꿉니다" },
76
+ //
77
+ // `except` (2026-10-02): counting digits is the other literal use. `한 자리
78
+ // 밀리초`, `세 자리 수`, `소수점 둘째 자리`, `일의 자리` name a digit
79
+ // position, and no plainer noun exists for it. A count followed by a particle
80
+ // (`한 자리에서 돈다`) is still raised: that one means a place. Corpus check:
81
+ // 149 lines before, 147 after, and the two dropped were both `한 자리 밀리초`.
82
+ {
83
+ re: /(?<!빈 |좌석 |앞 |뒤 |옆 )(?:^|(?<=\s))자리(?:에서|에서는|에|로|가|는|다|입니다|였습니다)?(?=\s|$|[.,·)])/,
84
+ except: /(?:^|\s)(?:\d+|한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|몇) 자리(?=\s|$)|(?:첫째|둘째|셋째|넷째|다섯째|[일십백천만]의) 자리|자리 ?(?:수|숫자)(?![가-힣]{2})/,
85
+ fix: "'지점'·'상황'·'부분'처럼 뜻을 그대로 담은 명사로 바꿉니다",
86
+ },
87
+ // The lookbehind pins the match to the start of a Hangul run. Without it the
88
+ // engine retried `[가-힣]+` from every character of a long run that has no
89
+ // `의 흐름` in it, which is quadratic: a 200k-character run took 20 seconds.
90
+ { re: /(?<![가-힣])[가-힣]+의\s*흐름/, fix: "'방향'·'순서'·'경과'로 바꿉니다" },
78
91
  { re: /닿(?:는다|습니다|아|는 지점)/, fix: "'겨냥하다'·'해당하다'처럼 동작을 그대로 서술합니다" },
79
92
  { re: /박(?:아 두|혀 있|아 넣)/, fix: "'명시하다'·'기록하다'로 바꿉니다" },
80
93
  { re: /걷어내/, fix: "'없애다'·'제거하다'로 바꿉니다" },
@@ -106,17 +119,24 @@ const METAPHOR_LEXICON = [
106
119
  const TRANSLATIONESE = [
107
120
  { re: /에 대한/, fix: '서술어로 풀어 씁니다' },
108
121
  { re: /[을를] 위한/, fix: '서술어로 풀어 씁니다' },
109
- { re: /되어지/, fix: '이중 피동을 없애고 능동이나 단일 피동으로 씁니다' },
122
+ // Every conjugation, not the stem alone: `되어졌다`, `되어진`, `되어질`.
123
+ { re: /되어[지진질져졌집]/, fix: '이중 피동을 없애고 능동이나 단일 피동으로 씁니다' },
110
124
  { re: /하는 것을 통해/, fix: "'~해서'·'~함으로써'로 줄입니다" },
111
125
  { re: /라고 할 수 있다/, fix: '단정하거나 근거를 붙여 서술합니다' },
112
126
  // Conservative additions (2026-09-13), sourced from 국립국어원 공공언어
113
127
  // 지침·한글문화연대 교정 사례·쿠버네티스 한글화 가이드. Bar for inclusion:
114
128
  // the form is nearly always an improvement to change, so a confirm-request
115
129
  // on it is rarely noise. See presets/korean-style/supplement.md.
116
- { re: /(?:보여|쓰여|불려|잊혀)[지집진질져]/, fix: "이중 피동입니다. '보인다'·'쓰인'·'불린'·'잊힌'처럼 단일 피동으로 씁니다" },
130
+ // `졌` is the past tense (`보여졌다`, `쓰여졌습니다`), which the first list missed.
131
+ { re: /(?:보여|쓰여|불려|잊혀)[지집진질져졌]/, fix: "이중 피동입니다. '보인다'·'쓰인'·'불린'·'잊힌'처럼 단일 피동으로 씁니다" },
117
132
  { re: /에 다름 아니/, fix: "일본어 번역투입니다. '~일 뿐이다'·'바로 ~이다'로 바꿉니다" },
118
133
  { re: /지 않으면 안 [되된됩돼]/, fix: "이중 부정 번역투입니다. '~해야 합니다'로 바꿉니다" },
119
- { re: /(?<!여기|거기|저기|어디)에 있어서/, fix: "'~에서'·'~에는'으로 바꿉니다" },
134
+ // `X에 있어서` also means "because it is at X", which is plain Korean. Two
135
+ // signs separate that reading without touching the calque: a noun that names
136
+ // a location or a container before it, or the clause ending right after it
137
+ // (the calque is never clause-final). Corpus check: 4 hits before, 3 after,
138
+ // the dropped one a quotation ending in `베드락에 있어서`.
139
+ { re: /(?<!여기|거기|저기|어디|(?:^|\s)(?:안|밖|위|아래|옆|앞|뒤|속)|서버|폴더|디렉터리|디렉토리|경로|파일|목록|메모리|디스크|저장소|캐시|변수|로컬|원격)에 있어서(?!요|["'”’)\]]|[.!?]|$)/, fix: "'~에서'·'~에는'으로 바꿉니다" },
120
140
  { re: /(?:의미|특징|장점|단점|성격|가능성|중요성|효과)[을를] 가지고 있/, fix: "'~이다'·'~가 있다'로 바꿉니다 (have 직역)" },
121
141
  ];
122
142
 
@@ -201,6 +221,19 @@ function stripCode(text) {
201
221
  return out;
202
222
  }
203
223
 
224
+ /**
225
+ * First match of `re` whose surroundings are not one of the literal uses in
226
+ * `except`. Every match is tried, so a literal use early in the line does not
227
+ * hide a figurative one after it.
228
+ */
229
+ function firstUnexcepted(line, re, except) {
230
+ for (const m of line.matchAll(new RegExp(re.source, 'g'))) {
231
+ const around = line.slice(Math.max(0, m.index - 8), m.index + m[0].length + 4);
232
+ if (!except.test(around)) return m;
233
+ }
234
+ return null;
235
+ }
236
+
204
237
  function hasKorean(s) {
205
238
  return /[가-힣]/.test(s);
206
239
  }
@@ -243,8 +276,8 @@ function lintKoreanText(text, { maxFindings = 20, code = false } = {}) {
243
276
  if (m) push('번역체', m[0], fix);
244
277
  }
245
278
 
246
- for (const { re, fix } of METAPHOR_LEXICON) {
247
- const m = line.match(re);
279
+ for (const { re, except, fix } of METAPHOR_LEXICON) {
280
+ const m = except ? firstUnexcepted(line, re, except) : line.match(re);
248
281
  if (m) push('비유 어휘', m[0], fix);
249
282
  }
250
283
 
@@ -159,6 +159,12 @@ export function formatBudgetReport(state, now = new Date(), mode = 'icon') {
159
159
  return lines;
160
160
  }
161
161
 
162
+ /** 갱신을 시도했다는 기록입니다. 같은 게이트웨이의 금액만 이어받습니다. */
163
+ export function stampAttempt(state, base, now) {
164
+ const kept = state && state.base === base ? state : {};
165
+ return { ...kept, base, checkedAt: now };
166
+ }
167
+
162
168
  /** 캐시가 오래됐으면 detached 자식으로 갱신을 예약합니다. 즉시 반환. */
163
169
  export function maybeSpawnBudgetCheck(env = process.env) {
164
170
  // 키 확보는 자식이 담당합니다. 렌더 경로에서 헬퍼를 돌리면 통계선이 느려집니다.
@@ -168,8 +174,11 @@ export function maybeSpawnBudgetCheck(env = process.env) {
168
174
  const age = Date.now() - (Number(s.checkedAt) || 0);
169
175
  if (s.base === base && age < CHECK_INTERVAL_MS) return false;
170
176
  // 오프라인/오류 시 렌더마다 자식을 다시 띄우지 않도록 시도 시각을 먼저 기록합니다.
177
+ // 게이트웨이가 바뀌었으면 이전 게이트웨이의 금액은 버립니다. 금액을 남긴 채
178
+ // base 만 새 주소로 바꾸면, budgetWindow 의 base 격리를 통과해 갱신이 끝날
179
+ // 때까지(오프라인이면 계속) 다른 프록시의 지출이 새 프록시의 게이지로 나옵니다.
171
180
  try {
172
- writeBudgetState({ ...s, base, checkedAt: Date.now() });
181
+ writeBudgetState(stampAttempt(s, base, Date.now()));
173
182
  } catch (e) {
174
183
  debug('litellm-budget:stamp', e);
175
184
  return false;
@@ -34,6 +34,7 @@ import { homedir } from 'node:os';
34
34
  import { userDataDir } from './paths.js';
35
35
  import { userLanguage } from './config.js';
36
36
  import { agentPhrase, agentPhraseEn } from './agents.js';
37
+ import { writeStateFile } from './state-file.js';
37
38
  import { signedUsd } from './money.js';
38
39
 
39
40
  // Post-promotion delegated-category error rate above this flags the rule
@@ -72,9 +73,14 @@ export function wilsonLowerBound(errs, n) {
72
73
  // close the cycle.
73
74
  const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 } };
74
75
 
76
+ /** The budget a rule is held to: its calibrated one, or the default of its tier. */
77
+ export function ruleBudget(rule) {
78
+ return rule.budget || DEFAULT_BUDGET[rule.tier] || DEFAULT_BUDGET.T2;
79
+ }
80
+
75
81
  /** The cap half of a budget clause, e.g. "도구 호출 8회·출력 1500 토큰". */
76
82
  export function budgetCapPhrase(rule, lang = userLanguage()) {
77
- const b = rule.budget || DEFAULT_BUDGET[rule.tier] || DEFAULT_BUDGET.T2;
83
+ const b = ruleBudget(rule);
78
84
  if (lang === 'ko') {
79
85
  return b.calls ? `도구 호출 ${b.calls}회·출력 ${b.out} 토큰` : `출력 ${b.out} 토큰`;
80
86
  }
@@ -125,15 +131,15 @@ function shortExample(text) {
125
131
  return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
126
132
  }
127
133
 
128
- export function modelRuleBaseText(g, lang = userLanguage()) {
134
+ export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
129
135
  if (lang === 'ko') {
130
136
  return g.tier === 'T2'
131
- ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent)}에 위임`
137
+ ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
132
138
  : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
133
139
  }
134
140
  const example = g.exampleEn || g.example;
135
141
  return g.tier === 'T2'
136
- ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent)}`
142
+ ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
137
143
  : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
138
144
  }
139
145
 
@@ -155,30 +161,30 @@ export function loadModelRules() {
155
161
  }
156
162
 
157
163
  /**
158
- * USD that measured delegations have saved, summed across registered rules.
159
- * Reads the file route-scan already wrote — the statusline refreshes every few
160
- * seconds and must never start a scan of its own (a scan parses tens of MB of
161
- * transcripts).
162
- *
163
- * Rules with no measured delegation contribute nothing, and an unreadable
164
- * registry returns 0 rather than throwing: the statusline reads 0 as "draw no
165
- * chip", which is the right outcome for anyone who never delegates.
164
+ * The one rule a delegated run in `projectDir` is credited to. A project rule
165
+ * for that project comes first, then a global rule promoted from it, then the
166
+ * first other global rule. Rule health (refreshModelRules) and the savings
167
+ * ledger (route-scan) both ask here, so a run is never credited to two rules
168
+ * and the two never disagree about whose it was.
166
169
  */
167
- export function delegationSavedUsd() {
168
- try {
169
- return loadModelRules().rules.reduce(
170
- (sum, r) => sum + (r.delegatedRuns ? (Number(r.savedUsd) || 0) : 0),
171
- 0,
172
- );
173
- } catch {
174
- return 0;
170
+ export function ruleForProject(rules, tier, category, projectDir) {
171
+ let promotedHere = null;
172
+ let firstGlobal = null;
173
+ for (const r of rules || []) {
174
+ if (!r || r.status === 'off' || r.tier !== tier || r.category !== category) continue;
175
+ if (r.scope !== 'global') {
176
+ if (r.project === projectDir) return r;
177
+ } else if (r.project === projectDir) {
178
+ promotedHere = promotedHere || r;
179
+ } else {
180
+ firstGlobal = firstGlobal || r;
181
+ }
175
182
  }
183
+ return promotedHere || firstGlobal;
176
184
  }
177
185
 
178
186
  export function saveModelRules(data) {
179
- const dir = stateDir();
180
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
181
- writeFileSync(modelRulesPath(), JSON.stringify(data, null, 2) + '\n');
187
+ writeStateFile(modelRulesPath(), JSON.stringify(data, null, 2) + '\n');
182
188
  }
183
189
 
184
190
  /** Add (or re-activate) a promoted rule; returns the stored entry. */
@@ -210,8 +216,12 @@ export function removeModelRule(index1) {
210
216
  * The LLM reads this file as instructions, so it is written in the user's
211
217
  * configured language — a Korean-only file would pull an English session's
212
218
  * responses into Korean.
219
+ *
220
+ * `root` is where a named subagent is looked up: the project the file belongs
221
+ * to, or null for the global file, which may only name agents the user
222
+ * directory holds (see agentExists). Left out, it is the current directory.
213
223
  */
214
- export function renderModelRatchet(rules, lang = userLanguage()) {
224
+ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {}) {
215
225
  const ko = lang === 'ko';
216
226
  const lines = ko ? [
217
227
  '# Model-Fitting Ratchet (sprag 자동 관리)',
@@ -377,9 +387,23 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
377
387
  if (!byCategory.has(r.category)) byCategory.set(r.category, []);
378
388
  byCategory.get(r.category).push(r);
379
389
  }
390
+ // The sentence comes from this version's template in the file's language. The
391
+ // text stored on the rule is whatever wording and language were current when
392
+ // it was promoted, which is how an English line sat in a Korean file. A rule
393
+ // missing what the template needs still falls back to its stored text.
394
+ const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1') && r.label && (ko || r.labelEn)
395
+ ? modelRuleBaseText(r, lang, { root }) : r.rule);
380
396
  for (const group of byCategory.values()) {
381
397
  const t2 = group.find((r) => r.tier === 'T2');
382
398
  const t1 = group.find((r) => r.tier === 'T1');
399
+ // A second rule of a tier the line already covers (two global rules of one
400
+ // category, promoted from different projects) says nothing the line does
401
+ // not, so it adds no line of its own. Its review flag and its numbers stay
402
+ // visible on the line that speaks for it.
403
+ const extras = t2 || t1 ? group.filter((r) => r !== t2 && r !== t1 && (r.tier === 'T2' || r.tier === 'T1')) : [];
404
+ const unknown = group.filter((r) => r !== t2 && r !== t1 && !extras.includes(r));
405
+ const extraHealth = extras.map(healthOf).join('');
406
+ const extraStats = extras.map((r) => ` / +${r.tier} ${statsOf(r)}`).join('');
383
407
  if (t2 && t1) {
384
408
  // Seeded rules carry a localized example; scan-produced ones only have the
385
409
  // user's own prompt, so exampleEn is a preference, not a requirement.
@@ -390,19 +414,18 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
390
414
  // clauses, so a rule the model re-reads on every request is short enough to
391
415
  // match at a glance.
392
416
  const rule = ko
393
- ? `"${t2.label}": 기본 ${agentPhrase(t2.agent)}(예: "${ex(t2)}"), ` +
417
+ ? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
394
418
  `여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
395
419
  mergedBudget(t2, t1)
396
- : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent)} by default (e.g. "${ex(t2)}"), ` +
420
+ : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
397
421
  `model: sonnet when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
398
422
  mergedBudget(t2, t1);
399
- lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
400
- for (const r of group) {
401
- if (r !== t2 && r !== t1) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
402
- }
403
- } else {
404
- for (const r of group) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
423
+ lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
424
+ } else if (t2 || t1) {
425
+ const r = t2 || t1;
426
+ lines.push(`- ${composeRuleText(baseTextOf(r), r, lang)}${healthOf(r)}${extraHealth} <!-- ${statsOf(r)}${extraStats} -->`);
405
427
  }
428
+ for (const r of unknown) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
406
429
  }
407
430
  return lines.join('\n') + '\n';
408
431
  }
@@ -467,7 +490,10 @@ export function syncAllFiles({ previousPaths = [] } = {}) {
467
490
  };
468
491
  const written = [];
469
492
  for (const [p, rules] of byPath) {
470
- if (writeIfChanged(p, renderModelRatchet(rules))) written.push(p);
493
+ // Agents are looked up where the file's readers are: the project for a
494
+ // project file, the user directory alone for the global one.
495
+ const root = rules[0].scope === 'global' ? null : rules[0].targetRoot;
496
+ if (writeIfChanged(p, renderModelRatchet(rules, undefined, { root }))) written.push(p);
471
497
  }
472
498
  // A target that lost its last rule is emptied, NOT deleted: CLAUDE.md
473
499
  // imports this path, and a dangling `@` import is worse than an empty file.
@@ -497,19 +523,40 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
497
523
  let changed = false;
498
524
  const pick = (stats, r) => stats.get(`${r.tier}|${r.category}|${r.project}`)
499
525
  || (r.scope === 'global' ? stats.get(`${r.tier}|${r.category}|*`) : null);
526
+ // Delegated outcomes are money, so each project's runs go to exactly one
527
+ // rule. Handing every global rule the `*` aggregate counted the same runs
528
+ // once per global rule of that tier and category.
529
+ const owned = new Map();
530
+ for (const [key, d] of delegatedStats) {
531
+ const [tier, category, ...rest] = key.split('|');
532
+ const project = rest.join('|');
533
+ if (project === '*') continue;
534
+ const rule = ruleForProject(data.rules, tier, category, project);
535
+ if (!rule) continue;
536
+ const o = owned.get(rule) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
537
+ o.runs += d.runs || 0;
538
+ o.errRuns += d.errRuns || 0;
539
+ o.outTokens += d.outTokens || 0;
540
+ o.savedUsd += d.savedUsd || 0;
541
+ owned.set(rule, o);
542
+ }
500
543
 
501
544
  for (const r of data.rules) {
502
545
  const s = pick(episodeStats, r);
503
- const d = pick(delegatedStats, r);
546
+ const d = owned.get(r);
504
547
  if (!s && !d) {
505
548
  // A rule whose category didn't appear at all this window keeps its last
506
- // known recurrence, but its measured-delegation fields must still read
507
- // as "nothing measured" rather than stay undefined — the CLI and the
508
- // rendered md both branch on them.
509
- r.delegatedRuns = r.delegatedRuns ?? 0;
510
- r.delegatedErrRate = r.delegatedErrRate ?? 0;
511
- r.savedUsd = r.savedUsd ?? 0;
512
- r.healthSource = r.healthSource ?? 'proxy';
549
+ // known recurrence. Everything else here describes the window, and this
550
+ // window holds nothing: the delegation figures go to zero and a review
551
+ // flag is lifted, as they are below when only one of the two is missing.
552
+ // Leaving them untouched froze the last window that had data, so a rule
553
+ // flagged once stayed flagged, with its old saving, after it stopped
554
+ // firing altogether.
555
+ const cleared = { delegatedRuns: 0, delegatedErrRate: 0, savedUsd: 0, healthSource: 'proxy' };
556
+ for (const [k, v] of Object.entries(cleared)) {
557
+ if (r[k] !== v) { r[k] = v; changed = true; }
558
+ }
559
+ if (r.status === 'review') { r.status = 'active'; changed = true; }
513
560
  continue;
514
561
  }
515
562
 
@@ -9,7 +9,7 @@
9
9
  * 합계에서 빼고 unpriced 로 셉니다. 정가표로 대신 채우지 않습니다.
10
10
  */
11
11
 
12
- import { sessionCost } from './claude-price.js';
12
+ import { sessionCost, pricedParts } from './claude-price.js';
13
13
 
14
14
  /** 이번 달 1일 00:00(로컬)의 epoch ms. */
15
15
  export function monthStartMs(now = new Date()) {
@@ -23,9 +23,9 @@ export function monthLabel(now = new Date()) {
23
23
 
24
24
  /**
25
25
  * 월초 이후 세션들의 지출 추정치 합계(USD).
26
- * endTime 이 월초 이후인 세션만 집계합니다. 세션이 월 경계에 걸치는
27
- * 경우는 세션 전체를 포함하는데, 경계 세션은 드물고 통계선 지표는
28
- * 추정치이므로 이 정도 오차는 허용합니다.
26
+ * endTime 이 월초 이후인 세션만 집계합니다. 월 경계에 걸친 세션은
27
+ * 호출하는 쪽이 parser.js 의 sessionsSince 로 월초 이후 요청만 남겨서
28
+ * 넘겨야 합니다. 그대로 넘기면 세션 전체가 이번 달 지출로 계산됩니다.
29
29
  *
30
30
  * @param {Array} sessions parseAllSessions 결과
31
31
  * @param {Date} [now]
@@ -39,14 +39,20 @@ export function monthSpend(sessions, now = new Date()) {
39
39
  for (const s of sessions || []) {
40
40
  if (!s || !s.endTime || s.endTime.getTime() < since) continue;
41
41
  if (!s.totals) continue;
42
- try {
43
- const c = sessionCost(s.totals, s.model);
44
- if (!c) { unpriced += 1; continue; }
45
- usd += c.actual;
46
- count += 1;
47
- } catch {
48
- // 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
42
+ // 세션이 띄운 서브에이전트 실행도 같은 청구서에 들어가므로, 실행마다 그 모델의
43
+ // 단가로 계산해 더합니다. 세션 수에는 넣지 않습니다.
44
+ let priced = false;
45
+ for (const part of pricedParts(s)) {
46
+ try {
47
+ const c = sessionCost(part.totals, part.model);
48
+ if (!c) { unpriced += 1; continue; }
49
+ usd += c.actual;
50
+ if (part.own) priced = true;
51
+ } catch {
52
+ // 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
53
+ }
49
54
  }
55
+ if (priced) count += 1;
50
56
  }
51
57
  return { usd, sessions: count, unpriced, sinceMs: since, label: monthLabel(now) };
52
58
  }