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,584 @@
1
+ // src/stats/format.ts — formatStatsText() for CLI + MCP text mode
2
+
3
+ import {
4
+ type ImputedModel,
5
+ imputeResult,
6
+ loadPrices,
7
+ type PriceTable,
8
+ } from "../price/index.js";
9
+ import type { ModelBreakdown, PassiveUsageResult } from "../session/index.js";
10
+ import type { StatsData } from "./data.js";
11
+
12
+ /**
13
+ * One "by model" line. provider is part of the identity, not decoration:
14
+ * OpenCode records the same id under different providers (mimo-v2.5 on
15
+ * opencode-go and on xiaomi are two rows), so printing the id alone renders
16
+ * them as one duplicated-looking model. session_count is printed because a
17
+ * row can legitimately be all zeros — 145 big-pickle sessions recorded no
18
+ * tokens at all — and without it a 0/0 line reads like a parse failure.
19
+ */
20
+ /**
21
+ * Input tokens as actually billed. Cache reads and cache writes ARE input —
22
+ * `tokens_input` alone is only the uncached remainder, and printing it renders a
23
+ * coding agent as having read less than it wrote (Claude Code showed 750k in /
24
+ * 64.6M out, which is impossible). The three stay separate in the readers on
25
+ * purpose: they bill at different rates, so pricing needs them apart. Summing
26
+ * belongs here, at the point of display.
27
+ */
28
+ function fmtIn(input: number, cacheRead: number, cacheWrite: number): string {
29
+ const cached = cacheRead + cacheWrite;
30
+ const total = input + cached;
31
+ return cached > 0
32
+ ? `${total.toLocaleString()} in (${cached.toLocaleString()} cached)`
33
+ : `${total.toLocaleString()} in`;
34
+ }
35
+
36
+ function modelLine(
37
+ m: ModelBreakdown,
38
+ withCost: boolean,
39
+ imp?: ImputedModel,
40
+ ): string {
41
+ const name = `${m.provider ? `${m.provider}/` : ""}${m.model || "(no model id)"}`;
42
+ let cost = "";
43
+ if (withCost) cost = ` ($${m.cost.toFixed(4)})`;
44
+ else if (imp && imp.status === "priced")
45
+ cost = ` (~$${imp.imputed_cost.toFixed(4)} list-price)`;
46
+ return (
47
+ ` ${name}: ${m.session_count} sessions, ` +
48
+ `${fmtIn(m.tokens_input, m.tokens_cache_read, m.tokens_cache_write)} / ` +
49
+ `${m.tokens_output.toLocaleString()} out${cost}`
50
+ );
51
+ }
52
+
53
+ /**
54
+ * บรรทัด list-price equivalent ต่อท้ายแต่ละ usage section — หน่วยเดียวที่
55
+ * เทียบข้าม client ได้ (client ที่ไม่บันทึก cost มีราคาติดตรงนี้)
56
+ * ราคา list ไม่ใช่เงินที่จ่ายจริง · unpriced แยกออกมาให้เห็น ไม่รวมใน 0
57
+ */
58
+ function imputedLines(
59
+ result: PassiveUsageResult,
60
+ prices: PriceTable | null,
61
+ ): { map: Map<string, ImputedModel>; lines: string[] } {
62
+ const map = new Map<string, ImputedModel>();
63
+ if (result.session_count === 0) return { map, lines: [] };
64
+ if (!prices) {
65
+ return {
66
+ map,
67
+ lines: [
68
+ ` list-price equivalent: — (run \`fapony price-scan\` to price ${result.session_count} sessions)`,
69
+ ],
70
+ };
71
+ }
72
+ const s = imputeResult(result, prices);
73
+ for (const m of s.by_model) map.set(`${m.provider}\0${m.model}`, m);
74
+ const parts = [
75
+ `~$${s.total_imputed.toFixed(4)} over ${s.priced_sessions} priced sessions`,
76
+ ];
77
+ if (s.free_sessions > 0) parts.push(`${s.free_sessions} free`);
78
+ if (s.unpriced_sessions > 0)
79
+ parts.push(
80
+ `unpriced: ${s.unpriced_sessions} sessions / ${s.unpriced_tokens.toLocaleString()} tokens`,
81
+ );
82
+ return { map, lines: [` list-price equivalent: ${parts.join(" · ")}`] };
83
+ }
84
+
85
+ function fmtRate(r: number): string {
86
+ return `${(r * 100).toFixed(0)}%`;
87
+ }
88
+
89
+ function fmtTokens(n: number | null): string {
90
+ if (n === null || n === 0) return "—";
91
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
92
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
93
+ return String(n);
94
+ }
95
+
96
+ /** Shorten a worktree path to its basename for table display. */
97
+ function shortWt(wt: string): string {
98
+ const parts = wt.replace(/\/$/, "").split("/");
99
+ return parts[parts.length - 1] || wt;
100
+ }
101
+
102
+ export function formatStatsText(data: StatsData): string {
103
+ if (data.runs.total === 0) return "no runs yet";
104
+
105
+ const lines: string[] = [];
106
+ // ราคา list จาก cache อย่างเดียว — query ไม่ fetch เอง (offline ได้, ไม่มี
107
+ // ไฟล์ = แสดง — + hint ไม่ throw)
108
+ const prices = loadPrices();
109
+
110
+ if (data.scope) {
111
+ lines.push(`scope: ${data.scope}`);
112
+ } else {
113
+ lines.push(`scope: all projects (${data.byWorktree.length})`);
114
+ }
115
+
116
+ lines.push(
117
+ `runs: ${data.runs.total} (${Object.entries(data.runs.byStatus)
118
+ .map(([k, v]) => `${k}=${v}`)
119
+ .join(", ")})`,
120
+ );
121
+ lines.push(
122
+ `pass rate: ${fmtRate(data.runs.passRate)} stall rate: ${fmtRate(data.runs.stallRate)}`,
123
+ );
124
+ lines.push(
125
+ `avg rounds to pass: ${data.runs.avgRounds.toFixed(1)} avg time to pass: ${data.runs.avgMinutes.toFixed(0)}m`,
126
+ );
127
+
128
+ lines.push(
129
+ `avg exec time (spawn→route): ${data.stages.exec.avg.toFixed(1)}m over ${data.stages.exec.count} rounds`,
130
+ );
131
+ lines.push(
132
+ `avg review turnaround (route→gate): ${data.stages.review.avg.toFixed(1)}m over ${data.stages.review.count} rounds`,
133
+ );
134
+
135
+ if (data.byModel.length > 0) {
136
+ const showWt = !data.scope;
137
+ lines.push("\nby model:");
138
+ if (showWt) {
139
+ lines.push(
140
+ " project | client | provider | model | agent | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
141
+ );
142
+ lines.push(
143
+ " ---------|--------|----------|-------|-------|-------|-------|----------|-------------|------------|----------------",
144
+ );
145
+ } else {
146
+ lines.push(
147
+ " client | provider | model | agent | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
148
+ );
149
+ lines.push(
150
+ " -------|----------|-------|-------|-------|-------|----------|-------------|------------|----------------",
151
+ );
152
+ }
153
+ for (const m of data.byModel) {
154
+ const wt = showWt ? `${shortWt(m.worktree).padEnd(9)} | ` : "";
155
+ lines.push(
156
+ ` ${wt}${m.client.padEnd(6)} | ${m.provider.padEnd(8)} | ${m.model.padEnd(5)} | ${m.agent.padEnd(5)} | ${String(m.gateCount).padStart(5)} | ${String(m.fails).padStart(5)} | ${fmtRate(m.failRate).padStart(8)} | ${fmtTokens(m.tokensPerPass).padStart(11)} | ${m.avgQuality.toFixed(1).padStart(10)} | ${fmtTokens(m.tokensInput).padStart(7)} in / ${fmtTokens(m.tokensOutput).padStart(7)} out`,
157
+ );
158
+ }
159
+ const a = data.modelAttribution;
160
+ if (a.inferred > 0 || a.none > 0) {
161
+ lines.push(
162
+ ` attribution: ${a.declared} declared, ${a.inferred} inferred from the live session, ${a.none} unknown`,
163
+ );
164
+ }
165
+ }
166
+
167
+ if (data.byPlanMode.length > 0) {
168
+ const showWt = !data.scope;
169
+ lines.push("\nplanned vs dove-in:");
170
+ if (showWt) {
171
+ lines.push(
172
+ " project | mode | model | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
173
+ );
174
+ lines.push(
175
+ " ---------|----------|-------|-------|-------|----------|-------------|------------|----------------",
176
+ );
177
+ } else {
178
+ lines.push(
179
+ " mode | model | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
180
+ );
181
+ lines.push(
182
+ " ----------|-------|-------|-------|----------|-------------|------------|----------------",
183
+ );
184
+ }
185
+ for (const r of data.byPlanMode) {
186
+ const mode = r.hasPlan ? "planned" : "no-plan";
187
+ const wt = showWt ? `${shortWt(r.worktree).padEnd(9)} | ` : "";
188
+ lines.push(
189
+ ` ${wt}${mode.padEnd(9)} | ${r.model.padEnd(5)} | ${String(r.gates).padStart(5)} | ${String(r.fails).padStart(5)} | ${fmtRate(r.failRate).padStart(8)} | ${fmtTokens(r.tokensPerPass).padStart(11)} | ${r.avgQuality.toFixed(1).padStart(10)} | ${fmtTokens(r.tokensInput).padStart(7)} in / ${fmtTokens(r.tokensOutput).padStart(7)} out`,
190
+ );
191
+ }
192
+ }
193
+
194
+ if (data.byRegime.length > 0) {
195
+ const showWt = !data.scope;
196
+ lines.push("\nby regime:");
197
+ if (showWt) {
198
+ lines.push(
199
+ " project | regime | model | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
200
+ );
201
+ lines.push(
202
+ " ---------|--------|-------|-------|-------|----------|-------------|------------|----------------",
203
+ );
204
+ } else {
205
+ lines.push(
206
+ " regime | model | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
207
+ );
208
+ lines.push(
209
+ " --------|-------|-------|-------|-------|----------|-------------|------------|----------------",
210
+ );
211
+ }
212
+ for (const r of data.byRegime) {
213
+ const wt = showWt ? `${shortWt(r.worktree).padEnd(9)} | ` : "";
214
+ lines.push(
215
+ ` ${wt}${r.regime.padEnd(7)} | ${r.model.padEnd(5)} | ${String(r.gates).padStart(5)} | ${String(r.fails).padStart(5)} | ${fmtRate(r.failRate).padStart(8)} | ${fmtTokens(r.tokensPerPass).padStart(11)} | ${r.avgQuality.toFixed(1).padStart(10)} | ${fmtTokens(r.tokensInput).padStart(7)} in / ${fmtTokens(r.tokensOutput).padStart(7)} out`,
216
+ );
217
+ }
218
+ }
219
+
220
+ if (data.byGrade.length > 0) {
221
+ lines.push("\nby grade:");
222
+ lines.push(" grade | count");
223
+ lines.push(" ------|------");
224
+ for (const g of data.byGrade) {
225
+ lines.push(` ${g.grade.padEnd(14)} | ${String(g.count).padStart(5)}`);
226
+ }
227
+ }
228
+
229
+ if (data.byWorktree.length > 0) {
230
+ lines.push("\nby worktree:");
231
+ lines.push(" worktree | runs | passed | stalled | pending");
232
+ lines.push(" ---------|------|--------|---------|--------");
233
+ for (const w of data.byWorktree) {
234
+ const pending = w.pending === null ? "—" : String(w.pending);
235
+ lines.push(
236
+ ` ${w.worktree.padEnd(8)} | ${String(w.runs).padStart(4)} | ${String(w.passed).padStart(6)} | ${String(w.stalled).padStart(7)} | ${pending.padStart(7)}`,
237
+ );
238
+ }
239
+ }
240
+
241
+ if (data.byReasonCode.length > 0) {
242
+ lines.push("\nby reason_code (non-pass gates only):");
243
+ lines.push(" worktree | reason | count");
244
+ lines.push(" ---------|--------|------");
245
+ for (const r of data.byReasonCode.slice(0, 3)) {
246
+ lines.push(
247
+ ` ${r.worktree.padEnd(8)} | ${r.reason.padEnd(14)} | ${String(r.count).padStart(5)}`,
248
+ );
249
+ }
250
+ }
251
+
252
+ if (data.byFile.length > 0) {
253
+ lines.push(
254
+ "\nby file (graded touches — absence means unmeasured, not safe):",
255
+ );
256
+ lines.push(" file | gates | fails | last reason");
257
+ lines.push(" -----|-------|-------|------------");
258
+ for (const f of data.byFile.slice(0, 10)) {
259
+ lines.push(
260
+ ` ${f.file.padEnd(40)} | ${String(f.gates).padStart(5)} | ${String(f.fails).padStart(5)} | ${f.lastReason ?? "—"}`,
261
+ );
262
+ }
263
+ }
264
+
265
+ if (data.escalatedRuns.length > 0) {
266
+ lines.push("\nescalated runs (round past cap — likely plan signal):");
267
+ for (const e of data.escalatedRuns.slice(0, 3)) {
268
+ lines.push(
269
+ ` run ${e.id} (${e.worktree}, plan ${e.plan ?? "—"}): round ${e.round}`,
270
+ );
271
+ }
272
+ }
273
+
274
+ if (data.bestPassing.length > 0) {
275
+ lines.push("\nplans passed at round 1 (reuse this shape):");
276
+ for (const b of data.bestPassing.slice(0, 3)) {
277
+ lines.push(` ${b.plan} (${b.worktree})`);
278
+ }
279
+ }
280
+
281
+ if (data.usage.session_count > 0) {
282
+ lines.push("\nusage:");
283
+ lines.push(
284
+ ` total: ${fmtIn(data.usage.total_tokens_input, data.usage.total_tokens_cache_read, data.usage.total_tokens_cache_write)} / ${data.usage.total_tokens_output.toLocaleString()} out / ${data.usage.total_tokens_reasoning.toLocaleString()} reasoning tokens over ${data.usage.session_count} sessions ($${data.usage.total_cost.toFixed(4)})`,
285
+ );
286
+ if (data.usage.by_model.length > 0) {
287
+ lines.push(" by model:");
288
+ for (const m of data.usage.by_model) lines.push(modelLine(m, true));
289
+ }
290
+ lines.push(...imputedLines(data.usage, prices).lines);
291
+ }
292
+
293
+ // ZCode usage (separate DB)
294
+ if (data.zcodeUsage && data.zcodeUsage.session_count > 0) {
295
+ const zu = data.zcodeUsage;
296
+ lines.push("\nzcode usage:");
297
+ lines.push(
298
+ ` total: ${fmtIn(zu.total_tokens_input, zu.total_tokens_cache_read, zu.total_tokens_cache_write)} / ${zu.total_tokens_output.toLocaleString()} out / ${zu.total_tokens_reasoning.toLocaleString()} reasoning tokens over ${zu.session_count} sessions`,
299
+ );
300
+ if (zu.by_model.length > 0) {
301
+ const imp = imputedLines(zu, prices);
302
+ lines.push(" by model:");
303
+ for (const m of zu.by_model)
304
+ lines.push(
305
+ modelLine(m, false, imp.map.get(`${m.provider}\0${m.model}`)),
306
+ );
307
+ lines.push(...imp.lines);
308
+ } else {
309
+ lines.push(...imputedLines(zu, prices).lines);
310
+ }
311
+ }
312
+
313
+ // Claude Code usage (JSONL files)
314
+ if (data.claudeCodeUsage && data.claudeCodeUsage.session_count > 0) {
315
+ const cc = data.claudeCodeUsage;
316
+ lines.push("\nclaude code usage:");
317
+ lines.push(
318
+ ` total: ${fmtIn(cc.total_tokens_input, cc.total_tokens_cache_read, cc.total_tokens_cache_write)} / ${cc.total_tokens_output.toLocaleString()} out / ${cc.total_tokens_reasoning.toLocaleString()} reasoning tokens over ${cc.session_count} sessions`,
319
+ );
320
+ if (cc.by_model.length > 0) {
321
+ const imp = imputedLines(cc, prices);
322
+ lines.push(" by model:");
323
+ for (const m of cc.by_model)
324
+ lines.push(
325
+ modelLine(m, false, imp.map.get(`${m.provider}\0${m.model}`)),
326
+ );
327
+ lines.push(...imp.lines);
328
+ } else {
329
+ lines.push(...imputedLines(cc, prices).lines);
330
+ }
331
+ }
332
+
333
+ // Codex usage (JSONL files)
334
+ if (data.codexUsage && data.codexUsage.session_count > 0) {
335
+ const cx = data.codexUsage;
336
+ lines.push("\ncodex usage:");
337
+ lines.push(
338
+ ` total: ${fmtIn(cx.total_tokens_input, cx.total_tokens_cache_read, cx.total_tokens_cache_write)} / ${cx.total_tokens_output.toLocaleString()} out / ${cx.total_tokens_reasoning.toLocaleString()} reasoning tokens over ${cx.session_count} sessions`,
339
+ );
340
+ if (cx.by_model.length > 0) {
341
+ const imp = imputedLines(cx, prices);
342
+ lines.push(" by model:");
343
+ for (const m of cx.by_model)
344
+ lines.push(
345
+ modelLine(m, false, imp.map.get(`${m.provider}\0${m.model}`)),
346
+ );
347
+ lines.push(...imp.lines);
348
+ } else {
349
+ lines.push(...imputedLines(cx, prices).lines);
350
+ }
351
+ }
352
+
353
+ return lines.join("\n");
354
+ }
355
+
356
+ // --- Verdict mode: Pareto frontier of quality vs tokens/pass ---
357
+
358
+ /** One model's record inside a single regime. */
359
+ export interface VerdictRow {
360
+ model: string;
361
+ gates: number;
362
+ fails: number;
363
+ avgQuality: number;
364
+ tokensPerPass: number | null;
365
+ }
366
+
367
+ export interface FrontierRow extends VerdictRow {
368
+ n: number;
369
+ }
370
+
371
+ interface DominatedRow extends VerdictRow {
372
+ dominator: string;
373
+ tokenRatio: number;
374
+ n: number;
375
+ }
376
+
377
+ /** Below this many gates a model is a candidate, not a yardstick. */
378
+ const MIN_N = 5;
379
+
380
+ /** Every regime the ledger accepts — so "never graded" is visible, not absent. */
381
+ const REGIMES = ["code", "fix", "review", "plan", "inquiry", "test"];
382
+
383
+ const withN = (r: VerdictRow): FrontierRow => ({ ...r, n: r.gates });
384
+
385
+ /** Cheapest first; quality breaks ties. */
386
+ const byTokens = (a: VerdictRow, b: VerdictRow) =>
387
+ (a.tokensPerPass ?? 0) - (b.tokensPerPass ?? 0) ||
388
+ b.avgQuality - a.avgQuality;
389
+
390
+ const hasTokens = (r: VerdictRow) =>
391
+ r.tokensPerPass !== null && r.tokensPerPass > 0;
392
+
393
+ /**
394
+ * Pareto frontier over (quality up, tokens/pass down), computed from models
395
+ * with n >= MIN_N only.
396
+ *
397
+ * Thin rows are listed separately and never dominate anyone. Without that
398
+ * split one lucky run redefines a whole regime: a model tried once at q4.0
399
+ * dominated nine established models here, including one with n=17.
400
+ */
401
+ export function computeFrontier(
402
+ rows: VerdictRow[],
403
+ minN = MIN_N,
404
+ ): {
405
+ frontier: FrontierRow[];
406
+ dominated: DominatedRow[];
407
+ candidates: FrontierRow[];
408
+ unranked: FrontierRow[];
409
+ } {
410
+ const unranked = rows.filter((r) => !hasTokens(r)).map(withN);
411
+ const candidates = rows
412
+ .filter((r) => hasTokens(r) && r.gates < minN)
413
+ .sort(byTokens)
414
+ .map(withN);
415
+ const ranked = rows
416
+ .filter((r) => hasTokens(r) && r.gates >= minN)
417
+ .sort(byTokens);
418
+
419
+ const frontier: FrontierRow[] = [];
420
+ let maxQuality = -1;
421
+ for (const r of ranked) {
422
+ if (r.avgQuality > maxQuality) {
423
+ frontier.push(withN(r));
424
+ maxQuality = r.avgQuality;
425
+ }
426
+ }
427
+
428
+ const onFrontier = new Set(frontier.map((f) => f.model));
429
+ const dominated: DominatedRow[] = [];
430
+ for (const r of ranked) {
431
+ if (onFrontier.has(r.model)) continue;
432
+ let dominator: string | null = null;
433
+ let tokenRatio = Infinity;
434
+ for (const f of frontier) {
435
+ if (r.avgQuality > f.avgQuality) continue;
436
+ if (r.tokensPerPass! <= f.tokensPerPass!) continue;
437
+ const ratio = r.tokensPerPass! / f.tokensPerPass!;
438
+ if (ratio < tokenRatio) {
439
+ tokenRatio = ratio;
440
+ dominator = f.model;
441
+ }
442
+ }
443
+ if (dominator) dominated.push({ ...r, dominator, tokenRatio, n: r.gates });
444
+ }
445
+
446
+ return { frontier, dominated, candidates, unranked };
447
+ }
448
+
449
+ /** `stealth/union-alpha q3.2 673.8k/pass n=5` */
450
+ function modelRow(r: FrontierRow): string {
451
+ return ` ${r.model.padEnd(35)} q${r.avgQuality.toFixed(1)} ${fmtTokens(r.tokensPerPass)}/pass n=${r.n}`;
452
+ }
453
+
454
+ /** The model to reach for, plus what the cheaper end of the frontier costs. */
455
+ function pick(frontier: FrontierRow[], closest: FrontierRow | null): string {
456
+ if (frontier.length === 0) {
457
+ return closest
458
+ ? `— no model at n≥${MIN_N} yet (closest: ${closest.model}, n=${closest.n})`
459
+ : `— no model has token attribution yet`;
460
+ }
461
+ const best = frontier[frontier.length - 1]!; // built cheapest-first, quality rising
462
+ const cheapest = frontier[0]!;
463
+ const line = `${best.model} q${best.avgQuality.toFixed(1)} ${fmtTokens(best.tokensPerPass)}/pass n=${best.n}`;
464
+ return cheapest.model === best.model
465
+ ? line
466
+ : `${line} · cheapest: ${cheapest.model} q${cheapest.avgQuality.toFixed(1)} ${fmtTokens(cheapest.tokensPerPass)}`;
467
+ }
468
+
469
+ /**
470
+ * Render verdict mode: which model to pay for, per regime, ranked on the
471
+ * Pareto frontier of quality vs tokens/pass.
472
+ *
473
+ * Without `regime` it is one line per regime. With one, it is that regime's
474
+ * full frontier / dominated / candidates breakdown.
475
+ *
476
+ * Pass rate is deliberately not the ranking axis — self-graded work passes
477
+ * almost always, so the footer reports fails rather than ranking on them.
478
+ */
479
+ export function formatVerdictText(data: StatsData, regime?: string): string {
480
+ if (data.runs.total === 0) return "no runs yet";
481
+
482
+ const scope = data.scope ?? "all projects";
483
+ const groups = new Map<string, VerdictRow[]>();
484
+ for (const r of data.byRegime) {
485
+ if (r.model === "—") continue; // unattributed: cannot be ranked
486
+ if (regime && r.regime !== regime) continue;
487
+ const g = groups.get(r.regime) ?? [];
488
+ g.push({
489
+ model: r.model,
490
+ gates: r.gates,
491
+ fails: r.fails,
492
+ avgQuality: r.avgQuality,
493
+ tokensPerPass: r.tokensPerPass,
494
+ });
495
+ groups.set(r.regime, g);
496
+ }
497
+
498
+ const sum = (rows: VerdictRow[], key: "gates" | "fails") =>
499
+ rows.reduce((s, r) => s + r[key], 0);
500
+ const closestToN = (rows: VerdictRow[]) =>
501
+ rows
502
+ .filter(hasTokens)
503
+ .map(withN)
504
+ .sort((a, b) => b.n - a.n)[0] ?? null;
505
+
506
+ // --- one regime: the full breakdown ---
507
+ if (regime) {
508
+ const rows = groups.get(regime);
509
+ if (!rows || rows.length === 0) {
510
+ return `regime=${regime} · ${scope} · no graded work yet`;
511
+ }
512
+ const gates = sum(rows, "gates");
513
+ const { frontier, dominated, candidates, unranked } = computeFrontier(rows);
514
+ const lines = [
515
+ `regime=${regime} · ${scope} · ${gates} gates · ${rows.length} models`,
516
+ ];
517
+
518
+ if (frontier.length > 0) {
519
+ lines.push(
520
+ `\nfrontier (n≥${MIN_N}) — nothing beats these on both quality and tokens:`,
521
+ );
522
+ for (const f of [...frontier].reverse()) lines.push(modelRow(f));
523
+ } else {
524
+ lines.push(`\n${pick(frontier, closestToN(rows))}`);
525
+ }
526
+
527
+ if (rows.length === 1) {
528
+ lines.push(
529
+ " — no comparison yet (only one model graded in this regime)",
530
+ );
531
+ }
532
+
533
+ if (dominated.length > 0) {
534
+ lines.push("\ndominated:");
535
+ for (const d of dominated) {
536
+ lines.push(
537
+ `${modelRow(d)} ← ${d.dominator} dominates, ${d.tokenRatio.toFixed(1)}× tokens`,
538
+ );
539
+ }
540
+ }
541
+
542
+ if (candidates.length > 0) {
543
+ lines.push(
544
+ `\ncandidates (n<${MIN_N} — shown, but never used as the yardstick):`,
545
+ );
546
+ for (const c of candidates) lines.push(modelRow(c));
547
+ }
548
+
549
+ if (unranked.length > 0) {
550
+ lines.push("\nno token attribution — cannot be ranked:");
551
+ for (const u of unranked) {
552
+ lines.push(
553
+ ` ${u.model.padEnd(35)} q${u.avgQuality.toFixed(1)} n=${u.n}`,
554
+ );
555
+ }
556
+ }
557
+
558
+ lines.push(
559
+ `\nfails: ${sum(rows, "fails")}/${gates} — pass/fail carries no signal here; ranking is quality × tokens`,
560
+ );
561
+ return lines.join("\n");
562
+ }
563
+
564
+ // --- all regimes: one line each ---
565
+ const allRows = [...groups.values()].flat();
566
+ const lines = [
567
+ `${scope} · ${sum(allRows, "gates")} gates · ${sum(allRows, "fails")} fails`,
568
+ "",
569
+ ];
570
+ for (const reg of REGIMES) {
571
+ const rows = groups.get(reg);
572
+ const label = ` ${reg.padEnd(8)} ${`(${rows ? sum(rows, "gates") : 0})`.padStart(5)}`;
573
+ if (!rows || rows.length === 0) {
574
+ lines.push(`${label} — no graded work`);
575
+ continue;
576
+ }
577
+ const { frontier } = computeFrontier(rows);
578
+ lines.push(`${label} ${pick(frontier, closestToN(rows))}`);
579
+ }
580
+ lines.push(
581
+ `\n(fapony stats --mode verdict --regime <name> for the breakdown)`,
582
+ );
583
+ return lines.join("\n");
584
+ }
@@ -0,0 +1,19 @@
1
+ // src/stats/index.ts — barrel re-export
2
+
3
+ export { cmdStats, currentWorktree } from "./cli.js";
4
+ export {
5
+ countPendingPlans,
6
+ getLastVerdictByPlan,
7
+ getPlanBreakdown,
8
+ getStatsData,
9
+ type PlanBreakdown,
10
+ resolveMaxRounds,
11
+ type StatsData,
12
+ } from "./data.js";
13
+ export {
14
+ computeFrontier,
15
+ type FrontierRow,
16
+ formatStatsText,
17
+ formatVerdictText,
18
+ type VerdictRow,
19
+ } from "./format.js";