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.
- package/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/_bmad/bme/_artifacts/config.yaml +15 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
- package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
- package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
- package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
- package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
- package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
- package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
- package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
- package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
- package/_bmad/bme/_team-factory/config.yaml +13 -0
- package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
- package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
- package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
- package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
- package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
- package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
- package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
- package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
- package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
- package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
- package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
- package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
- package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
- package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
- package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
- package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
- package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
- package/_bmad/bme/_team-factory/module-help.csv +3 -0
- package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
- package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
- package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
- package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
- package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
- package/_bmad/bme/_vortex/config.yaml +4 -4
- package/package.json +12 -7
- package/scripts/convoke-doctor.js +172 -1
- package/scripts/install-gyre-agents.js +0 -0
- package/scripts/lib/artifact-utils.js +521 -13
- package/scripts/lib/portfolio/portfolio-engine.js +301 -34
- package/scripts/lib/portfolio/rules/artifact-chain-rule.js +33 -3
- package/scripts/lib/portfolio/rules/conflict-resolver.js +22 -0
- package/scripts/migrate-artifacts.js +69 -10
- package/scripts/portability/catalog-generator.js +353 -0
- package/scripts/portability/classify-skills.js +646 -0
- package/scripts/portability/convoke-export.js +522 -0
- package/scripts/portability/export-engine.js +1133 -0
- package/scripts/portability/generate-adapters.js +79 -0
- package/scripts/portability/manifest-csv.js +147 -0
- package/scripts/portability/seed-catalog-repo.js +427 -0
- package/scripts/portability/templates/canonical-example.md +102 -0
- package/scripts/portability/templates/canonical-format.md +218 -0
- package/scripts/portability/templates/readme-template.md +72 -0
- package/scripts/portability/test-constants.js +42 -0
- package/scripts/portability/validate-classification.js +529 -0
- package/scripts/portability/validate-exports.js +348 -0
- package/scripts/update/lib/agent-registry.js +35 -0
- package/scripts/update/lib/config-merger.js +140 -10
- package/scripts/update/lib/refresh-installation.js +293 -8
- package/scripts/update/lib/utils.js +27 -1
- 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 };
|