Compare commits

...

5 Commits

Author SHA1 Message Date
Mario Semper 3feaff0dfb
Merge 4d48b0dbe1 into cf50f4935d 2025-12-08 13:36:30 -08: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
Brian Madison 55cb4681bc party mode and brainstorming had bmm config instead of core config listed causing loading error when bmm is not an installed module - fixed. 2025-12-08 08:11:39 -06:00
Brian 4d48b0dbe1
Merge branch 'main' into feature/ring-of-fire-sessions 2025-11-26 09:08:27 -06:00
Mario Semper 10dc25f43d feat: Ring of Fire (ROF) Sessions - Multi-agent parallel collaboration
Introduces Ring of Fire Sessions feature for BMad Method, enabling
multi-agent collaborative sessions that run in parallel to user workflow.

Key features:
- User-controlled scope (2 agents/5min to 10 agents/2hrs)
- Approval-gated tool access for safety
- Flexible reporting (brief/detailed/live)
- Parallel workflow support

Origin: tellingCube project (masemIT e.U.)
Real-world validated with successful multi-agent planning sessions.

Command: *rof "<topic>" --agents <list> [--report mode]
2025-11-23 02:22:21 +01:00
7 changed files with 324 additions and 60 deletions

View File

@ -0,0 +1,256 @@
# BMad Method PR #1: Ring of Fire (ROF) Sessions
**Feature Type**: Core workflow enhancement
**Status**: Draft for community review
**Origin**: tellingCube project (masemIT e.U.)
**Author**: Mario Semper (@sempre)
**Date**: 2025-11-23
---
## Summary
**Ring of Fire (ROF) Sessions** enable multi-agent collaborative sessions that run in parallel to the user's main workflow, allowing users to delegate complex multi-perspective analysis while continuing other work.
---
## Problem Statement
Current BMad Method requires **sequential agent interaction**. When users need multiple agents to collaborate on a complex topic, they must:
- Manually orchestrate each agent conversation
- Stay in the loop for every exchange
- Wait for sequential responses before proceeding
- Context-switch constantly between tasks
This creates **bottlenecks** and prevents **parallel work streams**.
---
## Proposed Solution: Ring of Fire Sessions
A new command pattern that enables **scoped multi-agent collaboration sessions** that run while the user continues other work.
### Command Syntax
```bash
*rof "<topic>" --agents <agent-list> [--report brief|detailed|live]
```
### Example Usage
```bash
*rof "API Refactoring Strategy" --agents dev,architect,qa --report brief
```
**What happens**:
1. Dev, Architect, and QA agents enter a collaborative session
2. They analyze the topic together (code review, design discussion, testing concerns)
3. When agents need tool access (read files, run commands), they request user approval
4. User continues working on other tasks in parallel
5. Session ends with consolidated report (brief: just recommendations, detailed: full transcript)
---
## Key Features
### 1. User-Controlled Scope
- **Small**: 2 agents, 5-minute quick discussion
- **Large**: 10 agents, 2-hour deep analysis
- User decides granularity based on complexity
### 2. Approval-Gated Tool Access
- Agents can **discuss** freely within the session
- When agents need **tools** (read files, execute commands, make changes), they:
- Pause the session
- Request user approval
- Resume after user decision
**Why**: Maintains user control, prevents runaway agent actions
### 3. Flexible Reporting
| Mode | Description | Use Case |
|------|-------------|----------|
| `brief` | Final recommendations only | "Just tell me what to do" |
| `detailed` | Full transcript + recommendations | "Show me the reasoning" |
| `live` | Real-time updates as agents discuss | "I want to observe" |
**Default**: `brief` with Q&A available
### 4. Parallel Workflows
- User works on **Task A** while ROF session tackles **Task B**
- No context-switching overhead
- Efficient use of time
---
## Use Cases
### 1. Architecture Reviews
```bash
*rof "Evaluate microservices vs monolith for new feature" --agents architect,dev,qa
```
**Agents collaborate on**: Design trade-offs, implementation complexity, testing implications
### 2. Code Refactoring
```bash
*rof "Refactor authentication module" --agents dev,architect --report detailed
```
**Agents collaborate on**: Current code analysis, refactoring approach, migration strategy
### 3. Feature Planning
```bash
*rof "Plan user notifications feature" --agents pm,ux,dev --report brief
```
**Agents collaborate on**: Requirements, UX flow, technical feasibility, timeline
### 4. Quality Gates
```bash
*rof "Investigate test failures in CI/CD" --agents qa,dev --report live
```
**Agents collaborate on**: Root cause analysis, fix recommendations, regression prevention
### 5. Documentation Sprints
```bash
*rof "Document API endpoints" --agents dev,pm,ux
```
**Agents collaborate on**: Technical accuracy, user-friendly examples, completeness
---
## User Experience Flow
```mermaid
sequenceDiagram
User->>River: *rof "Topic" --agents dev,architect
River->>Dev: Join ROF session
River->>Architect: Join ROF session
River->>User: Session started, continue your work
Dev->>Architect: Discuss approach
Architect->>Dev: Suggest alternatives
Dev->>User: Need to read auth.ts - approve?
User->>Dev: Approved
Dev->>Architect: After reading file...
Architect->>Dev: Recommendation
Dev->>River: Session complete
River->>User: Brief report: [Recommendations]
```
---
## Implementation Considerations
### Technical Requirements
- **Session state management**: Track active ROF sessions, participating agents
- **Agent context sharing**: Agents share knowledge within session scope
- **User approval workflow**: Clear prompt for tool requests
- **Report generation**: Brief/detailed/live output formatting
- **Workflow integration**: Link ROF findings to existing workflow plans/todos
### Open Questions for Community
1. **Integration**: Core BMad feature or plugin/extension?
2. **Concurrency**: How to handle file conflicts if multiple agents want to edit?
3. **Cost Model**: Guidance for LLM call budgeting with multiple agents?
4. **Session Limits**: Recommended max agents/duration?
5. **Agent Communication**: Free-form discussion or structured turn-taking?
---
## Real-World Validation
**Origin Project**: tellingCube (BI dashboard, masemIT e.U.)
**Validation Scenario**:
- **Topic**: "Next steps for tellingCube after validation test"
- **Agents**: River (orchestrator), Mary (analyst), Winston (architect)
- **Report Mode**: Brief
- **Outcome**: Successfully analyzed post-validation roadmap with 3 scenarios (GO/CHANGE/NO-GO), delivered consolidated recommendations in 5 minutes
**User Feedback (Mario Semper)**:
> "This is exactly what I needed - I wanted multiple perspectives without having to orchestrate every conversation. The brief report gave me actionable next steps immediately."
**Documentation**: `docs/_masemIT/readme.md` in tellingCube repository
---
## Proposed Documentation Structure
```
.bmad-core/
features/
ring-of-fire.md # Feature specification
docs/
guides/
using-rof-sessions.md # User guide with examples
architecture/
agent-collaboration.md # Technical design
rof-session-management.md # State handling approach
```
---
## Benefits
**Unlocks parallel workflows** - User productivity gains
**Reduces context-switching** - Cognitive load reduction
**Enables complex analysis** - Multi-perspective insights
**Maintains user control** - Approval gates for tools
**Scales flexibly** - From quick checks to deep dives
---
## Comparison to Existing Patterns
| Feature | Standard Agent Use | ROF Session |
|---------|-------------------|-------------|
| Agent collaboration | Sequential (one at a time) | Parallel (multiple simultaneously) |
| User involvement | Required for every exchange | Only for approvals |
| Parallel work | No (user waits) | Yes (user continues tasks) |
| Output | Chat transcript | Consolidated report |
| Use case | Single-perspective tasks | Multi-perspective analysis |
---
## Next Steps
1. **Community feedback** on approach and open questions
2. **Technical design** refinement (state management, agent communication)
3. **Prototype implementation** in BMad core or as extension
4. **Beta testing** with real projects (beyond tellingCube)
5. **Documentation** completion with examples
---
## Alternatives Considered
### Alt 1: "Breakout Session"
- **Pros**: Clear meeting metaphor
- **Cons**: Less evocative, doesn't convey "continuous collaborative space"
### Alt 2: "Agent Huddle"
- **Pros**: Short, casual
- **Cons**: Implies quick/informal only
### Alt 3: "Lagerfeuer" (original German name)
- **Pros**: Warm, campfire metaphor
- **Cons**: Poor i18n, hard to pronounce/remember for non-German speakers
**Chosen**: **Ring of Fire** - evokes continuous collaboration circle, internationally understood, memorable, shortcut "ROF" works well
---
## References
- **Source Project**: tellingCube (https://github.com/masemIT/telling-cube) [if public]
- **Documentation**: `docs/_masemIT/readme.md`
- **Discussion**: [Link to BMad community discussion if applicable]
---
**Contribution ready for review.** Feedback welcome! 🔥

View File

@ -28,7 +28,7 @@ This uses **micro-file architecture** for disciplined execution:
### Configuration Loading ### Configuration Loading
Load config from `{project-root}/{bmad_folder}/bmm/config.yaml` and resolve: Load config from `{project-root}/{bmad_folder}/core/config.yaml` and resolve:
- `project_name`, `output_folder`, `user_name` - `project_name`, `output_folder`, `user_name`
- `communication_language`, `document_output_language`, `user_skill_level` - `communication_language`, `document_output_language`, `user_skill_level`

View File

@ -27,7 +27,7 @@ This uses **micro-file architecture** with **sequential conversation orchestrati
### Configuration Loading ### Configuration Loading
Load config from `{project-root}/{bmad_folder}/bmm/config.yaml` and resolve: Load config from `{project-root}/{bmad_folder}/core/config.yaml` and resolve:
- `project_name`, `output_folder`, `user_name` - `project_name`, `output_folder`, `user_name`
- `communication_language`, `document_output_language`, `user_skill_level` - `communication_language`, `document_output_language`, `user_skill_level`

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
for (const customFile of finalCustomContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
for (const customFile of finalCustomContent.selectedFiles) {
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,6 +972,8 @@ 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);
const movedItems = [];
try {
for (const item of items) { for (const item of items) {
const srcPath = path.join(customDir, item); const srcPath = path.join(customDir, item);
const destPath = path.join(moduleTargetPath, item); const destPath = path.join(moduleTargetPath, item);
@ -982,9 +984,27 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
await fs.move(srcPath, destPath); await fs.move(srcPath, destPath);
movedItems.push({ src: srcPath, dest: destPath });
}
} catch (moveError) {
// 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}`);
}
}
try {
await fs.remove(tempCustomPath); 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
for (const customFile of config.customContent.selectedFiles) {
const { CustomHandler } = require('../custom/handler');
const customHandler = new CustomHandler(); const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) {
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

@ -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})`)}`,