fapony 0.1.3 → 0.2.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 (51) hide show
  1. package/README.md +111 -46
  2. package/fapony.ts +35 -2
  3. package/package.json +3 -2
  4. package/skill/move-to-done/SKILL.md +18 -28
  5. package/skill/plan-with-pony/SKILL.md +8 -3
  6. package/src/analyze.ts +175 -5
  7. package/src/conventions-seed.ts +3 -3
  8. package/src/db/defaults.ts +13 -5
  9. package/src/db/getters.ts +11 -9
  10. package/src/db/load.ts +2 -2
  11. package/src/db/store.ts +0 -75
  12. package/src/db/types.ts +2 -4
  13. package/src/debt.ts +176 -36
  14. package/src/digest/collect.ts +19 -2
  15. package/src/digest/text.ts +25 -0
  16. package/src/hook.ts +665 -24
  17. package/src/init-mem.ts +123 -37
  18. package/src/init.ts +57 -39
  19. package/src/install/claude.ts +44 -8
  20. package/src/install/codex.ts +105 -13
  21. package/src/install/opencode.ts +169 -5
  22. package/src/install.ts +2 -1
  23. package/src/lint-baseline.ts +3 -8
  24. package/src/mcp/evidence.ts +15 -3
  25. package/src/mcp/tools/index.ts +97 -185
  26. package/src/mcp/tools/mem.ts +153 -11
  27. package/src/mcp/transport.ts +5 -13
  28. package/src/mem/commands/read.ts +448 -0
  29. package/src/mem/commands/where.ts +56 -0
  30. package/{templates → src}/mem/commands/write.ts +12 -5
  31. package/src/mem/index.ts +144 -0
  32. package/src/mem/store.ts +350 -0
  33. package/src/memory.ts +238 -40
  34. package/src/plan-seed.ts +26 -6
  35. package/src/review-seed.ts +19 -0
  36. package/src/setup.ts +7 -8
  37. package/src/stats/data.ts +40 -104
  38. package/src/stats/format.ts +8 -9
  39. package/src/stats/index.ts +0 -1
  40. package/templates/PLAN.md +1 -0
  41. package/src/mcp/tools/context.ts +0 -66
  42. package/src/mcp/tools/plans.ts +0 -255
  43. package/src/mcp/tools/stats.ts +0 -96
  44. package/templates/mem/commands/read.ts +0 -194
  45. package/templates/mem/commands/selftest.ts +0 -450
  46. package/templates/mem/mem.ts +0 -68
  47. package/templates/mem/store.ts +0 -285
  48. /package/{templates → src}/mem/commands/plan.ts +0 -0
  49. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  50. /package/{templates → src}/mem/render.ts +0 -0
  51. /package/{templates → src}/mem/selectors.ts +0 -0
package/src/hook.ts CHANGED
@@ -16,14 +16,198 @@
16
16
  // cursor {workspace_roots, conversation_id, loop_count, status} → {"followup_message"}
17
17
  // (cursor: loop_count ≥ 1 = the hook already fired, status ≠ completed = allow)
18
18
 
19
- import { readFileSync, realpathSync, statSync } from "node:fs";
19
+ import {
20
+ appendFileSync,
21
+ existsSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ readFileSync,
25
+ realpathSync,
26
+ statSync,
27
+ } from "node:fs";
20
28
  import { homedir } from "node:os";
21
- import { basename, join, relative } from "node:path";
22
- import { collectSourceFiles, SCAN_EXTS } from "./analyze.js";
29
+ import { basename, join, relative, resolve, sep } from "node:path";
30
+ import { buildGraphCached, collectSourceFiles, SCAN_EXTS } from "./analyze.js";
23
31
  import { openDb } from "./db/index.js";
24
32
  import { debtForFile, loadConventions } from "./debt.js";
25
33
  import { readMemLog } from "./memory.js";
26
34
 
35
+ // --- Hint-fire log (PLAN-feedback-surface chunk 1) ---
36
+ //
37
+ // Append-only JSONL under state dir (<faponyDir>/hint-log/<key>.jsonl),
38
+ // one file per worktree. Best-effort: every error swallowed — a hook that
39
+ // cannot log must still annotate. Called at caller only (cmdHookReadHint +
40
+ // opencode plugins), never inside readHintFor/readContextLines/commitHintFor
41
+ // (test pollution: those functions are called ~20x in test/hook.test.ts
42
+ // without setting FAPONY_STATE_DIR).
43
+
44
+ const HINT_LOG_DIR = "hint-log";
45
+
46
+ /** Stable filename key from an absolute worktree path. */
47
+ export function worktreeKey(worktree: string): string {
48
+ return worktree.replace(/^\/+/, "").replace(/\//g, "--");
49
+ }
50
+
51
+ /** Directory holding one hint-fire log file per worktree. */
52
+ function hintLogDir(): string {
53
+ const base =
54
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
55
+ return join(base, HINT_LOG_DIR);
56
+ }
57
+
58
+ /** Absolute path of a worktree's hint-fire log — may not exist. */
59
+ export function hintLogPath(worktree: string): string {
60
+ return join(hintLogDir(), `${worktreeKey(worktree)}.jsonl`);
61
+ }
62
+
63
+ export interface HintFireRow {
64
+ ts: string;
65
+ worktree: string;
66
+ surface: "read" | "debt" | "mem" | "commit" | "edit";
67
+ file: string | null;
68
+ count: number;
69
+ ids?: string[];
70
+ }
71
+
72
+ /**
73
+ * Append a hint-fire log row. Best-effort: never throws, never blocks.
74
+ * Uses $FAPONY_STATE_DIR when set (tests, CI), otherwise ~/.config/fapony.
75
+ */
76
+ export function recordHintFire(row: HintFireRow): void {
77
+ try {
78
+ const dir = hintLogDir();
79
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
80
+ appendFileSync(
81
+ hintLogPath(row.worktree),
82
+ `${JSON.stringify(row)}\n`,
83
+ "utf-8",
84
+ );
85
+ } catch {
86
+ // best-effort — swallow
87
+ }
88
+ }
89
+
90
+ // --- Debt precision (PLAN-feedback-surface chunk 2) ---
91
+ //
92
+ // Reads the hint-fire log, re-runs debtForFile at HEAD for each file that
93
+ // received debt hints, and counts which ids are no longer flagged. This is
94
+ // deterministic (no proxy, no join with events) and answers: of the debt
95
+ // lines fapony showed, how many is the repo now clean of?
96
+
97
+ export interface HintImpact {
98
+ fired: number;
99
+ by_surface: {
100
+ read: number;
101
+ debt: number;
102
+ mem: number;
103
+ commit: number;
104
+ edit: number;
105
+ };
106
+ debt: { shown: number; resolved: number; unknown: number };
107
+ window: string | null;
108
+ }
109
+
110
+ /**
111
+ * Compute hint-fire impact from the log. `since` is an ISO date string;
112
+ * omit to scan all rows. `worktree` scopes to one project's log file (the
113
+ * `<key>.jsonl` naming makes this a filename comparison) — omit to merge
114
+ * every worktree. Returns zeroed counts (not null) when there are no rows —
115
+ * the caller decides how to present "no data" vs "zero".
116
+ */
117
+ export function computeHintImpact(
118
+ since?: string,
119
+ worktree?: string,
120
+ ): HintImpact {
121
+ const dir = hintLogDir();
122
+ const impact: HintImpact = {
123
+ fired: 0,
124
+ by_surface: { read: 0, debt: 0, mem: 0, commit: 0, edit: 0 },
125
+ debt: { shown: 0, resolved: 0, unknown: 0 },
126
+ window: since ?? null,
127
+ };
128
+
129
+ if (!existsSync(dir)) return impact;
130
+
131
+ // Read the worktree's .jsonl when scoped, else every file in the dir.
132
+ let files: string[];
133
+ try {
134
+ const all = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
135
+ files = worktree
136
+ ? all.filter((f) => f === `${worktreeKey(worktree)}.jsonl`)
137
+ : all;
138
+ } catch {
139
+ return impact;
140
+ }
141
+
142
+ // debtShown: Map<"worktree\tfile\tid", true> — unique debt ids per file.
143
+ const debtShown = new Map<string, true>();
144
+ // debtByFile: Map<"worktree\tfile", string[]> — all ids shown per file.
145
+ const debtByFile = new Map<string, string[]>();
146
+
147
+ for (const file of files) {
148
+ let content: string;
149
+ try {
150
+ content = readFileSync(join(dir, file), "utf-8");
151
+ } catch {
152
+ continue;
153
+ }
154
+ for (const line of content.split("\n")) {
155
+ if (!line) continue;
156
+ let row: HintFireRow;
157
+ try {
158
+ row = JSON.parse(line) as HintFireRow;
159
+ } catch {
160
+ continue;
161
+ }
162
+ if (since && row.ts < since) continue;
163
+ impact.fired++;
164
+ impact.by_surface[row.surface]++;
165
+
166
+ if (row.surface === "debt" && row.ids && row.file) {
167
+ const key = `${row.worktree}\t${row.file}`;
168
+ const existing = debtByFile.get(key) ?? [];
169
+ for (const id of row.ids) {
170
+ const dk = `${row.worktree}\t${row.file}\t${id}`;
171
+ if (!debtShown.has(dk)) {
172
+ debtShown.set(dk, true);
173
+ existing.push(id);
174
+ }
175
+ }
176
+ debtByFile.set(key, existing);
177
+ }
178
+ }
179
+ }
180
+
181
+ // Re-run debtForFile at HEAD for each file that had debt hints.
182
+ for (const [key, ids] of debtByFile) {
183
+ const [worktree, file] = key.split("\t");
184
+ const absFile = join(worktree, file);
185
+ let currentIds: Set<string>;
186
+ try {
187
+ if (!statSync(absFile).isFile()) {
188
+ // File deleted — all its debt ids are unknown.
189
+ impact.debt.unknown += ids.length;
190
+ continue;
191
+ }
192
+ const convs = debtForFile(worktree, absFile, loadConventions(worktree));
193
+ currentIds = new Set(convs.map((c) => c.id));
194
+ } catch {
195
+ impact.debt.unknown += ids.length;
196
+ continue;
197
+ }
198
+ for (const id of ids) {
199
+ impact.debt.shown++;
200
+ if (currentIds.has(id)) {
201
+ // still present — not resolved
202
+ } else {
203
+ impact.debt.resolved++;
204
+ }
205
+ }
206
+ }
207
+
208
+ return impact;
209
+ }
210
+
27
211
  export interface RawStopPayload {
28
212
  // Claude Code
29
213
  cwd?: string;
@@ -34,9 +218,13 @@ export interface RawStopPayload {
34
218
  conversation_id?: string;
35
219
  loop_count?: number;
36
220
  status?: string;
221
+ // Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
222
+ session_id?: string;
223
+ model?: string;
224
+ permission_mode?: string;
37
225
  }
38
226
 
39
- export type StopClient = "claude" | "cursor";
227
+ export type StopClient = "claude" | "cursor" | "codex";
40
228
 
41
229
  export interface NormalizedStopInput {
42
230
  client: StopClient;
@@ -141,11 +329,32 @@ export function isCursorPayload(raw: RawStopPayload): boolean {
141
329
  );
142
330
  }
143
331
 
144
- /** Field-mapping only — both clients feed the same decideStop below. */
332
+ /** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
333
+ export function isCodexPayload(raw: RawStopPayload): boolean {
334
+ return (
335
+ typeof raw.permission_mode === "string" ||
336
+ (typeof raw.model === "string" && !isCursorPayload(raw))
337
+ );
338
+ }
339
+
340
+ /** Field-mapping only — all three clients feed the same decideStop below. */
145
341
  export function normalizeStopInput(
146
342
  raw: RawStopPayload,
147
343
  home: string,
148
344
  ): NormalizedStopInput {
345
+ if (isCodexPayload(raw)) {
346
+ // Codex: cwd is the session working directory; stop_hook_active means
347
+ // the hook already fired once (same semantics as Claude).
348
+ return {
349
+ client: "codex",
350
+ cwd: raw.cwd ?? process.cwd(),
351
+ transcriptPath:
352
+ typeof raw.transcript_path === "string" && raw.transcript_path
353
+ ? raw.transcript_path
354
+ : null,
355
+ stopHookActive: raw.stop_hook_active === true,
356
+ };
357
+ }
149
358
  if (isCursorPayload(raw)) {
150
359
  const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
151
360
  let transcriptPath =
@@ -172,12 +381,13 @@ export function normalizeStopInput(
172
381
  };
173
382
  }
174
383
 
175
- /** Claude blocks with decision:block; Cursor's stop hook "blocks" by
176
- * auto-submitting the reason as the next user message. */
384
+ /** Claude blocks with decision:block; Cursor auto-submits as followup_message;
385
+ * Codex continues with decision:block + reason (continue:false would take
386
+ * precedence and end the turn instead — Codex Hooks, Stop section). */
177
387
  export function stopOutput(client: StopClient, reason: string): string {
178
- return client === "cursor"
179
- ? JSON.stringify({ followup_message: reason })
180
- : JSON.stringify({ decision: "block", reason });
388
+ if (client === "cursor") return JSON.stringify({ followup_message: reason });
389
+ if (client === "codex") return JSON.stringify({ decision: "block", reason });
390
+ return JSON.stringify({ decision: "block", reason });
181
391
  }
182
392
 
183
393
  /** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
@@ -192,6 +402,7 @@ export async function cmdHookStop(): Promise<void> {
192
402
  // died — commits from those turns are still caught at the next completed
193
403
  // stop (the window is the conversation transcript's birthtime).
194
404
  if (client === "cursor" && raw.status !== "completed") return;
405
+ // Codex: no status guard needed — Stop fires at turn end unconditionally.
195
406
 
196
407
  const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
197
408
 
@@ -319,6 +530,268 @@ export function readHintFor(opts: ReadHintInput): string | null {
319
530
  }
320
531
  }
321
532
 
533
+ // --- Re-read tracking (mtime heuristic — annotate only) ---
534
+ //
535
+ // The size hint above fires on almost nothing real: measured 2026-09-20 over
536
+ // 28,151 read parts across every project, only 2.3% had fileSize>=24KB with a
537
+ // full bound, while 55.5% of read context came from files under 24KB and 19.5%
538
+ // from re-reading a path already read this session (39.2% of context sits in
539
+ // (session,path) pairs read >=2x). The cost is repetition, not one big file —
540
+ // so the gate here is not size, it is "same file, unchanged, again".
541
+ //
542
+ // mtime is the whole mechanism: unchanged mtime since the previous read in
543
+ // this session = the same bytes = annotate; mtime moved = someone edited it =
544
+ // new content = silent. No Edit/Grep tracking — mtime already answers "did it
545
+ // change", so no tool-sequence tagging is needed.
546
+ //
547
+ // Log lives beside hint-log but is separate — hint-log counts fires, this
548
+ // records reads keyed by session (one file per session, so a hint never leaks
549
+ // into the next session / rule 11). Same contract as the size hint: annotate
550
+ // only, never block, every unknown → silent, best-effort. Called at the caller
551
+ // only, never inside a pure function (test pollution — same reason
552
+ // recordHintFire is caller-side).
553
+
554
+ const READ_TRACK_DIR = "read-track";
555
+
556
+ export interface ReadTrackRow {
557
+ ts: string;
558
+ path: string;
559
+ mtime: number;
560
+ }
561
+
562
+ /** Filename key for a session — basename of a transcript path or a raw id. */
563
+ export function sessionKey(session: string): string {
564
+ const base = basename(session).replace(/\.[^.]+$/, "");
565
+ return base.replace(/[^A-Za-z0-9_-]/g, "-") || "unknown";
566
+ }
567
+
568
+ /** Directory holding one read log per session. */
569
+ function readTrackDir(): string {
570
+ const base =
571
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
572
+ return join(base, READ_TRACK_DIR);
573
+ }
574
+
575
+ /** Absolute path of a session's read log — may not exist. */
576
+ export function readTrackPath(session: string): string {
577
+ return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
578
+ }
579
+
580
+ function readTrackRows(session: string): ReadTrackRow[] {
581
+ const p = readTrackPath(session);
582
+ if (!existsSync(p)) return [];
583
+ const rows: ReadTrackRow[] = [];
584
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
585
+ if (!line) continue;
586
+ try {
587
+ const r = JSON.parse(line) as ReadTrackRow;
588
+ if (typeof r.path === "string" && typeof r.mtime === "number") {
589
+ rows.push(r);
590
+ }
591
+ } catch {
592
+ // a torn line must not lose the rest of the log
593
+ }
594
+ }
595
+ return rows;
596
+ }
597
+
598
+ function appendReadTrackRow(session: string, row: ReadTrackRow): void {
599
+ const dir = readTrackDir();
600
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
601
+ appendFileSync(readTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
602
+ }
603
+
604
+ export interface RereadHintInput {
605
+ filePath: unknown;
606
+ offset?: unknown;
607
+ limit?: unknown;
608
+ cwd: string;
609
+ /** Session identity — transcript path (Claude) or session id (OpenCode). */
610
+ session?: unknown;
611
+ }
612
+
613
+ /**
614
+ * Annotate a full-file read of a path already read this session whose mtime
615
+ * has not moved, or null. Records every full read, so the returned count is
616
+ * the number of prior reads. Every unknown (kill switch on, no session, no
617
+ * path, bounded read, partial offset, missing file, read/write failure)
618
+ * resolves to null — a hint must never fire on a guess.
619
+ */
620
+ export function rereadHintFor(opts: RereadHintInput): string | null {
621
+ try {
622
+ if (process.env.FAPONY_NO_REREAD_HINT === "1") return null;
623
+ if (typeof opts.session !== "string" || opts.session === "") return null;
624
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
625
+ // A bounded/partial read is already cheap — same line the size hint draws.
626
+ const offset = typeof opts.offset === "number" ? opts.offset : null;
627
+ if (offset !== null && offset !== 0) return null;
628
+ const limit = typeof opts.limit === "number" ? opts.limit : null;
629
+ if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
630
+
631
+ const abs = (() => {
632
+ const p = opts.filePath.startsWith("/")
633
+ ? opts.filePath
634
+ : join(opts.cwd, opts.filePath);
635
+ try {
636
+ return realpathSync(p);
637
+ } catch {
638
+ return resolve(p);
639
+ }
640
+ })();
641
+ const st = statSync(abs);
642
+ if (!st.isFile()) return null;
643
+ const mtime = Math.round(st.mtimeMs);
644
+
645
+ const prior = readTrackRows(opts.session).filter((r) => r.path === abs);
646
+ const last = prior.at(-1);
647
+ appendReadTrackRow(opts.session, {
648
+ ts: new Date().toISOString(),
649
+ path: abs,
650
+ mtime,
651
+ });
652
+ if (!last || last.mtime !== mtime) return null;
653
+
654
+ // The hint feeds the agent's context — show the path *it* passed, relative
655
+ // to its own cwd, so a symlinked cwd (macOS /var → /private/var) does not
656
+ // turn a clean relative path into a resolved absolute one.
657
+ const rel = relative(opts.cwd, opts.filePath);
658
+ const shown = rel.startsWith("..") ? opts.filePath : rel;
659
+ return (
660
+ `fapony: already read ${shown} ${prior.length}\u00d7 this session — ` +
661
+ `content unchanged since the last read (mtime), grep the line range ` +
662
+ `you need instead of re-reading it`
663
+ );
664
+ } catch {
665
+ return null;
666
+ }
667
+ }
668
+
669
+ // --- Edit hint (PreToolUse annotate — importer count + once-per-session dedupe) ---
670
+ //
671
+ // Editing a file that has importers can silently break its consumers (measured:
672
+ // 17.9% of changed nodes over 30 commits had a 1-hop blast radius; ~12-16% of
673
+ // all-time fail rows were producer/consumer mismatches). The hint is a fact —
674
+ // the importer count plus the review-seed command that lists them — never a
675
+ // judgment about whether the edit is safe, and never a block.
676
+ //
677
+ // Dedupe is per (session, file): the first edit to a file fires, repeats stay
678
+ // silent. The track log reuses the read-track session mechanism (sessionKey,
679
+ // one jsonl per session) but lives in its own dir — sharing read-track's file
680
+ // would make an Edit look like a Read and falsely trip the re-read hint. Like
681
+ // rereadHintFor the track write happens inside this function (the caller-side
682
+ // rule covers recordHintFire, not dedupe state); unlike it there is no mtime
683
+ // comparison — an edit that moves mtime is still the same file in the same
684
+ // session, and repeating the count buys nothing.
685
+
686
+ const EDIT_TRACK_DIR = "edit-track";
687
+
688
+ export interface EditTrackRow {
689
+ ts: string;
690
+ path: string;
691
+ }
692
+
693
+ /** Directory holding one edit log per session. */
694
+ function editTrackDir(): string {
695
+ const base =
696
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
697
+ return join(base, EDIT_TRACK_DIR);
698
+ }
699
+
700
+ /** Absolute path of a session's edit log — may not exist. */
701
+ export function editTrackPath(session: string): string {
702
+ return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
703
+ }
704
+
705
+ function editTrackPaths(session: string): Set<string> {
706
+ const p = editTrackPath(session);
707
+ if (!existsSync(p)) return new Set();
708
+ const out = new Set<string>();
709
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
710
+ if (!line) continue;
711
+ try {
712
+ const r = JSON.parse(line) as EditTrackRow;
713
+ if (typeof r.path === "string") out.add(r.path);
714
+ } catch {
715
+ // a torn line must not lose the rest of the log
716
+ }
717
+ }
718
+ return out;
719
+ }
720
+
721
+ function appendEditTrackRow(session: string, row: EditTrackRow): void {
722
+ const dir = editTrackDir();
723
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
724
+ appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
725
+ }
726
+
727
+ export interface EditHintInput {
728
+ filePath: unknown;
729
+ cwd: string;
730
+ /**
731
+ * Session identity — transcript path (Claude) or session id. Without it the
732
+ * hint still fires (the importer fact holds) but cannot dedupe.
733
+ */
734
+ session?: unknown;
735
+ }
736
+
737
+ /**
738
+ * Factual one-liner for editing a source file that has importers, or null.
739
+ * Every unknown (no path, non-source ext, new/unsaved file, outside the
740
+ * worktree, no git repo, graph failure) resolves to null — a hint must never
741
+ * fire on a guess. Files with importers are checked before the dedupe log is
742
+ * touched, so a file nobody imports never writes a track row.
743
+ */
744
+ export function editHintFor(opts: EditHintInput): string | null {
745
+ try {
746
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
747
+ const dot = opts.filePath.lastIndexOf(".");
748
+ // SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
749
+ if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
750
+ // review-seed is a git command — outside a repo the hint would lie.
751
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
752
+ cwd: opts.cwd,
753
+ stdout: "pipe",
754
+ stderr: "pipe",
755
+ });
756
+ if (git.exitCode !== 0) return null;
757
+ // macOS /var → /private/var: normalize both sides before comparing.
758
+ const worktree = realpathSync(git.stdout.toString().trim());
759
+ const abs = (() => {
760
+ const p = opts.filePath.startsWith("/")
761
+ ? opts.filePath
762
+ : join(opts.cwd, opts.filePath);
763
+ try {
764
+ return realpathSync(p);
765
+ } catch {
766
+ return null; // new file — nothing imports it yet
767
+ }
768
+ })();
769
+ if (!abs) return null;
770
+ const rel = relative(worktree, abs).split(sep).join("/");
771
+ if (rel.startsWith("..") || rel === "") return null;
772
+
773
+ const importers = buildGraphCached(worktree).dependents.get(rel);
774
+ if (!importers || importers.size === 0) return null;
775
+
776
+ if (typeof opts.session === "string" && opts.session !== "") {
777
+ if (editTrackPaths(opts.session).has(abs)) return null;
778
+ appendEditTrackRow(opts.session, {
779
+ ts: new Date().toISOString(),
780
+ path: abs,
781
+ });
782
+ }
783
+
784
+ const n = importers.size;
785
+ return (
786
+ `fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
787
+ `review-seed --files ${rel} lists them (add --callers <export> for one ` +
788
+ `export's callers); check before changing its shape`
789
+ );
790
+ } catch {
791
+ return null;
792
+ }
793
+ }
794
+
322
795
  // --- Commit hint (tool.execute.after — annotate only, never block) ---
323
796
  //
324
797
  // OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
@@ -417,6 +890,8 @@ export async function cmdHookReadHint(): Promise<void> {
417
890
  try {
418
891
  const raw = JSON.parse(await Bun.stdin.text()) as {
419
892
  cwd?: string;
893
+ transcript_path?: string;
894
+ session_id?: string;
420
895
  tool_input?: {
421
896
  file_path?: unknown;
422
897
  offset?: unknown;
@@ -424,16 +899,31 @@ export async function cmdHookReadHint(): Promise<void> {
424
899
  };
425
900
  };
426
901
  const cwd = raw.cwd ?? process.cwd();
902
+ const filePath = raw.tool_input?.file_path;
903
+ // One read log per session — transcript_path is the Claude session file,
904
+ // session_id the fallback when a client omits it.
905
+ const session = raw.transcript_path ?? raw.session_id;
427
906
  const parts: string[] = [];
428
907
  const hint = readHintFor({
429
- filePath: raw.tool_input?.file_path,
908
+ filePath,
430
909
  offset: raw.tool_input?.offset,
431
910
  limit: raw.tool_input?.limit,
432
911
  cwd,
433
912
  });
434
913
  if (hint) parts.push(hint);
435
- for (const line of readContextLines(raw.tool_input?.file_path, cwd)) {
436
- parts.push(line);
914
+ const reread = rereadHintFor({
915
+ filePath,
916
+ offset: raw.tool_input?.offset,
917
+ limit: raw.tool_input?.limit,
918
+ cwd,
919
+ session,
920
+ });
921
+ if (reread) parts.push(reread);
922
+ const ctx = readContextData(filePath, cwd);
923
+ if (ctx) {
924
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
925
+ parts.push(line);
926
+ }
437
927
  }
438
928
  if (parts.length > 0) {
439
929
  console.log(
@@ -445,11 +935,139 @@ export async function cmdHookReadHint(): Promise<void> {
445
935
  }),
446
936
  );
447
937
  }
938
+
939
+ // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
940
+ // After output — best-effort, never block the hint.
941
+ const rel =
942
+ typeof filePath === "string"
943
+ ? (() => {
944
+ try {
945
+ const git = Bun.spawnSync(
946
+ ["git", "rev-parse", "--show-toplevel"],
947
+ { cwd, stdout: "pipe", stderr: "pipe" },
948
+ );
949
+ if (git.exitCode !== 0) return null;
950
+ const wt = realpathSync(git.stdout.toString().trim());
951
+ const abs = realpathSync(
952
+ filePath.startsWith("/") ? filePath : join(wt, filePath),
953
+ );
954
+ const r = relative(wt, abs).split("\\").join("/");
955
+ return r.startsWith("..") ? null : r;
956
+ } catch {
957
+ return null;
958
+ }
959
+ })()
960
+ : null;
961
+ const worktree = ctx?.worktree ?? null;
962
+ if (worktree) {
963
+ if (hint) {
964
+ recordHintFire({
965
+ ts: new Date().toISOString(),
966
+ worktree,
967
+ surface: "read",
968
+ file: rel,
969
+ count: 1,
970
+ });
971
+ }
972
+ if (ctx && ctx.debtIds.length > 0) {
973
+ recordHintFire({
974
+ ts: new Date().toISOString(),
975
+ worktree,
976
+ surface: "debt",
977
+ file: rel,
978
+ count: ctx.debtIds.length,
979
+ ids: ctx.debtIds,
980
+ });
981
+ }
982
+ if (ctx && ctx.memLines.length > 0) {
983
+ recordHintFire({
984
+ ts: new Date().toISOString(),
985
+ worktree,
986
+ surface: "mem",
987
+ file: rel,
988
+ count: ctx.memLines.length,
989
+ });
990
+ }
991
+ }
448
992
  } catch {
449
993
  // any failure = no hint; a hook must never block a read over a hint
450
994
  }
451
995
  }
452
996
 
997
+ /** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
998
+ * No permissionDecision ever — the edit always proceeds. Fires once per
999
+ * (session, file); the dedupe lives inside editHintFor. */
1000
+ export async function cmdHookEditHint(): Promise<void> {
1001
+ try {
1002
+ const raw = JSON.parse(await Bun.stdin.text()) as {
1003
+ cwd?: string;
1004
+ transcript_path?: string;
1005
+ session_id?: string;
1006
+ tool_input?: {
1007
+ file_path?: unknown;
1008
+ };
1009
+ };
1010
+ const cwd = raw.cwd ?? process.cwd();
1011
+ const filePath = raw.tool_input?.file_path;
1012
+ // One edit log per session — same identity as the read hint.
1013
+ const session = raw.transcript_path ?? raw.session_id;
1014
+ const hint = editHintFor({ filePath, cwd, session });
1015
+ if (hint) {
1016
+ console.log(
1017
+ JSON.stringify({
1018
+ hookSpecificOutput: {
1019
+ hookEventName: "PreToolUse",
1020
+ additionalContext: hint,
1021
+ },
1022
+ }),
1023
+ );
1024
+ }
1025
+
1026
+ // --- hint-fire log (PLAN-edit-importer-hint chunk 3) ---
1027
+ // After output — best-effort, never block the hint.
1028
+ if (hint) {
1029
+ try {
1030
+ const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
1031
+ cwd,
1032
+ stdout: "pipe",
1033
+ stderr: "pipe",
1034
+ });
1035
+ if (g.exitCode === 0) {
1036
+ const worktree = realpathSync(g.stdout.toString().trim());
1037
+ const abs =
1038
+ typeof filePath === "string"
1039
+ ? (() => {
1040
+ try {
1041
+ return realpathSync(
1042
+ filePath.startsWith("/")
1043
+ ? filePath
1044
+ : join(worktree, filePath),
1045
+ );
1046
+ } catch {
1047
+ return null;
1048
+ }
1049
+ })()
1050
+ : null;
1051
+ const rel = abs
1052
+ ? relative(worktree, abs).split("\\").join("/")
1053
+ : null;
1054
+ recordHintFire({
1055
+ ts: new Date().toISOString(),
1056
+ worktree,
1057
+ surface: "edit",
1058
+ file: rel && !rel.startsWith("..") ? rel : null,
1059
+ count: 1,
1060
+ });
1061
+ }
1062
+ } catch {
1063
+ // best-effort — swallow
1064
+ }
1065
+ }
1066
+ } catch {
1067
+ // any failure = no hint; a hook must never block an edit over a hint
1068
+ }
1069
+ }
1070
+
453
1071
  // --- Debt + mem context (PLAN-convention-debt chunk 4) ---
454
1072
  //
455
1073
  // The one moment paying down debt is worth tokens is when the file is already
@@ -462,15 +1080,26 @@ const DEBT_HINT_MAX = 3;
462
1080
  const MEM_HINT_MAX = 2;
463
1081
  const MEM_TEXT_MAX = 120;
464
1082
 
465
- export function readContextLines(filePath: unknown, cwd: string): string[] {
1083
+ export interface ContextLineData {
1084
+ worktree: string;
1085
+ debtIds: string[];
1086
+ debtLines: string[];
1087
+ memLines: string[];
1088
+ }
1089
+
1090
+ /** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
1091
+ export function readContextData(
1092
+ filePath: unknown,
1093
+ cwd: string,
1094
+ ): ContextLineData | null {
466
1095
  try {
467
- if (typeof filePath !== "string" || filePath === "") return [];
1096
+ if (typeof filePath !== "string" || filePath === "") return null;
468
1097
  const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
469
1098
  cwd,
470
1099
  stdout: "pipe",
471
1100
  stderr: "pipe",
472
1101
  });
473
- if (git.exitCode !== 0) return [];
1102
+ if (git.exitCode !== 0) return null;
474
1103
  // macOS /var → /private/var: git reports the resolved root while callers
475
1104
  // pass unresolved tmp paths — normalize both sides before comparing.
476
1105
  const worktree = realpathSync(git.stdout.toString().trim());
@@ -478,9 +1107,11 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
478
1107
  filePath.startsWith("/") ? filePath : join(worktree, filePath),
479
1108
  );
480
1109
  const rel = relative(worktree, abs).split("\\").join("/");
481
- if (rel.startsWith("..") || rel === "") return [];
1110
+ if (rel.startsWith("..") || rel === "") return null;
482
1111
 
483
- const lines: string[] = [];
1112
+ const debtIds: string[] = [];
1113
+ const debtLines: string[] = [];
1114
+ const memLines: string[] = [];
484
1115
 
485
1116
  // convention debt — source files only, fresh from the repo
486
1117
  const dot = rel.lastIndexOf(".");
@@ -490,7 +1121,8 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
490
1121
  abs,
491
1122
  loadConventions(worktree),
492
1123
  ).slice(0, DEBT_HINT_MAX)) {
493
- lines.push(`fapony debt: [${c.id}] ${c.rule}`);
1124
+ debtIds.push(c.id);
1125
+ debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
494
1126
  }
495
1127
  }
496
1128
 
@@ -520,15 +1152,24 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
520
1152
  if (sameName !== 1) usableBase = [];
521
1153
  }
522
1154
  // direct hits (files[] / full path) outrank bare-basename hits
523
- const memLines = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
524
- for (const r of memLines) {
525
- lines.push(
1155
+ const memHits = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
1156
+ for (const r of memHits) {
1157
+ memLines.push(
526
1158
  `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
527
1159
  );
528
1160
  }
529
1161
  }
530
- return lines.slice(0, DEBT_HINT_MAX + MEM_HINT_MAX);
1162
+ return { worktree, debtIds, debtLines, memLines };
531
1163
  } catch {
532
- return [];
1164
+ return null;
533
1165
  }
534
1166
  }
1167
+
1168
+ export function readContextLines(filePath: unknown, cwd: string): string[] {
1169
+ const data = readContextData(filePath, cwd);
1170
+ if (!data) return [];
1171
+ return [...data.debtLines, ...data.memLines].slice(
1172
+ 0,
1173
+ DEBT_HINT_MAX + MEM_HINT_MAX,
1174
+ );
1175
+ }