baldart 5.10.1 → 5.11.1

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,31 @@ 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
+ ## [5.11.1] - 2026-07-07
9
+
10
+ **`/new` now tears down its idle background subagents at end of run — closes the "Background work is running" leak on terminal exit.**
11
+ A user reported that after every interactive `/new` run, closing the terminal warns `Background work is running` and lists the batch's teammates (`## AUTONOMOUS CARD IMPLEMENTATION — FEAT-00NN`, the `i18n-translator` fill, the doc closer) as if they were still executing. They are not — they **finished**. Root cause: `/new` spawns its coder + closer subagents with `run_in_background: true`, and a background subagent that completes its work does **NOT** terminate — it goes idle ("rests") so the orchestrator could `SendMessage`/resume it (deliberate: the empty-result gate reads their transcripts from disk, and the framework explicitly forbids messaging a rested teammate). Phase 6c's teardown ended the **Codex broker** (a Bash process) + reaped orphan MCP servers, but there was **no `TaskStop` anywhere in the framework** — so the idle teammates stayed alive until Claude Code's `SessionEnd` (whenever the user closes the terminal). Harmless to the committed work (already merged), but noisy and wasteful of session resources.
12
+
13
+ - **New Phase 6c step 5c** (`framework/.claude/skills/new/references/merge-cleanup.md`) — after the merge and after every teammate's transcript has been consumed, `TaskList` → `TaskStop` the batch's idle teammates, scoped to THIS run only: matched by the `coder-<CARD-ID>` name prefix OR any card ID in the tracker `## Card Queue` (so the match survives a compaction). NEVER stops a task outside the batch signature (the user's unrelated background work in the same session). Best-effort + non-blocking + one log line (`Background subagents teardown: <stopped N | none alive | N/A (Codex) | skipped>`).
14
+ - **Mirror on early exit** (`framework/.claude/skills/new/SKILL.md` § "Terminal hygiene") — the same `TaskList`→`TaskStop` runs when the batch ends before Phase 6c (unrecoverable HALT / killed final-review workflow), exactly as the Codex-broker early teardown already did.
15
+ - **Pointer** in `references/team-mode.md` — the parallel `coder-<CARD-ID>` fan-out is the widest source of the leak; noted that Phase 6c 5c tears them down after merge.
16
+ - **`/new2` is unaffected** — its agents run inside the dynamic-workflow runtime and die with the workflow; only the classic `/new` (Task-tool background subagents) leaked.
17
+ - **Codex parity: N/A** — no observable Agent/Task tool on Codex, team-mode is forced sequential there, and Codex named-agent spawns do not persist as idle background tasks (`framework/agents/runtime-portability-protocol.md`). **No new `baldart.config.yml` key** → the schema-change propagation rule does NOT apply. `/new` skill version → 2.4.1.
18
+
19
+ ## [5.11.0] - 2026-07-06
20
+
21
+ **Seamless framework-drift reconciliation in `baldart update` — a trivially-drifted shared framework file no longer dead-ends the whole update.**
22
+ A `mayo` consumer stuck at v5.7.0→v5.9.0 surfaced the failure: five ancient `.framework/` commits (an add-then-revert of the v4.77.0 extractor, a superseded responsive-prose delete, a FEAT-0054-03 gating feature) were all flagged `custom-other` → class `mixed`/`real-custom` → **hard block**. Root cause, proven by diffing HEAD vs upstream file-by-file: of the **14** framework files those commits touched, **13 were already byte-identical to upstream** — only ONE, `design-system-protocol.md`, still diverged, and its *entire* divergence was **2 cosmetic characters** (a `+ ` vs `- ` markdown continuation bullet, a merge artifact). Because `isAbsorbedAgainstUpstream` is **all-or-nothing per commit** (a commit demotes to `absorbed` only when EVERY touched payload file matches upstream), that single 2-char drift in a file all five commits share poisoned the whole set. Five commits blocked by two cosmetic characters, on every release.
23
+
24
+ The fix encodes BALDART's own contract — **direct `.framework/` edits are blocked by the `framework-edit-gate` and sanctioned customizations live in overlays, so an uncovered residual is drift upstream OWNS** — into three additive classifier/updater pieces (no new agent, no new file):
25
+
26
+ - **New `liveDriftAgainstUpstream(touched)` + `reconcilable` category** (`src/utils/git.js` `classifyDivergence`). A would-be `custom-other` commit whose SURVIVING drift (`absorbablePayloadSubset` with HEAD≠upstream, computed by cheap blob-SHA compare) is confined to framework-owned files that (a) still exist upstream and (b) are NOT captured by any existing overlay is demoted from `custom-other` to `reconcilable`, carrying the exact file list. Overlay-COVERED drift routes to the capture path (intentional, tracked); net-new/deleted framework files keep flagging as real drift (an overwrite can't cleanly reconcile them).
27
+ - **Revert-pair cancellation** (`GitUtils.cancelRevertPairs`, pure/static). A `Revert "<subject>"` commit and the commit it reverts are a net no-op on the tree — both demote to `absorbed`. Kills the add-then-revert class that re-triggered the gate every release.
28
+ - **Seamless auto-align in `update`** (`src/commands/update.js`). New non-blocking `reconcilable` divergence branch: it lists the framework-owned files that drift, aligns each to its upstream (FETCH_HEAD) blob via the new `reconcileFrameworkDrift` helper and commits ONCE (so the subtree pull has no local hunk to conflict on), then proceeds — with the pre-pull backup tag making it fully recoverable. `git.on_divergence: abort` still lets a cautious consumer opt out; a genuine `custom-other` blocker (net-new/deleted/overlay-covered) still dominates to `mixed`/`real-custom` and stops for `/baldart-push` or `--on-divergence pull`.
29
+ - **`aggregateDivergenceClass`** gains the `reconcilable` class (blockers dominate → `mixed`/`real-custom`; else overlay-able → `overlay-able`; else reconcilable → `reconcilable`; else `all-noise`). `doctor`/`version` need no change (they delegate to `update`, which now self-heals the drift).
30
+ - Tests: `src/utils/__tests__/classify-divergence.test.js` gains 5 `reconcilable` aggregate fixtures + 4 `cancelRevertPairs` fixtures (61 pass, 0 fail). End-to-end validated on a synthetic subtree consumer reproducing the exact cosmetic-drift cascade → class flips from blocking to `reconcilable`, and `reconcileFrameworkDrift` aligns the file byte-for-byte to upstream.
31
+ - **Codex parity: portable** — pure CLI (Node), runtime-agnostic; benefits `[claude]`, `[codex]`, and `[claude, codex]` consumers identically. **No new `baldart.config.yml` key** (rides on the existing `git.on_divergence` policy) → the schema-change propagation rule does NOT apply.
32
+
8
33
  ## [5.10.1] - 2026-07-06
9
34
 
10
35
  **Per-wave Codex relay backported to the v5.6.0 blocking-foreground pattern — no more bogus TIMEOUT at 83s that kills a live Codex.**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.10.1
1
+ 5.11.1
@@ -2,6 +2,22 @@
2
2
 
3
3
  Formato: [Keep a Changelog](https://keepachangelog.com/) · [SemVer](https://semver.org/).
4
4
 
5
+ ## 2.4.1 — 2026-07-07
6
+
7
+ - **Teardown dei subagent in background a fine run (chiude il leak "Background work is running").**
8
+ I coder + closer di `/new` sono spawnati con `run_in_background: true`; un subagent che finisce il
9
+ lavoro **non termina** — va in idle ("rests") così l'orchestratore *potrebbe* farne resume. Il
10
+ teardown di Phase 6c fermava solo il **broker Codex** (processo Bash), mai i subagent → restavano
11
+ vivi in idle fino al `SessionEnd`, comparendo come "Background work is running" alla chiusura del
12
+ terminale. Nuovo step **6c.5c** (`references/merge-cleanup.md`): dopo il merge, `TaskList` →
13
+ `TaskStop` sui soli teammate di QUESTO batch — matchati per `coder-<CARD-ID>` o per card-ID dal
14
+ tracker `## Card Queue` (sopravvive alla compaction); mai un task fuori dalla firma del batch
15
+ (lavoro background non correlato dell'utente). Best-effort + non-bloccante + riga di log. Mirror
16
+ dello stesso teardown nel path di uscita anticipata (`SKILL.md` § "Terminal hygiene"); pointer in
17
+ `references/team-mode.md` (i coder paralleli sono la fonte principale del leak). `/new2` non è
18
+ affetto (agent nel runtime del workflow → muoiono col workflow). Codex parity: N/A (nessun
19
+ Agent/Task tool osservabile; team-mode forzato sequenziale). Nessuna nuova chiave `baldart.config.yml`.
20
+
5
21
  ## 2.4.0 — 2026-07-06
6
22
 
7
23
  - **Prossime wave per NOME nel messaggio finale.** Il Step F.6 "Prossimi Passi"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: new
3
3
  effort: medium
4
- version: 2.4.0
4
+ version: 2.4.1
5
5
  requires_feature: has_backlog
6
6
  description: >
7
7
  Orchestrate a team of specialized agents to implement one or more backlog cards
@@ -472,6 +472,14 @@ printf '%s' "$TD" | grep -qi "unknown command" \
472
472
  This NEVER touches a broker for another worktree or the user's interactive session (different cwd). If the
473
473
  orchestrator is hard-killed and never resumed, the `baldart doctor` reaper is the final safety net.
474
474
 
475
+ **Also stop THIS batch's idle background subagents on that early exit** (Claude-only, best-effort) — the
476
+ same teardown Phase 6c step 5c runs on the normal close. `/new`'s coder + closer teammates (`run_in_background`)
477
+ go idle rather than terminate, so an early exit leaves them alive and they surface as "Background work is
478
+ running" when the user closes the terminal. `TaskList` → `TaskStop` each task named `coder-<CARD-ID>` or whose
479
+ name/prompt references a card ID from the tracker `## Card Queue`; NEVER stop a task that does not match the
480
+ batch signature (the user's unrelated background work). Any `TaskStop` error / absent tool is fine — never
481
+ gate the exit on it. (Codex: N/A — no observable Task tool.)
482
+
475
483
  ---
476
484
 
477
485
 
@@ -263,6 +263,28 @@ The most common failure mode is leaving cards IN_PROGRESS after merge. This crea
263
263
  Never gate the close on these — any error or a "nothing to do" result is fine; capture each one-line
264
264
  summary for the log.
265
265
 
266
+ 5c. **Background subagent teardown — stop THIS batch's idle teammates (Claude-only, NON-BLOCKING)**.
267
+ `/new` spawns coder + closer subagents with `run_in_background: true` (team-mode coders
268
+ `coder-<CARD-ID>`, the step-7c `i18n-translator` fill, the doc closer). A background subagent that
269
+ finishes its work does **NOT** terminate — it goes idle ("rests") so the orchestrator *could*
270
+ resume it (and the empty-result gate reads its transcript from disk, never via `SendMessage`). By
271
+ Phase 6c every card is merged and every teammate's transcript has already been consumed, so these
272
+ idle teammates are pure dead weight — but the broker teardown above only ends the **Codex broker**
273
+ (a Bash process); it does NOT touch the subagents. Left alone they stay alive until Claude Code's
274
+ `SessionEnd` (hours away) and surface as **"Background work is running"** the moment the user tries
275
+ to close the terminal — noise + held session resources, though the committed work is already safe.
276
+ Stop ONLY the teammates belonging to THIS batch — matched by the batch's card IDs (read from the
277
+ tracker `## Card Queue`, so the match survives a compaction) or the `coder-` name prefix:
278
+ - `TaskList` → for each task still `running`/idle whose `name` is `coder-<CARD-ID>` OR whose
279
+ `name`/prompt references any card ID in `## Card Queue` (e.g. `FEAT-00NN`) → `TaskStop` it.
280
+ - **NEVER** `TaskStop` a task that does not match the batch signature — the user may have unrelated
281
+ background work in the same session. When a task's ownership is ambiguous, LEAVE it (a stray idle
282
+ `/new` teammate is harmless; killing the user's unrelated work is not).
283
+ Best-effort: a `TaskStop` error / "already stopped" / an absent `TaskList` tool is all fine —
284
+ capture a one-line count for the log, never gate the close on it. **(Codex path — N/A:** no
285
+ observable Agent/Task tool, team-mode is forced sequential, and Codex named-agent spawns do not
286
+ persist as idle background tasks — see `framework/agents/runtime-portability-protocol.md`.)
287
+
266
288
  6. **Log and exit**:
267
289
  ```
268
290
  ## Phase 6c — Workspace Hygiene Post-merge
@@ -273,6 +295,7 @@ The most common failure mode is leaving cards IN_PROGRESS after merge. This crea
273
295
  Phase 0 snapshot restore: <n/a | popped clean | conflict-deferred-to-user>
274
296
  Codex broker teardown: <graceful (pid) | signalled N | none | skipped (error)>
275
297
  Codex MCP hygiene (reap): <reaped N/M | nothing to reap | skipped (error)>
298
+ Background subagents teardown: <stopped N | none alive | N/A (Codex) | skipped (error)>
276
299
  Completed: <timestamp>
277
300
  ```
278
301
  If any step ended in HALT, set `Status: HALT` and report — Phase 7 must NOT start with an unclean main repo unless the user explicitly chose `[Lascia così]`.
@@ -348,4 +348,6 @@ Before starting group N, verify ALL cards in groups 0..N-1 are status: done. If
348
348
 
349
349
  After all groups are complete, run the same Final Review, Phase 6 (merge), and Phase 7 (production readiness) as documented above. Since v3.37.0 the Final Review runs a **single FULL `/codexreview` over the entire batch diff** (per the Final-review FULL gate) — no scope reduction — so every team-mode card, including any reviewed at `light` in D.4b, receives a guaranteed full review before merge.
350
350
 
351
+ > **The parallel `coder-<CARD-ID>` teammates are torn down at Phase 6c step 5c** (`references/merge-cleanup.md`) — a `run_in_background` coder goes idle rather than terminating, so team mode (the widest fan-out) is the main source of the "Background work is running" idle-teammate leak. The Phase 6c teardown `TaskStop`s them after merge; nothing to do here.
352
+
351
353
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "5.10.1",
3
+ "version": "5.11.1",
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"
@@ -118,6 +118,43 @@ function scaffoldOverlaysForCommits(commits) {
118
118
  return { created, skipped };
119
119
  }
120
120
 
121
+ // Seamless framework-drift reconciliation (v5.11.0). Given the exact set of
122
+ // framework-owned payload files whose HEAD content still diverges from upstream
123
+ // (the `reconcilableFiles` computed by classifyDivergence → liveDriftAgainst
124
+ // Upstream), align each to its upstream (FETCH_HEAD) blob and commit ONCE, so
125
+ // the subsequent subtree pull has no local hunk to conflict on. These are files
126
+ // BALDART's own contract says upstream owns (direct `.framework/` edits are
127
+ // blocked by the edit-gate; sanctioned customizations live in overlays), so
128
+ // overwriting them loses nothing tracked — and the pre-pull backup tag makes it
129
+ // recoverable regardless. Returns the list actually rewritten. Non-fatal on a
130
+ // per-file read/write error (skips it → the pull will surface any residue).
131
+ async function reconcileFrameworkDrift(git, files) {
132
+ const fs = require('fs');
133
+ const path = require('path');
134
+ const cwd = process.cwd();
135
+ const written = [];
136
+ for (const p of files) {
137
+ // p is a repo-relative `.framework/<rest>` path; upstream (FETCH_HEAD) has no
138
+ // `.framework/` prefix. Read the upstream blob and overwrite the working file.
139
+ const upstreamPath = p.replace(/^\.framework\//, '');
140
+ try {
141
+ const blob = await git.git.raw(['show', `FETCH_HEAD:${upstreamPath}`]);
142
+ fs.writeFileSync(path.join(cwd, p), blob);
143
+ written.push(p);
144
+ } catch (_) { /* skip — pull will reconcile or surface it */ }
145
+ }
146
+ if (written.length) {
147
+ try {
148
+ await git.git.add(written);
149
+ const st = await git.git.status();
150
+ if (st.staged.length) {
151
+ await git.git.commit('chore(baldart): align framework-owned drift to upstream (pre-pull reconcile)');
152
+ }
153
+ } catch (_) { /* non-fatal — files remain aligned on disk for the pull */ }
154
+ }
155
+ return written;
156
+ }
157
+
121
158
  // Path prefixes BALDART itself writes during install/update. Anything outside
122
159
  // these patterns is treated as user-owned and never auto-staged.
123
160
  //
@@ -1077,6 +1114,27 @@ async function update(options = {}, unknownArgs = []) {
1077
1114
  }
1078
1115
  if (status.divergenceClass === 'all-noise') {
1079
1116
  UI.info(`Detected ${status.divergenceCommits.length} subtree-merge / chore commit(s) (git plumbing noise — no real content drift). Auto-resolving…`);
1117
+ } else if (status.divergenceClass === 'reconcilable') {
1118
+ // Only framework-owned residual drift, uncovered by any overlay → upstream
1119
+ // owns these files. Align them to upstream BEFORE the pull so the update is
1120
+ // seamless instead of dead-ending. The backup tag (created below) + the
1121
+ // shown file list keep it transparent and recoverable. `--on-divergence
1122
+ // abort` still lets a cautious user opt out.
1123
+ const reconcileFiles = [...new Set(
1124
+ status.divergenceCommits.flatMap((c) => c.reconcilableFiles || [])
1125
+ )].sort();
1126
+ UI.warning(`Detected ${reconcileFiles.length} framework-owned file(s) that drift from upstream but carry no overlay — upstream owns them.`);
1127
+ for (const f of reconcileFiles) UI.info(` • ${f}`);
1128
+ if (divStrategy === 'abort') {
1129
+ UI.info('Update cancelled (--on-divergence abort). The drift is left untouched.');
1130
+ emitUpdateJson({ ok: false, action: 'aborted',
1131
+ divergence_class: status.divergenceClass, reconcile_files: reconcileFiles,
1132
+ reason: 'Reconcilable framework drift left untouched (--on-divergence abort).' }, 0);
1133
+ }
1134
+ UI.info('Aligning them to upstream (pre-pull reconcile) — nothing tracked is lost; a backup tag is created below…');
1135
+ const written = await reconcileFrameworkDrift(git, reconcileFiles);
1136
+ UI.success(`Aligned ${written.length} framework file(s) to upstream. Proceeding with the update.`);
1137
+ // fall through to the normal preview + pull
1080
1138
  } else if (status.divergenceClass === 'overlay-able') {
1081
1139
  UI.warning('Detected custom edits to framework files inside `.framework/.claude/{agents,skills,commands}/`.');
1082
1140
  UI.info('These are better expressed as overlays under `.baldart/overlays/` — they survive updates without divergence.');
@@ -1131,7 +1189,7 @@ async function update(options = {}, unknownArgs = []) {
1131
1189
  }
1132
1190
  } else if (status.divergenceClass === 'real-custom' || status.divergenceClass === 'mixed') {
1133
1191
  UI.warning(`Detected ${status.divergenceCommits.filter((c) => c.category === 'custom-other').length} custom commit(s) on .framework/ that are NOT auto-classifiable as overlays.`);
1134
- for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper'].includes(c.category))) {
1192
+ for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper', 'absorbed', 'reconcilable'].includes(c.category))) {
1135
1193
  UI.info(` • ${c.sha.slice(0, 7)} [${c.category}] — ${c.subject}`);
1136
1194
  }
1137
1195
  const overlayCommits = status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able');
@@ -121,6 +121,82 @@ const AGGREGATE_FIXTURES = [
121
121
  ],
122
122
  expected: 'overlay-able',
123
123
  },
124
+ // ─── reconcilable (v5.11.0): framework-owned drift, no overlay → seamless ──
125
+ {
126
+ name: 'reconcilable alone → reconcilable (framework-owned, non-blocking)',
127
+ commits: [{ category: 'reconcilable' }],
128
+ expected: 'reconcilable',
129
+ },
130
+ {
131
+ name: 'reconcilable + plumbing noise → reconcilable',
132
+ commits: [
133
+ { category: 'reconcilable' },
134
+ { category: 'subtree-merge' },
135
+ { category: 'absorbed' },
136
+ ],
137
+ expected: 'reconcilable',
138
+ },
139
+ {
140
+ name: 'reconcilable + custom-other → real-custom (blocker dominates)',
141
+ commits: [
142
+ { category: 'reconcilable' },
143
+ { category: 'custom-other' },
144
+ ],
145
+ expected: 'real-custom',
146
+ },
147
+ {
148
+ name: 'reconcilable + overlay-able → overlay-able (overlay path wins)',
149
+ commits: [
150
+ { category: 'reconcilable' },
151
+ { category: 'custom-overlay-able' },
152
+ ],
153
+ expected: 'overlay-able',
154
+ },
155
+ {
156
+ name: 'reconcilable + overlay-able + custom-other → mixed (blocker + overlay)',
157
+ commits: [
158
+ { category: 'reconcilable' },
159
+ { category: 'custom-overlay-able' },
160
+ { category: 'custom-other' },
161
+ ],
162
+ expected: 'mixed',
163
+ },
164
+ ];
165
+
166
+ // cancelRevertPairs(commits) — mutating: a `Revert "<subject>"` commit and its
167
+ // target both demote to `absorbed` (net-zero tree effect). Only custom-* are
168
+ // touched; noise categories and unmatched reverts are left as-is.
169
+ const REVERT_PAIR_FIXTURES = [
170
+ {
171
+ name: 'add + its Revert → both absorbed',
172
+ commits: [
173
+ { subject: '[CHORE] add extractor', category: 'reconcilable' },
174
+ { subject: 'Revert "[CHORE] add extractor"', category: 'reconcilable' },
175
+ ],
176
+ expected: ['absorbed', 'absorbed'],
177
+ },
178
+ {
179
+ name: 'revert with no matching target → left unchanged',
180
+ commits: [
181
+ { subject: 'Revert "something not here"', category: 'custom-other' },
182
+ ],
183
+ expected: ['custom-other'],
184
+ },
185
+ {
186
+ name: 'target is noise → only the custom revert is demoted',
187
+ commits: [
188
+ { subject: 'chore(baldart): x', category: 'chore-wrapper' },
189
+ { subject: 'Revert "chore(baldart): x"', category: 'custom-other' },
190
+ ],
191
+ expected: ['chore-wrapper', 'absorbed'],
192
+ },
193
+ {
194
+ name: 'non-revert commits untouched',
195
+ commits: [
196
+ { subject: 'feat: real work', category: 'custom-other' },
197
+ ],
198
+ expected: ['custom-other'],
199
+ },
124
200
  ];
125
201
 
126
202
  const PATH_FIXTURES = [
@@ -247,6 +323,16 @@ for (const f of AGGREGATE_FIXTURES) {
247
323
  }
248
324
  }
249
325
 
326
+ console.log('\n─── Revert-pair cancellation ───');
327
+ for (const f of REVERT_PAIR_FIXTURES) {
328
+ const commits = f.commits.map((c) => ({ ...c }));
329
+ GitUtils.cancelRevertPairs(commits);
330
+ const actual = commits.map((c) => c.category);
331
+ const ok = actual.length === f.expected.length && actual.every((v, i) => v === f.expected[i]);
332
+ if (ok) { pass++; console.log(` ✓ ${f.name} → [${actual.join(', ')}]`); }
333
+ else { fail++; console.log(` ✗ ${f.name} → [${actual.join(', ')}] (expected [${f.expected.join(', ')}])`); }
334
+ }
335
+
250
336
  console.log('\n─── Overlay path mapping ───');
251
337
  for (const f of OVERLAY_REL_FIXTURES) {
252
338
  const actual = GitUtils.overlayRelForFrameworkFile(f.file);
package/src/utils/git.js CHANGED
@@ -218,13 +218,21 @@ class GitUtils {
218
218
  // live drift → noise.
219
219
  // - custom-overlay-able: user edited a framework agent/skill/command
220
220
  // → should migrate to .baldart/overlays/
221
- // - custom-other: anything else (src/, CHANGELOG, ad-hoc fixes)
221
+ // - reconcilable: a would-be custom-other whose SURVIVING drift is confined
222
+ // to framework-owned payload files that exist upstream and
223
+ // are NOT captured by any overlay → upstream OWNS them;
224
+ // `update` aligns them to upstream then pulls (see
225
+ // liveDriftAgainstUpstream). Non-blocking.
226
+ // - custom-other: anything else that still carries genuine live drift
227
+ // (net-new/deleted framework files, overlay-covered edits)
222
228
  //
223
229
  // Aggregate `class`:
224
230
  // - all-noise: every commit is subtree-* / chore-wrapper / absorbed → auto-resolve
225
231
  // - overlay-able: every non-noise commit is overlay-able → suggest /overlay
232
+ // - reconcilable: non-noise commits are only framework-owned reconcilable
233
+ // drift → align-to-upstream then pull (seamless, non-blocking)
226
234
  // - real-custom: every non-noise commit is custom-other → prompt 3-way
227
- // - mixed: non-noise commits span both → prompt 3-way
235
+ // - mixed: non-noise commits span overlay-able + custom-other → prompt 3-way
228
236
  //
229
237
  // Default-conservative: any commit whose subject + touched-paths don't
230
238
  // match a known pattern is classified `custom-other`, never auto-resolved.
@@ -357,6 +365,32 @@ class GitUtils {
357
365
  return true;
358
366
  }
359
367
 
368
+ // The subset of a commit's touched `.framework/` payload files whose current
369
+ // HEAD content STILL diverges from upstream (FETCH_HEAD) — i.e. the surviving
370
+ // "live drift" the subtree pull would otherwise have to merge. Restricted to
371
+ // the `absorbablePayloadSubset` (consumer files + subtree bookkeeping
372
+ // excluded — see there). A file is INCLUDED only when it exists BOTH at HEAD
373
+ // and upstream and their blob SHAs differ; a file that is net-new locally
374
+ // (absent upstream) or deleted at HEAD is EXCLUDED here (it cannot be cleanly
375
+ // reconciled by an overwrite-with-upstream, so it must keep flagging as real
376
+ // drift). Used to decide the `reconcilable` category. Cheap — compares SHAs.
377
+ async liveDriftAgainstUpstream(touched) {
378
+ const prefix = `${FRAMEWORK_DIR}/`;
379
+ const payload = GitUtils.absorbablePayloadSubset(touched);
380
+ const drift = [];
381
+ for (const p of payload) {
382
+ const upstreamPath = p.slice(prefix.length);
383
+ let headSha;
384
+ let upSha;
385
+ try { headSha = (await this.git.raw(['rev-parse', `HEAD:${p}`])).trim(); }
386
+ catch (_) { continue; } // deleted at HEAD → not overwrite-reconcilable
387
+ try { upSha = (await this.git.raw(['rev-parse', `FETCH_HEAD:${upstreamPath}`])).trim(); }
388
+ catch (_) { continue; } // net-new local (absent upstream) → not reconcilable
389
+ if (headSha && upSha && headSha !== upSha) drift.push(p);
390
+ }
391
+ return drift;
392
+ }
393
+
360
394
  async classifyDivergence() {
361
395
  // Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
362
396
  const commits = [];
@@ -407,19 +441,69 @@ class GitUtils {
407
441
  && await this.isAbsorbedAgainstUpstream(touched)) {
408
442
  category = 'absorbed';
409
443
  }
444
+ // Seamless framework-drift reconciliation (v5.11.0). A `custom-other`
445
+ // commit that is NOT fully absorbed but whose SURVIVING drift is confined
446
+ // to framework-owned payload files that (a) still exist upstream and (b)
447
+ // are NOT captured by any existing overlay is, by BALDART's own contract,
448
+ // overwrite-reconcilable: direct `.framework/` edits are blocked by the
449
+ // `framework-edit-gate` and sanctioned customizations live in overlays, so
450
+ // an uncovered residual is drift upstream OWNS. Demote it to `reconcilable`
451
+ // + carry the exact file list so `update` can align those files to upstream
452
+ // BEFORE the pull instead of dead-ending the whole update. This kills the
453
+ // cascade where ONE trivially-drifted shared file (e.g. a cosmetic 2-char
454
+ // diff in `design-system-protocol.md`) poisons every commit that touches it
455
+ // into `custom-other`. Overlay-COVERED drift routes to the capture path
456
+ // instead (intentional, tracked); net-new/deleted files keep flagging.
457
+ let reconcilableFiles = [];
458
+ if (category === 'custom-other') {
459
+ const drift = await this.liveDriftAgainstUpstream(touched);
460
+ const anyCovered = drift.some((p) => {
461
+ const rel = GitUtils.overlayRelForFrameworkFile(p);
462
+ return rel ? fs.existsSync(path.join(this.cwd, rel)) : false;
463
+ });
464
+ if (drift.length && !anyCovered) {
465
+ category = 'reconcilable';
466
+ reconcilableFiles = drift;
467
+ }
468
+ }
410
469
  const overlayCovered = category === 'custom-overlay-able'
411
470
  ? this.overlayCoversTouched(touched)
412
471
  : false;
413
- commits.push({ sha, subject, category, parents, touched: touched || [], overlayCovered });
472
+ commits.push({ sha, subject, category, parents, touched: touched || [], overlayCovered, reconcilableFiles });
414
473
  }
415
474
  } catch (_) {
416
475
  return { class: 'unknown', commits: [] };
417
476
  }
418
477
 
419
478
  if (commits.length === 0) return { class: 'all-noise', commits: [] };
479
+ // Revert-pair cancellation: a `Revert "<subject>"` commit and the commit it
480
+ // reverts are a net no-op on the tree — neither contributes live drift, so
481
+ // both are noise. Detect by subject (git's default revert subject) and demote
482
+ // the pair to `absorbed`. Guards a long-lived add-then-revert of a framework
483
+ // file from re-triggering the divergence gate on every release.
484
+ GitUtils.cancelRevertPairs(commits);
420
485
  return { class: GitUtils.aggregateDivergenceClass(commits), commits };
421
486
  }
422
487
 
488
+ // Pure: demote reverted+revert commit pairs to `absorbed` in place. A default
489
+ // git revert of "<subject>" produces the subject `Revert "<subject>"`; when
490
+ // both the revert and its target appear in the divergence range, their net
491
+ // tree effect is zero. Only demotes commits that are still `custom-*` (never
492
+ // touches genuine noise categories). Extracted static for unit-testing.
493
+ static cancelRevertPairs(commits) {
494
+ const custom = new Set(['custom-overlay-able', 'custom-other', 'reconcilable']);
495
+ const bySubject = new Map();
496
+ for (const c of commits) bySubject.set(c.subject, c);
497
+ for (const c of commits) {
498
+ const m = /^Revert "(.+)"$/.exec(c.subject || '');
499
+ if (!m) continue;
500
+ const target = bySubject.get(m[1]);
501
+ if (!target) continue;
502
+ if (custom.has(c.category)) { c.category = 'absorbed'; c.reconcilableFiles = []; }
503
+ if (custom.has(target.category)) { target.category = 'absorbed'; target.reconcilableFiles = []; }
504
+ }
505
+ }
506
+
423
507
  // Pure aggregation of per-commit categories into the divergence class.
424
508
  // Extracted as a static method so it can be unit-tested without a live repo
425
509
  // (see src/utils/__tests__/classify-divergence.test.js). Noise categories
@@ -431,8 +515,15 @@ class GitUtils {
431
515
  if (nonNoise.length === 0) return 'all-noise';
432
516
  const hasOverlayable = nonNoise.some((c) => c.category === 'custom-overlay-able');
433
517
  const hasOther = nonNoise.some((c) => c.category === 'custom-other');
434
- if (hasOverlayable && !hasOther) return 'overlay-able';
435
- if (hasOverlayable && hasOther) return 'mixed';
518
+ const hasReconcilable = nonNoise.some((c) => c.category === 'reconcilable');
519
+ // A genuine `custom-other` blocker always dominates — it needs `/baldart-push`
520
+ // or an explicit `--on-divergence pull` (reconcilable commits ride along and
521
+ // are handled by the pull). Overlay-able edits route to the capture path.
522
+ if (hasOther) return hasOverlayable ? 'mixed' : 'real-custom';
523
+ if (hasOverlayable) return 'overlay-able';
524
+ // No blockers, no overlay-able: only framework-owned reconcilable drift →
525
+ // `update` aligns those files to upstream, then pulls. Seamless, non-blocking.
526
+ if (hasReconcilable) return 'reconcilable';
436
527
  return 'real-custom';
437
528
  }
438
529