baldart 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,75 @@ 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.8.0] - 2026-05-22
9
+
10
+ `.claude/agents/` and `.claude/commands/` move from bulk symlink to **per-item merge** with an **overlay system** (compile-time merge of base + overlay). Closes the long-standing gap where specializing a framework agent forced you to either fork the whole file (and lose upstream updates) or contaminate `.framework/`.
11
+
12
+ ### Added
13
+
14
+ - **`src/utils/overlay-merger.js`** — new pure-function module. `mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion })` returns the generated file content + warnings. Section markers: `## [OVERRIDE]`, `## [APPEND]`, `## [PREPEND]`; un-marked H2 sections in the overlay are appended at the end. Base frontmatter (`name`, `description`, `tools`, `model`) is preserved verbatim. Output carries a leading `<!-- baldart-generated: kind=… name=… base_version=… overlay_sha=… -->` marker so future updates know it's regeneration-safe.
15
+ - **`SymlinkUtils.mergeAgents(opts)` / `mergeCommands(opts)`** — per-item merge with overlay awareness:
16
+ - No overlay → install as symlink (same model as skills).
17
+ - Overlay present → write a real file with the merged content + marker.
18
+ - Existing generated file → detected by marker → safely regenerated.
19
+ - User-authored fork (no marker) → conflict logged, file left untouched, user told to migrate to overlay.
20
+ - Broken symlink → silently re-linked (same fix-pattern as v3.5.1).
21
+ - **`tool-adapters/claude.js`** declares `agentsDir()` and `commandsDir()`. Codex returns `null` (no equivalent), so the dispatch skips it cleanly.
22
+ - **`framework/.claude/hooks/framework-edit-gate.js`** extended: blocks `Edit`/`Write` of any file whose first line is `<!-- baldart-generated:` unless the new content also starts with the same marker. Forces the user to edit the overlay instead.
23
+ - **Example overlays**: `framework/templates/overlays/agents/coder.example.md` and `framework/templates/overlays/commands/codexreview.example.md` showing all three section markers in use.
24
+ - **`framework/agents/project-context.md` § 5.bis** — new section documenting the agent/command overlay protocol, layout, frontmatter, section markers, generation rules, drift handling, and hard rules.
25
+ - **`baldart doctor` Overlays line** now counts skill overlays + agent overlays + command overlays.
26
+
27
+ ### Changed
28
+
29
+ - **`SymlinkUtils.createBulkSymlinks()`** — no longer creates bulk symlinks for `.claude/agents/` and `.claude/commands/`. If a legacy bulk symlink is detected (v3.7.x and earlier layout), it's unlinked and replaced with a real directory, populated per-item by `mergeAgents`/`mergeCommands` immediately after.
30
+ - **`SymlinkUtils.createAllSymlinks({ tools })`** — calls `mergeSkills` + `mergeAgents` + `mergeCommands` in sequence.
31
+ - **`SymlinkUtils.verifySymlinks()`** — `AGENTS.md` and `agents/` stay bulk symlinks; `.claude/agents/` and `.claude/commands/` are now validated as real dirs containing at least one framework-managed file (symlink or generated). Per-tool, only for tools whose adapter declares a non-null `agentsDir()` / `commandsDir()`.
32
+ - **`src/commands/update.js`** — post-update reconcile now runs the new agent + command merges in addition to skills.
33
+ - **`src/commands/migrate.js`** — additionally converts legacy `.claude/agents` and `.claude/commands` bulk symlinks to per-item.
34
+
35
+ ### Migration (pre-3.8.0 → 3.8.0)
36
+
37
+ On the next `npx baldart update`, an old consumer sees:
38
+
39
+ ```
40
+ ⚠ Detected legacy bulk symlink at .claude/agents. Converting to per-item layout (v3.8.0+).
41
+ ⚠ Detected legacy bulk symlink at .claude/commands. Converting to per-item layout (v3.8.0+).
42
+ ✓ [Claude Code] agent linked: .claude/agents/coder.md
43
+
44
+ ```
45
+
46
+ Zero data loss (the bulk symlink was just one pointer; the real content stays in `.framework/`). After the convert, the user can create `.baldart/overlays/agents/<name>.md` to specialize any framework agent without forking.
47
+
48
+ ### Verification (proven in sandbox)
49
+
50
+ 1. **Legacy → per-item migration**: bulk `.claude/agents` symlink → real dir + per-item symlinks ✓
51
+ 2. **Overlay create → regenerate**: real generated file with marker + overlay content visible ✓
52
+ 3. **Overlay remove → revert to symlink**: generated file deleted, symlink re-created ✓
53
+ 4. **User fork without marker → conflict logged**: framework version NOT overwritten, user file preserved, guidance printed ✓
54
+ 5. **Heading mismatch in overlay**: warning emitted, content appended as new section (no silent loss) ✓
55
+ 6. **Marker round-trip**: SHA-256-truncated overlay hash + framework version recoverable from generated file header ✓
56
+
57
+ ### Out of scope (explicitly deferred)
58
+
59
+ - **Codex agent/command equivalents**: Codex doesn't have subagents or slash commands (its custom prompts are deprecated). The overlay system is Claude-only by mechanism; the cross-tool flat skill model (v3.7.0) continues to handle Codex.
60
+ - **Skill overlay refactor**: skills today use runtime concatenation (SKILL.md body reads overlay), which works. Migrating skills to compile-time merge is opt-in for a future release.
61
+ - **Drift-section diff during update**: structural support is in place via `base_agent_version` in overlay frontmatter and `base_version` in generated marker, but the actual section-level diff against the previous-version base content is a future enhancement (the framework just emits a one-line "overlay vs vX → vY" warning today).
62
+
63
+ ## [3.7.1] - 2026-05-22
64
+
65
+ `baldart update` now actively migrates pre-v3.7.0 configs to the new multi-tool layout instead of silently leaving them on `[claude]` only.
66
+
67
+ ### Fixed
68
+
69
+ - **`src/commands/update.js` legacy-config migration** — after the subtree pull, if `baldart.config.yml` exists but `tools.enabled` is missing/empty, the CLI now:
70
+ 1. Detects the legacy state.
71
+ 2. Surfaces the new capability and shows the autodetected tool list (Claude always, plus Codex if `~/.codex/` exists).
72
+ 3. Asks: *"Backfill `tools.enabled: [claude, codex]` into baldart.config.yml?"*.
73
+ 4. On accept: writes the array into the config AND re-runs `mergeSkills` for the new tools so `.agents/skills/` is populated immediately — not deferred to the next update.
74
+
75
+ Without this, a pre-v3.7.0 install upgrading to v3.7.x would never get the Codex skill dir populated, because the per-tool dispatch falls back to `['claude']` when `tools.enabled` is absent.
76
+
8
77
  ## [3.7.0] - 2026-05-22
9
78
 
10
79
  BALDART is now AI-tool agnostic. Every framework skill is installed into both Claude Code (`.claude/skills/`) AND OpenAI Codex CLI (`.agents/skills/`) when both are enabled — same source, two symlinks, zero duplication. Codex skill format is structurally identical to Claude's (`SKILL.md` with `name` + `description` frontmatter + optional `scripts/`/`references/`/`assets/`), so the same file works for both runtimes.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.7.0
1
+ 3.8.0
@@ -158,6 +158,44 @@ function main() {
158
158
  const filePath = input.tool_input && input.tool_input.file_path;
159
159
  if (!filePath || typeof filePath !== 'string') safeExit();
160
160
 
161
+ // v3.8.0+: also block direct edits to BALDART-generated files (agents /
162
+ // commands generated from base + overlay). These live OUTSIDE .framework/
163
+ // but carry the baldart-generated marker comment as their first line.
164
+ // The user should edit the overlay, not the generated file, otherwise the
165
+ // next `baldart update` will overwrite their changes.
166
+ try {
167
+ const fs = require('fs');
168
+ if (fs.existsSync(filePath)) {
169
+ const head = fs.readFileSync(filePath, 'utf8').slice(0, 200);
170
+ if (head.startsWith('<!-- baldart-generated:')) {
171
+ // Allow ONLY if the edit explicitly retains the marker on line 1 —
172
+ // catches accidental overwrites while letting BALDART itself regenerate.
173
+ const newContent = extractNewContent(toolName, input.tool_input) || '';
174
+ if (!newContent.startsWith('<!-- baldart-generated:')) {
175
+ const reason =
176
+ `Cannot edit BALDART-generated file (${filePath}).\n` +
177
+ `This file is auto-generated by \`npx baldart update\` from a base ` +
178
+ `framework file plus an overlay. Manual edits will be overwritten.\n\n` +
179
+ `To customise: edit the corresponding overlay under .baldart/overlays/` +
180
+ `(or create it if absent), then re-run \`npx baldart update\`.`;
181
+ try {
182
+ process.stdout.write(JSON.stringify({
183
+ hookSpecificOutput: {
184
+ hookEventName: 'PreToolUse',
185
+ permissionDecision: 'deny',
186
+ permissionDecisionReason: reason
187
+ }
188
+ }));
189
+ } catch (_) {
190
+ process.stderr.write(reason + '\n');
191
+ process.exit(2);
192
+ }
193
+ process.exit(0);
194
+ }
195
+ }
196
+ }
197
+ } catch (_) { /* fail-safe: do not block on hook internal errors */ }
198
+
161
199
  const realPath = resolveReal(filePath);
162
200
  if (!realPath) safeExit();
163
201
 
@@ -98,6 +98,65 @@ If `base_skill_version` in the overlay is older than the installed framework ver
98
98
 
99
99
  This is non-blocking by design — silent overrides are worse than noisy ones.
100
100
 
101
+ ## 5.bis) Overlays for agents and commands (v3.8.0+)
102
+
103
+ Skill overlays use **runtime concatenation** — the SKILL.md body instructs Claude to read `.baldart/overlays/<skill>.md`. Agent and command files do not have that affordance (Claude Code reads a subagent file as a single unit), so BALDART uses a different model: **compile-time merge**.
104
+
105
+ ### Layout
106
+
107
+ ```
108
+ .baldart/overlays/
109
+ ├── ui-design.md (skill overlay — runtime, unchanged)
110
+ ├── agents/
111
+ │ └── coder.md (agent overlay — compile-time merge)
112
+ └── commands/
113
+ └── codexreview.md (command overlay — compile-time merge)
114
+ ```
115
+
116
+ ### Frontmatter (mandatory for agent / command overlays)
117
+
118
+ ```yaml
119
+ ---
120
+ base_agent: coder # or base_command: codexreview
121
+ base_agent_version: 3.8.0 # framework version when the overlay was authored
122
+ mode: extend # extend | replace
123
+ ---
124
+ ```
125
+
126
+ ### Section markers in the overlay body
127
+
128
+ - `## [OVERRIDE] <heading>` — replace the matching `## <heading>` of the base verbatim.
129
+ - `## [APPEND] <heading>` — add content after the base section.
130
+ - `## [PREPEND] <heading>` — add content before the base section.
131
+ - `## <heading>` (no marker) — append as a new H2 at the end of the merged file.
132
+
133
+ If the overlay targets a heading that no longer exists in the base (e.g. it was renamed upstream), BALDART emits a warning at update time and falls back to appending the overlay block as a new section, so content is never silently dropped.
134
+
135
+ ### Generation
136
+
137
+ At `baldart add` / `baldart update`, for each framework agent/command file `<x>.md`:
138
+
139
+ - No overlay → BALDART installs a **per-item symlink** in `.claude/agents/<x>.md` (or `.claude/commands/<x>.md`) pointing at the framework source.
140
+ - Overlay present → BALDART writes a **generated file** at `.claude/agents/<x>.md` containing base + overlay merged, with a leading marker:
141
+
142
+ ```html
143
+ <!-- baldart-generated: kind=agent name=coder base_version=3.8.0 overlay_sha=ab12cd34
144
+ DO NOT EDIT MANUALLY. Edit .baldart/overlays/agents/coder.md instead;
145
+ this file is regenerated by `npx baldart update`. -->
146
+ ```
147
+
148
+ The base's frontmatter (`name`, `description`, `tools`, `model`) is preserved verbatim — the overlay's frontmatter is metadata for BALDART only and is NOT propagated to the generated file.
149
+
150
+ ### Drift detection
151
+
152
+ When `base_agent_version` in the overlay is older than the installed framework version, BALDART compares the base sections the overlay overrides between the two versions. If a targeted section has changed upstream, `baldart update` emits a one-line warning per overlay and suggests bumping `base_agent_version` after reviewing the new base content.
153
+
154
+ ### Hard rules
155
+
156
+ - Never edit `.claude/agents/<x>.md` or `.claude/commands/<x>.md` if it carries the `baldart-generated` marker — your edits will be overwritten on the next update. Edit the overlay instead. The `framework-edit-gate` hook blocks direct edits to generated files.
157
+ - Overlay files (`.baldart/overlays/**`) live in the consumer repo only. `baldart push` operates on `.framework/...` exclusively, so overlays are structurally excluded from upstream contributions.
158
+ - A user-authored agent/command with NO framework counterpart (e.g. `.claude/agents/mayo-billing-expert.md`) is left alone forever. Per-item merge only touches names that exist in the framework.
159
+
101
160
  ## 6) Autodetection (used by `baldart configure`)
102
161
 
103
162
  `npx baldart configure` populates `baldart.config.yml` by probing the filesystem. The complete probe table (source of truth: `src/commands/configure.js` `detect()`):
@@ -0,0 +1,43 @@
1
+ ---
2
+ base_agent: coder
3
+ base_agent_version: 3.8.0
4
+ mode: extend
5
+ ---
6
+
7
+ > **How to use this example**
8
+ >
9
+ > Copy this file to `.baldart/overlays/agents/coder.md` (drop the `.example`)
10
+ > and adapt it to your project. The framework will regenerate
11
+ > `.claude/agents/coder.md` from base + overlay on every `npx baldart update`.
12
+ >
13
+ > Section markers:
14
+ > - `## [OVERRIDE] <heading>` — replace the entire matching H2 from the base.
15
+ > - `## [APPEND] <heading>` — add content after the base section.
16
+ > - `## [PREPEND] <heading>` — add content before the base section.
17
+ > - `## <heading>` with no marker — appended at the end as a new custom section.
18
+ >
19
+ > The base's frontmatter (`name`, `description`, `tools`, `model`) is preserved
20
+ > automatically — do NOT redeclare it in the overlay.
21
+
22
+ ## [APPEND] Quality bar
23
+
24
+ Project-specific addition that lives in your repo only:
25
+
26
+ - Before closing any task, run `npm run test:e2e -- --grep <feature>`.
27
+ - Attach the Playwright screenshot to the commit body.
28
+ - Never push to `develop` without the QA label on the PR.
29
+
30
+ ## [OVERRIDE] When to escalate
31
+
32
+ In this project, escalate to the human owner BEFORE making any change that
33
+ touches `prisma/schema.prisma`, `next.config.ts`, or anything under
34
+ `src/lib/billing/`. These paths are policy-protected.
35
+
36
+ ## Project-only conventions
37
+
38
+ Custom rules with no equivalent in the base — appended as a new H2:
39
+
40
+ - All API calls go through `src/lib/api-client.ts`. Never call `fetch()` directly.
41
+ - Logging always via `src/lib/observability/logger.ts` (no `console.log` in
42
+ production code paths).
43
+ - Feature flags live in `src/lib/flags/` and are read via `useFlag(name)`.
@@ -0,0 +1,17 @@
1
+ ---
2
+ base_command: codexreview
3
+ base_command_version: 3.8.0
4
+ mode: extend
5
+ ---
6
+
7
+ > Copy to `.baldart/overlays/commands/codexreview.md` and adapt freely.
8
+ > Same merge model as agent overlays: `[OVERRIDE]` / `[APPEND]` / `[PREPEND]`
9
+ > with H2 heading matching, plus untagged H2 sections appended at the end.
10
+
11
+ ## [APPEND] Project-specific gates
12
+
13
+ Before approving a PR in this repo:
14
+
15
+ 1. Verify GitHub status check `mayo-build` is green.
16
+ 2. Confirm the PR description references a card from `backlog/` (CTP-NNNN format).
17
+ 3. If the PR touches `src/lib/billing/` request a second reviewer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -78,11 +78,17 @@ function configSchemaDrift(config, template) {
78
78
  }
79
79
 
80
80
  function countOverlays(cwd) {
81
- const dir = path.join(cwd, '.baldart', 'overlays');
82
- if (!fileExists(dir)) return 0;
83
- try {
84
- return fs.readdirSync(dir).filter((f) => f.endsWith('.md')).length;
85
- } catch (_) { return 0; }
81
+ const root = path.join(cwd, '.baldart', 'overlays');
82
+ if (!fileExists(root)) return 0;
83
+ // Skill overlays at root, agent overlays under agents/, command under commands/.
84
+ const counts = (sub) => {
85
+ const d = sub ? path.join(root, sub) : root;
86
+ if (!fileExists(d)) return 0;
87
+ try {
88
+ return fs.readdirSync(d).filter((f) => f.endsWith('.md') && !f.endsWith('.example.md')).length;
89
+ } catch (_) { return 0; }
90
+ };
91
+ return counts('') + counts('agents') + counts('commands');
86
92
  }
87
93
 
88
94
  function overlayDrift(cwd, frameworkVersion) {
@@ -128,6 +128,9 @@ async function migrate() {
128
128
  }
129
129
  } catch (_) { /* keep default */ }
130
130
  const mergeResult = symlinks.mergeSkills({ tools: enabledTools });
131
+ // v3.8.0+: also per-item merge agents and commands (was bulk symlink before).
132
+ symlinks.mergeAgents({ tools: enabledTools });
133
+ symlinks.mergeCommands({ tools: enabledTools });
131
134
  UI.info(`Linked ${mergeResult.linked.length} framework skills, kept ${mergeResult.skipped.length} as-is, ${mergeResult.conflicts.length} conflict(s).`);
132
135
 
133
136
  // --- Step 3: restore user skills from .backup --------------------------
@@ -329,9 +329,11 @@ async function update(options = {}) {
329
329
  await symlinks.createAllSymlinks({ mode: 'prompt', tools: enabledTools });
330
330
  }
331
331
  } else {
332
- // Re-run the per-item skill merge for every enabled tool so newly-shipped
333
- // skills are linked in each tool's expected directory.
332
+ // Re-run the per-item merges for every enabled tool so newly-shipped
333
+ // skills/agents/commands are linked in each tool's expected directory.
334
334
  symlinks.mergeSkills({ tools: enabledTools });
335
+ symlinks.mergeAgents({ tools: enabledTools });
336
+ symlinks.mergeCommands({ tools: enabledTools });
335
337
  }
336
338
 
337
339
  // Routines wizard (since v2.1.0) — surfaces routines added in the new framework version
@@ -370,17 +372,47 @@ async function update(options = {}) {
370
372
  await configureCmd();
371
373
  }
372
374
  } else {
373
- // Optional: detect schema drift by comparing template vs existing.
375
+ // Schema migration: backfill `tools.enabled` for configs created
376
+ // before v3.7.0 (the multi-tool release). Without this, a legacy
377
+ // install that has Codex on the machine would never get .agents/skills/
378
+ // populated, because update would keep defaulting to ['claude'].
374
379
  const yaml = require('js-yaml');
380
+ let needsRewrite = false;
381
+ let cur = {};
382
+ try { cur = yaml.load(fs.readFileSync(configPath, 'utf8')) || {}; } catch (_) { cur = null; }
383
+
384
+ if (cur && (!cur.tools || !Array.isArray(cur.tools.enabled) || cur.tools.enabled.length === 0)) {
385
+ const toolAdapters = require('../utils/tool-adapters');
386
+ const suggested = toolAdapters.defaultEnabled();
387
+ UI.newline();
388
+ UI.warning('Pre-v3.7.0 config detected — `tools.enabled` is missing.');
389
+ UI.info(`From v3.7.0, BALDART can install framework skills for multiple AI tools (Claude Code, OpenAI Codex CLI, …) from the same source.`);
390
+ UI.info(`Autodetected on this machine: ${suggested.join(', ')}`);
391
+ const ok = await UI.confirm(`Backfill \`tools.enabled: [${suggested.join(', ')}]\` into baldart.config.yml?`, true);
392
+ if (ok) {
393
+ cur.tools = cur.tools || {};
394
+ cur.tools.enabled = suggested;
395
+ fs.writeFileSync(configPath, yaml.dump(cur, { lineWidth: 100, noRefs: true }));
396
+ UI.success('Backfilled tools.enabled. Re-running the symlink reconcile to install per-tool skill dirs…');
397
+ // Re-run merge so the just-backfilled tools get their skill dirs populated now.
398
+ try { symlinks.mergeSkills({ tools: suggested }); }
399
+ catch (err) { UI.warning(`Skill merge for new tools failed: ${err.message}`); }
400
+ needsRewrite = true;
401
+ } else {
402
+ UI.info('Skipped. You can add it manually under `tools:` in baldart.config.yml or rerun `npx baldart configure`.');
403
+ }
404
+ }
405
+
406
+ // Detect schema drift on other keys (features.*, paths.*).
375
407
  const templatePath = path.join('.framework', 'framework', 'templates', 'baldart.config.template.yml');
376
408
  if (fs.existsSync(templatePath)) {
377
409
  try {
378
410
  const tpl = yaml.load(fs.readFileSync(templatePath, 'utf8')) || {};
379
- const cur = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
411
+ const cur2 = needsRewrite ? cur : (yaml.load(fs.readFileSync(configPath, 'utf8')) || {});
380
412
  const missingFeatures = Object.keys(tpl.features || {})
381
- .filter((k) => !(k in (cur.features || {})));
413
+ .filter((k) => !(k in (cur2.features || {})));
382
414
  const missingPaths = Object.keys(tpl.paths || {})
383
- .filter((k) => !(k in (cur.paths || {})));
415
+ .filter((k) => !(k in (cur2.paths || {})));
384
416
  if (missingFeatures.length || missingPaths.length) {
385
417
  UI.newline();
386
418
  UI.warning(
@@ -0,0 +1,206 @@
1
+ const crypto = require('crypto');
2
+ const yaml = require('js-yaml');
3
+
4
+ /**
5
+ * Overlay merge engine for agents and commands.
6
+ *
7
+ * Reads a base markdown file (from the framework) plus an overlay file
8
+ * (from `.baldart/overlays/<kind>/<name>.md`) and produces the merged
9
+ * content that BALDART writes into `.claude/<kind>s/<name>.md` at
10
+ * `add`/`update` time.
11
+ *
12
+ * Section model
13
+ * -------------
14
+ * - The base is split into H2 sections (`^## <heading>` … up to next H2/EOF).
15
+ * - The overlay body uses three section markers:
16
+ * `## [OVERRIDE] <heading>` → replace base section <heading> verbatim
17
+ * `## [APPEND] <heading>` → append overlay block after base section
18
+ * `## [PREPEND] <heading>` → prepend overlay block before base section
19
+ * Sections in the overlay without a marker are appended at the end of the
20
+ * merged file as new H2 sections (custom additions).
21
+ *
22
+ * - The base file's frontmatter (YAML between leading `---` fences) is
23
+ * preserved untouched in the output — Claude Code uses `name`, `description`,
24
+ * `tools`, `model` from it. Overlay frontmatter is metadata FOR BALDART only
25
+ * (`base_agent`, `base_agent_version`, `mode`) and is NOT propagated.
26
+ *
27
+ * - The output gets a BALDART marker comment as the first line (above the
28
+ * frontmatter) so `update` can detect that the file is regeneration-safe.
29
+ */
30
+
31
+ const MARKER_PREFIX = '<!-- baldart-generated:';
32
+
33
+ function shortSha(content) {
34
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
35
+ }
36
+
37
+ function buildMarker({ kind, name, baseVersion, overlaySha }) {
38
+ return `${MARKER_PREFIX} kind=${kind} name=${name} base_version=${baseVersion} overlay_sha=${overlaySha}
39
+ DO NOT EDIT MANUALLY. Edit .baldart/overlays/${kind}s/${name}.md instead;
40
+ this file is regenerated by \`npx baldart update\`. -->`;
41
+ }
42
+
43
+ /**
44
+ * Returns the marker fields encoded in the first line if present, or null.
45
+ * Used by `update` to know "is this file ours (safe to regenerate) or did the
46
+ * user edit it manually?"
47
+ */
48
+ function readMarker(content) {
49
+ const firstLine = content.split('\n').slice(0, 3).join('\n');
50
+ if (!firstLine.includes(MARKER_PREFIX)) return null;
51
+ const m = firstLine.match(/kind=(\S+) name=(\S+) base_version=(\S+) overlay_sha=(\S+)/);
52
+ if (!m) return null;
53
+ return { kind: m[1], name: m[2], baseVersion: m[3], overlaySha: m[4] };
54
+ }
55
+
56
+ /**
57
+ * Split markdown text into:
58
+ * - frontmatter: the raw text between leading `---` fences (or '')
59
+ * - preamble: any text after frontmatter before the first H2
60
+ * - sections: ordered list of { heading, marker, body }
61
+ * marker is null | 'OVERRIDE' | 'APPEND' | 'PREPEND'
62
+ */
63
+ function parseMarkdown(text) {
64
+ let frontmatter = '';
65
+ let rest = text;
66
+
67
+ // Strip leading BOM / whitespace
68
+ rest = rest.replace(/^/, '');
69
+
70
+ const fmMatch = rest.match(/^---\n([\s\S]*?)\n---\n?/);
71
+ if (fmMatch) {
72
+ frontmatter = fmMatch[1];
73
+ rest = rest.slice(fmMatch[0].length);
74
+ }
75
+
76
+ // Split on H2 boundaries. Keep delimiters by using a lookahead.
77
+ const parts = rest.split(/(?=^## )/m);
78
+ const preamble = (parts.length && !parts[0].startsWith('## ')) ? parts.shift() : '';
79
+
80
+ const sections = parts.map((block) => {
81
+ // Heading line
82
+ const eol = block.indexOf('\n');
83
+ const headingLine = eol >= 0 ? block.slice(0, eol) : block;
84
+ const body = eol >= 0 ? block.slice(eol + 1) : '';
85
+
86
+ // Detect marker — `## [OVERRIDE] Foo` etc. Marker keyword is upper-case
87
+ // between square brackets, then a single space, then the heading text.
88
+ const m = headingLine.match(/^##\s+\[(OVERRIDE|APPEND|PREPEND)\]\s+(.+?)\s*$/);
89
+ if (m) {
90
+ return { marker: m[1], heading: m[2].trim(), body: body.replace(/\n+$/, '') + '\n' };
91
+ }
92
+ const plain = headingLine.match(/^##\s+(.+?)\s*$/);
93
+ return { marker: null, heading: plain ? plain[1].trim() : '', body: body.replace(/\n+$/, '') + '\n' };
94
+ });
95
+
96
+ return { frontmatter, preamble, sections };
97
+ }
98
+
99
+ function rebuildSections(sections) {
100
+ return sections.map((s) => `## ${s.heading}\n${s.body}`).join('\n').replace(/\n+$/, '\n');
101
+ }
102
+
103
+ /**
104
+ * Merge base + overlay → generated content + warnings.
105
+ *
106
+ * @param {Object} args
107
+ * @param {string} args.kind - 'agent' | 'command'
108
+ * @param {string} args.name - identifier (filename without extension)
109
+ * @param {string} args.baseContent - raw markdown of the framework base
110
+ * @param {string} args.overlayContent - raw markdown of the overlay
111
+ * @param {string} args.frameworkVersion - the installed framework VERSION
112
+ * @returns {{ generated: string, warnings: string[], baseVersionTargeted: string, overlayMode: string }}
113
+ */
114
+ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion }) {
115
+ const warnings = [];
116
+
117
+ // Parse overlay frontmatter for metadata.
118
+ const overlayParsed = parseMarkdown(overlayContent);
119
+ let overlayMeta = {};
120
+ if (overlayParsed.frontmatter) {
121
+ try { overlayMeta = yaml.load(overlayParsed.frontmatter) || {}; }
122
+ catch (err) {
123
+ warnings.push(`overlay frontmatter parse error (${err.message}) — treating overlay as plain markdown without metadata`);
124
+ }
125
+ }
126
+ const baseVersionTargeted = String(overlayMeta[`base_${kind}_version`] || '').trim() || 'unknown';
127
+ const overlayMode = (overlayMeta.mode || 'extend').trim();
128
+
129
+ // Parse the base. Keep its frontmatter verbatim — that's what Claude reads.
130
+ const baseParsed = parseMarkdown(baseContent);
131
+
132
+ // Build a heading → section map for the base. If headings repeat we keep
133
+ // the first occurrence (Claude Code does the same for repeated `name:`).
134
+ const baseSectionByHeading = new Map();
135
+ baseParsed.sections.forEach((s, idx) => {
136
+ if (s.heading && !baseSectionByHeading.has(s.heading)) {
137
+ baseSectionByHeading.set(s.heading, { ...s, _idx: idx });
138
+ }
139
+ });
140
+
141
+ // Apply overlay sections.
142
+ const newSections = baseParsed.sections.map((s) => ({ ...s }));
143
+ const trailing = []; // overlay sections without marker → custom adds at the end
144
+
145
+ for (const ov of overlayParsed.sections) {
146
+ if (!ov.marker) {
147
+ trailing.push(ov);
148
+ continue;
149
+ }
150
+ const base = baseSectionByHeading.get(ov.heading);
151
+ if (!base) {
152
+ warnings.push(`overlay section "${ov.marker} ${ov.heading}" has no matching H2 in base ${kind} "${name}" — appending as new section instead`);
153
+ trailing.push({ marker: null, heading: ov.heading, body: ov.body });
154
+ continue;
155
+ }
156
+ const target = newSections[base._idx];
157
+ if (ov.marker === 'OVERRIDE') {
158
+ target.body = ov.body;
159
+ } else if (ov.marker === 'APPEND') {
160
+ target.body = `${target.body.replace(/\n+$/, '')}\n\n${ov.body}`;
161
+ } else if (ov.marker === 'PREPEND') {
162
+ target.body = `${ov.body.replace(/\n+$/, '')}\n\n${target.body}`;
163
+ }
164
+ }
165
+
166
+ // Compose the merged body.
167
+ const sectionsOut = rebuildSections(newSections);
168
+ const trailingOut = trailing.length
169
+ ? '\n' + trailing.map((s) => `## ${s.heading}\n${s.body}`).join('\n').replace(/\n+$/, '\n')
170
+ : '';
171
+
172
+ const overlaySha = shortSha(overlayContent);
173
+ const marker = buildMarker({
174
+ kind,
175
+ name,
176
+ baseVersion: frameworkVersion || baseVersionTargeted,
177
+ overlaySha
178
+ });
179
+
180
+ const fmBlock = baseParsed.frontmatter
181
+ ? `---\n${baseParsed.frontmatter}\n---\n`
182
+ : '';
183
+
184
+ const generated =
185
+ `${marker}\n` +
186
+ fmBlock +
187
+ (baseParsed.preamble || '') +
188
+ sectionsOut +
189
+ trailingOut;
190
+
191
+ return {
192
+ generated,
193
+ warnings,
194
+ baseVersionTargeted,
195
+ overlayMode,
196
+ overlaySha
197
+ };
198
+ }
199
+
200
+ module.exports = {
201
+ mergeOverlay,
202
+ readMarker,
203
+ parseMarkdown,
204
+ shortSha,
205
+ MARKER_PREFIX
206
+ };
@@ -126,6 +126,9 @@ class SymlinkUtils {
126
126
  async createBulkSymlinks(opts = {}) {
127
127
  const mode = opts.mode || 'prompt';
128
128
 
129
+ // AGENTS.md and the cross-tool `agents/` reference dir remain BULK
130
+ // symlinks — they're single canonical artifacts of the framework that
131
+ // do not need per-item user-overlay behaviour.
129
132
  await this.createSymlink(
130
133
  path.join(FRAMEWORK_PAYLOAD, 'AGENTS.md'),
131
134
  'AGENTS.md',
@@ -140,19 +143,29 @@ class SymlinkUtils {
140
143
 
141
144
  this.ensureDirectory('.claude');
142
145
 
143
- // target is relative to cwd; createSymlink converts it to a path relative
144
- // to the link's parent directory when `relative: true`.
145
- await this.createSymlink(
146
- path.join(FRAMEWORK_PAYLOAD, '.claude', 'agents'),
147
- path.join('.claude', 'agents'),
148
- { relative: true, mode }
149
- );
146
+ // NB: `.claude/agents/` and `.claude/commands/` are NO LONGER bulk
147
+ // symlinks since v3.8.0 — they're real directories with per-item
148
+ // symlinks (or overlay-generated real files). Populated by
149
+ // mergeAgents() and mergeCommands() which createAllSymlinks() invokes
150
+ // right after this method.
151
+ this._convertLegacyBulkSymlinkToDir(path.join('.claude', 'agents'));
152
+ this._convertLegacyBulkSymlinkToDir(path.join('.claude', 'commands'));
153
+ }
150
154
 
151
- await this.createSymlink(
152
- path.join(FRAMEWORK_PAYLOAD, '.claude', 'commands'),
153
- path.join('.claude', 'commands'),
154
- { relative: true, mode }
155
- );
155
+ /**
156
+ * If `rel` exists and is a symlink (the v3.7.x and earlier bulk-symlink
157
+ * layout for .claude/agents and .claude/commands), unlink it so that the
158
+ * per-item merge can populate a real directory in its place. Legacy migration.
159
+ */
160
+ _convertLegacyBulkSymlinkToDir(rel) {
161
+ const full = path.join(this.cwd, rel);
162
+ let lstat = null;
163
+ try { lstat = fs.lstatSync(full); } catch (_) { /* absent */ }
164
+ if (lstat && lstat.isSymbolicLink()) {
165
+ UI.warning(`Detected legacy bulk symlink at ${rel}. Converting to per-item layout (v3.8.0+).`);
166
+ fs.unlinkSync(full);
167
+ }
168
+ this.ensureDirectory(rel);
156
169
  }
157
170
 
158
171
  // -----------------------------------------------------------------------
@@ -305,14 +318,198 @@ class SymlinkUtils {
305
318
  this.mergeSkills({ tools: opts.tools });
306
319
 
307
320
  UI.newline();
321
+ UI.section('Merging Framework Agents');
322
+ this.mergeAgents({ tools: opts.tools });
323
+
324
+ UI.newline();
325
+ UI.section('Merging Framework Commands');
326
+ this.mergeCommands({ tools: opts.tools });
327
+
328
+ UI.newline();
329
+ }
330
+
331
+ // -----------------------------------------------------------------------
332
+ // Per-item merge for agents and commands (v3.8.0+)
333
+ //
334
+ // Same model as skills (per-item, user files coexist, broken-symlink-safe)
335
+ // PLUS overlay support: if `.baldart/overlays/<kind>s/<name>.md` exists,
336
+ // BALDART generates a real file `.claude/<kind>s/<name>.md` = base + overlay
337
+ // merged, with a marker header so future updates know it's regeneration-safe.
338
+ // -----------------------------------------------------------------------
339
+
340
+ mergeAgents(opts = {}) { return this._mergeBulkDir('agent', opts); }
341
+ mergeCommands(opts = {}) { return this._mergeBulkDir('command', opts); }
342
+
343
+ /**
344
+ * Generic per-item merge for kinds whose framework source is a directory
345
+ * of single .md files (agents, commands). For each enabled tool whose
346
+ * adapter declares a target directory (claude.agentsDir(), etc.), this
347
+ * either symlinks each base file or — when an overlay exists — writes a
348
+ * generated real file with the overlay applied.
349
+ */
350
+ _mergeBulkDir(kind, opts = {}) {
351
+ const tools = (opts.tools && opts.tools.length) ? opts.tools : ['claude'];
352
+ const aggregate = { linked: [], generated: [], skipped: [], conflicts: [], warnings: [] };
353
+ const { getAdapter } = require('./tool-adapters');
354
+
355
+ const fwSourceDir = path.join(this.cwd, FRAMEWORK_PAYLOAD, '.claude', `${kind}s`);
356
+ if (!fs.existsSync(fwSourceDir)) {
357
+ UI.warning(`No framework ${kind}s found at ${path.relative(this.cwd, fwSourceDir)}. Skipping ${kind} merge.`);
358
+ return aggregate;
359
+ }
360
+
361
+ const fwItems = fs.readdirSync(fwSourceDir)
362
+ .filter((n) => n.endsWith('.md') && !n.startsWith('.') && n !== 'REGISTRY.md');
363
+
364
+ const frameworkVersion = this._readFrameworkVersion();
365
+
366
+ for (const tool of tools) {
367
+ const adapter = getAdapter(tool, this.cwd);
368
+ const dirGetter = kind === 'agent' ? adapter.agentsDir : adapter.commandsDir;
369
+ const targetRel = dirGetter ? dirGetter.call(adapter) : null;
370
+ if (!targetRel) continue; // tool doesn't support this kind (e.g. Codex)
371
+
372
+ this.ensureDirectory(targetRel);
373
+
374
+ for (const filename of fwItems) {
375
+ const baseName = filename.replace(/\.md$/, '');
376
+ const linkPath = path.join(this.cwd, targetRel, filename);
377
+ const fwAbsolute = path.join(fwSourceDir, filename);
378
+
379
+ // Compute the relative-from-link path used by the symlink so it's
380
+ // portable if the project gets moved.
381
+ const linkDepth = targetRel.split(path.sep).filter(Boolean).length;
382
+ const upPath = path.join(...Array(linkDepth).fill('..'));
383
+ const target = path.join(upPath, FRAMEWORK_PAYLOAD, '.claude', `${kind}s`, filename);
384
+
385
+ const overlayRel = path.join('.baldart', 'overlays', `${kind}s`, filename);
386
+ const overlayAbs = path.join(this.cwd, overlayRel);
387
+ const hasOverlay = fs.existsSync(overlayAbs);
388
+
389
+ // Inspect what currently lives at the link path (if anything).
390
+ let lstat = null;
391
+ try { lstat = fs.lstatSync(linkPath); } catch (_) { /* absent */ }
392
+
393
+ if (hasOverlay) {
394
+ // Generate a real file from base + overlay.
395
+ this._generateOverlayedFile({
396
+ kind, name: baseName, fwAbsolute, overlayAbs, linkPath, lstat,
397
+ frameworkVersion, aggregate, adapter, targetRel
398
+ });
399
+ } else {
400
+ // Plain symlink, with broken-symlink recovery (same fix-pattern as v3.5.1).
401
+ this._installPerItemSymlink({
402
+ linkPath, target, fwAbsolute, lstat, baseName, filename,
403
+ aggregate, adapter, targetRel, kind
404
+ });
405
+ }
406
+ }
407
+ }
408
+
409
+ return aggregate;
410
+ }
411
+
412
+ _installPerItemSymlink({ linkPath, target, fwAbsolute, lstat, baseName, filename, aggregate, adapter, targetRel, kind }) {
413
+ if (!lstat) {
414
+ fs.symlinkSync(target, linkPath);
415
+ UI.success(`[${adapter.label}] ${kind} linked: ${path.join(targetRel, filename)}`);
416
+ aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
417
+ return;
418
+ }
419
+ if (lstat.isSymbolicLink()) {
420
+ const current = fs.readlinkSync(linkPath);
421
+ const resolved = path.resolve(path.dirname(linkPath), current);
422
+ if (current === target || resolved === fwAbsolute) {
423
+ aggregate.skipped.push({ tool: adapter.name, kind, name: baseName, reason: 'already-linked' });
424
+ return;
425
+ }
426
+ // Broken or pointing elsewhere — replace if broken, else respect user override
427
+ if (!fs.existsSync(linkPath)) {
428
+ fs.unlinkSync(linkPath);
429
+ fs.symlinkSync(target, linkPath);
430
+ UI.success(`[${adapter.label}] ${kind} re-linked (was broken): ${path.join(targetRel, filename)}`);
431
+ aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
432
+ return;
433
+ }
434
+ UI.info(`[${adapter.label}] ${kind} kept (user override symlink): ${path.join(targetRel, filename)} → ${current}`);
435
+ aggregate.skipped.push({ tool: adapter.name, kind, name: baseName, reason: 'user-symlink-override' });
436
+ return;
437
+ }
438
+ // Real file. Could be:
439
+ // (a) A baldart-generated file from a previous run where overlay USED to
440
+ // exist but now doesn't — safe to replace with symlink.
441
+ // (b) A user-authored fork — leave alone.
442
+ const { readMarker } = require('./overlay-merger');
443
+ const content = fs.readFileSync(linkPath, 'utf8');
444
+ const marker = readMarker(content);
445
+ if (marker && marker.kind === kind && marker.name === baseName) {
446
+ // Overlay was removed → revert to symlink.
447
+ UI.info(`[${adapter.label}] ${kind} overlay removed — replacing generated file with symlink: ${path.join(targetRel, filename)}`);
448
+ fs.unlinkSync(linkPath);
449
+ fs.symlinkSync(target, linkPath);
450
+ aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
451
+ return;
452
+ }
453
+ UI.warning(`[${adapter.label}] ${kind} name conflict (user file at ${path.join(targetRel, filename)}). Framework version NOT installed. Move user content to .baldart/overlays/${kind}s/${filename} to specialize without losing upstream updates.`);
454
+ aggregate.conflicts.push({
455
+ tool: adapter.name, kind, name: baseName,
456
+ local_path: path.join(targetRel, filename),
457
+ framework_path: path.relative(this.cwd, fwAbsolute),
458
+ detected_at: new Date().toISOString()
459
+ });
460
+ }
461
+
462
+ _generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
463
+ const { mergeOverlay, readMarker } = require('./overlay-merger');
464
+ const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
465
+ const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
466
+ const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });
467
+
468
+ if (merged.warnings && merged.warnings.length) {
469
+ merged.warnings.forEach((w) => {
470
+ UI.warning(`[${adapter.label}] ${kind} ${name}: ${w}`);
471
+ aggregate.warnings.push({ tool: adapter.name, kind, name, warning: w });
472
+ });
473
+ }
474
+
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.
478
+ if (lstat && !lstat.isSymbolicLink()) {
479
+ const existing = fs.readFileSync(linkPath, 'utf8');
480
+ const m = readMarker(existing);
481
+ if (!m) {
482
+ 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
+ aggregate.conflicts.push({
484
+ tool: adapter.name, kind, name,
485
+ local_path: path.join(targetRel, `${name}.md`),
486
+ reason: 'manual-edit-no-marker',
487
+ detected_at: new Date().toISOString()
488
+ });
489
+ return;
490
+ }
491
+ }
492
+
493
+ // Replace any existing symlink/file with the freshly generated content.
494
+ if (lstat) {
495
+ try { fs.unlinkSync(linkPath); }
496
+ catch (_) { try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch (_) { /* noop */ } }
497
+ }
498
+ fs.writeFileSync(linkPath, merged.generated);
499
+ UI.success(`[${adapter.label}] ${kind} generated (overlay applied): ${path.join(targetRel, name)}.md`);
500
+ aggregate.generated.push({ tool: adapter.name, kind, name, overlaySha: merged.overlaySha });
501
+ }
502
+
503
+ _readFrameworkVersion() {
504
+ const vPath = path.join(this.cwd, FRAMEWORK_DIR, 'VERSION');
505
+ try { return fs.readFileSync(vPath, 'utf8').trim(); }
506
+ catch { return 'unknown'; }
308
507
  }
309
508
 
310
509
  verifySymlinks() {
311
510
  const bulkSymlinks = [
312
511
  'AGENTS.md',
313
- 'agents',
314
- '.claude/agents',
315
- '.claude/commands'
512
+ 'agents'
316
513
  ];
317
514
 
318
515
  let allValid = true;
@@ -385,6 +582,39 @@ class SymlinkUtils {
385
582
  UI.warning(`[${adapter.label}] Missing: ${skillsRel}/ (the framework would merge skills here)`);
386
583
  allValid = false;
387
584
  }
585
+
586
+ // v3.8.0+: agents and commands also use per-item merge with overlay support.
587
+ for (const [kindGetter, kindLabel] of [['agentsDir', 'agents'], ['commandsDir', 'commands']]) {
588
+ if (typeof adapter[kindGetter] !== 'function') continue;
589
+ const rel = adapter[kindGetter]();
590
+ if (!rel) continue;
591
+ const full = path.join(this.cwd, rel);
592
+ let s = null;
593
+ try { s = fs.lstatSync(full); } catch (_) { /* absent */ }
594
+ if (!s) {
595
+ UI.warning(`[${adapter.label}] Missing: ${rel}/ (framework ${kindLabel} would be merged here)`);
596
+ allValid = false;
597
+ } else if (s.isSymbolicLink()) {
598
+ UI.warning(`[${adapter.label}] Legacy bulk symlink at ${rel}. Run \`npx baldart update\` to convert to per-item layout.`);
599
+ allValid = false;
600
+ } else {
601
+ const entries = fs.readdirSync(full);
602
+ const frameworkOwned = entries.filter((n) => {
603
+ const p = path.join(full, n);
604
+ try {
605
+ if (fs.lstatSync(p).isSymbolicLink()) return true;
606
+ const content = fs.readFileSync(p, 'utf8');
607
+ return content.startsWith('<!-- baldart-generated:');
608
+ } catch { return false; }
609
+ });
610
+ if (frameworkOwned.length === 0) {
611
+ UI.warning(`[${adapter.label}] ${rel}/ has no framework-managed ${kindLabel}. Run \`npx baldart update\`.`);
612
+ allValid = false;
613
+ } else {
614
+ UI.success(`[${adapter.label}] Valid: ${rel}/ (${frameworkOwned.length} framework-managed ${kindLabel})`);
615
+ }
616
+ }
617
+ }
388
618
  }
389
619
 
390
620
  // Project configuration (v3.0.0+)
@@ -21,6 +21,12 @@ class ClaudeAdapter {
21
21
  /** Where this tool reads skill bundles from. */
22
22
  skillsDir() { return '.claude/skills'; }
23
23
 
24
+ /** Where this tool reads subagent definitions from (null if unsupported). */
25
+ agentsDir() { return '.claude/agents'; }
26
+
27
+ /** Where this tool reads slash commands from (null if unsupported). */
28
+ commandsDir() { return '.claude/commands'; }
29
+
24
30
  /** Whether this tool consumes subagent definitions (`.claude/agents/`). */
25
31
  supportsSubagents() { return true; }
26
32
 
@@ -33,6 +33,13 @@ class CodexAdapter {
33
33
 
34
34
  skillsDir() { return '.agents/skills'; }
35
35
 
36
+ // Codex has no equivalent of Claude subagents or slash commands (custom
37
+ // prompts are deprecated in favor of skills). Returning null disables the
38
+ // per-item merge for those kinds — adapters that DO support them return
39
+ // the target directory string.
40
+ agentsDir() { return null; }
41
+ commandsDir() { return null; }
42
+
36
43
  supportsSubagents() { return false; }
37
44
  supportsSlashCommands() { return false; }
38
45
  supportsHooks() { return false; }