fapony 0.6.2 → 0.7.1

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 (63) hide show
  1. package/README.md +144 -156
  2. package/package.json +5 -6
  3. package/skill/define-convention/SKILL.md +3 -3
  4. package/skill/lookup-before-edit/SKILL.md +2 -1
  5. package/skill/move-to-done/SKILL.md +18 -22
  6. package/skill/plan-with-pony/SKILL.md +4 -4
  7. package/skill/review-pony/SKILL.md +10 -12
  8. package/src/adapters/cli.ts +11 -52
  9. package/src/adapters/hooks/index.ts +0 -67
  10. package/src/adapters/hooks/mv-guard.ts +2 -2
  11. package/src/analyze/discover.ts +1 -1
  12. package/src/analyze/index.ts +0 -1
  13. package/src/commands.ts +8 -39
  14. package/src/core/config.ts +0 -6
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +1 -2
  17. package/src/debt/load.ts +3 -5
  18. package/src/debt/promotion.ts +2 -2
  19. package/src/debt/scan.ts +1 -1
  20. package/src/digest/collect.ts +6 -7
  21. package/src/fael.ts +139 -0
  22. package/src/hook.ts +1 -81
  23. package/src/init.ts +30 -90
  24. package/src/install/antigravity.ts +8 -99
  25. package/src/install/claude.ts +66 -200
  26. package/src/install/codex.ts +6 -170
  27. package/src/install/detect.ts +4 -14
  28. package/src/install/opencode.ts +50 -486
  29. package/src/install/types.ts +1 -39
  30. package/src/install/zcode.ts +5 -91
  31. package/src/install.ts +9 -35
  32. package/src/memory.ts +10 -69
  33. package/src/plan/index.ts +34 -0
  34. package/src/plan/next.ts +181 -0
  35. package/src/plan/store.ts +41 -0
  36. package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
  37. package/src/seed/plan-seed.ts +10 -9
  38. package/src/setup.ts +4 -22
  39. package/src/update.ts +1 -1
  40. package/templates/PLAN.md +1 -1
  41. package/src/adapters/hooks/bug-markers.ts +0 -60
  42. package/src/adapters/hooks/context-data.ts +0 -139
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -410
  45. package/src/adapters/hooks/session-start.ts +0 -112
  46. package/src/adapters/hooks/stop.ts +0 -768
  47. package/src/adapters/mcp/tools/index.ts +0 -149
  48. package/src/adapters/mcp/tools/mem.ts +0 -252
  49. package/src/adapters/mcp/transport.ts +0 -147
  50. package/src/analyze/cache.ts +0 -162
  51. package/src/core/mem-log.ts +0 -375
  52. package/src/init-mem.ts +0 -143
  53. package/src/install/cursor.ts +0 -167
  54. package/src/install/utils.ts +0 -29
  55. package/src/mem/commands/read.ts +0 -738
  56. package/src/mem/commands/rotate.ts +0 -59
  57. package/src/mem/commands/where.ts +0 -56
  58. package/src/mem/commands/write.ts +0 -273
  59. package/src/mem/engine.ts +0 -315
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/render.ts +0 -66
  62. package/src/mem/selectors.ts +0 -160
  63. package/src/mem/store.ts +0 -356
@@ -1,25 +1,21 @@
1
1
  // src/install/opencode.ts — OpenCode install provider
2
2
  //
3
- // Adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
3
+ // Writes fapony's generated plugin (opt-in git-autonomy) into
4
+ // ~/.config/opencode/plugins and symlinks skills. Memory (MCP, commit hint,
5
+ // session start, per-file read context) moved to fael — `fael install`.
4
6
 
5
7
  import {
6
8
  existsSync,
7
9
  mkdirSync,
8
10
  readdirSync,
9
11
  readFileSync,
12
+ rmSync,
10
13
  writeFileSync,
11
14
  } from "node:fs";
12
15
  import { homedir } from "node:os";
13
16
  import { join } from "node:path";
14
17
  import { claudeSkillsDir, linkSkills, reportSkills } from "./skills.js";
15
- import {
16
- defaultExit,
17
- INSTALL_ROOT,
18
- type InstallDeps,
19
- MCP_CONFIG,
20
- MCP_KEY,
21
- } from "./types.js";
22
- import { computeDiff } from "./utils.js";
18
+ import { INSTALL_ROOT, type InstallDeps } from "./types.js";
23
19
 
24
20
  export function findOpencodeConfig(getHome: () => string): string | null {
25
21
  const dir = join(getHome(), ".config", "opencode");
@@ -30,63 +26,6 @@ export function findOpencodeConfig(getHome: () => string): string | null {
30
26
  return null;
31
27
  }
32
28
 
33
- function stripJsonc(input: string): string {
34
- let out = "";
35
- let i = 0;
36
- while (i < input.length) {
37
- const c = input[i];
38
- if (c === "/" && input[i + 1] === "/") {
39
- while (i < input.length && input[i] !== "\n") i++;
40
- continue;
41
- }
42
- if (c === "/" && input[i + 1] === "*") {
43
- i += 2;
44
- while (i < input.length && !(input[i] === "*" && input[i + 1] === "/"))
45
- i++;
46
- i += 2;
47
- continue;
48
- }
49
- if (c === '"' || c === "'") {
50
- const quote = c;
51
- out += c;
52
- i++;
53
- while (i < input.length && input[i] !== quote) {
54
- if (input[i] === "\\") {
55
- out += input[i];
56
- i++;
57
- }
58
- if (i < input.length) {
59
- out += input[i];
60
- i++;
61
- }
62
- }
63
- if (i < input.length) {
64
- out += input[i];
65
- i++;
66
- }
67
- continue;
68
- }
69
- out += c;
70
- i++;
71
- }
72
- return out;
73
- }
74
-
75
- function parseJsonc(text: string): Record<string, unknown> {
76
- return JSON.parse(stripJsonc(text)) as Record<string, unknown>;
77
- }
78
-
79
- function isConfigured(mcp: Record<string, unknown> | undefined): boolean {
80
- if (!mcp) return false;
81
- const entry = mcp[MCP_KEY];
82
- if (!entry || typeof entry !== "object") return false;
83
- const cfg = entry as Record<string, unknown>;
84
- return (
85
- cfg.type === MCP_CONFIG.type &&
86
- JSON.stringify(cfg.command) === JSON.stringify(MCP_CONFIG.command)
87
- );
88
- }
89
-
90
29
  export interface OpencodeInstallOpts {
91
30
  /** Opt-in only: write the git-autonomy rewrite plugin. Never default. */
92
31
  gitAutonomy?: boolean;
@@ -102,334 +41,62 @@ export function cmdInstallOpencode(
102
41
  deps: InstallDeps = {},
103
42
  opts: OpencodeInstallOpts = {},
104
43
  ): void {
105
- const exitFn = deps.exit ?? defaultExit;
106
44
  const getHome = deps.homedir ?? homedir;
107
45
 
108
- const installPlugins = (): void => {
109
- installReadHintPlugin(dryRun, getHome);
110
- installCommitHintPlugin(dryRun, getHome);
111
- installEditHintPlugin(dryRun, getHome);
112
- installSessionStartPlugin(dryRun, getHome);
113
- if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
114
- };
115
-
116
- // Refresh path: plugin bodies only — returns before findOpencodeConfig, so
117
- // neither opencode.json nor the skills symlink is read or written.
118
- if (opts.pluginsOnly) {
119
- installPlugins();
120
- return;
121
- }
122
-
123
- const configPath = findOpencodeConfig(getHome);
124
- let before: Record<string, unknown>;
125
- let isNew = false;
126
-
127
- if (configPath) {
128
- try {
129
- before = parseJsonc(readFileSync(configPath, "utf-8"));
130
- } catch (e) {
131
- console.error(`failed to parse ${configPath}: ${(e as Error).message}`);
132
- exitFn(1);
133
- return;
134
- }
135
- } else {
136
- isNew = true;
137
- before = {};
138
- }
139
-
140
- const mcp = (before.mcp as Record<string, unknown>) ?? {};
141
- if (isConfigured(mcp)) {
142
- console.error(`✓ mcp.${MCP_KEY} already configured — no change needed`);
143
- if (configPath) console.error(` (${configPath})`);
46
+ if (!opts.pluginsOnly) {
144
47
  const skillsDir = claudeSkillsDir(getHome);
145
48
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
146
- installPlugins();
147
- return;
148
49
  }
50
+ for (const [fileName, ownedName] of RETIRED_PLUGINS) {
51
+ removeRetiredPlugin(dryRun, getHome, fileName, ownedName);
52
+ }
53
+ if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
54
+ }
149
55
 
150
- const after = {
151
- ...before,
152
- mcp: {
153
- ...mcp,
154
- [MCP_KEY]: MCP_CONFIG,
155
- },
156
- };
56
+ /** Plugins fapony used to write — memory hints (now fael's) and the edit hint
57
+ * (cut 2026-09-26). Their bodies import exports src/hook.ts no longer has, so
58
+ * a leftover copy would fail to load inside OpenCode: remove ours (by owned
59
+ * name), never a foreign file. */
60
+ const RETIRED_PLUGINS: [string, string][] = [
61
+ ["fapony-read-hint.ts", "FaponyReadHint"],
62
+ ["fapony-commit-hint.ts", "FaponyCommitHint"],
63
+ ["fapony-session-start.ts", "FaponySessionStart"],
64
+ ["fapony-edit-hint.ts", "FaponyEditHint"],
65
+ ];
157
66
 
158
- if (dryRun) {
159
- console.error(`── dry-run: would write mcp.${MCP_KEY} ──`);
160
- if (isNew) {
67
+ function removeRetiredPlugin(
68
+ dryRun: boolean,
69
+ getHome: () => string,
70
+ fileName: string,
71
+ ownedName: string,
72
+ ): void {
73
+ const pluginPath = join(
74
+ getHome(),
75
+ ".config",
76
+ "opencode",
77
+ "plugins",
78
+ fileName,
79
+ );
80
+ let current: string;
81
+ try {
82
+ current = readFileSync(pluginPath, "utf-8");
83
+ } catch {
84
+ return;
85
+ }
86
+ if (!current.includes(ownedName)) return;
87
+ if (!dryRun) {
88
+ try {
89
+ rmSync(pluginPath);
90
+ } catch (e) {
161
91
  console.error(
162
- ` (new file: ${join(getHome(), ".config", "opencode", "opencode.json")})`,
92
+ ` ${fileName}: failed to remove — ${(e as Error).message}`,
163
93
  );
164
- } else {
165
- console.error(` (${configPath})`);
94
+ return;
166
95
  }
167
- console.log(computeDiff(before, after));
168
- installPlugins();
169
- return;
170
- }
171
-
172
- const targetPath =
173
- configPath ?? join(getHome(), ".config", "opencode", "opencode.json");
174
- const dir = targetPath.split("/").slice(0, -1).join("/");
175
- if (!existsSync(dir)) {
176
- mkdirSync(dir, { recursive: true });
177
96
  }
178
-
179
- writeFileSync(targetPath, `${JSON.stringify(after, null, 2)}\n`);
180
- if (isNew) {
181
- console.error(`✓ created ${targetPath} with mcp.${MCP_KEY}`);
182
- } else {
183
- console.error(`✓ added mcp.${MCP_KEY} to ${configPath}`);
184
- }
185
- console.error(` restart opencode to load the MCP server`);
186
- const skillsDir = claudeSkillsDir(getHome);
187
- reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
188
- installPlugins();
189
- }
190
-
191
- /**
192
- * OpenCode plugin — the read hint's in-process shape. `tool.execute.after`
193
- * receives `input.args` (the read call's own args) and a mutable
194
- * `output.output` string, so one hook computes and appends the hint with no
195
- * process spawn per Read. Annotate only: the hook mutates output, never
196
- * throws, never dedupes ("you already read this" goes false after context
197
- * compaction — a hook that guesses wrong must never trap the agent).
198
- *
199
- * Logic lives in src/hook.ts (readHintFor + readContextLines) — the plugin
200
- * imports it from the install root (path baked at install time), same
201
- * one-copy-per-client shape as the skills symlinks: a git pull in
202
- * INSTALL_ROOT updates every client. Mirrors Claude's cmdHookReadHint
203
- * (PreToolUse), which appends both the size hint and the debt/mem context —
204
- * OpenCode was missing the second half until now (only the size hint was
205
- * wired), so a convention debt on the file you just opened, or a mem-log
206
- * row about it, never reached OpenCode even though the detector already
207
- * runs on every read. Best-effort, same policy as the claude hooks: an
208
- * existing file that isn't fapony's is never overwritten, and a failure
209
- * never fails the install.
210
- */
211
- export function readHintPluginSource(root: string): string {
212
- const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
213
- return `// fapony read hint — annotates full-file reads of large source files with a
214
- // factual review-seed pointer, plus any convention debt or mem-log row about
215
- // the file. Annotate only: never blocks, never dedupes.
216
- // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
217
- import { readHintFor, rereadHintFor, readContextData, recordHintFire } from ${hookModule};
218
-
219
- export const FaponyReadHint = async ({ directory }) => {
220
- return {
221
- "tool.execute.after": async (input, output) => {
222
- try {
223
- if (input.tool !== "read") return;
224
- const filePath = input.args?.filePath;
225
- const parts = [];
226
- const hint = readHintFor({
227
- filePath,
228
- offset: input.args?.offset,
229
- limit: input.args?.limit,
230
- cwd: directory,
231
- });
232
- if (hint) parts.push(hint);
233
- const reread = rereadHintFor({
234
- filePath,
235
- offset: input.args?.offset,
236
- limit: input.args?.limit,
237
- cwd: directory,
238
- session: input.sessionID,
239
- });
240
- if (reread) parts.push(reread);
241
- const ctx = readContextData(filePath, directory);
242
- if (ctx) {
243
- for (const line of [...ctx.debtLines, ...ctx.memLines]) {
244
- parts.push(line);
245
- }
246
- }
247
- if (parts.length > 0 && typeof output.output === "string") {
248
- output.output = output.output + "\\n" + parts.join("\\n");
249
- }
250
-
251
- // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
252
- // After output — best-effort, never block the hint.
253
- if (ctx?.worktree) {
254
- const rel = typeof filePath === "string" ? (() => {
255
- try {
256
- const { realpathSync } = require("node:fs");
257
- const { relative } = require("node:path");
258
- const { join: pjoin } = require("node:path");
259
- const wt = ctx.worktree;
260
- const abs = realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
261
- const r = relative(wt, abs).split("\\\\").join("/");
262
- return r.startsWith("..") ? null : r;
263
- } catch { return null; }
264
- })() : null;
265
- if (hint) {
266
- recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "read", file: rel, count: 1 });
267
- }
268
- if (ctx.debtIds.length > 0) {
269
- recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
270
- }
271
- if (ctx.memLines.length > 0) {
272
- recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
273
- }
274
- if (ctx.openBugIds.length > 0) {
275
- recordHintFire({ ts: new Date().toISOString(), worktree: ctx.worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
276
- }
277
- }
278
- } catch {
279
- // a hint must never break a read
280
- }
281
- },
282
- };
283
- };
284
- `;
285
- }
286
-
287
- /**
288
- * OpenCode plugin — the commit hint's in-process shape. `tool.execute.after`
289
- * on the bash tool: when the command contains `git commit` and the worktree
290
- * has ungraded commits, appends a nudge to the command output. Annotate only
291
- * (no decision:block — OpenCode has no stop-hook equivalent).
292
- *
293
- * Logic lives in src/hook.ts (commitHintFor) — same one-copy-per-client shape
294
- * as the read hint: a git pull in INSTALL_ROOT updates every client.
295
- */
296
- export function commitHintPluginSource(root: string): string {
297
- const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
298
- return `// fapony commit hint — annotates git commit bash commands with a
299
- // verdict reminder when commits have no verdict filed yet.
300
- // Annotate only: never blocks, never dedupes.
301
- // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
302
- import { commitHintFor, recordHintFire } from ${hookModule};
303
-
304
- export const FaponyCommitHint = async ({ directory }) => {
305
- return {
306
- "tool.execute.after": async (input, output) => {
307
- try {
308
- if (input.tool !== "bash") return;
309
- const command = input.args?.command;
310
- const hint = commitHintFor({
311
- command,
312
- cwd: directory,
313
- });
314
- if (hint && typeof output.output === "string") {
315
- output.output = output.output + "\\n" + hint;
316
- }
317
-
318
- // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
319
- // After output — best-effort, never block the hint.
320
- if (hint) {
321
- try {
322
- const { spawnSync } = require("node:child_process");
323
- const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
324
- if (git.status === 0) {
325
- const worktree = require("node:fs").realpathSync(git.stdout.trim());
326
- // Count commits from the hint message (lines starting with "fapony: ").
327
- const commitLines = hint.split("\\n").filter(l => /^fapony:\\s+\\w{7,}/.test(l));
328
- recordHintFire({ ts: new Date().toISOString(), worktree, surface: "commit", file: null, count: commitLines.length || 1 });
329
- }
330
- } catch {
331
- // best-effort
332
- }
333
- }
334
- } catch {
335
- // a hint must never break a bash command
336
- }
337
- },
338
- };
339
- };
340
- `;
341
- }
342
-
343
- /**
344
- * OpenCode plugin — the edit hint's in-process shape. `tool.execute.after`
345
- * on the edit and write tools: when the touched file has importers, appends
346
- * the importer count plus the review-seed command that lists them. Annotate
347
- * only (no block, no dedupe beyond what editHintFor does itself).
348
- *
349
- * This is the OpenCode parity of Claude's Edit PreToolUse hook
350
- * (PLAN-edit-importer-hint): same shared logic (editHintFor in src/hook.ts),
351
- * same once-per-(session, file) dedupe, same `"edit"` fire surface. One
352
- * known difference: OpenCode's only annotate channel is `after`, so the hint
353
- * lands one step after the edit instead of before it — the `before` hook can
354
- * only mutate args or throw, and a hint must never block a tool call.
355
- *
356
- * `write` is guarded alongside `edit` for free: a write to a new file
357
- * resolves to null inside editHintFor (nothing imports it yet), so only a
358
- * write that overwrites an imported file fires. `apply_patch` is out of
359
- * scope — its paths live inside patchText, not filePath (separate plan).
360
- */
361
- export function editHintPluginSource(root: string): string {
362
- const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
363
- return `// fapony edit hint — annotates edits to source files that have importers
364
- // with the importer count, plus mem/debt context rows about the file.
365
- // Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
366
- // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
367
- import { editHintFor, readContextData, recordHintFire } from ${hookModule};
368
-
369
- export const FaponyEditHint = async ({ directory }) => {
370
- return {
371
- "tool.execute.after": async (input, output) => {
372
- try {
373
- if (input.tool !== "edit" && input.tool !== "write") return;
374
- // The only channel that reaches the agent is output.output; if it is
375
- // not a string the hint cannot surface, so bail before editHintFor
376
- // spends the per-session dedupe row or logs a fire that never showed.
377
- if (typeof output.output !== "string") return;
378
- const filePath = input.args?.filePath;
379
- const parts = [];
380
- const hint = editHintFor({
381
- filePath,
382
- cwd: directory,
383
- session: input.sessionID,
384
- });
385
- if (hint) parts.push(hint);
386
- // Attach mem/debt context (same as read hint — annotate only, cap 5 lines)
387
- const ctx = readContextData(filePath, directory);
388
- if (ctx) {
389
- for (const line of [...ctx.debtLines, ...ctx.memLines]) {
390
- parts.push(line);
391
- }
392
- }
393
- if (parts.length > 0) {
394
- output.output = output.output + "\\n" + parts.join("\\n");
395
-
396
- // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
397
- // After output — best-effort, never block the hint.
398
- try {
399
- const { spawnSync } = require("node:child_process");
400
- const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
401
- if (git.status === 0) {
402
- const worktree = require("node:fs").realpathSync(git.stdout.trim());
403
- const rel = typeof filePath === "string" ? (() => {
404
- try {
405
- const { relative, join: pjoin } = require("node:path");
406
- const abs = require("node:fs").realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
407
- const r = relative(worktree, abs).split("\\\\").join("/");
408
- return r.startsWith("..") ? null : r;
409
- } catch { return null; }
410
- })() : null;
411
- recordHintFire({ ts: new Date().toISOString(), worktree, surface: "edit", file: rel, count: 1 });
412
- if (ctx?.debtIds?.length) {
413
- recordHintFire({ ts: new Date().toISOString(), worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
414
- }
415
- if (ctx?.memLines?.length) {
416
- recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length, ids: ctx.memIds });
417
- }
418
- if (ctx?.openBugIds?.length) {
419
- recordHintFire({ ts: new Date().toISOString(), worktree, surface: "open-bug", file: rel, count: ctx.openBugIds.length, ids: ctx.openBugIds });
420
- }
421
- }
422
- } catch {
423
- // best-effort
424
- }
425
- }
426
- } catch {
427
- // a hint must never break an edit
428
- }
429
- },
430
- };
431
- };
432
- `;
97
+ console.error(
98
+ ` ${fileName}: ${dryRun ? "would remove" : "removed"} (retired plugin)`,
99
+ );
433
100
  }
434
101
 
435
102
  /**
@@ -498,109 +165,6 @@ function installPluginFile(
498
165
  }
499
166
  }
500
167
 
501
- function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
502
- installPluginFile(
503
- dryRun,
504
- getHome,
505
- "fapony-read-hint.ts",
506
- "read hint",
507
- "FaponyReadHint",
508
- readHintPluginSource(INSTALL_ROOT),
509
- );
510
- }
511
-
512
- function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
513
- installPluginFile(
514
- dryRun,
515
- getHome,
516
- "fapony-commit-hint.ts",
517
- "commit hint",
518
- "FaponyCommitHint",
519
- commitHintPluginSource(INSTALL_ROOT),
520
- );
521
- }
522
-
523
- function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
524
- installPluginFile(
525
- dryRun,
526
- getHome,
527
- "fapony-edit-hint.ts",
528
- "edit hint",
529
- "FaponyEditHint",
530
- editHintPluginSource(INSTALL_ROOT),
531
- );
532
- }
533
-
534
- /**
535
- * OpenCode plugin — session-start context, the parity of Claude's SessionStart
536
- * hook.
537
- *
538
- * Why `experimental.chat.system.transform` and not the `event` hook on
539
- * `session.created`: the event hook is observer-only (returns void, no
540
- * injection channel), while system.transform's `output.system` is the
541
- * documented injection point (`packages/plugin/src/index.ts`: input
542
- * `{ sessionID?, model }`, output `{ system: string[] }`). The context lands
543
- * on the first model dispatch instead of at session creation — one step late,
544
- * the same shape as every other OpenCode hint (annotate-after), and arguably
545
- * better: sessions that never dispatch never pay the spawn.
546
- *
547
- * The guard (no mem log = silence), the kickoff spawn and the cap all live in
548
- * `sessionStartContext` (src/hook.ts), imported at runtime like the read /
549
- * commit / edit hint plugins — so `git pull` updates this client too. It used
550
- * to bake `mem kickoff` into the generated body and push stdout, which injected
551
- * kickoff's "# <path> — 0 entries" header into every session of every repo
552
- * with no mem log (bug mub2ezhi) and could not be fixed by a pull. One import,
553
- * one implementation, shared with the Claude/Codex hooks.
554
- *
555
- * Once per session (closure Set — the factory runs once per server), silent
556
- * when `sessionStartContext` returns null (no mem log in scope), never throws.
557
- * `process.execPath` is the bun running opencode itself — no PATH dependency.
558
- * Verified against OpenCode 1.18.29; `experimental.chat.system.transform` is an
559
- * experimental hook, so a rename makes this plugin quiet with no error — check
560
- * on OpenCode upgrades.
561
- */
562
- export function sessionStartPluginSource(root: string): string {
563
- const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
564
- const faponyTs = JSON.stringify(join(root, "fapony.ts"));
565
- return `// fapony session start — injects the \`fapony mem kickoff\` digest as context
566
- // once per session via sessionStartContext, silent when the repo has no mem log.
567
- // Annotate only: never blocks.
568
- // Generated by \`fapony install\` — logic lives in src/hook.ts (sessionStartContext).
569
- import { sessionStartContext } from ${hookModule};
570
-
571
- export const FaponySessionStart = async ({ directory }) => {
572
- const seen = new Set();
573
- return {
574
- "experimental.chat.system.transform": async (input, output) => {
575
- try {
576
- const sessionID = input?.sessionID;
577
- if (!sessionID || seen.has(sessionID)) return;
578
- seen.add(sessionID);
579
- const ctx = sessionStartContext(directory, ${faponyTs});
580
- if (ctx) output.system.push(ctx);
581
- } catch {
582
- // context must never break a session start
583
- }
584
- },
585
- };
586
- };
587
- `;
588
- }
589
-
590
- function installSessionStartPlugin(
591
- dryRun: boolean,
592
- getHome: () => string,
593
- ): void {
594
- installPluginFile(
595
- dryRun,
596
- getHome,
597
- "fapony-session-start.ts",
598
- "session start",
599
- "FaponySessionStart",
600
- sessionStartPluginSource(INSTALL_ROOT),
601
- );
602
- }
603
-
604
168
  /**
605
169
  * OpenCode plugin — git-autonomy rewrites, the parity of the personal
606
170
  * `git-autonomy.ts` plugin. Two hooks because the policy lives on two
@@ -3,49 +3,11 @@
3
3
  import { join } from "node:path";
4
4
 
5
5
  /** Repo root (parent of src/) — absolute path to this fapony checkout,
6
- * so `claude mcp add` works even before `bun link`. Same pattern as src/update.ts.
6
+ * so hook commands work even before `bun link`. Same pattern as src/update.ts.
7
7
  * Note: this file lives in src/install/, so ../.. reaches the repo root. */
8
8
  export const INSTALL_ROOT = join(import.meta.dir, "..", "..");
9
9
 
10
- export const MCP_KEY = "fapony";
11
-
12
- export const MCP_CONFIG = {
13
- type: "local",
14
- command: ["bun", "run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
15
- };
16
-
17
- /** ZCode uses the stdio MCP shape: command is a string, args is an array.
18
- * See ~/.zcode/cli/plugins/cache/zcode-plugins-official/zcode-guide/...
19
- * (do not use OpenCode-style `command: [...]` in ZCode's JSON editor). */
20
- export const ZCODE_MCP_CONFIG = {
21
- type: "stdio",
22
- command: "bun",
23
- args: ["run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
24
- };
25
-
26
- export const CODEX_MCP_ENTRY = `[mcp_servers.fapony]
27
- command = "bun"
28
- args = ["run", "${INSTALL_ROOT}/fapony.ts", "mcp"]
29
- type = "stdio"
30
- `;
31
-
32
- /** Cursor's stdio MCP shape (mcpServers.fapony in ~/.cursor/mcp.json):
33
- * command is a string, args is an array — same as ZCode, not OpenCode's
34
- * `command: [...]`. */
35
- export const CURSOR_MCP_ENTRY = {
36
- command: "bun",
37
- args: ["run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
38
- };
39
-
40
- export interface ClaudeRunResult {
41
- exitCode: number;
42
- stdout: string;
43
- stderr: string;
44
- }
45
-
46
10
  export interface InstallDeps {
47
- /** Run argv synchronously. Defaults to Bun.spawnSync. Injected in tests. */
48
- run?: (argv: string[]) => ClaudeRunResult;
49
11
  exit?: (code: number) => never;
50
12
  /** Override os.homedir() for tests. */
51
13
  homedir?: () => string;