From dfdc18df2fe2204620076eae0254263267b5d919 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Mon, 27 Apr 2026 16:23:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(config):=20correct=20global=20layer=20prior?= =?UTF-8?q?ity=20=E2=80=94=20overrides=20installer=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scripts/resolve_config.py | 12 ++++----- src/scripts/resolve_customization.py | 6 ++--- test/test-config-resolution.js | 39 +++++++++++++++------------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/scripts/resolve_config.py b/src/scripts/resolve_config.py index ee6d509fe..6ddd20e02 100644 --- a/src/scripts/resolve_config.py +++ b/src/scripts/resolve_config.py @@ -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) diff --git a/src/scripts/resolve_customization.py b/src/scripts/resolve_customization.py index d2d6b3326..634c5baab 100755 --- a/src/scripts/resolve_customization.py +++ b/src/scripts/resolve_customization.py @@ -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) diff --git a/test/test-config-resolution.js b/test/test-config-resolution.js index 5db486a46..e19baf103 100644 --- a/test/test-config-resolution.js +++ b/test/test-config-resolution.js @@ -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 });