diff --git a/src/core/tasks/shard-doc.artifact.yaml b/src/core/tasks/shard-doc.artifact.yaml
new file mode 100644
index 000000000..7444a1b30
--- /dev/null
+++ b/src/core/tasks/shard-doc.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-shard-doc
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/shard-doc.xml
+displayName: Shard Document
+description: "Split large markdown documents into smaller files by section with an index."
+dependencies:
+ requires: []
diff --git a/test/fixtures/wave-2/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml b/test/fixtures/wave-2/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml
new file mode 100644
index 000000000..d0ef1f1ab
--- /dev/null
+++ b/test/fixtures/wave-2/sidecar-negative/basename-path-mismatch/shard-doc.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-shard-doc
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/not-shard-doc.xml
+displayName: Shard Document
+description: "Split large markdown documents into smaller files by section with an index."
+dependencies:
+ requires: []
diff --git a/test/fixtures/wave-2/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml b/test/fixtures/wave-2/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml
new file mode 100644
index 000000000..70efdad3c
--- /dev/null
+++ b/test/fixtures/wave-2/sidecar-negative/unknown-major-version/shard-doc.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 2
+canonicalId: bmad-shard-doc
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/shard-doc.xml
+displayName: Shard Document
+description: "Split large markdown documents into smaller files by section with an index."
+dependencies:
+ requires: []
diff --git a/test/test-installation-components.js b/test/test-installation-components.js
index b06db039c..67a42ac70 100644
--- a/test/test-installation-components.js
+++ b/test/test-installation-components.js
@@ -32,12 +32,19 @@ const {
const {
HELP_SIDECAR_REQUIRED_FIELDS,
HELP_SIDECAR_ERROR_CODES,
+ SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
+ SHARD_DOC_SIDECAR_ERROR_CODES,
validateHelpSidecarContractFile,
+ validateShardDocSidecarContractFile,
} = require('../tools/cli/installers/lib/core/sidecar-contract-validator');
const {
HELP_FRONTMATTER_MISMATCH_ERROR_CODES,
validateHelpAuthoritySplitAndPrecedence,
} = require('../tools/cli/installers/lib/core/help-authority-validator');
+const {
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES,
+ validateShardDocAuthoritySplitAndPrecedence,
+} = require('../tools/cli/installers/lib/core/shard-doc-authority-validator');
const {
HELP_CATALOG_GENERATION_ERROR_CODES,
EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
@@ -62,6 +69,7 @@ const {
validateHelpCatalogCompatibilitySurface,
validateHelpCatalogLoaderEntries,
validateGithubCopilotHelpLoaderEntries,
+ validateCommandDocSurfaceConsistency,
} = require('../tools/cli/installers/lib/core/projection-compatibility-validator');
const {
WAVE1_VALIDATION_ERROR_CODES,
@@ -385,6 +393,202 @@ async function runTests() {
console.log('');
+ // ============================================================
+ // Test 4b: Wave-2 shard-doc Sidecar Contract Validation
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 4b: Wave-2 shard-doc Sidecar Contract Validation${colors.reset}\n`);
+
+ const validShardDocSidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-shard-doc',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const shardDocFixtureRoot = path.join(projectRoot, 'test', 'fixtures', 'wave-2', 'sidecar-negative');
+ const unknownMajorFixturePath = path.join(shardDocFixtureRoot, 'unknown-major-version', 'shard-doc.artifact.yaml');
+ const basenameMismatchFixturePath = path.join(shardDocFixtureRoot, 'basename-path-mismatch', 'shard-doc.artifact.yaml');
+
+ const tempShardDocRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-shard-doc-sidecar-'));
+ const tempShardDocSidecarPath = path.join(tempShardDocRoot, 'shard-doc.artifact.yaml');
+ const deterministicShardDocSourcePath = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
+
+ const writeTempShardDocSidecar = async (data) => {
+ await fs.writeFile(tempShardDocSidecarPath, yaml.stringify(data), 'utf8');
+ };
+
+ const expectShardDocValidationError = async (data, expectedCode, expectedFieldPath, testLabel, expectedDetail = null) => {
+ await writeTempShardDocSidecar(data);
+
+ try {
+ await validateShardDocSidecarContractFile(tempShardDocSidecarPath, { errorSourcePath: deterministicShardDocSourcePath });
+ assert(false, testLabel, 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === deterministicShardDocSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${deterministicShardDocSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(deterministicShardDocSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ if (expectedDetail !== null) {
+ assert(
+ error.detail === expectedDetail,
+ `${testLabel} returns locked detail string`,
+ `Expected "${expectedDetail}", got "${error.detail}"`,
+ );
+ }
+ }
+ };
+
+ try {
+ await writeTempShardDocSidecar(validShardDocSidecar);
+ await validateShardDocSidecarContractFile(tempShardDocSidecarPath, { errorSourcePath: deterministicShardDocSourcePath });
+ assert(true, 'Valid shard-doc sidecar contract passes');
+
+ for (const requiredField of SHARD_DOC_SIDECAR_REQUIRED_FIELDS.filter((field) => field !== 'dependencies')) {
+ const invalidSidecar = structuredClone(validShardDocSidecar);
+ delete invalidSidecar[requiredField];
+ await expectShardDocValidationError(
+ invalidSidecar,
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
+ requiredField,
+ `Shard-doc missing required field "${requiredField}"`,
+ );
+ }
+
+ const unknownMajorFixture = yaml.parse(await fs.readFile(unknownMajorFixturePath, 'utf8'));
+ await expectShardDocValidationError(
+ unknownMajorFixture,
+ SHARD_DOC_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ 'schemaVersion',
+ 'Shard-doc unsupported sidecar major schema version',
+ 'sidecar schema major version is unsupported',
+ );
+
+ const basenameMismatchFixture = yaml.parse(await fs.readFile(basenameMismatchFixturePath, 'utf8'));
+ await expectShardDocValidationError(
+ basenameMismatchFixture,
+ SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'sourcePath',
+ 'Shard-doc sourcePath mismatch',
+ 'sidecar basename does not match sourcePath basename',
+ );
+
+ const mismatchedShardDocBasenamePath = path.join(tempShardDocRoot, 'not-shard-doc.artifact.yaml');
+ await fs.writeFile(mismatchedShardDocBasenamePath, yaml.stringify(validShardDocSidecar), 'utf8');
+ try {
+ await validateShardDocSidecarContractFile(mismatchedShardDocBasenamePath, {
+ errorSourcePath: 'bmad-fork/src/core/tasks/not-shard-doc.artifact.yaml',
+ });
+ assert(false, 'Shard-doc basename mismatch returns validation error', 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'Shard-doc basename mismatch returns expected error code',
+ );
+ assert(
+ error.fieldPath === 'sourcePath',
+ 'Shard-doc basename mismatch returns expected field path',
+ `Expected sourcePath, got ${error.fieldPath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH) &&
+ error.message.includes('bmad-fork/src/core/tasks/not-shard-doc.artifact.yaml'),
+ 'Shard-doc basename mismatch includes deterministic message context',
+ );
+ }
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, artifactType: 'workflow' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
+ 'artifactType',
+ 'Shard-doc invalid artifactType',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, module: 'bmm' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.MODULE_INVALID,
+ 'module',
+ 'Shard-doc invalid module',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, canonicalId: ' ' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'canonicalId',
+ 'Shard-doc empty canonicalId',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, sourcePath: '' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'sourcePath',
+ 'Shard-doc empty sourcePath',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, description: '' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'description',
+ 'Shard-doc empty description',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, displayName: '' },
+ SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'displayName',
+ 'Shard-doc empty displayName',
+ );
+
+ const missingShardDocDependencies = structuredClone(validShardDocSidecar);
+ delete missingShardDocDependencies.dependencies;
+ await expectShardDocValidationError(
+ missingShardDocDependencies,
+ SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
+ 'dependencies',
+ 'Shard-doc missing dependencies block',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, dependencies: { requires: 'skill:bmad-help' } },
+ SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
+ 'dependencies.requires',
+ 'Shard-doc non-array dependencies.requires',
+ );
+
+ await expectShardDocValidationError(
+ { ...validShardDocSidecar, dependencies: { requires: ['skill:bmad-help'] } },
+ SHARD_DOC_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
+ 'dependencies.requires',
+ 'Shard-doc non-empty dependencies.requires',
+ );
+ } catch (error) {
+ assert(false, 'Wave-2 shard-doc sidecar validation suite setup', error.message);
+ } finally {
+ await fs.remove(tempShardDocRoot);
+ }
+
+ console.log('');
+
// ============================================================
// Test 5: Authority Split and Frontmatter Precedence
// ============================================================
@@ -576,6 +780,217 @@ async function runTests() {
deterministicAuthorityPaths.source,
'Source dependencies.requires mismatch',
);
+
+ const tempShardDocAuthoritySidecarPath = path.join(tempAuthorityRoot, 'shard-doc.artifact.yaml');
+ const tempShardDocAuthoritySourcePath = path.join(tempAuthorityRoot, 'shard-doc.xml');
+ const tempShardDocModuleHelpPath = path.join(tempAuthorityRoot, 'module-help.csv');
+
+ const deterministicShardDocAuthorityPaths = {
+ sidecar: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ source: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ compatibility: 'bmad-fork/src/core/module-help.csv',
+ workflowFile: '_bmad/core/tasks/shard-doc.xml',
+ };
+
+ const validShardDocAuthoritySidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-shard-doc',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: deterministicShardDocAuthorityPaths.source,
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const writeModuleHelpCsv = async (rows) => {
+ const header = 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs';
+ const lines = rows.map((row) =>
+ [
+ row.module ?? 'core',
+ row.phase ?? 'anytime',
+ row.name ?? 'Shard Document',
+ row.code ?? 'SD',
+ row.sequence ?? '',
+ row.workflowFile ?? '',
+ row.command ?? '',
+ row.required ?? 'false',
+ row.agent ?? '',
+ row.options ?? '',
+ row.description ?? 'Compatibility row',
+ row.outputLocation ?? '',
+ row.outputs ?? '',
+ ].join(','),
+ );
+
+ await fs.writeFile(tempShardDocModuleHelpPath, [header, ...lines].join('\n'), 'utf8');
+ };
+
+ const runShardDocAuthorityValidation = async () =>
+ validateShardDocAuthoritySplitAndPrecedence({
+ sidecarPath: tempShardDocAuthoritySidecarPath,
+ sourceXmlPath: tempShardDocAuthoritySourcePath,
+ compatibilityCatalogPath: tempShardDocModuleHelpPath,
+ sidecarSourcePath: deterministicShardDocAuthorityPaths.sidecar,
+ sourceXmlSourcePath: deterministicShardDocAuthorityPaths.source,
+ compatibilityCatalogSourcePath: deterministicShardDocAuthorityPaths.compatibility,
+ compatibilityWorkflowFilePath: deterministicShardDocAuthorityPaths.workflowFile,
+ });
+
+ const expectShardDocAuthorityValidationError = async (
+ rows,
+ expectedCode,
+ expectedFieldPath,
+ testLabel,
+ expectedSourcePath = deterministicShardDocAuthorityPaths.compatibility,
+ ) => {
+ await writeModuleHelpCsv(rows);
+
+ try {
+ await runShardDocAuthorityValidation();
+ assert(false, testLabel, 'Expected shard-doc authority validation error but validation passed');
+ } catch (error) {
+ assert(error.code === expectedCode, `${testLabel} returns expected error code`, `Expected ${expectedCode}, got ${error.code}`);
+ assert(
+ error.fieldPath === expectedFieldPath,
+ `${testLabel} returns expected field path`,
+ `Expected ${expectedFieldPath}, got ${error.fieldPath}`,
+ );
+ assert(
+ error.sourcePath === expectedSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${expectedSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(expectedSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ }
+ };
+
+ await fs.writeFile(tempShardDocAuthoritySidecarPath, yaml.stringify(validShardDocAuthoritySidecar), 'utf8');
+ await fs.writeFile(tempShardDocAuthoritySourcePath, '\n', 'utf8');
+
+ await writeModuleHelpCsv([
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ ]);
+
+ const shardDocAuthorityValidation = await runShardDocAuthorityValidation();
+ assert(
+ shardDocAuthorityValidation.authoritativePresenceKey === 'capability:bmad-shard-doc',
+ 'Shard-doc authority validation returns expected authoritative presence key',
+ );
+ assert(
+ Array.isArray(shardDocAuthorityValidation.authoritativeRecords) && shardDocAuthorityValidation.authoritativeRecords.length === 2,
+ 'Shard-doc authority validation returns sidecar and source authority records',
+ );
+
+ const shardDocSidecarRecord = shardDocAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'sidecar',
+ );
+ const shardDocSourceRecord = shardDocAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'source-xml',
+ );
+
+ assert(
+ shardDocSidecarRecord &&
+ shardDocSourceRecord &&
+ shardDocSidecarRecord.authoritativePresenceKey === shardDocSourceRecord.authoritativePresenceKey,
+ 'Shard-doc sidecar and source-xml records share one authoritative presence key',
+ );
+ assert(
+ shardDocSidecarRecord &&
+ shardDocSourceRecord &&
+ shardDocSidecarRecord.authoritativePresenceKey === 'capability:bmad-shard-doc' &&
+ shardDocSourceRecord.authoritativePresenceKey === 'capability:bmad-shard-doc',
+ 'Shard-doc authority records lock authoritative presence key to capability:bmad-shard-doc',
+ );
+ assert(
+ shardDocSidecarRecord && shardDocSidecarRecord.authoritySourcePath === deterministicShardDocAuthorityPaths.sidecar,
+ 'Shard-doc metadata authority record preserves sidecar source path',
+ );
+ assert(
+ shardDocSourceRecord && shardDocSourceRecord.authoritySourcePath === deterministicShardDocAuthorityPaths.source,
+ 'Shard-doc source-body authority record preserves source XML path',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'legacy-shard-doc',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'command',
+ 'Shard-doc compatibility command mismatch',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: '_bmad/core/tasks/help.md',
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
+ 'workflow-file',
+ 'Shard-doc missing compatibility row',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ {
+ workflowFile: '_bmad/core/tasks/another.xml',
+ command: 'bmad-shard-doc',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
+ 'command',
+ 'Shard-doc duplicate canonical command rows',
+ );
+
+ await fs.writeFile(
+ tempShardDocAuthoritySidecarPath,
+ yaml.stringify({
+ ...validShardDocAuthoritySidecar,
+ canonicalId: 'bmad-shard-doc-renamed',
+ }),
+ 'utf8',
+ );
+
+ await expectShardDocAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicShardDocAuthorityPaths.workflowFile,
+ command: 'bmad-shard-doc-renamed',
+ name: 'Shard Document',
+ },
+ ],
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'canonicalId',
+ 'Shard-doc canonicalId drift fails deterministic authority validation',
+ deterministicShardDocAuthorityPaths.sidecar,
+ );
+
+ await fs.writeFile(tempShardDocAuthoritySidecarPath, yaml.stringify(validShardDocAuthoritySidecar), 'utf8');
} catch (error) {
assert(false, 'Authority split and precedence suite setup', error.message);
} finally {
@@ -592,79 +1007,453 @@ async function runTests() {
const tempInstallerRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-installer-sidecar-failfast-'));
try {
- const installer = new Installer();
- let authorityValidationCalled = false;
- let generateConfigsCalled = false;
- let manifestGenerationCalled = false;
- let helpCatalogGenerationCalled = false;
- let successResultCount = 0;
+ // 6a: Existing help sidecar fail-fast behavior remains intact.
+ {
+ const installer = new Installer();
+ let shardDocValidationCalled = false;
+ let shardDocAuthorityValidationCalled = false;
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
- installer.validateHelpSidecarContractFile = async () => {
- const error = new Error(expectedUnsupportedMajorDetail);
- error.code = HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED;
- error.fieldPath = 'schemaVersion';
- error.detail = expectedUnsupportedMajorDetail;
- throw error;
- };
-
- installer.validateHelpAuthoritySplitAndPrecedence = async () => {
- authorityValidationCalled = true;
- return {
- authoritativeRecords: [],
- authoritativePresenceKey: 'capability:bmad-help',
+ installer.validateShardDocSidecarContractFile = async () => {
+ shardDocValidationCalled = true;
+ };
+ installer.validateHelpSidecarContractFile = async () => {
+ const error = new Error(expectedUnsupportedMajorDetail);
+ error.code = HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED;
+ error.fieldPath = 'schemaVersion';
+ error.detail = expectedUnsupportedMajorDetail;
+ throw error;
};
- };
- installer.generateModuleConfigs = async () => {
- generateConfigsCalled = true;
- };
-
- installer.mergeModuleHelpCatalogs = async () => {
- helpCatalogGenerationCalled = true;
- };
-
- installer.ManifestGenerator = class ManifestGeneratorStub {
- async generateManifests() {
- manifestGenerationCalled = true;
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ shardDocAuthorityValidationCalled = true;
return {
- workflows: 0,
- agents: 0,
- tasks: 0,
- tools: 0,
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
};
- }
- };
+ };
+
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(
+ false,
+ 'Installer fail-fast blocks projection generation on help sidecar validation failure',
+ 'Expected sidecar validation failure but configuration generation completed',
+ );
+ } catch (error) {
+ assert(
+ error.code === HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ 'Installer fail-fast surfaces help sidecar validation error code',
+ `Expected ${HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED}, got ${error.code}`,
+ );
+ assert(shardDocValidationCalled, 'Installer runs shard-doc sidecar validation before help sidecar validation');
+ assert(
+ !shardDocAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
+ 'Installer help fail-fast prevents downstream authority/config/manifest/help generation',
+ );
+ assert(
+ successResultCount === 0,
+ 'Installer help fail-fast records no successful projection milestones',
+ `Expected 0, got ${successResultCount}`,
+ );
+ }
+ }
+
+ // 6b: Shard-doc fail-fast covers Wave-2 negative matrix classes.
+ {
+ const deterministicShardDocFailFastSourcePath = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
+ const shardDocFailureScenarios = [
+ {
+ label: 'missing shard-doc sidecar file',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.FILE_NOT_FOUND,
+ fieldPath: '',
+ detail: 'Expected shard-doc sidecar file was not found.',
+ },
+ {
+ label: 'malformed shard-doc sidecar YAML',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.PARSE_FAILED,
+ fieldPath: '',
+ detail: 'YAML parse failure: malformed content',
+ },
+ {
+ label: 'missing shard-doc required field',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
+ fieldPath: 'canonicalId',
+ detail: 'Missing required sidecar field "canonicalId".',
+ },
+ {
+ label: 'empty shard-doc required field',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ fieldPath: 'canonicalId',
+ detail: 'Required sidecar field "canonicalId" must be a non-empty string.',
+ },
+ {
+ label: 'unsupported shard-doc sidecar major schema version',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ fieldPath: 'schemaVersion',
+ detail: expectedUnsupportedMajorDetail,
+ },
+ {
+ label: 'shard-doc sourcePath basename mismatch',
+ code: SHARD_DOC_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ fieldPath: 'sourcePath',
+ detail: expectedBasenameMismatchDetail,
+ },
+ ];
+
+ for (const scenario of shardDocFailureScenarios) {
+ const installer = new Installer();
+ let helpValidationCalled = false;
+ let shardDocAuthorityValidationCalled = false;
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
+
+ installer.validateShardDocSidecarContractFile = async () => {
+ const error = new Error(scenario.detail);
+ error.code = scenario.code;
+ error.fieldPath = scenario.fieldPath;
+ error.sourcePath = deterministicShardDocFailFastSourcePath;
+ error.detail = scenario.detail;
+ throw error;
+ };
+ installer.validateHelpSidecarContractFile = async () => {
+ helpValidationCalled = true;
+ };
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ shardDocAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ };
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(false, `Installer fail-fast blocks projection generation on ${scenario.label}`);
+ } catch (error) {
+ assert(error.code === scenario.code, `Installer ${scenario.label} returns deterministic error code`);
+ assert(error.fieldPath === scenario.fieldPath, `Installer ${scenario.label} returns deterministic field path`);
+ assert(
+ error.sourcePath === deterministicShardDocFailFastSourcePath,
+ `Installer ${scenario.label} returns deterministic source path`,
+ );
+ assert(!helpValidationCalled, `Installer ${scenario.label} aborts before help sidecar validation`);
+ assert(
+ !shardDocAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
+ `Installer ${scenario.label} prevents downstream authority/config/manifest/help generation`,
+ );
+ assert(successResultCount === 0, `Installer ${scenario.label} records no successful projection milestones`);
+ }
+ }
+ }
+
+ // 6c: Shard-doc authority precedence conflict fails fast before help authority or generation.
+ {
+ const installer = new Installer();
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
+
+ installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateHelpSidecarContractFile = async () => {};
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ const error = new Error('Converted shard-doc compatibility command must match sidecar canonicalId');
+ error.code = SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH;
+ error.fieldPath = 'command';
+ error.sourcePath = 'bmad-fork/src/core/module-help.csv';
+ throw error;
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(false, 'Installer shard-doc authority mismatch fails fast pre-projection');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'Installer shard-doc authority mismatch returns deterministic error code',
+ );
+ assert(error.fieldPath === 'command', 'Installer shard-doc authority mismatch returns deterministic field path');
+ assert(
+ error.sourcePath === 'bmad-fork/src/core/module-help.csv',
+ 'Installer shard-doc authority mismatch returns deterministic source path',
+ );
+ assert(
+ !helpAuthorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled,
+ 'Installer shard-doc authority mismatch blocks downstream help authority/config/manifest/help generation',
+ );
+ assert(
+ successResultCount === 2,
+ 'Installer shard-doc authority mismatch records only sidecar gate pass milestones before abort',
+ `Expected 2, got ${successResultCount}`,
+ );
+ }
+ }
+
+ // 6d: Shard-doc canonical drift fails fast before help authority or generation.
+ {
+ const installer = new Installer();
+ let helpAuthorityValidationCalled = false;
+ let generateConfigsCalled = false;
+ let manifestGenerationCalled = false;
+ let helpCatalogGenerationCalled = false;
+ let successResultCount = 0;
+
+ installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateHelpSidecarContractFile = async () => {};
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ const error = new Error('Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc');
+ error.code = SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH;
+ error.fieldPath = 'canonicalId';
+ error.sourcePath = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
+ throw error;
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ helpAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ generateConfigsCalled = true;
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ helpCatalogGenerationCalled = true;
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ manifestGenerationCalled = true;
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
+
+ try {
+ await installer.runConfigurationGenerationTask({
+ message: () => {},
+ bmadDir: tempInstallerRoot,
+ moduleConfigs: { core: {} },
+ config: { ides: [] },
+ allModules: ['core'],
+ addResult: () => {
+ successResultCount += 1;
+ },
+ });
+ assert(false, 'Installer shard-doc canonical drift fails fast pre-projection');
+ } catch (error) {
+ assert(
+ error.code === SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'Installer shard-doc canonical drift returns deterministic error code',
+ );
+ assert(error.fieldPath === 'canonicalId', 'Installer shard-doc canonical drift returns deterministic field path');
+ assert(
+ error.sourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ 'Installer shard-doc canonical drift returns deterministic source path',
+ );
+ assert(
+ !helpAuthorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled,
+ 'Installer shard-doc canonical drift blocks downstream help authority/config/manifest/help generation',
+ );
+ assert(
+ successResultCount === 2,
+ 'Installer shard-doc canonical drift records only sidecar gate pass milestones before abort',
+ `Expected 2, got ${successResultCount}`,
+ );
+ }
+ }
+
+ // 6e: Valid sidecars preserve fail-fast ordering and allow generation path.
+ {
+ const installer = new Installer();
+ const executionOrder = [];
+ const resultMilestones = [];
+
+ installer.validateShardDocSidecarContractFile = async () => {
+ executionOrder.push('shard-doc-sidecar');
+ };
+ installer.validateHelpSidecarContractFile = async () => {
+ executionOrder.push('help-sidecar');
+ };
+ installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
+ executionOrder.push('shard-doc-authority');
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ };
+ };
+ installer.validateHelpAuthoritySplitAndPrecedence = async () => {
+ executionOrder.push('help-authority');
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-help',
+ };
+ };
+ installer.generateModuleConfigs = async () => {
+ executionOrder.push('config-generation');
+ };
+ installer.mergeModuleHelpCatalogs = async () => {
+ executionOrder.push('help-catalog-generation');
+ };
+ installer.ManifestGenerator = class ManifestGeneratorStub {
+ async generateManifests() {
+ executionOrder.push('manifest-generation');
+ return {
+ workflows: 0,
+ agents: 0,
+ tasks: 0,
+ tools: 0,
+ };
+ }
+ };
- try {
await installer.runConfigurationGenerationTask({
message: () => {},
bmadDir: tempInstallerRoot,
moduleConfigs: { core: {} },
config: { ides: [] },
allModules: ['core'],
- addResult: () => {
- successResultCount += 1;
+ addResult: (name) => {
+ resultMilestones.push(name);
},
});
+
assert(
- false,
- 'Installer fail-fast blocks projection generation on sidecar validation failure',
- 'Expected sidecar validation failure but configuration generation completed',
- );
- } catch (error) {
- assert(
- error.code === HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
- 'Installer fail-fast surfaces sidecar validation error code',
- `Expected ${HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED}, got ${error.code}`,
+ executionOrder.join(' -> ') ===
+ 'shard-doc-sidecar -> help-sidecar -> shard-doc-authority -> help-authority -> config-generation -> manifest-generation -> help-catalog-generation',
+ 'Installer valid sidecar path preserves fail-fast gate ordering and continues generation flow',
+ `Observed order: ${executionOrder.join(' -> ')}`,
);
assert(
- !authorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled,
- 'Installer fail-fast prevents downstream authority/config/manifest/help generation',
+ resultMilestones.includes('Shard-doc sidecar contract'),
+ 'Installer valid sidecar path records explicit shard-doc sidecar gate pass milestone',
);
assert(
- successResultCount === 0,
- 'Installer fail-fast records no successful projection milestones',
- `Expected 0, got ${successResultCount}`,
+ resultMilestones.includes('Shard-doc authority split'),
+ 'Installer valid sidecar path records explicit shard-doc authority gate pass milestone',
);
}
} catch (error) {
@@ -950,6 +1739,78 @@ async function runTests() {
'alias tuple resolved ambiguously to multiple canonical alias rows',
);
+ const shardDocAliasRows = [
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ },
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ },
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: true,
+ },
+ ];
+
+ const shardDocSlashResolution = await normalizeAndResolveExemplarAlias('/bmad-shard-doc', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ aliasRows: shardDocAliasRows,
+ aliasTableSourcePath: deterministicAliasTableSourcePath,
+ });
+ assert(
+ shardDocSlashResolution.postAliasCanonicalId === 'bmad-shard-doc' &&
+ shardDocSlashResolution.aliasRowLocator === 'alias-row:bmad-shard-doc:slash-command',
+ 'Alias resolver normalizes shard-doc slash-command tuple with explicit shard-doc alias rows',
+ );
+
+ await expectAliasNormalizationError(
+ () =>
+ normalizeAndResolveExemplarAlias('/bmad-shard-doc', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ aliasRows: LOCKED_EXEMPLAR_ALIAS_ROWS,
+ aliasTableSourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'preAliasNormalizedValue',
+ 'bmad-shard-doc|leadingSlash:true',
+ 'Shard-doc alias tuple unresolved without shard-doc alias table rows',
+ 'alias tuple did not resolve to any canonical alias row',
+ );
+
+ const ambiguousShardDocRows = [
+ ...shardDocAliasRows,
+ {
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command:duplicate',
+ canonicalId: 'bmad-shard-doc-alt',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: true,
+ },
+ ];
+ await expectAliasNormalizationError(
+ () =>
+ normalizeAndResolveExemplarAlias('/bmad-shard-doc', {
+ fieldPath: 'canonicalId',
+ sourcePath: deterministicAliasTableSourcePath,
+ aliasRows: ambiguousShardDocRows,
+ aliasTableSourcePath: deterministicAliasTableSourcePath,
+ }),
+ HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
+ 'preAliasNormalizedValue',
+ 'bmad-shard-doc|leadingSlash:true',
+ 'Shard-doc alias tuple ambiguous when duplicate shard-doc slash-command rows exist',
+ 'alias tuple resolved ambiguously to multiple canonical alias rows',
+ );
+
const tempAliasTableRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-canonical-alias-table-'));
const tempAliasTablePath = path.join(tempAliasTableRoot, 'canonical-aliases.csv');
const csvRows = [
@@ -1060,6 +1921,14 @@ async function runTests() {
path: 'core/tasks/validate-workflow.xml',
standalone: true,
},
+ {
+ name: 'shard-doc',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ module: 'core',
+ path: 'core/tasks/shard-doc.xml',
+ standalone: true,
+ },
];
manifestGenerator.helpAuthorityRecords = [
{
@@ -1071,7 +1940,17 @@ async function runTests() {
sourcePath: 'bmad-fork/src/core/tasks/help.md',
},
];
-
+ manifestGenerator.taskAuthorityRecords = [
+ ...manifestGenerator.helpAuthorityRecords,
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ },
+ ];
const tempTaskManifestConfigDir = path.join(tempTaskManifestRoot, '_config');
await fs.ensureDir(tempTaskManifestConfigDir);
await manifestGenerator.writeTaskManifest(tempTaskManifestConfigDir);
@@ -1093,6 +1972,7 @@ async function runTests() {
});
const helpTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'help');
const validateTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'validate-workflow');
+ const shardDocTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'shard-doc');
assert(!!helpTaskRow, 'Task manifest includes exemplar help row');
assert(helpTaskRow && helpTaskRow.legacyName === 'help', 'Task manifest help row sets legacyName=help');
@@ -1108,13 +1988,61 @@ async function runTests() {
validateTaskRow && validateTaskRow.legacyName === 'validate-workflow',
'Task manifest non-exemplar rows remain additive-compatible with default legacyName',
);
+ assert(!!shardDocTaskRow, 'Task manifest includes converted shard-doc row');
+ assert(shardDocTaskRow && shardDocTaskRow.legacyName === 'shard-doc', 'Task manifest shard-doc row sets legacyName=shard-doc');
+ assert(
+ shardDocTaskRow && shardDocTaskRow.canonicalId === 'bmad-shard-doc',
+ 'Task manifest shard-doc row sets canonicalId=bmad-shard-doc',
+ );
+ assert(
+ shardDocTaskRow && shardDocTaskRow.authoritySourceType === 'sidecar',
+ 'Task manifest shard-doc row sets authoritySourceType=sidecar',
+ );
+ assert(
+ shardDocTaskRow && shardDocTaskRow.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ 'Task manifest shard-doc row sets authoritySourcePath to shard-doc sidecar source path',
+ );
+
+ await manifestGenerator.writeTaskManifest(tempTaskManifestConfigDir);
+ const repeatedTaskManifestRaw = await fs.readFile(path.join(tempTaskManifestConfigDir, 'task-manifest.csv'), 'utf8');
+ assert(
+ repeatedTaskManifestRaw === writtenTaskManifestRaw,
+ 'Task manifest shard-doc canonical row values remain deterministic across repeated generation runs',
+ );
let capturedAuthorityValidationOptions = null;
+ let capturedShardDocAuthorityValidationOptions = null;
let capturedManifestHelpAuthorityRecords = null;
+ let capturedManifestTaskAuthorityRecords = null;
let capturedInstalledFiles = null;
const installer = new Installer();
+ installer.validateShardDocSidecarContractFile = async () => {};
installer.validateHelpSidecarContractFile = async () => {};
+ installer.validateShardDocAuthoritySplitAndPrecedence = async (options) => {
+ capturedShardDocAuthorityValidationOptions = options;
+ return {
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritativeRecords: [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: options.sidecarSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: options.sourceXmlSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ ],
+ };
+ };
installer.validateHelpAuthoritySplitAndPrecedence = async (options) => {
capturedAuthorityValidationOptions = options;
return {
@@ -1137,6 +2065,7 @@ async function runTests() {
async generateManifests(_bmadDir, _selectedModules, _installedFiles, options = {}) {
capturedInstalledFiles = _installedFiles;
capturedManifestHelpAuthorityRecords = options.helpAuthorityRecords;
+ capturedManifestTaskAuthorityRecords = options.taskAuthorityRecords;
return {
workflows: 0,
agents: 0,
@@ -1169,12 +2098,38 @@ async function runTests() {
capturedAuthorityValidationOptions && capturedAuthorityValidationOptions.runtimeMarkdownSourcePath === '_bmad/core/tasks/help.md',
'Installer passes locked runtime markdown path to authority validation',
);
+ assert(
+ capturedShardDocAuthorityValidationOptions &&
+ capturedShardDocAuthorityValidationOptions.sidecarSourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ 'Installer passes locked shard-doc sidecar source path to shard-doc authority validation',
+ );
+ assert(
+ capturedShardDocAuthorityValidationOptions &&
+ capturedShardDocAuthorityValidationOptions.sourceXmlSourcePath === 'bmad-fork/src/core/tasks/shard-doc.xml',
+ 'Installer passes locked shard-doc source XML path to shard-doc authority validation',
+ );
+ assert(
+ capturedShardDocAuthorityValidationOptions &&
+ capturedShardDocAuthorityValidationOptions.compatibilityCatalogSourcePath === 'bmad-fork/src/core/module-help.csv',
+ 'Installer passes locked module-help source path to shard-doc authority validation',
+ );
assert(
Array.isArray(capturedManifestHelpAuthorityRecords) &&
capturedManifestHelpAuthorityRecords[0] &&
capturedManifestHelpAuthorityRecords[0].authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml',
'Installer passes sidecar authority path into manifest generation options',
);
+ assert(
+ Array.isArray(capturedManifestTaskAuthorityRecords) &&
+ capturedManifestTaskAuthorityRecords.some(
+ (record) =>
+ record &&
+ record.canonicalId === 'bmad-shard-doc' &&
+ record.authoritySourceType === 'sidecar' &&
+ record.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ ),
+ 'Installer passes shard-doc sidecar authority records into task-manifest projection options',
+ );
assert(
Array.isArray(capturedInstalledFiles) &&
capturedInstalledFiles.some((filePath) => filePath.endsWith('/_config/canonical-aliases.csv')),
@@ -1208,6 +2163,17 @@ async function runTests() {
sourcePath: 'bmad-fork/src/core/tasks/help.md',
},
];
+ manifestGenerator.taskAuthorityRecords = [
+ ...manifestGenerator.helpAuthorityRecords,
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-shard-doc',
+ authoritativePresenceKey: 'capability:bmad-shard-doc',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ },
+ ];
const tempCanonicalAliasConfigDir = path.join(tempCanonicalAliasRoot, '_config');
await fs.ensureDir(tempCanonicalAliasConfigDir);
@@ -1227,96 +2193,142 @@ async function runTests() {
skip_empty_lines: true,
trim: true,
});
- assert(canonicalAliasRows.length === 3, 'Canonical alias table emits exactly three exemplar rows');
+ assert(canonicalAliasRows.length === 6, 'Canonical alias table emits help + shard-doc canonical alias exemplar rows');
assert(
- canonicalAliasRows.map((row) => row.aliasType).join(',') === 'canonical-id,legacy-name,slash-command',
+ canonicalAliasRows.map((row) => row.aliasType).join(',') ===
+ 'canonical-id,legacy-name,slash-command,canonical-id,legacy-name,slash-command',
'Canonical alias table preserves locked deterministic row ordering',
);
- const expectedRowsByType = new Map([
+ const expectedRowsByIdentity = new Map([
[
- 'canonical-id',
+ 'alias-row:bmad-help:canonical-id',
{
canonicalId: 'bmad-help',
alias: 'bmad-help',
- rowIdentity: 'alias-row:bmad-help:canonical-id',
+ aliasType: 'canonical-id',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.yaml',
normalizedAliasValue: 'bmad-help',
rawIdentityHasLeadingSlash: 'false',
resolutionEligibility: 'canonical-id-only',
},
],
[
- 'legacy-name',
+ 'alias-row:bmad-help:legacy-name',
{
canonicalId: 'bmad-help',
alias: 'help',
- rowIdentity: 'alias-row:bmad-help:legacy-name',
+ aliasType: 'legacy-name',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.yaml',
normalizedAliasValue: 'help',
rawIdentityHasLeadingSlash: 'false',
resolutionEligibility: 'legacy-name-only',
},
],
[
- 'slash-command',
+ 'alias-row:bmad-help:slash-command',
{
canonicalId: 'bmad-help',
alias: '/bmad-help',
- rowIdentity: 'alias-row:bmad-help:slash-command',
+ aliasType: 'slash-command',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/help.artifact.yaml',
normalizedAliasValue: 'bmad-help',
rawIdentityHasLeadingSlash: 'true',
resolutionEligibility: 'slash-command-only',
},
],
+ [
+ 'alias-row:bmad-shard-doc:canonical-id',
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: 'bmad-shard-doc',
+ aliasType: 'canonical-id',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'canonical-id-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-shard-doc:legacy-name',
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: 'shard-doc',
+ aliasType: 'legacy-name',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'legacy-name-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-shard-doc:slash-command',
+ {
+ canonicalId: 'bmad-shard-doc',
+ alias: '/bmad-shard-doc',
+ aliasType: 'slash-command',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: 'true',
+ resolutionEligibility: 'slash-command-only',
+ },
+ ],
]);
- for (const [aliasType, expectedRow] of expectedRowsByType) {
- const matchingRows = canonicalAliasRows.filter((row) => row.aliasType === aliasType);
- assert(matchingRows.length === 1, `Canonical alias table emits exactly one ${aliasType} exemplar row`);
+ for (const [rowIdentity, expectedRow] of expectedRowsByIdentity) {
+ const matchingRows = canonicalAliasRows.filter((row) => row.rowIdentity === rowIdentity);
+ assert(matchingRows.length === 1, `Canonical alias table emits exactly one ${rowIdentity} exemplar row`);
const row = matchingRows[0];
assert(
- row && row.authoritySourceType === 'sidecar' && row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml',
- `${aliasType} exemplar row uses sidecar provenance fields`,
+ row && row.authoritySourceType === 'sidecar' && row.authoritySourcePath === expectedRow.authoritySourcePath,
+ `${rowIdentity} exemplar row uses locked sidecar provenance`,
);
- assert(row && row.canonicalId === expectedRow.canonicalId, `${aliasType} exemplar row locks canonicalId contract`);
- assert(row && row.alias === expectedRow.alias, `${aliasType} exemplar row locks alias contract`);
- assert(row && row.rowIdentity === expectedRow.rowIdentity, `${aliasType} exemplar row locks rowIdentity contract`);
+ assert(row && row.canonicalId === expectedRow.canonicalId, `${rowIdentity} exemplar row locks canonicalId contract`);
+ assert(row && row.alias === expectedRow.alias, `${rowIdentity} exemplar row locks alias contract`);
+ assert(row && row.aliasType === expectedRow.aliasType, `${rowIdentity} exemplar row locks aliasType contract`);
+ assert(row && row.rowIdentity === rowIdentity, `${rowIdentity} exemplar row locks rowIdentity contract`);
assert(
row && row.normalizedAliasValue === expectedRow.normalizedAliasValue,
- `${aliasType} exemplar row locks normalizedAliasValue contract`,
+ `${rowIdentity} exemplar row locks normalizedAliasValue contract`,
);
assert(
row && row.rawIdentityHasLeadingSlash === expectedRow.rawIdentityHasLeadingSlash,
- `${aliasType} exemplar row locks rawIdentityHasLeadingSlash contract`,
+ `${rowIdentity} exemplar row locks rawIdentityHasLeadingSlash contract`,
);
assert(
row && row.resolutionEligibility === expectedRow.resolutionEligibility,
- `${aliasType} exemplar row locks resolutionEligibility contract`,
+ `${rowIdentity} exemplar row locks resolutionEligibility contract`,
);
}
const validateLockedCanonicalAliasProjection = (rows) => {
- for (const [aliasType, expectedRow] of expectedRowsByType) {
- const matchingRows = rows.filter((row) => row.canonicalId === 'bmad-help' && row.aliasType === aliasType);
+ for (const [rowIdentity, expectedRow] of expectedRowsByIdentity) {
+ const matchingRows = rows.filter((row) => row.rowIdentity === rowIdentity);
if (matchingRows.length === 0) {
- return { valid: false, reason: `missing:${aliasType}` };
+ return { valid: false, reason: `missing:${rowIdentity}` };
}
if (matchingRows.length > 1) {
- return { valid: false, reason: `conflict:${aliasType}` };
+ return { valid: false, reason: `conflict:${rowIdentity}` };
}
const row = matchingRows[0];
if (
- row.rowIdentity !== expectedRow.rowIdentity ||
+ row.canonicalId !== expectedRow.canonicalId ||
+ row.alias !== expectedRow.alias ||
+ row.aliasType !== expectedRow.aliasType ||
+ row.authoritySourceType !== 'sidecar' ||
+ row.authoritySourcePath !== expectedRow.authoritySourcePath ||
+ row.rowIdentity !== rowIdentity ||
row.normalizedAliasValue !== expectedRow.normalizedAliasValue ||
row.rawIdentityHasLeadingSlash !== expectedRow.rawIdentityHasLeadingSlash ||
row.resolutionEligibility !== expectedRow.resolutionEligibility
) {
- return { valid: false, reason: `conflict:${aliasType}` };
+ return { valid: false, reason: `conflict:${rowIdentity}` };
}
}
- if (rows.length !== expectedRowsByType.size) {
+ if (rows.length !== expectedRowsByIdentity.size) {
return { valid: false, reason: 'conflict:extra-rows' };
}
@@ -1330,23 +2342,22 @@ async function runTests() {
baselineProjectionValidation.reason,
);
- const missingLegacyRows = canonicalAliasRows.filter((row) => row.aliasType !== 'legacy-name');
+ const missingLegacyRows = canonicalAliasRows.filter((row) => row.rowIdentity !== 'alias-row:bmad-shard-doc:legacy-name');
const missingLegacyValidation = validateLockedCanonicalAliasProjection(missingLegacyRows);
assert(
- !missingLegacyValidation.valid && missingLegacyValidation.reason === 'missing:legacy-name',
- 'Canonical alias projection validator fails when required legacy-name row is missing',
+ !missingLegacyValidation.valid && missingLegacyValidation.reason === 'missing:alias-row:bmad-shard-doc:legacy-name',
+ 'Canonical alias projection validator fails when required shard-doc legacy-name row is missing',
);
const conflictingRows = [
...canonicalAliasRows,
{
- ...canonicalAliasRows.find((row) => row.aliasType === 'slash-command'),
- rowIdentity: 'alias-row:bmad-help:slash-command:duplicate',
+ ...canonicalAliasRows.find((row) => row.rowIdentity === 'alias-row:bmad-help:slash-command'),
},
];
const conflictingValidation = validateLockedCanonicalAliasProjection(conflictingRows);
assert(
- !conflictingValidation.valid && conflictingValidation.reason === 'conflict:slash-command',
+ !conflictingValidation.valid && conflictingValidation.reason === 'conflict:alias-row:bmad-help:slash-command',
'Canonical alias projection validator fails when conflicting duplicate exemplar rows appear',
);
@@ -1354,6 +2365,8 @@ async function runTests() {
fallbackManifestGenerator.bmadDir = tempCanonicalAliasRoot;
fallbackManifestGenerator.bmadFolderName = '_bmad';
fallbackManifestGenerator.helpAuthorityRecords = [];
+ fallbackManifestGenerator.taskAuthorityRecords = [];
+ fallbackManifestGenerator.includeConvertedShardDocAliasRows = true;
const fallbackCanonicalAliasPath = await fallbackManifestGenerator.writeCanonicalAliasManifest(tempCanonicalAliasConfigDir);
const fallbackCanonicalAliasRaw = await fs.readFile(fallbackCanonicalAliasPath, 'utf8');
const fallbackCanonicalAliasRows = csv.parse(fallbackCanonicalAliasRaw, {
@@ -1362,9 +2375,18 @@ async function runTests() {
trim: true,
});
assert(
- fallbackCanonicalAliasRows.every(
- (row) => row.authoritySourceType === 'sidecar' && row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml',
- ),
+ fallbackCanonicalAliasRows.every((row) => {
+ if (row.authoritySourceType !== 'sidecar') {
+ return false;
+ }
+ if (row.canonicalId === 'bmad-help') {
+ return row.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml';
+ }
+ if (row.canonicalId === 'bmad-shard-doc') {
+ return row.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
+ }
+ return false;
+ }),
'Canonical alias table falls back to locked sidecar provenance when authority records are unavailable',
);
@@ -1378,6 +2400,7 @@ async function runTests() {
ides: [],
preservedModules: [],
helpAuthorityRecords: manifestGenerator.helpAuthorityRecords,
+ taskAuthorityRecords: manifestGenerator.taskAuthorityRecords,
},
);
@@ -1477,11 +2500,17 @@ async function runTests() {
});
const exemplarRows = generatedHelpRows.filter((row) => row.command === 'bmad-help');
+ const shardDocRows = generatedHelpRows.filter((row) => row.command === 'bmad-shard-doc');
assert(exemplarRows.length === 1, 'Help catalog emits exactly one exemplar raw command row for bmad-help');
assert(
exemplarRows[0] && exemplarRows[0].name === 'bmad-help',
'Help catalog exemplar row preserves locked bmad-help workflow identity',
);
+ assert(shardDocRows.length === 1, 'Help catalog emits exactly one shard-doc raw command row for bmad-shard-doc');
+ assert(
+ shardDocRows[0] && shardDocRows[0]['workflow-file'] === '_bmad/core/tasks/shard-doc.xml',
+ 'Help catalog shard-doc row preserves locked shard-doc workflow identity',
+ );
const sidecarRaw = await fs.readFile(path.join(projectRoot, 'src', 'core', 'tasks', 'help.artifact.yaml'), 'utf8');
const sidecarData = yaml.parse(sidecarRaw);
@@ -1491,30 +2520,50 @@ async function runTests() {
);
const commandLabelRows = installer.helpCatalogCommandLabelReportRows || [];
- assert(commandLabelRows.length === 1, 'Installer emits one command-label report row for exemplar canonical id');
+ const helpCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-help');
+ const shardDocCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-shard-doc');
+ assert(commandLabelRows.length === 2, 'Installer emits command-label report rows for help and shard-doc canonical ids');
assert(
- commandLabelRows[0] &&
- commandLabelRows[0].rawCommandValue === 'bmad-help' &&
- commandLabelRows[0].displayedCommandLabel === '/bmad-help',
+ helpCommandLabelRow &&
+ helpCommandLabelRow.rawCommandValue === 'bmad-help' &&
+ helpCommandLabelRow.displayedCommandLabel === '/bmad-help',
'Command-label report locks raw and displayed command values for exemplar',
);
assert(
- commandLabelRows[0] &&
- commandLabelRows[0].authoritySourceType === 'sidecar' &&
- commandLabelRows[0].authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml',
+ helpCommandLabelRow &&
+ helpCommandLabelRow.authoritySourceType === 'sidecar' &&
+ helpCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/help.artifact.yaml',
'Command-label report includes sidecar provenance linkage',
);
+ assert(
+ shardDocCommandLabelRow &&
+ shardDocCommandLabelRow.rawCommandValue === 'bmad-shard-doc' &&
+ shardDocCommandLabelRow.displayedCommandLabel === '/bmad-shard-doc',
+ 'Command-label report locks raw and displayed command values for shard-doc',
+ );
+ assert(
+ shardDocCommandLabelRow &&
+ shardDocCommandLabelRow.authoritySourceType === 'sidecar' &&
+ shardDocCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ 'Command-label report includes shard-doc sidecar provenance linkage',
+ );
const generatedCommandLabelReportRaw = await fs.readFile(generatedCommandLabelReportPath, 'utf8');
const generatedCommandLabelReportRows = csv.parse(generatedCommandLabelReportRaw, {
columns: true,
skip_empty_lines: true,
trim: true,
});
+ const generatedHelpCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-help');
+ const generatedShardDocCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-shard-doc');
assert(
- generatedCommandLabelReportRows.length === 1 &&
- generatedCommandLabelReportRows[0].displayedCommandLabel === '/bmad-help' &&
- generatedCommandLabelReportRows[0].rowCountForCanonicalId === '1',
- 'Installer persists command-label report artifact with locked exemplar label contract values',
+ generatedCommandLabelReportRows.length === 2 &&
+ generatedHelpCommandLabelRow &&
+ generatedHelpCommandLabelRow.displayedCommandLabel === '/bmad-help' &&
+ generatedHelpCommandLabelRow.rowCountForCanonicalId === '1' &&
+ generatedShardDocCommandLabelRow &&
+ generatedShardDocCommandLabelRow.displayedCommandLabel === '/bmad-shard-doc' &&
+ generatedShardDocCommandLabelRow.rowCountForCanonicalId === '1',
+ 'Installer persists command-label report artifact with locked help and shard-doc label contract values',
);
const baselineLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows);
@@ -1523,10 +2572,100 @@ async function runTests() {
'Command-label validator passes when exactly one exemplar /bmad-help displayed label row exists',
baselineLabelContract.reason,
);
+ const baselineShardDocLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows, {
+ canonicalId: 'bmad-shard-doc',
+ displayedCommandLabel: '/bmad-shard-doc',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ });
+ assert(
+ baselineShardDocLabelContract.valid,
+ 'Command-label validator passes when exactly one /bmad-shard-doc displayed label row exists',
+ baselineShardDocLabelContract.reason,
+ );
+
+ const commandDocsSourcePath = path.join(projectRoot, 'docs', 'reference', 'commands.md');
+ const commandDocsMarkdown = await fs.readFile(commandDocsSourcePath, 'utf8');
+ const commandDocConsistency = validateCommandDocSurfaceConsistency(commandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows,
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(
+ commandDocConsistency.generatedCanonicalCommand === '/bmad-shard-doc',
+ 'Command-doc consistency validator passes when generated shard-doc command matches command docs canonical label',
+ );
+
+ const missingCanonicalCommandDocsMarkdown = commandDocsMarkdown.replace(
+ '| `/bmad-shard-doc` | Split a large markdown file into smaller sections |',
+ '| `/bmad-shard-doc-renamed` | Split a large markdown file into smaller sections |',
+ );
+ try {
+ validateCommandDocSurfaceConsistency(missingCanonicalCommandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows,
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(false, 'Command-doc consistency validator rejects missing canonical shard-doc command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING,
+ 'Command-doc consistency validator emits deterministic diagnostics for missing canonical shard-doc command docs row',
+ `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING}, got ${error.code}`,
+ );
+ }
+
+ const aliasAmbiguousCommandDocsMarkdown = `${commandDocsMarkdown}\n| \`/shard-doc\` | Legacy alias |\n`;
+ try {
+ validateCommandDocSurfaceConsistency(aliasAmbiguousCommandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows,
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(false, 'Command-doc consistency validator rejects shard-doc alias ambiguity in command docs');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS,
+ 'Command-doc consistency validator emits deterministic diagnostics for shard-doc alias ambiguity in command docs',
+ `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS}, got ${error.code}`,
+ );
+ }
+
+ try {
+ validateCommandDocSurfaceConsistency(commandDocsMarkdown, {
+ sourcePath: 'docs/reference/commands.md',
+ generatedSurfacePath: '_bmad/_config/bmad-help-command-label-report.csv',
+ commandLabelRows: [
+ helpCommandLabelRow,
+ {
+ ...shardDocCommandLabelRow,
+ displayedCommandLabel: '/shard-doc',
+ },
+ ],
+ canonicalId: 'bmad-shard-doc',
+ expectedDisplayedCommandLabel: '/bmad-shard-doc',
+ disallowedAliasLabels: ['/shard-doc'],
+ });
+ assert(false, 'Command-doc consistency validator rejects generated shard-doc command-label drift');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH,
+ 'Command-doc consistency validator emits deterministic diagnostics for generated shard-doc command-label drift',
+ `Expected ${PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH}, got ${error.code}`,
+ );
+ }
const invalidLegacyLabelContract = evaluateExemplarCommandLabelReportRows([
{
- ...commandLabelRows[0],
+ ...helpCommandLabelRow,
displayedCommandLabel: 'help',
},
]);
@@ -1537,7 +2676,7 @@ async function runTests() {
const invalidSlashHelpLabelContract = evaluateExemplarCommandLabelReportRows([
{
- ...commandLabelRows[0],
+ ...helpCommandLabelRow,
displayedCommandLabel: '/help',
},
]);
@@ -1546,6 +2685,25 @@ async function runTests() {
'Command-label validator fails on alternate displayed label form "/help"',
);
+ const invalidShardDocLabelContract = evaluateExemplarCommandLabelReportRows(
+ [
+ helpCommandLabelRow,
+ {
+ ...shardDocCommandLabelRow,
+ displayedCommandLabel: '/shard-doc',
+ },
+ ],
+ {
+ canonicalId: 'bmad-shard-doc',
+ displayedCommandLabel: '/bmad-shard-doc',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
+ },
+ );
+ assert(
+ !invalidShardDocLabelContract.valid && invalidShardDocLabelContract.reason === 'invalid-displayed-label:/shard-doc',
+ 'Command-label validator fails on alternate shard-doc displayed label form "/shard-doc"',
+ );
+
const pipelineRows = installer.helpCatalogPipelineRows || [];
assert(pipelineRows.length === 2, 'Installer emits two stage rows for help catalog pipeline evidence linkage');
const installedStageRow = pipelineRows.find((row) => row.stage === 'installed-compatibility-row');
@@ -1650,6 +2808,20 @@ async function runTests() {
}),
'utf8',
);
+ await fs.writeFile(
+ path.join(tempExportRoot, 'bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'bmad-shard-doc',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
const exemplarTaskArtifact = {
type: 'task',
@@ -1659,6 +2831,14 @@ async function runTests() {
relativePath: path.join('core', 'tasks', 'help.md'),
content: '---\nname: help\ndescription: Help command\ncanonicalId: bmad-help\n---\n\n# help\n',
};
+ const shardDocTaskArtifact = {
+ type: 'task',
+ name: 'shard-doc',
+ module: 'core',
+ sourcePath: path.join(tempExportRoot, '_bmad', 'core', 'tasks', 'shard-doc.xml'),
+ relativePath: path.join('core', 'tasks', 'shard-doc.md'),
+ content: 'Split markdown docs\n',
+ };
const writtenCount = await codexSetup.writeSkillArtifacts(skillsDir, [exemplarTaskArtifact], 'task', {
projectDir: tempExportRoot,
@@ -1688,6 +2868,64 @@ async function runTests() {
'Codex export records exemplar derivation source metadata from sidecar canonical-id',
);
+ const shardDocWrittenCount = await codexSetup.writeSkillArtifacts(skillsDir, [shardDocTaskArtifact], 'task', {
+ projectDir: tempExportRoot,
+ });
+ assert(shardDocWrittenCount === 1, 'Codex export writes one shard-doc converted skill artifact');
+
+ const shardDocSkillPath = path.join(skillsDir, 'bmad-shard-doc', 'SKILL.md');
+ assert(await fs.pathExists(shardDocSkillPath), 'Codex export derives shard-doc skill path from sidecar canonical identity');
+
+ const shardDocSkillRaw = await fs.readFile(shardDocSkillPath, 'utf8');
+ const shardDocFrontmatterMatch = shardDocSkillRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ const shardDocFrontmatter = shardDocFrontmatterMatch ? yaml.parse(shardDocFrontmatterMatch[1]) : null;
+ assert(
+ shardDocFrontmatter && shardDocFrontmatter.name === 'bmad-shard-doc',
+ 'Codex export frontmatter sets shard-doc required name from sidecar canonical identity',
+ );
+
+ const shardDocExportDerivationRecord = codexSetup.exportDerivationRecords.find(
+ (row) => row.exportPath === '.agents/skills/bmad-shard-doc/SKILL.md',
+ );
+ assert(
+ shardDocExportDerivationRecord &&
+ shardDocExportDerivationRecord.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE &&
+ shardDocExportDerivationRecord.exportIdDerivationSourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml' &&
+ shardDocExportDerivationRecord.sourcePath === 'bmad-fork/src/core/tasks/shard-doc.xml',
+ 'Codex export records shard-doc sidecar-canonical derivation metadata and source path',
+ );
+
+ const duplicateExportSetup = new CodexSetup();
+ const duplicateSkillDir = path.join(tempExportRoot, '.agents', 'skills-duplicate-check');
+ await fs.ensureDir(duplicateSkillDir);
+ try {
+ await duplicateExportSetup.writeSkillArtifacts(
+ duplicateSkillDir,
+ [
+ shardDocTaskArtifact,
+ {
+ ...shardDocTaskArtifact,
+ content: 'Duplicate shard-doc export artifact\n',
+ },
+ ],
+ 'task',
+ {
+ projectDir: tempExportRoot,
+ },
+ );
+ assert(
+ false,
+ 'Codex export rejects duplicate shard-doc canonical-id skill export surfaces',
+ 'Expected duplicate export-surface failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE,
+ 'Codex export duplicate shard-doc canonical-id rejection returns deterministic failure code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE}, got ${error.code}`,
+ );
+ }
+
const tempSubmoduleRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-submodule-root-'));
try {
const submoduleRootSetup = new CodexSetup();
@@ -1792,6 +3030,47 @@ async function runTests() {
await fs.remove(tempInferenceRoot);
}
+ const tempShardDocInferenceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-no-shard-doc-inference-'));
+ try {
+ const noShardDocInferenceSetup = new CodexSetup();
+ const noShardDocInferenceSkillDir = path.join(tempShardDocInferenceRoot, '.agents', 'skills');
+ await fs.ensureDir(noShardDocInferenceSkillDir);
+ await fs.ensureDir(path.join(tempShardDocInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks'));
+ await fs.writeFile(
+ path.join(tempShardDocInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'nonexistent-shard-doc-id',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
+ displayName: 'Shard Document',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+
+ try {
+ await noShardDocInferenceSetup.writeSkillArtifacts(noShardDocInferenceSkillDir, [shardDocTaskArtifact], 'task', {
+ projectDir: tempShardDocInferenceRoot,
+ });
+ assert(
+ false,
+ 'Codex export rejects path-inferred shard-doc id when sidecar canonical-id derivation is unresolved',
+ 'Expected shard-doc canonical-id derivation failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
+ 'Codex export unresolved shard-doc canonical-id derivation returns deterministic failure code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED}, got ${error.code}`,
+ );
+ }
+ } finally {
+ await fs.remove(tempShardDocInferenceRoot);
+ }
+
const compatibilitySetup = new CodexSetup();
const compatibilityIdentity = await compatibilitySetup.resolveSkillIdentityFromArtifact(
{
@@ -1991,6 +3270,25 @@ async function runTests() {
outputs: '',
futureAdditiveField: 'wave-1',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Shard Document',
+ code: 'SD',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/shard-doc.xml',
+ command: 'bmad-shard-doc',
+ required: 'false',
+ 'agent-name': '',
+ 'agent-command': '',
+ 'agent-display-name': '',
+ 'agent-title': '',
+ options: '',
+ description: 'Shard document command',
+ 'output-location': '',
+ outputs: '',
+ futureAdditiveField: 'wave-1',
+ },
{
module: 'bmm',
phase: 'planning',
@@ -2041,9 +3339,9 @@ async function runTests() {
const loadedHelpRows = await githubCopilotSetup.loadBmadHelp(tempCompatibilityRoot);
assert(
Array.isArray(loadedHelpRows) &&
- loadedHelpRows.length === 2 &&
- loadedHelpRows[0]['workflow-file'] === '_bmad/core/tasks/help.md' &&
- loadedHelpRows[0].command === 'bmad-help',
+ loadedHelpRows.length === 3 &&
+ loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/help.md' && row.command === 'bmad-help') &&
+ loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/shard-doc.xml' && row.command === 'bmad-shard-doc'),
'GitHub Copilot help loader remains parseable with additive help-catalog columns',
);
@@ -2063,6 +3361,45 @@ async function runTests() {
);
}
+ const missingShardDocRows = validHelpRows.filter((row) => row.command !== 'bmad-shard-doc');
+ const missingShardDocCsv =
+ [helpCatalogColumns.join(','), ...missingShardDocRows.map((row) => buildCsvLine(helpCatalogColumns, row))].join('\n') + '\n';
+ try {
+ validateHelpCatalogCompatibilitySurface(missingShardDocCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(false, 'Help-catalog validator rejects missing shard-doc canonical command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED &&
+ error.fieldPath === 'rows[*].command' &&
+ error.observedValue === '0',
+ 'Help-catalog validator emits deterministic diagnostics for missing shard-doc canonical command rows',
+ );
+ }
+
+ const shardDocBaselineRow = validHelpRows.find((row) => row.command === 'bmad-shard-doc');
+ const duplicateShardDocCsv =
+ [
+ helpCatalogColumns.join(','),
+ ...[...validHelpRows, { ...shardDocBaselineRow, name: 'Shard Document Duplicate' }].map((row) =>
+ buildCsvLine(helpCatalogColumns, row),
+ ),
+ ].join('\n') + '\n';
+ try {
+ validateHelpCatalogCompatibilitySurface(duplicateShardDocCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(false, 'Help-catalog validator rejects duplicate shard-doc canonical command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED &&
+ error.fieldPath === 'rows[*].command' &&
+ error.observedValue === '2',
+ 'Help-catalog validator emits deterministic diagnostics for duplicate shard-doc canonical command rows',
+ );
+ }
+
const missingWorkflowFileRows = [
{
...validHelpRows[0],
@@ -2254,6 +3591,24 @@ async function runTests() {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Shard Document',
+ code: 'SD',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/shard-doc.xml',
+ command: 'bmad-shard-doc',
+ required: 'false',
+ 'agent-name': '',
+ 'agent-command': '',
+ 'agent-display-name': '',
+ 'agent-title': '',
+ options: '',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ 'output-location': '',
+ outputs: '',
+ },
],
);
await writeCsv(
@@ -2289,6 +3644,21 @@ async function runTests() {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Shard Document',
+ code: 'SD',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/shard-doc.xml',
+ command: 'bmad-shard-doc',
+ required: 'false',
+ agent: '',
+ options: '',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ 'output-location': '',
+ outputs: '',
+ },
],
);
await writeCsv(
diff --git a/tools/cli/installers/lib/core/help-catalog-generator.js b/tools/cli/installers/lib/core/help-catalog-generator.js
index 7c410b0ae..6085ac26c 100644
--- a/tools/cli/installers/lib/core/help-catalog-generator.js
+++ b/tools/cli/installers/lib/core/help-catalog-generator.js
@@ -169,6 +169,8 @@ function resolveCanonicalIdFromAuthorityRecords(helpAuthorityRecords = []) {
function evaluateExemplarCommandLabelReportRows(rows, options = {}) {
const expectedCanonicalId = frontmatterMatchValue(options.canonicalId || EXEMPLAR_HELP_CATALOG_CANONICAL_ID);
const expectedDisplayedLabel = frontmatterMatchValue(options.displayedCommandLabel || `/${expectedCanonicalId}`);
+ const expectedAuthoritySourceType = frontmatterMatchValue(options.authoritySourceType || 'sidecar');
+ const expectedAuthoritySourcePath = frontmatterMatchValue(options.authoritySourcePath || EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH);
const normalizedExpectedDisplayedLabel = normalizeDisplayedCommandLabel(expectedDisplayedLabel);
const targetRows = (Array.isArray(rows) ? rows : []).filter(
@@ -200,11 +202,14 @@ function evaluateExemplarCommandLabelReportRows(rows, options = {}) {
return { valid: false, reason: `invalid-row-count-for-canonical-id:${String(row.rowCountForCanonicalId ?? '')}` };
}
- if (frontmatterMatchValue(row.authoritySourceType) !== 'sidecar') {
- return { valid: false, reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || ''}` };
+ if (frontmatterMatchValue(row.authoritySourceType) !== expectedAuthoritySourceType) {
+ return {
+ valid: false,
+ reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || ''}`,
+ };
}
- if (frontmatterMatchValue(row.authoritySourcePath) !== EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH) {
+ if (frontmatterMatchValue(row.authoritySourcePath) !== expectedAuthoritySourcePath) {
return {
valid: false,
reason: `invalid-authority-source-path:${frontmatterMatchValue(row.authoritySourcePath) || ''}`,
diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js
index c118ac1b0..cb05c37ff 100644
--- a/tools/cli/installers/lib/core/installer.js
+++ b/tools/cli/installers/lib/core/installer.js
@@ -9,8 +9,9 @@ const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector');
-const { validateHelpSidecarContractFile } = require('./sidecar-contract-validator');
+const { validateHelpSidecarContractFile, validateShardDocSidecarContractFile } = require('./sidecar-contract-validator');
const { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator');
+const { validateShardDocAuthoritySplitAndPrecedence } = require('./shard-doc-authority-validator');
const {
HELP_CATALOG_GENERATION_ERROR_CODES,
buildSidecarAwareExemplarHelpRow,
@@ -30,6 +31,10 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
const EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
+const EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
+const EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml';
+const EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH = 'bmad-fork/src/core/module-help.csv';
+const EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH = '_bmad/core/tasks/shard-doc.xml';
class Installer {
constructor() {
@@ -44,7 +49,9 @@ class Installer {
this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager();
this.validateHelpSidecarContractFile = validateHelpSidecarContractFile;
+ this.validateShardDocSidecarContractFile = validateShardDocSidecarContractFile;
this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence;
+ this.validateShardDocAuthoritySplitAndPrecedence = validateShardDocAuthoritySplitAndPrecedence;
this.ManifestGenerator = ManifestGenerator;
this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME;
@@ -56,12 +63,27 @@ class Installer {
}
async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) {
- // Validate exemplar sidecar contract before generating projections/manifests.
+ // Validate converted-capability sidecar contracts before generating projections/manifests.
// Fail-fast here prevents downstream artifacts from being produced on invalid metadata.
+ message('Validating shard-doc sidecar contract...');
+ await this.validateShardDocSidecarContractFile();
+
message('Validating exemplar sidecar contract...');
await this.validateHelpSidecarContractFile();
+
+ addResult('Shard-doc sidecar contract', 'ok', 'validated');
addResult('Sidecar contract', 'ok', 'validated');
+ message('Validating shard-doc authority split and XML precedence...');
+ const shardDocAuthorityValidation = await this.validateShardDocAuthoritySplitAndPrecedence({
+ sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
+ sourceXmlSourcePath: EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH,
+ compatibilityCatalogSourcePath: EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH,
+ compatibilityWorkflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH,
+ });
+ this.shardDocAuthorityRecords = shardDocAuthorityValidation.authoritativeRecords;
+ addResult('Shard-doc authority split', 'ok', shardDocAuthorityValidation.authoritativePresenceKey);
+
message('Validating authority split and frontmatter precedence...');
const helpAuthorityValidation = await this.validateHelpAuthoritySplitAndPrecedence({
bmadDir,
@@ -109,6 +131,7 @@ class Installer {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
helpAuthorityRecords: this.helpAuthorityRecords || [],
+ taskAuthorityRecords: [...(this.helpAuthorityRecords || []), ...(this.shardDocAuthorityRecords || [])],
});
addResult(
@@ -1780,6 +1803,38 @@ class Installer {
/**
* Private: Create directory structure
*/
+ resolveCanonicalIdFromAuthorityRecords({ authorityRecords, authoritySourcePath, fallbackCanonicalId }) {
+ const normalizedAuthoritySourcePath = String(authoritySourcePath || '')
+ .trim()
+ .replaceAll('\\', '/');
+ const normalizedFallbackCanonicalId = String(fallbackCanonicalId || '').trim();
+ const records = Array.isArray(authorityRecords) ? authorityRecords : [];
+
+ for (const record of records) {
+ if (!record || typeof record !== 'object') {
+ continue;
+ }
+
+ const recordCanonicalId = String(record.canonicalId || '').trim();
+ const recordAuthoritySourceType = String(record.authoritySourceType || '').trim();
+ const recordAuthoritySourcePath = String(record.authoritySourcePath || '')
+ .trim()
+ .replaceAll('\\', '/');
+ const recordType = String(record.recordType || '').trim();
+
+ if (
+ recordType === 'metadata-authority' &&
+ recordAuthoritySourceType === 'sidecar' &&
+ recordAuthoritySourcePath === normalizedAuthoritySourcePath &&
+ recordCanonicalId.length > 0
+ ) {
+ return recordCanonicalId;
+ }
+ }
+
+ return normalizedFallbackCanonicalId;
+ }
+
isExemplarHelpCatalogRow({ moduleName, name, workflowFile, command, canonicalId }) {
if (moduleName !== 'core') return false;
@@ -1830,7 +1885,7 @@ class Installer {
];
}
- isExemplarCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName }) {
+ isCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName, workflowFileContractPath, nameCandidates = [] }) {
const normalizedWorkflowFile = String(workflowFile || '')
.trim()
.replaceAll('\\', '/')
@@ -1849,13 +1904,27 @@ class Installer {
.toLowerCase()
.replace(/^\/+/, '');
- const isHelpWorkflow = normalizedWorkflowFile.endsWith('/core/tasks/help.md');
- const isExemplarIdentity =
- normalizedName === 'bmad-help' ||
- normalizedCommandValue === normalizedCanonicalId ||
- (normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName);
+ const normalizedWorkflowFileContractPath = String(workflowFileContractPath || '')
+ .trim()
+ .replaceAll('\\', '/')
+ .toLowerCase();
+ const workflowMarker = '/core/tasks/';
+ const markerIndex = normalizedWorkflowFileContractPath.indexOf(workflowMarker);
+ const workflowSuffix = markerIndex === -1 ? normalizedWorkflowFileContractPath : normalizedWorkflowFileContractPath.slice(markerIndex);
+ const hasWorkflowMatch = workflowSuffix.length > 0 && normalizedWorkflowFile.endsWith(workflowSuffix);
- return isHelpWorkflow && isExemplarIdentity;
+ const normalizedNameCandidates = (Array.isArray(nameCandidates) ? nameCandidates : [])
+ .map((candidate) =>
+ String(candidate || '')
+ .trim()
+ .toLowerCase(),
+ )
+ .filter((candidate) => candidate.length > 0);
+ const matchesNameCandidate = normalizedNameCandidates.includes(normalizedName);
+ const isCanonicalCommand = normalizedCanonicalId.length > 0 && normalizedCommandValue === normalizedCanonicalId;
+ const isLegacyCommand = normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName;
+
+ return hasWorkflowMatch && (matchesNameCandidate || isCanonicalCommand || isLegacyCommand);
}
async writeCsvArtifact(filePath, columns, rows) {
@@ -1886,6 +1955,31 @@ class Installer {
helpAuthorityRecords: this.helpAuthorityRecords || [],
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
});
+ const shardDocCanonicalId = this.resolveCanonicalIdFromAuthorityRecords({
+ authorityRecords: this.shardDocAuthorityRecords || [],
+ authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
+ fallbackCanonicalId: 'bmad-shard-doc',
+ });
+ const commandLabelContracts = [
+ {
+ canonicalId: sidecarAwareExemplar.canonicalId,
+ legacyName: sidecarAwareExemplar.legacyName,
+ displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel,
+ authoritySourceType: sidecarAwareExemplar.authoritySourceType,
+ authoritySourcePath: sidecarAwareExemplar.authoritySourcePath,
+ workflowFilePath: sidecarAwareExemplar.row['workflow-file'],
+ nameCandidates: [sidecarAwareExemplar.row.name],
+ },
+ {
+ canonicalId: shardDocCanonicalId,
+ legacyName: 'shard-doc',
+ displayedCommandLabel: renderDisplayedCommandLabel(shardDocCanonicalId),
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
+ workflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH,
+ nameCandidates: ['shard document', 'shard-doc'],
+ },
+ ];
let exemplarRowWritten = false;
// Load agent manifest for agent info lookup
@@ -2110,31 +2204,37 @@ class Installer {
continue;
}
- if (
- !this.isExemplarCommandLabelCandidate({
+ for (const contract of commandLabelContracts) {
+ const isContractCandidate = this.isCommandLabelCandidate({
workflowFile,
name,
rawCommandValue,
- canonicalId: sidecarAwareExemplar.canonicalId,
- legacyName: sidecarAwareExemplar.legacyName,
- })
- ) {
- continue;
+ canonicalId: contract.canonicalId,
+ legacyName: contract.legacyName,
+ workflowFileContractPath: contract.workflowFilePath,
+ nameCandidates: contract.nameCandidates,
+ });
+ if (isContractCandidate) {
+ const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue);
+ commandLabelRowsFromMergedCatalog.push({
+ surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
+ canonicalId: contract.canonicalId,
+ rawCommandValue,
+ displayedCommandLabel,
+ normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel),
+ authoritySourceType: contract.authoritySourceType,
+ authoritySourcePath: contract.authoritySourcePath,
+ });
+ break;
+ }
}
-
- const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue);
- commandLabelRowsFromMergedCatalog.push({
- surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
- canonicalId: sidecarAwareExemplar.canonicalId,
- rawCommandValue,
- displayedCommandLabel,
- normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel),
- authoritySourceType: sidecarAwareExemplar.authoritySourceType,
- authoritySourcePath: sidecarAwareExemplar.authoritySourcePath,
- });
}
- const exemplarRowCount = commandLabelRowsFromMergedCatalog.length;
+ const commandLabelRowCountByCanonicalId = new Map(commandLabelContracts.map((contract) => [contract.canonicalId, 0]));
+ for (const row of commandLabelRowsFromMergedCatalog) {
+ commandLabelRowCountByCanonicalId.set(row.canonicalId, (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) + 1);
+ }
+ const exemplarRowCount = commandLabelRowCountByCanonicalId.get(sidecarAwareExemplar.canonicalId) || 0;
this.helpCatalogPipelineRows = sidecarAwareExemplar.pipelineStageRows.map((row) => ({
...row,
@@ -2144,15 +2244,24 @@ class Installer {
}));
this.helpCatalogCommandLabelReportRows = commandLabelRowsFromMergedCatalog.map((row) => ({
...row,
- rowCountForCanonicalId: exemplarRowCount,
- status: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
+ rowCountForCanonicalId: commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0,
+ status: (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) === 1 ? 'PASS' : 'FAIL',
}));
- const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, {
- canonicalId: sidecarAwareExemplar.canonicalId,
- displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel,
- });
- if (!commandLabelContractResult.valid) {
+ const commandLabelContractFailures = new Map();
+ for (const contract of commandLabelContracts) {
+ const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, {
+ canonicalId: contract.canonicalId,
+ displayedCommandLabel: contract.displayedCommandLabel,
+ authoritySourceType: contract.authoritySourceType,
+ authoritySourcePath: contract.authoritySourcePath,
+ });
+ if (!commandLabelContractResult.valid) {
+ commandLabelContractFailures.set(contract.canonicalId, commandLabelContractResult.reason);
+ }
+ }
+
+ if (commandLabelContractFailures.size > 0) {
this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({
...row,
stageStatus: 'FAIL',
@@ -2161,14 +2270,19 @@ class Installer {
this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({
...row,
status: 'FAIL',
- failureReason: commandLabelContractResult.reason,
+ failureReason: commandLabelContractFailures.get(row.canonicalId) || row.failureReason || '',
}));
+ const commandLabelFailureSummary = [...commandLabelContractFailures.entries()]
+ .sort(([leftCanonicalId], [rightCanonicalId]) => leftCanonicalId.localeCompare(rightCanonicalId))
+ .map(([canonicalId, reason]) => `${canonicalId}:${reason}`)
+ .join('|');
+
const commandLabelError = new Error(
- `${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelContractResult.reason}`,
+ `${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelFailureSummary}`,
);
commandLabelError.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED;
- commandLabelError.detail = commandLabelContractResult.reason;
+ commandLabelError.detail = commandLabelFailureSummary;
throw commandLabelError;
}
diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js
index 0cd5e6d26..bffd0a320 100644
--- a/tools/cli/installers/lib/core/manifest-generator.js
+++ b/tools/cli/installers/lib/core/manifest-generator.js
@@ -15,6 +15,7 @@ const { validateTaskManifestCompatibilitySurface } = require('./projection-compa
// Load package.json for version info
const packageJson = require('../../../../../package.json');
const DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
+const DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
const CANONICAL_ALIAS_TABLE_COLUMNS = Object.freeze([
'canonicalId',
'alias',
@@ -55,6 +56,35 @@ const LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS = Object.freeze([
resolutionEligibility: 'slash-command-only',
}),
]);
+const LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS = Object.freeze([
+ Object.freeze({
+ canonicalId: 'bmad-shard-doc',
+ alias: 'bmad-shard-doc',
+ aliasType: 'canonical-id',
+ rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'canonical-id-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-shard-doc',
+ alias: 'shard-doc',
+ aliasType: 'legacy-name',
+ rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'legacy-name-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-shard-doc',
+ alias: '/bmad-shard-doc',
+ aliasType: 'slash-command',
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: true,
+ resolutionEligibility: 'slash-command-only',
+ }),
+]);
/**
* Generates manifest files for installed workflows, agents, and tasks
@@ -68,6 +98,73 @@ class ManifestGenerator {
this.modules = [];
this.files = [];
this.selectedIdes = [];
+ this.includeConvertedShardDocAliasRows = null;
+ }
+
+ normalizeTaskAuthorityRecords(records) {
+ if (!Array.isArray(records)) return [];
+
+ const normalized = [];
+ for (const record of records) {
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
+ continue;
+ }
+
+ const canonicalId = String(record.canonicalId ?? '').trim();
+ const authoritySourceType = String(record.authoritySourceType ?? '').trim();
+ const authoritySourcePath = String(record.authoritySourcePath ?? '').trim();
+ const sourcePath = String(record.sourcePath ?? '')
+ .trim()
+ .replaceAll('\\', '/');
+ const recordType = String(record.recordType ?? '').trim();
+
+ if (!canonicalId || !authoritySourceType || !authoritySourcePath || !sourcePath || !recordType) {
+ continue;
+ }
+
+ normalized.push({
+ recordType,
+ canonicalId,
+ authoritySourceType,
+ authoritySourcePath,
+ sourcePath,
+ });
+ }
+
+ normalized.sort((left, right) => {
+ const leftKey = `${left.canonicalId}|${left.recordType}|${left.authoritySourceType}|${left.authoritySourcePath}|${left.sourcePath}`;
+ const rightKey = `${right.canonicalId}|${right.recordType}|${right.authoritySourceType}|${right.authoritySourcePath}|${right.sourcePath}`;
+ return leftKey.localeCompare(rightKey);
+ });
+
+ return normalized;
+ }
+
+ buildTaskAuthorityProjectionIndex(records) {
+ const projectionIndex = new Map();
+ for (const record of records) {
+ if (!record || record.recordType !== 'metadata-authority' || record.authoritySourceType !== 'sidecar') {
+ continue;
+ }
+
+ const sourceMatch = String(record.sourcePath)
+ .replaceAll('\\', '/')
+ .match(/\/src\/([^/]+)\/tasks\/([^/.]+)\.(?:md|xml)$/i);
+ if (!sourceMatch) {
+ continue;
+ }
+
+ const moduleName = sourceMatch[1];
+ const taskName = sourceMatch[2];
+ projectionIndex.set(`${moduleName}:${taskName}`, {
+ legacyName: taskName,
+ canonicalId: record.canonicalId,
+ authoritySourceType: record.authoritySourceType,
+ authoritySourcePath: record.authoritySourcePath,
+ });
+ }
+
+ return projectionIndex;
}
/**
@@ -182,6 +279,13 @@ class ManifestGenerator {
}
this.helpAuthorityRecords = await this.normalizeHelpAuthorityRecords(options.helpAuthorityRecords);
+ const taskAuthorityInput = Object.prototype.hasOwnProperty.call(options, 'taskAuthorityRecords')
+ ? options.taskAuthorityRecords
+ : options.helpAuthorityRecords;
+ this.taskAuthorityRecords = this.normalizeTaskAuthorityRecords(taskAuthorityInput);
+ this.includeConvertedShardDocAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedShardDocAliasRows')
+ ? options.includeConvertedShardDocAliasRows === true
+ : null;
// Filter out any undefined/null values from IDE list
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
@@ -958,15 +1062,10 @@ class ManifestGenerator {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const compatibilitySurfacePath = `${this.bmadFolderName || '_bmad'}/_config/task-manifest.csv`;
- const sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords)
- ? this.helpAuthorityRecords.find(
- (record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
- )
- : null;
- const exemplarAuthoritySourceType = sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar';
- const exemplarAuthoritySourcePath = sidecarAuthorityRecord
- ? sidecarAuthorityRecord.authoritySourcePath
- : 'bmad-fork/src/core/tasks/help.artifact.yaml';
+ const taskAuthorityRecords = Array.isArray(this.taskAuthorityRecords)
+ ? this.taskAuthorityRecords
+ : this.normalizeTaskAuthorityRecords(this.helpAuthorityRecords);
+ const taskAuthorityProjectionIndex = this.buildTaskAuthorityProjectionIndex(taskAuthorityRecords);
// Read existing manifest to preserve entries
const existingEntries = new Map();
@@ -1015,7 +1114,7 @@ class ManifestGenerator {
for (const task of this.tasks) {
const key = `${task.module}:${task.name}`;
const previousRecord = allTasks.get(key);
- const isExemplarHelpTask = task.module === 'core' && task.name === 'help';
+ const authorityProjection = taskAuthorityProjectionIndex.get(key);
allTasks.set(key, {
name: task.name,
@@ -1024,10 +1123,10 @@ class ManifestGenerator {
module: task.module,
path: task.path,
standalone: task.standalone,
- legacyName: isExemplarHelpTask ? 'help' : previousRecord?.legacyName || task.name,
- canonicalId: isExemplarHelpTask ? 'bmad-help' : previousRecord?.canonicalId || '',
- authoritySourceType: isExemplarHelpTask ? exemplarAuthoritySourceType : previousRecord?.authoritySourceType || '',
- authoritySourcePath: isExemplarHelpTask ? exemplarAuthoritySourcePath : previousRecord?.authoritySourcePath || '',
+ legacyName: authorityProjection ? authorityProjection.legacyName : previousRecord?.legacyName || task.name,
+ canonicalId: authorityProjection ? authorityProjection.canonicalId : previousRecord?.canonicalId || '',
+ authoritySourceType: authorityProjection ? authorityProjection.authoritySourceType : previousRecord?.authoritySourceType || '',
+ authoritySourcePath: authorityProjection ? authorityProjection.authoritySourcePath : previousRecord?.authoritySourcePath || '',
});
}
@@ -1070,18 +1169,64 @@ class ManifestGenerator {
};
}
- buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath) {
- return LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS.map((row) => ({
- canonicalId: row.canonicalId,
- alias: row.alias,
- aliasType: row.aliasType,
- authoritySourceType,
- authoritySourcePath,
- rowIdentity: row.rowIdentity,
- normalizedAliasValue: row.normalizedAliasValue,
- rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash,
- resolutionEligibility: row.resolutionEligibility,
- }));
+ resolveShardDocAliasAuthorityRecord() {
+ const sidecarAuthorityRecord = Array.isArray(this.taskAuthorityRecords)
+ ? this.taskAuthorityRecords.find(
+ (record) => record?.canonicalId === 'bmad-shard-doc' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
+ )
+ : null;
+ return {
+ authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar',
+ authoritySourcePath: sidecarAuthorityRecord
+ ? sidecarAuthorityRecord.authoritySourcePath
+ : DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
+ };
+ }
+
+ hasShardDocTaskAuthorityProjection() {
+ if (!Array.isArray(this.taskAuthorityRecords)) {
+ return false;
+ }
+
+ return this.taskAuthorityRecords.some(
+ (record) =>
+ record?.recordType === 'metadata-authority' &&
+ record?.canonicalId === 'bmad-shard-doc' &&
+ record?.authoritySourceType === 'sidecar' &&
+ String(record?.authoritySourcePath || '').trim().length > 0,
+ );
+ }
+
+ shouldProjectShardDocAliasRows() {
+ if (this.includeConvertedShardDocAliasRows === true) {
+ return true;
+ }
+ if (this.includeConvertedShardDocAliasRows === false) {
+ return false;
+ }
+
+ return this.hasShardDocTaskAuthorityProjection();
+ }
+
+ buildCanonicalAliasProjectionRows() {
+ const buildRows = (lockedRows, authorityRecord) =>
+ lockedRows.map((row) => ({
+ canonicalId: row.canonicalId,
+ alias: row.alias,
+ aliasType: row.aliasType,
+ authoritySourceType: authorityRecord.authoritySourceType,
+ authoritySourcePath: authorityRecord.authoritySourcePath,
+ rowIdentity: row.rowIdentity,
+ normalizedAliasValue: row.normalizedAliasValue,
+ rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash,
+ resolutionEligibility: row.resolutionEligibility,
+ }));
+
+ const rows = [...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS, this.resolveExemplarAliasAuthorityRecord())];
+ if (this.shouldProjectShardDocAliasRows()) {
+ rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS, this.resolveShardDocAliasAuthorityRecord()));
+ }
+ return rows;
}
/**
@@ -1091,8 +1236,7 @@ class ManifestGenerator {
async writeCanonicalAliasManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'canonical-aliases.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
- const { authoritySourceType, authoritySourcePath } = this.resolveExemplarAliasAuthorityRecord();
- const projectedRows = this.buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath);
+ const projectedRows = this.buildCanonicalAliasProjectionRows();
let csvContent = `${CANONICAL_ALIAS_TABLE_COLUMNS.join(',')}\n`;
for (const row of projectedRows) {
diff --git a/tools/cli/installers/lib/core/projection-compatibility-validator.js b/tools/cli/installers/lib/core/projection-compatibility-validator.js
index d82fa3e87..257bbd20f 100644
--- a/tools/cli/installers/lib/core/projection-compatibility-validator.js
+++ b/tools/cli/installers/lib/core/projection-compatibility-validator.js
@@ -37,7 +37,13 @@ const PROJECTION_COMPATIBILITY_ERROR_CODES = Object.freeze({
HELP_CATALOG_HEADER_WAVE1_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_WAVE1_MISMATCH',
HELP_CATALOG_REQUIRED_COLUMN_MISSING: 'ERR_HELP_CATALOG_COMPAT_REQUIRED_COLUMN_MISSING',
HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_EXEMPLAR_ROW_CONTRACT_FAILED',
+ HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_SHARD_DOC_ROW_CONTRACT_FAILED',
GITHUB_COPILOT_WORKFLOW_FILE_MISSING: 'ERR_GITHUB_COPILOT_HELP_WORKFLOW_FILE_MISSING',
+ COMMAND_DOC_PARSE_FAILED: 'ERR_COMMAND_DOC_CONSISTENCY_PARSE_FAILED',
+ COMMAND_DOC_CANONICAL_COMMAND_MISSING: 'ERR_COMMAND_DOC_CONSISTENCY_CANONICAL_COMMAND_MISSING',
+ COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_CANONICAL_COMMAND_AMBIGUOUS',
+ COMMAND_DOC_ALIAS_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_ALIAS_AMBIGUOUS',
+ COMMAND_DOC_GENERATED_SURFACE_MISMATCH: 'ERR_COMMAND_DOC_CONSISTENCY_GENERATED_SURFACE_MISMATCH',
});
class ProjectionCompatibilityError extends Error {
@@ -177,6 +183,37 @@ function normalizeWorkflowPath(value) {
return normalizeSourcePath(value).toLowerCase();
}
+function normalizeDisplayedCommandLabel(value) {
+ const normalized = normalizeValue(value).toLowerCase().replace(/^\/+/, '');
+ return normalized.length > 0 ? `/${normalized}` : '';
+}
+
+function parseDocumentedSlashCommands(markdownContent, options = {}) {
+ const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md');
+ const surface = options.surface || 'command-doc-consistency';
+ const content = String(markdownContent ?? '');
+ const commandPattern = /\|\s*`(\/[^`]+)`\s*\|/g;
+ const commands = [];
+ let match;
+ while ((match = commandPattern.exec(content)) !== null) {
+ commands.push(normalizeDisplayedCommandLabel(match[1]));
+ }
+
+ if (commands.length === 0) {
+ throwCompatibilityError({
+ code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_PARSE_FAILED,
+ detail: 'Unable to find slash-command rows in command reference markdown',
+ surface,
+ fieldPath: 'docs.reference.commands',
+ sourcePath,
+ observedValue: '',
+ expectedValue: '| `/bmad-...` |',
+ });
+ }
+
+ return commands;
+}
+
function validateTaskManifestLoaderEntries(rows, options = {}) {
const surface = options.surface || 'task-manifest-loader';
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv');
@@ -261,6 +298,23 @@ function validateHelpCatalogLoaderEntries(rows, options = {}) {
});
}
+ const shardDocRows = parsedRows.filter(
+ (row) =>
+ normalizeCommandValue(row.command) === 'bmad-shard-doc' &&
+ normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/shard-doc.xml'),
+ );
+ if (shardDocRows.length !== 1) {
+ throwCompatibilityError({
+ code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED,
+ detail: 'Exactly one shard-doc compatibility row is required for help catalog consumers',
+ surface,
+ fieldPath: 'rows[*].command',
+ sourcePath,
+ observedValue: String(shardDocRows.length),
+ expectedValue: '1',
+ });
+ }
+
return true;
}
@@ -392,6 +446,84 @@ function validateHelpCatalogCompatibilitySurface(csvContent, options = {}) {
return { headerColumns, rows };
}
+function validateCommandDocSurfaceConsistency(commandDocMarkdown, options = {}) {
+ const surface = options.surface || 'command-doc-consistency';
+ const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md');
+ const canonicalId = normalizeValue(options.canonicalId || 'bmad-shard-doc');
+ const expectedDisplayedCommandLabel = normalizeDisplayedCommandLabel(options.expectedDisplayedCommandLabel || '/bmad-shard-doc');
+ const disallowedAliasLabels = Array.isArray(options.disallowedAliasLabels) ? options.disallowedAliasLabels : ['/shard-doc'];
+ const commandLabelRows = Array.isArray(options.commandLabelRows) ? options.commandLabelRows : [];
+
+ const documentedCommands = parseDocumentedSlashCommands(commandDocMarkdown, {
+ sourcePath,
+ surface,
+ });
+ const documentedCanonicalMatches = documentedCommands.filter((commandLabel) => commandLabel === expectedDisplayedCommandLabel);
+ if (documentedCanonicalMatches.length === 0) {
+ throwCompatibilityError({
+ code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING,
+ detail: 'Expected canonical command is missing from command reference markdown',
+ surface,
+ fieldPath: 'docs.reference.commands.canonical-command',
+ sourcePath,
+ observedValue: '',
+ expectedValue: expectedDisplayedCommandLabel,
+ });
+ }
+ if (documentedCanonicalMatches.length > 1) {
+ throwCompatibilityError({
+ code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS,
+ detail: 'Canonical command appears multiple times in command reference markdown',
+ surface,
+ fieldPath: 'docs.reference.commands.canonical-command',
+ sourcePath,
+ observedValue: String(documentedCanonicalMatches.length),
+ expectedValue: '1',
+ });
+ }
+
+ const normalizedDisallowedAliases = disallowedAliasLabels.map((label) => normalizeDisplayedCommandLabel(label)).filter(Boolean);
+ const presentDisallowedAlias = normalizedDisallowedAliases.find((label) => documentedCommands.includes(label));
+ if (presentDisallowedAlias) {
+ throwCompatibilityError({
+ code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS,
+ detail: 'Disallowed alias command detected in command reference markdown',
+ surface,
+ fieldPath: 'docs.reference.commands.alias-command',
+ sourcePath,
+ observedValue: presentDisallowedAlias,
+ expectedValue: expectedDisplayedCommandLabel,
+ });
+ }
+
+ const generatedCanonicalRows = commandLabelRows.filter((row) => normalizeValue(row.canonicalId) === canonicalId);
+ const generatedMatchingRows = generatedCanonicalRows.filter(
+ (row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel) === expectedDisplayedCommandLabel,
+ );
+ if (generatedCanonicalRows.length === 0 || generatedMatchingRows.length !== 1) {
+ throwCompatibilityError({
+ code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH,
+ detail: 'Generated command-label surface does not match canonical command-doc contract',
+ surface,
+ fieldPath: 'generated.command-label-report',
+ sourcePath: normalizeSourcePath(options.generatedSurfacePath || '_bmad/_config/bmad-help-command-label-report.csv'),
+ observedValue:
+ generatedCanonicalRows
+ .map((row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel))
+ .filter(Boolean)
+ .join('|') || '',
+ expectedValue: expectedDisplayedCommandLabel,
+ });
+ }
+
+ return {
+ canonicalId,
+ expectedDisplayedCommandLabel,
+ documentedCommands,
+ generatedCanonicalCommand: expectedDisplayedCommandLabel,
+ };
+}
+
module.exports = {
PROJECTION_COMPATIBILITY_ERROR_CODES,
ProjectionCompatibilityError,
@@ -404,4 +536,5 @@ module.exports = {
validateHelpCatalogCompatibilitySurface,
validateHelpCatalogLoaderEntries,
validateGithubCopilotHelpLoaderEntries,
+ validateCommandDocSurfaceConsistency,
};
diff --git a/tools/cli/installers/lib/core/shard-doc-authority-validator.js b/tools/cli/installers/lib/core/shard-doc-authority-validator.js
new file mode 100644
index 000000000..a6f3a8ef1
--- /dev/null
+++ b/tools/cli/installers/lib/core/shard-doc-authority-validator.js
@@ -0,0 +1,334 @@
+const path = require('node:path');
+const fs = require('fs-extra');
+const yaml = require('yaml');
+const csv = require('csv-parse/sync');
+const { getProjectRoot, getSourcePath } = require('../../../lib/project-root');
+
+const SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
+ SIDECAR_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
+ SIDECAR_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_PARSE_FAILED',
+ SIDECAR_INVALID_METADATA: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_INVALID_METADATA',
+ SIDECAR_CANONICAL_ID_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_CANONICAL_ID_MISMATCH',
+ SOURCE_XML_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SOURCE_XML_FILE_NOT_FOUND',
+ COMPATIBILITY_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_FILE_NOT_FOUND',
+ COMPATIBILITY_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_PARSE_FAILED',
+ COMPATIBILITY_ROW_MISSING: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_MISSING',
+ COMPATIBILITY_ROW_DUPLICATE: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_DUPLICATE',
+ COMMAND_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_COMMAND_MISMATCH',
+ DISPLAY_NAME_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_DISPLAY_NAME_MISMATCH',
+ DUPLICATE_CANONICAL_COMMAND: 'ERR_SHARD_DOC_AUTHORITY_DUPLICATE_CANONICAL_COMMAND',
+});
+
+const SHARD_DOC_LOCKED_CANONICAL_ID = 'bmad-shard-doc';
+const SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY = `capability:${SHARD_DOC_LOCKED_CANONICAL_ID}`;
+
+class ShardDocAuthorityValidationError extends Error {
+ constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
+ const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
+ super(message);
+ this.name = 'ShardDocAuthorityValidationError';
+ this.code = code;
+ this.detail = detail;
+ this.fieldPath = fieldPath;
+ this.sourcePath = sourcePath;
+ this.observedValue = observedValue;
+ this.expectedValue = expectedValue;
+ this.fullMessage = message;
+ }
+}
+
+function normalizeSourcePath(value) {
+ if (!value) return '';
+ return String(value).replaceAll('\\', '/');
+}
+
+function toProjectRelativePath(filePath) {
+ const projectRoot = getProjectRoot();
+ const relative = path.relative(projectRoot, filePath);
+
+ if (!relative || relative.startsWith('..')) {
+ return normalizeSourcePath(path.resolve(filePath));
+ }
+
+ return normalizeSourcePath(relative);
+}
+
+function hasOwn(obj, key) {
+ return Object.prototype.hasOwnProperty.call(obj, key);
+}
+
+function isBlankString(value) {
+ return typeof value !== 'string' || value.trim().length === 0;
+}
+
+function csvMatchValue(value) {
+ return String(value ?? '').trim();
+}
+
+function createValidationError(code, detail, fieldPath, sourcePath, observedValue, expectedValue) {
+ throw new ShardDocAuthorityValidationError({
+ code,
+ detail,
+ fieldPath,
+ sourcePath,
+ observedValue,
+ expectedValue,
+ });
+}
+
+function ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath) {
+ const requiredFields = ['canonicalId', 'displayName', 'description', 'sourcePath'];
+ for (const requiredField of requiredFields) {
+ if (!hasOwn(sidecarData, requiredField)) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ `Missing required sidecar metadata field "${requiredField}"`,
+ requiredField,
+ sidecarSourcePath,
+ );
+ }
+ }
+
+ for (const requiredField of requiredFields) {
+ if (isBlankString(sidecarData[requiredField])) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ `Required sidecar metadata field "${requiredField}" must be a non-empty string`,
+ requiredField,
+ sidecarSourcePath,
+ );
+ }
+ }
+
+ const normalizedCanonicalId = String(sidecarData.canonicalId).trim();
+ if (normalizedCanonicalId !== SHARD_DOC_LOCKED_CANONICAL_ID) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc',
+ 'canonicalId',
+ sidecarSourcePath,
+ normalizedCanonicalId,
+ SHARD_DOC_LOCKED_CANONICAL_ID,
+ );
+ }
+
+ const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath);
+ if (normalizedDeclaredSourcePath !== sourceXmlSourcePath) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ 'Sidecar sourcePath must match shard-doc XML source path',
+ 'sourcePath',
+ sidecarSourcePath,
+ normalizedDeclaredSourcePath,
+ sourceXmlSourcePath,
+ );
+ }
+}
+
+async function parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath) {
+ if (!(await fs.pathExists(compatibilityCatalogPath))) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_FILE_NOT_FOUND,
+ 'Expected module-help compatibility catalog file was not found',
+ '',
+ compatibilityCatalogSourcePath,
+ );
+ }
+
+ let csvRaw;
+ try {
+ csvRaw = await fs.readFile(compatibilityCatalogPath, 'utf8');
+ } catch (error) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED,
+ `Unable to read compatibility catalog file: ${error.message}`,
+ '',
+ compatibilityCatalogSourcePath,
+ );
+ }
+
+ try {
+ return csv.parse(csvRaw, {
+ columns: true,
+ skip_empty_lines: true,
+ relax_column_count: true,
+ trim: true,
+ });
+ } catch (error) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED,
+ `CSV parse failure: ${error.message}`,
+ '',
+ compatibilityCatalogSourcePath,
+ );
+ }
+}
+
+function validateCompatibilityPrecedence({ rows, displayName, workflowFilePath, compatibilityCatalogSourcePath }) {
+ const workflowMatches = rows.filter((row) => csvMatchValue(row['workflow-file']) === workflowFilePath);
+
+ if (workflowMatches.length === 0) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
+ 'Converted shard-doc compatibility row is missing from module-help catalog',
+ 'workflow-file',
+ compatibilityCatalogSourcePath,
+ '',
+ workflowFilePath,
+ );
+ }
+
+ if (workflowMatches.length > 1) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_DUPLICATE,
+ 'Converted shard-doc compatibility row appears more than once in module-help catalog',
+ 'workflow-file',
+ compatibilityCatalogSourcePath,
+ workflowMatches.length,
+ 1,
+ );
+ }
+
+ const canonicalCommandMatches = rows.filter((row) => csvMatchValue(row.command) === SHARD_DOC_LOCKED_CANONICAL_ID);
+ if (canonicalCommandMatches.length > 1) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
+ 'Converted shard-doc canonical command appears in more than one compatibility row',
+ 'command',
+ compatibilityCatalogSourcePath,
+ canonicalCommandMatches.length,
+ 1,
+ );
+ }
+
+ const shardDocRow = workflowMatches[0];
+ const observedCommand = csvMatchValue(shardDocRow.command);
+ if (!observedCommand || observedCommand !== SHARD_DOC_LOCKED_CANONICAL_ID) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'Converted shard-doc compatibility command must match locked canonical command bmad-shard-doc',
+ 'command',
+ compatibilityCatalogSourcePath,
+ observedCommand || '',
+ SHARD_DOC_LOCKED_CANONICAL_ID,
+ );
+ }
+
+ const observedDisplayName = csvMatchValue(shardDocRow.name);
+ if (observedDisplayName && observedDisplayName !== displayName) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DISPLAY_NAME_MISMATCH,
+ 'Converted shard-doc compatibility name must match sidecar displayName when provided',
+ 'name',
+ compatibilityCatalogSourcePath,
+ observedDisplayName,
+ displayName,
+ );
+ }
+}
+
+function buildShardDocAuthorityRecords({ canonicalId, sidecarSourcePath, sourceXmlSourcePath }) {
+ return [
+ {
+ recordType: 'metadata-authority',
+ canonicalId,
+ authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: sidecarSourcePath,
+ sourcePath: sourceXmlSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId,
+ authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: sourceXmlSourcePath,
+ sourcePath: sourceXmlSourcePath,
+ },
+ ];
+}
+
+async function validateShardDocAuthoritySplitAndPrecedence(options = {}) {
+ const sidecarPath = options.sidecarPath || getSourcePath('core', 'tasks', 'shard-doc.artifact.yaml');
+ const sourceXmlPath = options.sourceXmlPath || getSourcePath('core', 'tasks', 'shard-doc.xml');
+ const compatibilityCatalogPath = options.compatibilityCatalogPath || getSourcePath('core', 'module-help.csv');
+ const compatibilityWorkflowFilePath = options.compatibilityWorkflowFilePath || '_bmad/core/tasks/shard-doc.xml';
+
+ const sidecarSourcePath = normalizeSourcePath(options.sidecarSourcePath || toProjectRelativePath(sidecarPath));
+ const sourceXmlSourcePath = normalizeSourcePath(options.sourceXmlSourcePath || toProjectRelativePath(sourceXmlPath));
+ const compatibilityCatalogSourcePath = normalizeSourcePath(
+ options.compatibilityCatalogSourcePath || toProjectRelativePath(compatibilityCatalogPath),
+ );
+
+ if (!(await fs.pathExists(sidecarPath))) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
+ 'Expected shard-doc sidecar metadata file was not found',
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ let sidecarData;
+ try {
+ const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
+ sidecarData = yaml.parse(sidecarRaw);
+ } catch (error) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
+ `YAML parse failure: ${error.message}`,
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ 'Sidecar root must be a YAML mapping object',
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath);
+
+ if (!(await fs.pathExists(sourceXmlPath))) {
+ createValidationError(
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SOURCE_XML_FILE_NOT_FOUND,
+ 'Expected shard-doc XML source file was not found',
+ '',
+ sourceXmlSourcePath,
+ );
+ }
+
+ const compatibilityRows = await parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath);
+ validateCompatibilityPrecedence({
+ rows: compatibilityRows,
+ displayName: sidecarData.displayName.trim(),
+ workflowFilePath: compatibilityWorkflowFilePath,
+ compatibilityCatalogSourcePath,
+ });
+
+ const canonicalId = SHARD_DOC_LOCKED_CANONICAL_ID;
+ const authoritativeRecords = buildShardDocAuthorityRecords({
+ canonicalId,
+ sidecarSourcePath,
+ sourceXmlSourcePath,
+ });
+
+ return {
+ canonicalId,
+ authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritativeRecords,
+ checkedSurfaces: [sourceXmlSourcePath, compatibilityCatalogSourcePath],
+ };
+}
+
+module.exports = {
+ SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES,
+ SHARD_DOC_LOCKED_CANONICAL_ID,
+ ShardDocAuthorityValidationError,
+ buildShardDocAuthorityRecords,
+ validateShardDocAuthoritySplitAndPrecedence,
+};
diff --git a/tools/cli/installers/lib/core/sidecar-contract-validator.js b/tools/cli/installers/lib/core/sidecar-contract-validator.js
index a5b9e235c..b9c4c02ba 100644
--- a/tools/cli/installers/lib/core/sidecar-contract-validator.js
+++ b/tools/cli/installers/lib/core/sidecar-contract-validator.js
@@ -14,6 +14,8 @@ const HELP_SIDECAR_REQUIRED_FIELDS = Object.freeze([
'dependencies',
]);
+const SHARD_DOC_SIDECAR_REQUIRED_FIELDS = Object.freeze([...HELP_SIDECAR_REQUIRED_FIELDS]);
+
const HELP_SIDECAR_ERROR_CODES = Object.freeze({
FILE_NOT_FOUND: 'ERR_HELP_SIDECAR_FILE_NOT_FOUND',
PARSE_FAILED: 'ERR_HELP_SIDECAR_PARSE_FAILED',
@@ -29,8 +31,24 @@ const HELP_SIDECAR_ERROR_CODES = Object.freeze({
SOURCEPATH_BASENAME_MISMATCH: 'ERR_SIDECAR_SOURCEPATH_BASENAME_MISMATCH',
});
+const SHARD_DOC_SIDECAR_ERROR_CODES = Object.freeze({
+ FILE_NOT_FOUND: 'ERR_SHARD_DOC_SIDECAR_FILE_NOT_FOUND',
+ PARSE_FAILED: 'ERR_SHARD_DOC_SIDECAR_PARSE_FAILED',
+ INVALID_ROOT_OBJECT: 'ERR_SHARD_DOC_SIDECAR_INVALID_ROOT_OBJECT',
+ REQUIRED_FIELD_MISSING: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_MISSING',
+ REQUIRED_FIELD_EMPTY: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_EMPTY',
+ ARTIFACT_TYPE_INVALID: 'ERR_SHARD_DOC_SIDECAR_ARTIFACT_TYPE_INVALID',
+ MODULE_INVALID: 'ERR_SHARD_DOC_SIDECAR_MODULE_INVALID',
+ DEPENDENCIES_MISSING: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_MISSING',
+ DEPENDENCIES_REQUIRES_INVALID: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_INVALID',
+ DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY',
+ MAJOR_VERSION_UNSUPPORTED: 'ERR_SHARD_DOC_SIDECAR_MAJOR_VERSION_UNSUPPORTED',
+ SOURCEPATH_BASENAME_MISMATCH: 'ERR_SHARD_DOC_SIDECAR_SOURCEPATH_BASENAME_MISMATCH',
+});
+
const HELP_EXEMPLAR_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
-const HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR = 1;
+const SHARD_DOC_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml';
+const SIDECAR_SUPPORTED_SCHEMA_MAJOR = 1;
class SidecarContractError extends Error {
constructor({ code, detail, fieldPath, sourcePath }) {
@@ -108,43 +126,42 @@ function createValidationError(code, fieldPath, sourcePath, detail) {
});
}
-function validateHelpSidecarContractData(sidecarData, options = {}) {
- const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml');
+function validateSidecarContractData(sidecarData, options) {
+ const {
+ sourcePath,
+ requiredFields,
+ requiredNonEmptyStringFields,
+ errorCodes,
+ expectedArtifactType,
+ expectedModule,
+ expectedCanonicalSourcePath,
+ missingDependenciesDetail,
+ dependenciesObjectDetail,
+ dependenciesRequiresArrayDetail,
+ dependenciesRequiresNotEmptyDetail,
+ artifactTypeDetail,
+ moduleDetail,
+ requiresMustBeEmpty,
+ } = options;
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.INVALID_ROOT_OBJECT,
- '',
- sourcePath,
- 'Sidecar root must be a YAML mapping object.',
- );
+ createValidationError(errorCodes.INVALID_ROOT_OBJECT, '', sourcePath, 'Sidecar root must be a YAML mapping object.');
}
- for (const field of HELP_SIDECAR_REQUIRED_FIELDS) {
+ for (const field of requiredFields) {
if (!hasOwn(sidecarData, field)) {
if (field === 'dependencies') {
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
- field,
- sourcePath,
- 'Exemplar sidecar requires an explicit dependencies block.',
- );
+ createValidationError(errorCodes.DEPENDENCIES_MISSING, field, sourcePath, missingDependenciesDetail);
}
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
- field,
- sourcePath,
- `Missing required sidecar field "${field}".`,
- );
+ createValidationError(errorCodes.REQUIRED_FIELD_MISSING, field, sourcePath, `Missing required sidecar field "${field}".`);
}
}
- const requiredNonEmptyStringFields = ['canonicalId', 'sourcePath', 'displayName', 'description'];
for (const field of requiredNonEmptyStringFields) {
if (isBlankString(sidecarData[field])) {
createValidationError(
- HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ errorCodes.REQUIRED_FIELD_EMPTY,
field,
sourcePath,
`Required sidecar field "${field}" must be a non-empty string.`,
@@ -153,58 +170,33 @@ function validateHelpSidecarContractData(sidecarData, options = {}) {
}
const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion);
- if (schemaMajorVersion !== HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR) {
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
- 'schemaVersion',
- sourcePath,
- 'sidecar schema major version is unsupported',
- );
+ if (schemaMajorVersion !== SIDECAR_SUPPORTED_SCHEMA_MAJOR) {
+ createValidationError(errorCodes.MAJOR_VERSION_UNSUPPORTED, 'schemaVersion', sourcePath, 'sidecar schema major version is unsupported');
}
- if (sidecarData.artifactType !== 'task') {
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
- 'artifactType',
- sourcePath,
- 'Wave-1 exemplar requires artifactType to equal "task".',
- );
+ if (sidecarData.artifactType !== expectedArtifactType) {
+ createValidationError(errorCodes.ARTIFACT_TYPE_INVALID, 'artifactType', sourcePath, artifactTypeDetail);
}
- if (sidecarData.module !== 'core') {
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.MODULE_INVALID,
- 'module',
- sourcePath,
- 'Wave-1 exemplar requires module to equal "core".',
- );
+ if (sidecarData.module !== expectedModule) {
+ createValidationError(errorCodes.MODULE_INVALID, 'module', sourcePath, moduleDetail);
}
const dependencies = sidecarData.dependencies;
if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
- 'dependencies',
- sourcePath,
- 'Exemplar sidecar requires an explicit dependencies object.',
- );
+ createValidationError(errorCodes.DEPENDENCIES_MISSING, 'dependencies', sourcePath, dependenciesObjectDetail);
}
if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) {
- createValidationError(
- HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
- 'dependencies.requires',
- sourcePath,
- 'Exemplar dependencies.requires must be an array.',
- );
+ createValidationError(errorCodes.DEPENDENCIES_REQUIRES_INVALID, 'dependencies.requires', sourcePath, dependenciesRequiresArrayDetail);
}
- if (dependencies.requires.length > 0) {
+ if (requiresMustBeEmpty && dependencies.requires.length > 0) {
createValidationError(
- HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
+ errorCodes.DEPENDENCIES_REQUIRES_NOT_EMPTY,
'dependencies.requires',
sourcePath,
- 'Wave-1 exemplar requires explicit zero dependencies: dependencies.requires must be [].',
+ dependenciesRequiresNotEmptyDetail,
);
}
@@ -212,12 +204,12 @@ function validateHelpSidecarContractData(sidecarData, options = {}) {
const sidecarBasename = path.posix.basename(sourcePath);
const expectedSidecarBasename = getExpectedSidecarBasenameFromSourcePath(normalizedDeclaredSourcePath);
- const sourcePathMismatch = normalizedDeclaredSourcePath !== HELP_EXEMPLAR_CANONICAL_SOURCE_PATH;
+ const sourcePathMismatch = normalizedDeclaredSourcePath !== expectedCanonicalSourcePath;
const basenameMismatch = !expectedSidecarBasename || sidecarBasename !== expectedSidecarBasename;
if (sourcePathMismatch || basenameMismatch) {
createValidationError(
- HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ errorCodes.SOURCEPATH_BASENAME_MISMATCH,
'sourcePath',
sourcePath,
'sidecar basename does not match sourcePath basename',
@@ -225,6 +217,46 @@ function validateHelpSidecarContractData(sidecarData, options = {}) {
}
}
+function validateHelpSidecarContractData(sidecarData, options = {}) {
+ const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml');
+ validateSidecarContractData(sidecarData, {
+ sourcePath,
+ requiredFields: HELP_SIDECAR_REQUIRED_FIELDS,
+ requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'],
+ errorCodes: HELP_SIDECAR_ERROR_CODES,
+ expectedArtifactType: 'task',
+ expectedModule: 'core',
+ expectedCanonicalSourcePath: HELP_EXEMPLAR_CANONICAL_SOURCE_PATH,
+ missingDependenciesDetail: 'Exemplar sidecar requires an explicit dependencies block.',
+ dependenciesObjectDetail: 'Exemplar sidecar requires an explicit dependencies object.',
+ dependenciesRequiresArrayDetail: 'Exemplar dependencies.requires must be an array.',
+ dependenciesRequiresNotEmptyDetail: 'Wave-1 exemplar requires explicit zero dependencies: dependencies.requires must be [].',
+ artifactTypeDetail: 'Wave-1 exemplar requires artifactType to equal "task".',
+ moduleDetail: 'Wave-1 exemplar requires module to equal "core".',
+ requiresMustBeEmpty: true,
+ });
+}
+
+function validateShardDocSidecarContractData(sidecarData, options = {}) {
+ const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/shard-doc.artifact.yaml');
+ validateSidecarContractData(sidecarData, {
+ sourcePath,
+ requiredFields: SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
+ requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'],
+ errorCodes: SHARD_DOC_SIDECAR_ERROR_CODES,
+ expectedArtifactType: 'task',
+ expectedModule: 'core',
+ expectedCanonicalSourcePath: SHARD_DOC_CANONICAL_SOURCE_PATH,
+ missingDependenciesDetail: 'Shard-doc sidecar requires an explicit dependencies block.',
+ dependenciesObjectDetail: 'Shard-doc sidecar requires an explicit dependencies object.',
+ dependenciesRequiresArrayDetail: 'Shard-doc dependencies.requires must be an array.',
+ dependenciesRequiresNotEmptyDetail: 'Wave-2 shard-doc contract requires explicit zero dependencies: dependencies.requires must be [].',
+ artifactTypeDetail: 'Wave-2 shard-doc contract requires artifactType to equal "task".',
+ moduleDetail: 'Wave-2 shard-doc contract requires module to equal "core".',
+ requiresMustBeEmpty: true,
+ });
+}
+
async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'help.artifact.yaml'), options = {}) {
const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath));
@@ -253,10 +285,42 @@ async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core
validateHelpSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
}
+async function validateShardDocSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'shard-doc.artifact.yaml'), options = {}) {
+ const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath));
+
+ if (!(await fs.pathExists(sidecarPath))) {
+ createValidationError(
+ SHARD_DOC_SIDECAR_ERROR_CODES.FILE_NOT_FOUND,
+ '',
+ normalizedSourcePath,
+ 'Expected shard-doc sidecar file was not found.',
+ );
+ }
+
+ let parsedSidecar;
+ try {
+ const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
+ parsedSidecar = yaml.parse(sidecarRaw);
+ } catch (error) {
+ createValidationError(
+ SHARD_DOC_SIDECAR_ERROR_CODES.PARSE_FAILED,
+ '',
+ normalizedSourcePath,
+ `YAML parse failure: ${error.message}`,
+ );
+ }
+
+ validateShardDocSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
+}
+
module.exports = {
HELP_SIDECAR_REQUIRED_FIELDS,
+ SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
HELP_SIDECAR_ERROR_CODES,
+ SHARD_DOC_SIDECAR_ERROR_CODES,
SidecarContractError,
validateHelpSidecarContractData,
validateHelpSidecarContractFile,
+ validateShardDocSidecarContractData,
+ validateShardDocSidecarContractFile,
};
diff --git a/tools/cli/installers/lib/core/wave-1-validation-harness.js b/tools/cli/installers/lib/core/wave-1-validation-harness.js
index 5b2e05e0c..e321d03ad 100644
--- a/tools/cli/installers/lib/core/wave-1-validation-harness.js
+++ b/tools/cli/installers/lib/core/wave-1-validation-harness.js
@@ -854,13 +854,16 @@ class Wave1ValidationHarness {
const generator = new ManifestGenerator();
generator.bmadFolderName = runtimeFolder;
- generator.helpAuthorityRecords = [
+ generator.taskAuthorityRecords = [
{
+ recordType: 'metadata-authority',
canonicalId: 'bmad-help',
authoritySourceType: 'sidecar',
authoritySourcePath: SIDEcar_AUTHORITY_SOURCE_PATH,
+ sourcePath: SOURCE_MARKDOWN_SOURCE_PATH,
},
];
+ generator.helpAuthorityRecords = [...generator.taskAuthorityRecords];
generator.tasks = perturbed
? []
: [
@@ -931,6 +934,21 @@ class Wave1ValidationHarness {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Shard Document',
+ code: 'SD',
+ sequence: '',
+ 'workflow-file': `${runtimeFolder}/core/tasks/shard-doc.xml`,
+ command: 'bmad-shard-doc',
+ required: 'false',
+ agent: '',
+ options: '',
+ description: 'Split large markdown documents into smaller files by section with an index.',
+ 'output-location': '',
+ outputs: '',
+ },
];
await fs.writeFile(path.join(coreDir, 'module-help.csv'), serializeCsv(MODULE_HELP_COMPAT_COLUMNS, moduleHelpFixtureRows), 'utf8');
await fs.writeFile(
diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js
index 0d96db79c..b38b69037 100644
--- a/tools/cli/installers/lib/ide/codex.js
+++ b/tools/cli/installers/lib/ide/codex.js
@@ -16,19 +16,62 @@ const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({
SIDECAR_PARSE_FAILED: 'ERR_CODEX_EXPORT_SIDECAR_PARSE_FAILED',
CANONICAL_ID_MISSING: 'ERR_CODEX_EXPORT_CANONICAL_ID_MISSING',
CANONICAL_ID_DERIVATION_FAILED: 'ERR_CODEX_EXPORT_CANONICAL_ID_DERIVATION_FAILED',
+ DUPLICATE_EXPORT_SURFACE: 'ERR_CODEX_EXPORT_DUPLICATE_EXPORT_SURFACE',
});
const EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
+const EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml';
const EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
+const EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id';
-const EXEMPLAR_SIDECAR_SOURCE_CANDIDATES = Object.freeze([
+const SHARD_DOC_EXPORT_ALIAS_ROWS = Object.freeze([
Object.freeze({
- segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'],
+ rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: false,
}),
Object.freeze({
- segments: ['src', 'core', 'tasks', 'help.artifact.yaml'],
+ rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'shard-doc',
+ rawIdentityHasLeadingSlash: false,
+ }),
+ Object.freeze({
+ rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
+ canonicalId: 'bmad-shard-doc',
+ normalizedAliasValue: 'bmad-shard-doc',
+ rawIdentityHasLeadingSlash: true,
}),
]);
+const EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS = Object.freeze({
+ help: Object.freeze({
+ taskSourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH,
+ sourcePathSuffix: '/core/tasks/help.md',
+ sidecarSourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
+ sidecarSourceCandidates: Object.freeze([
+ Object.freeze({
+ segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'],
+ }),
+ Object.freeze({
+ segments: ['src', 'core', 'tasks', 'help.artifact.yaml'],
+ }),
+ ]),
+ }),
+ 'shard-doc': Object.freeze({
+ taskSourcePath: EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH,
+ sourcePathSuffix: '/core/tasks/shard-doc.xml',
+ sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH,
+ sidecarSourceCandidates: Object.freeze([
+ Object.freeze({
+ segments: ['bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'],
+ }),
+ Object.freeze({
+ segments: ['src', 'core', 'tasks', 'shard-doc.artifact.yaml'],
+ }),
+ ]),
+ }),
+});
class CodexExportDerivationError extends Error {
constructor({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
@@ -53,6 +96,7 @@ class CodexSetup extends BaseIdeSetup {
constructor() {
super('codex', 'Codex', false);
this.exportDerivationRecords = [];
+ this.exportSurfaceIdentityOwners = new Map();
}
/**
@@ -69,6 +113,7 @@ class CodexSetup extends BaseIdeSetup {
const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options);
this.exportDerivationRecords = [];
+ this.exportSurfaceIdentityOwners = new Map();
// Clean up old .codex/prompts locations (both global and project)
const oldGlobalDir = this.getOldCodexPromptDir(null, 'global');
@@ -246,14 +291,19 @@ class CodexSetup extends BaseIdeSetup {
* @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task')
* @returns {number} Number of skills written
*/
- isExemplarHelpTaskArtifact(artifact = {}) {
+ getConvertedTaskExportTarget(artifact = {}) {
if (artifact.type !== 'task' || artifact.module !== 'core') {
- return false;
+ return null;
}
const normalizedName = String(artifact.name || '')
.trim()
.toLowerCase();
+ const exportTarget = EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS[normalizedName];
+ if (!exportTarget) {
+ return null;
+ }
+
const normalizedRelativePath = String(artifact.relativePath || '')
.trim()
.replaceAll('\\', '/')
@@ -263,11 +313,17 @@ class CodexSetup extends BaseIdeSetup {
.replaceAll('\\', '/')
.toLowerCase();
- if (normalizedName !== 'help') {
- return false;
+ const normalizedRelativePathWithRoot = normalizedRelativePath.startsWith('/') ? normalizedRelativePath : `/${normalizedRelativePath}`;
+ if (!normalizedRelativePathWithRoot.endsWith(`/core/tasks/${normalizedName}.md`)) {
+ return null;
}
- return normalizedRelativePath.endsWith('/core/tasks/help.md') || normalizedSourcePath.endsWith('/core/tasks/help.md');
+ const normalizedSourcePathWithRoot = normalizedSourcePath.startsWith('/') ? normalizedSourcePath : `/${normalizedSourcePath}`;
+ if (normalizedSourcePath && !normalizedSourcePathWithRoot.endsWith(exportTarget.sourcePathSuffix)) {
+ return null;
+ }
+
+ return exportTarget;
}
throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
@@ -281,8 +337,8 @@ class CodexSetup extends BaseIdeSetup {
});
}
- async loadExemplarHelpSidecar(projectDir) {
- for (const candidate of EXEMPLAR_SIDECAR_SOURCE_CANDIDATES) {
+ async loadConvertedTaskSidecar(projectDir, exportTarget) {
+ for (const candidate of exportTarget.sidecarSourceCandidates) {
const sidecarPath = path.join(projectDir, ...candidate.segments);
if (await fs.pathExists(sidecarPath)) {
let sidecarData;
@@ -293,7 +349,7 @@ class CodexSetup extends BaseIdeSetup {
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
detail: `YAML parse failure: ${error.message}`,
fieldPath: '',
- sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
+ sourcePath: exportTarget.sidecarSourcePath,
observedValue: '',
cause: error,
});
@@ -304,7 +360,7 @@ class CodexSetup extends BaseIdeSetup {
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
detail: 'sidecar root must be a YAML mapping object',
fieldPath: '',
- sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
+ sourcePath: exportTarget.sidecarSourcePath,
observedValue: typeof sidecarData,
});
}
@@ -315,14 +371,14 @@ class CodexSetup extends BaseIdeSetup {
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_MISSING,
detail: 'sidecar canonicalId is required for exemplar export derivation',
fieldPath: 'canonicalId',
- sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
+ sourcePath: exportTarget.sidecarSourcePath,
observedValue: canonicalId,
});
}
return {
canonicalId,
- sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
+ sourcePath: exportTarget.sidecarSourcePath,
};
}
}
@@ -331,15 +387,15 @@ class CodexSetup extends BaseIdeSetup {
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
detail: 'expected exemplar sidecar metadata file was not found',
fieldPath: '',
- sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
+ sourcePath: exportTarget.sidecarSourcePath,
observedValue: projectDir,
});
}
async resolveSkillIdentityFromArtifact(artifact, projectDir) {
const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, '');
- const isExemplarHelpTask = this.isExemplarHelpTaskArtifact(artifact);
- if (!isExemplarHelpTask) {
+ const exportTarget = this.getConvertedTaskExportTarget(artifact);
+ if (!exportTarget) {
return {
skillName: inferredSkillName,
canonicalId: inferredSkillName,
@@ -348,14 +404,19 @@ class CodexSetup extends BaseIdeSetup {
};
}
- const sidecarData = await this.loadExemplarHelpSidecar(projectDir);
+ const sidecarData = await this.loadConvertedTaskSidecar(projectDir, exportTarget);
let canonicalResolution;
try {
- canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, {
+ const aliasResolutionOptions = {
fieldPath: 'canonicalId',
sourcePath: sidecarData.sourcePath,
- });
+ };
+ if (exportTarget.taskSourcePath === EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH) {
+ aliasResolutionOptions.aliasRows = SHARD_DOC_EXPORT_ALIAS_ROWS;
+ aliasResolutionOptions.aliasTableSourcePath = '_bmad/_config/canonical-aliases.csv';
+ }
+ canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions);
} catch (error) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
@@ -383,6 +444,7 @@ class CodexSetup extends BaseIdeSetup {
canonicalId: skillName,
exportIdDerivationSourceType: EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE,
exportIdDerivationSourcePath: sidecarData.sourcePath,
+ exportIdDerivationTaskSourcePath: exportTarget.taskSourcePath,
exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`,
};
}
@@ -402,6 +464,33 @@ class CodexSetup extends BaseIdeSetup {
// Create skill directory
const skillDir = path.join(destDir, skillName);
+ const skillPath = path.join(skillDir, 'SKILL.md');
+ const normalizedSkillPath = skillPath.replaceAll('\\', '/');
+ const ownerRecord = {
+ artifactType,
+ sourcePath: String(artifact.sourcePath || artifact.relativePath || ''),
+ };
+ const existingOwner = this.exportSurfaceIdentityOwners.get(normalizedSkillPath);
+ if (existingOwner) {
+ this.throwExportDerivationError({
+ code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE,
+ detail: `duplicate export surface path already claimed by ${existingOwner.artifactType}:${existingOwner.sourcePath}`,
+ fieldPath: 'canonicalId',
+ sourcePath: ownerRecord.sourcePath,
+ observedValue: normalizedSkillPath,
+ });
+ }
+
+ if (await fs.pathExists(skillPath)) {
+ this.throwExportDerivationError({
+ code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE,
+ detail: 'duplicate export surface path already exists on disk',
+ fieldPath: 'canonicalId',
+ sourcePath: ownerRecord.sourcePath,
+ observedValue: normalizedSkillPath,
+ });
+ }
+
await fs.ensureDir(skillDir);
// Transform content: rewrite frontmatter for skills format
@@ -409,14 +498,14 @@ class CodexSetup extends BaseIdeSetup {
// Write SKILL.md with platform-native line endings
const platformContent = skillContent.replaceAll('\n', os.EOL);
- const skillPath = path.join(skillDir, 'SKILL.md');
await fs.writeFile(skillPath, platformContent, 'utf8');
+ this.exportSurfaceIdentityOwners.set(normalizedSkillPath, ownerRecord);
writtenCount++;
if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) {
this.exportDerivationRecords.push({
exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'),
- sourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH,
+ sourcePath: exportIdentity.exportIdDerivationTaskSourcePath || EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH,
canonicalId: exportIdentity.canonicalId,
visibleId: skillName,
visibleSurfaceClass: 'export-id',