convoke-agents 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +37 -10
  3. package/_bmad/bme/_artifacts/config.yaml +15 -0
  4. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
  5. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
  6. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
  7. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
  8. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
  9. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
  10. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
  11. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
  12. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
  13. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
  14. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
  15. package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
  16. package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
  17. package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
  18. package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
  19. package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
  20. package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
  21. package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
  22. package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
  23. package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
  24. package/_bmad/bme/_team-factory/config.yaml +13 -0
  25. package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
  26. package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
  27. package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
  28. package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
  29. package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
  30. package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
  31. package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
  32. package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
  33. package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
  34. package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
  35. package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
  36. package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
  37. package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
  38. package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
  39. package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
  40. package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
  41. package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
  42. package/_bmad/bme/_team-factory/module-help.csv +3 -0
  43. package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
  44. package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
  45. package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
  46. package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
  47. package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
  48. package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
  49. package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
  50. package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
  51. package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
  52. package/_bmad/bme/_vortex/config.yaml +4 -4
  53. package/package.json +13 -7
  54. package/scripts/convoke-doctor.js +172 -1
  55. package/scripts/install-gyre-agents.js +0 -0
  56. package/scripts/lib/artifact-utils.js +521 -13
  57. package/scripts/lib/portfolio/portfolio-engine.js +301 -34
  58. package/scripts/lib/portfolio/rules/artifact-chain-rule.js +33 -3
  59. package/scripts/lib/portfolio/rules/conflict-resolver.js +22 -0
  60. package/scripts/migrate-artifacts.js +69 -10
  61. package/scripts/portability/catalog-generator.js +353 -0
  62. package/scripts/portability/classify-skills.js +646 -0
  63. package/scripts/portability/convoke-export.js +522 -0
  64. package/scripts/portability/export-engine.js +1156 -0
  65. package/scripts/portability/generate-adapters.js +79 -0
  66. package/scripts/portability/manifest-csv.js +147 -0
  67. package/scripts/portability/seed-catalog-repo.js +427 -0
  68. package/scripts/portability/templates/canonical-example.md +102 -0
  69. package/scripts/portability/templates/canonical-format.md +218 -0
  70. package/scripts/portability/templates/readme-template.md +72 -0
  71. package/scripts/portability/test-constants.js +42 -0
  72. package/scripts/portability/validate-classification.js +529 -0
  73. package/scripts/portability/validate-exports.js +348 -0
  74. package/scripts/update/lib/agent-registry.js +35 -0
  75. package/scripts/update/lib/config-merger.js +140 -10
  76. package/scripts/update/lib/refresh-installation.js +293 -8
  77. package/scripts/update/lib/utils.js +27 -1
  78. package/scripts/update/lib/validator.js +114 -4
@@ -27,6 +27,194 @@ const { formatMarkdown } = require('./formatters/markdown-formatter');
27
27
  /** Directories to exclude from portfolio scan */
28
28
  const EXCLUDE_DIRS = ['_archive', 'brainstorming', 'design-artifacts', 'journey-examples', 'project-documentation', 'test-artifacts', 'drafts'];
29
29
 
30
+ // --- Story 6.3: Attribution helpers ---
31
+
32
+ /**
33
+ * Scan a corpus for any taxonomy initiative ID or alias as a whole word.
34
+ * Mirrors `_scanCorpusForInitiative` in artifact-utils.js — kept local to avoid
35
+ * a cross-module dependency from portfolio into the migration suggester. The
36
+ * `[a-z0-9-]` boundary class keeps kebab-case identifiers atomic so `pre-gyre`
37
+ * does not match `gyre`.
38
+ *
39
+ * @param {string} corpus - Lowercased text to scan
40
+ * @param {import('../types').TaxonomyConfig} taxonomy
41
+ * @returns {string|null} Resolved canonical initiative ID, or null
42
+ */
43
+ function _scanCorpus(corpus, taxonomy) {
44
+ if (!corpus || !taxonomy || !taxonomy.initiatives) return null;
45
+ const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
46
+ const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
47
+ const allInitiatives = [...platform, ...user];
48
+ const aliasKeys = taxonomy.aliases ? Object.keys(taxonomy.aliases) : [];
49
+ const candidates = [...allInitiatives, ...aliasKeys].sort((a, b) => b.length - a.length);
50
+
51
+ for (const candidate of candidates) {
52
+ const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ const re = new RegExp(`(?:^|[^a-z0-9-])${escaped}(?:$|[^a-z0-9-])`, 'i');
54
+ if (re.test(corpus)) {
55
+ if (allInitiatives.includes(candidate)) return candidate;
56
+ if (taxonomy.aliases && taxonomy.aliases[candidate]) return taxonomy.aliases[candidate];
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Map of story-key filename prefixes (that are NOT taxonomy initiatives) to
64
+ * canonical initiative IDs. Story files (e.g. `tf-2-10-...`, `p3-1-1-...`) live
65
+ * in `implementation-artifacts/` and use compact prefixes that aren't real
66
+ * initiative IDs. Real-initiative prefixes (`gyre`, `forge`, `helm`, etc.) are
67
+ * resolved separately by checking taxonomy membership directly.
68
+ *
69
+ * Add new entries here when a new initiative starts producing stories under a
70
+ * non-initiative prefix.
71
+ */
72
+ const STORY_PREFIX_MAP = Object.freeze({
73
+ ag: 'convoke', // Artifact Governance — platform-level work
74
+ tf: 'loom', // Team Factory → Loom
75
+ p2: 'convoke', // Phase 2 — platform stabilization
76
+ p3: 'convoke', // Phase 3 — Convoke rename
77
+ enh: 'enhance', // Enhance module
78
+ sp: 'convoke', // Skill Portability — platform-level
79
+ });
80
+
81
+ /**
82
+ * Folder defaults for the portfolio engine. Mirrors the migration suggester:
83
+ * `planning-artifacts` is for cross-cutting convoke platform artifacts.
84
+ * Other dirs (`vortex-artifacts`, `implementation-artifacts`) are heterogeneous
85
+ * — no safe default, so they fall through to subsequent inference layers.
86
+ */
87
+ const PORTFOLIO_FOLDER_DEFAULT_MAP = Object.freeze({
88
+ 'planning-artifacts': 'convoke'
89
+ });
90
+
91
+ /**
92
+ * Attribute a file to an initiative when filename inference fails.
93
+ * Six-step priority chain (highest first):
94
+ * 1. frontmatter.title keyword scan → 'frontmatter-title'
95
+ * 2. first 5 lines of content keyword scan → 'content-fallback'
96
+ * 3. filename first-segment matches a real taxonomy initiative (e.g. `gyre-1-1-...`)
97
+ * → 'filename-prefix'
98
+ * 4. parent directory name (e.g. `gyre-artifacts/`) → 'parent-dir'
99
+ * 5. story-key prefix mapping (e.g. `tf-2-10-...` → loom) → 'story-prefix'
100
+ * 6. folder default (e.g. `planning-artifacts/` → convoke) → 'folder-default'
101
+ *
102
+ * @param {Object} fileInfo - File descriptor with filename and dir
103
+ * @param {string} content - Already-loaded file content
104
+ * @param {Object|null} frontmatter - Parsed frontmatter or null
105
+ * @param {import('../types').TaxonomyConfig} taxonomy
106
+ * @returns {{initiative: string|null, source: string|null}}
107
+ */
108
+ function attributeFile(fileInfo, content, frontmatter, taxonomy) {
109
+ // Step 1: Frontmatter title scan (highest priority)
110
+ if (frontmatter && typeof frontmatter.title === 'string' && frontmatter.title.length > 0) {
111
+ const match = _scanCorpus(frontmatter.title.toLowerCase(), taxonomy);
112
+ if (match) return { initiative: match, source: 'frontmatter-title' };
113
+ }
114
+
115
+ // Step 2: First-5-lines content scan
116
+ // Strip frontmatter before scanning so non-title fields (e.g. `tags: [gyre]`)
117
+ // don't false-trigger content-fallback. The migration suggester does the same.
118
+ if (content && content.length > 0) {
119
+ let body = content;
120
+ try {
121
+ const parsed = parseFrontmatter(content);
122
+ if (parsed && typeof parsed.content === 'string') {
123
+ body = parsed.content;
124
+ }
125
+ } catch {
126
+ // Frontmatter parse failed — fall back to raw content
127
+ }
128
+ const corpus = body.split('\n').slice(0, 5).join(' ').toLowerCase();
129
+ const match = _scanCorpus(corpus, taxonomy);
130
+ if (match) return { initiative: match, source: 'content-fallback' };
131
+ }
132
+
133
+ // Step 3: Filename first-segment matches a real taxonomy initiative.
134
+ // Catches patterns like `gyre-1-1-...`, `forge-epic-1-...`, `helm-prd.md`,
135
+ // and also single-word filenames like `gyre.md` (extension stripped first).
136
+ if (fileInfo.filename && taxonomy && taxonomy.initiatives) {
137
+ const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
138
+ const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
139
+ const allInitiatives = new Set([...platform, ...user]);
140
+ const stem = fileInfo.filename.replace(/\.(md|yaml|yml)$/i, '');
141
+ const firstSegment = stem.split('-')[0].toLowerCase();
142
+ if (allInitiatives.has(firstSegment)) {
143
+ return { initiative: firstSegment, source: 'filename-prefix' };
144
+ }
145
+ }
146
+
147
+ // Step 4: Parent directory scan
148
+ // The dir is something like `gyre-artifacts` — split on dashes and check each
149
+ // segment against the taxonomy. We can't reuse `_scanCorpus` here because it
150
+ // uses a kebab-aware boundary class (`[a-z0-9-]`) that intentionally treats
151
+ // `gyre-artifacts` as a single token, which is correct for content scanning
152
+ // but wrong for directory naming where `-` IS the segment separator.
153
+ if (fileInfo.dir && taxonomy && taxonomy.initiatives) {
154
+ const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
155
+ const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
156
+ const allInitiatives = new Set([...platform, ...user]);
157
+ const aliases = taxonomy.aliases || {};
158
+ const dirSegments = fileInfo.dir.toLowerCase().split('-');
159
+ for (const seg of dirSegments) {
160
+ if (allInitiatives.has(seg)) {
161
+ return { initiative: seg, source: 'parent-dir' };
162
+ }
163
+ if (aliases[seg]) {
164
+ return { initiative: aliases[seg], source: 'parent-dir' };
165
+ }
166
+ }
167
+ }
168
+
169
+ // Step 5: Story-key prefix mapping
170
+ // Match patterns like `tf-2-10-...`, `p3-epic-2-retrospective`, `ag-1-1-...`
171
+ // Take the first dash-separated segment of the filename stem and look it up in STORY_PREFIX_MAP.
172
+ if (fileInfo.filename) {
173
+ const stem = fileInfo.filename.replace(/\.(md|yaml|yml)$/i, '');
174
+ const firstSegment = stem.split('-')[0].toLowerCase();
175
+ if (Object.prototype.hasOwnProperty.call(STORY_PREFIX_MAP, firstSegment)) {
176
+ return { initiative: STORY_PREFIX_MAP[firstSegment], source: 'story-prefix' };
177
+ }
178
+ }
179
+
180
+ // Step 6: Folder default — last resort for cross-cutting platform artifacts
181
+ // (e.g. `planning-artifacts/architecture.md` → convoke)
182
+ if (fileInfo.dir && Object.prototype.hasOwnProperty.call(PORTFOLIO_FOLDER_DEFAULT_MAP, fileInfo.dir)) {
183
+ const defaultInit = PORTFOLIO_FOLDER_DEFAULT_MAP[fileInfo.dir];
184
+ if (defaultInit) {
185
+ return { initiative: defaultInit, source: 'folder-default' };
186
+ }
187
+ }
188
+
189
+ return { initiative: null, source: null };
190
+ }
191
+
192
+ /**
193
+ * Produce a one-line reason explaining why a file could not be attributed.
194
+ *
195
+ * @param {Object} fileInfo - File descriptor
196
+ * @param {string} content - File content (may be empty if unreadable)
197
+ * @param {Object|null} _frontmatter - Frontmatter (reserved for future heuristics)
198
+ * @returns {string} Reason
199
+ */
200
+ function explainUnattributed(fileInfo, content, _frontmatter) {
201
+ if (!content || content.length === 0) {
202
+ return 'unreadable or empty';
203
+ }
204
+ // Check whether the filename has a recognizable type prefix BEFORE checking content length —
205
+ // matches the spec's order so a 3-line file with no prefix returns the more actionable
206
+ // 'no type prefix' message rather than 'insufficient content'.
207
+ const hasTypePrefix = /^[a-z]+\d*-/.test(fileInfo.filename);
208
+ if (!hasTypePrefix) {
209
+ return 'no type prefix in filename';
210
+ }
211
+ const lineCount = content.split('\n').length;
212
+ if (lineCount < 5) {
213
+ return 'insufficient content for inference';
214
+ }
215
+ return 'no initiative signal in filename, frontmatter title, content, or parent directory';
216
+ }
217
+
30
218
  /**
31
219
  * Create an empty InitiativeState for a given initiative.
32
220
  * @param {string} initiative - Initiative ID
@@ -73,60 +261,103 @@ async function generatePortfolio(projectRoot, options = {}) {
73
261
  const registry = new Map();
74
262
  let governed = 0;
75
263
  let ungoverned = 0;
76
- let unattributed = 0;
264
+ let attributableButUngoverned = 0; // Story 6.3: counts files attributed via fallback layers
265
+ const unattributedFiles = []; // Story 6.3: track per-file reasons (replaces bare counter)
77
266
 
78
267
  const mdFiles = allFiles.filter(f => f.filename.endsWith('.md'));
79
268
 
80
269
  for (const file of mdFiles) {
81
270
 
82
- const typeResult = inferArtifactType(file.filename, taxonomy);
83
- const initResult = typeResult.type
84
- ? inferInitiative(typeResult.remainder, taxonomy)
85
- : { initiative: null, confidence: 'low', source: 'no-type', candidates: [] };
86
-
87
- // Files with no resolved initiative cannot be attributed — skip
88
- if (!initResult.initiative) {
89
- unattributed++;
90
- continue;
91
- }
92
-
93
- // Read frontmatter to classify governed vs ungoverned
271
+ // Read frontmatter FIRST — governed files have authoritative metadata
94
272
  let frontmatter = null;
95
273
  let content = '';
274
+ let readFailed = false;
96
275
  try {
97
276
  content = fs.readFileSync(file.fullPath, 'utf8');
98
277
  frontmatter = parseFrontmatter(content).data;
99
278
  } catch {
100
- // Unreadable — treat as no frontmatter
279
+ // Unreadable — treat as no frontmatter and skip fallback attribution
280
+ // (otherwise filename-prefix/story-prefix could silently attribute a file we never read)
281
+ readFailed = true;
101
282
  }
102
283
 
103
- // Governed = has frontmatter with matching initiative field
104
- const isGoverned = !!(frontmatter && frontmatter.initiative && frontmatter.initiative === initResult.initiative);
105
- if (isGoverned) {
284
+ // Strategy: frontmatter initiative is authoritative if present
285
+ let initiative, artifactType, isGoverned, typeResult;
286
+ let attributionSource = null; // Story 6.3: tracks which fallback layer provided the attribution
287
+
288
+ if (frontmatter && frontmatter.initiative && frontmatter.artifact_type) {
289
+ // Governed file — use frontmatter as source of truth
290
+ initiative = frontmatter.initiative;
291
+ artifactType = frontmatter.artifact_type;
292
+ isGoverned = true;
106
293
  governed++;
294
+ typeResult = { type: artifactType, hcPrefix: null, remainder: '', date: null, typeConfidence: 'high', typeSource: 'frontmatter' };
107
295
  } else {
108
- ungoverned++;
296
+ // Ungoverned file — fall back to filename inference
297
+ typeResult = inferArtifactType(file.filename, taxonomy);
298
+ const initResult = typeResult.type
299
+ ? inferInitiative(typeResult.remainder, taxonomy)
300
+ : { initiative: null, confidence: 'low', source: 'no-type', candidates: [] };
301
+
302
+ if (initResult.initiative) {
303
+ initiative = initResult.initiative;
304
+ artifactType = typeResult.type;
305
+ isGoverned = false;
306
+ ungoverned++;
307
+ } else if (readFailed) {
308
+ // Don't attempt fallback attribution on a file we couldn't read —
309
+ // otherwise filename/story-prefix layers would silently produce a phantom attribution.
310
+ unattributedFiles.push({
311
+ filename: file.filename,
312
+ dir: file.dir,
313
+ reason: 'unreadable or empty'
314
+ });
315
+ continue;
316
+ } else {
317
+ // Story 6.3: try content-fallback layers before giving up
318
+ const fallback = attributeFile(file, content, frontmatter, taxonomy);
319
+ if (fallback.initiative) {
320
+ initiative = fallback.initiative;
321
+ // Type may not be inferable — synthetic 'unknown' so registry can still index it
322
+ artifactType = typeResult.type || 'unknown';
323
+ isGoverned = false;
324
+ ungoverned++;
325
+ attributableButUngoverned++;
326
+ attributionSource = fallback.source;
327
+ } else {
328
+ // Truly unattributed — record reason for the diagnostic section
329
+ unattributedFiles.push({
330
+ filename: file.filename,
331
+ dir: file.dir,
332
+ reason: explainUnattributed(file, content, frontmatter)
333
+ });
334
+ continue;
335
+ }
336
+ }
109
337
  }
110
338
 
111
339
  const enriched = {
112
340
  filename: file.filename,
113
341
  dir: file.dir,
114
342
  fullPath: file.fullPath,
115
- type: typeResult.type,
343
+ type: artifactType,
116
344
  hcPrefix: typeResult.hcPrefix,
117
345
  date: typeResult.date,
118
- initiative: initResult.initiative,
346
+ initiative,
119
347
  frontmatter,
120
348
  content,
121
349
  isGoverned,
122
- degradedMode: !isGoverned
350
+ degradedMode: !isGoverned,
351
+ // Story 6.3: 'frontmatter-title' | 'content-fallback' | 'filename-prefix' | 'parent-dir' | 'story-prefix' | 'folder-default' | null
352
+ attributionSource
123
353
  };
124
354
 
125
- if (!registry.has(initResult.initiative)) {
126
- registry.set(initResult.initiative, []);
355
+ if (!registry.has(initiative)) {
356
+ registry.set(initiative, []);
127
357
  }
128
- registry.get(initResult.initiative).push(enriched);
358
+ registry.get(initiative).push(enriched);
129
359
  }
360
+ const unattributed = unattributedFiles.length;
130
361
 
131
362
  // FR39: warn if no governed artifacts
132
363
  if (governed === 0 && mdFiles.length > 0) {
@@ -180,11 +411,13 @@ async function generatePortfolio(projectRoot, options = {}) {
180
411
  return {
181
412
  initiatives: results,
182
413
  wipRadar,
414
+ unattributedFiles, // Story 6.3: full list with reasons (for --show-unattributed)
183
415
  summary: {
184
416
  total: mdFiles.length,
185
417
  governed,
186
418
  ungoverned,
187
419
  unattributed,
420
+ attributableButUngoverned, // Story 6.3: count of files attributed via fallback layers
188
421
  healthScore: { governed, total: attributable, percentage: healthPercentage }
189
422
  }
190
423
  };
@@ -199,17 +432,19 @@ Usage: convoke-portfolio [options]
199
432
  Generate a portfolio view of all initiatives from artifact analysis.
200
433
 
201
434
  Options:
202
- --terminal Terminal table output (default)
203
- --markdown Markdown table output
204
- --sort <mode> Sort: alpha (default), last-activity
205
- --filter <prefix> Filter initiatives by prefix (e.g., --filter gyre)
206
- --verbose Show inference trace per initiative (source + confidence)
207
- --help, -h Show this help
435
+ --terminal Terminal table output (default)
436
+ --markdown Markdown table output
437
+ --sort <mode> Sort: alpha (default), last-activity
438
+ --filter <prefix> Filter initiatives by prefix (e.g., --filter gyre)
439
+ --verbose Show inference trace per initiative (source + confidence)
440
+ --show-unattributed List each unattributed file with its reason
441
+ --help, -h Show this help
208
442
 
209
443
  Examples:
210
- convoke-portfolio Default terminal view
211
- convoke-portfolio --markdown Markdown output for chat/docs
212
- convoke-portfolio --sort last-activity Sort by most recent activity
444
+ convoke-portfolio Default terminal view
445
+ convoke-portfolio --markdown Markdown output for chat/docs
446
+ convoke-portfolio --sort last-activity Sort by most recent activity
447
+ convoke-portfolio --show-unattributed See why each unattributed file was skipped
213
448
  `);
214
449
  }
215
450
 
@@ -229,6 +464,7 @@ async function main() {
229
464
 
230
465
  const useMarkdown = args.includes('--markdown');
231
466
  const useVerbose = args.includes('--verbose');
467
+ const showUnattributed = args.includes('--show-unattributed');
232
468
  const sortMode = args.includes('--sort') && args[args.indexOf('--sort') + 1] === 'last-activity'
233
469
  ? 'last-activity'
234
470
  : 'alpha';
@@ -280,6 +516,29 @@ async function main() {
280
516
  const hs = result.summary.healthScore;
281
517
  console.log(`Governance: ${hs.governed}/${hs.total} artifacts governed (${hs.percentage}%)`);
282
518
 
519
+ // Story 6.3: Surface attributable-but-ungoverned guidance
520
+ if (result.summary.attributableButUngoverned > 0) {
521
+ console.log(
522
+ `${result.summary.attributableButUngoverned} files attributable to existing initiatives ` +
523
+ `but ungoverned — run convoke-migrate-artifacts to govern them`
524
+ );
525
+ }
526
+
527
+ // Story 6.3: Unattributed details
528
+ if (result.unattributedFiles && result.unattributedFiles.length > 0) {
529
+ if (showUnattributed) {
530
+ console.log(`\n--- Unattributed Files (${result.unattributedFiles.length}) ---`);
531
+ for (const u of result.unattributedFiles) {
532
+ console.log(` ${u.dir}/${u.filename}: ${u.reason}`);
533
+ }
534
+ } else {
535
+ console.log(
536
+ `\n${result.unattributedFiles.length} unattributed files ` +
537
+ `(run with --show-unattributed to see details)`
538
+ );
539
+ }
540
+ }
541
+
283
542
  // Verbose: inference trace per initiative
284
543
  if (useVerbose) {
285
544
  console.log('\n--- Inference Trace ---');
@@ -302,4 +561,12 @@ if (require.main === module) {
302
561
  });
303
562
  }
304
563
 
305
- module.exports = { generatePortfolio, makeEmptyState, EXCLUDE_DIRS };
564
+ module.exports = {
565
+ generatePortfolio,
566
+ makeEmptyState,
567
+ attributeFile,
568
+ explainUnattributed,
569
+ EXCLUDE_DIRS,
570
+ STORY_PREFIX_MAP,
571
+ PORTFOLIO_FOLDER_DEFAULT_MAP
572
+ };
@@ -90,11 +90,41 @@ function applyArtifactChainRule(state, artifacts, _options = {}) {
90
90
  return state;
91
91
  }
92
92
 
93
- // No recognized pattern
94
- state.phase = { value: 'unknown', source: 'artifact-chain', confidence: 'inferred' };
93
+ // No recognized pattern — collect evidence so the operator can see WHY it's unknown (Story 6.3)
94
+ const evidence = collectPhaseEvidence(artifacts, types, hcPrefixes);
95
+ state.phase = { value: 'unknown', source: 'artifact-chain', confidence: 'inferred', evidence };
95
96
  return state;
96
97
  }
97
98
 
99
+ /**
100
+ * Collect a one-line description of what the phase inference looked at when it
101
+ * couldn't determine a phase. Used to populate the Next Action with context
102
+ * instead of the generic "Create PRD or brief" message.
103
+ *
104
+ * @param {Array<Object>} artifacts - All artifacts for this initiative
105
+ * @param {Set<string>} types - Distinct artifact types present
106
+ * @param {Set<string>} hcPrefixes - HC prefixes present (e.g. 'hc1', 'hc2')
107
+ * @returns {string[]} Evidence list, e.g. ["3 artifacts found", "no PRD/brief", "no HC chain", ...]
108
+ */
109
+ function collectPhaseEvidence(artifacts, types, hcPrefixes) {
110
+ const evidence = [];
111
+ const count = artifacts.length;
112
+ evidence.push(count === 1 ? '1 artifact found' : `${count} artifacts found`);
113
+
114
+ // Special-case: only HC1 present (incomplete discovery, doesn't trigger discovery branch)
115
+ if (hcPrefixes.size === 1 && hcPrefixes.has('hc1')) {
116
+ evidence.push('incomplete HC chain (needs HC2-HC6)');
117
+ return evidence;
118
+ }
119
+
120
+ if (!types.has('prd') && !types.has('brief')) evidence.push('no PRD/brief');
121
+ if (!types.has('arch')) evidence.push('no architecture');
122
+ if (hcPrefixes.size === 0) evidence.push('no HC chain');
123
+ if (!types.has('epic')) evidence.push('no epic');
124
+
125
+ return evidence;
126
+ }
127
+
98
128
  /**
99
129
  * Check if epic content indicates completion via flexible markers.
100
130
  * @param {string} content - Epic file content
@@ -123,4 +153,4 @@ function detectHCChain(state, hcPrefixes) {
123
153
  }
124
154
  }
125
155
 
126
- module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, DONE_PATTERNS };
156
+ module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, collectPhaseEvidence, DONE_PATTERNS };
@@ -32,6 +32,28 @@ function applyConflictResolver(state, artifacts, _options = {}) {
32
32
  state.lastArtifact = { file: last.filename, date: last.date || 'unknown' };
33
33
  }
34
34
 
35
+ // Story 6.3: If phase is unknown AND a recognized-type artifact exists AND we have evidence,
36
+ // surface a context-aware next action instead of the generic "Create PRD or brief".
37
+ // - Initiatives with zero artifacts still get the generic message (legitimate use case).
38
+ // - Initiatives whose ONLY artifacts are fallback-attributed (synthetic 'unknown' type)
39
+ // also get the generic message — those don't reflect a real phase signal worth elaborating on.
40
+ // This guards against the design-intent inversion caught in code review:
41
+ // a single fallback-attributed note shouldn't override "Create PRD or brief".
42
+ const hasRecognizedArtifact = artifacts.some(a => a && a.type && a.type !== 'unknown');
43
+ if (
44
+ state.phase.value === 'unknown' &&
45
+ Array.isArray(state.phase.evidence) &&
46
+ state.phase.evidence.length > 0 &&
47
+ hasRecognizedArtifact
48
+ ) {
49
+ const summary = state.phase.evidence.slice(0, 2).join(', ');
50
+ state.nextAction = {
51
+ value: `Unknown phase: ${summary}`,
52
+ source: 'conflict-resolver'
53
+ };
54
+ return state;
55
+ }
56
+
35
57
  // Derive nextAction from phase if not already set by chain-gap analysis
36
58
  if (!state.nextAction.value) {
37
59
  state.nextAction = deriveNextAction(state);
@@ -23,6 +23,7 @@ const {
23
23
  verifyHistoryChain,
24
24
  executeInjections,
25
25
  resolveAmbiguous,
26
+ loadResolutionMap,
26
27
  detectMigrationState,
27
28
  generateGovernanceADR,
28
29
  supersedePreviousADR
@@ -39,7 +40,7 @@ const VALID_DIR_PATTERN = /^[a-zA-Z0-9_-]+$/;
39
40
  * Parse CLI arguments from argv array.
40
41
  *
41
42
  * @param {string[]} argv - Arguments (typically process.argv.slice(2))
42
- * @returns {{help: boolean, includeDirs: string[], apply: boolean, force: boolean, verbose: boolean}}
43
+ * @returns {{help: boolean, includeDirs: string[], apply: boolean, force: boolean, verbose: boolean, resolutionFile: string|null, resolutionFileError: string|null}}
43
44
  */
44
45
  function parseArgs(argv) {
45
46
  const help = argv.includes('--help') || argv.includes('-h');
@@ -66,7 +67,37 @@ function parseArgs(argv) {
66
67
  }
67
68
  }
68
69
 
69
- return { help, includeDirs, apply, force, verbose };
70
+ // Story 6.4: --resolution-file <path> operator decisions for AMBIGUOUS entries.
71
+ // Loaded and validated in main() after the manifest is generated.
72
+ // Also accept the GNU --resolution-file=path form. Reject missing/empty/flag-like
73
+ // values loudly via parse error so operators don't accidentally run a destructive
74
+ // --apply --force without their overrides being honored.
75
+ let resolutionFile = null;
76
+ let resolutionFileError = null;
77
+
78
+ // Check the equals form first: --resolution-file=path
79
+ const eqArg = argv.find(a => a.startsWith('--resolution-file='));
80
+ if (eqArg) {
81
+ const value = eqArg.slice('--resolution-file='.length);
82
+ if (!value || value.startsWith('-')) {
83
+ resolutionFileError = `--resolution-file requires a non-empty path (got: '${value}')`;
84
+ } else {
85
+ resolutionFile = value;
86
+ }
87
+ } else {
88
+ const resolutionIdx = argv.indexOf('--resolution-file');
89
+ if (resolutionIdx !== -1) {
90
+ const nextArg = argv[resolutionIdx + 1];
91
+ // Reject: missing arg, empty arg, anything starting with `-` (covers --flags AND -singledash)
92
+ if (!nextArg || nextArg.startsWith('-')) {
93
+ resolutionFileError = `--resolution-file requires a path argument (got: '${nextArg || '<missing>'}')`;
94
+ } else {
95
+ resolutionFile = nextArg;
96
+ }
97
+ }
98
+ }
99
+
100
+ return { help, includeDirs, apply, force, verbose, resolutionFile, resolutionFileError };
70
101
  }
71
102
 
72
103
  // --- Help ---
@@ -79,17 +110,21 @@ Analyze artifact files and show what the governance migration would do.
79
110
  Dry-run by default — no files are modified.
80
111
 
81
112
  Options:
82
- --include <dirs> Comma-separated directory names to scan (relative to _bmad-output/)
83
- Default: planning-artifacts,vortex-artifacts,gyre-artifacts
84
- --verbose Show cross-references for ambiguous files
85
- --apply Execute the rename migration (commit 1: git mv)
86
- --force Bypass confirmation prompt (use with --apply for automation)
87
- --help, -h Show this help
113
+ --include <dirs> Comma-separated directory names to scan (relative to _bmad-output/)
114
+ Default: planning-artifacts,vortex-artifacts,gyre-artifacts
115
+ --verbose Show cross-references for ambiguous files
116
+ --apply Execute the rename migration (commit 1: git mv)
117
+ --force Bypass confirmation prompt (use with --apply for automation)
118
+ --resolution-file <path> JSON file with operator decisions for ambiguous entries.
119
+ Combined with --force gives a fully non-interactive run.
120
+ Schema: { "schemaVersion": 1, "resolutions": { "dir/file.md": { "action": "rename", "initiative": "convoke" } } }
121
+ --help, -h Show this help
88
122
 
89
123
  Examples:
90
124
  convoke-migrate-artifacts Dry-run with default scope
91
125
  convoke-migrate-artifacts --verbose Dry-run with cross-references
92
126
  convoke-migrate-artifacts --include planning-artifacts Dry-run for one directory
127
+ convoke-migrate-artifacts --apply --force --resolution-file resolutions.json Non-interactive apply with operator decisions
93
128
  `);
94
129
  }
95
130
 
@@ -172,6 +207,13 @@ async function main() {
172
207
  return;
173
208
  }
174
209
 
210
+ // Story 6.4: fail fast on a malformed --resolution-file flag so a typo can't
211
+ // silently turn into a destructive --apply --force run with no overrides applied.
212
+ if (args.resolutionFileError) {
213
+ console.error(`Error: ${args.resolutionFileError}`);
214
+ process.exit(1);
215
+ }
216
+
175
217
  if (args.force && !args.apply) {
176
218
  console.log('Warning: --force has no effect without --apply. Running dry-run instead.');
177
219
  }
@@ -247,8 +289,25 @@ async function main() {
247
289
  // Load taxonomy for ambiguous resolution
248
290
  const taxonomy = readTaxonomy(projectRoot);
249
291
 
250
- // Resolve ambiguous files interactively (or auto-skip in --force mode)
251
- const resolution = await resolveAmbiguous(manifest, taxonomy, projectRoot, { force: args.force });
292
+ // Story 6.4: optional pre-loaded operator resolutions via --resolution-file.
293
+ // Loaded once here so any validation error fails fast before we touch the manifest.
294
+ let resolutionMap = null;
295
+ if (args.resolutionFile) {
296
+ try {
297
+ resolutionMap = loadResolutionMap(args.resolutionFile, taxonomy);
298
+ const count = Object.keys(resolutionMap).length;
299
+ console.log(`Loaded ${count} operator resolution(s) from ${args.resolutionFile}.`);
300
+ } catch (err) {
301
+ console.error(`Error loading resolution file: ${err.message}`);
302
+ process.exit(1);
303
+ }
304
+ }
305
+
306
+ // Resolve ambiguous files: resolution map (if any) → no-candidates skip → force skip → interactive prompt
307
+ const resolution = await resolveAmbiguous(manifest, taxonomy, projectRoot, {
308
+ force: args.force,
309
+ resolutionMap
310
+ });
252
311
  if (resolution.resolved > 0 || resolution.skipped > 0) {
253
312
  console.log(`\nAmbiguous resolution: ${resolution.resolved} resolved, ${resolution.skipped} skipped.`);
254
313
  }