fapony 0.2.1 → 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 +9 -8
- package/fapony.ts +10 -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/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/hook.ts +168 -6
- package/src/install/claude.ts +14 -0
- package/src/install/codex.ts +58 -19
- package/src/install/opencode.ts +98 -0
- package/src/lint-baseline.ts +2 -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/memory.ts +17 -8
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +1 -1
- package/src/debt.ts +0 -811
- package/src/mcp/tools/usage.ts +0 -211
package/src/hook.ts
CHANGED
|
@@ -29,8 +29,9 @@ 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
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
|
//
|
|
@@ -255,6 +256,8 @@ export function decideStop(opts: {
|
|
|
255
256
|
verdicts: number;
|
|
256
257
|
commitList?: string[];
|
|
257
258
|
memLastTs?: string | null;
|
|
259
|
+
/** Logs that exist in the repo but are out of scope from this worktree. */
|
|
260
|
+
memCandidates?: string[];
|
|
258
261
|
}): string | null {
|
|
259
262
|
if (opts.stopHookActive) return null; // already blocked once — let it end
|
|
260
263
|
if (!opts.worktree) return null;
|
|
@@ -272,16 +275,30 @@ export function decideStop(opts: {
|
|
|
272
275
|
lines.push(
|
|
273
276
|
`mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
|
|
274
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
|
+
);
|
|
275
285
|
} else {
|
|
276
286
|
lines.push("mem: no rows at all — nothing recorded in this project yet");
|
|
277
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
|
+
|
|
278
295
|
lines.push(
|
|
279
296
|
`Call verdict_submit before ending: worktree must be the absolute path above, ` +
|
|
280
297
|
`regime is one of code|fix|review|plan|inquiry|test, and the note must stand alone ` +
|
|
281
298
|
`(it is read months from now with no access to this conversation). ` +
|
|
282
299
|
`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.
|
|
284
|
-
`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.`,
|
|
285
302
|
);
|
|
286
303
|
return lines.join("\n");
|
|
287
304
|
}
|
|
@@ -390,6 +407,67 @@ export function stopOutput(client: StopClient, reason: string): string {
|
|
|
390
407
|
return JSON.stringify({ decision: "block", reason });
|
|
391
408
|
}
|
|
392
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;
|
|
469
|
+
}
|
|
470
|
+
|
|
393
471
|
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
394
472
|
export async function cmdHookStop(): Promise<void> {
|
|
395
473
|
let reason: string | null = null;
|
|
@@ -421,6 +499,7 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
421
499
|
let commitList: string[] = [];
|
|
422
500
|
let verdicts = 0;
|
|
423
501
|
let memLastTs: string | null = null;
|
|
502
|
+
let memCandidates: string[] = [];
|
|
424
503
|
if (worktree && since) {
|
|
425
504
|
const log = git(
|
|
426
505
|
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
@@ -442,6 +521,8 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
442
521
|
try {
|
|
443
522
|
const mem = readMemLog(worktree);
|
|
444
523
|
memLastTs = mem.rows[0]?.ts ?? null;
|
|
524
|
+
if (!memLastTs)
|
|
525
|
+
memCandidates = whereMemDir(worktree).candidates ?? [];
|
|
445
526
|
} catch {
|
|
446
527
|
memLastTs = null;
|
|
447
528
|
}
|
|
@@ -455,7 +536,16 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
455
536
|
verdicts,
|
|
456
537
|
commitList,
|
|
457
538
|
memLastTs,
|
|
539
|
+
memCandidates,
|
|
458
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
|
+
}
|
|
459
549
|
} catch {
|
|
460
550
|
reason = null; // any failure = allow the turn to end
|
|
461
551
|
}
|
|
@@ -463,6 +553,78 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
463
553
|
if (reason) console.log(stopOutput(client, reason));
|
|
464
554
|
}
|
|
465
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
|
+
|
|
466
628
|
// --- Read hint (PreToolUse annotate — never block, never dedupe) ---
|
|
467
629
|
//
|
|
468
630
|
// Reading a large file in full is where an agent spends tokens without
|
|
@@ -523,7 +685,7 @@ export function readHintFor(opts: ReadHintInput): string | null {
|
|
|
523
685
|
return (
|
|
524
686
|
`fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
|
|
525
687
|
`returns exports with line numbers, importers, and signatures first ` +
|
|
526
|
-
`(${READ_HINT_MEASURED})`
|
|
688
|
+
`(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
|
|
527
689
|
);
|
|
528
690
|
} catch {
|
|
529
691
|
return null;
|
|
@@ -785,7 +947,7 @@ export function editHintFor(opts: EditHintInput): string | null {
|
|
|
785
947
|
return (
|
|
786
948
|
`fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
|
|
787
949
|
`review-seed --files ${rel} lists them (add --callers <export> for one ` +
|
|
788
|
-
`export's callers); check before changing its shape`
|
|
950
|
+
`export's callers); check before changing its shape (skill /lookup-before-edit)`
|
|
789
951
|
);
|
|
790
952
|
} catch {
|
|
791
953
|
return null;
|
package/src/install/claude.ts
CHANGED
|
@@ -353,6 +353,7 @@ function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
|
|
|
353
353
|
installStopHook(dryRun, deps);
|
|
354
354
|
installReadHintHook(dryRun, deps);
|
|
355
355
|
installEditHintHook(dryRun, deps);
|
|
356
|
+
installSessionStartHook(dryRun, deps);
|
|
356
357
|
}
|
|
357
358
|
|
|
358
359
|
function installStopHook(dryRun: boolean, deps: InstallDeps): void {
|
|
@@ -395,3 +396,16 @@ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
|
395
396
|
label: "edit hint",
|
|
396
397
|
});
|
|
397
398
|
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* SessionStart hook: injects `fapony mem kickoff` as context when the repo has
|
|
402
|
+
* a mem log, and stays silent when it does not. Context only — SessionStart
|
|
403
|
+
* cannot block, and a repo without mem never sees a line.
|
|
404
|
+
*/
|
|
405
|
+
function installSessionStartHook(dryRun: boolean, deps: InstallDeps): void {
|
|
406
|
+
ensureClaudeHook(dryRun, deps, {
|
|
407
|
+
event: "SessionStart",
|
|
408
|
+
subcommand: "hook-session-start",
|
|
409
|
+
label: "session start",
|
|
410
|
+
});
|
|
411
|
+
}
|
package/src/install/codex.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/install/codex.ts — Codex install provider
|
|
2
2
|
//
|
|
3
3
|
// Reads/writes ~/.codex/config.toml directly for MCP config.
|
|
4
|
-
// Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop).
|
|
4
|
+
// Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop, SessionStart).
|
|
5
5
|
// Symlinks skills into ~/.agents/skills/ (same dir as ZCode).
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -46,18 +46,25 @@ function readJsonObject(path: string): Record<string, unknown> | null {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Append
|
|
50
|
-
* Never replaces other hook groups or foreign entries within the
|
|
51
|
-
*
|
|
49
|
+
* Append a fapony command hook to one event group in ~/.codex/hooks.json.
|
|
50
|
+
* Never replaces other hook groups or foreign entries within the group.
|
|
51
|
+
* A fapony-owned entry is identified by the subcommand string
|
|
52
|
+
* ("hook-stop" / "hook-session-start") inside its command — per-group
|
|
53
|
+
* idempotence, so an existing install with only Stop still picks up
|
|
54
|
+
* SessionStart on the next run.
|
|
52
55
|
*/
|
|
53
|
-
function
|
|
56
|
+
function installCodexHookGroup(
|
|
57
|
+
dryRun: boolean,
|
|
58
|
+
getHome: () => string,
|
|
59
|
+
opts: { event: "Stop" | "SessionStart"; subcommand: string; label: string },
|
|
60
|
+
): void {
|
|
54
61
|
const hooksPath = join(getHome(), ".codex", "hooks.json");
|
|
55
62
|
let config: Record<string, unknown> = {};
|
|
56
63
|
if (existsSync(hooksPath)) {
|
|
57
64
|
const parsed = readJsonObject(hooksPath);
|
|
58
65
|
if (!parsed) {
|
|
59
66
|
console.error(
|
|
60
|
-
`
|
|
67
|
+
` ${opts.label}: ${hooksPath} is unreadable or malformed — skipping`,
|
|
61
68
|
);
|
|
62
69
|
return;
|
|
63
70
|
}
|
|
@@ -70,45 +77,76 @@ function installStopHook(dryRun: boolean, getHome: () => string): void {
|
|
|
70
77
|
(typeof hookMap !== "object" || Array.isArray(hookMap))
|
|
71
78
|
) {
|
|
72
79
|
console.error(
|
|
73
|
-
`
|
|
80
|
+
` ${opts.label}: ${hooksPath} has an unexpected "hooks" shape — skipping`,
|
|
74
81
|
);
|
|
75
82
|
return;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
const map = (hookMap ?? {}) as Record<string, unknown>;
|
|
79
|
-
// Codex
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
+
// Codex event arrays: each element is { matcher?, hooks: [...] }
|
|
87
|
+
const group = Array.isArray(map[opts.event])
|
|
88
|
+
? (map[opts.event] as unknown[])
|
|
89
|
+
: [];
|
|
90
|
+
|
|
91
|
+
// Check if the fapony hook is already present in this group (by subcommand)
|
|
92
|
+
const serialized = JSON.stringify(group);
|
|
93
|
+
if (serialized.includes(opts.subcommand)) {
|
|
94
|
+
console.error(
|
|
95
|
+
` ${opts.label}: already configured in hooks.json — no change`,
|
|
96
|
+
);
|
|
86
97
|
return;
|
|
87
98
|
}
|
|
88
99
|
|
|
89
|
-
const command = `bun ${join(INSTALL_ROOT, "fapony.ts")}
|
|
100
|
+
const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} ${opts.subcommand}`;
|
|
90
101
|
const faponyEntry = { hooks: [{ type: "command", command }] };
|
|
91
102
|
const after = {
|
|
92
103
|
...config,
|
|
93
|
-
hooks: { ...map,
|
|
104
|
+
hooks: { ...map, [opts.event]: [...group, faponyEntry] },
|
|
94
105
|
};
|
|
95
106
|
|
|
96
107
|
if (!dryRun) {
|
|
97
108
|
try {
|
|
98
109
|
writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
|
|
99
110
|
} catch (e) {
|
|
100
|
-
console.error(
|
|
111
|
+
console.error(
|
|
112
|
+
` ${opts.label}: failed to write — ${(e as Error).message}`,
|
|
113
|
+
);
|
|
101
114
|
return;
|
|
102
115
|
}
|
|
103
116
|
}
|
|
104
117
|
console.error(
|
|
105
|
-
`
|
|
118
|
+
` ${opts.label}: ${dryRun ? "would write" : "wrote"} hooks.${opts.event} → ${hooksPath}`,
|
|
106
119
|
);
|
|
107
120
|
console.error(
|
|
108
121
|
` review and trust via Codex /hooks before the hook will run`,
|
|
109
122
|
);
|
|
110
123
|
}
|
|
111
124
|
|
|
125
|
+
function installStopHook(dryRun: boolean, getHome: () => string): void {
|
|
126
|
+
installCodexHookGroup(dryRun, getHome, {
|
|
127
|
+
event: "Stop",
|
|
128
|
+
subcommand: "hook-stop",
|
|
129
|
+
label: "stop hook",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* SessionStart hook: injects `fapony mem kickoff` as context when the repo
|
|
135
|
+
* has a mem log, silent when it does not (cmdHookSessionStart exits quiet).
|
|
136
|
+
* Same `hookSpecificOutput.additionalContext` contract Claude uses — Codex
|
|
137
|
+
* SessionStart reads that channel too. No matcher: match-all fires on every
|
|
138
|
+
* source including `clear` and on versions that send no source at all, where
|
|
139
|
+
* a `startup|resume` matcher would silently never fire; kickoff is capped at
|
|
140
|
+
* 4k and costs one cheap spawn, so the /clear path stays fast.
|
|
141
|
+
*/
|
|
142
|
+
function installSessionStartHook(dryRun: boolean, getHome: () => string): void {
|
|
143
|
+
installCodexHookGroup(dryRun, getHome, {
|
|
144
|
+
event: "SessionStart",
|
|
145
|
+
subcommand: "hook-session-start",
|
|
146
|
+
label: "session start",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
112
150
|
export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
|
|
113
151
|
const exitFn = deps.exit ?? defaultExit;
|
|
114
152
|
const getHome = deps.homedir ?? homedir;
|
|
@@ -144,8 +182,9 @@ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
|
|
|
144
182
|
console.error(` restart Codex to load the MCP server`);
|
|
145
183
|
}
|
|
146
184
|
|
|
147
|
-
// --- Stop
|
|
185
|
+
// --- Stop + SessionStart hooks (~/.codex/hooks.json) ---
|
|
148
186
|
installStopHook(dryRun, getHome);
|
|
187
|
+
installSessionStartHook(dryRun, getHome);
|
|
149
188
|
|
|
150
189
|
// --- Skills (~/.agents/skills/) ---
|
|
151
190
|
const skillsDir = agentsSkillsDir(getHome);
|
package/src/install/opencode.ts
CHANGED
|
@@ -114,6 +114,7 @@ export function cmdInstallOpencode(
|
|
|
114
114
|
installReadHintPlugin(dryRun, getHome);
|
|
115
115
|
installCommitHintPlugin(dryRun, getHome);
|
|
116
116
|
installEditHintPlugin(dryRun, getHome);
|
|
117
|
+
installSessionStartPlugin(dryRun, getHome);
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -138,6 +139,7 @@ export function cmdInstallOpencode(
|
|
|
138
139
|
installReadHintPlugin(dryRun, getHome);
|
|
139
140
|
installCommitHintPlugin(dryRun, getHome);
|
|
140
141
|
installEditHintPlugin(dryRun, getHome);
|
|
142
|
+
installSessionStartPlugin(dryRun, getHome);
|
|
141
143
|
return;
|
|
142
144
|
}
|
|
143
145
|
|
|
@@ -160,6 +162,7 @@ export function cmdInstallOpencode(
|
|
|
160
162
|
installReadHintPlugin(dryRun, getHome);
|
|
161
163
|
installCommitHintPlugin(dryRun, getHome);
|
|
162
164
|
installEditHintPlugin(dryRun, getHome);
|
|
165
|
+
installSessionStartPlugin(dryRun, getHome);
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
/**
|
|
@@ -483,3 +486,98 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
|
483
486
|
` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
484
487
|
);
|
|
485
488
|
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* OpenCode plugin — session-start context via `fapony mem kickoff`, the
|
|
492
|
+
* OpenCode parity of Claude's SessionStart hook.
|
|
493
|
+
*
|
|
494
|
+
* Why `experimental.chat.system.transform` and not the `event` hook on
|
|
495
|
+
* `session.created`: the event hook is observer-only (returns void, no
|
|
496
|
+
* injection channel), while system.transform's `output.system` is the
|
|
497
|
+
* documented injection point (`packages/plugin/src/index.ts`: input
|
|
498
|
+
* `{ sessionID?, model }`, output `{ system: string[] }`). The context lands
|
|
499
|
+
* on the first model dispatch instead of at session creation — one step late,
|
|
500
|
+
* the same shape as every other OpenCode hint (annotate-after), and arguably
|
|
501
|
+
* better: sessions that never dispatch never pay the spawn.
|
|
502
|
+
*
|
|
503
|
+
* Once per session (closure Set — the factory runs once per server), silent
|
|
504
|
+
* when kickoff exits nonzero or prints nothing (no mem log in scope), never
|
|
505
|
+
* throws. `process.execPath` is the bun running opencode itself — no PATH
|
|
506
|
+
* dependency. Logic shared via `capContext` in src/hook.ts, same one-copy
|
|
507
|
+
* shape as the other three plugins.
|
|
508
|
+
*/
|
|
509
|
+
export function sessionStartPluginSource(root: string): string {
|
|
510
|
+
const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
|
|
511
|
+
const faponyTs = JSON.stringify(join(root, "fapony.ts"));
|
|
512
|
+
return `// fapony session start — injects \`fapony mem kickoff\` as context once per
|
|
513
|
+
// session, silent when the repo has no mem log. Annotate only: never blocks.
|
|
514
|
+
// Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
|
|
515
|
+
import { capContext } from ${hookModule};
|
|
516
|
+
|
|
517
|
+
export const FaponySessionStart = async ({ directory }) => {
|
|
518
|
+
const seen = new Set();
|
|
519
|
+
return {
|
|
520
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
521
|
+
try {
|
|
522
|
+
const sessionID = input?.sessionID;
|
|
523
|
+
if (!sessionID || seen.has(sessionID)) return;
|
|
524
|
+
seen.add(sessionID);
|
|
525
|
+
const { spawnSync } = require("node:child_process");
|
|
526
|
+
const kick = spawnSync(process.execPath, [${faponyTs}, "mem", "kickoff"], {
|
|
527
|
+
cwd: directory,
|
|
528
|
+
encoding: "utf-8",
|
|
529
|
+
});
|
|
530
|
+
if (kick.status !== 0) return;
|
|
531
|
+
const out = (kick.stdout ?? "").toString().trim();
|
|
532
|
+
if (!out) return;
|
|
533
|
+
output.system.push(capContext(out));
|
|
534
|
+
} catch {
|
|
535
|
+
// context must never break a session start
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
};
|
|
540
|
+
`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function installSessionStartPlugin(
|
|
544
|
+
dryRun: boolean,
|
|
545
|
+
getHome: () => string,
|
|
546
|
+
): void {
|
|
547
|
+
const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
|
|
548
|
+
const pluginPath = join(pluginsDir, "fapony-session-start.ts");
|
|
549
|
+
if (existsSync(pluginPath)) {
|
|
550
|
+
let current = "";
|
|
551
|
+
try {
|
|
552
|
+
current = readFileSync(pluginPath, "utf-8");
|
|
553
|
+
} catch {
|
|
554
|
+
current = "";
|
|
555
|
+
}
|
|
556
|
+
if (current.includes("capContext")) {
|
|
557
|
+
console.error(` session start: already installed — no change`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
console.error(
|
|
561
|
+
` session start: ${pluginPath} exists but isn't fapony's — not overwriting.`,
|
|
562
|
+
);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (!dryRun) {
|
|
566
|
+
try {
|
|
567
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
568
|
+
writeFileSync(
|
|
569
|
+
pluginPath,
|
|
570
|
+
sessionStartPluginSource(INSTALL_ROOT),
|
|
571
|
+
"utf-8",
|
|
572
|
+
);
|
|
573
|
+
} catch (e) {
|
|
574
|
+
console.error(
|
|
575
|
+
` session start: failed to write — ${(e as Error).message}`,
|
|
576
|
+
);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
console.error(
|
|
581
|
+
` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
582
|
+
);
|
|
583
|
+
}
|
package/src/lint-baseline.ts
CHANGED
|
@@ -52,12 +52,12 @@ function gitSha(cwd: string): string {
|
|
|
52
52
|
return p.exitCode === 0 ? p.stdout.toString().trim().slice(0, 12) : "none";
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function baselinePath(worktree: string): string {
|
|
55
|
+
export function baselinePath(worktree: string): string {
|
|
56
56
|
const key = worktree.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
57
57
|
return join(faponyDir(), "lint-baseline", `${key}.json`);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function readEvidenceLintCmd(worktree: string): string | null {
|
|
60
|
+
export function readEvidenceLintCmd(worktree: string): string | null {
|
|
61
61
|
const evidence = join(worktree, DEFAULT_EVIDENCE_FILE);
|
|
62
62
|
try {
|
|
63
63
|
const parsed = JSON.parse(readFileSync(evidence, "utf-8")) as {
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
import { VERDICT_GRADES } from "../../parse.js";
|
|
10
10
|
import { REASON_CODES, REGIME_CODES } from "../types.js";
|
|
11
11
|
|
|
12
|
-
export { toolMemAdd, toolMemFind } from "./mem.js";
|
|
13
|
-
export { toolPassiveUsage } from "./usage.js";
|
|
12
|
+
export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
|
|
14
13
|
export { toolVerdictSubmit } from "./verdict.js";
|
|
15
14
|
|
|
16
15
|
// --- Tool definitions ---
|
|
@@ -179,43 +178,29 @@ export const TOOLS = [
|
|
|
179
178
|
},
|
|
180
179
|
},
|
|
181
180
|
{
|
|
182
|
-
name: "
|
|
181
|
+
name: "mem_close",
|
|
183
182
|
description:
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"another's, so this is the only way to compare them. Filter by " +
|
|
188
|
-
"worktree and time range.",
|
|
183
|
+
"Close a mem row by id with a tombstone message (what was done). " +
|
|
184
|
+
"The write half of closing what mem_find shows as open — id must exist. " +
|
|
185
|
+
"Returns the ref and timestamp.",
|
|
189
186
|
inputSchema: {
|
|
190
187
|
type: "object" as const,
|
|
191
188
|
properties: {
|
|
192
189
|
worktree: {
|
|
193
190
|
type: "string",
|
|
194
|
-
description: "Filter by worktree path (absolute)",
|
|
195
|
-
},
|
|
196
|
-
since: {
|
|
197
|
-
type: "number",
|
|
198
|
-
description:
|
|
199
|
-
"Unix timestamp — include sessions created at or after this time",
|
|
200
|
-
},
|
|
201
|
-
until: {
|
|
202
|
-
type: "number",
|
|
203
191
|
description:
|
|
204
|
-
"
|
|
192
|
+
"Absolute path (git rev-parse --show-toplevel) — required",
|
|
205
193
|
},
|
|
206
|
-
|
|
207
|
-
type: "
|
|
208
|
-
description:
|
|
209
|
-
"If true, include tool-call breakdown + step counts per session " +
|
|
210
|
-
"(activity signal, not quality). Default false keeps output compact.",
|
|
194
|
+
id: {
|
|
195
|
+
type: "string",
|
|
196
|
+
description: "The row id to close",
|
|
211
197
|
},
|
|
212
|
-
|
|
213
|
-
type: "
|
|
214
|
-
description:
|
|
215
|
-
"If true, return raw JSON PassiveUsageResult. If false (default), return human-readable text.",
|
|
198
|
+
text: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description: "Tombstone message — what was done (commit sha counts)",
|
|
216
201
|
},
|
|
217
202
|
},
|
|
218
|
-
required: [],
|
|
203
|
+
required: ["worktree", "id", "text"],
|
|
219
204
|
},
|
|
220
205
|
},
|
|
221
206
|
];
|