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,376 @@
1
+ // src/session/findModel.ts — resolve model+provider+client+agent from a session_id
2
+ //
3
+ // Tries each client reader in order: OpenCode → ZCode → Claude Code → Codex.
4
+ // Returns null when the id is not found in any session log. Never throws —
5
+ // a missing session is a normal "no signal" case, not an error.
6
+ //
7
+ // OpenCode/ZCode: query the session table by id directly — never scan part table.
8
+ // Claude Code/Codex: session_id is a .jsonl file path — check existsSync first.
9
+
10
+ import { Database } from "bun:sqlite";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ export type SessionClient = "opencode" | "zcode" | "claude-code" | "codex";
16
+
17
+ export interface SessionModel {
18
+ model: string;
19
+ /** Provider as the client logs it (raw — never mapped); "—" when unknown. */
20
+ provider: string;
21
+ client: SessionClient;
22
+ /** Subagent name (ZCode model_usage.agent only); null elsewhere. */
23
+ agent: string | null;
24
+ /** Total input tokens for the session (null when client does not record tokens). */
25
+ tokensInput: number | null;
26
+ /** Total output tokens for the session (null when client does not record tokens). */
27
+ tokensOutput: number | null;
28
+ }
29
+
30
+ /**
31
+ * Input tokens as the clients themselves account for them: fresh input plus
32
+ * cache read plus cache write. Cache is not a footnote — in a Claude Code
33
+ * session it is nearly all of the input, so counting `input_tokens` alone
34
+ * reported 4.2k in / 1.1M out for Sonnet, and made one client look ~1000x
35
+ * cheaper than another on the very comparison these numbers exist for.
36
+ * Null only when the client recorded nothing at all (never zero-as-measured).
37
+ */
38
+ function sumInput(...parts: (number | null | undefined)[]): number | null {
39
+ let total = 0;
40
+ let sawNumber = false;
41
+ for (const p of parts) {
42
+ if (typeof p === "number") {
43
+ total += p;
44
+ sawNumber = true;
45
+ }
46
+ }
47
+ return sawNumber ? total : null;
48
+ }
49
+
50
+ /** Single unknown-value sentinel — never "" or "(unknown)" (see task). */
51
+ const UNKNOWN = "—";
52
+
53
+ function resolveOpenCodeDbPath(): string {
54
+ return (
55
+ process.env.FAPONY_OPENCODE_DB ??
56
+ join(homedir(), ".local", "share", "opencode", "opencode.db")
57
+ );
58
+ }
59
+
60
+ function resolveZcodeDbPath(): string {
61
+ return (
62
+ process.env.FAPONY_ZCODE_DB ??
63
+ join(homedir(), ".zcode", "cli", "db", "db.sqlite")
64
+ );
65
+ }
66
+
67
+ function findInOpenCode(sessionId: string): SessionModel | null {
68
+ // NOTE: session.model is the *current* model, not the majority one — a
69
+ // mid-run /model switch leaves only the latest here. That's the best this
70
+ // reader can do: never scan the part table for this (per-task rule).
71
+ const dbPath = resolveOpenCodeDbPath();
72
+ if (!existsSync(dbPath)) return null;
73
+
74
+ let db: Database | null = null;
75
+ try {
76
+ db = new Database(dbPath, { readonly: true });
77
+ db.run("PRAGMA query_only = ON");
78
+
79
+ const row = db
80
+ .prepare(
81
+ `SELECT model, tokens_input, tokens_output,
82
+ tokens_cache_read, tokens_cache_write
83
+ FROM session WHERE id = ?`,
84
+ )
85
+ .get(sessionId) as {
86
+ model: string;
87
+ tokens_input: number | null;
88
+ tokens_output: number | null;
89
+ tokens_cache_read: number | null;
90
+ tokens_cache_write: number | null;
91
+ } | null;
92
+
93
+ if (!row) return null;
94
+
95
+ const tokensInput = sumInput(
96
+ row.tokens_input,
97
+ row.tokens_cache_read,
98
+ row.tokens_cache_write,
99
+ );
100
+ const tokensOutput =
101
+ typeof row.tokens_output === "number" ? row.tokens_output : null;
102
+
103
+ // model column is either plain text or JSON {providerID, id}
104
+ if (typeof row.model === "string") {
105
+ try {
106
+ if (row.model.startsWith("{")) {
107
+ const parsed = JSON.parse(row.model) as {
108
+ providerID?: string;
109
+ id?: string;
110
+ };
111
+ return {
112
+ model: parsed.id ?? row.model,
113
+ provider: parsed.providerID || UNKNOWN,
114
+ client: "opencode",
115
+ agent: null,
116
+ tokensInput,
117
+ tokensOutput,
118
+ };
119
+ }
120
+ } catch {
121
+ // fall through to plain text
122
+ }
123
+ return {
124
+ model: row.model,
125
+ provider: UNKNOWN,
126
+ client: "opencode",
127
+ agent: null,
128
+ tokensInput,
129
+ tokensOutput,
130
+ };
131
+ }
132
+ return null;
133
+ } catch {
134
+ return null;
135
+ } finally {
136
+ db?.close();
137
+ }
138
+ }
139
+
140
+ function findInZcode(sessionId: string): SessionModel | null {
141
+ const dbPath = resolveZcodeDbPath();
142
+ if (!existsSync(dbPath)) return null;
143
+
144
+ let db: Database | null = null;
145
+ try {
146
+ db = new Database(dbPath, { readonly: true });
147
+ db.run("PRAGMA query_only = ON");
148
+
149
+ // ZCode: session has no model column — model lives in model_usage, one
150
+ // row per request, so a mid-run /model switch leaves several model_ids.
151
+ // Majority rule (same as the JSONL readers): the (model, provider,
152
+ // agent) group with the most summed computed_total_tokens — a single
153
+ // big row must not beat many small rows doing most of the work
154
+ // (no JOIN — no session column is used, and session_id is FK'd anyway).
155
+ // provider_id is taken raw (sometimes a UUID, never mapped).
156
+ const row = db
157
+ .prepare(
158
+ `SELECT model_id AS model, provider_id AS provider, agent,
159
+ SUM(input_tokens) AS tokens_input,
160
+ SUM(output_tokens) AS tokens_output,
161
+ SUM(cache_read_input_tokens) AS tokens_cache_read,
162
+ SUM(cache_creation_input_tokens) AS tokens_cache_write
163
+ FROM model_usage
164
+ WHERE session_id = ?
165
+ GROUP BY model_id, provider_id, agent
166
+ ORDER BY SUM(computed_total_tokens) DESC
167
+ LIMIT 1`,
168
+ )
169
+ .get(sessionId) as {
170
+ model: string;
171
+ provider: string | null;
172
+ agent: string | null;
173
+ tokens_input: number | null;
174
+ tokens_output: number | null;
175
+ tokens_cache_read: number | null;
176
+ tokens_cache_write: number | null;
177
+ } | null;
178
+
179
+ if (!row) return null;
180
+ return {
181
+ model: row.model,
182
+ provider: row.provider || UNKNOWN,
183
+ client: "zcode",
184
+ agent: row.agent ?? null,
185
+ tokensInput: sumInput(
186
+ row.tokens_input,
187
+ row.tokens_cache_read,
188
+ row.tokens_cache_write,
189
+ ),
190
+ tokensOutput:
191
+ typeof row.tokens_output === "number" ? row.tokens_output : null,
192
+ };
193
+ } catch {
194
+ return null;
195
+ } finally {
196
+ db?.close();
197
+ }
198
+ }
199
+
200
+ function findInClaudeCode(sessionId: string): SessionModel | null {
201
+ // Claude Code: session_id is a .jsonl file path
202
+ if (!existsSync(sessionId)) return null;
203
+
204
+ let content: string;
205
+ try {
206
+ content = readFileSync(sessionId, "utf-8");
207
+ } catch {
208
+ return null;
209
+ }
210
+
211
+ // One session routinely uses several models (Opus diagnose → /model
212
+ // Sonnet to implement), so count message.model on every line and return the
213
+ // majority — never the first hit. Tie → last seen. Verified on real data:
214
+ // opus first (122 turns) but sonnet the worker (572 turns) must win.
215
+ const counts = new Map<string, number>();
216
+ const lastIdx = new Map<string, number>();
217
+ let totalInput = 0;
218
+ let totalOutput = 0;
219
+ const lines = content.split("\n");
220
+ for (let i = 0; i < lines.length; i++) {
221
+ const line = lines[i];
222
+ if (!line) continue;
223
+ try {
224
+ const parsed = JSON.parse(line) as {
225
+ message?: {
226
+ model?: string;
227
+ usage?: {
228
+ input_tokens?: number;
229
+ output_tokens?: number;
230
+ cache_read_input_tokens?: number;
231
+ cache_creation_input_tokens?: number;
232
+ };
233
+ };
234
+ };
235
+ const m = parsed.message?.model;
236
+ if (typeof m === "string" && m) {
237
+ counts.set(m, (counts.get(m) ?? 0) + 1);
238
+ lastIdx.set(m, i);
239
+ }
240
+ const usage = parsed.message?.usage;
241
+ if (usage) {
242
+ totalInput +=
243
+ (usage.input_tokens ?? 0) +
244
+ (usage.cache_read_input_tokens ?? 0) +
245
+ (usage.cache_creation_input_tokens ?? 0);
246
+ if (typeof usage.output_tokens === "number")
247
+ totalOutput += usage.output_tokens;
248
+ }
249
+ } catch {
250
+ // skip malformed
251
+ }
252
+ }
253
+ let best: string | null = null;
254
+ for (const [m, c] of counts) {
255
+ if (
256
+ best === null ||
257
+ c > (counts.get(best) ?? 0) ||
258
+ (c === (counts.get(best) ?? 0) &&
259
+ (lastIdx.get(m) ?? 0) > (lastIdx.get(best) ?? 0))
260
+ ) {
261
+ best = m;
262
+ }
263
+ }
264
+ return best
265
+ ? {
266
+ model: best,
267
+ provider: "anthropic",
268
+ client: "claude-code",
269
+ agent: null,
270
+ tokensInput: totalInput || null,
271
+ tokensOutput: totalOutput || null,
272
+ }
273
+ : null;
274
+ }
275
+
276
+ function findInCodex(sessionId: string): SessionModel | null {
277
+ // Codex: session_id is a .jsonl file path
278
+ if (!existsSync(sessionId)) return null;
279
+
280
+ let content: string;
281
+ try {
282
+ content = readFileSync(sessionId, "utf-8");
283
+ } catch {
284
+ return null;
285
+ }
286
+
287
+ // session_meta normally appears exactly once per file (verified: 59/59
288
+ // on this machine), but count anyway — same majority/tie→last rule as
289
+ // Claude Code in case a rollout ever carries several.
290
+ const counts = new Map<
291
+ string,
292
+ {
293
+ n: number;
294
+ lastIdx: number;
295
+ provider: string;
296
+ tokensInput: number;
297
+ tokensOutput: number;
298
+ }
299
+ >();
300
+ const lines = content.split("\n");
301
+ for (let i = 0; i < lines.length; i++) {
302
+ const line = lines[i];
303
+ if (!line) continue;
304
+ try {
305
+ const parsed = JSON.parse(line) as {
306
+ type?: string;
307
+ payload?: {
308
+ model?: string;
309
+ model_provider?: string;
310
+ base_instructions?: { provenance?: { model?: string } };
311
+ usage?: {
312
+ input_tokens?: number;
313
+ output_tokens?: number;
314
+ cached_input_tokens?: number;
315
+ cache_write_input_tokens?: number;
316
+ };
317
+ };
318
+ };
319
+ if (parsed.type === "session_meta" && parsed.payload) {
320
+ const model =
321
+ parsed.payload.model ??
322
+ parsed.payload.base_instructions?.provenance?.model;
323
+ if (model) {
324
+ const prev = counts.get(model);
325
+ const u = parsed.payload.usage;
326
+ const inp =
327
+ (u?.input_tokens ?? 0) +
328
+ (u?.cached_input_tokens ?? 0) +
329
+ (u?.cache_write_input_tokens ?? 0);
330
+ const out = parsed.payload.usage?.output_tokens ?? 0;
331
+ counts.set(model, {
332
+ n: (prev?.n ?? 0) + 1,
333
+ lastIdx: i,
334
+ provider: parsed.payload.model_provider ?? prev?.provider ?? "",
335
+ tokensInput: (prev?.tokensInput ?? 0) + inp,
336
+ tokensOutput: (prev?.tokensOutput ?? 0) + out,
337
+ });
338
+ }
339
+ }
340
+ } catch {
341
+ // skip malformed
342
+ }
343
+ }
344
+ let best: string | null = null;
345
+ for (const [m, s] of counts) {
346
+ const b = best === null ? undefined : counts.get(best);
347
+ if (!b || s.n > b.n || (s.n === b.n && s.lastIdx > b.lastIdx)) {
348
+ best = m;
349
+ }
350
+ }
351
+ if (!best) return null;
352
+ const bestData = counts.get(best)!;
353
+ return {
354
+ model: best,
355
+ provider: bestData.provider || UNKNOWN,
356
+ client: "codex",
357
+ agent: null,
358
+ tokensInput: bestData.tokensInput || null,
359
+ tokensOutput: bestData.tokensOutput || null,
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Resolve model+provider+client+agent from a session_id by trying each
365
+ * client reader. Returns null when not found in any session log.
366
+ */
367
+ export function findSessionModel(sessionId: string): SessionModel | null {
368
+ if (typeof sessionId !== "string" || !sessionId) return null;
369
+
370
+ return (
371
+ findInOpenCode(sessionId) ??
372
+ findInZcode(sessionId) ??
373
+ findInClaudeCode(sessionId) ??
374
+ findInCodex(sessionId)
375
+ );
376
+ }