fapony 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/fapony.ts +3 -116
  2. package/package.json +2 -2
  3. package/skill/move-to-done/SKILL.md +3 -2
  4. package/src/adapters/cli.ts +123 -0
  5. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  6. package/src/adapters/hooks/context-data.ts +102 -0
  7. package/src/adapters/hooks/edit-hint.ts +195 -0
  8. package/src/adapters/hooks/git-autonomy.ts +178 -0
  9. package/src/adapters/hooks/index.ts +79 -0
  10. package/src/adapters/hooks/mv-guard.ts +52 -0
  11. package/src/adapters/hooks/read-hint.ts +383 -0
  12. package/src/adapters/hooks/session-start.ts +101 -0
  13. package/src/adapters/hooks/stop.ts +302 -0
  14. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  15. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  17. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  19. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  20. package/src/adapters/mcp/types.ts +18 -0
  21. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  22. package/src/analyze.ts +1 -1
  23. package/src/conventions-seed.ts +1 -1
  24. package/src/core/config.ts +199 -0
  25. package/src/core/debt-format.ts +107 -0
  26. package/src/core/debt-types.ts +79 -0
  27. package/src/core/defaults.ts +8 -0
  28. package/src/core/enums.ts +34 -0
  29. package/src/core/format.ts +33 -0
  30. package/src/core/hint-log.ts +68 -0
  31. package/src/core/hook-helpers.ts +31 -0
  32. package/src/core/mem-log.ts +357 -0
  33. package/src/core/parse.ts +71 -0
  34. package/src/core/pricing.ts +217 -0
  35. package/src/core/safety.ts +18 -0
  36. package/src/core/types.ts +142 -0
  37. package/src/core/util.ts +105 -0
  38. package/src/db/store.ts +2 -2
  39. package/src/debt/cli.ts +1 -1
  40. package/src/debt/format.ts +2 -107
  41. package/src/debt/load.ts +1 -1
  42. package/src/debt/promotion.ts +1 -1
  43. package/src/debt/types.ts +14 -79
  44. package/src/digest/collect.ts +4 -3
  45. package/src/gate.ts +2 -2
  46. package/src/gates.ts +1 -1
  47. package/src/hook.ts +69 -1424
  48. package/src/init-mem.ts +1 -1
  49. package/src/init.ts +1 -1
  50. package/src/install/claude.ts +17 -0
  51. package/src/install/opencode.ts +114 -0
  52. package/src/install.ts +12 -5
  53. package/src/lint-baseline.ts +1 -2
  54. package/src/map.ts +29 -8
  55. package/src/mem/commands/plan.ts +70 -42
  56. package/src/mem/store.ts +5 -1
  57. package/src/memory.ts +24 -387
  58. package/src/parse.ts +9 -71
  59. package/src/price/fetch.ts +4 -16
  60. package/src/price/resolve.ts +12 -213
  61. package/src/report/cli.ts +3 -3
  62. package/src/safety.ts +2 -18
  63. package/src/seed/plan-seed.ts +8 -4
  64. package/src/seed/primitives.ts +1 -7
  65. package/src/session/types.ts +14 -128
  66. package/src/setup.ts +1 -1
  67. package/src/stats/data.ts +3 -3
  68. package/src/telemetry.ts +2 -2
  69. package/src/usage/cache.ts +1 -2
  70. package/src/usage/cli.ts +1 -1
  71. package/src/usage/scan.ts +2 -1
  72. package/src/util.ts +10 -93
  73. package/src/web/html.ts +2 -33
  74. package/src/db/defaults.ts +0 -34
  75. package/src/db/getters.ts +0 -35
  76. package/src/db/index.ts +0 -7
  77. package/src/db/load.ts +0 -57
  78. package/src/db/types.ts +0 -77
  79. package/src/mcp/types.ts +0 -54
  80. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  81. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
package/src/memory.ts CHANGED
@@ -1,15 +1,27 @@
1
- // src/memory.ts — memory domain: config.memory.* shell adapter helpers +
2
- // the shared mem-log reader (digest and project_health_context both need it).
3
- // ponytail: dedupe close-command logic that was copy-pasted in run.ts + stop.ts
1
+ // src/memory.ts — memory domain: config.memory.* shell adapter helpers.
2
+ // The shared mem-log reader now lives in src/core/mem-log.ts (PLAN-lib-layer chunk 2b).
4
3
 
5
4
  import { execSync } from "node:child_process";
6
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
7
- import { dirname, isAbsolute, join, resolve } from "node:path";
8
- import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "./db/defaults.js";
9
- import { type Config, loadConfig, safetyDeny } from "./db/index.js";
5
+ import type { Config } from "./core/config.js";
10
6
  import { assertSafe } from "./safety.js";
11
7
  import { templateArgs } from "./util.js";
12
8
 
9
+ // Re-export mem-log reader for existing callers (gate, hook, digest, mcp, seed, mem).
10
+ export {
11
+ type MemDirResult,
12
+ type MemRow,
13
+ readMemLog,
14
+ readRecentMemDecisions,
15
+ resolveMemDir,
16
+ whereMemDir,
17
+ } from "./core/mem-log.js";
18
+
19
+ // Internal import used by resolveMemoryConfig below.
20
+ import {
21
+ resolveMemDir as _resolveMemDir,
22
+ walkUpForMemDir,
23
+ } from "./core/mem-log.js";
24
+
13
25
  /** Default memory commands — built into fapony via `fapony mem <sub>`. */
14
26
  export const DEFAULT_MEMORY: Config["memory"] = {
15
27
  claim: ["fapony", "mem", "claim", "{id}"],
@@ -23,10 +35,6 @@ export const DEFAULT_MEMORY: Config["memory"] = {
23
35
  * - explicit config.memory wins if set
24
36
  * - fallback: a mem log dir exists (any `.fapony/.memory/`) → DEFAULT_MEMORY
25
37
  * - otherwise null (no memory)
26
- *
27
- * Since PLAN-agent-one-call the code is built into fapony (`fapony mem`), so the
28
- * old gate on `.fapony/.memory/mem.ts` no longer means anything — that file is
29
- * not scaffolded and `init-mem` deletes it. Gate on the dir the writer needs.
30
38
  */
31
39
  export function resolveMemoryConfig(
32
40
  config: Config,
@@ -34,9 +42,8 @@ export function resolveMemoryConfig(
34
42
  ): Config["memory"] {
35
43
  if (config.memory) return config.memory;
36
44
  // A real log is not required for wiring: `fapony init` scaffolds an empty
37
- // `.fapony/.memory/` and the writer creates the first log there. Resolution
38
- // proper (above) still skips empty dirs, so it cannot pick the wrong one.
39
- if (resolveMemDir(worktree) || walkUpForMemDir(worktree, true)) {
45
+ // `.fapony/.memory/` and the writer creates the first log there.
46
+ if (_resolveMemDir(worktree) || walkUpForMemDir(worktree, true)) {
40
47
  return DEFAULT_MEMORY;
41
48
  }
42
49
  return null;
@@ -52,7 +59,7 @@ export function closeMemory(
52
59
  if (!mem) return;
53
60
  try {
54
61
  const cmd = templateArgs(mem.close, { id: memId, msg });
55
- assertSafe(cmd, safetyDeny(config));
62
+ assertSafe(cmd, config.safety?.deny ?? []);
56
63
  execSync(cmd.join(" "), {
57
64
  cwd: worktree,
58
65
  stdio: "ignore",
@@ -72,7 +79,7 @@ export function claimMemory(
72
79
  if (!mem) return false;
73
80
  try {
74
81
  const cmd = templateArgs(mem.claim, { id: memId });
75
- assertSafe(cmd, safetyDeny(config));
82
+ assertSafe(cmd, config.safety?.deny ?? []);
76
83
  execSync(cmd.join(" "), {
77
84
  cwd: worktree,
78
85
  stdio: ["pipe", "pipe", "pipe"],
@@ -89,7 +96,7 @@ export function kickoffMemory(config: Config, worktree: string): string | null {
89
96
  const mem = resolveMemoryConfig(config, worktree);
90
97
  if (!mem?.kickoff) return null;
91
98
  try {
92
- assertSafe(mem.kickoff, safetyDeny(config));
99
+ assertSafe(mem.kickoff, config.safety?.deny ?? []);
93
100
  return execSync(mem.kickoff.join(" "), {
94
101
  cwd: worktree,
95
102
  encoding: "utf-8",
@@ -99,373 +106,3 @@ export function kickoffMemory(config: Config, worktree: string): string | null {
99
106
  return null;
100
107
  }
101
108
  }
102
-
103
- // --- mem log reading ---
104
- //
105
- // Shared by `fapony digest` and `project_health_context`: both need the same
106
- // directory discovery (new `.fapony/.memory`, legacy `.memory/` fallback) and
107
- // the same rotated-file handling. Kept here so the two callers cannot drift.
108
-
109
- export interface MemRow {
110
- ts: string;
111
- agent: string;
112
- kind: string;
113
- text: string;
114
- spec?: string;
115
- id?: string;
116
- ref?: string;
117
- /** Files the row is about — written by `mem add --files` (PLAN-convention-debt chunk 3). */
118
- files?: string[];
119
- }
120
-
121
- interface RawMemRow {
122
- ts?: string;
123
- agent?: string;
124
- kind?: string;
125
- text?: string;
126
- spec?: string;
127
- id?: string;
128
- ref?: string;
129
- files?: unknown;
130
- }
131
-
132
- /**
133
- * A directory is a mem dir only when it holds a `log.jsonl` or a named
134
- * `log.<person>.jsonl` (rotated `log.YYYY-MM-DD.jsonl` archives count too).
135
- * A `.fapony/.memory/` scaffolded empty by `fapony init` is not a candidate —
136
- * otherwise it would shadow an ancestor that holds the real log (SPEC §1).
137
- */
138
- function hasMemLogs(dir: string): boolean {
139
- try {
140
- return readdirSync(dir).some(
141
- (f) => f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f),
142
- );
143
- } catch {
144
- return false;
145
- }
146
- }
147
-
148
- /** Physical path (symlinks resolved) so start and repo root compare like with
149
- * like; falls back to a lexical resolve when the path does not exist yet. */
150
- function physical(p: string): string {
151
- try {
152
- return realpathSync(p);
153
- } catch {
154
- return resolve(p);
155
- }
156
- }
157
-
158
- /** `git rev-parse --show-toplevel` from a directory, or null outside a repo. */
159
- function gitRootOf(fromDir: string): string | null {
160
- try {
161
- const root = execSync("git rev-parse --show-toplevel", {
162
- cwd: fromDir,
163
- encoding: "utf-8",
164
- stdio: ["pipe", "pipe", "pipe"],
165
- }).trim();
166
- return root || null;
167
- } catch {
168
- return null;
169
- }
170
- }
171
-
172
- /**
173
- * The lexical ancestor of `fromDir` that is the git repo root, or null outside
174
- * a repo. `git rev-parse` returns a physical path (it resolves /var → /private/
175
- * var on macOS), so the walk compares `physical(dir)` against it and returns the
176
- * path in the caller's own lexical form — the returned dir must match what the
177
- * caller passed in, not a canonicalized stranger.
178
- */
179
- function repoRootOf(fromDir: string): string | null {
180
- const start = resolve(fromDir);
181
- const gitRoot = gitRootOf(start);
182
- if (!gitRoot) return null;
183
- const physicalRoot = physical(gitRoot);
184
- let dir = start;
185
- while (true) {
186
- if (physical(dir) === physicalRoot) return dir;
187
- const parent = dirname(dir);
188
- if (parent === dir) return null;
189
- dir = parent;
190
- }
191
- }
192
-
193
- /**
194
- * Locate the nearest `.fapony/.memory/` by walking up from `fromDir`.
195
- *
196
- * The walk stops at the git repo root (SPEC §1) — a mem dir in a parent
197
- * checkout is never ours. Outside a repo it stops at the filesystem root.
198
- * `acceptEmpty` is for the default-wiring check, which only needs the dir the
199
- * writer will use (log or not); the resolver itself requires a real log.
200
- */
201
- function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
202
- const start = resolve(fromDir);
203
- const boundary = repoRootOf(start) ?? "/";
204
- let dir = start;
205
- while (true) {
206
- const candidate = join(dir, DEFAULT_MEM_DIR);
207
- if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
208
- return candidate;
209
- }
210
- if (dir === boundary) break;
211
- const parent = dirname(dir);
212
- if (parent === dir) break;
213
- dir = parent;
214
- }
215
- return null;
216
- }
217
-
218
- export interface MemDirResult {
219
- dir: string | null;
220
- step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
221
- /**
222
- * `.fapony/.memory/` dirs under the repo root that hold logs but are not in
223
- * scope from `fromDir`. Set for "ambiguous" (two or more, the refusal) and
224
- * also for "repo-root"/"none" (one) — a caller that reports "nothing
225
- * recorded here" is lying when a single app-scoped log exists one level
226
- * down, and the honest line needs the path to say where.
227
- */
228
- candidates?: string[];
229
- }
230
-
231
- // Dirs that never hold a project mem log and are expensive to walk.
232
- const SCAN_SKIP = new Set([
233
- "node_modules",
234
- ".git",
235
- "dist",
236
- "build",
237
- "coverage",
238
- "vendor",
239
- "out",
240
- "target",
241
- ".next",
242
- ".turbo",
243
- ".cache",
244
- ]);
245
-
246
- /**
247
- * Every `.fapony/.memory/` under `root` that holds a real log, bounded to a
248
- * shallow walk. Used only to detect the ambiguous monorepo layout (SPEC §1
249
- * fail example): if two or more app-scoped logs exist and nothing at/above cwd
250
- * holds one, the caller must refuse rather than start a third log at the root.
251
- */
252
- function findMemDirsUnder(root: string): string[] {
253
- const found: string[] = [];
254
- const walk = (dir: string, depth: number): void => {
255
- if (depth > 5) return;
256
- let entries: import("node:fs").Dirent[];
257
- try {
258
- entries = readdirSync(dir, { withFileTypes: true });
259
- } catch {
260
- return;
261
- }
262
- for (const e of entries) {
263
- if (!e.isDirectory()) continue;
264
- if (e.name === FAPONY_DIR) {
265
- const candidate = join(dir, DEFAULT_MEM_DIR);
266
- if (hasMemLogs(candidate)) found.push(candidate);
267
- continue;
268
- }
269
- if (e.name.startsWith(".") || SCAN_SKIP.has(e.name)) continue;
270
- walk(join(dir, e.name), depth + 1);
271
- }
272
- };
273
- walk(root, 0);
274
- return found;
275
- }
276
-
277
- /**
278
- * The single resolver: returns the mem dir and which step won.
279
- *
280
- * Resolution order (SPEC §1):
281
- * 1. `--mem-dir <path>` (wins over everything)
282
- * 2. `paths.memDir` in the **repo-root** `fapony.config.json` (relative to root)
283
- * 3. walk up from cwd for the first `.fapony/.memory/` that holds a real log
284
- * 4. `<repo root>/.fapony/.memory/`
285
- *
286
- * `.memory/` (outside `.fapony/`) and the monorepo app-guess (`wt-<app>`,
287
- * `MEM_APP`) are both dead — they are not in the order and never return.
288
- */
289
- function resolveMemDirFrom(
290
- fromDir: string,
291
- explicitOverride?: string,
292
- ): MemDirResult {
293
- // Step 1: --mem-dir flag
294
- if (explicitOverride) {
295
- return {
296
- dir: existsSync(explicitOverride) ? resolve(explicitOverride) : null,
297
- step: "flag",
298
- };
299
- }
300
-
301
- const cwd = resolve(fromDir);
302
- const root = repoRootOf(cwd);
303
-
304
- // Step 2: paths.memDir in the repo-root config (FAPONY_CONFIG is not a
305
- // path source here — spec §1 pins this to fapony.config.json)
306
- const configDir = root ?? cwd;
307
- try {
308
- const configPath = join(configDir, CONFIG_FILENAME);
309
- if (existsSync(configPath)) {
310
- const config = loadConfig(configPath);
311
- if (config?.paths?.memDir) {
312
- const absCfg = isAbsolute(config.paths.memDir)
313
- ? config.paths.memDir
314
- : join(configDir, config.paths.memDir);
315
- if (existsSync(absCfg)) return { dir: absCfg, step: "config" };
316
- }
317
- }
318
- } catch {
319
- // no config or unreadable — continue
320
- }
321
-
322
- // Step 3: walk up from cwd, only a dir with a real log counts
323
- const walked = walkUpForMemDir(cwd, false);
324
- if (walked) return { dir: walked, step: "walk-up" };
325
-
326
- // Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
327
- // has two or more app-scoped ones — refuse instead of silently creating a
328
- // third log at the root that no app-scoped reader will ever see.
329
- // One candidate is not ambiguous, so resolution is unchanged — but it still
330
- // travels back with the result: out-of-scope is not the same as absent.
331
- const candidates = root ? findMemDirsUnder(root) : [];
332
- if (candidates.length >= 2) {
333
- return { dir: null, step: "ambiguous", candidates };
334
- }
335
- const outOfScope = candidates.length ? { candidates } : {};
336
-
337
- // Step 4: <repo root>/.fapony/.memory/ — where a new log is created
338
- if (root) {
339
- const rootDir = join(root, DEFAULT_MEM_DIR);
340
- if (existsSync(rootDir)) {
341
- return { dir: rootDir, step: "repo-root", ...outOfScope };
342
- }
343
- }
344
-
345
- return { dir: null, step: "none", ...outOfScope };
346
- }
347
-
348
- export function resolveMemDir(
349
- worktree?: string,
350
- explicitOverride?: string,
351
- ): string | null {
352
- return resolveMemDirFrom(worktree ?? process.cwd(), explicitOverride).dir;
353
- }
354
-
355
- /** Resolve and report which step won — for `fapony mem where`. */
356
- export function whereMemDir(
357
- fromDir?: string,
358
- explicitOverride?: string,
359
- ): MemDirResult {
360
- return resolveMemDirFrom(fromDir ?? process.cwd(), explicitOverride);
361
- }
362
-
363
- /**
364
- * Read every `log*.jsonl` row under the worktree's memory dir, newest first.
365
- * `sinceIso` (exclusive) drops older rows; omit it to read the whole log.
366
- * Malformed/unreadable rows are counted, never thrown — a corrupt line must
367
- * not take down the caller.
368
- */
369
- export function readMemLog(
370
- worktree: string,
371
- sinceIso?: string,
372
- ): { rows: MemRow[]; skipped: number; filesFound: number } {
373
- const dir = resolveMemDir(worktree);
374
- if (!dir) return { rows: [], skipped: 0, filesFound: 0 };
375
-
376
- // Every log*.jsonl, rotated archives (log.YYYY-MM-DD.jsonl) included. They
377
- // used to be excluded, which meant the day a repo crossed the rotate
378
- // threshold mem_find silently forgot everything already closed — the rows
379
- // most worth recalling. Rotate exists to keep the live file small, not to
380
- // decide what is still remembered.
381
- const isLogFile = (f: string): boolean =>
382
- f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f);
383
-
384
- let files: string[];
385
- try {
386
- files = readdirSync(dir)
387
- .filter(isLogFile)
388
- .sort()
389
- .map((f) => join(dir, f));
390
- } catch {
391
- return { rows: [], skipped: 0, filesFound: 0 };
392
- }
393
-
394
- if (files.length === 0) return { rows: [], skipped: 0, filesFound: 0 };
395
-
396
- let skipped = 0;
397
- const all: MemRow[] = [];
398
-
399
- for (const file of files) {
400
- let raw: string;
401
- try {
402
- raw = readFileSync(file, "utf-8");
403
- } catch {
404
- continue;
405
- }
406
- const lines = raw.split("\n").filter(Boolean);
407
- for (const line of lines) {
408
- let parsed: RawMemRow;
409
- try {
410
- parsed = JSON.parse(line) as RawMemRow;
411
- } catch {
412
- skipped++;
413
- continue;
414
- }
415
- if (!parsed.ts || !parsed.kind) {
416
- skipped++;
417
- continue;
418
- }
419
- if (sinceIso && parsed.ts < sinceIso) continue;
420
- all.push({
421
- ts: parsed.ts,
422
- agent: parsed.agent ?? "unknown",
423
- kind: parsed.kind,
424
- text: parsed.text ?? "",
425
- spec: parsed.spec,
426
- id: parsed.id,
427
- ref: parsed.ref,
428
- ...(Array.isArray(parsed.files)
429
- ? { files: parsed.files.filter((f) => typeof f === "string") }
430
- : {}),
431
- });
432
- }
433
- }
434
-
435
- all.sort((a, b) => b.ts.localeCompare(a.ts)); // newest first
436
- return { rows: all, skipped, filesFound: files.length };
437
- }
438
-
439
- /**
440
- * Decisions for the pre-edit context summary — newest first, capped at `limit`.
441
- *
442
- * `keywords` (usual suspects: the files about to be touched) are a
443
- * *preference*, not a filter: decisions mentioning one surface first, but when
444
- * nothing matches the newest decisions still come back. A summary that goes
445
- * silent because a path never appeared in a decision is worse than a recent one.
446
- */
447
- export function readRecentMemDecisions(
448
- worktree: string,
449
- limit: number,
450
- keywords?: string[],
451
- ): MemRow[] {
452
- let decisions: MemRow[];
453
- try {
454
- decisions = readMemLog(worktree).rows.filter((r) => r.kind === "decision");
455
- } catch {
456
- return [];
457
- }
458
- if (decisions.length === 0) return [];
459
-
460
- const kws = (keywords ?? [])
461
- .map((k) => k.toLowerCase())
462
- .filter((k) => k.length > 0);
463
- if (kws.length > 0) {
464
- const hits = decisions.filter((r) => {
465
- const hay = `${r.text}\n${r.spec ?? ""}`.toLowerCase();
466
- return kws.some((k) => hay.includes(k));
467
- });
468
- if (hits.length > 0) return hits.slice(0, limit);
469
- }
470
- return decisions.slice(0, limit);
471
- }
package/src/parse.ts CHANGED
@@ -1,71 +1,9 @@
1
- // src/parse.ts — grades, scores, and gate-event JSON parsing.
2
- // Marker-text parsers (parseGateVerdict/parsePlanUpdate) were removed with the
3
- // CLI loop: nothing produces VERDICT:/NEXT-PROMPT/FILE_DONE stdout anymore —
4
- // gate events are JSON, read via parseGateEventData.
5
-
6
- /** Locked grade vocabulary — additive-only (append, never rename/remove). */
7
- export type VerdictGrade =
8
- | "pass-excellent"
9
- | "pass-good"
10
- | "pass-adequate"
11
- | "pass"
12
- | "fail"
13
- | "uncertain";
14
-
15
- export const VERDICT_GRADES: ReadonlySet<string> = new Set<VerdictGrade>([
16
- "pass-excellent",
17
- "pass-good",
18
- "pass-adequate",
19
- "pass",
20
- "fail",
21
- "uncertain",
22
- ]);
23
-
24
- /** True when verdict belongs to the pass family (any pass-* variant). */
25
- export function isPassFamily(v: string): v is VerdictGrade {
26
- return v.startsWith("pass");
27
- }
28
-
29
- /**
30
- * Locked qualityScore mapping — read-time only (never written to events).
31
- * Values from SPEC-verdict-protocol §qualityScore (additive-only, never change).
32
- */
33
- const SCORE_MAP: Record<VerdictGrade, number> = {
34
- "pass-excellent": 5,
35
- "pass-good": 4,
36
- "pass-adequate": 3,
37
- pass: 3,
38
- fail: 0,
39
- uncertain: 1,
40
- };
41
-
42
- export function qualityScore(grade: VerdictGrade): number {
43
- return SCORE_MAP[grade] ?? 0;
44
- }
45
-
46
- export interface GateVerdict {
47
- verdict: VerdictGrade;
48
- note: string;
49
- }
50
-
51
- /**
52
- * Read a verdict back from a stored gate event (`kind='gate'`).
53
- * Gate events are JSON (`{verdict, note, round}` from gateOnce) — NOT
54
- * reviewer stdout. Returns null for missing/unparseable data or unknown grades.
55
- */
56
- export function parseGateEventData(data: string | null): GateVerdict | null {
57
- if (!data) return null;
58
- let parsed: unknown;
59
- try {
60
- parsed = JSON.parse(data);
61
- } catch {
62
- return null;
63
- }
64
- if (!parsed || typeof parsed !== "object") return null;
65
- const { verdict, note } = parsed as { verdict?: unknown; note?: unknown };
66
- if (typeof verdict !== "string" || !VERDICT_GRADES.has(verdict)) return null;
67
- return {
68
- verdict: verdict as VerdictGrade,
69
- note: typeof note === "string" ? note : "",
70
- };
71
- }
1
+ // shim — moved to src/core/parse.ts
2
+ export {
3
+ type GateVerdict,
4
+ isPassFamily,
5
+ parseGateEventData,
6
+ qualityScore,
7
+ VERDICT_GRADES,
8
+ type VerdictGrade,
9
+ } from "./core/parse.js";
@@ -13,26 +13,14 @@ import {
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
- import { faponyDir } from "../db/load.js";
17
- import type { Config } from "../db/types.js";
16
+ import { type Config, faponyDir } from "../core/config.js";
17
+ import type { ModelRates, PriceTable } from "../core/types.js";
18
+
19
+ export type { ModelRates, PriceTable } from "../core/types.js";
18
20
 
19
21
  const PRICES_FILENAME = "prices.json";
20
22
  const MODELS_URL = "https://openrouter.ai/api/v1/models";
21
23
 
22
- /** per-token rates (dollars) — every rate comes straight from OpenRouter, no guessing */
23
- export interface ModelRates {
24
- input: number;
25
- output: number;
26
- cacheRead: number;
27
- /** null = the table does not provide it → use the input rate instead (see calcCost) */
28
- cacheWrite: number | null;
29
- }
30
-
31
- export interface PriceTable {
32
- fetched_at: string;
33
- models: Record<string, ModelRates>;
34
- }
35
-
36
24
  export function pricesPath(config?: Config): string {
37
25
  return join(faponyDir(config), PRICES_FILENAME);
38
26
  }