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.
Files changed (78) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +37 -10
  3. package/_bmad/bme/_artifacts/config.yaml +15 -0
  4. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/SKILL.md +6 -0
  5. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-01-scope.md +138 -0
  6. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-02-dryrun.md +199 -0
  7. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-03-resolve.md +174 -0
  8. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/steps/step-04-execute.md +213 -0
  9. package/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md +85 -0
  10. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/SKILL.md +6 -0
  11. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-01-scan.md +131 -0
  12. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-02-explore.md +131 -0
  13. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/steps/step-03-recommend.md +149 -0
  14. package/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md +78 -0
  15. package/_bmad/bme/_portability/skills/bmad-export-skill/SKILL.md +6 -0
  16. package/_bmad/bme/_portability/skills/bmad-export-skill/workflow.md +74 -0
  17. package/_bmad/bme/_portability/skills/bmad-generate-catalog/SKILL.md +6 -0
  18. package/_bmad/bme/_portability/skills/bmad-generate-catalog/workflow.md +42 -0
  19. package/_bmad/bme/_portability/skills/bmad-seed-catalog/SKILL.md +6 -0
  20. package/_bmad/bme/_portability/skills/bmad-seed-catalog/workflow.md +61 -0
  21. package/_bmad/bme/_portability/skills/bmad-validate-exports/SKILL.md +6 -0
  22. package/_bmad/bme/_portability/skills/bmad-validate-exports/workflow.md +43 -0
  23. package/_bmad/bme/_team-factory/agents/team-factory.md +128 -0
  24. package/_bmad/bme/_team-factory/config.yaml +13 -0
  25. package/_bmad/bme/_team-factory/lib/cascade-logic.js +184 -0
  26. package/_bmad/bme/_team-factory/lib/collision-detector.js +228 -0
  27. package/_bmad/bme/_team-factory/lib/manifest-tracker.js +214 -0
  28. package/_bmad/bme/_team-factory/lib/spec-differ.js +176 -0
  29. package/_bmad/bme/_team-factory/lib/spec-parser.js +201 -0
  30. package/_bmad/bme/_team-factory/lib/spec-writer.js +128 -0
  31. package/_bmad/bme/_team-factory/lib/types/factory-types.js +193 -0
  32. package/_bmad/bme/_team-factory/lib/utils/csv-utils.js +62 -0
  33. package/_bmad/bme/_team-factory/lib/utils/naming-utils.js +45 -0
  34. package/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js +898 -0
  35. package/_bmad/bme/_team-factory/lib/writers/activation-validator.js +175 -0
  36. package/_bmad/bme/_team-factory/lib/writers/config-appender.js +192 -0
  37. package/_bmad/bme/_team-factory/lib/writers/config-creator.js +215 -0
  38. package/_bmad/bme/_team-factory/lib/writers/csv-appender.js +118 -0
  39. package/_bmad/bme/_team-factory/lib/writers/csv-creator.js +190 -0
  40. package/_bmad/bme/_team-factory/lib/writers/registry-appender.js +372 -0
  41. package/_bmad/bme/_team-factory/lib/writers/registry-writer.js +409 -0
  42. package/_bmad/bme/_team-factory/module-help.csv +3 -0
  43. package/_bmad/bme/_team-factory/schemas/schema-independent.json +147 -0
  44. package/_bmad/bme/_team-factory/schemas/schema-sequential.json +242 -0
  45. package/_bmad/bme/_team-factory/templates/team-spec-template.yaml +86 -0
  46. package/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md +105 -0
  47. package/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md +110 -0
  48. package/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md +116 -0
  49. package/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md +160 -0
  50. package/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md +146 -0
  51. package/_bmad/bme/_team-factory/workflows/step-00-route.md +76 -0
  52. package/_bmad/bme/_vortex/config.yaml +4 -4
  53. package/package.json +13 -7
  54. package/scripts/convoke-doctor.js +172 -1
  55. package/scripts/install-gyre-agents.js +0 -0
  56. package/scripts/lib/artifact-utils.js +521 -13
  57. package/scripts/lib/portfolio/portfolio-engine.js +301 -34
  58. package/scripts/lib/portfolio/rules/artifact-chain-rule.js +33 -3
  59. package/scripts/lib/portfolio/rules/conflict-resolver.js +22 -0
  60. package/scripts/migrate-artifacts.js +69 -10
  61. package/scripts/portability/catalog-generator.js +353 -0
  62. package/scripts/portability/classify-skills.js +646 -0
  63. package/scripts/portability/convoke-export.js +522 -0
  64. package/scripts/portability/export-engine.js +1156 -0
  65. package/scripts/portability/generate-adapters.js +79 -0
  66. package/scripts/portability/manifest-csv.js +147 -0
  67. package/scripts/portability/seed-catalog-repo.js +427 -0
  68. package/scripts/portability/templates/canonical-example.md +102 -0
  69. package/scripts/portability/templates/canonical-format.md +218 -0
  70. package/scripts/portability/templates/readme-template.md +72 -0
  71. package/scripts/portability/test-constants.js +42 -0
  72. package/scripts/portability/validate-classification.js +529 -0
  73. package/scripts/portability/validate-exports.js +348 -0
  74. package/scripts/update/lib/agent-registry.js +35 -0
  75. package/scripts/update/lib/config-merger.js +140 -10
  76. package/scripts/update/lib/refresh-installation.js +293 -8
  77. package/scripts/update/lib/utils.js +27 -1
  78. package/scripts/update/lib/validator.js +114 -4
@@ -0,0 +1,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
+ };