convoke-agents 3.0.4 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +14 -13
  3. package/_bmad/bme/_artifacts/config.yaml +15 -0
  4. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
  5. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
  6. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
  7. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
  8. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
  9. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
  10. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
  11. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
  12. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
  13. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
  14. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
  15. package/_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md +506 -0
  16. package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
  17. package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
  18. package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
  19. package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
  20. package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
  21. package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
  22. package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
  23. package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
  24. package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
  25. package/_bmad/bme/_team-factory/config.yaml +13 -0
  26. package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
  27. package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
  28. package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
  29. package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
  30. package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
  31. package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
  32. package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
  33. package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
  34. package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
  35. package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
  36. package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
  37. package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
  38. package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
  39. package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
  40. package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
  41. package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
  42. package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
  43. package/_bmad/bme/_team-factory/module-help.csv +3 -0
  44. package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
  45. package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
  46. package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
  47. package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
  48. package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
  49. package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
  50. package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
  51. package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
  52. package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
  53. package/_bmad/bme/_vortex/config.yaml +4 -4
  54. package/_bmad/bme/_vortex/guides/VORTEX-TEAM-GUIDE.md +441 -0
  55. package/package.json +17 -8
  56. package/scripts/archive.js +26 -45
  57. package/scripts/convoke-check.js +88 -0
  58. package/scripts/convoke-doctor.js +303 -4
  59. package/scripts/install-gyre-agents.js +0 -0
  60. package/scripts/lib/artifact-utils.js +2182 -0
  61. package/scripts/lib/portfolio/formatters/markdown-formatter.js +40 -0
  62. package/scripts/lib/portfolio/formatters/terminal-formatter.js +56 -0
  63. package/scripts/lib/portfolio/portfolio-engine.js +572 -0
  64. package/scripts/lib/portfolio/rules/artifact-chain-rule.js +156 -0
  65. package/scripts/lib/portfolio/rules/conflict-resolver.js +99 -0
  66. package/scripts/lib/portfolio/rules/frontmatter-rule.js +42 -0
  67. package/scripts/lib/portfolio/rules/git-recency-rule.js +69 -0
  68. package/scripts/lib/types.js +122 -0
  69. package/scripts/migrate-artifacts.js +439 -0
  70. package/scripts/portability/catalog-generator.js +353 -0
  71. package/scripts/portability/classify-skills.js +646 -0
  72. package/scripts/portability/convoke-export.js +522 -0
  73. package/scripts/portability/export-engine.js +1133 -0
  74. package/scripts/portability/generate-adapters.js +79 -0
  75. package/scripts/portability/manifest-csv.js +147 -0
  76. package/scripts/portability/seed-catalog-repo.js +427 -0
  77. package/scripts/portability/templates/canonical-example.md +102 -0
  78. package/scripts/portability/templates/canonical-format.md +218 -0
  79. package/scripts/portability/templates/readme-template.md +72 -0
  80. package/scripts/portability/test-constants.js +42 -0
  81. package/scripts/portability/validate-classification.js +529 -0
  82. package/scripts/portability/validate-exports.js +348 -0
  83. package/scripts/update/lib/agent-registry.js +35 -0
  84. package/scripts/update/lib/config-merger.js +140 -10
  85. package/scripts/update/lib/migration-runner.js +1 -1
  86. package/scripts/update/lib/refresh-installation.js +293 -8
  87. package/scripts/update/lib/taxonomy-merger.js +138 -0
  88. package/scripts/update/lib/utils.js +27 -1
  89. package/scripts/update/lib/validator.js +114 -4
  90. package/scripts/update/migrations/2.0.x-to-3.1.0.js +50 -0
  91. package/scripts/update/migrations/3.0.x-to-3.1.0.js +41 -0
  92. package/scripts/update/migrations/registry.js +14 -0
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ /** @typedef {import('./types/factory-types').ManifestEntry} ManifestEntry */
6
+
7
+ /**
8
+ * Build a file manifest from spec data and generation context.
9
+ * The manifest is built from tracked generation variables — NOT filesystem scanning.
10
+ *
11
+ * @param {Object} specData - Parsed team spec (needs team_name_kebab, spec_file_path)
12
+ * @param {Object} generationContext - Context from Step 4
13
+ * @param {string[]} generationContext.agent_files - Agent .md file paths
14
+ * @param {string[]} generationContext.workflow_dirs - Workflow directory paths
15
+ * @param {string[]} [generationContext.contract_files] - Contract .md file paths
16
+ * @param {string} generationContext.config_yaml_path - Path to config.yaml
17
+ * @param {string} generationContext.module_help_csv_path - Path to module-help.csv
18
+ * @param {string} generationContext.module_root - Module root directory
19
+ * @returns {ManifestEntry[]}
20
+ */
21
+ function buildManifest(specData, generationContext) {
22
+ const entries = [];
23
+ const moduleName = specData.team_name_kebab || 'unknown';
24
+
25
+ // Agent files — created
26
+ for (const agentFile of (generationContext.agent_files || [])) {
27
+ entries.push({ path: agentFile, operation: 'created', module: moduleName });
28
+ }
29
+
30
+ // Workflow directories — each gets a workflow.md and SKILL.md
31
+ for (const wfDir of (generationContext.workflow_dirs || [])) {
32
+ entries.push({ path: path.join(wfDir, 'workflow.md'), operation: 'created', module: moduleName });
33
+ entries.push({ path: path.join(wfDir, 'SKILL.md'), operation: 'created', module: moduleName });
34
+ }
35
+
36
+ // Contract files — created
37
+ for (const contractFile of (generationContext.contract_files || [])) {
38
+ entries.push({ path: contractFile, operation: 'created', module: moduleName });
39
+ }
40
+
41
+ // Compass routing reference — created (if exists in generated_files)
42
+ const compassFile = path.join(generationContext.module_root || '', 'compass-routing-reference.md');
43
+ const generatedFiles = generationContext.generated_files || [];
44
+ if (generatedFiles.includes(compassFile)) {
45
+ entries.push({ path: compassFile, operation: 'created', module: moduleName });
46
+ }
47
+
48
+ // Config.yaml — created
49
+ if (generationContext.config_yaml_path) {
50
+ entries.push({ path: generationContext.config_yaml_path, operation: 'created', module: moduleName });
51
+ }
52
+
53
+ // Module-help.csv — created
54
+ if (generationContext.module_help_csv_path) {
55
+ entries.push({ path: generationContext.module_help_csv_path, operation: 'created', module: moduleName });
56
+ }
57
+
58
+ // agent-registry.js — modified
59
+ entries.push({ path: 'scripts/update/lib/agent-registry.js', operation: 'modified', module: moduleName });
60
+
61
+ // Spec file — modified (progress updated)
62
+ if (specData.spec_file_path) {
63
+ entries.push({ path: specData.spec_file_path, operation: 'modified', module: moduleName });
64
+ }
65
+
66
+ return entries;
67
+ }
68
+
69
+ /**
70
+ * Format a manifest as a human-readable markdown table.
71
+ * @param {ManifestEntry[]} entries
72
+ * @returns {string}
73
+ */
74
+ function formatManifest(entries) {
75
+ const lines = [];
76
+ lines.push('| # | Path | Operation | Module |');
77
+ lines.push('|---|------|-----------|--------|');
78
+ entries.forEach((entry, i) => {
79
+ lines.push(`| ${i + 1} | \`${entry.path}\` | ${entry.operation} | ${entry.module} |`);
80
+ });
81
+ return lines.join('\n');
82
+ }
83
+
84
+ /**
85
+ * Format abort/removal instructions from a manifest.
86
+ * Created files get `rm`, modified files get `git checkout --`.
87
+ * @param {ManifestEntry[]} entries
88
+ * @returns {string}
89
+ */
90
+ function formatAbortInstructions(entries) {
91
+ const lines = [];
92
+ lines.push('# Removal instructions');
93
+ lines.push('');
94
+
95
+ const created = entries.filter(e => e.operation === 'created');
96
+ const modified = entries.filter(e => e.operation === 'modified');
97
+
98
+ if (created.length > 0) {
99
+ lines.push('# Created files — remove:');
100
+ for (const entry of created) {
101
+ lines.push(`rm "${entry.path}"`);
102
+ }
103
+ lines.push('');
104
+ }
105
+
106
+ if (modified.length > 0) {
107
+ lines.push('# Modified files — revert:');
108
+ for (const entry of modified) {
109
+ lines.push(`git checkout -- "${entry.path}"`);
110
+ }
111
+ }
112
+
113
+ return lines.join('\n');
114
+ }
115
+
116
+ /**
117
+ * Build a file manifest for an extension operation (add agent to existing team).
118
+ * New agent/workflow/contract files are "created"; config, CSV, and registry are "modified".
119
+ *
120
+ * @param {Object} extensionContext
121
+ * @param {string} extensionContext.new_agent_id - New agent ID (for module label)
122
+ * @param {string[]} extensionContext.new_agent_files - New agent .md file paths
123
+ * @param {string[]} extensionContext.new_workflow_dirs - New workflow directory paths
124
+ * @param {string[]} [extensionContext.new_contract_files] - New contract file paths
125
+ * @param {string} extensionContext.config_yaml_path - Path to config.yaml (modified)
126
+ * @param {string} extensionContext.module_help_csv_path - Path to module-help.csv (modified)
127
+ * @returns {ManifestEntry[]}
128
+ */
129
+ function buildExtensionManifest(extensionContext) {
130
+ const entries = [];
131
+ const moduleName = extensionContext.new_agent_id || 'unknown-agent';
132
+
133
+ // New agent files — created
134
+ for (const agentFile of (extensionContext.new_agent_files || [])) {
135
+ entries.push({ path: agentFile, operation: 'created', module: moduleName });
136
+ }
137
+
138
+ // New workflow directories — each gets workflow.md and SKILL.md
139
+ for (const wfDir of (extensionContext.new_workflow_dirs || [])) {
140
+ entries.push({ path: path.join(wfDir, 'workflow.md'), operation: 'created', module: moduleName });
141
+ entries.push({ path: path.join(wfDir, 'SKILL.md'), operation: 'created', module: moduleName });
142
+ }
143
+
144
+ // New contract files — created
145
+ for (const contractFile of (extensionContext.new_contract_files || [])) {
146
+ entries.push({ path: contractFile, operation: 'created', module: moduleName });
147
+ }
148
+
149
+ // Config.yaml — modified (agent appended)
150
+ if (extensionContext.config_yaml_path) {
151
+ entries.push({ path: extensionContext.config_yaml_path, operation: 'modified', module: moduleName });
152
+ }
153
+
154
+ // Module-help.csv — modified (row appended)
155
+ if (extensionContext.module_help_csv_path) {
156
+ entries.push({ path: extensionContext.module_help_csv_path, operation: 'modified', module: moduleName });
157
+ }
158
+
159
+ // agent-registry.js — modified (agent appended to existing block)
160
+ entries.push({ path: 'scripts/update/lib/agent-registry.js', operation: 'modified', module: moduleName });
161
+
162
+ return entries;
163
+ }
164
+
165
+ /**
166
+ * Build a file manifest for a skill/workflow extension (add workflow to existing agent).
167
+ * New workflow files are "created"; agent .md, config, CSV, and registry are "modified".
168
+ *
169
+ * @param {Object} skillContext
170
+ * @param {string} skillContext.new_workflow_name - New workflow name (for module label)
171
+ * @param {string} skillContext.agent_id - Target agent ID
172
+ * @param {string[]} skillContext.new_workflow_files - New workflow file paths (workflow.md, template)
173
+ * @param {string} [skillContext.agent_file_path] - Agent .md file path (modified for menu)
174
+ * @param {string} skillContext.config_yaml_path - Path to config.yaml (modified)
175
+ * @param {string} skillContext.module_help_csv_path - Path to module-help.csv (modified)
176
+ * @returns {ManifestEntry[]}
177
+ */
178
+ function buildSkillExtensionManifest(skillContext) {
179
+ const entries = [];
180
+ const moduleName = skillContext.new_workflow_name || 'unknown-workflow';
181
+
182
+ // New workflow files — created
183
+ for (const wfFile of (skillContext.new_workflow_files || [])) {
184
+ entries.push({ path: wfFile, operation: 'created', module: moduleName });
185
+ }
186
+
187
+ // Agent .md file — modified (menu item added)
188
+ if (skillContext.agent_file_path) {
189
+ entries.push({ path: skillContext.agent_file_path, operation: 'modified', module: moduleName });
190
+ }
191
+
192
+ // Config.yaml — modified (workflow appended)
193
+ if (skillContext.config_yaml_path) {
194
+ entries.push({ path: skillContext.config_yaml_path, operation: 'modified', module: moduleName });
195
+ }
196
+
197
+ // Module-help.csv — modified (row appended)
198
+ if (skillContext.module_help_csv_path) {
199
+ entries.push({ path: skillContext.module_help_csv_path, operation: 'modified', module: moduleName });
200
+ }
201
+
202
+ // agent-registry.js — modified (workflow appended to existing block)
203
+ entries.push({ path: 'scripts/update/lib/agent-registry.js', operation: 'modified', module: moduleName });
204
+
205
+ return entries;
206
+ }
207
+
208
+ module.exports = {
209
+ buildManifest,
210
+ buildExtensionManifest,
211
+ buildSkillExtensionManifest,
212
+ formatManifest,
213
+ formatAbortInstructions,
214
+ };
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const yaml = require('js-yaml');
5
+
6
+ /** @typedef {import('./types/factory-types').TeamSpec} TeamSpec */
7
+
8
+ /**
9
+ * Step completion states.
10
+ * @type {Object<string, string>}
11
+ */
12
+ const STEP_ORDER = ['orient', 'scope', 'connect', 'review', 'generate', 'validate'];
13
+
14
+ /**
15
+ * Determine the resume point for a spec file by reading its progress section.
16
+ * Returns the first step that is not 'complete'.
17
+ *
18
+ * For the 'generate' step, also checks per-agent completion status
19
+ * and returns which agents still need generation.
20
+ *
21
+ * @param {string} specPath - Absolute path to the spec file
22
+ * @returns {Promise<ResumeResult>}
23
+ */
24
+ async function findResumePoint(specPath) {
25
+ let raw;
26
+ try {
27
+ raw = await fs.readFile(specPath, 'utf8');
28
+ } catch (err) {
29
+ return { resumable: false, resumeStep: null, pendingAgents: [], errors: [`Cannot read spec file: ${err.message}`] };
30
+ }
31
+
32
+ let doc;
33
+ try {
34
+ doc = yaml.load(raw);
35
+ } catch (err) {
36
+ return { resumable: false, resumeStep: null, pendingAgents: [], errors: [`Invalid YAML: ${err.message}`] };
37
+ }
38
+
39
+ if (!doc || !doc.progress) {
40
+ return { resumable: false, resumeStep: null, pendingAgents: [], errors: ['Spec file has no progress section'] };
41
+ }
42
+
43
+ const progress = doc.progress;
44
+
45
+ // Find first non-complete step
46
+ for (const step of STEP_ORDER) {
47
+ const status = typeof progress[step] === 'string' ? progress[step] : null;
48
+
49
+ if (step === 'generate' && typeof progress[step] === 'object') {
50
+ // Generate step has per-agent tracking
51
+ const pending = [];
52
+ for (const [agentId, agentStatus] of Object.entries(progress[step])) {
53
+ if (agentStatus !== 'complete') {
54
+ pending.push(agentId);
55
+ }
56
+ }
57
+ if (pending.length > 0) {
58
+ return { resumable: true, resumeStep: 'generate', pendingAgents: pending, errors: [] };
59
+ }
60
+ // All agents complete — continue to next step
61
+ continue;
62
+ }
63
+
64
+ if (status !== 'complete') {
65
+ return { resumable: true, resumeStep: step, pendingAgents: [], errors: [] };
66
+ }
67
+ }
68
+
69
+ // All steps complete
70
+ return { resumable: false, resumeStep: null, pendingAgents: [], errors: [], allComplete: true };
71
+ }
72
+
73
+ /**
74
+ * Compare two spec objects and return which fields changed.
75
+ * Used for displaying diffs when resuming or in express mode.
76
+ *
77
+ * @param {TeamSpec} oldSpec - Previous spec state
78
+ * @param {TeamSpec} newSpec - Current spec state
79
+ * @returns {SpecDiff}
80
+ */
81
+ function diffSpecs(oldSpec, newSpec) {
82
+ const changes = [];
83
+
84
+ // Compare top-level scalar fields
85
+ const scalarFields = ['team_name', 'team_name_kebab', 'description', 'composition_pattern', 'factory_version'];
86
+ for (const field of scalarFields) {
87
+ if (oldSpec[field] !== newSpec[field]) {
88
+ changes.push({ field, oldValue: oldSpec[field], newValue: newSpec[field] });
89
+ }
90
+ }
91
+
92
+ // Compare agent count
93
+ const oldAgentCount = (oldSpec.agents || []).length;
94
+ const newAgentCount = (newSpec.agents || []).length;
95
+ if (oldAgentCount !== newAgentCount) {
96
+ changes.push({ field: 'agents.length', oldValue: String(oldAgentCount), newValue: String(newAgentCount) });
97
+ }
98
+
99
+ // Compare agent IDs
100
+ const oldIds = (oldSpec.agents || []).map(a => a.id).sort();
101
+ const newIds = (newSpec.agents || []).map(a => a.id).sort();
102
+ const addedAgents = newIds.filter(id => !oldIds.includes(id));
103
+ const removedAgents = oldIds.filter(id => !newIds.includes(id));
104
+ if (addedAgents.length > 0) {
105
+ changes.push({ field: 'agents.added', oldValue: '', newValue: addedAgents.join(', ') });
106
+ }
107
+ if (removedAgents.length > 0) {
108
+ changes.push({ field: 'agents.removed', oldValue: removedAgents.join(', '), newValue: '' });
109
+ }
110
+
111
+ // Compare contract count (Sequential only)
112
+ const oldContracts = (oldSpec.contracts || []).length;
113
+ const newContracts = (newSpec.contracts || []).length;
114
+ if (oldContracts !== newContracts) {
115
+ changes.push({ field: 'contracts.length', oldValue: String(oldContracts), newValue: String(newContracts) });
116
+ }
117
+
118
+ // Compare progress
119
+ for (const step of STEP_ORDER) {
120
+ const oldStatus = typeof oldSpec.progress?.[step] === 'string' ? oldSpec.progress[step] : stableStringify(oldSpec.progress?.[step]);
121
+ const newStatus = typeof newSpec.progress?.[step] === 'string' ? newSpec.progress[step] : stableStringify(newSpec.progress?.[step]);
122
+ if (oldStatus !== newStatus) {
123
+ changes.push({ field: `progress.${step}`, oldValue: oldStatus || 'undefined', newValue: newStatus || 'undefined' });
124
+ }
125
+ }
126
+
127
+ return {
128
+ hasChanges: changes.length > 0,
129
+ changeCount: changes.length,
130
+ changes,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * JSON.stringify with sorted keys for order-independent comparison.
136
+ * @param {*} obj
137
+ * @returns {string}
138
+ */
139
+ function stableStringify(obj) {
140
+ if (obj === null || obj === undefined) return String(obj);
141
+ if (typeof obj !== 'object') return JSON.stringify(obj);
142
+ const sorted = Object.keys(obj).sort().reduce((acc, key) => {
143
+ acc[key] = obj[key];
144
+ return acc;
145
+ }, {});
146
+ return JSON.stringify(sorted);
147
+ }
148
+
149
+ /**
150
+ * @typedef {Object} ResumeResult
151
+ * @property {boolean} resumable - True if there's a step to resume from
152
+ * @property {string|null} resumeStep - The step to resume from
153
+ * @property {string[]} pendingAgents - For generate step: agents still pending
154
+ * @property {string[]} errors
155
+ * @property {boolean} [allComplete] - True if all steps are complete
156
+ */
157
+
158
+ /**
159
+ * @typedef {Object} SpecDiff
160
+ * @property {boolean} hasChanges
161
+ * @property {number} changeCount
162
+ * @property {SpecChange[]} changes
163
+ */
164
+
165
+ /**
166
+ * @typedef {Object} SpecChange
167
+ * @property {string} field - Dot-path to the changed field
168
+ * @property {string} oldValue
169
+ * @property {string} newValue
170
+ */
171
+
172
+ module.exports = {
173
+ findResumePoint,
174
+ diffSpecs,
175
+ STEP_ORDER,
176
+ };
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const yaml = require('js-yaml');
6
+
7
+ /** @typedef {import('./types/factory-types').TeamSpec} TeamSpec */
8
+
9
+ const SCHEMAS_DIR = path.join(__dirname, '..', 'schemas');
10
+
11
+ /**
12
+ * Load and validate a team spec YAML file.
13
+ * Selects schema by composition_pattern, validates required fields and naming.
14
+ *
15
+ * @param {string} specPath - Absolute path to team spec YAML file
16
+ * @returns {Promise<{ valid: boolean, spec: TeamSpec|null, errors: string[] }>}
17
+ */
18
+ async function parseSpec(specPath) {
19
+ let raw;
20
+ try {
21
+ raw = await fs.readFile(specPath, 'utf8');
22
+ } catch (err) {
23
+ return { valid: false, spec: null, errors: [`Cannot read spec file: ${err.message}`] };
24
+ }
25
+
26
+ let doc;
27
+ try {
28
+ doc = yaml.load(raw);
29
+ } catch (err) {
30
+ return { valid: false, spec: null, errors: [`Invalid YAML: ${err.message}`] };
31
+ }
32
+
33
+ if (!doc || typeof doc !== 'object') {
34
+ return { valid: false, spec: null, errors: ['Spec file is empty or not an object'] };
35
+ }
36
+
37
+ // Determine pattern and load schema
38
+ const pattern = doc.composition_pattern;
39
+ if (!pattern) {
40
+ return { valid: false, spec: null, errors: ['Missing required field: composition_pattern'] };
41
+ }
42
+
43
+ const schemaFile = pattern === 'Sequential'
44
+ ? 'schema-sequential.json'
45
+ : pattern === 'Independent'
46
+ ? 'schema-independent.json'
47
+ : null;
48
+
49
+ if (!schemaFile) {
50
+ return { valid: false, spec: null, errors: [`Unknown composition_pattern: "${pattern}". Expected "Independent" or "Sequential"`] };
51
+ }
52
+
53
+ // Load schema for reference (structural validation below)
54
+ const schemaPath = path.join(SCHEMAS_DIR, schemaFile);
55
+ let schema;
56
+ try {
57
+ schema = JSON.parse(await fs.readFile(schemaPath, 'utf8'));
58
+ } catch (err) {
59
+ return { valid: false, spec: null, errors: [`Cannot load schema ${schemaFile}: ${err.message}`] };
60
+ }
61
+
62
+ // Validate against schema required fields and patterns
63
+ const errors = validateAgainstSchema(doc, schema, pattern);
64
+
65
+ if (errors.length > 0) {
66
+ return { valid: false, spec: null, errors };
67
+ }
68
+
69
+ return { valid: true, spec: doc, errors: [] };
70
+ }
71
+
72
+ /**
73
+ * Validate a parsed document against schema required fields, types, and patterns.
74
+ * Lightweight validation without ajv — checks required fields, naming patterns, and structural rules.
75
+ *
76
+ * @param {Object} doc - Parsed YAML document
77
+ * @param {Object} schema - JSON Schema object
78
+ * @param {string} pattern - "Independent" or "Sequential"
79
+ * @returns {string[]} errors
80
+ */
81
+ function validateAgainstSchema(doc, schema, pattern) {
82
+ const errors = [];
83
+
84
+ // Check required root fields
85
+ const required = schema.required || [];
86
+ for (const field of required) {
87
+ if (doc[field] === undefined || doc[field] === null) {
88
+ errors.push(`Missing required field: ${field}`);
89
+ }
90
+ }
91
+
92
+ // Type checks on key fields
93
+ if (doc.schema_version !== undefined && typeof doc.schema_version !== 'string') {
94
+ errors.push(`schema_version must be a string, got ${typeof doc.schema_version}`);
95
+ } else if (doc.schema_version && !/^\d+\.\d+$/.test(doc.schema_version)) {
96
+ errors.push(`schema_version "${doc.schema_version}" does not match pattern "N.N" (e.g., "1.0")`);
97
+ }
98
+
99
+ if (doc.composition_pattern !== undefined && doc.composition_pattern !== pattern) {
100
+ errors.push(`composition_pattern "${doc.composition_pattern}" does not match expected "${pattern}"`);
101
+ }
102
+
103
+ if (doc.team_name !== undefined && (typeof doc.team_name !== 'string' || doc.team_name.length === 0)) {
104
+ errors.push('team_name must be a non-empty string');
105
+ }
106
+
107
+ // Validate naming patterns
108
+ const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
109
+ const AGENT_ID_RE = /^[a-z]+(-[a-z]+)*$/;
110
+
111
+ if (doc.team_name_kebab && !KEBAB_RE.test(doc.team_name_kebab)) {
112
+ errors.push(`team_name_kebab "${doc.team_name_kebab}" does not match kebab-case pattern: ${KEBAB_RE}`);
113
+ }
114
+
115
+ // Validate agents array
116
+ if (Array.isArray(doc.agents)) {
117
+ if (doc.agents.length === 0) {
118
+ errors.push('agents array must contain at least one agent');
119
+ }
120
+ for (let i = 0; i < doc.agents.length; i++) {
121
+ const agent = doc.agents[i];
122
+ if (!agent.id) {
123
+ errors.push(`agents[${i}]: missing required field "id"`);
124
+ } else if (!AGENT_ID_RE.test(agent.id)) {
125
+ errors.push(`agents[${i}].id "${agent.id}" does not match agent ID pattern: ${AGENT_ID_RE}`);
126
+ }
127
+ if (!agent.role) {
128
+ errors.push(`agents[${i}]: missing required field "role"`);
129
+ }
130
+
131
+ // Sequential-specific: pipeline_position required
132
+ if (pattern === 'Sequential' && agent.pipeline_position === undefined) {
133
+ errors.push(`agents[${i}]: Sequential pattern requires pipeline_position`);
134
+ }
135
+ }
136
+
137
+ // Check for duplicate agent IDs
138
+ const ids = doc.agents.map(a => a.id).filter(Boolean);
139
+ const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
140
+ if (dupes.length > 0) {
141
+ errors.push(`Duplicate agent IDs: ${[...new Set(dupes)].join(', ')}`);
142
+ }
143
+ } else if (required.includes('agents')) {
144
+ errors.push('agents must be an array');
145
+ }
146
+
147
+ // Sequential-specific validations
148
+ if (pattern === 'Sequential') {
149
+ if (!Array.isArray(doc.contracts) || doc.contracts.length === 0) {
150
+ errors.push('Sequential pattern requires at least one contract');
151
+ } else {
152
+ for (let i = 0; i < doc.contracts.length; i++) {
153
+ const c = doc.contracts[i];
154
+ if (!c.id) errors.push(`contracts[${i}]: missing required field "id"`);
155
+ if (!c.source_agent) errors.push(`contracts[${i}]: missing required field "source_agent"`);
156
+ if (!Array.isArray(c.target_agents) || c.target_agents.length === 0) {
157
+ errors.push(`contracts[${i}]: target_agents must be a non-empty array`);
158
+ }
159
+ }
160
+ }
161
+
162
+ if (!doc.integration?.contract_prefix) {
163
+ errors.push('Sequential pattern requires integration.contract_prefix');
164
+ }
165
+ }
166
+
167
+ // Validate integration block
168
+ if (doc.integration) {
169
+ if (typeof doc.integration !== 'object' || Array.isArray(doc.integration)) {
170
+ errors.push('integration must be an object');
171
+ } else if (!doc.integration.output_directory) {
172
+ errors.push('integration.output_directory is required');
173
+ }
174
+ }
175
+
176
+ return errors;
177
+ }
178
+
179
+ /**
180
+ * Parse a spec from a YAML string (for testing or in-memory use).
181
+ * Pattern is determined from the document's composition_pattern field.
182
+ *
183
+ * @param {string} yamlString - Raw YAML content
184
+ * @returns {Promise<{ valid: boolean, spec: TeamSpec|null, errors: string[] }>}
185
+ */
186
+ async function parseSpecFromString(yamlString) {
187
+ const tmpDir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'bmad-tf-parse-'));
188
+ const tmpFile = path.join(tmpDir, 'spec.yaml');
189
+ try {
190
+ await fs.writeFile(tmpFile, yamlString, 'utf8');
191
+ return await parseSpec(tmpFile);
192
+ } finally {
193
+ await fs.remove(tmpDir);
194
+ }
195
+ }
196
+
197
+ module.exports = {
198
+ parseSpec,
199
+ parseSpecFromString,
200
+ validateAgainstSchema,
201
+ };