fapony 0.4.0 → 0.5.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.
- package/README.md +177 -412
- package/package.json +1 -1
- package/skill/lookup-before-edit/SKILL.md +2 -2
- package/skill/review-pony/SKILL.md +2 -2
- package/src/adapters/hooks/compute-hint-impact.ts +12 -1
- package/src/adapters/hooks/context-data.ts +31 -5
- package/src/adapters/hooks/edit-hint.ts +10 -0
- package/src/adapters/hooks/index.ts +15 -0
- package/src/adapters/hooks/read-hint.ts +10 -0
- package/src/adapters/hooks/stop.ts +347 -7
- package/src/adapters/mcp/tools/index.ts +13 -1
- package/src/adapters/mcp/tools/mem.ts +30 -5
- package/src/adapters/mcp/transport.ts +1 -1
- package/src/core/hint-log.ts +16 -1
- package/src/core/mem-log.ts +18 -0
- package/src/debt/cli.ts +25 -10
- package/src/hook.ts +15 -0
- package/src/install/antigravity.ts +18 -7
- package/src/install/detect.ts +8 -5
- package/src/install/opencode.ts +6 -0
- package/src/install.ts +6 -4
- package/src/mem/commands/plan.ts +70 -0
- package/src/mem/commands/read.ts +73 -18
- package/src/mem/commands/write.ts +75 -18
- package/src/mem/engine.ts +68 -2
- package/src/mem/index.ts +7 -5
- package/src/mem/render.ts +4 -1
- package/src/mem/selectors.ts +13 -0
- package/src/mem/store.ts +2 -0
- package/src/seed/plan-seed.ts +139 -10
- package/src/seed/review-seed.ts +141 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
@@ -30,7 +30,18 @@ export function computeHintImpact(
|
|
|
30
30
|
const dir = hintLogDir();
|
|
31
31
|
const impact: HintImpact = {
|
|
32
32
|
fired: 0,
|
|
33
|
-
by_surface: {
|
|
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
|
};
|
|
@@ -6,18 +6,19 @@
|
|
|
6
6
|
import { realpathSync } from "node:fs";
|
|
7
7
|
import { basename, dirname, join, relative } from "node:path";
|
|
8
8
|
import { collectSourceFiles, SCAN_EXTS } from "../../analyze.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
|
-
|
|
89
|
-
|
|
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
|
}
|
|
@@ -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";
|
|
@@ -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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
bugSignal ? "bug" : "commit",
|
|
419
|
-
)
|
|
679
|
+
since &&
|
|
680
|
+
process.env.FAPONY_NO_HANDOFF_BLOCK !== "1" &&
|
|
681
|
+
!norm.stopHookActive
|
|
420
682
|
) {
|
|
421
|
-
|
|
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
|
}
|
|
@@ -23,7 +23,8 @@ export const TOOLS = [
|
|
|
23
23
|
"memDir shows which log dir was resolved (walked up from the given " +
|
|
24
24
|
"worktree — in a monorepo pass the app directory to read its log). " +
|
|
25
25
|
"Returns {rows, total, filesFound, " +
|
|
26
|
-
"skipped, memDir}: memDir:null = no mem at all, not 'nothing matched'."
|
|
26
|
+
"skipped, memDir}: memDir:null = no mem at all, not 'nothing matched'. " +
|
|
27
|
+
"A key query that misses adds knownKeys (every key in the log).",
|
|
27
28
|
inputSchema: {
|
|
28
29
|
type: "object" as const,
|
|
29
30
|
properties: {
|
|
@@ -64,6 +65,11 @@ export const TOOLS = [
|
|
|
64
65
|
description:
|
|
65
66
|
"true = unresolved work only: drops close/claim/release/synced rows and work rows already closed. Omit = every row. With kind:[bug] answers 'what bugs remain?'",
|
|
66
67
|
},
|
|
68
|
+
key: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description:
|
|
71
|
+
"Exact problem-identity match — miss returns knownKeys (every key in the log). No pattern: a wrong-pattern query must reach the server",
|
|
72
|
+
},
|
|
67
73
|
},
|
|
68
74
|
required: ["worktree"],
|
|
69
75
|
},
|
|
@@ -104,6 +110,12 @@ export const TOOLS = [
|
|
|
104
110
|
type: "string",
|
|
105
111
|
description: "Optional spec/plan .md path",
|
|
106
112
|
},
|
|
113
|
+
key: {
|
|
114
|
+
type: "string",
|
|
115
|
+
pattern: "^[a-z0-9-]{3,40}$",
|
|
116
|
+
description:
|
|
117
|
+
"Problem identity (not kind, not files) — same problem, same key",
|
|
118
|
+
},
|
|
107
119
|
},
|
|
108
120
|
required: ["worktree", "kind", "text", "files"],
|
|
109
121
|
},
|