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.
@@ -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);
package/src/utils/git.js CHANGED
@@ -177,6 +177,205 @@ class GitUtils {
177
177
  }
178
178
  }
179
179
 
180
+ // ────────────────────────────────────────────────────────────────────
181
+ // SSOT update-status check (v3.25.0+).
182
+ //
183
+ // Before v3.25.0 three commands answered the same question — "is this
184
+ // consumer up-to-date with upstream BALDART?" — with three different
185
+ // queries, none of them right:
186
+ //
187
+ // - `update.js` used `hasChangesToPush()` (origin/main..HEAD — wrong
188
+ // remote AND wrong direction).
189
+ // - `version.js` and `doctor.js` used `HEAD...FETCH_HEAD` full-repo
190
+ // symmetric diff, which counts every subtree-merge commit ever
191
+ // generated by `git subtree pull --squash`. That count never
192
+ // decreases (squash merges are never "equal" on both sides), so
193
+ // even a perfectly-synced consumer is shown "N commits ahead".
194
+ //
195
+ // The authority for "aligned or not" is the VERSION file. The upstream
196
+ // BALDART repo has `VERSION` at root, so `git show FETCH_HEAD:VERSION`
197
+ // gives the remote version directly. Local `.framework/VERSION` gives
198
+ // the installed version. Identical strings → aligned, full stop.
199
+ //
200
+ // The commit count remains in the return value for diagnostic display
201
+ // (behind `--verbose` flags), explicitly labeled as subtree-merge noise
202
+ // so users don't misread it as a problem.
203
+ // ────────────────────────────────────────────────────────────────────
204
+ // ────────────────────────────────────────────────────────────────────
205
+ // Divergence classifier (v3.25.0+).
206
+ //
207
+ // When `aheadScoped > 0` (consumer has commits on `.framework/` not in
208
+ // upstream), we need to know WHETHER those commits are git plumbing
209
+ // noise (auto-resolve OK) or real user content (must prompt before
210
+ // anything destructive).
211
+ //
212
+ // Categories (in matching order — first match wins):
213
+ // - subtree-merge: `git subtree pull --squash` merge commits
214
+ // - subtree-squash: the squash payload commits themselves
215
+ // - chore-wrapper: CLI-generated [CHORE]/chore(baldart): commits
216
+ // - custom-overlay-able: user edited a framework agent/skill/command
217
+ // → should migrate to .baldart/overlays/
218
+ // - custom-other: anything else (src/, CHANGELOG, ad-hoc fixes)
219
+ //
220
+ // Aggregate `class`:
221
+ // - all-noise: every commit is subtree-* / chore-wrapper → auto-resolve
222
+ // - overlay-able: every non-noise commit is overlay-able → suggest /overlay
223
+ // - real-custom: every non-noise commit is custom-other → prompt 3-way
224
+ // - mixed: non-noise commits span both → prompt 3-way
225
+ //
226
+ // Default-conservative: any commit whose subject + touched-paths don't
227
+ // match a known pattern is classified `custom-other`, never auto-resolved.
228
+ // ────────────────────────────────────────────────────────────────────
229
+ classifyCommitSubject(subject) {
230
+ if (/^Merge (commit '[a-f0-9]+'|branch '[^']+') into \S+$/.test(subject)) {
231
+ return 'subtree-merge';
232
+ }
233
+ if (/^Squashed '\.framework\/' (changes from|content from)/.test(subject)) {
234
+ return 'subtree-squash';
235
+ }
236
+ // Two separate conditions: must look like a chore commit AND mention
237
+ // baldart anywhere (subject or scope). `chore(baldart): foo` is a
238
+ // common case — the keyword is in the scope, not after the colon.
239
+ const isChorePrefix = /^(\[CHORE\]|chore(\([^)]*\))?:)/.test(subject);
240
+ const hasBaldartKeyword = /baldart/i.test(subject);
241
+ if (isChorePrefix && hasBaldartKeyword) {
242
+ return 'chore-wrapper';
243
+ }
244
+ return null;
245
+ }
246
+
247
+ classifyCommitByPaths(touched) {
248
+ if (!touched.length) return 'custom-other';
249
+ const overlayablePrefix = '.framework/framework/.claude/';
250
+ const overlayableDirs = ['agents/', 'skills/', 'commands/'];
251
+ const allOverlayable = touched.every((p) => {
252
+ if (!p.startsWith(overlayablePrefix)) return false;
253
+ const rest = p.slice(overlayablePrefix.length);
254
+ return overlayableDirs.some((d) => rest.startsWith(d));
255
+ });
256
+ return allOverlayable ? 'custom-overlay-able' : 'custom-other';
257
+ }
258
+
259
+ async classifyDivergence() {
260
+ // Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
261
+ const commits = [];
262
+ try {
263
+ const raw = await this.git.raw([
264
+ 'log',
265
+ 'FETCH_HEAD..HEAD',
266
+ '--pretty=format:%H%x00%s',
267
+ '--',
268
+ FRAMEWORK_DIR
269
+ ]);
270
+ for (const line of raw.split('\n')) {
271
+ if (!line.trim()) continue;
272
+ const [sha, ...rest] = line.split('');
273
+ const subject = rest.join('');
274
+ let category = this.classifyCommitSubject(subject);
275
+ if (!category) {
276
+ // Need file list to disambiguate custom-overlay-able vs custom-other.
277
+ let touched = [];
278
+ try {
279
+ const filesRaw = await this.git.raw(['diff-tree', '--no-commit-id', '--name-only', '-r', sha]);
280
+ touched = filesRaw.split('\n').filter((l) => l.trim());
281
+ } catch (_) { /* leave empty → custom-other */ }
282
+ category = this.classifyCommitByPaths(touched);
283
+ }
284
+ commits.push({ sha, subject, category });
285
+ }
286
+ } catch (_) {
287
+ return { class: 'unknown', commits: [] };
288
+ }
289
+
290
+ if (commits.length === 0) return { class: 'all-noise', commits: [] };
291
+
292
+ const noiseCategories = new Set(['subtree-merge', 'subtree-squash', 'chore-wrapper']);
293
+ const nonNoise = commits.filter((c) => !noiseCategories.has(c.category));
294
+ if (nonNoise.length === 0) return { class: 'all-noise', commits };
295
+
296
+ const hasOverlayable = nonNoise.some((c) => c.category === 'custom-overlay-able');
297
+ const hasOther = nonNoise.some((c) => c.category === 'custom-other');
298
+ let aggregate = 'real-custom';
299
+ if (hasOverlayable && !hasOther) aggregate = 'overlay-able';
300
+ else if (hasOverlayable && hasOther) aggregate = 'mixed';
301
+
302
+ return { class: aggregate, commits };
303
+ }
304
+
305
+ async getUpdateStatus(repo, branch = 'main') {
306
+ const result = {
307
+ installedVersion: 'unknown',
308
+ remoteVersion: 'unknown',
309
+ isAligned: false,
310
+ commitCount: {
311
+ behind: 0,
312
+ ahead: 0,
313
+ note: 'Includes subtree-merge commits autogenerated by `git subtree pull --squash`. Does not reflect real content drift; use `isAligned` for update status.'
314
+ },
315
+ aheadScoped: 0,
316
+ hasDivergence: false,
317
+ hasFetchHead: false,
318
+ fetched: false,
319
+ };
320
+
321
+ try {
322
+ result.installedVersion = await this.getFrameworkVersion();
323
+ } catch (_) { /* leave 'unknown' */ }
324
+
325
+ try {
326
+ await this.fetch(repo, branch);
327
+ result.fetched = true;
328
+ } catch (_) {
329
+ // Offline — leave fetched=false, fall through with best-effort.
330
+ }
331
+
332
+ try {
333
+ const head = await this.git.raw(['rev-parse', 'FETCH_HEAD']);
334
+ result.hasFetchHead = head.trim().length > 0;
335
+ } catch (_) { /* no FETCH_HEAD */ }
336
+
337
+ if (result.hasFetchHead) {
338
+ try {
339
+ const v = await this.git.raw(['show', 'FETCH_HEAD:VERSION']);
340
+ result.remoteVersion = v.trim();
341
+ } catch (_) { /* upstream missing VERSION — leave 'unknown' */ }
342
+
343
+ try {
344
+ const out = await this.git.raw(['rev-list', '--left-right', '--count', 'HEAD...FETCH_HEAD']);
345
+ const [ahead, behind] = out.trim().split(/\s+/).map(Number);
346
+ if (!Number.isNaN(ahead)) result.commitCount.ahead = ahead;
347
+ if (!Number.isNaN(behind)) result.commitCount.behind = behind;
348
+ } catch (_) { /* leave 0/0 */ }
349
+
350
+ try {
351
+ const out = await this.git.raw(['rev-list', '--count', 'FETCH_HEAD..HEAD', '--', FRAMEWORK_DIR]);
352
+ result.aheadScoped = Number(out.trim()) || 0;
353
+ } catch (_) { /* leave 0 */ }
354
+ }
355
+
356
+ if (result.installedVersion !== 'unknown' && result.remoteVersion !== 'unknown') {
357
+ result.isAligned = result.installedVersion === result.remoteVersion;
358
+ }
359
+
360
+ result.hasDivergence = !result.isAligned && result.aheadScoped > 0;
361
+
362
+ if (result.hasDivergence) {
363
+ try {
364
+ const d = await this.classifyDivergence();
365
+ result.divergenceClass = d.class;
366
+ result.divergenceCommits = d.commits;
367
+ } catch (_) {
368
+ result.divergenceClass = 'unknown';
369
+ result.divergenceCommits = [];
370
+ }
371
+ } else {
372
+ result.divergenceClass = 'none';
373
+ result.divergenceCommits = [];
374
+ }
375
+
376
+ return result;
377
+ }
378
+
180
379
  // ────────────────────────────────────────────────────────────────────
181
380
  // `.framework/` is a git subtree — it MUST be tracked. If anything in
182
381
  // the gitignore chain (project .gitignore, .git/info/exclude, global
@@ -49,20 +49,38 @@ function buildMarker({ kind, name, baseVersion, baseSha, overlaySha }) {
49
49
  }
50
50
 
51
51
  /**
52
- * Returns the marker fields encoded in the first line if present, or null.
53
- * Used by `update` to know "is this file ours (safe to regenerate) or did the
54
- * user edit it manually?"
52
+ * Returns the marker fields encoded in the generated-marker block if present,
53
+ * or null. Used by `update` to know "is this file ours (safe to regenerate) or
54
+ * did the user edit it manually?"
55
+ *
56
+ * The marker block can appear in two positions:
57
+ * - line 1 (pre-v3.26.1 generated files — kept for back-compat reading)
58
+ * - immediately after the closing `---` of the YAML frontmatter (v3.26.1+)
55
59
  *
56
60
  * `base_sha` is optional — older generated files (pre-v3.19.0) won't have it.
57
61
  */
58
62
  function readMarker(content) {
59
- const firstLine = content.split('\n').slice(0, 3).join('\n');
60
- if (!firstLine.includes(MARKER_PREFIX)) return null;
61
- const m = firstLine.match(/kind=(\S+) name=(\S+) base_version=(\S+)(?: base_sha=(\S+))? overlay_sha=(\S+)/);
63
+ const idx = content.indexOf(MARKER_PREFIX);
64
+ if (idx === -1) return null;
65
+ // The marker block ends at the next `-->`. Cap the search to the first 4KB
66
+ // to avoid scanning huge files when the marker isn't actually there.
67
+ const block = content.slice(idx, idx + 4000);
68
+ const end = block.indexOf('-->');
69
+ if (end === -1) return null;
70
+ const m = block.slice(0, end).match(/kind=(\S+)\s+name=(\S+)\s+base_version=(\S+)(?:\s+base_sha=(\S+))?\s+overlay_sha=(\S+)/);
62
71
  if (!m) return null;
63
72
  return { kind: m[1], name: m[2], baseVersion: m[3], baseSha: m[4] || null, overlaySha: m[5] };
64
73
  }
65
74
 
75
+ /**
76
+ * Tells whether `content` is a BALDART-generated file (agent or command).
77
+ * Position-agnostic — accepts both pre-v3.26.1 (marker on line 1) and v3.26.1+
78
+ * (marker after the frontmatter) layouts.
79
+ */
80
+ function isGeneratedFile(content) {
81
+ return readMarker(content) !== null;
82
+ }
83
+
66
84
  /**
67
85
  * Split markdown text into:
68
86
  * - frontmatter: the raw text between leading `---` fences (or '')
@@ -193,12 +211,17 @@ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersio
193
211
  ? `---\n${baseParsed.frontmatter}\n---\n`
194
212
  : '';
195
213
 
196
- const generated =
197
- `${marker}\n` +
198
- fmBlock +
199
- (baseParsed.preamble || '') +
200
- sectionsOut +
201
- trailingOut;
214
+ // v3.26.1+ layout: frontmatter MUST be the first thing in the file —
215
+ // Claude Code's agent discovery uses a strict YAML-frontmatter parser that
216
+ // skips files where `---` is not on line 1. Pre-v3.26.1 placed the marker
217
+ // first, which silently de-registered every overlay-merged agent (no
218
+ // `subagent_type` exposed → spawns failed with "agent not found"). The
219
+ // marker now sits as a CommonMark HTML comment immediately after the
220
+ // closing `---`, which `readMarker` still finds via position-agnostic
221
+ // scan. If the base has no frontmatter (unusual), marker still goes first.
222
+ const generated = fmBlock
223
+ ? fmBlock + `${marker}\n` + (baseParsed.preamble || '') + sectionsOut + trailingOut
224
+ : `${marker}\n` + (baseParsed.preamble || '') + sectionsOut + trailingOut;
202
225
 
203
226
  return {
204
227
  generated,
@@ -212,6 +235,7 @@ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersio
212
235
  module.exports = {
213
236
  mergeOverlay,
214
237
  readMarker,
238
+ isGeneratedFile,
215
239
  parseMarkdown,
216
240
  shortSha,
217
241
  computeBaseFileSha,
@@ -423,6 +423,20 @@ class SymlinkUtils {
423
423
  aggregate.skipped.push({ tool: adapter.name, kind, name: baseName, reason: 'already-linked' });
424
424
  return;
425
425
  }
426
+ // Symlink pointing into .baldart/generated/ → BALDART-managed from a
427
+ // previous overlay state. Overlay has just been removed, so revert to
428
+ // the framework-base symlink and clean up the now-stale generated file.
429
+ if (current.split(path.sep).includes('generated') && current.includes('.baldart')) {
430
+ fs.unlinkSync(linkPath);
431
+ fs.symlinkSync(target, linkPath);
432
+ try {
433
+ const staleGenerated = path.join(this.cwd, '.baldart', 'generated', `${kind}s`, filename);
434
+ if (fs.existsSync(staleGenerated)) fs.unlinkSync(staleGenerated);
435
+ } catch (_) { /* best effort */ }
436
+ UI.info(`[${adapter.label}] ${kind} overlay removed — relinking ${path.join(targetRel, filename)} → framework base`);
437
+ aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
438
+ return;
439
+ }
426
440
  // Broken or pointing elsewhere — replace if broken, else respect user override
427
441
  if (!fs.existsSync(linkPath)) {
428
442
  fs.unlinkSync(linkPath);
@@ -438,6 +452,10 @@ class SymlinkUtils {
438
452
  // Real file. Could be:
439
453
  // (a) A baldart-generated file from a previous run where overlay USED to
440
454
  // exist but now doesn't — safe to replace with symlink.
455
+ // Pre-v3.27.0 layout: marker file directly at linkPath. v3.27.0+
456
+ // stores the merged file under .baldart/generated/ and points a
457
+ // symlink at it, so the "real file at linkPath" case here is the
458
+ // legacy layout that no longer regenerates — just unlink and resymlink.
441
459
  // (b) A user-authored fork — leave alone.
442
460
  const { readMarker } = require('./overlay-merger');
443
461
  const content = fs.readFileSync(linkPath, 'utf8');
@@ -447,6 +465,12 @@ class SymlinkUtils {
447
465
  UI.info(`[${adapter.label}] ${kind} overlay removed — replacing generated file with symlink: ${path.join(targetRel, filename)}`);
448
466
  fs.unlinkSync(linkPath);
449
467
  fs.symlinkSync(target, linkPath);
468
+ // Also clean up any stale .baldart/generated/<kind>s/<name>.md from
469
+ // the v3.27.0+ indirect layout (no overlay means no generated file).
470
+ try {
471
+ const staleGenerated = path.join(this.cwd, '.baldart', 'generated', `${kind}s`, filename);
472
+ if (fs.existsSync(staleGenerated)) fs.unlinkSync(staleGenerated);
473
+ } catch (_) { /* best effort */ }
450
474
  aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
451
475
  return;
452
476
  }
@@ -460,7 +484,7 @@ class SymlinkUtils {
460
484
  }
461
485
 
462
486
  _generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
463
- const { mergeOverlay, readMarker } = require('./overlay-merger');
487
+ const { mergeOverlay, readMarker, isGeneratedFile } = require('./overlay-merger');
464
488
  const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
465
489
  const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
466
490
  const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });
@@ -472,13 +496,28 @@ class SymlinkUtils {
472
496
  });
473
497
  }
474
498
 
475
- // If a previously generated file is on disk, check ownership via marker
476
- // before overwriting. If it lacks the marker (= user edited manually after
477
- // a previous generation, or a pre-3.8 fork), refuse to overwrite.
499
+ // v3.27.0+ layout: the merged content lives at
500
+ // .baldart/generated/<kind>s/<name>.md (real file)
501
+ // and the consumer-visible path is a symlink:
502
+ // .claude/agents/<name>.md → ../../.baldart/generated/agents/<name>.md
503
+ // This is a workaround for Claude Code upstream bug #20931, where the
504
+ // session-start scanner that builds the `subagent_type` registry skips
505
+ // regular files in .claude/agents/ (only symlinks are picked up). The
506
+ // pre-v3.27.0 layout wrote the merged file directly at linkPath as a
507
+ // real file, which made every overlay-merged agent silently invisible
508
+ // to the orchestrator — see CHANGELOG 3.27.0 root cause analysis.
509
+ const generatedDir = path.join(this.cwd, '.baldart', 'generated', `${kind}s`);
510
+ const generatedPath = path.join(generatedDir, `${name}.md`);
511
+ fs.mkdirSync(generatedDir, { recursive: true });
512
+
513
+ // Inspect what currently sits at linkPath. Three legal states:
514
+ // (1) symlink we already manage (resolves into .baldart/generated/) → stale, replace
515
+ // (2) symlink to the framework base (no overlay last time) → upgrade to indirect symlink
516
+ // (3) regular file with baldart-generated marker (legacy pre-v3.27.0 layout) → migrate
517
+ // (4) regular file without marker → user fork, refuse to touch
478
518
  if (lstat && !lstat.isSymbolicLink()) {
479
519
  const existing = fs.readFileSync(linkPath, 'utf8');
480
- const m = readMarker(existing);
481
- if (!m) {
520
+ if (!isGeneratedFile(existing)) {
482
521
  UI.warning(`[${adapter.label}] ${kind} ${name}: existing file at ${path.join(targetRel, name)}.md has no baldart-generated marker — NOT overwriting (user fork?). Move it aside (or merge it into the overlay) and re-run update.`);
483
522
  aggregate.conflicts.push({
484
523
  tool: adapter.name, kind, name,
@@ -488,15 +527,23 @@ class SymlinkUtils {
488
527
  });
489
528
  return;
490
529
  }
530
+ // Legacy generated file at linkPath — migrate (delete in-place, rewrite at generatedPath, symlink).
491
531
  }
492
532
 
493
- // Replace any existing symlink/file with the freshly generated content.
533
+ // Write merged content to the indirect location.
534
+ fs.writeFileSync(generatedPath, merged.generated);
535
+
536
+ // Compute the relative symlink target (portable across moves of the
537
+ // project root).
538
+ const relativeTarget = path.relative(path.dirname(linkPath), generatedPath);
539
+
540
+ // Replace whatever currently lives at linkPath with the new symlink.
494
541
  if (lstat) {
495
542
  try { fs.unlinkSync(linkPath); }
496
543
  catch (_) { try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch (_) { /* noop */ } }
497
544
  }
498
- fs.writeFileSync(linkPath, merged.generated);
499
- UI.success(`[${adapter.label}] ${kind} generated (overlay applied): ${path.join(targetRel, name)}.md`);
545
+ fs.symlinkSync(relativeTarget, linkPath);
546
+ UI.success(`[${adapter.label}] ${kind} generated (overlay applied): ${path.join(targetRel, name)}.md → ${relativeTarget}`);
500
547
  aggregate.generated.push({ tool: adapter.name, kind, name, overlaySha: merged.overlaySha });
501
548
  }
502
549
 
@@ -598,13 +645,13 @@ class SymlinkUtils {
598
645
  UI.warning(`[${adapter.label}] Legacy bulk symlink at ${rel}. Run \`npx baldart update\` to convert to per-item layout.`);
599
646
  allValid = false;
600
647
  } else {
648
+ const { isGeneratedFile } = require('./overlay-merger');
601
649
  const entries = fs.readdirSync(full);
602
650
  const frameworkOwned = entries.filter((n) => {
603
651
  const p = path.join(full, n);
604
652
  try {
605
653
  if (fs.lstatSync(p).isSymbolicLink()) return true;
606
- const content = fs.readFileSync(p, 'utf8');
607
- return content.startsWith('<!-- baldart-generated:');
654
+ return isGeneratedFile(fs.readFileSync(p, 'utf8'));
608
655
  } catch { return false; }
609
656
  });
610
657
  if (frameworkOwned.length === 0) {