fapony 0.2.1 → 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.
package/src/hook.ts CHANGED
@@ -29,8 +29,9 @@ import { homedir } from "node:os";
29
29
  import { basename, join, relative, resolve, sep } from "node:path";
30
30
  import { buildGraphCached, collectSourceFiles, SCAN_EXTS } from "./analyze.js";
31
31
  import { openDb } from "./db/index.js";
32
- import { debtForFile, loadConventions } from "./debt.js";
33
- import { readMemLog } from "./memory.js";
32
+ import { debtForFile, loadConventions } from "./debt/index.js";
33
+ import { detectTestRunner } from "./detect.js";
34
+ import { readMemLog, whereMemDir } from "./memory.js";
34
35
 
35
36
  // --- Hint-fire log (PLAN-feedback-surface chunk 1) ---
36
37
  //
@@ -255,6 +256,8 @@ export function decideStop(opts: {
255
256
  verdicts: number;
256
257
  commitList?: string[];
257
258
  memLastTs?: string | null;
259
+ /** Logs that exist in the repo but are out of scope from this worktree. */
260
+ memCandidates?: string[];
258
261
  }): string | null {
259
262
  if (opts.stopHookActive) return null; // already blocked once — let it end
260
263
  if (!opts.worktree) return null;
@@ -272,16 +275,30 @@ export function decideStop(opts: {
272
275
  lines.push(
273
276
  `mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
274
277
  );
278
+ } else if (opts.memCandidates?.length) {
279
+ // "nothing recorded" would be a lie: the log exists, it is just not in
280
+ // scope from here (monorepo — the log lives in the app dir). Say where.
281
+ lines.push(
282
+ `mem: no log in scope from ${opts.worktree} — found ` +
283
+ `${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
284
+ );
275
285
  } else {
276
286
  lines.push("mem: no rows at all — nothing recorded in this project yet");
277
287
  }
288
+ const runner = detectTestRunner(opts.worktree);
289
+ const verifyLine = runner
290
+ ? `If you did not run \`${runner.typecheckCmd ? `${runner.typecheckCmd} and ` : ""}${runner.testCmd}\` to a real exit code, ` +
291
+ `the honest verdict is uncertain, not pass. `
292
+ : `If you did not run this repo's typecheck and test suite to a real exit code, ` +
293
+ `the honest verdict is uncertain, not pass. `;
294
+
278
295
  lines.push(
279
296
  `Call verdict_submit before ending: worktree must be the absolute path above, ` +
280
297
  `regime is one of code|fix|review|plan|inquiry|test, and the note must stand alone ` +
281
298
  `(it is read months from now with no access to this conversation). ` +
282
299
  `Grade what actually happened — pass-family when it held up, fail if the first ` +
283
- `attempt was wrong, uncertain when you could not verify it. What deserves a mem ` +
284
- `row (decision/bug/note) is your call — not every unit needs one.`,
300
+ `attempt was wrong, uncertain when you could not verify it. ${verifyLine}` +
301
+ `What deserves a mem row (decision/bug/note) is your call — not every unit needs one.`,
285
302
  );
286
303
  return lines.join("\n");
287
304
  }
@@ -390,6 +407,67 @@ export function stopOutput(client: StopClient, reason: string): string {
390
407
  return JSON.stringify({ decision: "block", reason });
391
408
  }
392
409
 
410
+ // --- Block dedupe (one block per session + worktree) ---
411
+ //
412
+ // stop_hook_active only suppresses the block that fires *immediately* after
413
+ // one. Every later turn that still carries ungraded commits blocks again, so
414
+ // a session that keeps committing gets the same paragraph 4-5 times. The
415
+ // first block already delivered it; the repeats add noise, not force (rule 9
416
+ // — the forcing happens once, and the agent that ignored it once is not
417
+ // persuaded by the fifth copy).
418
+ //
419
+ // Keyed by the transcript path, which is already the session identity the
420
+ // commit window is measured from — no session field to thread through.
421
+
422
+ const STOP_BLOCK_DIR = "stop-block";
423
+
424
+ interface StopBlockRow {
425
+ ts: string;
426
+ worktree: string;
427
+ }
428
+
429
+ /** Absolute path of a session's block log — may not exist. */
430
+ export function stopBlockPath(session: string): string {
431
+ const base =
432
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
433
+ return join(base, STOP_BLOCK_DIR, `${sessionKey(session)}.jsonl`);
434
+ }
435
+
436
+ /**
437
+ * True when this session already blocked for this worktree — the caller then
438
+ * lets the turn end. Records the block when it has not. Every unknown (no
439
+ * session identity, unwritable state dir) resolves to false: a dedupe that
440
+ * guesses must fail towards blocking, never towards silence.
441
+ */
442
+ export function stopBlockedBefore(
443
+ session: string | null,
444
+ worktree: string,
445
+ ): boolean {
446
+ if (!session) return false;
447
+ const path = stopBlockPath(session);
448
+ try {
449
+ if (existsSync(path)) {
450
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
451
+ if (!line) continue;
452
+ try {
453
+ if ((JSON.parse(line) as StopBlockRow).worktree === worktree) {
454
+ return true;
455
+ }
456
+ } catch {
457
+ // a torn line must not lose the rest of the log
458
+ }
459
+ }
460
+ }
461
+ const dir = join(path, "..");
462
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
463
+ const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
464
+ appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
465
+ } catch {
466
+ return false;
467
+ }
468
+ return false;
469
+ }
470
+
393
471
  /** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
394
472
  export async function cmdHookStop(): Promise<void> {
395
473
  let reason: string | null = null;
@@ -421,6 +499,7 @@ export async function cmdHookStop(): Promise<void> {
421
499
  let commitList: string[] = [];
422
500
  let verdicts = 0;
423
501
  let memLastTs: string | null = null;
502
+ let memCandidates: string[] = [];
424
503
  if (worktree && since) {
425
504
  const log = git(
426
505
  ["log", "--since", `${since} +0000`, "--format=%h %s"],
@@ -442,6 +521,8 @@ export async function cmdHookStop(): Promise<void> {
442
521
  try {
443
522
  const mem = readMemLog(worktree);
444
523
  memLastTs = mem.rows[0]?.ts ?? null;
524
+ if (!memLastTs)
525
+ memCandidates = whereMemDir(worktree).candidates ?? [];
445
526
  } catch {
446
527
  memLastTs = null;
447
528
  }
@@ -455,7 +536,16 @@ export async function cmdHookStop(): Promise<void> {
455
536
  verdicts,
456
537
  commitList,
457
538
  memLastTs,
539
+ memCandidates,
458
540
  });
541
+ // Already blocked for this worktree in this session — say it once.
542
+ if (
543
+ reason &&
544
+ worktree &&
545
+ stopBlockedBefore(norm.transcriptPath, worktree)
546
+ ) {
547
+ reason = null;
548
+ }
459
549
  } catch {
460
550
  reason = null; // any failure = allow the turn to end
461
551
  }
@@ -463,6 +553,78 @@ export async function cmdHookStop(): Promise<void> {
463
553
  if (reason) console.log(stopOutput(client, reason));
464
554
  }
465
555
 
556
+ // --- Session start (kickoff as context, not as a thing to remember) ---
557
+ //
558
+ // `fapony mem kickoff` is the one command that pays for itself at session
559
+ // open — what is open, what is stale, what the last rows touched. Asking the
560
+ // agent to run it measured as not enough (rule 9), and an MCP tool would pay
561
+ // schema rent in every session of every client to save one bash round
562
+ // (rule 13). A SessionStart hook is neither: zero rent, and it fires whether
563
+ // or not anyone remembers.
564
+ //
565
+ // Runs the CLI in a subprocess rather than calling cmdKickoff: kickoff prints
566
+ // to stdout and exits on bad input, both of which would be this hook's stdout.
567
+
568
+ /** Cap on injected context — kickoff is short, a broken repo's output is not. */
569
+ export const SESSION_START_MAX_CHARS = 4_000;
570
+
571
+ const TRUNCATED = "… truncated — run `fapony mem kickoff` for the rest";
572
+
573
+ /** Trim to whole lines, keeping the marker's line boundary intact. */
574
+ function headLines(text: string, max: number): string {
575
+ const cut = text.slice(0, max);
576
+ const lastNl = cut.lastIndexOf("\n");
577
+ return (lastNl > 0 ? cut.slice(0, lastNl) : cut).trimEnd();
578
+ }
579
+
580
+ /**
581
+ * Trim to whole lines within the cap, with an honest truncation marker.
582
+ *
583
+ * "## next up" is kickoff's last section and its most actionable one, so a
584
+ * plain head-cut drops exactly the part worth injecting in a repo with a long
585
+ * open list (measured here: 92 rows, the cut landed mid-history). Keep it and
586
+ * spend the rest of the budget on the head.
587
+ */
588
+ export function capContext(
589
+ text: string,
590
+ max = SESSION_START_MAX_CHARS,
591
+ ): string {
592
+ if (text.length <= max) return text;
593
+ const at = text.lastIndexOf("\n## next up");
594
+ const tail = at > 0 ? text.slice(at).trimEnd() : "";
595
+ if (tail && tail.length < max / 2) {
596
+ return `${headLines(text, max - tail.length)}\n${TRUNCATED}\n${tail}`;
597
+ }
598
+ return `${headLines(text, max)}\n${TRUNCATED}`;
599
+ }
600
+
601
+ /** SessionStart hook: inject `fapony mem kickoff` output as context. */
602
+ export async function cmdHookSessionStart(): Promise<void> {
603
+ try {
604
+ const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
605
+ const cwd = raw.cwd ?? process.cwd();
606
+ // No mem log in scope = nothing to say. Silence beats "no rows yet".
607
+ if (!whereMemDir(cwd).dir) return;
608
+ const p = Bun.spawnSync([process.execPath, Bun.main, "mem", "kickoff"], {
609
+ cwd,
610
+ stdout: "pipe",
611
+ stderr: "pipe",
612
+ });
613
+ const out = p.stdout.toString().trim();
614
+ if (p.exitCode !== 0 || !out) return;
615
+ console.log(
616
+ JSON.stringify({
617
+ hookSpecificOutput: {
618
+ hookEventName: "SessionStart",
619
+ additionalContext: capContext(out),
620
+ },
621
+ }),
622
+ );
623
+ } catch {
624
+ // any failure = no context, never a broken session start
625
+ }
626
+ }
627
+
466
628
  // --- Read hint (PreToolUse annotate — never block, never dedupe) ---
467
629
  //
468
630
  // Reading a large file in full is where an agent spends tokens without
@@ -523,7 +685,7 @@ export function readHintFor(opts: ReadHintInput): string | null {
523
685
  return (
524
686
  `fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
525
687
  `returns exports with line numbers, importers, and signatures first ` +
526
- `(${READ_HINT_MEASURED})`
688
+ `(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
527
689
  );
528
690
  } catch {
529
691
  return null;
@@ -785,7 +947,7 @@ export function editHintFor(opts: EditHintInput): string | null {
785
947
  return (
786
948
  `fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
787
949
  `review-seed --files ${rel} lists them (add --callers <export> for one ` +
788
- `export's callers); check before changing its shape`
950
+ `export's callers); check before changing its shape (skill /lookup-before-edit)`
789
951
  );
790
952
  } catch {
791
953
  return null;
@@ -353,6 +353,7 @@ function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
353
353
  installStopHook(dryRun, deps);
354
354
  installReadHintHook(dryRun, deps);
355
355
  installEditHintHook(dryRun, deps);
356
+ installSessionStartHook(dryRun, deps);
356
357
  }
357
358
 
358
359
  function installStopHook(dryRun: boolean, deps: InstallDeps): void {
@@ -395,3 +396,16 @@ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
395
396
  label: "edit hint",
396
397
  });
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,7 +1,7 @@
1
1
  // src/install/codex.ts — Codex install provider
2
2
  //
3
3
  // Reads/writes ~/.codex/config.toml directly for MCP config.
4
- // Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop).
4
+ // Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop, SessionStart).
5
5
  // Symlinks skills into ~/.agents/skills/ (same dir as ZCode).
6
6
 
7
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -46,18 +46,25 @@ function readJsonObject(path: string): Record<string, unknown> | null {
46
46
  }
47
47
 
48
48
  /**
49
- * Append the fapony Stop hook to ~/.codex/hooks.json.
50
- * Never replaces other hook groups or foreign entries within the Stop group.
51
- * Fapony-owned entry is identified by the command string containing "hook-stop".
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.
52
55
  */
53
- function installStopHook(dryRun: boolean, getHome: () => string): void {
56
+ function installCodexHookGroup(
57
+ dryRun: boolean,
58
+ getHome: () => string,
59
+ opts: { event: "Stop" | "SessionStart"; subcommand: string; label: string },
60
+ ): void {
54
61
  const hooksPath = join(getHome(), ".codex", "hooks.json");
55
62
  let config: Record<string, unknown> = {};
56
63
  if (existsSync(hooksPath)) {
57
64
  const parsed = readJsonObject(hooksPath);
58
65
  if (!parsed) {
59
66
  console.error(
60
- ` stop hook: ${hooksPath} is unreadable or malformed — skipping`,
67
+ ` ${opts.label}: ${hooksPath} is unreadable or malformed — skipping`,
61
68
  );
62
69
  return;
63
70
  }
@@ -70,45 +77,76 @@ function installStopHook(dryRun: boolean, getHome: () => string): void {
70
77
  (typeof hookMap !== "object" || Array.isArray(hookMap))
71
78
  ) {
72
79
  console.error(
73
- ` stop hook: ${hooksPath} has an unexpected "hooks" shape — skipping`,
80
+ ` ${opts.label}: ${hooksPath} has an unexpected "hooks" shape — skipping`,
74
81
  );
75
82
  return;
76
83
  }
77
84
 
78
85
  const map = (hookMap ?? {}) as Record<string, unknown>;
79
- // Codex Stop array: each element is { matcher?, hooks: [...] }
80
- const stop = Array.isArray(map.Stop) ? (map.Stop as unknown[]) : [];
81
-
82
- // Check if fapony stop hook already present (by command string)
83
- const serialized = JSON.stringify(stop);
84
- if (serialized.includes("hook-stop")) {
85
- console.error(` stop hook: already configured in hooks.json — no change`);
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
+ );
86
97
  return;
87
98
  }
88
99
 
89
- const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} hook-stop`;
100
+ const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} ${opts.subcommand}`;
90
101
  const faponyEntry = { hooks: [{ type: "command", command }] };
91
102
  const after = {
92
103
  ...config,
93
- hooks: { ...map, Stop: [...stop, faponyEntry] },
104
+ hooks: { ...map, [opts.event]: [...group, faponyEntry] },
94
105
  };
95
106
 
96
107
  if (!dryRun) {
97
108
  try {
98
109
  writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
99
110
  } catch (e) {
100
- console.error(` stop hook: failed to write — ${(e as Error).message}`);
111
+ console.error(
112
+ ` ${opts.label}: failed to write — ${(e as Error).message}`,
113
+ );
101
114
  return;
102
115
  }
103
116
  }
104
117
  console.error(
105
- ` stop hook: ${dryRun ? "would write" : "wrote"} hooks.Stop → ${hooksPath}`,
118
+ ` ${opts.label}: ${dryRun ? "would write" : "wrote"} hooks.${opts.event} → ${hooksPath}`,
106
119
  );
107
120
  console.error(
108
121
  ` review and trust via Codex /hooks before the hook will run`,
109
122
  );
110
123
  }
111
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
+
112
150
  export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
113
151
  const exitFn = deps.exit ?? defaultExit;
114
152
  const getHome = deps.homedir ?? homedir;
@@ -144,8 +182,9 @@ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
144
182
  console.error(` restart Codex to load the MCP server`);
145
183
  }
146
184
 
147
- // --- Stop hook (~/.codex/hooks.json) ---
185
+ // --- Stop + SessionStart hooks (~/.codex/hooks.json) ---
148
186
  installStopHook(dryRun, getHome);
187
+ installSessionStartHook(dryRun, getHome);
149
188
 
150
189
  // --- Skills (~/.agents/skills/) ---
151
190
  const skillsDir = agentsSkillsDir(getHome);
@@ -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
  /**
@@ -483,3 +486,98 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
483
486
  ` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
484
487
  );
485
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
+ }
@@ -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 {
@@ -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
  ];