From 99537b20abdce5d4ad58caea170904ac777f6497 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Wed, 4 Mar 2026 22:22:07 +0000 Subject: [PATCH] feat(installer): add index-docs native skill authority projection --- src/core/tasks/index-docs.artifact.yaml | 9 + .../index-docs.artifact.yaml | 9 + .../index-docs.artifact.yaml | 9 + test/test-installation-components.js | 862 +++++++++++++++++- .../lib/core/help-validation-harness.js | 16 + .../core/index-docs-authority-validator.js | 330 +++++++ tools/cli/installers/lib/core/installer.js | 50 +- .../installers/lib/core/manifest-generator.js | 76 ++ .../projection-compatibility-validator.js | 18 + .../lib/core/shard-doc-validation-harness.js | 16 + .../lib/core/sidecar-contract-validator.js | 72 ++ tools/cli/installers/lib/ide/codex.js | 41 +- 12 files changed, 1489 insertions(+), 19 deletions(-) create mode 100644 src/core/tasks/index-docs.artifact.yaml create mode 100644 test/fixtures/index-docs/sidecar-negative/basename-path-mismatch/index-docs.artifact.yaml create mode 100644 test/fixtures/index-docs/sidecar-negative/unknown-major-version/index-docs.artifact.yaml create mode 100644 tools/cli/installers/lib/core/index-docs-authority-validator.js 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);