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.
- package/README.md +94 -77
- package/fapony.ts +32 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/package.json +5 -3
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +13 -1
- package/src/conventions-seed.ts +0 -1
- package/src/db/defaults.ts +1 -1
- package/src/db/getters.ts +3 -3
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +1 -1
- package/src/debt.ts +169 -34
- package/src/digest/collect.ts +16 -0
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +424 -16
- package/src/init-mem.ts +123 -37
- package/src/init.ts +51 -34
- package/src/install/claude.ts +7 -5
- package/src/install/opencode.ts +59 -5
- package/src/lint-baseline.ts +1 -7
- package/src/mcp/evidence.ts +1 -1
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +348 -0
- package/src/memory.ts +237 -40
- package/src/plan-seed.ts +20 -1
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +4 -6
- package/src/stats/data.ts +34 -86
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /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(
|
|
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
|
}
|
package/src/review-seed.ts
CHANGED
|
@@ -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: ["
|
|
84
|
-
close: ["
|
|
85
|
-
add: ["
|
|
86
|
-
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
|
|
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
|
|
606
|
-
* gates (measured here: 35 sessions behind 58 gates, up to 5
|
|
607
|
-
*
|
|
608
|
-
*
|
|
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
|
|
611
|
-
bucket: {
|
|
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
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
897
|
+
addGateTokenShare(bucket, g, gatesPerSession);
|
|
950
898
|
}
|
|
951
899
|
const byRegime = Object.values(regimeMap)
|
|
952
900
|
.map((b) => ({
|
package/src/stats/format.ts
CHANGED
|
@@ -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 |
|
|
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
|
-
|
|
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)} | ${
|
|
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
|
}
|
package/src/stats/index.ts
CHANGED
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>
|
package/src/mcp/tools/context.ts
DELETED
|
@@ -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
|
-
}
|