Compare commits

...

6 Commits

Author SHA1 Message Date
Dicky Moore 74977ece6a
Merge ddd0e8fbf2 into cf50f4935d 2025-12-09 15:48:42 +00:00
Alex Verkhovsky cf50f4935d
fix: address code review issues from alpha.14 to alpha.15 (#1068)
* fix: remove debug console.log statements from ui.js

* fix: add error handling and rollback for temp directory cleanup

* fix: use streaming for hash calculation to reduce memory usage

* refactor: hoist CustomHandler require to top of installer.js and ui.js

* fix: fail fast on malformed custom module YAML

User customizations must be valid - silent skip hides broken configs.

* refactor: use consistent return type in handleMissingCustomSources

* refactor: clone config at install() entry to prevent mutation
2025-12-08 13:24:30 -06:00
mq-bot ddd0e8fbf2 fix: remind workflows to reload sprint status 2025-11-26 14:06:12 +00:00
mq-bot 9e70b1d41c Merge upstream/main into feature/sprint-status-reload 2025-11-26 13:38:58 +00:00
mq-bot 9a3a46314e fix: remind workflows to reload sprint status 2025-11-24 19:31:35 +00:00
mq-bot a4c394fc78 Ensure workflow launcher loads core workflow 2025-11-24 14:56:30 +00:00
10 changed files with 80 additions and 62 deletions

View File

@ -3,6 +3,8 @@
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical> <critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
<critical>Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}</critical> <critical>Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}</critical>
<critical>Generate all documents in {document_output_language}</critical> <critical>Generate all documents in {document_output_language}</critical>
<critical>ALWAYS reload {{sprint_status}} (docs/sprint-artifacts/sprint-status.yaml by default) from disk immediately before reading or
updating statuses. Multiple workflows change this file between runs; never rely on a previously loaded copy or cached memory.</critical>
<critical>🔥 YOU ARE AN ADVERSARIAL CODE REVIEWER - Find what's wrong or missing! 🔥</critical> <critical>🔥 YOU ARE AN ADVERSARIAL CODE REVIEWER - Find what's wrong or missing! 🔥</critical>
<critical>Your purpose: Validate story file claims against actual implementation</critical> <critical>Your purpose: Validate story file claims against actual implementation</critical>
@ -221,4 +223,4 @@
</output> </output>
</step> </step>
</workflow> </workflow>

View File

@ -2,6 +2,8 @@
<critical>The workflow execution engine is governed by: {project-root}/{bmad_folder}/core/tasks/workflow.xml</critical> <critical>The workflow execution engine is governed by: {project-root}/{bmad_folder}/core/tasks/workflow.xml</critical>
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical> <critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
<critical>Communicate all responses in {communication_language} and generate all documents in {document_output_language}</critical> <critical>Communicate all responses in {communication_language} and generate all documents in {document_output_language}</critical>
<critical>ALWAYS reload {{sprint_status}} (docs/sprint-artifacts/sprint-status.yaml by default) from disk immediately before reading or
updating statuses. Multiple workflows change this file between runs; never rely on a previously loaded copy or cached memory.</critical>
<critical>🔥 CRITICAL MISSION: You are creating the ULTIMATE story context engine that prevents LLM developer mistakes, omissions or <critical>🔥 CRITICAL MISSION: You are creating the ULTIMATE story context engine that prevents LLM developer mistakes, omissions or
disasters! 🔥</critical> disasters! 🔥</critical>
@ -351,4 +353,4 @@
</output> </output>
</step> </step>
</workflow> </workflow>

View File

@ -3,6 +3,8 @@
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical> <critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
<critical>Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}</critical> <critical>Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}</critical>
<critical>Generate all documents in {document_output_language}</critical> <critical>Generate all documents in {document_output_language}</critical>
<critical>ALWAYS reload {{sprint_status}} (docs/sprint-artifacts/sprint-status.yaml by default) from disk immediately before reading or
updating statuses. Multiple workflows change this file between runs; never rely on a previously loaded copy or cached memory.</critical>
<critical>Only modify the story file in these areas: Tasks/Subtasks checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List, <critical>Only modify the story file in these areas: Tasks/Subtasks checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List,
Change Log, and Status</critical> Change Log, and Status</critical>
<critical>Execute ALL steps in exact order; do NOT skip steps</critical> <critical>Execute ALL steps in exact order; do NOT skip steps</critical>
@ -403,4 +405,4 @@
<action>Remain flexible - allow user to choose their own path or ask for other assistance</action> <action>Remain flexible - allow user to choose their own path or ask for other assistance</action>
</step> </step>
</workflow> </workflow>

View File

@ -26,6 +26,7 @@ PARTY MODE PROTOCOL:
- Create natural back-and-forth with user actively participating - Create natural back-and-forth with user actively participating
- Show disagreements, diverse perspectives, authentic team dynamics - Show disagreements, diverse perspectives, authentic team dynamics
</critical> </critical>
<critical>ALWAYS reload {{sprint_status_file}} (docs/sprint-artifacts/sprint-status.yaml by default) from disk immediately before reading or updating statuses. Multiple workflows change this file between runs; never rely on a previously loaded copy or cached memory.</critical>
<workflow> <workflow>

View File

@ -51,7 +51,19 @@ class CustomModuleCache {
} }
/** /**
* Calculate hash of a file or directory * Stream a file into the hash to avoid loading entire file into memory
*/
async hashFileStream(filePath, hash) {
return new Promise((resolve, reject) => {
const stream = require('node:fs').createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', resolve);
stream.on('error', reject);
});
}
/**
* Calculate hash of a file or directory using streaming to minimize memory usage
*/ */
async calculateHash(sourcePath) { async calculateHash(sourcePath) {
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
@ -76,14 +88,14 @@ class CustomModuleCache {
files.sort(); // Ensure consistent order files.sort(); // Ensure consistent order
for (const file of files) { for (const file of files) {
const content = await fs.readFile(file);
const relativePath = path.relative(sourcePath, file); const relativePath = path.relative(sourcePath, file);
hash.update(relativePath + '|' + content.toString('base64')); // Hash the path first, then stream file contents
hash.update(relativePath + '|');
await this.hashFileStream(file, hash);
} }
} else { } else {
// For single files // For single files, stream directly into hash
const content = await fs.readFile(sourcePath); await this.hashFileStream(sourcePath, hash);
hash.update(content);
} }
return hash.digest('hex'); return hash.digest('hex');

View File

@ -39,6 +39,7 @@ const { CLIUtils } = require('../../../lib/cli-utils');
const { ManifestGenerator } = require('./manifest-generator'); const { ManifestGenerator } = require('./manifest-generator');
const { IdeConfigManager } = require('./ide-config-manager'); const { IdeConfigManager } = require('./ide-config-manager');
const { replaceAgentSidecarFolders } = require('./post-install-sidecar-replacement'); const { replaceAgentSidecarFolders } = require('./post-install-sidecar-replacement');
const { CustomHandler } = require('../custom/handler');
class Installer { class Installer {
constructor() { constructor() {
@ -407,7 +408,10 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
* @param {string[]} config.ides - IDEs to configure * @param {string[]} config.ides - IDEs to configure
* @param {boolean} config.skipIde - Skip IDE configuration * @param {boolean} config.skipIde - Skip IDE configuration
*/ */
async install(config) { async install(originalConfig) {
// Clone config to avoid mutating the caller's object
const config = { ...originalConfig };
// Display BMAD logo // Display BMAD logo
CLIUtils.displayLogo(); CLIUtils.displayLogo();
@ -440,7 +444,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Handle selectedFiles (from existing install path or manual directory input) // Handle selectedFiles (from existing install path or manual directory input)
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) { for (const customFile of config.customContent.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, path.resolve(config.directory)); const customInfo = await customHandler.getCustomInfo(customFile, path.resolve(config.directory));
@ -837,9 +840,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Regular custom content from user input (non-cached) // Regular custom content from user input (non-cached)
if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
// Add custom modules to the installation list // Add custom modules to the installation list
const customHandler = new CustomHandler();
for (const customFile of finalCustomContent.selectedFiles) { for (const customFile of finalCustomContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler();
const customInfo = await customHandler.getCustomInfo(customFile, projectDir); const customInfo = await customHandler.getCustomInfo(customFile, projectDir);
if (customInfo && customInfo.id) { if (customInfo && customInfo.id) {
allModules.push(customInfo.id); allModules.push(customInfo.id);
@ -929,7 +931,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Finally check regular custom content // Finally check regular custom content
if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
for (const customFile of finalCustomContent.selectedFiles) { for (const customFile of finalCustomContent.selectedFiles) {
const info = await customHandler.getCustomInfo(customFile, projectDir); const info = await customHandler.getCustomInfo(customFile, projectDir);
@ -943,7 +944,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (isCustomModule && customInfo) { if (isCustomModule && customInfo) {
// Install custom module using CustomHandler but as a proper module // Install custom module using CustomHandler but as a proper module
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
// Install to module directory instead of custom directory // Install to module directory instead of custom directory
@ -972,19 +972,39 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (await fs.pathExists(customDir)) { if (await fs.pathExists(customDir)) {
// Move contents to module directory // Move contents to module directory
const items = await fs.readdir(customDir); const items = await fs.readdir(customDir);
for (const item of items) { const movedItems = [];
const srcPath = path.join(customDir, item); try {
const destPath = path.join(moduleTargetPath, item); for (const item of items) {
const srcPath = path.join(customDir, item);
const destPath = path.join(moduleTargetPath, item);
// If destination exists, remove it first (or we could merge) // If destination exists, remove it first (or we could merge)
if (await fs.pathExists(destPath)) { if (await fs.pathExists(destPath)) {
await fs.remove(destPath); await fs.remove(destPath);
}
await fs.move(srcPath, destPath);
movedItems.push({ src: srcPath, dest: destPath });
} }
} catch (moveError) {
await fs.move(srcPath, destPath); // Rollback: restore any successfully moved items
for (const moved of movedItems) {
try {
await fs.move(moved.dest, moved.src);
} catch {
// Best-effort rollback - log if it fails
console.error(`Failed to rollback ${moved.dest} during cleanup`);
}
}
throw new Error(`Failed to move custom module files: ${moveError.message}`);
} }
} }
await fs.remove(tempCustomPath); try {
await fs.remove(tempCustomPath);
} catch (cleanupError) {
// Non-fatal: temp directory cleanup failed but files were moved successfully
console.warn(`Warning: Could not clean up temp directory: ${cleanupError.message}`);
}
} }
// Create module config (include collected config from module.yaml prompts) // Create module config (include collected config from module.yaml prompts)
@ -1066,9 +1086,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
config.customContent.selectedFiles config.customContent.selectedFiles
) { ) {
// Filter out custom modules that were already installed // Filter out custom modules that were already installed
const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) { for (const customFile of config.customContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler();
const customInfo = await customHandler.getCustomInfo(customFile, projectDir); const customInfo = await customHandler.getCustomInfo(customFile, projectDir);
// Skip if this was installed as a module // Skip if this was installed as a module
@ -1080,7 +1099,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (remainingCustomContent.length > 0) { if (remainingCustomContent.length > 0) {
spinner.start('Installing remaining custom content...'); spinner.start('Installing remaining custom content...');
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
// Use the remaining files // Use the remaining files
@ -2581,18 +2599,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
installedModules, installedModules,
); );
// Handle both old return format (array) and new format (object) const { validCustomModules, keptModulesWithoutSources } = customModuleResult;
let validCustomModules = [];
let keptModulesWithoutSources = [];
if (Array.isArray(customModuleResult)) {
// Old format - just an array
validCustomModules = customModuleResult;
} else if (customModuleResult && typeof customModuleResult === 'object') {
// New format - object with two arrays
validCustomModules = customModuleResult.validCustomModules || [];
keptModulesWithoutSources = customModuleResult.keptModulesWithoutSources || [];
}
const customModulesFromManifest = validCustomModules.map((m) => ({ const customModulesFromManifest = validCustomModules.map((m) => ({
...m, ...m,
@ -3371,7 +3378,10 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// If no missing sources, return immediately // If no missing sources, return immediately
if (customModulesWithMissingSources.length === 0) { if (customModulesWithMissingSources.length === 0) {
return validCustomModules; return {
validCustomModules,
keptModulesWithoutSources: [],
};
} }
// Stop any spinner for interactive prompts // Stop any spinner for interactive prompts

View File

@ -125,12 +125,15 @@ class WorkflowCommandGenerator {
} }
} }
const coreWorkflowPath = `${this.bmadFolderName}/core/tasks/workflow.xml`;
// Replace template variables // Replace template variables
return template return template
.replaceAll('{{name}}', workflow.name) .replaceAll('{{name}}', workflow.name)
.replaceAll('{{module}}', workflow.module) .replaceAll('{{module}}', workflow.module)
.replaceAll('{{description}}', workflow.description) .replaceAll('{{description}}', workflow.description)
.replaceAll('{{workflow_path}}', workflowPath) .replaceAll('{{workflow_path}}', workflowPath)
.replaceAll('{{core_workflow_path}}', coreWorkflowPath)
.replaceAll('{bmad_folder}', this.bmadFolderName) .replaceAll('{bmad_folder}', this.bmadFolderName)
.replaceAll('{*bmad_folder*}', '{bmad_folder}'); .replaceAll('{*bmad_folder*}', '{bmad_folder}');
} }

View File

@ -5,7 +5,7 @@ description: '{{description}}'
IT IS CRITICAL THAT YOU FOLLOW THESE STEPS - while staying in character as the current agent persona you may have loaded: IT IS CRITICAL THAT YOU FOLLOW THESE STEPS - while staying in character as the current agent persona you may have loaded:
<steps CRITICAL="TRUE"> <steps CRITICAL="TRUE">
1. Always LOAD the FULL @{bmad_folder}/core/tasks/workflow.xml 1. Always LOAD the FULL @{{core_workflow_path}}
2. READ its entire contents - this is the CORE OS for EXECUTING the specific workflow-config @{{workflow_path}} 2. READ its entire contents - this is the CORE OS for EXECUTING the specific workflow-config @{{workflow_path}}
3. Pass the yaml path {{workflow_path}} as 'workflow-config' parameter to the workflow.xml instructions 3. Pass the yaml path {{workflow_path}} as 'workflow-config' parameter to the workflow.xml instructions
4. Follow workflow.xml instructions EXACTLY as written to process and follow the specific workflow config and its instructions 4. Follow workflow.xml instructions EXACTLY as written to process and follow the specific workflow config and its instructions

View File

@ -391,8 +391,8 @@ class ModuleManager {
if (config.code === moduleName) { if (config.code === moduleName) {
return modulePath; return modulePath;
} }
} catch { } catch (error) {
// Skip if can't read config throw new Error(`Failed to parse module.yaml at ${configPath}: ${error.message}`);
} }
} }
} }

View File

@ -24,6 +24,7 @@ const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { CustomHandler } = require('../installers/lib/custom/handler');
/** /**
* UI utilities for the installer * UI utilities for the installer
@ -150,7 +151,6 @@ class UI {
const { CustomModuleCache } = require('../installers/lib/core/custom-module-cache'); const { CustomModuleCache } = require('../installers/lib/core/custom-module-cache');
const cache = new CustomModuleCache(bmadDir); const cache = new CustomModuleCache(bmadDir);
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
@ -218,7 +218,6 @@ class UI {
customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', '')); customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', ''));
// Convert custom content to module IDs for installation // Convert custom content to module IDs for installation
const customContentModuleIds = []; const customContentModuleIds = [];
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
for (const customFile of customContentConfig.selectedFiles) { for (const customFile of customContentConfig.selectedFiles) {
// Get the module info to extract the ID // Get the module info to extract the ID
@ -637,8 +636,8 @@ class UI {
moduleData = yaml.load(yamlContent); moduleData = yaml.load(yamlContent);
foundPath = configPath; foundPath = configPath;
break; break;
} catch { } catch (error) {
// Continue to next path throw new Error(`Failed to parse config at ${configPath}: ${error.message}`);
} }
} }
} }
@ -654,20 +653,11 @@ class UI {
cached: true, cached: true,
}); });
} else { } else {
// Debug: show what paths we tried to check // Module config not found - skip silently (non-critical)
console.log(chalk.dim(`DEBUG: No module config found for ${cachedModule.id}`));
console.log(
chalk.dim(
`DEBUG: Tried paths:`,
possibleConfigPaths.map((p) => p.replace(cachedModule.cachePath, '.')),
),
);
console.log(chalk.dim(`DEBUG: cachedModule:`, JSON.stringify(cachedModule, null, 2)));
} }
} }
} else if (customContentConfig.customPath) { } else if (customContentConfig.customPath) {
// Existing installation - show from directory // Existing installation - show from directory
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); const customFiles = await customHandler.findCustomContent(customContentConfig.customPath);
@ -882,7 +872,6 @@ class UI {
expandedPath = this.expandUserPath(directory.trim()); expandedPath = this.expandUserPath(directory.trim());
// Check if directory has custom content // Check if directory has custom content
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(expandedPath); const customFiles = await customHandler.findCustomContent(expandedPath);
@ -1277,7 +1266,6 @@ class UI {
const resolvedPath = CLIUtils.expandPath(customPath); const resolvedPath = CLIUtils.expandPath(customPath);
// Find custom content // Find custom content
const { CustomHandler } = require('../installers/lib/custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
const customFiles = await customHandler.findCustomContent(resolvedPath); const customFiles = await customHandler.findCustomContent(resolvedPath);
@ -1302,12 +1290,10 @@ class UI {
// Display found items // Display found items
console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`)); console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`));
const { CustomHandler: CustomHandler2 } = require('../installers/lib/custom/handler');
const customHandler2 = new CustomHandler2();
const customContentItems = []; const customContentItems = [];
for (const customFile of customFiles) { for (const customFile of customFiles) {
const customInfo = await customHandler2.getCustomInfo(customFile); const customInfo = await customHandler.getCustomInfo(customFile);
if (customInfo) { if (customInfo) {
customContentItems.push({ customContentItems.push({
name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`,