fapony 0.2.0 → 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.
@@ -113,6 +113,7 @@ 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);
116
117
  return;
117
118
  }
118
119
 
@@ -136,6 +137,7 @@ export function cmdInstallOpencode(
136
137
  console.log(computeDiff(before, after));
137
138
  installReadHintPlugin(dryRun, getHome);
138
139
  installCommitHintPlugin(dryRun, getHome);
140
+ installEditHintPlugin(dryRun, getHome);
139
141
  return;
140
142
  }
141
143
 
@@ -157,6 +159,7 @@ export function cmdInstallOpencode(
157
159
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
158
160
  installReadHintPlugin(dryRun, getHome);
159
161
  installCommitHintPlugin(dryRun, getHome);
162
+ installEditHintPlugin(dryRun, getHome);
160
163
  }
161
164
 
162
165
  /**
@@ -228,7 +231,7 @@ export const FaponyReadHint = async ({ directory }) => {
228
231
  const { relative } = require("node:path");
229
232
  const { join: pjoin } = require("node:path");
230
233
  const wt = ctx.worktree;
231
- const abs = realpathSync(filePath.startsWith("/") ? filePath : pjoin(wt, filePath));
234
+ const abs = realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
232
235
  const r = relative(wt, abs).split("\\\\").join("/");
233
236
  return r.startsWith("..") ? null : r;
234
237
  } catch { return null; }
@@ -308,6 +311,80 @@ export const FaponyCommitHint = async ({ directory }) => {
308
311
  `;
309
312
  }
310
313
 
314
+ /**
315
+ * OpenCode plugin — the edit hint's in-process shape. `tool.execute.after`
316
+ * on the edit and write tools: when the touched file has importers, appends
317
+ * the importer count plus the review-seed command that lists them. Annotate
318
+ * only (no block, no dedupe beyond what editHintFor does itself).
319
+ *
320
+ * This is the OpenCode parity of Claude's Edit PreToolUse hook
321
+ * (PLAN-edit-importer-hint): same shared logic (editHintFor in src/hook.ts),
322
+ * same once-per-(session, file) dedupe, same `"edit"` fire surface. One
323
+ * known difference: OpenCode's only annotate channel is `after`, so the hint
324
+ * lands one step after the edit instead of before it — the `before` hook can
325
+ * only mutate args or throw, and a hint must never block a tool call.
326
+ *
327
+ * `write` is guarded alongside `edit` for free: a write to a new file
328
+ * resolves to null inside editHintFor (nothing imports it yet), so only a
329
+ * write that overwrites an imported file fires. `apply_patch` is out of
330
+ * scope — its paths live inside patchText, not filePath (separate plan).
331
+ */
332
+ export function editHintPluginSource(root: string): string {
333
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
334
+ return `// fapony edit hint — annotates edits to source files that have importers
335
+ // with the importer count plus the review-seed command that lists them.
336
+ // Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
337
+ // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
338
+ import { editHintFor, recordHintFire } from ${hookModule};
339
+
340
+ export const FaponyEditHint = async ({ directory }) => {
341
+ return {
342
+ "tool.execute.after": async (input, output) => {
343
+ try {
344
+ if (input.tool !== "edit" && input.tool !== "write") return;
345
+ // The only channel that reaches the agent is output.output; if it is
346
+ // not a string the hint cannot surface, so bail before editHintFor
347
+ // spends the per-session dedupe row or logs a fire that never showed.
348
+ if (typeof output.output !== "string") return;
349
+ const filePath = input.args?.filePath;
350
+ const hint = editHintFor({
351
+ filePath,
352
+ cwd: directory,
353
+ session: input.sessionID,
354
+ });
355
+
356
+ // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
357
+ // After output — best-effort, never block the hint.
358
+ if (hint) {
359
+ output.output = output.output + "\\n" + hint;
360
+ try {
361
+ const { spawnSync } = require("node:child_process");
362
+ const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
363
+ if (git.status === 0) {
364
+ const worktree = require("node:fs").realpathSync(git.stdout.trim());
365
+ const rel = typeof filePath === "string" ? (() => {
366
+ try {
367
+ const { relative, join: pjoin } = require("node:path");
368
+ const abs = require("node:fs").realpathSync(filePath.startsWith("/") ? filePath : pjoin(directory, filePath));
369
+ const r = relative(worktree, abs).split("\\\\").join("/");
370
+ return r.startsWith("..") ? null : r;
371
+ } catch { return null; }
372
+ })() : null;
373
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "edit", file: rel, count: 1 });
374
+ }
375
+ } catch {
376
+ // best-effort
377
+ }
378
+ }
379
+ } catch {
380
+ // a hint must never break an edit
381
+ }
382
+ },
383
+ };
384
+ };
385
+ `;
386
+ }
387
+
311
388
  function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
312
389
  const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
313
390
  const pluginPath = join(pluginsDir, "fapony-read-hint.ts");
@@ -373,3 +450,36 @@ function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
373
450
  ` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
374
451
  );
375
452
  }
453
+
454
+ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
455
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
456
+ const pluginPath = join(pluginsDir, "fapony-edit-hint.ts");
457
+ if (existsSync(pluginPath)) {
458
+ let current = "";
459
+ try {
460
+ current = readFileSync(pluginPath, "utf-8");
461
+ } catch {
462
+ current = "";
463
+ }
464
+ if (current.includes("editHintFor")) {
465
+ console.error(` edit hint: already installed — no change`);
466
+ return;
467
+ }
468
+ console.error(
469
+ ` edit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
470
+ );
471
+ return;
472
+ }
473
+ if (!dryRun) {
474
+ try {
475
+ mkdirSync(pluginsDir, { recursive: true });
476
+ writeFileSync(pluginPath, editHintPluginSource(INSTALL_ROOT), "utf-8");
477
+ } catch (e) {
478
+ console.error(` edit hint: failed to write — ${(e as Error).message}`);
479
+ return;
480
+ }
481
+ }
482
+ console.error(
483
+ ` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
484
+ );
485
+ }
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 {
@@ -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
 
@@ -57,7 +58,7 @@ function baselinePath(worktree: string): string {
57
58
  }
58
59
 
59
60
  function readEvidenceLintCmd(worktree: string): string | null {
60
- const evidence = join(worktree, ".fapony", "evidence.json");
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 }[];
@@ -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 { type Config, evidenceFile, safetyDeny } from "../db/index.js";
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(worktree, group, app, ".fapony", "evidence.json");
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
  }
@@ -110,7 +110,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
110
110
 
111
111
  const MCP_PROTOCOL_VERSION = "2025-03-26";
112
112
  const SERVER_NAME = "fapony-handcheck";
113
- const SERVER_VERSION = "0.2.0";
113
+ const SERVER_VERSION = "0.2.1";
114
114
 
115
115
  // --- JSON-RPC dispatch ---
116
116
 
package/src/mem/store.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "node:fs";
16
16
  import { hostname, networkInterfaces } from "node:os";
17
17
  import { basename, dirname, join, relative } from "node:path";
18
+ import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "../db/index.js";
18
19
  import { whereMemDir } from "../memory.js";
19
20
 
20
21
  // --- types ---
@@ -174,7 +175,7 @@ export function initStore(worktree: string, overrideMemDir?: string): void {
174
175
  `two or more .fapony/.memory dirs under ${root} — refusing to guess:\n` +
175
176
  `${list}\ncd into one, set paths.memDir, or pass --mem-dir`;
176
177
  }
177
- dir = resolved.dir ?? join(root, ".fapony", ".memory");
178
+ dir = resolved.dir ?? join(root, DEFAULT_MEM_DIR);
178
179
 
179
180
  // planBase: the .fapony dir — plan/done/conventions live here.
180
181
  // Derive from dir by going up from .fapony/.memory → .fapony
@@ -184,10 +185,11 @@ export function initStore(worktree: string, overrideMemDir?: string): void {
184
185
  app = dirname(planBase);
185
186
 
186
187
  // Read fapony.config.json
187
- const configDir = planBase === `${root}/.fapony` ? root : dirname(planBase);
188
+ const configDir =
189
+ planBase === `${root}/${FAPONY_DIR}` ? root : dirname(planBase);
188
190
  const configPaths = ((): Record<string, string> => {
189
191
  try {
190
- const raw = readFileSync(`${configDir}/fapony.config.json`, "utf8");
192
+ const raw = readFileSync(`${configDir}/${CONFIG_FILENAME}`, "utf8");
191
193
  return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
192
194
  } catch {
193
195
  return {};
@@ -198,7 +200,7 @@ export function initStore(worktree: string, overrideMemDir?: string): void {
198
200
  ? join(configDir, configPaths[key])
199
201
  : null;
200
202
 
201
- planDir = fromConfig("planDir") ?? `${planBase}/plan`;
203
+ planDir = `${planBase}/plan`;
202
204
 
203
205
  // done/ sits beside plan/ (same depth, relative links survive)
204
206
  doneDir =
package/src/memory.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { execSync } from "node:child_process";
6
6
  import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
7
7
  import { dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "./db/defaults.js";
8
9
  import { type Config, loadConfig, safetyDeny } from "./db/index.js";
9
10
  import { assertSafe } from "./safety.js";
10
11
  import { templateArgs } from "./util.js";
@@ -202,7 +203,7 @@ function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
202
203
  const boundary = repoRootOf(start) ?? "/";
203
204
  let dir = start;
204
205
  while (true) {
205
- const candidate = join(dir, ".fapony", ".memory");
206
+ const candidate = join(dir, DEFAULT_MEM_DIR);
206
207
  if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
207
208
  return candidate;
208
209
  }
@@ -254,8 +255,8 @@ function findMemDirsUnder(root: string): string[] {
254
255
  }
255
256
  for (const e of entries) {
256
257
  if (!e.isDirectory()) continue;
257
- if (e.name === ".fapony") {
258
- const candidate = join(dir, ".fapony", ".memory");
258
+ if (e.name === FAPONY_DIR) {
259
+ const candidate = join(dir, DEFAULT_MEM_DIR);
259
260
  if (hasMemLogs(candidate)) found.push(candidate);
260
261
  continue;
261
262
  }
@@ -298,7 +299,7 @@ function resolveMemDirFrom(
298
299
  // path source here — spec §1 pins this to fapony.config.json)
299
300
  const configDir = root ?? cwd;
300
301
  try {
301
- const configPath = join(configDir, "fapony.config.json");
302
+ const configPath = join(configDir, CONFIG_FILENAME);
302
303
  if (existsSync(configPath)) {
303
304
  const config = loadConfig(configPath);
304
305
  if (config?.paths?.memDir) {
@@ -328,7 +329,7 @@ function resolveMemDirFrom(
328
329
 
329
330
  // Step 4: <repo root>/.fapony/.memory/ — where a new log is created
330
331
  if (root) {
331
- const rootDir = join(root, ".fapony", ".memory");
332
+ const rootDir = join(root, DEFAULT_MEM_DIR);
332
333
  if (existsSync(rootDir)) return { dir: rootDir, step: "repo-root" };
333
334
  }
334
335
 
package/src/plan-seed.ts CHANGED
@@ -38,6 +38,7 @@ import { basename, join, relative, resolve, sep } from "node:path";
38
38
  import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "./analyze.js";
39
39
  import { computeModelFit } from "./context/projectHealth.js";
40
40
  import { doneDir, planDir, specDir } from "./db/getters.js";
41
+ import { CONFIG_FILENAME } from "./db/index.js";
41
42
  import { loadConfig } from "./db/load.js";
42
43
  import type { Config } from "./db/types.js";
43
44
  import { extractExports } from "./map.js";
@@ -97,7 +98,7 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
97
98
  if (keys.length === 0) return placeholder;
98
99
 
99
100
  const hits: { shipped: string; line: string }[] = [];
100
- for (const dir of [doneDir(config), specDir(config)]) {
101
+ for (const dir of [doneDir(config), specDir()]) {
101
102
  const abs = join(cwd, dir);
102
103
  let names: string[];
103
104
  try {
@@ -524,7 +525,7 @@ export function cmdPlanSeed(args: string[]): void {
524
525
  process.exit(1);
525
526
  }
526
527
  const cwd = process.cwd();
527
- const config = loadConfig(join(cwd, "fapony.config.json"));
528
+ const config = loadConfig(join(cwd, CONFIG_FILENAME));
528
529
  // Resolve the git worktree root so mem/ledger queries hit the same key
529
530
  // state.db uses (git rev-parse --show-toplevel). Running from a subdir
530
531
  // would otherwise mismatch: stats return empty, Context (fapony) always
@@ -568,7 +569,7 @@ export function cmdPlanSeed(args: string[]): void {
568
569
  );
569
570
  }
570
571
 
571
- const planDirAbs = join(cwd, planDir(config));
572
+ const planDirAbs = join(cwd, planDir());
572
573
  const planPath = join(planDirAbs, `PLAN-${name}.md`);
573
574
  if (existsSync(planPath)) {
574
575
  console.error(
@@ -582,7 +583,7 @@ export function cmdPlanSeed(args: string[]): void {
582
583
 
583
584
  let specLink: string | null = null;
584
585
  if (withSpec) {
585
- const specDirAbs = join(cwd, specDir(config));
586
+ const specDirAbs = join(cwd, specDir());
586
587
  const specPath = join(specDirAbs, `SPEC-${name}.md`);
587
588
  if (existsSync(specPath)) {
588
589
  console.error(
@@ -602,7 +603,7 @@ export function cmdPlanSeed(args: string[]): void {
602
603
  : null,
603
604
  ),
604
605
  );
605
- specLink = `../${specDir(config).split("/").pop()}/SPEC-${name}.md`;
606
+ specLink = `../${specDir().split("/").pop()}/SPEC-${name}.md`;
606
607
  }
607
608
 
608
609
  mkdirSync(planDirAbs, { recursive: true });
@@ -613,7 +614,7 @@ export function cmdPlanSeed(args: string[]): void {
613
614
  priorArt,
614
615
  contextFapony,
615
616
  specLink,
616
- `${planDir(config)}/PLAN-${name}.md`,
617
+ `${planDir()}/PLAN-${name}.md`,
617
618
  ),
618
619
  );
619
620
  console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
package/src/setup.ts CHANGED
@@ -6,6 +6,7 @@ import { existsSync, statSync, writeFileSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { seedConventionsFile } from "./conventions-seed.js";
9
+ import { CONFIG_FILENAME, FAPONY_DIR } from "./db/index.js";
9
10
  import { initProject } from "./init.js";
10
11
  import { isAffirmative } from "./util.js";
11
12
 
@@ -181,7 +182,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
181
182
  enableMemory,
182
183
  });
183
184
 
184
- const configPath = join(process.cwd(), "fapony.config.json");
185
+ const configPath = join(process.cwd(), CONFIG_FILENAME);
185
186
  if (existsSync(configPath)) {
186
187
  const overwrite = await askFn(
187
188
  "⚠ fapony.config.json already exists. Overwrite? (y/n)",
@@ -197,7 +198,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
197
198
  console.log(`\n ✓ Wrote ${configPath}`);
198
199
 
199
200
  // --- scaffold worktree ---
200
- const faponyDir = join(worktreePath, ".fapony");
201
+ const faponyDir = join(worktreePath, FAPONY_DIR);
201
202
  if (!existsSync(faponyDir)) {
202
203
  try {
203
204
  initProject(worktreePath);
package/src/stats/data.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  // src/stats/data.ts — StatsData shape + getStatsData()
2
2
 
3
- import { readdirSync, readFileSync } from "node:fs";
3
+ import { readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
-
6
- import { type Event, openDb, type Run } from "../db/index.js";
5
+ import { type Event, openDb, PLAN_DIR, type Run } from "../db/index.js";
7
6
  import { loadConfig } from "../db/load.js";
8
7
  import { enrichGateWindows } from "../gates.js";
9
8
  import { avg, minutesBetween } from "../math.js";
@@ -89,9 +88,7 @@ function enrichGates(
89
88
  /**
90
89
  * Count un-shipped plan files in a worktree's planDir.
91
90
  *
92
- * Reads the *target repo's own* fapony.config.json for `paths.planDir` — the
93
- * central config's worktrees map is optional and usually absent, and each repo
94
- * picks its own plan dir (vela uses apps/vela/plan, not .fapony/plan).
91
+ * planDir is hardcoded to .fapony/plan — not configurable (gitignored = private).
95
92
  *
96
93
  * Returns null — never 0 — when the path isn't a readable directory, so a
97
94
  * sentinel row like "mcp-external" renders as "—" instead of claiming
@@ -99,19 +96,10 @@ function enrichGates(
99
96
  */
100
97
  export function countPendingPlans(worktree: string): number | null {
101
98
  if (!worktree.startsWith("/")) return null;
102
- let planDir = ".fapony/plan";
103
- try {
104
- const cfg = JSON.parse(
105
- readFileSync(join(worktree, "fapony.config.json"), "utf8"),
106
- ) as { paths?: { planDir?: unknown } };
107
- if (typeof cfg.paths?.planDir === "string" && cfg.paths.planDir)
108
- planDir = cfg.paths.planDir;
109
- } catch {
110
- // no config (or unreadable/malformed) — fall back to the scaffold default
111
- }
112
99
  try {
113
- return readdirSync(join(worktree, planDir)).filter((f) => f.endsWith(".md"))
114
- .length;
100
+ return readdirSync(join(worktree, PLAN_DIR)).filter((f) =>
101
+ f.endsWith(".md"),
102
+ ).length;
115
103
  } catch {
116
104
  return null;
117
105
  }