fapony 0.2.1 → 0.3.3

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 (54) hide show
  1. package/README.md +95 -72
  2. package/fapony.ts +12 -5
  3. package/package.json +5 -4
  4. package/skill/define-convention/SKILL.md +77 -0
  5. package/skill/lookup-before-edit/SKILL.md +48 -0
  6. package/skill/move-to-done/SKILL.md +19 -30
  7. package/skill/review-pony/SKILL.md +38 -61
  8. package/src/analyze.ts +1 -1
  9. package/src/debt/cli.ts +193 -0
  10. package/src/debt/format.ts +107 -0
  11. package/src/debt/index.ts +19 -0
  12. package/src/debt/load.ts +92 -0
  13. package/src/debt/promotion.ts +152 -0
  14. package/src/debt/scan.ts +214 -0
  15. package/src/debt/types.ts +79 -0
  16. package/src/detect.ts +92 -0
  17. package/src/gate.ts +3 -3
  18. package/src/hook.ts +349 -100
  19. package/src/init-mem.ts +57 -71
  20. package/src/init.ts +12 -16
  21. package/src/install/antigravity.ts +112 -0
  22. package/src/install/claude.ts +16 -123
  23. package/src/install/codex.ts +58 -19
  24. package/src/install/detect.ts +17 -7
  25. package/src/install/opencode.ts +131 -6
  26. package/src/install.ts +12 -3
  27. package/src/lint-baseline.ts +2 -2
  28. package/src/mcp/primitives.ts +1 -1
  29. package/src/mcp/tools/index.ts +13 -102
  30. package/src/mcp/tools/mem.ts +71 -0
  31. package/src/mcp/transport.ts +8 -94
  32. package/src/mcp/worktree.ts +1 -1
  33. package/src/mem/commands/read.ts +231 -141
  34. package/src/mem/index.ts +4 -13
  35. package/src/memory.ts +17 -8
  36. package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
  37. package/src/seed/primitives.ts +66 -0
  38. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  39. package/src/session/helpers.ts +1 -1
  40. package/src/session/registry.ts +3 -6
  41. package/src/setup.ts +1 -1
  42. package/src/stats/data.ts +1 -1
  43. package/src/telemetry.ts +1 -1
  44. package/src/util.ts +61 -0
  45. package/templates/SPEC.md +8 -1
  46. package/images/logo.png +0 -0
  47. package/images/logo.webp +0 -0
  48. package/images/logo@400.webp +0 -0
  49. package/images/sample.webp +0 -0
  50. package/images/summary.webp +0 -0
  51. package/src/debt.ts +0 -811
  52. package/src/math.ts +0 -13
  53. package/src/mcp/tools/usage.ts +0 -211
  54. package/src/mcp/tools/verdict.ts +0 -161
@@ -1,14 +1,16 @@
1
1
  // src/install/detect.ts — detect which MCP clients are installed on this machine.
2
2
  //
3
3
  // Signal per client:
4
- // claude = `command -v claude` (CLI on PATH)
5
- // cursor = ~/.cursor exists (the app creates it on first run)
6
- // opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
7
- // zcode = ~/.zcode/cli/config.json or ~/.agents/mcp.json exists
8
- // codex = ~/.codex/config.toml exists
4
+ // antigravity = ~/.gemini exists (the app creates it on first run)
5
+ // claude = `command -v claude` (CLI on PATH)
6
+ // cursor = ~/.cursor exists (the app creates it on first run)
7
+ // opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
8
+ // zcode = ~/.zcode/cli/config.json or ~/.agents/mcp.json exists
9
+ // codex = ~/.codex/config.toml exists
9
10
 
10
11
  import { homedir } from "node:os";
11
12
  import { defaultCheckCmd } from "../setup.js";
13
+ import { findGeminiDir } from "./antigravity.js";
12
14
  import { findCodexConfig } from "./codex.js";
13
15
  import { findCursorDir } from "./cursor.js";
14
16
  import { findOpencodeConfig } from "./opencode.js";
@@ -23,10 +25,10 @@ export interface DetectedClient {
23
25
 
24
26
  /**
25
27
  * Detect which MCP clients are available on this machine.
26
- * Returns one entry per platform, ordered: claude, cursor, opencode, zcode, codex.
28
+ * Returns one entry per platform, ordered: antigravity, claude, cursor, opencode, zcode, codex.
27
29
  *
28
30
  * Uses resolvers from each provider (file/dir-exists check) for
29
- * cursor/opencode/zcode/codex, and `command -v claude` for claude — all
31
+ * antigravity/cursor/opencode/zcode/codex, and `command -v claude` for claude — all
30
32
  * through injected deps for testability.
31
33
  */
32
34
  export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
@@ -35,12 +37,20 @@ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
35
37
 
36
38
  const claude = checkCmd("claude");
37
39
 
40
+ const geminiDir = findGeminiDir(getHome);
38
41
  const cursorDir = findCursorDir(getHome);
39
42
  const opencodePath = findOpencodeConfig(getHome);
40
43
  const zcodeResult = findZcodeConfig(getHome);
41
44
  const codexPath = findCodexConfig(getHome);
42
45
 
43
46
  return [
47
+ {
48
+ platform: "antigravity",
49
+ installed: geminiDir !== null,
50
+ why: geminiDir
51
+ ? `dir at ${geminiDir}`
52
+ : "no ~/.gemini directory (open Antigravity once)",
53
+ },
44
54
  {
45
55
  platform: "claude",
46
56
  installed: claude,
@@ -114,6 +114,7 @@ export function cmdInstallOpencode(
114
114
  installReadHintPlugin(dryRun, getHome);
115
115
  installCommitHintPlugin(dryRun, getHome);
116
116
  installEditHintPlugin(dryRun, getHome);
117
+ installSessionStartPlugin(dryRun, getHome);
117
118
  return;
118
119
  }
119
120
 
@@ -138,6 +139,7 @@ export function cmdInstallOpencode(
138
139
  installReadHintPlugin(dryRun, getHome);
139
140
  installCommitHintPlugin(dryRun, getHome);
140
141
  installEditHintPlugin(dryRun, getHome);
142
+ installSessionStartPlugin(dryRun, getHome);
141
143
  return;
142
144
  }
143
145
 
@@ -160,6 +162,7 @@ export function cmdInstallOpencode(
160
162
  installReadHintPlugin(dryRun, getHome);
161
163
  installCommitHintPlugin(dryRun, getHome);
162
164
  installEditHintPlugin(dryRun, getHome);
165
+ installSessionStartPlugin(dryRun, getHome);
163
166
  }
164
167
 
165
168
  /**
@@ -332,10 +335,10 @@ export const FaponyCommitHint = async ({ directory }) => {
332
335
  export function editHintPluginSource(root: string): string {
333
336
  const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
334
337
  return `// fapony edit hint — annotates edits to source files that have importers
335
- // with the importer count plus the review-seed command that lists them.
338
+ // with the importer count, plus mem/debt context rows about the file.
336
339
  // Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
337
340
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
338
- import { editHintFor, recordHintFire } from ${hookModule};
341
+ import { editHintFor, readContextData, recordHintFire } from ${hookModule};
339
342
 
340
343
  export const FaponyEditHint = async ({ directory }) => {
341
344
  return {
@@ -347,16 +350,25 @@ export const FaponyEditHint = async ({ directory }) => {
347
350
  // spends the per-session dedupe row or logs a fire that never showed.
348
351
  if (typeof output.output !== "string") return;
349
352
  const filePath = input.args?.filePath;
353
+ const parts = [];
350
354
  const hint = editHintFor({
351
355
  filePath,
352
356
  cwd: directory,
353
357
  session: input.sessionID,
354
358
  });
359
+ if (hint) parts.push(hint);
360
+ // Attach mem/debt context (same as read hint — annotate only, cap 5 lines)
361
+ const ctx = readContextData(filePath, directory);
362
+ if (ctx) {
363
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
364
+ parts.push(line);
365
+ }
366
+ }
367
+ if (parts.length > 0) {
368
+ output.output = output.output + "\\n" + parts.join("\\n");
355
369
 
356
- // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
357
- // After output — best-effort, never block the hint.
358
- if (hint) {
359
- output.output = output.output + "\\n" + hint;
370
+ // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
371
+ // After output — best-effort, never block the hint.
360
372
  try {
361
373
  const { spawnSync } = require("node:child_process");
362
374
  const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
@@ -371,6 +383,12 @@ export const FaponyEditHint = async ({ directory }) => {
371
383
  } catch { return null; }
372
384
  })() : null;
373
385
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "edit", file: rel, count: 1 });
386
+ if (ctx?.debtIds?.length) {
387
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
388
+ }
389
+ if (ctx?.memLines?.length) {
390
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
391
+ }
374
392
  }
375
393
  } catch {
376
394
  // best-effort
@@ -483,3 +501,110 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
483
501
  ` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
484
502
  );
485
503
  }
504
+
505
+ /**
506
+ * OpenCode plugin — session-start context, the parity of Claude's SessionStart
507
+ * hook.
508
+ *
509
+ * Why `experimental.chat.system.transform` and not the `event` hook on
510
+ * `session.created`: the event hook is observer-only (returns void, no
511
+ * injection channel), while system.transform's `output.system` is the
512
+ * documented injection point (`packages/plugin/src/index.ts`: input
513
+ * `{ sessionID?, model }`, output `{ system: string[] }`). The context lands
514
+ * on the first model dispatch instead of at session creation — one step late,
515
+ * the same shape as every other OpenCode hint (annotate-after), and arguably
516
+ * better: sessions that never dispatch never pay the spawn.
517
+ *
518
+ * The guard (no mem log = silence), the kickoff spawn and the cap all live in
519
+ * `sessionStartContext` (src/hook.ts), imported at runtime like the read /
520
+ * commit / edit hint plugins — so `git pull` updates this client too. It used
521
+ * to bake `mem kickoff` into the generated body and push stdout, which injected
522
+ * kickoff's "# <path> — 0 entries" header into every session of every repo
523
+ * with no mem log (bug mub2ezhi) and could not be fixed by a pull. One import,
524
+ * one implementation, shared with the Claude/Codex hooks.
525
+ *
526
+ * Once per session (closure Set — the factory runs once per server), silent
527
+ * when `sessionStartContext` returns null (no mem log in scope), never throws.
528
+ * `process.execPath` is the bun running opencode itself — no PATH dependency.
529
+ * Verified against OpenCode 1.18.29; `experimental.chat.system.transform` is an
530
+ * experimental hook, so a rename makes this plugin quiet with no error — check
531
+ * on OpenCode upgrades.
532
+ */
533
+ export function sessionStartPluginSource(root: string): string {
534
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
535
+ const faponyTs = JSON.stringify(join(root, "fapony.ts"));
536
+ return `// fapony session start — injects the \`fapony mem kickoff\` digest as context
537
+ // once per session via sessionStartContext, silent when the repo has no mem log.
538
+ // Annotate only: never blocks.
539
+ // Generated by \`fapony install\` — logic lives in src/hook.ts (sessionStartContext).
540
+ import { sessionStartContext } from ${hookModule};
541
+
542
+ export const FaponySessionStart = async ({ directory }) => {
543
+ const seen = new Set();
544
+ return {
545
+ "experimental.chat.system.transform": async (input, output) => {
546
+ try {
547
+ const sessionID = input?.sessionID;
548
+ if (!sessionID || seen.has(sessionID)) return;
549
+ seen.add(sessionID);
550
+ const ctx = sessionStartContext(directory, ${faponyTs});
551
+ if (ctx) output.system.push(ctx);
552
+ } catch {
553
+ // context must never break a session start
554
+ }
555
+ },
556
+ };
557
+ };
558
+ `;
559
+ }
560
+
561
+ function installSessionStartPlugin(
562
+ dryRun: boolean,
563
+ getHome: () => string,
564
+ ): void {
565
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
566
+ const pluginPath = join(pluginsDir, "fapony-session-start.ts");
567
+ if (existsSync(pluginPath)) {
568
+ let current = "";
569
+ try {
570
+ current = readFileSync(pluginPath, "utf-8");
571
+ } catch {
572
+ current = "";
573
+ }
574
+ if (current.includes("FaponySessionStart")) {
575
+ // Ours — and generated, so never overwritten. A file baked by an older
576
+ // fapony differs from what we generate now: say so instead of "no change",
577
+ // which is how the pre-hook plugin (mub2ezhi) would have been kept.
578
+ if (current !== sessionStartPluginSource(INSTALL_ROOT)) {
579
+ console.error(
580
+ ` session start: stale fapony plugin — delete ${pluginPath} and re-run install`,
581
+ );
582
+ return;
583
+ }
584
+ console.error(` session start: already installed — no change`);
585
+ return;
586
+ }
587
+ console.error(
588
+ ` session start: ${pluginPath} exists but isn't fapony's — not overwriting.`,
589
+ );
590
+ return;
591
+ }
592
+ if (!dryRun) {
593
+ try {
594
+ mkdirSync(pluginsDir, { recursive: true });
595
+ writeFileSync(
596
+ pluginPath,
597
+ sessionStartPluginSource(INSTALL_ROOT),
598
+ "utf-8",
599
+ );
600
+ } catch (e) {
601
+ console.error(
602
+ ` session start: failed to write — ${(e as Error).message}`,
603
+ );
604
+ return;
605
+ }
606
+ }
607
+ console.error(
608
+ ` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
609
+ );
610
+ }
package/src/install.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // ZCode gets skill/<name>/ symlinked into ~/.agents/skills.
13
13
 
14
14
  import { createInterface } from "node:readline";
15
+ import { cmdInstallAntigravity } from "./install/antigravity.js";
15
16
  import { cmdInstallClaude } from "./install/claude.js";
16
17
  import { cmdInstallCodex } from "./install/codex.js";
17
18
  import { cmdInstallCursor } from "./install/cursor.js";
@@ -22,6 +23,7 @@ import { cmdInstallZcode } from "./install/zcode.js";
22
23
  import { ask } from "./setup.js";
23
24
  import { isAffirmative } from "./util.js";
24
25
 
26
+ export { cmdInstallAntigravity } from "./install/antigravity.js";
25
27
  export {
26
28
  claudeAddArgs,
27
29
  claudeGetArgs,
@@ -58,6 +60,10 @@ export async function cmdInstall(
58
60
  const installAll = args.includes("--all");
59
61
 
60
62
  // --- explicit platform: original behavior, unchanged ---
63
+ if (platform === "antigravity") {
64
+ cmdInstallAntigravity(dryRun, deps);
65
+ return;
66
+ }
61
67
  if (platform === "claude") {
62
68
  cmdInstallClaude(dryRun, deps);
63
69
  return;
@@ -80,10 +86,10 @@ export async function cmdInstall(
80
86
  }
81
87
  if (platform !== undefined) {
82
88
  console.error(
83
- `usage: fapony install --platform opencode|claude|cursor|zcode|codex [--dry-run]`,
89
+ `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run]`,
84
90
  );
85
91
  console.error(
86
- ` supported platforms: opencode, claude, cursor, zcode, codex`,
92
+ ` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
87
93
  );
88
94
  (deps.exit ?? defaultExit)(1);
89
95
  return;
@@ -107,7 +113,7 @@ export async function cmdInstall(
107
113
  if (found.length === 0) {
108
114
  console.error();
109
115
  console.error(
110
- ` no MCP client found (looked for claude on PATH; config files for cursor, opencode, zcode, codex).`,
116
+ ` no MCP client found (looked for claude on PATH; config files for antigravity, cursor, opencode, zcode, codex).`,
111
117
  );
112
118
  console.error(
113
119
  ` open the app once, then re-run — or force with: fapony install --platform <name>`,
@@ -162,6 +168,9 @@ function installPlatform(
162
168
  deps: InstallDeps,
163
169
  ): void {
164
170
  switch (platform) {
171
+ case "antigravity":
172
+ cmdInstallAntigravity(dryRun, deps);
173
+ break;
165
174
  case "claude":
166
175
  cmdInstallClaude(dryRun, deps);
167
176
  break;
@@ -52,12 +52,12 @@ function gitSha(cwd: string): string {
52
52
  return p.exitCode === 0 ? p.stdout.toString().trim().slice(0, 12) : "none";
53
53
  }
54
54
 
55
- function baselinePath(worktree: string): string {
55
+ export function baselinePath(worktree: string): string {
56
56
  const key = worktree.replace(/[^A-Za-z0-9._-]+/g, "_");
57
57
  return join(faponyDir(), "lint-baseline", `${key}.json`);
58
58
  }
59
59
 
60
- function readEvidenceLintCmd(worktree: string): string | null {
60
+ export function readEvidenceLintCmd(worktree: string): string | null {
61
61
  const evidence = join(worktree, DEFAULT_EVIDENCE_FILE);
62
62
  try {
63
63
  const parsed = JSON.parse(readFileSync(evidence, "utf-8")) as {
@@ -157,7 +157,7 @@ export interface VerificationReport {
157
157
  evidence: EvidenceItem[];
158
158
  /** Evidence summary (computed from evidence array). */
159
159
  evidence_summary: EvidenceSummary;
160
- /** Verdict grade + note (from gate/verdict_submit, or null if not yet). */
160
+ /** Verdict grade + note (from the frozen gate/verdict ledger, or null if never graded). */
161
161
  verdict: {
162
162
  grade: string;
163
163
  note: string;
@@ -6,86 +6,11 @@
6
6
  // saves more than it costs (see CLAUDE.md "จ่าย token อย่างฉลาด"). Keep
7
7
  // descriptions imperative — say what to send, not why it matters.
8
8
 
9
- import { VERDICT_GRADES } from "../../parse.js";
10
- import { REASON_CODES, REGIME_CODES } from "../types.js";
11
-
12
- export { toolMemAdd, toolMemFind } from "./mem.js";
13
- export { toolPassiveUsage } from "./usage.js";
14
- export { toolVerdictSubmit } from "./verdict.js";
9
+ export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
15
10
 
16
11
  // --- Tool definitions ---
17
12
 
18
13
  export const TOOLS = [
19
- {
20
- name: "verdict_submit",
21
- description:
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.",
28
- inputSchema: {
29
- type: "object" as const,
30
- properties: {
31
- run_id: {
32
- type: "number",
33
- description: "Existing run to attach to. Omitted = bind or create.",
34
- },
35
- verdict: {
36
- type: "string",
37
- enum: [...VERDICT_GRADES],
38
- description: "Verdict grade",
39
- },
40
- reason_code: {
41
- type: "string",
42
- enum: [...REASON_CODES],
43
- description:
44
- "Failure reason. Clean passes take 'none', never 'other'.",
45
- },
46
- regime: {
47
- type: "string",
48
- enum: [...REGIME_CODES],
49
- description:
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",
53
- },
54
- note: {
55
- type: "string",
56
- description:
57
- "Standalone note — read months later with no access to this " +
58
- "conversation. Required when reason_code = 'other'.",
59
- },
60
- worktree: {
61
- type: "string",
62
- description:
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.",
66
- },
67
- plan: {
68
- type: "string",
69
- description: "Plan file path, for a new run",
70
- },
71
- session_id: {
72
- type: "string",
73
- description:
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.",
77
- },
78
- files: {
79
- type: "array",
80
- items: { type: "string" },
81
- description:
82
- "Repo-relative paths this unit touched. Optional, but always " +
83
- "send them: nothing else records where the work landed.",
84
- },
85
- },
86
- required: ["verdict", "reason_code", "regime"],
87
- },
88
- },
89
14
  {
90
15
  name: "mem_find",
91
16
  description:
@@ -179,43 +104,29 @@ export const TOOLS = [
179
104
  },
180
105
  },
181
106
  {
182
- name: "fapony_usage",
107
+ name: "mem_close",
183
108
  description:
184
- "Query passive usage across every coding client on this machine — " +
185
- "OpenCode, Claude Code, Codex, and ZCode — on one ruler: token counts, " +
186
- "cost, and breakdown by model. No client's own session log can see " +
187
- "another's, so this is the only way to compare them. Filter by " +
188
- "worktree and time range.",
109
+ "Close a mem row by id with a tombstone message (what was done). " +
110
+ "The write half of closing what mem_find shows as open — id must exist. " +
111
+ "Returns the ref and timestamp.",
189
112
  inputSchema: {
190
113
  type: "object" as const,
191
114
  properties: {
192
115
  worktree: {
193
116
  type: "string",
194
- description: "Filter by worktree path (absolute)",
195
- },
196
- since: {
197
- type: "number",
198
- description:
199
- "Unix timestamp — include sessions created at or after this time",
200
- },
201
- until: {
202
- type: "number",
203
117
  description:
204
- "Unix timestamp — include sessions created at or before this time",
118
+ "Absolute path (git rev-parse --show-toplevel) — required",
205
119
  },
206
- detail: {
207
- type: "boolean",
208
- description:
209
- "If true, include tool-call breakdown + step counts per session " +
210
- "(activity signal, not quality). Default false keeps output compact.",
120
+ id: {
121
+ type: "string",
122
+ description: "The row id to close",
211
123
  },
212
- json: {
213
- type: "boolean",
214
- description:
215
- "If true, return raw JSON PassiveUsageResult. If false (default), return human-readable text.",
124
+ text: {
125
+ type: "string",
126
+ description: "Tombstone message — what was done (commit sha counts)",
216
127
  },
217
128
  },
218
- required: [],
129
+ required: ["worktree", "id", "text"],
219
130
  },
220
131
  },
221
132
  ];
@@ -235,3 +235,74 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
235
235
  return errorResult(e instanceof Error ? e.message : String(e));
236
236
  }
237
237
  }
238
+
239
+ // --- mem_close ---
240
+ //
241
+ // A separate tool on purpose, not kind:"close" inside mem_add: a close row
242
+ // carries {ref, text} with no files[] and no spec, while mem_add requires a
243
+ // non-empty files[] (and a spec for hold) — folding them into one schema
244
+ // would make required fields depend on the value of another field, the shape
245
+ // models call wrong most often. Mirrors CLI `mem close <id> "<msg>"`
246
+ // (commands/write.ts cmdClose): the id must exist; the tombstone voids the
247
+ // claim by itself.
248
+
249
+ export interface MemCloseResult {
250
+ ref: string;
251
+ text: string;
252
+ ts: string;
253
+ }
254
+
255
+ export function memClose(args: {
256
+ worktree: string;
257
+ id: string;
258
+ text: string;
259
+ }): MemCloseResult {
260
+ if (!args.id.trim()) {
261
+ throw new Error("id is required");
262
+ }
263
+ if (!args.text.trim()) {
264
+ throw new Error("text is required and must not be empty");
265
+ }
266
+
267
+ initStore(args.worktree);
268
+ const all = rows();
269
+ if (!all.some((r) => "id" in r && r.id === args.id)) {
270
+ throw new Error(`no id "${args.id}" in the log`);
271
+ }
272
+
273
+ const ts = new Date().toISOString();
274
+ put({ kind: "close", ref: args.id, text: args.text });
275
+
276
+ return { ref: args.id, text: args.text, ts };
277
+ }
278
+
279
+ export function toolMemClose(args: Record<string, unknown>): ToolResult {
280
+ const worktree =
281
+ typeof args.worktree === "string" ? args.worktree.trim() : "";
282
+ if (!worktree) {
283
+ return errorResult(
284
+ "worktree is required and must be an absolute path " +
285
+ "(git rev-parse --show-toplevel)",
286
+ );
287
+ }
288
+ if (!worktree.startsWith("/")) {
289
+ return errorResult(`worktree must be an absolute path, got: ${worktree}`);
290
+ }
291
+
292
+ const id = typeof args.id === "string" ? args.id.trim() : "";
293
+ if (!id) {
294
+ return errorResult("id is required — the row id to close");
295
+ }
296
+
297
+ const text = typeof args.text === "string" ? args.text.trim() : "";
298
+ if (!text) {
299
+ return errorResult("text is required and must not be empty");
300
+ }
301
+
302
+ try {
303
+ const result = memClose({ worktree, id, text });
304
+ return jsonResult(result);
305
+ } catch (e) {
306
+ return errorResult(e instanceof Error ? e.message : String(e));
307
+ }
308
+ }