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/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) ---
@@ -132,7 +142,19 @@ export function isTestedThroughBarrels(
132
142
  export const SCAN_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]);
133
143
 
134
144
  // Always skipped, hardcoded — no config (per plan: no .faponyignore in v1).
135
- const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git"]);
145
+ // "templates" for the same reason knip.json ignores templates/**: those files
146
+ // ship as a template copied into other repos by `fapony init-mem` and never
147
+ // have real importers here — scanning them produces false wrapper/orphan
148
+ // signals (measured: conventions-seed flagged 8 "wrappers" that were all
149
+ // src/mem/commands/*.ts helpers matched against unrelated identically-
150
+ // named calls elsewhere in the repo, e.g. "cmdNow() instead of now(").
151
+ const SKIP_DIRS = new Set([
152
+ "node_modules",
153
+ "dist",
154
+ "build",
155
+ ".git",
156
+ "templates",
157
+ ]);
136
158
 
137
159
  // A nested checkout (clone or `git worktree add`) is a different project that
138
160
  // happens to live inside this one — walking it doubles the graph and makes every
@@ -322,6 +344,154 @@ export function buildGraph(dir: string): ImportGraph {
322
344
  return { files, deps, dependents, unresolved, external, barrels };
323
345
  }
324
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
+
325
495
  // --- Diagnosis ---
326
496
 
327
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([
@@ -190,7 +191,6 @@ async function eslintRows(
190
191
  const rows: SeedRow[] = [];
191
192
  const configs: string[] = [];
192
193
  const skipped: string[] = [];
193
- const seen = new Set<string>();
194
194
  for (const abs of findConfigFiles(root)) {
195
195
  let raw: string;
196
196
  try {
@@ -377,7 +377,7 @@ function detectWrappers(root: string): SeedRow[] {
377
377
  // --- entry ---
378
378
 
379
379
  export async function seedConventionsFile(target: string): Promise<SeedResult> {
380
- const file = join(target, ".fapony", "conventions.json");
380
+ const file = join(target, CONVENTIONS_FILE);
381
381
  const base: SeedResult = {
382
382
  file,
383
383
  eslintRows: 0,
@@ -408,7 +408,7 @@ export async function seedConventionsFile(target: string): Promise<SeedResult> {
408
408
  // a wrapper scan failure is not an init failure
409
409
  }
410
410
  const payload = `${JSON.stringify({ conventions: rows }, null, 2)}\n`;
411
- mkdirSync(join(target, ".fapony"), { recursive: true });
411
+ mkdirSync(join(target, FAPONY_DIR), { recursive: true });
412
412
  writeFileSync(file, payload);
413
413
  return {
414
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_MEMORY_ENTRY = ".fapony/.memory/mem.ts";
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
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  DEFAULT_DONE_DIR,
3
3
  DEFAULT_EVIDENCE_FILE,
4
- DEFAULT_MEMORY_ENTRY,
5
- DEFAULT_PLAN_DIR,
4
+ DEFAULT_MEM_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,20 +12,22 @@ 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 {
24
26
  return config?.paths?.doneDir ?? DEFAULT_DONE_DIR;
25
27
  }
26
28
 
27
- export function memoryEntry(config?: Config): string {
28
- return config?.paths?.memoryEntry ?? DEFAULT_MEMORY_ENTRY;
29
+ export function memoryDir(config?: Config): string {
30
+ return config?.paths?.memDir ?? DEFAULT_MEM_DIR;
29
31
  }
30
32
 
31
33
  export function evidenceFile(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/store.ts CHANGED
@@ -135,48 +135,10 @@ export function addEvent(
135
135
  return Number(result.lastInsertRowid);
136
136
  }
137
137
 
138
- /**
139
- * Merge `patch` into an existing event's JSON data (keeps keys already set).
140
- * Used to complete a spawn row with bytes_out/usd after the agent finishes —
141
- * one row per spawn, timing (ts) stays at spawn start. No-op when the row
142
- * is missing or its data isn't a JSON object.
143
- */
144
- export function updateEventData(
145
- db: Database,
146
- eventId: number,
147
- patch: Record<string, unknown>,
148
- ): void {
149
- const row = db
150
- .prepare("SELECT data FROM events WHERE id = ?")
151
- .get(eventId) as { data: string | null } | null;
152
- if (!row) return;
153
- let base: Record<string, unknown> = {};
154
- try {
155
- const parsed = JSON.parse(row.data ?? "null") as unknown;
156
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
157
- base = parsed as Record<string, unknown>;
158
- }
159
- } catch {
160
- return;
161
- }
162
- db.prepare("UPDATE events SET data = ? WHERE id = ?").run(
163
- JSON.stringify({ ...base, ...patch }),
164
- eventId,
165
- );
166
- }
167
-
168
138
  export function getRun(db: Database, runId: number): Run | null {
169
139
  return db.prepare("SELECT * FROM runs WHERE id = ?").get(runId) as Run | null;
170
140
  }
171
141
 
172
- export function getActiveRuns(db: Database): Run[] {
173
- return db
174
- .prepare(
175
- "SELECT * FROM runs WHERE status NOT IN ('passed', 'stopped') ORDER BY id",
176
- )
177
- .all() as Run[];
178
- }
179
-
180
142
  /**
181
143
  * Latest still-open run for a worktree+plan pair, or null.
182
144
  * Used by MCP verdict_submit to bind a round-2+ verdict to the original run
@@ -221,43 +183,6 @@ export function getEvents(db: Database, runId: number): Event[] {
221
183
  .all(runId) as Event[];
222
184
  }
223
185
 
224
- /**
225
- * Pulls the most recent unresolved "gate fail" note for a worktree+mem_id pair —
226
- * i.e. the review feedback the next `fapony run` should hand back to the executor.
227
- * Only looks at the latest run for that pair; if it already passed, returns null
228
- * (nothing to carry forward).
229
- */
230
- export function getPendingFeedback(
231
- db: Database,
232
- worktree: string,
233
- memId: string,
234
- excludeRunId?: number,
235
- ): string | null {
236
- const run = db
237
- .prepare(
238
- `SELECT * FROM runs WHERE worktree = ? AND mem_id = ? AND id != ? ORDER BY id DESC LIMIT 1`,
239
- )
240
- .get(worktree, memId, excludeRunId ?? -1) as Run | null;
241
- if (run?.status !== "fixing") return null;
242
-
243
- const event = db
244
- .prepare(
245
- `SELECT * FROM events WHERE run_id = ? AND kind = 'gate' ORDER BY id DESC LIMIT 1`,
246
- )
247
- .get(run.id) as Event | null;
248
- if (!event?.data) return null;
249
-
250
- try {
251
- const parsed = JSON.parse(event.data) as {
252
- verdict?: string;
253
- note?: string;
254
- };
255
- return parsed.verdict === "fail" && parsed.note ? parsed.note : null;
256
- } catch {
257
- return null;
258
- }
259
- }
260
-
261
186
  /**
262
187
  * Merge extra fields into the most recent gate event's data JSON.
263
188
  * Used by MCP verdict_submit to add reason_code / source after gateOnce
package/src/db/types.ts CHANGED
@@ -55,11 +55,9 @@ 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
- memoryEntry?: string;
60
+ memDir?: string;
63
61
  evidenceFile?: string;
64
62
  } | null;
65
63
  safety?: {