inspecto 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,1234 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/parser/project-scanner.ts
7
+ import { readdir, stat } from "fs/promises";
8
+ import { join as join2, basename, extname } from "path";
9
+
10
+ // src/utils/paths.ts
11
+ import { join } from "path";
12
+ import { homedir } from "os";
13
+ function getClaudeDir() {
14
+ return join(homedir(), ".claude");
15
+ }
16
+
17
+ // src/parser/project-scanner.ts
18
+ async function scanSessions(options) {
19
+ const claudeDir = options?.dataDir ?? getClaudeDir();
20
+ const projectsDir = join2(claudeDir, "projects");
21
+ let projectDirs;
22
+ try {
23
+ projectDirs = await readdir(projectsDir);
24
+ } catch {
25
+ throw new Error(
26
+ `Claude Code data directory not found. Make sure Claude Code is installed and has been used at least once.
27
+ Expected: ${projectsDir}`
28
+ );
29
+ }
30
+ if (options?.project) {
31
+ projectDirs = projectDirs.filter(
32
+ (dir) => dir.toLowerCase().includes(options.project.toLowerCase())
33
+ );
34
+ }
35
+ const sessions = [];
36
+ for (const projectDir of projectDirs) {
37
+ if (projectDir.startsWith(".")) continue;
38
+ const fullProjectDir = join2(projectsDir, projectDir);
39
+ let entries;
40
+ try {
41
+ entries = await readdir(fullProjectDir);
42
+ } catch {
43
+ continue;
44
+ }
45
+ for (const entry of entries) {
46
+ if (extname(entry) !== ".jsonl") continue;
47
+ const filePath = join2(fullProjectDir, entry);
48
+ const sessionId = basename(entry, ".jsonl");
49
+ try {
50
+ const fileStat = await stat(filePath);
51
+ if (options?.since && fileStat.mtime < options.since) continue;
52
+ sessions.push({
53
+ path: filePath,
54
+ sessionId,
55
+ projectSlug: projectDir,
56
+ mtime: fileStat.mtime
57
+ });
58
+ } catch {
59
+ continue;
60
+ }
61
+ }
62
+ }
63
+ sessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
64
+ return sessions;
65
+ }
66
+ async function getMostRecentSession(options) {
67
+ const sessions = await scanSessions(options);
68
+ if (sessions.length === 0) {
69
+ throw new Error(
70
+ "No Claude Code sessions found. Use Claude Code in a project first to generate session data."
71
+ );
72
+ }
73
+ return sessions[0];
74
+ }
75
+
76
+ // src/parser/jsonl-reader.ts
77
+ import { createReadStream } from "fs";
78
+ import { createInterface } from "readline";
79
+ async function* readJsonl(filePath) {
80
+ const stream = createReadStream(filePath, { encoding: "utf-8" });
81
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
82
+ for await (const line of rl) {
83
+ const trimmed = line.trim();
84
+ if (trimmed.length === 0) continue;
85
+ try {
86
+ const record = JSON.parse(trimmed);
87
+ if (record && typeof record === "object" && "type" in record) {
88
+ yield record;
89
+ }
90
+ } catch {
91
+ }
92
+ }
93
+ }
94
+
95
+ // src/parser/session-builder.ts
96
+ async function buildSession(records, sessionId, projectSlug) {
97
+ const assistantChunks = /* @__PURE__ */ new Map();
98
+ const turns = [];
99
+ let cwd = "";
100
+ let gitBranch = null;
101
+ let model = "";
102
+ let firstTimestamp = "";
103
+ let lastTimestamp = "";
104
+ for await (const record of records) {
105
+ if (isSkippable(record.type)) continue;
106
+ if (record.type === "user") {
107
+ const userRecord = record;
108
+ handleUserRecord(userRecord, turns);
109
+ captureMetadata(userRecord);
110
+ } else if (record.type === "assistant") {
111
+ const assistantRecord = record;
112
+ if (assistantRecord.message.model === "<synthetic>") continue;
113
+ if (assistantRecord.error) continue;
114
+ handleAssistantChunk(assistantRecord, assistantChunks);
115
+ captureMetadata(assistantRecord);
116
+ }
117
+ }
118
+ for (const [, acc] of assistantChunks) {
119
+ turns.push({
120
+ role: "assistant",
121
+ content: acc.content,
122
+ usage: acc.usage,
123
+ complete: acc.complete,
124
+ timestamp: acc.timestamp,
125
+ isHumanTurn: false,
126
+ model: acc.model
127
+ });
128
+ }
129
+ turns.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
130
+ return {
131
+ id: sessionId,
132
+ projectSlug,
133
+ model,
134
+ turns,
135
+ startTime: firstTimestamp,
136
+ endTime: lastTimestamp,
137
+ cwd,
138
+ gitBranch,
139
+ durationMs: firstTimestamp && lastTimestamp ? new Date(lastTimestamp).getTime() - new Date(firstTimestamp).getTime() : 0
140
+ };
141
+ function captureMetadata(record) {
142
+ if (!firstTimestamp && record.timestamp) {
143
+ firstTimestamp = record.timestamp;
144
+ }
145
+ if (record.timestamp) {
146
+ lastTimestamp = record.timestamp;
147
+ }
148
+ if (!cwd && record.cwd) {
149
+ cwd = record.cwd;
150
+ }
151
+ if (gitBranch === null && record.gitBranch) {
152
+ gitBranch = record.gitBranch;
153
+ }
154
+ if (!model && record.type === "assistant") {
155
+ const ar = record;
156
+ if (ar.message.model && ar.message.model !== "<synthetic>") {
157
+ model = ar.message.model;
158
+ }
159
+ }
160
+ }
161
+ function handleUserRecord(record, turns2) {
162
+ const content = record.message.content;
163
+ const isHumanTurn = typeof content === "string" && !record.isMeta;
164
+ turns2.push({
165
+ role: "user",
166
+ content: normalizeContent(content),
167
+ usage: null,
168
+ complete: true,
169
+ timestamp: record.timestamp,
170
+ isHumanTurn
171
+ });
172
+ }
173
+ function handleAssistantChunk(record, chunks) {
174
+ const messageId = record.message.id;
175
+ let acc = chunks.get(messageId);
176
+ if (!acc) {
177
+ acc = {
178
+ content: [],
179
+ usage: null,
180
+ complete: false,
181
+ timestamp: record.timestamp,
182
+ model: record.message.model
183
+ };
184
+ chunks.set(messageId, acc);
185
+ }
186
+ for (const block of record.message.content) {
187
+ acc.content.push(block);
188
+ }
189
+ if (record.message.stop_reason !== null) {
190
+ acc.complete = true;
191
+ acc.usage = record.message.usage;
192
+ }
193
+ }
194
+ }
195
+ function normalizeContent(content) {
196
+ if (typeof content === "string") {
197
+ return [{ type: "text", text: content }];
198
+ }
199
+ return content;
200
+ }
201
+ var SKIPPABLE = /* @__PURE__ */ new Set([
202
+ "queue-operation",
203
+ "attachment",
204
+ "system",
205
+ "last-prompt"
206
+ ]);
207
+ function isSkippable(type) {
208
+ return SKIPPABLE.has(type);
209
+ }
210
+
211
+ // src/metrics/reads-per-edit.ts
212
+ var EDIT_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
213
+ var READ_TOOL = "Read";
214
+ function computeReadsPerEdit(session) {
215
+ let readsSinceLastEdit = 0;
216
+ const ratios = [];
217
+ for (const turn of session.turns) {
218
+ if (turn.role !== "assistant") continue;
219
+ for (const block of turn.content) {
220
+ if (block.type !== "tool_use") continue;
221
+ const toolBlock = block;
222
+ if (toolBlock.name === READ_TOOL) {
223
+ readsSinceLastEdit++;
224
+ } else if (EDIT_TOOLS.has(toolBlock.name)) {
225
+ ratios.push(readsSinceLastEdit);
226
+ readsSinceLastEdit = 0;
227
+ }
228
+ }
229
+ }
230
+ if (ratios.length === 0) {
231
+ return {
232
+ name: "reads-per-edit",
233
+ value: null,
234
+ status: "healthy",
235
+ label: "N/A",
236
+ detail: "No file modifications in this session"
237
+ };
238
+ }
239
+ const average2 = ratios.reduce((a, b) => a + b, 0) / ratios.length;
240
+ return {
241
+ name: "reads-per-edit",
242
+ value: round(average2),
243
+ status: average2 >= 4 ? "healthy" : average2 >= 2 ? "warning" : "critical",
244
+ label: round(average2).toString()
245
+ };
246
+ }
247
+ function round(n) {
248
+ return Math.round(n * 100) / 100;
249
+ }
250
+
251
+ // src/metrics/rewrite-ratio.ts
252
+ function computeRewriteRatio(session) {
253
+ let writes = 0;
254
+ let edits = 0;
255
+ for (const turn of session.turns) {
256
+ if (turn.role !== "assistant") continue;
257
+ for (const block of turn.content) {
258
+ if (block.type !== "tool_use") continue;
259
+ const toolBlock = block;
260
+ if (toolBlock.name === "Write") writes++;
261
+ else if (toolBlock.name === "Edit" || toolBlock.name === "NotebookEdit") edits++;
262
+ }
263
+ }
264
+ const total = writes + edits;
265
+ if (total === 0) {
266
+ return {
267
+ name: "rewrite-ratio",
268
+ value: null,
269
+ status: "healthy",
270
+ label: "N/A",
271
+ detail: "No file modifications in this session"
272
+ };
273
+ }
274
+ const ratio = writes / total;
275
+ return {
276
+ name: "rewrite-ratio",
277
+ value: round2(ratio),
278
+ status: ratio <= 0.25 ? "healthy" : ratio <= 0.5 ? "warning" : "critical",
279
+ label: round2(ratio).toString()
280
+ };
281
+ }
282
+ function round2(n) {
283
+ return Math.round(n * 100) / 100;
284
+ }
285
+
286
+ // src/metrics/cache-hit-rate.ts
287
+ function computeCacheHitRate(session) {
288
+ let totalCacheRead = 0;
289
+ let totalCacheCreation = 0;
290
+ for (const turn of session.turns) {
291
+ if (turn.role !== "assistant" || !turn.usage || !turn.complete) continue;
292
+ totalCacheRead += turn.usage.cache_read_input_tokens;
293
+ totalCacheCreation += turn.usage.cache_creation_input_tokens;
294
+ }
295
+ const totalInput = totalCacheRead + totalCacheCreation;
296
+ if (totalInput === 0) {
297
+ return {
298
+ name: "cache-hit-rate",
299
+ value: null,
300
+ status: "healthy",
301
+ label: "N/A",
302
+ detail: "No token usage data available"
303
+ };
304
+ }
305
+ const rate = totalCacheRead / totalInput;
306
+ return {
307
+ name: "cache-hit-rate",
308
+ value: round3(rate),
309
+ status: rate >= 0.5 ? "healthy" : rate >= 0.2 ? "warning" : "critical",
310
+ label: round3(rate).toString()
311
+ };
312
+ }
313
+ function round3(n) {
314
+ return Math.round(n * 100) / 100;
315
+ }
316
+
317
+ // src/metrics/task-completion.ts
318
+ var INTENT_PATTERNS = [
319
+ /\bI'll now\b/i,
320
+ /\bLet me\b/i,
321
+ /\bI'll update\b/i,
322
+ /\bNext,? I'll\b/i,
323
+ /\bI'll (?:also |then )?(?:fix|add|create|implement|refactor|modify|change|write|edit|update)\b/i,
324
+ /\bI'm going to\b/i
325
+ ];
326
+ function computeTaskCompletion(session) {
327
+ const assistantTurns = session.turns.filter(
328
+ (t) => t.role === "assistant" && t.complete
329
+ );
330
+ let totalIntents = 0;
331
+ let unfulfilledIntents = 0;
332
+ for (const turn of assistantTurns) {
333
+ const hasIntent = hasIntentPhrase(turn);
334
+ if (!hasIntent) continue;
335
+ totalIntents++;
336
+ const hasToolUse = turn.content.some((b) => b.type === "tool_use");
337
+ if (!hasToolUse) {
338
+ unfulfilledIntents++;
339
+ }
340
+ }
341
+ if (totalIntents === 0) {
342
+ return {
343
+ name: "task-completion",
344
+ value: 1,
345
+ status: "healthy",
346
+ label: "1.00",
347
+ detail: "No intent phrases detected"
348
+ };
349
+ }
350
+ const rate = 1 - unfulfilledIntents / totalIntents;
351
+ return {
352
+ name: "task-completion",
353
+ value: round4(rate),
354
+ status: rate >= 0.9 ? "healthy" : rate >= 0.7 ? "warning" : "critical",
355
+ label: round4(rate).toString()
356
+ };
357
+ }
358
+ function hasIntentPhrase(turn) {
359
+ for (const block of turn.content) {
360
+ if (block.type === "text") {
361
+ const textBlock = block;
362
+ if (INTENT_PATTERNS.some((p) => p.test(textBlock.text))) {
363
+ return true;
364
+ }
365
+ }
366
+ }
367
+ return false;
368
+ }
369
+ function round4(n) {
370
+ return Math.round(n * 100) / 100;
371
+ }
372
+
373
+ // src/utils/levenshtein.ts
374
+ function levenshteinDistance(a, b) {
375
+ if (a === b) return 0;
376
+ if (a.length === 0) return b.length;
377
+ if (b.length === 0) return a.length;
378
+ if (a.length > b.length) [a, b] = [b, a];
379
+ const aLen = a.length;
380
+ const bLen = b.length;
381
+ const row = new Array(aLen + 1);
382
+ for (let i = 0; i <= aLen; i++) row[i] = i;
383
+ for (let j = 1; j <= bLen; j++) {
384
+ let prev = row[0];
385
+ row[0] = j;
386
+ for (let i = 1; i <= aLen; i++) {
387
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
388
+ const temp = row[i];
389
+ row[i] = Math.min(
390
+ row[i] + 1,
391
+ // deletion
392
+ row[i - 1] + 1,
393
+ // insertion
394
+ prev + cost
395
+ // substitution
396
+ );
397
+ prev = temp;
398
+ }
399
+ }
400
+ return row[aLen];
401
+ }
402
+ function normalizedSimilarity(a, b, maxLen = 200) {
403
+ const aTrunc = a.slice(0, maxLen);
404
+ const bTrunc = b.slice(0, maxLen);
405
+ const maxLength = Math.max(aTrunc.length, bTrunc.length);
406
+ if (maxLength === 0) return 1;
407
+ const distance = levenshteinDistance(aTrunc, bTrunc);
408
+ return 1 - distance / maxLength;
409
+ }
410
+
411
+ // src/metrics/retry-density.ts
412
+ function computeRetryDensity(session) {
413
+ const humanTexts = [];
414
+ for (const turn of session.turns) {
415
+ if (!turn.isHumanTurn) continue;
416
+ const text = turn.content.filter((b) => b.type === "text").map((b) => b.text).join(" ");
417
+ if (text.length > 0) humanTexts.push(text);
418
+ }
419
+ if (humanTexts.length < 2) {
420
+ return {
421
+ name: "retry-density",
422
+ value: 0,
423
+ status: "healthy",
424
+ label: "0.00",
425
+ detail: "Not enough user messages to detect retries"
426
+ };
427
+ }
428
+ let retries = 0;
429
+ const pairs = humanTexts.length - 1;
430
+ for (let i = 0; i < pairs; i++) {
431
+ const similarity = normalizedSimilarity(humanTexts[i], humanTexts[i + 1]);
432
+ if (similarity > 0.6) {
433
+ retries++;
434
+ }
435
+ }
436
+ const density = retries / pairs;
437
+ return {
438
+ name: "retry-density",
439
+ value: round5(density),
440
+ status: density <= 0.1 ? "healthy" : density <= 0.25 ? "warning" : "critical",
441
+ label: round5(density).toString()
442
+ };
443
+ }
444
+ function round5(n) {
445
+ return Math.round(n * 100) / 100;
446
+ }
447
+
448
+ // src/metrics/tool-diversity.ts
449
+ function computeToolDiversity(session) {
450
+ const toolCounts = /* @__PURE__ */ new Map();
451
+ for (const turn of session.turns) {
452
+ if (turn.role !== "assistant") continue;
453
+ for (const block of turn.content) {
454
+ if (block.type !== "tool_use") continue;
455
+ const toolBlock = block;
456
+ toolCounts.set(toolBlock.name, (toolCounts.get(toolBlock.name) ?? 0) + 1);
457
+ }
458
+ }
459
+ const uniqueTools = toolCounts.size;
460
+ if (uniqueTools <= 1) {
461
+ return {
462
+ name: "tool-diversity",
463
+ value: uniqueTools === 0 ? null : 0,
464
+ status: uniqueTools === 0 ? "healthy" : "critical",
465
+ label: uniqueTools === 0 ? "N/A" : "0.00",
466
+ detail: uniqueTools === 0 ? "No tool usage in this session" : `Only one tool used: ${[...toolCounts.keys()][0]}`
467
+ };
468
+ }
469
+ const totalCalls = [...toolCounts.values()].reduce((a, b) => a + b, 0);
470
+ const maxEntropy = Math.log2(uniqueTools);
471
+ let entropy = 0;
472
+ for (const count of toolCounts.values()) {
473
+ const p = count / totalCalls;
474
+ entropy -= p * Math.log2(p);
475
+ }
476
+ const normalized = entropy / maxEntropy;
477
+ const sorted = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]);
478
+ const topTool = sorted[0];
479
+ const topPercent = Math.round(topTool[1] / totalCalls * 100);
480
+ const detail = `Most used: ${topTool[0]} (${topPercent}%)`;
481
+ return {
482
+ name: "tool-diversity",
483
+ value: round6(normalized),
484
+ status: normalized >= 0.6 ? "healthy" : normalized >= 0.4 ? "warning" : "critical",
485
+ label: round6(normalized).toString(),
486
+ detail
487
+ };
488
+ }
489
+ function round6(n) {
490
+ return Math.round(n * 100) / 100;
491
+ }
492
+
493
+ // src/metrics/tokens-per-edit.ts
494
+ var EDIT_TOOLS2 = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
495
+ function computeTokensPerEdit(session) {
496
+ let totalOutputTokens = 0;
497
+ let editCount = 0;
498
+ for (const turn of session.turns) {
499
+ if (turn.role !== "assistant") continue;
500
+ if (turn.complete && turn.usage) {
501
+ totalOutputTokens += turn.usage.output_tokens;
502
+ }
503
+ for (const block of turn.content) {
504
+ if (block.type !== "tool_use") continue;
505
+ const toolBlock = block;
506
+ if (EDIT_TOOLS2.has(toolBlock.name)) editCount++;
507
+ }
508
+ }
509
+ if (editCount === 0) {
510
+ return {
511
+ name: "tokens-per-edit",
512
+ value: null,
513
+ status: "healthy",
514
+ label: "N/A",
515
+ detail: "No file modifications in this session"
516
+ };
517
+ }
518
+ const ratio = totalOutputTokens / editCount;
519
+ return {
520
+ name: "tokens-per-edit",
521
+ value: Math.round(ratio),
522
+ status: ratio <= 5e3 ? "healthy" : ratio <= 15e3 ? "warning" : "critical",
523
+ label: Math.round(ratio).toLocaleString("en-US")
524
+ };
525
+ }
526
+
527
+ // src/metrics/grader.ts
528
+ var METRIC_WEIGHTS = [
529
+ {
530
+ compute: computeReadsPerEdit,
531
+ weight: 0.2,
532
+ // 0 reads → 0, 2 reads → 50, 4+ reads → 100
533
+ score: (v) => clamp(v / 4 * 100, 0, 100)
534
+ },
535
+ {
536
+ compute: computeRewriteRatio,
537
+ weight: 0.15,
538
+ // 0 ratio → 100, 0.25 → 50, 0.5+ → 0 (inverted: lower is better)
539
+ score: (v) => clamp((1 - v / 0.5) * 100, 0, 100)
540
+ },
541
+ {
542
+ compute: computeCacheHitRate,
543
+ weight: 0.15,
544
+ // 0% → 0, 50% → 100
545
+ score: (v) => clamp(v / 0.5 * 100, 0, 100)
546
+ },
547
+ {
548
+ compute: computeTaskCompletion,
549
+ weight: 0.15,
550
+ // 0.7 → 0, 0.9 → 50, 1.0 → 100
551
+ score: (v) => clamp((v - 0.7) / 0.3 * 100, 0, 100)
552
+ },
553
+ {
554
+ compute: computeRetryDensity,
555
+ weight: 0.1,
556
+ // 0% → 100, 10% → 60, 25%+ → 0 (inverted)
557
+ score: (v) => clamp((1 - v / 0.25) * 100, 0, 100)
558
+ },
559
+ {
560
+ compute: computeToolDiversity,
561
+ weight: 0.1,
562
+ // 0 → 0, 0.4 → 50, 0.6+ → 100
563
+ score: (v) => clamp(v / 0.6 * 100, 0, 100)
564
+ },
565
+ {
566
+ compute: computeTokensPerEdit,
567
+ weight: 0.15,
568
+ // 5000 → 100, 10000 → 50, 15000+ → 0 (inverted)
569
+ score: (v) => clamp((1 - (v - 5e3) / 1e4) * 100, 0, 100)
570
+ }
571
+ ];
572
+ var GRADE_THRESHOLDS = [
573
+ [97, "A+"],
574
+ [93, "A"],
575
+ [90, "A-"],
576
+ [87, "B+"],
577
+ [83, "B"],
578
+ [80, "B-"],
579
+ [77, "C+"],
580
+ [73, "C"],
581
+ [70, "C-"],
582
+ [67, "D+"],
583
+ [63, "D"],
584
+ [60, "D-"],
585
+ [0, "F"]
586
+ ];
587
+ function gradeSession(session) {
588
+ const metrics = [];
589
+ let weightedSum = 0;
590
+ let totalWeight = 0;
591
+ for (const mw of METRIC_WEIGHTS) {
592
+ const result = mw.compute(session);
593
+ metrics.push(result);
594
+ if (result.value !== null) {
595
+ weightedSum += mw.score(result.value) * mw.weight;
596
+ totalWeight += mw.weight;
597
+ }
598
+ }
599
+ const compositeScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
600
+ const letter = GRADE_THRESHOLDS.find(([threshold]) => compositeScore >= threshold)?.[1] ?? "F";
601
+ return {
602
+ letter,
603
+ score: Math.round(compositeScore),
604
+ metrics
605
+ };
606
+ }
607
+ function clamp(value, min, max) {
608
+ return Math.max(min, Math.min(max, value));
609
+ }
610
+
611
+ // src/reporter/terminal.ts
612
+ import chalk from "chalk";
613
+ import Table from "cli-table3";
614
+
615
+ // src/utils/format.ts
616
+ function formatDuration(ms) {
617
+ const seconds = Math.floor(ms / 1e3);
618
+ if (seconds < 60) return `${seconds}s`;
619
+ const minutes = Math.floor(seconds / 60);
620
+ if (minutes < 60) return `${minutes} min`;
621
+ const hours = Math.floor(minutes / 60);
622
+ const remainingMinutes = minutes % 60;
623
+ if (remainingMinutes === 0) return `${hours}h`;
624
+ return `${hours}h ${remainingMinutes}m`;
625
+ }
626
+ function shortSessionId(id) {
627
+ return id.slice(0, 8);
628
+ }
629
+ function projectNameFromSlug(slug) {
630
+ const parts = slug.split("-").filter(Boolean);
631
+ return parts[parts.length - 1] || slug;
632
+ }
633
+
634
+ // src/reporter/tips.ts
635
+ var TIPS = {
636
+ "reads-per-edit": {
637
+ warning: "Claude is editing with less context. Add 'Always read files before editing' to your CLAUDE.md.",
638
+ critical: "Very low reads before edits. Claude is making blind changes. Consider adding explicit read instructions."
639
+ },
640
+ "rewrite-ratio": {
641
+ warning: "High ratio of full-file rewrites. Add 'Prefer Edit over Write for existing files' to CLAUDE.md.",
642
+ critical: "Claude is rewriting entire files instead of making surgical edits. This wastes tokens and risks data loss."
643
+ },
644
+ "cache-hit-rate": {
645
+ warning: "Cache hit rate is below normal. Sessions may be too short for caching to help.",
646
+ critical: "Very low cache hit rate \u2014 possible cache bug. Try restarting Claude Code or downgrading to a previous version."
647
+ },
648
+ "task-completion": {
649
+ warning: "Claude is occasionally promising actions without following through.",
650
+ critical: "Frequent unfulfilled promises. Claude says it will do things but doesn't. Try breaking tasks into smaller steps."
651
+ },
652
+ "retry-density": {
653
+ warning: "Some user messages look like retries. Claude may be misunderstanding requests.",
654
+ critical: "High retry rate. Users are frequently re-asking. Consider providing more context in prompts."
655
+ },
656
+ "tool-diversity": {
657
+ warning: "Low tool diversity. Claude is over-relying on a narrow set of tools.",
658
+ critical: "Very narrow tool usage. Claude may be stuck in a pattern. Try prompting for specific tool usage."
659
+ },
660
+ "tokens-per-edit": {
661
+ warning: "Tokens per edit is above average. Claude may be verbose without being productive.",
662
+ critical: "Very high token cost per edit. Claude is burning tokens without proportional output."
663
+ }
664
+ };
665
+ function getTip(metric) {
666
+ if (metric.status === "healthy") return null;
667
+ const metricTips = TIPS[metric.name];
668
+ if (!metricTips) return null;
669
+ return metricTips[metric.status] ?? null;
670
+ }
671
+ function getAllTips(metrics) {
672
+ return metrics.map(getTip).filter((tip) => tip !== null);
673
+ }
674
+
675
+ // src/reporter/terminal.ts
676
+ var STATUS_ICONS = {
677
+ healthy: chalk.green("\u2713"),
678
+ warning: chalk.yellow("\u26A0"),
679
+ critical: chalk.red("\u2717")
680
+ };
681
+ var STATUS_LABELS = {
682
+ healthy: chalk.green("healthy"),
683
+ warning: chalk.yellow("warning"),
684
+ critical: chalk.red("critical")
685
+ };
686
+ var METRIC_DISPLAY_NAMES = {
687
+ "reads-per-edit": "Reads/edit",
688
+ "rewrite-ratio": "Rewrite ratio",
689
+ "cache-hit-rate": "Cache hit rate",
690
+ "task-completion": "Task completion",
691
+ "retry-density": "Retry density",
692
+ "tool-diversity": "Tool diversity",
693
+ "tokens-per-edit": "Tokens/useful-edit"
694
+ };
695
+ function renderAuditReport(session, grade) {
696
+ const lines = [];
697
+ lines.push("");
698
+ lines.push(chalk.bold(" cc-audit v1.0.0") + chalk.dim(" \u2014 Claude Code Session Quality Analyzer"));
699
+ lines.push("");
700
+ const sessionInfo = [
701
+ `Session: ${chalk.cyan(shortSessionId(session.id))}`,
702
+ projectNameFromSlug(session.projectSlug),
703
+ formatDuration(session.durationMs),
704
+ session.model
705
+ ].join(chalk.dim(" | "));
706
+ lines.push(` ${sessionInfo}`);
707
+ lines.push("");
708
+ const gradeColor = getGradeColor(grade.letter);
709
+ lines.push(` Overall grade: ${gradeColor(chalk.bold(grade.letter))}`);
710
+ lines.push("");
711
+ const table = new Table({
712
+ head: ["Metric", "Value", "Status"].map((h) => chalk.dim(h)),
713
+ style: { head: [], border: [], "padding-left": 2, "padding-right": 2 },
714
+ chars: {
715
+ top: "\u2500",
716
+ "top-mid": "\u2500",
717
+ "top-left": " ",
718
+ "top-right": "",
719
+ bottom: "\u2500",
720
+ "bottom-mid": "\u2500",
721
+ "bottom-left": " ",
722
+ "bottom-right": "",
723
+ left: " ",
724
+ "left-mid": " ",
725
+ mid: "\u2500",
726
+ "mid-mid": "\u2500",
727
+ right: "",
728
+ "right-mid": "",
729
+ middle: " "
730
+ }
731
+ });
732
+ for (const metric of grade.metrics) {
733
+ const displayName = METRIC_DISPLAY_NAMES[metric.name] ?? metric.name;
734
+ const icon = STATUS_ICONS[metric.status] ?? "";
735
+ table.push([displayName, metric.label, `${icon} ${STATUS_LABELS[metric.status] ?? metric.status}`]);
736
+ }
737
+ lines.push(table.toString());
738
+ const tips = getAllTips(grade.metrics);
739
+ if (tips.length > 0) {
740
+ lines.push("");
741
+ lines.push(chalk.yellow(" Tips:"));
742
+ for (const tip of tips) {
743
+ lines.push(` ${chalk.dim("\u2192")} ${tip}`);
744
+ }
745
+ }
746
+ lines.push("");
747
+ return lines.join("\n");
748
+ }
749
+ function renderTrendReport(results, sessionCount, period) {
750
+ const lines = [];
751
+ lines.push("");
752
+ lines.push(chalk.bold(` Trend report: last ${period}`) + chalk.dim(` (${sessionCount} sessions)`));
753
+ lines.push("");
754
+ const table = new Table({
755
+ head: ["Metric", "Recent avg", "Full avg", "Change", "Status"].map((h) => chalk.dim(h)),
756
+ style: { head: [], border: [], "padding-left": 2, "padding-right": 2 },
757
+ chars: {
758
+ top: "\u2500",
759
+ "top-mid": "\u2500",
760
+ "top-left": " ",
761
+ "top-right": "",
762
+ bottom: "\u2500",
763
+ "bottom-mid": "\u2500",
764
+ "bottom-left": " ",
765
+ "bottom-right": "",
766
+ left: " ",
767
+ "left-mid": " ",
768
+ mid: "\u2500",
769
+ "mid-mid": "\u2500",
770
+ right: "",
771
+ "right-mid": "",
772
+ middle: " "
773
+ }
774
+ });
775
+ for (const result of results) {
776
+ const displayName = METRIC_DISPLAY_NAMES[result.name] ?? result.name;
777
+ const recentStr = result.recentAvg !== null ? result.recentAvg.toFixed(2) : "N/A";
778
+ const fullStr = result.fullAvg !== null ? result.fullAvg.toFixed(2) : "N/A";
779
+ let changeStr = "N/A";
780
+ if (result.changePercent !== null) {
781
+ const arrow = result.changePercent > 0 ? "\u25B2" : result.changePercent < 0 ? "\u25BC" : "";
782
+ changeStr = `${arrow} ${Math.abs(Math.round(result.changePercent))}%`;
783
+ }
784
+ const statusStr = formatRegressionStatus(result.status);
785
+ table.push([displayName, recentStr, fullStr, changeStr, statusStr]);
786
+ }
787
+ lines.push(table.toString());
788
+ lines.push("");
789
+ return lines.join("\n");
790
+ }
791
+ function renderCacheCheckReport(results) {
792
+ const lines = [];
793
+ lines.push("");
794
+ lines.push(chalk.bold(" Cache health check"));
795
+ lines.push("");
796
+ const table = new Table({
797
+ head: ["Session", "Project", "Cache Hit", "Status"].map((h) => chalk.dim(h)),
798
+ style: { head: [], border: [], "padding-left": 2, "padding-right": 2 },
799
+ chars: {
800
+ top: "\u2500",
801
+ "top-mid": "\u2500",
802
+ "top-left": " ",
803
+ "top-right": "",
804
+ bottom: "\u2500",
805
+ "bottom-mid": "\u2500",
806
+ "bottom-left": " ",
807
+ "bottom-right": "",
808
+ left: " ",
809
+ "left-mid": " ",
810
+ mid: "\u2500",
811
+ "mid-mid": "\u2500",
812
+ right: "",
813
+ "right-mid": "",
814
+ middle: " "
815
+ }
816
+ });
817
+ for (const result of results) {
818
+ const hitStr = result.cacheHitRate !== null ? result.cacheHitRate.toFixed(2) : "N/A";
819
+ const statusStr = result.isAnomaly ? chalk.red("\u2717 ANOMALY") : chalk.green("\u2713 normal");
820
+ table.push([
821
+ shortSessionId(result.sessionId),
822
+ projectNameFromSlug(result.projectSlug),
823
+ hitStr,
824
+ statusStr
825
+ ]);
826
+ }
827
+ lines.push(table.toString());
828
+ const anomalies = results.filter((r) => r.isAnomaly);
829
+ if (anomalies.length > 0) {
830
+ lines.push("");
831
+ lines.push(
832
+ chalk.yellow(` \u26A0 ${anomalies.length} session(s) with abnormally low cache hit rate.`)
833
+ );
834
+ for (const a of anomalies) {
835
+ if (a.estimatedInflation) {
836
+ lines.push(
837
+ ` ${chalk.dim("\u2192")} Session ${shortSessionId(a.sessionId)} consumed ~${a.estimatedInflation}x more input tokens than expected.`
838
+ );
839
+ }
840
+ }
841
+ lines.push(
842
+ chalk.dim(" Try: restart Claude Code or downgrade to a previous version.")
843
+ );
844
+ } else {
845
+ lines.push("");
846
+ lines.push(chalk.green(" \u2713 No cache anomalies detected."));
847
+ }
848
+ lines.push("");
849
+ return lines.join("\n");
850
+ }
851
+ function getGradeColor(letter) {
852
+ if (letter.startsWith("A")) return chalk.green;
853
+ if (letter.startsWith("B")) return chalk.cyan;
854
+ if (letter.startsWith("C")) return chalk.yellow;
855
+ return chalk.red;
856
+ }
857
+ function formatRegressionStatus(status) {
858
+ switch (status) {
859
+ case "stable":
860
+ return chalk.green("\u2713 stable");
861
+ case "declining":
862
+ return chalk.yellow("\u26A0 declining");
863
+ case "regression":
864
+ return chalk.red("\u26A0 REGRESSION");
865
+ default:
866
+ return status;
867
+ }
868
+ }
869
+
870
+ // src/reporter/json-reporter.ts
871
+ function formatAuditJson(session, grade) {
872
+ const output = {
873
+ session: {
874
+ id: session.id,
875
+ project: session.projectSlug,
876
+ model: session.model,
877
+ durationMs: session.durationMs,
878
+ startTime: session.startTime
879
+ },
880
+ grade: grade.letter,
881
+ score: grade.score,
882
+ metrics: grade.metrics.map((m) => ({
883
+ name: m.name,
884
+ value: m.value,
885
+ status: m.status,
886
+ label: m.label
887
+ }))
888
+ };
889
+ return JSON.stringify(output, null, 2);
890
+ }
891
+ function formatTrendJson(results) {
892
+ return JSON.stringify({ trend: results }, null, 2);
893
+ }
894
+ function formatCacheCheckJson(results) {
895
+ return JSON.stringify({ cacheCheck: results }, null, 2);
896
+ }
897
+
898
+ // src/commands/audit.ts
899
+ async function runAudit(options) {
900
+ const sessionFile = await getMostRecentSession({
901
+ dataDir: options.dataDir,
902
+ project: options.project
903
+ });
904
+ const records = readJsonl(sessionFile.path);
905
+ const session = await buildSession(
906
+ records,
907
+ sessionFile.sessionId,
908
+ sessionFile.projectSlug
909
+ );
910
+ const grade = gradeSession(session);
911
+ if (options.json) {
912
+ console.log(formatAuditJson(session, grade));
913
+ } else {
914
+ console.log(renderAuditReport(session, grade));
915
+ }
916
+ }
917
+
918
+ // src/anomaly/baseline.ts
919
+ function computeBaselines(grades, recentCount) {
920
+ if (grades.length === 0) return [];
921
+ const recent = grades.slice(0, recentCount);
922
+ const full = grades;
923
+ const metricNames = grades[0].metrics.map((m) => m.name);
924
+ return metricNames.map((name) => {
925
+ const recentValues = extractValues(recent, name);
926
+ const fullValues = extractValues(full, name);
927
+ const recentAvg = average(recentValues);
928
+ const fullAvg = average(fullValues);
929
+ let changePercent = null;
930
+ if (recentAvg !== null && fullAvg !== null && fullAvg !== 0) {
931
+ changePercent = (recentAvg - fullAvg) / Math.abs(fullAvg) * 100;
932
+ }
933
+ return { name, recentAvg, fullAvg, changePercent };
934
+ });
935
+ }
936
+ function extractValues(grades, metricName) {
937
+ const values = [];
938
+ for (const grade of grades) {
939
+ const metric = grade.metrics.find((m) => m.name === metricName);
940
+ if (metric?.value !== null && metric?.value !== void 0) {
941
+ values.push(metric.value);
942
+ }
943
+ }
944
+ return values;
945
+ }
946
+ function average(values) {
947
+ if (values.length === 0) return null;
948
+ return values.reduce((a, b) => a + b, 0) / values.length;
949
+ }
950
+
951
+ // src/anomaly/regression-detector.ts
952
+ var INVERTED_METRICS = /* @__PURE__ */ new Set([
953
+ "rewrite-ratio",
954
+ "retry-density",
955
+ "tokens-per-edit"
956
+ ]);
957
+ function detectRegressions(baselines) {
958
+ return baselines.map((b) => {
959
+ let status = "stable";
960
+ if (b.changePercent !== null) {
961
+ const isInverted = INVERTED_METRICS.has(b.name);
962
+ const badDirection = isInverted ? b.changePercent > 0 : b.changePercent < 0;
963
+ const magnitude = Math.abs(b.changePercent);
964
+ if (badDirection && magnitude > 30) {
965
+ status = "regression";
966
+ } else if (badDirection && magnitude > 10) {
967
+ status = "declining";
968
+ }
969
+ }
970
+ return {
971
+ name: b.name,
972
+ recentAvg: b.recentAvg,
973
+ fullAvg: b.fullAvg,
974
+ changePercent: b.changePercent,
975
+ status
976
+ };
977
+ });
978
+ }
979
+
980
+ // src/utils/duration.ts
981
+ function parseDuration(duration, now = /* @__PURE__ */ new Date()) {
982
+ const match = duration.match(/^(\d+)d$/);
983
+ if (!match) {
984
+ throw new Error(
985
+ `Invalid duration: "${duration}". Use format like "7d", "14d", "30d".`
986
+ );
987
+ }
988
+ const days = parseInt(match[1], 10);
989
+ const result = new Date(now);
990
+ result.setDate(result.getDate() - days);
991
+ return result;
992
+ }
993
+
994
+ // src/commands/trend.ts
995
+ async function runTrend(options) {
996
+ const duration = options.since ?? "7d";
997
+ const sinceDate = parseDuration(duration);
998
+ const sessionFiles = await scanSessions({
999
+ dataDir: options.dataDir,
1000
+ project: options.project,
1001
+ since: sinceDate
1002
+ });
1003
+ if (sessionFiles.length === 0) {
1004
+ console.log(`No sessions found in the last ${duration}.`);
1005
+ return;
1006
+ }
1007
+ const grades = [];
1008
+ for (const sf of sessionFiles) {
1009
+ try {
1010
+ const records = readJsonl(sf.path);
1011
+ const session = await buildSession(records, sf.sessionId, sf.projectSlug);
1012
+ grades.push(gradeSession(session));
1013
+ } catch {
1014
+ }
1015
+ }
1016
+ if (grades.length === 0) {
1017
+ console.log("No valid sessions found to analyze.");
1018
+ return;
1019
+ }
1020
+ const recentCount = Math.max(1, Math.floor(grades.length / 2));
1021
+ const baselines = computeBaselines(grades, recentCount);
1022
+ const regressions = detectRegressions(baselines);
1023
+ if (options.json) {
1024
+ console.log(formatTrendJson(regressions));
1025
+ } else {
1026
+ console.log(renderTrendReport(regressions, grades.length, duration));
1027
+ }
1028
+ }
1029
+
1030
+ // src/anomaly/cache-anomaly.ts
1031
+ var ANOMALY_THRESHOLD = 0.05;
1032
+ var NORMAL_CACHE_RATE = 0.65;
1033
+ function checkCacheAnomaly(session) {
1034
+ const metric = computeCacheHitRate(session);
1035
+ const isAnomaly = metric.value !== null && metric.value < ANOMALY_THRESHOLD;
1036
+ let estimatedInflation = null;
1037
+ if (isAnomaly && metric.value !== null) {
1038
+ estimatedInflation = Math.round(1 / (1 - NORMAL_CACHE_RATE));
1039
+ }
1040
+ return {
1041
+ sessionId: session.id,
1042
+ projectSlug: session.projectSlug,
1043
+ timestamp: session.startTime,
1044
+ cacheHitRate: metric.value,
1045
+ isAnomaly,
1046
+ estimatedInflation
1047
+ };
1048
+ }
1049
+
1050
+ // src/commands/cache-check.ts
1051
+ async function runCacheCheck(options) {
1052
+ const duration = options.since ?? "7d";
1053
+ const sinceDate = parseDuration(duration);
1054
+ const sessionFiles = await scanSessions({
1055
+ dataDir: options.dataDir,
1056
+ since: sinceDate
1057
+ });
1058
+ if (sessionFiles.length === 0) {
1059
+ console.log(`No sessions found in the last ${duration}.`);
1060
+ return;
1061
+ }
1062
+ const results = [];
1063
+ for (const sf of sessionFiles) {
1064
+ try {
1065
+ const records = readJsonl(sf.path);
1066
+ const session = await buildSession(records, sf.sessionId, sf.projectSlug);
1067
+ results.push(checkCacheAnomaly(session));
1068
+ } catch {
1069
+ }
1070
+ }
1071
+ if (results.length === 0) {
1072
+ console.log("No valid sessions found to analyze.");
1073
+ return;
1074
+ }
1075
+ if (options.json) {
1076
+ console.log(formatCacheCheckJson(results));
1077
+ } else {
1078
+ console.log(renderCacheCheckReport(results));
1079
+ }
1080
+ }
1081
+
1082
+ // src/commands/compare.ts
1083
+ import chalk2 from "chalk";
1084
+ import Table2 from "cli-table3";
1085
+ async function runCompare(options) {
1086
+ const projectNames = options.projects.split(",").map((p) => p.trim());
1087
+ const summaries = [];
1088
+ for (const projectFilter of projectNames) {
1089
+ const sessionFiles = await scanSessions({
1090
+ dataDir: options.dataDir,
1091
+ project: projectFilter
1092
+ });
1093
+ if (sessionFiles.length === 0) continue;
1094
+ const grades = [];
1095
+ for (const sf of sessionFiles) {
1096
+ try {
1097
+ const records = readJsonl(sf.path);
1098
+ const session = await buildSession(records, sf.sessionId, sf.projectSlug);
1099
+ grades.push(gradeSession(session));
1100
+ } catch {
1101
+ continue;
1102
+ }
1103
+ }
1104
+ if (grades.length === 0) continue;
1105
+ const avgScore = grades.reduce((s, g) => s + g.score, 0) / grades.length;
1106
+ const metricAvgs = /* @__PURE__ */ new Map();
1107
+ for (const metric of grades[0].metrics) {
1108
+ const values = grades.map((g) => g.metrics.find((m) => m.name === metric.name)?.value).filter((v) => v !== null);
1109
+ if (values.length > 0) {
1110
+ metricAvgs.set(metric.name, values.reduce((a, b) => a + b, 0) / values.length);
1111
+ }
1112
+ }
1113
+ summaries.push({
1114
+ name: projectFilter,
1115
+ sessionCount: grades.length,
1116
+ avgGrade: Math.round(avgScore),
1117
+ avgLetter: getLetterGrade(avgScore),
1118
+ metrics: metricAvgs
1119
+ });
1120
+ }
1121
+ if (summaries.length === 0) {
1122
+ console.log("No matching projects found.");
1123
+ return;
1124
+ }
1125
+ if (options.json) {
1126
+ const jsonOutput = summaries.map((s) => ({
1127
+ project: s.name,
1128
+ sessions: s.sessionCount,
1129
+ grade: s.avgLetter,
1130
+ score: s.avgGrade,
1131
+ metrics: Object.fromEntries(s.metrics)
1132
+ }));
1133
+ console.log(JSON.stringify({ compare: jsonOutput }, null, 2));
1134
+ return;
1135
+ }
1136
+ const lines = [];
1137
+ lines.push("");
1138
+ lines.push(chalk2.bold(" Project comparison"));
1139
+ lines.push("");
1140
+ const head = ["Project", "Sessions", "Grade", ...summaries[0]?.metrics.keys() ?? []].map(
1141
+ (h) => chalk2.dim(h)
1142
+ );
1143
+ const table = new Table2({
1144
+ head,
1145
+ style: { head: [], border: [], "padding-left": 2, "padding-right": 2 },
1146
+ chars: {
1147
+ top: "\u2500",
1148
+ "top-mid": "\u2500",
1149
+ "top-left": " ",
1150
+ "top-right": "",
1151
+ bottom: "\u2500",
1152
+ "bottom-mid": "\u2500",
1153
+ "bottom-left": " ",
1154
+ "bottom-right": "",
1155
+ left: " ",
1156
+ "left-mid": " ",
1157
+ mid: "\u2500",
1158
+ "mid-mid": "\u2500",
1159
+ right: "",
1160
+ "right-mid": "",
1161
+ middle: " "
1162
+ }
1163
+ });
1164
+ for (const summary of summaries) {
1165
+ const row = [
1166
+ summary.name,
1167
+ summary.sessionCount.toString(),
1168
+ summary.avgLetter
1169
+ ];
1170
+ for (const [, value] of summary.metrics) {
1171
+ row.push(value.toFixed(2));
1172
+ }
1173
+ table.push(row);
1174
+ }
1175
+ lines.push(table.toString());
1176
+ lines.push("");
1177
+ console.log(lines.join("\n"));
1178
+ }
1179
+ function getLetterGrade(score) {
1180
+ if (score >= 97) return "A+";
1181
+ if (score >= 93) return "A";
1182
+ if (score >= 90) return "A-";
1183
+ if (score >= 87) return "B+";
1184
+ if (score >= 83) return "B";
1185
+ if (score >= 80) return "B-";
1186
+ if (score >= 77) return "C+";
1187
+ if (score >= 73) return "C";
1188
+ if (score >= 70) return "C-";
1189
+ if (score >= 67) return "D+";
1190
+ if (score >= 63) return "D";
1191
+ if (score >= 60) return "D-";
1192
+ return "F";
1193
+ }
1194
+
1195
+ // src/index.ts
1196
+ var program = new Command();
1197
+ program.name("cc-audit").description("Claude Code session quality analyzer \u2014 grade sessions, detect regressions, catch cache bugs").version("1.0.0");
1198
+ program.command("audit", { isDefault: true }).description("Grade the most recent Claude Code session").option("--json", "Output as JSON").option("--verbose", "Show per-message breakdown").option("--data-dir <path>", "Custom Claude data directory").option("--project <name>", "Filter to a specific project").action(async (options) => {
1199
+ try {
1200
+ await runAudit(options);
1201
+ } catch (error) {
1202
+ handleError(error);
1203
+ }
1204
+ });
1205
+ program.command("trend").description("Analyze quality trends and detect regressions over time").option("--since <duration>", "Time range: 7d, 14d, 30d", "7d").option("--json", "Output as JSON").option("--data-dir <path>", "Custom Claude data directory").option("--project <name>", "Filter to a specific project").action(async (options) => {
1206
+ try {
1207
+ await runTrend(options);
1208
+ } catch (error) {
1209
+ handleError(error);
1210
+ }
1211
+ });
1212
+ program.command("cache-check").description("Detect prompt cache bugs that inflate token costs").option("--since <duration>", "Time range: 7d, 14d, 30d", "7d").option("--json", "Output as JSON").option("--data-dir <path>", "Custom Claude data directory").action(async (options) => {
1213
+ try {
1214
+ await runCacheCheck(options);
1215
+ } catch (error) {
1216
+ handleError(error);
1217
+ }
1218
+ });
1219
+ program.command("compare").description("Compare quality metrics across projects").requiredOption("--projects <names>", "Comma-separated project names").option("--json", "Output as JSON").option("--data-dir <path>", "Custom Claude data directory").option("--since <duration>", "Time range: 7d, 14d, 30d").action(async (options) => {
1220
+ try {
1221
+ await runCompare(options);
1222
+ } catch (error) {
1223
+ handleError(error);
1224
+ }
1225
+ });
1226
+ function handleError(error) {
1227
+ const message = error instanceof Error ? error.message : String(error);
1228
+ console.error(`
1229
+ Error: ${message}
1230
+ `);
1231
+ process.exit(1);
1232
+ }
1233
+ program.parse();
1234
+ //# sourceMappingURL=index.js.map