feat: add ticket_id variable for namespaced output (#2148)
This commit is contained in:
parent
fa909a8916
commit
c5f1bcf2a6
|
|
@ -38,6 +38,21 @@ 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 | _bmad-output |
|
| `--output-folder <chemin>` | Chemin du dossier de sortie | _bmad-output |
|
||||||
|
| `--ticket-id <id>` | Espace de noms de sortie par ticket ou fonctionnalité (voir ci-dessous) | _(vide — pas d’espace de noms)_ |
|
||||||
|
|
||||||
|
#### Espace de noms par ticket
|
||||||
|
|
||||||
|
Lorsque `--ticket-id` est fourni, l’installateur insère l’ID comme sous-répertoire sous `output_folder`, isolant tous les artefacts par ticket ou fonctionnalité :
|
||||||
|
|
||||||
|
```
|
||||||
|
# Sans --ticket-id (par défaut) :
|
||||||
|
_bmad-output/planning-artifacts/prd.md
|
||||||
|
|
||||||
|
# Avec --ticket-id RZP-593 :
|
||||||
|
_bmad-output/RZP-593/planning-artifacts/prd.md
|
||||||
|
```
|
||||||
|
|
||||||
|
L’ID ne peut contenir que des lettres, chiffres, tirets, points ou underscores (regex : `^[a-zA-Z0-9._-]*$`). L’omettre conserve la structure de sortie actuelle.
|
||||||
|
|
||||||
### Autres options
|
### Autres options
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
| `--ticket-id <id>` | Namespace output by ticket or feature ID (see below) | _(empty — no namespace)_ |
|
||||||
|
|
||||||
#### Output Folder Path Resolution
|
#### Output Folder Path Resolution
|
||||||
|
|
||||||
|
|
@ -51,6 +52,22 @@ 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.
|
||||||
|
|
||||||
|
#### Ticket ID Namespacing
|
||||||
|
|
||||||
|
When `--ticket-id` is provided, the installer inserts the ID as a subdirectory under `output_folder`, isolating all artifacts per ticket or feature:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Without --ticket-id (default):
|
||||||
|
_bmad-output/planning-artifacts/prd.md
|
||||||
|
|
||||||
|
# With --ticket-id RZP-593:
|
||||||
|
_bmad-output/RZP-593/planning-artifacts/prd.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The ID must contain only letters, numbers, hyphens, dots, or underscores (regex: `^[a-zA-Z0-9._-]*$`). Omitting it preserves the current flat output structure.
|
||||||
|
|
||||||
|
Core's `config.yaml` stores `ticket_id` as a separate field and keeps `output_folder` unchanged; non-core module configs (e.g., bmm) receive the composed path so workflows resolve namespaced paths automatically.
|
||||||
|
|
||||||
### Other Options
|
### Other Options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
| `--ticket-id <id>` | 按工单或功能 ID 命名空间输出(见下文) | _(空 — 无命名空间)_ |
|
||||||
|
|
||||||
|
#### 工单 ID 命名空间
|
||||||
|
|
||||||
|
提供 `--ticket-id` 时,安装程序会在 `output_folder` 下插入该 ID 作为子目录,按工单或功能隔离所有产物:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 不带 --ticket-id(默认):
|
||||||
|
_bmad-output/planning-artifacts/prd.md
|
||||||
|
|
||||||
|
# 带 --ticket-id RZP-593:
|
||||||
|
_bmad-output/RZP-593/planning-artifacts/prd.md
|
||||||
|
```
|
||||||
|
|
||||||
|
ID 只能包含字母、数字、连字符、点或下划线(正则:`^[a-zA-Z0-9._-]*$`)。省略时保持当前的平面输出结构。
|
||||||
|
|
||||||
### 其他选项
|
### 其他选项
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
ticket_id:
|
||||||
|
prompt: "Ticket or feature ID (e.g., PROJ-123) — isolates artifacts per ticket (Enter to skip):"
|
||||||
|
default: ""
|
||||||
|
regex: "^[a-zA-Z0-9._-]*$"
|
||||||
|
result: "{value}"
|
||||||
|
|
|
||||||
|
|
@ -1816,6 +1816,124 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 33: ticket_id namespaced output_folder
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 33: ticket_id Namespaced Output${colors.reset}\n`);
|
||||||
|
|
||||||
|
let tempBmadDir33;
|
||||||
|
try {
|
||||||
|
const { Installer } = require('../tools/installer/core/installer');
|
||||||
|
const testInstaller = new Installer();
|
||||||
|
|
||||||
|
// Create a temp directory structure to simulate bmadDir with core + bmm modules
|
||||||
|
tempBmadDir33 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ticket-'));
|
||||||
|
const coreDir33 = path.join(tempBmadDir33, 'core');
|
||||||
|
const bmmDir33 = path.join(tempBmadDir33, 'bmm');
|
||||||
|
await fs.ensureDir(coreDir33);
|
||||||
|
await fs.ensureDir(bmmDir33);
|
||||||
|
|
||||||
|
// Test: ticket_id namespaces output_folder in non-core modules
|
||||||
|
const moduleConfigsWithTicket = {
|
||||||
|
core: {
|
||||||
|
user_name: 'Test',
|
||||||
|
communication_language: 'English',
|
||||||
|
document_output_language: 'English',
|
||||||
|
output_folder: '{project-root}/_bmad-output',
|
||||||
|
ticket_id: 'RZP-593',
|
||||||
|
},
|
||||||
|
bmm: {
|
||||||
|
project_name: 'TestProject',
|
||||||
|
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInstaller.generateModuleConfigs(tempBmadDir33, moduleConfigsWithTicket);
|
||||||
|
|
||||||
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
// Verify core config stores ticket_id separately (output_folder NOT namespaced)
|
||||||
|
const coreConfigContent = await fs.readFile(path.join(coreDir33, 'config.yaml'), 'utf8');
|
||||||
|
const coreConfigParsed = yaml.parse(coreConfigContent);
|
||||||
|
assert(
|
||||||
|
coreConfigParsed.output_folder === '{project-root}/_bmad-output',
|
||||||
|
'Core config output_folder is NOT namespaced',
|
||||||
|
`Expected "{project-root}/_bmad-output", got "${coreConfigParsed.output_folder}"`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
coreConfigParsed.ticket_id === 'RZP-593',
|
||||||
|
'Core config stores ticket_id as separate field',
|
||||||
|
`Expected "RZP-593", got "${coreConfigParsed.ticket_id}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify bmm config gets namespaced output_folder
|
||||||
|
const bmmConfigContent = await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8');
|
||||||
|
const bmmConfigParsed = yaml.parse(bmmConfigContent);
|
||||||
|
assert(
|
||||||
|
bmmConfigParsed.output_folder === '{project-root}/_bmad-output/RZP-593',
|
||||||
|
'BMM config output_folder IS namespaced',
|
||||||
|
`Expected "{project-root}/_bmad-output/RZP-593", got "${bmmConfigParsed.output_folder}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify ticket_id is inherited by bmm config via core config spread
|
||||||
|
assert(
|
||||||
|
bmmConfigParsed.ticket_id === 'RZP-593',
|
||||||
|
'BMM config inherits ticket_id from core spread',
|
||||||
|
`Expected "RZP-593", got "${bmmConfigParsed.ticket_id}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: empty ticket_id preserves original output_folder
|
||||||
|
const moduleConfigsNoTicket = {
|
||||||
|
core: {
|
||||||
|
user_name: 'Test',
|
||||||
|
communication_language: 'English',
|
||||||
|
document_output_language: 'English',
|
||||||
|
output_folder: '{project-root}/_bmad-output',
|
||||||
|
ticket_id: '',
|
||||||
|
},
|
||||||
|
bmm: {
|
||||||
|
project_name: 'TestProject',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInstaller.generateModuleConfigs(tempBmadDir33, moduleConfigsNoTicket);
|
||||||
|
|
||||||
|
const bmmConfigNoTicket = yaml.parse(await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8'));
|
||||||
|
assert(
|
||||||
|
bmmConfigNoTicket.output_folder === '{project-root}/_bmad-output',
|
||||||
|
'Empty ticket_id preserves original output_folder',
|
||||||
|
`Expected "{project-root}/_bmad-output", got "${bmmConfigNoTicket.output_folder}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: undefined ticket_id (missing from config) preserves original output_folder
|
||||||
|
const moduleConfigsUndefinedTicket = {
|
||||||
|
core: {
|
||||||
|
user_name: 'Test',
|
||||||
|
communication_language: 'English',
|
||||||
|
document_output_language: 'English',
|
||||||
|
output_folder: '{project-root}/_bmad-output',
|
||||||
|
},
|
||||||
|
bmm: {
|
||||||
|
project_name: 'TestProject',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInstaller.generateModuleConfigs(tempBmadDir33, moduleConfigsUndefinedTicket);
|
||||||
|
|
||||||
|
const bmmConfigUndefined = yaml.parse(await fs.readFile(path.join(bmmDir33, 'config.yaml'), 'utf8'));
|
||||||
|
assert(
|
||||||
|
bmmConfigUndefined.output_folder === '{project-root}/_bmad-output',
|
||||||
|
'Undefined ticket_id (missing key) preserves original output_folder',
|
||||||
|
`Expected "{project-root}/_bmad-output", got "${bmmConfigUndefined.output_folder}"`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, `ticket_id test suite error: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
if (tempBmadDir33) await fs.remove(tempBmadDir33).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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)'],
|
||||||
|
['--ticket-id <id>', 'Namespace output by ticket/feature ID (e.g., RZP-593)'],
|
||||||
['-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) => {
|
||||||
|
|
|
||||||
|
|
@ -784,6 +784,17 @@ class Installer {
|
||||||
// Extract core config values to share with other modules
|
// Extract core config values to share with other modules
|
||||||
const coreConfig = moduleConfigs.core || {};
|
const coreConfig = moduleConfigs.core || {};
|
||||||
|
|
||||||
|
// Create resolved copy with namespaced output_folder for non-core modules.
|
||||||
|
// Do NOT mutate coreConfig — it's a reference to moduleConfigs.core and is used
|
||||||
|
// to write core's own config.yaml (which must store ticket_id separately).
|
||||||
|
const resolvedCoreConfig = { ...coreConfig };
|
||||||
|
if (resolvedCoreConfig.ticket_id && resolvedCoreConfig.output_folder) {
|
||||||
|
if (resolvedCoreConfig.ticket_id === '.' || resolvedCoreConfig.ticket_id === '..') {
|
||||||
|
throw new Error(`Invalid ticket_id "${resolvedCoreConfig.ticket_id}": dot-segments are not allowed`);
|
||||||
|
}
|
||||||
|
resolvedCoreConfig.output_folder = `${resolvedCoreConfig.output_folder.replace(/[\\/]+$/, '')}/${resolvedCoreConfig.ticket_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all installed module directories
|
// Get all installed module directories
|
||||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||||
const installedModules = entries
|
const installedModules = entries
|
||||||
|
|
@ -816,9 +827,10 @@ class Installer {
|
||||||
if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) {
|
if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) {
|
||||||
// Add core values directly to the module config
|
// Add core values directly to the module config
|
||||||
// These will be available for reference in the module
|
// These will be available for reference in the module
|
||||||
|
// Use resolvedCoreConfig so non-core modules get the namespaced output_folder
|
||||||
finalConfig = {
|
finalConfig = {
|
||||||
...config,
|
...config,
|
||||||
...coreConfig, // Spread core config values directly into the module config
|
...resolvedCoreConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a comment section to identify core values
|
// Create a comment section to identify core values
|
||||||
|
|
@ -842,7 +854,9 @@ class Installer {
|
||||||
const moduleConfigLines = [];
|
const moduleConfigLines = [];
|
||||||
const coreConfigLines = [];
|
const coreConfigLines = [];
|
||||||
|
|
||||||
// Separate module-specific and core config lines
|
// Separate module-specific and core config lines.
|
||||||
|
// Note: uses coreConfig (not resolvedCoreConfig) for key detection — both have
|
||||||
|
// identical keys, only output_folder's value differs. This coupling is intentional.
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const key = line.split(':')[0].trim();
|
const key = line.split(':')[0].trim();
|
||||||
if (Object.prototype.hasOwnProperty.call(coreConfig, key)) {
|
if (Object.prototype.hasOwnProperty.call(coreConfig, key)) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@ const { CustomHandler } = require('./custom-handler');
|
||||||
const { ExternalModuleManager } = require('./modules/external-manager');
|
const { ExternalModuleManager } = require('./modules/external-manager');
|
||||||
const prompts = require('./prompts');
|
const prompts = require('./prompts');
|
||||||
|
|
||||||
|
// Ticket ID validation pattern: alphanumeric, hyphens, dots, underscores only
|
||||||
|
const TICKET_ID_PATTERN = /^[a-zA-Z0-9._-]*$/;
|
||||||
|
|
||||||
|
function validateTicketId(ticketId) {
|
||||||
|
if (ticketId && !TICKET_ID_PATTERN.test(ticketId)) {
|
||||||
|
return `Invalid ticket ID "${ticketId}": use only letters, numbers, hyphens, dots, or underscores (e.g., PROJ-123)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Separator class for visual grouping in select/multiselect prompts
|
// Separator class for visual grouping in select/multiselect prompts
|
||||||
// Note: @clack/prompts doesn't support separators natively, they are filtered out
|
// Note: @clack/prompts doesn't support separators natively, they are filtered out
|
||||||
class Separator {
|
class Separator {
|
||||||
|
|
@ -713,7 +723,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.ticketId) {
|
||||||
const coreConfig = {};
|
const coreConfig = {};
|
||||||
if (options.userName) {
|
if (options.userName) {
|
||||||
coreConfig.user_name = options.userName;
|
coreConfig.user_name = options.userName;
|
||||||
|
|
@ -731,6 +741,15 @@ 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.ticketId) {
|
||||||
|
const error = validateTicketId(options.ticketId);
|
||||||
|
if (error) {
|
||||||
|
await prompts.log.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
coreConfig.ticket_id = options.ticketId;
|
||||||
|
await prompts.log.info(`Using ticket ID from command-line: ${options.ticketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Load existing config to merge with provided options
|
// Load existing config to merge with provided options
|
||||||
await configCollector.loadExistingConfig(directory);
|
await configCollector.loadExistingConfig(directory);
|
||||||
|
|
@ -743,6 +762,8 @@ 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);
|
||||||
|
// Re-apply CLI-provided values after interactive prompts (which may overwrite them with defaults)
|
||||||
|
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 +783,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',
|
||||||
|
ticket_id: '',
|
||||||
};
|
};
|
||||||
await prompts.log.info('Using default configuration (--yes flag)');
|
await prompts.log.info('Using default configuration (--yes flag)');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue