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.
Files changed (63) hide show
  1. package/README.md +144 -156
  2. package/package.json +5 -6
  3. package/skill/define-convention/SKILL.md +3 -3
  4. package/skill/lookup-before-edit/SKILL.md +2 -1
  5. package/skill/move-to-done/SKILL.md +18 -22
  6. package/skill/plan-with-pony/SKILL.md +4 -4
  7. package/skill/review-pony/SKILL.md +10 -12
  8. package/src/adapters/cli.ts +11 -52
  9. package/src/adapters/hooks/index.ts +0 -67
  10. package/src/adapters/hooks/mv-guard.ts +2 -2
  11. package/src/analyze/discover.ts +1 -1
  12. package/src/analyze/index.ts +0 -1
  13. package/src/commands.ts +8 -39
  14. package/src/core/config.ts +0 -6
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +1 -2
  17. package/src/debt/load.ts +3 -5
  18. package/src/debt/promotion.ts +2 -2
  19. package/src/debt/scan.ts +1 -1
  20. package/src/digest/collect.ts +6 -7
  21. package/src/fael.ts +139 -0
  22. package/src/hook.ts +1 -81
  23. package/src/init.ts +30 -90
  24. package/src/install/antigravity.ts +8 -99
  25. package/src/install/claude.ts +66 -200
  26. package/src/install/codex.ts +6 -170
  27. package/src/install/detect.ts +4 -14
  28. package/src/install/opencode.ts +50 -486
  29. package/src/install/types.ts +1 -39
  30. package/src/install/zcode.ts +5 -91
  31. package/src/install.ts +9 -35
  32. package/src/memory.ts +10 -69
  33. package/src/plan/index.ts +34 -0
  34. package/src/plan/next.ts +181 -0
  35. package/src/plan/store.ts +41 -0
  36. package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
  37. package/src/seed/plan-seed.ts +10 -9
  38. package/src/setup.ts +4 -22
  39. package/src/update.ts +1 -1
  40. package/templates/PLAN.md +1 -1
  41. package/src/adapters/hooks/bug-markers.ts +0 -60
  42. package/src/adapters/hooks/context-data.ts +0 -139
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -410
  45. package/src/adapters/hooks/session-start.ts +0 -112
  46. package/src/adapters/hooks/stop.ts +0 -768
  47. package/src/adapters/mcp/tools/index.ts +0 -149
  48. package/src/adapters/mcp/tools/mem.ts +0 -252
  49. package/src/adapters/mcp/transport.ts +0 -147
  50. package/src/analyze/cache.ts +0 -162
  51. package/src/core/mem-log.ts +0 -375
  52. package/src/init-mem.ts +0 -143
  53. package/src/install/cursor.ts +0 -167
  54. package/src/install/utils.ts +0 -29
  55. package/src/mem/commands/read.ts +0 -738
  56. package/src/mem/commands/rotate.ts +0 -59
  57. package/src/mem/commands/where.ts +0 -56
  58. package/src/mem/commands/write.ts +0 -273
  59. package/src/mem/engine.ts +0 -315
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/render.ts +0 -66
  62. package/src/mem/selectors.ts +0 -160
  63. 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
- }