fapony 0.1.3 → 0.2.1

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 (51) hide show
  1. package/README.md +111 -46
  2. package/fapony.ts +35 -2
  3. package/package.json +3 -2
  4. package/skill/move-to-done/SKILL.md +18 -28
  5. package/skill/plan-with-pony/SKILL.md +8 -3
  6. package/src/analyze.ts +175 -5
  7. package/src/conventions-seed.ts +3 -3
  8. package/src/db/defaults.ts +13 -5
  9. package/src/db/getters.ts +11 -9
  10. package/src/db/load.ts +2 -2
  11. package/src/db/store.ts +0 -75
  12. package/src/db/types.ts +2 -4
  13. package/src/debt.ts +176 -36
  14. package/src/digest/collect.ts +19 -2
  15. package/src/digest/text.ts +25 -0
  16. package/src/hook.ts +665 -24
  17. package/src/init-mem.ts +123 -37
  18. package/src/init.ts +57 -39
  19. package/src/install/claude.ts +44 -8
  20. package/src/install/codex.ts +105 -13
  21. package/src/install/opencode.ts +169 -5
  22. package/src/install.ts +2 -1
  23. package/src/lint-baseline.ts +3 -8
  24. package/src/mcp/evidence.ts +15 -3
  25. package/src/mcp/tools/index.ts +97 -185
  26. package/src/mcp/tools/mem.ts +153 -11
  27. package/src/mcp/transport.ts +5 -13
  28. package/src/mem/commands/read.ts +448 -0
  29. package/src/mem/commands/where.ts +56 -0
  30. package/{templates → src}/mem/commands/write.ts +12 -5
  31. package/src/mem/index.ts +144 -0
  32. package/src/mem/store.ts +350 -0
  33. package/src/memory.ts +238 -40
  34. package/src/plan-seed.ts +26 -6
  35. package/src/review-seed.ts +19 -0
  36. package/src/setup.ts +7 -8
  37. package/src/stats/data.ts +40 -104
  38. package/src/stats/format.ts +8 -9
  39. package/src/stats/index.ts +0 -1
  40. package/templates/PLAN.md +1 -0
  41. package/src/mcp/tools/context.ts +0 -66
  42. package/src/mcp/tools/plans.ts +0 -255
  43. package/src/mcp/tools/stats.ts +0 -96
  44. package/templates/mem/commands/read.ts +0 -194
  45. package/templates/mem/commands/selftest.ts +0 -450
  46. package/templates/mem/mem.ts +0 -68
  47. package/templates/mem/store.ts +0 -285
  48. /package/{templates → src}/mem/commands/plan.ts +0 -0
  49. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  50. /package/{templates → src}/mem/render.ts +0 -0
  51. /package/{templates → src}/mem/selectors.ts +0 -0
package/src/plan-seed.ts CHANGED
@@ -38,6 +38,7 @@ import { basename, join, relative, resolve, sep } from "node:path";
38
38
  import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "./analyze.js";
39
39
  import { computeModelFit } from "./context/projectHealth.js";
40
40
  import { doneDir, planDir, specDir } from "./db/getters.js";
41
+ import { CONFIG_FILENAME } from "./db/index.js";
41
42
  import { loadConfig } from "./db/load.js";
42
43
  import type { Config } from "./db/types.js";
43
44
  import { extractExports } from "./map.js";
@@ -97,7 +98,7 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
97
98
  if (keys.length === 0) return placeholder;
98
99
 
99
100
  const hits: { shipped: string; line: string }[] = [];
100
- for (const dir of [doneDir(config), specDir(config)]) {
101
+ for (const dir of [doneDir(config), specDir()]) {
101
102
  const abs = join(cwd, dir);
102
103
  let names: string[];
103
104
  try {
@@ -181,6 +182,7 @@ function planTemplate(
181
182
  priorArt: string,
182
183
  contextFapony: string,
183
184
  specLink: string | null,
185
+ planRel: string,
184
186
  ): string {
185
187
  return `---
186
188
  kind: unit
@@ -216,8 +218,20 @@ _(agent fills in)_
216
218
  _(agent fills in)_
217
219
 
218
220
  ## 6. Steps (what in which order)
221
+ One step = one chunk = one session: finish it, close it, **stop** — starting the
222
+ next step in the same session is what rule 9 forbids.
223
+
219
224
  1. _(agent fills in — each step must be verifiable)_
220
225
 
226
+ **Closing a step:** tick its TL;DR box with the sha · \`git commit\` this step's
227
+ files only · \`verdict_submit\` (MCP) with this step's \`regime\` · then hand off:
228
+
229
+ \`\`\`bash
230
+ fapony mem add note "<what chunk N+1 must know>" --files <f1,f2> ${planRel}
231
+ \`\`\`
232
+
233
+ Next session opens with \`kickoff ${planRel}\` (or \`kickoff ${basename(planRel)}\` — kickoff resolves by filename too, so no need to retype the path).
234
+
221
235
  ## 7. Examples
222
236
  ${
223
237
  specLink
@@ -511,7 +525,7 @@ export function cmdPlanSeed(args: string[]): void {
511
525
  process.exit(1);
512
526
  }
513
527
  const cwd = process.cwd();
514
- const config = loadConfig(join(cwd, "fapony.config.json"));
528
+ const config = loadConfig(join(cwd, CONFIG_FILENAME));
515
529
  // Resolve the git worktree root so mem/ledger queries hit the same key
516
530
  // state.db uses (git rev-parse --show-toplevel). Running from a subdir
517
531
  // would otherwise mismatch: stats return empty, Context (fapony) always
@@ -555,7 +569,7 @@ export function cmdPlanSeed(args: string[]): void {
555
569
  );
556
570
  }
557
571
 
558
- const planDirAbs = join(cwd, planDir(config));
572
+ const planDirAbs = join(cwd, planDir());
559
573
  const planPath = join(planDirAbs, `PLAN-${name}.md`);
560
574
  if (existsSync(planPath)) {
561
575
  console.error(
@@ -569,7 +583,7 @@ export function cmdPlanSeed(args: string[]): void {
569
583
 
570
584
  let specLink: string | null = null;
571
585
  if (withSpec) {
572
- const specDirAbs = join(cwd, specDir(config));
586
+ const specDirAbs = join(cwd, specDir());
573
587
  const specPath = join(specDirAbs, `SPEC-${name}.md`);
574
588
  if (existsSync(specPath)) {
575
589
  console.error(
@@ -589,13 +603,19 @@ export function cmdPlanSeed(args: string[]): void {
589
603
  : null,
590
604
  ),
591
605
  );
592
- specLink = `../${specDir(config).split("/").pop()}/SPEC-${name}.md`;
606
+ specLink = `../${specDir().split("/").pop()}/SPEC-${name}.md`;
593
607
  }
594
608
 
595
609
  mkdirSync(planDirAbs, { recursive: true });
596
610
  writeFileSync(
597
611
  planPath,
598
- planTemplate(name, priorArt, contextFapony, specLink),
612
+ planTemplate(
613
+ name,
614
+ priorArt,
615
+ contextFapony,
616
+ specLink,
617
+ `${planDir()}/PLAN-${name}.md`,
618
+ ),
599
619
  );
600
620
  console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
601
621
  }
@@ -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,7 @@ 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";
9
+ import { CONFIG_FILENAME, FAPONY_DIR } from "./db/index.js";
10
10
  import { initProject } from "./init.js";
11
11
  import { isAffirmative } from "./util.js";
12
12
 
@@ -78,12 +78,11 @@ export function buildSetupConfig(a: SetupAnswers): Record<string, unknown> {
78
78
  };
79
79
 
80
80
  if (a.enableMemory) {
81
- const memEntry = DEFAULT_MEMORY_ENTRY;
82
81
  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"],
82
+ claim: ["fapony", "mem", "claim", "{id}"],
83
+ close: ["fapony", "mem", "close", "{id}", "{msg}"],
84
+ add: ["fapony", "mem", "add", "{kind}", "{text}"],
85
+ kickoff: ["fapony", "mem", "kickoff"],
87
86
  };
88
87
  }
89
88
 
@@ -183,7 +182,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
183
182
  enableMemory,
184
183
  });
185
184
 
186
- const configPath = join(process.cwd(), "fapony.config.json");
185
+ const configPath = join(process.cwd(), CONFIG_FILENAME);
187
186
  if (existsSync(configPath)) {
188
187
  const overwrite = await askFn(
189
188
  "⚠ fapony.config.json already exists. Overwrite? (y/n)",
@@ -199,7 +198,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
199
198
  console.log(`\n ✓ Wrote ${configPath}`);
200
199
 
201
200
  // --- scaffold worktree ---
202
- const faponyDir = join(worktreePath, ".fapony");
201
+ const faponyDir = join(worktreePath, FAPONY_DIR);
203
202
  if (!existsSync(faponyDir)) {
204
203
  try {
205
204
  initProject(worktreePath);
package/src/stats/data.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  // src/stats/data.ts — StatsData shape + getStatsData()
2
2
 
3
- import { readdirSync, readFileSync } from "node:fs";
3
+ import { readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
-
6
- import { type Event, openDb, type Run } from "../db/index.js";
5
+ import { type Event, openDb, PLAN_DIR, type Run } from "../db/index.js";
7
6
  import { loadConfig } from "../db/load.js";
8
7
  import { enrichGateWindows } from "../gates.js";
9
8
  import { avg, minutesBetween } from "../math.js";
@@ -89,9 +88,7 @@ function enrichGates(
89
88
  /**
90
89
  * Count un-shipped plan files in a worktree's planDir.
91
90
  *
92
- * Reads the *target repo's own* fapony.config.json for `paths.planDir` — the
93
- * central config's worktrees map is optional and usually absent, and each repo
94
- * picks its own plan dir (vela uses apps/vela/plan, not .fapony/plan).
91
+ * planDir is hardcoded to .fapony/plan — not configurable (gitignored = private).
95
92
  *
96
93
  * Returns null — never 0 — when the path isn't a readable directory, so a
97
94
  * sentinel row like "mcp-external" renders as "—" instead of claiming
@@ -99,19 +96,10 @@ function enrichGates(
99
96
  */
100
97
  export function countPendingPlans(worktree: string): number | null {
101
98
  if (!worktree.startsWith("/")) return null;
102
- let planDir = ".fapony/plan";
103
99
  try {
104
- const cfg = JSON.parse(
105
- readFileSync(join(worktree, "fapony.config.json"), "utf8"),
106
- ) as { paths?: { planDir?: unknown } };
107
- if (typeof cfg.paths?.planDir === "string" && cfg.paths.planDir)
108
- planDir = cfg.paths.planDir;
109
- } catch {
110
- // no config (or unreadable/malformed) — fall back to the scaffold default
111
- }
112
- try {
113
- return readdirSync(join(worktree, planDir)).filter((f) => f.endsWith(".md"))
114
- .length;
100
+ return readdirSync(join(worktree, PLAN_DIR)).filter((f) =>
101
+ f.endsWith(".md"),
102
+ ).length;
115
103
  } catch {
116
104
  return null;
117
105
  }
@@ -397,72 +385,6 @@ export function getPlanBreakdown(
397
385
  .sort((a, b) => b.runs - a.runs);
398
386
  }
399
387
 
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
388
  /** Runs past the round cap — plan-quality signal, not code (CLAUDE.md #2). */
467
389
  function getEscalatedRuns(runs: Run[], maxRounds: number): EscalatedRun[] {
468
390
  return runs
@@ -600,25 +522,35 @@ export interface StatsData {
600
522
  }
601
523
 
602
524
  /**
603
- * Charge a session's token totals to a bucket exactly once.
525
+ * Charge a gate its share of its session's token total: 1/N, N = that
526
+ * session's gate count.
604
527
  *
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.
528
+ * Tokens are a per-session total and one session routinely produces several
529
+ * gates (measured here: 35 sessions behind 58 gates, up to 5 in one), so
530
+ * summing the full total per gate would multiply it by the gate count.
531
+ * Charging it once per bucket instead — what this did until 2026-09-19 — is
532
+ * right within one table and wrong across tables: a session spanning several
533
+ * regimes charged its whole total to every regime it touched. Measured then,
534
+ * claude-opus-5 totalled 218.5M input in by-model while its five by-regime
535
+ * rows summed to 847M (3.9x), and the inflation scaled with how many regimes
536
+ * a model was used in — so `--mode verdict` ranked the broadly-used models as
537
+ * the expensive ones. Shares sum back to the session total in every table,
538
+ * which is why by-model is unchanged by this: one session is one model, so
539
+ * its N shares land in a single bucket.
609
540
  */
610
- function addSessionTokens(
611
- bucket: { seen: Set<string>; tokensInput: number; tokensOutput: number },
541
+ function addGateTokenShare(
542
+ bucket: { tokensInput: number; tokensOutput: number },
612
543
  g: {
613
544
  sessionId: string | null;
614
545
  tokensInput: number | null;
615
546
  tokensOutput: number | null;
616
547
  },
548
+ gatesPerSession: Map<string, number>,
617
549
  ): 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;
550
+ if (!g.sessionId) return;
551
+ const n = gatesPerSession.get(g.sessionId) ?? 1;
552
+ bucket.tokensInput += (g.tokensInput ?? 0) / n;
553
+ bucket.tokensOutput += (g.tokensOutput ?? 0) / n;
622
554
  }
623
555
 
624
556
  /**
@@ -733,6 +665,16 @@ export function getStatsData(worktree?: string): StatsData {
733
665
  // --- Gate enrichment ---
734
666
  const wtByRun = new Map(runs.map((r) => [r.id, r.worktree]));
735
667
  const enriched = enrichGates(events, wtByRun);
668
+ // Denominator for the per-gate token share — see addGateTokenShare.
669
+ const gatesPerSession = new Map<string, number>();
670
+ for (const g of enriched) {
671
+ if (g.sessionId) {
672
+ gatesPerSession.set(
673
+ g.sessionId,
674
+ (gatesPerSession.get(g.sessionId) ?? 0) + 1,
675
+ );
676
+ }
677
+ }
736
678
 
737
679
  // Group by worktree+client+provider+model+agent — the same model name on two
738
680
  // providers is two different things. Unknown dimension → "—" (never ""
@@ -749,7 +691,6 @@ export function getStatsData(worktree?: string): StatsData {
749
691
  fails: number;
750
692
  passes: number;
751
693
  qualities: number[];
752
- seen: Set<string>;
753
694
  tokensInput: number;
754
695
  tokensOutput: number;
755
696
  }
@@ -771,7 +712,6 @@ export function getStatsData(worktree?: string): StatsData {
771
712
  fails: 0,
772
713
  passes: 0,
773
714
  qualities: [],
774
- seen: new Set(),
775
715
  tokensInput: 0,
776
716
  tokensOutput: 0,
777
717
  });
@@ -780,7 +720,7 @@ export function getStatsData(worktree?: string): StatsData {
780
720
  if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
781
721
  const grade = g.verdict as VerdictGrade;
782
722
  if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
783
- addSessionTokens(bucket, g);
723
+ addGateTokenShare(bucket, g, gatesPerSession);
784
724
  }
785
725
  const byModel = Object.values(modelMap)
786
726
  .map((b) => ({
@@ -846,7 +786,6 @@ export function getStatsData(worktree?: string): StatsData {
846
786
  fails: number;
847
787
  passes: number;
848
788
  qualities: number[];
849
- seen: Set<string>;
850
789
  tokensInput: number;
851
790
  tokensOutput: number;
852
791
  }
@@ -864,7 +803,6 @@ export function getStatsData(worktree?: string): StatsData {
864
803
  fails: 0,
865
804
  passes: 0,
866
805
  qualities: [],
867
- seen: new Set<string>(),
868
806
  tokensInput: 0,
869
807
  tokensOutput: 0,
870
808
  });
@@ -873,7 +811,7 @@ export function getStatsData(worktree?: string): StatsData {
873
811
  if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
874
812
  const grade = g.verdict as VerdictGrade;
875
813
  if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
876
- addSessionTokens(bucket, g);
814
+ addGateTokenShare(bucket, g, gatesPerSession);
877
815
  }
878
816
  const byPlanMode = Object.values(planModeMap)
879
817
  .map((b) => ({
@@ -919,7 +857,6 @@ export function getStatsData(worktree?: string): StatsData {
919
857
  fails: number;
920
858
  passes: number;
921
859
  qualities: number[];
922
- seen: Set<string>;
923
860
  tokensInput: number;
924
861
  tokensOutput: number;
925
862
  }
@@ -937,7 +874,6 @@ export function getStatsData(worktree?: string): StatsData {
937
874
  fails: 0,
938
875
  passes: 0,
939
876
  qualities: [],
940
- seen: new Set<string>(),
941
877
  tokensInput: 0,
942
878
  tokensOutput: 0,
943
879
  });
@@ -946,7 +882,7 @@ export function getStatsData(worktree?: string): StatsData {
946
882
  if (g.verdict && isPassFamily(g.verdict)) bucket.passes++;
947
883
  const grade = g.verdict as VerdictGrade;
948
884
  if (VERDICT_GRADES.has(grade)) bucket.qualities.push(qualityScore(grade));
949
- addSessionTokens(bucket, g);
885
+ addGateTokenShare(bucket, g, gatesPerSession);
950
886
  }
951
887
  const byRegime = Object.values(regimeMap)
952
888
  .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
- }