fapony 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +75 -44
  2. package/fapony.ts +32 -1
  3. package/package.json +3 -2
  4. package/skill/move-to-done/SKILL.md +18 -28
  5. package/skill/plan-with-pony/SKILL.md +8 -3
  6. package/src/analyze.ts +13 -1
  7. package/src/conventions-seed.ts +0 -1
  8. package/src/db/defaults.ts +1 -1
  9. package/src/db/getters.ts +3 -3
  10. package/src/db/store.ts +0 -75
  11. package/src/db/types.ts +1 -1
  12. package/src/debt.ts +169 -34
  13. package/src/digest/collect.ts +16 -0
  14. package/src/digest/text.ts +25 -0
  15. package/src/hook.ts +424 -16
  16. package/src/init-mem.ts +123 -37
  17. package/src/init.ts +51 -34
  18. package/src/install/claude.ts +7 -5
  19. package/src/install/opencode.ts +59 -5
  20. package/src/lint-baseline.ts +1 -7
  21. package/src/mcp/evidence.ts +1 -1
  22. package/src/mcp/tools/index.ts +97 -185
  23. package/src/mcp/tools/mem.ts +153 -11
  24. package/src/mcp/transport.ts +5 -13
  25. package/src/mem/commands/read.ts +448 -0
  26. package/src/mem/commands/where.ts +56 -0
  27. package/{templates → src}/mem/commands/write.ts +12 -5
  28. package/src/mem/index.ts +144 -0
  29. package/src/mem/store.ts +348 -0
  30. package/src/memory.ts +237 -40
  31. package/src/plan-seed.ts +20 -1
  32. package/src/review-seed.ts +19 -0
  33. package/src/setup.ts +4 -6
  34. package/src/stats/data.ts +34 -86
  35. package/src/stats/format.ts +8 -9
  36. package/src/stats/index.ts +0 -1
  37. package/templates/PLAN.md +1 -0
  38. package/src/mcp/tools/context.ts +0 -66
  39. package/src/mcp/tools/plans.ts +0 -255
  40. package/src/mcp/tools/stats.ts +0 -96
  41. package/templates/mem/commands/read.ts +0 -194
  42. package/templates/mem/commands/selftest.ts +0 -450
  43. package/templates/mem/mem.ts +0 -68
  44. package/templates/mem/store.ts +0 -285
  45. /package/{templates → src}/mem/commands/plan.ts +0 -0
  46. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  47. /package/{templates → src}/mem/render.ts +0 -0
  48. /package/{templates → src}/mem/selectors.ts +0 -0
@@ -1,12 +1,15 @@
1
1
  // src/mcp/tools/index.ts — barrel + TOOLS array
2
+ //
3
+ // Every schema here is paid as input tokens in *every* session of *every*
4
+ // client that connects, whether or not the tool is called. Adding one is
5
+ // buying attention with a standing charge; earning it back means the tool
6
+ // saves more than it costs (see CLAUDE.md "จ่าย token อย่างฉลาด"). Keep
7
+ // descriptions imperative — say what to send, not why it matters.
2
8
 
3
9
  import { VERDICT_GRADES } from "../../parse.js";
4
10
  import { REASON_CODES, REGIME_CODES } from "../types.js";
5
11
 
6
- export { toolProjectHealthContext } from "./context.js";
7
- export { toolMemFind } from "./mem.js";
8
- export { toolPlanList } from "./plans.js";
9
- export { toolFaponyStats } from "./stats.js";
12
+ export { toolMemAdd, toolMemFind } from "./mem.js";
10
13
  export { toolPassiveUsage } from "./usage.js";
11
14
  export { toolVerdictSubmit } from "./verdict.js";
12
15
 
@@ -16,24 +19,18 @@ export const TOOLS = [
16
19
  {
17
20
  name: "verdict_submit",
18
21
  description:
19
- "Grade a finished unit of work. " +
20
- "WHEN: every unit that ends, including work that went right the first " +
21
- "time — this is a grade on the work, not a confession, and a model's " +
22
- "record is only worth the number of graded units behind it. " +
23
- "If a first attempt was wrong, submit 'fail' the moment you realize it, " +
24
- "then a pass-family verdict once the fix is verified. " +
25
- "Never leave a run open — one stuck at running/fixing " +
26
- "absorbs later unrelated verdicts for that worktree. " +
27
- "Without run_id, binds to the latest still-open run for the same " +
28
- "worktree+plan (round keeps counting toward review.maxRounds); " +
29
- "creates a new run entry only when none is open.",
22
+ "Grade a finished unit of work — every unit that ends, including work " +
23
+ "that went right the first time. Submit 'fail' the moment a first " +
24
+ "attempt turns out wrong, then a pass-family verdict once the fix is " +
25
+ "verified. Never leave a run open: one stuck at running/fixing absorbs " +
26
+ "later unrelated verdicts for that worktree. Without run_id, binds to " +
27
+ "the latest open run for the same worktree+plan, else creates one.",
30
28
  inputSchema: {
31
29
  type: "object" as const,
32
30
  properties: {
33
31
  run_id: {
34
32
  type: "number",
35
- description:
36
- "Optional run ID from fapony. If omitted, a new run is created automatically.",
33
+ description: "Existing run to attach to. Omitted = bind or create.",
37
34
  },
38
35
  verdict: {
39
36
  type: "string",
@@ -44,114 +41,141 @@ export const TOOLS = [
44
41
  type: "string",
45
42
  enum: [...REASON_CODES],
46
43
  description:
47
- "Standardized failure reason code. Use 'none' for clean passes (not 'other').",
44
+ "Failure reason. Clean passes take 'none', never 'other'.",
48
45
  },
49
46
  regime: {
50
47
  type: "string",
51
48
  enum: [...REGIME_CODES],
52
49
  description:
53
- "Task shape: code=new feature/refactor, fix=debugging an existing defect, " +
54
- "review=reviewing someone else's work/diff, plan=producing a plan or spec, " +
55
- "inquiry=asking questions without editing files, test=writing or editing tests as primary work",
50
+ "Task shape: code=feature/refactor, fix=debugging a defect, " +
51
+ "review=reviewing a diff, plan=producing a plan or spec, " +
52
+ "inquiry=questions without editing files, test=writing tests",
56
53
  },
57
54
  note: {
58
55
  type: "string",
59
- description: "Optional note (required when reason_code = 'other')",
56
+ description:
57
+ "Standalone note — read months later with no access to this " +
58
+ "conversation. Required when reason_code = 'other'.",
60
59
  },
61
60
  worktree: {
62
61
  type: "string",
63
62
  description:
64
- "Absolute path of the repo/worktree (git rev-parse --show-toplevel), used when run_id is omitted. " +
65
- "Send it: every fapony query scopes by absolute path, so a bare repo name lands in a bucket no " +
66
- 'query reads, and omitting it files the verdict under "mcp-external" instead of the project. ' +
67
- "Neither case errors.",
63
+ "Absolute repo path (git rev-parse --show-toplevel). Every query " +
64
+ "scopes by it, so a bare name or an omission files the verdict " +
65
+ "where nothing reads it. Neither case errors.",
68
66
  },
69
67
  plan: {
70
68
  type: "string",
71
- description:
72
- "Optional plan file path for a new run (used only when run_id is omitted)",
69
+ description: "Plan file path, for a new run",
73
70
  },
74
71
  session_id: {
75
72
  type: "string",
76
73
  description:
77
- "The client session id — Claude Code/Codex: the transcript .jsonl path; " +
78
- "OpenCode/ZCode: the session id. It is what attributes this verdict to a model; " +
79
- "without it fapony infers the model from whichever session is running, which is a " +
80
- "guess. Send it whenever the client exposes it. Cannot find it → omit, never invent one.",
74
+ "Client session id (Claude Code/Codex: the transcript .jsonl " +
75
+ "path; OpenCode/ZCode: the session id) — this is what attributes " +
76
+ "the verdict to a model. Cannot find it → omit, never invent one.",
81
77
  },
82
78
  files: {
83
79
  type: "array",
84
80
  items: { type: "string" },
85
81
  description:
86
- "Repo-relative paths of the files this work unit touched. Technically " +
87
- "optional, but always send them: this is the only input to per-file " +
88
- "risk history — a verdict with no files[] teaches the next session " +
89
- "nothing about where the risk was.",
82
+ "Repo-relative paths this unit touched. Optional, but always " +
83
+ "send them: nothing else records where the work landed.",
90
84
  },
91
85
  },
92
86
  required: ["verdict", "reason_code", "regime"],
93
87
  },
94
88
  },
95
89
  {
96
- name: "fapony_stats",
90
+ name: "mem_find",
97
91
  description:
98
- "Query accumulated run statistics: pass/stall rates, quality scores, " +
99
- "breakdown by model/grade/worktree. Returns StatsData shape. " +
100
- "With mode='verdict', returns the model to pay for each regime — the " +
101
- "Pareto frontier of quality vs tokens/pass, one line per regime (or the " +
102
- "full frontier/dominated/candidates breakdown for one regime). This is " +
103
- "the answer the ledger exists to give; call it before assuming which " +
104
- "model fits a task. With group_by='reason_code'|'plan'|'file', returns " +
105
- "top-N rows for that grouping (recurring failure signatures / per-plan " +
106
- "totals / per-file gate-vs-fail counts) instead of the full shape.",
92
+ "Search the project's mem log (.fapony/.memory/log*.jsonl — decisions, " +
93
+ "bugs, notes, and bookkeeping kinds alike; NO default kind filter). " +
94
+ "Read-only. Answer 'what was ever decided about this file?' in one call " +
95
+ "BEFORE editing: pass files[] (repo-relative). Matches the row's stored " +
96
+ "files[], falling back to a substring of text/spec/ref for rows written " +
97
+ "without it — a row that names the file nowhere cannot be found. " +
98
+ "memDir shows which log dir was resolved (walked up from the given " +
99
+ "worktree — in a monorepo pass the app directory to read its log). " +
100
+ "Returns {rows, total, filesFound, " +
101
+ "skipped, memDir}: memDir:null = no mem at all, not 'nothing matched'.",
107
102
  inputSchema: {
108
103
  type: "object" as const,
109
104
  properties: {
110
- mode: {
105
+ worktree: {
111
106
  type: "string",
112
- enum: ["verdict"],
113
107
  description:
114
- "'verdict' ranks models by quality vs tokens/pass instead of listing " +
115
- "raw counts — the leaderboard, not the ledger dump. Models below " +
116
- "n=5 are shown separately and never picked as the answer; a model " +
117
- "tried once at top quality does not get to define the frontier.",
108
+ "Absolute path (git rev-parse --show-toplevel) — required; " +
109
+ "scope of the mem log to read",
118
110
  },
119
- regime: {
120
- type: "string",
121
- enum: ["code", "fix", "review", "plan", "inquiry", "test"],
111
+ files: {
112
+ type: "array",
113
+ items: { type: "string" },
122
114
  description:
123
- "With mode='verdict', narrows to one regime's full frontier " +
124
- "(dominated models + candidates too). Omitted = one summary line " +
125
- "per regime.",
115
+ "Repo-relative paths — matched against the row's files[], else its text",
126
116
  },
127
- json: {
128
- type: "boolean",
117
+ text: {
118
+ type: "string",
119
+ description: "Substring, case-insensitive",
120
+ },
121
+ kind: {
122
+ type: "array",
123
+ items: { type: "string" },
129
124
  description:
130
- "If true, return raw JSON StatsData. If false (default), return human-readable text. Ignored when mode='verdict' (always text).",
125
+ "Filter by kind (decision/note/bug/close/…). Omit = every kind — " +
126
+ "no default filter",
131
127
  },
132
- group_by: {
128
+ since: {
133
129
  type: "string",
134
- enum: ["reason_code", "plan", "file"],
135
- description:
136
- "Optional grouping: top-N reason_code counts, per-plan totals, or " +
137
- "per-file risk (graded touches vs fails) from real gate events.",
130
+ description: "ISO date — only rows at or after this time",
138
131
  },
139
- top: {
132
+ limit: {
140
133
  type: "number",
141
- description: "Max rows returned with group_by (default 10).",
134
+ description:
135
+ "Max rows returned (default 20) — total still counts all matches",
142
136
  },
137
+ },
138
+ required: ["worktree"],
139
+ },
140
+ },
141
+ {
142
+ name: "mem_add",
143
+ description:
144
+ "Append a mem row (decision/bug/note/next/hold) with files[]. files is " +
145
+ "required — a row that does not name the file is unfindable when you " +
146
+ "touch that file. Returns the row's id, kind, files, and timestamp.",
147
+ inputSchema: {
148
+ type: "object" as const,
149
+ properties: {
143
150
  worktree: {
144
151
  type: "string",
145
152
  description:
146
- "Scope a query (verdict mode, group_by, or the default view) to one worktree path (absolute). Defaults to the repo the server is running in.",
153
+ "Absolute path (git rev-parse --show-toplevel) — required",
147
154
  },
148
- all: {
149
- type: "boolean",
155
+ kind: {
156
+ type: "string",
157
+ enum: ["next", "bug", "decision", "note", "hold"],
158
+ description:
159
+ "Row kind: decision=locked choice, bug=broken thing, note=state " +
160
+ "next session needs, next=pending work, hold=blocked on spec",
161
+ },
162
+ text: {
163
+ type: "string",
150
164
  description:
151
- "Ignore the default worktree scope and report every project at once. Off by default: averaging several projects reads as 'in this project' while being no such thing, and it is the largest payload this tool returns.",
165
+ "Standalone text — read months later, no conversation context",
166
+ },
167
+ files: {
168
+ type: "array",
169
+ items: { type: "string" },
170
+ description:
171
+ "Repo-relative paths this row is about — required, non-empty",
172
+ },
173
+ spec: {
174
+ type: "string",
175
+ description: "Optional spec/plan .md path",
152
176
  },
153
177
  },
154
- required: [],
178
+ required: ["worktree", "kind", "text", "files"],
155
179
  },
156
180
  },
157
181
  {
@@ -194,116 +218,4 @@ export const TOOLS = [
194
218
  required: [],
195
219
  },
196
220
  },
197
- {
198
- name: "project_health_context",
199
- description:
200
- "Known-patterns block for files[]: recurring fail reasons, escalated runs, " +
201
- "and round-1-pass shapes from real run history. Worth a call when you are " +
202
- "about to touch a file that has history — a long-lived file, one you have " +
203
- "not seen before, or one a previous attempt already failed on. The unit is " +
204
- "touched files, not a plan; a bug fix with no plan file still qualifies. " +
205
- "Returns nothing when there is no history (most files); short plain-text " +
206
- "block, framed as watch-fors, not constraints.",
207
- inputSchema: {
208
- type: "object" as const,
209
- properties: {
210
- worktree: {
211
- type: "string",
212
- description:
213
- "Scope to one worktree path (absolute). Global across worktrees when omitted.",
214
- },
215
- files: {
216
- type: "array",
217
- items: { type: "string" },
218
- description:
219
- "Filter findings to only those related to these files. " +
220
- "Matches against stored gate event files and note text.",
221
- },
222
- },
223
- required: [],
224
- },
225
- },
226
- {
227
- name: "mem_find",
228
- description:
229
- "Search the project's mem log (.fapony/.memory/log*.jsonl — decisions, " +
230
- "bugs, notes, and bookkeeping kinds alike; NO default kind filter). " +
231
- "Read-only. Answer 'what was ever decided about this file?' in one call " +
232
- "BEFORE editing: pass files[] (repo-relative) to match rows mentioning " +
233
- "them. mem never stored files[], so match is substring over text/spec/ref " +
234
- "— a row that never names the file cannot be found (limit of the data, " +
235
- "not the query). In a monorepo only the log of the app guessed from the " +
236
- "worktree name is read; memDir in the result shows which one. Returns " +
237
- "{rows, total, filesFound, skipped, memDir}: total is the match count " +
238
- "before limit, memDir:null means no mem at all (not 'nothing matched').",
239
- inputSchema: {
240
- type: "object" as const,
241
- properties: {
242
- worktree: {
243
- type: "string",
244
- description:
245
- "Absolute path (git rev-parse --show-toplevel) — required; " +
246
- "scope of the mem log to read",
247
- },
248
- files: {
249
- type: "array",
250
- items: { type: "string" },
251
- description:
252
- "Repo-relative paths — match rows whose text/spec/ref mentions them (substring)",
253
- },
254
- text: {
255
- type: "string",
256
- description: "Substring, case-insensitive",
257
- },
258
- kind: {
259
- type: "array",
260
- items: { type: "string" },
261
- description:
262
- "Filter by kind (decision/note/bug/close/…). Omit = every kind — " +
263
- "no default filter",
264
- },
265
- since: {
266
- type: "string",
267
- description: "ISO date — only rows at or after this time",
268
- },
269
- limit: {
270
- type: "number",
271
- description:
272
- "Max rows returned (default 20) — total still counts all matches",
273
- },
274
- },
275
- required: ["worktree"],
276
- },
277
- },
278
- {
279
- name: "plan_list",
280
- description:
281
- "List pending plan files grouped by state — active / blocked / " +
282
- "untouched / superseded / trackers — each with title and last-run " +
283
- "status joined from fapony run history, plus a count of archived " +
284
- "ones. Not a raw directory listing: answers 'what is left, what is " +
285
- "waiting on what, what should come next'. State comes from optional " +
286
- "plan frontmatter (kind: tracker | status: active|blocked|superseded " +
287
- "| blocked_by: | blocks: | superseded_by:); a plan without " +
288
- "frontmatter is grouped by run history alone (attempted = active, " +
289
- "never attempted = untouched). Within active, whatever unblocks the " +
290
- "most other plans is listed first.",
291
- inputSchema: {
292
- type: "object" as const,
293
- properties: {
294
- worktree: {
295
- type: "string",
296
- description: "Absolute path to the project's git worktree",
297
- },
298
- format: {
299
- type: "string",
300
- enum: ["json", "markdown"],
301
- description:
302
- "'markdown' renders the groups as a checklist to paste or read; " +
303
- "default 'json'.",
304
- },
305
- },
306
- required: ["worktree"],
307
- },
308
- },
309
221
  ];
@@ -1,13 +1,20 @@
1
- // src/mcp/tools/mem.ts — mem_find: read-only search over the project's mem log
1
+ // src/mcp/tools/mem.ts — mem_find (read) + mem_add (write)
2
2
  //
3
- // "writable ≠ readable back" — the write side works from the CLI with no MCP
4
- // (vela: 2,920 rows / 49 days); what was missing is "which rows are about the
5
- // files I am about to touch". Read-only over readMemLog (PLAN-mem-mcp chunk 2).
6
- //
7
- // No default kind filter — chunk 0 chose A: every kind is still live, so
8
- // filtering `synced`/`next`/`claim` by default would be enforcing prose that
9
- // was already retracted.
3
+ // "writable ≠ readable back" — the write side was CLI-only until now; what was
4
+ // missing is letting an agent append a mem row mid-session without spawning a
5
+ // shell. files[] is required on the write side: a row that does not name the
6
+ // file is unfindable when you touch that file (fill rate 26% CLI-flag vs 88%
7
+ // MCP-required — schema wins over prose every time, see CLAUDE.md rule 9).
10
8
 
9
+ import { openRows } from "../../mem/selectors.js";
10
+ import {
11
+ initStore,
12
+ KINDS,
13
+ nextId,
14
+ put,
15
+ rows,
16
+ type WorkKind,
17
+ } from "../../mem/store.js";
11
18
  import { type MemRow, readMemLog, resolveMemDir } from "../../memory.js";
12
19
  import { errorResult, jsonResult, type ToolResult } from "../types.js";
13
20
 
@@ -39,11 +46,15 @@ export function memFind(args: {
39
46
  rows = rows.filter((r) => r.text.toLowerCase().includes(needle));
40
47
  }
41
48
  if (args.files && args.files.length > 0) {
42
- // mem never stored files[] — match is substring over text/spec/ref.
43
- // Low recall by nature (spec §5.4): a row that never names the file
44
- // cannot be found — a limit of the data, not of the query.
49
+ // Rows written by `mem add --files` carry files[] — match that first.
50
+ // Older rows (and any row whose author skipped --files) have none, so the
51
+ // text/spec/ref substring stays as the fallback: low recall by nature,
52
+ // a limit of the data rather than of the query (spec §5.4).
45
53
  const paths = args.files.map((f) => f.toLowerCase());
46
54
  rows = rows.filter((r) => {
55
+ const stored = (r.files ?? []).map((f) => f.toLowerCase());
56
+ if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
57
+ return true;
47
58
  const hay = `${r.text}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
48
59
  return paths.some((p) => hay.includes(p));
49
60
  });
@@ -93,3 +104,134 @@ export function toolMemFind(args: Record<string, unknown>): ToolResult {
93
104
 
94
105
  return jsonResult(memFind({ worktree, files, text, kind, since, limit }));
95
106
  }
107
+
108
+ // --- mem_add ---
109
+
110
+ export interface MemAddResult {
111
+ id: string;
112
+ kind: string;
113
+ text: string;
114
+ files: string[];
115
+ spec?: string;
116
+ ts: string;
117
+ }
118
+
119
+ const CAP_NEXT = 15;
120
+ const CAP_HOLD = 10;
121
+
122
+ export function memAdd(args: {
123
+ worktree: string;
124
+ kind: string;
125
+ text: string;
126
+ files: string[];
127
+ spec?: string;
128
+ }): MemAddResult {
129
+ if (!KINDS.includes(args.kind as WorkKind)) {
130
+ throw new Error(
131
+ `kind must be one of ${KINDS.join("|")} — got "${args.kind}"`,
132
+ );
133
+ }
134
+ if (args.files.length === 0) {
135
+ throw new Error("files must contain at least one path");
136
+ }
137
+ if (!args.text.trim()) {
138
+ throw new Error("text is required and must not be empty");
139
+ }
140
+ // CLI cmdAdd rejects hold without a spec (write.ts) — keep the two writers
141
+ // in lockstep: without a spec a hold can never be resolved by rotate.
142
+ if (args.kind === "hold" && !args.spec) {
143
+ throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
144
+ }
145
+
146
+ initStore(args.worktree);
147
+ const all = rows();
148
+
149
+ // Cap check — matches CLI cmdAdd behaviour
150
+ if (args.kind === "next" && !process.env.MEM_FORCE) {
151
+ const openNext = openRows(all).filter((r) => r.kind === "next").length;
152
+ if (openNext >= CAP_NEXT) {
153
+ throw new Error(
154
+ `open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
155
+ );
156
+ }
157
+ }
158
+ if (args.kind === "hold" && !process.env.MEM_FORCE) {
159
+ const openHold = openRows(all).filter((r) => r.kind === "hold").length;
160
+ if (openHold >= CAP_HOLD) {
161
+ throw new Error(
162
+ `open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
163
+ );
164
+ }
165
+ }
166
+
167
+ const id = nextId(all);
168
+ const ts = new Date().toISOString();
169
+ put({
170
+ id,
171
+ kind: args.kind as WorkKind,
172
+ text: args.text,
173
+ spec: args.spec,
174
+ files: args.files,
175
+ });
176
+
177
+ return {
178
+ id,
179
+ kind: args.kind,
180
+ text: args.text,
181
+ files: args.files,
182
+ spec: args.spec,
183
+ ts,
184
+ };
185
+ }
186
+
187
+ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
188
+ const worktree =
189
+ typeof args.worktree === "string" ? args.worktree.trim() : "";
190
+ if (!worktree) {
191
+ return errorResult(
192
+ "worktree is required and must be an absolute path " +
193
+ "(git rev-parse --show-toplevel)",
194
+ );
195
+ }
196
+ if (!worktree.startsWith("/")) {
197
+ return errorResult(`worktree must be an absolute path, got: ${worktree}`);
198
+ }
199
+
200
+ const kind = typeof args.kind === "string" ? args.kind.trim() : "";
201
+ if (!kind) {
202
+ return errorResult(`kind is required — one of ${KINDS.join("|")}`);
203
+ }
204
+ if (!KINDS.includes(kind as WorkKind)) {
205
+ return errorResult(
206
+ `kind must be one of ${KINDS.join("|")} — got "${kind}"`,
207
+ );
208
+ }
209
+
210
+ const text = typeof args.text === "string" ? args.text.trim() : "";
211
+ if (!text) {
212
+ return errorResult("text is required and must not be empty");
213
+ }
214
+
215
+ const files = Array.isArray(args.files)
216
+ ? args.files
217
+ .filter((f): f is string => typeof f === "string" && f.length > 0)
218
+ .map((f) => f.trim().replace(/^\.\//, ""))
219
+ : [];
220
+ if (files.length === 0) {
221
+ return errorResult(
222
+ "files is required and must contain at least one repo-relative path",
223
+ );
224
+ }
225
+
226
+ const spec =
227
+ typeof args.spec === "string" && args.spec.trim().endsWith(".md")
228
+ ? args.spec.trim()
229
+ : undefined;
230
+
231
+ try {
232
+ const result = memAdd({ worktree, kind, text, files, spec });
233
+ return jsonResult(result);
234
+ } catch (e) {
235
+ return errorResult(e instanceof Error ? e.message : String(e));
236
+ }
237
+ }
@@ -8,11 +8,9 @@ import { mergeBytesByTool, type UsageDetail } from "../session/index.js";
8
8
  import { getServerSha } from "./primitives.js";
9
9
  import {
10
10
  TOOLS,
11
- toolFaponyStats,
11
+ toolMemAdd,
12
12
  toolMemFind,
13
13
  toolPassiveUsage,
14
- toolPlanList,
15
- toolProjectHealthContext,
16
14
  toolVerdictSubmit,
17
15
  } from "./tools/index.js";
18
16
  import { errorResult, type ToolResult } from "./types.js";
@@ -112,7 +110,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
112
110
 
113
111
  const MCP_PROTOCOL_VERSION = "2025-03-26";
114
112
  const SERVER_NAME = "fapony-handcheck";
115
- const SERVER_VERSION = "0.1.3";
113
+ const SERVER_VERSION = "0.2.0";
116
114
 
117
115
  // --- JSON-RPC dispatch ---
118
116
 
@@ -154,21 +152,15 @@ function dispatchToolCall(params: {
154
152
  case "verdict_submit":
155
153
  result = toolVerdictSubmit(args);
156
154
  break;
157
- case "fapony_stats":
158
- result = toolFaponyStats(args);
159
- break;
160
155
  case "fapony_usage":
161
156
  result = toolPassiveUsage(args);
162
157
  break;
163
- case "project_health_context":
164
- result = toolProjectHealthContext(args);
165
- break;
166
- case "plan_list":
167
- result = toolPlanList(args);
168
- break;
169
158
  case "mem_find":
170
159
  result = toolMemFind(args);
171
160
  break;
161
+ case "mem_add":
162
+ result = toolMemAdd(args);
163
+ break;
172
164
  default:
173
165
  return errorResult(`unknown tool: ${params.name}`);
174
166
  }