sprag-cli 3.40.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * session-records — parse a Claude Code session transcript into per-API-call
3
+ * records (model, tokens, depth, triggering user prompt, session cwd).
4
+ *
5
+ * This is the shared substrate for episode-level analysis (route-scan and the
6
+ * 3.x tier-classification work): one record per API call, deduplicated by
7
+ * requestId (last-write-wins, matching parser.js).
8
+ */
9
+
10
+ import { createReadStream } from 'node:fs';
11
+ import { createInterface } from 'node:readline';
12
+ import { resolveModelAlias } from './model-alias.js';
13
+
14
+ /**
15
+ * Strip context-window suffixes like "[1m]" so model ids compare cleanly, and
16
+ * turn a gateway inference-profile ARN back into a Claude alias. Ids that are
17
+ * already Claude aliases pass through untouched.
18
+ */
19
+ export function normalizeModelId(model) {
20
+ const resolved = resolveModelAlias(model || 'unknown');
21
+ return String(resolved).replace(/\[[^\]]*\]$/, '');
22
+ }
23
+
24
+ /** Extract plain text from a Claude transcript message content field. */
25
+ function contentText(content) {
26
+ if (typeof content === 'string') return content;
27
+ if (!Array.isArray(content)) return '';
28
+ return content
29
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
30
+ .map((b) => b.text)
31
+ .join('\n');
32
+ }
33
+
34
+ /**
35
+ * Parse one session transcript into call records.
36
+ * @returns {Promise<Array<{model, timestamp, prompt_tokens, completion_tokens, depth, userText, assistantText, cwd}>>}
37
+ */
38
+ const MUTATING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
39
+ const DELEGATION_TOOLS = new Set(['Task', 'Agent']);
40
+
41
+ // Permission rejections and harness policy denials arrive as is_error
42
+ // tool_results, but they encode the user's choice / the permission system's
43
+ // policy, not task difficulty — counting them poisons the rule-health error
44
+ // rate. Measured on a 14-day window: 6 user rejections + ~29 auto-mode
45
+ // classifier denials out of 142 is_error results (~25% of the numerator).
46
+ const REJECTION_RE = /doesn't want to proceed|tool use was rejected|doesn't want to take this action|denied by the claude code auto mode classifier|permission for this action was denied|requires approval/i;
47
+
48
+ // Harness-mechanical / self-corrected tool errors also arrive as is_error
49
+ // tool_results, but they reflect edit-ordering mechanics and the agent's own
50
+ // immediate correction loop — not task difficulty. Counting them poisons the
51
+ // rule-health error rate the same way permission denials do (v3.4.2). Measured
52
+ // on a 14-day window they dominate the numerator (e.g. "File has not been read
53
+ // yet" was 7/65 real errors in one project, edit-races + Task-lifecycle several
54
+ // more). Kept NARROW on purpose: ambiguous shell failures ("Exit code N", "File
55
+ // does not exist" on a Read) stay counted — those are genuine difficulty signal.
56
+ // Tool-argument schema violations (InputValidationError, "does not match the
57
+ // required schema/pattern") are the model mis-building a call, corrected on
58
+ // the next attempt — same self-correction family, not task difficulty.
59
+ // Measured 2026-09-09: they were the single largest numerator item (10/125).
60
+ const SELF_CORRECTED_RE = /File has not been read yet|has been modified since read|String to replace not found|is not running \(status:|<tool_use_error>Blocked:|InputValidationError|does not match the required/i;
61
+
62
+ // Failures with no diagnosable content — a bare exit code or "no output" —
63
+ // carry no evidence about WHY they failed, so they cannot support a
64
+ // rule-health verdict either way. Excluded from the numerator (16% of it was
65
+ // this plus schema errors, against a 20% review threshold). Anchored: an
66
+ // "Exit code 1" followed by a traceback still counts.
67
+ const NO_SIGNAL_RE = /^\s*(Command failed with no output|Exit code \d+)\s*$/;
68
+
69
+ // Environment constraints — a sandbox without `curl`/`wc`, a corporate proxy
70
+ // timing a fetch out — are not task difficulty either. The agent routinely
71
+ // routes around them and finishes: one 218-turn run that produced a 9,870-char
72
+ // sourced report was scored a failure on two `command not found` results.
73
+ // NARROW on purpose: only the shell's own "this binary is absent" wording and
74
+ // curl's transport-timeout exit, never a generic non-zero exit.
75
+ const ENVIRONMENT_RE = /command not found|curl: \(28\)|Operation timed out after|ETIMEDOUT|ENOTFOUND|getaddrinfo/i;
76
+
77
+ function toolResultText(content) {
78
+ if (typeof content === 'string') return content;
79
+ if (!Array.isArray(content)) return '';
80
+ return content.map((b) => (b && typeof b.text === 'string' ? b.text : '')).join(' ');
81
+ }
82
+
83
+ function isRealToolError(block) {
84
+ if (!block || block.type !== 'tool_result' || !block.is_error) return false;
85
+ const txt = toolResultText(block.content);
86
+ return !REJECTION_RE.test(txt) && !SELF_CORRECTED_RE.test(txt)
87
+ && !ENVIRONMENT_RE.test(txt) && !NO_SIGNAL_RE.test(txt);
88
+ }
89
+
90
+ export async function collectSessionRecords(filePath, { includeContent = true } = {}) {
91
+ const records = new Map();
92
+ let depth = 0;
93
+ let lastUserText = '';
94
+ let lastCwd = '';
95
+ let lastRecord = null;
96
+
97
+ const rl = createInterface({
98
+ input: createReadStream(filePath, { encoding: 'utf8' }),
99
+ crlfDelay: Infinity,
100
+ });
101
+
102
+ for await (const line of rl) {
103
+ let entry;
104
+ try {
105
+ entry = JSON.parse(line);
106
+ } catch {
107
+ continue;
108
+ }
109
+
110
+ const msg = entry.message;
111
+ if (typeof entry.cwd === 'string' && entry.cwd) lastCwd = entry.cwd;
112
+ if (entry.type === 'user' && msg) {
113
+ depth += 1;
114
+ const text = contentText(msg.content);
115
+ if (text) lastUserText = text;
116
+ // Tool errors arrive as tool_result blocks in the user entry that
117
+ // follows the assistant call — attribute them to that call's record.
118
+ if (lastRecord && Array.isArray(msg.content)) {
119
+ for (const b of msg.content) {
120
+ if (isRealToolError(b)) lastRecord.toolErrors += 1;
121
+ }
122
+ }
123
+ continue;
124
+ }
125
+ if (entry.type !== 'assistant' || !msg) continue;
126
+ depth += 1;
127
+
128
+ if (!msg.usage || !msg.id) continue;
129
+ // "<synthetic>" is Claude Code's placeholder for locally-generated
130
+ // entries (e.g. error stubs) — no real API call, nothing to record.
131
+ if (msg.model === '<synthetic>') continue;
132
+ const usage = msg.usage;
133
+ const reqId = entry.requestId || msg.id;
134
+
135
+ let mutatingToolCalls = 0;
136
+ let delegationCalls = 0;
137
+ const prev = records.get(reqId);
138
+ // Per-tool call histogram — what the call actually DID. route-scan's
139
+ // categorizer trusts this over the prompt's wording (behavior-first).
140
+ const toolCounts = { ...(prev?.toolCounts || {}) };
141
+ // tool_use ids of Task/Agent calls: the join key to the subagent
142
+ // transcripts under <session>/subagents/*.meta.json, which is how the
143
+ // outcome of a delegation is measured (rule-health v2).
144
+ const delegationToolUseIds = [...(prev?.delegationToolUseIds || [])];
145
+ if (Array.isArray(msg.content)) {
146
+ for (const b of msg.content) {
147
+ if (!b || b.type !== 'tool_use') continue;
148
+ if (MUTATING_TOOLS.has(b.name)) mutatingToolCalls += 1;
149
+ if (DELEGATION_TOOLS.has(b.name)) {
150
+ delegationCalls += 1;
151
+ if (typeof b.id === 'string') delegationToolUseIds.push(b.id);
152
+ }
153
+ if (typeof b.name === 'string') toolCounts[b.name] = (toolCounts[b.name] || 0) + 1;
154
+ }
155
+ }
156
+
157
+ lastRecord = {
158
+ model: normalizeModelId(msg.model),
159
+ timestamp: entry.timestamp || null,
160
+ prompt_tokens:
161
+ (usage.input_tokens || 0) +
162
+ (usage.cache_creation_input_tokens || 0) +
163
+ (usage.cache_read_input_tokens || 0),
164
+ completion_tokens: usage.output_tokens || 0,
165
+ // Per-bucket split, kept alongside the collapsed prompt_tokens: pricing
166
+ // differs per bucket, so costing a delegated run against what the
167
+ // session model would have charged needs them separated.
168
+ input_tokens: usage.input_tokens || 0,
169
+ cache_creation_tokens: usage.cache_creation_input_tokens || 0,
170
+ cache_read_tokens: usage.cache_read_input_tokens || 0,
171
+ ephemeral5m: usage.cache_creation?.ephemeral_5m_input_tokens || 0,
172
+ ephemeral1h: usage.cache_creation?.ephemeral_1h_input_tokens || 0,
173
+ depth,
174
+ userText: includeContent ? lastUserText : '',
175
+ assistantText: includeContent ? contentText(msg.content) : '',
176
+ cwd: lastCwd,
177
+ // Entries of the same request accumulate tool blocks and errors.
178
+ mutatingToolCalls: (prev?.mutatingToolCalls || 0) + mutatingToolCalls,
179
+ delegationCalls: (prev?.delegationCalls || 0) + delegationCalls,
180
+ delegationToolUseIds,
181
+ toolErrors: prev?.toolErrors || 0,
182
+ toolCounts,
183
+ };
184
+ records.set(reqId, lastRecord);
185
+ }
186
+
187
+ return [...records.values()];
188
+ }
package/src/stats.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Aggregate session data into daily trends, TTL breakdown, and anomalies.
3
+ */
4
+
5
+ function dateKey(date) {
6
+ return date.toISOString().slice(0, 10);
7
+ }
8
+
9
+ function hitRate(read, creation, input) {
10
+ const total = read + creation + input;
11
+ return total > 0 ? read / total : 0;
12
+ }
13
+
14
+ /**
15
+ * Daily cache hit rate trend
16
+ */
17
+ export function dailyTrend(sessions) {
18
+ const byDay = new Map();
19
+
20
+ for (const s of sessions) {
21
+ if (!s.startTime) continue;
22
+ const day = dateKey(s.startTime);
23
+ if (!byDay.has(day)) {
24
+ byDay.set(day, {
25
+ date: day,
26
+ input: 0,
27
+ cacheCreation: 0,
28
+ cacheRead: 0,
29
+ ephemeral5m: 0,
30
+ ephemeral1h: 0,
31
+ output: 0,
32
+ apiCalls: 0,
33
+ sessions: 0,
34
+ });
35
+ }
36
+ const d = byDay.get(day);
37
+ d.input += s.totals.input;
38
+ d.cacheCreation += s.totals.cacheCreation;
39
+ d.cacheRead += s.totals.cacheRead;
40
+ d.ephemeral5m += s.totals.ephemeral5m;
41
+ d.ephemeral1h += s.totals.ephemeral1h;
42
+ d.output += s.totals.output;
43
+ d.apiCalls += s.requestCount;
44
+ d.sessions += 1;
45
+ }
46
+
47
+ return [...byDay.values()]
48
+ .sort((a, b) => a.date.localeCompare(b.date))
49
+ .map((d) => ({
50
+ ...d,
51
+ hitRate: hitRate(d.cacheRead, d.cacheCreation, d.input),
52
+ totalInput: d.cacheRead + d.cacheCreation + d.input,
53
+ }));
54
+ }
55
+
56
+ /**
57
+ * TTL breakdown summary
58
+ */
59
+ export function ttlBreakdown(sessions) {
60
+ let total5m = 0;
61
+ let total1h = 0;
62
+ let gatewayObserved = false;
63
+
64
+ for (const s of sessions) {
65
+ total5m += s.totals.ephemeral5m;
66
+ total1h += s.totals.ephemeral1h;
67
+ if (s.gatewayObserved) gatewayObserved = true;
68
+ }
69
+
70
+ const total = total5m + total1h;
71
+ return {
72
+ ephemeral5m: total5m,
73
+ ephemeral1h: total1h,
74
+ total,
75
+ pct5m: total > 0 ? total5m / total : 0,
76
+ pct1h: total > 0 ? total1h / total : 0,
77
+ // Bedrock and Vertex fill only the `cache_creation_input_tokens` sum and
78
+ // leave the per-bucket split at zero, so `total === 0` there means "cannot
79
+ // tell" rather than "no cache writes happened". Carrying the observation
80
+ // alongside the numbers lets the display layer tell those two apart
81
+ // without going back to the environment.
82
+ gatewayObserved,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Detect anomalies — days where hit rate drops significantly from rolling average
88
+ */
89
+ export function detectAnomalies(trend, { threshold = 0.15 } = {}) {
90
+ const anomalies = [];
91
+ const windowSize = 7;
92
+
93
+ for (let i = 0; i < trend.length; i++) {
94
+ const day = trend[i];
95
+ if (day.apiCalls < 5) continue; // skip low-volume days
96
+
97
+ // rolling average of prior days
98
+ const windowStart = Math.max(0, i - windowSize);
99
+ const window = trend.slice(windowStart, i);
100
+ if (window.length < 3) continue;
101
+
102
+ const avgHitRate =
103
+ window.reduce((sum, d) => sum + d.hitRate, 0) / window.length;
104
+
105
+ const drop = avgHitRate - day.hitRate;
106
+ if (drop > threshold) {
107
+ anomalies.push({
108
+ date: day.date,
109
+ hitRate: day.hitRate,
110
+ avgHitRate,
111
+ drop,
112
+ apiCalls: day.apiCalls,
113
+ });
114
+ }
115
+ }
116
+
117
+ return anomalies;
118
+ }
119
+
120
+ /**
121
+ * Per-session metrics used by diagnostics.
122
+ */
123
+ export function sessionMetrics(session) {
124
+ const t = session.totals;
125
+ const totalInput = t.input + t.cacheCreation + t.cacheRead;
126
+ const reqs = session.requestCount || 0;
127
+ const avgInputPerReq = reqs > 0 ? totalInput / reqs : 0;
128
+ const hit = hitRate(t.cacheRead, t.cacheCreation, t.input);
129
+ const ttlSum = t.ephemeral5m + t.ephemeral1h;
130
+ const pct5m = ttlSum > 0 ? t.ephemeral5m / ttlSum : 0;
131
+ const outputRatio = totalInput > 0 ? t.output / totalInput : 0;
132
+ const writeToReadRatio = t.cacheRead > 0 ? t.cacheCreation / t.cacheRead : (t.cacheCreation > 0 ? Infinity : 0);
133
+ return {
134
+ sessionId: session.sessionId,
135
+ projectDir: session.projectDir,
136
+ startTime: session.startTime,
137
+ endTime: session.endTime,
138
+ requestCount: reqs,
139
+ totalInput,
140
+ avgInputPerReq,
141
+ hitRate: hit,
142
+ pct5m,
143
+ outputRatio,
144
+ writeToReadRatio,
145
+ maxContextPerRequest: session.maxContextPerRequest || 0,
146
+ gatewayObserved: !!session.gatewayObserved,
147
+ totals: t,
148
+ };
149
+ }
150
+
151
+ function median(values) {
152
+ if (values.length === 0) return 0;
153
+ const sorted = [...values].sort((a, b) => a - b);
154
+ const mid = Math.floor(sorted.length / 2);
155
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
156
+ }
157
+
158
+ function percentile(values, p) {
159
+ if (values.length === 0) return 0;
160
+ const sorted = [...values].sort((a, b) => a - b);
161
+ const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
162
+ return sorted[idx];
163
+ }
164
+
165
+ /**
166
+ * Baseline using sessions OUTSIDE the recent window. Recent sessions are what
167
+ * we're diagnosing — if we let them into the baseline they'd drag the baseline
168
+ * toward themselves and never register as anomalies.
169
+ */
170
+ export function computeBaseline(sessions, recentWindowMs = 24 * 60 * 60 * 1000) {
171
+ const cutoff = Date.now() - recentWindowMs;
172
+ const older = sessions.filter((s) => s.startTime && s.startTime.getTime() < cutoff && s.requestCount > 0);
173
+ if (older.length < 3) {
174
+ return { enough: false, sampleSize: older.length };
175
+ }
176
+ const metrics = older.map(sessionMetrics);
177
+ return {
178
+ enough: true,
179
+ sampleSize: older.length,
180
+ medianAvgInputPerReq: median(metrics.map((m) => m.avgInputPerReq)),
181
+ p95AvgInputPerReq: percentile(metrics.map((m) => m.avgInputPerReq), 0.95),
182
+ medianTotalInput: median(metrics.map((m) => m.totalInput)),
183
+ p95TotalInput: percentile(metrics.map((m) => m.totalInput), 0.95),
184
+ medianHitRate: median(metrics.map((m) => m.hitRate)),
185
+ medianRequestCount: median(metrics.map((m) => m.requestCount)),
186
+ p95RequestCount: percentile(metrics.map((m) => m.requestCount), 0.95),
187
+ medianMaxContext: median(metrics.map((m) => m.maxContextPerRequest)),
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Diagnose one session against a baseline. Returns issue codes + supporting
193
+ * numbers so the formatter can render human-readable messages without
194
+ * re-computing anything.
195
+ *
196
+ * Issue codes:
197
+ * LARGE_INPUT_PER_REQUEST — likely 1M context mode; avg input per request
198
+ * is 8x+ baseline or max req context > 250k
199
+ * LOW_HIT_RATE — below 0.5 and baseline was meaningfully higher
200
+ * BUCKET_5M_DOMINANT — 5m TTL writes dominate (>70%); prefix re-writes
201
+ * HIGH_OUTPUT_RATIO — output/input ratio > 0.15 (unusually chatty)
202
+ * HIGH_REQUEST_COUNT — request count > 3x baseline
203
+ * FREQUENT_CACHE_REBUILD — cacheCreation > cacheRead (cache not reused)
204
+ */
205
+ export function diagnoseSession(metrics, baseline) {
206
+ const issues = [];
207
+ if (!metrics || metrics.requestCount === 0) return issues;
208
+
209
+ const b = baseline?.enough ? baseline : null;
210
+
211
+ if (metrics.maxContextPerRequest > 250_000 ||
212
+ (b && b.medianAvgInputPerReq > 0 && metrics.avgInputPerReq > b.medianAvgInputPerReq * 8)) {
213
+ issues.push({
214
+ code: 'LARGE_INPUT_PER_REQUEST',
215
+ avgInputPerReq: metrics.avgInputPerReq,
216
+ maxContextPerRequest: metrics.maxContextPerRequest,
217
+ baseline: b?.medianAvgInputPerReq,
218
+ });
219
+ }
220
+
221
+ if (metrics.hitRate < 0.5 && (!b || b.medianHitRate > metrics.hitRate + 0.2)) {
222
+ issues.push({
223
+ code: 'LOW_HIT_RATE',
224
+ hitRate: metrics.hitRate,
225
+ baseline: b?.medianHitRate,
226
+ });
227
+ }
228
+
229
+ // The split is only knowable when the provider reports it. Requiring that
230
+ // sum outright meant gateway users — who are on a 5m-only backend and so
231
+ // need this warning most — never saw it at all.
232
+ const ttlSplitKnown = (metrics.totals.ephemeral5m + metrics.totals.ephemeral1h) > 0;
233
+ if (ttlSplitKnown && metrics.pct5m > 0.7) {
234
+ issues.push({
235
+ code: 'BUCKET_5M_DOMINANT',
236
+ pct5m: metrics.pct5m,
237
+ });
238
+ } else if (!ttlSplitKnown && metrics.gatewayObserved && metrics.totals.cacheCreation > 0) {
239
+ // Bedrock and Vertex offer no 1h bucket, so every write is a 5m write.
240
+ // The advice differs from the subscription case: no plan change fixes it.
241
+ issues.push({
242
+ code: 'BUCKET_5M_DOMINANT_GATEWAY',
243
+ pct5m: 1.0,
244
+ inferred: true,
245
+ });
246
+ }
247
+
248
+ if (metrics.outputRatio > 0.15) {
249
+ issues.push({
250
+ code: 'HIGH_OUTPUT_RATIO',
251
+ outputRatio: metrics.outputRatio,
252
+ });
253
+ }
254
+
255
+ if (b && metrics.requestCount > b.medianRequestCount * 3 && metrics.requestCount > 30) {
256
+ issues.push({
257
+ code: 'HIGH_REQUEST_COUNT',
258
+ requestCount: metrics.requestCount,
259
+ baseline: b.medianRequestCount,
260
+ });
261
+ }
262
+
263
+ if (metrics.writeToReadRatio !== Infinity &&
264
+ metrics.writeToReadRatio > 1 &&
265
+ metrics.totals.cacheCreation > 100_000) {
266
+ issues.push({
267
+ code: 'FREQUENT_CACHE_REBUILD',
268
+ writeToReadRatio: metrics.writeToReadRatio,
269
+ });
270
+ }
271
+
272
+ return issues;
273
+ }
274
+
275
+ /**
276
+ * Find sessions in the recent window whose token totals are >= multiplier x
277
+ * the baseline p95. Returns spikes sorted by severity (largest first) with
278
+ * diagnosis attached.
279
+ */
280
+ export function detectSpikes(sessions, { recentHours = 24, multiplier = 3 } = {}) {
281
+ const baseline = computeBaseline(sessions, recentHours * 60 * 60 * 1000);
282
+ const cutoff = Date.now() - recentHours * 60 * 60 * 1000;
283
+ const recent = sessions.filter(
284
+ (s) => s.startTime && s.startTime.getTime() >= cutoff && s.requestCount > 0,
285
+ );
286
+
287
+ const spikes = [];
288
+ for (const s of recent) {
289
+ const m = sessionMetrics(s);
290
+ // Need a floor so tiny sessions with a few hundred tokens don't register
291
+ // as spikes just because baseline is also small.
292
+ if (m.totalInput < 1_000_000) continue;
293
+
294
+ const ratio = baseline.enough && baseline.p95TotalInput > 0
295
+ ? m.totalInput / baseline.p95TotalInput
296
+ : null;
297
+
298
+ const isSpike =
299
+ (ratio !== null && ratio >= multiplier) ||
300
+ m.maxContextPerRequest > 250_000;
301
+
302
+ if (!isSpike) continue;
303
+
304
+ spikes.push({
305
+ metrics: m,
306
+ ratio,
307
+ issues: diagnoseSession(m, baseline),
308
+ });
309
+ }
310
+
311
+ spikes.sort((a, b) => b.metrics.totalInput - a.metrics.totalInput);
312
+ return { baseline, spikes };
313
+ }
314
+
315
+ /**
316
+ * Detect the likely context-window setting from the largest single-request
317
+ * context seen in the recent window. Claude Code's two modes are 200k (default)
318
+ * and 1M (Opus 4.7+ auto-enabled on Max). If max single-request context passes
319
+ * the 200k ceiling, the user has 1M turned on.
320
+ *
321
+ * Returns { size: '1M' | '200k' | 'unknown', maxContext, source, overWarn }.
322
+ * `size` stays informational (which window is in play); `overWarn` is the
323
+ * alarm condition and uses the much higher CONTEXT_WARN_TOKENS line.
324
+ */
325
+ export const CONTEXT_WARN_TOKENS = 500_000;
326
+
327
+ export function detectContextWindow(sessions, { recentHours = 24 } = {}) {
328
+ const cutoff = Date.now() - recentHours * 60 * 60 * 1000;
329
+ const recent = sessions.filter(
330
+ (s) => s.endTime && s.endTime.getTime() >= cutoff && s.requestCount > 0,
331
+ );
332
+ const pool = recent.length > 0 ? recent : sessions;
333
+ const maxContext = pool.reduce(
334
+ (m, s) => Math.max(m, s.maxContextPerRequest || 0),
335
+ 0,
336
+ );
337
+ if (maxContext === 0) return { size: 'unknown', maxContext, source: 'no-data', overWarn: false };
338
+ // 200_000 is the hard ceiling for the standard window. Anything materially
339
+ // over that means the 1M context is in play. Use 210k for a small safety
340
+ // margin against rounding/metadata tokens.
341
+ const source = recent.length > 0 ? 'recent' : 'all';
342
+ // `overWarn` is what the statusline and the report actually alarm on. The
343
+ // 200k line stopped separating careless sessions from ordinary ones: every
344
+ // current model ships a 1M window, the Claude Code system prompt plus a few
345
+ // file reads already clears 200k, so a fresh session tripped the warning
346
+ // right after install and then never stopped tripping it. A warning that is
347
+ // always on carries no information. 500k is where a context genuinely costs
348
+ // real money per turn and where /compact is the right answer.
349
+ const overWarn = maxContext > CONTEXT_WARN_TOKENS;
350
+ if (maxContext > 210_000) return { size: '1M', maxContext, source, overWarn };
351
+ return { size: '200k', maxContext, source, overWarn };
352
+ }
353
+
354
+ /**
355
+ * Overall summary
356
+ */
357
+ export function summary(sessions) {
358
+ const totals = sessions.reduce(
359
+ (acc, s) => {
360
+ acc.input += s.totals.input;
361
+ acc.cacheCreation += s.totals.cacheCreation;
362
+ acc.cacheRead += s.totals.cacheRead;
363
+ acc.ephemeral5m += s.totals.ephemeral5m;
364
+ acc.ephemeral1h += s.totals.ephemeral1h;
365
+ acc.output += s.totals.output;
366
+ acc.apiCalls += s.requestCount;
367
+ acc.sessions += 1;
368
+ return acc;
369
+ },
370
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0, apiCalls: 0, sessions: 0 },
371
+ );
372
+
373
+ const totalInput = totals.cacheRead + totals.cacheCreation + totals.input;
374
+
375
+ return {
376
+ ...totals,
377
+ totalInput,
378
+ hitRate: hitRate(totals.cacheRead, totals.cacheCreation, totals.input),
379
+ };
380
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Claude Code's statusline contract feeds this tool a JSON blob on stdin every
3
+ * refresh. These helpers own reading and interpreting that payload; everything
4
+ * else in the CLI takes the extracted values.
5
+ */
6
+
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ /**
10
+ * Read the JSON blob Claude Code feeds the statusline command on stdin.
11
+ * Returns null when stdin is a TTY or empty (e.g. user invokes `--statusline`
12
+ * by hand) so callers can fall back to flag/env config.
13
+ *
14
+ * The blob shape (subset we consume):
15
+ * {
16
+ * "transcript_path": "...",
17
+ * "rate_limits": {
18
+ * "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
19
+ * "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
20
+ * }
21
+ * }
22
+ *
23
+ * extractCaps treats `rate_limits` as a generic object so any future window
24
+ * Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
25
+ * changes — known keys get curated labels, unknowns get derived ones.
26
+ */
27
+ export function readStdinJson() {
28
+ if (process.stdin.isTTY) return null;
29
+ try {
30
+ const raw = readFileSync(0, 'utf8');
31
+ if (!raw || !raw.trim()) return null;
32
+ return JSON.parse(raw);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A percentage from the payload, or null when the field is absent, empty, or
40
+ * non-numeric. `Number(null|''|[])` is 0, so a plain Number() call renders
41
+ * missing data as "0% used" — the most dangerously wrong reading possible for
42
+ * a cap gauge. Out-of-range values are clamped to 0..100 at this entry point
43
+ * so every renderer (and caps-cache.js, which persists the value) agrees.
44
+ */
45
+ function normalizePct(raw) {
46
+ if (typeof raw !== 'number') {
47
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
48
+ raw = Number(raw);
49
+ }
50
+ if (!Number.isFinite(raw)) return null;
51
+ return Math.max(0, Math.min(100, raw));
52
+ }
53
+
54
+ export function extractCaps(stdinJson) {
55
+ if (!stdinJson || !stdinJson.rate_limits || typeof stdinJson.rate_limits !== 'object') return null;
56
+ const windows = [];
57
+ for (const [key, value] of Object.entries(stdinJson.rate_limits)) {
58
+ if (!value || typeof value !== 'object') continue;
59
+ const usedPct = normalizePct(value.used_percentage);
60
+ if (usedPct === null) continue;
61
+ const resetsAt = Number(value.resets_at);
62
+ windows.push({
63
+ key,
64
+ usedPct,
65
+ resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
66
+ });
67
+ }
68
+ return windows.length ? { windows } : null;
69
+ }
70
+
71
+ /**
72
+ * Pull the human-friendly model name out of Claude Code's stdin payload.
73
+ * `model.display_name` is the contract; fall back to `model.id` when it's
74
+ * absent. Returns null when nothing usable is in the JSON.
75
+ */
76
+ // Bedrock/litellm proxies pass model IDs like
77
+ // global.anthropic.claude-opus-4-7-20251001-v1:0
78
+ // anthropic.claude-sonnet-4-6-20250930-v1:0
79
+ // bedrock/anthropic.claude-haiku-4-5
80
+ // while Claude Code's `display_name` collapses these to a generic family
81
+ // label ("Opus 4", "Sonnet 4") that hides the actual minor version. Pull the
82
+ // version out of the id when we can spot it so the statusline shows the real
83
+ // model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
84
+ export function bedrockDisplayFromId(id) {
85
+ if (typeof id !== 'string') return null;
86
+ const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)[-_](\d+)/i);
87
+ if (!m) return null;
88
+ const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
89
+ return `${family} ${m[2]}.${m[3]}`;
90
+ }
91
+
92
+ /**
93
+ * Live context usage from Claude Code's stdin payload (`context_window`).
94
+ * More accurate than inferring from transcripts: it's the CURRENT session's
95
+ * real fill level, updated every refresh. Shape (subset):
96
+ * "context_window": { "context_window_size": 200000, "used_percentage": 68 }
97
+ */
98
+ export function extractContextUsage(stdinJson) {
99
+ const cw = stdinJson && stdinJson.context_window;
100
+ if (!cw || typeof cw !== 'object') return null;
101
+ const usedPct = normalizePct(cw.used_percentage);
102
+ const size = Number(cw.context_window_size);
103
+ if (usedPct === null) return null;
104
+ return {
105
+ usedPct,
106
+ size: Number.isFinite(size) && size > 0 ? size : null,
107
+ };
108
+ }
109
+
110
+ export function extractModel(stdinJson) {
111
+ if (!stdinJson || !stdinJson.model) return null;
112
+ const m = stdinJson.model;
113
+ if (typeof m === 'string') return bedrockDisplayFromId(m) || m;
114
+ // Prefer the id when it carries a precise version (e.g. Bedrock IDs); fall
115
+ // back to display_name for the standard Claude Code path where display_name
116
+ // already says "Claude Opus 4.7".
117
+ const idDerived = bedrockDisplayFromId(m.id);
118
+ if (idDerived) return idDerived;
119
+ if (typeof m.display_name === 'string' && m.display_name) return m.display_name;
120
+ if (typeof m.id === 'string' && m.id) return m.id;
121
+ return null;
122
+ }