@rune-kit/rune 2.18.1 → 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.
- package/README.md +22 -7
- package/compiler/__tests__/comprehension.test.js +916 -0
- package/compiler/__tests__/governance-collector.test.js +376 -0
- package/compiler/__tests__/setup.test.js +5 -0
- package/compiler/analytics.js +5 -0
- package/compiler/bin/rune.js +165 -1
- package/compiler/comprehension-client.js +2348 -0
- package/compiler/comprehension.js +1254 -0
- package/compiler/governance-collector.js +382 -0
- package/compiler/schemas/comprehension.schema.json +87 -0
- package/compiler/schemas/governance.schema.json +78 -0
- package/compiler/transforms/branding.js +1 -1
- package/hooks/context-watch/index.cjs +24 -13
- package/hooks/hooks.json +2 -2
- package/hooks/lib/context-key.cjs +37 -0
- package/hooks/metrics-collector/index.cjs +37 -8
- package/hooks/pre-tool-guard/index.cjs +44 -0
- package/hooks/session-start/index.cjs +4 -10
- package/package.json +1 -1
- package/skills/adversary/SKILL.md +36 -3
- package/skills/adversary/references/cross-model-escalation.md +85 -0
- package/skills/adversary/references/reasoning-modes.md +95 -0
- package/skills/autopsy/SKILL.md +41 -0
- package/skills/ba/SKILL.md +44 -2
- package/skills/ba/references/ears-format.md +91 -0
- package/skills/brainstorm/SKILL.md +32 -7
- package/skills/cook/SKILL.md +8 -1
- package/skills/debug/SKILL.md +4 -2
- package/skills/deploy/SKILL.md +20 -1
- package/skills/deploy/references/observability.md +146 -0
- package/skills/graft/SKILL.md +24 -8
- package/skills/onboard/SKILL.md +45 -0
- package/skills/perf/SKILL.md +4 -1
- 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
|
+
});
|
|
@@ -42,6 +42,11 @@ async function seedFakeRuneRoot(root) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
beforeEach(async () => {
|
|
45
|
+
// Clear tier env vars BEFORE each test so detection-isolation tests are
|
|
46
|
+
// deterministic regardless of the operator's shell (e.g. a dev with
|
|
47
|
+
// RUNE_PRO_ROOT set). afterEach also clears them.
|
|
48
|
+
delete process.env.RUNE_PRO_ROOT;
|
|
49
|
+
delete process.env.RUNE_BUSINESS_ROOT;
|
|
45
50
|
tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-setup-'));
|
|
46
51
|
await mkdir(path.join(tmpRoot, 'project'), { recursive: true });
|
|
47
52
|
});
|
package/compiler/analytics.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Upgrade path: swap readJsonl() with DuckDB queries when data
|
|
8
8
|
* volume exceeds ~1000 sessions (currently capped at 100).
|
|
9
|
+
*
|
|
10
|
+
* NOTE (metric scale): `tool_calls` semantics changed when context-watch widened
|
|
11
|
+
* its matcher from Edit|Write to all tools. Rows written before that change are
|
|
12
|
+
* Edit/Write-scaled (~2-3x lower); trend lines may show a one-time discontinuity.
|
|
13
|
+
* Treat absolute tool_calls comparisons across that boundary with care.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import { existsSync } from 'node:fs';
|
package/compiler/bin/rune.js
CHANGED
|
@@ -25,9 +25,11 @@ import { installHooks } from '../commands/hooks/install.js';
|
|
|
25
25
|
import { hookStatus } from '../commands/hooks/status.js';
|
|
26
26
|
import { uninstallHooks } from '../commands/hooks/uninstall.js';
|
|
27
27
|
import { formatSetupResult, runSetup } from '../commands/setup.js';
|
|
28
|
+
import { generateComprehensionHTML } from '../comprehension.js';
|
|
28
29
|
import { generateDashboardHTML } from '../dashboard.js';
|
|
29
30
|
import { checkMeshIntegrity, formatDoctorResults, formatMeshResults, runDoctor } from '../doctor.js';
|
|
30
31
|
import { buildAll } from '../emitter.js';
|
|
32
|
+
import { assembleGovernance } from '../governance-collector.js';
|
|
31
33
|
import { collectStats, renderStatus, renderStatusJson } from '../status.js';
|
|
32
34
|
import { collectGraphData, generateMeshHTML } from '../visualizer.js';
|
|
33
35
|
|
|
@@ -438,7 +440,7 @@ async function cmdAnalytics(projectRoot, args) {
|
|
|
438
440
|
return;
|
|
439
441
|
}
|
|
440
442
|
|
|
441
|
-
const days = args.days ? parseInt(args.days, 10) : 30;
|
|
443
|
+
const days = Number.isFinite(parseInt(args.days, 10)) ? parseInt(args.days, 10) : 30;
|
|
442
444
|
|
|
443
445
|
logStep('◎', `Querying metrics (${days > 0 ? `${days} days` : 'all time'})...`);
|
|
444
446
|
const data = await getAllAnalytics(projectRoot, days);
|
|
@@ -479,6 +481,163 @@ async function cmdAnalytics(projectRoot, args) {
|
|
|
479
481
|
}
|
|
480
482
|
}
|
|
481
483
|
|
|
484
|
+
// ─── Dashboard Command (Comprehension) ───
|
|
485
|
+
// Access control: intentionally open-to-all for MVP. Tier-gating (e.g. Business-only)
|
|
486
|
+
// is deferred to Phase 5 of the dashboard plan — see docs/VISION.md.
|
|
487
|
+
// Mesh fallback: collectGraphData(projectRoot) returns empty for non-Rune projects by
|
|
488
|
+
// design; the Understand tab prefers the project's own comprehension.json over the
|
|
489
|
+
// Rune mesh, so external projects see an empty graph until they run rune onboard.
|
|
490
|
+
|
|
491
|
+
async function cmdDashboard(projectRoot, args) {
|
|
492
|
+
const runeDir = path.join(projectRoot, '.rune');
|
|
493
|
+
const days = Number.isFinite(parseInt(args.days, 10)) ? parseInt(args.days, 10) : 30;
|
|
494
|
+
|
|
495
|
+
logStep('◎', 'Assembling dashboard data...');
|
|
496
|
+
|
|
497
|
+
// ── Tier detection (reuse existing detectTiers from setup.js) ──
|
|
498
|
+
// Mirrors the pattern used by cmdHooks (line ~317). detectTiers checks:
|
|
499
|
+
// 1. $RUNE_PRO_ROOT / $RUNE_BUSINESS_ROOT env vars
|
|
500
|
+
// 2. sibling monorepo paths (../Pro, ../Business)
|
|
501
|
+
// 3. Well-known developer paths (D:/Project/Rune/Pro etc.)
|
|
502
|
+
// Dashboard generation is always open (no CLI gate) — tier only affects
|
|
503
|
+
// WHICH panels render real data vs honest upsell inside the HTML.
|
|
504
|
+
const { detectTiers } = await import('../commands/setup.js');
|
|
505
|
+
const detectedTiers = detectTiers(projectRoot);
|
|
506
|
+
const hasPro = detectedTiers.pro !== null;
|
|
507
|
+
const hasBusiness = detectedTiers.business !== null;
|
|
508
|
+
const tier = hasBusiness ? 'business' : hasPro ? 'pro' : 'free';
|
|
509
|
+
logStep(
|
|
510
|
+
'◎',
|
|
511
|
+
`Tier: ${tier}${hasPro && !hasBusiness ? ' (Pro detected)' : ''}${hasBusiness ? ' (Business detected)' : ''}`,
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// 1. comprehension.json (optional — onboard/autopsy writes this)
|
|
515
|
+
let comprehensionData = {};
|
|
516
|
+
const comprehensionPath = path.join(runeDir, 'comprehension.json');
|
|
517
|
+
try {
|
|
518
|
+
if (existsSync(comprehensionPath)) {
|
|
519
|
+
comprehensionData = JSON.parse(await readFile(comprehensionPath, 'utf-8'));
|
|
520
|
+
logStep('✓', 'comprehension.json loaded');
|
|
521
|
+
} else {
|
|
522
|
+
logStep('·', 'comprehension.json not found — run rune onboard or rune autopsy to generate it');
|
|
523
|
+
}
|
|
524
|
+
} catch {
|
|
525
|
+
logStep('·', 'comprehension.json unreadable — skipping');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 2. Governance (assembleGovernance collects from metrics + mesh + Business packs)
|
|
529
|
+
let governanceData = { gates: [], signals: [], compliance: [], decisions: [] };
|
|
530
|
+
try {
|
|
531
|
+
governanceData = await assembleGovernance(projectRoot, days);
|
|
532
|
+
logStep('✓', `Governance: ${governanceData.gates.length} gates, ${governanceData.compliance.length} obligations`);
|
|
533
|
+
} catch {
|
|
534
|
+
logStep('·', 'Governance collection failed — using empty defaults');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// 3. Analytics
|
|
538
|
+
let analyticsData = {};
|
|
539
|
+
try {
|
|
540
|
+
analyticsData = await getAllAnalytics(projectRoot, days);
|
|
541
|
+
logStep('✓', `Analytics: ${analyticsData.overview?.total_sessions ?? 0} sessions`);
|
|
542
|
+
} catch {
|
|
543
|
+
logStep('·', 'Analytics collection failed — using empty defaults');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 4. Mesh graph data
|
|
547
|
+
let meshData = { nodes: [], edges: [] };
|
|
548
|
+
try {
|
|
549
|
+
const graphResult = await collectGraphData(projectRoot);
|
|
550
|
+
// collectGraphData returns { nodes, edges, signalEdges, ... }
|
|
551
|
+
meshData = { nodes: graphResult.nodes || [], edges: graphResult.edges || [] };
|
|
552
|
+
logStep('✓', `Mesh: ${meshData.nodes.length} nodes, ${meshData.edges.length} edges`);
|
|
553
|
+
} catch {
|
|
554
|
+
logStep('·', 'Mesh graph collection failed — using empty defaults');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 5. Dashboard profile (optional — persona + pinnedConcerns persisted by UI)
|
|
558
|
+
let profileData = null;
|
|
559
|
+
const profilePath = path.join(runeDir, 'dashboard-profile.json');
|
|
560
|
+
try {
|
|
561
|
+
if (existsSync(profilePath)) {
|
|
562
|
+
profileData = JSON.parse(await readFile(profilePath, 'utf-8'));
|
|
563
|
+
logStep('✓', `Dashboard profile loaded (persona: ${profileData.persona || 'exec'})`);
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
logStep('·', 'dashboard-profile.json unreadable — using defaults');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Merge into one data object
|
|
570
|
+
const data = {
|
|
571
|
+
// From comprehension.json
|
|
572
|
+
project: comprehensionData.project || '',
|
|
573
|
+
generated_at: comprehensionData.generated_at || new Date().toISOString(),
|
|
574
|
+
health_score: comprehensionData.health_score ?? null,
|
|
575
|
+
modules: comprehensionData.modules || [],
|
|
576
|
+
edges: comprehensionData.edges || [],
|
|
577
|
+
layers: comprehensionData.layers || [],
|
|
578
|
+
domains: comprehensionData.domains || [],
|
|
579
|
+
// From governance
|
|
580
|
+
gates: governanceData.gates || [],
|
|
581
|
+
signals: governanceData.signals || [],
|
|
582
|
+
compliance: governanceData.compliance || [],
|
|
583
|
+
decisions: governanceData.decisions || [],
|
|
584
|
+
window_days: days,
|
|
585
|
+
// From analytics — getSkillFrequency returns { skill, sessions_count }; renderMeasure
|
|
586
|
+
// reads .count, so we normalise here to avoid NaN bars.
|
|
587
|
+
overview: analyticsData.overview || {},
|
|
588
|
+
skillFrequency: (analyticsData.skillFrequency || []).map((s) => ({
|
|
589
|
+
skill: s.skill,
|
|
590
|
+
count: s.count ?? s.sessions_count ?? 0,
|
|
591
|
+
})),
|
|
592
|
+
modelDistribution: analyticsData.modelDistribution || [],
|
|
593
|
+
// Phase 5a additions — Measure tab enrichment
|
|
594
|
+
skillHeatmap: analyticsData.skillHeatmap || { heatmap: [], dates: [], maxCount: 1 },
|
|
595
|
+
sessionTimeline: analyticsData.sessionTimeline || [],
|
|
596
|
+
skillChains: analyticsData.skillChains || [],
|
|
597
|
+
// Mesh (for Understand tab fallback when no comprehension.json)
|
|
598
|
+
skillMesh: meshData,
|
|
599
|
+
// Profile (persona + pinnedConcerns) — seeds initial UI state
|
|
600
|
+
profile: profileData,
|
|
601
|
+
// Phase 5b — tier gating
|
|
602
|
+
// tier: 'free' | 'pro' | 'business'
|
|
603
|
+
// hasPro / hasBusiness: booleans used by the renderer to decide which panels to show.
|
|
604
|
+
// Free → Govern shows honest upsell (no real compliance/ledger panels).
|
|
605
|
+
// Pro → unlocks My Lens persona option.
|
|
606
|
+
// Business → Govern fully unlocked (existing scorecard/ledger/compliance/persona/provenance).
|
|
607
|
+
tier,
|
|
608
|
+
hasPro,
|
|
609
|
+
hasBusiness,
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const html = generateComprehensionHTML(data);
|
|
613
|
+
|
|
614
|
+
// Ensure .rune/ exists
|
|
615
|
+
if (!existsSync(runeDir)) {
|
|
616
|
+
const { mkdir: mkdirFs } = await import('node:fs/promises');
|
|
617
|
+
await mkdirFs(runeDir, { recursive: true });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const outputPath = args.output ? path.resolve(projectRoot, args.output) : path.join(runeDir, 'comprehension.html');
|
|
621
|
+
|
|
622
|
+
const { writeFile: writeFileFs } = await import('node:fs/promises');
|
|
623
|
+
await writeFileFs(outputPath, html, 'utf-8');
|
|
624
|
+
logStep('✓', `Dashboard written to ${path.relative(projectRoot, outputPath)}`);
|
|
625
|
+
|
|
626
|
+
// Open in browser (mirror analytics command pattern)
|
|
627
|
+
try {
|
|
628
|
+
const { exec } = await import('node:child_process');
|
|
629
|
+
const cmd =
|
|
630
|
+
process.platform === 'win32'
|
|
631
|
+
? `start "" "${outputPath}"`
|
|
632
|
+
: process.platform === 'darwin'
|
|
633
|
+
? `open "${outputPath}"`
|
|
634
|
+
: `xdg-open "${outputPath}"`;
|
|
635
|
+
exec(cmd);
|
|
636
|
+
} catch {
|
|
637
|
+
/* ignore if browser open fails */
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
482
641
|
// ─── Hook Commands ───
|
|
483
642
|
|
|
484
643
|
async function cmdHooks(projectRoot, args, subcommand) {
|
|
@@ -703,6 +862,9 @@ async function main() {
|
|
|
703
862
|
case 'dash':
|
|
704
863
|
await cmdAnalytics(projectRoot, args);
|
|
705
864
|
break;
|
|
865
|
+
case 'dashboard':
|
|
866
|
+
await cmdDashboard(projectRoot, args);
|
|
867
|
+
break;
|
|
706
868
|
case 'hooks':
|
|
707
869
|
await cmdHooks(projectRoot, args, subcommand);
|
|
708
870
|
break;
|
|
@@ -733,6 +895,8 @@ async function main() {
|
|
|
733
895
|
log(' status Project dashboard (skills, signals, packs, health)');
|
|
734
896
|
log(' visualize Interactive mesh graph (opens in browser)');
|
|
735
897
|
log(' analytics Usage analytics dashboard (Business tier)');
|
|
898
|
+
log(' dashboard Comprehension dashboard — Verdict hero + Govern + Measure + Understand tabs');
|
|
899
|
+
log(' [--days <n>] Lookback window for gate counts (default: 30)');
|
|
736
900
|
log(' hooks Install/uninstall/status for multi-platform auto-discipline');
|
|
737
901
|
log(
|
|
738
902
|
' hooks install [--preset gentle|strict|off] [--platform claude|cursor|windsurf|antigravity|all] [--global]',
|