@curdx/flow 7.1.5 → 7.1.7
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.
- package/CHANGELOG.md +87 -0
- package/README.md +14 -0
- package/dist/analyze-FX2PCSL6.mjs +1956 -0
- package/dist/check-TJPGCG3Z.mjs +222 -0
- package/dist/index.mjs +112 -107
- package/package.json +2 -2
- package/dist/analyze-4DE3HVCA.mjs +0 -794
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/analyze/index.ts
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync3, writeFileSync } from "fs";
|
|
5
|
+
import { homedir as homedir2 } from "os";
|
|
6
|
+
import path4 from "path";
|
|
7
|
+
|
|
8
|
+
// src/analyze/pricing.ts
|
|
9
|
+
var PRICING = {
|
|
10
|
+
"claude-opus-4-7": {
|
|
11
|
+
inputPerMTok: 5,
|
|
12
|
+
outputPerMTok: 25,
|
|
13
|
+
cacheReadMul: 0.1,
|
|
14
|
+
cache5mWriteMul: 1.25,
|
|
15
|
+
cache1hWriteMul: 2
|
|
16
|
+
},
|
|
17
|
+
"claude-sonnet-4-6": {
|
|
18
|
+
inputPerMTok: 3,
|
|
19
|
+
outputPerMTok: 15,
|
|
20
|
+
cacheReadMul: 0.1,
|
|
21
|
+
cache5mWriteMul: 1.25,
|
|
22
|
+
cache1hWriteMul: 2
|
|
23
|
+
},
|
|
24
|
+
"claude-haiku-4-5-20251001": {
|
|
25
|
+
inputPerMTok: 1,
|
|
26
|
+
outputPerMTok: 5,
|
|
27
|
+
cacheReadMul: 0.1,
|
|
28
|
+
cache5mWriteMul: 1.25,
|
|
29
|
+
cache1hWriteMul: 2
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var MODEL_ALIASES = {
|
|
33
|
+
"claude-haiku-4-5": "claude-haiku-4-5-20251001"
|
|
34
|
+
};
|
|
35
|
+
function resolveModelId(modelStr) {
|
|
36
|
+
if (!modelStr) return void 0;
|
|
37
|
+
const aliased = MODEL_ALIASES[modelStr];
|
|
38
|
+
if (aliased && aliased in PRICING) return aliased;
|
|
39
|
+
if (modelStr in PRICING) return modelStr;
|
|
40
|
+
return void 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/analyze/cost.ts
|
|
44
|
+
function round4(usd) {
|
|
45
|
+
return Math.round(usd * 1e4) / 1e4;
|
|
46
|
+
}
|
|
47
|
+
function computeCost(row) {
|
|
48
|
+
const canonical = resolveModelId(row.model);
|
|
49
|
+
if (!canonical) return 0;
|
|
50
|
+
const price = PRICING[canonical];
|
|
51
|
+
if (!price) return 0;
|
|
52
|
+
const input = row.inputTokens ?? 0;
|
|
53
|
+
const output = row.outputTokens ?? 0;
|
|
54
|
+
const cacheRead = row.cacheReadTokens ?? 0;
|
|
55
|
+
const cache5m = row.cacheCreate5mTokens ?? 0;
|
|
56
|
+
const cache1h = row.cacheCreate1hTokens ?? 0;
|
|
57
|
+
const usd = (input * price.inputPerMTok + cache5m * price.cache5mWriteMul * price.inputPerMTok + cache1h * price.cache1hWriteMul * price.inputPerMTok + cacheRead * price.cacheReadMul * price.inputPerMTok + output * price.outputPerMTok) / 1e6;
|
|
58
|
+
return round4(usd);
|
|
59
|
+
}
|
|
60
|
+
function readNumber(obj, dottedPath) {
|
|
61
|
+
if (!obj) return 0;
|
|
62
|
+
const parts = dottedPath.split(".");
|
|
63
|
+
let cur = obj;
|
|
64
|
+
for (const p of parts) {
|
|
65
|
+
if (cur && typeof cur === "object" && p in cur) {
|
|
66
|
+
cur = cur[p];
|
|
67
|
+
} else {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return typeof cur === "number" && Number.isFinite(cur) ? cur : 0;
|
|
72
|
+
}
|
|
73
|
+
function readString(obj, dottedPath) {
|
|
74
|
+
if (!obj) return void 0;
|
|
75
|
+
const parts = dottedPath.split(".");
|
|
76
|
+
let cur = obj;
|
|
77
|
+
for (const p of parts) {
|
|
78
|
+
if (cur && typeof cur === "object" && p in cur) {
|
|
79
|
+
cur = cur[p];
|
|
80
|
+
} else {
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return typeof cur === "string" ? cur : void 0;
|
|
85
|
+
}
|
|
86
|
+
var TRAILER_RE = /<usage>[\s\S]*?total_tokens:\s*(\d+)[\s\S]*?tool_uses:\s*(\d+)[\s\S]*?duration_ms:\s*(\d+)[\s\S]*?<\/usage>/g;
|
|
87
|
+
function extractTrailerUsage(text, parent) {
|
|
88
|
+
try {
|
|
89
|
+
if (typeof text !== "string" || text.length === 0) return [];
|
|
90
|
+
const rows = [];
|
|
91
|
+
TRAILER_RE.lastIndex = 0;
|
|
92
|
+
let m;
|
|
93
|
+
while ((m = TRAILER_RE.exec(text)) !== null) {
|
|
94
|
+
const totalTokens = parseInt(m[1] ?? "0", 10);
|
|
95
|
+
const durationMs = parseInt(m[3] ?? "0", 10);
|
|
96
|
+
rows.push({
|
|
97
|
+
ts: parent.ts,
|
|
98
|
+
requestId: parent.requestId,
|
|
99
|
+
correlationId: parent.correlationId,
|
|
100
|
+
model: "unknown",
|
|
101
|
+
inputTokens: 0,
|
|
102
|
+
outputTokens: Number.isFinite(totalTokens) ? totalTokens : 0,
|
|
103
|
+
cacheReadTokens: 0,
|
|
104
|
+
cacheCreate5mTokens: 0,
|
|
105
|
+
cacheCreate1hTokens: 0,
|
|
106
|
+
durationMs: Number.isFinite(durationMs) ? durationMs : 0,
|
|
107
|
+
source: "subagent_trailer"
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return rows;
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function extractUsageRowsFromEvents(events, errorEntries) {
|
|
116
|
+
const rows = [];
|
|
117
|
+
if (!Array.isArray(events)) return rows;
|
|
118
|
+
for (const ev of events) {
|
|
119
|
+
try {
|
|
120
|
+
if (!ev) continue;
|
|
121
|
+
try {
|
|
122
|
+
const payloadAny = ev.payload;
|
|
123
|
+
const message = payloadAny?.["message"];
|
|
124
|
+
const outerContent = message?.["content"];
|
|
125
|
+
if (Array.isArray(outerContent)) {
|
|
126
|
+
for (const item of outerContent) {
|
|
127
|
+
const inner = item?.["content"];
|
|
128
|
+
if (!Array.isArray(inner)) continue;
|
|
129
|
+
for (const seg of inner) {
|
|
130
|
+
const text = seg?.["text"];
|
|
131
|
+
if (typeof text !== "string" || text.length === 0) continue;
|
|
132
|
+
const trailerRows = extractTrailerUsage(text, {
|
|
133
|
+
ts: ev.ts,
|
|
134
|
+
requestId: ev.requestId ?? "",
|
|
135
|
+
correlationId: readString(payloadAny, "correlationId")
|
|
136
|
+
});
|
|
137
|
+
if (trailerRows.length > 0) rows.push(...trailerRows);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
if (ev.kind !== "assistant_turn") continue;
|
|
144
|
+
const payload = ev.payload;
|
|
145
|
+
const model = readString(payload, "message.model");
|
|
146
|
+
const canonical = resolveModelId(model);
|
|
147
|
+
if (!canonical) continue;
|
|
148
|
+
const usage = payload?.["message"]?.["usage"];
|
|
149
|
+
const inputTokens = readNumber(usage, "input_tokens");
|
|
150
|
+
const outputTokens = readNumber(usage, "output_tokens");
|
|
151
|
+
const cacheReadTokens = readNumber(usage, "cache_read_input_tokens");
|
|
152
|
+
let cacheCreate5mTokens = readNumber(usage, "cache_creation.ephemeral_5m_input_tokens");
|
|
153
|
+
let cacheCreate1hTokens = readNumber(usage, "cache_creation.ephemeral_1h_input_tokens");
|
|
154
|
+
if (cacheCreate5mTokens === 0 && cacheCreate1hTokens === 0) {
|
|
155
|
+
const legacy = readNumber(usage, "cache_creation_input_tokens");
|
|
156
|
+
if (legacy > 0) cacheCreate5mTokens = legacy;
|
|
157
|
+
}
|
|
158
|
+
const requestId = ev.requestId ?? readString(payload, "message.id") ?? readString(payload, "requestId") ?? "";
|
|
159
|
+
const correlationId = readString(payload, "correlationId");
|
|
160
|
+
const isSidechain = payload?.["isSidechain"] ?? void 0;
|
|
161
|
+
rows.push({
|
|
162
|
+
ts: ev.ts,
|
|
163
|
+
requestId,
|
|
164
|
+
uuid: ev.uuid,
|
|
165
|
+
model: canonical,
|
|
166
|
+
inputTokens,
|
|
167
|
+
outputTokens,
|
|
168
|
+
cacheReadTokens,
|
|
169
|
+
cacheCreate5mTokens,
|
|
170
|
+
cacheCreate1hTokens,
|
|
171
|
+
correlationId,
|
|
172
|
+
isSidechain,
|
|
173
|
+
source: "assistant"
|
|
174
|
+
});
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return rows;
|
|
180
|
+
}
|
|
181
|
+
function parseCorrelationId(cid) {
|
|
182
|
+
if (typeof cid !== "string" || cid.length === 0) return {};
|
|
183
|
+
const parts = cid.split(":");
|
|
184
|
+
if (parts.length !== 3) return {};
|
|
185
|
+
const [sid, task, iter] = parts;
|
|
186
|
+
if (!sid || !task || !iter) return {};
|
|
187
|
+
return { sid, task, iter };
|
|
188
|
+
}
|
|
189
|
+
function aggregateBy(rows, level, ctx) {
|
|
190
|
+
try {
|
|
191
|
+
if (!Array.isArray(rows) || rows.length === 0) return [];
|
|
192
|
+
const specPhaseMap = ctx?.specPhaseMap ?? {};
|
|
193
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
if (!row) continue;
|
|
196
|
+
const parsed = parseCorrelationId(row.correlationId);
|
|
197
|
+
let key;
|
|
198
|
+
let specHint;
|
|
199
|
+
let phaseHint;
|
|
200
|
+
if (level === "spec") {
|
|
201
|
+
key = parsed.sid ?? "unknown";
|
|
202
|
+
specHint = parsed.sid;
|
|
203
|
+
} else if (level === "phase") {
|
|
204
|
+
key = (parsed.sid && specPhaseMap[parsed.sid]) ?? "unknown";
|
|
205
|
+
specHint = parsed.sid;
|
|
206
|
+
phaseHint = parsed.sid ? specPhaseMap[parsed.sid] : void 0;
|
|
207
|
+
} else {
|
|
208
|
+
key = row.correlationId ?? "unknown";
|
|
209
|
+
specHint = parsed.sid;
|
|
210
|
+
phaseHint = parsed.sid ? specPhaseMap[parsed.sid] : void 0;
|
|
211
|
+
}
|
|
212
|
+
let bucket = buckets.get(key);
|
|
213
|
+
if (!bucket) {
|
|
214
|
+
bucket = {
|
|
215
|
+
level,
|
|
216
|
+
key,
|
|
217
|
+
totalUSD: 0,
|
|
218
|
+
inputTokens: 0,
|
|
219
|
+
outputTokens: 0,
|
|
220
|
+
cacheReadTokens: 0,
|
|
221
|
+
cacheCreate5mTokens: 0,
|
|
222
|
+
cacheCreate1hTokens: 0,
|
|
223
|
+
rowCount: 0,
|
|
224
|
+
trailerCount: 0,
|
|
225
|
+
durationMs: 0,
|
|
226
|
+
modelMix: {},
|
|
227
|
+
spec: specHint,
|
|
228
|
+
phase: phaseHint
|
|
229
|
+
};
|
|
230
|
+
buckets.set(key, bucket);
|
|
231
|
+
}
|
|
232
|
+
const usd = computeCost(row);
|
|
233
|
+
bucket.totalUSD += usd;
|
|
234
|
+
bucket.inputTokens += row.inputTokens ?? 0;
|
|
235
|
+
bucket.outputTokens += row.outputTokens ?? 0;
|
|
236
|
+
bucket.cacheReadTokens += row.cacheReadTokens ?? 0;
|
|
237
|
+
bucket.cacheCreate5mTokens += row.cacheCreate5mTokens ?? 0;
|
|
238
|
+
bucket.cacheCreate1hTokens += row.cacheCreate1hTokens ?? 0;
|
|
239
|
+
bucket.rowCount += 1;
|
|
240
|
+
if (row.source === "subagent_trailer") bucket.trailerCount += 1;
|
|
241
|
+
bucket.durationMs += row.durationMs ?? 0;
|
|
242
|
+
const modelKey = row.model || "unknown";
|
|
243
|
+
const mix = bucket.modelMix[modelKey] ?? { tokens: 0, usd: 0 };
|
|
244
|
+
mix.tokens += (row.inputTokens ?? 0) + (row.outputTokens ?? 0);
|
|
245
|
+
mix.usd += usd;
|
|
246
|
+
bucket.modelMix[modelKey] = mix;
|
|
247
|
+
}
|
|
248
|
+
const out = Array.from(buckets.values()).map((b) => ({
|
|
249
|
+
...b,
|
|
250
|
+
totalUSD: Math.round(b.totalUSD * 1e4) / 1e4
|
|
251
|
+
}));
|
|
252
|
+
out.sort((a, b) => b.totalUSD - a.totalUSD);
|
|
253
|
+
return out;
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/analyze/filter.ts
|
|
260
|
+
function parseSince(since) {
|
|
261
|
+
if (!since) return void 0;
|
|
262
|
+
const m = /^(\d+)d$/.exec(since);
|
|
263
|
+
if (m) {
|
|
264
|
+
const days = Number(m[1]);
|
|
265
|
+
return new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
|
|
266
|
+
}
|
|
267
|
+
const d = new Date(since);
|
|
268
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
269
|
+
return void 0;
|
|
270
|
+
}
|
|
271
|
+
function filterEvents(events, opts) {
|
|
272
|
+
const sinceDate = parseSince(opts.since);
|
|
273
|
+
const seen = /* @__PURE__ */ new Set();
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const ev of events) {
|
|
276
|
+
if (sinceDate && ev.ts) {
|
|
277
|
+
const evDate = new Date(ev.ts);
|
|
278
|
+
if (!Number.isNaN(evDate.getTime()) && evDate < sinceDate) continue;
|
|
279
|
+
}
|
|
280
|
+
const dedupeKey = computeDedupeKey(ev);
|
|
281
|
+
if (dedupeKey !== void 0) {
|
|
282
|
+
if (seen.has(dedupeKey)) continue;
|
|
283
|
+
seen.add(dedupeKey);
|
|
284
|
+
}
|
|
285
|
+
void opts.project;
|
|
286
|
+
out.push(ev);
|
|
287
|
+
}
|
|
288
|
+
out.sort((a, b) => a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
|
|
289
|
+
if (typeof opts.limit === "number" && opts.limit > 0) {
|
|
290
|
+
return out.slice(0, opts.limit);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
function computeDedupeKey(ev) {
|
|
295
|
+
if (ev.uuid && ev.requestId) return `${ev.uuid}|${ev.requestId}`;
|
|
296
|
+
if (ev.uuid) return ev.uuid;
|
|
297
|
+
return void 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/analyze/parser.ts
|
|
301
|
+
import { createReadStream, readFileSync, statSync } from "fs";
|
|
302
|
+
import readline from "readline";
|
|
303
|
+
import path from "path";
|
|
304
|
+
import { fileURLToPath } from "url";
|
|
305
|
+
var BUILTIN_KIND_MAP = {
|
|
306
|
+
hook_success: "hook_invocation",
|
|
307
|
+
attachment: "hook_invocation",
|
|
308
|
+
// overridden by attachment.type when present
|
|
309
|
+
tool_use: "tool_call",
|
|
310
|
+
assistant: "assistant_turn",
|
|
311
|
+
user: "user_turn"
|
|
312
|
+
};
|
|
313
|
+
var BUILTIN_SCHEMA_MAP = {
|
|
314
|
+
hook_success: {
|
|
315
|
+
action: "hook_invocation",
|
|
316
|
+
fields: ["hookName", "hookEvent", "exitCode", "durationMs", "stderr"],
|
|
317
|
+
stderrMaxBytes: 500
|
|
318
|
+
},
|
|
319
|
+
tool_use: {
|
|
320
|
+
action: "tool_call",
|
|
321
|
+
fields: ["name", "input.subagent_type"],
|
|
322
|
+
filter: { name: ["Agent", "Task"] }
|
|
323
|
+
},
|
|
324
|
+
assistant: {
|
|
325
|
+
action: "assistant_turn",
|
|
326
|
+
fields: ["attributionPlugin", "attributionSkill"]
|
|
327
|
+
},
|
|
328
|
+
user: {
|
|
329
|
+
action: "user_turn",
|
|
330
|
+
fields: ["content"],
|
|
331
|
+
extractCommandName: true
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
function loadSchemaMap(schemaPath) {
|
|
335
|
+
const candidates = [];
|
|
336
|
+
if (schemaPath) candidates.push(schemaPath);
|
|
337
|
+
try {
|
|
338
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
339
|
+
candidates.push(
|
|
340
|
+
path.resolve(here, "..", "..", "plugins", "curdx-flow", "schemas", "transcript-events.json"),
|
|
341
|
+
path.resolve(here, "..", "plugins", "curdx-flow", "schemas", "transcript-events.json")
|
|
342
|
+
);
|
|
343
|
+
} catch {
|
|
344
|
+
}
|
|
345
|
+
candidates.push(
|
|
346
|
+
path.resolve(process.cwd(), "plugins", "curdx-flow", "schemas", "transcript-events.json")
|
|
347
|
+
);
|
|
348
|
+
for (const candidate of candidates) {
|
|
349
|
+
try {
|
|
350
|
+
const raw = readFileSync(candidate, "utf8");
|
|
351
|
+
const parsed = JSON.parse(raw);
|
|
352
|
+
if (parsed && typeof parsed === "object" && parsed.events && typeof parsed.events === "object") {
|
|
353
|
+
const out = {};
|
|
354
|
+
for (const [type, def] of Object.entries(parsed.events)) {
|
|
355
|
+
if (!def || typeof def !== "object") continue;
|
|
356
|
+
const d = def;
|
|
357
|
+
if (typeof d.action !== "string") continue;
|
|
358
|
+
out[type] = {
|
|
359
|
+
action: d.action,
|
|
360
|
+
fields: Array.isArray(d.fields) ? d.fields.filter((f) => typeof f === "string") : [],
|
|
361
|
+
...d.filter && typeof d.filter === "object" ? { filter: d.filter } : {},
|
|
362
|
+
...typeof d.extractCommandName === "boolean" ? { extractCommandName: d.extractCommandName } : {},
|
|
363
|
+
...typeof d.stderrMaxBytes === "number" ? { stderrMaxBytes: d.stderrMaxBytes } : {}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (Object.keys(out).length > 0) return out;
|
|
367
|
+
}
|
|
368
|
+
process.stderr.write(`[analyze] schema map at ${candidate} malformed, trying next probe
|
|
369
|
+
`);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
const code = err.code;
|
|
372
|
+
if (code === "ENOENT") continue;
|
|
373
|
+
process.stderr.write(`[analyze] schema map probe failed at ${candidate}: ${err.message}
|
|
374
|
+
`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
process.stderr.write("[analyze] schema map not found, using builtin fallback\n");
|
|
378
|
+
return BUILTIN_SCHEMA_MAP;
|
|
379
|
+
}
|
|
380
|
+
var ACTION_TO_KIND = {
|
|
381
|
+
hook_invocation: "hook_invocation",
|
|
382
|
+
tool_call: "tool_call",
|
|
383
|
+
assistant_turn: "assistant_turn",
|
|
384
|
+
user_turn: "user_turn"
|
|
385
|
+
};
|
|
386
|
+
function classify(raw, schemaMap) {
|
|
387
|
+
const top = typeof raw.type === "string" ? raw.type : void 0;
|
|
388
|
+
if (!top) return void 0;
|
|
389
|
+
let effectiveType = top;
|
|
390
|
+
if (top === "attachment" && raw.attachment && typeof raw.attachment === "object") {
|
|
391
|
+
const att = raw.attachment;
|
|
392
|
+
if (typeof att.type === "string") effectiveType = att.type;
|
|
393
|
+
}
|
|
394
|
+
if (schemaMap && schemaMap[effectiveType]) {
|
|
395
|
+
const action = schemaMap[effectiveType]?.action;
|
|
396
|
+
if (action) {
|
|
397
|
+
const kind = ACTION_TO_KIND[action];
|
|
398
|
+
return kind ?? "unknown";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return BUILTIN_KIND_MAP[effectiveType];
|
|
402
|
+
}
|
|
403
|
+
function pickString(raw, key) {
|
|
404
|
+
const v = raw[key];
|
|
405
|
+
return typeof v === "string" ? v : void 0;
|
|
406
|
+
}
|
|
407
|
+
async function* parseTranscript(path5, startOffset, schemaMap, counters) {
|
|
408
|
+
const localCounters = counters ?? { unknown_type: 0, parse_error: 0, processed: 0 };
|
|
409
|
+
const stream = createReadStream(path5, { encoding: "utf8", start: startOffset });
|
|
410
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
411
|
+
for await (const line of rl) {
|
|
412
|
+
if (!line) continue;
|
|
413
|
+
let raw;
|
|
414
|
+
try {
|
|
415
|
+
raw = JSON.parse(line);
|
|
416
|
+
} catch {
|
|
417
|
+
localCounters.parse_error += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const kind = classify(raw, schemaMap);
|
|
421
|
+
if (!kind) {
|
|
422
|
+
localCounters.unknown_type += 1;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
localCounters.processed += 1;
|
|
426
|
+
const ts = pickString(raw, "timestamp") ?? pickString(raw, "ts") ?? "";
|
|
427
|
+
const uuid = pickString(raw, "uuid");
|
|
428
|
+
const requestId = pickString(raw, "requestId");
|
|
429
|
+
const cwd = pickString(raw, "cwd");
|
|
430
|
+
yield {
|
|
431
|
+
kind,
|
|
432
|
+
ts,
|
|
433
|
+
...uuid !== void 0 ? { uuid } : {},
|
|
434
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
435
|
+
...cwd !== void 0 ? { cwd } : {},
|
|
436
|
+
payload: raw
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function getStateForPath(path5) {
|
|
441
|
+
const st = statSync(path5);
|
|
442
|
+
return {
|
|
443
|
+
byteOffset: st.size,
|
|
444
|
+
lastModifiedMs: st.mtimeMs,
|
|
445
|
+
sizeBytes: st.size
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function shouldRotate(prev, current) {
|
|
449
|
+
if (!prev) return false;
|
|
450
|
+
if (current.sizeBytes < prev.sizeBytes) return true;
|
|
451
|
+
if (current.lastModifiedMs < prev.lastModifiedMs) return true;
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/analyze/recommend.ts
|
|
456
|
+
var REC_THRESHOLDS = {
|
|
457
|
+
cacheHitWarn: 0.6,
|
|
458
|
+
cacheHitSev: 0.3,
|
|
459
|
+
outputTokWarn: 8e3,
|
|
460
|
+
outputTokSev: 16e3,
|
|
461
|
+
hitCapWarn: 0.1,
|
|
462
|
+
hitCapSev: 0.2,
|
|
463
|
+
opusMixWarn: 0.3,
|
|
464
|
+
opusMixSev: 0.5,
|
|
465
|
+
madZ: 3.5,
|
|
466
|
+
wallClockWarn: 1.5,
|
|
467
|
+
wallClockSev: 2,
|
|
468
|
+
cacheChurnWarn: 1,
|
|
469
|
+
cacheChurnSev: 3,
|
|
470
|
+
retryWarn: 3,
|
|
471
|
+
retrySev: 5,
|
|
472
|
+
madMinN: 10
|
|
473
|
+
};
|
|
474
|
+
var CRITICAL_PHASES = [
|
|
475
|
+
"critical",
|
|
476
|
+
"debug-hard",
|
|
477
|
+
"security"
|
|
478
|
+
];
|
|
479
|
+
var MIN_N = 10;
|
|
480
|
+
var Z_THRESHOLD = 3.5;
|
|
481
|
+
var SCALE = 0.6745;
|
|
482
|
+
function median(sorted) {
|
|
483
|
+
const n = sorted.length;
|
|
484
|
+
const m = n >> 1;
|
|
485
|
+
if (n === 0) return 0;
|
|
486
|
+
return n % 2 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
|
|
487
|
+
}
|
|
488
|
+
function modifiedZScore(values) {
|
|
489
|
+
if (values.length < MIN_N) return values.map(() => 0);
|
|
490
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
491
|
+
const med = median(sorted);
|
|
492
|
+
const devs = values.map((v) => Math.abs(v - med));
|
|
493
|
+
const mad = median([...devs].sort((a, b) => a - b));
|
|
494
|
+
if (mad === 0) return values.map(() => 0);
|
|
495
|
+
return values.map((v) => SCALE * (v - med) / mad);
|
|
496
|
+
}
|
|
497
|
+
function findOutliers(values) {
|
|
498
|
+
const z = modifiedZScore(values);
|
|
499
|
+
const out = [];
|
|
500
|
+
for (let i = 0; i < z.length; i++) {
|
|
501
|
+
const zi = z[i];
|
|
502
|
+
if (zi !== void 0 && Math.abs(zi) > Z_THRESHOLD) out.push(i);
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
function bucketScope(b) {
|
|
507
|
+
if (b.level === "spec") return { spec: b.key };
|
|
508
|
+
if (b.level === "phase") {
|
|
509
|
+
const out2 = {};
|
|
510
|
+
if (b.spec !== void 0) out2.spec = b.spec;
|
|
511
|
+
out2.phase = b.phase ?? b.key;
|
|
512
|
+
return out2;
|
|
513
|
+
}
|
|
514
|
+
const out = { task: b.key };
|
|
515
|
+
if (b.spec !== void 0) out.spec = b.spec;
|
|
516
|
+
if (b.phase !== void 0) out.phase = b.phase;
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
function pct(n) {
|
|
520
|
+
return `${(n * 100).toFixed(1)}%`;
|
|
521
|
+
}
|
|
522
|
+
function isOpusModel(modelId) {
|
|
523
|
+
return modelId.toLowerCase().includes("opus");
|
|
524
|
+
}
|
|
525
|
+
function ruleCacheHitLow(buckets) {
|
|
526
|
+
const out = [];
|
|
527
|
+
for (const b of buckets) {
|
|
528
|
+
try {
|
|
529
|
+
const denom = b.cacheReadTokens + b.cacheCreate5mTokens + b.cacheCreate1hTokens + b.inputTokens;
|
|
530
|
+
if (denom <= 0) {
|
|
531
|
+
out.push({
|
|
532
|
+
rule: "cache-hit-low",
|
|
533
|
+
severity: "insufficient_data",
|
|
534
|
+
scope: bucketScope(b),
|
|
535
|
+
message: `cache-hit-low: n=0 input-side tokens; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
536
|
+
evidence: { cacheRead: 0, cacheCreate5m: 0, cacheCreate1h: 0, inputTokens: 0 }
|
|
537
|
+
});
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const hit = b.cacheReadTokens / denom;
|
|
541
|
+
let severity = null;
|
|
542
|
+
if (hit < REC_THRESHOLDS.cacheHitSev) severity = "sev";
|
|
543
|
+
else if (hit < REC_THRESHOLDS.cacheHitWarn) severity = "warn";
|
|
544
|
+
else severity = "info";
|
|
545
|
+
if (severity === "info" && hit < REC_THRESHOLDS.cacheHitWarn) continue;
|
|
546
|
+
out.push({
|
|
547
|
+
rule: "cache-hit-low",
|
|
548
|
+
severity,
|
|
549
|
+
scope: bucketScope(b),
|
|
550
|
+
message: severity === "info" ? `cache-hit-low: ${pct(hit)} \u2265 ${pct(REC_THRESHOLDS.cacheHitWarn)} (healthy); cache discipline is paying off` : `cache-hit-low: ${pct(hit)} < ${pct(severity === "sev" ? REC_THRESHOLDS.cacheHitSev : REC_THRESHOLDS.cacheHitWarn)} threshold; suggest extracting constants/prompts to system prompt to widen cache surface`,
|
|
551
|
+
evidence: {
|
|
552
|
+
cacheRead: b.cacheReadTokens,
|
|
553
|
+
cacheCreate5m: b.cacheCreate5mTokens,
|
|
554
|
+
cacheCreate1h: b.cacheCreate1hTokens,
|
|
555
|
+
inputTokens: b.inputTokens,
|
|
556
|
+
hitRate: hit
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
} catch {
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
function ruleOutputTokHigh(buckets) {
|
|
565
|
+
const out = [];
|
|
566
|
+
for (const b of buckets) {
|
|
567
|
+
if (b.level !== "task") continue;
|
|
568
|
+
try {
|
|
569
|
+
if (b.rowCount <= 0) {
|
|
570
|
+
out.push({
|
|
571
|
+
rule: "output-tok-high",
|
|
572
|
+
severity: "insufficient_data",
|
|
573
|
+
scope: bucketScope(b),
|
|
574
|
+
message: `output-tok-high: rowCount=0; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
575
|
+
evidence: { outputTokens: b.outputTokens, rowCount: 0 }
|
|
576
|
+
});
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const avg = b.outputTokens / b.rowCount;
|
|
580
|
+
let severity = null;
|
|
581
|
+
if (avg > REC_THRESHOLDS.outputTokSev) severity = "sev";
|
|
582
|
+
else if (avg > REC_THRESHOLDS.outputTokWarn) severity = "warn";
|
|
583
|
+
if (!severity) continue;
|
|
584
|
+
out.push({
|
|
585
|
+
rule: "output-tok-high",
|
|
586
|
+
severity,
|
|
587
|
+
scope: bucketScope(b),
|
|
588
|
+
message: `output-tok-high: avg ${Math.round(avg)} output tok/turn > ${severity === "sev" ? REC_THRESHOLDS.outputTokSev : REC_THRESHOLDS.outputTokWarn} threshold; suggest splitting task or capping max_tokens`,
|
|
589
|
+
evidence: {
|
|
590
|
+
avgOutputTokens: avg,
|
|
591
|
+
totalOutputTokens: b.outputTokens,
|
|
592
|
+
rowCount: b.rowCount
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
} catch {
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
function ruleHitCapRate(buckets, errorEntries) {
|
|
601
|
+
const out = [];
|
|
602
|
+
if (!errorEntries || errorEntries.length === 0) {
|
|
603
|
+
return [
|
|
604
|
+
{
|
|
605
|
+
rule: "hit-cap-rate",
|
|
606
|
+
severity: "insufficient_data",
|
|
607
|
+
scope: {},
|
|
608
|
+
message: `hit-cap-rate: errors.jsonl \u4E2D\u65E0 ratelimit_429 / \u603B\u4E8B\u4EF6\u4FE1\u53F7\uFF1B\u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
609
|
+
evidence: { errorEntries: 0 }
|
|
610
|
+
}
|
|
611
|
+
];
|
|
612
|
+
}
|
|
613
|
+
for (const b of buckets) {
|
|
614
|
+
if (b.level !== "spec") continue;
|
|
615
|
+
try {
|
|
616
|
+
const sid = b.key;
|
|
617
|
+
const scoped = errorEntries.filter((e) => {
|
|
618
|
+
const cid = e.correlationId;
|
|
619
|
+
return typeof cid === "string" && cid.startsWith(`${sid}:`);
|
|
620
|
+
});
|
|
621
|
+
if (scoped.length === 0) continue;
|
|
622
|
+
const cap = scoped.filter((e) => e.kind === "ratelimit_429").length;
|
|
623
|
+
const ratio = cap / scoped.length;
|
|
624
|
+
let severity = null;
|
|
625
|
+
if (ratio > REC_THRESHOLDS.hitCapSev) severity = "sev";
|
|
626
|
+
else if (ratio > REC_THRESHOLDS.hitCapWarn) severity = "warn";
|
|
627
|
+
if (!severity) continue;
|
|
628
|
+
out.push({
|
|
629
|
+
rule: "hit-cap-rate",
|
|
630
|
+
severity,
|
|
631
|
+
scope: bucketScope(b),
|
|
632
|
+
message: `hit-cap-rate: ${pct(ratio)} of events were 429 rate-limit hits > ${pct(
|
|
633
|
+
severity === "sev" ? REC_THRESHOLDS.hitCapSev : REC_THRESHOLDS.hitCapWarn
|
|
634
|
+
)} threshold; suggest backing off concurrency or upgrading tier`,
|
|
635
|
+
evidence: { rateLimitedCount: cap, totalEvents: scoped.length, ratio }
|
|
636
|
+
});
|
|
637
|
+
} catch {
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
function ruleOpusMixHigh(buckets, ctx) {
|
|
643
|
+
const out = [];
|
|
644
|
+
const skipList = ctx.criticalPhases ?? CRITICAL_PHASES;
|
|
645
|
+
for (const b of buckets) {
|
|
646
|
+
if (b.level !== "phase") continue;
|
|
647
|
+
try {
|
|
648
|
+
const phase = b.phase ?? ctx.phase;
|
|
649
|
+
if (phase === void 0) {
|
|
650
|
+
out.push({
|
|
651
|
+
rule: "opus-mix-high",
|
|
652
|
+
severity: "insufficient_data",
|
|
653
|
+
scope: bucketScope(b),
|
|
654
|
+
message: `opus-mix-high: phase tag \u7F3A\u5931; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
655
|
+
evidence: { reason: "no-phase-tag" }
|
|
656
|
+
});
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (skipList.includes(phase)) continue;
|
|
660
|
+
const mix = b.modelMix;
|
|
661
|
+
let totalTok = 0;
|
|
662
|
+
let opusTok = 0;
|
|
663
|
+
for (const [model, m] of Object.entries(mix)) {
|
|
664
|
+
totalTok += m.tokens;
|
|
665
|
+
if (isOpusModel(model)) opusTok += m.tokens;
|
|
666
|
+
}
|
|
667
|
+
if (totalTok <= 0) {
|
|
668
|
+
out.push({
|
|
669
|
+
rule: "opus-mix-high",
|
|
670
|
+
severity: "insufficient_data",
|
|
671
|
+
scope: bucketScope(b),
|
|
672
|
+
message: `opus-mix-high: modelMix tokens=0; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
673
|
+
evidence: { totalTokens: 0 }
|
|
674
|
+
});
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const share = opusTok / totalTok;
|
|
678
|
+
let severity;
|
|
679
|
+
if (share > REC_THRESHOLDS.opusMixSev) severity = "sev";
|
|
680
|
+
else if (share > REC_THRESHOLDS.opusMixWarn) severity = "warn";
|
|
681
|
+
else severity = "info";
|
|
682
|
+
out.push({
|
|
683
|
+
rule: "opus-mix-high",
|
|
684
|
+
severity,
|
|
685
|
+
scope: bucketScope(b),
|
|
686
|
+
message: severity === "info" ? `opus-mix-high: Opus share ${pct(share)} \u2264 ${pct(REC_THRESHOLDS.opusMixWarn)} (healthy mix)` : `opus-mix-high: Opus share ${pct(share)} > ${pct(
|
|
687
|
+
severity === "sev" ? REC_THRESHOLDS.opusMixSev : REC_THRESHOLDS.opusMixWarn
|
|
688
|
+
)} threshold; suggest routing routine work to Sonnet/Haiku`,
|
|
689
|
+
evidence: { opusTokens: opusTok, totalTokens: totalTok, share, phase }
|
|
690
|
+
});
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return out;
|
|
695
|
+
}
|
|
696
|
+
function ruleCostPerTaskSpike(buckets) {
|
|
697
|
+
const out = [];
|
|
698
|
+
try {
|
|
699
|
+
const taskBuckets = buckets.filter((b) => b.level === "task");
|
|
700
|
+
if (taskBuckets.length < REC_THRESHOLDS.madMinN) {
|
|
701
|
+
return [
|
|
702
|
+
{
|
|
703
|
+
rule: "cost-per-task-spike",
|
|
704
|
+
severity: "insufficient_data",
|
|
705
|
+
scope: {},
|
|
706
|
+
message: `cost-per-task-spike: n=${taskBuckets.length} < ${REC_THRESHOLDS.madMinN}; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD (MAD \u9700 \u2265 ${REC_THRESHOLDS.madMinN} \u4E2A task bucket)`,
|
|
707
|
+
evidence: { n: taskBuckets.length, madMinN: REC_THRESHOLDS.madMinN }
|
|
708
|
+
}
|
|
709
|
+
];
|
|
710
|
+
}
|
|
711
|
+
const values = taskBuckets.map((b) => b.totalUSD);
|
|
712
|
+
const z = modifiedZScore(values);
|
|
713
|
+
const allZero = z.every((v) => v === 0);
|
|
714
|
+
if (allZero) {
|
|
715
|
+
return [
|
|
716
|
+
{
|
|
717
|
+
rule: "cost-per-task-spike",
|
|
718
|
+
severity: "insufficient_data",
|
|
719
|
+
scope: {},
|
|
720
|
+
message: `cost-per-task-spike: MAD=0 (\u226550% identical task costs); \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
721
|
+
evidence: { n: values.length, mad: 0 }
|
|
722
|
+
}
|
|
723
|
+
];
|
|
724
|
+
}
|
|
725
|
+
const outlierIndices = findOutliers(values);
|
|
726
|
+
for (const idx of outlierIndices) {
|
|
727
|
+
const b = taskBuckets[idx];
|
|
728
|
+
const zi = z[idx];
|
|
729
|
+
if (!b || zi === void 0) continue;
|
|
730
|
+
out.push({
|
|
731
|
+
rule: "cost-per-task-spike",
|
|
732
|
+
severity: "sev",
|
|
733
|
+
scope: bucketScope(b),
|
|
734
|
+
message: `cost-per-task-spike: $${b.totalUSD.toFixed(4)} USD with |z|=${Math.abs(zi).toFixed(2)} > ${REC_THRESHOLDS.madZ} (MAD outlier); inspect task for runaway loop or oversized prompt`,
|
|
735
|
+
evidence: { totalUSD: b.totalUSD, modifiedZ: zi, n: values.length }
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
} catch {
|
|
739
|
+
}
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
function ruleWallClockP95(buckets) {
|
|
743
|
+
const out = [];
|
|
744
|
+
try {
|
|
745
|
+
const taskBuckets = buckets.filter((b) => b.level === "task" && b.rowCount > 0);
|
|
746
|
+
if (taskBuckets.length < 5) {
|
|
747
|
+
return [
|
|
748
|
+
{
|
|
749
|
+
rule: "wall-clock-p95",
|
|
750
|
+
severity: "insufficient_data",
|
|
751
|
+
scope: {},
|
|
752
|
+
message: `wall-clock-p95: n=${taskBuckets.length} < 5 task buckets; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
753
|
+
evidence: { n: taskBuckets.length }
|
|
754
|
+
}
|
|
755
|
+
];
|
|
756
|
+
}
|
|
757
|
+
const perRowDur = taskBuckets.map((b) => b.durationMs / Math.max(b.rowCount, 1));
|
|
758
|
+
const sorted = [...perRowDur].sort((a, b) => a - b);
|
|
759
|
+
const baseline = median(sorted);
|
|
760
|
+
if (baseline <= 0) {
|
|
761
|
+
return [
|
|
762
|
+
{
|
|
763
|
+
rule: "wall-clock-p95",
|
|
764
|
+
severity: "insufficient_data",
|
|
765
|
+
scope: {},
|
|
766
|
+
message: `wall-clock-p95: baseline duration=0; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
767
|
+
evidence: { n: taskBuckets.length, baseline: 0 }
|
|
768
|
+
}
|
|
769
|
+
];
|
|
770
|
+
}
|
|
771
|
+
const warnLine = REC_THRESHOLDS.wallClockWarn * baseline;
|
|
772
|
+
const sevLine = REC_THRESHOLDS.wallClockSev * baseline;
|
|
773
|
+
for (let i = 0; i < taskBuckets.length; i++) {
|
|
774
|
+
const b = taskBuckets[i];
|
|
775
|
+
const dur = perRowDur[i];
|
|
776
|
+
if (!b || dur === void 0) continue;
|
|
777
|
+
let severity = null;
|
|
778
|
+
if (dur > sevLine) severity = "sev";
|
|
779
|
+
else if (dur > warnLine) severity = "warn";
|
|
780
|
+
if (!severity) continue;
|
|
781
|
+
out.push({
|
|
782
|
+
rule: "wall-clock-p95",
|
|
783
|
+
severity,
|
|
784
|
+
scope: bucketScope(b),
|
|
785
|
+
message: `wall-clock-p95: ${Math.round(dur)}ms/turn > ${severity === "sev" ? REC_THRESHOLDS.wallClockSev : REC_THRESHOLDS.wallClockWarn}\xD7 baseline (${Math.round(baseline)}ms); inspect for tool-call thrash or slow MCP server`,
|
|
786
|
+
evidence: {
|
|
787
|
+
durationPerRowMs: dur,
|
|
788
|
+
baselineMs: baseline,
|
|
789
|
+
ratio: dur / baseline,
|
|
790
|
+
rowCount: b.rowCount
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
}
|
|
796
|
+
return out;
|
|
797
|
+
}
|
|
798
|
+
function ruleCacheChurn(buckets) {
|
|
799
|
+
const out = [];
|
|
800
|
+
for (const b of buckets) {
|
|
801
|
+
if (b.level !== "spec" && b.level !== "phase") continue;
|
|
802
|
+
try {
|
|
803
|
+
if (b.cacheReadTokens <= 0) continue;
|
|
804
|
+
const writeTotal = b.cacheCreate5mTokens + b.cacheCreate1hTokens;
|
|
805
|
+
if (writeTotal <= 0) {
|
|
806
|
+
out.push({
|
|
807
|
+
rule: "cache-churn",
|
|
808
|
+
severity: "info",
|
|
809
|
+
scope: bucketScope(b),
|
|
810
|
+
message: `cache-churn: zero new cache writes against ${b.cacheReadTokens} cached reads (pure reuse, healthy)`,
|
|
811
|
+
evidence: { cacheRead: b.cacheReadTokens, cacheCreate5m: 0, cacheCreate1h: 0, ratio: 0 }
|
|
812
|
+
});
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
const ratio = writeTotal / b.cacheReadTokens;
|
|
816
|
+
let severity = null;
|
|
817
|
+
if (ratio > REC_THRESHOLDS.cacheChurnSev) severity = "sev";
|
|
818
|
+
else if (ratio > REC_THRESHOLDS.cacheChurnWarn) severity = "warn";
|
|
819
|
+
else severity = "info";
|
|
820
|
+
if (severity === "info") {
|
|
821
|
+
if (ratio > REC_THRESHOLDS.cacheChurnWarn) continue;
|
|
822
|
+
}
|
|
823
|
+
out.push({
|
|
824
|
+
rule: "cache-churn",
|
|
825
|
+
severity,
|
|
826
|
+
scope: bucketScope(b),
|
|
827
|
+
message: severity === "info" ? `cache-churn: write/read ratio ${ratio.toFixed(2)} \u2264 ${REC_THRESHOLDS.cacheChurnWarn} (healthy reuse)` : `cache-churn: write/read ratio ${ratio.toFixed(2)} > ${severity === "sev" ? REC_THRESHOLDS.cacheChurnSev : REC_THRESHOLDS.cacheChurnWarn} threshold; cache is being rebuilt faster than reused \u2014 check for prompt instability or session churn`,
|
|
828
|
+
evidence: {
|
|
829
|
+
cacheRead: b.cacheReadTokens,
|
|
830
|
+
cacheCreate5m: b.cacheCreate5mTokens,
|
|
831
|
+
cacheCreate1h: b.cacheCreate1hTokens,
|
|
832
|
+
ratio
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
} catch {
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
function ruleRetryLoop(buckets, errorEntries) {
|
|
841
|
+
const out = [];
|
|
842
|
+
if (!errorEntries || errorEntries.length === 0) {
|
|
843
|
+
return [
|
|
844
|
+
{
|
|
845
|
+
rule: "retry-loop",
|
|
846
|
+
severity: "insufficient_data",
|
|
847
|
+
scope: {},
|
|
848
|
+
message: `retry-loop: errors.jsonl \u65E0 retry \u8BB0\u5F55; \u6570\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD`,
|
|
849
|
+
evidence: { errorEntries: 0 }
|
|
850
|
+
}
|
|
851
|
+
];
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
const retryByCorrId = /* @__PURE__ */ new Map();
|
|
855
|
+
for (const e of errorEntries) {
|
|
856
|
+
if (e.kind !== "retry") continue;
|
|
857
|
+
const cid = e.correlationId;
|
|
858
|
+
if (typeof cid !== "string" || cid.length === 0) continue;
|
|
859
|
+
retryByCorrId.set(cid, (retryByCorrId.get(cid) ?? 0) + 1);
|
|
860
|
+
}
|
|
861
|
+
if (retryByCorrId.size === 0) {
|
|
862
|
+
return [];
|
|
863
|
+
}
|
|
864
|
+
const taskBuckets = buckets.filter((b) => b.level === "task");
|
|
865
|
+
for (const b of taskBuckets) {
|
|
866
|
+
const count = retryByCorrId.get(b.key) ?? 0;
|
|
867
|
+
let severity = null;
|
|
868
|
+
if (count >= REC_THRESHOLDS.retrySev) severity = "sev";
|
|
869
|
+
else if (count >= REC_THRESHOLDS.retryWarn) severity = "warn";
|
|
870
|
+
if (!severity) continue;
|
|
871
|
+
out.push({
|
|
872
|
+
rule: "retry-loop",
|
|
873
|
+
severity,
|
|
874
|
+
scope: bucketScope(b),
|
|
875
|
+
message: `retry-loop: ${count} retries on this task \u2265 ${severity === "sev" ? REC_THRESHOLDS.retrySev : REC_THRESHOLDS.retryWarn} threshold; inspect for stuck loop / failing precondition`,
|
|
876
|
+
evidence: { retries: count, correlationId: b.key }
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
} catch {
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
function recommend(buckets, ctx = {}) {
|
|
884
|
+
const out = [];
|
|
885
|
+
const safe = (fn) => {
|
|
886
|
+
try {
|
|
887
|
+
return fn();
|
|
888
|
+
} catch {
|
|
889
|
+
return [];
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
out.push(...safe(() => ruleCacheHitLow(buckets)));
|
|
893
|
+
out.push(...safe(() => ruleOutputTokHigh(buckets)));
|
|
894
|
+
out.push(...safe(() => ruleHitCapRate(buckets, ctx.errorEntries)));
|
|
895
|
+
out.push(...safe(() => ruleOpusMixHigh(buckets, ctx)));
|
|
896
|
+
out.push(...safe(() => ruleCostPerTaskSpike(buckets)));
|
|
897
|
+
out.push(...safe(() => ruleWallClockP95(buckets)));
|
|
898
|
+
out.push(...safe(() => ruleCacheChurn(buckets)));
|
|
899
|
+
out.push(...safe(() => ruleRetryLoop(buckets, ctx.errorEntries)));
|
|
900
|
+
return out;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/analyze/redact.ts
|
|
904
|
+
import { createHash } from "crypto";
|
|
905
|
+
import path2 from "path";
|
|
906
|
+
var PATH_FIELDS = /* @__PURE__ */ new Set(["cwd", "path", "file", "transcript_path", "projectPath"]);
|
|
907
|
+
var COMMAND_NAME_RE = /<command-name>([^<]+)<\/command-name>/g;
|
|
908
|
+
function hashProject(absPath) {
|
|
909
|
+
return createHash("sha256").update(absPath).digest("hex").slice(0, 8);
|
|
910
|
+
}
|
|
911
|
+
function redactPath(p) {
|
|
912
|
+
if (!p) return p;
|
|
913
|
+
const base = path2.basename(p) || p;
|
|
914
|
+
return `${base}@${hashProject(p)}`;
|
|
915
|
+
}
|
|
916
|
+
function extractCommandHints(text) {
|
|
917
|
+
const hints = [];
|
|
918
|
+
let m;
|
|
919
|
+
COMMAND_NAME_RE.lastIndex = 0;
|
|
920
|
+
while (m = COMMAND_NAME_RE.exec(text)) {
|
|
921
|
+
if (m[1]) hints.push(m[1].trim());
|
|
922
|
+
}
|
|
923
|
+
return hints;
|
|
924
|
+
}
|
|
925
|
+
function redactEvent(ev, opts = {}) {
|
|
926
|
+
const topType = ev.payload.type;
|
|
927
|
+
if (topType === "file-history-snapshot") return null;
|
|
928
|
+
const att = ev.payload.attachment;
|
|
929
|
+
if (att && typeof att === "object" && att.type === "file-history-snapshot") {
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
if (opts.includePrompts) return ev;
|
|
933
|
+
const next = { ...ev, payload: { ...ev.payload } };
|
|
934
|
+
if (typeof next.cwd === "string") next.cwd = redactPath(next.cwd);
|
|
935
|
+
for (const key of PATH_FIELDS) {
|
|
936
|
+
const v = next.payload[key];
|
|
937
|
+
if (typeof v === "string") next.payload[key] = redactPath(v);
|
|
938
|
+
}
|
|
939
|
+
if (next.kind === "user_turn") {
|
|
940
|
+
let totalLength = 0;
|
|
941
|
+
const hints = [];
|
|
942
|
+
const message = next.payload.message;
|
|
943
|
+
if (message && typeof message === "object") {
|
|
944
|
+
const content = message.content;
|
|
945
|
+
if (typeof content === "string") {
|
|
946
|
+
totalLength += Buffer.byteLength(content, "utf8");
|
|
947
|
+
hints.push(...extractCommandHints(content));
|
|
948
|
+
} else if (Array.isArray(content)) {
|
|
949
|
+
for (const part of content) {
|
|
950
|
+
if (part && typeof part === "object") {
|
|
951
|
+
const t = part.text;
|
|
952
|
+
if (typeof t === "string") {
|
|
953
|
+
totalLength += Buffer.byteLength(t, "utf8");
|
|
954
|
+
hints.push(...extractCommandHints(t));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const synthesized = hints.map((h) => ({ type: "text", text: `<command-name>${h}</command-name>` }));
|
|
960
|
+
next.payload.message = { ...message, content: synthesized };
|
|
961
|
+
}
|
|
962
|
+
if (typeof next.payload.content === "string") {
|
|
963
|
+
totalLength += Buffer.byteLength(next.payload.content, "utf8");
|
|
964
|
+
hints.push(...extractCommandHints(next.payload.content));
|
|
965
|
+
delete next.payload.content;
|
|
966
|
+
}
|
|
967
|
+
next.payload.redacted = {
|
|
968
|
+
length: totalLength,
|
|
969
|
+
commandHints: Array.from(new Set(hints))
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
return next;
|
|
973
|
+
}
|
|
974
|
+
function redactReportFields(report, opts = {}) {
|
|
975
|
+
if (opts.includePrompts) return report;
|
|
976
|
+
return walk(report);
|
|
977
|
+
}
|
|
978
|
+
function walk(value) {
|
|
979
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
980
|
+
if (value && typeof value === "object") {
|
|
981
|
+
const out = {};
|
|
982
|
+
for (const [k, v] of Object.entries(value)) {
|
|
983
|
+
if (typeof v === "string" && PATH_FIELDS.has(k)) {
|
|
984
|
+
out[k] = redactPath(v);
|
|
985
|
+
} else {
|
|
986
|
+
out[k] = walk(v);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return out;
|
|
990
|
+
}
|
|
991
|
+
return value;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// src/analyze/report.ts
|
|
995
|
+
var DEFAULT_LIMIT = 10;
|
|
996
|
+
var STDERR_TRUNC = 200;
|
|
997
|
+
var FUZZY_TS_WINDOW_MS = 2e3;
|
|
998
|
+
var MIN_SAMPLES_FOR_PCT = 5;
|
|
999
|
+
var COMMAND_NAME_RE2 = /<command-name>([^<]+)<\/command-name>/;
|
|
1000
|
+
function escapeCell(s) {
|
|
1001
|
+
return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
1002
|
+
}
|
|
1003
|
+
function truncate(s, max = STDERR_TRUNC) {
|
|
1004
|
+
if (!s) return "";
|
|
1005
|
+
return s.length <= max ? s : s.slice(0, max);
|
|
1006
|
+
}
|
|
1007
|
+
function rollupHookFailures(events, errors, limit) {
|
|
1008
|
+
const buckets = [];
|
|
1009
|
+
for (const ev of events) {
|
|
1010
|
+
if (ev.kind !== "hook_invocation") continue;
|
|
1011
|
+
const att = ev.payload.attachment;
|
|
1012
|
+
if (!att || typeof att !== "object") continue;
|
|
1013
|
+
const a = att;
|
|
1014
|
+
if (a.type !== "hook_success") continue;
|
|
1015
|
+
const hookName = typeof a.hookName === "string" ? a.hookName : void 0;
|
|
1016
|
+
const exitCode = typeof a.exitCode === "number" ? a.exitCode : void 0;
|
|
1017
|
+
if (!hookName || exitCode === void 0 || exitCode === 0) continue;
|
|
1018
|
+
const stderr = truncate(typeof a.stderr === "string" ? a.stderr : "");
|
|
1019
|
+
const tsMs = ev.ts ? Date.parse(ev.ts) : NaN;
|
|
1020
|
+
buckets.push({
|
|
1021
|
+
hook: hookName,
|
|
1022
|
+
ts: Number.isFinite(tsMs) ? tsMs : 0,
|
|
1023
|
+
...ev.cwd ? { cwd: ev.cwd } : {},
|
|
1024
|
+
stderr,
|
|
1025
|
+
source: "jsonl"
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
for (const e of errors) {
|
|
1029
|
+
if (!e.hook) continue;
|
|
1030
|
+
const tsMs = e.ts ? Date.parse(e.ts) : NaN;
|
|
1031
|
+
const cwd = e.cwd;
|
|
1032
|
+
const tsResolved = Number.isFinite(tsMs) ? tsMs : 0;
|
|
1033
|
+
const dup = buckets.find(
|
|
1034
|
+
(b) => b.hook === e.hook && Math.abs(b.ts - tsResolved) <= FUZZY_TS_WINDOW_MS && (b.cwd ?? "") === (cwd ?? "")
|
|
1035
|
+
);
|
|
1036
|
+
if (dup) {
|
|
1037
|
+
dup.source = "merged";
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
buckets.push({
|
|
1041
|
+
hook: e.hook,
|
|
1042
|
+
ts: tsResolved,
|
|
1043
|
+
...cwd ? { cwd } : {},
|
|
1044
|
+
stderr: truncate(e.msg ?? ""),
|
|
1045
|
+
source: "errors.jsonl"
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1049
|
+
for (const b of buckets) {
|
|
1050
|
+
const prev = counts.get(b.hook);
|
|
1051
|
+
if (prev) {
|
|
1052
|
+
prev.count += 1;
|
|
1053
|
+
if (b.stderr) prev.lastStderr = b.stderr;
|
|
1054
|
+
if (prev.source !== b.source) prev.source = "merged";
|
|
1055
|
+
} else {
|
|
1056
|
+
counts.set(b.hook, { count: 1, lastStderr: b.stderr, source: b.source });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const rows = Array.from(counts.entries()).map(([hook, v]) => ({ hook, count: v.count, lastStderr: v.lastStderr, source: v.source })).sort((a, b) => b.count - a.count);
|
|
1060
|
+
return rows.slice(0, limit);
|
|
1061
|
+
}
|
|
1062
|
+
function renderHookFailures(rows, limit) {
|
|
1063
|
+
const lines = [];
|
|
1064
|
+
lines.push(`## Hook Failures Top-${limit}`);
|
|
1065
|
+
lines.push("");
|
|
1066
|
+
if (rows.length === 0) {
|
|
1067
|
+
lines.push("_No hook failures recorded._");
|
|
1068
|
+
lines.push("");
|
|
1069
|
+
return lines.join("\n");
|
|
1070
|
+
}
|
|
1071
|
+
lines.push("| Hook | Count | Last stderr | Source |");
|
|
1072
|
+
lines.push("| --- | --- | --- | --- |");
|
|
1073
|
+
for (const r of rows) {
|
|
1074
|
+
lines.push(`| ${escapeCell(r.hook)} | ${r.count} | ${escapeCell(r.lastStderr)} | ${r.source} |`);
|
|
1075
|
+
}
|
|
1076
|
+
lines.push("");
|
|
1077
|
+
return lines.join("\n");
|
|
1078
|
+
}
|
|
1079
|
+
function rollupSlashCommands(events, limit) {
|
|
1080
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1081
|
+
for (const ev of events) {
|
|
1082
|
+
if (ev.kind === "assistant_turn") {
|
|
1083
|
+
const skill = ev.payload.attributionSkill;
|
|
1084
|
+
if (typeof skill === "string" && skill.length > 0) {
|
|
1085
|
+
counts.set(skill, (counts.get(skill) ?? 0) + 1);
|
|
1086
|
+
}
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (ev.kind === "user_turn") {
|
|
1090
|
+
const message = ev.payload.message;
|
|
1091
|
+
if (!message || typeof message !== "object") continue;
|
|
1092
|
+
const content = message.content;
|
|
1093
|
+
const texts = [];
|
|
1094
|
+
if (Array.isArray(content)) {
|
|
1095
|
+
for (const part of content) {
|
|
1096
|
+
if (part && typeof part === "object") {
|
|
1097
|
+
const t = part.text;
|
|
1098
|
+
if (typeof t === "string") texts.push(t);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
} else if (typeof content === "string") {
|
|
1102
|
+
texts.push(content);
|
|
1103
|
+
}
|
|
1104
|
+
for (const t of texts) {
|
|
1105
|
+
const m = COMMAND_NAME_RE2.exec(t);
|
|
1106
|
+
if (m && m[1]) {
|
|
1107
|
+
const cmd = m[1].trim();
|
|
1108
|
+
if (cmd) counts.set(cmd, (counts.get(cmd) ?? 0) + 1);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const rows = Array.from(counts.entries()).map(([command, count]) => ({ command, count })).sort((a, b) => b.count - a.count);
|
|
1114
|
+
return rows.slice(0, limit);
|
|
1115
|
+
}
|
|
1116
|
+
function renderSlashCommands(rows, limit) {
|
|
1117
|
+
const lines = [];
|
|
1118
|
+
lines.push(`## Slash Commands Top-${limit}`);
|
|
1119
|
+
lines.push("");
|
|
1120
|
+
if (rows.length === 0) {
|
|
1121
|
+
lines.push("_No slash command activity recorded._");
|
|
1122
|
+
lines.push("");
|
|
1123
|
+
return lines.join("\n");
|
|
1124
|
+
}
|
|
1125
|
+
lines.push("| Command | Count |");
|
|
1126
|
+
lines.push("| --- | --- |");
|
|
1127
|
+
for (const r of rows) {
|
|
1128
|
+
lines.push(`| ${escapeCell(r.command)} | ${r.count} |`);
|
|
1129
|
+
}
|
|
1130
|
+
lines.push("");
|
|
1131
|
+
return lines.join("\n");
|
|
1132
|
+
}
|
|
1133
|
+
function rollupSubagents(events, limit) {
|
|
1134
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1135
|
+
for (const ev of events) {
|
|
1136
|
+
if (ev.kind !== "tool_call" && ev.kind !== "assistant_turn") continue;
|
|
1137
|
+
const message = ev.payload.message;
|
|
1138
|
+
if (message && typeof message === "object") {
|
|
1139
|
+
const content = message.content;
|
|
1140
|
+
if (Array.isArray(content)) {
|
|
1141
|
+
for (const part of content) {
|
|
1142
|
+
if (!part || typeof part !== "object") continue;
|
|
1143
|
+
const p = part;
|
|
1144
|
+
if (p.type !== "tool_use") continue;
|
|
1145
|
+
if (p.name !== "Agent" && p.name !== "Task") continue;
|
|
1146
|
+
const input = p.input;
|
|
1147
|
+
const sub = input && typeof input.subagent_type === "string" ? input.subagent_type : void 0;
|
|
1148
|
+
if (sub) counts.set(sub, (counts.get(sub) ?? 0) + 1);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const direct = ev.payload;
|
|
1153
|
+
if (direct.name === "Agent" || direct.name === "Task") {
|
|
1154
|
+
const input = direct.input;
|
|
1155
|
+
const sub = input && typeof input.subagent_type === "string" ? input.subagent_type : void 0;
|
|
1156
|
+
if (sub) counts.set(sub, (counts.get(sub) ?? 0) + 1);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const rows = Array.from(counts.entries()).map(([subagent, count]) => ({ subagent, count })).sort((a, b) => b.count - a.count);
|
|
1160
|
+
return rows.slice(0, limit);
|
|
1161
|
+
}
|
|
1162
|
+
function renderSubagents(rows, limit) {
|
|
1163
|
+
const lines = [];
|
|
1164
|
+
lines.push(`## Subagents Top-${limit}`);
|
|
1165
|
+
lines.push("");
|
|
1166
|
+
if (rows.length === 0) {
|
|
1167
|
+
lines.push("_No subagent dispatches recorded._");
|
|
1168
|
+
lines.push("");
|
|
1169
|
+
return lines.join("\n");
|
|
1170
|
+
}
|
|
1171
|
+
lines.push("| Subagent | Count |");
|
|
1172
|
+
lines.push("| --- | --- |");
|
|
1173
|
+
for (const r of rows) {
|
|
1174
|
+
lines.push(`| ${escapeCell(r.subagent)} | ${r.count} |`);
|
|
1175
|
+
}
|
|
1176
|
+
lines.push("");
|
|
1177
|
+
return lines.join("\n");
|
|
1178
|
+
}
|
|
1179
|
+
var PHASE_ORDER = ["research", "requirements", "design", "tasks", "execution", "done"];
|
|
1180
|
+
function rollupSpecFunnel(specStates) {
|
|
1181
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1182
|
+
for (const s of specStates) {
|
|
1183
|
+
counts.set(s.phase, (counts.get(s.phase) ?? 0) + 1);
|
|
1184
|
+
}
|
|
1185
|
+
const rows = [];
|
|
1186
|
+
for (const p of PHASE_ORDER) {
|
|
1187
|
+
rows.push({ phase: p, count: counts.get(p) ?? 0 });
|
|
1188
|
+
counts.delete(p);
|
|
1189
|
+
}
|
|
1190
|
+
for (const [phase, count] of Array.from(counts.entries()).sort()) {
|
|
1191
|
+
rows.push({ phase, count });
|
|
1192
|
+
}
|
|
1193
|
+
return rows;
|
|
1194
|
+
}
|
|
1195
|
+
function renderSpecFunnel(rows) {
|
|
1196
|
+
const lines = [];
|
|
1197
|
+
lines.push("## Spec Funnel");
|
|
1198
|
+
lines.push("");
|
|
1199
|
+
lines.push("| Phase | Count |");
|
|
1200
|
+
lines.push("| --- | --- |");
|
|
1201
|
+
for (const r of rows) {
|
|
1202
|
+
lines.push(`| ${escapeCell(r.phase)} | ${r.count} |`);
|
|
1203
|
+
}
|
|
1204
|
+
lines.push("");
|
|
1205
|
+
return lines.join("\n");
|
|
1206
|
+
}
|
|
1207
|
+
function percentile(sortedAsc, p) {
|
|
1208
|
+
if (sortedAsc.length === 0) return null;
|
|
1209
|
+
const n = sortedAsc.length;
|
|
1210
|
+
const idx = Math.min(Math.max(Math.ceil(p * n) - 1, 0), n - 1);
|
|
1211
|
+
return sortedAsc[idx] ?? null;
|
|
1212
|
+
}
|
|
1213
|
+
function rollupHookDuration(events) {
|
|
1214
|
+
const samples = /* @__PURE__ */ new Map();
|
|
1215
|
+
for (const ev of events) {
|
|
1216
|
+
if (ev.kind !== "hook_invocation") continue;
|
|
1217
|
+
const att = ev.payload.attachment;
|
|
1218
|
+
if (!att || typeof att !== "object") continue;
|
|
1219
|
+
const a = att;
|
|
1220
|
+
if (a.type !== "hook_success") continue;
|
|
1221
|
+
const hookName = typeof a.hookName === "string" ? a.hookName : void 0;
|
|
1222
|
+
const dur = typeof a.durationMs === "number" ? a.durationMs : void 0;
|
|
1223
|
+
if (!hookName || dur === void 0) continue;
|
|
1224
|
+
if (!samples.has(hookName)) samples.set(hookName, []);
|
|
1225
|
+
samples.get(hookName).push(dur);
|
|
1226
|
+
}
|
|
1227
|
+
const rows = [];
|
|
1228
|
+
for (const [hook, arr] of samples.entries()) {
|
|
1229
|
+
arr.sort((a, b) => a - b);
|
|
1230
|
+
rows.push({
|
|
1231
|
+
hook,
|
|
1232
|
+
samples: arr.length,
|
|
1233
|
+
p50: percentile(arr, 0.5),
|
|
1234
|
+
p95: percentile(arr, 0.95),
|
|
1235
|
+
p99: percentile(arr, 0.99)
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
rows.sort((a, b) => b.samples - a.samples);
|
|
1239
|
+
return rows;
|
|
1240
|
+
}
|
|
1241
|
+
function renderHookDuration(rows) {
|
|
1242
|
+
const lines = [];
|
|
1243
|
+
lines.push("## Hook Duration");
|
|
1244
|
+
lines.push("");
|
|
1245
|
+
if (rows.length === 0) {
|
|
1246
|
+
lines.push("_No hook duration samples recorded._");
|
|
1247
|
+
lines.push("");
|
|
1248
|
+
return lines.join("\n");
|
|
1249
|
+
}
|
|
1250
|
+
lines.push("| Hook | Samples | P50 (ms) | P95 (ms) | P99 (ms) |");
|
|
1251
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
1252
|
+
for (const r of rows) {
|
|
1253
|
+
if (r.samples < MIN_SAMPLES_FOR_PCT) {
|
|
1254
|
+
lines.push(`| ${escapeCell(r.hook)} | ${r.samples} | (\u6837\u672C\u4E0D\u8DB3: ${r.samples}) | (\u6837\u672C\u4E0D\u8DB3: ${r.samples}) | (\u6837\u672C\u4E0D\u8DB3: ${r.samples}) |`);
|
|
1255
|
+
} else {
|
|
1256
|
+
lines.push(`| ${escapeCell(r.hook)} | ${r.samples} | ${r.p50 ?? "-"} | ${r.p95 ?? "-"} | ${r.p99 ?? "-"} |`);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
lines.push("");
|
|
1260
|
+
return lines.join("\n");
|
|
1261
|
+
}
|
|
1262
|
+
function renderSchemaDrift(input) {
|
|
1263
|
+
const lines = [];
|
|
1264
|
+
lines.push("## Schema Drift");
|
|
1265
|
+
lines.push("");
|
|
1266
|
+
lines.push("| Metric | Value |");
|
|
1267
|
+
lines.push("| --- | --- |");
|
|
1268
|
+
lines.push(`| unknown_type_count | ${input.unknownTypeCount} |`);
|
|
1269
|
+
lines.push(`| parse_error_count | ${input.parseErrorCount} |`);
|
|
1270
|
+
lines.push("");
|
|
1271
|
+
if (input.unknownTypeCount > 0 || input.parseErrorCount > 0) {
|
|
1272
|
+
lines.push("_Hint: \u5982\u957F\u671F > 0 \u8868\u660E Claude Code \u5347\u7EA7\u4E86 schema\uFF0C\u8BF7\u68C0\u67E5 plugins/curdx-flow/schemas/transcript-events.json_");
|
|
1273
|
+
} else {
|
|
1274
|
+
lines.push("_no drift detected_");
|
|
1275
|
+
}
|
|
1276
|
+
lines.push("");
|
|
1277
|
+
return lines.join("\n");
|
|
1278
|
+
}
|
|
1279
|
+
function rollupParentChain(events) {
|
|
1280
|
+
const uuids = /* @__PURE__ */ new Set();
|
|
1281
|
+
for (const ev of events) {
|
|
1282
|
+
if (ev.uuid) uuids.add(ev.uuid);
|
|
1283
|
+
}
|
|
1284
|
+
let withParent = 0;
|
|
1285
|
+
let broken = 0;
|
|
1286
|
+
for (const ev of events) {
|
|
1287
|
+
const p = ev.payload.parentUuid;
|
|
1288
|
+
if (typeof p === "string" && p.length > 0) {
|
|
1289
|
+
withParent += 1;
|
|
1290
|
+
if (!uuids.has(p)) broken += 1;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return {
|
|
1294
|
+
totalEvents: events.length,
|
|
1295
|
+
withParent,
|
|
1296
|
+
brokenLinks: broken,
|
|
1297
|
+
brokenRatio: withParent === 0 ? 0 : broken / withParent,
|
|
1298
|
+
hasData: withParent > 0
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function renderParentChain(s) {
|
|
1302
|
+
const lines = [];
|
|
1303
|
+
lines.push("## Parent Chain");
|
|
1304
|
+
lines.push("");
|
|
1305
|
+
if (!s.hasData) {
|
|
1306
|
+
lines.push("_(no parent chain data)_");
|
|
1307
|
+
lines.push("");
|
|
1308
|
+
return lines.join("\n");
|
|
1309
|
+
}
|
|
1310
|
+
lines.push("| Metric | Value |");
|
|
1311
|
+
lines.push("| --- | --- |");
|
|
1312
|
+
lines.push(`| total_events | ${s.totalEvents} |`);
|
|
1313
|
+
lines.push(`| events_with_parent | ${s.withParent} |`);
|
|
1314
|
+
lines.push(`| broken_links | ${s.brokenLinks} |`);
|
|
1315
|
+
lines.push(`| parentUuid_broken_ratio | ${s.brokenRatio.toFixed(4)} |`);
|
|
1316
|
+
lines.push("");
|
|
1317
|
+
return lines.join("\n");
|
|
1318
|
+
}
|
|
1319
|
+
function fmtUsd(n) {
|
|
1320
|
+
return Number.isFinite(n) ? `$${(Math.round(n * 1e4) / 1e4).toFixed(4)}` : "$0.0000";
|
|
1321
|
+
}
|
|
1322
|
+
function fmtPct(n) {
|
|
1323
|
+
return Number.isFinite(n) ? `${(n * 100).toFixed(2)}%` : "0.00%";
|
|
1324
|
+
}
|
|
1325
|
+
function renderR1PerSpec(buckets) {
|
|
1326
|
+
const lines = [];
|
|
1327
|
+
lines.push("### R1 \u2014 Cost per Spec");
|
|
1328
|
+
lines.push("");
|
|
1329
|
+
if (buckets.length === 0) {
|
|
1330
|
+
lines.push("_no data_");
|
|
1331
|
+
lines.push("");
|
|
1332
|
+
return lines.join("\n");
|
|
1333
|
+
}
|
|
1334
|
+
const sorted = [...buckets].sort((a, b) => b.totalUSD - a.totalUSD);
|
|
1335
|
+
lines.push("| Spec | Total USD | Rows | Trailers | Duration (ms) |");
|
|
1336
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
1337
|
+
for (const b of sorted) {
|
|
1338
|
+
lines.push(
|
|
1339
|
+
`| ${escapeCell(b.key)} | ${fmtUsd(b.totalUSD)} | ${b.rowCount} | ${b.trailerCount} | ${b.durationMs ?? 0} |`
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
lines.push("");
|
|
1343
|
+
return lines.join("\n");
|
|
1344
|
+
}
|
|
1345
|
+
function renderR2PerPhase(buckets) {
|
|
1346
|
+
const lines = [];
|
|
1347
|
+
lines.push("### R2 \u2014 Cost per Phase");
|
|
1348
|
+
lines.push("");
|
|
1349
|
+
if (buckets.length === 0) {
|
|
1350
|
+
lines.push("_no data_");
|
|
1351
|
+
lines.push("");
|
|
1352
|
+
return lines.join("\n");
|
|
1353
|
+
}
|
|
1354
|
+
const order = new Map(PHASE_ORDER.map((p, i) => [p, i]));
|
|
1355
|
+
const sorted = [...buckets].sort((a, b) => {
|
|
1356
|
+
const ai = order.get(a.key) ?? PHASE_ORDER.length;
|
|
1357
|
+
const bi = order.get(b.key) ?? PHASE_ORDER.length;
|
|
1358
|
+
if (ai !== bi) return ai - bi;
|
|
1359
|
+
return b.totalUSD - a.totalUSD;
|
|
1360
|
+
});
|
|
1361
|
+
lines.push("| Phase | Total USD | Rows | Trailers | Duration (ms) |");
|
|
1362
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
1363
|
+
for (const b of sorted) {
|
|
1364
|
+
lines.push(
|
|
1365
|
+
`| ${escapeCell(b.key)} | ${fmtUsd(b.totalUSD)} | ${b.rowCount} | ${b.trailerCount} | ${b.durationMs ?? 0} |`
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
lines.push("");
|
|
1369
|
+
return lines.join("\n");
|
|
1370
|
+
}
|
|
1371
|
+
function renderR3PerTask(buckets) {
|
|
1372
|
+
const lines = [];
|
|
1373
|
+
lines.push("### R3 \u2014 Cost per Task");
|
|
1374
|
+
lines.push("");
|
|
1375
|
+
if (buckets.length === 0) {
|
|
1376
|
+
lines.push("_no data_");
|
|
1377
|
+
lines.push("");
|
|
1378
|
+
return lines.join("\n");
|
|
1379
|
+
}
|
|
1380
|
+
const sorted = [...buckets].sort((a, b) => b.totalUSD - a.totalUSD);
|
|
1381
|
+
lines.push("| Task | Total USD | Rows | Trailers | trailerHit | Duration (ms) |");
|
|
1382
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
1383
|
+
for (const b of sorted) {
|
|
1384
|
+
const trailerHit = b.trailerCount > 0 ? "yes" : "no";
|
|
1385
|
+
lines.push(
|
|
1386
|
+
`| ${escapeCell(b.key)} | ${fmtUsd(b.totalUSD)} | ${b.rowCount} | ${b.trailerCount} | ${trailerHit} | ${b.durationMs ?? 0} |`
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
lines.push("");
|
|
1390
|
+
return lines.join("\n");
|
|
1391
|
+
}
|
|
1392
|
+
function renderR4CacheHit(buckets) {
|
|
1393
|
+
const lines = [];
|
|
1394
|
+
lines.push("### R4 \u2014 Cache Hit Rate");
|
|
1395
|
+
lines.push("");
|
|
1396
|
+
lines.push("_Formula: hitRate = cacheRead / (cacheRead + 5m + 1h)_");
|
|
1397
|
+
lines.push("");
|
|
1398
|
+
if (buckets.length === 0) {
|
|
1399
|
+
lines.push("_no data_");
|
|
1400
|
+
lines.push("");
|
|
1401
|
+
return lines.join("\n");
|
|
1402
|
+
}
|
|
1403
|
+
const rows = buckets.map((b) => {
|
|
1404
|
+
const denom = (b.cacheReadTokens ?? 0) + (b.cacheCreate5mTokens ?? 0) + (b.cacheCreate1hTokens ?? 0);
|
|
1405
|
+
const hitRate = denom > 0 ? (b.cacheReadTokens ?? 0) / denom : 0;
|
|
1406
|
+
return { key: b.key, hitRate, read: b.cacheReadTokens ?? 0, w5m: b.cacheCreate5mTokens ?? 0, w1h: b.cacheCreate1hTokens ?? 0 };
|
|
1407
|
+
});
|
|
1408
|
+
rows.sort((a, b) => a.hitRate - b.hitRate);
|
|
1409
|
+
lines.push("| Scope | Hit Rate | cacheRead | 5m write | 1h write |");
|
|
1410
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
1411
|
+
for (const r of rows) {
|
|
1412
|
+
lines.push(`| ${escapeCell(r.key)} | ${fmtPct(r.hitRate)} | ${r.read} | ${r.w5m} | ${r.w1h} |`);
|
|
1413
|
+
}
|
|
1414
|
+
lines.push("");
|
|
1415
|
+
return lines.join("\n");
|
|
1416
|
+
}
|
|
1417
|
+
function renderR5WallClock(buckets) {
|
|
1418
|
+
const lines = [];
|
|
1419
|
+
lines.push("### R5 \u2014 Wall-Clock P95");
|
|
1420
|
+
lines.push("");
|
|
1421
|
+
if (buckets.length === 0) {
|
|
1422
|
+
lines.push("_no data_");
|
|
1423
|
+
lines.push("");
|
|
1424
|
+
return lines.join("\n");
|
|
1425
|
+
}
|
|
1426
|
+
const sorted = [...buckets].sort((a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0));
|
|
1427
|
+
const n = sorted.length;
|
|
1428
|
+
const p95Idx = Math.min(Math.max(Math.ceil(0.95 * n) - 1, 0), n - 1);
|
|
1429
|
+
const p95 = sorted[p95Idx]?.durationMs ?? 0;
|
|
1430
|
+
lines.push(`_P95 wall-clock across ${n} bucket(s): ${p95} ms_`);
|
|
1431
|
+
lines.push("");
|
|
1432
|
+
lines.push("| Scope | Duration (ms) |");
|
|
1433
|
+
lines.push("| --- | --- |");
|
|
1434
|
+
for (const b of sorted) {
|
|
1435
|
+
lines.push(`| ${escapeCell(b.key)} | ${b.durationMs ?? 0} |`);
|
|
1436
|
+
}
|
|
1437
|
+
lines.push("");
|
|
1438
|
+
return lines.join("\n");
|
|
1439
|
+
}
|
|
1440
|
+
function renderR6ModelMix(buckets) {
|
|
1441
|
+
const lines = [];
|
|
1442
|
+
lines.push("### R6 \u2014 Model Mix");
|
|
1443
|
+
lines.push("");
|
|
1444
|
+
const mix = /* @__PURE__ */ new Map();
|
|
1445
|
+
for (const b of buckets) {
|
|
1446
|
+
if (!b.modelMix) continue;
|
|
1447
|
+
for (const [model, m] of Object.entries(b.modelMix)) {
|
|
1448
|
+
const prev = mix.get(model) ?? { tokens: 0, usd: 0 };
|
|
1449
|
+
prev.tokens += m.tokens ?? 0;
|
|
1450
|
+
prev.usd += m.usd ?? 0;
|
|
1451
|
+
mix.set(model, prev);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
if (mix.size === 0) {
|
|
1455
|
+
lines.push("_no data_");
|
|
1456
|
+
lines.push("");
|
|
1457
|
+
lines.push("_Note: Opus 4.7 tokenizer counts ~35% more tokens than older models \u2014 token totals are not directly comparable across model cohorts. (FR-REPORT-4 / AC8)_");
|
|
1458
|
+
lines.push("");
|
|
1459
|
+
return lines.join("\n");
|
|
1460
|
+
}
|
|
1461
|
+
const rows = Array.from(mix.entries()).sort((a, b) => b[1].usd - a[1].usd);
|
|
1462
|
+
lines.push("| Model | Tokens | USD |");
|
|
1463
|
+
lines.push("| --- | --- | --- |");
|
|
1464
|
+
for (const [model, m] of rows) {
|
|
1465
|
+
lines.push(`| ${escapeCell(model)} | ${m.tokens} | ${fmtUsd(m.usd)} |`);
|
|
1466
|
+
}
|
|
1467
|
+
lines.push("");
|
|
1468
|
+
lines.push("_Note: Opus 4.7 tokenizer counts ~35% more tokens than older models \u2014 token totals are not directly comparable across model cohorts._");
|
|
1469
|
+
lines.push("");
|
|
1470
|
+
return lines.join("\n");
|
|
1471
|
+
}
|
|
1472
|
+
function renderR7TopN(buckets) {
|
|
1473
|
+
const lines = [];
|
|
1474
|
+
lines.push("### R7 \u2014 Top-N Tasks");
|
|
1475
|
+
lines.push("");
|
|
1476
|
+
if (buckets.length === 0) {
|
|
1477
|
+
lines.push("_no data_");
|
|
1478
|
+
lines.push("");
|
|
1479
|
+
return lines.join("\n");
|
|
1480
|
+
}
|
|
1481
|
+
const sorted = [...buckets].sort((a, b) => b.totalUSD - a.totalUSD);
|
|
1482
|
+
lines.push("| Rank | Task | Total USD | Rows | Trailers |");
|
|
1483
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
1484
|
+
sorted.forEach((b, i) => {
|
|
1485
|
+
lines.push(`| ${i + 1} | ${escapeCell(b.key)} | ${fmtUsd(b.totalUSD)} | ${b.rowCount} | ${b.trailerCount} |`);
|
|
1486
|
+
});
|
|
1487
|
+
lines.push("");
|
|
1488
|
+
return lines.join("\n");
|
|
1489
|
+
}
|
|
1490
|
+
var SEV_PREFIX = {
|
|
1491
|
+
sev: "[SEV]",
|
|
1492
|
+
warn: "[WARN]",
|
|
1493
|
+
info: "[INFO]",
|
|
1494
|
+
insufficient_data: "[N/A]"
|
|
1495
|
+
};
|
|
1496
|
+
var SEV_COLOR = {
|
|
1497
|
+
sev: "\x1B[31m",
|
|
1498
|
+
warn: "\x1B[33m",
|
|
1499
|
+
info: "\x1B[34m",
|
|
1500
|
+
insufficient_data: "\x1B[90m"
|
|
1501
|
+
};
|
|
1502
|
+
var ANSI_RESET = "\x1B[0m";
|
|
1503
|
+
function colorAllowed() {
|
|
1504
|
+
return !(typeof process !== "undefined" && process.env && "NO_COLOR" in process.env);
|
|
1505
|
+
}
|
|
1506
|
+
function severityPrefix(sev) {
|
|
1507
|
+
const tag = SEV_PREFIX[sev];
|
|
1508
|
+
if (!colorAllowed()) return tag;
|
|
1509
|
+
return `${SEV_COLOR[sev]}${tag}${ANSI_RESET}`;
|
|
1510
|
+
}
|
|
1511
|
+
function formatScope(scope) {
|
|
1512
|
+
const parts = [];
|
|
1513
|
+
if (scope.spec) parts.push(`spec=${scope.spec}`);
|
|
1514
|
+
if (scope.phase) parts.push(`phase=${scope.phase}`);
|
|
1515
|
+
if (scope.task) parts.push(`task=${scope.task}`);
|
|
1516
|
+
return parts.length === 0 ? "global" : parts.join("/");
|
|
1517
|
+
}
|
|
1518
|
+
function formatEvidence(evidence) {
|
|
1519
|
+
try {
|
|
1520
|
+
return JSON.stringify(evidence);
|
|
1521
|
+
} catch {
|
|
1522
|
+
return "{}";
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
function renderRecommendations(recs) {
|
|
1526
|
+
if (!recs || recs.length === 0) return "";
|
|
1527
|
+
const lines = [];
|
|
1528
|
+
lines.push("## Recommendations");
|
|
1529
|
+
lines.push("");
|
|
1530
|
+
for (const r of recs) {
|
|
1531
|
+
const prefix = severityPrefix(r.severity);
|
|
1532
|
+
const scope = formatScope(r.scope ?? {});
|
|
1533
|
+
const evidence = formatEvidence(r.evidence ?? {});
|
|
1534
|
+
let line = `- ${prefix} ${r.rule} @ ${scope} \u2014 ${r.message} (${evidence})`;
|
|
1535
|
+
if (r.severity === "insufficient_data") {
|
|
1536
|
+
const n = r.evidence?.n;
|
|
1537
|
+
const nDisplay = typeof n === "number" ? n : "?";
|
|
1538
|
+
line += ` _n=${nDisplay} \u4E0D\u8DB3\u4EE5\u5224\u65AD_`;
|
|
1539
|
+
}
|
|
1540
|
+
lines.push(line);
|
|
1541
|
+
}
|
|
1542
|
+
lines.push("");
|
|
1543
|
+
return lines.join("\n");
|
|
1544
|
+
}
|
|
1545
|
+
function renderCostBreakdown(input) {
|
|
1546
|
+
const lines = [];
|
|
1547
|
+
lines.push("## Cost Breakdown");
|
|
1548
|
+
lines.push("");
|
|
1549
|
+
lines.push(`_Total: ${fmtUsd(input.totalCost?.usd ?? 0)} USD across ${input.R3_perTask?.length ?? 0} task bucket(s)_`);
|
|
1550
|
+
lines.push("");
|
|
1551
|
+
lines.push(renderR1PerSpec(input.R1_perSpec ?? []));
|
|
1552
|
+
lines.push(renderR2PerPhase(input.R2_perPhase ?? []));
|
|
1553
|
+
lines.push(renderR3PerTask(input.R3_perTask ?? []));
|
|
1554
|
+
lines.push(renderR4CacheHit(input.R3_perTask ?? []));
|
|
1555
|
+
lines.push(renderR5WallClock(input.R3_perTask ?? []));
|
|
1556
|
+
lines.push(renderR6ModelMix(input.R3_perTask ?? []));
|
|
1557
|
+
lines.push(renderR7TopN(input.R7_topN ?? []));
|
|
1558
|
+
return lines.join("\n");
|
|
1559
|
+
}
|
|
1560
|
+
function renderReport(events, errorEntries, specStates, opts) {
|
|
1561
|
+
const limit = typeof opts.limit === "number" && opts.limit > 0 ? opts.limit : DEFAULT_LIMIT;
|
|
1562
|
+
const hookFailures = rollupHookFailures(events, errorEntries, limit);
|
|
1563
|
+
const slashCommands = rollupSlashCommands(events, limit);
|
|
1564
|
+
const subagents = rollupSubagents(events, limit);
|
|
1565
|
+
const specFunnel = rollupSpecFunnel(specStates);
|
|
1566
|
+
const hookDuration = rollupHookDuration(events);
|
|
1567
|
+
const parentChain = rollupParentChain(events);
|
|
1568
|
+
let markdown = renderHookFailures(hookFailures, limit) + "\n" + renderSlashCommands(slashCommands, limit) + "\n" + renderSubagents(subagents, limit) + "\n" + renderSpecFunnel(specFunnel) + "\n" + renderHookDuration(hookDuration) + "\n" + renderSchemaDrift(opts.schemaDrift) + "\n" + renderParentChain(parentChain);
|
|
1569
|
+
if (opts.costBreakdown) {
|
|
1570
|
+
markdown += "\n" + renderCostBreakdown(opts.costBreakdown);
|
|
1571
|
+
}
|
|
1572
|
+
if (opts.recommendations && opts.recommendations.length > 0) {
|
|
1573
|
+
const recsMd = renderRecommendations(opts.recommendations);
|
|
1574
|
+
if (recsMd) markdown += "\n" + recsMd;
|
|
1575
|
+
}
|
|
1576
|
+
const json = {
|
|
1577
|
+
hookFailures,
|
|
1578
|
+
slashCommands,
|
|
1579
|
+
subagents,
|
|
1580
|
+
specFunnel,
|
|
1581
|
+
hookDuration,
|
|
1582
|
+
schemaDrift: {
|
|
1583
|
+
unknownTypeCount: opts.schemaDrift.unknownTypeCount,
|
|
1584
|
+
parseErrorCount: opts.schemaDrift.parseErrorCount
|
|
1585
|
+
},
|
|
1586
|
+
parentChain
|
|
1587
|
+
};
|
|
1588
|
+
return { markdown, json };
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// src/analyze/transcript-path.ts
|
|
1592
|
+
import { readdirSync, realpathSync, statSync as statSync2 } from "fs";
|
|
1593
|
+
import { homedir } from "os";
|
|
1594
|
+
import path3 from "path";
|
|
1595
|
+
var TranscriptNotFoundError = class extends Error {
|
|
1596
|
+
path;
|
|
1597
|
+
hint;
|
|
1598
|
+
constructor(p, hint) {
|
|
1599
|
+
super(`Transcript not found at ${p}
|
|
1600
|
+
hint: ${hint}`);
|
|
1601
|
+
this.name = "TranscriptNotFoundError";
|
|
1602
|
+
this.path = p;
|
|
1603
|
+
this.hint = hint;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
var REALPATH_CACHE = /* @__PURE__ */ new Map();
|
|
1607
|
+
function resolveRealCwd(cwd) {
|
|
1608
|
+
const cached = REALPATH_CACHE.get(cwd);
|
|
1609
|
+
if (cached !== void 0) return cached;
|
|
1610
|
+
let real;
|
|
1611
|
+
try {
|
|
1612
|
+
real = realpathSync(cwd);
|
|
1613
|
+
} catch {
|
|
1614
|
+
real = cwd;
|
|
1615
|
+
}
|
|
1616
|
+
REALPATH_CACHE.set(cwd, real);
|
|
1617
|
+
return real;
|
|
1618
|
+
}
|
|
1619
|
+
function encodeCwd(realCwd) {
|
|
1620
|
+
return realCwd.replace(/\//g, "-");
|
|
1621
|
+
}
|
|
1622
|
+
function resolveTranscriptSource(opts = {}) {
|
|
1623
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1624
|
+
if (opts.fixtureOverride) {
|
|
1625
|
+
const fixturePath = opts.fixtureOverride;
|
|
1626
|
+
try {
|
|
1627
|
+
const st = statSync2(fixturePath);
|
|
1628
|
+
if (!st.isFile()) {
|
|
1629
|
+
throw new TranscriptNotFoundError(
|
|
1630
|
+
fixturePath,
|
|
1631
|
+
"fixture path is not a regular file"
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
} catch (err) {
|
|
1635
|
+
if (err instanceof TranscriptNotFoundError) throw err;
|
|
1636
|
+
throw new TranscriptNotFoundError(
|
|
1637
|
+
fixturePath,
|
|
1638
|
+
"fixture not found \u2014 check CURDX_TRANSCRIPT_FIXTURE points to an existing .jsonl file"
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
return { kind: "fixture", paths: [fixturePath], cwd };
|
|
1642
|
+
}
|
|
1643
|
+
const home = opts.homedir ?? homedir();
|
|
1644
|
+
const realCwd = resolveRealCwd(cwd);
|
|
1645
|
+
const encoded = encodeCwd(realCwd);
|
|
1646
|
+
const encodedDir = path3.join(home, ".claude", "projects", encoded);
|
|
1647
|
+
let entries = [];
|
|
1648
|
+
try {
|
|
1649
|
+
entries = readdirSync(encodedDir, { withFileTypes: true });
|
|
1650
|
+
} catch {
|
|
1651
|
+
throw new TranscriptNotFoundError(
|
|
1652
|
+
encodedDir,
|
|
1653
|
+
`no Claude Code project dir for cwd ${cwd} \u2014 run \`claude\` here at least once, or pass CURDX_TRANSCRIPT_FIXTURE=\u2026 for tests`
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
let paths = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => path3.join(encodedDir, e.name));
|
|
1657
|
+
if (opts.sessionFilter) {
|
|
1658
|
+
const wanted = `${opts.sessionFilter}.jsonl`;
|
|
1659
|
+
paths = paths.filter((p) => path3.basename(p) === wanted);
|
|
1660
|
+
}
|
|
1661
|
+
if (paths.length === 0) {
|
|
1662
|
+
const hint = opts.sessionFilter ? `no transcript file ${opts.sessionFilter}.jsonl in ${encodedDir} \u2014 drop --session or check the uuid` : `no .jsonl transcripts in ${encodedDir} \u2014 open Claude Code in this cwd to generate one`;
|
|
1663
|
+
throw new TranscriptNotFoundError(encodedDir, hint);
|
|
1664
|
+
}
|
|
1665
|
+
return { kind: "real", paths, cwd, realCwd, encodedDir };
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// src/analyze/index.ts
|
|
1669
|
+
var STATE_DIR = path4.join(homedir2(), ".claude", "curdx-flow");
|
|
1670
|
+
var STATE_PATH = path4.join(STATE_DIR, "observability-state.json");
|
|
1671
|
+
var ERRORS_LOG_PATH = path4.join(STATE_DIR, "errors.jsonl");
|
|
1672
|
+
var SPECS_DIR_REL = "specs";
|
|
1673
|
+
function readState() {
|
|
1674
|
+
if (!existsSync(STATE_PATH)) return { version: 1, files: {} };
|
|
1675
|
+
try {
|
|
1676
|
+
const raw = readFileSync2(STATE_PATH, "utf8");
|
|
1677
|
+
const parsed = JSON.parse(raw);
|
|
1678
|
+
if (parsed && parsed.version === 1 && parsed.files) return parsed;
|
|
1679
|
+
} catch {
|
|
1680
|
+
}
|
|
1681
|
+
return { version: 1, files: {} };
|
|
1682
|
+
}
|
|
1683
|
+
function writeState(state) {
|
|
1684
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
1685
|
+
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf8");
|
|
1686
|
+
}
|
|
1687
|
+
function cleanupOrphanState(state, currentPaths) {
|
|
1688
|
+
const now = Date.now();
|
|
1689
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1690
|
+
const KEEP_MAX = 100;
|
|
1691
|
+
const protectedKeys = new Set(currentPaths);
|
|
1692
|
+
const dropped = [];
|
|
1693
|
+
for (const key of Object.keys(state.files)) {
|
|
1694
|
+
if (protectedKeys.has(key)) continue;
|
|
1695
|
+
const entry = state.files[key];
|
|
1696
|
+
if (!entry) continue;
|
|
1697
|
+
const stale = now - entry.lastModifiedMs > THIRTY_DAYS_MS;
|
|
1698
|
+
const gone = !existsSync(key);
|
|
1699
|
+
if (stale || gone) {
|
|
1700
|
+
delete state.files[key];
|
|
1701
|
+
dropped.push(key);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
const overflow = Object.keys(state.files).length - KEEP_MAX;
|
|
1705
|
+
if (overflow > 0) {
|
|
1706
|
+
const candidates = Object.entries(state.files).filter(([k]) => !protectedKeys.has(k)).sort((a, b) => a[1].lastModifiedMs - b[1].lastModifiedMs);
|
|
1707
|
+
for (const [key] of candidates.slice(0, overflow)) {
|
|
1708
|
+
delete state.files[key];
|
|
1709
|
+
dropped.push(key);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
if (dropped.length) {
|
|
1713
|
+
console.warn(`state: GC dropped ${dropped.length} orphan entr${dropped.length === 1 ? "y" : "ies"}`);
|
|
1714
|
+
}
|
|
1715
|
+
return state;
|
|
1716
|
+
}
|
|
1717
|
+
function loadSpecStates() {
|
|
1718
|
+
const specsDir = path4.resolve(process.cwd(), SPECS_DIR_REL);
|
|
1719
|
+
if (!existsSync(specsDir)) return [];
|
|
1720
|
+
let entries;
|
|
1721
|
+
try {
|
|
1722
|
+
entries = readdirSync2(specsDir);
|
|
1723
|
+
} catch {
|
|
1724
|
+
return [];
|
|
1725
|
+
}
|
|
1726
|
+
const out = [];
|
|
1727
|
+
for (const name of entries) {
|
|
1728
|
+
if (name.startsWith(".")) continue;
|
|
1729
|
+
const stateFile = path4.join(specsDir, name, ".curdx-state.json");
|
|
1730
|
+
if (!existsSync(stateFile)) continue;
|
|
1731
|
+
try {
|
|
1732
|
+
const raw = readFileSync2(stateFile, "utf8");
|
|
1733
|
+
const parsed = JSON.parse(raw);
|
|
1734
|
+
const phase = typeof parsed.phase === "string" ? parsed.phase : void 0;
|
|
1735
|
+
if (!phase) continue;
|
|
1736
|
+
out.push({ name, phase });
|
|
1737
|
+
} catch {
|
|
1738
|
+
continue;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
return out;
|
|
1742
|
+
}
|
|
1743
|
+
function loadErrorEntries() {
|
|
1744
|
+
if (!existsSync(ERRORS_LOG_PATH)) return [];
|
|
1745
|
+
let raw;
|
|
1746
|
+
try {
|
|
1747
|
+
raw = readFileSync2(ERRORS_LOG_PATH, "utf8");
|
|
1748
|
+
} catch {
|
|
1749
|
+
return [];
|
|
1750
|
+
}
|
|
1751
|
+
const out = [];
|
|
1752
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1753
|
+
if (!line) continue;
|
|
1754
|
+
try {
|
|
1755
|
+
const parsed = JSON.parse(line);
|
|
1756
|
+
const level = parsed.level === "error" || parsed.level === "info" || parsed.level === "metric" || parsed.level === "decision" ? parsed.level : "error";
|
|
1757
|
+
const kind = typeof parsed.kind === "string" ? parsed.kind : "unknown";
|
|
1758
|
+
const payload = parsed.payload && typeof parsed.payload === "object" && !Array.isArray(parsed.payload) ? parsed.payload : void 0;
|
|
1759
|
+
const correlationId = typeof parsed.correlationId === "string" ? parsed.correlationId : void 0;
|
|
1760
|
+
out.push({
|
|
1761
|
+
ts: typeof parsed.ts === "string" ? parsed.ts : "",
|
|
1762
|
+
...typeof parsed.hook === "string" ? { hook: parsed.hook } : {},
|
|
1763
|
+
...typeof parsed.event === "string" ? { event: parsed.event } : {},
|
|
1764
|
+
...typeof parsed.msg === "string" ? { msg: parsed.msg } : {},
|
|
1765
|
+
...typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {},
|
|
1766
|
+
...typeof parsed.transcript_path === "string" ? { transcript_path: parsed.transcript_path } : {},
|
|
1767
|
+
level,
|
|
1768
|
+
kind,
|
|
1769
|
+
...payload !== void 0 ? { payload } : {},
|
|
1770
|
+
...correlationId !== void 0 ? { correlationId } : {}
|
|
1771
|
+
});
|
|
1772
|
+
} catch {
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return out;
|
|
1777
|
+
}
|
|
1778
|
+
async function runAnalyzeInner(opts) {
|
|
1779
|
+
const source = resolveTranscriptSource({
|
|
1780
|
+
fixtureOverride: process.env.CURDX_TRANSCRIPT_FIXTURE,
|
|
1781
|
+
sessionFilter: opts.session
|
|
1782
|
+
});
|
|
1783
|
+
const limit = Number(opts.limit) || 10;
|
|
1784
|
+
const state = readState();
|
|
1785
|
+
const pathStats = [];
|
|
1786
|
+
let allCachedReady = true;
|
|
1787
|
+
for (const p of source.paths) {
|
|
1788
|
+
const stat = statSync3(p);
|
|
1789
|
+
const prev = state.files[p];
|
|
1790
|
+
const rotate = shouldRotate(prev, { sizeBytes: stat.size, lastModifiedMs: stat.mtimeMs });
|
|
1791
|
+
const startOffset = rotate || !prev ? 0 : prev.byteOffset;
|
|
1792
|
+
if (rotate || !prev || startOffset < stat.size) allCachedReady = false;
|
|
1793
|
+
pathStats.push({ p, sizeBytes: stat.size, lastModifiedMs: stat.mtimeMs, rotate, startOffset });
|
|
1794
|
+
}
|
|
1795
|
+
const includePrompts = Boolean(opts.includePrompts);
|
|
1796
|
+
const costSummary = opts.costSummary === true;
|
|
1797
|
+
const cacheCompatible = (state.lastIncludePrompts ?? false) === includePrompts && (state.lastCostSummary ?? false) === costSummary;
|
|
1798
|
+
if (allCachedReady && pathStats.length > 0 && cacheCompatible && (state.lastReportJson || state.lastReportMarkdown)) {
|
|
1799
|
+
if (opts.json && state.lastReportJson) {
|
|
1800
|
+
process.stdout.write(state.lastReportJson);
|
|
1801
|
+
writeState(state);
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (!opts.json && state.lastReportMarkdown) {
|
|
1805
|
+
process.stdout.write(state.lastReportMarkdown);
|
|
1806
|
+
writeState(state);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
const schemaMap = loadSchemaMap();
|
|
1811
|
+
const counters = { unknown_type: 0, parse_error: 0, processed: 0 };
|
|
1812
|
+
const collected = [];
|
|
1813
|
+
try {
|
|
1814
|
+
for (const { p, startOffset } of pathStats) {
|
|
1815
|
+
for await (const ev of parseTranscript(p, startOffset, schemaMap, counters)) {
|
|
1816
|
+
collected.push(ev);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
const redacted = [];
|
|
1820
|
+
for (const ev of collected) {
|
|
1821
|
+
const r = redactEvent(ev, { includePrompts });
|
|
1822
|
+
if (r) redacted.push(r);
|
|
1823
|
+
}
|
|
1824
|
+
const filtered = filterEvents(redacted, { ...opts, limit });
|
|
1825
|
+
const errorEntries = loadErrorEntries();
|
|
1826
|
+
const specStates = loadSpecStates();
|
|
1827
|
+
let costBreakdown;
|
|
1828
|
+
let recommendations = [];
|
|
1829
|
+
if (opts.costSummary === true) {
|
|
1830
|
+
let specPhaseMap = {};
|
|
1831
|
+
try {
|
|
1832
|
+
for (const s of specStates) {
|
|
1833
|
+
if (s && typeof s.name === "string" && typeof s.phase === "string") {
|
|
1834
|
+
specPhaseMap[s.name] = s.phase;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
} catch {
|
|
1838
|
+
specPhaseMap = {};
|
|
1839
|
+
}
|
|
1840
|
+
const usageRows = extractUsageRowsFromEvents(filtered, errorEntries);
|
|
1841
|
+
let totalUsd = 0;
|
|
1842
|
+
for (const r of usageRows) totalUsd += computeCost(r);
|
|
1843
|
+
totalUsd = Math.round(totalUsd * 1e4) / 1e4;
|
|
1844
|
+
const anyByFlag = opts.bySpec === true || opts.byPhase === true || opts.byTask === true;
|
|
1845
|
+
const wantSpec = anyByFlag ? opts.bySpec === true : true;
|
|
1846
|
+
const wantPhase = anyByFlag ? opts.byPhase === true : true;
|
|
1847
|
+
const wantTask = anyByFlag ? opts.byTask === true : true;
|
|
1848
|
+
const top = typeof opts.top === "number" && Number.isFinite(opts.top) && opts.top > 0 ? Math.floor(opts.top) : 10;
|
|
1849
|
+
const costAggregates = {};
|
|
1850
|
+
if (wantSpec) costAggregates.spec = aggregateBy(usageRows, "spec", { specPhaseMap });
|
|
1851
|
+
if (wantPhase) costAggregates.phase = aggregateBy(usageRows, "phase", { specPhaseMap });
|
|
1852
|
+
if (wantTask) {
|
|
1853
|
+
const taskBuckets = aggregateBy(usageRows, "task", { specPhaseMap });
|
|
1854
|
+
costAggregates.task = taskBuckets.slice(0, top);
|
|
1855
|
+
}
|
|
1856
|
+
const taskBucketsAll = costAggregates.task ?? [];
|
|
1857
|
+
const r7TopN = taskBucketsAll.slice(0, top);
|
|
1858
|
+
costBreakdown = {
|
|
1859
|
+
R1_perSpec: costAggregates.spec ?? [],
|
|
1860
|
+
R2_perPhase: costAggregates.phase ?? [],
|
|
1861
|
+
R3_perTask: taskBucketsAll,
|
|
1862
|
+
R4_cacheHit: [],
|
|
1863
|
+
R5_wallClock: [],
|
|
1864
|
+
R6_modelMix: [],
|
|
1865
|
+
R7_topN: r7TopN,
|
|
1866
|
+
totalCost: { usd: totalUsd }
|
|
1867
|
+
};
|
|
1868
|
+
try {
|
|
1869
|
+
const allBuckets = [
|
|
1870
|
+
...costAggregates.spec ?? [],
|
|
1871
|
+
...costAggregates.phase ?? [],
|
|
1872
|
+
...taskBucketsAll
|
|
1873
|
+
];
|
|
1874
|
+
recommendations = recommend(allBuckets, {
|
|
1875
|
+
errorEntries,
|
|
1876
|
+
criticalPhases: ["critical", "debug-hard", "security"]
|
|
1877
|
+
});
|
|
1878
|
+
} catch {
|
|
1879
|
+
recommendations = [];
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
const { markdown, json } = renderReport(filtered, errorEntries, specStates, {
|
|
1883
|
+
...opts,
|
|
1884
|
+
limit,
|
|
1885
|
+
schemaDrift: {
|
|
1886
|
+
unknownTypeCount: counters.unknown_type,
|
|
1887
|
+
parseErrorCount: counters.parse_error
|
|
1888
|
+
},
|
|
1889
|
+
...costBreakdown ? { costBreakdown } : {},
|
|
1890
|
+
...recommendations.length > 0 ? { recommendations } : {}
|
|
1891
|
+
});
|
|
1892
|
+
const safeJson = redactReportFields(json, { includePrompts });
|
|
1893
|
+
void opts.out;
|
|
1894
|
+
const markdownStr = markdown;
|
|
1895
|
+
let jsonObj = safeJson;
|
|
1896
|
+
if (opts.costSummary === true && costBreakdown) {
|
|
1897
|
+
jsonObj = {
|
|
1898
|
+
...jsonObj,
|
|
1899
|
+
totalCost: { usd: costBreakdown.totalCost.usd },
|
|
1900
|
+
costBreakdown,
|
|
1901
|
+
recommendations
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
const jsonStr = `${JSON.stringify(jsonObj)}
|
|
1905
|
+
`;
|
|
1906
|
+
state.lastReportJson = jsonStr;
|
|
1907
|
+
state.lastReportMarkdown = markdownStr;
|
|
1908
|
+
state.lastIncludePrompts = includePrompts;
|
|
1909
|
+
state.lastCostSummary = costSummary;
|
|
1910
|
+
if (opts.json) {
|
|
1911
|
+
process.stdout.write(jsonStr);
|
|
1912
|
+
} else {
|
|
1913
|
+
process.stdout.write(markdownStr);
|
|
1914
|
+
}
|
|
1915
|
+
void safeJson;
|
|
1916
|
+
if (counters.parse_error || counters.unknown_type) {
|
|
1917
|
+
process.stderr.write(
|
|
1918
|
+
`(analyze: parse_error=${counters.parse_error} unknown_type=${counters.unknown_type} processed=${counters.processed})
|
|
1919
|
+
`
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
} finally {
|
|
1923
|
+
for (const ps of pathStats) {
|
|
1924
|
+
state.files[ps.p] = {
|
|
1925
|
+
byteOffset: ps.sizeBytes,
|
|
1926
|
+
lastModifiedMs: ps.lastModifiedMs,
|
|
1927
|
+
sizeBytes: ps.sizeBytes
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
void getStateForPath;
|
|
1931
|
+
try {
|
|
1932
|
+
cleanupOrphanState(state, pathStats.map((ps) => ps.p));
|
|
1933
|
+
} catch (err) {
|
|
1934
|
+
console.warn("state: GC failed (continuing without cleanup):", err.message);
|
|
1935
|
+
}
|
|
1936
|
+
writeState(state);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
async function runAnalyze(opts) {
|
|
1940
|
+
try {
|
|
1941
|
+
await runAnalyzeInner(opts);
|
|
1942
|
+
} catch (err) {
|
|
1943
|
+
if (err instanceof TranscriptNotFoundError) {
|
|
1944
|
+
process.stderr.write(`warning: no transcripts found at ${err.path}
|
|
1945
|
+
`);
|
|
1946
|
+
process.stderr.write(`hint: ${err.hint}
|
|
1947
|
+
`);
|
|
1948
|
+
process.exit(1);
|
|
1949
|
+
}
|
|
1950
|
+
throw err;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
export {
|
|
1954
|
+
cleanupOrphanState,
|
|
1955
|
+
runAnalyze
|
|
1956
|
+
};
|