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.
@@ -274,6 +274,31 @@ async function detectState(cwd, opts = {}) {
274
274
  }
275
275
  } catch (_) {}
276
276
 
277
+ // ---- Codex hook health (S2) ----------------------------------------
278
+ // Probe .codex/hooks.json when Codex is an enabled tool. Reports missing /
279
+ // drifted / untrusted separately — hook trust is an interactive Codex step
280
+ // BALDART cannot complete, so `untrusted` is advisory, never a hard failure.
281
+ state.codexHooksEnabled = false;
282
+ state.codexHooksStatus = { malformed: false, hooks: [] };
283
+ state.codexHooksMissing = [];
284
+ state.codexHooksDrifted = [];
285
+ state.codexHooksUntrusted = [];
286
+ try {
287
+ const toolsEnabled = (config && !config.__malformed && config.tools && Array.isArray(config.tools.enabled))
288
+ ? config.tools.enabled : ['claude'];
289
+ if (toolsEnabled.includes('codex')) {
290
+ state.codexHooksEnabled = true;
291
+ const CodexHooks = require('../utils/hooks/codex');
292
+ const cstatus = CodexHooks.getStatus(cwd);
293
+ state.codexHooksStatus = cstatus;
294
+ if (!cstatus.malformed) {
295
+ state.codexHooksMissing = cstatus.hooks.filter((h) => !h.registered).map((h) => h.id);
296
+ state.codexHooksDrifted = cstatus.hooks.filter((h) => h.registered && h.drift).map((h) => h.id);
297
+ state.codexHooksUntrusted = cstatus.hooks.filter((h) => h.registered && !h.trusted).map((h) => h.id);
298
+ }
299
+ }
300
+ } catch (_) {}
301
+
277
302
  // ---- Git-hooks health (since v3.34.0) ------------------------------
278
303
  // Assert that the repo's versioned git hooks (pre-commit/pre-push/…) are
279
304
  // actually being run — i.e. `core.hooksPath` resolves to the directory
@@ -811,6 +836,53 @@ function planActions(state) {
811
836
  });
812
837
  }
813
838
 
839
+ // Codex hooks (S2). Same three buckets in .codex/hooks.json, gated on Codex
840
+ // being an enabled tool. Trust is NOT repaired here (BALDART never fakes hook
841
+ // trust) — it is surfaced in reportStatus as an advisory.
842
+ if (state.codexHooksEnabled && state.codexHooksMissing && state.codexHooksMissing.length > 0) {
843
+ actions.push({
844
+ key: 'register-codex-hooks',
845
+ label: `Register BALDART hook(s) in .codex/hooks.json (${state.codexHooksMissing.join(', ')})`,
846
+ why: 'Codex is an enabled tool but one or more BALDART hooks are missing from .codex/hooks.json. Registration is additive (Graphify and user hooks are preserved) and idempotent. After it runs, Codex will ask you to approve the new hook(s) once (hook trust) before they activate.',
847
+ autoOk: true,
848
+ run: async () => {
849
+ const CodexHooks = require('../utils/hooks/codex');
850
+ const res = await CodexHooks.registerAll(process.cwd(), {
851
+ onDrift: CodexHooks.createDriftPrompt(UI, { autoYes: state.autoMode }),
852
+ });
853
+ if (res.malformed) {
854
+ UI.warning('.codex/hooks.json is malformed; fix it manually then re-run `npx baldart doctor`.');
855
+ return;
856
+ }
857
+ for (const r of res.results) {
858
+ if (r.status === 'created') UI.success(`Registered Codex hook \`${r.id}\``);
859
+ }
860
+ const cv = CodexHooks.verifyAll(process.cwd());
861
+ if (cv.untrusted && cv.untrusted.length) {
862
+ UI.info(`Approve ${cv.untrusted.length} new hook(s) on the next Codex session (hook trust): ${cv.untrusted.join(', ')}.`);
863
+ }
864
+ },
865
+ });
866
+ }
867
+ if (state.codexHooksEnabled && state.codexHooksDrifted && state.codexHooksDrifted.length > 0) {
868
+ actions.push({
869
+ key: 'reconcile-codex-hooks-drift',
870
+ label: `Reconcile drifted Codex hook(s) (${state.codexHooksDrifted.join(', ')})`,
871
+ why: 'These Codex hooks are registered in .codex/hooks.json but their command differs from the BALDART default. Reconciliation prompts you per hook (Keep / Replace / Show diff).',
872
+ autoOk: false,
873
+ run: async () => {
874
+ const CodexHooks = require('../utils/hooks/codex');
875
+ const res = await CodexHooks.registerAll(process.cwd(), {
876
+ onDrift: CodexHooks.createDriftPrompt(UI),
877
+ });
878
+ for (const r of res.results) {
879
+ if (r.status === 'drift-replaced') UI.success(`Replaced Codex hook \`${r.id}\` (was: ${r.previousCommand})`);
880
+ else if (r.status === 'drift-kept') UI.info(`Kept Codex hook \`${r.id}\` as-is.`);
881
+ }
882
+ },
883
+ });
884
+ }
885
+
814
886
  // Git-hooks health (since v3.34.0). ADVISORY ONLY — the action's run() never
815
887
  // mutates `core.hooksPath` (a wrong heuristic + auto-fix is worse than the
816
888
  // original silent-inactive bug). It surfaces the WARN + the exact remediation
@@ -1448,6 +1520,38 @@ function renderDiagnostic(state) {
1448
1520
  console.log(` • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
1449
1521
  }
1450
1522
 
1523
+ // Codex hooks (S2). Only shown when Codex is an enabled tool. Trust is
1524
+ // reported separately from presence: a hook can be correctly configured yet
1525
+ // awaiting the user's one-time Codex trust approval.
1526
+ if (state.codexHooksEnabled) {
1527
+ const total = state.codexHooksStatus && state.codexHooksStatus.hooks ? state.codexHooksStatus.hooks.length : 0;
1528
+ const missing = state.codexHooksMissing ? state.codexHooksMissing.length : 0;
1529
+ const drifted = state.codexHooksDrifted ? state.codexHooksDrifted.length : 0;
1530
+ const untrusted = state.codexHooksUntrusted ? state.codexHooksUntrusted.length : 0;
1531
+ const registered = total - missing;
1532
+ let summary; let status;
1533
+ if (state.codexHooksStatus && state.codexHooksStatus.malformed) {
1534
+ summary = '.codex/hooks.json is malformed';
1535
+ status = 'warn';
1536
+ } else if (missing === 0 && drifted === 0 && untrusted === 0) {
1537
+ summary = `${registered}/${total} registered & trusted`;
1538
+ status = 'ok';
1539
+ } else {
1540
+ const parts = [`${registered}/${total} registered`];
1541
+ if (missing) parts.push(`${missing} missing`);
1542
+ if (drifted) parts.push(`${drifted} drifted`);
1543
+ if (untrusted) parts.push(`${untrusted} untrusted`);
1544
+ summary = parts.join(', ');
1545
+ // Untrusted-only is not a defect (it needs an interactive Codex approval),
1546
+ // so it stays 'ok'-toned unless something is actually missing/drifted.
1547
+ status = (missing || drifted) ? 'warn' : 'ok';
1548
+ }
1549
+ console.log(statusLine('Codex hooks', summary, status));
1550
+ if (missing) console.log(` • missing: ${state.codexHooksMissing.join(', ')}`);
1551
+ if (drifted) console.log(` • drifted: ${state.codexHooksDrifted.join(', ')}`);
1552
+ if (untrusted) console.log(` • untrusted: ${state.codexHooksUntrusted.join(', ')} (approve once on the next Codex session)`);
1553
+ }
1554
+
1451
1555
  // Git-hooks health (v3.34.0+). Silent when no versioned-hooks dir exists
1452
1556
  // (`checked:false`) — most consumers have no managed git hooks and must see
1453
1557
  // zero noise here.
@@ -1315,6 +1315,37 @@ async function update(options = {}, unknownArgs = []) {
1315
1315
  UI.warning(`Hook registration skipped: ${err.message}`);
1316
1316
  }
1317
1317
 
1318
+ // Codex hooks (S2) — backfill the framework-edit-gate (PreToolUse/apply_patch)
1319
+ // + agent-discovery-info (SessionStart) hooks into .codex/hooks.json when
1320
+ // Codex is an enabled tool. Additive: foreign entries (Graphify, user hooks)
1321
+ // are preserved; hook trust is NEVER faked (Codex asks the user to approve
1322
+ // new/changed hooks on the next session — surfaced below).
1323
+ if (Array.isArray(enabledTools) && enabledTools.includes('codex')) {
1324
+ try {
1325
+ const CodexHooks = require('../utils/hooks/codex');
1326
+ const res = await CodexHooks.registerAll(process.cwd(), {
1327
+ onDrift: CodexHooks.createDriftPrompt(UI, { autoYes }),
1328
+ });
1329
+ if (res.malformed) {
1330
+ UI.warning('.codex/hooks.json is malformed; skipped Codex hook registration. Run `npx baldart doctor` after fixing it.');
1331
+ } else {
1332
+ const created = res.results.filter((r) => r.status === 'created').map((r) => r.id);
1333
+ if (created.length === 1) UI.success(`Registered Codex hook \`${created[0]}\` in .codex/hooks.json.`);
1334
+ else if (created.length > 1) UI.success(`Auto-registered ${created.length} Codex hook(s): ${created.join(', ')}.`);
1335
+ for (const r of res.results) {
1336
+ if (r.status === 'drift-replaced') UI.success(`Replaced drifted Codex hook \`${r.id}\` (was: ${r.previousCommand})`);
1337
+ else if (r.status === 'drift-kept') UI.info(`Kept user-customized Codex hook \`${r.id}\` as-is.`);
1338
+ }
1339
+ const verify = CodexHooks.verifyAll(process.cwd());
1340
+ if (verify.untrusted && verify.untrusted.length) {
1341
+ UI.info(`Codex will ask you to approve ${verify.untrusted.length} new hook(s) on the next session (hook trust): ${verify.untrusted.join(', ')}. Until approved, they stay inactive.`);
1342
+ }
1343
+ }
1344
+ } catch (err) {
1345
+ UI.warning(`Codex hook registration skipped: ${err.message}`);
1346
+ }
1347
+ }
1348
+
1318
1349
  // Project configuration check (since v3.0.0)
1319
1350
  // Never overwrite baldart.config.yml on update. Offer to (re)run configure
1320
1351
  // if the schema has gained required keys, or if no config exists at all.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Claude Code hook renderer for `.claude/settings.json`.
3
+ *
4
+ * This module owns the Claude-side settings-file schema + I/O. The hook *set*
5
+ * is defined once, runtime-neutrally, in `./registry.js`; this renderer
6
+ * consumes `defsFor('claude')` and knows nothing about other runtimes. The
7
+ * Codex renderer (`./codex.js`, S2) is its sibling, sharing the same
8
+ * registry and the same call sites in add/update/doctor.
9
+ *
10
+ * BALDART ships one or more Claude Code hooks (PreToolUse, Stop, SessionStart,
11
+ * …) that are auto-registered in the consumer's `.claude/settings.json` on
12
+ * `baldart add` and re-checked on `baldart update` / `baldart doctor`.
13
+ *
14
+ * **Drift policy**
15
+ *
16
+ * When an existing entry matches our `id` but its `command` differs, we don't
17
+ * silently rewrite it — that would obliterate user customizations. Instead we
18
+ * call back to `onDrift({ id, event, matcher, expected, actual })` and expect
19
+ * `'keep'` or `'replace'`. Callers wire `onDrift` to an inquirer prompt
20
+ * (with a `'diff'` option that shows the diff and re-prompts). The default
21
+ * when `onDrift` is not provided is `'keep'` — safe for non-interactive runs.
22
+ *
23
+ * **Failure isolation**
24
+ *
25
+ * Claude Code's runtime executes hooks on the same event in array order and
26
+ * isolates failures itself (a non-zero exit from a Stop/SessionStart hook
27
+ * does not block the others). Our only responsibility is to never corrupt
28
+ * the array of hooks the user has set up themselves — entries we don't own
29
+ * (no `id` of ours, no `legacyMatch` of ours) are preserved verbatim and
30
+ * stay in their original position.
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const { defsFor } = require('./registry');
36
+
37
+ const SETTINGS_FILE = path.join('.claude', 'settings.json');
38
+
39
+ /**
40
+ * The Claude-runtime hook definitions, in the flat shape the loops below
41
+ * expect (`{ id, order, event, matcher, command, legacyMatch? }`). Sourced
42
+ * from the neutral registry so the hook set lives in exactly one place.
43
+ * @type {Array<{id:string,order:number,event:string,matcher:string,command:string,legacyMatch?:string}>}
44
+ */
45
+ const HOOK_REGISTRY = defsFor('claude');
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // settings.json I/O
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function readSettings(cwd = process.cwd()) {
52
+ const full = path.join(cwd, SETTINGS_FILE);
53
+ if (!fs.existsSync(full)) return { path: full, settings: null, malformed: false };
54
+ try {
55
+ const raw = fs.readFileSync(full, 'utf8');
56
+ if (!raw.trim()) return { path: full, settings: {}, malformed: false };
57
+ return { path: full, settings: JSON.parse(raw), malformed: false };
58
+ } catch (_) {
59
+ return { path: full, settings: null, malformed: true };
60
+ }
61
+ }
62
+
63
+ function writeSettings(settingsPath, settings) {
64
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
65
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Internal: locate an entry that owns a given hook definition
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Walks `settings.hooks[event]` and returns the first entry/hook pair whose
74
+ * `id` matches, or whose `command` contains `legacyMatch` (pre-v3.18 layout).
75
+ * Returns `null` when the hook is not yet registered.
76
+ */
77
+ function findOwnedEntry(eventList, def) {
78
+ if (!Array.isArray(eventList)) return null;
79
+ for (let i = 0; i < eventList.length; i++) {
80
+ const entry = eventList[i];
81
+ if (!entry || !Array.isArray(entry.hooks)) continue;
82
+ for (let j = 0; j < entry.hooks.length; j++) {
83
+ const h = entry.hooks[j];
84
+ if (!h) continue;
85
+ if (h.id === def.id) return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: false };
86
+ if (def.legacyMatch && h.type === 'command'
87
+ && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
88
+ return { entryIdx: i, hookIdx: j, entry, hook: h, byLegacy: true };
89
+ }
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Status — read-only inspection of every hook in the registry
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * @typedef {Object} HookStatus
101
+ * @property {string} id
102
+ * @property {string} event
103
+ * @property {boolean} registered hook with this id exists in settings.json
104
+ * @property {boolean} drift registered but command differs from expected
105
+ * @property {boolean} legacy matched only by legacyMatch (id is missing)
106
+ * @property {string} expected command BALDART wants
107
+ * @property {string} [actual] command currently in settings.json (when registered)
108
+ */
109
+
110
+ /**
111
+ * Returns the status of every hook in the registry. Pure read; never writes.
112
+ *
113
+ * @param {string} cwd
114
+ * @returns {{ malformed: boolean, path: string, hooks: HookStatus[] }}
115
+ */
116
+ function getStatus(cwd = process.cwd()) {
117
+ const { path: settingsPath, settings, malformed } = readSettings(cwd);
118
+ if (malformed) {
119
+ return { malformed: true, path: settingsPath, hooks: [] };
120
+ }
121
+ const allHooks = (settings && settings.hooks) || {};
122
+ const hooks = HOOK_REGISTRY.map((def) => {
123
+ const found = findOwnedEntry(allHooks[def.event], def);
124
+ if (!found) {
125
+ return {
126
+ id: def.id, event: def.event,
127
+ registered: false, drift: false, legacy: false,
128
+ expected: def.command,
129
+ };
130
+ }
131
+ const actual = found.hook.command;
132
+ return {
133
+ id: def.id, event: def.event,
134
+ registered: true,
135
+ drift: actual !== def.command,
136
+ legacy: found.byLegacy,
137
+ expected: def.command,
138
+ actual,
139
+ };
140
+ });
141
+ return { malformed: false, path: settingsPath, hooks };
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Register — write path with drift callback
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Idempotently register every hook in the registry.
150
+ *
151
+ * @param {string} [cwd]
152
+ * @param {Object} [options]
153
+ * @param {(info: { id: string, event: string, matcher: string,
154
+ * expected: string, actual: string })
155
+ * => Promise<'keep'|'replace'>|('keep'|'replace')} [options.onDrift]
156
+ * Called when a hook with our `id` exists but command differs.
157
+ * Default: `'keep'` (safe for non-interactive / CI).
158
+ * @returns {Promise<{ malformed: boolean, path: string,
159
+ * created: boolean, updated: boolean,
160
+ * results: Array<{ id: string, status:
161
+ * 'created'|'already'|'drift-replaced'|'drift-kept'|'legacy-migrated',
162
+ * previousCommand?: string }> }>}
163
+ */
164
+ async function registerAll(cwd = process.cwd(), options = {}) {
165
+ const onDrift = typeof options.onDrift === 'function'
166
+ ? options.onDrift
167
+ : () => 'keep';
168
+
169
+ const { path: settingsPath, settings: existing, malformed } = readSettings(cwd);
170
+ if (malformed) {
171
+ return { malformed: true, path: settingsPath, created: false, updated: false, results: [] };
172
+ }
173
+
174
+ const wasNew = !existing;
175
+ const settings = existing && typeof existing === 'object' ? structuredClone(existing) : {};
176
+ settings.hooks = settings.hooks && typeof settings.hooks === 'object'
177
+ ? settings.hooks
178
+ : {};
179
+
180
+ const results = [];
181
+ let modified = false;
182
+
183
+ for (const def of HOOK_REGISTRY) {
184
+ const list = Array.isArray(settings.hooks[def.event])
185
+ ? settings.hooks[def.event]
186
+ : [];
187
+
188
+ const found = findOwnedEntry(list, def);
189
+
190
+ if (!found) {
191
+ list.push({
192
+ matcher: def.matcher,
193
+ hooks: [{ id: def.id, type: 'command', command: def.command }],
194
+ });
195
+ settings.hooks[def.event] = list;
196
+ results.push({ id: def.id, status: 'created' });
197
+ modified = true;
198
+ continue;
199
+ }
200
+
201
+ if (found.byLegacy) {
202
+ // Pre-v3.18 entry: stamp our id on it and align the command.
203
+ const previousCommand = found.hook.command;
204
+ found.hook.id = def.id;
205
+ found.hook.command = def.command;
206
+ results.push({ id: def.id, status: 'legacy-migrated', previousCommand });
207
+ modified = true;
208
+ continue;
209
+ }
210
+
211
+ if (found.hook.command === def.command) {
212
+ results.push({ id: def.id, status: 'already' });
213
+ continue;
214
+ }
215
+
216
+ // Drift: same id, different command. Ask the caller.
217
+ const decision = await onDrift({
218
+ id: def.id,
219
+ event: def.event,
220
+ matcher: def.matcher,
221
+ expected: def.command,
222
+ actual: found.hook.command,
223
+ });
224
+
225
+ if (decision === 'replace') {
226
+ const previousCommand = found.hook.command;
227
+ found.hook.command = def.command;
228
+ results.push({ id: def.id, status: 'drift-replaced', previousCommand });
229
+ modified = true;
230
+ } else {
231
+ results.push({
232
+ id: def.id, status: 'drift-kept', previousCommand: found.hook.command,
233
+ });
234
+ }
235
+ }
236
+
237
+ if (modified) writeSettings(settingsPath, settings);
238
+
239
+ return {
240
+ malformed: false,
241
+ path: settingsPath,
242
+ created: modified && wasNew,
243
+ updated: modified && !wasNew,
244
+ results,
245
+ };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Unregister — remove every BALDART hook from settings.json
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * Remove every hook defined in the registry from settings.json. Idempotent.
254
+ *
255
+ * @param {string} [cwd]
256
+ * @returns {{ malformed: boolean, path: string,
257
+ * results: Array<{ id: string, status: 'removed'|'not-found' }> }}
258
+ */
259
+ function unregisterAll(cwd = process.cwd()) {
260
+ const { path: settingsPath, settings, malformed } = readSettings(cwd);
261
+ if (malformed || !settings || !settings.hooks) {
262
+ return {
263
+ malformed,
264
+ path: settingsPath,
265
+ results: HOOK_REGISTRY.map((d) => ({ id: d.id, status: 'not-found' })),
266
+ };
267
+ }
268
+
269
+ const next = structuredClone(settings);
270
+ const results = [];
271
+ let modified = false;
272
+
273
+ for (const def of HOOK_REGISTRY) {
274
+ const list = Array.isArray(next.hooks[def.event]) ? next.hooks[def.event] : null;
275
+ if (!list) {
276
+ results.push({ id: def.id, status: 'not-found' });
277
+ continue;
278
+ }
279
+ let removed = false;
280
+ const filtered = list
281
+ .map((entry) => {
282
+ if (!entry || !Array.isArray(entry.hooks)) return entry;
283
+ const innerKept = entry.hooks.filter((h) => {
284
+ if (!h) return true;
285
+ if (h.id === def.id) { removed = true; return false; }
286
+ if (def.legacyMatch && h.type === 'command'
287
+ && typeof h.command === 'string' && h.command.includes(def.legacyMatch)) {
288
+ removed = true;
289
+ return false;
290
+ }
291
+ return true;
292
+ });
293
+ if (innerKept.length === 0) return null;
294
+ return { ...entry, hooks: innerKept };
295
+ })
296
+ .filter(Boolean);
297
+
298
+ if (removed) {
299
+ if (filtered.length === 0) {
300
+ delete next.hooks[def.event];
301
+ } else {
302
+ next.hooks[def.event] = filtered;
303
+ }
304
+ modified = true;
305
+ results.push({ id: def.id, status: 'removed' });
306
+ } else {
307
+ results.push({ id: def.id, status: 'not-found' });
308
+ }
309
+ }
310
+
311
+ if (Object.keys(next.hooks).length === 0) delete next.hooks;
312
+ if (modified) writeSettings(settingsPath, next);
313
+
314
+ return { malformed: false, path: settingsPath, results };
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Verify — post-flight assertion that every active hook is registered
319
+ // ---------------------------------------------------------------------------
320
+
321
+ /**
322
+ * Confirms that every active hook in the registry is present in
323
+ * `.claude/settings.json`. Pure read (delegates to `getStatus`). Designed as
324
+ * the post-flight gate for `add` / `update --reset`: the install must never
325
+ * declare success while a hook is missing (the silent partial-install class,
326
+ * v3.27.1 / mayo 3.28→3.31).
327
+ *
328
+ * A malformed settings.json counts as a failure: registration was skipped, so
329
+ * the hooks are not verifiably present.
330
+ *
331
+ * @param {string} [cwd]
332
+ * @returns {{ ok: boolean, malformed: boolean, missing: string[], path: string }}
333
+ */
334
+ function verifyAll(cwd = process.cwd()) {
335
+ const status = getStatus(cwd);
336
+ if (status.malformed) {
337
+ return {
338
+ ok: false,
339
+ malformed: true,
340
+ missing: HOOK_REGISTRY.map((d) => d.id),
341
+ path: status.path,
342
+ };
343
+ }
344
+ const missing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
345
+ return { ok: missing.length === 0, malformed: false, missing, path: status.path };
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Drift prompt helper (interactive Keep / Replace / Show diff)
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /**
353
+ * Build an `onDrift` callback suitable for `registerAll`. Wraps inquirer via
354
+ * the project's UI helper so the CLI output stays consistent.
355
+ *
356
+ * @param {Object} UI the `src/utils/ui.js` module (avoids a
357
+ * circular require by accepting it from
358
+ * the caller)
359
+ * @param {Object} [options]
360
+ * @param {boolean} [options.autoYes] non-interactive mode: skip prompt and
361
+ * always return 'keep' (safe default)
362
+ * @returns {(info: {id:string, event:string, expected:string, actual:string})
363
+ * => Promise<'keep'|'replace'>}
364
+ */
365
+ function createDriftPrompt(UI, { autoYes = false } = {}) {
366
+ if (autoYes) {
367
+ return async ({ id }) => {
368
+ UI.warning(`Hook drift detected for \`${id}\` — keeping current command (auto mode). Run \`baldart doctor\` interactively to resolve.`);
369
+ return 'keep';
370
+ };
371
+ }
372
+
373
+ const truncate = (s, n = 70) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
374
+
375
+ return async function onDrift({ id, event, expected, actual }) {
376
+ UI.warning(`Hook \`${id}\` (${event}) is registered with a non-default command.`);
377
+ // Loop while the user keeps choosing 'diff'
378
+ for (;;) {
379
+ const choice = await UI.select(
380
+ `What do you want to do with \`${id}\`?`,
381
+ [
382
+ { name: `Keep current — leave: ${truncate(actual)}`, value: 'keep' },
383
+ { name: `Replace with BALDART — set: ${truncate(expected)}`, value: 'replace' },
384
+ { name: `Show full diff and decide`, value: 'diff' },
385
+ ],
386
+ );
387
+ if (choice !== 'diff') return choice;
388
+ console.log(` expected: ${expected}`);
389
+ console.log(` actual: ${actual}`);
390
+ }
391
+ };
392
+ }
393
+
394
+ module.exports = {
395
+ HOOK_REGISTRY,
396
+ SETTINGS_FILE,
397
+ getStatus,
398
+ verifyAll,
399
+ registerAll,
400
+ unregisterAll,
401
+ createDriftPrompt,
402
+ };