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.
@@ -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
+ };