Compare commits

...

6 Commits

Author SHA1 Message Date
Alex Verkhovsky ea94745758
Merge d5e5796ba3 into c24821b6ed 2025-12-16 08:44:23 +01:00
Brian Madison c24821b6ed menu wording updates 2025-12-16 01:25:49 +08:00
Brian Madison 2c4c2d9717 reduce installer log output 2025-12-15 23:53:26 +08:00
Brian d5e5796ba3
Merge branch 'main' into feature/more-cynical-review 2025-12-15 07:50:26 +08:00
Alex Verkhovsky 43c6b6e5bd feat(bmm): add automatic adversarial code review to quick-dev workflow
Adds Step 5 to quick-dev that automatically runs adversarial code review
after implementation completes. Captures baseline commit at workflow start
and reviews all changes (tracked + newly created files) using a cynical
reviewer persona via subagent, CLI fallback, or inline self-review.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 23:06:32 -07:00
Alex Verkhovsky 65c93c529c feat(bmm): add information-asymmetric adversarial code review
Enhance code review workflow with a two-phase approach:
- Context-aware review (step 3): Uses story knowledge to check implementation
- Asymmetric adversarial review (step 4): Cynical reviewer with no story context
  judges changes purely on technical merit

Key additions:
- Cynical reviewer persona that expects to find problems
- Execution hierarchy: Task tool > CLI fresh context > inline fallback
- Findings consolidation with deduplication across both review phases
- Improved severity assessment (CRITICAL/HIGH/MEDIUM/LOW)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 12:13:46 -07:00
7 changed files with 416 additions and 415 deletions

View File

@ -104,52 +104,104 @@
</action>
<action>Find at least 3 more specific, actionable issues</action>
</check>
<!-- Store context-aware findings for later consolidation -->
<action>Set {{context_aware_findings}} = all issues found in this step (numbered list with file:line locations)</action>
</step>
<step n="4" goal="Present findings and fix them">
<action>Categorize findings: HIGH (must fix), MEDIUM (should fix), LOW (nice to fix)</action>
<step n="4" goal="Run information-asymmetric adversarial review">
<critical>Reviewer has FULL repo access but NO knowledge of WHY changes were made</critical>
<critical>DO NOT include story file in prompt - asymmetry is about intent, not visibility</critical>
<critical>Reviewer can explore codebase to understand impact, but judges changes on merit alone</critical>
<!-- Construct diff of story-related changes -->
<action>Construct the diff of story-related changes:
- Uncommitted changes: `git diff` + `git diff --cached`
- Committed changes (if story spans commits): `git log --oneline` to find relevant commits, then `git diff base..HEAD`
- Exclude story file from diff: `git diff -- . ':!{{story_path}}'`
</action>
<action>Set {{asymmetric_target}} = the diff output (reviewer can explore repo but is prompted to review this diff)</action>
<!-- Execution hierarchy: cleanest context first -->
<check if="Task tool available (can spawn subagent)">
<action>Launch general-purpose subagent with adversarial prompt:
"You are a cynical, jaded code reviewer with zero patience for sloppy work.
A clueless weasel submitted the following changes and you expect to find problems.
Find at least ten findings to fix or improve. Look for what's missing, not just what's wrong.
Number each finding (1., 2., 3., ...). Be skeptical of everything.
Changes to review:
{{asymmetric_target}}"
</action>
<action>Collect numbered findings into {{asymmetric_findings}}</action>
</check>
<check if="no Task tool BUT can use Bash to invoke CLI for fresh context">
<action>Execute adversarial review via CLI (e.g., claude --print) in fresh context with same prompt</action>
<action>Collect numbered findings into {{asymmetric_findings}}</action>
</check>
<check if="cannot create clean slate agent by any means (fallback)">
<action>Execute adversarial prompt inline in main context</action>
<action>Note: Has context pollution but cynical reviewer persona still adds significant value</action>
<action>Collect numbered findings into {{asymmetric_findings}}</action>
</check>
</step>
<step n="5" goal="Consolidate findings and present to user">
<critical>Merge findings from BOTH context-aware review (step 3) AND asymmetric review (step 4)</critical>
<action>Combine {{context_aware_findings}} from step 3 with {{asymmetric_findings}} from step 4</action>
<action>Deduplicate findings:
- Identify findings that describe the same underlying issue
- Keep the more detailed/actionable version
- Note when both reviews caught the same issue (validates severity)
</action>
<action>Assess each finding:
- Is this a real issue or noise/false positive?
- Assign severity: 🔴 CRITICAL, 🟠 HIGH, 🟡 MEDIUM, 🟢 LOW
</action>
<action>Filter out non-issues:
- Remove false positives
- Remove nitpicks that do not warrant action
- Keep anything that could cause problems in production
</action>
<action>Sort by severity (CRITICAL → HIGH → MEDIUM → LOW)</action>
<action>Set {{fixed_count}} = 0</action>
<action>Set {{action_count}} = 0</action>
<output>**🔥 CODE REVIEW FINDINGS, {user_name}!**
**Story:** {{story_file}}
**Story:** {{story_path}}
**Git vs Story Discrepancies:** {{git_discrepancy_count}} found
**Issues Found:** {{high_count}} High, {{medium_count}} Medium, {{low_count}} Low
**Issues Found:** {{critical_count}} Critical, {{high_count}} High, {{medium_count}} Medium, {{low_count}} Low
## 🔴 CRITICAL ISSUES
- Tasks marked [x] but not actually implemented
- Acceptance Criteria not implemented
- Story claims files changed but no git evidence
- Security vulnerabilities
| # | Severity | Summary | Location |
|---|----------|---------|----------|
{{findings_table}}
## 🟡 MEDIUM ISSUES
- Files changed but not documented in story File List
- Uncommitted changes not tracked
- Performance problems
- Poor test coverage/quality
- Code maintainability issues
## 🟢 LOW ISSUES
- Code style improvements
- Documentation gaps
- Git commit message quality
**{{total_count}} issues found** ({{critical_count}} critical, {{high_count}} high, {{medium_count}} medium, {{low_count}} low)
</output>
<ask>What should I do with these issues?
1. **Fix them automatically** - I'll update the code and tests
1. **Fix them automatically** - I'll fix all HIGH and CRITICAL, you approve each
2. **Create action items** - Add to story Tasks/Subtasks for later
3. **Show me details** - Deep dive into specific issues
3. **Details on #N** - Explain specific issue
Choose [1], [2], or specify which issue to examine:</ask>
<check if="user chooses 1">
<action>Fix all HIGH and MEDIUM issues in the code</action>
<action>Fix all CRITICAL and HIGH issues in the code</action>
<action>Add/update tests as needed</action>
<action>Update File List in story if files changed</action>
<action>Update story Dev Agent Record with fixes applied</action>
<action>Set {{fixed_count}} = number of HIGH and MEDIUM issues fixed</action>
<action>Set {{fixed_count}} = number of CRITICAL and HIGH issues fixed</action>
<action>Set {{action_count}} = 0</action>
</check>
@ -166,13 +218,13 @@
</check>
</step>
<step n="5" goal="Update story status and sync sprint tracking">
<step n="6" goal="Update story status and sync sprint tracking">
<!-- Determine new status based on review outcome -->
<check if="all HIGH and MEDIUM issues fixed AND all ACs implemented">
<check if="all CRITICAL and HIGH issues fixed AND all ACs implemented">
<action>Set {{new_status}} = "done"</action>
<action>Update story Status field to "done"</action>
</check>
<check if="HIGH or MEDIUM issues remain OR ACs not fully implemented">
<check if="CRITICAL or HIGH issues remain OR ACs not fully implemented">
<action>Set {{new_status}} = "in-progress"</action>
<action>Update story Status field to "in-progress"</action>
</check>

View File

@ -23,3 +23,11 @@
- [ ] Acceptance criteria satisfied
- [ ] Tech-spec updated (if applicable)
- [ ] Summary provided to user
## Adversarial Review
- [ ] Diff constructed (tracked changes from {baseline_commit} + new untracked files)
- [ ] Adversarial review executed (subagent preferred)
- [ ] Findings presented with severity and classification
- [ ] User chose handling approach (walk through / auto-fix / skip)
- [ ] Findings resolved or acknowledged

View File

@ -15,6 +15,8 @@
<step n="1" goal="Load project context and determine execution mode">
<action>Record current HEAD as baseline for later review. Run `git rev-parse HEAD` and store the result as {baseline_commit}.</action>
<action>Check if {project_context} exists. If yes, load it - this is your foundational reference for ALL implementation decisions (patterns, conventions, architecture).</action>
<action>Parse user input:
@ -170,7 +172,7 @@ Use holistic judgment, not mechanical keyword matching.</action>
</step>
<step n="4" goal="Verify and complete">
<step n="4" goal="Verify and transition to review">
<action>Verify: all tasks [x], tests passing, AC satisfied, patterns followed</action>
@ -185,17 +187,89 @@ Use holistic judgment, not mechanical keyword matching.</action>
**Tests:** {{test_summary}}
**AC Status:** {{ac_status}}
---
Running adversarial code review...
</output>
**Before committing (Recommended): Copy this code review prompt to a different LLM**
<action>Proceed immediately to step 5</action>
</step>
<step n="5" goal="Adversarial code review (automatic)">
<action>Construct diff of all changes since workflow started and capture as {diff_output}:
**Tracked file changes:**
```bash
git diff {baseline_commit}
```
You are a cynical, jaded code reviewer with zero patience for sloppy work. These uncommitted changes were submitted by a clueless weasel and you expect to find problems. Find at least five issues to fix or improve in it. Number them. Be skeptical of everything.
```
**New files created by this workflow:**
Only include untracked files that YOU actually created during steps 2-4. Do not include pre-existing untracked files. For each new file you created, include its full content as a "new file" addition.
Combine both into {diff_output} for review. Do NOT `git add` anything - this is read-only inspection.</action>
<action>Execute adversarial review using this hierarchy (try in order until one succeeds):
1. **Spawn subagent** (preferred) - pass the diff output along with this prompt:
```
You are a cynical, jaded code reviewer with zero patience for sloppy work. This diff was submitted by a clueless weasel and you expect to find problems. Find at least five issues to fix or improve. Number them. Be skeptical of everything.
<diff>
{diff_output}
</diff>
```
2. **CLI fallback** - pipe diff to `claude --print` with same prompt
3. **Inline self-review** - Review the diff output yourself using the cynical reviewer persona above
</action>
<check if="zero findings returned">
<action>HALT - Zero findings is suspicious. Adversarial review should always find something. Request user guidance.</action>
</check>
<action>Process findings:
- Assign IDs: F1, F2, F3...
- Assign severity: 🔴 Critical | 🟠 High | 🟡 Medium | 🟢 Low
- Classify each: **real** (confirmed issue) | **noise** (false positive) | **uncertain** (needs discussion)
</action>
<output>**Adversarial Review Findings**
| ID | Severity | Classification | Finding |
| --- | -------- | -------------- | ------- |
| F1 | 🟠 | real | ... |
| F2 | 🟡 | noise | ... |
| ... |
</output>
<action>You must explain what was implemented based on {user_skill_level}</action>
<ask>How would you like to handle these findings?
**[1] Walk through** - Discuss each finding individually
**[2] Auto-fix** - Automatically fix issues classified as "real"
**[3] Skip** - Acknowledge and proceed to commit</ask>
<check if="1">
<action>Present each finding one by one. For each, ask: fix now / skip / discuss</action>
<action>Apply fixes as approved</action>
</check>
<check if="2">
<action>Automatically fix all findings classified as "real"</action>
<action>Report what was fixed</action>
</check>
<check if="3">
<action>Acknowledge findings were reviewed and user chose to skip</action>
</check>
<output>**Review complete. Ready to commit.**</output>
<action>Explain what was implemented based on {user_skill_level}</action>
</step>

View File

@ -21,15 +21,6 @@ module.exports = {
return;
}
// Handle agent compilation separately
if (config.actionType === 'compile') {
const result = await installer.compileAgents(config);
console.log(chalk.green('\n✨ Agent compilation complete!'));
console.log(chalk.cyan(`Rebuilt ${result.agentCount} agents and ${result.taskCount} tasks`));
process.exit(0);
return;
}
// Handle quick update separately
if (config.actionType === 'quick-update') {
const result = await installer.quickUpdate(config);

View File

@ -439,6 +439,33 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}
}
} else {
// For regular updates (modify flow), check manifest for custom module sources
if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) {
for (const customModule of config._existingInstall.customModules) {
// Ensure we have an absolute sourcePath
let absoluteSourcePath = customModule.sourcePath;
// Check if sourcePath is a cache-relative path (starts with _config)
if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) {
// Convert cache-relative path to absolute path
absoluteSourcePath = path.join(bmadDir, absoluteSourcePath);
}
// If no sourcePath but we have relativePath, convert it
else if (!absoluteSourcePath && customModule.relativePath) {
// relativePath is relative to the project root (parent of bmad dir)
absoluteSourcePath = path.resolve(projectDir, customModule.relativePath);
}
// Ensure sourcePath is absolute for anything else
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
absoluteSourcePath = path.resolve(absoluteSourcePath);
}
if (absoluteSourcePath) {
customModulePaths.set(customModule.id, absoluteSourcePath);
}
}
}
// Build custom module paths map from customContent
// Handle selectedFiles (from existing install path or manual directory input)
@ -589,20 +616,39 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
const existingFilesManifest = await this.readFilesManifest(bmadDir);
console.log(chalk.dim(`DEBUG: Read ${existingFilesManifest.length} files from manifest`));
console.log(chalk.dim(`DEBUG: Manifest has hashes: ${existingFilesManifest.some((f) => f.hash)}`));
const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest);
console.log(chalk.dim(`DEBUG: Found ${customFiles.length} custom files, ${modifiedFiles.length} modified files`));
if (modifiedFiles.length > 0) {
console.log(chalk.yellow('DEBUG: Modified files:'));
for (const f of modifiedFiles) console.log(chalk.dim(` - ${f.path}`));
}
config._customFiles = customFiles;
config._modifiedFiles = modifiedFiles;
// Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) {
const moduleId = cachedModule.name;
// Skip if we already have this module from manifest
if (customModulePaths.has(moduleId)) {
continue;
}
const cachedPath = path.join(cacheDir, moduleId);
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModulePaths.set(moduleId, cachedPath);
}
}
}
// Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths);
}
// If there are custom files, back them up temporarily
if (customFiles.length > 0) {
const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp');
@ -625,20 +671,16 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir);
console.log(chalk.yellow(`\nDEBUG: Backing up ${modifiedFiles.length} modified files to temp location`));
spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
console.log(chalk.dim(`DEBUG: Backing up ${relativePath} to temp`));
await fs.ensureDir(path.dirname(tempBackupPath));
await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
}
spinner.succeed(`Backed up ${modifiedFiles.length} modified files`);
config._tempModifiedBackupDir = tempModifiedBackupDir;
} else {
console.log(chalk.dim('DEBUG: No modified files detected'));
}
}
} else if (existingInstall.installed && config._quickUpdate) {
@ -654,6 +696,34 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
config._customFiles = customFiles;
config._modifiedFiles = modifiedFiles;
// Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) {
const moduleId = cachedModule.name;
// Skip if we already have this module from manifest
if (customModulePaths.has(moduleId)) {
continue;
}
const cachedPath = path.join(cacheDir, moduleId);
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModulePaths.set(moduleId, cachedPath);
}
}
}
// Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths);
}
// Back up custom files
if (customFiles.length > 0) {
const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp');
@ -832,7 +902,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// For dependency resolution, we need to pass the project root
// Create a temporary module manager that knows about custom content locations
const tempModuleManager = new ModuleManager({
scanProjectForModules: true,
bmadDir: bmadDir, // Pass bmadDir so we can check cache
});
@ -1057,7 +1126,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Pass pre-collected configuration to avoid re-prompting
await this.ideManager.setup(ide, projectDir, bmadDir, {
selectedModules: config.modules || [],
selectedModules: allModules || [],
preCollectedConfig: ideConfigurations[ide] || null,
verbose: config.verbose,
});
@ -1184,11 +1253,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Report custom and modified files if any were found
if (customFiles.length > 0) {
console.log(chalk.cyan(`\n📁 Custom files preserved: ${customFiles.length}`));
console.log(chalk.dim('The following custom files were found and restored:\n'));
for (const customFile of customFiles) {
const relativePath = path.relative(projectDir, customFile);
console.log(chalk.dim(`${relativePath}`));
}
}
if (modifiedFiles.length > 0) {
@ -2184,41 +2248,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const configuredIdes = existingInstall.ides || [];
const projectRoot = path.dirname(bmadDir);
// Get custom module sources from manifest and cache
// Get custom module sources from cache
const customModuleSources = new Map();
// First check manifest for backward compatibility
if (existingInstall.customModules) {
for (const customModule of existingInstall.customModules) {
// Ensure we have an absolute sourcePath
let absoluteSourcePath = customModule.sourcePath;
// Check if sourcePath is a cache-relative path (starts with _config/)
if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) {
// Convert cache-relative path to absolute path
absoluteSourcePath = path.join(bmadDir, absoluteSourcePath);
}
// If no sourcePath but we have relativePath, convert it
else if (!absoluteSourcePath && customModule.relativePath) {
// relativePath is relative to the project root (parent of bmad dir)
absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath);
}
// Ensure sourcePath is absolute for anything else
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
absoluteSourcePath = path.resolve(absoluteSourcePath);
}
// Update the custom module object with the absolute path
const updatedModule = {
...customModule,
sourcePath: absoluteSourcePath,
};
customModuleSources.set(customModule.id, updatedModule);
}
}
// Also check cache directory for any modules not in manifest
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
@ -2277,126 +2308,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}
}
// Check for untracked custom modules (installed but not in manifest)
const untrackedCustomModules = [];
for (const installedModule of installedModules) {
// Skip standard modules and core
const standardModuleIds = ['bmb', 'bmgd', 'bmm', 'cis', 'core'];
if (standardModuleIds.includes(installedModule)) {
continue;
}
// Check if this installed module is not tracked in customModules
if (!customModuleSources.has(installedModule)) {
const modulePath = path.join(bmadDir, installedModule);
if (await fs.pathExists(modulePath)) {
untrackedCustomModules.push({
id: installedModule,
name: installedModule, // We don't have the original name
path: modulePath,
untracked: true,
});
}
}
}
// If we found untracked custom modules, offer to track them
if (untrackedCustomModules.length > 0) {
spinner.stop();
console.log(chalk.yellow(`\n⚠️ Found ${untrackedCustomModules.length} custom module(s) not tracked in manifest:`));
for (const untracked of untrackedCustomModules) {
console.log(chalk.dim(`${untracked.id} (installed at ${path.relative(projectRoot, untracked.path)})`));
}
const { trackModules } = await inquirer.prompt([
{
type: 'confirm',
name: 'trackModules',
message: chalk.cyan('Would you like to scan for their source locations?'),
default: true,
},
]);
if (trackModules) {
const { scanDirectory } = await inquirer.prompt([
{
type: 'input',
name: 'scanDirectory',
message: 'Enter directory to scan for custom module sources (or leave blank to skip):',
default: projectRoot,
validate: async (input) => {
if (input && input.trim() !== '') {
const expandedPath = path.resolve(input.trim());
if (!(await fs.pathExists(expandedPath))) {
return 'Directory does not exist';
}
const stats = await fs.stat(expandedPath);
if (!stats.isDirectory()) {
return 'Path must be a directory';
}
}
return true;
},
},
]);
if (scanDirectory && scanDirectory.trim() !== '') {
console.log(chalk.dim('\nScanning for custom module sources...'));
// Scan for all module.yaml files
const allModulePaths = await this.moduleManager.findModulesInProject(scanDirectory);
const { ModuleManager } = require('../modules/manager');
const mm = new ModuleManager({ scanProjectForModules: true });
for (const untracked of untrackedCustomModules) {
let foundSource = null;
// Try to find by module ID
for (const modulePath of allModulePaths) {
try {
const moduleInfo = await mm.getModuleInfo(modulePath);
if (moduleInfo && moduleInfo.id === untracked.id) {
foundSource = {
path: modulePath,
info: moduleInfo,
};
break;
}
} catch {
// Continue searching
}
}
if (foundSource) {
console.log(chalk.green(` ✓ Found source for ${untracked.id}: ${path.relative(projectRoot, foundSource.path)}`));
// Add to manifest
await this.manifest.addCustomModule(bmadDir, {
id: untracked.id,
name: foundSource.info.name || untracked.name,
sourcePath: path.resolve(foundSource.path),
installDate: new Date().toISOString(),
tracked: true,
});
// Add to customModuleSources for processing
customModuleSources.set(untracked.id, {
id: untracked.id,
name: foundSource.info.name || untracked.name,
sourcePath: path.resolve(foundSource.path),
});
} else {
console.log(chalk.yellow(` ⚠ Could not find source for ${untracked.id}`));
}
}
}
}
console.log(chalk.dim('\nUntracked custom modules will remain installed but cannot be updated without their source.'));
spinner.start('Preparing update...');
}
// Handle missing custom module sources using shared method
const customModuleResult = await this.handleMissingCustomSources(
customModuleSources,
@ -2414,18 +2325,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
hasUpdate: true,
}));
// Add untracked modules to the update list but mark them as untrackable
for (const untracked of untrackedCustomModules) {
if (!customModuleSources.has(untracked.id)) {
customModulesFromManifest.push({
...untracked,
isCustom: true,
hasUpdate: false, // Can't update without source
untracked: true,
});
}
}
const allAvailableModules = [...availableModules, ...customModulesFromManifest];
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));

View File

@ -28,7 +28,6 @@ class ModuleManager {
this.modulesSourcePath = getSourcePath('modules');
this.xmlHandler = new XmlHandler();
this.bmadFolderName = 'bmad'; // Default, can be overridden
this.scanProjectForModules = options.scanProjectForModules !== false; // Default to true for backward compatibility
this.customModulePaths = new Map(); // Initialize custom module paths
}
@ -116,76 +115,6 @@ class ModuleManager {
}
}
/**
* Find all modules in the project by searching for module.yaml files
* @returns {Array} List of module paths
*/
async findModulesInProject() {
const projectRoot = getProjectRoot();
const modulePaths = new Set();
// Helper function to recursively scan directories
async function scanDirectory(dir, excludePaths = []) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip hidden directories, node_modules, and literal placeholder directories
if (
entry.name.startsWith('.') ||
entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '{project-root}'
) {
continue;
}
// Skip excluded paths
if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) {
continue;
}
if (entry.isDirectory()) {
// Skip core module - it's always installed first and not selectable
if (entry.name === 'core') {
continue;
}
// Check if this directory contains a module (module.yaml OR custom.yaml)
const moduleConfigPath = path.join(fullPath, 'module.yaml');
const installerConfigPath = path.join(fullPath, '_module-installer', 'module.yaml');
const customConfigPath = path.join(fullPath, '_module-installer', 'custom.yaml');
const rootCustomConfigPath = path.join(fullPath, 'custom.yaml');
if (
(await fs.pathExists(moduleConfigPath)) ||
(await fs.pathExists(installerConfigPath)) ||
(await fs.pathExists(customConfigPath)) ||
(await fs.pathExists(rootCustomConfigPath))
) {
modulePaths.add(fullPath);
// Don't scan inside modules - they might have their own nested structures
continue;
}
// Recursively scan subdirectories
await scanDirectory(fullPath, excludePaths);
}
}
} catch {
// Ignore errors (e.g., permission denied)
}
}
// Scan the entire project, but exclude src/modules since we handle it separately
await scanDirectory(projectRoot, [this.modulesSourcePath]);
return [...modulePaths];
}
/**
* List all available modules (excluding core which is always installed)
* @returns {Object} Object with modules array and customModules array
@ -228,43 +157,19 @@ class ModuleManager {
}
}
// Then, find all other modules in the project (only if scanning is enabled)
if (this.scanProjectForModules) {
const otherModulePaths = await this.findModulesInProject();
for (const modulePath of otherModulePaths) {
const moduleName = path.basename(modulePath);
const relativePath = path.relative(getProjectRoot(), modulePath);
// Skip core module - it's always installed first and not selectable
if (moduleName === 'core') {
continue;
}
const moduleInfo = await this.getModuleInfo(modulePath, moduleName, relativePath);
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
// Avoid duplicates - skip if we already have this module ID
if (moduleInfo.isCustom) {
customModules.push(moduleInfo);
} else {
modules.push(moduleInfo);
}
}
}
// Also check for cached custom modules in _config/custom/
if (this.bmadDir) {
const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
if (await fs.pathExists(customCacheDir)) {
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
for (const entry of cacheEntries) {
if (entry.isDirectory()) {
const cachePath = path.join(customCacheDir, entry.name);
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
moduleInfo.isCustom = true;
moduleInfo.fromCache = true;
customModules.push(moduleInfo);
}
// Check for cached custom modules in _config/custom/
if (this.bmadDir) {
const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
if (await fs.pathExists(customCacheDir)) {
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
for (const entry of cacheEntries) {
if (entry.isDirectory()) {
const cachePath = path.join(customCacheDir, entry.name);
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
moduleInfo.isCustom = true;
moduleInfo.fromCache = true;
customModules.push(moduleInfo);
}
}
}

View File

@ -195,11 +195,7 @@ class UI {
}
// Common actions
choices.push(
{ name: 'Modify BMAD Installation', value: 'update' },
{ name: 'Add / Update Custom Content', value: 'add-custom' },
{ name: 'Rebuild Agents', value: 'compile' },
);
choices.push({ name: 'Modify BMAD Installation', value: 'update' });
const promptResult = await inquirer.prompt([
{
@ -224,64 +220,6 @@ class UI {
};
}
// Handle add custom content separately
if (actionType === 'add-custom') {
customContentConfig = await this.promptCustomContentSource();
// After adding custom content, continue to select additional modules
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
// Ask if user wants to add additional modules
const { wantsMoreModules } = await inquirer.prompt([
{
type: 'confirm',
name: 'wantsMoreModules',
message: 'Do you want to add any additional modules?',
default: false,
},
]);
let selectedModules = [];
if (wantsMoreModules) {
const moduleChoices = await this.getModuleChoices(installedModuleIds, customContentConfig);
selectedModules = await this.selectModules(moduleChoices);
// Process custom content selection
const selectedCustomContent = selectedModules.filter((mod) => mod.startsWith('__CUSTOM_CONTENT__'));
if (selectedCustomContent.length > 0) {
customContentConfig.selected = true;
customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', ''));
// Convert to module IDs
const customContentModuleIds = [];
const customHandler = new CustomHandler();
for (const customFile of customContentConfig.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile);
if (customInfo) {
customContentModuleIds.push(customInfo.id);
}
}
selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds];
}
}
return {
actionType: 'update',
directory: confirmedDirectory,
installCore: false, // Don't reinstall core
modules: selectedModules,
customContent: customContentConfig,
};
}
// Handle agent compilation separately
if (actionType === 'compile') {
return {
actionType: 'compile',
directory: confirmedDirectory,
};
}
// If actionType === 'update', handle it with the new flow
// Return early with modify configuration
if (actionType === 'update') {
@ -293,7 +231,7 @@ class UI {
{
type: 'confirm',
name: 'changeModuleSelection',
message: 'Change which modules are installed?',
message: 'Modify official module selection (BMad Method, BMad Builder, Creative Innovation Suite)?',
default: false,
},
]);
@ -307,6 +245,14 @@ class UI {
selectedModules = [...installedModuleIds];
}
// After module selection, ask about custom modules
const customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
// Merge any selected custom modules
if (customModuleResult.selectedCustomModules.length > 0) {
selectedModules.push(...customModuleResult.selectedCustomModules);
}
// Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, selectedModules);
@ -337,7 +283,7 @@ class UI {
ides: toolSelection.ides,
skipIde: toolSelection.skipIde,
coreConfig: coreConfig,
customContent: { hasCustomContent: false },
customContent: customModuleResult.customContentConfig,
enableAgentVibes: enableTts,
agentVibesInstalled: false,
};
@ -352,7 +298,7 @@ class UI {
{
type: 'confirm',
name: 'wantsOfficialModules',
message: 'Will you be installing any official modules (BMad Method, BMad Builder, Creative Innovation Suite)?',
message: 'Will you be installing any official BMad modules (BMad Method, BMad Builder, Creative Innovation Suite)?',
default: true,
},
]);
@ -368,7 +314,7 @@ class UI {
{
type: 'confirm',
name: 'wantsCustomContent',
message: 'Will you be installing any locally stored custom content?',
message: 'Would you like to install a local custom module (this includes custom agents and workflows also)?',
default: false,
},
]);
@ -734,14 +680,8 @@ class UI {
// Add official modules
const { ModuleManager } = require('../installers/lib/modules/manager');
// For new installations, don't scan project yet (will do after custom content is discovered)
// For existing installations, scan if user selected custom content
const shouldScanProject =
!isNewInstallation && customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected;
const moduleManager = new ModuleManager({
scanProjectForModules: shouldScanProject,
});
const { modules: availableModules, customModules: customModulesFromProject } = await moduleManager.listAvailable();
const moduleManager = new ModuleManager();
const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable();
// First, add all items to appropriate sections
const allCustomModules = [];
@ -749,14 +689,14 @@ class UI {
// Add custom content items from directory
allCustomModules.push(...customContentItems);
// Add custom modules from project scan (if scanning is enabled)
for (const mod of customModulesFromProject) {
// Add custom modules from cache
for (const mod of customModulesFromCache) {
// Skip if this module is already in customContentItems (by path)
const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path));
if (!isDuplicate) {
allCustomModules.push({
name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(${mod.source})`)}`,
name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(cached)`)}`,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
});
@ -803,7 +743,9 @@ class UI {
},
]);
return moduleAnswer.modules || [];
const selected = moduleAnswer.modules || [];
return selected;
}
/**
@ -1472,6 +1414,136 @@ class UI {
return customContentConfig;
}
/**
* Handle custom modules in the modify flow
* @param {string} directory - Installation directory
* @param {Array} selectedModules - Currently selected modules
* @returns {Object} Result with selected custom modules and custom content config
*/
async handleCustomModulesInModifyFlow(directory, selectedModules) {
// Get existing installation to find custom modules
const { existingInstall } = await this.getExistingInstallation(directory);
// Check if there are any custom modules in cache
const { Installer } = require('../installers/lib/core/installer');
const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(directory);
const cacheDir = path.join(bmadDir, '_config', 'custom');
const cachedCustomModules = [];
if (await fs.pathExists(cacheDir)) {
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const moduleYamlPath = path.join(cacheDir, entry.name, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
const yaml = require('yaml');
const content = await fs.readFile(moduleYamlPath, 'utf8');
const moduleData = yaml.parse(content);
cachedCustomModules.push({
id: entry.name,
name: moduleData.name || entry.name,
description: moduleData.description || 'Custom module from cache',
checked: selectedModules.includes(entry.name),
fromCache: true,
});
}
}
}
}
const result = {
selectedCustomModules: [],
customContentConfig: { hasCustomContent: false },
};
if (cachedCustomModules.length === 0) {
return result;
}
// Ask user about custom modules
console.log(chalk.cyan('\n⚙ Custom Modules'));
console.log(chalk.dim('Found custom modules in your installation:'));
const { customAction } = await inquirer.prompt([
{
type: 'list',
name: 'customAction',
message: 'What would you like to do with custom modules?',
choices: [
{ name: 'Keep all existing custom modules', value: 'keep' },
{ name: 'Select which custom modules to keep', value: 'select' },
{ name: 'Add new custom modules', value: 'add' },
{ name: 'Remove all custom modules', value: 'remove' },
],
default: 'keep',
},
]);
switch (customAction) {
case 'keep': {
// Keep all existing custom modules
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
console.log(chalk.dim(`Keeping ${result.selectedCustomModules.length} custom module(s)`));
break;
}
case 'select': {
// Let user choose which to keep
const choices = cachedCustomModules.map((m) => ({
name: `${m.name} ${chalk.gray(`(${m.id})`)}`,
value: m.id,
}));
const { keepModules } = await inquirer.prompt([
{
type: 'checkbox',
name: 'keepModules',
message: 'Select custom modules to keep:',
choices: choices,
default: cachedCustomModules.filter((m) => m.checked).map((m) => m.id),
},
]);
result.selectedCustomModules = keepModules;
break;
}
case 'add': {
// First ask to keep existing ones
const { keepExisting } = await inquirer.prompt([
{
type: 'confirm',
name: 'keepExisting',
message: 'Keep existing custom modules?',
default: true,
},
]);
if (keepExisting) {
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
}
// Then prompt for new ones (reuse existing method)
const newCustomContent = await this.promptCustomContentSource();
if (newCustomContent.hasCustomContent && newCustomContent.selected) {
result.selectedCustomModules.push(...newCustomContent.selectedModuleIds);
result.customContentConfig = newCustomContent;
}
break;
}
case 'remove': {
// Remove all custom modules
console.log(chalk.yellow('All custom modules will be removed from the installation'));
break;
}
}
return result;
}
}
module.exports = { UI };