convoke-agents 3.0.4 → 3.1.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.
@@ -0,0 +1,1674 @@
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
+ /**
456
+ * Determine the governance state of a file based on filename convention and frontmatter.
457
+ *
458
+ * @param {string} filename - The filename to check
459
+ * @param {string} fileContent - Raw file content (for frontmatter parsing)
460
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
461
+ * @returns {{state: 'fully-governed'|'half-governed'|'ungoverned'|'invalid-governed'|'ambiguous', fileInitiative: string|null, frontmatterInitiative: string|null, candidates: string[]}}
462
+ */
463
+ function getGovernanceState(filename, fileContent, taxonomy) {
464
+ const typeResult = inferArtifactType(filename, taxonomy);
465
+ const initiativeResult = typeResult.type
466
+ ? inferInitiative(typeResult.remainder, taxonomy)
467
+ : { initiative: null, confidence: 'low', source: 'no-type', candidates: [] };
468
+
469
+ const fileInitiative = initiativeResult.initiative;
470
+
471
+ // Check frontmatter
472
+ let frontmatterInitiative = null;
473
+ try {
474
+ const { data } = parseFrontmatter(fileContent);
475
+ frontmatterInitiative = data.initiative || null;
476
+ } catch {
477
+ // No valid frontmatter — treat as absent
478
+ }
479
+
480
+ // Determine state
481
+ if (typeResult.type === null) {
482
+ return { state: 'ungoverned', fileInitiative, frontmatterInitiative, candidates: [] };
483
+ }
484
+
485
+ // Type matched but initiative ambiguous — distinct from ungoverned
486
+ if (initiativeResult.confidence === 'low') {
487
+ return { state: 'ambiguous', fileInitiative, frontmatterInitiative, candidates: initiativeResult.candidates || [] };
488
+ }
489
+
490
+ if (!frontmatterInitiative) {
491
+ return { state: 'half-governed', fileInitiative, frontmatterInitiative, candidates: [] };
492
+ }
493
+
494
+ if (frontmatterInitiative !== fileInitiative) {
495
+ return { state: 'invalid-governed', fileInitiative, frontmatterInitiative, candidates: [] };
496
+ }
497
+
498
+ return { state: 'fully-governed', fileInitiative, frontmatterInitiative, candidates: [] };
499
+ }
500
+
501
+ /**
502
+ * Generate a new filename following the governance convention.
503
+ * Format: {initiative}-{artifactType}[-{qualifier}][-{date}].md
504
+ *
505
+ * @param {string} filename - Original filename
506
+ * @param {string} initiative - Resolved initiative ID
507
+ * @param {string} artifactType - Resolved artifact type
508
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
509
+ * @returns {string} New filename following convention
510
+ */
511
+ function generateNewFilename(filename, initiative, artifactType, taxonomy) {
512
+ const typeResult = inferArtifactType(filename, taxonomy);
513
+
514
+ // Build qualifier from: HC prefix + remainder after initiative extraction
515
+ const parts = [];
516
+
517
+ // Add HC prefix as qualifier if present
518
+ if (typeResult.hcPrefix) {
519
+ parts.push(typeResult.hcPrefix);
520
+ }
521
+
522
+ // Extract qualifier: remainder minus the initiative segments
523
+ if (typeResult.remainder) {
524
+ const remainderSegments = typeResult.remainder.split('-');
525
+ const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
526
+ const aliasKeys = Object.keys(taxonomy.aliases || {});
527
+
528
+ // Try to find which segments the initiative match consumed
529
+ // Check prefixes (longest first)
530
+ let consumedStart = -1;
531
+ let consumedEnd = -1;
532
+
533
+ // Try prefix matches first (e.g., 'forge-phase-a' → 'forge' consumed at start)
534
+ for (let i = remainderSegments.length; i >= 1; i--) {
535
+ const prefix = remainderSegments.slice(0, i).join('-');
536
+ if (allInitiatives.includes(prefix) || aliasKeys.includes(prefix)) {
537
+ consumedStart = 0;
538
+ consumedEnd = i;
539
+ break;
540
+ }
541
+ }
542
+
543
+ // If no prefix match, try suffix matches (e.g., 'decision-strategy-perimeter' → 'strategy-perimeter' consumed at end)
544
+ if (consumedStart === -1) {
545
+ for (let i = 1; i < remainderSegments.length; i++) {
546
+ const suffix = remainderSegments.slice(i).join('-');
547
+ if (allInitiatives.includes(suffix) || aliasKeys.includes(suffix)) {
548
+ consumedStart = i;
549
+ consumedEnd = remainderSegments.length;
550
+ break;
551
+ }
552
+ }
553
+ }
554
+
555
+ // If still no match, try single first segment
556
+ if (consumedStart === -1) {
557
+ const first = remainderSegments[0];
558
+ if (allInitiatives.includes(first) || aliasKeys.includes(first)) {
559
+ consumedStart = 0;
560
+ consumedEnd = 1;
561
+ }
562
+ }
563
+
564
+ // Build qualifier from unconsumed segments
565
+ if (consumedStart >= 0) {
566
+ const before = remainderSegments.slice(0, consumedStart);
567
+ const after = remainderSegments.slice(consumedEnd);
568
+ const qualifierSegments = [...before, ...after];
569
+ if (qualifierSegments.length > 0) {
570
+ parts.push(qualifierSegments.join('-'));
571
+ }
572
+ } else {
573
+ // No initiative found — entire remainder is qualifier
574
+ parts.push(typeResult.remainder);
575
+ }
576
+ }
577
+
578
+ const qualifier = parts.length > 0 ? parts.join('-') : null;
579
+
580
+ // Build new filename
581
+ let newName = `${initiative}-${artifactType}`;
582
+ if (qualifier) {
583
+ newName += `-${qualifier}`;
584
+ }
585
+ if (typeResult.date) {
586
+ newName += `-${typeResult.date}`;
587
+ }
588
+
589
+ // Preserve original extension (.md or .yaml)
590
+ const extMatch = filename.match(/\.(md|yaml)$/i);
591
+ newName += extMatch ? `.${extMatch[1].toLowerCase()}` : '.md';
592
+
593
+ return newName;
594
+ }
595
+
596
+ // --- Schema Validation ---
597
+
598
+ /** Valid artifact-level status values (closed enum) */
599
+ const VALID_STATUSES = ['draft', 'validated', 'superseded', 'active'];
600
+
601
+ /** ISO 8601 date format: YYYY-MM-DD */
602
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
603
+
604
+ /**
605
+ * Validate frontmatter fields against the governance schema v1.
606
+ *
607
+ * @param {Object} fields - Frontmatter fields to validate
608
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config for initiative/type validation
609
+ * @returns {{valid: boolean, errors: string[]}} Validation result with error messages
610
+ */
611
+ function validateFrontmatterSchema(fields, taxonomy) {
612
+ const errors = [];
613
+
614
+ // Required fields
615
+ if (!fields.initiative) {
616
+ errors.push('Missing required field: initiative');
617
+ }
618
+ if (!fields.artifact_type) {
619
+ errors.push('Missing required field: artifact_type');
620
+ }
621
+ if (!fields.created) {
622
+ errors.push('Missing required field: created');
623
+ }
624
+ if (fields.schema_version === undefined || fields.schema_version === null) {
625
+ errors.push('Missing required field: schema_version');
626
+ }
627
+
628
+ // schema_version must be integer >= 1
629
+ if (fields.schema_version !== undefined && fields.schema_version !== null) {
630
+ if (!Number.isInteger(fields.schema_version) || fields.schema_version < 1) {
631
+ errors.push(`Invalid schema_version "${fields.schema_version}": must be an integer >= 1`);
632
+ }
633
+ }
634
+
635
+ // created must be ISO 8601 date format
636
+ if (fields.created && !DATE_PATTERN.test(fields.created)) {
637
+ errors.push(`Invalid created date "${fields.created}": must be YYYY-MM-DD format`);
638
+ }
639
+
640
+ // status is optional but must be from closed enum if present
641
+ if (fields.status !== undefined && !VALID_STATUSES.includes(fields.status)) {
642
+ errors.push(`Invalid status "${fields.status}": must be one of ${VALID_STATUSES.join(', ')}`);
643
+ }
644
+
645
+ // initiative must exist in taxonomy
646
+ if (fields.initiative && taxonomy) {
647
+ const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
648
+ if (!allInitiatives.includes(fields.initiative)) {
649
+ errors.push(`Initiative "${fields.initiative}" not found in taxonomy (platform or user sections)`);
650
+ }
651
+ }
652
+
653
+ // artifact_type must exist in taxonomy
654
+ if (fields.artifact_type && taxonomy) {
655
+ if (!taxonomy.artifact_types.includes(fields.artifact_type)) {
656
+ errors.push(`Artifact type "${fields.artifact_type}" not found in taxonomy artifact_types list`);
657
+ }
658
+ }
659
+
660
+ return { valid: errors.length === 0, errors };
661
+ }
662
+
663
+ /**
664
+ * Build a complete frontmatter field set conforming to schema v1.
665
+ * Does NOT validate — use validateFrontmatterSchema() for that.
666
+ *
667
+ * @param {string} initiative - Initiative ID from taxonomy
668
+ * @param {string} artifactType - Artifact type from taxonomy
669
+ * @param {Object} [options={}] - Optional overrides (status, created)
670
+ * @param {string} [options.status] - Optional artifact status (draft/validated/superseded/active)
671
+ * @param {string} [options.created] - Optional created date (defaults to today YYYY-MM-DD)
672
+ * @returns {import('./types').FrontmatterSchema} Complete frontmatter fields
673
+ */
674
+ function buildSchemaFields(initiative, artifactType, options = {}) {
675
+ const fields = {
676
+ initiative,
677
+ artifact_type: artifactType,
678
+ created: options.created || new Date().toISOString().split('T')[0],
679
+ schema_version: 1
680
+ };
681
+
682
+ if (options.status !== undefined) {
683
+ fields.status = options.status;
684
+ }
685
+
686
+ return fields;
687
+ }
688
+
689
+ // --- Manifest Generation ---
690
+
691
+ /**
692
+ * Get context clues for a file (first 3 lines + git author/date).
693
+ * Used in dry-run manifest for ambiguous/conflict files.
694
+ *
695
+ * @param {string} filePath - Absolute path to the file
696
+ * @param {string} projectRoot - Absolute path to project root
697
+ * @returns {Promise<{firstLines: string[], gitAuthor: string|null, gitDate: string|null}>}
698
+ */
699
+ async function getContextClues(filePath, projectRoot) {
700
+ let firstLines = [];
701
+ try {
702
+ const content = await fs.readFile(filePath, 'utf8');
703
+ const lines = content.split('\n');
704
+ firstLines = lines.slice(0, 3).map(l => l.trimEnd());
705
+ } catch {
706
+ // File unreadable — return empty lines
707
+ }
708
+
709
+ let gitAuthor = null;
710
+ let gitDate = null;
711
+ try {
712
+ const raw = execFileSync(
713
+ 'git', ['log', '-1', '--format=%an|%as', '--', path.relative(projectRoot, filePath)],
714
+ { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
715
+ ).trim();
716
+ if (raw) {
717
+ const parts = raw.split('|');
718
+ gitAuthor = parts[0] || null;
719
+ gitDate = parts[1] || null;
720
+ }
721
+ } catch {
722
+ // Not tracked in git or git unavailable
723
+ }
724
+
725
+ return { firstLines, gitAuthor, gitDate };
726
+ }
727
+
728
+ /**
729
+ * Find files that reference a target filename via markdown links or bare mentions.
730
+ * Only called when --verbose is set (reads every file in scope).
731
+ *
732
+ * @param {string} targetFilename - The filename to search for references to
733
+ * @param {Array<{filename: string, fullPath: string}>} scopeFiles - All files in scope
734
+ * @param {string} _projectRoot - Project root (unused, reserved for future)
735
+ * @returns {Promise<string[]>} List of filenames that reference the target
736
+ */
737
+ async function getCrossReferences(targetFilename, scopeFiles, _projectRoot) {
738
+ const refs = [];
739
+ for (const file of scopeFiles) {
740
+ if (file.filename === targetFilename) continue;
741
+ if (!file.fullPath.endsWith('.md')) continue;
742
+ try {
743
+ const content = await fs.readFile(file.fullPath, 'utf8');
744
+ // Match: [text](targetFilename), [text](../dir/targetFilename), or bare targetFilename
745
+ if (content.includes(targetFilename)) {
746
+ refs.push(file.filename);
747
+ }
748
+ } catch {
749
+ // Skip unreadable files
750
+ }
751
+ }
752
+ return refs;
753
+ }
754
+
755
+ /**
756
+ * Build a single manifest entry for a file, classifying its action.
757
+ *
758
+ * @param {{filename: string, dir: string, fullPath: string}} fileInfo - File from scanArtifactDirs
759
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
760
+ * @param {string} _projectRoot - Project root (reserved)
761
+ * @returns {Promise<import('./types').ManifestEntry>}
762
+ */
763
+ async function buildManifestEntry(fileInfo, taxonomy, _projectRoot) {
764
+ const { filename, dir, fullPath } = fileInfo;
765
+ const oldPath = `${dir}/${filename}`;
766
+
767
+ // Only process markdown files — YAML and other files are not migration targets
768
+ if (!filename.endsWith('.md')) {
769
+ return {
770
+ oldPath, newPath: null, initiative: null, artifactType: null,
771
+ confidence: 'low', source: 'non-markdown', action: 'SKIP',
772
+ dir, contextClues: null, crossReferences: null, candidates: [],
773
+ collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
774
+ typeConfidence: 'low', typeSource: 'none'
775
+ };
776
+ }
777
+
778
+ let fileContent;
779
+ try {
780
+ fileContent = await fs.readFile(fullPath, 'utf8');
781
+ } catch {
782
+ return {
783
+ oldPath, newPath: null, initiative: null, artifactType: null,
784
+ confidence: 'low', source: 'unreadable', action: 'AMBIGUOUS',
785
+ dir, contextClues: null, crossReferences: null, candidates: [],
786
+ collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
787
+ typeConfidence: 'low', typeSource: 'none'
788
+ };
789
+ }
790
+
791
+ // Single inference pass — getGovernanceState uses inferArtifactType + inferInitiative internally.
792
+ // We call inferArtifactType once here to get typeConfidence/typeSource for manifest display.
793
+ const typeResult = inferArtifactType(filename, taxonomy);
794
+ const govState = getGovernanceState(filename, fileContent, taxonomy);
795
+
796
+ const initConfidence = govState.state === 'ambiguous' || govState.state === 'ungoverned' ? 'low' : 'high';
797
+ const initSource = govState.state === 'ungoverned' ? 'no-type'
798
+ : govState.state === 'ambiguous' ? 'unresolved'
799
+ : govState.fileInitiative ? 'inferred' : 'none';
800
+
801
+ const base = {
802
+ oldPath, dir,
803
+ initiative: govState.fileInitiative,
804
+ artifactType: typeResult.type,
805
+ confidence: initConfidence,
806
+ source: initSource,
807
+ typeConfidence: typeResult.typeConfidence,
808
+ typeSource: typeResult.typeSource,
809
+ contextClues: null,
810
+ crossReferences: null,
811
+ candidates: govState.candidates || [],
812
+ collisionWith: null,
813
+ frontmatterInitiative: govState.frontmatterInitiative,
814
+ fileInitiative: govState.fileInitiative
815
+ };
816
+
817
+ if (govState.state === 'ungoverned') {
818
+ return { ...base, newPath: null, action: 'AMBIGUOUS' };
819
+ }
820
+
821
+ if (govState.state === 'ambiguous') {
822
+ return { ...base, newPath: null, action: 'AMBIGUOUS' };
823
+ }
824
+
825
+ if (govState.state === 'invalid-governed') {
826
+ return { ...base, newPath: null, action: 'CONFLICT' };
827
+ }
828
+
829
+ // Half-governed or fully-governed: type + initiative resolved
830
+ // Compare current filename with governance target to determine action
831
+ let newFilename;
832
+ try {
833
+ newFilename = generateNewFilename(filename, govState.fileInitiative, typeResult.type, taxonomy);
834
+ } catch {
835
+ // generateNewFilename failed — treat as ambiguous rather than aborting the entire manifest
836
+ return { ...base, newPath: null, action: 'AMBIGUOUS' };
837
+ }
838
+ const newPath = `${dir}/${newFilename}`;
839
+
840
+ if (govState.state === 'fully-governed') {
841
+ if (filename === newFilename) {
842
+ return { ...base, newPath: null, action: 'SKIP' };
843
+ }
844
+ return { ...base, newPath, action: 'RENAME' };
845
+ }
846
+
847
+ // half-governed
848
+ if (filename === newFilename) {
849
+ return { ...base, newPath: null, action: 'INJECT_ONLY' };
850
+ }
851
+ return { ...base, newPath, action: 'RENAME' };
852
+ }
853
+
854
+ /**
855
+ * Detect target filename collisions in manifest entries.
856
+ *
857
+ * @param {import('./types').ManifestEntry[]} entries - All manifest entries
858
+ * @returns {Map<string, string[]>} Map of colliding newPath -> list of oldPaths
859
+ */
860
+ function detectCollisions(entries) {
861
+ const targetMap = new Map();
862
+
863
+ // Collect all target filenames (from RENAME entries)
864
+ for (const entry of entries) {
865
+ if (entry.action === 'RENAME' && entry.newPath) {
866
+ if (!targetMap.has(entry.newPath)) {
867
+ targetMap.set(entry.newPath, []);
868
+ }
869
+ targetMap.get(entry.newPath).push(entry.oldPath);
870
+ }
871
+ }
872
+
873
+ // Also check if any target matches an existing file (SKIP/INJECT entries)
874
+ const existingPaths = new Set(
875
+ entries.filter(e => e.action === 'SKIP' || e.action === 'INJECT_ONLY').map(e => e.oldPath)
876
+ );
877
+
878
+ for (const target of targetMap.keys()) {
879
+ if (existingPaths.has(target)) {
880
+ const sources = targetMap.get(target);
881
+ const sentinel = `(existing) ${target}`;
882
+ if (!sources.includes(sentinel)) {
883
+ sources.push(sentinel);
884
+ }
885
+ }
886
+ }
887
+
888
+ // Filter to only actual collisions (more than 1 source)
889
+ const collisions = new Map();
890
+ for (const [target, sources] of targetMap) {
891
+ if (sources.length > 1) {
892
+ collisions.set(target, sources);
893
+ }
894
+ }
895
+
896
+ return collisions;
897
+ }
898
+
899
+ /**
900
+ * Generate the full dry-run manifest for all in-scope artifact directories.
901
+ *
902
+ * @param {string} projectRoot - Absolute path to project root
903
+ * @param {Object} [options={}]
904
+ * @param {string[]} [options.includeDirs=['planning-artifacts','vortex-artifacts','gyre-artifacts']]
905
+ * @param {string[]} [options.excludeDirs=['_archive']]
906
+ * @param {boolean} [options.verbose=false]
907
+ * @returns {Promise<import('./types').ManifestResult>}
908
+ */
909
+ async function generateManifest(projectRoot, options = {}) {
910
+ const {
911
+ includeDirs = ['planning-artifacts', 'vortex-artifacts', 'gyre-artifacts'],
912
+ excludeDirs = ['_archive'],
913
+ verbose = false
914
+ } = options;
915
+
916
+ const taxonomy = readTaxonomy(projectRoot);
917
+ const scopeFiles = await scanArtifactDirs(projectRoot, includeDirs, excludeDirs);
918
+ const entries = [];
919
+
920
+ for (const fileInfo of scopeFiles) {
921
+ const entry = await buildManifestEntry(fileInfo, taxonomy, projectRoot);
922
+ entries.push(entry);
923
+ }
924
+
925
+ // Detect collisions and annotate entries
926
+ const collisions = detectCollisions(entries);
927
+ for (const [target, sources] of collisions) {
928
+ for (const entry of entries) {
929
+ if (entry.newPath === target && entry.action === 'RENAME') {
930
+ entry.collisionWith = sources.filter(s => s !== entry.oldPath);
931
+ }
932
+ }
933
+ }
934
+
935
+ // Gather context clues for AMBIGUOUS and CONFLICT entries
936
+ for (const entry of entries) {
937
+ if (entry.action === 'AMBIGUOUS' || entry.action === 'CONFLICT') {
938
+ const fullPath = path.join(projectRoot, '_bmad-output', entry.oldPath);
939
+ entry.contextClues = await getContextClues(fullPath, projectRoot);
940
+
941
+ if (verbose) {
942
+ entry.crossReferences = await getCrossReferences(
943
+ entry.oldPath.split('/').pop(),
944
+ scopeFiles,
945
+ projectRoot
946
+ );
947
+ }
948
+ }
949
+ }
950
+
951
+ // Build summary
952
+ const summary = { total: entries.length, skip: 0, rename: 0, inject: 0, conflict: 0, ambiguous: 0 };
953
+ for (const entry of entries) {
954
+ switch (entry.action) {
955
+ case 'SKIP': summary.skip++; break;
956
+ case 'RENAME': summary.rename++; break;
957
+ case 'INJECT_ONLY': summary.inject++; break;
958
+ case 'CONFLICT': summary.conflict++; break;
959
+ case 'AMBIGUOUS': summary.ambiguous++; break;
960
+ }
961
+ }
962
+
963
+ return { entries, collisions, summary };
964
+ }
965
+
966
+ /**
967
+ * Format the manifest as a human-readable text report.
968
+ *
969
+ * @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
970
+ * @param {Object} [options={}]
971
+ * @param {boolean} [options.verbose=false]
972
+ * @returns {string} Formatted manifest text
973
+ */
974
+ function formatManifest(manifest, options = {}) {
975
+ const { verbose = false } = options;
976
+ const lines = [];
977
+
978
+ for (const entry of manifest.entries) {
979
+ switch (entry.action) {
980
+ case 'SKIP':
981
+ lines.push(`[SKIP] ${entry.oldPath} -- already governed`);
982
+ break;
983
+
984
+ case 'INJECT_ONLY':
985
+ lines.push(`[INJECT] ${entry.oldPath} -- frontmatter needed`);
986
+ break;
987
+
988
+ case 'RENAME':
989
+ lines.push(`${entry.oldPath} -> ${entry.newPath}`);
990
+ lines.push(` Initiative: ${entry.initiative} (confidence: ${entry.confidence}, source: ${entry.source})`);
991
+ lines.push(` Type: ${entry.artifactType} (confidence: ${entry.typeConfidence || 'high'}, source: ${entry.typeSource || 'prefix'})`);
992
+ if (entry.collisionWith && entry.collisionWith.length > 0) {
993
+ lines.push(` [!] COLLISION: same target as ${entry.collisionWith.join(', ')}`);
994
+ }
995
+ break;
996
+
997
+ case 'CONFLICT':
998
+ lines.push(`[!] ${entry.oldPath} -> CONFLICT (filename says ${entry.fileInitiative}, frontmatter says ${entry.frontmatterInitiative})`);
999
+ lines.push(' ACTION REQUIRED: Resolve initiative conflict before migration');
1000
+ if (entry.contextClues) {
1001
+ for (let i = 0; i < entry.contextClues.firstLines.length; i++) {
1002
+ lines.push(` Line ${i + 1}: "${entry.contextClues.firstLines[i]}"`);
1003
+ }
1004
+ if (entry.contextClues.gitAuthor) {
1005
+ lines.push(` Git author: ${entry.contextClues.gitAuthor} (${entry.contextClues.gitDate})`);
1006
+ }
1007
+ }
1008
+ break;
1009
+
1010
+ case 'AMBIGUOUS': {
1011
+ const typeLabel = entry.artifactType
1012
+ ? `type: ${entry.artifactType}, initiative unknown`
1013
+ : 'cannot infer type or initiative';
1014
+ lines.push(`[!] ${entry.oldPath} -> ??? (ambiguous -- ${typeLabel})`);
1015
+ if (entry.contextClues) {
1016
+ for (let i = 0; i < entry.contextClues.firstLines.length; i++) {
1017
+ lines.push(` Line ${i + 1}: "${entry.contextClues.firstLines[i]}"`);
1018
+ }
1019
+ if (entry.contextClues.gitAuthor) {
1020
+ lines.push(` Git author: ${entry.contextClues.gitAuthor} (${entry.contextClues.gitDate})`);
1021
+ }
1022
+ if (verbose && entry.crossReferences && entry.crossReferences.length > 0) {
1023
+ lines.push(` Referenced by: ${entry.crossReferences.join(', ')}`);
1024
+ }
1025
+ }
1026
+ if (entry.candidates.length > 0) {
1027
+ lines.push(` Candidates: ${entry.candidates.join(', ')}`);
1028
+ }
1029
+ lines.push(' ACTION REQUIRED: Specify initiative for this file');
1030
+ break;
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // Summary footer
1036
+ const s = manifest.summary;
1037
+ lines.push('');
1038
+ lines.push(`--- Manifest Summary ---`);
1039
+ lines.push(`Total: ${s.total} | Rename: ${s.rename} | Skip: ${s.skip} | Inject: ${s.inject} | Conflict: ${s.conflict} | Ambiguous: ${s.ambiguous}`);
1040
+
1041
+ if (manifest.collisions.size > 0) {
1042
+ lines.push(`[!] ${manifest.collisions.size} filename collision(s) detected -- resolve before executing`);
1043
+ }
1044
+
1045
+ return lines.join('\n');
1046
+ }
1047
+
1048
+ // --- Migration Execution ---
1049
+
1050
+ /**
1051
+ * Structured error for migration failures. Named ArtifactMigrationError to avoid
1052
+ * collision with MigrationError in scripts/update/lib/migration-runner.js.
1053
+ *
1054
+ * @property {string} file - Which file caused the error
1055
+ * @property {'rename'|'inject'} phase - Drives programmatic rollback target
1056
+ * @property {boolean} recoverable - Can re-run fix this?
1057
+ */
1058
+ class ArtifactMigrationError extends Error {
1059
+ constructor(message, { file = null, phase, recoverable = true } = {}) {
1060
+ super(message);
1061
+ this.name = 'ArtifactMigrationError';
1062
+ this.file = file;
1063
+ this.phase = phase;
1064
+ this.recoverable = recoverable;
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * Execute all renames from a manifest as a single atomic git commit.
1070
+ * If any git mv fails, rolls back ALL renames via git reset --hard HEAD.
1071
+ *
1072
+ * @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
1073
+ * @param {string} projectRoot - Absolute path to project root
1074
+ * @returns {{renamedCount: number, commitSha: string}} Result with count and commit SHA
1075
+ * @throws {ArtifactMigrationError} On collision detection or git mv failure (after rollback)
1076
+ */
1077
+ function executeRenames(manifest, projectRoot) {
1078
+ const renameEntries = manifest.entries.filter(e => e.action === 'RENAME');
1079
+
1080
+ if (renameEntries.length === 0) {
1081
+ return { renamedCount: 0, commitSha: null };
1082
+ }
1083
+
1084
+ // Pre-flight: refuse to proceed if collisions exist
1085
+ const colliding = renameEntries.filter(e => e.collisionWith && e.collisionWith.length > 0);
1086
+ if (colliding.length > 0) {
1087
+ const details = colliding.map(e => ` ${e.oldPath} -> ${e.newPath} (collides with ${e.collisionWith.join(', ')})`).join('\n');
1088
+ throw new ArtifactMigrationError(
1089
+ `Cannot execute renames: ${colliding.length} filename collision(s) detected.\n${details}`,
1090
+ { phase: 'rename', recoverable: false }
1091
+ );
1092
+ }
1093
+
1094
+ const outputDir = path.join(projectRoot, '_bmad-output');
1095
+
1096
+ // Execute all git mv operations
1097
+ for (const entry of renameEntries) {
1098
+ const oldFull = path.join(outputDir, entry.oldPath);
1099
+ const newFull = path.join(outputDir, entry.newPath);
1100
+
1101
+ try {
1102
+ execFileSync('git', ['mv', oldFull, newFull], { cwd: projectRoot, stdio: 'pipe' });
1103
+ } catch (err) {
1104
+ // Rollback ALL renames done so far
1105
+ let rollbackOk = false;
1106
+ try {
1107
+ execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
1108
+ rollbackOk = true;
1109
+ } catch { /* rollback failed — tree is dirty */ }
1110
+
1111
+ throw new ArtifactMigrationError(
1112
+ `git mv failed for ${entry.oldPath} -> ${entry.newPath}: ${err.message}`,
1113
+ { file: entry.oldPath, phase: 'rename', recoverable: rollbackOk }
1114
+ );
1115
+ }
1116
+ }
1117
+
1118
+ // Commit all renames as a single atomic commit (git mv already stages changes)
1119
+ try {
1120
+ execFileSync(
1121
+ 'git', ['commit', '-m', 'chore: rename artifacts to governance convention'],
1122
+ { cwd: projectRoot, stdio: 'pipe' }
1123
+ );
1124
+ } catch (err) {
1125
+ // Commit failed after all git mv succeeded — rollback all renames
1126
+ let rollbackOk = false;
1127
+ try {
1128
+ execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
1129
+ rollbackOk = true;
1130
+ } catch { /* rollback failed */ }
1131
+
1132
+ throw new ArtifactMigrationError(
1133
+ `git commit failed after renames: ${err.message}`,
1134
+ { phase: 'rename', recoverable: rollbackOk }
1135
+ );
1136
+ }
1137
+
1138
+ let commitSha = null;
1139
+ try {
1140
+ const shaOutput = execFileSync(
1141
+ 'git', ['rev-parse', 'HEAD'],
1142
+ { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
1143
+ );
1144
+ commitSha = (typeof shaOutput === 'string' ? shaOutput : shaOutput.toString('utf8')).trim();
1145
+ } catch {
1146
+ // Commit succeeded but SHA retrieval failed — non-fatal
1147
+ }
1148
+
1149
+ return { renamedCount: renameEntries.length, commitSha };
1150
+ }
1151
+
1152
+ /**
1153
+ * Verify git history chain is preserved for a sample of renamed files.
1154
+ * Informational only — does NOT rollback on failure.
1155
+ *
1156
+ * @param {import('./types').ManifestEntry[]} renamedEntries - Entries that were renamed
1157
+ * @param {string} projectRoot - Absolute path to project root
1158
+ * @returns {{verified: number, failed: string[]}} Verification result
1159
+ */
1160
+ function verifyHistoryChain(renamedEntries, projectRoot) {
1161
+ const sample = renamedEntries.slice(0, 5);
1162
+ let verified = 0;
1163
+ const failed = [];
1164
+
1165
+ for (const entry of sample) {
1166
+ const fullPath = path.join(projectRoot, '_bmad-output', entry.newPath);
1167
+ try {
1168
+ const log = execFileSync(
1169
+ 'git', ['log', '--follow', '--oneline', '-3', '--', fullPath],
1170
+ { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
1171
+ ).trim();
1172
+
1173
+ const lines = log.split('\n').filter(Boolean);
1174
+ if (lines.length >= 2) {
1175
+ verified++;
1176
+ } else {
1177
+ failed.push(entry.newPath);
1178
+ }
1179
+ } catch {
1180
+ failed.push(entry.newPath);
1181
+ }
1182
+ }
1183
+
1184
+ return { verified, failed };
1185
+ }
1186
+
1187
+ /**
1188
+ * Update internal markdown links in all .md files within scope after renames.
1189
+ * Handles 4 patterns: [text](file.md), [text](./file.md), [text](../dir/file.md),
1190
+ * and frontmatter inputDocuments arrays. Preserves anchor fragments.
1191
+ *
1192
+ * @param {Map<string, string>} oldToNewMap - Map of old basenames to new basenames
1193
+ * @param {string[]} scopeDirs - Directory names to scan (relative to _bmad-output/)
1194
+ * @param {string} projectRoot - Absolute path to project root
1195
+ * @returns {Promise<{updatedFiles: number, updatedLinks: number}>}
1196
+ */
1197
+ async function updateLinks(oldToNewMap, scopeDirs, projectRoot) {
1198
+ const allFiles = await scanArtifactDirs(projectRoot, scopeDirs, ['_archive']);
1199
+ let updatedFiles = 0;
1200
+ let updatedLinks = 0;
1201
+
1202
+ for (const file of allFiles) {
1203
+ if (!file.fullPath.endsWith('.md')) continue;
1204
+
1205
+ const original = fs.readFileSync(file.fullPath, 'utf8');
1206
+ let content = original;
1207
+ let fileLinks = 0;
1208
+
1209
+ // Parse frontmatter to handle inputDocuments arrays
1210
+ const parsed = matter(content);
1211
+ let fmChanged = false;
1212
+ if (parsed.data && parsed.data.inputDocuments && Array.isArray(parsed.data.inputDocuments)) {
1213
+ parsed.data.inputDocuments = parsed.data.inputDocuments.map(doc => {
1214
+ if (typeof doc !== 'string') return doc;
1215
+ for (const [oldName, newName] of oldToNewMap) {
1216
+ // Exact match or path-suffix match (e.g., "dir/oldname.md") — prevents substring corruption
1217
+ if (doc === oldName || doc.endsWith('/' + oldName)) {
1218
+ fmChanged = true;
1219
+ fileLinks++;
1220
+ return doc === oldName ? newName : doc.slice(0, doc.length - oldName.length) + newName;
1221
+ }
1222
+ }
1223
+ return doc;
1224
+ });
1225
+ }
1226
+
1227
+ // Reassemble content if frontmatter changed
1228
+ if (fmChanged) {
1229
+ content = matter.stringify(parsed.content, parsed.data);
1230
+ }
1231
+
1232
+ // Update markdown link patterns in body content
1233
+ for (const [oldName, newName] of oldToNewMap) {
1234
+ // Escape dots for regex
1235
+ const escaped = oldName.replace(/\./g, '\\.');
1236
+
1237
+ // Patterns 1+2: [text](oldname.md) or [text](./oldname.md) with optional anchor
1238
+ const directPattern = new RegExp(
1239
+ `(\\[[^\\]]*\\]\\()(\\.\\/)?${escaped}(#[^)]*)?\\)`,
1240
+ 'g'
1241
+ );
1242
+
1243
+ // Pattern 3: [text](../dir/oldname.md) with optional anchor — replace only the filename
1244
+ const parentDirPattern = new RegExp(
1245
+ `(\\[[^\\]]*\\]\\([^)]*\\/)${escaped}(#[^)]*)?\\)`,
1246
+ 'g'
1247
+ );
1248
+
1249
+ let bodyChanges = 0;
1250
+ content = content.replace(directPattern, (_m, prefix, dotSlash, anchor) => {
1251
+ bodyChanges++;
1252
+ return `${prefix}${dotSlash || ''}${newName}${anchor || ''})`;
1253
+ });
1254
+ content = content.replace(parentDirPattern, (_m, prefix, anchor) => {
1255
+ bodyChanges++;
1256
+ return `${prefix}${newName}${anchor || ''})`;
1257
+ });
1258
+ fileLinks += bodyChanges;
1259
+ }
1260
+
1261
+ if (content !== original) {
1262
+ fs.writeFileSync(file.fullPath, content, 'utf8');
1263
+ updatedFiles++;
1264
+ updatedLinks += fileLinks;
1265
+ }
1266
+ }
1267
+
1268
+ return { updatedFiles, updatedLinks };
1269
+ }
1270
+
1271
+ /**
1272
+ * Execute commit 2: inject frontmatter into renamed files and update links.
1273
+ * Runs AFTER executeRenames (commit 1) has completed.
1274
+ *
1275
+ * @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
1276
+ * @param {string} projectRoot - Absolute path to project root
1277
+ * @param {string[]} scopeDirs - Scope directories for link scanning
1278
+ * @returns {Promise<{injectedCount: number, linkUpdates: {updatedFiles: number, updatedLinks: number}, conflictCount: number, commitSha: string|null}>}
1279
+ * @throws {ArtifactMigrationError} On write failure (after rollback to commit 1)
1280
+ */
1281
+ async function executeInjections(manifest, projectRoot, scopeDirs) {
1282
+ const renameEntries = manifest.entries.filter(e => e.action === 'RENAME');
1283
+ let injectedCount = 0;
1284
+ let conflictCount = 0;
1285
+ const outputDir = path.join(projectRoot, '_bmad-output');
1286
+
1287
+ // Build old->new basename map for link updating
1288
+ const oldToNewMap = new Map();
1289
+ for (const entry of renameEntries) {
1290
+ const oldBasename = entry.oldPath.split('/').pop();
1291
+ const newBasename = entry.newPath.split('/').pop();
1292
+ if (oldBasename !== newBasename) {
1293
+ oldToNewMap.set(oldBasename, newBasename);
1294
+ }
1295
+ }
1296
+
1297
+ // Inject frontmatter into each renamed file
1298
+ for (const entry of renameEntries) {
1299
+ const filePath = path.join(outputDir, entry.newPath);
1300
+ try {
1301
+ const content = fs.readFileSync(filePath, 'utf8');
1302
+ const fields = buildSchemaFields(entry.initiative, entry.artifactType);
1303
+ const result = injectFrontmatter(content, fields);
1304
+
1305
+ // Log conflicts
1306
+ for (const c of result.conflicts) {
1307
+ console.warn(` Warning: Skipping field "${c.field}" in ${entry.newPath}: existing value "${c.existingValue}" differs from proposed "${c.newValue}"`);
1308
+ conflictCount++;
1309
+ }
1310
+
1311
+ fs.writeFileSync(filePath, result.content, 'utf8');
1312
+ injectedCount++;
1313
+ } catch (err) {
1314
+ // Write failure — rollback to commit 1
1315
+ let rollbackOk = false;
1316
+ try {
1317
+ execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
1318
+ rollbackOk = true;
1319
+ } catch { /* rollback failed */ }
1320
+
1321
+ throw new ArtifactMigrationError(
1322
+ `Failed to inject frontmatter into ${entry.newPath}: ${err.message}`,
1323
+ { file: entry.newPath, phase: 'inject', recoverable: rollbackOk }
1324
+ );
1325
+ }
1326
+ }
1327
+
1328
+ // Update internal links across all scoped .md files
1329
+ const linkUpdates = await updateLinks(oldToNewMap, scopeDirs, projectRoot);
1330
+
1331
+ // Generate rename map (committed with injection phase)
1332
+ const renameMapContent = generateRenameMap(renameEntries);
1333
+ const renameMapPath = path.join(outputDir, 'planning-artifacts', 'artifact-rename-map.md');
1334
+ fs.writeFileSync(renameMapPath, renameMapContent, 'utf8');
1335
+
1336
+ // Stage and commit (scoped to _bmad-output/)
1337
+ try {
1338
+ execFileSync('git', ['add', '_bmad-output/'], { cwd: projectRoot, stdio: 'pipe' });
1339
+ execFileSync(
1340
+ 'git', ['commit', '-m', 'chore: inject frontmatter metadata and update links'],
1341
+ { cwd: projectRoot, stdio: 'pipe' }
1342
+ );
1343
+ } catch (err) {
1344
+ let rollbackOk = false;
1345
+ try {
1346
+ execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
1347
+ rollbackOk = true;
1348
+ } catch { /* rollback failed */ }
1349
+
1350
+ throw new ArtifactMigrationError(
1351
+ `git commit failed after injections: ${err.message}`,
1352
+ { phase: 'inject', recoverable: rollbackOk }
1353
+ );
1354
+ }
1355
+
1356
+ let commitSha = null;
1357
+ try {
1358
+ const shaOutput = execFileSync(
1359
+ 'git', ['rev-parse', 'HEAD'],
1360
+ { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
1361
+ );
1362
+ commitSha = (typeof shaOutput === 'string' ? shaOutput : shaOutput.toString('utf8')).trim();
1363
+ } catch {
1364
+ // Non-fatal — commit succeeded
1365
+ }
1366
+
1367
+ return { injectedCount, linkUpdates, conflictCount, commitSha };
1368
+ }
1369
+
1370
+ /**
1371
+ * Prompt operator for initiative assignment on a single ambiguous file.
1372
+ * Exported for mocking in tests — tests should NEVER interact with real readline.
1373
+ *
1374
+ * @param {string} filename - The ambiguous filename
1375
+ * @param {string[]} candidates - Possible initiative matches
1376
+ * @returns {Promise<string>} Selected initiative or 'skip'
1377
+ */
1378
+ async function promptInitiative(filename, candidates) {
1379
+ const readline = require('readline');
1380
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1381
+ const options = [...candidates, 'skip'].join('/');
1382
+ return new Promise(resolve => {
1383
+ let resolved = false;
1384
+ const done = (value) => { if (!resolved) { resolved = true; resolve(value); } };
1385
+ rl.on('close', () => done('skip'));
1386
+ rl.question(`Assign initiative for ${filename} [${options}]: `, answer => {
1387
+ rl.close();
1388
+ const trimmed = (answer || '').trim().toLowerCase();
1389
+ if (trimmed === 'skip' || candidates.includes(trimmed)) {
1390
+ done(trimmed);
1391
+ } else {
1392
+ done('skip');
1393
+ }
1394
+ });
1395
+ });
1396
+ }
1397
+
1398
+ /**
1399
+ * Resolve ambiguous manifest entries interactively or auto-skip in force mode.
1400
+ * Mutates manifest entries in-place.
1401
+ *
1402
+ * @param {import('./types').ManifestResult} manifest - Manifest to resolve
1403
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy for filename generation
1404
+ * @param {string} _projectRoot - Project root (reserved)
1405
+ * @param {Object} [options={}]
1406
+ * @param {boolean} [options.force=false] - Auto-skip all ambiguous in force mode
1407
+ * @returns {Promise<{resolved: number, skipped: number}>}
1408
+ */
1409
+ async function resolveAmbiguous(manifest, taxonomy, _projectRoot, options = {}) {
1410
+ const { force = false, promptFn = promptInitiative } = options;
1411
+ let resolved = 0;
1412
+ let skipped = 0;
1413
+
1414
+ for (const entry of manifest.entries) {
1415
+ if (entry.action !== 'AMBIGUOUS') continue;
1416
+
1417
+ // Non-resolvable: no type or no candidates — auto-skip
1418
+ if (!entry.artifactType || !entry.candidates || entry.candidates.length === 0) {
1419
+ entry.action = 'SKIP';
1420
+ skipped++;
1421
+ continue;
1422
+ }
1423
+
1424
+ // Force mode: auto-skip all ambiguous
1425
+ if (force) {
1426
+ entry.action = 'SKIP';
1427
+ skipped++;
1428
+ continue;
1429
+ }
1430
+
1431
+ // Interactive prompt
1432
+ const filename = entry.oldPath.split('/').pop();
1433
+ const choice = await promptFn(filename, entry.candidates);
1434
+
1435
+ if (choice === 'skip') {
1436
+ entry.action = 'SKIP';
1437
+ skipped++;
1438
+ } else {
1439
+ entry.initiative = choice;
1440
+ const newFilename = generateNewFilename(filename, choice, entry.artifactType, taxonomy);
1441
+ entry.newPath = `${entry.dir}/${newFilename}`;
1442
+ entry.action = 'RENAME';
1443
+ entry.confidence = 'high';
1444
+ entry.source = 'operator';
1445
+ resolved++;
1446
+ }
1447
+ }
1448
+
1449
+ // Update summary counts
1450
+ manifest.summary.rename = manifest.entries.filter(e => e.action === 'RENAME').length;
1451
+ manifest.summary.skip = manifest.entries.filter(e => e.action === 'SKIP').length;
1452
+ manifest.summary.ambiguous = manifest.entries.filter(e => e.action === 'AMBIGUOUS').length;
1453
+
1454
+ return { resolved, skipped };
1455
+ }
1456
+
1457
+ /**
1458
+ * Generate artifact-rename-map.md content as a markdown table.
1459
+ *
1460
+ * @param {import('./types').ManifestEntry[]} renamedEntries - Entries that were renamed
1461
+ * @returns {string} Markdown content for the rename map file
1462
+ */
1463
+ function generateRenameMap(renamedEntries) {
1464
+ const date = new Date().toISOString().split('T')[0];
1465
+ const lines = [
1466
+ `# Artifact Rename Map`,
1467
+ '',
1468
+ `**Generated:** ${date}`,
1469
+ `**Total renamed:** ${renamedEntries.length}`,
1470
+ '',
1471
+ '| Old Path | New Path |',
1472
+ '|----------|----------|'
1473
+ ];
1474
+
1475
+ for (const entry of renamedEntries) {
1476
+ lines.push(`| ${entry.oldPath} | ${entry.newPath} |`);
1477
+ }
1478
+
1479
+ return lines.join('\n') + '\n';
1480
+ }
1481
+
1482
+ /**
1483
+ * Detect the current migration state for idempotent recovery.
1484
+ * Uses commit message as primary signal (inference engine can't recognize
1485
+ * initiative-first filenames after rename — see ag-3-3 Dev Notes).
1486
+ *
1487
+ * @param {string} projectRoot - Absolute path to project root
1488
+ * @returns {'complete'|'renames-done'|'fresh'} Current migration state
1489
+ */
1490
+ /**
1491
+ * Generate the content for the new governance convention ADR.
1492
+ *
1493
+ * @param {string} date - ISO date string (YYYY-MM-DD)
1494
+ * @param {{renamedCount: number, injectedCount: number, linksUpdated: number, scopeDirs: string[]}} migrationStats
1495
+ * @returns {string} Markdown content for the ADR file
1496
+ */
1497
+ function generateGovernanceADR(date, migrationStats = {}) {
1498
+ const { renamedCount = 0, injectedCount = 0, linksUpdated = 0, scopeDirs = [] } = migrationStats;
1499
+ return `# Architecture Decision Record: Artifact Governance Convention
1500
+
1501
+ **Status:** ACCEPTED
1502
+ **Date:** ${date}
1503
+ **Decision Makers:** Convoke migration tool
1504
+ **Supersedes:** adr-repo-organization-conventions-2026-03-22.md
1505
+
1506
+ ---
1507
+
1508
+ ## Context
1509
+
1510
+ 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.
1511
+
1512
+ ## Decision
1513
+
1514
+ All artifacts within \`_bmad-output/\` follow the governance naming convention:
1515
+
1516
+ \`\`\`
1517
+ {initiative}-{artifact_type}[-{qualifier}][-{date}].md
1518
+ \`\`\`
1519
+
1520
+ **Examples:**
1521
+ - \`gyre-prd.md\` (initiative: gyre, type: prd)
1522
+ - \`helm-lean-persona-2026-04-04.md\` (initiative: helm, type: lean-persona, date)
1523
+ - \`forge-problem-def-hc2-2026-03-21.md\` (initiative: forge, type: problem-def, qualifier: hc2, date)
1524
+
1525
+ ## Taxonomy
1526
+
1527
+ **Platform initiatives (8):** vortex, gyre, bmm, forge, helm, enhance, loom, convoke
1528
+
1529
+ **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
1530
+
1531
+ **Aliases (migration-specific):** Historical name variants mapped to canonical initiative IDs during migration (e.g., strategy-perimeter -> helm, team-factory -> loom).
1532
+
1533
+ ## Frontmatter Schema v1
1534
+
1535
+ Every governed artifact includes YAML frontmatter with these required fields:
1536
+
1537
+ \`\`\`yaml
1538
+ ---
1539
+ initiative: gyre # Required. From taxonomy.yaml
1540
+ artifact_type: prd # Required. From taxonomy.yaml
1541
+ created: 2026-04-06 # Required. ISO 8601 date
1542
+ schema_version: 1 # Required. Integer >= 1
1543
+ ---
1544
+ \`\`\`
1545
+
1546
+ Existing frontmatter fields are preserved — migration adds fields, never overwrites.
1547
+
1548
+ ## Migration Scope
1549
+
1550
+ - **Directories:** ${scopeDirs.length > 0 ? scopeDirs.join(', ') : 'planning-artifacts, vortex-artifacts, gyre-artifacts'}
1551
+ - **Files renamed:** ${renamedCount}
1552
+ - **Frontmatter injected:** ${injectedCount}
1553
+ - **Links updated:** ${linksUpdated}
1554
+ - **Archive excluded:** \`_bmad-output/_archive/\` always excluded (FR50)
1555
+
1556
+ ## Consequences
1557
+
1558
+ - All artifacts are discoverable by initiative and type via filename convention
1559
+ - Automated portfolio tooling can infer initiative state from artifact metadata
1560
+ - \`git log --follow\` preserves full history for renamed files
1561
+ - The previous convention (type-first: \`prd-gyre.md\`) is superseded
1562
+ `;
1563
+ }
1564
+
1565
+ /**
1566
+ * Update the previous ADR's status to SUPERSEDED and add a Superseded-by reference.
1567
+ *
1568
+ * @param {string} projectRoot - Absolute path to project root
1569
+ * @param {string} newADRFilename - Filename of the new ADR (e.g., 'adr-artifact-governance-convention-2026-04-06.md')
1570
+ * @returns {boolean} true if updated, false if old ADR not found
1571
+ */
1572
+ function supersedePreviousADR(projectRoot, newADRFilename) {
1573
+ const oldADRPath = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'adr-repo-organization-conventions-2026-03-22.md');
1574
+
1575
+ if (!fs.existsSync(oldADRPath)) {
1576
+ console.warn('Warning: Previous ADR not found at expected path. Skipping supersession.');
1577
+ return false;
1578
+ }
1579
+
1580
+ let content = fs.readFileSync(oldADRPath, 'utf8');
1581
+
1582
+ // Replace status
1583
+ content = content.replace('**Status:** ACCEPTED', '**Status:** SUPERSEDED');
1584
+
1585
+ // Insert Superseded-by line after the Supersedes line (guard against double-insertion on re-run)
1586
+ const supersedesLine = '**Supersedes:** N/A (first formal repo organization standard)';
1587
+ if (content.includes(supersedesLine) && !content.includes('**Superseded by:**')) {
1588
+ content = content.replace(
1589
+ supersedesLine,
1590
+ `${supersedesLine}\n**Superseded by:** ${newADRFilename}`
1591
+ );
1592
+ }
1593
+
1594
+ fs.writeFileSync(oldADRPath, content, 'utf8');
1595
+ return true;
1596
+ }
1597
+
1598
+ function detectMigrationState(projectRoot) {
1599
+ try {
1600
+ // Check recent commits (not just last one) to handle intervening manual commits
1601
+ const recentMsgs = execFileSync(
1602
+ 'git', ['log', '-5', '--format=%s'],
1603
+ { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
1604
+ ).trim().split('\n');
1605
+
1606
+ // Check in order: most recent first
1607
+ for (const msg of recentMsgs) {
1608
+ if (msg === 'chore: inject frontmatter metadata and update links' ||
1609
+ msg === 'chore: generate governance convention ADR') {
1610
+ return 'complete';
1611
+ }
1612
+ if (msg === 'chore: rename artifacts to governance convention') {
1613
+ return 'renames-done';
1614
+ }
1615
+ }
1616
+ } catch {
1617
+ // Not a git repo or no commits — treat as fresh
1618
+ }
1619
+
1620
+ return 'fresh';
1621
+ }
1622
+
1623
+ // --- Exports ---
1624
+
1625
+ module.exports = {
1626
+ // Constants
1627
+ VALID_CATEGORIES,
1628
+ NAMING_PATTERN,
1629
+ DATED_PATTERN,
1630
+ CATEGORIZED_PATTERN,
1631
+ VALID_STATUSES,
1632
+ // Filename parsing
1633
+ isValidCategory,
1634
+ parseFilename,
1635
+ toLowerKebab,
1636
+ // Directory scanning
1637
+ scanArtifactDirs,
1638
+ // Taxonomy
1639
+ readTaxonomy,
1640
+ // Frontmatter
1641
+ parseFrontmatter,
1642
+ injectFrontmatter,
1643
+ // Schema
1644
+ validateFrontmatterSchema,
1645
+ buildSchemaFields,
1646
+ // Inference
1647
+ ARTIFACT_TYPE_ALIASES,
1648
+ inferArtifactType,
1649
+ inferInitiative,
1650
+ getGovernanceState,
1651
+ generateNewFilename,
1652
+ // Git
1653
+ ensureCleanTree,
1654
+ // Manifest
1655
+ getContextClues,
1656
+ getCrossReferences,
1657
+ buildManifestEntry,
1658
+ detectCollisions,
1659
+ generateManifest,
1660
+ formatManifest,
1661
+ // Execution
1662
+ ArtifactMigrationError,
1663
+ executeRenames,
1664
+ verifyHistoryChain,
1665
+ updateLinks,
1666
+ executeInjections,
1667
+ // Interactive & Recovery
1668
+ promptInitiative,
1669
+ resolveAmbiguous,
1670
+ generateRenameMap,
1671
+ detectMigrationState,
1672
+ generateGovernanceADR,
1673
+ supersedePreviousADR
1674
+ };