fapony 0.3.0 → 0.3.4

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 (96) hide show
  1. package/README.md +87 -65
  2. package/fapony.ts +3 -116
  3. package/package.json +6 -5
  4. package/skill/move-to-done/SKILL.md +22 -32
  5. package/skill/review-pony/SKILL.md +27 -58
  6. package/src/adapters/cli.ts +123 -0
  7. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  8. package/src/adapters/hooks/context-data.ts +102 -0
  9. package/src/adapters/hooks/edit-hint.ts +195 -0
  10. package/src/adapters/hooks/git-autonomy.ts +178 -0
  11. package/src/adapters/hooks/index.ts +79 -0
  12. package/src/adapters/hooks/mv-guard.ts +52 -0
  13. package/src/adapters/hooks/read-hint.ts +383 -0
  14. package/src/adapters/hooks/session-start.ts +101 -0
  15. package/src/adapters/hooks/stop.ts +302 -0
  16. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
  18. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  20. package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
  21. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  22. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  23. package/src/{mcp → adapters/mcp}/transport.ts +4 -39
  24. package/src/adapters/mcp/types.ts +18 -0
  25. package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
  26. package/src/analyze.ts +2 -2
  27. package/src/conventions-seed.ts +1 -1
  28. package/src/core/config.ts +199 -0
  29. package/src/core/debt-format.ts +107 -0
  30. package/src/core/debt-types.ts +79 -0
  31. package/src/core/defaults.ts +8 -0
  32. package/src/core/enums.ts +34 -0
  33. package/src/core/format.ts +33 -0
  34. package/src/core/hint-log.ts +68 -0
  35. package/src/core/hook-helpers.ts +31 -0
  36. package/src/core/mem-log.ts +357 -0
  37. package/src/core/parse.ts +71 -0
  38. package/src/core/pricing.ts +217 -0
  39. package/src/core/safety.ts +18 -0
  40. package/src/core/types.ts +142 -0
  41. package/src/core/util.ts +105 -0
  42. package/src/db/store.ts +2 -2
  43. package/src/debt/cli.ts +1 -1
  44. package/src/debt/format.ts +2 -107
  45. package/src/debt/load.ts +1 -1
  46. package/src/debt/promotion.ts +1 -1
  47. package/src/debt/types.ts +14 -79
  48. package/src/digest/collect.ts +4 -3
  49. package/src/gate.ts +5 -5
  50. package/src/gates.ts +1 -1
  51. package/src/hook.ts +69 -1337
  52. package/src/init-mem.ts +58 -72
  53. package/src/init.ts +13 -17
  54. package/src/install/antigravity.ts +112 -0
  55. package/src/install/claude.ts +19 -123
  56. package/src/install/detect.ts +17 -7
  57. package/src/install/opencode.ts +167 -26
  58. package/src/install.ts +23 -7
  59. package/src/lint-baseline.ts +1 -2
  60. package/src/map.ts +29 -8
  61. package/src/mem/commands/plan.ts +70 -42
  62. package/src/mem/commands/read.ts +219 -146
  63. package/src/mem/index.ts +4 -13
  64. package/src/mem/store.ts +5 -1
  65. package/src/memory.ts +24 -387
  66. package/src/parse.ts +9 -71
  67. package/src/price/fetch.ts +4 -16
  68. package/src/price/resolve.ts +12 -213
  69. package/src/report/cli.ts +3 -3
  70. package/src/safety.ts +2 -18
  71. package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
  72. package/src/seed/primitives.ts +60 -0
  73. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  74. package/src/session/types.ts +14 -128
  75. package/src/setup.ts +1 -1
  76. package/src/stats/data.ts +4 -4
  77. package/src/telemetry.ts +3 -3
  78. package/src/usage/cache.ts +1 -2
  79. package/src/usage/cli.ts +1 -1
  80. package/src/usage/scan.ts +2 -1
  81. package/src/util.ts +10 -32
  82. package/src/web/html.ts +2 -33
  83. package/templates/SPEC.md +8 -1
  84. package/images/logo.png +0 -0
  85. package/images/logo.webp +0 -0
  86. package/images/logo@400.webp +0 -0
  87. package/images/sample.webp +0 -0
  88. package/images/summary.webp +0 -0
  89. package/src/db/defaults.ts +0 -34
  90. package/src/db/getters.ts +0 -35
  91. package/src/db/index.ts +0 -7
  92. package/src/db/load.ts +0 -57
  93. package/src/db/types.ts +0 -77
  94. package/src/math.ts +0 -13
  95. package/src/mcp/tools/verdict.ts +0 -161
  96. package/src/mcp/types.ts +0 -54
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
- 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
- }
9
+ import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./core/config.js";
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
@@ -21,7 +21,7 @@ import {
21
21
  memoryDir,
22
22
  planDir,
23
23
  specDir,
24
- } from "./db/index.js";
24
+ } from "./core/config.js";
25
25
  import { isAffirmative } from "./util.js";
26
26
 
27
27
  const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
@@ -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
+ installMvGuardHook(dryRun, deps);
356
236
  installSessionStartHook(dryRun, deps);
357
237
  }
358
238
 
@@ -397,6 +277,22 @@ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
397
277
  });
398
278
  }
399
279
 
280
+ /**
281
+ * PreToolUse hook on Bash: denies a raw `git mv` of a plan file into a done/
282
+ * directory, pointing at `fapony mem plan-sweep --apply` instead — that
283
+ * command does the link rewrite a plain `git mv` skips. The one fapony hook
284
+ * that blocks besides Stop; matcher "Bash" keeps the spawn off every other
285
+ * tool call.
286
+ */
287
+ function installMvGuardHook(dryRun: boolean, deps: InstallDeps): void {
288
+ ensureClaudeHook(dryRun, deps, {
289
+ event: "PreToolUse",
290
+ matcher: "Bash",
291
+ subcommand: "hook-mv-guard",
292
+ label: "plan-mv guard",
293
+ });
294
+ }
295
+
400
296
  /**
401
297
  * SessionStart hook: injects `fapony mem kickoff` as context when the repo has
402
298
  * a mem log, and stays silent when it does not. Context only — SessionStart
@@ -1,14 +1,16 @@
1
1
  // src/install/detect.ts — detect which MCP clients are installed on this machine.
2
2
  //
3
3
  // Signal per client:
4
- // claude = `command -v claude` (CLI on PATH)
5
- // cursor = ~/.cursor exists (the app creates it on first run)
6
- // opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
7
- // zcode = ~/.zcode/cli/config.json or ~/.agents/mcp.json exists
8
- // codex = ~/.codex/config.toml exists
4
+ // antigravity = ~/.gemini exists (the app creates it on first run)
5
+ // claude = `command -v claude` (CLI on PATH)
6
+ // cursor = ~/.cursor exists (the app creates it on first run)
7
+ // opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
8
+ // zcode = ~/.zcode/cli/config.json or ~/.agents/mcp.json exists
9
+ // codex = ~/.codex/config.toml exists
9
10
 
10
11
  import { homedir } from "node:os";
11
12
  import { defaultCheckCmd } from "../setup.js";
13
+ import { findGeminiDir } from "./antigravity.js";
12
14
  import { findCodexConfig } from "./codex.js";
13
15
  import { findCursorDir } from "./cursor.js";
14
16
  import { findOpencodeConfig } from "./opencode.js";
@@ -23,10 +25,10 @@ export interface DetectedClient {
23
25
 
24
26
  /**
25
27
  * Detect which MCP clients are available on this machine.
26
- * Returns one entry per platform, ordered: claude, cursor, opencode, zcode, codex.
28
+ * Returns one entry per platform, ordered: antigravity, claude, cursor, opencode, zcode, codex.
27
29
  *
28
30
  * Uses resolvers from each provider (file/dir-exists check) for
29
- * cursor/opencode/zcode/codex, and `command -v claude` for claude — all
31
+ * antigravity/cursor/opencode/zcode/codex, and `command -v claude` for claude — all
30
32
  * through injected deps for testability.
31
33
  */
32
34
  export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
@@ -35,12 +37,20 @@ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
35
37
 
36
38
  const claude = checkCmd("claude");
37
39
 
40
+ const geminiDir = findGeminiDir(getHome);
38
41
  const cursorDir = findCursorDir(getHome);
39
42
  const opencodePath = findOpencodeConfig(getHome);
40
43
  const zcodeResult = findZcodeConfig(getHome);
41
44
  const codexPath = findCodexConfig(getHome);
42
45
 
43
46
  return [
47
+ {
48
+ platform: "antigravity",
49
+ installed: geminiDir !== null,
50
+ why: geminiDir
51
+ ? `dir at ${geminiDir}`
52
+ : "no ~/.gemini directory (open Antigravity once)",
53
+ },
44
54
  {
45
55
  platform: "claude",
46
56
  installed: claude,