baldart 3.25.0 → 3.26.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.
@@ -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}`);
@@ -11,28 +11,23 @@ function fmtDate(iso) {
11
11
  try { return iso.slice(0, 10); } catch (_) { return iso; }
12
12
  }
13
13
 
14
- async function describeDrift(git) {
15
- // Returns { ahead, behind, uncommittedFiles, hasFetchHead } or null on failure.
14
+ async function describeDrift(git, status) {
15
+ // Uncommitted local edits to .framework/ (working tree, NOT commits)
16
+ let uncommittedFiles = 0;
16
17
  try {
17
- let hasFetchHead = false;
18
- try {
19
- const out = await git.git.raw(['rev-list', '--left-right', '--count', 'HEAD...FETCH_HEAD']);
20
- const [a, b] = out.trim().split('\t').map(Number);
21
- hasFetchHead = !Number.isNaN(a) && !Number.isNaN(b);
22
- var ahead = a, behind = b;
23
- } catch (_) { /* no FETCH_HEAD — needs fetch first */ }
18
+ const dirty = await git.git.raw(['diff', '--name-only', 'HEAD', '--', FRAMEWORK_DIR]);
19
+ uncommittedFiles = dirty.trim() ? dirty.trim().split('\n').length : 0;
20
+ } catch (_) { /* repo without HEAD yet */ }
24
21
 
25
- // Uncommitted local edits to .framework/
26
- let uncommittedFiles = 0;
27
- try {
28
- const dirty = await git.git.raw(['diff', '--name-only', 'HEAD', '--', FRAMEWORK_DIR]);
29
- uncommittedFiles = dirty.trim() ? dirty.trim().split('\n').length : 0;
30
- } catch (_) { /* repo without HEAD yet */ }
31
-
32
- return { ahead, behind, uncommittedFiles, hasFetchHead };
33
- } catch (_) {
34
- return null;
35
- }
22
+ return {
23
+ isAligned: status.isAligned,
24
+ installedVersion: status.installedVersion,
25
+ remoteVersion: status.remoteVersion,
26
+ behind: status.commitCount.behind,
27
+ ahead: status.commitCount.ahead,
28
+ uncommittedFiles,
29
+ hasFetchHead: status.hasFetchHead,
30
+ };
36
31
  }
37
32
 
38
33
  async function version(opts = {}) {
@@ -50,29 +45,42 @@ async function version(opts = {}) {
50
45
  const state = State.load();
51
46
  const cliVersion = require('../../package.json').version;
52
47
  const cliUpdate = UpdateNotifier.hint({ currentVersion: cliVersion });
48
+ const verbose = opts.verbose === true;
53
49
 
54
- // Optionally fetch upstream to compute drift (default: yes).
55
- const offline = opts.offline === true;
56
- if (!offline) {
57
- try {
58
- await git.fetch(state.framework_repo || REPO_DEFAULT);
59
- } catch (_) { /* offline — drift report falls back */ }
50
+ // SSOT (v3.25.0+): one query, version-compare authority.
51
+ const repo = state.framework_repo || REPO_DEFAULT;
52
+ let status;
53
+ if (opts.offline === true) {
54
+ status = {
55
+ installedVersion: frameworkVersion,
56
+ remoteVersion: 'unknown',
57
+ isAligned: false,
58
+ commitCount: { behind: 0, ahead: 0, note: '' },
59
+ aheadScoped: 0, hasDivergence: false, hasFetchHead: false, fetched: false,
60
+ };
61
+ } else {
62
+ status = await git.getUpdateStatus(repo);
60
63
  }
61
- const drift = await describeDrift(git);
64
+ const drift = await describeDrift(git, status);
62
65
 
63
- UI.newline();
64
- UI.box('BALDART VERSION', [
66
+ // Messaging based on `isAligned` (not commit count, which is subtree noise).
67
+ let remoteLine;
68
+ if (!drift.hasFetchHead) {
69
+ remoteLine = 'Remote: (offline / not fetched)';
70
+ } else if (drift.isAligned) {
71
+ remoteLine = `Remote: v${drift.remoteVersion} (aligned)`;
72
+ } else if (drift.remoteVersion !== 'unknown') {
73
+ remoteLine = `Remote: v${drift.remoteVersion} (installed v${drift.installedVersion} — \`npx baldart update\`)`;
74
+ } else {
75
+ remoteLine = 'Remote: (upstream VERSION unreadable)';
76
+ }
77
+
78
+ const lines = [
65
79
  `Installed: v${frameworkVersion} ${state.install_date ? `(since ${fmtDate(state.install_date)})` : ''}`.trim(),
66
- drift && drift.hasFetchHead
67
- ? (drift.behind > 0
68
- ? `Remote: ${drift.behind} commit(s) ahead — pull with \`npx baldart update\``
69
- : 'Remote: up to date')
70
- : 'Remote: (offline / not fetched)',
71
- drift
72
- ? (drift.uncommittedFiles > 0
73
- ? `Local: v${frameworkVersion} + ${drift.uncommittedFiles} uncommitted file(s) in .framework/`
74
- : `Local: v${frameworkVersion} (clean)`)
75
- : '',
80
+ remoteLine,
81
+ drift.uncommittedFiles > 0
82
+ ? `Local: v${frameworkVersion} + ${drift.uncommittedFiles} uncommitted file(s) in .framework/`
83
+ : `Local: v${frameworkVersion} (clean)`,
76
84
  '',
77
85
  `Last update: ${fmtDate(state.last_update_date)}`,
78
86
  `Last push: v${state.last_pushed_version || '—'} ${state.last_push_date ? `(${fmtDate(state.last_push_date)})` : ''}`.trim(),
@@ -80,8 +88,19 @@ async function version(opts = {}) {
80
88
  cliUpdate
81
89
  ? `CLI: v${cliVersion} → v${cliUpdate.latest} available (npm i -g baldart@latest)`
82
90
  : `CLI: v${cliVersion}`,
83
- `Repository: https://github.com/${state.framework_repo || REPO_DEFAULT}`,
84
- ].filter((l) => l !== ''));
91
+ `Repository: https://github.com/${repo}`,
92
+ ];
93
+
94
+ if (verbose && drift.hasFetchHead) {
95
+ lines.push('');
96
+ lines.push('Diagnostics (subtree commit history — includes auto-generated merges):');
97
+ lines.push(` commits ahead: ${drift.ahead}`);
98
+ lines.push(` commits behind: ${drift.behind}`);
99
+ lines.push(' (these counts grow with every `git subtree pull --squash`; ignore unless investigating)');
100
+ }
101
+
102
+ UI.newline();
103
+ UI.box('BALDART VERSION', lines.filter((l) => l !== ''));
85
104
  UI.newline();
86
105
  } catch (error) {
87
106
  UI.error(`Failed to get version: ${error.message}`);
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fixture-driven test for classifyDivergence pattern matching.
4
+ *
5
+ * Run: node src/utils/__tests__/classify-divergence.test.js
6
+ * Exits 0 on success, 1 on any mismatch (CI-friendly).
7
+ *
8
+ * Fixtures cover the exact commit subjects observed during the
9
+ * `mayo` consumer debug (v3.25.0 release). Adversarial-review enforced.
10
+ */
11
+ const GitUtils = require('../git');
12
+ const g = new GitUtils();
13
+
14
+ const SUBJECT_FIXTURES = [
15
+ // ─── subtree-merge (branch-agnostic) ─────────────────────────────────
16
+ { subject: "Merge commit 'abc123' into develop", expected: 'subtree-merge' },
17
+ { subject: "Merge commit 'abc123' into main", expected: 'subtree-merge' },
18
+ { subject: "Merge commit 'deadbeef' into feature/x", expected: 'subtree-merge' },
19
+ { subject: "Merge branch 'feature-x' into main", expected: 'subtree-merge' },
20
+
21
+ // ─── subtree-squash ──────────────────────────────────────────────────
22
+ { subject: "Squashed '.framework/' changes from abc..def", expected: 'subtree-squash' },
23
+ { subject: "Squashed '.framework/' content from 1234..5678", expected: 'subtree-squash' },
24
+
25
+ // ─── chore-wrapper (real commits from mayo) ──────────────────────────
26
+ { subject: '[CHORE] reconcile baldart state ledger v3.22.1 -> v3.25.0', expected: 'chore-wrapper' },
27
+ { subject: '[CHORE] register baldart agent-discovery hooks', expected: 'chore-wrapper' },
28
+ { subject: 'chore(baldart): post-update reconcile to v3.25.0', expected: 'chore-wrapper' },
29
+ { subject: 'chore: bump baldart version', expected: 'chore-wrapper' },
30
+ { subject: '[CHORE] baldart update → v3.12.0', expected: 'chore-wrapper' },
31
+
32
+ // ─── non-matches (should return null → fall through to path classifier) ──
33
+ { subject: 'feat: add new login screen', expected: null },
34
+ { subject: 'fix: ui-expert agent for mayo design system', expected: null },
35
+ { subject: 'docs: update README', expected: null },
36
+
37
+ // ─── adversarial false-positive guards ───────────────────────────────
38
+ // "Merge pull request" is NOT a subtree merge — must NOT match
39
+ { subject: 'Merge pull request #42 from feature/x', expected: null },
40
+ // "Squashed" without `.framework/` path — NOT subtree-squash
41
+ { subject: "Squashed 'docs/' content from abc..def", expected: null },
42
+ // Chore without 'baldart' keyword — NOT chore-wrapper
43
+ { subject: 'chore: update dependencies', expected: null },
44
+ ];
45
+
46
+ const PATH_FIXTURES = [
47
+ {
48
+ name: 'all touched in .framework/.claude/agents → overlay-able',
49
+ touched: ['.framework/framework/.claude/agents/ui-expert.md'],
50
+ expected: 'custom-overlay-able',
51
+ },
52
+ {
53
+ name: 'all touched in .framework/.claude/skills → overlay-able',
54
+ touched: [
55
+ '.framework/framework/.claude/skills/ui-design/SKILL.md',
56
+ '.framework/framework/.claude/skills/ui-design/scripts/foo.sh',
57
+ ],
58
+ expected: 'custom-overlay-able',
59
+ },
60
+ {
61
+ name: 'mixed agents + skills → still overlay-able',
62
+ touched: [
63
+ '.framework/framework/.claude/agents/ui-expert.md',
64
+ '.framework/framework/.claude/commands/check.md',
65
+ ],
66
+ expected: 'custom-overlay-able',
67
+ },
68
+ {
69
+ name: 'touches .framework/src → custom-other',
70
+ touched: ['.framework/src/utils/git.js'],
71
+ expected: 'custom-other',
72
+ },
73
+ {
74
+ name: 'mix overlay-able + src → custom-other',
75
+ touched: [
76
+ '.framework/framework/.claude/agents/ui-expert.md',
77
+ '.framework/src/utils/git.js',
78
+ ],
79
+ expected: 'custom-other',
80
+ },
81
+ {
82
+ name: 'empty paths → custom-other (conservative default)',
83
+ touched: [],
84
+ expected: 'custom-other',
85
+ },
86
+ {
87
+ name: 'touches CHANGELOG → custom-other',
88
+ touched: ['.framework/CHANGELOG.md'],
89
+ expected: 'custom-other',
90
+ },
91
+ ];
92
+
93
+ let pass = 0, fail = 0;
94
+
95
+ console.log('─── Subject classifier ───');
96
+ for (const f of SUBJECT_FIXTURES) {
97
+ const actual = g.classifyCommitSubject(f.subject);
98
+ if (actual === f.expected) {
99
+ pass++;
100
+ console.log(` ✓ "${f.subject}" → ${actual}`);
101
+ } else {
102
+ fail++;
103
+ console.log(` ✗ "${f.subject}" → ${actual} (expected ${f.expected})`);
104
+ }
105
+ }
106
+
107
+ console.log('\n─── Path classifier ───');
108
+ for (const f of PATH_FIXTURES) {
109
+ const actual = g.classifyCommitByPaths(f.touched);
110
+ if (actual === f.expected) {
111
+ pass++;
112
+ console.log(` ✓ ${f.name} → ${actual}`);
113
+ } else {
114
+ fail++;
115
+ console.log(` ✗ ${f.name} → ${actual} (expected ${f.expected})`);
116
+ }
117
+ }
118
+
119
+ console.log(`\nResult: ${pass} pass, ${fail} fail`);
120
+ process.exit(fail > 0 ? 1 : 0);