baldart 3.25.0 → 3.27.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.
@@ -117,15 +117,22 @@ function overlayDrift(cwd, frameworkVersion) {
117
117
  }
118
118
 
119
119
  async function describeRemote(git, repo, offline) {
120
- // Best-effort remote drift detection. Returns { ahead, behind, fetched }.
121
- if (offline) return { fetched: false };
122
- try { await git.fetch(repo); }
123
- catch (_) { return { fetched: false }; }
124
- try {
125
- const out = await git.git.raw(['rev-list', '--left-right', '--count', 'HEAD...FETCH_HEAD']);
126
- const [ahead, behind] = out.trim().split('\t').map(Number);
127
- return { ahead, behind, fetched: true };
128
- } catch (_) { return { fetched: false }; }
120
+ // SSOT (v3.25.0+): version-compare authority + diagnostic counts.
121
+ // The legacy { ahead, behind, fetched } shape is preserved for downstream
122
+ // callers that read it; the new authoritative fields are `isAligned`,
123
+ // `installedVersion`, `remoteVersion`.
124
+ if (offline) return { fetched: false, isAligned: false, installedVersion: 'unknown', remoteVersion: 'unknown', aheadScoped: 0 };
125
+ const status = await git.getUpdateStatus(repo);
126
+ return {
127
+ fetched: status.fetched,
128
+ ahead: status.commitCount.ahead,
129
+ behind: status.commitCount.behind,
130
+ isAligned: status.isAligned,
131
+ installedVersion: status.installedVersion,
132
+ remoteVersion: status.remoteVersion,
133
+ aheadScoped: status.aheadScoped,
134
+ hasFetchHead: status.hasFetchHead,
135
+ };
129
136
  }
130
137
 
131
138
  async function localFrameworkChanges(git) {
@@ -137,11 +144,12 @@ async function localFrameworkChanges(git) {
137
144
  } catch (_) { return 0; }
138
145
  }
139
146
 
140
- async function commitsAheadOfRemote(git) {
141
- try {
142
- const out = await git.git.raw(['rev-list', '--count', 'FETCH_HEAD..HEAD', '--', FRAMEWORK_DIR]);
143
- return Number(out.trim()) || 0;
144
- } catch (_) { return 0; }
147
+ async function commitsAheadOfRemote(git, remote) {
148
+ // Pre-v3.25.0 this re-ran the git query; now we read from the SSOT
149
+ // result populated by describeRemote(). Kept as a helper for readability
150
+ // in detectState().
151
+ if (remote && typeof remote.aheadScoped === 'number') return remote.aheadScoped;
152
+ return 0;
145
153
  }
146
154
 
147
155
  async function detectState(cwd, opts = {}) {
@@ -206,7 +214,7 @@ async function detectState(cwd, opts = {}) {
206
214
  }
207
215
 
208
216
  state.remote = await describeRemote(git, ledger.framework_repo || REPO_DEFAULT, !!opts.offline);
209
- state.commitsAhead = await commitsAheadOfRemote(git);
217
+ state.commitsAhead = await commitsAheadOfRemote(git, state.remote);
210
218
  state.workingTreeDirty = await localFrameworkChanges(git);
211
219
 
212
220
  state.overlays = countOverlays(cwd);
@@ -577,14 +585,24 @@ function planActions(state) {
577
585
  });
578
586
  }
579
587
 
580
- const remoteAhead = state.remote.fetched && state.remote.behind > 0;
581
- const hasLocalWork = state.workingTreeDirty > 0 || state.commitsAhead > 0;
588
+ // v3.25.0+: drift detection is authoritative via VERSION compare (isAligned).
589
+ // The HEAD...FETCH_HEAD commit count is subtree-merge noise and never reaches
590
+ // 0, so we MUST NOT use it as the "needs update" signal.
591
+ const remoteAhead = state.remote.fetched && state.remote.isAligned === false;
592
+ // Local work detection: working-tree dirty is real; commitsAhead (aheadScoped)
593
+ // includes subtree-merge noise — only count it when NOT aligned (i.e. there's
594
+ // a real divergence to investigate). C2 adds classifier to filter noise.
595
+ const hasLocalWork = state.workingTreeDirty > 0 ||
596
+ (state.commitsAhead > 0 && state.remote.fetched && state.remote.isAligned === false);
582
597
 
583
598
  if (remoteAhead) {
599
+ const fromTo = (state.remote.installedVersion && state.remote.remoteVersion)
600
+ ? `v${state.remote.installedVersion} → v${state.remote.remoteVersion}`
601
+ : 'update available';
584
602
  actions.push({
585
603
  key: 'update',
586
- label: `Pull ${state.remote.behind} commit(s) from upstream`,
587
- why: 'Remote framework is ahead of your local copy. Updating first keeps your contributions on a fresh base.',
604
+ label: `Update framework (${fromTo})`,
605
+ why: 'Remote framework version differs from local. Updating first keeps your contributions on a fresh base.',
588
606
  autoOk: true, // update has its own internal "Proceed?" prompt + pre-flight stash
589
607
  run: () => require('./update')(),
590
608
  });
@@ -596,7 +614,7 @@ function planActions(state) {
596
614
  label: 'Push local framework improvements upstream',
597
615
  why: state.workingTreeDirty
598
616
  ? `${state.workingTreeDirty} uncommitted file(s) in .framework/ — commit them through \`baldart push\`.`
599
- : `${state.commitsAhead} commit(s) on .framework/ not yet pushed.`,
617
+ : `${state.commitsAhead} commit(s) on .framework/ not yet pushed (may include subtree-merge noise; \`baldart push\` will classify).`,
600
618
  autoOk: false, // push writes to remote and asks for version classification — keep explicit intent
601
619
  run: () => require('./push')(),
602
620
  });
@@ -685,25 +703,39 @@ function renderDiagnostic(state) {
685
703
  }
686
704
 
687
705
  if (state.remote.fetched) {
688
- const behind = state.remote.behind || 0;
689
- console.log(statusLine(
690
- 'Remote',
691
- behind > 0 ? `${behind} commit(s) ahead — update available` : 'up to date',
692
- behind > 0 ? 'warn' : 'ok'
693
- ));
706
+ // v3.25.0+: messaging based on isAligned (version-compare), not on
707
+ // commit count which is structurally noisy on subtree pulls.
708
+ if (state.remote.isAligned) {
709
+ console.log(statusLine(
710
+ 'Remote',
711
+ `v${state.remote.remoteVersion} (aligned)`,
712
+ 'ok'
713
+ ));
714
+ } else if (state.remote.remoteVersion && state.remote.remoteVersion !== 'unknown') {
715
+ console.log(statusLine(
716
+ 'Remote',
717
+ `v${state.remote.remoteVersion} — update available (installed v${state.remote.installedVersion})`,
718
+ 'warn'
719
+ ));
720
+ } else {
721
+ console.log(statusLine('Remote', 'upstream VERSION unreadable', 'warn'));
722
+ }
694
723
  } else {
695
724
  console.log(statusLine('Remote', 'offline / not fetched', 'warn'));
696
725
  }
697
726
 
698
- console.log(statusLine(
699
- 'Local changes',
700
- state.workingTreeDirty > 0
701
- ? `${state.workingTreeDirty} file(s) in .framework/`
702
- : state.commitsAhead > 0
703
- ? `${state.commitsAhead} unpushed commit(s)`
704
- : 'clean',
705
- (state.workingTreeDirty + state.commitsAhead) > 0 ? 'warn' : 'ok'
706
- ));
727
+ // Local changes: working-tree dirty is real signal. Commit count is shown
728
+ // ONLY if not aligned (otherwise it's subtree-merge noise — see Fix 2 for
729
+ // the classifier that further filters this).
730
+ const showCommitsAhead = state.commitsAhead > 0 &&
731
+ state.remote.fetched && state.remote.isAligned === false;
732
+ const localStatusText = state.workingTreeDirty > 0
733
+ ? `${state.workingTreeDirty} file(s) in .framework/`
734
+ : showCommitsAhead
735
+ ? `${state.commitsAhead} unpushed commit(s)`
736
+ : 'clean';
737
+ const localSeverity = (state.workingTreeDirty > 0 || showCommitsAhead) ? 'warn' : 'ok';
738
+ console.log(statusLine('Local changes', localStatusText, localSeverity));
707
739
 
708
740
  console.log(statusLine(
709
741
  'Overlays',
@@ -3,6 +3,7 @@ const SymlinkUtils = require('../utils/symlinks');
3
3
  const UI = require('../utils/ui');
4
4
  const State = require('../utils/state');
5
5
  const Hooks = require('../utils/hooks');
6
+ const UpdateNotifier = require('../utils/update-notifier');
6
7
 
7
8
  function readEnabledTools(cwd = process.cwd()) {
8
9
  try {
@@ -108,6 +109,239 @@ async function postUpdateAutoCommit(git, newVersion, options) {
108
109
  }
109
110
  }
110
111
 
112
+ // --reset nuclear option (v3.26.0+).
113
+ //
114
+ // When the standard update keeps failing due to subtree divergence the user
115
+ // doesn't want to investigate, --reset wipes .framework/ and reinstalls
116
+ // cleanly. The user keeps every customization that lives OUTSIDE .framework/:
117
+ //
118
+ // - baldart.config.yml (real file, root of consumer)
119
+ // - .baldart/ (whole dir: overlays/, state.json, ...)
120
+ // - .claude/settings.json
121
+ // - .claude/{agents,skills,commands}/<*.md> ONLY if NOT a symlink into
122
+ // .framework/ (those are
123
+ // regenerated by `add`)
124
+ //
125
+ // Safety:
126
+ // - Refuses if the working tree is dirty (commit/stash first).
127
+ // - Refuses --yes unless --i-know is ALSO passed: --reset rm -rf's
128
+ // untracked & .gitignored files inside .framework/ that the user
129
+ // might not realize exist.
130
+ // - Always creates a backup tag before deleting anything.
131
+ async function runReset(git, options, autoYes) {
132
+ const fs = require('fs');
133
+ const path = require('path');
134
+ const { spawnSync } = require('child_process');
135
+
136
+ UI.newline();
137
+ UI.header('BALDART RESET (NUCLEAR OPTION)');
138
+ UI.warning('This will rm -rf .framework/ and reinstall from upstream.');
139
+ UI.info('What is preserved (NEVER touched):');
140
+ UI.list([
141
+ 'baldart.config.yml',
142
+ '.baldart/ (overlays, state.json, skill-conflicts.json, …)',
143
+ '.claude/settings.json',
144
+ '.claude/{agents,skills,commands}/*.md authored by you (non-framework symlinks)',
145
+ ], 'cyan');
146
+
147
+ // Safety gate #1: working tree MUST be clean.
148
+ const clean = await git.hasCleanWorkingTree();
149
+ if (!clean) {
150
+ UI.error('Working tree is dirty. Commit or `git stash -u` first, then re-run.');
151
+ UI.info('Reset cannot run with pending changes — too easy to lose work.');
152
+ process.exit(1);
153
+ }
154
+
155
+ // Safety gate #2: any untracked / ignored files inside .framework/?
156
+ let untrackedInsideFramework = '';
157
+ try {
158
+ untrackedInsideFramework = await git.git.raw([
159
+ 'ls-files', '--others', '--ignored', '--exclude-standard', '--', '.framework/'
160
+ ]).catch(() => '');
161
+ // Some git versions return non-zero when --ignored has no matches; treat as empty.
162
+ if (typeof untrackedInsideFramework !== 'string') untrackedInsideFramework = '';
163
+ } catch (_) { untrackedInsideFramework = ''; }
164
+ const ignoredLines = untrackedInsideFramework.split('\n').filter((l) => l.trim());
165
+
166
+ if (ignoredLines.length) {
167
+ UI.newline();
168
+ UI.warning(`${ignoredLines.length} untracked / ignored file(s) inside .framework/ will be DELETED:`);
169
+ UI.list(ignoredLines.slice(0, 10), 'yellow');
170
+ if (ignoredLines.length > 10) UI.info(`…and ${ignoredLines.length - 10} more.`);
171
+ if (autoYes && options.iKnow !== true) {
172
+ UI.error('--reset --yes refuses to delete untracked/ignored files without --i-know.');
173
+ UI.info('Run: npx baldart update --reset --yes --i-know');
174
+ process.exit(1);
175
+ }
176
+ if (!autoYes) {
177
+ const ok = await UI.confirm('Proceed and DELETE these files permanently?', false);
178
+ if (!ok) { UI.info('Reset aborted.'); process.exit(0); }
179
+ }
180
+ }
181
+
182
+ // Safety gate #3: backup tag before doing anything destructive.
183
+ const backupTag = await git.createBackupTag();
184
+ UI.success(`Backup tag created: ${backupTag}`);
185
+
186
+ // Snapshot user-owned files OUTSIDE .framework/ for restore-after-add.
187
+ // All these live OUTSIDE .framework/ so `rm -rf .framework/` doesn't touch
188
+ // them — but we record what was present so a post-install sanity check can
189
+ // confirm nothing got clobbered by the fresh install flow.
190
+ const preserved = {
191
+ baldartConfig: fs.existsSync('baldart.config.yml'),
192
+ baldartDir: fs.existsSync('.baldart'),
193
+ claudeSettings: fs.existsSync(path.join('.claude', 'settings.json')),
194
+ customAgents: snapshotCustomEntries('.claude/agents'),
195
+ customSkills: snapshotCustomEntries('.claude/skills'),
196
+ customCommands: snapshotCustomEntries('.claude/commands'),
197
+ };
198
+ UI.info(`Preserved snapshot: config=${preserved.baldartConfig}, .baldart=${preserved.baldartDir}, settings.json=${preserved.claudeSettings}, custom files: ${preserved.customAgents.length + preserved.customSkills.length + preserved.customCommands.length}`);
199
+
200
+ // Wipe .framework/. The `git rm` first ensures git's index reflects the
201
+ // deletion (otherwise a stray subtree commit might recreate refs); then
202
+ // rm -rf removes any untracked leftovers.
203
+ UI.newline();
204
+ UI.info('Removing .framework/…');
205
+ try {
206
+ spawnSync('git', ['rm', '-rf', '--ignore-unmatch', '.framework'], { stdio: 'pipe' });
207
+ } catch (_) { /* fall through to rm -rf */ }
208
+ try {
209
+ fs.rmSync('.framework', { recursive: true, force: true });
210
+ } catch (err) {
211
+ UI.error(`Could not remove .framework/: ${err.message}`);
212
+ UI.info(`Rollback with: git reset --hard ${backupTag}`);
213
+ process.exit(1);
214
+ }
215
+ UI.success('.framework/ removed.');
216
+
217
+ // Commit the deletion so `subtree add` has a clean tree to land on.
218
+ try {
219
+ await git.git.add(['-A']);
220
+ const idx = await git.git.status();
221
+ if (idx.staged.length || idx.modified.length || idx.deleted.length) {
222
+ await git.git.commit(`chore(baldart): reset .framework/ (pre-reinstall, backup ${backupTag})`);
223
+ UI.success('Committed reset.');
224
+ }
225
+ } catch (err) {
226
+ UI.warning(`Could not commit reset (${err.message}). Continuing — subtree add may still succeed.`);
227
+ }
228
+
229
+ // Hand off to `add` for the fresh install. `add` will:
230
+ // - `git subtree add --squash` → fresh subtree, no divergence history.
231
+ // - Reconcile symlinks (custom files preserved by existing collision logic).
232
+ // - Run configure if missing baldart.config.yml (we preserved it, so skip).
233
+ // - Register hooks (will see existing settings.json, idempotent).
234
+ UI.newline();
235
+ UI.info('Re-installing framework via `baldart add`…');
236
+ const addCmd = require('./add');
237
+ await addCmd({ yes: true });
238
+
239
+ // Post-restore sanity check — confirm nothing user-owned got clobbered.
240
+ const stillPresent = {
241
+ baldartConfig: fs.existsSync('baldart.config.yml'),
242
+ baldartDir: fs.existsSync('.baldart'),
243
+ claudeSettings: fs.existsSync(path.join('.claude', 'settings.json')),
244
+ };
245
+ const lost = Object.entries(stillPresent).filter(([k, v]) => preserved[k] && !v).map(([k]) => k);
246
+ if (lost.length) {
247
+ UI.error(`Reset DROPPED user-owned files: ${lost.join(', ')}.`);
248
+ UI.info(`Rollback IMMEDIATELY: git reset --hard ${backupTag}`);
249
+ process.exit(1);
250
+ }
251
+
252
+ UI.header('✓ RESET COMPLETE');
253
+ UI.box('RESET RESULT', [
254
+ `Framework: fresh install (${await git.getFrameworkVersion()})`,
255
+ `Backup tag: ${backupTag}`,
256
+ `Rollback if needed: git reset --hard ${backupTag}`,
257
+ '',
258
+ 'All user-owned files preserved.',
259
+ ]);
260
+ process.exit(0);
261
+ }
262
+
263
+ function snapshotCustomEntries(dir) {
264
+ const fs = require('fs');
265
+ const path = require('path');
266
+ if (!fs.existsSync(dir)) return [];
267
+ try {
268
+ return fs.readdirSync(dir)
269
+ .filter((name) => {
270
+ // Symlinks pointing into .framework/ are framework-owned (will be
271
+ // recreated by `add`). Real files / dirs are user-authored.
272
+ try {
273
+ const lst = fs.lstatSync(path.join(dir, name));
274
+ if (!lst.isSymbolicLink()) return true;
275
+ const target = fs.readlinkSync(path.join(dir, name));
276
+ return !target.includes('.framework/');
277
+ } catch (_) { return false; }
278
+ });
279
+ } catch (_) { return []; }
280
+ }
281
+
282
+ // CLI self-upgrade auto-relaunch (v3.25.0+).
283
+ // When the globally-installed `baldart` CLI is older than the latest on npm,
284
+ // transparently re-invoke `npx baldart@latest update <flags>` so the user
285
+ // gets the fix without having to remember `npm i -g baldart@latest`. The
286
+ // child inherits stdio (preserves prompts), and BALDART_RELAUNCHED=1 in env
287
+ // is a loop guard: if npm cache somehow serves an older version, the child
288
+ // sees the flag and skips its own relaunch check.
289
+ async function maybeRelaunchUnderLatest(options) {
290
+ if (process.env.BALDART_RELAUNCHED === '1') return false;
291
+ if (process.env.BALDART_NO_RELAUNCH === '1') return false;
292
+
293
+ const cliVersion = require('../../package.json').version;
294
+ let info = UpdateNotifier.hint({ currentVersion: cliVersion });
295
+ if (!info) {
296
+ // No cached hint — force a refresh so a brand-new `baldart update`
297
+ // invocation can still benefit. Best-effort, network-bounded.
298
+ try {
299
+ await UpdateNotifier.refresh({ currentVersion: cliVersion, timeoutMs: 3000, force: true });
300
+ info = UpdateNotifier.hint({ currentVersion: cliVersion });
301
+ } catch (_) { /* offline / blocked — fall through */ }
302
+ }
303
+ if (!info) return false;
304
+
305
+ const autoYes = options.yes === true;
306
+ let proceed = autoYes;
307
+ if (!autoYes) {
308
+ UI.newline();
309
+ UI.warning(`The installed CLI is older than npm latest: v${cliVersion} → v${info.latest}.`);
310
+ const choice = await UI.select('How would you like to proceed?', [
311
+ { name: `Auto-relaunch via npx baldart@${info.latest} (recommended)`, value: 'relaunch' },
312
+ { name: 'Proceed with the current CLI (you may hit fixed bugs)', value: 'current' },
313
+ { name: 'Abort and upgrade manually (`npm i -g baldart@latest`)', value: 'abort' },
314
+ ]);
315
+ if (choice === 'abort') {
316
+ UI.info(`Run: npm i -g baldart@latest && npx baldart update${options.yes ? ' --yes' : ''}`);
317
+ process.exit(0);
318
+ }
319
+ proceed = (choice === 'relaunch');
320
+ }
321
+ if (!proceed) return false;
322
+
323
+ // Reconstruct the original argv flags for the child.
324
+ const flags = [];
325
+ if (options.yes === true) flags.push('--yes');
326
+ if (options.autoStash === true && options.yes !== true) flags.push('--auto-stash');
327
+ if (options.commit === false) flags.push('--no-commit');
328
+ if (options.reset === true) flags.push('--reset');
329
+ if (options.iKnow === true) flags.push('--i-know');
330
+
331
+ const spawn = require('child_process').spawnSync;
332
+ UI.info(`Auto-relaunching via npx baldart@${info.latest}…`);
333
+ const res = spawn('npx', ['-y', `baldart@${info.latest}`, 'update', ...flags], {
334
+ stdio: 'inherit',
335
+ env: { ...process.env, BALDART_RELAUNCHED: '1' },
336
+ timeout: 600000, // mirror the longest legitimate flow downstream
337
+ });
338
+ if (res.error || res.status === null) {
339
+ UI.warning(`Auto-relaunch failed (${res.error ? res.error.message : 'no exit'}). Falling back to current CLI.`);
340
+ return false;
341
+ }
342
+ process.exit(res.status || 0);
343
+ }
344
+
111
345
  async function update(options = {}) {
112
346
  const git = new GitUtils();
113
347
  const symlinks = new SymlinkUtils();
@@ -137,6 +371,21 @@ async function update(options = {}) {
137
371
  process.exit(1);
138
372
  }
139
373
 
374
+ // v3.26.0+ NUCLEAR OPTION: --reset rips out .framework/ and reinstalls
375
+ // fresh, preserving everything user-owned that lives OUTSIDE .framework/.
376
+ // Branches the flow entirely — never proceeds to the standard subtree
377
+ // pull path.
378
+ if (options.reset === true) {
379
+ await runReset(git, options, autoYes);
380
+ return; // runReset exits the process on success
381
+ }
382
+
383
+ // v3.25.0+: CLI self-upgrade — if a newer baldart is on npm, transparently
384
+ // relaunch under `npx baldart@latest` so the user always runs the fixed
385
+ // code path. Loop guard via BALDART_RELAUNCHED env. No-op when already on
386
+ // latest or when the user opts out / network is down.
387
+ await maybeRelaunchUnderLatest(options);
388
+
140
389
  const currentVersion = await git.getFrameworkVersion();
141
390
  UI.success(`Current version: ${currentVersion}`);
142
391
 
@@ -168,8 +417,12 @@ async function update(options = {}) {
168
417
 
169
418
  const spinner = UI.spinner('Connecting to repository...').start();
170
419
 
420
+ // SSOT (v3.25.0+): one query gives version compare + commit counts +
421
+ // divergence class. Replaces hasChangesToPush() (which checked the wrong
422
+ // remote AND wrong direction — see git.js history for context).
423
+ let status;
171
424
  try {
172
- await git.fetch(repo);
425
+ status = await git.getUpdateStatus(repo);
173
426
  spinner.succeed('Connected to repository');
174
427
  } catch (error) {
175
428
  spinner.fail();
@@ -187,16 +440,74 @@ async function update(options = {}) {
187
440
  process.exit(1);
188
441
  }
189
442
 
190
- // Compare versions (simplified - in real scenario would fetch VERSION from remote)
191
- UI.info('Checking for updates...');
443
+ if (!status.fetched || !status.hasFetchHead) {
444
+ UI.error('Could not fetch upstream — try again with network access.');
445
+ process.exit(1);
446
+ }
192
447
 
193
- const hasChanges = await git.hasChangesToPush();
194
- if (!hasChanges) {
195
- UI.success('Already up to date!');
448
+ // VERSION-compare authority. Subtree-merge commit count is noise; never
449
+ // use behind > 0 as the up-to-date signal (root cause of v3.24.x bug).
450
+ if (status.isAligned) {
451
+ UI.success(`Already up to date! (v${status.installedVersion})`);
196
452
  process.exit(0);
197
453
  }
198
454
 
199
- UI.warning('Updates available!');
455
+ UI.warning(`Update available: v${status.installedVersion} → v${status.remoteVersion}`);
456
+
457
+ // Divergence handling: when consumer has commits on .framework/ that
458
+ // aren't upstream, classify before deciding. all-noise = subtree
459
+ // plumbing autogenerated → auto-resolve. overlay-able = user-customised
460
+ // a framework file → suggest /overlay. real-custom = prompt 3-way.
461
+ if (status.hasDivergence) {
462
+ UI.newline();
463
+ if (status.divergenceClass === 'all-noise') {
464
+ UI.info(`Detected ${status.divergenceCommits.length} subtree-merge / chore commit(s) (git plumbing noise — no real content drift). Auto-resolving…`);
465
+ } else if (status.divergenceClass === 'overlay-able') {
466
+ UI.warning('Detected custom edits to framework files inside `.framework/.claude/{agents,skills,commands}/`.');
467
+ UI.info('These are better expressed as overlays under `.baldart/overlays/` — they survive updates without divergence.');
468
+ for (const c of status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able')) {
469
+ UI.info(` • ${c.sha.slice(0, 7)} — ${c.subject}`);
470
+ }
471
+ if (autoYes) {
472
+ UI.error('Refusing to overwrite custom edits in --yes mode. Re-run without --yes to choose.');
473
+ UI.info('Recommended: use the `/overlay` skill to migrate these edits, then re-run update.');
474
+ process.exit(1);
475
+ }
476
+ const choice = await UI.select('How would you like to proceed?', [
477
+ { name: 'Migrate to overlay and pull anyway (recommended — overlay wins)', value: 'overlay' },
478
+ { name: 'Keep the custom commits (subtree pull may conflict)', value: 'keep' },
479
+ { name: 'Abort', value: 'abort' },
480
+ ]);
481
+ if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
482
+ if (choice === 'overlay') {
483
+ UI.info('Run `/overlay` from Claude Code to migrate the custom edits, then re-run `npx baldart update`.');
484
+ process.exit(0);
485
+ }
486
+ // 'keep' → fall through, subtree pull will attempt merge
487
+ } else if (status.divergenceClass === 'real-custom' || status.divergenceClass === 'mixed') {
488
+ UI.warning(`Detected ${status.divergenceCommits.filter((c) => c.category === 'custom-other').length} custom commit(s) on .framework/ that are NOT auto-classifiable as overlays.`);
489
+ for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper'].includes(c.category))) {
490
+ UI.info(` • ${c.sha.slice(0, 7)} [${c.category}] — ${c.subject}`);
491
+ }
492
+ if (autoYes) {
493
+ UI.error('Refusing to pull over custom commits in --yes mode. Re-run without --yes to choose.');
494
+ process.exit(1);
495
+ }
496
+ const choice = await UI.select('How would you like to proceed?', [
497
+ { name: 'Push these commits upstream first (recommended — `/baldart-push`)', value: 'push' },
498
+ { name: 'Pull anyway (will create a merge commit — possible conflicts)', value: 'pull' },
499
+ { name: 'Abort', value: 'abort' },
500
+ ]);
501
+ if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
502
+ if (choice === 'push') {
503
+ UI.info('Run `/baldart-push` from Claude Code (or `npx baldart push`) to contribute upstream, then re-run update.');
504
+ process.exit(0);
505
+ }
506
+ // 'pull' → fall through
507
+ }
508
+ // 'unknown' → conservative: continue silently, the subtree pull will
509
+ // surface any real conflict.
510
+ }
200
511
 
201
512
  // Step 3: Show changes preview
202
513
  UI.header('STEP 3/5: Preview Changes');
@@ -412,13 +723,23 @@ async function update(options = {}) {
412
723
  if (res.malformed) {
413
724
  UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
414
725
  } 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
726
+ // Aggregate output for missing-backfill (the common case after a major
727
+ // bump) to keep the noise floor low. Drift events stay per-line since
728
+ // they represent user-visible decisions.
729
+ const created = res.results.filter((r) => r.status === 'created').map((r) => r.id);
730
+ const legacy = res.results.filter((r) => r.status === 'legacy-migrated');
731
+ const drifts = res.results.filter((r) => r.status === 'drift-replaced' || r.status === 'drift-kept');
732
+ if (created.length === 1) {
733
+ UI.success(`Registered hook \`${created[0]}\` in .claude/settings.json.`);
734
+ } else if (created.length > 1) {
735
+ UI.success(`Auto-registered ${created.length} missing hook(s): ${created.join(', ')}.`);
736
+ }
737
+ for (const r of legacy) UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
738
+ for (const r of drifts) {
739
+ if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
740
+ else UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
421
741
  }
742
+ // 'already' is silent on update to avoid noise
422
743
  }
423
744
  } catch (err) {
424
745
  UI.warning(`Hook registration skipped: ${err.message}`);