fapony 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +144 -156
  2. package/package.json +5 -6
  3. package/skill/define-convention/SKILL.md +3 -3
  4. package/skill/lookup-before-edit/SKILL.md +2 -1
  5. package/skill/move-to-done/SKILL.md +18 -22
  6. package/skill/plan-with-pony/SKILL.md +4 -4
  7. package/skill/review-pony/SKILL.md +10 -12
  8. package/src/adapters/cli.ts +11 -52
  9. package/src/adapters/hooks/index.ts +0 -67
  10. package/src/adapters/hooks/mv-guard.ts +2 -2
  11. package/src/analyze/discover.ts +1 -1
  12. package/src/analyze/index.ts +0 -1
  13. package/src/commands.ts +8 -39
  14. package/src/core/config.ts +0 -6
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +1 -2
  17. package/src/debt/load.ts +3 -5
  18. package/src/debt/promotion.ts +2 -2
  19. package/src/debt/scan.ts +1 -1
  20. package/src/digest/collect.ts +6 -7
  21. package/src/fael.ts +139 -0
  22. package/src/hook.ts +1 -81
  23. package/src/init.ts +30 -90
  24. package/src/install/antigravity.ts +8 -99
  25. package/src/install/claude.ts +66 -200
  26. package/src/install/codex.ts +6 -170
  27. package/src/install/detect.ts +4 -14
  28. package/src/install/opencode.ts +50 -486
  29. package/src/install/types.ts +1 -39
  30. package/src/install/zcode.ts +5 -91
  31. package/src/install.ts +9 -35
  32. package/src/memory.ts +10 -69
  33. package/src/plan/index.ts +34 -0
  34. package/src/plan/next.ts +181 -0
  35. package/src/plan/store.ts +41 -0
  36. package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
  37. package/src/seed/plan-seed.ts +10 -9
  38. package/src/setup.ts +4 -22
  39. package/src/update.ts +1 -1
  40. package/templates/PLAN.md +1 -1
  41. package/src/adapters/hooks/bug-markers.ts +0 -60
  42. package/src/adapters/hooks/context-data.ts +0 -139
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -410
  45. package/src/adapters/hooks/session-start.ts +0 -112
  46. package/src/adapters/hooks/stop.ts +0 -768
  47. package/src/adapters/mcp/tools/index.ts +0 -149
  48. package/src/adapters/mcp/tools/mem.ts +0 -252
  49. package/src/adapters/mcp/transport.ts +0 -147
  50. package/src/analyze/cache.ts +0 -162
  51. package/src/core/mem-log.ts +0 -375
  52. package/src/init-mem.ts +0 -143
  53. package/src/install/cursor.ts +0 -167
  54. package/src/install/utils.ts +0 -29
  55. package/src/mem/commands/read.ts +0 -738
  56. package/src/mem/commands/rotate.ts +0 -59
  57. package/src/mem/commands/where.ts +0 -56
  58. package/src/mem/commands/write.ts +0 -273
  59. package/src/mem/engine.ts +0 -315
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/render.ts +0 -66
  62. package/src/mem/selectors.ts +0 -160
  63. package/src/mem/store.ts +0 -356
@@ -1,215 +0,0 @@
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/index.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
- if (ctx && ctx.memLines.length > 0) {
188
- recordHintFire({
189
- ts: new Date().toISOString(),
190
- worktree,
191
- surface: "mem",
192
- file: rel && !rel.startsWith("..") ? rel : null,
193
- count: ctx.memLines.length,
194
- ids: ctx.memIds,
195
- });
196
- }
197
- if (ctx && ctx.openBugIds.length > 0) {
198
- recordHintFire({
199
- ts: new Date().toISOString(),
200
- worktree,
201
- surface: "open-bug",
202
- file: rel && !rel.startsWith("..") ? rel : null,
203
- count: ctx.openBugIds.length,
204
- ids: ctx.openBugIds,
205
- });
206
- }
207
- }
208
- } catch {
209
- // best-effort — swallow
210
- }
211
- }
212
- } catch {
213
- // any failure = no hint; a hook must never block an edit over a hint
214
- }
215
- }
@@ -1,410 +0,0 @@
1
- // src/adapters/hooks/read-hint.ts — Read hint + re-read hint + commit hint
2
- //
3
- // Split from src/hook.ts (PLAN-lib-layer chunk 3). Attaches size/read context
4
- // to file reads. Also includes commitHintFor (OpenCode commit hint).
5
-
6
- import {
7
- appendFileSync,
8
- existsSync,
9
- mkdirSync,
10
- readFileSync,
11
- realpathSync,
12
- statSync,
13
- } from "node:fs";
14
- import { homedir } from "node:os";
15
- import { join, relative, resolve } from "node:path";
16
- import { SCAN_EXTS } from "../../analyze/index.js";
17
- import { recordHintFire } from "../../core/hint-log.js";
18
- import { sessionKey } from "../../core/hook-helpers.js";
19
- import { readMemLog } from "../../memory.js";
20
- import { renderSeed } from "../../seed/review-seed.js";
21
- import { hasBugMarker, isBugfixCommit } from "./bug-markers.js";
22
- import { readContextData } from "./context-data.js";
23
-
24
- // --- Read hint (size) ---
25
-
26
- /** Below this size a full read is already cheap — stay silent. */
27
- export const READ_HINT_MIN_BYTES = 24_000;
28
- /** A caller-chosen limit below this is a bounded read — already cheap. */
29
- export const READ_HINT_MIN_LIMIT = 300;
30
- /** One-time measurement (2026-09-17, this repo): 5 files / 2,146 lines ≈ 3.7KB out. */
31
- const READ_HINT_MEASURED = "measured ~3.7KB output on a 2,146-line file";
32
- /** Cap on the outline attached to the read hint — keeps the hint compact. */
33
- const READ_HINT_OUTLINE_CAP = 2_000;
34
-
35
- export interface ReadHintInput {
36
- filePath: unknown;
37
- offset?: unknown;
38
- limit?: unknown;
39
- cwd: string;
40
- }
41
-
42
- /**
43
- * Factual one-liner for a full-file read of a large source file, or null.
44
- * Every unknown resolves to null — a hint must never fire on a guess.
45
- */
46
- export function readHintFor(opts: ReadHintInput): string | null {
47
- try {
48
- if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
49
- const dot = opts.filePath.lastIndexOf(".");
50
- if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
51
- const limit = typeof opts.limit === "number" ? opts.limit : null;
52
- if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
53
- const st = statSync(opts.filePath);
54
- if (!st.isFile() || st.size < READ_HINT_MIN_BYTES) return null;
55
- const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
56
- cwd: opts.cwd,
57
- stdout: "pipe",
58
- stderr: "pipe",
59
- });
60
- if (git.exitCode !== 0) return null;
61
- const lines = readFileSync(opts.filePath, "utf-8").split("\n").length;
62
- const rel = relative(opts.cwd, opts.filePath);
63
- const shown = rel.startsWith("..") ? opts.filePath : rel;
64
-
65
- let outline = "";
66
- try {
67
- const seed = renderSeed(["--files", shown], opts.cwd);
68
- const sigMatch = seed.match(
69
- /signatures \(current\):\n([\s\S]*?)(?:\n\w|\nstatic graph)/,
70
- );
71
- if (sigMatch) {
72
- outline = sigMatch[1].trim();
73
- } else {
74
- const afterFiles = seed.indexOf("\nimporters");
75
- if (afterFiles > 0) {
76
- outline = seed
77
- .slice(0, Math.min(afterFiles, READ_HINT_OUTLINE_CAP))
78
- .trim();
79
- } else {
80
- outline = seed.slice(0, READ_HINT_OUTLINE_CAP).trim();
81
- }
82
- }
83
- if (outline.length > READ_HINT_OUTLINE_CAP) {
84
- outline = `${outline.slice(0, READ_HINT_OUTLINE_CAP).trimEnd()}\n… truncated`;
85
- }
86
- } catch {
87
- // review-seed failed — fall back to the command suggestion
88
- }
89
-
90
- if (outline) {
91
- return (
92
- `fapony: ${shown} is ${lines} lines\n${outline}\n` +
93
- `(review-seed --files ${shown} for importers + callers; skill /lookup-before-edit)`
94
- );
95
- }
96
- return (
97
- `fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
98
- `returns exports with line numbers, importers, and signatures first ` +
99
- `(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
100
- );
101
- } catch {
102
- return null;
103
- }
104
- }
105
-
106
- // --- Re-read tracking (mtime heuristic) ---
107
-
108
- const READ_TRACK_DIR = "read-track";
109
-
110
- export interface ReadTrackRow {
111
- ts: string;
112
- path: string;
113
- mtime: number;
114
- }
115
-
116
- /** Directory holding one read log per session. */
117
- function readTrackDir(): string {
118
- const base =
119
- process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
120
- return join(base, READ_TRACK_DIR);
121
- }
122
-
123
- /** Absolute path of a session's read log — may not exist. */
124
- export function readTrackPath(session: string): string {
125
- return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
126
- }
127
-
128
- function readTrackRows(session: string): ReadTrackRow[] {
129
- const p = readTrackPath(session);
130
- if (!existsSync(p)) return [];
131
- const rows: ReadTrackRow[] = [];
132
- for (const line of readFileSync(p, "utf-8").split("\n")) {
133
- if (!line) continue;
134
- try {
135
- const r = JSON.parse(line) as ReadTrackRow;
136
- if (typeof r.path === "string" && typeof r.mtime === "number") {
137
- rows.push(r);
138
- }
139
- } catch {
140
- // a torn line must not lose the rest of the log
141
- }
142
- }
143
- return rows;
144
- }
145
-
146
- function appendReadTrackRow(session: string, row: ReadTrackRow): void {
147
- const dir = readTrackDir();
148
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
149
- appendFileSync(readTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
150
- }
151
-
152
- export interface RereadHintInput {
153
- filePath: unknown;
154
- offset?: unknown;
155
- limit?: unknown;
156
- cwd: string;
157
- session?: unknown;
158
- }
159
-
160
- /**
161
- * Annotate a full-file read of a path already read this session whose mtime
162
- * has not moved, or null. Records every full read.
163
- */
164
- export function rereadHintFor(opts: RereadHintInput): string | null {
165
- try {
166
- if (process.env.FAPONY_NO_REREAD_HINT === "1") return null;
167
- if (typeof opts.session !== "string" || opts.session === "") return null;
168
- if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
169
- const offset = typeof opts.offset === "number" ? opts.offset : null;
170
- if (offset !== null && offset !== 0) return null;
171
- const limit = typeof opts.limit === "number" ? opts.limit : null;
172
- if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
173
-
174
- const abs = (() => {
175
- const p = opts.filePath.startsWith("/")
176
- ? opts.filePath
177
- : join(opts.cwd, opts.filePath);
178
- try {
179
- return realpathSync(p);
180
- } catch {
181
- return resolve(p);
182
- }
183
- })();
184
- const st = statSync(abs);
185
- if (!st.isFile()) return null;
186
- const mtime = Math.round(st.mtimeMs);
187
-
188
- const prior = readTrackRows(opts.session).filter((r) => r.path === abs);
189
- const last = prior.at(-1);
190
- appendReadTrackRow(opts.session, {
191
- ts: new Date().toISOString(),
192
- path: abs,
193
- mtime,
194
- });
195
- if (!last || last.mtime !== mtime) return null;
196
-
197
- const rel = relative(opts.cwd, opts.filePath);
198
- const shown = rel.startsWith("..") ? opts.filePath : rel;
199
- return (
200
- `fapony: already read ${shown} ${prior.length}\u00d7 this session — ` +
201
- `content unchanged since the last read (mtime), grep the line range ` +
202
- `you need instead of re-reading it`
203
- );
204
- } catch {
205
- return null;
206
- }
207
- }
208
-
209
- // --- Commit hint (OpenCode tool.execute.after) ---
210
-
211
- /** Below this number of commits, the hint is unnecessary noise. */
212
- export const COMMIT_HINT_MIN_COMMITS = 1;
213
-
214
- export interface CommitHintInput {
215
- command: unknown;
216
- cwd: string;
217
- }
218
-
219
- function git(args: string[], cwd: string): string | null {
220
- try {
221
- const p = Bun.spawnSync(["git", ...args], {
222
- cwd,
223
- stdout: "pipe",
224
- stderr: "pipe",
225
- });
226
- return p.exitCode === 0 ? p.stdout.toString().trim() : null;
227
- } catch {
228
- return null;
229
- }
230
- }
231
-
232
- import { hookTsMs, utcStamp } from "../../core/hook-helpers.js";
233
-
234
- /**
235
- * Nudge for bash commands containing `git commit` that produced commits with
236
- * no mem row recorded for them.
237
- */
238
- export function commitHintFor(opts: CommitHintInput): string | null {
239
- try {
240
- if (typeof opts.command !== "string" || opts.command === "") return null;
241
- if (!/\bgit\s+commit\b/.test(opts.command)) return null;
242
-
243
- const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
244
- if (!worktree) return null;
245
-
246
- let memLastTs: string | null = null;
247
- try {
248
- // Anchor at the dir the commit ran in, not the repo root: the log is
249
- // app-scoped in a monorepo, and root resolution misses it (bug muc9q47r).
250
- memLastTs = readMemLog(opts.cwd).rows[0]?.ts ?? null;
251
- } catch {
252
- memLastTs = null;
253
- }
254
- if (!memLastTs) return null;
255
- const since = utcStamp(new Date(hookTsMs(memLastTs) + 1000));
256
-
257
- const log = git(
258
- ["log", "--since", `${since} +0000`, "--format=%h %s"],
259
- worktree,
260
- );
261
- const commitList = log ? log.split("\n").filter(Boolean) : [];
262
- if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
263
-
264
- // %h %s — strip the short hash to test the subject alone.
265
- const subjectOf = (c: string) => c.replace(/^\S+\s+/, "");
266
- const bugCommits = commitList.filter(
267
- (c) =>
268
- isBugfixCommit(subjectOf(c)) || hasBugMarker(subjectOf(c)) !== null,
269
- );
270
-
271
- const lines: string[] = [
272
- `${commitList.length} commit(s) since last mem row (${memLastTs.slice(0, 10)}) — record a mem row for this work.`,
273
- ];
274
- for (const c of commitList.slice(0, 5)) lines.push(` ${c}`);
275
- if (commitList.length > 5) lines.push(` … +${commitList.length - 5} more`);
276
- lines.push(
277
- `fapony mem add <decision|bug|note> "what happened" --files <files> ${worktree}/.fapony/plan/PLAN.md`,
278
- );
279
- if (bugCommits.length > 0) {
280
- lines.push(
281
- `${bugCommits.length} of these read as a bug (fix-type commit or found-a-bug wording) — use kind:bug so it surfaces later, not note:`,
282
- );
283
- lines.push(` fapony mem add bug "what broke" --files <files>`);
284
- }
285
-
286
- const prefixed = lines.map((l) => `fapony: ${l}`).join("\n");
287
- return prefixed;
288
- } catch {
289
- return null;
290
- }
291
- }
292
-
293
- // --- Read hint CLI command ---
294
-
295
- /** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out. */
296
- export async function cmdHookReadHint(): Promise<void> {
297
- try {
298
- const raw = JSON.parse(await Bun.stdin.text()) as {
299
- cwd?: string;
300
- transcript_path?: string;
301
- session_id?: string;
302
- tool_input?: {
303
- file_path?: unknown;
304
- offset?: unknown;
305
- limit?: unknown;
306
- };
307
- };
308
- const cwd = raw.cwd ?? process.cwd();
309
- const filePath = raw.tool_input?.file_path;
310
- const session = raw.transcript_path ?? raw.session_id;
311
- const parts: string[] = [];
312
- const hint = readHintFor({
313
- filePath,
314
- offset: raw.tool_input?.offset,
315
- limit: raw.tool_input?.limit,
316
- cwd,
317
- });
318
- if (hint) parts.push(hint);
319
- const reread = rereadHintFor({
320
- filePath,
321
- offset: raw.tool_input?.offset,
322
- limit: raw.tool_input?.limit,
323
- cwd,
324
- session,
325
- });
326
- if (reread) parts.push(reread);
327
- const ctx = readContextData(filePath, cwd);
328
- if (ctx) {
329
- for (const line of [...ctx.debtLines, ...ctx.memLines]) {
330
- parts.push(line);
331
- }
332
- }
333
- if (parts.length > 0) {
334
- console.log(
335
- JSON.stringify({
336
- hookSpecificOutput: {
337
- hookEventName: "PreToolUse",
338
- additionalContext: parts.join("\n"),
339
- },
340
- }),
341
- );
342
- }
343
-
344
- // --- hint-fire log ---
345
- const rel =
346
- typeof filePath === "string"
347
- ? (() => {
348
- try {
349
- const git = Bun.spawnSync(
350
- ["git", "rev-parse", "--show-toplevel"],
351
- { cwd, stdout: "pipe", stderr: "pipe" },
352
- );
353
- if (git.exitCode !== 0) return null;
354
- const wt = realpathSync(git.stdout.toString().trim());
355
- const abs = realpathSync(
356
- filePath.startsWith("/") ? filePath : join(wt, filePath),
357
- );
358
- const r = relative(wt, abs).split("\\").join("/");
359
- return r.startsWith("..") ? null : r;
360
- } catch {
361
- return null;
362
- }
363
- })()
364
- : null;
365
- const worktree = ctx?.worktree ?? null;
366
- if (worktree) {
367
- if (hint) {
368
- recordHintFire({
369
- ts: new Date().toISOString(),
370
- worktree,
371
- surface: "read",
372
- file: rel,
373
- count: 1,
374
- });
375
- }
376
- if (ctx && ctx.debtIds.length > 0) {
377
- recordHintFire({
378
- ts: new Date().toISOString(),
379
- worktree,
380
- surface: "debt",
381
- file: rel,
382
- count: ctx.debtIds.length,
383
- ids: ctx.debtIds,
384
- });
385
- }
386
- if (ctx && ctx.memLines.length > 0) {
387
- recordHintFire({
388
- ts: new Date().toISOString(),
389
- worktree,
390
- surface: "mem",
391
- file: rel,
392
- count: ctx.memLines.length,
393
- ids: ctx.memIds,
394
- });
395
- }
396
- if (ctx && ctx.openBugIds.length > 0) {
397
- recordHintFire({
398
- ts: new Date().toISOString(),
399
- worktree,
400
- surface: "open-bug",
401
- file: rel,
402
- count: ctx.openBugIds.length,
403
- ids: ctx.openBugIds,
404
- });
405
- }
406
- }
407
- } catch {
408
- // any failure = no hint; a hook must never block a read over a hint
409
- }
410
- }