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,359 @@
1
+ // src/context/projectHealth.ts — project-health context block builder
2
+ //
3
+ // Composes the cross-run knowledge queries (PLAN-project-health-context §2)
4
+ // into a short plain-text block fed to `plan-with-pony` as "known patterns"
5
+ // before drafting. Framed as "watch for", never "must follow" (overfitting
6
+ // guard — PLAN §5). Pure function over StatsData: no DB, no I/O.
7
+
8
+ import type {
9
+ ReasonCodeCount,
10
+ RecentVerdictNote,
11
+ StatsData,
12
+ } from "../stats/data.js";
13
+ import { computeFrontier } from "../stats/format.js";
14
+
15
+ export interface HealthContextOptions {
16
+ /** Scope to one worktree path. Global across worktrees when omitted. */
17
+ worktree?: string;
18
+ /** Max reason_code rows (default 3 — PLAN §5 escape hatch against prompt bloat). */
19
+ topReasons?: number;
20
+ /** Min runs before trends are reported (default 5 — PLAN §5 sample-size guard). */
21
+ minRuns?: number;
22
+ /** Filter recentVerdictNotes to only those mentioning these files. */
23
+ files?: string[];
24
+ /**
25
+ * Project decisions from the mem log, surfaced first. Reading the log is I/O,
26
+ * so it happens in the tool handler — this builder stays pure over its inputs.
27
+ */
28
+ memDecisions?: MemDecision[];
29
+ /**
30
+ * Structural hubs among the files being touched, computed at the handler
31
+ * from the live import graph (I/O) — this builder stays pure and just
32
+ * renders. Files below the dependents threshold never reach here.
33
+ */
34
+ hubs?: HubEntry[];
35
+ }
36
+
37
+ /** One structural hub from the live import graph (dependents ≥ threshold). */
38
+ export interface HubEntry {
39
+ file: string;
40
+ dependents: number;
41
+ tested: boolean;
42
+ /** Transitive (multi-hop) dependent count — omitted when equal to dependents. */
43
+ transitive?: number;
44
+ }
45
+
46
+ // Files imported by at least this many direct dependents are structural hubs —
47
+ // a break there drags every dependent with it (PLAN-hub-signal §4: 5 is the
48
+ // noise floor guess). Threshold gates on direct dependents only; transitive
49
+ // count is additive context on the same line, not a second gate.
50
+ export const HUB_DEPENDENTS_MIN = 5;
51
+ // One line, few entries — the mechanical 15-line cap already bounds the block.
52
+ const HUB_MAX = 3;
53
+
54
+ /** One mem-log decision distilled for the block. */
55
+ export interface MemDecision {
56
+ text: string;
57
+ spec?: string;
58
+ }
59
+
60
+ /** Best-scoring model for one regime, from real graded work. */
61
+ export interface ModelFit {
62
+ regime: string;
63
+ model: string;
64
+ gates: number;
65
+ failRate: number;
66
+ avgQuality: number;
67
+ tokensPerPass: number | null;
68
+ }
69
+
70
+ // Model right-sizing: below this many graded touches a bucket is noise, not a
71
+ // recommendation (same sample-size guard the trend lines use).
72
+ const MIN_MODEL_FIT_N = 5;
73
+ // Most regimes are 4 (code|fix|review|plan); cap protects the 15-line budget.
74
+ const MAX_MODEL_FIT = 4;
75
+ // Mem decisions shown ahead of the less-specific lines below.
76
+ const MEM_DECISION_MAX = 3;
77
+ const MEM_DECISION_CHARS = 140;
78
+ // Free-text verdict notes can be a whole paragraph — one 4k-char note once
79
+ // made the whole block 5,001 chars (measured 2026-09-17). Cap ~200, same trim
80
+ // shape as the mem-decision line above.
81
+ const RECENT_NOTE_CHARS = 200;
82
+
83
+ function fmtShortTokens(n: number): string {
84
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : `${Math.round(n)}`;
85
+ }
86
+
87
+ function truncate(s: string, max: number): string {
88
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
89
+ }
90
+
91
+ // `none` asserts "checked, nothing was wrong" — it is not a missing value, and
92
+ // verdict_submit's schema says to send it on every clean pass rather than
93
+ // `other`. Either way it prints as no tag at all: a `[none]` prefix carries
94
+ // nothing and was what made three 1.4k-char notes read as a wall. A real reason
95
+ // tag stays — it is the KPI axis the trend line counts by.
96
+ function fmtRecentNote(n: RecentVerdictNote): string {
97
+ const body = truncate(n.note, RECENT_NOTE_CHARS);
98
+ return n.reason === "none" ? body : `[${n.reason}] ${body}`;
99
+ }
100
+
101
+ // Per-worktree rows repeat the same reason_code across worktrees — without a
102
+ // worktree scope, merge counts by reason BEFORE slicing, or the line reads
103
+ // "spec_gap (5×), scope_mismatch (2×), spec_gap (2×)" (measured) instead of
104
+ // one entry per reason with the true total.
105
+ function topReasonRows(
106
+ rows: ReasonCodeCount[],
107
+ cap: number,
108
+ mergeAcrossWorktrees: boolean,
109
+ ): ReasonCodeCount[] {
110
+ if (!mergeAcrossWorktrees) return rows.slice(0, cap);
111
+ const merged = new Map<string, number>();
112
+ for (const r of rows)
113
+ merged.set(r.reason, (merged.get(r.reason) ?? 0) + r.count);
114
+ return [...merged.entries()]
115
+ .map(([reason, count]) => ({ worktree: "(all)", reason, count }))
116
+ .sort((a, b) => b.count - a.count)
117
+ .slice(0, cap);
118
+ }
119
+
120
+ function hubLine(hubs: HubEntry[]): string | null {
121
+ if (hubs.length === 0) return null;
122
+ const list = hubs
123
+ .slice(0, HUB_MAX)
124
+ .map((h) => {
125
+ const transitiveBit =
126
+ h.transitive !== undefined && h.transitive > h.dependents
127
+ ? `, ${h.transitive} transitively`
128
+ : "";
129
+ return `${h.file} (imported by ${h.dependents} files${transitiveBit}${h.tested ? "" : "; untested"})`;
130
+ })
131
+ .join(" · ");
132
+ return `- Hubs you are touching (high blast radius): ${list}`;
133
+ }
134
+
135
+ /**
136
+ * Pick the best model per regime from real graded work, on the same Pareto
137
+ * frontier `stats --mode verdict` renders: quality up, tokens/pass down.
138
+ *
139
+ * This used to sort by failRate first. That axis is dead in practice — work is
140
+ * self-graded and nearly every bucket sits at 0 fails — so the sort fell
141
+ * through to tokens and almost never read quality at all. failRate is still
142
+ * reported, just no longer the ranking key.
143
+ *
144
+ * Buckets under `minN` are skipped: one lucky verdict is not a recommendation.
145
+ */
146
+ export function computeModelFit(
147
+ byRegime: StatsData["byRegime"],
148
+ worktree?: string,
149
+ minN = MIN_MODEL_FIT_N,
150
+ ): ModelFit[] {
151
+ const eligible = byRegime.filter(
152
+ (r) =>
153
+ r.regime !== "—" &&
154
+ r.model !== "—" &&
155
+ r.gates >= minN &&
156
+ (worktree ? r.worktree === worktree : true),
157
+ );
158
+
159
+ const byName = new Map<string, typeof eligible>();
160
+ for (const r of eligible) {
161
+ const list = byName.get(r.regime) ?? [];
162
+ list.push(r);
163
+ byName.set(r.regime, list);
164
+ }
165
+
166
+ const out: ModelFit[] = [];
167
+ for (const list of byName.values()) {
168
+ const { frontier } = computeFrontier(
169
+ list.map((r) => ({
170
+ model: r.model,
171
+ gates: r.gates,
172
+ fails: r.fails,
173
+ avgQuality: r.avgQuality,
174
+ tokensPerPass: r.tokensPerPass,
175
+ })),
176
+ minN,
177
+ );
178
+ // The frontier is built cheapest-first with quality rising, so its last row
179
+ // is the best quality nothing beats outright. An empty frontier means no
180
+ // bucket had token attribution — then quality alone decides.
181
+ const best =
182
+ frontier[frontier.length - 1]?.model ??
183
+ [...list].sort(
184
+ (a, b) => b.avgQuality - a.avgQuality || a.failRate - b.failRate,
185
+ )[0].model;
186
+ const row = list.find((r) => r.model === best)!;
187
+ out.push({
188
+ regime: row.regime,
189
+ model: row.model,
190
+ gates: row.gates,
191
+ failRate: row.failRate,
192
+ avgQuality: row.avgQuality,
193
+ tokensPerPass: row.tokensPerPass,
194
+ });
195
+ }
196
+ return out.slice(0, MAX_MODEL_FIT);
197
+ }
198
+
199
+ /**
200
+ * Build the "known patterns" block (spec §3 shape, capped ~15 lines).
201
+ * Low-history scopes get an explicit "not enough history yet" line instead
202
+ * of noise from n=1 patterns looking like trends.
203
+ */
204
+ export function buildProjectHealthContext(
205
+ data: StatsData,
206
+ opts?: HealthContextOptions,
207
+ ): string {
208
+ const worktree = opts?.worktree;
209
+ const topReasons = opts?.topReasons ?? 3;
210
+ const minRuns = opts?.minRuns ?? 5;
211
+ const files = opts?.files;
212
+
213
+ const total = worktree
214
+ ? (data.byWorktree.find((w) => w.worktree === worktree)?.runs ?? 0)
215
+ : data.runs.total;
216
+ const scope = worktree ?? "all worktrees";
217
+ const header = `## Known patterns for this project (from fapony history, N=${total} runs, ${scope})`;
218
+
219
+ // Lead with the two things no code-exploration tool can produce: what this
220
+ // project already decided (mem) and which model actually holds up for each
221
+ // task shape (the ledger). Everything below is the older, weaker watch-fors.
222
+ const lead: string[] = [];
223
+ const memDecisions = (opts?.memDecisions ?? []).slice(0, MEM_DECISION_MAX);
224
+ if (memDecisions.length > 0) {
225
+ const list = memDecisions
226
+ .map((d) => `"${truncate(d.text, MEM_DECISION_CHARS)}"`)
227
+ .join(" · ");
228
+ lead.push(`- Decisions on record (mem): ${list}`);
229
+ }
230
+ for (const f of computeModelFit(data.byRegime, worktree)) {
231
+ const bits = [
232
+ `failRate ${Math.round(f.failRate * 100)}%`,
233
+ `quality ${f.avgQuality.toFixed(1)}`,
234
+ `N=${f.gates}`,
235
+ ];
236
+ if (f.tokensPerPass !== null) {
237
+ bits.push(`${fmtShortTokens(f.tokensPerPass)} tok/pass`);
238
+ }
239
+ lead.push(
240
+ `- Model fit: regime=${f.regime} → ${f.model} (${bits.join(", ")})`,
241
+ );
242
+ }
243
+
244
+ // Recent free-text notes carry signal from N=1 (a specific "worked around
245
+ // X" beats a count) — unlike the trend lines below, not gated by minRuns.
246
+ // Order matters: filter (worktree, then files) BEFORE slicing, so a match
247
+ // sitting past the top-3 cutoff still surfaces when files[] is given.
248
+ let notes = worktree
249
+ ? data.recentVerdictNotes.filter((n) => n.worktree === worktree)
250
+ : data.recentVerdictNotes;
251
+ // When files[] is provided, keep only notes that mention at least one of
252
+ // the target files (substring match on note text or stored files array).
253
+ if (files && files.length > 0) {
254
+ const fileSet = new Set(files.map((f) => f.toLowerCase()));
255
+ notes = notes.filter((n) => {
256
+ // Check stored files array first (reliable, from gate event data).
257
+ if (n.files && n.files.length > 0) {
258
+ return n.files.some((f) => fileSet.has(f.toLowerCase()));
259
+ }
260
+ // Fallback: substring match on note text.
261
+ const noteLower = n.note.toLowerCase();
262
+ return files.some((f) => noteLower.includes(f.toLowerCase()));
263
+ });
264
+ }
265
+ notes = notes.slice(0, 3);
266
+
267
+ // File risk for exactly the files being touched. Unlike the trend lines
268
+ // below this is NOT gated by minRuns: "this file failed last time" is
269
+ // actionable at n=1, and the count is printed so the reader can weigh it.
270
+ let riskLine: string | null = null;
271
+ if (files && files.length > 0) {
272
+ const fileSet = new Set(files.map((f) => f.toLowerCase()));
273
+ const hits = data.byFile
274
+ .filter((f) => (worktree ? f.worktree === worktree : true))
275
+ .filter((f) => f.fails > 0 && fileSet.has(f.file.toLowerCase()))
276
+ .slice(0, 3);
277
+ if (hits.length > 0) {
278
+ riskLine = `- Files you are touching that failed before: ${hits
279
+ .map(
280
+ (h) =>
281
+ `${h.file} (${h.fails}/${h.gates} graded touches failed${h.lastReason ? `, last: ${h.lastReason}` : ""})`,
282
+ )
283
+ .join(" · ")}`;
284
+ }
285
+ }
286
+
287
+ // Structural hubs (computed at the handler — pure here) warn before an
288
+ // edit: same watch-for framing as riskLine, but deterministic, not history.
289
+ const hub = hubLine(opts?.hubs ?? []);
290
+
291
+ if (total < minRuns) {
292
+ const lines = [
293
+ header,
294
+ ...lead,
295
+ `- Not enough history yet (${total} runs, need ${minRuns}+) for recurring patterns; draft freely.`,
296
+ ];
297
+ if (riskLine) lines.push(riskLine);
298
+ if (hub) lines.push(hub);
299
+ if (notes.length > 0) {
300
+ lines.push(
301
+ `- Recent verdict notes: ${notes.map(fmtRecentNote).join(" · ")}`,
302
+ );
303
+ }
304
+ return lines.slice(0, 15).join("\n");
305
+ }
306
+
307
+ const reasons = topReasonRows(
308
+ worktree
309
+ ? data.byReasonCode.filter((r) => r.worktree === worktree)
310
+ : data.byReasonCode,
311
+ Math.max(topReasons, 0),
312
+ !worktree,
313
+ );
314
+ const escalated = worktree
315
+ ? data.escalatedRuns.filter((e) => e.worktree === worktree)
316
+ : data.escalatedRuns;
317
+ const passing = (
318
+ worktree
319
+ ? data.bestPassing.filter((b) => b.worktree === worktree)
320
+ : data.bestPassing
321
+ ).slice(0, 3);
322
+
323
+ const lines = [header, ...lead];
324
+ if (riskLine) lines.push(riskLine);
325
+ if (hub) lines.push(hub);
326
+ if (reasons.length > 0) {
327
+ const list = reasons.map((r) => `${r.reason} (${r.count}×)`).join(", ");
328
+ lines.push(
329
+ `- Recurring fail reasons (non-pass gates): ${list} — watch for these in the new plan.`,
330
+ );
331
+ }
332
+ if (escalated.length > 0) {
333
+ // Top-1 concrete example only (PLAN §5: never dump full history).
334
+ const ex = escalated[0];
335
+ const planBit = ex.plan
336
+ ? ` (e.g. plan "${ex.plan}", round ${ex.round})`
337
+ : "";
338
+ lines.push(
339
+ `- ${escalated.length} run${escalated.length === 1 ? "" : "s"} escalated past the round cap${planBit} — likely the plan was underspecified, not the code.`,
340
+ );
341
+ }
342
+ if (passing.length > 0) {
343
+ const list = passing.map((b) => `"${b.plan}"`).join(", ");
344
+ lines.push(
345
+ `- Passed round 1 before: ${list} — shapes worth reusing when they fit.`,
346
+ );
347
+ }
348
+ if (notes.length > 0) {
349
+ lines.push(
350
+ `- Recent verdict notes: ${notes.map(fmtRecentNote).join(" · ")}`,
351
+ );
352
+ }
353
+ if (lines.length === 1) {
354
+ lines.push(
355
+ "- No recurring failure or escalation patterns observed — draft freely, keep the scope tight.",
356
+ );
357
+ }
358
+ return lines.slice(0, 15).join("\n");
359
+ }