baldart 4.86.0 → 4.88.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/CHANGELOG.md +39 -0
- package/VERSION +1 -1
- package/framework/.claude/hooks/agent-discovery-info.sh +30 -3
- package/framework/.claude/hooks/framework-edit-gate.js +147 -14
- package/framework/agents/skills-mapping.md +30 -1
- package/framework/docs/CODEX-AGENTS.md +4 -0
- package/framework/docs/CODEX-HOOKS.md +142 -0
- package/framework/templates/primitives/AGENTS.CHANGELOG.md +9 -0
- package/framework/templates/primitives/AGENTS.md +5 -1
- package/package.json +1 -1
- package/src/commands/add.js +45 -0
- package/src/commands/doctor.js +104 -0
- package/src/commands/update.js +31 -0
- package/src/utils/hooks/claude.js +402 -0
- package/src/utils/hooks/codex.js +360 -0
- package/src/utils/hooks/registry.js +176 -0
- package/src/utils/hooks.js +17 -467
- package/src/utils/tool-adapters/claude.js +3 -0
- package/src/utils/tool-adapters/codex.js +12 -1
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex hook renderer for `.codex/hooks.json`.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `./claude.js`: same runtime-neutral registry (`./registry.js`),
|
|
5
|
+
* same call sites (add/update/doctor), a different on-disk target. Codex uses
|
|
6
|
+
* the SAME hooks schema as Claude Code — verified against the OpenAI Codex
|
|
7
|
+
* hooks spec (CLI 0.142.5):
|
|
8
|
+
*
|
|
9
|
+
* { "hooks": { "<Event>": [ { "matcher": "<regex?>",
|
|
10
|
+
* "hooks": [ { "type": "command", "command": "…" } ] } ] } }
|
|
11
|
+
*
|
|
12
|
+
* Two structural differences drive this renderer:
|
|
13
|
+
* 1. **No per-entry `id`.** Codex addresses hooks by position and trusts them
|
|
14
|
+
* by content hash, and its (Rust/serde) deserializer is unforgiving of
|
|
15
|
+
* unknown fields — so we DO NOT stamp an `id` on our entries. Ownership is
|
|
16
|
+
* matched by a stable command substring (`def.marker`) instead.
|
|
17
|
+
* 2. **Hook trust.** A new or changed hook is skipped until the user approves
|
|
18
|
+
* it; the approval is recorded in `~/.codex/config.toml` as
|
|
19
|
+
* `[hooks.state."<hooks.json abspath>:<event_snake>:<entryIdx>:<hookIdx>"]`
|
|
20
|
+
* with a `trusted_hash`. BALDART NEVER writes that trust (faking it would
|
|
21
|
+
* defeat the security model) — it only *reads* it so `doctor` can report
|
|
22
|
+
* "configured but not trusted" separately from "missing".
|
|
23
|
+
*
|
|
24
|
+
* Additive by contract: entries we don't own (Graphify's `graphify hook-check`,
|
|
25
|
+
* user hooks) are preserved verbatim, in place. We only append our own entries
|
|
26
|
+
* and reconcile drift on entries carrying our marker.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const { defsFor } = require('./registry');
|
|
33
|
+
|
|
34
|
+
const HOOKS_FILE = path.join('.codex', 'hooks.json');
|
|
35
|
+
|
|
36
|
+
/** The Codex-runtime hook definitions: `{ id, order, event, matcher?, command, marker }`. */
|
|
37
|
+
const CODEX_HOOKS = defsFor('codex');
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// hooks.json I/O
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function hooksPath(cwd = process.cwd()) {
|
|
44
|
+
return path.join(cwd, HOOKS_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readHooks(cwd = process.cwd()) {
|
|
48
|
+
const full = hooksPath(cwd);
|
|
49
|
+
if (!fs.existsSync(full)) return { path: full, doc: null, malformed: false };
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(full, 'utf8');
|
|
52
|
+
if (!raw.trim()) return { path: full, doc: {}, malformed: false };
|
|
53
|
+
return { path: full, doc: JSON.parse(raw), malformed: false };
|
|
54
|
+
} catch (_) {
|
|
55
|
+
return { path: full, doc: null, malformed: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeHooks(hooksJsonPath, doc) {
|
|
60
|
+
fs.mkdirSync(path.dirname(hooksJsonPath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(hooksJsonPath, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Ownership (by command marker — Codex entries carry no id)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Find the first entry/hook pair in `eventList` whose command contains
|
|
70
|
+
* `def.marker` (our stable ownership substring). Returns index coordinates so
|
|
71
|
+
* the trust key can be computed. `null` when not registered.
|
|
72
|
+
*/
|
|
73
|
+
function findOwnedEntry(eventList, def) {
|
|
74
|
+
if (!Array.isArray(eventList)) return null;
|
|
75
|
+
for (let i = 0; i < eventList.length; i++) {
|
|
76
|
+
const entry = eventList[i];
|
|
77
|
+
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
78
|
+
for (let j = 0; j < entry.hooks.length; j++) {
|
|
79
|
+
const h = entry.hooks[j];
|
|
80
|
+
if (!h || h.type !== 'command' || typeof h.command !== 'string') continue;
|
|
81
|
+
if (h.command.includes(def.marker)) {
|
|
82
|
+
return { entryIdx: i, hookIdx: j, entry, hook: h };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Trust probe (~/.codex/config.toml)
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function codexHome() {
|
|
94
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** camelCase event (`PreToolUse`) → snake_case trust-key segment (`pre_tool_use`). */
|
|
98
|
+
function eventSnake(event) {
|
|
99
|
+
return String(event)
|
|
100
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
101
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
102
|
+
.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse `~/.codex/config.toml` and return the Set of `[hooks.state."<key>"]`
|
|
107
|
+
* keys that carry a `trusted_hash`. Dependency-free (no TOML lib — same
|
|
108
|
+
* hand-parse posture as codex-agent-transpiler.js). Best-effort: any read/parse
|
|
109
|
+
* problem yields an empty set (→ "not trusted", the safe report).
|
|
110
|
+
*/
|
|
111
|
+
function readTrustedKeys() {
|
|
112
|
+
const cfg = path.join(codexHome(), 'config.toml');
|
|
113
|
+
let raw;
|
|
114
|
+
try { raw = fs.readFileSync(cfg, 'utf8'); } catch (_) { return new Set(); }
|
|
115
|
+
const trusted = new Set();
|
|
116
|
+
// Split into TOML table sections at every line starting with `[`.
|
|
117
|
+
const lines = raw.split('\n');
|
|
118
|
+
let curKey = null;
|
|
119
|
+
let sawHash = false;
|
|
120
|
+
const flush = () => { if (curKey && sawHash) trusted.add(curKey); };
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
const header = line.match(/^\s*\[hooks\.state\."(.+?)"\]\s*$/);
|
|
123
|
+
if (header) { flush(); curKey = header[1]; sawHash = false; continue; }
|
|
124
|
+
if (/^\s*\[/.test(line)) { flush(); curKey = null; sawHash = false; continue; }
|
|
125
|
+
if (curKey && /^\s*trusted_hash\s*=/.test(line)) sawHash = true;
|
|
126
|
+
}
|
|
127
|
+
flush();
|
|
128
|
+
return trusted;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Trust key Codex uses for a hook at a given position in a given hooks.json. */
|
|
132
|
+
function trustKey(hooksJsonAbsPath, event, entryIdx, hookIdx) {
|
|
133
|
+
return `${hooksJsonAbsPath}:${eventSnake(event)}:${entryIdx}:${hookIdx}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Status
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {Object} CodexHookStatus
|
|
142
|
+
* @property {string} id
|
|
143
|
+
* @property {string} event
|
|
144
|
+
* @property {boolean} registered present (by marker) in .codex/hooks.json
|
|
145
|
+
* @property {boolean} drift registered but command differs from expected
|
|
146
|
+
* @property {boolean} trusted a trusted_hash entry exists for its position key
|
|
147
|
+
* @property {string} expected
|
|
148
|
+
* @property {string} [actual]
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Status of every Codex hook. Pure read. `trusted` reflects presence of a
|
|
153
|
+
* trust entry at the hook's position key — it does NOT re-verify the hash
|
|
154
|
+
* (Codex's hash algorithm is unspecified), so a drifted hook may still read
|
|
155
|
+
* `trusted: true` from a stale entry. `doctor` surfaces this as best-effort.
|
|
156
|
+
*
|
|
157
|
+
* @returns {{ malformed: boolean, path: string, hooks: CodexHookStatus[] }}
|
|
158
|
+
*/
|
|
159
|
+
function getStatus(cwd = process.cwd()) {
|
|
160
|
+
const { path: hooksJsonPath, doc, malformed } = readHooks(cwd);
|
|
161
|
+
if (malformed) return { malformed: true, path: hooksJsonPath, hooks: [] };
|
|
162
|
+
const abs = path.resolve(hooksJsonPath);
|
|
163
|
+
const allHooks = (doc && doc.hooks) || {};
|
|
164
|
+
const trustedKeys = readTrustedKeys();
|
|
165
|
+
const hooks = CODEX_HOOKS.map((def) => {
|
|
166
|
+
const found = findOwnedEntry(allHooks[def.event], def);
|
|
167
|
+
if (!found) {
|
|
168
|
+
return { id: def.id, event: def.event, registered: false, drift: false, trusted: false, expected: def.command };
|
|
169
|
+
}
|
|
170
|
+
const actual = found.hook.command;
|
|
171
|
+
const key = trustKey(abs, def.event, found.entryIdx, found.hookIdx);
|
|
172
|
+
return {
|
|
173
|
+
id: def.id, event: def.event,
|
|
174
|
+
registered: true,
|
|
175
|
+
drift: actual !== def.command,
|
|
176
|
+
trusted: trustedKeys.has(key),
|
|
177
|
+
expected: def.command,
|
|
178
|
+
actual,
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
return { malformed: false, path: hooksJsonPath, hooks };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Register (additive, drift-aware, preserves foreign entries)
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Idempotently register every Codex hook. Foreign entries (Graphify, user
|
|
190
|
+
* hooks) are never touched. Mirrors `claude.js#registerAll`'s return shape.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} [cwd]
|
|
193
|
+
* @param {{ onDrift?: (info) => Promise<'keep'|'replace'>|('keep'|'replace') }} [options]
|
|
194
|
+
*/
|
|
195
|
+
async function registerAll(cwd = process.cwd(), options = {}) {
|
|
196
|
+
const onDrift = typeof options.onDrift === 'function' ? options.onDrift : () => 'keep';
|
|
197
|
+
|
|
198
|
+
const { path: hooksJsonPath, doc: existing, malformed } = readHooks(cwd);
|
|
199
|
+
if (malformed) {
|
|
200
|
+
return { malformed: true, path: hooksJsonPath, created: false, updated: false, results: [] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const wasNew = !existing;
|
|
204
|
+
const doc = existing && typeof existing === 'object' ? structuredClone(existing) : {};
|
|
205
|
+
doc.hooks = doc.hooks && typeof doc.hooks === 'object' ? doc.hooks : {};
|
|
206
|
+
|
|
207
|
+
const results = [];
|
|
208
|
+
let modified = false;
|
|
209
|
+
|
|
210
|
+
for (const def of CODEX_HOOKS) {
|
|
211
|
+
const list = Array.isArray(doc.hooks[def.event]) ? doc.hooks[def.event] : [];
|
|
212
|
+
const found = findOwnedEntry(list, def);
|
|
213
|
+
|
|
214
|
+
if (!found) {
|
|
215
|
+
const entry = { hooks: [{ type: 'command', command: def.command }] };
|
|
216
|
+
if (def.matcher) entry.matcher = def.matcher;
|
|
217
|
+
list.push(entry);
|
|
218
|
+
doc.hooks[def.event] = list;
|
|
219
|
+
results.push({ id: def.id, status: 'created' });
|
|
220
|
+
modified = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (found.hook.command === def.command) {
|
|
225
|
+
results.push({ id: def.id, status: 'already' });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const decision = await onDrift({
|
|
230
|
+
id: def.id, event: def.event, matcher: def.matcher,
|
|
231
|
+
expected: def.command, actual: found.hook.command,
|
|
232
|
+
});
|
|
233
|
+
if (decision === 'replace') {
|
|
234
|
+
const previousCommand = found.hook.command;
|
|
235
|
+
found.hook.command = def.command;
|
|
236
|
+
results.push({ id: def.id, status: 'drift-replaced', previousCommand });
|
|
237
|
+
modified = true;
|
|
238
|
+
} else {
|
|
239
|
+
results.push({ id: def.id, status: 'drift-kept', previousCommand: found.hook.command });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (modified) writeHooks(hooksJsonPath, doc);
|
|
244
|
+
return {
|
|
245
|
+
malformed: false, path: hooksJsonPath,
|
|
246
|
+
created: modified && wasNew, updated: modified && !wasNew, results,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Unregister
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
function unregisterAll(cwd = process.cwd()) {
|
|
255
|
+
const { path: hooksJsonPath, doc, malformed } = readHooks(cwd);
|
|
256
|
+
if (malformed || !doc || !doc.hooks) {
|
|
257
|
+
return { malformed, path: hooksJsonPath, results: CODEX_HOOKS.map((d) => ({ id: d.id, status: 'not-found' })) };
|
|
258
|
+
}
|
|
259
|
+
const next = structuredClone(doc);
|
|
260
|
+
const results = [];
|
|
261
|
+
let modified = false;
|
|
262
|
+
|
|
263
|
+
for (const def of CODEX_HOOKS) {
|
|
264
|
+
const list = Array.isArray(next.hooks[def.event]) ? next.hooks[def.event] : null;
|
|
265
|
+
if (!list) { results.push({ id: def.id, status: 'not-found' }); continue; }
|
|
266
|
+
let removed = false;
|
|
267
|
+
const filtered = list
|
|
268
|
+
.map((entry) => {
|
|
269
|
+
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
270
|
+
const innerKept = entry.hooks.filter((h) => {
|
|
271
|
+
if (h && h.type === 'command' && typeof h.command === 'string' && h.command.includes(def.marker)) {
|
|
272
|
+
removed = true; return false;
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
if (innerKept.length === 0) return null;
|
|
277
|
+
return { ...entry, hooks: innerKept };
|
|
278
|
+
})
|
|
279
|
+
.filter(Boolean);
|
|
280
|
+
|
|
281
|
+
if (removed) {
|
|
282
|
+
if (filtered.length === 0) delete next.hooks[def.event];
|
|
283
|
+
else next.hooks[def.event] = filtered;
|
|
284
|
+
modified = true;
|
|
285
|
+
results.push({ id: def.id, status: 'removed' });
|
|
286
|
+
} else {
|
|
287
|
+
results.push({ id: def.id, status: 'not-found' });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (next.hooks && Object.keys(next.hooks).length === 0) delete next.hooks;
|
|
292
|
+
if (modified) writeHooks(hooksJsonPath, next);
|
|
293
|
+
return { malformed: false, path: hooksJsonPath, results };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Verify
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Confirms every Codex hook is present (by marker) in `.codex/hooks.json`.
|
|
302
|
+
* Trust is intentionally NOT part of `ok` — a hook can be correctly configured
|
|
303
|
+
* yet awaiting the user's one-time Codex trust approval, which BALDART cannot
|
|
304
|
+
* perform. `untrusted` is reported separately for `doctor` to surface.
|
|
305
|
+
*
|
|
306
|
+
* @returns {{ ok: boolean, malformed: boolean, missing: string[], untrusted: string[], path: string }}
|
|
307
|
+
*/
|
|
308
|
+
function verifyAll(cwd = process.cwd()) {
|
|
309
|
+
const status = getStatus(cwd);
|
|
310
|
+
if (status.malformed) {
|
|
311
|
+
return { ok: false, malformed: true, missing: CODEX_HOOKS.map((d) => d.id), untrusted: [], path: status.path };
|
|
312
|
+
}
|
|
313
|
+
const missing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
|
|
314
|
+
const untrusted = status.hooks.filter((h) => h.registered && !h.trusted).map((h) => h.id);
|
|
315
|
+
return { ok: missing.length === 0, malformed: false, missing, untrusted, path: status.path };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Drift prompt (mirrors claude.js so add/update/doctor can reuse it verbatim)
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function createDriftPrompt(UI, { autoYes = false } = {}) {
|
|
323
|
+
if (autoYes) {
|
|
324
|
+
return async ({ id }) => {
|
|
325
|
+
UI.warning(`Codex hook drift for \`${id}\` — keeping current command (auto mode). Run \`baldart doctor\` interactively to resolve.`);
|
|
326
|
+
return 'keep';
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const truncate = (s, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
330
|
+
return async function onDrift({ id, event, expected, actual }) {
|
|
331
|
+
UI.warning(`Codex hook \`${id}\` (${event}) is registered with a non-default command.`);
|
|
332
|
+
for (;;) {
|
|
333
|
+
const choice = await UI.select(
|
|
334
|
+
`What do you want to do with \`${id}\` in .codex/hooks.json?`,
|
|
335
|
+
[
|
|
336
|
+
{ name: `Keep current — leave: ${truncate(actual)}`, value: 'keep' },
|
|
337
|
+
{ name: `Replace with BALDART — set: ${truncate(expected)}`, value: 'replace' },
|
|
338
|
+
{ name: `Show full diff and decide`, value: 'diff' },
|
|
339
|
+
],
|
|
340
|
+
);
|
|
341
|
+
if (choice !== 'diff') return choice;
|
|
342
|
+
console.log(` expected: ${expected}`);
|
|
343
|
+
console.log(` actual: ${actual}`);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = {
|
|
349
|
+
HOOKS_FILE,
|
|
350
|
+
CODEX_HOOKS,
|
|
351
|
+
hooksPath,
|
|
352
|
+
getStatus,
|
|
353
|
+
verifyAll,
|
|
354
|
+
registerAll,
|
|
355
|
+
unregisterAll,
|
|
356
|
+
createDriftPrompt,
|
|
357
|
+
readTrustedKeys,
|
|
358
|
+
trustKey,
|
|
359
|
+
eventSnake,
|
|
360
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-neutral hook registry — S1 (hook-registry split) of the BALDART
|
|
3
|
+
* cross-tool Codex-parity foundation.
|
|
4
|
+
*
|
|
5
|
+
* BALDART ships a small set of *logical* hooks (framework-edit-gate,
|
|
6
|
+
* agent-discovery-gate, agent-discovery-info, overlay-telemetry). Each logical
|
|
7
|
+
* hook has a stable, runtime-independent identity (`id`, `order`, `purpose`)
|
|
8
|
+
* and one binding per runtime that can express it.
|
|
9
|
+
*
|
|
10
|
+
* - **Claude Code** binds via `.claude/settings.json` PreToolUse / SessionStart
|
|
11
|
+
* / PostToolUse entries (see `runtimes.claude`).
|
|
12
|
+
* - **Codex** binds via `.codex/hooks.json` events (`runtimes.codex`); a hook
|
|
13
|
+
* with no `codex` binding is simply not registered on Codex — the renderer
|
|
14
|
+
* iterates `defsFor('codex')`, which skips it. Codex uses the SAME hooks
|
|
15
|
+
* schema as Claude (`{ hooks: { <Event>: [{ matcher, hooks: [{type,command}] }] } }`,
|
|
16
|
+
* verified against the OpenAI Codex hooks spec, CLI 0.142.5) but a DIFFERENT
|
|
17
|
+
* event/tool vocabulary (`apply_patch` not `Edit|Write`, session-scoped
|
|
18
|
+
* `SessionStart`, no observable `Read`/`Agent` tool) and NO per-entry `id`
|
|
19
|
+
* (hooks are addressed by position + trusted by content hash) — so a Codex
|
|
20
|
+
* binding carries a `marker` (a stable command substring) for ownership
|
|
21
|
+
* matching instead of relying on an `id` field.
|
|
22
|
+
*
|
|
23
|
+
* This module is the SINGLE SOURCE of the hook set. Renderers
|
|
24
|
+
* (`hooks/claude.js`, later `hooks/codex.js`) consume `defsFor(runtime)` and
|
|
25
|
+
* own only the settings-file schema + I/O for their runtime. Adding a hook is
|
|
26
|
+
* one entry here; wiring a new runtime is one renderer + one `runtimes.<name>`
|
|
27
|
+
* binding — never a change to the call sites in add/update/doctor.
|
|
28
|
+
*
|
|
29
|
+
* The projection `defsFor('claude')` returns the exact flat shape the Claude
|
|
30
|
+
* renderer expected before the split (`{ id, order, event, matcher, command,
|
|
31
|
+
* legacyMatch? }`), so the Claude code path — and its `.claude/settings.json`
|
|
32
|
+
* output — is byte-for-byte unchanged.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} ClaudeBinding
|
|
37
|
+
* @property {string} event Claude Code event (`PreToolUse`, `Stop`, `SessionStart`, `PostToolUse`, …)
|
|
38
|
+
* @property {string} matcher tool-name regex, or '*' for matcherless events
|
|
39
|
+
* @property {string} command shell command Claude Code runs
|
|
40
|
+
* @property {string} [legacyMatch] substring identifying pre-v3.18 single-hook entries to migrate
|
|
41
|
+
*
|
|
42
|
+
* @typedef {Object} CodexBinding
|
|
43
|
+
* @property {string} event Codex hook event (`PreToolUse`, `SessionStart`, `PostToolUse`, `Stop`, …)
|
|
44
|
+
* @property {string} [matcher] tool/command matcher when the event takes one (omit for matcherless events)
|
|
45
|
+
* @property {string} command shell command Codex runs
|
|
46
|
+
* @property {string} marker stable command substring identifying BALDART ownership (Codex entries carry no `id`)
|
|
47
|
+
*
|
|
48
|
+
* @typedef {Object} HookDef
|
|
49
|
+
* @property {string} id stable marker stored in the runtime's settings under the entry
|
|
50
|
+
* @property {number} order ascending sort key among BALDART hooks on the same event
|
|
51
|
+
* @property {string} purpose one-line human description (docs / doctor labels)
|
|
52
|
+
* @property {{ claude?: ClaudeBinding, codex?: CodexBinding }} runtimes per-runtime bindings
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** @type {HookDef[]} */
|
|
56
|
+
const HOOK_REGISTRY = [
|
|
57
|
+
{
|
|
58
|
+
id: 'baldart-framework-edit-gate',
|
|
59
|
+
order: 100,
|
|
60
|
+
purpose: 'Block edits/writes that resolve inside .framework/ and carry project-specific contamination.',
|
|
61
|
+
runtimes: {
|
|
62
|
+
claude: {
|
|
63
|
+
event: 'PreToolUse',
|
|
64
|
+
matcher: 'Edit|Write|MultiEdit|NotebookEdit',
|
|
65
|
+
command: 'node .framework/framework/.claude/hooks/framework-edit-gate.js',
|
|
66
|
+
legacyMatch: 'framework-edit-gate.js',
|
|
67
|
+
},
|
|
68
|
+
// Same script, Codex PreToolUse. Codex normalizes file edits to the
|
|
69
|
+
// `apply_patch` tool (matcher still accepts Edit|Write), and the script
|
|
70
|
+
// is runtime-aware (parses the apply_patch envelope). The deny format
|
|
71
|
+
// (`hookSpecificOutput.permissionDecision`) is identical across runtimes.
|
|
72
|
+
codex: {
|
|
73
|
+
event: 'PreToolUse',
|
|
74
|
+
matcher: 'Edit|Write|apply_patch',
|
|
75
|
+
command: 'node .framework/framework/.claude/hooks/framework-edit-gate.js',
|
|
76
|
+
marker: 'framework-edit-gate.js',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
// Denies `Agent` tool calls whose subagent_type matches a BALDART agent
|
|
82
|
+
// name but the consumer's .claude/agents/<name>.md is missing or its
|
|
83
|
+
// symlink is broken. Mitigates anthropics/claude-code#56869 (silent
|
|
84
|
+
// sub-agent fallback) and #20931 (broken file-based discovery) for the
|
|
85
|
+
// filesystem-detectable subset.
|
|
86
|
+
id: 'baldart-agent-discovery-gate',
|
|
87
|
+
order: 200,
|
|
88
|
+
purpose: 'Deny Agent spawns naming a BALDART agent whose definition is missing (no silent fallback).',
|
|
89
|
+
runtimes: {
|
|
90
|
+
claude: {
|
|
91
|
+
event: 'PreToolUse',
|
|
92
|
+
matcher: 'Agent',
|
|
93
|
+
command: 'node .framework/framework/.claude/hooks/agent-discovery-gate.js',
|
|
94
|
+
},
|
|
95
|
+
// No Codex binding (deliberate): Codex spawns subagents BY NAME, not via
|
|
96
|
+
// an observable `Agent` PreToolUse tool, and its `SubagentStart` event is
|
|
97
|
+
// session-scoped with unconfirmed deny semantics. The no-silent-fallback
|
|
98
|
+
// guarantee is instead enforced Codex-side by the skill preflight (the
|
|
99
|
+
// /new + /prd Codex runtime modules validate `.codex/agents/<name>.toml`
|
|
100
|
+
// presence before spawning). See framework/docs/CODEX-HOOKS.md.
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
// Injects an additionalContext warning at session start listing BALDART
|
|
105
|
+
// agents whose .claude/agents/<name>.md is missing. Best-effort early
|
|
106
|
+
// signal (Claude Code #10373 may suppress injection on new
|
|
107
|
+
// conversations) — the blocking counterpart is agent-discovery-gate.
|
|
108
|
+
id: 'baldart-agent-discovery-info',
|
|
109
|
+
order: 100,
|
|
110
|
+
purpose: 'Session-start warning listing BALDART agents whose definition is missing.',
|
|
111
|
+
runtimes: {
|
|
112
|
+
claude: {
|
|
113
|
+
event: 'SessionStart',
|
|
114
|
+
matcher: '*',
|
|
115
|
+
command: 'bash .framework/framework/.claude/hooks/agent-discovery-info.sh',
|
|
116
|
+
},
|
|
117
|
+
// Codex also has a session-scoped SessionStart event that supports
|
|
118
|
+
// `additionalContext` injection. Same script, but told to inspect the
|
|
119
|
+
// Codex agent directory (.codex/agents/*.toml) via --runtime codex.
|
|
120
|
+
codex: {
|
|
121
|
+
event: 'SessionStart',
|
|
122
|
+
command: 'bash .framework/framework/.claude/hooks/agent-discovery-info.sh --runtime codex',
|
|
123
|
+
marker: 'agent-discovery-info.sh',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
// Observation-only telemetry: records every Read of a
|
|
129
|
+
// `.baldart/overlays/*.md` file to `.baldart/telemetry/overlay-loads.jsonl`.
|
|
130
|
+
// Measures, objectively, whether skill overlays are actually loaded at
|
|
131
|
+
// runtime (skill overlays use runtime-concat, which is NOT structurally
|
|
132
|
+
// enforced — unlike agent/command overlays, which are compile-time merged).
|
|
133
|
+
// Numerator-only by design: skill invocation is prompt-expansion, not a
|
|
134
|
+
// tool call, so the denominator is not hook-observable (claude-code #43630,
|
|
135
|
+
// #22655). bash, not node — it fires on EVERY Read (hottest tool) in the
|
|
136
|
+
// critical path, so a per-read node cold-start would tax every file read.
|
|
137
|
+
// Fail-safe — never blocks. See framework/docs/OVERLAY-TELEMETRY.md.
|
|
138
|
+
id: 'baldart-overlay-telemetry',
|
|
139
|
+
order: 100,
|
|
140
|
+
purpose: 'Record every Read of a .baldart/overlays/*.md file (overlay-load telemetry).',
|
|
141
|
+
runtimes: {
|
|
142
|
+
claude: {
|
|
143
|
+
event: 'PostToolUse',
|
|
144
|
+
matcher: 'Read',
|
|
145
|
+
command: 'bash .framework/framework/.claude/hooks/overlay-telemetry.sh',
|
|
146
|
+
},
|
|
147
|
+
// No Codex binding (deliberate): Codex exposes no reliable file-Read tool
|
|
148
|
+
// event to hook on (its PreToolUse/PostToolUse matcher surface covers
|
|
149
|
+
// Bash/apply_patch/Edit/Write/MCP, not a `Read`). Overlay-load telemetry
|
|
150
|
+
// is observation-only and Claude-only for now. See CODEX-HOOKS.md.
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
// Future hooks are appended here as one neutral entry with per-runtime bindings.
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Project the neutral registry onto one runtime's flat def shape.
|
|
158
|
+
*
|
|
159
|
+
* Returns only the hooks that declare a binding for `runtime`, each flattened
|
|
160
|
+
* to `{ id, order, ...binding }` — the exact shape a renderer's register /
|
|
161
|
+
* status / verify loop iterates. Hooks with no binding for `runtime` are
|
|
162
|
+
* skipped (they are simply unsupported there).
|
|
163
|
+
*
|
|
164
|
+
* @param {'claude'|'codex'} runtime
|
|
165
|
+
* @returns {Array<{ id: string, order: number } & (ClaudeBinding|CodexBinding)>}
|
|
166
|
+
*/
|
|
167
|
+
function defsFor(runtime) {
|
|
168
|
+
return HOOK_REGISTRY
|
|
169
|
+
.filter((h) => h.runtimes && h.runtimes[runtime])
|
|
170
|
+
.map((h) => ({ id: h.id, order: h.order, ...h.runtimes[runtime] }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
HOOK_REGISTRY,
|
|
175
|
+
defsFor,
|
|
176
|
+
};
|