baldart 4.99.0 → 5.0.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +1 -0
  3. package/VERSION +1 -1
  4. package/framework/.claude/agents/CHANGELOG.md +105 -0
  5. package/framework/.claude/agents/REGISTRY.md +3 -1
  6. package/framework/.claude/agents/code-reviewer.md +84 -400
  7. package/framework/.claude/agents/codebase-architect.md +71 -356
  8. package/framework/.claude/agents/coder.md +130 -291
  9. package/framework/.claude/agents/doc-reviewer.md +127 -450
  10. package/framework/.claude/agents/plan-auditor.md +137 -436
  11. package/framework/.claude/agents/prd-card-writer.md +1 -1
  12. package/framework/.claude/agents/qa-sentinel.md +59 -313
  13. package/framework/.claude/agents/skill-improver.md +60 -4
  14. package/framework/.claude/agents/ui-expert.md +104 -591
  15. package/framework/.claude/commands/codexreview.md +26 -4
  16. package/framework/.claude/hooks/agent-discovery-gate.js +2 -2
  17. package/framework/.claude/hooks/agent-discovery-info.sh +1 -0
  18. package/framework/.claude/skills/new/CHANGELOG.md +39 -0
  19. package/framework/.claude/skills/new/SKILL.md +1 -1
  20. package/framework/.claude/skills/new/references/codex-gate.md +25 -11
  21. package/framework/.claude/skills/new/references/implement.md +49 -5
  22. package/framework/.claude/skills/new/references/review-cycle.md +20 -5
  23. package/framework/.claude/skills/new/scripts/build-review-bundle.mjs +112 -0
  24. package/framework/.claude/skills/new/scripts/doc-invariants.mjs +166 -0
  25. package/framework/.claude/skills/new2/CHANGELOG.md +13 -0
  26. package/framework/.claude/skills/new2/SKILL.md +5 -1
  27. package/framework/.claude/workflows/new-card-review.js +16 -2
  28. package/framework/.claude/workflows/new2-resolve.js +11 -4
  29. package/framework/.claude/workflows/new2.js +51 -3
  30. package/framework/agents/agent-operating-protocol.md +152 -0
  31. package/framework/agents/coding-standards.md +2 -2
  32. package/framework/agents/doc-audit-protocol.md +232 -0
  33. package/framework/agents/index.md +4 -0
  34. package/framework/agents/review-protocol.md +207 -0
  35. package/framework/docs/UPGRADE-3.12-UI-COHERENCE.md +1 -1
  36. package/framework/routines/finding-mine.routine.yml +56 -0
  37. package/framework/routines/index.yml +11 -0
  38. package/package.json +1 -1
  39. package/src/commands/configure.js +15 -0
  40. package/src/commands/doctor.js +69 -2
  41. package/src/utils/agent-slots.js +109 -0
  42. package/src/utils/overlay-merger.js +17 -8
  43. package/src/utils/symlinks.js +93 -33
@@ -324,7 +324,7 @@ async function detectState(cwd, opts = {}) {
324
324
  const consumerAgentsDir = path.join(cwd, '.claude', 'agents');
325
325
  if (fs.existsSync(frameworkAgentsDir)) {
326
326
  const baldartAgents = fs.readdirSync(frameworkAgentsDir)
327
- .filter((f) => f.endsWith('.md') && f !== 'REGISTRY.md')
327
+ .filter((f) => f.endsWith('.md') && f !== 'REGISTRY.md' && f !== 'CHANGELOG.md')
328
328
  .map((f) => f.replace(/\.md$/, ''));
329
329
  for (const name of baldartAgents) {
330
330
  try {
@@ -337,6 +337,47 @@ async function detectState(cwd, opts = {}) {
337
337
  }
338
338
  } catch (_) { /* never block doctor on probe */ }
339
339
 
340
+ // ---- Slot-generated agents integrity (since v5.0.0) -----------------
341
+ // An agent whose framework source carries `{{#flag}}` conditionals is
342
+ // installed as a GENERATED file (.baldart/generated/agents/<name>.md behind
343
+ // a symlink), stamped with base_sha (RAW framework source), config_sha
344
+ // (resolved flag set) and overlay_sha. Stale = any of the three drifted,
345
+ // or the agent is still a plain pre-5.0 symlink (slots never resolved).
346
+ // Heal = `regenerate-agents` action (delegates to SymlinkUtils.mergeAgents
347
+ // — the doctor never duplicates merge logic). Fully fail-safe.
348
+ state.generatedAgentsStale = [];
349
+ try {
350
+ if (state.frameworkPresent) {
351
+ const slots = require('../utils/agent-slots');
352
+ const om = require('../utils/overlay-merger');
353
+ const flags = slots.resolveAgentFlags((config && !config.__malformed) ? config : {});
354
+ const wantConfigSha = slots.computeAgentConfigSha(flags);
355
+ const frameworkAgentsDir = path.join(cwd, '.framework', 'framework', '.claude', 'agents');
356
+ if (fs.existsSync(frameworkAgentsDir)) {
357
+ for (const f of fs.readdirSync(frameworkAgentsDir)) {
358
+ if (!f.endsWith('.md') || f === 'REGISTRY.md' || f === 'CHANGELOG.md') continue;
359
+ const raw = fs.readFileSync(path.join(frameworkAgentsDir, f), 'utf8');
360
+ if (!slots.hasAgentSlots(raw)) continue;
361
+ const name = f.replace(/\.md$/, '');
362
+ let reason = null;
363
+ try {
364
+ const content = fs.readFileSync(path.join(cwd, '.claude', 'agents', f), 'utf8'); // follows the symlink
365
+ const marker = om.readMarker(content);
366
+ if (!marker || marker.kind !== 'agent') reason = 'slots not resolved (plain pre-5.0 symlink)';
367
+ else if (marker.baseSha !== om.shortSha(raw)) reason = 'framework source changed';
368
+ else if (marker.configSha !== wantConfigSha) reason = 'baldart.config.yml flags changed';
369
+ else {
370
+ const overlayAbs = path.join(cwd, '.baldart', 'overlays', 'agents', f);
371
+ const wantOverlaySha = fs.existsSync(overlayAbs) ? om.shortSha(fs.readFileSync(overlayAbs, 'utf8')) : 'none';
372
+ if (marker.overlaySha !== wantOverlaySha) reason = 'overlay changed';
373
+ }
374
+ } catch (_) { reason = 'not installed'; }
375
+ if (reason) state.generatedAgentsStale.push({ name, reason });
376
+ }
377
+ }
378
+ }
379
+ } catch (_) { /* never block doctor on probe */ }
380
+
340
381
  // ---- Codex agent transpile integrity (S6) --------------------------
341
382
  // When Codex is an enabled tool, every framework agent (.md) must have been
342
383
  // transpiled to `.codex/agents/<name>.toml` (generated on install/update).
@@ -357,7 +398,7 @@ async function detectState(cwd, opts = {}) {
357
398
  const codexAgentsDir = path.join(cwd, '.codex', 'agents');
358
399
  if (fs.existsSync(frameworkAgentsDir)) {
359
400
  const baldartAgents = fs.readdirSync(frameworkAgentsDir)
360
- .filter((f) => f.endsWith('.md') && f !== 'REGISTRY.md')
401
+ .filter((f) => f.endsWith('.md') && f !== 'REGISTRY.md' && f !== 'CHANGELOG.md')
361
402
  .map((f) => f.replace(/\.md$/, ''));
362
403
  for (const name of baldartAgents) {
363
404
  try { fs.statSync(path.join(codexAgentsDir, `${name}.toml`)); }
@@ -963,6 +1004,32 @@ function planActions(state) {
963
1004
  });
964
1005
  }
965
1006
 
1007
+ // Slot-generated agents integrity (since v5.0.0). Regenerates any agent whose
1008
+ // installed copy drifted from source/config/overlay, or that a pre-5.0 install
1009
+ // left as a plain symlink (slot conditionals never resolved — the model would
1010
+ // read ALL variants). Delegates to `SymlinkUtils.mergeAgents`, same rationale
1011
+ // as `repair-agent-symlinks`: only the merge pass, never a full update.
1012
+ if (state.generatedAgentsStale && state.generatedAgentsStale.length > 0) {
1013
+ const detail = state.generatedAgentsStale.map((a) => `${a.name}: ${a.reason}`).join('; ');
1014
+ actions.push({
1015
+ key: 'regenerate-agents',
1016
+ label: `Regenerate ${state.generatedAgentsStale.length} slot-generated agent(s)`,
1017
+ why: `These agents are generated per-consumer from their framework source + baldart.config.yml flags (+ overlay), and the installed copy is out of date (${detail}). They are BALDART-generated — edit .baldart/overlays/agents/<name>.md, not the file itself.`,
1018
+ autoOk: true,
1019
+ run: async () => {
1020
+ const SymlinkUtils = require('../utils/symlinks');
1021
+ const cfg = loadConfig(process.cwd());
1022
+ const enabledTools = (cfg && !cfg.__malformed
1023
+ && cfg.tools && Array.isArray(cfg.tools.enabled))
1024
+ ? cfg.tools.enabled
1025
+ : ['claude'];
1026
+ const symlinks = new SymlinkUtils();
1027
+ symlinks.mergeAgents({ tools: enabledTools });
1028
+ UI.success(`Regenerated slot-generated agents for tools: ${enabledTools.join(', ')}`);
1029
+ },
1030
+ });
1031
+ }
1032
+
966
1033
  // Workflow symlink integrity (since v4.14.0). Advisory — a missing workflow
967
1034
  // symlink degrades the consuming skill to its inline path, it does not block.
968
1035
  // Re-runs only the workflow merge pass (same rationale as the agent repair).
@@ -0,0 +1,109 @@
1
+ /**
2
+ * agent-slots.js — install-time slot resolution for agent definitions (v5.0.0).
3
+ *
4
+ * Agent sources under framework/.claude/agents/ may carry `{{#flag}}…{{/flag}}`
5
+ * conditionals (SLOT syntax — flags ONLY: no `{{ scalar }}`, no `{{> partial}}`;
6
+ * `${paths.*}` references stay runtime-resolved prose, unchanged). At install/
7
+ * update time the conditionals are resolved from the consumer's
8
+ * `baldart.config.yml`, so the installed agent contains ONLY the variant that
9
+ * matches this project (e.g. one `stack.database` block instead of four).
10
+ *
11
+ * Every flag derives from EXISTING config keys — this module introduces no new
12
+ * `baldart.config.yml` surface (schema-change propagation rule: N/A).
13
+ *
14
+ * Consumed by SymlinkUtils._mergeBulkDir (kind `agent`, MERGE_KINDS.slots) and
15
+ * by the doctor staleness probe. Pure functions except loadAgentConfig (fs read).
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const yaml = require('js-yaml');
21
+ const { shortSha } = require('./overlay-merger');
22
+
23
+ /** Parse baldart.config.yml; {} when absent/malformed (fail-open: no flags → all conditionals drop). */
24
+ function loadAgentConfig(cwd) {
25
+ try {
26
+ return yaml.load(fs.readFileSync(path.join(cwd, 'baldart.config.yml'), 'utf8')) || {};
27
+ } catch (_) {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Derive the agent-slot flag set from the config. Keep this list in sync with
34
+ * the flags actually used by framework/.claude/agents/*.md sources — doctor's
35
+ * staleness probe hashes exactly this object (config_sha).
36
+ */
37
+ function resolveAgentFlags(cfg) {
38
+ const features = (cfg && cfg.features) || {};
39
+ const stack = (cfg && cfg.stack) || {};
40
+ const db = String(stack.database || 'none').toLowerCase();
41
+ const relational = ['postgres', 'postgresql', 'supabase', 'mysql', 'sqlite'].includes(db);
42
+ return {
43
+ // features.* (existing keys only)
44
+ has_backlog: features.has_backlog === true,
45
+ has_adrs: features.has_adrs === true,
46
+ has_i18n: features.has_i18n === true,
47
+ has_design_system: features.has_design_system === true,
48
+ has_toolchain: features.has_toolchain === true,
49
+ has_lsp_layer: features.has_lsp_layer === true,
50
+ has_code_graph: features.has_code_graph === true,
51
+ has_wiki_overlay: features.has_wiki_overlay === true,
52
+ multi_tenant_theming: features.multi_tenant_theming === true,
53
+ // stack.database → exactly one db_* true (db_none when unset/none)
54
+ db_firestore: db === 'firestore',
55
+ db_relational: relational,
56
+ db_mongodb: db === 'mongodb',
57
+ db_dynamodb: db === 'dynamodb',
58
+ db_none: !relational && !['firestore', 'mongodb', 'dynamodb'].includes(db),
59
+ // stack.* accents
60
+ fw_nextjs: String(stack.framework || '').toLowerCase() === 'nextjs',
61
+ deploy_vercel: String(stack.deployment || '').toLowerCase() === 'vercel',
62
+ e2e_playwright: String((stack.testing && stack.testing.e2e) || '').toLowerCase() === 'playwright',
63
+ };
64
+ }
65
+
66
+ /** True when the content carries slot syntax (flag conditionals only). */
67
+ function hasAgentSlots(content) {
68
+ return /\{\{#/.test(content);
69
+ }
70
+
71
+ /**
72
+ * Resolve `{{#flag}}…{{/flag}}` conditionals against the flag set. Returns
73
+ * { content, residual } where residual lists any `{{…}}` still present after
74
+ * the fill — always an authoring error (unknown flag / malformed block), never
75
+ * silently stripped, so the caller can surface it.
76
+ *
77
+ * Deliberately NOT root-primitives' fillTemplate: that engine also substitutes
78
+ * scalars/partials and blanks unknown `{{ x }}` tokens — for agents a leftover
79
+ * token must be a loud warning, not an empty string.
80
+ */
81
+ function fillAgentSlots(content, flags) {
82
+ // Iterate to a fixpoint (bounded) so NESTED conditionals resolve too: a kept
83
+ // outer block re-exposes its inner `{{#flag}}` blocks on the next pass; a
84
+ // dropped outer block removes its inner blocks with it (correct semantics).
85
+ let out = content;
86
+ for (let i = 0; i < 5; i++) {
87
+ const next = out.replace(/\{\{#([\w.]+)\}\}\n?([\s\S]*?)\{\{\/\1\}\}\n?/g, (_, key, body) =>
88
+ (flags[key] ? body : ''));
89
+ if (next === out) break;
90
+ out = next;
91
+ }
92
+ const residual = [...new Set((out.match(/\{\{[^}]*\}\}/g) || []))];
93
+ // collapse blank runs left by dropped blocks; keep everything else verbatim
94
+ const cleaned = out.replace(/\n{3,}/g, '\n\n');
95
+ return { content: cleaned, residual };
96
+ }
97
+
98
+ /** Stable hash of the resolved flag set — stamped as config_sha in the marker. */
99
+ function computeAgentConfigSha(flags) {
100
+ return shortSha(JSON.stringify(flags));
101
+ }
102
+
103
+ module.exports = {
104
+ loadAgentConfig,
105
+ resolveAgentFlags,
106
+ hasAgentSlots,
107
+ fillAgentSlots,
108
+ computeAgentConfigSha,
109
+ };
@@ -133,9 +133,13 @@ function refreshOverlayVersionPin(overlayPath, kind, frameworkVersion, currentBa
133
133
  return { changed: true, reason: 'refreshed' };
134
134
  }
135
135
 
136
- function buildMarker({ kind, name, baseSha, overlaySha }) {
136
+ function buildMarker({ kind, name, baseSha, overlaySha, configSha }) {
137
137
  const baseShaField = baseSha ? ` base_sha=${baseSha}` : '';
138
- return `${MARKER_PREFIX} kind=${kind} name=${name}${baseShaField} overlay_sha=${overlaySha}
138
+ // config_sha (v5.0.0, slot-generated agents) is a TRAILING field on purpose:
139
+ // readMarker and the edit-gate's GENERATED_MARKER_RE match a prefix ending at
140
+ // overlay_sha, so older readers tolerate it transparently.
141
+ const configShaField = configSha ? ` config_sha=${configSha}` : '';
142
+ return `${MARKER_PREFIX} kind=${kind} name=${name}${baseShaField} overlay_sha=${overlaySha}${configShaField}
139
143
  DO NOT EDIT MANUALLY. Edit .baldart/overlays/${kind}s/${name}.md instead;
140
144
  this file is regenerated by \`npx baldart update\`. -->`;
141
145
  }
@@ -162,9 +166,9 @@ function readMarker(content) {
162
166
  const block = content.slice(idx, idx + 4000);
163
167
  const end = block.indexOf('-->');
164
168
  if (end === -1) return null;
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+)/);
169
+ const m = block.slice(0, end).match(/kind=(\S+)\s+name=(\S+)(?:\s+base_version=(\S+))?(?:\s+base_sha=(\S+))?\s+overlay_sha=(\S+)(?:\s+config_sha=(\S+))?/);
166
170
  if (!m) return null;
167
- return { kind: m[1], name: m[2], baseVersion: m[3] || null, baseSha: m[4] || null, overlaySha: m[5] };
171
+ return { kind: m[1], name: m[2], baseVersion: m[3] || null, baseSha: m[4] || null, overlaySha: m[5], configSha: m[6] || null };
168
172
  }
169
173
 
170
174
  /**
@@ -234,7 +238,7 @@ function rebuildSections(sections) {
234
238
  * @param {string} args.frameworkVersion - the installed framework VERSION
235
239
  * @returns {{ generated: string, warnings: string[], baseVersionTargeted: string, overlayMode: string }}
236
240
  */
237
- function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion }) {
241
+ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion, configSha, baseShaOverride }) {
238
242
  const warnings = [];
239
243
 
240
244
  // Parse overlay frontmatter for metadata.
@@ -293,9 +297,14 @@ function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersio
293
297
  ? '\n' + trailing.map((s) => `## ${s.heading}\n${s.body}`).join('\n').replace(/\n+$/, '\n')
294
298
  : '';
295
299
 
296
- const overlaySha = shortSha(overlayContent);
297
- const baseSha = shortSha(baseContent);
298
- const marker = buildMarker({ kind, name, baseSha, overlaySha });
300
+ // An absent overlay (slot-only generation, v5.0.0) is stamped `none`, not
301
+ // sha-of-empty-string truthful and stable for byte-identity comparisons.
302
+ const overlaySha = overlayContent ? shortSha(overlayContent) : 'none';
303
+ // baseShaOverride: slot-generated agents pin the RAW framework source here
304
+ // (upstream-drift signal), not the filled content (which changes with config
305
+ // — that drift is config_sha's job).
306
+ const baseSha = baseShaOverride || shortSha(baseContent);
307
+ const marker = buildMarker({ kind, name, baseSha, overlaySha, configSha });
299
308
 
300
309
  const fmBlock = baseParsed.frontmatter
301
310
  ? `---\n${baseParsed.frontmatter}\n---\n`
@@ -30,7 +30,10 @@ const CONFLICT_LOG = path.join('.baldart', 'skill-conflicts.json');
30
30
  // kind name doesn't pluralize to the on-disk dir — e.g. `outputStyle` lives in
31
31
  // the hyphenated `output-styles/` dir Claude Code requires, not `outputStyles/`.
32
32
  const MERGE_KINDS = {
33
- agent: { dirGetter: 'agentsDir', ext: '.md', overlay: true, exclude: new Set(['REGISTRY.md']) },
33
+ // - slots: `.md` sources may carry `{{#flag}}` conditionals resolved at
34
+ // install-time from baldart.config.yml (v5.0.0 — see agent-slots.js).
35
+ // A slot-bearing source is GENERATED per-consumer even without an overlay.
36
+ agent: { dirGetter: 'agentsDir', ext: '.md', overlay: true, slots: true, exclude: new Set(['REGISTRY.md', 'CHANGELOG.md']) },
34
37
  command: { dirGetter: 'commandsDir', ext: '.md', overlay: true, exclude: new Set(['REGISTRY.md']) },
35
38
  workflow: { dirGetter: 'workflowsDir', ext: '.js', overlay: false, exclude: new Set() },
36
39
  outputStyle: { dirGetter: 'outputStylesDir', ext: '.md', overlay: false, exclude: new Set(), srcDir: 'output-styles' },
@@ -415,6 +418,28 @@ class SymlinkUtils {
415
418
 
416
419
  const frameworkVersion = this._readFrameworkVersion();
417
420
 
421
+ // v5.0.0 — install-time slot resolution (kinds with cfg.slots). Flags are
422
+ // resolved ONCE per merge run from baldart.config.yml; only sources that
423
+ // actually carry `{{#flag}}` syntax pay the fill (others stay symlinks).
424
+ let slotEnv = null;
425
+ const slotFillFor = (fwAbsolute) => {
426
+ if (!cfg.slots) return null;
427
+ const raw = fs.readFileSync(fwAbsolute, 'utf8');
428
+ const slots = require('./agent-slots');
429
+ if (!slots.hasAgentSlots(raw)) return null;
430
+ if (!slotEnv) {
431
+ const flags = slots.resolveAgentFlags(slots.loadAgentConfig(this.cwd));
432
+ slotEnv = { flags, configSha: slots.computeAgentConfigSha(flags) };
433
+ }
434
+ const filled = slots.fillAgentSlots(raw, slotEnv.flags);
435
+ return {
436
+ content: filled.content,
437
+ residual: filled.residual,
438
+ configSha: slotEnv.configSha,
439
+ rawBaseSha: require('./overlay-merger').shortSha(raw),
440
+ };
441
+ };
442
+
418
443
  for (const tool of tools) {
419
444
  const adapter = getAdapter(tool, this.cwd);
420
445
  const dirGetter = adapter[cfg.dirGetter];
@@ -438,14 +463,25 @@ class SymlinkUtils {
438
463
  const overlayAbs = path.join(this.cwd, overlayRel);
439
464
  const hasOverlay = cfg.overlay && fs.existsSync(overlayAbs);
440
465
 
466
+ // Slot fill (v5.0.0): resolved BEFORE overlay merge and BEFORE transpile,
467
+ // so both consume the config-effective markdown. A leftover `{{…}}` after
468
+ // the fill is an authoring error — surfaced, never silently stripped.
469
+ const slotFill = slotFillFor(fwAbsolute);
470
+ if (slotFill && slotFill.residual.length) {
471
+ const w = `unresolved slot token(s) after fill: ${slotFill.residual.join(' ')} — check the flag names in the framework source`;
472
+ UI.warning(`[${adapter.label}] ${kind} ${baseName}: ${w}`);
473
+ aggregate.warnings.push({ tool: adapter.name, kind, name: baseName, warning: w });
474
+ }
475
+
441
476
  // Transpile path: when the adapter declares a transpiler for this kind
442
477
  // (e.g. Codex agents → TOML), do NOT symlink the `.md` source — generate
443
- // the tool-native file from it (overlay-merged first when present).
478
+ // the tool-native file from it (slot-filled + overlay-merged first).
444
479
  const transpiler = (typeof adapter.transpilerFor === 'function') ? adapter.transpilerFor(kind) : null;
445
480
  if (transpiler) {
446
481
  this._installTranspiledFile({
447
482
  kind, baseName, fwAbsolute, overlayAbs, hasOverlay,
448
- targetRel, transpiler, adapter, frameworkVersion, aggregate
483
+ targetRel, transpiler, adapter, frameworkVersion, aggregate,
484
+ baseContentOverride: slotFill ? slotFill.content : null,
449
485
  });
450
486
  continue;
451
487
  }
@@ -454,11 +490,14 @@ class SymlinkUtils {
454
490
  let lstat = null;
455
491
  try { lstat = fs.lstatSync(linkPath); } catch (_) { /* absent */ }
456
492
 
457
- if (hasOverlay) {
458
- // Generate a real file from base + overlay.
493
+ if (hasOverlay || slotFill) {
494
+ // Generate a real file: slot-filled base + (optional) overlay.
459
495
  this._generateOverlayedFile({
460
496
  kind, name: baseName, fwAbsolute, overlayAbs, linkPath, lstat,
461
- frameworkVersion, aggregate, adapter, targetRel
497
+ frameworkVersion, aggregate, adapter, targetRel, hasOverlay,
498
+ baseContentOverride: slotFill ? slotFill.content : null,
499
+ configSha: slotFill ? slotFill.configSha : null,
500
+ baseShaOverride: slotFill ? slotFill.rawBaseSha : null,
462
501
  });
463
502
  } else {
464
503
  // Plain symlink, with broken-symlink recovery (same fix-pattern as v3.5.1).
@@ -562,13 +601,14 @@ class SymlinkUtils {
562
601
  * `# baldart-generated:` marker so a later update can safely regenerate it
563
602
  * and a user fork (no marker) is never clobbered.
564
603
  */
565
- _installTranspiledFile({ kind, baseName, fwAbsolute, overlayAbs, hasOverlay, targetRel, transpiler, adapter, frameworkVersion, aggregate }) {
604
+ _installTranspiledFile({ kind, baseName, fwAbsolute, overlayAbs, hasOverlay, targetRel, transpiler, adapter, frameworkVersion, aggregate, baseContentOverride = null }) {
566
605
  const outName = `${baseName}${transpiler.outExt}`;
567
606
  const outPath = path.join(this.cwd, targetRel, outName);
568
607
  const relOut = path.join(targetRel, outName);
569
608
 
570
- // Effective markdown = overlay-merged when an overlay exists, else the base.
571
- const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
609
+ // Effective markdown = slot-filled (v5.0.0, when the source carries {{#flag}})
610
+ // then overlay-merged when an overlay exists, else the base.
611
+ const baseContent = baseContentOverride != null ? baseContentOverride : fs.readFileSync(fwAbsolute, 'utf8');
572
612
  let effectiveMd = baseContent;
573
613
  if (hasOverlay) {
574
614
  try {
@@ -621,27 +661,33 @@ class SymlinkUtils {
621
661
  aggregate.generated.push({ tool: adapter.name, kind, name: baseName });
622
662
  }
623
663
 
624
- _generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
664
+ _generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel, hasOverlay = true, baseContentOverride = null, configSha = null, baseShaOverride = null }) {
625
665
  const { mergeOverlay, isGeneratedFile, computeBaseFileSha, ensureBaseFileShaInOverlay, refreshOverlayVersionPin } = require('./overlay-merger');
626
- const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
627
- const baseSha = computeBaseFileSha(baseContent);
628
- // Backfill base_file_sha into the overlay frontmatter so `overlay drift` is
629
- // deterministic for EVERY overlay, not just freshly-scaffolded ones
630
- // (additive-only, never overwrites). Done BEFORE reading overlayContent so
631
- // the marker's overlay_sha already reflects it — no one-time regen churn.
632
- // (v3.33.0)
633
- try {
634
- const bf = ensureBaseFileShaInOverlay(overlayAbs, baseSha);
635
- if (bf.changed) UI.info(`[${adapter.label}] ${kind} ${name}: backfilled base_file_sha into overlay frontmatter.`);
636
- // Keep the informational base_<kind>_version pin truthfulbut ONLY when
637
- // the base content is unchanged (sha matches). When the base HAS changed
638
- // the sha differs and the pin is left stale on purpose, so it stays a
639
- // legitimate drift signal to reconcile. (v4.11.0)
640
- const vp = refreshOverlayVersionPin(overlayAbs, kind, frameworkVersion, baseSha);
641
- if (vp.changed) UI.info(`[${adapter.label}] ${kind} ${name}: refreshed base_${kind}_version pin to v${frameworkVersion} (base unchanged).`);
642
- } catch (_) { /* non-fatal never block a merge on backfill */ }
643
- const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
644
- const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });
666
+ // baseContentOverride = the slot-filled markdown (v5.0.0). The overlay pin
667
+ // and the marker's base_sha stay anchored to the RAW framework source
668
+ // (baseShaOverride) so upstream drift and config drift are separate signals.
669
+ const baseContent = baseContentOverride != null ? baseContentOverride : fs.readFileSync(fwAbsolute, 'utf8');
670
+ const baseSha = baseShaOverride || computeBaseFileSha(baseContent);
671
+ let overlayContent = '';
672
+ if (hasOverlay) {
673
+ // Backfill base_file_sha into the overlay frontmatter so `overlay drift` is
674
+ // deterministic for EVERY overlay, not just freshly-scaffolded ones
675
+ // (additive-only, never overwrites). Done BEFORE reading overlayContent so
676
+ // the marker's overlay_sha already reflects it no one-time regen churn.
677
+ // (v3.33.0)
678
+ try {
679
+ const bf = ensureBaseFileShaInOverlay(overlayAbs, baseSha);
680
+ if (bf.changed) UI.info(`[${adapter.label}] ${kind} ${name}: backfilled base_file_sha into overlay frontmatter.`);
681
+ // Keep the informational base_<kind>_version pin truthful but ONLY when
682
+ // the base content is unchanged (sha matches). When the base HAS changed
683
+ // the sha differs and the pin is left stale on purpose, so it stays a
684
+ // legitimate drift signal to reconcile. (v4.11.0)
685
+ const vp = refreshOverlayVersionPin(overlayAbs, kind, frameworkVersion, baseSha);
686
+ if (vp.changed) UI.info(`[${adapter.label}] ${kind} ${name}: refreshed base_${kind}_version pin to v${frameworkVersion} (base unchanged).`);
687
+ } catch (_) { /* non-fatal — never block a merge on backfill */ }
688
+ overlayContent = fs.readFileSync(overlayAbs, 'utf8');
689
+ }
690
+ const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion, configSha, baseShaOverride: baseSha });
645
691
 
646
692
  if (merged.warnings && merged.warnings.length) {
647
693
  merged.warnings.forEach((w) => {
@@ -684,20 +730,34 @@ class SymlinkUtils {
684
730
  // Legacy generated file at linkPath — migrate (delete in-place, rewrite at generatedPath, symlink).
685
731
  }
686
732
 
687
- // Write merged content to the indirect location.
688
- fs.writeFileSync(generatedPath, merged.generated);
689
-
690
733
  // Compute the relative symlink target (portable across moves of the
691
734
  // project root).
692
735
  const relativeTarget = path.relative(path.dirname(linkPath), generatedPath);
693
736
 
737
+ // Byte-stability (v5.0.0): when the generated content is unchanged AND the
738
+ // consumer-visible symlink already points at it, this is a no-op — skip
739
+ // silently so repeated `baldart update` runs produce zero churn.
740
+ try {
741
+ if (fs.existsSync(generatedPath)
742
+ && fs.readFileSync(generatedPath, 'utf8') === merged.generated
743
+ && lstat && lstat.isSymbolicLink()
744
+ && path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath)) === generatedPath) {
745
+ aggregate.skipped.push({ tool: adapter.name, kind, name, reason: 'already-generated' });
746
+ return;
747
+ }
748
+ } catch (_) { /* fall through to the full write */ }
749
+
750
+ // Write merged content to the indirect location.
751
+ fs.writeFileSync(generatedPath, merged.generated);
752
+
694
753
  // Replace whatever currently lives at linkPath with the new symlink.
695
754
  if (lstat) {
696
755
  try { fs.unlinkSync(linkPath); }
697
756
  catch (_) { try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch (_) { /* noop */ } }
698
757
  }
699
758
  fs.symlinkSync(relativeTarget, linkPath);
700
- UI.success(`[${adapter.label}] ${kind} generated (overlay applied): ${path.join(targetRel, name)}.md ${relativeTarget}`);
759
+ const mode = hasOverlay && configSha ? 'slots + overlay' : (hasOverlay ? 'overlay applied' : 'slots resolved');
760
+ UI.success(`[${adapter.label}] ${kind} generated (${mode}): ${path.join(targetRel, name)}.md → ${relativeTarget}`);
701
761
  aggregate.generated.push({ tool: adapter.name, kind, name, overlaySha: merged.overlaySha });
702
762
  }
703
763