fapony 0.2.0 → 0.3.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 +50 -15
- package/fapony.ts +12 -3
- package/package.json +1 -1
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/review-pony/SKILL.md +11 -3
- package/src/analyze.ts +162 -4
- package/src/conventions-seed.ts +3 -2
- package/src/db/defaults.ts +13 -5
- package/src/db/getters.ts +8 -6
- package/src/db/load.ts +2 -2
- package/src/db/types.ts +1 -3
- 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/digest/collect.ts +3 -2
- package/src/hook.ts +412 -17
- package/src/init-mem.ts +5 -5
- package/src/init.ts +6 -5
- package/src/install/claude.ts +51 -3
- package/src/install/codex.ts +144 -13
- package/src/install/opencode.ts +209 -1
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +4 -3
- package/src/mcp/evidence.ts +14 -2
- package/src/mcp/tools/index.ts +13 -28
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +12 -63
- package/src/mem/commands/read.ts +18 -1
- package/src/mem/store.ts +6 -4
- package/src/memory.ts +23 -13
- package/src/plan-seed.ts +7 -6
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +4 -3
- package/src/stats/data.ts +6 -18
- package/src/debt.ts +0 -806
- package/src/mcp/tools/usage.ts +0 -211
package/src/hook.ts
CHANGED
|
@@ -26,11 +26,12 @@ import {
|
|
|
26
26
|
statSync,
|
|
27
27
|
} from "node:fs";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import { basename, join, relative, resolve } from "node:path";
|
|
30
|
-
import { collectSourceFiles, SCAN_EXTS } from "./analyze.js";
|
|
29
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
30
|
+
import { buildGraphCached, collectSourceFiles, SCAN_EXTS } from "./analyze.js";
|
|
31
31
|
import { openDb } from "./db/index.js";
|
|
32
|
-
import { debtForFile, loadConventions } from "./debt.js";
|
|
33
|
-
import {
|
|
32
|
+
import { debtForFile, loadConventions } from "./debt/index.js";
|
|
33
|
+
import { detectTestRunner } from "./detect.js";
|
|
34
|
+
import { readMemLog, whereMemDir } from "./memory.js";
|
|
34
35
|
|
|
35
36
|
// --- Hint-fire log (PLAN-feedback-surface chunk 1) ---
|
|
36
37
|
//
|
|
@@ -63,7 +64,7 @@ export function hintLogPath(worktree: string): string {
|
|
|
63
64
|
export interface HintFireRow {
|
|
64
65
|
ts: string;
|
|
65
66
|
worktree: string;
|
|
66
|
-
surface: "read" | "debt" | "mem" | "commit";
|
|
67
|
+
surface: "read" | "debt" | "mem" | "commit" | "edit";
|
|
67
68
|
file: string | null;
|
|
68
69
|
count: number;
|
|
69
70
|
ids?: string[];
|
|
@@ -96,7 +97,13 @@ export function recordHintFire(row: HintFireRow): void {
|
|
|
96
97
|
|
|
97
98
|
export interface HintImpact {
|
|
98
99
|
fired: number;
|
|
99
|
-
by_surface: {
|
|
100
|
+
by_surface: {
|
|
101
|
+
read: number;
|
|
102
|
+
debt: number;
|
|
103
|
+
mem: number;
|
|
104
|
+
commit: number;
|
|
105
|
+
edit: number;
|
|
106
|
+
};
|
|
100
107
|
debt: { shown: number; resolved: number; unknown: number };
|
|
101
108
|
window: string | null;
|
|
102
109
|
}
|
|
@@ -115,7 +122,7 @@ export function computeHintImpact(
|
|
|
115
122
|
const dir = hintLogDir();
|
|
116
123
|
const impact: HintImpact = {
|
|
117
124
|
fired: 0,
|
|
118
|
-
by_surface: { read: 0, debt: 0, mem: 0, commit: 0 },
|
|
125
|
+
by_surface: { read: 0, debt: 0, mem: 0, commit: 0, edit: 0 },
|
|
119
126
|
debt: { shown: 0, resolved: 0, unknown: 0 },
|
|
120
127
|
window: since ?? null,
|
|
121
128
|
};
|
|
@@ -212,9 +219,13 @@ export interface RawStopPayload {
|
|
|
212
219
|
conversation_id?: string;
|
|
213
220
|
loop_count?: number;
|
|
214
221
|
status?: string;
|
|
222
|
+
// Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
|
|
223
|
+
session_id?: string;
|
|
224
|
+
model?: string;
|
|
225
|
+
permission_mode?: string;
|
|
215
226
|
}
|
|
216
227
|
|
|
217
|
-
export type StopClient = "claude" | "cursor";
|
|
228
|
+
export type StopClient = "claude" | "cursor" | "codex";
|
|
218
229
|
|
|
219
230
|
export interface NormalizedStopInput {
|
|
220
231
|
client: StopClient;
|
|
@@ -245,6 +256,8 @@ export function decideStop(opts: {
|
|
|
245
256
|
verdicts: number;
|
|
246
257
|
commitList?: string[];
|
|
247
258
|
memLastTs?: string | null;
|
|
259
|
+
/** Logs that exist in the repo but are out of scope from this worktree. */
|
|
260
|
+
memCandidates?: string[];
|
|
248
261
|
}): string | null {
|
|
249
262
|
if (opts.stopHookActive) return null; // already blocked once — let it end
|
|
250
263
|
if (!opts.worktree) return null;
|
|
@@ -262,16 +275,30 @@ export function decideStop(opts: {
|
|
|
262
275
|
lines.push(
|
|
263
276
|
`mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
|
|
264
277
|
);
|
|
278
|
+
} else if (opts.memCandidates?.length) {
|
|
279
|
+
// "nothing recorded" would be a lie: the log exists, it is just not in
|
|
280
|
+
// scope from here (monorepo — the log lives in the app dir). Say where.
|
|
281
|
+
lines.push(
|
|
282
|
+
`mem: no log in scope from ${opts.worktree} — found ` +
|
|
283
|
+
`${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
|
|
284
|
+
);
|
|
265
285
|
} else {
|
|
266
286
|
lines.push("mem: no rows at all — nothing recorded in this project yet");
|
|
267
287
|
}
|
|
288
|
+
const runner = detectTestRunner(opts.worktree);
|
|
289
|
+
const verifyLine = runner
|
|
290
|
+
? `If you did not run \`${runner.typecheckCmd ? `${runner.typecheckCmd} and ` : ""}${runner.testCmd}\` to a real exit code, ` +
|
|
291
|
+
`the honest verdict is uncertain, not pass. `
|
|
292
|
+
: `If you did not run this repo's typecheck and test suite to a real exit code, ` +
|
|
293
|
+
`the honest verdict is uncertain, not pass. `;
|
|
294
|
+
|
|
268
295
|
lines.push(
|
|
269
296
|
`Call verdict_submit before ending: worktree must be the absolute path above, ` +
|
|
270
297
|
`regime is one of code|fix|review|plan|inquiry|test, and the note must stand alone ` +
|
|
271
298
|
`(it is read months from now with no access to this conversation). ` +
|
|
272
299
|
`Grade what actually happened — pass-family when it held up, fail if the first ` +
|
|
273
|
-
`attempt was wrong, uncertain when you could not verify it.
|
|
274
|
-
`row (decision/bug/note) is your call — not every unit needs one.`,
|
|
300
|
+
`attempt was wrong, uncertain when you could not verify it. ${verifyLine}` +
|
|
301
|
+
`What deserves a mem row (decision/bug/note) is your call — not every unit needs one.`,
|
|
275
302
|
);
|
|
276
303
|
return lines.join("\n");
|
|
277
304
|
}
|
|
@@ -319,11 +346,32 @@ export function isCursorPayload(raw: RawStopPayload): boolean {
|
|
|
319
346
|
);
|
|
320
347
|
}
|
|
321
348
|
|
|
322
|
-
/**
|
|
349
|
+
/** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
|
|
350
|
+
export function isCodexPayload(raw: RawStopPayload): boolean {
|
|
351
|
+
return (
|
|
352
|
+
typeof raw.permission_mode === "string" ||
|
|
353
|
+
(typeof raw.model === "string" && !isCursorPayload(raw))
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Field-mapping only — all three clients feed the same decideStop below. */
|
|
323
358
|
export function normalizeStopInput(
|
|
324
359
|
raw: RawStopPayload,
|
|
325
360
|
home: string,
|
|
326
361
|
): NormalizedStopInput {
|
|
362
|
+
if (isCodexPayload(raw)) {
|
|
363
|
+
// Codex: cwd is the session working directory; stop_hook_active means
|
|
364
|
+
// the hook already fired once (same semantics as Claude).
|
|
365
|
+
return {
|
|
366
|
+
client: "codex",
|
|
367
|
+
cwd: raw.cwd ?? process.cwd(),
|
|
368
|
+
transcriptPath:
|
|
369
|
+
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
370
|
+
? raw.transcript_path
|
|
371
|
+
: null,
|
|
372
|
+
stopHookActive: raw.stop_hook_active === true,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
327
375
|
if (isCursorPayload(raw)) {
|
|
328
376
|
const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
|
|
329
377
|
let transcriptPath =
|
|
@@ -350,12 +398,74 @@ export function normalizeStopInput(
|
|
|
350
398
|
};
|
|
351
399
|
}
|
|
352
400
|
|
|
353
|
-
/** Claude blocks with decision:block; Cursor
|
|
354
|
-
*
|
|
401
|
+
/** Claude blocks with decision:block; Cursor auto-submits as followup_message;
|
|
402
|
+
* Codex continues with decision:block + reason (continue:false would take
|
|
403
|
+
* precedence and end the turn instead — Codex Hooks, Stop section). */
|
|
355
404
|
export function stopOutput(client: StopClient, reason: string): string {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
405
|
+
if (client === "cursor") return JSON.stringify({ followup_message: reason });
|
|
406
|
+
if (client === "codex") return JSON.stringify({ decision: "block", reason });
|
|
407
|
+
return JSON.stringify({ decision: "block", reason });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- Block dedupe (one block per session + worktree) ---
|
|
411
|
+
//
|
|
412
|
+
// stop_hook_active only suppresses the block that fires *immediately* after
|
|
413
|
+
// one. Every later turn that still carries ungraded commits blocks again, so
|
|
414
|
+
// a session that keeps committing gets the same paragraph 4-5 times. The
|
|
415
|
+
// first block already delivered it; the repeats add noise, not force (rule 9
|
|
416
|
+
// — the forcing happens once, and the agent that ignored it once is not
|
|
417
|
+
// persuaded by the fifth copy).
|
|
418
|
+
//
|
|
419
|
+
// Keyed by the transcript path, which is already the session identity the
|
|
420
|
+
// commit window is measured from — no session field to thread through.
|
|
421
|
+
|
|
422
|
+
const STOP_BLOCK_DIR = "stop-block";
|
|
423
|
+
|
|
424
|
+
interface StopBlockRow {
|
|
425
|
+
ts: string;
|
|
426
|
+
worktree: string;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Absolute path of a session's block log — may not exist. */
|
|
430
|
+
export function stopBlockPath(session: string): string {
|
|
431
|
+
const base =
|
|
432
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
433
|
+
return join(base, STOP_BLOCK_DIR, `${sessionKey(session)}.jsonl`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* True when this session already blocked for this worktree — the caller then
|
|
438
|
+
* lets the turn end. Records the block when it has not. Every unknown (no
|
|
439
|
+
* session identity, unwritable state dir) resolves to false: a dedupe that
|
|
440
|
+
* guesses must fail towards blocking, never towards silence.
|
|
441
|
+
*/
|
|
442
|
+
export function stopBlockedBefore(
|
|
443
|
+
session: string | null,
|
|
444
|
+
worktree: string,
|
|
445
|
+
): boolean {
|
|
446
|
+
if (!session) return false;
|
|
447
|
+
const path = stopBlockPath(session);
|
|
448
|
+
try {
|
|
449
|
+
if (existsSync(path)) {
|
|
450
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
451
|
+
if (!line) continue;
|
|
452
|
+
try {
|
|
453
|
+
if ((JSON.parse(line) as StopBlockRow).worktree === worktree) {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
// a torn line must not lose the rest of the log
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const dir = join(path, "..");
|
|
462
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
463
|
+
const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
|
|
464
|
+
appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
465
|
+
} catch {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
359
469
|
}
|
|
360
470
|
|
|
361
471
|
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
@@ -370,6 +480,7 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
370
480
|
// died — commits from those turns are still caught at the next completed
|
|
371
481
|
// stop (the window is the conversation transcript's birthtime).
|
|
372
482
|
if (client === "cursor" && raw.status !== "completed") return;
|
|
483
|
+
// Codex: no status guard needed — Stop fires at turn end unconditionally.
|
|
373
484
|
|
|
374
485
|
const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
|
|
375
486
|
|
|
@@ -388,6 +499,7 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
388
499
|
let commitList: string[] = [];
|
|
389
500
|
let verdicts = 0;
|
|
390
501
|
let memLastTs: string | null = null;
|
|
502
|
+
let memCandidates: string[] = [];
|
|
391
503
|
if (worktree && since) {
|
|
392
504
|
const log = git(
|
|
393
505
|
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
@@ -409,6 +521,8 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
409
521
|
try {
|
|
410
522
|
const mem = readMemLog(worktree);
|
|
411
523
|
memLastTs = mem.rows[0]?.ts ?? null;
|
|
524
|
+
if (!memLastTs)
|
|
525
|
+
memCandidates = whereMemDir(worktree).candidates ?? [];
|
|
412
526
|
} catch {
|
|
413
527
|
memLastTs = null;
|
|
414
528
|
}
|
|
@@ -422,7 +536,16 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
422
536
|
verdicts,
|
|
423
537
|
commitList,
|
|
424
538
|
memLastTs,
|
|
539
|
+
memCandidates,
|
|
425
540
|
});
|
|
541
|
+
// Already blocked for this worktree in this session — say it once.
|
|
542
|
+
if (
|
|
543
|
+
reason &&
|
|
544
|
+
worktree &&
|
|
545
|
+
stopBlockedBefore(norm.transcriptPath, worktree)
|
|
546
|
+
) {
|
|
547
|
+
reason = null;
|
|
548
|
+
}
|
|
426
549
|
} catch {
|
|
427
550
|
reason = null; // any failure = allow the turn to end
|
|
428
551
|
}
|
|
@@ -430,6 +553,78 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
430
553
|
if (reason) console.log(stopOutput(client, reason));
|
|
431
554
|
}
|
|
432
555
|
|
|
556
|
+
// --- Session start (kickoff as context, not as a thing to remember) ---
|
|
557
|
+
//
|
|
558
|
+
// `fapony mem kickoff` is the one command that pays for itself at session
|
|
559
|
+
// open — what is open, what is stale, what the last rows touched. Asking the
|
|
560
|
+
// agent to run it measured as not enough (rule 9), and an MCP tool would pay
|
|
561
|
+
// schema rent in every session of every client to save one bash round
|
|
562
|
+
// (rule 13). A SessionStart hook is neither: zero rent, and it fires whether
|
|
563
|
+
// or not anyone remembers.
|
|
564
|
+
//
|
|
565
|
+
// Runs the CLI in a subprocess rather than calling cmdKickoff: kickoff prints
|
|
566
|
+
// to stdout and exits on bad input, both of which would be this hook's stdout.
|
|
567
|
+
|
|
568
|
+
/** Cap on injected context — kickoff is short, a broken repo's output is not. */
|
|
569
|
+
export const SESSION_START_MAX_CHARS = 4_000;
|
|
570
|
+
|
|
571
|
+
const TRUNCATED = "… truncated — run `fapony mem kickoff` for the rest";
|
|
572
|
+
|
|
573
|
+
/** Trim to whole lines, keeping the marker's line boundary intact. */
|
|
574
|
+
function headLines(text: string, max: number): string {
|
|
575
|
+
const cut = text.slice(0, max);
|
|
576
|
+
const lastNl = cut.lastIndexOf("\n");
|
|
577
|
+
return (lastNl > 0 ? cut.slice(0, lastNl) : cut).trimEnd();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Trim to whole lines within the cap, with an honest truncation marker.
|
|
582
|
+
*
|
|
583
|
+
* "## next up" is kickoff's last section and its most actionable one, so a
|
|
584
|
+
* plain head-cut drops exactly the part worth injecting in a repo with a long
|
|
585
|
+
* open list (measured here: 92 rows, the cut landed mid-history). Keep it and
|
|
586
|
+
* spend the rest of the budget on the head.
|
|
587
|
+
*/
|
|
588
|
+
export function capContext(
|
|
589
|
+
text: string,
|
|
590
|
+
max = SESSION_START_MAX_CHARS,
|
|
591
|
+
): string {
|
|
592
|
+
if (text.length <= max) return text;
|
|
593
|
+
const at = text.lastIndexOf("\n## next up");
|
|
594
|
+
const tail = at > 0 ? text.slice(at).trimEnd() : "";
|
|
595
|
+
if (tail && tail.length < max / 2) {
|
|
596
|
+
return `${headLines(text, max - tail.length)}\n${TRUNCATED}\n${tail}`;
|
|
597
|
+
}
|
|
598
|
+
return `${headLines(text, max)}\n${TRUNCATED}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** SessionStart hook: inject `fapony mem kickoff` output as context. */
|
|
602
|
+
export async function cmdHookSessionStart(): Promise<void> {
|
|
603
|
+
try {
|
|
604
|
+
const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
|
|
605
|
+
const cwd = raw.cwd ?? process.cwd();
|
|
606
|
+
// No mem log in scope = nothing to say. Silence beats "no rows yet".
|
|
607
|
+
if (!whereMemDir(cwd).dir) return;
|
|
608
|
+
const p = Bun.spawnSync([process.execPath, Bun.main, "mem", "kickoff"], {
|
|
609
|
+
cwd,
|
|
610
|
+
stdout: "pipe",
|
|
611
|
+
stderr: "pipe",
|
|
612
|
+
});
|
|
613
|
+
const out = p.stdout.toString().trim();
|
|
614
|
+
if (p.exitCode !== 0 || !out) return;
|
|
615
|
+
console.log(
|
|
616
|
+
JSON.stringify({
|
|
617
|
+
hookSpecificOutput: {
|
|
618
|
+
hookEventName: "SessionStart",
|
|
619
|
+
additionalContext: capContext(out),
|
|
620
|
+
},
|
|
621
|
+
}),
|
|
622
|
+
);
|
|
623
|
+
} catch {
|
|
624
|
+
// any failure = no context, never a broken session start
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
433
628
|
// --- Read hint (PreToolUse annotate — never block, never dedupe) ---
|
|
434
629
|
//
|
|
435
630
|
// Reading a large file in full is where an agent spends tokens without
|
|
@@ -490,7 +685,7 @@ export function readHintFor(opts: ReadHintInput): string | null {
|
|
|
490
685
|
return (
|
|
491
686
|
`fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
|
|
492
687
|
`returns exports with line numbers, importers, and signatures first ` +
|
|
493
|
-
`(${READ_HINT_MEASURED})`
|
|
688
|
+
`(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
|
|
494
689
|
);
|
|
495
690
|
} catch {
|
|
496
691
|
return null;
|
|
@@ -633,6 +828,132 @@ export function rereadHintFor(opts: RereadHintInput): string | null {
|
|
|
633
828
|
}
|
|
634
829
|
}
|
|
635
830
|
|
|
831
|
+
// --- Edit hint (PreToolUse annotate — importer count + once-per-session dedupe) ---
|
|
832
|
+
//
|
|
833
|
+
// Editing a file that has importers can silently break its consumers (measured:
|
|
834
|
+
// 17.9% of changed nodes over 30 commits had a 1-hop blast radius; ~12-16% of
|
|
835
|
+
// all-time fail rows were producer/consumer mismatches). The hint is a fact —
|
|
836
|
+
// the importer count plus the review-seed command that lists them — never a
|
|
837
|
+
// judgment about whether the edit is safe, and never a block.
|
|
838
|
+
//
|
|
839
|
+
// Dedupe is per (session, file): the first edit to a file fires, repeats stay
|
|
840
|
+
// silent. The track log reuses the read-track session mechanism (sessionKey,
|
|
841
|
+
// one jsonl per session) but lives in its own dir — sharing read-track's file
|
|
842
|
+
// would make an Edit look like a Read and falsely trip the re-read hint. Like
|
|
843
|
+
// rereadHintFor the track write happens inside this function (the caller-side
|
|
844
|
+
// rule covers recordHintFire, not dedupe state); unlike it there is no mtime
|
|
845
|
+
// comparison — an edit that moves mtime is still the same file in the same
|
|
846
|
+
// session, and repeating the count buys nothing.
|
|
847
|
+
|
|
848
|
+
const EDIT_TRACK_DIR = "edit-track";
|
|
849
|
+
|
|
850
|
+
export interface EditTrackRow {
|
|
851
|
+
ts: string;
|
|
852
|
+
path: string;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** Directory holding one edit log per session. */
|
|
856
|
+
function editTrackDir(): string {
|
|
857
|
+
const base =
|
|
858
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
859
|
+
return join(base, EDIT_TRACK_DIR);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Absolute path of a session's edit log — may not exist. */
|
|
863
|
+
export function editTrackPath(session: string): string {
|
|
864
|
+
return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function editTrackPaths(session: string): Set<string> {
|
|
868
|
+
const p = editTrackPath(session);
|
|
869
|
+
if (!existsSync(p)) return new Set();
|
|
870
|
+
const out = new Set<string>();
|
|
871
|
+
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
872
|
+
if (!line) continue;
|
|
873
|
+
try {
|
|
874
|
+
const r = JSON.parse(line) as EditTrackRow;
|
|
875
|
+
if (typeof r.path === "string") out.add(r.path);
|
|
876
|
+
} catch {
|
|
877
|
+
// a torn line must not lose the rest of the log
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function appendEditTrackRow(session: string, row: EditTrackRow): void {
|
|
884
|
+
const dir = editTrackDir();
|
|
885
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
886
|
+
appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export interface EditHintInput {
|
|
890
|
+
filePath: unknown;
|
|
891
|
+
cwd: string;
|
|
892
|
+
/**
|
|
893
|
+
* Session identity — transcript path (Claude) or session id. Without it the
|
|
894
|
+
* hint still fires (the importer fact holds) but cannot dedupe.
|
|
895
|
+
*/
|
|
896
|
+
session?: unknown;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Factual one-liner for editing a source file that has importers, or null.
|
|
901
|
+
* Every unknown (no path, non-source ext, new/unsaved file, outside the
|
|
902
|
+
* worktree, no git repo, graph failure) resolves to null — a hint must never
|
|
903
|
+
* fire on a guess. Files with importers are checked before the dedupe log is
|
|
904
|
+
* touched, so a file nobody imports never writes a track row.
|
|
905
|
+
*/
|
|
906
|
+
export function editHintFor(opts: EditHintInput): string | null {
|
|
907
|
+
try {
|
|
908
|
+
if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
|
|
909
|
+
const dot = opts.filePath.lastIndexOf(".");
|
|
910
|
+
// SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
|
|
911
|
+
if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
|
|
912
|
+
// review-seed is a git command — outside a repo the hint would lie.
|
|
913
|
+
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
914
|
+
cwd: opts.cwd,
|
|
915
|
+
stdout: "pipe",
|
|
916
|
+
stderr: "pipe",
|
|
917
|
+
});
|
|
918
|
+
if (git.exitCode !== 0) return null;
|
|
919
|
+
// macOS /var → /private/var: normalize both sides before comparing.
|
|
920
|
+
const worktree = realpathSync(git.stdout.toString().trim());
|
|
921
|
+
const abs = (() => {
|
|
922
|
+
const p = opts.filePath.startsWith("/")
|
|
923
|
+
? opts.filePath
|
|
924
|
+
: join(opts.cwd, opts.filePath);
|
|
925
|
+
try {
|
|
926
|
+
return realpathSync(p);
|
|
927
|
+
} catch {
|
|
928
|
+
return null; // new file — nothing imports it yet
|
|
929
|
+
}
|
|
930
|
+
})();
|
|
931
|
+
if (!abs) return null;
|
|
932
|
+
const rel = relative(worktree, abs).split(sep).join("/");
|
|
933
|
+
if (rel.startsWith("..") || rel === "") return null;
|
|
934
|
+
|
|
935
|
+
const importers = buildGraphCached(worktree).dependents.get(rel);
|
|
936
|
+
if (!importers || importers.size === 0) return null;
|
|
937
|
+
|
|
938
|
+
if (typeof opts.session === "string" && opts.session !== "") {
|
|
939
|
+
if (editTrackPaths(opts.session).has(abs)) return null;
|
|
940
|
+
appendEditTrackRow(opts.session, {
|
|
941
|
+
ts: new Date().toISOString(),
|
|
942
|
+
path: abs,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const n = importers.size;
|
|
947
|
+
return (
|
|
948
|
+
`fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
|
|
949
|
+
`review-seed --files ${rel} lists them (add --callers <export> for one ` +
|
|
950
|
+
`export's callers); check before changing its shape (skill /lookup-before-edit)`
|
|
951
|
+
);
|
|
952
|
+
} catch {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
636
957
|
// --- Commit hint (tool.execute.after — annotate only, never block) ---
|
|
637
958
|
//
|
|
638
959
|
// OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
|
|
@@ -835,6 +1156,80 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
835
1156
|
}
|
|
836
1157
|
}
|
|
837
1158
|
|
|
1159
|
+
/** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
|
|
1160
|
+
* No permissionDecision ever — the edit always proceeds. Fires once per
|
|
1161
|
+
* (session, file); the dedupe lives inside editHintFor. */
|
|
1162
|
+
export async function cmdHookEditHint(): Promise<void> {
|
|
1163
|
+
try {
|
|
1164
|
+
const raw = JSON.parse(await Bun.stdin.text()) as {
|
|
1165
|
+
cwd?: string;
|
|
1166
|
+
transcript_path?: string;
|
|
1167
|
+
session_id?: string;
|
|
1168
|
+
tool_input?: {
|
|
1169
|
+
file_path?: unknown;
|
|
1170
|
+
};
|
|
1171
|
+
};
|
|
1172
|
+
const cwd = raw.cwd ?? process.cwd();
|
|
1173
|
+
const filePath = raw.tool_input?.file_path;
|
|
1174
|
+
// One edit log per session — same identity as the read hint.
|
|
1175
|
+
const session = raw.transcript_path ?? raw.session_id;
|
|
1176
|
+
const hint = editHintFor({ filePath, cwd, session });
|
|
1177
|
+
if (hint) {
|
|
1178
|
+
console.log(
|
|
1179
|
+
JSON.stringify({
|
|
1180
|
+
hookSpecificOutput: {
|
|
1181
|
+
hookEventName: "PreToolUse",
|
|
1182
|
+
additionalContext: hint,
|
|
1183
|
+
},
|
|
1184
|
+
}),
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// --- hint-fire log (PLAN-edit-importer-hint chunk 3) ---
|
|
1189
|
+
// After output — best-effort, never block the hint.
|
|
1190
|
+
if (hint) {
|
|
1191
|
+
try {
|
|
1192
|
+
const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
1193
|
+
cwd,
|
|
1194
|
+
stdout: "pipe",
|
|
1195
|
+
stderr: "pipe",
|
|
1196
|
+
});
|
|
1197
|
+
if (g.exitCode === 0) {
|
|
1198
|
+
const worktree = realpathSync(g.stdout.toString().trim());
|
|
1199
|
+
const abs =
|
|
1200
|
+
typeof filePath === "string"
|
|
1201
|
+
? (() => {
|
|
1202
|
+
try {
|
|
1203
|
+
return realpathSync(
|
|
1204
|
+
filePath.startsWith("/")
|
|
1205
|
+
? filePath
|
|
1206
|
+
: join(worktree, filePath),
|
|
1207
|
+
);
|
|
1208
|
+
} catch {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
})()
|
|
1212
|
+
: null;
|
|
1213
|
+
const rel = abs
|
|
1214
|
+
? relative(worktree, abs).split("\\").join("/")
|
|
1215
|
+
: null;
|
|
1216
|
+
recordHintFire({
|
|
1217
|
+
ts: new Date().toISOString(),
|
|
1218
|
+
worktree,
|
|
1219
|
+
surface: "edit",
|
|
1220
|
+
file: rel && !rel.startsWith("..") ? rel : null,
|
|
1221
|
+
count: 1,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
} catch {
|
|
1225
|
+
// best-effort — swallow
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
} catch {
|
|
1229
|
+
// any failure = no hint; a hook must never block an edit over a hint
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
838
1233
|
// --- Debt + mem context (PLAN-convention-debt chunk 4) ---
|
|
839
1234
|
//
|
|
840
1235
|
// The one moment paying down debt is worth tokens is when the file is already
|
package/src/init-mem.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
rmSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
import { loadConfig } from "./db/index.js";
|
|
16
|
+
import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
|
|
17
17
|
|
|
18
18
|
export function copyDir(src: string, dest: string): string[] {
|
|
19
19
|
mkdirSync(dest, { recursive: true });
|
|
@@ -62,8 +62,8 @@ export function cmdInitMem(args: string[]): void {
|
|
|
62
62
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
63
63
|
if (
|
|
64
64
|
entry.isDirectory() &&
|
|
65
|
-
|
|
66
|
-
entry.name
|
|
65
|
+
entry.name !== "node_modules" &&
|
|
66
|
+
(!entry.name.startsWith(".") || entry.name === FAPONY_DIR)
|
|
67
67
|
) {
|
|
68
68
|
walk(join(dir, entry.name), depth + 1);
|
|
69
69
|
}
|
|
@@ -95,7 +95,7 @@ export function cmdInitMem(args: string[]): void {
|
|
|
95
95
|
`keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
|
|
96
96
|
);
|
|
97
97
|
console.log(
|
|
98
|
-
` move them under ${
|
|
98
|
+
` move them under ${DEFAULT_MEM_DIR}/, or re-run with --force to delete`,
|
|
99
99
|
);
|
|
100
100
|
kept++;
|
|
101
101
|
continue;
|
|
@@ -109,7 +109,7 @@ export function cmdInitMem(args: string[]): void {
|
|
|
109
109
|
console.log(
|
|
110
110
|
`\nremoved ${removed} legacy .memory/ director${removed === 1 ? "y" : "ies"}${
|
|
111
111
|
kept > 0
|
|
112
|
-
? ` · kept ${kept} with logs — move them under
|
|
112
|
+
? ` · kept ${kept} with logs — move them under ${DEFAULT_MEM_DIR}/, then re-run`
|
|
113
113
|
: ""
|
|
114
114
|
}`,
|
|
115
115
|
);
|
package/src/init.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type Config,
|
|
18
18
|
doneDir,
|
|
19
19
|
evidenceFile,
|
|
20
|
+
FAPONY_DIR,
|
|
20
21
|
memoryDir,
|
|
21
22
|
planDir,
|
|
22
23
|
specDir,
|
|
@@ -108,7 +109,7 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
108
109
|
mkdirSync(targetPath, { recursive: true });
|
|
109
110
|
|
|
110
111
|
// --- .fapony/ marker ---
|
|
111
|
-
const faponyDir = join(targetPath,
|
|
112
|
+
const faponyDir = join(targetPath, FAPONY_DIR);
|
|
112
113
|
if (existsSync(faponyDir)) {
|
|
113
114
|
throw new Error(
|
|
114
115
|
`${faponyDir} already exists — delete it first if you want a fresh scaffold.`,
|
|
@@ -142,7 +143,7 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
142
143
|
writeFileSync(evidencePath, EVIDENCE_JSON);
|
|
143
144
|
|
|
144
145
|
// --- plan/ spec/ .memory/ — all under .fapony/ ---
|
|
145
|
-
const planDirAbs = join(targetPath, planDir(
|
|
146
|
+
const planDirAbs = join(targetPath, planDir());
|
|
146
147
|
if (existsSync(planDirAbs)) {
|
|
147
148
|
throw new Error(`${planDirAbs} already exists — not overwriting.`);
|
|
148
149
|
}
|
|
@@ -156,7 +157,7 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
156
157
|
mkdirSync(doneDirAbs, { recursive: true });
|
|
157
158
|
|
|
158
159
|
// --- spec/ ---
|
|
159
|
-
const specDirAbs = join(targetPath, specDir(
|
|
160
|
+
const specDirAbs = join(targetPath, specDir());
|
|
160
161
|
if (existsSync(specDirAbs)) {
|
|
161
162
|
throw new Error(`${specDirAbs} already exists — not overwriting.`);
|
|
162
163
|
}
|
|
@@ -177,9 +178,9 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
177
178
|
console.log(
|
|
178
179
|
` .fapony/ — project dir (plans, specs, memory, evidence)`,
|
|
179
180
|
);
|
|
180
|
-
console.log(` ${planDir(
|
|
181
|
+
console.log(` ${planDir()}/ — live plan files`);
|
|
181
182
|
console.log(` ${doneDir(config)}/ — shipped plans (archive)`);
|
|
182
|
-
console.log(` ${specDir(
|
|
183
|
+
console.log(` ${specDir()}/ — spec files`);
|
|
183
184
|
console.log(
|
|
184
185
|
` ${evidenceFile(config)} — allowlist for 'fapony report' (edit the cmds!)`,
|
|
185
186
|
);
|