fapony 0.3.3 → 0.3.5

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 (87) hide show
  1. package/README.md +7 -1
  2. package/fapony.ts +3 -116
  3. package/package.json +5 -6
  4. package/skill/move-to-done/SKILL.md +3 -2
  5. package/src/adapters/cli.ts +128 -0
  6. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  7. package/src/adapters/hooks/context-data.ts +102 -0
  8. package/src/adapters/hooks/edit-hint.ts +195 -0
  9. package/src/adapters/hooks/git-autonomy.ts +178 -0
  10. package/src/adapters/hooks/index.ts +80 -0
  11. package/src/adapters/hooks/mv-guard.ts +52 -0
  12. package/src/adapters/hooks/read-hint.ts +383 -0
  13. package/src/adapters/hooks/session-start.ts +101 -0
  14. package/src/adapters/hooks/stop.ts +434 -0
  15. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  20. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  21. package/src/adapters/mcp/types.ts +18 -0
  22. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  23. package/src/analyze.ts +1 -1
  24. package/src/commands.ts +180 -0
  25. package/src/conventions-seed.ts +1 -1
  26. package/src/core/config.ts +199 -0
  27. package/src/core/debt-format.ts +107 -0
  28. package/src/core/debt-types.ts +79 -0
  29. package/src/core/defaults.ts +8 -0
  30. package/src/core/enums.ts +34 -0
  31. package/src/core/format.ts +33 -0
  32. package/src/core/hint-log.ts +68 -0
  33. package/src/core/hook-helpers.ts +31 -0
  34. package/src/core/mem-log.ts +357 -0
  35. package/src/core/parse.ts +71 -0
  36. package/src/core/pricing.ts +217 -0
  37. package/src/core/safety.ts +18 -0
  38. package/src/core/types.ts +142 -0
  39. package/src/core/util.ts +105 -0
  40. package/src/db/store.ts +2 -2
  41. package/src/debt/cli.ts +1 -1
  42. package/src/debt/format.ts +2 -107
  43. package/src/debt/load.ts +1 -1
  44. package/src/debt/promotion.ts +1 -1
  45. package/src/debt/types.ts +14 -79
  46. package/src/digest/collect.ts +4 -3
  47. package/src/gate.ts +2 -2
  48. package/src/gates.ts +1 -1
  49. package/src/hook.ts +70 -1424
  50. package/src/init-mem.ts +1 -1
  51. package/src/init.ts +1 -1
  52. package/src/install/claude.ts +17 -0
  53. package/src/install/opencode.ts +114 -0
  54. package/src/install.ts +12 -5
  55. package/src/lint-baseline.ts +1 -2
  56. package/src/map.ts +29 -8
  57. package/src/mem/commands/plan.ts +197 -42
  58. package/src/mem/commands/read.ts +53 -12
  59. package/src/mem/index.ts +60 -1
  60. package/src/mem/store.ts +5 -1
  61. package/src/memory.ts +26 -388
  62. package/src/parse.ts +9 -71
  63. package/src/price/fetch.ts +4 -16
  64. package/src/price/resolve.ts +12 -213
  65. package/src/report/cli.ts +3 -3
  66. package/src/safety.ts +2 -18
  67. package/src/seed/plan-seed.ts +126 -32
  68. package/src/seed/primitives.ts +1 -7
  69. package/src/session/types.ts +14 -128
  70. package/src/setup.ts +1 -1
  71. package/src/stats/data.ts +3 -3
  72. package/src/telemetry.ts +2 -2
  73. package/src/update.ts +1 -1
  74. package/src/usage/cache.ts +1 -2
  75. package/src/usage/cli.ts +1 -1
  76. package/src/usage/scan.ts +2 -1
  77. package/src/util.ts +10 -93
  78. package/src/web/html.ts +2 -33
  79. package/src/db/defaults.ts +0 -34
  80. package/src/db/getters.ts +0 -35
  81. package/src/db/index.ts +0 -7
  82. package/src/db/load.ts +0 -57
  83. package/src/db/types.ts +0 -77
  84. package/src/mcp/types.ts +0 -54
  85. package/src/test.ts +0 -2
  86. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  87. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
@@ -0,0 +1,195 @@
1
+ // src/adapters/hooks/edit-hint.ts — Edit hint: importer count + once-per-session dedupe
2
+ //
3
+ // Split from src/hook.ts (PLAN-lib-layer chunk 3). Attaches importer count
4
+ // when editing a source file.
5
+
6
+ import {
7
+ appendFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join, relative, sep } from "node:path";
15
+ import { buildGraphCached, SCAN_EXTS } from "../../analyze.js";
16
+ import { recordHintFire } from "../../core/hint-log.js";
17
+ import { sessionKey } from "../../core/hook-helpers.js";
18
+ import { readContextData } from "./context-data.js";
19
+
20
+ const EDIT_TRACK_DIR = "edit-track";
21
+
22
+ export interface EditTrackRow {
23
+ ts: string;
24
+ path: string;
25
+ }
26
+
27
+ /** Directory holding one edit log per session. */
28
+ function editTrackDir(): string {
29
+ const base =
30
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
31
+ return join(base, EDIT_TRACK_DIR);
32
+ }
33
+
34
+ /** Absolute path of a session's edit log — may not exist. */
35
+ export function editTrackPath(session: string): string {
36
+ return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
37
+ }
38
+
39
+ function editTrackPaths(session: string): Set<string> {
40
+ const p = editTrackPath(session);
41
+ if (!existsSync(p)) return new Set();
42
+ const out = new Set<string>();
43
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
44
+ if (!line) continue;
45
+ try {
46
+ const r = JSON.parse(line) as EditTrackRow;
47
+ if (typeof r.path === "string") out.add(r.path);
48
+ } catch {
49
+ // a torn line must not lose the rest of the log
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function appendEditTrackRow(session: string, row: EditTrackRow): void {
56
+ const dir = editTrackDir();
57
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
58
+ appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
59
+ }
60
+
61
+ export interface EditHintInput {
62
+ filePath: unknown;
63
+ cwd: string;
64
+ session?: unknown;
65
+ }
66
+
67
+ /**
68
+ * Factual one-liner for editing a source file that has importers, or null.
69
+ * Every unknown resolves to null — a hint must never fire on a guess.
70
+ */
71
+ export function editHintFor(opts: EditHintInput): string | null {
72
+ try {
73
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
74
+ const dot = opts.filePath.lastIndexOf(".");
75
+ if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
76
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
77
+ cwd: opts.cwd,
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+ if (git.exitCode !== 0) return null;
82
+ const worktree = realpathSync(git.stdout.toString().trim());
83
+ const abs = (() => {
84
+ const p = opts.filePath.startsWith("/")
85
+ ? opts.filePath
86
+ : join(opts.cwd, opts.filePath);
87
+ try {
88
+ return realpathSync(p);
89
+ } catch {
90
+ return null;
91
+ }
92
+ })();
93
+ if (!abs) return null;
94
+ const rel = relative(worktree, abs).split(sep).join("/");
95
+ if (rel.startsWith("..") || rel === "") return null;
96
+
97
+ const importers = buildGraphCached(worktree).dependents.get(rel);
98
+ if (!importers || importers.size === 0) return null;
99
+
100
+ if (typeof opts.session === "string" && opts.session !== "") {
101
+ if (editTrackPaths(opts.session).has(abs)) return null;
102
+ appendEditTrackRow(opts.session, {
103
+ ts: new Date().toISOString(),
104
+ path: abs,
105
+ });
106
+ }
107
+
108
+ const n = importers.size;
109
+ return (
110
+ `fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
111
+ `review-seed --files ${rel} lists them (add --callers <export> for one ` +
112
+ `export's callers); check before changing its shape (skill /lookup-before-edit)`
113
+ );
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out. */
120
+ export async function cmdHookEditHint(): Promise<void> {
121
+ try {
122
+ const raw = JSON.parse(await Bun.stdin.text()) as {
123
+ cwd?: string;
124
+ transcript_path?: string;
125
+ session_id?: string;
126
+ tool_input?: {
127
+ file_path?: unknown;
128
+ };
129
+ };
130
+ const cwd = raw.cwd ?? process.cwd();
131
+ const filePath = raw.tool_input?.file_path;
132
+ const session = raw.transcript_path ?? raw.session_id;
133
+ const parts: string[] = [];
134
+ const hint = editHintFor({ filePath, cwd, session });
135
+ if (hint) parts.push(hint);
136
+ const ctx = readContextData(filePath, cwd);
137
+ if (ctx) {
138
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
139
+ parts.push(line);
140
+ }
141
+ }
142
+ if (parts.length > 0) {
143
+ console.log(
144
+ JSON.stringify({
145
+ hookSpecificOutput: {
146
+ hookEventName: "PreToolUse",
147
+ additionalContext: parts.join("\n"),
148
+ },
149
+ }),
150
+ );
151
+ }
152
+
153
+ // --- hint-fire log ---
154
+ if (hint) {
155
+ try {
156
+ const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
157
+ cwd,
158
+ stdout: "pipe",
159
+ stderr: "pipe",
160
+ });
161
+ if (g.exitCode === 0) {
162
+ const worktree = realpathSync(g.stdout.toString().trim());
163
+ const abs =
164
+ typeof filePath === "string"
165
+ ? (() => {
166
+ try {
167
+ return realpathSync(
168
+ filePath.startsWith("/")
169
+ ? filePath
170
+ : join(worktree, filePath),
171
+ );
172
+ } catch {
173
+ return null;
174
+ }
175
+ })()
176
+ : null;
177
+ const rel = abs
178
+ ? relative(worktree, abs).split("\\").join("/")
179
+ : null;
180
+ recordHintFire({
181
+ ts: new Date().toISOString(),
182
+ worktree,
183
+ surface: "edit",
184
+ file: rel && !rel.startsWith("..") ? rel : null,
185
+ count: 1,
186
+ });
187
+ }
188
+ } catch {
189
+ // best-effort — swallow
190
+ }
191
+ }
192
+ } catch {
193
+ // any failure = no hint; a hook must never block an edit over a hint
194
+ }
195
+ }
@@ -0,0 +1,178 @@
1
+ // src/adapters/hooks/git-autonomy.ts — git-autonomy rewrites as shared logic.
2
+ //
3
+ // A personal opencode plugin (~/.config/opencode/plugins/git-autonomy.ts)
4
+ // rewrote opencode's built-in "never commit unless asked" policy on two
5
+ // surfaces: the system prompt (experimental.chat.system.transform) and the
6
+ // bash tool description (tool.definition). This module is that file's logic,
7
+ // extracted so `fapony install --platform opencode --git-autonomy` can
8
+ // generate a thin plugin that imports it — one copy, updated by `git pull`,
9
+ // same shape as the read/commit/edit/session-start hints.
10
+ //
11
+ // Opt-in only: the installer never writes this unless the flag is passed.
12
+ // The rewrite itself is an opinion (commit-as-you-go), not a utility, so it
13
+ // must never ride the default install path.
14
+ //
15
+ // Stale-regex guard (the "rots silently" problem): upstream text lives in the
16
+ // opencode binary, not in a file, so a reword upstream makes a pattern stop
17
+ // matching with no error. Every rewrite carries `required`; `gitAutonomyStatus`
18
+ // reports required ids that hit nothing — the analyze-like help for this
19
+ // feature. The tone-example cut is required:false: versions without those
20
+ // examples are fine, a missing commit policy is not.
21
+
22
+ /** Commit-as-you-go policy replacing opencode's ask-first rule. */
23
+ export const GIT_AUTONOMY_COMMIT_POLICY =
24
+ "Commit each finished unit of work as you go — do not wait to be asked. If the user wants to review before a commit they will say so.";
25
+
26
+ /** Same policy, bash-tool-description surface. */
27
+ export const GIT_AUTONOMY_TOOL_POLICY =
28
+ "Commit finished work as you go — do not wait to be asked; never amend published commits or force-push.";
29
+
30
+ export const GIT_AUTONOMY_PLUGIN_FILE = "fapony-git-autonomy.ts";
31
+ export const GIT_AUTONOMY_PLUGIN_NAME = "FaponyGitAutonomy";
32
+
33
+ export interface GitAutonomyRewrite {
34
+ /** Stable id used by gitAutonomyStatus to name the stale pattern. */
35
+ id: string;
36
+ pattern: RegExp;
37
+ replacement: string;
38
+ /** False = a miss is fine (upstream dropped that text). True = a miss is rot. */
39
+ required: boolean;
40
+ }
41
+
42
+ export const GIT_AUTONOMY_SYSTEM_REWRITES: GitAutonomyRewrite[] = [
43
+ {
44
+ id: "system-commit-full",
45
+ pattern: /NEVER commit changes[\s\S]{0,500}?too proactive\./g,
46
+ replacement: GIT_AUTONOMY_COMMIT_POLICY,
47
+ required: true,
48
+ },
49
+ {
50
+ id: "system-commit-fallback",
51
+ pattern: /NEVER commit changes[^.]*\./g,
52
+ replacement: GIT_AUTONOMY_COMMIT_POLICY,
53
+ required: true,
54
+ },
55
+ {
56
+ id: "system-bash-desc",
57
+ pattern:
58
+ /- Only commit, amend, push, or create PRs when explicitly requested\./g,
59
+ replacement:
60
+ "- Commit and push finished work as you go, and open a PR when a branch is ready. Do not amend published commits or force-push.",
61
+ required: true,
62
+ },
63
+ {
64
+ // "# Tone and style" few-shot examples: from the intro down to the next
65
+ // heading. Anchored on the unique intro so no other <example> block is
66
+ // touched. Pure token rent — the surrounding sentences already state the
67
+ // terseness rules.
68
+ id: "system-tone-examples",
69
+ pattern:
70
+ /Here are some examples to demonstrate appropriate verbosity:[\s\S]*?(?=\n# )/g,
71
+ replacement: "",
72
+ required: false,
73
+ },
74
+ ];
75
+
76
+ export interface GitAutonomyToolRewrite extends GitAutonomyRewrite {
77
+ toolID: string;
78
+ }
79
+
80
+ export const GIT_AUTONOMY_TOOL_REWRITES: GitAutonomyToolRewrite[] = [
81
+ {
82
+ id: "tool-bash-commit",
83
+ toolID: "bash",
84
+ pattern:
85
+ /- Only commit, amend, push, or create PRs when explicitly requested\./g,
86
+ replacement: `- ${GIT_AUTONOMY_TOOL_POLICY}`,
87
+ required: true,
88
+ },
89
+ ];
90
+
91
+ function fresh(re: RegExp): RegExp {
92
+ // /g patterns are stateful via lastIndex — clone so repeated calls and
93
+ // .test-then-.replace sequences never skip a match.
94
+ return new RegExp(re.source, re.flags);
95
+ }
96
+
97
+ /**
98
+ * Apply the system-prompt rewrites. Returns the rewritten text plus the ids
99
+ * that fired, in order — the caller (plugin) ignores the second half, tests
100
+ * and gitAutonomyStatus use it to tell "rewrote" from "silently missed".
101
+ */
102
+ export function rewriteGitAutonomySystem(text: string): {
103
+ text: string;
104
+ matched: string[];
105
+ } {
106
+ const matched: string[] = [];
107
+ let out = text;
108
+ for (const r of GIT_AUTONOMY_SYSTEM_REWRITES) {
109
+ const re = fresh(r.pattern);
110
+ if (re.test(out)) {
111
+ matched.push(r.id);
112
+ out = out.replace(fresh(r.pattern), r.replacement);
113
+ // The loose fallback must never run once the full block was replaced.
114
+ if (r.id === "system-commit-full") break;
115
+ }
116
+ }
117
+ return { text: out, matched };
118
+ }
119
+
120
+ /** Apply the tool.description rewrites for one toolID. */
121
+ export function rewriteGitAutonomyTool(
122
+ toolID: string,
123
+ description: string,
124
+ ): { description: string; matched: string[] } {
125
+ const matched: string[] = [];
126
+ let out = description;
127
+ for (const r of GIT_AUTONOMY_TOOL_REWRITES) {
128
+ if (r.toolID !== toolID) continue;
129
+ const re = fresh(r.pattern);
130
+ if (re.test(out)) {
131
+ matched.push(r.id);
132
+ out = out.replace(fresh(r.pattern), r.replacement);
133
+ }
134
+ }
135
+ return { description: out, matched };
136
+ }
137
+
138
+ export interface GitAutonomyStatus {
139
+ /** Required ids that fired at least once across the inputs. */
140
+ hits: string[];
141
+ /** Required ids that hit nothing — upstream reworded or moved the text. */
142
+ stale: string[];
143
+ /** True when no required pattern is stale. */
144
+ ok: boolean;
145
+ }
146
+
147
+ /**
148
+ * Analyze-like help for this feature: feed the current opencode system lines
149
+ * + tool descriptions, get back which required patterns are stale. Optional
150
+ * rewrites (tone examples) never appear in `stale` — their absence is fine.
151
+ */
152
+ export function gitAutonomyStatus(
153
+ systemTexts: string[],
154
+ toolDescs: Record<string, string>,
155
+ ): GitAutonomyStatus {
156
+ const seen = new Set<string>();
157
+ for (const t of systemTexts) {
158
+ for (const id of rewriteGitAutonomySystem(t).matched) seen.add(id);
159
+ }
160
+ for (const [toolID, desc] of Object.entries(toolDescs)) {
161
+ for (const id of rewriteGitAutonomyTool(toolID, desc).matched) seen.add(id);
162
+ }
163
+ const requiredIds = new Set<string>();
164
+ for (const r of GIT_AUTONOMY_SYSTEM_REWRITES)
165
+ if (r.required) requiredIds.add(r.id);
166
+ for (const r of GIT_AUTONOMY_TOOL_REWRITES)
167
+ if (r.required) requiredIds.add(r.id);
168
+ // system-commit-full and system-commit-fallback are two spellings of one
169
+ // policy: either hit means the policy landed, only both missing is stale.
170
+ const commitHit =
171
+ seen.has("system-commit-full") || seen.has("system-commit-fallback");
172
+ const hits = [...seen].filter((id) => requiredIds.has(id));
173
+ const stale: string[] = [];
174
+ if (!commitHit) stale.push("system-commit");
175
+ if (!seen.has("system-bash-desc")) stale.push("system-bash-desc");
176
+ if (!seen.has("tool-bash-commit")) stale.push("tool-bash-commit");
177
+ return { hits, stale, ok: stale.length === 0 };
178
+ }
@@ -0,0 +1,80 @@
1
+ // src/adapters/hooks/index.ts — re-export all hook adapters
2
+
3
+ // Re-export hint-log for backwards compatibility.
4
+ export {
5
+ type HintFireRow,
6
+ type HintImpact,
7
+ hintLogDir,
8
+ hintLogPath,
9
+ recordHintFire,
10
+ worktreeKey,
11
+ } from "../../core/hint-log.js";
12
+ // Re-export pure helpers from core for backwards compatibility.
13
+ export {
14
+ hookTsMs,
15
+ sessionKey,
16
+ utcStamp,
17
+ } from "../../core/hook-helpers.js";
18
+ export { computeHintImpact } from "./compute-hint-impact.js";
19
+ export {
20
+ type ContextLineData,
21
+ readContextData,
22
+ readContextLines,
23
+ } from "./context-data.js";
24
+ export {
25
+ cmdHookEditHint,
26
+ type EditHintInput,
27
+ type EditTrackRow,
28
+ editHintFor,
29
+ editTrackPath,
30
+ } from "./edit-hint.js";
31
+ export {
32
+ GIT_AUTONOMY_COMMIT_POLICY,
33
+ GIT_AUTONOMY_PLUGIN_FILE,
34
+ GIT_AUTONOMY_PLUGIN_NAME,
35
+ GIT_AUTONOMY_SYSTEM_REWRITES,
36
+ GIT_AUTONOMY_TOOL_POLICY,
37
+ GIT_AUTONOMY_TOOL_REWRITES,
38
+ type GitAutonomyRewrite,
39
+ type GitAutonomyStatus,
40
+ type GitAutonomyToolRewrite,
41
+ gitAutonomyStatus,
42
+ rewriteGitAutonomySystem,
43
+ rewriteGitAutonomyTool,
44
+ } from "./git-autonomy.js";
45
+ export { cmdHookMvGuard, mvGuardDecision } from "./mv-guard.js";
46
+ export {
47
+ COMMIT_HINT_MIN_COMMITS,
48
+ type CommitHintInput,
49
+ cmdHookReadHint,
50
+ commitHintFor,
51
+ READ_HINT_MIN_BYTES,
52
+ READ_HINT_MIN_LIMIT,
53
+ type ReadHintInput,
54
+ type ReadTrackRow,
55
+ type RereadHintInput,
56
+ readHintFor,
57
+ readTrackPath,
58
+ rereadHintFor,
59
+ } from "./read-hint.js";
60
+ export {
61
+ capContext,
62
+ cmdHookSessionStart,
63
+ SESSION_START_MAX_CHARS,
64
+ sessionStartContext,
65
+ } from "./session-start.js";
66
+ export {
67
+ bugSignalFromTranscript,
68
+ cmdHookStop,
69
+ cursorTranscriptPath,
70
+ decideStop,
71
+ isCodexPayload,
72
+ isCursorPayload,
73
+ type NormalizedStopInput,
74
+ normalizeStopInput,
75
+ type RawStopPayload,
76
+ type StopClient,
77
+ stopBlockedBefore,
78
+ stopBlockPath,
79
+ stopOutput,
80
+ } from "./stop.js";
@@ -0,0 +1,52 @@
1
+ // src/adapters/hooks/mv-guard.ts — PreToolUse Bash guard: deny a raw `git mv`
2
+ // of a plan file into a done/ directory, point at `fapony mem plan-sweep
3
+ // --apply` instead.
4
+ //
5
+ // Manual `git mv` skips the link rewrite plan-sweep does — that produced two
6
+ // rounds of dangling links (mem mtjn3ldk, mtl15q4y) and once, a plan moved to
7
+ // a path that doesn't exist (.fapony/plan/done/ instead of .fapony/done/).
8
+ // Every other fapony hook only annotates; this one denies, because asking
9
+ // ("should use plan-sweep") measurably does not change agent behavior —
10
+ // only required/reject and Stop-style blocks do (CLAUDE.md rule 9).
11
+ //
12
+ // Claude-only: OpenCode's tool.execute.after fires after the mv already ran,
13
+ // so there is nothing left to deny by the time that hook sees it.
14
+
15
+ const MV_PATTERN =
16
+ /git\s+mv\s+(?:-\S+\s+)*["']?([^"'\s]*\.fapony\/(?:[^/\s]+\/)*plan\/PLAN-[^"'\s]+\.md)["']?\s+["']?([^"'\s]*\bdone\/?)["']?/;
17
+
18
+ /** Deny reason for a raw `git mv <plan>.md <...done/>` command, or null to allow. */
19
+ export function mvGuardDecision(command: unknown): string | null {
20
+ if (typeof command !== "string" || command === "") return null;
21
+ const m = MV_PATTERN.exec(command);
22
+ if (!m) return null;
23
+ const [, src] = m;
24
+ return (
25
+ `fapony: raw \`git mv\` of a plan into done/ skips the link rewrite — ` +
26
+ `run \`fapony mem plan-sweep --apply ${src}\` instead, it moves the file ` +
27
+ `and fixes inbound/outbound links together.`
28
+ );
29
+ }
30
+
31
+ /** Claude Code PreToolUse (matcher Bash): stdin JSON in, deny decision out. */
32
+ export async function cmdHookMvGuard(): Promise<void> {
33
+ try {
34
+ const raw = JSON.parse(await Bun.stdin.text()) as {
35
+ tool_input?: { command?: unknown };
36
+ };
37
+ const reason = mvGuardDecision(raw.tool_input?.command);
38
+ if (reason) {
39
+ console.log(
40
+ JSON.stringify({
41
+ hookSpecificOutput: {
42
+ hookEventName: "PreToolUse",
43
+ permissionDecision: "deny",
44
+ permissionDecisionReason: reason,
45
+ },
46
+ }),
47
+ );
48
+ }
49
+ } catch {
50
+ // a parse failure must never block a command — silence, not deny
51
+ }
52
+ }