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,255 @@
1
+ // src/mcp/tools/plans.ts — plan_list tool
2
+ //
3
+ // Answers "what's pending, what's blocked on what, and what should come
4
+ // next" — not a directory listing (an agent can `ls` on its own for that).
5
+ // Two joins give it that: filesystem plan files × real run history from
6
+ // `runs`/`events`, and the plan's own frontmatter × the plans it points at.
7
+ //
8
+ // The frontmatter is deliberately tiny (4 keys) because everything else is
9
+ // derivable: "never touched" comes from run history, ordering comes from the
10
+ // blocks/blocked_by edges. A plan with no frontmatter at all still lands in a
11
+ // sensible group, so an existing repo gets value before anyone annotates it.
12
+
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import {
16
+ doneDir,
17
+ type Event,
18
+ loadConfig,
19
+ openDb,
20
+ planDir,
21
+ type Run,
22
+ } from "../../db/index.js";
23
+ import { getLastVerdictByPlan, resolveMaxRounds } from "../../stats/index.js";
24
+ import { errorResult, jsonResult, type ToolResult } from "../types.js";
25
+
26
+ type Front = {
27
+ kind?: string;
28
+ status?: string;
29
+ blocked_by?: string;
30
+ blocks: string[];
31
+ superseded_by?: string;
32
+ spec?: string;
33
+ };
34
+
35
+ type Entry = {
36
+ file: string;
37
+ title: string;
38
+ runs: number;
39
+ last: string;
40
+ escalated: boolean;
41
+ progress?: string;
42
+ blocked_by?: string;
43
+ blocks?: string[];
44
+ superseded_by?: string;
45
+ spec?: string;
46
+ };
47
+
48
+ const EMPTY: Front = { blocks: [] };
49
+
50
+ /** Frontmatter without a YAML dependency: flat `key: value` lines only. */
51
+ export function parseFront(text: string): Front {
52
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
53
+ if (!m) return { blocks: [] };
54
+ const front: Front = { blocks: [] };
55
+ for (const line of m[1].split(/\r?\n/)) {
56
+ const kv = /^\s*([a-z_]+)\s*:\s*(.*?)\s*$/.exec(line);
57
+ if (!kv) continue;
58
+ // A trailing `# why` comment is prose for the human, not part of the value.
59
+ const value = kv[2].replace(/\s+#.*$/, "").trim();
60
+ if (!value) continue;
61
+ if (kv[1] === "blocks") {
62
+ front.blocks = value
63
+ .split(",")
64
+ .map((s) => s.trim())
65
+ .filter(Boolean);
66
+ } else if (kv[1] === "kind") front.kind = value;
67
+ else if (kv[1] === "spec") front.spec = value;
68
+ else if (kv[1] === "status") front.status = value;
69
+ else if (kv[1] === "blocked_by") front.blocked_by = value;
70
+ else if (kv[1] === "superseded_by") front.superseded_by = value;
71
+ }
72
+ return front;
73
+ }
74
+
75
+ /**
76
+ * Checkbox tally of the plan's summary block — the first `##` section after
77
+ * the title, whatever it is called. Anchoring on position instead of on the
78
+ * literal "TL;DR" keeps this working for plans written in any language; the
79
+ * deeper sections are skipped on purpose, because a step list halfway down a
80
+ * 140KB plan is detail, not status.
81
+ */
82
+ function progressOf(text: string): string | undefined {
83
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
84
+ const start = body.search(/^##\s+/m);
85
+ if (start < 0) return undefined;
86
+ const rest = body.slice(start);
87
+ const next = rest.slice(3).search(/^##\s+/m);
88
+ const block = next < 0 ? rest : rest.slice(0, next + 3);
89
+ const total = block.match(/^\s*[-*]\s+\[[ xX]\]/gm)?.length ?? 0;
90
+ if (!total) return undefined;
91
+ const done = block.match(/^\s*[-*]\s+\[[xX]\]/gm)?.length ?? 0;
92
+ return `${done}/${total}`;
93
+ }
94
+
95
+ function read(path: string): {
96
+ title: string;
97
+ front: Front;
98
+ progress?: string;
99
+ } {
100
+ let text: string;
101
+ try {
102
+ text = readFileSync(path, "utf8");
103
+ } catch {
104
+ return { title: "(unreadable)", front: EMPTY };
105
+ }
106
+ const m = /^#\s+(.+)$/m.exec(text);
107
+ return {
108
+ title: m ? m[1].trim() : "(no title)",
109
+ front: parseFront(text),
110
+ progress: progressOf(text),
111
+ };
112
+ }
113
+
114
+ type Groups = {
115
+ active: Entry[];
116
+ blocked: Entry[];
117
+ untouched: Entry[];
118
+ superseded: Entry[];
119
+ trackers: Entry[];
120
+ done: number;
121
+ };
122
+
123
+ function line(e: Entry): string {
124
+ const bits: string[] = [e.progress ?? e.last];
125
+ if (e.blocks?.length) bits.push(`unblocks ${e.blocks.join(", ")}`);
126
+ if (e.blocked_by) bits.push(`waiting: ${e.blocked_by}`);
127
+ if (e.superseded_by) bits.push(`replaced by ${e.superseded_by}`);
128
+ if (e.escalated) bits.push("escalated");
129
+ const box = e.progress?.match(/^(\d+)\/\1$/) ? "x" : " ";
130
+ return `- [${box}] ${e.file.replace(/\.md$/, "")} — ${bits.join(" · ")}`;
131
+ }
132
+
133
+ /**
134
+ * The hand-maintained master checklist, generated instead. Everything here
135
+ * already lives in the plans themselves (frontmatter edges + summary
136
+ * checkboxes), so a rendered view can never drift out of date the way a
137
+ * MASTER.md does.
138
+ */
139
+ export function renderPlanList(g: Groups): string {
140
+ const out: string[] = [];
141
+ const section = (name: string, list: Entry[]) => {
142
+ if (!list.length) return;
143
+ out.push(`## ${name} (${list.length})`);
144
+ for (const e of list) out.push(line(e));
145
+ out.push("");
146
+ };
147
+ section("active — in order", g.active);
148
+ section("blocked", g.blocked);
149
+ section("untouched", g.untouched);
150
+ section("superseded — archive these", g.superseded);
151
+ section("trackers — not backlog", g.trackers);
152
+ out.push(`done: ${g.done} archived`);
153
+ return out.join("\n");
154
+ }
155
+
156
+ export function toolPlanList(args: Record<string, unknown>): ToolResult {
157
+ const worktree = typeof args.worktree === "string" ? args.worktree : "";
158
+ if (!worktree) return errorResult("worktree is required (absolute path)");
159
+ const markdown = args.format === "markdown";
160
+
161
+ // Path layout belongs to the worktree being listed, not to wherever the MCP
162
+ // server happened to start — a repo that keeps plans in apps/<app>/plan says
163
+ // so in its own fapony.config.json. Fall back to the server-global config
164
+ // (FAPONY_CONFIG or cwd) when the worktree has none.
165
+ const localConfig = join(worktree, "fapony.config.json");
166
+ const config = existsSync(localConfig)
167
+ ? loadConfig(localConfig)
168
+ : loadConfig();
169
+ const dir = join(worktree, planDir(config));
170
+ // Archive lives beside plan/, so a plan keeps its depth (and its relative
171
+ // links) when it is archived. Repos from before that layout keep theirs:
172
+ // fall back to plan/done/ rather than silently reporting 0 archived.
173
+ const configured = join(worktree, doneDir(config));
174
+ const archive = existsSync(configured) ? configured : join(dir, "done");
175
+ const empty = {
176
+ active: [],
177
+ blocked: [],
178
+ untouched: [],
179
+ superseded: [],
180
+ trackers: [],
181
+ };
182
+ if (!existsSync(dir)) {
183
+ return jsonResult({ ...empty, done: 0, error: `no plan dir at ${dir}` });
184
+ }
185
+
186
+ const pendingFiles = readdirSync(dir).filter((f) => f.endsWith(".md"));
187
+ const doneCount = existsSync(archive)
188
+ ? readdirSync(archive).filter((f) => f.endsWith(".md")).length
189
+ : 0;
190
+
191
+ const db = openDb();
192
+ const runs = db.prepare("SELECT * FROM runs ORDER BY id").all() as Run[];
193
+ const events = db
194
+ .prepare("SELECT * FROM events ORDER BY run_id, id")
195
+ .all() as Event[];
196
+ const byPlan = getLastVerdictByPlan(runs, events, resolveMaxRounds());
197
+
198
+ const active: Entry[] = [];
199
+ const blocked: Entry[] = [];
200
+ const untouched: Entry[] = [];
201
+ const superseded: Entry[] = [];
202
+ const trackers: Entry[] = [];
203
+
204
+ for (const file of pendingFiles) {
205
+ const { title, front, progress } = read(join(dir, file));
206
+ // Runs record whatever plan string the caller passed (often a relative
207
+ // or absolute path) — match by filename suffix, not exact equality.
208
+ const match = byPlan.find((p) => p.plan.endsWith(file));
209
+ const entry: Entry = {
210
+ file,
211
+ title,
212
+ runs: match?.runs ?? 0,
213
+ last: match
214
+ ? match.lastReasonCode
215
+ ? `${match.lastVerdict}(${match.lastReasonCode})`
216
+ : match.lastVerdict
217
+ : "never attempted",
218
+ escalated: match?.escalated ?? false,
219
+ };
220
+ if (progress) entry.progress = progress;
221
+ if (front.spec) entry.spec = front.spec;
222
+ if (front.blocked_by) entry.blocked_by = front.blocked_by;
223
+ if (front.blocks.length) entry.blocks = front.blocks;
224
+ if (front.superseded_by) entry.superseded_by = front.superseded_by;
225
+
226
+ // A tracker is never a unit of work, so it never "finishes" and must not
227
+ // sit in the backlog shaming everyone — that's why done/ never moved.
228
+ if (front.kind === "tracker") trackers.push(entry);
229
+ else if (front.status === "superseded") superseded.push(entry);
230
+ else if (front.status === "blocked" || front.blocked_by)
231
+ blocked.push(entry);
232
+ else if (front.status === "active" || entry.runs > 0) active.push(entry);
233
+ else untouched.push(entry);
234
+ }
235
+
236
+ // Ordering falls out of the blocks edges: what unblocks the most goes first.
237
+ // No priority number to argue with later.
238
+ active.sort(
239
+ (a, b) =>
240
+ (b.blocks?.length ?? 0) - (a.blocks?.length ?? 0) ||
241
+ a.file.localeCompare(b.file),
242
+ );
243
+
244
+ const groups: Groups = {
245
+ active,
246
+ blocked,
247
+ untouched,
248
+ superseded,
249
+ trackers,
250
+ done: doneCount,
251
+ };
252
+ return markdown
253
+ ? { content: [{ type: "text", text: renderPlanList(groups) }] }
254
+ : jsonResult(groups);
255
+ }
@@ -0,0 +1,285 @@
1
+ // src/mcp/tools/report.ts — verification_report MCP tool
2
+ //
3
+ // High-level composition: collects facts + handoff check + evidence +
4
+ // run metrics + verdict into a single VerificationReport.
5
+ // Calls existing primitives — no duplicate parser/conformance logic.
6
+
7
+ import { blastRadiusForWorktree } from "../../analyze.js";
8
+ import { getEvents, getRun, openDb } from "../../db/index.js";
9
+ import { loadConfig } from "../../db/load.js";
10
+ import { parseGateEventData } from "../../parse.js";
11
+ import { collectEvidence } from "../evidence.js";
12
+ import type { CheckResult, VerificationReport } from "../primitives.js";
13
+ import {
14
+ computeEvidenceSummary,
15
+ getServerSha,
16
+ renderReportText,
17
+ } from "../primitives.js";
18
+ import { errorResult, jsonResult, type ToolResult } from "../types.js";
19
+ import { resolveWorktreeArg } from "../worktree.js";
20
+ import { extractMultiField, toolHandoffCheck } from "./check.js";
21
+ import { toolHandoffCollect } from "./collect.js";
22
+
23
+ // --- Handoff text reader ---
24
+
25
+ function readHandoffFromEvents(runId: number): string | null {
26
+ const db = openDb();
27
+ try {
28
+ const events = getEvents(db, runId);
29
+ for (let i = events.length - 1; i >= 0; i--) {
30
+ if (events[i].kind !== "handoff") continue;
31
+ try {
32
+ const parsed = JSON.parse(events[i].data ?? "") as {
33
+ missing?: boolean;
34
+ };
35
+ if (!parsed.missing && events[i].data) {
36
+ // Reconstruct handoff text from the parsed data. The executor
37
+ // template mandates uncertain:/not_done: lines always, so empty
38
+ // arrays rebuild as "none" — faithful to what the agent reported.
39
+ const dataStr = events[i].data as string;
40
+ const d = JSON.parse(dataStr) as Record<string, unknown>;
41
+ const lines = ["## HANDOFF"];
42
+ if (d.claimed) lines.push(`claimed: ${d.claimed}`);
43
+ if (d.commits)
44
+ lines.push(
45
+ `commits: ${Array.isArray(d.commits) ? d.commits.join(" ") : d.commits}`,
46
+ );
47
+ if (d.checks) lines.push(`checks: ${d.checks}`);
48
+ lines.push(
49
+ `uncertain: ${d.uncertain && Array.isArray(d.uncertain) && d.uncertain.length ? d.uncertain.join("\n") : "none"}`,
50
+ );
51
+ lines.push(
52
+ `not_done: ${d.not_done && Array.isArray(d.not_done) && d.not_done.length ? d.not_done.join("\n") : "none"}`,
53
+ );
54
+ return lines.join("\n");
55
+ }
56
+ } catch {
57
+ // skip unparseable events
58
+ }
59
+ break;
60
+ }
61
+ return null;
62
+ } finally {
63
+ db.close();
64
+ }
65
+ }
66
+
67
+ // --- Tool implementation ---
68
+
69
+ export function toolVerificationReport(
70
+ args: Record<string, unknown>,
71
+ ): ToolResult {
72
+ const {
73
+ run_id,
74
+ worktree,
75
+ base_sha,
76
+ head_sha,
77
+ handoff: handoffArg,
78
+ evidence_commands,
79
+ format,
80
+ } = args;
81
+
82
+ // --- Resolve worktree + run ---
83
+ let resolvedWorktree: string | null = null;
84
+ let resolvedRunId: number | null = null;
85
+
86
+ if (typeof run_id === "number" && Number.isInteger(run_id)) {
87
+ const db = openDb();
88
+ try {
89
+ const run = getRun(db, run_id);
90
+ if (!run) {
91
+ return errorResult(`run ${run_id} not found`);
92
+ }
93
+ resolvedRunId = run_id;
94
+ // run.worktree may be a key (e.g. "falsify"), an absolute path, or
95
+ // the "mcp-external" sentinel — resolve via shared helper so git
96
+ // commands run in the right directory.
97
+ try {
98
+ resolvedWorktree = resolveWorktreeArg(run.worktree);
99
+ } catch (e) {
100
+ return errorResult((e as Error).message);
101
+ }
102
+ } finally {
103
+ db.close();
104
+ }
105
+ } else if (typeof worktree === "string") {
106
+ resolvedWorktree = worktree;
107
+ } else {
108
+ return errorResult("provide run_id or worktree");
109
+ }
110
+
111
+ // --- Git facts ---
112
+ let facts: VerificationReport["facts"] = {
113
+ files_changed: 0,
114
+ lines_changed: 0,
115
+ insertions: 0,
116
+ deletions: 0,
117
+ commits: [],
118
+ branch: "",
119
+ files: [],
120
+ git_error: null,
121
+ };
122
+
123
+ if (resolvedWorktree) {
124
+ const collectResult = toolHandoffCollect({
125
+ worktree: resolvedWorktree,
126
+ ...(typeof base_sha === "string" ? { base_sha } : {}),
127
+ ...(typeof head_sha === "string" ? { head_sha } : {}),
128
+ });
129
+ const collectData = JSON.parse(collectResult.content[0].text) as {
130
+ facts?: VerificationReport["facts"];
131
+ error?: unknown;
132
+ };
133
+ if (collectData.facts) {
134
+ facts = {
135
+ files_changed: collectData.facts.files_changed ?? 0,
136
+ lines_changed: collectData.facts.lines_changed ?? 0,
137
+ insertions: collectData.facts.insertions ?? 0,
138
+ deletions: collectData.facts.deletions ?? 0,
139
+ commits: collectData.facts.commits ?? [],
140
+ branch: collectData.facts.branch ?? "",
141
+ files: Array.isArray(collectData.facts.files)
142
+ ? collectData.facts.files.filter(
143
+ (f): f is string => typeof f === "string",
144
+ )
145
+ : [],
146
+ git_error: collectData.facts.git_error ?? null,
147
+ };
148
+ }
149
+ // Don't swallow collection failures: a refused/errored collect leaves
150
+ // zeroed facts, so surface the error instead of reporting "0 files".
151
+ if (
152
+ typeof collectData.error === "string" &&
153
+ collectData.error &&
154
+ !facts.git_error
155
+ ) {
156
+ facts = { ...facts, git_error: collectData.error };
157
+ }
158
+ }
159
+
160
+ // --- Handoff checks ---
161
+ let handoffChecks: VerificationReport["handoff_checks"] = null;
162
+ const handoffText =
163
+ typeof handoffArg === "string"
164
+ ? handoffArg
165
+ : resolvedRunId
166
+ ? readHandoffFromEvents(resolvedRunId)
167
+ : null;
168
+
169
+ if (handoffText) {
170
+ // Forward caller-supplied uncertain/not_done/checks when present;
171
+ // otherwise a field present in the agent's text counts as reported
172
+ // (content is always read from the text by toolHandoffCheck). This keeps
173
+ // the composed tool exactly as strict as handoff_check on the same text —
174
+ // no hardcoded "none" vouching for fields the caller never supplied.
175
+ const checkArgs: Record<string, unknown> = { handoff: handoffText, facts };
176
+ for (const field of ["uncertain", "not_done", "checks"] as const) {
177
+ const fromCaller = args[field];
178
+ if (typeof fromCaller === "string") {
179
+ checkArgs[field] = fromCaller;
180
+ } else if (new RegExp(`^\\s*${field}:`, "im").test(handoffText)) {
181
+ checkArgs[field] = extractMultiField(handoffText, field)[0] ?? "";
182
+ }
183
+ }
184
+ const checkResult = toolHandoffCheck(checkArgs);
185
+ const checkData = JSON.parse(checkResult.content[0].text) as {
186
+ checks?: unknown[];
187
+ summary?: {
188
+ total: number;
189
+ passed: number;
190
+ failed: number;
191
+ needs_human_review: boolean;
192
+ };
193
+ };
194
+ if (checkData.checks && checkData.summary) {
195
+ handoffChecks = {
196
+ checks: checkData.checks as CheckResult[],
197
+ summary: checkData.summary,
198
+ };
199
+ }
200
+ }
201
+
202
+ // --- Evidence ---
203
+ const agentCmds = Array.isArray(evidence_commands)
204
+ ? evidence_commands.filter((c): c is string => typeof c === "string")
205
+ : undefined;
206
+ const evidence = resolvedWorktree
207
+ ? collectEvidence({
208
+ worktree: resolvedWorktree,
209
+ agentCommands: agentCmds,
210
+ files: facts.files ?? [],
211
+ config: loadConfig(),
212
+ })
213
+ : [];
214
+ const evidence_summary = computeEvidenceSummary(evidence);
215
+
216
+ // --- Verdict ---
217
+ // Gate events store JSON ({verdict, note, round}), so read the JSON
218
+ // shape directly via parseGateEventData.
219
+ let verdict: VerificationReport["verdict"] = null;
220
+ if (resolvedRunId) {
221
+ const db = openDb();
222
+ try {
223
+ const events = getEvents(db, resolvedRunId);
224
+ for (let i = events.length - 1; i >= 0; i--) {
225
+ if (events[i].kind !== "gate") continue;
226
+ const parsed = parseGateEventData(events[i].data);
227
+ if (parsed) {
228
+ verdict = { grade: parsed.verdict, note: parsed.note };
229
+ }
230
+ break;
231
+ }
232
+ } finally {
233
+ db.close();
234
+ }
235
+ }
236
+
237
+ // --- Duration + rounds ---
238
+ let duration_ms: number | null = null;
239
+ let rounds = 0;
240
+ if (resolvedRunId) {
241
+ const db = openDb();
242
+ try {
243
+ const run = getRun(db, resolvedRunId);
244
+ if (run) {
245
+ rounds = run.round;
246
+ const t0 = new Date(`${run.created_at.replace(" ", "T")}Z`).getTime();
247
+ const t1 = new Date(`${run.updated_at.replace(" ", "T")}Z`).getTime();
248
+ duration_ms = t1 - t0;
249
+ }
250
+ } finally {
251
+ db.close();
252
+ }
253
+ }
254
+
255
+ // No standalone logging: a report is a read, not a unit of work. A
256
+ // worktree-only call (no run_id) leaves runs/events untouched — opening a
257
+ // row here left orphan runs stuck at running forever and inflated
258
+ // runs.total / byWorktree / project_health_context counts.
259
+
260
+ // --- Assemble report ---
261
+ const report: VerificationReport = {
262
+ facts,
263
+ handoff_checks: handoffChecks,
264
+ blast_radius: resolvedWorktree
265
+ ? blastRadiusForWorktree(resolvedWorktree, facts.files ?? [])
266
+ : null,
267
+ evidence,
268
+ evidence_summary,
269
+ verdict,
270
+ duration_ms,
271
+ rounds,
272
+ meta: {
273
+ generated_at: new Date().toISOString(),
274
+ source: "fapony_mcp",
275
+ run_id: resolvedRunId,
276
+ server_sha: getServerSha(),
277
+ },
278
+ };
279
+
280
+ // --- Output ---
281
+ if (format === "json") {
282
+ return jsonResult(report);
283
+ }
284
+ return { content: [{ type: "text", text: renderReportText(report) }] };
285
+ }
@@ -0,0 +1,96 @@
1
+ // src/mcp/tools/stats.ts — fapony_stats tool
2
+
3
+ import type { Run } from "../../db/index.js";
4
+ import { openDb } from "../../db/index.js";
5
+ import {
6
+ currentWorktree,
7
+ formatStatsText,
8
+ formatVerdictText,
9
+ getPlanBreakdown,
10
+ getStatsData,
11
+ type PlanBreakdown,
12
+ resolveMaxRounds,
13
+ } from "../../stats/index.js";
14
+ import { errorResult, jsonResult, type ToolResult } from "../types.js";
15
+
16
+ export function toolFaponyStats(args: Record<string, unknown>): ToolResult {
17
+ // Same default as `fapony stats` (src/stats/cli.ts): the project you are
18
+ // standing in. A bare MCP call used to go global, so an agent asking about
19
+ // one repo got every repo averaged together — wrong answer AND the biggest
20
+ // payload the tool can return. `all:true` is the opt-in, mirroring `--all`.
21
+ const worktree =
22
+ typeof args.worktree === "string" && args.worktree
23
+ ? args.worktree
24
+ : args.all === true
25
+ ? undefined
26
+ : (currentWorktree() ?? undefined);
27
+ const data = getStatsData(worktree);
28
+
29
+ // group_by: top-N slice from real events (PLAN-project-health-context §2).
30
+ // Worktree-scoped when `worktree` is given, global otherwise.
31
+ const groupBy = args.group_by;
32
+ if (groupBy === "reason_code" || groupBy === "plan" || groupBy === "file") {
33
+ const top =
34
+ typeof args.top === "number" && args.top > 0 ? Math.floor(args.top) : 10;
35
+ if (groupBy === "file") {
36
+ const rows = (
37
+ worktree
38
+ ? data.byFile.filter((f) => f.worktree === worktree)
39
+ : data.byFile
40
+ ).slice(0, top);
41
+ return jsonResult({
42
+ group_by: groupBy,
43
+ worktree: worktree ?? null,
44
+ rows,
45
+ });
46
+ }
47
+ if (groupBy === "reason_code") {
48
+ const rows = (
49
+ worktree
50
+ ? data.byReasonCode.filter((r) => r.worktree === worktree)
51
+ : data.byReasonCode
52
+ ).slice(0, top);
53
+ return jsonResult({
54
+ group_by: groupBy,
55
+ worktree: worktree ?? null,
56
+ rows,
57
+ });
58
+ }
59
+ // plan grouping: recompute from scoped runs when worktree is given,
60
+ // so counts reflect only runs in that worktree (not global counts).
61
+ let rows: PlanBreakdown[];
62
+ if (worktree) {
63
+ const db = openDb();
64
+ try {
65
+ const scopedRuns = db
66
+ .prepare("SELECT * FROM runs WHERE worktree = ? ORDER BY id")
67
+ .all(worktree) as Run[];
68
+ rows = getPlanBreakdown(scopedRuns, resolveMaxRounds()).slice(0, top);
69
+ } finally {
70
+ db.close();
71
+ }
72
+ } else {
73
+ rows = data.byPlan.slice(0, top);
74
+ }
75
+ return jsonResult({ group_by: groupBy, worktree: worktree ?? null, rows });
76
+ }
77
+ if (typeof groupBy !== "undefined") {
78
+ return errorResult(`group_by must be one of: reason_code, plan, file`);
79
+ }
80
+
81
+ // mode: "verdict" → Pareto frontier of quality vs tokens/pass
82
+ if (args.mode === "verdict") {
83
+ const regime = typeof args.regime === "string" ? args.regime : undefined;
84
+ return {
85
+ content: [{ type: "text", text: formatVerdictText(data, regime) }],
86
+ };
87
+ }
88
+
89
+ // json:true → StatsData ล้วน (SPEC-verdict-stats) — ห้ามแทรก text อื่น
90
+ if (args.json === true) {
91
+ return jsonResult(data);
92
+ }
93
+
94
+ // json:false → text เดียวกับ `fapony stats` — same formatter, raw (not JSON-wrapped)
95
+ return { content: [{ type: "text", text: formatStatsText(data) }] };
96
+ }