baldart 4.87.0 → 4.88.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.
@@ -1,473 +1,23 @@
1
1
  /**
2
- * Consumer-side hook registration for `.claude/settings.json`.
2
+ * Consumer-side hook registration compatibility shim.
3
3
  *
4
- * BALDART ships one or more Claude Code hooks (PreToolUse, Stop, SessionStart,
5
- * ) that are auto-registered in the consumer's `.claude/settings.json` on
6
- * `baldart add` and re-checked on `baldart update` / `baldart doctor`.
4
+ * The hook machinery is split for cross-tool parity (BALDART Codex-parity
5
+ * foundation, S1):
6
+ * - `hooks/registry.js` the runtime-neutral hook set (one entry per logical
7
+ * hook, with per-runtime bindings).
8
+ * - `hooks/claude.js` — the Claude Code renderer (`.claude/settings.json`).
9
+ * - `hooks/codex.js` — the Codex renderer (`.codex/hooks.json`, S2 slice).
7
10
  *
8
- * **Multi-hook architecture (since v3.18.0)**
11
+ * This file preserves the historical import path `require('../utils/hooks')`
12
+ * and its exact public surface (`registerAll` / `verifyAll` / `getStatus` /
13
+ * `unregisterAll` / `createDriftPrompt` / `HOOK_REGISTRY` / `SETTINGS_FILE`),
14
+ * which every call site in `add` / `update` / `doctor` uses to manage the
15
+ * **Claude** hooks. Codex hook management is reached explicitly via
16
+ * `require('../utils/hooks/codex')`, never through this shim, so existing
17
+ * Claude behavior is byte-for-byte unchanged.
9
18
  *
10
- * Hook definitions live in `HOOK_REGISTRY` below adding a hook is a matter
11
- * of appending one entry. Every register/unregister/status operation iterates
12
- * the registry, so call sites don't change when we ship a new hook.
13
- *
14
- * Each hook entry carries:
15
- * - `id` stable marker stored in settings.json under `hooks[].id`
16
- * - `event` Claude Code event (`PreToolUse`, `Stop`, `SessionStart`, …)
17
- * - `matcher` tool-name regex (use '*' for events that don't take one)
18
- * - `command` shell command Claude Code runs
19
- * - `order` sort key when this hook coexists with other BALDART hooks
20
- * on the same event (lower runs first)
21
- * - `legacyMatch` (optional) substring that identifies pre-v3.18 entries
22
- * when migrating consumers from the single-hook layout
23
- *
24
- * **Drift policy**
25
- *
26
- * When an existing entry matches our `id` but its `command` differs, we don't
27
- * silently rewrite it — that would obliterate user customizations. Instead we
28
- * call back to `onDrift({ id, event, matcher, expected, actual })` and expect
29
- * `'keep'` or `'replace'`. Callers wire `onDrift` to an inquirer prompt
30
- * (with a `'diff'` option that shows the diff and re-prompts). The default
31
- * when `onDrift` is not provided is `'keep'` — safe for non-interactive runs.
32
- *
33
- * **Failure isolation**
34
- *
35
- * Claude Code's runtime executes hooks on the same event in array order and
36
- * isolates failures itself (a non-zero exit from a Stop/SessionStart hook
37
- * does not block the others). Our only responsibility is to never corrupt
38
- * the array of hooks the user has set up themselves — entries we don't own
39
- * (no `id` of ours, no `legacyMatch` of ours) are preserved verbatim and
40
- * stay in their original position.
41
- */
42
-
43
- const fs = require('fs');
44
- const path = require('path');
45
-
46
- const SETTINGS_FILE = path.join('.claude', 'settings.json');
47
-
48
- /** @typedef {Object} HookDef
49
- * @property {string} id
50
- * @property {string} event Claude Code event name
51
- * @property {string} matcher tool-name regex, or '*' for matcherless events
52
- * @property {string} command shell command
53
- * @property {number} order ascending sort key among BALDART hooks on same event
54
- * @property {string} [legacyMatch] substring marker for pre-v3.18 entries to migrate
55
- */
56
-
57
- /** @type {HookDef[]} */
58
- const HOOK_REGISTRY = [
59
- {
60
- id: 'baldart-framework-edit-gate',
61
- event: 'PreToolUse',
62
- matcher: 'Edit|Write|MultiEdit|NotebookEdit',
63
- command: 'node .framework/framework/.claude/hooks/framework-edit-gate.js',
64
- order: 100,
65
- legacyMatch: 'framework-edit-gate.js',
66
- },
67
- {
68
- // Denies `Agent` tool calls whose subagent_type matches a BALDART agent
69
- // name but the consumer's .claude/agents/<name>.md is missing or its
70
- // symlink is broken. Mitigates anthropics/claude-code#56869 (silent
71
- // sub-agent fallback) and #20931 (broken file-based discovery) for the
72
- // filesystem-detectable subset.
73
- id: 'baldart-agent-discovery-gate',
74
- event: 'PreToolUse',
75
- matcher: 'Agent',
76
- command: 'node .framework/framework/.claude/hooks/agent-discovery-gate.js',
77
- order: 200,
78
- },
79
- {
80
- // Injects an additionalContext warning at session start listing BALDART
81
- // agents whose .claude/agents/<name>.md is missing. Best-effort early
82
- // signal (Claude Code #10373 may suppress injection on new
83
- // conversations) — the blocking counterpart is agent-discovery-gate.
84
- id: 'baldart-agent-discovery-info',
85
- event: 'SessionStart',
86
- matcher: '*',
87
- command: 'bash .framework/framework/.claude/hooks/agent-discovery-info.sh',
88
- order: 100,
89
- },
90
- {
91
- // Observation-only telemetry: records every Read of a
92
- // `.baldart/overlays/*.md` file to `.baldart/telemetry/overlay-loads.jsonl`.
93
- // Measures, objectively, whether skill overlays are actually loaded at
94
- // runtime (skill overlays use runtime-concat, which is NOT structurally
95
- // enforced — unlike agent/command overlays, which are compile-time merged).
96
- // Numerator-only by design: skill invocation is prompt-expansion, not a
97
- // tool call, so the denominator is not hook-observable (claude-code #43630,
98
- // #22655). bash, not node — it fires on EVERY Read (hottest tool) in the
99
- // critical path, so a per-read node cold-start would tax every file read.
100
- // Fail-safe — never blocks. See framework/docs/OVERLAY-TELEMETRY.md.
101
- id: 'baldart-overlay-telemetry',
102
- event: 'PostToolUse',
103
- matcher: 'Read',
104
- command: 'bash .framework/framework/.claude/hooks/overlay-telemetry.sh',
105
- order: 100,
106
- },
107
- // Future hooks (T1.3, T1.4, T2.3) will be appended here, e.g.:
108
- // {
109
- // id: 'baldart-capture-detect',
110
- // event: 'Stop',
111
- // matcher: '*',
112
- // command: 'bash .framework/framework/.claude/hooks/capture-detect.sh',
113
- // order: 100,
114
- // },
115
- ];
116
-
117
- // ---------------------------------------------------------------------------
118
- // settings.json I/O
119
- // ---------------------------------------------------------------------------
120
-
121
- function readSettings(cwd = process.cwd()) {
122
- const full = path.join(cwd, SETTINGS_FILE);
123
- if (!fs.existsSync(full)) return { path: full, settings: null, malformed: false };
124
- try {
125
- const raw = fs.readFileSync(full, 'utf8');
126
- if (!raw.trim()) return { path: full, settings: {}, malformed: false };
127
- return { path: full, settings: JSON.parse(raw), malformed: false };
128
- } catch (_) {
129
- return { path: full, settings: null, malformed: true };
130
- }
131
- }
132
-
133
- function writeSettings(settingsPath, settings) {
134
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
135
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // Internal: locate an entry that owns a given hook definition
140
- // ---------------------------------------------------------------------------
141
-
142
- /**
143
- * Walks `settings.hooks[event]` and returns the first entry/hook pair whose
144
- * `id` matches, or whose `command` contains `legacyMatch` (pre-v3.18 layout).
145
- * Returns `null` when the hook is not yet registered.
19
+ * NOT to be confused with `src/utils/githooks.js`, which manages the repo's
20
+ * versioned git hooks (`core.hooksPath`).
146
21
  */
147
- function findOwnedEntry(eventList, def) {
148
- if (!Array.isArray(eventList)) return null;
149
- for (let i = 0; i < eventList.length; i++) {
150
- const entry = eventList[i];
151
- if (!entry || !Array.isArray(entry.hooks)) continue;
152
- for (let j = 0; j < entry.hooks.length; j++) {
153
- const h = entry.hooks[j];
154
- if (!h) continue;
155
- if (h.id === def.id) return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: false };
156
- if (def.legacyMatch && h.type === 'command'
157
- && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
158
- return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: true };
159
- }
160
- }
161
- }
162
- return null;
163
- }
164
-
165
- // ---------------------------------------------------------------------------
166
- // Status — read-only inspection of every hook in the registry
167
- // ---------------------------------------------------------------------------
168
-
169
- /**
170
- * @typedef {Object} HookStatus
171
- * @property {string} id
172
- * @property {string} event
173
- * @property {boolean} registered hook with this id exists in settings.json
174
- * @property {boolean} drift registered but command differs from expected
175
- * @property {boolean} legacy matched only by legacyMatch (id is missing)
176
- * @property {string} expected command BALDART wants
177
- * @property {string} [actual] command currently in settings.json (when registered)
178
- */
179
-
180
- /**
181
- * Returns the status of every hook in the registry. Pure read; never writes.
182
- *
183
- * @param {string} cwd
184
- * @returns {{ malformed: boolean, path: string, hooks: HookStatus[] }}
185
- */
186
- function getStatus(cwd = process.cwd()) {
187
- const { path: settingsPath, settings, malformed } = readSettings(cwd);
188
- if (malformed) {
189
- return { malformed: true, path: settingsPath, hooks: [] };
190
- }
191
- const allHooks = (settings && settings.hooks) || {};
192
- const hooks = HOOK_REGISTRY.map((def) => {
193
- const found = findOwnedEntry(allHooks[def.event], def);
194
- if (!found) {
195
- return {
196
- id: def.id, event: def.event,
197
- registered: false, drift: false, legacy: false,
198
- expected: def.command,
199
- };
200
- }
201
- const actual = found.hook.command;
202
- return {
203
- id: def.id, event: def.event,
204
- registered: true,
205
- drift: actual !== def.command,
206
- legacy: found.byLegacy,
207
- expected: def.command,
208
- actual,
209
- };
210
- });
211
- return { malformed: false, path: settingsPath, hooks };
212
- }
213
-
214
- // ---------------------------------------------------------------------------
215
- // Register — write path with drift callback
216
- // ---------------------------------------------------------------------------
217
-
218
- /**
219
- * Idempotently register every hook in the registry.
220
- *
221
- * @param {string} [cwd]
222
- * @param {Object} [options]
223
- * @param {(info: { id: string, event: string, matcher: string,
224
- * expected: string, actual: string })
225
- * => Promise<'keep'|'replace'>|('keep'|'replace')} [options.onDrift]
226
- * Called when a hook with our `id` exists but command differs.
227
- * Default: `'keep'` (safe for non-interactive / CI).
228
- * @returns {Promise<{ malformed: boolean, path: string,
229
- * created: boolean, updated: boolean,
230
- * results: Array<{ id: string, status:
231
- * 'created'|'already'|'drift-replaced'|'drift-kept'|'legacy-migrated',
232
- * previousCommand?: string }> }>}
233
- */
234
- async function registerAll(cwd = process.cwd(), options = {}) {
235
- const onDrift = typeof options.onDrift === 'function'
236
- ? options.onDrift
237
- : () => 'keep';
238
-
239
- const { path: settingsPath, settings: existing, malformed } = readSettings(cwd);
240
- if (malformed) {
241
- return { malformed: true, path: settingsPath, created: false, updated: false, results: [] };
242
- }
243
-
244
- const wasNew = !existing;
245
- const settings = existing && typeof existing === 'object' ? structuredClone(existing) : {};
246
- settings.hooks = settings.hooks && typeof settings.hooks === 'object'
247
- ? settings.hooks
248
- : {};
249
-
250
- const results = [];
251
- let modified = false;
252
-
253
- for (const def of HOOK_REGISTRY) {
254
- const list = Array.isArray(settings.hooks[def.event])
255
- ? settings.hooks[def.event]
256
- : [];
257
-
258
- const found = findOwnedEntry(list, def);
259
-
260
- if (!found) {
261
- list.push({
262
- matcher: def.matcher,
263
- hooks: [{ id: def.id, type: 'command', command: def.command }],
264
- });
265
- settings.hooks[def.event] = list;
266
- results.push({ id: def.id, status: 'created' });
267
- modified = true;
268
- continue;
269
- }
270
-
271
- if (found.byLegacy) {
272
- // Pre-v3.18 entry: stamp our id on it and align the command.
273
- const previousCommand = found.hook.command;
274
- found.hook.id = def.id;
275
- found.hook.command = def.command;
276
- results.push({ id: def.id, status: 'legacy-migrated', previousCommand });
277
- modified = true;
278
- continue;
279
- }
280
-
281
- if (found.hook.command === def.command) {
282
- results.push({ id: def.id, status: 'already' });
283
- continue;
284
- }
285
-
286
- // Drift: same id, different command. Ask the caller.
287
- const decision = await onDrift({
288
- id: def.id,
289
- event: def.event,
290
- matcher: def.matcher,
291
- expected: def.command,
292
- actual: found.hook.command,
293
- });
294
-
295
- if (decision === 'replace') {
296
- const previousCommand = found.hook.command;
297
- found.hook.command = def.command;
298
- results.push({ id: def.id, status: 'drift-replaced', previousCommand });
299
- modified = true;
300
- } else {
301
- results.push({
302
- id: def.id, status: 'drift-kept', previousCommand: found.hook.command,
303
- });
304
- }
305
- }
306
-
307
- if (modified) writeSettings(settingsPath, settings);
308
-
309
- return {
310
- malformed: false,
311
- path: settingsPath,
312
- created: modified && wasNew,
313
- updated: modified && !wasNew,
314
- results,
315
- };
316
- }
317
-
318
- // ---------------------------------------------------------------------------
319
- // Unregister — remove every BALDART hook from settings.json
320
- // ---------------------------------------------------------------------------
321
-
322
- /**
323
- * Remove every hook defined in the registry from settings.json. Idempotent.
324
- *
325
- * @param {string} [cwd]
326
- * @returns {{ malformed: boolean, path: string,
327
- * results: Array<{ id: string, status: 'removed'|'not-found' }> }}
328
- */
329
- function unregisterAll(cwd = process.cwd()) {
330
- const { path: settingsPath, settings, malformed } = readSettings(cwd);
331
- if (malformed || !settings || !settings.hooks) {
332
- return {
333
- malformed,
334
- path: settingsPath,
335
- results: HOOK_REGISTRY.map((d) => ({ id: d.id, status: 'not-found' })),
336
- };
337
- }
338
-
339
- const next = structuredClone(settings);
340
- const results = [];
341
- let modified = false;
342
-
343
- for (const def of HOOK_REGISTRY) {
344
- const list = Array.isArray(next.hooks[def.event]) ? next.hooks[def.event] : null;
345
- if (!list) {
346
- results.push({ id: def.id, status: 'not-found' });
347
- continue;
348
- }
349
- let removed = false;
350
- const filtered = list
351
- .map((entry) => {
352
- if (!entry || !Array.isArray(entry.hooks)) return entry;
353
- const innerKept = entry.hooks.filter((h) => {
354
- if (!h) return true;
355
- if (h.id === def.id) { removed = true; return false; }
356
- if (def.legacyMatch && h.type === 'command'
357
- && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
358
- removed = true;
359
- return false;
360
- }
361
- return true;
362
- });
363
- if (innerKept.length === 0) return null;
364
- return { ...entry, hooks: innerKept };
365
- })
366
- .filter(Boolean);
367
-
368
- if (removed) {
369
- if (filtered.length === 0) {
370
- delete next.hooks[def.event];
371
- } else {
372
- next.hooks[def.event] = filtered;
373
- }
374
- modified = true;
375
- results.push({ id: def.id, status: 'removed' });
376
- } else {
377
- results.push({ id: def.id, status: 'not-found' });
378
- }
379
- }
380
-
381
- if (Object.keys(next.hooks).length === 0) delete next.hooks;
382
- if (modified) writeSettings(settingsPath, next);
383
-
384
- return { malformed: false, path: settingsPath, results };
385
- }
386
-
387
- // ---------------------------------------------------------------------------
388
- // Verify — post-flight assertion that every active hook is registered
389
- // ---------------------------------------------------------------------------
390
-
391
- /**
392
- * Confirms that every active hook in HOOK_REGISTRY is present in
393
- * `.claude/settings.json`. Pure read (delegates to `getStatus`). Designed as
394
- * the post-flight gate for `add` / `update --reset`: the install must never
395
- * declare success while a hook is missing (the silent partial-install class,
396
- * v3.27.1 / mayo 3.28→3.31). Commented-out registry entries (e.g.
397
- * capture-detect) are excluded for free — `getStatus` only iterates the array.
398
- *
399
- * A malformed settings.json counts as a failure: registration was skipped, so
400
- * the hooks are not verifiably present.
401
- *
402
- * @param {string} [cwd]
403
- * @returns {{ ok: boolean, malformed: boolean, missing: string[], path: string }}
404
- */
405
- function verifyAll(cwd = process.cwd()) {
406
- const status = getStatus(cwd);
407
- if (status.malformed) {
408
- return {
409
- ok: false,
410
- malformed: true,
411
- missing: HOOK_REGISTRY.map((d) => d.id),
412
- path: status.path,
413
- };
414
- }
415
- const missing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
416
- return { ok: missing.length === 0, malformed: false, missing, path: status.path };
417
- }
418
-
419
- // ---------------------------------------------------------------------------
420
- // Drift prompt helper (interactive Keep / Replace / Show diff)
421
- // ---------------------------------------------------------------------------
422
-
423
- /**
424
- * Build an `onDrift` callback suitable for `registerAll`. Wraps inquirer via
425
- * the project's UI helper so the CLI output stays consistent.
426
- *
427
- * @param {Object} UI the `src/utils/ui.js` module (avoids a
428
- * circular require by accepting it from
429
- * the caller)
430
- * @param {Object} [options]
431
- * @param {boolean} [options.autoYes] non-interactive mode: skip prompt and
432
- * always return 'keep' (safe default)
433
- * @returns {(info: {id:string, event:string, expected:string, actual:string})
434
- * => Promise<'keep'|'replace'>}
435
- */
436
- function createDriftPrompt(UI, { autoYes = false } = {}) {
437
- if (autoYes) {
438
- return async ({ id }) => {
439
- UI.warning(`Hook drift detected for \`${id}\` — keeping current command (auto mode). Run \`baldart doctor\` interactively to resolve.`);
440
- return 'keep';
441
- };
442
- }
443
-
444
- const truncate = (s, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
445
-
446
- return async function onDrift({ id, event, expected, actual }) {
447
- UI.warning(`Hook \`${id}\` (${event}) is registered with a non-default command.`);
448
- // Loop while the user keeps choosing 'diff'
449
- for (;;) {
450
- const choice = await UI.select(
451
- `What do you want to do with \`${id}\`?`,
452
- [
453
- { name: `Keep current — leave: ${truncate(actual)}`, value: 'keep' },
454
- { name: `Replace with BALDART — set: ${truncate(expected)}`, value: 'replace' },
455
- { name: `Show full diff and decide`, value: 'diff' },
456
- ],
457
- );
458
- if (choice !== 'diff') return choice;
459
- console.log(` expected: ${expected}`);
460
- console.log(` actual: ${actual}`);
461
- }
462
- };
463
- }
464
22
 
465
- module.exports = {
466
- HOOK_REGISTRY,
467
- SETTINGS_FILE,
468
- getStatus,
469
- verifyAll,
470
- registerAll,
471
- unregisterAll,
472
- createDriftPrompt,
473
- };
23
+ module.exports = require('./hooks/claude');
@@ -51,6 +51,9 @@ class ClaudeAdapter {
51
51
  /** Whether this tool consumes the Claude PreToolUse hook system. */
52
52
  supportsHooks() { return true; }
53
53
 
54
+ /** Where this tool reads its hook configuration from. */
55
+ hookTargetPath() { return '.claude/settings.json'; }
56
+
54
57
  /**
55
58
  * Autodetection signal. We assume Claude is always intended (it's the
56
59
  * framework's primary target). The opt-out is via `tools.enabled` in
@@ -74,7 +74,18 @@ class CodexAdapter {
74
74
 
75
75
  supportsSubagents() { return true; }
76
76
  supportsSlashCommands() { return false; }
77
- supportsHooks() { return false; }
77
+
78
+ // Codex has a native hook surface using the SAME schema as Claude Code, in
79
+ // `.codex/hooks.json` (project-level). BALDART manages it via a parallel
80
+ // renderer (`src/utils/hooks/codex.js`) that registers the framework-edit-gate
81
+ // (PreToolUse/apply_patch) and agent-discovery-info (SessionStart) hooks
82
+ // additively — preserving foreign entries (e.g. Graphify) and never faking
83
+ // hook trust. (Enabled once the Codex renderer + add/update/doctor wiring
84
+ // landed in the Codex-parity foundation, S2.)
85
+ supportsHooks() { return true; }
86
+
87
+ /** Where this tool reads its hook configuration from. */
88
+ hookTargetPath() { return '.codex/hooks.json'; }
78
89
 
79
90
  /**
80
91
  * Heuristic: Codex is present on the user's machine if `~/.codex/` exists