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.
Files changed (92) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +14 -13
  3. package/_bmad/bme/_artifacts/config.yaml +15 -0
  4. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
  5. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
  6. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
  7. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
  8. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
  9. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
  10. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
  11. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
  12. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
  13. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
  14. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
  15. package/_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md +506 -0
  16. package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
  17. package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
  18. package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
  19. package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
  20. package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
  21. package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
  22. package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
  23. package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
  24. package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
  25. package/_bmad/bme/_team-factory/config.yaml +13 -0
  26. package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
  27. package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
  28. package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
  29. package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
  30. package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
  31. package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
  32. package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
  33. package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
  34. package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
  35. package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
  36. package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
  37. package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
  38. package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
  39. package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
  40. package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
  41. package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
  42. package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
  43. package/_bmad/bme/_team-factory/module-help.csv +3 -0
  44. package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
  45. package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
  46. package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
  47. package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
  48. package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
  49. package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
  50. package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
  51. package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
  52. package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
  53. package/_bmad/bme/_vortex/config.yaml +4 -4
  54. package/_bmad/bme/_vortex/guides/VORTEX-TEAM-GUIDE.md +441 -0
  55. package/package.json +17 -8
  56. package/scripts/archive.js +26 -45
  57. package/scripts/convoke-check.js +88 -0
  58. package/scripts/convoke-doctor.js +303 -4
  59. package/scripts/install-gyre-agents.js +0 -0
  60. package/scripts/lib/artifact-utils.js +2182 -0
  61. package/scripts/lib/portfolio/formatters/markdown-formatter.js +40 -0
  62. package/scripts/lib/portfolio/formatters/terminal-formatter.js +56 -0
  63. package/scripts/lib/portfolio/portfolio-engine.js +572 -0
  64. package/scripts/lib/portfolio/rules/artifact-chain-rule.js +156 -0
  65. package/scripts/lib/portfolio/rules/conflict-resolver.js +99 -0
  66. package/scripts/lib/portfolio/rules/frontmatter-rule.js +42 -0
  67. package/scripts/lib/portfolio/rules/git-recency-rule.js +69 -0
  68. package/scripts/lib/types.js +122 -0
  69. package/scripts/migrate-artifacts.js +439 -0
  70. package/scripts/portability/catalog-generator.js +353 -0
  71. package/scripts/portability/classify-skills.js +646 -0
  72. package/scripts/portability/convoke-export.js +522 -0
  73. package/scripts/portability/export-engine.js +1133 -0
  74. package/scripts/portability/generate-adapters.js +79 -0
  75. package/scripts/portability/manifest-csv.js +147 -0
  76. package/scripts/portability/seed-catalog-repo.js +427 -0
  77. package/scripts/portability/templates/canonical-example.md +102 -0
  78. package/scripts/portability/templates/canonical-format.md +218 -0
  79. package/scripts/portability/templates/readme-template.md +72 -0
  80. package/scripts/portability/test-constants.js +42 -0
  81. package/scripts/portability/validate-classification.js +529 -0
  82. package/scripts/portability/validate-exports.js +348 -0
  83. package/scripts/update/lib/agent-registry.js +35 -0
  84. package/scripts/update/lib/config-merger.js +140 -10
  85. package/scripts/update/lib/migration-runner.js +1 -1
  86. package/scripts/update/lib/refresh-installation.js +293 -8
  87. package/scripts/update/lib/taxonomy-merger.js +138 -0
  88. package/scripts/update/lib/utils.js +27 -1
  89. package/scripts/update/lib/validator.js +114 -4
  90. package/scripts/update/migrations/2.0.x-to-3.1.0.js +50 -0
  91. package/scripts/update/migrations/3.0.x-to-3.1.0.js +41 -0
  92. 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
+ };