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 +22 -0
- package/VERSION +1 -1
- package/framework/.claude/skills/new/SKILL.md +3 -2
- package/framework/.claude/skills/worktree-manager/SKILL.md +7 -3
- package/package.json +1 -1
- package/src/commands/doctor.js +21 -1
- package/src/commands/status.js +23 -1
- package/src/utils/__tests__/overlay-merger.test.js +117 -0
- package/src/utils/overlay-merger.js +63 -12
- package/src/utils/symlinks.js +9 -2
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.
|
|
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
|
-
|
|
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/$
|
|
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/$
|
|
462
|
-
cp "$CARD_FILE" "$WORKTREE_PATH/$
|
|
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
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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}
|
|
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+)
|
|
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()
|
|
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
|
};
|
package/src/utils/symlinks.js
CHANGED
|
@@ -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,
|
|
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 });
|