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.
- package/CHANGELOG.md +95 -0
- package/VERSION +1 -1
- package/bin/baldart.js +14 -0
- package/framework/.claude/hooks/agent-discovery-info.sh +30 -8
- package/framework/.claude/hooks/framework-edit-gate.js +13 -7
- package/framework/.claude/skills/baldart-update/SKILL.md +123 -73
- package/package.json +1 -1
- package/src/commands/doctor.js +67 -35
- package/src/commands/update.js +334 -13
- package/src/commands/version.js +60 -41
- package/src/utils/__tests__/classify-divergence.test.js +120 -0
- package/src/utils/git.js +199 -0
- package/src/utils/overlay-merger.js +36 -12
- package/src/utils/symlinks.js +58 -11
package/src/commands/version.js
CHANGED
|
@@ -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
|
-
//
|
|
14
|
+
async function describeDrift(git, status) {
|
|
15
|
+
// Uncommitted local edits to .framework/ (working tree, NOT commits)
|
|
16
|
+
let uncommittedFiles = 0;
|
|
16
17
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
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
|
-
//
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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/${
|
|
84
|
-
]
|
|
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
|
|
53
|
-
* Used by `update` to know "is this file ours (safe to regenerate) or
|
|
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
|
|
60
|
-
if (
|
|
61
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
package/src/utils/symlinks.js
CHANGED
|
@@ -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
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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) {
|