@rune-kit/rune 2.4.0 → 2.7.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.
Files changed (49) hide show
  1. package/README.md +47 -17
  2. package/compiler/__tests__/executive-dashboards.test.js +285 -0
  3. package/compiler/__tests__/inject.test.js +128 -0
  4. package/compiler/__tests__/orchestrators.test.js +151 -0
  5. package/compiler/__tests__/org-templates.test.js +447 -0
  6. package/compiler/__tests__/pack-split.test.js +141 -1
  7. package/compiler/__tests__/parser.test.js +147 -1
  8. package/compiler/__tests__/scripts-bundling.test.js +10 -11
  9. package/compiler/__tests__/skill-index.test.js +218 -0
  10. package/compiler/__tests__/status.test.js +336 -0
  11. package/compiler/__tests__/templates.test.js +245 -0
  12. package/compiler/__tests__/visualizer.test.js +325 -0
  13. package/compiler/adapters/antigravity.js +18 -4
  14. package/compiler/bin/rune.js +90 -1
  15. package/compiler/doctor.js +283 -2
  16. package/compiler/emitter.js +490 -17
  17. package/compiler/parser.js +255 -4
  18. package/compiler/status.js +342 -0
  19. package/compiler/visualizer.js +622 -0
  20. package/hooks/hooks.json +12 -0
  21. package/hooks/intent-router/index.cjs +108 -0
  22. package/hooks/pre-tool-guard/index.cjs +177 -68
  23. package/package.json +63 -63
  24. package/skills/autopsy/SKILL.md +48 -1
  25. package/skills/brainstorm/SKILL.md +2 -0
  26. package/skills/completion-gate/SKILL.md +26 -1
  27. package/skills/context-engine/SKILL.md +93 -2
  28. package/skills/cook/SKILL.md +794 -648
  29. package/skills/debug/SKILL.md +409 -392
  30. package/skills/deploy/SKILL.md +2 -0
  31. package/skills/docs/SKILL.md +28 -3
  32. package/skills/fix/SKILL.md +284 -281
  33. package/skills/mcp-builder/SKILL.md +53 -1
  34. package/skills/onboard/SKILL.md +58 -1
  35. package/skills/perf/SKILL.md +34 -1
  36. package/skills/plan/SKILL.md +372 -342
  37. package/skills/preflight/SKILL.md +396 -360
  38. package/skills/retro/SKILL.md +95 -1
  39. package/skills/review/SKILL.md +535 -489
  40. package/skills/scope-guard/SKILL.md +1 -0
  41. package/skills/scout/SKILL.md +1 -0
  42. package/skills/sentinel/SKILL.md +353 -299
  43. package/skills/sentinel/references/policy-driven-constraints.md +424 -0
  44. package/skills/session-bridge/SKILL.md +58 -2
  45. package/skills/session-bridge/references/evolutionary-memory-patterns.md +312 -0
  46. package/skills/team/SKILL.md +16 -1
  47. package/skills/test/SKILL.md +587 -585
  48. package/skills/verification/SKILL.md +1 -0
  49. package/skills/watchdog/SKILL.md +2 -0
@@ -1,8 +1,12 @@
1
1
  import assert from 'node:assert';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
3
5
  import path from 'node:path';
4
- import { test } from 'node:test';
6
+ import { describe, test } from 'node:test';
5
7
  import { fileURLToPath } from 'node:url';
8
+ import { getAdapter } from '../adapters/index.js';
9
+ import { buildAll } from '../emitter.js';
6
10
  import { parsePack } from '../parser.js';
7
11
 
8
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -143,3 +147,139 @@ test('parsePack: real backend PACK.md parses as split (post-split)', () => {
143
147
  assert.strictEqual(parsed.layer, 'L4');
144
148
  assert.ok(parsed.sections.size > 0);
145
149
  });
150
+
151
+ // --- Integration: buildAll auto-discovers split pack skill files ---
152
+
153
+ describe('buildAll split pack auto-discovery', () => {
154
+ async function createTempWithSplitPack() {
155
+ const tmp = path.join(tmpdir(), `rune-split-test-${Date.now()}`);
156
+ const skillsDir = path.join(tmp, 'skills', 'test-skill');
157
+ const extDir = path.join(tmp, 'extensions', 'test-pack');
158
+ const extSkillsDir = path.join(extDir, 'skills');
159
+
160
+ await mkdir(skillsDir, { recursive: true });
161
+ await mkdir(extSkillsDir, { recursive: true });
162
+
163
+ // Minimal core skill (required for build)
164
+ await writeFile(
165
+ path.join(skillsDir, 'SKILL.md'),
166
+ [
167
+ '---',
168
+ 'name: test-skill',
169
+ 'description: "A test skill"',
170
+ 'layer: L3',
171
+ 'group: utility',
172
+ 'connections: []',
173
+ 'tags: [test]',
174
+ '---',
175
+ '',
176
+ '# test-skill',
177
+ '',
178
+ 'Hello.',
179
+ ].join('\n'),
180
+ 'utf-8',
181
+ );
182
+
183
+ // Split pack WITHOUT skills: array in frontmatter
184
+ await writeFile(
185
+ path.join(extDir, 'PACK.md'),
186
+ [
187
+ '---',
188
+ 'name: "@rune/test-pack"',
189
+ 'description: "Test pack"',
190
+ 'metadata:',
191
+ ' version: "0.1.0"',
192
+ ' format: split',
193
+ '---',
194
+ '',
195
+ '# @rune/test-pack',
196
+ '',
197
+ 'Pack index body.',
198
+ ].join('\n'),
199
+ 'utf-8',
200
+ );
201
+
202
+ // Two skill files in skills/ subdir
203
+ await writeFile(
204
+ path.join(extSkillsDir, 'alpha.md'),
205
+ [
206
+ '---',
207
+ 'name: "alpha"',
208
+ 'description: "Alpha skill"',
209
+ 'model: sonnet',
210
+ '---',
211
+ '',
212
+ '# alpha',
213
+ '',
214
+ 'Alpha body content here.',
215
+ ].join('\n'),
216
+ 'utf-8',
217
+ );
218
+ await writeFile(
219
+ path.join(extSkillsDir, 'beta.md'),
220
+ [
221
+ '---',
222
+ 'name: "beta"',
223
+ 'description: "Beta skill"',
224
+ 'model: sonnet',
225
+ '---',
226
+ '',
227
+ '# beta',
228
+ '',
229
+ 'Beta body content here.',
230
+ ].join('\n'),
231
+ 'utf-8',
232
+ );
233
+
234
+ return tmp;
235
+ }
236
+
237
+ test('auto-discovers skill files from skills/ subdir when manifest is empty', async () => {
238
+ const tmp = await createTempWithSplitPack();
239
+ try {
240
+ const outputRoot = path.join(tmp, 'out');
241
+ const adapter = getAdapter('cursor');
242
+ const stats = await buildAll({ runeRoot: tmp, outputRoot, adapter });
243
+
244
+ assert.strictEqual(stats.packCount, 1, 'should build 1 pack');
245
+
246
+ // Read the compiled pack output
247
+ const packOutput = await readFile(path.join(outputRoot, adapter.outputDir, 'rune-ext-test-pack.mdc'), 'utf-8');
248
+
249
+ // Should contain the pack index body
250
+ assert.ok(packOutput.includes('Pack index body'), 'missing pack index body');
251
+
252
+ // Should contain BOTH auto-discovered skill bodies
253
+ assert.ok(packOutput.includes('Alpha body content here'), 'missing alpha skill body');
254
+ assert.ok(packOutput.includes('Beta body content here'), 'missing beta skill body');
255
+ } finally {
256
+ await rm(tmp, { recursive: true, force: true });
257
+ }
258
+ });
259
+
260
+ test('real ai-ml pack includes skill content after build', async () => {
261
+ const aimlPath = path.join(EXTENSIONS_DIR, 'ai-ml', 'PACK.md');
262
+ if (!existsSync(aimlPath)) {
263
+ console.log(' skip: ai-ml PACK.md not found');
264
+ return;
265
+ }
266
+
267
+ const tmp = path.join(tmpdir(), `rune-aiml-test-${Date.now()}`);
268
+ const runeRoot = path.resolve(__dirname, '../..');
269
+ const adapter = getAdapter('cursor');
270
+ await buildAll({ runeRoot, outputRoot: tmp, adapter });
271
+
272
+ try {
273
+ const packOutput = await readFile(path.join(tmp, adapter.outputDir, 'rune-ext-ai-ml.mdc'), 'utf-8');
274
+
275
+ // Should be significantly longer than just the index body (~99 lines without skills)
276
+ const lineCount = packOutput.split('\n').length;
277
+ assert.ok(lineCount > 200, `ai-ml pack output too short (${lineCount} lines) — skills likely not included`);
278
+
279
+ // Should contain content from individual skill files
280
+ assert.ok(packOutput.includes('ai-agents') || packOutput.includes('AI agent'), 'missing ai-agents skill content');
281
+ } finally {
282
+ await rm(tmp, { recursive: true, force: true });
283
+ }
284
+ });
285
+ });
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { test } from 'node:test';
4
+ import { describe, test } from 'node:test';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseSkill } from '../parser.js';
7
7
 
@@ -53,3 +53,149 @@ test('parse verification SKILL.md', () => {
53
53
  assert.strictEqual(parsed.name, 'verification');
54
54
  assert.strictEqual(parsed.layer, 'L3');
55
55
  });
56
+
57
+ describe('signals parsing', () => {
58
+ test('parses emit and listen from metadata', () => {
59
+ const content = [
60
+ '---',
61
+ 'name: alpha',
62
+ 'description: "Test skill"',
63
+ 'metadata:',
64
+ ' layer: L2',
65
+ ' emit: code.changed, tests.passed',
66
+ ' listen: plan.ready',
67
+ '---',
68
+ '',
69
+ '# alpha',
70
+ ].join('\n');
71
+
72
+ const parsed = parseSkill(content);
73
+ assert.ok(parsed.signals, 'signals should not be null');
74
+ assert.deepStrictEqual(parsed.signals.emit, ['code.changed', 'tests.passed']);
75
+ assert.deepStrictEqual(parsed.signals.listen, ['plan.ready']);
76
+ });
77
+
78
+ test('emit only — no listen', () => {
79
+ const content = [
80
+ '---',
81
+ 'name: beta',
82
+ 'description: "Emitter only"',
83
+ 'metadata:',
84
+ ' layer: L3',
85
+ ' emit: deploy.complete',
86
+ '---',
87
+ '',
88
+ '# beta',
89
+ ].join('\n');
90
+
91
+ const parsed = parseSkill(content);
92
+ assert.ok(parsed.signals);
93
+ assert.deepStrictEqual(parsed.signals.emit, ['deploy.complete']);
94
+ assert.deepStrictEqual(parsed.signals.listen, []);
95
+ });
96
+
97
+ test('listen only — no emit', () => {
98
+ const content = [
99
+ '---',
100
+ 'name: gamma',
101
+ 'description: "Listener only"',
102
+ 'metadata:',
103
+ ' layer: L3',
104
+ ' listen: code.changed',
105
+ '---',
106
+ '',
107
+ '# gamma',
108
+ ].join('\n');
109
+
110
+ const parsed = parseSkill(content);
111
+ assert.ok(parsed.signals);
112
+ assert.deepStrictEqual(parsed.signals.emit, []);
113
+ assert.deepStrictEqual(parsed.signals.listen, ['code.changed']);
114
+ });
115
+
116
+ test('no signals — returns null', () => {
117
+ const content = [
118
+ '---',
119
+ 'name: delta',
120
+ 'description: "No signals"',
121
+ 'metadata:',
122
+ ' layer: L3',
123
+ '---',
124
+ '',
125
+ '# delta',
126
+ ].join('\n');
127
+
128
+ const parsed = parseSkill(content);
129
+ assert.strictEqual(parsed.signals, null);
130
+ });
131
+
132
+ test('real cook skill has signals', () => {
133
+ const content = readFileSync(path.join(SKILLS_DIR, 'cook/SKILL.md'), 'utf-8');
134
+ const parsed = parseSkill(content, 'cook/SKILL.md');
135
+ assert.ok(parsed.signals, 'cook should have signals');
136
+ assert.ok(parsed.signals.emit.includes('phase.complete'));
137
+ });
138
+
139
+ test('real test skill has emit and listen', () => {
140
+ const content = readFileSync(path.join(SKILLS_DIR, 'test/SKILL.md'), 'utf-8');
141
+ const parsed = parseSkill(content, 'test/SKILL.md');
142
+ assert.ok(parsed.signals, 'test should have signals');
143
+ assert.ok(parsed.signals.emit.includes('tests.passed'));
144
+ assert.ok(parsed.signals.emit.includes('tests.failed'));
145
+ assert.ok(parsed.signals.listen.includes('code.changed'));
146
+ });
147
+
148
+ test('top-level emit/listen (Pro/Business pack format)', () => {
149
+ const content = [
150
+ '---',
151
+ 'name: "feature-spec"',
152
+ 'pack: "@rune-pro/product"',
153
+ 'version: "1.2.0"',
154
+ 'model: opus',
155
+ 'tools: [Read, Write]',
156
+ 'emit: product.spec.drafted',
157
+ 'listen: sales.account.researched, product.research.complete',
158
+ '---',
159
+ '',
160
+ '# feature-spec',
161
+ ].join('\n');
162
+
163
+ const parsed = parseSkill(content);
164
+ assert.ok(parsed.signals, 'top-level signals should be parsed');
165
+ assert.deepStrictEqual(parsed.signals.emit, ['product.spec.drafted']);
166
+ assert.deepStrictEqual(parsed.signals.listen, ['sales.account.researched', 'product.research.complete']);
167
+ });
168
+
169
+ test('top-level emit only (no listen)', () => {
170
+ const content = [
171
+ '---',
172
+ 'name: "account-research"',
173
+ 'emit: sales.account.researched',
174
+ '---',
175
+ '',
176
+ '# account-research',
177
+ ].join('\n');
178
+
179
+ const parsed = parseSkill(content);
180
+ assert.ok(parsed.signals);
181
+ assert.deepStrictEqual(parsed.signals.emit, ['sales.account.researched']);
182
+ assert.deepStrictEqual(parsed.signals.listen, []);
183
+ });
184
+
185
+ test('metadata emit takes precedence over top-level', () => {
186
+ const content = [
187
+ '---',
188
+ 'name: "hybrid"',
189
+ 'emit: top.level.signal',
190
+ 'metadata:',
191
+ ' emit: nested.signal',
192
+ '---',
193
+ '',
194
+ '# hybrid',
195
+ ].join('\n');
196
+
197
+ const parsed = parseSkill(content);
198
+ assert.ok(parsed.signals);
199
+ assert.deepStrictEqual(parsed.signals.emit, ['nested.signal']);
200
+ });
201
+ });
@@ -138,11 +138,7 @@ describe('buildAll with scripts', () => {
138
138
 
139
139
  // Minimal .claude-plugin for openclaw
140
140
  await mkdir(path.join(tmp, '.claude-plugin'), { recursive: true });
141
- await writeFile(
142
- path.join(tmp, '.claude-plugin', 'plugin.json'),
143
- JSON.stringify({ version: '0.0.1' }),
144
- 'utf-8',
145
- );
141
+ await writeFile(path.join(tmp, '.claude-plugin', 'plugin.json'), JSON.stringify({ version: '0.0.1' }), 'utf-8');
146
142
 
147
143
  return tmp;
148
144
  }
@@ -168,7 +164,10 @@ describe('buildAll with scripts', () => {
168
164
  assert.ok(mdc.includes('.cursor/rules/rune-test-slide-scripts/build-deck.js'), 'resolved path missing');
169
165
 
170
166
  // Plain skill: no scripts dir created
171
- assert.ok(!existsSync(path.join(outputRoot, adapter.outputDir, 'rune-test-plain-scripts')), 'plain skill should not have scripts dir');
167
+ assert.ok(
168
+ !existsSync(path.join(outputRoot, adapter.outputDir, 'rune-test-plain-scripts')),
169
+ 'plain skill should not have scripts dir',
170
+ );
172
171
  } finally {
173
172
  await rm(tmp, { recursive: true, force: true });
174
173
  }
@@ -189,10 +188,7 @@ describe('buildAll with scripts', () => {
189
188
  assert.ok(existsSync(path.join(scriptsOut, 'helper.py')), 'helper.py missing');
190
189
 
191
190
  // Placeholder resolved
192
- const md = await readFile(
193
- path.join(outputRoot, adapter.outputDir, 'rune-test-slide', 'SKILL.md'),
194
- 'utf-8',
195
- );
191
+ const md = await readFile(path.join(outputRoot, adapter.outputDir, 'rune-test-slide', 'SKILL.md'), 'utf-8');
196
192
  assert.ok(!md.includes('{scripts_dir}'), 'placeholder should be resolved');
197
193
  assert.ok(md.includes('.codex/skills/rune-test-slide/scripts/build-deck.js'), 'resolved path missing');
198
194
  } finally {
@@ -254,7 +250,10 @@ describe('buildAll with scripts', () => {
254
250
  await buildAll({ runeRoot: tmp, outputRoot, adapter });
255
251
 
256
252
  const original = await readFile(path.join(tmp, 'skills', 'test-slide', 'scripts', 'build-deck.js'), 'utf-8');
257
- const copied = await readFile(path.join(outputRoot, adapter.outputDir, 'rune-test-slide-scripts', 'build-deck.js'), 'utf-8');
253
+ const copied = await readFile(
254
+ path.join(outputRoot, adapter.outputDir, 'rune-test-slide-scripts', 'build-deck.js'),
255
+ 'utf-8',
256
+ );
258
257
  assert.strictEqual(copied, original, 'script content should be identical');
259
258
  } finally {
260
259
  await rm(tmp, { recursive: true, force: true });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Skill Index Generation Tests
3
+ *
4
+ * Tests that buildAll generates a valid skill-index.json with:
5
+ * - Intent patterns mapped to skills
6
+ * - Mesh-aware chain prediction from connections
7
+ * - Complete skill graph
8
+ */
9
+
10
+ import assert from 'node:assert';
11
+ import { existsSync } from 'node:fs';
12
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import path from 'node:path';
15
+ import { describe, test } from 'node:test';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { getAdapter } from '../adapters/index.js';
18
+ import { buildAll } from '../emitter.js';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const RUNE_ROOT = path.resolve(__dirname, '../..');
22
+
23
+ describe('skill-index.json generation', () => {
24
+ test('buildAll emits skill-index.json with correct structure', async () => {
25
+ const tmp = path.join(tmpdir(), `rune-idx-test-${Date.now()}`);
26
+ try {
27
+ const adapter = getAdapter('cursor');
28
+ await buildAll({ runeRoot: RUNE_ROOT, outputRoot: tmp, adapter });
29
+
30
+ const indexPath = path.join(tmp, adapter.outputDir, 'skill-index.json');
31
+ assert.ok(existsSync(indexPath), 'skill-index.json not found in output');
32
+
33
+ const index = JSON.parse(await readFile(indexPath, 'utf-8'));
34
+
35
+ // Structure checks
36
+ assert.strictEqual(index.version, 2);
37
+ assert.ok(index.generated, 'missing generated timestamp');
38
+ assert.ok(index.skillCount >= 50, `too few skills: ${index.skillCount}`);
39
+ assert.ok(typeof index.skills === 'object', 'missing skills object');
40
+ assert.ok(typeof index.graph === 'object', 'missing graph object');
41
+ assert.ok(typeof index.signals === 'object', 'missing signals object');
42
+ assert.ok(typeof index.intents === 'object', 'missing intents object');
43
+ } finally {
44
+ await rm(tmp, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ test('skill-index contains intent patterns with chains', async () => {
49
+ const tmp = path.join(tmpdir(), `rune-idx-test-${Date.now()}`);
50
+ try {
51
+ const adapter = getAdapter('cursor');
52
+ await buildAll({ runeRoot: RUNE_ROOT, outputRoot: tmp, adapter });
53
+
54
+ const index = JSON.parse(await readFile(path.join(tmp, adapter.outputDir, 'skill-index.json'), 'utf-8'));
55
+
56
+ // cook intent should exist with keywords and chain
57
+ assert.ok(index.intents.cook, 'missing cook intent');
58
+ assert.ok(Array.isArray(index.intents.cook.keywords), 'cook keywords not array');
59
+ assert.ok(index.intents.cook.keywords.includes('implement'), 'cook missing "implement" keyword');
60
+ assert.ok(Array.isArray(index.intents.cook.chain), 'cook chain not array');
61
+ assert.strictEqual(index.intents.cook.chain[0], 'cook', 'cook chain should start with cook');
62
+ assert.ok(index.intents.cook.chain.length > 1, 'cook chain should have connected skills');
63
+
64
+ // debug intent
65
+ assert.ok(index.intents.debug, 'missing debug intent');
66
+ assert.ok(index.intents.debug.keywords.includes('bug'), 'debug missing "bug" keyword');
67
+
68
+ // sentinel intent
69
+ assert.ok(index.intents.sentinel, 'missing sentinel intent');
70
+ assert.ok(index.intents.sentinel.keywords.includes('security'), 'sentinel missing "security" keyword');
71
+ } finally {
72
+ await rm(tmp, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test('skill-index graph has connections from cross-refs', async () => {
77
+ const tmp = path.join(tmpdir(), `rune-idx-test-${Date.now()}`);
78
+ try {
79
+ const adapter = getAdapter('cursor');
80
+ await buildAll({ runeRoot: RUNE_ROOT, outputRoot: tmp, adapter });
81
+
82
+ const index = JSON.parse(await readFile(path.join(tmp, adapter.outputDir, 'skill-index.json'), 'utf-8'));
83
+
84
+ // cook should have outbound connections
85
+ assert.ok(index.graph.cook, 'cook not in graph');
86
+ assert.ok(index.graph.cook.length > 3, `cook should have many connections, got ${index.graph.cook.length}`);
87
+
88
+ // Skills entry should have layer and description
89
+ assert.ok(index.skills.cook, 'cook not in skills');
90
+ assert.strictEqual(index.skills.cook.layer, 'L1');
91
+ assert.ok(index.skills.cook.description.length > 20, 'cook description too short');
92
+ } finally {
93
+ await rm(tmp, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test('skill-index contains signal graph', async () => {
98
+ const tmp = path.join(tmpdir(), `rune-idx-test-${Date.now()}`);
99
+ try {
100
+ const adapter = getAdapter('cursor');
101
+ await buildAll({ runeRoot: RUNE_ROOT, outputRoot: tmp, adapter });
102
+
103
+ const index = JSON.parse(await readFile(path.join(tmp, adapter.outputDir, 'skill-index.json'), 'utf-8'));
104
+
105
+ // Signal graph should exist
106
+ assert.ok(typeof index.signals === 'object', 'missing signals object');
107
+ assert.ok(Object.keys(index.signals).length >= 10, `too few signals: ${Object.keys(index.signals).length}`);
108
+
109
+ // code.changed should be a well-connected signal
110
+ assert.ok(index.signals['code.changed'], 'missing code.changed signal');
111
+ assert.ok(index.signals['code.changed'].emitters.includes('fix'), 'fix should emit code.changed');
112
+ assert.ok(index.signals['code.changed'].listeners.length >= 3, 'code.changed should have 3+ listeners');
113
+
114
+ // Per-skill signals should be included
115
+ assert.ok(index.skills.test.signals, 'test skill should have signals');
116
+ assert.ok(index.skills.test.signals.emit.includes('tests.passed'), 'test should emit tests.passed');
117
+ assert.ok(index.skills.test.signals.listen.includes('code.changed'), 'test should listen to code.changed');
118
+
119
+ // Skills without signals should not have the field
120
+ const noSignalSkill = Object.values(index.skills).find((s) => !s.signals);
121
+ assert.ok(noSignalSkill, 'at least one skill should have no signals');
122
+ } finally {
123
+ await rm(tmp, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('skill-index signal graph with synthetic skills', async () => {
128
+ const tmp = path.join(tmpdir(), `rune-idx-sig-${Date.now()}`);
129
+ const skillsDir = path.join(tmp, 'skills');
130
+ await mkdir(path.join(skillsDir, 'emitter'), { recursive: true });
131
+ await mkdir(path.join(skillsDir, 'listener'), { recursive: true });
132
+ await mkdir(path.join(tmp, 'extensions'), { recursive: true });
133
+
134
+ await writeFile(
135
+ path.join(skillsDir, 'emitter', 'SKILL.md'),
136
+ [
137
+ '---',
138
+ 'name: emitter',
139
+ 'description: "Emits signals"',
140
+ 'metadata:',
141
+ ' layer: L2',
142
+ ' emit: data.ready',
143
+ '---',
144
+ '',
145
+ '# emitter',
146
+ ].join('\n'),
147
+ 'utf-8',
148
+ );
149
+
150
+ await writeFile(
151
+ path.join(skillsDir, 'listener', 'SKILL.md'),
152
+ [
153
+ '---',
154
+ 'name: listener',
155
+ 'description: "Listens to signals"',
156
+ 'metadata:',
157
+ ' layer: L3',
158
+ ' listen: data.ready',
159
+ '---',
160
+ '',
161
+ '# listener',
162
+ ].join('\n'),
163
+ 'utf-8',
164
+ );
165
+
166
+ try {
167
+ const adapter = getAdapter('generic');
168
+ await buildAll({ runeRoot: tmp, outputRoot: tmp, adapter });
169
+
170
+ const index = JSON.parse(await readFile(path.join(tmp, adapter.outputDir, 'skill-index.json'), 'utf-8'));
171
+
172
+ assert.ok(index.signals['data.ready'], 'data.ready signal should exist');
173
+ assert.deepStrictEqual(index.signals['data.ready'].emitters, ['emitter']);
174
+ assert.deepStrictEqual(index.signals['data.ready'].listeners, ['listener']);
175
+ assert.ok(index.skills.emitter.signals);
176
+ assert.deepStrictEqual(index.skills.emitter.signals.emit, ['data.ready']);
177
+ } finally {
178
+ await rm(tmp, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test('skill-index works with minimal skill tree', async () => {
183
+ const tmp = path.join(tmpdir(), `rune-idx-min-${Date.now()}`);
184
+ const skillsDir = path.join(tmp, 'skills', 'alpha');
185
+ await mkdir(skillsDir, { recursive: true });
186
+ await mkdir(path.join(tmp, 'extensions'), { recursive: true });
187
+
188
+ await writeFile(
189
+ path.join(skillsDir, 'SKILL.md'),
190
+ [
191
+ '---',
192
+ 'name: alpha',
193
+ 'description: "Test skill"',
194
+ 'metadata:',
195
+ ' layer: L3',
196
+ ' group: utility',
197
+ '---',
198
+ '',
199
+ '# alpha',
200
+ '',
201
+ 'Body.',
202
+ ].join('\n'),
203
+ 'utf-8',
204
+ );
205
+
206
+ try {
207
+ const adapter = getAdapter('generic');
208
+ await buildAll({ runeRoot: tmp, outputRoot: tmp, adapter });
209
+
210
+ const index = JSON.parse(await readFile(path.join(tmp, adapter.outputDir, 'skill-index.json'), 'utf-8'));
211
+ assert.strictEqual(index.skillCount, 1);
212
+ assert.ok(index.skills.alpha, 'alpha not in skills');
213
+ assert.deepStrictEqual(index.graph.alpha, [], 'alpha should have no connections');
214
+ } finally {
215
+ await rm(tmp, { recursive: true, force: true });
216
+ }
217
+ });
218
+ });