fapony 0.3.0 → 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 (96) hide show
  1. package/README.md +87 -65
  2. package/fapony.ts +3 -116
  3. package/package.json +6 -5
  4. package/skill/move-to-done/SKILL.md +22 -32
  5. package/skill/review-pony/SKILL.md +27 -58
  6. package/src/adapters/cli.ts +123 -0
  7. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  8. package/src/adapters/hooks/context-data.ts +102 -0
  9. package/src/adapters/hooks/edit-hint.ts +195 -0
  10. package/src/adapters/hooks/git-autonomy.ts +178 -0
  11. package/src/adapters/hooks/index.ts +79 -0
  12. package/src/adapters/hooks/mv-guard.ts +52 -0
  13. package/src/adapters/hooks/read-hint.ts +383 -0
  14. package/src/adapters/hooks/session-start.ts +101 -0
  15. package/src/adapters/hooks/stop.ts +302 -0
  16. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
  18. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  20. package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
  21. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  22. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  23. package/src/{mcp → adapters/mcp}/transport.ts +4 -39
  24. package/src/adapters/mcp/types.ts +18 -0
  25. package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
  26. package/src/analyze.ts +2 -2
  27. package/src/conventions-seed.ts +1 -1
  28. package/src/core/config.ts +199 -0
  29. package/src/core/debt-format.ts +107 -0
  30. package/src/core/debt-types.ts +79 -0
  31. package/src/core/defaults.ts +8 -0
  32. package/src/core/enums.ts +34 -0
  33. package/src/core/format.ts +33 -0
  34. package/src/core/hint-log.ts +68 -0
  35. package/src/core/hook-helpers.ts +31 -0
  36. package/src/core/mem-log.ts +357 -0
  37. package/src/core/parse.ts +71 -0
  38. package/src/core/pricing.ts +217 -0
  39. package/src/core/safety.ts +18 -0
  40. package/src/core/types.ts +142 -0
  41. package/src/core/util.ts +105 -0
  42. package/src/db/store.ts +2 -2
  43. package/src/debt/cli.ts +1 -1
  44. package/src/debt/format.ts +2 -107
  45. package/src/debt/load.ts +1 -1
  46. package/src/debt/promotion.ts +1 -1
  47. package/src/debt/types.ts +14 -79
  48. package/src/digest/collect.ts +4 -3
  49. package/src/gate.ts +5 -5
  50. package/src/gates.ts +1 -1
  51. package/src/hook.ts +69 -1337
  52. package/src/init-mem.ts +58 -72
  53. package/src/init.ts +13 -17
  54. package/src/install/antigravity.ts +112 -0
  55. package/src/install/claude.ts +19 -123
  56. package/src/install/detect.ts +17 -7
  57. package/src/install/opencode.ts +167 -26
  58. package/src/install.ts +23 -7
  59. package/src/lint-baseline.ts +1 -2
  60. package/src/map.ts +29 -8
  61. package/src/mem/commands/plan.ts +70 -42
  62. package/src/mem/commands/read.ts +219 -146
  63. package/src/mem/index.ts +4 -13
  64. package/src/mem/store.ts +5 -1
  65. package/src/memory.ts +24 -387
  66. package/src/parse.ts +9 -71
  67. package/src/price/fetch.ts +4 -16
  68. package/src/price/resolve.ts +12 -213
  69. package/src/report/cli.ts +3 -3
  70. package/src/safety.ts +2 -18
  71. package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
  72. package/src/seed/primitives.ts +60 -0
  73. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  74. package/src/session/types.ts +14 -128
  75. package/src/setup.ts +1 -1
  76. package/src/stats/data.ts +4 -4
  77. package/src/telemetry.ts +3 -3
  78. package/src/usage/cache.ts +1 -2
  79. package/src/usage/cli.ts +1 -1
  80. package/src/usage/scan.ts +2 -1
  81. package/src/util.ts +10 -32
  82. package/src/web/html.ts +2 -33
  83. package/templates/SPEC.md +8 -1
  84. package/images/logo.png +0 -0
  85. package/images/logo.webp +0 -0
  86. package/images/logo@400.webp +0 -0
  87. package/images/sample.webp +0 -0
  88. package/images/summary.webp +0 -0
  89. package/src/db/defaults.ts +0 -34
  90. package/src/db/getters.ts +0 -35
  91. package/src/db/index.ts +0 -7
  92. package/src/db/load.ts +0 -57
  93. package/src/db/types.ts +0 -77
  94. package/src/math.ts +0 -13
  95. package/src/mcp/tools/verdict.ts +0 -161
  96. package/src/mcp/types.ts +0 -54
@@ -81,9 +81,15 @@ function isConfigured(mcp: Record<string, unknown> | undefined): boolean {
81
81
  );
82
82
  }
83
83
 
84
+ export interface OpencodeInstallOpts {
85
+ /** Opt-in only: write the git-autonomy rewrite plugin. Never default. */
86
+ gitAutonomy?: boolean;
87
+ }
88
+
84
89
  export function cmdInstallOpencode(
85
90
  dryRun: boolean,
86
91
  deps: InstallDeps = {},
92
+ opts: OpencodeInstallOpts = {},
87
93
  ): void {
88
94
  const exitFn = deps.exit ?? defaultExit;
89
95
  const getHome = deps.homedir ?? homedir;
@@ -115,6 +121,7 @@ export function cmdInstallOpencode(
115
121
  installCommitHintPlugin(dryRun, getHome);
116
122
  installEditHintPlugin(dryRun, getHome);
117
123
  installSessionStartPlugin(dryRun, getHome);
124
+ if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
118
125
  return;
119
126
  }
120
127
 
@@ -140,6 +147,7 @@ export function cmdInstallOpencode(
140
147
  installCommitHintPlugin(dryRun, getHome);
141
148
  installEditHintPlugin(dryRun, getHome);
142
149
  installSessionStartPlugin(dryRun, getHome);
150
+ if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
143
151
  return;
144
152
  }
145
153
 
@@ -163,6 +171,7 @@ export function cmdInstallOpencode(
163
171
  installCommitHintPlugin(dryRun, getHome);
164
172
  installEditHintPlugin(dryRun, getHome);
165
173
  installSessionStartPlugin(dryRun, getHome);
174
+ if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
166
175
  }
167
176
 
168
177
  /**
@@ -335,10 +344,10 @@ export const FaponyCommitHint = async ({ directory }) => {
335
344
  export function editHintPluginSource(root: string): string {
336
345
  const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
337
346
  return `// fapony edit hint — annotates edits to source files that have importers
338
- // with the importer count plus the review-seed command that lists them.
347
+ // with the importer count, plus mem/debt context rows about the file.
339
348
  // Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
340
349
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
341
- import { editHintFor, recordHintFire } from ${hookModule};
350
+ import { editHintFor, readContextData, recordHintFire } from ${hookModule};
342
351
 
343
352
  export const FaponyEditHint = async ({ directory }) => {
344
353
  return {
@@ -350,16 +359,25 @@ export const FaponyEditHint = async ({ directory }) => {
350
359
  // spends the per-session dedupe row or logs a fire that never showed.
351
360
  if (typeof output.output !== "string") return;
352
361
  const filePath = input.args?.filePath;
362
+ const parts = [];
353
363
  const hint = editHintFor({
354
364
  filePath,
355
365
  cwd: directory,
356
366
  session: input.sessionID,
357
367
  });
368
+ if (hint) parts.push(hint);
369
+ // Attach mem/debt context (same as read hint — annotate only, cap 5 lines)
370
+ const ctx = readContextData(filePath, directory);
371
+ if (ctx) {
372
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
373
+ parts.push(line);
374
+ }
375
+ }
376
+ if (parts.length > 0) {
377
+ output.output = output.output + "\\n" + parts.join("\\n");
358
378
 
359
- // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
360
- // After output — best-effort, never block the hint.
361
- if (hint) {
362
- output.output = output.output + "\\n" + hint;
379
+ // --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
380
+ // After output — best-effort, never block the hint.
363
381
  try {
364
382
  const { spawnSync } = require("node:child_process");
365
383
  const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
@@ -374,6 +392,12 @@ export const FaponyEditHint = async ({ directory }) => {
374
392
  } catch { return null; }
375
393
  })() : null;
376
394
  recordHintFire({ ts: new Date().toISOString(), worktree, surface: "edit", file: rel, count: 1 });
395
+ if (ctx?.debtIds?.length) {
396
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
397
+ }
398
+ if (ctx?.memLines?.length) {
399
+ recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
400
+ }
377
401
  }
378
402
  } catch {
379
403
  // best-effort
@@ -488,8 +512,8 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
488
512
  }
489
513
 
490
514
  /**
491
- * OpenCode plugin — session-start context via `fapony mem kickoff`, the
492
- * OpenCode parity of Claude's SessionStart hook.
515
+ * OpenCode plugin — session-start context, the parity of Claude's SessionStart
516
+ * hook.
493
517
  *
494
518
  * Why `experimental.chat.system.transform` and not the `event` hook on
495
519
  * `session.created`: the event hook is observer-only (returns void, no
@@ -500,19 +524,29 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
500
524
  * the same shape as every other OpenCode hint (annotate-after), and arguably
501
525
  * better: sessions that never dispatch never pay the spawn.
502
526
  *
527
+ * The guard (no mem log = silence), the kickoff spawn and the cap all live in
528
+ * `sessionStartContext` (src/hook.ts), imported at runtime like the read /
529
+ * commit / edit hint plugins — so `git pull` updates this client too. It used
530
+ * to bake `mem kickoff` into the generated body and push stdout, which injected
531
+ * kickoff's "# <path> — 0 entries" header into every session of every repo
532
+ * with no mem log (bug mub2ezhi) and could not be fixed by a pull. One import,
533
+ * one implementation, shared with the Claude/Codex hooks.
534
+ *
503
535
  * Once per session (closure Set — the factory runs once per server), silent
504
- * when kickoff exits nonzero or prints nothing (no mem log in scope), never
505
- * throws. `process.execPath` is the bun running opencode itself — no PATH
506
- * dependency. Logic shared via `capContext` in src/hook.ts, same one-copy
507
- * shape as the other three plugins.
536
+ * when `sessionStartContext` returns null (no mem log in scope), never throws.
537
+ * `process.execPath` is the bun running opencode itself — no PATH dependency.
538
+ * Verified against OpenCode 1.18.29; `experimental.chat.system.transform` is an
539
+ * experimental hook, so a rename makes this plugin quiet with no error — check
540
+ * on OpenCode upgrades.
508
541
  */
509
542
  export function sessionStartPluginSource(root: string): string {
510
543
  const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
511
544
  const faponyTs = JSON.stringify(join(root, "fapony.ts"));
512
- return `// fapony session start — injects \`fapony mem kickoff\` as context once per
513
- // session, silent when the repo has no mem log. Annotate only: never blocks.
514
- // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
515
- import { capContext } from ${hookModule};
545
+ return `// fapony session start — injects the \`fapony mem kickoff\` digest as context
546
+ // once per session via sessionStartContext, silent when the repo has no mem log.
547
+ // Annotate only: never blocks.
548
+ // Generated by \`fapony install\` — logic lives in src/hook.ts (sessionStartContext).
549
+ import { sessionStartContext } from ${hookModule};
516
550
 
517
551
  export const FaponySessionStart = async ({ directory }) => {
518
552
  const seen = new Set();
@@ -522,15 +556,8 @@ export const FaponySessionStart = async ({ directory }) => {
522
556
  const sessionID = input?.sessionID;
523
557
  if (!sessionID || seen.has(sessionID)) return;
524
558
  seen.add(sessionID);
525
- const { spawnSync } = require("node:child_process");
526
- const kick = spawnSync(process.execPath, [${faponyTs}, "mem", "kickoff"], {
527
- cwd: directory,
528
- encoding: "utf-8",
529
- });
530
- if (kick.status !== 0) return;
531
- const out = (kick.stdout ?? "").toString().trim();
532
- if (!out) return;
533
- output.system.push(capContext(out));
559
+ const ctx = sessionStartContext(directory, ${faponyTs});
560
+ if (ctx) output.system.push(ctx);
534
561
  } catch {
535
562
  // context must never break a session start
536
563
  }
@@ -553,7 +580,16 @@ function installSessionStartPlugin(
553
580
  } catch {
554
581
  current = "";
555
582
  }
556
- if (current.includes("capContext")) {
583
+ if (current.includes("FaponySessionStart")) {
584
+ // Ours — and generated, so never overwritten. A file baked by an older
585
+ // fapony differs from what we generate now: say so instead of "no change",
586
+ // which is how the pre-hook plugin (mub2ezhi) would have been kept.
587
+ if (current !== sessionStartPluginSource(INSTALL_ROOT)) {
588
+ console.error(
589
+ ` session start: stale fapony plugin — delete ${pluginPath} and re-run install`,
590
+ );
591
+ return;
592
+ }
557
593
  console.error(` session start: already installed — no change`);
558
594
  return;
559
595
  }
@@ -581,3 +617,108 @@ function installSessionStartPlugin(
581
617
  ` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
582
618
  );
583
619
  }
620
+
621
+ /**
622
+ * OpenCode plugin — git-autonomy rewrites, the parity of the personal
623
+ * `git-autonomy.ts` plugin. Two hooks because the policy lives on two
624
+ * surfaces the other never sees:
625
+ * `experimental.chat.system.transform` (system prompt) + `tool.definition`
626
+ * (bash tool description).
627
+ *
628
+ * OPT-IN ONLY — written only when `fapony install` gets `--git-autonomy`.
629
+ * Never on the default path, never via `--all` unless the flag rides along.
630
+ * The policy is an opinion (commit-as-you-go), not a utility, and the file
631
+ * name is `fapony-git-autonomy.ts`: the user's own `git-autonomy.ts` is a
632
+ * different file and is never touched — delete it by hand after verifying.
633
+ *
634
+ * Logic lives in src/adapters/hooks/git-autonomy.ts
635
+ * (rewriteGitAutonomySystem / rewriteGitAutonomyTool) — the plugin imports
636
+ * it from the install root, so `git pull` updates every client. No baked
637
+ * regex in the generated body: a baked copy would rot silently when upstream
638
+ * rewords the policy, the same failure this module's gitAutonomyStatus
639
+ * exists to name. Never throws: a rewrite must not break a session start
640
+ * or hide a tool.
641
+ */
642
+ export function gitAutonomyPluginSource(root: string): string {
643
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
644
+ return `// fapony git-autonomy — rewrites opencode's ask-first git policy to
645
+ // commit-as-you-go on both surfaces that carry it (system prompt +
646
+ // bash tool description), and cuts the tone few-shot examples.
647
+ // OPT-IN: written only by \`fapony install --platform opencode --git-autonomy\`.
648
+ // Generated by \`fapony install\` — logic lives in src/adapters/hooks/git-autonomy.ts.
649
+ import { rewriteGitAutonomySystem, rewriteGitAutonomyTool } from ${hookModule};
650
+
651
+ export const FaponyGitAutonomy = async () => ({
652
+ "experimental.chat.system.transform": async (
653
+ _input: unknown,
654
+ output: { system: string[] },
655
+ ) => {
656
+ try {
657
+ for (let i = 0; i < output.system.length; i++) {
658
+ output.system[i] = rewriteGitAutonomySystem(output.system[i]).text;
659
+ }
660
+ } catch {
661
+ // a rewrite must never break a session start
662
+ }
663
+ },
664
+ "tool.definition": async (
665
+ input: { toolID: string },
666
+ output: { description: string; parameters: unknown },
667
+ ) => {
668
+ try {
669
+ const r = rewriteGitAutonomyTool(input.toolID, output.description);
670
+ output.description = r.description;
671
+ } catch {
672
+ // a rewrite must never hide a tool
673
+ }
674
+ },
675
+ });
676
+ `;
677
+ }
678
+
679
+ function installGitAutonomyPlugin(
680
+ dryRun: boolean,
681
+ getHome: () => string,
682
+ ): void {
683
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
684
+ const pluginPath = join(pluginsDir, "fapony-git-autonomy.ts");
685
+ if (existsSync(pluginPath)) {
686
+ let current = "";
687
+ try {
688
+ current = readFileSync(pluginPath, "utf-8");
689
+ } catch {
690
+ current = "";
691
+ }
692
+ if (current.includes("FaponyGitAutonomy")) {
693
+ // Ours — and generated, so never overwritten. Same stale-check shape
694
+ // as the session-start plugin: an older generation must say so instead
695
+ // of "no change", or a fixed rewrite never reaches the client.
696
+ if (current !== gitAutonomyPluginSource(INSTALL_ROOT)) {
697
+ console.error(
698
+ ` git-autonomy: stale fapony plugin — delete ${pluginPath} and re-run install --git-autonomy`,
699
+ );
700
+ return;
701
+ }
702
+ console.error(` git-autonomy: already installed — no change`);
703
+ return;
704
+ }
705
+ console.error(
706
+ ` git-autonomy: ${pluginPath} exists but isn't fapony's — not overwriting.`,
707
+ );
708
+ return;
709
+ }
710
+ if (!dryRun) {
711
+ try {
712
+ mkdirSync(pluginsDir, { recursive: true });
713
+ writeFileSync(pluginPath, gitAutonomyPluginSource(INSTALL_ROOT), "utf-8");
714
+ } catch (e) {
715
+ console.error(
716
+ ` git-autonomy: failed to write — ${(e as Error).message}`,
717
+ );
718
+ return;
719
+ }
720
+ }
721
+ console.error(
722
+ ` git-autonomy: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
723
+ );
724
+ }
package/src/install.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // ZCode gets skill/<name>/ symlinked into ~/.agents/skills.
13
13
 
14
14
  import { createInterface } from "node:readline";
15
+ import { cmdInstallAntigravity } from "./install/antigravity.js";
15
16
  import { cmdInstallClaude } from "./install/claude.js";
16
17
  import { cmdInstallCodex } from "./install/codex.js";
17
18
  import { cmdInstallCursor } from "./install/cursor.js";
@@ -22,6 +23,7 @@ import { cmdInstallZcode } from "./install/zcode.js";
22
23
  import { ask } from "./setup.js";
23
24
  import { isAffirmative } from "./util.js";
24
25
 
26
+ export { cmdInstallAntigravity } from "./install/antigravity.js";
25
27
  export {
26
28
  claudeAddArgs,
27
29
  claudeGetArgs,
@@ -35,6 +37,8 @@ export {
35
37
  cmdInstallOpencode,
36
38
  commitHintPluginSource,
37
39
  editHintPluginSource,
40
+ gitAutonomyPluginSource,
41
+ type OpencodeInstallOpts,
38
42
  readHintPluginSource,
39
43
  } from "./install/opencode.js";
40
44
  export {
@@ -56,8 +60,16 @@ export async function cmdInstall(
56
60
  const platform = args.find((a) => !a.startsWith("--"));
57
61
  const dryRun = args.includes("--dry-run");
58
62
  const installAll = args.includes("--all");
63
+ // Opt-in only: the git-autonomy rewrite is an opinion (commit-as-you-go),
64
+ // not a utility — never installed unless this flag rides along, including
65
+ // via --all. Opencode-only; other platforms ignore it.
66
+ const gitAutonomy = args.includes("--git-autonomy");
59
67
 
60
68
  // --- explicit platform: original behavior, unchanged ---
69
+ if (platform === "antigravity") {
70
+ cmdInstallAntigravity(dryRun, deps);
71
+ return;
72
+ }
61
73
  if (platform === "claude") {
62
74
  cmdInstallClaude(dryRun, deps);
63
75
  return;
@@ -75,15 +87,15 @@ export async function cmdInstall(
75
87
  return;
76
88
  }
77
89
  if (platform === "opencode") {
78
- cmdInstallOpencode(dryRun, deps);
90
+ cmdInstallOpencode(dryRun, deps, { gitAutonomy });
79
91
  return;
80
92
  }
81
93
  if (platform !== undefined) {
82
94
  console.error(
83
- `usage: fapony install --platform opencode|claude|cursor|zcode|codex [--dry-run]`,
95
+ `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy]`,
84
96
  );
85
97
  console.error(
86
- ` supported platforms: opencode, claude, cursor, zcode, codex`,
98
+ ` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
87
99
  );
88
100
  (deps.exit ?? defaultExit)(1);
89
101
  return;
@@ -107,7 +119,7 @@ export async function cmdInstall(
107
119
  if (found.length === 0) {
108
120
  console.error();
109
121
  console.error(
110
- ` no MCP client found (looked for claude on PATH; config files for cursor, opencode, zcode, codex).`,
122
+ ` no MCP client found (looked for claude on PATH; config files for antigravity, cursor, opencode, zcode, codex).`,
111
123
  );
112
124
  console.error(
113
125
  ` open the app once, then re-run — or force with: fapony install --platform <name>`,
@@ -119,7 +131,7 @@ export async function cmdInstall(
119
131
  if (installAll) {
120
132
  console.error();
121
133
  for (const client of found) {
122
- installPlatform(client.platform, dryRun, deps);
134
+ installPlatform(client.platform, dryRun, deps, { gitAutonomy });
123
135
  }
124
136
  return;
125
137
  }
@@ -148,7 +160,7 @@ export async function cmdInstall(
148
160
  for (const client of found) {
149
161
  const answer = await askFn(`install into ${client.platform}?`, "Y");
150
162
  if (isAffirmative(answer)) {
151
- installPlatform(client.platform, dryRun, deps);
163
+ installPlatform(client.platform, dryRun, deps, { gitAutonomy });
152
164
  }
153
165
  }
154
166
  } finally {
@@ -160,8 +172,12 @@ function installPlatform(
160
172
  platform: string,
161
173
  dryRun: boolean,
162
174
  deps: InstallDeps,
175
+ opts: { gitAutonomy?: boolean } = {},
163
176
  ): void {
164
177
  switch (platform) {
178
+ case "antigravity":
179
+ cmdInstallAntigravity(dryRun, deps);
180
+ break;
165
181
  case "claude":
166
182
  cmdInstallClaude(dryRun, deps);
167
183
  break;
@@ -169,7 +185,7 @@ function installPlatform(
169
185
  cmdInstallCursor(dryRun, deps);
170
186
  break;
171
187
  case "opencode":
172
- cmdInstallOpencode(dryRun, deps);
188
+ cmdInstallOpencode(dryRun, deps, opts);
173
189
  break;
174
190
  case "zcode":
175
191
  cmdInstallZcode(dryRun, deps);
@@ -14,8 +14,7 @@
14
14
  import { execSync } from "node:child_process";
15
15
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
16
  import { join, relative } from "node:path";
17
- import { DEFAULT_EVIDENCE_FILE } from "./db/index.js";
18
- import { faponyDir } from "./db/load.js";
17
+ import { DEFAULT_EVIDENCE_FILE, faponyDir } from "./core/config.js";
19
18
  import { assertSafe } from "./safety.js";
20
19
 
21
20
  const USAGE =
package/src/map.ts CHANGED
@@ -41,19 +41,36 @@ export interface ExportScan {
41
41
  error: string | null;
42
42
  }
43
43
 
44
- // --- Parse gate ---
44
+ // --- Parse gate (injectable: default is Bun.Transpiler, no Node fallback —
45
+ // Bun-only is a repo constraint. Pass a custom scanner to extractExports()
46
+ // to reuse this module outside Bun or in tests.) ---
45
47
 
46
- let transpiler: Bun.Transpiler | null = null;
48
+ export interface ExportScanner {
49
+ scan(source: string): { exports: string[] };
50
+ }
51
+
52
+ function createBunScanner(): ExportScanner {
53
+ const transpiler = new Bun.Transpiler({ loader: "tsx" });
54
+ return {
55
+ scan: (source) => transpiler.scan(source) as { exports: string[] },
56
+ };
57
+ }
58
+
59
+ let defaultScanner: ExportScanner | null = null;
60
+
61
+ function getDefaultScanner(): ExportScanner {
62
+ if (!defaultScanner) defaultScanner = createBunScanner();
63
+ return defaultScanner;
64
+ }
47
65
 
48
66
  interface ScanResult {
49
67
  error: string | null;
50
68
  exports: string[];
51
69
  }
52
70
 
53
- function scanSource(source: string): ScanResult {
71
+ function scanSource(source: string, scanner: ExportScanner): ScanResult {
54
72
  try {
55
- if (!transpiler) transpiler = new Bun.Transpiler({ loader: "tsx" });
56
- const scanned = transpiler.scan(source) as { exports: string[] };
73
+ const scanned = scanner.scan(source);
57
74
  return { error: null, exports: scanned.exports };
58
75
  } catch (e) {
59
76
  return {
@@ -150,8 +167,12 @@ function braceNames(
150
167
 
151
168
  const VAR_DECL_RE = /^export\s+(?:const|let|var)\b/;
152
169
 
153
- export function extractExports(source: string): ExportScan {
154
- const scanned = scanSource(source);
170
+ export function extractExports(
171
+ source: string,
172
+ scanner?: ExportScanner,
173
+ ): ExportScan {
174
+ const s = scanner ?? getDefaultScanner();
175
+ const scanned = scanSource(source, s);
155
176
  if (scanned.error) return { symbols: [], error: scanned.error };
156
177
 
157
178
  // Ambient declarations (`export declare ...`) are invisible to scan(), so
@@ -161,7 +182,7 @@ export function extractExports(source: string): ExportScan {
161
182
  /^([ \t]*)export\s+declare\s+/gm,
162
183
  "$1export ",
163
184
  );
164
- const strippedScan = scanSource(stripped);
185
+ const strippedScan = scanSource(stripped, s);
165
186
  const valid = new Set([
166
187
  ...scanned.exports,
167
188
  ...(strippedScan.error ? [] : strippedScan.exports),