fapony 0.2.0 → 0.3.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.
@@ -105,6 +105,10 @@ export function cmdInstallClaude(
105
105
  console.error(` (Claude Code user scope)`);
106
106
  const dir = claudeSkillsDir(deps.homedir ?? homedir);
107
107
  reportSkills(linkSkills(dir, dryRun), dir, dryRun);
108
+ // MCP is already wired, but a hook can be new since the last install
109
+ // (e.g. the Edit hint) — always ensure the hook wiring, not only on a
110
+ // fresh MCP add. This is the upgrade path for existing installs.
111
+ installClaudeHooks(dryRun, deps);
108
112
  return;
109
113
  }
110
114
  console.error(
@@ -120,6 +124,9 @@ export function cmdInstallClaude(
120
124
  if (dryRun) {
121
125
  console.error(`── dry-run: would run ──`);
122
126
  console.error(` ${addArgs.join(" ")}`);
127
+ // Dry-run shows the hook wiring too — the installers are dry-run-safe
128
+ // ("would write", no writes), so the preview stays truthful.
129
+ installClaudeHooks(dryRun, deps);
123
130
  return;
124
131
  }
125
132
 
@@ -148,9 +155,9 @@ export function cmdInstallClaude(
148
155
  installStatusline(dryRun, deps);
149
156
 
150
157
  // Wire the Stop hook that refuses to end a turn with ungraded commits,
151
- // and the Read hint that annotates large-file reads (annotate-only).
152
- installStopHook(dryRun, deps);
153
- installReadHintHook(dryRun, deps);
158
+ // and the Read/Edit hints that annotate reads of large files and edits to
159
+ // files with importers (both annotate-only).
160
+ installClaudeHooks(dryRun, deps);
154
161
  }
155
162
 
156
163
  /**
@@ -336,6 +343,19 @@ function ensureClaudeHook(
336
343
  );
337
344
  }
338
345
 
346
+ /**
347
+ * Wire every hook fapony owns: the Stop hook that refuses to end a turn with
348
+ * ungraded commits, and the Read/Edit PreToolUse hints (annotate-only).
349
+ * Idempotent and dry-run-safe. Called on every install, not just a fresh MCP
350
+ * add — an existing install must still pick up a hook added later.
351
+ */
352
+ function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
353
+ installStopHook(dryRun, deps);
354
+ installReadHintHook(dryRun, deps);
355
+ installEditHintHook(dryRun, deps);
356
+ installSessionStartHook(dryRun, deps);
357
+ }
358
+
339
359
  function installStopHook(dryRun: boolean, deps: InstallDeps): void {
340
360
  ensureClaudeHook(dryRun, deps, {
341
361
  event: "Stop",
@@ -361,3 +381,31 @@ function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
361
381
  label: "read hint",
362
382
  });
363
383
  }
384
+
385
+ /**
386
+ * PreToolUse hook on Edit: annotates an edit with the file's importer count
387
+ * plus the review-seed command that lists them, once per (session, file).
388
+ * Annotate only — no permissionDecision is ever returned, the edit always
389
+ * proceeds. The matcher "Edit" keeps the spawn off every other tool call.
390
+ */
391
+ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
392
+ ensureClaudeHook(dryRun, deps, {
393
+ event: "PreToolUse",
394
+ matcher: "Edit",
395
+ subcommand: "hook-edit-hint",
396
+ label: "edit hint",
397
+ });
398
+ }
399
+
400
+ /**
401
+ * SessionStart hook: injects `fapony mem kickoff` as context when the repo has
402
+ * a mem log, and stays silent when it does not. Context only — SessionStart
403
+ * cannot block, and a repo without mem never sees a line.
404
+ */
405
+ function installSessionStartHook(dryRun: boolean, deps: InstallDeps): void {
406
+ ensureClaudeHook(dryRun, deps, {
407
+ event: "SessionStart",
408
+ subcommand: "hook-session-start",
409
+ label: "session start",
410
+ });
411
+ }
@@ -1,17 +1,30 @@
1
1
  // src/install/codex.ts — Codex install provider
2
2
  //
3
- // Reads/writes ~/.codex/config.toml directly. Codex has no CLI for MCP config.
3
+ // Reads/writes ~/.codex/config.toml directly for MCP config.
4
+ // Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop, SessionStart).
5
+ // Symlinks skills into ~/.agents/skills/ (same dir as ZCode).
4
6
 
5
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
8
  import { homedir } from "node:os";
7
9
  import { join } from "node:path";
8
- import { CODEX_MCP_ENTRY, defaultExit, type InstallDeps } from "./types.js";
10
+ import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
11
+ import {
12
+ CODEX_MCP_ENTRY,
13
+ defaultExit,
14
+ INSTALL_ROOT,
15
+ type InstallDeps,
16
+ } from "./types.js";
9
17
 
10
18
  export function findCodexConfig(getHome: () => string): string | null {
11
19
  const p = join(getHome(), ".codex", "config.toml");
12
20
  return existsSync(p) ? p : null;
13
21
  }
14
22
 
23
+ export function findCodexHooksJson(getHome: () => string): string | null {
24
+ const p = join(getHome(), ".codex", "hooks.json");
25
+ return existsSync(p) ? p : null;
26
+ }
27
+
15
28
  function isCodexConfigured(content: string): boolean {
16
29
  // Check if [mcp_servers.fapony] section exists with our command
17
30
  const sectionRegex = /\[mcp_servers\.fapony\]/;
@@ -20,9 +33,124 @@ function isCodexConfigured(content: string): boolean {
20
33
  return content.includes("fapony.ts") && content.includes("mcp");
21
34
  }
22
35
 
36
+ function readJsonObject(path: string): Record<string, unknown> | null {
37
+ try {
38
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
39
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
40
+ return null;
41
+ }
42
+ return parsed as Record<string, unknown>;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Append a fapony command hook to one event group in ~/.codex/hooks.json.
50
+ * Never replaces other hook groups or foreign entries within the group.
51
+ * A fapony-owned entry is identified by the subcommand string
52
+ * ("hook-stop" / "hook-session-start") inside its command — per-group
53
+ * idempotence, so an existing install with only Stop still picks up
54
+ * SessionStart on the next run.
55
+ */
56
+ function installCodexHookGroup(
57
+ dryRun: boolean,
58
+ getHome: () => string,
59
+ opts: { event: "Stop" | "SessionStart"; subcommand: string; label: string },
60
+ ): void {
61
+ const hooksPath = join(getHome(), ".codex", "hooks.json");
62
+ let config: Record<string, unknown> = {};
63
+ if (existsSync(hooksPath)) {
64
+ const parsed = readJsonObject(hooksPath);
65
+ if (!parsed) {
66
+ console.error(
67
+ ` ${opts.label}: ${hooksPath} is unreadable or malformed — skipping`,
68
+ );
69
+ return;
70
+ }
71
+ config = parsed;
72
+ }
73
+
74
+ const hookMap = config.hooks;
75
+ if (
76
+ hookMap !== undefined &&
77
+ (typeof hookMap !== "object" || Array.isArray(hookMap))
78
+ ) {
79
+ console.error(
80
+ ` ${opts.label}: ${hooksPath} has an unexpected "hooks" shape — skipping`,
81
+ );
82
+ return;
83
+ }
84
+
85
+ const map = (hookMap ?? {}) as Record<string, unknown>;
86
+ // Codex event arrays: each element is { matcher?, hooks: [...] }
87
+ const group = Array.isArray(map[opts.event])
88
+ ? (map[opts.event] as unknown[])
89
+ : [];
90
+
91
+ // Check if the fapony hook is already present in this group (by subcommand)
92
+ const serialized = JSON.stringify(group);
93
+ if (serialized.includes(opts.subcommand)) {
94
+ console.error(
95
+ ` ${opts.label}: already configured in hooks.json — no change`,
96
+ );
97
+ return;
98
+ }
99
+
100
+ const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} ${opts.subcommand}`;
101
+ const faponyEntry = { hooks: [{ type: "command", command }] };
102
+ const after = {
103
+ ...config,
104
+ hooks: { ...map, [opts.event]: [...group, faponyEntry] },
105
+ };
106
+
107
+ if (!dryRun) {
108
+ try {
109
+ writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
110
+ } catch (e) {
111
+ console.error(
112
+ ` ${opts.label}: failed to write — ${(e as Error).message}`,
113
+ );
114
+ return;
115
+ }
116
+ }
117
+ console.error(
118
+ ` ${opts.label}: ${dryRun ? "would write" : "wrote"} hooks.${opts.event} → ${hooksPath}`,
119
+ );
120
+ console.error(
121
+ ` review and trust via Codex /hooks before the hook will run`,
122
+ );
123
+ }
124
+
125
+ function installStopHook(dryRun: boolean, getHome: () => string): void {
126
+ installCodexHookGroup(dryRun, getHome, {
127
+ event: "Stop",
128
+ subcommand: "hook-stop",
129
+ label: "stop hook",
130
+ });
131
+ }
132
+
133
+ /**
134
+ * SessionStart hook: injects `fapony mem kickoff` as context when the repo
135
+ * has a mem log, silent when it does not (cmdHookSessionStart exits quiet).
136
+ * Same `hookSpecificOutput.additionalContext` contract Claude uses — Codex
137
+ * SessionStart reads that channel too. No matcher: match-all fires on every
138
+ * source including `clear` and on versions that send no source at all, where
139
+ * a `startup|resume` matcher would silently never fire; kickoff is capped at
140
+ * 4k and costs one cheap spawn, so the /clear path stays fast.
141
+ */
142
+ function installSessionStartHook(dryRun: boolean, getHome: () => string): void {
143
+ installCodexHookGroup(dryRun, getHome, {
144
+ event: "SessionStart",
145
+ subcommand: "hook-session-start",
146
+ label: "session start",
147
+ });
148
+ }
149
+
23
150
  export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
24
151
  const exitFn = deps.exit ?? defaultExit;
25
- const configPath = findCodexConfig(deps.homedir ?? homedir);
152
+ const getHome = deps.homedir ?? homedir;
153
+ const configPath = findCodexConfig(getHome);
26
154
 
27
155
  if (!configPath) {
28
156
  console.error(
@@ -44,18 +172,21 @@ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
44
172
  if (isCodexConfigured(content)) {
45
173
  console.error(`✓ mcp_servers.fapony already configured — no change needed`);
46
174
  console.error(` (${configPath})`);
47
- return;
48
- }
49
-
50
- if (dryRun) {
175
+ } else if (dryRun) {
51
176
  console.error(`── dry-run: would append to ${configPath} ──`);
52
177
  console.log(CODEX_MCP_ENTRY);
53
- return;
178
+ } else {
179
+ const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
180
+ writeFileSync(configPath, newContent);
181
+ console.error(`✓ added mcp_servers.fapony to ${configPath}`);
182
+ console.error(` restart Codex to load the MCP server`);
54
183
  }
55
184
 
56
- // Append the fapony MCP server entry to the end of the config file
57
- const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
58
- writeFileSync(configPath, newContent);
59
- console.error(`✓ added mcp_servers.fapony to ${configPath}`);
60
- console.error(` restart Codex to load the MCP server`);
185
+ // --- Stop + SessionStart hooks (~/.codex/hooks.json) ---
186
+ installStopHook(dryRun, getHome);
187
+ installSessionStartHook(dryRun, getHome);
188
+
189
+ // --- Skills (~/.agents/skills/) ---
190
+ const skillsDir = agentsSkillsDir(getHome);
191
+ reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
61
192
  }
@@ -113,6 +113,8 @@ export function cmdInstallOpencode(
113
113
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
114
114
  installReadHintPlugin(dryRun, getHome);
115
115
  installCommitHintPlugin(dryRun, getHome);
116
+ installEditHintPlugin(dryRun, getHome);
117
+ installSessionStartPlugin(dryRun, getHome);
116
118
  return;
117
119
  }
118
120
 
@@ -136,6 +138,8 @@ export function cmdInstallOpencode(
136
138
  console.log(computeDiff(before, after));
137
139
  installReadHintPlugin(dryRun, getHome);
138
140
  installCommitHintPlugin(dryRun, getHome);
141
+ installEditHintPlugin(dryRun, getHome);
142
+ installSessionStartPlugin(dryRun, getHome);
139
143
  return;
140
144
  }
141
145
 
@@ -157,6 +161,8 @@ export function cmdInstallOpencode(
157
161
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
158
162
  installReadHintPlugin(dryRun, getHome);
159
163
  installCommitHintPlugin(dryRun, getHome);
164
+ installEditHintPlugin(dryRun, getHome);
165
+ installSessionStartPlugin(dryRun, getHome);
160
166
  }
161
167
 
162
168
  /**
@@ -228,7 +234,7 @@ export const FaponyReadHint = async ({ directory }) => {
228
234
  const { relative } = require("node:path");
229
235
  const { join: pjoin } = require("node:path");
230
236
  const wt = ctx.worktree;
231
- const abs = realpathSync(filePath.startsWith("/") ? filePath : pjoin(wt, filePath));
237
+ const abs = realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
232
238
  const r = relative(wt, abs).split("\\\\").join("/");
233
239
  return r.startsWith("..") ? null : r;
234
240
  } catch { return null; }
@@ -308,6 +314,80 @@ export const FaponyCommitHint = async ({ directory }) => {
308
314
  `;
309
315
  }
310
316
 
317
+ /**
318
+ * OpenCode plugin — the edit hint's in-process shape. `tool.execute.after`
319
+ * on the edit and write tools: when the touched file has importers, appends
320
+ * the importer count plus the review-seed command that lists them. Annotate
321
+ * only (no block, no dedupe beyond what editHintFor does itself).
322
+ *
323
+ * This is the OpenCode parity of Claude's Edit PreToolUse hook
324
+ * (PLAN-edit-importer-hint): same shared logic (editHintFor in src/hook.ts),
325
+ * same once-per-(session, file) dedupe, same `"edit"` fire surface. One
326
+ * known difference: OpenCode's only annotate channel is `after`, so the hint
327
+ * lands one step after the edit instead of before it — the `before` hook can
328
+ * only mutate args or throw, and a hint must never block a tool call.
329
+ *
330
+ * `write` is guarded alongside `edit` for free: a write to a new file
331
+ * resolves to null inside editHintFor (nothing imports it yet), so only a
332
+ * write that overwrites an imported file fires. `apply_patch` is out of
333
+ * scope — its paths live inside patchText, not filePath (separate plan).
334
+ */
335
+ export function editHintPluginSource(root: string): string {
336
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
337
+ return `// fapony edit hint — annotates edits to source files that have importers
338
+ // with the importer count plus the review-seed command that lists them.
339
+ // Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
340
+ // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
341
+ import { editHintFor, recordHintFire } from ${hookModule};
342
+
343
+ export const FaponyEditHint = async ({ directory }) => {
344
+ return {
345
+ "tool.execute.after": async (input, output) => {
346
+ try {
347
+ if (input.tool !== "edit" && input.tool !== "write") return;
348
+ // The only channel that reaches the agent is output.output; if it is
349
+ // not a string the hint cannot surface, so bail before editHintFor
350
+ // spends the per-session dedupe row or logs a fire that never showed.
351
+ if (typeof output.output !== "string") return;
352
+ const filePath = input.args?.filePath;
353
+ const hint = editHintFor({
354
+ filePath,
355
+ cwd: directory,
356
+ session: input.sessionID,
357
+ });
358
+
359
+ // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
360
+ // After output — best-effort, never block the hint.
361
+ if (hint) {
362
+ output.output = output.output + "\\n" + hint;
363
+ try {
364
+ const { spawnSync } = require("node:child_process");
365
+ const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
366
+ if (git.status === 0) {
367
+ const worktree = require("node:fs").realpathSync(git.stdout.trim());
368
+ const rel = typeof filePath === "string" ? (() => {
369
+ try {
370
+ const { relative, join: pjoin } = require("node:path");
371
+ const abs = require("node:fs").realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
372
+ const r = relative(worktree, abs).split("\\\\").join("/");
373
+ return r.startsWith("..") ? null : r;
374
+ } catch { return null; }
375
+ })() : null;
376
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "edit", file: rel, count: 1 });
377
+ }
378
+ } catch {
379
+ // best-effort
380
+ }
381
+ }
382
+ } catch {
383
+ // a hint must never break an edit
384
+ }
385
+ },
386
+ };
387
+ };
388
+ `;
389
+ }
390
+
311
391
  function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
312
392
  const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
313
393
  const pluginPath = join(pluginsDir, "fapony-read-hint.ts");
@@ -373,3 +453,131 @@ function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
373
453
  ` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
374
454
  );
375
455
  }
456
+
457
+ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
458
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
459
+ const pluginPath = join(pluginsDir, "fapony-edit-hint.ts");
460
+ if (existsSync(pluginPath)) {
461
+ let current = "";
462
+ try {
463
+ current = readFileSync(pluginPath, "utf-8");
464
+ } catch {
465
+ current = "";
466
+ }
467
+ if (current.includes("editHintFor")) {
468
+ console.error(` edit hint: already installed — no change`);
469
+ return;
470
+ }
471
+ console.error(
472
+ ` edit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
473
+ );
474
+ return;
475
+ }
476
+ if (!dryRun) {
477
+ try {
478
+ mkdirSync(pluginsDir, { recursive: true });
479
+ writeFileSync(pluginPath, editHintPluginSource(INSTALL_ROOT), "utf-8");
480
+ } catch (e) {
481
+ console.error(` edit hint: failed to write — ${(e as Error).message}`);
482
+ return;
483
+ }
484
+ }
485
+ console.error(
486
+ ` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
487
+ );
488
+ }
489
+
490
+ /**
491
+ * OpenCode plugin — session-start context via `fapony mem kickoff`, the
492
+ * OpenCode parity of Claude's SessionStart hook.
493
+ *
494
+ * Why `experimental.chat.system.transform` and not the `event` hook on
495
+ * `session.created`: the event hook is observer-only (returns void, no
496
+ * injection channel), while system.transform's `output.system` is the
497
+ * documented injection point (`packages/plugin/src/index.ts`: input
498
+ * `{ sessionID?, model }`, output `{ system: string[] }`). The context lands
499
+ * on the first model dispatch instead of at session creation — one step late,
500
+ * the same shape as every other OpenCode hint (annotate-after), and arguably
501
+ * better: sessions that never dispatch never pay the spawn.
502
+ *
503
+ * Once per session (closure Set — the factory runs once per server), silent
504
+ * when kickoff exits nonzero or prints nothing (no mem log in scope), never
505
+ * throws. `process.execPath` is the bun running opencode itself — no PATH
506
+ * dependency. Logic shared via `capContext` in src/hook.ts, same one-copy
507
+ * shape as the other three plugins.
508
+ */
509
+ export function sessionStartPluginSource(root: string): string {
510
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
511
+ const faponyTs = JSON.stringify(join(root, "fapony.ts"));
512
+ return `// fapony session start — injects \`fapony mem kickoff\` as context once per
513
+ // session, silent when the repo has no mem log. Annotate only: never blocks.
514
+ // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
515
+ import { capContext } from ${hookModule};
516
+
517
+ export const FaponySessionStart = async ({ directory }) => {
518
+ const seen = new Set();
519
+ return {
520
+ "experimental.chat.system.transform": async (input, output) => {
521
+ try {
522
+ const sessionID = input?.sessionID;
523
+ if (!sessionID || seen.has(sessionID)) return;
524
+ seen.add(sessionID);
525
+ const { spawnSync } = require("node:child_process");
526
+ const kick = spawnSync(process.execPath, [${faponyTs}, "mem", "kickoff"], {
527
+ cwd: directory,
528
+ encoding: "utf-8",
529
+ });
530
+ if (kick.status !== 0) return;
531
+ const out = (kick.stdout ?? "").toString().trim();
532
+ if (!out) return;
533
+ output.system.push(capContext(out));
534
+ } catch {
535
+ // context must never break a session start
536
+ }
537
+ },
538
+ };
539
+ };
540
+ `;
541
+ }
542
+
543
+ function installSessionStartPlugin(
544
+ dryRun: boolean,
545
+ getHome: () => string,
546
+ ): void {
547
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
548
+ const pluginPath = join(pluginsDir, "fapony-session-start.ts");
549
+ if (existsSync(pluginPath)) {
550
+ let current = "";
551
+ try {
552
+ current = readFileSync(pluginPath, "utf-8");
553
+ } catch {
554
+ current = "";
555
+ }
556
+ if (current.includes("capContext")) {
557
+ console.error(` session start: already installed — no change`);
558
+ return;
559
+ }
560
+ console.error(
561
+ ` session start: ${pluginPath} exists but isn't fapony's — not overwriting.`,
562
+ );
563
+ return;
564
+ }
565
+ if (!dryRun) {
566
+ try {
567
+ mkdirSync(pluginsDir, { recursive: true });
568
+ writeFileSync(
569
+ pluginPath,
570
+ sessionStartPluginSource(INSTALL_ROOT),
571
+ "utf-8",
572
+ );
573
+ } catch (e) {
574
+ console.error(
575
+ ` session start: failed to write — ${(e as Error).message}`,
576
+ );
577
+ return;
578
+ }
579
+ }
580
+ console.error(
581
+ ` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
582
+ );
583
+ }
package/src/install.ts CHANGED
@@ -28,12 +28,13 @@ export {
28
28
  claudeGetPointsToFapony,
29
29
  cmdInstallClaude,
30
30
  } from "./install/claude.js";
31
- export { cmdInstallCodex } from "./install/codex.js";
31
+ export { cmdInstallCodex, findCodexHooksJson } from "./install/codex.js";
32
32
  export { cmdInstallCursor } from "./install/cursor.js";
33
33
  export { detectClients } from "./install/detect.js";
34
34
  export {
35
35
  cmdInstallOpencode,
36
36
  commitHintPluginSource,
37
+ editHintPluginSource,
37
38
  readHintPluginSource,
38
39
  } from "./install/opencode.js";
39
40
  export {
@@ -14,6 +14,7 @@
14
14
  import { execSync } from "node:child_process";
15
15
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
16
  import { join, relative } from "node:path";
17
+ import { DEFAULT_EVIDENCE_FILE } from "./db/index.js";
17
18
  import { faponyDir } from "./db/load.js";
18
19
  import { assertSafe } from "./safety.js";
19
20
 
@@ -51,13 +52,13 @@ function gitSha(cwd: string): string {
51
52
  return p.exitCode === 0 ? p.stdout.toString().trim().slice(0, 12) : "none";
52
53
  }
53
54
 
54
- function baselinePath(worktree: string): string {
55
+ export function baselinePath(worktree: string): string {
55
56
  const key = worktree.replace(/[^A-Za-z0-9._-]+/g, "_");
56
57
  return join(faponyDir(), "lint-baseline", `${key}.json`);
57
58
  }
58
59
 
59
- function readEvidenceLintCmd(worktree: string): string | null {
60
- const evidence = join(worktree, ".fapony", "evidence.json");
60
+ export function readEvidenceLintCmd(worktree: string): string | null {
61
+ const evidence = join(worktree, DEFAULT_EVIDENCE_FILE);
61
62
  try {
62
63
  const parsed = JSON.parse(readFileSync(evidence, "utf-8")) as {
63
64
  commands?: { name?: string; cmd?: string }[];
@@ -17,7 +17,13 @@
17
17
  import { execSync } from "node:child_process";
18
18
  import { existsSync, readFileSync } from "node:fs";
19
19
  import { join, relative } from "node:path";
20
- import { type Config, evidenceFile, safetyDeny } from "../db/index.js";
20
+ import {
21
+ type Config,
22
+ EVIDENCE_FILENAME,
23
+ evidenceFile,
24
+ FAPONY_DIR,
25
+ safetyDeny,
26
+ } from "../db/index.js";
21
27
  import { assertSafe } from "../safety.js";
22
28
  import type { EvidenceItem, EvidenceStatus } from "./primitives.js";
23
29
 
@@ -80,7 +86,13 @@ export function resolveEvidencePath(
80
86
  APP_GROUPS.includes(group) &&
81
87
  heads.every(([g, a]) => g === group && a === app)
82
88
  ) {
83
- const candidate = join(worktree, group, app, ".fapony", "evidence.json");
89
+ const candidate = join(
90
+ worktree,
91
+ group,
92
+ app,
93
+ FAPONY_DIR,
94
+ EVIDENCE_FILENAME,
95
+ );
84
96
  if (existsSync(candidate)) return candidate;
85
97
  }
86
98
  }
@@ -9,8 +9,7 @@
9
9
  import { VERDICT_GRADES } from "../../parse.js";
10
10
  import { REASON_CODES, REGIME_CODES } from "../types.js";
11
11
 
12
- export { toolMemAdd, toolMemFind } from "./mem.js";
13
- export { toolPassiveUsage } from "./usage.js";
12
+ export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
14
13
  export { toolVerdictSubmit } from "./verdict.js";
15
14
 
16
15
  // --- Tool definitions ---
@@ -179,43 +178,29 @@ export const TOOLS = [
179
178
  },
180
179
  },
181
180
  {
182
- name: "fapony_usage",
181
+ name: "mem_close",
183
182
  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.",
183
+ "Close a mem row by id with a tombstone message (what was done). " +
184
+ "The write half of closing what mem_find shows as open — id must exist. " +
185
+ "Returns the ref and timestamp.",
189
186
  inputSchema: {
190
187
  type: "object" as const,
191
188
  properties: {
192
189
  worktree: {
193
190
  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
191
  description:
204
- "Unix timestamp — include sessions created at or before this time",
192
+ "Absolute path (git rev-parse --show-toplevel) — required",
205
193
  },
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.",
194
+ id: {
195
+ type: "string",
196
+ description: "The row id to close",
211
197
  },
212
- json: {
213
- type: "boolean",
214
- description:
215
- "If true, return raw JSON PassiveUsageResult. If false (default), return human-readable text.",
198
+ text: {
199
+ type: "string",
200
+ description: "Tombstone message — what was done (commit sha counts)",
216
201
  },
217
202
  },
218
- required: [],
203
+ required: ["worktree", "id", "text"],
219
204
  },
220
205
  },
221
206
  ];