fapony 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -8
- package/fapony.ts +4 -2
- package/package.json +1 -1
- 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.ts +9 -4
- package/src/digest/collect.ts +3 -2
- package/src/hook.ts +245 -12
- package/src/init-mem.ts +5 -5
- package/src/init.ts +6 -5
- package/src/install/claude.ts +37 -3
- package/src/install/codex.ts +105 -13
- package/src/install/opencode.ts +111 -1
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +2 -1
- package/src/mcp/evidence.ts +14 -2
- package/src/mcp/transport.ts +1 -1
- package/src/mem/store.ts +6 -4
- package/src/memory.ts +6 -5
- package/src/plan-seed.ts +7 -6
- package/src/setup.ts +3 -2
- package/src/stats/data.ts +6 -18
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`;
|
|
@@ -157,6 +157,7 @@ flowchart LR
|
|
|
157
157
|
B[OpenCode] --> F
|
|
158
158
|
C[ZCode] --> F
|
|
159
159
|
D[Codex] --> F
|
|
160
|
+
E[Cursor] --> F
|
|
160
161
|
F --> G[git facts + session logs]
|
|
161
162
|
G --> S[stats / usage]
|
|
162
163
|
G --> V[verification report]
|
|
@@ -175,6 +176,31 @@ losing a single number.
|
|
|
175
176
|
| Writes | one graded row per unit of work | nothing |
|
|
176
177
|
| Skip it and | there is no fapony | fapony still answers every question |
|
|
177
178
|
|
|
179
|
+
### What runs where
|
|
180
|
+
|
|
181
|
+
`fapony install` wires five clients (Claude Code, OpenCode, Cursor, ZCode, Codex). MCP is the only
|
|
182
|
+
piece all of them get — the hooks and in-process hints are per-client, and the read/edit hints
|
|
183
|
+
arrive **before** the call on Claude Code but **after** it on OpenCode, whose only annotate channel
|
|
184
|
+
is `tool.execute.after`. Nothing here is required: skip the hooks and every MCP tool still answers.
|
|
185
|
+
|
|
186
|
+
| | Claude Code | OpenCode | Cursor | ZCode | Codex |
|
|
187
|
+
|---|---|---|---|---|---|
|
|
188
|
+
| MCP tools — `mem_find` `mem_add` `fapony_usage` `verdict_submit` | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
189
|
+
| Stop hook — refuse to end a turn with ungraded commits | ✅ | — | ✅ | — | ✅ after trust |
|
|
190
|
+
| Read hint — big-file pointer + debt/mem lines | ✅ before | ✅ after | — | — | — |
|
|
191
|
+
| Re-read hint — unchanged repeat read | ✅ before | ✅ after | — | — | — |
|
|
192
|
+
| Edit hint — importer count before a shape change | ✅ before | ✅ after | — | — | — |
|
|
193
|
+
| Commit hint — `git commit` → ungraded-run nudge | — | ✅ after | — | — | — |
|
|
194
|
+
| Skills symlinked into `~/.claude/skills` | ✅ | ✅ | — | — | — |
|
|
195
|
+
| Skills symlinked into `~/.agents/skills` | — | — | — | ✅ | ✅ |
|
|
196
|
+
| `usage-scan` reads this client's session log | ✅ | ✅ | — | ✅ | ✅ |
|
|
197
|
+
|
|
198
|
+
`—` means not wired, not impossible: Cursor has no PreToolUse hook, and ZCode/Codex expose no
|
|
199
|
+
in-process hook surface for read/edit hints yet (Codex's `apply_patch` sends patch text, not
|
|
200
|
+
resolved file paths). Codex hooks require trust via `/hooks` before they run — `fapony install`
|
|
201
|
+
tells you when. The hints live on hooks rather than MCP on purpose — they must fire mid-turn
|
|
202
|
+
without the agent deciding to call anything ([why](#when-to-call-what)).
|
|
203
|
+
|
|
178
204
|
## The ledger — this is the product
|
|
179
205
|
|
|
180
206
|
One habit feeds it: grade a unit of work when it ends. Everything else on this page is
|
|
@@ -202,10 +228,13 @@ sequenceDiagram
|
|
|
202
228
|
```
|
|
203
229
|
|
|
204
230
|
The Stop hook is the only thing fapony *blocks* — once per turn, when a commit ends ungraded.
|
|
205
|
-
It never picks the grade; it cannot see whether the work held up. The
|
|
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
|
|
231
|
+
It never picks the grade; it cannot see whether the work held up. The hints only annotate and never
|
|
232
|
+
block: the **Read** hook adds one factual line when a read is large enough to be cheaper as
|
|
233
|
+
`review-seed`, or when the same file is read again in a session and its mtime has not moved
|
|
234
|
+
(`FAPONY_NO_REREAD_HINT=1` turns the re-read line off); the **Edit** hook names a file's importer
|
|
235
|
+
count, once per session, before you change its shape; OpenCode's **commit** hook nudges after a
|
|
236
|
+
`git commit` that left the run ungraded. Claude Code receives read/edit *before* the call, OpenCode
|
|
237
|
+
*after* it — [What runs where](#what-runs-where) has the full client matrix.
|
|
209
238
|
|
|
210
239
|
### The 4 tools
|
|
211
240
|
|
|
@@ -339,8 +368,9 @@ the claim on faith.
|
|
|
339
368
|
|
|
340
369
|
`fapony install --platform claude` (or `opencode`) symlinks these directories into
|
|
341
370
|
`~/.claude/skills` rather than copying them, so `fapony update` refreshes every client
|
|
342
|
-
at once.
|
|
343
|
-
alone — replace it by hand
|
|
371
|
+
at once. ZCode and Codex get the same skills linked into `~/.agents/skills`. A destination
|
|
372
|
+
that already exists and isn't a fapony link is reported and left alone — replace it by hand
|
|
373
|
+
if you want fapony's version.
|
|
344
374
|
|
|
345
375
|
`plan-with-pony` is vendor-neutral — the SKILL.md *is* the prompt, so pipe it to any agent:
|
|
346
376
|
|
|
@@ -475,8 +505,12 @@ Env overrides: `FAPONY_CONFIG` (config file), `FAPONY_STATE_DIR` (state DB locat
|
|
|
475
505
|
- Memory integration via shell adapter, per project (configurable or default-wired)
|
|
476
506
|
- Opt-in telemetry, off by default ([TELEMETRY.md](https://github.com/kire21b/fapony/blob/main/TELEMETRY.md) lists exactly what leaves the machine)
|
|
477
507
|
- Bun-only; run state in SQLite via `bun:sqlite` (WAL mode)
|
|
508
|
+
- Per-client hooks alongside MCP: Stop hook on Claude Code + Cursor · read/re-read/Edit hints on
|
|
509
|
+
Claude Code + OpenCode · commit hint on OpenCode — [What runs where](#what-runs-where)
|
|
478
510
|
|
|
479
511
|
**Not supported (yet):**
|
|
512
|
+
- PreToolUse hints on Cursor, ZCode or Codex — Cursor has no such hook and the other two expose no
|
|
513
|
+
in-process hook surface for read/edit hints (Codex's `apply_patch` sends patch text, not file paths)
|
|
480
514
|
- A hosted or shared ledger for a team — `runs.worktree` is the only sharing key today, and it's a
|
|
481
515
|
path, not an identity. If you want to try pointing two machines at the same ledger anyway,
|
|
482
516
|
`FAPONY_STATE_DIR` can be set to a synced folder (Syncthing, a shared drive) — but SQLite's WAL
|
package/fapony.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync } from "node:fs";
|
|
|
7
7
|
import { cmdAnalyze } from "./src/analyze.js";
|
|
8
8
|
import { cmdDebt } from "./src/debt.js";
|
|
9
9
|
import { cmdDigest } from "./src/digest/cli.js";
|
|
10
|
-
import { cmdHookReadHint, cmdHookStop } from "./src/hook.js";
|
|
10
|
+
import { cmdHookEditHint, cmdHookReadHint, cmdHookStop } from "./src/hook.js";
|
|
11
11
|
import { cmdInit } from "./src/init.js";
|
|
12
12
|
import { cmdInitMem } from "./src/init-mem.js";
|
|
13
13
|
import { cmdInstall } from "./src/install.js";
|
|
@@ -85,6 +85,8 @@ if (cmd === "analyze") {
|
|
|
85
85
|
await cmdHookStop();
|
|
86
86
|
} else if (cmd === "hook-read-hint") {
|
|
87
87
|
await cmdHookReadHint();
|
|
88
|
+
} else if (cmd === "hook-edit-hint") {
|
|
89
|
+
await cmdHookEditHint();
|
|
88
90
|
} else if (cmd === "mcp") {
|
|
89
91
|
cmdMcp();
|
|
90
92
|
} else if (cmd === "report") {
|
|
@@ -106,7 +108,7 @@ if (cmd === "analyze") {
|
|
|
106
108
|
} else {
|
|
107
109
|
console.error(`fapony: unknown command "${cmd ?? ""}"`);
|
|
108
110
|
console.error(
|
|
109
|
-
"usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|test> [args]",
|
|
111
|
+
"usage: fapony <setup|update|stats|telemetry|init|init-mem|mem|install|report|report-web|usage-scan|usage-web|price-scan|analyze|debt|lint-baseline|plan-seed|review-seed|digest|mcp|hook-stop|hook-read-hint|hook-edit-hint|test> [args]",
|
|
110
112
|
);
|
|
111
113
|
process.exit(1);
|
|
112
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Measurement layer for coding agents \u2014 measure what agents do, verify what they claim. 4 MCP tools, any agent, no loop required",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "delamind (https://github.com/kire21b)",
|
package/src/analyze.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
// src/analyze.ts — `fapony analyze`: structural health diagnosis for a TS/JS project.
|
|
2
2
|
//
|
|
3
3
|
// One file on purpose (plan cap: ≤1 new file in src/). Computes a file-level
|
|
4
|
-
// import graph live with Bun.Transpiler.scan() —
|
|
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 {
|
package/src/db/types.ts
CHANGED
|
@@ -55,9 +55,7 @@ export interface Config {
|
|
|
55
55
|
// state dir override (default: $XDG_CONFIG_HOME/fapony or ~/.config/fapony).
|
|
56
56
|
// $FAPONY_STATE_DIR env wins over this when set.
|
|
57
57
|
stateDir?: string;
|
|
58
|
-
// plan/spec/
|
|
59
|
-
planDir?: string;
|
|
60
|
-
specDir?: string;
|
|
58
|
+
// plan/spec live in .fapony/{plan,spec} — not configurable (gitignored = private).
|
|
61
59
|
doneDir?: string;
|
|
62
60
|
memDir?: string;
|
|
63
61
|
evidenceFile?: string;
|
package/src/debt.ts
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
23
|
import { collectSourceFiles } from "./analyze.js";
|
|
24
|
+
import {
|
|
25
|
+
CONVENTIONS_FILE,
|
|
26
|
+
CONVENTIONS_FILENAME,
|
|
27
|
+
FAPONY_DIR,
|
|
28
|
+
} from "./db/defaults.js";
|
|
24
29
|
import { openDb } from "./db/index.js";
|
|
25
30
|
import { readMemLog, resolveMemDir } from "./memory.js";
|
|
26
31
|
|
|
@@ -52,13 +57,13 @@ export function resolveConventionsPath(worktree: string): string | null {
|
|
|
52
57
|
// Conventions live in the same .fapony/ dir as the mem log — derive from
|
|
53
58
|
// the resolved mem dir so both resolvers cannot drift apart.
|
|
54
59
|
const memDir = resolveMemDir(worktree);
|
|
55
|
-
const base = memDir ? join(memDir, "..") : join(worktree,
|
|
56
|
-
const app = join(base,
|
|
60
|
+
const base = memDir ? join(memDir, "..") : join(worktree, FAPONY_DIR);
|
|
61
|
+
const app = join(base, CONVENTIONS_FILENAME);
|
|
57
62
|
if (existsSync(app)) return app;
|
|
58
63
|
// Monorepo where the app has not scaffolded .fapony/ yet, and single repos
|
|
59
64
|
// that ran `fapony init` at the root — the root file still scopes fine
|
|
60
65
|
// because every `where` is repo-relative.
|
|
61
|
-
const root = join(worktree,
|
|
66
|
+
const root = join(worktree, CONVENTIONS_FILE);
|
|
62
67
|
return existsSync(root) ? root : null;
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -668,7 +673,7 @@ export function worktreeOf(arg: string | undefined): string {
|
|
|
668
673
|
const boundary = gitRoot ? real(gitRoot) : null;
|
|
669
674
|
let dir = base;
|
|
670
675
|
while (true) {
|
|
671
|
-
if (existsSync(join(dir,
|
|
676
|
+
if (existsSync(join(dir, CONVENTIONS_FILE))) return dir;
|
|
672
677
|
if (boundary && real(dir) === boundary) break;
|
|
673
678
|
const parent = dirname(dir);
|
|
674
679
|
if (parent === dir) break;
|
package/src/digest/collect.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import {
|
|
9
|
+
CONFIG_FILENAME,
|
|
9
10
|
doneDir,
|
|
10
11
|
type Event,
|
|
11
12
|
loadConfig,
|
|
@@ -165,8 +166,8 @@ function readPlans(worktree: string): {
|
|
|
165
166
|
ok: boolean;
|
|
166
167
|
detail: string;
|
|
167
168
|
} {
|
|
168
|
-
const config = loadConfig(join(worktree,
|
|
169
|
-
const pDir = join(worktree, planDir(
|
|
169
|
+
const config = loadConfig(join(worktree, CONFIG_FILENAME));
|
|
170
|
+
const pDir = join(worktree, planDir());
|
|
170
171
|
const dDir = join(worktree, doneDir(config));
|
|
171
172
|
|
|
172
173
|
if (!existsSync(pDir)) {
|