fapony 0.2.0 → 0.3.0

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
@@ -26,11 +26,12 @@ import {
26
26
  statSync,
27
27
  } from "node:fs";
28
28
  import { homedir } from "node:os";
29
- import { basename, join, relative, resolve } from "node:path";
30
- 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";
31
31
  import { openDb } from "./db/index.js";
32
- import { debtForFile, loadConventions } from "./debt.js";
33
- import { readMemLog } from "./memory.js";
32
+ import { debtForFile, loadConventions } from "./debt/index.js";
33
+ import { detectTestRunner } from "./detect.js";
34
+ import { readMemLog, whereMemDir } from "./memory.js";
34
35
 
35
36
  // --- Hint-fire log (PLAN-feedback-surface chunk 1) ---
36
37
  //
@@ -63,7 +64,7 @@ export function hintLogPath(worktree: string): string {
63
64
  export interface HintFireRow {
64
65
  ts: string;
65
66
  worktree: string;
66
- surface: "read" | "debt" | "mem" | "commit";
67
+ surface: "read" | "debt" | "mem" | "commit" | "edit";
67
68
  file: string | null;
68
69
  count: number;
69
70
  ids?: string[];
@@ -96,7 +97,13 @@ export function recordHintFire(row: HintFireRow): void {
96
97
 
97
98
  export interface HintImpact {
98
99
  fired: number;
99
- by_surface: { read: number; debt: number; mem: number; commit: number };
100
+ by_surface: {
101
+ read: number;
102
+ debt: number;
103
+ mem: number;
104
+ commit: number;
105
+ edit: number;
106
+ };
100
107
  debt: { shown: number; resolved: number; unknown: number };
101
108
  window: string | null;
102
109
  }
@@ -115,7 +122,7 @@ export function computeHintImpact(
115
122
  const dir = hintLogDir();
116
123
  const impact: HintImpact = {
117
124
  fired: 0,
118
- by_surface: { read: 0, debt: 0, mem: 0, commit: 0 },
125
+ by_surface: { read: 0, debt: 0, mem: 0, commit: 0, edit: 0 },
119
126
  debt: { shown: 0, resolved: 0, unknown: 0 },
120
127
  window: since ?? null,
121
128
  };
@@ -212,9 +219,13 @@ export interface RawStopPayload {
212
219
  conversation_id?: string;
213
220
  loop_count?: number;
214
221
  status?: string;
222
+ // Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
223
+ session_id?: string;
224
+ model?: string;
225
+ permission_mode?: string;
215
226
  }
216
227
 
217
- export type StopClient = "claude" | "cursor";
228
+ export type StopClient = "claude" | "cursor" | "codex";
218
229
 
219
230
  export interface NormalizedStopInput {
220
231
  client: StopClient;
@@ -245,6 +256,8 @@ export function decideStop(opts: {
245
256
  verdicts: number;
246
257
  commitList?: string[];
247
258
  memLastTs?: string | null;
259
+ /** Logs that exist in the repo but are out of scope from this worktree. */
260
+ memCandidates?: string[];
248
261
  }): string | null {
249
262
  if (opts.stopHookActive) return null; // already blocked once — let it end
250
263
  if (!opts.worktree) return null;
@@ -262,16 +275,30 @@ export function decideStop(opts: {
262
275
  lines.push(
263
276
  `mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
264
277
  );
278
+ } 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
+ lines.push(
282
+ `mem: no log in scope from ${opts.worktree} — found ` +
283
+ `${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
284
+ );
265
285
  } else {
266
286
  lines.push("mem: no rows at all — nothing recorded in this project yet");
267
287
  }
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
+
268
295
  lines.push(
269
296
  `Call verdict_submit before ending: worktree must be the absolute path above, ` +
270
297
  `regime is one of code|fix|review|plan|inquiry|test, and the note must stand alone ` +
271
298
  `(it is read months from now with no access to this conversation). ` +
272
299
  `Grade what actually happened — pass-family when it held up, fail if the first ` +
273
- `attempt was wrong, uncertain when you could not verify it. What deserves a mem ` +
274
- `row (decision/bug/note) is your call — not every unit needs one.`,
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.`,
275
302
  );
276
303
  return lines.join("\n");
277
304
  }
@@ -319,11 +346,32 @@ export function isCursorPayload(raw: RawStopPayload): boolean {
319
346
  );
320
347
  }
321
348
 
322
- /** Field-mapping only — both clients feed the same decideStop below. */
349
+ /** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
350
+ export function isCodexPayload(raw: RawStopPayload): boolean {
351
+ return (
352
+ typeof raw.permission_mode === "string" ||
353
+ (typeof raw.model === "string" && !isCursorPayload(raw))
354
+ );
355
+ }
356
+
357
+ /** Field-mapping only — all three clients feed the same decideStop below. */
323
358
  export function normalizeStopInput(
324
359
  raw: RawStopPayload,
325
360
  home: string,
326
361
  ): NormalizedStopInput {
362
+ if (isCodexPayload(raw)) {
363
+ // Codex: cwd is the session working directory; stop_hook_active means
364
+ // the hook already fired once (same semantics as Claude).
365
+ return {
366
+ client: "codex",
367
+ cwd: raw.cwd ?? process.cwd(),
368
+ transcriptPath:
369
+ typeof raw.transcript_path === "string" && raw.transcript_path
370
+ ? raw.transcript_path
371
+ : null,
372
+ stopHookActive: raw.stop_hook_active === true,
373
+ };
374
+ }
327
375
  if (isCursorPayload(raw)) {
328
376
  const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
329
377
  let transcriptPath =
@@ -350,12 +398,74 @@ export function normalizeStopInput(
350
398
  };
351
399
  }
352
400
 
353
- /** Claude blocks with decision:block; Cursor's stop hook "blocks" by
354
- * auto-submitting the reason as the next user message. */
401
+ /** Claude blocks with decision:block; Cursor auto-submits as followup_message;
402
+ * Codex continues with decision:block + reason (continue:false would take
403
+ * precedence and end the turn instead — Codex Hooks, Stop section). */
355
404
  export function stopOutput(client: StopClient, reason: string): string {
356
- return client === "cursor"
357
- ? JSON.stringify({ followup_message: reason })
358
- : JSON.stringify({ decision: "block", reason });
405
+ if (client === "cursor") return JSON.stringify({ followup_message: reason });
406
+ if (client === "codex") return JSON.stringify({ decision: "block", reason });
407
+ return JSON.stringify({ decision: "block", reason });
408
+ }
409
+
410
+ // --- Block dedupe (one block per session + worktree) ---
411
+ //
412
+ // stop_hook_active only suppresses the block that fires *immediately* after
413
+ // one. Every later turn that still carries ungraded commits blocks again, so
414
+ // a session that keeps committing gets the same paragraph 4-5 times. The
415
+ // first block already delivered it; the repeats add noise, not force (rule 9
416
+ // — the forcing happens once, and the agent that ignored it once is not
417
+ // persuaded by the fifth copy).
418
+ //
419
+ // Keyed by the transcript path, which is already the session identity the
420
+ // commit window is measured from — no session field to thread through.
421
+
422
+ const STOP_BLOCK_DIR = "stop-block";
423
+
424
+ interface StopBlockRow {
425
+ ts: string;
426
+ worktree: string;
427
+ }
428
+
429
+ /** Absolute path of a session's block log — may not exist. */
430
+ export function stopBlockPath(session: string): string {
431
+ const base =
432
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
433
+ return join(base, STOP_BLOCK_DIR, `${sessionKey(session)}.jsonl`);
434
+ }
435
+
436
+ /**
437
+ * True when this session already blocked for this worktree — the caller then
438
+ * lets the turn end. Records the block when it has not. Every unknown (no
439
+ * session identity, unwritable state dir) resolves to false: a dedupe that
440
+ * guesses must fail towards blocking, never towards silence.
441
+ */
442
+ export function stopBlockedBefore(
443
+ session: string | null,
444
+ worktree: string,
445
+ ): boolean {
446
+ if (!session) return false;
447
+ const path = stopBlockPath(session);
448
+ try {
449
+ if (existsSync(path)) {
450
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
451
+ if (!line) continue;
452
+ try {
453
+ if ((JSON.parse(line) as StopBlockRow).worktree === worktree) {
454
+ return true;
455
+ }
456
+ } catch {
457
+ // a torn line must not lose the rest of the log
458
+ }
459
+ }
460
+ }
461
+ const dir = join(path, "..");
462
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
463
+ const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
464
+ appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
465
+ } catch {
466
+ return false;
467
+ }
468
+ return false;
359
469
  }
360
470
 
361
471
  /** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
@@ -370,6 +480,7 @@ export async function cmdHookStop(): Promise<void> {
370
480
  // died — commits from those turns are still caught at the next completed
371
481
  // stop (the window is the conversation transcript's birthtime).
372
482
  if (client === "cursor" && raw.status !== "completed") return;
483
+ // Codex: no status guard needed — Stop fires at turn end unconditionally.
373
484
 
374
485
  const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
375
486
 
@@ -388,6 +499,7 @@ export async function cmdHookStop(): Promise<void> {
388
499
  let commitList: string[] = [];
389
500
  let verdicts = 0;
390
501
  let memLastTs: string | null = null;
502
+ let memCandidates: string[] = [];
391
503
  if (worktree && since) {
392
504
  const log = git(
393
505
  ["log", "--since", `${since} +0000`, "--format=%h %s"],
@@ -409,6 +521,8 @@ export async function cmdHookStop(): Promise<void> {
409
521
  try {
410
522
  const mem = readMemLog(worktree);
411
523
  memLastTs = mem.rows[0]?.ts ?? null;
524
+ if (!memLastTs)
525
+ memCandidates = whereMemDir(worktree).candidates ?? [];
412
526
  } catch {
413
527
  memLastTs = null;
414
528
  }
@@ -422,7 +536,16 @@ export async function cmdHookStop(): Promise<void> {
422
536
  verdicts,
423
537
  commitList,
424
538
  memLastTs,
539
+ memCandidates,
425
540
  });
541
+ // Already blocked for this worktree in this session — say it once.
542
+ if (
543
+ reason &&
544
+ worktree &&
545
+ stopBlockedBefore(norm.transcriptPath, worktree)
546
+ ) {
547
+ reason = null;
548
+ }
426
549
  } catch {
427
550
  reason = null; // any failure = allow the turn to end
428
551
  }
@@ -430,6 +553,78 @@ export async function cmdHookStop(): Promise<void> {
430
553
  if (reason) console.log(stopOutput(client, reason));
431
554
  }
432
555
 
556
+ // --- Session start (kickoff as context, not as a thing to remember) ---
557
+ //
558
+ // `fapony mem kickoff` is the one command that pays for itself at session
559
+ // open — what is open, what is stale, what the last rows touched. Asking the
560
+ // agent to run it measured as not enough (rule 9), and an MCP tool would pay
561
+ // schema rent in every session of every client to save one bash round
562
+ // (rule 13). A SessionStart hook is neither: zero rent, and it fires whether
563
+ // or not anyone remembers.
564
+ //
565
+ // Runs the CLI in a subprocess rather than calling cmdKickoff: kickoff prints
566
+ // to stdout and exits on bad input, both of which would be this hook's stdout.
567
+
568
+ /** Cap on injected context — kickoff is short, a broken repo's output is not. */
569
+ export const SESSION_START_MAX_CHARS = 4_000;
570
+
571
+ const TRUNCATED = "… truncated — run `fapony mem kickoff` for the rest";
572
+
573
+ /** Trim to whole lines, keeping the marker's line boundary intact. */
574
+ function headLines(text: string, max: number): string {
575
+ const cut = text.slice(0, max);
576
+ const lastNl = cut.lastIndexOf("\n");
577
+ return (lastNl > 0 ? cut.slice(0, lastNl) : cut).trimEnd();
578
+ }
579
+
580
+ /**
581
+ * Trim to whole lines within the cap, with an honest truncation marker.
582
+ *
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.
587
+ */
588
+ export function capContext(
589
+ text: string,
590
+ max = SESSION_START_MAX_CHARS,
591
+ ): string {
592
+ if (text.length <= max) return text;
593
+ const at = text.lastIndexOf("\n## next up");
594
+ const tail = at > 0 ? text.slice(at).trimEnd() : "";
595
+ if (tail && tail.length < max / 2) {
596
+ return `${headLines(text, max - tail.length)}\n${TRUNCATED}\n${tail}`;
597
+ }
598
+ return `${headLines(text, max)}\n${TRUNCATED}`;
599
+ }
600
+
601
+ /** SessionStart hook: inject `fapony mem kickoff` output as context. */
602
+ export async function cmdHookSessionStart(): Promise<void> {
603
+ try {
604
+ const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
605
+ 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;
615
+ console.log(
616
+ JSON.stringify({
617
+ hookSpecificOutput: {
618
+ hookEventName: "SessionStart",
619
+ additionalContext: capContext(out),
620
+ },
621
+ }),
622
+ );
623
+ } catch {
624
+ // any failure = no context, never a broken session start
625
+ }
626
+ }
627
+
433
628
  // --- Read hint (PreToolUse annotate — never block, never dedupe) ---
434
629
  //
435
630
  // Reading a large file in full is where an agent spends tokens without
@@ -490,7 +685,7 @@ export function readHintFor(opts: ReadHintInput): string | null {
490
685
  return (
491
686
  `fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
492
687
  `returns exports with line numbers, importers, and signatures first ` +
493
- `(${READ_HINT_MEASURED})`
688
+ `(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
494
689
  );
495
690
  } catch {
496
691
  return null;
@@ -633,6 +828,132 @@ export function rereadHintFor(opts: RereadHintInput): string | null {
633
828
  }
634
829
  }
635
830
 
831
+ // --- Edit hint (PreToolUse annotate — importer count + once-per-session dedupe) ---
832
+ //
833
+ // Editing a file that has importers can silently break its consumers (measured:
834
+ // 17.9% of changed nodes over 30 commits had a 1-hop blast radius; ~12-16% of
835
+ // all-time fail rows were producer/consumer mismatches). The hint is a fact —
836
+ // the importer count plus the review-seed command that lists them — never a
837
+ // judgment about whether the edit is safe, and never a block.
838
+ //
839
+ // Dedupe is per (session, file): the first edit to a file fires, repeats stay
840
+ // silent. The track log reuses the read-track session mechanism (sessionKey,
841
+ // one jsonl per session) but lives in its own dir — sharing read-track's file
842
+ // would make an Edit look like a Read and falsely trip the re-read hint. Like
843
+ // rereadHintFor the track write happens inside this function (the caller-side
844
+ // rule covers recordHintFire, not dedupe state); unlike it there is no mtime
845
+ // comparison — an edit that moves mtime is still the same file in the same
846
+ // session, and repeating the count buys nothing.
847
+
848
+ const EDIT_TRACK_DIR = "edit-track";
849
+
850
+ export interface EditTrackRow {
851
+ ts: string;
852
+ path: string;
853
+ }
854
+
855
+ /** Directory holding one edit log per session. */
856
+ function editTrackDir(): string {
857
+ const base =
858
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
859
+ return join(base, EDIT_TRACK_DIR);
860
+ }
861
+
862
+ /** Absolute path of a session's edit log — may not exist. */
863
+ export function editTrackPath(session: string): string {
864
+ return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
865
+ }
866
+
867
+ function editTrackPaths(session: string): Set<string> {
868
+ const p = editTrackPath(session);
869
+ if (!existsSync(p)) return new Set();
870
+ const out = new Set<string>();
871
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
872
+ if (!line) continue;
873
+ try {
874
+ const r = JSON.parse(line) as EditTrackRow;
875
+ if (typeof r.path === "string") out.add(r.path);
876
+ } catch {
877
+ // a torn line must not lose the rest of the log
878
+ }
879
+ }
880
+ return out;
881
+ }
882
+
883
+ function appendEditTrackRow(session: string, row: EditTrackRow): void {
884
+ const dir = editTrackDir();
885
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
886
+ appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
887
+ }
888
+
889
+ export interface EditHintInput {
890
+ filePath: unknown;
891
+ cwd: string;
892
+ /**
893
+ * Session identity — transcript path (Claude) or session id. Without it the
894
+ * hint still fires (the importer fact holds) but cannot dedupe.
895
+ */
896
+ session?: unknown;
897
+ }
898
+
899
+ /**
900
+ * Factual one-liner for editing a source file that has importers, or null.
901
+ * Every unknown (no path, non-source ext, new/unsaved file, outside the
902
+ * worktree, no git repo, graph failure) resolves to null — a hint must never
903
+ * fire on a guess. Files with importers are checked before the dedupe log is
904
+ * touched, so a file nobody imports never writes a track row.
905
+ */
906
+ export function editHintFor(opts: EditHintInput): string | null {
907
+ try {
908
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
909
+ const dot = opts.filePath.lastIndexOf(".");
910
+ // SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
911
+ if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
912
+ // review-seed is a git command — outside a repo the hint would lie.
913
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
914
+ cwd: opts.cwd,
915
+ stdout: "pipe",
916
+ stderr: "pipe",
917
+ });
918
+ if (git.exitCode !== 0) return null;
919
+ // macOS /var → /private/var: normalize both sides before comparing.
920
+ const worktree = realpathSync(git.stdout.toString().trim());
921
+ const abs = (() => {
922
+ const p = opts.filePath.startsWith("/")
923
+ ? opts.filePath
924
+ : join(opts.cwd, opts.filePath);
925
+ try {
926
+ return realpathSync(p);
927
+ } catch {
928
+ return null; // new file — nothing imports it yet
929
+ }
930
+ })();
931
+ if (!abs) return null;
932
+ const rel = relative(worktree, abs).split(sep).join("/");
933
+ if (rel.startsWith("..") || rel === "") return null;
934
+
935
+ const importers = buildGraphCached(worktree).dependents.get(rel);
936
+ if (!importers || importers.size === 0) return null;
937
+
938
+ if (typeof opts.session === "string" && opts.session !== "") {
939
+ if (editTrackPaths(opts.session).has(abs)) return null;
940
+ appendEditTrackRow(opts.session, {
941
+ ts: new Date().toISOString(),
942
+ path: abs,
943
+ });
944
+ }
945
+
946
+ const n = importers.size;
947
+ return (
948
+ `fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
949
+ `review-seed --files ${rel} lists them (add --callers <export> for one ` +
950
+ `export's callers); check before changing its shape (skill /lookup-before-edit)`
951
+ );
952
+ } catch {
953
+ return null;
954
+ }
955
+ }
956
+
636
957
  // --- Commit hint (tool.execute.after — annotate only, never block) ---
637
958
  //
638
959
  // OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
@@ -835,6 +1156,80 @@ export async function cmdHookReadHint(): Promise<void> {
835
1156
  }
836
1157
  }
837
1158
 
1159
+ /** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
1160
+ * No permissionDecision ever — the edit always proceeds. Fires once per
1161
+ * (session, file); the dedupe lives inside editHintFor. */
1162
+ export async function cmdHookEditHint(): Promise<void> {
1163
+ try {
1164
+ const raw = JSON.parse(await Bun.stdin.text()) as {
1165
+ cwd?: string;
1166
+ transcript_path?: string;
1167
+ session_id?: string;
1168
+ tool_input?: {
1169
+ file_path?: unknown;
1170
+ };
1171
+ };
1172
+ const cwd = raw.cwd ?? process.cwd();
1173
+ const filePath = raw.tool_input?.file_path;
1174
+ // One edit log per session — same identity as the read hint.
1175
+ const session = raw.transcript_path ?? raw.session_id;
1176
+ const hint = editHintFor({ filePath, cwd, session });
1177
+ if (hint) {
1178
+ console.log(
1179
+ JSON.stringify({
1180
+ hookSpecificOutput: {
1181
+ hookEventName: "PreToolUse",
1182
+ additionalContext: hint,
1183
+ },
1184
+ }),
1185
+ );
1186
+ }
1187
+
1188
+ // --- hint-fire log (PLAN-edit-importer-hint chunk 3) ---
1189
+ // After output — best-effort, never block the hint.
1190
+ if (hint) {
1191
+ try {
1192
+ const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
1193
+ cwd,
1194
+ stdout: "pipe",
1195
+ stderr: "pipe",
1196
+ });
1197
+ if (g.exitCode === 0) {
1198
+ const worktree = realpathSync(g.stdout.toString().trim());
1199
+ const abs =
1200
+ typeof filePath === "string"
1201
+ ? (() => {
1202
+ try {
1203
+ return realpathSync(
1204
+ filePath.startsWith("/")
1205
+ ? filePath
1206
+ : join(worktree, filePath),
1207
+ );
1208
+ } catch {
1209
+ return null;
1210
+ }
1211
+ })()
1212
+ : null;
1213
+ const rel = abs
1214
+ ? relative(worktree, abs).split("\\").join("/")
1215
+ : null;
1216
+ recordHintFire({
1217
+ ts: new Date().toISOString(),
1218
+ worktree,
1219
+ surface: "edit",
1220
+ file: rel && !rel.startsWith("..") ? rel : null,
1221
+ count: 1,
1222
+ });
1223
+ }
1224
+ } catch {
1225
+ // best-effort — swallow
1226
+ }
1227
+ }
1228
+ } catch {
1229
+ // any failure = no hint; a hook must never block an edit over a hint
1230
+ }
1231
+ }
1232
+
838
1233
  // --- Debt + mem context (PLAN-convention-debt chunk 4) ---
839
1234
  //
840
1235
  // The one moment paying down debt is worth tokens is when the file is already
package/src/init-mem.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  rmSync,
14
14
  } from "node:fs";
15
15
  import { join } from "node:path";
16
- import { loadConfig } from "./db/index.js";
16
+ import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
17
17
 
18
18
  export function copyDir(src: string, dest: string): string[] {
19
19
  mkdirSync(dest, { recursive: true });
@@ -62,8 +62,8 @@ export function cmdInitMem(args: string[]): void {
62
62
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
63
63
  if (
64
64
  entry.isDirectory() &&
65
- !entry.name.startsWith(".") &&
66
- entry.name !== "node_modules"
65
+ entry.name !== "node_modules" &&
66
+ (!entry.name.startsWith(".") || entry.name === FAPONY_DIR)
67
67
  ) {
68
68
  walk(join(dir, entry.name), depth + 1);
69
69
  }
@@ -95,7 +95,7 @@ export function cmdInitMem(args: string[]): void {
95
95
  `keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
96
96
  );
97
97
  console.log(
98
- ` move them under ${join(d, "..", ".fapony", ".memory")}/, or re-run with --force to delete`,
98
+ ` move them under ${DEFAULT_MEM_DIR}/, or re-run with --force to delete`,
99
99
  );
100
100
  kept++;
101
101
  continue;
@@ -109,7 +109,7 @@ export function cmdInitMem(args: string[]): void {
109
109
  console.log(
110
110
  `\nremoved ${removed} legacy .memory/ director${removed === 1 ? "y" : "ies"}${
111
111
  kept > 0
112
- ? ` · kept ${kept} with logs — move them under .fapony/.memory/, then re-run`
112
+ ? ` · kept ${kept} with logs — move them under ${DEFAULT_MEM_DIR}/, then re-run`
113
113
  : ""
114
114
  }`,
115
115
  );
package/src/init.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type Config,
18
18
  doneDir,
19
19
  evidenceFile,
20
+ FAPONY_DIR,
20
21
  memoryDir,
21
22
  planDir,
22
23
  specDir,
@@ -108,7 +109,7 @@ export function initProject(targetPath: string, config?: Config): void {
108
109
  mkdirSync(targetPath, { recursive: true });
109
110
 
110
111
  // --- .fapony/ marker ---
111
- const faponyDir = join(targetPath, ".fapony");
112
+ const faponyDir = join(targetPath, FAPONY_DIR);
112
113
  if (existsSync(faponyDir)) {
113
114
  throw new Error(
114
115
  `${faponyDir} already exists — delete it first if you want a fresh scaffold.`,
@@ -142,7 +143,7 @@ export function initProject(targetPath: string, config?: Config): void {
142
143
  writeFileSync(evidencePath, EVIDENCE_JSON);
143
144
 
144
145
  // --- plan/ spec/ .memory/ — all under .fapony/ ---
145
- const planDirAbs = join(targetPath, planDir(config));
146
+ const planDirAbs = join(targetPath, planDir());
146
147
  if (existsSync(planDirAbs)) {
147
148
  throw new Error(`${planDirAbs} already exists — not overwriting.`);
148
149
  }
@@ -156,7 +157,7 @@ export function initProject(targetPath: string, config?: Config): void {
156
157
  mkdirSync(doneDirAbs, { recursive: true });
157
158
 
158
159
  // --- spec/ ---
159
- const specDirAbs = join(targetPath, specDir(config));
160
+ const specDirAbs = join(targetPath, specDir());
160
161
  if (existsSync(specDirAbs)) {
161
162
  throw new Error(`${specDirAbs} already exists — not overwriting.`);
162
163
  }
@@ -177,9 +178,9 @@ export function initProject(targetPath: string, config?: Config): void {
177
178
  console.log(
178
179
  ` .fapony/ — project dir (plans, specs, memory, evidence)`,
179
180
  );
180
- console.log(` ${planDir(config)}/ — live plan files`);
181
+ console.log(` ${planDir()}/ — live plan files`);
181
182
  console.log(` ${doneDir(config)}/ — shipped plans (archive)`);
182
- console.log(` ${specDir(config)}/ — spec files`);
183
+ console.log(` ${specDir()}/ — spec files`);
183
184
  console.log(
184
185
  ` ${evidenceFile(config)} — allowlist for 'fapony report' (edit the cmds!)`,
185
186
  );