fapony 0.1.2 → 0.2.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 (53) hide show
  1. package/README.md +94 -77
  2. package/fapony.ts +32 -1
  3. package/images/logo.png +0 -0
  4. package/images/logo.webp +0 -0
  5. package/images/logo@400.webp +0 -0
  6. package/images/sample.webp +0 -0
  7. package/images/summary.webp +0 -0
  8. package/package.json +5 -3
  9. package/skill/move-to-done/SKILL.md +18 -28
  10. package/skill/plan-with-pony/SKILL.md +8 -3
  11. package/src/analyze.ts +13 -1
  12. package/src/conventions-seed.ts +0 -1
  13. package/src/db/defaults.ts +1 -1
  14. package/src/db/getters.ts +3 -3
  15. package/src/db/store.ts +0 -75
  16. package/src/db/types.ts +1 -1
  17. package/src/debt.ts +169 -34
  18. package/src/digest/collect.ts +16 -0
  19. package/src/digest/text.ts +25 -0
  20. package/src/hook.ts +424 -16
  21. package/src/init-mem.ts +123 -37
  22. package/src/init.ts +51 -34
  23. package/src/install/claude.ts +7 -5
  24. package/src/install/opencode.ts +59 -5
  25. package/src/lint-baseline.ts +1 -7
  26. package/src/mcp/evidence.ts +1 -1
  27. package/src/mcp/tools/index.ts +97 -185
  28. package/src/mcp/tools/mem.ts +153 -11
  29. package/src/mcp/transport.ts +5 -13
  30. package/src/mem/commands/read.ts +448 -0
  31. package/src/mem/commands/where.ts +56 -0
  32. package/{templates → src}/mem/commands/write.ts +12 -5
  33. package/src/mem/index.ts +144 -0
  34. package/src/mem/store.ts +348 -0
  35. package/src/memory.ts +237 -40
  36. package/src/plan-seed.ts +20 -1
  37. package/src/review-seed.ts +19 -0
  38. package/src/setup.ts +4 -6
  39. package/src/stats/data.ts +34 -86
  40. package/src/stats/format.ts +8 -9
  41. package/src/stats/index.ts +0 -1
  42. package/templates/PLAN.md +1 -0
  43. package/src/mcp/tools/context.ts +0 -66
  44. package/src/mcp/tools/plans.ts +0 -255
  45. package/src/mcp/tools/stats.ts +0 -96
  46. package/templates/mem/commands/read.ts +0 -194
  47. package/templates/mem/commands/selftest.ts +0 -450
  48. package/templates/mem/mem.ts +0 -68
  49. package/templates/mem/store.ts +0 -285
  50. /package/{templates → src}/mem/commands/plan.ts +0 -0
  51. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  52. /package/{templates → src}/mem/render.ts +0 -0
  53. /package/{templates → src}/mem/selectors.ts +0 -0
package/src/plan-seed.ts CHANGED
@@ -181,6 +181,7 @@ function planTemplate(
181
181
  priorArt: string,
182
182
  contextFapony: string,
183
183
  specLink: string | null,
184
+ planRel: string,
184
185
  ): string {
185
186
  return `---
186
187
  kind: unit
@@ -216,8 +217,20 @@ _(agent fills in)_
216
217
  _(agent fills in)_
217
218
 
218
219
  ## 6. Steps (what in which order)
220
+ One step = one chunk = one session: finish it, close it, **stop** — starting the
221
+ next step in the same session is what rule 9 forbids.
222
+
219
223
  1. _(agent fills in — each step must be verifiable)_
220
224
 
225
+ **Closing a step:** tick its TL;DR box with the sha · \`git commit\` this step's
226
+ files only · \`verdict_submit\` (MCP) with this step's \`regime\` · then hand off:
227
+
228
+ \`\`\`bash
229
+ fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}
230
+ \`\`\`
231
+
232
+ Next session opens with \`kickoff ${planRel}\` (or \`kickoff ${basename(planRel)}\` — kickoff resolves by filename too, so no need to retype the path).
233
+
221
234
  ## 7. Examples
222
235
  ${
223
236
  specLink
@@ -595,7 +608,13 @@ export function cmdPlanSeed(args: string[]): void {
595
608
  mkdirSync(planDirAbs, { recursive: true });
596
609
  writeFileSync(
597
610
  planPath,
598
- planTemplate(name, priorArt, contextFapony, specLink),
611
+ planTemplate(
612
+ name,
613
+ priorArt,
614
+ contextFapony,
615
+ specLink,
616
+ `${planDir(config)}/PLAN-${name}.md`,
617
+ ),
599
618
  );
600
619
  console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
601
620
  }
@@ -270,6 +270,10 @@ function expandFilesScope(
270
270
  const notFound: string[] = [];
271
271
  const emptyDirs: string[] = [];
272
272
  const dirs: string[] = [];
273
+ // Per-dir top-level subdir counts, gathered while walking — cheap because
274
+ // it reuses `rels` already collected below; only printed if the cap cuts
275
+ // something, so a scope that fits never pays for it in the output.
276
+ const breakdowns: string[] = [];
273
277
  let dirExpanded = false;
274
278
  let cutNamed = 0;
275
279
  let cutExpanded = 0;
@@ -310,6 +314,20 @@ function expandFilesScope(
310
314
  dirExpanded = true;
311
315
  const rels = collectSourceFiles(join(worktree, p));
312
316
  if (rels.length === 0) emptyDirs.push(p);
317
+ if (rels.length > MAX_CHANGED_FILES) {
318
+ const counts = new Map<string, number>();
319
+ for (const r of rels) {
320
+ const top = r.includes("/") ? r.slice(0, r.indexOf("/")) : "(root)";
321
+ counts.set(top, (counts.get(top) ?? 0) + 1);
322
+ }
323
+ const list = [...counts.entries()]
324
+ .sort((a, b) => b[1] - a[1])
325
+ .map(
326
+ ([name, count]) => `${p === "." ? name : `${p}/${name}`} (${count})`,
327
+ )
328
+ .join(" · ");
329
+ breakdowns.push(`${p} (${rels.length} files) → ${list}`);
330
+ }
313
331
  for (const r of rels) add(p === "." ? r : `${p}/${r}`, true);
314
332
  }
315
333
  if (notFound.length > 0) {
@@ -329,6 +347,7 @@ function expandFilesScope(
329
347
  notes.push(
330
348
  `… +${cutExpanded} more file(s) under the expanded dirs — capped at ${MAX_CHANGED_FILES}, narrow the scope`,
331
349
  );
350
+ for (const b of breakdowns) notes.push(` ${b}`);
332
351
  }
333
352
  return { files, notes, dirExpanded };
334
353
  }
package/src/setup.ts CHANGED
@@ -6,7 +6,6 @@ import { existsSync, statSync, writeFileSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { seedConventionsFile } from "./conventions-seed.js";
9
- import { DEFAULT_MEMORY_ENTRY } from "./db/index.js";
10
9
  import { initProject } from "./init.js";
11
10
  import { isAffirmative } from "./util.js";
12
11
 
@@ -78,12 +77,11 @@ export function buildSetupConfig(a: SetupAnswers): Record<string, unknown> {
78
77
  };
79
78
 
80
79
  if (a.enableMemory) {
81
- const memEntry = DEFAULT_MEMORY_ENTRY;
82
80
  config.memory = {
83
- claim: ["bun", memEntry, "claim", "{id}"],
84
- close: ["bun", memEntry, "close", "{id}", "{msg}"],
85
- add: ["bun", memEntry, "add", "{kind}", "{text}"],
86
- kickoff: ["bun", memEntry, "kickoff"],
81
+ claim: ["fapony", "mem", "claim", "{id}"],
82
+ close: ["fapony", "mem", "close", "{id}", "{msg}"],
83
+ add: ["fapony", "mem", "add", "{kind}", "{text}"],
84
+ kickoff: ["fapony", "mem", "kickoff"],
87
85
  };
88
86
  }
89
87
 
package/src/stats/data.ts CHANGED
@@ -397,72 +397,6 @@ export function getPlanBreakdown(
397
397
  .sort((a, b) => b.runs - a.runs);
398
398
  }
399
399
 
400
- export interface PlanLastVerdict {
401
- plan: string;
402
- runs: number;
403
- lastVerdict: string;
404
- /** null for a pass-family last verdict (gateReason only flags non-pass). */
405
- lastReasonCode: string | null;
406
- escalated: boolean;
407
- }
408
-
409
- /**
410
- * Most recent gate verdict per plan string, for `plan_list` (mcp/tools/plans.ts)
411
- * to join filesystem plan files against real run history — "2 runs, last:
412
- * fail(spec_gap)" instead of a bare directory listing.
413
- */
414
- export function getLastVerdictByPlan(
415
- runs: Run[],
416
- events: Event[],
417
- maxRounds: number,
418
- ): PlanLastVerdict[] {
419
- const runById = new Map(runs.map((r) => [r.id, r]));
420
- const runCounts = new Map<string, number>();
421
- const escalatedPlans = new Set<string>();
422
- for (const r of runs) {
423
- if (!r.plan) continue;
424
- runCounts.set(r.plan, (runCounts.get(r.plan) ?? 0) + 1);
425
- if (r.round > maxRounds) escalatedPlans.add(r.plan);
426
- }
427
-
428
- const lastGateByPlan = new Map<
429
- string,
430
- { ts: string; verdict: string; reason: string | null }
431
- >();
432
- for (const e of events) {
433
- if (e.kind !== "gate" || !e.data) continue;
434
- const plan = runById.get(e.run_id)?.plan;
435
- if (!plan) continue;
436
- let verdict: string | null = null;
437
- try {
438
- const d = JSON.parse(e.data) as { verdict?: unknown };
439
- if (typeof d.verdict === "string") verdict = d.verdict;
440
- } catch {
441
- continue;
442
- }
443
- if (!verdict) continue;
444
- const prev = lastGateByPlan.get(plan);
445
- if (!prev || e.ts >= prev.ts) {
446
- lastGateByPlan.set(plan, {
447
- ts: e.ts,
448
- verdict,
449
- reason: gateReason(e.data),
450
- });
451
- }
452
- }
453
-
454
- return [...runCounts.keys()].map((plan) => {
455
- const last = lastGateByPlan.get(plan);
456
- return {
457
- plan,
458
- runs: runCounts.get(plan) ?? 0,
459
- lastVerdict: last?.verdict ?? "(no gate yet)",
460
- lastReasonCode: last?.reason ?? null,
461
- escalated: escalatedPlans.has(plan),
462
- };
463
- });
464
- }
465
-
466
400
  /** Runs past the round cap — plan-quality signal, not code (CLAUDE.md #2). */
467
401
  function getEscalatedRuns(runs: Run[], maxRounds: number): EscalatedRun[] {
468
402
  return runs
@@ -600,25 +534,35 @@ export interface StatsData {
600
534
  }
601
535
 
602
536
  /**
603
- * Charge a session's token totals to a bucket exactly once.
537
+ * Charge a gate its share of its session's token total: 1/N, N = that
538
+ * session's gate count.
604
539
  *
605
- * Tokens are a per-session total, and one session routinely produces several
606
- * gates (measured here: 35 sessions behind 58 gates, up to 5 gates in one).
607
- * Summing per gate would multiply that session's tokens by its gate count —
608
- * unevenly across models, so the ranking itself would be wrong.
540
+ * Tokens are a per-session total and one session routinely produces several
541
+ * gates (measured here: 35 sessions behind 58 gates, up to 5 in one), so
542
+ * summing the full total per gate would multiply it by the gate count.
543
+ * Charging it once per bucket instead — what this did until 2026-09-19 — is
544
+ * right within one table and wrong across tables: a session spanning several
545
+ * regimes charged its whole total to every regime it touched. Measured then,
546
+ * claude-opus-5 totalled 218.5M input in by-model while its five by-regime
547
+ * rows summed to 847M (3.9x), and the inflation scaled with how many regimes
548
+ * a model was used in — so `--mode verdict` ranked the broadly-used models as
549
+ * the expensive ones. Shares sum back to the session total in every table,
550
+ * which is why by-model is unchanged by this: one session is one model, so
551
+ * its N shares land in a single bucket.
609
552
  */
610
- function addSessionTokens(
611
- bucket: { seen: Set<string>; tokensInput: number; tokensOutput: number },
553
+ function addGateTokenShare(
554
+ bucket: { tokensInput: number; tokensOutput: number },
612
555
  g: {
613
556
  sessionId: string | null;
614
557
  tokensInput: number | null;
615
558
  tokensOutput: number | null;
616
559
  },
560
+ gatesPerSession: Map<string, number>,
617
561
  ): void {
618
- if (!g.sessionId || bucket.seen.has(g.sessionId)) return;
619
- bucket.seen.add(g.sessionId);
620
- if (g.tokensInput !== null) bucket.tokensInput += g.tokensInput;
621
- if (g.tokensOutput !== null) bucket.tokensOutput += g.tokensOutput;
562
+ if (!g.sessionId) return;
563
+ const n = gatesPerSession.get(g.sessionId) ?? 1;
564
+ bucket.tokensInput += (g.tokensInput ?? 0) / n;
565
+ bucket.tokensOutput += (g.tokensOutput ?? 0) / n;
622
566
  }
623
567
 
624
568
  /**
@@ -733,6 +677,16 @@ export function getStatsData(worktree?: string): StatsData {
733
677
  // --- Gate enrichment ---
734
678
  const wtByRun = new Map(runs.map((r) => [r.id, r.worktree]));
735
679
  const enriched = enrichGates(events, wtByRun);
680
+ // Denominator for the per-gate token share — see addGateTokenShare.
681
+ const gatesPerSession = new Map<string, number>();
682
+ for (const g of enriched) {
683
+ if (g.sessionId) {
684
+ gatesPerSession.set(
685
+ g.sessionId,
686
+ (gatesPerSession.get(g.sessionId) ?? 0) + 1,
687
+ );
688
+ }
689
+ }
736
690
 
737
691
  // Group by worktree+client+provider+model+agent — the same model name on two
738
692
  // providers is two different things. Unknown dimension → "—" (never ""
@@ -749,7 +703,6 @@ export function getStatsData(worktree?: string): StatsData {
749
703
  fails: number;
750
704
  passes: number;
751
705
  qualities: number[];
752
- seen: Set<string>;
753
706
  tokensInput: number;
754
707
  tokensOutput: number;
755
708
  }
@@ -771,7 +724,6 @@ export function getStatsData(worktree?: string): StatsData {
771
724
  fails: 0,
772
725
  passes: 0,
773
726
  qualities: [],
774
- seen: new Set(),
775
727
  tokensInput: 0,
776
728
  tokensOutput: 0,
777
729
  });
@@ -780,7 +732,7 @@ export function getStatsData(worktree?: string): StatsData {
780
732
  if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
781
733
  const grade = g.verdict as VerdictGrade;
782
734
  if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
783
- addSessionTokens(bucket, g);
735
+ addGateTokenShare(bucket, g, gatesPerSession);
784
736
  }
785
737
  const byModel = Object.values(modelMap)
786
738
  .map((b) => ({
@@ -846,7 +798,6 @@ export function getStatsData(worktree?: string): StatsData {
846
798
  fails: number;
847
799
  passes: number;
848
800
  qualities: number[];
849
- seen: Set<string>;
850
801
  tokensInput: number;
851
802
  tokensOutput: number;
852
803
  }
@@ -864,7 +815,6 @@ export function getStatsData(worktree?: string): StatsData {
864
815
  fails: 0,
865
816
  passes: 0,
866
817
  qualities: [],
867
- seen: new Set<string>(),
868
818
  tokensInput: 0,
869
819
  tokensOutput: 0,
870
820
  });
@@ -873,7 +823,7 @@ export function getStatsData(worktree?: string): StatsData {
873
823
  if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
874
824
  const grade = g.verdict as VerdictGrade;
875
825
  if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
876
- addSessionTokens(bucket, g);
826
+ addGateTokenShare(bucket, g, gatesPerSession);
877
827
  }
878
828
  const byPlanMode = Object.values(planModeMap)
879
829
  .map((b) => ({
@@ -919,7 +869,6 @@ export function getStatsData(worktree?: string): StatsData {
919
869
  fails: number;
920
870
  passes: number;
921
871
  qualities: number[];
922
- seen: Set<string>;
923
872
  tokensInput: number;
924
873
  tokensOutput: number;
925
874
  }
@@ -937,7 +886,6 @@ export function getStatsData(worktree?: string): StatsData {
937
886
  fails: 0,
938
887
  passes: 0,
939
888
  qualities: [],
940
- seen: new Set<string>(),
941
889
  tokensInput: 0,
942
890
  tokensOutput: 0,
943
891
  });
@@ -946,7 +894,7 @@ export function getStatsData(worktree?: string): StatsData {
946
894
  if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
947
895
  const grade = g.verdict as VerdictGrade;
948
896
  if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
949
- addSessionTokens(bucket, g);
897
+ addGateTokenShare(bucket, g, gatesPerSession);
950
898
  }
951
899
  const byRegime = Object.values(regimeMap)
952
900
  .map((b) => ({
@@ -192,28 +192,27 @@ export function formatStatsText(data: StatsData): string {
192
192
  }
193
193
  }
194
194
 
195
+ // No token columns here: cost per regime is what `--mode verdict` ranks on,
196
+ // and printing it twice invited reading this table as the cost answer when
197
+ // it is the coverage one. Quality and fails are what this table adds.
195
198
  if (data.byRegime.length > 0) {
196
199
  const showWt = !data.scope;
197
200
  lines.push("\nby regime:");
198
201
  if (showWt) {
199
202
  lines.push(
200
- " project | regime | model | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
203
+ " project | regime | model | gates | fails | failRate | avgQuality",
201
204
  );
202
205
  lines.push(
203
- " ---------|--------|-------|-------|-------|----------|-------------|------------|----------------",
206
+ " ---------|--------|-------|-------|-------|----------|------------",
204
207
  );
205
208
  } else {
206
- lines.push(
207
- " regime | model | gates | fails | failRate | tokens/pass | avgQuality | tokens/session",
208
- );
209
- lines.push(
210
- " --------|-------|-------|-------|-------|----------|-------------|------------|----------------",
211
- );
209
+ lines.push(" regime | model | gates | fails | failRate | avgQuality");
210
+ lines.push(" --------|-------|-------|-------|----------|------------");
212
211
  }
213
212
  for (const r of data.byRegime) {
214
213
  const wt = showWt ? `${shortWt(r.worktree).padEnd(9)} | ` : "";
215
214
  lines.push(
216
- ` ${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`,
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)} | ${r.avgQuality.toFixed(1).padStart(10)}`,
217
216
  );
218
217
  }
219
218
  }
@@ -3,7 +3,6 @@
3
3
  export { cmdStats, currentWorktree } from "./cli.js";
4
4
  export {
5
5
  countPendingPlans,
6
- getLastVerdictByPlan,
7
6
  getPlanBreakdown,
8
7
  getStatsData,
9
8
  type PlanBreakdown,
package/templates/PLAN.md CHANGED
@@ -11,6 +11,7 @@ blocked_by: <plan or sentence> # required when status: blocked
11
11
  blocks: PLAN-<other>.md # plans that cannot start until this one lands (comma-separated)
12
12
  superseded_by: PLAN-<other>.md # required when status: superseded
13
13
  spec: SPEC-<feature>.md # if any
14
+ priority: high # optional: high = appears first in kickoff · omit = normal
14
15
  ---
15
16
 
16
17
  # PLAN-<feature>.md — <short name>
@@ -1,66 +0,0 @@
1
- // src/mcp/tools/context.ts — project_health_context tool
2
- //
3
- // Single call, any caller: returns the paste-ready "known patterns" block built
4
- // from real run history, keyed by files[] (no raw dump, ~15 lines max). Not a
5
- // pre-edit reflex — most files have no history (see CLAUDE.md rule 8); it earns
6
- // its call on a file that does. `plan-with-pony` is one caller, not the only one.
7
- //
8
- // It also surfaces project decisions from the mem log — that read is the only
9
- // I/O here; `buildProjectHealthContext` stays pure and just renders.
10
-
11
- import { blastRadiusForWorktree } from "../../analyze.js";
12
- import {
13
- buildProjectHealthContext,
14
- HUB_DEPENDENTS_MIN,
15
- type HubEntry,
16
- } from "../../context/index.js";
17
- import { readRecentMemDecisions } from "../../memory.js";
18
- import { getStatsData } from "../../stats/index.js";
19
- import type { ToolResult } from "../types.js";
20
-
21
- export function toolProjectHealthContext(
22
- args: Record<string, unknown>,
23
- ): ToolResult {
24
- const worktree =
25
- typeof args.worktree === "string" && args.worktree
26
- ? args.worktree
27
- : undefined;
28
- const files =
29
- Array.isArray(args.files) && args.files.length > 0
30
- ? args.files.filter(
31
- (f): f is string => typeof f === "string" && f.length > 0,
32
- )
33
- : undefined;
34
-
35
- // No worktree (or no log) → no decisions line, block still returns.
36
- const memDecisions = worktree
37
- ? readRecentMemDecisions(worktree, 3, files).map((r) => ({
38
- text: r.text,
39
- spec: r.spec,
40
- }))
41
- : [];
42
-
43
- // Hub detection needs both worktree (graph root) and files[] (what to map).
44
- // Same live-graph cost handoff_check already pays; null/unreadable graph →
45
- // no hub line, never a throw (PLAN-hub-signal §3).
46
- const hubs: HubEntry[] =
47
- worktree && files
48
- ? Object.entries(blastRadiusForWorktree(worktree, files) ?? {})
49
- .filter(([, b]) => b.dependents >= HUB_DEPENDENTS_MIN)
50
- .sort((a, b) => b[1].dependents - a[1].dependents)
51
- .map(([file, b]) => ({
52
- file,
53
- dependents: b.dependents,
54
- tested: b.tested,
55
- transitive: b.transitive,
56
- }))
57
- : [];
58
-
59
- const block = buildProjectHealthContext(getStatsData(), {
60
- worktree,
61
- files,
62
- memDecisions,
63
- hubs,
64
- });
65
- return { content: [{ type: "text", text: block }] };
66
- }