baldart 3.18.1 → 3.21.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.
@@ -212,8 +212,47 @@ async function detectState(cwd, opts = {}) {
212
212
  state.overlays = countOverlays(cwd);
213
213
  state.overlaysDrifted = overlayDrift(cwd, state.frameworkVersion);
214
214
 
215
- state.editGateRegistered = false;
216
- try { state.editGateRegistered = Hooks.isRegistered(cwd); } catch (_) {}
215
+ state.hooksStatus = { malformed: false, hooks: [] };
216
+ state.hooksMissing = [];
217
+ state.hooksDrifted = [];
218
+ state.hooksLegacy = [];
219
+ try {
220
+ const status = Hooks.getStatus(cwd);
221
+ state.hooksStatus = status;
222
+ if (!status.malformed) {
223
+ state.hooksMissing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
224
+ state.hooksDrifted = status.hooks.filter((h) => h.registered && h.drift && !h.legacy).map((h) => h.id);
225
+ state.hooksLegacy = status.hooks.filter((h) => h.legacy).map((h) => h.id);
226
+ }
227
+ } catch (_) {}
228
+
229
+ // ---- Agent symlink integrity (since v3.20.0) -----------------------
230
+ // Source of truth for the BALDART agent list is the framework filesystem
231
+ // directory itself (`.framework/framework/.claude/agents/*.md`). For each
232
+ // name, the consumer must have a working `.claude/agents/<name>.md`
233
+ // (symlink to the framework copy, possibly merged with an overlay).
234
+ // A missing or broken symlink would otherwise be detected at runtime by
235
+ // `agent-discovery-gate.js`, blocking Agent calls. This probe lets the
236
+ // doctor heal it offline before the user hits the gate.
237
+ state.agentSymlinksBroken = [];
238
+ try {
239
+ if (state.frameworkPresent) {
240
+ const frameworkAgentsDir = path.join(cwd, '.framework', 'framework', '.claude', 'agents');
241
+ const consumerAgentsDir = path.join(cwd, '.claude', 'agents');
242
+ if (fs.existsSync(frameworkAgentsDir)) {
243
+ const baldartAgents = fs.readdirSync(frameworkAgentsDir)
244
+ .filter((f) => f.endsWith('.md') && f !== 'REGISTRY.md')
245
+ .map((f) => f.replace(/\.md$/, ''));
246
+ for (const name of baldartAgents) {
247
+ try {
248
+ fs.statSync(path.join(consumerAgentsDir, `${name}.md`));
249
+ } catch (_) {
250
+ state.agentSymlinksBroken.push(name);
251
+ }
252
+ }
253
+ }
254
+ }
255
+ } catch (_) { /* never block doctor on probe */ }
217
256
 
218
257
  // `.framework/` is a git subtree — gitignore matching it breaks every
219
258
  // future `git add` under that path (the failure mode that hit v3.13.0
@@ -410,15 +449,75 @@ function planActions(state) {
410
449
  // Non-blocking — keep looking for other actions to run in the same pass.
411
450
  }
412
451
 
413
- if (state.editGateRegistered === false) {
452
+ // BALDART hooks (multi-hook registry since v3.18.0).
453
+ // Three buckets, three actions: missing → propose; legacy → propose
454
+ // (silent migration to current id); drifted → require interactive prompt.
455
+ if ((state.hooksMissing && state.hooksMissing.length > 0)
456
+ || (state.hooksLegacy && state.hooksLegacy.length > 0)) {
457
+ const ids = [...(state.hooksMissing || []), ...(state.hooksLegacy || [])];
414
458
  actions.push({
415
- key: 'register-edit-gate',
416
- label: 'Register framework-edit-gate hook (.claude/settings.json)',
417
- why: 'Prevents project-specific tokens (Neo-Brutalism, merchant, …) from being written to .framework/ accidentally. Standard in v3.3.0+; missing on installs predating that version.',
459
+ key: 'register-hooks',
460
+ label: `Register/migrate BALDART hook(s) in .claude/settings.json (${ids.join(', ')})`,
461
+ why: 'BALDART hooks are missing or registered under a pre-v3.18 layout. They run on PreToolUse/Stop/SessionStart to gate edits and feed the auto-learning loop. Re-registration is idempotent and never overwrites user-customized commands without asking.',
418
462
  autoOk: true,
419
463
  run: async () => {
420
- const res = require('../utils/hooks').register();
421
- UI.success(`Hook registered: ${res.status} (${res.path})`);
464
+ const Hooks = require('../utils/hooks');
465
+ const res = await Hooks.registerAll(process.cwd(), {
466
+ onDrift: Hooks.createDriftPrompt(UI, { autoYes: state.autoMode }),
467
+ });
468
+ if (res.malformed) {
469
+ UI.warning('.claude/settings.json is malformed; fix it manually then re-run `npx baldart doctor`.');
470
+ return;
471
+ }
472
+ for (const r of res.results) {
473
+ if (r.status === 'created') UI.success(`Registered hook \`${r.id}\``);
474
+ else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
475
+ }
476
+ },
477
+ });
478
+ }
479
+ if (state.hooksDrifted && state.hooksDrifted.length > 0) {
480
+ actions.push({
481
+ key: 'reconcile-hooks-drift',
482
+ label: `Reconcile drifted BALDART hook(s) (${state.hooksDrifted.join(', ')})`,
483
+ why: 'These hooks are registered but their command differs from the BALDART default. This is typically a sign of a manual customization or a stale framework version on disk. Reconciliation prompts you per hook (Keep / Replace / Show diff).',
484
+ autoOk: false, // drift reconciliation is the kind of decision --auto must NOT make
485
+ run: async () => {
486
+ const Hooks = require('../utils/hooks');
487
+ const res = await Hooks.registerAll(process.cwd(), {
488
+ onDrift: Hooks.createDriftPrompt(UI), // always interactive here
489
+ });
490
+ for (const r of res.results) {
491
+ if (r.status === 'drift-replaced') UI.success(`Replaced \`${r.id}\` (was: ${r.previousCommand})`);
492
+ else if (r.status === 'drift-kept') UI.info(`Kept \`${r.id}\` as-is.`);
493
+ }
494
+ },
495
+ });
496
+ }
497
+
498
+ // Agent symlink integrity (since v3.20.0). Re-runs the agent merge loop to
499
+ // recreate any missing per-agent symlink. Calls `SymlinkUtils.mergeAgents`
500
+ // directly rather than dispatching to `update` — we want only the symlink
501
+ // pass, not a full update (which would also pull, merge overlays, register
502
+ // hooks, and verify config drift).
503
+ if (state.agentSymlinksBroken && state.agentSymlinksBroken.length > 0) {
504
+ actions.push({
505
+ key: 'repair-agent-symlinks',
506
+ label: `Repair ${state.agentSymlinksBroken.length} broken agent symlink(s)`,
507
+ why: `BALDART expects these agents to be reachable via .claude/agents/<name>.md but the symlinks are missing or broken: ${state.agentSymlinksBroken.join(', ')}. Without these, the agent-discovery-gate PreToolUse hook will deny Agent calls targeting these names.`,
508
+ autoOk: true,
509
+ run: async () => {
510
+ const SymlinkUtils = require('../utils/symlinks');
511
+ // Re-read the config here (not stashed on state) to pick up
512
+ // `tools.enabled`. Falls back to ['claude'] if absent/malformed.
513
+ const cfg = loadConfig(process.cwd());
514
+ const enabledTools = (cfg && !cfg.__malformed
515
+ && cfg.tools && Array.isArray(cfg.tools.enabled))
516
+ ? cfg.tools.enabled
517
+ : ['claude'];
518
+ const symlinks = new SymlinkUtils();
519
+ symlinks.mergeAgents({ tools: enabledTools });
520
+ UI.success(`Re-merged agent symlinks for tools: ${enabledTools.join(', ')}`);
422
521
  },
423
522
  });
424
523
  }
@@ -616,11 +715,36 @@ function renderDiagnostic(state) {
616
715
  console.log(` • ${o.name} targets v${o.target}, installed v${o.installed}`));
617
716
  }
618
717
 
619
- console.log(statusLine(
620
- 'Edit gate',
621
- state.editGateRegistered ? 'registered' : 'NOT registered (.claude/settings.json)',
622
- state.editGateRegistered ? 'ok' : 'warn'
623
- ));
718
+ {
719
+ const total = state.hooksStatus && state.hooksStatus.hooks ? state.hooksStatus.hooks.length : 0;
720
+ const missing = state.hooksMissing ? state.hooksMissing.length : 0;
721
+ const drifted = state.hooksDrifted ? state.hooksDrifted.length : 0;
722
+ const legacy = state.hooksLegacy ? state.hooksLegacy.length : 0;
723
+ const registered = total - missing;
724
+ let summary;
725
+ let status;
726
+ if (state.hooksStatus && state.hooksStatus.malformed) {
727
+ summary = '.claude/settings.json is malformed';
728
+ status = 'warn';
729
+ } else if (missing === 0 && drifted === 0 && legacy === 0) {
730
+ summary = `${registered}/${total} registered`;
731
+ status = 'ok';
732
+ } else {
733
+ const parts = [`${registered}/${total} registered`];
734
+ if (missing) parts.push(`${missing} missing`);
735
+ if (drifted) parts.push(`${drifted} drifted`);
736
+ if (legacy) parts.push(`${legacy} pre-v3.18`);
737
+ summary = parts.join(', ');
738
+ status = 'warn';
739
+ }
740
+ console.log(statusLine('BALDART hooks', summary, status));
741
+ if (state.hooksMissing && state.hooksMissing.length)
742
+ console.log(` • missing: ${state.hooksMissing.join(', ')}`);
743
+ if (state.hooksDrifted && state.hooksDrifted.length)
744
+ console.log(` • drifted: ${state.hooksDrifted.join(', ')}`);
745
+ if (state.hooksLegacy && state.hooksLegacy.length)
746
+ console.log(` • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
747
+ }
624
748
 
625
749
  if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
626
750
  console.log(statusLine(
@@ -663,6 +787,7 @@ async function doctor(opts = {}) {
663
787
  const offline = !!opts.offline;
664
788
 
665
789
  const state = await detectState(cwd, { offline });
790
+ state.autoMode = auto;
666
791
  renderDiagnostic(state);
667
792
 
668
793
  const actions = planActions(state);
@@ -0,0 +1,381 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const yaml = require('js-yaml');
4
+ const UI = require('../utils/ui');
5
+ const { mergeOverlay, computeBaseFileSha } = require('../utils/overlay-merger');
6
+
7
+ const FRAMEWORK_DIR = '.framework';
8
+ const FRAMEWORK_PAYLOAD = path.join(FRAMEWORK_DIR, 'framework');
9
+ const OVERLAYS_DIR = path.join('.baldart', 'overlays');
10
+
11
+ const KINDS = ['skill', 'agent', 'command'];
12
+
13
+ function frameworkRoot(cwd) {
14
+ return path.join(cwd, FRAMEWORK_DIR);
15
+ }
16
+
17
+ function ensureConsumer(cwd) {
18
+ if (!fs.existsSync(frameworkRoot(cwd))) {
19
+ UI.error('Not a BALDART consumer repo — `.framework/` not found.');
20
+ UI.info('Run `npx baldart` to install the framework first.');
21
+ process.exit(1);
22
+ }
23
+ }
24
+
25
+ function readFrameworkVersion(cwd) {
26
+ try {
27
+ return fs.readFileSync(path.join(cwd, FRAMEWORK_DIR, 'VERSION'), 'utf8').trim();
28
+ } catch (_) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function baseFilePathFor(cwd, kind, name) {
34
+ if (kind === 'skill') {
35
+ return path.join(cwd, FRAMEWORK_PAYLOAD, '.claude', 'skills', name, 'SKILL.md');
36
+ }
37
+ return path.join(cwd, FRAMEWORK_PAYLOAD, '.claude', `${kind}s`, `${name}.md`);
38
+ }
39
+
40
+ function overlayPathFor(cwd, kind, name) {
41
+ if (kind === 'skill') return path.join(cwd, OVERLAYS_DIR, `${name}.md`);
42
+ return path.join(cwd, OVERLAYS_DIR, `${kind}s`, `${name}.md`);
43
+ }
44
+
45
+ function parseTarget(target) {
46
+ // Accept "agents/coder", "commands/codexreview", "skill/bug", or bare name
47
+ // (skill assumed). Always normalise to { kind: 'agent'|'command'|'skill', name }.
48
+ if (!target || typeof target !== 'string') return null;
49
+ if (target.includes('/')) {
50
+ const [head, ...rest] = target.split('/');
51
+ const name = rest.join('/');
52
+ if (!name) return null;
53
+ if (head === 'skill' || head === 'skills') return { kind: 'skill', name };
54
+ if (head === 'agent' || head === 'agents') return { kind: 'agent', name };
55
+ if (head === 'command' || head === 'commands') return { kind: 'command', name };
56
+ return null;
57
+ }
58
+ return { kind: 'skill', name: target };
59
+ }
60
+
61
+ function parseFrontmatter(content) {
62
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
63
+ if (!m) return { fm: {}, found: false };
64
+ try {
65
+ const fm = yaml.load(m[1]) || {};
66
+ // Coerce `base_file_sha` to string — a 12-char hex SHA may be all digits,
67
+ // which YAML would otherwise parse as a number (0 → falsy → false drift).
68
+ if (fm.base_file_sha != null) fm.base_file_sha = String(fm.base_file_sha);
69
+ return { fm, found: true };
70
+ } catch (_) {
71
+ return { fm: {}, found: true, malformed: true };
72
+ }
73
+ }
74
+
75
+ function scaffoldBody(kind, name) {
76
+ if (kind === 'skill') {
77
+ return [
78
+ '',
79
+ `> Skill overlays are concatenated to the base SKILL.md at runtime by the Claude`,
80
+ `> agent. The merger does NOT parse [OVERRIDE]/[APPEND]/[PREPEND] markers here —`,
81
+ `> they are textual conventions for the agent. Use \`mode: override\` in`,
82
+ `> frontmatter to replace the base skill entirely.`,
83
+ '',
84
+ `## [APPEND] Project-specific guidance`,
85
+ '',
86
+ `<!-- Add the rules / vocabulary / opinions that apply to THIS project. -->`,
87
+ '',
88
+ ].join('\n');
89
+ }
90
+ return [
91
+ '',
92
+ `> Section markers (parsed by overlay-merger.js):`,
93
+ `> ## [OVERRIDE] <H2> — replace base section verbatim`,
94
+ `> ## [APPEND] <H2> — add overlay content after base section`,
95
+ `> ## [PREPEND] <H2> — add overlay content before base section`,
96
+ `> ## <H2> — appended as a new section at the end`,
97
+ '',
98
+ `## [APPEND] <heading from base ${kind} "${name}">`,
99
+ '',
100
+ `<!-- Replace the heading above with one that exists in the base file. -->`,
101
+ '',
102
+ ].join('\n');
103
+ }
104
+
105
+ function buildFrontmatter({ kind, name, frameworkVersion, baseSha, mode }) {
106
+ const lines = ['---'];
107
+ lines.push(`base_${kind}: ${name}`);
108
+ lines.push(`base_${kind}_version: ${frameworkVersion || 'unknown'}`);
109
+ // Quote the SHA — a 12-char hex string can be all digits, which YAML would
110
+ // otherwise parse as a number (0 → falsy → false-negative in drift check).
111
+ if (baseSha) lines.push(`base_file_sha: "${baseSha}"`);
112
+ lines.push(`mode: ${mode || 'extend'}`);
113
+ lines.push('---');
114
+ return lines.join('\n') + '\n';
115
+ }
116
+
117
+ // ─── scaffold ──────────────────────────────────────────────────────────────
118
+
119
+ async function scaffold(target, options = {}) {
120
+ const cwd = process.cwd();
121
+ ensureConsumer(cwd);
122
+
123
+ const parsed = parseTarget(target);
124
+ if (!parsed) {
125
+ UI.error('Invalid target. Use: skill/<name>, agents/<name>, or commands/<name>.');
126
+ UI.info('Example: npx baldart overlay scaffold agents/coder');
127
+ process.exit(1);
128
+ }
129
+ const { kind, name } = parsed;
130
+
131
+ const baseAbs = baseFilePathFor(cwd, kind, name);
132
+ if (!fs.existsSync(baseAbs)) {
133
+ UI.error(`Base ${kind} "${name}" not found at ${path.relative(cwd, baseAbs)}.`);
134
+ UI.info(`Run \`ls ${path.relative(cwd, path.dirname(baseAbs))}\` to list available ${kind}s.`);
135
+ process.exit(1);
136
+ }
137
+
138
+ const overlayAbs = overlayPathFor(cwd, kind, name);
139
+ const overlayRel = path.relative(cwd, overlayAbs);
140
+
141
+ if (fs.existsSync(overlayAbs)) {
142
+ UI.warning(`Overlay already exists: ${overlayRel}`);
143
+ UI.info('Run `npx baldart overlay drift ' + overlayRel + '` to check it, or edit it directly to extend.');
144
+ process.exit(0);
145
+ }
146
+
147
+ fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
148
+
149
+ const baseContent = fs.readFileSync(baseAbs, 'utf8');
150
+ const baseSha = computeBaseFileSha(baseContent);
151
+ const fwVersion = readFrameworkVersion(cwd);
152
+ const mode = (options.mode || 'extend').trim();
153
+ if (!['extend', 'override'].includes(mode)) {
154
+ UI.error(`Invalid --mode "${mode}". Use "extend" or "override".`);
155
+ process.exit(1);
156
+ }
157
+
158
+ const content =
159
+ buildFrontmatter({ kind, name, frameworkVersion: fwVersion, baseSha, mode }) +
160
+ scaffoldBody(kind, name);
161
+
162
+ fs.writeFileSync(overlayAbs, content);
163
+ UI.success(`Created ${overlayRel}`);
164
+ UI.info(`Base file: ${path.relative(cwd, baseAbs)}`);
165
+ UI.info(`Targets framework v${fwVersion || 'unknown'} (base_file_sha=${baseSha}).`);
166
+ UI.newline();
167
+ UI.list([
168
+ `Edit ${overlayRel} to add your customisations.`,
169
+ `Validate the merge with: npx baldart overlay validate ${overlayRel}`,
170
+ kind === 'skill'
171
+ ? `Run any /-command that uses this skill to see the overlay applied at runtime.`
172
+ : `Apply with: npx baldart update (regenerates .claude/${kind}s/${name}.md from base + overlay).`,
173
+ ]);
174
+ }
175
+
176
+ // ─── validate ──────────────────────────────────────────────────────────────
177
+
178
+ function deriveTargetFromOverlayPath(cwd, overlayPath) {
179
+ const abs = path.isAbsolute(overlayPath) ? overlayPath : path.join(cwd, overlayPath);
180
+ const root = path.join(cwd, OVERLAYS_DIR);
181
+ const rel = path.relative(root, abs);
182
+ if (rel.startsWith('..')) return null;
183
+ const parts = rel.split(path.sep);
184
+ if (parts.length === 1) {
185
+ return { kind: 'skill', name: parts[0].replace(/\.md$/, ''), abs };
186
+ }
187
+ if (parts.length === 2) {
188
+ const sub = parts[0];
189
+ if (sub === 'agents') return { kind: 'agent', name: parts[1].replace(/\.md$/, ''), abs };
190
+ if (sub === 'commands') return { kind: 'command', name: parts[1].replace(/\.md$/, ''), abs };
191
+ }
192
+ return null;
193
+ }
194
+
195
+ async function validate(overlayPath) {
196
+ const cwd = process.cwd();
197
+ ensureConsumer(cwd);
198
+
199
+ if (!overlayPath) {
200
+ UI.error('Usage: npx baldart overlay validate <path>');
201
+ UI.info('Example: npx baldart overlay validate .baldart/overlays/agents/coder.md');
202
+ process.exit(1);
203
+ }
204
+
205
+ const t = deriveTargetFromOverlayPath(cwd, overlayPath);
206
+ if (!t) {
207
+ UI.error(`Path is not under ${OVERLAYS_DIR}/ or has unexpected shape: ${overlayPath}`);
208
+ process.exit(1);
209
+ }
210
+ if (!fs.existsSync(t.abs)) {
211
+ UI.error(`Overlay file not found: ${path.relative(cwd, t.abs)}`);
212
+ process.exit(1);
213
+ }
214
+
215
+ const overlayContent = fs.readFileSync(t.abs, 'utf8');
216
+ const { fm, found, malformed } = parseFrontmatter(overlayContent);
217
+ if (!found) {
218
+ UI.error('Overlay has no YAML frontmatter.');
219
+ process.exit(1);
220
+ }
221
+ if (malformed) {
222
+ UI.error('Overlay frontmatter is not valid YAML.');
223
+ process.exit(1);
224
+ }
225
+ const expectedKindField = `base_${t.kind}`;
226
+ if (fm[expectedKindField] !== t.name) {
227
+ UI.error(`Frontmatter mismatch: expected ${expectedKindField}: ${t.name} (got ${fm[expectedKindField] || 'missing'}).`);
228
+ process.exit(1);
229
+ }
230
+
231
+ const baseAbs = baseFilePathFor(cwd, t.kind, t.name);
232
+ if (!fs.existsSync(baseAbs)) {
233
+ UI.error(`Base ${t.kind} "${t.name}" not found at ${path.relative(cwd, baseAbs)}.`);
234
+ process.exit(1);
235
+ }
236
+
237
+ if (t.kind === 'skill') {
238
+ UI.success(`Overlay valid (skill, concat model).`);
239
+ UI.info(`Targets v${fm.base_skill_version || 'unknown'}, mode=${fm.mode || 'extend'}.`);
240
+ if (fm.base_file_sha) {
241
+ const currentSha = computeBaseFileSha(fs.readFileSync(baseAbs, 'utf8'));
242
+ if (currentSha !== fm.base_file_sha) {
243
+ UI.warning(`Base file changed since overlay was authored (sha ${fm.base_file_sha} → ${currentSha}). Review for drift.`);
244
+ } else {
245
+ UI.success('Base file unchanged since overlay was authored.');
246
+ }
247
+ } else {
248
+ UI.info('No base_file_sha in frontmatter — drift check by version only.');
249
+ }
250
+ return;
251
+ }
252
+
253
+ // Agent/command — dry-run the merger.
254
+ const baseContent = fs.readFileSync(baseAbs, 'utf8');
255
+ const fwVersion = readFrameworkVersion(cwd);
256
+ let result;
257
+ try {
258
+ result = mergeOverlay({
259
+ kind: t.kind,
260
+ name: t.name,
261
+ baseContent,
262
+ overlayContent,
263
+ frameworkVersion: fwVersion
264
+ });
265
+ } catch (err) {
266
+ UI.error(`Merger threw: ${err.message}`);
267
+ process.exit(1);
268
+ }
269
+
270
+ if (result.warnings && result.warnings.length) {
271
+ result.warnings.forEach((w) => UI.warning(w));
272
+ }
273
+ UI.success(`Overlay valid (${t.kind}, marker model).`);
274
+ UI.info(`Targets v${result.baseVersionTargeted}, mode=${result.overlayMode}.`);
275
+ UI.info(`Generated output: ${result.generated.split('\n').length} lines.`);
276
+ if (fm.base_file_sha) {
277
+ const currentSha = computeBaseFileSha(baseContent);
278
+ if (currentSha !== fm.base_file_sha) {
279
+ UI.warning(`Base file changed since overlay was authored (sha ${fm.base_file_sha} → ${currentSha}). Review for drift.`);
280
+ } else {
281
+ UI.success('Base file unchanged since overlay was authored.');
282
+ }
283
+ }
284
+ }
285
+
286
+ // ─── drift ─────────────────────────────────────────────────────────────────
287
+
288
+ function collectOverlays(cwd) {
289
+ const root = path.join(cwd, OVERLAYS_DIR);
290
+ if (!fs.existsSync(root)) return [];
291
+ const out = [];
292
+ const pushFrom = (sub, kind) => {
293
+ const dir = sub ? path.join(root, sub) : root;
294
+ if (!fs.existsSync(dir)) return;
295
+ for (const f of fs.readdirSync(dir)) {
296
+ if (!f.endsWith('.md')) continue;
297
+ if (f.endsWith('.example.md')) continue;
298
+ if (sub === '' && (f === 'README.md')) continue;
299
+ out.push({ kind, name: f.replace(/\.md$/, ''), abs: path.join(dir, f), rel: path.join(OVERLAYS_DIR, sub || '', f) });
300
+ }
301
+ };
302
+ pushFrom('', 'skill');
303
+ pushFrom('agents', 'agent');
304
+ pushFrom('commands', 'command');
305
+ return out;
306
+ }
307
+
308
+ async function drift(target, options = {}) {
309
+ const cwd = process.cwd();
310
+ ensureConsumer(cwd);
311
+
312
+ const fwVersion = readFrameworkVersion(cwd);
313
+ const all = !!options.all || !target;
314
+ const targets = [];
315
+ if (all) {
316
+ targets.push(...collectOverlays(cwd));
317
+ } else {
318
+ const t = deriveTargetFromOverlayPath(cwd, target);
319
+ if (!t) {
320
+ UI.error(`Path is not under ${OVERLAYS_DIR}/ or has unexpected shape: ${target}`);
321
+ process.exit(1);
322
+ }
323
+ if (!fs.existsSync(t.abs)) {
324
+ UI.error(`Overlay not found: ${path.relative(cwd, t.abs)}`);
325
+ process.exit(1);
326
+ }
327
+ targets.push({ kind: t.kind, name: t.name, abs: t.abs, rel: path.relative(cwd, t.abs) });
328
+ }
329
+
330
+ if (targets.length === 0) {
331
+ UI.info('No overlays authored.');
332
+ return;
333
+ }
334
+
335
+ let drifted = 0;
336
+ let unknown = 0;
337
+ let clean = 0;
338
+ for (const t of targets) {
339
+ const content = fs.readFileSync(t.abs, 'utf8');
340
+ const { fm } = parseFrontmatter(content);
341
+ const versionField = `base_${t.kind}_version`;
342
+ const overlayVersion = fm[versionField];
343
+ const baseAbs = baseFilePathFor(cwd, t.kind, t.name);
344
+ const baseExists = fs.existsSync(baseAbs);
345
+
346
+ if (!baseExists) {
347
+ UI.error(`${t.rel}: base ${t.kind} "${t.name}" no longer exists in framework.`);
348
+ drifted++;
349
+ continue;
350
+ }
351
+
352
+ const baseSha = computeBaseFileSha(fs.readFileSync(baseAbs, 'utf8'));
353
+ let shaDrift = null;
354
+ if (fm.base_file_sha) {
355
+ shaDrift = fm.base_file_sha !== baseSha;
356
+ }
357
+ const versionDrift = overlayVersion && fwVersion && overlayVersion !== fwVersion;
358
+
359
+ if (shaDrift === true) {
360
+ UI.warning(`${t.rel}: base file changed since overlay was authored (sha ${fm.base_file_sha} → ${baseSha}).`);
361
+ drifted++;
362
+ } else if (shaDrift === false) {
363
+ UI.success(`${t.rel}: clean (sha matches base v${fwVersion}).`);
364
+ clean++;
365
+ } else if (versionDrift) {
366
+ UI.warning(`${t.rel}: targets v${overlayVersion}, installed v${fwVersion} — review (no base_file_sha to confirm).`);
367
+ unknown++;
368
+ } else if (overlayVersion) {
369
+ UI.info(`${t.rel}: targets v${overlayVersion} (no base_file_sha — drift unknown).`);
370
+ unknown++;
371
+ } else {
372
+ UI.warning(`${t.rel}: no base_${t.kind}_version in frontmatter.`);
373
+ unknown++;
374
+ }
375
+ }
376
+
377
+ UI.newline();
378
+ UI.info(`Summary: ${clean} clean, ${drifted} drifted, ${unknown} unknown — of ${targets.length} overlay(s).`);
379
+ }
380
+
381
+ module.exports = { scaffold, validate, drift };
@@ -400,13 +400,25 @@ async function update(options = {}) {
400
400
  UI.warning(`Routines wizard skipped: ${err.message}`);
401
401
  }
402
402
 
403
- // Framework-edit-gate hook (since v3.3.0) — backfill for consumers
404
- // installed before v3.3.0 didn't have this hook. We register it on every
405
- // update (idempotent no-op if already present).
403
+ // BALDART hooks (multi-hook registry since v3.18.0) — backfill for
404
+ // consumers that predate any of the hooks shipped in the current
405
+ // framework version. Idempotent: no-op when every hook is already in
406
+ // place. Drift on an existing entry triggers an interactive prompt
407
+ // (Keep / Replace / Show diff); on --auto / -y the default is 'keep'.
406
408
  try {
407
- const res = Hooks.register();
408
- if (res.status === 'created' || res.status === 'updated') {
409
- UI.success('Registered framework-edit-gate hook in .claude/settings.json (v3.3.0+ default).');
409
+ const res = await Hooks.registerAll(process.cwd(), {
410
+ onDrift: Hooks.createDriftPrompt(UI, { autoYes }),
411
+ });
412
+ if (res.malformed) {
413
+ UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
414
+ } else {
415
+ for (const r of res.results) {
416
+ if (r.status === 'created') UI.success(`Registered hook \`${r.id}\` in .claude/settings.json (v3.18.0+ default).`);
417
+ else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
418
+ else if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
419
+ else if (r.status === 'drift-kept') UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
420
+ // 'already' is silent on update to avoid noise
421
+ }
410
422
  }
411
423
  } catch (err) {
412
424
  UI.warning(`Hook registration skipped: ${err.message}`);