fapony 0.1.3 → 0.2.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.
Files changed (48) hide show
  1. package/README.md +75 -44
  2. package/fapony.ts +32 -1
  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 +13 -1
  7. package/src/conventions-seed.ts +0 -1
  8. package/src/db/defaults.ts +1 -1
  9. package/src/db/getters.ts +3 -3
  10. package/src/db/store.ts +0 -75
  11. package/src/db/types.ts +1 -1
  12. package/src/debt.ts +169 -34
  13. package/src/digest/collect.ts +16 -0
  14. package/src/digest/text.ts +25 -0
  15. package/src/hook.ts +424 -16
  16. package/src/init-mem.ts +123 -37
  17. package/src/init.ts +51 -34
  18. package/src/install/claude.ts +7 -5
  19. package/src/install/opencode.ts +59 -5
  20. package/src/lint-baseline.ts +1 -7
  21. package/src/mcp/evidence.ts +1 -1
  22. package/src/mcp/tools/index.ts +97 -185
  23. package/src/mcp/tools/mem.ts +153 -11
  24. package/src/mcp/transport.ts +5 -13
  25. package/src/mem/commands/read.ts +448 -0
  26. package/src/mem/commands/where.ts +56 -0
  27. package/{templates → src}/mem/commands/write.ts +12 -5
  28. package/src/mem/index.ts +144 -0
  29. package/src/mem/store.ts +348 -0
  30. package/src/memory.ts +237 -40
  31. package/src/plan-seed.ts +20 -1
  32. package/src/review-seed.ts +19 -0
  33. package/src/setup.ts +4 -6
  34. package/src/stats/data.ts +34 -86
  35. package/src/stats/format.ts +8 -9
  36. package/src/stats/index.ts +0 -1
  37. package/templates/PLAN.md +1 -0
  38. package/src/mcp/tools/context.ts +0 -66
  39. package/src/mcp/tools/plans.ts +0 -255
  40. package/src/mcp/tools/stats.ts +0 -96
  41. package/templates/mem/commands/read.ts +0 -194
  42. package/templates/mem/commands/selftest.ts +0 -450
  43. package/templates/mem/mem.ts +0 -68
  44. package/templates/mem/store.ts +0 -285
  45. /package/{templates → src}/mem/commands/plan.ts +0 -0
  46. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  47. /package/{templates → src}/mem/render.ts +0 -0
  48. /package/{templates → src}/mem/selectors.ts +0 -0
package/src/hook.ts CHANGED
@@ -16,14 +16,192 @@
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";
29
+ import { basename, join, relative, resolve } from "node:path";
22
30
  import { 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";
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: { read: number; debt: number; mem: number; commit: number };
100
+ debt: { shown: number; resolved: number; unknown: number };
101
+ window: string | null;
102
+ }
103
+
104
+ /**
105
+ * Compute hint-fire impact from the log. `since` is an ISO date string;
106
+ * omit to scan all rows. `worktree` scopes to one project's log file (the
107
+ * `<key>.jsonl` naming makes this a filename comparison) — omit to merge
108
+ * every worktree. Returns zeroed counts (not null) when there are no rows —
109
+ * the caller decides how to present "no data" vs "zero".
110
+ */
111
+ export function computeHintImpact(
112
+ since?: string,
113
+ worktree?: string,
114
+ ): HintImpact {
115
+ const dir = hintLogDir();
116
+ const impact: HintImpact = {
117
+ fired: 0,
118
+ by_surface: { read: 0, debt: 0, mem: 0, commit: 0 },
119
+ debt: { shown: 0, resolved: 0, unknown: 0 },
120
+ window: since ?? null,
121
+ };
122
+
123
+ if (!existsSync(dir)) return impact;
124
+
125
+ // Read the worktree's .jsonl when scoped, else every file in the dir.
126
+ let files: string[];
127
+ try {
128
+ const all = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
129
+ files = worktree
130
+ ? all.filter((f) => f === `${worktreeKey(worktree)}.jsonl`)
131
+ : all;
132
+ } catch {
133
+ return impact;
134
+ }
135
+
136
+ // debtShown: Map<"worktree\tfile\tid", true> — unique debt ids per file.
137
+ const debtShown = new Map<string, true>();
138
+ // debtByFile: Map<"worktree\tfile", string[]> — all ids shown per file.
139
+ const debtByFile = new Map<string, string[]>();
140
+
141
+ for (const file of files) {
142
+ let content: string;
143
+ try {
144
+ content = readFileSync(join(dir, file), "utf-8");
145
+ } catch {
146
+ continue;
147
+ }
148
+ for (const line of content.split("\n")) {
149
+ if (!line) continue;
150
+ let row: HintFireRow;
151
+ try {
152
+ row = JSON.parse(line) as HintFireRow;
153
+ } catch {
154
+ continue;
155
+ }
156
+ if (since && row.ts < since) continue;
157
+ impact.fired++;
158
+ impact.by_surface[row.surface]++;
159
+
160
+ if (row.surface === "debt" && row.ids && row.file) {
161
+ const key = `${row.worktree}\t${row.file}`;
162
+ const existing = debtByFile.get(key) ?? [];
163
+ for (const id of row.ids) {
164
+ const dk = `${row.worktree}\t${row.file}\t${id}`;
165
+ if (!debtShown.has(dk)) {
166
+ debtShown.set(dk, true);
167
+ existing.push(id);
168
+ }
169
+ }
170
+ debtByFile.set(key, existing);
171
+ }
172
+ }
173
+ }
174
+
175
+ // Re-run debtForFile at HEAD for each file that had debt hints.
176
+ for (const [key, ids] of debtByFile) {
177
+ const [worktree, file] = key.split("\t");
178
+ const absFile = join(worktree, file);
179
+ let currentIds: Set<string>;
180
+ try {
181
+ if (!statSync(absFile).isFile()) {
182
+ // File deleted — all its debt ids are unknown.
183
+ impact.debt.unknown += ids.length;
184
+ continue;
185
+ }
186
+ const convs = debtForFile(worktree, absFile, loadConventions(worktree));
187
+ currentIds = new Set(convs.map((c) => c.id));
188
+ } catch {
189
+ impact.debt.unknown += ids.length;
190
+ continue;
191
+ }
192
+ for (const id of ids) {
193
+ impact.debt.shown++;
194
+ if (currentIds.has(id)) {
195
+ // still present — not resolved
196
+ } else {
197
+ impact.debt.resolved++;
198
+ }
199
+ }
200
+ }
201
+
202
+ return impact;
203
+ }
204
+
27
205
  export interface RawStopPayload {
28
206
  // Claude Code
29
207
  cwd?: string;
@@ -319,6 +497,142 @@ export function readHintFor(opts: ReadHintInput): string | null {
319
497
  }
320
498
  }
321
499
 
500
+ // --- Re-read tracking (mtime heuristic — annotate only) ---
501
+ //
502
+ // The size hint above fires on almost nothing real: measured 2026-09-20 over
503
+ // 28,151 read parts across every project, only 2.3% had fileSize>=24KB with a
504
+ // full bound, while 55.5% of read context came from files under 24KB and 19.5%
505
+ // from re-reading a path already read this session (39.2% of context sits in
506
+ // (session,path) pairs read >=2x). The cost is repetition, not one big file —
507
+ // so the gate here is not size, it is "same file, unchanged, again".
508
+ //
509
+ // mtime is the whole mechanism: unchanged mtime since the previous read in
510
+ // this session = the same bytes = annotate; mtime moved = someone edited it =
511
+ // new content = silent. No Edit/Grep tracking — mtime already answers "did it
512
+ // change", so no tool-sequence tagging is needed.
513
+ //
514
+ // Log lives beside hint-log but is separate — hint-log counts fires, this
515
+ // records reads keyed by session (one file per session, so a hint never leaks
516
+ // into the next session / rule 11). Same contract as the size hint: annotate
517
+ // only, never block, every unknown → silent, best-effort. Called at the caller
518
+ // only, never inside a pure function (test pollution — same reason
519
+ // recordHintFire is caller-side).
520
+
521
+ const READ_TRACK_DIR = "read-track";
522
+
523
+ export interface ReadTrackRow {
524
+ ts: string;
525
+ path: string;
526
+ mtime: number;
527
+ }
528
+
529
+ /** Filename key for a session — basename of a transcript path or a raw id. */
530
+ export function sessionKey(session: string): string {
531
+ const base = basename(session).replace(/\.[^.]+$/, "");
532
+ return base.replace(/[^A-Za-z0-9_-]/g, "-") || "unknown";
533
+ }
534
+
535
+ /** Directory holding one read log per session. */
536
+ function readTrackDir(): string {
537
+ const base =
538
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
539
+ return join(base, READ_TRACK_DIR);
540
+ }
541
+
542
+ /** Absolute path of a session's read log — may not exist. */
543
+ export function readTrackPath(session: string): string {
544
+ return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
545
+ }
546
+
547
+ function readTrackRows(session: string): ReadTrackRow[] {
548
+ const p = readTrackPath(session);
549
+ if (!existsSync(p)) return [];
550
+ const rows: ReadTrackRow[] = [];
551
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
552
+ if (!line) continue;
553
+ try {
554
+ const r = JSON.parse(line) as ReadTrackRow;
555
+ if (typeof r.path === "string" && typeof r.mtime === "number") {
556
+ rows.push(r);
557
+ }
558
+ } catch {
559
+ // a torn line must not lose the rest of the log
560
+ }
561
+ }
562
+ return rows;
563
+ }
564
+
565
+ function appendReadTrackRow(session: string, row: ReadTrackRow): void {
566
+ const dir = readTrackDir();
567
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
568
+ appendFileSync(readTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
569
+ }
570
+
571
+ export interface RereadHintInput {
572
+ filePath: unknown;
573
+ offset?: unknown;
574
+ limit?: unknown;
575
+ cwd: string;
576
+ /** Session identity — transcript path (Claude) or session id (OpenCode). */
577
+ session?: unknown;
578
+ }
579
+
580
+ /**
581
+ * Annotate a full-file read of a path already read this session whose mtime
582
+ * has not moved, or null. Records every full read, so the returned count is
583
+ * the number of prior reads. Every unknown (kill switch on, no session, no
584
+ * path, bounded read, partial offset, missing file, read/write failure)
585
+ * resolves to null — a hint must never fire on a guess.
586
+ */
587
+ export function rereadHintFor(opts: RereadHintInput): string | null {
588
+ try {
589
+ if (process.env.FAPONY_NO_REREAD_HINT === "1") return null;
590
+ if (typeof opts.session !== "string" || opts.session === "") return null;
591
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
592
+ // A bounded/partial read is already cheap — same line the size hint draws.
593
+ const offset = typeof opts.offset === "number" ? opts.offset : null;
594
+ if (offset !== null && offset !== 0) return null;
595
+ const limit = typeof opts.limit === "number" ? opts.limit : null;
596
+ if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
597
+
598
+ const abs = (() => {
599
+ const p = opts.filePath.startsWith("/")
600
+ ? opts.filePath
601
+ : join(opts.cwd, opts.filePath);
602
+ try {
603
+ return realpathSync(p);
604
+ } catch {
605
+ return resolve(p);
606
+ }
607
+ })();
608
+ const st = statSync(abs);
609
+ if (!st.isFile()) return null;
610
+ const mtime = Math.round(st.mtimeMs);
611
+
612
+ const prior = readTrackRows(opts.session).filter((r) => r.path === abs);
613
+ const last = prior.at(-1);
614
+ appendReadTrackRow(opts.session, {
615
+ ts: new Date().toISOString(),
616
+ path: abs,
617
+ mtime,
618
+ });
619
+ if (!last || last.mtime !== mtime) return null;
620
+
621
+ // The hint feeds the agent's context — show the path *it* passed, relative
622
+ // to its own cwd, so a symlinked cwd (macOS /var → /private/var) does not
623
+ // turn a clean relative path into a resolved absolute one.
624
+ const rel = relative(opts.cwd, opts.filePath);
625
+ const shown = rel.startsWith("..") ? opts.filePath : rel;
626
+ return (
627
+ `fapony: already read ${shown} ${prior.length}\u00d7 this session — ` +
628
+ `content unchanged since the last read (mtime), grep the line range ` +
629
+ `you need instead of re-reading it`
630
+ );
631
+ } catch {
632
+ return null;
633
+ }
634
+ }
635
+
322
636
  // --- Commit hint (tool.execute.after — annotate only, never block) ---
323
637
  //
324
638
  // OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
@@ -417,6 +731,8 @@ export async function cmdHookReadHint(): Promise<void> {
417
731
  try {
418
732
  const raw = JSON.parse(await Bun.stdin.text()) as {
419
733
  cwd?: string;
734
+ transcript_path?: string;
735
+ session_id?: string;
420
736
  tool_input?: {
421
737
  file_path?: unknown;
422
738
  offset?: unknown;
@@ -424,16 +740,31 @@ export async function cmdHookReadHint(): Promise<void> {
424
740
  };
425
741
  };
426
742
  const cwd = raw.cwd ?? process.cwd();
743
+ const filePath = raw.tool_input?.file_path;
744
+ // One read log per session — transcript_path is the Claude session file,
745
+ // session_id the fallback when a client omits it.
746
+ const session = raw.transcript_path ?? raw.session_id;
427
747
  const parts: string[] = [];
428
748
  const hint = readHintFor({
429
- filePath: raw.tool_input?.file_path,
749
+ filePath,
430
750
  offset: raw.tool_input?.offset,
431
751
  limit: raw.tool_input?.limit,
432
752
  cwd,
433
753
  });
434
754
  if (hint) parts.push(hint);
435
- for (const line of readContextLines(raw.tool_input?.file_path, cwd)) {
436
- parts.push(line);
755
+ const reread = rereadHintFor({
756
+ filePath,
757
+ offset: raw.tool_input?.offset,
758
+ limit: raw.tool_input?.limit,
759
+ cwd,
760
+ session,
761
+ });
762
+ if (reread) parts.push(reread);
763
+ const ctx = readContextData(filePath, cwd);
764
+ if (ctx) {
765
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
766
+ parts.push(line);
767
+ }
437
768
  }
438
769
  if (parts.length > 0) {
439
770
  console.log(
@@ -445,6 +776,60 @@ export async function cmdHookReadHint(): Promise<void> {
445
776
  }),
446
777
  );
447
778
  }
779
+
780
+ // --- hint-fire log (PLAN-feedback-surface chunk 1) ---
781
+ // After output — best-effort, never block the hint.
782
+ const rel =
783
+ typeof filePath === "string"
784
+ ? (() => {
785
+ try {
786
+ const git = Bun.spawnSync(
787
+ ["git", "rev-parse", "--show-toplevel"],
788
+ { cwd, stdout: "pipe", stderr: "pipe" },
789
+ );
790
+ if (git.exitCode !== 0) return null;
791
+ const wt = realpathSync(git.stdout.toString().trim());
792
+ const abs = realpathSync(
793
+ filePath.startsWith("/") ? filePath : join(wt, filePath),
794
+ );
795
+ const r = relative(wt, abs).split("\\").join("/");
796
+ return r.startsWith("..") ? null : r;
797
+ } catch {
798
+ return null;
799
+ }
800
+ })()
801
+ : null;
802
+ const worktree = ctx?.worktree ?? null;
803
+ if (worktree) {
804
+ if (hint) {
805
+ recordHintFire({
806
+ ts: new Date().toISOString(),
807
+ worktree,
808
+ surface: "read",
809
+ file: rel,
810
+ count: 1,
811
+ });
812
+ }
813
+ if (ctx && ctx.debtIds.length > 0) {
814
+ recordHintFire({
815
+ ts: new Date().toISOString(),
816
+ worktree,
817
+ surface: "debt",
818
+ file: rel,
819
+ count: ctx.debtIds.length,
820
+ ids: ctx.debtIds,
821
+ });
822
+ }
823
+ if (ctx && ctx.memLines.length > 0) {
824
+ recordHintFire({
825
+ ts: new Date().toISOString(),
826
+ worktree,
827
+ surface: "mem",
828
+ file: rel,
829
+ count: ctx.memLines.length,
830
+ });
831
+ }
832
+ }
448
833
  } catch {
449
834
  // any failure = no hint; a hook must never block a read over a hint
450
835
  }
@@ -462,15 +847,26 @@ const DEBT_HINT_MAX = 3;
462
847
  const MEM_HINT_MAX = 2;
463
848
  const MEM_TEXT_MAX = 120;
464
849
 
465
- export function readContextLines(filePath: unknown, cwd: string): string[] {
850
+ export interface ContextLineData {
851
+ worktree: string;
852
+ debtIds: string[];
853
+ debtLines: string[];
854
+ memLines: string[];
855
+ }
856
+
857
+ /** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
858
+ export function readContextData(
859
+ filePath: unknown,
860
+ cwd: string,
861
+ ): ContextLineData | null {
466
862
  try {
467
- if (typeof filePath !== "string" || filePath === "") return [];
863
+ if (typeof filePath !== "string" || filePath === "") return null;
468
864
  const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
469
865
  cwd,
470
866
  stdout: "pipe",
471
867
  stderr: "pipe",
472
868
  });
473
- if (git.exitCode !== 0) return [];
869
+ if (git.exitCode !== 0) return null;
474
870
  // macOS /var → /private/var: git reports the resolved root while callers
475
871
  // pass unresolved tmp paths — normalize both sides before comparing.
476
872
  const worktree = realpathSync(git.stdout.toString().trim());
@@ -478,9 +874,11 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
478
874
  filePath.startsWith("/") ? filePath : join(worktree, filePath),
479
875
  );
480
876
  const rel = relative(worktree, abs).split("\\").join("/");
481
- if (rel.startsWith("..") || rel === "") return [];
877
+ if (rel.startsWith("..") || rel === "") return null;
482
878
 
483
- const lines: string[] = [];
879
+ const debtIds: string[] = [];
880
+ const debtLines: string[] = [];
881
+ const memLines: string[] = [];
484
882
 
485
883
  // convention debt — source files only, fresh from the repo
486
884
  const dot = rel.lastIndexOf(".");
@@ -490,7 +888,8 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
490
888
  abs,
491
889
  loadConventions(worktree),
492
890
  ).slice(0, DEBT_HINT_MAX)) {
493
- lines.push(`fapony debt: [${c.id}] ${c.rule}`);
891
+ debtIds.push(c.id);
892
+ debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
494
893
  }
495
894
  }
496
895
 
@@ -520,15 +919,24 @@ export function readContextLines(filePath: unknown, cwd: string): string[] {
520
919
  if (sameName !== 1) usableBase = [];
521
920
  }
522
921
  // 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(
922
+ const memHits = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
923
+ for (const r of memHits) {
924
+ memLines.push(
526
925
  `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
527
926
  );
528
927
  }
529
928
  }
530
- return lines.slice(0, DEBT_HINT_MAX + MEM_HINT_MAX);
929
+ return { worktree, debtIds, debtLines, memLines };
531
930
  } catch {
532
- return [];
931
+ return null;
533
932
  }
534
933
  }
934
+
935
+ export function readContextLines(filePath: unknown, cwd: string): string[] {
936
+ const data = readContextData(filePath, cwd);
937
+ if (!data) return [];
938
+ return [...data.debtLines, ...data.memLines].slice(
939
+ 0,
940
+ DEBT_HINT_MAX + MEM_HINT_MAX,
941
+ );
942
+ }