This commit is contained in:
Dicky Moore 2026-03-05 19:48:27 +00:00 committed by GitHub
commit 17835ea243
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 16345 additions and 88 deletions

View File

@ -0,0 +1,9 @@
schemaVersion: 1
canonicalId: bmad-help
artifactType: task
module: core
sourcePath: bmad-fork/src/core/tasks/help.md
displayName: help
description: "Analyzes what is done and the users query and offers advice on what to do next. Use if user says what should I do next or what do I do now"
dependencies:
requires: []

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-shard-doc
artifactType: task
module: core
sourcePath: bmad-fork/src/core/tasks/shard-doc.xml
displayName: Shard Document
description: "Split large markdown documents into smaller files by section with an index."
dependencies:
requires: []

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

View File

@ -0,0 +1,9 @@
schemaVersion: 1
canonicalId: bmad-shard-doc
artifactType: task
module: core
sourcePath: bmad-fork/src/core/tasks/not-shard-doc.xml
displayName: Shard Document
description: "Split large markdown documents into smaller files by section with an index."
dependencies:
requires: []

View File

@ -0,0 +1,9 @@
schemaVersion: 2
canonicalId: bmad-shard-doc
artifactType: task
module: core
sourcePath: bmad-fork/src/core/tasks/shard-doc.xml
displayName: Shard Document
description: "Split large markdown documents into smaller files by section with an index."
dependencies:
requires: []

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,266 @@
const path = require('node:path');
const fs = require('fs-extra');
const csv = require('csv-parse/sync');
const HELP_ALIAS_NORMALIZATION_ERROR_CODES = Object.freeze({
EMPTY_INPUT: 'ERR_CAPABILITY_ALIAS_EMPTY_INPUT',
MULTIPLE_LEADING_SLASHES: 'ERR_CAPABILITY_ALIAS_MULTIPLE_LEADING_SLASHES',
EMPTY_PREALIAS: 'ERR_CAPABILITY_ALIAS_EMPTY_PREALIAS',
UNRESOLVED: 'ERR_CAPABILITY_ALIAS_UNRESOLVED',
});
const EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH = '_bmad/_config/canonical-aliases.csv';
const LOCKED_EXEMPLAR_ALIAS_ROWS = Object.freeze([
Object.freeze({
rowIdentity: 'alias-row:bmad-help:canonical-id',
canonicalId: 'bmad-help',
normalizedAliasValue: 'bmad-help',
rawIdentityHasLeadingSlash: false,
}),
Object.freeze({
rowIdentity: 'alias-row:bmad-help:legacy-name',
canonicalId: 'bmad-help',
normalizedAliasValue: 'help',
rawIdentityHasLeadingSlash: false,
}),
Object.freeze({
rowIdentity: 'alias-row:bmad-help:slash-command',
canonicalId: 'bmad-help',
normalizedAliasValue: 'bmad-help',
rawIdentityHasLeadingSlash: true,
}),
]);
class HelpAliasNormalizationError extends Error {
constructor({ code, detail, fieldPath, sourcePath, observedValue }) {
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath}, observedValue=${observedValue})`;
super(message);
this.name = 'HelpAliasNormalizationError';
this.code = code;
this.detail = detail;
this.fieldPath = fieldPath;
this.sourcePath = sourcePath;
this.observedValue = observedValue;
this.fullMessage = message;
}
}
function normalizeSourcePath(value) {
if (!value) return '';
return String(value).replaceAll('\\', '/');
}
function collapseWhitespace(value) {
return String(value).replaceAll(/\s+/g, ' ');
}
function parseBoolean(value) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
const normalized = String(value ?? '')
.trim()
.toLowerCase();
if (normalized === 'true' || normalized === '1') return true;
if (normalized === 'false' || normalized === '0') return false;
return null;
}
function throwAliasNormalizationError({ code, detail, fieldPath, sourcePath, observedValue }) {
throw new HelpAliasNormalizationError({
code,
detail,
fieldPath,
sourcePath,
observedValue,
});
}
function normalizeRawIdentityToTuple(rawIdentity, options = {}) {
const fieldPath = options.fieldPath || 'rawIdentity';
const sourcePath = normalizeSourcePath(options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
const normalizedRawIdentity = collapseWhitespace(
String(rawIdentity ?? '')
.trim()
.toLowerCase(),
);
if (!normalizedRawIdentity) {
throwAliasNormalizationError({
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_INPUT,
detail: 'alias identity is empty after normalization',
fieldPath,
sourcePath,
observedValue: normalizedRawIdentity,
});
}
if (/^\/{2,}/.test(normalizedRawIdentity)) {
throwAliasNormalizationError({
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.MULTIPLE_LEADING_SLASHES,
detail: 'alias identity contains multiple leading slashes',
fieldPath,
sourcePath,
observedValue: normalizedRawIdentity,
});
}
const rawIdentityHasLeadingSlash = normalizedRawIdentity.startsWith('/');
const preAliasNormalizedValue = rawIdentityHasLeadingSlash ? normalizedRawIdentity.slice(1) : normalizedRawIdentity;
if (!preAliasNormalizedValue) {
throwAliasNormalizationError({
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.EMPTY_PREALIAS,
detail: 'alias preAliasNormalizedValue is empty after slash normalization',
fieldPath: 'preAliasNormalizedValue',
sourcePath,
observedValue: normalizedRawIdentity,
});
}
return {
normalizedRawIdentity,
rawIdentityHasLeadingSlash,
preAliasNormalizedValue,
};
}
function normalizeAliasRows(aliasRows, aliasTableSourcePath = EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH) {
if (!Array.isArray(aliasRows)) return [];
const normalizedRows = [];
const sourcePath = normalizeSourcePath(aliasTableSourcePath);
for (const row of aliasRows) {
if (!row || typeof row !== 'object' || Array.isArray(row)) {
continue;
}
const canonicalId = collapseWhitespace(
String(row.canonicalId ?? '')
.trim()
.toLowerCase(),
);
const rowIdentity = String(row.rowIdentity ?? '').trim();
const parsedLeadingSlash = parseBoolean(row.rawIdentityHasLeadingSlash);
const normalizedAliasValue = collapseWhitespace(
String(row.normalizedAliasValue ?? '')
.trim()
.toLowerCase(),
);
if (!rowIdentity || !canonicalId || parsedLeadingSlash === null || !normalizedAliasValue) {
continue;
}
normalizedRows.push({
rowIdentity,
canonicalId,
rawIdentityHasLeadingSlash: parsedLeadingSlash,
normalizedAliasValue,
sourcePath,
});
}
normalizedRows.sort((left, right) => left.rowIdentity.localeCompare(right.rowIdentity));
return normalizedRows;
}
function resolveAliasTupleFromRows(tuple, aliasRows, options = {}) {
const sourcePath = normalizeSourcePath(options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
const normalizedRows = normalizeAliasRows(aliasRows, sourcePath);
const matches = normalizedRows.filter(
(row) =>
row.rawIdentityHasLeadingSlash === tuple.rawIdentityHasLeadingSlash && row.normalizedAliasValue === tuple.preAliasNormalizedValue,
);
if (matches.length === 0) {
throwAliasNormalizationError({
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
detail: 'alias tuple did not resolve to any canonical alias row',
fieldPath: 'preAliasNormalizedValue',
sourcePath,
observedValue: `${tuple.preAliasNormalizedValue}|leadingSlash:${tuple.rawIdentityHasLeadingSlash}`,
});
}
if (matches.length > 1) {
throwAliasNormalizationError({
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
detail: 'alias tuple resolved ambiguously to multiple canonical alias rows',
fieldPath: 'preAliasNormalizedValue',
sourcePath,
observedValue: `${tuple.preAliasNormalizedValue}|leadingSlash:${tuple.rawIdentityHasLeadingSlash}`,
});
}
const match = matches[0];
return {
aliasRowLocator: match.rowIdentity,
postAliasCanonicalId: match.canonicalId,
aliasResolutionSourcePath: sourcePath,
};
}
async function resolveAliasTupleUsingCanonicalAliasCsv(tuple, aliasTablePath, options = {}) {
const sourcePath = normalizeSourcePath(options.sourcePath || aliasTablePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH);
if (!aliasTablePath || !(await fs.pathExists(aliasTablePath))) {
throwAliasNormalizationError({
code: HELP_ALIAS_NORMALIZATION_ERROR_CODES.UNRESOLVED,
detail: 'canonical alias table file was not found',
fieldPath: 'aliasTablePath',
sourcePath,
observedValue: aliasTablePath || '',
});
}
const csvRaw = await fs.readFile(aliasTablePath, 'utf8');
const parsedRows = csv.parse(csvRaw, {
columns: true,
skip_empty_lines: true,
trim: true,
});
return resolveAliasTupleFromRows(tuple, parsedRows, { sourcePath });
}
async function normalizeAndResolveExemplarAlias(rawIdentity, options = {}) {
const tuple = normalizeRawIdentityToTuple(rawIdentity, {
fieldPath: options.fieldPath || 'rawIdentity',
sourcePath: options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
});
let resolution;
if (Array.isArray(options.aliasRows)) {
resolution = resolveAliasTupleFromRows(tuple, options.aliasRows, {
sourcePath: options.aliasTableSourcePath || options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
});
} else if (options.aliasTablePath) {
resolution = await resolveAliasTupleUsingCanonicalAliasCsv(tuple, options.aliasTablePath, {
sourcePath: options.aliasTableSourcePath || options.sourcePath || normalizeSourcePath(path.resolve(options.aliasTablePath)),
});
} else {
resolution = resolveAliasTupleFromRows(tuple, LOCKED_EXEMPLAR_ALIAS_ROWS, {
sourcePath: options.aliasTableSourcePath || options.sourcePath || EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
});
}
return {
...tuple,
...resolution,
};
}
module.exports = {
HELP_ALIAS_NORMALIZATION_ERROR_CODES,
EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
LOCKED_EXEMPLAR_ALIAS_ROWS,
HelpAliasNormalizationError,
normalizeRawIdentityToTuple,
resolveAliasTupleFromRows,
resolveAliasTupleUsingCanonicalAliasCsv,
normalizeAndResolveExemplarAlias,
};

View File

@ -0,0 +1,401 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { getProjectRoot, getSourcePath } = require('../../../lib/project-root');
const { normalizeAndResolveExemplarAlias } = require('./help-alias-normalizer');
const { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
const HELP_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
SIDECAR_FILENAME_AMBIGUOUS: 'ERR_HELP_AUTHORITY_SIDECAR_FILENAME_AMBIGUOUS',
SIDECAR_PARSE_FAILED: 'ERR_HELP_AUTHORITY_SIDECAR_PARSE_FAILED',
SIDECAR_INVALID_METADATA: 'ERR_HELP_AUTHORITY_SIDECAR_INVALID_METADATA',
MARKDOWN_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_MARKDOWN_FILE_NOT_FOUND',
FRONTMATTER_PARSE_FAILED: 'ERR_HELP_AUTHORITY_FRONTMATTER_PARSE_FAILED',
});
const HELP_FRONTMATTER_MISMATCH_ERROR_CODES = Object.freeze({
CANONICAL_ID_MISMATCH: 'ERR_FRONTMATTER_CANONICAL_ID_MISMATCH',
DISPLAY_NAME_MISMATCH: 'ERR_FRONTMATTER_DISPLAY_NAME_MISMATCH',
DESCRIPTION_MISMATCH: 'ERR_FRONTMATTER_DESCRIPTION_MISMATCH',
DEPENDENCIES_REQUIRES_MISMATCH: 'ERR_FRONTMATTER_DEPENDENCIES_REQUIRES_MISMATCH',
});
const FRONTMATTER_MISMATCH_DETAILS = Object.freeze({
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH]: 'frontmatter canonicalId must match sidecar canonicalId',
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH]: 'frontmatter name must match sidecar displayName',
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH]: 'frontmatter description must match sidecar description',
[HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH]:
'frontmatter dependencies.requires must match sidecar dependencies.requires',
});
class HelpAuthorityValidationError extends Error {
constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
super(message);
this.name = 'HelpAuthorityValidationError';
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 ensureSidecarMetadata(sidecarData, sidecarSourcePath) {
const requiredFields = ['canonicalId', 'displayName', 'description', 'dependencies'];
for (const requiredField of requiredFields) {
if (!hasOwn(sidecarData, requiredField)) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
detail: `Missing required sidecar metadata field "${requiredField}"`,
fieldPath: requiredField,
sourcePath: sidecarSourcePath,
});
}
}
const requiredStringFields = ['canonicalId', 'displayName', 'description'];
for (const requiredStringField of requiredStringFields) {
if (isBlankString(sidecarData[requiredStringField])) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
detail: `Required sidecar metadata field "${requiredStringField}" must be a non-empty string`,
fieldPath: requiredStringField,
sourcePath: sidecarSourcePath,
});
}
}
const requires = sidecarData.dependencies?.requires;
if (!Array.isArray(requires)) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
detail: 'Sidecar metadata field "dependencies.requires" must be an array',
fieldPath: 'dependencies.requires',
sourcePath: sidecarSourcePath,
observedValue: requires,
expectedValue: [],
});
}
}
function serializeNormalizedDependencyTargets(value) {
if (!Array.isArray(value)) return null;
const normalized = value
.map((target) =>
String(target ?? '')
.trim()
.toLowerCase(),
)
.filter((target) => target.length > 0)
.sort();
return JSON.stringify(normalized);
}
function frontmatterMatchValue(value) {
if (typeof value === 'string') {
return value.trim();
}
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function createFrontmatterMismatchError(code, fieldPath, sourcePath, observedValue, expectedValue) {
throw new HelpAuthorityValidationError({
code,
detail: FRONTMATTER_MISMATCH_DETAILS[code],
fieldPath,
sourcePath,
observedValue,
expectedValue,
});
}
function validateFrontmatterPrecedence(frontmatter, sidecarData, markdownSourcePath) {
if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
return;
}
const sidecarCanonicalId = frontmatterMatchValue(sidecarData.canonicalId);
const sidecarDisplayName = frontmatterMatchValue(sidecarData.displayName);
const sidecarDescription = frontmatterMatchValue(sidecarData.description);
if (hasOwn(frontmatter, 'canonicalId')) {
const observedCanonicalId = frontmatterMatchValue(frontmatter.canonicalId);
if (observedCanonicalId.length > 0 && observedCanonicalId !== sidecarCanonicalId) {
createFrontmatterMismatchError(
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.CANONICAL_ID_MISMATCH,
'canonicalId',
markdownSourcePath,
observedCanonicalId,
sidecarCanonicalId,
);
}
}
if (hasOwn(frontmatter, 'name')) {
const observedName = frontmatterMatchValue(frontmatter.name);
if (observedName.length > 0 && observedName !== sidecarDisplayName) {
createFrontmatterMismatchError(
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DISPLAY_NAME_MISMATCH,
'name',
markdownSourcePath,
observedName,
sidecarDisplayName,
);
}
}
if (hasOwn(frontmatter, 'description')) {
const observedDescription = frontmatterMatchValue(frontmatter.description);
if (observedDescription.length > 0 && observedDescription !== sidecarDescription) {
createFrontmatterMismatchError(
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DESCRIPTION_MISMATCH,
'description',
markdownSourcePath,
observedDescription,
sidecarDescription,
);
}
}
const hasDependencyRequires =
frontmatter.dependencies &&
typeof frontmatter.dependencies === 'object' &&
!Array.isArray(frontmatter.dependencies) &&
hasOwn(frontmatter.dependencies, 'requires');
if (hasDependencyRequires) {
const observedSerialized = serializeNormalizedDependencyTargets(frontmatter.dependencies.requires);
const expectedSerialized = serializeNormalizedDependencyTargets(sidecarData.dependencies.requires);
if (observedSerialized === null || observedSerialized !== expectedSerialized) {
createFrontmatterMismatchError(
HELP_FRONTMATTER_MISMATCH_ERROR_CODES.DEPENDENCIES_REQUIRES_MISMATCH,
'dependencies.requires',
markdownSourcePath,
observedSerialized,
expectedSerialized,
);
}
}
}
async function parseMarkdownFrontmatter(markdownPath, markdownSourcePath) {
if (!(await fs.pathExists(markdownPath))) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.MARKDOWN_FILE_NOT_FOUND,
detail: 'Expected markdown surface file was not found',
fieldPath: '<file>',
sourcePath: markdownSourcePath,
});
}
let markdownRaw;
try {
markdownRaw = await fs.readFile(markdownPath, 'utf8');
} catch (error) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.FRONTMATTER_PARSE_FAILED,
detail: `Unable to read markdown content: ${error.message}`,
fieldPath: '<document>',
sourcePath: markdownSourcePath,
});
}
const frontmatterMatch = markdownRaw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!frontmatterMatch) {
return {};
}
try {
const parsed = yaml.parse(frontmatterMatch[1]);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed;
} catch (error) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.FRONTMATTER_PARSE_FAILED,
detail: `YAML frontmatter parse failure: ${error.message}`,
fieldPath: '<frontmatter>',
sourcePath: markdownSourcePath,
});
}
}
function buildHelpAuthorityRecords({ canonicalId, sidecarSourcePath, sourceMarkdownSourcePath }) {
const authoritativePresenceKey = `capability:${canonicalId}`;
return [
{
recordType: 'metadata-authority',
canonicalId,
authoritativePresenceKey,
authoritySourceType: 'sidecar',
authoritySourcePath: sidecarSourcePath,
sourcePath: sourceMarkdownSourcePath,
},
{
recordType: 'source-body-authority',
canonicalId,
authoritativePresenceKey,
authoritySourceType: 'source-markdown',
authoritySourcePath: sourceMarkdownSourcePath,
sourcePath: sourceMarkdownSourcePath,
},
];
}
async function validateHelpAuthoritySplitAndPrecedence(options = {}) {
const sourceMarkdownPath = options.sourceMarkdownPath || getSourcePath('core', 'tasks', 'help.md');
const runtimeMarkdownPath = options.runtimeMarkdownPath || '';
let resolvedMetadataAuthority;
try {
resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
sourceFilePath: sourceMarkdownPath,
metadataPath: options.sidecarPath || '',
metadataSourcePath: options.sidecarSourcePath || '',
ambiguousErrorCode: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
});
} catch (error) {
throw new HelpAuthorityValidationError({
code: error.code || HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
detail: error.detail || error.message,
fieldPath: error.fieldPath || '<file>',
sourcePath: normalizeSourcePath(error.sourcePath || toProjectRelativePath(sourceMarkdownPath)),
});
}
const sidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
const sidecarSourcePath = normalizeSourcePath(
options.sidecarSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath || resolvedMetadataAuthority.resolvedSourcePath,
);
const sourceMarkdownSourcePath = normalizeSourcePath(options.sourceMarkdownSourcePath || toProjectRelativePath(sourceMarkdownPath));
const runtimeMarkdownSourcePath = normalizeSourcePath(
options.runtimeMarkdownSourcePath || (runtimeMarkdownPath ? toProjectRelativePath(runtimeMarkdownPath) : ''),
);
if (!sidecarPath || !(await fs.pathExists(sidecarPath))) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
detail: 'Expected sidecar metadata file was not found',
fieldPath: '<file>',
sourcePath: sidecarSourcePath,
});
}
let sidecarData;
try {
const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
sidecarData = yaml.parse(sidecarRaw);
} catch (error) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
detail: `YAML parse failure: ${error.message}`,
fieldPath: '<document>',
sourcePath: sidecarSourcePath,
});
}
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
throw new HelpAuthorityValidationError({
code: HELP_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
detail: 'Sidecar root must be a YAML mapping object',
fieldPath: '<document>',
sourcePath: sidecarSourcePath,
});
}
ensureSidecarMetadata(sidecarData, sidecarSourcePath);
const sourceFrontmatter = await parseMarkdownFrontmatter(sourceMarkdownPath, sourceMarkdownSourcePath);
validateFrontmatterPrecedence(sourceFrontmatter, sidecarData, sourceMarkdownSourcePath);
const checkedSurfaces = [sourceMarkdownSourcePath];
if (runtimeMarkdownPath && (await fs.pathExists(runtimeMarkdownPath))) {
const runtimeFrontmatter = await parseMarkdownFrontmatter(runtimeMarkdownPath, runtimeMarkdownSourcePath);
validateFrontmatterPrecedence(runtimeFrontmatter, sidecarData, runtimeMarkdownSourcePath);
checkedSurfaces.push(runtimeMarkdownSourcePath);
}
const aliasResolutionOptions = {
fieldPath: 'canonicalId',
sourcePath: sidecarSourcePath,
};
const inferredAliasTablePath =
options.aliasTablePath || (options.bmadDir ? path.join(options.bmadDir, '_config', 'canonical-aliases.csv') : '');
if (inferredAliasTablePath && (await fs.pathExists(inferredAliasTablePath))) {
aliasResolutionOptions.aliasTablePath = inferredAliasTablePath;
aliasResolutionOptions.aliasTableSourcePath = normalizeSourcePath(
options.aliasTableSourcePath || toProjectRelativePath(inferredAliasTablePath),
);
}
const resolvedSidecarIdentity = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions);
const canonicalId = resolvedSidecarIdentity.postAliasCanonicalId;
const authoritativeRecords = buildHelpAuthorityRecords({
canonicalId,
sidecarSourcePath,
sourceMarkdownSourcePath,
});
return {
canonicalId,
authoritativePresenceKey: `capability:${canonicalId}`,
authoritativeRecords,
checkedSurfaces,
metadataAuthority: {
resolvedPath: normalizeSourcePath(resolvedMetadataAuthority.resolvedSourcePath || sidecarSourcePath),
resolvedFilename: normalizeSourcePath(resolvedMetadataAuthority.resolvedFilename || ''),
canonicalTargetFilename: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetFilename || 'skill-manifest.yaml'),
canonicalTargetPath: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetSourcePath || sidecarSourcePath),
derivationMode: normalizeSourcePath(resolvedMetadataAuthority.derivationMode || ''),
},
};
}
module.exports = {
HELP_AUTHORITY_VALIDATION_ERROR_CODES,
HELP_FRONTMATTER_MISMATCH_ERROR_CODES,
HelpAuthorityValidationError,
buildHelpAuthorityRecords,
serializeNormalizedDependencyTargets,
validateHelpAuthoritySplitAndPrecedence,
};

View File

@ -0,0 +1,397 @@
const fs = require('fs-extra');
const path = require('node:path');
const yaml = require('yaml');
const { getSourcePath, getProjectRoot } = require('../../../lib/project-root');
const { normalizeAndResolveExemplarAlias } = require('./help-alias-normalizer');
const { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
const EXEMPLAR_HELP_CATALOG_CANONICAL_ID = 'bmad-help';
const EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
const EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
const EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT =
'bmad-fork/tools/cli/installers/lib/core/help-catalog-generator.js::buildSidecarAwareExemplarHelpRow()';
const INSTALLER_HELP_CATALOG_MERGE_COMPONENT = 'bmad-fork/tools/cli/installers/lib/core/installer.js::mergeModuleHelpCatalogs()';
const HELP_CATALOG_GENERATION_ERROR_CODES = Object.freeze({
SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_CATALOG_SIDECAR_FILE_NOT_FOUND',
SIDECAR_FILENAME_AMBIGUOUS: 'ERR_HELP_CATALOG_SIDECAR_FILENAME_AMBIGUOUS',
SIDECAR_PARSE_FAILED: 'ERR_HELP_CATALOG_SIDECAR_PARSE_FAILED',
SIDECAR_INVALID_METADATA: 'ERR_HELP_CATALOG_SIDECAR_INVALID_METADATA',
CANONICAL_ID_MISMATCH: 'ERR_HELP_CATALOG_CANONICAL_ID_MISMATCH',
COMMAND_LABEL_CONTRACT_FAILED: 'ERR_HELP_COMMAND_LABEL_CONTRACT_FAILED',
});
class HelpCatalogGenerationError extends Error {
constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
super(message);
this.name = 'HelpCatalogGenerationError';
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 frontmatterMatchValue(value) {
if (typeof value === 'string') {
return value.trim();
}
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function createGenerationError(code, fieldPath, sourcePath, detail, observedValue, expectedValue) {
throw new HelpCatalogGenerationError({
code,
detail,
fieldPath,
sourcePath,
observedValue,
expectedValue,
});
}
async function loadExemplarHelpSidecar(sidecarPath = '') {
const sourceMarkdownPath = getSourcePath('core', 'tasks', 'help.md');
let resolvedMetadataAuthority;
try {
resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
sourceFilePath: sourceMarkdownPath,
metadataPath: sidecarPath,
ambiguousErrorCode: HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
});
} catch (error) {
createGenerationError(
error.code || HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
error.fieldPath || '<file>',
normalizeSourcePath(error.sourcePath || toProjectRelativePath(sourceMarkdownPath)),
error.detail || error.message,
);
}
const resolvedMetadataPath = resolvedMetadataAuthority.resolvedAbsolutePath;
const sourcePath = normalizeSourcePath(
resolvedMetadataAuthority.canonicalTargetSourcePath || resolvedMetadataAuthority.resolvedSourcePath,
);
if (!resolvedMetadataPath || !(await fs.pathExists(resolvedMetadataPath))) {
createGenerationError(
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
'<file>',
sourcePath,
'Expected sidecar metadata file was not found',
);
}
let sidecarData;
try {
sidecarData = yaml.parse(await fs.readFile(resolvedMetadataPath, 'utf8'));
} catch (error) {
createGenerationError(
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
'<document>',
sourcePath,
`YAML parse failure: ${error.message}`,
);
}
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
createGenerationError(
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
'<document>',
sourcePath,
'Sidecar root must be a YAML mapping object',
);
}
const canonicalId = frontmatterMatchValue(sidecarData.canonicalId);
const displayName = frontmatterMatchValue(sidecarData.displayName);
const description = frontmatterMatchValue(sidecarData.description);
const missingStringField =
canonicalId.length === 0 ? 'canonicalId' : displayName.length === 0 ? 'displayName' : description.length === 0 ? 'description' : '';
if (missingStringField.length > 0) {
const observedValues = {
canonicalId,
displayName,
description,
};
createGenerationError(
HELP_CATALOG_GENERATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
missingStringField,
sourcePath,
'Sidecar canonicalId, displayName, and description must be non-empty strings',
observedValues[missingStringField],
);
}
return {
canonicalId,
displayName,
description,
sourcePath,
resolvedFilename: normalizeSourcePath(resolvedMetadataAuthority.resolvedFilename || ''),
canonicalTargetFilename: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetFilename || 'skill-manifest.yaml'),
derivationMode: normalizeSourcePath(resolvedMetadataAuthority.derivationMode || ''),
};
}
function normalizeDisplayedCommandLabel(label) {
const trimmed = frontmatterMatchValue(label);
if (!trimmed) return '';
const hasLeadingSlash = trimmed.startsWith('/');
const withoutLeadingSlash = trimmed.replace(/^\/+/, '').trim();
const normalizedBody = withoutLeadingSlash.toLowerCase().replaceAll(/\s+/g, ' ');
if (!normalizedBody) return hasLeadingSlash ? '/' : '';
return hasLeadingSlash ? `/${normalizedBody}` : normalizedBody;
}
function renderDisplayedCommandLabel(rawCommandValue) {
const normalizedRaw = frontmatterMatchValue(rawCommandValue).replace(/^\/+/, '');
if (!normalizedRaw) {
return '/';
}
return `/${normalizedRaw}`;
}
function resolveCanonicalIdFromAuthorityRecords(helpAuthorityRecords = []) {
if (!Array.isArray(helpAuthorityRecords)) return '';
const sidecarRecord = helpAuthorityRecords.find(
(record) =>
record &&
typeof record === 'object' &&
record.authoritySourceType === 'sidecar' &&
frontmatterMatchValue(record.authoritySourcePath) === EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH &&
frontmatterMatchValue(record.canonicalId).length > 0,
);
return sidecarRecord ? frontmatterMatchValue(sidecarRecord.canonicalId) : '';
}
function evaluateExemplarCommandLabelReportRows(rows, options = {}) {
const expectedCanonicalId = frontmatterMatchValue(options.canonicalId || EXEMPLAR_HELP_CATALOG_CANONICAL_ID);
const expectedDisplayedLabel = frontmatterMatchValue(options.displayedCommandLabel || `/${expectedCanonicalId}`);
const expectedAuthoritySourceType = frontmatterMatchValue(options.authoritySourceType || 'sidecar');
const expectedAuthoritySourcePath = frontmatterMatchValue(options.authoritySourcePath || EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH);
const normalizedExpectedDisplayedLabel = normalizeDisplayedCommandLabel(expectedDisplayedLabel);
const targetRows = (Array.isArray(rows) ? rows : []).filter(
(row) => frontmatterMatchValue(row && row.canonicalId) === expectedCanonicalId,
);
if (targetRows.length !== 1) {
return { valid: false, reason: `row-count:${targetRows.length}` };
}
const row = targetRows[0];
const rawCommandValue = frontmatterMatchValue(row.rawCommandValue);
if (rawCommandValue !== expectedCanonicalId) {
return { valid: false, reason: `invalid-raw-command-value:${rawCommandValue || '<empty>'}` };
}
const displayedCommandLabel = frontmatterMatchValue(row.displayedCommandLabel);
if (displayedCommandLabel !== expectedDisplayedLabel) {
return { valid: false, reason: `invalid-displayed-label:${displayedCommandLabel || '<empty>'}` };
}
const normalizedDisplayedLabel = normalizeDisplayedCommandLabel(row.normalizedDisplayedLabel || row.displayedCommandLabel);
if (normalizedDisplayedLabel !== normalizedExpectedDisplayedLabel) {
return { valid: false, reason: `invalid-normalized-displayed-label:${normalizedDisplayedLabel || '<empty>'}` };
}
const rowCountForCanonicalId = Number.parseInt(String(row.rowCountForCanonicalId ?? ''), 10);
if (!Number.isFinite(rowCountForCanonicalId) || rowCountForCanonicalId !== 1) {
return { valid: false, reason: `invalid-row-count-for-canonical-id:${String(row.rowCountForCanonicalId ?? '<empty>')}` };
}
if (frontmatterMatchValue(row.authoritySourceType) !== expectedAuthoritySourceType) {
return {
valid: false,
reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || '<empty>'}`,
};
}
if (frontmatterMatchValue(row.authoritySourcePath) !== expectedAuthoritySourcePath) {
return {
valid: false,
reason: `invalid-authority-source-path:${frontmatterMatchValue(row.authoritySourcePath) || '<empty>'}`,
};
}
return { valid: true, reason: 'ok' };
}
function buildExemplarHelpCatalogRow({ canonicalId, description }) {
return {
module: 'core',
phase: 'anytime',
name: 'bmad-help',
code: 'BH',
sequence: '',
'workflow-file': '_bmad/core/tasks/help.md',
command: canonicalId,
required: 'false',
'agent-name': '',
'agent-command': '',
'agent-display-name': '',
'agent-title': '',
options: '',
description,
'output-location': '',
outputs: '',
};
}
function buildPipelineStageRows({ bmadFolderName, canonicalId, commandValue, descriptionValue, authoritySourcePath, sourcePath }) {
const runtimeFolder = frontmatterMatchValue(bmadFolderName) || '_bmad';
const bindingEvidence = `authority:${authoritySourcePath}|source:${sourcePath}|canonical:${canonicalId}|command:${commandValue}`;
return [
{
stage: 'installed-compatibility-row',
artifactPath: `${runtimeFolder}/core/module-help.csv`,
rowIdentity: 'module-help-row:bmad-help',
canonicalId,
sourcePath,
rowCountForStageCanonicalId: 1,
commandValue,
expectedCommandValue: canonicalId,
descriptionValue,
expectedDescriptionValue: descriptionValue,
descriptionAuthoritySourceType: 'sidecar',
descriptionAuthoritySourcePath: authoritySourcePath,
commandAuthoritySourceType: 'sidecar',
commandAuthoritySourcePath: authoritySourcePath,
issuerOwnerClass: 'installer',
issuingComponent: EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT,
issuingComponentBindingEvidence: `${EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT}|${bindingEvidence}`,
stageStatus: 'PASS',
status: 'PASS',
},
{
stage: 'merged-config-row',
artifactPath: `${runtimeFolder}/_config/bmad-help.csv`,
rowIdentity: 'merged-help-row:bmad-help',
canonicalId,
sourcePath,
rowCountForStageCanonicalId: 1,
commandValue,
expectedCommandValue: canonicalId,
descriptionValue,
expectedDescriptionValue: descriptionValue,
descriptionAuthoritySourceType: 'sidecar',
descriptionAuthoritySourcePath: authoritySourcePath,
commandAuthoritySourceType: 'sidecar',
commandAuthoritySourcePath: authoritySourcePath,
issuerOwnerClass: 'installer',
issuingComponent: INSTALLER_HELP_CATALOG_MERGE_COMPONENT,
issuingComponentBindingEvidence: `${INSTALLER_HELP_CATALOG_MERGE_COMPONENT}|${bindingEvidence}`,
stageStatus: 'PASS',
status: 'PASS',
},
];
}
async function buildSidecarAwareExemplarHelpRow(options = {}) {
const authorityCanonicalId = resolveCanonicalIdFromAuthorityRecords(options.helpAuthorityRecords);
const sidecarMetadata = await loadExemplarHelpSidecar(options.sidecarPath);
const canonicalIdentityResolution = await normalizeAndResolveExemplarAlias(sidecarMetadata.canonicalId, {
fieldPath: 'canonicalId',
sourcePath: sidecarMetadata.sourcePath,
aliasTablePath: options.aliasTablePath,
aliasTableSourcePath: options.aliasTableSourcePath,
});
const canonicalId = canonicalIdentityResolution.postAliasCanonicalId;
if (authorityCanonicalId && authorityCanonicalId !== canonicalId) {
createGenerationError(
HELP_CATALOG_GENERATION_ERROR_CODES.CANONICAL_ID_MISMATCH,
'canonicalId',
sidecarMetadata.sourcePath,
'Authority record canonicalId does not match sidecar canonicalId',
authorityCanonicalId,
canonicalId,
);
}
const commandValue = canonicalId;
const displayedCommandLabel = renderDisplayedCommandLabel(commandValue);
const normalizedDisplayedLabel = normalizeDisplayedCommandLabel(displayedCommandLabel);
const row = buildExemplarHelpCatalogRow({
canonicalId: commandValue,
description: sidecarMetadata.description,
});
const pipelineStageRows = buildPipelineStageRows({
bmadFolderName: options.bmadFolderName || '_bmad',
canonicalId,
commandValue,
descriptionValue: sidecarMetadata.description,
authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
sourcePath: EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
});
const commandLabelReportRow = {
surface: `${frontmatterMatchValue(options.bmadFolderName) || '_bmad'}/_config/bmad-help.csv`,
canonicalId,
rawCommandValue: commandValue,
displayedCommandLabel,
normalizedDisplayedLabel,
rowCountForCanonicalId: 1,
authoritySourceType: 'sidecar',
authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
status: 'PASS',
};
return {
canonicalId,
legacyName: sidecarMetadata.displayName,
commandValue,
displayedCommandLabel,
normalizedDisplayedLabel,
descriptionValue: sidecarMetadata.description,
authoritySourceType: 'sidecar',
authoritySourcePath: EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
sourcePath: EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
row,
pipelineStageRows,
commandLabelReportRow,
};
}
module.exports = {
HELP_CATALOG_GENERATION_ERROR_CODES,
HelpCatalogGenerationError,
EXEMPLAR_HELP_CATALOG_CANONICAL_ID,
EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH,
EXEMPLAR_HELP_CATALOG_SOURCE_MARKDOWN_SOURCE_PATH,
EXEMPLAR_HELP_CATALOG_ISSUING_COMPONENT,
INSTALLER_HELP_CATALOG_MERGE_COMPONENT,
normalizeDisplayedCommandLabel,
renderDisplayedCommandLabel,
evaluateExemplarCommandLabelReportRows,
buildSidecarAwareExemplarHelpRow,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,359 @@
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 { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
const INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
SIDECAR_FILE_NOT_FOUND: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
SIDECAR_FILENAME_AMBIGUOUS: 'ERR_INDEX_DOCS_AUTHORITY_SIDECAR_FILENAME_AMBIGUOUS',
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 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';
let resolvedMetadataAuthority;
try {
resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
sourceFilePath: sourceXmlPath,
metadataPath: options.sidecarPath || '',
metadataSourcePath: options.sidecarSourcePath || '',
ambiguousErrorCode: INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
});
} catch (error) {
createValidationError(
error.code || INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
error.detail || error.message,
error.fieldPath || '<file>',
normalizeSourcePath(error.sourcePath || toProjectRelativePath(sourceXmlPath)),
);
}
const sidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
const sidecarSourcePath = normalizeSourcePath(
options.sidecarSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath || resolvedMetadataAuthority.resolvedSourcePath,
);
const sourceXmlSourcePath = normalizeSourcePath(options.sourceXmlSourcePath || toProjectRelativePath(sourceXmlPath));
const compatibilityCatalogSourcePath = normalizeSourcePath(
options.compatibilityCatalogSourcePath || toProjectRelativePath(compatibilityCatalogPath),
);
if (!sidecarPath || !(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,
metadataAuthority: {
resolvedPath: normalizeSourcePath(resolvedMetadataAuthority.resolvedSourcePath || sidecarSourcePath),
resolvedFilename: normalizeSourcePath(resolvedMetadataAuthority.resolvedFilename || ''),
canonicalTargetFilename: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetFilename || 'skill-manifest.yaml'),
canonicalTargetPath: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetSourcePath || sidecarSourcePath),
derivationMode: normalizeSourcePath(resolvedMetadataAuthority.derivationMode || ''),
},
};
}
module.exports = {
INDEX_DOCS_AUTHORITY_VALIDATION_ERROR_CODES,
IndexDocsAuthorityValidationError,
validateIndexDocsAuthoritySplitAndPrecedence,
};

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,25 @@ const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/xml-handler'); const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver'); const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector'); const { ConfigCollector } = require('./config-collector');
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,
evaluateExemplarCommandLabelReportRows,
normalizeDisplayedCommandLabel,
renderDisplayedCommandLabel,
} = require('./help-catalog-generator');
const { validateHelpCatalogCompatibilitySurface } = require('./projection-compatibility-validator');
const { HelpValidationHarness } = require('./help-validation-harness');
const { ShardDocValidationHarness } = require('./shard-doc-validation-harness');
const { IndexDocsValidationHarness } = require('./index-docs-validation-harness');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { CLIUtils } = require('../../../lib/cli-utils'); const { CLIUtils } = require('../../../lib/cli-utils');
const { ManifestGenerator } = require('./manifest-generator'); const { ManifestGenerator } = require('./manifest-generator');
@ -17,6 +36,17 @@ const { CustomHandler } = require('../custom/handler');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
const EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
const EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
const EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml';
const EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH = 'bmad-fork/src/core/module-help.csv';
const EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH = '_bmad/core/tasks/shard-doc.xml';
const EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs/skill-manifest.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 { class Installer {
constructor() { constructor() {
this.detector = new Detector(); this.detector = new Detector();
@ -29,8 +59,169 @@ class Installer {
this.dependencyResolver = new DependencyResolver(); this.dependencyResolver = new DependencyResolver();
this.configCollector = new ConfigCollector(); this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager(); 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.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME; this.bmadFolderName = BMAD_FOLDER_NAME;
this.helpCatalogPipelineRows = [];
this.helpCatalogCommandLabelReportRows = [];
this.codexExportDerivationRecords = [];
this.helpAuthorityRecords = [];
this.shardDocAuthorityRecords = [];
this.indexDocsAuthorityRecords = [];
this.latestHelpValidationRun = null;
this.latestShardDocValidationRun = null;
this.latestIndexDocsValidationRun = null;
this.helpValidationHarness = new HelpValidationHarness();
this.shardDocValidationHarness = new ShardDocValidationHarness();
this.indexDocsValidationHarness = new IndexDocsValidationHarness();
}
async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) {
// Validate converted-capability sidecar contracts before generating projections/manifests.
// Fail-fast here prevents downstream artifacts from being produced on invalid metadata.
message('Validating shard-doc sidecar contract...');
await this.validateShardDocSidecarContractFile();
message('Validating 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...');
const shardDocAuthorityValidation = await this.validateShardDocAuthoritySplitAndPrecedence({
sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
sourceXmlSourcePath: EXEMPLAR_SHARD_DOC_SOURCE_XML_SOURCE_PATH,
compatibilityCatalogSourcePath: EXEMPLAR_SHARD_DOC_COMPATIBILITY_CATALOG_SOURCE_PATH,
compatibilityWorkflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH,
});
this.shardDocAuthorityRecords = shardDocAuthorityValidation.authoritativeRecords;
addResult('Shard-doc authority split', 'ok', shardDocAuthorityValidation.authoritativePresenceKey);
message('Validating 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,
runtimeMarkdownPath: path.join(bmadDir, 'core', 'tasks', 'help.md'),
sidecarSourcePath: EXEMPLAR_HELP_SIDECAR_SOURCE_PATH,
sourceMarkdownSourcePath: EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH,
runtimeMarkdownSourcePath: `${this.bmadFolderName}/core/tasks/help.md`,
});
this.helpAuthorityRecords = helpAuthorityValidation.authoritativeRecords;
addResult('Authority split', 'ok', helpAuthorityValidation.authoritativePresenceKey);
// Generate clean config.yaml files for each installed module
await this.generateModuleConfigs(bmadDir, moduleConfigs);
addResult('Configurations', 'ok', 'generated');
// Pre-register manifest files
const cfgDir = path.join(bmadDir, '_config');
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'canonical-aliases.csv'));
this.installedFiles.add(path.join(cfgDir, 'bmad-help-catalog-pipeline.csv'));
this.installedFiles.add(path.join(cfgDir, 'bmad-help-command-label-report.csv'));
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
message('Generating manifests...');
const manifestGen = new this.ManifestGenerator();
const allModulesForManifest = config._quickUpdate
? config._existingModules || allModules || []
: config._preserveModules
? [...allModules, ...config._preserveModules]
: allModules || [];
let modulesForCsvPreserve;
if (config._quickUpdate) {
modulesForCsvPreserve = config._existingModules || allModules || [];
} else {
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
}
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
helpAuthorityRecords: this.helpAuthorityRecords || [],
taskAuthorityRecords: [
...(this.helpAuthorityRecords || []),
...(this.shardDocAuthorityRecords || []),
...(this.indexDocsAuthorityRecords || []),
],
});
addResult(
'Manifests',
'ok',
`${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
);
// Merge help catalogs
message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(bmadDir);
addResult('Help catalog', 'ok');
return 'Configurations generated';
}
async buildHelpValidationOptions({ projectDir, bmadDir }) {
const exportSkillProjectionPath = path.join(projectDir, '.agents', 'skills', 'bmad-help', 'SKILL.md');
const hasCodexExportDerivationRecords =
Array.isArray(this.codexExportDerivationRecords) && this.codexExportDerivationRecords.length > 0;
const requireExportSkillProjection = hasCodexExportDerivationRecords || (await fs.pathExists(exportSkillProjectionPath));
return {
projectDir,
bmadDir,
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
helpAuthorityRecords: this.helpAuthorityRecords || [],
helpCatalogPipelineRows: this.helpCatalogPipelineRows || [],
helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [],
codexExportDerivationRecords: this.codexExportDerivationRecords || [],
requireExportSkillProjection,
};
}
async buildShardDocValidationOptions({ projectDir, bmadDir }) {
return {
projectDir,
bmadDir,
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
shardDocAuthorityRecords: this.shardDocAuthorityRecords || [],
helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [],
};
}
async buildIndexDocsValidationOptions({ projectDir, bmadDir }) {
return {
projectDir,
bmadDir,
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
indexDocsAuthorityRecords: this.indexDocsAuthorityRecords || [],
helpCatalogCommandLabelReportRows: this.helpCatalogCommandLabelReportRows || [],
};
} }
/** /**
@ -1098,54 +1289,15 @@ class Installer {
// Configuration generation task (stored as named reference for deferred execution) // Configuration generation task (stored as named reference for deferred execution)
const configTask = { const configTask = {
title: 'Generating configurations', title: 'Generating configurations',
task: async (message) => { task: async (message) =>
// Generate clean config.yaml files for each installed module this.runConfigurationGenerationTask({
await this.generateModuleConfigs(bmadDir, moduleConfigs); message,
addResult('Configurations', 'ok', 'generated'); bmadDir,
moduleConfigs,
// Pre-register manifest files config,
const cfgDir = path.join(bmadDir, '_config'); allModules,
this.installedFiles.add(path.join(cfgDir, 'manifest.yaml')); addResult,
this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv')); }),
this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes
// This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv
message('Generating manifests...');
const manifestGen = new ManifestGenerator();
const allModulesForManifest = config._quickUpdate
? config._existingModules || allModules || []
: config._preserveModules
? [...allModules, ...config._preserveModules]
: allModules || [];
let modulesForCsvPreserve;
if (config._quickUpdate) {
modulesForCsvPreserve = config._existingModules || allModules || [];
} else {
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
}
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
});
addResult(
'Manifests',
'ok',
`${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`,
);
// Merge help catalogs
message('Generating help catalog...');
await this.mergeModuleHelpCatalogs(bmadDir);
addResult('Help catalog', 'ok');
return 'Configurations generated';
},
}; };
installTasks.push(configTask); installTasks.push(configTask);
@ -1173,6 +1325,7 @@ class Installer {
// Resolution is now available via closure-scoped taskResolution // Resolution is now available via closure-scoped taskResolution
const resolution = taskResolution; const resolution = taskResolution;
this.codexExportDerivationRecords = [];
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// IDE SETUP: Keep as spinner since it may prompt for user input // IDE SETUP: Keep as spinner since it may prompt for user input
@ -1217,6 +1370,9 @@ class Installer {
} }
if (setupResult.success) { if (setupResult.success) {
if (Array.isArray(setupResult.exportDerivationRecords) && setupResult.exportDerivationRecords.length > 0) {
this.codexExportDerivationRecords = [...setupResult.exportDerivationRecords];
}
addResult(ide, 'ok', setupResult.detail || ''); addResult(ide, 'ok', setupResult.detail || '');
} else { } else {
addResult(ide, 'error', setupResult.error || 'failed'); addResult(ide, 'error', setupResult.error || 'failed');
@ -1242,6 +1398,44 @@ class Installer {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
const postIdeTasks = []; const postIdeTasks = [];
postIdeTasks.push({
title: 'Generating validation artifacts',
task: async (message) => {
message('Generating deterministic help validation artifact suite...');
const validationOptions = await this.buildHelpValidationOptions({
projectDir,
bmadDir,
});
const validationRun = await this.helpValidationHarness.generateAndValidate(validationOptions);
this.latestHelpValidationRun = validationRun;
addResult('Help validation artifacts', 'ok', `${validationRun.generatedArtifactCount} artifacts`);
message('Generating deterministic shard-doc validation artifact suite...');
const shardDocValidationOptions = await this.buildShardDocValidationOptions({
projectDir,
bmadDir,
});
const shardDocValidationRun = await this.shardDocValidationHarness.generateAndValidate(shardDocValidationOptions);
this.latestShardDocValidationRun = shardDocValidationRun;
addResult('Shard-doc validation artifacts', 'ok', `${shardDocValidationRun.generatedArtifactCount} artifacts`);
message('Generating deterministic index-docs validation artifact suite...');
const indexDocsValidationOptions = await this.buildIndexDocsValidationOptions({
projectDir,
bmadDir,
});
const indexDocsValidationRun = await this.indexDocsValidationHarness.generateAndValidate(indexDocsValidationOptions);
this.latestIndexDocsValidationRun = indexDocsValidationRun;
addResult('Index-docs validation artifacts', 'ok', `${indexDocsValidationRun.generatedArtifactCount} artifacts`);
return `${
validationRun.generatedArtifactCount +
shardDocValidationRun.generatedArtifactCount +
indexDocsValidationRun.generatedArtifactCount
} validation artifacts generated`;
},
});
// File restoration task (only for updates) // File restoration task (only for updates)
if ( if (
config._isUpdate && config._isUpdate &&
@ -1690,6 +1884,140 @@ class Installer {
/** /**
* Private: Create directory structure * Private: Create directory structure
*/ */
resolveCanonicalIdFromAuthorityRecords({ authorityRecords, authoritySourcePath, fallbackCanonicalId }) {
const normalizedAuthoritySourcePath = String(authoritySourcePath || '')
.trim()
.replaceAll('\\', '/');
const normalizedFallbackCanonicalId = String(fallbackCanonicalId || '').trim();
const records = Array.isArray(authorityRecords) ? authorityRecords : [];
for (const record of records) {
if (!record || typeof record !== 'object') {
continue;
}
const recordCanonicalId = String(record.canonicalId || '').trim();
const recordAuthoritySourceType = String(record.authoritySourceType || '').trim();
const recordAuthoritySourcePath = String(record.authoritySourcePath || '')
.trim()
.replaceAll('\\', '/');
const recordType = String(record.recordType || '').trim();
if (
recordType === 'metadata-authority' &&
recordAuthoritySourceType === 'sidecar' &&
recordAuthoritySourcePath === normalizedAuthoritySourcePath &&
recordCanonicalId.length > 0
) {
return recordCanonicalId;
}
}
return normalizedFallbackCanonicalId;
}
isExemplarHelpCatalogRow({ moduleName, name, workflowFile, command, canonicalId }) {
if (moduleName !== 'core') return false;
const normalizedName = String(name || '')
.trim()
.toLowerCase();
const normalizedWorkflowFile = String(workflowFile || '')
.trim()
.replaceAll('\\', '/')
.toLowerCase();
const normalizedCommand = String(command || '')
.trim()
.toLowerCase()
.replace(/^\/+/, '');
const normalizedCanonicalId = String(canonicalId || '')
.trim()
.toLowerCase()
.replace(/^\/+/, '');
const hasExemplarWorkflowPath = normalizedWorkflowFile.endsWith('/core/tasks/help.md');
const hasExemplarIdentity =
normalizedName === 'bmad-help' || normalizedCommand === normalizedCanonicalId || normalizedCommand === 'bmad-help';
return hasExemplarWorkflowPath && hasExemplarIdentity;
}
buildHelpCatalogRowWithAgentInfo(row, fallback, agentInfo) {
const agentName = String(row['agent-name'] || fallback.agentName || '').trim();
const agentData = agentInfo.get(agentName) || { command: '', displayName: '', title: '' };
return [
row.module || fallback.module || '',
row.phase || fallback.phase || '',
row.name || fallback.name || '',
row.code || fallback.code || '',
row.sequence || fallback.sequence || '',
row['workflow-file'] || fallback.workflowFile || '',
row.command || fallback.command || '',
row.required || fallback.required || 'false',
agentName,
row['agent-command'] || agentData.command,
row['agent-display-name'] || agentData.displayName,
row['agent-title'] || agentData.title,
row.options || fallback.options || '',
row.description || fallback.description || '',
row['output-location'] || fallback.outputLocation || '',
row.outputs || fallback.outputs || '',
];
}
isCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName, workflowFileContractPath, nameCandidates = [] }) {
const normalizedWorkflowFile = String(workflowFile || '')
.trim()
.replaceAll('\\', '/')
.toLowerCase();
const normalizedName = String(name || '')
.trim()
.toLowerCase();
const normalizedCanonicalId = String(canonicalId || '')
.trim()
.toLowerCase();
const normalizedLegacyName = String(legacyName || '')
.trim()
.toLowerCase();
const normalizedCommandValue = String(rawCommandValue || '')
.trim()
.toLowerCase()
.replace(/^\/+/, '');
const normalizedWorkflowFileContractPath = String(workflowFileContractPath || '')
.trim()
.replaceAll('\\', '/')
.toLowerCase();
const workflowMarker = '/core/tasks/';
const markerIndex = normalizedWorkflowFileContractPath.indexOf(workflowMarker);
const workflowSuffix = markerIndex === -1 ? normalizedWorkflowFileContractPath : normalizedWorkflowFileContractPath.slice(markerIndex);
const hasWorkflowMatch = workflowSuffix.length > 0 && normalizedWorkflowFile.endsWith(workflowSuffix);
const normalizedNameCandidates = (Array.isArray(nameCandidates) ? nameCandidates : [])
.map((candidate) =>
String(candidate || '')
.trim()
.toLowerCase(),
)
.filter((candidate) => candidate.length > 0);
const matchesNameCandidate = normalizedNameCandidates.includes(normalizedName);
const isCanonicalCommand = normalizedCanonicalId.length > 0 && normalizedCommandValue === normalizedCanonicalId;
const isLegacyCommand = normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName;
return hasWorkflowMatch && (matchesNameCandidate || isCanonicalCommand || isLegacyCommand);
}
async writeCsvArtifact(filePath, columns, rows) {
const csvLines = [columns.join(',')];
for (const row of rows || []) {
const csvRow = columns.map((column) => this.escapeCSVField(Object.prototype.hasOwnProperty.call(row, column) ? row[column] : ''));
csvLines.push(csvRow.join(','));
}
await fs.writeFile(filePath, csvLines.join('\n'), 'utf8');
this.installedFiles.add(filePath);
}
/** /**
* Merge all module-help.csv files into a single bmad-help.csv * Merge all module-help.csv files into a single bmad-help.csv
* Scans all installed modules for module-help.csv and merges them * Scans all installed modules for module-help.csv and merges them
@ -1701,6 +2029,53 @@ class Installer {
const allRows = []; const allRows = [];
const headerRow = const headerRow =
'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs'; 'module,phase,name,code,sequence,workflow-file,command,required,agent-name,agent-command,agent-display-name,agent-title,options,description,output-location,outputs';
this.helpCatalogPipelineRows = [];
this.helpCatalogCommandLabelReportRows = [];
const sidecarAwareExemplar = await buildSidecarAwareExemplarHelpRow({
helpAuthorityRecords: this.helpAuthorityRecords || [],
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
});
const shardDocCanonicalId = this.resolveCanonicalIdFromAuthorityRecords({
authorityRecords: this.shardDocAuthorityRecords || [],
authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
fallbackCanonicalId: 'bmad-shard-doc',
});
const indexDocsCanonicalId = this.resolveCanonicalIdFromAuthorityRecords({
authorityRecords: this.indexDocsAuthorityRecords || [],
authoritySourcePath: EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH,
fallbackCanonicalId: 'bmad-index-docs',
});
const commandLabelContracts = [
{
canonicalId: sidecarAwareExemplar.canonicalId,
legacyName: sidecarAwareExemplar.legacyName,
displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel,
authoritySourceType: sidecarAwareExemplar.authoritySourceType,
authoritySourcePath: sidecarAwareExemplar.authoritySourcePath,
workflowFilePath: sidecarAwareExemplar.row['workflow-file'],
nameCandidates: [sidecarAwareExemplar.row.name],
},
{
canonicalId: shardDocCanonicalId,
legacyName: 'shard-doc',
displayedCommandLabel: renderDisplayedCommandLabel(shardDocCanonicalId),
authoritySourceType: 'sidecar',
authoritySourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
workflowFilePath: EXEMPLAR_SHARD_DOC_WORKFLOW_FILE_PATH,
nameCandidates: ['shard document', 'shard-doc'],
},
{
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;
// Load agent manifest for agent info lookup // Load agent manifest for agent info lookup
const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv'); const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv');
@ -1795,29 +2170,62 @@ class Installer {
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools) // If module column is empty, set it to this module's name (except for core which stays empty for universal tools)
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || ''; const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
// Lookup agent info const isExemplarRow = this.isExemplarHelpCatalogRow({
const cleanAgentName = agentName ? agentName.trim() : ''; moduleName,
const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' }; name,
workflowFile,
command,
canonicalId: sidecarAwareExemplar.canonicalId,
});
// Build new row with agent info const fallbackRow = {
const newRow = [ module: finalModule,
finalModule, phase: phase || '',
phase || '', name: name || '',
name || '', code: code || '',
code || '', sequence: sequence || '',
sequence || '', workflowFile: workflowFile || '',
workflowFile || '', command: command || '',
command || '', required: required || 'false',
required || 'false', agentName: agentName || '',
cleanAgentName, options: options || '',
agentData.command, description: description || '',
agentData.displayName, outputLocation: outputLocation || '',
agentData.title, outputs: outputs || '',
options || '', };
description || '',
outputLocation || '', let newRow;
outputs || '', if (isExemplarRow) {
]; if (exemplarRowWritten) {
continue;
}
newRow = this.buildHelpCatalogRowWithAgentInfo(sidecarAwareExemplar.row, fallbackRow, agentInfo);
exemplarRowWritten = true;
} else {
newRow = this.buildHelpCatalogRowWithAgentInfo(
{
module: finalModule,
phase: phase || '',
name: name || '',
code: code || '',
sequence: sequence || '',
'workflow-file': workflowFile || '',
command: command || '',
required: required || 'false',
'agent-name': agentName || '',
'agent-command': '',
'agent-display-name': '',
'agent-title': '',
options: options || '',
description: description || '',
'output-location': outputLocation || '',
outputs: outputs || '',
},
fallbackRow,
agentInfo,
);
}
allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(',')); allRows.push(newRow.map((c) => this.escapeCSVField(c)).join(','));
} }
@ -1832,6 +2240,30 @@ class Installer {
} }
} }
if (!exemplarRowWritten) {
const injectedExemplarRow = this.buildHelpCatalogRowWithAgentInfo(
sidecarAwareExemplar.row,
{
module: 'core',
phase: sidecarAwareExemplar.row.phase,
name: sidecarAwareExemplar.row.name,
code: sidecarAwareExemplar.row.code,
sequence: sidecarAwareExemplar.row.sequence,
workflowFile: sidecarAwareExemplar.row['workflow-file'],
command: sidecarAwareExemplar.row.command,
required: sidecarAwareExemplar.row.required,
agentName: sidecarAwareExemplar.row['agent-name'],
options: sidecarAwareExemplar.row.options,
description: sidecarAwareExemplar.row.description,
outputLocation: sidecarAwareExemplar.row['output-location'],
outputs: sidecarAwareExemplar.row.outputs,
},
agentInfo,
);
allRows.push(injectedExemplarRow.map((c) => this.escapeCSVField(c)).join(','));
exemplarRowWritten = true;
}
// Sort by module, then phase, then sequence // Sort by module, then phase, then sequence
allRows.sort((a, b) => { allRows.sort((a, b) => {
const colsA = this.parseCSVLine(a); const colsA = this.parseCSVLine(a);
@ -1857,17 +2289,156 @@ class Installer {
return seqA - seqB; return seqA - seqB;
}); });
const commandLabelRowsFromMergedCatalog = [];
for (const row of allRows) {
const columns = this.parseCSVLine(row);
const workflowFile = String(columns[5] || '').trim();
const name = String(columns[2] || '').trim();
const rawCommandValue = String(columns[6] || '').trim();
if (!rawCommandValue) {
continue;
}
for (const contract of commandLabelContracts) {
const isContractCandidate = this.isCommandLabelCandidate({
workflowFile,
name,
rawCommandValue,
canonicalId: contract.canonicalId,
legacyName: contract.legacyName,
workflowFileContractPath: contract.workflowFilePath,
nameCandidates: contract.nameCandidates,
});
if (isContractCandidate) {
const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue);
commandLabelRowsFromMergedCatalog.push({
surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
canonicalId: contract.canonicalId,
rawCommandValue,
displayedCommandLabel,
normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel),
authoritySourceType: contract.authoritySourceType,
authoritySourcePath: contract.authoritySourcePath,
});
break;
}
}
}
const commandLabelRowCountByCanonicalId = new Map(commandLabelContracts.map((contract) => [contract.canonicalId, 0]));
for (const row of commandLabelRowsFromMergedCatalog) {
commandLabelRowCountByCanonicalId.set(row.canonicalId, (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) + 1);
}
const exemplarRowCount = commandLabelRowCountByCanonicalId.get(sidecarAwareExemplar.canonicalId) || 0;
this.helpCatalogPipelineRows = sidecarAwareExemplar.pipelineStageRows.map((row) => ({
...row,
rowCountForStageCanonicalId: exemplarRowCount,
stageStatus: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
status: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
}));
this.helpCatalogCommandLabelReportRows = commandLabelRowsFromMergedCatalog.map((row) => ({
...row,
rowCountForCanonicalId: commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0,
status: (commandLabelRowCountByCanonicalId.get(row.canonicalId) || 0) === 1 ? 'PASS' : 'FAIL',
}));
const commandLabelContractFailures = new Map();
for (const contract of commandLabelContracts) {
const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, {
canonicalId: contract.canonicalId,
displayedCommandLabel: contract.displayedCommandLabel,
authoritySourceType: contract.authoritySourceType,
authoritySourcePath: contract.authoritySourcePath,
});
if (!commandLabelContractResult.valid) {
commandLabelContractFailures.set(contract.canonicalId, commandLabelContractResult.reason);
}
}
if (commandLabelContractFailures.size > 0) {
this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({
...row,
stageStatus: 'FAIL',
status: 'FAIL',
}));
this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({
...row,
status: 'FAIL',
failureReason: commandLabelContractFailures.get(row.canonicalId) || row.failureReason || '',
}));
const commandLabelFailureSummary = [...commandLabelContractFailures.entries()]
.sort(([leftCanonicalId], [rightCanonicalId]) => leftCanonicalId.localeCompare(rightCanonicalId))
.map(([canonicalId, reason]) => `${canonicalId}:${reason}`)
.join('|');
const commandLabelError = new Error(
`${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelFailureSummary}`,
);
commandLabelError.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED;
commandLabelError.detail = commandLabelFailureSummary;
throw commandLabelError;
}
// Write merged catalog // Write merged catalog
const outputDir = path.join(bmadDir, '_config'); const outputDir = path.join(bmadDir, '_config');
await fs.ensureDir(outputDir); await fs.ensureDir(outputDir);
const outputPath = path.join(outputDir, 'bmad-help.csv'); const outputPath = path.join(outputDir, 'bmad-help.csv');
const helpCatalogPipelinePath = path.join(outputDir, 'bmad-help-catalog-pipeline.csv');
const commandLabelReportPath = path.join(outputDir, 'bmad-help-command-label-report.csv');
const mergedContent = [headerRow, ...allRows].join('\n'); const mergedContent = [headerRow, ...allRows].join('\n');
validateHelpCatalogCompatibilitySurface(mergedContent, {
sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
});
await fs.writeFile(outputPath, mergedContent, 'utf8'); await fs.writeFile(outputPath, mergedContent, 'utf8');
// Track the installed file // Track the installed file
this.installedFiles.add(outputPath); this.installedFiles.add(outputPath);
await this.writeCsvArtifact(
helpCatalogPipelinePath,
[
'stage',
'artifactPath',
'rowIdentity',
'canonicalId',
'sourcePath',
'rowCountForStageCanonicalId',
'commandValue',
'expectedCommandValue',
'descriptionValue',
'expectedDescriptionValue',
'descriptionAuthoritySourceType',
'descriptionAuthoritySourcePath',
'commandAuthoritySourceType',
'commandAuthoritySourcePath',
'issuerOwnerClass',
'issuingComponent',
'issuingComponentBindingEvidence',
'stageStatus',
'status',
],
this.helpCatalogPipelineRows,
);
await this.writeCsvArtifact(
commandLabelReportPath,
[
'surface',
'canonicalId',
'rawCommandValue',
'displayedCommandLabel',
'normalizedDisplayedLabel',
'rowCountForCanonicalId',
'authoritySourceType',
'authoritySourcePath',
'status',
'failureReason',
],
this.helpCatalogCommandLabelReportRows,
);
if (process.env.BMAD_VERBOSE_INSTALL === 'true') { if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`); await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`);
} }

View File

@ -5,9 +5,116 @@ const crypto = require('node:crypto');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const { getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getSourcePath, getModulePath } = require('../../../lib/project-root');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const {
EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH,
LOCKED_EXEMPLAR_ALIAS_ROWS,
normalizeAndResolveExemplarAlias,
} = require('./help-alias-normalizer');
const { validateTaskManifestCompatibilitySurface } = require('./projection-compatibility-validator');
// Load package.json for version info // Load package.json for version info
const packageJson = require('../../../../../package.json'); const packageJson = require('../../../../../package.json');
const DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help/skill-manifest.yaml';
const DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
const DEFAULT_EXEMPLAR_INDEX_DOCS_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml';
const CANONICAL_ALIAS_TABLE_COLUMNS = Object.freeze([
'canonicalId',
'alias',
'aliasType',
'authoritySourceType',
'authoritySourcePath',
'rowIdentity',
'normalizedAliasValue',
'rawIdentityHasLeadingSlash',
'resolutionEligibility',
]);
const LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS = Object.freeze([
Object.freeze({
canonicalId: 'bmad-help',
alias: 'bmad-help',
aliasType: 'canonical-id',
rowIdentity: 'alias-row:bmad-help:canonical-id',
normalizedAliasValue: 'bmad-help',
rawIdentityHasLeadingSlash: false,
resolutionEligibility: 'canonical-id-only',
}),
Object.freeze({
canonicalId: 'bmad-help',
alias: 'help',
aliasType: 'legacy-name',
rowIdentity: 'alias-row:bmad-help:legacy-name',
normalizedAliasValue: 'help',
rawIdentityHasLeadingSlash: false,
resolutionEligibility: 'legacy-name-only',
}),
Object.freeze({
canonicalId: 'bmad-help',
alias: '/bmad-help',
aliasType: 'slash-command',
rowIdentity: 'alias-row:bmad-help:slash-command',
normalizedAliasValue: 'bmad-help',
rawIdentityHasLeadingSlash: true,
resolutionEligibility: 'slash-command-only',
}),
]);
const LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS = Object.freeze([
Object.freeze({
canonicalId: 'bmad-shard-doc',
alias: 'bmad-shard-doc',
aliasType: 'canonical-id',
rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
normalizedAliasValue: 'bmad-shard-doc',
rawIdentityHasLeadingSlash: false,
resolutionEligibility: 'canonical-id-only',
}),
Object.freeze({
canonicalId: 'bmad-shard-doc',
alias: 'shard-doc',
aliasType: 'legacy-name',
rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
normalizedAliasValue: 'shard-doc',
rawIdentityHasLeadingSlash: false,
resolutionEligibility: 'legacy-name-only',
}),
Object.freeze({
canonicalId: 'bmad-shard-doc',
alias: '/bmad-shard-doc',
aliasType: 'slash-command',
rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
normalizedAliasValue: 'bmad-shard-doc',
rawIdentityHasLeadingSlash: true,
resolutionEligibility: 'slash-command-only',
}),
]);
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 * Generates manifest files for installed workflows, agents, and tasks
@ -21,6 +128,74 @@ class ManifestGenerator {
this.modules = []; this.modules = [];
this.files = []; this.files = [];
this.selectedIdes = []; this.selectedIdes = [];
this.includeConvertedShardDocAliasRows = null;
this.includeConvertedIndexDocsAliasRows = null;
}
normalizeTaskAuthorityRecords(records) {
if (!Array.isArray(records)) return [];
const normalized = [];
for (const record of records) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
continue;
}
const canonicalId = String(record.canonicalId ?? '').trim();
const authoritySourceType = String(record.authoritySourceType ?? '').trim();
const authoritySourcePath = String(record.authoritySourcePath ?? '').trim();
const sourcePath = String(record.sourcePath ?? '')
.trim()
.replaceAll('\\', '/');
const recordType = String(record.recordType ?? '').trim();
if (!canonicalId || !authoritySourceType || !authoritySourcePath || !sourcePath || !recordType) {
continue;
}
normalized.push({
recordType,
canonicalId,
authoritySourceType,
authoritySourcePath,
sourcePath,
});
}
normalized.sort((left, right) => {
const leftKey = `${left.canonicalId}|${left.recordType}|${left.authoritySourceType}|${left.authoritySourcePath}|${left.sourcePath}`;
const rightKey = `${right.canonicalId}|${right.recordType}|${right.authoritySourceType}|${right.authoritySourcePath}|${right.sourcePath}`;
return leftKey.localeCompare(rightKey);
});
return normalized;
}
buildTaskAuthorityProjectionIndex(records) {
const projectionIndex = new Map();
for (const record of records) {
if (!record || record.recordType !== 'metadata-authority' || record.authoritySourceType !== 'sidecar') {
continue;
}
const sourceMatch = String(record.sourcePath)
.replaceAll('\\', '/')
.match(/\/src\/([^/]+)\/tasks\/([^/.]+)\.(?:md|xml)$/i);
if (!sourceMatch) {
continue;
}
const moduleName = sourceMatch[1];
const taskName = sourceMatch[2];
projectionIndex.set(`${moduleName}:${taskName}`, {
legacyName: taskName,
canonicalId: record.canonicalId,
authoritySourceType: record.authoritySourceType,
authoritySourcePath: record.authoritySourcePath,
});
}
return projectionIndex;
} }
/** /**
@ -34,6 +209,65 @@ class ManifestGenerator {
return text.trim().replaceAll(/\s+/g, ' '); // Normalize all whitespace (including newlines) to single space return text.trim().replaceAll(/\s+/g, ' '); // Normalize all whitespace (including newlines) to single space
} }
/**
* Normalize authority records emitted by help authority validation so they can
* be written into downstream artifacts deterministically.
* @param {Array<object>} records - Raw authority records
* @returns {Array<object>} Normalized and sorted records
*/
async normalizeHelpAuthorityRecords(records) {
if (!Array.isArray(records)) return [];
const normalized = [];
const canonicalAliasTablePath = this.bmadDir ? path.join(this.bmadDir, '_config', 'canonical-aliases.csv') : '';
const hasCanonicalAliasTable = canonicalAliasTablePath ? await fs.pathExists(canonicalAliasTablePath) : false;
const canonicalAliasSourcePath = hasCanonicalAliasTable
? `${this.bmadFolderName || '_bmad'}/_config/canonical-aliases.csv`
: EXEMPLAR_CANONICAL_ALIAS_SOURCE_PATH;
for (const record of records) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
continue;
}
const rawCanonicalIdentity = String(record.canonicalId ?? '').trim();
const authoritySourceType = String(record.authoritySourceType ?? '').trim();
const authoritySourcePath = String(record.authoritySourcePath ?? '').trim();
const sourcePath = String(record.sourcePath ?? '').trim();
const recordType = String(record.recordType ?? '').trim();
if (!rawCanonicalIdentity || !authoritySourceType || !authoritySourcePath || !sourcePath) {
continue;
}
const canonicalIdentityResolution = await normalizeAndResolveExemplarAlias(rawCanonicalIdentity, {
fieldPath: 'canonicalId',
sourcePath: authoritySourcePath,
aliasTablePath: hasCanonicalAliasTable ? canonicalAliasTablePath : undefined,
aliasRows: hasCanonicalAliasTable ? undefined : LOCKED_EXEMPLAR_ALIAS_ROWS,
aliasTableSourcePath: canonicalAliasSourcePath,
});
const canonicalId = canonicalIdentityResolution.postAliasCanonicalId;
normalized.push({
recordType,
canonicalId,
authoritativePresenceKey: `capability:${canonicalId}`,
authoritySourceType,
authoritySourcePath,
sourcePath,
});
}
normalized.sort((left, right) => {
const leftKey = `${left.canonicalId}|${left.recordType}|${left.authoritySourceType}|${left.authoritySourcePath}|${left.sourcePath}`;
const rightKey = `${right.canonicalId}|${right.recordType}|${right.authoritySourceType}|${right.authoritySourcePath}|${right.sourcePath}`;
return leftKey.localeCompare(rightKey);
});
return normalized;
}
/** /**
* Generate all manifests for the installation * Generate all manifests for the installation
* @param {string} bmadDir - _bmad * @param {string} bmadDir - _bmad
@ -75,6 +309,18 @@ class ManifestGenerator {
throw new TypeError('ManifestGenerator expected `options.ides` to be an array.'); throw new TypeError('ManifestGenerator expected `options.ides` to be an array.');
} }
this.helpAuthorityRecords = await this.normalizeHelpAuthorityRecords(options.helpAuthorityRecords);
const taskAuthorityInput = Object.prototype.hasOwnProperty.call(options, 'taskAuthorityRecords')
? options.taskAuthorityRecords
: options.helpAuthorityRecords;
this.taskAuthorityRecords = this.normalizeTaskAuthorityRecords(taskAuthorityInput);
this.includeConvertedShardDocAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedShardDocAliasRows')
? options.includeConvertedShardDocAliasRows === true
: null;
this.includeConvertedIndexDocsAliasRows = Object.prototype.hasOwnProperty.call(options, 'includeConvertedIndexDocsAliasRows')
? options.includeConvertedIndexDocsAliasRows === true
: null;
// Filter out any undefined/null values from IDE list // Filter out any undefined/null values from IDE list
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string'); this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
@ -96,6 +342,7 @@ class ManifestGenerator {
await this.writeWorkflowManifest(cfgDir), await this.writeWorkflowManifest(cfgDir),
await this.writeAgentManifest(cfgDir), await this.writeAgentManifest(cfgDir),
await this.writeTaskManifest(cfgDir), await this.writeTaskManifest(cfgDir),
await this.writeCanonicalAliasManifest(cfgDir),
await this.writeToolManifest(cfgDir), await this.writeToolManifest(cfgDir),
await this.writeFilesManifest(cfgDir), await this.writeFilesManifest(cfgDir),
]; ];
@ -630,6 +877,12 @@ class ManifestGenerator {
ides: this.selectedIdes, ides: this.selectedIdes,
}; };
if (this.helpAuthorityRecords.length > 0) {
manifest.helpAuthority = {
records: this.helpAuthorityRecords,
};
}
// Clean the manifest to remove any non-serializable values // Clean the manifest to remove any non-serializable values
const cleanManifest = structuredClone(manifest); const cleanManifest = structuredClone(manifest);
@ -842,22 +1095,46 @@ class ManifestGenerator {
async writeTaskManifest(cfgDir) { async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv'); const csvPath = path.join(cfgDir, 'task-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`; const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const compatibilitySurfacePath = `${this.bmadFolderName || '_bmad'}/_config/task-manifest.csv`;
const taskAuthorityRecords = Array.isArray(this.taskAuthorityRecords)
? this.taskAuthorityRecords
: this.normalizeTaskAuthorityRecords(this.helpAuthorityRecords);
const taskAuthorityProjectionIndex = this.buildTaskAuthorityProjectionIndex(taskAuthorityRecords);
// Read existing manifest to preserve entries // Read existing manifest to preserve entries
const existingEntries = new Map(); const existingEntries = new Map();
if (await fs.pathExists(csvPath)) { if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8'); const content = await fs.readFile(csvPath, 'utf8');
validateTaskManifestCompatibilitySurface(content, {
sourcePath: compatibilitySurfacePath,
allowLegacyPrefixOnly: true,
});
const records = csv.parse(content, { const records = csv.parse(content, {
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
}); });
for (const record of records) { for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record); if (!record?.module || !record?.name) {
continue;
}
existingEntries.set(`${record.module}:${record.name}`, {
name: record.name,
displayName: record.displayName,
description: record.description,
module: record.module,
path: record.path,
standalone: record.standalone,
legacyName: record.legacyName || record.name,
canonicalId: record.canonicalId || '',
authoritySourceType: record.authoritySourceType || '',
authoritySourcePath: record.authoritySourcePath || '',
});
} }
} }
// Create CSV header with standalone column // Create CSV header with compatibility-prefix columns followed by additive canonical-identity columns.
let csvContent = 'name,displayName,description,module,path,standalone\n'; let csvContent = 'name,displayName,description,module,path,standalone,legacyName,canonicalId,authoritySourceType,authoritySourcePath\n';
// Combine existing and new tasks // Combine existing and new tasks
const allTasks = new Map(); const allTasks = new Map();
@ -870,6 +1147,9 @@ class ManifestGenerator {
// Add/update new tasks // Add/update new tasks
for (const task of this.tasks) { for (const task of this.tasks) {
const key = `${task.module}:${task.name}`; const key = `${task.module}:${task.name}`;
const previousRecord = allTasks.get(key);
const authorityProjection = taskAuthorityProjectionIndex.get(key);
allTasks.set(key, { allTasks.set(key, {
name: task.name, name: task.name,
displayName: task.displayName, displayName: task.displayName,
@ -877,11 +1157,17 @@ class ManifestGenerator {
module: task.module, module: task.module,
path: task.path, path: task.path,
standalone: task.standalone, standalone: task.standalone,
legacyName: authorityProjection ? authorityProjection.legacyName : previousRecord?.legacyName || task.name,
canonicalId: authorityProjection ? authorityProjection.canonicalId : previousRecord?.canonicalId || '',
authoritySourceType: authorityProjection ? authorityProjection.authoritySourceType : previousRecord?.authoritySourceType || '',
authoritySourcePath: authorityProjection ? authorityProjection.authoritySourcePath : previousRecord?.authoritySourcePath || '',
}); });
} }
// Write all tasks // Write all tasks in deterministic order.
for (const [, record] of allTasks) { const sortedTaskKeys = [...allTasks.keys()].sort((left, right) => left.localeCompare(right));
for (const taskKey of sortedTaskKeys) {
const record = allTasks.get(taskKey);
const row = [ const row = [
escapeCsv(record.name), escapeCsv(record.name),
escapeCsv(record.displayName), escapeCsv(record.displayName),
@ -889,14 +1175,176 @@ class ManifestGenerator {
escapeCsv(record.module), escapeCsv(record.module),
escapeCsv(record.path), escapeCsv(record.path),
escapeCsv(record.standalone), escapeCsv(record.standalone),
escapeCsv(record.legacyName || record.name),
escapeCsv(record.canonicalId || ''),
escapeCsv(record.authoritySourceType || ''),
escapeCsv(record.authoritySourcePath || ''),
].join(','); ].join(',');
csvContent += row + '\n'; csvContent += row + '\n';
} }
validateTaskManifestCompatibilitySurface(csvContent, {
sourcePath: compatibilitySurfacePath,
});
await fs.writeFile(csvPath, csvContent); await fs.writeFile(csvPath, csvContent);
return csvPath; return csvPath;
} }
resolveExemplarAliasAuthorityRecord() {
const sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords)
? this.helpAuthorityRecords.find(
(record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
)
: null;
return {
authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar',
authoritySourcePath: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourcePath : DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH,
};
}
resolveShardDocAliasAuthorityRecord() {
const sidecarAuthorityRecord = Array.isArray(this.taskAuthorityRecords)
? this.taskAuthorityRecords.find(
(record) => record?.canonicalId === 'bmad-shard-doc' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
)
: null;
return {
authoritySourceType: sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar',
authoritySourcePath: sidecarAuthorityRecord
? sidecarAuthorityRecord.authoritySourcePath
: DEFAULT_EXEMPLAR_SHARD_DOC_SIDECAR_SOURCE_PATH,
};
}
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;
}
return this.taskAuthorityRecords.some(
(record) =>
record?.recordType === 'metadata-authority' &&
record?.canonicalId === 'bmad-shard-doc' &&
record?.authoritySourceType === 'sidecar' &&
String(record?.authoritySourcePath || '').trim().length > 0,
);
}
shouldProjectShardDocAliasRows() {
if (this.includeConvertedShardDocAliasRows === true) {
return true;
}
if (this.includeConvertedShardDocAliasRows === false) {
return false;
}
return this.hasShardDocTaskAuthorityProjection();
}
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) => ({
canonicalId: row.canonicalId,
alias: row.alias,
aliasType: row.aliasType,
authoritySourceType: authorityRecord.authoritySourceType,
authoritySourcePath: authorityRecord.authoritySourcePath,
rowIdentity: row.rowIdentity,
normalizedAliasValue: row.normalizedAliasValue,
rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash,
resolutionEligibility: row.resolutionEligibility,
}));
const rows = [...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS, this.resolveExemplarAliasAuthorityRecord())];
if (this.shouldProjectShardDocAliasRows()) {
rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_SHARD_DOC_ROWS, this.resolveShardDocAliasAuthorityRecord()));
}
if (this.shouldProjectIndexDocsAliasRows()) {
rows.push(...buildRows(LOCKED_CANONICAL_ALIAS_TABLE_INDEX_DOCS_ROWS, this.resolveIndexDocsAliasAuthorityRecord()));
}
return rows;
}
/**
* Write canonical alias table projection CSV.
* @returns {string} Path to the canonical alias projection file
*/
async writeCanonicalAliasManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'canonical-aliases.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const projectedRows = this.buildCanonicalAliasProjectionRows();
let csvContent = `${CANONICAL_ALIAS_TABLE_COLUMNS.join(',')}\n`;
for (const row of projectedRows) {
const serializedRow = [
escapeCsv(row.canonicalId),
escapeCsv(row.alias),
escapeCsv(row.aliasType),
escapeCsv(row.authoritySourceType),
escapeCsv(row.authoritySourcePath),
escapeCsv(row.rowIdentity),
escapeCsv(row.normalizedAliasValue),
escapeCsv(row.rawIdentityHasLeadingSlash),
escapeCsv(row.resolutionEligibility),
].join(',');
csvContent += `${serializedRow}\n`;
}
await fs.writeFile(csvPath, csvContent);
const trackedPath = `${this.bmadFolderName || '_bmad'}/_config/canonical-aliases.csv`;
if (!this.files.some((file) => file.path === trackedPath)) {
this.files.push({
type: 'config',
name: 'canonical-aliases',
module: '_config',
path: trackedPath,
});
}
return csvPath;
}
/** /**
* Write tool manifest CSV * Write tool manifest CSV
* @returns {string} Path to the manifest file * @returns {string} Path to the manifest file

View File

@ -0,0 +1,558 @@
const csv = require('csv-parse/sync');
const TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze(['name', 'displayName', 'description', 'module', 'path', 'standalone']);
const TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS = Object.freeze(['legacyName', 'canonicalId', 'authoritySourceType', 'authoritySourcePath']);
const HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze([
'module',
'phase',
'name',
'code',
'sequence',
'workflow-file',
'command',
'required',
]);
const HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS = Object.freeze([
'agent-name',
'agent-command',
'agent-display-name',
'agent-title',
'options',
'description',
'output-location',
'outputs',
]);
const PROJECTION_COMPATIBILITY_ERROR_CODES = Object.freeze({
TASK_MANIFEST_CSV_PARSE_FAILED: 'ERR_TASK_MANIFEST_COMPAT_PARSE_FAILED',
TASK_MANIFEST_HEADER_PREFIX_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_PREFIX_MISMATCH',
TASK_MANIFEST_HEADER_CANONICAL_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_CANONICAL_MISMATCH',
TASK_MANIFEST_REQUIRED_COLUMN_MISSING: 'ERR_TASK_MANIFEST_COMPAT_REQUIRED_COLUMN_MISSING',
TASK_MANIFEST_ROW_FIELD_EMPTY: 'ERR_TASK_MANIFEST_COMPAT_ROW_FIELD_EMPTY',
HELP_CATALOG_CSV_PARSE_FAILED: 'ERR_HELP_CATALOG_COMPAT_PARSE_FAILED',
HELP_CATALOG_HEADER_PREFIX_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_PREFIX_MISMATCH',
HELP_CATALOG_HEADER_CANONICAL_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_CANONICAL_MISMATCH',
HELP_CATALOG_REQUIRED_COLUMN_MISSING: 'ERR_HELP_CATALOG_COMPAT_REQUIRED_COLUMN_MISSING',
HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_EXEMPLAR_ROW_CONTRACT_FAILED',
HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_SHARD_DOC_ROW_CONTRACT_FAILED',
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',
COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_CANONICAL_COMMAND_AMBIGUOUS',
COMMAND_DOC_ALIAS_AMBIGUOUS: 'ERR_COMMAND_DOC_CONSISTENCY_ALIAS_AMBIGUOUS',
COMMAND_DOC_GENERATED_SURFACE_MISMATCH: 'ERR_COMMAND_DOC_CONSISTENCY_GENERATED_SURFACE_MISMATCH',
});
class ProjectionCompatibilityError extends Error {
constructor({ code, detail, surface, fieldPath, sourcePath, observedValue, expectedValue }) {
const message = `${code}: ${detail} (surface=${surface}, fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
super(message);
this.name = 'ProjectionCompatibilityError';
this.code = code;
this.detail = detail;
this.surface = surface;
this.fieldPath = fieldPath;
this.sourcePath = sourcePath;
this.observedValue = observedValue;
this.expectedValue = expectedValue;
this.fullMessage = message;
}
}
function normalizeSourcePath(value) {
return String(value || '')
.trim()
.replaceAll('\\', '/');
}
function normalizeValue(value) {
return String(value ?? '').trim();
}
function throwCompatibilityError({ code, detail, surface, fieldPath, sourcePath, observedValue, expectedValue }) {
throw new ProjectionCompatibilityError({
code,
detail,
surface,
fieldPath,
sourcePath,
observedValue,
expectedValue,
});
}
function parseHeaderColumns(csvContent, { code, surface, sourcePath }) {
try {
const parsed = csv.parse(String(csvContent ?? ''), {
to_line: 1,
skip_empty_lines: true,
trim: true,
});
const headerColumns = Array.isArray(parsed) && parsed.length > 0 ? parsed[0].map(String) : [];
if (headerColumns.length === 0) {
throwCompatibilityError({
code,
detail: 'CSV surface is missing a header row',
surface,
fieldPath: '<header>',
sourcePath,
observedValue: '<empty>',
expectedValue: 'comma-separated header columns',
});
}
return headerColumns;
} catch (error) {
if (error instanceof ProjectionCompatibilityError) {
throw error;
}
throwCompatibilityError({
code,
detail: `Unable to parse CSV header: ${error.message}`,
surface,
fieldPath: '<header>',
sourcePath,
observedValue: '<parse-failure>',
expectedValue: 'valid CSV header',
});
}
}
function parseRowsWithHeaders(csvContent, { code, surface, sourcePath }) {
try {
return csv.parse(String(csvContent ?? ''), {
columns: true,
skip_empty_lines: true,
trim: true,
});
} catch (error) {
throwCompatibilityError({
code,
detail: `Unable to parse CSV rows: ${error.message}`,
surface,
fieldPath: '<rows>',
sourcePath,
observedValue: '<parse-failure>',
expectedValue: 'valid CSV rows',
});
}
}
function assertLockedColumns({ headerColumns, expectedColumns, offset, code, detail, surface, sourcePath }) {
for (const [index, expectedValue] of expectedColumns.entries()) {
const headerIndex = offset + index;
const observedValue = normalizeValue(headerColumns[headerIndex]);
if (observedValue !== expectedValue) {
throwCompatibilityError({
code,
detail,
surface,
fieldPath: `header[${headerIndex}]`,
sourcePath,
observedValue: observedValue || '<missing>',
expectedValue,
});
}
}
}
function assertRequiredColumns({ headerColumns, requiredColumns, code, surface, sourcePath }) {
const headerSet = new Set(headerColumns.map((column) => normalizeValue(column)));
for (const column of requiredColumns) {
if (!headerSet.has(column)) {
throwCompatibilityError({
code,
detail: 'Required compatibility column is missing from projection surface',
surface,
fieldPath: `header.${column}`,
sourcePath,
observedValue: '<missing>',
expectedValue: column,
});
}
}
}
function normalizeCommandValue(value) {
return normalizeValue(value).toLowerCase().replace(/^\/+/, '');
}
function normalizeWorkflowPath(value) {
return normalizeSourcePath(value).toLowerCase();
}
function normalizeDisplayedCommandLabel(value) {
const normalized = normalizeValue(value).toLowerCase().replace(/^\/+/, '');
return normalized.length > 0 ? `/${normalized}` : '';
}
function parseDocumentedSlashCommands(markdownContent, options = {}) {
const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md');
const surface = options.surface || 'command-doc-consistency';
const content = String(markdownContent ?? '');
const commandPattern = /\|\s*`(\/[^`]+)`\s*\|/g;
const commands = [];
let match;
while ((match = commandPattern.exec(content)) !== null) {
commands.push(normalizeDisplayedCommandLabel(match[1]));
}
if (commands.length === 0) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_PARSE_FAILED,
detail: 'Unable to find slash-command rows in command reference markdown',
surface,
fieldPath: 'docs.reference.commands',
sourcePath,
observedValue: '<no-slash-command-rows>',
expectedValue: '| `/bmad-...` |',
});
}
return commands;
}
function validateTaskManifestLoaderEntries(rows, options = {}) {
const surface = options.surface || 'task-manifest-loader';
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv');
const headerColumns = Array.isArray(options.headerColumns) ? options.headerColumns : Object.keys(rows?.[0] || {});
const requiredColumns = ['name', 'module', 'path'];
assertRequiredColumns({
headerColumns,
requiredColumns,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_REQUIRED_COLUMN_MISSING,
surface,
sourcePath,
});
for (let index = 0; index < (Array.isArray(rows) ? rows.length : 0); index += 1) {
const row = rows[index];
for (const requiredColumn of requiredColumns) {
if (!row || normalizeValue(row[requiredColumn]).length === 0) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_ROW_FIELD_EMPTY,
detail: 'Task-manifest row is missing a required compatibility value',
surface,
fieldPath: `rows[${index}].${requiredColumn}`,
sourcePath,
observedValue: normalizeValue(row ? row[requiredColumn] : '') || '<empty>',
expectedValue: 'non-empty string',
});
}
}
}
return true;
}
function validateHelpCatalogLoaderEntries(rows, options = {}) {
const surface = options.surface || 'bmad-help-catalog-loader';
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv');
const headerColumns = Array.isArray(options.headerColumns) ? options.headerColumns : Object.keys(rows?.[0] || {});
const requiredColumns = ['name', 'workflow-file', 'command'];
assertRequiredColumns({
headerColumns,
requiredColumns,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_REQUIRED_COLUMN_MISSING,
surface,
sourcePath,
});
const parsedRows = Array.isArray(rows) ? rows : [];
for (const [index, row] of parsedRows.entries()) {
const rawCommandValue = normalizeValue(row.command);
if (rawCommandValue.length === 0) {
continue;
}
if (normalizeValue(row['workflow-file']).length === 0) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.GITHUB_COPILOT_WORKFLOW_FILE_MISSING,
detail: 'Rows with command values must preserve workflow-file for prompt generation loaders',
surface,
fieldPath: `rows[${index}].workflow-file`,
sourcePath,
observedValue: '<empty>',
expectedValue: 'non-empty string',
});
}
}
const exemplarRows = parsedRows.filter(
(row) =>
normalizeCommandValue(row.command) === 'bmad-help' && normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/help.md'),
);
if (exemplarRows.length !== 1) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED,
detail: 'Exactly one exemplar bmad-help compatibility row is required for help catalog consumers',
surface,
fieldPath: 'rows[*].command',
sourcePath,
observedValue: String(exemplarRows.length),
expectedValue: '1',
});
}
const shardDocRows = parsedRows.filter(
(row) =>
normalizeCommandValue(row.command) === 'bmad-shard-doc' &&
normalizeWorkflowPath(row['workflow-file']).endsWith('/core/tasks/shard-doc.xml'),
);
if (shardDocRows.length !== 1) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_SHARD_DOC_ROW_CONTRACT_FAILED,
detail: 'Exactly one shard-doc compatibility row is required for help catalog consumers',
surface,
fieldPath: 'rows[*].command',
sourcePath,
observedValue: String(shardDocRows.length),
expectedValue: '1',
});
}
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;
}
function validateGithubCopilotHelpLoaderEntries(rows, options = {}) {
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv');
return validateHelpCatalogLoaderEntries(rows, {
...options,
sourcePath,
surface: options.surface || 'github-copilot-help-loader',
});
}
function validateTaskManifestCompatibilitySurface(csvContent, options = {}) {
const surface = options.surface || 'task-manifest-loader';
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/task-manifest.csv');
const allowLegacyPrefixOnly = options.allowLegacyPrefixOnly === true;
const headerColumns = parseHeaderColumns(csvContent, {
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED,
surface,
sourcePath,
});
const isLegacyPrefixOnlyHeader = headerColumns.length === TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length;
if (allowLegacyPrefixOnly && isLegacyPrefixOnlyHeader) {
assertLockedColumns({
headerColumns,
expectedColumns: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
offset: 0,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_PREFIX_MISMATCH,
detail: 'Task-manifest compatibility-prefix header ordering changed (non-additive change)',
surface,
sourcePath,
});
const rows = parseRowsWithHeaders(csvContent, {
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED,
surface,
sourcePath,
});
validateTaskManifestLoaderEntries(rows, {
surface,
sourcePath,
headerColumns,
});
return { headerColumns, rows, isLegacyPrefixOnlyHeader: true };
}
assertLockedColumns({
headerColumns,
expectedColumns: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
offset: 0,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_PREFIX_MISMATCH,
detail: 'Task-manifest compatibility-prefix header ordering changed (non-additive change)',
surface,
sourcePath,
});
assertLockedColumns({
headerColumns,
expectedColumns: TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS,
offset: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_CANONICAL_MISMATCH,
detail: 'Task-manifest canonical additive columns must remain appended after compatibility-prefix columns',
surface,
sourcePath,
});
const rows = parseRowsWithHeaders(csvContent, {
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_CSV_PARSE_FAILED,
surface,
sourcePath,
});
validateTaskManifestLoaderEntries(rows, {
surface,
sourcePath,
headerColumns,
});
return { headerColumns, rows };
}
function validateHelpCatalogCompatibilitySurface(csvContent, options = {}) {
const surface = options.surface || 'bmad-help-catalog-loader';
const sourcePath = normalizeSourcePath(options.sourcePath || '_bmad/_config/bmad-help.csv');
const headerColumns = parseHeaderColumns(csvContent, {
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_CSV_PARSE_FAILED,
surface,
sourcePath,
});
assertLockedColumns({
headerColumns,
expectedColumns: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS,
offset: 0,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_PREFIX_MISMATCH,
detail: 'Help-catalog compatibility-prefix header ordering changed (non-additive change)',
surface,
sourcePath,
});
assertLockedColumns({
headerColumns,
expectedColumns: HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS,
offset: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS.length,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_CANONICAL_MISMATCH,
detail: 'Help-catalog canonical additive columns must remain appended after compatibility-prefix columns',
surface,
sourcePath,
});
const rows = parseRowsWithHeaders(csvContent, {
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_CSV_PARSE_FAILED,
surface,
sourcePath,
});
validateHelpCatalogLoaderEntries(rows, {
surface,
sourcePath,
headerColumns,
});
validateGithubCopilotHelpLoaderEntries(rows, {
sourcePath,
headerColumns,
});
return { headerColumns, rows };
}
function validateCommandDocSurfaceConsistency(commandDocMarkdown, options = {}) {
const surface = options.surface || 'command-doc-consistency';
const sourcePath = normalizeSourcePath(options.sourcePath || 'docs/reference/commands.md');
const canonicalId = normalizeValue(options.canonicalId || 'bmad-shard-doc');
const expectedDisplayedCommandLabel = normalizeDisplayedCommandLabel(options.expectedDisplayedCommandLabel || '/bmad-shard-doc');
const disallowedAliasLabels = Array.isArray(options.disallowedAliasLabels) ? options.disallowedAliasLabels : ['/shard-doc'];
const commandLabelRows = Array.isArray(options.commandLabelRows) ? options.commandLabelRows : [];
const documentedCommands = parseDocumentedSlashCommands(commandDocMarkdown, {
sourcePath,
surface,
});
const documentedCanonicalMatches = documentedCommands.filter((commandLabel) => commandLabel === expectedDisplayedCommandLabel);
if (documentedCanonicalMatches.length === 0) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_MISSING,
detail: 'Expected canonical command is missing from command reference markdown',
surface,
fieldPath: 'docs.reference.commands.canonical-command',
sourcePath,
observedValue: '<missing>',
expectedValue: expectedDisplayedCommandLabel,
});
}
if (documentedCanonicalMatches.length > 1) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_CANONICAL_COMMAND_AMBIGUOUS,
detail: 'Canonical command appears multiple times in command reference markdown',
surface,
fieldPath: 'docs.reference.commands.canonical-command',
sourcePath,
observedValue: String(documentedCanonicalMatches.length),
expectedValue: '1',
});
}
const normalizedDisallowedAliases = disallowedAliasLabels.map((label) => normalizeDisplayedCommandLabel(label)).filter(Boolean);
const presentDisallowedAlias = normalizedDisallowedAliases.find((label) => documentedCommands.includes(label));
if (presentDisallowedAlias) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_ALIAS_AMBIGUOUS,
detail: 'Disallowed alias command detected in command reference markdown',
surface,
fieldPath: 'docs.reference.commands.alias-command',
sourcePath,
observedValue: presentDisallowedAlias,
expectedValue: expectedDisplayedCommandLabel,
});
}
const generatedCanonicalRows = commandLabelRows.filter((row) => normalizeValue(row.canonicalId) === canonicalId);
const generatedMatchingRows = generatedCanonicalRows.filter(
(row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel) === expectedDisplayedCommandLabel,
);
if (generatedCanonicalRows.length === 0 || generatedMatchingRows.length !== 1) {
throwCompatibilityError({
code: PROJECTION_COMPATIBILITY_ERROR_CODES.COMMAND_DOC_GENERATED_SURFACE_MISMATCH,
detail: 'Generated command-label surface does not match canonical command-doc contract',
surface,
fieldPath: 'generated.command-label-report',
sourcePath: normalizeSourcePath(options.generatedSurfacePath || '_bmad/_config/bmad-help-command-label-report.csv'),
observedValue:
generatedCanonicalRows
.map((row) => normalizeDisplayedCommandLabel(row.displayedCommandLabel))
.filter(Boolean)
.join('|') || '<missing>',
expectedValue: expectedDisplayedCommandLabel,
});
}
return {
canonicalId,
expectedDisplayedCommandLabel,
documentedCommands,
generatedCanonicalCommand: expectedDisplayedCommandLabel,
};
}
module.exports = {
PROJECTION_COMPATIBILITY_ERROR_CODES,
ProjectionCompatibilityError,
TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
TASK_MANIFEST_CANONICAL_ADDITIVE_COLUMNS,
HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS,
HELP_CATALOG_CANONICAL_ADDITIVE_COLUMNS,
validateTaskManifestCompatibilitySurface,
validateTaskManifestLoaderEntries,
validateHelpCatalogCompatibilitySurface,
validateHelpCatalogLoaderEntries,
validateGithubCopilotHelpLoaderEntries,
validateCommandDocSurfaceConsistency,
};

View File

@ -0,0 +1,363 @@
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 { resolveSkillMetadataAuthority } = require('./sidecar-contract-validator');
const SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
SIDECAR_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
SIDECAR_FILENAME_AMBIGUOUS: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_FILENAME_AMBIGUOUS',
SIDECAR_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_PARSE_FAILED',
SIDECAR_INVALID_METADATA: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_INVALID_METADATA',
SIDECAR_CANONICAL_ID_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_SIDECAR_CANONICAL_ID_MISMATCH',
SOURCE_XML_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_SOURCE_XML_FILE_NOT_FOUND',
COMPATIBILITY_FILE_NOT_FOUND: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_FILE_NOT_FOUND',
COMPATIBILITY_PARSE_FAILED: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_PARSE_FAILED',
COMPATIBILITY_ROW_MISSING: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_MISSING',
COMPATIBILITY_ROW_DUPLICATE: 'ERR_SHARD_DOC_AUTHORITY_COMPATIBILITY_ROW_DUPLICATE',
COMMAND_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_COMMAND_MISMATCH',
DISPLAY_NAME_MISMATCH: 'ERR_SHARD_DOC_AUTHORITY_DISPLAY_NAME_MISMATCH',
DUPLICATE_CANONICAL_COMMAND: 'ERR_SHARD_DOC_AUTHORITY_DUPLICATE_CANONICAL_COMMAND',
});
const SHARD_DOC_LOCKED_CANONICAL_ID = 'bmad-shard-doc';
const SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY = `capability:${SHARD_DOC_LOCKED_CANONICAL_ID}`;
class ShardDocAuthorityValidationError extends Error {
constructor({ code, detail, fieldPath, sourcePath, observedValue, expectedValue }) {
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
super(message);
this.name = 'ShardDocAuthorityValidationError';
this.code = code;
this.detail = detail;
this.fieldPath = fieldPath;
this.sourcePath = sourcePath;
this.observedValue = observedValue;
this.expectedValue = expectedValue;
this.fullMessage = message;
}
}
function normalizeSourcePath(value) {
if (!value) return '';
return String(value).replaceAll('\\', '/');
}
function toProjectRelativePath(filePath) {
const projectRoot = getProjectRoot();
const relative = path.relative(projectRoot, filePath);
if (!relative || relative.startsWith('..')) {
return normalizeSourcePath(path.resolve(filePath));
}
return normalizeSourcePath(relative);
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function isBlankString(value) {
return typeof value !== 'string' || value.trim().length === 0;
}
function csvMatchValue(value) {
return String(value ?? '').trim();
}
function createValidationError(code, detail, fieldPath, sourcePath, observedValue, expectedValue) {
throw new ShardDocAuthorityValidationError({
code,
detail,
fieldPath,
sourcePath,
observedValue,
expectedValue,
});
}
function ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath) {
const requiredFields = ['canonicalId', 'displayName', 'description', 'sourcePath'];
for (const requiredField of requiredFields) {
if (!hasOwn(sidecarData, requiredField)) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
`Missing required sidecar metadata field "${requiredField}"`,
requiredField,
sidecarSourcePath,
);
}
}
for (const requiredField of requiredFields) {
if (isBlankString(sidecarData[requiredField])) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
`Required sidecar metadata field "${requiredField}" must be a non-empty string`,
requiredField,
sidecarSourcePath,
);
}
}
const normalizedCanonicalId = String(sidecarData.canonicalId).trim();
if (normalizedCanonicalId !== SHARD_DOC_LOCKED_CANONICAL_ID) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_CANONICAL_ID_MISMATCH,
'Converted shard-doc sidecar canonicalId must remain locked to bmad-shard-doc',
'canonicalId',
sidecarSourcePath,
normalizedCanonicalId,
SHARD_DOC_LOCKED_CANONICAL_ID,
);
}
const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath);
if (normalizedDeclaredSourcePath !== sourceXmlSourcePath) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
'Sidecar sourcePath must match shard-doc XML source path',
'sourcePath',
sidecarSourcePath,
normalizedDeclaredSourcePath,
sourceXmlSourcePath,
);
}
}
async function parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath) {
if (!(await fs.pathExists(compatibilityCatalogPath))) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_FILE_NOT_FOUND,
'Expected module-help compatibility catalog file was not found',
'<file>',
compatibilityCatalogSourcePath,
);
}
let csvRaw;
try {
csvRaw = await fs.readFile(compatibilityCatalogPath, 'utf8');
} catch (error) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED,
`Unable to read compatibility catalog file: ${error.message}`,
'<document>',
compatibilityCatalogSourcePath,
);
}
try {
return csv.parse(csvRaw, {
columns: true,
skip_empty_lines: true,
relax_column_count: true,
trim: true,
});
} catch (error) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_PARSE_FAILED,
`CSV parse failure: ${error.message}`,
'<document>',
compatibilityCatalogSourcePath,
);
}
}
function validateCompatibilityPrecedence({ rows, displayName, workflowFilePath, compatibilityCatalogSourcePath }) {
const workflowMatches = rows.filter((row) => csvMatchValue(row['workflow-file']) === workflowFilePath);
if (workflowMatches.length === 0) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_MISSING,
'Converted shard-doc compatibility row is missing from module-help catalog',
'workflow-file',
compatibilityCatalogSourcePath,
'<missing>',
workflowFilePath,
);
}
if (workflowMatches.length > 1) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMPATIBILITY_ROW_DUPLICATE,
'Converted shard-doc compatibility row appears more than once in module-help catalog',
'workflow-file',
compatibilityCatalogSourcePath,
workflowMatches.length,
1,
);
}
const canonicalCommandMatches = rows.filter((row) => csvMatchValue(row.command) === SHARD_DOC_LOCKED_CANONICAL_ID);
if (canonicalCommandMatches.length > 1) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DUPLICATE_CANONICAL_COMMAND,
'Converted shard-doc canonical command appears in more than one compatibility row',
'command',
compatibilityCatalogSourcePath,
canonicalCommandMatches.length,
1,
);
}
const shardDocRow = workflowMatches[0];
const observedCommand = csvMatchValue(shardDocRow.command);
if (!observedCommand || observedCommand !== SHARD_DOC_LOCKED_CANONICAL_ID) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.COMMAND_MISMATCH,
'Converted shard-doc compatibility command must match locked canonical command bmad-shard-doc',
'command',
compatibilityCatalogSourcePath,
observedCommand || '<empty>',
SHARD_DOC_LOCKED_CANONICAL_ID,
);
}
const observedDisplayName = csvMatchValue(shardDocRow.name);
if (observedDisplayName && observedDisplayName !== displayName) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.DISPLAY_NAME_MISMATCH,
'Converted shard-doc compatibility name must match sidecar displayName when provided',
'name',
compatibilityCatalogSourcePath,
observedDisplayName,
displayName,
);
}
}
function buildShardDocAuthorityRecords({ canonicalId, sidecarSourcePath, sourceXmlSourcePath }) {
return [
{
recordType: 'metadata-authority',
canonicalId,
authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
authoritySourceType: 'sidecar',
authoritySourcePath: sidecarSourcePath,
sourcePath: sourceXmlSourcePath,
},
{
recordType: 'source-body-authority',
canonicalId,
authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
authoritySourceType: 'source-xml',
authoritySourcePath: sourceXmlSourcePath,
sourcePath: sourceXmlSourcePath,
},
];
}
async function validateShardDocAuthoritySplitAndPrecedence(options = {}) {
const sourceXmlPath = options.sourceXmlPath || getSourcePath('core', 'tasks', 'shard-doc.xml');
const compatibilityCatalogPath = options.compatibilityCatalogPath || getSourcePath('core', 'module-help.csv');
const compatibilityWorkflowFilePath = options.compatibilityWorkflowFilePath || '_bmad/core/tasks/shard-doc.xml';
let resolvedMetadataAuthority;
try {
resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
sourceFilePath: sourceXmlPath,
metadataPath: options.sidecarPath || '',
metadataSourcePath: options.sidecarSourcePath || '',
ambiguousErrorCode: SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
});
} catch (error) {
createValidationError(
error.code || SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
error.detail || error.message,
error.fieldPath || '<file>',
normalizeSourcePath(error.sourcePath || toProjectRelativePath(sourceXmlPath)),
);
}
const sidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
const sidecarSourcePath = normalizeSourcePath(
options.sidecarSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath || resolvedMetadataAuthority.resolvedSourcePath,
);
const sourceXmlSourcePath = normalizeSourcePath(options.sourceXmlSourcePath || toProjectRelativePath(sourceXmlPath));
const compatibilityCatalogSourcePath = normalizeSourcePath(
options.compatibilityCatalogSourcePath || toProjectRelativePath(compatibilityCatalogPath),
);
if (!sidecarPath || !(await fs.pathExists(sidecarPath))) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
'Expected shard-doc sidecar metadata file was not found',
'<file>',
sidecarSourcePath,
);
}
let sidecarData;
try {
const sidecarRaw = await fs.readFile(sidecarPath, 'utf8');
sidecarData = yaml.parse(sidecarRaw);
} catch (error) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
`YAML parse failure: ${error.message}`,
'<document>',
sidecarSourcePath,
);
}
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SIDECAR_INVALID_METADATA,
'Sidecar root must be a YAML mapping object',
'<document>',
sidecarSourcePath,
);
}
ensureSidecarMetadata(sidecarData, sidecarSourcePath, sourceXmlSourcePath);
if (!(await fs.pathExists(sourceXmlPath))) {
createValidationError(
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES.SOURCE_XML_FILE_NOT_FOUND,
'Expected shard-doc XML source file was not found',
'<file>',
sourceXmlSourcePath,
);
}
const compatibilityRows = await parseCompatibilityRows(compatibilityCatalogPath, compatibilityCatalogSourcePath);
validateCompatibilityPrecedence({
rows: compatibilityRows,
displayName: sidecarData.displayName.trim(),
workflowFilePath: compatibilityWorkflowFilePath,
compatibilityCatalogSourcePath,
});
const canonicalId = SHARD_DOC_LOCKED_CANONICAL_ID;
const authoritativeRecords = buildShardDocAuthorityRecords({
canonicalId,
sidecarSourcePath,
sourceXmlSourcePath,
});
return {
canonicalId,
authoritativePresenceKey: SHARD_DOC_LOCKED_AUTHORITATIVE_PRESENCE_KEY,
authoritativeRecords,
checkedSurfaces: [sourceXmlSourcePath, compatibilityCatalogSourcePath],
metadataAuthority: {
resolvedPath: normalizeSourcePath(resolvedMetadataAuthority.resolvedSourcePath || sidecarSourcePath),
resolvedFilename: normalizeSourcePath(resolvedMetadataAuthority.resolvedFilename || ''),
canonicalTargetFilename: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetFilename || 'skill-manifest.yaml'),
canonicalTargetPath: normalizeSourcePath(resolvedMetadataAuthority.canonicalTargetSourcePath || sidecarSourcePath),
derivationMode: normalizeSourcePath(resolvedMetadataAuthority.derivationMode || ''),
},
};
}
module.exports = {
SHARD_DOC_AUTHORITY_VALIDATION_ERROR_CODES,
SHARD_DOC_LOCKED_CANONICAL_ID,
ShardDocAuthorityValidationError,
buildShardDocAuthorityRecords,
validateShardDocAuthoritySplitAndPrecedence,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,621 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { getProjectRoot, getSourcePath } = require('../../../lib/project-root');
const HELP_SIDECAR_REQUIRED_FIELDS = Object.freeze([
'schemaVersion',
'canonicalId',
'artifactType',
'module',
'sourcePath',
'displayName',
'description',
'dependencies',
]);
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',
PARSE_FAILED: 'ERR_HELP_SIDECAR_PARSE_FAILED',
INVALID_ROOT_OBJECT: 'ERR_HELP_SIDECAR_INVALID_ROOT_OBJECT',
REQUIRED_FIELD_MISSING: 'ERR_HELP_SIDECAR_REQUIRED_FIELD_MISSING',
REQUIRED_FIELD_EMPTY: 'ERR_HELP_SIDECAR_REQUIRED_FIELD_EMPTY',
ARTIFACT_TYPE_INVALID: 'ERR_HELP_SIDECAR_ARTIFACT_TYPE_INVALID',
MODULE_INVALID: 'ERR_HELP_SIDECAR_MODULE_INVALID',
DEPENDENCIES_MISSING: 'ERR_HELP_SIDECAR_DEPENDENCIES_MISSING',
DEPENDENCIES_REQUIRES_INVALID: 'ERR_HELP_SIDECAR_DEPENDENCIES_REQUIRES_INVALID',
DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_HELP_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY',
MAJOR_VERSION_UNSUPPORTED: 'ERR_SIDECAR_MAJOR_VERSION_UNSUPPORTED',
SOURCEPATH_BASENAME_MISMATCH: 'ERR_SIDECAR_SOURCEPATH_BASENAME_MISMATCH',
METADATA_FILENAME_AMBIGUOUS: 'ERR_HELP_SIDECAR_METADATA_FILENAME_AMBIGUOUS',
});
const SHARD_DOC_SIDECAR_ERROR_CODES = Object.freeze({
FILE_NOT_FOUND: 'ERR_SHARD_DOC_SIDECAR_FILE_NOT_FOUND',
PARSE_FAILED: 'ERR_SHARD_DOC_SIDECAR_PARSE_FAILED',
INVALID_ROOT_OBJECT: 'ERR_SHARD_DOC_SIDECAR_INVALID_ROOT_OBJECT',
REQUIRED_FIELD_MISSING: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_MISSING',
REQUIRED_FIELD_EMPTY: 'ERR_SHARD_DOC_SIDECAR_REQUIRED_FIELD_EMPTY',
ARTIFACT_TYPE_INVALID: 'ERR_SHARD_DOC_SIDECAR_ARTIFACT_TYPE_INVALID',
MODULE_INVALID: 'ERR_SHARD_DOC_SIDECAR_MODULE_INVALID',
DEPENDENCIES_MISSING: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_MISSING',
DEPENDENCIES_REQUIRES_INVALID: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_INVALID',
DEPENDENCIES_REQUIRES_NOT_EMPTY: 'ERR_SHARD_DOC_SIDECAR_DEPENDENCIES_REQUIRES_NOT_EMPTY',
MAJOR_VERSION_UNSUPPORTED: 'ERR_SHARD_DOC_SIDECAR_MAJOR_VERSION_UNSUPPORTED',
SOURCEPATH_BASENAME_MISMATCH: 'ERR_SHARD_DOC_SIDECAR_SOURCEPATH_BASENAME_MISMATCH',
METADATA_FILENAME_AMBIGUOUS: 'ERR_SHARD_DOC_SIDECAR_METADATA_FILENAME_AMBIGUOUS',
});
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',
METADATA_FILENAME_AMBIGUOUS: 'ERR_INDEX_DOCS_SIDECAR_METADATA_FILENAME_AMBIGUOUS',
});
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 SKILL_METADATA_CANONICAL_FILENAME = 'skill-manifest.yaml';
const SKILL_METADATA_LEGACY_FILENAMES = Object.freeze(['bmad-config.yaml', 'manifest.yaml']);
const SKILL_METADATA_DERIVATION_MODES = Object.freeze({
CANONICAL: 'canonical',
LEGACY_FALLBACK: 'legacy-fallback',
});
const SKILL_METADATA_RESOLUTION_ERROR_CODES = Object.freeze({
AMBIGUOUS_MATCH: 'ERR_SKILL_METADATA_FILENAME_AMBIGUOUS',
});
const SIDECAR_SUPPORTED_SCHEMA_MAJOR = 1;
class SidecarContractError extends Error {
constructor({ code, detail, fieldPath, sourcePath }) {
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath})`;
super(message);
this.name = 'SidecarContractError';
this.code = code;
this.detail = detail;
this.fieldPath = fieldPath;
this.sourcePath = sourcePath;
this.fullMessage = message;
}
}
function normalizeSourcePath(value) {
if (!value) return '';
return String(value).replaceAll('\\', '/');
}
function toProjectRelativePath(filePath, projectRoot = getProjectRoot()) {
const relative = path.relative(projectRoot, filePath);
if (!relative || relative.startsWith('..')) {
return normalizeSourcePath(path.resolve(filePath));
}
return normalizeSourcePath(relative);
}
function dedupeAndSort(values) {
const normalized = new Set();
for (const value of values || []) {
const text = normalizeSourcePath(value).trim();
if (text.length > 0) {
normalized.add(text);
}
}
return [...normalized].sort((left, right) => left.localeCompare(right));
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function isBlankString(value) {
return typeof value !== 'string' || value.trim().length === 0;
}
function parseSchemaMajorVersion(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\d+)(?:\.\d+)?$/);
if (!match) return null;
return Number.parseInt(match[1], 10);
}
return null;
}
function classifyMetadataFilename(filename) {
const normalizedFilename = String(filename || '')
.trim()
.toLowerCase();
if (normalizedFilename === SKILL_METADATA_CANONICAL_FILENAME) {
return SKILL_METADATA_DERIVATION_MODES.CANONICAL;
}
if (SKILL_METADATA_LEGACY_FILENAMES.includes(normalizedFilename) || normalizedFilename.endsWith('.artifact.yaml')) {
return SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK;
}
return SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK;
}
function getMetadataStemFromSourcePath(sourcePathValue) {
const normalizedSourcePath = normalizeSourcePath(sourcePathValue).trim();
if (!normalizedSourcePath) return '';
const sourceBasename = path.posix.basename(normalizedSourcePath);
if (!sourceBasename) return '';
const sourceExt = path.posix.extname(sourceBasename);
const baseWithoutExt = sourceExt ? sourceBasename.slice(0, -sourceExt.length) : sourceBasename;
return baseWithoutExt.trim();
}
function buildSkillMetadataResolutionPlan({ sourceFilePath, projectRoot = getProjectRoot() }) {
const absoluteSourceFilePath = path.resolve(sourceFilePath);
const sourceDirAbsolutePath = path.dirname(absoluteSourceFilePath);
const metadataStem = getMetadataStemFromSourcePath(absoluteSourceFilePath);
const skillFolderAbsolutePath = path.join(sourceDirAbsolutePath, metadataStem);
const canonicalTargetAbsolutePath = path.join(skillFolderAbsolutePath, SKILL_METADATA_CANONICAL_FILENAME);
const candidateGroups = [
{
precedenceToken: SKILL_METADATA_CANONICAL_FILENAME,
derivationMode: SKILL_METADATA_DERIVATION_MODES.CANONICAL,
// Canonical authority is per-skill only; root task-folder canonical files are not eligible.
explicitCandidates: [canonicalTargetAbsolutePath],
wildcardDirectories: [],
},
{
precedenceToken: 'bmad-config.yaml',
derivationMode: SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK,
explicitCandidates: [path.join(skillFolderAbsolutePath, 'bmad-config.yaml'), path.join(sourceDirAbsolutePath, 'bmad-config.yaml')],
wildcardDirectories: [],
},
{
precedenceToken: 'manifest.yaml',
derivationMode: SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK,
explicitCandidates: [path.join(skillFolderAbsolutePath, 'manifest.yaml'), path.join(sourceDirAbsolutePath, 'manifest.yaml')],
wildcardDirectories: [],
},
{
precedenceToken: `${metadataStem}.artifact.yaml`,
derivationMode: SKILL_METADATA_DERIVATION_MODES.LEGACY_FALLBACK,
explicitCandidates: [
path.join(sourceDirAbsolutePath, `${metadataStem}.artifact.yaml`),
path.join(skillFolderAbsolutePath, `${metadataStem}.artifact.yaml`),
],
wildcardDirectories: [],
},
];
return {
metadataStem,
canonicalTargetAbsolutePath,
canonicalTargetSourcePath: toProjectRelativePath(canonicalTargetAbsolutePath, projectRoot),
candidateGroups,
};
}
async function resolveCandidateGroupMatches(group = {}) {
const explicitMatches = [];
for (const candidatePath of group.explicitCandidates || []) {
if (await fs.pathExists(candidatePath)) {
explicitMatches.push(path.resolve(candidatePath));
}
}
const wildcardMatches = [];
for (const wildcardDirectory of group.wildcardDirectories || []) {
if (!(await fs.pathExists(wildcardDirectory))) {
continue;
}
const directoryEntries = await fs.readdir(wildcardDirectory, { withFileTypes: true });
for (const entry of directoryEntries) {
if (!entry.isFile()) continue;
const filename = String(entry.name || '').trim();
if (!filename.toLowerCase().endsWith('.artifact.yaml')) continue;
wildcardMatches.push(path.join(wildcardDirectory, filename));
}
}
return dedupeAndSort([...explicitMatches, ...wildcardMatches]);
}
async function resolveSkillMetadataAuthority({
sourceFilePath,
metadataPath = '',
metadataSourcePath = '',
projectRoot = getProjectRoot(),
ambiguousErrorCode = SKILL_METADATA_RESOLUTION_ERROR_CODES.AMBIGUOUS_MATCH,
}) {
const resolutionPlan = buildSkillMetadataResolutionPlan({
sourceFilePath,
projectRoot,
});
const resolvedMetadataPath = String(metadataPath || '').trim();
if (resolvedMetadataPath.length > 0) {
const resolvedAbsolutePath = path.resolve(resolvedMetadataPath);
const resolvedFilename = path.posix.basename(normalizeSourcePath(resolvedAbsolutePath));
return {
resolvedAbsolutePath,
resolvedSourcePath: normalizeSourcePath(metadataSourcePath || toProjectRelativePath(resolvedAbsolutePath, projectRoot)),
resolvedFilename,
canonicalTargetFilename: SKILL_METADATA_CANONICAL_FILENAME,
canonicalTargetSourcePath: resolutionPlan.canonicalTargetSourcePath,
derivationMode: classifyMetadataFilename(resolvedFilename),
precedenceToken: resolvedFilename,
};
}
for (const group of resolutionPlan.candidateGroups) {
const matches = await resolveCandidateGroupMatches(group);
if (matches.length === 0) {
continue;
}
if (matches.length > 1) {
throw new SidecarContractError({
code: ambiguousErrorCode,
detail: `metadata filename resolution is ambiguous for precedence "${group.precedenceToken}": ${matches.join('|')}`,
fieldPath: '<file>',
sourcePath: resolutionPlan.canonicalTargetSourcePath,
});
}
const resolvedAbsolutePath = matches[0];
const resolvedFilename = path.posix.basename(normalizeSourcePath(resolvedAbsolutePath));
return {
resolvedAbsolutePath,
resolvedSourcePath: normalizeSourcePath(toProjectRelativePath(resolvedAbsolutePath, projectRoot)),
resolvedFilename,
canonicalTargetFilename: SKILL_METADATA_CANONICAL_FILENAME,
canonicalTargetSourcePath: resolutionPlan.canonicalTargetSourcePath,
derivationMode: group.derivationMode,
precedenceToken: group.precedenceToken,
};
}
return {
resolvedAbsolutePath: '',
resolvedSourcePath: '',
resolvedFilename: '',
canonicalTargetFilename: SKILL_METADATA_CANONICAL_FILENAME,
canonicalTargetSourcePath: resolutionPlan.canonicalTargetSourcePath,
derivationMode: '',
precedenceToken: '',
};
}
function getExpectedLegacyArtifactBasenameFromSourcePath(sourcePathValue) {
const normalized = normalizeSourcePath(sourcePathValue).trim();
if (!normalized) return '';
const sourceBasename = path.posix.basename(normalized);
if (!sourceBasename) return '';
const sourceExt = path.posix.extname(sourceBasename);
const baseWithoutExt = sourceExt ? sourceBasename.slice(0, -sourceExt.length) : sourceBasename;
if (!baseWithoutExt) return '';
return `${baseWithoutExt}.artifact.yaml`;
}
function createValidationError(code, fieldPath, sourcePath, detail) {
throw new SidecarContractError({
code,
fieldPath,
sourcePath,
detail,
});
}
function validateSidecarContractData(sidecarData, options) {
const {
sourcePath,
requiredFields,
requiredNonEmptyStringFields,
errorCodes,
expectedArtifactType,
expectedModule,
expectedCanonicalSourcePath,
missingDependenciesDetail,
dependenciesObjectDetail,
dependenciesRequiresArrayDetail,
dependenciesRequiresNotEmptyDetail,
artifactTypeDetail,
moduleDetail,
requiresMustBeEmpty,
} = options;
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
createValidationError(errorCodes.INVALID_ROOT_OBJECT, '<document>', sourcePath, 'Sidecar root must be a YAML mapping object.');
}
for (const field of requiredFields) {
if (!hasOwn(sidecarData, field)) {
if (field === 'dependencies') {
createValidationError(errorCodes.DEPENDENCIES_MISSING, field, sourcePath, missingDependenciesDetail);
}
createValidationError(errorCodes.REQUIRED_FIELD_MISSING, field, sourcePath, `Missing required sidecar field "${field}".`);
}
}
for (const field of requiredNonEmptyStringFields) {
if (isBlankString(sidecarData[field])) {
createValidationError(
errorCodes.REQUIRED_FIELD_EMPTY,
field,
sourcePath,
`Required sidecar field "${field}" must be a non-empty string.`,
);
}
}
const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion);
if (schemaMajorVersion !== SIDECAR_SUPPORTED_SCHEMA_MAJOR) {
createValidationError(errorCodes.MAJOR_VERSION_UNSUPPORTED, 'schemaVersion', sourcePath, 'sidecar schema major version is unsupported');
}
if (sidecarData.artifactType !== expectedArtifactType) {
createValidationError(errorCodes.ARTIFACT_TYPE_INVALID, 'artifactType', sourcePath, artifactTypeDetail);
}
if (sidecarData.module !== expectedModule) {
createValidationError(errorCodes.MODULE_INVALID, 'module', sourcePath, moduleDetail);
}
const dependencies = sidecarData.dependencies;
if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
createValidationError(errorCodes.DEPENDENCIES_MISSING, 'dependencies', sourcePath, dependenciesObjectDetail);
}
if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) {
createValidationError(errorCodes.DEPENDENCIES_REQUIRES_INVALID, 'dependencies.requires', sourcePath, dependenciesRequiresArrayDetail);
}
if (requiresMustBeEmpty && dependencies.requires.length > 0) {
createValidationError(
errorCodes.DEPENDENCIES_REQUIRES_NOT_EMPTY,
'dependencies.requires',
sourcePath,
dependenciesRequiresNotEmptyDetail,
);
}
const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath);
const sidecarBasename = path.posix.basename(normalizeSourcePath(sourcePath)).toLowerCase();
const expectedLegacyArtifactBasename = getExpectedLegacyArtifactBasenameFromSourcePath(normalizedDeclaredSourcePath).toLowerCase();
const allowedMetadataBasenames = new Set([SKILL_METADATA_CANONICAL_FILENAME, ...SKILL_METADATA_LEGACY_FILENAMES]);
if (expectedLegacyArtifactBasename.length > 0) {
allowedMetadataBasenames.add(expectedLegacyArtifactBasename);
}
const sourcePathMismatch = normalizedDeclaredSourcePath !== expectedCanonicalSourcePath;
const basenameMismatch = !allowedMetadataBasenames.has(sidecarBasename);
if (sourcePathMismatch || basenameMismatch) {
createValidationError(
errorCodes.SOURCEPATH_BASENAME_MISMATCH,
'sourcePath',
sourcePath,
'sidecar basename does not match sourcePath basename',
);
}
}
function validateHelpSidecarContractData(sidecarData, options = {}) {
const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help/skill-manifest.yaml');
validateSidecarContractData(sidecarData, {
sourcePath,
requiredFields: HELP_SIDECAR_REQUIRED_FIELDS,
requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'],
errorCodes: HELP_SIDECAR_ERROR_CODES,
expectedArtifactType: 'task',
expectedModule: 'core',
expectedCanonicalSourcePath: HELP_EXEMPLAR_CANONICAL_SOURCE_PATH,
missingDependenciesDetail: 'Exemplar sidecar requires an explicit dependencies block.',
dependenciesObjectDetail: 'Exemplar sidecar requires an explicit dependencies object.',
dependenciesRequiresArrayDetail: 'Exemplar dependencies.requires must be an array.',
dependenciesRequiresNotEmptyDetail: 'help exemplar requires explicit zero dependencies: dependencies.requires must be [].',
artifactTypeDetail: 'help exemplar requires artifactType to equal "task".',
moduleDetail: 'help exemplar requires module to equal "core".',
requiresMustBeEmpty: true,
});
}
function validateShardDocSidecarContractData(sidecarData, options = {}) {
const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/shard-doc/skill-manifest.yaml');
validateSidecarContractData(sidecarData, {
sourcePath,
requiredFields: SHARD_DOC_SIDECAR_REQUIRED_FIELDS,
requiredNonEmptyStringFields: ['canonicalId', 'sourcePath', 'displayName', 'description'],
errorCodes: SHARD_DOC_SIDECAR_ERROR_CODES,
expectedArtifactType: 'task',
expectedModule: 'core',
expectedCanonicalSourcePath: SHARD_DOC_CANONICAL_SOURCE_PATH,
missingDependenciesDetail: 'Shard-doc sidecar requires an explicit dependencies block.',
dependenciesObjectDetail: 'Shard-doc sidecar requires an explicit dependencies object.',
dependenciesRequiresArrayDetail: 'Shard-doc dependencies.requires must be an array.',
dependenciesRequiresNotEmptyDetail: 'Shard-doc contract requires explicit zero dependencies: dependencies.requires must be [].',
artifactTypeDetail: 'Shard-doc contract requires artifactType to equal "task".',
moduleDetail: 'Shard-doc contract requires module to equal "core".',
requiresMustBeEmpty: true,
});
}
function validateIndexDocsSidecarContractData(sidecarData, options = {}) {
const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/index-docs/skill-manifest.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 = '', options = {}) {
const sourceFilePath = options.sourceFilePath || getSourcePath('core', 'tasks', 'help.md');
const resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
sourceFilePath,
metadataPath: sidecarPath,
metadataSourcePath: options.errorSourcePath,
ambiguousErrorCode: HELP_SIDECAR_ERROR_CODES.METADATA_FILENAME_AMBIGUOUS,
});
const resolvedSidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
const normalizedSourcePath = normalizeSourcePath(
options.errorSourcePath || resolvedMetadataAuthority.resolvedSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath,
);
if (!resolvedSidecarPath || !(await fs.pathExists(resolvedSidecarPath))) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.FILE_NOT_FOUND,
'<file>',
normalizedSourcePath,
'Expected exemplar sidecar file was not found.',
);
}
let parsedSidecar;
try {
const sidecarRaw = await fs.readFile(resolvedSidecarPath, 'utf8');
parsedSidecar = yaml.parse(sidecarRaw);
} catch (error) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.PARSE_FAILED,
'<document>',
normalizedSourcePath,
`YAML parse failure: ${error.message}`,
);
}
validateHelpSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
return resolvedMetadataAuthority;
}
async function validateShardDocSidecarContractFile(sidecarPath = '', options = {}) {
const sourceFilePath = options.sourceFilePath || getSourcePath('core', 'tasks', 'shard-doc.xml');
const resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
sourceFilePath,
metadataPath: sidecarPath,
metadataSourcePath: options.errorSourcePath,
ambiguousErrorCode: SHARD_DOC_SIDECAR_ERROR_CODES.METADATA_FILENAME_AMBIGUOUS,
});
const resolvedSidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
const normalizedSourcePath = normalizeSourcePath(
options.errorSourcePath || resolvedMetadataAuthority.resolvedSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath,
);
if (!resolvedSidecarPath || !(await fs.pathExists(resolvedSidecarPath))) {
createValidationError(
SHARD_DOC_SIDECAR_ERROR_CODES.FILE_NOT_FOUND,
'<file>',
normalizedSourcePath,
'Expected shard-doc sidecar file was not found.',
);
}
let parsedSidecar;
try {
const sidecarRaw = await fs.readFile(resolvedSidecarPath, 'utf8');
parsedSidecar = yaml.parse(sidecarRaw);
} catch (error) {
createValidationError(
SHARD_DOC_SIDECAR_ERROR_CODES.PARSE_FAILED,
'<document>',
normalizedSourcePath,
`YAML parse failure: ${error.message}`,
);
}
validateShardDocSidecarContractData(parsedSidecar, { errorSourcePath: normalizedSourcePath });
return resolvedMetadataAuthority;
}
async function validateIndexDocsSidecarContractFile(sidecarPath = '', options = {}) {
const sourceFilePath = options.sourceFilePath || getSourcePath('core', 'tasks', 'index-docs.xml');
const resolvedMetadataAuthority = await resolveSkillMetadataAuthority({
sourceFilePath,
metadataPath: sidecarPath,
metadataSourcePath: options.errorSourcePath,
ambiguousErrorCode: INDEX_DOCS_SIDECAR_ERROR_CODES.METADATA_FILENAME_AMBIGUOUS,
});
const resolvedSidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
const normalizedSourcePath = normalizeSourcePath(
options.errorSourcePath || resolvedMetadataAuthority.resolvedSourcePath || resolvedMetadataAuthority.canonicalTargetSourcePath,
);
if (!resolvedSidecarPath || !(await fs.pathExists(resolvedSidecarPath))) {
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(resolvedSidecarPath, '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 });
return resolvedMetadataAuthority;
}
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,
SKILL_METADATA_CANONICAL_FILENAME,
SKILL_METADATA_DERIVATION_MODES,
SKILL_METADATA_LEGACY_FILENAMES,
SKILL_METADATA_RESOLUTION_ERROR_CODES,
SidecarContractError,
resolveSkillMetadataAuthority,
validateHelpSidecarContractData,
validateHelpSidecarContractFile,
validateShardDocSidecarContractData,
validateShardDocSidecarContractFile,
validateIndexDocsSidecarContractData,
validateIndexDocsSidecarContractFile,
};

View File

@ -8,14 +8,134 @@ const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator'); const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
const { getTasksFromBmad } = require('./shared/bmad-artifacts'); const { getTasksFromBmad } = require('./shared/bmad-artifacts');
const { toDashPath, customAgentDashName } = require('./shared/path-utils'); const { toDashPath, customAgentDashName } = require('./shared/path-utils');
const { normalizeAndResolveExemplarAlias } = require('../core/help-alias-normalizer');
const { resolveSkillMetadataAuthority } = require('../core/sidecar-contract-validator');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const CODEX_EXPORT_DERIVATION_ERROR_CODES = Object.freeze({
SIDECAR_FILE_NOT_FOUND: 'ERR_CODEX_EXPORT_SIDECAR_FILE_NOT_FOUND',
SIDECAR_FILENAME_AMBIGUOUS: 'ERR_CODEX_EXPORT_SIDECAR_FILENAME_AMBIGUOUS',
SIDECAR_PARSE_FAILED: 'ERR_CODEX_EXPORT_SIDECAR_PARSE_FAILED',
CANONICAL_ID_MISSING: 'ERR_CODEX_EXPORT_CANONICAL_ID_MISSING',
CANONICAL_ID_DERIVATION_FAILED: 'ERR_CODEX_EXPORT_CANONICAL_ID_DERIVATION_FAILED',
DUPLICATE_EXPORT_SURFACE: 'ERR_CODEX_EXPORT_DUPLICATE_EXPORT_SURFACE',
});
const EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
const EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc.xml';
const EXEMPLAR_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/skill-manifest.yaml';
const EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/shard-doc/skill-manifest.yaml';
const EXEMPLAR_INDEX_DOCS_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/index-docs/skill-manifest.yaml';
const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id';
const SHARD_DOC_EXPORT_ALIAS_ROWS = Object.freeze([
Object.freeze({
rowIdentity: 'alias-row:bmad-shard-doc:canonical-id',
canonicalId: 'bmad-shard-doc',
normalizedAliasValue: 'bmad-shard-doc',
rawIdentityHasLeadingSlash: false,
}),
Object.freeze({
rowIdentity: 'alias-row:bmad-shard-doc:legacy-name',
canonicalId: 'bmad-shard-doc',
normalizedAliasValue: 'shard-doc',
rawIdentityHasLeadingSlash: false,
}),
Object.freeze({
rowIdentity: 'alias-row:bmad-shard-doc:slash-command',
canonicalId: 'bmad-shard-doc',
normalizedAliasValue: 'bmad-shard-doc',
rawIdentityHasLeadingSlash: true,
}),
]);
const 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,
sourcePathSuffix: '/core/tasks/help.md',
sidecarSourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
sourceFileCandidates: Object.freeze([
Object.freeze({
segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.md'],
}),
Object.freeze({
segments: ['src', 'core', 'tasks', 'help.md'],
}),
]),
}),
'shard-doc': Object.freeze({
taskSourcePath: EXEMPLAR_SHARD_DOC_TASK_XML_SOURCE_PATH,
sourcePathSuffix: '/core/tasks/shard-doc.xml',
sidecarSourcePath: EXEMPLAR_SHARD_DOC_SIDECAR_CONTRACT_SOURCE_PATH,
aliasRows: SHARD_DOC_EXPORT_ALIAS_ROWS,
sourceFileCandidates: Object.freeze([
Object.freeze({
segments: ['bmad-fork', 'src', 'core', 'tasks', 'shard-doc.xml'],
}),
Object.freeze({
segments: ['src', 'core', 'tasks', 'shard-doc.xml'],
}),
]),
}),
'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,
sourceFileCandidates: Object.freeze([
Object.freeze({
segments: ['bmad-fork', 'src', 'core', 'tasks', 'index-docs.xml'],
}),
Object.freeze({
segments: ['src', 'core', 'tasks', 'index-docs.xml'],
}),
]),
}),
});
class CodexExportDerivationError extends Error {
constructor({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
const message = `${code}: ${detail} (fieldPath=${fieldPath}, sourcePath=${sourcePath}, observedValue=${observedValue})`;
super(message);
this.name = 'CodexExportDerivationError';
this.code = code;
this.detail = detail;
this.fieldPath = fieldPath;
this.sourcePath = sourcePath;
this.observedValue = observedValue;
if (cause) {
this.cause = cause;
}
}
}
/** /**
* Codex setup handler (CLI mode) * Codex setup handler (CLI mode)
*/ */
class CodexSetup extends BaseIdeSetup { class CodexSetup extends BaseIdeSetup {
constructor() { constructor() {
super('codex', 'Codex', false); super('codex', 'Codex', false);
this.exportDerivationRecords = [];
this.exportSurfaceIdentityOwners = new Map();
} }
/** /**
@ -31,6 +151,8 @@ class CodexSetup extends BaseIdeSetup {
const mode = 'cli'; const mode = 'cli';
const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options);
this.exportDerivationRecords = [];
this.exportSurfaceIdentityOwners = new Map();
// Clean up old .codex/prompts locations (both global and project) // Clean up old .codex/prompts locations (both global and project)
const oldGlobalDir = this.getOldCodexPromptDir(null, 'global'); const oldGlobalDir = this.getOldCodexPromptDir(null, 'global');
@ -46,7 +168,7 @@ class CodexSetup extends BaseIdeSetup {
// Collect and write agent skills // Collect and write agent skills
const agentGen = new AgentCommandGenerator(this.bmadFolderName); const agentGen = new AgentCommandGenerator(this.bmadFolderName);
const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []); const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []);
const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher'); const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher', { projectDir });
// Collect and write task skills // Collect and write task skills
const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []); const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []);
@ -77,12 +199,12 @@ class CodexSetup extends BaseIdeSetup {
...artifact, ...artifact,
content: ttGen.generateCommandContent(artifact, artifact.type), content: ttGen.generateCommandContent(artifact, artifact.type),
})); }));
const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task'); const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task', { projectDir });
// Collect and write workflow skills // Collect and write workflow skills
const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName); const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName);
const { artifacts: workflowArtifacts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir); const { artifacts: workflowArtifacts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir);
const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command'); const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command', { projectDir });
const written = agentCount + workflowCount + tasksWritten; const written = agentCount + workflowCount + tasksWritten;
@ -99,6 +221,7 @@ class CodexSetup extends BaseIdeSetup {
counts, counts,
destination: destDir, destination: destDir,
written, written,
exportDerivationRecords: [...this.exportDerivationRecords],
}; };
} }
@ -207,7 +330,203 @@ class CodexSetup extends BaseIdeSetup {
* @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task') * @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task')
* @returns {number} Number of skills written * @returns {number} Number of skills written
*/ */
async writeSkillArtifacts(destDir, artifacts, artifactType) { getConvertedTaskExportTarget(artifact = {}) {
if (artifact.type !== 'task' || artifact.module !== 'core') {
return null;
}
const normalizedName = String(artifact.name || '')
.trim()
.toLowerCase();
const exportTarget = EXEMPLAR_CONVERTED_TASK_EXPORT_TARGETS[normalizedName];
if (!exportTarget) {
return null;
}
const normalizedRelativePath = String(artifact.relativePath || '')
.trim()
.replaceAll('\\', '/')
.toLowerCase();
const normalizedSourcePath = String(artifact.sourcePath || '')
.trim()
.replaceAll('\\', '/')
.toLowerCase();
const normalizedRelativePathWithRoot = normalizedRelativePath.startsWith('/') ? normalizedRelativePath : `/${normalizedRelativePath}`;
if (!normalizedRelativePathWithRoot.endsWith(`/core/tasks/${normalizedName}.md`)) {
return null;
}
const normalizedSourcePathWithRoot = normalizedSourcePath.startsWith('/') ? normalizedSourcePath : `/${normalizedSourcePath}`;
if (normalizedSourcePath && !normalizedSourcePathWithRoot.endsWith(exportTarget.sourcePathSuffix)) {
return null;
}
return exportTarget;
}
throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
throw new CodexExportDerivationError({
code,
detail,
fieldPath,
sourcePath,
observedValue,
cause,
});
}
async loadConvertedTaskSidecar(projectDir, exportTarget) {
const sourceCandidates = (exportTarget.sourceFileCandidates || []).map((candidate) => path.join(projectDir, ...candidate.segments));
if (sourceCandidates.length === 0) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
detail: 'expected exemplar metadata source candidates are missing',
fieldPath: '<file>',
sourcePath: exportTarget.sidecarSourcePath,
observedValue: projectDir,
});
}
let resolvedMetadataAuthority = null;
for (const sourceCandidate of sourceCandidates) {
try {
const resolution = await resolveSkillMetadataAuthority({
sourceFilePath: sourceCandidate,
projectRoot: projectDir,
ambiguousErrorCode: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
});
if (!resolvedMetadataAuthority) {
resolvedMetadataAuthority = resolution;
}
if (resolution.resolvedAbsolutePath && (await fs.pathExists(resolution.resolvedAbsolutePath))) {
resolvedMetadataAuthority = resolution;
break;
}
} catch (error) {
this.throwExportDerivationError({
code: error.code || CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILENAME_AMBIGUOUS,
detail: error.detail || error.message,
fieldPath: error.fieldPath || '<file>',
sourcePath: exportTarget.sidecarSourcePath,
observedValue: error.sourcePath || projectDir,
cause: error,
});
}
}
const sidecarPath = resolvedMetadataAuthority.resolvedAbsolutePath;
if (!sidecarPath || !(await fs.pathExists(sidecarPath))) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
detail: 'expected exemplar sidecar metadata file was not found',
fieldPath: '<file>',
sourcePath: exportTarget.sidecarSourcePath,
observedValue: projectDir,
});
}
let sidecarData;
try {
sidecarData = yaml.parse(await fs.readFile(sidecarPath, 'utf8'));
} catch (error) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
detail: `YAML parse failure: ${error.message}`,
fieldPath: '<document>',
sourcePath: exportTarget.sidecarSourcePath,
observedValue: '<parse-error>',
cause: error,
});
}
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_PARSE_FAILED,
detail: 'sidecar root must be a YAML mapping object',
fieldPath: '<document>',
sourcePath: exportTarget.sidecarSourcePath,
observedValue: typeof sidecarData,
});
}
const canonicalId = String(sidecarData.canonicalId || '').trim();
if (canonicalId.length === 0) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_MISSING,
detail: 'sidecar canonicalId is required for exemplar export derivation',
fieldPath: 'canonicalId',
sourcePath: exportTarget.sidecarSourcePath,
observedValue: canonicalId,
});
}
return {
canonicalId,
sourcePath: exportTarget.sidecarSourcePath,
resolvedFilename: String(resolvedMetadataAuthority.resolvedFilename || ''),
derivationMode: String(resolvedMetadataAuthority.derivationMode || ''),
};
}
async resolveSkillIdentityFromArtifact(artifact, projectDir) {
const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, '');
const exportTarget = this.getConvertedTaskExportTarget(artifact);
if (!exportTarget) {
return {
skillName: inferredSkillName,
canonicalId: inferredSkillName,
exportIdDerivationSourceType: 'path-derived',
exportIdDerivationSourcePath: String(artifact.relativePath || ''),
};
}
const sidecarData = await this.loadConvertedTaskSidecar(projectDir, exportTarget);
let canonicalResolution;
try {
const aliasResolutionOptions = {
fieldPath: 'canonicalId',
sourcePath: sidecarData.sourcePath,
};
if (Array.isArray(exportTarget.aliasRows)) {
aliasResolutionOptions.aliasRows = exportTarget.aliasRows;
aliasResolutionOptions.aliasTableSourcePath = '_bmad/_config/canonical-aliases.csv';
}
canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, aliasResolutionOptions);
} catch (error) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
detail: `failed to derive exemplar export id from sidecar canonicalId (${error.code || error.message})`,
fieldPath: 'canonicalId',
sourcePath: sidecarData.sourcePath,
observedValue: sidecarData.canonicalId,
cause: error,
});
}
const skillName = String(canonicalResolution.postAliasCanonicalId || '').trim();
if (skillName.length === 0) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.CANONICAL_ID_DERIVATION_FAILED,
detail: 'resolved canonical export id is empty',
fieldPath: 'canonicalId',
sourcePath: sidecarData.sourcePath,
observedValue: sidecarData.canonicalId,
});
}
return {
skillName,
canonicalId: skillName,
exportIdDerivationSourceType: EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE,
exportIdDerivationSourcePath: sidecarData.sourcePath,
exportIdDerivationTaskSourcePath: exportTarget.taskSourcePath,
exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`,
};
}
async writeSkillArtifacts(destDir, artifacts, artifactType, options = {}) {
let writtenCount = 0; let writtenCount = 0;
for (const artifact of artifacts) { for (const artifact of artifacts) {
@ -217,11 +536,38 @@ class CodexSetup extends BaseIdeSetup {
} }
// Get the dash-format name (e.g., bmad-bmm-create-prd.md) and remove .md // Get the dash-format name (e.g., bmad-bmm-create-prd.md) and remove .md
const flatName = toDashPath(artifact.relativePath); const exportIdentity = await this.resolveSkillIdentityFromArtifact(artifact, options.projectDir || process.cwd());
const skillName = flatName.replace(/\.md$/, ''); const skillName = exportIdentity.skillName;
// Create skill directory // Create skill directory
const skillDir = path.join(destDir, skillName); const skillDir = path.join(destDir, skillName);
const skillPath = path.join(skillDir, 'SKILL.md');
const normalizedSkillPath = skillPath.replaceAll('\\', '/');
const ownerRecord = {
artifactType,
sourcePath: String(artifact.sourcePath || artifact.relativePath || '<unknown>'),
};
const existingOwner = this.exportSurfaceIdentityOwners.get(normalizedSkillPath);
if (existingOwner) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE,
detail: `duplicate export surface path already claimed by ${existingOwner.artifactType}:${existingOwner.sourcePath}`,
fieldPath: 'canonicalId',
sourcePath: ownerRecord.sourcePath,
observedValue: normalizedSkillPath,
});
}
if (await fs.pathExists(skillPath)) {
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.DUPLICATE_EXPORT_SURFACE,
detail: 'duplicate export surface path already exists on disk',
fieldPath: 'canonicalId',
sourcePath: ownerRecord.sourcePath,
observedValue: normalizedSkillPath,
});
}
await fs.ensureDir(skillDir); await fs.ensureDir(skillDir);
// Transform content: rewrite frontmatter for skills format // Transform content: rewrite frontmatter for skills format
@ -229,8 +575,26 @@ class CodexSetup extends BaseIdeSetup {
// Write SKILL.md with platform-native line endings // Write SKILL.md with platform-native line endings
const platformContent = skillContent.replaceAll('\n', os.EOL); const platformContent = skillContent.replaceAll('\n', os.EOL);
await fs.writeFile(path.join(skillDir, 'SKILL.md'), platformContent, 'utf8'); await fs.writeFile(skillPath, platformContent, 'utf8');
this.exportSurfaceIdentityOwners.set(normalizedSkillPath, ownerRecord);
writtenCount++; writtenCount++;
if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) {
this.exportDerivationRecords.push({
exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'),
sourcePath: exportIdentity.exportIdDerivationTaskSourcePath || EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH,
canonicalId: exportIdentity.canonicalId,
visibleId: skillName,
visibleSurfaceClass: 'export-id',
authoritySourceType: 'sidecar',
authoritySourcePath: exportIdentity.exportIdDerivationSourcePath,
exportIdDerivationSourceType: exportIdentity.exportIdDerivationSourceType,
exportIdDerivationSourcePath: exportIdentity.exportIdDerivationSourcePath,
issuingComponent: 'bmad-fork/tools/cli/installers/lib/ide/codex.js',
issuingComponentBindingEvidence: exportIdentity.exportIdDerivationEvidence || '',
generatedSkillPath: skillPath.replaceAll('\\', '/'),
});
}
} }
return writtenCount; return writtenCount;
@ -437,4 +801,9 @@ class CodexSetup extends BaseIdeSetup {
} }
} }
module.exports = { CodexSetup }; module.exports = {
CodexSetup,
CODEX_EXPORT_DERIVATION_ERROR_CODES,
CodexExportDerivationError,
EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE,
};

View File

@ -6,6 +6,11 @@ const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils');
const fs = require('fs-extra'); const fs = require('fs-extra');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const yaml = require('yaml'); const yaml = require('yaml');
const {
ProjectionCompatibilityError,
validateHelpCatalogLoaderEntries,
validateGithubCopilotHelpLoaderEntries,
} = require('../core/projection-compatibility-validator');
/** /**
* GitHub Copilot setup handler * GitHub Copilot setup handler
@ -131,12 +136,20 @@ class GitHubCopilotSetup extends BaseIdeSetup {
try { try {
const csvContent = await fs.readFile(helpPath, 'utf8'); const csvContent = await fs.readFile(helpPath, 'utf8');
return csv.parse(csvContent, { const rows = csv.parse(csvContent, {
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
}); });
} catch { const sourcePath = `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`;
validateHelpCatalogLoaderEntries(rows, { sourcePath });
validateGithubCopilotHelpLoaderEntries(rows, { sourcePath });
return rows;
} catch (error) {
// Gracefully degrade if help CSV is unreadable/malformed // Gracefully degrade if help CSV is unreadable/malformed
// but fail-fast on deterministic compatibility contract violations.
if (error instanceof ProjectionCompatibilityError) {
throw error;
}
return null; return null;
} }
} }

View File

@ -2,6 +2,7 @@ const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils'); const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils');
const { validateTaskManifestLoaderEntries } = require('../../core/projection-compatibility-validator');
/** /**
* Generates command files for standalone tasks and tools * Generates command files for standalone tasks and tools
@ -197,10 +198,14 @@ Follow all instructions in the ${type} file exactly as written.
} }
const csvContent = await fs.readFile(manifestPath, 'utf8'); const csvContent = await fs.readFile(manifestPath, 'utf8');
return csv.parse(csvContent, { const records = csv.parse(csvContent, {
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
}); });
validateTaskManifestLoaderEntries(records, {
sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/task-manifest.csv`,
});
return records;
} }
/** /**