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:
parent
670050fa90
commit
82d2b4de1a
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue