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/init-mem.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
8
8
  import { join } from "node:path";
9
- import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
9
+ import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./core/config.js";
10
10
  import { walkDir } from "./util.js";
11
11
 
12
12
  export function cmdInitMem(args: string[]): void {
package/src/init.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  memoryDir,
22
22
  planDir,
23
23
  specDir,
24
- } from "./db/index.js";
24
+ } from "./core/config.js";
25
25
  import { isAffirmative } from "./util.js";
26
26
 
27
27
  const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
@@ -232,6 +232,7 @@ function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
232
232
  installStopHook(dryRun, deps);
233
233
  installReadHintHook(dryRun, deps);
234
234
  installEditHintHook(dryRun, deps);
235
+ installMvGuardHook(dryRun, deps);
235
236
  installSessionStartHook(dryRun, deps);
236
237
  }
237
238
 
@@ -276,6 +277,22 @@ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
276
277
  });
277
278
  }
278
279
 
280
+ /**
281
+ * PreToolUse hook on Bash: denies a raw `git mv` of a plan file into a done/
282
+ * directory, pointing at `fapony mem plan-sweep --apply` instead — that
283
+ * command does the link rewrite a plain `git mv` skips. The one fapony hook
284
+ * that blocks besides Stop; matcher "Bash" keeps the spawn off every other
285
+ * tool call.
286
+ */
287
+ function installMvGuardHook(dryRun: boolean, deps: InstallDeps): void {
288
+ ensureClaudeHook(dryRun, deps, {
289
+ event: "PreToolUse",
290
+ matcher: "Bash",
291
+ subcommand: "hook-mv-guard",
292
+ label: "plan-mv guard",
293
+ });
294
+ }
295
+
279
296
  /**
280
297
  * SessionStart hook: injects `fapony mem kickoff` as context when the repo has
281
298
  * a mem log, and stays silent when it does not. Context only — SessionStart
@@ -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
  /**
@@ -608,3 +617,108 @@ function installSessionStartPlugin(
608
617
  ` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
609
618
  );
610
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
@@ -37,6 +37,8 @@ export {
37
37
  cmdInstallOpencode,
38
38
  commitHintPluginSource,
39
39
  editHintPluginSource,
40
+ gitAutonomyPluginSource,
41
+ type OpencodeInstallOpts,
40
42
  readHintPluginSource,
41
43
  } from "./install/opencode.js";
42
44
  export {
@@ -58,6 +60,10 @@ export async function cmdInstall(
58
60
  const platform = args.find((a) => !a.startsWith("--"));
59
61
  const dryRun = args.includes("--dry-run");
60
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");
61
67
 
62
68
  // --- explicit platform: original behavior, unchanged ---
63
69
  if (platform === "antigravity") {
@@ -81,12 +87,12 @@ export async function cmdInstall(
81
87
  return;
82
88
  }
83
89
  if (platform === "opencode") {
84
- cmdInstallOpencode(dryRun, deps);
90
+ cmdInstallOpencode(dryRun, deps, { gitAutonomy });
85
91
  return;
86
92
  }
87
93
  if (platform !== undefined) {
88
94
  console.error(
89
- `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run]`,
95
+ `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy]`,
90
96
  );
91
97
  console.error(
92
98
  ` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
@@ -125,7 +131,7 @@ export async function cmdInstall(
125
131
  if (installAll) {
126
132
  console.error();
127
133
  for (const client of found) {
128
- installPlatform(client.platform, dryRun, deps);
134
+ installPlatform(client.platform, dryRun, deps, { gitAutonomy });
129
135
  }
130
136
  return;
131
137
  }
@@ -154,7 +160,7 @@ export async function cmdInstall(
154
160
  for (const client of found) {
155
161
  const answer = await askFn(`install into ${client.platform}?`, "Y");
156
162
  if (isAffirmative(answer)) {
157
- installPlatform(client.platform, dryRun, deps);
163
+ installPlatform(client.platform, dryRun, deps, { gitAutonomy });
158
164
  }
159
165
  }
160
166
  } finally {
@@ -166,6 +172,7 @@ function installPlatform(
166
172
  platform: string,
167
173
  dryRun: boolean,
168
174
  deps: InstallDeps,
175
+ opts: { gitAutonomy?: boolean } = {},
169
176
  ): void {
170
177
  switch (platform) {
171
178
  case "antigravity":
@@ -178,7 +185,7 @@ function installPlatform(
178
185
  cmdInstallCursor(dryRun, deps);
179
186
  break;
180
187
  case "opencode":
181
- cmdInstallOpencode(dryRun, deps);
188
+ cmdInstallOpencode(dryRun, deps, opts);
182
189
  break;
183
190
  case "zcode":
184
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),
@@ -2,8 +2,9 @@
2
2
  // rationale: moving by hand = chasing relative links yourself (in the file + files that link to it) → the step gets skipped often
3
3
  // no arg = report only (safe, shows every kickoff/stale run)
4
4
  // <file.md> = check a single file, is it ready to move
5
- // <file.md> --apply = git mv + fix markdown links inside the file + fix inbound links from other files in plan/
6
- // + warn about plain-text mentions (detect-only, no auto-fix)
5
+ // <file.md> --apply = git mv + fix markdown links inside the file + fix inbound links from every .md under .fapony/
6
+ // (plan/ + done/ + spec/) + warn about plain-text mentions (detect-only, no auto-fix)
7
+ // + warn about tracked files outside .fapony/ that mention the filename (detect-only)
7
8
 
8
9
  import {
9
10
  existsSync,
@@ -12,10 +13,20 @@ import {
12
13
  readFileSync,
13
14
  writeFileSync,
14
15
  } from "node:fs";
15
- import { dirname, join, relative, resolve } from "node:path";
16
+ import { basename, dirname, join, relative, resolve } from "node:path";
16
17
 
17
18
  import { openRows } from "../selectors.js";
18
- import { doneDir, memCmd, nextId, planDir, put, rel, rows } from "../store.js";
19
+ import {
20
+ doneDir,
21
+ memCmd,
22
+ nextId,
23
+ planBase,
24
+ planDir,
25
+ put,
26
+ rel,
27
+ root,
28
+ rows,
29
+ } from "../store.js";
19
30
 
20
31
  const SHIPPED = /^>\s*✅/m;
21
32
  const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -52,6 +63,11 @@ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
63
  // the file moved dir (same content) — an old markdown link meant the old path relative to oldDir, must re-relativize via newDir
53
64
  // resolve from newDir (where the file is now) — if the target also moved to the same dir → plain filename,
54
65
  // if the target stayed in oldDir → ../target (both correct)
66
+ //
67
+ // Runs on EVERY move, including the standard sibling layout (plan/ → done/ beside
68
+ // it): same depth is not the same directory, so a sibling link like
69
+ // [other](PLAN-other.md) breaks unless rewritten to ../plan/PLAN-other.md.
70
+ // Skipping this pass left exactly that dangling (mtjn3ldk).
55
71
  // pass 1 only — fix only [text](target) markdown links, do not touch plain text
56
72
  export const rewriteMovedFileLinks = (
57
73
  file: string,
@@ -65,11 +81,17 @@ export const rewriteMovedFileLinks = (
65
81
  const [target, anchor] = t.split("#");
66
82
  if (!target || /^(https?:|mailto:|\/)/.test(target)) return m;
67
83
  // resolve from where the file IS now (newDir); if target doesn't exist
68
- // there, fall back to oldDir (target stayed in original location)
84
+ // there, fall back to oldDir (target stayed in original location).
85
+ // If it exists in neither, the link is already broken — leave it for
86
+ // plan-check instead of fabricating a new broken path.
69
87
  const inNew = resolve(newDir, target);
70
- const abs = existsSync(inNew) ? inNew : resolve(oldDir, target);
88
+ const inOld = resolve(oldDir, target);
89
+ const abs = existsSync(inNew) ? inNew : existsSync(inOld) ? inOld : null;
90
+ if (!abs) return m;
91
+ const next = `](${relative(newDir, abs) || "."}${anchor ? `#${anchor}` : ""})`;
92
+ if (next === m) return m;
71
93
  n++;
72
- return `](${relative(newDir, abs) || "."}${anchor ? `#${anchor}` : ""})`;
94
+ return next;
73
95
  });
74
96
 
75
97
  if (n) writeFileSync(file, out);
@@ -172,11 +194,20 @@ export const cmdPlanSweep = (a: string[]) => {
172
194
  return;
173
195
  }
174
196
 
175
- const src = join(dir, target);
176
- if (!existsSync(src)) {
177
- console.error(`${rel(src)} not found`);
197
+ // target arrives as a bare name (PLAN-x.md, the SKILL form) or a repo-relative
198
+ // path (.fapony/plan/PLAN-x.md, the example form) — both must resolve, so try
199
+ // planDir first, then the repo root, then the bare basename as a last resort.
200
+ const src = [
201
+ join(dir, target),
202
+ join(root, target),
203
+ join(dir, basename(target)),
204
+ ].find((p) => existsSync(p));
205
+ if (!src) {
206
+ console.error(`${target} not found (looked in ${rel(dir)}/ and repo root)`);
178
207
  process.exit(1);
179
208
  }
209
+ const name = basename(src);
210
+ const srcDir = dirname(src);
180
211
  const shipped = hasShippedHeader(src);
181
212
  if (!apply) {
182
213
  console.log(
@@ -191,21 +222,21 @@ export const cmdPlanSweep = (a: string[]) => {
191
222
  // but then run --apply directly and skip everything → risky when an agent ships automatically with no human check, so hard block
192
223
  if (!shipped && !process.env.MEM_FORCE) {
193
224
  console.error(
194
- `${target}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
225
+ `${name}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
195
226
  );
196
227
  process.exit(1);
197
228
  }
198
- const openSpec = `${rel(dir)}/${target}`;
229
+ const openSpec = rel(src);
199
230
  const openN = openRows(rows()).filter((r) => r.spec === openSpec);
200
231
  if (openN.length && !process.env.MEM_FORCE) {
201
232
  console.error(
202
- `${target}: still has ${openN.length} open row(s) (next/bug/hold/decision/note) — close them or move the spec first (MEM_FORCE=1 to override):\n` +
233
+ `${name}: still has ${openN.length} open row(s) (next/bug/hold/decision/note) — close them or move the spec first (MEM_FORCE=1 to override):\n` +
203
234
  openN.map((r) => ` [${r.id}] ${r.kind} ${r.text}`).join("\n"),
204
235
  );
205
236
  process.exit(1);
206
237
  }
207
238
 
208
- const dst = join(doneDir, target);
239
+ const dst = join(doneDir, name);
209
240
  if (existsSync(dst)) {
210
241
  console.error(`${rel(dst)} already exists`);
211
242
  process.exit(1);
@@ -221,14 +252,17 @@ export const cmdPlanSweep = (a: string[]) => {
221
252
  process.exit(1);
222
253
  }
223
254
 
224
- // done/ is a sibling of plan/ = same depth, links in the file still resolve, no need to touch
225
- // the old layout (plan/done/) is one level deeper, so it does need re-relativizing
226
- const nested = dirname(doneDir) !== dirname(dir);
227
- const ownLinks = nested ? rewriteMovedFileLinks(dst, dir, doneDir) : 0;
255
+ // own links always need re-relativizing — the file changed directory even in
256
+ // the sibling layout (plan/ → done/ beside it), so sibling links dangle
257
+ // unless rewritten (see rewriteMovedFileLinks).
258
+ const ownLinks = rewriteMovedFileLinks(dst, srcDir, doneDir);
228
259
 
260
+ // inbound: every .md under .fapony/ can link here — other active plans,
261
+ // shipped plans in done/ (they reference each other), and specs. Scanning
262
+ // plan/ only left done/+spec/ links dangling (mtl15q4y).
229
263
  let inbound = 0;
230
264
  let inboundFiles = 0;
231
- for (const f of mdFiles(dir)) {
265
+ for (const f of mdFiles(planBase)) {
232
266
  if (f === dst) continue;
233
267
  const n = rewriteMarkdownLinks(f, src, dst);
234
268
  if (n) {
@@ -238,13 +272,9 @@ export const cmdPlanSweep = (a: string[]) => {
238
272
  }
239
273
 
240
274
  console.log(`moved ${rel(src)} → ${rel(dst)}`);
275
+ console.log(`links rewritten inside the file: ${ownLinks}`);
241
276
  console.log(
242
- nested
243
- ? `links rewritten inside the file: ${ownLinks}`
244
- : `links rewritten inside the file: 0 (same depth, existing links still resolve)`,
245
- );
246
- console.log(
247
- `inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(dir)}/** only)`,
277
+ `inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(planBase)}/**)`,
248
278
  );
249
279
 
250
280
  // log decision — record ship event (reuse existing kind, no new schema)
@@ -252,7 +282,7 @@ export const cmdPlanSweep = (a: string[]) => {
252
282
  put({
253
283
  id: nextId(rows()),
254
284
  kind: "decision",
255
- text: `${target} shipped → ${rel(dst)}`,
285
+ text: `${name} shipped → ${rel(dst)}`,
256
286
  spec: doneSpec,
257
287
  });
258
288
  // ponytail: this decision *is* the move itself, nothing to write back into the spec — do not mark synced
@@ -262,35 +292,33 @@ export const cmdPlanSweep = (a: string[]) => {
262
292
  // plain-text mention detection (detect-only, no auto-fix)
263
293
  let plainTextTotal = 0;
264
294
  const plainTextFiles: string[] = [];
265
- for (const f of mdFiles(dir)) {
266
- const n = countPlainTextMentions(f, target);
295
+ for (const f of mdFiles(planBase)) {
296
+ if (f === dst) continue;
297
+ const n = countPlainTextMentions(f, name);
267
298
  if (n) {
268
299
  plainTextTotal += n;
269
- plainTextFiles.push(f.replace(`${dir}/`, ""));
300
+ plainTextFiles.push(f.replace(`${planBase}/`, ""));
270
301
  }
271
302
  }
272
303
  if (plainTextTotal > 0) {
273
304
  console.log(
274
- `⚠ ${plainTextTotal} plain-text mention(s) in ${plainTextFiles.length} file(s) under ${rel(dir)}/ — grep and update the paths yourself:\n${plainTextFiles.join("\n")}`,
305
+ `⚠ ${plainTextTotal} plain-text mention(s) in ${plainTextFiles.length} file(s) under ${rel(planBase)}/ — grep and update the paths yourself:\n${plainTextFiles.join("\n")}`,
275
306
  );
276
307
  }
277
308
 
278
- // files outside plan/ that mention the target — detect-only
279
- const grep = Bun.spawnSync([
280
- "git",
281
- "grep",
282
- "-l",
283
- target,
284
- "--",
285
- // the "files outside plan/" scope = the folder plan/ lives under (apps/vela, .fapony, …)
286
- rel(dirname(planDir)),
287
- `:!${rel(dir)}`,
288
- ])
309
+ // files outside .fapony/ that mention the filename — detect-only.
310
+ // Everything under planBase/ was auto-fixed above, so what remains is repo
311
+ // docs and prose (README, docs/, CLAUDE.md) with hand-written paths.
312
+ // cwd: root — the pathspecs are root-relative no matter where fapony runs from.
313
+ const grep = Bun.spawnSync(
314
+ ["git", "grep", "-l", name, "--", ".", `:!${rel(planBase)}`],
315
+ { cwd: root },
316
+ )
289
317
  .stdout.toString()
290
318
  .trim();
291
319
  if (grep)
292
320
  console.log(
293
- `⚠ files outside ${rel(dir)}/ still mention "${target}" — check them yourself (not auto-fixed):\n${grep}`,
321
+ `⚠ files outside ${rel(planBase)}/ still mention "${name}" — check them yourself (not auto-fixed):\n${grep}`,
294
322
  );
295
323
  };
296
324
 
package/src/mem/store.ts CHANGED
@@ -15,7 +15,11 @@ import {
15
15
  } from "node:fs";
16
16
  import { hostname, networkInterfaces } from "node:os";
17
17
  import { basename, dirname, join, relative } from "node:path";
18
- import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "../db/index.js";
18
+ import {
19
+ CONFIG_FILENAME,
20
+ DEFAULT_MEM_DIR,
21
+ FAPONY_DIR,
22
+ } from "../core/config.js";
19
23
  import { whereMemDir } from "../memory.js";
20
24
 
21
25
  // --- types ---