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,75 @@
1
+ // src/digest/cli.ts — fapony digest CLI
2
+ //
3
+ // fapony digest [--since <7d|YYYY-MM-DD>] [--format text|html] [--json] [--out <file>]
4
+
5
+ import { writeFileSync } from "node:fs";
6
+ import { collectDigest } from "./collect.js";
7
+ import { renderDigestHtml } from "./html.js";
8
+ import { renderDigestText } from "./text.js";
9
+
10
+ export async function cmdDigest(args: string[]): Promise<void> {
11
+ let since: string | undefined;
12
+ let format: "text" | "html" = "text";
13
+ let json = false;
14
+ let out: string | undefined;
15
+
16
+ for (let i = 0; i < args.length; i++) {
17
+ const a = args[i];
18
+ if (a === "--since" && i + 1 < args.length) {
19
+ since = args[++i];
20
+ } else if (a === "--format" && i + 1 < args.length) {
21
+ const f = args[++i];
22
+ if (f !== "text" && f !== "html") {
23
+ console.error(
24
+ `fapony digest: unknown format "${f}" — use text or html`,
25
+ );
26
+ process.exit(1);
27
+ }
28
+ format = f;
29
+ } else if (a === "--json") {
30
+ json = true;
31
+ } else if (a === "--out" && i + 1 < args.length) {
32
+ out = args[++i];
33
+ } else if (a === "--help" || a === "-h") {
34
+ console.log(
35
+ "usage: fapony digest [--since <7d|YYYY-MM-DD>] [--format text|html] [--json] [--out <file>]",
36
+ );
37
+ process.exit(0);
38
+ } else {
39
+ console.error(`fapony digest: unknown flag "${a}"`);
40
+ console.error(
41
+ "usage: fapony digest [--since <7d|YYYY-MM-DD>] [--format text|html] [--json] [--out <file>]",
42
+ );
43
+ process.exit(1);
44
+ }
45
+ }
46
+
47
+ try {
48
+ const data = await collectDigest({ since });
49
+
50
+ let output: string;
51
+ if (json) {
52
+ output = JSON.stringify(data, null, 2);
53
+ } else if (format === "html") {
54
+ output = renderDigestHtml(data);
55
+ } else {
56
+ output = renderDigestText(data);
57
+ }
58
+
59
+ if (out) {
60
+ writeFileSync(out, output, "utf-8");
61
+ console.error(`written to ${out}`);
62
+ } else {
63
+ process.stdout.write(`${output}\n`);
64
+ }
65
+ } catch (err) {
66
+ if (
67
+ err instanceof Error &&
68
+ err.message.startsWith("invalid --since format:")
69
+ ) {
70
+ console.error(`fapony digest: ${err.message}`);
71
+ process.exit(1);
72
+ }
73
+ throw err;
74
+ }
75
+ }
@@ -0,0 +1,625 @@
1
+ // src/digest/collect.ts — รวมข้อมูลจาก 4 แหล่งเป็น DigestData ก้อนเดียว
2
+ //
3
+ // ไม่ render อะไรเลย — แค่อ่าน + จัดรูป struct
4
+
5
+ import { execSync } from "node:child_process";
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import {
9
+ doneDir,
10
+ type Event,
11
+ loadConfig,
12
+ openDb,
13
+ planDir,
14
+ type Run,
15
+ } from "../db/index.js";
16
+ import { type MemRow, readMemLog } from "../memory.js";
17
+ import { isPassFamily, VERDICT_GRADES } from "../parse.js";
18
+ import { imputeResult, loadPrices } from "../price/index.js";
19
+ import { EMPTY_RESULT, type PassiveUsageResult } from "../session/types.js";
20
+ import { type CacheEntry, readCache } from "../usage/cache.js";
21
+
22
+ // --- types ---
23
+
24
+ export type { MemRow };
25
+
26
+ export interface SourceStatus {
27
+ name: string;
28
+ ok: boolean;
29
+ detail: string;
30
+ }
31
+
32
+ export interface PlanRow {
33
+ file: string;
34
+ status?: string;
35
+ done: number;
36
+ total: number;
37
+ }
38
+
39
+ export interface Tally {
40
+ key: string;
41
+ count: number;
42
+ }
43
+
44
+ export interface CostRow {
45
+ model: string;
46
+ provider: string;
47
+ sessions: number;
48
+ input: number;
49
+ output: number;
50
+ cost: number;
51
+ imputed: number;
52
+ }
53
+
54
+ export interface RegimeModelRow {
55
+ regime: string;
56
+ model: string;
57
+ gates: number;
58
+ fails: number;
59
+ }
60
+
61
+ export interface DigestData {
62
+ generated_at: string;
63
+ since: string;
64
+ worktree: string;
65
+ scope_note: string;
66
+ sources: SourceStatus[];
67
+ decisions: MemRow[];
68
+ bugs: { open: MemRow[]; closed: MemRow[] };
69
+ notes: MemRow[];
70
+ plans: { pending: PlanRow[]; shipped: PlanRow[] };
71
+ cost: {
72
+ by_model: CostRow[];
73
+ total_usd: number;
74
+ imputed_usd: number;
75
+ unpriced_sessions: number;
76
+ };
77
+ verdicts: {
78
+ by_grade: Tally[];
79
+ by_regime_model: RegimeModelRow[];
80
+ by_reason_code: Tally[];
81
+ /** runs with ≥1 gate verdict in period — the "units" the headline counts. */
82
+ units_graded: number;
83
+ /** % of those whose earliest in-period gate passed at round ≤ 1. */
84
+ round1_pct: number;
85
+ };
86
+ skipped_malformed: number;
87
+ }
88
+
89
+ export interface CollectOpts {
90
+ since?: string; // "7d" or "YYYY-MM-DD"
91
+ worktree?: string; // override — auto-detect from cwd when omitted
92
+ /** @internal now override for deterministic testing */
93
+ _now?: number;
94
+ }
95
+
96
+ // --- helpers ---
97
+
98
+ function parseSince(
99
+ raw: string | undefined,
100
+ now?: number,
101
+ ): { iso: string; label: string } {
102
+ const def = "7d";
103
+ const s = raw ?? def;
104
+ const dMatch = /^(\d+)d$/.exec(s);
105
+ if (dMatch) {
106
+ const days = Number(dMatch[1]);
107
+ const dt = new Date((now ?? Date.now()) - days * 86400000);
108
+ return { iso: dt.toISOString(), label: `${days}d` };
109
+ }
110
+ const dateMatch = /^\d{4}-\d{2}-\d{2}$/.exec(s);
111
+ if (dateMatch) {
112
+ return { iso: `${s}T00:00:00.000Z`, label: s };
113
+ }
114
+ throw new Error(`invalid --since format: "${s}" — use <N>d or YYYY-MM-DD`);
115
+ }
116
+
117
+ function resolveWorktree(override?: string): string {
118
+ if (override) return override;
119
+ try {
120
+ return execSync("git rev-parse --show-toplevel", {
121
+ encoding: "utf-8",
122
+ stdio: ["pipe", "pipe", "pipe"],
123
+ }).trim();
124
+ } catch {
125
+ return process.cwd();
126
+ }
127
+ }
128
+
129
+ // --- plans ---
130
+
131
+ function parseFrontmatter(text: string): { status?: string; kind?: string } {
132
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
133
+ if (!m) return {};
134
+ const front: { status?: string; kind?: string } = {};
135
+ for (const line of m[1].split(/\r?\n/)) {
136
+ const kv = /^\s*([a-z_]+)\s*:\s*(.*?)\s*$/.exec(line);
137
+ if (!kv) continue;
138
+ const value = kv[2].replace(/\s+#.*$/, "").trim();
139
+ if (!value) continue;
140
+ if (kv[1] === "status") front.status = value;
141
+ else if (kv[1] === "kind") front.kind = value;
142
+ }
143
+ return front;
144
+ }
145
+
146
+ function countCheckboxes(text: string): { done: number; total: number } {
147
+ // นับ checkbox ใน section ## แรกเท่านั้น (same logic as plans.ts)
148
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
149
+ const start = body.search(/^##\s+/m);
150
+ if (start < 0) return { done: 0, total: 0 };
151
+ const rest = body.slice(start);
152
+ const next = rest.slice(3).search(/^##\s+/m);
153
+ const block = next < 0 ? rest : rest.slice(0, next + 3);
154
+ const total = block.match(/^\s*[-*]\s+\[[ xX]\]/gm)?.length ?? 0;
155
+ const done = block.match(/^\s*[-*]\s+\[[xX]\]/gm)?.length ?? 0;
156
+ return { done, total };
157
+ }
158
+
159
+ function readPlans(worktree: string): {
160
+ pending: PlanRow[];
161
+ shipped: PlanRow[];
162
+ ok: boolean;
163
+ detail: string;
164
+ } {
165
+ const config = loadConfig(join(worktree, "fapony.config.json"));
166
+ const pDir = join(worktree, planDir(config));
167
+ const dDir = join(worktree, doneDir(config));
168
+
169
+ if (!existsSync(pDir)) {
170
+ return {
171
+ pending: [],
172
+ shipped: [],
173
+ ok: false,
174
+ detail: `no plan dir — run: fapony init ${worktree}`,
175
+ };
176
+ }
177
+
178
+ const readPlanFile = (file: string): PlanRow => {
179
+ let text: string;
180
+ try {
181
+ text = readFileSync(join(pDir, file), "utf8");
182
+ } catch {
183
+ return { file, done: 0, total: 0 };
184
+ }
185
+ const { status, kind } = parseFrontmatter(text);
186
+ const { done, total } = countCheckboxes(text);
187
+ if (kind === "tracker") return { file, status, done: 0, total: 0 };
188
+ return { file, status, done, total };
189
+ };
190
+
191
+ const pending = readdirSync(pDir)
192
+ .filter((f) => f.endsWith(".md"))
193
+ .map(readPlanFile);
194
+
195
+ let shipped: PlanRow[] = [];
196
+ if (existsSync(dDir)) {
197
+ shipped = readdirSync(dDir)
198
+ .filter((f) => f.endsWith(".md"))
199
+ .map((f) => ({ file: f, status: "shipped", done: 0, total: 0 }));
200
+ }
201
+
202
+ return {
203
+ pending,
204
+ shipped,
205
+ ok: true,
206
+ detail: `${pending.length} pending / ${shipped.length} shipped`,
207
+ };
208
+ }
209
+
210
+ // --- usage + cost ---
211
+
212
+ function readUsageAndCost(_worktree: string): {
213
+ usage: PassiveUsageResult;
214
+ cost: DigestData["cost"];
215
+ ok: boolean;
216
+ detail: string;
217
+ } {
218
+ const cache = readCache();
219
+ // หา global entry (worktree=null) หรือรวมทุก entry
220
+ const globalEntries = cache.filter((e) => !e.worktree);
221
+ if (globalEntries.length === 0 && cache.length === 0) {
222
+ return {
223
+ usage: EMPTY_RESULT,
224
+ cost: {
225
+ by_model: [],
226
+ total_usd: 0,
227
+ imputed_usd: 0,
228
+ unpriced_sessions: 0,
229
+ },
230
+ ok: false,
231
+ detail: "no usage cache — run: fapony usage-scan",
232
+ };
233
+ }
234
+
235
+ // ใช้ global entries ถ้ามี ไม่งั้นรวมทุก entry
236
+ const entries = globalEntries.length > 0 ? globalEntries : cache;
237
+ const usage = entriesToUsage(entries);
238
+
239
+ // คำนวณราคา
240
+ const prices = loadPrices();
241
+ if (!prices) {
242
+ return {
243
+ usage,
244
+ cost: {
245
+ by_model: usage.by_model.map((m) => ({
246
+ model: m.model,
247
+ provider: m.provider,
248
+ sessions: m.session_count,
249
+ input: m.tokens_input,
250
+ output: m.tokens_output,
251
+ cost: m.cost,
252
+ imputed: 0,
253
+ })),
254
+ total_usd: usage.total_cost,
255
+ imputed_usd: 0,
256
+ unpriced_sessions: usage.session_count,
257
+ },
258
+ ok: true,
259
+ detail: `cache ${entries[0]?.scanned_at ?? "unknown"} — no prices`,
260
+ };
261
+ }
262
+
263
+ const imp = imputeResult(usage, prices);
264
+ const by_model = imp.by_model.map((m) => ({
265
+ model: m.model,
266
+ provider: m.provider,
267
+ sessions: m.session_count,
268
+ input: m.tokens_input,
269
+ output: m.tokens_output,
270
+ cost: 0, // recorded cost from cache (separate from imputed)
271
+ imputed: m.imputed_cost,
272
+ }));
273
+
274
+ // เพิ่ม cost จริงจาก cache
275
+ const cacheByKey = new Map(
276
+ entries.flatMap((e) =>
277
+ e.by_model.map((m) => [`${m.provider}\0${m.model}`, m]),
278
+ ),
279
+ );
280
+ for (const cm of by_model) {
281
+ const real = cacheByKey.get(`${cm.provider}\0${cm.model}`);
282
+ if (real) cm.cost = real.cost;
283
+ }
284
+
285
+ return {
286
+ usage,
287
+ cost: {
288
+ by_model,
289
+ total_usd: usage.total_cost,
290
+ imputed_usd: imp.total_imputed,
291
+ unpriced_sessions: imp.unpriced_sessions,
292
+ },
293
+ ok: true,
294
+ detail: `cache ${entries[0]?.scanned_at ?? "unknown"} · prices ${prices.fetched_at.slice(0, 10)}`,
295
+ };
296
+ }
297
+
298
+ function entriesToUsage(entries: CacheEntry[]): PassiveUsageResult {
299
+ const r: PassiveUsageResult = {
300
+ total_tokens_input: 0,
301
+ total_tokens_output: 0,
302
+ total_tokens_reasoning: 0,
303
+ total_tokens_cache_read: 0,
304
+ total_tokens_cache_write: 0,
305
+ total_cost: 0,
306
+ session_count: 0,
307
+ by_model: [],
308
+ };
309
+ const modelMap = new Map<
310
+ string,
311
+ {
312
+ provider: string;
313
+ model: string;
314
+ session_count: number;
315
+ tokens_input: number;
316
+ tokens_output: number;
317
+ tokens_reasoning: number;
318
+ tokens_cache_read: number;
319
+ tokens_cache_write: number;
320
+ cost: number;
321
+ }
322
+ >();
323
+
324
+ for (const e of entries) {
325
+ r.total_tokens_input += e.total_tokens_input;
326
+ r.total_tokens_output += e.total_tokens_output;
327
+ r.total_tokens_reasoning += e.total_tokens_reasoning;
328
+ r.total_tokens_cache_read += e.total_tokens_cache_read;
329
+ r.total_tokens_cache_write += e.total_tokens_cache_write;
330
+ r.total_cost += e.total_cost;
331
+ r.session_count += e.session_count;
332
+
333
+ for (const m of e.by_model) {
334
+ const key = `${m.provider}\0${m.model}`;
335
+ let bucket = modelMap.get(key);
336
+ if (!bucket) {
337
+ bucket = {
338
+ provider: m.provider,
339
+ model: m.model,
340
+ session_count: 0,
341
+ tokens_input: 0,
342
+ tokens_output: 0,
343
+ tokens_reasoning: 0,
344
+ tokens_cache_read: 0,
345
+ tokens_cache_write: 0,
346
+ cost: 0,
347
+ };
348
+ modelMap.set(key, bucket);
349
+ }
350
+ bucket.session_count += m.session_count;
351
+ bucket.tokens_input += m.tokens_input;
352
+ bucket.tokens_output += m.tokens_output;
353
+ bucket.tokens_reasoning += m.tokens_reasoning;
354
+ bucket.tokens_cache_read += m.tokens_cache_read;
355
+ bucket.tokens_cache_write += m.tokens_cache_write;
356
+ bucket.cost += m.cost;
357
+ }
358
+ }
359
+
360
+ r.by_model = [...modelMap.values()].sort(
361
+ (a, b) =>
362
+ b.tokens_input + b.tokens_output - (a.tokens_input + a.tokens_output),
363
+ );
364
+ return r;
365
+ }
366
+
367
+ // --- verdicts ---
368
+
369
+ function readVerdicts(
370
+ worktree: string,
371
+ sinceIso: string,
372
+ ): {
373
+ ok: boolean;
374
+ detail: string;
375
+ by_grade: Tally[];
376
+ by_regime_model: RegimeModelRow[];
377
+ by_reason_code: Tally[];
378
+ units_graded: number;
379
+ round1_pct: number;
380
+ } {
381
+ const empty = {
382
+ ok: false,
383
+ detail: "no graded runs yet for this worktree",
384
+ by_grade: [],
385
+ by_regime_model: [],
386
+ by_reason_code: [],
387
+ units_graded: 0,
388
+ round1_pct: 0,
389
+ };
390
+ let db: ReturnType<typeof openDb>;
391
+ try {
392
+ db = openDb();
393
+ } catch {
394
+ return empty;
395
+ }
396
+
397
+ try {
398
+ const runs = db
399
+ .prepare("SELECT * FROM runs WHERE worktree = ? ORDER BY id")
400
+ .all(worktree) as Run[];
401
+ const runIds = new Set(runs.map((r) => r.id));
402
+ const allEvents = db
403
+ .prepare("SELECT * FROM events ORDER BY run_id, id")
404
+ .all() as Event[];
405
+ const events = allEvents.filter(
406
+ (e) => runIds.has(e.run_id) && e.ts >= sinceIso,
407
+ );
408
+
409
+ if (events.length === 0) {
410
+ return empty;
411
+ }
412
+
413
+ // by_grade: count gate verdicts
414
+ const gradeMap = new Map<string, number>();
415
+ // by_regime_model: regime × model
416
+ const regimeModelMap = new Map<string, RegimeModelRow>();
417
+ // by_reason_code: non-pass gates
418
+ const reasonMap = new Map<string, number>();
419
+
420
+ // regime per run
421
+ const regimeByRun = new Map<number, string>();
422
+ for (const e of events) {
423
+ if (e.kind !== "gate" || !e.data) continue;
424
+ if (regimeByRun.has(e.run_id)) continue;
425
+ try {
426
+ const d = JSON.parse(e.data) as { regime?: unknown };
427
+ if (typeof d.regime === "string") regimeByRun.set(e.run_id, d.regime);
428
+ } catch {
429
+ // skip
430
+ }
431
+ }
432
+
433
+ // Per-run verdicts: one run routinely spans several gates (fail → fix →
434
+ // pass), so gate counts are not work units. events arrive ordered by
435
+ // (run_id, id) and ids are time-ordered, so first-seen per run is the
436
+ // earliest gate in the period.
437
+ const firstGateByRun = new Map<
438
+ number,
439
+ { verdict: string; round: number }
440
+ >();
441
+ const hasVerdictByRun = new Set<number>();
442
+ for (const e of events) {
443
+ if (e.kind !== "gate" || !e.data) continue;
444
+ let verdict: string | null = null;
445
+ let reasonCode: string | null = null;
446
+ let model: string | null = null;
447
+ // gateOnce always writes round (src/gate.ts); older rows may lack it —
448
+ // same default as the stats enrichment (src/gates.ts).
449
+ let round = 1;
450
+ try {
451
+ const d = JSON.parse(e.data) as {
452
+ verdict?: unknown;
453
+ reason_code?: unknown;
454
+ note?: unknown;
455
+ model?: unknown;
456
+ round?: unknown;
457
+ };
458
+ if (typeof d.verdict === "string" && VERDICT_GRADES.has(d.verdict))
459
+ verdict = d.verdict;
460
+ if (typeof d.reason_code === "string") reasonCode = d.reason_code;
461
+ else if (typeof d.note === "string") {
462
+ const m = /^\[([a-z_]+)\]/.exec(d.note);
463
+ if (m) reasonCode = m[1];
464
+ }
465
+ if (typeof d.model === "string") model = d.model;
466
+ if (typeof d.round === "number") round = d.round;
467
+ } catch {
468
+ // skip
469
+ }
470
+
471
+ if (verdict) {
472
+ gradeMap.set(verdict, (gradeMap.get(verdict) ?? 0) + 1);
473
+ hasVerdictByRun.add(e.run_id);
474
+ if (!firstGateByRun.has(e.run_id))
475
+ firstGateByRun.set(e.run_id, { verdict, round });
476
+ }
477
+
478
+ // regime × model
479
+ const regime = regimeByRun.get(e.run_id) ?? "—";
480
+ const modelKey = model ?? "—";
481
+ const rmKey = `${regime}\0${modelKey}`;
482
+ let rmRow = regimeModelMap.get(rmKey);
483
+ if (!rmRow) {
484
+ rmRow = { regime, model: modelKey, gates: 0, fails: 0 };
485
+ regimeModelMap.set(rmKey, rmRow);
486
+ }
487
+ rmRow.gates++;
488
+ if (verdict && !isPassFamily(verdict)) rmRow.fails++;
489
+
490
+ // reason_code (non-pass only)
491
+ if (verdict && !isPassFamily(verdict) && reasonCode) {
492
+ reasonMap.set(reasonCode, (reasonMap.get(reasonCode) ?? 0) + 1);
493
+ }
494
+ }
495
+
496
+ const by_grade = [...gradeMap.entries()]
497
+ .map(([key, count]) => ({ key, count }))
498
+ .sort((a, b) => b.count - a.count);
499
+
500
+ const by_regime_model = [...regimeModelMap.values()].sort(
501
+ (a, b) => b.gates - a.gates,
502
+ );
503
+
504
+ const by_reason_code = [...reasonMap.entries()]
505
+ .map(([key, count]) => ({ key, count }))
506
+ .sort((a, b) => b.count - a.count);
507
+
508
+ const units_graded = hasVerdictByRun.size;
509
+ const round1 = [...firstGateByRun.values()].filter(
510
+ (v) => isPassFamily(v.verdict) && v.round <= 1,
511
+ ).length;
512
+ const round1_pct = units_graded
513
+ ? Math.round((round1 / units_graded) * 100)
514
+ : 0;
515
+
516
+ return {
517
+ ok: true,
518
+ detail: `${runs.length} runs`,
519
+ by_grade,
520
+ by_regime_model,
521
+ by_reason_code,
522
+ units_graded,
523
+ round1_pct,
524
+ };
525
+ } finally {
526
+ db.close();
527
+ }
528
+ }
529
+
530
+ // --- main ---
531
+
532
+ export async function collectDigest(
533
+ opts: CollectOpts = {},
534
+ ): Promise<DigestData> {
535
+ const { iso: sinceIso } = parseSince(opts.since, opts._now);
536
+ const worktree = resolveWorktree(opts.worktree);
537
+ const scopeNote =
538
+ "this machine only — mem log is not shared via git in this repo";
539
+
540
+ const sources: SourceStatus[] = [];
541
+ let skippedMalformed = 0;
542
+
543
+ // 1. memory log
544
+ const mem = readMemLog(worktree, sinceIso);
545
+ skippedMalformed += mem.skipped;
546
+ sources.push({
547
+ name: "memory",
548
+ ok: mem.filesFound > 0,
549
+ detail:
550
+ mem.filesFound > 0
551
+ ? `${mem.rows.length} rows from ${mem.filesFound} file${mem.filesFound > 1 ? "s" : ""}`
552
+ : "no memory log — run: fapony init <path>",
553
+ });
554
+
555
+ // 2. plans
556
+ const planResult = readPlans(worktree);
557
+ sources.push({
558
+ name: "plans",
559
+ ok: planResult.ok,
560
+ detail: planResult.detail,
561
+ });
562
+
563
+ // 3. usage
564
+ const usageResult = readUsageAndCost(worktree);
565
+ sources.push({
566
+ name: "usage",
567
+ ok: usageResult.ok,
568
+ detail: usageResult.detail,
569
+ });
570
+
571
+ // 4. prices
572
+ const prices = loadPrices();
573
+ sources.push({
574
+ name: "prices",
575
+ ok: prices !== null,
576
+ detail: prices
577
+ ? `table ${prices.fetched_at.slice(0, 10)}`
578
+ : "no price table — run: fapony price-scan",
579
+ });
580
+
581
+ // 5. verdicts
582
+ const verdictResult = readVerdicts(worktree, sinceIso);
583
+ sources.push({
584
+ name: "verdicts",
585
+ ok: verdictResult.ok,
586
+ detail: verdictResult.detail,
587
+ });
588
+
589
+ // classify mem rows
590
+ const decisions = mem.rows.filter((r) => r.kind === "decision");
591
+ const notes = mem.rows.filter((r) => r.kind === "note");
592
+
593
+ // bug = open, close = closed — find open bugs by tracking closed refs
594
+ // close rows have `ref` pointing to the bug's `id`
595
+ const closedRefs = new Set(
596
+ mem.rows.filter((r) => r.kind === "close").map((r) => r.ref ?? ""),
597
+ );
598
+ const allBugs = mem.rows.filter((r) => r.kind === "bug");
599
+ const bugsOpen = allBugs.filter((r) => !closedRefs.has(r.id ?? ""));
600
+ const bugsClosed = mem.rows.filter((r) => r.kind === "close");
601
+
602
+ return {
603
+ generated_at: new Date().toISOString(),
604
+ since: sinceIso,
605
+ worktree,
606
+ scope_note: scopeNote,
607
+ sources,
608
+ decisions,
609
+ bugs: { open: bugsOpen, closed: bugsClosed },
610
+ notes,
611
+ plans: {
612
+ pending: planResult.pending,
613
+ shipped: planResult.shipped,
614
+ },
615
+ cost: usageResult.cost,
616
+ verdicts: {
617
+ by_grade: verdictResult.by_grade,
618
+ by_regime_model: verdictResult.by_regime_model,
619
+ by_reason_code: verdictResult.by_reason_code,
620
+ units_graded: verdictResult.units_graded,
621
+ round1_pct: verdictResult.round1_pct,
622
+ },
623
+ skipped_malformed: skippedMalformed,
624
+ };
625
+ }