fapony 0.2.0 → 0.2.1
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 +42 -8
- package/fapony.ts +4 -2
- package/package.json +1 -1
- 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.ts +9 -4
- package/src/digest/collect.ts +3 -2
- package/src/hook.ts +245 -12
- package/src/init-mem.ts +5 -5
- package/src/init.ts +6 -5
- package/src/install/claude.ts +37 -3
- package/src/install/codex.ts +105 -13
- package/src/install/opencode.ts +111 -1
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +2 -1
- package/src/mcp/evidence.ts +14 -2
- package/src/mcp/transport.ts +1 -1
- package/src/mem/store.ts +6 -4
- package/src/memory.ts +6 -5
- package/src/plan-seed.ts +7 -6
- package/src/setup.ts +3 -2
- package/src/stats/data.ts +6 -18
package/src/hook.ts
CHANGED
|
@@ -26,8 +26,8 @@ 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
32
|
import { debtForFile, loadConventions } from "./debt.js";
|
|
33
33
|
import { readMemLog } from "./memory.js";
|
|
@@ -63,7 +63,7 @@ export function hintLogPath(worktree: string): string {
|
|
|
63
63
|
export interface HintFireRow {
|
|
64
64
|
ts: string;
|
|
65
65
|
worktree: string;
|
|
66
|
-
surface: "read" | "debt" | "mem" | "commit";
|
|
66
|
+
surface: "read" | "debt" | "mem" | "commit" | "edit";
|
|
67
67
|
file: string | null;
|
|
68
68
|
count: number;
|
|
69
69
|
ids?: string[];
|
|
@@ -96,7 +96,13 @@ export function recordHintFire(row: HintFireRow): void {
|
|
|
96
96
|
|
|
97
97
|
export interface HintImpact {
|
|
98
98
|
fired: number;
|
|
99
|
-
by_surface: {
|
|
99
|
+
by_surface: {
|
|
100
|
+
read: number;
|
|
101
|
+
debt: number;
|
|
102
|
+
mem: number;
|
|
103
|
+
commit: number;
|
|
104
|
+
edit: number;
|
|
105
|
+
};
|
|
100
106
|
debt: { shown: number; resolved: number; unknown: number };
|
|
101
107
|
window: string | null;
|
|
102
108
|
}
|
|
@@ -115,7 +121,7 @@ export function computeHintImpact(
|
|
|
115
121
|
const dir = hintLogDir();
|
|
116
122
|
const impact: HintImpact = {
|
|
117
123
|
fired: 0,
|
|
118
|
-
by_surface: { read: 0, debt: 0, mem: 0, commit: 0 },
|
|
124
|
+
by_surface: { read: 0, debt: 0, mem: 0, commit: 0, edit: 0 },
|
|
119
125
|
debt: { shown: 0, resolved: 0, unknown: 0 },
|
|
120
126
|
window: since ?? null,
|
|
121
127
|
};
|
|
@@ -212,9 +218,13 @@ export interface RawStopPayload {
|
|
|
212
218
|
conversation_id?: string;
|
|
213
219
|
loop_count?: number;
|
|
214
220
|
status?: string;
|
|
221
|
+
// Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
|
|
222
|
+
session_id?: string;
|
|
223
|
+
model?: string;
|
|
224
|
+
permission_mode?: string;
|
|
215
225
|
}
|
|
216
226
|
|
|
217
|
-
export type StopClient = "claude" | "cursor";
|
|
227
|
+
export type StopClient = "claude" | "cursor" | "codex";
|
|
218
228
|
|
|
219
229
|
export interface NormalizedStopInput {
|
|
220
230
|
client: StopClient;
|
|
@@ -319,11 +329,32 @@ export function isCursorPayload(raw: RawStopPayload): boolean {
|
|
|
319
329
|
);
|
|
320
330
|
}
|
|
321
331
|
|
|
322
|
-
/**
|
|
332
|
+
/** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
|
|
333
|
+
export function isCodexPayload(raw: RawStopPayload): boolean {
|
|
334
|
+
return (
|
|
335
|
+
typeof raw.permission_mode === "string" ||
|
|
336
|
+
(typeof raw.model === "string" && !isCursorPayload(raw))
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Field-mapping only — all three clients feed the same decideStop below. */
|
|
323
341
|
export function normalizeStopInput(
|
|
324
342
|
raw: RawStopPayload,
|
|
325
343
|
home: string,
|
|
326
344
|
): NormalizedStopInput {
|
|
345
|
+
if (isCodexPayload(raw)) {
|
|
346
|
+
// Codex: cwd is the session working directory; stop_hook_active means
|
|
347
|
+
// the hook already fired once (same semantics as Claude).
|
|
348
|
+
return {
|
|
349
|
+
client: "codex",
|
|
350
|
+
cwd: raw.cwd ?? process.cwd(),
|
|
351
|
+
transcriptPath:
|
|
352
|
+
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
353
|
+
? raw.transcript_path
|
|
354
|
+
: null,
|
|
355
|
+
stopHookActive: raw.stop_hook_active === true,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
327
358
|
if (isCursorPayload(raw)) {
|
|
328
359
|
const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
|
|
329
360
|
let transcriptPath =
|
|
@@ -350,12 +381,13 @@ export function normalizeStopInput(
|
|
|
350
381
|
};
|
|
351
382
|
}
|
|
352
383
|
|
|
353
|
-
/** Claude blocks with decision:block; Cursor
|
|
354
|
-
*
|
|
384
|
+
/** Claude blocks with decision:block; Cursor auto-submits as followup_message;
|
|
385
|
+
* Codex continues with decision:block + reason (continue:false would take
|
|
386
|
+
* precedence and end the turn instead — Codex Hooks, Stop section). */
|
|
355
387
|
export function stopOutput(client: StopClient, reason: string): string {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
388
|
+
if (client === "cursor") return JSON.stringify({ followup_message: reason });
|
|
389
|
+
if (client === "codex") return JSON.stringify({ decision: "block", reason });
|
|
390
|
+
return JSON.stringify({ decision: "block", reason });
|
|
359
391
|
}
|
|
360
392
|
|
|
361
393
|
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
@@ -370,6 +402,7 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
370
402
|
// died — commits from those turns are still caught at the next completed
|
|
371
403
|
// stop (the window is the conversation transcript's birthtime).
|
|
372
404
|
if (client === "cursor" && raw.status !== "completed") return;
|
|
405
|
+
// Codex: no status guard needed — Stop fires at turn end unconditionally.
|
|
373
406
|
|
|
374
407
|
const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
|
|
375
408
|
|
|
@@ -633,6 +666,132 @@ export function rereadHintFor(opts: RereadHintInput): string | null {
|
|
|
633
666
|
}
|
|
634
667
|
}
|
|
635
668
|
|
|
669
|
+
// --- Edit hint (PreToolUse annotate — importer count + once-per-session dedupe) ---
|
|
670
|
+
//
|
|
671
|
+
// Editing a file that has importers can silently break its consumers (measured:
|
|
672
|
+
// 17.9% of changed nodes over 30 commits had a 1-hop blast radius; ~12-16% of
|
|
673
|
+
// all-time fail rows were producer/consumer mismatches). The hint is a fact —
|
|
674
|
+
// the importer count plus the review-seed command that lists them — never a
|
|
675
|
+
// judgment about whether the edit is safe, and never a block.
|
|
676
|
+
//
|
|
677
|
+
// Dedupe is per (session, file): the first edit to a file fires, repeats stay
|
|
678
|
+
// silent. The track log reuses the read-track session mechanism (sessionKey,
|
|
679
|
+
// one jsonl per session) but lives in its own dir — sharing read-track's file
|
|
680
|
+
// would make an Edit look like a Read and falsely trip the re-read hint. Like
|
|
681
|
+
// rereadHintFor the track write happens inside this function (the caller-side
|
|
682
|
+
// rule covers recordHintFire, not dedupe state); unlike it there is no mtime
|
|
683
|
+
// comparison — an edit that moves mtime is still the same file in the same
|
|
684
|
+
// session, and repeating the count buys nothing.
|
|
685
|
+
|
|
686
|
+
const EDIT_TRACK_DIR = "edit-track";
|
|
687
|
+
|
|
688
|
+
export interface EditTrackRow {
|
|
689
|
+
ts: string;
|
|
690
|
+
path: string;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Directory holding one edit log per session. */
|
|
694
|
+
function editTrackDir(): string {
|
|
695
|
+
const base =
|
|
696
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
697
|
+
return join(base, EDIT_TRACK_DIR);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Absolute path of a session's edit log — may not exist. */
|
|
701
|
+
export function editTrackPath(session: string): string {
|
|
702
|
+
return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function editTrackPaths(session: string): Set<string> {
|
|
706
|
+
const p = editTrackPath(session);
|
|
707
|
+
if (!existsSync(p)) return new Set();
|
|
708
|
+
const out = new Set<string>();
|
|
709
|
+
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
710
|
+
if (!line) continue;
|
|
711
|
+
try {
|
|
712
|
+
const r = JSON.parse(line) as EditTrackRow;
|
|
713
|
+
if (typeof r.path === "string") out.add(r.path);
|
|
714
|
+
} catch {
|
|
715
|
+
// a torn line must not lose the rest of the log
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function appendEditTrackRow(session: string, row: EditTrackRow): void {
|
|
722
|
+
const dir = editTrackDir();
|
|
723
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
724
|
+
appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export interface EditHintInput {
|
|
728
|
+
filePath: unknown;
|
|
729
|
+
cwd: string;
|
|
730
|
+
/**
|
|
731
|
+
* Session identity — transcript path (Claude) or session id. Without it the
|
|
732
|
+
* hint still fires (the importer fact holds) but cannot dedupe.
|
|
733
|
+
*/
|
|
734
|
+
session?: unknown;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Factual one-liner for editing a source file that has importers, or null.
|
|
739
|
+
* Every unknown (no path, non-source ext, new/unsaved file, outside the
|
|
740
|
+
* worktree, no git repo, graph failure) resolves to null — a hint must never
|
|
741
|
+
* fire on a guess. Files with importers are checked before the dedupe log is
|
|
742
|
+
* touched, so a file nobody imports never writes a track row.
|
|
743
|
+
*/
|
|
744
|
+
export function editHintFor(opts: EditHintInput): string | null {
|
|
745
|
+
try {
|
|
746
|
+
if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
|
|
747
|
+
const dot = opts.filePath.lastIndexOf(".");
|
|
748
|
+
// SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
|
|
749
|
+
if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
|
|
750
|
+
// review-seed is a git command — outside a repo the hint would lie.
|
|
751
|
+
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
752
|
+
cwd: opts.cwd,
|
|
753
|
+
stdout: "pipe",
|
|
754
|
+
stderr: "pipe",
|
|
755
|
+
});
|
|
756
|
+
if (git.exitCode !== 0) return null;
|
|
757
|
+
// macOS /var → /private/var: normalize both sides before comparing.
|
|
758
|
+
const worktree = realpathSync(git.stdout.toString().trim());
|
|
759
|
+
const abs = (() => {
|
|
760
|
+
const p = opts.filePath.startsWith("/")
|
|
761
|
+
? opts.filePath
|
|
762
|
+
: join(opts.cwd, opts.filePath);
|
|
763
|
+
try {
|
|
764
|
+
return realpathSync(p);
|
|
765
|
+
} catch {
|
|
766
|
+
return null; // new file — nothing imports it yet
|
|
767
|
+
}
|
|
768
|
+
})();
|
|
769
|
+
if (!abs) return null;
|
|
770
|
+
const rel = relative(worktree, abs).split(sep).join("/");
|
|
771
|
+
if (rel.startsWith("..") || rel === "") return null;
|
|
772
|
+
|
|
773
|
+
const importers = buildGraphCached(worktree).dependents.get(rel);
|
|
774
|
+
if (!importers || importers.size === 0) return null;
|
|
775
|
+
|
|
776
|
+
if (typeof opts.session === "string" && opts.session !== "") {
|
|
777
|
+
if (editTrackPaths(opts.session).has(abs)) return null;
|
|
778
|
+
appendEditTrackRow(opts.session, {
|
|
779
|
+
ts: new Date().toISOString(),
|
|
780
|
+
path: abs,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const n = importers.size;
|
|
785
|
+
return (
|
|
786
|
+
`fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
|
|
787
|
+
`review-seed --files ${rel} lists them (add --callers <export> for one ` +
|
|
788
|
+
`export's callers); check before changing its shape`
|
|
789
|
+
);
|
|
790
|
+
} catch {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
636
795
|
// --- Commit hint (tool.execute.after — annotate only, never block) ---
|
|
637
796
|
//
|
|
638
797
|
// OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
|
|
@@ -835,6 +994,80 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
835
994
|
}
|
|
836
995
|
}
|
|
837
996
|
|
|
997
|
+
/** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
|
|
998
|
+
* No permissionDecision ever — the edit always proceeds. Fires once per
|
|
999
|
+
* (session, file); the dedupe lives inside editHintFor. */
|
|
1000
|
+
export async function cmdHookEditHint(): Promise<void> {
|
|
1001
|
+
try {
|
|
1002
|
+
const raw = JSON.parse(await Bun.stdin.text()) as {
|
|
1003
|
+
cwd?: string;
|
|
1004
|
+
transcript_path?: string;
|
|
1005
|
+
session_id?: string;
|
|
1006
|
+
tool_input?: {
|
|
1007
|
+
file_path?: unknown;
|
|
1008
|
+
};
|
|
1009
|
+
};
|
|
1010
|
+
const cwd = raw.cwd ?? process.cwd();
|
|
1011
|
+
const filePath = raw.tool_input?.file_path;
|
|
1012
|
+
// One edit log per session — same identity as the read hint.
|
|
1013
|
+
const session = raw.transcript_path ?? raw.session_id;
|
|
1014
|
+
const hint = editHintFor({ filePath, cwd, session });
|
|
1015
|
+
if (hint) {
|
|
1016
|
+
console.log(
|
|
1017
|
+
JSON.stringify({
|
|
1018
|
+
hookSpecificOutput: {
|
|
1019
|
+
hookEventName: "PreToolUse",
|
|
1020
|
+
additionalContext: hint,
|
|
1021
|
+
},
|
|
1022
|
+
}),
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// --- hint-fire log (PLAN-edit-importer-hint chunk 3) ---
|
|
1027
|
+
// After output — best-effort, never block the hint.
|
|
1028
|
+
if (hint) {
|
|
1029
|
+
try {
|
|
1030
|
+
const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
1031
|
+
cwd,
|
|
1032
|
+
stdout: "pipe",
|
|
1033
|
+
stderr: "pipe",
|
|
1034
|
+
});
|
|
1035
|
+
if (g.exitCode === 0) {
|
|
1036
|
+
const worktree = realpathSync(g.stdout.toString().trim());
|
|
1037
|
+
const abs =
|
|
1038
|
+
typeof filePath === "string"
|
|
1039
|
+
? (() => {
|
|
1040
|
+
try {
|
|
1041
|
+
return realpathSync(
|
|
1042
|
+
filePath.startsWith("/")
|
|
1043
|
+
? filePath
|
|
1044
|
+
: join(worktree, filePath),
|
|
1045
|
+
);
|
|
1046
|
+
} catch {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
})()
|
|
1050
|
+
: null;
|
|
1051
|
+
const rel = abs
|
|
1052
|
+
? relative(worktree, abs).split("\\").join("/")
|
|
1053
|
+
: null;
|
|
1054
|
+
recordHintFire({
|
|
1055
|
+
ts: new Date().toISOString(),
|
|
1056
|
+
worktree,
|
|
1057
|
+
surface: "edit",
|
|
1058
|
+
file: rel && !rel.startsWith("..") ? rel : null,
|
|
1059
|
+
count: 1,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
} catch {
|
|
1063
|
+
// best-effort — swallow
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
} catch {
|
|
1067
|
+
// any failure = no hint; a hook must never block an edit over a hint
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
838
1071
|
// --- Debt + mem context (PLAN-convention-debt chunk 4) ---
|
|
839
1072
|
//
|
|
840
1073
|
// 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
|
);
|
package/src/install/claude.ts
CHANGED
|
@@ -105,6 +105,10 @@ export function cmdInstallClaude(
|
|
|
105
105
|
console.error(` (Claude Code user scope)`);
|
|
106
106
|
const dir = claudeSkillsDir(deps.homedir ?? homedir);
|
|
107
107
|
reportSkills(linkSkills(dir, dryRun), dir, dryRun);
|
|
108
|
+
// MCP is already wired, but a hook can be new since the last install
|
|
109
|
+
// (e.g. the Edit hint) — always ensure the hook wiring, not only on a
|
|
110
|
+
// fresh MCP add. This is the upgrade path for existing installs.
|
|
111
|
+
installClaudeHooks(dryRun, deps);
|
|
108
112
|
return;
|
|
109
113
|
}
|
|
110
114
|
console.error(
|
|
@@ -120,6 +124,9 @@ export function cmdInstallClaude(
|
|
|
120
124
|
if (dryRun) {
|
|
121
125
|
console.error(`── dry-run: would run ──`);
|
|
122
126
|
console.error(` ${addArgs.join(" ")}`);
|
|
127
|
+
// Dry-run shows the hook wiring too — the installers are dry-run-safe
|
|
128
|
+
// ("would write", no writes), so the preview stays truthful.
|
|
129
|
+
installClaudeHooks(dryRun, deps);
|
|
123
130
|
return;
|
|
124
131
|
}
|
|
125
132
|
|
|
@@ -148,9 +155,9 @@ export function cmdInstallClaude(
|
|
|
148
155
|
installStatusline(dryRun, deps);
|
|
149
156
|
|
|
150
157
|
// Wire the Stop hook that refuses to end a turn with ungraded commits,
|
|
151
|
-
// and the Read
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
// and the Read/Edit hints that annotate reads of large files and edits to
|
|
159
|
+
// files with importers (both annotate-only).
|
|
160
|
+
installClaudeHooks(dryRun, deps);
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
/**
|
|
@@ -336,6 +343,18 @@ function ensureClaudeHook(
|
|
|
336
343
|
);
|
|
337
344
|
}
|
|
338
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Wire every hook fapony owns: the Stop hook that refuses to end a turn with
|
|
348
|
+
* ungraded commits, and the Read/Edit PreToolUse hints (annotate-only).
|
|
349
|
+
* Idempotent and dry-run-safe. Called on every install, not just a fresh MCP
|
|
350
|
+
* add — an existing install must still pick up a hook added later.
|
|
351
|
+
*/
|
|
352
|
+
function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
|
|
353
|
+
installStopHook(dryRun, deps);
|
|
354
|
+
installReadHintHook(dryRun, deps);
|
|
355
|
+
installEditHintHook(dryRun, deps);
|
|
356
|
+
}
|
|
357
|
+
|
|
339
358
|
function installStopHook(dryRun: boolean, deps: InstallDeps): void {
|
|
340
359
|
ensureClaudeHook(dryRun, deps, {
|
|
341
360
|
event: "Stop",
|
|
@@ -361,3 +380,18 @@ function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
|
361
380
|
label: "read hint",
|
|
362
381
|
});
|
|
363
382
|
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* PreToolUse hook on Edit: annotates an edit with the file's importer count
|
|
386
|
+
* plus the review-seed command that lists them, once per (session, file).
|
|
387
|
+
* Annotate only — no permissionDecision is ever returned, the edit always
|
|
388
|
+
* proceeds. The matcher "Edit" keeps the spawn off every other tool call.
|
|
389
|
+
*/
|
|
390
|
+
function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
391
|
+
ensureClaudeHook(dryRun, deps, {
|
|
392
|
+
event: "PreToolUse",
|
|
393
|
+
matcher: "Edit",
|
|
394
|
+
subcommand: "hook-edit-hint",
|
|
395
|
+
label: "edit hint",
|
|
396
|
+
});
|
|
397
|
+
}
|
package/src/install/codex.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
// src/install/codex.ts — Codex install provider
|
|
2
2
|
//
|
|
3
|
-
// Reads/writes ~/.codex/config.toml directly
|
|
3
|
+
// Reads/writes ~/.codex/config.toml directly for MCP config.
|
|
4
|
+
// Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop).
|
|
5
|
+
// Symlinks skills into ~/.agents/skills/ (same dir as ZCode).
|
|
4
6
|
|
|
5
7
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
8
|
import { homedir } from "node:os";
|
|
7
9
|
import { join } from "node:path";
|
|
8
|
-
import {
|
|
10
|
+
import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
|
|
11
|
+
import {
|
|
12
|
+
CODEX_MCP_ENTRY,
|
|
13
|
+
defaultExit,
|
|
14
|
+
INSTALL_ROOT,
|
|
15
|
+
type InstallDeps,
|
|
16
|
+
} from "./types.js";
|
|
9
17
|
|
|
10
18
|
export function findCodexConfig(getHome: () => string): string | null {
|
|
11
19
|
const p = join(getHome(), ".codex", "config.toml");
|
|
12
20
|
return existsSync(p) ? p : null;
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
export function findCodexHooksJson(getHome: () => string): string | null {
|
|
24
|
+
const p = join(getHome(), ".codex", "hooks.json");
|
|
25
|
+
return existsSync(p) ? p : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
function isCodexConfigured(content: string): boolean {
|
|
16
29
|
// Check if [mcp_servers.fapony] section exists with our command
|
|
17
30
|
const sectionRegex = /\[mcp_servers\.fapony\]/;
|
|
@@ -20,9 +33,86 @@ function isCodexConfigured(content: string): boolean {
|
|
|
20
33
|
return content.includes("fapony.ts") && content.includes("mcp");
|
|
21
34
|
}
|
|
22
35
|
|
|
36
|
+
function readJsonObject(path: string): Record<string, unknown> | null {
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
39
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return parsed as Record<string, unknown>;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Append the fapony Stop hook to ~/.codex/hooks.json.
|
|
50
|
+
* Never replaces other hook groups or foreign entries within the Stop group.
|
|
51
|
+
* Fapony-owned entry is identified by the command string containing "hook-stop".
|
|
52
|
+
*/
|
|
53
|
+
function installStopHook(dryRun: boolean, getHome: () => string): void {
|
|
54
|
+
const hooksPath = join(getHome(), ".codex", "hooks.json");
|
|
55
|
+
let config: Record<string, unknown> = {};
|
|
56
|
+
if (existsSync(hooksPath)) {
|
|
57
|
+
const parsed = readJsonObject(hooksPath);
|
|
58
|
+
if (!parsed) {
|
|
59
|
+
console.error(
|
|
60
|
+
` stop hook: ${hooksPath} is unreadable or malformed — skipping`,
|
|
61
|
+
);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
config = parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hookMap = config.hooks;
|
|
68
|
+
if (
|
|
69
|
+
hookMap !== undefined &&
|
|
70
|
+
(typeof hookMap !== "object" || Array.isArray(hookMap))
|
|
71
|
+
) {
|
|
72
|
+
console.error(
|
|
73
|
+
` stop hook: ${hooksPath} has an unexpected "hooks" shape — skipping`,
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const map = (hookMap ?? {}) as Record<string, unknown>;
|
|
79
|
+
// Codex Stop array: each element is { matcher?, hooks: [...] }
|
|
80
|
+
const stop = Array.isArray(map.Stop) ? (map.Stop as unknown[]) : [];
|
|
81
|
+
|
|
82
|
+
// Check if fapony stop hook already present (by command string)
|
|
83
|
+
const serialized = JSON.stringify(stop);
|
|
84
|
+
if (serialized.includes("hook-stop")) {
|
|
85
|
+
console.error(` stop hook: already configured in hooks.json — no change`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} hook-stop`;
|
|
90
|
+
const faponyEntry = { hooks: [{ type: "command", command }] };
|
|
91
|
+
const after = {
|
|
92
|
+
...config,
|
|
93
|
+
hooks: { ...map, Stop: [...stop, faponyEntry] },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (!dryRun) {
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error(` stop hook: failed to write — ${(e as Error).message}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.error(
|
|
105
|
+
` stop hook: ${dryRun ? "would write" : "wrote"} hooks.Stop → ${hooksPath}`,
|
|
106
|
+
);
|
|
107
|
+
console.error(
|
|
108
|
+
` review and trust via Codex /hooks before the hook will run`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
23
112
|
export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
|
|
24
113
|
const exitFn = deps.exit ?? defaultExit;
|
|
25
|
-
const
|
|
114
|
+
const getHome = deps.homedir ?? homedir;
|
|
115
|
+
const configPath = findCodexConfig(getHome);
|
|
26
116
|
|
|
27
117
|
if (!configPath) {
|
|
28
118
|
console.error(
|
|
@@ -44,18 +134,20 @@ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
|
|
|
44
134
|
if (isCodexConfigured(content)) {
|
|
45
135
|
console.error(`✓ mcp_servers.fapony already configured — no change needed`);
|
|
46
136
|
console.error(` (${configPath})`);
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (dryRun) {
|
|
137
|
+
} else if (dryRun) {
|
|
51
138
|
console.error(`── dry-run: would append to ${configPath} ──`);
|
|
52
139
|
console.log(CODEX_MCP_ENTRY);
|
|
53
|
-
|
|
140
|
+
} else {
|
|
141
|
+
const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
|
|
142
|
+
writeFileSync(configPath, newContent);
|
|
143
|
+
console.error(`✓ added mcp_servers.fapony to ${configPath}`);
|
|
144
|
+
console.error(` restart Codex to load the MCP server`);
|
|
54
145
|
}
|
|
55
146
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
147
|
+
// --- Stop hook (~/.codex/hooks.json) ---
|
|
148
|
+
installStopHook(dryRun, getHome);
|
|
149
|
+
|
|
150
|
+
// --- Skills (~/.agents/skills/) ---
|
|
151
|
+
const skillsDir = agentsSkillsDir(getHome);
|
|
152
|
+
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
61
153
|
}
|