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.
- package/LICENSE +21 -0
- package/README.md +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- 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
|
+
}
|