moshcode 0.59.0 → 0.61.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 +171 -10
- package/bin/moshcode.mjs +2 -2
- package/package.json +1 -1
- package/prd/0011-herd-agent-protocol.md +391 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +100 -6
- package/src/commands.mjs +85 -10
- package/src/cost.mjs +121 -2
- package/src/engines.mjs +32 -0
- package/src/herd-cli.mjs +812 -20
- package/src/herd-eval.mjs +301 -0
- package/src/herd-hooks.mjs +285 -0
- package/src/herd-remote.mjs +365 -0
- package/src/herd-serve.mjs +515 -0
- package/src/herd-state.mjs +167 -10
- package/src/herd-tasks.mjs +377 -0
- package/src/herd.mjs +89 -7
- package/src/templates.mjs +32 -5
- package/src/tools.mjs +74 -0
- package/src/tui.mjs +1 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// `moshcode herd eval` — which engine is best at *this* repo (PRD 0011 R13).
|
|
2
|
+
//
|
|
3
|
+
// "Which engine should I use" is answered on the internet with benchmarks run
|
|
4
|
+
// against engines nobody deploys, on repos nobody has. The herd can answer it
|
|
5
|
+
// the only way that means anything: run your dataset through the engines you
|
|
6
|
+
// actually have, on the machine you actually work on, and count.
|
|
7
|
+
//
|
|
8
|
+
// Nothing here is new machinery. A row is fanned across the named engines with
|
|
9
|
+
// the verbs that already exist — start a session, prompt it, wait, read what
|
|
10
|
+
// came back out of the ledger — and scored either by a pattern the dataset
|
|
11
|
+
// carries or by an engine acting as judge (the `ai()` verb, which is the same
|
|
12
|
+
// headless invocation moshscript uses). The exit code follows `wait`'s
|
|
13
|
+
// discipline, because a CI job needs to tell "the agent got worse" apart from
|
|
14
|
+
// "the harness fell over", and a single non-zero code cannot.
|
|
15
|
+
//
|
|
16
|
+
// The DO Gradient ADK ships `gradient agent evaluate --dataset-file --categories
|
|
17
|
+
// --success-threshold` for deployed agents. This is that idea pointed at
|
|
18
|
+
// interactive engines, which is the comparison nobody else is placed to run.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
import { runAi } from "./cli.mjs";
|
|
23
|
+
import { ENGINES, resolveEngine, resolveExecutable } from "./engines.mjs";
|
|
24
|
+
import { capture, killSession, listSessions, sendPrompt, startSession } from "./herd.mjs";
|
|
25
|
+
import { endTask, screenDelta, startTask } from "./herd-tasks.mjs";
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_THRESHOLD = 0.8;
|
|
28
|
+
|
|
29
|
+
/* --------------------------------------------------------------- datasets */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A minimal CSV reader: quoted fields, doubled quotes, embedded newlines.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately not a dependency. A dataset is a file someone wrote by hand or
|
|
35
|
+
* exported from a spreadsheet, and those two shapes are the whole requirement.
|
|
36
|
+
*/
|
|
37
|
+
export function parseCsv(text) {
|
|
38
|
+
const rows = [];
|
|
39
|
+
let row = [], field = "", quoted = false;
|
|
40
|
+
const src = String(text ?? "").replace(/\r\n/g, "\n");
|
|
41
|
+
for (let i = 0; i < src.length; i++) {
|
|
42
|
+
const c = src[i];
|
|
43
|
+
if (quoted) {
|
|
44
|
+
if (c === '"') {
|
|
45
|
+
if (src[i + 1] === '"') { field += '"'; i++; }
|
|
46
|
+
else quoted = false;
|
|
47
|
+
} else field += c;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (c === '"') { quoted = true; continue; }
|
|
51
|
+
if (c === ",") { row.push(field); field = ""; continue; }
|
|
52
|
+
if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; continue; }
|
|
53
|
+
field += c;
|
|
54
|
+
}
|
|
55
|
+
if (field.length || row.length) { row.push(field); rows.push(row); }
|
|
56
|
+
return rows.filter((r) => r.some((cell) => String(cell).trim()));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read a dataset. `.jsonl` is one object per line, `.json` an array, `.csv` a
|
|
61
|
+
* header row plus rows. Every shape ends up as the same list of cases.
|
|
62
|
+
*
|
|
63
|
+
* A case is { id, prompt, expect?, rubric? }: the prompt to submit, an optional
|
|
64
|
+
* pattern the answer must match (the `rules` judge), and an optional rubric for
|
|
65
|
+
* an engine judge to score against.
|
|
66
|
+
*/
|
|
67
|
+
export function loadDataset(file) {
|
|
68
|
+
let text;
|
|
69
|
+
try { text = fs.readFileSync(file, "utf8"); }
|
|
70
|
+
catch (error) { return { ok: false, error }; }
|
|
71
|
+
|
|
72
|
+
const ext = path.extname(file).toLowerCase();
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
if (ext === ".csv") {
|
|
76
|
+
const rows = parseCsv(text);
|
|
77
|
+
if (!rows.length) return { ok: false, error: new Error(`${file} is empty`) };
|
|
78
|
+
const header = rows[0].map((h) => String(h).trim().toLowerCase());
|
|
79
|
+
raw = rows.slice(1).map((cells) => Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ""])));
|
|
80
|
+
} else if (ext === ".json") {
|
|
81
|
+
const parsed = JSON.parse(text);
|
|
82
|
+
raw = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.cases) ? parsed.cases : null;
|
|
83
|
+
if (!raw) return { ok: false, error: new Error(`${file} must hold an array of cases`) };
|
|
84
|
+
} else {
|
|
85
|
+
raw = text.split("\n").filter((l) => l.trim()).map((line, i) => {
|
|
86
|
+
try { return JSON.parse(line); }
|
|
87
|
+
catch (error) { throw new Error(`${file}:${i + 1} is not valid JSON (${error.message})`); }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
} catch (error) { return { ok: false, error }; }
|
|
91
|
+
|
|
92
|
+
const cases = [];
|
|
93
|
+
for (const [i, entry] of raw.entries()) {
|
|
94
|
+
const prompt = String(entry?.prompt ?? entry?.input ?? "").trim();
|
|
95
|
+
if (!prompt) return { ok: false, error: new Error(`${file}: case ${i + 1} has no prompt`) };
|
|
96
|
+
cases.push({
|
|
97
|
+
id: String(entry.id ?? `case-${i + 1}`),
|
|
98
|
+
prompt,
|
|
99
|
+
expect: entry.expect ? String(entry.expect) : null,
|
|
100
|
+
rubric: entry.rubric ? String(entry.rubric) : null,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (!cases.length) return { ok: false, error: new Error(`${file} holds no cases`) };
|
|
104
|
+
return { ok: true, cases };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---------------------------------------------------------------- scoring */
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The `rules` judge: does the answer match what the dataset expected?
|
|
111
|
+
*
|
|
112
|
+
* The pattern is a regex, case-insensitive, because a dataset written by hand
|
|
113
|
+
* says `expect: "3 tests passed"` and means it loosely. A case with no
|
|
114
|
+
* expectation cannot be scored by rules, and says so rather than scoring zero —
|
|
115
|
+
* a missing expectation is the dataset's bug, not the engine's.
|
|
116
|
+
*/
|
|
117
|
+
export function scoreByRules(testCase, answer) {
|
|
118
|
+
if (!testCase.expect) {
|
|
119
|
+
return { ok: false, score: 0, why: "no `expect` pattern — this case needs a judge, or an expectation" };
|
|
120
|
+
}
|
|
121
|
+
let re;
|
|
122
|
+
try { re = new RegExp(testCase.expect, "i"); }
|
|
123
|
+
catch { re = null; }
|
|
124
|
+
const hit = re ? re.test(String(answer ?? "")) : String(answer ?? "").toLowerCase().includes(testCase.expect.toLowerCase());
|
|
125
|
+
return { ok: true, score: hit ? 1 : 0, why: hit ? "matched the expectation" : "did not match the expectation" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Pull the first JSON object out of an engine's answer. */
|
|
129
|
+
export function extractVerdict(text) {
|
|
130
|
+
const raw = String(text ?? "");
|
|
131
|
+
const start = raw.indexOf("{");
|
|
132
|
+
if (start < 0) return null;
|
|
133
|
+
for (let end = raw.lastIndexOf("}"); end > start; end = raw.lastIndexOf("}", end - 1)) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
136
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
137
|
+
} catch { /* keep shrinking */ }
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function judgePrompt(testCase, answer) {
|
|
143
|
+
return [
|
|
144
|
+
"You are grading one answer produced by a coding agent. Reply with JSON only.",
|
|
145
|
+
"",
|
|
146
|
+
`TASK: ${testCase.prompt}`,
|
|
147
|
+
testCase.rubric ? `RUBRIC: ${testCase.rubric}` : "RUBRIC: is this a correct, complete, and directly responsive answer to the task?",
|
|
148
|
+
"",
|
|
149
|
+
"ANSWER:",
|
|
150
|
+
String(answer ?? "").slice(-6000),
|
|
151
|
+
"",
|
|
152
|
+
'Reply with exactly: {"score": <0 to 1>, "why": "<one sentence>"}',
|
|
153
|
+
].join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The engine judge. Returns { ok, score, why } and never throws. */
|
|
157
|
+
export function scoreByJudge(testCase, answer, { engine, run = runAi, out = () => {} } = {}) {
|
|
158
|
+
let text;
|
|
159
|
+
try { text = run({ out, dryRun: false }, judgePrompt(testCase, answer), { engine }); }
|
|
160
|
+
catch (error) { return { ok: false, score: 0, why: `judge failed: ${String(error.message || error)}` }; }
|
|
161
|
+
const verdict = extractVerdict(text);
|
|
162
|
+
if (!verdict || typeof verdict.score !== "number" || !Number.isFinite(verdict.score)) {
|
|
163
|
+
return { ok: false, score: 0, why: `judge did not answer with a score (${String(text).trim().slice(0, 120)})` };
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, score: Math.max(0, Math.min(1, verdict.score)), why: String(verdict.why || "").slice(0, 200) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ----------------------------------------------------------------- running */
|
|
169
|
+
|
|
170
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Run every case against one engine, in its own session.
|
|
174
|
+
*
|
|
175
|
+
* Sequential within an engine because a terminal is a terminal: two prompts
|
|
176
|
+
* typed into one session at once interleave into one prompt neither of them
|
|
177
|
+
* asked. Engines run against each other in parallel, which is the fan-out.
|
|
178
|
+
*/
|
|
179
|
+
export async function runEngine(engineKey, cases, {
|
|
180
|
+
waitFor,
|
|
181
|
+
cwd = process.cwd(),
|
|
182
|
+
timeoutMs = 10 * 60 * 1000,
|
|
183
|
+
session = `eval-${engineKey}`,
|
|
184
|
+
keep = false,
|
|
185
|
+
out = () => {},
|
|
186
|
+
now = () => Date.now(),
|
|
187
|
+
} = {}) {
|
|
188
|
+
const engine = ENGINES[engineKey];
|
|
189
|
+
if (!engine) return { engine: engineKey, ok: false, error: `unknown engine ${engineKey}`, results: [] };
|
|
190
|
+
|
|
191
|
+
const already = listSessions().some((s) => s.name === session && s.alive);
|
|
192
|
+
if (!already) {
|
|
193
|
+
const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin;
|
|
194
|
+
const started = startSession({ name: session, engine: engineKey, bin, args: engine.agentArgs || [], stripEnv: engine.stripEnv || [], cwd });
|
|
195
|
+
if (!started.ok) {
|
|
196
|
+
// Infrastructure, not quality. Reported as such so a missing engine never
|
|
197
|
+
// reads as an engine that failed the dataset.
|
|
198
|
+
return { engine: engineKey, ok: false, error: String(started.error?.message || started.error), results: [] };
|
|
199
|
+
}
|
|
200
|
+
// An engine needs a moment to draw its first screen; prompting into a
|
|
201
|
+
// terminal that has not finished starting types into nothing.
|
|
202
|
+
await sleep(4000);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const results = [];
|
|
206
|
+
for (const testCase of cases) {
|
|
207
|
+
const at = now();
|
|
208
|
+
const baseline = capture(session, { lines: 60 });
|
|
209
|
+
const taskId = startTask(session, testCase.prompt, { screen: baseline, now: at });
|
|
210
|
+
const sent = sendPrompt(session, testCase.prompt);
|
|
211
|
+
if (!sent.ok) {
|
|
212
|
+
endTask(session, taskId, { state: "done", artifact: "", ts: now() });
|
|
213
|
+
results.push({ ...testCase, engine: engineKey, taskId, ok: false, answer: "", error: String(sent.error?.message || sent.error) });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
await waitFor(session, ["working"], { timeoutMs: 8000, intervalMs: 500 });
|
|
217
|
+
const outcome = await waitFor(session, ["blocked", "done", "idle"], { timeoutMs });
|
|
218
|
+
const answer = screenDelta(baseline, capture(session, { lines: 400 }));
|
|
219
|
+
endTask(session, taskId, { state: outcome.state, artifact: answer, ts: now() });
|
|
220
|
+
out(` ${engineKey} · ${testCase.id} · ${outcome.outcome} (${outcome.state})`);
|
|
221
|
+
results.push({ ...testCase, engine: engineKey, taskId, ok: outcome.outcome === "matched", answer, outcome: outcome.outcome, state: outcome.state });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!already && !keep) killSession(session);
|
|
225
|
+
return { engine: engineKey, ok: true, session, results };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The whole run: fan the dataset across the engines, score, and total up.
|
|
230
|
+
*
|
|
231
|
+
* `waitFor` is injected rather than imported so the runner can be exercised
|
|
232
|
+
* without a herd — the alternative is a test that starts real engines, which is
|
|
233
|
+
* not a test anyone will run.
|
|
234
|
+
*/
|
|
235
|
+
export async function runEval({
|
|
236
|
+
cases,
|
|
237
|
+
engines,
|
|
238
|
+
judge = "rules",
|
|
239
|
+
threshold = DEFAULT_THRESHOLD,
|
|
240
|
+
waitFor,
|
|
241
|
+
cwd = process.cwd(),
|
|
242
|
+
timeoutMs = 10 * 60 * 1000,
|
|
243
|
+
keep = false,
|
|
244
|
+
out = () => {},
|
|
245
|
+
judgeRun = runAi,
|
|
246
|
+
// Injected so the scoring and aggregation — the parts with the decisions in
|
|
247
|
+
// them — can be tested without starting an engine. A test that needs Claude
|
|
248
|
+
// installed is a test nobody runs.
|
|
249
|
+
run = runEngine,
|
|
250
|
+
} = {}) {
|
|
251
|
+
const runs = await Promise.all(engines.map((engineKey) =>
|
|
252
|
+
run(engineKey, cases, { waitFor, cwd, timeoutMs, keep, out })));
|
|
253
|
+
|
|
254
|
+
const engineResults = runs.map((run) => {
|
|
255
|
+
if (!run.ok) return { engine: run.engine, ok: false, error: run.error, score: 0, cases: [] };
|
|
256
|
+
const scored = run.results.map((result) => {
|
|
257
|
+
const verdict = judge === "rules"
|
|
258
|
+
? scoreByRules(result, result.answer)
|
|
259
|
+
: scoreByJudge(result, result.answer, { engine: judge, run: judgeRun, out });
|
|
260
|
+
return {
|
|
261
|
+
id: result.id, prompt: result.prompt, taskId: result.taskId,
|
|
262
|
+
answer: result.answer, state: result.state ?? null,
|
|
263
|
+
score: verdict.score, why: verdict.why, scored: verdict.ok,
|
|
264
|
+
...(result.error ? { error: result.error } : {}),
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
const total = scored.reduce((sum, c) => sum + c.score, 0);
|
|
268
|
+
return {
|
|
269
|
+
engine: run.engine,
|
|
270
|
+
ok: true,
|
|
271
|
+
score: scored.length ? total / scored.length : 0,
|
|
272
|
+
passed: scored.filter((c) => c.score >= 1).length,
|
|
273
|
+
unscorable: scored.filter((c) => !c.scored).length,
|
|
274
|
+
cases: scored,
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const infrastructure = engineResults.filter((e) => !e.ok);
|
|
279
|
+
const below = engineResults.filter((e) => e.ok && e.score < threshold);
|
|
280
|
+
return {
|
|
281
|
+
judge, threshold,
|
|
282
|
+
engines: engineResults,
|
|
283
|
+
// The three outcomes CI needs to tell apart, decided here rather than at
|
|
284
|
+
// the exit-code site, so `--json` and the exit code cannot disagree.
|
|
285
|
+
outcome: infrastructure.length ? "infrastructure" : below.length ? "below" : "pass",
|
|
286
|
+
below: below.map((e) => e.engine),
|
|
287
|
+
broken: infrastructure.map((e) => ({ engine: e.engine, error: e.error })),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Resolve `--engines a,b` to canonical keys, naming anything it cannot. */
|
|
292
|
+
export function resolveEngines(list) {
|
|
293
|
+
const wanted = String(list || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
294
|
+
const keys = [], unknown = [];
|
|
295
|
+
for (const name of wanted) {
|
|
296
|
+
const resolved = resolveEngine(name);
|
|
297
|
+
if (resolved) keys.push(resolved[0]);
|
|
298
|
+
else unknown.push(name);
|
|
299
|
+
}
|
|
300
|
+
return { keys: [...new Set(keys)], unknown };
|
|
301
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Engine lifecycle hooks — the herd's tier-1 state, installed (PRD 0011 R1–R2).
|
|
2
|
+
//
|
|
3
|
+
// PRD 0009 built the mechanism and never plugged anything into it. `moshcode
|
|
4
|
+
// herd report` has existed since the herd did; it beats the screen, it is
|
|
5
|
+
// TTL-bounded, and on a default install nothing ever called it. So every
|
|
6
|
+
// session was classified by regex against a screen capture, and every engine
|
|
7
|
+
// release was a chance for the roster to start lying — a weakness
|
|
8
|
+
// src/herd-state.mjs documents about itself in its own header.
|
|
9
|
+
//
|
|
10
|
+
// This is the other end of that socket. `herd hooks install claude` writes
|
|
11
|
+
// Claude Code's own lifecycle hooks so the engine reports its state directly,
|
|
12
|
+
// and the roster starts reading `authority: hook`.
|
|
13
|
+
//
|
|
14
|
+
// THREE RULES, all of them about not being a bad guest in someone's config:
|
|
15
|
+
//
|
|
16
|
+
// MERGE, NEVER CLOBBER. The file we write is the user's, and it is the file
|
|
17
|
+
// their other hooks live in. Install extends it; remove takes out only the
|
|
18
|
+
// entries whose command is ours, and leaves empty structure behind only when
|
|
19
|
+
// it was already there.
|
|
20
|
+
//
|
|
21
|
+
// A HOOK MUST NEVER BREAK AN ENGINE. The command is guarded so that outside a
|
|
22
|
+
// herd session — no MOSHCODE_HERD_NAME — it does nothing and exits 0, and so
|
|
23
|
+
// that a box without moshcode on PATH gets the same silence rather than a
|
|
24
|
+
// failing hook on every turn. Degrading to today's screen rules is fine;
|
|
25
|
+
// degrading below today is not.
|
|
26
|
+
//
|
|
27
|
+
// THE SCREEN RULES STAY. A hook that a schema change quietly breaks falls back
|
|
28
|
+
// to exactly what the herd did before it, which is why engines.mjs keeps its
|
|
29
|
+
// `state` patterns alongside the new `hooks` spec rather than replacing them.
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
|
|
33
|
+
import { ENGINES } from "./engines.mjs";
|
|
34
|
+
|
|
35
|
+
/** Engines that ship a hook spec, in table order. */
|
|
36
|
+
export function hookableEngines() {
|
|
37
|
+
return Object.entries(ENGINES).filter(([, engine]) => engine.hooks).map(([key]) => key);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The shell command one hook runs.
|
|
42
|
+
*
|
|
43
|
+
* Every clause is load-bearing:
|
|
44
|
+
* `[ -n "$MOSHCODE_HERD_NAME" ]` — outside the herd this hook is a no-op. The
|
|
45
|
+
* engine is used by hand far more often than it is used in a herd.
|
|
46
|
+
* `command -v moshcode` — a machine where moshcode was uninstalled must not
|
|
47
|
+
* get a failing hook on every turn of an engine that still works.
|
|
48
|
+
* `>/dev/null 2>&1` — a status report has nothing to say to the operator; its
|
|
49
|
+
* whole output belongs in the roster, not in the middle of a session.
|
|
50
|
+
* `; exit 0` — whatever happened above, the engine carries on.
|
|
51
|
+
*/
|
|
52
|
+
export function hookCommand(state) {
|
|
53
|
+
return `[ -n "$MOSHCODE_HERD_NAME" ] && command -v moshcode >/dev/null 2>&1 `
|
|
54
|
+
+ `&& moshcode herd report "$MOSHCODE_HERD_NAME" ${state} >/dev/null 2>&1; exit 0`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Is this hook entry one of ours?
|
|
59
|
+
*
|
|
60
|
+
* Matched on the command text rather than on a marker field we invent, because
|
|
61
|
+
* the file's schema belongs to the engine: an unknown key is something the
|
|
62
|
+
* engine is entitled to reject, and a hook config it rejects is worse than no
|
|
63
|
+
* hook at all. The command is a string we wrote, so it is a marker already.
|
|
64
|
+
*/
|
|
65
|
+
export function isOurs(entry) {
|
|
66
|
+
return typeof entry?.command === "string" && /\bmoshcode herd report\b/.test(entry.command);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const HOOK_FILE_MODE = 0o600;
|
|
70
|
+
|
|
71
|
+
function readJsonFile(file) {
|
|
72
|
+
let text;
|
|
73
|
+
try { text = fs.readFileSync(file, "utf8"); }
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code === "ENOENT") return { ok: true, present: false, data: {} };
|
|
76
|
+
return { ok: false, present: true, error };
|
|
77
|
+
}
|
|
78
|
+
if (!text.trim()) return { ok: true, present: true, data: {} };
|
|
79
|
+
try { return { ok: true, present: true, data: JSON.parse(text) }; }
|
|
80
|
+
catch (error) {
|
|
81
|
+
// Refusing is the whole point. A settings file we cannot parse is one we
|
|
82
|
+
// cannot merge into, and overwriting it would take every other hook, MCP
|
|
83
|
+
// server and preference in it with us.
|
|
84
|
+
return { ok: false, present: true, error: new Error(`${file} is not valid JSON (${error.message}) — fix it and re-run`) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeJsonFile(file, data, { mode = HOOK_FILE_MODE } = {}) {
|
|
89
|
+
const body = `${JSON.stringify(data, null, 2)}\n`;
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
91
|
+
// Write-then-rename: a crash mid-write on the engine's own settings file
|
|
92
|
+
// would otherwise leave it truncated, which is the one failure that costs
|
|
93
|
+
// more than the feature is worth.
|
|
94
|
+
const tmp = `${file}.moshcode-${process.pid}`;
|
|
95
|
+
fs.writeFileSync(tmp, body, { mode });
|
|
96
|
+
fs.renameSync(tmp, file);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The mode an existing file already has, so an install does not tighten it. */
|
|
100
|
+
function existingMode(file) {
|
|
101
|
+
try { return fs.statSync(file).mode & 0o777; }
|
|
102
|
+
catch { return HOOK_FILE_MODE; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Where this engine's hooks live, resolved now (specs hold a function). */
|
|
106
|
+
export function hookFile(engine) {
|
|
107
|
+
const spec = ENGINES[engine]?.hooks;
|
|
108
|
+
if (!spec) return null;
|
|
109
|
+
return typeof spec.file === "function" ? spec.file() : spec.file;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// The claude-settings format
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
//
|
|
116
|
+
// { "hooks": { "<Event>": [ { "hooks": [ { "type": "command", "command": … } ] } ] } }
|
|
117
|
+
//
|
|
118
|
+
// The outer array is matcher groups. Stop, Notification and UserPromptSubmit
|
|
119
|
+
// take no matcher, so ours is a group of one hook with no matcher key — an
|
|
120
|
+
// empty `matcher` would be a claim about tool names for events that have none.
|
|
121
|
+
|
|
122
|
+
function ensureArray(object, key) {
|
|
123
|
+
if (!Array.isArray(object[key])) object[key] = [];
|
|
124
|
+
return object[key];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Add (or refresh) our entry for one event. Returns what changed, so the caller
|
|
129
|
+
* can tell "installed 3" from "already installed" without diffing twice.
|
|
130
|
+
*/
|
|
131
|
+
function mergeEvent(settings, event, command) {
|
|
132
|
+
const hooks = (settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks))
|
|
133
|
+
? settings.hooks
|
|
134
|
+
: (settings.hooks = {});
|
|
135
|
+
const groups = ensureArray(hooks, event);
|
|
136
|
+
|
|
137
|
+
for (const group of groups) {
|
|
138
|
+
const entries = Array.isArray(group?.hooks) ? group.hooks : null;
|
|
139
|
+
if (!entries) continue;
|
|
140
|
+
const at = entries.findIndex(isOurs);
|
|
141
|
+
if (at < 0) continue;
|
|
142
|
+
if (entries[at].command === command) return "unchanged";
|
|
143
|
+
// Ours, but not the current text — an upgrade that changed the command, or
|
|
144
|
+
// a hand-edit. Replacing beats appending a second copy that fires twice.
|
|
145
|
+
entries[at] = { type: "command", command };
|
|
146
|
+
return "updated";
|
|
147
|
+
}
|
|
148
|
+
groups.push({ hooks: [{ type: "command", command }] });
|
|
149
|
+
return "added";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Take our entries back out, leaving structure we did not create alone. */
|
|
153
|
+
function pruneEvent(settings, event) {
|
|
154
|
+
const hooks = settings.hooks;
|
|
155
|
+
if (!hooks || typeof hooks !== "object" || !Array.isArray(hooks[event])) return 0;
|
|
156
|
+
let removed = 0;
|
|
157
|
+
const groups = [];
|
|
158
|
+
for (const group of hooks[event]) {
|
|
159
|
+
if (!Array.isArray(group?.hooks)) { groups.push(group); continue; }
|
|
160
|
+
const before = group.hooks.length;
|
|
161
|
+
const kept = group.hooks.filter((entry) => !isOurs(entry));
|
|
162
|
+
removed += before - kept.length;
|
|
163
|
+
// A group that held only our hook goes with it; one that held someone
|
|
164
|
+
// else's stays, with theirs intact.
|
|
165
|
+
if (!kept.length && before) continue;
|
|
166
|
+
groups.push({ ...group, hooks: kept });
|
|
167
|
+
}
|
|
168
|
+
if (groups.length) hooks[event] = groups;
|
|
169
|
+
else delete hooks[event];
|
|
170
|
+
if (!Object.keys(hooks).length) delete settings.hooks;
|
|
171
|
+
return removed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What is installed for one engine right now. */
|
|
175
|
+
export function hooksStatus(engine, { file = hookFile(engine) } = {}) {
|
|
176
|
+
const spec = ENGINES[engine]?.hooks;
|
|
177
|
+
if (!spec) return { engine, supported: false, file: null, events: [] };
|
|
178
|
+
const read = readJsonFile(file);
|
|
179
|
+
if (!read.ok) {
|
|
180
|
+
return { engine, supported: true, file, readable: false, error: String(read.error?.message || read.error), events: [] };
|
|
181
|
+
}
|
|
182
|
+
const settings = read.data || {};
|
|
183
|
+
const events = spec.events.map(({ event, state, label }) => {
|
|
184
|
+
const want = hookCommand(state);
|
|
185
|
+
const groups = Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [];
|
|
186
|
+
const found = groups.flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : [])).filter(isOurs);
|
|
187
|
+
if (!found.length) return { event, label: label || event, state, installed: false };
|
|
188
|
+
// "Installed, but not the command this version writes" is its own answer:
|
|
189
|
+
// it is how a spec change after an upgrade shows up, and `install` fixes it.
|
|
190
|
+
return { event, label: label || event, state, installed: true, current: found.some((h) => h.command === want) };
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
engine,
|
|
194
|
+
supported: true,
|
|
195
|
+
file,
|
|
196
|
+
readable: true,
|
|
197
|
+
present: read.present,
|
|
198
|
+
installed: events.every((e) => e.installed && e.current),
|
|
199
|
+
partial: events.some((e) => e.installed) && !events.every((e) => e.installed && e.current),
|
|
200
|
+
events,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Write this engine's hooks. `dryRun` computes everything and writes nothing,
|
|
206
|
+
* returning the file as it would have been so the caller can show a diff.
|
|
207
|
+
*/
|
|
208
|
+
export function installHooks(engine, { file = hookFile(engine), dryRun = false } = {}) {
|
|
209
|
+
const spec = ENGINES[engine]?.hooks;
|
|
210
|
+
if (!spec) {
|
|
211
|
+
return { ok: false, engine, supported: false, error: new Error(`${engine} ships no hook spec — its sessions stay on the screen rules`) };
|
|
212
|
+
}
|
|
213
|
+
const read = readJsonFile(file);
|
|
214
|
+
if (!read.ok) return { ok: false, engine, supported: true, file, error: read.error };
|
|
215
|
+
|
|
216
|
+
const before = JSON.stringify(read.data ?? {}, null, 2);
|
|
217
|
+
const settings = read.data ?? {};
|
|
218
|
+
const changes = spec.events.map(({ event, state, label }) => ({ event, label: label || event, state, change: mergeEvent(settings, event, hookCommand(state)) }));
|
|
219
|
+
const after = JSON.stringify(settings, null, 2);
|
|
220
|
+
|
|
221
|
+
if (!dryRun) {
|
|
222
|
+
try { writeJsonFile(file, settings, { mode: read.present ? existingMode(file) : HOOK_FILE_MODE }); }
|
|
223
|
+
catch (error) { return { ok: false, engine, supported: true, file, error }; }
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
ok: true, engine, supported: true, file, dryRun,
|
|
227
|
+
changes,
|
|
228
|
+
written: changes.filter((c) => c.change !== "unchanged").length,
|
|
229
|
+
before, after,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Take them out again. Only ever removes commands this module wrote. */
|
|
234
|
+
export function removeHooks(engine, { file = hookFile(engine), dryRun = false } = {}) {
|
|
235
|
+
const spec = ENGINES[engine]?.hooks;
|
|
236
|
+
if (!spec) return { ok: false, engine, supported: false, error: new Error(`${engine} ships no hook spec`) };
|
|
237
|
+
const read = readJsonFile(file);
|
|
238
|
+
if (!read.ok) return { ok: false, engine, supported: true, file, error: read.error };
|
|
239
|
+
if (!read.present) return { ok: true, engine, supported: true, file, removed: 0, dryRun };
|
|
240
|
+
|
|
241
|
+
const settings = read.data ?? {};
|
|
242
|
+
const before = JSON.stringify(settings, null, 2);
|
|
243
|
+
let removed = 0;
|
|
244
|
+
// Every event the spec knows about, plus any event that still carries one of
|
|
245
|
+
// ours from an older spec — otherwise `remove` after an upgrade would leave
|
|
246
|
+
// the hook the previous version installed firing forever.
|
|
247
|
+
const events = new Set([
|
|
248
|
+
...spec.events.map((e) => e.event),
|
|
249
|
+
...Object.keys(settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {}),
|
|
250
|
+
]);
|
|
251
|
+
for (const event of events) removed += pruneEvent(settings, event);
|
|
252
|
+
const after = JSON.stringify(settings, null, 2);
|
|
253
|
+
|
|
254
|
+
if (!dryRun && removed) {
|
|
255
|
+
try { writeJsonFile(file, settings, { mode: existingMode(file) }); }
|
|
256
|
+
catch (error) { return { ok: false, engine, supported: true, file, error }; }
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, engine, supported: true, file, removed, dryRun, before, after };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A unified diff of the two JSON snapshots an install/remove produced.
|
|
263
|
+
*
|
|
264
|
+
* Hand-rolled and deliberately dumb — a line is context, an addition, or a
|
|
265
|
+
* removal, decided by whether the other side has it at the same place. What
|
|
266
|
+
* `--dry-run` needs is "show me what you are about to do to my settings file",
|
|
267
|
+
* and for a JSON object printed at two-space indent that is what this gives.
|
|
268
|
+
*/
|
|
269
|
+
export function hookDiff(before = "", after = "") {
|
|
270
|
+
const a = String(before).split("\n");
|
|
271
|
+
const b = String(after).split("\n");
|
|
272
|
+
const out = [];
|
|
273
|
+
let i = 0, j = 0;
|
|
274
|
+
while (i < a.length || j < b.length) {
|
|
275
|
+
if (i < a.length && j < b.length && a[i] === b[j]) { out.push(` ${a[i]}`); i++; j++; continue; }
|
|
276
|
+
const laterInB = b.indexOf(a[i] ?? "", j);
|
|
277
|
+
const laterInA = a.indexOf(b[j] ?? "", i);
|
|
278
|
+
if (i >= a.length || (laterInB >= 0 && (laterInA < 0 || laterInB - j <= laterInA - i))) {
|
|
279
|
+
out.push(`+ ${b[j]}`); j++;
|
|
280
|
+
} else {
|
|
281
|
+
out.push(`- ${a[i]}`); i++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out.join("\n");
|
|
285
|
+
}
|