baldart 3.24.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.
- package/CHANGELOG.md +99 -0
- package/VERSION +1 -1
- package/bin/baldart.js +14 -0
- package/framework/.claude/agents/coder.md +9 -4
- package/framework/.claude/skills/baldart-update/SKILL.md +110 -72
- package/framework/.claude/skills/new/SKILL.md +171 -9
- package/framework/.claude/skills/worktree-manager/SKILL.md +4 -0
- package/framework/agents/workflows.md +4 -0
- 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/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
|