baldart 3.26.0 → 3.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ 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
+ ## [3.27.1] - 2026-05-28
9
+
10
+ Patch follow-up alla v3.27.0 dopo l'incidente reale in `mayo`: l'utente aveva CLI globale v3.24.0 e ha eseguito `/baldart-update` per aggiornarsi a v3.27.0. La skill ha rilanciato `npx baldart update`, che ha aggiornato il framework payload da v3.24 a v3.27, ma `update.js` in esecuzione era il binario v3.24.0 — quello che precede sia il meccanismo di auto-relaunch (introdotto v3.26.0) sia la logica di symlink-indirection (v3.27.0). Risultato: i 6 agent overlay-merged sono stati rigenerati con la **vecchia logica** (file regolari direttamente in `.claude/agents/`), e quindi il fix v3.27.0 NON si è attivato — `npx baldart doctor` ha riportato "tutto healthy" ma in realtà gli agent erano ancora invisibili a Claude Code (bug #20931). Chicken-and-egg: il CLI vecchio non sa rilanciare il nuovo, il nuovo non è ancora in esecuzione.
11
+
12
+ ### Fixed — /baldart-update skill: CLI version drift gate
13
+
14
+ - **[framework/.claude/skills/baldart-update/SKILL.md](framework/.claude/skills/baldart-update/SKILL.md)** Step 0: la skill ora rileva esplicitamente il CLI version drift e HALTA pre-update quando il CLI globale è < v3.26.0 (no auto-relaunch) e il payload sta per attraversare il boundary v3.27.0 (migration breaking). Decision logic articolata: se CLI è già >= v3.26.0 lascia che l'auto-relaunch faccia il suo lavoro; altrimenti istruisce `npm i -g baldart@latest` PRIMA di ri-eseguire la skill. Evita il silent-fail dove il CLI vecchio applica una pseudo-migration con codice obsoleto.
15
+
16
+ ### Note al curator
17
+
18
+ Questo è un edge case strutturale che si manifesta SOLO quando un consumer attraversa il boundary v3.26.0 / v3.27.0 partendo da un CLI globale precedente alla v3.26.0. I consumer già su CLI v3.26.0+ hanno l'auto-relaunch (`npx baldart@latest`) e non sono affected. Per i consumer pre-v3.26.0 il fix in skill aiuta solo le sessioni future (skill caricata da framework payload >= v3.27.1); chi aveva già la skill vecchia in sessione al momento dell'incidente — come `mayo` ieri — può solo riparare manualmente: `npm i -g baldart@latest` + restart sessione CC + `/baldart-update`.
19
+
20
+ ## [3.27.0] - 2026-05-28
21
+
22
+ Fix critico al meccanismo di overlay-merge degli agent. Sintomo riproducibile osservato in `mayo` durante `/new FEAT-0009 -full` (team mode, claude-opus-4-7): gli agent overlay-merged (`coder`, `ui-expert`, `code-reviewer`, `doc-reviewer`, `qa-sentinel`, `security-reviewer` quando hanno un overlay) **non comparivano nella lista "Available agent types"** del Claude Code session reminder. Sequenza concreta: `codebase-architect` (symlink) parte e completa; i 4 background agent (`coder×2` + `ui-expert×2`) lanciati subito dopo falliscono con `InputValidationError: subagent_type not recognized`; l'orchestratore halta con un AskUserQuestion fuori-protocollo. Repro: `ls -la .claude/agents/` mostra 21 symlink + 6 file regolari (i 6 overlay-merged); CC discoverizza i 21 symlink e omette i 6 file regolari. Confermato su sessioni indipendenti del 2026-05-27 e 2026-05-28 → bug strutturale, non transiente. Workflow `/new` in team mode, `/check`, `/qa`, `/codexreview` e skill `prd` erano **bloccati end-to-end** su qualsiasi consumer con overlay agent.
23
+
24
+ ### Root cause — Claude Code upstream bug #20931
25
+
26
+ Il session-start discovery scanner di Claude Code che costruisce la registry di `subagent_type` validi **skippa silenziosamente i file regolari** in `.claude/agents/` (solo i symlink vengono caricati). Il bug è già documentato in `framework/.claude/hooks/agent-discovery-gate.js:10-15`:
27
+
28
+ > #20931 — file-based discovery of `.claude/agents/*.md` is documented as BROKEN in some scenarios (files ignored at session start)
29
+
30
+ Dalla v3.8.0 in poi BALDART ha generato i file overlay-merged come **file regolari** direttamente in `.claude/agents/<name>.md`, intersecando esattamente la zona di rottura di CC. Tutti gli overlay agent attivi nei consumer sono stati silenziosamente invisibili per ~3 mesi, e il sintomo emergeva solo quando una skill provava ad invocare lo `subagent_type` (es. team-mode `/new`).
31
+
32
+ Diagnosi precedente erroneamente attribuita a "HTML comment prima del frontmatter rompe il parser YAML" è stata rivista: il riordino del marker post-frontmatter resta una pulizia di formato corretta ma **non era la causa primaria** del sintomo.
33
+
34
+ ### Fixed — Symlink indirection per agent/command overlay-merged (root cause fix)
35
+
36
+ - **[src/utils/symlinks.js](src/utils/symlinks.js)** `_generateOverlayedFile()`: il content merged ora vive in `.baldart/generated/<kind>s/<name>.md` (file regolare), e `.claude/agents/<name>.md` (o `.claude/commands/<name>.md`) viene creato come **symlink relativo** verso quel file. Claude Code vede il path consumer come un symlink come tutti gli altri e lo discoverizza correttamente. Stesso pattern per Codex (`.agents/skills/`) tramite il tool adapter.
37
+ - **[src/utils/symlinks.js](src/utils/symlinks.js)** `_installPerItemSymlink()`: quando `hasOverlay` viene rimosso e il path consumer è un symlink che punta in `.baldart/generated/`, il CLI relinka al framework base e cancella lo stale generated file. Coerenza forte tra "overlay esiste" e "esiste un generated file".
38
+ - **[src/utils/symlinks.js](src/utils/symlinks.js)**: migrazione automatica del legacy layout. Se il consumer ha un file regolare baldart-generated direttamente in `.claude/agents/<name>.md` (pre-v3.27.0), la prossima `npx baldart update` lo riconosce dal marker, sposta il content in `.baldart/generated/agents/<name>.md` e crea il symlink. Nessun intervento manuale richiesto. User-fork (file regolare senza marker baldart) restano protetti.
39
+
40
+ ### Added — Safety net SessionStart hook
41
+
42
+ - **[framework/.claude/hooks/agent-discovery-info.sh](framework/.claude/hooks/agent-discovery-info.sh)**: oltre a rilevare gli agent **mancanti** (file assente), il hook ora rileva anche gli agent in **stato degradato** — ovvero file regolari con marker `<!-- baldart-generated: -->` presenti in `.claude/agents/` (pre-v3.27.0 layout). Quando rilevati, emette `additionalContext` esplicito al modello con il nome degli agent affected, la causa (upstream bug #20931) e l'azione richiesta (`npx baldart update`). Difesa-in-profondità: il `mergeAgents` migra automaticamente e questo hook copre l'edge case in cui un consumer non lanci `update` subito dopo l'upgrade.
43
+
44
+ ### Fixed — Formato file generato (cleanup secondario, non root cause)
45
+
46
+ - **[src/utils/overlay-merger.js](src/utils/overlay-merger.js)**: i file generati hanno il frontmatter YAML su riga 1 e il marker `<!-- baldart-generated: -->` subito dopo il `---` di chiusura (era prima invertito). Allinea il formato alla convenzione standard CommonMark + YAML frontmatter, indipendentemente dal fatto che CC sia o meno strict sul parser.
47
+ - **[src/utils/overlay-merger.js](src/utils/overlay-merger.js)**: nuovo export `isGeneratedFile(content)` posizione-agnostico. `readMarker()` cerca il marker in tutto il file (cap 4KB) per leggere sia file v3.27.0+ sia legacy pre-v3.27.0 (retrocompat fondamentale per la migration).
48
+ - **[framework/.claude/hooks/framework-edit-gate.js](framework/.claude/hooks/framework-edit-gate.js)**: il PreToolUse hook controlla il marker via `head.includes` su slice 5KB, così intercetta edit ai file generati sia che il marker sia su riga 1 (legacy) sia dopo il frontmatter (v3.27.0+).
49
+
50
+ ### Migration path per i consumer
51
+
52
+ Su un consumer con overlay agent in layout legacy (es. `mayo`):
53
+
54
+ 1. `npx baldart update` rileva la nuova VERSION e lancia `mergeAgents`.
55
+ 2. Per ogni file regolare baldart-generated in `.claude/agents/`: marker letto → content spostato in `.baldart/generated/agents/<name>.md` → simlink creato.
56
+ 3. `git status` mostra: `.claude/agents/<name>.md` modificato (file → symlink), `.baldart/generated/agents/<name>.md` nuovo. La `_autoCommitFn` del CLI committa con `chore(baldart): post-update reconcile to v3.27.0`.
57
+ 4. **Restart della Claude Code session** richiesto perché la registry di `subagent_type` viene letta al session-start.
58
+ 5. Verifica: `Available agent types` ora include tutti i nomi (sia symlink che generated-symlink). `/new` in team mode funziona.
59
+
60
+ Nessuna perdita di overlay: i file in `.baldart/overlays/agents/*.md` sono consumer-owned e non vengono toccati. Lo skill `/baldart-update` cita esplicitamente nel report post-flight gli agent migrati e ricorda di restartare la sessione CC.
61
+
8
62
  ## [3.26.0] - 2026-05-27
9
63
 
10
64
  Fix definitivo del processo `baldart update` dopo che un'esecuzione reale su un consumer (`mayo`) ha esposto un workflow strutturalmente rotto. Sintomi: `update --yes` stampava "Already up to date!" mentre `version`/`doctor` riportavano correttamente "Remote: 51 commit ahead"; CLI globale (v3.18.1) silenzioso vs npm latest (v3.24.0); divergenza subtree (19 commit locali + 51 upstream) mai menzionata; output sporcato da `ExperimentalWarning` di npm a ogni invocazione; skill `/baldart-update` che narrava il workflow rotto senza accorgersene. Root cause: tre superfici (`update`, `version`, `doctor`) implementavano TRE check diversi per la stessa domanda, e nessuna era giusta — `update.js:193` usava `hasChangesToPush()` (commit LOCALI non pushati al CONSUMER's origin, niente a che vedere con BALDART upstream); `version`/`doctor` usavano `HEAD...FETCH_HEAD` full-repo che include sempre subtree-merge noise → falso positivo perenne "52 commit ahead".
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.26.0
1
+ 3.27.1
@@ -62,9 +62,19 @@ CONSUMER_AGENTS_DIR=".claude/agents"
62
62
 
63
63
  [ -d "$FRAMEWORK_AGENTS_DIR" ] || exit 0 # consumer not installed — silent.
64
64
 
65
- # --- compute missing set --------------------------------------------------
65
+ # --- compute missing + degraded sets --------------------------------------
66
+ #
67
+ # MISSING — file does not exist (or symlink is broken). agent-discovery-gate
68
+ # will hard-deny calls to these names.
69
+ # DEGRADED — file exists as a *regular file* with the baldart-generated marker.
70
+ # Pre-v3.27.0 layout produced these, but Claude Code upstream bug
71
+ # #20931 skips regular files at session-start discovery, so the
72
+ # `subagent_type` value is silently unavailable to the orchestrator.
73
+ # The fix is `npx baldart update` (migrates them to a symlink into
74
+ # .baldart/generated/<kind>s/).
66
75
 
67
76
  MISSING=""
77
+ DEGRADED=""
68
78
  while IFS= read -r f; do
69
79
  [ -z "$f" ] && continue
70
80
  name="$(basename "$f" .md)"
@@ -72,16 +82,21 @@ while IFS= read -r f; do
72
82
  target="$CONSUMER_AGENTS_DIR/${name}.md"
73
83
  # `test -e` returns false for broken symlinks too — exactly what we want.
74
84
  if [ ! -e "$target" ]; then
75
- if [ -z "$MISSING" ]; then
76
- MISSING="$name"
77
- else
78
- MISSING="$MISSING, $name"
85
+ if [ -z "$MISSING" ]; then MISSING="$name"; else MISSING="$MISSING, $name"; fi
86
+ continue
87
+ fi
88
+ # File exists. If it's a symlink → CC discovery sees it, healthy.
89
+ # If it's a regular file with the baldart-generated marker → degraded
90
+ # state (legacy overlay layout before v3.27.0).
91
+ if [ ! -L "$target" ] && [ -f "$target" ]; then
92
+ if head -c 4096 "$target" 2>/dev/null | grep -q "<!-- baldart-generated:" ; then
93
+ if [ -z "$DEGRADED" ]; then DEGRADED="$name"; else DEGRADED="$DEGRADED, $name"; fi
79
94
  fi
80
95
  fi
81
96
  done < <(find "$FRAMEWORK_AGENTS_DIR" -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort)
82
97
 
83
- # Nothing missing — exit silently. No noise when everything is healthy.
84
- [ -z "$MISSING" ] && exit 0
98
+ # Nothing to report — exit silently. No noise when everything is healthy.
99
+ [ -z "$MISSING" ] && [ -z "$DEGRADED" ] && exit 0
85
100
 
86
101
  # --- emit additionalContext ----------------------------------------------
87
102
 
@@ -90,7 +105,14 @@ escape_json() {
90
105
  printf '%s' "$1" | awk 'BEGIN{ORS=""} {gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); gsub(/\r/,""); gsub(/\n/,"\\n"); print}'
91
106
  }
92
107
 
93
- MSG="BALDART expects the following sub-agents to be reachable via the Agent tool in this session, but their backing files are missing from .claude/agents/: ${MISSING}. Calling Agent with one of these subagent_type values will be denied by the agent-discovery-gate PreToolUse hook. Run \`npx baldart doctor\` to repair, then restart this session."
108
+ MSG=""
109
+ if [ -n "$MISSING" ]; then
110
+ MSG="BALDART expects the following sub-agents to be reachable via the Agent tool in this session, but their backing files are missing from .claude/agents/: ${MISSING}. Calling Agent with one of these subagent_type values will be denied by the agent-discovery-gate PreToolUse hook. Run \`npx baldart doctor\` to repair, then restart this session."
111
+ fi
112
+ if [ -n "$DEGRADED" ]; then
113
+ DEG_MSG="BALDART detected overlay-merged agents stored in the legacy pre-v3.27.0 layout (regular files in .claude/agents/ with the baldart-generated marker): ${DEGRADED}. Claude Code upstream bug #20931 silently skips regular files at session-start discovery, so calling Agent with one of these subagent_type values will fail with InputValidationError even though the file is on disk. Run \`npx baldart update\` to migrate to the v3.27.0+ symlink-indirection layout (the file is moved to .baldart/generated/<kind>s/<name>.md and .claude/agents/<name>.md becomes a symlink to it), then restart this session."
114
+ if [ -n "$MSG" ]; then MSG="${MSG} | ${DEG_MSG}"; else MSG="$DEG_MSG"; fi
115
+ fi
94
116
 
95
117
  cat <<JSON
96
118
  {
@@ -163,18 +163,24 @@ function main() {
163
163
 
164
164
  // v3.8.0+: also block direct edits to BALDART-generated files (agents /
165
165
  // commands generated from base + overlay). These live OUTSIDE .framework/
166
- // but carry the baldart-generated marker comment as their first line.
167
- // The user should edit the overlay, not the generated file, otherwise the
168
- // next `baldart update` will overwrite their changes.
166
+ // but carry the baldart-generated marker comment in their head. Pre-v3.27.0
167
+ // the marker was on line 1; v3.27.0+ places it after the YAML frontmatter
168
+ // and the actual file lives in .baldart/generated/<kind>s/<name>.md with a
169
+ // symlink from .claude/agents/<name>.md. Match both layouts. The structural
170
+ // regex (requires kind=…name=…overlay_sha=…) avoids false-positives on
171
+ // framework source files that mention the marker as a string literal
172
+ // (e.g. this hook itself, agent-discovery-info.sh, overlay-merger.js).
173
+ const GENERATED_MARKER_RE =
174
+ /<!-- baldart-generated:[\s\S]{0,400}?kind=\S+[\s\S]{0,400}?name=\S+[\s\S]{0,400}?overlay_sha=\S+/;
169
175
  try {
170
176
  const fs = require('fs');
171
177
  if (fs.existsSync(filePath)) {
172
- const head = fs.readFileSync(filePath, 'utf8').slice(0, 200);
173
- if (head.startsWith('<!-- baldart-generated:')) {
174
- // Allow ONLY if the edit explicitly retains the marker on line 1
178
+ const head = fs.readFileSync(filePath, 'utf8').slice(0, 5000);
179
+ if (GENERATED_MARKER_RE.test(head)) {
180
+ // Allow ONLY if the edit explicitly retains the marker —
175
181
  // catches accidental overwrites while letting BALDART itself regenerate.
176
182
  const newContent = extractNewContent(toolName, input.tool_input) || '';
177
- if (!newContent.startsWith('<!-- baldart-generated:')) {
183
+ if (!GENERATED_MARKER_RE.test(newContent)) {
178
184
  const reason =
179
185
  `Cannot edit BALDART-generated file (${filePath}).\n` +
180
186
  `This file is auto-generated by \`npx baldart update\` from a base ` +
@@ -178,9 +178,28 @@ Match the `Remote:` line in this order (first match wins):
178
178
 
179
179
  Always also capture:
180
180
  - `Installed:\s+v(\S+)` → `installedVersion` (saved for post-flight assert).
181
- - `CLI:\s+v\S+\s+→\s+v(\S+)\s+available`if present, tell the user once:
182
- "Il CLI globale è vecchio (v<old> v<new>). L'update lo gestirà
183
- auto-relanciando via `npx baldart@latest` — niente da fare."
181
+ - `CLI:\s+v(\S+?)(?:\s+→\s+v(\S+)\s+available)?``cliVersion` (current CLI binary)
182
+ + optional `cliLatest` (newest npm version).
183
+
184
+ **CLI version drift gate (BLOCKING)** — added in v3.27.1 after a real
185
+ incident in `mayo`: a consumer on CLI v3.24.0 ran update against payload
186
+ v3.27.0 and the v3.27.0 symlink-indirection migration silently did NOT
187
+ trigger (the running `update.js` was the v3.24.0 binary, predating both
188
+ auto-relaunch and the migration logic). The CLI v3.26.0+ self-relaunches
189
+ via `npx baldart@latest`, but pre-v3.26.0 binaries do not — chicken-and-egg.
190
+
191
+ Decision logic (apply after parsing):
192
+
193
+ | If… | Then… |
194
+ |---|---|
195
+ | `cliVersion >= 3.26.0` AND `cliLatest` present | Tell once: "Il CLI globale è v<cliVersion>, ne esiste v<cliLatest>. L'update si autorelancerà via `npx baldart@latest` — niente da fare." Continue. |
196
+ | `cliVersion < 3.26.0` AND `installedVersion >= 3.27.0` (already on the new payload but old binary) | **HALT**. Tell the user: "Il CLI globale (v<cliVersion>) è troppo vecchio per applicare la migration symlink-indirection di v3.27.0 (introdotta v3.26.0+ self-relaunch e v3.27.0 symlink-indirection). Lancia `npm i -g baldart@latest` PRIMA di ri-eseguire /baldart-update, altrimenti `update.js` non vedrà la nuova logica e i 6 agent overlay-merged resteranno file regolari invisibili a Claude Code (bug #20931)." Do not launch `npx baldart update`. |
197
+ | `cliVersion < 3.26.0` AND `installedVersion < 3.27.0` AND update is going to bring the payload to `>= 3.27.0` (parse the remoteVersion from Step 0) | **HALT** with the same message — the payload will cross the v3.27.0 boundary during this update, and the running `update.js` will lack the migration logic. |
198
+ | Otherwise | Continue normally. |
199
+
200
+ Semver compare: split on `.`, compare numerically. Treat any non-numeric
201
+ segment (e.g. `3.27.0-rc1`) as "less than" the same major.minor.patch
202
+ without suffix only when strictly required; if unsure, ASK the user.
184
203
 
185
204
  **Decision after parsing**:
186
205
 
@@ -312,11 +331,23 @@ Report to the user, in order:
312
331
  baldart@…", surface it once so the user knows the CLI bumped too.
313
332
  4. **Hooks reconciled**: any hook backfilled (parse "Auto-registered N
314
333
  missing hook(s)" or per-line "Registered hook" from stdout).
315
- 5. **Schema notes**: any missing key still flagged suggest
334
+ 5. **Agent layout migration (v3.27.0+)**: parse stdout for lines matching
335
+ `agent generated (overlay applied): .claude/agents/<name>.md → ../../.baldart/generated/agents/<name>.md`.
336
+ Pre-v3.27.0 overlay-merged agents (`coder`, `ui-expert`, `code-reviewer`,
337
+ `doc-reviewer`, `qa-sentinel`, `security-reviewer` when overlayed) lived
338
+ as regular files in `.claude/agents/` and were silently invisible to
339
+ Claude Code session-start discovery (upstream bug #20931). The CLI now
340
+ stores merged content in `.baldart/generated/<kind>s/<name>.md` and
341
+ places a symlink at `.claude/agents/<name>.md`. Report the migrated
342
+ names so the user knows they should restart their CC session for the
343
+ new layout to take effect on `subagent_type` resolution.
344
+ 6. **Schema notes**: any missing key still flagged → suggest
316
345
  `npx baldart configure`.
317
- 6. **Stash-pop status**: clean / conflicts present (with file list).
318
- 7. **Suggested next step**: if schema drift residual or other non-update
319
- doctor checks fired, suggest `npx baldart` (smart doctor).
346
+ 7. **Stash-pop status**: clean / conflicts present (with file list).
347
+ 8. **Suggested next step**: if schema drift residual or other non-update
348
+ doctor checks fired, suggest `npx baldart` (smart doctor). Also remind:
349
+ if any agent was migrated (step 5), **restart the Claude Code session**
350
+ so the discovery scanner sees the new symlinks.
320
351
 
321
352
  ## Failure modes
322
353
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.26.0",
3
+ "version": "3.27.1",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -49,20 +49,38 @@ function buildMarker({ kind, name, baseVersion, baseSha, overlaySha }) {
49
49
  }
50
50
 
51
51
  /**
52
- * Returns the marker fields encoded in the first line if present, or null.
53
- * Used by `update` to know "is this file ours (safe to regenerate) or did the
54
- * user edit it manually?"
52
+ * Returns the marker fields encoded in the generated-marker block if present,
53
+ * or null. Used by `update` to know "is this file ours (safe to regenerate) or
54
+ * did the user edit it manually?"
55
+ *
56
+ * The marker block can appear in two positions:
57
+ * - line 1 (pre-v3.26.1 generated files — kept for back-compat reading)
58
+ * - immediately after the closing `---` of the YAML frontmatter (v3.26.1+)
55
59
  *
56
60
  * `base_sha` is optional — older generated files (pre-v3.19.0) won't have it.
57
61
  */
58
62
  function readMarker(content) {
59
- const firstLine = content.split('\n').slice(0, 3).join('\n');
60
- if (!firstLine.includes(MARKER_PREFIX)) return null;
61
- const m = firstLine.match(/kind=(\S+) name=(\S+) base_version=(\S+)(?: base_sha=(\S+))? overlay_sha=(\S+)/);
63
+ const idx = content.indexOf(MARKER_PREFIX);
64
+ if (idx === -1) return null;
65
+ // The marker block ends at the next `-->`. Cap the search to the first 4KB
66
+ // to avoid scanning huge files when the marker isn't actually there.
67
+ const block = content.slice(idx, idx + 4000);
68
+ const end = block.indexOf('-->');
69
+ if (end === -1) return null;
70
+ const m = block.slice(0, end).match(/kind=(\S+)\s+name=(\S+)\s+base_version=(\S+)(?:\s+base_sha=(\S+))?\s+overlay_sha=(\S+)/);
62
71
  if (!m) return null;
63
72
  return { kind: m[1], name: m[2], baseVersion: m[3], baseSha: m[4] || null, overlaySha: m[5] };
64
73
  }
65
74
 
75
+ /**
76
+ * Tells whether `content` is a BALDART-generated file (agent or command).
77
+ * Position-agnostic — accepts both pre-v3.26.1 (marker on line 1) and v3.26.1+
78
+ * (marker after the frontmatter) layouts.
79
+ */
80
+ function isGeneratedFile(content) {
81
+ return readMarker(content) !== null;
82
+ }
83
+
66
84
  /**
67
85
  * Split markdown text into:
68
86
  * - frontmatter: the raw text between leading `---` fences (or '')
@@ -193,12 +211,17 @@ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersio
193
211
  ? `---\n${baseParsed.frontmatter}\n---\n`
194
212
  : '';
195
213
 
196
- const generated =
197
- `${marker}\n` +
198
- fmBlock +
199
- (baseParsed.preamble || '') +
200
- sectionsOut +
201
- trailingOut;
214
+ // v3.26.1+ layout: frontmatter MUST be the first thing in the file —
215
+ // Claude Code's agent discovery uses a strict YAML-frontmatter parser that
216
+ // skips files where `---` is not on line 1. Pre-v3.26.1 placed the marker
217
+ // first, which silently de-registered every overlay-merged agent (no
218
+ // `subagent_type` exposed → spawns failed with "agent not found"). The
219
+ // marker now sits as a CommonMark HTML comment immediately after the
220
+ // closing `---`, which `readMarker` still finds via position-agnostic
221
+ // scan. If the base has no frontmatter (unusual), marker still goes first.
222
+ const generated = fmBlock
223
+ ? fmBlock + `${marker}\n` + (baseParsed.preamble || '') + sectionsOut + trailingOut
224
+ : `${marker}\n` + (baseParsed.preamble || '') + sectionsOut + trailingOut;
202
225
 
203
226
  return {
204
227
  generated,
@@ -212,6 +235,7 @@ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersio
212
235
  module.exports = {
213
236
  mergeOverlay,
214
237
  readMarker,
238
+ isGeneratedFile,
215
239
  parseMarkdown,
216
240
  shortSha,
217
241
  computeBaseFileSha,
@@ -423,6 +423,20 @@ class SymlinkUtils {
423
423
  aggregate.skipped.push({ tool: adapter.name, kind, name: baseName, reason: 'already-linked' });
424
424
  return;
425
425
  }
426
+ // Symlink pointing into .baldart/generated/ → BALDART-managed from a
427
+ // previous overlay state. Overlay has just been removed, so revert to
428
+ // the framework-base symlink and clean up the now-stale generated file.
429
+ if (current.split(path.sep).includes('generated') && current.includes('.baldart')) {
430
+ fs.unlinkSync(linkPath);
431
+ fs.symlinkSync(target, linkPath);
432
+ try {
433
+ const staleGenerated = path.join(this.cwd, '.baldart', 'generated', `${kind}s`, filename);
434
+ if (fs.existsSync(staleGenerated)) fs.unlinkSync(staleGenerated);
435
+ } catch (_) { /* best effort */ }
436
+ UI.info(`[${adapter.label}] ${kind} overlay removed — relinking ${path.join(targetRel, filename)} → framework base`);
437
+ aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
438
+ return;
439
+ }
426
440
  // Broken or pointing elsewhere — replace if broken, else respect user override
427
441
  if (!fs.existsSync(linkPath)) {
428
442
  fs.unlinkSync(linkPath);
@@ -438,6 +452,10 @@ class SymlinkUtils {
438
452
  // Real file. Could be:
439
453
  // (a) A baldart-generated file from a previous run where overlay USED to
440
454
  // exist but now doesn't — safe to replace with symlink.
455
+ // Pre-v3.27.0 layout: marker file directly at linkPath. v3.27.0+
456
+ // stores the merged file under .baldart/generated/ and points a
457
+ // symlink at it, so the "real file at linkPath" case here is the
458
+ // legacy layout that no longer regenerates — just unlink and resymlink.
441
459
  // (b) A user-authored fork — leave alone.
442
460
  const { readMarker } = require('./overlay-merger');
443
461
  const content = fs.readFileSync(linkPath, 'utf8');
@@ -447,6 +465,12 @@ class SymlinkUtils {
447
465
  UI.info(`[${adapter.label}] ${kind} overlay removed — replacing generated file with symlink: ${path.join(targetRel, filename)}`);
448
466
  fs.unlinkSync(linkPath);
449
467
  fs.symlinkSync(target, linkPath);
468
+ // Also clean up any stale .baldart/generated/<kind>s/<name>.md from
469
+ // the v3.27.0+ indirect layout (no overlay means no generated file).
470
+ try {
471
+ const staleGenerated = path.join(this.cwd, '.baldart', 'generated', `${kind}s`, filename);
472
+ if (fs.existsSync(staleGenerated)) fs.unlinkSync(staleGenerated);
473
+ } catch (_) { /* best effort */ }
450
474
  aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
451
475
  return;
452
476
  }
@@ -460,7 +484,7 @@ class SymlinkUtils {
460
484
  }
461
485
 
462
486
  _generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
463
- const { mergeOverlay, readMarker } = require('./overlay-merger');
487
+ const { mergeOverlay, readMarker, isGeneratedFile } = require('./overlay-merger');
464
488
  const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
465
489
  const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
466
490
  const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });
@@ -472,13 +496,28 @@ class SymlinkUtils {
472
496
  });
473
497
  }
474
498
 
475
- // If a previously generated file is on disk, check ownership via marker
476
- // before overwriting. If it lacks the marker (= user edited manually after
477
- // a previous generation, or a pre-3.8 fork), refuse to overwrite.
499
+ // v3.27.0+ layout: the merged content lives at
500
+ // .baldart/generated/<kind>s/<name>.md (real file)
501
+ // and the consumer-visible path is a symlink:
502
+ // .claude/agents/<name>.md → ../../.baldart/generated/agents/<name>.md
503
+ // This is a workaround for Claude Code upstream bug #20931, where the
504
+ // session-start scanner that builds the `subagent_type` registry skips
505
+ // regular files in .claude/agents/ (only symlinks are picked up). The
506
+ // pre-v3.27.0 layout wrote the merged file directly at linkPath as a
507
+ // real file, which made every overlay-merged agent silently invisible
508
+ // to the orchestrator — see CHANGELOG 3.27.0 root cause analysis.
509
+ const generatedDir = path.join(this.cwd, '.baldart', 'generated', `${kind}s`);
510
+ const generatedPath = path.join(generatedDir, `${name}.md`);
511
+ fs.mkdirSync(generatedDir, { recursive: true });
512
+
513
+ // Inspect what currently sits at linkPath. Three legal states:
514
+ // (1) symlink we already manage (resolves into .baldart/generated/) → stale, replace
515
+ // (2) symlink to the framework base (no overlay last time) → upgrade to indirect symlink
516
+ // (3) regular file with baldart-generated marker (legacy pre-v3.27.0 layout) → migrate
517
+ // (4) regular file without marker → user fork, refuse to touch
478
518
  if (lstat && !lstat.isSymbolicLink()) {
479
519
  const existing = fs.readFileSync(linkPath, 'utf8');
480
- const m = readMarker(existing);
481
- if (!m) {
520
+ if (!isGeneratedFile(existing)) {
482
521
  UI.warning(`[${adapter.label}] ${kind} ${name}: existing file at ${path.join(targetRel, name)}.md has no baldart-generated marker — NOT overwriting (user fork?). Move it aside (or merge it into the overlay) and re-run update.`);
483
522
  aggregate.conflicts.push({
484
523
  tool: adapter.name, kind, name,
@@ -488,15 +527,23 @@ class SymlinkUtils {
488
527
  });
489
528
  return;
490
529
  }
530
+ // Legacy generated file at linkPath — migrate (delete in-place, rewrite at generatedPath, symlink).
491
531
  }
492
532
 
493
- // Replace any existing symlink/file with the freshly generated content.
533
+ // Write merged content to the indirect location.
534
+ fs.writeFileSync(generatedPath, merged.generated);
535
+
536
+ // Compute the relative symlink target (portable across moves of the
537
+ // project root).
538
+ const relativeTarget = path.relative(path.dirname(linkPath), generatedPath);
539
+
540
+ // Replace whatever currently lives at linkPath with the new symlink.
494
541
  if (lstat) {
495
542
  try { fs.unlinkSync(linkPath); }
496
543
  catch (_) { try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch (_) { /* noop */ } }
497
544
  }
498
- fs.writeFileSync(linkPath, merged.generated);
499
- UI.success(`[${adapter.label}] ${kind} generated (overlay applied): ${path.join(targetRel, name)}.md`);
545
+ fs.symlinkSync(relativeTarget, linkPath);
546
+ UI.success(`[${adapter.label}] ${kind} generated (overlay applied): ${path.join(targetRel, name)}.md → ${relativeTarget}`);
500
547
  aggregate.generated.push({ tool: adapter.name, kind, name, overlaySha: merged.overlaySha });
501
548
  }
502
549
 
@@ -598,13 +645,13 @@ class SymlinkUtils {
598
645
  UI.warning(`[${adapter.label}] Legacy bulk symlink at ${rel}. Run \`npx baldart update\` to convert to per-item layout.`);
599
646
  allValid = false;
600
647
  } else {
648
+ const { isGeneratedFile } = require('./overlay-merger');
601
649
  const entries = fs.readdirSync(full);
602
650
  const frameworkOwned = entries.filter((n) => {
603
651
  const p = path.join(full, n);
604
652
  try {
605
653
  if (fs.lstatSync(p).isSymbolicLink()) return true;
606
- const content = fs.readFileSync(p, 'utf8');
607
- return content.startsWith('<!-- baldart-generated:');
654
+ return isGeneratedFile(fs.readFileSync(p, 'utf8'));
608
655
  } catch { return false; }
609
656
  });
610
657
  if (frameworkOwned.length === 0) {