baldart 3.19.0 → 3.21.2

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,28 +1,105 @@
1
1
  /**
2
2
  * Consumer-side hook registration for `.claude/settings.json`.
3
3
  *
4
- * BALDART ships a Claude Code PreToolUse hook (`framework-edit-gate`) that
5
- * intercepts Edit / Write / MultiEdit calls targeting files inside
6
- * `.framework/` and blocks them with an informative reason if the new
7
- * content contains project-specific tokens (Neo-Brutalism, merchant,
8
- * Recharts, secrets, hardcoded paths, …).
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`.
9
7
  *
10
- * Registration is auto-handled by `baldart add` and re-checked by
11
- * `baldart update` / `baldart doctor`. The user can remove the entry from
12
- * `.claude/settings.json` if they want to disable it.
8
+ * **Multi-hook architecture (since v3.18.0)**
13
9
  *
14
- * Schema: the hook config under `hooks.PreToolUse[]` follows Claude Code's
15
- * documented hook format. Each entry has a `matcher` (tool name regex) and
16
- * an inner `hooks[]` array of `{ type: "command", command: <shell> }`.
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.
17
41
  */
18
42
 
19
43
  const fs = require('fs');
20
44
  const path = require('path');
21
45
 
22
46
  const SETTINGS_FILE = path.join('.claude', 'settings.json');
23
- const HOOK_ID = 'baldart-framework-edit-gate';
24
- const HOOK_COMMAND = 'node .framework/framework/.claude/hooks/framework-edit-gate.js';
25
- const HOOK_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
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
+ // Future hooks (T1.3, T1.4, T2.3) will be appended here, e.g.:
91
+ // {
92
+ // id: 'baldart-capture-detect',
93
+ // event: 'Stop',
94
+ // matcher: '*',
95
+ // command: 'bash .framework/framework/.claude/hooks/capture-detect.sh',
96
+ // order: 100,
97
+ // },
98
+ ];
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // settings.json I/O
102
+ // ---------------------------------------------------------------------------
26
103
 
27
104
  function readSettings(cwd = process.cwd()) {
28
105
  const full = path.join(cwd, SETTINGS_FILE);
@@ -41,112 +118,306 @@ function writeSettings(settingsPath, settings) {
41
118
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
42
119
  }
43
120
 
121
+ // ---------------------------------------------------------------------------
122
+ // Internal: locate an entry that owns a given hook definition
123
+ // ---------------------------------------------------------------------------
124
+
44
125
  /**
45
- * Returns true iff the framework-edit-gate hook is already registered in
46
- * `.claude/settings.json` (matching either by command string or by the
47
- * HOOK_ID marker we plant in a sibling property).
126
+ * Walks `settings.hooks[event]` and returns the first entry/hook pair whose
127
+ * `id` matches, or whose `command` contains `legacyMatch` (pre-v3.18 layout).
128
+ * Returns `null` when the hook is not yet registered.
48
129
  */
49
- function isRegistered(cwd = process.cwd()) {
50
- const { settings } = readSettings(cwd);
51
- if (!settings || !settings.hooks || !Array.isArray(settings.hooks.PreToolUse)) return false;
52
- for (const entry of settings.hooks.PreToolUse) {
130
+ function findOwnedEntry(eventList, def) {
131
+ if (!Array.isArray(eventList)) return null;
132
+ for (let i = 0; i < eventList.length; i++) {
133
+ const entry = eventList[i];
53
134
  if (!entry || !Array.isArray(entry.hooks)) continue;
54
- for (const h of entry.hooks) {
135
+ for (let j = 0; j < entry.hooks.length; j++) {
136
+ const h = entry.hooks[j];
55
137
  if (!h) continue;
56
- if (h.id === HOOK_ID) return true;
57
- if (h.type === 'command' && typeof h.command === 'string') {
58
- if (h.command.includes('framework-edit-gate.js')) return true;
138
+ if (h.id === def.id) return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: false };
139
+ if (def.legacyMatch && h.type === 'command'
140
+ && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
141
+ return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: true };
59
142
  }
60
143
  }
61
144
  }
62
- return false;
145
+ return null;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Status — read-only inspection of every hook in the registry
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * @typedef {Object} HookStatus
154
+ * @property {string} id
155
+ * @property {string} event
156
+ * @property {boolean} registered hook with this id exists in settings.json
157
+ * @property {boolean} drift registered but command differs from expected
158
+ * @property {boolean} legacy matched only by legacyMatch (id is missing)
159
+ * @property {string} expected command BALDART wants
160
+ * @property {string} [actual] command currently in settings.json (when registered)
161
+ */
162
+
163
+ /**
164
+ * Returns the status of every hook in the registry. Pure read; never writes.
165
+ *
166
+ * @param {string} cwd
167
+ * @returns {{ malformed: boolean, path: string, hooks: HookStatus[] }}
168
+ */
169
+ function getStatus(cwd = process.cwd()) {
170
+ const { path: settingsPath, settings, malformed } = readSettings(cwd);
171
+ if (malformed) {
172
+ return { malformed: true, path: settingsPath, hooks: [] };
173
+ }
174
+ const allHooks = (settings && settings.hooks) || {};
175
+ const hooks = HOOK_REGISTRY.map((def) => {
176
+ const found = findOwnedEntry(allHooks[def.event], def);
177
+ if (!found) {
178
+ return {
179
+ id: def.id, event: def.event,
180
+ registered: false, drift: false, legacy: false,
181
+ expected: def.command,
182
+ };
183
+ }
184
+ const actual = found.hook.command;
185
+ return {
186
+ id: def.id, event: def.event,
187
+ registered: true,
188
+ drift: actual !== def.command,
189
+ legacy: found.byLegacy,
190
+ expected: def.command,
191
+ actual,
192
+ };
193
+ });
194
+ return { malformed: false, path: settingsPath, hooks };
63
195
  }
64
196
 
197
+ // ---------------------------------------------------------------------------
198
+ // Register — write path with drift callback
199
+ // ---------------------------------------------------------------------------
200
+
65
201
  /**
66
- * Register the hook idempotently. Returns:
67
- * { status: 'created', path } — settings.json created from scratch
68
- * { status: 'updated', path } existing settings.json merged
69
- * { status: 'already', path } hook was already registered, no-op
70
- * { status: 'malformed', path } — existing settings.json is invalid JSON
202
+ * Idempotently register every hook in the registry.
203
+ *
204
+ * @param {string} [cwd]
205
+ * @param {Object} [options]
206
+ * @param {(info: { id: string, event: string, matcher: string,
207
+ * expected: string, actual: string })
208
+ * => Promise<'keep'|'replace'>|('keep'|'replace')} [options.onDrift]
209
+ * Called when a hook with our `id` exists but command differs.
210
+ * Default: `'keep'` (safe for non-interactive / CI).
211
+ * @returns {Promise<{ malformed: boolean, path: string,
212
+ * created: boolean, updated: boolean,
213
+ * results: Array<{ id: string, status:
214
+ * 'created'|'already'|'drift-replaced'|'drift-kept'|'legacy-migrated',
215
+ * previousCommand?: string }> }>}
71
216
  */
72
- function register(cwd = process.cwd()) {
217
+ async function registerAll(cwd = process.cwd(), options = {}) {
218
+ const onDrift = typeof options.onDrift === 'function'
219
+ ? options.onDrift
220
+ : () => 'keep';
221
+
73
222
  const { path: settingsPath, settings: existing, malformed } = readSettings(cwd);
74
- if (malformed) return { status: 'malformed', path: settingsPath };
223
+ if (malformed) {
224
+ return { malformed: true, path: settingsPath, created: false, updated: false, results: [] };
225
+ }
75
226
 
76
- const settings = existing && typeof existing === 'object' ? { ...existing } : {};
227
+ const wasNew = !existing;
228
+ const settings = existing && typeof existing === 'object' ? structuredClone(existing) : {};
77
229
  settings.hooks = settings.hooks && typeof settings.hooks === 'object'
78
- ? { ...settings.hooks }
230
+ ? settings.hooks
79
231
  : {};
80
232
 
81
- const list = Array.isArray(settings.hooks.PreToolUse) ? [...settings.hooks.PreToolUse] : [];
233
+ const results = [];
234
+ let modified = false;
82
235
 
83
- // Already present?
84
- for (const entry of list) {
85
- if (!entry || !Array.isArray(entry.hooks)) continue;
86
- for (const h of entry.hooks) {
87
- if (!h) continue;
88
- if (h.id === HOOK_ID) return { status: 'already', path: settingsPath };
89
- if (h.type === 'command' && typeof h.command === 'string'
90
- && h.command.includes('framework-edit-gate.js')) {
91
- return { status: 'already', path: settingsPath };
92
- }
236
+ for (const def of HOOK_REGISTRY) {
237
+ const list = Array.isArray(settings.hooks[def.event])
238
+ ? settings.hooks[def.event]
239
+ : [];
240
+
241
+ const found = findOwnedEntry(list, def);
242
+
243
+ if (!found) {
244
+ list.push({
245
+ matcher: def.matcher,
246
+ hooks: [{ id: def.id, type: 'command', command: def.command }],
247
+ });
248
+ settings.hooks[def.event] = list;
249
+ results.push({ id: def.id, status: 'created' });
250
+ modified = true;
251
+ continue;
252
+ }
253
+
254
+ if (found.byLegacy) {
255
+ // Pre-v3.18 entry: stamp our id on it and align the command.
256
+ const previousCommand = found.hook.command;
257
+ found.hook.id = def.id;
258
+ found.hook.command = def.command;
259
+ results.push({ id: def.id, status: 'legacy-migrated', previousCommand });
260
+ modified = true;
261
+ continue;
262
+ }
263
+
264
+ if (found.hook.command === def.command) {
265
+ results.push({ id: def.id, status: 'already' });
266
+ continue;
267
+ }
268
+
269
+ // Drift: same id, different command. Ask the caller.
270
+ const decision = await onDrift({
271
+ id: def.id,
272
+ event: def.event,
273
+ matcher: def.matcher,
274
+ expected: def.command,
275
+ actual: found.hook.command,
276
+ });
277
+
278
+ if (decision === 'replace') {
279
+ const previousCommand = found.hook.command;
280
+ found.hook.command = def.command;
281
+ results.push({ id: def.id, status: 'drift-replaced', previousCommand });
282
+ modified = true;
283
+ } else {
284
+ results.push({
285
+ id: def.id, status: 'drift-kept', previousCommand: found.hook.command,
286
+ });
93
287
  }
94
288
  }
95
289
 
96
- list.push({
97
- matcher: HOOK_MATCHER,
98
- hooks: [{
99
- id: HOOK_ID,
100
- type: 'command',
101
- command: HOOK_COMMAND,
102
- }],
103
- });
290
+ if (modified) writeSettings(settingsPath, settings);
104
291
 
105
- settings.hooks.PreToolUse = list;
106
- writeSettings(settingsPath, settings);
107
- return { status: existing ? 'updated' : 'created', path: settingsPath };
292
+ return {
293
+ malformed: false,
294
+ path: settingsPath,
295
+ created: modified && wasNew,
296
+ updated: modified && !wasNew,
297
+ results,
298
+ };
108
299
  }
109
300
 
301
+ // ---------------------------------------------------------------------------
302
+ // Unregister — remove every BALDART hook from settings.json
303
+ // ---------------------------------------------------------------------------
304
+
110
305
  /**
111
- * Remove the hook from settings.json. Used by tests and as an undo helper.
306
+ * Remove every hook defined in the registry from settings.json. Idempotent.
307
+ *
308
+ * @param {string} [cwd]
309
+ * @returns {{ malformed: boolean, path: string,
310
+ * results: Array<{ id: string, status: 'removed'|'not-found' }> }}
112
311
  */
113
- function unregister(cwd = process.cwd()) {
312
+ function unregisterAll(cwd = process.cwd()) {
114
313
  const { path: settingsPath, settings, malformed } = readSettings(cwd);
115
- if (malformed || !settings || !settings.hooks || !Array.isArray(settings.hooks.PreToolUse)) {
116
- return { status: 'not-found', path: settingsPath };
314
+ if (malformed || !settings || !settings.hooks) {
315
+ return {
316
+ malformed,
317
+ path: settingsPath,
318
+ results: HOOK_REGISTRY.map((d) => ({ id: d.id, status: 'not-found' })),
319
+ };
117
320
  }
118
321
 
119
- const filtered = settings.hooks.PreToolUse
120
- .map((entry) => {
121
- if (!entry || !Array.isArray(entry.hooks)) return entry;
122
- const innerKept = entry.hooks.filter((h) => {
123
- if (!h) return true;
124
- if (h.id === HOOK_ID) return false;
125
- if (h.type === 'command' && typeof h.command === 'string'
126
- && h.command.includes('framework-edit-gate.js')) return false;
127
- return true;
128
- });
129
- if (innerKept.length === 0) return null;
130
- return { ...entry, hooks: innerKept };
131
- })
132
- .filter(Boolean);
322
+ const next = structuredClone(settings);
323
+ const results = [];
324
+ let modified = false;
133
325
 
134
- if (filtered.length === settings.hooks.PreToolUse.length) {
135
- return { status: 'not-found', path: settingsPath };
326
+ for (const def of HOOK_REGISTRY) {
327
+ const list = Array.isArray(next.hooks[def.event]) ? next.hooks[def.event] : null;
328
+ if (!list) {
329
+ results.push({ id: def.id, status: 'not-found' });
330
+ continue;
331
+ }
332
+ let removed = false;
333
+ const filtered = list
334
+ .map((entry) => {
335
+ if (!entry || !Array.isArray(entry.hooks)) return entry;
336
+ const innerKept = entry.hooks.filter((h) => {
337
+ if (!h) return true;
338
+ if (h.id === def.id) { removed = true; return false; }
339
+ if (def.legacyMatch && h.type === 'command'
340
+ && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
341
+ removed = true;
342
+ return false;
343
+ }
344
+ return true;
345
+ });
346
+ if (innerKept.length === 0) return null;
347
+ return { ...entry, hooks: innerKept };
348
+ })
349
+ .filter(Boolean);
350
+
351
+ if (removed) {
352
+ if (filtered.length === 0) {
353
+ delete next.hooks[def.event];
354
+ } else {
355
+ next.hooks[def.event] = filtered;
356
+ }
357
+ modified = true;
358
+ results.push({ id: def.id, status: 'removed' });
359
+ } else {
360
+ results.push({ id: def.id, status: 'not-found' });
361
+ }
136
362
  }
137
363
 
138
- const next = { ...settings, hooks: { ...settings.hooks, PreToolUse: filtered } };
139
- if (next.hooks.PreToolUse.length === 0) delete next.hooks.PreToolUse;
140
364
  if (Object.keys(next.hooks).length === 0) delete next.hooks;
141
- writeSettings(settingsPath, next);
142
- return { status: 'removed', path: settingsPath };
365
+ if (modified) writeSettings(settingsPath, next);
366
+
367
+ return { malformed: false, path: settingsPath, results };
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Drift prompt helper (interactive Keep / Replace / Show diff)
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Build an `onDrift` callback suitable for `registerAll`. Wraps inquirer via
376
+ * the project's UI helper so the CLI output stays consistent.
377
+ *
378
+ * @param {Object} UI the `src/utils/ui.js` module (avoids a
379
+ * circular require by accepting it from
380
+ * the caller)
381
+ * @param {Object} [options]
382
+ * @param {boolean} [options.autoYes] non-interactive mode: skip prompt and
383
+ * always return 'keep' (safe default)
384
+ * @returns {(info: {id:string, event:string, expected:string, actual:string})
385
+ * => Promise<'keep'|'replace'>}
386
+ */
387
+ function createDriftPrompt(UI, { autoYes = false } = {}) {
388
+ if (autoYes) {
389
+ return async ({ id }) => {
390
+ UI.warning(`Hook drift detected for \`${id}\` — keeping current command (auto mode). Run \`baldart doctor\` interactively to resolve.`);
391
+ return 'keep';
392
+ };
393
+ }
394
+
395
+ const truncate = (s, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
396
+
397
+ return async function onDrift({ id, event, expected, actual }) {
398
+ UI.warning(`Hook \`${id}\` (${event}) is registered with a non-default command.`);
399
+ // Loop while the user keeps choosing 'diff'
400
+ for (;;) {
401
+ const choice = await UI.select(
402
+ `What do you want to do with \`${id}\`?`,
403
+ [
404
+ { name: `Keep current — leave: ${truncate(actual)}`, value: 'keep' },
405
+ { name: `Replace with BALDART — set: ${truncate(expected)}`, value: 'replace' },
406
+ { name: `Show full diff and decide`, value: 'diff' },
407
+ ],
408
+ );
409
+ if (choice !== 'diff') return choice;
410
+ console.log(` expected: ${expected}`);
411
+ console.log(` actual: ${actual}`);
412
+ }
413
+ };
143
414
  }
144
415
 
145
416
  module.exports = {
146
- isRegistered,
147
- register,
148
- unregister,
149
- HOOK_ID,
150
- HOOK_COMMAND,
417
+ HOOK_REGISTRY,
151
418
  SETTINGS_FILE,
419
+ getStatus,
420
+ registerAll,
421
+ unregisterAll,
422
+ createDriftPrompt,
152
423
  };