diff --git a/src/core/tasks/index-docs.artifact.yaml b/src/core/tasks/index-docs.artifact.yaml
new file mode 100644
index 000000000..3ba9f8ab3
--- /dev/null
+++ b/src/core/tasks/index-docs.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-index-docs
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/index-docs.xml
+displayName: Index Docs
+description: "Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything."
+dependencies:
+ requires: []
diff --git a/test/fixtures/index-docs/sidecar-negative/basename-path-mismatch/index-docs.artifact.yaml b/test/fixtures/index-docs/sidecar-negative/basename-path-mismatch/index-docs.artifact.yaml
new file mode 100644
index 000000000..88d48b041
--- /dev/null
+++ b/test/fixtures/index-docs/sidecar-negative/basename-path-mismatch/index-docs.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1
+canonicalId: bmad-index-docs
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/not-index-docs.xml
+displayName: Index Docs
+description: "Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything."
+dependencies:
+ requires: []
diff --git a/test/fixtures/index-docs/sidecar-negative/unknown-major-version/index-docs.artifact.yaml b/test/fixtures/index-docs/sidecar-negative/unknown-major-version/index-docs.artifact.yaml
new file mode 100644
index 000000000..2e3c07140
--- /dev/null
+++ b/test/fixtures/index-docs/sidecar-negative/unknown-major-version/index-docs.artifact.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 2
+canonicalId: bmad-index-docs
+artifactType: task
+module: core
+sourcePath: bmad-fork/src/core/tasks/index-docs.xml
+displayName: Index Docs
+description: "Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything."
+dependencies:
+ requires: []
diff --git a/test/test-installation-components.js b/test/test-installation-components.js
index 208b48cb0..8e3b872f4 100644
--- a/test/test-installation-components.js
+++ b/test/test-installation-components.js
@@ -34,8 +34,11 @@ const {
HELP_SIDECAR_ERROR_CODES,
SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
SHARD_DOC_SIDECAR_ERROR_CODES,
+ INDEX_DOCS_SIDECAR_REQUIRED_FIELDS,
+ INDEX_DOCS_SIDECAR_ERROR_CODES,
validateHelpSidecarContractFile,
validateShardDocSidecarContractFile,
+ validateIndexDocsSidecarContractFile,
} = require('../tools/cli/installers/lib/core/sidecar-contract-validator');
const {
HELP_FRONTMATTER_MISMATCH_ERROR_CODES,
@@ -45,6 +48,10 @@ const {
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES,
validateShardDocAuthoritySplitAndPrecedence,
} = require('../tools/cli/installers/lib/core/shard-doc-authority-validator');
+const {
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES,
+ validateIndexDocsAuthoritySplitAndPrecedence,
+} = require('../tools/cli/installers/lib/core/index-docs-authority-validator');
const {
HELP_CATALOG_GENERATION_ERROR_CODES,
EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
@@ -594,6 +601,203 @@ async function runTests() {
console.log('');
+ // ============================================================
+ // Test 4c: Index-docs Sidecar Contract Validation
+ // ============================================================
+ console.log(`${colors.yellow}Test Suite 4c: Index-docs Sidecar Contract Validation${colors.reset}\n`);
+
+ const validIndexDocsSidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-index-docs',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml',
+ displayName: 'Index Docs',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const indexDocsFixtureRoot = path.join(projectRoot, 'test', 'fixtures', 'index-docs', 'sidecar-negative');
+ const indexDocsUnknownMajorFixturePath = path.join(indexDocsFixtureRoot, 'unknown-major-version', 'index-docs.artifact.yaml');
+ const indexDocsBasenameMismatchFixturePath = path.join(indexDocsFixtureRoot, 'basename-path-mismatch', 'index-docs.artifact.yaml');
+
+ const tempIndexDocsRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-index-docs-sidecar-'));
+ const tempIndexDocsSidecarPath = path.join(tempIndexDocsRoot, 'index-docs.artifact.yaml');
+ const deterministicIndexDocsSourcePath = 'bmad-fork/src/core/tasks/index-docs.artifact.yaml';
+
+ const writeTempIndexDocsSidecar = async (data) => {
+ await fs.writeFile(tempIndexDocsSidecarPath, yaml.stringify(data), 'utf8');
+ };
+
+ const expectIndexDocsValidationError = async (data, expectedCode, expectedFieldPath, testLabel, expectedDetail = null) => {
+ await writeTempIndexDocsSidecar(data);
+
+ try {
+ await validateIndexDocsSidecarContractFile(tempIndexDocsSidecarPath, { errorSourcePath: deterministicIndexDocsSourcePath });
+ 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 === deterministicIndexDocsSourcePath,
+ `${testLabel} returns expected source path`,
+ `Expected ${deterministicIndexDocsSourcePath}, got ${error.sourcePath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(expectedCode) &&
+ error.message.includes(expectedFieldPath) &&
+ error.message.includes(deterministicIndexDocsSourcePath),
+ `${testLabel} includes deterministic message context`,
+ );
+ if (expectedDetail !== null) {
+ assert(
+ error.detail === expectedDetail,
+ `${testLabel} returns locked detail string`,
+ `Expected "${expectedDetail}", got "${error.detail}"`,
+ );
+ }
+ }
+ };
+
+ try {
+ await writeTempIndexDocsSidecar(validIndexDocsSidecar);
+ await validateIndexDocsSidecarContractFile(tempIndexDocsSidecarPath, { errorSourcePath: deterministicIndexDocsSourcePath });
+ assert(true, 'Valid index-docs sidecar contract passes');
+
+ for (const requiredField of INDEX_DOCS_SIDECAR_REQUIRED_FIELDS.filter((field) => field !== 'dependencies')) {
+ const invalidSidecar = structuredClone(validIndexDocsSidecar);
+ delete invalidSidecar[requiredField];
+ await expectIndexDocsValidationError(
+ invalidSidecar,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
+ requiredField,
+ `Index-docs missing required field "${requiredField}"`,
+ );
+ }
+
+ const unknownMajorFixture = yaml.parse(await fs.readFile(indexDocsUnknownMajorFixturePath, 'utf8'));
+ await expectIndexDocsValidationError(
+ unknownMajorFixture,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
+ 'schemaVersion',
+ 'Index-docs unsupported sidecar major schema version',
+ 'sidecar schema major version is unsupported',
+ );
+
+ const basenameMismatchFixture = yaml.parse(await fs.readFile(indexDocsBasenameMismatchFixturePath, 'utf8'));
+ await expectIndexDocsValidationError(
+ basenameMismatchFixture,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'sourcePath',
+ 'Index-docs sourcePath mismatch',
+ 'sidecar basename does not match sourcePath basename',
+ );
+
+ const mismatchedIndexDocsBasenamePath = path.join(tempIndexDocsRoot, 'not-index-docs.artifact.yaml');
+ await fs.writeFile(mismatchedIndexDocsBasenamePath, yaml.stringify(validIndexDocsSidecar), 'utf8');
+ try {
+ await validateIndexDocsSidecarContractFile(mismatchedIndexDocsBasenamePath, {
+ errorSourcePath: 'bmad-fork/src/core/tasks/not-index-docs.artifact.yaml',
+ });
+ assert(false, 'Index-docs basename mismatch returns validation error', 'Expected validation error but validation passed');
+ } catch (error) {
+ assert(
+ error.code === INDEX_DOCS_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
+ 'Index-docs basename mismatch returns expected error code',
+ );
+ assert(
+ error.fieldPath === 'sourcePath',
+ 'Index-docs basename mismatch returns expected field path',
+ `Expected sourcePath, got ${error.fieldPath}`,
+ );
+ assert(
+ typeof error.message === 'string' &&
+ error.message.includes(INDEX_DOCS_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH) &&
+ error.message.includes('bmad-fork/src/core/tasks/not-index-docs.artifact.yaml'),
+ 'Index-docs basename mismatch includes deterministic message context',
+ );
+ }
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, artifactType: 'workflow' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
+ 'artifactType',
+ 'Index-docs invalid artifactType',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, module: 'bmm' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.MODULE_INVALID,
+ 'module',
+ 'Index-docs invalid module',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, canonicalId: ' ' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'canonicalId',
+ 'Index-docs empty canonicalId',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, sourcePath: '' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'sourcePath',
+ 'Index-docs empty sourcePath',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, description: '' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'description',
+ 'Index-docs empty description',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, displayName: '' },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
+ 'displayName',
+ 'Index-docs empty displayName',
+ );
+
+ const missingIndexDocsDependencies = structuredClone(validIndexDocsSidecar);
+ delete missingIndexDocsDependencies.dependencies;
+ await expectIndexDocsValidationError(
+ missingIndexDocsDependencies,
+ INDEX_DOCS_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
+ 'dependencies',
+ 'Index-docs missing dependencies block',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, dependencies: { requires: 'skill:bmad-help' } },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
+ 'dependencies.requires',
+ 'Index-docs non-array dependencies.requires',
+ );
+
+ await expectIndexDocsValidationError(
+ { ...validIndexDocsSidecar, dependencies: { requires: ['skill:bmad-help'] } },
+ INDEX_DOCS_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
+ 'dependencies.requires',
+ 'Index-docs non-empty dependencies.requires',
+ );
+ } catch (error) {
+ assert(false, 'Index-docs sidecar validation suite setup', error.message);
+ } finally {
+ await fs.remove(tempIndexDocsRoot);
+ }
+
+ console.log('');
+
// ============================================================
// Test 5: Authority Split and Frontmatter Precedence
// ============================================================
@@ -996,6 +1200,218 @@ async function runTests() {
);
await fs.writeFile(tempShardDocAuthoritySidecarPath, yaml.stringify(validShardDocAuthoritySidecar), 'utf8');
+
+ const tempIndexDocsAuthoritySidecarPath = path.join(tempAuthorityRoot, 'index-docs.artifact.yaml');
+ const tempIndexDocsAuthoritySourcePath = path.join(tempAuthorityRoot, 'index-docs.xml');
+ const tempIndexDocsModuleHelpPath = path.join(tempAuthorityRoot, 'index-docs-module-help.csv');
+
+ const deterministicIndexDocsAuthorityPaths = {
+ sidecar: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ source: 'bmad-fork/src/core/tasks/index-docs.xml',
+ compatibility: 'bmad-fork/src/core/module-help.csv',
+ workflowFile: '_bmad/core/tasks/index-docs.xml',
+ };
+
+ const validIndexDocsAuthoritySidecar = {
+ schemaVersion: 1,
+ canonicalId: 'bmad-index-docs',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: deterministicIndexDocsAuthorityPaths.source,
+ displayName: 'Index Docs',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ dependencies: {
+ requires: [],
+ },
+ };
+
+ const writeIndexDocsModuleHelpCsv = 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 ?? 'Index Docs',
+ row.code ?? 'ID',
+ 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(tempIndexDocsModuleHelpPath, [header, ...lines].join('\n'), 'utf8');
+ };
+
+ const runIndexDocsAuthorityValidation = async () =>
+ validateIndexDocsAuthoritySplitAndPrecedence({
+ sidecarPath: tempIndexDocsAuthoritySidecarPath,
+ sourceXmlPath: tempIndexDocsAuthoritySourcePath,
+ compatibilityCatalogPath: tempIndexDocsModuleHelpPath,
+ sidecarSourcePath: deterministicIndexDocsAuthorityPaths.sidecar,
+ sourceXmlSourcePath: deterministicIndexDocsAuthorityPaths.source,
+ compatibilityCatalogSourcePath: deterministicIndexDocsAuthorityPaths.compatibility,
+ compatibilityWorkflowFilePath: deterministicIndexDocsAuthorityPaths.workflowFile,
+ });
+
+ const expectIndexDocsAuthorityValidationError = async (
+ rows,
+ expectedCode,
+ expectedFieldPath,
+ testLabel,
+ expectedSourcePath = deterministicIndexDocsAuthorityPaths.compatibility,
+ ) => {
+ await writeIndexDocsModuleHelpCsv(rows);
+
+ try {
+ await runIndexDocsAuthorityValidation();
+ assert(false, testLabel, 'Expected index-docs 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(tempIndexDocsAuthoritySidecarPath, yaml.stringify(validIndexDocsAuthoritySidecar), 'utf8');
+ await fs.writeFile(tempIndexDocsAuthoritySourcePath, '\n', 'utf8');
+
+ await writeIndexDocsModuleHelpCsv([
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ ]);
+
+ const indexDocsAuthorityValidation = await runIndexDocsAuthorityValidation();
+ assert(
+ indexDocsAuthorityValidation.authoritativePresenceKey === 'capability:bmad-index-docs',
+ 'Index-docs authority validation returns expected authoritative presence key',
+ );
+ assert(
+ Array.isArray(indexDocsAuthorityValidation.authoritativeRecords) && indexDocsAuthorityValidation.authoritativeRecords.length === 2,
+ 'Index-docs authority validation returns sidecar and source authority records',
+ );
+
+ const indexDocsSidecarRecord = indexDocsAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'sidecar',
+ );
+ const indexDocsSourceRecord = indexDocsAuthorityValidation.authoritativeRecords.find(
+ (record) => record.authoritySourceType === 'source-xml',
+ );
+
+ assert(
+ indexDocsSidecarRecord &&
+ indexDocsSourceRecord &&
+ indexDocsSidecarRecord.authoritativePresenceKey === indexDocsSourceRecord.authoritativePresenceKey,
+ 'Index-docs sidecar and source-xml records share one authoritative presence key',
+ );
+ assert(
+ indexDocsSidecarRecord &&
+ indexDocsSourceRecord &&
+ indexDocsSidecarRecord.authoritativePresenceKey === 'capability:bmad-index-docs' &&
+ indexDocsSourceRecord.authoritativePresenceKey === 'capability:bmad-index-docs',
+ 'Index-docs authority records lock authoritative presence key to capability:bmad-index-docs',
+ );
+ assert(
+ indexDocsSidecarRecord && indexDocsSidecarRecord.authoritySourcePath === deterministicIndexDocsAuthorityPaths.sidecar,
+ 'Index-docs metadata authority record preserves sidecar source path',
+ );
+ assert(
+ indexDocsSourceRecord && indexDocsSourceRecord.authoritySourcePath === deterministicIndexDocsAuthorityPaths.source,
+ 'Index-docs source-body authority record preserves source XML path',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'legacy-index-docs',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'command',
+ 'Index-docs compatibility command mismatch',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: '_bmad/core/tasks/help.md',
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
+ 'workflow-file',
+ 'Index-docs missing compatibility row',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ {
+ workflowFile: '_bmad/core/tasks/another.xml',
+ command: 'bmad-index-docs',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
+ 'command',
+ 'Index-docs duplicate canonical command rows',
+ );
+
+ await fs.writeFile(
+ tempIndexDocsAuthoritySidecarPath,
+ yaml.stringify({
+ ...validIndexDocsAuthoritySidecar,
+ canonicalId: 'bmad-index-docs-renamed',
+ }),
+ 'utf8',
+ );
+
+ await expectIndexDocsAuthorityValidationError(
+ [
+ {
+ workflowFile: deterministicIndexDocsAuthorityPaths.workflowFile,
+ command: 'bmad-index-docs-renamed',
+ name: 'Index Docs',
+ },
+ ],
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'canonicalId',
+ 'Index-docs canonicalId drift fails deterministic authority validation',
+ deterministicIndexDocsAuthorityPaths.sidecar,
+ );
+
+ await fs.writeFile(tempIndexDocsAuthoritySidecarPath, yaml.stringify(validIndexDocsAuthoritySidecar), 'utf8');
} catch (error) {
assert(false, 'Authority split and precedence suite setup', error.message);
} finally {
@@ -1016,7 +1432,9 @@ async function runTests() {
{
const installer = new Installer();
let shardDocValidationCalled = false;
+ let indexDocsValidationCalled = false;
let shardDocAuthorityValidationCalled = false;
+ let indexDocsAuthorityValidationCalled = false;
let helpAuthorityValidationCalled = false;
let generateConfigsCalled = false;
let manifestGenerationCalled = false;
@@ -1026,6 +1444,9 @@ async function runTests() {
installer.validateShardDocSidecarContractFile = async () => {
shardDocValidationCalled = true;
};
+ installer.validateIndexDocsSidecarContractFile = async () => {
+ indexDocsValidationCalled = true;
+ };
installer.validateHelpSidecarContractFile = async () => {
const error = new Error(expectedUnsupportedMajorDetail);
error.code = HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED;
@@ -1041,6 +1462,13 @@ async function runTests() {
authoritativePresenceKey: 'capability:bmad-shard-doc',
};
};
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
installer.validateHelpAuthoritySplitAndPrecedence = async () => {
helpAuthorityValidationCalled = true;
@@ -1093,8 +1521,10 @@ async function runTests() {
`Expected ${HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED}, got ${error.code}`,
);
assert(shardDocValidationCalled, 'Installer runs shard-doc sidecar validation before help sidecar validation');
+ assert(indexDocsValidationCalled, 'Installer runs index-docs sidecar validation before help sidecar validation');
assert(
!shardDocAuthorityValidationCalled &&
+ !indexDocsAuthorityValidationCalled &&
!helpAuthorityValidationCalled &&
!generateConfigsCalled &&
!manifestGenerationCalled &&
@@ -1153,8 +1583,10 @@ async function runTests() {
for (const scenario of shardDocFailureScenarios) {
const installer = new Installer();
+ let indexDocsValidationCalled = false;
let helpValidationCalled = false;
let shardDocAuthorityValidationCalled = false;
+ let indexDocsAuthorityValidationCalled = false;
let helpAuthorityValidationCalled = false;
let generateConfigsCalled = false;
let manifestGenerationCalled = false;
@@ -1169,6 +1601,9 @@ async function runTests() {
error.detail = scenario.detail;
throw error;
};
+ installer.validateIndexDocsSidecarContractFile = async () => {
+ indexDocsValidationCalled = true;
+ };
installer.validateHelpSidecarContractFile = async () => {
helpValidationCalled = true;
};
@@ -1179,6 +1614,13 @@ async function runTests() {
authoritativePresenceKey: 'capability:bmad-shard-doc',
};
};
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
installer.validateHelpAuthoritySplitAndPrecedence = async () => {
helpAuthorityValidationCalled = true;
return {
@@ -1223,9 +1665,11 @@ async function runTests() {
error.sourcePath === deterministicShardDocFailFastSourcePath,
`Installer ${scenario.label} returns deterministic source path`,
);
+ assert(!indexDocsValidationCalled, `Installer ${scenario.label} aborts before index-docs sidecar validation`);
assert(!helpValidationCalled, `Installer ${scenario.label} aborts before help sidecar validation`);
assert(
!shardDocAuthorityValidationCalled &&
+ !indexDocsAuthorityValidationCalled &&
!helpAuthorityValidationCalled &&
!generateConfigsCalled &&
!manifestGenerationCalled &&
@@ -1240,6 +1684,7 @@ async function runTests() {
// 6c: Shard-doc authority precedence conflict fails fast before help authority or generation.
{
const installer = new Installer();
+ let indexDocsAuthorityValidationCalled = false;
let helpAuthorityValidationCalled = false;
let generateConfigsCalled = false;
let manifestGenerationCalled = false;
@@ -1247,6 +1692,7 @@ async function runTests() {
let successResultCount = 0;
installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateIndexDocsSidecarContractFile = async () => {};
installer.validateHelpSidecarContractFile = async () => {};
installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
const error = new Error('Converted shard-doc compatibility command must match sidecar canonicalId');
@@ -1255,6 +1701,13 @@ async function runTests() {
error.sourcePath = 'bmad-fork/src/core/module-help.csv';
throw error;
};
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
installer.validateHelpAuthoritySplitAndPrecedence = async () => {
helpAuthorityValidationCalled = true;
return {
@@ -1303,13 +1756,17 @@ async function runTests() {
'Installer shard-doc authority mismatch returns deterministic source path',
);
assert(
- !helpAuthorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled,
+ !indexDocsAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
'Installer shard-doc authority mismatch blocks downstream help authority/config/manifest/help generation',
);
assert(
- successResultCount === 2,
+ successResultCount === 3,
'Installer shard-doc authority mismatch records only sidecar gate pass milestones before abort',
- `Expected 2, got ${successResultCount}`,
+ `Expected 3, got ${successResultCount}`,
);
}
}
@@ -1317,6 +1774,7 @@ async function runTests() {
// 6d: Shard-doc canonical drift fails fast before help authority or generation.
{
const installer = new Installer();
+ let indexDocsAuthorityValidationCalled = false;
let helpAuthorityValidationCalled = false;
let generateConfigsCalled = false;
let manifestGenerationCalled = false;
@@ -1324,6 +1782,7 @@ async function runTests() {
let successResultCount = 0;
installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateIndexDocsSidecarContractFile = async () => {};
installer.validateHelpSidecarContractFile = async () => {};
installer.validateShardDocAuthoritySplitAndPrecedence = async () => {
const error = new Error('Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc');
@@ -1332,6 +1791,13 @@ async function runTests() {
error.sourcePath = 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml';
throw error;
};
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ indexDocsAuthorityValidationCalled = true;
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
installer.validateHelpAuthoritySplitAndPrecedence = async () => {
helpAuthorityValidationCalled = true;
return {
@@ -1380,13 +1846,17 @@ async function runTests() {
'Installer shard-doc canonical drift returns deterministic source path',
);
assert(
- !helpAuthorityValidationCalled && !generateConfigsCalled && !manifestGenerationCalled && !helpCatalogGenerationCalled,
+ !indexDocsAuthorityValidationCalled &&
+ !helpAuthorityValidationCalled &&
+ !generateConfigsCalled &&
+ !manifestGenerationCalled &&
+ !helpCatalogGenerationCalled,
'Installer shard-doc canonical drift blocks downstream help authority/config/manifest/help generation',
);
assert(
- successResultCount === 2,
+ successResultCount === 3,
'Installer shard-doc canonical drift records only sidecar gate pass milestones before abort',
- `Expected 2, got ${successResultCount}`,
+ `Expected 3, got ${successResultCount}`,
);
}
}
@@ -1400,6 +1870,9 @@ async function runTests() {
installer.validateShardDocSidecarContractFile = async () => {
executionOrder.push('shard-doc-sidecar');
};
+ installer.validateIndexDocsSidecarContractFile = async () => {
+ executionOrder.push('index-docs-sidecar');
+ };
installer.validateHelpSidecarContractFile = async () => {
executionOrder.push('help-sidecar');
};
@@ -1410,6 +1883,13 @@ async function runTests() {
authoritativePresenceKey: 'capability:bmad-shard-doc',
};
};
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async () => {
+ executionOrder.push('index-docs-authority');
+ return {
+ authoritativeRecords: [],
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ };
+ };
installer.validateHelpAuthoritySplitAndPrecedence = async () => {
executionOrder.push('help-authority');
return {
@@ -1448,7 +1928,7 @@ async function runTests() {
assert(
executionOrder.join(' -> ') ===
- 'shard-doc-sidecar -> help-sidecar -> shard-doc-authority -> help-authority -> config-generation -> manifest-generation -> help-catalog-generation',
+ 'shard-doc-sidecar -> index-docs-sidecar -> help-sidecar -> shard-doc-authority -> index-docs-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(' -> ')}`,
);
@@ -1456,10 +1936,18 @@ async function runTests() {
resultMilestones.includes('Shard-doc sidecar contract'),
'Installer valid sidecar path records explicit shard-doc sidecar gate pass milestone',
);
+ assert(
+ resultMilestones.includes('Index-docs sidecar contract'),
+ 'Installer valid sidecar path records explicit index-docs sidecar gate pass milestone',
+ );
assert(
resultMilestones.includes('Shard-doc authority split'),
'Installer valid sidecar path records explicit shard-doc authority gate pass milestone',
);
+ assert(
+ resultMilestones.includes('Index-docs authority split'),
+ 'Installer valid sidecar path records explicit index-docs authority gate pass milestone',
+ );
}
} catch (error) {
assert(false, 'Installer fail-fast test setup', error.message);
@@ -1934,6 +2422,15 @@ async function runTests() {
path: 'core/tasks/shard-doc.xml',
standalone: true,
},
+ {
+ name: 'index-docs',
+ displayName: 'Index Docs',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ module: 'core',
+ path: 'core/tasks/index-docs.xml',
+ standalone: true,
+ },
];
manifestGenerator.helpAuthorityRecords = [
{
@@ -1955,6 +2452,14 @@ async function runTests() {
authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
},
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml',
+ },
];
const tempTaskManifestConfigDir = path.join(tempTaskManifestRoot, '_config');
await fs.ensureDir(tempTaskManifestConfigDir);
@@ -1978,6 +2483,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');
+ const indexDocsTaskRow = writtenTaskManifestRecords.find((record) => record.module === 'core' && record.name === 'index-docs');
assert(!!helpTaskRow, 'Task manifest includes exemplar help row');
assert(helpTaskRow && helpTaskRow.legacyName === 'help', 'Task manifest help row sets legacyName=help');
@@ -2007,6 +2513,20 @@ async function runTests() {
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',
);
+ assert(!!indexDocsTaskRow, 'Task manifest includes converted index-docs row');
+ assert(indexDocsTaskRow && indexDocsTaskRow.legacyName === 'index-docs', 'Task manifest index-docs row sets legacyName=index-docs');
+ assert(
+ indexDocsTaskRow && indexDocsTaskRow.canonicalId === 'bmad-index-docs',
+ 'Task manifest index-docs row sets canonicalId=bmad-index-docs',
+ );
+ assert(
+ indexDocsTaskRow && indexDocsTaskRow.authoritySourceType === 'sidecar',
+ 'Task manifest index-docs row sets authoritySourceType=sidecar',
+ );
+ assert(
+ indexDocsTaskRow && indexDocsTaskRow.authoritySourcePath === 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ 'Task manifest index-docs row sets authoritySourcePath to index-docs sidecar source path',
+ );
await manifestGenerator.writeTaskManifest(tempTaskManifestConfigDir);
const repeatedTaskManifestRaw = await fs.readFile(path.join(tempTaskManifestConfigDir, 'task-manifest.csv'), 'utf8');
@@ -2017,12 +2537,14 @@ async function runTests() {
let capturedAuthorityValidationOptions = null;
let capturedShardDocAuthorityValidationOptions = null;
+ let capturedIndexDocsAuthorityValidationOptions = null;
let capturedManifestHelpAuthorityRecords = null;
let capturedManifestTaskAuthorityRecords = null;
let capturedInstalledFiles = null;
const installer = new Installer();
installer.validateShardDocSidecarContractFile = async () => {};
+ installer.validateIndexDocsSidecarContractFile = async () => {};
installer.validateHelpSidecarContractFile = async () => {};
installer.validateShardDocAuthoritySplitAndPrecedence = async (options) => {
capturedShardDocAuthorityValidationOptions = options;
@@ -2048,6 +2570,30 @@ async function runTests() {
],
};
};
+ installer.validateIndexDocsAuthoritySplitAndPrecedence = async (options) => {
+ capturedIndexDocsAuthorityValidationOptions = options;
+ return {
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritativeRecords: [
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: options.sidecarSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: options.sourceXmlSourcePath,
+ sourcePath: options.sourceXmlSourcePath,
+ },
+ ],
+ };
+ };
installer.validateHelpAuthoritySplitAndPrecedence = async (options) => {
capturedAuthorityValidationOptions = options;
return {
@@ -2118,6 +2664,21 @@ async function runTests() {
capturedShardDocAuthorityValidationOptions.compatibilityCatalogSourcePath === 'bmad-fork/src/core/module-help.csv',
'Installer passes locked module-help source path to shard-doc authority validation',
);
+ assert(
+ capturedIndexDocsAuthorityValidationOptions &&
+ capturedIndexDocsAuthorityValidationOptions.sidecarSourcePath === 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ 'Installer passes locked index-docs sidecar source path to index-docs authority validation',
+ );
+ assert(
+ capturedIndexDocsAuthorityValidationOptions &&
+ capturedIndexDocsAuthorityValidationOptions.sourceXmlSourcePath === 'bmad-fork/src/core/tasks/index-docs.xml',
+ 'Installer passes locked index-docs source XML path to index-docs authority validation',
+ );
+ assert(
+ capturedIndexDocsAuthorityValidationOptions &&
+ capturedIndexDocsAuthorityValidationOptions.compatibilityCatalogSourcePath === 'bmad-fork/src/core/module-help.csv',
+ 'Installer passes locked module-help source path to index-docs authority validation',
+ );
assert(
Array.isArray(capturedManifestHelpAuthorityRecords) &&
capturedManifestHelpAuthorityRecords[0] &&
@@ -2135,6 +2696,17 @@ async function runTests() {
),
'Installer passes shard-doc sidecar authority records into task-manifest projection options',
);
+ assert(
+ Array.isArray(capturedManifestTaskAuthorityRecords) &&
+ capturedManifestTaskAuthorityRecords.some(
+ (record) =>
+ record &&
+ record.canonicalId === 'bmad-index-docs' &&
+ record.authoritySourceType === 'sidecar' &&
+ record.authoritySourcePath === 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ ),
+ 'Installer passes index-docs sidecar authority records into task-manifest projection options',
+ );
assert(
Array.isArray(capturedInstalledFiles) &&
capturedInstalledFiles.some((filePath) => filePath.endsWith('/_config/canonical-aliases.csv')),
@@ -2178,6 +2750,14 @@ async function runTests() {
authoritySourcePath: 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
sourcePath: 'bmad-fork/src/core/tasks/shard-doc.xml',
},
+ {
+ recordType: 'metadata-authority',
+ canonicalId: 'bmad-index-docs',
+ authoritativePresenceKey: 'capability:bmad-index-docs',
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml',
+ },
];
const tempCanonicalAliasConfigDir = path.join(tempCanonicalAliasRoot, '_config');
@@ -2198,10 +2778,10 @@ async function runTests() {
skip_empty_lines: true,
trim: true,
});
- assert(canonicalAliasRows.length === 6, 'Canonical alias table emits help + shard-doc canonical alias exemplar rows');
+ assert(canonicalAliasRows.length === 9, 'Canonical alias table emits help + shard-doc + index-docs canonical alias exemplar rows');
assert(
canonicalAliasRows.map((row) => row.aliasType).join(',') ===
- 'canonical-id,legacy-name,slash-command,canonical-id,legacy-name,slash-command',
+ 'canonical-id,legacy-name,slash-command,canonical-id,legacy-name,slash-command,canonical-id,legacy-name,slash-command',
'Canonical alias table preserves locked deterministic row ordering',
);
@@ -2278,6 +2858,42 @@ async function runTests() {
resolutionEligibility: 'slash-command-only',
},
],
+ [
+ 'alias-row:bmad-index-docs:canonical-id',
+ {
+ canonicalId: 'bmad-index-docs',
+ alias: 'bmad-index-docs',
+ aliasType: 'canonical-id',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'canonical-id-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-index-docs:legacy-name',
+ {
+ canonicalId: 'bmad-index-docs',
+ alias: 'index-docs',
+ aliasType: 'legacy-name',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ normalizedAliasValue: 'index-docs',
+ rawIdentityHasLeadingSlash: 'false',
+ resolutionEligibility: 'legacy-name-only',
+ },
+ ],
+ [
+ 'alias-row:bmad-index-docs:slash-command',
+ {
+ canonicalId: 'bmad-index-docs',
+ alias: '/bmad-index-docs',
+ aliasType: 'slash-command',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: 'true',
+ resolutionEligibility: 'slash-command-only',
+ },
+ ],
]);
for (const [rowIdentity, expectedRow] of expectedRowsByIdentity) {
@@ -2506,6 +3122,7 @@ async function runTests() {
const exemplarRows = generatedHelpRows.filter((row) => row.command === 'bmad-help');
const shardDocRows = generatedHelpRows.filter((row) => row.command === 'bmad-shard-doc');
+ const indexDocsRows = generatedHelpRows.filter((row) => row.command === 'bmad-index-docs');
assert(exemplarRows.length === 1, 'Help catalog emits exactly one exemplar raw command row for bmad-help');
assert(
exemplarRows[0] && exemplarRows[0].name === 'bmad-help',
@@ -2516,6 +3133,11 @@ async function runTests() {
shardDocRows[0] && shardDocRows[0]['workflow-file'] === '_bmad/core/tasks/shard-doc.xml',
'Help catalog shard-doc row preserves locked shard-doc workflow identity',
);
+ assert(indexDocsRows.length === 1, 'Help catalog emits exactly one index-docs raw command row for bmad-index-docs');
+ assert(
+ indexDocsRows[0] && indexDocsRows[0]['workflow-file'] === '_bmad/core/tasks/index-docs.xml',
+ 'Help catalog index-docs row preserves locked index-docs workflow identity',
+ );
const sidecarRaw = await fs.readFile(path.join(projectRoot, 'src', 'core', 'tasks', 'help.artifact.yaml'), 'utf8');
const sidecarData = yaml.parse(sidecarRaw);
@@ -2527,7 +3149,8 @@ async function runTests() {
const commandLabelRows = installer.helpCatalogCommandLabelReportRows || [];
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');
+ const indexDocsCommandLabelRow = commandLabelRows.find((row) => row.canonicalId === 'bmad-index-docs');
+ assert(commandLabelRows.length === 3, 'Installer emits command-label report rows for help, shard-doc, and index-docs canonical ids');
assert(
helpCommandLabelRow &&
helpCommandLabelRow.rawCommandValue === 'bmad-help' &&
@@ -2552,6 +3175,18 @@ async function runTests() {
shardDocCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/shard-doc.artifact.yaml',
'Command-label report includes shard-doc sidecar provenance linkage',
);
+ assert(
+ indexDocsCommandLabelRow &&
+ indexDocsCommandLabelRow.rawCommandValue === 'bmad-index-docs' &&
+ indexDocsCommandLabelRow.displayedCommandLabel === '/bmad-index-docs',
+ 'Command-label report locks raw and displayed command values for index-docs',
+ );
+ assert(
+ indexDocsCommandLabelRow &&
+ indexDocsCommandLabelRow.authoritySourceType === 'sidecar' &&
+ indexDocsCommandLabelRow.authoritySourcePath === 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ 'Command-label report includes index-docs sidecar provenance linkage',
+ );
const generatedCommandLabelReportRaw = await fs.readFile(generatedCommandLabelReportPath, 'utf8');
const generatedCommandLabelReportRows = csv.parse(generatedCommandLabelReportRaw, {
columns: true,
@@ -2560,15 +3195,19 @@ async function runTests() {
});
const generatedHelpCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-help');
const generatedShardDocCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-shard-doc');
+ const generatedIndexDocsCommandLabelRow = generatedCommandLabelReportRows.find((row) => row.canonicalId === 'bmad-index-docs');
assert(
- generatedCommandLabelReportRows.length === 2 &&
+ generatedCommandLabelReportRows.length === 3 &&
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',
+ generatedShardDocCommandLabelRow.rowCountForCanonicalId === '1' &&
+ generatedIndexDocsCommandLabelRow &&
+ generatedIndexDocsCommandLabelRow.displayedCommandLabel === '/bmad-index-docs' &&
+ generatedIndexDocsCommandLabelRow.rowCountForCanonicalId === '1',
+ 'Installer persists command-label report artifact with locked help, shard-doc, and index-docs label contract values',
);
const baselineLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows);
@@ -2587,6 +3226,16 @@ async function runTests() {
'Command-label validator passes when exactly one /bmad-shard-doc displayed label row exists',
baselineShardDocLabelContract.reason,
);
+ const baselineIndexDocsLabelContract = evaluateExemplarCommandLabelReportRows(commandLabelRows, {
+ canonicalId: 'bmad-index-docs',
+ displayedCommandLabel: '/bmad-index-docs',
+ authoritySourcePath: 'bmad-fork/src/core/tasks/index-docs.artifact.yaml',
+ });
+ assert(
+ baselineIndexDocsLabelContract.valid,
+ 'Command-label validator passes when exactly one /bmad-index-docs displayed label row exists',
+ baselineIndexDocsLabelContract.reason,
+ );
const commandDocsSourcePath = path.join(projectRoot, 'docs', 'reference', 'commands.md');
const commandDocsMarkdown = await fs.readFile(commandDocsSourcePath, 'utf8');
@@ -2827,6 +3476,21 @@ async function runTests() {
}),
'utf8',
);
+ await fs.writeFile(
+ path.join(tempExportRoot, 'bmad-fork', 'src', 'core', 'tasks', 'index-docs.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'bmad-index-docs',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml',
+ displayName: 'Index Docs',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
const exemplarTaskArtifact = {
type: 'task',
@@ -2844,6 +3508,14 @@ async function runTests() {
relativePath: path.join('core', 'tasks', 'shard-doc.md'),
content: 'Split markdown docs\n',
};
+ const indexDocsTaskArtifact = {
+ type: 'task',
+ name: 'index-docs',
+ module: 'core',
+ sourcePath: path.join(tempExportRoot, '_bmad', 'core', 'tasks', 'index-docs.xml'),
+ relativePath: path.join('core', 'tasks', 'index-docs.md'),
+ content: 'Index docs\n',
+ };
const writtenCount = await codexSetup.writeSkillArtifacts(skillsDir, [exemplarTaskArtifact], 'task', {
projectDir: tempExportRoot,
@@ -2900,6 +3572,33 @@ async function runTests() {
'Codex export records shard-doc sidecar-canonical derivation metadata and source path',
);
+ const indexDocsWrittenCount = await codexSetup.writeSkillArtifacts(skillsDir, [indexDocsTaskArtifact], 'task', {
+ projectDir: tempExportRoot,
+ });
+ assert(indexDocsWrittenCount === 1, 'Codex export writes one index-docs converted skill artifact');
+
+ const indexDocsSkillPath = path.join(skillsDir, 'bmad-index-docs', 'SKILL.md');
+ assert(await fs.pathExists(indexDocsSkillPath), 'Codex export derives index-docs skill path from sidecar canonical identity');
+
+ const indexDocsSkillRaw = await fs.readFile(indexDocsSkillPath, 'utf8');
+ const indexDocsFrontmatterMatch = indexDocsSkillRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ const indexDocsFrontmatter = indexDocsFrontmatterMatch ? yaml.parse(indexDocsFrontmatterMatch[1]) : null;
+ assert(
+ indexDocsFrontmatter && indexDocsFrontmatter.name === 'bmad-index-docs',
+ 'Codex export frontmatter sets index-docs required name from sidecar canonical identity',
+ );
+
+ const indexDocsExportDerivationRecord = codexSetup.exportDerivationRecords.find(
+ (row) => row.exportPath === '.agents/skills/bmad-index-docs/SKILL.md',
+ );
+ assert(
+ indexDocsExportDerivationRecord &&
+ indexDocsExportDerivationRecord.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE &&
+ indexDocsExportDerivationRecord.exportIdDerivationSourcePath === 'bmad-fork/src/core/tasks/index-docs.artifact.yaml' &&
+ indexDocsExportDerivationRecord.sourcePath === 'bmad-fork/src/core/tasks/index-docs.xml',
+ 'Codex export records index-docs sidecar-canonical derivation metadata and source path',
+ );
+
const duplicateExportSetup = new CodexSetup();
const duplicateSkillDir = path.join(tempExportRoot, '.agents', 'skills-duplicate-check');
await fs.ensureDir(duplicateSkillDir);
@@ -3076,6 +3775,48 @@ async function runTests() {
await fs.remove(tempShardDocInferenceRoot);
}
+ const tempIndexDocsInferenceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-export-no-index-docs-inference-'));
+ try {
+ const noIndexDocsInferenceSetup = new CodexSetup();
+ const noIndexDocsInferenceSkillDir = path.join(tempIndexDocsInferenceRoot, '.agents', 'skills');
+ await fs.ensureDir(noIndexDocsInferenceSkillDir);
+ await fs.ensureDir(path.join(tempIndexDocsInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks'));
+ await fs.writeFile(
+ path.join(tempIndexDocsInferenceRoot, 'bmad-fork', 'src', 'core', 'tasks', 'index-docs.artifact.yaml'),
+ yaml.stringify({
+ schemaVersion: 1,
+ canonicalId: 'nonexistent-index-docs-id',
+ artifactType: 'task',
+ module: 'core',
+ sourcePath: 'bmad-fork/src/core/tasks/index-docs.xml',
+ displayName: 'Index Docs',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ dependencies: { requires: [] },
+ }),
+ 'utf8',
+ );
+
+ try {
+ await noIndexDocsInferenceSetup.writeSkillArtifacts(noIndexDocsInferenceSkillDir, [indexDocsTaskArtifact], 'task', {
+ projectDir: tempIndexDocsInferenceRoot,
+ });
+ assert(
+ false,
+ 'Codex export rejects path-inferred index-docs id when sidecar canonical-id derivation is unresolved',
+ 'Expected index-docs canonical-id derivation failure but export succeeded',
+ );
+ } catch (error) {
+ assert(
+ error.code === CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
+ 'Codex export unresolved index-docs canonical-id derivation returns deterministic failure code',
+ `Expected ${CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED}, got ${error.code}`,
+ );
+ }
+ } finally {
+ await fs.remove(tempIndexDocsInferenceRoot);
+ }
+
const compatibilitySetup = new CodexSetup();
const compatibilityIdentity = await compatibilitySetup.resolveSkillIdentityFromArtifact(
{
@@ -3294,6 +4035,25 @@ async function runTests() {
outputs: '',
futureAdditiveField: 'canonical-additive',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Index Docs',
+ code: 'ID',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/index-docs.xml',
+ command: 'bmad-index-docs',
+ required: 'false',
+ 'agent-name': '',
+ 'agent-command': '',
+ 'agent-display-name': '',
+ 'agent-title': '',
+ options: '',
+ description: 'Index docs command',
+ 'output-location': '',
+ outputs: '',
+ futureAdditiveField: 'canonical-additive',
+ },
{
module: 'bmm',
phase: 'planning',
@@ -3344,9 +4104,10 @@ async function runTests() {
const loadedHelpRows = await githubCopilotSetup.loadBmadHelp(tempCompatibilityRoot);
assert(
Array.isArray(loadedHelpRows) &&
- loadedHelpRows.length === 3 &&
+ loadedHelpRows.length === 4 &&
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'),
+ loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/shard-doc.xml' && row.command === 'bmad-shard-doc') &&
+ loadedHelpRows.some((row) => row['workflow-file'] === '_bmad/core/tasks/index-docs.xml' && row.command === 'bmad-index-docs'),
'GitHub Copilot help loader remains parseable with additive help-catalog columns',
);
@@ -3383,6 +4144,23 @@ async function runTests() {
);
}
+ const missingIndexDocsRows = validHelpRows.filter((row) => row.command !== 'bmad-index-docs');
+ const missingIndexDocsCsv =
+ [helpCatalogColumns.join(','), ...missingIndexDocsRows.map((row) => buildCsvLine(helpCatalogColumns, row))].join('\n') + '\n';
+ try {
+ validateHelpCatalogCompatibilitySurface(missingIndexDocsCsv, {
+ sourcePath: '_bmad/_config/bmad-help.csv',
+ });
+ assert(false, 'Help-catalog validator rejects missing index-docs canonical command rows');
+ } catch (error) {
+ assert(
+ error.code === PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_INDEX_DOCS_ROW_CONTRACT_FAILED &&
+ error.fieldPath === 'rows[*].command' &&
+ error.observedValue === '0',
+ 'Help-catalog validator emits deterministic diagnostics for missing index-docs canonical command rows',
+ );
+ }
+
const shardDocBaselineRow = validHelpRows.find((row) => row.command === 'bmad-shard-doc');
const duplicateShardDocCsv =
[
@@ -3614,6 +4392,25 @@ async function runTests() {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Index Docs',
+ code: 'ID',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/index-docs.xml',
+ command: 'bmad-index-docs',
+ required: 'false',
+ 'agent-name': '',
+ 'agent-command': '',
+ 'agent-display-name': '',
+ 'agent-title': '',
+ options: '',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ 'output-location': '',
+ outputs: '',
+ },
],
);
await writeCsv(
@@ -3664,6 +4461,22 @@ async function runTests() {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Index Docs',
+ code: 'ID',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/index-docs.xml',
+ command: 'bmad-index-docs',
+ required: 'false',
+ agent: '',
+ options: '',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ 'output-location': '',
+ outputs: '',
+ },
],
);
await writeCsv(
@@ -4301,6 +5114,25 @@ async function runTests() {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Index Docs',
+ code: 'ID',
+ sequence: '',
+ 'workflow-file': '_bmad/core/tasks/index-docs.xml',
+ command: 'bmad-index-docs',
+ required: 'false',
+ 'agent-name': '',
+ 'agent-command': '',
+ 'agent-display-name': '',
+ 'agent-title': '',
+ options: '',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ 'output-location': '',
+ outputs: '',
+ },
],
);
await writeCsv(
diff --git a/tools/cli/installers/lib/core/help-validation-harness.js b/tools/cli/installers/lib/core/help-validation-harness.js
index ccb17a343..494072cc9 100644
--- a/tools/cli/installers/lib/core/help-validation-harness.js
+++ b/tools/cli/installers/lib/core/help-validation-harness.js
@@ -949,6 +949,22 @@ class HelpValidationHarness {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Index Docs',
+ code: 'ID',
+ sequence: '',
+ 'workflow-file': `${runtimeFolder}/core/tasks/index-docs.xml`,
+ command: 'bmad-index-docs',
+ required: 'false',
+ agent: '',
+ options: '',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ '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/core/index-docs-authority-validator.js b/tools/cli/installers/lib/core/index-docs-authority-validator.js
new file mode 100644
index 000000000..242bd5b53
--- /dev/null
+++ b/tools/cli/installers/lib/core/index-docs-authority-validator.js
@@ -0,0 +1,330 @@
+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 INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
+ SIDECAR_FILE_NOT_FOUND: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
+ SIDECAR_PARSE_FAILED: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_PARSE_FAILED',
+ SIDECAR_INVALID_METADATA: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_INVALID_METADATA',
+ SIDECAR_CANONICAL_ID_MISMATCH: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_CANONICAL_ID_MISMATCH',
+ SOURCE_XML_FILE_NOT_FOUND: 'ERR_INDEX_DOCS_AUTHORITY_SOURCE_XML_FILE_NOT_FOUND',
+ COMPATIBILITY_FILE_NOT_FOUND: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_FILE_NOT_FOUND',
+ COMPATIBILITY_PARSE_FAILED: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_PARSE_FAILED',
+ COMPATIBILITY_ROW_MISSING: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_ROW_MISSING',
+ COMPATIBILITY_ROW_DUPLICATE: 'ERR_INDEX_DOCS_AUTHORITY_COMPATIBILITY_ROW_DUPLICATE',
+ COMMAND_MISMATCH: 'ERR_INDEX_DOCS_AUTHORITY_COMMAND_MISMATCH',
+ DISPLAY_NAME_MISMATCH: 'ERR_INDEX_DOCS_AUTHORITY_DISPLAY_NAME_MISMATCH',
+ DUPLICATE_CANONICAL_COMMAND: 'ERR_INDEX_DOCS_AUTHORITY_DUPLICATE_CANONICAL_COMMAND',
+});
+
+const INDEX_DOCS_LOCKED_CANONICAL_ID = 'bmad-index-docs';
+const INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY = `capability:${INDEX_DOCS_LOCKED_CANONICAL_ID}`;
+
+class IndexDocsAuthorityValidationError extends Error {
+ constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
+ const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
+ super(message);
+ this.name = 'IndexDocsAuthorityValidationError';
+ 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 IndexDocsAuthorityValidationError({
+ 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(
+ INDEX_DOCS_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(
+ INDEX_DOCS_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 !== INDEX_DOCS_LOCKED_CANONICAL_ID) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
+ 'Converted index-docs sidecar canonicalId must remain locked to bmad-index-docs',
+ 'canonicalId',
+ sidecarSourcePath,
+ normalizedCanonicalId,
+ INDEX_DOCS_LOCKED_CANONICAL_ID,
+ );
+ }
+
+ const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath);
+ if (normalizedDeclaredSourcePath !== sourceXmlSourcePath) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
+ 'Sidecar sourcePath must match index-docs XML source path',
+ 'sourcePath',
+ sidecarSourcePath,
+ normalizedDeclaredSourcePath,
+ sourceXmlSourcePath,
+ );
+ }
+}
+
+async function parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath) {
+ if (!(await fs.pathExists(compatibilityCatalogPath))) {
+ createValidationError(
+ INDEX_DOCS_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(
+ INDEX_DOCS_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(
+ INDEX_DOCS_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(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
+ 'Converted index-docs compatibility row is missing from module-help catalog',
+ 'workflow-file',
+ compatibilityCatalogSourcePath,
+ '',
+ workflowFilePath,
+ );
+ }
+
+ if (workflowMatches.length > 1) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_DUPLICATE,
+ 'Converted index-docs compatibility row appears more than once in module-help catalog',
+ 'workflow-file',
+ compatibilityCatalogSourcePath,
+ workflowMatches.length,
+ 1,
+ );
+ }
+
+ const canonicalCommandMatches = rows.filter((row) => csvMatchValue(row.command) === INDEX_DOCS_LOCKED_CANONICAL_ID);
+ if (canonicalCommandMatches.length > 1) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
+ 'Converted index-docs canonical command appears in more than one compatibility row',
+ 'command',
+ compatibilityCatalogSourcePath,
+ canonicalCommandMatches.length,
+ 1,
+ );
+ }
+
+ const indexDocsRow = workflowMatches[0];
+ const observedCommand = csvMatchValue(indexDocsRow.command);
+ if (!observedCommand || observedCommand !== INDEX_DOCS_LOCKED_CANONICAL_ID) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
+ 'Converted index-docs compatibility command must match locked canonical command bmad-index-docs',
+ 'command',
+ compatibilityCatalogSourcePath,
+ observedCommand || '',
+ INDEX_DOCS_LOCKED_CANONICAL_ID,
+ );
+ }
+
+ const observedDisplayName = csvMatchValue(indexDocsRow.name);
+ if (observedDisplayName && observedDisplayName !== displayName) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.DISPLAY_NAME_MISMATCH,
+ 'Converted index-docs compatibility name must match sidecar displayName when provided',
+ 'name',
+ compatibilityCatalogSourcePath,
+ observedDisplayName,
+ displayName,
+ );
+ }
+}
+
+function buildIndexDocsAuthorityRecords({ canonicalId, sidecarSourcePath, sourceXmlSourcePath }) {
+ return [
+ {
+ recordType: 'metadata-authority',
+ canonicalId,
+ authoritativePresenceKey: INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: sidecarSourcePath,
+ sourcePath: sourceXmlSourcePath,
+ },
+ {
+ recordType: 'source-body-authority',
+ canonicalId,
+ authoritativePresenceKey: INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritySourceType: 'source-xml',
+ authoritySourcePath: sourceXmlSourcePath,
+ sourcePath: sourceXmlSourcePath,
+ },
+ ];
+}
+
+async function validateIndexDocsAuthoritySplitAndPrecedence(options = {}) {
+ const sidecarPath = options.sidecarPath || getSourcePath('core', 'tasks', 'index-docs.artifact.yaml');
+ const sourceXmlPath = options.sourceXmlPath || getSourcePath('core', 'tasks', 'index-docs.xml');
+ const compatibilityCatalogPath = options.compatibilityCatalogPath || getSourcePath('core', 'module-help.csv');
+ const compatibilityWorkflowFilePath = options.compatibilityWorkflowFilePath || '_bmad/core/tasks/index-docs.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(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
+ 'Expected index-docs sidecar metadata file was not found',
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ let sidecarData;
+ try {
+ sidecarData = yaml.parse(await fs.readFile(sidecarPath, 'utf8'));
+ } catch (error) {
+ createValidationError(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
+ `YAML parse failure: ${error.message}`,
+ '',
+ sidecarSourcePath,
+ );
+ }
+
+ if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
+ createValidationError(
+ INDEX_DOCS_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(
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SOURCE_XML_FILE_NOT_FOUND,
+ 'Expected index-docs XML source file was not found',
+ '',
+ sourceXmlSourcePath,
+ );
+ }
+
+ const compatibilityRows = await parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath);
+ validateCompatibilityPrecedence({
+ rows: compatibilityRows,
+ displayName: String(sidecarData.displayName || '').trim(),
+ workflowFilePath: compatibilityWorkflowFilePath,
+ compatibilityCatalogSourcePath,
+ });
+
+ const canonicalId = INDEX_DOCS_LOCKED_CANONICAL_ID;
+ const authoritativeRecords = buildIndexDocsAuthorityRecords({
+ canonicalId,
+ sidecarSourcePath,
+ sourceXmlSourcePath,
+ });
+
+ return {
+ canonicalId,
+ authoritativePresenceKey: INDEX_DOCS_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
+ authoritativeRecords,
+ };
+}
+
+module.exports = {
+ INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES,
+ IndexDocsAuthorityValidationError,
+ validateIndexDocsAuthoritySplitAndPrecedence,
+};
diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js
index 81155aaa3..74776ec26 100644
--- a/tools/cli/installers/lib/core/installer.js
+++ b/tools/cli/installers/lib/core/installer.js
@@ -9,9 +9,14 @@ const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector');
-const { validateHelpSidecarContractFile, validateShardDocSidecarContractFile } = require('./sidecar-contract-validator');
+const {
+ validateHelpSidecarContractFile,
+ validateShardDocSidecarContractFile,
+ validateIndexDocsSidecarContractFile,
+} = require('./sidecar-contract-validator');
const { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator');
const { validateShardDocAuthoritySplitAndPrecedence } = require('./shard-doc-authority-validator');
+const { validateIndexDocsAuthoritySplitAndPrecedence } = require('./index-docs-authority-validator');
const {
HELP_CATALOG_GENERATION_ERROR_CODES,
buildSidecarAwareExemplarHelpRow,
@@ -36,6 +41,10 @@ const EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-d
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';
+const EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.artifact.yaml';
+const EXEMPLAR_INDEX_DOCS_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.xml';
+const EXEMPLAR_INDEX_DOCS_COMPATIBILITY_CATALOG_SOURCE_PATH = 'bmad-fork/src/core/module-help.csv';
+const EXEMPLAR_INDEX_DOCS_WORKFLOW_FILE_PATH = '_bmad/core/tasks/index-docs.xml';
class Installer {
constructor() {
@@ -51,14 +60,19 @@ class Installer {
this.ideConfigManager = new IdeConfigManager();
this.validateHelpSidecarContractFile = validateHelpSidecarContractFile;
this.validateShardDocSidecarContractFile = validateShardDocSidecarContractFile;
+ this.validateIndexDocsSidecarContractFile = validateIndexDocsSidecarContractFile;
this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence;
this.validateShardDocAuthoritySplitAndPrecedence = validateShardDocAuthoritySplitAndPrecedence;
+ this.validateIndexDocsAuthoritySplitAndPrecedence = validateIndexDocsAuthoritySplitAndPrecedence;
this.ManifestGenerator = ManifestGenerator;
this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME;
this.helpCatalogPipelineRows = [];
this.helpCatalogCommandLabelReportRows = [];
this.codexExportDerivationRecords = [];
+ this.helpAuthorityRecords = [];
+ this.shardDocAuthorityRecords = [];
+ this.indexDocsAuthorityRecords = [];
this.latestHelpValidationRun = null;
this.latestShardDocValidationRun = null;
this.helpValidationHarness = new HelpValidationHarness();
@@ -71,10 +85,14 @@ class Installer {
message('Validating shard-doc sidecar contract...');
await this.validateShardDocSidecarContractFile();
+ message('Validating index-docs sidecar contract...');
+ await this.validateIndexDocsSidecarContractFile();
+
message('Validating exemplar sidecar contract...');
await this.validateHelpSidecarContractFile();
addResult('Shard-doc sidecar contract', 'ok', 'validated');
+ addResult('Index-docs sidecar contract', 'ok', 'validated');
addResult('Sidecar contract', 'ok', 'validated');
message('Validating shard-doc authority split and XML precedence...');
@@ -87,6 +105,16 @@ class Installer {
this.shardDocAuthorityRecords = shardDocAuthorityValidation.authoritativeRecords;
addResult('Shard-doc authority split', 'ok', shardDocAuthorityValidation.authoritativePresenceKey);
+ message('Validating index-docs authority split and XML precedence...');
+ const indexDocsAuthorityValidation = await this.validateIndexDocsAuthoritySplitAndPrecedence({
+ sidecarSourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
+ sourceXmlSourcePath: EXEMPLAR_INDEX_DOCS_SOURCE_XML_SOURCE_PATH,
+ compatibilityCatalogSourcePath: EXEMPLAR_INDEX_DOCS_COMPATIBILITY_CATALOG_SOURCE_PATH,
+ compatibilityWorkflowFilePath: EXEMPLAR_INDEX_DOCS_WORKFLOW_FILE_PATH,
+ });
+ this.indexDocsAuthorityRecords = indexDocsAuthorityValidation.authoritativeRecords;
+ addResult('Index-docs authority split', 'ok', indexDocsAuthorityValidation.authoritativePresenceKey);
+
message('Validating authority split and frontmatter precedence...');
const helpAuthorityValidation = await this.validateHelpAuthoritySplitAndPrecedence({
bmadDir,
@@ -134,7 +162,11 @@ class Installer {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
helpAuthorityRecords: this.helpAuthorityRecords || [],
- taskAuthorityRecords: [...(this.helpAuthorityRecords || []), ...(this.shardDocAuthorityRecords || [])],
+ taskAuthorityRecords: [
+ ...(this.helpAuthorityRecords || []),
+ ...(this.shardDocAuthorityRecords || []),
+ ...(this.indexDocsAuthorityRecords || []),
+ ],
});
addResult(
@@ -1983,6 +2015,11 @@ class Installer {
authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
fallbackCanonicalId: 'bmad-shard-doc',
});
+ const indexDocsCanonicalId = this.resolveCanonicalIdFromAuthorityRecords({
+ authorityRecords: this.indexDocsAuthorityRecords || [],
+ authoritySourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
+ fallbackCanonicalId: 'bmad-index-docs',
+ });
const commandLabelContracts = [
{
canonicalId: sidecarAwareExemplar.canonicalId,
@@ -2002,6 +2039,15 @@ class Installer {
workflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH,
nameCandidates: ['shard document', 'shard-doc'],
},
+ {
+ canonicalId: indexDocsCanonicalId,
+ legacyName: 'index-docs',
+ displayedCommandLabel: renderDisplayedCommandLabel(indexDocsCanonicalId),
+ authoritySourceType: 'sidecar',
+ authoritySourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
+ workflowFilePath: EXEMPLAR_INDEX_DOCS_WORKFLOW_FILE_PATH,
+ nameCandidates: ['index docs', 'index-docs'],
+ },
];
let exemplarRowWritten = false;
diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js
index 2bd05b944..8fcefbb53 100644
--- a/tools/cli/installers/lib/core/manifest-generator.js
+++ b/tools/cli/installers/lib/core/manifest-generator.js
@@ -16,6 +16,7 @@ const { validateTaskManifestCompatibilitySurface } = require('./projection-compa
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 DEFAULT_EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.artifact.yaml';
const CANONICAL_ALIAS_TABLE_COLUMNS = Object.freeze([
'canonicalId',
'alias',
@@ -85,6 +86,35 @@ const LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS = Object.freeze([
resolutionEligibility: 'slash-command-only',
}),
]);
+const LOCKED_CANONICAL_ALIAS_TABLE_INDEX_DOCS_ROWS = Object.freeze([
+ Object.freeze({
+ canonicalId: 'bmad-index-docs',
+ alias: 'bmad-index-docs',
+ aliasType: 'canonical-id',
+ rowIdentity: 'alias-row:bmad-index-docs:canonical-id',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'canonical-id-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-index-docs',
+ alias: 'index-docs',
+ aliasType: 'legacy-name',
+ rowIdentity: 'alias-row:bmad-index-docs:legacy-name',
+ normalizedAliasValue: 'index-docs',
+ rawIdentityHasLeadingSlash: false,
+ resolutionEligibility: 'legacy-name-only',
+ }),
+ Object.freeze({
+ canonicalId: 'bmad-index-docs',
+ alias: '/bmad-index-docs',
+ aliasType: 'slash-command',
+ rowIdentity: 'alias-row:bmad-index-docs:slash-command',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: true,
+ resolutionEligibility: 'slash-command-only',
+ }),
+]);
/**
* Generates manifest files for installed workflows, agents, and tasks
@@ -99,6 +129,7 @@ class ManifestGenerator {
this.files = [];
this.selectedIdes = [];
this.includeConvertedShardDocAliasRows = null;
+ this.includeConvertedIndexDocsAliasRows = null;
}
normalizeTaskAuthorityRecords(records) {
@@ -286,6 +317,9 @@ class ManifestGenerator {
this.includeConvertedShardDocAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedShardDocAliasRows')
? options.includeConvertedShardDocAliasRows === true
: null;
+ this.includeConvertedIndexDocsAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedIndexDocsAliasRows')
+ ? options.includeConvertedIndexDocsAliasRows === true
+ : null;
// Filter out any undefined/null values from IDE list
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
@@ -1183,6 +1217,20 @@ class ManifestGenerator {
};
}
+ resolveIndexDocsAliasAuthorityRecord() {
+ const sidecarAuthorityRecord = Array.isArray(this.taskAuthorityRecords)
+ ? this.taskAuthorityRecords.find(
+ (record) => record?.canonicalId === 'bmad-index-docs' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
+ )
+ : null;
+ return {
+ authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar',
+ authoritySourcePath: sidecarAuthorityRecord
+ ? sidecarAuthorityRecord.authoritySourcePath
+ : DEFAULT_EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
+ };
+ }
+
hasShardDocTaskAuthorityProjection() {
if (!Array.isArray(this.taskAuthorityRecords)) {
return false;
@@ -1208,6 +1256,31 @@ class ManifestGenerator {
return this.hasShardDocTaskAuthorityProjection();
}
+ hasIndexDocsTaskAuthorityProjection() {
+ if (!Array.isArray(this.taskAuthorityRecords)) {
+ return false;
+ }
+
+ return this.taskAuthorityRecords.some(
+ (record) =>
+ record?.recordType === 'metadata-authority' &&
+ record?.canonicalId === 'bmad-index-docs' &&
+ record?.authoritySourceType === 'sidecar' &&
+ String(record?.authoritySourcePath || '').trim().length > 0,
+ );
+ }
+
+ shouldProjectIndexDocsAliasRows() {
+ if (this.includeConvertedIndexDocsAliasRows === true) {
+ return true;
+ }
+ if (this.includeConvertedIndexDocsAliasRows === false) {
+ return false;
+ }
+
+ return this.hasIndexDocsTaskAuthorityProjection();
+ }
+
buildCanonicalAliasProjectionRows() {
const buildRows = (lockedRows, authorityRecord) =>
lockedRows.map((row) => ({
@@ -1226,6 +1299,9 @@ class ManifestGenerator {
if (this.shouldProjectShardDocAliasRows()) {
rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS, this.resolveShardDocAliasAuthorityRecord()));
}
+ if (this.shouldProjectIndexDocsAliasRows()) {
+ rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_INDEX_DOCS_ROWS, this.resolveIndexDocsAliasAuthorityRecord()));
+ }
return rows;
}
diff --git a/tools/cli/installers/lib/core/projection-compatibility-validator.js b/tools/cli/installers/lib/core/projection-compatibility-validator.js
index 0a350af20..44e4df2ba 100644
--- a/tools/cli/installers/lib/core/projection-compatibility-validator.js
+++ b/tools/cli/installers/lib/core/projection-compatibility-validator.js
@@ -38,6 +38,7 @@ const PROJECTION_COMPATIBILITY_ERROR_CODES = Object.freeze({
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',
+ HELP_CATALOG_INDEX_DOCS_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_INDEX_DOCS_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',
@@ -315,6 +316,23 @@ function validateHelpCatalogLoaderEntries(rows, options = {}) {
});
}
+ const indexDocsRows = parsedRows.filter(
+ (row) =>
+ normalizeCommandValue(row.command) === 'bmad-index-docs' &&
+ normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/index-docs.xml'),
+ );
+ if (indexDocsRows.length !== 1) {
+ throwCompatibilityError({
+ code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_INDEX_DOCS_ROW_CONTRACT_FAILED,
+ detail: 'Exactly one index-docs compatibility row is required for help catalog consumers',
+ surface,
+ fieldPath: 'rows[*].command',
+ sourcePath,
+ observedValue: String(indexDocsRows.length),
+ expectedValue: '1',
+ });
+ }
+
return true;
}
diff --git a/tools/cli/installers/lib/core/shard-doc-validation-harness.js b/tools/cli/installers/lib/core/shard-doc-validation-harness.js
index 7cbebd42e..6dab6d973 100644
--- a/tools/cli/installers/lib/core/shard-doc-validation-harness.js
+++ b/tools/cli/installers/lib/core/shard-doc-validation-harness.js
@@ -547,6 +547,22 @@ class ShardDocValidationHarness {
'output-location': '',
outputs: '',
},
+ {
+ module: 'core',
+ phase: 'anytime',
+ name: 'Index Docs',
+ code: 'ID',
+ sequence: '',
+ 'workflow-file': `${runtimeFolder}/core/tasks/index-docs.xml`,
+ command: 'bmad-index-docs',
+ required: 'false',
+ agent: '',
+ options: '',
+ description:
+ 'Create lightweight index for quick LLM scanning. Use when LLM needs to understand available docs without loading everything.',
+ 'output-location': '',
+ outputs: '',
+ },
],
);
diff --git a/tools/cli/installers/lib/core/sidecar-contract-validator.js b/tools/cli/installers/lib/core/sidecar-contract-validator.js
index 57868e949..ebdc5f6f2 100644
--- a/tools/cli/installers/lib/core/sidecar-contract-validator.js
+++ b/tools/cli/installers/lib/core/sidecar-contract-validator.js
@@ -15,6 +15,7 @@ const HELP_SIDECAR_REQUIRED_FIELDS = Object.freeze([
]);
const SHARD_DOC_SIDECAR_REQUIRED_FIELDS = Object.freeze([...HELP_SIDECAR_REQUIRED_FIELDS]);
+const INDEX_DOCS_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',
@@ -46,8 +47,24 @@ const SHARD_DOC_SIDECAR_ERROR_CODES = Object.freeze({
SOURCEPATH_BASENAME_MISMATCH: 'ERR_SHARD_DOC_SIDECAR_SOURCEPATH_BASENAME_MISMATCH',
});
+const INDEX_DOCS_SIDECAR_ERROR_CODES = Object.freeze({
+ FILE_NOT_FOUND: 'ERR_INDEX_DOCS_SIDECAR_FILE_NOT_FOUND',
+ PARSE_FAILED: 'ERR_INDEX_DOCS_SIDECAR_PARSE_FAILED',
+ INVALID_ROOT_OBJECT: 'ERR_INDEX_DOCS_SIDECAR_INVALID_ROOT_OBJECT',
+ REQUIRED_FIELD_MISSING: 'ERR_INDEX_DOCS_SIDECAR_REQUIRED_FIELD_MISSING',
+ REQUIRED_FIELD_EMPTY: 'ERR_INDEX_DOCS_SIDECAR_REQUIRED_FIELD_EMPTY',
+ ARTIFACT_TYPE_INVALID: 'ERR_INDEX_DOCS_SIDECAR_ARTIFACT_TYPE_INVALID',
+ MODULE_INVALID: 'ERR_INDEX_DOCS_SIDECAR_MODULE_INVALID',
+ DEPENDENCIES_MISSING: 'ERR_INDEX_DOCS_SIDECAR_DEPENDENCIES_MISSING',
+ DEPENDENCIES_REQUIRES_INVALID: 'ERR_INDEX_DOCS_SIDECAR_DEPENDENCIES_REQUIRES_INVALID',
+ DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_INDEX_DOCS_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY',
+ MAJOR_VERSION_UNSUPPORTED: 'ERR_INDEX_DOCS_SIDECAR_MAJOR_VERSION_UNSUPPORTED',
+ SOURCEPATH_BASENAME_MISMATCH: 'ERR_INDEX_DOCS_SIDECAR_SOURCEPATH_BASENAME_MISMATCH',
+});
+
const HELP_EXEMPLAR_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
const SHARD_DOC_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml';
+const INDEX_DOCS_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.xml';
const SIDECAR_SUPPORTED_SCHEMA_MAJOR = 1;
class SidecarContractError extends Error {
@@ -257,6 +274,26 @@ function validateShardDocSidecarContractData(sidecarData, options = {}) {
});
}
+function validateIndexDocsSidecarContractData(sidecarData, options = {}) {
+ const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/index-docs.artifact.yaml');
+ validateSidecarContractData(sidecarData, {
+ sourcePath,
+ requiredFields: INDEX_DOCS_SIDECAR_REQUIRED_FIELDS,
+ requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'],
+ errorCodes: INDEX_DOCS_SIDECAR_ERROR_CODES,
+ expectedArtifactType: 'task',
+ expectedModule: 'core',
+ expectedCanonicalSourcePath: INDEX_DOCS_CANONICAL_SOURCE_PATH,
+ missingDependenciesDetail: 'Index-docs sidecar requires an explicit dependencies block.',
+ dependenciesObjectDetail: 'Index-docs sidecar requires an explicit dependencies object.',
+ dependenciesRequiresArrayDetail: 'Index-docs dependencies.requires must be an array.',
+ dependenciesRequiresNotEmptyDetail: 'Index-docs contract requires explicit zero dependencies: dependencies.requires must be [].',
+ artifactTypeDetail: 'Index-docs contract requires artifactType to equal "task".',
+ moduleDetail: 'Index-docs 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));
@@ -313,14 +350,49 @@ async function validateShardDocSidecarContractFile(sidecarPath = getSourcePath('
validateShardDocSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
}
+async function validateIndexDocsSidecarContractFile(
+ sidecarPath = getSourcePath('core', 'tasks', 'index-docs.artifact.yaml'),
+ options = {},
+) {
+ const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath));
+
+ if (!(await fs.pathExists(sidecarPath))) {
+ createValidationError(
+ INDEX_DOCS_SIDECAR_ERROR_CODES.FILE_NOT_FOUND,
+ '',
+ normalizedSourcePath,
+ 'Expected index-docs sidecar file was not found.',
+ );
+ }
+
+ let parsedSidecar;
+ try {
+ const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
+ parsedSidecar = yaml.parse(sidecarRaw);
+ } catch (error) {
+ createValidationError(
+ INDEX_DOCS_SIDECAR_ERROR_CODES.PARSE_FAILED,
+ '',
+ normalizedSourcePath,
+ `YAML parse failure: ${error.message}`,
+ );
+ }
+
+ validateIndexDocsSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
+}
+
module.exports = {
HELP_SIDECAR_REQUIRED_FIELDS,
SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
+ INDEX_DOCS_SIDECAR_REQUIRED_FIELDS,
HELP_SIDECAR_ERROR_CODES,
SHARD_DOC_SIDECAR_ERROR_CODES,
+ INDEX_DOCS_SIDECAR_ERROR_CODES,
SidecarContractError,
validateHelpSidecarContractData,
validateHelpSidecarContractFile,
validateShardDocSidecarContractData,
validateShardDocSidecarContractFile,
+ validateIndexDocsSidecarContractData,
+ validateIndexDocsSidecarContractFile,
};
diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js
index b38b69037..f5e102fbc 100644
--- a/tools/cli/installers/lib/ide/codex.js
+++ b/tools/cli/installers/lib/ide/codex.js
@@ -21,8 +21,10 @@ const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({
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_INDEX_DOCS_TASK_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.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_INDEX_DOCS_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs.artifact.yaml';
const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id';
const SHARD_DOC_EXPORT_ALIAS_ROWS = Object.freeze([
Object.freeze({
@@ -44,6 +46,26 @@ const SHARD_DOC_EXPORT_ALIAS_ROWS = Object.freeze([
rawIdentityHasLeadingSlash: true,
}),
]);
+const INDEX_DOCS_EXPORT_ALIAS_ROWS = Object.freeze([
+ Object.freeze({
+ rowIdentity: 'alias-row:bmad-index-docs:canonical-id',
+ canonicalId: 'bmad-index-docs',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: false,
+ }),
+ Object.freeze({
+ rowIdentity: 'alias-row:bmad-index-docs:legacy-name',
+ canonicalId: 'bmad-index-docs',
+ normalizedAliasValue: 'index-docs',
+ rawIdentityHasLeadingSlash: false,
+ }),
+ Object.freeze({
+ rowIdentity: 'alias-row:bmad-index-docs:slash-command',
+ canonicalId: 'bmad-index-docs',
+ normalizedAliasValue: 'bmad-index-docs',
+ rawIdentityHasLeadingSlash: true,
+ }),
+]);
const EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS = Object.freeze({
help: Object.freeze({
taskSourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH,
@@ -62,6 +84,7 @@ const EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS = Object.freeze({
taskSourcePath: EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH,
sourcePathSuffix: '/core/tasks/shard-doc.xml',
sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH,
+ aliasRows: SHARD_DOC_EXPORT_ALIAS_ROWS,
sidecarSourceCandidates: Object.freeze([
Object.freeze({
segments: ['bmad-fork', 'src', 'core', 'tasks', 'shard-doc.artifact.yaml'],
@@ -71,6 +94,20 @@ const EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS = Object.freeze({
}),
]),
}),
+ 'index-docs': Object.freeze({
+ taskSourcePath: EXEMPLAR_INDEX_DOCS_TASK_XML_SOURCE_PATH,
+ sourcePathSuffix: '/core/tasks/index-docs.xml',
+ sidecarSourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_CONTRACT_SOURCE_PATH,
+ aliasRows: INDEX_DOCS_EXPORT_ALIAS_ROWS,
+ sidecarSourceCandidates: Object.freeze([
+ Object.freeze({
+ segments: ['bmad-fork', 'src', 'core', 'tasks', 'index-docs.artifact.yaml'],
+ }),
+ Object.freeze({
+ segments: ['src', 'core', 'tasks', 'index-docs.artifact.yaml'],
+ }),
+ ]),
+ }),
});
class CodexExportDerivationError extends Error {
@@ -412,8 +449,8 @@ class CodexSetup extends BaseIdeSetup {
fieldPath: 'canonicalId',
sourcePath: sidecarData.sourcePath,
};
- if (exportTarget.taskSourcePath === EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH) {
- aliasResolutionOptions.aliasRows = SHARD_DOC_EXPORT_ALIAS_ROWS;
+ if (Array.isArray(exportTarget.aliasRows)) {
+ aliasResolutionOptions.aliasRows = exportTarget.aliasRows;
aliasResolutionOptions.aliasTableSourcePath = '_bmad/_config/canonical-aliases.csv';
}
canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions);