fapony 0.2.0 → 0.3.0

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`;
@@ -122,7 +122,6 @@ fapony usage-scan # scan the session logs already on dis
122
122
  fapony price-scan # fetch the OpenRouter price table → ~/.config/fapony/prices.json
123
123
  fapony usage-web # dashboard; re-run the scans to refresh
124
124
  # both scans are manual by design — nothing fetches or re-reads session logs behind your back
125
- # ask your agent: "Run fapony_usage — what has it cost me, per model?"
126
125
 
127
126
  # 4. Verify (optional, per project) — scaffold the evidence allowlist
128
127
  fapony init /path/to/your-worktree
@@ -157,6 +156,7 @@ flowchart LR
157
156
  B[OpenCode] --> F
158
157
  C[ZCode] --> F
159
158
  D[Codex] --> F
159
+ E[Cursor] --> F
160
160
  F --> G[git facts + session logs]
161
161
  G --> S[stats / usage]
162
162
  G --> V[verification report]
@@ -175,6 +175,31 @@ losing a single number.
175
175
  | Writes | one graded row per unit of work | nothing |
176
176
  | Skip it and | there is no fapony | fapony still answers every question |
177
177
 
178
+ ### What runs where
179
+
180
+ `fapony install` wires five clients (Claude Code, OpenCode, Cursor, ZCode, Codex). MCP is the only
181
+ piece all of them get — the hooks and in-process hints are per-client, and the read/edit hints
182
+ arrive **before** the call on Claude Code but **after** it on OpenCode, whose only annotate channel
183
+ is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP tool still answers.
184
+
185
+ | | Claude Code | OpenCode | Cursor | ZCode | Codex |
186
+ |---|---|---|---|---|---|
187
+ | MCP tools — `mem_find` `mem_add` `mem_close` `verdict_submit` | ✅ | ✅ | ✅ | ✅ | ✅ |
188
+ | Stop hook — refuse to end a turn with ungraded commits | ✅ | — | ✅ | — | ✅ after trust |
189
+ | Read hint — big-file pointer + debt/mem lines | ✅ before | ✅ after | — | — | — |
190
+ | Re-read hint — unchanged repeat read | ✅ before | ✅ after | — | — | — |
191
+ | Edit hint — importer count before a shape change | ✅ before | ✅ after | — | — | — |
192
+ | Commit hint — `git commit` → ungraded-run nudge | — | ✅ after | — | — | — |
193
+ | Skills symlinked into `~/.claude/skills` | ✅ | ✅ | — | — | — |
194
+ | Skills symlinked into `~/.agents/skills` | — | — | — | ✅ | ✅ |
195
+ | `usage-scan` reads this client's session log | ✅ | ✅ | — | ✅ | ✅ |
196
+
197
+ `—` means not wired, not impossible: Cursor has no PreToolUse hook, and ZCode/Codex expose no
198
+ in-process hook surface for read/edit hints yet (Codex's `apply_patch` sends patch text, not
199
+ resolved file paths). Codex hooks require trust via `/hooks` before they run — `fapony install`
200
+ tells you when. The hints live on hooks rather than MCP on purpose — they must fire mid-turn
201
+ without the agent deciding to call anything ([why](#when-to-call-what)).
202
+
178
203
  ## The ledger — this is the product
179
204
 
180
205
  One habit feeds it: grade a unit of work when it ends. Everything else on this page is
@@ -202,26 +227,29 @@ sequenceDiagram
202
227
  ```
203
228
 
204
229
  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.
230
+ It never picks the grade; it cannot see whether the work held up. The hints only annotate and never
231
+ block: the **Read** hook adds one factual line when a read is large enough to be cheaper as
232
+ `review-seed`, or when the same file is read again in a session and its mtime has not moved
233
+ (`FAPONY_NO_REREAD_HINT=1` turns the re-read line off); the **Edit** hook names a file's importer
234
+ count, once per session, before you change its shape; OpenCode's **commit** hook nudges after a
235
+ `git commit` that left the run ungraded. Claude Code receives read/edit *before* the call, OpenCode
236
+ *after* it — [What runs where](#what-runs-where) has the full client matrix.
209
237
 
210
238
  ### The 4 tools
211
239
 
212
240
  | Tool | Tier | Purpose |
213
241
  |------|------|---------|
214
- | `fapony_usage` | measure | Passive usage from OpenCode, ZCode, Claude Code, and Codex sessions (tokens, cost, by-model; `detail:true` adds per-step timing) |
215
242
  | `verdict_submit` | verify | Store a 6-grade verdict (pass-excellent → uncertain) with a required `regime` — the task shape the grade applies to |
216
243
  | `mem_find` | recall | Search the project's mem log read-only — decisions/bugs/notes matched on the row's `files[]` (text substring for rows written without it), `text`, `kind` (no default filter), `since`. "What was ever decided about this file?" in one call before editing |
217
244
  | `mem_add` | recall | Append a mem row (decision/bug/note/next/hold) with `files[]` required and rejected when empty — the write half of `mem_find`, so the row is findable when you next touch that file |
245
+ | `mem_close` | recall | Close a mem row by id with a tombstone message — a separate tool (not `kind:"close"`) because a close row carries no `files[]`, so sharing `mem_add`'s schema would make required fields depend on another field's value |
218
246
 
219
247
  **A tool earns its schema by being called mid-task without being asked.** Everything you invoke
220
248
  deliberately is a CLI command instead: the schema is paid as input tokens in every session of
221
249
  every client whether or not it is used, while a CLI command costs nothing until it runs. That is
222
- why the handoff/report family is CLI-only, and why `fapony_stats`, `project_health_context` and
223
- `plan_list` left the MCP surface in 2026-09 (`fapony stats` answers the first, `fapony mem
224
- kickoff` the third; the second had no caller).
250
+ why the handoff/report family is CLI-only, and why `fapony_stats`, `project_health_context`,
251
+ `plan_list` and `fapony_usage` left the MCP surface in 2026-09 (`fapony stats` answers the first, `fapony mem
252
+ kickoff` the third, `fapony usage-web` the fourth; the second had no caller).
225
253
  Cutting is not the goal — spending where it pays back is: `mem_find` and `verdict_submit` keep
226
254
  their schemas because nobody is going to type them at the right moment. `fapony report <run-id>` prints the full report for a run (facts + handoff conformance + evidence + verdict); `fapony report-web [file]` renders it as a static HTML page (overwrites `file` on every call — safe to reuse the same path). Run `bun run overview` for a one-shot shortcut that writes it to `/tmp/fapony-overview.html` and opens it. `fapony usage-scan` scans session logs and writes a cache file; `fapony usage-web [port]` serves a static HTML dashboard from that cache (no live scanning). Run `fapony usage-scan` periodically to keep data fresh.
227
255
 
@@ -275,13 +303,15 @@ That is the whole trick; there is no model in the middle.
275
303
 
276
304
  ### Skills
277
305
 
278
- fapony ships five portable skills, each as `skill/<name>/SKILL.md` — the layout Claude
306
+ fapony ships seven portable skills, each as `skill/<name>/SKILL.md` — the layout Claude
279
307
  Code expects, so a client can symlink the directory rather than copy the file:
280
308
 
281
309
  | Skill | Purpose | Trigger |
282
310
  |-------|---------|---------|
283
311
  | `skill/plan-with-pony/` | Draft plan + spec from "what's in your head" via conversation | `/plan-with-pony` |
284
312
  | `skill/review-pony/` | Review as verification, wired to fapony: scope facts before (`review-seed`), verdict after | `/review-pony` |
313
+ | `skill/lookup-before-edit/` | Look up unfamiliar files (`review-seed --files` + mem + debt) before reading/editing them | `/lookup-before-edit` |
314
+ | `skill/define-convention/` | Turn a not-yet-migrated pattern into a tracked convention (interview + dry-run `debt`) | `/define-convention` |
285
315
  | `skill/move-to-done/` | Archive a shipped PLAN into .fapony/done/ | `/move-to-done` |
286
316
  | `skill/git-commit-conventional/` | Commit split by concern + conventional message | `/git-commit` |
287
317
  | `skill/git-ship/` | Push branch, open PR with drafted title/body, merge, reset branch onto base | `/ship`, `/pr` |
@@ -334,13 +364,14 @@ plain `/git-ship` detects that and behaves like `pr` on its own.
334
364
 
335
365
  **What this is not.** It doesn't reduce your token bill — an agent that plans against known
336
366
  failure patterns tends to spend fewer rounds getting there, but fapony measures that, it doesn't
337
- cause it. Use `fapony_usage` to find out whether it actually happened for you rather than taking
367
+ cause it. Use `fapony usage-web` to find out whether it actually happened for you rather than taking
338
368
  the claim on faith.
339
369
 
340
370
  `fapony install --platform claude` (or `opencode`) symlinks these directories into
341
371
  `~/.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.
372
+ at once. ZCode and Codex get the same skills linked into `~/.agents/skills`. A destination
373
+ that already exists and isn't a fapony link is reported and left alone — replace it by hand
374
+ if you want fapony's version.
344
375
 
345
376
  `plan-with-pony` is vendor-neutral — the SKILL.md *is* the prompt, so pipe it to any agent:
346
377
 
@@ -475,8 +506,12 @@ Env overrides: `FAPONY_CONFIG` (config file), `FAPONY_STATE_DIR` (state DB locat
475
506
  - Memory integration via shell adapter, per project (configurable or default-wired)
476
507
  - Opt-in telemetry, off by default ([TELEMETRY.md](https://github.com/kire21b/fapony/blob/main/TELEMETRY.md) lists exactly what leaves the machine)
477
508
  - Bun-only; run state in SQLite via `bun:sqlite` (WAL mode)
509
+ - Per-client hooks alongside MCP: Stop hook on Claude Code + Cursor · read/re-read/Edit hints on
510
+ Claude Code + OpenCode · commit hint on OpenCode — [What runs where](#what-runs-where)
478
511
 
479
512
  **Not supported (yet):**
513
+ - PreToolUse hints on Cursor, ZCode or Codex — Cursor has no such hook and the other two expose no
514
+ in-process hook surface for read/edit hints (Codex's `apply_patch` sends patch text, not file paths)
480
515
  - A hosted or shared ledger for a team — `runs.worktree` is the only sharing key today, and it's a
481
516
  path, not an identity. If you want to try pointing two machines at the same ledger anyway,
482
517
  `FAPONY_STATE_DIR` can be set to a synced folder (Syncthing, a shared drive) — but SQLite's WAL
package/fapony.ts CHANGED
@@ -5,9 +5,14 @@
5
5
 
6
6
  import { existsSync } from "node:fs";
7
7
  import { cmdAnalyze } from "./src/analyze.js";
8
- import { cmdDebt } from "./src/debt.js";
8
+ import { cmdDebt } from "./src/debt/cli.js";
9
9
  import { cmdDigest } from "./src/digest/cli.js";
10
- import { cmdHookReadHint, cmdHookStop } from "./src/hook.js";
10
+ import {
11
+ cmdHookEditHint,
12
+ cmdHookReadHint,
13
+ cmdHookSessionStart,
14
+ cmdHookStop,
15
+ } from "./src/hook.js";
11
16
  import { cmdInit } from "./src/init.js";
12
17
  import { cmdInitMem } from "./src/init-mem.js";
13
18
  import { cmdInstall } from "./src/install.js";
@@ -85,6 +90,10 @@ if (cmd === "analyze") {
85
90
  await cmdHookStop();
86
91
  } else if (cmd === "hook-read-hint") {
87
92
  await cmdHookReadHint();
93
+ } else if (cmd === "hook-edit-hint") {
94
+ await cmdHookEditHint();
95
+ } else if (cmd === "hook-session-start") {
96
+ await cmdHookSessionStart();
88
97
  } else if (cmd === "mcp") {
89
98
  cmdMcp();
90
99
  } else if (cmd === "report") {
@@ -106,7 +115,7 @@ if (cmd === "analyze") {
106
115
  } else {
107
116
  console.error(`fapony: unknown command "${cmd ?? ""}"`);
108
117
  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]",
118
+ "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|hook-session-start|test> [args]",
110
119
  );
111
120
  process.exit(1);
112
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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)",
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: define-convention
3
+ description: Turn "files that haven't migrated yet" into a tracked convention — one question, a draft built from real examples, then dry-run fapony debt until the counts hold. Trigger on /define-convention and when the user wants migration tracking or fapony debt shows declared rows with no regex.
4
+ ---
5
+
6
+ # Define Convention — from pain to a counted migration
7
+
8
+ One convention = the pattern to use (`ok`) + the pattern meaning not-yet-migrated
9
+ (`stale`) + scope (`where`) + an optional file condition (`guard`). The output is
10
+ one row in `<worktree>/.fapony/conventions.json` (`{"conventions": [...]}`), in the
11
+ same `.fapony/` dir as the mem log — run `fapony mem where`, go up one level, that
12
+ is where the file lives (app-scoped in a monorepo). No file there yet = create it;
13
+ a file with rows = append only, never rewrite other rows.
14
+
15
+ ## Phase 1 — One question, then the checker question
16
+
17
+ Ask this, and nothing else:
18
+
19
+ > "What should stop appearing, and what does the migrated code look like? Which
20
+ > directory is it in?"
21
+
22
+ Then the iron-rule question (one line, always asked — a convention eslint already
23
+ flags is one fapony must stay silent on):
24
+
25
+ > "Does an eslint rule or script already flag the old pattern? If yes, name it —
26
+ > fapony will record it as `checker` and never report this debt."
27
+
28
+ ## Phase 2 — Real examples before regex
29
+
30
+ Never invent the regex from prose — regex from prose is how entries get dropped.
31
+ `grep` the `where` dir for 2–3 hits of the old pattern and 1–2 of the new one. No
32
+ hits on either side = stop and say so; there is no migration to track yet, only
33
+ an opinion.
34
+
35
+ ## Phase 3 — Draft the row, show it, append it
36
+
37
+ ```json
38
+ {"id": "raw-throw", "rule": "throw failWith, not raw Error",
39
+ "where": "src", "stale": "throw new Error", "ok": "failWith"}
40
+ ```
41
+
42
+ - `id` short, kebab; `rule` one human line; `where` a repo-relative dir that
43
+ exists (`"."` = whole repo).
44
+ - `guard` only when `stale` alone is too broad (the file must ALSO match, e.g.
45
+ `"extends Base"`).
46
+ - `checker` set from Phase 1 = silent by design; verify it with eslint, not fapony.
47
+ - `stale: null` ships a `declared` placeholder — fill it or delete it, never keep it.
48
+ - The shown draft is the confirmation (rule 6c). Append, don't rewrite.
49
+
50
+ ## Phase 4 — Dry-run until the counts hold
51
+
52
+ ```bash
53
+ fapony debt --id <new-id>
54
+ ```
55
+
56
+ Read it literally — every outcome names its fix:
57
+
58
+ - `debt N · moved M` with N > 0 → the convention now counts. Report id, N, moved%
59
+ back in chat.
60
+ - `debt 0 · moved M — clean` → the migration is already done; nothing left to
61
+ track. Report it and change nothing.
62
+ - `debt 0` with no moved → `stale` matches nothing you care about; widen it or
63
+ fix `where`.
64
+ - `⚠ <id>: ... too broad` → narrow `stale`, shrink `where`, or add `guard` (the
65
+ match was capped past 250 files, so the entry was dropped).
66
+ - `⚠ <id>: stale regex broken` / `where: <dir> does not exist` → fix syntax / path.
67
+ - A row prints `declared, no checker, stale not filled in` → `stale` is null; see
68
+ Phase 3.
69
+ - `0 convention(s)` after `--id` → that id does not exist (misspelling — a dropped
70
+ row still prints its `⚠`). Only `no conventions.json in <dir>` means you wrote
71
+ to the wrong `.fapony/` (re-check `mem where`).
72
+
73
+ ## Later
74
+
75
+ A convention whose fix recurs ≥3 times makes `fapony debt` ask "time for a
76
+ checker?" — that promotion answers to a human, never to this skill. `no-checker`
77
+ means "never ask again": say it only deliberately.
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: lookup-before-edit
3
+ description: Look up unfamiliar files before reading or editing them — exports, line numbers, and importers without reading the whole file. Trigger on /lookup-before-edit and proactively whenever you are about to read, edit, or refactor a file you do not already know.
4
+ ---
5
+
6
+ # Lookup Before Edit — scope first, read second
7
+
8
+ You are about to touch files you do not know. Do not `Read` them whole — look them up first.
9
+ A full-file read on a 500-line module costs ~35k tokens of output; a lookup costs under 1k.
10
+
11
+ ## The one command
12
+
13
+ ```bash
14
+ fapony review-seed --files <f1,f2,dir> [--body <sym>] [--callers <sym>]
15
+ ```
16
+
17
+ What it returns: every export with its line number (uncapped), plus the importers — first 12
18
+ per file, the rest as `(+N)`, so the total is still readable. That is your entry map — then
19
+ `Read` only the line ranges you actually need.
20
+
21
+ ## Narrow it
22
+
23
+ - `--body <sym>[,<sym>]` — declaration slice of those exports (truncated at 80 lines each):
24
+ "what does this do" without the file.
25
+ - `--callers <sym>` — symbol→symbol scan across the importers the static graph sees.
26
+ - Directories expand to the source files under them (cap 40, stated when cut). Paths that do
27
+ not exist are dropped with a notice, not counted silently.
28
+
29
+ ## Limits (do not work around them)
30
+
31
+ - **Exports only.** A non-exported function answers "no export named X in scope" — that is
32
+ the correct answer, not a failure. Read the file for internals.
33
+ - **Static only.** Dynamic use is invisible to the graph; an empty caller list means "not
34
+ seen statically", never "unused".
35
+ - **120 lines total.** Past that the tail is cut (`… (+N lines truncated)`) — and the tail is
36
+ the later files' signatures. Many files at once → split the call, don't trust a cut list.
37
+ - No `fapony` CLI or the call errors → read the file normally and carry on. A hint, not a gate.
38
+
39
+ ## History + debt (same paths, two calls)
40
+
41
+ - `mem_find` with `files: [<same paths>]` — "what was ever decided about this file" (MCP, no CLI spawn).
42
+ - `fapony debt --where <dir|file>` — conventions this path still violates; empty until `conventions.json` exists.
43
+
44
+ ## After the lookup
45
+
46
+ 1. Pick line ranges from the export list, `Read` those slices only.
47
+ 2. Before changing a shape, sum shown + `(+N)` importers — that is your blast radius.
48
+ 3. Do not re-read a file whose mtime has not moved; `grep` it instead.
@@ -117,10 +117,15 @@ them is how a review launders an assumption into a fact.
117
117
 
118
118
  The reader has the diff and is deciding what to do next. Nothing else belongs here.
119
119
 
120
- **Verdict first, then at most 3 findings, at most 4 lines each, then one deferred line.**
120
+ **Verdict first, then at most 10 findings, at most 4 lines each, then one deferred line.**
121
121
  Severity order: blocker → major → nit, and cut the nits entirely when anything structural
122
122
  survived — they dilute the only thing worth reading.
123
123
 
124
+ **A capped report must not read as a complete one.** When more findings survived pass 3 than
125
+ the cap allows, the report ends with one line naming how many were held back and the worst
126
+ severity among them (`+ 4 more (major) — ask`) — never a silent stop at 10. A reader who
127
+ cannot tell "that's all" from "that's the cap" has been told a lie by omission.
128
+
124
129
  ```
125
130
  <ship | fix-then-ship | rework | reject> — the single biggest reason, one sentence.
126
131
 
@@ -130,6 +135,7 @@ survived — they dilute the only thing worth reading.
130
135
  fix: <the minimal change>
131
136
 
132
137
  deferred: <thing> (<where it was specified>) · <thing>
138
+ + <N> more (<worst severity>) ← required only when findings exceeded the cap
133
139
  ```
134
140
 
135
141
  Four lines is a ceiling, not a quota — a finding that fits in two ships in two. Drop `repro:`
@@ -214,9 +220,11 @@ The four passes are the rules. These three are what they fail on in practice:
214
220
 
215
221
  - **Order is not optional.** No line-by-line notes before pass 1, no finding before passes 2-3
216
222
  earned it, nothing stated as fact that pass 4 cannot cite.
217
- - **The budget is binding.** Verdict, ≤3 findings, ≤4 lines each, one deferred line. Over budget
223
+ - **The budget is binding.** Verdict, ≤10 findings, ≤4 lines each, one deferred line. Over budget
218
224
  means you are reporting process. "LGTM" is not an output either — finding nothing ships as the
219
- verdict line plus one line naming what you walked.
225
+ verdict line plus one line naming what you walked. When the cap bites, say how much it bit:
226
+ a `+ N more (<severity>)` line is required — a silent stop at 10 is the same lie as a silent
227
+ stop at 3.
220
228
  - **Forget who wrote it.** The author's reasoning is context, never evidence.
221
229
 
222
230
  ## Example
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 {