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.
package/README.md CHANGED
@@ -88,8 +88,8 @@ Stated up front, because the gap between these two things is where most tooling
88
88
  facts and that uncertainty was declared — not that the code works. Those are different
89
89
  guarantees and fapony only offers the first.
90
90
  - **Almost nothing blocks.** No CI failure, no gate on your own commands. The one exception is the
91
- Stop hook, once per turn when a commit ends ungraded; the read hint only annotates. Skip the
92
- install of both and you are back to exactly the workflow you had.
91
+ Stop hook, once per turn when a commit ends ungraded; the read/edit/commit hints only annotate.
92
+ Skip the install of all of them and you are back to exactly the workflow you had.
93
93
  - **Model attribution is inferred, not declared.** A gate is attributed to whichever client
94
94
  session was live in that worktree at that moment. When one model writes the code and another
95
95
  reviews and files the verdict, the grade lands on the reviewer. Reports label it `inferred`;
@@ -157,6 +157,7 @@ flowchart LR
157
157
  B[OpenCode] --> F
158
158
  C[ZCode] --> F
159
159
  D[Codex] --> F
160
+ E[Cursor] --> F
160
161
  F --> G[git facts + session logs]
161
162
  G --> S[stats / usage]
162
163
  G --> V[verification report]
@@ -175,6 +176,31 @@ losing a single number.
175
176
  | Writes | one graded row per unit of work | nothing |
176
177
  | Skip it and | there is no fapony | fapony still answers every question |
177
178
 
179
+ ### What runs where
180
+
181
+ `fapony install` wires five clients (Claude Code, OpenCode, Cursor, ZCode, Codex). MCP is the only
182
+ piece all of them get — the hooks and in-process hints are per-client, and the read/edit hints
183
+ arrive **before** the call on Claude Code but **after** it on OpenCode, whose only annotate channel
184
+ is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP tool still answers.
185
+
186
+ | | Claude Code | OpenCode | Cursor | ZCode | Codex |
187
+ |---|---|---|---|---|---|
188
+ | MCP tools — `mem_find` `mem_add` `fapony_usage` `verdict_submit` | ✅ | ✅ | ✅ | ✅ | ✅ |
189
+ | Stop hook — refuse to end a turn with ungraded commits | ✅ | — | ✅ | — | ✅ after trust |
190
+ | Read hint — big-file pointer + debt/mem lines | ✅ before | ✅ after | — | — | — |
191
+ | Re-read hint — unchanged repeat read | ✅ before | ✅ after | — | — | — |
192
+ | Edit hint — importer count before a shape change | ✅ before | ✅ after | — | — | — |
193
+ | Commit hint — `git commit` → ungraded-run nudge | — | ✅ after | — | — | — |
194
+ | Skills symlinked into `~/.claude/skills` | ✅ | ✅ | — | — | — |
195
+ | Skills symlinked into `~/.agents/skills` | — | — | — | ✅ | ✅ |
196
+ | `usage-scan` reads this client's session log | ✅ | ✅ | — | ✅ | ✅ |
197
+
198
+ `—` means not wired, not impossible: Cursor has no PreToolUse hook, and ZCode/Codex expose no
199
+ in-process hook surface for read/edit hints yet (Codex's `apply_patch` sends patch text, not
200
+ resolved file paths). Codex hooks require trust via `/hooks` before they run — `fapony install`
201
+ tells you when. The hints live on hooks rather than MCP on purpose — they must fire mid-turn
202
+ without the agent deciding to call anything ([why](#when-to-call-what)).
203
+
178
204
  ## The ledger — this is the product
179
205
 
180
206
  One habit feeds it: grade a unit of work when it ends. Everything else on this page is
@@ -202,10 +228,13 @@ sequenceDiagram
202
228
  ```
203
229
 
204
230
  The Stop hook is the only thing fapony *blocks* — once per turn, when a commit ends ungraded.
205
- It never picks the grade; it cannot see whether the work held up. The Read hook only annotates:
206
- one factual line when a read is large enough to be cheaper as `review-seed`, or when the same
207
- file is read again in a session and its mtime has not moved. The read always proceeds, and
208
- `FAPONY_NO_REREAD_HINT=1` turns the re-read line off.
231
+ It never picks the grade; it cannot see whether the work held up. The hints only annotate and never
232
+ block: the **Read** hook adds one factual line when a read is large enough to be cheaper as
233
+ `review-seed`, or when the same file is read again in a session and its mtime has not moved
234
+ (`FAPONY_NO_REREAD_HINT=1` turns the re-read line off); the **Edit** hook names a file's importer
235
+ count, once per session, before you change its shape; OpenCode's **commit** hook nudges after a
236
+ `git commit` that left the run ungraded. Claude Code receives read/edit *before* the call, OpenCode
237
+ *after* it — [What runs where](#what-runs-where) has the full client matrix.
209
238
 
210
239
  ### The 4 tools
211
240
 
@@ -339,8 +368,9 @@ the claim on faith.
339
368
 
340
369
  `fapony install --platform claude` (or `opencode`) symlinks these directories into
341
370
  `~/.claude/skills` rather than copying them, so `fapony update` refreshes every client
342
- at once. A destination that already exists and isn't a fapony link is reported and left
343
- alone — replace it by hand if you want fapony's version.
371
+ at once. ZCode and Codex get the same skills linked into `~/.agents/skills`. A destination
372
+ that already exists and isn't a fapony link is reported and left alone — replace it by hand
373
+ if you want fapony's version.
344
374
 
345
375
  `plan-with-pony` is vendor-neutral — the SKILL.md *is* the prompt, so pipe it to any agent:
346
376
 
@@ -475,8 +505,12 @@ Env overrides: `FAPONY_CONFIG` (config file), `FAPONY_STATE_DIR` (state DB locat
475
505
  - Memory integration via shell adapter, per project (configurable or default-wired)
476
506
  - Opt-in telemetry, off by default ([TELEMETRY.md](https://github.com/kire21b/fapony/blob/main/TELEMETRY.md) lists exactly what leaves the machine)
477
507
  - Bun-only; run state in SQLite via `bun:sqlite` (WAL mode)
508
+ - Per-client hooks alongside MCP: Stop hook on Claude Code + Cursor · read/re-read/Edit hints on
509
+ Claude Code + OpenCode · commit hint on OpenCode — [What runs where](#what-runs-where)
478
510
 
479
511
  **Not supported (yet):**
512
+ - PreToolUse hints on Cursor, ZCode or Codex — Cursor has no such hook and the other two expose no
513
+ in-process hook surface for read/edit hints (Codex's `apply_patch` sends patch text, not file paths)
480
514
  - A hosted or shared ledger for a team — `runs.worktree` is the only sharing key today, and it's a
481
515
  path, not an identity. If you want to try pointing two machines at the same ledger anyway,
482
516
  `FAPONY_STATE_DIR` can be set to a synced folder (Syncthing, a shared drive) — but SQLite's WAL
package/fapony.ts CHANGED
@@ -7,7 +7,7 @@ import { existsSync } from "node:fs";
7
7
  import { cmdAnalyze } from "./src/analyze.js";
8
8
  import { cmdDebt } from "./src/debt.js";
9
9
  import { cmdDigest } from "./src/digest/cli.js";
10
- import { cmdHookReadHint, cmdHookStop } from "./src/hook.js";
10
+ import { cmdHookEditHint, cmdHookReadHint, cmdHookStop } from "./src/hook.js";
11
11
  import { cmdInit } from "./src/init.js";
12
12
  import { cmdInitMem } from "./src/init-mem.js";
13
13
  import { cmdInstall } from "./src/install.js";
@@ -85,6 +85,8 @@ if (cmd === "analyze") {
85
85
  await cmdHookStop();
86
86
  } else if (cmd === "hook-read-hint") {
87
87
  await cmdHookReadHint();
88
+ } else if (cmd === "hook-edit-hint") {
89
+ await cmdHookEditHint();
88
90
  } else if (cmd === "mcp") {
89
91
  cmdMcp();
90
92
  } else if (cmd === "report") {
@@ -106,7 +108,7 @@ if (cmd === "analyze") {
106
108
  } else {
107
109
  console.error(`fapony: unknown command "${cmd ?? ""}"`);
108
110
  console.error(
109
- "usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|test> [args]",
111
+ "usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|hook-edit-hint|test> [args]",
110
112
  );
111
113
  process.exit(1);
112
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Measurement layer for coding agents \u2014 measure what agents do, verify what they claim. 4 MCP tools, any agent, no loop required",
5
5
  "license": "MIT",
6
6
  "author": "delamind (https://github.com/kire21b)",
package/src/analyze.ts CHANGED
@@ -1,15 +1,24 @@
1
1
  // src/analyze.ts — `fapony analyze`: structural health diagnosis for a TS/JS project.
2
2
  //
3
3
  // One file on purpose (plan cap: ≤1 new file in src/). Computes a file-level
4
- // import graph live with Bun.Transpiler.scan() — never persisted, no new table.
5
- // Read-only: never writes
6
- // into the analyzed directory.
4
+ // import graph live with Bun.Transpiler.scan() — no new table. Read-only: never
5
+ // writes into the analyzed directory. buildGraphCached additionally mirrors the
6
+ // derived graph to the state dir (outside the worktree) as a best-effort cache
7
+ // for callers that run as repeated short-lived processes (hooks) — see below.
7
8
  //
8
9
  // Same module also serves handoff_check / verification_report: blastRadius()
9
10
  // turns facts.files[] into per-file { dependents, tested } facts.
10
11
 
11
12
  import type { Dirent } from "node:fs";
12
- import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ renameSync,
19
+ statSync,
20
+ writeFileSync,
21
+ } from "node:fs";
13
22
  import { join, relative, resolve, sep } from "node:path";
14
23
  import {
15
24
  dirname as posixDirname,
@@ -17,6 +26,7 @@ import {
17
26
  normalize as posixNormalize,
18
27
  } from "node:path/posix";
19
28
 
29
+ import { faponyDir } from "./db/load.js";
20
30
  import { extractExports } from "./map.js";
21
31
 
22
32
  // --- Types (mirror SPEC-analyze-checkup.md) ---
@@ -334,6 +344,154 @@ export function buildGraph(dir: string): ImportGraph {
334
344
  return { files, deps, dependents, unresolved, external, barrels };
335
345
  }
336
346
 
347
+ // --- Session-scoped graph cache ---
348
+ //
349
+ // buildGraph is cheap on this repo (~50ms/150 files) but the Edit hint calls it
350
+ // on every Edit — and a Claude Code PreToolUse hook is a *fresh process per
351
+ // tool call*, so an in-process cache alone never survives to the next edit. The
352
+ // graph is therefore mirrored to <faponyDir>/graph-cache/<key>.json:
353
+ // - auto-build: every build is written through, best-effort (never blocks)
354
+ // - auto-invalidate: a fingerprint over the source-file set (rel path + size
355
+ // + mtimeMs) is stored beside the graph; a mismatch means rebuild
356
+ // - cost: the cache file is only stat-ed when there is one to validate, so a
357
+ // first-ever call pays build + write; later calls pay a walk + stat + parse,
358
+ // well under a rebuild on any repo large enough for this to matter
359
+ // Everything here is derived state — an unreadable, corrupt, stale or
360
+ // unwritable cache falls back to a live build and no code path trusts it.
361
+
362
+ const GRAPH_CACHE_VERSION = 1;
363
+ const GRAPH_CACHE_DIR = "graph-cache";
364
+
365
+ interface SerializedGraph {
366
+ v: number;
367
+ fp: string;
368
+ files: string[];
369
+ deps: Record<string, string[]>;
370
+ dependents: Record<string, string[]>;
371
+ unresolved: number;
372
+ external: number;
373
+ barrels: string[];
374
+ }
375
+
376
+ let _graphCache: { dir: string; fp: string; graph: ImportGraph } | null = null;
377
+
378
+ /** Drop the in-process graph cache (tests simulate a fresh hook process). */
379
+ export function resetGraphCache(): void {
380
+ _graphCache = null;
381
+ }
382
+
383
+ /** Absolute path of a worktree's graph-cache file — may not exist. */
384
+ export function graphCachePath(dir: string): string {
385
+ const abs = resolve(dir);
386
+ const slug = abs.replace(/[^A-Za-z0-9]+/g, "-").slice(0, 60);
387
+ return join(
388
+ faponyDir(),
389
+ GRAPH_CACHE_DIR,
390
+ `${slug}-${Bun.hash(abs).toString(36)}.json`,
391
+ );
392
+ }
393
+
394
+ // The graph changes only when the set of source files or their bytes change —
395
+ // size/mtime/ctime catch that without reading any file. ctime rides the same
396
+ // stat call for free and cannot be forged like mtime can (only the system
397
+ // moves it), so an mtime-preserving rewrite still invalidates.
398
+ function graphFingerprint(absDir: string): string {
399
+ const parts: string[] = [];
400
+ for (const rel of collectSourceFiles(absDir)) {
401
+ try {
402
+ const st = statSync(join(absDir, rel));
403
+ parts.push(
404
+ `${rel}\u0000${st.size}\u0000${st.mtimeMs}\u0000${st.ctimeMs}`,
405
+ );
406
+ } catch {
407
+ parts.push(`${rel}\u0000?\u0000?\u0000?`);
408
+ }
409
+ }
410
+ return Bun.hash(parts.join("\n")).toString(36);
411
+ }
412
+
413
+ function serializeGraph(graph: ImportGraph, fp: string): SerializedGraph {
414
+ const rec = (m: Map<string, Set<string>>): Record<string, string[]> => {
415
+ const out: Record<string, string[]> = {};
416
+ for (const [k, v] of m) out[k] = [...v];
417
+ return out;
418
+ };
419
+ return {
420
+ v: GRAPH_CACHE_VERSION,
421
+ fp,
422
+ files: graph.files,
423
+ deps: rec(graph.deps),
424
+ dependents: rec(graph.dependents),
425
+ unresolved: graph.unresolved,
426
+ external: graph.external,
427
+ barrels: [...graph.barrels],
428
+ };
429
+ }
430
+
431
+ function hydrateGraph(c: SerializedGraph): ImportGraph {
432
+ const toMap = (r: Record<string, string[]>): Map<string, Set<string>> => {
433
+ const m = new Map<string, Set<string>>();
434
+ for (const [k, v] of Object.entries(r)) m.set(k, new Set(v));
435
+ return m;
436
+ };
437
+ return {
438
+ files: c.files,
439
+ deps: toMap(c.deps),
440
+ dependents: toMap(c.dependents),
441
+ unresolved: c.unresolved,
442
+ external: c.external,
443
+ barrels: new Set(c.barrels),
444
+ };
445
+ }
446
+
447
+ function readCachedGraph(path: string, fp: string): ImportGraph | null {
448
+ try {
449
+ const cached = JSON.parse(readFileSync(path, "utf-8")) as SerializedGraph;
450
+ if (cached.v !== GRAPH_CACHE_VERSION || cached.fp !== fp) return null;
451
+ return hydrateGraph(cached);
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
456
+
457
+ function writeCachedGraph(path: string, graph: ImportGraph, fp: string): void {
458
+ try {
459
+ if (!existsSync(join(faponyDir(), GRAPH_CACHE_DIR))) {
460
+ mkdirSync(join(faponyDir(), GRAPH_CACHE_DIR), { recursive: true });
461
+ }
462
+ // pid-suffixed temp + rename: a reader never sees a half-written file even
463
+ // when two hook processes race.
464
+ const tmp = `${path}.${process.pid}.tmp`;
465
+ writeFileSync(tmp, JSON.stringify(serializeGraph(graph, fp)), "utf-8");
466
+ renameSync(tmp, path);
467
+ } catch {
468
+ // best-effort — a cache that cannot be written must not break the caller
469
+ }
470
+ }
471
+
472
+ export function buildGraphCached(dir: string): ImportGraph {
473
+ const abs = resolve(dir);
474
+ // One walk per call: the fingerprint doubles as the in-process validity
475
+ // check, so a same-process second call after an edit rebuilds instead of
476
+ // serving the stale graph. A drift between this fp and the built graph
477
+ // self-heals — the next call recomputes and rebuilds again.
478
+ const fp = graphFingerprint(abs);
479
+ if (_graphCache?.dir === abs && _graphCache.fp === fp)
480
+ return _graphCache.graph;
481
+ const path = graphCachePath(abs);
482
+ if (existsSync(path)) {
483
+ const cached = readCachedGraph(path, fp);
484
+ if (cached) {
485
+ _graphCache = { dir: abs, fp, graph: cached };
486
+ return cached;
487
+ }
488
+ }
489
+ const graph = buildGraph(abs);
490
+ _graphCache = { dir: abs, fp, graph };
491
+ writeCachedGraph(path, graph, fp);
492
+ return graph;
493
+ }
494
+
337
495
  // --- Diagnosis ---
338
496
 
339
497
  function findCycles(graph: ImportGraph): string[][] {
@@ -15,6 +15,7 @@ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join, relative } from "node:path";
16
16
  import { pathToFileURL } from "node:url";
17
17
  import { collectSourceFiles, isSkippedDir, isTestFile } from "./analyze.js";
18
+ import { CONVENTIONS_FILE, FAPONY_DIR } from "./db/index.js";
18
19
  import { extractBody, extractExports } from "./map.js";
19
20
 
20
21
  const RESTRICTED_RULES = new Set([
@@ -376,7 +377,7 @@ function detectWrappers(root: string): SeedRow[] {
376
377
  // --- entry ---
377
378
 
378
379
  export async function seedConventionsFile(target: string): Promise<SeedResult> {
379
- const file = join(target, ".fapony", "conventions.json");
380
+ const file = join(target, CONVENTIONS_FILE);
380
381
  const base: SeedResult = {
381
382
  file,
382
383
  eslintRows: 0,
@@ -407,7 +408,7 @@ export async function seedConventionsFile(target: string): Promise<SeedResult> {
407
408
  // a wrapper scan failure is not an init failure
408
409
  }
409
410
  const payload = `${JSON.stringify({ conventions: rows }, null, 2)}\n`;
410
- mkdirSync(join(target, ".fapony"), { recursive: true });
411
+ mkdirSync(join(target, FAPONY_DIR), { recursive: true });
411
412
  writeFileSync(file, payload);
412
413
  return {
413
414
  file,
@@ -6,13 +6,21 @@ export const DEFAULT_SAFETY_DENY = [
6
6
  "checkout\\s+--\\s",
7
7
  "git\\s+stash",
8
8
  ];
9
- export const DEFAULT_PLAN_DIR = ".fapony/plan";
10
- export const DEFAULT_SPEC_DIR = ".fapony/spec";
9
+ // --- .fapony/ layout (single source of truth — do not hardcode ".fapony" elsewhere) ---
10
+ // plan/spec live in .fapony/ — not configurable (gitignored = private).
11
+ export const FAPONY_DIR = ".fapony";
12
+ export const CONFIG_FILENAME = "fapony.config.json";
13
+ export const CONVENTIONS_FILENAME = "conventions.json";
14
+ export const EVIDENCE_FILENAME = "evidence.json";
15
+ export const CONVENTIONS_FILE = `${FAPONY_DIR}/${CONVENTIONS_FILENAME}`;
16
+ // plan/spec live in .fapony/ — not configurable (gitignored = private).
17
+ export const PLAN_DIR = `${FAPONY_DIR}/plan`;
18
+ export const SPEC_DIR = `${FAPONY_DIR}/spec`;
11
19
  // Archive sits beside plan/, not inside it, so archiving never changes a file's
12
20
  // depth and its relative links survive the move untouched.
13
- export const DEFAULT_DONE_DIR = ".fapony/done";
14
- export const DEFAULT_MEM_DIR = ".fapony/.memory";
15
- export const DEFAULT_EVIDENCE_FILE = ".fapony/evidence.json";
21
+ export const DEFAULT_DONE_DIR = `${FAPONY_DIR}/done`;
22
+ export const DEFAULT_MEM_DIR = `${FAPONY_DIR}/.memory`;
23
+ export const DEFAULT_EVIDENCE_FILE = `${FAPONY_DIR}/${EVIDENCE_FILENAME}`;
16
24
 
17
25
  export const DEFAULT_CONFIG: Config = {
18
26
  worktrees: {},
package/src/db/getters.ts CHANGED
@@ -2,9 +2,9 @@ import {
2
2
  DEFAULT_DONE_DIR,
3
3
  DEFAULT_EVIDENCE_FILE,
4
4
  DEFAULT_MEM_DIR,
5
- DEFAULT_PLAN_DIR,
6
5
  DEFAULT_SAFETY_DENY,
7
- DEFAULT_SPEC_DIR,
6
+ PLAN_DIR,
7
+ SPEC_DIR,
8
8
  } from "./defaults.js";
9
9
  import type { Config } from "./types.js";
10
10
 
@@ -12,12 +12,14 @@ export function safetyDeny(config?: Config): string[] {
12
12
  return config?.safety?.deny ?? DEFAULT_SAFETY_DENY;
13
13
  }
14
14
 
15
- export function planDir(config?: Config): string {
16
- return config?.paths?.planDir ?? DEFAULT_PLAN_DIR;
15
+ /** Hardcoded — plan/spec live in .fapony/ (gitignored = private). */
16
+ export function planDir(): string {
17
+ return PLAN_DIR;
17
18
  }
18
19
 
19
- export function specDir(config?: Config): string {
20
- return config?.paths?.specDir ?? DEFAULT_SPEC_DIR;
20
+ /** Hardcoded — plan/spec live in .fapony/ (gitignored = private). */
21
+ export function specDir(): string {
22
+ return SPEC_DIR;
21
23
  }
22
24
 
23
25
  export function doneDir(config?: Config): string {
package/src/db/load.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { DEFAULT_CONFIG } from "./defaults.js";
4
+ import { CONFIG_FILENAME, DEFAULT_CONFIG } from "./defaults.js";
5
5
  import type { Config } from "./types.js";
6
6
 
7
7
  // XDG Base Directory convention (macOS ignores Apple's ~/Library/Application Support
@@ -20,7 +20,7 @@ function _dbPath(config?: Config): string {
20
20
 
21
21
  export function configFilePath(): string {
22
22
  if (process.env.FAPONY_CONFIG) return process.env.FAPONY_CONFIG;
23
- return join(process.cwd(), "fapony.config.json");
23
+ return join(process.cwd(), CONFIG_FILENAME);
24
24
  }
25
25
 
26
26
  function freshDefaultConfig(): Config {
package/src/db/types.ts CHANGED
@@ -55,9 +55,7 @@ export interface Config {
55
55
  // state dir override (default: $XDG_CONFIG_HOME/fapony or ~/.config/fapony).
56
56
  // $FAPONY_STATE_DIR env wins over this when set.
57
57
  stateDir?: string;
58
- // plan/spec/memory layout inside each worktree (relative to worktree root).
59
- planDir?: string;
60
- specDir?: string;
58
+ // plan/spec live in .fapony/{plan,spec} — not configurable (gitignored = private).
61
59
  doneDir?: string;
62
60
  memDir?: string;
63
61
  evidenceFile?: string;
package/src/debt.ts CHANGED
@@ -21,6 +21,11 @@
21
21
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
22
22
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
23
23
  import { collectSourceFiles } from "./analyze.js";
24
+ import {
25
+ CONVENTIONS_FILE,
26
+ CONVENTIONS_FILENAME,
27
+ FAPONY_DIR,
28
+ } from "./db/defaults.js";
24
29
  import { openDb } from "./db/index.js";
25
30
  import { readMemLog, resolveMemDir } from "./memory.js";
26
31
 
@@ -52,13 +57,13 @@ export function resolveConventionsPath(worktree: string): string | null {
52
57
  // Conventions live in the same .fapony/ dir as the mem log — derive from
53
58
  // the resolved mem dir so both resolvers cannot drift apart.
54
59
  const memDir = resolveMemDir(worktree);
55
- const base = memDir ? join(memDir, "..") : join(worktree, ".fapony");
56
- const app = join(base, "conventions.json");
60
+ const base = memDir ? join(memDir, "..") : join(worktree, FAPONY_DIR);
61
+ const app = join(base, CONVENTIONS_FILENAME);
57
62
  if (existsSync(app)) return app;
58
63
  // Monorepo where the app has not scaffolded .fapony/ yet, and single repos
59
64
  // that ran `fapony init` at the root — the root file still scopes fine
60
65
  // because every `where` is repo-relative.
61
- const root = join(worktree, ".fapony", "conventions.json");
66
+ const root = join(worktree, CONVENTIONS_FILE);
62
67
  return existsSync(root) ? root : null;
63
68
  }
64
69
 
@@ -668,7 +673,7 @@ export function worktreeOf(arg: string | undefined): string {
668
673
  const boundary = gitRoot ? real(gitRoot) : null;
669
674
  let dir = base;
670
675
  while (true) {
671
- if (existsSync(join(dir, ".fapony", "conventions.json"))) return dir;
676
+ if (existsSync(join(dir, CONVENTIONS_FILE))) return dir;
672
677
  if (boundary && real(dir) === boundary) break;
673
678
  const parent = dirname(dir);
674
679
  if (parent === dir) break;
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
6
6
  import { existsSync, readdirSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import {
9
+ CONFIG_FILENAME,
9
10
  doneDir,
10
11
  type Event,
11
12
  loadConfig,
@@ -165,8 +166,8 @@ function readPlans(worktree: string): {
165
166
  ok: boolean;
166
167
  detail: string;
167
168
  } {
168
- const config = loadConfig(join(worktree, "fapony.config.json"));
169
- const pDir = join(worktree, planDir(config));
169
+ const config = loadConfig(join(worktree, CONFIG_FILENAME));
170
+ const pDir = join(worktree, planDir());
170
171
  const dDir = join(worktree, doneDir(config));
171
172
 
172
173
  if (!existsSync(pDir)) {