fapony 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +201 -431
  2. package/package.json +1 -1
  3. package/skill/lookup-before-edit/SKILL.md +2 -2
  4. package/skill/plan-with-pony/SKILL.md +3 -1
  5. package/skill/review-pony/SKILL.md +2 -2
  6. package/src/adapters/cli.ts +1 -1
  7. package/src/adapters/hooks/bug-markers.ts +19 -9
  8. package/src/adapters/hooks/compute-hint-impact.ts +12 -1
  9. package/src/adapters/hooks/context-data.ts +32 -6
  10. package/src/adapters/hooks/edit-hint.ts +11 -1
  11. package/src/adapters/hooks/index.ts +15 -0
  12. package/src/adapters/hooks/read-hint.ts +11 -1
  13. package/src/adapters/hooks/stop.ts +347 -7
  14. package/src/adapters/mcp/primitives.ts +1 -1
  15. package/src/adapters/mcp/tools/check.ts +1 -1
  16. package/src/adapters/mcp/tools/collect.ts +1 -1
  17. package/src/adapters/mcp/tools/index.ts +13 -1
  18. package/src/adapters/mcp/tools/mem.ts +30 -5
  19. package/src/adapters/mcp/tools/report.ts +1 -1
  20. package/src/adapters/mcp/transport.ts +1 -1
  21. package/src/analyze/barrels.ts +56 -0
  22. package/src/analyze/blast.ts +58 -0
  23. package/src/analyze/cache.ts +162 -0
  24. package/src/analyze/cli.ts +28 -0
  25. package/src/analyze/criteria.ts +81 -0
  26. package/src/analyze/diagnose.ts +113 -0
  27. package/src/analyze/discover.ts +84 -0
  28. package/src/analyze/format.ts +38 -0
  29. package/src/analyze/graph.ts +111 -0
  30. package/src/analyze/index.ts +18 -0
  31. package/src/analyze/python.ts +411 -0
  32. package/src/analyze/resolve-ts.ts +39 -0
  33. package/src/analyze/types.ts +44 -0
  34. package/src/conventions-seed.ts +6 -2
  35. package/src/core/hint-log.ts +16 -1
  36. package/src/core/mem-log.ts +18 -0
  37. package/src/debt/cli.ts +25 -10
  38. package/src/debt/scan.ts +1 -1
  39. package/src/hook.ts +15 -0
  40. package/src/install/antigravity.ts +21 -8
  41. package/src/install/detect.ts +8 -5
  42. package/src/install/opencode.ts +6 -0
  43. package/src/install.ts +6 -4
  44. package/src/map.ts +220 -0
  45. package/src/mem/commands/plan.ts +70 -0
  46. package/src/mem/commands/read.ts +73 -18
  47. package/src/mem/commands/write.ts +75 -18
  48. package/src/mem/engine.ts +68 -2
  49. package/src/mem/index.ts +7 -5
  50. package/src/mem/render.ts +4 -1
  51. package/src/mem/selectors.ts +16 -0
  52. package/src/mem/store.ts +2 -0
  53. package/src/seed/plan-seed.ts +146 -13
  54. package/src/seed/review-seed.ts +144 -57
  55. package/templates/PLAN.md +1 -0
  56. package/src/analyze.ts +0 -688
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick — plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
5
5
  "license": "MIT",
6
6
  "author": "delamind (https://github.com/kire21b)",
@@ -11,7 +11,7 @@ A full-file read on a 500-line module costs ~35k tokens of output; a lookup cost
11
11
  ## The one command
12
12
 
13
13
  ```bash
14
- fapony review-seed --files <f1,f2,dir> [--body <sym>] [--callers <sym>]
14
+ fapony review-seed --files <f1,f2,dir> [--body <sym>[,<sym>]] [--callers <sym>[,<sym>]]
15
15
  ```
16
16
 
17
17
  What it returns: every export with its line number (uncapped), plus the importers — first 12
@@ -22,7 +22,7 @@ per file, the rest as `(+N)`, so the total is still readable. That is your entry
22
22
 
23
23
  - `--body <sym>[,<sym>]` — declaration slice of those exports (truncated at 80 lines each):
24
24
  "what does this do" without the file.
25
- - `--callers <sym>` — symbol→symbol scan across the importers the static graph sees.
25
+ - `--callers <sym>[,<sym>]` — symbol→symbol scan across the importers the static graph sees.
26
26
  - Directories expand to the source files under them (cap 40, stated when cut). Paths that do
27
27
  not exist are dropped with a notice, not counted silently.
28
28
 
@@ -192,7 +192,9 @@ only place that ordering stays true.
192
192
  becomes a second copy of the plan, and then neither copy can be trusted. `fapony mem kickoff` reads the
193
193
  checkboxes in the **first `##` section only**, so section 6 stays detail rather than status.
194
194
 
195
- Section 6 — every step must be verifiable. Section 8 — must link back to anything it came from.
195
+ Section 6 — every step must be verifiable. Section 8 — must link back to anything it came from. **A step that needs something the system does not store yet** ("the month the accountant has seen",
196
+ "last synced") must say where it lives, who writes it, and who reads it — or the executing agent
197
+ designs it alone, by exploring (measured: one such chunk burned ~250k tokens before a line of code).
196
198
  **Plan = what/why/order, spec = how in detail**: never paste API shapes, schemas, wireframes, or
197
199
  edge-case tables into section 7; link to the spec instead. Full template: `templates/PLAN.md`.
198
200
 
@@ -35,8 +35,8 @@ proof that you looked. Start at pass 1.
35
35
  Run `fapony review-seed` with the scope flag matching what you're reviewing (default = uncommitted,
36
36
  `--commit <sha>`, `--range <a...b>`, `--files f1,f2,dir`, `--plan <PLAN.md>`). The output is where to
37
37
  enter, never coverage — walk it, run it, kill your findings as normal. No fapony CLI or the call
38
- errors → skip silently and review anyway — a hint, not a gate. Mid-walk, `--files <f> --body <sym>
39
- --callers <sym>` answers "what does this do / who calls it" without reading the file.
38
+ errors → skip silently and review anyway — a hint, not a gate. Mid-walk, `--files <f> --body <sym>[,<sym>]
39
+ --callers <sym>[,<sym>]` answers "what does this do / who calls it" without reading the file.
40
40
 
41
41
  **`--plan` on an already-shipped plan comes back "nothing in this scope" — that's the wrong scope,
42
42
  not no scope.** A shipped plan has nothing left in the working tree to diff. If its header cites
@@ -5,7 +5,7 @@
5
5
  // argv routing.
6
6
 
7
7
  import { existsSync } from "node:fs";
8
- import { cmdAnalyze } from "../analyze.js";
8
+ import { cmdAnalyze } from "../analyze/index.js";
9
9
  import { renderUsage, suggestCommand } from "../commands.js";
10
10
  import { cmdDebt } from "../debt/cli.js";
11
11
  import { cmdDigest } from "../digest/cli.js";
@@ -18,23 +18,33 @@
18
18
  // appear in every bug report and would fire on any turn that reads one — the
19
19
  // Stop hook blocks once per session on a match, so a false positive is costly.
20
20
 
21
- /** Free-text announcement phrases ("I found a bug"), never symptom words. */
21
+ /** Free-text announcement phrases ("I found a bug"), never symptom words.
22
+ * `g` flag is required — hasBugMarker walks every match (matchAll). */
22
23
  export const BUG_MARKERS: RegExp[] = [
23
- /เจอบั๊ก/,
24
- /พบบั๊ก/,
25
- /\bfound (?:a |the )?bug\b/i,
26
- /\b(?:this|that|it)(?:'s| is) a bug\b/i,
27
- /\bbug\b\s*:/i,
24
+ /(?:เจอ|พบ)(?:ว่า)?(?:เป็น)?บั๊ก/g, // พบบั๊ก · พบว่าเป็นบั๊กจริง
25
+ /บั๊กที่(?:เจอ|พบ)/g,
26
+ /\bfound (?:a |the )?(?:real |actual )?bug\b/gi,
27
+ /\b(?:this|that|it)(?:'s| is) a (?:real )?bug\b/gi,
28
+ /\b(?:bug confirmed|confirmed (?:a |real )?bug)\b/gi,
29
+ /\bbug\b\s*(?:\([^)\n]{0,80}\))?\s*:/gi, // **Bug (cause…):**
28
30
  ];
29
31
 
32
+ // Negation / hypothetical right before a match ("ไม่พบบั๊ก", "จะเจอบั๊ก",
33
+ // "not a bug"). Bare "เป็นบั๊ก" is deliberately not a marker: "อาจเป็นบั๊ก" is
34
+ // everywhere and a false fire costs a Stop-hook block.
35
+ const NEGATED = /(?:ไม่|จะ|ถ้า|อาจ|\bnot\s|\bno\s|\bif\s)\s*$/i;
36
+
30
37
  /**
31
38
  * The matched marker phrase, or null. Returns the matched text so the caller
32
- * can quote it back to the agent (stop.ts does, in its block reason).
39
+ * can quote it back to the agent (stop.ts does, in its block reason). Every
40
+ * match is checked, so "ไม่พบบั๊กใหม่ แต่เจอบั๊กที่ X" still fires on the second.
33
41
  */
34
42
  export function hasBugMarker(text: string): string | null {
35
43
  for (const re of BUG_MARKERS) {
36
- const m = text.match(re);
37
- if (m) return m[0];
44
+ for (const m of text.matchAll(re)) {
45
+ const before = text.slice(Math.max(0, m.index - 15), m.index);
46
+ if (!NEGATED.test(before)) return m[0];
47
+ }
38
48
  }
39
49
  return null;
40
50
  }
@@ -30,7 +30,18 @@ export function computeHintImpact(
30
30
  const dir = hintLogDir();
31
31
  const impact: HintImpact = {
32
32
  fired: 0,
33
- by_surface: { read: 0, debt: 0, mem: 0, commit: 0, edit: 0 },
33
+ by_surface: {
34
+ read: 0,
35
+ debt: 0,
36
+ mem: 0,
37
+ commit: 0,
38
+ edit: 0,
39
+ "open-bug": 0,
40
+ "handoff-would-block": 0,
41
+ "handoff-pass": 0,
42
+ "commit-block": 0,
43
+ "bug-block": 0,
44
+ },
34
45
  debt: { shown: 0, resolved: 0, unknown: 0 },
35
46
  window: since ?? null,
36
47
  };
@@ -5,19 +5,20 @@
5
5
 
6
6
  import { realpathSync } from "node:fs";
7
7
  import { basename, dirname, join, relative } from "node:path";
8
- import { collectSourceFiles, SCAN_EXTS } from "../../analyze.js";
8
+ import { collectSourceFiles, SCAN_EXTS } from "../../analyze/index.js";
9
+ import { MEM_TEXT_MAX, readMemLog } from "../../core/mem-log.js";
9
10
  import { debtForFile, resolveDebtScope } from "../../debt/index.js";
10
- import { readMemLog } from "../../memory.js";
11
11
 
12
12
  const DEBT_HINT_MAX = 3;
13
13
  const MEM_HINT_MAX = 2;
14
- const MEM_TEXT_MAX = 120;
15
14
 
16
15
  export interface ContextLineData {
17
16
  worktree: string;
18
17
  debtIds: string[];
19
18
  debtLines: string[];
20
19
  memLines: string[];
20
+ /** Ids of open bugs actually emitted as OPEN BUG lines above (for the fire log). */
21
+ openBugIds: string[];
21
22
  }
22
23
 
23
24
  /** Structured data behind readContextLines — used by cmdHookReadHint for logging. */
@@ -43,6 +44,7 @@ export function readContextData(
43
44
  const debtIds: string[] = [];
44
45
  const debtLines: string[] = [];
45
46
  const memLines: string[] = [];
47
+ const openBugIds: string[] = [];
46
48
 
47
49
  // convention debt — source files only, fresh from the repo. The scope
48
50
  // pairs the git root (repo-relative `where`) with the nearest
@@ -66,6 +68,23 @@ export function readContextData(
66
68
  // even though the file being touched sits right under its log (bug muc9q47r).
67
69
  const mem = readMemLog(dirname(abs));
68
70
  if (mem.rows.length > 0) {
71
+ // Open bugs first (PLAN-active-pain chunk 3): a kind:"bug" row whose id
72
+ // is in no close row's ref — the same tombstone shape as openRows in
73
+ // src/mem/selectors.ts, never a regex on text. Exact files[] match only:
74
+ // a bug row with no files[] stays silent rather than guessed at.
75
+ const closed = new Set(
76
+ mem.rows.filter((r) => r.kind === "close").map((r) => r.ref),
77
+ );
78
+ const openLines: string[] = [];
79
+ for (const r of mem.rows) {
80
+ if (openLines.length >= MEM_HINT_MAX) break;
81
+ if (r.kind !== "bug" || !r.id || closed.has(r.id)) continue;
82
+ if (!(r.files ?? []).includes(rel)) continue;
83
+ openLines.push(
84
+ `fapony mem: OPEN BUG ${r.id} — "${r.text.slice(0, MEM_TEXT_MAX)}" — ปิดด้วย fapony mem close ${r.id} "<msg>" เมื่อแก้แล้ว`,
85
+ );
86
+ openBugIds.push(r.id);
87
+ }
69
88
  const base = basename(rel);
70
89
  const direct: typeof mem.rows = [];
71
90
  const baseOnly: typeof mem.rows = [];
@@ -85,14 +104,21 @@ export function readContextData(
85
104
  ).length;
86
105
  if (sameName !== 1) usableBase = [];
87
106
  }
88
- const memHits = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
89
- for (const r of memHits) {
107
+ // Open lines take the closed rows' slots first; the rest fills the
108
+ // remaining budget without adding lines past the cap — and without
109
+ // repeating a row already emitted as OPEN BUG.
110
+ const emitted = new Set(openBugIds);
111
+ const rest = [...direct, ...usableBase]
112
+ .filter((r) => !(r.id && emitted.has(r.id)))
113
+ .slice(0, MEM_HINT_MAX - openLines.length);
114
+ for (const line of openLines) memLines.push(line);
115
+ for (const r of rest) {
90
116
  memLines.push(
91
117
  `fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
92
118
  );
93
119
  }
94
120
  }
95
- return { worktree, debtIds, debtLines, memLines };
121
+ return { worktree, debtIds, debtLines, memLines, openBugIds };
96
122
  } catch {
97
123
  return null;
98
124
  }
@@ -12,7 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { join, relative, sep } from "node:path";
15
- import { buildGraphCached, SCAN_EXTS } from "../../analyze.js";
15
+ import { buildGraphCached, SCAN_EXTS } from "../../analyze/index.js";
16
16
  import { recordHintFire } from "../../core/hint-log.js";
17
17
  import { sessionKey } from "../../core/hook-helpers.js";
18
18
  import { readContextData } from "./context-data.js";
@@ -184,6 +184,16 @@ export async function cmdHookEditHint(): Promise<void> {
184
184
  file: rel && !rel.startsWith("..") ? rel : null,
185
185
  count: 1,
186
186
  });
187
+ if (ctx && ctx.openBugIds.length > 0) {
188
+ recordHintFire({
189
+ ts: new Date().toISOString(),
190
+ worktree,
191
+ surface: "open-bug",
192
+ file: rel && !rel.startsWith("..") ? rel : null,
193
+ count: ctx.openBugIds.length,
194
+ ids: ctx.openBugIds,
195
+ });
196
+ }
187
197
  }
188
198
  } catch {
189
199
  // best-effort — swallow
@@ -69,17 +69,32 @@ export {
69
69
  sessionStartContext,
70
70
  } from "./session-start.js";
71
71
  export {
72
+ bugDedupeKey,
72
73
  bugSignalFromTranscript,
73
74
  cmdHookStop,
75
+ countTicks,
74
76
  cursorTranscriptPath,
77
+ decideHandoff,
75
78
  decideStop,
79
+ fileAtRevision,
80
+ type HandoffDecision,
81
+ type HandoffPlanFile,
82
+ handoffBaseSha,
83
+ handoffBlockMessage,
84
+ handoffDedupeKey,
85
+ hasHandoffLiteral,
76
86
  isCodexPayload,
77
87
  isCursorPayload,
88
+ isPlanPath,
89
+ memHasHandoffForPlan,
90
+ mergeStopReasons,
78
91
  type NormalizedStopInput,
79
92
  normalizeStopInput,
80
93
  type RawStopPayload,
81
94
  type StopClient,
95
+ sessionPlanFiles,
82
96
  stopBlockedBefore,
83
97
  stopBlockPath,
98
+ stopBlockSurface,
84
99
  stopOutput,
85
100
  } from "./stop.js";
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join, relative, resolve } from "node:path";
16
- import { SCAN_EXTS } from "../../analyze.js";
16
+ import { SCAN_EXTS } from "../../analyze/index.js";
17
17
  import { recordHintFire } from "../../core/hint-log.js";
18
18
  import { sessionKey } from "../../core/hook-helpers.js";
19
19
  import { readMemLog } from "../../memory.js";
@@ -392,6 +392,16 @@ export async function cmdHookReadHint(): Promise<void> {
392
392
  count: ctx.memLines.length,
393
393
  });
394
394
  }
395
+ if (ctx && ctx.openBugIds.length > 0) {
396
+ recordHintFire({
397
+ ts: new Date().toISOString(),
398
+ worktree,
399
+ surface: "open-bug",
400
+ file: rel,
401
+ count: ctx.openBugIds.length,
402
+ ids: ctx.openBugIds,
403
+ });
404
+ }
395
405
  }
396
406
  } catch {
397
407
  // any failure = no hint; a hook must never block a read over a hint
@@ -13,7 +13,9 @@ import {
13
13
  } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
+ import { recordHintFire } from "../../core/hint-log.js";
16
17
  import { hookTsMs, sessionKey, utcStamp } from "../../core/hook-helpers.js";
18
+ import type { MemRow } from "../../core/mem-log.js";
17
19
  import { readMemLog, whereMemDir } from "../../memory.js";
18
20
  import { hasBugMarker } from "./bug-markers.js";
19
21
 
@@ -334,6 +336,247 @@ function hasBugRowSince(worktree: string, since: string): boolean {
334
336
  }
335
337
  }
336
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
+
337
580
  function git(args: string[], cwd: string): string | null {
338
581
  try {
339
582
  const p = Bun.spawnSync(["git", ...args], {
@@ -409,17 +652,114 @@ export async function cmdHookStop(): Promise<void> {
409
652
  bugSignal,
410
653
  bugRowSinceStart,
411
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;
412
677
  if (
413
- reason &&
414
678
  worktree &&
415
- stopBlockedBefore(
416
- norm.transcriptPath,
417
- worktree,
418
- bugSignal ? "bug" : "commit",
419
- )
679
+ since &&
680
+ process.env.FAPONY_NO_HANDOFF_BLOCK !== "1" &&
681
+ !norm.stopHookActive
420
682
  ) {
421
- reason = null;
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
+ }
422
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
+ });
423
763
  } catch {
424
764
  reason = null;
425
765
  }