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.
- package/README.md +111 -46
- package/fapony.ts +35 -2
- package/package.json +3 -2
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +175 -5
- package/src/conventions-seed.ts +3 -3
- package/src/db/defaults.ts +13 -5
- package/src/db/getters.ts +11 -9
- package/src/db/load.ts +2 -2
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +2 -4
- package/src/debt.ts +176 -36
- package/src/digest/collect.ts +19 -2
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +665 -24
- package/src/init-mem.ts +123 -37
- package/src/init.ts +57 -39
- package/src/install/claude.ts +44 -8
- package/src/install/codex.ts +105 -13
- package/src/install/opencode.ts +169 -5
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +3 -8
- package/src/mcp/evidence.ts +15 -3
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +350 -0
- package/src/memory.ts +238 -40
- package/src/plan-seed.ts +26 -6
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +7 -8
- package/src/stats/data.ts +40 -104
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /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 {
|
|
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
|
-
/**
|
|
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
|
|
176
|
-
*
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
436
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
524
|
-
for (const r of
|
|
525
|
-
|
|
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
|
|
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
|
+
}
|