feat(installer): add lean shard-doc skill prototype install path

This commit is contained in:
Dicky Moore 2026-03-07 16:59:26 +00:00
parent 434e7efab6
commit a036381d84
8 changed files with 387 additions and 17 deletions

View File

@ -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

View File

@ -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.

View File

@ -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-name>/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)

View File

@ -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.

View File

@ -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"

View File

@ -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'),
'<task id="_bmad/core/tasks/shard-doc" name="Shard Document" description="Test shard-doc task"><objective>Test objective</objective></task>\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
// ============================================================

View File

@ -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<number>} 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<number>} 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<Object>} 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<string[]>} 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 };
}
/**

View File

@ -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 };