fix(config): correct global layer priority — overrides installer defaults

The global user layer was lowest priority, meaning installer-generated
defaults in _bmad/config.user.toml always shadowed it. Reorder so
global user preferences override installer defaults but are still
overridden by hand-authored project customizations.

New order for resolve_config.py:
  base_team → base_user → global_user → custom_team → custom_user

New order for resolve_customization.py:
  defaults → global_user → team → user
This commit is contained in:
Jerome Revillard 2026-04-27 16:23:46 +02:00
parent 9f6e83c255
commit dfdc18df2f
3 changed files with 30 additions and 27 deletions

View File

@ -3,9 +3,9 @@
Resolve BMad's central config using five-layer TOML merge.
Reads from five layers (highest priority last):
1. ~/.bmad/config/config.user.toml (global user defaults)
2. {project-root}/_bmad/config.toml (installer-owned team)
3. {project-root}/_bmad/config.user.toml (installer-owned user)
1. {project-root}/_bmad/config.toml (installer-owned team)
2. {project-root}/_bmad/config.user.toml (installer-owned user)
3. ~/.bmad/config/config.user.toml (global user preferences)
4. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
5. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
@ -153,14 +153,14 @@ def main():
project_root = Path(args.project_root).resolve()
bmad_dir = project_root / "_bmad"
global_user = load_toml(GLOBAL_DIR / "config.user.toml")
base_team = load_toml(bmad_dir / "config.toml", required=True)
base_user = load_toml(bmad_dir / "config.user.toml")
global_user = load_toml(GLOBAL_DIR / "config.user.toml")
custom_team = load_toml(bmad_dir / "custom" / "config.toml")
custom_user = load_toml(bmad_dir / "custom" / "config.user.toml")
merged = deep_merge(global_user, base_team)
merged = deep_merge(merged, base_user)
merged = deep_merge(base_team, base_user)
merged = deep_merge(merged, global_user)
merged = deep_merge(merged, custom_team)
merged = deep_merge(merged, custom_user)

View File

@ -5,8 +5,8 @@ Resolve customization for a BMad skill using four-layer TOML merge.
Reads customization from four layers (highest priority first):
1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored)
2. {project-root}/_bmad/custom/{name}.toml (team/org, committed)
3. {skill-root}/customize.toml (skill defaults)
4. ~/.bmad/config/{name}.user.toml (global user defaults)
3. ~/.bmad/config/{name}.user.toml (global user preferences)
4. {skill-root}/customize.toml (skill defaults)
Skill name is derived from the basename of the skill directory.
@ -215,7 +215,7 @@ def main():
global_user = load_toml(GLOBAL_DIR / f"{skill_name}.user.toml")
merged = deep_merge(global_user, defaults)
merged = deep_merge(defaults, global_user)
merged = deep_merge(merged, team)
merged = deep_merge(merged, user)

View File

@ -65,7 +65,7 @@ async function withTempDir(fn) {
async function testResolveConfig() {
console.log('\n--- resolve_config.py ---\n');
// Test 1: global user layer is loaded
// Test 1: global user overrides installer defaults
await withTempDir(async (tmpDir) => {
const globalDir = path.join(tmpDir, '.bmad', 'config');
await fs.mkdir(globalDir, { recursive: true });
@ -79,7 +79,10 @@ async function testResolveConfig() {
await fs.mkdir(path.join(bmadDir, 'custom'), { recursive: true });
await writeToml(path.join(bmadDir, 'config.toml'), {
project_name: 'TestProject',
user_name: 'ProjectAlice',
user_name: 'InstallerDefault',
});
await writeToml(path.join(bmadDir, 'config.user.toml'), {
user_name: 'InstallerUserDefault',
});
const origHome = process.env.HOME;
@ -88,18 +91,18 @@ async function testResolveConfig() {
const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_config.py --project-root ${projectDir}`, { encoding: 'utf-8' }));
assert(
result.user_name === 'ProjectAlice',
'project config.user overrides global user_name',
`Expected "ProjectAlice", got "${result.user_name}"`,
result.user_name === 'GlobalAlice',
'global user overrides installer defaults',
`Expected "GlobalAlice", got "${result.user_name}"`,
);
assert(
result.communication_language === 'en',
'global communication_language preserved when project has no override',
'global communication_language preserved when installer has no override',
`Expected "en", got "${result.communication_language}"`,
);
assert(
result.project_name === 'TestProject',
'project config values preserved',
'installer team config values preserved',
`Expected "TestProject", got "${result.project_name}"`,
);
} finally {
@ -131,19 +134,19 @@ async function testResolveConfig() {
}
});
// Test 3: full priority chain — global < base_team < base_user < custom_team < custom_user
// Test 3: full priority chain — base_team < base_user < global < custom_team < custom_user
await withTempDir(async (tmpDir) => {
const globalDir = path.join(tmpDir, '.bmad', 'config');
await fs.mkdir(globalDir, { recursive: true });
await writeToml(path.join(globalDir, 'config.user.toml'), {
user_name: 'L0-Global',
user_name: 'L2-Global',
});
const projectDir = path.join(tmpDir, 'project');
const bmadDir = path.join(projectDir, '_bmad');
await fs.mkdir(path.join(bmadDir, 'custom'), { recursive: true });
await writeToml(path.join(bmadDir, 'config.toml'), { user_name: 'L1-BaseTeam' });
await writeToml(path.join(bmadDir, 'config.user.toml'), { user_name: 'L2-BaseUser' });
await writeToml(path.join(bmadDir, 'config.toml'), { user_name: 'L0-BaseTeam' });
await writeToml(path.join(bmadDir, 'config.user.toml'), { user_name: 'L1-BaseUser' });
await writeToml(path.join(bmadDir, 'custom', 'config.toml'), { user_name: 'L3-CustomTeam' });
await writeToml(path.join(bmadDir, 'custom', 'config.user.toml'), { user_name: 'L4-CustomUser' });
@ -211,7 +214,7 @@ async function testResolveConfig() {
async function testResolveCustomization() {
console.log('\n--- resolve_customization.py ---\n');
// Test 1: global skill user layer is loaded
// Test 1: global skill user overrides skill defaults
await withTempDir(async (tmpDir) => {
const globalDir = path.join(tmpDir, '.bmad', 'config');
await fs.mkdir(globalDir, { recursive: true });
@ -232,9 +235,9 @@ async function testResolveCustomization() {
const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_customization.py --skill ${skillDir}`, { encoding: 'utf-8' }));
assert(
result.agent === 'default-agent-prompt',
'skill defaults override global user layer',
`Expected "default-agent-prompt", got "${result.agent}"`,
result.agent === 'global-agent-prompt',
'global user overrides skill defaults',
`Expected "global-agent-prompt", got "${result.agent}"`,
);
assert(result.version === '1.0.0', 'skill default values preserved', `Expected "1.0.0", got "${result.version}"`);
} finally {
@ -291,17 +294,17 @@ async function testResolveCustomization() {
}
});
// Test 4: full priority chain — global < defaults < team < user
// Test 4: full priority chain — defaults < global < team < user
await withTempDir(async (tmpDir) => {
const globalDir = path.join(tmpDir, '.bmad', 'config');
await fs.mkdir(globalDir, { recursive: true });
await writeToml(path.join(globalDir, 'test-skill.user.toml'), {
agent: 'L0-Global',
agent: 'L1-Global',
});
const skillDir = path.join(tmpDir, 'project', '_bmad', 'skills', 'test-skill');
await fs.mkdir(skillDir, { recursive: true });
await writeToml(path.join(skillDir, 'customize.toml'), { agent: 'L1-Defaults' });
await writeToml(path.join(skillDir, 'customize.toml'), { agent: 'L0-Defaults' });
const customDir = path.join(tmpDir, 'project', '_bmad', 'custom');
await fs.mkdir(customDir, { recursive: true });