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,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
+ }
@@ -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
+ }