convoke-agents 3.0.4 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +14 -13
- package/_bmad/bme/_artifacts/config.yaml +15 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
- package/_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md +506 -0
- package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
- package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
- package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
- package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
- package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
- package/_bmad/bme/_team-factory/config.yaml +13 -0
- package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
- package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
- package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
- package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
- package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
- package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
- package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
- package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
- package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
- package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
- package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
- package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
- package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
- package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
- package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
- package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
- package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
- package/_bmad/bme/_team-factory/module-help.csv +3 -0
- package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
- package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
- package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
- package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
- package/_bmad/bme/_vortex/config.yaml +4 -4
- package/_bmad/bme/_vortex/guides/VORTEX-TEAM-GUIDE.md +441 -0
- package/package.json +17 -8
- package/scripts/archive.js +26 -45
- package/scripts/convoke-check.js +88 -0
- package/scripts/convoke-doctor.js +303 -4
- package/scripts/install-gyre-agents.js +0 -0
- package/scripts/lib/artifact-utils.js +2182 -0
- package/scripts/lib/portfolio/formatters/markdown-formatter.js +40 -0
- package/scripts/lib/portfolio/formatters/terminal-formatter.js +56 -0
- package/scripts/lib/portfolio/portfolio-engine.js +572 -0
- package/scripts/lib/portfolio/rules/artifact-chain-rule.js +156 -0
- package/scripts/lib/portfolio/rules/conflict-resolver.js +99 -0
- package/scripts/lib/portfolio/rules/frontmatter-rule.js +42 -0
- package/scripts/lib/portfolio/rules/git-recency-rule.js +69 -0
- package/scripts/lib/types.js +122 -0
- package/scripts/migrate-artifacts.js +439 -0
- package/scripts/portability/catalog-generator.js +353 -0
- package/scripts/portability/classify-skills.js +646 -0
- package/scripts/portability/convoke-export.js +522 -0
- package/scripts/portability/export-engine.js +1133 -0
- package/scripts/portability/generate-adapters.js +79 -0
- package/scripts/portability/manifest-csv.js +147 -0
- package/scripts/portability/seed-catalog-repo.js +427 -0
- package/scripts/portability/templates/canonical-example.md +102 -0
- package/scripts/portability/templates/canonical-format.md +218 -0
- package/scripts/portability/templates/readme-template.md +72 -0
- package/scripts/portability/test-constants.js +42 -0
- package/scripts/portability/validate-classification.js +529 -0
- package/scripts/portability/validate-exports.js +348 -0
- package/scripts/update/lib/agent-registry.js +35 -0
- package/scripts/update/lib/config-merger.js +140 -10
- package/scripts/update/lib/migration-runner.js +1 -1
- package/scripts/update/lib/refresh-installation.js +293 -8
- package/scripts/update/lib/taxonomy-merger.js +138 -0
- package/scripts/update/lib/utils.js +27 -1
- package/scripts/update/lib/validator.js +114 -4
- package/scripts/update/migrations/2.0.x-to-3.1.0.js +50 -0
- package/scripts/update/migrations/3.0.x-to-3.1.0.js +41 -0
- package/scripts/update/migrations/registry.js +14 -0
|
@@ -0,0 +1,2182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared artifact utilities for the Convoke governance system.
|
|
3
|
+
* Consumed by: migrate-artifacts.js, portfolio-engine.js, archive.js
|
|
4
|
+
*
|
|
5
|
+
* @module artifact-utils
|
|
6
|
+
* @see types.js for type definitions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs-extra');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const yaml = require('js-yaml');
|
|
12
|
+
const matter = require('gray-matter');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
// --- Constants (extracted from archive.js) ---
|
|
16
|
+
|
|
17
|
+
/** Valid artifact category prefixes from the ADR naming convention */
|
|
18
|
+
const VALID_CATEGORIES = [
|
|
19
|
+
'prd', 'epic', 'arch', 'adr', 'brief', 'report', 'spec', 'vision',
|
|
20
|
+
'hc', 'persona', 'experiment', 'learning', 'sprint', 'decision',
|
|
21
|
+
'research'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Regex for valid lowercase kebab-case filenames */
|
|
25
|
+
const NAMING_PATTERN = /^[a-z][a-z0-9-]*\.(?:md|yaml)$/;
|
|
26
|
+
|
|
27
|
+
/** Regex to extract date suffix from filenames */
|
|
28
|
+
const DATED_PATTERN = /^(.+)-(\d{4}-\d{2}-\d{2})\.(md|yaml)$/;
|
|
29
|
+
|
|
30
|
+
/** Regex to extract category prefix from filenames */
|
|
31
|
+
const CATEGORIZED_PATTERN = /^([a-z]+\d*)-(.+)\.(md|yaml)$/;
|
|
32
|
+
|
|
33
|
+
// --- Filename Parsing ---
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a category string is in the valid categories list.
|
|
37
|
+
* Handles numeric suffixes (e.g., 'hc2' → check 'hc').
|
|
38
|
+
* @param {string} cat - Category to validate
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function isValidCategory(cat) {
|
|
42
|
+
const base = cat.replace(/\d+$/, '');
|
|
43
|
+
return VALID_CATEGORIES.includes(base) || VALID_CATEGORIES.includes(cat);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a filename to extract naming convention components.
|
|
48
|
+
* Backward compatible — works with or without taxonomy parameter.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} filename - The filename to parse (e.g., 'prd-gyre.md')
|
|
51
|
+
* @param {import('./types').TaxonomyConfig} [taxonomy] - Optional taxonomy for extended initiative inference
|
|
52
|
+
* @returns {import('./types').ParsedFilename}
|
|
53
|
+
*/
|
|
54
|
+
function parseFilename(filename, _taxonomy) {
|
|
55
|
+
const lower = filename.toLowerCase();
|
|
56
|
+
const dated = lower.match(DATED_PATTERN);
|
|
57
|
+
const categorized = lower.match(CATEGORIZED_PATTERN);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
filename,
|
|
61
|
+
isDated: !!dated,
|
|
62
|
+
date: dated ? dated[2] : null,
|
|
63
|
+
baseName: dated ? dated[1] : lower.replace(/\.(md|yaml)$/, ''),
|
|
64
|
+
category: categorized ? categorized[1] : null,
|
|
65
|
+
hasValidCategory: categorized ? isValidCategory(categorized[1]) : false,
|
|
66
|
+
isUppercase: filename !== lower,
|
|
67
|
+
matchesConvention: !!(NAMING_PATTERN.test(filename) && categorized && isValidCategory(categorized[1]))
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert a filename to lowercase kebab-case.
|
|
73
|
+
* @param {string} filename
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function toLowerKebab(filename) {
|
|
77
|
+
return filename.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Directory Scanning ---
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Scan artifact directories and return file inventory.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
86
|
+
* @param {string[]} includeDirs - Directory names to scan (relative to _bmad-output/)
|
|
87
|
+
* @param {string[]} [excludeDirs=['_archive']] - Directory names to exclude from results
|
|
88
|
+
* @returns {Promise<Array<{filename: string, dir: string, fullPath: string}>>}
|
|
89
|
+
*/
|
|
90
|
+
async function scanArtifactDirs(projectRoot, includeDirs, excludeDirs = ['_archive']) {
|
|
91
|
+
const outputDir = path.join(projectRoot, '_bmad-output');
|
|
92
|
+
const results = [];
|
|
93
|
+
|
|
94
|
+
for (const dir of includeDirs) {
|
|
95
|
+
if (excludeDirs.includes(dir)) continue;
|
|
96
|
+
|
|
97
|
+
const fullDir = path.join(outputDir, dir);
|
|
98
|
+
if (!await fs.pathExists(fullDir)) continue;
|
|
99
|
+
|
|
100
|
+
const files = (await fs.readdir(fullDir)).sort();
|
|
101
|
+
for (const filename of files) {
|
|
102
|
+
if (filename.startsWith('.')) continue;
|
|
103
|
+
const fullPath = path.join(fullDir, filename);
|
|
104
|
+
const stat = await fs.stat(fullPath);
|
|
105
|
+
if (!stat.isFile()) continue;
|
|
106
|
+
|
|
107
|
+
results.push({ filename, dir, fullPath });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Taxonomy ---
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load and validate taxonomy configuration.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
120
|
+
* @returns {import('./types').TaxonomyConfig}
|
|
121
|
+
* @throws {Error} If file not found, malformed YAML, or invalid structure
|
|
122
|
+
*/
|
|
123
|
+
function readTaxonomy(projectRoot) {
|
|
124
|
+
const configPath = path.join(projectRoot, '_bmad', '_config', 'taxonomy.yaml');
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(configPath)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Taxonomy config not found at ${configPath}. ` +
|
|
129
|
+
'Run convoke-migrate-artifacts or convoke-update to create it.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let raw;
|
|
134
|
+
try {
|
|
135
|
+
raw = yaml.load(fs.readFileSync(configPath, 'utf8'));
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Invalid YAML in taxonomy config: ${err.message}. File: ${configPath}`,
|
|
139
|
+
{ cause: err }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Validate structure
|
|
144
|
+
if (!raw || typeof raw !== 'object') {
|
|
145
|
+
throw new Error(`Taxonomy config is empty or not an object. File: ${configPath}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!raw.initiatives || !Array.isArray(raw.initiatives.platform)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'Taxonomy config missing required field: initiatives.platform (must be an array). ' +
|
|
151
|
+
`File: ${configPath}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!Array.isArray(raw.artifact_types)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
'Taxonomy config missing required field: artifact_types (must be an array). ' +
|
|
158
|
+
`File: ${configPath}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Ensure optional fields have defaults
|
|
163
|
+
const config = {
|
|
164
|
+
initiatives: {
|
|
165
|
+
platform: raw.initiatives.platform || [],
|
|
166
|
+
user: raw.initiatives.user || []
|
|
167
|
+
},
|
|
168
|
+
artifact_types: raw.artifact_types || [],
|
|
169
|
+
aliases: raw.aliases || {}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Validate entry format: lowercase alphanumeric with optional dashes
|
|
173
|
+
const idPattern = /^[a-z][a-z0-9-]*$/;
|
|
174
|
+
const allIds = [...config.initiatives.platform, ...config.initiatives.user];
|
|
175
|
+
|
|
176
|
+
for (const id of allIds) {
|
|
177
|
+
if (!idPattern.test(id)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Invalid initiative ID "${id}": must be lowercase alphanumeric with optional dashes. ` +
|
|
180
|
+
`File: ${configPath}`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const type of config.artifact_types) {
|
|
186
|
+
if (!idPattern.test(type)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Invalid artifact type "${type}": must be lowercase alphanumeric with optional dashes. ` +
|
|
189
|
+
`File: ${configPath}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Check for duplicates between platform and user
|
|
195
|
+
const platformSet = new Set(config.initiatives.platform);
|
|
196
|
+
for (const userId of config.initiatives.user) {
|
|
197
|
+
if (platformSet.has(userId)) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Duplicate initiative ID "${userId}" found in both platform and user sections. ` +
|
|
200
|
+
`File: ${configPath}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return config;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Frontmatter ---
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse frontmatter from file content.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} fileContent - Raw file content string
|
|
214
|
+
* @returns {{data: Object, content: string}} Parsed frontmatter data and content below
|
|
215
|
+
*/
|
|
216
|
+
function parseFrontmatter(fileContent) {
|
|
217
|
+
if (typeof fileContent !== 'string') {
|
|
218
|
+
throw new Error('parseFrontmatter expects a string. Ensure files are read with utf8 encoding.');
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const parsed = matter(fileContent);
|
|
222
|
+
return { data: parsed.data, content: parsed.content };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
throw new Error(`Failed to parse frontmatter: ${err.message}`, { cause: err });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Inject frontmatter fields into file content.
|
|
230
|
+
* Adds new fields, NEVER overwrites existing fields.
|
|
231
|
+
* Returns conflicts when existing field values differ from proposed values.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} fileContent - Raw file content string
|
|
234
|
+
* @param {Object} newFields - Fields to inject (e.g., {initiative: 'helm', artifact_type: 'prd'})
|
|
235
|
+
* @returns {import('./types').InjectResult} Modified content + any detected conflicts
|
|
236
|
+
*/
|
|
237
|
+
function injectFrontmatter(fileContent, newFields) {
|
|
238
|
+
const parsed = matter(fileContent);
|
|
239
|
+
const conflicts = [];
|
|
240
|
+
|
|
241
|
+
// Detect conflicts: existing field has different value than proposed
|
|
242
|
+
for (const [key, value] of Object.entries(newFields)) {
|
|
243
|
+
if (parsed.data[key] !== undefined && parsed.data[key] !== value) {
|
|
244
|
+
conflicts.push({
|
|
245
|
+
field: key,
|
|
246
|
+
existingValue: parsed.data[key],
|
|
247
|
+
newValue: value
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Merge: new fields go first (for consistent ordering), existing fields override
|
|
253
|
+
// This means existing values are preserved — newFields only fill gaps
|
|
254
|
+
const merged = { ...newFields, ...parsed.data };
|
|
255
|
+
|
|
256
|
+
const content = matter.stringify(parsed.content, merged);
|
|
257
|
+
return { content, conflicts };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- Git Operations ---
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Verify the working tree is clean within scope directories.
|
|
264
|
+
* Checks both tracked changes (staged + unstaged) and untracked files in scope.
|
|
265
|
+
*
|
|
266
|
+
* @param {string[]} scopeDirs - Directory names to check (relative to _bmad-output/)
|
|
267
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
268
|
+
* @throws {Error} If working tree is dirty with details of dirty files
|
|
269
|
+
*/
|
|
270
|
+
function ensureCleanTree(scopeDirs, projectRoot) {
|
|
271
|
+
// Build scoped paths for git commands (forward slashes for git)
|
|
272
|
+
const scopePaths = scopeDirs.map(dir => `_bmad-output/${dir}`);
|
|
273
|
+
|
|
274
|
+
// Check tracked changes (staged and unstaged) — scoped to scopeDirs only
|
|
275
|
+
try {
|
|
276
|
+
execFileSync('git', ['diff', '--quiet', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' });
|
|
277
|
+
} catch {
|
|
278
|
+
let diff = '(unable to list files)';
|
|
279
|
+
try {
|
|
280
|
+
diff = execFileSync('git', ['diff', '--name-only', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
281
|
+
} catch { /* best-effort */ }
|
|
282
|
+
throw new Error(
|
|
283
|
+
'Working tree has uncommitted changes in scope directories. Commit or stash before running migration.\n' +
|
|
284
|
+
`Dirty files:\n${diff}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
execFileSync('git', ['diff', '--cached', '--quiet', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' });
|
|
290
|
+
} catch {
|
|
291
|
+
let staged = '(unable to list files)';
|
|
292
|
+
try {
|
|
293
|
+
staged = execFileSync('git', ['diff', '--cached', '--name-only', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
294
|
+
} catch { /* best-effort */ }
|
|
295
|
+
throw new Error(
|
|
296
|
+
'Working tree has staged changes in scope directories. Commit or stash before running migration.\n' +
|
|
297
|
+
`Staged files:\n${staged}`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Check untracked files within scope directories
|
|
302
|
+
for (const scopePath of scopePaths) {
|
|
303
|
+
const untracked = execFileSync(
|
|
304
|
+
'git', ['ls-files', '--others', '--exclude-standard', scopePath],
|
|
305
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
306
|
+
).trim();
|
|
307
|
+
|
|
308
|
+
if (untracked) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`Untracked files found in ${scopePath}. Add or remove them before running migration.\n` +
|
|
311
|
+
`Untracked:\n${untracked}`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- Inference Engine ---
|
|
318
|
+
|
|
319
|
+
/** HC prefix pattern: matches hcN- at start of basename (e.g., hc2-, hc3-) */
|
|
320
|
+
const HC_PREFIX_PATTERN = /^hc\d+-/;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Maps long-form artifact type names found in existing filenames to canonical taxonomy types.
|
|
324
|
+
* Migration-specific — these are OLD naming patterns that don't match the taxonomy abbreviations.
|
|
325
|
+
*/
|
|
326
|
+
const ARTIFACT_TYPE_ALIASES = {
|
|
327
|
+
'problem-definition': 'problem-def',
|
|
328
|
+
'pre-registration': 'pre-reg',
|
|
329
|
+
'architecture': 'arch',
|
|
330
|
+
'hypothesis-contract': 'hypothesis'
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Infer artifact type from a filename using greedy longest-prefix matching.
|
|
335
|
+
* Handles HC-prefixed files by stripping the HC prefix before matching.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} filename - The filename to analyze (e.g., 'prd-gyre.md')
|
|
338
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy with artifact_types list
|
|
339
|
+
* @returns {{type: string|null, hcPrefix: string|null, remainder: string}} Inferred type, HC prefix if any, and remaining segments
|
|
340
|
+
*/
|
|
341
|
+
function inferArtifactType(filename, taxonomy) {
|
|
342
|
+
if (!filename || typeof filename !== 'string') {
|
|
343
|
+
return { type: null, hcPrefix: null, remainder: '', date: null, typeConfidence: 'low', typeSource: 'none' };
|
|
344
|
+
}
|
|
345
|
+
const lower = filename.toLowerCase();
|
|
346
|
+
// Strip extension
|
|
347
|
+
const withoutExt = lower.replace(/\.(md|yaml)$/, '');
|
|
348
|
+
// Strip date suffix if present
|
|
349
|
+
const dateMatch = withoutExt.match(/-(\d{4}-\d{2}-\d{2})$/);
|
|
350
|
+
const date = dateMatch ? dateMatch[1] : null;
|
|
351
|
+
const baseName = date ? withoutExt.slice(0, -(date.length + 1)) : withoutExt;
|
|
352
|
+
|
|
353
|
+
// Check for HC prefix (hc2-, hc3-, etc.)
|
|
354
|
+
let hcPrefix = null;
|
|
355
|
+
let nameToMatch = baseName;
|
|
356
|
+
const hcMatch = baseName.match(HC_PREFIX_PATTERN);
|
|
357
|
+
if (hcMatch) {
|
|
358
|
+
hcPrefix = hcMatch[0].slice(0, -1); // e.g., 'hc2' (without trailing dash)
|
|
359
|
+
nameToMatch = baseName.slice(hcMatch[0].length);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Try artifact type aliases FIRST (longer, more specific — e.g., 'hypothesis-contract' before 'hypothesis')
|
|
363
|
+
const sortedAliasKeys = Object.keys(ARTIFACT_TYPE_ALIASES).sort((a, b) => b.length - a.length);
|
|
364
|
+
for (const aliasKey of sortedAliasKeys) {
|
|
365
|
+
if (nameToMatch.startsWith(aliasKey + '-') || nameToMatch === aliasKey) {
|
|
366
|
+
const canonicalType = ARTIFACT_TYPE_ALIASES[aliasKey];
|
|
367
|
+
const remainder = nameToMatch === aliasKey ? '' : nameToMatch.slice(aliasKey.length + 1);
|
|
368
|
+
return { type: canonicalType, hcPrefix, remainder, date, typeConfidence: 'high', typeSource: 'alias' };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Then try direct match against taxonomy types (dash boundary, longest first)
|
|
373
|
+
const sortedTypes = [...taxonomy.artifact_types].sort((a, b) => b.length - a.length);
|
|
374
|
+
for (const type of sortedTypes) {
|
|
375
|
+
if (nameToMatch.startsWith(type + '-') || nameToMatch === type) {
|
|
376
|
+
const remainder = nameToMatch === type ? '' : nameToMatch.slice(type.length + 1);
|
|
377
|
+
return { type, hcPrefix, remainder, date, typeConfidence: 'high', typeSource: 'prefix' };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// No match
|
|
382
|
+
return { type: null, hcPrefix, remainder: nameToMatch, date, typeConfidence: 'low', typeSource: 'none' };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Infer which initiative owns an artifact based on the remaining filename segments.
|
|
387
|
+
* Five-step lookup: (1) exact match → (2) alias match → (3) progressive prefix → (4) progressive suffix → (5) first segment. Falls through to ambiguous if all steps fail.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} remainder - Filename segments after type prefix and date are removed
|
|
390
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy with initiatives and aliases
|
|
391
|
+
* @returns {{initiative: string|null, confidence: 'high'|'low', source: string, candidates: string[]}}
|
|
392
|
+
*/
|
|
393
|
+
function inferInitiative(remainder, taxonomy) {
|
|
394
|
+
if (!remainder) {
|
|
395
|
+
return { initiative: null, confidence: 'low', source: 'empty', candidates: [] };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
|
|
399
|
+
const segments = remainder.split('-');
|
|
400
|
+
|
|
401
|
+
// Step 1: Try full remainder as exact initiative match
|
|
402
|
+
if (allInitiatives.includes(remainder)) {
|
|
403
|
+
return { initiative: remainder, confidence: 'high', source: 'exact', candidates: [] };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Step 2: Try full remainder as alias match
|
|
407
|
+
if (taxonomy.aliases && taxonomy.aliases[remainder]) {
|
|
408
|
+
return { initiative: taxonomy.aliases[remainder], confidence: 'high', source: 'alias', candidates: [] };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Step 3: Try progressive prefixes (longest first) against initiatives and aliases
|
|
412
|
+
// e.g., for 'strategy-perimeter-foo', try 'strategy-perimeter-foo', then 'strategy-perimeter', then 'strategy'
|
|
413
|
+
for (let i = segments.length - 1; i >= 1; i--) {
|
|
414
|
+
const prefix = segments.slice(0, i).join('-');
|
|
415
|
+
|
|
416
|
+
if (allInitiatives.includes(prefix)) {
|
|
417
|
+
return { initiative: prefix, confidence: 'high', source: 'exact', candidates: [] };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (taxonomy.aliases && taxonomy.aliases[prefix]) {
|
|
421
|
+
return { initiative: taxonomy.aliases[prefix], confidence: 'high', source: 'alias', candidates: [] };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Step 4: Try suffixes (last N segments) — catches 'prd-validation-gyre' → 'gyre'
|
|
426
|
+
for (let i = 1; i < segments.length; i++) {
|
|
427
|
+
const suffix = segments.slice(i).join('-');
|
|
428
|
+
|
|
429
|
+
if (allInitiatives.includes(suffix)) {
|
|
430
|
+
return { initiative: suffix, confidence: 'high', source: 'exact', candidates: [] };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (taxonomy.aliases && taxonomy.aliases[suffix]) {
|
|
434
|
+
return { initiative: taxonomy.aliases[suffix], confidence: 'high', source: 'alias', candidates: [] };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Step 5: Try first segment alone
|
|
439
|
+
const firstSegment = segments[0];
|
|
440
|
+
if (allInitiatives.includes(firstSegment)) {
|
|
441
|
+
return { initiative: firstSegment, confidence: 'high', source: 'exact', candidates: [] };
|
|
442
|
+
}
|
|
443
|
+
if (taxonomy.aliases && taxonomy.aliases[firstSegment]) {
|
|
444
|
+
return { initiative: taxonomy.aliases[firstSegment], confidence: 'high', source: 'alias', candidates: [] };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Ambiguous — build candidate list from any partial matches
|
|
448
|
+
const candidates = allInitiatives.filter(id =>
|
|
449
|
+
segments.some(seg => seg === id || seg.startsWith(id) || id.startsWith(seg))
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
return { initiative: null, confidence: 'low', source: 'unresolved', candidates };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- Suggested Initiative (Story 6.2) ---
|
|
456
|
+
// inferInitiative() is intentionally cautious — it never guesses. The suggester
|
|
457
|
+
// layers ON TOP, providing reviewable defaults for AMBIGUOUS entries based on
|
|
458
|
+
// content keywords, folder defaults, and git context. Suggestions are guidance,
|
|
459
|
+
// not decisions: the manifest entry stays AMBIGUOUS, but the operator gets a
|
|
460
|
+
// "REVIEW SUGGESTION: accept '{X}' or specify" prompt instead of a bare wall.
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Folder-default map for initiative inference.
|
|
464
|
+
* - planning-artifacts → convoke (platform-level artifacts default to convoke)
|
|
465
|
+
* - vortex-artifacts → null (Vortex spans multiple initiatives, no safe default)
|
|
466
|
+
* - gyre-artifacts → gyre (all gyre-artifacts/* belong to gyre)
|
|
467
|
+
*
|
|
468
|
+
* @type {Object<string, string|null>}
|
|
469
|
+
*/
|
|
470
|
+
const FOLDER_DEFAULT_MAP = Object.freeze({
|
|
471
|
+
'planning-artifacts': 'convoke',
|
|
472
|
+
'vortex-artifacts': null,
|
|
473
|
+
'gyre-artifacts': 'gyre'
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
/** Cap on git queries per migration run to preserve NFR2 (dry-run < 10s for 200 files) */
|
|
477
|
+
const MAX_GIT_SUGGESTER_QUERIES = 50;
|
|
478
|
+
let _gitSuggesterQueryCount = 0;
|
|
479
|
+
let _gitSuggesterWarned = false;
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Reset the per-run git query counter.
|
|
483
|
+
* Called by generateManifest() at the start of each run, and by tests
|
|
484
|
+
* via the exported helper to avoid cross-test state pollution.
|
|
485
|
+
*/
|
|
486
|
+
function _resetGitSuggesterCounter() {
|
|
487
|
+
_gitSuggesterQueryCount = 0;
|
|
488
|
+
_gitSuggesterWarned = false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Scan a text corpus for any taxonomy initiative ID or alias as a whole word.
|
|
493
|
+
* Returns the first match (longest first to prefer specific over generic).
|
|
494
|
+
*
|
|
495
|
+
* Uses hyphen-aware lookarounds rather than `\b` because JS `\b` treats `-` as a
|
|
496
|
+
* word boundary, which would cause `pre-gyre` to match the `gyre` initiative.
|
|
497
|
+
* The boundary class `[a-z0-9-]` keeps kebab-case identifiers atomic.
|
|
498
|
+
*
|
|
499
|
+
* @param {string} corpus - Lowercased text to scan
|
|
500
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy with initiatives and aliases
|
|
501
|
+
* @returns {string|null} Resolved initiative ID, or null if no match
|
|
502
|
+
*/
|
|
503
|
+
function _scanCorpusForInitiative(corpus, taxonomy) {
|
|
504
|
+
if (!corpus) return null;
|
|
505
|
+
if (!taxonomy || !taxonomy.initiatives) return null;
|
|
506
|
+
|
|
507
|
+
const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
|
|
508
|
+
const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
|
|
509
|
+
const allInitiatives = [...platform, ...user];
|
|
510
|
+
const aliasKeys = taxonomy.aliases ? Object.keys(taxonomy.aliases) : [];
|
|
511
|
+
|
|
512
|
+
// Combine and sort by length descending — prefer 'strategy-perimeter' over 'strategy'.
|
|
513
|
+
const candidates = [...allInitiatives, ...aliasKeys].sort((a, b) => b.length - a.length);
|
|
514
|
+
|
|
515
|
+
for (const candidate of candidates) {
|
|
516
|
+
const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
517
|
+
// Boundary class: not preceded/followed by [a-z0-9-]. Treats hyphen as word-internal,
|
|
518
|
+
// so 'gyrescope' rejects (preceded by 'gyre' won't trigger; 'scope' = letter), 'pre-gyre'
|
|
519
|
+
// also rejects (the leading 'pre-' counts as boundary since hyphen is in the class).
|
|
520
|
+
const re = new RegExp(`(?:^|[^a-z0-9-])${escaped}(?:$|[^a-z0-9-])`, 'i');
|
|
521
|
+
if (re.test(corpus)) {
|
|
522
|
+
// Resolve aliases to canonical initiative
|
|
523
|
+
if (allInitiatives.includes(candidate)) return candidate;
|
|
524
|
+
if (taxonomy.aliases && taxonomy.aliases[candidate]) return taxonomy.aliases[candidate];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Suggest a likely initiative for a file when inferInitiative() returns null.
|
|
532
|
+
* Three-step priority chain:
|
|
533
|
+
* 1. Content keyword scan (frontmatter title + first 10 lines) → 'medium' confidence
|
|
534
|
+
* 2. Folder default (FOLDER_DEFAULT_MAP) → 'low' confidence
|
|
535
|
+
* 3. Git creation commit message → 'low' confidence
|
|
536
|
+
*
|
|
537
|
+
* Suggestions are GUIDANCE for the operator, not auto-resolutions. The action
|
|
538
|
+
* label remains AMBIGUOUS so the operator must still confirm.
|
|
539
|
+
*
|
|
540
|
+
* @param {string} filename - The file's basename
|
|
541
|
+
* @param {string} dirName - The parent directory name (e.g., 'planning-artifacts')
|
|
542
|
+
* @param {string} fileContent - Already-loaded file content (avoids double-read)
|
|
543
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
|
|
544
|
+
* @param {string} projectRoot - Absolute path to project root (for git queries)
|
|
545
|
+
* @returns {{initiative: string|null, source: 'content-keyword'|'folder-default'|'git-context'|null, confidence: 'medium'|'low'|null}}
|
|
546
|
+
*/
|
|
547
|
+
function suggestInitiative(filename, dirName, fileContent, taxonomy, projectRoot) {
|
|
548
|
+
// Step 1: Content keyword scan (highest priority)
|
|
549
|
+
// Scan frontmatter title + first 10 lines, lowercased.
|
|
550
|
+
// Note: a file titled "Comparing Gyre vs Vortex" matches both — first match wins
|
|
551
|
+
// (longest first via _scanCorpusForInitiative). This is a documented trade-off.
|
|
552
|
+
let title = '';
|
|
553
|
+
let firstLines;
|
|
554
|
+
try {
|
|
555
|
+
const parsed = matter(fileContent);
|
|
556
|
+
if (parsed.data && typeof parsed.data.title === 'string') {
|
|
557
|
+
title = parsed.data.title;
|
|
558
|
+
}
|
|
559
|
+
const body = parsed.content || fileContent;
|
|
560
|
+
firstLines = body.split('\n').slice(0, 10).join(' ');
|
|
561
|
+
} catch {
|
|
562
|
+
// Frontmatter parse failed — fall back to raw content
|
|
563
|
+
firstLines = fileContent.split('\n').slice(0, 10).join(' ');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const corpus = `${title} ${firstLines}`.toLowerCase();
|
|
567
|
+
const contentMatch = _scanCorpusForInitiative(corpus, taxonomy);
|
|
568
|
+
if (contentMatch) {
|
|
569
|
+
return { initiative: contentMatch, source: 'content-keyword', confidence: 'medium' };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Step 2: Folder default
|
|
573
|
+
if (Object.prototype.hasOwnProperty.call(FOLDER_DEFAULT_MAP, dirName)) {
|
|
574
|
+
const folderDefault = FOLDER_DEFAULT_MAP[dirName];
|
|
575
|
+
if (folderDefault) {
|
|
576
|
+
return { initiative: folderDefault, source: 'folder-default', confidence: 'low' };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Step 3: Git context (lowest priority, capped to preserve NFR2).
|
|
581
|
+
// Skip git entirely if we don't have a project root to run inside.
|
|
582
|
+
if (!projectRoot) {
|
|
583
|
+
return { initiative: null, source: null, confidence: null };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Cap check: emit a one-time warning when we first hit the cap, then short-circuit.
|
|
587
|
+
if (_gitSuggesterQueryCount >= MAX_GIT_SUGGESTER_QUERIES) {
|
|
588
|
+
if (!_gitSuggesterWarned) {
|
|
589
|
+
_gitSuggesterWarned = true;
|
|
590
|
+
console.warn(
|
|
591
|
+
`Warning: git-context suggester cap reached (${MAX_GIT_SUGGESTER_QUERIES} queries). ` +
|
|
592
|
+
`Remaining ambiguous files will not get git-based suggestions.`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return { initiative: null, source: null, confidence: null };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
_gitSuggesterQueryCount++;
|
|
599
|
+
try {
|
|
600
|
+
const relPath = path.join('_bmad-output', dirName, filename);
|
|
601
|
+
const raw = execFileSync(
|
|
602
|
+
'git', ['log', '--diff-filter=A', '--format=%s', '--', relPath],
|
|
603
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
604
|
+
).trim();
|
|
605
|
+
if (raw) {
|
|
606
|
+
const gitMatch = _scanCorpusForInitiative(raw.toLowerCase(), taxonomy);
|
|
607
|
+
if (gitMatch) {
|
|
608
|
+
return { initiative: gitMatch, source: 'git-context', confidence: 'low' };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
// Git unavailable, file not tracked, or other failure — silent
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return { initiative: null, source: null, confidence: null };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Determine the governance state of a file based on filename convention and frontmatter.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} filename - The filename to check
|
|
622
|
+
* @param {string} fileContent - Raw file content (for frontmatter parsing)
|
|
623
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
|
|
624
|
+
* @returns {{state: 'fully-governed'|'half-governed'|'ungoverned'|'invalid-governed'|'ambiguous', fileInitiative: string|null, frontmatterInitiative: string|null, candidates: string[]}}
|
|
625
|
+
*/
|
|
626
|
+
function getGovernanceState(filename, fileContent, taxonomy) {
|
|
627
|
+
const typeResult = inferArtifactType(filename, taxonomy);
|
|
628
|
+
const initiativeResult = typeResult.type
|
|
629
|
+
? inferInitiative(typeResult.remainder, taxonomy)
|
|
630
|
+
: { initiative: null, confidence: 'low', source: 'no-type', candidates: [] };
|
|
631
|
+
|
|
632
|
+
const fileInitiative = initiativeResult.initiative;
|
|
633
|
+
|
|
634
|
+
// Check frontmatter
|
|
635
|
+
let frontmatterInitiative = null;
|
|
636
|
+
try {
|
|
637
|
+
const { data } = parseFrontmatter(fileContent);
|
|
638
|
+
frontmatterInitiative = data.initiative || null;
|
|
639
|
+
} catch {
|
|
640
|
+
// No valid frontmatter — treat as absent
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Determine state
|
|
644
|
+
if (typeResult.type === null) {
|
|
645
|
+
return { state: 'ungoverned', fileInitiative, frontmatterInitiative, candidates: [] };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Type matched but initiative ambiguous — distinct from ungoverned
|
|
649
|
+
if (initiativeResult.confidence === 'low') {
|
|
650
|
+
return { state: 'ambiguous', fileInitiative, frontmatterInitiative, candidates: initiativeResult.candidates || [] };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (!frontmatterInitiative) {
|
|
654
|
+
return { state: 'half-governed', fileInitiative, frontmatterInitiative, candidates: [] };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (frontmatterInitiative !== fileInitiative) {
|
|
658
|
+
return { state: 'invalid-governed', fileInitiative, frontmatterInitiative, candidates: [] };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return { state: 'fully-governed', fileInitiative, frontmatterInitiative, candidates: [] };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Generate a new filename following the governance convention.
|
|
666
|
+
* Format: {initiative}-{artifactType}[-{qualifier}][-{date}].md
|
|
667
|
+
*
|
|
668
|
+
* @param {string} filename - Original filename
|
|
669
|
+
* @param {string} initiative - Resolved initiative ID
|
|
670
|
+
* @param {string} artifactType - Resolved artifact type
|
|
671
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
|
|
672
|
+
* @returns {string} New filename following convention
|
|
673
|
+
*/
|
|
674
|
+
function generateNewFilename(filename, initiative, artifactType, taxonomy) {
|
|
675
|
+
const typeResult = inferArtifactType(filename, taxonomy);
|
|
676
|
+
|
|
677
|
+
// Build qualifier from: HC prefix + remainder after initiative extraction
|
|
678
|
+
const parts = [];
|
|
679
|
+
|
|
680
|
+
// Add HC prefix as qualifier if present
|
|
681
|
+
if (typeResult.hcPrefix) {
|
|
682
|
+
parts.push(typeResult.hcPrefix);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Extract qualifier: remainder minus the initiative segments
|
|
686
|
+
if (typeResult.remainder) {
|
|
687
|
+
const remainderSegments = typeResult.remainder.split('-');
|
|
688
|
+
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
|
|
689
|
+
const aliasKeys = Object.keys(taxonomy.aliases || {});
|
|
690
|
+
|
|
691
|
+
// Try to find which segments the initiative match consumed
|
|
692
|
+
// Check prefixes (longest first)
|
|
693
|
+
let consumedStart = -1;
|
|
694
|
+
let consumedEnd = -1;
|
|
695
|
+
|
|
696
|
+
// Try prefix matches first (e.g., 'forge-phase-a' → 'forge' consumed at start)
|
|
697
|
+
for (let i = remainderSegments.length; i >= 1; i--) {
|
|
698
|
+
const prefix = remainderSegments.slice(0, i).join('-');
|
|
699
|
+
if (allInitiatives.includes(prefix) || aliasKeys.includes(prefix)) {
|
|
700
|
+
consumedStart = 0;
|
|
701
|
+
consumedEnd = i;
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// If no prefix match, try suffix matches (e.g., 'decision-strategy-perimeter' → 'strategy-perimeter' consumed at end)
|
|
707
|
+
if (consumedStart === -1) {
|
|
708
|
+
for (let i = 1; i < remainderSegments.length; i++) {
|
|
709
|
+
const suffix = remainderSegments.slice(i).join('-');
|
|
710
|
+
if (allInitiatives.includes(suffix) || aliasKeys.includes(suffix)) {
|
|
711
|
+
consumedStart = i;
|
|
712
|
+
consumedEnd = remainderSegments.length;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// If still no match, try single first segment
|
|
719
|
+
if (consumedStart === -1) {
|
|
720
|
+
const first = remainderSegments[0];
|
|
721
|
+
if (allInitiatives.includes(first) || aliasKeys.includes(first)) {
|
|
722
|
+
consumedStart = 0;
|
|
723
|
+
consumedEnd = 1;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Build qualifier from unconsumed segments
|
|
728
|
+
if (consumedStart >= 0) {
|
|
729
|
+
const before = remainderSegments.slice(0, consumedStart);
|
|
730
|
+
const after = remainderSegments.slice(consumedEnd);
|
|
731
|
+
const qualifierSegments = [...before, ...after];
|
|
732
|
+
if (qualifierSegments.length > 0) {
|
|
733
|
+
parts.push(qualifierSegments.join('-'));
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
// No initiative found — entire remainder is qualifier
|
|
737
|
+
parts.push(typeResult.remainder);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const qualifier = parts.length > 0 ? parts.join('-') : null;
|
|
742
|
+
|
|
743
|
+
// Build new filename
|
|
744
|
+
let newName = `${initiative}-${artifactType}`;
|
|
745
|
+
if (qualifier) {
|
|
746
|
+
newName += `-${qualifier}`;
|
|
747
|
+
}
|
|
748
|
+
if (typeResult.date) {
|
|
749
|
+
newName += `-${typeResult.date}`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Preserve original extension (.md or .yaml)
|
|
753
|
+
const extMatch = filename.match(/\.(md|yaml)$/i);
|
|
754
|
+
newName += extMatch ? `.${extMatch[1].toLowerCase()}` : '.md';
|
|
755
|
+
|
|
756
|
+
return newName;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// --- Schema Validation ---
|
|
760
|
+
|
|
761
|
+
/** Valid artifact-level status values (closed enum) */
|
|
762
|
+
const VALID_STATUSES = ['draft', 'validated', 'superseded', 'active'];
|
|
763
|
+
|
|
764
|
+
/** ISO 8601 date format: YYYY-MM-DD */
|
|
765
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Validate frontmatter fields against the governance schema v1.
|
|
769
|
+
*
|
|
770
|
+
* @param {Object} fields - Frontmatter fields to validate
|
|
771
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config for initiative/type validation
|
|
772
|
+
* @returns {{valid: boolean, errors: string[]}} Validation result with error messages
|
|
773
|
+
*/
|
|
774
|
+
function validateFrontmatterSchema(fields, taxonomy) {
|
|
775
|
+
const errors = [];
|
|
776
|
+
|
|
777
|
+
// Required fields
|
|
778
|
+
if (!fields.initiative) {
|
|
779
|
+
errors.push('Missing required field: initiative');
|
|
780
|
+
}
|
|
781
|
+
if (!fields.artifact_type) {
|
|
782
|
+
errors.push('Missing required field: artifact_type');
|
|
783
|
+
}
|
|
784
|
+
if (!fields.created) {
|
|
785
|
+
errors.push('Missing required field: created');
|
|
786
|
+
}
|
|
787
|
+
if (fields.schema_version === undefined || fields.schema_version === null) {
|
|
788
|
+
errors.push('Missing required field: schema_version');
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// schema_version must be integer >= 1
|
|
792
|
+
if (fields.schema_version !== undefined && fields.schema_version !== null) {
|
|
793
|
+
if (!Number.isInteger(fields.schema_version) || fields.schema_version < 1) {
|
|
794
|
+
errors.push(`Invalid schema_version "${fields.schema_version}": must be an integer >= 1`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// created must be ISO 8601 date format
|
|
799
|
+
if (fields.created && !DATE_PATTERN.test(fields.created)) {
|
|
800
|
+
errors.push(`Invalid created date "${fields.created}": must be YYYY-MM-DD format`);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// status is optional but must be from closed enum if present
|
|
804
|
+
if (fields.status !== undefined && !VALID_STATUSES.includes(fields.status)) {
|
|
805
|
+
errors.push(`Invalid status "${fields.status}": must be one of ${VALID_STATUSES.join(', ')}`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// initiative must exist in taxonomy
|
|
809
|
+
if (fields.initiative && taxonomy) {
|
|
810
|
+
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
|
|
811
|
+
if (!allInitiatives.includes(fields.initiative)) {
|
|
812
|
+
errors.push(`Initiative "${fields.initiative}" not found in taxonomy (platform or user sections)`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// artifact_type must exist in taxonomy
|
|
817
|
+
if (fields.artifact_type && taxonomy) {
|
|
818
|
+
if (!taxonomy.artifact_types.includes(fields.artifact_type)) {
|
|
819
|
+
errors.push(`Artifact type "${fields.artifact_type}" not found in taxonomy artifact_types list`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return { valid: errors.length === 0, errors };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Build a complete frontmatter field set conforming to schema v1.
|
|
828
|
+
* Does NOT validate — use validateFrontmatterSchema() for that.
|
|
829
|
+
*
|
|
830
|
+
* @param {string} initiative - Initiative ID from taxonomy
|
|
831
|
+
* @param {string} artifactType - Artifact type from taxonomy
|
|
832
|
+
* @param {Object} [options={}] - Optional overrides (status, created)
|
|
833
|
+
* @param {string} [options.status] - Optional artifact status (draft/validated/superseded/active)
|
|
834
|
+
* @param {string} [options.created] - Optional created date (defaults to today YYYY-MM-DD)
|
|
835
|
+
* @returns {import('./types').FrontmatterSchema} Complete frontmatter fields
|
|
836
|
+
*/
|
|
837
|
+
function buildSchemaFields(initiative, artifactType, options = {}) {
|
|
838
|
+
const fields = {
|
|
839
|
+
initiative,
|
|
840
|
+
artifact_type: artifactType,
|
|
841
|
+
created: options.created || new Date().toISOString().split('T')[0],
|
|
842
|
+
schema_version: 1
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
if (options.status !== undefined) {
|
|
846
|
+
fields.status = options.status;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return fields;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// --- Manifest Generation ---
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Get context clues for a file (first 3 lines + git author/date).
|
|
856
|
+
* Used in dry-run manifest for ambiguous/conflict files.
|
|
857
|
+
*
|
|
858
|
+
* @param {string} filePath - Absolute path to the file
|
|
859
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
860
|
+
* @returns {Promise<{firstLines: string[], gitAuthor: string|null, gitDate: string|null}>}
|
|
861
|
+
*/
|
|
862
|
+
async function getContextClues(filePath, projectRoot) {
|
|
863
|
+
let firstLines = [];
|
|
864
|
+
try {
|
|
865
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
866
|
+
const lines = content.split('\n');
|
|
867
|
+
firstLines = lines.slice(0, 3).map(l => l.trimEnd());
|
|
868
|
+
} catch {
|
|
869
|
+
// File unreadable — return empty lines
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
let gitAuthor = null;
|
|
873
|
+
let gitDate = null;
|
|
874
|
+
try {
|
|
875
|
+
const raw = execFileSync(
|
|
876
|
+
'git', ['log', '-1', '--format=%an|%as', '--', path.relative(projectRoot, filePath)],
|
|
877
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
878
|
+
).trim();
|
|
879
|
+
if (raw) {
|
|
880
|
+
const parts = raw.split('|');
|
|
881
|
+
gitAuthor = parts[0] || null;
|
|
882
|
+
gitDate = parts[1] || null;
|
|
883
|
+
}
|
|
884
|
+
} catch {
|
|
885
|
+
// Not tracked in git or git unavailable
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return { firstLines, gitAuthor, gitDate };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Find files that reference a target filename via markdown links or bare mentions.
|
|
893
|
+
* Only called when --verbose is set (reads every file in scope).
|
|
894
|
+
*
|
|
895
|
+
* @param {string} targetFilename - The filename to search for references to
|
|
896
|
+
* @param {Array<{filename: string, fullPath: string}>} scopeFiles - All files in scope
|
|
897
|
+
* @param {string} _projectRoot - Project root (unused, reserved for future)
|
|
898
|
+
* @returns {Promise<string[]>} List of filenames that reference the target
|
|
899
|
+
*/
|
|
900
|
+
async function getCrossReferences(targetFilename, scopeFiles, _projectRoot) {
|
|
901
|
+
const refs = [];
|
|
902
|
+
for (const file of scopeFiles) {
|
|
903
|
+
if (file.filename === targetFilename) continue;
|
|
904
|
+
if (!file.fullPath.endsWith('.md')) continue;
|
|
905
|
+
try {
|
|
906
|
+
const content = await fs.readFile(file.fullPath, 'utf8');
|
|
907
|
+
// Match: [text](targetFilename), [text](../dir/targetFilename), or bare targetFilename
|
|
908
|
+
if (content.includes(targetFilename)) {
|
|
909
|
+
refs.push(file.filename);
|
|
910
|
+
}
|
|
911
|
+
} catch {
|
|
912
|
+
// Skip unreadable files
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return refs;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Build a single manifest entry for a file, classifying its action.
|
|
920
|
+
*
|
|
921
|
+
* @param {{filename: string, dir: string, fullPath: string}} fileInfo - File from scanArtifactDirs
|
|
922
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
|
|
923
|
+
* @param {string} _projectRoot - Project root (reserved)
|
|
924
|
+
* @returns {Promise<import('./types').ManifestEntry>}
|
|
925
|
+
*/
|
|
926
|
+
async function buildManifestEntry(fileInfo, taxonomy, projectRoot) {
|
|
927
|
+
const { filename, dir, fullPath } = fileInfo;
|
|
928
|
+
const oldPath = `${dir}/${filename}`;
|
|
929
|
+
|
|
930
|
+
// Only process markdown files — YAML and other files are not migration targets
|
|
931
|
+
if (!filename.endsWith('.md')) {
|
|
932
|
+
return {
|
|
933
|
+
oldPath, newPath: null, initiative: null, artifactType: null,
|
|
934
|
+
confidence: 'low', source: 'non-markdown', action: 'SKIP',
|
|
935
|
+
dir, contextClues: null, crossReferences: null, candidates: [],
|
|
936
|
+
collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
|
|
937
|
+
typeConfidence: 'low', typeSource: 'none',
|
|
938
|
+
suggestedInitiative: null, suggestedFrom: null, suggestedConfidence: null
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
let fileContent;
|
|
943
|
+
try {
|
|
944
|
+
fileContent = await fs.readFile(fullPath, 'utf8');
|
|
945
|
+
} catch {
|
|
946
|
+
return {
|
|
947
|
+
oldPath, newPath: null, initiative: null, artifactType: null,
|
|
948
|
+
confidence: 'low', source: 'unreadable', action: 'AMBIGUOUS',
|
|
949
|
+
dir, contextClues: null, crossReferences: null, candidates: [],
|
|
950
|
+
collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
|
|
951
|
+
typeConfidence: 'low', typeSource: 'none',
|
|
952
|
+
suggestedInitiative: null, suggestedFrom: null, suggestedConfidence: null
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Single inference pass — getGovernanceState uses inferArtifactType + inferInitiative internally.
|
|
957
|
+
// We call inferArtifactType once here to get typeConfidence/typeSource for manifest display.
|
|
958
|
+
const typeResult = inferArtifactType(filename, taxonomy);
|
|
959
|
+
const govState = getGovernanceState(filename, fileContent, taxonomy);
|
|
960
|
+
|
|
961
|
+
const initConfidence = govState.state === 'ambiguous' || govState.state === 'ungoverned' ? 'low' : 'high';
|
|
962
|
+
const initSource = govState.state === 'ungoverned' ? 'no-type'
|
|
963
|
+
: govState.state === 'ambiguous' ? 'unresolved'
|
|
964
|
+
: govState.fileInitiative ? 'inferred' : 'none';
|
|
965
|
+
|
|
966
|
+
const base = {
|
|
967
|
+
oldPath, dir,
|
|
968
|
+
initiative: govState.fileInitiative,
|
|
969
|
+
artifactType: typeResult.type,
|
|
970
|
+
confidence: initConfidence,
|
|
971
|
+
source: initSource,
|
|
972
|
+
typeConfidence: typeResult.typeConfidence,
|
|
973
|
+
typeSource: typeResult.typeSource,
|
|
974
|
+
contextClues: null,
|
|
975
|
+
crossReferences: null,
|
|
976
|
+
candidates: govState.candidates || [],
|
|
977
|
+
collisionWith: null,
|
|
978
|
+
frontmatterInitiative: govState.frontmatterInitiative,
|
|
979
|
+
fileInitiative: govState.fileInitiative,
|
|
980
|
+
// Suggestion fields (Story 6.2)
|
|
981
|
+
// - suggestedInitiative/From/Confidence: populated for AMBIGUOUS entries by suggestInitiative()
|
|
982
|
+
// - suggestedNewPath: populated for colliding RENAME entries by suggestDifferentiator()
|
|
983
|
+
// (set to null here so consumers always see a defined field)
|
|
984
|
+
suggestedInitiative: null,
|
|
985
|
+
suggestedFrom: null,
|
|
986
|
+
suggestedConfidence: null,
|
|
987
|
+
suggestedNewPath: null
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// For ambiguous/ungoverned entries, layer a suggestion on top via suggestInitiative.
|
|
991
|
+
// The action stays AMBIGUOUS — the operator must still confirm. This is guidance, not auto-resolution.
|
|
992
|
+
if (govState.state === 'ungoverned' || govState.state === 'ambiguous') {
|
|
993
|
+
const suggestion = suggestInitiative(filename, dir, fileContent, taxonomy, projectRoot);
|
|
994
|
+
return {
|
|
995
|
+
...base,
|
|
996
|
+
newPath: null,
|
|
997
|
+
action: 'AMBIGUOUS',
|
|
998
|
+
suggestedInitiative: suggestion.initiative,
|
|
999
|
+
suggestedFrom: suggestion.source,
|
|
1000
|
+
suggestedConfidence: suggestion.confidence
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (govState.state === 'invalid-governed') {
|
|
1005
|
+
return { ...base, newPath: null, action: 'CONFLICT' };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Half-governed or fully-governed: type + initiative resolved
|
|
1009
|
+
// Compare current filename with governance target to determine action
|
|
1010
|
+
let newFilename;
|
|
1011
|
+
try {
|
|
1012
|
+
newFilename = generateNewFilename(filename, govState.fileInitiative, typeResult.type, taxonomy);
|
|
1013
|
+
} catch {
|
|
1014
|
+
// generateNewFilename failed — treat as ambiguous rather than aborting the entire manifest
|
|
1015
|
+
return { ...base, newPath: null, action: 'AMBIGUOUS' };
|
|
1016
|
+
}
|
|
1017
|
+
const newPath = `${dir}/${newFilename}`;
|
|
1018
|
+
|
|
1019
|
+
if (govState.state === 'fully-governed') {
|
|
1020
|
+
if (filename === newFilename) {
|
|
1021
|
+
return { ...base, newPath: null, action: 'SKIP' };
|
|
1022
|
+
}
|
|
1023
|
+
return { ...base, newPath, action: 'RENAME' };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// half-governed
|
|
1027
|
+
if (filename === newFilename) {
|
|
1028
|
+
return { ...base, newPath: null, action: 'INJECT_ONLY' };
|
|
1029
|
+
}
|
|
1030
|
+
return { ...base, newPath, action: 'RENAME' };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Suggest a differentiator suffix for two source filenames colliding on the same target.
|
|
1035
|
+
* Strategy:
|
|
1036
|
+
* 1. Strip date suffix from sources and target
|
|
1037
|
+
* 2. For each source, find the longest unique segment chain that the OTHER sources don't share
|
|
1038
|
+
* 3. Insert that differentiator into the target before the date suffix
|
|
1039
|
+
*
|
|
1040
|
+
* Returns null if sources are too similar to differentiate (e.g., exact duplicates).
|
|
1041
|
+
*
|
|
1042
|
+
* @param {string[]} sourcePaths - List of colliding source paths (e.g., 'vortex-artifacts/lean-persona-strategic-navigator-2026-04-04.md')
|
|
1043
|
+
* @param {string} targetPath - The collision target (e.g., 'vortex-artifacts/helm-lean-persona-2026-04-04.md')
|
|
1044
|
+
* @returns {Map<string, string|null>} Map of sourcePath → suggestedNewPath (or null)
|
|
1045
|
+
*/
|
|
1046
|
+
function suggestDifferentiator(sourcePaths, targetPath) {
|
|
1047
|
+
const result = new Map();
|
|
1048
|
+
|
|
1049
|
+
// Filter out sentinel entries like '(existing) ...' that aren't real source files
|
|
1050
|
+
const realSources = sourcePaths.filter(s => !s.startsWith('(existing) '));
|
|
1051
|
+
if (realSources.length < 2) {
|
|
1052
|
+
for (const s of sourcePaths) result.set(s, null);
|
|
1053
|
+
return result;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Parse target: directory + stem + date + ext
|
|
1057
|
+
const targetMatch = targetPath.match(/^(.*\/)?([^/]+?)(-(\d{4}-\d{2}-\d{2}))?\.(md|yaml)$/);
|
|
1058
|
+
if (!targetMatch) {
|
|
1059
|
+
for (const s of sourcePaths) result.set(s, null);
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
const targetDir = targetMatch[1] || '';
|
|
1063
|
+
const targetStem = targetMatch[2];
|
|
1064
|
+
const targetDate = targetMatch[4] || '';
|
|
1065
|
+
const targetExt = targetMatch[5];
|
|
1066
|
+
|
|
1067
|
+
// For each real source, extract segments that are not in the target stem.
|
|
1068
|
+
// Then verify uniqueness against the other sources.
|
|
1069
|
+
const sourceData = realSources.map(srcPath => {
|
|
1070
|
+
const srcMatch = srcPath.match(/^(.*\/)?([^/]+?)(-(\d{4}-\d{2}-\d{2}))?\.(md|yaml)$/);
|
|
1071
|
+
if (!srcMatch) return { srcPath, segments: [] };
|
|
1072
|
+
const srcStem = srcMatch[2];
|
|
1073
|
+
const srcSegments = srcStem.split('-');
|
|
1074
|
+
const targetSegments = new Set(targetStem.split('-'));
|
|
1075
|
+
// Keep only segments not present in the target stem
|
|
1076
|
+
const unique = srcSegments.filter(s => !targetSegments.has(s));
|
|
1077
|
+
return { srcPath, segments: unique, srcStem };
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// Cross-check: each source's segments must also distinguish it from OTHER sources
|
|
1081
|
+
for (const { srcPath, segments } of sourceData) {
|
|
1082
|
+
const otherSegSets = sourceData
|
|
1083
|
+
.filter(d => d.srcPath !== srcPath)
|
|
1084
|
+
.map(d => new Set(d.segments));
|
|
1085
|
+
|
|
1086
|
+
// Find segments that are unique to THIS source (not in any other source's segments)
|
|
1087
|
+
const uniqueToMe = segments.filter(seg => otherSegSets.every(other => !other.has(seg)));
|
|
1088
|
+
|
|
1089
|
+
if (uniqueToMe.length === 0) {
|
|
1090
|
+
// No distinguishing segments — can't differentiate
|
|
1091
|
+
result.set(srcPath, null);
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Build the differentiator (join unique segments)
|
|
1096
|
+
const differentiator = uniqueToMe.join('-');
|
|
1097
|
+
|
|
1098
|
+
// Construct the suggested new path: targetDir + targetStem + '-' + differentiator + dateSuffix + ext
|
|
1099
|
+
const dateSuffix = targetDate ? `-${targetDate}` : '';
|
|
1100
|
+
const suggestedFilename = `${targetStem}-${differentiator}${dateSuffix}.${targetExt}`;
|
|
1101
|
+
const suggestedNewPath = `${targetDir}${suggestedFilename}`;
|
|
1102
|
+
result.set(srcPath, suggestedNewPath);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Edge case: if the suggested new paths themselves collide, append a numeric suffix.
|
|
1106
|
+
// First pass: count duplicates. Second pass: rename ALL duplicates (not just 2nd+).
|
|
1107
|
+
// Uses the same greedy regex as the source/target parser above to avoid the
|
|
1108
|
+
// lazy-`.*?` empty-stem bug.
|
|
1109
|
+
const dupCounts = new Map();
|
|
1110
|
+
for (const suggested of result.values()) {
|
|
1111
|
+
if (!suggested) continue;
|
|
1112
|
+
dupCounts.set(suggested, (dupCounts.get(suggested) || 0) + 1);
|
|
1113
|
+
}
|
|
1114
|
+
const assignedSuffix = new Map(); // suggested → next index to assign
|
|
1115
|
+
for (const [src, suggested] of result) {
|
|
1116
|
+
if (!suggested) continue;
|
|
1117
|
+
if ((dupCounts.get(suggested) || 0) < 2) continue; // not a dup, skip
|
|
1118
|
+
const idx = (assignedSuffix.get(suggested) || 0) + 1;
|
|
1119
|
+
assignedSuffix.set(suggested, idx);
|
|
1120
|
+
const reMatch = suggested.match(/^(.*\/)?([^/]+?)(-(\d{4}-\d{2}-\d{2}))?\.(md|yaml)$/);
|
|
1121
|
+
if (reMatch) {
|
|
1122
|
+
const dirPrefix = reMatch[1] || '';
|
|
1123
|
+
const stem = reMatch[2];
|
|
1124
|
+
const dateSuffix = reMatch[4] ? `-${reMatch[4]}` : '';
|
|
1125
|
+
const ext = reMatch[5];
|
|
1126
|
+
result.set(src, `${dirPrefix}${stem}-${idx}${dateSuffix}.${ext}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Sentinels get null
|
|
1131
|
+
for (const s of sourcePaths) {
|
|
1132
|
+
if (!result.has(s)) result.set(s, null);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
return result;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Detect target filename collisions in manifest entries.
|
|
1140
|
+
*
|
|
1141
|
+
* @param {import('./types').ManifestEntry[]} entries - All manifest entries
|
|
1142
|
+
* @returns {Map<string, string[]>} Map of colliding newPath -> list of oldPaths
|
|
1143
|
+
*/
|
|
1144
|
+
function detectCollisions(entries) {
|
|
1145
|
+
const targetMap = new Map();
|
|
1146
|
+
|
|
1147
|
+
// Collect all target filenames (from RENAME entries)
|
|
1148
|
+
for (const entry of entries) {
|
|
1149
|
+
if (entry.action === 'RENAME' && entry.newPath) {
|
|
1150
|
+
if (!targetMap.has(entry.newPath)) {
|
|
1151
|
+
targetMap.set(entry.newPath, []);
|
|
1152
|
+
}
|
|
1153
|
+
targetMap.get(entry.newPath).push(entry.oldPath);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Also check if any target matches an existing file (SKIP/INJECT entries)
|
|
1158
|
+
const existingPaths = new Set(
|
|
1159
|
+
entries.filter(e => e.action === 'SKIP' || e.action === 'INJECT_ONLY').map(e => e.oldPath)
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
for (const target of targetMap.keys()) {
|
|
1163
|
+
if (existingPaths.has(target)) {
|
|
1164
|
+
const sources = targetMap.get(target);
|
|
1165
|
+
const sentinel = `(existing) ${target}`;
|
|
1166
|
+
if (!sources.includes(sentinel)) {
|
|
1167
|
+
sources.push(sentinel);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Filter to only actual collisions (more than 1 source)
|
|
1173
|
+
const collisions = new Map();
|
|
1174
|
+
for (const [target, sources] of targetMap) {
|
|
1175
|
+
if (sources.length > 1) {
|
|
1176
|
+
collisions.set(target, sources);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
return collisions;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Generate the full dry-run manifest for all in-scope artifact directories.
|
|
1185
|
+
*
|
|
1186
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1187
|
+
* @param {Object} [options={}]
|
|
1188
|
+
* @param {string[]} [options.includeDirs=['planning-artifacts','vortex-artifacts','gyre-artifacts']]
|
|
1189
|
+
* @param {string[]} [options.excludeDirs=['_archive']]
|
|
1190
|
+
* @param {boolean} [options.verbose=false]
|
|
1191
|
+
* @returns {Promise<import('./types').ManifestResult>}
|
|
1192
|
+
*/
|
|
1193
|
+
async function generateManifest(projectRoot, options = {}) {
|
|
1194
|
+
const {
|
|
1195
|
+
includeDirs = ['planning-artifacts', 'vortex-artifacts', 'gyre-artifacts'],
|
|
1196
|
+
excludeDirs = ['_archive'],
|
|
1197
|
+
verbose = false
|
|
1198
|
+
} = options;
|
|
1199
|
+
|
|
1200
|
+
const taxonomy = readTaxonomy(projectRoot);
|
|
1201
|
+
const scopeFiles = await scanArtifactDirs(projectRoot, includeDirs, excludeDirs);
|
|
1202
|
+
const entries = [];
|
|
1203
|
+
|
|
1204
|
+
// Reset the per-run git query counter (Story 6.2 — caps git suggestions to preserve NFR2)
|
|
1205
|
+
_resetGitSuggesterCounter();
|
|
1206
|
+
|
|
1207
|
+
for (const fileInfo of scopeFiles) {
|
|
1208
|
+
const entry = await buildManifestEntry(fileInfo, taxonomy, projectRoot);
|
|
1209
|
+
entries.push(entry);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Detect collisions and annotate entries
|
|
1213
|
+
const collisions = detectCollisions(entries);
|
|
1214
|
+
for (const [target, sources] of collisions) {
|
|
1215
|
+
// Compute differentiator suggestions for this collision (Story 6.2)
|
|
1216
|
+
const differentiators = suggestDifferentiator(sources, target);
|
|
1217
|
+
for (const entry of entries) {
|
|
1218
|
+
if (entry.newPath === target && entry.action === 'RENAME') {
|
|
1219
|
+
entry.collisionWith = sources.filter(s => s !== entry.oldPath);
|
|
1220
|
+
entry.suggestedNewPath = differentiators.get(entry.oldPath) || null;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Gather context clues for AMBIGUOUS and CONFLICT entries
|
|
1226
|
+
for (const entry of entries) {
|
|
1227
|
+
if (entry.action === 'AMBIGUOUS' || entry.action === 'CONFLICT') {
|
|
1228
|
+
const fullPath = path.join(projectRoot, '_bmad-output', entry.oldPath);
|
|
1229
|
+
entry.contextClues = await getContextClues(fullPath, projectRoot);
|
|
1230
|
+
|
|
1231
|
+
if (verbose) {
|
|
1232
|
+
entry.crossReferences = await getCrossReferences(
|
|
1233
|
+
entry.oldPath.split('/').pop(),
|
|
1234
|
+
scopeFiles,
|
|
1235
|
+
projectRoot
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Build summary
|
|
1242
|
+
const summary = { total: entries.length, skip: 0, rename: 0, inject: 0, conflict: 0, ambiguous: 0 };
|
|
1243
|
+
for (const entry of entries) {
|
|
1244
|
+
switch (entry.action) {
|
|
1245
|
+
case 'SKIP': summary.skip++; break;
|
|
1246
|
+
case 'RENAME': summary.rename++; break;
|
|
1247
|
+
case 'INJECT_ONLY': summary.inject++; break;
|
|
1248
|
+
case 'CONFLICT': summary.conflict++; break;
|
|
1249
|
+
case 'AMBIGUOUS': summary.ambiguous++; break;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return { entries, collisions, summary };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Format the manifest as a human-readable text report.
|
|
1258
|
+
*
|
|
1259
|
+
* @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
|
|
1260
|
+
* @param {Object} [options={}]
|
|
1261
|
+
* @param {boolean} [options.verbose=false]
|
|
1262
|
+
* @returns {string} Formatted manifest text
|
|
1263
|
+
*/
|
|
1264
|
+
function formatManifest(manifest, options = {}) {
|
|
1265
|
+
const { verbose = false } = options;
|
|
1266
|
+
const lines = [];
|
|
1267
|
+
|
|
1268
|
+
for (const entry of manifest.entries) {
|
|
1269
|
+
switch (entry.action) {
|
|
1270
|
+
case 'SKIP':
|
|
1271
|
+
lines.push(`[SKIP] ${entry.oldPath} -- already governed`);
|
|
1272
|
+
break;
|
|
1273
|
+
|
|
1274
|
+
case 'INJECT_ONLY':
|
|
1275
|
+
lines.push(`[INJECT] ${entry.oldPath} -- frontmatter needed`);
|
|
1276
|
+
break;
|
|
1277
|
+
|
|
1278
|
+
case 'RENAME':
|
|
1279
|
+
lines.push(`${entry.oldPath} -> ${entry.newPath}`);
|
|
1280
|
+
lines.push(` Initiative: ${entry.initiative} (confidence: ${entry.confidence}, source: ${entry.source})`);
|
|
1281
|
+
lines.push(` Type: ${entry.artifactType} (confidence: ${entry.typeConfidence || 'high'}, source: ${entry.typeSource || 'prefix'})`);
|
|
1282
|
+
if (entry.collisionWith && entry.collisionWith.length > 0) {
|
|
1283
|
+
lines.push(` [!] COLLISION: same target as ${entry.collisionWith.join(', ')}`);
|
|
1284
|
+
if (entry.suggestedNewPath) {
|
|
1285
|
+
lines.push(` Suggested rename: ${entry.suggestedNewPath}`);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
break;
|
|
1289
|
+
|
|
1290
|
+
case 'CONFLICT':
|
|
1291
|
+
lines.push(`[!] ${entry.oldPath} -> CONFLICT (filename says ${entry.fileInitiative}, frontmatter says ${entry.frontmatterInitiative})`);
|
|
1292
|
+
lines.push(' ACTION REQUIRED: Resolve initiative conflict before migration');
|
|
1293
|
+
if (entry.contextClues) {
|
|
1294
|
+
for (let i = 0; i < entry.contextClues.firstLines.length; i++) {
|
|
1295
|
+
lines.push(` Line ${i + 1}: "${entry.contextClues.firstLines[i]}"`);
|
|
1296
|
+
}
|
|
1297
|
+
if (entry.contextClues.gitAuthor) {
|
|
1298
|
+
lines.push(` Git author: ${entry.contextClues.gitAuthor} (${entry.contextClues.gitDate})`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
break;
|
|
1302
|
+
|
|
1303
|
+
case 'AMBIGUOUS': {
|
|
1304
|
+
const typeLabel = entry.artifactType
|
|
1305
|
+
? `type: ${entry.artifactType}, initiative unknown`
|
|
1306
|
+
: 'cannot infer type or initiative';
|
|
1307
|
+
lines.push(`[!] ${entry.oldPath} -> ??? (ambiguous -- ${typeLabel})`);
|
|
1308
|
+
if (entry.contextClues) {
|
|
1309
|
+
for (let i = 0; i < entry.contextClues.firstLines.length; i++) {
|
|
1310
|
+
lines.push(` Line ${i + 1}: "${entry.contextClues.firstLines[i]}"`);
|
|
1311
|
+
}
|
|
1312
|
+
if (entry.contextClues.gitAuthor) {
|
|
1313
|
+
lines.push(` Git author: ${entry.contextClues.gitAuthor} (${entry.contextClues.gitDate})`);
|
|
1314
|
+
}
|
|
1315
|
+
if (verbose && entry.crossReferences && entry.crossReferences.length > 0) {
|
|
1316
|
+
lines.push(` Referenced by: ${entry.crossReferences.join(', ')}`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (entry.candidates.length > 0) {
|
|
1320
|
+
lines.push(` Candidates: ${entry.candidates.join(', ')}`);
|
|
1321
|
+
}
|
|
1322
|
+
// Suggestion (Story 6.2): if a default exists, surface it and switch the action label
|
|
1323
|
+
if (entry.suggestedInitiative) {
|
|
1324
|
+
lines.push(` Suggested: ${entry.suggestedInitiative} (source: ${entry.suggestedFrom}, confidence: ${entry.suggestedConfidence})`);
|
|
1325
|
+
lines.push(` REVIEW SUGGESTION: Accept '${entry.suggestedInitiative}' or specify initiative`);
|
|
1326
|
+
} else {
|
|
1327
|
+
lines.push(' ACTION REQUIRED: Specify initiative for this file');
|
|
1328
|
+
}
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Summary footer
|
|
1335
|
+
const s = manifest.summary;
|
|
1336
|
+
lines.push('');
|
|
1337
|
+
lines.push(`--- Manifest Summary ---`);
|
|
1338
|
+
lines.push(`Total: ${s.total} | Rename: ${s.rename} | Skip: ${s.skip} | Inject: ${s.inject} | Conflict: ${s.conflict} | Ambiguous: ${s.ambiguous}`);
|
|
1339
|
+
|
|
1340
|
+
if (manifest.collisions.size > 0) {
|
|
1341
|
+
lines.push(`[!] ${manifest.collisions.size} filename collision(s) detected -- resolve before executing`);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
return lines.join('\n');
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// --- Migration Execution ---
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Structured error for migration failures. Named ArtifactMigrationError to avoid
|
|
1351
|
+
* collision with MigrationError in scripts/update/lib/migration-runner.js.
|
|
1352
|
+
*
|
|
1353
|
+
* @property {string} file - Which file caused the error
|
|
1354
|
+
* @property {'rename'|'inject'} phase - Drives programmatic rollback target
|
|
1355
|
+
* @property {boolean} recoverable - Can re-run fix this?
|
|
1356
|
+
*/
|
|
1357
|
+
class ArtifactMigrationError extends Error {
|
|
1358
|
+
constructor(message, { file = null, phase, recoverable = true } = {}) {
|
|
1359
|
+
super(message);
|
|
1360
|
+
this.name = 'ArtifactMigrationError';
|
|
1361
|
+
this.file = file;
|
|
1362
|
+
this.phase = phase;
|
|
1363
|
+
this.recoverable = recoverable;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Execute all renames from a manifest as a single atomic git commit.
|
|
1369
|
+
* If any git mv fails, rolls back ALL renames via git reset --hard HEAD.
|
|
1370
|
+
*
|
|
1371
|
+
* @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
|
|
1372
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1373
|
+
* @returns {{renamedCount: number, commitSha: string}} Result with count and commit SHA
|
|
1374
|
+
* @throws {ArtifactMigrationError} On collision detection or git mv failure (after rollback)
|
|
1375
|
+
*/
|
|
1376
|
+
function executeRenames(manifest, projectRoot) {
|
|
1377
|
+
const renameEntries = manifest.entries.filter(e => e.action === 'RENAME');
|
|
1378
|
+
|
|
1379
|
+
if (renameEntries.length === 0) {
|
|
1380
|
+
return { renamedCount: 0, commitSha: null };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Pre-flight: refuse to proceed if collisions exist
|
|
1384
|
+
const colliding = renameEntries.filter(e => e.collisionWith && e.collisionWith.length > 0);
|
|
1385
|
+
if (colliding.length > 0) {
|
|
1386
|
+
const details = colliding.map(e => ` ${e.oldPath} -> ${e.newPath} (collides with ${e.collisionWith.join(', ')})`).join('\n');
|
|
1387
|
+
throw new ArtifactMigrationError(
|
|
1388
|
+
`Cannot execute renames: ${colliding.length} filename collision(s) detected.\n${details}`,
|
|
1389
|
+
{ phase: 'rename', recoverable: false }
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const outputDir = path.join(projectRoot, '_bmad-output');
|
|
1394
|
+
|
|
1395
|
+
// Execute all git mv operations
|
|
1396
|
+
for (const entry of renameEntries) {
|
|
1397
|
+
const oldFull = path.join(outputDir, entry.oldPath);
|
|
1398
|
+
const newFull = path.join(outputDir, entry.newPath);
|
|
1399
|
+
|
|
1400
|
+
try {
|
|
1401
|
+
execFileSync('git', ['mv', oldFull, newFull], { cwd: projectRoot, stdio: 'pipe' });
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
// Rollback ALL renames done so far
|
|
1404
|
+
let rollbackOk = false;
|
|
1405
|
+
try {
|
|
1406
|
+
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
|
|
1407
|
+
rollbackOk = true;
|
|
1408
|
+
} catch { /* rollback failed — tree is dirty */ }
|
|
1409
|
+
|
|
1410
|
+
throw new ArtifactMigrationError(
|
|
1411
|
+
`git mv failed for ${entry.oldPath} -> ${entry.newPath}: ${err.message}`,
|
|
1412
|
+
{ file: entry.oldPath, phase: 'rename', recoverable: rollbackOk }
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Commit all renames as a single atomic commit (git mv already stages changes)
|
|
1418
|
+
try {
|
|
1419
|
+
execFileSync(
|
|
1420
|
+
'git', ['commit', '-m', 'chore: rename artifacts to governance convention'],
|
|
1421
|
+
{ cwd: projectRoot, stdio: 'pipe' }
|
|
1422
|
+
);
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
// Commit failed after all git mv succeeded — rollback all renames
|
|
1425
|
+
let rollbackOk = false;
|
|
1426
|
+
try {
|
|
1427
|
+
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
|
|
1428
|
+
rollbackOk = true;
|
|
1429
|
+
} catch { /* rollback failed */ }
|
|
1430
|
+
|
|
1431
|
+
throw new ArtifactMigrationError(
|
|
1432
|
+
`git commit failed after renames: ${err.message}`,
|
|
1433
|
+
{ phase: 'rename', recoverable: rollbackOk }
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
let commitSha = null;
|
|
1438
|
+
try {
|
|
1439
|
+
const shaOutput = execFileSync(
|
|
1440
|
+
'git', ['rev-parse', 'HEAD'],
|
|
1441
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
1442
|
+
);
|
|
1443
|
+
commitSha = (typeof shaOutput === 'string' ? shaOutput : shaOutput.toString('utf8')).trim();
|
|
1444
|
+
} catch {
|
|
1445
|
+
// Commit succeeded but SHA retrieval failed — non-fatal
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
return { renamedCount: renameEntries.length, commitSha };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Verify git history chain is preserved for a sample of renamed files.
|
|
1453
|
+
* Informational only — does NOT rollback on failure.
|
|
1454
|
+
*
|
|
1455
|
+
* @param {import('./types').ManifestEntry[]} renamedEntries - Entries that were renamed
|
|
1456
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1457
|
+
* @returns {{verified: number, failed: string[]}} Verification result
|
|
1458
|
+
*/
|
|
1459
|
+
function verifyHistoryChain(renamedEntries, projectRoot) {
|
|
1460
|
+
const sample = renamedEntries.slice(0, 5);
|
|
1461
|
+
let verified = 0;
|
|
1462
|
+
const failed = [];
|
|
1463
|
+
|
|
1464
|
+
for (const entry of sample) {
|
|
1465
|
+
const fullPath = path.join(projectRoot, '_bmad-output', entry.newPath);
|
|
1466
|
+
try {
|
|
1467
|
+
const log = execFileSync(
|
|
1468
|
+
'git', ['log', '--follow', '--oneline', '-3', '--', fullPath],
|
|
1469
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
1470
|
+
).trim();
|
|
1471
|
+
|
|
1472
|
+
const lines = log.split('\n').filter(Boolean);
|
|
1473
|
+
if (lines.length >= 2) {
|
|
1474
|
+
verified++;
|
|
1475
|
+
} else {
|
|
1476
|
+
failed.push(entry.newPath);
|
|
1477
|
+
}
|
|
1478
|
+
} catch {
|
|
1479
|
+
failed.push(entry.newPath);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return { verified, failed };
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Update internal markdown links in all .md files within scope after renames.
|
|
1488
|
+
* Handles 4 patterns: [text](file.md), [text](./file.md), [text](../dir/file.md),
|
|
1489
|
+
* and frontmatter inputDocuments arrays. Preserves anchor fragments.
|
|
1490
|
+
*
|
|
1491
|
+
* @param {Map<string, string>} oldToNewMap - Map of old basenames to new basenames
|
|
1492
|
+
* @param {string[]} scopeDirs - Directory names to scan (relative to _bmad-output/)
|
|
1493
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1494
|
+
* @returns {Promise<{updatedFiles: number, updatedLinks: number}>}
|
|
1495
|
+
*/
|
|
1496
|
+
async function updateLinks(oldToNewMap, scopeDirs, projectRoot) {
|
|
1497
|
+
const allFiles = await scanArtifactDirs(projectRoot, scopeDirs, ['_archive']);
|
|
1498
|
+
let updatedFiles = 0;
|
|
1499
|
+
let updatedLinks = 0;
|
|
1500
|
+
|
|
1501
|
+
for (const file of allFiles) {
|
|
1502
|
+
if (!file.fullPath.endsWith('.md')) continue;
|
|
1503
|
+
|
|
1504
|
+
const original = fs.readFileSync(file.fullPath, 'utf8');
|
|
1505
|
+
let content = original;
|
|
1506
|
+
let fileLinks = 0;
|
|
1507
|
+
|
|
1508
|
+
// Parse frontmatter to handle inputDocuments arrays
|
|
1509
|
+
const parsed = matter(content);
|
|
1510
|
+
let fmChanged = false;
|
|
1511
|
+
if (parsed.data && parsed.data.inputDocuments && Array.isArray(parsed.data.inputDocuments)) {
|
|
1512
|
+
parsed.data.inputDocuments = parsed.data.inputDocuments.map(doc => {
|
|
1513
|
+
if (typeof doc !== 'string') return doc;
|
|
1514
|
+
for (const [oldName, newName] of oldToNewMap) {
|
|
1515
|
+
// Exact match or path-suffix match (e.g., "dir/oldname.md") — prevents substring corruption
|
|
1516
|
+
if (doc === oldName || doc.endsWith('/' + oldName)) {
|
|
1517
|
+
fmChanged = true;
|
|
1518
|
+
fileLinks++;
|
|
1519
|
+
return doc === oldName ? newName : doc.slice(0, doc.length - oldName.length) + newName;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return doc;
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Reassemble content if frontmatter changed
|
|
1527
|
+
if (fmChanged) {
|
|
1528
|
+
content = matter.stringify(parsed.content, parsed.data);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Update markdown link patterns in body content
|
|
1532
|
+
for (const [oldName, newName] of oldToNewMap) {
|
|
1533
|
+
// Escape dots for regex
|
|
1534
|
+
const escaped = oldName.replace(/\./g, '\\.');
|
|
1535
|
+
|
|
1536
|
+
// Patterns 1+2: [text](oldname.md) or [text](./oldname.md) with optional anchor
|
|
1537
|
+
const directPattern = new RegExp(
|
|
1538
|
+
`(\\[[^\\]]*\\]\\()(\\.\\/)?${escaped}(#[^)]*)?\\)`,
|
|
1539
|
+
'g'
|
|
1540
|
+
);
|
|
1541
|
+
|
|
1542
|
+
// Pattern 3: [text](../dir/oldname.md) with optional anchor — replace only the filename
|
|
1543
|
+
const parentDirPattern = new RegExp(
|
|
1544
|
+
`(\\[[^\\]]*\\]\\([^)]*\\/)${escaped}(#[^)]*)?\\)`,
|
|
1545
|
+
'g'
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
let bodyChanges = 0;
|
|
1549
|
+
content = content.replace(directPattern, (_m, prefix, dotSlash, anchor) => {
|
|
1550
|
+
bodyChanges++;
|
|
1551
|
+
return `${prefix}${dotSlash || ''}${newName}${anchor || ''})`;
|
|
1552
|
+
});
|
|
1553
|
+
content = content.replace(parentDirPattern, (_m, prefix, anchor) => {
|
|
1554
|
+
bodyChanges++;
|
|
1555
|
+
return `${prefix}${newName}${anchor || ''})`;
|
|
1556
|
+
});
|
|
1557
|
+
fileLinks += bodyChanges;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
if (content !== original) {
|
|
1561
|
+
fs.writeFileSync(file.fullPath, content, 'utf8');
|
|
1562
|
+
updatedFiles++;
|
|
1563
|
+
updatedLinks += fileLinks;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
return { updatedFiles, updatedLinks };
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Execute commit 2: inject frontmatter into renamed files and update links.
|
|
1572
|
+
* Runs AFTER executeRenames (commit 1) has completed.
|
|
1573
|
+
*
|
|
1574
|
+
* @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
|
|
1575
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1576
|
+
* @param {string[]} scopeDirs - Scope directories for link scanning
|
|
1577
|
+
* @returns {Promise<{injectedCount: number, linkUpdates: {updatedFiles: number, updatedLinks: number}, conflictCount: number, commitSha: string|null}>}
|
|
1578
|
+
* @throws {ArtifactMigrationError} On write failure (after rollback to commit 1)
|
|
1579
|
+
*/
|
|
1580
|
+
async function executeInjections(manifest, projectRoot, scopeDirs) {
|
|
1581
|
+
const renameEntries = manifest.entries.filter(e => e.action === 'RENAME');
|
|
1582
|
+
let injectedCount = 0;
|
|
1583
|
+
let conflictCount = 0;
|
|
1584
|
+
const outputDir = path.join(projectRoot, '_bmad-output');
|
|
1585
|
+
|
|
1586
|
+
// Build old->new basename map for link updating
|
|
1587
|
+
const oldToNewMap = new Map();
|
|
1588
|
+
for (const entry of renameEntries) {
|
|
1589
|
+
const oldBasename = entry.oldPath.split('/').pop();
|
|
1590
|
+
const newBasename = entry.newPath.split('/').pop();
|
|
1591
|
+
if (oldBasename !== newBasename) {
|
|
1592
|
+
oldToNewMap.set(oldBasename, newBasename);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// Inject frontmatter into each renamed file
|
|
1597
|
+
for (const entry of renameEntries) {
|
|
1598
|
+
const filePath = path.join(outputDir, entry.newPath);
|
|
1599
|
+
try {
|
|
1600
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1601
|
+
const fields = buildSchemaFields(entry.initiative, entry.artifactType);
|
|
1602
|
+
const result = injectFrontmatter(content, fields);
|
|
1603
|
+
|
|
1604
|
+
// Log conflicts
|
|
1605
|
+
for (const c of result.conflicts) {
|
|
1606
|
+
console.warn(` Warning: Skipping field "${c.field}" in ${entry.newPath}: existing value "${c.existingValue}" differs from proposed "${c.newValue}"`);
|
|
1607
|
+
conflictCount++;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
fs.writeFileSync(filePath, result.content, 'utf8');
|
|
1611
|
+
injectedCount++;
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
// Write failure — rollback to commit 1
|
|
1614
|
+
let rollbackOk = false;
|
|
1615
|
+
try {
|
|
1616
|
+
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
|
|
1617
|
+
rollbackOk = true;
|
|
1618
|
+
} catch { /* rollback failed */ }
|
|
1619
|
+
|
|
1620
|
+
throw new ArtifactMigrationError(
|
|
1621
|
+
`Failed to inject frontmatter into ${entry.newPath}: ${err.message}`,
|
|
1622
|
+
{ file: entry.newPath, phase: 'inject', recoverable: rollbackOk }
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Update internal links across all scoped .md files
|
|
1628
|
+
const linkUpdates = await updateLinks(oldToNewMap, scopeDirs, projectRoot);
|
|
1629
|
+
|
|
1630
|
+
// Generate rename map (committed with injection phase)
|
|
1631
|
+
const renameMapContent = generateRenameMap(renameEntries);
|
|
1632
|
+
const renameMapPath = path.join(outputDir, 'planning-artifacts', 'artifact-rename-map.md');
|
|
1633
|
+
fs.writeFileSync(renameMapPath, renameMapContent, 'utf8');
|
|
1634
|
+
|
|
1635
|
+
// Stage and commit (scoped to _bmad-output/)
|
|
1636
|
+
try {
|
|
1637
|
+
execFileSync('git', ['add', '_bmad-output/'], { cwd: projectRoot, stdio: 'pipe' });
|
|
1638
|
+
execFileSync(
|
|
1639
|
+
'git', ['commit', '-m', 'chore: inject frontmatter metadata and update links'],
|
|
1640
|
+
{ cwd: projectRoot, stdio: 'pipe' }
|
|
1641
|
+
);
|
|
1642
|
+
} catch (err) {
|
|
1643
|
+
let rollbackOk = false;
|
|
1644
|
+
try {
|
|
1645
|
+
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
|
|
1646
|
+
rollbackOk = true;
|
|
1647
|
+
} catch { /* rollback failed */ }
|
|
1648
|
+
|
|
1649
|
+
throw new ArtifactMigrationError(
|
|
1650
|
+
`git commit failed after injections: ${err.message}`,
|
|
1651
|
+
{ phase: 'inject', recoverable: rollbackOk }
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
let commitSha = null;
|
|
1656
|
+
try {
|
|
1657
|
+
const shaOutput = execFileSync(
|
|
1658
|
+
'git', ['rev-parse', 'HEAD'],
|
|
1659
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
1660
|
+
);
|
|
1661
|
+
commitSha = (typeof shaOutput === 'string' ? shaOutput : shaOutput.toString('utf8')).trim();
|
|
1662
|
+
} catch {
|
|
1663
|
+
// Non-fatal — commit succeeded
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
return { injectedCount, linkUpdates, conflictCount, commitSha };
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Prompt operator for initiative assignment on a single ambiguous file.
|
|
1671
|
+
* Exported for mocking in tests — tests should NEVER interact with real readline.
|
|
1672
|
+
*
|
|
1673
|
+
* @param {string} filename - The ambiguous filename
|
|
1674
|
+
* @param {string[]} candidates - Possible initiative matches
|
|
1675
|
+
* @returns {Promise<string>} Selected initiative or 'skip'
|
|
1676
|
+
*/
|
|
1677
|
+
async function promptInitiative(filename, candidates) {
|
|
1678
|
+
const readline = require('readline');
|
|
1679
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1680
|
+
const options = [...candidates, 'skip'].join('/');
|
|
1681
|
+
return new Promise(resolve => {
|
|
1682
|
+
let resolved = false;
|
|
1683
|
+
const done = (value) => { if (!resolved) { resolved = true; resolve(value); } };
|
|
1684
|
+
rl.on('close', () => done('skip'));
|
|
1685
|
+
rl.question(`Assign initiative for ${filename} [${options}]: `, answer => {
|
|
1686
|
+
rl.close();
|
|
1687
|
+
const trimmed = (answer || '').trim().toLowerCase();
|
|
1688
|
+
if (trimmed === 'skip' || candidates.includes(trimmed)) {
|
|
1689
|
+
done(trimmed);
|
|
1690
|
+
} else {
|
|
1691
|
+
done('skip');
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Resolve ambiguous manifest entries interactively, via a resolution map, or auto-skip in force mode.
|
|
1699
|
+
* Mutates manifest entries in-place.
|
|
1700
|
+
*
|
|
1701
|
+
* **Caller responsibility:** after this function returns, the caller MUST re-run `detectCollisions()`
|
|
1702
|
+
* on the manifest. Resolution-map and interactive renames may produce target-path collisions that
|
|
1703
|
+
* the original `generateManifest()` did not see. The CLI's `main()` does this; programmatic callers
|
|
1704
|
+
* must do the same.
|
|
1705
|
+
*
|
|
1706
|
+
* Priority order for AMBIGUOUS entries:
|
|
1707
|
+
* 1. Resolution map (Story 6.4) — operator decisions passed via --resolution-file
|
|
1708
|
+
* 2. No-candidates auto-skip — entries the engine couldn't even generate candidates for
|
|
1709
|
+
* 3. Force auto-skip — `--force` flag bypasses interactive prompts
|
|
1710
|
+
* 4. Interactive prompt — fallback
|
|
1711
|
+
*
|
|
1712
|
+
* @param {import('./types').ManifestResult} manifest - Manifest to resolve
|
|
1713
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy for filename generation
|
|
1714
|
+
* @param {string} _projectRoot - Project root (reserved)
|
|
1715
|
+
* @param {Object} [options={}]
|
|
1716
|
+
* @param {boolean} [options.force=false] - Auto-skip all ambiguous in force mode
|
|
1717
|
+
* @param {Function} [options.promptFn=promptInitiative] - Prompt function for tests
|
|
1718
|
+
* @param {Object|null} [options.resolutionMap=null] - Pre-loaded resolutions keyed by oldPath.
|
|
1719
|
+
* Each value: `{ action: 'rename'|'skip', initiative?: string }`. Validated by loadResolutionMap()
|
|
1720
|
+
* before being passed in here. When a resolution-map entry exists for an oldPath, it takes
|
|
1721
|
+
* precedence over BOTH the no-candidates auto-skip and the force flag — the operator's
|
|
1722
|
+
* explicit decision wins. If `entry.artifactType` is null (engine couldn't infer type) and the
|
|
1723
|
+
* resolution says rename, falls back to a synthetic 'note' type so generateNewFilename can run.
|
|
1724
|
+
* @returns {Promise<{resolved: number, skipped: number}>}
|
|
1725
|
+
*/
|
|
1726
|
+
async function resolveAmbiguous(manifest, taxonomy, _projectRoot, options = {}) {
|
|
1727
|
+
const { force = false, promptFn = promptInitiative, resolutionMap = null } = options;
|
|
1728
|
+
let resolved = 0;
|
|
1729
|
+
let skipped = 0;
|
|
1730
|
+
|
|
1731
|
+
for (const entry of manifest.entries) {
|
|
1732
|
+
if (entry.action !== 'AMBIGUOUS') continue;
|
|
1733
|
+
|
|
1734
|
+
// Story 6.4: Resolution map takes precedence over all other guards.
|
|
1735
|
+
// Operator decisions passed via --resolution-file are honored even when the engine
|
|
1736
|
+
// would have auto-skipped (no candidates) or when --force is set.
|
|
1737
|
+
if (resolutionMap && Object.prototype.hasOwnProperty.call(resolutionMap, entry.oldPath)) {
|
|
1738
|
+
const resolution = resolutionMap[entry.oldPath];
|
|
1739
|
+
if (resolution.action === 'skip') {
|
|
1740
|
+
entry.action = 'SKIP';
|
|
1741
|
+
entry.source = 'operator';
|
|
1742
|
+
skipped++;
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
if (resolution.action === 'rename') {
|
|
1746
|
+
// Initiative pre-validated against taxonomy by loadResolutionMap()
|
|
1747
|
+
entry.initiative = resolution.initiative;
|
|
1748
|
+
// Fallback type for entries the engine couldn't classify (e.g. bare `notes.md`).
|
|
1749
|
+
// We synthesize 'note' ONLY if the taxonomy actually declares 'note' as an artifact type.
|
|
1750
|
+
// Otherwise we leave the entry as AMBIGUOUS rather than producing a path that downstream
|
|
1751
|
+
// schema validation would reject. The operator can extend taxonomy.artifact_types and re-run.
|
|
1752
|
+
if (!entry.artifactType) {
|
|
1753
|
+
const validTypes = Array.isArray(taxonomy && taxonomy.artifact_types) ? taxonomy.artifact_types : [];
|
|
1754
|
+
if (!validTypes.includes('note')) {
|
|
1755
|
+
console.warn(
|
|
1756
|
+
`Warning: cannot honor resolution for ${entry.oldPath} — no artifact type inferable ` +
|
|
1757
|
+
`and 'note' is not in taxonomy.artifact_types. Entry left as AMBIGUOUS.`
|
|
1758
|
+
);
|
|
1759
|
+
// Skip the rename logic and let the entry fall through to whatever the next guard says.
|
|
1760
|
+
// We don't continue here so the existing no-candidates / force / interactive paths apply.
|
|
1761
|
+
} else {
|
|
1762
|
+
entry.artifactType = 'note';
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
// Only proceed with the rename if we now have a type (either real or synthesized).
|
|
1766
|
+
if (entry.artifactType) {
|
|
1767
|
+
// Guard: entry.dir must be set or the rename target becomes 'undefined/foo.md'.
|
|
1768
|
+
// Derive from oldPath as a safety net.
|
|
1769
|
+
if (!entry.dir) {
|
|
1770
|
+
const lastSlash = entry.oldPath.lastIndexOf('/');
|
|
1771
|
+
entry.dir = lastSlash >= 0 ? entry.oldPath.slice(0, lastSlash) : '';
|
|
1772
|
+
}
|
|
1773
|
+
const filename = entry.oldPath.split('/').pop();
|
|
1774
|
+
const newFilename = generateNewFilename(filename, resolution.initiative, entry.artifactType, taxonomy);
|
|
1775
|
+
entry.newPath = entry.dir ? `${entry.dir}/${newFilename}` : newFilename;
|
|
1776
|
+
entry.action = 'RENAME';
|
|
1777
|
+
entry.confidence = 'high';
|
|
1778
|
+
entry.source = 'operator';
|
|
1779
|
+
resolved++;
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
// Otherwise fall through to the normal AMBIGUOUS handling below.
|
|
1783
|
+
} else {
|
|
1784
|
+
// Unknown action — loadResolutionMap should have caught this. Throw rather than silently
|
|
1785
|
+
// dropping the operator's intent.
|
|
1786
|
+
throw new ArtifactMigrationError(
|
|
1787
|
+
`Resolution map for ${entry.oldPath} has unknown action '${resolution.action}'`,
|
|
1788
|
+
{ phase: 'rename', recoverable: true }
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Non-resolvable: no type or no candidates — auto-skip
|
|
1794
|
+
if (!entry.artifactType || !entry.candidates || entry.candidates.length === 0) {
|
|
1795
|
+
entry.action = 'SKIP';
|
|
1796
|
+
skipped++;
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Force mode: auto-skip all ambiguous
|
|
1801
|
+
if (force) {
|
|
1802
|
+
entry.action = 'SKIP';
|
|
1803
|
+
skipped++;
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Interactive prompt
|
|
1808
|
+
const filename = entry.oldPath.split('/').pop();
|
|
1809
|
+
const choice = await promptFn(filename, entry.candidates);
|
|
1810
|
+
|
|
1811
|
+
if (choice === 'skip') {
|
|
1812
|
+
entry.action = 'SKIP';
|
|
1813
|
+
skipped++;
|
|
1814
|
+
} else {
|
|
1815
|
+
entry.initiative = choice;
|
|
1816
|
+
const newFilename = generateNewFilename(filename, choice, entry.artifactType, taxonomy);
|
|
1817
|
+
entry.newPath = `${entry.dir}/${newFilename}`;
|
|
1818
|
+
entry.action = 'RENAME';
|
|
1819
|
+
entry.confidence = 'high';
|
|
1820
|
+
entry.source = 'operator';
|
|
1821
|
+
resolved++;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// Update summary counts
|
|
1826
|
+
manifest.summary.rename = manifest.entries.filter(e => e.action === 'RENAME').length;
|
|
1827
|
+
manifest.summary.skip = manifest.entries.filter(e => e.action === 'SKIP').length;
|
|
1828
|
+
manifest.summary.ambiguous = manifest.entries.filter(e => e.action === 'AMBIGUOUS').length;
|
|
1829
|
+
|
|
1830
|
+
return { resolved, skipped };
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Load and validate a resolution-map JSON file for use with `resolveAmbiguous()`.
|
|
1835
|
+
*
|
|
1836
|
+
* Expected file shape:
|
|
1837
|
+
* ```json
|
|
1838
|
+
* {
|
|
1839
|
+
* "schemaVersion": 1,
|
|
1840
|
+
* "resolutions": {
|
|
1841
|
+
* "dir/file.md": { "action": "rename", "initiative": "convoke" },
|
|
1842
|
+
* "dir/other.md": { "action": "skip" }
|
|
1843
|
+
* }
|
|
1844
|
+
* }
|
|
1845
|
+
* ```
|
|
1846
|
+
*
|
|
1847
|
+
* Throws an `ArtifactMigrationError` (phase: 'rename', recoverable: true) on any validation
|
|
1848
|
+
* failure with a clear message. Validation is strict — partial files are not accepted.
|
|
1849
|
+
*
|
|
1850
|
+
* @param {string} filePath - Absolute path to the JSON file
|
|
1851
|
+
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy for initiative validation
|
|
1852
|
+
* @returns {Object} The validated `resolutions` map (oldPath → { action, initiative? })
|
|
1853
|
+
* @throws {ArtifactMigrationError}
|
|
1854
|
+
*/
|
|
1855
|
+
function loadResolutionMap(filePath, taxonomy) {
|
|
1856
|
+
if (!fs.existsSync(filePath)) {
|
|
1857
|
+
throw new ArtifactMigrationError(
|
|
1858
|
+
`Resolution file not found: ${filePath}`,
|
|
1859
|
+
{ phase: 'rename', recoverable: true }
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
let raw;
|
|
1864
|
+
try {
|
|
1865
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
1866
|
+
} catch (err) {
|
|
1867
|
+
throw new ArtifactMigrationError(
|
|
1868
|
+
`Cannot read resolution file ${filePath}: ${err.message}`,
|
|
1869
|
+
{ phase: 'rename', recoverable: true }
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
let parsed;
|
|
1874
|
+
try {
|
|
1875
|
+
parsed = JSON.parse(raw);
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
throw new ArtifactMigrationError(
|
|
1878
|
+
`Invalid JSON in resolution file ${filePath}: ${err.message}`,
|
|
1879
|
+
{ phase: 'rename', recoverable: true }
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
1884
|
+
throw new ArtifactMigrationError(
|
|
1885
|
+
`Resolution file must contain a JSON object: ${filePath}`,
|
|
1886
|
+
{ phase: 'rename', recoverable: true }
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
if (parsed.schemaVersion !== 1) {
|
|
1891
|
+
throw new ArtifactMigrationError(
|
|
1892
|
+
`Unsupported schemaVersion ${parsed.schemaVersion} in ${filePath} (expected 1)`,
|
|
1893
|
+
{ phase: 'rename', recoverable: true }
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
if (!parsed.resolutions || typeof parsed.resolutions !== 'object') {
|
|
1898
|
+
throw new ArtifactMigrationError(
|
|
1899
|
+
`Resolution file ${filePath} missing required 'resolutions' object`,
|
|
1900
|
+
{ phase: 'rename', recoverable: true }
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const validInitiatives = new Set([
|
|
1905
|
+
...(Array.isArray(taxonomy && taxonomy.initiatives && taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : []),
|
|
1906
|
+
...(Array.isArray(taxonomy && taxonomy.initiatives && taxonomy.initiatives.user) ? taxonomy.initiatives.user : [])
|
|
1907
|
+
]);
|
|
1908
|
+
|
|
1909
|
+
// Use Object.create(null) for the output map so consumers can do plain `map[key]`
|
|
1910
|
+
// lookups without prototype pollution risk. We still validate the keys themselves.
|
|
1911
|
+
const safeMap = Object.create(null);
|
|
1912
|
+
|
|
1913
|
+
for (const [oldPath, resolution] of Object.entries(parsed.resolutions)) {
|
|
1914
|
+
// Reject keys that could pollute the prototype chain or are otherwise unsafe.
|
|
1915
|
+
if (oldPath === '__proto__' || oldPath === 'constructor' || oldPath === 'prototype') {
|
|
1916
|
+
throw new ArtifactMigrationError(
|
|
1917
|
+
`Unsafe resolution key '${oldPath}' rejected`,
|
|
1918
|
+
{ phase: 'rename', recoverable: true }
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
if (typeof oldPath !== 'string' || oldPath.length === 0) {
|
|
1922
|
+
throw new ArtifactMigrationError(
|
|
1923
|
+
`Resolution keys must be non-empty strings`,
|
|
1924
|
+
{ phase: 'rename', recoverable: true }
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
if (!resolution || typeof resolution !== 'object') {
|
|
1928
|
+
throw new ArtifactMigrationError(
|
|
1929
|
+
`Invalid resolution entry for ${oldPath}: must be an object`,
|
|
1930
|
+
{ phase: 'rename', recoverable: true }
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
if (resolution.action !== 'rename' && resolution.action !== 'skip') {
|
|
1934
|
+
throw new ArtifactMigrationError(
|
|
1935
|
+
`Invalid action '${resolution.action}' for ${oldPath} (expected 'rename' or 'skip')`,
|
|
1936
|
+
{ phase: 'rename', recoverable: true }
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
if (resolution.action === 'rename') {
|
|
1940
|
+
if (typeof resolution.initiative !== 'string' || resolution.initiative.length === 0) {
|
|
1941
|
+
throw new ArtifactMigrationError(
|
|
1942
|
+
`Resolution for ${oldPath} has action='rename' but no initiative`,
|
|
1943
|
+
{ phase: 'rename', recoverable: true }
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
if (!validInitiatives.has(resolution.initiative)) {
|
|
1947
|
+
throw new ArtifactMigrationError(
|
|
1948
|
+
`Unknown initiative '${resolution.initiative}' for ${oldPath} (not in taxonomy)`,
|
|
1949
|
+
{ phase: 'rename', recoverable: true }
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
safeMap[oldPath] = { action: 'rename', initiative: resolution.initiative };
|
|
1953
|
+
} else {
|
|
1954
|
+
safeMap[oldPath] = { action: 'skip' };
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
return safeMap;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/**
|
|
1962
|
+
* Generate artifact-rename-map.md content as a markdown table.
|
|
1963
|
+
*
|
|
1964
|
+
* @param {import('./types').ManifestEntry[]} renamedEntries - Entries that were renamed
|
|
1965
|
+
* @returns {string} Markdown content for the rename map file
|
|
1966
|
+
*/
|
|
1967
|
+
function generateRenameMap(renamedEntries) {
|
|
1968
|
+
const date = new Date().toISOString().split('T')[0];
|
|
1969
|
+
const lines = [
|
|
1970
|
+
`# Artifact Rename Map`,
|
|
1971
|
+
'',
|
|
1972
|
+
`**Generated:** ${date}`,
|
|
1973
|
+
`**Total renamed:** ${renamedEntries.length}`,
|
|
1974
|
+
'',
|
|
1975
|
+
'| Old Path | New Path |',
|
|
1976
|
+
'|----------|----------|'
|
|
1977
|
+
];
|
|
1978
|
+
|
|
1979
|
+
for (const entry of renamedEntries) {
|
|
1980
|
+
lines.push(`| ${entry.oldPath} | ${entry.newPath} |`);
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
return lines.join('\n') + '\n';
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/**
|
|
1987
|
+
* Detect the current migration state for idempotent recovery.
|
|
1988
|
+
* Uses commit message as primary signal (inference engine can't recognize
|
|
1989
|
+
* initiative-first filenames after rename — see ag-3-3 Dev Notes).
|
|
1990
|
+
*
|
|
1991
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
1992
|
+
* @returns {'complete'|'renames-done'|'fresh'} Current migration state
|
|
1993
|
+
*/
|
|
1994
|
+
/**
|
|
1995
|
+
* Generate the content for the new governance convention ADR.
|
|
1996
|
+
*
|
|
1997
|
+
* @param {string} date - ISO date string (YYYY-MM-DD)
|
|
1998
|
+
* @param {{renamedCount: number, injectedCount: number, linksUpdated: number, scopeDirs: string[]}} migrationStats
|
|
1999
|
+
* @returns {string} Markdown content for the ADR file
|
|
2000
|
+
*/
|
|
2001
|
+
function generateGovernanceADR(date, migrationStats = {}) {
|
|
2002
|
+
const { renamedCount = 0, injectedCount = 0, linksUpdated = 0, scopeDirs = [] } = migrationStats;
|
|
2003
|
+
return `# Architecture Decision Record: Artifact Governance Convention
|
|
2004
|
+
|
|
2005
|
+
**Status:** ACCEPTED
|
|
2006
|
+
**Date:** ${date}
|
|
2007
|
+
**Decision Makers:** Convoke migration tool
|
|
2008
|
+
**Supersedes:** adr-repo-organization-conventions-2026-03-22.md
|
|
2009
|
+
|
|
2010
|
+
---
|
|
2011
|
+
|
|
2012
|
+
## Context
|
|
2013
|
+
|
|
2014
|
+
The project accumulated artifacts across multiple initiatives (Vortex, Gyre, Forge, Helm, Enhance, Loom, Convoke) using inconsistent naming conventions. Files like \`prd-gyre.md\`, \`architecture-gyre.md\`, and \`hc2-problem-definition-gyre-2026-03-21.md\` followed different patterns, making it difficult to identify which initiative owned each artifact and to build automated tooling on top of the artifact structure.
|
|
2015
|
+
|
|
2016
|
+
## Decision
|
|
2017
|
+
|
|
2018
|
+
All artifacts within \`_bmad-output/\` follow the governance naming convention:
|
|
2019
|
+
|
|
2020
|
+
\`\`\`
|
|
2021
|
+
{initiative}-{artifact_type}[-{qualifier}][-{date}].md
|
|
2022
|
+
\`\`\`
|
|
2023
|
+
|
|
2024
|
+
**Examples:**
|
|
2025
|
+
- \`gyre-prd.md\` (initiative: gyre, type: prd)
|
|
2026
|
+
- \`helm-lean-persona-2026-04-04.md\` (initiative: helm, type: lean-persona, date)
|
|
2027
|
+
- \`forge-problem-def-hc2-2026-03-21.md\` (initiative: forge, type: problem-def, qualifier: hc2, date)
|
|
2028
|
+
|
|
2029
|
+
## Taxonomy
|
|
2030
|
+
|
|
2031
|
+
**Platform initiatives (8):** vortex, gyre, bmm, forge, helm, enhance, loom, convoke
|
|
2032
|
+
|
|
2033
|
+
**Artifact types (21):** prd, epic, arch, adr, persona, lean-persona, empathy-map, problem-def, hypothesis, experiment, signal, decision, scope, pre-reg, sprint, brief, vision, report, research, story, spec
|
|
2034
|
+
|
|
2035
|
+
**Aliases (migration-specific):** Historical name variants mapped to canonical initiative IDs during migration (e.g., strategy-perimeter -> helm, team-factory -> loom).
|
|
2036
|
+
|
|
2037
|
+
## Frontmatter Schema v1
|
|
2038
|
+
|
|
2039
|
+
Every governed artifact includes YAML frontmatter with these required fields:
|
|
2040
|
+
|
|
2041
|
+
\`\`\`yaml
|
|
2042
|
+
---
|
|
2043
|
+
initiative: gyre # Required. From taxonomy.yaml
|
|
2044
|
+
artifact_type: prd # Required. From taxonomy.yaml
|
|
2045
|
+
created: 2026-04-06 # Required. ISO 8601 date
|
|
2046
|
+
schema_version: 1 # Required. Integer >= 1
|
|
2047
|
+
---
|
|
2048
|
+
\`\`\`
|
|
2049
|
+
|
|
2050
|
+
Existing frontmatter fields are preserved — migration adds fields, never overwrites.
|
|
2051
|
+
|
|
2052
|
+
## Migration Scope
|
|
2053
|
+
|
|
2054
|
+
- **Directories:** ${scopeDirs.length > 0 ? scopeDirs.join(', ') : 'planning-artifacts, vortex-artifacts, gyre-artifacts'}
|
|
2055
|
+
- **Files renamed:** ${renamedCount}
|
|
2056
|
+
- **Frontmatter injected:** ${injectedCount}
|
|
2057
|
+
- **Links updated:** ${linksUpdated}
|
|
2058
|
+
- **Archive excluded:** \`_bmad-output/_archive/\` always excluded (FR50)
|
|
2059
|
+
|
|
2060
|
+
## Consequences
|
|
2061
|
+
|
|
2062
|
+
- All artifacts are discoverable by initiative and type via filename convention
|
|
2063
|
+
- Automated portfolio tooling can infer initiative state from artifact metadata
|
|
2064
|
+
- \`git log --follow\` preserves full history for renamed files
|
|
2065
|
+
- The previous convention (type-first: \`prd-gyre.md\`) is superseded
|
|
2066
|
+
`;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* Update the previous ADR's status to SUPERSEDED and add a Superseded-by reference.
|
|
2071
|
+
*
|
|
2072
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
2073
|
+
* @param {string} newADRFilename - Filename of the new ADR (e.g., 'adr-artifact-governance-convention-2026-04-06.md')
|
|
2074
|
+
* @returns {boolean} true if updated, false if old ADR not found
|
|
2075
|
+
*/
|
|
2076
|
+
function supersedePreviousADR(projectRoot, newADRFilename) {
|
|
2077
|
+
const oldADRPath = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'adr-repo-organization-conventions-2026-03-22.md');
|
|
2078
|
+
|
|
2079
|
+
if (!fs.existsSync(oldADRPath)) {
|
|
2080
|
+
console.warn('Warning: Previous ADR not found at expected path. Skipping supersession.');
|
|
2081
|
+
return false;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
let content = fs.readFileSync(oldADRPath, 'utf8');
|
|
2085
|
+
|
|
2086
|
+
// Replace status
|
|
2087
|
+
content = content.replace('**Status:** ACCEPTED', '**Status:** SUPERSEDED');
|
|
2088
|
+
|
|
2089
|
+
// Insert Superseded-by line after the Supersedes line (guard against double-insertion on re-run)
|
|
2090
|
+
const supersedesLine = '**Supersedes:** N/A (first formal repo organization standard)';
|
|
2091
|
+
if (content.includes(supersedesLine) && !content.includes('**Superseded by:**')) {
|
|
2092
|
+
content = content.replace(
|
|
2093
|
+
supersedesLine,
|
|
2094
|
+
`${supersedesLine}\n**Superseded by:** ${newADRFilename}`
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
fs.writeFileSync(oldADRPath, content, 'utf8');
|
|
2099
|
+
return true;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function detectMigrationState(projectRoot) {
|
|
2103
|
+
try {
|
|
2104
|
+
// Check recent commits (not just last one) to handle intervening manual commits
|
|
2105
|
+
const recentMsgs = execFileSync(
|
|
2106
|
+
'git', ['log', '-5', '--format=%s'],
|
|
2107
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
2108
|
+
).trim().split('\n');
|
|
2109
|
+
|
|
2110
|
+
// Check in order: most recent first
|
|
2111
|
+
for (const msg of recentMsgs) {
|
|
2112
|
+
if (msg === 'chore: inject frontmatter metadata and update links' ||
|
|
2113
|
+
msg === 'chore: generate governance convention ADR') {
|
|
2114
|
+
return 'complete';
|
|
2115
|
+
}
|
|
2116
|
+
if (msg === 'chore: rename artifacts to governance convention') {
|
|
2117
|
+
return 'renames-done';
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
} catch {
|
|
2121
|
+
// Not a git repo or no commits — treat as fresh
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
return 'fresh';
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// --- Exports ---
|
|
2128
|
+
|
|
2129
|
+
module.exports = {
|
|
2130
|
+
// Constants
|
|
2131
|
+
VALID_CATEGORIES,
|
|
2132
|
+
NAMING_PATTERN,
|
|
2133
|
+
DATED_PATTERN,
|
|
2134
|
+
CATEGORIZED_PATTERN,
|
|
2135
|
+
VALID_STATUSES,
|
|
2136
|
+
// Filename parsing
|
|
2137
|
+
isValidCategory,
|
|
2138
|
+
parseFilename,
|
|
2139
|
+
toLowerKebab,
|
|
2140
|
+
// Directory scanning
|
|
2141
|
+
scanArtifactDirs,
|
|
2142
|
+
// Taxonomy
|
|
2143
|
+
readTaxonomy,
|
|
2144
|
+
// Frontmatter
|
|
2145
|
+
parseFrontmatter,
|
|
2146
|
+
injectFrontmatter,
|
|
2147
|
+
// Schema
|
|
2148
|
+
validateFrontmatterSchema,
|
|
2149
|
+
buildSchemaFields,
|
|
2150
|
+
// Inference
|
|
2151
|
+
ARTIFACT_TYPE_ALIASES,
|
|
2152
|
+
inferArtifactType,
|
|
2153
|
+
inferInitiative,
|
|
2154
|
+
suggestInitiative,
|
|
2155
|
+
suggestDifferentiator,
|
|
2156
|
+
FOLDER_DEFAULT_MAP,
|
|
2157
|
+
getGovernanceState,
|
|
2158
|
+
generateNewFilename,
|
|
2159
|
+
// Git
|
|
2160
|
+
ensureCleanTree,
|
|
2161
|
+
// Manifest
|
|
2162
|
+
getContextClues,
|
|
2163
|
+
getCrossReferences,
|
|
2164
|
+
buildManifestEntry,
|
|
2165
|
+
detectCollisions,
|
|
2166
|
+
generateManifest,
|
|
2167
|
+
formatManifest,
|
|
2168
|
+
// Execution
|
|
2169
|
+
ArtifactMigrationError,
|
|
2170
|
+
executeRenames,
|
|
2171
|
+
verifyHistoryChain,
|
|
2172
|
+
updateLinks,
|
|
2173
|
+
executeInjections,
|
|
2174
|
+
// Interactive & Recovery
|
|
2175
|
+
promptInitiative,
|
|
2176
|
+
resolveAmbiguous,
|
|
2177
|
+
loadResolutionMap,
|
|
2178
|
+
generateRenameMap,
|
|
2179
|
+
detectMigrationState,
|
|
2180
|
+
generateGovernanceADR,
|
|
2181
|
+
supersedePreviousADR
|
|
2182
|
+
};
|