From c5f1bcf2a6f863c3be391d890d8ec9f6ada9d6d9 Mon Sep 17 00:00:00 2001 From: Gani Mohamed Parakadhullah Date: Sun, 29 Mar 2026 01:07:14 +0800 Subject: [PATCH] feat: add ticket_id variable for namespaced output (#2148) --- .../fr/how-to/non-interactive-installation.md | 15 +++ docs/how-to/non-interactive-installation.md | 17 +++ .../how-to/non-interactive-installation.md | 15 +++ src/core-skills/module.yaml | 6 + test/test-installation-components.js | 118 ++++++++++++++++++ tools/installer/commands/install.js | 1 + tools/installer/core/installer.js | 18 ++- tools/installer/ui.js | 24 +++- 8 files changed, 211 insertions(+), 3 deletions(-) diff --git a/docs/fr/how-to/non-interactive-installation.md b/docs/fr/how-to/non-interactive-installation.md index 0fe6588f9..c6ee4f8f4 100644 --- a/docs/fr/how-to/non-interactive-installation.md +++ b/docs/fr/how-to/non-interactive-installation.md @@ -38,6 +38,21 @@ Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm). | `--communication-language ` | Langue de communication des agents | Anglais | | `--document-output-language ` | Langue de sortie des documents | Anglais | | `--output-folder ` | Chemin du dossier de sortie | _bmad-output | +| `--ticket-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 diff --git a/docs/how-to/non-interactive-installation.md b/docs/how-to/non-interactive-installation.md index 64687c0a1..80a56a528 100644 --- a/docs/how-to/non-interactive-installation.md +++ b/docs/how-to/non-interactive-installation.md @@ -38,6 +38,7 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm). | `--communication-language ` | Agent communication language | English | | `--document-output-language ` | Document output language | English | | `--output-folder ` | Output folder path (see resolution rules below) | `_bmad-output` | +| `--ticket-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 | diff --git a/docs/zh-cn/how-to/non-interactive-installation.md b/docs/zh-cn/how-to/non-interactive-installation.md index df7259d97..3ad72fbf4 100644 --- a/docs/zh-cn/how-to/non-interactive-installation.md +++ b/docs/zh-cn/how-to/non-interactive-installation.md @@ -38,6 +38,21 @@ sidebar: | `--communication-language ` | 智能体通信语言 | 英语 | | `--document-output-language ` | 文档输出语言 | 英语 | | `--output-folder ` | 输出文件夹路径 | _bmad-output | +| `--ticket-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._-]*$`)。省略时保持当前的平面输出结构。 ### 其他选项 diff --git a/src/core-skills/module.yaml b/src/core-skills/module.yaml index 48e7a58f7..5012e1122 100644 --- a/src/core-skills/module.yaml +++ b/src/core-skills/module.yaml @@ -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}" diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 38da1eba4..e2a03612b 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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 // ============================================================ diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 96f536ef4..0d595dc82 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -23,6 +23,7 @@ module.exports = { ['--communication-language ', 'Language for agent communication (default: English)'], ['--document-output-language ', 'Language for document output (default: English)'], ['--output-folder ', 'Output folder path relative to project root (default: _bmad-output)'], + ['--ticket-id ', 'Namespace output by ticket/feature ID (e.g., RZP-593)'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'], ], action: async (options) => { diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 111c88b54..a120dde8e 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -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)) { diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 03d38e4da..2600b9b65 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -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)'); }