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 |
|
||||
| `--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 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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._-]*$`)。省略时保持当前的平面输出结构。
|
||||
|
||||
### 其他选项
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue