@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.
Files changed (34) hide show
  1. package/README.md +22 -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 +5 -0
  5. package/compiler/analytics.js +5 -0
  6. package/compiler/bin/rune.js +165 -1
  7. package/compiler/comprehension-client.js +2348 -0
  8. package/compiler/comprehension.js +1254 -0
  9. package/compiler/governance-collector.js +382 -0
  10. package/compiler/schemas/comprehension.schema.json +87 -0
  11. package/compiler/schemas/governance.schema.json +78 -0
  12. package/compiler/transforms/branding.js +1 -1
  13. package/hooks/context-watch/index.cjs +24 -13
  14. package/hooks/hooks.json +2 -2
  15. package/hooks/lib/context-key.cjs +37 -0
  16. package/hooks/metrics-collector/index.cjs +37 -8
  17. package/hooks/pre-tool-guard/index.cjs +44 -0
  18. package/hooks/session-start/index.cjs +4 -10
  19. package/package.json +1 -1
  20. package/skills/adversary/SKILL.md +36 -3
  21. package/skills/adversary/references/cross-model-escalation.md +85 -0
  22. package/skills/adversary/references/reasoning-modes.md +95 -0
  23. package/skills/autopsy/SKILL.md +41 -0
  24. package/skills/ba/SKILL.md +44 -2
  25. package/skills/ba/references/ears-format.md +91 -0
  26. package/skills/brainstorm/SKILL.md +32 -7
  27. package/skills/cook/SKILL.md +8 -1
  28. package/skills/debug/SKILL.md +4 -2
  29. package/skills/deploy/SKILL.md +20 -1
  30. package/skills/deploy/references/observability.md +146 -0
  31. package/skills/graft/SKILL.md +24 -8
  32. package/skills/onboard/SKILL.md +45 -0
  33. package/skills/perf/SKILL.md +4 -1
  34. 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
  });
@@ -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';
@@ -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]',