feat(installer): add index-docs native skill authority projection

This commit is contained in:
Dicky Moore 2026-03-04 22:22:07 +00:00
parent c7680ab1a8
commit 99537b20ab
12 changed files with 1489 additions and 19 deletions

View File

@ -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: []

View File

@ -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: []

View File

@ -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: []

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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',
'<file>',
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}`,
'<document>',
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}`,
'<document>',
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,
'<missing>',
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 || '<empty>',
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',
'<file>',
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}`,
'<document>',
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',
'<document>',
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',
'<file>',
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,
};

View File

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

View File

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

View File

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

View File

@ -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: '',
},
],
);

View File

@ -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,
'<file>',
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,
'<document>',
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,
};

View File

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