@rune-kit/rune 2.6.0 → 2.8.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 (42) hide show
  1. package/README.md +22 -6
  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__/parser.test.js +201 -147
  7. package/compiler/__tests__/skill-index.test.js +218 -218
  8. package/compiler/__tests__/status.test.js +336 -0
  9. package/compiler/__tests__/templates.test.js +245 -0
  10. package/compiler/__tests__/visualizer.test.js +325 -0
  11. package/compiler/adapters/antigravity.js +71 -71
  12. package/compiler/bin/rune.js +444 -355
  13. package/compiler/doctor.js +272 -1
  14. package/compiler/emitter.js +939 -678
  15. package/compiler/parser.js +498 -267
  16. package/compiler/status.js +342 -0
  17. package/compiler/visualizer.js +622 -0
  18. package/package.json +1 -1
  19. package/skills/autopsy/SKILL.md +48 -1
  20. package/skills/completion-gate/SKILL.md +51 -3
  21. package/skills/context-engine/SKILL.md +141 -2
  22. package/skills/cook/SKILL.md +177 -4
  23. package/skills/debug/SKILL.md +50 -1
  24. package/skills/docs/SKILL.md +28 -3
  25. package/skills/fix/SKILL.md +26 -1
  26. package/skills/mcp-builder/SKILL.md +53 -1
  27. package/skills/onboard/SKILL.md +51 -1
  28. package/skills/perf/SKILL.md +34 -1
  29. package/skills/plan/SKILL.md +27 -1
  30. package/skills/preflight/SKILL.md +35 -1
  31. package/skills/research/SKILL.md +24 -1
  32. package/skills/retro/SKILL.md +95 -1
  33. package/skills/review/SKILL.md +45 -1
  34. package/skills/scope-guard/SKILL.md +1 -0
  35. package/skills/scout/SKILL.md +22 -1
  36. package/skills/sentinel/SKILL.md +35 -1
  37. package/skills/sentinel/references/policy-driven-constraints.md +424 -0
  38. package/skills/sentinel-env/SKILL.md +0 -2
  39. package/skills/session-bridge/SKILL.md +57 -2
  40. package/skills/session-bridge/references/evolutionary-memory-patterns.md +312 -0
  41. package/skills/team/SKILL.md +15 -1
  42. package/skills/verification/SKILL.md +0 -2
@@ -0,0 +1,336 @@
1
+ import assert from 'node:assert';
2
+ import path from 'node:path';
3
+ import { describe, test } from 'node:test';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { collectStats, renderStatus, renderStatusJson } from '../status.js';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const RUNE_ROOT = path.resolve(__dirname, '../..');
9
+ const PRO_DIR = path.resolve(RUNE_ROOT, '../../Pro/extensions');
10
+ const BIZ_DIR = path.resolve(RUNE_ROOT, '../../Business/extensions');
11
+
12
+ // ─── collectStats ───
13
+
14
+ describe('collectStats', () => {
15
+ test('collects core skill count', async () => {
16
+ const stats = await collectStats(RUNE_ROOT);
17
+ assert.ok(stats.skillCount >= 60, `Expected >= 60 skills, got ${stats.skillCount}`);
18
+ });
19
+
20
+ test('counts skills by layer', async () => {
21
+ const stats = await collectStats(RUNE_ROOT);
22
+ assert.strictEqual(stats.layers.L0, 1);
23
+ assert.ok(stats.layers.L1 >= 4);
24
+ assert.ok(stats.layers.L2 >= 25);
25
+ assert.ok(stats.layers.L3 >= 20);
26
+ });
27
+
28
+ test('counts signals', async () => {
29
+ const stats = await collectStats(RUNE_ROOT);
30
+ assert.ok(stats.signalCount >= 10, `Expected >= 10 signals, got ${stats.signalCount}`);
31
+ });
32
+
33
+ test('builds signal map with emitters and listeners', async () => {
34
+ const stats = await collectStats(RUNE_ROOT);
35
+ assert.ok(Object.keys(stats.signalMap.emitters).length > 0);
36
+ assert.ok(Object.keys(stats.signalMap.listeners).length > 0);
37
+ });
38
+
39
+ test('counts connections', async () => {
40
+ const stats = await collectStats(RUNE_ROOT);
41
+ assert.ok(stats.totalConnections > 100);
42
+ assert.ok(parseFloat(stats.avgConnections) > 1);
43
+ });
44
+
45
+ test('discovers free packs', async () => {
46
+ const stats = await collectStats(RUNE_ROOT);
47
+ assert.ok(stats.freePacks.length >= 14);
48
+ });
49
+
50
+ test('detects free tier when no tier sources', async () => {
51
+ const stats = await collectStats(RUNE_ROOT);
52
+ assert.strictEqual(stats.tier, 'free');
53
+ });
54
+
55
+ test('detects pro tier when pro sources exist', async () => {
56
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR });
57
+ if (stats.proPacks.length > 0) {
58
+ assert.strictEqual(stats.tier, 'pro');
59
+ }
60
+ });
61
+
62
+ test('detects business tier when business sources exist', async () => {
63
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR, business: BIZ_DIR });
64
+ if (stats.bizPacks.length > 0) {
65
+ assert.strictEqual(stats.tier, 'business');
66
+ }
67
+ });
68
+
69
+ test('counts pro pack lines', async () => {
70
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR });
71
+ for (const pack of stats.proPacks) {
72
+ assert.ok(pack.name, 'Pack should have a name');
73
+ assert.ok(pack.lines > 0, `Pack ${pack.name} should have lines`);
74
+ }
75
+ });
76
+
77
+ test('counts business pack lines', async () => {
78
+ const stats = await collectStats(RUNE_ROOT, { business: BIZ_DIR });
79
+ for (const pack of stats.bizPacks) {
80
+ assert.ok(pack.name);
81
+ assert.ok(pack.lines > 0);
82
+ }
83
+ });
84
+
85
+ test('handles non-existent tier paths gracefully', async () => {
86
+ const stats = await collectStats(RUNE_ROOT, {
87
+ pro: '/nonexistent/path',
88
+ business: '/nonexistent/path',
89
+ });
90
+ assert.deepStrictEqual(stats.proPacks, []);
91
+ assert.deepStrictEqual(stats.bizPacks, []);
92
+ assert.strictEqual(stats.tier, 'free');
93
+ });
94
+
95
+ test('returns parsedSkills array', async () => {
96
+ const stats = await collectStats(RUNE_ROOT);
97
+ assert.strictEqual(stats.parsedSkills.length, stats.skillCount);
98
+ for (const skill of stats.parsedSkills) {
99
+ assert.ok(skill.name);
100
+ }
101
+ });
102
+ });
103
+
104
+ // ─── renderStatus ───
105
+
106
+ describe('renderStatus', () => {
107
+ test('renders box with Unicode borders', async () => {
108
+ const stats = await collectStats(RUNE_ROOT);
109
+ const output = renderStatus(stats);
110
+ assert.ok(output.includes('╭'));
111
+ assert.ok(output.includes('╰'));
112
+ assert.ok(output.includes('│'));
113
+ });
114
+
115
+ test('shows tier icon — free', async () => {
116
+ const stats = await collectStats(RUNE_ROOT);
117
+ const output = renderStatus(stats);
118
+ assert.ok(output.includes('🔮 Rune'));
119
+ });
120
+
121
+ test('shows tier icon — pro', async () => {
122
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR });
123
+ if (stats.tier === 'pro') {
124
+ const output = renderStatus(stats);
125
+ assert.ok(output.includes('⚡ Rune Pro'));
126
+ }
127
+ });
128
+
129
+ test('shows tier icon — business', async () => {
130
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR, business: BIZ_DIR });
131
+ if (stats.tier === 'business') {
132
+ const output = renderStatus(stats);
133
+ assert.ok(output.includes('🏢 Rune Business'));
134
+ }
135
+ });
136
+
137
+ test('shows skill count with layer breakdown', async () => {
138
+ const stats = await collectStats(RUNE_ROOT);
139
+ const output = renderStatus(stats);
140
+ assert.match(output, /Skills\s+\d+ core/);
141
+ assert.ok(output.includes('L0:'));
142
+ assert.ok(output.includes('L1:'));
143
+ assert.ok(output.includes('L2:'));
144
+ assert.ok(output.includes('L3:'));
145
+ });
146
+
147
+ test('shows pack counts', async () => {
148
+ const stats = await collectStats(RUNE_ROOT);
149
+ const output = renderStatus(stats);
150
+ assert.match(output, /Packs\s+\d+ free/);
151
+ });
152
+
153
+ test('shows signal count', async () => {
154
+ const stats = await collectStats(RUNE_ROOT);
155
+ const output = renderStatus(stats);
156
+ assert.match(output, /Signals\s+\d+ defined/);
157
+ });
158
+
159
+ test('shows mesh connections', async () => {
160
+ const stats = await collectStats(RUNE_ROOT);
161
+ const output = renderStatus(stats);
162
+ assert.match(output, /Mesh\s+\d+\+ connections/);
163
+ });
164
+
165
+ test('shows health progress bar', async () => {
166
+ const stats = await collectStats(RUNE_ROOT);
167
+ const output = renderStatus(stats);
168
+ assert.ok(output.includes('▓'));
169
+ assert.ok(output.includes('mesh health'));
170
+ });
171
+
172
+ test('shows active signals section', async () => {
173
+ const stats = await collectStats(RUNE_ROOT);
174
+ const output = renderStatus(stats);
175
+ assert.ok(output.includes('Active Signals'));
176
+ assert.ok(output.includes('→'));
177
+ });
178
+
179
+ test('shows project info when provided', async () => {
180
+ const stats = await collectStats(RUNE_ROOT);
181
+ const output = renderStatus(stats, {
182
+ version: '2.6.0',
183
+ platform: 'cursor',
184
+ projectName: 'my-app',
185
+ });
186
+ assert.ok(output.includes('my-app'));
187
+ assert.ok(output.includes('cursor'));
188
+ assert.ok(output.includes('2.6.0'));
189
+ });
190
+
191
+ test('shows Pro pack section for pro tier', async () => {
192
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR });
193
+ if (stats.proPacks.length > 0) {
194
+ const output = renderStatus(stats);
195
+ assert.ok(output.includes('Pro Packs'));
196
+ assert.ok(output.includes('@rune-pro/'));
197
+ assert.ok(output.includes('lines'));
198
+ }
199
+ });
200
+
201
+ test('shows Business pack section for business tier', async () => {
202
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR, business: BIZ_DIR });
203
+ if (stats.bizPacks.length > 0) {
204
+ const output = renderStatus(stats);
205
+ assert.ok(output.includes('Business Packs'));
206
+ assert.ok(output.includes('@rune-biz/'));
207
+ }
208
+ });
209
+
210
+ test('omits Pro/Biz sections for free tier', async () => {
211
+ const stats = await collectStats(RUNE_ROOT);
212
+ const output = renderStatus(stats);
213
+ assert.ok(!output.includes('Pro Packs'));
214
+ assert.ok(!output.includes('Business Packs'));
215
+ });
216
+ });
217
+
218
+ // ─── renderStatusJson ───
219
+
220
+ describe('renderStatusJson', () => {
221
+ test('returns valid JSON', async () => {
222
+ const stats = await collectStats(RUNE_ROOT);
223
+ const parsed = JSON.parse(renderStatusJson(stats));
224
+ assert.ok(parsed);
225
+ });
226
+
227
+ test('includes tier field', async () => {
228
+ const stats = await collectStats(RUNE_ROOT);
229
+ const parsed = JSON.parse(renderStatusJson(stats));
230
+ assert.ok(['free', 'pro', 'business'].includes(parsed.tier));
231
+ });
232
+
233
+ test('includes skills with layers', async () => {
234
+ const stats = await collectStats(RUNE_ROOT);
235
+ const parsed = JSON.parse(renderStatusJson(stats));
236
+ assert.ok(parsed.skills.total >= 60);
237
+ assert.strictEqual(parsed.skills.layers.L0, 1);
238
+ });
239
+
240
+ test('includes packs breakdown', async () => {
241
+ const stats = await collectStats(RUNE_ROOT);
242
+ const parsed = JSON.parse(renderStatusJson(stats));
243
+ assert.ok(parsed.packs.free >= 14);
244
+ assert.ok(Array.isArray(parsed.packs.pro));
245
+ assert.ok(Array.isArray(parsed.packs.business));
246
+ });
247
+
248
+ test('includes signal data with top signals', async () => {
249
+ const stats = await collectStats(RUNE_ROOT);
250
+ const parsed = JSON.parse(renderStatusJson(stats));
251
+ assert.ok(parsed.signals.count >= 10);
252
+ assert.ok(parsed.signals.top.length > 0);
253
+ for (const sig of parsed.signals.top) {
254
+ assert.ok(sig.name);
255
+ assert.ok(Array.isArray(sig.emitters));
256
+ assert.ok(Array.isArray(sig.listeners));
257
+ }
258
+ });
259
+
260
+ test('includes mesh stats', async () => {
261
+ const stats = await collectStats(RUNE_ROOT);
262
+ const parsed = JSON.parse(renderStatusJson(stats));
263
+ assert.ok(parsed.mesh.connections > 100);
264
+ assert.ok(parsed.mesh.avgPerSkill > 1);
265
+ });
266
+
267
+ test('includes health score 0-100', async () => {
268
+ const stats = await collectStats(RUNE_ROOT);
269
+ const parsed = JSON.parse(renderStatusJson(stats));
270
+ assert.ok(parsed.health >= 0);
271
+ assert.ok(parsed.health <= 100);
272
+ });
273
+
274
+ test('includes project info when provided', async () => {
275
+ const stats = await collectStats(RUNE_ROOT);
276
+ const parsed = JSON.parse(renderStatusJson(stats, { version: '2.6.0', platform: 'cursor', projectName: 'test' }));
277
+ assert.strictEqual(parsed.project, 'test');
278
+ assert.strictEqual(parsed.platform, 'cursor');
279
+ assert.strictEqual(parsed.version, '2.6.0');
280
+ });
281
+
282
+ test('pro packs include name and lines', async () => {
283
+ const stats = await collectStats(RUNE_ROOT, { pro: PRO_DIR });
284
+ const parsed = JSON.parse(renderStatusJson(stats));
285
+ for (const pack of parsed.packs.pro) {
286
+ assert.ok(pack.name);
287
+ assert.ok(pack.lines > 0);
288
+ }
289
+ });
290
+ });
291
+
292
+ // ─── Health Score ───
293
+
294
+ describe('health score', () => {
295
+ test('free tier has reasonable health', async () => {
296
+ const stats = await collectStats(RUNE_ROOT);
297
+ const parsed = JSON.parse(renderStatusJson(stats));
298
+ assert.ok(parsed.health >= 70, `Health ${parsed.health} should be >= 70`);
299
+ });
300
+
301
+ test('business tier health >= free tier health', async () => {
302
+ const freeStats = await collectStats(RUNE_ROOT);
303
+ const bizStats = await collectStats(RUNE_ROOT, { pro: PRO_DIR, business: BIZ_DIR });
304
+
305
+ const freeHealth = JSON.parse(renderStatusJson(freeStats)).health;
306
+ const bizHealth = JSON.parse(renderStatusJson(bizStats)).health;
307
+ assert.ok(bizHealth >= freeHealth, `Biz ${bizHealth} should be >= Free ${freeHealth}`);
308
+ });
309
+ });
310
+
311
+ // ─── Box Rendering ───
312
+
313
+ describe('box rendering', () => {
314
+ test('all box lines end with correct border char', async () => {
315
+ const stats = await collectStats(RUNE_ROOT);
316
+ const output = renderStatus(stats);
317
+ const lines = output.split('\n');
318
+ const boxLines = lines.filter((l) => l.startsWith('│') || l.startsWith('╭') || l.startsWith('╰'));
319
+
320
+ for (const line of boxLines) {
321
+ const lastChar = line.trimEnd().slice(-1);
322
+ assert.ok(
323
+ ['│', '╮', '╯'].includes(lastChar),
324
+ `Line should end with border char, got: "${lastChar}" in "${line}"`,
325
+ );
326
+ }
327
+ });
328
+
329
+ test('box has exactly one top and one bottom border', async () => {
330
+ const stats = await collectStats(RUNE_ROOT);
331
+ const output = renderStatus(stats);
332
+ const lines = output.split('\n');
333
+ assert.strictEqual(lines.filter((l) => l.startsWith('╭')).length, 1);
334
+ assert.strictEqual(lines.filter((l) => l.startsWith('╰')).length, 1);
335
+ });
336
+ });
@@ -0,0 +1,245 @@
1
+ import assert from 'node:assert';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describe, test } from 'node:test';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseSkill, parseTemplate } from '../parser.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const PRO_DIR = path.resolve(__dirname, '../../../Pro/extensions');
10
+
11
+ // Signal naming: lowercase, dot-separated (same as validate-signals.js)
12
+ const SIGNAL_NAME_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
13
+
14
+ describe('parseTemplate', () => {
15
+ test('parses template frontmatter correctly', () => {
16
+ const content = [
17
+ '---',
18
+ 'name: test-workflow',
19
+ 'pack: "@rune-pro/product"',
20
+ 'version: "1.0.0"',
21
+ 'description: A test workflow template',
22
+ 'domain: product',
23
+ 'chain: full',
24
+ 'signals:',
25
+ ' emit: product.test.complete',
26
+ ' listen: codebase.scanned',
27
+ 'connections:',
28
+ ' - ba',
29
+ ' - plan',
30
+ ' - brainstorm',
31
+ '---',
32
+ '',
33
+ '# Template: Test Workflow',
34
+ '',
35
+ '## Phases',
36
+ '',
37
+ '### Phase 1: Setup',
38
+ '**Skills**: `rune:ba`',
39
+ ].join('\n');
40
+
41
+ const parsed = parseTemplate(content);
42
+
43
+ assert.strictEqual(parsed.name, 'test-workflow');
44
+ assert.strictEqual(parsed.pack, '@rune-pro/product');
45
+ assert.strictEqual(parsed.version, '1.0.0');
46
+ assert.strictEqual(parsed.domain, 'product');
47
+ assert.strictEqual(parsed.chain, 'full');
48
+ assert.deepStrictEqual(parsed.signals.emit, ['product.test.complete']);
49
+ assert.deepStrictEqual(parsed.signals.listen, ['codebase.scanned']);
50
+ assert.deepStrictEqual(parsed.connections, ['ba', 'plan', 'brainstorm']);
51
+ assert.ok(parsed.sections.has('Phases'));
52
+ });
53
+
54
+ test('handles multiple emit signals', () => {
55
+ const content = [
56
+ '---',
57
+ 'name: multi-emit',
58
+ 'signals:',
59
+ ' emit: signal.one, signal.two, signal.three',
60
+ ' listen: signal.input',
61
+ '---',
62
+ '',
63
+ '# Multi-emit template',
64
+ ].join('\n');
65
+
66
+ const parsed = parseTemplate(content);
67
+ assert.deepStrictEqual(parsed.signals.emit, ['signal.one', 'signal.two', 'signal.three']);
68
+ assert.deepStrictEqual(parsed.signals.listen, ['signal.input']);
69
+ });
70
+
71
+ test('handles no signals gracefully', () => {
72
+ const content = ['---', 'name: no-signals', 'domain: test', '---', '', '# Template without signals'].join('\n');
73
+
74
+ const parsed = parseTemplate(content);
75
+ assert.deepStrictEqual(parsed.signals.emit, []);
76
+ assert.deepStrictEqual(parsed.signals.listen, []);
77
+ });
78
+
79
+ test('handles no connections gracefully', () => {
80
+ const content = ['---', 'name: no-connections', '---', '', '# Template without connections'].join('\n');
81
+
82
+ const parsed = parseTemplate(content);
83
+ assert.deepStrictEqual(parsed.connections, []);
84
+ });
85
+
86
+ test('defaults chain to standard', () => {
87
+ const content = ['---', 'name: default-chain', '---', '', '# Template'].join('\n');
88
+
89
+ const parsed = parseTemplate(content);
90
+ assert.strictEqual(parsed.chain, 'standard');
91
+ });
92
+
93
+ test('extracts cross-references from body', () => {
94
+ const content = [
95
+ '---',
96
+ 'name: with-refs',
97
+ '---',
98
+ '',
99
+ '# Template',
100
+ '',
101
+ 'Uses `rune:ba` and `rune:plan` for setup.',
102
+ ].join('\n');
103
+
104
+ const parsed = parseTemplate(content);
105
+ assert.strictEqual(parsed.crossRefs.length, 2);
106
+ assert.strictEqual(parsed.crossRefs[0].skillName, 'ba');
107
+ assert.strictEqual(parsed.crossRefs[1].skillName, 'plan');
108
+ });
109
+ });
110
+
111
+ describe('YAML list parsing in frontmatter', () => {
112
+ // This tests the parser's ability to handle YAML list items (- item)
113
+ // which was added for template connections support
114
+ test('parses YAML list items under nested key', () => {
115
+ const content = [
116
+ '---',
117
+ 'name: yaml-list-test',
118
+ 'connections:',
119
+ ' - alpha',
120
+ ' - beta',
121
+ ' - gamma-delta',
122
+ '---',
123
+ '',
124
+ '# Test',
125
+ ].join('\n');
126
+
127
+ const parsed = parseTemplate(content);
128
+ assert.deepStrictEqual(parsed.connections, ['alpha', 'beta', 'gamma-delta']);
129
+ });
130
+
131
+ test('YAML list does not break existing key-value nested blocks', () => {
132
+ // Ensure the parser still handles metadata: { key: value } correctly
133
+ const content = [
134
+ '---',
135
+ 'name: skill-test',
136
+ 'description: Test',
137
+ 'metadata:',
138
+ ' layer: L2',
139
+ ' model: sonnet',
140
+ ' emit: code.changed',
141
+ '---',
142
+ '',
143
+ '# skill-test',
144
+ ].join('\n');
145
+
146
+ const parsed = parseSkill(content);
147
+ assert.strictEqual(parsed.name, 'skill-test');
148
+ assert.strictEqual(parsed.layer, 'L2');
149
+ assert.strictEqual(parsed.model, 'sonnet');
150
+ assert.deepStrictEqual(parsed.signals.emit, ['code.changed']);
151
+ });
152
+ });
153
+
154
+ // Skip Pro tier tests when Pro repo is not available (CI)
155
+ const HAS_PRO = existsSync(PRO_DIR);
156
+
157
+ (HAS_PRO ? describe : describe.skip)('Pro template files', () => {
158
+ const packs = ['pro-product', 'pro-data-science', 'pro-sales', 'pro-support'];
159
+
160
+ for (const pack of packs) {
161
+ const templatesDir = path.join(PRO_DIR, pack, 'templates');
162
+
163
+ test(`${pack} has templates directory`, () => {
164
+ assert.ok(existsSync(templatesDir), `${templatesDir} should exist`);
165
+ });
166
+
167
+ // Skip if Pro dir doesn't exist (CI without Pro repo)
168
+ if (!existsSync(templatesDir)) continue;
169
+
170
+ const files = readdirSync(templatesDir).filter((f) => f.endsWith('.md'));
171
+
172
+ for (const file of files) {
173
+ const templatePath = path.join(templatesDir, file);
174
+
175
+ test(`${pack}/${file}: parses without error`, () => {
176
+ const content = readFileSync(templatePath, 'utf-8');
177
+ const parsed = parseTemplate(content, templatePath);
178
+ assert.ok(parsed.name, `template ${file} must have a name`);
179
+ });
180
+
181
+ test(`${pack}/${file}: has required frontmatter fields`, () => {
182
+ const content = readFileSync(templatePath, 'utf-8');
183
+ const parsed = parseTemplate(content, templatePath);
184
+
185
+ assert.ok(parsed.name, 'name is required');
186
+ assert.ok(parsed.pack, 'pack is required');
187
+ assert.ok(parsed.version, 'version is required');
188
+ assert.ok(parsed.description, 'description is required');
189
+ assert.ok(parsed.domain, 'domain is required');
190
+ assert.ok(parsed.chain, 'chain is required');
191
+ });
192
+
193
+ test(`${pack}/${file}: has valid signal names`, () => {
194
+ const content = readFileSync(templatePath, 'utf-8');
195
+ const parsed = parseTemplate(content, templatePath);
196
+
197
+ for (const signal of parsed.signals.emit) {
198
+ assert.ok(SIGNAL_NAME_PATTERN.test(signal), `emit signal "${signal}" must match lowercase.dot.notation`);
199
+ }
200
+ for (const signal of parsed.signals.listen) {
201
+ assert.ok(SIGNAL_NAME_PATTERN.test(signal), `listen signal "${signal}" must match lowercase.dot.notation`);
202
+ }
203
+ });
204
+
205
+ test(`${pack}/${file}: has at least one connection`, () => {
206
+ const content = readFileSync(templatePath, 'utf-8');
207
+ const parsed = parseTemplate(content, templatePath);
208
+ assert.ok(parsed.connections.length > 0, 'template must declare skill connections');
209
+ });
210
+
211
+ test(`${pack}/${file}: body has Phases section`, () => {
212
+ const content = readFileSync(templatePath, 'utf-8');
213
+ const parsed = parseTemplate(content, templatePath);
214
+ assert.ok(parsed.sections.has('Phases'), 'template must have a ## Phases section');
215
+ });
216
+
217
+ test(`${pack}/${file}: body has Acceptance Criteria section`, () => {
218
+ const content = readFileSync(templatePath, 'utf-8');
219
+ const parsed = parseTemplate(content, templatePath);
220
+ assert.ok(parsed.sections.has('Acceptance Criteria'), 'template must have ## Acceptance Criteria');
221
+ });
222
+ }
223
+ }
224
+ });
225
+
226
+ (HAS_PRO ? describe : describe.skip)('template count per pack', () => {
227
+ const expectedCounts = {
228
+ 'pro-product': 3,
229
+ 'pro-data-science': 2,
230
+ 'pro-sales': 2,
231
+ 'pro-support': 2,
232
+ };
233
+
234
+ for (const [pack, expected] of Object.entries(expectedCounts)) {
235
+ test(`${pack} has ${expected} templates`, () => {
236
+ const templatesDir = path.join(PRO_DIR, pack, 'templates');
237
+ if (!existsSync(templatesDir)) {
238
+ assert.fail(`${templatesDir} does not exist`);
239
+ return;
240
+ }
241
+ const files = readdirSync(templatesDir).filter((f) => f.endsWith('.md'));
242
+ assert.strictEqual(files.length, expected, `${pack} should have ${expected} templates, got ${files.length}`);
243
+ });
244
+ }
245
+ });