feat: add ticket_id variable for namespaced output (#2148)

This commit is contained in:
Gani Mohamed Parakadhullah 2026-03-29 01:07:14 +08:00
parent fa909a8916
commit c5f1bcf2a6
No known key found for this signature in database
GPG Key ID: C80851BD80DBA364
8 changed files with 211 additions and 3 deletions

View File

@ -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 |
| `--document-output-language <langue>` | Langue de sortie des documents | Anglais |
| `--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 despace de noms)_ |
#### Espace de noms par ticket
Lorsque `--ticket-id` est fourni, linstallateur insère lID 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
```
LID ne peut contenir que des lettres, chiffres, tirets, points ou underscores (regex : `^[a-zA-Z0-9._-]*$`). Lomettre conserve la structure de sortie actuelle.
### Autres options

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 |
| `--document-output-language <lang>` | Document output language | English |
| `--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
@ -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.
#### 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
| Flag | Description |

View File

@ -38,6 +38,21 @@ sidebar:
| `--communication-language <lang>` | 智能体通信语言 | 英语 |
| `--document-output-language <lang>` | 文档输出语言 | 英语 |
| `--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._-]*$`)。省略时保持当前的平面输出结构。
### 其他选项

View File

@ -23,3 +23,9 @@ output_folder:
prompt: "Where should output files be saved?"
default: "_bmad-output"
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}"

View File

@ -1816,6 +1816,124 @@ async function runTests() {
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
// ============================================================

View File

@ -23,6 +23,7 @@ module.exports = {
['--communication-language <lang>', 'Language for agent communication (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)'],
['--ticket-id <id>', 'Namespace output by ticket/feature ID (e.g., RZP-593)'],
['-y, --yes', 'Accept all defaults and skip prompts where possible'],
],
action: async (options) => {

View File

@ -784,6 +784,17 @@ class Installer {
// Extract core config values to share with other modules
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
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const installedModules = entries
@ -816,9 +827,10 @@ class Installer {
if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) {
// Add core values directly to the module config
// These will be available for reference in the module
// Use resolvedCoreConfig so non-core modules get the namespaced output_folder
finalConfig = {
...config,
...coreConfig, // Spread core config values directly into the module config
...resolvedCoreConfig,
};
// Create a comment section to identify core values
@ -842,7 +854,9 @@ class Installer {
const moduleConfigLines = [];
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) {
const key = line.split(':')[0].trim();
if (Object.prototype.hasOwnProperty.call(coreConfig, key)) {

View File

@ -6,6 +6,16 @@ const { CustomHandler } = require('./custom-handler');
const { ExternalModuleManager } = require('./modules/external-manager');
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
// Note: @clack/prompts doesn't support separators natively, they are filtered out
class Separator {
@ -713,7 +723,7 @@ class UI {
const configCollector = new OfficialModules();
// 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 = {};
if (options.userName) {
coreConfig.user_name = options.userName;
@ -731,6 +741,15 @@ class UI {
coreConfig.output_folder = 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
await configCollector.loadExistingConfig(directory);
@ -743,6 +762,8 @@ class UI {
(!options.userName || !options.communicationLanguage || !options.documentOutputLanguage || !options.outputFolder)
) {
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) {
// Use all defaults when --yes flag is set
@ -762,6 +783,7 @@ class UI {
communication_language: 'English',
document_output_language: 'English',
output_folder: '_bmad-output',
ticket_id: '',
};
await prompts.log.info('Using default configuration (--yes flag)');
}