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.
- package/CHANGELOG.md +114 -0
- package/README.md +1 -0
- package/VERSION +1 -1
- package/framework/.claude/agents/CHANGELOG.md +105 -0
- package/framework/.claude/agents/REGISTRY.md +3 -1
- package/framework/.claude/agents/code-reviewer.md +84 -400
- package/framework/.claude/agents/codebase-architect.md +71 -356
- package/framework/.claude/agents/coder.md +130 -291
- package/framework/.claude/agents/doc-reviewer.md +127 -450
- package/framework/.claude/agents/plan-auditor.md +137 -436
- package/framework/.claude/agents/prd-card-writer.md +1 -1
- package/framework/.claude/agents/qa-sentinel.md +59 -313
- package/framework/.claude/agents/skill-improver.md +60 -4
- package/framework/.claude/agents/ui-expert.md +104 -591
- package/framework/.claude/commands/codexreview.md +26 -4
- package/framework/.claude/hooks/agent-discovery-gate.js +2 -2
- package/framework/.claude/hooks/agent-discovery-info.sh +1 -0
- package/framework/.claude/skills/new/CHANGELOG.md +39 -0
- package/framework/.claude/skills/new/SKILL.md +1 -1
- package/framework/.claude/skills/new/references/codex-gate.md +25 -11
- package/framework/.claude/skills/new/references/implement.md +49 -5
- package/framework/.claude/skills/new/references/review-cycle.md +20 -5
- package/framework/.claude/skills/new/scripts/build-review-bundle.mjs +112 -0
- package/framework/.claude/skills/new/scripts/doc-invariants.mjs +166 -0
- package/framework/.claude/skills/new2/CHANGELOG.md +13 -0
- package/framework/.claude/skills/new2/SKILL.md +5 -1
- package/framework/.claude/workflows/new-card-review.js +16 -2
- package/framework/.claude/workflows/new2-resolve.js +11 -4
- package/framework/.claude/workflows/new2.js +51 -3
- package/framework/agents/agent-operating-protocol.md +152 -0
- package/framework/agents/coding-standards.md +2 -2
- package/framework/agents/doc-audit-protocol.md +232 -0
- package/framework/agents/index.md +4 -0
- package/framework/agents/review-protocol.md +207 -0
- package/framework/docs/UPGRADE-3.12-UI-COHERENCE.md +1 -1
- package/framework/routines/finding-mine.routine.yml +56 -0
- package/framework/routines/index.yml +11 -0
- package/package.json +1 -1
- package/src/commands/configure.js +15 -0
- package/src/commands/doctor.js +69 -2
- package/src/utils/agent-slots.js +109 -0
- package/src/utils/overlay-merger.js +17 -8
- package/src/utils/symlinks.js +93 -33
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
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`
|
package/src/utils/symlinks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
571
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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
|
|