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.
- package/LICENSE +21 -0
- package/README.md +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- 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
|
+
}
|