fapony 0.6.2 → 0.7.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 +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -6
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +1 -2
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -139
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -410
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -252
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -375
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -738
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -273
- package/src/mem/engine.ts +0 -315
- package/src/mem/index.ts +0 -201
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -160
- package/src/mem/store.ts +0 -356
|
@@ -1,768 +0,0 @@
|
|
|
1
|
-
// src/adapters/hooks/stop.ts — Stop hook: refuse to end a turn that produced
|
|
2
|
-
// commits but no mem row.
|
|
3
|
-
//
|
|
4
|
-
// Split from src/hook.ts (PLAN-lib-layer chunk 3). Pure helpers (utcStamp,
|
|
5
|
-
// hookTsMs, sessionKey) live in core/hook-helpers.ts.
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
appendFileSync,
|
|
9
|
-
existsSync,
|
|
10
|
-
mkdirSync,
|
|
11
|
-
readFileSync,
|
|
12
|
-
statSync,
|
|
13
|
-
} from "node:fs";
|
|
14
|
-
import { homedir } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { recordHintFire } from "../../core/hint-log.js";
|
|
17
|
-
import { hookTsMs, sessionKey, utcStamp } from "../../core/hook-helpers.js";
|
|
18
|
-
import type { MemRow } from "../../core/mem-log.js";
|
|
19
|
-
import { readMemLog, whereMemDir } from "../../memory.js";
|
|
20
|
-
import { hasBugMarker } from "./bug-markers.js";
|
|
21
|
-
|
|
22
|
-
// --- Types ---
|
|
23
|
-
|
|
24
|
-
export interface RawStopPayload {
|
|
25
|
-
// Claude Code
|
|
26
|
-
cwd?: string;
|
|
27
|
-
transcript_path?: string | null;
|
|
28
|
-
stop_hook_active?: boolean;
|
|
29
|
-
// Cursor — common schema (https://cursor.com/docs/agent/hooks)
|
|
30
|
-
workspace_roots?: string[];
|
|
31
|
-
conversation_id?: string;
|
|
32
|
-
loop_count?: number;
|
|
33
|
-
status?: string;
|
|
34
|
-
// Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
|
|
35
|
-
session_id?: string;
|
|
36
|
-
model?: string;
|
|
37
|
-
permission_mode?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export type StopClient = "claude" | "cursor" | "codex";
|
|
41
|
-
|
|
42
|
-
export interface NormalizedStopInput {
|
|
43
|
-
client: StopClient;
|
|
44
|
-
cwd: string;
|
|
45
|
-
transcriptPath: string | null;
|
|
46
|
-
stopHookActive: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// --- Pure helpers ---
|
|
50
|
-
|
|
51
|
-
/** Cursor transcript location derived from the conversation id. */
|
|
52
|
-
export function cursorTranscriptPath(
|
|
53
|
-
home: string,
|
|
54
|
-
cwd: string,
|
|
55
|
-
conversationId: string,
|
|
56
|
-
): string {
|
|
57
|
-
const slug = cwd.replace(/^\//, "").replace(/\//g, "-");
|
|
58
|
-
return join(
|
|
59
|
-
home,
|
|
60
|
-
".cursor",
|
|
61
|
-
"projects",
|
|
62
|
-
slug,
|
|
63
|
-
"agent-transcripts",
|
|
64
|
-
conversationId,
|
|
65
|
-
`${conversationId}.jsonl`,
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function isCursorPayload(raw: RawStopPayload): boolean {
|
|
70
|
-
return (
|
|
71
|
-
Array.isArray(raw.workspace_roots) ||
|
|
72
|
-
typeof raw.conversation_id === "string"
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
|
|
77
|
-
export function isCodexPayload(raw: RawStopPayload): boolean {
|
|
78
|
-
return (
|
|
79
|
-
typeof raw.permission_mode === "string" ||
|
|
80
|
-
(typeof raw.model === "string" && !isCursorPayload(raw))
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Field-mapping only — all three clients feed the same decideStop below. */
|
|
85
|
-
export function normalizeStopInput(
|
|
86
|
-
raw: RawStopPayload,
|
|
87
|
-
home: string,
|
|
88
|
-
): NormalizedStopInput {
|
|
89
|
-
if (isCodexPayload(raw)) {
|
|
90
|
-
return {
|
|
91
|
-
client: "codex",
|
|
92
|
-
cwd: raw.cwd ?? process.cwd(),
|
|
93
|
-
transcriptPath:
|
|
94
|
-
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
95
|
-
? raw.transcript_path
|
|
96
|
-
: null,
|
|
97
|
-
stopHookActive: raw.stop_hook_active === true,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
if (isCursorPayload(raw)) {
|
|
101
|
-
const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
|
|
102
|
-
let transcriptPath =
|
|
103
|
-
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
104
|
-
? raw.transcript_path
|
|
105
|
-
: null;
|
|
106
|
-
if (!transcriptPath && raw.conversation_id) {
|
|
107
|
-
transcriptPath = cursorTranscriptPath(home, cwd, raw.conversation_id);
|
|
108
|
-
}
|
|
109
|
-
return {
|
|
110
|
-
client: "cursor",
|
|
111
|
-
cwd,
|
|
112
|
-
transcriptPath,
|
|
113
|
-
stopHookActive: (raw.loop_count ?? 0) > 0,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
return {
|
|
117
|
-
client: "claude",
|
|
118
|
-
cwd: raw.cwd ?? process.cwd(),
|
|
119
|
-
transcriptPath: raw.transcript_path ?? null,
|
|
120
|
-
stopHookActive: raw.stop_hook_active === true,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Pure decision: block when this session produced commits but no mem row
|
|
126
|
-
* newer than session start exists, or when the agent announced a bug but
|
|
127
|
-
* filed no kind:bug row.
|
|
128
|
-
*/
|
|
129
|
-
export function decideStop(opts: {
|
|
130
|
-
stopHookActive: boolean;
|
|
131
|
-
worktree: string | null;
|
|
132
|
-
commits: number;
|
|
133
|
-
since?: string | null;
|
|
134
|
-
commitList?: string[];
|
|
135
|
-
memLastTs?: string | null;
|
|
136
|
-
memCandidates?: string[];
|
|
137
|
-
bugSignal?: string | null;
|
|
138
|
-
bugRowSinceStart?: boolean;
|
|
139
|
-
}): string | null {
|
|
140
|
-
if (opts.stopHookActive) return null;
|
|
141
|
-
if (!opts.worktree) return null;
|
|
142
|
-
|
|
143
|
-
// --- Bug-signal block (independent of commits) ---
|
|
144
|
-
if (opts.bugSignal && !opts.bugRowSinceStart) {
|
|
145
|
-
return [
|
|
146
|
-
`This turn reported a bug ("${opts.bugSignal}") but no mem row with kind:bug exists for this session.`,
|
|
147
|
-
`A bug described in chat is lost when the room closes — a decision row does not surface it.`,
|
|
148
|
-
` fapony mem add bug "<what is broken>" --files <files>`,
|
|
149
|
-
`Already filed elsewhere, or not a bug? End the turn again — this fires once per session.`,
|
|
150
|
-
].join("\n");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// --- Commit block (original) ---
|
|
154
|
-
if (opts.commits < 1) return null;
|
|
155
|
-
if (!opts.memLastTs && !opts.memCandidates?.length) return null;
|
|
156
|
-
if (opts.since && opts.memLastTs) {
|
|
157
|
-
const sinceMs = hookTsMs(opts.since);
|
|
158
|
-
const memMs = hookTsMs(opts.memLastTs);
|
|
159
|
-
if (Number.isNaN(sinceMs) || Number.isNaN(memMs)) return null;
|
|
160
|
-
if (memMs >= sinceMs) return null;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const lines: string[] = [
|
|
164
|
-
`${opts.commits} commit(s) landed in ${opts.worktree} this session — no mem row recorded for this work.`,
|
|
165
|
-
];
|
|
166
|
-
const list = opts.commitList ?? [];
|
|
167
|
-
for (const c of list.slice(0, 5)) lines.push(` ${c}`);
|
|
168
|
-
if (list.length > 5) lines.push(` … +${list.length - 5} more`);
|
|
169
|
-
if (opts.memLastTs) {
|
|
170
|
-
lines.push(
|
|
171
|
-
`mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
|
|
172
|
-
);
|
|
173
|
-
} else if (opts.memCandidates?.length) {
|
|
174
|
-
lines.push(
|
|
175
|
-
`mem: no log in scope from ${opts.worktree} — found ` +
|
|
176
|
-
`${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
lines.push(
|
|
181
|
-
`Record a mem row before ending: fapony mem add <decision|bug|note> "what happened" --files <files> ` +
|
|
182
|
-
`${opts.worktree}/.fapony/plan/PLAN.md (or the relevant plan). ` +
|
|
183
|
-
`files[] is required — a row without it is unfindable when you touch that file next session.`,
|
|
184
|
-
);
|
|
185
|
-
if (opts.bugSignal) {
|
|
186
|
-
lines.push(
|
|
187
|
-
`This turn announced a bug ("${opts.bugSignal}") — use kind:bug, not decision.`,
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
return lines.join("\n");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Claude blocks with decision:block; Cursor auto-submits as followup_message;
|
|
194
|
-
* Codex continues with decision:block + reason. */
|
|
195
|
-
export function stopOutput(client: StopClient, reason: string): string {
|
|
196
|
-
if (client === "cursor") return JSON.stringify({ followup_message: reason });
|
|
197
|
-
if (client === "codex") return JSON.stringify({ decision: "block", reason });
|
|
198
|
-
return JSON.stringify({ decision: "block", reason });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// --- Block dedupe ---
|
|
202
|
-
|
|
203
|
-
const STOP_BLOCK_DIR = "stop-block";
|
|
204
|
-
|
|
205
|
-
interface StopBlockRow {
|
|
206
|
-
ts: string;
|
|
207
|
-
worktree: string;
|
|
208
|
-
kind?: string;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Absolute path of a session's block log — may not exist. */
|
|
212
|
-
export function stopBlockPath(session: string): string {
|
|
213
|
-
const base =
|
|
214
|
-
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
215
|
-
return join(base, STOP_BLOCK_DIR, `${sessionKey(session)}.jsonl`);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* True when this session already blocked for this worktree + kind — the caller
|
|
220
|
-
* then lets the turn end. Records the block when it has not.
|
|
221
|
-
*
|
|
222
|
-
* `kind` defaults to `"commit"` for backwards compatibility. Bug blocks use
|
|
223
|
-
* `"bug"` so they don't consume the commit block's quota.
|
|
224
|
-
*/
|
|
225
|
-
export function stopBlockedBefore(
|
|
226
|
-
session: string | null,
|
|
227
|
-
worktree: string,
|
|
228
|
-
kind: string = "commit",
|
|
229
|
-
): boolean {
|
|
230
|
-
if (!session) return false;
|
|
231
|
-
const path = stopBlockPath(session);
|
|
232
|
-
try {
|
|
233
|
-
if (existsSync(path)) {
|
|
234
|
-
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
235
|
-
if (!line) continue;
|
|
236
|
-
try {
|
|
237
|
-
const row = JSON.parse(line) as StopBlockRow;
|
|
238
|
-
if (row.worktree === worktree && (row.kind ?? "commit") === kind) {
|
|
239
|
-
return true;
|
|
240
|
-
}
|
|
241
|
-
} catch {
|
|
242
|
-
// a torn line must not lose the rest of the log
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
const dir = join(path, "..");
|
|
247
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
248
|
-
const row: StopBlockRow = { ts: new Date().toISOString(), worktree, kind };
|
|
249
|
-
appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
250
|
-
} catch {
|
|
251
|
-
return false;
|
|
252
|
-
}
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// --- Bug-signal detection ---
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Scan assistant text from a Claude transcript for bug markers. Reads only
|
|
260
|
-
* the tail of the file (last 200KB) to avoid parsing the full transcript.
|
|
261
|
-
* Returns the first matched marker word, or null.
|
|
262
|
-
*/
|
|
263
|
-
export function bugSignalFromTranscript(
|
|
264
|
-
transcriptPath: string,
|
|
265
|
-
sinceMs: number,
|
|
266
|
-
): string | null {
|
|
267
|
-
try {
|
|
268
|
-
const stat = statSync(transcriptPath);
|
|
269
|
-
// Cap at 10MB — hook must not stall turn-end
|
|
270
|
-
if (stat.size > 10 * 1024 * 1024) return null;
|
|
271
|
-
|
|
272
|
-
// Read tail to avoid parsing the full transcript
|
|
273
|
-
const tailBytes = Math.min(stat.size, 200 * 1024);
|
|
274
|
-
const fd = require("node:fs").openSync(transcriptPath, "r");
|
|
275
|
-
const buf = Buffer.alloc(tailBytes);
|
|
276
|
-
require("node:fs").readSync(
|
|
277
|
-
fd,
|
|
278
|
-
buf,
|
|
279
|
-
0,
|
|
280
|
-
tailBytes,
|
|
281
|
-
Math.max(0, stat.size - tailBytes),
|
|
282
|
-
);
|
|
283
|
-
require("node:fs").closeSync(fd);
|
|
284
|
-
|
|
285
|
-
const tail = buf.toString("utf-8");
|
|
286
|
-
// When reading from the middle of a large file, the first line is partial
|
|
287
|
-
const readingMidFile = tailBytes < stat.size;
|
|
288
|
-
const startIdx = readingMidFile ? tail.indexOf("\n") : -1;
|
|
289
|
-
const lines =
|
|
290
|
-
startIdx >= 0 ? tail.slice(startIdx + 1).split("\n") : tail.split("\n");
|
|
291
|
-
|
|
292
|
-
for (const line of lines) {
|
|
293
|
-
if (!line) continue;
|
|
294
|
-
// Transcript tail: untyped JSONL — the structural type below names only
|
|
295
|
-
// the fields this scan reads (biome noExplicitAny: no `any` annotation).
|
|
296
|
-
let o: {
|
|
297
|
-
message?: {
|
|
298
|
-
role?: string;
|
|
299
|
-
content?: Array<{ type?: string; text?: string }>;
|
|
300
|
-
created_at?: string;
|
|
301
|
-
};
|
|
302
|
-
};
|
|
303
|
-
try {
|
|
304
|
-
o = JSON.parse(line);
|
|
305
|
-
} catch {
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
const m = o.message;
|
|
309
|
-
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
310
|
-
// Skip messages older than session start
|
|
311
|
-
if (m.created_at) {
|
|
312
|
-
const msgMs = new Date(m.created_at).getTime();
|
|
313
|
-
if (!Number.isNaN(msgMs) && msgMs < sinceMs) continue;
|
|
314
|
-
}
|
|
315
|
-
for (const b of m.content) {
|
|
316
|
-
if (b.type !== "text" || typeof b.text !== "string") continue;
|
|
317
|
-
const hit = hasBugMarker(b.text);
|
|
318
|
-
if (hit) return hit;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
} catch {
|
|
322
|
-
return null;
|
|
323
|
-
}
|
|
324
|
-
return null;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* True when the mem log has at least one kind:bug row with ts >= since.
|
|
329
|
-
*/
|
|
330
|
-
function hasBugRowSince(worktree: string, since: string): boolean {
|
|
331
|
-
try {
|
|
332
|
-
const { rows } = readMemLog(worktree, since);
|
|
333
|
-
return rows.some((r) => r.kind === "bug");
|
|
334
|
-
} catch {
|
|
335
|
-
return false;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// --- Handoff enforcement (PLAN-active-pain chunk 1) ---
|
|
340
|
-
//
|
|
341
|
-
// Rule 11 closes a chunk with a tick + commit + a mem note the next session
|
|
342
|
-
// opens from — but rule 9 says "the agent will remember" never holds. This
|
|
343
|
-
// checks the handoff row exists when a plan chunk was ticked this session.
|
|
344
|
-
// Ships as a log-only trial first: default mode only writes hint-log rows
|
|
345
|
-
// (surfaces handoff-would-block / handoff-pass); FAPONY_HANDOFF_BLOCK=1
|
|
346
|
-
// enforces for real. FAPONY_NO_HANDOFF_BLOCK=1 disables both entirely.
|
|
347
|
-
|
|
348
|
-
/** Repo-relative plan dir — hardcoded like planDir(), never from config. */
|
|
349
|
-
const HANDOFF_PLAN_PREFIX = ".fapony/plan/";
|
|
350
|
-
|
|
351
|
-
/** A repo-relative path the gate cares about: a .md file under plan/. */
|
|
352
|
-
export function isPlanPath(p: string): boolean {
|
|
353
|
-
const norm = p.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
354
|
-
return (
|
|
355
|
-
norm.startsWith(HANDOFF_PLAN_PREFIX) &&
|
|
356
|
-
norm.endsWith(".md") &&
|
|
357
|
-
norm.length > HANDOFF_PLAN_PREFIX.length + ".md".length
|
|
358
|
-
);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Count ticked `- [x]` checkbox lines (indented or not — the plan template
|
|
362
|
-
* indents TL;DR progress with two spaces). */
|
|
363
|
-
export function countTicks(content: string): number {
|
|
364
|
-
let n = 0;
|
|
365
|
-
for (const line of content.split("\n")) {
|
|
366
|
-
if (/^\s*-\s\[[xX]\]/.test(line)) n++;
|
|
367
|
-
}
|
|
368
|
-
return n;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* A plan opts into the mechanism with a handoff literal — a checkbox line
|
|
373
|
-
* mentioning handoff, ticked or not. Status is ignored on purpose: a ticked
|
|
374
|
-
* box alone proves no note exists (review finding 1), so the literal only
|
|
375
|
-
* marks participation and the mem row is the only way through. Plans without
|
|
376
|
-
* one (every old plan) fail open.
|
|
377
|
-
*/
|
|
378
|
-
export function hasHandoffLiteral(content: string): boolean {
|
|
379
|
-
for (const line of content.split("\n")) {
|
|
380
|
-
if (/^\s*-\s\[[ xX]\].*handoff/i.test(line)) return true;
|
|
381
|
-
}
|
|
382
|
-
return false;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function planRefMatches(value: string | undefined, rel: string): boolean {
|
|
386
|
-
if (!value) return false;
|
|
387
|
-
const v = value.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
388
|
-
const r = rel.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
389
|
-
if (v === r) return true;
|
|
390
|
-
if (v.endsWith(`/${r}`) || r.endsWith(`/${v}`)) return true;
|
|
391
|
-
// Plan filenames are unique per repo (PLAN-<name>.md) — a bare basename
|
|
392
|
-
// still names the file when one side stored only it.
|
|
393
|
-
const vb = v.split("/").pop() ?? v;
|
|
394
|
-
const rb = r.split("/").pop() ?? r;
|
|
395
|
-
return vb.length > 0 && vb === rb;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* True when a note/next row filed at or after session start points at the
|
|
400
|
-
* plan — via spec (the positional plan path of `mem add`) or files[].
|
|
401
|
-
* Timestamps compare numerically: mem rows are ISO, session start is
|
|
402
|
-
* utcStamp, and string-compare reads every same-day row as newer ('T' > ' ').
|
|
403
|
-
*/
|
|
404
|
-
export function memHasHandoffForPlan(
|
|
405
|
-
rows: MemRow[],
|
|
406
|
-
planRel: string,
|
|
407
|
-
sinceMs: number,
|
|
408
|
-
): boolean {
|
|
409
|
-
for (const r of rows) {
|
|
410
|
-
if (r.kind !== "note" && r.kind !== "next") continue;
|
|
411
|
-
const ms = hookTsMs(r.ts);
|
|
412
|
-
if (Number.isNaN(ms) || ms < sinceMs) continue;
|
|
413
|
-
if (planRefMatches(r.spec, planRel)) return true;
|
|
414
|
-
for (const f of r.files ?? []) {
|
|
415
|
-
if (planRefMatches(f, planRel)) return true;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
return false;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
export interface HandoffPlanFile {
|
|
422
|
-
/** Repo-relative plan path, e.g. .fapony/plan/PLAN-x.md. */
|
|
423
|
-
rel: string;
|
|
424
|
-
/** Content at the pre-session base commit ("" when untracked there). */
|
|
425
|
-
before: string;
|
|
426
|
-
/** Content on disk now. */
|
|
427
|
-
after: string;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
export interface HandoffDecision {
|
|
431
|
-
/** First plan with literal + new tick + no handoff row, or null. */
|
|
432
|
-
blockedPlan: string | null;
|
|
433
|
-
/** Plans the gate actually evaluated (literal present). */
|
|
434
|
-
evaluated: string[];
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Pure decision over pre-loaded file states. Every condition must hold to
|
|
439
|
-
* block: literal present, more ticks than at base, no handoff row since
|
|
440
|
-
* session start. Anything else passes — including plans that never opted in.
|
|
441
|
-
*/
|
|
442
|
-
export function decideHandoff(opts: {
|
|
443
|
-
files: HandoffPlanFile[];
|
|
444
|
-
memRows: MemRow[];
|
|
445
|
-
sinceMs: number;
|
|
446
|
-
}): HandoffDecision {
|
|
447
|
-
const evaluated: string[] = [];
|
|
448
|
-
let blockedPlan: string | null = null;
|
|
449
|
-
for (const f of opts.files) {
|
|
450
|
-
if (!hasHandoffLiteral(f.after)) continue;
|
|
451
|
-
evaluated.push(f.rel);
|
|
452
|
-
if (blockedPlan) continue;
|
|
453
|
-
if (countTicks(f.after) <= countTicks(f.before)) continue;
|
|
454
|
-
if (memHasHandoffForPlan(opts.memRows, f.rel, opts.sinceMs)) continue;
|
|
455
|
-
blockedPlan = f.rel;
|
|
456
|
-
}
|
|
457
|
-
return { blockedPlan, evaluated };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
export function handoffBlockMessage(planRel: string): string {
|
|
461
|
-
return [
|
|
462
|
-
`Chunk ticked in ${planRel} but no handoff mem row exists for this session.`,
|
|
463
|
-
`The next session opens from that note — without it, chunk N+1 re-derives everything from zero.`,
|
|
464
|
-
` fapony mem add note "<what chunk N+1 must know>" --files <files> ${planRel}`,
|
|
465
|
-
`Then end the turn again — the row is the handoff; ticking the literal is cosmetic.`,
|
|
466
|
-
`Fires once per session per plan.`,
|
|
467
|
-
].join("\n");
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Which hint-log surface a commit/bug stop fire records. Logged on every
|
|
472
|
-
* fire — shown or dedupe-suppressed alike — so a repeated signal stays
|
|
473
|
-
* measurable: "shown" is recoverable by joining stop-block rows, but a
|
|
474
|
-
* suppressed fire with no row at all would vanish entirely.
|
|
475
|
-
*/
|
|
476
|
-
export function stopBlockSurface(
|
|
477
|
-
bugSignal: string | null,
|
|
478
|
-
): "commit-block" | "bug-block" {
|
|
479
|
-
return bugSignal ? "bug-block" : "commit-block";
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Dedupe keys are per-problem, not per-kind: the same problem nags once per
|
|
484
|
-
* session, but a different plan / a different announced bug still surfaces.
|
|
485
|
-
* A coarse kind ("handoff") lets the first problem spend the quota for all
|
|
486
|
-
* the others. Keys stay free-form strings — stopBlockedBefore compares them
|
|
487
|
-
* opaquely, and JSON escaping keeps even odd markers one row per line.
|
|
488
|
-
*/
|
|
489
|
-
export function handoffDedupeKey(planRel: string): string {
|
|
490
|
-
return `handoff:${planRel}`;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
export function bugDedupeKey(marker: string): string {
|
|
494
|
-
return `bug:${marker}`;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Merge the commit/bug reason with the handoff reason. Each blocking problem
|
|
499
|
-
* owns its own dedupe quota: the commit/bug dedupe below must only ever see
|
|
500
|
-
* a commit/bug-derived reason, and a handoff reason only fills an
|
|
501
|
-
* otherwise-allowed turn.
|
|
502
|
-
*
|
|
503
|
-
* The shape this replaces shared one `reason` variable for both, so a
|
|
504
|
-
* handoff block fell into the commit dedupe and recorded a kind:"commit"
|
|
505
|
-
* row for a handoff block — spending the commit quota without a commit
|
|
506
|
-
* block ever firing, and letting the next quota-less turn through.
|
|
507
|
-
*/
|
|
508
|
-
export function mergeStopReasons(opts: {
|
|
509
|
-
commitReason: string | null;
|
|
510
|
-
handoffReason: string | null;
|
|
511
|
-
session: string | null;
|
|
512
|
-
worktree: string | null;
|
|
513
|
-
bugSignal: string | null;
|
|
514
|
-
}): string | null {
|
|
515
|
-
let reason = opts.commitReason;
|
|
516
|
-
if (
|
|
517
|
-
reason &&
|
|
518
|
-
opts.worktree &&
|
|
519
|
-
stopBlockedBefore(
|
|
520
|
-
opts.session,
|
|
521
|
-
opts.worktree,
|
|
522
|
-
opts.bugSignal ? bugDedupeKey(opts.bugSignal) : "commit",
|
|
523
|
-
)
|
|
524
|
-
) {
|
|
525
|
-
reason = null;
|
|
526
|
-
}
|
|
527
|
-
if (!reason) reason = opts.handoffReason;
|
|
528
|
-
return reason;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** Plan files this session wrote: committed since birthtime, unstaged, or brand-new. */
|
|
532
|
-
export function sessionPlanFiles(cwd: string, since: string): string[] | null {
|
|
533
|
-
const names = (args: string[]): string[] | null => {
|
|
534
|
-
const out = git(args, cwd);
|
|
535
|
-
if (out === null) return null;
|
|
536
|
-
return out
|
|
537
|
-
.split("\n")
|
|
538
|
-
.map((s) => s.trim())
|
|
539
|
-
.filter(Boolean);
|
|
540
|
-
};
|
|
541
|
-
const a = names([
|
|
542
|
-
"log",
|
|
543
|
-
"--name-only",
|
|
544
|
-
"--since",
|
|
545
|
-
`${since} +0000`,
|
|
546
|
-
"--format=",
|
|
547
|
-
]);
|
|
548
|
-
const b = names(["diff", "--name-only", "HEAD"]);
|
|
549
|
-
const c = names(["ls-files", "--others", "--exclude-standard"]);
|
|
550
|
-
if (!a && !b && !c) return null;
|
|
551
|
-
const seen = new Set<string>();
|
|
552
|
-
for (const list of [a, b, c]) {
|
|
553
|
-
for (const p of list ?? []) {
|
|
554
|
-
const norm = p.replace(/^\.\//, "");
|
|
555
|
-
if (isPlanPath(norm)) seen.add(norm);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
return [...seen].sort();
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Last commit that existed before the session started — the baseline a
|
|
563
|
-
* "new tick" compares against. `git diff HEAD` alone misses the common case:
|
|
564
|
-
* the chunk workflow commits the tick before the hook fires.
|
|
565
|
-
*/
|
|
566
|
-
export function handoffBaseSha(cwd: string, since: string): string | null {
|
|
567
|
-
const sha = git(["rev-list", "-1", `--before=${since} +0000`, "HEAD"], cwd);
|
|
568
|
-
return sha || null;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** File content at a revision, or null when untracked there / on git error. */
|
|
572
|
-
export function fileAtRevision(
|
|
573
|
-
cwd: string,
|
|
574
|
-
base: string,
|
|
575
|
-
rel: string,
|
|
576
|
-
): string | null {
|
|
577
|
-
return git(["show", `${base}:${rel}`], cwd);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function git(args: string[], cwd: string): string | null {
|
|
581
|
-
try {
|
|
582
|
-
const p = Bun.spawnSync(["git", ...args], {
|
|
583
|
-
cwd,
|
|
584
|
-
stdout: "pipe",
|
|
585
|
-
stderr: "pipe",
|
|
586
|
-
});
|
|
587
|
-
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
|
|
588
|
-
} catch {
|
|
589
|
-
return null;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
594
|
-
export async function cmdHookStop(): Promise<void> {
|
|
595
|
-
let reason: string | null = null;
|
|
596
|
-
let client: StopClient = "claude";
|
|
597
|
-
try {
|
|
598
|
-
const raw = JSON.parse(await Bun.stdin.text()) as RawStopPayload;
|
|
599
|
-
const norm = normalizeStopInput(raw, homedir());
|
|
600
|
-
client = norm.client;
|
|
601
|
-
if (client === "cursor" && raw.status !== "completed") return;
|
|
602
|
-
|
|
603
|
-
const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
|
|
604
|
-
|
|
605
|
-
let since: string | null = null;
|
|
606
|
-
if (norm.transcriptPath) {
|
|
607
|
-
try {
|
|
608
|
-
since = utcStamp(statSync(norm.transcriptPath).birthtime);
|
|
609
|
-
} catch {
|
|
610
|
-
since = null;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
let commits = 0;
|
|
615
|
-
let commitList: string[] = [];
|
|
616
|
-
let memLastTs: string | null = null;
|
|
617
|
-
let memCandidates: string[] = [];
|
|
618
|
-
let bugSignal: string | null = null;
|
|
619
|
-
let bugRowSinceStart = false;
|
|
620
|
-
if (worktree && since) {
|
|
621
|
-
const log = git(
|
|
622
|
-
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
623
|
-
norm.cwd,
|
|
624
|
-
);
|
|
625
|
-
commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
626
|
-
commits = commitList.length;
|
|
627
|
-
try {
|
|
628
|
-
const mem = readMemLog(worktree);
|
|
629
|
-
memLastTs = mem.rows[0]?.ts ?? null;
|
|
630
|
-
if (!memLastTs) memCandidates = whereMemDir(worktree).candidates ?? [];
|
|
631
|
-
} catch {
|
|
632
|
-
memLastTs = null;
|
|
633
|
-
}
|
|
634
|
-
// Bug-signal detection (independent of commits)
|
|
635
|
-
if (norm.transcriptPath && process.env.FAPONY_NO_BUG_BLOCK !== "1") {
|
|
636
|
-
const sinceMs = hookTsMs(since);
|
|
637
|
-
if (!Number.isNaN(sinceMs)) {
|
|
638
|
-
bugSignal = bugSignalFromTranscript(norm.transcriptPath, sinceMs);
|
|
639
|
-
if (bugSignal) bugRowSinceStart = hasBugRowSince(worktree, since);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
reason = decideStop({
|
|
645
|
-
stopHookActive: norm.stopHookActive,
|
|
646
|
-
worktree: since ? worktree : null,
|
|
647
|
-
commits,
|
|
648
|
-
since,
|
|
649
|
-
commitList,
|
|
650
|
-
memLastTs,
|
|
651
|
-
memCandidates,
|
|
652
|
-
bugSignal,
|
|
653
|
-
bugRowSinceStart,
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
// Record every commit/bug fire — shown or suppressed. The dedupe below
|
|
657
|
-
// decides the message, never the record: a suppressed repeat is still a
|
|
658
|
-
// problem found, and the trial can only answer what the log kept.
|
|
659
|
-
if (reason && worktree) {
|
|
660
|
-
recordHintFire({
|
|
661
|
-
ts: new Date().toISOString(),
|
|
662
|
-
worktree,
|
|
663
|
-
surface: stopBlockSurface(bugSignal),
|
|
664
|
-
file: null,
|
|
665
|
-
count: 1,
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Handoff trial (PLAN-active-pain chunk 1): independent of commits.
|
|
670
|
-
// Default ships log-only — a would-block/pass row in hint-log answers
|
|
671
|
-
// the trial questions (gate precision, read-only silence, old-plan
|
|
672
|
-
// silence, committed-tick detection) without blocking anyone.
|
|
673
|
-
// FAPONY_HANDOFF_BLOCK=1 enforces; FAPONY_NO_HANDOFF_BLOCK=1 skips all.
|
|
674
|
-
// Kept in its own variable so the commit/bug dedupe below never consumes
|
|
675
|
-
// a handoff block's quota (or vice versa) — each kind fires once.
|
|
676
|
-
let handoffReason: string | null = null;
|
|
677
|
-
if (
|
|
678
|
-
worktree &&
|
|
679
|
-
since &&
|
|
680
|
-
process.env.FAPONY_NO_HANDOFF_BLOCK !== "1" &&
|
|
681
|
-
!norm.stopHookActive
|
|
682
|
-
) {
|
|
683
|
-
try {
|
|
684
|
-
const sinceMs = hookTsMs(since);
|
|
685
|
-
if (!Number.isNaN(sinceMs)) {
|
|
686
|
-
const plans = sessionPlanFiles(norm.cwd, since);
|
|
687
|
-
if (plans && plans.length > 0) {
|
|
688
|
-
const baseSha = handoffBaseSha(norm.cwd, since);
|
|
689
|
-
if (baseSha) {
|
|
690
|
-
const handoffMem = readMemLog(worktree);
|
|
691
|
-
// No log at all = fail-open, silently: without mem data the
|
|
692
|
-
// evaluation never ran, so a pass row would pollute the trial.
|
|
693
|
-
if (handoffMem.filesFound > 0) {
|
|
694
|
-
const files: HandoffPlanFile[] = [];
|
|
695
|
-
for (const rel of plans) {
|
|
696
|
-
let after: string | null = null;
|
|
697
|
-
try {
|
|
698
|
-
after = await Bun.file(join(worktree, rel)).text();
|
|
699
|
-
} catch {
|
|
700
|
-
after = null;
|
|
701
|
-
}
|
|
702
|
-
if (after === null) continue;
|
|
703
|
-
files.push({
|
|
704
|
-
rel,
|
|
705
|
-
before: fileAtRevision(norm.cwd, baseSha, rel) ?? "",
|
|
706
|
-
after,
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
if (files.length > 0) {
|
|
710
|
-
const h = decideHandoff({
|
|
711
|
-
files,
|
|
712
|
-
memRows: handoffMem.rows,
|
|
713
|
-
sinceMs,
|
|
714
|
-
});
|
|
715
|
-
const now = new Date().toISOString();
|
|
716
|
-
if (h.blockedPlan) {
|
|
717
|
-
recordHintFire({
|
|
718
|
-
ts: now,
|
|
719
|
-
worktree,
|
|
720
|
-
surface: "handoff-would-block",
|
|
721
|
-
file: h.blockedPlan,
|
|
722
|
-
count: 1,
|
|
723
|
-
});
|
|
724
|
-
if (
|
|
725
|
-
process.env.FAPONY_HANDOFF_BLOCK === "1" &&
|
|
726
|
-
!reason &&
|
|
727
|
-
!stopBlockedBefore(
|
|
728
|
-
norm.transcriptPath,
|
|
729
|
-
worktree,
|
|
730
|
-
handoffDedupeKey(h.blockedPlan),
|
|
731
|
-
)
|
|
732
|
-
) {
|
|
733
|
-
handoffReason = handoffBlockMessage(h.blockedPlan);
|
|
734
|
-
}
|
|
735
|
-
} else if (h.evaluated.length > 0) {
|
|
736
|
-
recordHintFire({
|
|
737
|
-
ts: now,
|
|
738
|
-
worktree,
|
|
739
|
-
surface: "handoff-pass",
|
|
740
|
-
file: h.evaluated[0],
|
|
741
|
-
count: 1,
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
} catch {
|
|
750
|
-
// fail-open — never block on handoff machinery failing
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Handoff merges last and only fills an otherwise-allowed turn — the
|
|
755
|
-
// commit/bug dedupe never sees (or consumes) a handoff-derived reason.
|
|
756
|
-
reason = mergeStopReasons({
|
|
757
|
-
commitReason: reason,
|
|
758
|
-
handoffReason,
|
|
759
|
-
session: norm.transcriptPath,
|
|
760
|
-
worktree,
|
|
761
|
-
bugSignal,
|
|
762
|
-
});
|
|
763
|
-
} catch {
|
|
764
|
-
reason = null;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
if (reason) console.log(stopOutput(client, reason));
|
|
768
|
-
}
|