fix: address PR review findings from triage

- Fix regex capture group index in module manager workflow path parsing
- Remove stale workflow handler references from handler-multi.txt
- Replace workflow with multi in activation-steps dispatch contract
- Remove dead validate-workflow emission from compiler and xml-builder
- Align commands.md wording to remove engine references
- Fix relativePath anchoring in _base-ide.js recursive directory scans
- Remove dead code from workflow-command-generator (unused template,
  generateCommandContent, writeColonArtifacts, writeDashArtifacts)
- Delete unused workflow-commander.md template
- Add regression test for workflow path regex
This commit is contained in:
Alex Verkhovsky 2026-03-08 17:36:26 -06:00
parent 61fe92a129
commit a5aa8f8499
10 changed files with 108 additions and 154 deletions

View File

@ -88,7 +88,7 @@ See [Agents](./agents.md) for the full list of default agents and their triggers
### Workflow Skills ### Workflow Skills
Workflow skills run a structured, multi-step process without loading an agent persona first. They load the workflow engine and pass a specific workflow configuration. Workflow skills run a structured, multi-step process without loading an agent persona first. They load a workflow configuration and follow its steps.
| Example skill | Purpose | | Example skill | Purpose |
| --- | --- | | --- | --- |

View File

@ -11,4 +11,4 @@
<step n="{HELP_STEP}">Let {user_name} know they can type command `/bmad-help` at any time to get advice on what to do next, and that they can combine that with what they need help with <example>`/bmad-help where should I start with an idea I have that does XYZ`</example></step> <step n="{HELP_STEP}">Let {user_name} know they can type command `/bmad-help` at any time to get advice on what to do next, and that they can combine that with what they need help with <example>`/bmad-help where should I start with an idea I have that does XYZ`</example></step>
<step n="{HALT_STEP}">STOP and WAIT for user input - do NOT execute menu items automatically - accept number or cmd trigger or fuzzy command match</step> <step n="{HALT_STEP}">STOP and WAIT for user input - do NOT execute menu items automatically - accept number or cmd trigger or fuzzy command match</step>
<step n="{INPUT_STEP}">On user input: Number → process menu item[n] | Text → case-insensitive substring match | Multiple matches → ask user to clarify | No match → show "Not recognized"</step> <step n="{INPUT_STEP}">On user input: Number → process menu item[n] | Text → case-insensitive substring match | Multiple matches → ask user to clarify | No match → show "Not recognized"</step>
<step n="{EXECUTE_STEP}">When processing a menu item: Check menu-handlers section below - extract any attributes from the selected menu item (workflow, exec, tmpl, data, action) and follow the corresponding handler instructions</step> <step n="{EXECUTE_STEP}">When processing a menu item: Check menu-handlers section below - extract any attributes from the selected menu item (exec, tmpl, data, action, multi) and follow the corresponding handler instructions</step>

View File

@ -4,10 +4,9 @@
2. Parse all nested handlers within the multi item 2. Parse all nested handlers within the multi item
3. For each nested handler: 3. For each nested handler:
- Use the 'match' attribute for fuzzy matching user input (or Exact Match of character code in brackets []) - Use the 'match' attribute for fuzzy matching user input (or Exact Match of character code in brackets [])
- Process based on handler attributes (exec, workflow, action) - Process based on handler attributes (exec, action)
4. When user input matches a handler's 'match' pattern: 4. When user input matches a handler's 'match' pattern:
- For exec="path/to/file.md": follow the `handler type="exec"` instructions - For exec="path/to/file.md": follow the `handler type="exec"` instructions
- For workflow="path/to/workflow.md": follow the `handler type="workflow"` instructions
- For action="...": Perform the specified action directly - For action="...": Perform the specified action directly
5. Support both exact matches and fuzzy matching based on the match attribute 5. Support both exact matches and fuzzy matching based on the match attribute
6. If no handler matches, prompt user to choose from available options 6. If no handler matches, prompt user to choose from available options

View File

@ -0,0 +1,88 @@
/**
* Workflow Path Regex Tests
*
* Tests that the source and install workflow path regexes in ModuleManager
* extract the correct capture groups (module name and workflow sub-path).
*
* Usage: node test/test-workflow-path-regex.js
*/
// ANSI colors
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
cyan: '\u001B[36m',
dim: '\u001B[2m',
};
let passed = 0;
let failed = 0;
function assert(condition, testName, errorMessage = '') {
if (condition) {
console.log(`${colors.green}${colors.reset} ${testName}`);
passed++;
} else {
console.log(`${colors.red}${colors.reset} ${testName}`);
if (errorMessage) {
console.log(` ${colors.dim}${errorMessage}${colors.reset}`);
}
failed++;
}
}
// ---------------------------------------------------------------------------
// These regexes are extracted from ModuleManager.vendorWorkflowDependencies()
// in tools/cli/installers/lib/modules/manager.js
// ---------------------------------------------------------------------------
// Source regex (line ~1081) — uses non-capturing group for _bmad
const SOURCE_REGEX = /\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/;
// Install regex (line ~1091) — uses non-capturing group for _bmad,
// consistent with source regex
const INSTALL_REGEX = /\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/;
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const sourcePath = '{project-root}/_bmad/bmm/workflows/4-implementation/create-story/workflow.md';
const installPath = '{project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.md';
console.log(`\n${colors.cyan}Workflow Path Regex Tests${colors.reset}\n`);
// --- Source regex tests (these should pass — source regex is correct) ---
const sourceMatch = sourcePath.match(SOURCE_REGEX);
assert(sourceMatch !== null, 'Source regex matches source path');
assert(
sourceMatch && sourceMatch[1] === 'bmm',
'Source regex group [1] is the module name',
`Expected "bmm", got "${sourceMatch && sourceMatch[1]}"`,
);
assert(
sourceMatch && sourceMatch[2] === '4-implementation/create-story/workflow.md',
'Source regex group [2] is the workflow sub-path',
`Expected "4-implementation/create-story/workflow.md", got "${sourceMatch && sourceMatch[2]}"`,
);
// --- Install regex tests (group [2] returns module name, not sub-path) ---
const installMatch = installPath.match(INSTALL_REGEX);
assert(installMatch !== null, 'Install regex matches install path');
// This is the critical test: installMatch[2] should be the workflow sub-path,
// because the code uses it as `installWorkflowSubPath`.
// With the bug, installMatch[2] is "bmgd" (module name) instead of the sub-path.
assert(
installMatch && installMatch[2] === '4-production/create-story/workflow.md',
'Install regex group [2] is the workflow sub-path (used as installWorkflowSubPath)',
`Expected "4-production/create-story/workflow.md", got "${installMatch && installMatch[2]}"`,
);
// --- Summary ---
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);

View File

@ -326,9 +326,11 @@ class BaseIdeSetup {
/** /**
* Recursively find workflow.md files * Recursively find workflow.md files
* @param {string} dir - Directory to search * @param {string} dir - Directory to search
* @param {string} [rootDir] - Original root directory (used internally for recursion)
* @returns {Array} List of workflow file info objects * @returns {Array} List of workflow file info objects
*/ */
async findWorkflowFiles(dir) { async findWorkflowFiles(dir, rootDir = null) {
rootDir = rootDir || dir;
const workflows = []; const workflows = [];
if (!(await fs.pathExists(dir))) { if (!(await fs.pathExists(dir))) {
@ -342,7 +344,7 @@ class BaseIdeSetup {
if (entry.isDirectory()) { if (entry.isDirectory()) {
// Recursively search subdirectories // Recursively search subdirectories
const subWorkflows = await this.findWorkflowFiles(fullPath); const subWorkflows = await this.findWorkflowFiles(fullPath, rootDir);
workflows.push(...subWorkflows); workflows.push(...subWorkflows);
} else if (entry.isFile() && entry.name === 'workflow.md') { } else if (entry.isFile() && entry.name === 'workflow.md') {
// Read workflow.md frontmatter to get name and standalone property // Read workflow.md frontmatter to get name and standalone property
@ -360,7 +362,7 @@ class BaseIdeSetup {
workflows.push({ workflows.push({
name: workflowData.name, name: workflowData.name,
path: fullPath, path: fullPath,
relativePath: path.relative(dir, fullPath), relativePath: path.relative(rootDir, fullPath),
filename: entry.name, filename: entry.name,
description: workflowData.description || '', description: workflowData.description || '',
standalone: standalone, standalone: standalone,
@ -379,9 +381,11 @@ class BaseIdeSetup {
* Scan a directory for files with specific extension(s) * Scan a directory for files with specific extension(s)
* @param {string} dir - Directory to scan * @param {string} dir - Directory to scan
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml']) * @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
* @param {string} [rootDir] - Original root directory (used internally for recursion)
* @returns {Array} List of file info objects * @returns {Array} List of file info objects
*/ */
async scanDirectory(dir, ext) { async scanDirectory(dir, ext, rootDir = null) {
rootDir = rootDir || dir;
const files = []; const files = [];
if (!(await fs.pathExists(dir))) { if (!(await fs.pathExists(dir))) {
@ -398,7 +402,7 @@ class BaseIdeSetup {
if (entry.isDirectory()) { if (entry.isDirectory()) {
// Recursively scan subdirectories // Recursively scan subdirectories
const subFiles = await this.scanDirectory(fullPath, ext); const subFiles = await this.scanDirectory(fullPath, ext, rootDir);
files.push(...subFiles); files.push(...subFiles);
} else if (entry.isFile()) { } else if (entry.isFile()) {
// Check if file matches any of the extensions // Check if file matches any of the extensions
@ -407,7 +411,7 @@ class BaseIdeSetup {
files.push({ files.push({
name: path.basename(entry.name, matchedExt), name: path.basename(entry.name, matchedExt),
path: fullPath, path: fullPath,
relativePath: path.relative(dir, fullPath), relativePath: path.relative(rootDir, fullPath),
filename: entry.name, filename: entry.name,
}); });
} }
@ -421,9 +425,11 @@ class BaseIdeSetup {
* Scan a directory for files with specific extension(s) and check standalone attribute * Scan a directory for files with specific extension(s) and check standalone attribute
* @param {string} dir - Directory to scan * @param {string} dir - Directory to scan
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml']) * @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
* @param {string} [rootDir] - Original root directory (used internally for recursion)
* @returns {Array} List of file info objects with standalone property * @returns {Array} List of file info objects with standalone property
*/ */
async scanDirectoryWithStandalone(dir, ext) { async scanDirectoryWithStandalone(dir, ext, rootDir = null) {
rootDir = rootDir || dir;
const files = []; const files = [];
if (!(await fs.pathExists(dir))) { if (!(await fs.pathExists(dir))) {
@ -440,7 +446,7 @@ class BaseIdeSetup {
if (entry.isDirectory()) { if (entry.isDirectory()) {
// Recursively scan subdirectories // Recursively scan subdirectories
const subFiles = await this.scanDirectoryWithStandalone(fullPath, ext); const subFiles = await this.scanDirectoryWithStandalone(fullPath, ext, rootDir);
files.push(...subFiles); files.push(...subFiles);
} else if (entry.isFile()) { } else if (entry.isFile()) {
// Check if file matches any of the extensions // Check if file matches any of the extensions
@ -484,7 +490,7 @@ class BaseIdeSetup {
files.push({ files.push({
name: path.basename(entry.name, matchedExt), name: path.basename(entry.name, matchedExt),
path: fullPath, path: fullPath,
relativePath: path.relative(dir, fullPath), relativePath: path.relative(rootDir, fullPath),
filename: entry.name, filename: entry.name,
standalone: standalone, standalone: standalone,
}); });

View File

@ -1,8 +1,7 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const prompts = require('../../../../lib/prompts'); const { BMAD_FOLDER_NAME } = require('./path-utils');
const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils');
/** /**
* Generates command files for each workflow in the manifest * Generates command files for each workflow in the manifest
@ -12,46 +11,6 @@ class WorkflowCommandGenerator {
this.bmadFolderName = bmadFolderName; this.bmadFolderName = bmadFolderName;
} }
/**
* Generate workflow commands from the manifest CSV
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
*/
async generateWorkflowCommands(projectDir, bmadDir) {
const workflows = await this.loadWorkflowManifest(bmadDir);
if (!workflows) {
await prompts.log.warn('Workflow manifest not found. Skipping command generation.');
return { generated: 0 };
}
// ALL workflows now generate commands - no standalone filtering
const allWorkflows = workflows;
// Base commands directory
const baseCommandsDir = path.join(projectDir, '.claude', 'commands', 'bmad');
let generatedCount = 0;
// Generate a command file for each workflow, organized by module
for (const workflow of allWorkflows) {
const moduleWorkflowsDir = path.join(baseCommandsDir, workflow.module, 'workflows');
await fs.ensureDir(moduleWorkflowsDir);
const commandContent = await this.generateCommandContent(workflow, bmadDir);
const commandPath = path.join(moduleWorkflowsDir, `${workflow.name}.md`);
await fs.writeFile(commandPath, commandContent);
generatedCount++;
}
// Also create a workflow launcher README in each module
const groupedWorkflows = this.groupWorkflowsByModule(allWorkflows);
await this.createModuleWorkflowLaunchers(baseCommandsDir, groupedWorkflows);
return { generated: generatedCount };
}
async collectWorkflowArtifacts(bmadDir) { async collectWorkflowArtifacts(bmadDir) {
const workflows = await this.loadWorkflowManifest(bmadDir); const workflows = await this.loadWorkflowManifest(bmadDir);
@ -65,7 +24,6 @@ class WorkflowCommandGenerator {
const artifacts = []; const artifacts = [];
for (const workflow of allWorkflows) { for (const workflow of allWorkflows) {
const commandContent = await this.generateCommandContent(workflow, bmadDir);
// Calculate the relative workflow path (e.g., bmm/workflows/4-implementation/sprint-planning/workflow.md) // Calculate the relative workflow path (e.g., bmm/workflows/4-implementation/sprint-planning/workflow.md)
let workflowRelPath = workflow.path || ''; let workflowRelPath = workflow.path || '';
// Normalize path separators for cross-platform compatibility // Normalize path separators for cross-platform compatibility
@ -92,7 +50,6 @@ class WorkflowCommandGenerator {
canonicalId: workflow.canonicalId || '', canonicalId: workflow.canonicalId || '',
relativePath: path.join(workflow.module, 'workflows', `${workflow.name}.md`), relativePath: path.join(workflow.module, 'workflows', `${workflow.name}.md`),
workflowPath: workflowRelPath, // Relative path to actual workflow file workflowPath: workflowRelPath, // Relative path to actual workflow file
content: commandContent,
sourcePath: workflow.path, sourcePath: workflow.path,
}); });
} }
@ -117,43 +74,6 @@ class WorkflowCommandGenerator {
}; };
} }
/**
* Generate command content for a workflow
*/
async generateCommandContent(workflow, bmadDir) {
const templatePath = path.join(__dirname, '../templates/workflow-commander.md');
// Load the template
const template = await fs.readFile(templatePath, 'utf8');
// Convert source path to installed path
// From: /Users/.../src/bmm/workflows/.../workflow.md
// To: {project-root}/_bmad/bmm/workflows/.../workflow.md
let workflowPath = workflow.path;
// Extract the relative path from source
if (workflowPath.includes('/src/bmm/')) {
// bmm is directly under src/
const match = workflowPath.match(/\/src\/bmm\/(.+)/);
if (match) {
workflowPath = `${this.bmadFolderName}/bmm/${match[1]}`;
}
} else if (workflowPath.includes('/src/core/')) {
const match = workflowPath.match(/\/src\/core\/(.+)/);
if (match) {
workflowPath = `${this.bmadFolderName}/core/${match[1]}`;
}
}
// Replace template variables
return template
.replaceAll('{{name}}', workflow.name)
.replaceAll('{{module}}', workflow.module)
.replaceAll('{{description}}', workflow.description)
.replaceAll('{{workflow_path}}', workflowPath)
.replaceAll('_bmad', this.bmadFolderName);
}
/** /**
* Create workflow launcher files for each module * Create workflow launcher files for each module
*/ */
@ -254,58 +174,6 @@ When running any workflow:
skip_empty_lines: true, skip_empty_lines: true,
}); });
} }
/**
* Write workflow command artifacts using underscore format (Windows-compatible)
* Creates flat files like: bmad_bmm_correct-course.md
*
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Workflow artifacts
* @returns {number} Count of commands written
*/
async writeColonArtifacts(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'workflow-command') {
// Convert relativePath to underscore format: bmm/workflows/correct-course.md → bmad_bmm_correct-course.md
const flatName = toColonPath(artifact.relativePath);
const commandPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(commandPath));
await fs.writeFile(commandPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
/**
* Write workflow command artifacts using dash format (NEW STANDARD)
* Creates flat files like: bmad-bmm-correct-course.md
*
* Note: Workflows do NOT have bmad-agent- prefix - only agents do.
*
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Workflow artifacts
* @returns {number} Count of commands written
*/
async writeDashArtifacts(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'workflow-command') {
// Convert relativePath to dash format: bmm/workflows/correct-course.md → bmad-bmm-correct-course.md
const flatName = toDashPath(artifact.relativePath);
const commandPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(commandPath));
await fs.writeFile(commandPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
} }
module.exports = { WorkflowCommandGenerator }; module.exports = { WorkflowCommandGenerator };

View File

@ -1,5 +0,0 @@
---
description: '{{description}}'
---
IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL {project-root}/{{workflow_path}}, READ its entire contents and follow its directions exactly!

View File

@ -1088,7 +1088,7 @@ class ModuleManager {
// Parse INSTALL workflow path // Parse INSTALL workflow path
// Example: {project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.md // Example: {project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.md
const installMatch = installWorkflowPath.match(/\{project-root\}\/(_bmad)\/([^/]+)\/workflows\/(.+)/); const installMatch = installWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/);
if (!installMatch) { if (!installMatch) {
await prompts.log.warn(` Could not parse workflow-install path: ${installWorkflowPath}`); await prompts.log.warn(` Could not parse workflow-install path: ${installWorkflowPath}`);
continue; continue;

View File

@ -186,7 +186,6 @@ function buildNestedHandlers(triggers) {
// Add handler attributes based on exec data // Add handler attributes based on exec data
if (execData.route) attrs.push(`exec="${execData.route}"`); if (execData.route) attrs.push(`exec="${execData.route}"`);
if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`);
if (execData.action) attrs.push(`action="${execData.action}"`); if (execData.action) attrs.push(`action="${execData.action}"`);
if (execData.data) attrs.push(`data="${execData.data}"`); if (execData.data) attrs.push(`data="${execData.data}"`);
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`); if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);

View File

@ -408,7 +408,6 @@ class YamlXmlBuilder {
// Add handler attributes based on exec data // Add handler attributes based on exec data
if (execData.route) attrs.push(`exec="${execData.route}"`); if (execData.route) attrs.push(`exec="${execData.route}"`);
if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`);
if (execData.action) attrs.push(`action="${execData.action}"`); if (execData.action) attrs.push(`action="${execData.action}"`);
if (execData.data) attrs.push(`data="${execData.data}"`); if (execData.data) attrs.push(`data="${execData.data}"`);
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`); if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);