@rune-kit/rune 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,466 +1,678 @@
1
- /**
2
- * Emitter
3
- *
4
- * Writes transformed skill files to the platform's output directory.
5
- * Handles file naming, directory creation, and index generation.
6
- */
7
-
8
- import { existsSync } from 'node:fs';
9
- import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
10
- import path from 'node:path';
11
- import { extractCrossRefs, extractToolRefs, parsePack, parseSkill } from './parser.js';
12
- import { transformSkill } from './transformer.js';
13
- import { resolveScriptsPath } from './transforms/scripts-path.js';
14
-
15
- /**
16
- * Discover all SKILL.md files in the skills directory
17
- *
18
- * @param {string} skillsDir - path to skills/ directory
19
- * @returns {Promise<string[]>} array of SKILL.md file paths
20
- */
21
- async function discoverSkills(skillsDir) {
22
- const entries = await readdir(skillsDir, { withFileTypes: true });
23
- const paths = [];
24
-
25
- for (const entry of entries) {
26
- if (!entry.isDirectory()) continue;
27
- const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
28
- if (existsSync(skillFile)) {
29
- paths.push(skillFile);
30
- }
31
- }
32
-
33
- return paths.sort();
34
- }
35
-
36
- /**
37
- * Discover all PACK.md files in the extensions directory
38
- *
39
- * @param {string} extensionsDir - path to extensions/ directory
40
- * @param {string[]} [enabledPacks] - list of enabled pack names (null = all)
41
- * @returns {Promise<string[]>} array of PACK.md file paths
42
- */
43
- async function discoverPacks(extensionsDir, enabledPacks = null) {
44
- if (!existsSync(extensionsDir)) return [];
45
-
46
- const entries = await readdir(extensionsDir, { withFileTypes: true });
47
- const paths = [];
48
-
49
- for (const entry of entries) {
50
- if (!entry.isDirectory()) continue;
51
- if (enabledPacks && !enabledPacks.includes(entry.name) && !enabledPacks.includes(`@rune/${entry.name}`)) {
52
- continue;
53
- }
54
- const packFile = path.join(extensionsDir, entry.name, 'PACK.md');
55
- if (existsSync(packFile)) {
56
- paths.push(packFile);
57
- }
58
- }
59
-
60
- return paths.sort();
61
- }
62
-
63
- /**
64
- * Copy scripts directory from skill source to output.
65
- *
66
- * @param {string} sourceScriptsDir - e.g. skills/slides/scripts/
67
- * @param {string} outputScriptsDir - e.g. .cursor/rules/rune-slides-scripts/
68
- * @returns {Promise<string[]>} list of copied file paths
69
- */
70
- async function copyScriptsDir(sourceScriptsDir, outputScriptsDir) {
71
- if (!existsSync(sourceScriptsDir)) return [];
72
-
73
- const entries = await readdir(sourceScriptsDir, { recursive: true, withFileTypes: true });
74
- const files = entries.filter((e) => e.isFile());
75
- if (entries.length === 0) return [];
76
-
77
- await cp(sourceScriptsDir, outputScriptsDir, { recursive: true });
78
-
79
- // Return relative paths within the scripts dir (same structure as source after recursive cp)
80
- return files.map((e) => {
81
- const parent = e.parentPath || e.path;
82
- return path.relative(sourceScriptsDir, path.join(parent, e.name));
83
- });
84
- }
85
-
86
- /**
87
- * Tier priority: higher number = higher priority (wins override)
88
- */
89
- const TIER_PRIORITY = { free: 0, pro: 1, business: 2 };
90
-
91
- /**
92
- * Normalize pack name for tier comparison.
93
- * Strips tier prefixes (pro-, business-) so packs can be compared across tiers.
94
- * e.g. "pro-product" → "product", "saas" → "saas"
95
- */
96
- function normalizePackName(dirName) {
97
- return dirName.replace(/^(pro|business)-/, '');
98
- }
99
-
100
- /**
101
- * Discover packs across multiple tier sources and resolve overrides.
102
- * Business > Pro > Free: if the same normalized pack name exists in multiple tiers,
103
- * the highest-priority tier wins.
104
- *
105
- * @param {string} freeExtDir - path to free extensions/ directory
106
- * @param {Object<string, string>} [tierSources] - { pro: "/path/to/pro/extensions", business: "/path/to/business/extensions" }
107
- * @param {string[]} [enabledPacks] - list of enabled pack names (null = all)
108
- * @returns {Promise<Array<{path: string, tier: string, dirName: string}>>} resolved pack entries
109
- */
110
- export async function discoverTieredPacks(freeExtDir, tierSources = {}, enabledPacks = null) {
111
- // Collect all packs with their tier info: Map<normalizedName, {path, tier, priority, dirName}>
112
- const packMap = new Map();
113
-
114
- // Helper: scan one extensions directory for packs
115
- async function scanDir(extDir, tier) {
116
- if (!existsSync(extDir)) return;
117
- const entries = await readdir(extDir, { withFileTypes: true });
118
-
119
- for (const entry of entries) {
120
- if (!entry.isDirectory()) continue;
121
- if (enabledPacks && !enabledPacks.includes(entry.name) && !enabledPacks.includes(`@rune/${entry.name}`)) {
122
- continue;
123
- }
124
- const packFile = path.join(extDir, entry.name, 'PACK.md');
125
- if (!existsSync(packFile)) continue;
126
-
127
- const normalized = normalizePackName(entry.name);
128
- const priority = TIER_PRIORITY[tier] ?? 0;
129
- const existing = packMap.get(normalized);
130
-
131
- // Higher priority tier wins — track overridden lower-tier entries for skill-level merging
132
- if (!existing || priority > existing.priority) {
133
- const overrides = existing
134
- ? [...(existing.overrides || []), { path: existing.path, tier: existing.tier, dirName: existing.dirName }]
135
- : [];
136
- packMap.set(normalized, { path: packFile, tier, priority, dirName: entry.name, overrides });
137
- }
138
- }
139
- }
140
-
141
- // Scan free first (lowest priority), then pro, then business
142
- await scanDir(freeExtDir, 'free');
143
- if (tierSources.pro) {
144
- await scanDir(tierSources.pro, 'pro');
145
- }
146
- if (tierSources.business) {
147
- await scanDir(tierSources.business, 'business');
148
- }
149
-
150
- // Return sorted by dirName for deterministic output
151
- return [...packMap.values()].sort((a, b) => a.dirName.localeCompare(b.dirName));
152
- }
153
-
154
- /**
155
- * Generate output filename for a skill
156
- */
157
- function outputFileName(skillName, adapter) {
158
- return `${adapter.skillPrefix}${skillName}${adapter.skillSuffix}${adapter.fileExtension}`;
159
- }
160
-
161
- /**
162
- * Build all skills for a target platform
163
- *
164
- * @param {object} options
165
- * @param {string} options.runeRoot - root of the Rune repo
166
- * @param {string} options.outputRoot - where to write output (project root or dist/)
167
- * @param {object} options.adapter - platform adapter
168
- * @param {string[]} [options.disabledSkills] - skills to skip
169
- * @param {string[]} [options.enabledPacks] - extension packs to include (null = all)
170
- * @param {Object<string, string>} [options.tierSources] - tier extension dirs { pro: "path", business: "path" }
171
- * @returns {Promise<object>} build result stats
172
- */
173
- export async function buildAll({
174
- runeRoot,
175
- outputRoot,
176
- adapter,
177
- disabledSkills = [],
178
- enabledPacks = null,
179
- tierSources = {},
180
- }) {
181
- // Claude Code = passthrough, no build needed
182
- if (adapter.name === 'claude') {
183
- return {
184
- platform: 'claude',
185
- message: 'Claude Code uses source SKILL.md files directly. No compilation needed.',
186
- skillCount: 0,
187
- packCount: 0,
188
- files: [],
189
- };
190
- }
191
-
192
- const skillsDir = path.join(runeRoot, 'skills');
193
- const extensionsDir = path.join(runeRoot, 'extensions');
194
- const outputDir = path.join(outputRoot, adapter.outputDir);
195
-
196
- // Ensure output directory exists
197
- await mkdir(outputDir, { recursive: true });
198
-
199
- const skillPaths = await discoverSkills(skillsDir);
200
-
201
- // Tier-aware pack discovery: if tierSources provided, resolve overrides
202
- const hasTiers = tierSources && (tierSources.pro || tierSources.business);
203
- const packEntries = hasTiers
204
- ? await discoverTieredPacks(extensionsDir, tierSources, enabledPacks)
205
- : (await discoverPacks(extensionsDir, enabledPacks)).map((p) => ({
206
- path: p,
207
- tier: 'free',
208
- dirName: path.basename(path.dirname(p)),
209
- }));
210
-
211
- const stats = {
212
- platform: adapter.name,
213
- skillCount: 0,
214
- packCount: 0,
215
- crossRefsResolved: 0,
216
- toolRefsResolved: 0,
217
- scriptsCopied: 0,
218
- files: [],
219
- skipped: [],
220
- errors: [],
221
- tierOverrides: [],
222
- };
223
-
224
- // Build skills
225
- for (const skillPath of skillPaths) {
226
- try {
227
- const content = await readFile(skillPath, 'utf-8');
228
- const parsed = parseSkill(content, skillPath);
229
-
230
- // Check disabled
231
- if (disabledSkills.includes(parsed.name)) {
232
- stats.skipped.push(parsed.name);
233
- continue;
234
- }
235
-
236
- const { header, body: rawBody, footer } = transformSkill(parsed, adapter);
237
-
238
- // Resolve {scripts_dir} placeholder if adapter supports scripts
239
- const skillSourceDir = path.dirname(skillPath);
240
- const scriptsSource = path.join(skillSourceDir, 'scripts');
241
- const hasScripts = existsSync(scriptsSource) && adapter.scriptsDir;
242
- const scriptsRelPath = hasScripts
243
- ? path.join(adapter.outputDir, adapter.scriptsDir(parsed.name)).replaceAll('\\', '/')
244
- : null;
245
- const body = hasScripts ? resolveScriptsPath(rawBody, scriptsRelPath) : rawBody;
246
-
247
- // Warn if {scripts_dir} placeholder exists but no scripts/ folder to resolve it
248
- if (!hasScripts && rawBody.includes('{scripts_dir}')) {
249
- stats.errors.push({
250
- file: skillPath,
251
- error: `{scripts_dir} placeholder found but no scripts/ directory exists for skill "${parsed.name}"`,
252
- });
253
- }
254
-
255
- const output = [header, body, footer].filter(Boolean).join('\n');
256
-
257
- let outputPath;
258
- let displayName;
259
-
260
- if (adapter.useSkillDirectories) {
261
- // Directory-per-skill: .codex/skills/rune-{name}/SKILL.md
262
- const dirName = `${adapter.skillPrefix}${parsed.name}`;
263
- const skillDir = path.join(outputDir, dirName);
264
- await mkdir(skillDir, { recursive: true });
265
- outputPath = path.join(skillDir, adapter.skillFileName || 'SKILL.md');
266
- displayName = `${dirName}/${adapter.skillFileName || 'SKILL.md'}`;
267
- } else {
268
- const fileName = outputFileName(parsed.name, adapter);
269
- outputPath = path.join(outputDir, fileName);
270
- displayName = fileName;
271
- }
272
-
273
- await writeFile(outputPath, output, 'utf-8');
274
-
275
- // Copy scripts/ directory if present
276
- if (hasScripts) {
277
- const scriptsOutput = path.join(outputDir, adapter.scriptsDir(parsed.name));
278
- const copied = await copyScriptsDir(scriptsSource, scriptsOutput);
279
- stats.scriptsCopied += copied.length;
280
- }
281
-
282
- stats.skillCount++;
283
- stats.crossRefsResolved += parsed.crossRefs.length;
284
- stats.toolRefsResolved += parsed.toolRefs.length;
285
- stats.files.push(displayName);
286
- } catch (err) {
287
- stats.errors.push({ file: skillPath, error: err.message });
288
- }
289
- }
290
-
291
- // Build extension packs (tier-aware)
292
- for (const packEntry of packEntries) {
293
- try {
294
- const packPath = packEntry.path;
295
- const content = await readFile(packPath, 'utf-8');
296
- const parsed = parsePack(content, packPath);
297
- const packName = packEntry.dirName;
298
- const packDir = path.dirname(packPath);
299
-
300
- // Track tier overrides for reporting
301
- if (packEntry.tier !== 'free') {
302
- stats.tierOverrides.push({ pack: packName, tier: packEntry.tier });
303
- }
304
-
305
- // Tier Override: merge skill manifests from lower tiers
306
- // If a Pro/Business pack overrides a Free pack, inherit skills the higher tier doesn't provide
307
- if (packEntry.overrides?.length > 0 && parsed.isSplit && parsed.skillManifest.length > 0) {
308
- const winnerSkillNames = new Set(parsed.skillManifest.map((s) => s.name));
309
- for (const lower of packEntry.overrides) {
310
- try {
311
- const lowerContent = await readFile(lower.path, 'utf-8');
312
- const lowerParsed = parsePack(lowerContent, lower.path);
313
- if (lowerParsed.isSplit) {
314
- const lowerPackDir = path.dirname(lower.path);
315
- for (const lowerSkill of lowerParsed.skillManifest) {
316
- if (!winnerSkillNames.has(lowerSkill.name)) {
317
- // Inherit skill from lower tier — track source directory for file resolution
318
- parsed.skillManifest.push({ ...lowerSkill, _sourceDir: lowerPackDir });
319
- winnerSkillNames.add(lowerSkill.name);
320
- stats.tierOverrides.push({ pack: packName, skill: lowerSkill.name, inherited: lower.tier });
321
- }
322
- }
323
- }
324
- } catch {
325
- // Lower-tier pack unreadable — skip gracefully
326
- }
327
- }
328
- }
329
-
330
- // For split packs, load individual skill files and concatenate into body
331
- if (parsed.isSplit && parsed.skillManifest.length > 0) {
332
- const skillBodies = [];
333
- for (const skill of parsed.skillManifest) {
334
- // Resolve skill file path — use _sourceDir for inherited lower-tier skills
335
- const sourceDir = skill._sourceDir || packDir;
336
- const skillPath = path.join(sourceDir, skill.file);
337
- if (existsSync(skillPath)) {
338
- const skillContent = await readFile(skillPath, 'utf-8');
339
- // Strip frontmatter from skill file — we only need the body
340
- const skillBodyMatch = skillContent.replace(/\r\n/g, '\n').match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
341
- const skillBody = skillBodyMatch ? skillBodyMatch[1].trim() : skillContent.trim();
342
- skillBodies.push(skillBody);
343
- } else {
344
- stats.errors.push({ file: skillPath, error: `Skill file not found (listed in ${packPath} manifest)` });
345
- }
346
- }
347
- // Concatenate: index body + all skill bodies
348
- parsed.body = `${parsed.body}\n\n${skillBodies.join('\n\n---\n\n')}`;
349
- // Re-extract refs from the full concatenated body
350
- parsed.crossRefs = extractCrossRefs(parsed.body);
351
- parsed.toolRefs = extractToolRefs(parsed.body);
352
- }
353
-
354
- // Normalize pack name for headers (ext-trading instead of @rune/trading)
355
- parsed.name = `ext-${packName}`;
356
-
357
- const { header, body, footer } = transformSkill(parsed, adapter);
358
- const output = [header, body, footer].filter(Boolean).join('\n');
359
-
360
- let outputPath;
361
- let displayName;
362
-
363
- if (adapter.useSkillDirectories) {
364
- const dirName = `${adapter.skillPrefix}ext-${packName}`;
365
- const outPackDir = path.join(outputDir, dirName);
366
- await mkdir(outPackDir, { recursive: true });
367
- outputPath = path.join(outPackDir, adapter.skillFileName || 'SKILL.md');
368
- displayName = `${dirName}/${adapter.skillFileName || 'SKILL.md'}`;
369
- } else {
370
- const fileName = outputFileName(`ext-${packName}`, adapter);
371
- outputPath = path.join(outputDir, fileName);
372
- displayName = fileName;
373
- }
374
-
375
- await writeFile(outputPath, output, 'utf-8');
376
-
377
- stats.packCount++;
378
- stats.files.push(displayName);
379
- } catch (err) {
380
- stats.errors.push({ file: packPath, error: err.message });
381
- }
382
- }
383
-
384
- // Generate index file
385
- const indexContent = generateIndex(stats, adapter);
386
- const indexFileName = outputFileName('index', adapter);
387
- await writeFile(path.join(outputDir, indexFileName), indexContent, 'utf-8');
388
- stats.files.push(indexFileName);
389
-
390
- // OpenClaw adapter: generate manifest + TypeScript entry point
391
- if (adapter.name === 'openclaw' && adapter.generateManifest && adapter.generateEntryPoint) {
392
- const pluginJsonPath = path.join(runeRoot, '.claude-plugin', 'plugin.json');
393
- let pluginJson = { version: '0.0.0' };
394
- if (existsSync(pluginJsonPath)) {
395
- pluginJson = JSON.parse(await readFile(pluginJsonPath, 'utf-8'));
396
- }
397
-
398
- // Collect parsed skills for manifest/entry generation
399
- const parsedSkills = [];
400
- for (const sp of skillPaths) {
401
- try {
402
- const c = await readFile(sp, 'utf-8');
403
- parsedSkills.push(parseSkill(c, sp));
404
- } catch {
405
- /* skip on error */
406
- }
407
- }
408
-
409
- // Read skill-router content for system prompt injection
410
- const routerPath = path.join(runeRoot, 'skills', 'skill-router', 'SKILL.md');
411
- let routerContent = '';
412
- if (existsSync(routerPath)) {
413
- routerContent = await readFile(routerPath, 'utf-8');
414
- }
415
-
416
- // Write openclaw.plugin.json to parent of skills dir (.openclaw/rune/)
417
- const openclawRoot = path.resolve(outputDir, '..');
418
- const manifest = adapter.generateManifest(parsedSkills, pluginJson);
419
- await writeFile(path.join(openclawRoot, 'openclaw.plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
420
- stats.files.push('openclaw.plugin.json');
421
-
422
- // Write src/index.ts entry point
423
- const srcDir = path.join(openclawRoot, 'src');
424
- await mkdir(srcDir, { recursive: true });
425
- const entryPoint = adapter.generateEntryPoint(parsedSkills, routerContent);
426
- await writeFile(path.join(srcDir, 'index.ts'), entryPoint, 'utf-8');
427
- stats.files.push('src/index.ts');
428
-
429
- // Write README.md + SKILL.md for ClawHub listing page
430
- if (adapter.generateReadme) {
431
- const readme = adapter.generateReadme(parsedSkills, pluginJson);
432
- await writeFile(path.join(openclawRoot, 'README.md'), readme, 'utf-8');
433
- stats.files.push('README.md');
434
- // SKILL.md required by clawhub publish
435
- await writeFile(path.join(openclawRoot, 'SKILL.md'), readme, 'utf-8');
436
- stats.files.push('SKILL.md');
437
- }
438
- }
439
-
440
- return stats;
441
- }
442
-
443
- /**
444
- * Generate an index file listing all compiled skills
445
- */
446
- function generateIndex(stats, adapter) {
447
- const lines = [
448
- '# Rune Skill Index',
449
- '',
450
- `> Platform: ${adapter.name} | Skills: ${stats.skillCount} | Extensions: ${stats.packCount}`,
451
- '',
452
- '## Core Skills',
453
- '',
454
- ...stats.files.filter((f) => !f.match(/[-/]ext-/) && !f.includes('index')).map((f) => `- ${f}`),
455
- '',
456
- ];
457
-
458
- const extFiles = stats.files.filter((f) => f.match(/[-/]ext-/));
459
- if (extFiles.length > 0) {
460
- lines.push('## Extension Packs', '', ...extFiles.map((f) => `- ${f}`), '');
461
- }
462
-
463
- lines.push('---', '> Rune Skill Mesh — https://github.com/rune-kit/rune');
464
-
465
- return lines.join('\n');
466
- }
1
+ /**
2
+ * Emitter
3
+ *
4
+ * Writes transformed skill files to the platform's output directory.
5
+ * Handles file naming, directory creation, index generation, and AGENTS.md creation.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { extractCrossRefs, extractToolRefs, parsePack, parseSkill } from './parser.js';
12
+ import { transformSkill } from './transformer.js';
13
+ import { resolveScriptsPath } from './transforms/scripts-path.js';
14
+
15
+ /**
16
+ * Discover all SKILL.md files in the skills directory
17
+ *
18
+ * @param {string} skillsDir - path to skills/ directory
19
+ * @returns {Promise<string[]>} array of SKILL.md file paths
20
+ */
21
+ async function discoverSkills(skillsDir) {
22
+ const entries = await readdir(skillsDir, { withFileTypes: true });
23
+ const paths = [];
24
+
25
+ for (const entry of entries) {
26
+ if (!entry.isDirectory()) continue;
27
+ const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
28
+ if (existsSync(skillFile)) {
29
+ paths.push(skillFile);
30
+ }
31
+ }
32
+
33
+ return paths.sort();
34
+ }
35
+
36
+ /**
37
+ * Discover all PACK.md files in the extensions directory
38
+ *
39
+ * @param {string} extensionsDir - path to extensions/ directory
40
+ * @param {string[]} [enabledPacks] - list of enabled pack names (null = all)
41
+ * @returns {Promise<string[]>} array of PACK.md file paths
42
+ */
43
+ async function discoverPacks(extensionsDir, enabledPacks = null) {
44
+ if (!existsSync(extensionsDir)) return [];
45
+
46
+ const entries = await readdir(extensionsDir, { withFileTypes: true });
47
+ const paths = [];
48
+
49
+ for (const entry of entries) {
50
+ if (!entry.isDirectory()) continue;
51
+ if (enabledPacks && !enabledPacks.includes(entry.name) && !enabledPacks.includes(`@rune/${entry.name}`)) {
52
+ continue;
53
+ }
54
+ const packFile = path.join(extensionsDir, entry.name, 'PACK.md');
55
+ if (existsSync(packFile)) {
56
+ paths.push(packFile);
57
+ }
58
+ }
59
+
60
+ return paths.sort();
61
+ }
62
+
63
+ /**
64
+ * Copy extra directories from skill source to output
65
+ * Copies directories except SKILL.md (already processed) and denylisted dirs
66
+ *
67
+ * @param {string} sourceSkillDir - e.g. skills/cook/
68
+ * @param {string} outputSkillDir - e.g. .codex/skills/rune-cook/
69
+ * @returns {Promise<string[]>} list of copied directory names
70
+ */
71
+ const COPY_DENYLIST = new Set(['.git', 'node_modules', '__pycache__', '.DS_Store', '.venv', '.env']);
72
+
73
+ async function copySkillExtraDirs(sourceSkillDir, outputSkillDir) {
74
+ if (!existsSync(sourceSkillDir)) return [];
75
+
76
+ const entries = await readdir(sourceSkillDir, { withFileTypes: true });
77
+ const dirs = entries.filter((e) => e.isDirectory() && !COPY_DENYLIST.has(e.name));
78
+
79
+ const copied = [];
80
+ for (const dir of dirs) {
81
+ const sourcePath = path.join(sourceSkillDir, dir.name);
82
+ const outputPath = path.join(outputSkillDir, dir.name);
83
+ await cp(sourcePath, outputPath, { recursive: true });
84
+ copied.push(dir.name);
85
+ }
86
+
87
+ return copied;
88
+ }
89
+
90
+ /**
91
+ * Copy scripts directory from skill source to output.
92
+ *
93
+ * @param {string} sourceScriptsDir - e.g. skills/slides/scripts/
94
+ * @param {string} outputScriptsDir - e.g. .cursor/rules/rune-slides-scripts/
95
+ * @returns {Promise<string[]>} list of copied file paths
96
+ */
97
+ async function copyScriptsDir(sourceScriptsDir, outputScriptsDir) {
98
+ if (!existsSync(sourceScriptsDir)) return [];
99
+
100
+ const entries = await readdir(sourceScriptsDir, { recursive: true, withFileTypes: true });
101
+ const files = entries.filter((e) => e.isFile());
102
+ if (entries.length === 0) return [];
103
+
104
+ await cp(sourceScriptsDir, outputScriptsDir, { recursive: true });
105
+
106
+ // Return relative paths within the scripts dir (same structure as source after recursive cp)
107
+ return files.map((e) => {
108
+ const parent = e.parentPath || e.path;
109
+ return path.relative(sourceScriptsDir, path.join(parent, e.name));
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Tier priority: higher number = higher priority (wins override)
115
+ */
116
+ const TIER_PRIORITY = { free: 0, pro: 1, business: 2 };
117
+
118
+ /**
119
+ * Normalize pack name for tier comparison.
120
+ * Strips tier prefixes (pro-, business-) so packs can be compared across tiers.
121
+ * e.g. "pro-product" "product", "saas" → "saas"
122
+ */
123
+ function normalizePackName(dirName) {
124
+ return dirName.replace(/^(pro|business)-/, '');
125
+ }
126
+
127
+ /**
128
+ * Discover packs across multiple tier sources and resolve overrides.
129
+ * Business > Pro > Free: if the same normalized pack name exists in multiple tiers,
130
+ * the highest-priority tier wins.
131
+ *
132
+ * @param {string} freeExtDir - path to free extensions/ directory
133
+ * @param {Object<string, string>} [tierSources] - { pro: "/path/to/pro/extensions", business: "/path/to/business/extensions" }
134
+ * @param {string[]} [enabledPacks] - list of enabled pack names (null = all)
135
+ * @returns {Promise<Array<{path: string, tier: string, dirName: string}>>} resolved pack entries
136
+ */
137
+ export async function discoverTieredPacks(freeExtDir, tierSources = {}, enabledPacks = null) {
138
+ // Collect all packs with their tier info: Map<normalizedName, {path, tier, priority, dirName}>
139
+ const packMap = new Map();
140
+
141
+ // Helper: scan one extensions directory for packs
142
+ async function scanDir(extDir, tier) {
143
+ if (!existsSync(extDir)) return;
144
+ const entries = await readdir(extDir, { withFileTypes: true });
145
+
146
+ for (const entry of entries) {
147
+ if (!entry.isDirectory()) continue;
148
+ if (enabledPacks && !enabledPacks.includes(entry.name) && !enabledPacks.includes(`@rune/${entry.name}`)) {
149
+ continue;
150
+ }
151
+ const packFile = path.join(extDir, entry.name, 'PACK.md');
152
+ if (!existsSync(packFile)) continue;
153
+
154
+ const normalized = normalizePackName(entry.name);
155
+ const priority = TIER_PRIORITY[tier] ?? 0;
156
+ const existing = packMap.get(normalized);
157
+
158
+ // Higher priority tier wins — track overridden lower-tier entries for skill-level merging
159
+ if (!existing || priority > existing.priority) {
160
+ const overrides = existing
161
+ ? [...(existing.overrides || []), { path: existing.path, tier: existing.tier, dirName: existing.dirName }]
162
+ : [];
163
+ packMap.set(normalized, { path: packFile, tier, priority, dirName: entry.name, overrides });
164
+ }
165
+ }
166
+ }
167
+
168
+ // Scan free first (lowest priority), then pro, then business
169
+ await scanDir(freeExtDir, 'free');
170
+ if (tierSources.pro) {
171
+ await scanDir(tierSources.pro, 'pro');
172
+ }
173
+ if (tierSources.business) {
174
+ await scanDir(tierSources.business, 'business');
175
+ }
176
+
177
+ // Return sorted by dirName for deterministic output
178
+ return [...packMap.values()].sort((a, b) => a.dirName.localeCompare(b.dirName));
179
+ }
180
+
181
+ /**
182
+ * Generate output filename for a skill
183
+ */
184
+ function outputFileName(skillName, adapter) {
185
+ return `${adapter.skillPrefix}${skillName}${adapter.skillSuffix}${adapter.fileExtension}`;
186
+ }
187
+
188
+ /**
189
+ * Build all skills for a target platform
190
+ *
191
+ * @param {object} options
192
+ * @param {string} options.runeRoot - root of the Rune repo
193
+ * @param {string} options.outputRoot - where to write output (project root or dist/)
194
+ * @param {object} options.adapter - platform adapter
195
+ * @param {string[]} [options.disabledSkills] - skills to skip
196
+ * @param {string[]} [options.enabledPacks] - extension packs to include (null = all)
197
+ * @param {Object<string, string>} [options.tierSources] - tier extension dirs { pro: "path", business: "path" }
198
+ * @returns {Promise<object>} build result stats
199
+ */
200
+ export async function buildAll({
201
+ runeRoot,
202
+ outputRoot,
203
+ adapter,
204
+ disabledSkills = [],
205
+ enabledPacks = null,
206
+ tierSources = {},
207
+ }) {
208
+ // Claude Code = passthrough, no build needed
209
+ if (adapter.name === 'claude') {
210
+ return {
211
+ platform: 'claude',
212
+ message: 'Claude Code uses source SKILL.md files directly. No compilation needed.',
213
+ skillCount: 0,
214
+ packCount: 0,
215
+ files: [],
216
+ };
217
+ }
218
+
219
+ const skillsDir = path.join(runeRoot, 'skills');
220
+ const extensionsDir = path.join(runeRoot, 'extensions');
221
+ const outputDir = path.join(outputRoot, adapter.outputDir);
222
+
223
+ // Ensure output directory exists
224
+ await mkdir(outputDir, { recursive: true });
225
+
226
+ const skillPaths = await discoverSkills(skillsDir);
227
+
228
+ // Tier-aware pack discovery: if tierSources provided, resolve overrides
229
+ const hasTiers = tierSources && (tierSources.pro || tierSources.business);
230
+ const packEntries = hasTiers
231
+ ? await discoverTieredPacks(extensionsDir, tierSources, enabledPacks)
232
+ : (await discoverPacks(extensionsDir, enabledPacks)).map((p) => ({
233
+ path: p,
234
+ tier: 'free',
235
+ dirName: path.basename(path.dirname(p)),
236
+ }));
237
+
238
+ const stats = {
239
+ platform: adapter.name,
240
+ skillCount: 0,
241
+ packCount: 0,
242
+ crossRefsResolved: 0,
243
+ toolRefsResolved: 0,
244
+ scriptsCopied: 0,
245
+ files: [],
246
+ skipped: [],
247
+ errors: [],
248
+ tierOverrides: [],
249
+ };
250
+
251
+ // Build skills collect parsed data for skill-index + openclaw reuse
252
+ const parsedSkills = [];
253
+
254
+ for (const skillPath of skillPaths) {
255
+ try {
256
+ const content = await readFile(skillPath, 'utf-8');
257
+ const parsed = parseSkill(content, skillPath);
258
+
259
+ // Check disabled
260
+ if (disabledSkills.includes(parsed.name)) {
261
+ stats.skipped.push(parsed.name);
262
+ continue;
263
+ }
264
+
265
+ const { header, body: rawBody, footer } = transformSkill(parsed, adapter);
266
+
267
+ // Resolve {scripts_dir} placeholder if adapter supports scripts
268
+ const skillSourceDir = path.dirname(skillPath);
269
+ const scriptsSource = path.join(skillSourceDir, 'scripts');
270
+ const hasScripts = existsSync(scriptsSource) && adapter.scriptsDir;
271
+ const scriptsRelPath = hasScripts
272
+ ? path.join(adapter.outputDir, adapter.scriptsDir(parsed.name)).replaceAll('\\', '/')
273
+ : null;
274
+ const body = hasScripts ? resolveScriptsPath(rawBody, scriptsRelPath) : rawBody;
275
+
276
+ // Warn if {scripts_dir} placeholder exists but no scripts/ folder to resolve it
277
+ if (!hasScripts && rawBody.includes('{scripts_dir}')) {
278
+ stats.errors.push({
279
+ file: skillPath,
280
+ error: `{scripts_dir} placeholder found but no scripts/ directory exists for skill "${parsed.name}"`,
281
+ });
282
+ }
283
+
284
+ const output = [header, body, footer].filter(Boolean).join('\n');
285
+
286
+ let outputPath;
287
+ let displayName;
288
+ let skillDir = null;
289
+
290
+ if (adapter.useSkillDirectories) {
291
+ // Directory-per-skill: .codex/skills/rune-{name}/SKILL.md
292
+ const dirName = `${adapter.skillPrefix}${parsed.name}`;
293
+ skillDir = path.join(outputDir, dirName);
294
+ await mkdir(skillDir, { recursive: true });
295
+ outputPath = path.join(skillDir, adapter.skillFileName || 'SKILL.md');
296
+ displayName = `${dirName}/${adapter.skillFileName || 'SKILL.md'}`;
297
+ } else {
298
+ const fileName = outputFileName(parsed.name, adapter);
299
+ outputPath = path.join(outputDir, fileName);
300
+ displayName = fileName;
301
+ }
302
+
303
+ await writeFile(outputPath, output, 'utf-8');
304
+
305
+ // Copy extra directories (references/, etc.) from skill source
306
+ if (adapter.useSkillDirectories && skillDir) {
307
+ await copySkillExtraDirs(skillSourceDir, skillDir);
308
+ }
309
+
310
+ // Copy scripts/ directory if present
311
+ if (hasScripts) {
312
+ const scriptsOutput = path.join(outputDir, adapter.scriptsDir(parsed.name));
313
+ const copied = await copyScriptsDir(scriptsSource, scriptsOutput);
314
+ stats.scriptsCopied += copied.length;
315
+ }
316
+
317
+ parsedSkills.push(parsed);
318
+ stats.skillCount++;
319
+ stats.crossRefsResolved += parsed.crossRefs.length;
320
+ stats.toolRefsResolved += parsed.toolRefs.length;
321
+ stats.files.push(displayName);
322
+ } catch (err) {
323
+ stats.errors.push({ file: skillPath, error: err.message });
324
+ }
325
+ }
326
+
327
+ // Build extension packs (tier-aware)
328
+ for (const packEntry of packEntries) {
329
+ try {
330
+ const packPath = packEntry.path;
331
+ const content = await readFile(packPath, 'utf-8');
332
+ const parsed = parsePack(content, packPath);
333
+ const packName = packEntry.dirName;
334
+ const packDir = path.dirname(packPath);
335
+
336
+ // Track tier overrides for reporting
337
+ if (packEntry.tier !== 'free') {
338
+ stats.tierOverrides.push({ pack: packName, tier: packEntry.tier });
339
+ }
340
+
341
+ // Tier Override: merge skill manifests from lower tiers
342
+ // If a Pro/Business pack overrides a Free pack, inherit skills the higher tier doesn't provide
343
+ if (packEntry.overrides?.length > 0 && parsed.isSplit && parsed.skillManifest.length > 0) {
344
+ const winnerSkillNames = new Set(parsed.skillManifest.map((s) => s.name));
345
+ for (const lower of packEntry.overrides) {
346
+ try {
347
+ const lowerContent = await readFile(lower.path, 'utf-8');
348
+ const lowerParsed = parsePack(lowerContent, lower.path);
349
+ if (lowerParsed.isSplit) {
350
+ const lowerPackDir = path.dirname(lower.path);
351
+ for (const lowerSkill of lowerParsed.skillManifest) {
352
+ if (!winnerSkillNames.has(lowerSkill.name)) {
353
+ // Inherit skill from lower tier — track source directory for file resolution
354
+ parsed.skillManifest.push({ ...lowerSkill, _sourceDir: lowerPackDir });
355
+ winnerSkillNames.add(lowerSkill.name);
356
+ stats.tierOverrides.push({ pack: packName, skill: lowerSkill.name, inherited: lower.tier });
357
+ }
358
+ }
359
+ }
360
+ } catch {
361
+ // Lower-tier pack unreadable — skip gracefully
362
+ }
363
+ }
364
+ }
365
+
366
+ // For split packs: auto-discover skill files from skills/ subdir when manifest is empty
367
+ if (parsed.isSplit && parsed.skillManifest.length === 0) {
368
+ const skillsSubdir = path.join(packDir, 'skills');
369
+ if (existsSync(skillsSubdir)) {
370
+ const skillFiles = (await readdir(skillsSubdir)).filter((f) => f.endsWith('.md')).sort();
371
+ for (const sf of skillFiles) {
372
+ parsed.skillManifest.push({ name: sf.replace(/\.md$/, ''), file: `skills/${sf}` });
373
+ }
374
+ }
375
+ }
376
+
377
+ // For split packs, load individual skill files and concatenate into body
378
+ if (parsed.isSplit && parsed.skillManifest.length > 0) {
379
+ const skillBodies = [];
380
+ for (const skill of parsed.skillManifest) {
381
+ // Resolve skill file path — use _sourceDir for inherited lower-tier skills
382
+ const sourceDir = skill._sourceDir || packDir;
383
+ const skillPath = path.join(sourceDir, skill.file);
384
+ if (existsSync(skillPath)) {
385
+ const skillContent = await readFile(skillPath, 'utf-8');
386
+ // Strip frontmatter from skill file — we only need the body
387
+ const skillBodyMatch = skillContent.replace(/\r\n/g, '\n').match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
388
+ const skillBody = skillBodyMatch ? skillBodyMatch[1].trim() : skillContent.trim();
389
+ skillBodies.push(skillBody);
390
+ } else {
391
+ stats.errors.push({ file: skillPath, error: `Skill file not found (listed in ${packPath} manifest)` });
392
+ }
393
+ }
394
+ // Concatenate: index body + all skill bodies
395
+ parsed.body = `${parsed.body}\n\n${skillBodies.join('\n\n---\n\n')}`;
396
+ // Re-extract refs from the full concatenated body
397
+ parsed.crossRefs = extractCrossRefs(parsed.body);
398
+ parsed.toolRefs = extractToolRefs(parsed.body);
399
+ }
400
+
401
+ // Normalize pack name for headers (ext-trading instead of @rune/trading)
402
+ parsed.name = `ext-${packName}`;
403
+
404
+ const { header, body, footer } = transformSkill(parsed, adapter);
405
+ const output = [header, body, footer].filter(Boolean).join('\n');
406
+
407
+ let outputPath;
408
+ let displayName;
409
+
410
+ if (adapter.useSkillDirectories) {
411
+ const dirName = `${adapter.skillPrefix}ext-${packName}`;
412
+ const outPackDir = path.join(outputDir, dirName);
413
+ await mkdir(outPackDir, { recursive: true });
414
+ outputPath = path.join(outPackDir, adapter.skillFileName || 'SKILL.md');
415
+ displayName = `${dirName}/${adapter.skillFileName || 'SKILL.md'}`;
416
+ } else {
417
+ const fileName = outputFileName(`ext-${packName}`, adapter);
418
+ outputPath = path.join(outputDir, fileName);
419
+ displayName = fileName;
420
+ }
421
+
422
+ await writeFile(outputPath, output, 'utf-8');
423
+
424
+ stats.packCount++;
425
+ stats.files.push(displayName);
426
+ } catch (err) {
427
+ stats.errors.push({ file: packPath, error: err.message });
428
+ }
429
+ }
430
+
431
+ // Generate index file
432
+ const indexContent = generateIndex(stats, adapter);
433
+ const indexFileName = outputFileName('index', adapter);
434
+ await writeFile(path.join(outputDir, indexFileName), indexContent, 'utf-8');
435
+ stats.files.push(indexFileName);
436
+
437
+ // Generate skill-index.json — compiled intent mesh for auto-trigger hooks
438
+ const skillIndex = generateSkillIndex(parsedSkills);
439
+ await writeFile(path.join(outputDir, 'skill-index.json'), `${JSON.stringify(skillIndex, null, 2)}\n`, 'utf-8');
440
+ stats.files.push('skill-index.json');
441
+
442
+ // Generate AGENTS.md for Codex (OpenAI convention — not used by other platforms)
443
+ if (adapter.name === 'codex') {
444
+ const agentsMdContent = generateAgentsMd(stats, adapter);
445
+ await writeFile(path.join(outputRoot, 'AGENTS.md'), agentsMdContent, 'utf-8');
446
+ stats.files.push('AGENTS.md');
447
+ }
448
+
449
+ // OpenClaw adapter: generate manifest + TypeScript entry point
450
+ if (adapter.name === 'openclaw' && adapter.generateManifest && adapter.generateEntryPoint) {
451
+ const pluginJsonPath = path.join(runeRoot, '.claude-plugin', 'plugin.json');
452
+ let pluginJson = { version: '0.0.0' };
453
+ if (existsSync(pluginJsonPath)) {
454
+ pluginJson = JSON.parse(await readFile(pluginJsonPath, 'utf-8'));
455
+ }
456
+
457
+ // Read skill-router content for system prompt injection
458
+ const routerPath = path.join(runeRoot, 'skills', 'skill-router', 'SKILL.md');
459
+ let routerContent = '';
460
+ if (existsSync(routerPath)) {
461
+ routerContent = await readFile(routerPath, 'utf-8');
462
+ }
463
+
464
+ // Write openclaw.plugin.json to parent of skills dir (.openclaw/rune/)
465
+ const openclawRoot = path.resolve(outputDir, '..');
466
+ const manifest = adapter.generateManifest(parsedSkills, pluginJson);
467
+ await writeFile(path.join(openclawRoot, 'openclaw.plugin.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8');
468
+ stats.files.push('openclaw.plugin.json');
469
+
470
+ // Write src/index.ts entry point
471
+ const srcDir = path.join(openclawRoot, 'src');
472
+ await mkdir(srcDir, { recursive: true });
473
+ const entryPoint = adapter.generateEntryPoint(parsedSkills, routerContent);
474
+ await writeFile(path.join(srcDir, 'index.ts'), entryPoint, 'utf-8');
475
+ stats.files.push('src/index.ts');
476
+
477
+ // Write README.md + SKILL.md for ClawHub listing page
478
+ if (adapter.generateReadme) {
479
+ const readme = adapter.generateReadme(parsedSkills, pluginJson);
480
+ await writeFile(path.join(openclawRoot, 'README.md'), readme, 'utf-8');
481
+ stats.files.push('README.md');
482
+ // SKILL.md required by clawhub publish
483
+ await writeFile(path.join(openclawRoot, 'SKILL.md'), readme, 'utf-8');
484
+ stats.files.push('SKILL.md');
485
+ }
486
+ }
487
+
488
+ return stats;
489
+ }
490
+
491
+ /**
492
+ * Generate an index file listing all compiled skills
493
+ */
494
+ function generateIndex(stats, adapter) {
495
+ const lines = [
496
+ '# Rune Skill Index',
497
+ '',
498
+ `> Platform: ${adapter.name} | Skills: ${stats.skillCount} | Extensions: ${stats.packCount}`,
499
+ '',
500
+ '## Core Skills',
501
+ '',
502
+ ...stats.files.filter((f) => !f.match(/[-/]ext-/) && !f.includes('index')).map((f) => `- ${f}`),
503
+ '',
504
+ ];
505
+
506
+ const extFiles = stats.files.filter((f) => f.match(/[-/]ext-/));
507
+ if (extFiles.length > 0) {
508
+ lines.push('## Extension Packs', '', ...extFiles.map((f) => `- ${f}`), '');
509
+ }
510
+
511
+ lines.push('---', '> Rune Skill Mesh — https://github.com/rune-kit/rune');
512
+
513
+ return lines.join('\n');
514
+ }
515
+
516
+ /**
517
+ * Generate AGENTS.md for Codex (OpenAI convention)
518
+ * Uses dynamic counts from build stats — no hardcoded skill lists
519
+ */
520
+ function generateAgentsMd(stats, adapter) {
521
+ const lines = [
522
+ '# Rune — Project Configuration',
523
+ '',
524
+ '## Overview',
525
+ '',
526
+ 'Rune is an interconnected skill ecosystem for AI coding assistants.',
527
+ `${stats.skillCount} core skills | 5-layer mesh architecture | ${stats.crossRefsResolved} connections | Multi-platform.`,
528
+ 'Philosophy: "Less skills. Deeper connections."',
529
+ '',
530
+ `Platform: ${adapter.name}`,
531
+ '',
532
+ '## Skills',
533
+ '',
534
+ `**${stats.skillCount} core skills** + **${stats.packCount} extension packs**`,
535
+ '',
536
+ '## Usage',
537
+ '',
538
+ 'Reference skills using the `Skill` tool or delegate to subagents using the `Agent` tool.',
539
+ '',
540
+ '## Skills Directory',
541
+ '',
542
+ `Skills are located in: ${adapter.outputDir}/`,
543
+ '',
544
+ '---',
545
+ '> Rune Skill Mesh — https://github.com/rune-kit/rune',
546
+ '',
547
+ ];
548
+
549
+ return lines.join('\n');
550
+ }
551
+
552
+ /**
553
+ * Intent keyword patterns for each skill — extracted from description + Triggers section
554
+ * Maps common user intent words to the skill that handles them
555
+ */
556
+ const INTENT_KEYWORDS = {
557
+ cook: ['implement', 'build', 'create', 'add', 'feature', 'fix', 'code', 'write', 'make', 'develop'],
558
+ team: ['parallel', 'split', 'multiple', 'large', 'many files', 'multi-module'],
559
+ launch: ['deploy', 'launch', 'release', 'ship', 'publish', 'production'],
560
+ rescue: ['legacy', 'refactor', 'modernize', 'rescue', 'clean up', 'old code', 'messy'],
561
+ scaffold: ['new project', 'bootstrap', 'scaffold', 'init', 'greenfield', 'starter'],
562
+ plan: ['plan', 'architect', 'design system', 'roadmap', 'strategy'],
563
+ brainstorm: ['brainstorm', 'explore', 'ideas', 'alternatives', 'approaches'],
564
+ debug: ['debug', 'error', 'bug', 'broken', 'trace', 'diagnose', 'crash', 'fail'],
565
+ fix: ['fix', 'patch', 'hotfix', 'resolve', 'repair'],
566
+ test: ['test', 'tdd', 'coverage', 'unit test', 'e2e', 'spec'],
567
+ review: ['review', 'code review', 'check quality', 'audit code'],
568
+ sentinel: ['security', 'vulnerability', 'owasp', 'secret', 'audit security'],
569
+ preflight: ['pre-commit', 'quality gate', 'check before'],
570
+ deploy: ['deploy', 'ci/cd', 'pipeline', 'kubernetes', 'docker'],
571
+ design: ['ui', 'ux', 'design', 'layout', 'component design', 'wireframe'],
572
+ perf: ['performance', 'slow', 'optimize', 'n+1', 'memory leak', 'bundle size'],
573
+ db: ['database', 'migration', 'schema', 'sql', 'query', 'index'],
574
+ audit: ['audit', 'health check', 'project assessment', 'codebase review'],
575
+ onboard: ['onboard', 'setup', 'configure project', 'get started'],
576
+ docs: ['document', 'readme', 'api docs', 'changelog'],
577
+ ba: ['requirements', 'business analysis', 'user stories', 'stakeholder'],
578
+ adversary: ['red team', 'challenge', 'stress test', 'edge case'],
579
+ incident: ['incident', 'outage', 'downtime', 'postmortem'],
580
+ surgeon: ['refactor', 'extract', 'strangler', 'decompose'],
581
+ 'mcp-builder': ['mcp', 'mcp server', 'tool server', 'model context'],
582
+ 'skill-forge': ['new skill', 'create skill', 'edit skill'],
583
+ 'review-intake': ['pr feedback', 'review comments', 'received review'],
584
+ 'logic-guardian': ['business logic', 'protect logic', 'critical path'],
585
+ marketing: ['marketing', 'landing page', 'seo', 'social media', 'copy'],
586
+ retro: ['retrospective', 'sprint review', 'velocity', 'team health'],
587
+ };
588
+
589
+ /**
590
+ * Generate skill-index.json — compiled intent mesh for runtime auto-trigger
591
+ *
592
+ * Extracts from parsed skills: name, description, layer, model, group,
593
+ * cross-references (connections), and maps intent keywords to skill chains.
594
+ *
595
+ * @param {Array} parsedSkills - array of parsed skill objects
596
+ * @returns {object} skill index with graph + intents
597
+ */
598
+ function generateSkillIndex(parsedSkills) {
599
+ // Build adjacency graph from cross-references
600
+ const graph = {};
601
+ const skills = {};
602
+
603
+ for (const skill of parsedSkills) {
604
+ const outbound = [...new Set(skill.crossRefs.map((r) => r.skillName))];
605
+ graph[skill.name] = outbound;
606
+ skills[skill.name] = {
607
+ layer: skill.layer,
608
+ model: skill.model,
609
+ group: skill.group,
610
+ description: skill.description.slice(0, 200),
611
+ connections: outbound,
612
+ ...(skill.signals ? { signals: skill.signals } : {}),
613
+ };
614
+ }
615
+
616
+ // Build signal graph — maps each signal to its emitters and listeners
617
+ const signalGraph = buildSignalGraph(parsedSkills);
618
+
619
+ // Build intent patterns from INTENT_KEYWORDS + skill descriptions
620
+ const intents = {};
621
+ for (const [skillName, keywords] of Object.entries(INTENT_KEYWORDS)) {
622
+ if (!skills[skillName]) continue;
623
+ const skill = skills[skillName];
624
+
625
+ // Build chain: primary skill + its direct connections (1-hop)
626
+ const chain = [skillName, ...graph[skillName].filter((c) => skills[c]).slice(0, 5)];
627
+
628
+ intents[skillName] = {
629
+ keywords,
630
+ layer: skill.layer,
631
+ model: skill.model,
632
+ chain,
633
+ };
634
+ }
635
+
636
+ return {
637
+ version: 2,
638
+ generated: new Date().toISOString(),
639
+ skillCount: parsedSkills.length,
640
+ skills,
641
+ graph,
642
+ signals: signalGraph,
643
+ intents,
644
+ };
645
+ }
646
+
647
+ /**
648
+ * Build signal graph from parsed skills' emit/listen declarations.
649
+ * Maps each signal name to its emitters and listeners.
650
+ *
651
+ * @param {Array} parsedSkills
652
+ * @returns {object} { "code.changed": { emitters: ["fix"], listeners: ["test", "review"] } }
653
+ */
654
+ function buildSignalGraph(parsedSkills) {
655
+ const signals = {};
656
+
657
+ for (const skill of parsedSkills) {
658
+ if (!skill.signals) continue;
659
+
660
+ for (const signal of skill.signals.emit) {
661
+ if (!signals[signal]) signals[signal] = { emitters: [], listeners: [] };
662
+ signals[signal].emitters.push(skill.name);
663
+ }
664
+
665
+ for (const signal of skill.signals.listen) {
666
+ if (!signals[signal]) signals[signal] = { emitters: [], listeners: [] };
667
+ signals[signal].listeners.push(skill.name);
668
+ }
669
+ }
670
+
671
+ // Sort for deterministic output
672
+ for (const entry of Object.values(signals)) {
673
+ entry.emitters.sort();
674
+ entry.listeners.sort();
675
+ }
676
+
677
+ return signals;
678
+ }