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,1156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* export-engine.js — Story sp-2-2
|
|
3
|
+
*
|
|
4
|
+
* Pure-transform export engine for converting Tier 1 BMAD skills into the
|
|
5
|
+
* canonical LLM-agnostic format defined in
|
|
6
|
+
* scripts/portability/templates/canonical-format.md.
|
|
7
|
+
*
|
|
8
|
+
* The engine is read-only: it reads source files, returns transformed
|
|
9
|
+
* strings, and never writes anything. The CLI (sp-2-3) handles file output.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const { exportSkill } = require('./scripts/portability/export-engine');
|
|
13
|
+
* const result = exportSkill('bmad-brainstorming', findProjectRoot());
|
|
14
|
+
* // result = { instructions, persona, sections, warnings }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { readManifest } = require('./manifest-csv');
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// CONSTANTS
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
const META_SECTIONS_TO_STRIP = [
|
|
28
|
+
'MANDATORY EXECUTION RULES',
|
|
29
|
+
'EXECUTION PROTOCOLS',
|
|
30
|
+
'CONTEXT BOUNDARIES',
|
|
31
|
+
'SUCCESS METRICS',
|
|
32
|
+
'FAILURE MODES',
|
|
33
|
+
'SYSTEM SUCCESS/FAILURE METRICS',
|
|
34
|
+
'ROLE REINFORCEMENT',
|
|
35
|
+
'STEP-SPECIFIC RULES',
|
|
36
|
+
'NEXT STEPS',
|
|
37
|
+
'NEXT STEP',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ALLOWED_WARNING_TYPES = new Set([
|
|
41
|
+
'hook-script-stripped',
|
|
42
|
+
'unresolved-template-path',
|
|
43
|
+
'deep-conditional-skipped',
|
|
44
|
+
'unstripped-xml-tag',
|
|
45
|
+
'step-file-not-found',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// HELPERS
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Recursively search a directory tree for a file by basename.
|
|
54
|
+
* Returns the first match found, or null.
|
|
55
|
+
*/
|
|
56
|
+
function findFileByName(dir, basename) {
|
|
57
|
+
try {
|
|
58
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const full = path.join(dir, entry.name);
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
const found = findFileByName(full, basename);
|
|
63
|
+
if (found) return found;
|
|
64
|
+
} else if (entry.name === basename) {
|
|
65
|
+
return full;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (_) {
|
|
69
|
+
// Permission error, symlink loop, etc. — skip silently
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// SOURCE LOADERS
|
|
76
|
+
// =============================================================================
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the skill manifest row for a given skill name.
|
|
80
|
+
* Throws if the skill is not in the manifest.
|
|
81
|
+
*/
|
|
82
|
+
function loadSkillRow(skillName, projectRoot) {
|
|
83
|
+
const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv');
|
|
84
|
+
const { header, rows } = readManifest(manifestPath);
|
|
85
|
+
const nameIdx = header.indexOf('name');
|
|
86
|
+
const row = rows.find((r) => r[nameIdx] === skillName);
|
|
87
|
+
if (!row) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Skill "${skillName}" is not in the manifest at ${path.relative(projectRoot, manifestPath)}.`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
// Return as a structured object
|
|
93
|
+
const result = {};
|
|
94
|
+
for (let i = 0; i < header.length; i++) {
|
|
95
|
+
result[header[i]] = row[i];
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Load a skill's source files: SKILL.md + workflow.md + step files.
|
|
102
|
+
* Returns { skillContent, workflowContent, stepContents }
|
|
103
|
+
* where stepContents is { 'step-01-foo': '...', 'step-02a-bar': '...' }
|
|
104
|
+
*/
|
|
105
|
+
function loadSkillSource(skillRow, projectRoot, warnings) {
|
|
106
|
+
const skillPath = path.join(projectRoot, skillRow.path);
|
|
107
|
+
const skillContent = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : '';
|
|
108
|
+
|
|
109
|
+
if (!skillContent) {
|
|
110
|
+
throw new Error(`SKILL.md not found at ${path.relative(projectRoot, skillPath)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Try to find workflow.md as a sibling of SKILL.md
|
|
114
|
+
const skillDir = path.dirname(skillPath);
|
|
115
|
+
const workflowPath = path.join(skillDir, 'workflow.md');
|
|
116
|
+
const workflowContent = fs.existsSync(workflowPath) ? fs.readFileSync(workflowPath, 'utf8') : '';
|
|
117
|
+
|
|
118
|
+
// Find step files: scan steps/ subdirectory if it exists
|
|
119
|
+
const stepContents = {};
|
|
120
|
+
const stepsDir = path.join(skillDir, 'steps');
|
|
121
|
+
if (fs.existsSync(stepsDir) && fs.statSync(stepsDir).isDirectory()) {
|
|
122
|
+
const stepFiles = fs.readdirSync(stepsDir).filter((f) => f.endsWith('.md'));
|
|
123
|
+
for (const stepFile of stepFiles) {
|
|
124
|
+
const stepName = stepFile.replace(/\.md$/, '');
|
|
125
|
+
stepContents[stepName] = fs.readFileSync(path.join(stepsDir, stepFile), 'utf8');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Also scan for step subdirectories with patterns like steps-c, steps-t, etc.
|
|
130
|
+
const skillDirEntries = fs.readdirSync(skillDir, { withFileTypes: true });
|
|
131
|
+
for (const entry of skillDirEntries) {
|
|
132
|
+
if (entry.isDirectory() && entry.name.startsWith('steps-')) {
|
|
133
|
+
const subStepsDir = path.join(skillDir, entry.name);
|
|
134
|
+
const subStepFiles = fs.readdirSync(subStepsDir).filter((f) => f.endsWith('.md'));
|
|
135
|
+
for (const stepFile of subStepFiles) {
|
|
136
|
+
const stepName = stepFile.replace(/\.md$/, '');
|
|
137
|
+
stepContents[stepName] = fs.readFileSync(path.join(subStepsDir, stepFile), 'utf8');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { skillContent, workflowContent, stepContents, skillDir };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// PERSONA RESOLUTION
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Read the agent manifest and return all rows as objects.
|
|
151
|
+
*/
|
|
152
|
+
function loadAgentManifest(projectRoot) {
|
|
153
|
+
const manifestPath = path.join(projectRoot, '_bmad', '_config', 'agent-manifest.csv');
|
|
154
|
+
const { header, rows } = readManifest(manifestPath);
|
|
155
|
+
return rows.map((row) => {
|
|
156
|
+
const obj = {};
|
|
157
|
+
for (let i = 0; i < header.length; i++) {
|
|
158
|
+
obj[header[i]] = row[i];
|
|
159
|
+
}
|
|
160
|
+
return obj;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Shared agent-manifest matching logic (Strategies 1 + 2 + 2b).
|
|
166
|
+
* No file I/O — operates purely on the passed-in agents array.
|
|
167
|
+
* Returns the matched agent row object, or null if no match.
|
|
168
|
+
*/
|
|
169
|
+
const CIS_SKILL_TO_AGENT = {
|
|
170
|
+
'bmad-cis-storytelling': 'bmad-cis-agent-storyteller',
|
|
171
|
+
'bmad-cis-innovation-strategy': 'bmad-cis-agent-innovation-strategist',
|
|
172
|
+
'bmad-cis-problem-solving': 'bmad-cis-agent-creative-problem-solver',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// BME agent short-name mapping (agent-manifest uses short names like "Emma")
|
|
176
|
+
const BME_SKILL_TO_AGENT = {
|
|
177
|
+
'bmad-agent-bme-contextualization-expert': 'Emma',
|
|
178
|
+
'bmad-agent-bme-discovery-empathy-expert': 'Isla',
|
|
179
|
+
'bmad-agent-bme-research-convergence-specialist': 'Mila',
|
|
180
|
+
'bmad-agent-bme-hypothesis-engineer': 'Liam',
|
|
181
|
+
'bmad-agent-bme-lean-experiments-specialist': 'Wade',
|
|
182
|
+
'bmad-agent-bme-production-intelligence-specialist': 'Noah',
|
|
183
|
+
'bmad-agent-bme-learning-decision-expert': 'Max',
|
|
184
|
+
'bmad-agent-bme-stack-detective': 'Scout',
|
|
185
|
+
'bmad-agent-bme-model-curator': 'Atlas',
|
|
186
|
+
'bmad-agent-bme-readiness-analyst': 'Lens',
|
|
187
|
+
'bmad-agent-bme-review-coach': 'Coach',
|
|
188
|
+
'bmad-agent-bme-team-factory': 'Loom Master',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function findAgentMatch(skillName, agents) {
|
|
192
|
+
// Strategy 1: exact name match
|
|
193
|
+
let agent = agents.find((a) => a.name === skillName);
|
|
194
|
+
if (agent) return agent;
|
|
195
|
+
|
|
196
|
+
// Strategy 2: bmad-cis-agent-* pattern transformation
|
|
197
|
+
const base = skillName.startsWith('bmad-cis-')
|
|
198
|
+
? skillName.replace(/^bmad-cis-/, 'bmad-cis-agent-')
|
|
199
|
+
: skillName.replace(/^bmad-/, 'bmad-cis-agent-');
|
|
200
|
+
const candidates = [base, base + '-coach', base + '-specialist', base + '-expert'];
|
|
201
|
+
for (const candidate of candidates) {
|
|
202
|
+
agent = agents.find((a) => a.name === candidate);
|
|
203
|
+
if (agent) return agent;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Strategy 2b: alias map for CIS stem mismatches
|
|
207
|
+
const aliasName = CIS_SKILL_TO_AGENT[skillName];
|
|
208
|
+
if (aliasName) {
|
|
209
|
+
agent = agents.find((a) => a.name === aliasName);
|
|
210
|
+
if (agent) return agent;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Strategy 2c: BME agent short-name mapping (Emma, Isla, Scout, etc.)
|
|
214
|
+
const bmeShortName = BME_SKILL_TO_AGENT[skillName];
|
|
215
|
+
if (bmeShortName) {
|
|
216
|
+
agent = agents.find((a) => a.name === bmeShortName);
|
|
217
|
+
if (agent) return agent;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Lightweight persona summary for catalog generation.
|
|
225
|
+
* Uses Strategies 1+2+2b only (no file reads). Falls back to humanized name + 🔧.
|
|
226
|
+
* @param {string} skillName
|
|
227
|
+
* @param {object[]} agents - array of agent-manifest row objects
|
|
228
|
+
* @returns {{ name: string, icon: string }}
|
|
229
|
+
*/
|
|
230
|
+
function resolvePersonaSummary(skillName, agents) {
|
|
231
|
+
const agent = findAgentMatch(skillName, agents);
|
|
232
|
+
if (agent) {
|
|
233
|
+
return { name: agent.displayName || agent.name, icon: agent.icon || '' };
|
|
234
|
+
}
|
|
235
|
+
return { name: humanizeSkillName(skillName), icon: '🔧' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a persona for the given skill via the 5-strategy fallback chain.
|
|
240
|
+
* Strategies 1-4 use agent-manifest or inline extraction. Strategy 5
|
|
241
|
+
* synthesizes a minimal persona from workflow content (never throws).
|
|
242
|
+
*/
|
|
243
|
+
function loadPersona(skillName, skillContent, workflowContent, projectRoot) {
|
|
244
|
+
const agents = loadAgentManifest(projectRoot);
|
|
245
|
+
|
|
246
|
+
// Strategies 1 + 2 + 2b: manifest-based matching (shared with resolvePersonaSummary)
|
|
247
|
+
let agent = findAgentMatch(skillName, agents);
|
|
248
|
+
|
|
249
|
+
// Strategy 3: description fuzzy match (look for "talk to <Name>")
|
|
250
|
+
if (!agent) {
|
|
251
|
+
const allContent = skillContent + '\n' + workflowContent;
|
|
252
|
+
const nameMatch = allContent.match(/\b(?:talk to|requests? the|asks? to talk to|asks? for)\s+([A-Z][a-z]+)/);
|
|
253
|
+
if (nameMatch) {
|
|
254
|
+
const candidateName = nameMatch[1];
|
|
255
|
+
agent = agents.find((a) => a.displayName === candidateName);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Strategy 4: inline extraction from skill content
|
|
260
|
+
if (!agent) {
|
|
261
|
+
const inline = extractInlinePersona(skillContent + '\n' + workflowContent);
|
|
262
|
+
if (inline) return inline;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Strategy 5: synthesize minimal persona from workflow/skill content (sp-2-4)
|
|
266
|
+
if (!agent) {
|
|
267
|
+
return synthesizePersonaFromWorkflow(skillName, skillContent, workflowContent, projectRoot);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Convert agent row to persona object
|
|
271
|
+
return {
|
|
272
|
+
name: agent.displayName,
|
|
273
|
+
icon: agent.icon || '',
|
|
274
|
+
title: agent.title || '',
|
|
275
|
+
role: agent.role || '',
|
|
276
|
+
identity: agent.identity || '',
|
|
277
|
+
communicationStyle: agent.communicationStyle || '',
|
|
278
|
+
principles: agent.principles || '',
|
|
279
|
+
source: 'agent-manifest',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Strategy 4: extract persona from inline markers in skill source.
|
|
285
|
+
* Looks for `# <Name>`, `## Identity`, `## Communication Style`, `## Principles` headings.
|
|
286
|
+
*/
|
|
287
|
+
function extractInlinePersona(content) {
|
|
288
|
+
// Try to find a top-level `# Name` heading near the start
|
|
289
|
+
const lines = content.split('\n');
|
|
290
|
+
let name = null;
|
|
291
|
+
for (let i = 0; i < Math.min(50, lines.length); i++) {
|
|
292
|
+
const m = lines[i].match(/^#\s+([A-Z][a-zA-Z]+)\s*$/);
|
|
293
|
+
if (m) {
|
|
294
|
+
name = m[1];
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!name) return null;
|
|
300
|
+
|
|
301
|
+
// Extract sections by heading
|
|
302
|
+
const identity = extractSectionByHeading(content, 'Identity');
|
|
303
|
+
const commStyle = extractSectionByHeading(content, 'Communication Style');
|
|
304
|
+
const principles = extractSectionByHeading(content, 'Principles');
|
|
305
|
+
const overview = extractSectionByHeading(content, 'Overview');
|
|
306
|
+
|
|
307
|
+
if (!identity && !commStyle && !principles) return null;
|
|
308
|
+
|
|
309
|
+
// Try to find an icon emoji near the name
|
|
310
|
+
const iconMatch = content.match(new RegExp(`#\\s+${name}\\s*([\\p{Emoji}])`, 'u'));
|
|
311
|
+
const icon = iconMatch ? iconMatch[1] : '';
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
name,
|
|
315
|
+
icon,
|
|
316
|
+
title: overview ? overview.split('\n')[0].slice(0, 80) : '',
|
|
317
|
+
role: '',
|
|
318
|
+
identity: identity || overview || '',
|
|
319
|
+
communicationStyle: commStyle || '',
|
|
320
|
+
principles: principles || '',
|
|
321
|
+
source: 'inline',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Strategy 5: synthesize a minimal persona from workflow/skill content.
|
|
327
|
+
* Used for tool-like and wrapper skills that have no agent-manifest row
|
|
328
|
+
* and no inline persona block. Always returns a valid persona — never throws.
|
|
329
|
+
*/
|
|
330
|
+
function synthesizePersonaFromWorkflow(skillName, skillContent, workflowContent, projectRoot) {
|
|
331
|
+
const allContent = skillContent + '\n' + workflowContent;
|
|
332
|
+
|
|
333
|
+
// Name: humanized skill name (e.g., bmad-distillator → Distillator)
|
|
334
|
+
const name = humanizeSkillName(skillName);
|
|
335
|
+
|
|
336
|
+
// Role: extract from **Goal:** line, or Your Role: line, or fall back to skill name
|
|
337
|
+
let role = '';
|
|
338
|
+
const goalMatch = allContent.match(/\*\*Goal:\*\*\s*(.+?)(?:\n|$)/);
|
|
339
|
+
if (goalMatch) {
|
|
340
|
+
role = goalMatch[1].trim();
|
|
341
|
+
} else {
|
|
342
|
+
const roleMatch = allContent.match(/(?:\*\*)?Your Role:?\*?\*?\s*(.+?)(?:\n|$)/i);
|
|
343
|
+
if (roleMatch) role = roleMatch[1].trim();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Identity: ## Overview first paragraph, or manifest description
|
|
347
|
+
let identity = '';
|
|
348
|
+
const overview = extractSectionByHeading(allContent, 'Overview');
|
|
349
|
+
if (overview) {
|
|
350
|
+
// First non-empty paragraph
|
|
351
|
+
const para = overview.split(/\n\n/)[0];
|
|
352
|
+
identity = para ? para.trim().slice(0, 200) : '';
|
|
353
|
+
}
|
|
354
|
+
if (!identity) {
|
|
355
|
+
// Fall back to manifest description. Wrapped in try/catch to preserve the
|
|
356
|
+
// "never throws" contract — manifest was already read earlier in the pipeline
|
|
357
|
+
// but we re-read here for the description column.
|
|
358
|
+
try {
|
|
359
|
+
const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv');
|
|
360
|
+
const { header, rows } = readManifest(manifestPath);
|
|
361
|
+
const nameIdx = header.indexOf('name');
|
|
362
|
+
const descIdx = header.indexOf('description');
|
|
363
|
+
const row = rows.find((r) => r[nameIdx] === skillName);
|
|
364
|
+
if (row && descIdx >= 0) {
|
|
365
|
+
identity = (row[descIdx] || '').slice(0, 200);
|
|
366
|
+
}
|
|
367
|
+
} catch (_) {
|
|
368
|
+
// Manifest read failed — identity stays empty, which is acceptable
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
name,
|
|
374
|
+
icon: '🔧',
|
|
375
|
+
title: role || name,
|
|
376
|
+
role: role || '',
|
|
377
|
+
identity: identity || role || '',
|
|
378
|
+
communicationStyle: '',
|
|
379
|
+
principles: '',
|
|
380
|
+
source: 'workflow-derived',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Extract a markdown section by heading name (case-insensitive).
|
|
386
|
+
* Returns the content between `## <heading>` and the next `## ` heading.
|
|
387
|
+
*/
|
|
388
|
+
function extractSectionByHeading(content, headingName) {
|
|
389
|
+
const re = new RegExp(`^##\\s+${headingName}\\s*$([\\s\\S]*?)(?=^##\\s|(?![\\s\\S]))`, 'mi');
|
|
390
|
+
const m = content.match(re);
|
|
391
|
+
if (!m) return null;
|
|
392
|
+
return m[1].trim();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// =============================================================================
|
|
396
|
+
// TRANSFORMATION RULES (Phases 1-7)
|
|
397
|
+
// =============================================================================
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Apply all transformation rules in order. Pure functional, no side effects
|
|
401
|
+
* except optional warning emission.
|
|
402
|
+
*/
|
|
403
|
+
function applyTransformations(text, warnings, options = {}) {
|
|
404
|
+
let result = text;
|
|
405
|
+
|
|
406
|
+
// Phase 1: Strip frontmatter blocks at the start of any block
|
|
407
|
+
result = result.replace(/^---\n[\s\S]*?\n---\n/gm, '');
|
|
408
|
+
|
|
409
|
+
// Phase 2: Strip XML/MDX tags (keep content)
|
|
410
|
+
result = result.replace(/<\/?(workflow|step|action|check|critical|output|ask)(\s[^>]*)?>/g, '');
|
|
411
|
+
|
|
412
|
+
// Phase 3: Strip framework calls (line-by-line)
|
|
413
|
+
const frameworkPatterns = [
|
|
414
|
+
/bmad-init/i,
|
|
415
|
+
/bmad-help/i,
|
|
416
|
+
/Skill:\s*bmad-/i,
|
|
417
|
+
/\{project-root\}/,
|
|
418
|
+
/\.claude\/hooks/,
|
|
419
|
+
/bmad-speak/,
|
|
420
|
+
];
|
|
421
|
+
result = result
|
|
422
|
+
.split('\n')
|
|
423
|
+
.filter((line) => {
|
|
424
|
+
for (const pattern of frameworkPatterns) {
|
|
425
|
+
if (pattern.test(line)) {
|
|
426
|
+
// Track hook-script removals as warnings
|
|
427
|
+
if (warnings && /\.claude\/hooks|bmad-speak/.test(line)) {
|
|
428
|
+
warnings.push({
|
|
429
|
+
type: 'hook-script-stripped',
|
|
430
|
+
message: `stripped line: ${line.trim().slice(0, 100)}`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return true;
|
|
437
|
+
})
|
|
438
|
+
.join('\n');
|
|
439
|
+
|
|
440
|
+
// Phase 3b: Strip _bmad/ paths (after framework calls — those references are already gone)
|
|
441
|
+
// Only strip lines whose primary content is a _bmad/ path (e.g., "Read _bmad/foo/bar.md").
|
|
442
|
+
// Avoid stripping the line if _bmad/ appears only as a parenthetical or backtick reference.
|
|
443
|
+
result = result
|
|
444
|
+
.split('\n')
|
|
445
|
+
.filter((line) => {
|
|
446
|
+
// Strip lines that are predominantly a _bmad/ path
|
|
447
|
+
if (/^\s*[`*-]?\s*_bmad\//.test(line)) return false;
|
|
448
|
+
// Strip lines like "Load `{project-root}/_bmad/..." which the framework filter already caught
|
|
449
|
+
return true;
|
|
450
|
+
})
|
|
451
|
+
.join('\n');
|
|
452
|
+
|
|
453
|
+
// Phase 4: Strip micro-file directives
|
|
454
|
+
const microFileDirectives = [
|
|
455
|
+
/Load step:/i,
|
|
456
|
+
/read fully and follow/i,
|
|
457
|
+
/Read fully and execute:/i,
|
|
458
|
+
/Load fully and follow:/i,
|
|
459
|
+
];
|
|
460
|
+
result = result
|
|
461
|
+
.split('\n')
|
|
462
|
+
.filter((line) => !microFileDirectives.some((p) => p.test(line)))
|
|
463
|
+
.join('\n');
|
|
464
|
+
|
|
465
|
+
// Phase 5: Replace tool names
|
|
466
|
+
// Order matters — longer/more specific patterns first
|
|
467
|
+
result = result
|
|
468
|
+
.replace(/(?:the\s+)?Read tool/g, 'read the file at')
|
|
469
|
+
.replace(/(?:the\s+)?Edit tool/g, 'edit the file at')
|
|
470
|
+
.replace(/(?:the\s+)?Write tool/g, 'create a file at')
|
|
471
|
+
.replace(/(?:the\s+)?Bash tool/g, 'run the shell command')
|
|
472
|
+
.replace(/(?:the\s+)?Glob tool/g, 'find files matching')
|
|
473
|
+
.replace(/(?:the\s+)?Grep tool/g, 'search file contents for');
|
|
474
|
+
|
|
475
|
+
// Skill tool: strip the entire line (Option A from Dev Notes)
|
|
476
|
+
result = result
|
|
477
|
+
.split('\n')
|
|
478
|
+
.filter((line) => !/Skill tool/.test(line))
|
|
479
|
+
.join('\n');
|
|
480
|
+
|
|
481
|
+
// Phase 6: Substitute config vars (skipped when processing template content — preserves {{var}} placeholders)
|
|
482
|
+
if (options.skipPhase6) {
|
|
483
|
+
// Phase 7: Collapse whitespace (still applies even when Phase 6 is skipped)
|
|
484
|
+
result = result.replace(/\n{3,}/g, '\n\n').trim();
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
const configVarMap = {
|
|
488
|
+
user_name: '[your name]',
|
|
489
|
+
communication_language: '[your preferred language]',
|
|
490
|
+
document_output_language: '[your document language]',
|
|
491
|
+
output_folder: '[your output folder]',
|
|
492
|
+
planning_artifacts: '[your planning artifacts directory]',
|
|
493
|
+
implementation_artifacts: '[your implementation artifacts directory]',
|
|
494
|
+
};
|
|
495
|
+
for (const [varName, replacement] of Object.entries(configVarMap)) {
|
|
496
|
+
const re = new RegExp(`\\{${varName}\\}`, 'g');
|
|
497
|
+
result = result.replace(re, replacement);
|
|
498
|
+
}
|
|
499
|
+
// Also handle {{var}} double-brace forms
|
|
500
|
+
for (const [varName, replacement] of Object.entries(configVarMap)) {
|
|
501
|
+
const re = new RegExp(`\\{\\{${varName}\\}\\}`, 'g');
|
|
502
|
+
result = result.replace(re, replacement);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Strip any remaining {var} placeholders that weren't in the config map (avoid leakage).
|
|
506
|
+
// Emit a warning per unique unmapped var so typos in configVarMap don't go silent.
|
|
507
|
+
const unmappedSeen = new Set();
|
|
508
|
+
result = result.replace(/\{\{?([\w_-]+)\}?\}/g, (_match, varName) => {
|
|
509
|
+
if (warnings && !unmappedSeen.has(varName)) {
|
|
510
|
+
unmappedSeen.add(varName);
|
|
511
|
+
warnings.push({
|
|
512
|
+
type: 'unresolved-template-path',
|
|
513
|
+
message: `unmapped config var stripped via catch-all: {${varName}}`,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return '[your context]';
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Phase 7: Collapse whitespace
|
|
520
|
+
result = result.replace(/\n{3,}/g, '\n\n').trim();
|
|
521
|
+
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// =============================================================================
|
|
526
|
+
// SECTION EXTRACTORS
|
|
527
|
+
// =============================================================================
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Strip leading bmad- prefixes and convert kebab-case to title case.
|
|
531
|
+
*/
|
|
532
|
+
function humanizeSkillName(skillName) {
|
|
533
|
+
return skillName
|
|
534
|
+
.replace(/^bmad-cis-agent-/, '')
|
|
535
|
+
.replace(/^bmad-agent-/, '')
|
|
536
|
+
.replace(/^bmad-cis-/, '')
|
|
537
|
+
.replace(/^bmad-/, '')
|
|
538
|
+
.split('-')
|
|
539
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
540
|
+
.join(' ');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function extractTitle(skillName, persona) {
|
|
544
|
+
const display = humanizeSkillName(skillName);
|
|
545
|
+
if (persona && persona.name) {
|
|
546
|
+
return `# ${display} with ${persona.name}`;
|
|
547
|
+
}
|
|
548
|
+
return `# ${display}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function extractPersona(persona) {
|
|
552
|
+
const lines = [];
|
|
553
|
+
const nameWithIcon = persona.icon ? `${persona.name} ${persona.icon}` : persona.name;
|
|
554
|
+
lines.push(`## You are ${nameWithIcon}`);
|
|
555
|
+
lines.push('');
|
|
556
|
+
if (persona.role || persona.title) {
|
|
557
|
+
lines.push(`**Role:** ${persona.role || persona.title}`);
|
|
558
|
+
lines.push('');
|
|
559
|
+
}
|
|
560
|
+
if (persona.identity) {
|
|
561
|
+
lines.push(`**Identity:** ${persona.identity}`);
|
|
562
|
+
lines.push('');
|
|
563
|
+
}
|
|
564
|
+
if (persona.communicationStyle) {
|
|
565
|
+
lines.push(`**Communication style:** ${persona.communicationStyle}`);
|
|
566
|
+
lines.push('');
|
|
567
|
+
}
|
|
568
|
+
if (persona.principles) {
|
|
569
|
+
lines.push(`**Principles:**`);
|
|
570
|
+
lines.push('');
|
|
571
|
+
// Principles may be a single string with bullets, or just prose
|
|
572
|
+
const principlesText = persona.principles;
|
|
573
|
+
if (principlesText.includes('- ')) {
|
|
574
|
+
// Already has bullets — keep as-is, just normalize
|
|
575
|
+
const bulletLines = principlesText
|
|
576
|
+
.split(/\n+/)
|
|
577
|
+
.map((l) => l.trim())
|
|
578
|
+
.filter((l) => l.length > 0)
|
|
579
|
+
.map((l) => (l.startsWith('-') ? l : `- ${l}`));
|
|
580
|
+
lines.push(...bulletLines);
|
|
581
|
+
} else {
|
|
582
|
+
// Split on sentences (approximate)
|
|
583
|
+
const sentences = principlesText.split(/(?<=\.)\s+/).filter((s) => s.trim().length > 0);
|
|
584
|
+
for (const s of sentences) {
|
|
585
|
+
lines.push(`- ${s.trim()}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return lines.join('\n');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function extractWhenToUse(skillRow, workflowContent) {
|
|
593
|
+
const lines = [];
|
|
594
|
+
lines.push('## When to use this skill');
|
|
595
|
+
lines.push('');
|
|
596
|
+
// Description from skill manifest
|
|
597
|
+
const description = skillRow.description || '';
|
|
598
|
+
// Strip the "Use when..." trailing clause if present, since we'll generate our own
|
|
599
|
+
const cleanDesc = description.replace(/\s*Use when[^.]*\.?\s*$/i, '').trim();
|
|
600
|
+
lines.push(cleanDesc);
|
|
601
|
+
lines.push('');
|
|
602
|
+
lines.push('**Use when:**');
|
|
603
|
+
lines.push('');
|
|
604
|
+
|
|
605
|
+
// Try to extract trigger conditions from the description's "Use when..." clause
|
|
606
|
+
const useWhenMatch = description.match(/Use when[^.]*\./i);
|
|
607
|
+
if (useWhenMatch) {
|
|
608
|
+
// Parse simple "X or Y" patterns
|
|
609
|
+
const trigger = useWhenMatch[0].replace(/^Use when\s+/i, '').replace(/\.$/, '');
|
|
610
|
+
const parts = trigger.split(/\s+or\s+/i);
|
|
611
|
+
for (const part of parts) {
|
|
612
|
+
lines.push(`- ${part.trim()}`);
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
// Generate one fallback bullet
|
|
616
|
+
const humanName = humanizeSkillName(skillRow.name).toLowerCase();
|
|
617
|
+
lines.push(`- The user explicitly requests ${humanName}`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return lines.join('\n');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function extractInputs(workflowContent, stepContents) {
|
|
624
|
+
const lines = [];
|
|
625
|
+
lines.push('## Inputs you may need');
|
|
626
|
+
lines.push('');
|
|
627
|
+
|
|
628
|
+
const inputs = [];
|
|
629
|
+
|
|
630
|
+
// Look for `### Configuration Loading` block in workflow
|
|
631
|
+
const configBlock = workflowContent.match(/### Configuration Loading\s*([\s\S]*?)(?=^###|(?![\s\S]))/m);
|
|
632
|
+
if (configBlock) {
|
|
633
|
+
// Extract config var names mentioned
|
|
634
|
+
const varNames = [...configBlock[1].matchAll(/`?\{([\w_-]+)\}`?/g)].map((m) => m[1]);
|
|
635
|
+
const seen = new Set();
|
|
636
|
+
for (const v of varNames) {
|
|
637
|
+
if (seen.has(v)) continue;
|
|
638
|
+
seen.add(v);
|
|
639
|
+
// Skip the universal config vars — they're substituted, not exposed as inputs
|
|
640
|
+
if (
|
|
641
|
+
['user_name', 'communication_language', 'document_output_language', 'output_folder',
|
|
642
|
+
'planning_artifacts', 'implementation_artifacts', 'project_name', 'project_knowledge',
|
|
643
|
+
'user_skill_level', 'date'].includes(v)
|
|
644
|
+
) {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
inputs.push(`- **${v.replace(/_/g, ' ')}.** Replace any placeholder for this with your project's actual value.`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Look for context_file references
|
|
652
|
+
if (/context_file/.test(workflowContent)) {
|
|
653
|
+
inputs.push('- **Optional context file.** A markdown file with project-specific guidance to inform the session.');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (inputs.length === 0) {
|
|
657
|
+
lines.push('(none required)');
|
|
658
|
+
} else {
|
|
659
|
+
lines.push(...inputs);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return lines.join('\n');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function extractHowToProceed(workflowContent, stepContents, skillContent) {
|
|
666
|
+
const lines = [];
|
|
667
|
+
lines.push('## How to proceed');
|
|
668
|
+
lines.push('');
|
|
669
|
+
|
|
670
|
+
// Group step files by base step number to collapse branches
|
|
671
|
+
const stepNames = Object.keys(stepContents).sort();
|
|
672
|
+
const groups = {};
|
|
673
|
+
for (const stepName of stepNames) {
|
|
674
|
+
// Match patterns like "step-01-foo", "step-02a-bar", "step-t-01-baz"
|
|
675
|
+
const m = stepName.match(/step-(?:[a-z]-)?(\d+)/);
|
|
676
|
+
const num = m ? parseInt(m[1], 10) : 0;
|
|
677
|
+
if (!groups[num]) groups[num] = [];
|
|
678
|
+
groups[num].push(stepName);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const sortedNums = Object.keys(groups).map(Number).sort((a, b) => a - b);
|
|
682
|
+
|
|
683
|
+
if (sortedNums.length === 0) {
|
|
684
|
+
// No step files — derive from workflow content directly, then SKILL.md
|
|
685
|
+
// Search both workflow and skill content for procedural blocks
|
|
686
|
+
const sources = [workflowContent, skillContent || ''];
|
|
687
|
+
let extracted = null;
|
|
688
|
+
for (const source of sources) {
|
|
689
|
+
// Try several heading variants
|
|
690
|
+
const taskBlock = source.match(/##\s+(?:Your Task|Instructions|Workflow|Execution|How to proceed|On Activation)\s*([\s\S]*?)(?=^##\s|(?![\s\S]))/im);
|
|
691
|
+
if (taskBlock) {
|
|
692
|
+
extracted = taskBlock[1].trim();
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (extracted) {
|
|
697
|
+
lines.push(extracted);
|
|
698
|
+
} else {
|
|
699
|
+
lines.push('Follow the persona\'s established workflow as described in the persona section above. The user will guide the conversation.');
|
|
700
|
+
}
|
|
701
|
+
return lines.join('\n');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Inline each step group as a numbered list item
|
|
705
|
+
let outputNum = 1;
|
|
706
|
+
for (const num of sortedNums) {
|
|
707
|
+
const groupSteps = groups[num];
|
|
708
|
+
if (groupSteps.length === 1) {
|
|
709
|
+
// Single step — inline directly
|
|
710
|
+
const content = extractStepInstructionalContent(stepContents[groupSteps[0]]);
|
|
711
|
+
const title = extractStepTitle(stepContents[groupSteps[0]]) || `Step ${outputNum}`;
|
|
712
|
+
lines.push(`${outputNum}. **${title}**`);
|
|
713
|
+
lines.push('');
|
|
714
|
+
const indented = content.split('\n').map((l) => l ? ` ${l}` : '').join('\n');
|
|
715
|
+
lines.push(indented);
|
|
716
|
+
lines.push('');
|
|
717
|
+
outputNum++;
|
|
718
|
+
} else {
|
|
719
|
+
// Branching — present as nested options
|
|
720
|
+
// Use the first step's title as the umbrella title
|
|
721
|
+
const umbrellaTitle = `Step ${outputNum} (multiple paths)`;
|
|
722
|
+
lines.push(`${outputNum}. **${umbrellaTitle}** — choose one of the following:`);
|
|
723
|
+
lines.push('');
|
|
724
|
+
for (const stepName of groupSteps) {
|
|
725
|
+
const content = extractStepInstructionalContent(stepContents[stepName]);
|
|
726
|
+
const title = extractStepTitle(stepContents[stepName]) || stepName;
|
|
727
|
+
lines.push(` - **${title}**`);
|
|
728
|
+
const indented = content.split('\n').map((l) => l ? ` ${l}` : '').join('\n');
|
|
729
|
+
lines.push(indented);
|
|
730
|
+
lines.push('');
|
|
731
|
+
}
|
|
732
|
+
outputNum++;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return lines.join('\n');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function extractStepTitle(stepContent) {
|
|
740
|
+
// Look for the first `# Step X: Title` or `## Step X: Title` heading
|
|
741
|
+
const m = stepContent.match(/^#+\s+(?:Step\s+\d+[a-z]?:\s+)?(.+?)$/m);
|
|
742
|
+
if (m) return m[1].trim();
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function extractStepInstructionalContent(stepContent) {
|
|
747
|
+
// Strip meta sections by walking the content and skipping headings in the meta list
|
|
748
|
+
const lines = stepContent.split('\n');
|
|
749
|
+
const result = [];
|
|
750
|
+
let skipping = false;
|
|
751
|
+
let inFirstHeading = true;
|
|
752
|
+
|
|
753
|
+
for (let i = 0; i < lines.length; i++) {
|
|
754
|
+
const line = lines[i];
|
|
755
|
+
// Check for heading
|
|
756
|
+
const headingMatch = line.match(/^##\s+(.+?)\s*:?$/);
|
|
757
|
+
if (headingMatch) {
|
|
758
|
+
const headingText = headingMatch[1].toUpperCase().replace(/[^A-Z\s/]/g, '').trim();
|
|
759
|
+
// Check if this matches a meta section
|
|
760
|
+
const isMeta = META_SECTIONS_TO_STRIP.some((meta) => headingText.includes(meta));
|
|
761
|
+
if (isMeta) {
|
|
762
|
+
skipping = true;
|
|
763
|
+
continue;
|
|
764
|
+
} else {
|
|
765
|
+
skipping = false;
|
|
766
|
+
// Skip the very first heading (it's the step title, already captured separately)
|
|
767
|
+
if (inFirstHeading) {
|
|
768
|
+
inFirstHeading = false;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Skip H1 step titles too
|
|
774
|
+
if (/^#\s+Step\s+\d/i.test(line)) {
|
|
775
|
+
inFirstHeading = false;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (!skipping) {
|
|
779
|
+
result.push(line);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Collapse and trim
|
|
784
|
+
return result.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function extractWhatYouProduce(workflowContent, stepContents, skillRow) {
|
|
788
|
+
const lines = [];
|
|
789
|
+
lines.push('## What you produce');
|
|
790
|
+
lines.push('');
|
|
791
|
+
|
|
792
|
+
// Try several patterns
|
|
793
|
+
// 1. ## Output heading
|
|
794
|
+
const outputBlock = workflowContent.match(/^##\s+Output\s*([\s\S]*?)(?=^##\s|(?![\s\S]))/m);
|
|
795
|
+
if (outputBlock) {
|
|
796
|
+
lines.push(outputBlock[1].trim());
|
|
797
|
+
return lines.join('\n');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 2. *_output_file or *_artifact path variable in ### Paths
|
|
801
|
+
const pathsBlock = workflowContent.match(/###\s+Paths\s*([\s\S]*?)(?=^###|^##|(?![\s\S]))/m);
|
|
802
|
+
if (pathsBlock) {
|
|
803
|
+
const outputFile = pathsBlock[1].match(/[`*]?(\w*_output_file|\w*_artifact)[`*]?\s*=\s*[`]?([^`\n]+)[`]?/);
|
|
804
|
+
if (outputFile) {
|
|
805
|
+
const humanName = humanizeSkillName(skillRow.name).toLowerCase();
|
|
806
|
+
lines.push(`A markdown ${humanName} document at \`${outputFile[2].replace(/\{[\w_-]+\}/g, '[your output folder]')}\`. The document captures the session output and is intentionally raw — value comes from quantity and diversity, not pre-curation.`);
|
|
807
|
+
return lines.join('\n');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// 3. **Goal:** line — extract the deliverable noun
|
|
812
|
+
const goalMatch = workflowContent.match(/\*\*Goal:\*\*\s+(.+?)(?:\n|$)/);
|
|
813
|
+
if (goalMatch) {
|
|
814
|
+
const humanName = humanizeSkillName(skillRow.name).toLowerCase();
|
|
815
|
+
lines.push(`A markdown document capturing ${goalMatch[1].toLowerCase().replace(/^[a-z]/, (c) => c)}. Lives at \`[your output folder]/${humanName.replace(/\s+/g, '-')}/[date].md\`.`);
|
|
816
|
+
return lines.join('\n');
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Fallback
|
|
820
|
+
const humanName = humanizeSkillName(skillRow.name).toLowerCase();
|
|
821
|
+
lines.push(`A markdown document at \`[your output folder]/${humanName.replace(/\s+/g, '-')}/[date].md\`.`);
|
|
822
|
+
return lines.join('\n');
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function extractQualityChecks(workflowContent, stepContents) {
|
|
826
|
+
// Look for `## SUCCESS METRICS` blocks across step files
|
|
827
|
+
const checks = [];
|
|
828
|
+
for (const stepName of Object.keys(stepContents)) {
|
|
829
|
+
const content = stepContents[stepName];
|
|
830
|
+
const successBlock = content.match(/##\s+(?:SUCCESS METRICS|SUCCESS CRITERIA|QUALITY CHECKS|CRITICAL RULES)\s*([\s\S]*?)(?=^##\s|(?![\s\S]))/mi);
|
|
831
|
+
if (successBlock) {
|
|
832
|
+
// Extract bullet items, only those that look like complete sentences (no truncation artifacts)
|
|
833
|
+
const bullets = successBlock[1]
|
|
834
|
+
.split('\n')
|
|
835
|
+
.map((l) => l.trim())
|
|
836
|
+
.filter((l) => /^[-*✅]/.test(l))
|
|
837
|
+
.map((l) => l.replace(/^[-*✅]\s*/, '').replace(/^✅\s*/, '').trim())
|
|
838
|
+
// Filter out truncated entries (no trailing punctuation, ends mid-word)
|
|
839
|
+
.filter((l) => l.length > 10 && !l.endsWith(' ') && /[.a-zA-Z0-9]$/.test(l));
|
|
840
|
+
checks.push(...bullets);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (checks.length === 0) return null; // omit section entirely
|
|
845
|
+
|
|
846
|
+
// Dedupe (case-insensitive, normalize whitespace)
|
|
847
|
+
const seen = new Set();
|
|
848
|
+
const unique = [];
|
|
849
|
+
for (const check of checks) {
|
|
850
|
+
const normalized = check.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
851
|
+
if (seen.has(normalized)) continue;
|
|
852
|
+
seen.add(normalized);
|
|
853
|
+
unique.push(check);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Cap at 10 to keep the section digestible
|
|
857
|
+
const capped = unique.slice(0, 10);
|
|
858
|
+
|
|
859
|
+
const lines = [];
|
|
860
|
+
lines.push('## Quality checks');
|
|
861
|
+
lines.push('');
|
|
862
|
+
lines.push('Before declaring the session complete, verify:');
|
|
863
|
+
lines.push('');
|
|
864
|
+
for (const check of capped) {
|
|
865
|
+
lines.push(`- [ ] ${check}`);
|
|
866
|
+
}
|
|
867
|
+
return lines.join('\n');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// =============================================================================
|
|
871
|
+
// TIER 2: DEPENDENCY HANDLING (sp-5-1)
|
|
872
|
+
// =============================================================================
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Categorize a skill's dependencies into templates, skill-refs, and sidecars.
|
|
876
|
+
* @param {string} depsString - semicolon-separated deps from manifest
|
|
877
|
+
* @param {string} projectRoot
|
|
878
|
+
* @param {Set<string>} manifestSkillNames - all skill names in manifest
|
|
879
|
+
* @param {string} skillDir - the skill's source directory (for relative path resolution)
|
|
880
|
+
* @returns {{ templates: Array, skillRefs: Array, sidecars: Array }}
|
|
881
|
+
*/
|
|
882
|
+
function categorizeDependencies(depsString, projectRoot, manifestSkillNames, skillDir) {
|
|
883
|
+
const templates = [];
|
|
884
|
+
const skillRefs = [];
|
|
885
|
+
const sidecars = [];
|
|
886
|
+
|
|
887
|
+
if (!depsString || !depsString.trim()) return { templates, skillRefs, sidecars };
|
|
888
|
+
|
|
889
|
+
const deps = depsString.split(';').map((d) => d.trim()).filter(Boolean);
|
|
890
|
+
|
|
891
|
+
for (const dep of deps) {
|
|
892
|
+
// 1. Skill reference (matches a manifest name)
|
|
893
|
+
if (manifestSkillNames.has(dep)) {
|
|
894
|
+
skillRefs.push({ name: dep });
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// 2. Sidecar (path contains _memory or sidecar)
|
|
899
|
+
if (dep.includes('_memory') || dep.includes('sidecar')) {
|
|
900
|
+
sidecars.push({ name: path.basename(dep), path: dep });
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// 3. Template (path under templates/ directory, exists on disk)
|
|
905
|
+
// Try to resolve: direct resolution, project-root-relative, then subtree search by basename
|
|
906
|
+
let resolvedPath = null;
|
|
907
|
+
const candidates = [
|
|
908
|
+
path.resolve(skillDir, dep),
|
|
909
|
+
path.join(projectRoot, dep),
|
|
910
|
+
];
|
|
911
|
+
for (const candidate of candidates) {
|
|
912
|
+
if (fs.existsSync(candidate)) {
|
|
913
|
+
resolvedPath = candidate;
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Subtree search fallback: look for the basename under the skill dir tree
|
|
918
|
+
if (!resolvedPath && dep.endsWith('.md')) {
|
|
919
|
+
const basename = path.basename(dep);
|
|
920
|
+
const searchDirs = [skillDir, path.join(projectRoot, '_bmad')];
|
|
921
|
+
for (const searchDir of searchDirs) {
|
|
922
|
+
if (!fs.existsSync(searchDir)) continue;
|
|
923
|
+
const found = findFileByName(searchDir, basename);
|
|
924
|
+
if (found) { resolvedPath = found; break; }
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (resolvedPath && dep.includes('templates/')) {
|
|
929
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
930
|
+
const displayName = path.basename(dep, '.md')
|
|
931
|
+
.replace(/-/g, ' ')
|
|
932
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
933
|
+
templates.push({ name: displayName, path: resolvedPath, content });
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// 4. Exists on disk but not under templates/ → sidecar (conservative)
|
|
938
|
+
if (resolvedPath) {
|
|
939
|
+
sidecars.push({ name: path.basename(dep), path: dep });
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// 5. Unknown → sidecar
|
|
944
|
+
sidecars.push({ name: path.basename(dep), path: dep });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return { templates, skillRefs, sidecars };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Build inlined template sections for Tier 2 skills.
|
|
952
|
+
* Each template gets its own ## heading with the content below.
|
|
953
|
+
* Applies transformations with skipPhase6 to preserve {{var}} placeholders.
|
|
954
|
+
*/
|
|
955
|
+
function buildTemplateSections(templates, warnings) {
|
|
956
|
+
const sections = [];
|
|
957
|
+
for (const tpl of templates) {
|
|
958
|
+
let content = tpl.content;
|
|
959
|
+
// Strip YAML frontmatter from template
|
|
960
|
+
content = content.replace(/^---\n[\s\S]*?\n---\n/, '');
|
|
961
|
+
// Apply transformations Phases 1-5 + 7 only (skip Phase 6 to preserve {{var}})
|
|
962
|
+
content = applyTransformations(content, warnings, { skipPhase6: true });
|
|
963
|
+
|
|
964
|
+
sections.push(`## Template: ${tpl.name}`);
|
|
965
|
+
sections.push('');
|
|
966
|
+
sections.push('> Replace template placeholders ({{...}}) with your project\'s actual values.');
|
|
967
|
+
sections.push('');
|
|
968
|
+
sections.push('Use this template as the starting structure for your output document.');
|
|
969
|
+
sections.push('');
|
|
970
|
+
sections.push(content);
|
|
971
|
+
}
|
|
972
|
+
return sections.join('\n');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Build companion-skill and sidecar notes for the Inputs section.
|
|
977
|
+
*/
|
|
978
|
+
function buildDependencyNotes(skillRefs, sidecars, manifestSkillNames) {
|
|
979
|
+
const lines = [];
|
|
980
|
+
for (const ref of skillRefs) {
|
|
981
|
+
const qualifier = manifestSkillNames.has(ref.name) ? ' Available in the skills catalog.' : '';
|
|
982
|
+
const displayName = humanizeSkillName(ref.name);
|
|
983
|
+
lines.push(`- **Companion skill: ${displayName}.** This skill works best when used together with the ${displayName} skill.${qualifier}`);
|
|
984
|
+
}
|
|
985
|
+
for (const sc of sidecars) {
|
|
986
|
+
lines.push(`- **Persistent data: ${sc.name}.** This skill maintains a data file for session history. Create an empty file at this path if starting fresh.`);
|
|
987
|
+
}
|
|
988
|
+
return lines.join('\n');
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// =============================================================================
|
|
992
|
+
// MAIN: exportSkill
|
|
993
|
+
// =============================================================================
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Export a Tier 1 standalone skill into canonical instructions.md format.
|
|
997
|
+
*
|
|
998
|
+
* @param {string} skillName - The skill's canonical name (e.g., 'bmad-brainstorming')
|
|
999
|
+
* @param {string} projectRoot - Absolute path to the project root
|
|
1000
|
+
* @param {object} [options] - Reserved for future use
|
|
1001
|
+
* @returns {{ instructions: string, persona: object, sections: object, warnings: object[] }}
|
|
1002
|
+
*
|
|
1003
|
+
* Throws:
|
|
1004
|
+
* - if the skill is not in the manifest
|
|
1005
|
+
* - if the skill's tier is 'pipeline' (only standalone + light-deps are exportable)
|
|
1006
|
+
* - if persona resolution fails (all 5 strategies)
|
|
1007
|
+
*/
|
|
1008
|
+
function exportSkill(skillName, projectRoot, options = {}) {
|
|
1009
|
+
const warnings = [];
|
|
1010
|
+
|
|
1011
|
+
// 1. Load skill row + tier check (standalone + light-deps allowed; pipeline rejected)
|
|
1012
|
+
const skillRow = loadSkillRow(skillName, projectRoot);
|
|
1013
|
+
if (skillRow.tier === 'pipeline') {
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
`${skillName} is tier "pipeline" — pipeline skills are not exported per the portability schema.`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// 2. Load source files
|
|
1020
|
+
const { skillContent, workflowContent, stepContents, skillDir } = loadSkillSource(skillRow, projectRoot, warnings);
|
|
1021
|
+
|
|
1022
|
+
// 3. Resolve persona (throws if all 5 strategies fail)
|
|
1023
|
+
const persona = loadPersona(skillName, skillContent, workflowContent, projectRoot);
|
|
1024
|
+
|
|
1025
|
+
// 4. Categorize dependencies for Tier 2 (no-op for standalone — empty deps)
|
|
1026
|
+
const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv');
|
|
1027
|
+
const { header: mHeader, rows: mRows } = readManifest(manifestPath);
|
|
1028
|
+
const mNameIdx = mHeader.indexOf('name');
|
|
1029
|
+
const manifestSkillNames = new Set(mRows.map((r) => r[mNameIdx]));
|
|
1030
|
+
const deps = categorizeDependencies(
|
|
1031
|
+
skillRow.dependencies || '', projectRoot, manifestSkillNames, skillDir || path.dirname(path.join(projectRoot, skillRow.path))
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
// 5. Run all section extractors
|
|
1035
|
+
const sections = {
|
|
1036
|
+
title: extractTitle(skillName, persona),
|
|
1037
|
+
persona: extractPersona(persona),
|
|
1038
|
+
whenToUse: extractWhenToUse(skillRow, workflowContent),
|
|
1039
|
+
inputs: extractInputs(workflowContent, stepContents),
|
|
1040
|
+
howToProceed: extractHowToProceed(workflowContent, stepContents, skillContent),
|
|
1041
|
+
whatYouProduce: extractWhatYouProduce(workflowContent, stepContents, skillRow),
|
|
1042
|
+
qualityChecks: extractQualityChecks(workflowContent, stepContents),
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
// 5. Apply transformations to each section (post-extraction cleanup)
|
|
1046
|
+
const transformedSections = {};
|
|
1047
|
+
for (const [key, value] of Object.entries(sections)) {
|
|
1048
|
+
if (value === null) {
|
|
1049
|
+
transformedSections[key] = null;
|
|
1050
|
+
} else {
|
|
1051
|
+
transformedSections[key] = applyTransformations(value, warnings);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// 5b. Replace template-path references in workflow text with "see Template section below"
|
|
1056
|
+
if (deps.templates.length > 0 && transformedSections.howToProceed) {
|
|
1057
|
+
for (const tpl of deps.templates) {
|
|
1058
|
+
const basename = path.basename(tpl.path);
|
|
1059
|
+
// Replace lines referencing the template file (load, read, use the template, etc.)
|
|
1060
|
+
transformedSections.howToProceed = transformedSections.howToProceed
|
|
1061
|
+
.split('\n')
|
|
1062
|
+
.map((line) => {
|
|
1063
|
+
if (line.includes(basename) || (line.includes('template') && line.includes('load'))) {
|
|
1064
|
+
// Only replace if the line looks like a template-loading directive
|
|
1065
|
+
if (/(?:load|read|use|initialize from|open)\b/i.test(line) && line.includes(basename)) {
|
|
1066
|
+
return line.replace(
|
|
1067
|
+
new RegExp(`[^\`]*${basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^\`]*`, 'i'),
|
|
1068
|
+
`see the "Template: ${tpl.name}" section below`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return line;
|
|
1073
|
+
})
|
|
1074
|
+
.join('\n');
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// 6. Assemble the final instructions.md
|
|
1079
|
+
const parts = [
|
|
1080
|
+
transformedSections.title,
|
|
1081
|
+
'',
|
|
1082
|
+
transformedSections.persona,
|
|
1083
|
+
'',
|
|
1084
|
+
transformedSections.whenToUse,
|
|
1085
|
+
'',
|
|
1086
|
+
transformedSections.inputs,
|
|
1087
|
+
'',
|
|
1088
|
+
transformedSections.howToProceed,
|
|
1089
|
+
'',
|
|
1090
|
+
transformedSections.whatYouProduce,
|
|
1091
|
+
];
|
|
1092
|
+
if (transformedSections.qualityChecks) {
|
|
1093
|
+
parts.push('');
|
|
1094
|
+
parts.push(transformedSections.qualityChecks);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// 6b. Append dependency notes to inputs section (Tier 2 — no-op for standalone)
|
|
1098
|
+
const depNotes = buildDependencyNotes(deps.skillRefs, deps.sidecars, manifestSkillNames);
|
|
1099
|
+
if (depNotes) {
|
|
1100
|
+
// Find the inputs part and append notes
|
|
1101
|
+
const inputsIdx = parts.indexOf(transformedSections.inputs);
|
|
1102
|
+
if (inputsIdx >= 0) {
|
|
1103
|
+
parts[inputsIdx] = parts[inputsIdx] + '\n' + depNotes;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// 6c. Append inlined template sections (Tier 2 — no-op for standalone)
|
|
1108
|
+
const templateContent = buildTemplateSections(deps.templates, warnings);
|
|
1109
|
+
if (templateContent) {
|
|
1110
|
+
parts.push('');
|
|
1111
|
+
parts.push(templateContent);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const instructions = parts.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
1115
|
+
|
|
1116
|
+
// 7. Final pass: catch any remaining unstripped XML tags as warnings
|
|
1117
|
+
const remainingTags = instructions.match(/<\/?(workflow|step|action|check|critical|output|ask)\b/g);
|
|
1118
|
+
if (remainingTags) {
|
|
1119
|
+
warnings.push({
|
|
1120
|
+
type: 'unstripped-xml-tag',
|
|
1121
|
+
message: `${remainingTags.length} unstripped XML tag(s) remain after pass: ${remainingTags.slice(0, 3).join(', ')}`,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return {
|
|
1126
|
+
instructions,
|
|
1127
|
+
persona,
|
|
1128
|
+
sections: transformedSections,
|
|
1129
|
+
warnings,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// =============================================================================
|
|
1134
|
+
// MODULE EXPORTS
|
|
1135
|
+
// =============================================================================
|
|
1136
|
+
|
|
1137
|
+
module.exports = {
|
|
1138
|
+
exportSkill,
|
|
1139
|
+
// Internal helpers exported for testing
|
|
1140
|
+
loadSkillRow,
|
|
1141
|
+
loadSkillSource,
|
|
1142
|
+
loadPersona,
|
|
1143
|
+
applyTransformations,
|
|
1144
|
+
humanizeSkillName,
|
|
1145
|
+
extractTitle,
|
|
1146
|
+
extractPersona,
|
|
1147
|
+
extractWhenToUse,
|
|
1148
|
+
extractInputs,
|
|
1149
|
+
extractHowToProceed,
|
|
1150
|
+
extractWhatYouProduce,
|
|
1151
|
+
extractQualityChecks,
|
|
1152
|
+
ALLOWED_WARNING_TYPES,
|
|
1153
|
+
// Catalog support (sp-3-1)
|
|
1154
|
+
resolvePersonaSummary,
|
|
1155
|
+
loadAgentManifest,
|
|
1156
|
+
};
|