baldart 4.10.0 → 4.11.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 CHANGED
@@ -5,6 +5,28 @@ 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.11.0] - 2026-06-04
9
+
10
+ **Gli overlay non costringono più a riconciliare a ogni `baldart update` — la VERSION non è più incisa nell'identità dei file generati né usata come segnale di drift quando il base file non è cambiato.** Sintomo riportato dall'utente: ogni update richiedeva una riconciliazione. La verifica del sorgente ha trovato **due trigger indipendenti con la stessa radice** — la VERSION del framework veniva trattata come parte dell'identità di overlay/file-generati, ma viene bumpata a *ogni* release a prescindere dal fatto che lo specifico base file sia cambiato. **(A) churn dei file agent/command generati:** `buildMarker()` stampava `base_version=<VERSION>` nel commento `<!-- baldart-generated -->`; a ogni update la VERSION cambiava → la riga del marker cambiava anche su contenuto byte-identico → git vedeva un diff → scattava il commit `post-update reconcile` *a ogni update*. Il campo era puramente informativo (`readMarker` lo leggeva ma nessuno gate-ava la rigenerazione su di esso). **(B) falso-drift degli skill overlay:** `doctor` e `status` confrontavano il pin `base_skill_version` *direttamente* con la VERSION installata (ignorando `base_file_sha`), così ogni bump faceva apparire ogni overlay come "drifted" anche con base SKILL.md identico — mentre `overlay drift` lo faceva già correttamente SHA-first. **MINOR** (cambia il formato del marker generato; nessuna chiave `baldart.config.yml` — la fix riusa `base_file_sha` già presente).
11
+
12
+ > **Why.** Il drift deve segnalare *modifiche reali di contenuto*, non bump di versione. La radice comune era usare la VERSION come proxy di "il base è cambiato": un proxy sempre falso-positivo dato che VERSION sale a ogni release. La fix rende ogni layer **content-addressed** (SHA) e relega la VERSION a metadato informativo: (A) il marker porta solo `base_sha` + `overlay_sha` — entrambi cambiano *solo* su modifica reale → i file generati tornano byte-stabili tra update di sola-versione (il commit di reconcile scatta solo quando base o overlay cambiano davvero); (B) `doctor`/`status` diventano SHA-first come `overlay drift`, col confronto di versione come fallback solo quando manca `base_file_sha`. In più il pin `base_<kind>_version` viene rinfrescato al merge **solo quando lo SHA combacia** (base invariato) — così resta veritiero senza mai mascherare drift reale: se il base è cambiato lo SHA differisce e il pin stale resta un segnale legittimo da riconciliare. Retrocompatibile in lettura: `readMarker` tollera ancora i marker legacy con `base_version=` (i consumer pre-4.11 subiscono un diff one-shot al primo update, poi stabilità).
13
+
14
+ ### Changed
15
+
16
+ - **`src/utils/overlay-merger.js`** — `buildMarker()` non stampa più `base_version` (identità content-addressed: solo `base_sha` + `overlay_sha`); `readMarker()` rende `base_version` opzionale nella regex (legge sia il formato nuovo sia il legacy, `baseVersion: null` quando assente); call-site di `mergeOverlay` semplificato. Nuovo helper `refreshOverlayVersionPin(overlayPath, kind, frameworkVersion, currentBaseSha)` — rinfresca il pin di versione **solo** quando `base_file_sha` combacia col base corrente (no-op su mismatch → drift preservato; no-op su already-current; fail-safe su I/O). `baseVersionTargeted` ora usa `frameworkVersion` come fallback significativo invece di `'unknown'`.
17
+ - **`src/commands/doctor.js`** — `overlayDrift` SHA-first: confronta `base_file_sha` con lo SHA del base SKILL.md corrente; fallback al confronto di versione solo quando `base_file_sha` manca (o il base è illeggibile). Esclude i file `*.example.md`.
18
+ - **`src/commands/status.js`** — display drift SHA-first allineato al contratto di `overlay drift` (mostra `base changed: sha X → Y` su drift reale, `base unchanged` quando lo SHA combacia; fallback di versione solo senza `base_file_sha`).
19
+ - **`src/utils/symlinks.js`** — `_generateOverlayedFile` calcola `baseSha` una volta e, dopo il backfill di `base_file_sha`, chiama `refreshOverlayVersionPin` per tenere truthful il pin informativo quando il base è invariato.
20
+
21
+ ### Added
22
+
23
+ - **`src/utils/__tests__/overlay-merger.test.js`** — 7 test: stabilità byte-identica del merge tra bump di sola-versione (fix Trigger A), assenza di `base_version` nel marker generato, lettura back-compat del marker legacy, e i tre rami di `refreshOverlayVersionPin` (refresh su sha-match, no-op su sha-mismatch con drift preservato, no-op su already-current).
24
+
25
+ ### Fixed
26
+
27
+ - **`framework/.claude/skills/new/SKILL.md`** (Phase 3.7 Step A, card-scoped diff) — risoluzione della backlog card per nome file resa **case-insensitive** (`find -name` → `-iname`). Era l'unico punto in tutte le skill che risolve una card by-name, e `-name` è case-sensitive: i file su disco hanno prefisso maiuscolo (`FEAT-`/`BUG-`/`CHORE-`), ma se `CARD_ID` arriva con case diverso (es. `/new feat-0019`) `find` tornava **vuoto in silenzio** → `CARD_FILE=""` → la card veniva trattata come inesistente (stessa famiglia del case-collision docs/PRD noto). Stesso blocco: il placeholder `${paths.backlog_dir:-../../backlog}` non era bash valido (il punto nel nome variabile → `bad substitution` se eseguito verbatim) — ora emesso già risolto in una variabile `BACKLOG_DIR` (fallback `backlog`).
28
+ - **`framework/.claude/skills/worktree-manager/SKILL.md`** (`/nw` step 3b, sync card non-tracciate) — stesso fix di normalizzazione: il token `${paths.backlog_dir:-backlog}` letterale (bash non valido) sostituito da una variabile `BACKLOG_DIR` risolta, allineando il fallback (`backlog`) tra le due skill.
29
+
8
30
  ## [4.10.0] - 2026-06-04
9
31
 
10
32
  **`/new` ora ha una todo list persistente e una progress bar — durante un batch sai sempre quale fase gira, quale wave del team mode è in volo, e quale gate è risolto o skippato (con motivo).** Finora `/new` era un orchestratore autonomo cieco verso l'utente: l'unica traccia di stato era il tracker file interno `/tmp/batch-tracker-<FIRST-CARD-ID>.md`, che l'utente **non vede**. Risultato: in un run lungo (15 fasi per card, 50+ gate, sequential o team mode a wave) non avevi modo di capire dove fosse il processo. La fix replica il doppio meccanismo che dà a `/prd` la sua todo list amata, adattato alla struttura `wave → card → fase/gate`: (1) un **Task spine nativo** (TaskCreate/TaskUpdate) — il pannello todo sempre visibile della UI — tenuto *coarse*, un task per card etichettato per wave (`Wave 1 · FEAT-0502 — <title>`) più i framing task `Pre-flight`/`Final review`/`Merge & cleanup`; (2) una **Progress Bar markdown** emessa **alle transizioni** (cambio fase/wave/card, decisione gate, ogni `AskUserQuestion`/STOP — non a ogni messaggio, per non fare rumore nei tratti autonomi) con tabella card×wave + un **Gate ledger** che mostra ogni gate `✅ risolto` / `⏭️ skippato (+motivo)`. **MINOR** (capability aggiunta alla skill `/new`; nessuna chiave `baldart.config.yml` — usa solo i Task tool nativi e l'`execution_strategy` già esistente).
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.10.0
1
+ 4.11.0
@@ -1485,12 +1485,13 @@ skill's Phase 1 falls back to deriving Gherkin scenarios from
1485
1485
 
1486
1486
  Run this exact bash block in the worktree. It is deterministic (grep + path match), not LLM-discretionary.
1487
1487
 
1488
- `$TRUNK` is the trunk branch resolved in Phase 0 (`git.trunk_branch`, autodetected when the key is absent — see Phase 0 step 0). `$HIGH_RISK_RE` is an alternation built from `paths.high_risk_modules` in `baldart.config.yml` (e.g. `path1|path2|withAuth`). **If `paths.high_risk_modules` is absent, the path-based triggers (#1) emit a one-line diagnostic and match nothing — no hardcoded project path is baked in.**
1488
+ `$TRUNK` is the trunk branch resolved in Phase 0 (`git.trunk_branch`, autodetected when the key is absent — see Phase 0 step 0). `$HIGH_RISK_RE` is an alternation built from `paths.high_risk_modules` in `baldart.config.yml` (e.g. `path1|path2|withAuth`). **If `paths.high_risk_modules` is absent, the path-based triggers (#1) emit a one-line diagnostic and match nothing — no hardcoded project path is baked in.** `BACKLOG_DIR` is the value of `paths.backlog_dir` from `baldart.config.yml` (fallback `backlog` when the key is absent — emit it resolved, never the literal `${paths.backlog_dir}` token, which is not valid bash). The card lookup uses `-iname` (case-insensitive) on purpose: card filenames carry uppercase prefixes (`FEAT-`/`BUG-`/`CHORE-`) but `CARD_ID` may arrive lowercased (e.g. `/new feat-0019`), and a case-sensitive `-name` would return empty silently → the card would be treated as non-existent.
1489
1489
 
1490
1490
  ```bash
1491
1491
  cd <worktree-path>
1492
1492
  CARD_ID="<CARD-ID>"
1493
- CARD_FILE="$(find "${paths.backlog_dir:-../../backlog}" -name "${CARD_ID}*.yml" 2>/dev/null | head -1)"
1493
+ BACKLOG_DIR="<value of paths.backlog_dir, or 'backlog' if the key is absent>"
1494
+ CARD_FILE="$(find "$BACKLOG_DIR" -iname "${CARD_ID}*.yml" 2>/dev/null | head -1)"
1494
1495
  # Detect what THIS card's commits changed against the trunk (post-commit), with an uncommitted fallback:
1495
1496
  CHANGED="$(git diff --name-only "$TRUNK...HEAD" 2>/dev/null || git diff --name-only HEAD~1..HEAD)"
1496
1497
  # High-risk path alternation from config (empty when paths.high_risk_modules is unset):
@@ -453,13 +453,17 @@ Backlog cards created during `/prd` sessions may exist as untracked files in the
453
453
  but are NOT on the trunk branch yet. The worktree (branched from the trunk) won't have them.
454
454
 
455
455
  ```bash
456
+ # BACKLOG_DIR = value of paths.backlog_dir from baldart.config.yml (fallback `backlog`
457
+ # when the key is absent — emit it resolved, never the literal ${paths.backlog_dir}
458
+ # token, which is not valid bash). Same resolution as /new's card-scoped diff block.
459
+ BACKLOG_DIR="<value of paths.backlog_dir, or 'backlog' if the key is absent>"
456
460
  MAIN_ROOT="$(git -C "$WORKTREE_PATH" rev-parse --show-superproject-working-tree 2>/dev/null || pwd)"
457
461
 
458
462
  # For each card in the batch, check if its YAML exists in the main repo but not in the worktree
459
- for CARD_FILE in $(ls "$MAIN_ROOT/${paths.backlog_dir:-backlog}"/*.yml 2>/dev/null); do
463
+ for CARD_FILE in $(ls "$MAIN_ROOT/$BACKLOG_DIR"/*.yml 2>/dev/null); do
460
464
  BASENAME=$(basename "$CARD_FILE")
461
- if [ ! -f "$WORKTREE_PATH/${paths.backlog_dir:-backlog}/$BASENAME" ]; then
462
- cp "$CARD_FILE" "$WORKTREE_PATH/${paths.backlog_dir:-backlog}/$BASENAME"
465
+ if [ ! -f "$WORKTREE_PATH/$BACKLOG_DIR/$BASENAME" ]; then
466
+ cp "$CARD_FILE" "$WORKTREE_PATH/$BACKLOG_DIR/$BASENAME"
463
467
  echo "Synced untracked card: $BASENAME"
464
468
  fi
465
469
  done
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
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"
@@ -94,19 +94,39 @@ function countOverlays(cwd) {
94
94
  return counts('') + counts('agents') + counts('commands');
95
95
  }
96
96
 
97
+ // SHA-first drift detection for skill overlays, mirroring `overlay drift`
98
+ // (overlay.js): a pinned `base_file_sha` is the source of truth — an overlay is
99
+ // drifted only when the base SKILL.md content actually changed. The version is
100
+ // a fallback ONLY when no `base_file_sha` is present, because VERSION bumps on
101
+ // every release regardless of whether THIS base file changed; comparing it
102
+ // directly flagged every unchanged overlay as drifted after any update (v4.11.0).
97
103
  function overlayDrift(cwd, frameworkVersion) {
98
104
  if (!frameworkVersion) return [];
99
105
  const dir = path.join(cwd, '.baldart', 'overlays');
100
106
  if (!fileExists(dir)) return [];
107
+ let computeBaseFileSha;
108
+ try { ({ computeBaseFileSha } = require('../utils/overlay-merger')); }
109
+ catch (_) { computeBaseFileSha = null; }
101
110
  const drifted = [];
102
111
  try {
103
112
  for (const f of fs.readdirSync(dir)) {
104
- if (!f.endsWith('.md')) continue;
113
+ if (!f.endsWith('.md') || f.endsWith('.example.md')) continue;
105
114
  const txt = fs.readFileSync(path.join(dir, f), 'utf8');
106
115
  const fm = txt.match(/^---\n([\s\S]*?)\n---/);
107
116
  if (!fm) continue;
108
117
  try {
109
118
  const meta = yaml.load(fm[1]) || {};
119
+ const name = f.replace(/\.md$/, '');
120
+ const baseAbs = path.join(cwd, '.framework', 'framework', '.claude', 'skills', name, 'SKILL.md');
121
+ // SHA-first: trust the pinned base_file_sha when we can read the base.
122
+ if (meta.base_file_sha && computeBaseFileSha && fileExists(baseAbs)) {
123
+ const currentSha = computeBaseFileSha(fs.readFileSync(baseAbs, 'utf8'));
124
+ if (String(meta.base_file_sha) !== currentSha) {
125
+ drifted.push({ name: f, target: meta.base_skill_version || 'sha', installed: frameworkVersion });
126
+ }
127
+ continue;
128
+ }
129
+ // Fallback: no base_file_sha (or base unreadable) → version comparison.
110
130
  if (meta.base_skill_version && meta.base_skill_version !== frameworkVersion) {
111
131
  drifted.push({ name: f, target: meta.base_skill_version, installed: frameworkVersion });
112
132
  }
@@ -81,9 +81,16 @@ async function status() {
81
81
  if (overlays.length === 0) {
82
82
  UI.info('No overlays authored. Examples in: .framework/templates/overlays/');
83
83
  } else {
84
- // Best-effort version-drift check
84
+ // Best-effort drift check — SHA-first (mirrors `overlay drift`): the
85
+ // pinned base_file_sha is the source of truth; an overlay is drifted
86
+ // only when the base SKILL.md content actually changed. Version is a
87
+ // fallback ONLY when base_file_sha is absent, since VERSION bumps every
88
+ // release regardless of whether THIS base changed (v4.11.0).
85
89
  const yaml = require('js-yaml');
86
90
  const fwVersion = version;
91
+ let computeBaseFileSha;
92
+ try { ({ computeBaseFileSha } = require('../utils/overlay-merger')); }
93
+ catch (_) { computeBaseFileSha = null; }
87
94
  overlays.forEach((file) => {
88
95
  const full = path.join(overlaysDir, file);
89
96
  let line = `Overlay: ${file}`;
@@ -92,6 +99,21 @@ async function status() {
92
99
  const fmMatch = txt.match(/^---\n([\s\S]*?)\n---/);
93
100
  if (fmMatch) {
94
101
  const fm = yaml.load(fmMatch[1]) || {};
102
+ const name = file.replace(/\.md$/, '');
103
+ const baseAbs = path.join(process.cwd(), '.framework', 'framework', '.claude', 'skills', name, 'SKILL.md');
104
+ // SHA-first when we can read the base file.
105
+ if (fm.base_file_sha && computeBaseFileSha && fs.existsSync(baseAbs)) {
106
+ const currentSha = computeBaseFileSha(fs.readFileSync(baseAbs, 'utf8'));
107
+ if (String(fm.base_file_sha) !== currentSha) {
108
+ line += ` (base changed: sha ${fm.base_file_sha} → ${currentSha} — review for drift)`;
109
+ UI.warning(line);
110
+ return;
111
+ }
112
+ if (fm.base_skill_version) line += ` (v${fm.base_skill_version}, base unchanged)`;
113
+ UI.success(line);
114
+ return;
115
+ }
116
+ // Fallback: no base_file_sha → version comparison.
95
117
  if (fm.base_skill_version && fwVersion && fm.base_skill_version !== fwVersion) {
96
118
  line += ` (targets v${fm.base_skill_version}, installed v${fwVersion} — review for drift)`;
97
119
  UI.warning(line);
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit tests for overlay-merger marker stability + version-pin refresh (v4.11.0).
4
+ *
5
+ * Run: node src/utils/__tests__/overlay-merger.test.js
6
+ * Exits 0 on success, non-zero on failure (CI-friendly via node:test).
7
+ *
8
+ * Contract under test:
9
+ * - The generated-file marker is CONTENT-ADDRESSED only (base_sha + overlay_sha)
10
+ * so `mergeOverlay` produces BYTE-IDENTICAL output across version-only bumps —
11
+ * killing the per-update reconcile churn (Trigger A).
12
+ * - `readMarker` still reads legacy markers that carry `base_version=`.
13
+ * - `refreshOverlayVersionPin` only rewrites the pin when the base is unchanged
14
+ * (sha matches), never masking real drift.
15
+ */
16
+ const test = require('node:test');
17
+ const assert = require('node:assert');
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+
22
+ const {
23
+ mergeOverlay, readMarker, isGeneratedFile, computeBaseFileSha,
24
+ refreshOverlayVersionPin, MARKER_PREFIX,
25
+ } = require('../overlay-merger');
26
+
27
+ const BASE = `---
28
+ name: coder
29
+ description: coding agent
30
+ ---
31
+
32
+ Preamble line.
33
+
34
+ ## Role
35
+ You write code.
36
+
37
+ ## Rules
38
+ Be careful.
39
+ `;
40
+
41
+ const OVERLAY = `---
42
+ base_agent: coder
43
+ base_agent_version: 4.9.0
44
+ base_file_sha: "${computeBaseFileSha(BASE)}"
45
+ mode: extend
46
+ ---
47
+
48
+ ## [APPEND] Rules
49
+ Always run the linter.
50
+ `;
51
+
52
+ test('marker is byte-stable across version-only bumps (Trigger A fix)', () => {
53
+ const a = mergeOverlay({ kind: 'agent', name: 'coder', baseContent: BASE, overlayContent: OVERLAY, frameworkVersion: '4.10.0' });
54
+ const b = mergeOverlay({ kind: 'agent', name: 'coder', baseContent: BASE, overlayContent: OVERLAY, frameworkVersion: '4.11.0' });
55
+ assert.strictEqual(a.generated, b.generated, 'generated output must be identical when only frameworkVersion changes');
56
+ });
57
+
58
+ test('generated marker no longer stamps base_version', () => {
59
+ const { generated } = mergeOverlay({ kind: 'agent', name: 'coder', baseContent: BASE, overlayContent: OVERLAY, frameworkVersion: '4.11.0' });
60
+ assert.ok(generated.includes(MARKER_PREFIX), 'must carry the generated marker');
61
+ assert.ok(!/base_version=/.test(generated), 'marker must NOT contain base_version=');
62
+ assert.ok(/base_sha=/.test(generated), 'marker must keep base_sha=');
63
+ assert.ok(/overlay_sha=/.test(generated), 'marker must keep overlay_sha=');
64
+ });
65
+
66
+ test('readMarker reads NEW marker (no base_version)', () => {
67
+ const { generated } = mergeOverlay({ kind: 'agent', name: 'coder', baseContent: BASE, overlayContent: OVERLAY, frameworkVersion: '4.11.0' });
68
+ const m = readMarker(generated);
69
+ assert.ok(m, 'marker must be detected');
70
+ assert.strictEqual(m.kind, 'agent');
71
+ assert.strictEqual(m.name, 'coder');
72
+ assert.strictEqual(m.baseVersion, null, 'new markers have no base_version');
73
+ assert.ok(m.baseSha && m.overlaySha);
74
+ assert.ok(isGeneratedFile(generated));
75
+ });
76
+
77
+ test('readMarker still reads LEGACY marker (with base_version)', () => {
78
+ const legacy = `---\nname: coder\n---\n${MARKER_PREFIX} kind=agent name=coder base_version=4.5.0 base_sha=abc123def456 overlay_sha=deadbeef0000\n DO NOT EDIT. -->\n\n## Role\nx\n`;
79
+ const m = readMarker(legacy);
80
+ assert.ok(m, 'legacy marker must still parse');
81
+ assert.strictEqual(m.baseVersion, '4.5.0');
82
+ assert.strictEqual(m.baseSha, 'abc123def456');
83
+ assert.strictEqual(m.overlaySha, 'deadbeef0000');
84
+ assert.ok(isGeneratedFile(legacy));
85
+ });
86
+
87
+ function withTempOverlay(content, fn) {
88
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-pin-'));
89
+ const p = path.join(dir, 'coder.md');
90
+ fs.writeFileSync(p, content);
91
+ try { return fn(p); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
92
+ }
93
+
94
+ test('refreshOverlayVersionPin: refreshes when sha matches and version differs', () => {
95
+ withTempOverlay(OVERLAY, (p) => {
96
+ const r = refreshOverlayVersionPin(p, 'agent', '4.11.0', computeBaseFileSha(BASE));
97
+ assert.strictEqual(r.changed, true, r.reason);
98
+ assert.match(fs.readFileSync(p, 'utf8'), /base_agent_version:\s*4\.11\.0/);
99
+ });
100
+ });
101
+
102
+ test('refreshOverlayVersionPin: NO-OP when base sha mismatches (drift preserved)', () => {
103
+ withTempOverlay(OVERLAY, (p) => {
104
+ const r = refreshOverlayVersionPin(p, 'agent', '4.11.0', 'feedfacefeed'); // wrong sha → base changed
105
+ assert.strictEqual(r.changed, false);
106
+ assert.match(fs.readFileSync(p, 'utf8'), /base_agent_version:\s*4\.9\.0/, 'stale pin must remain as a drift signal');
107
+ });
108
+ });
109
+
110
+ test('refreshOverlayVersionPin: NO-OP when already current', () => {
111
+ const overlayCurrent = OVERLAY.replace('4.9.0', '4.11.0');
112
+ withTempOverlay(overlayCurrent, (p) => {
113
+ const r = refreshOverlayVersionPin(p, 'agent', '4.11.0', computeBaseFileSha(BASE));
114
+ assert.strictEqual(r.changed, false);
115
+ assert.strictEqual(r.reason, 'already-current');
116
+ });
117
+ });
@@ -81,9 +81,61 @@ function ensureBaseFileShaInOverlay(overlayPath, baseSha) {
81
81
  return { changed: true, reason: 'backfilled' };
82
82
  }
83
83
 
84
- function buildMarker({ kind, name, baseVersion, baseSha, overlaySha }) {
84
+ // The marker identity is CONTENT-ADDRESSED only: `base_sha` + `overlay_sha`,
85
+ // both of which change exclusively when the base file or the overlay actually
86
+ // change. `base_version` is deliberately NOT stamped here — VERSION bumps on
87
+ // every framework release regardless of whether THIS base file changed, so
88
+ // embedding it made every `baldart update` rewrite this line and produce a
89
+ // spurious git diff on byte-identical generated files (→ a "post-update
90
+ // reconcile" commit every single update). The field was informational only
91
+ // (readMarker parsed it but nothing gated regeneration on it), so dropping it
92
+ // buys byte-stability across version-only bumps at zero cost. (v4.11.0)
93
+ /**
94
+ * Refresh the overlay's informational `base_<kind>_version` pin to the installed
95
+ * VERSION — but ONLY when the base content is unchanged (the overlay's
96
+ * `base_file_sha` still matches the current base sha). This keeps the pin
97
+ * truthful (it tracks the version the overlay was last reconciled against)
98
+ * without ever masking real drift: when the base content HAS changed the sha
99
+ * differs, so we leave the stale pin alone and it remains a legitimate
100
+ * "review me" signal for `overlay drift` / `status` / `doctor`.
101
+ *
102
+ * Contract: NO-OP unless `base_file_sha` is present AND equals `currentBaseSha`
103
+ * AND the version field actually differs. Fail-safe: any I/O error returns
104
+ * `changed:false` so it can never block a merge. (v4.11.0)
105
+ *
106
+ * @param {string} overlayPath absolute path to `.baldart/overlays/<…>.md`
107
+ * @param {string} kind 'agent' | 'command' | 'skill'
108
+ * @param {string} frameworkVersion installed framework VERSION
109
+ * @param {string} currentBaseSha computeBaseFileSha(currentBaseContent)
110
+ * @returns {{ changed: boolean, reason: string }}
111
+ */
112
+ function refreshOverlayVersionPin(overlayPath, kind, frameworkVersion, currentBaseSha) {
113
+ if (!frameworkVersion || !currentBaseSha) return { changed: false, reason: 'missing-input' };
114
+ let content;
115
+ try { content = fs.readFileSync(overlayPath, 'utf8'); }
116
+ catch (_) { return { changed: false, reason: 'unreadable' }; }
117
+ const m = content.match(/^(?\s*)---\n([\s\S]*?)\n---(\r?\n|$)/);
118
+ if (!m) return { changed: false, reason: 'no-frontmatter' };
119
+ const fmBody = m[2];
120
+ // Only refresh when the base is provably unchanged — never mask drift.
121
+ const shaMatch = fmBody.match(/^\s*base_file_sha\s*:\s*["']?([0-9a-f]+)["']?\s*$/m);
122
+ if (!shaMatch || shaMatch[1] !== currentBaseSha) return { changed: false, reason: 'sha-mismatch-or-absent' };
123
+ const versionKey = `base_${kind}_version`;
124
+ const verRe = new RegExp(`^(\\s*${versionKey}\\s*:\\s*).*$`, 'm');
125
+ if (!verRe.test(fmBody)) return { changed: false, reason: 'no-version-field' };
126
+ const current = fmBody.match(verRe)[0].split(':').slice(1).join(':').trim();
127
+ if (current === String(frameworkVersion)) return { changed: false, reason: 'already-current' };
128
+ const newFmBody = fmBody.replace(verRe, `$1${frameworkVersion}`);
129
+ const replaced = `${m[1]}---\n${newFmBody}\n---${m[3]}`;
130
+ const updated = content.slice(0, m.index) + replaced + content.slice(m.index + m[0].length);
131
+ try { fs.writeFileSync(overlayPath, updated, 'utf8'); }
132
+ catch (_) { return { changed: false, reason: 'unwritable' }; }
133
+ return { changed: true, reason: 'refreshed' };
134
+ }
135
+
136
+ function buildMarker({ kind, name, baseSha, overlaySha }) {
85
137
  const baseShaField = baseSha ? ` base_sha=${baseSha}` : '';
86
- return `${MARKER_PREFIX} kind=${kind} name=${name} base_version=${baseVersion}${baseShaField} overlay_sha=${overlaySha}
138
+ return `${MARKER_PREFIX} kind=${kind} name=${name}${baseShaField} overlay_sha=${overlaySha}
87
139
  DO NOT EDIT MANUALLY. Edit .baldart/overlays/${kind}s/${name}.md instead;
88
140
  this file is regenerated by \`npx baldart update\`. -->`;
89
141
  }
@@ -98,6 +150,9 @@ function buildMarker({ kind, name, baseVersion, baseSha, overlaySha }) {
98
150
  * - immediately after the closing `---` of the YAML frontmatter (v3.26.1+)
99
151
  *
100
152
  * `base_sha` is optional — older generated files (pre-v3.19.0) won't have it.
153
+ * `base_version` is optional too: v4.11.0+ stops stamping it (see buildMarker),
154
+ * but files generated by older versions still carry it, so the regex tolerates
155
+ * either shape and surfaces `baseVersion` as null when absent.
101
156
  */
102
157
  function readMarker(content) {
103
158
  const idx = content.indexOf(MARKER_PREFIX);
@@ -107,9 +162,9 @@ function readMarker(content) {
107
162
  const block = content.slice(idx, idx + 4000);
108
163
  const end = block.indexOf('-->');
109
164
  if (end === -1) return null;
110
- const m = block.slice(0, end).match(/kind=(\S+)\s+name=(\S+)\s+base_version=(\S+)(?:\s+base_sha=(\S+))?\s+overlay_sha=(\S+)/);
165
+ const m = block.slice(0, end).match(/kind=(\S+)\s+name=(\S+)(?:\s+base_version=(\S+))?(?:\s+base_sha=(\S+))?\s+overlay_sha=(\S+)/);
111
166
  if (!m) return null;
112
- return { kind: m[1], name: m[2], baseVersion: m[3], baseSha: m[4] || null, overlaySha: m[5] };
167
+ return { kind: m[1], name: m[2], baseVersion: m[3] || null, baseSha: m[4] || null, overlaySha: m[5] };
113
168
  }
114
169
 
115
170
  /**
@@ -191,7 +246,8 @@ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersio
191
246
  warnings.push(`overlay frontmatter parse error (${err.message}) — treating overlay as plain markdown without metadata`);
192
247
  }
193
248
  }
194
- const baseVersionTargeted = String(overlayMeta[`base_${kind}_version`] || '').trim() || 'unknown';
249
+ const baseVersionTargeted = String(overlayMeta[`base_${kind}_version`] || '').trim()
250
+ || (frameworkVersion ? String(frameworkVersion) : 'unknown');
195
251
  const overlayMode = (overlayMeta.mode || 'extend').trim();
196
252
 
197
253
  // Parse the base. Keep its frontmatter verbatim — that's what Claude reads.
@@ -239,13 +295,7 @@ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersio
239
295
 
240
296
  const overlaySha = shortSha(overlayContent);
241
297
  const baseSha = shortSha(baseContent);
242
- const marker = buildMarker({
243
- kind,
244
- name,
245
- baseVersion: frameworkVersion || baseVersionTargeted,
246
- baseSha,
247
- overlaySha
248
- });
298
+ const marker = buildMarker({ kind, name, baseSha, overlaySha });
249
299
 
250
300
  const fmBlock = baseParsed.frontmatter
251
301
  ? `---\n${baseParsed.frontmatter}\n---\n`
@@ -280,5 +330,6 @@ module.exports = {
280
330
  shortSha,
281
331
  computeBaseFileSha,
282
332
  ensureBaseFileShaInOverlay,
333
+ refreshOverlayVersionPin,
283
334
  MARKER_PREFIX
284
335
  };
@@ -484,16 +484,23 @@ class SymlinkUtils {
484
484
  }
485
485
 
486
486
  _generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
487
- const { mergeOverlay, isGeneratedFile, computeBaseFileSha, ensureBaseFileShaInOverlay } = require('./overlay-merger');
487
+ const { mergeOverlay, isGeneratedFile, computeBaseFileSha, ensureBaseFileShaInOverlay, refreshOverlayVersionPin } = require('./overlay-merger');
488
488
  const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
489
+ const baseSha = computeBaseFileSha(baseContent);
489
490
  // Backfill base_file_sha into the overlay frontmatter so `overlay drift` is
490
491
  // deterministic for EVERY overlay, not just freshly-scaffolded ones
491
492
  // (additive-only, never overwrites). Done BEFORE reading overlayContent so
492
493
  // the marker's overlay_sha already reflects it — no one-time regen churn.
493
494
  // (v3.33.0)
494
495
  try {
495
- const bf = ensureBaseFileShaInOverlay(overlayAbs, computeBaseFileSha(baseContent));
496
+ const bf = ensureBaseFileShaInOverlay(overlayAbs, baseSha);
496
497
  if (bf.changed) UI.info(`[${adapter.label}] ${kind} ${name}: backfilled base_file_sha into overlay frontmatter.`);
498
+ // Keep the informational base_<kind>_version pin truthful — but ONLY when
499
+ // the base content is unchanged (sha matches). When the base HAS changed
500
+ // the sha differs and the pin is left stale on purpose, so it stays a
501
+ // legitimate drift signal to reconcile. (v4.11.0)
502
+ const vp = refreshOverlayVersionPin(overlayAbs, kind, frameworkVersion, baseSha);
503
+ if (vp.changed) UI.info(`[${adapter.label}] ${kind} ${name}: refreshed base_${kind}_version pin to v${frameworkVersion} (base unchanged).`);
497
504
  } catch (_) { /* non-fatal — never block a merge on backfill */ }
498
505
  const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
499
506
  const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });