sprag-cli 3.55.0 → 3.57.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 (59) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +5 -1
  3. package/bin/cli.js +60 -19
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +44 -5
  6. package/docs/CODEX.md +74 -12
  7. package/package.json +2 -2
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +49 -0
  11. package/src/codex-brief.js +55 -2
  12. package/src/codex-cache-policy.js +26 -41
  13. package/src/codex-delegation.js +56 -8
  14. package/src/codex-doctor.js +1 -1
  15. package/src/codex-harness.js +63 -18
  16. package/src/codex-hooks.js +129 -20
  17. package/src/codex-ledger.js +150 -16
  18. package/src/codex-panel.js +51 -26
  19. package/src/codex-parser.js +41 -4
  20. package/src/codex-route-scan.js +14 -7
  21. package/src/codex-rule-health.js +41 -0
  22. package/src/commands/brief.js +14 -3
  23. package/src/commands/codex-delegate.js +17 -5
  24. package/src/commands/codex.js +20 -10
  25. package/src/commands/harness.js +6 -0
  26. package/src/commands/route-scan.js +4 -3
  27. package/src/compact-window.js +12 -4
  28. package/src/config.js +3 -3
  29. package/src/cost.js +62 -5
  30. package/src/ctx-window-cache.js +61 -0
  31. package/src/delegation-guard.js +15 -9
  32. package/src/doc2md.cjs +22 -8
  33. package/src/fig2md.cjs +7 -1
  34. package/src/formatters/statusline.js +22 -11
  35. package/src/formatters/table.js +19 -10
  36. package/src/gateway-auth.js +31 -3
  37. package/src/gateway-prices.js +39 -0
  38. package/src/harness.js +43 -12
  39. package/src/history.js +6 -2
  40. package/src/hook-manager.js +54 -32
  41. package/src/installer.js +286 -153
  42. package/src/korean-lint.cjs +40 -7
  43. package/src/litellm-budget.js +10 -1
  44. package/src/litellm-models.js +72 -10
  45. package/src/model-rules.js +90 -42
  46. package/src/money.js +9 -0
  47. package/src/month-spend.js +23 -12
  48. package/src/openai-prices.js +33 -0
  49. package/src/parser.js +232 -56
  50. package/src/postinstall.js +32 -0
  51. package/src/route-caps.js +74 -0
  52. package/src/route-inject.js +31 -7
  53. package/src/route-scan.js +93 -33
  54. package/src/savings-ledger.js +43 -11
  55. package/src/seed-rules.js +3 -4
  56. package/src/session-cache.js +35 -1
  57. package/src/state-file.js +43 -0
  58. package/src/stats.js +20 -5
  59. package/src/stdin-payload.js +18 -4
package/src/route-scan.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * ratchet promote flow (`harness promote R<N> --global|--project`).
18
18
  */
19
19
 
20
- import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { userDataDir } from './paths.js';
23
23
  import { discoverSessionFiles } from './parser.js';
@@ -25,7 +25,8 @@ import { collectSessionRecords } from './session-records.js';
25
25
  import {
26
26
  collectSubagentRuns, indexRuns, exactRunsForEpisode, fallbackRunsForEpisode,
27
27
  } from './subagent-records.js';
28
- import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
28
+ import { modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
29
+ import { sessionCost } from './claude-price.js';
29
30
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
30
31
  import { modelRuleBaseText } from './model-rules.js';
31
32
 
@@ -54,7 +55,16 @@ export const MIN_DELEGABLE_OUT = 100;
54
55
  // Measured 2026-09-20 on 1,276 local prompts: '퍼블리시' (npm publish, 15 hits)
55
56
  // and '이유' (root-cause asks) were slipping through as run/translate, and a
56
57
  // bare '머지' matched inside '나머지' ("나머지 진행하자" ×7), hence the lookbehind.
57
- export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge/i;
58
+ //
59
+ // Measured 2026-10-01 on 10,958 local prompts: reviews, security checks and
60
+ // root-cause asks were hinted to haiku because they are phrased as a check
61
+ // ("보안 취약점 있는지 확인해줘", "개선점 확인해보자", "버그 원인 확인"). Of the
62
+ // 300 short prompts that got a hint, the added terms and the whole-word
63
+ // keywords further down take it away from 18: judgement requests, and
64
+ // questions that merely contained "statusline". None gained a hint. Bare
65
+ // '리뷰' and '보안' are left out on purpose: they are product nouns in ordinary
66
+ // status checks ("리뷰 허브 배치 확인"), so only the request-shaped forms count.
67
+ export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인\s*(?:분석|파악|확인|규명|조사)|원인을?\s*찾|버그|개선할|개선점|개선\s*(?:사항|방안)|검토해보|검수|코드\s*리뷰|리뷰\s*해|취약|보안\s*(?:점검|검토|감사|리뷰|이슈)|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge|root cause|code review|security (?:review|audit)|vulnerabilit/i;
58
68
 
59
69
  // Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
60
70
  // 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
@@ -115,30 +125,39 @@ const CATEGORIES = [
115
125
  label: '탐색·조회 (파일/값 찾기)',
116
126
  labelEn: 'lookup (finding files/values)',
117
127
  agent: 'haiku-explore',
118
- kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1]],
128
+ kw: [[/\bgrep\b|검색|\bsearch|\bfind\b/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
129
+ [/정의(?:돼|되어)\s*있|어느\s*파일|어떤\s*파일에|\bwhich\s+file\b|\bwhere\s+(?:is|are)\b[^?\n]*\bdefined\b/i, 1]],
119
130
  },
120
131
  {
121
132
  id: 'read',
122
133
  label: '읽기·요약·설명',
123
134
  labelEn: 'reading / summarizing / explaining',
124
135
  agent: 'haiku-explore',
125
- kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|what/i, 1]],
136
+ kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|\bwhat\b/i, 1]],
126
137
  },
127
138
  {
128
139
  id: 'check',
129
140
  label: '상태 확인·검증',
130
141
  labelEn: 'status checks / verification',
131
142
  agent: 'haiku-explore',
132
- kw: [[/확인|검증|verify|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|check|status/i, 1]],
143
+ kw: [[/확인|검증|\bverif|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|\bcheck\b|\bstatus\b/i, 1]],
133
144
  },
134
145
  {
135
146
  id: 'run',
136
147
  label: '명령 실행 (빌드·테스트·git)',
137
148
  labelEn: 'running commands (build/test/git)',
138
149
  agent: 'haiku-runner',
139
- kw: [[/git |commit|push|npm |pip|빌드해|빌드 돌/i, 2], [/실행|돌려|run |build|빌드|테스트|설치/i, 1]],
150
+ kw: [[/\bgit |\bcommit(?:s|ted)?\b|\bpush(?:ed|es)?\b|\bnpm |\bpip3? |빌드해|빌드 돌/i, 2], [/실행|돌려|\brun\b|\bbuild\b|빌드|테스트|설치/i, 1]],
140
151
  },
141
152
  ];
153
+ // English keywords are matched as whole words. As bare substrings, `pip`
154
+ // claimed "pipeline", `commit` "commitment", `find` "findings" and `status`
155
+ // "statusline", which filed questions about those under run, explore or check.
156
+ // `\b` works next to Hangul here because the keyword itself is ASCII: the
157
+ // boundary falls between its last letter and the syllable that follows.
158
+ // `git`, `npm` and `pip` keep the trailing space they always had: followed by
159
+ // a subcommand they are a command to run, while "npm, github 쪽에도" and
160
+ // "git은 나중에" only mention the tool.
142
161
 
143
162
  // ── Behavior signal (1st) — what the episode actually DID ────────────────
144
163
  // The tool-call histogram is ground truth the prompt's wording is not:
@@ -333,9 +352,13 @@ export function worthDelegating(tier, rank) {
333
352
  * Approximation, deliberately stated as one: it holds token counts constant,
334
353
  * which a cheaper model would not reproduce exactly. Directionally right and
335
354
  * enough to rank rules by value, so it is rendered as "~$X".
355
+ *
356
+ * Signed: a run that moved to a pricier model is a loss and comes back
357
+ * negative. null means a side has no price (a gateway session whose /model/info
358
+ * table lacks the model), which is unpriced, not zero.
336
359
  */
337
360
  export function runSaving(run, mainModel) {
338
- if (!run.model || !mainModel) return 0;
361
+ if (!run.model || !mainModel) return null;
339
362
  const totals = {
340
363
  input: run.input,
341
364
  cacheCreation: run.cacheCreation,
@@ -344,9 +367,10 @@ export function runSaving(run, mainModel) {
344
367
  ephemeral1h: run.ephemeral1h,
345
368
  output: run.out,
346
369
  };
347
- const actual = estimateCost(totals, run.model).actual;
348
- const counterfactual = estimateCost(totals, mainModel).actual;
349
- return Math.max(0, counterfactual - actual);
370
+ const actual = sessionCost(totals, run.model);
371
+ const counterfactual = sessionCost(totals, mainModel);
372
+ if (!actual || !counterfactual) return null;
373
+ return counterfactual.actual - actual.actual;
350
374
  }
351
375
 
352
376
  const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
@@ -404,6 +428,42 @@ export function ruleCouldHaveRouted(rule, run) {
404
428
  return run.startedAt >= promoted;
405
429
  }
406
430
 
431
+ /**
432
+ * Number the candidates of a scan, in place, and return the highest number in
433
+ * use. A candidate keeps its number for as long as it stays listed. Numbering
434
+ * by rank let a rescan between "R2 is a candidate" and `promote R2` hand the
435
+ * number to another pattern, and the wrong rule was registered. A new candidate
436
+ * takes a number no listed candidate has held; numbering starts over only once
437
+ * the list has been empty.
438
+ *
439
+ * @param {Array} candidates this scan's candidates
440
+ * @param {object|null} prev the previous scan's cache
441
+ */
442
+ export function assignCandidateIds(candidates, prev) {
443
+ const prevIds = new Map((prev?.candidates || [])
444
+ .filter((c) => Number.isInteger(c?.id) && c.id > 0).map((c) => [c.signature, c.id]));
445
+ let last = prevIds.size ? Math.max(Number(prev?.lastCandidateId) || 0, ...prevIds.values()) : 0;
446
+ for (const c of candidates) c.id = prevIds.get(c.signature) ?? ++last;
447
+ return last;
448
+ }
449
+
450
+ /**
451
+ * Bytes in the scan window: each transcript and the subagent transcripts under
452
+ * it. The scan records this figure and the rescan gate diffs a fresh one
453
+ * against it, so both take it from here. Counted separately, the scan included
454
+ * subagent bytes and the gate did not: the gate's total came out smaller than
455
+ * the recorded one by that amount, and the "enough new data" clause could not
456
+ * fire for a workload made mostly of delegations.
457
+ */
458
+ export function windowBytes(files) {
459
+ let total = 0;
460
+ for (const f of files || []) {
461
+ total += f.size || 0;
462
+ for (const a of f.subagentFiles || []) total += a.size || 0;
463
+ }
464
+ return total;
465
+ }
466
+
407
467
  export async function runRouteScan({ days = 14 } = {}) {
408
468
  const files = await discoverSessionFiles({ days });
409
469
 
@@ -420,12 +480,11 @@ export async function runRouteScan({ days = 14 } = {}) {
420
480
  // Pass 1 — collect episodes (needed up front: thresholds are calibrated
421
481
  // from the full window's output distribution before any tiering).
422
482
  const all = []; // { ep, projectDir, sessionPath }
423
- let dataBytes = 0; // window size snapshot — the rescan gate diffs against it
483
+ const dataBytes = windowBytes(files); // window size snapshot — the rescan gate diffs against it
424
484
  const runIndexBySession = new Map(); // sessionPath → indexRuns() result
425
485
  for (const f of files) {
426
486
  let records;
427
487
  try {
428
- dataBytes += statSync(f.path).size;
429
488
  records = await collectSessionRecords(f.path, { includeContent: true });
430
489
  } catch {
431
490
  continue;
@@ -438,12 +497,7 @@ export async function runRouteScan({ days = 14 } = {}) {
438
497
  // delegation it made. Best-effort: sessions that never delegated have no
439
498
  // directory and cost one failed readdir.
440
499
  const runs = await collectSubagentRuns(f.path);
441
- if (runs.length > 0) {
442
- runIndexBySession.set(f.path, indexRuns(runs));
443
- // Subagent bytes count toward the window size so the rescan gate stays
444
- // accurate for delegation-heavy workloads.
445
- for (const r of runs) dataBytes += r.bytes || 0;
446
- }
500
+ if (runs.length > 0) runIndexBySession.set(f.path, indexRuns(runs));
447
501
  }
448
502
  const totalEpisodes = all.length;
449
503
  const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
@@ -549,7 +603,7 @@ export async function runRouteScan({ days = 14 } = {}) {
549
603
  d.runs += 1;
550
604
  if (isFailedRun(run)) d.errRuns += 1;
551
605
  d.outTokens += run.out || 0;
552
- d.savedUsd += saved;
606
+ if (Number.isFinite(saved)) d.savedUsd += saved; // unpriced runs add nothing
553
607
  delegatedStats.set(key, d);
554
608
  };
555
609
  // Ledger events feed the statusline's weekly/monthly "Routing saved"
@@ -566,9 +620,11 @@ export async function runRouteScan({ days = 14 } = {}) {
566
620
  // savings here would credit the tool for work it did not route.
567
621
  const ledgerEvents = [];
568
622
  let ledgerRules = [];
623
+ let ruleForProject = () => undefined;
569
624
  try {
570
- const { loadModelRules } = await import('./model-rules.js');
571
- ledgerRules = loadModelRules().rules.filter((r) => r.status !== 'off');
625
+ const rules = await import('./model-rules.js');
626
+ ledgerRules = rules.loadModelRules().rules.filter((r) => r.status !== 'off');
627
+ ruleForProject = rules.ruleForProject;
572
628
  } catch { /* no registry → no attributable savings, which is the honest zero */ }
573
629
  // A rule's baseline: what it stored at promotion, else what this scan still
574
630
  // observes handling the category directly (a rule promoted before baselines
@@ -579,9 +635,9 @@ export async function runRouteScan({ days = 14 } = {}) {
579
635
  || (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
580
636
  return s?.baselineModel || null;
581
637
  };
582
- const ruleForRun = (tier, catId, projectDir) => ledgerRules.find((r) =>
583
- r.tier === tier && r.category === catId &&
584
- (r.scope === 'global' || r.project === projectDir));
638
+ // The same choice rule health makes (model-rules.js), so the ledger and the
639
+ // registry credit a run to the same rule.
640
+ const ruleForRun = (tier, catId, projectDir) => ruleForProject(ledgerRules, tier, catId, projectDir);
585
641
  for (const [sessionPath, index] of runIndexBySession) {
586
642
  const used = new Set();
587
643
  const eps = all.filter((x) => x.sessionPath === sessionPath);
@@ -674,8 +730,8 @@ export async function runRouteScan({ days = 14 } = {}) {
674
730
  .filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
675
731
  .sort((a, b) => b.count - a.count)
676
732
  .slice(0, 8)
677
- .map((g, i) => ({
678
- id: i + 1,
733
+ .map((g) => ({
734
+ id: null, // assigned below, from the previous scan where there was one
679
735
  signature: `${g.tier}|${g.category}|${g.project}`,
680
736
  tier: g.tier,
681
737
  category: g.category,
@@ -702,6 +758,8 @@ export async function runRouteScan({ days = 14 } = {}) {
702
758
  ruleEn: ruleTextEn(g),
703
759
  }));
704
760
 
761
+ const lastCandidateId = assignCandidateIds(candidates, prev);
762
+
705
763
  // Same category appearing in 2+ projects → suggest global for each.
706
764
  const catProjects = new Map();
707
765
  for (const c of candidates) {
@@ -720,6 +778,7 @@ export async function runRouteScan({ days = 14 } = {}) {
720
778
  easyEpisodes: tieredEpisodes,
721
779
  thresholds,
722
780
  candidates,
781
+ lastCandidateId,
723
782
  resolved: [...resolved],
724
783
  unresolvedRuns,
725
784
  unresolvedModels: [...unresolvedModels].slice(0, 5),
@@ -759,7 +818,8 @@ export async function runRouteScan({ days = 14 } = {}) {
759
818
  // profile-map.json's `modelAliases` to bring these runs back in.
760
819
  if (!baseline || !isRecognizedModelId(baseline)) continue;
761
820
  const usd = runSaving(e.run, baseline);
762
- if (usd <= 0) continue;
821
+ // A loss is recorded too; only an unpriced run (null) has nothing to record.
822
+ if (!Number.isFinite(usd)) continue;
763
823
  priced.push({
764
824
  key: e.run.path,
765
825
  ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
@@ -811,11 +871,11 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
811
871
  let total = 0;
812
872
  let anyNew = false;
813
873
  try {
814
- for (const f of await discoverSessionFiles({ days })) {
815
- const s = statSync(f.path);
816
- total += s.size;
817
- if (s.mtimeMs > ts) anyNew = true;
818
- }
874
+ const files = await discoverSessionFiles({ days });
875
+ total = windowBytes(files);
876
+ // A subagent transcript is new data too: a delegation writes there and may
877
+ // leave the parent transcript untouched until the run returns.
878
+ anyNew = files.some((f) => f.mtime > ts || (f.subagentFiles || []).some((a) => a.mtime > ts));
819
879
  } catch {
820
880
  return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
821
881
  }
@@ -22,19 +22,36 @@
22
22
  * empty, and the statusline renders totals of 0 as "no chip".
23
23
  */
24
24
 
25
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
25
+ import { readFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { userDataDir } from './paths.js';
28
+ import { writeStateFile } from './state-file.js';
28
29
 
29
30
  const WEEK_MS = 7 * 24 * 3600 * 1000;
30
31
  const MONTH_MS = 30 * 24 * 3600 * 1000;
31
32
 
33
+ /** Stored USD is kept to 4 decimals so re-scans compare equal and files stay small. */
34
+ export const roundUsd = (usd) => Math.round(Number(usd) * 10000) / 10000;
35
+
32
36
  export function ledgerPath() {
33
37
  return join(userDataDir(), 'delegation-ledger.json');
34
38
  }
35
39
 
36
40
  export const LEDGER_VERSION = 2;
37
41
 
42
+ /**
43
+ * Which run a ledger key names, wherever its transcript sits:
44
+ * `<session id>/subagents/<agent file>`. A project folder that was renamed or
45
+ * copied holds the same run under two paths, and keyed by full path alone the
46
+ * ledger counted its saving twice. Keys that are not subagent transcript paths
47
+ * identify themselves.
48
+ */
49
+ export function runIdentity(key) {
50
+ const parts = String(key).split(/[\\/]/);
51
+ const i = parts.lastIndexOf('subagents');
52
+ return i > 0 ? parts.slice(i - 1).join('/') : String(key);
53
+ }
54
+
38
55
  export function loadLedger() {
39
56
  try {
40
57
  const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
@@ -45,6 +62,15 @@ export function loadLedger() {
45
62
  // instead of mixing two meanings into one total. The next scan rebuilds
46
63
  // whatever is still attributable.
47
64
  if (data.version !== LEDGER_VERSION) return { version: LEDGER_VERSION, events: {} };
65
+ // One event per run: duplicates recorded from a second copy of the same
66
+ // transcript are dropped here, so every reader sums each run once and the
67
+ // next write persists the cleanup.
68
+ const seen = new Set();
69
+ for (const key of Object.keys(data.events)) {
70
+ const id = runIdentity(key);
71
+ if (seen.has(id)) delete data.events[key];
72
+ else seen.add(id);
73
+ }
48
74
  return data;
49
75
  } catch {
50
76
  return { version: LEDGER_VERSION, events: {} };
@@ -55,19 +81,27 @@ export function loadLedger() {
55
81
  * Upsert saving events. `events` is an array of
56
82
  * { key, ts, usd, rule, from, to } where `key` is the run's transcript path
57
83
  * (unique per subagent run) and `from`/`to` name the models the routing
58
- * decision moved between. Zero-saving runs are skipped — they carry no
59
- * information the totals care about.
84
+ * decision moved between. `usd` is signed: a run on a pricier model is a loss
85
+ * and is recorded as a negative amount so it reduces the totals. Only
86
+ * non-finite amounts (unpriced runs) are skipped.
60
87
  */
61
88
  export function recordDelegationEvents(events) {
62
89
  if (!Array.isArray(events) || events.length === 0) return;
63
90
  const data = loadLedger();
64
91
  data.version = LEDGER_VERSION;
65
92
  let changed = false;
93
+ const keyOf = new Map(Object.keys(data.events).map((k) => [runIdentity(k), k]));
66
94
  for (const e of events) {
67
- if (!e || !e.key || !(Number(e.usd) > 0) || !Number.isFinite(e.ts)) continue;
68
- const prev = data.events[e.key];
69
- const usd = Math.round(Number(e.usd) * 10000) / 10000;
95
+ if (!e || !e.key || typeof e.usd !== 'number' || !Number.isFinite(e.usd) || !Number.isFinite(e.ts)) continue;
96
+ // The run may already be recorded under the path of another copy of its
97
+ // transcript; that entry is the one to compare with and to replace.
98
+ const id = runIdentity(e.key);
99
+ const prevKey = keyOf.get(id) ?? e.key;
100
+ const prev = data.events[prevKey];
101
+ const usd = roundUsd(e.usd);
70
102
  if (prev && prev.ts === e.ts && prev.usd === usd) continue;
103
+ if (prevKey !== e.key) delete data.events[prevKey];
104
+ keyOf.set(id, e.key);
71
105
  data.events[e.key] = {
72
106
  ts: e.ts,
73
107
  usd,
@@ -79,9 +113,7 @@ export function recordDelegationEvents(events) {
79
113
  }
80
114
  if (!changed) return;
81
115
  try {
82
- const dir = userDataDir();
83
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
84
- writeFileSync(ledgerPath(), JSON.stringify(data) + '\n');
116
+ writeStateFile(ledgerPath(), JSON.stringify(data) + '\n');
85
117
  } catch {
86
118
  // statusline totals just stay stale until the next successful scan
87
119
  }
@@ -115,8 +147,8 @@ export function delegationSavedTotals(now = Date.now()) {
115
147
  const byPair = new Map();
116
148
  try {
117
149
  for (const e of Object.values(loadLedger().events)) {
118
- const usd = Number(e.usd) || 0;
119
- if (usd <= 0) continue;
150
+ const usd = Number(e.usd);
151
+ if (!Number.isFinite(usd)) continue;
120
152
  totals.total += usd;
121
153
  if (Number.isFinite(e.ts)) {
122
154
  if (now - e.ts <= WEEK_MS) totals.week += usd;
package/src/seed-rules.js CHANGED
@@ -23,13 +23,14 @@
23
23
  * { decided: { "<id>": { action: 'accepted' | 'skipped', at, scope? } } }
24
24
  */
25
25
 
26
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
26
+ import { readFileSync } from 'node:fs';
27
27
  import { CLI_NAME } from './cli-name.js';
28
28
  import { join, dirname } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { createHash } from 'node:crypto';
31
31
  import { userDataDir } from './paths.js';
32
32
  import { userLanguage } from './config.js';
33
+ import { writeStateFile } from './state-file.js';
33
34
  import {
34
35
  addModelRule,
35
36
  composeRuleText,
@@ -62,9 +63,7 @@ export function loadSeedState(opts = {}) {
62
63
  }
63
64
 
64
65
  export function saveSeedState(state, opts = {}) {
65
- const dir = userDataDir();
66
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
67
- writeFileSync(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
66
+ writeStateFile(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
68
67
  }
69
68
 
70
69
  /** Bundled tier-delegation presets (empty array when the file is unreadable). */
@@ -31,7 +31,11 @@ const CACHE_PATH = join(userDataDir(), 'session-cache.json');
31
31
  // the users the flag exists for.
32
32
  // 3: version 2's serialize() never actually wrote `gatewayObserved`, so every
33
33
  // v2 entry lacks the flag it was bumped for. Bumped again to discard them.
34
- const CACHE_VERSION = 3;
34
+ // 4: sessions that used more than one model carry `modelTotals`, and
35
+ // zero-token `<synthetic>` stubs no longer count as requests. A v3 entry has
36
+ // neither, so a mixed-model session cached before the bump would go on being
37
+ // priced at one rate until its transcript changed.
38
+ const CACHE_VERSION = 4;
35
39
  // Entries for transcripts this old are pruned on write. Keeps the file
36
40
  // bounded without an existence check per entry (which would cost the syscalls
37
41
  // the cache exists to avoid).
@@ -47,6 +51,7 @@ const PRUNE_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
47
51
  * @property {object} totals
48
52
  * @property {number} maxContextPerRequest
49
53
  * @property {string} model
54
+ * @property {Record<string, object>} [modelTotals] totals per model, present only when more than one model spent tokens
50
55
  * @property {string} [projectDir]
51
56
  */
52
57
 
@@ -60,6 +65,7 @@ function serialize(session) {
60
65
  totals: session.totals,
61
66
  maxContextPerRequest: session.maxContextPerRequest,
62
67
  model: session.model,
68
+ ...(session.modelTotals ? { modelTotals: session.modelTotals } : {}),
63
69
  gatewayObserved: !!session.gatewayObserved,
64
70
  };
65
71
  }
@@ -75,6 +81,7 @@ function deserialize(stored, filePath, projectDir) {
75
81
  totals: stored.totals,
76
82
  maxContextPerRequest: stored.maxContextPerRequest || 0,
77
83
  model: stored.model || 'unknown',
84
+ ...(stored.modelTotals && typeof stored.modelTotals === 'object' ? { modelTotals: stored.modelTotals } : {}),
78
85
  gatewayObserved: !!stored.gatewayObserved,
79
86
  };
80
87
  }
@@ -132,6 +139,33 @@ export function putCached(cache, file, session) {
132
139
  };
133
140
  }
134
141
 
142
+ /**
143
+ * A session re-aggregated from a fixed point in time (the start of the month),
144
+ * stored beside the full summary of the same file. One slot per entry: the
145
+ * point moves once a month, and an entry whose file changed is replaced whole
146
+ * by putCached, which drops the slot with it.
147
+ *
148
+ * @returns {SessionSummary|null} null when the file changed or the slot is for another point
149
+ */
150
+ export function getCachedSince(cache, file, sinceMs) {
151
+ const e = cache.entries[file.path];
152
+ if (!e || e.mtimeMs !== file.mtime || e.size !== file.size || !e.t || e.t.since !== sinceMs || !e.t.s) return null;
153
+ try {
154
+ return deserialize(e.t.s, file.path, file.projectDir);
155
+ } catch (err) {
156
+ debug('session-cache:deserialize-since', err);
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /** Returns false when there is no current entry for the file to attach the slot to. */
162
+ export function putCachedSince(cache, file, sinceMs, session) {
163
+ const e = cache.entries[file.path];
164
+ if (!e || e.mtimeMs !== file.mtime || e.size !== file.size) return false;
165
+ e.t = { since: sinceMs, s: serialize(session) };
166
+ return true;
167
+ }
168
+
135
169
  /**
136
170
  * Atomically persist the cache (tmp + rename), pruning long-dead transcripts.
137
171
  * Concurrent statusline refreshes are expected — rename is atomic on POSIX and
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Writes for the state files this tool owns: the rule registry, the savings
3
+ * ledger, the seed answers, the config.
4
+ *
5
+ * Their loaders read a file that fails to parse as empty state, which is the
6
+ * right thing for a report but meant the next save replaced the whole file
7
+ * with that empty state. The savings ledger is a lifetime total, so one
8
+ * half-written file (two statuslines saving at once, a full disk) erased it
9
+ * for good. Two things stop that here: the write goes to a temp file that is
10
+ * renamed over the target, so a reader never sees half a file, and a target
11
+ * that does not parse is copied aside before it is replaced.
12
+ */
13
+
14
+ import { writeFileSync, renameSync, readFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+
17
+ /** Where an unreadable state file is kept. One slot: the first copy holds the data worth recovering. */
18
+ export const corruptCopyPath = (file) => `${file}.corrupt`;
19
+
20
+ function keepUnreadable(file) {
21
+ let raw;
22
+ try { raw = readFileSync(file, 'utf8'); } catch { return; }
23
+ if (!raw.trim()) return;
24
+ try { JSON.parse(raw); return; } catch { /* does not parse: keep it */ }
25
+ try {
26
+ if (!existsSync(corruptCopyPath(file))) copyFileSync(file, corruptCopyPath(file));
27
+ } catch { /* best effort; the write below still goes ahead */ }
28
+ }
29
+
30
+ export function writeStateFile(file, text) {
31
+ mkdirSync(dirname(file), { recursive: true });
32
+ keepUnreadable(file);
33
+ const tmp = `${file}.${process.pid}.tmp`;
34
+ try {
35
+ writeFileSync(tmp, text);
36
+ renameSync(tmp, file);
37
+ } catch {
38
+ // Windows refuses the rename while another process holds the target open.
39
+ // Writing in place is what this did before, and better than not saving.
40
+ try { rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
41
+ writeFileSync(file, text);
42
+ }
43
+ }
package/src/stats.js CHANGED
@@ -2,8 +2,12 @@
2
2
  * Aggregate session data into daily trends, TTL breakdown, and anomalies.
3
3
  */
4
4
 
5
+ // The local calendar day. toISOString() gives the UTC day, which put a session
6
+ // started at 08:00 in Seoul under the previous date, while the month total and
7
+ // the history files next to this trend count in local time.
5
8
  function dateKey(date) {
6
- return date.toISOString().slice(0, 10);
9
+ const pad = (n) => String(n).padStart(2, '0');
10
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
7
11
  }
8
12
 
9
13
  function hitRate(read, creation, input) {
@@ -167,9 +171,17 @@ function percentile(values, p) {
167
171
  * we're diagnosing — if we let them into the baseline they'd drag the baseline
168
172
  * toward themselves and never register as anomalies.
169
173
  */
174
+ // A session this short is a one-shot call, not a conversation: `claude -p`
175
+ // from a script, a hook, a batch job. They are tiny and can outnumber real
176
+ // sessions a hundred to one (measured: 1,924 of 1,941 sessions in 7 days were
177
+ // single-request), which put the "normal" p95 at 51k tokens and reported an
178
+ // ordinary 13M session as 252x p95. The baseline describes conversations, so
179
+ // only conversations go into it.
180
+ export const BASELINE_MIN_REQUESTS = 3;
181
+
170
182
  export function computeBaseline(sessions, recentWindowMs = 24 * 60 * 60 * 1000) {
171
183
  const cutoff = Date.now() - recentWindowMs;
172
- const older = sessions.filter((s) => s.startTime && s.startTime.getTime() < cutoff && s.requestCount > 0);
184
+ const older = sessions.filter((s) => s.startTime && s.startTime.getTime() < cutoff && s.requestCount >= BASELINE_MIN_REQUESTS);
173
185
  if (older.length < 3) {
174
186
  return { enough: false, sampleSize: older.length };
175
187
  }
@@ -295,15 +307,18 @@ export function detectSpikes(sessions, { recentHours = 24, multiplier = 3 } = {}
295
307
  ? m.totalInput / baseline.p95TotalInput
296
308
  : null;
297
309
 
298
- const isSpike =
299
- (ratio !== null && ratio >= multiplier) ||
300
- m.maxContextPerRequest > 250_000;
310
+ const byRatio = ratio !== null && ratio >= multiplier;
311
+ const isSpike = byRatio || m.maxContextPerRequest > 250_000;
301
312
 
302
313
  if (!isSpike) continue;
303
314
 
304
315
  spikes.push({
305
316
  metrics: m,
306
317
  ratio,
318
+ // Which of the two tests it met. A session listed for one large request
319
+ // alone has a ratio too, and printing that ratio ("0.1x p95") as the
320
+ // reason would name the test it did not meet.
321
+ byRatio,
307
322
  issues: diagnoseSession(m, baseline),
308
323
  });
309
324
  }
@@ -59,11 +59,16 @@ export function extractCaps(stdinJson) {
59
59
  if (!value || typeof value !== 'object') continue;
60
60
  const usedPct = normalizePct(value.used_percentage);
61
61
  if (usedPct === null) continue;
62
- const resetsAt = Number(value.resets_at);
62
+ // Same trap as the percentage: Number(null) is 0, and a reset "at 0" is
63
+ // 1970, which renders as a window that reset long ago. Only a positive
64
+ // number is a time.
65
+ const rawReset = typeof value.resets_at === 'string' && value.resets_at.trim() !== ''
66
+ ? Number(value.resets_at)
67
+ : value.resets_at;
63
68
  windows.push({
64
69
  key,
65
70
  usedPct,
66
- resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
71
+ resetsAt: typeof rawReset === 'number' && Number.isFinite(rawReset) && rawReset > 0 ? rawReset : null,
67
72
  });
68
73
  }
69
74
  return windows.length ? { windows } : null;
@@ -84,10 +89,13 @@ export function extractCaps(stdinJson) {
84
89
  // model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
85
90
  export function bedrockDisplayFromId(id) {
86
91
  if (typeof id !== 'string') return null;
87
- const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)[-_](\d+)/i);
92
+ // The minor version is one or two digits. An id with no minor goes straight
93
+ // to its date stamp (`claude-opus-4-20250514`), and reading that stamp as the
94
+ // minor printed "Opus 4.20250514".
95
+ const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)(?:[-_](\d{1,2})(?!\d))?/i);
88
96
  if (!m) return null;
89
97
  const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
90
- return `${family} ${m[2]}.${m[3]}`;
98
+ return m[3] === undefined ? `${family} ${m[2]}` : `${family} ${m[2]}.${m[3]}`;
91
99
  }
92
100
 
93
101
  /**
@@ -108,6 +116,12 @@ export function extractContextUsage(stdinJson) {
108
116
  };
109
117
  }
110
118
 
119
+ /** The session the payload belongs to, or null when the field is absent. */
120
+ export function extractSessionId(stdinJson) {
121
+ const id = stdinJson?.session_id;
122
+ return typeof id === 'string' && id !== '' ? id : null;
123
+ }
124
+
111
125
  export function extractModel(stdinJson) {
112
126
  if (!stdinJson || !stdinJson.model) return null;
113
127
  const m = stdinJson.model;