convoke-agents 3.1.0 → 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 (78) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  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 +12 -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 +1133 -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
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+
6
+ /** @typedef {import('../types/factory-types')} Types */
7
+
8
+ /**
9
+ * Regex to extract activation XML block from agent markdown files.
10
+ * Matches <activation ...>...</activation> including multiline content.
11
+ */
12
+ const ACTIVATION_REGEX = /<activation[^>]*>([\s\S]*?)<\/activation>/;
13
+
14
+ /**
15
+ * Validate activation blocks in generated agent .md files.
16
+ * Read-only — this module NEVER writes to any file.
17
+ *
18
+ * Checks:
19
+ * 1. Activation block exists in the agent file
20
+ * 2. Config path reference points to the team's config.yaml
21
+ * 3. Module path reference is correct
22
+ *
23
+ * @param {string[]} agentFiles - Array of absolute paths to agent .md files
24
+ * @param {Object} moduleConfig - Module context for validation
25
+ * @param {string} moduleConfig.configPath - Expected config.yaml path
26
+ * @param {string} moduleConfig.modulePath - Expected module path (e.g., "bme/_team-name")
27
+ * @param {string} moduleConfig.moduleDir - Absolute path to module directory
28
+ * @returns {Promise<import('../types/factory-types').ValidationResult>}
29
+ */
30
+ async function validateActivation(agentFiles, moduleConfig) {
31
+ const results = [];
32
+
33
+ for (const agentFile of agentFiles) {
34
+ const result = await validateSingleAgent(agentFile, moduleConfig);
35
+ results.push(result);
36
+ }
37
+
38
+ const valid = results.every(r => r.errors.length === 0);
39
+ return { valid, results };
40
+ }
41
+
42
+ /**
43
+ * Validate a single agent file's activation block.
44
+ * @param {string} agentFile - Absolute path to agent .md file
45
+ * @param {Object} moduleConfig - Module context
46
+ * @returns {Promise<import('../types/factory-types').ActivationResult>}
47
+ */
48
+ async function validateSingleAgent(agentFile, moduleConfig) {
49
+ const checks = [];
50
+ const errors = [];
51
+
52
+ // Read agent file
53
+ let content;
54
+ try {
55
+ content = await fs.readFile(agentFile, 'utf8');
56
+ } catch (err) {
57
+ return { agentFile, checks: [], errors: [`Cannot read agent file: ${err.message}`] };
58
+ }
59
+
60
+ // Check 1: Activation block exists
61
+ const match = content.match(ACTIVATION_REGEX);
62
+ if (!match) {
63
+ checks.push({ check: 'Activation block exists', passed: false, detail: 'No <activation> block found in agent file' });
64
+ errors.push('No <activation> block found');
65
+ return { agentFile, checks, errors };
66
+ }
67
+ checks.push({ check: 'Activation block exists', passed: true });
68
+
69
+ const activationContent = match[0];
70
+
71
+ // Check 2: Config path reference
72
+ const configPathValid = activationContent.includes(moduleConfig.configPath);
73
+ checks.push({
74
+ check: 'Config path reference',
75
+ passed: configPathValid,
76
+ detail: configPathValid ? undefined : `Expected reference to "${moduleConfig.configPath}" not found in activation block`
77
+ });
78
+ if (!configPathValid) {
79
+ errors.push(`Config path "${moduleConfig.configPath}" not referenced in activation block`);
80
+ }
81
+
82
+ // Check 3: Config file exists on disk
83
+ const configAbsPath = path.resolve(moduleConfig.moduleDir, 'config.yaml');
84
+ const configExists = await fs.pathExists(configAbsPath);
85
+ checks.push({
86
+ check: 'Config file exists',
87
+ passed: configExists,
88
+ detail: configExists ? undefined : `Config file not found at ${configAbsPath}`
89
+ });
90
+ if (!configExists) {
91
+ errors.push(`Config file does not exist at ${configAbsPath}`);
92
+ }
93
+
94
+ // Check 4: Module path reference — strict module= attribute match only
95
+ const moduleAttrRegex = /module\s*=\s*"([^"]*)"/;
96
+ const moduleAttrMatch = activationContent.match(moduleAttrRegex);
97
+ const modulePathValid = moduleAttrMatch
98
+ ? moduleAttrMatch[1] === moduleConfig.modulePath
99
+ : false;
100
+ checks.push({
101
+ check: 'Module path reference',
102
+ passed: modulePathValid,
103
+ detail: modulePathValid
104
+ ? undefined
105
+ : moduleAttrMatch
106
+ ? `Expected module="${moduleConfig.modulePath}" but found module="${moduleAttrMatch[1]}" in activation block`
107
+ : `No module="..." attribute found in activation block`
108
+ });
109
+ if (!modulePathValid) {
110
+ errors.push(`Module path "${moduleConfig.modulePath}" not referenced correctly in activation block`);
111
+ }
112
+
113
+ // Check 5: Module directory exists
114
+ const moduleDirExists = await fs.pathExists(moduleConfig.moduleDir);
115
+ checks.push({
116
+ check: 'Module directory exists',
117
+ passed: moduleDirExists,
118
+ detail: moduleDirExists ? undefined : `Module directory not found at ${moduleConfig.moduleDir}`
119
+ });
120
+ if (!moduleDirExists) {
121
+ errors.push(`Module directory does not exist at ${moduleConfig.moduleDir}`);
122
+ }
123
+
124
+ return { agentFile, checks, errors };
125
+ }
126
+
127
+ // --- CLI entry point ---
128
+ if (require.main === module) {
129
+ const args = process.argv.slice(2);
130
+ const agentFilesIdx = args.indexOf('--agent-files');
131
+ const configPathIdx = args.indexOf('--config-path');
132
+
133
+ if (agentFilesIdx === -1 || configPathIdx === -1) {
134
+ console.error('Usage: node activation-validator.js --agent-files <glob-or-paths> --config-path <path>');
135
+ process.exit(1);
136
+ }
137
+
138
+ const agentGlob = args[agentFilesIdx + 1];
139
+ const configPath = args[configPathIdx + 1];
140
+
141
+ (async () => {
142
+ try {
143
+ // Resolve agent files from glob or comma-separated list
144
+ let agentFiles;
145
+ if (agentGlob.includes('*')) {
146
+ // Use fs.readdir-based simple glob for *.md in a directory
147
+ const dir = path.dirname(agentGlob);
148
+ const entries = await fs.readdir(dir);
149
+ agentFiles = entries
150
+ .filter(e => e.endsWith('.md'))
151
+ .map(e => path.join(dir, e));
152
+ } else {
153
+ agentFiles = agentGlob.split(',').map(f => f.trim());
154
+ }
155
+
156
+ // Derive module context from config path
157
+ const moduleDir = path.dirname(configPath);
158
+ const modulePath = path.relative(path.resolve(moduleDir, '../../'), moduleDir);
159
+
160
+ const result = await validateActivation(agentFiles, {
161
+ configPath: configPath,
162
+ modulePath: modulePath,
163
+ moduleDir: moduleDir
164
+ });
165
+
166
+ console.log(JSON.stringify(result, null, 2));
167
+ process.exit(result.valid ? 0 : 1);
168
+ } catch (err) {
169
+ console.log(JSON.stringify({ valid: false, results: [], errors: [err.message] }, null, 2));
170
+ process.exit(1);
171
+ }
172
+ })();
173
+ }
174
+
175
+ module.exports = { validateActivation, validateSingleAgent, ACTIVATION_REGEX };
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const yaml = require('js-yaml');
6
+ const YAML = require('yaml'); // Comment-preserving YAML library (ag-7-1: I10). Used for the WRITE round-trip; js-yaml stays for the post-write read-back validation.
7
+ const { checkDirtyTree } = require('./registry-writer');
8
+
9
+ /** @typedef {import('../types/factory-types').CreatorResult} CreatorResult */
10
+
11
+ /**
12
+ * Append a new agent ID to an existing team's config.yaml.
13
+ * Enhanced Simple safety: read → validate → write (.tmp) → verify parse → rename.
14
+ *
15
+ * @param {string} newAgentId - Agent ID to add (e.g., "gamma-guardian")
16
+ * @param {string} configPath - Absolute path to existing config.yaml
17
+ * @param {Object} [options]
18
+ * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests)
19
+ * @returns {Promise<CreatorResult>}
20
+ */
21
+ async function appendConfigAgent(newAgentId, configPath, options = {}) {
22
+ if (!newAgentId || !newAgentId.trim()) {
23
+ return { success: false, filePath: configPath, errors: ['newAgentId is required'] };
24
+ }
25
+
26
+ // --- Read existing config ---
27
+ if (!await fs.pathExists(configPath)) {
28
+ return { success: false, filePath: configPath, errors: ['config.yaml does not exist at target path'] };
29
+ }
30
+
31
+ // --- Dirty-tree detection (per-write) ---
32
+ if (!options.skipDirtyCheck) {
33
+ const dirtyResult = checkDirtyTree(configPath);
34
+ if (dirtyResult.dirty) {
35
+ return { success: false, filePath: configPath, errors: [], dirty: true, diff: dirtyResult.diff };
36
+ }
37
+ }
38
+
39
+ let content;
40
+ try {
41
+ content = await fs.readFile(configPath, 'utf8');
42
+ } catch (err) {
43
+ return { success: false, filePath: configPath, errors: [`Cannot read config: ${err.message}`] };
44
+ }
45
+
46
+ // --- Parse and validate (ag-7-1: I10 — use comment-preserving YAML.parseDocument) ---
47
+ // Note: YAML.parseDocument does NOT throw on syntax errors; it returns a Document
48
+ // with .errors populated. Check both throw AND doc.errors to match js-yaml semantics.
49
+ let doc;
50
+ let config;
51
+ try {
52
+ doc = YAML.parseDocument(content);
53
+ if (doc.errors && doc.errors.length > 0) {
54
+ return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${doc.errors[0].message}`] };
55
+ }
56
+ config = doc.toJSON();
57
+ } catch (err) {
58
+ return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${err.message}`] };
59
+ }
60
+
61
+ if (!config || !Array.isArray(config.agents)) {
62
+ return { success: false, filePath: configPath, errors: ['config.yaml missing agents array'] };
63
+ }
64
+
65
+ // --- Duplicate check (additive-only) ---
66
+ if (config.agents.includes(newAgentId)) {
67
+ return { success: true, filePath: configPath, errors: [], skipped: 'agent already in config' };
68
+ }
69
+
70
+ // --- Append (mutate via Document API to preserve comments) ---
71
+ const agentsNode = doc.get('agents');
72
+ if (agentsNode && typeof agentsNode.add === 'function') {
73
+ agentsNode.add(newAgentId);
74
+ } else {
75
+ // Fallback: create a new sequence with the appended item
76
+ doc.set('agents', [...config.agents, newAgentId]);
77
+ }
78
+
79
+ // --- Atomic write (.tmp → validate → rename) ---
80
+ const tmpPath = configPath + '.tmp';
81
+ try {
82
+ const newContent = doc.toString({ lineWidth: 0 });
83
+ await fs.writeFile(tmpPath, newContent, 'utf8');
84
+
85
+ // Verify parse of tmp file (ag-7-1 Task 5.4: read-back stays on js-yaml — checks structure only, not comments)
86
+ const readBack = yaml.load(await fs.readFile(tmpPath, 'utf8'));
87
+ if (!readBack || !Array.isArray(readBack.agents) || !readBack.agents.includes(newAgentId)) {
88
+ await fs.remove(tmpPath);
89
+ return { success: false, filePath: configPath, errors: ['Post-write verification failed: new agent not found in parsed output'] };
90
+ }
91
+
92
+ // Rename tmp → target
93
+ await fs.rename(tmpPath, configPath);
94
+ } catch (err) {
95
+ await fs.remove(tmpPath).catch(() => {});
96
+ return { success: false, filePath: configPath, errors: [`Write failed: ${err.message}`] };
97
+ }
98
+
99
+ return { success: true, filePath: configPath, errors: [] };
100
+ }
101
+
102
+ /**
103
+ * Append a new workflow name to an existing team's config.yaml.
104
+ * Enhanced Simple safety: read → validate → write (.tmp) → verify parse → rename.
105
+ *
106
+ * @param {string} newWorkflowName - Workflow name to add (kebab-case, e.g., "data-analysis")
107
+ * @param {string} configPath - Absolute path to existing config.yaml
108
+ * @param {Object} [options]
109
+ * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests)
110
+ * @returns {Promise<CreatorResult>}
111
+ */
112
+ async function appendConfigWorkflow(newWorkflowName, configPath, options = {}) {
113
+ if (!newWorkflowName || !newWorkflowName.trim()) {
114
+ return { success: false, filePath: configPath, errors: ['newWorkflowName is required'] };
115
+ }
116
+
117
+ // --- Read existing config ---
118
+ if (!await fs.pathExists(configPath)) {
119
+ return { success: false, filePath: configPath, errors: ['config.yaml does not exist at target path'] };
120
+ }
121
+
122
+ // --- Dirty-tree detection (per-write) ---
123
+ if (!options.skipDirtyCheck) {
124
+ const dirtyResult = checkDirtyTree(configPath);
125
+ if (dirtyResult.dirty) {
126
+ return { success: false, filePath: configPath, errors: [], dirty: true, diff: dirtyResult.diff };
127
+ }
128
+ }
129
+
130
+ let content;
131
+ try {
132
+ content = await fs.readFile(configPath, 'utf8');
133
+ } catch (err) {
134
+ return { success: false, filePath: configPath, errors: [`Cannot read config: ${err.message}`] };
135
+ }
136
+
137
+ // --- Parse and validate (ag-7-1: I10 — use comment-preserving YAML.parseDocument) ---
138
+ // Note: YAML.parseDocument does NOT throw on syntax errors; it returns a Document
139
+ // with .errors populated. Check both throw AND doc.errors to match js-yaml semantics.
140
+ let doc;
141
+ let config;
142
+ try {
143
+ doc = YAML.parseDocument(content);
144
+ if (doc.errors && doc.errors.length > 0) {
145
+ return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${doc.errors[0].message}`] };
146
+ }
147
+ config = doc.toJSON();
148
+ } catch (err) {
149
+ return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${err.message}`] };
150
+ }
151
+
152
+ if (!config || !Array.isArray(config.workflows)) {
153
+ return { success: false, filePath: configPath, errors: ['config.yaml missing workflows array'] };
154
+ }
155
+
156
+ // --- Duplicate check (additive-only) ---
157
+ if (config.workflows.includes(newWorkflowName)) {
158
+ return { success: true, filePath: configPath, errors: [], skipped: 'workflow already in config' };
159
+ }
160
+
161
+ // --- Append (mutate via Document API to preserve comments) ---
162
+ const workflowsNode = doc.get('workflows');
163
+ if (workflowsNode && typeof workflowsNode.add === 'function') {
164
+ workflowsNode.add(newWorkflowName);
165
+ } else {
166
+ doc.set('workflows', [...config.workflows, newWorkflowName]);
167
+ }
168
+
169
+ // --- Atomic write (.tmp → validate → rename) ---
170
+ const tmpPath = configPath + '.tmp';
171
+ try {
172
+ const newContent = doc.toString({ lineWidth: 0 });
173
+ await fs.writeFile(tmpPath, newContent, 'utf8');
174
+
175
+ // Verify parse of tmp file (ag-7-1 Task 5.4: read-back stays on js-yaml — checks structure only, not comments)
176
+ const readBack = yaml.load(await fs.readFile(tmpPath, 'utf8'));
177
+ if (!readBack || !Array.isArray(readBack.workflows) || !readBack.workflows.includes(newWorkflowName)) {
178
+ await fs.remove(tmpPath);
179
+ return { success: false, filePath: configPath, errors: ['Post-write verification failed: new workflow not found in parsed output'] };
180
+ }
181
+
182
+ // Rename tmp → target
183
+ await fs.rename(tmpPath, configPath);
184
+ } catch (err) {
185
+ await fs.remove(tmpPath).catch(() => {});
186
+ return { success: false, filePath: configPath, errors: [`Write failed: ${err.message}`] };
187
+ }
188
+
189
+ return { success: true, filePath: configPath, errors: [] };
190
+ }
191
+
192
+ module.exports = { appendConfigAgent, appendConfigWorkflow };
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const yaml = require('js-yaml');
6
+ const { toKebab, deriveWorkflowName } = require('../utils/naming-utils');
7
+
8
+ /** @typedef {import('../types/factory-types')} Types */
9
+
10
+ /**
11
+ * Create a per-module config.yaml for a new team.
12
+ * Matches the Gyre/Vortex config.yaml schema exactly.
13
+ *
14
+ * Safety: Simple (write → verify parse). Additive-only (NFR17).
15
+ * Collision detection runs before writing (NFR15).
16
+ *
17
+ * @param {Object} specData - Parsed team spec file content
18
+ * @param {string} outputPath - Full path to write config.yaml
19
+ * @param {string} bmeRoot - Path to _bmad/bme/ directory for collision scanning
20
+ * @returns {Promise<import('../types/factory-types').CreatorResult>}
21
+ */
22
+ async function createConfig(specData, outputPath, bmeRoot) {
23
+ const errors = [];
24
+
25
+ // --- Additive-only check (NFR17) ---
26
+ if (await fs.pathExists(outputPath)) {
27
+ return { success: false, filePath: outputPath, errors: ['config.yaml already exists at target path — additive-only, will not overwrite'], collisions: [] };
28
+ }
29
+
30
+ // --- Collision detection (NFR15) ---
31
+ const collisions = await detectCollisions(specData, bmeRoot);
32
+ if (collisions.length > 0) {
33
+ return { success: false, filePath: outputPath, errors: ['Collision(s) detected with existing modules'], collisions };
34
+ }
35
+
36
+ // --- Build config data ---
37
+ const configData = buildConfigData(specData);
38
+
39
+ // --- Write config.yaml ---
40
+ try {
41
+ await fs.ensureDir(path.dirname(outputPath));
42
+ const content = yaml.dump(configData, { indent: 2, lineWidth: -1, noRefs: true });
43
+ await fs.writeFile(outputPath, content, 'utf8');
44
+ } catch (err) {
45
+ return { success: false, filePath: outputPath, errors: [`Write failed: ${err.message}`], collisions: [] };
46
+ }
47
+
48
+ // --- Verify parse (Simple safety) ---
49
+ try {
50
+ const readBack = await fs.readFile(outputPath, 'utf8');
51
+ const parsed = yaml.load(readBack);
52
+ if (!parsed || !parsed.submodule_name || !parsed.agents || !parsed.workflows) {
53
+ errors.push('Verification failed: config.yaml missing required fields after write');
54
+ }
55
+ } catch (err) {
56
+ errors.push(`Verification failed: ${err.message}`);
57
+ }
58
+
59
+ if (errors.length > 0) {
60
+ return { success: false, filePath: outputPath, errors, collisions: [] };
61
+ }
62
+
63
+ return { success: true, filePath: outputPath, errors: [], collisions: [] };
64
+ }
65
+
66
+ /**
67
+ * Build config.yaml data matching Gyre/Vortex schema.
68
+ * @param {Object} specData - Parsed team spec file
69
+ * @returns {import('../types/factory-types').ConfigData}
70
+ */
71
+ function buildConfigData(specData) {
72
+ const agents = (specData.agents || []).map(a => a.id);
73
+
74
+ // Derive workflow names from agents' capabilities (first capability kebab-case)
75
+ // or use workflow_names if available in spec
76
+ const workflows = deriveWorkflowNames(specData);
77
+
78
+ return {
79
+ submodule_name: `_${specData.team_name_kebab}`,
80
+ description: specData.description || `${specData.team_name} team module`,
81
+ module: 'bme',
82
+ output_folder: specData.integration.output_directory,
83
+ agents,
84
+ workflows,
85
+ version: '1.0.0',
86
+ user_name: '{user}',
87
+ communication_language: 'en',
88
+ party_mode_enabled: true,
89
+ core_module: 'bme'
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Derive workflow names from spec data.
95
+ * Uses shared deriveWorkflowName() for per-agent logic, then deduplicates.
96
+ * @param {Object} specData
97
+ * @returns {string[]}
98
+ */
99
+ function deriveWorkflowNames(specData) {
100
+ const names = (specData.agents || []).map(agent => deriveWorkflowName(agent, specData));
101
+
102
+ // Check for intra-spec duplicate workflow names
103
+ const seen = new Set();
104
+ for (let i = 0; i < names.length; i++) {
105
+ if (seen.has(names[i])) {
106
+ const agent = (specData.agents || [])[i];
107
+ // Disambiguate by appending agent id
108
+ names[i] = `${names[i]}-${(agent && agent.id) || i}`;
109
+ }
110
+ seen.add(names[i]);
111
+ }
112
+
113
+ return names;
114
+ }
115
+
116
+ /**
117
+ * Scan existing config.yaml files in bmeRoot for collisions (NFR15).
118
+ * Checks submodule_name, agent IDs, and workflow names.
119
+ * @param {Object} specData - New team spec
120
+ * @param {string} bmeRoot - Path to _bmad/bme/
121
+ * @returns {Promise<import('../types/factory-types').Collision[]>}
122
+ */
123
+ async function detectCollisions(specData, bmeRoot) {
124
+ const collisions = [];
125
+ const newSubmodule = `_${specData.team_name_kebab}`;
126
+ const newAgentIds = (specData.agents || []).map(a => a.id);
127
+ const newWorkflows = deriveWorkflowNames(specData);
128
+
129
+ // Find all existing config.yaml files under bmeRoot
130
+ let entries;
131
+ try {
132
+ entries = await fs.readdir(bmeRoot);
133
+ } catch {
134
+ return collisions; // bmeRoot doesn't exist yet — no collisions possible
135
+ }
136
+
137
+ for (const entry of entries) {
138
+ // Skip the new team's own directory to avoid self-collision on re-run
139
+ if (entry === newSubmodule) continue;
140
+ const configPath = path.join(bmeRoot, entry, 'config.yaml');
141
+ if (!await fs.pathExists(configPath)) continue;
142
+
143
+ let existing;
144
+ try {
145
+ existing = yaml.load(await fs.readFile(configPath, 'utf8'));
146
+ } catch {
147
+ continue; // Skip unparseable configs
148
+ }
149
+ if (!existing) continue;
150
+
151
+ const moduleName = entry;
152
+
153
+ // Check submodule_name collision
154
+ if (existing.submodule_name === newSubmodule) {
155
+ collisions.push({ field: 'submodule_name', value: newSubmodule, existingModule: moduleName });
156
+ }
157
+
158
+ // Check agent ID collisions
159
+ const existingAgents = existing.agents || [];
160
+ for (const agentId of newAgentIds) {
161
+ if (existingAgents.includes(agentId)) {
162
+ collisions.push({ field: 'agent', value: agentId, existingModule: moduleName });
163
+ }
164
+ }
165
+
166
+ // Check workflow name collisions
167
+ const existingWorkflows = existing.workflows || [];
168
+ for (const wf of newWorkflows) {
169
+ if (existingWorkflows.includes(wf)) {
170
+ collisions.push({ field: 'workflow', value: wf, existingModule: moduleName });
171
+ }
172
+ }
173
+ }
174
+
175
+ return collisions;
176
+ }
177
+
178
+ // --- CLI entry point ---
179
+ if (require.main === module) {
180
+ const args = process.argv.slice(2);
181
+ const specFileIdx = args.indexOf('--spec-file');
182
+ const dryRunIdx = args.indexOf('--dry-run');
183
+
184
+ if (specFileIdx === -1 || !args[specFileIdx + 1]) {
185
+ console.error('Usage: node config-creator.js --spec-file <path> [--dry-run]');
186
+ process.exit(1);
187
+ }
188
+
189
+ const specFilePath = args[specFileIdx + 1];
190
+ const dryRun = dryRunIdx !== -1;
191
+
192
+ (async () => {
193
+ try {
194
+ const specContent = await fs.readFile(specFilePath, 'utf8');
195
+ const specData = yaml.load(specContent);
196
+ const bmeRoot = path.resolve(__dirname, '../../../../');
197
+ const outputPath = path.join(bmeRoot, `_${specData.team_name_kebab}`, 'config.yaml');
198
+
199
+ if (dryRun) {
200
+ const collisions = await detectCollisions(specData, bmeRoot);
201
+ console.log(JSON.stringify({ dryRun: true, collisions }, null, 2));
202
+ process.exit(collisions.length > 0 ? 1 : 0);
203
+ }
204
+
205
+ const result = await createConfig(specData, outputPath, bmeRoot);
206
+ console.log(JSON.stringify(result, null, 2));
207
+ process.exit(result.success ? 0 : 1);
208
+ } catch (err) {
209
+ console.log(JSON.stringify({ success: false, errors: [err.message] }, null, 2));
210
+ process.exit(1);
211
+ }
212
+ })();
213
+ }
214
+
215
+ module.exports = { createConfig, buildConfigData, detectCollisions, deriveWorkflowNames, toKebab };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const { CSV_HEADER, formatCsvRow, csvQuote, deriveCode, toTitleCase } = require('./csv-creator');
6
+ const { checkDirtyTree } = require('./registry-writer');
7
+
8
+ /** @typedef {import('../types/factory-types').CreatorResult} CreatorResult */
9
+
10
+ /**
11
+ * Append a new row to an existing team's module-help.csv.
12
+ * Enhanced Simple safety: read → validate header → append → write (.tmp) → verify → rename.
13
+ *
14
+ * @param {Object} rowData - Data for the new CSV row
15
+ * @param {string} rowData.module - Module path (e.g., "bme/_gyre")
16
+ * @param {string} rowData.workflowName - Workflow name (kebab-case)
17
+ * @param {string} rowData.agentId - Agent ID
18
+ * @param {string} rowData.agentRole - Agent role description
19
+ * @param {string} rowData.teamNameKebab - Team name for command derivation
20
+ * @param {string} rowData.outputLocation - Output directory
21
+ * @param {number} [rowData.sequence] - Optional sequence number (auto-derived if omitted)
22
+ * @param {string} csvPath - Absolute path to existing module-help.csv
23
+ * @param {Object} [options]
24
+ * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests)
25
+ * @returns {Promise<CreatorResult>}
26
+ */
27
+ async function appendCsvRow(rowData, csvPath, options = {}) {
28
+ if (!rowData || !rowData.agentId) {
29
+ return { success: false, filePath: csvPath, rowCount: 0, errors: ['rowData with agentId is required'] };
30
+ }
31
+
32
+ // --- Read existing CSV ---
33
+ if (!await fs.pathExists(csvPath)) {
34
+ return { success: false, filePath: csvPath, rowCount: 0, errors: ['module-help.csv does not exist at target path'] };
35
+ }
36
+
37
+ // --- Dirty-tree detection (per-write) ---
38
+ if (!options.skipDirtyCheck) {
39
+ const dirtyResult = checkDirtyTree(csvPath);
40
+ if (dirtyResult.dirty) {
41
+ return { success: false, filePath: csvPath, rowCount: 0, errors: [], dirty: true, diff: dirtyResult.diff };
42
+ }
43
+ }
44
+
45
+ let content;
46
+ try {
47
+ content = await fs.readFile(csvPath, 'utf8');
48
+ } catch (err) {
49
+ return { success: false, filePath: csvPath, rowCount: 0, errors: [`Cannot read CSV: ${err.message}`] };
50
+ }
51
+
52
+ const lines = content.trim().split('\n');
53
+
54
+ // --- Validate header ---
55
+ if (lines[0] !== CSV_HEADER) {
56
+ return { success: false, filePath: csvPath, rowCount: 0, errors: ['CSV header mismatch — file format not recognized'] };
57
+ }
58
+
59
+ // --- Duplicate check ---
60
+ const existingRows = lines.slice(1);
61
+ for (const row of existingRows) {
62
+ const cols = row.split(',');
63
+ // Column 8 (index 8) is agent
64
+ if (cols[8] === rowData.agentId) {
65
+ return { success: true, filePath: csvPath, rowCount: existingRows.length, errors: [], skipped: 'agent row already exists' };
66
+ }
67
+ }
68
+
69
+ // --- Build new row ---
70
+ const sequence = rowData.sequence || ((existingRows.length + 1) * 10);
71
+ const csvRow = {
72
+ module: rowData.module,
73
+ phase: 'anytime',
74
+ name: toTitleCase(rowData.workflowName),
75
+ code: deriveCode(rowData.workflowName),
76
+ sequence,
77
+ workflow_file: `_bmad/bme/_${rowData.teamNameKebab}/workflows/${rowData.workflowName}/workflow.md`,
78
+ command: `bmad-${rowData.teamNameKebab}-${rowData.workflowName}`,
79
+ required: 'false',
80
+ agent: rowData.agentId,
81
+ options: 'Create Mode',
82
+ description: rowData.agentRole,
83
+ output_location: rowData.outputLocation,
84
+ outputs: rowData.workflowName,
85
+ };
86
+
87
+ const newLine = formatCsvRow(csvRow);
88
+
89
+ // --- Atomic write (.tmp → verify → rename) ---
90
+ const tmpPath = csvPath + '.tmp';
91
+ try {
92
+ const newContent = content.trimEnd() + '\n' + newLine + '\n';
93
+ await fs.writeFile(tmpPath, newContent, 'utf8');
94
+
95
+ // Verify header preserved
96
+ const readBack = await fs.readFile(tmpPath, 'utf8');
97
+ const readLines = readBack.trim().split('\n');
98
+ if (readLines[0] !== CSV_HEADER) {
99
+ await fs.remove(tmpPath);
100
+ return { success: false, filePath: csvPath, rowCount: 0, errors: ['Post-write verification failed: header mismatch'] };
101
+ }
102
+
103
+ // Verify row count increased
104
+ if (readLines.length <= lines.length) {
105
+ await fs.remove(tmpPath);
106
+ return { success: false, filePath: csvPath, rowCount: 0, errors: ['Post-write verification failed: row count did not increase'] };
107
+ }
108
+
109
+ await fs.rename(tmpPath, csvPath);
110
+ } catch (err) {
111
+ await fs.remove(tmpPath).catch(() => {});
112
+ return { success: false, filePath: csvPath, rowCount: 0, errors: [`Write failed: ${err.message}`] };
113
+ }
114
+
115
+ return { success: true, filePath: csvPath, rowCount: existingRows.length + 1, errors: [] };
116
+ }
117
+
118
+ module.exports = { appendCsvRow };