@rune-kit/rune 2.18.0 → 2.20.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 (35) hide show
  1. package/README.md +26 -7
  2. package/compiler/__tests__/comprehension.test.js +916 -0
  3. package/compiler/__tests__/governance-collector.test.js +376 -0
  4. package/compiler/__tests__/setup.test.js +338 -7
  5. package/compiler/analytics.js +5 -0
  6. package/compiler/bin/rune.js +165 -1
  7. package/compiler/commands/setup.js +190 -3
  8. package/compiler/comprehension-client.js +2348 -0
  9. package/compiler/comprehension.js +1254 -0
  10. package/compiler/governance-collector.js +382 -0
  11. package/compiler/schemas/comprehension.schema.json +87 -0
  12. package/compiler/schemas/governance.schema.json +78 -0
  13. package/compiler/transforms/branding.js +1 -1
  14. package/hooks/context-watch/index.cjs +24 -13
  15. package/hooks/hooks.json +2 -2
  16. package/hooks/lib/context-key.cjs +37 -0
  17. package/hooks/metrics-collector/index.cjs +37 -8
  18. package/hooks/pre-tool-guard/index.cjs +44 -0
  19. package/hooks/session-start/index.cjs +4 -10
  20. package/package.json +1 -1
  21. package/skills/adversary/SKILL.md +36 -3
  22. package/skills/adversary/references/cross-model-escalation.md +85 -0
  23. package/skills/adversary/references/reasoning-modes.md +95 -0
  24. package/skills/autopsy/SKILL.md +41 -0
  25. package/skills/ba/SKILL.md +44 -2
  26. package/skills/ba/references/ears-format.md +91 -0
  27. package/skills/brainstorm/SKILL.md +32 -7
  28. package/skills/cook/SKILL.md +8 -1
  29. package/skills/debug/SKILL.md +4 -2
  30. package/skills/deploy/SKILL.md +20 -1
  31. package/skills/deploy/references/observability.md +146 -0
  32. package/skills/graft/SKILL.md +24 -8
  33. package/skills/onboard/SKILL.md +45 -0
  34. package/skills/perf/SKILL.md +4 -1
  35. package/skills/review/SKILL.md +4 -2
@@ -0,0 +1,376 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, describe, it } from 'node:test';
6
+ import { assembleGovernance } from '../governance-collector.js';
7
+
8
+ // ─── Test Helpers ───
9
+
10
+ function today() {
11
+ return new Date().toISOString().slice(0, 10);
12
+ }
13
+
14
+ function sessionEntry(overrides = {}) {
15
+ return {
16
+ id: `s-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
17
+ date: today(),
18
+ duration_min: 10,
19
+ tool_calls: 20,
20
+ skill_invocations: 3,
21
+ skills_used: ['cook', 'plan', 'scout'],
22
+ primary_skill: 'cook',
23
+ models_used: { sonnet: 2 },
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ async function setupMetrics(tmpDir, sessions = [], skillTotals = null) {
29
+ const metricsDir = path.join(tmpDir, '.rune', 'metrics');
30
+ await mkdir(metricsDir, { recursive: true });
31
+
32
+ if (sessions.length > 0) {
33
+ const lines = `${sessions.map((s) => JSON.stringify(s)).join('\n')}\n`;
34
+ await writeFile(path.join(metricsDir, 'sessions.jsonl'), lines);
35
+ }
36
+
37
+ if (skillTotals) {
38
+ await writeFile(
39
+ path.join(metricsDir, 'skills.json'),
40
+ JSON.stringify({ version: 1, updated: new Date().toISOString(), skills: skillTotals }),
41
+ );
42
+ }
43
+ }
44
+
45
+ // ─── Tests ───
46
+
47
+ describe('assembleGovernance — empty state', () => {
48
+ let tmpDir;
49
+ beforeEach(async () => {
50
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), 'rune-gov-test-'));
51
+ });
52
+ afterEach(async () => {
53
+ await rm(tmpDir, { recursive: true, force: true });
54
+ });
55
+
56
+ it('returns a valid object shape when no metrics exist', async () => {
57
+ const result = await assembleGovernance(tmpDir, 30);
58
+
59
+ assert.ok(typeof result === 'object' && result !== null, 'should return an object');
60
+ assert.ok(typeof result.generated_at === 'string', 'generated_at should be a string');
61
+ assert.ok(Array.isArray(result.gates), 'gates should be an array');
62
+ assert.ok(Array.isArray(result.signals), 'signals should be an array');
63
+ assert.ok(Array.isArray(result.compliance), 'compliance should be an array');
64
+ assert.ok(Array.isArray(result.decisions), 'decisions should be an array');
65
+ });
66
+
67
+ it('generated_at is a valid ISO date-time string', async () => {
68
+ const result = await assembleGovernance(tmpDir, 30);
69
+ const parsed = new Date(result.generated_at);
70
+ assert.ok(!Number.isNaN(parsed.getTime()), 'generated_at must be parseable as a date');
71
+ });
72
+
73
+ it('window_days reflects the days parameter', async () => {
74
+ const result = await assembleGovernance(tmpDir, 14);
75
+ assert.equal(result.window_days, 14);
76
+ });
77
+
78
+ it('returns empty arrays when no data exists', async () => {
79
+ const result = await assembleGovernance(tmpDir, 30);
80
+ assert.deepEqual(result.gates, []);
81
+ assert.deepEqual(result.decisions, []);
82
+ });
83
+ });
84
+
85
+ describe('assembleGovernance — gate skill counting', () => {
86
+ let tmpDir;
87
+ beforeEach(async () => {
88
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), 'rune-gov-test-'));
89
+ });
90
+ afterEach(async () => {
91
+ await rm(tmpDir, { recursive: true, force: true });
92
+ });
93
+
94
+ it('counts sentinel invocations from sessions', async () => {
95
+ await setupMetrics(tmpDir, [
96
+ sessionEntry({ skills_used: ['cook', 'sentinel', 'plan'] }),
97
+ sessionEntry({ skills_used: ['sentinel', 'preflight'] }),
98
+ sessionEntry({ skills_used: ['cook', 'debug'] }),
99
+ ]);
100
+
101
+ const result = await assembleGovernance(tmpDir, 30);
102
+ const sentinelGate = result.gates.find((g) => g.name === 'sentinel');
103
+ assert.ok(sentinelGate, 'sentinel gate should be in gates[]');
104
+ assert.equal(sentinelGate.fired, 2);
105
+ });
106
+
107
+ it('counts preflight invocations correctly', async () => {
108
+ await setupMetrics(tmpDir, [
109
+ sessionEntry({ skills_used: ['preflight', 'cook'] }),
110
+ sessionEntry({ skills_used: ['preflight'] }),
111
+ ]);
112
+
113
+ const result = await assembleGovernance(tmpDir, 30);
114
+ const preflightGate = result.gates.find((g) => g.name === 'preflight');
115
+ assert.ok(preflightGate, 'preflight gate should be in gates[]');
116
+ assert.equal(preflightGate.fired, 2);
117
+ });
118
+
119
+ it('includes multiple gate skills in one result', async () => {
120
+ await setupMetrics(tmpDir, [
121
+ sessionEntry({
122
+ skills_used: ['sentinel', 'preflight', 'completion-gate', 'logic-guardian', 'cook'],
123
+ }),
124
+ ]);
125
+
126
+ const result = await assembleGovernance(tmpDir, 30);
127
+ const gateNames = result.gates.map((g) => g.name);
128
+ assert.ok(gateNames.includes('sentinel'), 'sentinel should appear');
129
+ assert.ok(gateNames.includes('preflight'), 'preflight should appear');
130
+ assert.ok(gateNames.includes('completion-gate'), 'completion-gate should appear');
131
+ assert.ok(gateNames.includes('logic-guardian'), 'logic-guardian should appear');
132
+ });
133
+
134
+ it('omits non-gate skills from gates[]', async () => {
135
+ await setupMetrics(tmpDir, [sessionEntry({ skills_used: ['cook', 'plan', 'scout', 'debug'] })]);
136
+
137
+ const result = await assembleGovernance(tmpDir, 30);
138
+ const nonGateInGates = result.gates.filter(
139
+ (g) =>
140
+ ![
141
+ 'sentinel',
142
+ 'sentinel-env',
143
+ 'preflight',
144
+ 'completion-gate',
145
+ 'logic-guardian',
146
+ 'constraint-check',
147
+ 'hallucination-guard',
148
+ 'integrity-check',
149
+ ].includes(g.name),
150
+ );
151
+ assert.equal(nonGateInGates.length, 0, 'no non-gate skills should appear in gates[]');
152
+ });
153
+
154
+ it('gate entries have required schema fields', async () => {
155
+ await setupMetrics(tmpDir, [sessionEntry({ skills_used: ['sentinel'] })]);
156
+
157
+ const result = await assembleGovernance(tmpDir, 30);
158
+ const gate = result.gates[0];
159
+ assert.ok(Object.hasOwn(gate, 'name'));
160
+ assert.ok(Object.hasOwn(gate, 'fired'));
161
+ assert.ok(Object.hasOwn(gate, 'passed'));
162
+ assert.ok(Object.hasOwn(gate, 'bypassed'));
163
+ assert.ok(Object.hasOwn(gate, 'blocked'));
164
+ assert.equal(typeof gate.fired, 'number');
165
+ // passed/bypassed remain 0 — not observable at the hook layer (GAP-1)
166
+ assert.equal(gate.passed, 0, 'passed must be 0 (GAP-1: no outcome capture)');
167
+ assert.equal(gate.bypassed, 0, 'bypassed must be 0 (GAP-1: no outcome capture)');
168
+ // blocked IS captured when gate-outcomes.jsonl is present (Phase 3).
169
+ // In this test there is no fixture, so blocked === 0.
170
+ assert.equal(gate.blocked, 0, 'blocked must be 0 when no gate-outcomes.jsonl fixture is present');
171
+ });
172
+
173
+ it('surfaces blocked count from gate-outcomes.jsonl (HIGH-1 regression)', async () => {
174
+ // Arrange: write a gate-outcomes.jsonl with 3 blocked events for "privacy-mesh".
175
+ // "privacy-mesh" is NOT in GATE_SKILLS, so this test specifically exercises the
176
+ // HIGH-1 fix: gateOutcomes.keys() must be included in allGateNames.
177
+ const metricsDir = path.join(tmpDir, '.rune', 'metrics');
178
+ await mkdir(metricsDir, { recursive: true });
179
+
180
+ const ts1 = '2026-06-01T10:00:00.000Z';
181
+ const ts2 = '2026-06-02T11:00:00.000Z';
182
+ const ts3 = '2026-06-03T12:00:00.000Z';
183
+ const lines = `${[
184
+ JSON.stringify({ ts: ts1, gate: 'privacy-mesh', outcome: 'blocked', detail: 'file matched BLOCK-tier' }),
185
+ JSON.stringify({ ts: ts2, gate: 'privacy-mesh', outcome: 'blocked', detail: 'file matched BLOCK-tier' }),
186
+ JSON.stringify({ ts: ts3, gate: 'privacy-mesh', outcome: 'blocked', detail: 'file matched BLOCK-tier' }),
187
+ ].join('\n')}\n`;
188
+ await writeFile(path.join(metricsDir, 'gate-outcomes.jsonl'), lines);
189
+
190
+ // Act
191
+ const result = await assembleGovernance(tmpDir, 30);
192
+
193
+ // Assert: privacy-mesh must appear in gates[] with blocked=3 and a non-null ts
194
+ const pmGate = result.gates.find((g) => g.name === 'privacy-mesh');
195
+ assert.ok(pmGate, 'privacy-mesh gate must appear in gates[] when it has blocked events');
196
+ assert.equal(pmGate.blocked, 3, 'blocked must equal the number of blocked outcome records');
197
+ assert.ok(pmGate.ts !== null && typeof pmGate.ts === 'string', 'ts must be non-null when blocked events exist');
198
+ assert.equal(pmGate.ts, ts3, 'ts must be the most-recent blocked event timestamp');
199
+ // fired and passed/bypassed are 0 — no sessions or skills.json for this gate
200
+ assert.equal(pmGate.fired, 0, 'fired must be 0 when gate has no session/skills.json data');
201
+ });
202
+
203
+ it('reads gate counts from skills.json totals when no windowed sessions', async () => {
204
+ // No sessions — but skills.json says sentinel ran 5 times globally.
205
+ // skills.json uses the REAL producer shape: { skill: { total_invocations, last_used } }.
206
+ await setupMetrics(tmpDir, [], {
207
+ sentinel: { total_invocations: 5, last_used: today() },
208
+ cook: { total_invocations: 10, last_used: today() },
209
+ });
210
+
211
+ const result = await assembleGovernance(tmpDir, 30);
212
+ const sentinelGate = result.gates.find((g) => g.name === 'sentinel');
213
+ assert.ok(sentinelGate, 'sentinel should appear from skills.json totals');
214
+ assert.equal(sentinelGate.fired, 5, 'fired must come from total_invocations');
215
+ assert.ok(sentinelGate.ts, 'ts should be derived from last_used');
216
+ // cook is not a gate skill — should not appear
217
+ const cookGate = result.gates.find((g) => g.name === 'cook');
218
+ assert.equal(cookGate, undefined, 'cook should not appear in gates[]');
219
+ });
220
+ });
221
+
222
+ describe('assembleGovernance — signals from static mesh', () => {
223
+ let tmpDir;
224
+ beforeEach(async () => {
225
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), 'rune-gov-test-'));
226
+ });
227
+ afterEach(async () => {
228
+ await rm(tmpDir, { recursive: true, force: true });
229
+ });
230
+
231
+ it('signals[] is an array (may be empty if skills dir missing)', async () => {
232
+ const result = await assembleGovernance(tmpDir, 30);
233
+ assert.ok(Array.isArray(result.signals));
234
+ });
235
+
236
+ it('does not throw when skills/ dir is missing', async () => {
237
+ // tmpDir has no skills/ — should degrade to []
238
+ const result = await assembleGovernance(tmpDir, 30);
239
+ assert.deepEqual(result.signals, []);
240
+ });
241
+
242
+ it('signal entries have required fields when signals exist', async () => {
243
+ // Create a minimal skills/ dir with one SKILL.md that has signals
244
+ const skillsDir = path.join(tmpDir, 'skills', 'test-skill');
245
+ await mkdir(skillsDir, { recursive: true });
246
+ const skillContent = `---
247
+ name: test-skill
248
+ description: A test skill
249
+ metadata:
250
+ version: "0.1.0"
251
+ layer: L2
252
+ emit: test.signal.done
253
+ listen: test.signal.start
254
+ ---
255
+ # test-skill
256
+ A minimal skill for testing.
257
+ `;
258
+ await writeFile(path.join(skillsDir, 'SKILL.md'), skillContent);
259
+
260
+ const result = await assembleGovernance(tmpDir, 30);
261
+ // Signals array may be empty if the visualizer cannot fully parse, but it should not throw
262
+ assert.ok(Array.isArray(result.signals));
263
+ // If signals populated, verify shape
264
+ for (const sig of result.signals) {
265
+ assert.ok(typeof sig.name === 'string', 'signal must have a name');
266
+ assert.ok(Object.hasOwn(sig, 'count'), 'signal must have count field');
267
+ assert.equal(typeof sig.count, 'number');
268
+ }
269
+ });
270
+ });
271
+
272
+ describe('assembleGovernance — compliance from Business packs', () => {
273
+ // Isolation: a single temp ROOT holds both the Free-side runeRoot and the
274
+ // Business sibling, so the collector's `runeRoot/../Business` resolution works
275
+ // AND the whole tree is cleaned up — never pollutes the shared os.tmpdir().
276
+ let root, runeRoot;
277
+ beforeEach(async () => {
278
+ root = await mkdtemp(path.join(os.tmpdir(), 'rune-gov-root-'));
279
+ runeRoot = path.join(root, 'Free');
280
+ await mkdir(runeRoot, { recursive: true });
281
+ });
282
+ afterEach(async () => {
283
+ await rm(root, { recursive: true, force: true });
284
+ });
285
+
286
+ it('returns empty compliance[] when no Business dir exists', async () => {
287
+ const result = await assembleGovernance(runeRoot, 30);
288
+ assert.deepEqual(result.compliance, []);
289
+ });
290
+
291
+ it('extracts MUST constraints from a Business PACK.md', async () => {
292
+ const bizRoot = path.join(root, 'Business', 'extensions', 'pro-test-pack');
293
+ await mkdir(bizRoot, { recursive: true });
294
+
295
+ const packContent = `---
296
+ name: "@rune-pro/test-pack"
297
+ metadata:
298
+ version: "1.0.0"
299
+ ---
300
+ # @rune-pro/test-pack
301
+
302
+ ## Constraints
303
+
304
+ 1. MUST verify user permissions before returning data
305
+ 2. MUST NOT make changes without approval
306
+ 3. NEVER log sensitive credentials
307
+
308
+ ## Done When
309
+
310
+ - [ ] At least one system connected (verified via health check)
311
+ - [x] All permissions verified
312
+ `;
313
+ await writeFile(path.join(bizRoot, 'PACK.md'), packContent);
314
+
315
+ const result = await assembleGovernance(runeRoot, 30);
316
+
317
+ assert.ok(Array.isArray(result.compliance));
318
+ const mustItems = result.compliance.filter(
319
+ (c) => c.obligation.startsWith('MUST') || c.obligation.startsWith('NEVER'),
320
+ );
321
+ assert.ok(mustItems.length >= 2, 'should extract MUST/NEVER constraints');
322
+
323
+ for (const item of result.compliance) {
324
+ assert.ok(typeof item.pack === 'string');
325
+ assert.ok(typeof item.obligation === 'string');
326
+ assert.ok(['met', 'partial', 'gap', 'unknown'].includes(item.status));
327
+ }
328
+ });
329
+ });
330
+
331
+ describe('assembleGovernance — error resilience', () => {
332
+ let tmpDir;
333
+ beforeEach(async () => {
334
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), 'rune-gov-test-'));
335
+ });
336
+ afterEach(async () => {
337
+ await rm(tmpDir, { recursive: true, force: true });
338
+ });
339
+
340
+ it('does not throw when sessions.jsonl contains invalid JSON lines', async () => {
341
+ const metricsDir = path.join(tmpDir, '.rune', 'metrics');
342
+ await mkdir(metricsDir, { recursive: true });
343
+ await writeFile(
344
+ path.join(metricsDir, 'sessions.jsonl'),
345
+ `${JSON.stringify(sessionEntry({ skills_used: ['sentinel'] }))}\nNOT_JSON\n${JSON.stringify(sessionEntry())}\n`,
346
+ );
347
+
348
+ const result = await assembleGovernance(tmpDir, 30);
349
+ assert.ok(result, 'should not throw on malformed JSONL');
350
+ assert.ok(Array.isArray(result.gates));
351
+ });
352
+
353
+ it('does not throw when skills.json is corrupted', async () => {
354
+ const metricsDir = path.join(tmpDir, '.rune', 'metrics');
355
+ await mkdir(metricsDir, { recursive: true });
356
+ await writeFile(path.join(metricsDir, 'skills.json'), 'NOT_JSON');
357
+
358
+ const result = await assembleGovernance(tmpDir, 30);
359
+ assert.ok(result);
360
+ assert.ok(Array.isArray(result.gates));
361
+ });
362
+
363
+ it('does not throw when .rune/ directory does not exist', async () => {
364
+ // tmpDir exists but has no .rune/ subdirectory
365
+ const result = await assembleGovernance(tmpDir, 30);
366
+ assert.ok(result);
367
+ assert.ok(typeof result.generated_at === 'string');
368
+ assert.deepEqual(result.gates, []);
369
+ });
370
+
371
+ it('decisions[] is always an empty array in Phase 1', async () => {
372
+ await setupMetrics(tmpDir, [sessionEntry({ skills_used: ['sentinel'] })]);
373
+ const result = await assembleGovernance(tmpDir, 30);
374
+ assert.deepEqual(result.decisions, [], 'decisions must be [] until provenance capture is added (GAP-5)');
375
+ });
376
+ });