fapony 0.3.0 → 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.
package/src/init-mem.ts CHANGED
@@ -4,32 +4,10 @@
4
4
  // Now fapony owns the mem code (src/mem/) and calls it directly via `fapony mem`.
5
5
  // This command's new job: delete legacy .memory/ dirs + warn about package.json refs.
6
6
 
7
- import {
8
- copyFileSync,
9
- existsSync,
10
- mkdirSync,
11
- readdirSync,
12
- readFileSync,
13
- rmSync,
14
- } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
15
8
  import { join } from "node:path";
16
9
  import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
17
-
18
- export function copyDir(src: string, dest: string): string[] {
19
- mkdirSync(dest, { recursive: true });
20
- const copied: string[] = [];
21
- for (const entry of readdirSync(src, { withFileTypes: true })) {
22
- const s = join(src, entry.name);
23
- const d = join(dest, entry.name);
24
- if (entry.isDirectory()) {
25
- copied.push(...copyDir(s, d));
26
- } else {
27
- copyFileSync(s, d);
28
- copied.push(d);
29
- }
30
- }
31
- return copied;
32
- }
10
+ import { walkDir } from "./util.js";
33
11
 
34
12
  export function cmdInitMem(args: string[]): void {
35
13
  const force = args.includes("--force");
@@ -52,27 +30,18 @@ export function cmdInitMem(args: string[]): void {
52
30
  const root = gitRoot || worktree;
53
31
 
54
32
  // 1) Find and delete .memory/ directories (legacy layout)
55
- const memoryDirs: string[] = [];
56
- const walk = (dir: string, depth = 0) => {
57
- if (depth > 4) return;
58
- if (existsSync(`${dir}/.memory`)) {
59
- memoryDirs.push(`${dir}/.memory`);
60
- }
61
- try {
62
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
63
- if (
64
- entry.isDirectory() &&
65
- entry.name !== "node_modules" &&
66
- (!entry.name.startsWith(".") || entry.name === FAPONY_DIR)
67
- ) {
68
- walk(join(dir, entry.name), depth + 1);
69
- }
70
- }
71
- } catch {
72
- // ignore
73
- }
74
- };
75
- walk(root);
33
+ const memoryDirs = walkDir(root, {
34
+ includeDotDirs: [FAPONY_DIR],
35
+ predicate: (dir) => existsSync(join(dir, ".memory")),
36
+ }).map((dir) => join(dir, ".memory"));
37
+
38
+ // Legacy filenames that may live alongside live logs inside .fapony/.memory/
39
+ const LEGACY_MEM_FILENAMES = [
40
+ "mem.ts",
41
+ "store.ts",
42
+ "selectors.ts",
43
+ "render.ts",
44
+ ];
76
45
 
77
46
  if (memoryDirs.length === 0) {
78
47
  console.log("no legacy .memory/ directories found — already clean");
@@ -90,6 +59,39 @@ export function cmdInitMem(args: string[]): void {
90
59
  } catch {
91
60
  // unreadable dir — fall through and remove
92
61
  }
62
+ // Inside .fapony/.memory/: remove known legacy .ts files while keeping
63
+ // live logs. The keep-if-logs guard would preserve the whole directory,
64
+ // but legacy scaffolding (mem.ts, store.ts, ...) is dead code that
65
+ // should not linger beside the active log.
66
+ const isFaponyMemory =
67
+ d.endsWith(`/${FAPONY_DIR}/.memory`) || d === `${FAPONY_DIR}/.memory`;
68
+ if (isFaponyMemory && logs.length > 0) {
69
+ let legacyRemoved = 0;
70
+ let commandsRemoved = 0;
71
+ for (const name of LEGACY_MEM_FILENAMES) {
72
+ const fp = join(d, name);
73
+ if (existsSync(fp)) {
74
+ rmSync(fp);
75
+ legacyRemoved++;
76
+ }
77
+ }
78
+ const commandsDir = join(d, "commands");
79
+ if (existsSync(commandsDir)) {
80
+ rmSync(commandsDir, { recursive: true, force: true });
81
+ commandsRemoved++;
82
+ }
83
+ if (legacyRemoved || commandsRemoved) {
84
+ console.log(
85
+ `cleaned ${d} — removed ${legacyRemoved} legacy file(s)${
86
+ commandsRemoved ? " + commands/" : ""
87
+ } (kept ${logs.length} log file(s): ${logs.join(", ")})`,
88
+ );
89
+ } else {
90
+ console.log(`${d} — already clean (logs: ${logs.join(", ")})`);
91
+ }
92
+ kept++;
93
+ continue;
94
+ }
93
95
  if (logs.length > 0 && !force) {
94
96
  console.log(
95
97
  `keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
@@ -116,42 +118,26 @@ export function cmdInitMem(args: string[]): void {
116
118
  }
117
119
 
118
120
  // 2) Warn about package.json call sites still referencing .memory/mem.ts
119
- const warnAboutCallSites = (dir: string, depth = 0) => {
120
- if (depth > 4) return;
121
+ for (const dir of walkDir(root)) {
121
122
  const pkg = join(dir, "package.json");
122
- if (existsSync(pkg)) {
123
- try {
124
- const raw = readFileSync(pkg, "utf8");
125
- if (raw.includes(".memory/mem.ts")) {
126
- console.log(`\n⚠ ${pkg} still references .memory/mem.ts`);
127
- console.log(
128
- ` update to: "fapony mem <sub>" (e.g. "fapony mem add", "fapony mem close")`,
129
- );
130
- }
131
- } catch {
132
- // ignore
133
- }
134
- }
123
+ if (!existsSync(pkg)) continue;
135
124
  try {
136
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
137
- if (
138
- entry.isDirectory() &&
139
- !entry.name.startsWith(".") &&
140
- entry.name !== "node_modules"
141
- ) {
142
- warnAboutCallSites(join(dir, entry.name), depth + 1);
143
- }
125
+ const raw = readFileSync(pkg, "utf8");
126
+ if (raw.includes(".memory/mem.ts")) {
127
+ console.log(`\n⚠ ${pkg} still references .memory/mem.ts`);
128
+ console.log(
129
+ ` update to: "fapony mem <sub>" (e.g. "fapony mem add", "fapony mem close")`,
130
+ );
144
131
  }
145
132
  } catch {
146
133
  // ignore
147
134
  }
148
- };
149
- warnAboutCallSites(root);
135
+ }
150
136
 
151
137
  console.log("\nmem commands are now built into fapony:");
152
138
  console.log(' fapony mem add <kind> "<text>" --files <files>');
153
139
  console.log(' fapony mem close <id> "<text>"');
154
140
  console.log(" fapony mem find <word>");
155
141
  console.log(" fapony mem kickoff [id|spec.md]");
156
- console.log(" fapony mem now");
142
+ console.log(" fapony mem done | stale");
157
143
  }
package/src/init.ts CHANGED
@@ -95,10 +95,9 @@ work done. Cut at chunk boundaries instead:
95
95
  Finish a chunk, before starting the next:
96
96
  1. Tick its checkbox + stamp the TL;DR in the plan file
97
97
  2. Commit — separate from other chunks
98
- 3. \`verdict_submit\` (fapony MCP), grading what actually happened
99
- 4. \`fapony mem add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
98
+ 3. \`fapony mem add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
100
99
  — use the same plan path every time
101
- 5. Stop. Do not continue to the next chunk in the same session unless told to.
100
+ 4. Stop. Do not continue to the next chunk in the same session unless told to.
102
101
 
103
102
  Next chunk, new session — open with \`fapony mem kickoff <path/to/PLAN-x.md>\` instead
104
103
  of carrying the old transcript forward. kickoff already filters to the rows for that plan,
@@ -196,16 +195,6 @@ export function initProject(targetPath: string, config?: Config): void {
196
195
 
197
196
  const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
198
197
 
199
- function ask(question: string): Promise<string> {
200
- const rl = createInterface({ input: process.stdin, output: process.stdout });
201
- return new Promise((resolve) => {
202
- rl.question(`${question} `, (answer) => {
203
- rl.close();
204
- resolve(answer.trim());
205
- });
206
- });
207
- }
208
-
209
198
  export async function cmdInit(args: string[]): Promise<void> {
210
199
  const targetPath = args[0];
211
200
  if (!targetPath) {
@@ -242,9 +231,16 @@ export async function cmdInit(args: string[]): Promise<void> {
242
231
  );
243
232
  if (found.length === 0) return;
244
233
 
245
- const answer = await ask(
246
- `\nAppend the memory-logging rules above to ${found.map((f) => relative(targetPath, f)).join(" and ")}? [y/N]`,
247
- );
234
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
235
+ const answer = await new Promise<string>((resolve) => {
236
+ rl.question(
237
+ `\nAppend the memory-logging rules above to ${found.map((f) => relative(targetPath, f)).join(" and ")}? [y/N] `,
238
+ (a) => {
239
+ rl.close();
240
+ resolve(a.trim());
241
+ },
242
+ );
243
+ });
248
244
  if (!isAffirmative(answer)) return;
249
245
 
250
246
  const snippet = `\n\n${RULES_SNIPPET()}\n`;
@@ -0,0 +1,112 @@
1
+ // src/install/antigravity.ts — Google Antigravity install provider
2
+ //
3
+ // Writes mcpServers.fapony to ~/.gemini/config/mcp_config.json (Antigravity's
4
+ // global MCP config) and symlinks skills into ~/.agents/skills/ (Progressive
5
+ // Skills path). No hooks in the first phase — Antigravity's hook surface is
6
+ // still evolving.
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
12
+ import {
13
+ CURSOR_MCP_ENTRY,
14
+ defaultExit,
15
+ type InstallDeps,
16
+ MCP_KEY,
17
+ } from "./types.js";
18
+
19
+ /** ~/.gemini — created on first Antigravity launch. */
20
+ export function findGeminiDir(getHome: () => string): string | null {
21
+ const dir = join(getHome(), ".gemini");
22
+ return existsSync(dir) ? dir : null;
23
+ }
24
+
25
+ function isFaponyEntry(entry: unknown): boolean {
26
+ if (!entry || typeof entry !== "object") return false;
27
+ const cfg = entry as Record<string, unknown>;
28
+ return (
29
+ cfg.command === CURSOR_MCP_ENTRY.command &&
30
+ JSON.stringify(cfg.args) === JSON.stringify(CURSOR_MCP_ENTRY.args)
31
+ );
32
+ }
33
+
34
+ function readJsonObject(path: string): Record<string, unknown> | null {
35
+ try {
36
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
37
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
38
+ return null;
39
+ }
40
+ return parsed as Record<string, unknown>;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function cmdInstallAntigravity(
47
+ dryRun: boolean,
48
+ deps: InstallDeps = {},
49
+ ): void {
50
+ const exitFn = deps.exit ?? defaultExit;
51
+ const getHome = deps.homedir ?? homedir;
52
+ const geminiDir = findGeminiDir(getHome);
53
+
54
+ if (!geminiDir) {
55
+ console.error(
56
+ `Antigravity not found — open Antigravity at least once to create ~/.gemini`,
57
+ );
58
+ exitFn(1);
59
+ return;
60
+ }
61
+
62
+ // --- 1. MCP server (mcpServers.fapony in ~/.gemini/config/mcp_config.json) ---
63
+ const configDir = join(geminiDir, "config");
64
+ const mcpPath = join(configDir, "mcp_config.json");
65
+ let mcp: Record<string, unknown> = {};
66
+ let mcpIsNew = true;
67
+ if (existsSync(mcpPath)) {
68
+ const parsed = readJsonObject(mcpPath);
69
+ if (!parsed) {
70
+ console.error(`failed to parse ${mcpPath} — fix or remove it first`);
71
+ exitFn(1);
72
+ return;
73
+ }
74
+ mcp = parsed;
75
+ mcpIsNew = false;
76
+ }
77
+ const servers = (mcp.mcpServers ?? {}) as Record<string, unknown>;
78
+ const existing = servers[MCP_KEY];
79
+ if (existing !== undefined && !isFaponyEntry(existing)) {
80
+ console.error(
81
+ `an MCP server named "${MCP_KEY}" exists but points elsewhere — not overwriting.`,
82
+ );
83
+ console.error(` inspect ${mcpPath} and remove it first`);
84
+ exitFn(1);
85
+ return;
86
+ }
87
+ if (existing !== undefined) {
88
+ console.error(
89
+ `✓ mcpServers.${MCP_KEY} already configured — no change needed`,
90
+ );
91
+ console.error(` (${mcpPath})`);
92
+ } else {
93
+ const after = {
94
+ ...mcp,
95
+ mcpServers: { ...servers, [MCP_KEY]: CURSOR_MCP_ENTRY },
96
+ };
97
+ if (dryRun) {
98
+ console.error(
99
+ `── dry-run: would ${mcpIsNew ? "create" : "write"} ${mcpPath}${mcpIsNew ? "" : ` (mcpServers.${MCP_KEY})`} ──`,
100
+ );
101
+ } else {
102
+ writeFileSync(mcpPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
103
+ console.error(`✓ added mcpServers.${MCP_KEY} to ${mcpPath}`);
104
+ console.error(` restart Antigravity to load the MCP server`);
105
+ }
106
+ }
107
+
108
+ // --- 2. Skills (~/.agents/skills/) ---
109
+ const skillsDir = agentsSkillsDir(getHome);
110
+ const results = linkSkills(skillsDir, dryRun);
111
+ reportSkills(results, skillsDir, dryRun);
112
+ }
@@ -2,14 +2,7 @@
2
2
  //
3
3
  // Shells out to `claude mcp add` (never parses/writes ~/.claude.json directly).
4
4
 
5
- import {
6
- chmodSync,
7
- copyFileSync,
8
- existsSync,
9
- mkdirSync,
10
- readFileSync,
11
- writeFileSync,
12
- } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
6
  import { homedir } from "node:os";
14
7
  import { join } from "node:path";
15
8
  import { assertSafe } from "../safety.js";
@@ -151,132 +144,18 @@ export function cmdInstallClaude(
151
144
  const skillsDir = claudeSkillsDir(deps.homedir ?? homedir);
152
145
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
153
146
 
154
- // Wire statusline: copy script + update settings.json.
155
- installStatusline(dryRun, deps);
156
-
157
147
  // Wire the Stop hook that refuses to end a turn with ungraded commits,
158
148
  // and the Read/Edit hints that annotate reads of large files and edits to
159
149
  // files with importers (both annotate-only).
160
150
  installClaudeHooks(dryRun, deps);
161
151
  }
162
152
 
163
- /**
164
- * Copy the statusline script to ~/.claude/statusline.sh and add the
165
- * statusLine field to ~/.claude/settings.json. Best-effort — never fails
166
- * the install if settings.json is unreadable or has unexpected shape.
167
- */
168
- function installStatusline(dryRun: boolean, deps: InstallDeps): void {
169
- const home = deps.homedir ? deps.homedir() : homedir();
170
- const claudeDir = join(home, ".claude");
171
- const scriptSrc = join(INSTALL_ROOT, "statusline", "claude-statusline.sh");
172
- const scriptDest = join(claudeDir, "statusline.sh");
173
- const settingsPath = join(claudeDir, "settings.json");
174
-
175
- // 1. Copy the statusline script — never overwrite someone else's.
176
- // Mirrors the mcp-entry policy above: an existing script that isn't ours
177
- // is left alone (the settings guard below will also refuse to repoint it).
178
- if (!existsSync(scriptSrc)) {
179
- console.error(` statusline: script not found at ${scriptSrc} — skipping`);
180
- return;
181
- }
182
- if (existsSync(scriptDest)) {
183
- let current = "";
184
- try {
185
- current = readFileSync(scriptDest, "utf-8");
186
- } catch {
187
- current = "";
188
- }
189
- if (!current.includes("fapony")) {
190
- console.error(
191
- ` statusline: ${scriptDest} exists but isn't fapony's — not overwriting.`,
192
- );
193
- console.error(
194
- ` inspect it first, then remove it to let fapony install its own.`,
195
- );
196
- return;
197
- }
198
- }
199
- try {
200
- if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
201
- if (!dryRun) copyFileSync(scriptSrc, scriptDest);
202
- // Claude Code execs this file — the copy must stay executable.
203
- if (!dryRun) chmodSync(scriptDest, 0o755);
204
- console.error(
205
- ` statusline: ${dryRun ? "would copy" : "copied"} ${scriptDest}`,
206
- );
207
- } catch (e) {
208
- console.error(
209
- ` statusline: failed to copy script — ${(e as Error).message}`,
210
- );
211
- return;
212
- }
213
-
214
- // 2. Update settings.json with statusLine field.
215
- let settings: Record<string, unknown> = {};
216
- if (existsSync(settingsPath)) {
217
- try {
218
- settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<
219
- string,
220
- unknown
221
- >;
222
- } catch {
223
- console.error(
224
- ` statusline: ${settingsPath} is unreadable or malformed — skipping settings update`,
225
- );
226
- return;
227
- }
228
- }
229
-
230
- // Don't overwrite if already configured (same command path). A foreign
231
- // statusLine (someone else's command) is left alone — same policy as the
232
- // mcp-entry "points elsewhere" refusal above. Match on our exact dest:
233
- // any *statusline.sh substring (e.g. another plugin's script) is not ours.
234
- const existing = settings.statusLine as Record<string, unknown> | undefined;
235
- if (
236
- existing &&
237
- existing.type === "command" &&
238
- typeof existing.command === "string" &&
239
- existing.command === scriptDest
240
- ) {
241
- console.error(
242
- ` statusline: already configured in settings.json — no change`,
243
- );
244
- return;
245
- }
246
- if (existing && typeof existing === "object") {
247
- console.error(
248
- ` statusline: settings.json already has a statusLine that isn't fapony's — not overwriting.`,
249
- );
250
- console.error(
251
- ` inspect it first, then remove it to let fapony wire its own.`,
252
- );
253
- return;
254
- }
255
-
256
- settings.statusLine = {
257
- type: "command",
258
- command: scriptDest,
259
- };
260
-
261
- if (!dryRun) {
262
- writeFileSync(
263
- settingsPath,
264
- `${JSON.stringify(settings, null, 2)}\n`,
265
- "utf-8",
266
- );
267
- }
268
- console.error(
269
- ` statusline: ${dryRun ? "would write" : "wrote"} statusLine → ${settingsPath}`,
270
- );
271
- }
272
-
273
153
  /**
274
154
  * Shared append-to-settings.json hook installer. Both fapony hooks live
275
155
  * here now (Stop since PLAN-mem-mcp, PreToolUse read hint since the
276
156
  * large-file annotate feature) — the read/write/idempotence/append shape
277
157
  * is one implementation with two callers, not a scaffold.
278
- * Same policy as installStatusline: never touch a hook someone else
279
- * registered, never fail the install over it.
158
+ * Never touch a hook someone else registered, never fail the install over it.
280
159
  */
281
160
  function ensureClaudeHook(
282
161
  dryRun: boolean,
@@ -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,
@@ -335,10 +335,10 @@ export const FaponyCommitHint = async ({ directory }) => {
335
335
  export function editHintPluginSource(root: string): string {
336
336
  const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
337
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.
338
+ // with the importer count, plus mem/debt context rows about the file.
339
339
  // Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
340
340
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
341
- import { editHintFor, recordHintFire } from ${hookModule};
341
+ import { editHintFor, readContextData, recordHintFire } from ${hookModule};
342
342
 
343
343
  export const FaponyEditHint = async ({ directory }) => {
344
344
  return {
@@ -350,16 +350,25 @@ export const FaponyEditHint = async ({ directory }) => {
350
350
  // spends the per-session dedupe row or logs a fire that never showed.
351
351
  if (typeof output.output !== "string") return;
352
352
  const filePath = input.args?.filePath;
353
+ const parts = [];
353
354
  const hint = editHintFor({
354
355
  filePath,
355
356
  cwd: directory,
356
357
  session: input.sessionID,
357
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");
358
369
 
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;
370
+ // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
371
+ // After output — best-effort, never block the hint.
363
372
  try {
364
373
  const { spawnSync } = require("node:child_process");
365
374
  const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
@@ -374,6 +383,12 @@ export const FaponyEditHint = async ({ directory }) => {
374
383
  } catch { return null; }
375
384
  })() : null;
376
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
+ }
377
392
  }
378
393
  } catch {
379
394
  // best-effort
@@ -488,8 +503,8 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
488
503
  }
489
504
 
490
505
  /**
491
- * OpenCode plugin — session-start context via `fapony mem kickoff`, the
492
- * OpenCode parity of Claude's SessionStart hook.
506
+ * OpenCode plugin — session-start context, the parity of Claude's SessionStart
507
+ * hook.
493
508
  *
494
509
  * Why `experimental.chat.system.transform` and not the `event` hook on
495
510
  * `session.created`: the event hook is observer-only (returns void, no
@@ -500,19 +515,29 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
500
515
  * the same shape as every other OpenCode hint (annotate-after), and arguably
501
516
  * better: sessions that never dispatch never pay the spawn.
502
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
+ *
503
526
  * 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.
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.
508
532
  */
509
533
  export function sessionStartPluginSource(root: string): string {
510
534
  const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
511
535
  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};
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};
516
541
 
517
542
  export const FaponySessionStart = async ({ directory }) => {
518
543
  const seen = new Set();
@@ -522,15 +547,8 @@ export const FaponySessionStart = async ({ directory }) => {
522
547
  const sessionID = input?.sessionID;
523
548
  if (!sessionID || seen.has(sessionID)) return;
524
549
  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));
550
+ const ctx = sessionStartContext(directory, ${faponyTs});
551
+ if (ctx) output.system.push(ctx);
534
552
  } catch {
535
553
  // context must never break a session start
536
554
  }
@@ -553,7 +571,16 @@ function installSessionStartPlugin(
553
571
  } catch {
554
572
  current = "";
555
573
  }
556
- if (current.includes("capContext")) {
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
+ }
557
584
  console.error(` session start: already installed — no change`);
558
585
  return;
559
586
  }