fix: agent-manifest.csv empty after install — type mismatch + scan path bug (#2115)

Two bugs combined to produce an empty agent-manifest.csv:

1. collectAgents() only scanned {module}/agents/ directories, but agents
   live at various paths (bmm/1-analysis/bmad-agent-analyst/,
   cis/skills/bmad-cis-agent-*, etc.). Now walks the full module tree.

2. All 9 BMM agent manifests declared type: skill instead of type: agent.
   The manifest generator requires type: agent to include a directory in
   agent-manifest.csv. CIS, GDS, TEA, and WDS already had the correct type.

Changes:
- Fix 9 BMM bmad-skill-manifest.yaml files: type: skill → type: agent
- Replace collectAgents/getAgentsFromDir with full-tree recursive scan
- Module field from manifest file always takes precedence over directory
- Remove dead skillManifest load (legacy .md agent support removed)
- Add TODO in bmad-artifacts.js documenting legacy agent pipeline as dead code
- Add 10 regression tests covering BMM, CIS, and GDS directory layouts
This commit is contained in:
Brian 2026-03-24 00:18:29 -05:00 committed by GitHub
parent 94831cbb1e
commit a04635efe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 188 additions and 124 deletions

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-analyst name: bmad-agent-analyst
displayName: Mary displayName: Mary
title: Business Analyst title: Business Analyst

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-tech-writer name: bmad-agent-tech-writer
displayName: Paige displayName: Paige
title: Technical Writer title: Technical Writer

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-pm name: bmad-agent-pm
displayName: John displayName: John
title: Product Manager title: Product Manager

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-ux-designer name: bmad-agent-ux-designer
displayName: Sally displayName: Sally
title: UX Designer title: UX Designer

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-architect name: bmad-agent-architect
displayName: Winston displayName: Winston
title: Architect title: Architect

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-dev name: bmad-agent-dev
displayName: Amelia displayName: Amelia
title: Developer Agent title: Developer Agent

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-qa name: bmad-agent-qa
displayName: Quinn displayName: Quinn
title: QA Engineer title: QA Engineer

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-quick-flow-solo-dev name: bmad-agent-quick-flow-solo-dev
displayName: Barry displayName: Barry
title: Quick Flow Solo Dev title: Quick Flow Solo Dev

View File

@ -1,4 +1,4 @@
type: skill type: agent
name: bmad-agent-sm name: bmad-agent-sm
displayName: Bob displayName: Bob
title: Scrum Master title: Scrum Master

View File

@ -1648,6 +1648,93 @@ async function runTests() {
// skill-manifest.csv should include the native agent entrypoint // skill-manifest.csv should include the native agent entrypoint
const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8'); const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8');
assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint'); assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint');
// --- Agents at non-agents/ paths (regression test for BMM/CIS layouts) ---
// Create a second fixture with agents at paths like bmm/1-analysis/bmad-agent-analyst/
const tempFixture29b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-paths-'));
await fs.ensureDir(path.join(tempFixture29b, '_config'));
// Agent at bmm-style path: bmm/1-analysis/bmad-agent-analyst/
const bmmAgentDir = path.join(tempFixture29b, 'bmm', '1-analysis', 'bmad-agent-analyst');
await fs.ensureDir(bmmAgentDir);
await fs.writeFile(
path.join(bmmAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: bmad-agent-analyst',
'displayName: Mary',
'title: Business Analyst',
'role: Strategic Business Analyst',
'module: bmm',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(bmmAgentDir, 'SKILL.md'),
'---\nname: bmad-agent-analyst\ndescription: Business Analyst agent\n---\n\nAnalyst agent.\n',
);
// Agent at cis-style path: cis/skills/bmad-cis-agent-brainstorming-coach/
const cisAgentDir = path.join(tempFixture29b, 'cis', 'skills', 'bmad-cis-agent-brainstorming-coach');
await fs.ensureDir(cisAgentDir);
await fs.writeFile(
path.join(cisAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: bmad-cis-agent-brainstorming-coach',
'displayName: Carson',
'title: Brainstorming Specialist',
'role: Master Facilitator',
'module: cis',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(cisAgentDir, 'SKILL.md'),
'---\nname: bmad-cis-agent-brainstorming-coach\ndescription: Brainstorming coach\n---\n\nCoach.\n',
);
// Agent at standard agents/ path (GDS-style): gds/agents/gds-agent-game-dev/
const gdsAgentDir = path.join(tempFixture29b, 'gds', 'agents', 'gds-agent-game-dev');
await fs.ensureDir(gdsAgentDir);
await fs.writeFile(
path.join(gdsAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: gds-agent-game-dev',
'displayName: Link',
'title: Game Developer',
'role: Senior Game Dev',
'module: gds',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(gdsAgentDir, 'SKILL.md'),
'---\nname: gds-agent-game-dev\ndescription: Game developer agent\n---\n\nGame dev.\n',
);
const generator29b = new ManifestGenerator();
await generator29b.generateManifests(tempFixture29b, ['bmm', 'cis', 'gds'], [], { ides: [] });
// All three agents should appear in agents[] regardless of directory layout
const bmmAgent = generator29b.agents.find((a) => a.name === 'bmad-agent-analyst');
assert(bmmAgent !== undefined, 'Agent at bmm/1-analysis/ path appears in agents[]');
assert(bmmAgent && bmmAgent.module === 'bmm', 'BMM agent module field comes from manifest file');
assert(bmmAgent && bmmAgent.path.includes('bmm/1-analysis/bmad-agent-analyst'), 'BMM agent path reflects actual directory layout');
const cisAgent = generator29b.agents.find((a) => a.name === 'bmad-cis-agent-brainstorming-coach');
assert(cisAgent !== undefined, 'Agent at cis/skills/ path appears in agents[]');
assert(cisAgent && cisAgent.module === 'cis', 'CIS agent module field comes from manifest file');
const gdsAgent = generator29b.agents.find((a) => a.name === 'gds-agent-game-dev');
assert(gdsAgent !== undefined, 'Agent at gds/agents/ path appears in agents[]');
assert(gdsAgent && gdsAgent.module === 'gds', 'GDS agent module field comes from manifest file');
// agent-manifest.csv should contain all three
const agentCsv29b = await fs.readFile(path.join(tempFixture29b, '_config', 'agent-manifest.csv'), 'utf8');
assert(agentCsv29b.includes('bmad-agent-analyst'), 'agent-manifest.csv includes BMM-layout agent');
assert(agentCsv29b.includes('bmad-cis-agent-brainstorming-coach'), 'agent-manifest.csv includes CIS-layout agent');
assert(agentCsv29b.includes('gds-agent-game-dev'), 'agent-manifest.csv includes GDS-layout agent');
await fs.remove(tempFixture29b).catch(() => {});
} catch (error) { } catch (error) {
assert(false, 'Unified skill scanner test succeeds', error.message); assert(false, 'Unified skill scanner test succeeds', error.message);
} finally { } finally {

View File

@ -268,64 +268,66 @@ class ManifestGenerator {
} }
/** /**
* Collect all agents from core and selected modules * Collect all agents from selected modules by walking their directory trees.
* Scans the INSTALLED bmad directory, not the source
*/ */
async collectAgents(selectedModules) { async collectAgents(selectedModules) {
this.agents = []; this.agents = [];
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
// Use updatedModules which already includes deduplicated 'core' + selectedModules // 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 agentsPath = path.join(this.bmadDir, moduleName, 'agents'); const modulePath = path.join(this.bmadDir, moduleName);
if (!(await fs.pathExists(modulePath))) continue;
if (await fs.pathExists(agentsPath)) { const moduleAgents = await this.getAgentsFromDirRecursive(modulePath, moduleName, '', debug);
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
this.agents.push(...moduleAgents); this.agents.push(...moduleAgents);
} }
}
// Get standalone agents from bmad/agents/ directory // Get standalone agents from bmad/agents/ directory
const standaloneAgentsDir = path.join(this.bmadDir, 'agents'); const standaloneAgentsDir = path.join(this.bmadDir, 'agents');
if (await fs.pathExists(standaloneAgentsDir)) { if (await fs.pathExists(standaloneAgentsDir)) {
const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true }); const standaloneAgents = await this.getAgentsFromDirRecursive(standaloneAgentsDir, 'standalone', '', debug);
for (const agentDir of agentDirs) {
if (!agentDir.isDirectory()) continue;
const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
const standaloneAgents = await this.getAgentsFromDir(agentDirPath, 'standalone');
this.agents.push(...standaloneAgents); this.agents.push(...standaloneAgents);
} }
if (debug) {
console.log(`[DEBUG] collectAgents: total agents found: ${this.agents.length}`);
} }
} }
/** /**
* Get agents from a directory recursively * Recursively walk a directory tree collecting agents.
* Only includes .md files with agent content * 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 getAgentsFromDir(dirPath, moduleName, relativePath = '') { async getAgentsFromDirRecursive(dirPath, moduleName, relativePath = '', debug = false) {
// Skip directories claimed by collectSkills
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dirPath)) return [];
const agents = []; const agents = [];
const entries = await fs.readdir(dirPath, { withFileTypes: true }); let entries;
// Load skill manifest for this directory (if present) try {
const skillManifest = await this.loadSkillManifest(dirPath); entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
return agents;
}
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
const fullPath = path.join(dirPath, entry.name); const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) { // Check for type:agent manifest BEFORE checking skillClaimedDirs —
// Check for new-format agent: bmad-skill-manifest.yaml with type: agent // agent dirs may be claimed by collectSkills for IDE installation,
// Note: type:agent dirs may also be claimed by collectSkills for IDE installation, // but we still need them in agent-manifest.csv.
// but we still need to process them here for agent-manifest.csv
const dirManifest = await this.loadSkillManifest(fullPath); const dirManifest = await this.loadSkillManifest(fullPath);
if (dirManifest && dirManifest.__single && dirManifest.__single.type === 'agent') { if (dirManifest && dirManifest.__single && dirManifest.__single.type === 'agent') {
const m = dirManifest.__single; const m = dirManifest.__single;
const dirRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; const dirRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const installPath = const agentModule = m.module || moduleName;
moduleName === 'core' const installPath = `${this.bmadFolderName}/${agentModule}/${dirRelativePath}`;
? `${this.bmadFolderName}/core/agents/${dirRelativePath}`
: `${this.bmadFolderName}/${moduleName}/agents/${dirRelativePath}`;
agents.push({ agents.push({
name: m.name || entry.name, name: m.name || entry.name,
@ -337,7 +339,7 @@ class ManifestGenerator {
identity: m.identity ? this.cleanForCSV(m.identity) : '', identity: m.identity ? this.cleanForCSV(m.identity) : '',
communicationStyle: m.communicationStyle ? this.cleanForCSV(m.communicationStyle) : '', communicationStyle: m.communicationStyle ? this.cleanForCSV(m.communicationStyle) : '',
principles: m.principles ? this.cleanForCSV(m.principles) : '', principles: m.principles ? this.cleanForCSV(m.principles) : '',
module: m.module || moduleName, module: agentModule,
path: installPath, path: installPath,
canonicalId: m.canonicalId || '', canonicalId: m.canonicalId || '',
}); });
@ -345,76 +347,24 @@ class ManifestGenerator {
this.files.push({ this.files.push({
type: 'agent', type: 'agent',
name: m.name || entry.name, name: m.name || entry.name,
module: moduleName, module: agentModule,
path: installPath, 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) // Skip directories claimed by collectSkills (non-agent type skills) —
// avoids recursing into skill trees that can't contain agents.
if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue; if (this.skillClaimedDirs && this.skillClaimedDirs.has(fullPath)) continue;
// Recurse into subdirectories // Recurse into subdirectories
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const subDirAgents = await this.getAgentsFromDir(fullPath, moduleName, newRelativePath); const subDirAgents = await this.getAgentsFromDirRecursive(fullPath, moduleName, newRelativePath, debug);
agents.push(...subDirAgents); agents.push(...subDirAgents);
} else if (entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md') {
const content = await fs.readFile(fullPath, 'utf8');
// Skip files that don't contain <agent> tag (e.g., README files)
if (!content.includes('<agent')) {
continue;
}
// Skip web-only agents
if (content.includes('localskip="true"')) {
continue;
}
// Extract agent metadata from the XML structure
const nameMatch = content.match(/name="([^"]+)"/);
const titleMatch = content.match(/title="([^"]+)"/);
const iconMatch = content.match(/icon="([^"]+)"/);
const capabilitiesMatch = content.match(/capabilities="([^"]+)"/);
// Extract persona fields
const roleMatch = content.match(/<role>([^<]+)<\/role>/);
const identityMatch = content.match(/<identity>([\s\S]*?)<\/identity>/);
const styleMatch = content.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
const principlesMatch = content.match(/<principles>([\s\S]*?)<\/principles>/);
// Build relative path for installation
const fileRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const installPath =
moduleName === 'core'
? `${this.bmadFolderName}/core/agents/${fileRelativePath}`
: `${this.bmadFolderName}/${moduleName}/agents/${fileRelativePath}`;
const agentName = entry.name.replace('.md', '');
agents.push({
name: agentName,
displayName: nameMatch ? nameMatch[1] : agentName,
title: titleMatch ? titleMatch[1] : '',
icon: iconMatch ? iconMatch[1] : '',
capabilities: capabilitiesMatch ? this.cleanForCSV(capabilitiesMatch[1]) : '',
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
principles: principlesMatch ? this.cleanForCSV(principlesMatch[1]) : '',
module: moduleName,
path: installPath,
canonicalId: this.getCanonicalId(skillManifest, entry.name),
});
// Add to files list
this.files.push({
type: 'agent',
name: agentName,
module: moduleName,
path: installPath,
});
}
} }
return agents; return agents;

View File

@ -5,6 +5,33 @@ const { loadSkillManifest, getCanonicalId } = require('./skill-manifest');
/** /**
* Helpers for gathering BMAD agents/tasks from the installed tree. * Helpers for gathering BMAD agents/tasks from the installed tree.
* Shared by installers that need Claude-style exports. * Shared by installers that need Claude-style exports.
*
* TODO: Dead code cleanup compiled XML agents are retired.
*
* All agents now use the SKILL.md directory format with bmad-skill-manifest.yaml
* (type: agent). The legacy pipeline below only discovers compiled .md files
* containing <agent> XML tags, which no longer exist. The following are dead:
*
* - getAgentsFromBmad() scans {module}/agents/ for .md files with <agent> tags
* - getAgentsFromDir() recursive helper for the above
* - AgentCommandGenerator (agent-command-generator.js) generates launcher .md files
* that tell the LLM to load a compiled agent .md file
* - agent-command-template.md (templates/) the launcher template with hardcoded
* {module}/agents/{{path}} reference
*
* Agent metadata for agent-manifest.csv is now handled entirely by
* ManifestGenerator.getAgentsFromDirRecursive() in manifest-generator.js,
* which walks the full module tree and finds type:agent directories.
*
* IDE installation of agents is handled by the native skill pipeline
* each agent's SKILL.md directory is installed directly to the IDE's
* skills path, so no launcher intermediary is needed.
*
* Cleanup: remove getAgentsFromBmad, getAgentsFromDir, their exports,
* AgentCommandGenerator, agent-command-template.md, and all call sites
* in IDE installers that invoke collectAgentArtifacts / writeAgentLaunchers /
* writeColonArtifacts / writeDashArtifacts.
* getTasksFromBmad and getTasksFromDir may still be live verify before removing.
*/ */
async function getAgentsFromBmad(bmadDir, selectedModules = []) { async function getAgentsFromBmad(bmadDir, selectedModules = []) {
const agents = []; const agents = [];