fapony 0.3.3 → 0.3.5
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 +7 -1
- package/fapony.ts +3 -116
- package/package.json +5 -6
- package/skill/move-to-done/SKILL.md +3 -2
- package/src/adapters/cli.ts +128 -0
- package/src/adapters/hooks/compute-hint-impact.ts +107 -0
- package/src/adapters/hooks/context-data.ts +102 -0
- package/src/adapters/hooks/edit-hint.ts +195 -0
- package/src/adapters/hooks/git-autonomy.ts +178 -0
- package/src/adapters/hooks/index.ts +80 -0
- package/src/adapters/hooks/mv-guard.ts +52 -0
- package/src/adapters/hooks/read-hint.ts +383 -0
- package/src/adapters/hooks/session-start.ts +101 -0
- package/src/adapters/hooks/stop.ts +434 -0
- package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
- package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
- package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
- package/src/adapters/mcp/types.ts +18 -0
- package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
- package/src/analyze.ts +1 -1
- package/src/commands.ts +180 -0
- package/src/conventions-seed.ts +1 -1
- package/src/core/config.ts +199 -0
- package/src/core/debt-format.ts +107 -0
- package/src/core/debt-types.ts +79 -0
- package/src/core/defaults.ts +8 -0
- package/src/core/enums.ts +34 -0
- package/src/core/format.ts +33 -0
- package/src/core/hint-log.ts +68 -0
- package/src/core/hook-helpers.ts +31 -0
- package/src/core/mem-log.ts +357 -0
- package/src/core/parse.ts +71 -0
- package/src/core/pricing.ts +217 -0
- package/src/core/safety.ts +18 -0
- package/src/core/types.ts +142 -0
- package/src/core/util.ts +105 -0
- package/src/db/store.ts +2 -2
- package/src/debt/cli.ts +1 -1
- package/src/debt/format.ts +2 -107
- package/src/debt/load.ts +1 -1
- package/src/debt/promotion.ts +1 -1
- package/src/debt/types.ts +14 -79
- package/src/digest/collect.ts +4 -3
- package/src/gate.ts +2 -2
- package/src/gates.ts +1 -1
- package/src/hook.ts +70 -1424
- package/src/init-mem.ts +1 -1
- package/src/init.ts +1 -1
- package/src/install/claude.ts +17 -0
- package/src/install/opencode.ts +114 -0
- package/src/install.ts +12 -5
- package/src/lint-baseline.ts +1 -2
- package/src/map.ts +29 -8
- package/src/mem/commands/plan.ts +197 -42
- package/src/mem/commands/read.ts +53 -12
- package/src/mem/index.ts +60 -1
- package/src/mem/store.ts +5 -1
- package/src/memory.ts +26 -388
- package/src/parse.ts +9 -71
- package/src/price/fetch.ts +4 -16
- package/src/price/resolve.ts +12 -213
- package/src/report/cli.ts +3 -3
- package/src/safety.ts +2 -18
- package/src/seed/plan-seed.ts +126 -32
- package/src/seed/primitives.ts +1 -7
- package/src/session/types.ts +14 -128
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +3 -3
- package/src/telemetry.ts +2 -2
- package/src/update.ts +1 -1
- package/src/usage/cache.ts +1 -2
- package/src/usage/cli.ts +1 -1
- package/src/usage/scan.ts +2 -1
- package/src/util.ts +10 -93
- package/src/web/html.ts +2 -33
- package/src/db/defaults.ts +0 -34
- package/src/db/getters.ts +0 -35
- package/src/db/index.ts +0 -7
- package/src/db/load.ts +0 -57
- package/src/db/types.ts +0 -77
- package/src/mcp/types.ts +0 -54
- package/src/test.ts +0 -2
- /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
- /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
package/src/hook.ts
CHANGED
|
@@ -1,1424 +1,70 @@
|
|
|
1
|
-
// src/hook.ts —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
211
|
-
export interface RawStopPayload {
|
|
212
|
-
// Claude Code
|
|
213
|
-
cwd?: string;
|
|
214
|
-
transcript_path?: string | null;
|
|
215
|
-
stop_hook_active?: boolean;
|
|
216
|
-
// Cursor — common schema (https://cursor.com/docs/agent/hooks)
|
|
217
|
-
workspace_roots?: string[];
|
|
218
|
-
conversation_id?: string;
|
|
219
|
-
loop_count?: number;
|
|
220
|
-
status?: string;
|
|
221
|
-
// Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
|
|
222
|
-
session_id?: string;
|
|
223
|
-
model?: string;
|
|
224
|
-
permission_mode?: string;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export type StopClient = "claude" | "cursor" | "codex";
|
|
228
|
-
|
|
229
|
-
export interface NormalizedStopInput {
|
|
230
|
-
client: StopClient;
|
|
231
|
-
cwd: string;
|
|
232
|
-
transcriptPath: string | null;
|
|
233
|
-
stopHookActive: boolean;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** UTC 'YYYY-MM-DD HH:MM:SS' — the format events.ts is written in. */
|
|
237
|
-
export function utcStamp(d: Date): string {
|
|
238
|
-
return d.toISOString().slice(0, 19).replace("T", " ");
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
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.
|
|
259
|
-
*
|
|
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.
|
|
264
|
-
*/
|
|
265
|
-
export function decideStop(opts: {
|
|
266
|
-
stopHookActive: boolean;
|
|
267
|
-
worktree: string | null;
|
|
268
|
-
commits: number;
|
|
269
|
-
since?: string | null;
|
|
270
|
-
commitList?: string[];
|
|
271
|
-
memLastTs?: string | null;
|
|
272
|
-
/** Logs that exist in the repo but are out of scope from this worktree. */
|
|
273
|
-
memCandidates?: string[];
|
|
274
|
-
}): string | null {
|
|
275
|
-
if (opts.stopHookActive) return null; // already blocked once — let it end
|
|
276
|
-
if (!opts.worktree) return null;
|
|
277
|
-
if (opts.commits < 1) 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
|
-
}
|
|
290
|
-
|
|
291
|
-
const lines: string[] = [
|
|
292
|
-
`${opts.commits} commit(s) landed in ${opts.worktree} this session — no mem row recorded for this work.`,
|
|
293
|
-
];
|
|
294
|
-
// ≤ 5 commits listed, rest folded into "… +N more" (spec §6: ≤ 12 lines).
|
|
295
|
-
const list = opts.commitList ?? [];
|
|
296
|
-
for (const c of list.slice(0, 5)) lines.push(` ${c}`);
|
|
297
|
-
if (list.length > 5) lines.push(` … +${list.length - 5} more`);
|
|
298
|
-
if (opts.memLastTs) {
|
|
299
|
-
lines.push(
|
|
300
|
-
`mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
|
|
301
|
-
);
|
|
302
|
-
} else if (opts.memCandidates?.length) {
|
|
303
|
-
lines.push(
|
|
304
|
-
`mem: no log in scope from ${opts.worktree} — found ` +
|
|
305
|
-
`${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
lines.push(
|
|
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.`,
|
|
313
|
-
);
|
|
314
|
-
return lines.join("\n");
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function git(args: string[], cwd: string): string | null {
|
|
318
|
-
try {
|
|
319
|
-
const p = Bun.spawnSync(["git", ...args], {
|
|
320
|
-
cwd,
|
|
321
|
-
stdout: "pipe",
|
|
322
|
-
stderr: "pipe",
|
|
323
|
-
});
|
|
324
|
-
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
|
|
325
|
-
} catch {
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Cursor transcript location derived from the conversation id —
|
|
332
|
-
* ~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl where slug is
|
|
333
|
-
* the workspace path minus its leading "/", "/" → "-" (Claude Code's slug
|
|
334
|
-
* convention). Fallback only: a real transcript_path in the payload wins.
|
|
335
|
-
*/
|
|
336
|
-
export function cursorTranscriptPath(
|
|
337
|
-
home: string,
|
|
338
|
-
cwd: string,
|
|
339
|
-
conversationId: string,
|
|
340
|
-
): string {
|
|
341
|
-
const slug = cwd.replace(/^\//, "").replace(/\//g, "-");
|
|
342
|
-
return join(
|
|
343
|
-
home,
|
|
344
|
-
".cursor",
|
|
345
|
-
"projects",
|
|
346
|
-
slug,
|
|
347
|
-
"agent-transcripts",
|
|
348
|
-
conversationId,
|
|
349
|
-
`${conversationId}.jsonl`,
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export function isCursorPayload(raw: RawStopPayload): boolean {
|
|
354
|
-
return (
|
|
355
|
-
Array.isArray(raw.workspace_roots) ||
|
|
356
|
-
typeof raw.conversation_id === "string"
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
|
|
361
|
-
export function isCodexPayload(raw: RawStopPayload): boolean {
|
|
362
|
-
return (
|
|
363
|
-
typeof raw.permission_mode === "string" ||
|
|
364
|
-
(typeof raw.model === "string" && !isCursorPayload(raw))
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** Field-mapping only — all three clients feed the same decideStop below. */
|
|
369
|
-
export function normalizeStopInput(
|
|
370
|
-
raw: RawStopPayload,
|
|
371
|
-
home: string,
|
|
372
|
-
): NormalizedStopInput {
|
|
373
|
-
if (isCodexPayload(raw)) {
|
|
374
|
-
// Codex: cwd is the session working directory; stop_hook_active means
|
|
375
|
-
// the hook already fired once (same semantics as Claude).
|
|
376
|
-
return {
|
|
377
|
-
client: "codex",
|
|
378
|
-
cwd: raw.cwd ?? process.cwd(),
|
|
379
|
-
transcriptPath:
|
|
380
|
-
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
381
|
-
? raw.transcript_path
|
|
382
|
-
: null,
|
|
383
|
-
stopHookActive: raw.stop_hook_active === true,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
if (isCursorPayload(raw)) {
|
|
387
|
-
const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
|
|
388
|
-
let transcriptPath =
|
|
389
|
-
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
390
|
-
? raw.transcript_path
|
|
391
|
-
: null;
|
|
392
|
-
if (!transcriptPath && raw.conversation_id) {
|
|
393
|
-
transcriptPath = cursorTranscriptPath(home, cwd, raw.conversation_id);
|
|
394
|
-
}
|
|
395
|
-
return {
|
|
396
|
-
client: "cursor",
|
|
397
|
-
cwd,
|
|
398
|
-
transcriptPath,
|
|
399
|
-
// loop_count counts follow-ups this hook already triggered — ≥ 1 means
|
|
400
|
-
// we already blocked once (Cursor's stop_hook_active).
|
|
401
|
-
stopHookActive: (raw.loop_count ?? 0) > 0,
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
return {
|
|
405
|
-
client: "claude",
|
|
406
|
-
cwd: raw.cwd ?? process.cwd(),
|
|
407
|
-
transcriptPath: raw.transcript_path ?? null,
|
|
408
|
-
stopHookActive: raw.stop_hook_active === true,
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** Claude blocks with decision:block; Cursor auto-submits as followup_message;
|
|
413
|
-
* Codex continues with decision:block + reason (continue:false would take
|
|
414
|
-
* precedence and end the turn instead — Codex Hooks, Stop section). */
|
|
415
|
-
export function stopOutput(client: StopClient, reason: string): string {
|
|
416
|
-
if (client === "cursor") return JSON.stringify({ followup_message: reason });
|
|
417
|
-
if (client === "codex") return JSON.stringify({ decision: "block", reason });
|
|
418
|
-
return JSON.stringify({ decision: "block", reason });
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// --- Block dedupe (one block per session + worktree) ---
|
|
422
|
-
//
|
|
423
|
-
// stop_hook_active only suppresses the block that fires *immediately* after
|
|
424
|
-
// one. Every later turn that still carries ungraded commits blocks again, so
|
|
425
|
-
// a session that keeps committing gets the same paragraph 4-5 times. The
|
|
426
|
-
// first block already delivered it; the repeats add noise, not force (rule 9
|
|
427
|
-
// — the forcing happens once, and the agent that ignored it once is not
|
|
428
|
-
// persuaded by the fifth copy).
|
|
429
|
-
//
|
|
430
|
-
// Keyed by the transcript path, which is already the session identity the
|
|
431
|
-
// commit window is measured from — no session field to thread through.
|
|
432
|
-
|
|
433
|
-
const STOP_BLOCK_DIR = "stop-block";
|
|
434
|
-
|
|
435
|
-
interface StopBlockRow {
|
|
436
|
-
ts: string;
|
|
437
|
-
worktree: string;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/** Absolute path of a session's block log — may not exist. */
|
|
441
|
-
export function stopBlockPath(session: string): string {
|
|
442
|
-
const base =
|
|
443
|
-
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
444
|
-
return join(base, STOP_BLOCK_DIR, `${sessionKey(session)}.jsonl`);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* True when this session already blocked for this worktree — the caller then
|
|
449
|
-
* lets the turn end. Records the block when it has not. Every unknown (no
|
|
450
|
-
* session identity, unwritable state dir) resolves to false: a dedupe that
|
|
451
|
-
* guesses must fail towards blocking, never towards silence.
|
|
452
|
-
*/
|
|
453
|
-
export function stopBlockedBefore(
|
|
454
|
-
session: string | null,
|
|
455
|
-
worktree: string,
|
|
456
|
-
): boolean {
|
|
457
|
-
if (!session) return false;
|
|
458
|
-
const path = stopBlockPath(session);
|
|
459
|
-
try {
|
|
460
|
-
if (existsSync(path)) {
|
|
461
|
-
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
462
|
-
if (!line) continue;
|
|
463
|
-
try {
|
|
464
|
-
if ((JSON.parse(line) as StopBlockRow).worktree === worktree) {
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
467
|
-
} catch {
|
|
468
|
-
// a torn line must not lose the rest of the log
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
const dir = join(path, "..");
|
|
473
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
474
|
-
const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
|
|
475
|
-
appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
476
|
-
} catch {
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
479
|
-
return false;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
483
|
-
export async function cmdHookStop(): Promise<void> {
|
|
484
|
-
let reason: string | null = null;
|
|
485
|
-
let client: StopClient = "claude";
|
|
486
|
-
try {
|
|
487
|
-
const raw = JSON.parse(await Bun.stdin.text()) as RawStopPayload;
|
|
488
|
-
const norm = normalizeStopInput(raw, homedir());
|
|
489
|
-
client = norm.client;
|
|
490
|
-
// Cursor aborted/errored turns pass: the user said stop, or the loop
|
|
491
|
-
// died — commits from those turns are still caught at the next completed
|
|
492
|
-
// stop (the window is the conversation transcript's birthtime).
|
|
493
|
-
if (client === "cursor" && raw.status !== "completed") return;
|
|
494
|
-
// Codex: no status guard needed — Stop fires at turn end unconditionally.
|
|
495
|
-
|
|
496
|
-
const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
|
|
497
|
-
|
|
498
|
-
// Session start = when the transcript file was created. No transcript,
|
|
499
|
-
// no window to measure — allow.
|
|
500
|
-
let since: string | null = null;
|
|
501
|
-
if (norm.transcriptPath) {
|
|
502
|
-
try {
|
|
503
|
-
since = utcStamp(statSync(norm.transcriptPath).birthtime);
|
|
504
|
-
} catch {
|
|
505
|
-
since = null;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
let commits = 0;
|
|
510
|
-
let commitList: string[] = [];
|
|
511
|
-
let memLastTs: string | null = null;
|
|
512
|
-
let memCandidates: string[] = [];
|
|
513
|
-
if (worktree && since) {
|
|
514
|
-
const log = git(
|
|
515
|
-
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
516
|
-
norm.cwd,
|
|
517
|
-
);
|
|
518
|
-
commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
519
|
-
commits = commitList.length;
|
|
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;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
reason = decideStop({
|
|
532
|
-
stopHookActive: norm.stopHookActive,
|
|
533
|
-
worktree: since ? worktree : null,
|
|
534
|
-
commits,
|
|
535
|
-
since,
|
|
536
|
-
commitList,
|
|
537
|
-
memLastTs,
|
|
538
|
-
memCandidates,
|
|
539
|
-
});
|
|
540
|
-
// Already blocked for this worktree in this session — say it once.
|
|
541
|
-
if (
|
|
542
|
-
reason &&
|
|
543
|
-
worktree &&
|
|
544
|
-
stopBlockedBefore(norm.transcriptPath, worktree)
|
|
545
|
-
) {
|
|
546
|
-
reason = null;
|
|
547
|
-
}
|
|
548
|
-
} catch {
|
|
549
|
-
reason = null; // any failure = allow the turn to end
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
if (reason) console.log(stopOutput(client, reason));
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// --- Session start (kickoff as context, not as a thing to remember) ---
|
|
556
|
-
//
|
|
557
|
-
// `fapony mem kickoff` is the one command that pays for itself at session
|
|
558
|
-
// open — what is open, what is stale, what the last rows touched. Asking the
|
|
559
|
-
// agent to run it measured as not enough (rule 9), and an MCP tool would pay
|
|
560
|
-
// schema rent in every session of every client to save one bash round
|
|
561
|
-
// (rule 13). A SessionStart hook is neither: zero rent, and it fires whether
|
|
562
|
-
// or not anyone remembers.
|
|
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
|
-
//
|
|
570
|
-
// Runs the CLI in a subprocess rather than calling cmdKickoff: kickoff prints
|
|
571
|
-
// to stdout and exits on bad input, both of which would be this hook's stdout.
|
|
572
|
-
|
|
573
|
-
/** Cap on injected context — kickoff is short, a broken repo's output is not. */
|
|
574
|
-
export const SESSION_START_MAX_CHARS = 4_000;
|
|
575
|
-
|
|
576
|
-
const TRUNCATED = "… truncated — run `fapony mem find <word>` for the rest";
|
|
577
|
-
|
|
578
|
-
/** Trim to whole lines, keeping the marker's line boundary intact. */
|
|
579
|
-
function headLines(text: string, max: number): string {
|
|
580
|
-
const cut = text.slice(0, max);
|
|
581
|
-
const lastNl = cut.lastIndexOf("\n");
|
|
582
|
-
return (lastNl > 0 ? cut.slice(0, lastNl) : cut).trimEnd();
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* Trim to whole lines within the cap, with an honest truncation marker.
|
|
587
|
-
*
|
|
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.
|
|
593
|
-
*/
|
|
594
|
-
export function capContext(
|
|
595
|
-
text: string,
|
|
596
|
-
max = SESSION_START_MAX_CHARS,
|
|
597
|
-
): string {
|
|
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)
|
|
617
|
-
const at = text.lastIndexOf("\n## next up");
|
|
618
|
-
const tail = at > 0 ? text.slice(at).trimEnd() : "";
|
|
619
|
-
if (tail && tail.length < max / 2) {
|
|
620
|
-
return `${headLines(text, max - tail.length)}\n${TRUNCATED}\n${tail}`;
|
|
621
|
-
}
|
|
622
|
-
return `${headLines(text, max)}\n${TRUNCATED}`;
|
|
623
|
-
}
|
|
624
|
-
|
|
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. */
|
|
653
|
-
export async function cmdHookSessionStart(): Promise<void> {
|
|
654
|
-
try {
|
|
655
|
-
const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
|
|
656
|
-
const cwd = raw.cwd ?? process.cwd();
|
|
657
|
-
const ctx = sessionStartContext(cwd);
|
|
658
|
-
if (!ctx) return;
|
|
659
|
-
console.log(
|
|
660
|
-
JSON.stringify({
|
|
661
|
-
hookSpecificOutput: {
|
|
662
|
-
hookEventName: "SessionStart",
|
|
663
|
-
additionalContext: ctx,
|
|
664
|
-
},
|
|
665
|
-
}),
|
|
666
|
-
);
|
|
667
|
-
} catch {
|
|
668
|
-
// any failure = no context, never a broken session start
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// --- Read hint (PreToolUse annotate — never block, never dedupe) ---
|
|
673
|
-
//
|
|
674
|
-
// Reading a large file in full is where an agent spends tokens without
|
|
675
|
-
// noticing — warnings in a skill were never enough (same principle as the
|
|
676
|
-
// Stop hook: speak while it is spending). But this hook **annotates only**:
|
|
677
|
-
// no permissionDecision, no "already read" dedupe — context compaction makes
|
|
678
|
-
// "already read" false, and a hook that guesses wrong and traps the agent is
|
|
679
|
-
// worse than no hook (the rule from the original hook.ts) — annotate cannot
|
|
680
|
-
// trap by construction, the worst cost of a miss is one unnecessary line
|
|
681
|
-
//
|
|
682
|
-
// The text is facts only (line count + command + a one-time measurement), not
|
|
683
|
-
// a per-file estimate — guessing tokens is dressing up as data, against
|
|
684
|
-
// "facts only"
|
|
685
|
-
|
|
686
|
-
/** Below this size a full read is already cheap — stay silent. */
|
|
687
|
-
export const READ_HINT_MIN_BYTES = 24_000;
|
|
688
|
-
/** A caller-chosen limit below this is a bounded read — already cheap. */
|
|
689
|
-
export const READ_HINT_MIN_LIMIT = 300;
|
|
690
|
-
/** One-time measurement (2026-09-17, this repo): 5 files / 2,146 lines ≈ 3.7KB out. */
|
|
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;
|
|
694
|
-
|
|
695
|
-
export interface ReadHintInput {
|
|
696
|
-
filePath: unknown;
|
|
697
|
-
offset?: unknown;
|
|
698
|
-
limit?: unknown;
|
|
699
|
-
cwd: string;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Factual one-liner for a full-file read of a large source file, or null.
|
|
704
|
-
* Every unknown (no path, non-source ext, small file, bounded read, no git
|
|
705
|
-
* repo, stat/read failure) resolves to null — a hint must never fire on a
|
|
706
|
-
* guess. Fast path is statSync only; the file is read just to count lines,
|
|
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).
|
|
712
|
-
*/
|
|
713
|
-
export function readHintFor(opts: ReadHintInput): string | null {
|
|
714
|
-
try {
|
|
715
|
-
if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
|
|
716
|
-
const dot = opts.filePath.lastIndexOf(".");
|
|
717
|
-
// SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
|
|
718
|
-
if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
|
|
719
|
-
const limit = typeof opts.limit === "number" ? opts.limit : null;
|
|
720
|
-
if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
|
|
721
|
-
const st = statSync(opts.filePath);
|
|
722
|
-
if (!st.isFile() || st.size < READ_HINT_MIN_BYTES) return null;
|
|
723
|
-
// review-seed is a git command — outside a repo the hint would lie.
|
|
724
|
-
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
725
|
-
cwd: opts.cwd,
|
|
726
|
-
stdout: "pipe",
|
|
727
|
-
stderr: "pipe",
|
|
728
|
-
});
|
|
729
|
-
if (git.exitCode !== 0) return null;
|
|
730
|
-
const lines = readFileSync(opts.filePath, "utf-8").split("\n").length;
|
|
731
|
-
// The hint feeds a command line — inside the worktree show the clean
|
|
732
|
-
// relative path, outside it relative() climbs dots, show absolute.
|
|
733
|
-
const rel = relative(opts.cwd, opts.filePath);
|
|
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
|
-
}
|
|
770
|
-
return (
|
|
771
|
-
`fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
|
|
772
|
-
`returns exports with line numbers, importers, and signatures first ` +
|
|
773
|
-
`(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
|
|
774
|
-
);
|
|
775
|
-
} catch {
|
|
776
|
-
return null;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// --- Re-read tracking (mtime heuristic — annotate only) ---
|
|
781
|
-
//
|
|
782
|
-
// The size hint above fires on almost nothing real: measured 2026-09-20 over
|
|
783
|
-
// 28,151 read parts across every project, only 2.3% had fileSize>=24KB with a
|
|
784
|
-
// full bound, while 55.5% of read context came from files under 24KB and 19.5%
|
|
785
|
-
// from re-reading a path already read this session (39.2% of context sits in
|
|
786
|
-
// (session,path) pairs read >=2x). The cost is repetition, not one big file —
|
|
787
|
-
// so the gate here is not size, it is "same file, unchanged, again".
|
|
788
|
-
//
|
|
789
|
-
// mtime is the whole mechanism: unchanged mtime since the previous read in
|
|
790
|
-
// this session = the same bytes = annotate; mtime moved = someone edited it =
|
|
791
|
-
// new content = silent. No Edit/Grep tracking — mtime already answers "did it
|
|
792
|
-
// change", so no tool-sequence tagging is needed.
|
|
793
|
-
//
|
|
794
|
-
// Log lives beside hint-log but is separate — hint-log counts fires, this
|
|
795
|
-
// records reads keyed by session (one file per session, so a hint never leaks
|
|
796
|
-
// into the next session / rule 11). Same contract as the size hint: annotate
|
|
797
|
-
// only, never block, every unknown → silent, best-effort. Called at the caller
|
|
798
|
-
// only, never inside a pure function (test pollution — same reason
|
|
799
|
-
// recordHintFire is caller-side).
|
|
800
|
-
|
|
801
|
-
const READ_TRACK_DIR = "read-track";
|
|
802
|
-
|
|
803
|
-
export interface ReadTrackRow {
|
|
804
|
-
ts: string;
|
|
805
|
-
path: string;
|
|
806
|
-
mtime: number;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/** Filename key for a session — basename of a transcript path or a raw id. */
|
|
810
|
-
export function sessionKey(session: string): string {
|
|
811
|
-
const base = basename(session).replace(/\.[^.]+$/, "");
|
|
812
|
-
return base.replace(/[^A-Za-z0-9_-]/g, "-") || "unknown";
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
/** Directory holding one read log per session. */
|
|
816
|
-
function readTrackDir(): string {
|
|
817
|
-
const base =
|
|
818
|
-
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
819
|
-
return join(base, READ_TRACK_DIR);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/** Absolute path of a session's read log — may not exist. */
|
|
823
|
-
export function readTrackPath(session: string): string {
|
|
824
|
-
return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
function readTrackRows(session: string): ReadTrackRow[] {
|
|
828
|
-
const p = readTrackPath(session);
|
|
829
|
-
if (!existsSync(p)) return [];
|
|
830
|
-
const rows: ReadTrackRow[] = [];
|
|
831
|
-
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
832
|
-
if (!line) continue;
|
|
833
|
-
try {
|
|
834
|
-
const r = JSON.parse(line) as ReadTrackRow;
|
|
835
|
-
if (typeof r.path === "string" && typeof r.mtime === "number") {
|
|
836
|
-
rows.push(r);
|
|
837
|
-
}
|
|
838
|
-
} catch {
|
|
839
|
-
// a torn line must not lose the rest of the log
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return rows;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function appendReadTrackRow(session: string, row: ReadTrackRow): void {
|
|
846
|
-
const dir = readTrackDir();
|
|
847
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
848
|
-
appendFileSync(readTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
export interface RereadHintInput {
|
|
852
|
-
filePath: unknown;
|
|
853
|
-
offset?: unknown;
|
|
854
|
-
limit?: unknown;
|
|
855
|
-
cwd: string;
|
|
856
|
-
/** Session identity — transcript path (Claude) or session id (OpenCode). */
|
|
857
|
-
session?: unknown;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* Annotate a full-file read of a path already read this session whose mtime
|
|
862
|
-
* has not moved, or null. Records every full read, so the returned count is
|
|
863
|
-
* the number of prior reads. Every unknown (kill switch on, no session, no
|
|
864
|
-
* path, bounded read, partial offset, missing file, read/write failure)
|
|
865
|
-
* resolves to null — a hint must never fire on a guess.
|
|
866
|
-
*/
|
|
867
|
-
export function rereadHintFor(opts: RereadHintInput): string | null {
|
|
868
|
-
try {
|
|
869
|
-
if (process.env.FAPONY_NO_REREAD_HINT === "1") return null;
|
|
870
|
-
if (typeof opts.session !== "string" || opts.session === "") return null;
|
|
871
|
-
if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
|
|
872
|
-
// A bounded/partial read is already cheap — same line the size hint draws.
|
|
873
|
-
const offset = typeof opts.offset === "number" ? opts.offset : null;
|
|
874
|
-
if (offset !== null && offset !== 0) return null;
|
|
875
|
-
const limit = typeof opts.limit === "number" ? opts.limit : null;
|
|
876
|
-
if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
|
|
877
|
-
|
|
878
|
-
const abs = (() => {
|
|
879
|
-
const p = opts.filePath.startsWith("/")
|
|
880
|
-
? opts.filePath
|
|
881
|
-
: join(opts.cwd, opts.filePath);
|
|
882
|
-
try {
|
|
883
|
-
return realpathSync(p);
|
|
884
|
-
} catch {
|
|
885
|
-
return resolve(p);
|
|
886
|
-
}
|
|
887
|
-
})();
|
|
888
|
-
const st = statSync(abs);
|
|
889
|
-
if (!st.isFile()) return null;
|
|
890
|
-
const mtime = Math.round(st.mtimeMs);
|
|
891
|
-
|
|
892
|
-
const prior = readTrackRows(opts.session).filter((r) => r.path === abs);
|
|
893
|
-
const last = prior.at(-1);
|
|
894
|
-
appendReadTrackRow(opts.session, {
|
|
895
|
-
ts: new Date().toISOString(),
|
|
896
|
-
path: abs,
|
|
897
|
-
mtime,
|
|
898
|
-
});
|
|
899
|
-
if (!last || last.mtime !== mtime) return null;
|
|
900
|
-
|
|
901
|
-
// The hint feeds the agent's context — show the path *it* passed, relative
|
|
902
|
-
// to its own cwd, so a symlinked cwd (macOS /var → /private/var) does not
|
|
903
|
-
// turn a clean relative path into a resolved absolute one.
|
|
904
|
-
const rel = relative(opts.cwd, opts.filePath);
|
|
905
|
-
const shown = rel.startsWith("..") ? opts.filePath : rel;
|
|
906
|
-
return (
|
|
907
|
-
`fapony: already read ${shown} ${prior.length}\u00d7 this session — ` +
|
|
908
|
-
`content unchanged since the last read (mtime), grep the line range ` +
|
|
909
|
-
`you need instead of re-reading it`
|
|
910
|
-
);
|
|
911
|
-
} catch {
|
|
912
|
-
return null;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
// --- Edit hint (PreToolUse annotate — importer count + once-per-session dedupe) ---
|
|
917
|
-
//
|
|
918
|
-
// Editing a file that has importers can silently break its consumers (measured:
|
|
919
|
-
// 17.9% of changed nodes over 30 commits had a 1-hop blast radius; ~12-16% of
|
|
920
|
-
// all-time fail rows were producer/consumer mismatches). The hint is a fact —
|
|
921
|
-
// the importer count plus the review-seed command that lists them — never a
|
|
922
|
-
// judgment about whether the edit is safe, and never a block.
|
|
923
|
-
//
|
|
924
|
-
// Dedupe is per (session, file): the first edit to a file fires, repeats stay
|
|
925
|
-
// silent. The track log reuses the read-track session mechanism (sessionKey,
|
|
926
|
-
// one jsonl per session) but lives in its own dir — sharing read-track's file
|
|
927
|
-
// would make an Edit look like a Read and falsely trip the re-read hint. Like
|
|
928
|
-
// rereadHintFor the track write happens inside this function (the caller-side
|
|
929
|
-
// rule covers recordHintFire, not dedupe state); unlike it there is no mtime
|
|
930
|
-
// comparison — an edit that moves mtime is still the same file in the same
|
|
931
|
-
// session, and repeating the count buys nothing.
|
|
932
|
-
|
|
933
|
-
const EDIT_TRACK_DIR = "edit-track";
|
|
934
|
-
|
|
935
|
-
export interface EditTrackRow {
|
|
936
|
-
ts: string;
|
|
937
|
-
path: string;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
/** Directory holding one edit log per session. */
|
|
941
|
-
function editTrackDir(): string {
|
|
942
|
-
const base =
|
|
943
|
-
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
944
|
-
return join(base, EDIT_TRACK_DIR);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/** Absolute path of a session's edit log — may not exist. */
|
|
948
|
-
export function editTrackPath(session: string): string {
|
|
949
|
-
return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function editTrackPaths(session: string): Set<string> {
|
|
953
|
-
const p = editTrackPath(session);
|
|
954
|
-
if (!existsSync(p)) return new Set();
|
|
955
|
-
const out = new Set<string>();
|
|
956
|
-
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
957
|
-
if (!line) continue;
|
|
958
|
-
try {
|
|
959
|
-
const r = JSON.parse(line) as EditTrackRow;
|
|
960
|
-
if (typeof r.path === "string") out.add(r.path);
|
|
961
|
-
} catch {
|
|
962
|
-
// a torn line must not lose the rest of the log
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
return out;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
function appendEditTrackRow(session: string, row: EditTrackRow): void {
|
|
969
|
-
const dir = editTrackDir();
|
|
970
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
971
|
-
appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
export interface EditHintInput {
|
|
975
|
-
filePath: unknown;
|
|
976
|
-
cwd: string;
|
|
977
|
-
/**
|
|
978
|
-
* Session identity — transcript path (Claude) or session id. Without it the
|
|
979
|
-
* hint still fires (the importer fact holds) but cannot dedupe.
|
|
980
|
-
*/
|
|
981
|
-
session?: unknown;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
/**
|
|
985
|
-
* Factual one-liner for editing a source file that has importers, or null.
|
|
986
|
-
* Every unknown (no path, non-source ext, new/unsaved file, outside the
|
|
987
|
-
* worktree, no git repo, graph failure) resolves to null — a hint must never
|
|
988
|
-
* fire on a guess. Files with importers are checked before the dedupe log is
|
|
989
|
-
* touched, so a file nobody imports never writes a track row.
|
|
990
|
-
*/
|
|
991
|
-
export function editHintFor(opts: EditHintInput): string | null {
|
|
992
|
-
try {
|
|
993
|
-
if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
|
|
994
|
-
const dot = opts.filePath.lastIndexOf(".");
|
|
995
|
-
// SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
|
|
996
|
-
if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
|
|
997
|
-
// review-seed is a git command — outside a repo the hint would lie.
|
|
998
|
-
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
999
|
-
cwd: opts.cwd,
|
|
1000
|
-
stdout: "pipe",
|
|
1001
|
-
stderr: "pipe",
|
|
1002
|
-
});
|
|
1003
|
-
if (git.exitCode !== 0) return null;
|
|
1004
|
-
// macOS /var → /private/var: normalize both sides before comparing.
|
|
1005
|
-
const worktree = realpathSync(git.stdout.toString().trim());
|
|
1006
|
-
const abs = (() => {
|
|
1007
|
-
const p = opts.filePath.startsWith("/")
|
|
1008
|
-
? opts.filePath
|
|
1009
|
-
: join(opts.cwd, opts.filePath);
|
|
1010
|
-
try {
|
|
1011
|
-
return realpathSync(p);
|
|
1012
|
-
} catch {
|
|
1013
|
-
return null; // new file — nothing imports it yet
|
|
1014
|
-
}
|
|
1015
|
-
})();
|
|
1016
|
-
if (!abs) return null;
|
|
1017
|
-
const rel = relative(worktree, abs).split(sep).join("/");
|
|
1018
|
-
if (rel.startsWith("..") || rel === "") return null;
|
|
1019
|
-
|
|
1020
|
-
const importers = buildGraphCached(worktree).dependents.get(rel);
|
|
1021
|
-
if (!importers || importers.size === 0) return null;
|
|
1022
|
-
|
|
1023
|
-
if (typeof opts.session === "string" && opts.session !== "") {
|
|
1024
|
-
if (editTrackPaths(opts.session).has(abs)) return null;
|
|
1025
|
-
appendEditTrackRow(opts.session, {
|
|
1026
|
-
ts: new Date().toISOString(),
|
|
1027
|
-
path: abs,
|
|
1028
|
-
});
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
const n = importers.size;
|
|
1032
|
-
return (
|
|
1033
|
-
`fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
|
|
1034
|
-
`review-seed --files ${rel} lists them (add --callers <export> for one ` +
|
|
1035
|
-
`export's callers); check before changing its shape (skill /lookup-before-edit)`
|
|
1036
|
-
);
|
|
1037
|
-
} catch {
|
|
1038
|
-
return null;
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// --- Commit hint (tool.execute.after — annotate only, never block) ---
|
|
1043
|
-
//
|
|
1044
|
-
// OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
|
|
1045
|
-
// so it cannot block a turn; instead it appends an annotate to the bash tool
|
|
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 →
|
|
1048
|
-
// silent · called from the opencode plugin by direct import (like
|
|
1049
|
-
// readHintFor), no CLI subcommand because no client needs it as a subprocess
|
|
1050
|
-
// (Cursor uses its own hook-stop instead)
|
|
1051
|
-
//
|
|
1052
|
-
// The text is facts only (commit list + mem status), not an estimate
|
|
1053
|
-
|
|
1054
|
-
/** Below this number of commits, the hint is unnecessary noise. */
|
|
1055
|
-
export const COMMIT_HINT_MIN_COMMITS = 1;
|
|
1056
|
-
|
|
1057
|
-
export interface CommitHintInput {
|
|
1058
|
-
command: unknown;
|
|
1059
|
-
cwd: string;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
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).
|
|
1067
|
-
*
|
|
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.
|
|
1073
|
-
*/
|
|
1074
|
-
export function commitHintFor(opts: CommitHintInput): string | null {
|
|
1075
|
-
try {
|
|
1076
|
-
if (typeof opts.command !== "string" || opts.command === "") return null;
|
|
1077
|
-
// Only fire on git commit commands — not `git push`, `git pull`, etc.
|
|
1078
|
-
if (!/\bgit\s+commit\b/.test(opts.command)) return null;
|
|
1079
|
-
|
|
1080
|
-
const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
|
|
1081
|
-
if (!worktree) return null;
|
|
1082
|
-
|
|
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
|
-
);
|
|
1103
|
-
const commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
1104
|
-
if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
|
|
1105
|
-
|
|
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");
|
|
1118
|
-
return prefixed;
|
|
1119
|
-
} catch {
|
|
1120
|
-
return null; // any failure = no hint
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
/** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out.
|
|
1125
|
-
* No permissionDecision ever — the tool call always proceeds. */
|
|
1126
|
-
export async function cmdHookReadHint(): Promise<void> {
|
|
1127
|
-
try {
|
|
1128
|
-
const raw = JSON.parse(await Bun.stdin.text()) as {
|
|
1129
|
-
cwd?: string;
|
|
1130
|
-
transcript_path?: string;
|
|
1131
|
-
session_id?: string;
|
|
1132
|
-
tool_input?: {
|
|
1133
|
-
file_path?: unknown;
|
|
1134
|
-
offset?: unknown;
|
|
1135
|
-
limit?: unknown;
|
|
1136
|
-
};
|
|
1137
|
-
};
|
|
1138
|
-
const cwd = raw.cwd ?? process.cwd();
|
|
1139
|
-
const filePath = raw.tool_input?.file_path;
|
|
1140
|
-
// One read log per session — transcript_path is the Claude session file,
|
|
1141
|
-
// session_id the fallback when a client omits it.
|
|
1142
|
-
const session = raw.transcript_path ?? raw.session_id;
|
|
1143
|
-
const parts: string[] = [];
|
|
1144
|
-
const hint = readHintFor({
|
|
1145
|
-
filePath,
|
|
1146
|
-
offset: raw.tool_input?.offset,
|
|
1147
|
-
limit: raw.tool_input?.limit,
|
|
1148
|
-
cwd,
|
|
1149
|
-
});
|
|
1150
|
-
if (hint) parts.push(hint);
|
|
1151
|
-
const reread = rereadHintFor({
|
|
1152
|
-
filePath,
|
|
1153
|
-
offset: raw.tool_input?.offset,
|
|
1154
|
-
limit: raw.tool_input?.limit,
|
|
1155
|
-
cwd,
|
|
1156
|
-
session,
|
|
1157
|
-
});
|
|
1158
|
-
if (reread) parts.push(reread);
|
|
1159
|
-
const ctx = readContextData(filePath, cwd);
|
|
1160
|
-
if (ctx) {
|
|
1161
|
-
for (const line of [...ctx.debtLines, ...ctx.memLines]) {
|
|
1162
|
-
parts.push(line);
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
if (parts.length > 0) {
|
|
1166
|
-
console.log(
|
|
1167
|
-
JSON.stringify({
|
|
1168
|
-
hookSpecificOutput: {
|
|
1169
|
-
hookEventName: "PreToolUse",
|
|
1170
|
-
additionalContext: parts.join("\n"),
|
|
1171
|
-
},
|
|
1172
|
-
}),
|
|
1173
|
-
);
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// --- hint-fire log (PLAN-feedback-surface chunk 1) ---
|
|
1177
|
-
// After output — best-effort, never block the hint.
|
|
1178
|
-
const rel =
|
|
1179
|
-
typeof filePath === "string"
|
|
1180
|
-
? (() => {
|
|
1181
|
-
try {
|
|
1182
|
-
const git = Bun.spawnSync(
|
|
1183
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
1184
|
-
{ cwd, stdout: "pipe", stderr: "pipe" },
|
|
1185
|
-
);
|
|
1186
|
-
if (git.exitCode !== 0) return null;
|
|
1187
|
-
const wt = realpathSync(git.stdout.toString().trim());
|
|
1188
|
-
const abs = realpathSync(
|
|
1189
|
-
filePath.startsWith("/") ? filePath : join(wt, filePath),
|
|
1190
|
-
);
|
|
1191
|
-
const r = relative(wt, abs).split("\\").join("/");
|
|
1192
|
-
return r.startsWith("..") ? null : r;
|
|
1193
|
-
} catch {
|
|
1194
|
-
return null;
|
|
1195
|
-
}
|
|
1196
|
-
})()
|
|
1197
|
-
: null;
|
|
1198
|
-
const worktree = ctx?.worktree ?? null;
|
|
1199
|
-
if (worktree) {
|
|
1200
|
-
if (hint) {
|
|
1201
|
-
recordHintFire({
|
|
1202
|
-
ts: new Date().toISOString(),
|
|
1203
|
-
worktree,
|
|
1204
|
-
surface: "read",
|
|
1205
|
-
file: rel,
|
|
1206
|
-
count: 1,
|
|
1207
|
-
});
|
|
1208
|
-
}
|
|
1209
|
-
if (ctx && ctx.debtIds.length > 0) {
|
|
1210
|
-
recordHintFire({
|
|
1211
|
-
ts: new Date().toISOString(),
|
|
1212
|
-
worktree,
|
|
1213
|
-
surface: "debt",
|
|
1214
|
-
file: rel,
|
|
1215
|
-
count: ctx.debtIds.length,
|
|
1216
|
-
ids: ctx.debtIds,
|
|
1217
|
-
});
|
|
1218
|
-
}
|
|
1219
|
-
if (ctx && ctx.memLines.length > 0) {
|
|
1220
|
-
recordHintFire({
|
|
1221
|
-
ts: new Date().toISOString(),
|
|
1222
|
-
worktree,
|
|
1223
|
-
surface: "mem",
|
|
1224
|
-
file: rel,
|
|
1225
|
-
count: ctx.memLines.length,
|
|
1226
|
-
});
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
} catch {
|
|
1230
|
-
// any failure = no hint; a hook must never block a read over a hint
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
/** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
|
|
1235
|
-
* No permissionDecision ever — the edit always proceeds. Fires once per
|
|
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. */
|
|
1240
|
-
export async function cmdHookEditHint(): Promise<void> {
|
|
1241
|
-
try {
|
|
1242
|
-
const raw = JSON.parse(await Bun.stdin.text()) as {
|
|
1243
|
-
cwd?: string;
|
|
1244
|
-
transcript_path?: string;
|
|
1245
|
-
session_id?: string;
|
|
1246
|
-
tool_input?: {
|
|
1247
|
-
file_path?: unknown;
|
|
1248
|
-
};
|
|
1249
|
-
};
|
|
1250
|
-
const cwd = raw.cwd ?? process.cwd();
|
|
1251
|
-
const filePath = raw.tool_input?.file_path;
|
|
1252
|
-
// One edit log per session — same identity as the read hint.
|
|
1253
|
-
const session = raw.transcript_path ?? raw.session_id;
|
|
1254
|
-
const parts: string[] = [];
|
|
1255
|
-
const hint = editHintFor({ filePath, cwd, session });
|
|
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) {
|
|
1265
|
-
console.log(
|
|
1266
|
-
JSON.stringify({
|
|
1267
|
-
hookSpecificOutput: {
|
|
1268
|
-
hookEventName: "PreToolUse",
|
|
1269
|
-
additionalContext: parts.join("\n"),
|
|
1270
|
-
},
|
|
1271
|
-
}),
|
|
1272
|
-
);
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// --- hint-fire log (PLAN-edit-importer-hint chunk 3) ---
|
|
1276
|
-
// After output — best-effort, never block the hint.
|
|
1277
|
-
if (hint) {
|
|
1278
|
-
try {
|
|
1279
|
-
const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
1280
|
-
cwd,
|
|
1281
|
-
stdout: "pipe",
|
|
1282
|
-
stderr: "pipe",
|
|
1283
|
-
});
|
|
1284
|
-
if (g.exitCode === 0) {
|
|
1285
|
-
const worktree = realpathSync(g.stdout.toString().trim());
|
|
1286
|
-
const abs =
|
|
1287
|
-
typeof filePath === "string"
|
|
1288
|
-
? (() => {
|
|
1289
|
-
try {
|
|
1290
|
-
return realpathSync(
|
|
1291
|
-
filePath.startsWith("/")
|
|
1292
|
-
? filePath
|
|
1293
|
-
: join(worktree, filePath),
|
|
1294
|
-
);
|
|
1295
|
-
} catch {
|
|
1296
|
-
return null;
|
|
1297
|
-
}
|
|
1298
|
-
})()
|
|
1299
|
-
: null;
|
|
1300
|
-
const rel = abs
|
|
1301
|
-
? relative(worktree, abs).split("\\").join("/")
|
|
1302
|
-
: null;
|
|
1303
|
-
recordHintFire({
|
|
1304
|
-
ts: new Date().toISOString(),
|
|
1305
|
-
worktree,
|
|
1306
|
-
surface: "edit",
|
|
1307
|
-
file: rel && !rel.startsWith("..") ? rel : null,
|
|
1308
|
-
count: 1,
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
} catch {
|
|
1312
|
-
// best-effort — swallow
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
} catch {
|
|
1316
|
-
// any failure = no hint; a hook must never block an edit over a hint
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
// --- Debt + mem context (PLAN-convention-debt chunk 4) ---
|
|
1321
|
-
//
|
|
1322
|
-
// The one moment paying down debt is worth tokens is when the file is already
|
|
1323
|
-
// open — so hook-read-hint appends two things after the size hint: conventions
|
|
1324
|
-
// the file still violates (debt detector, computed live) and mem rows that
|
|
1325
|
-
// mention the file (across sessions) · **annotate only**, as before — no
|
|
1326
|
-
// block, no dedupe, every unknown → silent · combined cap 5 lines (debt 3 · mem 2)
|
|
1327
|
-
|
|
1328
|
-
const DEBT_HINT_MAX = 3;
|
|
1329
|
-
const MEM_HINT_MAX = 2;
|
|
1330
|
-
const MEM_TEXT_MAX = 120;
|
|
1331
|
-
|
|
1332
|
-
export interface ContextLineData {
|
|
1333
|
-
worktree: string;
|
|
1334
|
-
debtIds: string[];
|
|
1335
|
-
debtLines: string[];
|
|
1336
|
-
memLines: string[];
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
/** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
|
|
1340
|
-
export function readContextData(
|
|
1341
|
-
filePath: unknown,
|
|
1342
|
-
cwd: string,
|
|
1343
|
-
): ContextLineData | null {
|
|
1344
|
-
try {
|
|
1345
|
-
if (typeof filePath !== "string" || filePath === "") return null;
|
|
1346
|
-
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
1347
|
-
cwd,
|
|
1348
|
-
stdout: "pipe",
|
|
1349
|
-
stderr: "pipe",
|
|
1350
|
-
});
|
|
1351
|
-
if (git.exitCode !== 0) return null;
|
|
1352
|
-
// macOS /var → /private/var: git reports the resolved root while callers
|
|
1353
|
-
// pass unresolved tmp paths — normalize both sides before comparing.
|
|
1354
|
-
const worktree = realpathSync(git.stdout.toString().trim());
|
|
1355
|
-
const abs = realpathSync(
|
|
1356
|
-
filePath.startsWith("/") ? filePath : join(worktree, filePath),
|
|
1357
|
-
);
|
|
1358
|
-
const rel = relative(worktree, abs).split("\\").join("/");
|
|
1359
|
-
if (rel.startsWith("..") || rel === "") return null;
|
|
1360
|
-
|
|
1361
|
-
const debtIds: string[] = [];
|
|
1362
|
-
const debtLines: string[] = [];
|
|
1363
|
-
const memLines: string[] = [];
|
|
1364
|
-
|
|
1365
|
-
// convention debt — source files only, fresh from the repo
|
|
1366
|
-
const dot = rel.lastIndexOf(".");
|
|
1367
|
-
if (dot >= 0 && SCAN_EXTS.has(rel.slice(dot))) {
|
|
1368
|
-
for (const c of debtForFile(
|
|
1369
|
-
worktree,
|
|
1370
|
-
abs,
|
|
1371
|
-
loadConventions(worktree),
|
|
1372
|
-
).slice(0, DEBT_HINT_MAX)) {
|
|
1373
|
-
debtIds.push(c.id);
|
|
1374
|
-
debtLines.push(`fapony debt: [${c.id}] ${c.rule}`);
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// mem rows that are about this file
|
|
1379
|
-
const mem = readMemLog(worktree);
|
|
1380
|
-
if (mem.rows.length > 0) {
|
|
1381
|
-
const base = basename(rel);
|
|
1382
|
-
const direct: typeof mem.rows = [];
|
|
1383
|
-
const baseOnly: typeof mem.rows = [];
|
|
1384
|
-
for (const r of mem.rows) {
|
|
1385
|
-
if (r.kind === "claim" || r.kind === "release") continue;
|
|
1386
|
-
const hay = `${r.text}\n${r.spec ?? ""}\n${(r.files ?? []).join(",")}`;
|
|
1387
|
-
if ((r.files ?? []).includes(rel) || hay.includes(rel)) {
|
|
1388
|
-
direct.push(r);
|
|
1389
|
-
continue;
|
|
1390
|
-
}
|
|
1391
|
-
if (base && hay.includes(base)) baseOnly.push(r);
|
|
1392
|
-
}
|
|
1393
|
-
// A bare-basename hit is only usable when that name is unique in the
|
|
1394
|
-
// repo (24% of files share a basename — guessing would attach a row
|
|
1395
|
-
// about a DIFFERENT index.ts). The walk is paid only when a hit exists.
|
|
1396
|
-
let usableBase = baseOnly;
|
|
1397
|
-
if (baseOnly.length > 0) {
|
|
1398
|
-
const sameName = collectSourceFiles(worktree).filter(
|
|
1399
|
-
(f) => basename(f) === base,
|
|
1400
|
-
).length;
|
|
1401
|
-
if (sameName !== 1) usableBase = [];
|
|
1402
|
-
}
|
|
1403
|
-
// direct hits (files[] / full path) outrank bare-basename hits
|
|
1404
|
-
const memHits = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
|
|
1405
|
-
for (const r of memHits) {
|
|
1406
|
-
memLines.push(
|
|
1407
|
-
`fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
|
|
1408
|
-
);
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
return { worktree, debtIds, debtLines, memLines };
|
|
1412
|
-
} catch {
|
|
1413
|
-
return null;
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
export function readContextLines(filePath: unknown, cwd: string): string[] {
|
|
1418
|
-
const data = readContextData(filePath, cwd);
|
|
1419
|
-
if (!data) return [];
|
|
1420
|
-
return [...data.debtLines, ...data.memLines].slice(
|
|
1421
|
-
0,
|
|
1422
|
-
DEBT_HINT_MAX + MEM_HINT_MAX,
|
|
1423
|
-
);
|
|
1424
|
-
}
|
|
1
|
+
// src/hook.ts — re-export shim (PLAN-lib-layer chunk 3)
|
|
2
|
+
//
|
|
3
|
+
// All hook logic now lives in src/adapters/hooks/. This file re-exports
|
|
4
|
+
// everything for backwards compatibility: test/hook.test.ts,
|
|
5
|
+
// src/digest/collect.ts, and the generated OpenCode plugin all import
|
|
6
|
+
// from this path.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
bugSignalFromTranscript,
|
|
10
|
+
COMMIT_HINT_MIN_COMMITS,
|
|
11
|
+
type CommitHintInput,
|
|
12
|
+
type ContextLineData,
|
|
13
|
+
capContext,
|
|
14
|
+
cmdHookEditHint,
|
|
15
|
+
cmdHookMvGuard,
|
|
16
|
+
cmdHookReadHint,
|
|
17
|
+
cmdHookSessionStart,
|
|
18
|
+
cmdHookStop,
|
|
19
|
+
commitHintFor,
|
|
20
|
+
computeHintImpact,
|
|
21
|
+
cursorTranscriptPath,
|
|
22
|
+
decideStop,
|
|
23
|
+
type EditHintInput,
|
|
24
|
+
type EditTrackRow,
|
|
25
|
+
editHintFor,
|
|
26
|
+
editTrackPath,
|
|
27
|
+
GIT_AUTONOMY_COMMIT_POLICY,
|
|
28
|
+
GIT_AUTONOMY_PLUGIN_FILE,
|
|
29
|
+
GIT_AUTONOMY_PLUGIN_NAME,
|
|
30
|
+
GIT_AUTONOMY_SYSTEM_REWRITES,
|
|
31
|
+
GIT_AUTONOMY_TOOL_POLICY,
|
|
32
|
+
GIT_AUTONOMY_TOOL_REWRITES,
|
|
33
|
+
type GitAutonomyRewrite,
|
|
34
|
+
type GitAutonomyStatus,
|
|
35
|
+
type GitAutonomyToolRewrite,
|
|
36
|
+
gitAutonomyStatus,
|
|
37
|
+
type HintFireRow,
|
|
38
|
+
type HintImpact,
|
|
39
|
+
hintLogDir,
|
|
40
|
+
hintLogPath,
|
|
41
|
+
hookTsMs,
|
|
42
|
+
isCodexPayload,
|
|
43
|
+
isCursorPayload,
|
|
44
|
+
mvGuardDecision,
|
|
45
|
+
type NormalizedStopInput,
|
|
46
|
+
normalizeStopInput,
|
|
47
|
+
type RawStopPayload,
|
|
48
|
+
READ_HINT_MIN_BYTES,
|
|
49
|
+
READ_HINT_MIN_LIMIT,
|
|
50
|
+
type ReadHintInput,
|
|
51
|
+
type ReadTrackRow,
|
|
52
|
+
type RereadHintInput,
|
|
53
|
+
readContextData,
|
|
54
|
+
readContextLines,
|
|
55
|
+
readHintFor,
|
|
56
|
+
readTrackPath,
|
|
57
|
+
recordHintFire,
|
|
58
|
+
rereadHintFor,
|
|
59
|
+
rewriteGitAutonomySystem,
|
|
60
|
+
rewriteGitAutonomyTool,
|
|
61
|
+
SESSION_START_MAX_CHARS,
|
|
62
|
+
type StopClient,
|
|
63
|
+
sessionKey,
|
|
64
|
+
sessionStartContext,
|
|
65
|
+
stopBlockedBefore,
|
|
66
|
+
stopBlockPath,
|
|
67
|
+
stopOutput,
|
|
68
|
+
utcStamp,
|
|
69
|
+
worktreeKey,
|
|
70
|
+
} from "./adapters/hooks/index.js";
|