test+docs: add central config unit tests; fix stale recipe count

- test: two new suites (35 + 36) covering writeCentralConfig and
  ensureCustomConfigStubs. Verifies scope partitioning (user_name
  lands only in config.user.toml), core-key pollution stripping
  from [modules.*], unknown-schema fallthrough (external modules
  survive without schema), agent roster baked into config.toml
  [agents.*] only, stub-preservation on re-install. 44 new
  assertions.
- docs: fixed four stale "four recipes" references to say "five"
  after Recipe 5 (Customize the Agent Roster) was added. Touches
  frontmatter, opening paragraph, Combining Recipes paragraph,
  and the named-agents cross-link blurb.
This commit is contained in:
Brian Madison 2026-04-19 22:19:37 -05:00
parent 670050fa90
commit 82d2b4de1a
3 changed files with 171 additions and 4 deletions

View File

@ -82,7 +82,7 @@ There's also a second customization surface for *cross-cutting* concerns: the ce
For the full customization surface and worked examples, see: For the full customization surface and worked examples, see:
- [How to Customize BMad](../how-to/customize-bmad.md) — the reference for what's customizable and how merge works - [How to Customize BMad](../how-to/customize-bmad.md) — the reference for what's customizable and how merge works
- [How to Expand BMad for Your Organization](../how-to/expand-bmad-for-your-org.md) — four worked recipes spanning agent-wide rules, workflow conventions, external publishing, and template swaps - [How to Expand BMad for Your Organization](../how-to/expand-bmad-for-your-org.md) — five worked recipes spanning agent-wide rules, workflow conventions, external publishing, template swaps, and agent roster customization
## The Bigger Idea ## The Bigger Idea

View File

@ -1,11 +1,11 @@
--- ---
title: 'How to Expand BMad for Your Organization' title: 'How to Expand BMad for Your Organization'
description: Four customization patterns that reshape BMad without forking — org conventions, agent-wide rules, external publishing, and template swaps description: Five customization patterns that reshape BMad without forking — agent-wide rules, workflow conventions, external publishing, template swaps, and agent roster changes
sidebar: sidebar:
order: 9 order: 9
--- ---
BMad's customization surface lets an organization reshape behavior without editing installed files or forking skills. This guide walks through four recipes that cover most enterprise needs. BMad's customization surface lets an organization reshape behavior without editing installed files or forking skills. This guide walks through five recipes that cover most enterprise needs.
:::note[Prerequisites] :::note[Prerequisites]
@ -225,7 +225,7 @@ Keep the IDE file **succinct**. A dozen well-chosen lines are more effective tha
## Combining Recipes ## Combining Recipes
All four recipes compose. A realistic enterprise override for `bmad-product-brief` might set `persistent_facts` (Recipe 2), `on_complete` (Recipe 3), and `brief_template` (Recipe 4) in one file. The agent-level rule (Recipe 1) lives in a separate file under the agent's name and applies in parallel. All five recipes compose. A realistic enterprise override for `bmad-product-brief` might set `persistent_facts` (Recipe 2), `on_complete` (Recipe 3), and `brief_template` (Recipe 4) in one file. The agent-level rule (Recipe 1) lives in a separate file under the agent's name, central config (Recipe 5) pins the shared roster and team settings, and all four apply in parallel.
```toml ```toml
# _bmad/custom/bmad-product-brief.toml (workflow-level) # _bmad/custom/bmad-product-brief.toml (workflow-level)

View File

@ -2023,6 +2023,173 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 35: Central Config Emission
// ============================================================
console.log(`${colors.yellow}Test Suite 35: Central Config Emission${colors.reset}\n`);
{
// Use the real src/ tree (core-skills + bmm-skills module.yaml are read via
// getModulePath). Only the destination bmadDir is a temp dir, which the
// installer writes config.toml / config.user.toml / custom/ into.
const tempBmadDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-central-config-'));
try {
const moduleConfigs = {
core: {
user_name: 'TestUser',
communication_language: 'Spanish',
document_output_language: 'English',
output_folder: '_bmad-output',
},
bmm: {
project_name: 'demo-project',
user_skill_level: 'expert',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
project_knowledge: '{project-root}/docs',
// Spread-from-core pollution: legacy per-module config.yaml merges
// core values into every module; writeCentralConfig must strip these
// from [modules.bmm] so core values only live in [core].
user_name: 'TestUser',
communication_language: 'Spanish',
document_output_language: 'English',
output_folder: '_bmad-output',
},
'external-mod': {
// No src/modules/external-mod/module.yaml exists; installer treats
// this as unknown-schema and falls through. Core-key stripping still
// applies, so user_name/language must NOT appear under this module.
custom_setting: 'external-value',
another_setting: 'another-value',
user_name: 'TestUser',
communication_language: 'Spanish',
},
};
const generator35 = new ManifestGenerator();
generator35.bmadDir = tempBmadDir35;
generator35.bmadFolderName = path.basename(tempBmadDir35);
generator35.updatedModules = ['core', 'bmm', 'external-mod'];
// collectAgentsFromModuleYaml reads from src/bmm-skills/module.yaml
await generator35.collectAgentsFromModuleYaml();
assert(generator35.agents.length >= 6, 'collectAgentsFromModuleYaml discovers bmm agents from module.yaml (>= 6 agents)');
const maryEntry = generator35.agents.find((a) => a.code === 'bmad-agent-analyst');
assert(maryEntry !== undefined, 'collectAgentsFromModuleYaml includes bmad-agent-analyst');
assert(maryEntry && maryEntry.name === 'Mary', 'Agent entry carries name field');
assert(maryEntry && maryEntry.title === 'Business Analyst', 'Agent entry carries title field');
assert(maryEntry && maryEntry.icon === '📊', 'Agent entry carries icon field');
assert(maryEntry && maryEntry.description.length > 0, 'Agent entry carries description field');
assert(maryEntry && maryEntry.module === 'bmm', 'Agent entry module derives from owning module');
assert(maryEntry && maryEntry.team === 'bmm', 'Agent entry team defaults to module code');
// writeCentralConfig produces the two root files
const [teamPath, userPath] = await generator35.writeCentralConfig(tempBmadDir35, moduleConfigs);
assert(teamPath === path.join(tempBmadDir35, 'config.toml'), 'writeCentralConfig returns team config path');
assert(userPath === path.join(tempBmadDir35, 'config.user.toml'), 'writeCentralConfig returns user config path');
assert(await fs.pathExists(teamPath), 'config.toml is written to disk');
assert(await fs.pathExists(userPath), 'config.user.toml is written to disk');
const teamContent = await fs.readFile(teamPath, 'utf8');
const userContent = await fs.readFile(userPath, 'utf8');
// [core] — team-scoped keys land in config.toml
assert(teamContent.includes('[core]'), 'config.toml has [core] section');
assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml');
assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml');
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml');
assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
// [core] — user-scoped keys land in config.user.toml
assert(userContent.includes('[core]'), 'config.user.toml has [core] section');
assert(userContent.includes('user_name = "TestUser"'), 'user_name lands in config.user.toml');
assert(userContent.includes('communication_language = "Spanish"'), 'communication_language lands in config.user.toml');
assert(!userContent.includes('document_output_language'), 'Team-scope key is absent from config.user.toml');
// [modules.bmm] — core-key pollution stripped; own user-scope key routed to user file
const bmmTeamMatch = teamContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section');
if (bmmTeamMatch) {
const bmmTeamBlock = bmmTeamMatch[0];
assert(bmmTeamBlock.includes('project_name = "demo-project"'), 'bmm team-scope key lands under [modules.bmm]');
assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)');
assert(!bmmTeamBlock.includes('communication_language'), 'communication_language stripped from [modules.bmm]');
assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml');
}
const bmmUserMatch = userContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section');
if (bmmUserMatch) {
assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level lands in config.user.toml [modules.bmm]');
}
// [modules.external-mod] — unknown schema, falls through as team; core keys still stripped
const extMatch = teamContent.match(/\[modules\.external-mod\][\s\S]*?(?=\n\[|$)/);
assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section');
if (extMatch) {
const extBlock = extMatch[0];
assert(extBlock.includes('custom_setting = "external-value"'), 'Unknown-schema module retains its own keys');
assert(!extBlock.includes('user_name'), 'Core-key pollution stripped from unknown-schema module too');
assert(!extBlock.includes('communication_language'), 'All core-key pollution stripped from unknown-schema module');
}
// [agents.*] — agent roster from bmm module.yaml baked into config.toml (team-only)
assert(teamContent.includes('[agents.bmad-agent-analyst]'), 'config.toml has [agents.bmad-agent-analyst] table');
assert(teamContent.includes('[agents.bmad-agent-dev]'), 'config.toml has [agents.bmad-agent-dev] table');
assert(teamContent.includes('module = "bmm"'), 'Agent entry serializes module field');
assert(teamContent.includes('team = "bmm"'), 'Agent entry serializes team field');
assert(teamContent.includes('name = "Mary"'), 'Agent entry serializes name');
assert(teamContent.includes('icon = "📊"'), 'Agent entry serializes icon');
assert(!userContent.includes('[agents.'), '[agents.*] tables are never written to config.user.toml');
// Header comments present on both files
assert(teamContent.includes('Installer-managed. Regenerated on every install.'), 'config.toml has installer-managed header');
assert(userContent.includes('Holds install answers scoped to YOU personally.'), 'config.user.toml header clarifies user scope');
} finally {
await fs.remove(tempBmadDir35).catch(() => {});
}
}
console.log('');
// ============================================================
// Test Suite 36: Custom Config Stubs
// ============================================================
console.log(`${colors.yellow}Test Suite 36: Custom Config Stubs${colors.reset}\n`);
{
const tempBmadDir36 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-custom-stubs-'));
try {
const generator36 = new ManifestGenerator();
// First install: both stubs are created
await generator36.ensureCustomConfigStubs(tempBmadDir36);
const teamStub = path.join(tempBmadDir36, 'custom', 'config.toml');
const userStub = path.join(tempBmadDir36, 'custom', 'config.user.toml');
assert(await fs.pathExists(teamStub), 'ensureCustomConfigStubs creates custom/config.toml');
assert(await fs.pathExists(userStub), 'ensureCustomConfigStubs creates custom/config.user.toml');
// User writes content into the stub
const userEdit = '# User edit\n[agents.kirk]\ndescription = "Enterprise captain"\n';
await fs.writeFile(userStub, userEdit);
// Second install: stubs are NOT overwritten
await generator36.ensureCustomConfigStubs(tempBmadDir36);
const preservedContent = await fs.readFile(userStub, 'utf8');
assert(preservedContent === userEdit, 'ensureCustomConfigStubs does not overwrite user-edited custom/config.user.toml');
} finally {
await fs.remove(tempBmadDir36).catch(() => {});
}
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================