From a036381d846014b669c0623ada4bce4ec2a6063a Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 7 Mar 2026 16:59:26 +0000 Subject: [PATCH 1/7] feat(installer): add lean shard-doc skill prototype install path --- .../phase-1-analysis.md | 44 +++++++++ .../phase-2-prd.md | 33 +++++++ .../phase-3-architecture.md | 47 ++++++++++ .../phase-4-implementation.md | 32 +++++++ src/core/tasks/bmad-skill-manifest.yaml | 2 + test/test-installation-components.js | 94 +++++++++++++++++++ .../cli/installers/lib/ide/_config-driven.js | 91 ++++++++++++++++-- .../lib/ide/shared/skill-manifest.js | 61 ++++++++++-- 8 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md create mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md create mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md create mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md b/docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md new file mode 100644 index 000000000..0427e945d --- /dev/null +++ b/docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md @@ -0,0 +1,44 @@ +# Phase 1 Analysis: Native Skills Lean PoC + +Date: 2026-03-07 +Branch: `feature/native-skills-lean-shard-doc-prototype` +North-star reference: `docs/native-skills-transition-north-star-thread-2026-03-07.md` + +## Problem Statement + +The prior native-skills transition effort overshot scope. This recovery PoC must prove a single end-to-end duplicate native-skill path while preserving all current legacy task/help behavior. + +## Scope + +In scope: + +1. One duplicated native-skill prototype only: `bmad-shard-doc-skill-prototype` +2. Source capability remains `src/core/tasks/shard-doc.xml` +3. Installer behavior only for supported native-skill tools: + - discover prototype metadata + - register/copy to skill output surface +4. Minimal tests proving prototype duplication for skill tools and no regression for non-skill tools + +Out of scope: + +1. Multi-capability conversion +2. Broad authority/metadata redesign +3. Command/help surface changes +4. Repository-wide migration framework + +## Constraints + +1. Keep `module-help.csv` behavior unchanged +2. Keep legacy `bmad-shard-doc` capability intact +3. Keep PR lean and reviewable +4. Avoid touching unrelated installer paths + +## Risks and Mitigations + +1. Risk: duplicate visible command surfaces + Mitigation: apply prototype duplication only on `skill_format` installers +2. Risk: behavior drift for legacy task/help paths + Mitigation: no edits to legacy task file, task/help catalogs, or command generation rules +3. Risk: hidden regressions across tool outputs + Mitigation: add targeted install-component tests for one skill-format and one non-skill tool + diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md b/docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md new file mode 100644 index 000000000..19d966963 --- /dev/null +++ b/docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md @@ -0,0 +1,33 @@ +# Phase 2 PRD: Native Skills Lean PoC + +Date: 2026-03-07 +Branch: `feature/native-skills-lean-shard-doc-prototype` + +## Goal + +Ship a narrow, testable PoC that installs a duplicated native skill for shard-doc as `bmad-shard-doc-skill-prototype` while preserving existing shard-doc command/help behavior. + +## Functional Requirements + +1. The core task skill metadata supports a prototype duplicate ID for `shard-doc.xml`. +2. Installer discovery reads the prototype duplicate ID from source metadata. +3. For `skill_format` tools, installer writes both: + - canonical skill: `bmad-shard-doc/SKILL.md` + - prototype skill: `bmad-shard-doc-skill-prototype/SKILL.md` +4. For non-`skill_format` tools, installer output remains unchanged (no prototype duplicate command file). +5. Existing shard-doc legacy artifact remains available via current task/help flows. + +## Non-Functional Requirements + +1. PR stays lean (minimal files and logic changes). +2. No behavior change for existing command/help interfaces. +3. Tests are deterministic and run in current installation component suite. + +## Acceptance Criteria + +1. `src/core/tasks/shard-doc.xml` remains unchanged as the legacy capability artifact. +2. Installing for Codex creates `bmad-shard-doc-skill-prototype/SKILL.md` in `.agents/skills`. +3. Installing for Codex still creates the existing `bmad-shard-doc/SKILL.md`. +4. Installing for Gemini does not create `bmad-shard-doc-skill-prototype` command output. +5. Existing install-component suite continues to pass with added assertions. + diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md b/docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md new file mode 100644 index 000000000..e9441b133 --- /dev/null +++ b/docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md @@ -0,0 +1,47 @@ +# Phase 3 Architecture: Native Skills Lean PoC + +Date: 2026-03-07 +Branch: `feature/native-skills-lean-shard-doc-prototype` + +## Existing Baseline + +1. Installer already uses `bmad-skill-manifest.yaml` for canonical skill IDs. +2. `skill_format` platforms write directory-based skills (`/SKILL.md`). +3. Task/help command surfaces are driven by existing manifests/catalogs. + +## Proposed Minimal Design + +### 1) Metadata Extension + +Extend per-file skill metadata to optionally include duplicate prototype IDs: + +```yaml +shard-doc.xml: + canonicalId: bmad-shard-doc + prototypeIds: + - bmad-shard-doc-skill-prototype +``` + +### 2) Installer Duplication Rule + +In config-driven IDE setup, when `skill_format` is enabled: + +1. Write canonical skill output as today. +2. Resolve prototype IDs for the same source artifact from sidecar metadata. +3. Write additional `SKILL.md` outputs under each prototype ID directory. + +No duplication is applied for non-`skill_format` outputs. + +### 3) Invariants + +1. Legacy source artifact remains `src/core/tasks/shard-doc.xml`. +2. Existing help/command catalogs remain unchanged. +3. No new artifact category or broad migration framework introduced. + +## Touched Components + +1. `src/core/tasks/bmad-skill-manifest.yaml` (prototype metadata for shard-doc) +2. `tools/cli/installers/lib/ide/shared/skill-manifest.js` (read prototype IDs) +3. `tools/cli/installers/lib/ide/_config-driven.js` (duplicate skill write for skill-format installers) +4. `test/test-installation-components.js` (targeted Codex/Gemini assertions) + diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md b/docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md new file mode 100644 index 000000000..f91116b7b --- /dev/null +++ b/docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md @@ -0,0 +1,32 @@ +# Phase 4 Implementation: Native Skills Lean PoC + +Date: 2026-03-07 +Branch: `feature/native-skills-lean-shard-doc-prototype` + +## Story + +As BMAD installer maintainers, we need one duplicated native-skill prototype for shard-doc so we can validate intermediary migration behavior without changing existing task/help surfaces. + +## Tasks + +1. Add prototype ID metadata for `shard-doc.xml`. +2. Extend skill-manifest helper to expose prototype IDs. +3. Update config-driven installer to emit duplicate skill directories for `skill_format` targets only. +4. Add install-component tests: + - Codex (skill-format) writes canonical + prototype shard-doc skills + - Gemini (non-skill) does not write prototype duplicate output +5. Run installer component tests. + +## Verification Plan + +1. `node test/test-installation-components.js` +2. Confirm no edits to legacy `shard-doc.xml` behavior content. +3. Confirm no edits to `src/core/module-help.csv` command/help entries. + +## Done Criteria + +1. Four-phase artifacts exist in docs. +2. Prototype skill duplication works on supported skill-format install path. +3. Legacy shard-doc command/help behavior remains unchanged. +4. Test suite passes with new assertions. + diff --git a/src/core/tasks/bmad-skill-manifest.yaml b/src/core/tasks/bmad-skill-manifest.yaml index 4f7e6b40e..9c227aabc 100644 --- a/src/core/tasks/bmad-skill-manifest.yaml +++ b/src/core/tasks/bmad-skill-manifest.yaml @@ -30,6 +30,8 @@ review-edge-case-hunter.xml: shard-doc.xml: canonicalId: bmad-shard-doc + prototypeIds: + - bmad-shard-doc-skill-prototype type: task description: "Splits large markdown documents into smaller, organized files based on sections" diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 63f2567f5..07faab05d 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -81,6 +81,41 @@ async function createTestBmadFixture() { return fixtureDir; } +async function createShardDocPrototypeFixture() { + const fixtureDir = await createTestBmadFixture(); + + await fs.ensureDir(path.join(fixtureDir, 'core', 'tasks')); + await fs.writeFile( + path.join(fixtureDir, 'core', 'tasks', 'shard-doc.xml'), + 'Test objective\n', + ); + + await fs.writeFile( + path.join(fixtureDir, 'core', 'tasks', 'bmad-skill-manifest.yaml'), + [ + 'shard-doc.xml:', + ' canonicalId: bmad-shard-doc', + ' prototypeIds:', + ' - bmad-shard-doc-skill-prototype', + '', + ].join('\n'), + ); + + await fs.writeFile( + path.join(fixtureDir, '_config', 'task-manifest.csv'), + [ + 'name,displayName,description,module,path,standalone,canonicalId', + 'shard-doc,Shard Document,Test shard-doc task,core,_bmad/core/tasks/shard-doc.xml,true,bmad-shard-doc', + '', + ].join('\n'), + ); + + // Ensure tool manifest exists to avoid parser edge-cases in some environments. + await fs.writeFile(path.join(fixtureDir, '_config', 'tool-manifest.csv'), ''); + + return fixtureDir; +} + /** * Test Suite */ @@ -524,6 +559,65 @@ async function runTests() { console.log(''); + // ============================================================ + // Test 11: Shard-doc Prototype Duplication (Skill-Format Only) + // ============================================================ + console.log(`${colors.yellow}Test Suite 11: Shard-doc Prototype Duplication${colors.reset}\n`); + + try { + clearCache(); + const platformCodes = await loadPlatformCodes(); + const codexInstaller = platformCodes.platforms.codex?.installer; + const geminiInstaller = platformCodes.platforms.gemini?.installer; + + assert(codexInstaller?.skill_format === true, 'Codex installer uses skill_format output'); + assert(geminiInstaller?.skill_format !== true, 'Gemini installer remains non-skill_format'); + + const tempCodexProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-codex-prototype-test-')); + const tempGeminiProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-gemini-prototype-test-')); + const installedBmadDir = await createShardDocPrototypeFixture(); + + const ideManager = new IdeManager(); + await ideManager.ensureInitialized(); + + const codexResult = await ideManager.setup('codex', tempCodexProjectDir, installedBmadDir, { + silent: true, + selectedModules: ['bmm'], + }); + + assert(codexResult.success === true, 'Codex setup succeeds for shard-doc prototype fixture'); + + const codexCanonicalSkill = path.join(tempCodexProjectDir, '.agents', 'skills', 'bmad-shard-doc', 'SKILL.md'); + const codexPrototypeSkill = path.join(tempCodexProjectDir, '.agents', 'skills', 'bmad-shard-doc-skill-prototype', 'SKILL.md'); + assert(await fs.pathExists(codexCanonicalSkill), 'Codex install writes canonical shard-doc skill'); + assert(await fs.pathExists(codexPrototypeSkill), 'Codex install writes duplicated shard-doc prototype skill'); + + const codexCanonicalContent = await fs.readFile(codexCanonicalSkill, 'utf8'); + const codexPrototypeContent = await fs.readFile(codexPrototypeSkill, 'utf8'); + assert(codexCanonicalContent.includes('name: bmad-shard-doc'), 'Canonical shard-doc skill keeps canonical frontmatter name'); + assert(codexPrototypeContent.includes('name: bmad-shard-doc-skill-prototype'), 'Prototype shard-doc skill uses prototype frontmatter name'); + + const geminiResult = await ideManager.setup('gemini', tempGeminiProjectDir, installedBmadDir, { + silent: true, + selectedModules: ['bmm'], + }); + + assert(geminiResult.success === true, 'Gemini setup succeeds for shard-doc prototype fixture'); + + const geminiCanonicalTask = path.join(tempGeminiProjectDir, '.gemini', 'commands', 'bmad-shard-doc.toml'); + const geminiPrototypeTask = path.join(tempGeminiProjectDir, '.gemini', 'commands', 'bmad-shard-doc-skill-prototype.toml'); + assert(await fs.pathExists(geminiCanonicalTask), 'Gemini install writes canonical shard-doc command artifact'); + assert(!(await fs.pathExists(geminiPrototypeTask)), 'Gemini install does not write duplicated shard-doc prototype artifact'); + + await fs.remove(tempCodexProjectDir); + await fs.remove(tempGeminiProjectDir); + await fs.remove(installedBmadDir); + } catch (error) { + assert(false, 'Shard-doc prototype duplication test succeeds', error.message); + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index d23d8d6d0..30f2c4ae3 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -7,6 +7,7 @@ const prompts = require('../../../lib/prompts'); const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator'); const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator'); +const { loadSkillManifest, getPrototypeIds } = require('./shared/skill-manifest'); /** * Config-driven IDE setup handler @@ -132,21 +133,21 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { if (!artifact_types || artifact_types.includes('agents')) { const agentGen = new AgentCommandGenerator(this.bmadFolderName); const { artifacts } = await agentGen.collectAgentArtifacts(bmadDir, selectedModules); - results.agents = await this.writeAgentArtifacts(targetPath, artifacts, template_type, config); + results.agents = await this.writeAgentArtifacts(targetPath, artifacts, template_type, config, bmadDir); } // Install workflows if (!artifact_types || artifact_types.includes('workflows')) { const workflowGen = new WorkflowCommandGenerator(this.bmadFolderName); const { artifacts } = await workflowGen.collectWorkflowArtifacts(bmadDir); - results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config); + results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config, bmadDir); } // Install tasks and tools using template system (supports TOML for Gemini, MD for others) if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) { const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName); const { artifacts } = await taskToolGen.collectTaskToolArtifacts(bmadDir); - const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config); + const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config, bmadDir); results.tasks = taskToolResult.tasks || 0; results.tools = taskToolResult.tools || 0; } @@ -187,7 +188,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} Count of artifacts written */ - async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}) { + async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) { // Try to load platform-specific template, fall back to default-agent const { content: template, extension } = await this.loadTemplate(templateType, 'agent', config, 'default-agent'); let count = 0; @@ -197,7 +198,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, 'agent', extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content); + await this.writeSkillFile(targetPath, artifact, content, bmadDir); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -216,7 +217,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} Count of artifacts written */ - async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}) { + async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) { let count = 0; for (const artifact of artifacts) { @@ -235,7 +236,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, 'workflow', extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content); + await this.writeSkillFile(targetPath, artifact, content, bmadDir); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -255,7 +256,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @param {Object} config - Installation configuration * @returns {Promise} Counts of tasks and tools written */ - async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}) { + async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}, bmadDir = null) { let taskCount = 0; let toolCount = 0; @@ -283,7 +284,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { const filename = this.generateFilename(artifact, artifact.type, extension); if (config.skill_format) { - await this.writeSkillFile(targetPath, artifact, content); + await this.writeSkillFile(targetPath, artifact, content, bmadDir); } else { const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); @@ -478,7 +479,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} * @param {Object} artifact - Artifact data * @param {string} content - Rendered template content */ - async writeSkillFile(targetPath, artifact, content) { + async writeSkillFile(targetPath, artifact, content, bmadDir = null) { const { resolveSkillName } = require('./shared/path-utils'); // Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md @@ -497,6 +498,76 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} const skillContent = this.transformToSkillFormat(content, skillName); await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent); + + if (!bmadDir) return; + + const duplicatePrototypeIds = await this.getPrototypeSkillIdsForArtifact(artifact, bmadDir); + for (const prototypeId of duplicatePrototypeIds) { + if (prototypeId === skillName) continue; + const prototypeDir = path.join(targetPath, prototypeId); + await this.ensureDir(prototypeDir); + const prototypeContent = this.transformToSkillFormat(content, prototypeId); + await this.writeFile(path.join(prototypeDir, 'SKILL.md'), prototypeContent); + } + } + + /** + * Resolve duplicate prototype IDs for an artifact from the installed bmad source tree. + * @param {Object} artifact - Artifact metadata + * @param {string} bmadDir - Installed bmad directory + * @returns {Promise} Prototype skill IDs + */ + async getPrototypeSkillIdsForArtifact(artifact, bmadDir) { + const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); + if (!sourceRef) return []; + + const manifest = await loadSkillManifest(sourceRef.dirPath); + return getPrototypeIds(manifest, sourceRef.filename); + } + + /** + * Resolve the artifact source directory + filename within installed bmad tree. + * @param {Object} artifact - Artifact metadata + * @param {string} bmadDir - Installed bmad directory + * @returns {{dirPath: string, filename: string}|null} + */ + resolveArtifactSourceRef(artifact, bmadDir) { + let sourcePath = ''; + + if ((artifact.type === 'task' || artifact.type === 'tool') && artifact.path) { + sourcePath = artifact.path; + } else if (artifact.type === 'workflow-command' && artifact.workflowPath) { + sourcePath = artifact.workflowPath; + } else if (artifact.type === 'agent-launcher' && artifact.agentPath) { + sourcePath = artifact.agentPath; + } else if (typeof artifact.sourcePath === 'string') { + sourcePath = artifact.sourcePath; + } + + if (!sourcePath) return null; + + let normalized = sourcePath.replaceAll('\\', '/'); + if (path.isAbsolute(normalized)) { + normalized = path.relative(bmadDir, normalized).replaceAll('\\', '/'); + } + + for (const prefix of [`${this.bmadFolderName}/`, '_bmad/', 'bmad/']) { + if (normalized.startsWith(prefix)) { + normalized = normalized.slice(prefix.length); + break; + } + } + + // eslint-disable-next-line unicorn/prefer-string-replace-all -- regex replacement is intentional + normalized = normalized.replace(/^\/+/, ''); + if (!normalized || normalized.startsWith('..')) return null; + + const filename = path.basename(normalized); + if (!filename || filename === '.' || filename === '..') return null; + + const relativeDir = path.dirname(normalized); + const dirPath = relativeDir === '.' ? bmadDir : path.join(bmadDir, relativeDir); + return { dirPath, filename }; } /** diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index ff940242f..71a380cf0 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -31,18 +31,65 @@ async function loadSkillManifest(dirPath) { * @returns {string} canonicalId or empty string */ function getCanonicalId(manifest, filename) { - if (!manifest) return ''; + const manifestEntry = resolveManifestEntry(manifest, filename); + return manifestEntry?.canonicalId || ''; +} + +/** + * Get duplicate prototype skill IDs for a specific file from a loaded skill manifest. + * Prototype IDs are optional and only used by skill-format installers. + * @param {Object|null} manifest - Loaded manifest (from loadSkillManifest) + * @param {string} filename - Source filename to look up + * @returns {string[]} Duplicate prototype IDs + */ +function getPrototypeIds(manifest, filename) { + const manifestEntry = resolveManifestEntry(manifest, filename); + if (!manifestEntry) return []; + + // Support one canonical field name plus temporary/fallback aliases during transition. + const rawIds = manifestEntry.prototypeIds ?? manifestEntry.skillPrototypeIds ?? manifestEntry.duplicateSkillIds ?? []; + return normalizeIdList(rawIds); +} + +/** + * Resolve a manifest entry for a source filename. + * Handles single-entry manifests and extension fallbacks. + * @param {Object|null} manifest - Loaded manifest + * @param {string} filename - Source filename + * @returns {Object|null} Manifest entry object + */ +function resolveManifestEntry(manifest, filename) { + if (!manifest) return null; // Single-entry manifest applies to all files in the directory - if (manifest.__single) return manifest.__single.canonicalId || ''; + if (manifest.__single) return manifest.__single; // Multi-entry: look up by filename directly - if (manifest[filename]) return manifest[filename].canonicalId || ''; + if (manifest[filename]) return manifest[filename]; // Fallback: try alternate extensions for compiled files const baseName = filename.replace(/\.(md|xml)$/i, ''); const agentKey = `${baseName}.agent.yaml`; - if (manifest[agentKey]) return manifest[agentKey].canonicalId || ''; + if (manifest[agentKey]) return manifest[agentKey]; const xmlKey = `${baseName}.xml`; - if (manifest[xmlKey]) return manifest[xmlKey].canonicalId || ''; - return ''; + if (manifest[xmlKey]) return manifest[xmlKey]; + return null; } -module.exports = { loadSkillManifest, getCanonicalId }; +/** + * Normalize possible scalar/array ID list formats to a unique string array. + * @param {string|string[]|unknown} ids - Candidate IDs + * @returns {string[]} Normalized IDs + */ +function normalizeIdList(ids) { + const asArray = Array.isArray(ids) ? ids : typeof ids === 'string' ? [ids] : []; + const unique = new Set(); + + for (const id of asArray) { + if (typeof id !== 'string') continue; + const trimmed = id.trim(); + if (!trimmed) continue; + unique.add(trimmed); + } + + return [...unique]; +} + +module.exports = { loadSkillManifest, getCanonicalId, getPrototypeIds }; From 86556edfccefbaa8453ffe5add632bb6e0706c5c Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 7 Mar 2026 18:02:27 +0000 Subject: [PATCH 2/7] fix(installer): trim shard-doc prototype PR and address review feedback --- .../phase-1-analysis.md | 44 ----------------- .../phase-2-prd.md | 33 ------------- .../phase-3-architecture.md | 47 ------------------- .../phase-4-implementation.md | 32 ------------- test/test-installation-components.js | 20 +++++--- .../cli/installers/lib/ide/_config-driven.js | 18 +++++-- 6 files changed, 29 insertions(+), 165 deletions(-) delete mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md delete mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md delete mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md delete mode 100644 docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md b/docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md deleted file mode 100644 index 0427e945d..000000000 --- a/docs/native-skills-lean-shard-doc-prototype/phase-1-analysis.md +++ /dev/null @@ -1,44 +0,0 @@ -# Phase 1 Analysis: Native Skills Lean PoC - -Date: 2026-03-07 -Branch: `feature/native-skills-lean-shard-doc-prototype` -North-star reference: `docs/native-skills-transition-north-star-thread-2026-03-07.md` - -## Problem Statement - -The prior native-skills transition effort overshot scope. This recovery PoC must prove a single end-to-end duplicate native-skill path while preserving all current legacy task/help behavior. - -## Scope - -In scope: - -1. One duplicated native-skill prototype only: `bmad-shard-doc-skill-prototype` -2. Source capability remains `src/core/tasks/shard-doc.xml` -3. Installer behavior only for supported native-skill tools: - - discover prototype metadata - - register/copy to skill output surface -4. Minimal tests proving prototype duplication for skill tools and no regression for non-skill tools - -Out of scope: - -1. Multi-capability conversion -2. Broad authority/metadata redesign -3. Command/help surface changes -4. Repository-wide migration framework - -## Constraints - -1. Keep `module-help.csv` behavior unchanged -2. Keep legacy `bmad-shard-doc` capability intact -3. Keep PR lean and reviewable -4. Avoid touching unrelated installer paths - -## Risks and Mitigations - -1. Risk: duplicate visible command surfaces - Mitigation: apply prototype duplication only on `skill_format` installers -2. Risk: behavior drift for legacy task/help paths - Mitigation: no edits to legacy task file, task/help catalogs, or command generation rules -3. Risk: hidden regressions across tool outputs - Mitigation: add targeted install-component tests for one skill-format and one non-skill tool - diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md b/docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md deleted file mode 100644 index 19d966963..000000000 --- a/docs/native-skills-lean-shard-doc-prototype/phase-2-prd.md +++ /dev/null @@ -1,33 +0,0 @@ -# Phase 2 PRD: Native Skills Lean PoC - -Date: 2026-03-07 -Branch: `feature/native-skills-lean-shard-doc-prototype` - -## Goal - -Ship a narrow, testable PoC that installs a duplicated native skill for shard-doc as `bmad-shard-doc-skill-prototype` while preserving existing shard-doc command/help behavior. - -## Functional Requirements - -1. The core task skill metadata supports a prototype duplicate ID for `shard-doc.xml`. -2. Installer discovery reads the prototype duplicate ID from source metadata. -3. For `skill_format` tools, installer writes both: - - canonical skill: `bmad-shard-doc/SKILL.md` - - prototype skill: `bmad-shard-doc-skill-prototype/SKILL.md` -4. For non-`skill_format` tools, installer output remains unchanged (no prototype duplicate command file). -5. Existing shard-doc legacy artifact remains available via current task/help flows. - -## Non-Functional Requirements - -1. PR stays lean (minimal files and logic changes). -2. No behavior change for existing command/help interfaces. -3. Tests are deterministic and run in current installation component suite. - -## Acceptance Criteria - -1. `src/core/tasks/shard-doc.xml` remains unchanged as the legacy capability artifact. -2. Installing for Codex creates `bmad-shard-doc-skill-prototype/SKILL.md` in `.agents/skills`. -3. Installing for Codex still creates the existing `bmad-shard-doc/SKILL.md`. -4. Installing for Gemini does not create `bmad-shard-doc-skill-prototype` command output. -5. Existing install-component suite continues to pass with added assertions. - diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md b/docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md deleted file mode 100644 index e9441b133..000000000 --- a/docs/native-skills-lean-shard-doc-prototype/phase-3-architecture.md +++ /dev/null @@ -1,47 +0,0 @@ -# Phase 3 Architecture: Native Skills Lean PoC - -Date: 2026-03-07 -Branch: `feature/native-skills-lean-shard-doc-prototype` - -## Existing Baseline - -1. Installer already uses `bmad-skill-manifest.yaml` for canonical skill IDs. -2. `skill_format` platforms write directory-based skills (`/SKILL.md`). -3. Task/help command surfaces are driven by existing manifests/catalogs. - -## Proposed Minimal Design - -### 1) Metadata Extension - -Extend per-file skill metadata to optionally include duplicate prototype IDs: - -```yaml -shard-doc.xml: - canonicalId: bmad-shard-doc - prototypeIds: - - bmad-shard-doc-skill-prototype -``` - -### 2) Installer Duplication Rule - -In config-driven IDE setup, when `skill_format` is enabled: - -1. Write canonical skill output as today. -2. Resolve prototype IDs for the same source artifact from sidecar metadata. -3. Write additional `SKILL.md` outputs under each prototype ID directory. - -No duplication is applied for non-`skill_format` outputs. - -### 3) Invariants - -1. Legacy source artifact remains `src/core/tasks/shard-doc.xml`. -2. Existing help/command catalogs remain unchanged. -3. No new artifact category or broad migration framework introduced. - -## Touched Components - -1. `src/core/tasks/bmad-skill-manifest.yaml` (prototype metadata for shard-doc) -2. `tools/cli/installers/lib/ide/shared/skill-manifest.js` (read prototype IDs) -3. `tools/cli/installers/lib/ide/_config-driven.js` (duplicate skill write for skill-format installers) -4. `test/test-installation-components.js` (targeted Codex/Gemini assertions) - diff --git a/docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md b/docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md deleted file mode 100644 index f91116b7b..000000000 --- a/docs/native-skills-lean-shard-doc-prototype/phase-4-implementation.md +++ /dev/null @@ -1,32 +0,0 @@ -# Phase 4 Implementation: Native Skills Lean PoC - -Date: 2026-03-07 -Branch: `feature/native-skills-lean-shard-doc-prototype` - -## Story - -As BMAD installer maintainers, we need one duplicated native-skill prototype for shard-doc so we can validate intermediary migration behavior without changing existing task/help surfaces. - -## Tasks - -1. Add prototype ID metadata for `shard-doc.xml`. -2. Extend skill-manifest helper to expose prototype IDs. -3. Update config-driven installer to emit duplicate skill directories for `skill_format` targets only. -4. Add install-component tests: - - Codex (skill-format) writes canonical + prototype shard-doc skills - - Gemini (non-skill) does not write prototype duplicate output -5. Run installer component tests. - -## Verification Plan - -1. `node test/test-installation-components.js` -2. Confirm no edits to legacy `shard-doc.xml` behavior content. -3. Confirm no edits to `src/core/module-help.csv` command/help entries. - -## Done Criteria - -1. Four-phase artifacts exist in docs. -2. Prototype skill duplication works on supported skill-format install path. -3. Legacy shard-doc command/help behavior remains unchanged. -4. Test suite passes with new assertions. - diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 07faab05d..ee99c0136 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -97,6 +97,8 @@ async function createShardDocPrototypeFixture() { ' canonicalId: bmad-shard-doc', ' prototypeIds:', ' - bmad-shard-doc-skill-prototype', + ' type: task', + ' description: "Splits large markdown documents into smaller, organized files based on sections"', '', ].join('\n'), ); @@ -564,6 +566,9 @@ async function runTests() { // ============================================================ console.log(`${colors.yellow}Test Suite 11: Shard-doc Prototype Duplication${colors.reset}\n`); + let tempCodexProjectDir; + let tempGeminiProjectDir; + let installedBmadDir; try { clearCache(); const platformCodes = await loadPlatformCodes(); @@ -573,9 +578,9 @@ async function runTests() { assert(codexInstaller?.skill_format === true, 'Codex installer uses skill_format output'); assert(geminiInstaller?.skill_format !== true, 'Gemini installer remains non-skill_format'); - const tempCodexProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-codex-prototype-test-')); - const tempGeminiProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-gemini-prototype-test-')); - const installedBmadDir = await createShardDocPrototypeFixture(); + tempCodexProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-codex-prototype-test-')); + tempGeminiProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-gemini-prototype-test-')); + installedBmadDir = await createShardDocPrototypeFixture(); const ideManager = new IdeManager(); await ideManager.ensureInitialized(); @@ -609,11 +614,14 @@ async function runTests() { assert(await fs.pathExists(geminiCanonicalTask), 'Gemini install writes canonical shard-doc command artifact'); assert(!(await fs.pathExists(geminiPrototypeTask)), 'Gemini install does not write duplicated shard-doc prototype artifact'); - await fs.remove(tempCodexProjectDir); - await fs.remove(tempGeminiProjectDir); - await fs.remove(installedBmadDir); } catch (error) { assert(false, 'Shard-doc prototype duplication test succeeds', error.message); + } finally { + await Promise.allSettled( + [tempCodexProjectDir, tempGeminiProjectDir, installedBmadDir] + .filter(Boolean) + .map((dir) => fs.remove(dir)), + ); } console.log(''); diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 30f2c4ae3..285246548 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -27,6 +27,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { super(platformCode, platformConfig.name, platformConfig.preferred); this.platformConfig = platformConfig; this.installerConfig = platformConfig.installer || null; + this._manifestCache = new Map(); // Set configDir from target_dir so base-class detect() works if (this.installerConfig?.target_dir) { @@ -116,6 +117,7 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { */ async installToTarget(projectDir, bmadDir, config, options) { const { target_dir, template_type, artifact_types } = config; + this._manifestCache = new Map(); // Skip targets with explicitly empty artifact_types array // This prevents creating empty directories when no artifacts will be written @@ -521,7 +523,11 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); if (!sourceRef) return []; - const manifest = await loadSkillManifest(sourceRef.dirPath); + let manifest = this._manifestCache.get(sourceRef.dirPath); + if (manifest === undefined) { + manifest = await loadSkillManifest(sourceRef.dirPath); + this._manifestCache.set(sourceRef.dirPath, manifest); + } return getPrototypeIds(manifest, sourceRef.filename); } @@ -558,15 +564,21 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} } } - // eslint-disable-next-line unicorn/prefer-string-replace-all -- regex replacement is intentional normalized = normalized.replace(/^\/+/, ''); if (!normalized || normalized.startsWith('..')) return null; const filename = path.basename(normalized); if (!filename || filename === '.' || filename === '..') return null; + const resolvedBmadDir = path.resolve(bmadDir); const relativeDir = path.dirname(normalized); - const dirPath = relativeDir === '.' ? bmadDir : path.join(bmadDir, relativeDir); + const dirPath = relativeDir === '.' ? resolvedBmadDir : path.resolve(resolvedBmadDir, relativeDir); + const pathFromBmadRoot = path.relative(resolvedBmadDir, dirPath); + + if (pathFromBmadRoot === '..' || pathFromBmadRoot.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromBmadRoot)) { + return null; + } + return { dirPath, filename }; } From 18277c0ba1843a2cc0db5df43f78f530f5fd28c5 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 7 Mar 2026 18:22:17 +0000 Subject: [PATCH 3/7] feat(installer): align shard-doc prototype with source-skill install model --- .../bmad-shard-doc-skill-prototype/SKILL.md | 12 +++++ .../skill-manifest.yaml | 3 ++ ...kill-manifest.yaml => skill-manifest.yaml} | 0 test/test-installation-components.js | 25 ++++++++- .../cli/installers/lib/ide/_config-driven.js | 53 +++++++++++++++++-- .../installers/lib/ide/shared/path-utils.js | 2 +- .../lib/ide/shared/skill-manifest.js | 32 ++++++----- 7 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md create mode 100644 src/core/tasks/bmad-shard-doc-skill-prototype/skill-manifest.yaml rename src/core/tasks/{bmad-skill-manifest.yaml => skill-manifest.yaml} (100%) diff --git a/src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md b/src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md new file mode 100644 index 000000000..afa37c831 --- /dev/null +++ b/src/core/tasks/bmad-shard-doc-skill-prototype/SKILL.md @@ -0,0 +1,12 @@ +--- +name: bmad-shard-doc-skill-prototype +description: Prototype native skill wrapper for shard-doc during transition. +--- + +# bmad-shard-doc-skill-prototype + +Prototype marker: source-authored-skill + +Read and execute from: {project-root}/_bmad/core/tasks/shard-doc.xml + +Follow all shard-doc task instructions exactly as written. diff --git a/src/core/tasks/bmad-shard-doc-skill-prototype/skill-manifest.yaml b/src/core/tasks/bmad-shard-doc-skill-prototype/skill-manifest.yaml new file mode 100644 index 000000000..29ce5aeb6 --- /dev/null +++ b/src/core/tasks/bmad-shard-doc-skill-prototype/skill-manifest.yaml @@ -0,0 +1,3 @@ +canonicalId: bmad-shard-doc-skill-prototype +type: task +description: "Prototype native skill wrapper for shard-doc during installer transition" diff --git a/src/core/tasks/bmad-skill-manifest.yaml b/src/core/tasks/skill-manifest.yaml similarity index 100% rename from src/core/tasks/bmad-skill-manifest.yaml rename to src/core/tasks/skill-manifest.yaml diff --git a/test/test-installation-components.js b/test/test-installation-components.js index ee99c0136..90ec733b9 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -72,7 +72,7 @@ async function createTestBmadFixture() { await fs.ensureDir(path.join(fixtureDir, 'core', 'agents')); await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'bmad-master.md'), minimalAgent); // Skill manifest so the installer uses 'bmad-master' as the canonical skill name - await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'bmad-skill-manifest.yaml'), 'bmad-master.md:\n canonicalId: bmad-master\n'); + await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'skill-manifest.yaml'), 'bmad-master.md:\n canonicalId: bmad-master\n'); // Minimal compiled agent for bmm module (tests use selectedModules: ['bmm']) await fs.ensureDir(path.join(fixtureDir, 'bmm', 'agents')); @@ -91,7 +91,7 @@ async function createShardDocPrototypeFixture() { ); await fs.writeFile( - path.join(fixtureDir, 'core', 'tasks', 'bmad-skill-manifest.yaml'), + path.join(fixtureDir, 'core', 'tasks', 'skill-manifest.yaml'), [ 'shard-doc.xml:', ' canonicalId: bmad-shard-doc', @@ -103,6 +103,26 @@ async function createShardDocPrototypeFixture() { ].join('\n'), ); + await fs.ensureDir(path.join(fixtureDir, 'core', 'tasks', 'bmad-shard-doc-skill-prototype')); + await fs.writeFile( + path.join(fixtureDir, 'core', 'tasks', 'bmad-shard-doc-skill-prototype', 'SKILL.md'), + [ + '---', + 'name: bmad-shard-doc-skill-prototype', + 'description: Source-authored prototype skill', + '---', + '', + '# bmad-shard-doc-skill-prototype', + '', + 'Prototype marker: source-authored-skill', + '', + 'Read and execute from: {project-root}/_bmad/core/tasks/shard-doc.xml', + '', + 'Follow all shard-doc task instructions exactly as written.', + '', + ].join('\n'), + ); + await fs.writeFile( path.join(fixtureDir, '_config', 'task-manifest.csv'), [ @@ -601,6 +621,7 @@ async function runTests() { const codexPrototypeContent = await fs.readFile(codexPrototypeSkill, 'utf8'); assert(codexCanonicalContent.includes('name: bmad-shard-doc'), 'Canonical shard-doc skill keeps canonical frontmatter name'); assert(codexPrototypeContent.includes('name: bmad-shard-doc-skill-prototype'), 'Prototype shard-doc skill uses prototype frontmatter name'); + assert(codexPrototypeContent.includes('Prototype marker: source-authored-skill'), 'Prototype shard-doc skill is copied from source SKILL.md'); const geminiResult = await ideManager.setup('gemini', tempGeminiProjectDir, installedBmadDir, { silent: true, diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index 285246548..de72e5ab4 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -503,12 +503,15 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} if (!bmadDir) return; - const duplicatePrototypeIds = await this.getPrototypeSkillIdsForArtifact(artifact, bmadDir); + const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); + const duplicatePrototypeIds = await this.getPrototypeSkillIdsForArtifact(artifact, bmadDir, sourceRef); for (const prototypeId of duplicatePrototypeIds) { + if (!this.isSafeSkillFolderName(prototypeId)) continue; if (prototypeId === skillName) continue; const prototypeDir = path.join(targetPath, prototypeId); await this.ensureDir(prototypeDir); - const prototypeContent = this.transformToSkillFormat(content, prototypeId); + const sourceSkillContent = await this.readPrototypeSourceSkillContent(sourceRef, prototypeId); + const prototypeContent = sourceSkillContent ?? this.transformToSkillFormat(content, prototypeId); await this.writeFile(path.join(prototypeDir, 'SKILL.md'), prototypeContent); } } @@ -519,8 +522,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} * @param {string} bmadDir - Installed bmad directory * @returns {Promise} Prototype skill IDs */ - async getPrototypeSkillIdsForArtifact(artifact, bmadDir) { - const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); + async getPrototypeSkillIdsForArtifact(artifact, bmadDir, sourceRefOverride = null) { + const sourceRef = sourceRefOverride ?? this.resolveArtifactSourceRef(artifact, bmadDir); if (!sourceRef) return []; let manifest = this._manifestCache.get(sourceRef.dirPath); @@ -531,6 +534,48 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} return getPrototypeIds(manifest, sourceRef.filename); } + /** + * Read prototype SKILL.md content directly from source when present. + * This enables copy-as-is installation for native skill prototypes. + * @param {{dirPath: string, filename: string}|null} sourceRef - Resolved source reference + * @param {string} prototypeId - Prototype skill ID + * @returns {Promise} Source SKILL.md content or null + */ + async readPrototypeSourceSkillContent(sourceRef, prototypeId) { + if (!sourceRef || !this.isSafeSkillFolderName(prototypeId)) return null; + + const resolvedSourceDir = path.resolve(sourceRef.dirPath); + const sourceSkillPath = path.resolve(resolvedSourceDir, prototypeId, 'SKILL.md'); + const relativeToSourceDir = path.relative(resolvedSourceDir, sourceSkillPath); + + if ( + relativeToSourceDir === '..' || + relativeToSourceDir.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeToSourceDir) + ) { + return null; + } + + if (!(await fs.pathExists(sourceSkillPath))) return null; + return fs.readFile(sourceSkillPath, 'utf8'); + } + + /** + * Validate skill folder names used for source lookup. + * @param {string} skillId - Candidate skill ID + * @returns {boolean} True if safe to use as a folder name segment + */ + isSafeSkillFolderName(skillId) { + return ( + typeof skillId === 'string' && + skillId.length > 0 && + skillId !== '.' && + skillId !== '..' && + !skillId.includes('/') && + !skillId.includes('\\') + ); + } + /** * Resolve the artifact source directory + filename within installed bmad tree. * @param {Object} artifact - Artifact metadata diff --git a/tools/cli/installers/lib/ide/shared/path-utils.js b/tools/cli/installers/lib/ide/shared/path-utils.js index 45efd2ec1..ebec2b6cb 100644 --- a/tools/cli/installers/lib/ide/shared/path-utils.js +++ b/tools/cli/installers/lib/ide/shared/path-utils.js @@ -266,7 +266,7 @@ function parseUnderscoreName(filename) { /** * Resolve the skill name for an artifact. - * Prefers canonicalId from a bmad-skill-manifest.yaml sidecar when available, + * Prefers canonicalId from a skill manifest sidecar when available, * falling back to the path-derived name from toDashPath(). * * @param {Object} artifact - Artifact object (must have relativePath; may have canonicalId) diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index 71a380cf0..dce4aaca6 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -2,26 +2,32 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); +const SKILL_MANIFEST_FILENAMES = ['skill-manifest.yaml', 'bmad-skill-manifest.yaml', 'manifest.yaml']; + /** - * Load bmad-skill-manifest.yaml from a directory. + * Load skill manifest from a directory. * Single-entry manifests (canonicalId at top level) apply to all files in the directory. * Multi-entry manifests are keyed by source filename. - * @param {string} dirPath - Directory to check for bmad-skill-manifest.yaml + * @param {string} dirPath - Directory to check for supported manifest filenames * @returns {Object|null} Parsed manifest or null */ async function loadSkillManifest(dirPath) { - const manifestPath = path.join(dirPath, 'bmad-skill-manifest.yaml'); - try { - if (!(await fs.pathExists(manifestPath))) return null; - const content = await fs.readFile(manifestPath, 'utf8'); - const parsed = yaml.parse(content); - if (!parsed || typeof parsed !== 'object') return null; - if (parsed.canonicalId) return { __single: parsed }; - return parsed; - } catch (error) { - console.warn(`Warning: Failed to parse bmad-skill-manifest.yaml in ${dirPath}: ${error.message}`); - return null; + for (const manifestFilename of SKILL_MANIFEST_FILENAMES) { + const manifestPath = path.join(dirPath, manifestFilename); + try { + if (!(await fs.pathExists(manifestPath))) continue; + const content = await fs.readFile(manifestPath, 'utf8'); + const parsed = yaml.parse(content); + if (!parsed || typeof parsed !== 'object') return null; + if (parsed.canonicalId) return { __single: parsed }; + return parsed; + } catch (error) { + console.warn(`Warning: Failed to parse ${manifestFilename} in ${dirPath}: ${error.message}`); + return null; + } } + + return null; } /** From d5bb2398bd5e31f8db6a746383852509e5b6eeb1 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 7 Mar 2026 20:10:27 +0000 Subject: [PATCH 4/7] chore(skills): keep bmad-prefixed sidecar manifest filenames --- .../{skill-manifest.yaml => bmad-skill-manifest.yaml} | 0 .../tasks/{skill-manifest.yaml => bmad-skill-manifest.yaml} | 0 test/test-installation-components.js | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/core/tasks/bmad-shard-doc-skill-prototype/{skill-manifest.yaml => bmad-skill-manifest.yaml} (100%) rename src/core/tasks/{skill-manifest.yaml => bmad-skill-manifest.yaml} (100%) diff --git a/src/core/tasks/bmad-shard-doc-skill-prototype/skill-manifest.yaml b/src/core/tasks/bmad-shard-doc-skill-prototype/bmad-skill-manifest.yaml similarity index 100% rename from src/core/tasks/bmad-shard-doc-skill-prototype/skill-manifest.yaml rename to src/core/tasks/bmad-shard-doc-skill-prototype/bmad-skill-manifest.yaml diff --git a/src/core/tasks/skill-manifest.yaml b/src/core/tasks/bmad-skill-manifest.yaml similarity index 100% rename from src/core/tasks/skill-manifest.yaml rename to src/core/tasks/bmad-skill-manifest.yaml diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 90ec733b9..c4321dfa7 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -72,7 +72,7 @@ async function createTestBmadFixture() { await fs.ensureDir(path.join(fixtureDir, 'core', 'agents')); await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'bmad-master.md'), minimalAgent); // Skill manifest so the installer uses 'bmad-master' as the canonical skill name - await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'skill-manifest.yaml'), 'bmad-master.md:\n canonicalId: bmad-master\n'); + await fs.writeFile(path.join(fixtureDir, 'core', 'agents', 'bmad-skill-manifest.yaml'), 'bmad-master.md:\n canonicalId: bmad-master\n'); // Minimal compiled agent for bmm module (tests use selectedModules: ['bmm']) await fs.ensureDir(path.join(fixtureDir, 'bmm', 'agents')); @@ -91,7 +91,7 @@ async function createShardDocPrototypeFixture() { ); await fs.writeFile( - path.join(fixtureDir, 'core', 'tasks', 'skill-manifest.yaml'), + path.join(fixtureDir, 'core', 'tasks', 'bmad-skill-manifest.yaml'), [ 'shard-doc.xml:', ' canonicalId: bmad-shard-doc', From b08495c6e4599d0096658ffb9a3c87f5ec812cd2 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 7 Mar 2026 20:26:34 +0000 Subject: [PATCH 5/7] refactor(installer): trim shard-doc prototype path to lean behavior --- .../cli/installers/lib/ide/_config-driven.js | 55 +++---------------- .../installers/lib/ide/shared/path-utils.js | 2 +- .../lib/ide/shared/skill-manifest.js | 30 ++++------ 3 files changed, 20 insertions(+), 67 deletions(-) diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index de72e5ab4..59c303243 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -27,7 +27,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { super(platformCode, platformConfig.name, platformConfig.preferred); this.platformConfig = platformConfig; this.installerConfig = platformConfig.installer || null; - this._manifestCache = new Map(); // Set configDir from target_dir so base-class detect() works if (this.installerConfig?.target_dir) { @@ -117,7 +116,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { */ async installToTarget(projectDir, bmadDir, config, options) { const { target_dir, template_type, artifact_types } = config; - this._manifestCache = new Map(); // Skip targets with explicitly empty artifact_types array // This prevents creating empty directories when no artifacts will be written @@ -506,13 +504,13 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} const sourceRef = this.resolveArtifactSourceRef(artifact, bmadDir); const duplicatePrototypeIds = await this.getPrototypeSkillIdsForArtifact(artifact, bmadDir, sourceRef); for (const prototypeId of duplicatePrototypeIds) { - if (!this.isSafeSkillFolderName(prototypeId)) continue; if (prototypeId === skillName) continue; + if (typeof prototypeId !== 'string' || !prototypeId.trim()) continue; const prototypeDir = path.join(targetPath, prototypeId); await this.ensureDir(prototypeDir); const sourceSkillContent = await this.readPrototypeSourceSkillContent(sourceRef, prototypeId); - const prototypeContent = sourceSkillContent ?? this.transformToSkillFormat(content, prototypeId); - await this.writeFile(path.join(prototypeDir, 'SKILL.md'), prototypeContent); + if (!sourceSkillContent) continue; + await this.writeFile(path.join(prototypeDir, 'SKILL.md'), sourceSkillContent); } } @@ -526,11 +524,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} const sourceRef = sourceRefOverride ?? this.resolveArtifactSourceRef(artifact, bmadDir); if (!sourceRef) return []; - let manifest = this._manifestCache.get(sourceRef.dirPath); - if (manifest === undefined) { - manifest = await loadSkillManifest(sourceRef.dirPath); - this._manifestCache.set(sourceRef.dirPath, manifest); - } + const manifest = await loadSkillManifest(sourceRef.dirPath); return getPrototypeIds(manifest, sourceRef.filename); } @@ -542,40 +536,12 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} * @returns {Promise} Source SKILL.md content or null */ async readPrototypeSourceSkillContent(sourceRef, prototypeId) { - if (!sourceRef || !this.isSafeSkillFolderName(prototypeId)) return null; - - const resolvedSourceDir = path.resolve(sourceRef.dirPath); - const sourceSkillPath = path.resolve(resolvedSourceDir, prototypeId, 'SKILL.md'); - const relativeToSourceDir = path.relative(resolvedSourceDir, sourceSkillPath); - - if ( - relativeToSourceDir === '..' || - relativeToSourceDir.startsWith(`..${path.sep}`) || - path.isAbsolute(relativeToSourceDir) - ) { - return null; - } - + if (!sourceRef || typeof prototypeId !== 'string' || !prototypeId.trim()) return null; + const sourceSkillPath = path.join(sourceRef.dirPath, prototypeId, 'SKILL.md'); if (!(await fs.pathExists(sourceSkillPath))) return null; return fs.readFile(sourceSkillPath, 'utf8'); } - /** - * Validate skill folder names used for source lookup. - * @param {string} skillId - Candidate skill ID - * @returns {boolean} True if safe to use as a folder name segment - */ - isSafeSkillFolderName(skillId) { - return ( - typeof skillId === 'string' && - skillId.length > 0 && - skillId !== '.' && - skillId !== '..' && - !skillId.includes('/') && - !skillId.includes('\\') - ); - } - /** * Resolve the artifact source directory + filename within installed bmad tree. * @param {Object} artifact - Artifact metadata @@ -615,15 +581,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} const filename = path.basename(normalized); if (!filename || filename === '.' || filename === '..') return null; - const resolvedBmadDir = path.resolve(bmadDir); const relativeDir = path.dirname(normalized); - const dirPath = relativeDir === '.' ? resolvedBmadDir : path.resolve(resolvedBmadDir, relativeDir); - const pathFromBmadRoot = path.relative(resolvedBmadDir, dirPath); - - if (pathFromBmadRoot === '..' || pathFromBmadRoot.startsWith(`..${path.sep}`) || path.isAbsolute(pathFromBmadRoot)) { - return null; - } - + const dirPath = relativeDir === '.' ? bmadDir : path.join(bmadDir, relativeDir); return { dirPath, filename }; } diff --git a/tools/cli/installers/lib/ide/shared/path-utils.js b/tools/cli/installers/lib/ide/shared/path-utils.js index ebec2b6cb..45efd2ec1 100644 --- a/tools/cli/installers/lib/ide/shared/path-utils.js +++ b/tools/cli/installers/lib/ide/shared/path-utils.js @@ -266,7 +266,7 @@ function parseUnderscoreName(filename) { /** * Resolve the skill name for an artifact. - * Prefers canonicalId from a skill manifest sidecar when available, + * Prefers canonicalId from a bmad-skill-manifest.yaml sidecar when available, * falling back to the path-derived name from toDashPath(). * * @param {Object} artifact - Artifact object (must have relativePath; may have canonicalId) diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index dce4aaca6..b47266b83 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -2,32 +2,26 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); -const SKILL_MANIFEST_FILENAMES = ['skill-manifest.yaml', 'bmad-skill-manifest.yaml', 'manifest.yaml']; - /** * Load skill manifest from a directory. * Single-entry manifests (canonicalId at top level) apply to all files in the directory. * Multi-entry manifests are keyed by source filename. - * @param {string} dirPath - Directory to check for supported manifest filenames + * @param {string} dirPath - Directory to check for bmad-skill-manifest.yaml * @returns {Object|null} Parsed manifest or null */ async function loadSkillManifest(dirPath) { - for (const manifestFilename of SKILL_MANIFEST_FILENAMES) { - const manifestPath = path.join(dirPath, manifestFilename); - try { - if (!(await fs.pathExists(manifestPath))) continue; - const content = await fs.readFile(manifestPath, 'utf8'); - const parsed = yaml.parse(content); - if (!parsed || typeof parsed !== 'object') return null; - if (parsed.canonicalId) return { __single: parsed }; - return parsed; - } catch (error) { - console.warn(`Warning: Failed to parse ${manifestFilename} in ${dirPath}: ${error.message}`); - return null; - } + const manifestPath = path.join(dirPath, 'bmad-skill-manifest.yaml'); + try { + if (!(await fs.pathExists(manifestPath))) return null; + const content = await fs.readFile(manifestPath, 'utf8'); + const parsed = yaml.parse(content); + if (!parsed || typeof parsed !== 'object') return null; + if (parsed.canonicalId) return { __single: parsed }; + return parsed; + } catch (error) { + console.warn(`Warning: Failed to parse bmad-skill-manifest.yaml in ${dirPath}: ${error.message}`); + return null; } - - return null; } /** From 25b6bfd58fa2ebf38b9933a73d1d5295c3ae8779 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 7 Mar 2026 20:40:07 +0000 Subject: [PATCH 6/7] style(test): format installation components suite --- test/test-installation-components.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 558c5f07e..03b6db4a5 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -922,8 +922,14 @@ async function runTests() { const codexCanonicalContent = await fs.readFile(codexCanonicalSkill, 'utf8'); const codexPrototypeContent = await fs.readFile(codexPrototypeSkill, 'utf8'); assert(codexCanonicalContent.includes('name: bmad-shard-doc'), 'Canonical shard-doc skill keeps canonical frontmatter name'); - assert(codexPrototypeContent.includes('name: bmad-shard-doc-skill-prototype'), 'Prototype shard-doc skill uses prototype frontmatter name'); - assert(codexPrototypeContent.includes('Prototype marker: source-authored-skill'), 'Prototype shard-doc skill is copied from source SKILL.md'); + assert( + codexPrototypeContent.includes('name: bmad-shard-doc-skill-prototype'), + 'Prototype shard-doc skill uses prototype frontmatter name', + ); + assert( + codexPrototypeContent.includes('Prototype marker: source-authored-skill'), + 'Prototype shard-doc skill is copied from source SKILL.md', + ); const geminiResult = await ideManager.setup('gemini', tempGeminiProjectDir, installedBmadDir, { silent: true, @@ -936,15 +942,10 @@ async function runTests() { const geminiPrototypeSkill = path.join(tempGeminiProjectDir, '.gemini', 'skills', 'bmad-shard-doc-skill-prototype', 'SKILL.md'); assert(await fs.pathExists(geminiCanonicalSkill), 'Gemini install writes canonical shard-doc skill'); assert(await fs.pathExists(geminiPrototypeSkill), 'Gemini install writes duplicated shard-doc prototype skill'); - } catch (error) { assert(false, 'Shard-doc prototype duplication test succeeds', error.message); } finally { - await Promise.allSettled( - [tempCodexProjectDir, tempGeminiProjectDir, installedBmadDir] - .filter(Boolean) - .map((dir) => fs.remove(dir)), - ); + await Promise.allSettled([tempCodexProjectDir, tempGeminiProjectDir, installedBmadDir].filter(Boolean).map((dir) => fs.remove(dir))); } // Test 17: GitHub Copilot Native Skills Install From cdffa0508c51f1aa77a4022e281a00f0c9125dda Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 7 Mar 2026 21:03:31 +0000 Subject: [PATCH 7/7] refactor(installer): keep prototype resolution task-only and prototypeIds-only --- tools/cli/installers/lib/ide/_config-driven.js | 15 ++------------- .../installers/lib/ide/shared/skill-manifest.js | 3 +-- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index f2e9e3a02..4b2f7dac8 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -549,19 +549,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} * @returns {{dirPath: string, filename: string}|null} */ resolveArtifactSourceRef(artifact, bmadDir) { - let sourcePath = ''; - - if ((artifact.type === 'task' || artifact.type === 'tool') && artifact.path) { - sourcePath = artifact.path; - } else if (artifact.type === 'workflow-command' && artifact.workflowPath) { - sourcePath = artifact.workflowPath; - } else if (artifact.type === 'agent-launcher' && artifact.agentPath) { - sourcePath = artifact.agentPath; - } else if (typeof artifact.sourcePath === 'string') { - sourcePath = artifact.sourcePath; - } - - if (!sourcePath) return null; + if (artifact.type !== 'task' || !artifact.path) return null; + const sourcePath = artifact.path; let normalized = sourcePath.replaceAll('\\', '/'); if (path.isAbsolute(normalized)) { diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index b47266b83..53d6afd01 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -46,8 +46,7 @@ function getPrototypeIds(manifest, filename) { const manifestEntry = resolveManifestEntry(manifest, filename); if (!manifestEntry) return []; - // Support one canonical field name plus temporary/fallback aliases during transition. - const rawIds = manifestEntry.prototypeIds ?? manifestEntry.skillPrototypeIds ?? manifestEntry.duplicateSkillIds ?? []; + const rawIds = manifestEntry.prototypeIds ?? []; return normalizeIdList(rawIds); }