convoke-agents 3.0.3 → 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.
- package/CHANGELOG.md +29 -0
- package/README.md +14 -13
- package/_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md +506 -0
- package/_bmad/bme/_vortex/config.yaml +1 -1
- package/_bmad/bme/_vortex/guides/VORTEX-TEAM-GUIDE.md +441 -0
- package/package.json +7 -3
- package/scripts/archive.js +26 -45
- package/scripts/convoke-check.js +88 -0
- package/scripts/convoke-doctor.js +138 -6
- package/scripts/lib/artifact-utils.js +1674 -0
- package/scripts/lib/portfolio/formatters/markdown-formatter.js +40 -0
- package/scripts/lib/portfolio/formatters/terminal-formatter.js +56 -0
- package/scripts/lib/portfolio/portfolio-engine.js +305 -0
- package/scripts/lib/portfolio/rules/artifact-chain-rule.js +126 -0
- package/scripts/lib/portfolio/rules/conflict-resolver.js +77 -0
- package/scripts/lib/portfolio/rules/frontmatter-rule.js +42 -0
- package/scripts/lib/portfolio/rules/git-recency-rule.js +69 -0
- package/scripts/lib/types.js +122 -0
- package/scripts/migrate-artifacts.js +380 -0
- package/scripts/update/lib/migration-runner.js +1 -1
- package/scripts/update/lib/taxonomy-merger.js +138 -0
- package/scripts/update/migrations/2.0.x-to-3.1.0.js +50 -0
- package/scripts/update/migrations/3.0.x-to-3.1.0.js +41 -0
- package/scripts/update/migrations/registry.js +14 -0
|
@@ -0,0 +1,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
|
+
};
|