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 +50 -15
- package/fapony.ts +12 -3
- package/package.json +1 -1
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/review-pony/SKILL.md +11 -3
- package/src/analyze.ts +162 -4
- package/src/conventions-seed.ts +3 -2
- package/src/db/defaults.ts +13 -5
- package/src/db/getters.ts +8 -6
- package/src/db/load.ts +2 -2
- package/src/db/types.ts +1 -3
- package/src/debt/cli.ts +193 -0
- package/src/debt/format.ts +107 -0
- package/src/debt/index.ts +19 -0
- package/src/debt/load.ts +92 -0
- package/src/debt/promotion.ts +152 -0
- package/src/debt/scan.ts +214 -0
- package/src/debt/types.ts +79 -0
- package/src/detect.ts +92 -0
- package/src/digest/collect.ts +3 -2
- package/src/hook.ts +412 -17
- package/src/init-mem.ts +5 -5
- package/src/init.ts +6 -5
- package/src/install/claude.ts +51 -3
- package/src/install/codex.ts +144 -13
- package/src/install/opencode.ts +209 -1
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +4 -3
- package/src/mcp/evidence.ts +14 -2
- package/src/mcp/tools/index.ts +13 -28
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +12 -63
- package/src/mem/commands/read.ts +18 -1
- package/src/mem/store.ts +6 -4
- package/src/memory.ts +23 -13
- package/src/plan-seed.ts +7 -6
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +4 -3
- package/src/stats/data.ts +6 -18
- package/src/debt.ts +0 -806
- package/src/mcp/tools/usage.ts +0 -211
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
|
|
92
|
-
install of
|
|
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
|
|
206
|
-
one factual line when a read is large enough to be cheaper as
|
|
207
|
-
file is read again in a session and its mtime has not moved
|
|
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
|
|
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
|
|
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 `
|
|
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.
|
|
343
|
-
alone — replace it by hand
|
|
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 {
|
|
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.
|
|
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
|
|
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, ≤
|
|
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() —
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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[][] {
|
package/src/conventions-seed.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
411
|
+
mkdirSync(join(target, FAPONY_DIR), { recursive: true });
|
|
411
412
|
writeFileSync(file, payload);
|
|
412
413
|
return {
|
|
413
414
|
file,
|
package/src/db/defaults.ts
CHANGED
|
@@ -6,13 +6,21 @@ export const DEFAULT_SAFETY_DENY = [
|
|
|
6
6
|
"checkout\\s+--\\s",
|
|
7
7
|
"git\\s+stash",
|
|
8
8
|
];
|
|
9
|
-
|
|
10
|
-
|
|
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 =
|
|
14
|
-
export const DEFAULT_MEM_DIR =
|
|
15
|
-
export const DEFAULT_EVIDENCE_FILE =
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
15
|
+
/** Hardcoded — plan/spec live in .fapony/ (gitignored = private). */
|
|
16
|
+
export function planDir(): string {
|
|
17
|
+
return PLAN_DIR;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
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(),
|
|
23
|
+
return join(process.cwd(), CONFIG_FILENAME);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function freshDefaultConfig(): Config {
|