feat(installer): add canonical help projections and wave-1 validation harness

This commit is contained in:
Dicky Moore 2026-03-02 23:06:33 +00:00
parent 44972d62b9
commit 51a73e28bd
13 changed files with 7871 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: []

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,372 @@
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 HELP_AUTHORITY_VALIDATION_ERROR_CODES = Object.freeze({
SIDECAR_FILE_NOT_FOUND: 'ERR_HELP_AUTHORITY_SIDECAR_FILE_NOT_FOUND',
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 sidecarPath = options.sidecarPath || getSourcePath('core', 'tasks', 'help.artifact.yaml');
const sourceMarkdownPath = options.sourceMarkdownPath || getSourcePath('core', 'tasks', 'help.md');
const runtimeMarkdownPath = options.runtimeMarkdownPath || '';
const sidecarSourcePath = normalizeSourcePath(options.sidecarSourcePath || toProjectRelativePath(sidecarPath));
const sourceMarkdownSourcePath = normalizeSourcePath(options.sourceMarkdownSourcePath || toProjectRelativePath(sourceMarkdownPath));
const runtimeMarkdownSourcePath = normalizeSourcePath(
options.runtimeMarkdownSourcePath || (runtimeMarkdownPath ? toProjectRelativePath(runtimeMarkdownPath) : ''),
);
if (!(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,
};
}
module.exports = {
HELP_AUTHORITY_VALIDATION_ERROR_CODES,
HELP_FRONTMATTER_MISMATCH_ERROR_CODES,
HelpAuthorityValidationError,
buildHelpAuthorityRecords,
serializeNormalizedDependencyTargets,
validateHelpAuthoritySplitAndPrecedence,
};

View File

@ -0,0 +1,367 @@
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 EXEMPLAR_HELP_CATALOG_CANONICAL_ID = 'bmad-help';
const EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.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_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 = getSourcePath('core', 'tasks', 'help.artifact.yaml')) {
const sourcePath = normalizeSourcePath(toProjectRelativePath(sidecarPath));
if (!(await fs.pathExists(sidecarPath))) {
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(sidecarPath, '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,
};
}
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 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) !== 'sidecar') {
return { valid: false, reason: `invalid-authority-source-type:${frontmatterMatchValue(row.authoritySourceType) || '<empty>'}` };
}
if (frontmatterMatchValue(row.authoritySourcePath) !== EXEMPLAR_HELP_CATALOG_AUTHORITY_SOURCE_PATH) {
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,
};

View File

@ -9,6 +9,17 @@ const { Config } = require('../../../lib/config');
const { XmlHandler } = require('../../../lib/xml-handler');
const { DependencyResolver } = require('./dependency-resolver');
const { ConfigCollector } = require('./config-collector');
const { validateHelpSidecarContractFile } = require('./sidecar-contract-validator');
const { validateHelpAuthoritySplitAndPrecedence } = require('./help-authority-validator');
const {
HELP_CATALOG_GENERATION_ERROR_CODES,
buildSidecarAwareExemplarHelpRow,
evaluateExemplarCommandLabelReportRows,
normalizeDisplayedCommandLabel,
renderDisplayedCommandLabel,
} = require('./help-catalog-generator');
const { validateHelpCatalogCompatibilitySurface } = require('./projection-compatibility-validator');
const { Wave1ValidationHarness } = require('./wave-1-validation-harness');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { CLIUtils } = require('../../../lib/cli-utils');
const { ManifestGenerator } = require('./manifest-generator');
@ -17,6 +28,9 @@ const { CustomHandler } = require('../custom/handler');
const prompts = require('../../../lib/prompts');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
const EXEMPLAR_HELP_SOURCE_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
class Installer {
constructor() {
this.detector = new Detector();
@ -29,8 +43,104 @@ class Installer {
this.dependencyResolver = new DependencyResolver();
this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager();
this.validateHelpSidecarContractFile = validateHelpSidecarContractFile;
this.validateHelpAuthoritySplitAndPrecedence = validateHelpAuthoritySplitAndPrecedence;
this.ManifestGenerator = ManifestGenerator;
this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME;
this.helpCatalogPipelineRows = [];
this.helpCatalogCommandLabelReportRows = [];
this.codexExportDerivationRecords = [];
this.latestWave1ValidationRun = null;
this.wave1ValidationHarness = new Wave1ValidationHarness();
}
async runConfigurationGenerationTask({ message, bmadDir, moduleConfigs, config, allModules, addResult }) {
// Validate exemplar sidecar contract before generating projections/manifests.
// Fail-fast here prevents downstream artifacts from being produced on invalid metadata.
message('Validating exemplar sidecar contract...');
await this.validateHelpSidecarContractFile();
addResult('Sidecar contract', 'ok', 'validated');
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 || [],
});
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 buildWave1ValidationOptions({ 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,
};
}
/**
@ -1098,54 +1208,15 @@ class Installer {
// Configuration generation task (stored as named reference for deferred execution)
const configTask = {
title: 'Generating configurations',
task: async (message) => {
// 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'));
// 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';
},
task: async (message) =>
this.runConfigurationGenerationTask({
message,
bmadDir,
moduleConfigs,
config,
allModules,
addResult,
}),
};
installTasks.push(configTask);
@ -1173,6 +1244,7 @@ class Installer {
// Resolution is now available via closure-scoped taskResolution
const resolution = taskResolution;
this.codexExportDerivationRecords = [];
// ─────────────────────────────────────────────────────────────────────────
// IDE SETUP: Keep as spinner since it may prompt for user input
@ -1217,6 +1289,9 @@ class Installer {
}
if (setupResult.success) {
if (Array.isArray(setupResult.exportDerivationRecords) && setupResult.exportDerivationRecords.length > 0) {
this.codexExportDerivationRecords = [...setupResult.exportDerivationRecords];
}
addResult(ide, 'ok', setupResult.detail || '');
} else {
addResult(ide, 'error', setupResult.error || 'failed');
@ -1242,6 +1317,21 @@ class Installer {
// ─────────────────────────────────────────────────────────────────────────
const postIdeTasks = [];
postIdeTasks.push({
title: 'Generating validation artifacts',
task: async (message) => {
message('Generating deterministic wave-1 validation artifact suite...');
const validationOptions = await this.buildWave1ValidationOptions({
projectDir,
bmadDir,
});
const validationRun = await this.wave1ValidationHarness.generateAndValidate(validationOptions);
this.latestWave1ValidationRun = validationRun;
addResult('Validation artifacts', 'ok', `${validationRun.generatedArtifactCount} artifacts`);
return `${validationRun.generatedArtifactCount} validation artifacts generated`;
},
});
// File restoration task (only for updates)
if (
config._isUpdate &&
@ -1690,6 +1780,94 @@ class Installer {
/**
* Private: Create directory structure
*/
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 || '',
];
}
isExemplarCommandLabelCandidate({ workflowFile, name, rawCommandValue, canonicalId, legacyName }) {
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 isHelpWorkflow = normalizedWorkflowFile.endsWith('/core/tasks/help.md');
const isExemplarIdentity =
normalizedName === 'bmad-help' ||
normalizedCommandValue === normalizedCanonicalId ||
(normalizedLegacyName.length > 0 && normalizedCommandValue === normalizedLegacyName);
return isHelpWorkflow && isExemplarIdentity;
}
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
* Scans all installed modules for module-help.csv and merges them
@ -1701,6 +1879,14 @@ class Installer {
const allRows = [];
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';
this.helpCatalogPipelineRows = [];
this.helpCatalogCommandLabelReportRows = [];
const sidecarAwareExemplar = await buildSidecarAwareExemplarHelpRow({
helpAuthorityRecords: this.helpAuthorityRecords || [],
bmadFolderName: this.bmadFolderName || BMAD_FOLDER_NAME,
});
let exemplarRowWritten = false;
// Load agent manifest for agent info lookup
const agentManifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv');
@ -1795,29 +1981,62 @@ class Installer {
// 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 || '';
// Lookup agent info
const cleanAgentName = agentName ? agentName.trim() : '';
const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' };
const isExemplarRow = this.isExemplarHelpCatalogRow({
moduleName,
name,
workflowFile,
command,
canonicalId: sidecarAwareExemplar.canonicalId,
});
// Build new row with agent info
const newRow = [
finalModule,
phase || '',
name || '',
code || '',
sequence || '',
workflowFile || '',
command || '',
required || 'false',
cleanAgentName,
agentData.command,
agentData.displayName,
agentData.title,
options || '',
description || '',
outputLocation || '',
outputs || '',
];
const fallbackRow = {
module: finalModule,
phase: phase || '',
name: name || '',
code: code || '',
sequence: sequence || '',
workflowFile: workflowFile || '',
command: command || '',
required: required || 'false',
agentName: agentName || '',
options: options || '',
description: description || '',
outputLocation: outputLocation || '',
outputs: outputs || '',
};
let newRow;
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(','));
}
@ -1832,6 +2051,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
allRows.sort((a, b) => {
const colsA = this.parseCSVLine(a);
@ -1857,17 +2100,136 @@ class Installer {
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;
}
if (
!this.isExemplarCommandLabelCandidate({
workflowFile,
name,
rawCommandValue,
canonicalId: sidecarAwareExemplar.canonicalId,
legacyName: sidecarAwareExemplar.legacyName,
})
) {
continue;
}
const displayedCommandLabel = renderDisplayedCommandLabel(rawCommandValue);
commandLabelRowsFromMergedCatalog.push({
surface: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
canonicalId: sidecarAwareExemplar.canonicalId,
rawCommandValue,
displayedCommandLabel,
normalizedDisplayedLabel: normalizeDisplayedCommandLabel(displayedCommandLabel),
authoritySourceType: sidecarAwareExemplar.authoritySourceType,
authoritySourcePath: sidecarAwareExemplar.authoritySourcePath,
});
}
const exemplarRowCount = commandLabelRowsFromMergedCatalog.length;
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: exemplarRowCount,
status: exemplarRowCount === 1 ? 'PASS' : 'FAIL',
}));
const commandLabelContractResult = evaluateExemplarCommandLabelReportRows(this.helpCatalogCommandLabelReportRows, {
canonicalId: sidecarAwareExemplar.canonicalId,
displayedCommandLabel: sidecarAwareExemplar.displayedCommandLabel,
});
if (!commandLabelContractResult.valid) {
this.helpCatalogPipelineRows = this.helpCatalogPipelineRows.map((row) => ({
...row,
stageStatus: 'FAIL',
status: 'FAIL',
}));
this.helpCatalogCommandLabelReportRows = this.helpCatalogCommandLabelReportRows.map((row) => ({
...row,
status: 'FAIL',
failureReason: commandLabelContractResult.reason,
}));
const commandLabelError = new Error(
`${HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED}: ${commandLabelContractResult.reason}`,
);
commandLabelError.code = HELP_CATALOG_GENERATION_ERROR_CODES.COMMAND_LABEL_CONTRACT_FAILED;
commandLabelError.detail = commandLabelContractResult.reason;
throw commandLabelError;
}
// Write merged catalog
const outputDir = path.join(bmadDir, '_config');
await fs.ensureDir(outputDir);
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');
validateHelpCatalogCompatibilitySurface(mergedContent, {
sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/bmad-help.csv`,
});
await fs.writeFile(outputPath, mergedContent, 'utf8');
// Track the installed file
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') {
await prompts.log.message(` Generated bmad-help.csv: ${allRows.length} workflows`);
}

View File

@ -5,9 +5,56 @@ const crypto = require('node:crypto');
const csv = require('csv-parse/sync');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
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
const packageJson = require('../../../../../package.json');
const DEFAULT_EXEMPLAR_HELP_SIDECAR_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.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',
}),
]);
/**
* Generates manifest files for installed workflows, agents, and tasks
@ -34,6 +81,65 @@ class ManifestGenerator {
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
* @param {string} bmadDir - _bmad
@ -75,6 +181,8 @@ class ManifestGenerator {
throw new TypeError('ManifestGenerator expected `options.ides` to be an array.');
}
this.helpAuthorityRecords = await this.normalizeHelpAuthorityRecords(options.helpAuthorityRecords);
// Filter out any undefined/null values from IDE list
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
@ -96,6 +204,7 @@ class ManifestGenerator {
await this.writeWorkflowManifest(cfgDir),
await this.writeAgentManifest(cfgDir),
await this.writeTaskManifest(cfgDir),
await this.writeCanonicalAliasManifest(cfgDir),
await this.writeToolManifest(cfgDir),
await this.writeFilesManifest(cfgDir),
];
@ -630,6 +739,12 @@ class ManifestGenerator {
ides: this.selectedIdes,
};
if (this.helpAuthorityRecords.length > 0) {
manifest.helpAuthority = {
records: this.helpAuthorityRecords,
};
}
// Clean the manifest to remove any non-serializable values
const cleanManifest = structuredClone(manifest);
@ -842,22 +957,51 @@ class ManifestGenerator {
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const compatibilitySurfacePath = `${this.bmadFolderName || '_bmad'}/_config/task-manifest.csv`;
const sidecarAuthorityRecord = Array.isArray(this.helpAuthorityRecords)
? this.helpAuthorityRecords.find(
(record) => record?.canonicalId === 'bmad-help' && record?.authoritySourceType === 'sidecar' && record?.authoritySourcePath,
)
: null;
const exemplarAuthoritySourceType = sidecarAuthorityRecord ? sidecarAuthorityRecord.authoritySourceType : 'sidecar';
const exemplarAuthoritySourcePath = sidecarAuthorityRecord
? sidecarAuthorityRecord.authoritySourcePath
: 'bmad-fork/src/core/tasks/help.artifact.yaml';
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
validateTaskManifestCompatibilitySurface(content, {
sourcePath: compatibilitySurfacePath,
allowLegacyPrefixOnly: true,
});
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
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
let csvContent = 'name,displayName,description,module,path,standalone\n';
// Create CSV header with compatibility-prefix columns followed by additive wave-1 columns.
let csvContent = 'name,displayName,description,module,path,standalone,legacyName,canonicalId,authoritySourceType,authoritySourcePath\n';
// Combine existing and new tasks
const allTasks = new Map();
@ -870,6 +1014,9 @@ class ManifestGenerator {
// Add/update new tasks
for (const task of this.tasks) {
const key = `${task.module}:${task.name}`;
const previousRecord = allTasks.get(key);
const isExemplarHelpTask = task.module === 'core' && task.name === 'help';
allTasks.set(key, {
name: task.name,
displayName: task.displayName,
@ -877,11 +1024,17 @@ class ManifestGenerator {
module: task.module,
path: task.path,
standalone: task.standalone,
legacyName: isExemplarHelpTask ? 'help' : previousRecord?.legacyName || task.name,
canonicalId: isExemplarHelpTask ? 'bmad-help' : previousRecord?.canonicalId || '',
authoritySourceType: isExemplarHelpTask ? exemplarAuthoritySourceType : previousRecord?.authoritySourceType || '',
authoritySourcePath: isExemplarHelpTask ? exemplarAuthoritySourcePath : previousRecord?.authoritySourcePath || '',
});
}
// Write all tasks
for (const [, record] of allTasks) {
// Write all tasks in deterministic order.
const sortedTaskKeys = [...allTasks.keys()].sort((left, right) => left.localeCompare(right));
for (const taskKey of sortedTaskKeys) {
const record = allTasks.get(taskKey);
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
@ -889,14 +1042,89 @@ class ManifestGenerator {
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.standalone),
escapeCsv(record.legacyName || record.name),
escapeCsv(record.canonicalId || ''),
escapeCsv(record.authoritySourceType || ''),
escapeCsv(record.authoritySourcePath || ''),
].join(',');
csvContent += row + '\n';
}
validateTaskManifestCompatibilitySurface(csvContent, {
sourcePath: compatibilitySurfacePath,
});
await fs.writeFile(csvPath, csvContent);
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,
};
}
buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath) {
return LOCKED_CANONICAL_ALIAS_TABLE_EXEMPLAR_ROWS.map((row) => ({
canonicalId: row.canonicalId,
alias: row.alias,
aliasType: row.aliasType,
authoritySourceType,
authoritySourcePath,
rowIdentity: row.rowIdentity,
normalizedAliasValue: row.normalizedAliasValue,
rawIdentityHasLeadingSlash: row.rawIdentityHasLeadingSlash,
resolutionEligibility: row.resolutionEligibility,
}));
}
/**
* 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 { authoritySourceType, authoritySourcePath } = this.resolveExemplarAliasAuthorityRecord();
const projectedRows = this.buildCanonicalAliasProjectionRows(authoritySourceType, authoritySourcePath);
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
* @returns {string} Path to the manifest file

View File

@ -0,0 +1,407 @@
const csv = require('csv-parse/sync');
const TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS = Object.freeze(['name', 'displayName', 'description', 'module', 'path', 'standalone']);
const TASK_MANIFEST_WAVE1_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_WAVE1_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_WAVE1_MISMATCH: 'ERR_TASK_MANIFEST_COMPAT_HEADER_WAVE1_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_WAVE1_MISMATCH: 'ERR_HELP_CATALOG_COMPAT_HEADER_WAVE1_MISMATCH',
HELP_CATALOG_REQUIRED_COLUMN_MISSING: 'ERR_HELP_CATALOG_COMPAT_REQUIRED_COLUMN_MISSING',
HELP_CATALOG_EXEMPLAR_ROW_CONTRACT_FAILED: 'ERR_HELP_CATALOG_COMPAT_EXEMPLAR_ROW_CONTRACT_FAILED',
GITHUB_COPILOT_WORKFLOW_FILE_MISSING: 'ERR_GITHUB_COPILOT_HELP_WORKFLOW_FILE_MISSING',
});
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 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',
});
}
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_WAVE1_ADDITIVE_COLUMNS,
offset: TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS.length,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.TASK_MANIFEST_HEADER_WAVE1_MISMATCH,
detail: 'Task-manifest wave-1 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_WAVE1_ADDITIVE_COLUMNS,
offset: HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS.length,
code: PROJECTION_COMPATIBILITY_ERROR_CODES.HELP_CATALOG_HEADER_WAVE1_MISMATCH,
detail: 'Help-catalog wave-1 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 };
}
module.exports = {
PROJECTION_COMPATIBILITY_ERROR_CODES,
ProjectionCompatibilityError,
TASK_MANIFEST_COMPATIBILITY_PREFIX_COLUMNS,
TASK_MANIFEST_WAVE1_ADDITIVE_COLUMNS,
HELP_CATALOG_COMPATIBILITY_PREFIX_COLUMNS,
HELP_CATALOG_WAVE1_ADDITIVE_COLUMNS,
validateTaskManifestCompatibilitySurface,
validateTaskManifestLoaderEntries,
validateHelpCatalogCompatibilitySurface,
validateHelpCatalogLoaderEntries,
validateGithubCopilotHelpLoaderEntries,
};

View File

@ -0,0 +1,262 @@
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 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',
});
const HELP_EXEMPLAR_CANONICAL_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
const HELP_EXEMPLAR_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) {
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 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 getExpectedSidecarBasenameFromSourcePath(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 validateHelpSidecarContractData(sidecarData, options = {}) {
const sourcePath = normalizeSourcePath(options.errorSourcePath || 'src/core/tasks/help.artifact.yaml');
if (!sidecarData || typeof sidecarData !== 'object' || Array.isArray(sidecarData)) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.INVALID_ROOT_OBJECT,
'<document>',
sourcePath,
'Sidecar root must be a YAML mapping object.',
);
}
for (const field of HELP_SIDECAR_REQUIRED_FIELDS) {
if (!hasOwn(sidecarData, field)) {
if (field === 'dependencies') {
createValidationError(
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
field,
sourcePath,
'Exemplar sidecar requires an explicit dependencies block.',
);
}
createValidationError(
HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_MISSING,
field,
sourcePath,
`Missing required sidecar field "${field}".`,
);
}
}
const requiredNonEmptyStringFields = ['canonicalId', 'sourcePath', 'displayName', 'description'];
for (const field of requiredNonEmptyStringFields) {
if (isBlankString(sidecarData[field])) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.REQUIRED_FIELD_EMPTY,
field,
sourcePath,
`Required sidecar field "${field}" must be a non-empty string.`,
);
}
}
const schemaMajorVersion = parseSchemaMajorVersion(sidecarData.schemaVersion);
if (schemaMajorVersion !== HELP_EXEMPLAR_SUPPORTED_SCHEMA_MAJOR) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.MAJOR_VERSION_UNSUPPORTED,
'schemaVersion',
sourcePath,
'sidecar schema major version is unsupported',
);
}
if (sidecarData.artifactType !== 'task') {
createValidationError(
HELP_SIDECAR_ERROR_CODES.ARTIFACT_TYPE_INVALID,
'artifactType',
sourcePath,
'Wave-1 exemplar requires artifactType to equal "task".',
);
}
if (sidecarData.module !== 'core') {
createValidationError(
HELP_SIDECAR_ERROR_CODES.MODULE_INVALID,
'module',
sourcePath,
'Wave-1 exemplar requires module to equal "core".',
);
}
const dependencies = sidecarData.dependencies;
if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_MISSING,
'dependencies',
sourcePath,
'Exemplar sidecar requires an explicit dependencies object.',
);
}
if (!hasOwn(dependencies, 'requires') || !Array.isArray(dependencies.requires)) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_INVALID,
'dependencies.requires',
sourcePath,
'Exemplar dependencies.requires must be an array.',
);
}
if (dependencies.requires.length > 0) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.DEPENDENCIES_REQUIRES_NOT_EMPTY,
'dependencies.requires',
sourcePath,
'Wave-1 exemplar requires explicit zero dependencies: dependencies.requires must be [].',
);
}
const normalizedDeclaredSourcePath = normalizeSourcePath(sidecarData.sourcePath);
const sidecarBasename = path.posix.basename(sourcePath);
const expectedSidecarBasename = getExpectedSidecarBasenameFromSourcePath(normalizedDeclaredSourcePath);
const sourcePathMismatch = normalizedDeclaredSourcePath !== HELP_EXEMPLAR_CANONICAL_SOURCE_PATH;
const basenameMismatch = !expectedSidecarBasename || sidecarBasename !== expectedSidecarBasename;
if (sourcePathMismatch || basenameMismatch) {
createValidationError(
HELP_SIDECAR_ERROR_CODES.SOURCEPATH_BASENAME_MISMATCH,
'sourcePath',
sourcePath,
'sidecar basename does not match sourcePath basename',
);
}
}
async function validateHelpSidecarContractFile(sidecarPath = getSourcePath('core', 'tasks', 'help.artifact.yaml'), options = {}) {
const normalizedSourcePath = normalizeSourcePath(options.errorSourcePath || toProjectRelativePath(sidecarPath));
if (!(await fs.pathExists(sidecarPath))) {
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(sidecarPath, '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 });
}
module.exports = {
HELP_SIDECAR_REQUIRED_FIELDS,
HELP_SIDECAR_ERROR_CODES,
SidecarContractError,
validateHelpSidecarContractData,
validateHelpSidecarContractFile,
};

File diff suppressed because it is too large Load Diff

View File

@ -8,14 +8,51 @@ const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
const { getTasksFromBmad } = require('./shared/bmad-artifacts');
const { toDashPath, customAgentDashName } = require('./shared/path-utils');
const { normalizeAndResolveExemplarAlias } = require('../core/help-alias-normalizer');
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_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',
});
const EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.md';
const EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH = 'bmad-fork/src/core/tasks/help.artifact.yaml';
const EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE = 'sidecar-canonical-id';
const EXEMPLAR_SIDECAR_SOURCE_CANDIDATES = Object.freeze([
Object.freeze({
segments: ['bmad-fork', 'src', 'core', 'tasks', 'help.artifact.yaml'],
}),
Object.freeze({
segments: ['src', 'core', 'tasks', 'help.artifact.yaml'],
}),
]);
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)
*/
class CodexSetup extends BaseIdeSetup {
constructor() {
super('codex', 'Codex', false);
this.exportDerivationRecords = [];
}
/**
@ -31,6 +68,7 @@ class CodexSetup extends BaseIdeSetup {
const mode = 'cli';
const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options);
this.exportDerivationRecords = [];
// Clean up old .codex/prompts locations (both global and project)
const oldGlobalDir = this.getOldCodexPromptDir(null, 'global');
@ -46,7 +84,7 @@ class CodexSetup extends BaseIdeSetup {
// Collect and write agent skills
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
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
const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []);
@ -77,12 +115,12 @@ class CodexSetup extends BaseIdeSetup {
...artifact,
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
const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName);
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;
@ -99,6 +137,7 @@ class CodexSetup extends BaseIdeSetup {
counts,
destination: destDir,
written,
exportDerivationRecords: [...this.exportDerivationRecords],
};
}
@ -207,7 +246,148 @@ class CodexSetup extends BaseIdeSetup {
* @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task')
* @returns {number} Number of skills written
*/
async writeSkillArtifacts(destDir, artifacts, artifactType) {
isExemplarHelpTaskArtifact(artifact = {}) {
if (artifact.type !== 'task' || artifact.module !== 'core') {
return false;
}
const normalizedName = String(artifact.name || '')
.trim()
.toLowerCase();
const normalizedRelativePath = String(artifact.relativePath || '')
.trim()
.replaceAll('\\', '/')
.toLowerCase();
const normalizedSourcePath = String(artifact.sourcePath || '')
.trim()
.replaceAll('\\', '/')
.toLowerCase();
if (normalizedName !== 'help') {
return false;
}
return normalizedRelativePath.endsWith('/core/tasks/help.md') || normalizedSourcePath.endsWith('/core/tasks/help.md');
}
throwExportDerivationError({ code, detail, fieldPath, sourcePath, observedValue, cause = null }) {
throw new CodexExportDerivationError({
code,
detail,
fieldPath,
sourcePath,
observedValue,
cause,
});
}
async loadExemplarHelpSidecar(projectDir) {
for (const candidate of EXEMPLAR_SIDECAR_SOURCE_CANDIDATES) {
const sidecarPath = path.join(projectDir, ...candidate.segments);
if (await fs.pathExists(sidecarPath)) {
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: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
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: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
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: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
observedValue: canonicalId,
});
}
return {
canonicalId,
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
};
}
}
this.throwExportDerivationError({
code: CODEX_EXPORT_DERIVATION_ERROR_CODES.SIDECAR_FILE_NOT_FOUND,
detail: 'expected exemplar sidecar metadata file was not found',
fieldPath: '<file>',
sourcePath: EXEMPLAR_HELP_SIDECAR_CONTRACT_SOURCE_PATH,
observedValue: projectDir,
});
}
async resolveSkillIdentityFromArtifact(artifact, projectDir) {
const inferredSkillName = toDashPath(artifact.relativePath).replace(/\.md$/, '');
const isExemplarHelpTask = this.isExemplarHelpTaskArtifact(artifact);
if (!isExemplarHelpTask) {
return {
skillName: inferredSkillName,
canonicalId: inferredSkillName,
exportIdDerivationSourceType: 'path-derived',
exportIdDerivationSourcePath: String(artifact.relativePath || ''),
};
}
const sidecarData = await this.loadExemplarHelpSidecar(projectDir);
let canonicalResolution;
try {
canonicalResolution = await normalizeAndResolveExemplarAlias(sidecarData.canonicalId, {
fieldPath: 'canonicalId',
sourcePath: sidecarData.sourcePath,
});
} 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,
exportIdDerivationEvidence: `applied:${canonicalResolution.preAliasNormalizedValue}|leadingSlash:${canonicalResolution.rawIdentityHasLeadingSlash}->${canonicalResolution.postAliasCanonicalId}|rows:${canonicalResolution.aliasRowLocator}`,
};
}
async writeSkillArtifacts(destDir, artifacts, artifactType, options = {}) {
let writtenCount = 0;
for (const artifact of artifacts) {
@ -217,8 +397,8 @@ class CodexSetup extends BaseIdeSetup {
}
// Get the dash-format name (e.g., bmad-bmm-create-prd.md) and remove .md
const flatName = toDashPath(artifact.relativePath);
const skillName = flatName.replace(/\.md$/, '');
const exportIdentity = await this.resolveSkillIdentityFromArtifact(artifact, options.projectDir || process.cwd());
const skillName = exportIdentity.skillName;
// Create skill directory
const skillDir = path.join(destDir, skillName);
@ -229,8 +409,26 @@ class CodexSetup extends BaseIdeSetup {
// Write SKILL.md with platform-native line endings
const platformContent = skillContent.replaceAll('\n', os.EOL);
await fs.writeFile(path.join(skillDir, 'SKILL.md'), platformContent, 'utf8');
const skillPath = path.join(skillDir, 'SKILL.md');
await fs.writeFile(skillPath, platformContent, 'utf8');
writtenCount++;
if (exportIdentity.exportIdDerivationSourceType === EXEMPLAR_HELP_EXPORT_DERIVATION_SOURCE_TYPE) {
this.exportDerivationRecords.push({
exportPath: path.join('.agents', 'skills', skillName, 'SKILL.md').replaceAll('\\', '/'),
sourcePath: EXEMPLAR_HELP_TASK_MARKDOWN_SOURCE_PATH,
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;
@ -437,4 +635,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 csv = require('csv-parse/sync');
const yaml = require('yaml');
const {
ProjectionCompatibilityError,
validateHelpCatalogLoaderEntries,
validateGithubCopilotHelpLoaderEntries,
} = require('../core/projection-compatibility-validator');
/**
* GitHub Copilot setup handler
@ -131,12 +136,20 @@ class GitHubCopilotSetup extends BaseIdeSetup {
try {
const csvContent = await fs.readFile(helpPath, 'utf8');
return csv.parse(csvContent, {
const rows = csv.parse(csvContent, {
columns: 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
// but fail-fast on deterministic compatibility contract violations.
if (error instanceof ProjectionCompatibilityError) {
throw error;
}
return null;
}
}

View File

@ -2,6 +2,7 @@ const path = require('node:path');
const fs = require('fs-extra');
const csv = require('csv-parse/sync');
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
@ -197,10 +198,14 @@ Follow all instructions in the ${type} file exactly as written.
}
const csvContent = await fs.readFile(manifestPath, 'utf8');
return csv.parse(csvContent, {
const records = csv.parse(csvContent, {
columns: true,
skip_empty_lines: true,
});
validateTaskManifestLoaderEntries(records, {
sourcePath: `${this.bmadFolderName || BMAD_FOLDER_NAME}/_config/task-manifest.csv`,
});
return records;
}
/**