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