convoke-agents 3.1.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +37 -10
- 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 +13 -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 +1156 -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,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const yaml = require('js-yaml');
|
|
6
|
+
const { deriveWorkflowNames, toKebab } = require('./config-creator');
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('../types/factory-types')} Types */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Standard module-help.csv header — matches BMB and BMM exactly.
|
|
12
|
+
* Trailing comma is intentional (existing pattern).
|
|
13
|
+
*/
|
|
14
|
+
const CSV_HEADER = 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs,';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a per-module module-help.csv for a new team.
|
|
18
|
+
* One row per workflow, matching the BMB/BMM column format.
|
|
19
|
+
*
|
|
20
|
+
* Safety: Simple (write → verify header match). Additive-only (NFR17).
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} specData - Parsed team spec file content
|
|
23
|
+
* @param {string} outputPath - Full path to write module-help.csv
|
|
24
|
+
* @returns {Promise<import('../types/factory-types').CreatorResult>}
|
|
25
|
+
*/
|
|
26
|
+
async function createCsv(specData, outputPath) {
|
|
27
|
+
// --- Additive-only check (NFR17) ---
|
|
28
|
+
if (await fs.pathExists(outputPath)) {
|
|
29
|
+
return { success: false, filePath: outputPath, rowCount: 0, errors: ['module-help.csv already exists at target path — additive-only, will not overwrite'] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Build CSV rows ---
|
|
33
|
+
const rows = buildCsvRows(specData);
|
|
34
|
+
|
|
35
|
+
// --- Write CSV ---
|
|
36
|
+
try {
|
|
37
|
+
await fs.ensureDir(path.dirname(outputPath));
|
|
38
|
+
const content = CSV_HEADER + '\n' + rows.map(formatCsvRow).join('\n') + '\n';
|
|
39
|
+
await fs.writeFile(outputPath, content, 'utf8');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { success: false, filePath: outputPath, rowCount: 0, errors: [`Write failed: ${err.message}`] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Verify header match (Simple safety) ---
|
|
45
|
+
try {
|
|
46
|
+
const readBack = await fs.readFile(outputPath, 'utf8');
|
|
47
|
+
const firstLine = readBack.split('\n')[0];
|
|
48
|
+
if (firstLine !== CSV_HEADER) {
|
|
49
|
+
return { success: false, filePath: outputPath, rowCount: rows.length, errors: ['Verification failed: header mismatch after write'] };
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { success: false, filePath: outputPath, rowCount: 0, errors: [`Verification failed: ${err.message}`] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { success: true, filePath: outputPath, rowCount: rows.length, errors: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build CSV row data from spec.
|
|
60
|
+
* One row per workflow (one workflow per agent).
|
|
61
|
+
* @param {Object} specData
|
|
62
|
+
* @returns {import('../types/factory-types').CsvRow[]}
|
|
63
|
+
*/
|
|
64
|
+
function buildCsvRows(specData) {
|
|
65
|
+
const modulePath = `bme/_${specData.team_name_kebab}`;
|
|
66
|
+
const outputLocation = specData.integration.output_directory;
|
|
67
|
+
const workflows = deriveWorkflowNames(specData);
|
|
68
|
+
const rows = [];
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < (specData.agents || []).length; i++) {
|
|
71
|
+
const agent = specData.agents[i];
|
|
72
|
+
const workflowName = workflows[i];
|
|
73
|
+
const displayName = toTitleCase(workflowName);
|
|
74
|
+
const code = deriveCode(workflowName);
|
|
75
|
+
const sequence = (i + 1) * 10;
|
|
76
|
+
|
|
77
|
+
rows.push({
|
|
78
|
+
module: modulePath,
|
|
79
|
+
phase: 'anytime',
|
|
80
|
+
name: displayName,
|
|
81
|
+
code,
|
|
82
|
+
sequence,
|
|
83
|
+
workflow_file: `_bmad/bme/_${specData.team_name_kebab}/workflows/${workflowName}/workflow.md`,
|
|
84
|
+
command: `bmad-${specData.team_name_kebab}-${workflowName}`,
|
|
85
|
+
required: 'false',
|
|
86
|
+
agent: agent.id,
|
|
87
|
+
options: 'Create Mode',
|
|
88
|
+
description: agent.role,
|
|
89
|
+
output_location: outputLocation,
|
|
90
|
+
outputs: workflowName
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format a CsvRow into a CSV line string.
|
|
99
|
+
* Matches BMB/BMM format: values with commas/spaces are quoted.
|
|
100
|
+
* Trailing comma included.
|
|
101
|
+
* @param {import('../types/factory-types').CsvRow} row
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
function formatCsvRow(row) {
|
|
105
|
+
const values = [
|
|
106
|
+
row.module,
|
|
107
|
+
row.phase,
|
|
108
|
+
row.name,
|
|
109
|
+
row.code,
|
|
110
|
+
row.sequence,
|
|
111
|
+
row.workflow_file,
|
|
112
|
+
row.command,
|
|
113
|
+
row.required,
|
|
114
|
+
row.agent,
|
|
115
|
+
row.options,
|
|
116
|
+
csvQuote(row.description),
|
|
117
|
+
row.output_location,
|
|
118
|
+
csvQuote(row.outputs)
|
|
119
|
+
];
|
|
120
|
+
return values.join(',') + ',';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Quote a CSV value if it contains commas, quotes, or newlines.
|
|
125
|
+
* @param {string} val
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
function csvQuote(val) {
|
|
129
|
+
if (val == null || val === '') return '';
|
|
130
|
+
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
|
131
|
+
return `"${val.replace(/"/g, '""')}"`;
|
|
132
|
+
}
|
|
133
|
+
return val;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Derive a 2-letter uppercase code from a workflow name.
|
|
138
|
+
* Takes first letter of first two words (e.g., "stack-detection" → "SD").
|
|
139
|
+
* @param {string} name - kebab-case workflow name
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function deriveCode(name) {
|
|
143
|
+
const words = name.split('-').filter(Boolean);
|
|
144
|
+
if (words.length >= 2) {
|
|
145
|
+
return (words[0][0] + words[1][0]).toUpperCase();
|
|
146
|
+
}
|
|
147
|
+
return (words[0] || 'XX').substring(0, 2).toUpperCase();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Convert kebab-case to Title Case.
|
|
152
|
+
* @param {string} str
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
function toTitleCase(str) {
|
|
156
|
+
return str
|
|
157
|
+
.split('-')
|
|
158
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
159
|
+
.join(' ');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- CLI entry point ---
|
|
163
|
+
if (require.main === module) {
|
|
164
|
+
const args = process.argv.slice(2);
|
|
165
|
+
const specFileIdx = args.indexOf('--spec-file');
|
|
166
|
+
|
|
167
|
+
if (specFileIdx === -1 || !args[specFileIdx + 1]) {
|
|
168
|
+
console.error('Usage: node csv-creator.js --spec-file <path>');
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const specFilePath = args[specFileIdx + 1];
|
|
173
|
+
|
|
174
|
+
(async () => {
|
|
175
|
+
try {
|
|
176
|
+
const specContent = await fs.readFile(specFilePath, 'utf8');
|
|
177
|
+
const specData = yaml.load(specContent);
|
|
178
|
+
const bmeRoot = path.resolve(__dirname, '../../../../');
|
|
179
|
+
const outputPath = path.join(bmeRoot, `_${specData.team_name_kebab}`, 'module-help.csv');
|
|
180
|
+
const result = await createCsv(specData, outputPath);
|
|
181
|
+
console.log(JSON.stringify(result, null, 2));
|
|
182
|
+
process.exit(result.success ? 0 : 1);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.log(JSON.stringify({ success: false, errors: [err.message] }, null, 2));
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { createCsv, buildCsvRows, formatCsvRow, csvQuote, deriveCode, toTitleCase, CSV_HEADER };
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
derivePrefix,
|
|
7
|
+
buildAgentEntry,
|
|
8
|
+
escapeSingleQuotes,
|
|
9
|
+
verifyRequire,
|
|
10
|
+
checkDirtyTree,
|
|
11
|
+
} = require('./registry-writer');
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('../types/factory-types').RegistryResult} RegistryResult */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Append a new agent to an existing team's module block in agent-registry.js.
|
|
17
|
+
* Uses Full Write Safety Protocol: stage → validate → check → apply → verify → rollback.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} teamNameKebab - Existing team (e.g., "gyre")
|
|
20
|
+
* @param {Object} newAgentData - Agent spec with id, name, icon, role, persona fields
|
|
21
|
+
* @param {string} registryPath - Absolute path to agent-registry.js
|
|
22
|
+
* @param {Object} [options]
|
|
23
|
+
* @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests)
|
|
24
|
+
* @returns {Promise<RegistryResult>}
|
|
25
|
+
*/
|
|
26
|
+
async function appendAgentToBlock(teamNameKebab, newAgentData, registryPath, options = {}) {
|
|
27
|
+
if (!teamNameKebab || !teamNameKebab.trim()) {
|
|
28
|
+
return fail(['teamNameKebab is required and must not be empty']);
|
|
29
|
+
}
|
|
30
|
+
if (!newAgentData || !newAgentData.id) {
|
|
31
|
+
return fail(['newAgentData with id is required']);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const prefix = derivePrefix(teamNameKebab);
|
|
35
|
+
|
|
36
|
+
// --- Read current content ---
|
|
37
|
+
let currentContent;
|
|
38
|
+
try {
|
|
39
|
+
currentContent = await fs.readFile(registryPath, 'utf8');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return fail([`Cannot read registry file: ${err.message}`]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- 1. STAGE: Locate existing block and build new entry ---
|
|
45
|
+
const agentsVarName = `${prefix}_AGENTS`;
|
|
46
|
+
if (!currentContent.includes(`const ${agentsVarName}`)) {
|
|
47
|
+
return fail([`Team block not found: const ${agentsVarName} does not exist in registry`]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check duplicate agent ID
|
|
51
|
+
const agentIdLiteral = `id: '${escapeSingleQuotes(newAgentData.id)}'`;
|
|
52
|
+
if (currentContent.includes(agentIdLiteral)) {
|
|
53
|
+
return { success: true, written: [], skipped: ['agent already exists in block'], errors: [], rollbackApplied: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build the new agent entry as JS text
|
|
57
|
+
const entry = buildAgentEntry(newAgentData, teamNameKebab);
|
|
58
|
+
const entryLines = formatAgentEntry(entry);
|
|
59
|
+
|
|
60
|
+
// --- 2. VALIDATE: Structural checks ---
|
|
61
|
+
// Find the closing ]; of the AGENTS array
|
|
62
|
+
const arrayStart = currentContent.indexOf(`const ${agentsVarName} = [`);
|
|
63
|
+
if (arrayStart === -1) {
|
|
64
|
+
return fail([`Cannot parse ${agentsVarName} array start`]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const closingBracket = findArrayClose(currentContent, arrayStart);
|
|
68
|
+
if (closingBracket === -1) {
|
|
69
|
+
return fail([`Cannot find closing ]; for ${agentsVarName}`]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- 3. CHECK: Dirty-tree detection ---
|
|
73
|
+
if (!options.skipDirtyCheck) {
|
|
74
|
+
const dirtyResult = checkDirtyTree(registryPath);
|
|
75
|
+
if (dirtyResult.dirty) {
|
|
76
|
+
return { success: false, written: [], skipped: [], errors: [], rollbackApplied: false, dirty: true, diff: dirtyResult.diff };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- 4. APPLY: Insert new entry before closing ]; ---
|
|
81
|
+
const bakPath = `${registryPath}.bak`;
|
|
82
|
+
if (await fs.pathExists(bakPath)) {
|
|
83
|
+
return fail(['Stale .bak file exists — a previous run may have crashed. Remove it manually before retrying.']);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await fs.writeFile(bakPath, currentContent, 'utf8');
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return fail([`Failed to create backup: ${err.message}`]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Insert the new entry before the closing ];
|
|
93
|
+
const before = currentContent.slice(0, closingBracket);
|
|
94
|
+
const after = currentContent.slice(closingBracket);
|
|
95
|
+
const modified = before + entryLines + '\n' + after;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await fs.writeFile(registryPath, modified, 'utf8');
|
|
99
|
+
} catch (err) {
|
|
100
|
+
await fs.writeFile(registryPath, currentContent, 'utf8');
|
|
101
|
+
await fs.remove(bakPath);
|
|
102
|
+
return { success: false, written: [], skipped: [], errors: [`Write failed: ${err.message}`], rollbackApplied: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- 5. VERIFY: Re-read + node require() ---
|
|
106
|
+
const verifyError = verifyRequire(registryPath);
|
|
107
|
+
if (verifyError) {
|
|
108
|
+
await fs.writeFile(registryPath, currentContent, 'utf8');
|
|
109
|
+
await fs.remove(bakPath);
|
|
110
|
+
return { success: false, written: [], skipped: [], errors: [verifyError], rollbackApplied: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Verify new agent appears in the file
|
|
114
|
+
const verifyContent = await fs.readFile(registryPath, 'utf8');
|
|
115
|
+
if (!verifyContent.includes(agentIdLiteral)) {
|
|
116
|
+
await fs.writeFile(registryPath, currentContent, 'utf8');
|
|
117
|
+
await fs.remove(bakPath);
|
|
118
|
+
return { success: false, written: [], skipped: [], errors: ['Post-write verification: new agent entry not found'], rollbackApplied: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Cleanup ---
|
|
122
|
+
await fs.remove(bakPath);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
written: [newAgentData.id],
|
|
127
|
+
skipped: [],
|
|
128
|
+
errors: [],
|
|
129
|
+
rollbackApplied: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Format an agent entry object as JS source text for insertion into AGENTS array.
|
|
135
|
+
* @param {Object} entry - From buildAgentEntry
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
function formatAgentEntry(entry) {
|
|
139
|
+
const lines = [];
|
|
140
|
+
lines.push(' {');
|
|
141
|
+
lines.push(` id: '${escapeSingleQuotes(entry.id)}', name: '${escapeSingleQuotes(entry.name)}', icon: '${entry.icon}',`);
|
|
142
|
+
lines.push(` title: '${escapeSingleQuotes(entry.title)}', stream: '${escapeSingleQuotes(entry.stream)}',`);
|
|
143
|
+
lines.push(' persona: {');
|
|
144
|
+
lines.push(` role: '${escapeSingleQuotes(entry.persona.role)}',`);
|
|
145
|
+
lines.push(` identity: '${escapeSingleQuotes(entry.persona.identity)}',`);
|
|
146
|
+
lines.push(` communication_style: '${escapeSingleQuotes(entry.persona.communication_style)}',`);
|
|
147
|
+
lines.push(` expertise: '${escapeSingleQuotes(entry.persona.expertise)}',`);
|
|
148
|
+
lines.push(' },');
|
|
149
|
+
lines.push(' },');
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Find the closing ]; of an array that starts with `const NAME = [`.
|
|
155
|
+
* Counts bracket depth to handle nested objects.
|
|
156
|
+
* @param {string} content
|
|
157
|
+
* @param {number} startIdx - Index of `const NAME = [`
|
|
158
|
+
* @returns {number} Index of the `]` in `];`, or -1
|
|
159
|
+
*/
|
|
160
|
+
function findArrayClose(content, startIdx) {
|
|
161
|
+
const bracketOpen = content.indexOf('[', startIdx);
|
|
162
|
+
if (bracketOpen === -1) return -1;
|
|
163
|
+
|
|
164
|
+
let depth = 0;
|
|
165
|
+
let inString = false;
|
|
166
|
+
let stringChar = '';
|
|
167
|
+
let escaped = false;
|
|
168
|
+
|
|
169
|
+
for (let i = bracketOpen; i < content.length; i++) {
|
|
170
|
+
const ch = content[i];
|
|
171
|
+
|
|
172
|
+
if (escaped) {
|
|
173
|
+
escaped = false;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (ch === '\\') {
|
|
178
|
+
escaped = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (inString) {
|
|
183
|
+
if (ch === stringChar) {
|
|
184
|
+
inString = false;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Skip line comments
|
|
190
|
+
if (ch === '/' && content[i + 1] === '/') {
|
|
191
|
+
const eol = content.indexOf('\n', i);
|
|
192
|
+
i = eol === -1 ? content.length : eol;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Skip block comments
|
|
197
|
+
if (ch === '/' && content[i + 1] === '*') {
|
|
198
|
+
const end = content.indexOf('*/', i + 2);
|
|
199
|
+
i = end === -1 ? content.length : end + 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
204
|
+
inString = true;
|
|
205
|
+
stringChar = ch;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (ch === '[') depth++;
|
|
210
|
+
if (ch === ']') {
|
|
211
|
+
depth--;
|
|
212
|
+
if (depth === 0) return i;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return -1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create a failure result.
|
|
221
|
+
* @param {string[]} errors
|
|
222
|
+
* @returns {RegistryResult}
|
|
223
|
+
*/
|
|
224
|
+
function fail(errors) {
|
|
225
|
+
return { success: false, written: [], skipped: [], errors, rollbackApplied: false };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Append a new workflow entry to an existing team's WORKFLOWS array in agent-registry.js.
|
|
230
|
+
* Uses Full Write Safety Protocol: stage → validate → check → apply → verify → rollback.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} teamNameKebab - Existing team (e.g., "gyre")
|
|
233
|
+
* @param {string} workflowName - Workflow name (kebab-case, e.g., "data-analysis")
|
|
234
|
+
* @param {string} agentId - Agent ID this workflow belongs to (e.g., "stack-detective")
|
|
235
|
+
* @param {string} registryPath - Absolute path to agent-registry.js
|
|
236
|
+
* @param {Object} [options]
|
|
237
|
+
* @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests)
|
|
238
|
+
* @returns {Promise<RegistryResult>}
|
|
239
|
+
*/
|
|
240
|
+
async function appendWorkflowToBlock(teamNameKebab, workflowName, agentId, registryPath, options = {}) {
|
|
241
|
+
if (!teamNameKebab || !teamNameKebab.trim()) {
|
|
242
|
+
return fail(['teamNameKebab is required and must not be empty']);
|
|
243
|
+
}
|
|
244
|
+
if (!workflowName || !workflowName.trim()) {
|
|
245
|
+
return fail(['workflowName is required and must not be empty']);
|
|
246
|
+
}
|
|
247
|
+
if (!agentId || !agentId.trim()) {
|
|
248
|
+
return fail(['agentId is required and must not be empty']);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Normalize inputs — trim whitespace to prevent phantom entries
|
|
252
|
+
teamNameKebab = teamNameKebab.trim();
|
|
253
|
+
workflowName = workflowName.trim();
|
|
254
|
+
agentId = agentId.trim();
|
|
255
|
+
|
|
256
|
+
const prefix = derivePrefix(teamNameKebab);
|
|
257
|
+
|
|
258
|
+
// --- Read current content ---
|
|
259
|
+
let currentContent;
|
|
260
|
+
try {
|
|
261
|
+
currentContent = await fs.readFile(registryPath, 'utf8');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return fail([`Cannot read registry file: ${err.message}`]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- 1. STAGE: Locate existing block and build new entry ---
|
|
267
|
+
const workflowsVarName = `${prefix}_WORKFLOWS`;
|
|
268
|
+
if (!currentContent.includes(`const ${workflowsVarName}`)) {
|
|
269
|
+
return fail([`Team block not found: const ${workflowsVarName} does not exist in registry`]);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- 2. VALIDATE: Structural checks ---
|
|
273
|
+
const arrayStart = currentContent.indexOf(`const ${workflowsVarName} = [`);
|
|
274
|
+
if (arrayStart === -1) {
|
|
275
|
+
return fail([`Cannot parse ${workflowsVarName} array start`]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const closingBracket = findArrayClose(currentContent, arrayStart);
|
|
279
|
+
if (closingBracket === -1) {
|
|
280
|
+
return fail([`Cannot find closing ]; for ${workflowsVarName}`]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Check duplicate workflow entry — scoped to THIS team's WORKFLOWS block only
|
|
284
|
+
const nameLiteral = `name: '${escapeSingleQuotes(workflowName)}'`;
|
|
285
|
+
const blockContent = currentContent.slice(arrayStart, closingBracket + 1);
|
|
286
|
+
if (blockContent.includes(nameLiteral)) {
|
|
287
|
+
return { success: true, written: [], skipped: ['workflow already exists in block'], errors: [], rollbackApplied: false };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- 3. CHECK: Dirty-tree detection ---
|
|
291
|
+
if (!options.skipDirtyCheck) {
|
|
292
|
+
const dirtyResult = checkDirtyTree(registryPath);
|
|
293
|
+
if (dirtyResult.dirty) {
|
|
294
|
+
return { success: false, written: [], skipped: [], errors: [], rollbackApplied: false, dirty: true, diff: dirtyResult.diff };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- 4. APPLY: Insert new entry before closing ]; ---
|
|
299
|
+
const bakPath = `${registryPath}.bak`;
|
|
300
|
+
if (await fs.pathExists(bakPath)) {
|
|
301
|
+
return fail(['Stale .bak file exists — a previous run may have crashed. Remove it manually before retrying.']);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await fs.writeFile(bakPath, currentContent, 'utf8');
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return fail([`Failed to create backup: ${err.message}`]);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Build workflow entry line
|
|
311
|
+
const entryLine = ` { name: '${escapeSingleQuotes(workflowName)}', agent: '${escapeSingleQuotes(agentId)}' },`;
|
|
312
|
+
|
|
313
|
+
const before = currentContent.slice(0, closingBracket);
|
|
314
|
+
const after = currentContent.slice(closingBracket);
|
|
315
|
+
const modified = before + entryLine + '\n' + after;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await fs.writeFile(registryPath, modified, 'utf8');
|
|
319
|
+
} catch (err) {
|
|
320
|
+
let rollbackApplied = false;
|
|
321
|
+
try {
|
|
322
|
+
await fs.writeFile(registryPath, currentContent, 'utf8');
|
|
323
|
+
rollbackApplied = true;
|
|
324
|
+
} catch (rollbackErr) {
|
|
325
|
+
return { success: false, written: [], skipped: [], errors: [`Write failed: ${err.message}`, `Rollback also failed: ${rollbackErr.message}`], rollbackApplied: false };
|
|
326
|
+
}
|
|
327
|
+
await fs.remove(bakPath).catch(() => {});
|
|
328
|
+
return { success: false, written: [], skipped: [], errors: [`Write failed: ${err.message}`], rollbackApplied };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- 5. VERIFY: Re-read + node require() ---
|
|
332
|
+
const verifyError = verifyRequire(registryPath);
|
|
333
|
+
if (verifyError) {
|
|
334
|
+
try {
|
|
335
|
+
await fs.writeFile(registryPath, currentContent, 'utf8');
|
|
336
|
+
await fs.remove(bakPath);
|
|
337
|
+
} catch (rollbackErr) {
|
|
338
|
+
return { success: false, written: [], skipped: [], errors: [verifyError, `Rollback failed: ${rollbackErr.message}`], rollbackApplied: false };
|
|
339
|
+
}
|
|
340
|
+
return { success: false, written: [], skipped: [], errors: [verifyError], rollbackApplied: true };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Verify new workflow appears in the file
|
|
344
|
+
const verifyContent = await fs.readFile(registryPath, 'utf8');
|
|
345
|
+
if (!verifyContent.includes(nameLiteral)) {
|
|
346
|
+
try {
|
|
347
|
+
await fs.writeFile(registryPath, currentContent, 'utf8');
|
|
348
|
+
await fs.remove(bakPath);
|
|
349
|
+
} catch (rollbackErr) {
|
|
350
|
+
return { success: false, written: [], skipped: [], errors: ['Post-write verification: new workflow entry not found', `Rollback failed: ${rollbackErr.message}`], rollbackApplied: false };
|
|
351
|
+
}
|
|
352
|
+
return { success: false, written: [], skipped: [], errors: ['Post-write verification: new workflow entry not found'], rollbackApplied: true };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Cleanup ---
|
|
356
|
+
await fs.remove(bakPath);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
success: true,
|
|
360
|
+
written: [workflowName],
|
|
361
|
+
skipped: [],
|
|
362
|
+
errors: [],
|
|
363
|
+
rollbackApplied: false,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = {
|
|
368
|
+
appendAgentToBlock,
|
|
369
|
+
appendWorkflowToBlock,
|
|
370
|
+
formatAgentEntry,
|
|
371
|
+
findArrayClose,
|
|
372
|
+
};
|