fapony 0.1.3 → 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 +111 -46
- package/fapony.ts +35 -2
- package/package.json +3 -2
- package/skill/move-to-done/SKILL.md +18 -28
- package/skill/plan-with-pony/SKILL.md +8 -3
- package/src/analyze.ts +175 -5
- package/src/conventions-seed.ts +3 -3
- package/src/db/defaults.ts +13 -5
- package/src/db/getters.ts +11 -9
- package/src/db/load.ts +2 -2
- package/src/db/store.ts +0 -75
- package/src/db/types.ts +2 -4
- package/src/debt.ts +176 -36
- package/src/digest/collect.ts +19 -2
- package/src/digest/text.ts +25 -0
- package/src/hook.ts +665 -24
- package/src/init-mem.ts +123 -37
- package/src/init.ts +57 -39
- package/src/install/claude.ts +44 -8
- package/src/install/codex.ts +105 -13
- package/src/install/opencode.ts +169 -5
- package/src/install.ts +2 -1
- package/src/lint-baseline.ts +3 -8
- package/src/mcp/evidence.ts +15 -3
- package/src/mcp/tools/index.ts +97 -185
- package/src/mcp/tools/mem.ts +153 -11
- package/src/mcp/transport.ts +5 -13
- package/src/mem/commands/read.ts +448 -0
- package/src/mem/commands/where.ts +56 -0
- package/{templates → src}/mem/commands/write.ts +12 -5
- package/src/mem/index.ts +144 -0
- package/src/mem/store.ts +350 -0
- package/src/memory.ts +238 -40
- package/src/plan-seed.ts +26 -6
- package/src/review-seed.ts +19 -0
- package/src/setup.ts +7 -8
- package/src/stats/data.ts +40 -104
- package/src/stats/format.ts +8 -9
- package/src/stats/index.ts +0 -1
- package/templates/PLAN.md +1 -0
- package/src/mcp/tools/context.ts +0 -66
- package/src/mcp/tools/plans.ts +0 -255
- package/src/mcp/tools/stats.ts +0 -96
- package/templates/mem/commands/read.ts +0 -194
- package/templates/mem/commands/selftest.ts +0 -450
- package/templates/mem/mem.ts +0 -68
- package/templates/mem/store.ts +0 -285
- /package/{templates → src}/mem/commands/plan.ts +0 -0
- /package/{templates → src}/mem/commands/rotate.ts +0 -0
- /package/{templates → src}/mem/render.ts +0 -0
- /package/{templates → src}/mem/selectors.ts +0 -0
package/src/init-mem.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
// src/init-mem.ts —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// .fapony/; run state stays in ~/.config/fapony/state.db, never in the worktree).
|
|
7
|
-
// Re-run to re-sync after editing the template — not automatic, on purpose.
|
|
1
|
+
// src/init-mem.ts — cleanup old .memory/ directories and warn about stale call sites.
|
|
2
|
+
//
|
|
3
|
+
// Before PLAN-agent-one-call, this file scaffolded templates/mem/ into every repo.
|
|
4
|
+
// Now fapony owns the mem code (src/mem/) and calls it directly via `fapony mem`.
|
|
5
|
+
// This command's new job: delete legacy .memory/ dirs + warn about package.json refs.
|
|
8
6
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
import {
|
|
8
|
+
copyFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
|
|
12
17
|
|
|
13
18
|
export function copyDir(src: string, dest: string): string[] {
|
|
14
19
|
mkdirSync(dest, { recursive: true });
|
|
@@ -27,13 +32,11 @@ export function copyDir(src: string, dest: string): string[] {
|
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export function cmdInitMem(args: string[]): void {
|
|
30
|
-
const
|
|
35
|
+
const force = args.includes("--force");
|
|
31
36
|
const worktreeKey = args.find((x) => !x.startsWith("-"));
|
|
32
37
|
const config = loadConfig();
|
|
33
38
|
|
|
34
|
-
// no key given = the repo you are standing in
|
|
35
|
-
// without registering the worktree first (loadConfig already reads the cwd's fapony.config.json,
|
|
36
|
-
// so it picks up that project's paths.memoryEntry by itself)
|
|
39
|
+
// no key given = the repo you are standing in
|
|
37
40
|
const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
|
|
38
41
|
if (!worktree) {
|
|
39
42
|
console.error(`unknown worktree key: ${worktreeKey}`);
|
|
@@ -41,31 +44,114 @@ export function cmdInitMem(args: string[]): void {
|
|
|
41
44
|
process.exit(1);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
const gitRoot = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
48
|
+
cwd: worktree,
|
|
49
|
+
})
|
|
50
|
+
.stdout.toString()
|
|
51
|
+
.trim();
|
|
52
|
+
const root = gitRoot || worktree;
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
// 1) Find and delete .memory/ directories (legacy layout)
|
|
55
|
+
const memoryDirs: string[] = [];
|
|
56
|
+
const walk = (dir: string, depth = 0) => {
|
|
57
|
+
if (depth > 4) return;
|
|
58
|
+
if (existsSync(`${dir}/.memory`)) {
|
|
59
|
+
memoryDirs.push(`${dir}/.memory`);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
63
|
+
if (
|
|
64
|
+
entry.isDirectory() &&
|
|
65
|
+
entry.name !== "node_modules" &&
|
|
66
|
+
(!entry.name.startsWith(".") || entry.name === FAPONY_DIR)
|
|
67
|
+
) {
|
|
68
|
+
walk(join(dir, entry.name), depth + 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(root);
|
|
76
|
+
|
|
77
|
+
if (memoryDirs.length === 0) {
|
|
78
|
+
console.log("no legacy .memory/ directories found — already clean");
|
|
79
|
+
} else {
|
|
80
|
+
let removed = 0;
|
|
81
|
+
let kept = 0;
|
|
82
|
+
for (const d of memoryDirs) {
|
|
83
|
+
// Any log*.jsonl is history — includes log.<person>.jsonl (the standard
|
|
84
|
+
// filename) and rotated log.YYYY-MM-DD.jsonl, not just log.jsonl.
|
|
85
|
+
let logs: string[] = [];
|
|
86
|
+
try {
|
|
87
|
+
logs = readdirSync(d).filter(
|
|
88
|
+
(f) => f === "log.jsonl" || /^log\..*\.jsonl$/.test(f),
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
// unreadable dir — fall through and remove
|
|
92
|
+
}
|
|
93
|
+
if (logs.length > 0 && !force) {
|
|
94
|
+
console.log(
|
|
95
|
+
`keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
|
|
96
|
+
);
|
|
97
|
+
console.log(
|
|
98
|
+
` move them under ${DEFAULT_MEM_DIR}/, or re-run with --force to delete`,
|
|
99
|
+
);
|
|
100
|
+
kept++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
console.log(
|
|
104
|
+
`deleting ${d}${logs.length > 0 ? ` (--force: ${logs.length} log file(s))` : ""}`,
|
|
105
|
+
);
|
|
106
|
+
rmSync(d, { recursive: true, force: true });
|
|
107
|
+
removed++;
|
|
108
|
+
}
|
|
109
|
+
console.log(
|
|
110
|
+
`\nremoved ${removed} legacy .memory/ director${removed === 1 ? "y" : "ies"}${
|
|
111
|
+
kept > 0
|
|
112
|
+
? ` · kept ${kept} with logs — move them under ${DEFAULT_MEM_DIR}/, then re-run`
|
|
113
|
+
: ""
|
|
114
|
+
}`,
|
|
52
115
|
);
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
if (update && !existsSync(destFile)) {
|
|
56
|
-
console.error(`${destFile} not found — run \`fapony init\` first.`);
|
|
57
|
-
process.exit(1);
|
|
58
116
|
}
|
|
59
117
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
118
|
+
// 2) Warn about package.json call sites still referencing .memory/mem.ts
|
|
119
|
+
const warnAboutCallSites = (dir: string, depth = 0) => {
|
|
120
|
+
if (depth > 4) return;
|
|
121
|
+
const pkg = join(dir, "package.json");
|
|
122
|
+
if (existsSync(pkg)) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = readFileSync(pkg, "utf8");
|
|
125
|
+
if (raw.includes(".memory/mem.ts")) {
|
|
126
|
+
console.log(`\n⚠ ${pkg} still references .memory/mem.ts`);
|
|
127
|
+
console.log(
|
|
128
|
+
` update to: "fapony mem <sub>" (e.g. "fapony mem add", "fapony mem close")`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
if (
|
|
138
|
+
entry.isDirectory() &&
|
|
139
|
+
!entry.name.startsWith(".") &&
|
|
140
|
+
entry.name !== "node_modules"
|
|
141
|
+
) {
|
|
142
|
+
warnAboutCallSites(join(dir, entry.name), depth + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
warnAboutCallSites(root);
|
|
150
|
+
|
|
151
|
+
console.log("\nmem commands are now built into fapony:");
|
|
152
|
+
console.log(' fapony mem add <kind> "<text>" --files <files>');
|
|
153
|
+
console.log(' fapony mem close <id> "<text>"');
|
|
154
|
+
console.log(" fapony mem find <word>");
|
|
155
|
+
console.log(" fapony mem kickoff [id|spec.md]");
|
|
156
|
+
console.log(" fapony mem now");
|
|
71
157
|
}
|
package/src/init.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
// state.db stays in ~/.config/fapony/ by design (security boundary — see db.ts),
|
|
4
4
|
// never inside the worktree where agents have full write access.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
appendFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
7
13
|
import { dirname, join, relative } from "node:path";
|
|
8
14
|
import { createInterface } from "node:readline";
|
|
9
15
|
import { seedConventionsFile } from "./conventions-seed.js";
|
|
@@ -11,11 +17,11 @@ import {
|
|
|
11
17
|
type Config,
|
|
12
18
|
doneDir,
|
|
13
19
|
evidenceFile,
|
|
14
|
-
|
|
20
|
+
FAPONY_DIR,
|
|
21
|
+
memoryDir,
|
|
15
22
|
planDir,
|
|
16
23
|
specDir,
|
|
17
24
|
} from "./db/index.js";
|
|
18
|
-
import { copyDir } from "./init-mem.js";
|
|
19
25
|
import { isAffirmative } from "./util.js";
|
|
20
26
|
|
|
21
27
|
const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
|
|
@@ -37,7 +43,7 @@ const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
|
|
|
37
43
|
# running in this worktree cannot rewrite run state / audit trail.
|
|
38
44
|
#
|
|
39
45
|
# Ask your agent for the plan picture instead of listing these by hand:
|
|
40
|
-
#
|
|
46
|
+
# run "fapony mem kickoff" — priority plans, the first unchecked chunk, open bugs
|
|
41
47
|
`;
|
|
42
48
|
|
|
43
49
|
// Static template — deliberately NOT derived from the repo (reading package.json
|
|
@@ -59,22 +65,22 @@ const EVIDENCE_JSON = `{
|
|
|
59
65
|
// Not in SERVER_INSTRUCTIONS either: that reaches every MCP session of every user,
|
|
60
66
|
// and most of them never ran `fapony init` — it would tell them to run a command
|
|
61
67
|
// that does not exist.
|
|
62
|
-
const RULES_SNIPPET =
|
|
63
|
-
|
|
64
|
-
) => `## Memory: ${dirname(memEntry)}/log.<you>.jsonl (append-only)
|
|
68
|
+
const RULES_SNIPPET =
|
|
69
|
+
() => `## Memory: .fapony/.memory/log.<you>.jsonl (append-only)
|
|
65
70
|
|
|
66
71
|
The log is this project's shared brain — it lives in git, so anyone who clones the
|
|
67
72
|
repo gets every decision, bug and note with it. The filename comes from
|
|
68
|
-
\`git config user.name
|
|
73
|
+
\`git config user.name\` — one file per person, and \`*.jsonl merge=union\` in
|
|
74
|
+
.gitattributes keeps both sides when two people end up sharing a name anyway.
|
|
69
75
|
|
|
70
76
|
Log as you work — do not wait to be asked. Nothing writes it for you:
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
fapony mem kickoff <plan.md> # start a session with this
|
|
79
|
+
fapony mem add decision "what was locked, and why" --files src/x.ts
|
|
80
|
+
fapony mem add bug "what is broken" --files src/x.ts
|
|
81
|
+
fapony mem add note "state the next session needs" --files src/x.ts
|
|
82
|
+
fapony mem close <id> "fixed in <sha>"
|
|
83
|
+
fapony mem find "<text>"
|
|
78
84
|
|
|
79
85
|
Write each entry standalone — it is read months later with no chat to refer to.
|
|
80
86
|
--files is required: rows that name no file cannot be recalled when that file is
|
|
@@ -90,20 +96,20 @@ Finish a chunk, before starting the next:
|
|
|
90
96
|
1. Tick its checkbox + stamp the TL;DR in the plan file
|
|
91
97
|
2. Commit — separate from other chunks
|
|
92
98
|
3. \`verdict_submit\` (fapony MCP), grading what actually happened
|
|
93
|
-
4. \`
|
|
94
|
-
—
|
|
99
|
+
4. \`fapony mem add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
|
|
100
|
+
— use the same plan path every time
|
|
95
101
|
5. Stop. Do not continue to the next chunk in the same session unless told to.
|
|
96
102
|
|
|
97
|
-
Next chunk, new session — open with \`
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
Next chunk, new session — open with \`fapony mem kickoff <path/to/PLAN-x.md>\` instead
|
|
104
|
+
of carrying the old transcript forward. kickoff already filters to the rows for that plan,
|
|
105
|
+
and takes just the filename (\`kickoff PLAN-x.md\`) when you do not want to type the path.`;
|
|
100
106
|
|
|
101
107
|
export function initProject(targetPath: string, config?: Config): void {
|
|
102
108
|
// Create target root
|
|
103
109
|
mkdirSync(targetPath, { recursive: true });
|
|
104
110
|
|
|
105
111
|
// --- .fapony/ marker ---
|
|
106
|
-
const faponyDir = join(targetPath,
|
|
112
|
+
const faponyDir = join(targetPath, FAPONY_DIR);
|
|
107
113
|
if (existsSync(faponyDir)) {
|
|
108
114
|
throw new Error(
|
|
109
115
|
`${faponyDir} already exists — delete it first if you want a fresh scaffold.`,
|
|
@@ -112,6 +118,22 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
112
118
|
mkdirSync(faponyDir, { recursive: true });
|
|
113
119
|
writeFileSync(join(faponyDir, "README"), FAPONY_README);
|
|
114
120
|
|
|
121
|
+
// --- .gitattributes: union-merge the append-only logs ---
|
|
122
|
+
//
|
|
123
|
+
// Every row is one person's append, so both sides of a "conflict" are always
|
|
124
|
+
// right. Without the line, two people whose git user.name collides (admin /
|
|
125
|
+
// user / owner — what a fresh OS install offers) resolve a conflict by hand
|
|
126
|
+
// on every pull. Appended, never rewritten: the file is the repo's, not ours.
|
|
127
|
+
const attrs = join(targetPath, ".gitattributes");
|
|
128
|
+
const attrsBody = existsSync(attrs) ? readFileSync(attrs, "utf-8") : "";
|
|
129
|
+
if (!/^\s*\*\.jsonl\s+merge=union\s*$/m.test(attrsBody)) {
|
|
130
|
+
appendFileSync(
|
|
131
|
+
attrs,
|
|
132
|
+
`${attrsBody && !attrsBody.endsWith("\n") ? "\n" : ""}# append-only memory logs: keep both sides, never hand-resolve\n*.jsonl merge=union\n`,
|
|
133
|
+
);
|
|
134
|
+
console.log(` + ${attrs} — *.jsonl merge=union`);
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
// --- evidence.json (verification_report allowlist — see src/mcp/evidence.ts) ---
|
|
116
138
|
const evidencePath = join(targetPath, evidenceFile(config));
|
|
117
139
|
if (existsSync(evidencePath)) {
|
|
@@ -121,7 +143,7 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
121
143
|
writeFileSync(evidencePath, EVIDENCE_JSON);
|
|
122
144
|
|
|
123
145
|
// --- plan/ spec/ .memory/ — all under .fapony/ ---
|
|
124
|
-
const planDirAbs = join(targetPath, planDir(
|
|
146
|
+
const planDirAbs = join(targetPath, planDir());
|
|
125
147
|
if (existsSync(planDirAbs)) {
|
|
126
148
|
throw new Error(`${planDirAbs} already exists — not overwriting.`);
|
|
127
149
|
}
|
|
@@ -135,44 +157,41 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
135
157
|
mkdirSync(doneDirAbs, { recursive: true });
|
|
136
158
|
|
|
137
159
|
// --- spec/ ---
|
|
138
|
-
const specDirAbs = join(targetPath, specDir(
|
|
160
|
+
const specDirAbs = join(targetPath, specDir());
|
|
139
161
|
if (existsSync(specDirAbs)) {
|
|
140
162
|
throw new Error(`${specDirAbs} already exists — not overwriting.`);
|
|
141
163
|
}
|
|
142
164
|
mkdirSync(specDirAbs, { recursive: true });
|
|
143
165
|
|
|
144
|
-
// --- .memory/ (
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
targetPath,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
`${join(targetPath, memEntry)} already exists — delete it first if you want a fresh copy.`,
|
|
166
|
+
// --- .fapony/.memory/ (empty dir — mem commands are built into fapony now) ---
|
|
167
|
+
const memoryDirPath = join(targetPath, memoryDir(config));
|
|
168
|
+
if (existsSync(memoryDirPath)) {
|
|
169
|
+
console.log(` ${relative(targetPath, memoryDirPath)}/ — already exists`);
|
|
170
|
+
} else {
|
|
171
|
+
mkdirSync(memoryDirPath, { recursive: true });
|
|
172
|
+
console.log(
|
|
173
|
+
` ${relative(targetPath, memoryDirPath)}/ — mem commands are built into fapony (fapony mem ...)`,
|
|
153
174
|
);
|
|
154
175
|
}
|
|
155
|
-
const templateDir = join(import.meta.dir, "..", "templates", "mem");
|
|
156
|
-
const files = copyDir(templateDir, memoryDir);
|
|
157
176
|
|
|
158
177
|
console.log(`scaffolded ${targetPath}/`);
|
|
159
178
|
console.log(
|
|
160
179
|
` .fapony/ — project dir (plans, specs, memory, evidence)`,
|
|
161
180
|
);
|
|
162
|
-
console.log(` ${planDir(
|
|
181
|
+
console.log(` ${planDir()}/ — live plan files`);
|
|
163
182
|
console.log(` ${doneDir(config)}/ — shipped plans (archive)`);
|
|
164
|
-
console.log(` ${specDir(
|
|
183
|
+
console.log(` ${specDir()}/ — spec files`);
|
|
165
184
|
console.log(
|
|
166
185
|
` ${evidenceFile(config)} — allowlist for 'fapony report' (edit the cmds!)`,
|
|
167
186
|
);
|
|
168
187
|
console.log(
|
|
169
|
-
` ${relative(targetPath,
|
|
188
|
+
` ${relative(targetPath, memoryDirPath)}/ — mem commands are built into fapony (fapony mem ...)`,
|
|
170
189
|
);
|
|
171
190
|
console.log(`\nNext: add "${targetPath}" to fapony.config.json worktrees`);
|
|
172
191
|
console.log(
|
|
173
192
|
`\nThen paste this into your agent-rules file (CLAUDE.md / AGENTS.md / opencode.json\ninstructions) — the memory log only fills up if the rules your agent already reads\ntell it to write:\n`,
|
|
174
193
|
);
|
|
175
|
-
console.log(RULES_SNIPPET(
|
|
194
|
+
console.log(RULES_SNIPPET());
|
|
176
195
|
}
|
|
177
196
|
|
|
178
197
|
const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
|
|
@@ -228,8 +247,7 @@ export async function cmdInit(args: string[]): Promise<void> {
|
|
|
228
247
|
);
|
|
229
248
|
if (!isAffirmative(answer)) return;
|
|
230
249
|
|
|
231
|
-
const
|
|
232
|
-
const snippet = `\n\n${RULES_SNIPPET(memEntry)}\n`;
|
|
250
|
+
const snippet = `\n\n${RULES_SNIPPET()}\n`;
|
|
233
251
|
for (const f of found) {
|
|
234
252
|
appendFileSync(f, snippet);
|
|
235
253
|
console.log(` appended to ${relative(targetPath, f)}`);
|
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",
|
|
@@ -345,11 +364,13 @@ function installStopHook(dryRun: boolean, deps: InstallDeps): void {
|
|
|
345
364
|
}
|
|
346
365
|
|
|
347
366
|
/**
|
|
348
|
-
* PreToolUse hook on Read: annotates a full-file read
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
367
|
+
* PreToolUse hook on Read: annotates a full-file read with one factual line —
|
|
368
|
+
* the size + the review-seed command when the file is large, and the re-read
|
|
369
|
+
* line when the same path was already read this session and its mtime has not
|
|
370
|
+
* moved (an unchanged file, so the second read buys nothing; a changed one
|
|
371
|
+
* stays silent). Annotate only — no permissionDecision is ever returned, the
|
|
372
|
+
* read always proceeds. The matcher "Read" keeps the spawn off every other
|
|
373
|
+
* tool call.
|
|
353
374
|
*/
|
|
354
375
|
function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
355
376
|
ensureClaudeHook(dryRun, deps, {
|
|
@@ -359,3 +380,18 @@ function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
|
359
380
|
label: "read hint",
|
|
360
381
|
});
|
|
361
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
|
}
|