fapony 0.3.0 → 0.3.3

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.
package/src/hook.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/hook.ts — Claude Code / Cursor Stop hook: refuse to end a turn that produced
2
- // commits but no verdict.
2
+ // commits but no mem row.
3
3
  //
4
4
  // Why a hook and not a message: SERVER_INSTRUCTIONS is a *request* that the
5
5
  // agent remember, and it measured as not enough · the hook does not grade in
@@ -28,10 +28,9 @@ import {
28
28
  import { homedir } from "node:os";
29
29
  import { basename, join, relative, resolve, sep } from "node:path";
30
30
  import { buildGraphCached, collectSourceFiles, SCAN_EXTS } from "./analyze.js";
31
- import { openDb } from "./db/index.js";
32
31
  import { debtForFile, loadConventions } from "./debt/index.js";
33
- import { detectTestRunner } from "./detect.js";
34
32
  import { readMemLog, whereMemDir } from "./memory.js";
33
+ import { renderSeed } from "./seed/review-seed.js";
35
34
 
36
35
  // --- Hint-fire log (PLAN-feedback-surface chunk 1) ---
37
36
  //
@@ -240,20 +239,34 @@ export function utcStamp(d: Date): string {
240
239
  }
241
240
 
242
241
  /**
243
- * Pure decision: block only when this session produced commits and none of
244
- * them got graded. Every unknown (no git, no transcript, hook already fired)
245
- * resolves to "allow" — a hook that guesses wrong must never trap the agent.
242
+ * Parse either timestamp shape this repo produces: mem rows are ISO
243
+ * (`new Date().toISOString()`), session start is utcStamp
244
+ * ('YYYY-MM-DD HH:MM:SS', UTC). Never compare them as strings — 'T' > ' '
245
+ * makes any same-date mem row read as "newer than session start".
246
+ */
247
+ export function hookTsMs(ts: string): number {
248
+ const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)
249
+ ? `${ts.replace(" ", "T")}Z`
250
+ : ts;
251
+ return new Date(iso).getTime();
252
+ }
253
+
254
+ /**
255
+ * Pure decision: block when this session produced commits but no mem row
256
+ * newer than session start exists. Every unknown (no git, no transcript,
257
+ * hook already fired, no mem log) resolves to "allow" — a hook that guesses
258
+ * wrong must never trap the agent.
246
259
  *
247
- * PLAN-mem-mcp chunk 3: the block message now carries the commit list and the
248
- * mem-log status (last row date). Both are *information*, never conditions —
249
- * the block condition stays verdict-only (rule 7: the hook does not judge, it
250
- * reports what is pending so the agent decides what deserves recording).
260
+ * PLAN-verdict-to-mem: block condition changed from verdicts to mem rows.
261
+ * The stop hook is the most expensive enforcement tool we have (rule 9);
262
+ * using it for verdicts that are 94% pass-family was wasteful. Now it
263
+ * enforces mem_add — rows that session N can actually read.
251
264
  */
252
265
  export function decideStop(opts: {
253
266
  stopHookActive: boolean;
254
267
  worktree: string | null;
255
268
  commits: number;
256
- verdicts: number;
269
+ since?: string | null;
257
270
  commitList?: string[];
258
271
  memLastTs?: string | null;
259
272
  /** Logs that exist in the repo but are out of scope from this worktree. */
@@ -262,10 +275,21 @@ export function decideStop(opts: {
262
275
  if (opts.stopHookActive) return null; // already blocked once — let it end
263
276
  if (!opts.worktree) return null;
264
277
  if (opts.commits < 1) return null;
265
- if (opts.verdicts > 0) return null;
278
+ // No mem log at all = allow (same as sessionStartContext — silent when missing).
279
+ if (!opts.memLastTs && !opts.memCandidates?.length) return null;
280
+ // Has mem rows — block only if nothing newer than session start. Parsed
281
+ // as dates, not strings: the two sides arrive in different shapes (ISO mem
282
+ // rows vs utcStamp session start). Unparseable = allow, per the contract
283
+ // above — an unknown timestamp is not proof either way.
284
+ if (opts.since && opts.memLastTs) {
285
+ const sinceMs = hookTsMs(opts.since);
286
+ const memMs = hookTsMs(opts.memLastTs);
287
+ if (Number.isNaN(sinceMs) || Number.isNaN(memMs)) return null;
288
+ if (memMs >= sinceMs) return null;
289
+ }
266
290
 
267
291
  const lines: string[] = [
268
- `${opts.commits} commit(s) landed in ${opts.worktree} this session with no verdict filed.`,
292
+ `${opts.commits} commit(s) landed in ${opts.worktree} this session — no mem row recorded for this work.`,
269
293
  ];
270
294
  // ≤ 5 commits listed, rest folded into "… +N more" (spec §6: ≤ 12 lines).
271
295
  const list = opts.commitList ?? [];
@@ -276,29 +300,16 @@ export function decideStop(opts: {
276
300
  `mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
277
301
  );
278
302
  } else if (opts.memCandidates?.length) {
279
- // "nothing recorded" would be a lie: the log exists, it is just not in
280
- // scope from here (monorepo — the log lives in the app dir). Say where.
281
303
  lines.push(
282
304
  `mem: no log in scope from ${opts.worktree} — found ` +
283
305
  `${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
284
306
  );
285
- } else {
286
- lines.push("mem: no rows at all — nothing recorded in this project yet");
287
307
  }
288
- const runner = detectTestRunner(opts.worktree);
289
- const verifyLine = runner
290
- ? `If you did not run \`${runner.typecheckCmd ? `${runner.typecheckCmd} and ` : ""}${runner.testCmd}\` to a real exit code, ` +
291
- `the honest verdict is uncertain, not pass. `
292
- : `If you did not run this repo's typecheck and test suite to a real exit code, ` +
293
- `the honest verdict is uncertain, not pass. `;
294
308
 
295
309
  lines.push(
296
- `Call verdict_submit before ending: worktree must be the absolute path above, ` +
297
- `regime is one of code|fix|review|plan|inquiry|test, and the note must stand alone ` +
298
- `(it is read months from now with no access to this conversation). ` +
299
- `Grade what actually happened — pass-family when it held up, fail if the first ` +
300
- `attempt was wrong, uncertain when you could not verify it. ${verifyLine}` +
301
- `What deserves a mem row (decision/bug/note) is your call — not every unit needs one.`,
310
+ `Record a mem row before ending: fapony mem add <decision|bug|note> "what happened" --files <files> ` +
311
+ `${opts.worktree}/.fapony/plan/PLAN.md (or the relevant plan). ` +
312
+ `files[] is required — a row without it is unfindable when you touch that file next session.`,
302
313
  );
303
314
  return lines.join("\n");
304
315
  }
@@ -497,7 +508,6 @@ export async function cmdHookStop(): Promise<void> {
497
508
 
498
509
  let commits = 0;
499
510
  let commitList: string[] = [];
500
- let verdicts = 0;
501
511
  let memLastTs: string | null = null;
502
512
  let memCandidates: string[] = [];
503
513
  if (worktree && since) {
@@ -507,25 +517,14 @@ export async function cmdHookStop(): Promise<void> {
507
517
  );
508
518
  commitList = log ? log.split("\n").filter(Boolean) : [];
509
519
  commits = commitList.length;
510
- if (commits > 0) {
511
- const db = openDb();
512
- const row = db
513
- .query(
514
- `SELECT COUNT(*) AS n FROM events e JOIN runs r ON r.id = e.run_id
515
- WHERE e.kind = 'gate' AND r.worktree = ? AND e.ts >= ?`,
516
- )
517
- .get(worktree, since) as { n: number } | null;
518
- verdicts = row?.n ?? 0;
519
- // Informational only — read-only, degrade silently (mem status never
520
- // becomes a block condition, rule 7).
521
- try {
522
- const mem = readMemLog(worktree);
523
- memLastTs = mem.rows[0]?.ts ?? null;
524
- if (!memLastTs)
525
- memCandidates = whereMemDir(worktree).candidates ?? [];
526
- } catch {
527
- memLastTs = null;
528
- }
520
+ // Read mem log regardless of commits — the block condition is mem rows,
521
+ // not verdicts (PLAN-verdict-to-mem).
522
+ try {
523
+ const mem = readMemLog(worktree);
524
+ memLastTs = mem.rows[0]?.ts ?? null;
525
+ if (!memLastTs) memCandidates = whereMemDir(worktree).candidates ?? [];
526
+ } catch {
527
+ memLastTs = null;
529
528
  }
530
529
  }
531
530
 
@@ -533,7 +532,7 @@ export async function cmdHookStop(): Promise<void> {
533
532
  stopHookActive: norm.stopHookActive,
534
533
  worktree: since ? worktree : null,
535
534
  commits,
536
- verdicts,
535
+ since,
537
536
  commitList,
538
537
  memLastTs,
539
538
  memCandidates,
@@ -562,13 +561,19 @@ export async function cmdHookStop(): Promise<void> {
562
561
  // (rule 13). A SessionStart hook is neither: zero rent, and it fires whether
563
562
  // or not anyone remembers.
564
563
  //
564
+ // sessionStartContext is the one copy of that: guard (no mem log = null), the
565
+ // kickoff spawn, and the cap. cmdHookSessionStart wraps it in the Claude/Codex
566
+ // JSON, and the generated OpenCode plugin imports it directly — so the logic
567
+ // lives here and `git pull` updates all three clients, instead of being baked
568
+ // into the plugin file where a pull could not reach it (mub2ezhi).
569
+ //
565
570
  // Runs the CLI in a subprocess rather than calling cmdKickoff: kickoff prints
566
571
  // to stdout and exits on bad input, both of which would be this hook's stdout.
567
572
 
568
573
  /** Cap on injected context — kickoff is short, a broken repo's output is not. */
569
574
  export const SESSION_START_MAX_CHARS = 4_000;
570
575
 
571
- const TRUNCATED = "… truncated — run `fapony mem kickoff` for the rest";
576
+ const TRUNCATED = "… truncated — run `fapony mem find <word>` for the rest";
572
577
 
573
578
  /** Trim to whole lines, keeping the marker's line boundary intact. */
574
579
  function headLines(text: string, max: number): string {
@@ -580,16 +585,35 @@ function headLines(text: string, max: number): string {
580
585
  /**
581
586
  * Trim to whole lines within the cap, with an honest truncation marker.
582
587
  *
583
- * "## next up" is kickoff's last section and its most actionable one, so a
584
- * plain head-cut drops exactly the part worth injecting in a repo with a long
585
- * open list (measured here: 92 rows, the cut landed mid-history). Keep it and
586
- * spend the rest of the budget on the head.
588
+ * Kickoff's ordering is now: ranked rows → "## next up" → "## recent". The
589
+ * most actionable content is at the top (bugs, diff-matched) and in "## next
590
+ * up". A plain head-cut drops the suggestions — so we try to keep "## next up"
591
+ * visible. When "## recent" exists, we cut it first; otherwise fall back to
592
+ * keeping "## next up" at the tail.
587
593
  */
588
594
  export function capContext(
589
595
  text: string,
590
596
  max = SESSION_START_MAX_CHARS,
591
597
  ): string {
592
598
  if (text.length <= max) return text;
599
+ // New ordering: ranked → ## next up → ## recent → sweep/rotate
600
+ // Cut ## recent first to keep ranked content + suggestions visible.
601
+ const recentIdx = text.indexOf("\n## recent");
602
+ if (recentIdx > 0) {
603
+ const head = text.slice(0, recentIdx).trimEnd();
604
+ if (head.length <= max) return head;
605
+ // Head still too long — try to keep ## next up at the end
606
+ const nextIdx = head.lastIndexOf("\n## next up");
607
+ if (nextIdx > 0) {
608
+ const beforeNext = head.slice(0, nextIdx).trimEnd();
609
+ const nextTail = head.slice(nextIdx).trimEnd();
610
+ if (nextTail.length < max / 2) {
611
+ return `${headLines(beforeNext, max - nextTail.length)}\n${TRUNCATED}\n${nextTail}`;
612
+ }
613
+ }
614
+ return `${headLines(head, max)}\n${TRUNCATED}`;
615
+ }
616
+ // Fallback: no ## recent found (short output or other path)
593
617
  const at = text.lastIndexOf("\n## next up");
594
618
  const tail = at > 0 ? text.slice(at).trimEnd() : "";
595
619
  if (tail && tail.length < max / 2) {
@@ -598,25 +622,45 @@ export function capContext(
598
622
  return `${headLines(text, max)}\n${TRUNCATED}`;
599
623
  }
600
624
 
601
- /** SessionStart hook: inject `fapony mem kickoff` output as context. */
625
+ /**
626
+ * Kickoff output for `cwd`'s mem log, capped — or null when there is no log in
627
+ * scope, the command fails, or it prints nothing. The single implementation
628
+ * behind both cmdHookSessionStart (Claude/Codex) and the generated OpenCode
629
+ * plugin, so a pull of INSTALL_ROOT updates all three.
630
+ *
631
+ * `faponyTs` is where the CLI lives. The hook defaults to `Bun.main` (itself,
632
+ * when run as `bun <root>/fapony.ts hook-session-start`); the OpenCode plugin
633
+ * must pass its baked path because `Bun.main` inside OpenCode is OpenCode's own
634
+ * entry, not fapony's.
635
+ */
636
+ export function sessionStartContext(
637
+ cwd: string,
638
+ faponyTs: string = Bun.main,
639
+ ): string | null {
640
+ // No mem log in scope = nothing to say. Silence beats "no rows yet".
641
+ if (!whereMemDir(cwd).dir) return null;
642
+ const p = Bun.spawnSync([process.execPath, faponyTs, "mem", "kickoff"], {
643
+ cwd,
644
+ stdout: "pipe",
645
+ stderr: "pipe",
646
+ });
647
+ const out = p.stdout.toString().trim();
648
+ if (p.exitCode !== 0 || !out) return null;
649
+ return capContext(out);
650
+ }
651
+
652
+ /** SessionStart hook: emit sessionStartContext as Claude/Codex JSON. */
602
653
  export async function cmdHookSessionStart(): Promise<void> {
603
654
  try {
604
655
  const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
605
656
  const cwd = raw.cwd ?? process.cwd();
606
- // No mem log in scope = nothing to say. Silence beats "no rows yet".
607
- if (!whereMemDir(cwd).dir) return;
608
- const p = Bun.spawnSync([process.execPath, Bun.main, "mem", "kickoff"], {
609
- cwd,
610
- stdout: "pipe",
611
- stderr: "pipe",
612
- });
613
- const out = p.stdout.toString().trim();
614
- if (p.exitCode !== 0 || !out) return;
657
+ const ctx = sessionStartContext(cwd);
658
+ if (!ctx) return;
615
659
  console.log(
616
660
  JSON.stringify({
617
661
  hookSpecificOutput: {
618
662
  hookEventName: "SessionStart",
619
- additionalContext: capContext(out),
663
+ additionalContext: ctx,
620
664
  },
621
665
  }),
622
666
  );
@@ -645,6 +689,8 @@ export const READ_HINT_MIN_BYTES = 24_000;
645
689
  export const READ_HINT_MIN_LIMIT = 300;
646
690
  /** One-time measurement (2026-09-17, this repo): 5 files / 2,146 lines ≈ 3.7KB out. */
647
691
  const READ_HINT_MEASURED = "measured ~3.7KB output on a 2,146-line file";
692
+ /** Cap on the outline attached to the read hint — keeps the hint compact. */
693
+ const READ_HINT_OUTLINE_CAP = 2_000;
648
694
 
649
695
  export interface ReadHintInput {
650
696
  filePath: unknown;
@@ -659,6 +705,10 @@ export interface ReadHintInput {
659
705
  * repo, stat/read failure) resolves to null — a hint must never fire on a
660
706
  * guess. Fast path is statSync only; the file is read just to count lines,
661
707
  * and only after the size threshold passed.
708
+ *
709
+ * When review-seed succeeds, the hint attaches the actual outline (exports
710
+ * with line numbers) instead of telling the agent to run a command (rules
711
+ * 9/13: ask does not work, attach the data directly).
662
712
  */
663
713
  export function readHintFor(opts: ReadHintInput): string | null {
664
714
  try {
@@ -682,6 +732,41 @@ export function readHintFor(opts: ReadHintInput): string | null {
682
732
  // relative path, outside it relative() climbs dots, show absolute.
683
733
  const rel = relative(opts.cwd, opts.filePath);
684
734
  const shown = rel.startsWith("..") ? opts.filePath : rel;
735
+
736
+ // Try to attach the actual outline from review-seed (cap ~2KB)
737
+ let outline = "";
738
+ try {
739
+ const seed = renderSeed(["--files", shown], opts.cwd);
740
+ // Extract signatures section — the most useful part for reading
741
+ const sigMatch = seed.match(
742
+ /signatures \(current\):\n([\s\S]*?)(?:\n\w|\nstatic graph)/,
743
+ );
744
+ if (sigMatch) {
745
+ outline = sigMatch[1].trim();
746
+ } else {
747
+ // Fallback: take the first section after the file list
748
+ const afterFiles = seed.indexOf("\nimporters");
749
+ if (afterFiles > 0) {
750
+ outline = seed
751
+ .slice(0, Math.min(afterFiles, READ_HINT_OUTLINE_CAP))
752
+ .trim();
753
+ } else {
754
+ outline = seed.slice(0, READ_HINT_OUTLINE_CAP).trim();
755
+ }
756
+ }
757
+ if (outline.length > READ_HINT_OUTLINE_CAP) {
758
+ outline = `${outline.slice(0, READ_HINT_OUTLINE_CAP).trimEnd()}\n… truncated`;
759
+ }
760
+ } catch {
761
+ // review-seed failed — fall back to the command suggestion
762
+ }
763
+
764
+ if (outline) {
765
+ return (
766
+ `fapony: ${shown} is ${lines} lines\n${outline}\n` +
767
+ `(review-seed --files ${shown} for importers + callers; skill /lookup-before-edit)`
768
+ );
769
+ }
685
770
  return (
686
771
  `fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
687
772
  `returns exports with line numbers, importers, and signatures first ` +
@@ -958,18 +1043,16 @@ export function editHintFor(opts: EditHintInput): string | null {
958
1043
  //
959
1044
  // OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
960
1045
  // so it cannot block a turn; instead it appends an annotate to the bash tool
961
- // output whenever there is a git commit with no verdict pending. It is the
962
- // same kind of nudge as the read hint: no block, no dedupe, every unknown →
1046
+ // output whenever there is a git commit with no mem row recorded for it. It is
1047
+ // the same kind of nudge as the read hint: no block, no dedupe, every unknown →
963
1048
  // silent · called from the opencode plugin by direct import (like
964
1049
  // readHintFor), no CLI subcommand because no client needs it as a subprocess
965
1050
  // (Cursor uses its own hook-stop instead)
966
1051
  //
967
- // The text is facts only (commit list + verdict status), not an estimate
1052
+ // The text is facts only (commit list + mem status), not an estimate
968
1053
 
969
1054
  /** Below this number of commits, the hint is unnecessary noise. */
970
1055
  export const COMMIT_HINT_MIN_COMMITS = 1;
971
- /** Cap commits shown in the hint message. */
972
- const COMMIT_HINT_MAX_LIST = 5;
973
1056
 
974
1057
  export interface CommitHintInput {
975
1058
  command: unknown;
@@ -977,14 +1060,16 @@ export interface CommitHintInput {
977
1060
  }
978
1061
 
979
1062
  /**
980
- * Nudge for bash commands containing `git commit` that produced
981
- * ungraded commits. Returns a one-to-two line hint string, or null
982
- * when there is nothing to nudge about (already graded, no commits,
983
- * not a git commit command, not a git repo, any failure).
1063
+ * Nudge for bash commands containing `git commit` that produced commits with
1064
+ * no mem row recorded for them. Returns a one-to-two line hint string, or
1065
+ * null when there is nothing to nudge about (no new commits, not a git
1066
+ * commit command, not a git repo, no mem log to window on, any failure).
984
1067
  *
985
- * Every unknown resolves to null — a hint must never fire on a
986
- * guess. The work is cheap: one git rev-parse + one git log + one
987
- * SQLite count.
1068
+ * Every unknown resolves to null — a hint must never fire on a guess. The
1069
+ * work is cheap: one git rev-parse + one mem-log read + one git log.
1070
+ * The window is the mem log, never the verdict ledger: gate events have no
1071
+ * writer left (PLAN-verdict-to-mem), so the last verdict is frozen — fresh
1072
+ * machines listed their entire repo history as unrecorded.
988
1073
  */
989
1074
  export function commitHintFor(opts: CommitHintInput): string | null {
990
1075
  try {
@@ -995,51 +1080,41 @@ export function commitHintFor(opts: CommitHintInput): string | null {
995
1080
  const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
996
1081
  if (!worktree) return null;
997
1082
 
998
- // Window = commits since the worktree's last verdict, not "does a
999
- // verdict exist anywhere in its history" — a worktree that earned one
1000
- // verdict months ago must still nudge on every commit made since, the
1001
- // same way cmdHookStop windows on `e.ts >= since` (session start) rather
1002
- // than "any verdict this worktree has ever had".
1003
- const db = openDb();
1004
- const lastVerdict = db
1005
- .query(
1006
- `SELECT MAX(e.ts) AS ts FROM events e JOIN runs r ON r.id = e.run_id
1007
- WHERE e.kind = 'gate' AND r.worktree = ?`,
1008
- )
1009
- .get(worktree) as { ts: string | null } | null;
1010
- // git's --since is inclusive to the second, and the commit a verdict
1011
- // just graded often lands in the same UTC second as the verdict itself
1012
- // (verdict_submit runs right after the commit) — bump by 1s so that
1013
- // commit isn't re-flagged as ungraded because of its own grade.
1014
- const since = lastVerdict?.ts
1015
- ? utcStamp(
1016
- new Date(
1017
- new Date(`${lastVerdict.ts.replace(" ", "T")}Z`).getTime() + 1000,
1018
- ),
1019
- )
1020
- : null;
1021
-
1022
- const log = since
1023
- ? git(["log", "--since", `${since} +0000`, "--format=%h %s"], worktree)
1024
- : git(["log", "--format=%h %s"], worktree);
1083
+ // Window = commits newer than the last mem row. No mem log in scope =
1084
+ // no window to measure — stay silent (same as the stop hook: a hint must
1085
+ // never fire on a guess, and the whole-history fire on fresh machines is
1086
+ // what this replaced).
1087
+ let memLastTs: string | null = null;
1088
+ try {
1089
+ memLastTs = readMemLog(worktree).rows[0]?.ts ?? null;
1090
+ } catch {
1091
+ memLastTs = null;
1092
+ }
1093
+ if (!memLastTs) return null;
1094
+ // git's --since is inclusive to the second, and a commit can land in the
1095
+ // same UTC second as the row recorded for it — bump by 1s so recorded
1096
+ // work isn't re-flagged.
1097
+ const since = utcStamp(new Date(hookTsMs(memLastTs) + 1000));
1098
+
1099
+ const log = git(
1100
+ ["log", "--since", `${since} +0000`, "--format=%h %s"],
1101
+ worktree,
1102
+ );
1025
1103
  const commitList = log ? log.split("\n").filter(Boolean) : [];
1026
1104
  if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
1027
1105
 
1028
- const reason = decideStop({
1029
- stopHookActive: false, // annotate-only: never "already blocked"
1030
- worktree,
1031
- commits: commitList.length,
1032
- verdicts: 0, // every commit left in the window is, by construction, ungraded
1033
- commitList: commitList.slice(0, COMMIT_HINT_MAX_LIST),
1034
- });
1035
- if (!reason) return null;
1036
-
1037
- // Prefix each line with "fapony:" so it's visually distinct
1038
- // from normal bash output in the agent's context.
1039
- const prefixed = reason
1040
- .split("\n")
1041
- .map((l) => `fapony: ${l}`)
1042
- .join("\n");
1106
+ // Build the nudge directly — commitHintFor is annotate-only (informational),
1107
+ // while decideStop is blocking enforcement. They serve different purposes.
1108
+ const lines: string[] = [
1109
+ `${commitList.length} commit(s) since last mem row (${memLastTs.slice(0, 10)}) — record a mem row for this work.`,
1110
+ ];
1111
+ for (const c of commitList.slice(0, 5)) lines.push(` ${c}`);
1112
+ if (commitList.length > 5) lines.push(` … +${commitList.length - 5} more`);
1113
+ lines.push(
1114
+ `fapony mem add <decision|bug|note> "what happened" --files <files> ${worktree}/.fapony/plan/PLAN.md`,
1115
+ );
1116
+
1117
+ const prefixed = lines.map((l) => `fapony: ${l}`).join("\n");
1043
1118
  return prefixed;
1044
1119
  } catch {
1045
1120
  return null; // any failure = no hint
@@ -1158,7 +1233,10 @@ export async function cmdHookReadHint(): Promise<void> {
1158
1233
 
1159
1234
  /** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
1160
1235
  * No permissionDecision ever — the edit always proceeds. Fires once per
1161
- * (session, file); the dedupe lives inside editHintFor. */
1236
+ * (session, file); the dedupe lives inside editHintFor.
1237
+ *
1238
+ * Also attaches mem/debt context lines (same as read hint) — the moment
1239
+ * paying down debt is worth tokens is when the file is already open. */
1162
1240
  export async function cmdHookEditHint(): Promise<void> {
1163
1241
  try {
1164
1242
  const raw = JSON.parse(await Bun.stdin.text()) as {
@@ -1173,13 +1251,22 @@ export async function cmdHookEditHint(): Promise<void> {
1173
1251
  const filePath = raw.tool_input?.file_path;
1174
1252
  // One edit log per session — same identity as the read hint.
1175
1253
  const session = raw.transcript_path ?? raw.session_id;
1254
+ const parts: string[] = [];
1176
1255
  const hint = editHintFor({ filePath, cwd, session });
1177
- if (hint) {
1256
+ if (hint) parts.push(hint);
1257
+ // Attach mem/debt context (same as read hint — annotate only, cap 5 lines)
1258
+ const ctx = readContextData(filePath, cwd);
1259
+ if (ctx) {
1260
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
1261
+ parts.push(line);
1262
+ }
1263
+ }
1264
+ if (parts.length > 0) {
1178
1265
  console.log(
1179
1266
  JSON.stringify({
1180
1267
  hookSpecificOutput: {
1181
1268
  hookEventName: "PreToolUse",
1182
- additionalContext: hint,
1269
+ additionalContext: parts.join("\n"),
1183
1270
  },
1184
1271
  }),
1185
1272
  );