baldart 4.9.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 +32 -0
- package/VERSION +1 -1
- package/framework/.claude/skills/new/SKILL.md +88 -5
- 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,38 @@ 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
|
+
|
|
30
|
+
## [4.10.0] - 2026-06-04
|
|
31
|
+
|
|
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).
|
|
33
|
+
|
|
34
|
+
> **Why.** Il valore è la *visibilità*, ma il vincolo è non trasformare il ledger in una licenza di skip. Per costruzione: ogni entry `⏭️` deve citare un motivo **verbatim** dalle skip-reason già enumerate nelle Gate table (`IS_TRIVIAL`, `review_profile=light`, `backend-only diff`, `features.has_e2e_review:false`, `balanced→Final`, `holistic_audit provenance`); un `⏭️ (time budget)` / `(to save tokens)` è esso stesso una violazione di protocollo per la clausola top-of-file "NO PHASE SKIP FOR PERCEIVED TIME". Il ledger rende i skip **auditabili**, non li autorizza. Il tracker interno resta la SSOT di recovery; il Task spine + Progress Bar sono il suo specchio live — esattamente il pattern `/prd` state-file ↔ progress bar.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`framework/.claude/skills/new/SKILL.md`** — nuova sezione autoritativa **"Progress Visibility (MANDATORY)"** (subito dopo "Context Tracking"): definisce una volta sola (A) il Task spine nativo per-card etichettato per wave con la tabella delle transizioni di stato (`Pre-flight`/card/`Final review`/`Merge & cleanup`), (B) il format e i trigger della Progress Bar a transizione con il Gate ledger e la regola dei motivi-verbatim, (C) il mapping fase→cella del ledger (2.5b/2.55/2.6/3/3.5/3.7 + i gate di setup). Reminder a una riga `→ Visibility` nei punti di transizione già esistenti — Pre-flight (creazione spine dopo la risoluzione di modalità/wave), Phase 1 step 2b (claim → card `in_progress`), Phase 4 step 29 (DONE → card `completed`), team mode Step B/D.6/E (confini di wave) e Step F.1/Phase 6 (transizioni di batch) — senza duplicare logica in ogni fase. Aggiunta una "Update rule" del tracker che lega ogni transizione interna ai due surface visibili.
|
|
39
|
+
|
|
8
40
|
## [4.9.0] - 2026-06-04
|
|
9
41
|
|
|
10
42
|
**`prd-card-writer` Rule C apre `light` alle card UI-presentazionali — la metà `-ui` di una coppia split non paga più una review Codex piena su un diff di pixel.** Finora il pavimento era assoluto: *ogni* card `feature`/`enhancement` era `balanced` minimo, mai `light`. La regola nasceva giusta (non sotto-revisionare feature che *sembrano* piccole ma nascondono logica), ma non distingueva una UI card che *contiene* logica da quella genuinamente presentazionale — tipicamente la metà `-ui` di una coppia `logic/ui` già splittata dalla atomicity rule (es. `…-submit-ui`, la cui logica vive nella card sorella `…-submit-logic`). Risultato: su un epic come FEAT-0019, card puramente compositive venivano marcate `balanced` → review Codex-`full` per-card su un diff di soli componenti. Nuovo **ramo LIGHT (b)**, recintato e deterministico: scatta SOLO se TUTTE valgono — card pure-UI (`areas == [ui]`, o `owner_agent: ui-expert` quando `areas` è assente) · ≤3 file, tutti componenti/stili (zero data-fetching / state / API / server) · **nessun path sotto `paths.components_primitives`** (compone primitive esistenti, non ne crea/modifica) · nessun trigger DEEP. **MINOR** (nuova capability di routing in Rule C; nessuna chiave `baldart.config.yml` — si appoggia su `areas`/`owner_agent`/`paths.components_primitives` già esistenti).
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.11.0
|
|
@@ -129,6 +129,78 @@ Trunk branch: [resolved git.trunk_branch — Phase 0 step 0 populates]
|
|
|
129
129
|
- **card_status: DONE (verified)** — confirms the backlog YAML was updated and re-read to verify
|
|
130
130
|
- **When blocked**: log the blocker in `## Issues & Flags`.
|
|
131
131
|
- **On context recovery**: if you ever feel lost or after context compaction, IMMEDIATELY read your tracker file (`/tmp/batch-tracker-<FIRST-CARD-ID>.md`) to restore your state.
|
|
132
|
+
- **Mirror every transition to the user (MANDATORY)**: this tracker file is internal — the user does NOT see it. Every transition you log here MUST also be reflected in the **two user-visible surfaces** defined in § "Progress Visibility": the native **Task spine** (TaskUpdate) and, at transition boundaries, the **Progress Bar**. The tracker is the recovery SSOT; the Task spine + Progress Bar are its live mirror.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Progress Visibility (MANDATORY)
|
|
137
|
+
|
|
138
|
+
The `/tmp/batch-tracker-*.md` file is your **internal** recovery SSOT — the user never sees it. Because `/new` runs largely autonomously (YOLO mode, long stretches of agent spawns), the user otherwise has no idea which phase is running, which wave of the team mode is in flight, or which gate was just resolved vs skipped. This section defines the **two user-visible surfaces** that fix that. They are the live mirror of the tracker; keeping them in sync is non-negotiable.
|
|
139
|
+
|
|
140
|
+
> **HARD RULE — visibility.** (1) Maintain the native **Task spine** via TaskCreate/TaskUpdate. (2) Emit the **Progress Bar** at every transition boundary (defined below). Both mirror the tracker. Failing to update either after a transition is a protocol violation.
|
|
141
|
+
|
|
142
|
+
### A. Task spine — native TaskCreate/TaskUpdate (the always-visible panel)
|
|
143
|
+
|
|
144
|
+
The native task list is what Claude Code renders as a **persistent, always-visible todo panel** (this is exactly how `/prd` gets its beloved todo list — see `framework/.claude/skills/prd/SKILL.md` HARD RULE 4). Keep it **coarse**: one task per card plus a few batch-level framing tasks. Do NOT create a task per phase (that buries the overview).
|
|
145
|
+
|
|
146
|
+
**When to create (once):** right after Pre-flight has resolved the execution mode (sequential vs team) and, in team mode, the wave layout (`execution_strategy.groups[].level`) — because only then do you know the wave label for each card. Create, in this order:
|
|
147
|
+
|
|
148
|
+
1. `Pre-flight` — framing task (already in progress by the time you create the spine; mark it `in_progress` immediately and `completed` as soon as the worktree is ready).
|
|
149
|
+
2. One task **per card**, in queue order. Subject:
|
|
150
|
+
- **team mode** (wave-labelled): `Wave <level> · <CARD-ID> — <title>` (e.g. `Wave 1 · FEAT-0502 — Add merchant filter`).
|
|
151
|
+
- **sequential mode**: `<CARD-ID> — <title>` (no wave prefix).
|
|
152
|
+
3. `Final review` — framing task.
|
|
153
|
+
4. `Merge & cleanup` — framing task.
|
|
154
|
+
|
|
155
|
+
**State transitions (TaskUpdate):**
|
|
156
|
+
|
|
157
|
+
| Task | → `in_progress` | → `completed` |
|
|
158
|
+
|------|-----------------|---------------|
|
|
159
|
+
| `Pre-flight` | at spine creation | worktree ready (end of Pre-flight) |
|
|
160
|
+
| card task | **Phase 1 step 2b** (claim — when you set the card `status: IN_PROGRESS`) | **Phase 4 step 28** (after commit + `card_status: DONE (verified)`) |
|
|
161
|
+
| `Final review` | start of Final Review (F.1) | Final Review complete (fixes applied, build green) |
|
|
162
|
+
| `Merge & cleanup` | start of Phase 6 | end of Phase 6c |
|
|
163
|
+
|
|
164
|
+
**Live sub-state in the subject (optional but encouraged):** while a card task is `in_progress`, you MAY append the current phase to its subject so the panel shows live movement between Progress Bar emissions — e.g. `Wave 1 · FEAT-0502 — Add merchant filter · Phase 3.7 Codex 🔄`. Strip the suffix when the card completes.
|
|
165
|
+
|
|
166
|
+
In team mode the cards of a wave run in parallel: set ALL of a wave's card tasks `in_progress` when that wave's coders are spawned (Step B), and complete each as its per-card pipeline reaches Phase 4 (team Step D.5/D.6).
|
|
167
|
+
|
|
168
|
+
### B. Progress Bar — markdown, at transition boundaries
|
|
169
|
+
|
|
170
|
+
Append this block to your message **at every transition boundary** (NOT on every message — that would be noise across long autonomous runs). A **transition boundary** is any of: a phase change, a wave change, a card change, **any gate decision** (resolved or skipped), and every `AskUserQuestion` / STOP.
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
---
|
|
174
|
+
📋 **Progresso /new: <batch-id>** — modalità <sequential|team> · Wave <X>/<Y>
|
|
175
|
+
|
|
176
|
+
| Card | Wave | Fase corrente | Stato |
|
|
177
|
+
|------|------|---------------|-------|
|
|
178
|
+
| FEAT-0501 | 0 | Phase 4 Commit | ✅ |
|
|
179
|
+
| FEAT-0502 | 1 | Phase 3.7 Codex | 🔄 |
|
|
180
|
+
| FEAT-0503 | 1 | — | ⬜ |
|
|
181
|
+
|
|
182
|
+
Gate ledger (card corrente): 2.5b AC-Closure ✅ · 2.55 Simplify ✅ · 2.6 E2E ⏭️ (backend-only) · 3 Doc ⏭️ (light, no-doc-diff→Final) · 3.5 QA ⏭️ (balanced→Final) · 3.7 Codex 🔄
|
|
183
|
+
Prossimo passo: <cosa succede dopo>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- Legend: `⬜ da fare · 🔄 in corso · ✅ risolto · ⏭️ skippato`.
|
|
187
|
+
- The `Wave` column and the `Wave <X>/<Y>` header are present in **team mode** only; in sequential mode drop the column and write `modalità sequential` with no wave counter.
|
|
188
|
+
- The **Gate ledger** line shows the current card's per-card gates (see § C for which). Each entry is `<gate> <state>`; a skipped entry MUST carry its reason **verbatim from the enumerated Gate-table / fast-lane skip reasons** — e.g. `IS_TRIVIAL`, `review_profile=light`, `backend-only diff`, `features.has_e2e_review:false`, `balanced→Final`, `holistic_audit provenance`. **Never invent a reason.** A ledger entry reading `⏭️ (time budget)` / `(to save tokens)` / any model-invented constraint is itself a protocol violation (see the top-of-file "NO PHASE SKIP FOR PERCEIVED TIME" clause) — the ledger exists to make skips auditable, not to license them.
|
|
189
|
+
|
|
190
|
+
### C. Phase → ledger mapping
|
|
191
|
+
|
|
192
|
+
These are the per-card gates tracked in the Gate ledger line (the rest of the pipeline — Phase 1 context, Phase 2 implement, Phase 4 commit — is shown in the table's "Fase corrente" column, not the ledger):
|
|
193
|
+
|
|
194
|
+
| Ledger entry | Phase | Skipped when (enumerated reason) |
|
|
195
|
+
|--------------|-------|----------------------------------|
|
|
196
|
+
| `2.5b AC-Closure` | Phase 2.5b | never (BLOCKING, unconditional) |
|
|
197
|
+
| `2.55 Simplify` | Phase 2.55 | `IS_TRIVIAL` |
|
|
198
|
+
| `2.6 E2E` | Phase 2.6 | `features.has_e2e_review:false`, backend-only diff, or card type ∈ {backend/api/db/infra/docs/chore/config} |
|
|
199
|
+
| `3 Doc` | Phase 3 | `review_profile=light` AND no doc files in diff → deferred to Final F.3 |
|
|
200
|
+
| `3.5 QA` | Phase 3.5 | `skip`/`light` (tests already ran Phase 2), or `balanced`→Final (unless Step-A escalation) |
|
|
201
|
+
| `3.7 Codex` | Phase 3.7 | `IS_TRIVIAL` only (otherwise unconditional; `light`/`full` is depth, not skip) |
|
|
202
|
+
|
|
203
|
+
In **team mode** the same gates map to the per-card team sub-steps (D.3a AC-Closure, D.3b Simplify, D.3c E2E, D.4 QA, D.4a/D.2 doc, D.4b Codex) — track them identically. The pre-flight cross-card Codex check (Step 3d) and plan-auditor grounding (Phase 1 step 4) are batch/card-setup gates: surface their resolve/skip in the `Prossimo passo` line or a one-off ledger note, not as recurring per-card rows.
|
|
132
204
|
|
|
133
205
|
---
|
|
134
206
|
|
|
@@ -274,6 +346,8 @@ Trunk branch: [resolved git.trunk_branch — Phase 0 step 0 populates]
|
|
|
274
346
|
|
|
275
347
|
When `mode == sequential`, the per-card pipeline below runs exactly as documented. The `execution_strategy.groups` levels are simply ignored. When `mode == team`, skip the per-card pipeline and follow the **Team Mode** section at the end of this document.
|
|
276
348
|
|
|
349
|
+
**→ Create the Task spine now.** The execution mode and (in team mode) the wave layout are resolved — create the native Task spine per § "Progress Visibility" A: `Pre-flight` (→ `in_progress`) + one task per card (wave-labelled in team mode) + `Final review` + `Merge & cleanup`. Emit the first Progress Bar with this batch's table. Mark `Pre-flight` → `completed` once worktree setup (step 5) finishes.
|
|
350
|
+
|
|
277
351
|
3d. **Codex batch cross-card grounding check** (runs in background during worktree setup)
|
|
278
352
|
|
|
279
353
|
> **Why**: a non-Anthropic frontier model (Codex, via `codex-companion.mjs`) reviews the full card batch for cross-card conflicts that per-card plan-auditor checks cannot detect (each plan-auditor sees only one card).
|
|
@@ -493,7 +567,7 @@ For each card, execute these phases in order:
|
|
|
493
567
|
- Read that card's backlog YAML and check its `status` field.
|
|
494
568
|
- If NOT `DONE` → HALT: log in `## Issues & Flags` and ask the user: "Card <CARD-ID> depends on <DEP-ID> which is `<status>`. Proceed anyway, or wait?" Do not start implementation until the user responds explicitly. **The card status is NOT written until this gate passes** — so a HALT leaves the card untouched (no stale `IN_PROGRESS` for the next run to mis-read).
|
|
495
569
|
- If `DONE` (or the user chose "proceed anyway") → continue.
|
|
496
|
-
2b. **Claim** — only now set the card status to `IN_PROGRESS` and assign yourself.
|
|
570
|
+
2b. **Claim** — only now set the card status to `IN_PROGRESS` and assign yourself. **→ Visibility**: TaskUpdate this card's spine task → `in_progress`, and emit a Progress Bar (card change) per § "Progress Visibility".
|
|
497
571
|
2c. **Trivial-card classification (BLOCKING gate for steps 3–4)** — evaluate `IS_TRIVIAL(card)` per § "Trivial-card fast-lane". Note: condition 3 (non-source diff) cannot be fully evaluated until the coder has produced the diff, so at this point compute the **provisional** trivial flag from conditions 1+2 only (`review_profile == skip` AND no Step-A trigger sourced from the card YAML text + `files_likely_touched` extensions — if EVERY path in `files_likely_touched` is non-source, condition 3 is provisionally satisfied). If provisionally trivial → **SKIP steps 3, 3a, and 4** (architecture grounding); log `trivial: architecture grounding skipped (review_profile=skip + non-source files_likely_touched + 0 triggers)` and jump to Phase 2. Re-confirm `IS_TRIVIAL` on the ACTUAL committed diff at the review gates (Phase 2.55/3.5/3.7); if the coder unexpectedly touched a source file, the guard flips the card back onto the normal review path there. If NOT provisionally trivial → run steps 3, 3a, 4 as normal.
|
|
498
572
|
3. **(skip when provisionally trivial — see 2c)** Invoke the **codebase-architect** agent (MUST per AGENTS.md) to understand the relevant codebase area, existing patterns, and architecture before any implementation. When `features.has_lsp_layer: true`, the architect uses LSP find-references for identifier-shaped lookups — this needs NO handoff from the orchestrator: the architect reads `features.has_lsp_layer` from `baldart.config.yml` directly (the flag is ambient) per `agents/code-search-protocol.md`. The orchestrator does NOT propagate the flag. (Earlier doc versions numbered this step 4; the step that read project-status BEFORE the architect was removed because it persisted pre-analysis context — see step 3a.)
|
|
499
573
|
3a. Update `${paths.references_dir}/project-status.md` Active Code Context (skip when the file does not exist in the project) — do this AFTER the codebase-architect run (step 3) so the "Active Code Context" reflects the architect's findings (which files are actually in scope), not just the card YAML's `files_likely_touched`. Writing it before the architect run would persist pre-analysis claims that downstream agents (e.g. a parallel card) would then read as truth.
|
|
@@ -1411,12 +1485,13 @@ skill's Phase 1 falls back to deriving Gherkin scenarios from
|
|
|
1411
1485
|
|
|
1412
1486
|
Run this exact bash block in the worktree. It is deterministic (grep + path match), not LLM-discretionary.
|
|
1413
1487
|
|
|
1414
|
-
`$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.
|
|
1415
1489
|
|
|
1416
1490
|
```bash
|
|
1417
1491
|
cd <worktree-path>
|
|
1418
1492
|
CARD_ID="<CARD-ID>"
|
|
1419
|
-
|
|
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)"
|
|
1420
1495
|
# Detect what THIS card's commits changed against the trunk (post-commit), with an uncommitted fallback:
|
|
1421
1496
|
CHANGED="$(git diff --name-only "$TRUNK...HEAD" 2>/dev/null || git diff --name-only HEAD~1..HEAD)"
|
|
1422
1497
|
# High-risk path alternation from config (empty when paths.high_risk_modules is unset):
|
|
@@ -1599,7 +1674,7 @@ The detector (Step A) is bash + grep — guaranteed to run, no LLM skip. The dow
|
|
|
1599
1674
|
c. **Update `${paths.references_dir}/ssot-registry.md`** — add/update the entry for this card's feature area. The pre-commit doc-freshness hook BLOCKS commits that touch `${paths.backlog_dir}/` without a corresponding ssot-registry update. Always include ssot-registry.md in the same commit as the backlog YAML.
|
|
1600
1675
|
d. **Verify the write**: re-read the YAML file and confirm `status: DONE` is present. If not, retry the edit.
|
|
1601
1676
|
e. Stage BOTH the updated YAML AND ssot-registry.md, then commit (or as an immediate follow-up commit if the Phase 4 implementation commit already happened). When this produces a SECOND commit for the card, record BOTH hashes in the tracker (`commit: <impl-hash> + <done-hash>`) so traceability/bisect is unambiguous.
|
|
1602
|
-
29. **Update tracker**: move card to `## Completed Cards` with commit hash(es), summary, flags, **and `card_status: DONE (verified)`**.
|
|
1677
|
+
29. **Update tracker**: move card to `## Completed Cards` with commit hash(es), summary, flags, **and `card_status: DONE (verified)`**. **→ Visibility**: TaskUpdate this card's spine task → `completed` (strip any live phase suffix) and emit a Progress Bar (card change) per § "Progress Visibility".
|
|
1603
1678
|
|
|
1604
1679
|
### Sub-agent failure protocol (since v3.28.3)
|
|
1605
1680
|
|
|
@@ -1649,6 +1724,8 @@ Once ALL cards are committed in the worktree:
|
|
|
1649
1724
|
|
|
1650
1725
|
### Step F.1 — Resolve scope
|
|
1651
1726
|
|
|
1727
|
+
**→ Visibility (batch transition)**: all cards are committed. TaskUpdate `Final review` → `in_progress` and emit a Progress Bar per § "Progress Visibility". Mark `Final review` → `completed` when F.5 finishes (fixes applied, build green).
|
|
1728
|
+
|
|
1652
1729
|
1. **Read the tracker file** to get the full picture: card IDs, files changed, commit hashes.
|
|
1653
1730
|
2. Gather git evidence in the worktree:
|
|
1654
1731
|
```bash
|
|
@@ -1882,6 +1959,8 @@ Once ALL cards are committed in the worktree:
|
|
|
1882
1959
|
|
|
1883
1960
|
## Phase 6 — Post-batch merge & cleanup (delegated to worktree-manager skill)
|
|
1884
1961
|
|
|
1962
|
+
**→ Visibility (batch transition)**: TaskUpdate `Merge & cleanup` → `in_progress` and emit a Progress Bar per § "Progress Visibility". Mark it → `completed` at the end of Phase 6c (merge done, worktree removed, workspace reconciled).
|
|
1963
|
+
|
|
1885
1964
|
After the final review passes AND all cards are committed in the worktree, delegate the entire merge and cleanup to the **worktree-manager** skill (`/mw` in programmatic mode):
|
|
1886
1965
|
|
|
1887
1966
|
1. **BEFORE invoking /mw** — verify no uncommitted files remain in the worktree:
|
|
@@ -2365,6 +2444,8 @@ its `arch_baseline_path` at this file, so the review reuses the group baseline i
|
|
|
2365
2444
|
|
|
2366
2445
|
#### Step B: Spawn parallel coder agents
|
|
2367
2446
|
|
|
2447
|
+
**→ Visibility (wave change)**: this is a new wave starting. TaskUpdate ALL of this group's card spine tasks → `in_progress`, and emit a Progress Bar with the new `Wave <X>/<Y>` header per § "Progress Visibility".
|
|
2448
|
+
|
|
2368
2449
|
For each card in the current group, spawn a coder agent using the Agent tool. ALL agents for the group MUST be spawned in a **SINGLE message** (multiple Agent tool calls) to run truly in parallel.
|
|
2369
2450
|
|
|
2370
2451
|
Each coder agent receives a **SELF-CONTAINED** mission briefing that includes EVERYTHING it needs — it will NOT call codebase-architect or plan-auditor itself:
|
|
@@ -2513,6 +2594,7 @@ After ALL agents in the group complete successfully:
|
|
|
2513
2594
|
b. **Verify the write**: re-read the YAML file and confirm `status: DONE` is present. If not, retry.
|
|
2514
2595
|
c. Stage the updated YAML and include it in the card's commit (or as an immediate follow-up commit).
|
|
2515
2596
|
d. Log in tracker: `card_status: DONE (verified)` for each card.
|
|
2597
|
+
e. **→ Visibility**: TaskUpdate each card's spine task → `completed` (strip any live phase suffix) as it reaches DONE here.
|
|
2516
2598
|
Note: Phase 6b (Status Reconciliation) will catch any card missed here, but aim for zero misses.
|
|
2517
2599
|
|
|
2518
2600
|
#### Step D coverage assertion (MANDATORY end-of-group check)
|
|
@@ -2535,7 +2617,8 @@ A missing entry means a sub-step was skipped. An entry whose value is a **`revie
|
|
|
2535
2617
|
After committing all cards in the group:
|
|
2536
2618
|
1. Update tracker: move group to done, log all results per card.
|
|
2537
2619
|
2. **PURGE**: forget all implementation details, review findings, architect context.
|
|
2538
|
-
3.
|
|
2620
|
+
3. **→ Visibility**: emit a Progress Bar reflecting the wave boundary (this wave done; next `Wave <X+1>/<Y>` pending) per § "Progress Visibility".
|
|
2621
|
+
4. Move to the next pending group (Step A again).
|
|
2539
2622
|
|
|
2540
2623
|
### Sequential fallback within team mode
|
|
2541
2624
|
|
|
@@ -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 });
|