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,211 @@
|
|
|
1
|
+
// src/mcp/tools/usage.ts — fapony_usage tool
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ImputeSummary,
|
|
5
|
+
imputeResult,
|
|
6
|
+
loadPrices,
|
|
7
|
+
type PriceTable,
|
|
8
|
+
} from "../../price/index.js";
|
|
9
|
+
import {
|
|
10
|
+
CLIENTS,
|
|
11
|
+
mergeBytesByTool,
|
|
12
|
+
type PassiveUsageResult,
|
|
13
|
+
} from "../../session/index.js";
|
|
14
|
+
import { jsonResult, type ToolResult } from "../types.js";
|
|
15
|
+
|
|
16
|
+
/** สรุป list-price ต่อ client — additive ไม่แตะบรรทัดเดิม */
|
|
17
|
+
function imputationOf(
|
|
18
|
+
result: PassiveUsageResult,
|
|
19
|
+
prices: PriceTable | null,
|
|
20
|
+
): (ImputeSummary & { prices_fetched_at: string }) | null {
|
|
21
|
+
if (result.session_count === 0 || !prices) return null;
|
|
22
|
+
return {
|
|
23
|
+
...imputeResult(result, prices),
|
|
24
|
+
prices_fetched_at: prices.fetched_at,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function imputedTextLines(
|
|
29
|
+
label: string,
|
|
30
|
+
result: PassiveUsageResult,
|
|
31
|
+
prices: PriceTable | null,
|
|
32
|
+
): string[] {
|
|
33
|
+
if (result.session_count === 0) return [];
|
|
34
|
+
const imp = imputationOf(result, prices);
|
|
35
|
+
if (!imp)
|
|
36
|
+
return [` ${label}list-price equivalent: — (run \`fapony price-scan\`)`];
|
|
37
|
+
const parts = [
|
|
38
|
+
`~$${imp.total_imputed.toFixed(4)} over ${imp.priced_sessions} priced sessions`,
|
|
39
|
+
];
|
|
40
|
+
if (imp.free_sessions > 0) parts.push(`${imp.free_sessions} free`);
|
|
41
|
+
if (imp.unpriced_sessions > 0)
|
|
42
|
+
parts.push(
|
|
43
|
+
`unpriced: ${imp.unpriced_sessions} sessions / ${imp.unpriced_tokens.toLocaleString()} tokens`,
|
|
44
|
+
);
|
|
45
|
+
return [` ${label}list-price equivalent: ${parts.join(" · ")}`];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** ราคา list ราย model ต่อท้ายชื่อรุ่น — เฉพาะแถวที่ client ไม่บันทึก cost */
|
|
49
|
+
function imputedSuffix(
|
|
50
|
+
provider: string,
|
|
51
|
+
model: string,
|
|
52
|
+
imp: (ImputeSummary & { prices_fetched_at: string }) | null,
|
|
53
|
+
): string {
|
|
54
|
+
if (!imp) return "";
|
|
55
|
+
const m = imp.by_model.find(
|
|
56
|
+
(b) => b.provider === provider && b.model === model,
|
|
57
|
+
);
|
|
58
|
+
if (m && m.status === "priced")
|
|
59
|
+
return ` (~$${m.imputed_cost.toFixed(4)} list-price)`;
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function toolPassiveUsage(args: Record<string, unknown>): ToolResult {
|
|
64
|
+
const worktree =
|
|
65
|
+
typeof args.worktree === "string" ? args.worktree : undefined;
|
|
66
|
+
const since = typeof args.since === "number" ? args.since : undefined;
|
|
67
|
+
const until = typeof args.until === "number" ? args.until : undefined;
|
|
68
|
+
const detail = args.detail === true;
|
|
69
|
+
|
|
70
|
+
const primary = CLIENTS.find((c) => c.primary)!;
|
|
71
|
+
const others = CLIENTS.filter((c) => !c.primary);
|
|
72
|
+
|
|
73
|
+
const data: PassiveUsageResult = primary.read(worktree, since, until, detail);
|
|
74
|
+
|
|
75
|
+
// Every non-primary client is always fetched when its own log exists.
|
|
76
|
+
const otherResults = others.map((client) => ({
|
|
77
|
+
client,
|
|
78
|
+
result: client.read(worktree, since, until, detail),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
// ราคา list จาก cache อย่างเดียว — อ่านครั้งเดียวต่อ call ไม่ใช่ต่อ section
|
|
82
|
+
const prices = loadPrices();
|
|
83
|
+
|
|
84
|
+
if (args.json === true) {
|
|
85
|
+
const json: Record<string, unknown> = {
|
|
86
|
+
...data,
|
|
87
|
+
imputation: imputationOf(data, prices),
|
|
88
|
+
};
|
|
89
|
+
for (const { client, result } of otherResults) {
|
|
90
|
+
json[client.key] =
|
|
91
|
+
result.session_count > 0
|
|
92
|
+
? { ...result, imputation: imputationOf(result, prices) }
|
|
93
|
+
: null;
|
|
94
|
+
}
|
|
95
|
+
return jsonResult(json);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
lines.push("Passive Usage Report");
|
|
100
|
+
lines.push("====================");
|
|
101
|
+
lines.push(`Sessions: ${data.session_count}`);
|
|
102
|
+
lines.push(`Total Input Tokens: ${data.total_tokens_input.toLocaleString()}`);
|
|
103
|
+
lines.push(
|
|
104
|
+
`Total Output Tokens: ${data.total_tokens_output.toLocaleString()}`,
|
|
105
|
+
);
|
|
106
|
+
lines.push(
|
|
107
|
+
`Total Reasoning Tokens: ${data.total_tokens_reasoning.toLocaleString()}`,
|
|
108
|
+
);
|
|
109
|
+
lines.push(`Cache Read: ${data.total_tokens_cache_read.toLocaleString()}`);
|
|
110
|
+
lines.push(`Cache Write: ${data.total_tokens_cache_write.toLocaleString()}`);
|
|
111
|
+
lines.push(`Total Cost: $${data.total_cost.toFixed(4)}`);
|
|
112
|
+
lines.push(...imputedTextLines("", data, prices));
|
|
113
|
+
|
|
114
|
+
if (data.by_model.length > 0) {
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push("By Model:");
|
|
117
|
+
lines.push("--------");
|
|
118
|
+
for (const m of data.by_model) {
|
|
119
|
+
const prefix = m.provider ? `${m.provider}/` : "";
|
|
120
|
+
lines.push(
|
|
121
|
+
` ${prefix}${m.model}: ${m.session_count} sessions, ` +
|
|
122
|
+
`${m.tokens_input.toLocaleString()} in / ${m.tokens_output.toLocaleString()} out, ` +
|
|
123
|
+
`$${m.cost.toFixed(4)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// detail:true is opt-in — default text above is byte-identical to before.
|
|
129
|
+
if (detail && data.detail) {
|
|
130
|
+
lines.push("");
|
|
131
|
+
lines.push("Detail (tool activity — signal, not quality):");
|
|
132
|
+
lines.push(` steps: ${data.detail.steps.toLocaleString()}`);
|
|
133
|
+
const tools = Object.entries(data.detail.tool_breakdown).sort(
|
|
134
|
+
(a, b) => b[1] - a[1],
|
|
135
|
+
);
|
|
136
|
+
if (tools.length > 0) {
|
|
137
|
+
lines.push(" by tool:");
|
|
138
|
+
for (const [tool, count] of tools.slice(0, 20)) {
|
|
139
|
+
lines.push(` ${tool}: ${count.toLocaleString()}`);
|
|
140
|
+
}
|
|
141
|
+
if (tools.length > 20) {
|
|
142
|
+
lines.push(` ... +${tools.length - 20} more (see json:true)`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Context bytes by tool — proportion of context window consumed per tool.
|
|
146
|
+
// Bytes are a proxy for tokens; shown as % of total, never as "tokens".
|
|
147
|
+
// Merged across all clients: OpenCode/ZCode (state.output), Claude Code
|
|
148
|
+
// and Codex (tool_result) each populate bytes_by_tool, so reading the
|
|
149
|
+
// top-level (opencode) detail alone would miss the other clients.
|
|
150
|
+
const bytes = mergeBytesByTool(
|
|
151
|
+
data.detail,
|
|
152
|
+
...otherResults.map((r) => r.result.detail),
|
|
153
|
+
);
|
|
154
|
+
if (bytes) {
|
|
155
|
+
const entries = Object.entries(bytes).sort((a, b) => b[1] - a[1]);
|
|
156
|
+
const total = entries.reduce((s, e) => s + e[1], 0);
|
|
157
|
+
if (entries.length > 0 && total > 0) {
|
|
158
|
+
lines.push(" context bytes by tool (% of total):");
|
|
159
|
+
for (const [tool, b] of entries.slice(0, 10)) {
|
|
160
|
+
const pct = ((b / total) * 100).toFixed(1);
|
|
161
|
+
lines.push(` ${tool}: ${pct}%`);
|
|
162
|
+
}
|
|
163
|
+
if (entries.length > 10) {
|
|
164
|
+
lines.push(` ... +${entries.length - 10} more (see json:true)`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
lines.push(` sessions with activity: ${data.detail.by_session.length}`);
|
|
169
|
+
const top = data.detail.by_session.slice(0, 10);
|
|
170
|
+
for (const s of top) {
|
|
171
|
+
const topTool = Object.entries(s.tools).sort((a, b) => b[1] - a[1])[0];
|
|
172
|
+
lines.push(
|
|
173
|
+
` ${s.session_id}: ${s.steps} steps` +
|
|
174
|
+
(topTool ? `, top tool ${topTool[0]}×${topTool[1]}` : ""),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (data.detail.by_session.length > 10) {
|
|
178
|
+
lines.push(
|
|
179
|
+
` ... +${data.detail.by_session.length - 10} more (see json:true)`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// One text section per non-primary client — same shape for every client,
|
|
185
|
+
// new ones need no new formatting code, only a registry.ts entry.
|
|
186
|
+
for (const { client, result } of otherResults) {
|
|
187
|
+
if (result.session_count === 0) continue;
|
|
188
|
+
const imp = imputationOf(result, prices);
|
|
189
|
+
const label = client.reportLabel ?? client.key;
|
|
190
|
+
lines.push("");
|
|
191
|
+
lines.push(`${label} usage:`);
|
|
192
|
+
lines.push(
|
|
193
|
+
` total: ${result.total_tokens_input.toLocaleString()} in / ${result.total_tokens_output.toLocaleString()} out / ${result.total_tokens_reasoning.toLocaleString()} reasoning tokens over ${result.session_count} sessions`,
|
|
194
|
+
);
|
|
195
|
+
lines.push(
|
|
196
|
+
` cache: ${result.total_tokens_cache_read.toLocaleString()} read / ${result.total_tokens_cache_write.toLocaleString()} write`,
|
|
197
|
+
);
|
|
198
|
+
if (result.by_model.length > 0) {
|
|
199
|
+
lines.push(" by model:");
|
|
200
|
+
for (const m of result.by_model) {
|
|
201
|
+
const prefix = m.provider ? `${m.provider}/` : "";
|
|
202
|
+
lines.push(
|
|
203
|
+
` ${prefix}${m.model}: ${m.tokens_input.toLocaleString()} in / ${m.tokens_output.toLocaleString()} out (cache r/w: ${(m.tokens_cache_read ?? 0).toLocaleString()} / ${(m.tokens_cache_write ?? 0).toLocaleString()})${imputedSuffix(m.provider, m.model, imp)}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
lines.push(...imputedTextLines("", result, prices));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
211
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// src/mcp/tools/verdict.ts — verdict_submit tool
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
findOpenRun,
|
|
5
|
+
findOpenRunWithNullPlan,
|
|
6
|
+
getRun,
|
|
7
|
+
newRun,
|
|
8
|
+
openDb,
|
|
9
|
+
patchLastGateEvent,
|
|
10
|
+
} from "../../db/index.js";
|
|
11
|
+
import { gateOnce } from "../../gate.js";
|
|
12
|
+
import { VERDICT_GRADES, type VerdictGrade } from "../../parse.js";
|
|
13
|
+
import {
|
|
14
|
+
errorResult,
|
|
15
|
+
jsonResult,
|
|
16
|
+
REASON_CODES,
|
|
17
|
+
REGIME_CODES,
|
|
18
|
+
type ReasonCode,
|
|
19
|
+
type RegimeCode,
|
|
20
|
+
type ToolResult,
|
|
21
|
+
} from "../types.js";
|
|
22
|
+
import { resolveWorktreeArg } from "../worktree.js";
|
|
23
|
+
|
|
24
|
+
// --- Tool implementation ---
|
|
25
|
+
|
|
26
|
+
export function toolVerdictSubmit(args: Record<string, unknown>): ToolResult {
|
|
27
|
+
const {
|
|
28
|
+
run_id,
|
|
29
|
+
verdict,
|
|
30
|
+
reason_code,
|
|
31
|
+
regime,
|
|
32
|
+
note,
|
|
33
|
+
worktree,
|
|
34
|
+
plan,
|
|
35
|
+
session_id,
|
|
36
|
+
files,
|
|
37
|
+
} = args;
|
|
38
|
+
|
|
39
|
+
if (typeof verdict !== "string" || !VERDICT_GRADES.has(verdict)) {
|
|
40
|
+
return errorResult(
|
|
41
|
+
`verdict must be one of: ${[...VERDICT_GRADES].join(", ")}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const grade = verdict as VerdictGrade;
|
|
45
|
+
if (!REASON_CODES.includes(reason_code as ReasonCode)) {
|
|
46
|
+
return errorResult(
|
|
47
|
+
`reason_code must be one of: ${REASON_CODES.join(", ")}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (reason_code === "other" && (!note || typeof note !== "string")) {
|
|
51
|
+
return errorResult("reason_code 'other' requires a note");
|
|
52
|
+
}
|
|
53
|
+
if (!REGIME_CODES.includes(regime as RegimeCode)) {
|
|
54
|
+
return errorResult(`regime must be one of: ${REGIME_CODES.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
const regimeCode = regime as RegimeCode;
|
|
57
|
+
|
|
58
|
+
const db = openDb();
|
|
59
|
+
|
|
60
|
+
// Resolve or create run_id
|
|
61
|
+
let resolvedRunId: number;
|
|
62
|
+
if (typeof run_id === "number" && Number.isInteger(run_id)) {
|
|
63
|
+
const run = getRun(db, run_id);
|
|
64
|
+
if (!run) {
|
|
65
|
+
return jsonResult({ stored: false, error: "run not found" });
|
|
66
|
+
}
|
|
67
|
+
resolvedRunId = run_id;
|
|
68
|
+
} else {
|
|
69
|
+
// Bind to the latest still-open run for the same worktree+plan so a
|
|
70
|
+
// round-2+ verdict lands on the original row (round keeps counting and
|
|
71
|
+
// review.maxRounds can actually trigger). Only when no open run matches
|
|
72
|
+
// is a fresh row created. plan=null always opens a new run (no guess).
|
|
73
|
+
// Free-text plans are normalized (trim+lowercase) so "Fix Login" and
|
|
74
|
+
// "fix login" bind to the same run. When no exact match exists, falls
|
|
75
|
+
// back to any open run with plan=null (the "no PLAN file" flow).
|
|
76
|
+
const resolvedWorktree =
|
|
77
|
+
typeof worktree === "string" && worktree
|
|
78
|
+
? resolveWorktreeArg(worktree)
|
|
79
|
+
: "mcp-external";
|
|
80
|
+
const resolvedPlan =
|
|
81
|
+
typeof plan === "string" && plan ? plan.trim().toLowerCase() : null;
|
|
82
|
+
let open = resolvedPlan
|
|
83
|
+
? findOpenRun(db, resolvedWorktree, resolvedPlan)
|
|
84
|
+
: null;
|
|
85
|
+
// Fallback: if no exact match and plan is non-null, try any open run with
|
|
86
|
+
// plan=null. This lets a verdict with free-text intent bind to a run that
|
|
87
|
+
// was created without a plan (the common "no PLAN file" flow).
|
|
88
|
+
if (!open && resolvedPlan) {
|
|
89
|
+
open = findOpenRunWithNullPlan(db, resolvedWorktree);
|
|
90
|
+
}
|
|
91
|
+
if (open) {
|
|
92
|
+
resolvedRunId = open.id;
|
|
93
|
+
} else {
|
|
94
|
+
// worktree/plan let callers (e.g. move-to-done) attribute the verdict
|
|
95
|
+
// so byReasonCode/bestPassing aggregate correctly instead of collapsing
|
|
96
|
+
// into "mcp-external".
|
|
97
|
+
resolvedRunId = newRun(db, resolvedWorktree, resolvedPlan, null, "mcp");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Normalize files: must be a non-empty array of strings.
|
|
102
|
+
const resolvedFiles =
|
|
103
|
+
Array.isArray(files) && files.length > 0
|
|
104
|
+
? files.filter((f): f is string => typeof f === "string" && f.length > 0)
|
|
105
|
+
: undefined;
|
|
106
|
+
|
|
107
|
+
// Route through gateOnce for consistent status/round/memory handling.
|
|
108
|
+
const mcpNote =
|
|
109
|
+
typeof note === "string" && note
|
|
110
|
+
? `[${reason_code}] ${note}`
|
|
111
|
+
: `[${reason_code}]`;
|
|
112
|
+
const result = gateOnce(resolvedRunId, grade, mcpNote, resolvedFiles);
|
|
113
|
+
|
|
114
|
+
if (result.error) {
|
|
115
|
+
return jsonResult({
|
|
116
|
+
stored: false,
|
|
117
|
+
error: result.error,
|
|
118
|
+
run_id: resolvedRunId,
|
|
119
|
+
status: result.status,
|
|
120
|
+
round: result.round,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Patch the gate event with MCP-specific fields (reason_code, source).
|
|
125
|
+
// session_id lets gates.ts resolve model from client session logs when the
|
|
126
|
+
// window has no spawn events (the old execute→review loop that wrote spawns
|
|
127
|
+
// is gone). Optional — agents that can't expose it just omit it.
|
|
128
|
+
const patch: Record<string, unknown> = {
|
|
129
|
+
reason_code,
|
|
130
|
+
regime: regimeCode,
|
|
131
|
+
source: "mcp",
|
|
132
|
+
};
|
|
133
|
+
if (typeof session_id === "string" && session_id) {
|
|
134
|
+
patch.session_id = session_id;
|
|
135
|
+
}
|
|
136
|
+
patchLastGateEvent(db, resolvedRunId, patch);
|
|
137
|
+
|
|
138
|
+
return jsonResult({
|
|
139
|
+
stored: true,
|
|
140
|
+
run_id: resolvedRunId,
|
|
141
|
+
verdict: grade,
|
|
142
|
+
reason_code,
|
|
143
|
+
regime: regimeCode,
|
|
144
|
+
status: result.status,
|
|
145
|
+
round: result.round,
|
|
146
|
+
...(resolvedFiles ? { files: resolvedFiles } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// src/mcp/transport.ts — JSON-RPC dispatch + stdio entry point
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { mergeBytesByTool, type UsageDetail } from "../session/index.js";
|
|
8
|
+
import { getServerSha } from "./primitives.js";
|
|
9
|
+
import {
|
|
10
|
+
TOOLS,
|
|
11
|
+
toolFaponyStats,
|
|
12
|
+
toolMemFind,
|
|
13
|
+
toolPassiveUsage,
|
|
14
|
+
toolPlanList,
|
|
15
|
+
toolProjectHealthContext,
|
|
16
|
+
toolVerdictSubmit,
|
|
17
|
+
} from "./tools/index.js";
|
|
18
|
+
import { errorResult, type ToolResult } from "./types.js";
|
|
19
|
+
|
|
20
|
+
// --- Server instructions ---
|
|
21
|
+
//
|
|
22
|
+
// MCP's initialize response carries an `instructions` string that clients
|
|
23
|
+
// inject into the model's context. This is the vendor-neutral place for the
|
|
24
|
+
// habit fapony depends on — a user should never have to paste rules
|
|
25
|
+
// into their own CLAUDE.md (or AGENTS.md, or a hook) to make the tools work,
|
|
26
|
+
// and a rule pasted there would only cover one client anyway.
|
|
27
|
+
//
|
|
28
|
+
// Kept short on purpose: this text is spent on every session of every user.
|
|
29
|
+
// Both habits degrade silently — an agent that ignores them still gets
|
|
30
|
+
// correct answers from every tool, just a thinner history.
|
|
31
|
+
|
|
32
|
+
const SERVER_INSTRUCTIONS = `fapony is a ledger of how work in this project turned out: which model, on which shape of task, produced work that held up. One habit feeds it.
|
|
33
|
+
|
|
34
|
+
When a unit of work is finished, call verdict_submit to grade it — pass-excellent..pass when it holds, fail when the first attempt was wrong, uncertain when you could not verify it (never guess pass). This is a grade on the work, not a confession: grade routinely, including work that went right the first time, because a model's record is only as good as the number of graded units behind it.
|
|
35
|
+
|
|
36
|
+
worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the verdict where nothing reads it, and nothing errors to say so. Write the note standalone — what the work was and how it held up — it is read months later with no access to this conversation. Never leave a run non-terminal; an open run absorbs later unrelated verdicts for that worktree.
|
|
37
|
+
|
|
38
|
+
Skip it and every tool still answers correctly, on a thinner history.`;
|
|
39
|
+
|
|
40
|
+
// --- Statusline cache ---
|
|
41
|
+
//
|
|
42
|
+
// Written after every MCP tool call. The Claude Code statusline script reads
|
|
43
|
+
// this file (< 1ms, no spawn, no db). Format: single line of text.
|
|
44
|
+
// Only fapony_usage with detail:true produces meaningful data (bytes_by_tool,
|
|
45
|
+
// aggregated across all clients — the bytes live on the claude_code
|
|
46
|
+
// sub-object, never top-level); other tools write a minimal "fapony" marker.
|
|
47
|
+
|
|
48
|
+
const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
|
|
49
|
+
|
|
50
|
+
function writeStatuslineCache(toolResult: ToolResult): void {
|
|
51
|
+
try {
|
|
52
|
+
// Extract bytes_by_tool from fapony_usage detail JSON response.
|
|
53
|
+
let line = "fapony";
|
|
54
|
+
if (
|
|
55
|
+
toolResult &&
|
|
56
|
+
typeof toolResult === "object" &&
|
|
57
|
+
"content" in toolResult &&
|
|
58
|
+
Array.isArray(toolResult.content)
|
|
59
|
+
) {
|
|
60
|
+
for (const c of toolResult.content) {
|
|
61
|
+
if (
|
|
62
|
+
c &&
|
|
63
|
+
typeof c === "object" &&
|
|
64
|
+
c.type === "text" &&
|
|
65
|
+
typeof c.text === "string"
|
|
66
|
+
) {
|
|
67
|
+
// Try to extract bytes_by_tool from JSON text response.
|
|
68
|
+
// Aggregated across all clients: the bytes live on the Claude Code
|
|
69
|
+
// sub-object (claude_code.detail), never on the top-level detail,
|
|
70
|
+
// so reading top-level alone would always miss.
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(c.text) as {
|
|
73
|
+
detail?: UsageDetail | null;
|
|
74
|
+
zcode?: { detail?: UsageDetail | null } | null;
|
|
75
|
+
claude_code?: { detail?: UsageDetail | null } | null;
|
|
76
|
+
codex?: { detail?: UsageDetail | null } | null;
|
|
77
|
+
};
|
|
78
|
+
const bbt = mergeBytesByTool(
|
|
79
|
+
parsed?.detail,
|
|
80
|
+
parsed?.zcode?.detail,
|
|
81
|
+
parsed?.claude_code?.detail,
|
|
82
|
+
parsed?.codex?.detail,
|
|
83
|
+
);
|
|
84
|
+
const entries = Object.entries(bbt).sort((a, b) => b[1] - a[1]);
|
|
85
|
+
const total = entries.reduce((s, e) => s + e[1], 0);
|
|
86
|
+
if (total > 0) {
|
|
87
|
+
// Format: "84.2k" for total, or "Read 42k · Grep 31k" for top tools.
|
|
88
|
+
const fmt = (n: number) =>
|
|
89
|
+
n >= 1024 ? `${(n / 1024).toFixed(1)}k` : `${Math.round(n)}`;
|
|
90
|
+
if (entries.length <= 3) {
|
|
91
|
+
line = `fapony ${entries.map((e) => `${e[0]} ${fmt(e[1])}`).join(" · ")}`;
|
|
92
|
+
} else {
|
|
93
|
+
line = `fapony ${fmt(total)}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Not JSON — that's fine, use default "fapony" marker.
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const dir = join(homedir(), ".config", "fapony");
|
|
104
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
105
|
+
writeFileSync(STATUSLINE_PATH, line, "utf-8");
|
|
106
|
+
} catch {
|
|
107
|
+
// Cache write is best-effort — never block MCP on it.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- MCP protocol constants ---
|
|
112
|
+
|
|
113
|
+
const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
114
|
+
const SERVER_NAME = "fapony-handcheck";
|
|
115
|
+
const SERVER_VERSION = "0.1.0";
|
|
116
|
+
|
|
117
|
+
// --- JSON-RPC dispatch ---
|
|
118
|
+
|
|
119
|
+
export function dispatch(
|
|
120
|
+
method: string,
|
|
121
|
+
params: unknown,
|
|
122
|
+
): object | ToolResult | null {
|
|
123
|
+
switch (method) {
|
|
124
|
+
case "initialize":
|
|
125
|
+
return {
|
|
126
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
127
|
+
capabilities: { tools: {} },
|
|
128
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
129
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
130
|
+
};
|
|
131
|
+
case "notifications/initialized":
|
|
132
|
+
return null; // no response needed
|
|
133
|
+
case "tools/list":
|
|
134
|
+
return { tools: TOOLS };
|
|
135
|
+
case "tools/call":
|
|
136
|
+
return dispatchToolCall(
|
|
137
|
+
params as { name: string; arguments?: Record<string, unknown> },
|
|
138
|
+
);
|
|
139
|
+
default:
|
|
140
|
+
return {
|
|
141
|
+
code: -32601,
|
|
142
|
+
message: `method not found: ${method}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function dispatchToolCall(params: {
|
|
148
|
+
name: string;
|
|
149
|
+
arguments?: Record<string, unknown>;
|
|
150
|
+
}): ToolResult {
|
|
151
|
+
const args = params.arguments ?? {};
|
|
152
|
+
let result: ToolResult;
|
|
153
|
+
switch (params.name) {
|
|
154
|
+
case "verdict_submit":
|
|
155
|
+
result = toolVerdictSubmit(args);
|
|
156
|
+
break;
|
|
157
|
+
case "fapony_stats":
|
|
158
|
+
result = toolFaponyStats(args);
|
|
159
|
+
break;
|
|
160
|
+
case "fapony_usage":
|
|
161
|
+
result = toolPassiveUsage(args);
|
|
162
|
+
break;
|
|
163
|
+
case "project_health_context":
|
|
164
|
+
result = toolProjectHealthContext(args);
|
|
165
|
+
break;
|
|
166
|
+
case "plan_list":
|
|
167
|
+
result = toolPlanList(args);
|
|
168
|
+
break;
|
|
169
|
+
case "mem_find":
|
|
170
|
+
result = toolMemFind(args);
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
return errorResult(`unknown tool: ${params.name}`);
|
|
174
|
+
}
|
|
175
|
+
// Write statusline cache after every tool call — best-effort, never blocks.
|
|
176
|
+
writeStatuslineCache(result);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Entry point ---
|
|
181
|
+
|
|
182
|
+
export function cmdMcp(): void {
|
|
183
|
+
getServerSha(); // cache while the process is still fresh, not on first report call
|
|
184
|
+
const rl = createInterface({ input: process.stdin });
|
|
185
|
+
|
|
186
|
+
rl.on("line", (line) => {
|
|
187
|
+
const trimmed = line.trim();
|
|
188
|
+
if (!trimmed) return;
|
|
189
|
+
|
|
190
|
+
let msg: { id?: number; method: string; params?: unknown };
|
|
191
|
+
try {
|
|
192
|
+
msg = JSON.parse(trimmed);
|
|
193
|
+
} catch {
|
|
194
|
+
// Invalid JSON — send error
|
|
195
|
+
const resp = {
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
id: null,
|
|
198
|
+
error: { code: -32700, message: "Parse error" },
|
|
199
|
+
};
|
|
200
|
+
process.stdout.write(`${JSON.stringify(resp)}\n`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const result = dispatch(msg.method, msg.params ?? {});
|
|
205
|
+
|
|
206
|
+
// notifications don't get a response
|
|
207
|
+
if (result === null) return;
|
|
208
|
+
|
|
209
|
+
const resp: Record<string, unknown> = {
|
|
210
|
+
jsonrpc: "2.0",
|
|
211
|
+
id: msg.id ?? null,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (
|
|
215
|
+
result &&
|
|
216
|
+
typeof result === "object" &&
|
|
217
|
+
"content" in result &&
|
|
218
|
+
"isError" in result
|
|
219
|
+
) {
|
|
220
|
+
// Tool result
|
|
221
|
+
resp.result = result;
|
|
222
|
+
} else if (
|
|
223
|
+
result &&
|
|
224
|
+
typeof result === "object" &&
|
|
225
|
+
"code" in result &&
|
|
226
|
+
"message" in result
|
|
227
|
+
) {
|
|
228
|
+
// Error response
|
|
229
|
+
resp.error = result;
|
|
230
|
+
} else {
|
|
231
|
+
// Normal result
|
|
232
|
+
resp.result = result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
process.stdout.write(`${JSON.stringify(resp)}\n`);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
rl.on("close", () => {
|
|
239
|
+
process.exit(0);
|
|
240
|
+
});
|
|
241
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/mcp/types.ts — ReasonCode enum, ToolResult, helpers
|
|
2
|
+
|
|
3
|
+
// --- ReasonCode enum (locked in step 0, append-only) ---
|
|
4
|
+
|
|
5
|
+
export const REASON_CODES = [
|
|
6
|
+
"missing_test",
|
|
7
|
+
"scope_mismatch",
|
|
8
|
+
"unsafe_command",
|
|
9
|
+
"spec_gap",
|
|
10
|
+
"timeout",
|
|
11
|
+
"blocked",
|
|
12
|
+
"incomplete",
|
|
13
|
+
"none",
|
|
14
|
+
"other",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type ReasonCode = (typeof REASON_CODES)[number];
|
|
18
|
+
|
|
19
|
+
// --- RegimeCode enum (task-shape axis for model × project × regime × quality) ---
|
|
20
|
+
|
|
21
|
+
export const REGIME_CODES = [
|
|
22
|
+
"code",
|
|
23
|
+
"fix",
|
|
24
|
+
"review",
|
|
25
|
+
"plan",
|
|
26
|
+
"inquiry",
|
|
27
|
+
"test",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type RegimeCode = (typeof REGIME_CODES)[number];
|
|
31
|
+
|
|
32
|
+
// --- Tool result types ---
|
|
33
|
+
|
|
34
|
+
export interface ToolResult {
|
|
35
|
+
content: { type: "text"; text: string }[];
|
|
36
|
+
isError?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function jsonResult(data: unknown): ToolResult {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function errorResult(message: string): ToolResult {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseToolResult(result: ToolResult): unknown {
|
|
53
|
+
return JSON.parse(result.content[0].text);
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// src/mcp/worktree.ts — resolve worktree argument to absolute path
|
|
2
|
+
|
|
3
|
+
import { loadConfig } from "../db/load.js";
|
|
4
|
+
|
|
5
|
+
const SENTINEL_MCP_EXTERNAL = "mcp-external";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a worktree argument to an absolute path.
|
|
9
|
+
* - Absolute paths (contain "/") pass through unchanged
|
|
10
|
+
* - Keys are looked up in config.worktrees
|
|
11
|
+
* - "mcp-external" sentinel passes through (set by verdict_submit itself)
|
|
12
|
+
* - Throws if key not found in config
|
|
13
|
+
*/
|
|
14
|
+
export function resolveWorktreeArg(value: string): string {
|
|
15
|
+
if (value === SENTINEL_MCP_EXTERNAL) return value;
|
|
16
|
+
if (value.includes("/")) return value;
|
|
17
|
+
|
|
18
|
+
const config = loadConfig();
|
|
19
|
+
const resolved = config.worktrees[value];
|
|
20
|
+
if (resolved) return resolved;
|
|
21
|
+
|
|
22
|
+
const keys = Object.keys(config.worktrees);
|
|
23
|
+
throw new Error(
|
|
24
|
+
`worktree "${value}" is not an absolute path and not found in config.worktrees. ` +
|
|
25
|
+
`Send an absolute path (containing "/") or one of these keys: ${keys.length ? keys.join(", ") : "(none configured)"}`,
|
|
26
|
+
);
|
|
27
|
+
}
|