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,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
|
+
}
|