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.
- package/README.md +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -6
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +1 -2
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -139
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -410
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -252
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -375
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -738
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -273
- package/src/mem/engine.ts +0 -315
- package/src/mem/index.ts +0 -201
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -160
- package/src/mem/store.ts +0 -356
package/src/install/opencode.ts
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
// src/install/opencode.ts — OpenCode install provider
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
`
|
|
92
|
+
` ${fileName}: failed to remove — ${(e as Error).message}`,
|
|
163
93
|
);
|
|
164
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
package/src/install/types.ts
CHANGED
|
@@ -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
|
|
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;
|