refactor: remove bmad-skill-manifest yaml; introduce four-layer central config.toml

- Agent essence moves from per-skill bmad-skill-manifest.yaml files
  into each module.yaml's `agents:` block (code, name, title, icon,
  description). Per-agent customize.toml remains the deep-behavior
  source of truth.
- Installer emits four TOML files:
    _bmad/config.toml              team install answers + agent roster
    _bmad/config.user.toml         user install answers
    _bmad/custom/config.toml       team overrides stub
    _bmad/custom/config.user.toml  personal overrides stub
  Prompts declare scope: user to route answers to config.user.toml.
- resolve_config.py merges four layers: base-team -> base-user ->
  custom-team -> custom-user.
- Three consumer skills (party-mode, advanced-elicitation,
  retrospective) switched from agent-manifest.csv to the resolver.
- installer.js mergeModuleHelpCatalogs now takes the in-memory
  agent list from ManifestGenerator -- no CSV roundtrip.
- Deleted: 6 bmad-skill-manifest.yaml files, agent-manifest.csv
  emission, collectAgents/getAgentsFromDirRecursive,
  paths.agentManifest().
This commit is contained in:
Brian Madison 2026-04-19 17:31:25 -05:00
parent 0dbfae675b
commit 6b91aa249d
17 changed files with 486 additions and 311 deletions

View File

@ -1,11 +0,0 @@
type: agent
name: bmad-agent-analyst
displayName: Mary
title: Business Analyst
icon: "📊"
capabilities: "market research, competitive analysis, requirements elicitation, domain expertise"
role: Strategic Business Analyst + Requirements Expert
identity: "Senior analyst with deep expertise in market research, competitive analysis, and requirements elicitation. Specializes in translating vague needs into actionable specs."
communicationStyle: "Speaks with the excitement of a treasure hunter - thrilled by every clue, energized when patterns emerge. Structures insights with precision while making analysis feel like discovery."
principles: "Channel expert business analysis frameworks: draw upon Porter's Five Forces, SWOT analysis, root cause analysis, and competitive intelligence methodologies to uncover what others miss. Every business challenge has root causes waiting to be discovered. Ground findings in verifiable evidence. Articulate requirements with absolute precision. Ensure all stakeholder voices heard."
module: bmm

View File

@ -1,11 +0,0 @@
type: agent
name: bmad-agent-tech-writer
displayName: Paige
title: Technical Writer
icon: "📚"
capabilities: "documentation, Mermaid diagrams, standards compliance, concept explanation"
role: Technical Documentation Specialist + Knowledge Curator
identity: "Experienced technical writer expert in CommonMark, DITA, OpenAPI. Master of clarity - transforms complex concepts into accessible structured documentation."
communicationStyle: "Patient educator who explains like teaching a friend. Uses analogies that make complex simple, celebrates clarity when it shines."
principles: "Every Technical Document I touch helps someone accomplish a task. Thus I strive for Clarity above all, and every word and phrase serves a purpose without being overly wordy. I believe a picture/diagram is worth 1000s of words and will include diagrams over drawn out text. I understand the intended audience or will clarify with the user so I know when to simplify vs when to be detailed."
module: bmm

View File

@ -1,11 +0,0 @@
type: agent
name: bmad-agent-pm
displayName: John
title: Product Manager
icon: "📋"
capabilities: "PRD creation, requirements discovery, stakeholder alignment, user interviews"
role: "Product Manager specializing in collaborative PRD creation through user interviews, requirement discovery, and stakeholder alignment."
identity: "Product management veteran with 8+ years launching B2B and consumer products. Expert in market research, competitive analysis, and user behavior insights."
communicationStyle: "Asks 'WHY?' relentlessly like a detective on a case. Direct and data-sharp, cuts through fluff to what actually matters."
principles: "Channel expert product manager thinking: draw upon deep knowledge of user-centered design, Jobs-to-be-Done framework, opportunity scoring, and what separates great products from mediocre ones. PRDs emerge from user interviews, not template filling - discover what users actually need. Ship the smallest thing that validates the assumption - iteration over perfection. Technical feasibility is a constraint, not the driver - user value first."
module: bmm

View File

@ -1,11 +0,0 @@
type: agent
name: bmad-agent-ux-designer
displayName: Sally
title: UX Designer
icon: "🎨"
capabilities: "user research, interaction design, UI patterns, experience strategy"
role: User Experience Designer + UI Specialist
identity: "Senior UX Designer with 7+ years creating intuitive experiences across web and mobile. Expert in user research, interaction design, AI-assisted tools."
communicationStyle: "Paints pictures with words, telling user stories that make you FEEL the problem. Empathetic advocate with creative storytelling flair."
principles: "Every decision serves genuine user needs. Start simple, evolve through feedback. Balance empathy with edge case attention. AI tools accelerate human-centered design. Data-informed but always creative."
module: bmm

View File

@ -1,11 +0,0 @@
type: agent
name: bmad-agent-architect
displayName: Winston
title: Architect
icon: "🏗️"
capabilities: "distributed systems, cloud infrastructure, API design, scalable patterns"
role: System Architect + Technical Design Leader
identity: "Senior architect with expertise in distributed systems, cloud infrastructure, and API design. Specializes in scalable patterns and technology selection."
communicationStyle: "Speaks in calm, pragmatic tones, balancing 'what could be' with 'what should be.'"
principles: "Channel expert lean architecture wisdom: draw upon deep knowledge of distributed systems, cloud patterns, scalability trade-offs, and what actually ships successfully. User journeys drive technical decisions. Embrace boring technology for stability. Design simple solutions that scale when needed. Developer productivity is architecture. Connect every decision to business value and user impact."
module: bmm

View File

@ -1,11 +0,0 @@
type: agent
name: bmad-agent-dev
displayName: Amelia
title: Developer Agent
icon: "💻"
capabilities: "story execution, test-driven development, code implementation"
role: Senior Software Engineer
identity: "Executes approved stories with strict adherence to story details and team standards and practices."
communicationStyle: "Ultra-succinct. Speaks in file paths and AC IDs - every statement citable. No fluff, all precision."
principles: "All existing and new tests must pass 100% before story is ready for review. Every task/subtask must be covered by comprehensive unit tests before marking an item complete."
module: bmm

View File

@ -51,7 +51,7 @@ Load config from `{project-root}/_bmad/bmm/config.yaml` and resolve:
### Required Inputs ### Required Inputs
- `agent_manifest` = `{project-root}/_bmad/_config/agent-manifest.csv` - `agent_roster` = resolved via `python3 {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key agents` (merges `_bmad/config.toml`, `_bmad/custom/config.toml`, and `_bmad/custom/config.user.toml`)
### Context ### Context
@ -478,7 +478,7 @@ Amelia (Developer): "No problem. We'll still do a thorough retro on Epic {{epic_
<step n="5" goal="Initialize Retrospective with Rich Context"> <step n="5" goal="Initialize Retrospective with Rich Context">
<action>Load agent configurations from {agent_manifest}</action> <action>Load agent roster from {agent_roster}</action>
<action>Identify which agents participated in Epic {{epic_number}} based on story records</action> <action>Identify which agents participated in Epic {{epic_number}} based on story records</action>
<action>Ensure key roles present: Product Owner, Developer (facilitating), Testing/QA, Architect</action> <action>Ensure key roles present: Product Owner, Developer (facilitating), Testing/QA, Architect</action>

View File

@ -18,6 +18,7 @@ user_skill_level:
prompt: prompt:
- "What is your development experience level?" - "What is your development experience level?"
- "This affects how agents explain concepts in chat." - "This affects how agents explain concepts in chat."
scope: user
default: "intermediate" default: "intermediate"
result: "{value}" result: "{value}"
single-select: single-select:
@ -48,3 +49,45 @@ directories:
- "{planning_artifacts}" - "{planning_artifacts}"
- "{implementation_artifacts}" - "{implementation_artifacts}"
- "{project_knowledge}" - "{project_knowledge}"
# Agent roster — essence only. External skills (party-mode, retrospective,
# advanced-elicitation, help catalog) read these descriptors to route, display,
# and embody agents. Full persona and behavior live in each agent's
# customize.toml. `team` defaults to the module code when omitted; users can
# add their own agents (real or fictional) via _bmad/custom/config.toml or _bmad/custom/config.user.toml.
agents:
- code: bmad-agent-analyst
name: Mary
title: Business Analyst
icon: "📊"
description: "Channels Porter's strategic rigor and Minto's Pyramid Principle, grounds every finding in verifiable evidence, represents every stakeholder voice. Speaks like a treasure hunter narrating the find: thrilled by every clue, precise once the pattern emerges."
- code: bmad-agent-tech-writer
name: Paige
title: Technical Writer
icon: "📚"
description: "Master of CommonMark, DITA, and OpenAPI; turns complex concepts into accessible structured docs, favors diagrams over walls of text, every word earning its place. Speaks like the patient teacher you wish you'd had, using analogies that make complex things feel simple."
- code: bmad-agent-pm
name: John
title: Product Manager
icon: "📋"
description: "Drives Jobs-to-be-Done over template filling, user value first, technical feasibility is a constraint not the driver. Speaks like a detective interrogating a cold case: short questions, sharper follow-ups, every 'why?' tightening the net."
- code: bmad-agent-ux-designer
name: Sally
title: UX Designer
icon: "🎨"
description: "Balances empathy with edge-case rigor, starts simple and evolves through feedback, every decision serves a genuine user need. Speaks like a filmmaker pitching the scene before the code exists, painting user stories that make you feel the problem."
- code: bmad-agent-architect
name: Winston
title: System Architect
icon: "🏗️"
description: "Favors boring technology for stability, developer productivity as architecture, ties every decision to business value. Speaks like a seasoned engineer at the whiteboard: measured, always laying out trade-offs rather than verdicts."
- code: bmad-agent-dev
name: Amelia
title: Senior Software Engineer
icon: "💻"
description: "Test-first discipline (red, green, refactor), 100% pass before review, no fluff all precision. Speaks like a terminal prompt: exact file paths, AC IDs, and commit-message brevity — every statement citable."

View File

@ -35,7 +35,13 @@ When invoked from another prompt or process:
### Step 1: Method Registry Loading ### Step 1: Method Registry Loading
**Action:** Load and read `./methods.csv` and '{project-root}/_bmad/_config/agent-manifest.csv' **Action:** Load `./methods.csv` for elicitation methods. If party-mode may participate, resolve the agent roster via:
```bash
python3 {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key agents
```
The resolver merges `_bmad/config.toml` (installer base) with `_bmad/custom/config.toml` (team) and `_bmad/custom/config.user.toml` (personal). Each entry under `agents` has `code`, `name`, `title`, `icon`, `description`, `module`, and `team`.
#### CSV Structure #### CSV Structure

View File

@ -174,7 +174,7 @@ parts: 1
## Current Installer (migration context) ## Current Installer (migration context)
- Entry: `tools/installer/bmad-cli.js` (Commander.js) → `tools/installer/core/installer.js` - Entry: `tools/installer/bmad-cli.js` (Commander.js) → `tools/installer/core/installer.js`
- Platforms: `platform-codes.yaml` (~20 platforms with target dirs, legacy dirs, template types, special flags) - Platforms: `platform-codes.yaml` (~20 platforms with target dirs, legacy dirs, template types, special flags)
- Manifests: CSV files (skill/workflow/agent-manifest.csv) are current source of truth, not JSON - Manifests: skill-manifest.csv is the current source of truth; agent essence lives in `_bmad/config.toml` (generated from each module.yaml's `agents:` block)
- External modules: `external-official-modules.yaml` (CIS, GDS, TEA, WDS) from npm with semver - External modules: `external-official-modules.yaml` (CIS, GDS, TEA, WDS) from npm with semver
- Dependencies: 4-pass resolver (collect → parse → resolve → transitive); YAML-declared only - Dependencies: 4-pass resolver (collect → parse → resolve → transitive); YAML-declared only
- Config: prompts for name, communication language, document output language, output folder - Config: prompts for name, communication language, document output language, output folder

View File

@ -26,7 +26,13 @@ Party mode accepts optional arguments when invoked:
- Use `{user_name}` for greeting - Use `{user_name}` for greeting
- Use `{communication_language}` for all communications - Use `{communication_language}` for all communications
3. **Read the agent manifest** at `{project-root}/_bmad/_config/agent-manifest.csv`. Build an internal roster of available agents with their displayName, title, icon, role, identity, communicationStyle, and principles. 3. **Resolve the agent roster** by running:
```bash
python3 {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key agents
```
The resolver merges `_bmad/config.toml` (installer base) with `_bmad/custom/config.toml` (team) and `_bmad/custom/config.user.toml` (personal overrides). Each entry under `agents` carries `code`, `name`, `title`, `icon`, `description`, `module`, and `team`. Build an internal roster of available agents from those fields.
4. **Load project context** — search for `**/project-context.md`. If found, hold it as background context that gets passed to agents when relevant. 4. **Load project context** — search for `**/project-context.md`. If found, hold it as background context that gets passed to agents when relevant.
@ -50,15 +56,12 @@ Choose 2-4 agents whose expertise is most relevant to what the user is asking. U
For each selected agent, spawn a subagent using the Agent tool. Each subagent gets: For each selected agent, spawn a subagent using the Agent tool. Each subagent gets:
**The agent prompt** (built from the manifest data): **The agent prompt** (built from the resolved roster entry):
``` ```
You are {displayName} ({title}), a BMAD agent in a collaborative roundtable discussion. You are {name} ({title}), a BMAD agent in a collaborative roundtable discussion.
## Your Persona ## Your Persona
- Icon: {icon} {icon} {name} — {description}
- Communication Style: {communicationStyle}
- Principles: {principles}
- Identity: {identity}
## Discussion Context ## Discussion Context
{summary of the conversation so far — keep under 400 words} {summary of the conversation so far — keep under 400 words}
@ -72,11 +75,11 @@ You are {displayName} ({title}), a BMAD agent in a collaborative roundtable disc
{the user's actual message} {the user's actual message}
## Guidelines ## Guidelines
- Respond authentically as {displayName}. Your perspective should reflect your genuine expertise. - Respond authentically as {name}. Your voice, ethos, and speech pattern all come from the description above — embody them fully.
- Start your response with: {icon} **{displayName}:** - Start your response with: {icon} **{name}:**
- Speak in {communication_language}. - Speak in {communication_language}.
- Scale your response to the substance — don't pad. If you have a brief point, make it briefly. - Scale your response to the substance — don't pad. If you have a brief point, make it briefly.
- Disagree with other agents when your expertise tells you to. Don't hedge or be polite about it. - Disagree with other agents when your perspective tells you to. Don't hedge or be polite about it.
- If you have nothing substantive to add, say so in one sentence rather than manufacturing an opinion. - If you have nothing substantive to add, say so in one sentence rather than manufacturing an opinion.
- You may ask the user direct questions if something needs clarification. - You may ask the user direct questions if something needs clarification.
- Do NOT use tools. Just respond with your perspective. - Do NOT use tools. Just respond with your perspective.

View File

@ -7,11 +7,13 @@ subheader: "Configure the core settings for your BMad installation.\nThese setti
user_name: user_name:
prompt: "What should agents call you? (Use your name or a team name)" prompt: "What should agents call you? (Use your name or a team name)"
scope: user
default: "BMad" default: "BMad"
result: "{value}" 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
default: "English" default: "English"
result: "{value}" result: "{value}"

View File

@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
Resolve BMad's central config using four-layer TOML merge.
Reads from four layers (highest priority last):
1. {project-root}/_bmad/config.toml (installer-owned team)
2. {project-root}/_bmad/config.user.toml (installer-owned user)
3. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
4. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
Outputs merged JSON to stdout. Errors go to stderr.
Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`,
no virtualenv plain `python3` is sufficient.
python3 resolve_config.py --project-root /abs/path/to/project
python3 resolve_config.py --project-root ... --key core
python3 resolve_config.py --project-root ... --key agents
Merge rules (same as resolve_customization.py):
- Scalars: override wins
- Tables: deep merge
- Arrays of tables where every item shares `code` or `id`: merge by that key
- All other arrays: append
"""
import argparse
import json
import sys
from pathlib import Path
try:
import tomllib
except ImportError:
sys.stderr.write(
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n"
)
sys.exit(3)
_MISSING = object()
_KEYED_MERGE_FIELDS = ("code", "id")
def load_toml(file_path: Path, required: bool = False) -> dict:
if not file_path.exists():
if required:
sys.stderr.write(f"error: required config file not found: {file_path}\n")
sys.exit(1)
return {}
try:
with file_path.open("rb") as f:
parsed = tomllib.load(f)
if not isinstance(parsed, dict):
return {}
return parsed
except tomllib.TOMLDecodeError as error:
level = "error" if required else "warning"
sys.stderr.write(f"{level}: failed to parse {file_path}: {error}\n")
if required:
sys.exit(1)
return {}
except OSError as error:
level = "error" if required else "warning"
sys.stderr.write(f"{level}: failed to read {file_path}: {error}\n")
if required:
sys.exit(1)
return {}
def _detect_keyed_merge_field(items):
if not items or not all(isinstance(item, dict) for item in items):
return None
for candidate in _KEYED_MERGE_FIELDS:
if all(item.get(candidate) is not None for item in items):
return candidate
return None
def _merge_by_key(base, override, key_name):
result = []
index_by_key = {}
for item in base:
if not isinstance(item, dict):
continue
if item.get(key_name) is not None:
index_by_key[item[key_name]] = len(result)
result.append(dict(item))
for item in override:
if not isinstance(item, dict):
result.append(item)
continue
key = item.get(key_name)
if key is not None and key in index_by_key:
result[index_by_key[key]] = dict(item)
else:
if key is not None:
index_by_key[key] = len(result)
result.append(dict(item))
return result
def _merge_arrays(base, override):
base_arr = base if isinstance(base, list) else []
override_arr = override if isinstance(override, list) else []
keyed_field = _detect_keyed_merge_field(base_arr + override_arr)
if keyed_field:
return _merge_by_key(base_arr, override_arr, keyed_field)
return base_arr + override_arr
def deep_merge(base, override):
if isinstance(base, dict) and isinstance(override, dict):
result = dict(base)
for key, over_val in override.items():
if key in result:
result[key] = deep_merge(result[key], over_val)
else:
result[key] = over_val
return result
if isinstance(base, list) and isinstance(override, list):
return _merge_arrays(base, override)
return override
def extract_key(data, dotted_key: str):
parts = dotted_key.split(".")
current = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return _MISSING
return current
def main():
parser = argparse.ArgumentParser(
description="Resolve BMad central config using three-layer TOML merge.",
)
parser.add_argument(
"--project-root", "-p", required=True,
help="Absolute path to the project root (contains _bmad/)",
)
parser.add_argument(
"--key", "-k", action="append", default=[],
help="Dotted field path to resolve (repeatable). Omit for full dump.",
)
args = parser.parse_args()
project_root = Path(args.project_root).resolve()
bmad_dir = project_root / "_bmad"
base_team = load_toml(bmad_dir / "config.toml", required=True)
base_user = load_toml(bmad_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(base_team, base_user)
merged = deep_merge(merged, custom_team)
merged = deep_merge(merged, custom_user)
if args.key:
output = {}
for key in args.key:
value = extract_key(merged, key)
if value is not _MISSING:
output[key] = value
else:
output = merged
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
if __name__ == "__main__":
main()

View File

@ -91,15 +91,6 @@ async function createSkillCollisionFixture() {
const configDir = path.join(fixtureDir, '_config'); const configDir = path.join(fixtureDir, '_config');
await fs.ensureDir(configDir); await fs.ensureDir(configDir);
await fs.writeFile(
path.join(configDir, 'agent-manifest.csv'),
[
'name,displayName,title,icon,capabilities,role,identity,communicationStyle,principles,module,path,canonicalId',
'"bmad-master","BMAD Master","","","","","","","","core","_bmad/core/agents/bmad-master.md","bmad-master"',
'',
].join('\n'),
);
await fs.writeFile( await fs.writeFile(
path.join(configDir, 'skill-manifest.csv'), path.join(configDir, 'skill-manifest.csv'),
[ [
@ -1458,16 +1449,16 @@ async function runTests() {
const taskSkillEntry29 = generator29.skills.find((s) => s.canonicalId === 'task-skill'); const taskSkillEntry29 = generator29.skills.find((s) => s.canonicalId === 'task-skill');
assert(taskSkillEntry29 !== undefined, 'Skill in tasks/ dir appears in skills[]'); assert(taskSkillEntry29 !== undefined, 'Skill in tasks/ dir appears in skills[]');
// Native agent entrypoint should be installed as a verbatim skill and also // Native agent entrypoint should be installed as a verbatim skill.
// remain visible to the agent manifest pipeline. // (Agent roster is now sourced from module.yaml's `agents:` block, not
// from per-skill bmad-skill-manifest.yaml sidecars, so this test no longer
// verifies agents[] membership — see collectAgentsFromModuleYaml tests.)
const nativeAgentEntry29 = generator29.skills.find((s) => s.canonicalId === 'bmad-tea'); const nativeAgentEntry29 = generator29.skills.find((s) => s.canonicalId === 'bmad-tea');
assert(nativeAgentEntry29 !== undefined, 'Native type:agent SKILL.md dir appears in skills[]'); assert(nativeAgentEntry29 !== undefined, 'Native type:agent SKILL.md dir appears in skills[]');
assert( assert(
nativeAgentEntry29 && nativeAgentEntry29.path.includes('agents/bmad-tea/SKILL.md'), nativeAgentEntry29 && nativeAgentEntry29.path.includes('agents/bmad-tea/SKILL.md'),
'Native type:agent SKILL.md path points to the agent directory entrypoint', 'Native type:agent SKILL.md path points to the agent directory entrypoint',
); );
const nativeAgentManifest29 = generator29.agents.find((a) => a.name === 'bmad-tea');
assert(nativeAgentManifest29 !== undefined, 'Native type:agent SKILL.md dir appears in agents[] for agent metadata');
// Regular type:workflow should NOT appear in skills[] // Regular type:workflow should NOT appear in skills[]
const regularInSkills29 = generator29.skills.find((s) => s.canonicalId === 'regular-wf'); const regularInSkills29 = generator29.skills.find((s) => s.canonicalId === 'regular-wf');

View File

@ -54,8 +54,11 @@ class InstallPaths {
manifestFile() { manifestFile() {
return path.join(this.configDir, 'manifest.yaml'); return path.join(this.configDir, 'manifest.yaml');
} }
agentManifest() { centralConfig() {
return path.join(this.configDir, 'agent-manifest.csv'); return path.join(this.bmadDir, 'config.toml');
}
centralUserConfig() {
return path.join(this.bmadDir, 'config.user.toml');
} }
filesManifest() { filesManifest() {
return path.join(this.configDir, 'files-manifest.csv'); return path.join(this.configDir, 'files-manifest.csv');

View File

@ -310,7 +310,8 @@ class Installer {
addResult('Configurations', 'ok', 'generated'); addResult('Configurations', 'ok', 'generated');
this.installedFiles.add(paths.manifestFile()); this.installedFiles.add(paths.manifestFile());
this.installedFiles.add(paths.agentManifest()); this.installedFiles.add(paths.centralConfig());
this.installedFiles.add(paths.centralUserConfig());
message('Generating manifests...'); message('Generating manifests...');
const manifestGen = new ManifestGenerator(); const manifestGen = new ManifestGenerator();
@ -331,10 +332,11 @@ class Installer {
await manifestGen.generateManifests(paths.bmadDir, allModulesForManifest, [...this.installedFiles], { await manifestGen.generateManifests(paths.bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [], ides: config.ides || [],
preservedModules: modulesForCsvPreserve, preservedModules: modulesForCsvPreserve,
moduleConfigs,
}); });
message('Generating help catalog...'); message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(paths.bmadDir); await this.mergeModuleHelpCatalogs(paths.bmadDir, manifestGen.agents);
addResult('Help catalog', 'ok'); addResult('Help catalog', 'ok');
return 'Configurations generated'; return 'Configurations generated';
@ -922,46 +924,30 @@ class Installer {
} }
/** /**
* Merge all module-help.csv files into a single bmad-help.csv * Merge all module-help.csv files into a single bmad-help.csv.
* Scans all installed modules for module-help.csv and merges them * Scans all installed modules for module-help.csv and merges them.
* Enriches agent info from agent-manifest.csv * Enriches agent info from the in-memory agent list produced by ManifestGenerator.
* Output is written to _bmad/_config/bmad-help.csv * Output is written to _bmad/_config/bmad-help.csv.
* @param {string} bmadDir - BMAD installation directory * @param {string} bmadDir - BMAD installation directory
* @param {Array<Object>} agentEntries - Agents collected from module.yaml (code, name, title, icon, module, ...)
*/ */
async mergeModuleHelpCatalogs(bmadDir) { async mergeModuleHelpCatalogs(bmadDir, agentEntries = []) {
const allRows = []; const allRows = [];
const headerRow = const headerRow =
'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs'; 'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs';
// Load agent manifest for agent info lookup // Build agent lookup from the in-memory list (agent code → command + display fields).
const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv'); const agentInfo = new Map();
const agentInfo = new Map(); // agent-name -> {command, displayName, title+icon} for (const agent of agentEntries) {
if (!agent || !agent.code) continue;
if (await fs.pathExists(agentManifestPath)) { const agentCommand = agent.module ? `bmad:${agent.module}:agent:${agent.code}` : `bmad:agent:${agent.code}`;
const manifestContent = await fs.readFile(agentManifestPath, 'utf8'); const displayName = agent.name || agent.code;
const lines = manifestContent.split('\n').filter((line) => line.trim()); const titleCombined = agent.icon && agent.title ? `${agent.icon} ${agent.title}` : agent.title || agent.code;
agentInfo.set(agent.code, {
for (const line of lines) { command: agentCommand,
if (line.startsWith('name,')) continue; // Skip header displayName,
title: titleCombined,
const cols = line.split(','); });
if (cols.length >= 4) {
const agentName = cols[0].replaceAll('"', '').trim();
const displayName = cols[1].replaceAll('"', '').trim();
const title = cols[2].replaceAll('"', '').trim();
const icon = cols[3].replaceAll('"', '').trim();
const module = cols[10] ? cols[10].replaceAll('"', '').trim() : '';
// Build agent command: bmad:module:agent:name
const agentCommand = module ? `bmad:${module}:agent:${agentName}` : `bmad:agent:${agentName}`;
agentInfo.set(agentName, {
command: agentCommand,
displayName: displayName || agentName,
title: icon && title ? `${icon} ${title}` : title || agentName,
});
}
}
} }
// Get all installed module directories // Get all installed module directories

View File

@ -2,14 +2,8 @@ const path = require('node:path');
const fs = require('../fs-native'); const fs = require('../fs-native');
const yaml = require('yaml'); const yaml = require('yaml');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const csv = require('csv-parse/sync'); const { getModulePath } = require('../project-root');
const { getSourcePath, getModulePath } = require('../project-root');
const prompts = require('../prompts'); const prompts = require('../prompts');
const {
loadSkillManifest: loadSkillManifestShared,
getCanonicalId: getCanonicalIdShared,
getArtifactType: getArtifactTypeShared,
} = require('../ide/shared/skill-manifest');
// Load package.json for version info // Load package.json for version info
const packageJson = require('../../../package.json'); const packageJson = require('../../../package.json');
@ -26,21 +20,6 @@ class ManifestGenerator {
this.selectedIdes = []; this.selectedIdes = [];
} }
/** Delegate to shared skill-manifest module */
async loadSkillManifest(dirPath) {
return loadSkillManifestShared(dirPath);
}
/** Delegate to shared skill-manifest module */
getCanonicalId(manifest, filename) {
return getCanonicalIdShared(manifest, filename);
}
/** Delegate to shared skill-manifest module */
getArtifactType(manifest, filename) {
return getArtifactTypeShared(manifest, filename);
}
/** /**
* Clean text for CSV output by normalizing whitespace. * Clean text for CSV output by normalizing whitespace.
* Note: Quote escaping is handled by escapeCsv() at write time. * Note: Quote escaping is handled by escapeCsv() at write time.
@ -98,17 +77,21 @@ class ManifestGenerator {
// Collect skills first (populates skillClaimedDirs before legacy collectors run) // Collect skills first (populates skillClaimedDirs before legacy collectors run)
await this.collectSkills(); await this.collectSkills();
// Collect agent data - use updatedModules which includes all installed modules // Collect agent essence from each module's source module.yaml `agents:` array
await this.collectAgents(this.updatedModules); await this.collectAgentsFromModuleYaml();
// Write manifest files and collect their paths // Write manifest files and collect their paths
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {});
const manifestFiles = [ const manifestFiles = [
await this.writeMainManifest(cfgDir), await this.writeMainManifest(cfgDir),
await this.writeSkillManifest(cfgDir), await this.writeSkillManifest(cfgDir),
await this.writeAgentManifest(cfgDir), teamConfigPath,
userConfigPath,
await this.writeFilesManifest(cfgDir), await this.writeFilesManifest(cfgDir),
]; ];
await this.ensureCustomConfigStubs(bmadDir);
return { return {
skills: this.skills.length, skills: this.skills.length,
agents: this.agents.length, agents: this.agents.length,
@ -150,24 +133,13 @@ class ManifestGenerator {
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug); const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
if (skillMeta) { if (skillMeta) {
// Load manifest when present (for agent metadata)
const manifest = await this.loadSkillManifest(dir);
const artifactType = this.getArtifactType(manifest, skillFile);
// Build path relative from module root (points to SKILL.md — the permanent entrypoint) // Build path relative from module root (points to SKILL.md — the permanent entrypoint)
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/'); const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
const installPath = relativePath const installPath = relativePath
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}` ? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}`
: `${this.bmadFolderName}/${moduleName}/${skillFile}`; : `${this.bmadFolderName}/${moduleName}/${skillFile}`;
// Native SKILL.md entrypoints derive canonicalId from directory name. // Native SKILL.md entrypoints always derive canonicalId from directory name.
// Agent entrypoints may keep canonicalId metadata for compatibility, so
// only warn for non-agent SKILL.md directories.
if (manifest && manifest.__single && manifest.__single.canonicalId && artifactType !== 'agent') {
console.warn(
`Warning: Native entrypoint manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for SKILL.md directories (directory name is the canonical ID)`,
);
}
const canonicalId = dirName; const canonicalId = dirName;
this.skills.push({ this.skills.push({
@ -263,105 +235,49 @@ class ManifestGenerator {
} }
/** /**
* Collect all agents from selected modules by walking their directory trees. * Collect agents from each installed module's source module.yaml `agents:` array.
* Essence fields (code, name, title, icon, description) are authored in module.yaml;
* `team` defaults to module code when not set; `module` is always the owning module.
*/ */
async collectAgents(selectedModules) { async collectAgentsFromModuleYaml() {
this.agents = []; this.agents = [];
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true'; const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
// Walk each module's full directory tree looking for type:agent manifests
for (const moduleName of this.updatedModules) { for (const moduleName of this.updatedModules) {
const modulePath = path.join(this.bmadDir, moduleName); const moduleYamlPath = path.join(getModulePath(moduleName), 'module.yaml');
if (!(await fs.pathExists(modulePath))) continue; if (!(await fs.pathExists(moduleYamlPath))) continue;
const moduleAgents = await this.getAgentsFromDirRecursive(modulePath, moduleName, '', debug); let moduleDef;
this.agents.push(...moduleAgents); try {
} moduleDef = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
} catch (error) {
// Get standalone agents from bmad/agents/ directory if (debug) console.log(`[DEBUG] collectAgentsFromModuleYaml: failed to parse ${moduleYamlPath}: ${error.message}`);
const standaloneAgentsDir = path.join(this.bmadDir, 'agents');
if (await fs.pathExists(standaloneAgentsDir)) {
const standaloneAgents = await this.getAgentsFromDirRecursive(standaloneAgentsDir, 'standalone', '', debug);
this.agents.push(...standaloneAgents);
}
if (debug) {
console.log(`[DEBUG] collectAgents: total agents found: ${this.agents.length}`);
}
}
/**
* Recursively walk a directory tree collecting agents.
* Discovers agents via directory with bmad-skill-manifest.yaml containing type: agent
*
* @param {string} dirPath - Current directory being scanned
* @param {string} moduleName - Module this directory belongs to
* @param {string} relativePath - Path relative to the module root (for install path construction)
* @param {boolean} debug - Emit debug messages
*/
async getAgentsFromDirRecursive(dirPath, moduleName, relativePath = '', debug = false) {
const agents = [];
let entries;
try {
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
return agents;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
const fullPath = path.join(dirPath, entry.name);
// Check for type:agent manifest BEFORE checking skillClaimedDirs —
// agent dirs may be claimed by collectSkills for IDE installation,
// but we still need them in agent-manifest.csv.
const dirManifest = await this.loadSkillManifest(fullPath);
if (dirManifest && dirManifest.__single && dirManifest.__single.type === 'agent') {
const m = dirManifest.__single;
const dirRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const agentModule = m.module || moduleName;
const installPath = `${this.bmadFolderName}/${agentModule}/${dirRelativePath}`;
agents.push({
name: m.name || entry.name,
displayName: m.displayName || m.name || entry.name,
title: m.title || '',
icon: m.icon || '',
role: m.role ? this.cleanForCSV(m.role) : '',
identity: m.identity ? this.cleanForCSV(m.identity) : '',
communicationStyle: m.communicationStyle ? this.cleanForCSV(m.communicationStyle) : '',
principles: m.principles ? this.cleanForCSV(m.principles) : '',
module: agentModule,
path: installPath,
canonicalId: m.canonicalId || '',
});
this.files.push({
type: 'agent',
name: m.name || entry.name,
module: agentModule,
path: installPath,
});
if (debug) {
console.log(`[DEBUG] collectAgents: found type:agent "${m.name || entry.name}" at ${fullPath}`);
}
continue; continue;
} }
// Skip directories claimed by collectSkills (non-agent type skills) — if (!moduleDef || !Array.isArray(moduleDef.agents)) continue;
// avoids recursing into skill trees that can't contain agents.
if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue;
// Recurse into subdirectories for (const entry of moduleDef.agents) {
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; if (!entry || typeof entry.code !== 'string') continue;
const subDirAgents = await this.getAgentsFromDirRecursive(fullPath, moduleName, newRelativePath, debug); this.agents.push({
agents.push(...subDirAgents); code: entry.code,
name: entry.name || '',
title: entry.title || '',
icon: entry.icon || '',
description: entry.description || '',
module: moduleName,
team: entry.team || moduleName,
});
}
if (debug) {
console.log(`[DEBUG] collectAgentsFromModuleYaml: ${moduleName} contributed ${moduleDef.agents.length} agents`);
}
} }
return agents; if (debug) {
console.log(`[DEBUG] collectAgentsFromModuleYaml: total agents found: ${this.agents.length}`);
}
} }
/** /**
@ -477,75 +393,170 @@ class ManifestGenerator {
} }
/** /**
* Write agent manifest CSV * Write central _bmad/config.toml with [core], [modules.<code>], [agents.<code>] tables.
* @returns {string} Path to the manifest file * Install-owned. Team-scope answers config.toml; user-scope answers config.user.toml.
* Both files are regenerated on every install. User overrides live in
* _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer).
* @returns {string[]} Paths to the written config files
*/ */
async writeAgentManifest(cfgDir) { async writeCentralConfig(bmadDir, moduleConfigs) {
const csvPath = path.join(cfgDir, 'agent-manifest.csv'); const teamPath = path.join(bmadDir, 'config.toml');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`; const userPath = path.join(bmadDir, 'config.user.toml');
// Read existing manifest to preserve entries // Load each module's source module.yaml to determine scope per prompt key.
const existingEntries = new Map(); // Default scope is 'team' when the prompt doesn't declare one.
if (await fs.pathExists(csvPath)) { const scopeByModuleKey = {};
const content = await fs.readFile(csvPath, 'utf8'); for (const moduleName of this.updatedModules) {
const records = csv.parse(content, { const moduleYamlPath = path.join(getModulePath(moduleName), 'module.yaml');
columns: true, if (!(await fs.pathExists(moduleYamlPath))) continue;
skip_empty_lines: true, try {
}); const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
for (const record of records) { if (!parsed || typeof parsed !== 'object') continue;
existingEntries.set(`${record.module}:${record.name}`, record); scopeByModuleKey[moduleName] = {};
for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) {
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
}
}
} catch {
// Silently skip unparseable module.yaml — default-team behavior applies
} }
} }
// Create CSV header with persona fields and canonicalId const partition = (moduleName, cfg) => {
let csvContent = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path,canonicalId\n'; const team = {};
const user = {};
const scopes = scopeByModuleKey[moduleName] || {};
for (const [key, value] of Object.entries(cfg || {})) {
if (scopes[key] === 'user') {
user[key] = value;
} else {
team[key] = value;
}
}
return { team, user };
};
// Combine existing and new agents, preferring new data for duplicates const teamHeader = [
const allAgents = new Map(); '# ─────────────────────────────────────────────────────────────────',
'# DO NOT EDIT — regenerated on every install.',
'#',
'# To override any value, add it to one of:',
'# _bmad/custom/config.toml (team, committed to version control)',
'# _bmad/custom/config.user.toml (personal, gitignored)',
'# ─────────────────────────────────────────────────────────────────',
'',
];
// Add existing entries const userHeader = [
for (const [key, value] of existingEntries) { '# ─────────────────────────────────────────────────────────────────',
allAgents.set(key, value); '# DO NOT EDIT — regenerated on every install.',
'# This file holds install answers scoped to YOU personally.',
'#',
'# To override any value, add it to:',
'# _bmad/custom/config.user.toml (personal, gitignored)',
'# ─────────────────────────────────────────────────────────────────',
'',
];
const teamLines = [...teamHeader];
const userLines = [...userHeader];
// [core] — split into team and user
const coreConfig = moduleConfigs.core || {};
const { team: coreTeam, user: coreUser } = partition('core', coreConfig);
if (Object.keys(coreTeam).length > 0) {
teamLines.push('[core]');
for (const [key, value] of Object.entries(coreTeam)) {
teamLines.push(`${key} = ${formatTomlValue(value)}`);
}
teamLines.push('');
}
if (Object.keys(coreUser).length > 0) {
userLines.push('[core]');
for (const [key, value] of Object.entries(coreUser)) {
userLines.push(`${key} = ${formatTomlValue(value)}`);
}
userLines.push('');
} }
// Add/update new agents // [modules.<code>] — split per module
for (const moduleName of this.updatedModules) {
if (moduleName === 'core') continue;
const cfg = moduleConfigs[moduleName];
if (!cfg || Object.keys(cfg).length === 0) continue;
const { team: modTeam, user: modUser } = partition(moduleName, cfg);
if (Object.keys(modTeam).length > 0) {
teamLines.push(`[modules.${moduleName}]`);
for (const [key, value] of Object.entries(modTeam)) {
teamLines.push(`${key} = ${formatTomlValue(value)}`);
}
teamLines.push('');
}
if (Object.keys(modUser).length > 0) {
userLines.push(`[modules.${moduleName}]`);
for (const [key, value] of Object.entries(modUser)) {
userLines.push(`${key} = ${formatTomlValue(value)}`);
}
userLines.push('');
}
}
// [agents.<code>] — always team (agent roster is organizational)
for (const agent of this.agents) { for (const agent of this.agents) {
const key = `${agent.module}:${agent.name}`; const agentLines = [`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`];
allAgents.set(key, { if (agent.name) agentLines.push(`name = ${formatTomlValue(agent.name)}`);
name: agent.name, if (agent.title) agentLines.push(`title = ${formatTomlValue(agent.title)}`);
displayName: agent.displayName, if (agent.icon) agentLines.push(`icon = ${formatTomlValue(agent.icon)}`);
title: agent.title, if (agent.description) agentLines.push(`description = ${formatTomlValue(agent.description)}`);
icon: agent.icon, agentLines.push('');
role: agent.role, teamLines.push(...agentLines);
identity: agent.identity,
communicationStyle: agent.communicationStyle,
principles: agent.principles,
module: agent.module,
path: agent.path,
canonicalId: agent.canonicalId || '',
});
} }
// Write all agents const teamContent = teamLines.join('\n').replace(/\n+$/, '\n');
for (const [, record] of allAgents) { const userContent = userLines.join('\n').replace(/\n+$/, '\n');
const row = [ await fs.writeFile(teamPath, teamContent);
escapeCsv(record.name), await fs.writeFile(userPath, userContent);
escapeCsv(record.displayName), return [teamPath, userPath];
escapeCsv(record.title), }
escapeCsv(record.icon),
escapeCsv(record.role),
escapeCsv(record.identity),
escapeCsv(record.communicationStyle),
escapeCsv(record.principles),
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.canonicalId),
].join(',');
csvContent += row + '\n';
}
await fs.writeFile(csvPath, csvContent); /**
return csvPath; * Create empty _bmad/custom/config.toml and _bmad/custom/config.user.toml stubs
* on first install only. Installer never touches these files again after creation.
*/
async ensureCustomConfigStubs(bmadDir) {
const customDir = path.join(bmadDir, 'custom');
await fs.ensureDir(customDir);
const stubs = [
{
file: path.join(customDir, 'config.toml'),
header: [
'# Team / enterprise overrides for _bmad/config.toml.',
'# Committed to the repo — applies to every developer on the project.',
'# Tables deep-merge over base config; keyed entries merge by key.',
'# Example: override an agent descriptor, or add a new agent.',
'#',
'# [agents.bmad-agent-pm]',
'# description = "Prefers short, bulleted PRDs over narrative drafts."',
'',
],
},
{
file: path.join(customDir, 'config.user.toml'),
header: [
'# Personal overrides for _bmad/config.toml.',
'# NOT committed (gitignored) — applies only to your local install.',
'# Wins over both base config and team overrides.',
'',
],
},
];
for (const { file, header } of stubs) {
if (await fs.pathExists(file)) continue;
await fs.writeFile(file, header.join('\n'));
}
} }
/** /**
@ -691,4 +702,24 @@ class ManifestGenerator {
} }
} }
/**
* Format a JS scalar as a TOML value literal.
* Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars.
* Objects are not expected at this emit path.
*/
function formatTomlValue(value) {
if (value === null || value === undefined) return '""';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (Array.isArray(value)) return `[${value.map((v) => formatTomlValue(v)).join(', ')}]`;
const str = String(value);
const escaped = str
.replaceAll('\\', '\\\\')
.replaceAll('"', String.raw`\"`)
.replaceAll('\n', String.raw`\n`)
.replaceAll('\r', String.raw`\r`)
.replaceAll('\t', String.raw`\t`);
return `"${escaped}"`;
}
module.exports = { ManifestGenerator }; module.exports = { ManifestGenerator };