inspecto 1.0.12 → 1.0.13

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 CHANGED
@@ -68,6 +68,7 @@ Expected: ${projectsDir}`
68
68
  sessionId,
69
69
  projectSlug: projectDir,
70
70
  mtime: fileStat.mtime,
71
+ birthtime: fileStat.birthtime,
71
72
  subagentPaths
72
73
  };
73
74
  } catch {
@@ -256,7 +257,7 @@ function normalizeContent(content) {
256
257
  // src/metrics/reads-per-edit.ts
257
258
  var EDIT_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
258
259
  var READ_TOOL = "Read";
259
- function computeReadsPerEdit(session) {
260
+ function computeReadsPerEdit(session, thresholds) {
260
261
  let readsSinceLastEdit = 0;
261
262
  const ratios = [];
262
263
  for (const turn of session.turns) {
@@ -282,10 +283,11 @@ function computeReadsPerEdit(session) {
282
283
  };
283
284
  }
284
285
  const average2 = ratios.reduce((a, b) => a + b, 0) / ratios.length;
286
+ const { healthy, warning } = thresholds ?? { healthy: 4, warning: 2 };
285
287
  return {
286
288
  name: "reads-per-edit",
287
289
  value: round(average2),
288
- status: average2 >= 4 ? "healthy" : average2 >= 2 ? "warning" : "critical",
290
+ status: average2 >= healthy ? "healthy" : average2 >= warning ? "warning" : "critical",
289
291
  label: round(average2).toString()
290
292
  };
291
293
  }
@@ -294,7 +296,7 @@ function round(n) {
294
296
  }
295
297
 
296
298
  // src/metrics/rewrite-ratio.ts
297
- function computeRewriteRatio(session) {
299
+ function computeRewriteRatio(session, thresholds) {
298
300
  let writes = 0;
299
301
  let edits = 0;
300
302
  for (const turn of session.turns) {
@@ -317,10 +319,11 @@ function computeRewriteRatio(session) {
317
319
  };
318
320
  }
319
321
  const ratio = writes / total;
322
+ const { healthy, warning } = thresholds ?? { healthy: 0.25, warning: 0.5 };
320
323
  return {
321
324
  name: "rewrite-ratio",
322
325
  value: round2(ratio),
323
- status: ratio <= 0.25 ? "healthy" : ratio <= 0.5 ? "warning" : "critical",
326
+ status: ratio <= healthy ? "healthy" : ratio <= warning ? "warning" : "critical",
324
327
  label: round2(ratio).toString()
325
328
  };
326
329
  }
@@ -329,7 +332,7 @@ function round2(n) {
329
332
  }
330
333
 
331
334
  // src/metrics/cache-hit-rate.ts
332
- function computeCacheHitRate(session) {
335
+ function computeCacheHitRate(session, thresholds) {
333
336
  let totalCacheRead = 0;
334
337
  let totalCacheCreation = 0;
335
338
  for (const turn of session.turns) {
@@ -348,10 +351,11 @@ function computeCacheHitRate(session) {
348
351
  };
349
352
  }
350
353
  const rate = totalCacheRead / totalInput;
354
+ const { healthy, warning } = thresholds ?? { healthy: 0.5, warning: 0.2 };
351
355
  return {
352
356
  name: "cache-hit-rate",
353
357
  value: round3(rate),
354
- status: rate >= 0.5 ? "healthy" : rate >= 0.2 ? "warning" : "critical",
358
+ status: rate >= healthy ? "healthy" : rate >= warning ? "warning" : "critical",
355
359
  label: round3(rate).toString()
356
360
  };
357
361
  }
@@ -368,7 +372,7 @@ var INTENT_PATTERNS = [
368
372
  /\bI'll (?:also |then )?(?:fix|add|create|implement|refactor|modify|change|write|edit|update)\b/i,
369
373
  /\bI'm going to\b/i
370
374
  ];
371
- function computeTaskCompletion(session) {
375
+ function computeTaskCompletion(session, thresholds) {
372
376
  const assistantTurns = session.turns.filter(
373
377
  (t) => t.role === "assistant" && t.complete
374
378
  );
@@ -393,10 +397,11 @@ function computeTaskCompletion(session) {
393
397
  };
394
398
  }
395
399
  const rate = 1 - unfulfilledIntents / totalIntents;
400
+ const { healthy, warning } = thresholds ?? { healthy: 0.9, warning: 0.7 };
396
401
  return {
397
402
  name: "task-completion",
398
403
  value: round4(rate),
399
- status: rate >= 0.9 ? "healthy" : rate >= 0.7 ? "warning" : "critical",
404
+ status: rate >= healthy ? "healthy" : rate >= warning ? "warning" : "critical",
400
405
  label: round4(rate).toString()
401
406
  };
402
407
  }
@@ -454,7 +459,7 @@ function normalizedSimilarity(a, b, maxLen = 200) {
454
459
  }
455
460
 
456
461
  // src/metrics/retry-density.ts
457
- function computeRetryDensity(session) {
462
+ function computeRetryDensity(session, thresholds) {
458
463
  const humanTexts = [];
459
464
  for (const turn of session.turns) {
460
465
  if (!turn.isHumanTurn) continue;
@@ -479,10 +484,11 @@ function computeRetryDensity(session) {
479
484
  }
480
485
  }
481
486
  const density = retries / pairs;
487
+ const { healthy, warning } = thresholds ?? { healthy: 0.1, warning: 0.25 };
482
488
  return {
483
489
  name: "retry-density",
484
490
  value: round5(density),
485
- status: density <= 0.1 ? "healthy" : density <= 0.25 ? "warning" : "critical",
491
+ status: density <= healthy ? "healthy" : density <= warning ? "warning" : "critical",
486
492
  label: round5(density).toString()
487
493
  };
488
494
  }
@@ -491,7 +497,7 @@ function round5(n) {
491
497
  }
492
498
 
493
499
  // src/metrics/tool-diversity.ts
494
- function computeToolDiversity(session) {
500
+ function computeToolDiversity(session, thresholds) {
495
501
  const toolCounts = /* @__PURE__ */ new Map();
496
502
  for (const turn of session.turns) {
497
503
  if (turn.role !== "assistant") continue;
@@ -523,10 +529,11 @@ function computeToolDiversity(session) {
523
529
  const topTool = sorted[0];
524
530
  const topPercent = Math.round(topTool[1] / totalCalls * 100);
525
531
  const detail = `Most used: ${topTool[0]} (${topPercent}%)`;
532
+ const { healthy, warning } = thresholds ?? { healthy: 0.6, warning: 0.4 };
526
533
  return {
527
534
  name: "tool-diversity",
528
535
  value: round6(normalized),
529
- status: normalized >= 0.6 ? "healthy" : normalized >= 0.4 ? "warning" : "critical",
536
+ status: normalized >= healthy ? "healthy" : normalized >= warning ? "warning" : "critical",
530
537
  label: round6(normalized).toString(),
531
538
  detail
532
539
  };
@@ -537,7 +544,7 @@ function round6(n) {
537
544
 
538
545
  // src/metrics/tokens-per-edit.ts
539
546
  var EDIT_TOOLS2 = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
540
- function computeTokensPerEdit(session) {
547
+ function computeTokensPerEdit(session, thresholds) {
541
548
  let totalOutputTokens = 0;
542
549
  let editCount = 0;
543
550
  for (const turn of session.turns) {
@@ -561,10 +568,11 @@ function computeTokensPerEdit(session) {
561
568
  };
562
569
  }
563
570
  const ratio = totalOutputTokens / editCount;
571
+ const { healthy, warning } = thresholds ?? { healthy: 5e3, warning: 15e3 };
564
572
  return {
565
573
  name: "tokens-per-edit",
566
574
  value: Math.round(ratio),
567
- status: ratio <= 5e3 ? "healthy" : ratio <= 15e3 ? "warning" : "critical",
575
+ status: ratio <= healthy ? "healthy" : ratio <= warning ? "warning" : "critical",
568
576
  label: Math.round(ratio).toLocaleString("en-US")
569
577
  };
570
578
  }
@@ -611,57 +619,231 @@ function computeSubagentOverhead(session) {
611
619
  };
612
620
  }
613
621
 
614
- // src/metrics/grader.ts
615
- var METRIC_WEIGHTS = [
616
- {
617
- compute: computeReadsPerEdit,
618
- weight: 0.2,
619
- // 0 reads 0, 2 reads → 50, 4+ reads → 100
620
- score: (v) => clamp(v / 4 * 100, 0, 100)
621
- },
622
- {
623
- compute: computeRewriteRatio,
624
- weight: 0.15,
625
- // 0 ratio → 100, 0.25 → 50, 0.5+ → 0 (inverted: lower is better)
626
- score: (v) => clamp((1 - v / 0.5) * 100, 0, 100)
627
- },
628
- {
629
- compute: computeCacheHitRate,
630
- weight: 0.15,
631
- // 0% → 0, 50% → 100
632
- score: (v) => clamp(v / 0.5 * 100, 0, 100)
633
- },
634
- {
635
- compute: computeTaskCompletion,
636
- weight: 0.15,
637
- // 0.7 → 0, 0.9 → 50, 1.0 → 100
638
- score: (v) => clamp((v - 0.7) / 0.3 * 100, 0, 100)
639
- },
640
- {
641
- compute: computeRetryDensity,
642
- weight: 0.1,
643
- // 0% → 100, 10% → 60, 25%+ → 0 (inverted)
644
- score: (v) => clamp((1 - v / 0.25) * 100, 0, 100)
645
- },
646
- {
647
- compute: computeToolDiversity,
648
- weight: 0.05,
649
- // 0 → 0, 0.4 → 50, 0.6+ → 100
650
- score: (v) => clamp(v / 0.6 * 100, 0, 100)
651
- },
652
- {
653
- compute: computeTokensPerEdit,
654
- weight: 0.15,
655
- // 5000 → 100, 10000 → 50, 15000+ → 0 (inverted)
656
- score: (v) => clamp((1 - (v - 5e3) / 1e4) * 100, 0, 100)
657
- },
658
- {
659
- compute: computeSubagentOverhead,
660
- weight: 0.05,
661
- // main ratio 0 → 100, 0.6 → 100 (threshold), 0.8 → 50, 1.0 → 0 (inverted)
662
- score: (v) => clamp((1 - v) / 0.4 * 100, 0, 100)
622
+ // src/metrics/tool-error-rate.ts
623
+ function computeToolErrorRate(session) {
624
+ let totalResults = 0;
625
+ let errorResults = 0;
626
+ for (const turn of session.turns) {
627
+ for (const block of turn.content) {
628
+ if (block.type !== "tool_result") continue;
629
+ const resultBlock = block;
630
+ totalResults++;
631
+ if (resultBlock.is_error === true) errorResults++;
632
+ }
663
633
  }
664
- ];
634
+ if (totalResults === 0) {
635
+ return {
636
+ name: "tool-error-rate",
637
+ value: null,
638
+ status: "healthy",
639
+ label: "N/A",
640
+ detail: "No tool calls in this session"
641
+ };
642
+ }
643
+ const rate = errorResults / totalResults;
644
+ return {
645
+ name: "tool-error-rate",
646
+ value: round7(rate),
647
+ status: rate <= 0.05 ? "healthy" : rate <= 0.15 ? "warning" : "critical",
648
+ label: `${(rate * 100).toFixed(1)}%`
649
+ };
650
+ }
651
+ function round7(n) {
652
+ return Math.round(n * 1e4) / 1e4;
653
+ }
654
+
655
+ // src/metrics/thinking-utilization.ts
656
+ function computeThinkingUtilization(session) {
657
+ const assistantTurns = session.turns.filter((t) => t.role === "assistant");
658
+ const hasAnyToolUse = assistantTurns.some(
659
+ (t) => t.content.some((b) => b.type === "tool_use")
660
+ );
661
+ if (!hasAnyToolUse) {
662
+ return {
663
+ name: "thinking-utilization",
664
+ value: null,
665
+ status: "healthy",
666
+ label: "N/A",
667
+ detail: "No tool use in this session"
668
+ };
669
+ }
670
+ const turnsWithThinking = assistantTurns.filter(
671
+ (t) => t.content.some((b) => b.type === "thinking")
672
+ ).length;
673
+ if (turnsWithThinking === 0) {
674
+ return {
675
+ name: "thinking-utilization",
676
+ value: 0,
677
+ status: "critical",
678
+ label: "0%",
679
+ detail: "No extended thinking blocks detected"
680
+ };
681
+ }
682
+ const ratio = Math.min(turnsWithThinking / assistantTurns.length, 1);
683
+ return {
684
+ name: "thinking-utilization",
685
+ value: round8(ratio),
686
+ status: ratio >= 0.3 ? "healthy" : ratio >= 0.1 ? "warning" : "critical",
687
+ label: `${(ratio * 100).toFixed(1)}%`
688
+ };
689
+ }
690
+ function round8(n) {
691
+ return Math.round(n * 1e4) / 1e4;
692
+ }
693
+
694
+ // src/metrics/mcp-usage.ts
695
+ function computeMcpUsage(session) {
696
+ const assistantTurnsWithTools = session.turns.filter(
697
+ (t) => t.role === "assistant" && t.content.some((b) => b.type === "tool_use")
698
+ );
699
+ if (assistantTurnsWithTools.length === 0) {
700
+ return {
701
+ name: "mcp-usage",
702
+ value: null,
703
+ status: "healthy",
704
+ label: "N/A",
705
+ detail: "No tool use in this session"
706
+ };
707
+ }
708
+ let mcpTurnCount = 0;
709
+ for (const turn of assistantTurnsWithTools) {
710
+ const hasMcpTool = turn.content.some(
711
+ (b) => b.type === "tool_use" && b.name.startsWith("mcp__")
712
+ );
713
+ const hasServerToolUse = (turn.usage?.server_tool_use?.web_search_requests ?? 0) > 0 || (turn.usage?.server_tool_use?.web_fetch_requests ?? 0) > 0;
714
+ if (hasMcpTool || hasServerToolUse) mcpTurnCount++;
715
+ }
716
+ const ratio = mcpTurnCount / assistantTurnsWithTools.length;
717
+ return {
718
+ name: "mcp-usage",
719
+ value: round9(ratio),
720
+ status: "healthy",
721
+ label: `${mcpTurnCount} turn${mcpTurnCount !== 1 ? "s" : ""}`
722
+ };
723
+ }
724
+ function round9(n) {
725
+ return Math.round(n * 1e4) / 1e4;
726
+ }
727
+
728
+ // src/metrics/session-cost.ts
729
+ var PRICE_PER_M = {
730
+ output: 15,
731
+ cacheCreation: 3.75,
732
+ cacheRead: 0.3,
733
+ input: 3
734
+ };
735
+ function computeSessionCost(session, thresholds) {
736
+ let outputTokens = 0;
737
+ let cacheCreationTokens = 0;
738
+ let cacheReadTokens = 0;
739
+ for (const turn of session.turns) {
740
+ if (!turn.usage) continue;
741
+ outputTokens += turn.usage.output_tokens ?? 0;
742
+ cacheCreationTokens += turn.usage.cache_creation_input_tokens ?? 0;
743
+ cacheReadTokens += turn.usage.cache_read_input_tokens ?? 0;
744
+ }
745
+ const totalTokens = outputTokens + cacheCreationTokens + cacheReadTokens;
746
+ if (totalTokens === 0) {
747
+ return {
748
+ name: "session-cost",
749
+ value: null,
750
+ status: "healthy",
751
+ label: "N/A",
752
+ detail: "No token usage data in this session"
753
+ };
754
+ }
755
+ const cost = (outputTokens * PRICE_PER_M.output + cacheCreationTokens * PRICE_PER_M.cacheCreation + cacheReadTokens * PRICE_PER_M.cacheRead) / 1e6;
756
+ const { healthy, warning } = thresholds ?? { healthy: 2, warning: 5 };
757
+ return {
758
+ name: "session-cost",
759
+ value: round10(cost),
760
+ status: cost <= healthy ? "healthy" : cost <= warning ? "warning" : "critical",
761
+ label: `$${cost.toFixed(2)}`
762
+ };
763
+ }
764
+ function round10(n) {
765
+ return Math.round(n * 1e4) / 1e4;
766
+ }
767
+
768
+ // src/metrics/grader.ts
769
+ function buildMetricWeights(config2) {
770
+ const t = config2?.thresholds ?? {};
771
+ const w = config2?.weights ?? {};
772
+ return [
773
+ {
774
+ compute: (session) => computeReadsPerEdit(session, t.readsPerEdit),
775
+ weight: w.readsPerEdit ?? 0.14,
776
+ // 0 reads → 0, 2 reads → 50, 4+ reads → 100
777
+ score: (v) => clamp(v / 4 * 100, 0, 100)
778
+ },
779
+ {
780
+ compute: (session) => computeRewriteRatio(session, t.rewriteRatio),
781
+ weight: w.rewriteRatio ?? 0.11,
782
+ // 0 ratio → 100, 0.25 → 50, 0.5+ → 0 (inverted: lower is better)
783
+ score: (v) => clamp((1 - v / 0.5) * 100, 0, 100)
784
+ },
785
+ {
786
+ compute: (session) => computeCacheHitRate(session, t.cacheHitRate),
787
+ weight: w.cacheHitRate ?? 0.11,
788
+ // 0% → 0, 50% → 100
789
+ score: (v) => clamp(v / 0.5 * 100, 0, 100)
790
+ },
791
+ {
792
+ compute: (session) => computeTaskCompletion(session, t.taskCompletion),
793
+ weight: w.taskCompletion ?? 0.1,
794
+ // 0.7 → 0, 0.9 → 50, 1.0 → 100
795
+ score: (v) => clamp((v - 0.7) / 0.3 * 100, 0, 100)
796
+ },
797
+ {
798
+ compute: (session) => computeRetryDensity(session, t.retryDensity),
799
+ weight: w.retryDensity ?? 0.07,
800
+ // 0% → 100, 10% → 60, 25%+ → 0 (inverted)
801
+ score: (v) => clamp((1 - v / 0.25) * 100, 0, 100)
802
+ },
803
+ {
804
+ compute: (session) => computeToolDiversity(session, t.toolDiversity),
805
+ weight: w.toolDiversity ?? 0.06,
806
+ // 0 → 0, 0.4 → 50, 0.6+ → 100
807
+ score: (v) => clamp(v / 0.6 * 100, 0, 100)
808
+ },
809
+ {
810
+ compute: (session) => computeTokensPerEdit(session, t.tokensPerEdit),
811
+ weight: w.tokensPerEdit ?? 0.11,
812
+ // 5000 → 100, 10000 → 50, 15000+ → 0 (inverted)
813
+ score: (v) => clamp((1 - (v - 5e3) / 1e4) * 100, 0, 100)
814
+ },
815
+ {
816
+ compute: computeSubagentOverhead,
817
+ weight: 0.05,
818
+ // main ratio 0 → 100, 0.6 → 100 (threshold), 0.8 → 50, 1.0 → 0 (inverted)
819
+ score: (v) => clamp((1 - v) / 0.4 * 100, 0, 100)
820
+ },
821
+ {
822
+ compute: computeToolErrorRate,
823
+ weight: 0.08,
824
+ // 0% → 100, 5% → 83, 15% → 50, 30%+ → 0 (inverted: lower is better)
825
+ score: (v) => clamp((1 - v / 0.3) * 100, 0, 100)
826
+ },
827
+ {
828
+ compute: computeThinkingUtilization,
829
+ weight: 0.07,
830
+ // 0% → 0, 10% → 33, 30%+ → 100
831
+ score: (v) => clamp(v / 0.3 * 100, 0, 100)
832
+ },
833
+ {
834
+ compute: computeMcpUsage,
835
+ weight: 0.05,
836
+ // Informational: always contributes max score
837
+ score: (_v) => 100
838
+ },
839
+ {
840
+ compute: (session) => computeSessionCost(session, t.sessionCost),
841
+ weight: 0.05,
842
+ // $0 → 100, $5 → 50, $10+ → 0 (inverted: lower cost is better)
843
+ score: (v) => clamp((1 - v / 10) * 100, 0, 100)
844
+ }
845
+ ];
846
+ }
665
847
  var GRADE_THRESHOLDS = [
666
848
  [97, "A+"],
667
849
  [93, "A"],
@@ -680,11 +862,12 @@ var GRADE_THRESHOLDS = [
680
862
  function gradeLetterFromScore(score) {
681
863
  return GRADE_THRESHOLDS.find(([threshold]) => score >= threshold)?.[1] ?? "F";
682
864
  }
683
- function gradeSession(session) {
865
+ function gradeSession(session, config2) {
866
+ const metricWeights = buildMetricWeights(config2);
684
867
  const metrics = [];
685
868
  let weightedSum = 0;
686
869
  let totalWeight = 0;
687
- for (const mw of METRIC_WEIGHTS) {
870
+ for (const mw of metricWeights) {
688
871
  const result = mw.compute(session);
689
872
  metrics.push(result);
690
873
  if (result.value !== null) {
@@ -756,6 +939,18 @@ var TIPS = {
756
939
  "tokens-per-edit": {
757
940
  warning: "Tokens per edit is above average. Claude may be verbose without being productive.",
758
941
  critical: "Very high token cost per edit. Claude is burning tokens without proportional output."
942
+ },
943
+ "tool-error-rate": {
944
+ warning: "Elevated tool error rate. Claude may be calling tools with invalid arguments or on wrong paths.",
945
+ critical: "High tool error rate. Frequent tool failures indicate misaligned inputs or environment issues."
946
+ },
947
+ "thinking-utilization": {
948
+ warning: "Extended thinking is underutilized. Enable it in your Claude Code settings for complex tasks.",
949
+ critical: "No extended thinking detected on tool-using turns. Complex tasks benefit greatly from thinking blocks."
950
+ },
951
+ "session-cost": {
952
+ warning: "Session cost is above $2. Consider breaking long sessions into smaller focused tasks.",
953
+ critical: "Session cost exceeds $5. Review if Claude is re-reading large files or running redundant operations."
759
954
  }
760
955
  };
761
956
  function getTip(metric) {
@@ -999,13 +1194,119 @@ function formatCacheCheckJson(results) {
999
1194
  return JSON.stringify({ cacheCheck: results }, null, 2);
1000
1195
  }
1001
1196
 
1197
+ // src/reporter/csv-reporter.ts
1198
+ function csvEscape(value) {
1199
+ if (value === null) return "";
1200
+ const str = String(value);
1201
+ if (str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r")) {
1202
+ return `"${str.replace(/"/g, '""')}"`;
1203
+ }
1204
+ return str;
1205
+ }
1206
+ function csvRow(fields) {
1207
+ return fields.map(csvEscape).join(",");
1208
+ }
1209
+ function exportAuditCsv(grade) {
1210
+ const lines = [];
1211
+ lines.push(csvRow(["name", "value", "status", "label"]));
1212
+ for (const metric of grade.metrics) {
1213
+ lines.push(csvRow([metric.name, metric.value, metric.status, metric.label]));
1214
+ }
1215
+ return lines.join("\n");
1216
+ }
1217
+ function exportTrendCsv(results) {
1218
+ const lines = [];
1219
+ lines.push(csvRow(["name", "recentAvg", "fullAvg", "changePercent", "status"]));
1220
+ for (const r of results) {
1221
+ lines.push(csvRow([r.name, r.recentAvg, r.fullAvg, r.changePercent, r.status]));
1222
+ }
1223
+ return lines.join("\n");
1224
+ }
1225
+
1226
+ // src/config/loader.ts
1227
+ import { readFileSync } from "fs";
1228
+ import { join as join3 } from "path";
1229
+ var KNOWN_THRESHOLD_KEYS = /* @__PURE__ */ new Set([
1230
+ "readsPerEdit",
1231
+ "rewriteRatio",
1232
+ "cacheHitRate",
1233
+ "taskCompletion",
1234
+ "retryDensity",
1235
+ "toolDiversity",
1236
+ "tokensPerEdit",
1237
+ "sessionCost"
1238
+ ]);
1239
+ var KNOWN_WEIGHT_KEYS = /* @__PURE__ */ new Set([
1240
+ "readsPerEdit",
1241
+ "rewriteRatio",
1242
+ "cacheHitRate",
1243
+ "taskCompletion",
1244
+ "retryDensity",
1245
+ "toolDiversity",
1246
+ "tokensPerEdit"
1247
+ ]);
1248
+ var KNOWN_TOP_KEYS = /* @__PURE__ */ new Set(["thresholds", "weights", "dataDir", "defaultProject"]);
1249
+ function loadConfig() {
1250
+ const configPath = join3(process.cwd(), ".inspecto.json");
1251
+ let raw;
1252
+ try {
1253
+ raw = readFileSync(configPath, "utf8");
1254
+ } catch {
1255
+ return {};
1256
+ }
1257
+ let parsed;
1258
+ try {
1259
+ parsed = JSON.parse(raw);
1260
+ } catch {
1261
+ process.stderr.write("inspecto: warning: .inspecto.json contains invalid JSON, using defaults\n");
1262
+ return {};
1263
+ }
1264
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1265
+ process.stderr.write("inspecto: warning: .inspecto.json must be a JSON object, using defaults\n");
1266
+ return {};
1267
+ }
1268
+ const config2 = parsed;
1269
+ for (const key of Object.keys(config2)) {
1270
+ if (!KNOWN_TOP_KEYS.has(key)) {
1271
+ process.stderr.write(`inspecto: warning: .inspecto.json unknown key "${key}"
1272
+ `);
1273
+ }
1274
+ }
1275
+ if (config2["thresholds"] && typeof config2["thresholds"] === "object" && !Array.isArray(config2["thresholds"])) {
1276
+ for (const key of Object.keys(config2["thresholds"])) {
1277
+ if (!KNOWN_THRESHOLD_KEYS.has(key)) {
1278
+ process.stderr.write(`inspecto: warning: .inspecto.json unknown thresholds key "${key}"
1279
+ `);
1280
+ }
1281
+ }
1282
+ }
1283
+ if (config2["weights"] && typeof config2["weights"] === "object" && !Array.isArray(config2["weights"])) {
1284
+ for (const key of Object.keys(config2["weights"])) {
1285
+ if (!KNOWN_WEIGHT_KEYS.has(key)) {
1286
+ process.stderr.write(`inspecto: warning: .inspecto.json unknown weights key "${key}"
1287
+ `);
1288
+ }
1289
+ }
1290
+ const weights = config2["weights"];
1291
+ const numericValues = Object.values(weights).filter((v) => typeof v === "number");
1292
+ const sum = numericValues.reduce((a, b) => a + b, 0);
1293
+ if (sum > 1 + 1e-9) {
1294
+ process.stderr.write(
1295
+ `inspecto: warning: weights in .inspecto.json sum to ${sum.toFixed(4)}, which exceeds 1.0
1296
+ `
1297
+ );
1298
+ }
1299
+ }
1300
+ return parsed;
1301
+ }
1302
+
1002
1303
  // src/commands/audit.ts
1003
1304
  var KNOWN_FORMAT_VERSION = "2.1.167";
1004
1305
  async function runAudit(options) {
1005
- const sessionFile = await getMostRecentSession({
1006
- dataDir: options.dataDir,
1007
- project: options.project
1008
- });
1306
+ const config2 = loadConfig();
1307
+ const dataDir = options.dataDir ?? config2.dataDir;
1308
+ const project = options.project ?? config2.defaultProject;
1309
+ const sessionFile = await getMostRecentSession({ dataDir, project });
1009
1310
  const records = readJsonl(sessionFile.path);
1010
1311
  const session = await buildSession(
1011
1312
  records,
@@ -1013,24 +1314,29 @@ async function runAudit(options) {
1013
1314
  sessionFile.projectSlug,
1014
1315
  sessionFile.subagentPaths
1015
1316
  );
1016
- const grade = gradeSession(session);
1017
- if (options.json) {
1317
+ const grade = gradeSession(session, config2);
1318
+ if (options.json || options.format === "json") {
1018
1319
  console.log(formatAuditJson(session, grade));
1019
- return;
1020
- }
1021
- if (session.formatVersion && session.formatVersion !== KNOWN_FORMAT_VERSION) {
1022
- console.log(
1023
- chalk2.yellow(
1024
- `\u26A0 JSONL format version ${session.formatVersion} detected (expected ${KNOWN_FORMAT_VERSION}). Metrics may be inaccurate.`
1025
- )
1026
- );
1027
- }
1028
- if (session.unknownRecordTypes.size > 0) {
1029
- const types = [...session.unknownRecordTypes].sort().join(", ");
1030
- process.stdout.write(chalk2.dim(`Note: skipped unknown record types: ${types}
1320
+ } else if (options.format === "csv") {
1321
+ console.log(exportAuditCsv(grade));
1322
+ } else {
1323
+ if (session.formatVersion && session.formatVersion !== KNOWN_FORMAT_VERSION) {
1324
+ console.log(
1325
+ chalk2.yellow(
1326
+ `\u26A0 JSONL format version ${session.formatVersion} detected (expected ${KNOWN_FORMAT_VERSION}). Metrics may be inaccurate.`
1327
+ )
1328
+ );
1329
+ }
1330
+ if (session.unknownRecordTypes.size > 0) {
1331
+ const types = [...session.unknownRecordTypes].sort().join(", ");
1332
+ process.stdout.write(chalk2.dim(`Note: skipped unknown record types: ${types}
1031
1333
  `));
1334
+ }
1335
+ console.log(renderAuditReport(session, grade));
1336
+ }
1337
+ if (options.fail !== false && grade.score < 67) {
1338
+ process.exitCode = 1;
1032
1339
  }
1033
- console.log(renderAuditReport(session, grade));
1034
1340
  }
1035
1341
 
1036
1342
  // src/anomaly/baseline.ts
@@ -1173,13 +1479,12 @@ function setCachedGrade(sessionPath, mtime, grade) {
1173
1479
  // src/commands/trend.ts
1174
1480
  var CONCURRENCY = 16;
1175
1481
  async function runTrend(options) {
1482
+ const config2 = loadConfig();
1483
+ const dataDir = options.dataDir ?? config2.dataDir;
1484
+ const project = options.project ?? config2.defaultProject;
1176
1485
  const duration = options.since ?? "7d";
1177
1486
  const sinceDate = parseDuration(duration);
1178
- const sessionFiles = await scanSessions({
1179
- dataDir: options.dataDir,
1180
- project: options.project,
1181
- since: sinceDate
1182
- });
1487
+ const sessionFiles = await scanSessions({ dataDir, project, since: sinceDate });
1183
1488
  if (sessionFiles.length === 0) {
1184
1489
  console.log(`No sessions found in the last ${duration}.`);
1185
1490
  return;
@@ -1189,7 +1494,7 @@ async function runTrend(options) {
1189
1494
  if (cached) return cached;
1190
1495
  const records = readJsonl(sf.path);
1191
1496
  const session = await buildSession(records, sf.sessionId, sf.projectSlug, sf.subagentPaths);
1192
- const grade = gradeSession(session);
1497
+ const grade = gradeSession(session, config2);
1193
1498
  setCachedGrade(sf.path, sf.mtime, grade);
1194
1499
  return grade;
1195
1500
  });
@@ -1201,11 +1506,16 @@ async function runTrend(options) {
1201
1506
  const recentCount = Math.max(1, Math.floor(grades.length / 2));
1202
1507
  const baselines = computeBaselines(grades, recentCount);
1203
1508
  const regressions = detectRegressions(baselines);
1204
- if (options.json) {
1509
+ if (options.json || options.format === "json") {
1205
1510
  console.log(formatTrendJson(regressions));
1511
+ } else if (options.format === "csv") {
1512
+ console.log(exportTrendCsv(regressions));
1206
1513
  } else {
1207
1514
  console.log(renderTrendReport(regressions, grades.length, duration));
1208
1515
  }
1516
+ if (options.fail !== false && regressions.some((r) => r.status === "regression")) {
1517
+ process.exitCode = 1;
1518
+ }
1209
1519
  }
1210
1520
 
1211
1521
  // src/anomaly/cache-anomaly.ts
@@ -1257,6 +1567,9 @@ async function runCacheCheck(options) {
1257
1567
  } else {
1258
1568
  console.log(renderCacheCheckReport(results));
1259
1569
  }
1570
+ if (options.fail !== false && results.some((r) => r.isAnomaly)) {
1571
+ process.exitCode = 1;
1572
+ }
1260
1573
  }
1261
1574
 
1262
1575
  // src/commands/compare.ts
@@ -1264,14 +1577,13 @@ import chalk3 from "chalk";
1264
1577
  import Table2 from "cli-table3";
1265
1578
  var CONCURRENCY2 = 16;
1266
1579
  async function runCompare(options) {
1580
+ const config2 = loadConfig();
1581
+ const dataDir = options.dataDir ?? config2.dataDir;
1267
1582
  const projectNames = options.projects.split(",").map((p) => p.trim());
1268
1583
  const summaries = [];
1269
1584
  const projectSummaries = await Promise.all(
1270
1585
  projectNames.map(async (projectFilter) => {
1271
- const sessionFiles = await scanSessions({
1272
- dataDir: options.dataDir,
1273
- project: projectFilter
1274
- });
1586
+ const sessionFiles = await scanSessions({ dataDir, project: projectFilter });
1275
1587
  if (sessionFiles.length === 0) return null;
1276
1588
  const settled = await concurrentSettled(
1277
1589
  sessionFiles,
@@ -1286,7 +1598,7 @@ async function runCompare(options) {
1286
1598
  sf.projectSlug,
1287
1599
  sf.subagentPaths
1288
1600
  );
1289
- const grade = gradeSession(session);
1601
+ const grade = gradeSession(session, config2);
1290
1602
  setCachedGrade(sf.path, sf.mtime, grade);
1291
1603
  return grade;
1292
1604
  }
@@ -1372,37 +1684,269 @@ async function runCompare(options) {
1372
1684
  console.log(lines.join("\n"));
1373
1685
  }
1374
1686
 
1687
+ // src/commands/list.ts
1688
+ import chalk4 from "chalk";
1689
+ import Table3 from "cli-table3";
1690
+ var TABLE_CHARS = {
1691
+ top: "\u2500",
1692
+ "top-mid": "\u2500",
1693
+ "top-left": " ",
1694
+ "top-right": "",
1695
+ bottom: "\u2500",
1696
+ "bottom-mid": "\u2500",
1697
+ "bottom-left": " ",
1698
+ "bottom-right": "",
1699
+ left: " ",
1700
+ "left-mid": " ",
1701
+ mid: "\u2500",
1702
+ "mid-mid": "\u2500",
1703
+ right: "",
1704
+ "right-mid": "",
1705
+ middle: " "
1706
+ };
1707
+ var TABLE_STYLE = { head: [], border: [], "padding-left": 2, "padding-right": 2 };
1708
+ async function runList(options) {
1709
+ const sessionFiles = await scanSessions({
1710
+ dataDir: options.dataDir,
1711
+ project: options.project
1712
+ });
1713
+ if (sessionFiles.length === 0) {
1714
+ console.log("No sessions found.");
1715
+ return;
1716
+ }
1717
+ if (options.sessions || options.project) {
1718
+ const toShow = options.project ? sessionFiles : sessionFiles.slice(0, 20);
1719
+ const lines = [""];
1720
+ if (options.project) {
1721
+ lines.push(chalk4.bold(` Sessions for ${options.project}`) + chalk4.dim(` (${toShow.length})`));
1722
+ } else {
1723
+ lines.push(chalk4.bold(" 20 most recent sessions"));
1724
+ }
1725
+ lines.push("");
1726
+ const table = new Table3({
1727
+ head: ["Session", "Project", "Date", "Duration"].map((h) => chalk4.dim(h)),
1728
+ style: TABLE_STYLE,
1729
+ chars: TABLE_CHARS
1730
+ });
1731
+ for (const sf of toShow) {
1732
+ const dateStr = sf.mtime.toISOString().slice(0, 10);
1733
+ let durStr = "\u2014";
1734
+ if (sf.birthtime && sf.birthtime.getTime() > 0 && sf.birthtime.getTime() < sf.mtime.getTime()) {
1735
+ durStr = formatDuration(sf.mtime.getTime() - sf.birthtime.getTime());
1736
+ }
1737
+ table.push([shortSessionId(sf.sessionId), sf.projectSlug, dateStr, durStr]);
1738
+ }
1739
+ lines.push(table.toString());
1740
+ lines.push("");
1741
+ console.log(lines.join("\n"));
1742
+ } else {
1743
+ const projectMap = /* @__PURE__ */ new Map();
1744
+ for (const sf of sessionFiles) {
1745
+ const existing = projectMap.get(sf.projectSlug);
1746
+ if (!existing) {
1747
+ projectMap.set(sf.projectSlug, { count: 1, lastActive: sf.mtime });
1748
+ } else {
1749
+ existing.count++;
1750
+ if (sf.mtime > existing.lastActive) existing.lastActive = sf.mtime;
1751
+ }
1752
+ }
1753
+ const projects = [...projectMap.entries()].sort(
1754
+ (a, b) => b[1].lastActive.getTime() - a[1].lastActive.getTime()
1755
+ );
1756
+ const lines = [""];
1757
+ lines.push(chalk4.bold(" Projects") + chalk4.dim(` (${projects.length} found)`));
1758
+ lines.push("");
1759
+ const table = new Table3({
1760
+ head: ["Project", "Sessions", "Last active"].map((h) => chalk4.dim(h)),
1761
+ style: TABLE_STYLE,
1762
+ chars: TABLE_CHARS
1763
+ });
1764
+ for (const [slug, info] of projects) {
1765
+ table.push([slug, info.count.toString(), info.lastActive.toISOString().slice(0, 10)]);
1766
+ }
1767
+ lines.push(table.toString());
1768
+ lines.push("");
1769
+ console.log(lines.join("\n"));
1770
+ }
1771
+ }
1772
+
1773
+ // src/commands/config-validate.ts
1774
+ import chalk5 from "chalk";
1775
+ import Table4 from "cli-table3";
1776
+
1777
+ // src/config/types.ts
1778
+ var DEFAULT_THRESHOLDS = {
1779
+ readsPerEdit: { healthy: 4, warning: 2 },
1780
+ rewriteRatio: { healthy: 0.25, warning: 0.4 },
1781
+ cacheHitRate: { healthy: 0.5, warning: 0.3 },
1782
+ taskCompletion: { healthy: 0.9, warning: 0.7 },
1783
+ retryDensity: { healthy: 0.1, warning: 0.2 },
1784
+ toolDiversity: { healthy: 0.6, warning: 0.4 },
1785
+ tokensPerEdit: { healthy: 5e3, warning: 1e4 },
1786
+ sessionCost: { healthy: 2, warning: 5 }
1787
+ };
1788
+ var DEFAULT_WEIGHTS = {
1789
+ readsPerEdit: 0.14,
1790
+ rewriteRatio: 0.11,
1791
+ cacheHitRate: 0.11,
1792
+ taskCompletion: 0.1,
1793
+ retryDensity: 0.07,
1794
+ toolDiversity: 0.06,
1795
+ tokensPerEdit: 0.11
1796
+ };
1797
+
1798
+ // src/commands/config-validate.ts
1799
+ async function runConfigValidate() {
1800
+ const config2 = loadConfig();
1801
+ const hasConfig = Object.keys(config2).length > 0;
1802
+ console.log("");
1803
+ if (!hasConfig) {
1804
+ console.log(chalk5.dim(" No .inspecto.json found in current directory. Showing all defaults.\n"));
1805
+ } else {
1806
+ console.log(chalk5.bold(" .inspecto.json") + chalk5.dim(" \u2014 effective configuration\n"));
1807
+ }
1808
+ const thresholdTable = new Table4({
1809
+ head: ["Metric", "healthy", "warning", "Source"].map((h) => chalk5.dim(h)),
1810
+ style: { head: [], border: [], "padding-left": 2, "padding-right": 2 },
1811
+ chars: {
1812
+ top: "\u2500",
1813
+ "top-mid": "\u2500",
1814
+ "top-left": " ",
1815
+ "top-right": "",
1816
+ bottom: "\u2500",
1817
+ "bottom-mid": "\u2500",
1818
+ "bottom-left": " ",
1819
+ "bottom-right": "",
1820
+ left: " ",
1821
+ "left-mid": " ",
1822
+ mid: "\u2500",
1823
+ "mid-mid": "\u2500",
1824
+ right: "",
1825
+ "right-mid": "",
1826
+ middle: " "
1827
+ }
1828
+ });
1829
+ const thresholdEntries = [
1830
+ ["readsPerEdit", "reads-per-edit"],
1831
+ ["rewriteRatio", "rewrite-ratio"],
1832
+ ["cacheHitRate", "cache-hit-rate"],
1833
+ ["taskCompletion", "task-completion"],
1834
+ ["retryDensity", "retry-density"],
1835
+ ["toolDiversity", "tool-diversity"],
1836
+ ["tokensPerEdit", "tokens-per-edit"],
1837
+ ["sessionCost", "session-cost"]
1838
+ ];
1839
+ for (const [key, label] of thresholdEntries) {
1840
+ const override = config2.thresholds?.[key];
1841
+ const def = DEFAULT_THRESHOLDS[key];
1842
+ const effective = { ...def, ...override };
1843
+ const source = override ? chalk5.green("config") : chalk5.dim("default");
1844
+ thresholdTable.push([label, String(effective.healthy), String(effective.warning), source]);
1845
+ }
1846
+ console.log(chalk5.bold(" Thresholds"));
1847
+ console.log(thresholdTable.toString());
1848
+ console.log("");
1849
+ const weightTable = new Table4({
1850
+ head: ["Metric", "Weight", "Source"].map((h) => chalk5.dim(h)),
1851
+ style: { head: [], border: [], "padding-left": 2, "padding-right": 2 },
1852
+ chars: {
1853
+ top: "\u2500",
1854
+ "top-mid": "\u2500",
1855
+ "top-left": " ",
1856
+ "top-right": "",
1857
+ bottom: "\u2500",
1858
+ "bottom-mid": "\u2500",
1859
+ "bottom-left": " ",
1860
+ "bottom-right": "",
1861
+ left: " ",
1862
+ "left-mid": " ",
1863
+ mid: "\u2500",
1864
+ "mid-mid": "\u2500",
1865
+ right: "",
1866
+ "right-mid": "",
1867
+ middle: " "
1868
+ }
1869
+ });
1870
+ const weightEntries = [
1871
+ ["readsPerEdit", "reads-per-edit"],
1872
+ ["rewriteRatio", "rewrite-ratio"],
1873
+ ["cacheHitRate", "cache-hit-rate"],
1874
+ ["taskCompletion", "task-completion"],
1875
+ ["retryDensity", "retry-density"],
1876
+ ["toolDiversity", "tool-diversity"],
1877
+ ["tokensPerEdit", "tokens-per-edit"]
1878
+ ];
1879
+ let configuredWeightSum = 0;
1880
+ for (const [key] of weightEntries) {
1881
+ configuredWeightSum += config2.weights?.[key] ?? DEFAULT_WEIGHTS[key];
1882
+ }
1883
+ for (const [key, label] of weightEntries) {
1884
+ const override = config2.weights?.[key];
1885
+ const effective = override ?? DEFAULT_WEIGHTS[key];
1886
+ const source = override !== void 0 ? chalk5.green("config") : chalk5.dim("default");
1887
+ weightTable.push([label, effective.toFixed(2), source]);
1888
+ }
1889
+ console.log(chalk5.bold(" Weights") + chalk5.dim(` (configurable sum: ${configuredWeightSum.toFixed(2)})`));
1890
+ console.log(weightTable.toString());
1891
+ console.log("");
1892
+ if (config2.dataDir || config2.defaultProject) {
1893
+ console.log(chalk5.bold(" Other settings"));
1894
+ if (config2.dataDir) {
1895
+ console.log(` ${chalk5.dim("dataDir")} ${config2.dataDir} ${chalk5.green("(config)")}`);
1896
+ }
1897
+ if (config2.defaultProject) {
1898
+ console.log(` ${chalk5.dim("defaultProject")} ${config2.defaultProject} ${chalk5.green("(config)")}`);
1899
+ }
1900
+ console.log("");
1901
+ }
1902
+ }
1903
+
1375
1904
  // src/index.ts
1376
1905
  var program = new Command();
1377
1906
  program.name("inspecto").description("Claude Code session quality analyzer \u2014 grade sessions, detect regressions, catch cache bugs").version(VERSION);
1378
- 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) => {
1907
+ program.command("audit", { isDefault: true }).description("Grade the most recent Claude Code session").option("--json", "Output as JSON").option("--format <format>", "Output format: json, csv").option("--verbose", "Show per-message breakdown").option("--data-dir <path>", "Custom Claude data directory").option("--project <name>", "Filter to a specific project").option("--no-fail", "Always exit 0, even for D/F grades").action(async (options) => {
1379
1908
  try {
1380
1909
  await runAudit(options);
1381
1910
  } catch (error) {
1382
1911
  handleError(error);
1383
1912
  }
1384
1913
  });
1385
- 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) => {
1914
+ 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("--format <format>", "Output format: json, csv").option("--data-dir <path>", "Custom Claude data directory").option("--project <name>", "Filter to a specific project").option("--no-fail", "Always exit 0, even on regressions").action(async (options) => {
1386
1915
  try {
1387
1916
  await runTrend(options);
1388
1917
  } catch (error) {
1389
1918
  handleError(error);
1390
1919
  }
1391
1920
  });
1392
- 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) => {
1921
+ 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").option("--no-fail", "Always exit 0, even when anomalies are detected").action(async (options) => {
1393
1922
  try {
1394
1923
  await runCacheCheck(options);
1395
1924
  } catch (error) {
1396
1925
  handleError(error);
1397
1926
  }
1398
1927
  });
1399
- 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) => {
1928
+ 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").option("--no-fail", "No-op (compare always exits 0)").action(async (options) => {
1400
1929
  try {
1401
1930
  await runCompare(options);
1402
1931
  } catch (error) {
1403
1932
  handleError(error);
1404
1933
  }
1405
1934
  });
1935
+ program.command("list").description("List discovered projects and sessions").option("--sessions", "Show 20 most recent sessions instead of projects").option("--project <name>", "Filter to sessions for a specific project").option("--data-dir <path>", "Custom Claude data directory").action(async (options) => {
1936
+ try {
1937
+ await runList(options);
1938
+ } catch (error) {
1939
+ handleError(error);
1940
+ }
1941
+ });
1942
+ var config = program.command("config").description("Manage inspecto configuration");
1943
+ config.command("validate").description("Show effective configuration merged from .inspecto.json and defaults").action(async () => {
1944
+ try {
1945
+ await runConfigValidate();
1946
+ } catch (error) {
1947
+ handleError(error);
1948
+ }
1949
+ });
1406
1950
  var cache = program.command("cache").description("Manage the inspecto grade cache");
1407
1951
  cache.command("clear").description("Delete the grade cache file (~/.claude/inspecto-cache.db)").action(async () => {
1408
1952
  try {