baldart 4.16.1 → 4.16.2

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 CHANGED
@@ -5,6 +5,18 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.16.2] - 2026-06-06
9
+
10
+ **`baldart update` non si blocca più all'infinito su un edit `.framework/` ormai assorbito a monte.** Un edit locale a un file framework (es. `agents/ui-expert.md`) committato con un subject che *non* contiene la parola "baldart" sfuggiva alla classificazione `chore-wrapper` (rumore) e veniva marcato `custom-overlay-able`. Quel commit resta per sempre ancestor di `HEAD` ma mai di `FETCH_HEAD`, quindi `git log FETCH_HEAD..HEAD -- .framework` lo ripesca a **ogni rilascio successivo** — e il flusso seamless si fermava sul gate `divergence-capture-incomplete` perché l'overlay (additivo) non riproduceva byte-a-byte il file `.framework`, anche quando il contenuto era già identico all'upstream e la personalizzazione viveva correttamente nell'overlay. Risultato: lo stesso commit "fantasma" rifaceva muro a ogni `update`, senza via d'uscita via overlay (riconciliare un overlay additivo non può mai far passare il round-trip stretto). **PATCH** (bugfix del detector di divergenza + reset-safety degli overlay additivi; nessun cambio di capability, nessuna chiave `baldart.config.yml`).
11
+
12
+ > **Why.** Il check di fedeltà capture+verify pretendeva `merge(base_upstream, overlay) == file_.framework`. Per un overlay `extend` (additivo) questo è **strutturalmente falso** quando `.framework` è pristine-upstream: il merge aggiunge sezioni, il file no → "stale" eterno. E il classifier non distingueva un edit *vivo* (da preservare) da uno *assorbito* (contenuto già a monte → un re-sync non perde nulla). Due correzioni complementari chiudono la classe di falso positivo per tutti i consumer.
13
+
14
+ ### Fixed
15
+
16
+ - **`src/utils/git.js` — categoria `absorbed` nel classifier di divergenza.** `classifyDivergence` ora demota a rumore (`absorbed`) ogni commit `custom-*` i cui file `.framework/` toccati sono **byte-identici all'upstream (`FETCH_HEAD`)** — confronto di blob-SHA, prefisso `.framework/` strippato per mappare il path upstream; conservativo (file fuori da `.framework/`, mancante a monte, o SHA diverso → resta drift reale). `aggregateDivergenceClass` include `absorbed` tra le `noiseCategories`, così un edit ormai assorbito non spinge più la classe verso `overlay-able`/`mixed`: la batch torna `all-noise` → auto-resolve pulito.
17
+ - **`src/utils/overlay-capture.js` — reset-safety per overlay additivi (`captureAndVerify`).** Quando esiste già un overlay agent/command, il file `.framework` viene riconosciuto come **reset-safe** (nessun edit inline da perdere) se è pristine: (a) byte-identico alla base upstream verso cui si farebbe reset, **oppure** (b) corrisponde alla base con cui l'overlay è stato autorato (`base_file_sha` in frontmatter) — pristine anche dopo che l'upstream è andato avanti. In quel caso è `captured` (non più `stale`), invece di pretendere il round-trip stretto che un overlay additivo non può mai soddisfare. Il caso *genuinamente* stale (edit inline reale che l'overlay non riproduce) resta correttamente un blocker.
18
+ - **Test.** `classify-divergence.test.js`: 3 fixture per `absorbed` (da solo / con merge plumbing → `all-noise`; con overlay-able genuino → `overlay-able`). `overlay-capture.test.js`: 2 fixture per la reset-safety (overlay additivo + `.framework` == base → `captured`; upstream spostato ma `.framework` pristine vs `base_file_sha` → `captured`).
19
+
8
20
  ## [4.16.1] - 2026-06-05
9
21
 
10
22
  **Review di allineamento `new` ↔ `new2`: 9 fix di fedeltà sui workflow `new2`/`new2-resolve` emersi da una review adversariale a 3 reviewer paralleli.** La v4.16.0 ha introdotto `new2` come variante workflow-hosted di `/new`; una review di allineamento (gate-coverage · child-workflow wiring · control-flow) ha refutato l'implementazione su difetti reali — di semantica/allineamento, non di esecuzione (il wiring tra i 3 workflow era contract-fedele). **PATCH** (bugfix di allineamento del nuovo `new2`, nessun cambio di capability, nessuna chiave `baldart.config.yml`).
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.16.1
1
+ 4.16.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "4.16.1",
3
+ "version": "4.16.2",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -100,6 +100,27 @@ const AGGREGATE_FIXTURES = [
100
100
  ],
101
101
  expected: 'all-noise',
102
102
  },
103
+ {
104
+ name: 'absorbed commit alone → all-noise (content now == upstream)',
105
+ commits: [{ category: 'absorbed' }],
106
+ expected: 'all-noise',
107
+ },
108
+ {
109
+ name: 'absorbed + plumbing merge → all-noise (absorbed is noise)',
110
+ commits: [
111
+ { category: 'absorbed' },
112
+ { category: 'subtree-merge' },
113
+ ],
114
+ expected: 'all-noise',
115
+ },
116
+ {
117
+ name: 'absorbed + genuine overlay-able → overlay-able (absorbed excluded)',
118
+ commits: [
119
+ { category: 'absorbed' },
120
+ { category: 'custom-overlay-able' },
121
+ ],
122
+ expected: 'overlay-able',
123
+ },
103
124
  ];
104
125
 
105
126
  const PATH_FIXTURES = [
@@ -20,6 +20,7 @@ const {
20
20
  buildOverlayBody, verifyCapture, kindNameFromPath, toRepoPath, captureAndVerify,
21
21
  } = require('../overlay-capture');
22
22
  const { buildFrontmatter } = require('../../commands/overlay');
23
+ const { computeBaseFileSha } = require('../overlay-merger');
23
24
 
24
25
  const BASE = `---
25
26
  name: ui-expert
@@ -198,6 +199,55 @@ test('captureAndVerify: STALE existing agent overlay (does not reproduce edit)
198
199
  }
199
200
  });
200
201
 
202
+ test('captureAndVerify: ADDITIVE overlay + pristine .framework (== base) → captured, not stale (Fix B)', async () => {
203
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
204
+ try {
205
+ const fwRel = '.framework/framework/.claude/agents/ui-expert.md';
206
+ const fwAbs = path.join(tmp, fwRel);
207
+ fs.mkdirSync(path.dirname(fwAbs), { recursive: true });
208
+ // `.framework` is pristine upstream base — the customization lives ONLY in
209
+ // an additive (append-a-new-section) overlay, so reset+reapply loses
210
+ // nothing. The strict round-trip would call this "stale" (merge=base+append
211
+ // ≠ base); Fix B recognizes the pristine file as reset-safe → captured.
212
+ fs.writeFileSync(fwAbs, BASE);
213
+ const overlayAbs = path.join(tmp, '.baldart/overlays/agents/ui-expert.md');
214
+ fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
215
+ fs.writeFileSync(overlayAbs, overlayFor('\n## Mayo Stack Locks\nCSS Modules only.\n'));
216
+
217
+ const git = { git: { raw: async (a) => (a[0] === 'show' ? BASE : '') } };
218
+ const res = await captureAndVerify({ cwd: tmp, git, files: [fwRel], frameworkVersion: '4.16.1' });
219
+ assert.deepStrictEqual(res.blockers, []);
220
+ assert.deepStrictEqual(res.captured, ['.baldart/overlays/agents/ui-expert.md']);
221
+ } finally {
222
+ fs.rmSync(tmp, { recursive: true, force: true });
223
+ }
224
+ });
225
+
226
+ test('captureAndVerify: upstream moved but .framework pristine wrt overlay base_file_sha → captured (Fix B)', async () => {
227
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
228
+ try {
229
+ const fwRel = '.framework/framework/.claude/agents/ui-expert.md';
230
+ const OLD = BASE; // base the overlay was authored against
231
+ const NEW = BASE.replace('You are a UI expert.', 'You are a UI expert (upstream v2).'); // upstream moved on
232
+ const fwAbs = path.join(tmp, fwRel);
233
+ fs.mkdirSync(path.dirname(fwAbs), { recursive: true });
234
+ fs.writeFileSync(fwAbs, OLD); // .framework still pristine-at-OLD (no inline edit)
235
+ const overlayAbs = path.join(tmp, '.baldart/overlays/agents/ui-expert.md');
236
+ fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
237
+ const overlay = buildFrontmatter({
238
+ kind: 'agent', name: 'ui-expert', frameworkVersion: '4.16.1', baseSha: computeBaseFileSha(OLD), mode: 'extend',
239
+ }) + '\n## Mayo Locks\nCSS Modules only.\n';
240
+ fs.writeFileSync(overlayAbs, overlay);
241
+
242
+ const git = { git: { raw: async (a) => (a[0] === 'show' ? NEW : '') } }; // FETCH_HEAD base = NEW (differs from .framework)
243
+ const res = await captureAndVerify({ cwd: tmp, git, files: [fwRel], frameworkVersion: '4.16.1' });
244
+ assert.deepStrictEqual(res.blockers, []);
245
+ assert.deepStrictEqual(res.captured, ['.baldart/overlays/agents/ui-expert.md']);
246
+ } finally {
247
+ fs.rmSync(tmp, { recursive: true, force: true });
248
+ }
249
+ });
250
+
201
251
  test('captureAndVerify: unmappable overlay-able path (nested agent) → blocker, never silently dropped', async () => {
202
252
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
203
253
  try {
package/src/utils/git.js CHANGED
@@ -209,12 +209,16 @@ class GitUtils {
209
209
  // - subtree-merge: `git subtree pull --squash` merge commits
210
210
  // - subtree-squash: the squash payload commits themselves
211
211
  // - chore-wrapper: CLI-generated [CHORE]/chore(baldart): commits
212
+ // - absorbed: a would-be custom commit whose touched .framework/ files
213
+ // are now byte-identical to upstream (FETCH_HEAD) — the edit
214
+ // has been absorbed into upstream (or reset away), so a
215
+ // re-sync loses nothing. Pure history, not live drift → noise.
212
216
  // - custom-overlay-able: user edited a framework agent/skill/command
213
217
  // → should migrate to .baldart/overlays/
214
218
  // - custom-other: anything else (src/, CHANGELOG, ad-hoc fixes)
215
219
  //
216
220
  // Aggregate `class`:
217
- // - all-noise: every commit is subtree-* / chore-wrapper → auto-resolve
221
+ // - all-noise: every commit is subtree-* / chore-wrapper / absorbed → auto-resolve
218
222
  // - overlay-able: every non-noise commit is overlay-able → suggest /overlay
219
223
  // - real-custom: every non-noise commit is custom-other → prompt 3-way
220
224
  // - mixed: non-noise commits span both → prompt 3-way
@@ -293,6 +297,30 @@ class GitUtils {
293
297
  });
294
298
  }
295
299
 
300
+ // True when EVERY touched `.framework/` file's content at HEAD is already
301
+ // byte-identical to the upstream (FETCH_HEAD) blob — i.e. the commit's edit
302
+ // has been fully absorbed into upstream (or reset away) and a re-sync would
303
+ // lose nothing. Compares blob SHAs (cheap, exact). The subtree prefix
304
+ // `.framework/` is stripped to reach the upstream path (FETCH_HEAD has no
305
+ // `.framework/` prefix). Conservative: any file outside `.framework/`, missing
306
+ // upstream, or whose SHA differs → not absorbed (real drift, keep flagging).
307
+ async isAbsorbedAgainstUpstream(touched) {
308
+ if (!touched || !touched.length) return false;
309
+ const prefix = `${FRAMEWORK_DIR}/`;
310
+ for (const p of touched) {
311
+ if (!p.startsWith(prefix)) return false;
312
+ const upstreamPath = p.slice(prefix.length);
313
+ let headSha;
314
+ let upSha;
315
+ try { headSha = (await this.git.raw(['rev-parse', `HEAD:${p}`])).trim(); }
316
+ catch (_) { return false; }
317
+ try { upSha = (await this.git.raw(['rev-parse', `FETCH_HEAD:${upstreamPath}`])).trim(); }
318
+ catch (_) { return false; }
319
+ if (!headSha || headSha !== upSha) return false;
320
+ }
321
+ return true;
322
+ }
323
+
296
324
  async classifyDivergence() {
297
325
  // Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
298
326
  const commits = [];
@@ -334,6 +362,15 @@ class GitUtils {
334
362
  } catch (_) { /* leave empty → custom-other */ }
335
363
  category = this.classifyCommitByPaths(touched);
336
364
  }
365
+ // A would-be custom commit whose touched files are now identical to
366
+ // upstream is absorbed history, not live drift — demote it to noise so
367
+ // a long-resolved local edit (e.g. one whose content later landed in or
368
+ // matched upstream) stops re-triggering the divergence gate on every
369
+ // release. Only runs for custom-* (where `touched` is populated).
370
+ if ((category === 'custom-overlay-able' || category === 'custom-other')
371
+ && await this.isAbsorbedAgainstUpstream(touched)) {
372
+ category = 'absorbed';
373
+ }
337
374
  const overlayCovered = category === 'custom-overlay-able'
338
375
  ? this.overlayCoversTouched(touched)
339
376
  : false;
@@ -353,7 +390,7 @@ class GitUtils {
353
390
  // (subtree plumbing + CLI chore wrappers + merge commits) are excluded
354
391
  // BEFORE deciding overlay-able vs mixed, so plumbing never downgrades the UX.
355
392
  static aggregateDivergenceClass(commits) {
356
- const noiseCategories = new Set(['subtree-merge', 'subtree-squash', 'chore-wrapper']);
393
+ const noiseCategories = new Set(['subtree-merge', 'subtree-squash', 'chore-wrapper', 'absorbed']);
357
394
  const nonNoise = commits.filter((c) => !noiseCategories.has(c.category));
358
395
  if (nonNoise.length === 0) return 'all-noise';
359
396
  const hasOverlayable = nonNoise.some((c) => c.category === 'custom-overlay-able');
@@ -181,6 +181,26 @@ async function captureAndVerify({ cwd, git, files, frameworkVersion }) {
181
181
  let overlayContent;
182
182
  try { overlayContent = fs.readFileSync(overlayAbs, 'utf8'); }
183
183
  catch (_) { blockers.push({ file, reason: 'existing overlay unreadable' }); continue; }
184
+
185
+ // Reset-safety shortcut: when the `.framework/` file carries NO inline
186
+ // edit to lose, a reset+re-apply preserves everything (the customization
187
+ // lives in the overlay and is re-applied). Two pristine signals:
188
+ // (a) it is byte-identical to the upstream base we'd reset to, or
189
+ // (b) it matches the base the overlay was authored against
190
+ // (`base_file_sha`) — pristine even after upstream moved on.
191
+ // This is what makes an ADDITIVE (extend) overlay survive: the strict
192
+ // round-trip below would otherwise demand merge(base, overlay) == file,
193
+ // which never holds when the overlay only appends — flagging a perfectly
194
+ // healthy overlay as "stale" on every release.
195
+ const overlayBaseSha = (/^\s*base_file_sha\s*:\s*"?([a-f0-9]+)"?/m.exec(overlayContent) || [])[1];
196
+ const editedPristine =
197
+ norm(editedContent) === norm(baseContent)
198
+ || (overlayBaseSha && computeBaseFileSha(editedContent) === overlayBaseSha);
199
+ if (editedPristine) {
200
+ captured.push(overlayRel);
201
+ continue;
202
+ }
203
+
184
204
  if (verifyCapture({ kind: kn.kind, name: kn.name, baseContent, overlayContent, editedContent, frameworkVersion })) {
185
205
  captured.push(overlayRel);
186
206
  } else {