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
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';
@@ -55,7 +55,16 @@ export const MIN_DELEGABLE_OUT = 100;
55
55
  // Measured 2026-09-20 on 1,276 local prompts: '퍼블리시' (npm publish, 15 hits)
56
56
  // and '이유' (root-cause asks) were slipping through as run/translate, and a
57
57
  // bare '머지' matched inside '나머지' ("나머지 진행하자" ×7), hence the lookbehind.
58
- 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;
59
68
 
60
69
  // Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
61
70
  // 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
@@ -116,7 +125,7 @@ const CATEGORIES = [
116
125
  label: '탐색·조회 (파일/값 찾기)',
117
126
  labelEn: 'lookup (finding files/values)',
118
127
  agent: 'haiku-explore',
119
- kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
128
+ kw: [[/\bgrep\b|검색|\bsearch|\bfind\b/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
120
129
  [/정의(?:돼|되어)\s*있|어느\s*파일|어떤\s*파일에|\bwhich\s+file\b|\bwhere\s+(?:is|are)\b[^?\n]*\bdefined\b/i, 1]],
121
130
  },
122
131
  {
@@ -124,23 +133,31 @@ const CATEGORIES = [
124
133
  label: '읽기·요약·설명',
125
134
  labelEn: 'reading / summarizing / explaining',
126
135
  agent: 'haiku-explore',
127
- kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|what/i, 1]],
136
+ kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|\bwhat\b/i, 1]],
128
137
  },
129
138
  {
130
139
  id: 'check',
131
140
  label: '상태 확인·검증',
132
141
  labelEn: 'status checks / verification',
133
142
  agent: 'haiku-explore',
134
- kw: [[/확인|검증|verify|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|check|status/i, 1]],
143
+ kw: [[/확인|검증|\bverif|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|\bcheck\b|\bstatus\b/i, 1]],
135
144
  },
136
145
  {
137
146
  id: 'run',
138
147
  label: '명령 실행 (빌드·테스트·git)',
139
148
  labelEn: 'running commands (build/test/git)',
140
149
  agent: 'haiku-runner',
141
- 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]],
142
151
  },
143
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.
144
161
 
145
162
  // ── Behavior signal (1st) — what the episode actually DID ────────────────
146
163
  // The tool-call histogram is ground truth the prompt's wording is not:
@@ -411,6 +428,42 @@ export function ruleCouldHaveRouted(rule, run) {
411
428
  return run.startedAt >= promoted;
412
429
  }
413
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
+
414
467
  export async function runRouteScan({ days = 14 } = {}) {
415
468
  const files = await discoverSessionFiles({ days });
416
469
 
@@ -427,12 +480,11 @@ export async function runRouteScan({ days = 14 } = {}) {
427
480
  // Pass 1 — collect episodes (needed up front: thresholds are calibrated
428
481
  // from the full window's output distribution before any tiering).
429
482
  const all = []; // { ep, projectDir, sessionPath }
430
- 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
431
484
  const runIndexBySession = new Map(); // sessionPath → indexRuns() result
432
485
  for (const f of files) {
433
486
  let records;
434
487
  try {
435
- dataBytes += statSync(f.path).size;
436
488
  records = await collectSessionRecords(f.path, { includeContent: true });
437
489
  } catch {
438
490
  continue;
@@ -445,12 +497,7 @@ export async function runRouteScan({ days = 14 } = {}) {
445
497
  // delegation it made. Best-effort: sessions that never delegated have no
446
498
  // directory and cost one failed readdir.
447
499
  const runs = await collectSubagentRuns(f.path);
448
- if (runs.length > 0) {
449
- runIndexBySession.set(f.path, indexRuns(runs));
450
- // Subagent bytes count toward the window size so the rescan gate stays
451
- // accurate for delegation-heavy workloads.
452
- for (const r of runs) dataBytes += r.bytes || 0;
453
- }
500
+ if (runs.length > 0) runIndexBySession.set(f.path, indexRuns(runs));
454
501
  }
455
502
  const totalEpisodes = all.length;
456
503
  const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
@@ -573,9 +620,11 @@ export async function runRouteScan({ days = 14 } = {}) {
573
620
  // savings here would credit the tool for work it did not route.
574
621
  const ledgerEvents = [];
575
622
  let ledgerRules = [];
623
+ let ruleForProject = () => undefined;
576
624
  try {
577
- const { loadModelRules } = await import('./model-rules.js');
578
- 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;
579
628
  } catch { /* no registry → no attributable savings, which is the honest zero */ }
580
629
  // A rule's baseline: what it stored at promotion, else what this scan still
581
630
  // observes handling the category directly (a rule promoted before baselines
@@ -586,9 +635,9 @@ export async function runRouteScan({ days = 14 } = {}) {
586
635
  || (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
587
636
  return s?.baselineModel || null;
588
637
  };
589
- const ruleForRun = (tier, catId, projectDir) => ledgerRules.find((r) =>
590
- r.tier === tier && r.category === catId &&
591
- (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);
592
641
  for (const [sessionPath, index] of runIndexBySession) {
593
642
  const used = new Set();
594
643
  const eps = all.filter((x) => x.sessionPath === sessionPath);
@@ -681,8 +730,8 @@ export async function runRouteScan({ days = 14 } = {}) {
681
730
  .filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
682
731
  .sort((a, b) => b.count - a.count)
683
732
  .slice(0, 8)
684
- .map((g, i) => ({
685
- id: i + 1,
733
+ .map((g) => ({
734
+ id: null, // assigned below, from the previous scan where there was one
686
735
  signature: `${g.tier}|${g.category}|${g.project}`,
687
736
  tier: g.tier,
688
737
  category: g.category,
@@ -709,6 +758,8 @@ export async function runRouteScan({ days = 14 } = {}) {
709
758
  ruleEn: ruleTextEn(g),
710
759
  }));
711
760
 
761
+ const lastCandidateId = assignCandidateIds(candidates, prev);
762
+
712
763
  // Same category appearing in 2+ projects → suggest global for each.
713
764
  const catProjects = new Map();
714
765
  for (const c of candidates) {
@@ -727,6 +778,7 @@ export async function runRouteScan({ days = 14 } = {}) {
727
778
  easyEpisodes: tieredEpisodes,
728
779
  thresholds,
729
780
  candidates,
781
+ lastCandidateId,
730
782
  resolved: [...resolved],
731
783
  unresolvedRuns,
732
784
  unresolvedModels: [...unresolvedModels].slice(0, 5),
@@ -819,11 +871,11 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
819
871
  let total = 0;
820
872
  let anyNew = false;
821
873
  try {
822
- for (const f of await discoverSessionFiles({ days })) {
823
- const s = statSync(f.path);
824
- total += s.size;
825
- if (s.mtimeMs > ts) anyNew = true;
826
- }
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));
827
879
  } catch {
828
880
  return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
829
881
  }
@@ -22,9 +22,10 @@
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;
@@ -38,6 +39,19 @@ export function ledgerPath() {
38
39
 
39
40
  export const LEDGER_VERSION = 2;
40
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
+
41
55
  export function loadLedger() {
42
56
  try {
43
57
  const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
@@ -48,6 +62,15 @@ export function loadLedger() {
48
62
  // instead of mixing two meanings into one total. The next scan rebuilds
49
63
  // whatever is still attributable.
50
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
+ }
51
74
  return data;
52
75
  } catch {
53
76
  return { version: LEDGER_VERSION, events: {} };
@@ -67,11 +90,18 @@ export function recordDelegationEvents(events) {
67
90
  const data = loadLedger();
68
91
  data.version = LEDGER_VERSION;
69
92
  let changed = false;
93
+ const keyOf = new Map(Object.keys(data.events).map((k) => [runIdentity(k), k]));
70
94
  for (const e of events) {
71
95
  if (!e || !e.key || typeof e.usd !== 'number' || !Number.isFinite(e.usd) || !Number.isFinite(e.ts)) continue;
72
- const prev = data.events[e.key];
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];
73
101
  const usd = roundUsd(e.usd);
74
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);
75
105
  data.events[e.key] = {
76
106
  ts: e.ts,
77
107
  usd,
@@ -83,9 +113,7 @@ export function recordDelegationEvents(events) {
83
113
  }
84
114
  if (!changed) return;
85
115
  try {
86
- const dir = userDataDir();
87
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
88
- writeFileSync(ledgerPath(), JSON.stringify(data) + '\n');
116
+ writeStateFile(ledgerPath(), JSON.stringify(data) + '\n');
89
117
  } catch {
90
118
  // statusline totals just stay stale until the next successful scan
91
119
  }
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
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * statusline-base — run the user's original statusline underneath ours.
3
+ *
4
+ * `sprag install` can merge with a statusline that was already configured: the
5
+ * original settings.json entry is saved as config `statuslineBase`, and each
6
+ * refresh prints that command's output first, sprag's lines after it.
7
+ * The statusline runs every few seconds, so nothing here may throw or stall.
8
+ */
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { loadConfig } from './config.js';
12
+ import { readStdinRaw } from './stdin-payload.js';
13
+
14
+ // A light name check instead of importing installer.js (heavy for a hot path).
15
+ // If the saved base were ourselves, running it would recurse without end.
16
+ const OURS = /(^|[\s/\\])(sprag|claude-token-saver)(-cli)?(\.cmd|\.exe|\.ps1)?(\s|$)/i;
17
+
18
+ // A wrapper script saved as the base may itself call `sprag --statusline`;
19
+ // that inner run must not start the base again, so the child carries a marker.
20
+ const NESTED = 'SPRAG_STATUSLINE_BASE';
21
+
22
+ export function withBaseStatusline(output) {
23
+ if (process.env[NESTED] === '1') return output;
24
+ try {
25
+ const cmd = loadConfig().statuslineBase?.command;
26
+ if (typeof cmd !== 'string' || !cmd.trim() || OURS.test(cmd)) return output;
27
+ const opts = { input: readStdinRaw(), encoding: 'utf8', timeout: 1500, env: { ...process.env, [NESTED]: '1' } };
28
+ const r = process.platform === 'win32'
29
+ ? spawnSync(cmd, { ...opts, shell: true })
30
+ : spawnSync('/bin/sh', ['-c', cmd], opts);
31
+ if (r.error || r.status !== 0) return output;
32
+ const base = String(r.stdout || '').replace(/[\r\n]+$/, '');
33
+ return base ? `${base}\n${output}` : output;
34
+ } catch {
35
+ return output;
36
+ }
37
+ }
@@ -25,10 +25,27 @@ import { readFileSync } from 'node:fs';
25
25
  * Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
26
26
  * changes — known keys get curated labels, unknowns get derived ones.
27
27
  */
28
+ // fd 0 can only be drained once. The statusline needs the payload twice: once
29
+ // to parse it, and once to forward it verbatim to a wrapped base statusline
30
+ // command, so the raw text is read on first use and cached for the process.
31
+ let rawCache = null;
32
+
33
+ /** The raw stdin text, read once. '' when stdin is a TTY or empty. */
34
+ export function readStdinRaw() {
35
+ if (rawCache !== null) return rawCache;
36
+ if (process.stdin.isTTY) return (rawCache = '');
37
+ try {
38
+ rawCache = readFileSync(0, 'utf8') || '';
39
+ } catch {
40
+ rawCache = '';
41
+ }
42
+ return rawCache;
43
+ }
44
+
28
45
  export function readStdinJson() {
29
46
  if (process.stdin.isTTY) return null;
30
47
  try {
31
- const raw = readFileSync(0, 'utf8');
48
+ const raw = readStdinRaw();
32
49
  if (!raw || !raw.trim()) return null;
33
50
  return JSON.parse(raw);
34
51
  } catch {
@@ -59,11 +76,16 @@ export function extractCaps(stdinJson) {
59
76
  if (!value || typeof value !== 'object') continue;
60
77
  const usedPct = normalizePct(value.used_percentage);
61
78
  if (usedPct === null) continue;
62
- const resetsAt = Number(value.resets_at);
79
+ // Same trap as the percentage: Number(null) is 0, and a reset "at 0" is
80
+ // 1970, which renders as a window that reset long ago. Only a positive
81
+ // number is a time.
82
+ const rawReset = typeof value.resets_at === 'string' && value.resets_at.trim() !== ''
83
+ ? Number(value.resets_at)
84
+ : value.resets_at;
63
85
  windows.push({
64
86
  key,
65
87
  usedPct,
66
- resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
88
+ resetsAt: typeof rawReset === 'number' && Number.isFinite(rawReset) && rawReset > 0 ? rawReset : null,
67
89
  });
68
90
  }
69
91
  return windows.length ? { windows } : null;
@@ -84,10 +106,13 @@ export function extractCaps(stdinJson) {
84
106
  // model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
85
107
  export function bedrockDisplayFromId(id) {
86
108
  if (typeof id !== 'string') return null;
87
- const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)[-_](\d+)/i);
109
+ // The minor version is one or two digits. An id with no minor goes straight
110
+ // to its date stamp (`claude-opus-4-20250514`), and reading that stamp as the
111
+ // minor printed "Opus 4.20250514".
112
+ const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)(?:[-_](\d{1,2})(?!\d))?/i);
88
113
  if (!m) return null;
89
114
  const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
90
- return `${family} ${m[2]}.${m[3]}`;
115
+ return m[3] === undefined ? `${family} ${m[2]}` : `${family} ${m[2]}.${m[3]}`;
91
116
  }
92
117
 
93
118
  /**
@@ -108,6 +133,12 @@ export function extractContextUsage(stdinJson) {
108
133
  };
109
134
  }
110
135
 
136
+ /** The session the payload belongs to, or null when the field is absent. */
137
+ export function extractSessionId(stdinJson) {
138
+ const id = stdinJson?.session_id;
139
+ return typeof id === 'string' && id !== '' ? id : null;
140
+ }
141
+
111
142
  export function extractModel(stdinJson) {
112
143
  if (!stdinJson || !stdinJson.model) return null;
113
144
  const m = stdinJson.model;