fix(installer): preserve module-help.csv schema in merged bmad-help.csv (#2278)

The installer's mergeModuleHelpCatalogs was rewriting the merged catalog
under a different schema (module,phase,name,code,sequence,workflow-file,...)
than the documented source schema in every module's module-help.csv
(module,skill,display-name,menu-code,description,action,args,phase,...).

Worse, the parsing assumed the wrong source column order, so column data
was scrambled in the merged output. SKILL.md docs the source schema, so
the bmad-help skill was navigating a catalog whose actual columns no
longer matched its mental model.

Drop the transformation and the agent enrichment columns (which had no
consumers anywhere in the codebase). Emit rows verbatim in the source
schema, padding short rows and filling empty module fields. Sort by
module then phase, stable within phase to preserve authored order.

Closes #2278
This commit is contained in:
Brian Madison 2026-04-27 23:41:54 -05:00
parent 815600e4ca
commit 27ccb7ee98
1 changed files with 29 additions and 98 deletions

View File

@ -923,29 +923,15 @@ 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 the in-memory agent list produced by ManifestGenerator. * Output preserves the source schema verbatim see schema below.
* 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, ...) * @param {Array<Object>} _agentEntries - Unused; retained for call-site compatibility
*/ */
async mergeModuleHelpCatalogs(bmadDir, agentEntries = []) { async mergeModuleHelpCatalogs(bmadDir, _agentEntries = []) {
const allRows = []; const allRows = [];
const headerRow = const headerRow = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,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'; const COLUMN_COUNT = 13;
const PHASE_INDEX = 7;
// Build agent lookup from the in-memory list (agent code → command + display fields).
const agentInfo = new Map();
for (const agent of agentEntries) {
if (!agent || !agent.code) continue;
const agentCommand = agent.module ? `bmad:${agent.module}:agent:${agent.code}` : `bmad:agent:${agent.code}`;
const displayName = agent.name || agent.code;
const titleCombined = agent.icon && agent.title ? `${agent.icon} ${agent.title}` : agent.title || agent.code;
agentInfo.set(agent.code, {
command: agentCommand,
displayName,
title: titleCombined,
});
}
// Get all installed module directories // Get all installed module directories
const entries = await fs.readdir(bmadDir, { withFileTypes: true }); const entries = await fs.readdir(bmadDir, { withFileTypes: true });
@ -984,64 +970,19 @@ class Installer {
// Parse the line - handle quoted fields with commas // Parse the line - handle quoted fields with commas
const columns = this.parseCSVLine(line); const columns = this.parseCSVLine(line);
if (columns.length >= 12) { if (columns.length < COLUMN_COUNT - 1) continue;
// Map old schema to new schema
// Old: module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs
// New: module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs
const [ // Pad short rows; truncate over-long rows
module, const padded = columns.slice(0, COLUMN_COUNT);
phase, while (padded.length < COLUMN_COUNT) padded.push('');
name,
code,
sequence,
workflowFile,
command,
required,
agentName,
options,
description,
outputLocation,
outputs,
] = columns;
// Pass through _meta rows as-is (module metadata, not a skill) // If module column is empty, fill with this module's name
if (phase === '_meta') { // (core stays empty so its rows render as universal tools)
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || ''; if ((!padded[0] || padded[0].trim() === '') && moduleName !== 'core') {
const metaRow = [finalModule, '_meta', '', '', '', '', '', 'false', '', '', '', '', '', '', outputLocation || '', '']; padded[0] = moduleName;
allRows.push(metaRow.map((c) => this.escapeCSVField(c)).join(','));
continue;
} }
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools) allRows.push(padded.map((c) => this.escapeCSVField(c)).join(','));
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
// Lookup agent info
const cleanAgentName = agentName ? agentName.trim() : '';
const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' };
// Build new row with agent info
const newRow = [
finalModule,
phase || '',
name || '',
code || '',
sequence || '',
workflowFile || '',
command || '',
required || 'false',
cleanAgentName,
agentData.command,
agentData.displayName,
agentData.title,
options || '',
description || '',
outputLocation || '',
outputs || '',
];
allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(','));
}
} }
if (process.env.BMAD_VERBOSE_INSTALL === 'true') { if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
@ -1053,44 +994,34 @@ class Installer {
} }
} }
// Sort by module, then phase, then sequence // Sort by module, then phase. Stable sort preserves authored order within a phase.
allRows.sort((a, b) => { const decorated = allRows.map((row, index) => ({ row, index, cols: this.parseCSVLine(row) }));
const colsA = this.parseCSVLine(a); decorated.sort((a, b) => {
const colsB = this.parseCSVLine(b); const moduleA = (a.cols[0] || '').toLowerCase();
const moduleB = (b.cols[0] || '').toLowerCase();
if (moduleA !== moduleB) return moduleA.localeCompare(moduleB);
// Module comparison (empty module/universal tools come first) const phaseA = a.cols[PHASE_INDEX] || '';
const moduleA = (colsA[0] || '').toLowerCase(); const phaseB = b.cols[PHASE_INDEX] || '';
const moduleB = (colsB[0] || '').toLowerCase(); if (phaseA !== phaseB) return phaseA.localeCompare(phaseB);
if (moduleA !== moduleB) {
return moduleA.localeCompare(moduleB);
}
// Phase comparison return a.index - b.index;
const phaseA = colsA[1] || '';
const phaseB = colsB[1] || '';
if (phaseA !== phaseB) {
return phaseA.localeCompare(phaseB);
}
// Sequence comparison
const seqA = parseInt(colsA[4] || '0', 10);
const seqB = parseInt(colsB[4] || '0', 10);
return seqA - seqB;
}); });
const sortedRows = decorated.map((d) => d.row);
// Write merged catalog // Write merged catalog
const outputDir = path.join(bmadDir, '_config'); const outputDir = path.join(bmadDir, '_config');
await fs.ensureDir(outputDir); await fs.ensureDir(outputDir);
const outputPath = path.join(outputDir, 'bmad-help.csv'); const outputPath = path.join(outputDir, 'bmad-help.csv');
const mergedContent = [headerRow, ...allRows].join('\n'); const mergedContent = [headerRow, ...sortedRows].join('\n');
await fs.writeFile(outputPath, mergedContent, 'utf8'); await fs.writeFile(outputPath, mergedContent, 'utf8');
// Track the installed file // Track the installed file
this.installedFiles.add(outputPath); this.installedFiles.add(outputPath);
if (process.env.BMAD_VERBOSE_INSTALL === 'true') { if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`); await prompts.log.message(` Generated bmad-help.csv: ${sortedRows.length} workflows`);
} }
} }