fix(config): promote project_name to core, fixes #2279

project_name was a bmm-specific prompt despite being a universal
project-level concept used by every module — including core skills like
bmad-brainstorming, which loads from _bmad/core/config.yaml and was
silently broken because project_name lived under bmm. Users without bmm
installed could not run brainstorming at all.

Move:
- src/core-skills/module.yaml: declare project_name with prompt
  "What is your project called?" and default {directory_name}, matching
  what bmm previously had.
- src/bmm-skills/module.yaml: remove the bmm definition; add project_name
  to the "Variables from Core Config inserted" header comment so
  contributors can see what's inherited.

Migration for existing installs:
- tools/installer/modules/official-modules.js: after loadExistingConfig
  reads each per-module config.yaml, hoist any keys that are now declared
  in core but appear under non-core modules. Without this, the partition
  logic in writeCentralConfig (which strips core keys from non-core
  buckets) would silently drop the user's prior project_name on the next
  quick-update. Generic — handles project_name today and any future
  module→core promotions.
- The hoist preserves precedence: an existing core value beats a stale
  module-side copy.

--yes seed:
- tools/installer/ui.js: add project_name to the hardcoded core seed
  (using path.basename(directory) to match the {directory_name} default)
  so non-interactive fresh installs populate it. Without this the seed
  silently omits project_name and core skills fall back to literals.

Tests:
- test/test-installation-components.js Suite 43 (9 assertions) covers
  the schema move, the loadExistingConfig hoist, and the precedence rule.
- Suite 35 fixture updated: project_name moved from bmm bucket to core,
  with a stale bmm copy left in place to verify it gets stripped.

Verified manually:
- Fresh install -y: project_name lands in [core] of config.toml.
- Existing install with project_name in bmm/config.yaml: quick-update
  hoists it to [core] and strips it from [modules.bmm].
This commit is contained in:
Brian Madison 2026-04-27 23:20:52 -05:00
parent 7ee5fa313b
commit b37ca3b314
5 changed files with 146 additions and 7 deletions

View File

@ -5,15 +5,11 @@ default_selected: true # This module will be selected by default for new install
# Variables from Core Config inserted: # Variables from Core Config inserted:
## user_name ## user_name
## project_name
## communication_language ## communication_language
## document_output_language ## document_output_language
## output_folder ## output_folder
project_name:
prompt: "What is your project called?"
default: "{directory_name}"
result: "{value}"
user_skill_level: user_skill_level:
prompt: prompt:
- "What is your development experience level?" - "What is your development experience level?"

View File

@ -11,6 +11,11 @@ user_name:
default: "BMad" default: "BMad"
result: "{value}" result: "{value}"
project_name:
prompt: "What is your project called?"
default: "{directory_name}"
result: "{value}"
communication_language: communication_language:
prompt: "What language should agents use when chatting with you?" prompt: "What language should agents use when chatting with you?"
scope: user scope: user

View File

@ -1813,12 +1813,12 @@ async function runTests() {
const moduleConfigs = { const moduleConfigs = {
core: { core: {
user_name: 'TestUser', user_name: 'TestUser',
project_name: 'demo-project',
communication_language: 'Spanish', communication_language: 'Spanish',
document_output_language: 'English', document_output_language: 'English',
output_folder: '_bmad-output', output_folder: '_bmad-output',
}, },
bmm: { bmm: {
project_name: 'demo-project',
user_skill_level: 'expert', user_skill_level: 'expert',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts', planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts', implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
@ -1826,7 +1826,10 @@ async function runTests() {
// Spread-from-core pollution: legacy per-module config.yaml merges // Spread-from-core pollution: legacy per-module config.yaml merges
// core values into every module; writeCentralConfig must strip these // core values into every module; writeCentralConfig must strip these
// from [modules.bmm] so core values only live in [core]. // from [modules.bmm] so core values only live in [core].
// project_name is now a core key (#2279), so it joins user_name etc.
// as a spread-from-core key that must be stripped.
user_name: 'TestUser', user_name: 'TestUser',
project_name: 'stale-bmm-copy',
communication_language: 'Spanish', communication_language: 'Spanish',
document_output_language: 'English', document_output_language: 'English',
output_folder: '_bmad-output', output_folder: '_bmad-output',
@ -1874,6 +1877,7 @@ async function runTests() {
assert(teamContent.includes('[core]'), 'config.toml has [core] section'); 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('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('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml');
assert(teamContent.includes('project_name = "demo-project"'), 'project_name lands in [core] (core key as of #2279)');
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from 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'); assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
@ -1888,7 +1892,9 @@ async function runTests() {
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section'); assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section');
if (bmmTeamMatch) { if (bmmTeamMatch) {
const bmmTeamBlock = bmmTeamMatch[0]; const bmmTeamBlock = bmmTeamMatch[0];
assert(bmmTeamBlock.includes('project_name = "demo-project"'), 'bmm team-scope key lands under [modules.bmm]'); assert(bmmTeamBlock.includes('planning_artifacts'), 'bmm-owned team-scope key (planning_artifacts) lands under [modules.bmm]');
assert(!bmmTeamBlock.includes('project_name'), 'project_name stripped from [modules.bmm] (now a core key, #2279)');
assert(!bmmTeamBlock.includes('stale-bmm-copy'), 'stale bmm-copy of project_name not leaked into config.toml');
assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)'); 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('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'); assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml');
@ -2861,6 +2867,92 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 43: project_name promoted to core + hoist migration (#2279)
// ============================================================
console.log(`${colors.yellow}Test Suite 43: project_name in core + hoist migration${colors.reset}\n`);
try {
const yamlLib = require('yaml');
const coreSchemaPath = path.join(__dirname, '..', 'src', 'core-skills', 'module.yaml');
const bmmSchemaPath = path.join(__dirname, '..', 'src', 'bmm-skills', 'module.yaml');
const coreSchema = yamlLib.parse(await fs.readFile(coreSchemaPath, 'utf8'));
const bmmSchema = yamlLib.parse(await fs.readFile(bmmSchemaPath, 'utf8'));
assert(
coreSchema.project_name && coreSchema.project_name.prompt && coreSchema.project_name.default === '{directory_name}',
'core/module.yaml declares project_name with {directory_name} default',
);
assert(coreSchema.project_name.scope === undefined, 'project_name has no user scope (project-scoped, not user-scoped)');
assert(bmmSchema.project_name === undefined, 'bmm/module.yaml no longer declares project_name (now inherited from core)');
// Set up a mock existing install: bmm directory has project_name (legacy),
// core has user_name but not project_name. After hoist, project_name should
// move to core, leaving bmm with only its own keys.
const fixtureRoot43 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-43-'));
const bmadDir43 = path.join(fixtureRoot43, '_bmad');
await fs.ensureDir(path.join(bmadDir43, '_config'));
await fs.writeFile(path.join(bmadDir43, '_config', 'manifest.yaml'), 'modules: []\n', 'utf8');
await fs.ensureDir(path.join(bmadDir43, 'core'));
await fs.ensureDir(path.join(bmadDir43, 'bmm'));
await fs.writeFile(path.join(bmadDir43, 'core', 'config.yaml'), 'user_name: alice\n', 'utf8');
await fs.writeFile(
path.join(bmadDir43, 'bmm', 'config.yaml'),
'project_name: legacy-from-bmm\nuser_skill_level: intermediate\n',
'utf8',
);
const officialModules43 = new OfficialModules();
await officialModules43.loadExistingConfig(fixtureRoot43);
assert(
officialModules43.existingConfig.core?.project_name === 'legacy-from-bmm',
'loadExistingConfig hoists bmm.project_name to core on existing-install upgrade',
);
assert(
!('project_name' in (officialModules43.existingConfig.bmm || {})),
'loadExistingConfig removes project_name from bmm after hoisting',
);
assert(
officialModules43.existingConfig.bmm?.user_skill_level === 'intermediate',
'loadExistingConfig leaves non-core bmm keys (user_skill_level) untouched',
);
assert(officialModules43.existingConfig.core?.user_name === 'alice', 'loadExistingConfig preserves pre-existing core values');
// Precedence: if core already has the key, hoist must NOT overwrite it.
const fixtureRoot43b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-fixture-43b-'));
const bmadDir43b = path.join(fixtureRoot43b, '_bmad');
await fs.ensureDir(path.join(bmadDir43b, '_config'));
await fs.writeFile(path.join(bmadDir43b, '_config', 'manifest.yaml'), 'modules: []\n', 'utf8');
await fs.ensureDir(path.join(bmadDir43b, 'core'));
await fs.ensureDir(path.join(bmadDir43b, 'bmm'));
await fs.writeFile(path.join(bmadDir43b, 'core', 'config.yaml'), 'project_name: from-core\n', 'utf8');
await fs.writeFile(path.join(bmadDir43b, 'bmm', 'config.yaml'), 'project_name: stale-from-bmm\n', 'utf8');
const officialModules43b = new OfficialModules();
await officialModules43b.loadExistingConfig(fixtureRoot43b);
assert(officialModules43b.existingConfig.core?.project_name === 'from-core', 'hoist does not overwrite an existing core value');
assert(
!('project_name' in (officialModules43b.existingConfig.bmm || {})),
'hoist still strips the duplicate from bmm so writeCentralConfig partition stays clean',
);
await fs.remove(fixtureRoot43).catch(() => {});
await fs.remove(fixtureRoot43b).catch(() => {});
} catch (error) {
console.log(`${colors.red}Test Suite 43 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -914,9 +914,52 @@ class OfficialModules {
} }
} }
if (foundAny) {
await this._hoistCoreKeysFromLegacyModuleConfigs();
}
return foundAny; return foundAny;
} }
/**
* Migrate prior answers when a key has moved from a non-core module to core
* (e.g. project_name moving from bmm to core in #2279). Without this, the
* partition logic in writeCentralConfig drops the value from the bmm bucket
* (because it's now a core key) without re-homing it under [core], so the
* user's prior answer silently disappears on the next install/quick-update.
*/
async _hoistCoreKeysFromLegacyModuleConfigs() {
const coreSchemaPath = path.join(getSourcePath(), 'core-skills', 'module.yaml');
if (!(await fs.pathExists(coreSchemaPath))) return;
let coreSchema;
try {
coreSchema = yaml.parse(await fs.readFile(coreSchemaPath, 'utf8'));
} catch {
return;
}
if (!coreSchema || typeof coreSchema !== 'object') return;
const coreKeys = new Set(
Object.entries(coreSchema)
.filter(([, v]) => v && typeof v === 'object' && 'prompt' in v)
.map(([k]) => k),
);
if (coreKeys.size === 0) return;
this._existingConfig.core = this._existingConfig.core || {};
for (const [moduleName, cfg] of Object.entries(this._existingConfig)) {
if (moduleName === 'core' || !cfg || typeof cfg !== 'object') continue;
for (const key of Object.keys(cfg)) {
if (!coreKeys.has(key)) continue;
if (!(key in this._existingConfig.core)) {
this._existingConfig.core[key] = cfg[key];
}
delete cfg[key];
}
}
}
/** /**
* Pre-scan module schemas to gather metadata for the configuration gateway prompt. * Pre-scan module schemas to gather metadata for the configuration gateway prompt.
* Returns info about which modules have configurable options. * Returns info about which modules have configurable options.

View File

@ -758,6 +758,9 @@ class UI {
const defaultUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1); const defaultUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1);
configCollector.collectedConfig.core = { configCollector.collectedConfig.core = {
user_name: defaultUsername, user_name: defaultUsername,
// {directory_name} default per src/core-skills/module.yaml — matches what the
// interactive flow resolves via buildQuestion()'s {directory_name} placeholder.
project_name: path.basename(directory),
communication_language: 'English', communication_language: 'English',
document_output_language: 'English', document_output_language: 'English',
output_folder: '_bmad-output', output_folder: '_bmad-output',