fapony 0.2.1 → 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.
Files changed (54) hide show
  1. package/README.md +95 -72
  2. package/fapony.ts +12 -5
  3. package/package.json +5 -4
  4. package/skill/define-convention/SKILL.md +77 -0
  5. package/skill/lookup-before-edit/SKILL.md +48 -0
  6. package/skill/move-to-done/SKILL.md +19 -30
  7. package/skill/review-pony/SKILL.md +38 -61
  8. package/src/analyze.ts +1 -1
  9. package/src/debt/cli.ts +193 -0
  10. package/src/debt/format.ts +107 -0
  11. package/src/debt/index.ts +19 -0
  12. package/src/debt/load.ts +92 -0
  13. package/src/debt/promotion.ts +152 -0
  14. package/src/debt/scan.ts +214 -0
  15. package/src/debt/types.ts +79 -0
  16. package/src/detect.ts +92 -0
  17. package/src/gate.ts +3 -3
  18. package/src/hook.ts +349 -100
  19. package/src/init-mem.ts +57 -71
  20. package/src/init.ts +12 -16
  21. package/src/install/antigravity.ts +112 -0
  22. package/src/install/claude.ts +16 -123
  23. package/src/install/codex.ts +58 -19
  24. package/src/install/detect.ts +17 -7
  25. package/src/install/opencode.ts +131 -6
  26. package/src/install.ts +12 -3
  27. package/src/lint-baseline.ts +2 -2
  28. package/src/mcp/primitives.ts +1 -1
  29. package/src/mcp/tools/index.ts +13 -102
  30. package/src/mcp/tools/mem.ts +71 -0
  31. package/src/mcp/transport.ts +8 -94
  32. package/src/mcp/worktree.ts +1 -1
  33. package/src/mem/commands/read.ts +231 -141
  34. package/src/mem/index.ts +4 -13
  35. package/src/memory.ts +17 -8
  36. package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
  37. package/src/seed/primitives.ts +66 -0
  38. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  39. package/src/session/helpers.ts +1 -1
  40. package/src/session/registry.ts +3 -6
  41. package/src/setup.ts +1 -1
  42. package/src/stats/data.ts +1 -1
  43. package/src/telemetry.ts +1 -1
  44. package/src/util.ts +61 -0
  45. package/templates/SPEC.md +8 -1
  46. package/images/logo.png +0 -0
  47. package/images/logo.webp +0 -0
  48. package/images/logo@400.webp +0 -0
  49. package/images/sample.webp +0 -0
  50. package/images/summary.webp +0 -0
  51. package/src/debt.ts +0 -811
  52. package/src/math.ts +0 -13
  53. package/src/mcp/tools/usage.ts +0 -211
  54. package/src/mcp/tools/verdict.ts +0 -161
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,
@@ -353,6 +232,7 @@ function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
353
232
  installStopHook(dryRun, deps);
354
233
  installReadHintHook(dryRun, deps);
355
234
  installEditHintHook(dryRun, deps);
235
+ installSessionStartHook(dryRun, deps);
356
236
  }
357
237
 
358
238
  function installStopHook(dryRun: boolean, deps: InstallDeps): void {
@@ -395,3 +275,16 @@ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
395
275
  label: "edit hint",
396
276
  });
397
277
  }
278
+
279
+ /**
280
+ * SessionStart hook: injects `fapony mem kickoff` as context when the repo has
281
+ * a mem log, and stays silent when it does not. Context only — SessionStart
282
+ * cannot block, and a repo without mem never sees a line.
283
+ */
284
+ function installSessionStartHook(dryRun: boolean, deps: InstallDeps): void {
285
+ ensureClaudeHook(dryRun, deps, {
286
+ event: "SessionStart",
287
+ subcommand: "hook-session-start",
288
+ label: "session start",
289
+ });
290
+ }
@@ -1,7 +1,7 @@
1
1
  // src/install/codex.ts — Codex install provider
2
2
  //
3
3
  // Reads/writes ~/.codex/config.toml directly for MCP config.
4
- // Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop).
4
+ // Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop, SessionStart).
5
5
  // Symlinks skills into ~/.agents/skills/ (same dir as ZCode).
6
6
 
7
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -46,18 +46,25 @@ function readJsonObject(path: string): Record<string, unknown> | null {
46
46
  }
47
47
 
48
48
  /**
49
- * Append the fapony Stop hook to ~/.codex/hooks.json.
50
- * Never replaces other hook groups or foreign entries within the Stop group.
51
- * Fapony-owned entry is identified by the command string containing "hook-stop".
49
+ * Append a fapony command hook to one event group in ~/.codex/hooks.json.
50
+ * Never replaces other hook groups or foreign entries within the group.
51
+ * A fapony-owned entry is identified by the subcommand string
52
+ * ("hook-stop" / "hook-session-start") inside its command — per-group
53
+ * idempotence, so an existing install with only Stop still picks up
54
+ * SessionStart on the next run.
52
55
  */
53
- function installStopHook(dryRun: boolean, getHome: () => string): void {
56
+ function installCodexHookGroup(
57
+ dryRun: boolean,
58
+ getHome: () => string,
59
+ opts: { event: "Stop" | "SessionStart"; subcommand: string; label: string },
60
+ ): void {
54
61
  const hooksPath = join(getHome(), ".codex", "hooks.json");
55
62
  let config: Record<string, unknown> = {};
56
63
  if (existsSync(hooksPath)) {
57
64
  const parsed = readJsonObject(hooksPath);
58
65
  if (!parsed) {
59
66
  console.error(
60
- ` stop hook: ${hooksPath} is unreadable or malformed — skipping`,
67
+ ` ${opts.label}: ${hooksPath} is unreadable or malformed — skipping`,
61
68
  );
62
69
  return;
63
70
  }
@@ -70,45 +77,76 @@ function installStopHook(dryRun: boolean, getHome: () => string): void {
70
77
  (typeof hookMap !== "object" || Array.isArray(hookMap))
71
78
  ) {
72
79
  console.error(
73
- ` stop hook: ${hooksPath} has an unexpected "hooks" shape — skipping`,
80
+ ` ${opts.label}: ${hooksPath} has an unexpected "hooks" shape — skipping`,
74
81
  );
75
82
  return;
76
83
  }
77
84
 
78
85
  const map = (hookMap ?? {}) as Record<string, unknown>;
79
- // Codex Stop array: each element is { matcher?, hooks: [...] }
80
- const stop = Array.isArray(map.Stop) ? (map.Stop as unknown[]) : [];
81
-
82
- // Check if fapony stop hook already present (by command string)
83
- const serialized = JSON.stringify(stop);
84
- if (serialized.includes("hook-stop")) {
85
- console.error(` stop hook: already configured in hooks.json — no change`);
86
+ // Codex event arrays: each element is { matcher?, hooks: [...] }
87
+ const group = Array.isArray(map[opts.event])
88
+ ? (map[opts.event] as unknown[])
89
+ : [];
90
+
91
+ // Check if the fapony hook is already present in this group (by subcommand)
92
+ const serialized = JSON.stringify(group);
93
+ if (serialized.includes(opts.subcommand)) {
94
+ console.error(
95
+ ` ${opts.label}: already configured in hooks.json — no change`,
96
+ );
86
97
  return;
87
98
  }
88
99
 
89
- const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} hook-stop`;
100
+ const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} ${opts.subcommand}`;
90
101
  const faponyEntry = { hooks: [{ type: "command", command }] };
91
102
  const after = {
92
103
  ...config,
93
- hooks: { ...map, Stop: [...stop, faponyEntry] },
104
+ hooks: { ...map, [opts.event]: [...group, faponyEntry] },
94
105
  };
95
106
 
96
107
  if (!dryRun) {
97
108
  try {
98
109
  writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
99
110
  } catch (e) {
100
- console.error(` stop hook: failed to write — ${(e as Error).message}`);
111
+ console.error(
112
+ ` ${opts.label}: failed to write — ${(e as Error).message}`,
113
+ );
101
114
  return;
102
115
  }
103
116
  }
104
117
  console.error(
105
- ` stop hook: ${dryRun ? "would write" : "wrote"} hooks.Stop → ${hooksPath}`,
118
+ ` ${opts.label}: ${dryRun ? "would write" : "wrote"} hooks.${opts.event} → ${hooksPath}`,
106
119
  );
107
120
  console.error(
108
121
  ` review and trust via Codex /hooks before the hook will run`,
109
122
  );
110
123
  }
111
124
 
125
+ function installStopHook(dryRun: boolean, getHome: () => string): void {
126
+ installCodexHookGroup(dryRun, getHome, {
127
+ event: "Stop",
128
+ subcommand: "hook-stop",
129
+ label: "stop hook",
130
+ });
131
+ }
132
+
133
+ /**
134
+ * SessionStart hook: injects `fapony mem kickoff` as context when the repo
135
+ * has a mem log, silent when it does not (cmdHookSessionStart exits quiet).
136
+ * Same `hookSpecificOutput.additionalContext` contract Claude uses — Codex
137
+ * SessionStart reads that channel too. No matcher: match-all fires on every
138
+ * source including `clear` and on versions that send no source at all, where
139
+ * a `startup|resume` matcher would silently never fire; kickoff is capped at
140
+ * 4k and costs one cheap spawn, so the /clear path stays fast.
141
+ */
142
+ function installSessionStartHook(dryRun: boolean, getHome: () => string): void {
143
+ installCodexHookGroup(dryRun, getHome, {
144
+ event: "SessionStart",
145
+ subcommand: "hook-session-start",
146
+ label: "session start",
147
+ });
148
+ }
149
+
112
150
  export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
113
151
  const exitFn = deps.exit ?? defaultExit;
114
152
  const getHome = deps.homedir ?? homedir;
@@ -144,8 +182,9 @@ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
144
182
  console.error(` restart Codex to load the MCP server`);
145
183
  }
146
184
 
147
- // --- Stop hook (~/.codex/hooks.json) ---
185
+ // --- Stop + SessionStart hooks (~/.codex/hooks.json) ---
148
186
  installStopHook(dryRun, getHome);
187
+ installSessionStartHook(dryRun, getHome);
149
188
 
150
189
  // --- Skills (~/.agents/skills/) ---
151
190
  const skillsDir = agentsSkillsDir(getHome);