fapony 0.2.1 → 0.3.3
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 +95 -72
- package/fapony.ts +12 -5
- package/package.json +5 -4
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/move-to-done/SKILL.md +19 -30
- package/skill/review-pony/SKILL.md +38 -61
- package/src/analyze.ts +1 -1
- package/src/debt/cli.ts +193 -0
- package/src/debt/format.ts +107 -0
- package/src/debt/index.ts +19 -0
- package/src/debt/load.ts +92 -0
- package/src/debt/promotion.ts +152 -0
- package/src/debt/scan.ts +214 -0
- package/src/debt/types.ts +79 -0
- package/src/detect.ts +92 -0
- package/src/gate.ts +3 -3
- package/src/hook.ts +349 -100
- package/src/init-mem.ts +57 -71
- package/src/init.ts +12 -16
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +16 -123
- package/src/install/codex.ts +58 -19
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +131 -6
- package/src/install.ts +12 -3
- package/src/lint-baseline.ts +2 -2
- package/src/mcp/primitives.ts +1 -1
- package/src/mcp/tools/index.ts +13 -102
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +8 -94
- package/src/mcp/worktree.ts +1 -1
- package/src/mem/commands/read.ts +231 -141
- package/src/mem/index.ts +4 -13
- package/src/memory.ts +17 -8
- package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
- package/src/seed/primitives.ts +66 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/util.ts +61 -0
- package/templates/SPEC.md +8 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/src/debt.ts +0 -811
- package/src/math.ts +0 -13
- package/src/mcp/tools/usage.ts +0 -211
- package/src/mcp/tools/verdict.ts +0 -161
package/src/hook.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/hook.ts — Claude Code / Cursor Stop hook: refuse to end a turn that produced
|
|
2
|
-
// commits but no
|
|
2
|
+
// commits but no mem row.
|
|
3
3
|
//
|
|
4
4
|
// Why a hook and not a message: SERVER_INSTRUCTIONS is a *request* that the
|
|
5
5
|
// agent remember, and it measured as not enough · the hook does not grade in
|
|
@@ -28,9 +28,9 @@ import {
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
30
30
|
import { buildGraphCached, collectSourceFiles, SCAN_EXTS } from "./analyze.js";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
31
|
+
import { debtForFile, loadConventions } from "./debt/index.js";
|
|
32
|
+
import { readMemLog, whereMemDir } from "./memory.js";
|
|
33
|
+
import { renderSeed } from "./seed/review-seed.js";
|
|
34
34
|
|
|
35
35
|
// --- Hint-fire log (PLAN-feedback-surface chunk 1) ---
|
|
36
36
|
//
|
|
@@ -239,30 +239,57 @@ export function utcStamp(d: Date): string {
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
242
|
+
* Parse either timestamp shape this repo produces: mem rows are ISO
|
|
243
|
+
* (`new Date().toISOString()`), session start is utcStamp
|
|
244
|
+
* ('YYYY-MM-DD HH:MM:SS', UTC). Never compare them as strings — 'T' > ' '
|
|
245
|
+
* makes any same-date mem row read as "newer than session start".
|
|
246
|
+
*/
|
|
247
|
+
export function hookTsMs(ts: string): number {
|
|
248
|
+
const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)
|
|
249
|
+
? `${ts.replace(" ", "T")}Z`
|
|
250
|
+
: ts;
|
|
251
|
+
return new Date(iso).getTime();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Pure decision: block when this session produced commits but no mem row
|
|
256
|
+
* newer than session start exists. Every unknown (no git, no transcript,
|
|
257
|
+
* hook already fired, no mem log) resolves to "allow" — a hook that guesses
|
|
258
|
+
* wrong must never trap the agent.
|
|
245
259
|
*
|
|
246
|
-
* PLAN-mem
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
260
|
+
* PLAN-verdict-to-mem: block condition changed from verdicts to mem rows.
|
|
261
|
+
* The stop hook is the most expensive enforcement tool we have (rule 9);
|
|
262
|
+
* using it for verdicts that are 94% pass-family was wasteful. Now it
|
|
263
|
+
* enforces mem_add — rows that session N can actually read.
|
|
250
264
|
*/
|
|
251
265
|
export function decideStop(opts: {
|
|
252
266
|
stopHookActive: boolean;
|
|
253
267
|
worktree: string | null;
|
|
254
268
|
commits: number;
|
|
255
|
-
|
|
269
|
+
since?: string | null;
|
|
256
270
|
commitList?: string[];
|
|
257
271
|
memLastTs?: string | null;
|
|
272
|
+
/** Logs that exist in the repo but are out of scope from this worktree. */
|
|
273
|
+
memCandidates?: string[];
|
|
258
274
|
}): string | null {
|
|
259
275
|
if (opts.stopHookActive) return null; // already blocked once — let it end
|
|
260
276
|
if (!opts.worktree) return null;
|
|
261
277
|
if (opts.commits < 1) return null;
|
|
262
|
-
|
|
278
|
+
// No mem log at all = allow (same as sessionStartContext — silent when missing).
|
|
279
|
+
if (!opts.memLastTs && !opts.memCandidates?.length) return null;
|
|
280
|
+
// Has mem rows — block only if nothing newer than session start. Parsed
|
|
281
|
+
// as dates, not strings: the two sides arrive in different shapes (ISO mem
|
|
282
|
+
// rows vs utcStamp session start). Unparseable = allow, per the contract
|
|
283
|
+
// above — an unknown timestamp is not proof either way.
|
|
284
|
+
if (opts.since && opts.memLastTs) {
|
|
285
|
+
const sinceMs = hookTsMs(opts.since);
|
|
286
|
+
const memMs = hookTsMs(opts.memLastTs);
|
|
287
|
+
if (Number.isNaN(sinceMs) || Number.isNaN(memMs)) return null;
|
|
288
|
+
if (memMs >= sinceMs) return null;
|
|
289
|
+
}
|
|
263
290
|
|
|
264
291
|
const lines: string[] = [
|
|
265
|
-
`${opts.commits} commit(s) landed in ${opts.worktree} this session
|
|
292
|
+
`${opts.commits} commit(s) landed in ${opts.worktree} this session — no mem row recorded for this work.`,
|
|
266
293
|
];
|
|
267
294
|
// ≤ 5 commits listed, rest folded into "… +N more" (spec §6: ≤ 12 lines).
|
|
268
295
|
const list = opts.commitList ?? [];
|
|
@@ -272,16 +299,17 @@ export function decideStop(opts: {
|
|
|
272
299
|
lines.push(
|
|
273
300
|
`mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
|
|
274
301
|
);
|
|
275
|
-
} else {
|
|
276
|
-
lines.push(
|
|
302
|
+
} else if (opts.memCandidates?.length) {
|
|
303
|
+
lines.push(
|
|
304
|
+
`mem: no log in scope from ${opts.worktree} — found ` +
|
|
305
|
+
`${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
|
|
306
|
+
);
|
|
277
307
|
}
|
|
308
|
+
|
|
278
309
|
lines.push(
|
|
279
|
-
`
|
|
280
|
-
|
|
281
|
-
`
|
|
282
|
-
`Grade what actually happened — pass-family when it held up, fail if the first ` +
|
|
283
|
-
`attempt was wrong, uncertain when you could not verify it. What deserves a mem ` +
|
|
284
|
-
`row (decision/bug/note) is your call — not every unit needs one.`,
|
|
310
|
+
`Record a mem row before ending: fapony mem add <decision|bug|note> "what happened" --files <files> ` +
|
|
311
|
+
`${opts.worktree}/.fapony/plan/PLAN.md (or the relevant plan). ` +
|
|
312
|
+
`files[] is required — a row without it is unfindable when you touch that file next session.`,
|
|
285
313
|
);
|
|
286
314
|
return lines.join("\n");
|
|
287
315
|
}
|
|
@@ -390,6 +418,67 @@ export function stopOutput(client: StopClient, reason: string): string {
|
|
|
390
418
|
return JSON.stringify({ decision: "block", reason });
|
|
391
419
|
}
|
|
392
420
|
|
|
421
|
+
// --- Block dedupe (one block per session + worktree) ---
|
|
422
|
+
//
|
|
423
|
+
// stop_hook_active only suppresses the block that fires *immediately* after
|
|
424
|
+
// one. Every later turn that still carries ungraded commits blocks again, so
|
|
425
|
+
// a session that keeps committing gets the same paragraph 4-5 times. The
|
|
426
|
+
// first block already delivered it; the repeats add noise, not force (rule 9
|
|
427
|
+
// — the forcing happens once, and the agent that ignored it once is not
|
|
428
|
+
// persuaded by the fifth copy).
|
|
429
|
+
//
|
|
430
|
+
// Keyed by the transcript path, which is already the session identity the
|
|
431
|
+
// commit window is measured from — no session field to thread through.
|
|
432
|
+
|
|
433
|
+
const STOP_BLOCK_DIR = "stop-block";
|
|
434
|
+
|
|
435
|
+
interface StopBlockRow {
|
|
436
|
+
ts: string;
|
|
437
|
+
worktree: string;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Absolute path of a session's block log — may not exist. */
|
|
441
|
+
export function stopBlockPath(session: string): string {
|
|
442
|
+
const base =
|
|
443
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
444
|
+
return join(base, STOP_BLOCK_DIR, `${sessionKey(session)}.jsonl`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* True when this session already blocked for this worktree — the caller then
|
|
449
|
+
* lets the turn end. Records the block when it has not. Every unknown (no
|
|
450
|
+
* session identity, unwritable state dir) resolves to false: a dedupe that
|
|
451
|
+
* guesses must fail towards blocking, never towards silence.
|
|
452
|
+
*/
|
|
453
|
+
export function stopBlockedBefore(
|
|
454
|
+
session: string | null,
|
|
455
|
+
worktree: string,
|
|
456
|
+
): boolean {
|
|
457
|
+
if (!session) return false;
|
|
458
|
+
const path = stopBlockPath(session);
|
|
459
|
+
try {
|
|
460
|
+
if (existsSync(path)) {
|
|
461
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
462
|
+
if (!line) continue;
|
|
463
|
+
try {
|
|
464
|
+
if ((JSON.parse(line) as StopBlockRow).worktree === worktree) {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
// a torn line must not lose the rest of the log
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const dir = join(path, "..");
|
|
473
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
474
|
+
const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
|
|
475
|
+
appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
476
|
+
} catch {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
393
482
|
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
394
483
|
export async function cmdHookStop(): Promise<void> {
|
|
395
484
|
let reason: string | null = null;
|
|
@@ -419,8 +508,8 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
419
508
|
|
|
420
509
|
let commits = 0;
|
|
421
510
|
let commitList: string[] = [];
|
|
422
|
-
let verdicts = 0;
|
|
423
511
|
let memLastTs: string | null = null;
|
|
512
|
+
let memCandidates: string[] = [];
|
|
424
513
|
if (worktree && since) {
|
|
425
514
|
const log = git(
|
|
426
515
|
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
@@ -428,23 +517,14 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
428
517
|
);
|
|
429
518
|
commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
430
519
|
commits = commitList.length;
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
verdicts = row?.n ?? 0;
|
|
440
|
-
// Informational only — read-only, degrade silently (mem status never
|
|
441
|
-
// becomes a block condition, rule 7).
|
|
442
|
-
try {
|
|
443
|
-
const mem = readMemLog(worktree);
|
|
444
|
-
memLastTs = mem.rows[0]?.ts ?? null;
|
|
445
|
-
} catch {
|
|
446
|
-
memLastTs = null;
|
|
447
|
-
}
|
|
520
|
+
// Read mem log regardless of commits — the block condition is mem rows,
|
|
521
|
+
// not verdicts (PLAN-verdict-to-mem).
|
|
522
|
+
try {
|
|
523
|
+
const mem = readMemLog(worktree);
|
|
524
|
+
memLastTs = mem.rows[0]?.ts ?? null;
|
|
525
|
+
if (!memLastTs) memCandidates = whereMemDir(worktree).candidates ?? [];
|
|
526
|
+
} catch {
|
|
527
|
+
memLastTs = null;
|
|
448
528
|
}
|
|
449
529
|
}
|
|
450
530
|
|
|
@@ -452,10 +532,19 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
452
532
|
stopHookActive: norm.stopHookActive,
|
|
453
533
|
worktree: since ? worktree : null,
|
|
454
534
|
commits,
|
|
455
|
-
|
|
535
|
+
since,
|
|
456
536
|
commitList,
|
|
457
537
|
memLastTs,
|
|
538
|
+
memCandidates,
|
|
458
539
|
});
|
|
540
|
+
// Already blocked for this worktree in this session — say it once.
|
|
541
|
+
if (
|
|
542
|
+
reason &&
|
|
543
|
+
worktree &&
|
|
544
|
+
stopBlockedBefore(norm.transcriptPath, worktree)
|
|
545
|
+
) {
|
|
546
|
+
reason = null;
|
|
547
|
+
}
|
|
459
548
|
} catch {
|
|
460
549
|
reason = null; // any failure = allow the turn to end
|
|
461
550
|
}
|
|
@@ -463,6 +552,123 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
463
552
|
if (reason) console.log(stopOutput(client, reason));
|
|
464
553
|
}
|
|
465
554
|
|
|
555
|
+
// --- Session start (kickoff as context, not as a thing to remember) ---
|
|
556
|
+
//
|
|
557
|
+
// `fapony mem kickoff` is the one command that pays for itself at session
|
|
558
|
+
// open — what is open, what is stale, what the last rows touched. Asking the
|
|
559
|
+
// agent to run it measured as not enough (rule 9), and an MCP tool would pay
|
|
560
|
+
// schema rent in every session of every client to save one bash round
|
|
561
|
+
// (rule 13). A SessionStart hook is neither: zero rent, and it fires whether
|
|
562
|
+
// or not anyone remembers.
|
|
563
|
+
//
|
|
564
|
+
// sessionStartContext is the one copy of that: guard (no mem log = null), the
|
|
565
|
+
// kickoff spawn, and the cap. cmdHookSessionStart wraps it in the Claude/Codex
|
|
566
|
+
// JSON, and the generated OpenCode plugin imports it directly — so the logic
|
|
567
|
+
// lives here and `git pull` updates all three clients, instead of being baked
|
|
568
|
+
// into the plugin file where a pull could not reach it (mub2ezhi).
|
|
569
|
+
//
|
|
570
|
+
// Runs the CLI in a subprocess rather than calling cmdKickoff: kickoff prints
|
|
571
|
+
// to stdout and exits on bad input, both of which would be this hook's stdout.
|
|
572
|
+
|
|
573
|
+
/** Cap on injected context — kickoff is short, a broken repo's output is not. */
|
|
574
|
+
export const SESSION_START_MAX_CHARS = 4_000;
|
|
575
|
+
|
|
576
|
+
const TRUNCATED = "… truncated — run `fapony mem find <word>` for the rest";
|
|
577
|
+
|
|
578
|
+
/** Trim to whole lines, keeping the marker's line boundary intact. */
|
|
579
|
+
function headLines(text: string, max: number): string {
|
|
580
|
+
const cut = text.slice(0, max);
|
|
581
|
+
const lastNl = cut.lastIndexOf("\n");
|
|
582
|
+
return (lastNl > 0 ? cut.slice(0, lastNl) : cut).trimEnd();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Trim to whole lines within the cap, with an honest truncation marker.
|
|
587
|
+
*
|
|
588
|
+
* Kickoff's ordering is now: ranked rows → "## next up" → "## recent". The
|
|
589
|
+
* most actionable content is at the top (bugs, diff-matched) and in "## next
|
|
590
|
+
* up". A plain head-cut drops the suggestions — so we try to keep "## next up"
|
|
591
|
+
* visible. When "## recent" exists, we cut it first; otherwise fall back to
|
|
592
|
+
* keeping "## next up" at the tail.
|
|
593
|
+
*/
|
|
594
|
+
export function capContext(
|
|
595
|
+
text: string,
|
|
596
|
+
max = SESSION_START_MAX_CHARS,
|
|
597
|
+
): string {
|
|
598
|
+
if (text.length <= max) return text;
|
|
599
|
+
// New ordering: ranked → ## next up → ## recent → sweep/rotate
|
|
600
|
+
// Cut ## recent first to keep ranked content + suggestions visible.
|
|
601
|
+
const recentIdx = text.indexOf("\n## recent");
|
|
602
|
+
if (recentIdx > 0) {
|
|
603
|
+
const head = text.slice(0, recentIdx).trimEnd();
|
|
604
|
+
if (head.length <= max) return head;
|
|
605
|
+
// Head still too long — try to keep ## next up at the end
|
|
606
|
+
const nextIdx = head.lastIndexOf("\n## next up");
|
|
607
|
+
if (nextIdx > 0) {
|
|
608
|
+
const beforeNext = head.slice(0, nextIdx).trimEnd();
|
|
609
|
+
const nextTail = head.slice(nextIdx).trimEnd();
|
|
610
|
+
if (nextTail.length < max / 2) {
|
|
611
|
+
return `${headLines(beforeNext, max - nextTail.length)}\n${TRUNCATED}\n${nextTail}`;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return `${headLines(head, max)}\n${TRUNCATED}`;
|
|
615
|
+
}
|
|
616
|
+
// Fallback: no ## recent found (short output or other path)
|
|
617
|
+
const at = text.lastIndexOf("\n## next up");
|
|
618
|
+
const tail = at > 0 ? text.slice(at).trimEnd() : "";
|
|
619
|
+
if (tail && tail.length < max / 2) {
|
|
620
|
+
return `${headLines(text, max - tail.length)}\n${TRUNCATED}\n${tail}`;
|
|
621
|
+
}
|
|
622
|
+
return `${headLines(text, max)}\n${TRUNCATED}`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Kickoff output for `cwd`'s mem log, capped — or null when there is no log in
|
|
627
|
+
* scope, the command fails, or it prints nothing. The single implementation
|
|
628
|
+
* behind both cmdHookSessionStart (Claude/Codex) and the generated OpenCode
|
|
629
|
+
* plugin, so a pull of INSTALL_ROOT updates all three.
|
|
630
|
+
*
|
|
631
|
+
* `faponyTs` is where the CLI lives. The hook defaults to `Bun.main` (itself,
|
|
632
|
+
* when run as `bun <root>/fapony.ts hook-session-start`); the OpenCode plugin
|
|
633
|
+
* must pass its baked path because `Bun.main` inside OpenCode is OpenCode's own
|
|
634
|
+
* entry, not fapony's.
|
|
635
|
+
*/
|
|
636
|
+
export function sessionStartContext(
|
|
637
|
+
cwd: string,
|
|
638
|
+
faponyTs: string = Bun.main,
|
|
639
|
+
): string | null {
|
|
640
|
+
// No mem log in scope = nothing to say. Silence beats "no rows yet".
|
|
641
|
+
if (!whereMemDir(cwd).dir) return null;
|
|
642
|
+
const p = Bun.spawnSync([process.execPath, faponyTs, "mem", "kickoff"], {
|
|
643
|
+
cwd,
|
|
644
|
+
stdout: "pipe",
|
|
645
|
+
stderr: "pipe",
|
|
646
|
+
});
|
|
647
|
+
const out = p.stdout.toString().trim();
|
|
648
|
+
if (p.exitCode !== 0 || !out) return null;
|
|
649
|
+
return capContext(out);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** SessionStart hook: emit sessionStartContext as Claude/Codex JSON. */
|
|
653
|
+
export async function cmdHookSessionStart(): Promise<void> {
|
|
654
|
+
try {
|
|
655
|
+
const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
|
|
656
|
+
const cwd = raw.cwd ?? process.cwd();
|
|
657
|
+
const ctx = sessionStartContext(cwd);
|
|
658
|
+
if (!ctx) return;
|
|
659
|
+
console.log(
|
|
660
|
+
JSON.stringify({
|
|
661
|
+
hookSpecificOutput: {
|
|
662
|
+
hookEventName: "SessionStart",
|
|
663
|
+
additionalContext: ctx,
|
|
664
|
+
},
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
} catch {
|
|
668
|
+
// any failure = no context, never a broken session start
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
466
672
|
// --- Read hint (PreToolUse annotate — never block, never dedupe) ---
|
|
467
673
|
//
|
|
468
674
|
// Reading a large file in full is where an agent spends tokens without
|
|
@@ -483,6 +689,8 @@ export const READ_HINT_MIN_BYTES = 24_000;
|
|
|
483
689
|
export const READ_HINT_MIN_LIMIT = 300;
|
|
484
690
|
/** One-time measurement (2026-09-17, this repo): 5 files / 2,146 lines ≈ 3.7KB out. */
|
|
485
691
|
const READ_HINT_MEASURED = "measured ~3.7KB output on a 2,146-line file";
|
|
692
|
+
/** Cap on the outline attached to the read hint — keeps the hint compact. */
|
|
693
|
+
const READ_HINT_OUTLINE_CAP = 2_000;
|
|
486
694
|
|
|
487
695
|
export interface ReadHintInput {
|
|
488
696
|
filePath: unknown;
|
|
@@ -497,6 +705,10 @@ export interface ReadHintInput {
|
|
|
497
705
|
* repo, stat/read failure) resolves to null — a hint must never fire on a
|
|
498
706
|
* guess. Fast path is statSync only; the file is read just to count lines,
|
|
499
707
|
* and only after the size threshold passed.
|
|
708
|
+
*
|
|
709
|
+
* When review-seed succeeds, the hint attaches the actual outline (exports
|
|
710
|
+
* with line numbers) instead of telling the agent to run a command (rules
|
|
711
|
+
* 9/13: ask does not work, attach the data directly).
|
|
500
712
|
*/
|
|
501
713
|
export function readHintFor(opts: ReadHintInput): string | null {
|
|
502
714
|
try {
|
|
@@ -520,10 +732,45 @@ export function readHintFor(opts: ReadHintInput): string | null {
|
|
|
520
732
|
// relative path, outside it relative() climbs dots, show absolute.
|
|
521
733
|
const rel = relative(opts.cwd, opts.filePath);
|
|
522
734
|
const shown = rel.startsWith("..") ? opts.filePath : rel;
|
|
735
|
+
|
|
736
|
+
// Try to attach the actual outline from review-seed (cap ~2KB)
|
|
737
|
+
let outline = "";
|
|
738
|
+
try {
|
|
739
|
+
const seed = renderSeed(["--files", shown], opts.cwd);
|
|
740
|
+
// Extract signatures section — the most useful part for reading
|
|
741
|
+
const sigMatch = seed.match(
|
|
742
|
+
/signatures \(current\):\n([\s\S]*?)(?:\n\w|\nstatic graph)/,
|
|
743
|
+
);
|
|
744
|
+
if (sigMatch) {
|
|
745
|
+
outline = sigMatch[1].trim();
|
|
746
|
+
} else {
|
|
747
|
+
// Fallback: take the first section after the file list
|
|
748
|
+
const afterFiles = seed.indexOf("\nimporters");
|
|
749
|
+
if (afterFiles > 0) {
|
|
750
|
+
outline = seed
|
|
751
|
+
.slice(0, Math.min(afterFiles, READ_HINT_OUTLINE_CAP))
|
|
752
|
+
.trim();
|
|
753
|
+
} else {
|
|
754
|
+
outline = seed.slice(0, READ_HINT_OUTLINE_CAP).trim();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (outline.length > READ_HINT_OUTLINE_CAP) {
|
|
758
|
+
outline = `${outline.slice(0, READ_HINT_OUTLINE_CAP).trimEnd()}\n… truncated`;
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
// review-seed failed — fall back to the command suggestion
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (outline) {
|
|
765
|
+
return (
|
|
766
|
+
`fapony: ${shown} is ${lines} lines\n${outline}\n` +
|
|
767
|
+
`(review-seed --files ${shown} for importers + callers; skill /lookup-before-edit)`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
523
770
|
return (
|
|
524
771
|
`fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
|
|
525
772
|
`returns exports with line numbers, importers, and signatures first ` +
|
|
526
|
-
`(${READ_HINT_MEASURED})`
|
|
773
|
+
`(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
|
|
527
774
|
);
|
|
528
775
|
} catch {
|
|
529
776
|
return null;
|
|
@@ -785,7 +1032,7 @@ export function editHintFor(opts: EditHintInput): string | null {
|
|
|
785
1032
|
return (
|
|
786
1033
|
`fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
|
|
787
1034
|
`review-seed --files ${rel} lists them (add --callers <export> for one ` +
|
|
788
|
-
`export's callers); check before changing its shape`
|
|
1035
|
+
`export's callers); check before changing its shape (skill /lookup-before-edit)`
|
|
789
1036
|
);
|
|
790
1037
|
} catch {
|
|
791
1038
|
return null;
|
|
@@ -796,18 +1043,16 @@ export function editHintFor(opts: EditHintInput): string | null {
|
|
|
796
1043
|
//
|
|
797
1044
|
// OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
|
|
798
1045
|
// so it cannot block a turn; instead it appends an annotate to the bash tool
|
|
799
|
-
// output whenever there is a git commit with no
|
|
800
|
-
// same kind of nudge as the read hint: no block, no dedupe, every unknown →
|
|
1046
|
+
// output whenever there is a git commit with no mem row recorded for it. It is
|
|
1047
|
+
// the same kind of nudge as the read hint: no block, no dedupe, every unknown →
|
|
801
1048
|
// silent · called from the opencode plugin by direct import (like
|
|
802
1049
|
// readHintFor), no CLI subcommand because no client needs it as a subprocess
|
|
803
1050
|
// (Cursor uses its own hook-stop instead)
|
|
804
1051
|
//
|
|
805
|
-
// The text is facts only (commit list +
|
|
1052
|
+
// The text is facts only (commit list + mem status), not an estimate
|
|
806
1053
|
|
|
807
1054
|
/** Below this number of commits, the hint is unnecessary noise. */
|
|
808
1055
|
export const COMMIT_HINT_MIN_COMMITS = 1;
|
|
809
|
-
/** Cap commits shown in the hint message. */
|
|
810
|
-
const COMMIT_HINT_MAX_LIST = 5;
|
|
811
1056
|
|
|
812
1057
|
export interface CommitHintInput {
|
|
813
1058
|
command: unknown;
|
|
@@ -815,14 +1060,16 @@ export interface CommitHintInput {
|
|
|
815
1060
|
}
|
|
816
1061
|
|
|
817
1062
|
/**
|
|
818
|
-
* Nudge for bash commands containing `git commit` that produced
|
|
819
|
-
*
|
|
820
|
-
* when there is nothing to nudge about (
|
|
821
|
-
*
|
|
1063
|
+
* Nudge for bash commands containing `git commit` that produced commits with
|
|
1064
|
+
* no mem row recorded for them. Returns a one-to-two line hint string, or
|
|
1065
|
+
* null when there is nothing to nudge about (no new commits, not a git
|
|
1066
|
+
* commit command, not a git repo, no mem log to window on, any failure).
|
|
822
1067
|
*
|
|
823
|
-
* Every unknown resolves to null — a hint must never fire on a
|
|
824
|
-
*
|
|
825
|
-
*
|
|
1068
|
+
* Every unknown resolves to null — a hint must never fire on a guess. The
|
|
1069
|
+
* work is cheap: one git rev-parse + one mem-log read + one git log.
|
|
1070
|
+
* The window is the mem log, never the verdict ledger: gate events have no
|
|
1071
|
+
* writer left (PLAN-verdict-to-mem), so the last verdict is frozen — fresh
|
|
1072
|
+
* machines listed their entire repo history as unrecorded.
|
|
826
1073
|
*/
|
|
827
1074
|
export function commitHintFor(opts: CommitHintInput): string | null {
|
|
828
1075
|
try {
|
|
@@ -833,51 +1080,41 @@ export function commitHintFor(opts: CommitHintInput): string | null {
|
|
|
833
1080
|
const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
|
|
834
1081
|
if (!worktree) return null;
|
|
835
1082
|
|
|
836
|
-
// Window = commits
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
),
|
|
857
|
-
)
|
|
858
|
-
: null;
|
|
859
|
-
|
|
860
|
-
const log = since
|
|
861
|
-
? git(["log", "--since", `${since} +0000`, "--format=%h %s"], worktree)
|
|
862
|
-
: git(["log", "--format=%h %s"], worktree);
|
|
1083
|
+
// Window = commits newer than the last mem row. No mem log in scope =
|
|
1084
|
+
// no window to measure — stay silent (same as the stop hook: a hint must
|
|
1085
|
+
// never fire on a guess, and the whole-history fire on fresh machines is
|
|
1086
|
+
// what this replaced).
|
|
1087
|
+
let memLastTs: string | null = null;
|
|
1088
|
+
try {
|
|
1089
|
+
memLastTs = readMemLog(worktree).rows[0]?.ts ?? null;
|
|
1090
|
+
} catch {
|
|
1091
|
+
memLastTs = null;
|
|
1092
|
+
}
|
|
1093
|
+
if (!memLastTs) return null;
|
|
1094
|
+
// git's --since is inclusive to the second, and a commit can land in the
|
|
1095
|
+
// same UTC second as the row recorded for it — bump by 1s so recorded
|
|
1096
|
+
// work isn't re-flagged.
|
|
1097
|
+
const since = utcStamp(new Date(hookTsMs(memLastTs) + 1000));
|
|
1098
|
+
|
|
1099
|
+
const log = git(
|
|
1100
|
+
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
1101
|
+
worktree,
|
|
1102
|
+
);
|
|
863
1103
|
const commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
864
1104
|
if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
|
|
865
1105
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const prefixed =
|
|
878
|
-
.split("\n")
|
|
879
|
-
.map((l) => `fapony: ${l}`)
|
|
880
|
-
.join("\n");
|
|
1106
|
+
// Build the nudge directly — commitHintFor is annotate-only (informational),
|
|
1107
|
+
// while decideStop is blocking enforcement. They serve different purposes.
|
|
1108
|
+
const lines: string[] = [
|
|
1109
|
+
`${commitList.length} commit(s) since last mem row (${memLastTs.slice(0, 10)}) — record a mem row for this work.`,
|
|
1110
|
+
];
|
|
1111
|
+
for (const c of commitList.slice(0, 5)) lines.push(` ${c}`);
|
|
1112
|
+
if (commitList.length > 5) lines.push(` … +${commitList.length - 5} more`);
|
|
1113
|
+
lines.push(
|
|
1114
|
+
`fapony mem add <decision|bug|note> "what happened" --files <files> ${worktree}/.fapony/plan/PLAN.md`,
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
const prefixed = lines.map((l) => `fapony: ${l}`).join("\n");
|
|
881
1118
|
return prefixed;
|
|
882
1119
|
} catch {
|
|
883
1120
|
return null; // any failure = no hint
|
|
@@ -996,7 +1233,10 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
996
1233
|
|
|
997
1234
|
/** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
|
|
998
1235
|
* No permissionDecision ever — the edit always proceeds. Fires once per
|
|
999
|
-
* (session, file); the dedupe lives inside editHintFor.
|
|
1236
|
+
* (session, file); the dedupe lives inside editHintFor.
|
|
1237
|
+
*
|
|
1238
|
+
* Also attaches mem/debt context lines (same as read hint) — the moment
|
|
1239
|
+
* paying down debt is worth tokens is when the file is already open. */
|
|
1000
1240
|
export async function cmdHookEditHint(): Promise<void> {
|
|
1001
1241
|
try {
|
|
1002
1242
|
const raw = JSON.parse(await Bun.stdin.text()) as {
|
|
@@ -1011,13 +1251,22 @@ export async function cmdHookEditHint(): Promise<void> {
|
|
|
1011
1251
|
const filePath = raw.tool_input?.file_path;
|
|
1012
1252
|
// One edit log per session — same identity as the read hint.
|
|
1013
1253
|
const session = raw.transcript_path ?? raw.session_id;
|
|
1254
|
+
const parts: string[] = [];
|
|
1014
1255
|
const hint = editHintFor({ filePath, cwd, session });
|
|
1015
|
-
if (hint)
|
|
1256
|
+
if (hint) parts.push(hint);
|
|
1257
|
+
// Attach mem/debt context (same as read hint — annotate only, cap 5 lines)
|
|
1258
|
+
const ctx = readContextData(filePath, cwd);
|
|
1259
|
+
if (ctx) {
|
|
1260
|
+
for (const line of [...ctx.debtLines, ...ctx.memLines]) {
|
|
1261
|
+
parts.push(line);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (parts.length > 0) {
|
|
1016
1265
|
console.log(
|
|
1017
1266
|
JSON.stringify({
|
|
1018
1267
|
hookSpecificOutput: {
|
|
1019
1268
|
hookEventName: "PreToolUse",
|
|
1020
|
-
additionalContext:
|
|
1269
|
+
additionalContext: parts.join("\n"),
|
|
1021
1270
|
},
|
|
1022
1271
|
}),
|
|
1023
1272
|
);
|