fapony 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,1019 @@
1
+ // src/stats/data.ts — StatsData shape + getStatsData()
2
+
3
+ import { readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ import { type Event, openDb, type Run } from "../db/index.js";
7
+ import { loadConfig } from "../db/load.js";
8
+ import { enrichGateWindows } from "../gates.js";
9
+ import { avg, minutesBetween } from "../math.js";
10
+ import { REASON_CODES, REGIME_CODES } from "../mcp/types.js";
11
+ import {
12
+ isPassFamily,
13
+ qualityScore,
14
+ VERDICT_GRADES,
15
+ type VerdictGrade,
16
+ } from "../parse.js";
17
+ import {
18
+ type PassiveUsageResult,
19
+ readClaudeCodeUsage,
20
+ readCodexUsage,
21
+ readPassiveUsage,
22
+ readZcodeUsage,
23
+ } from "../session/index.js";
24
+
25
+ // Walks events per run in order and pairs up spawn→route (executor time)
26
+ // and route→gate (review turnaround) per round, since one run row can span
27
+ // multiple rounds (spawn/commit/route/gate repeating).
28
+ function stageMinutes(events: Event[]): { exec: number[]; review: number[] } {
29
+ const exec: number[] = [];
30
+ const review: number[] = [];
31
+ let spawnTs: string | null = null;
32
+ let routeTs: string | null = null;
33
+
34
+ for (const e of events) {
35
+ if (e.kind === "spawn") spawnTs = e.ts;
36
+ else if (e.kind === "route") {
37
+ if (spawnTs) exec.push(minutesBetween(spawnTs, e.ts));
38
+ routeTs = e.ts;
39
+ } else if (e.kind === "gate") {
40
+ if (routeTs) review.push(minutesBetween(routeTs, e.ts));
41
+ routeTs = null;
42
+ }
43
+ }
44
+ return { exec, review };
45
+ }
46
+
47
+ // --- Per-gate enrichment: model attribution (read-time join) ---
48
+
49
+ interface EnrichedGate {
50
+ runId: number;
51
+ verdict: string;
52
+ model: string | null;
53
+ provider: string | null;
54
+ client: string | null;
55
+ agent: string | null;
56
+ /** How `model` was resolved — "inferred" is a guess, not a declaration. */
57
+ modelSource: "spawn" | "session_id" | "inferred" | null;
58
+ /** Session the token totals belong to — dedupe key, never summed per gate. */
59
+ sessionId: string | null;
60
+ /** Total input tokens for the session (null when unknown or spawn-based). */
61
+ tokensInput: number | null;
62
+ /** Total output tokens for the session (null when unknown or spawn-based). */
63
+ tokensOutput: number | null;
64
+ }
65
+
66
+ /**
67
+ * Read-time join (SPEC-verdict-stats): per gate, model comes only from
68
+ * kind='spawn' events in (prevGateId, gateId) of the same run — per-round,
69
+ * never cumulative. Windowing lives in enrichGateWindows (src/gates.ts).
70
+ */
71
+ function enrichGates(
72
+ events: Event[],
73
+ worktreeByRun?: Map<number, string>,
74
+ ): EnrichedGate[] {
75
+ return enrichGateWindows(events, worktreeByRun).map((w) => ({
76
+ runId: w.runId,
77
+ verdict: w.verdict,
78
+ model: w.model,
79
+ provider: w.provider,
80
+ client: w.client,
81
+ agent: w.agent,
82
+ modelSource: w.modelSource,
83
+ sessionId: w.sessionId,
84
+ tokensInput: w.tokensInput,
85
+ tokensOutput: w.tokensOutput,
86
+ }));
87
+ }
88
+
89
+ /**
90
+ * Count un-shipped plan files in a worktree's planDir.
91
+ *
92
+ * Reads the *target repo's own* fapony.config.json for `paths.planDir` — the
93
+ * central config's worktrees map is optional and usually absent, and each repo
94
+ * picks its own plan dir (vela uses apps/vela/plan, not .fapony/plan).
95
+ *
96
+ * Returns null — never 0 — when the path isn't a readable directory, so a
97
+ * sentinel row like "mcp-external" renders as "—" instead of claiming
98
+ * "nothing pending", which would be a lie.
99
+ */
100
+ export function countPendingPlans(worktree: string): number | null {
101
+ if (!worktree.startsWith("/")) return null;
102
+ let planDir = ".fapony/plan";
103
+ try {
104
+ const cfg = JSON.parse(
105
+ readFileSync(join(worktree, "fapony.config.json"), "utf8"),
106
+ ) as { paths?: { planDir?: unknown } };
107
+ if (typeof cfg.paths?.planDir === "string" && cfg.paths.planDir)
108
+ planDir = cfg.paths.planDir;
109
+ } catch {
110
+ // no config (or unreadable/malformed) — fall back to the scaffold default
111
+ }
112
+ try {
113
+ return readdirSync(join(worktree, planDir)).filter((f) => f.endsWith(".md"))
114
+ .length;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // --- Cross-run knowledge queries (PLAN-project-health-context §2) ---
121
+ //
122
+ // Pure functions over already-loaded runs/events — no extra SQL, read-only.
123
+ // reason_code comes from gate event data: `reason_code` field (patched by
124
+ // verdict_submit) with fallback to the `[reason_code]` note prefix that
125
+ // gateOnce writes. Only non-pass gates count (recurring failure signature).
126
+
127
+ export interface ReasonCodeCount {
128
+ worktree: string;
129
+ reason: string;
130
+ count: number;
131
+ }
132
+
133
+ export interface PlanBreakdown {
134
+ plan: string;
135
+ runs: number;
136
+ passed: number;
137
+ escalated: number;
138
+ /** Worktrees that have at least one run with this plan (sorted). */
139
+ worktrees: string[];
140
+ }
141
+
142
+ export interface EscalatedRun {
143
+ id: number;
144
+ worktree: string;
145
+ plan: string | null;
146
+ round: number;
147
+ }
148
+
149
+ export interface BestPassing {
150
+ plan: string;
151
+ worktree: string;
152
+ }
153
+
154
+ /** maxRounds from config (default 2) — the round-cap signal (CLAUDE.md #2). */
155
+ export function resolveMaxRounds(): number {
156
+ try {
157
+ const mr = loadConfig().review?.maxRounds;
158
+ return typeof mr === "number" && mr >= 0 ? mr : 2;
159
+ } catch {
160
+ return 2;
161
+ }
162
+ }
163
+
164
+ /** Reason code on a gate event, regardless of pass/fail (for note surfacing). */
165
+ function eventReasonCode(data: string | null): string | null {
166
+ if (!data) return null;
167
+ try {
168
+ const d = JSON.parse(data) as { reason_code?: unknown; note?: unknown };
169
+ if (
170
+ typeof d.reason_code === "string" &&
171
+ (REASON_CODES as readonly string[]).includes(d.reason_code)
172
+ )
173
+ return d.reason_code;
174
+ // Fallback: gateOnce writes `[reason_code]` note prefix via verdict_submit.
175
+ if (typeof d.note === "string") {
176
+ const m = /^\[([a-z_]+)\]/.exec(d.note);
177
+ if (m && (REASON_CODES as readonly string[]).includes(m[1])) return m[1];
178
+ }
179
+ return null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /** Reason code on a non-pass gate event only (byReasonCode KPI — fail signal). */
186
+ function gateReason(data: string | null): string | null {
187
+ if (!data) return null;
188
+ try {
189
+ const d = JSON.parse(data) as { verdict?: unknown };
190
+ if (
191
+ typeof d.verdict === "string" &&
192
+ (d.verdict === "pass" || d.verdict.startsWith("pass-"))
193
+ )
194
+ return null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ return eventReasonCode(data);
199
+ }
200
+
201
+ export interface FileRisk {
202
+ worktree: string;
203
+ file: string;
204
+ /** Gate verdicts that listed this file. */
205
+ gates: number;
206
+ /** Of those, non-pass-family verdicts. */
207
+ fails: number;
208
+ /** reason_code of the most recent failing gate on this file. */
209
+ lastReason: string | null;
210
+ }
211
+
212
+ /**
213
+ * Per-file risk: how often a file appeared in a gate verdict, and how often
214
+ * that verdict was non-pass. Reads files[] already stored on gate events —
215
+ * no new table, no new write path.
216
+ *
217
+ * Counts are "touches that were graded", not edits: a file only shows up here
218
+ * once someone submitted a verdict naming it, so absence means unmeasured,
219
+ * never safe. Read a row as a prior, not a score — at gates=1 it is one
220
+ * anecdote.
221
+ */
222
+ function getFileRisk(runs: Run[], events: Event[]): FileRisk[] {
223
+ const wtByRun = new Map(runs.map((r) => [r.id, r.worktree]));
224
+ const map = new Map<
225
+ string,
226
+ {
227
+ worktree: string;
228
+ file: string;
229
+ gates: number;
230
+ fails: number;
231
+ lastReason: string | null;
232
+ }
233
+ >();
234
+ for (const e of events) {
235
+ if (e.kind !== "gate") continue;
236
+ let verdict: string | null = null;
237
+ let files: string[] = [];
238
+ try {
239
+ const d = JSON.parse(e.data ?? "{}") as {
240
+ verdict?: unknown;
241
+ files?: unknown;
242
+ };
243
+ if (typeof d.verdict === "string" && VERDICT_GRADES.has(d.verdict))
244
+ verdict = d.verdict;
245
+ if (Array.isArray(d.files))
246
+ files = d.files.filter(
247
+ (f): f is string => typeof f === "string" && !!f,
248
+ );
249
+ } catch {
250
+ continue; // unparseable gate data — nothing to attribute
251
+ }
252
+ if (!verdict || files.length === 0) continue;
253
+ const wt = wtByRun.get(e.run_id) ?? "(unknown)";
254
+ const failed = !isPassFamily(verdict);
255
+ const reason = failed ? eventReasonCode(e.data) : null;
256
+ for (const file of files) {
257
+ const key = `${wt}\u0000${file}`;
258
+ let b = map.get(key);
259
+ if (!b) {
260
+ b = { worktree: wt, file, gates: 0, fails: 0, lastReason: null };
261
+ map.set(key, b);
262
+ }
263
+ b.gates++;
264
+ if (failed) {
265
+ b.fails++;
266
+ // events arrive oldest-first, so the last write wins = most recent.
267
+ if (reason) b.lastReason = reason;
268
+ }
269
+ }
270
+ }
271
+ return [...map.values()].sort(
272
+ (a, b) =>
273
+ b.fails - a.fails ||
274
+ b.gates - a.gates ||
275
+ (a.file < b.file ? -1 : a.file > b.file ? 1 : 0),
276
+ );
277
+ }
278
+
279
+ /** Top reason_code per worktree, sorted by count desc (spec §2 query). */
280
+ function getReasonCodeBreakdown(
281
+ runs: Run[],
282
+ events: Event[],
283
+ ): ReasonCodeCount[] {
284
+ const wtByRun = new Map(runs.map((r) => [r.id, r.worktree]));
285
+ const counts = new Map<string, Map<string, number>>();
286
+ for (const e of events) {
287
+ if (e.kind !== "gate") continue;
288
+ const reason = gateReason(e.data);
289
+ if (!reason) continue;
290
+ const wt = wtByRun.get(e.run_id) ?? "(unknown)";
291
+ let inner = counts.get(wt);
292
+ if (!inner) {
293
+ inner = new Map();
294
+ counts.set(wt, inner);
295
+ }
296
+ inner.set(reason, (inner.get(reason) ?? 0) + 1);
297
+ }
298
+ const out: ReasonCodeCount[] = [];
299
+ for (const [worktree, inner] of counts)
300
+ for (const [reason, count] of inner) out.push({ worktree, reason, count });
301
+ return out.sort((a, b) => b.count - a.count);
302
+ }
303
+
304
+ export interface RecentVerdictNote {
305
+ worktree: string;
306
+ reason: string;
307
+ note: string;
308
+ ts: string;
309
+ files?: string[];
310
+ }
311
+
312
+ /**
313
+ * Most recent gate notes with actual text, ANY verdict (spec §2 knowledge-
314
+ * accumulation extra) — unlike byReasonCode (fail-only KPI), a pass-adequate
315
+ * note still carries signal ("worked around X"). Sorted newest first, capped
316
+ * at `limit`.
317
+ *
318
+ * `limit` is a collection cap, not a display cap: callers filter this list
319
+ * (by worktree, by files[]) and slice it themselves, so pass enough to filter
320
+ * over — see the getStatsData call site.
321
+ */
322
+ function getRecentVerdictNotes(
323
+ runs: Run[],
324
+ events: Event[],
325
+ limit = 3,
326
+ ): RecentVerdictNote[] {
327
+ const wtByRun = new Map(runs.map((r) => [r.id, r.worktree]));
328
+ const out: RecentVerdictNote[] = [];
329
+ // events is oldest→first per typical read order; walk backwards for recency.
330
+ for (let i = events.length - 1; i >= 0; i--) {
331
+ const e = events[i];
332
+ if (e.kind !== "gate") continue;
333
+ const reason = eventReasonCode(e.data);
334
+ if (!reason) continue; // no recognizable reason_code
335
+ let note = "";
336
+ try {
337
+ const d = JSON.parse(e.data ?? "{}") as { note?: unknown };
338
+ if (typeof d.note === "string") note = d.note;
339
+ } catch {
340
+ // unparseable — skip note text, keep looking
341
+ }
342
+ note = note.replace(/^\[[a-z_]+\]\s*/, "").trim();
343
+ if (!note) continue; // no free-text note beyond the reason_code tag
344
+ // Extract files[] stored in gate event data (added by step 3 of
345
+ // PLAN-loop-and-savings). When present, enables file-scoped filtering
346
+ // in project_health_context.
347
+ let files: string[] | undefined;
348
+ try {
349
+ const d2 = JSON.parse(e.data ?? "{}") as { files?: unknown };
350
+ if (Array.isArray(d2.files) && d2.files.length > 0) {
351
+ files = d2.files.filter((f): f is string => typeof f === "string");
352
+ }
353
+ } catch {
354
+ // no files field — that's fine
355
+ }
356
+ out.push({
357
+ worktree: wtByRun.get(e.run_id) ?? "(unknown)",
358
+ reason,
359
+ note,
360
+ ts: e.ts,
361
+ ...(files ? { files } : {}),
362
+ });
363
+ if (out.length >= limit) break;
364
+ }
365
+ return out;
366
+ }
367
+
368
+ /** Per-plan totals with pass + escalation counts. */
369
+ export function getPlanBreakdown(
370
+ runs: Run[],
371
+ maxRounds: number,
372
+ ): PlanBreakdown[] {
373
+ const map = new Map<
374
+ string,
375
+ { runs: number; passed: number; escalated: number; worktrees: Set<string> }
376
+ >();
377
+ for (const r of runs) {
378
+ const plan = r.plan ?? "(no plan)";
379
+ let b = map.get(plan);
380
+ if (!b) {
381
+ b = { runs: 0, passed: 0, escalated: 0, worktrees: new Set() };
382
+ map.set(plan, b);
383
+ }
384
+ b.runs++;
385
+ if (r.status === "passed") b.passed++;
386
+ if (r.round > maxRounds) b.escalated++;
387
+ b.worktrees.add(r.worktree);
388
+ }
389
+ return [...map.entries()]
390
+ .map(([plan, b]) => ({
391
+ plan,
392
+ runs: b.runs,
393
+ passed: b.passed,
394
+ escalated: b.escalated,
395
+ worktrees: [...b.worktrees].sort(),
396
+ }))
397
+ .sort((a, b) => b.runs - a.runs);
398
+ }
399
+
400
+ export interface PlanLastVerdict {
401
+ plan: string;
402
+ runs: number;
403
+ lastVerdict: string;
404
+ /** null for a pass-family last verdict (gateReason only flags non-pass). */
405
+ lastReasonCode: string | null;
406
+ escalated: boolean;
407
+ }
408
+
409
+ /**
410
+ * Most recent gate verdict per plan string, for `plan_list` (mcp/tools/plans.ts)
411
+ * to join filesystem plan files against real run history — "2 runs, last:
412
+ * fail(spec_gap)" instead of a bare directory listing.
413
+ */
414
+ export function getLastVerdictByPlan(
415
+ runs: Run[],
416
+ events: Event[],
417
+ maxRounds: number,
418
+ ): PlanLastVerdict[] {
419
+ const runById = new Map(runs.map((r) => [r.id, r]));
420
+ const runCounts = new Map<string, number>();
421
+ const escalatedPlans = new Set<string>();
422
+ for (const r of runs) {
423
+ if (!r.plan) continue;
424
+ runCounts.set(r.plan, (runCounts.get(r.plan) ?? 0) + 1);
425
+ if (r.round > maxRounds) escalatedPlans.add(r.plan);
426
+ }
427
+
428
+ const lastGateByPlan = new Map<
429
+ string,
430
+ { ts: string; verdict: string; reason: string | null }
431
+ >();
432
+ for (const e of events) {
433
+ if (e.kind !== "gate" || !e.data) continue;
434
+ const plan = runById.get(e.run_id)?.plan;
435
+ if (!plan) continue;
436
+ let verdict: string | null = null;
437
+ try {
438
+ const d = JSON.parse(e.data) as { verdict?: unknown };
439
+ if (typeof d.verdict === "string") verdict = d.verdict;
440
+ } catch {
441
+ continue;
442
+ }
443
+ if (!verdict) continue;
444
+ const prev = lastGateByPlan.get(plan);
445
+ if (!prev || e.ts >= prev.ts) {
446
+ lastGateByPlan.set(plan, {
447
+ ts: e.ts,
448
+ verdict,
449
+ reason: gateReason(e.data),
450
+ });
451
+ }
452
+ }
453
+
454
+ return [...runCounts.keys()].map((plan) => {
455
+ const last = lastGateByPlan.get(plan);
456
+ return {
457
+ plan,
458
+ runs: runCounts.get(plan) ?? 0,
459
+ lastVerdict: last?.verdict ?? "(no gate yet)",
460
+ lastReasonCode: last?.reason ?? null,
461
+ escalated: escalatedPlans.has(plan),
462
+ };
463
+ });
464
+ }
465
+
466
+ /** Runs past the round cap — plan-quality signal, not code (CLAUDE.md #2). */
467
+ function getEscalatedRuns(runs: Run[], maxRounds: number): EscalatedRun[] {
468
+ return runs
469
+ .filter((r) => r.round > maxRounds)
470
+ .map((r) => ({
471
+ id: r.id,
472
+ worktree: r.worktree,
473
+ plan: r.plan,
474
+ round: r.round,
475
+ }))
476
+ .sort((a, b) => a.id - b.id);
477
+ }
478
+
479
+ /** Plans that passed at round 1 — worth reusing as a template (spec §2). */
480
+ function getBestPassing(runs: Run[], events: Event[]): BestPassing[] {
481
+ const passRunIds = new Set<number>();
482
+ for (const e of events) {
483
+ if (e.kind !== "gate" || !e.data) continue;
484
+ try {
485
+ const d = JSON.parse(e.data) as { verdict?: unknown };
486
+ if (
487
+ typeof d.verdict === "string" &&
488
+ (d.verdict === "pass" || d.verdict.startsWith("pass-"))
489
+ )
490
+ passRunIds.add(e.run_id);
491
+ } catch {
492
+ // unparseable — skip
493
+ }
494
+ }
495
+ return runs
496
+ .filter((r) => r.round <= 1 && r.plan !== null && passRunIds.has(r.id))
497
+ .map((r) => ({ plan: r.plan as string, worktree: r.worktree }))
498
+ .sort((a, b) => (a.plan < b.plan ? -1 : 1));
499
+ }
500
+
501
+ export interface StatsData {
502
+ /** Worktree these numbers describe; null = every project in the db. */
503
+ scope: string | null;
504
+ runs: {
505
+ total: number;
506
+ byStatus: Record<string, number>;
507
+ passRate: number;
508
+ stallRate: number;
509
+ avgRounds: number;
510
+ avgMinutes: number;
511
+ };
512
+ stages: {
513
+ exec: { avg: number; count: number };
514
+ review: { avg: number; count: number };
515
+ };
516
+ byModel: Array<{
517
+ worktree: string;
518
+ client: string;
519
+ provider: string;
520
+ model: string;
521
+ agent: string;
522
+ gateCount: number;
523
+ /** Non-pass-family verdicts in this bucket. */
524
+ fails: number;
525
+ /** fails / gateCount — the per-model question nothing else can answer. */
526
+ failRate: number;
527
+ avgQuality: number;
528
+ /** Total input tokens across sessions attributed to this model (null when none recorded). */
529
+ tokensInput: number | null;
530
+ /** Total output tokens across sessions attributed to this model (null when none recorded). */
531
+ tokensOutput: number | null;
532
+ /** Pass-family gates in this bucket — denominator for tokensPerPass. */
533
+ passes: number;
534
+ /** (tokensInput + tokensOutput) / passes — null when passes=0 or no tokens. */
535
+ tokensPerPass: number | null;
536
+ }>;
537
+ /** How many gates got their model by inference vs. a declared session_id. */
538
+ modelAttribution: { inferred: number; declared: number; none: number };
539
+ byGrade: Array<{
540
+ grade: string;
541
+ count: number;
542
+ }>;
543
+ byWorktree: Array<{
544
+ worktree: string;
545
+ runs: number;
546
+ passed: number;
547
+ stalled: number;
548
+ /** Un-shipped plan files in that repo's planDir, or null when uncountable. */
549
+ pending: number | null;
550
+ }>;
551
+ /** Cross-run knowledge (PLAN-project-health-context §2) — additive, always present. */
552
+ byReasonCode: ReasonCodeCount[];
553
+ byPlan: PlanBreakdown[];
554
+ escalatedRuns: EscalatedRun[];
555
+ bestPassing: BestPassing[];
556
+ recentVerdictNotes: RecentVerdictNote[];
557
+ /** Per-file gate/fail counts (risk heatmap), worst first. */
558
+ byFile: FileRisk[];
559
+ /** planned vs dove-in split: plan != null → planned, plan == null → no-plan. */
560
+ byPlanMode: Array<{
561
+ worktree: string;
562
+ hasPlan: boolean;
563
+ model: string;
564
+ gates: number;
565
+ fails: number;
566
+ failRate: number;
567
+ avgQuality: number;
568
+ tokensInput: number | null;
569
+ tokensOutput: number | null;
570
+ /** Pass-family gates in this bucket — denominator for tokensPerPass. */
571
+ passes: number;
572
+ /** (tokensInput + tokensOutput) / passes — null when passes=0 or no tokens. */
573
+ tokensPerPass: number | null;
574
+ }>;
575
+ /** regime × model split (old gates with no regime sit in the "—" row). */
576
+ byRegime: Array<{
577
+ worktree: string;
578
+ regime: string;
579
+ model: string;
580
+ gates: number;
581
+ fails: number;
582
+ failRate: number;
583
+ avgQuality: number;
584
+ tokensInput: number | null;
585
+ tokensOutput: number | null;
586
+ /** Pass-family gates in this bucket — denominator for tokensPerPass. */
587
+ passes: number;
588
+ /** (tokensInput + tokensOutput) / passes — null when passes=0 or no tokens. */
589
+ tokensPerPass: number | null;
590
+ }>;
591
+ usage: PassiveUsageResult;
592
+ /** ZCode passive usage (when ~/.zcode/cli/db/db.sqlite exists). */
593
+ zcodeUsage?: PassiveUsageResult | null;
594
+ /** Claude Code passive usage (when ~/.claude/projects/ exists). */
595
+ claudeCodeUsage?: PassiveUsageResult | null;
596
+ /** Codex passive usage (when ~/.codex/sessions/ exists). */
597
+ codexUsage?: PassiveUsageResult | null;
598
+ /** ISO timestamp of the most recent run creation (for freshness display). */
599
+ latestRunAt: string;
600
+ }
601
+
602
+ /**
603
+ * Charge a session's token totals to a bucket exactly once.
604
+ *
605
+ * Tokens are a per-session total, and one session routinely produces several
606
+ * gates (measured here: 35 sessions behind 58 gates, up to 5 gates in one).
607
+ * Summing per gate would multiply that session's tokens by its gate count —
608
+ * unevenly across models, so the ranking itself would be wrong.
609
+ */
610
+ function addSessionTokens(
611
+ bucket: { seen: Set<string>; tokensInput: number; tokensOutput: number },
612
+ g: {
613
+ sessionId: string | null;
614
+ tokensInput: number | null;
615
+ tokensOutput: number | null;
616
+ },
617
+ ): void {
618
+ if (!g.sessionId || bucket.seen.has(g.sessionId)) return;
619
+ bucket.seen.add(g.sessionId);
620
+ if (g.tokensInput !== null) bucket.tokensInput += g.tokensInput;
621
+ if (g.tokensOutput !== null) bucket.tokensOutput += g.tokensOutput;
622
+ }
623
+
624
+ /**
625
+ * tokens/pass — retry tax ที่มองเห็นได้ (SPEC-cost-per-pass).
626
+ *
627
+ * ตัวหารคือ pass-family gate ไม่ใช่ run: bucket เป็น gate อยู่แล้ว และงานที่
628
+ * "จบ" คือ gate ที่ผ่าน · token เป็นยอดต่อ session (dedupe แล้ว) ไม่ใช่ต่อ gate —
629
+ * session เดียวออกหลาย gate ได้ ถ้า sum ต่อ gate จะคูณเกินไม่เท่ากันทุกโมเดล
630
+ *
631
+ * `passes=0` หรือ token รวม 0 → null (ไม่ใช่ Infinity/NaN/0): "วัดไม่ได้" ต้อง
632
+ * แยกจาก "ฟรี" และห้ามหารศูนย์ · cost/pass ยังไม่มีใน v1 — ไม่มี cache split
633
+ * (tokensInput คือ fresh+cache_read+cache_write รวมกัน) จึงคิดราคาไม่ได้โดยไม่เดา
634
+ */
635
+ function tokensPerPass(
636
+ passes: number,
637
+ tokensInput: number,
638
+ tokensOutput: number,
639
+ ): number | null {
640
+ if (passes <= 0) return null;
641
+ const total = tokensInput + tokensOutput;
642
+ return total > 0 ? total / passes : null;
643
+ }
644
+
645
+ /**
646
+ * KPIs across runs, scoped to one worktree unless `worktree` is omitted.
647
+ *
648
+ * Scoping happens here, at the source, rather than per table: every downstream
649
+ * number (pass rate, by-model, regime, plan mode) then agrees on which runs it
650
+ * is describing. Mixing projects silently is the failure mode worth designing
651
+ * against — a TS/React app and a Bun CLI are different work, and an average
652
+ * over both answers a question nobody asked while looking like it answered
653
+ * "in this project". Callers pass the scope; `null` worktree means all of them
654
+ * and is reported as such (see `scope` on the returned object).
655
+ */
656
+ export function getStatsData(worktree?: string): StatsData {
657
+ const db = openDb();
658
+ try {
659
+ const runs = (
660
+ worktree
661
+ ? db
662
+ .prepare("SELECT * FROM runs WHERE worktree = ? ORDER BY id")
663
+ .all(worktree)
664
+ : db.prepare("SELECT * FROM runs ORDER BY id").all()
665
+ ) as Run[];
666
+ const runIds = new Set(runs.map((r) => r.id));
667
+ const events = (
668
+ db.prepare("SELECT * FROM events ORDER BY run_id, id").all() as Event[]
669
+ ).filter((e) => !worktree || runIds.has(e.run_id));
670
+
671
+ // --- Runs summary ---
672
+ const byStatus: Record<string, number> = {};
673
+ for (const r of runs) byStatus[r.status] = (byStatus[r.status] ?? 0) + 1;
674
+
675
+ // Pass rate is computed from verdicts, not run status. Status is set BY
676
+ // the verdict, so scoring status counted the same fact twice and let
677
+ // ungraded rows distort it: 32 runs from the deleted CLI loop carry no
678
+ // gate at all (see History — kept as a record, not scored), and a `fail`
679
+ // leaves a run non-terminal, so a status-based rate never saw one.
680
+ // Denominator: runs with any verdict. Numerator: those whose LAST
681
+ // verdict is pass-family (a fail that was later fixed counts as passed).
682
+ const lastVerdictByRun = new Map<number, string>();
683
+ for (const e of events) {
684
+ if (e.kind !== "gate" || !e.data) continue;
685
+ try {
686
+ const d = JSON.parse(e.data) as { verdict?: unknown };
687
+ if (typeof d.verdict === "string" && VERDICT_GRADES.has(d.verdict))
688
+ lastVerdictByRun.set(e.run_id, d.verdict);
689
+ } catch {
690
+ // unparseable gate data — not a verdict
691
+ }
692
+ }
693
+ const graded = [...lastVerdictByRun.values()];
694
+ const passed = runs.filter((r) => r.status === "passed");
695
+
696
+ const passRate = graded.length
697
+ ? graded.filter((v) => isPassFamily(v)).length / graded.length
698
+ : 0;
699
+ // Stalls are a status-only condition (no verdict is ever submitted for
700
+ // one), so this stays over terminal rows — but only graded ones, so the
701
+ // loop-era rows do not dilute it.
702
+ const terminal = runs.filter(
703
+ (r) =>
704
+ ["passed", "stopped", "stalled"].includes(r.status) &&
705
+ lastVerdictByRun.has(r.id),
706
+ );
707
+ const stallRate = terminal.length
708
+ ? (byStatus.stalled ?? 0) / terminal.length
709
+ : 0;
710
+ const avgRounds = passed.length
711
+ ? passed.reduce((s, r) => s + r.round, 0) / passed.length
712
+ : 0;
713
+ const avgMinutes = passed.length
714
+ ? passed.reduce(
715
+ (s, r) => s + minutesBetween(r.created_at, r.updated_at),
716
+ 0,
717
+ ) / passed.length
718
+ : 0;
719
+
720
+ // --- Stages ---
721
+ const eventsByRun: Record<number, Event[]> = {};
722
+ for (const e of events) (eventsByRun[e.run_id] ??= []).push(e);
723
+
724
+ const execAll: number[] = [];
725
+ const reviewAll: number[] = [];
726
+ for (const es of Object.values(eventsByRun)) {
727
+ const { exec, review } = stageMinutes(es);
728
+ execAll.push(...exec);
729
+ reviewAll.push(...review);
730
+ }
731
+
732
+ // --- Gate enrichment ---
733
+ const wtByRun = new Map(runs.map((r) => [r.id, r.worktree]));
734
+ const enriched = enrichGates(events, wtByRun);
735
+
736
+ // Group by worktree+client+provider+model+agent — the same model name on two
737
+ // providers is two different things. Unknown dimension → "—" (never ""
738
+ // or "(unknown)").
739
+ const modelMap: Record<
740
+ string,
741
+ {
742
+ worktree: string;
743
+ client: string;
744
+ provider: string;
745
+ model: string;
746
+ agent: string;
747
+ gateCount: number;
748
+ fails: number;
749
+ passes: number;
750
+ qualities: number[];
751
+ seen: Set<string>;
752
+ tokensInput: number;
753
+ tokensOutput: number;
754
+ }
755
+ > = {};
756
+ for (const g of enriched) {
757
+ const wt = wtByRun.get(g.runId) ?? "(unknown)";
758
+ const client = g.client ?? "—";
759
+ const provider = g.provider ?? "—";
760
+ const model = g.model ?? "—";
761
+ const agent = g.agent ?? "—";
762
+ const key = [wt, client, provider, model, agent].join("\0");
763
+ const bucket = (modelMap[key] ??= {
764
+ worktree: wt,
765
+ client,
766
+ provider,
767
+ model,
768
+ agent,
769
+ gateCount: 0,
770
+ fails: 0,
771
+ passes: 0,
772
+ qualities: [],
773
+ seen: new Set(),
774
+ tokensInput: 0,
775
+ tokensOutput: 0,
776
+ });
777
+ bucket.gateCount++;
778
+ if (g.verdict && !isPassFamily(g.verdict)) bucket.fails++;
779
+ if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
780
+ const grade = g.verdict as VerdictGrade;
781
+ if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
782
+ addSessionTokens(bucket, g);
783
+ }
784
+ const byModel = Object.values(modelMap)
785
+ .map((b) => ({
786
+ worktree: b.worktree,
787
+ client: b.client,
788
+ provider: b.provider,
789
+ model: b.model,
790
+ agent: b.agent,
791
+ gateCount: b.gateCount,
792
+ fails: b.fails,
793
+ failRate: b.gateCount ? b.fails / b.gateCount : 0,
794
+ avgQuality: b.qualities.length ? avg(b.qualities) : 0,
795
+ tokensInput: b.tokensInput || null,
796
+ tokensOutput: b.tokensOutput || null,
797
+ passes: b.passes,
798
+ tokensPerPass: tokensPerPass(b.passes, b.tokensInput, b.tokensOutput),
799
+ }))
800
+ .sort((a, b) => b.gateCount - a.gateCount);
801
+
802
+ const modelAttribution = { inferred: 0, declared: 0, none: 0 };
803
+ for (const g of enriched) {
804
+ if (g.modelSource === "inferred") modelAttribution.inferred++;
805
+ else if (g.modelSource === null) modelAttribution.none++;
806
+ else modelAttribution.declared++;
807
+ }
808
+
809
+ const gradeMap: Record<string, number> = {};
810
+ for (const g of enriched) {
811
+ const gr = g.verdict || "(unknown)";
812
+ gradeMap[gr] = (gradeMap[gr] ?? 0) + 1;
813
+ }
814
+ const byGrade = Object.entries(gradeMap)
815
+ .map(([grade, count]) => ({ grade, count }))
816
+ .sort((a, b) => b.count - a.count);
817
+
818
+ const wtMap: Record<
819
+ string,
820
+ { runs: number; passed: number; stalled: number }
821
+ > = {};
822
+ for (const r of runs) {
823
+ const b = (wtMap[r.worktree] ??= { runs: 0, passed: 0, stalled: 0 });
824
+ b.runs++;
825
+ if (r.status === "passed") b.passed++;
826
+ if (r.status === "stalled") b.stalled++;
827
+ }
828
+ const byWorktree = Object.entries(wtMap)
829
+ .map(([worktree, b]) => ({
830
+ worktree,
831
+ ...b,
832
+ pending: countPendingPlans(worktree),
833
+ }))
834
+ .sort((a, b) => b.runs - a.runs);
835
+
836
+ // --- byPlanMode: planned (has plan) vs dove-in (no plan) × model ---
837
+ const runPlanMap = new Map(runs.map((r) => [r.id, r.plan]));
838
+ const planModeMap: Record<
839
+ string,
840
+ {
841
+ worktree: string;
842
+ hasPlan: boolean;
843
+ model: string;
844
+ gates: number;
845
+ fails: number;
846
+ passes: number;
847
+ qualities: number[];
848
+ seen: Set<string>;
849
+ tokensInput: number;
850
+ tokensOutput: number;
851
+ }
852
+ > = {};
853
+ for (const g of enriched) {
854
+ const wt = wtByRun.get(g.runId) ?? "(unknown)";
855
+ const hasPlan = (runPlanMap.get(g.runId) ?? null) !== null;
856
+ const model = g.model ?? "—";
857
+ const key = `${wt}\0${hasPlan}\0${model}`;
858
+ const bucket = (planModeMap[key] ??= {
859
+ worktree: wt,
860
+ hasPlan,
861
+ model,
862
+ gates: 0,
863
+ fails: 0,
864
+ passes: 0,
865
+ qualities: [],
866
+ seen: new Set<string>(),
867
+ tokensInput: 0,
868
+ tokensOutput: 0,
869
+ });
870
+ bucket.gates++;
871
+ if (g.verdict && !isPassFamily(g.verdict)) bucket.fails++;
872
+ if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
873
+ const grade = g.verdict as VerdictGrade;
874
+ if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
875
+ addSessionTokens(bucket, g);
876
+ }
877
+ const byPlanMode = Object.values(planModeMap)
878
+ .map((b) => ({
879
+ worktree: b.worktree,
880
+ hasPlan: b.hasPlan,
881
+ model: b.model,
882
+ gates: b.gates,
883
+ fails: b.fails,
884
+ failRate: b.gates ? b.fails / b.gates : 0,
885
+ avgQuality: b.qualities.length ? avg(b.qualities) : 0,
886
+ tokensInput: b.tokensInput || null,
887
+ tokensOutput: b.tokensOutput || null,
888
+ passes: b.passes,
889
+ tokensPerPass: tokensPerPass(b.passes, b.tokensInput, b.tokensOutput),
890
+ }))
891
+ .sort((a, b) => b.gates - a.gates);
892
+
893
+ // --- byRegime: regime × model (old gates with no regime → "—" row) ---
894
+ // Pre-compute regime per run from gate events (first valid regime wins).
895
+ const regimeByRun = new Map<number, string>();
896
+ for (const e of events) {
897
+ if (e.kind !== "gate" || !e.data) continue;
898
+ if (regimeByRun.has(e.run_id)) continue;
899
+ try {
900
+ const d = JSON.parse(e.data) as { regime?: unknown };
901
+ if (
902
+ typeof d.regime === "string" &&
903
+ (REGIME_CODES as readonly string[]).includes(d.regime)
904
+ ) {
905
+ regimeByRun.set(e.run_id, d.regime);
906
+ }
907
+ } catch {
908
+ // unparseable — skip
909
+ }
910
+ }
911
+ const regimeMap: Record<
912
+ string,
913
+ {
914
+ worktree: string;
915
+ regime: string;
916
+ model: string;
917
+ gates: number;
918
+ fails: number;
919
+ passes: number;
920
+ qualities: number[];
921
+ seen: Set<string>;
922
+ tokensInput: number;
923
+ tokensOutput: number;
924
+ }
925
+ > = {};
926
+ for (const g of enriched) {
927
+ const wt = wtByRun.get(g.runId) ?? "(unknown)";
928
+ const regime = regimeByRun.get(g.runId) ?? "—";
929
+ const model = g.model ?? "—";
930
+ const key = `${wt}\0${regime}\0${model}`;
931
+ const bucket = (regimeMap[key] ??= {
932
+ worktree: wt,
933
+ regime,
934
+ model,
935
+ gates: 0,
936
+ fails: 0,
937
+ passes: 0,
938
+ qualities: [],
939
+ seen: new Set<string>(),
940
+ tokensInput: 0,
941
+ tokensOutput: 0,
942
+ });
943
+ bucket.gates++;
944
+ if (g.verdict && !isPassFamily(g.verdict)) bucket.fails++;
945
+ if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
946
+ const grade = g.verdict as VerdictGrade;
947
+ if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
948
+ addSessionTokens(bucket, g);
949
+ }
950
+ const byRegime = Object.values(regimeMap)
951
+ .map((b) => ({
952
+ worktree: b.worktree,
953
+ regime: b.regime,
954
+ model: b.model,
955
+ gates: b.gates,
956
+ fails: b.fails,
957
+ failRate: b.gates ? b.fails / b.gates : 0,
958
+ avgQuality: b.qualities.length ? avg(b.qualities) : 0,
959
+ tokensInput: b.tokensInput || null,
960
+ tokensOutput: b.tokensOutput || null,
961
+ passes: b.passes,
962
+ tokensPerPass: tokensPerPass(b.passes, b.tokensInput, b.tokensOutput),
963
+ }))
964
+ .sort((a, b) => b.gates - a.gates);
965
+
966
+ const usage = readPassiveUsage();
967
+ const zcodeUsage = readZcodeUsage();
968
+ const claudeCodeUsage = readClaudeCodeUsage();
969
+ const codexUsage = readCodexUsage();
970
+
971
+ const maxRounds = resolveMaxRounds();
972
+
973
+ // Latest run creation timestamp (for freshness display in reports)
974
+ const latestRunAt = runs.length
975
+ ? runs.reduce((a, b) => (a.created_at > b.created_at ? a : b)).created_at
976
+ : "";
977
+
978
+ return {
979
+ scope: worktree ?? null,
980
+ runs: {
981
+ total: runs.length,
982
+ byStatus,
983
+ passRate,
984
+ stallRate,
985
+ avgRounds,
986
+ avgMinutes,
987
+ },
988
+ stages: {
989
+ exec: { avg: avg(execAll), count: execAll.length },
990
+ review: { avg: avg(reviewAll), count: reviewAll.length },
991
+ },
992
+ byModel,
993
+ modelAttribution,
994
+ byGrade,
995
+ byWorktree,
996
+ byReasonCode: getReasonCodeBreakdown(runs, events),
997
+ byPlan: getPlanBreakdown(runs, maxRounds),
998
+ escalatedRuns: getEscalatedRuns(runs, maxRounds),
999
+ bestPassing: getBestPassing(runs, events),
1000
+ // 50, not the display cap of 3: project_health_context filters this
1001
+ // list by worktree and files[] before slicing, so a cap of 3 here would
1002
+ // throw away the very notes a file-scoped query is looking for.
1003
+ recentVerdictNotes: getRecentVerdictNotes(runs, events, 50),
1004
+ byFile: getFileRisk(runs, events),
1005
+ byPlanMode,
1006
+ byRegime,
1007
+ usage,
1008
+ zcodeUsage: zcodeUsage.session_count > 0 ? zcodeUsage : null,
1009
+ claudeCodeUsage:
1010
+ claudeCodeUsage.session_count > 0 ? claudeCodeUsage : null,
1011
+ codexUsage: codexUsage.session_count > 0 ? codexUsage : null,
1012
+ latestRunAt,
1013
+ };
1014
+ } finally {
1015
+ // The MCP server is a long-lived stdio process — polling tools must not
1016
+ // leak one SQLite handle per call.
1017
+ db.close();
1018
+ }
1019
+ }