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
@@ -452,6 +452,169 @@ function inferInitiative(remainder, taxonomy) {
452
452
  return { initiative: null, confidence: 'low', source: 'unresolved', candidates };
453
453
  }
454
454
 
455
+ // --- Suggested Initiative (Story 6.2) ---
456
+ // inferInitiative() is intentionally cautious — it never guesses. The suggester
457
+ // layers ON TOP, providing reviewable defaults for AMBIGUOUS entries based on
458
+ // content keywords, folder defaults, and git context. Suggestions are guidance,
459
+ // not decisions: the manifest entry stays AMBIGUOUS, but the operator gets a
460
+ // "REVIEW SUGGESTION: accept '{X}' or specify" prompt instead of a bare wall.
461
+
462
+ /**
463
+ * Folder-default map for initiative inference.
464
+ * - planning-artifacts → convoke (platform-level artifacts default to convoke)
465
+ * - vortex-artifacts → null (Vortex spans multiple initiatives, no safe default)
466
+ * - gyre-artifacts → gyre (all gyre-artifacts/* belong to gyre)
467
+ *
468
+ * @type {Object<string, string|null>}
469
+ */
470
+ const FOLDER_DEFAULT_MAP = Object.freeze({
471
+ 'planning-artifacts': 'convoke',
472
+ 'vortex-artifacts': null,
473
+ 'gyre-artifacts': 'gyre'
474
+ });
475
+
476
+ /** Cap on git queries per migration run to preserve NFR2 (dry-run < 10s for 200 files) */
477
+ const MAX_GIT_SUGGESTER_QUERIES = 50;
478
+ let _gitSuggesterQueryCount = 0;
479
+ let _gitSuggesterWarned = false;
480
+
481
+ /**
482
+ * Reset the per-run git query counter.
483
+ * Called by generateManifest() at the start of each run, and by tests
484
+ * via the exported helper to avoid cross-test state pollution.
485
+ */
486
+ function _resetGitSuggesterCounter() {
487
+ _gitSuggesterQueryCount = 0;
488
+ _gitSuggesterWarned = false;
489
+ }
490
+
491
+ /**
492
+ * Scan a text corpus for any taxonomy initiative ID or alias as a whole word.
493
+ * Returns the first match (longest first to prefer specific over generic).
494
+ *
495
+ * Uses hyphen-aware lookarounds rather than `\b` because JS `\b` treats `-` as a
496
+ * word boundary, which would cause `pre-gyre` to match the `gyre` initiative.
497
+ * The boundary class `[a-z0-9-]` keeps kebab-case identifiers atomic.
498
+ *
499
+ * @param {string} corpus - Lowercased text to scan
500
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy with initiatives and aliases
501
+ * @returns {string|null} Resolved initiative ID, or null if no match
502
+ */
503
+ function _scanCorpusForInitiative(corpus, taxonomy) {
504
+ if (!corpus) return null;
505
+ if (!taxonomy || !taxonomy.initiatives) return null;
506
+
507
+ const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : [];
508
+ const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : [];
509
+ const allInitiatives = [...platform, ...user];
510
+ const aliasKeys = taxonomy.aliases ? Object.keys(taxonomy.aliases) : [];
511
+
512
+ // Combine and sort by length descending — prefer 'strategy-perimeter' over 'strategy'.
513
+ const candidates = [...allInitiatives, ...aliasKeys].sort((a, b) => b.length - a.length);
514
+
515
+ for (const candidate of candidates) {
516
+ const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
517
+ // Boundary class: not preceded/followed by [a-z0-9-]. Treats hyphen as word-internal,
518
+ // so 'gyrescope' rejects (preceded by 'gyre' won't trigger; 'scope' = letter), 'pre-gyre'
519
+ // also rejects (the leading 'pre-' counts as boundary since hyphen is in the class).
520
+ const re = new RegExp(`(?:^|[^a-z0-9-])${escaped}(?:$|[^a-z0-9-])`, 'i');
521
+ if (re.test(corpus)) {
522
+ // Resolve aliases to canonical initiative
523
+ if (allInitiatives.includes(candidate)) return candidate;
524
+ if (taxonomy.aliases && taxonomy.aliases[candidate]) return taxonomy.aliases[candidate];
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+
530
+ /**
531
+ * Suggest a likely initiative for a file when inferInitiative() returns null.
532
+ * Three-step priority chain:
533
+ * 1. Content keyword scan (frontmatter title + first 10 lines) → 'medium' confidence
534
+ * 2. Folder default (FOLDER_DEFAULT_MAP) → 'low' confidence
535
+ * 3. Git creation commit message → 'low' confidence
536
+ *
537
+ * Suggestions are GUIDANCE for the operator, not auto-resolutions. The action
538
+ * label remains AMBIGUOUS so the operator must still confirm.
539
+ *
540
+ * @param {string} filename - The file's basename
541
+ * @param {string} dirName - The parent directory name (e.g., 'planning-artifacts')
542
+ * @param {string} fileContent - Already-loaded file content (avoids double-read)
543
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
544
+ * @param {string} projectRoot - Absolute path to project root (for git queries)
545
+ * @returns {{initiative: string|null, source: 'content-keyword'|'folder-default'|'git-context'|null, confidence: 'medium'|'low'|null}}
546
+ */
547
+ function suggestInitiative(filename, dirName, fileContent, taxonomy, projectRoot) {
548
+ // Step 1: Content keyword scan (highest priority)
549
+ // Scan frontmatter title + first 10 lines, lowercased.
550
+ // Note: a file titled "Comparing Gyre vs Vortex" matches both — first match wins
551
+ // (longest first via _scanCorpusForInitiative). This is a documented trade-off.
552
+ let title = '';
553
+ let firstLines;
554
+ try {
555
+ const parsed = matter(fileContent);
556
+ if (parsed.data && typeof parsed.data.title === 'string') {
557
+ title = parsed.data.title;
558
+ }
559
+ const body = parsed.content || fileContent;
560
+ firstLines = body.split('\n').slice(0, 10).join(' ');
561
+ } catch {
562
+ // Frontmatter parse failed — fall back to raw content
563
+ firstLines = fileContent.split('\n').slice(0, 10).join(' ');
564
+ }
565
+
566
+ const corpus = `${title} ${firstLines}`.toLowerCase();
567
+ const contentMatch = _scanCorpusForInitiative(corpus, taxonomy);
568
+ if (contentMatch) {
569
+ return { initiative: contentMatch, source: 'content-keyword', confidence: 'medium' };
570
+ }
571
+
572
+ // Step 2: Folder default
573
+ if (Object.prototype.hasOwnProperty.call(FOLDER_DEFAULT_MAP, dirName)) {
574
+ const folderDefault = FOLDER_DEFAULT_MAP[dirName];
575
+ if (folderDefault) {
576
+ return { initiative: folderDefault, source: 'folder-default', confidence: 'low' };
577
+ }
578
+ }
579
+
580
+ // Step 3: Git context (lowest priority, capped to preserve NFR2).
581
+ // Skip git entirely if we don't have a project root to run inside.
582
+ if (!projectRoot) {
583
+ return { initiative: null, source: null, confidence: null };
584
+ }
585
+
586
+ // Cap check: emit a one-time warning when we first hit the cap, then short-circuit.
587
+ if (_gitSuggesterQueryCount >= MAX_GIT_SUGGESTER_QUERIES) {
588
+ if (!_gitSuggesterWarned) {
589
+ _gitSuggesterWarned = true;
590
+ console.warn(
591
+ `Warning: git-context suggester cap reached (${MAX_GIT_SUGGESTER_QUERIES} queries). ` +
592
+ `Remaining ambiguous files will not get git-based suggestions.`
593
+ );
594
+ }
595
+ return { initiative: null, source: null, confidence: null };
596
+ }
597
+
598
+ _gitSuggesterQueryCount++;
599
+ try {
600
+ const relPath = path.join('_bmad-output', dirName, filename);
601
+ const raw = execFileSync(
602
+ 'git', ['log', '--diff-filter=A', '--format=%s', '--', relPath],
603
+ { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
604
+ ).trim();
605
+ if (raw) {
606
+ const gitMatch = _scanCorpusForInitiative(raw.toLowerCase(), taxonomy);
607
+ if (gitMatch) {
608
+ return { initiative: gitMatch, source: 'git-context', confidence: 'low' };
609
+ }
610
+ }
611
+ } catch {
612
+ // Git unavailable, file not tracked, or other failure — silent
613
+ }
614
+
615
+ return { initiative: null, source: null, confidence: null };
616
+ }
617
+
455
618
  /**
456
619
  * Determine the governance state of a file based on filename convention and frontmatter.
457
620
  *
@@ -760,7 +923,7 @@ async function getCrossReferences(targetFilename, scopeFiles, _projectRoot) {
760
923
  * @param {string} _projectRoot - Project root (reserved)
761
924
  * @returns {Promise<import('./types').ManifestEntry>}
762
925
  */
763
- async function buildManifestEntry(fileInfo, taxonomy, _projectRoot) {
926
+ async function buildManifestEntry(fileInfo, taxonomy, projectRoot) {
764
927
  const { filename, dir, fullPath } = fileInfo;
765
928
  const oldPath = `${dir}/${filename}`;
766
929
 
@@ -771,7 +934,8 @@ async function buildManifestEntry(fileInfo, taxonomy, _projectRoot) {
771
934
  confidence: 'low', source: 'non-markdown', action: 'SKIP',
772
935
  dir, contextClues: null, crossReferences: null, candidates: [],
773
936
  collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
774
- typeConfidence: 'low', typeSource: 'none'
937
+ typeConfidence: 'low', typeSource: 'none',
938
+ suggestedInitiative: null, suggestedFrom: null, suggestedConfidence: null
775
939
  };
776
940
  }
777
941
 
@@ -784,7 +948,8 @@ async function buildManifestEntry(fileInfo, taxonomy, _projectRoot) {
784
948
  confidence: 'low', source: 'unreadable', action: 'AMBIGUOUS',
785
949
  dir, contextClues: null, crossReferences: null, candidates: [],
786
950
  collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
787
- typeConfidence: 'low', typeSource: 'none'
951
+ typeConfidence: 'low', typeSource: 'none',
952
+ suggestedInitiative: null, suggestedFrom: null, suggestedConfidence: null
788
953
  };
789
954
  }
790
955
 
@@ -811,15 +976,29 @@ async function buildManifestEntry(fileInfo, taxonomy, _projectRoot) {
811
976
  candidates: govState.candidates || [],
812
977
  collisionWith: null,
813
978
  frontmatterInitiative: govState.frontmatterInitiative,
814
- fileInitiative: govState.fileInitiative
979
+ fileInitiative: govState.fileInitiative,
980
+ // Suggestion fields (Story 6.2)
981
+ // - suggestedInitiative/From/Confidence: populated for AMBIGUOUS entries by suggestInitiative()
982
+ // - suggestedNewPath: populated for colliding RENAME entries by suggestDifferentiator()
983
+ // (set to null here so consumers always see a defined field)
984
+ suggestedInitiative: null,
985
+ suggestedFrom: null,
986
+ suggestedConfidence: null,
987
+ suggestedNewPath: null
815
988
  };
816
989
 
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' };
990
+ // For ambiguous/ungoverned entries, layer a suggestion on top via suggestInitiative.
991
+ // The action stays AMBIGUOUS — the operator must still confirm. This is guidance, not auto-resolution.
992
+ if (govState.state === 'ungoverned' || govState.state === 'ambiguous') {
993
+ const suggestion = suggestInitiative(filename, dir, fileContent, taxonomy, projectRoot);
994
+ return {
995
+ ...base,
996
+ newPath: null,
997
+ action: 'AMBIGUOUS',
998
+ suggestedInitiative: suggestion.initiative,
999
+ suggestedFrom: suggestion.source,
1000
+ suggestedConfidence: suggestion.confidence
1001
+ };
823
1002
  }
824
1003
 
825
1004
  if (govState.state === 'invalid-governed') {
@@ -851,6 +1030,111 @@ async function buildManifestEntry(fileInfo, taxonomy, _projectRoot) {
851
1030
  return { ...base, newPath, action: 'RENAME' };
852
1031
  }
853
1032
 
1033
+ /**
1034
+ * Suggest a differentiator suffix for two source filenames colliding on the same target.
1035
+ * Strategy:
1036
+ * 1. Strip date suffix from sources and target
1037
+ * 2. For each source, find the longest unique segment chain that the OTHER sources don't share
1038
+ * 3. Insert that differentiator into the target before the date suffix
1039
+ *
1040
+ * Returns null if sources are too similar to differentiate (e.g., exact duplicates).
1041
+ *
1042
+ * @param {string[]} sourcePaths - List of colliding source paths (e.g., 'vortex-artifacts/lean-persona-strategic-navigator-2026-04-04.md')
1043
+ * @param {string} targetPath - The collision target (e.g., 'vortex-artifacts/helm-lean-persona-2026-04-04.md')
1044
+ * @returns {Map<string, string|null>} Map of sourcePath → suggestedNewPath (or null)
1045
+ */
1046
+ function suggestDifferentiator(sourcePaths, targetPath) {
1047
+ const result = new Map();
1048
+
1049
+ // Filter out sentinel entries like '(existing) ...' that aren't real source files
1050
+ const realSources = sourcePaths.filter(s => !s.startsWith('(existing) '));
1051
+ if (realSources.length < 2) {
1052
+ for (const s of sourcePaths) result.set(s, null);
1053
+ return result;
1054
+ }
1055
+
1056
+ // Parse target: directory + stem + date + ext
1057
+ const targetMatch = targetPath.match(/^(.*\/)?([^/]+?)(-(\d{4}-\d{2}-\d{2}))?\.(md|yaml)$/);
1058
+ if (!targetMatch) {
1059
+ for (const s of sourcePaths) result.set(s, null);
1060
+ return result;
1061
+ }
1062
+ const targetDir = targetMatch[1] || '';
1063
+ const targetStem = targetMatch[2];
1064
+ const targetDate = targetMatch[4] || '';
1065
+ const targetExt = targetMatch[5];
1066
+
1067
+ // For each real source, extract segments that are not in the target stem.
1068
+ // Then verify uniqueness against the other sources.
1069
+ const sourceData = realSources.map(srcPath => {
1070
+ const srcMatch = srcPath.match(/^(.*\/)?([^/]+?)(-(\d{4}-\d{2}-\d{2}))?\.(md|yaml)$/);
1071
+ if (!srcMatch) return { srcPath, segments: [] };
1072
+ const srcStem = srcMatch[2];
1073
+ const srcSegments = srcStem.split('-');
1074
+ const targetSegments = new Set(targetStem.split('-'));
1075
+ // Keep only segments not present in the target stem
1076
+ const unique = srcSegments.filter(s => !targetSegments.has(s));
1077
+ return { srcPath, segments: unique, srcStem };
1078
+ });
1079
+
1080
+ // Cross-check: each source's segments must also distinguish it from OTHER sources
1081
+ for (const { srcPath, segments } of sourceData) {
1082
+ const otherSegSets = sourceData
1083
+ .filter(d => d.srcPath !== srcPath)
1084
+ .map(d => new Set(d.segments));
1085
+
1086
+ // Find segments that are unique to THIS source (not in any other source's segments)
1087
+ const uniqueToMe = segments.filter(seg => otherSegSets.every(other => !other.has(seg)));
1088
+
1089
+ if (uniqueToMe.length === 0) {
1090
+ // No distinguishing segments — can't differentiate
1091
+ result.set(srcPath, null);
1092
+ continue;
1093
+ }
1094
+
1095
+ // Build the differentiator (join unique segments)
1096
+ const differentiator = uniqueToMe.join('-');
1097
+
1098
+ // Construct the suggested new path: targetDir + targetStem + '-' + differentiator + dateSuffix + ext
1099
+ const dateSuffix = targetDate ? `-${targetDate}` : '';
1100
+ const suggestedFilename = `${targetStem}-${differentiator}${dateSuffix}.${targetExt}`;
1101
+ const suggestedNewPath = `${targetDir}${suggestedFilename}`;
1102
+ result.set(srcPath, suggestedNewPath);
1103
+ }
1104
+
1105
+ // Edge case: if the suggested new paths themselves collide, append a numeric suffix.
1106
+ // First pass: count duplicates. Second pass: rename ALL duplicates (not just 2nd+).
1107
+ // Uses the same greedy regex as the source/target parser above to avoid the
1108
+ // lazy-`.*?` empty-stem bug.
1109
+ const dupCounts = new Map();
1110
+ for (const suggested of result.values()) {
1111
+ if (!suggested) continue;
1112
+ dupCounts.set(suggested, (dupCounts.get(suggested) || 0) + 1);
1113
+ }
1114
+ const assignedSuffix = new Map(); // suggested → next index to assign
1115
+ for (const [src, suggested] of result) {
1116
+ if (!suggested) continue;
1117
+ if ((dupCounts.get(suggested) || 0) < 2) continue; // not a dup, skip
1118
+ const idx = (assignedSuffix.get(suggested) || 0) + 1;
1119
+ assignedSuffix.set(suggested, idx);
1120
+ const reMatch = suggested.match(/^(.*\/)?([^/]+?)(-(\d{4}-\d{2}-\d{2}))?\.(md|yaml)$/);
1121
+ if (reMatch) {
1122
+ const dirPrefix = reMatch[1] || '';
1123
+ const stem = reMatch[2];
1124
+ const dateSuffix = reMatch[4] ? `-${reMatch[4]}` : '';
1125
+ const ext = reMatch[5];
1126
+ result.set(src, `${dirPrefix}${stem}-${idx}${dateSuffix}.${ext}`);
1127
+ }
1128
+ }
1129
+
1130
+ // Sentinels get null
1131
+ for (const s of sourcePaths) {
1132
+ if (!result.has(s)) result.set(s, null);
1133
+ }
1134
+
1135
+ return result;
1136
+ }
1137
+
854
1138
  /**
855
1139
  * Detect target filename collisions in manifest entries.
856
1140
  *
@@ -917,6 +1201,9 @@ async function generateManifest(projectRoot, options = {}) {
917
1201
  const scopeFiles = await scanArtifactDirs(projectRoot, includeDirs, excludeDirs);
918
1202
  const entries = [];
919
1203
 
1204
+ // Reset the per-run git query counter (Story 6.2 — caps git suggestions to preserve NFR2)
1205
+ _resetGitSuggesterCounter();
1206
+
920
1207
  for (const fileInfo of scopeFiles) {
921
1208
  const entry = await buildManifestEntry(fileInfo, taxonomy, projectRoot);
922
1209
  entries.push(entry);
@@ -925,9 +1212,12 @@ async function generateManifest(projectRoot, options = {}) {
925
1212
  // Detect collisions and annotate entries
926
1213
  const collisions = detectCollisions(entries);
927
1214
  for (const [target, sources] of collisions) {
1215
+ // Compute differentiator suggestions for this collision (Story 6.2)
1216
+ const differentiators = suggestDifferentiator(sources, target);
928
1217
  for (const entry of entries) {
929
1218
  if (entry.newPath === target && entry.action === 'RENAME') {
930
1219
  entry.collisionWith = sources.filter(s => s !== entry.oldPath);
1220
+ entry.suggestedNewPath = differentiators.get(entry.oldPath) || null;
931
1221
  }
932
1222
  }
933
1223
  }
@@ -991,6 +1281,9 @@ function formatManifest(manifest, options = {}) {
991
1281
  lines.push(` Type: ${entry.artifactType} (confidence: ${entry.typeConfidence || 'high'}, source: ${entry.typeSource || 'prefix'})`);
992
1282
  if (entry.collisionWith && entry.collisionWith.length > 0) {
993
1283
  lines.push(` [!] COLLISION: same target as ${entry.collisionWith.join(', ')}`);
1284
+ if (entry.suggestedNewPath) {
1285
+ lines.push(` Suggested rename: ${entry.suggestedNewPath}`);
1286
+ }
994
1287
  }
995
1288
  break;
996
1289
 
@@ -1026,7 +1319,13 @@ function formatManifest(manifest, options = {}) {
1026
1319
  if (entry.candidates.length > 0) {
1027
1320
  lines.push(` Candidates: ${entry.candidates.join(', ')}`);
1028
1321
  }
1029
- lines.push(' ACTION REQUIRED: Specify initiative for this file');
1322
+ // Suggestion (Story 6.2): if a default exists, surface it and switch the action label
1323
+ if (entry.suggestedInitiative) {
1324
+ lines.push(` Suggested: ${entry.suggestedInitiative} (source: ${entry.suggestedFrom}, confidence: ${entry.suggestedConfidence})`);
1325
+ lines.push(` REVIEW SUGGESTION: Accept '${entry.suggestedInitiative}' or specify initiative`);
1326
+ } else {
1327
+ lines.push(' ACTION REQUIRED: Specify initiative for this file');
1328
+ }
1030
1329
  break;
1031
1330
  }
1032
1331
  }
@@ -1396,24 +1695,101 @@ async function promptInitiative(filename, candidates) {
1396
1695
  }
1397
1696
 
1398
1697
  /**
1399
- * Resolve ambiguous manifest entries interactively or auto-skip in force mode.
1698
+ * Resolve ambiguous manifest entries interactively, via a resolution map, or auto-skip in force mode.
1400
1699
  * Mutates manifest entries in-place.
1401
1700
  *
1701
+ * **Caller responsibility:** after this function returns, the caller MUST re-run `detectCollisions()`
1702
+ * on the manifest. Resolution-map and interactive renames may produce target-path collisions that
1703
+ * the original `generateManifest()` did not see. The CLI's `main()` does this; programmatic callers
1704
+ * must do the same.
1705
+ *
1706
+ * Priority order for AMBIGUOUS entries:
1707
+ * 1. Resolution map (Story 6.4) — operator decisions passed via --resolution-file
1708
+ * 2. No-candidates auto-skip — entries the engine couldn't even generate candidates for
1709
+ * 3. Force auto-skip — `--force` flag bypasses interactive prompts
1710
+ * 4. Interactive prompt — fallback
1711
+ *
1402
1712
  * @param {import('./types').ManifestResult} manifest - Manifest to resolve
1403
1713
  * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy for filename generation
1404
1714
  * @param {string} _projectRoot - Project root (reserved)
1405
1715
  * @param {Object} [options={}]
1406
1716
  * @param {boolean} [options.force=false] - Auto-skip all ambiguous in force mode
1717
+ * @param {Function} [options.promptFn=promptInitiative] - Prompt function for tests
1718
+ * @param {Object|null} [options.resolutionMap=null] - Pre-loaded resolutions keyed by oldPath.
1719
+ * Each value: `{ action: 'rename'|'skip', initiative?: string }`. Validated by loadResolutionMap()
1720
+ * before being passed in here. When a resolution-map entry exists for an oldPath, it takes
1721
+ * precedence over BOTH the no-candidates auto-skip and the force flag — the operator's
1722
+ * explicit decision wins. If `entry.artifactType` is null (engine couldn't infer type) and the
1723
+ * resolution says rename, falls back to a synthetic 'note' type so generateNewFilename can run.
1407
1724
  * @returns {Promise<{resolved: number, skipped: number}>}
1408
1725
  */
1409
1726
  async function resolveAmbiguous(manifest, taxonomy, _projectRoot, options = {}) {
1410
- const { force = false, promptFn = promptInitiative } = options;
1727
+ const { force = false, promptFn = promptInitiative, resolutionMap = null } = options;
1411
1728
  let resolved = 0;
1412
1729
  let skipped = 0;
1413
1730
 
1414
1731
  for (const entry of manifest.entries) {
1415
1732
  if (entry.action !== 'AMBIGUOUS') continue;
1416
1733
 
1734
+ // Story 6.4: Resolution map takes precedence over all other guards.
1735
+ // Operator decisions passed via --resolution-file are honored even when the engine
1736
+ // would have auto-skipped (no candidates) or when --force is set.
1737
+ if (resolutionMap && Object.prototype.hasOwnProperty.call(resolutionMap, entry.oldPath)) {
1738
+ const resolution = resolutionMap[entry.oldPath];
1739
+ if (resolution.action === 'skip') {
1740
+ entry.action = 'SKIP';
1741
+ entry.source = 'operator';
1742
+ skipped++;
1743
+ continue;
1744
+ }
1745
+ if (resolution.action === 'rename') {
1746
+ // Initiative pre-validated against taxonomy by loadResolutionMap()
1747
+ entry.initiative = resolution.initiative;
1748
+ // Fallback type for entries the engine couldn't classify (e.g. bare `notes.md`).
1749
+ // We synthesize 'note' ONLY if the taxonomy actually declares 'note' as an artifact type.
1750
+ // Otherwise we leave the entry as AMBIGUOUS rather than producing a path that downstream
1751
+ // schema validation would reject. The operator can extend taxonomy.artifact_types and re-run.
1752
+ if (!entry.artifactType) {
1753
+ const validTypes = Array.isArray(taxonomy && taxonomy.artifact_types) ? taxonomy.artifact_types : [];
1754
+ if (!validTypes.includes('note')) {
1755
+ console.warn(
1756
+ `Warning: cannot honor resolution for ${entry.oldPath} — no artifact type inferable ` +
1757
+ `and 'note' is not in taxonomy.artifact_types. Entry left as AMBIGUOUS.`
1758
+ );
1759
+ // Skip the rename logic and let the entry fall through to whatever the next guard says.
1760
+ // We don't continue here so the existing no-candidates / force / interactive paths apply.
1761
+ } else {
1762
+ entry.artifactType = 'note';
1763
+ }
1764
+ }
1765
+ // Only proceed with the rename if we now have a type (either real or synthesized).
1766
+ if (entry.artifactType) {
1767
+ // Guard: entry.dir must be set or the rename target becomes 'undefined/foo.md'.
1768
+ // Derive from oldPath as a safety net.
1769
+ if (!entry.dir) {
1770
+ const lastSlash = entry.oldPath.lastIndexOf('/');
1771
+ entry.dir = lastSlash >= 0 ? entry.oldPath.slice(0, lastSlash) : '';
1772
+ }
1773
+ const filename = entry.oldPath.split('/').pop();
1774
+ const newFilename = generateNewFilename(filename, resolution.initiative, entry.artifactType, taxonomy);
1775
+ entry.newPath = entry.dir ? `${entry.dir}/${newFilename}` : newFilename;
1776
+ entry.action = 'RENAME';
1777
+ entry.confidence = 'high';
1778
+ entry.source = 'operator';
1779
+ resolved++;
1780
+ continue;
1781
+ }
1782
+ // Otherwise fall through to the normal AMBIGUOUS handling below.
1783
+ } else {
1784
+ // Unknown action — loadResolutionMap should have caught this. Throw rather than silently
1785
+ // dropping the operator's intent.
1786
+ throw new ArtifactMigrationError(
1787
+ `Resolution map for ${entry.oldPath} has unknown action '${resolution.action}'`,
1788
+ { phase: 'rename', recoverable: true }
1789
+ );
1790
+ }
1791
+ }
1792
+
1417
1793
  // Non-resolvable: no type or no candidates — auto-skip
1418
1794
  if (!entry.artifactType || !entry.candidates || entry.candidates.length === 0) {
1419
1795
  entry.action = 'SKIP';
@@ -1454,6 +1830,134 @@ async function resolveAmbiguous(manifest, taxonomy, _projectRoot, options = {})
1454
1830
  return { resolved, skipped };
1455
1831
  }
1456
1832
 
1833
+ /**
1834
+ * Load and validate a resolution-map JSON file for use with `resolveAmbiguous()`.
1835
+ *
1836
+ * Expected file shape:
1837
+ * ```json
1838
+ * {
1839
+ * "schemaVersion": 1,
1840
+ * "resolutions": {
1841
+ * "dir/file.md": { "action": "rename", "initiative": "convoke" },
1842
+ * "dir/other.md": { "action": "skip" }
1843
+ * }
1844
+ * }
1845
+ * ```
1846
+ *
1847
+ * Throws an `ArtifactMigrationError` (phase: 'rename', recoverable: true) on any validation
1848
+ * failure with a clear message. Validation is strict — partial files are not accepted.
1849
+ *
1850
+ * @param {string} filePath - Absolute path to the JSON file
1851
+ * @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy for initiative validation
1852
+ * @returns {Object} The validated `resolutions` map (oldPath → { action, initiative? })
1853
+ * @throws {ArtifactMigrationError}
1854
+ */
1855
+ function loadResolutionMap(filePath, taxonomy) {
1856
+ if (!fs.existsSync(filePath)) {
1857
+ throw new ArtifactMigrationError(
1858
+ `Resolution file not found: ${filePath}`,
1859
+ { phase: 'rename', recoverable: true }
1860
+ );
1861
+ }
1862
+
1863
+ let raw;
1864
+ try {
1865
+ raw = fs.readFileSync(filePath, 'utf8');
1866
+ } catch (err) {
1867
+ throw new ArtifactMigrationError(
1868
+ `Cannot read resolution file ${filePath}: ${err.message}`,
1869
+ { phase: 'rename', recoverable: true }
1870
+ );
1871
+ }
1872
+
1873
+ let parsed;
1874
+ try {
1875
+ parsed = JSON.parse(raw);
1876
+ } catch (err) {
1877
+ throw new ArtifactMigrationError(
1878
+ `Invalid JSON in resolution file ${filePath}: ${err.message}`,
1879
+ { phase: 'rename', recoverable: true }
1880
+ );
1881
+ }
1882
+
1883
+ if (!parsed || typeof parsed !== 'object') {
1884
+ throw new ArtifactMigrationError(
1885
+ `Resolution file must contain a JSON object: ${filePath}`,
1886
+ { phase: 'rename', recoverable: true }
1887
+ );
1888
+ }
1889
+
1890
+ if (parsed.schemaVersion !== 1) {
1891
+ throw new ArtifactMigrationError(
1892
+ `Unsupported schemaVersion ${parsed.schemaVersion} in ${filePath} (expected 1)`,
1893
+ { phase: 'rename', recoverable: true }
1894
+ );
1895
+ }
1896
+
1897
+ if (!parsed.resolutions || typeof parsed.resolutions !== 'object') {
1898
+ throw new ArtifactMigrationError(
1899
+ `Resolution file ${filePath} missing required 'resolutions' object`,
1900
+ { phase: 'rename', recoverable: true }
1901
+ );
1902
+ }
1903
+
1904
+ const validInitiatives = new Set([
1905
+ ...(Array.isArray(taxonomy && taxonomy.initiatives && taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : []),
1906
+ ...(Array.isArray(taxonomy && taxonomy.initiatives && taxonomy.initiatives.user) ? taxonomy.initiatives.user : [])
1907
+ ]);
1908
+
1909
+ // Use Object.create(null) for the output map so consumers can do plain `map[key]`
1910
+ // lookups without prototype pollution risk. We still validate the keys themselves.
1911
+ const safeMap = Object.create(null);
1912
+
1913
+ for (const [oldPath, resolution] of Object.entries(parsed.resolutions)) {
1914
+ // Reject keys that could pollute the prototype chain or are otherwise unsafe.
1915
+ if (oldPath === '__proto__' || oldPath === 'constructor' || oldPath === 'prototype') {
1916
+ throw new ArtifactMigrationError(
1917
+ `Unsafe resolution key '${oldPath}' rejected`,
1918
+ { phase: 'rename', recoverable: true }
1919
+ );
1920
+ }
1921
+ if (typeof oldPath !== 'string' || oldPath.length === 0) {
1922
+ throw new ArtifactMigrationError(
1923
+ `Resolution keys must be non-empty strings`,
1924
+ { phase: 'rename', recoverable: true }
1925
+ );
1926
+ }
1927
+ if (!resolution || typeof resolution !== 'object') {
1928
+ throw new ArtifactMigrationError(
1929
+ `Invalid resolution entry for ${oldPath}: must be an object`,
1930
+ { phase: 'rename', recoverable: true }
1931
+ );
1932
+ }
1933
+ if (resolution.action !== 'rename' && resolution.action !== 'skip') {
1934
+ throw new ArtifactMigrationError(
1935
+ `Invalid action '${resolution.action}' for ${oldPath} (expected 'rename' or 'skip')`,
1936
+ { phase: 'rename', recoverable: true }
1937
+ );
1938
+ }
1939
+ if (resolution.action === 'rename') {
1940
+ if (typeof resolution.initiative !== 'string' || resolution.initiative.length === 0) {
1941
+ throw new ArtifactMigrationError(
1942
+ `Resolution for ${oldPath} has action='rename' but no initiative`,
1943
+ { phase: 'rename', recoverable: true }
1944
+ );
1945
+ }
1946
+ if (!validInitiatives.has(resolution.initiative)) {
1947
+ throw new ArtifactMigrationError(
1948
+ `Unknown initiative '${resolution.initiative}' for ${oldPath} (not in taxonomy)`,
1949
+ { phase: 'rename', recoverable: true }
1950
+ );
1951
+ }
1952
+ safeMap[oldPath] = { action: 'rename', initiative: resolution.initiative };
1953
+ } else {
1954
+ safeMap[oldPath] = { action: 'skip' };
1955
+ }
1956
+ }
1957
+
1958
+ return safeMap;
1959
+ }
1960
+
1457
1961
  /**
1458
1962
  * Generate artifact-rename-map.md content as a markdown table.
1459
1963
  *
@@ -1647,6 +2151,9 @@ module.exports = {
1647
2151
  ARTIFACT_TYPE_ALIASES,
1648
2152
  inferArtifactType,
1649
2153
  inferInitiative,
2154
+ suggestInitiative,
2155
+ suggestDifferentiator,
2156
+ FOLDER_DEFAULT_MAP,
1650
2157
  getGovernanceState,
1651
2158
  generateNewFilename,
1652
2159
  // Git
@@ -1667,6 +2174,7 @@ module.exports = {
1667
2174
  // Interactive & Recovery
1668
2175
  promptInitiative,
1669
2176
  resolveAmbiguous,
2177
+ loadResolutionMap,
1670
2178
  generateRenameMap,
1671
2179
  detectMigrationState,
1672
2180
  generateGovernanceADR,