fapony 0.1.3 → 0.2.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 (51) hide show
  1. package/README.md +111 -46
  2. package/fapony.ts +35 -2
  3. package/package.json +3 -2
  4. package/skill/move-to-done/SKILL.md +18 -28
  5. package/skill/plan-with-pony/SKILL.md +8 -3
  6. package/src/analyze.ts +175 -5
  7. package/src/conventions-seed.ts +3 -3
  8. package/src/db/defaults.ts +13 -5
  9. package/src/db/getters.ts +11 -9
  10. package/src/db/load.ts +2 -2
  11. package/src/db/store.ts +0 -75
  12. package/src/db/types.ts +2 -4
  13. package/src/debt.ts +176 -36
  14. package/src/digest/collect.ts +19 -2
  15. package/src/digest/text.ts +25 -0
  16. package/src/hook.ts +665 -24
  17. package/src/init-mem.ts +123 -37
  18. package/src/init.ts +57 -39
  19. package/src/install/claude.ts +44 -8
  20. package/src/install/codex.ts +105 -13
  21. package/src/install/opencode.ts +169 -5
  22. package/src/install.ts +2 -1
  23. package/src/lint-baseline.ts +3 -8
  24. package/src/mcp/evidence.ts +15 -3
  25. package/src/mcp/tools/index.ts +97 -185
  26. package/src/mcp/tools/mem.ts +153 -11
  27. package/src/mcp/transport.ts +5 -13
  28. package/src/mem/commands/read.ts +448 -0
  29. package/src/mem/commands/where.ts +56 -0
  30. package/{templates → src}/mem/commands/write.ts +12 -5
  31. package/src/mem/index.ts +144 -0
  32. package/src/mem/store.ts +350 -0
  33. package/src/memory.ts +238 -40
  34. package/src/plan-seed.ts +26 -6
  35. package/src/review-seed.ts +19 -0
  36. package/src/setup.ts +7 -8
  37. package/src/stats/data.ts +40 -104
  38. package/src/stats/format.ts +8 -9
  39. package/src/stats/index.ts +0 -1
  40. package/templates/PLAN.md +1 -0
  41. package/src/mcp/tools/context.ts +0 -66
  42. package/src/mcp/tools/plans.ts +0 -255
  43. package/src/mcp/tools/stats.ts +0 -96
  44. package/templates/mem/commands/read.ts +0 -194
  45. package/templates/mem/commands/selftest.ts +0 -450
  46. package/templates/mem/mem.ts +0 -68
  47. package/templates/mem/store.ts +0 -285
  48. /package/{templates → src}/mem/commands/plan.ts +0 -0
  49. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  50. /package/{templates → src}/mem/render.ts +0 -0
  51. /package/{templates → src}/mem/selectors.ts +0 -0
package/src/init-mem.ts CHANGED
@@ -1,14 +1,19 @@
1
- // src/init-mem.ts — scaffold the canonical .memory/ system into a new worktree.
2
- // Source of truth lives in fapony/templates/mem/ — named after the CLI it implements
3
- // (`mem`), not after where it lands; the destination keeps the .memory name because that
4
- // is where the log lives.
5
- // Destination is <worktree>/.fapony/.memory/ (plans/specs/memory all live under
6
- // .fapony/; run state stays in ~/.config/fapony/state.db, never in the worktree).
7
- // Re-run to re-sync after editing the template — not automatic, on purpose.
1
+ // src/init-mem.ts — cleanup old .memory/ directories and warn about stale call sites.
2
+ //
3
+ // Before PLAN-agent-one-call, this file scaffolded templates/mem/ into every repo.
4
+ // Now fapony owns the mem code (src/mem/) and calls it directly via `fapony mem`.
5
+ // This command's new job: delete legacy .memory/ dirs + warn about package.json refs.
8
6
 
9
- import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
10
- import { dirname, join } from "node:path";
11
- import { loadConfig, memoryEntry } from "./db/index.js";
7
+ import {
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ rmSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
12
17
 
13
18
  export function copyDir(src: string, dest: string): string[] {
14
19
  mkdirSync(dest, { recursive: true });
@@ -27,13 +32,11 @@ export function copyDir(src: string, dest: string): string[] {
27
32
  }
28
33
 
29
34
  export function cmdInitMem(args: string[]): void {
30
- const update = args.includes("--update");
35
+ const force = args.includes("--force");
31
36
  const worktreeKey = args.find((x) => !x.startsWith("-"));
32
37
  const config = loadConfig();
33
38
 
34
- // no key given = the repo you are standing in — so `fapony init-mem --update` runs in anyone's project
35
- // without registering the worktree first (loadConfig already reads the cwd's fapony.config.json,
36
- // so it picks up that project's paths.memoryEntry by itself)
39
+ // no key given = the repo you are standing in
37
40
  const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
38
41
  if (!worktree) {
39
42
  console.error(`unknown worktree key: ${worktreeKey}`);
@@ -41,31 +44,114 @@ export function cmdInitMem(args: string[]): void {
41
44
  process.exit(1);
42
45
  }
43
46
 
44
- const templateDir = join(import.meta.dir, "..", "templates", "mem");
45
- const memEntry = memoryEntry(config);
46
- const destDir = join(worktree, dirname(memEntry));
47
- const destFile = join(worktree, memEntry);
47
+ const gitRoot = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
48
+ cwd: worktree,
49
+ })
50
+ .stdout.toString()
51
+ .trim();
52
+ const root = gitRoot || worktree;
48
53
 
49
- if (existsSync(destFile) && !update) {
50
- console.error(
51
- `${destFile} already exists — re-running would overwrite local edits.\nRun \`fapony init-mem --update\` to refresh it from the template (log.jsonl is kept).`,
54
+ // 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);
76
+
77
+ if (memoryDirs.length === 0) {
78
+ console.log("no legacy .memory/ directories found — already clean");
79
+ } else {
80
+ let removed = 0;
81
+ let kept = 0;
82
+ for (const d of memoryDirs) {
83
+ // Any log*.jsonl is history — includes log.<person>.jsonl (the standard
84
+ // filename) and rotated log.YYYY-MM-DD.jsonl, not just log.jsonl.
85
+ let logs: string[] = [];
86
+ try {
87
+ logs = readdirSync(d).filter(
88
+ (f) => f === "log.jsonl" || /^log\..*\.jsonl$/.test(f),
89
+ );
90
+ } catch {
91
+ // unreadable dir — fall through and remove
92
+ }
93
+ if (logs.length > 0 && !force) {
94
+ console.log(
95
+ `keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
96
+ );
97
+ console.log(
98
+ ` move them under ${DEFAULT_MEM_DIR}/, or re-run with --force to delete`,
99
+ );
100
+ kept++;
101
+ continue;
102
+ }
103
+ console.log(
104
+ `deleting ${d}${logs.length > 0 ? ` (--force: ${logs.length} log file(s))` : ""}`,
105
+ );
106
+ rmSync(d, { recursive: true, force: true });
107
+ removed++;
108
+ }
109
+ console.log(
110
+ `\nremoved ${removed} legacy .memory/ director${removed === 1 ? "y" : "ies"}${
111
+ kept > 0
112
+ ? ` · kept ${kept} with logs — move them under ${DEFAULT_MEM_DIR}/, then re-run`
113
+ : ""
114
+ }`,
52
115
  );
53
- process.exit(1);
54
- }
55
- if (update && !existsSync(destFile)) {
56
- console.error(`${destFile} not found — run \`fapony init\` first.`);
57
- process.exit(1);
58
116
  }
59
117
 
60
- const files = copyDir(templateDir, destDir);
61
- if (update) {
62
- console.log(`updated ${files.length} files in ${destDir}`);
63
- console.log(`(log.jsonl and other data files left untouched)`);
64
- return;
65
- }
66
- console.log(`scaffolded ${files.length} files into ${destDir}`);
67
- console.log(`\nAdd to fapony.config.json:`);
68
- console.log(
69
- ` "memory": {\n "claim": ["bun", "${memEntry}", "claim", "{id}"],\n "close": ["bun", "${memEntry}", "close", "{id}", "{msg}"],\n "add": ["bun", "${memEntry}", "add", "{kind}", "{text}"],\n "kickoff": ["bun", "${memEntry}", "kickoff"]\n }`,
70
- );
118
+ // 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
+ 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
+ }
135
+ 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
+ }
144
+ }
145
+ } catch {
146
+ // ignore
147
+ }
148
+ };
149
+ warnAboutCallSites(root);
150
+
151
+ console.log("\nmem commands are now built into fapony:");
152
+ console.log(' fapony mem add <kind> "<text>" --files <files>');
153
+ console.log(' fapony mem close <id> "<text>"');
154
+ console.log(" fapony mem find <word>");
155
+ console.log(" fapony mem kickoff [id|spec.md]");
156
+ console.log(" fapony mem now");
71
157
  }
package/src/init.ts CHANGED
@@ -3,7 +3,13 @@
3
3
  // state.db stays in ~/.config/fapony/ by design (security boundary — see db.ts),
4
4
  // never inside the worktree where agents have full write access.
5
5
 
6
- import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import {
7
+ appendFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ } from "node:fs";
7
13
  import { dirname, join, relative } from "node:path";
8
14
  import { createInterface } from "node:readline";
9
15
  import { seedConventionsFile } from "./conventions-seed.js";
@@ -11,11 +17,11 @@ import {
11
17
  type Config,
12
18
  doneDir,
13
19
  evidenceFile,
14
- memoryEntry,
20
+ FAPONY_DIR,
21
+ memoryDir,
15
22
  planDir,
16
23
  specDir,
17
24
  } from "./db/index.js";
18
- import { copyDir } from "./init-mem.js";
19
25
  import { isAffirmative } from "./util.js";
20
26
 
21
27
  const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
@@ -37,7 +43,7 @@ const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
37
43
  # running in this worktree cannot rewrite run state / audit trail.
38
44
  #
39
45
  # Ask your agent for the plan picture instead of listing these by hand:
40
- # "run plan_list" — what is active, blocked, untouched, archived
46
+ # run "fapony mem kickoff" — priority plans, the first unchecked chunk, open bugs
41
47
  `;
42
48
 
43
49
  // Static template — deliberately NOT derived from the repo (reading package.json
@@ -59,22 +65,22 @@ const EVIDENCE_JSON = `{
59
65
  // Not in SERVER_INSTRUCTIONS either: that reaches every MCP session of every user,
60
66
  // and most of them never ran `fapony init` — it would tell them to run a command
61
67
  // that does not exist.
62
- const RULES_SNIPPET = (
63
- memEntry: string,
64
- ) => `## Memory: ${dirname(memEntry)}/log.<you>.jsonl (append-only)
68
+ const RULES_SNIPPET =
69
+ () => `## Memory: .fapony/.memory/log.<you>.jsonl (append-only)
65
70
 
66
71
  The log is this project's shared brain — it lives in git, so anyone who clones the
67
72
  repo gets every decision, bug and note with it. The filename comes from
68
- \`git config user.name\`, one file per person, so there is nothing to merge.
73
+ \`git config user.name\` — one file per person, and \`*.jsonl merge=union\` in
74
+ .gitattributes keeps both sides when two people end up sharing a name anyway.
69
75
 
70
76
  Log as you work — do not wait to be asked. Nothing writes it for you:
71
77
 
72
- bun ${memEntry} kickoff <plan.md> # start a session with this
73
- bun ${memEntry} add decision "what was locked, and why" --files src/x.ts
74
- bun ${memEntry} add bug "what is broken" --files src/x.ts
75
- bun ${memEntry} add note "state the next session needs" --files src/x.ts
76
- bun ${memEntry} close <id> "fixed in <sha>"
77
- bun ${memEntry} find "<text>"
78
+ fapony mem kickoff <plan.md> # start a session with this
79
+ fapony mem add decision "what was locked, and why" --files src/x.ts
80
+ fapony mem add bug "what is broken" --files src/x.ts
81
+ fapony mem add note "state the next session needs" --files src/x.ts
82
+ fapony mem close <id> "fixed in <sha>"
83
+ fapony mem find "<text>"
78
84
 
79
85
  Write each entry standalone — it is read months later with no chat to refer to.
80
86
  --files is required: rows that name no file cannot be recalled when that file is
@@ -90,20 +96,20 @@ Finish a chunk, before starting the next:
90
96
  1. Tick its checkbox + stamp the TL;DR in the plan file
91
97
  2. Commit — separate from other chunks
92
98
  3. \`verdict_submit\` (fapony MCP), grading what actually happened
93
- 4. \`bun ${memEntry} add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
94
- — pass the exact same plan path every time; kickoff matches it as a literal string
99
+ 4. \`fapony mem add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
100
+ — use the same plan path every time
95
101
  5. Stop. Do not continue to the next chunk in the same session unless told to.
96
102
 
97
- Next chunk, new session — open with \`bun ${memEntry} kickoff <path/to/PLAN-x.md>\` (same
98
- path) instead of carrying the old transcript forward. kickoff already filters to the
99
- rows written against that exact path.`;
103
+ Next chunk, new session — open with \`fapony mem kickoff <path/to/PLAN-x.md>\` instead
104
+ of carrying the old transcript forward. kickoff already filters to the rows for that plan,
105
+ and takes just the filename (\`kickoff PLAN-x.md\`) when you do not want to type the path.`;
100
106
 
101
107
  export function initProject(targetPath: string, config?: Config): void {
102
108
  // Create target root
103
109
  mkdirSync(targetPath, { recursive: true });
104
110
 
105
111
  // --- .fapony/ marker ---
106
- const faponyDir = join(targetPath, ".fapony");
112
+ const faponyDir = join(targetPath, FAPONY_DIR);
107
113
  if (existsSync(faponyDir)) {
108
114
  throw new Error(
109
115
  `${faponyDir} already exists — delete it first if you want a fresh scaffold.`,
@@ -112,6 +118,22 @@ export function initProject(targetPath: string, config?: Config): void {
112
118
  mkdirSync(faponyDir, { recursive: true });
113
119
  writeFileSync(join(faponyDir, "README"), FAPONY_README);
114
120
 
121
+ // --- .gitattributes: union-merge the append-only logs ---
122
+ //
123
+ // Every row is one person's append, so both sides of a "conflict" are always
124
+ // right. Without the line, two people whose git user.name collides (admin /
125
+ // user / owner — what a fresh OS install offers) resolve a conflict by hand
126
+ // on every pull. Appended, never rewritten: the file is the repo's, not ours.
127
+ const attrs = join(targetPath, ".gitattributes");
128
+ const attrsBody = existsSync(attrs) ? readFileSync(attrs, "utf-8") : "";
129
+ if (!/^\s*\*\.jsonl\s+merge=union\s*$/m.test(attrsBody)) {
130
+ appendFileSync(
131
+ attrs,
132
+ `${attrsBody && !attrsBody.endsWith("\n") ? "\n" : ""}# append-only memory logs: keep both sides, never hand-resolve\n*.jsonl merge=union\n`,
133
+ );
134
+ console.log(` + ${attrs} — *.jsonl merge=union`);
135
+ }
136
+
115
137
  // --- evidence.json (verification_report allowlist — see src/mcp/evidence.ts) ---
116
138
  const evidencePath = join(targetPath, evidenceFile(config));
117
139
  if (existsSync(evidencePath)) {
@@ -121,7 +143,7 @@ export function initProject(targetPath: string, config?: Config): void {
121
143
  writeFileSync(evidencePath, EVIDENCE_JSON);
122
144
 
123
145
  // --- plan/ spec/ .memory/ — all under .fapony/ ---
124
- const planDirAbs = join(targetPath, planDir(config));
146
+ const planDirAbs = join(targetPath, planDir());
125
147
  if (existsSync(planDirAbs)) {
126
148
  throw new Error(`${planDirAbs} already exists — not overwriting.`);
127
149
  }
@@ -135,44 +157,41 @@ export function initProject(targetPath: string, config?: Config): void {
135
157
  mkdirSync(doneDirAbs, { recursive: true });
136
158
 
137
159
  // --- spec/ ---
138
- const specDirAbs = join(targetPath, specDir(config));
160
+ const specDirAbs = join(targetPath, specDir());
139
161
  if (existsSync(specDirAbs)) {
140
162
  throw new Error(`${specDirAbs} already exists — not overwriting.`);
141
163
  }
142
164
  mkdirSync(specDirAbs, { recursive: true });
143
165
 
144
- // --- .memory/ (from template) ---
145
- const memEntry = memoryEntry(config); // e.g. .fapony/.memory/mem.ts
146
- const memoryDir = join(
147
- targetPath,
148
- memEntry.split("/").slice(0, -1).join("/"),
149
- );
150
- if (existsSync(join(targetPath, memEntry))) {
151
- throw new Error(
152
- `${join(targetPath, memEntry)} already exists — delete it first if you want a fresh copy.`,
166
+ // --- .fapony/.memory/ (empty dir — mem commands are built into fapony now) ---
167
+ const memoryDirPath = join(targetPath, memoryDir(config));
168
+ if (existsSync(memoryDirPath)) {
169
+ console.log(` ${relative(targetPath, memoryDirPath)}/ — already exists`);
170
+ } else {
171
+ mkdirSync(memoryDirPath, { recursive: true });
172
+ console.log(
173
+ ` ${relative(targetPath, memoryDirPath)}/ — mem commands are built into fapony (fapony mem ...)`,
153
174
  );
154
175
  }
155
- const templateDir = join(import.meta.dir, "..", "templates", "mem");
156
- const files = copyDir(templateDir, memoryDir);
157
176
 
158
177
  console.log(`scaffolded ${targetPath}/`);
159
178
  console.log(
160
179
  ` .fapony/ — project dir (plans, specs, memory, evidence)`,
161
180
  );
162
- console.log(` ${planDir(config)}/ — live plan files`);
181
+ console.log(` ${planDir()}/ — live plan files`);
163
182
  console.log(` ${doneDir(config)}/ — shipped plans (archive)`);
164
- console.log(` ${specDir(config)}/ — spec files`);
183
+ console.log(` ${specDir()}/ — spec files`);
165
184
  console.log(
166
185
  ` ${evidenceFile(config)} — allowlist for 'fapony report' (edit the cmds!)`,
167
186
  );
168
187
  console.log(
169
- ` ${relative(targetPath, memoryDir)}/ — ${files.length} files from template`,
188
+ ` ${relative(targetPath, memoryDirPath)}/ — mem commands are built into fapony (fapony mem ...)`,
170
189
  );
171
190
  console.log(`\nNext: add "${targetPath}" to fapony.config.json worktrees`);
172
191
  console.log(
173
192
  `\nThen paste this into your agent-rules file (CLAUDE.md / AGENTS.md / opencode.json\ninstructions) — the memory log only fills up if the rules your agent already reads\ntell it to write:\n`,
174
193
  );
175
- console.log(RULES_SNIPPET(memEntry));
194
+ console.log(RULES_SNIPPET());
176
195
  }
177
196
 
178
197
  const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
@@ -228,8 +247,7 @@ export async function cmdInit(args: string[]): Promise<void> {
228
247
  );
229
248
  if (!isAffirmative(answer)) return;
230
249
 
231
- const memEntry = memoryEntry();
232
- const snippet = `\n\n${RULES_SNIPPET(memEntry)}\n`;
250
+ const snippet = `\n\n${RULES_SNIPPET()}\n`;
233
251
  for (const f of found) {
234
252
  appendFileSync(f, snippet);
235
253
  console.log(` appended to ${relative(targetPath, f)}`);
@@ -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 hint that annotates large-file reads (annotate-only).
152
- installStopHook(dryRun, deps);
153
- installReadHintHook(dryRun, deps);
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,18 @@ 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
+ }
357
+
339
358
  function installStopHook(dryRun: boolean, deps: InstallDeps): void {
340
359
  ensureClaudeHook(dryRun, deps, {
341
360
  event: "Stop",
@@ -345,11 +364,13 @@ function installStopHook(dryRun: boolean, deps: InstallDeps): void {
345
364
  }
346
365
 
347
366
  /**
348
- * PreToolUse hook on Read: annotates a full-file read of a large source file
349
- * with one factual line (size + the review-seed command). Annotate only —
350
- * no permissionDecision is ever returned, the read always proceeds; and no
351
- * "already read" dedupe (context compaction makes that claim false). The
352
- * matcher "Read" keeps the spawn off every other tool call.
367
+ * PreToolUse hook on Read: annotates a full-file read with one factual line —
368
+ * the size + the review-seed command when the file is large, and the re-read
369
+ * line when the same path was already read this session and its mtime has not
370
+ * moved (an unchanged file, so the second read buys nothing; a changed one
371
+ * stays silent). Annotate only — no permissionDecision is ever returned, the
372
+ * read always proceeds. The matcher "Read" keeps the spawn off every other
373
+ * tool call.
353
374
  */
354
375
  function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
355
376
  ensureClaudeHook(dryRun, deps, {
@@ -359,3 +380,18 @@ function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
359
380
  label: "read hint",
360
381
  });
361
382
  }
383
+
384
+ /**
385
+ * PreToolUse hook on Edit: annotates an edit with the file's importer count
386
+ * plus the review-seed command that lists them, once per (session, file).
387
+ * Annotate only — no permissionDecision is ever returned, the edit always
388
+ * proceeds. The matcher "Edit" keeps the spawn off every other tool call.
389
+ */
390
+ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
391
+ ensureClaudeHook(dryRun, deps, {
392
+ event: "PreToolUse",
393
+ matcher: "Edit",
394
+ subcommand: "hook-edit-hint",
395
+ label: "edit hint",
396
+ });
397
+ }
@@ -1,17 +1,30 @@
1
1
  // src/install/codex.ts — Codex install provider
2
2
  //
3
- // Reads/writes ~/.codex/config.toml directly. Codex has no CLI for MCP config.
3
+ // Reads/writes ~/.codex/config.toml directly for MCP config.
4
+ // Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop).
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 { CODEX_MCP_ENTRY, defaultExit, type InstallDeps } from "./types.js";
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,86 @@ 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 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".
52
+ */
53
+ function installStopHook(dryRun: boolean, getHome: () => string): void {
54
+ const hooksPath = join(getHome(), ".codex", "hooks.json");
55
+ let config: Record<string, unknown> = {};
56
+ if (existsSync(hooksPath)) {
57
+ const parsed = readJsonObject(hooksPath);
58
+ if (!parsed) {
59
+ console.error(
60
+ ` stop hook: ${hooksPath} is unreadable or malformed — skipping`,
61
+ );
62
+ return;
63
+ }
64
+ config = parsed;
65
+ }
66
+
67
+ const hookMap = config.hooks;
68
+ if (
69
+ hookMap !== undefined &&
70
+ (typeof hookMap !== "object" || Array.isArray(hookMap))
71
+ ) {
72
+ console.error(
73
+ ` stop hook: ${hooksPath} has an unexpected "hooks" shape — skipping`,
74
+ );
75
+ return;
76
+ }
77
+
78
+ 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
+ return;
87
+ }
88
+
89
+ const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} hook-stop`;
90
+ const faponyEntry = { hooks: [{ type: "command", command }] };
91
+ const after = {
92
+ ...config,
93
+ hooks: { ...map, Stop: [...stop, faponyEntry] },
94
+ };
95
+
96
+ if (!dryRun) {
97
+ try {
98
+ writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
99
+ } catch (e) {
100
+ console.error(` stop hook: failed to write — ${(e as Error).message}`);
101
+ return;
102
+ }
103
+ }
104
+ console.error(
105
+ ` stop hook: ${dryRun ? "would write" : "wrote"} hooks.Stop → ${hooksPath}`,
106
+ );
107
+ console.error(
108
+ ` review and trust via Codex /hooks before the hook will run`,
109
+ );
110
+ }
111
+
23
112
  export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
24
113
  const exitFn = deps.exit ?? defaultExit;
25
- const configPath = findCodexConfig(deps.homedir ?? homedir);
114
+ const getHome = deps.homedir ?? homedir;
115
+ const configPath = findCodexConfig(getHome);
26
116
 
27
117
  if (!configPath) {
28
118
  console.error(
@@ -44,18 +134,20 @@ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
44
134
  if (isCodexConfigured(content)) {
45
135
  console.error(`✓ mcp_servers.fapony already configured — no change needed`);
46
136
  console.error(` (${configPath})`);
47
- return;
48
- }
49
-
50
- if (dryRun) {
137
+ } else if (dryRun) {
51
138
  console.error(`── dry-run: would append to ${configPath} ──`);
52
139
  console.log(CODEX_MCP_ENTRY);
53
- return;
140
+ } else {
141
+ const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
142
+ writeFileSync(configPath, newContent);
143
+ console.error(`✓ added mcp_servers.fapony to ${configPath}`);
144
+ console.error(` restart Codex to load the MCP server`);
54
145
  }
55
146
 
56
- // Append the fapony MCP server entry to the end of the config file
57
- const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
58
- writeFileSync(configPath, newContent);
59
- console.error(`✓ added mcp_servers.fapony to ${configPath}`);
60
- console.error(` restart Codex to load the MCP server`);
147
+ // --- Stop hook (~/.codex/hooks.json) ---
148
+ installStopHook(dryRun, getHome);
149
+
150
+ // --- Skills (~/.agents/skills/) ---
151
+ const skillsDir = agentsSkillsDir(getHome);
152
+ reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
61
153
  }