feat: add scope variable for isolated planning/implementation artifacts (#2148)

When scope is set, planning_artifacts and implementation_artifacts resolve
to {output_folder}/{scope}/planning-artifacts/ and
{output_folder}/{scope}/implementation-artifacts/. The output_folder root
stays shared for project-context and brainstorming. Unscoped root folders
remain as the default bucket for quick-dev and unscoped work.

Empty or missing scope preserves current behavior (backward compatible).

Design validated by @alexeyv via Discord.
Fixes #2148
This commit is contained in:
Gani Mohamed Parakadhullah 2026-03-30 11:23:32 +08:00
parent 2302d9cdc5
commit eac137afd9
No known key found for this signature in database
GPG Key ID: C80851BD80DBA364
8 changed files with 231 additions and 15 deletions

View File

@ -38,6 +38,7 @@ Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm).
| `--communication-language <langue>` | Langue de communication des agents | Anglais | | `--communication-language <langue>` | Langue de communication des agents | Anglais |
| `--document-output-language <langue>` | Langue de sortie des documents | Anglais | | `--document-output-language <langue>` | Langue de sortie des documents | Anglais |
| `--output-folder <chemin>` | Chemin du dossier de sortie (voir les règles de résolution ci-dessous) | `_bmad-output` | | `--output-folder <chemin>` | Chemin du dossier de sortie (voir les règles de résolution ci-dessous) | `_bmad-output` |
| `--scope <nom>` | Nom de portée pour isoler les artefacts de planification/implémentation (voir ci-dessous) | _(vide - pas de portée)_ |
#### Résolution du chemin du dossier de sortie #### Résolution du chemin du dossier de sortie
@ -46,10 +47,24 @@ La valeur passée à `--output-folder` (ou saisie de manière interactive) est r
| Type d'entrée | Exemple | Résolu comme | | Type d'entrée | Exemple | Résolu comme |
|-------------------------------|----------------------------|--------------------------------------------------------------| |-------------------------------|----------------------------|--------------------------------------------------------------|
| Chemin relatif (par défaut) | `_bmad-output` | `<racine-du-projet>/_bmad-output` | | Chemin relatif (par défaut) | `_bmad-output` | `<racine-du-projet>/_bmad-output` |
| Chemin relatif avec traversée | `../../shared-outputs` | Chemin absolu normalisé ex. `/Users/me/shared-outputs` | | Chemin relatif avec traversée | `../../shared-outputs` | Chemin absolu normalisé - ex. `/Users/me/shared-outputs` |
| Chemin absolu | `/Users/me/shared-outputs` | Utilisé tel quel la racine du projet n'est **pas** ajoutée | | Chemin absolu | `/Users/me/shared-outputs` | Utilisé tel quel - la racine du projet n'est **pas** ajoutée |
Le chemin résolu est ce que les agents et les workflows vont utiliser lors de l'écriture des fichiers de sortie. L'utilisation d'un chemin absolu ou d'un chemin relatif avec traversée vous permet de diriger tous les artefacts générés vers un répertoire en dehors de l'arborescence de votre projet — utile pour les configurations partagées ou les monorepos. Le chemin résolu est ce que les agents et les workflows vont utiliser lors de l'écriture des fichiers de sortie. L'utilisation d'un chemin absolu ou d'un chemin relatif avec traversée vous permet de diriger tous les artefacts générés vers un répertoire en dehors de l'arborescence de votre projet - utile pour les configurations partagées ou les monorepos.
#### Portée
Lorsque `--scope` est fourni, `planning_artifacts` et `implementation_artifacts` sont placés dans un sous-répertoire. Le dossier de sortie racine reste partagé pour le contexte projet et le brainstorming.
```
# Sans --scope :
_bmad-output/planning-artifacts/prd.md
# Avec --scope admin-portal :
_bmad-output/admin-portal/planning-artifacts/prd.md
```
Le nom ne peut contenir que des lettres, chiffres, tirets, points ou underscores (`.` et `..` ne sont pas autorisés). L'omettre conserve la structure actuelle.
### Autres options ### Autres options
@ -60,16 +75,16 @@ Le chemin résolu est ce que les agents et les workflows vont utiliser lors de l
## IDs de modules ## IDs de modules
IDs de modules disponibles pour loption `--modules` : IDs de modules disponibles pour l'option `--modules` :
- `bmm` méthode BMad Master - `bmm` - méthode BMad Master
- `bmb` BMad Builder - `bmb` - BMad Builder
Consultez le [registre BMad](https://github.com/bmad-code-org) pour les modules externes disponibles. Consultez le [registre BMad](https://github.com/bmad-code-org) pour les modules externes disponibles.
## IDs d'outils/IDE ## IDs d'outils/IDE
IDs d'outils disponibles pour loption `--tools` : IDs d'outils disponibles pour l'option `--tools` :
**Recommandés :** `claude-code`, `cursor` **Recommandés :** `claude-code`, `cursor`
@ -140,15 +155,15 @@ npx bmad-method install \
BMad valide toutes les options fournis : BMad valide toutes les options fournis :
- **Directory** Doit être un chemin valide avec des permissions d'écriture - **Directory** - Doit être un chemin valide avec des permissions d'écriture
- **Modules** Avertit des IDs de modules invalides (mais n'échoue pas) - **Modules** - Avertit des IDs de modules invalides (mais n'échoue pas)
- **Tools** Avertit des IDs d'outils invalides (mais n'échoue pas) - **Tools** - Avertit des IDs d'outils invalides (mais n'échoue pas)
- **Custom Content** Chaque chemin doit contenir un fichier `module.yaml` valide - **Custom Content** - Chaque chemin doit contenir un fichier `module.yaml` valide
- **Action** Doit être l'une des suivantes : `install`, `update`, `quick-update` - **Action** - Doit être l'une des suivantes : `install`, `update`, `quick-update`
Les valeurs invalides entraîneront soit : Les valeurs invalides entraîneront soit :
1. Laffichage dun message d'erreur suivi dun exit (pour les options critiques comme le répertoire) 1. L'affichage d'un message d'erreur suivi d'un exit (pour les options critiques comme le répertoire)
2. Un avertissement puis la continuation de linstallation (pour les éléments optionnels comme le contenu personnalisé) 2. Un avertissement puis la continuation de l'installation (pour les éléments optionnels comme le contenu personnalisé)
3. Un retour aux invites interactives (pour les valeurs requises manquantes) 3. Un retour aux invites interactives (pour les valeurs requises manquantes)
:::tip[Bonnes pratiques] :::tip[Bonnes pratiques]

View File

@ -38,6 +38,7 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
| `--communication-language <lang>` | Agent communication language | English | | `--communication-language <lang>` | Agent communication language | English |
| `--document-output-language <lang>` | Document output language | English | | `--document-output-language <lang>` | Document output language | English |
| `--output-folder <path>` | Output folder path (see resolution rules below) | `_bmad-output` | | `--output-folder <path>` | Output folder path (see resolution rules below) | `_bmad-output` |
| `--scope <name>` | Scope name to isolate planning/implementation artifacts (see below) | _(empty — no scope)_ |
#### Output Folder Path Resolution #### Output Folder Path Resolution
@ -51,6 +52,20 @@ The value passed to `--output-folder` (or entered interactively) is resolved acc
The resolved path is what agents and workflows use at runtime when writing output files. Using an absolute path or a traversal-based relative path lets you direct all generated artifacts to a directory outside your project tree — useful for shared or monorepo setups. The resolved path is what agents and workflows use at runtime when writing output files. Using an absolute path or a traversal-based relative path lets you direct all generated artifacts to a directory outside your project tree — useful for shared or monorepo setups.
#### Scope
When `--scope` is provided, `planning_artifacts` and `implementation_artifacts` are placed under a scoped subdirectory. The output folder root stays shared for project-context and brainstorming.
```
# Without --scope:
_bmad-output/planning-artifacts/prd.md
# With --scope admin-portal:
_bmad-output/admin-portal/planning-artifacts/prd.md
```
The name must contain only letters, numbers, hyphens, dots, or underscores (`.` and `..` are not allowed). Omitting it preserves the current flat structure.
### Other Options ### Other Options
| Flag | Description | | Flag | Description |

View File

@ -38,6 +38,21 @@ sidebar:
| `--communication-language <lang>` | 智能体通信语言 | 英语 | | `--communication-language <lang>` | 智能体通信语言 | 英语 |
| `--document-output-language <lang>` | 文档输出语言 | 英语 | | `--document-output-language <lang>` | 文档输出语言 | 英语 |
| `--output-folder <path>` | 输出文件夹路径 | _bmad-output | | `--output-folder <path>` | 输出文件夹路径 | _bmad-output |
| `--scope <name>` | 范围名称,用于隔离规划/实施产物(见下文) | _空 — 无范围_ |
#### 范围
提供 `--scope` 时,`planning_artifacts` 和 `implementation_artifacts` 会放在范围子目录下。输出文件夹根目录保持共享。
```
# 不带 --scope
_bmad-output/planning-artifacts/prd.md
# 带 --scope admin-portal
_bmad-output/admin-portal/planning-artifacts/prd.md
```
名称只能包含字母、数字、连字符、点或下划线(不允许 `.``..`)。省略时保持当前的平面结构。
### 其他选项 ### 其他选项

View File

@ -23,3 +23,9 @@ output_folder:
prompt: "Where should output files be saved?" prompt: "Where should output files be saved?"
default: "_bmad-output" default: "_bmad-output"
result: "{project-root}/{value}" result: "{project-root}/{value}"
scope:
prompt: "Scope name to keep outputs separate (e.g., admin-portal). Enter to skip:"
default: ""
regex: "^$|^[a-zA-Z0-9._-]*[a-zA-Z0-9][a-zA-Z0-9._-]*$"
result: "{value}"

View File

@ -1713,6 +1713,128 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 33: scope isolates planning/implementation artifacts
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Scoped Output Isolation${colors.reset}\n`);
let tempBmadDir33;
try {
const { Installer } = require('../tools/installer/core/installer');
const testInstaller = new Installer();
const yaml = require('yaml');
tempBmadDir33 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-scope-'));
const coreDir33 = path.join(tempBmadDir33, 'core');
const bmmDir33 = path.join(tempBmadDir33, 'bmm');
await fs.ensureDir(coreDir33);
await fs.ensureDir(bmmDir33);
// --- Scoped install ---
await testInstaller.generateModuleConfigs(tempBmadDir33, {
core: {
user_name: 'Test',
output_folder: '{project-root}/_bmad-output',
scope: 'admin-portal',
},
bmm: {
project_name: 'TestProject',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
},
});
const core33 = yaml.parse(await fs.readFile(path.join(coreDir33, 'config.yaml'), 'utf8'));
const bmm33 = yaml.parse(await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8'));
assert(core33.output_folder === '{project-root}/_bmad-output', 'Core output_folder is NOT scoped');
assert(core33.scope === 'admin-portal', 'Core stores scope separately');
assert(bmm33.planning_artifacts === '{project-root}/_bmad-output/admin-portal/planning-artifacts', 'BMM planning_artifacts is scoped');
assert(
bmm33.implementation_artifacts === '{project-root}/_bmad-output/admin-portal/implementation-artifacts',
'BMM implementation_artifacts is scoped',
);
assert(bmm33.output_folder === '{project-root}/_bmad-output', 'BMM output_folder stays as shared root');
assert(bmm33.scope === 'admin-portal', 'BMM inherits scope from core spread');
// --- Empty scope (backward compat) ---
await testInstaller.generateModuleConfigs(tempBmadDir33, {
core: { user_name: 'Test', output_folder: '{project-root}/_bmad-output', scope: '' },
bmm: {
project_name: 'TestProject',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
},
});
const bmmEmpty = yaml.parse(await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8'));
assert(
bmmEmpty.planning_artifacts === '{project-root}/_bmad-output/planning-artifacts',
'Empty scope preserves original planning_artifacts',
);
// --- Missing scope key (backward compat) ---
await testInstaller.generateModuleConfigs(tempBmadDir33, {
core: { user_name: 'Test', output_folder: '{project-root}/_bmad-output' },
bmm: {
project_name: 'TestProject',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
},
});
const bmmMissing = yaml.parse(await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8'));
assert(
bmmMissing.planning_artifacts === '{project-root}/_bmad-output/planning-artifacts',
'Missing scope key preserves original planning_artifacts',
);
// --- Scoped with raw output_folder (no {project-root} prefix, matches --yes path) ---
await testInstaller.generateModuleConfigs(tempBmadDir33, {
core: { user_name: 'Test', output_folder: '_bmad-output', scope: 'phase-2' },
bmm: {
project_name: 'TestProject',
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
},
});
const bmmRaw = yaml.parse(await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8'));
assert(
bmmRaw.planning_artifacts === '{project-root}/_bmad-output/phase-2/planning-artifacts',
'Raw output_folder (--yes path) still scopes correctly',
);
// --- Dot-segment scope rejected ---
let dotSegmentThrew = false;
try {
await testInstaller.generateModuleConfigs(tempBmadDir33, {
core: { user_name: 'Test', output_folder: '{project-root}/_bmad-output', scope: '..' },
bmm: { project_name: 'TestProject', planning_artifacts: '{project-root}/_bmad-output/planning-artifacts' },
});
} catch (error) {
dotSegmentThrew = error.message.includes('dot-segments');
}
assert(dotSegmentThrew, 'Dot-segment scope ".." is rejected');
// --- Custom path not under output_folder (scope bypassed) ---
await testInstaller.generateModuleConfigs(tempBmadDir33, {
core: { user_name: 'Test', output_folder: '{project-root}/_bmad-output', scope: 'test' },
bmm: {
project_name: 'TestProject',
planning_artifacts: '{project-root}/custom-folder/planning',
},
});
const bmmCustom = yaml.parse(await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8'));
assert(bmmCustom.planning_artifacts === '{project-root}/custom-folder/planning', 'Custom path not under output_folder bypasses scope');
} catch (error) {
assert(false, `Scoped output test error: ${error.message}`);
} finally {
if (tempBmadDir33) await fs.remove(tempBmadDir33).catch(() => {});
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -23,6 +23,7 @@ module.exports = {
['--communication-language <lang>', 'Language for agent communication (default: English)'], ['--communication-language <lang>', 'Language for agent communication (default: English)'],
['--document-output-language <lang>', 'Language for document output (default: English)'], ['--document-output-language <lang>', 'Language for document output (default: English)'],
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'], ['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
['--scope <name>', 'Scope name to isolate planning/implementation artifacts (e.g., admin-portal)'],
['-y, --yes', 'Accept all defaults and skip prompts where possible'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'],
], ],
action: async (options) => { action: async (options) => {

View File

@ -823,6 +823,20 @@ class Installer {
// Create a comment section to identify core values // Create a comment section to identify core values
coreSection = '\n# Core Configuration Values\n'; coreSection = '\n# Core Configuration Values\n';
// When scope is set, insert it after output_folder in artifact paths
if (finalConfig.scope && finalConfig.output_folder) {
if (finalConfig.scope === '.' || finalConfig.scope === '..') {
throw new Error(`Invalid scope "${finalConfig.scope}": dot-segments are not allowed`);
}
const folder = finalConfig.output_folder.replace(/[\\/]+$/, '');
for (const key of ['planning_artifacts', 'implementation_artifacts']) {
// Match only at a directory boundary (followed by / or end of string)
if (finalConfig[key]?.includes(`${folder}/`)) {
finalConfig[key] = finalConfig[key].replace(`${folder}/`, `${folder}/${finalConfig.scope}/`);
}
}
}
} }
// Clean the config to remove any non-serializable values (like functions) // Clean the config to remove any non-serializable values (like functions)

View File

@ -713,7 +713,7 @@ class UI {
const configCollector = new OfficialModules(); const configCollector = new OfficialModules();
// Seed core config from CLI options if provided // Seed core config from CLI options if provided
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) { if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder || options.scope) {
const coreConfig = {}; const coreConfig = {};
if (options.userName) { if (options.userName) {
coreConfig.user_name = options.userName; coreConfig.user_name = options.userName;
@ -731,10 +731,36 @@ class UI {
coreConfig.output_folder = options.outputFolder; coreConfig.output_folder = options.outputFolder;
await prompts.log.info(`Using output folder from command-line: ${options.outputFolder}`); await prompts.log.info(`Using output folder from command-line: ${options.outputFolder}`);
} }
if (options.scope) {
if (!/^[a-zA-Z0-9._-]+$/.test(options.scope) || options.scope === '.' || options.scope === '..') {
await prompts.log.error(
`Invalid scope "${options.scope}": use only letters, numbers, hyphens, dots, or underscores (not "." or "..")`,
);
process.exit(1);
}
coreConfig.scope = options.scope;
await prompts.log.info(`Using scope from command-line: ${options.scope}`);
}
// Load existing config to merge with provided options // Load existing config to merge with provided options
await configCollector.loadExistingConfig(directory); await configCollector.loadExistingConfig(directory);
const existingConfig = configCollector.collectedConfig.core || {}; const existingConfig = configCollector.collectedConfig.core || {};
// When --yes, fill in defaults for core values not provided via CLI
if (options.yes) {
if (!existingConfig.user_name) {
let safeUsername;
try {
safeUsername = os.userInfo().username;
} catch {
safeUsername = process.env.USER || process.env.USERNAME || 'User';
}
existingConfig.user_name = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1);
}
existingConfig.communication_language = existingConfig.communication_language || 'English';
existingConfig.document_output_language = existingConfig.document_output_language || 'English';
existingConfig.output_folder = existingConfig.output_folder || '_bmad-output';
existingConfig.scope = existingConfig.scope || '';
}
configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig }; configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig };
// If not all options are provided, collect the missing ones interactively (unless --yes flag) // If not all options are provided, collect the missing ones interactively (unless --yes flag)
@ -743,6 +769,7 @@ class UI {
(!options.userName || !options.communicationLanguage || !options.documentOutputLanguage || !options.outputFolder) (!options.userName || !options.communicationLanguage || !options.documentOutputLanguage || !options.outputFolder)
) { ) {
await configCollector.collectModuleConfig('core', directory, false, true); await configCollector.collectModuleConfig('core', directory, false, true);
Object.assign(configCollector.collectedConfig.core, coreConfig);
} }
} else if (options.yes) { } else if (options.yes) {
// Use all defaults when --yes flag is set // Use all defaults when --yes flag is set
@ -762,6 +789,7 @@ class UI {
communication_language: 'English', communication_language: 'English',
document_output_language: 'English', document_output_language: 'English',
output_folder: '_bmad-output', output_folder: '_bmad-output',
scope: '',
}; };
await prompts.log.info('Using default configuration (--yes flag)'); await prompts.log.info('Using default configuration (--yes flag)');
} }