@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
@@ -1,9 +1,11 @@
1
1
  import assert from 'node:assert';
2
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
4
+ import { platform as osPlatform, tmpdir } from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, test } from 'node:test';
6
- import { detectTiers, formatSetupResult, runSetup } from '../commands/setup.js';
7
+ import { resolveTier } from '../commands/hooks/tiers.js';
8
+ import { detectTiers, formatSetupResult, installTierSkills, runSetup } from '../commands/setup.js';
7
9
 
8
10
  let tmpRoot;
9
11
 
@@ -11,11 +13,11 @@ async function seedClaude(root) {
11
13
  await mkdir(path.join(root, '.claude'), { recursive: true });
12
14
  }
13
15
 
14
- async function seedTier(root, tier) {
15
- const dir = path.join(root, '..', tier === 'pro' ? 'Pro' : 'Business', 'hooks');
16
- await mkdir(dir, { recursive: true });
16
+ async function seedTier(root, tier, opts = {}) {
17
+ const tierRoot = path.join(root, '..', tier === 'pro' ? 'Pro' : 'Business');
18
+ await mkdir(path.join(tierRoot, 'hooks'), { recursive: true });
17
19
  await writeFile(
18
- path.join(dir, 'manifest.json'),
20
+ path.join(tierRoot, 'hooks', 'manifest.json'),
19
21
  JSON.stringify({
20
22
  tier,
21
23
  version: '1.0.0',
@@ -24,9 +26,27 @@ async function seedTier(root, tier) {
24
26
  entries: [],
25
27
  }),
26
28
  );
29
+ if (Array.isArray(opts.skills)) {
30
+ for (const skill of opts.skills) {
31
+ const skillDir = path.join(tierRoot, 'skills', skill);
32
+ await mkdir(skillDir, { recursive: true });
33
+ await writeFile(path.join(skillDir, 'SKILL.md'), `---\nname: ${skill}\n---\n# ${skill}\n`);
34
+ }
35
+ }
36
+ }
37
+
38
+ async function seedFakeRuneRoot(root) {
39
+ const runeRoot = path.join(root, 'fake-rune');
40
+ await mkdir(path.join(runeRoot, 'skills'), { recursive: true });
41
+ return runeRoot;
27
42
  }
28
43
 
29
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;
30
50
  tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-setup-'));
31
51
  await mkdir(path.join(tmpRoot, 'project'), { recursive: true });
32
52
  });
@@ -120,6 +140,256 @@ describe('runSetup (non-interactive)', () => {
120
140
  });
121
141
  });
122
142
 
143
+ describe('installTierSkills', () => {
144
+ test('copies Pro skill directories into runeRoot/skills', async () => {
145
+ const projectRoot = path.join(tmpRoot, 'project');
146
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
147
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot', 'context-inject'] });
148
+ const manifest = await resolveTier('pro', projectRoot);
149
+
150
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
151
+
152
+ assert.strictEqual(result.tier, 'pro');
153
+ assert.deepStrictEqual(result.installed.sort(), ['autopilot', 'context-inject']);
154
+ assert.deepStrictEqual(result.skipped, []);
155
+ assert.strictEqual(result.reason, null);
156
+ assert.ok(existsSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md')));
157
+ assert.ok(existsSync(path.join(runeRoot, 'skills', 'context-inject', 'SKILL.md')));
158
+ });
159
+
160
+ test('skips skills already present (no Free clobber, no edit stomp)', async () => {
161
+ const projectRoot = path.join(tmpRoot, 'project');
162
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
163
+ // Pre-existing skill at target (e.g. Free skill with same name, or prior install)
164
+ await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
165
+ await writeFile(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'), '# existing — must not be overwritten');
166
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
167
+ const manifest = await resolveTier('pro', projectRoot);
168
+
169
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
170
+
171
+ assert.deepStrictEqual(result.installed, []);
172
+ assert.strictEqual(result.skipped.length, 1);
173
+ assert.strictEqual(result.skipped[0].skill, 'autopilot');
174
+ const preserved = readFileSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'), 'utf-8');
175
+ assert.match(preserved, /existing — must not be overwritten/);
176
+ });
177
+
178
+ test('dry mode reports installs without writing', async () => {
179
+ const projectRoot = path.join(tmpRoot, 'project');
180
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
181
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
182
+ const manifest = await resolveTier('pro', projectRoot);
183
+
184
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot, dry: true });
185
+
186
+ assert.deepStrictEqual(result.installed, ['autopilot']);
187
+ assert.ok(!existsSync(path.join(runeRoot, 'skills', 'autopilot')));
188
+ });
189
+
190
+ test('returns reason when tier has no skills/ dir', async () => {
191
+ const projectRoot = path.join(tmpRoot, 'project');
192
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
193
+ await seedTier(projectRoot, 'pro'); // no skills option
194
+ const manifest = await resolveTier('pro', projectRoot);
195
+
196
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
197
+
198
+ assert.deepStrictEqual(result.installed, []);
199
+ assert.match(result.reason, /no skills\//);
200
+ });
201
+
202
+ test('returns reason when runeRoot/skills target missing', async () => {
203
+ const projectRoot = path.join(tmpRoot, 'project');
204
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
205
+ const manifest = await resolveTier('pro', projectRoot);
206
+ const runeRoot = path.join(tmpRoot, 'missing-rune-root');
207
+
208
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
209
+
210
+ assert.deepStrictEqual(result.installed, []);
211
+ assert.match(result.reason, /target skills\/ missing/);
212
+ });
213
+
214
+ test('rejects adversarial skill directory names (path traversal)', async () => {
215
+ // The OS won't let us mkdir literal '..' or names with '/', so the basename
216
+ // guard is unit-tested at the path.basename level. The runtime guard inside
217
+ // installTierSkills uses the same path.basename(name) !== name check.
218
+ assert.strictEqual(path.basename('normal'), 'normal');
219
+ assert.notStrictEqual(path.basename('../escape'), '../escape');
220
+ assert.notStrictEqual(path.basename('a/b'), 'a/b');
221
+ // Integration smoke: a clean install still works after the guard was added
222
+ const projectRoot = path.join(tmpRoot, 'project');
223
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
224
+ await seedTier(projectRoot, 'pro', { skills: ['normal'] });
225
+ const manifest = await resolveTier('pro', projectRoot);
226
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
227
+ assert.deepStrictEqual(result.installed, ['normal']);
228
+ });
229
+
230
+ test('rejects relative tierManifest.source path', async () => {
231
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
232
+ const fakeManifest = {
233
+ tier: 'pro',
234
+ source: './relative/manifest.json', // not absolute
235
+ entries: [],
236
+ };
237
+
238
+ const result = await installTierSkills({ tier: 'pro', tierManifest: fakeManifest, runeRoot });
239
+
240
+ assert.deepStrictEqual(result.installed, []);
241
+ assert.match(result.reason, /must be absolute/);
242
+ });
243
+
244
+ test('skips non-directory entries in skills/ (e.g. stray README.md)', async () => {
245
+ const projectRoot = path.join(tmpRoot, 'project');
246
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
247
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
248
+ // Stray file at sibling of skill dirs — must NOT be installed
249
+ await writeFile(path.join(tmpRoot, 'Pro', 'skills', 'README.md'), '# Pro skills\n');
250
+ const manifest = await resolveTier('pro', projectRoot);
251
+
252
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
253
+
254
+ assert.deepStrictEqual(result.installed, ['autopilot']);
255
+ assert.ok(!existsSync(path.join(runeRoot, 'skills', 'README.md')));
256
+ });
257
+
258
+ test('skip message includes version drift when source > installed', async () => {
259
+ const projectRoot = path.join(tmpRoot, 'project');
260
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
261
+ // Installed v1.0.0 at target
262
+ await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
263
+ await writeFile(
264
+ path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'),
265
+ '---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n# autopilot\n',
266
+ );
267
+ // Seed Pro tier (creates manifest.json) then source skill with v1.5.0
268
+ await seedTier(projectRoot, 'pro');
269
+ await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'autopilot'), { recursive: true });
270
+ await writeFile(
271
+ path.join(tmpRoot, 'Pro', 'skills', 'autopilot', 'SKILL.md'),
272
+ '---\nname: autopilot\nmetadata:\n version: "1.5.0"\n---\n# autopilot\n',
273
+ );
274
+ const manifest = await resolveTier('pro', projectRoot);
275
+
276
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
277
+
278
+ assert.deepStrictEqual(result.installed, []);
279
+ assert.strictEqual(result.skipped.length, 1);
280
+ assert.match(result.skipped[0].reason, /stale: installed v1\.0\.0, source has v1\.5\.0/);
281
+ });
282
+
283
+ test('rejects symlink entries inside skills/ (no sandbox escape)', async (t) => {
284
+ // Symlinks on Windows require elevated privileges or Developer Mode — skip cleanly.
285
+ if (osPlatform() === 'win32') {
286
+ t.skip('symlink creation needs admin/dev-mode on Windows');
287
+ return;
288
+ }
289
+ const projectRoot = path.join(tmpRoot, 'project');
290
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
291
+ await seedTier(projectRoot, 'pro', { skills: ['real-skill'] });
292
+ // Create a malicious symlink targeting /etc inside the Pro skills/ dir
293
+ const escapeTarget = path.join(tmpRoot, 'attacker-target');
294
+ await mkdir(escapeTarget, { recursive: true });
295
+ await writeFile(path.join(escapeTarget, 'SECRET'), 'should not be reachable');
296
+ await symlink(escapeTarget, path.join(tmpRoot, 'Pro', 'skills', 'escape-link'), 'dir');
297
+ const manifest = await resolveTier('pro', projectRoot);
298
+
299
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
300
+
301
+ assert.deepStrictEqual(result.installed, ['real-skill']);
302
+ const rejected = result.skipped.find((s) => s.skill === 'escape-link');
303
+ assert.ok(rejected, 'symlink entry should appear in skipped[]');
304
+ assert.match(rejected.reason, /rejected: symlink/);
305
+ assert.ok(!existsSync(path.join(runeRoot, 'skills', 'escape-link')), 'symlink must not be recreated at target');
306
+ });
307
+
308
+ test('version regex ignores `version:` text inside multiline description', async () => {
309
+ const projectRoot = path.join(tmpRoot, 'project');
310
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
311
+ // Installed v1.0.0
312
+ await mkdir(path.join(runeRoot, 'skills', 'foo'), { recursive: true });
313
+ await writeFile(
314
+ path.join(runeRoot, 'skills', 'foo', 'SKILL.md'),
315
+ '---\nname: foo\nmetadata:\n version: "1.0.0"\n---\n',
316
+ );
317
+ // Source has DECEPTIVE description with `version: 9.9.9` text inside YAML block scalar;
318
+ // real metadata.version is also 1.0.0 → drift detector must report "same version", NOT "stale v9.9.9 → v1.0.0"
319
+ await seedTier(projectRoot, 'pro');
320
+ await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'foo'), { recursive: true });
321
+ await writeFile(
322
+ path.join(tmpRoot, 'Pro', 'skills', 'foo', 'SKILL.md'),
323
+ '---\nname: foo\ndescription: |\n version: 9.9.9 is the legacy format we used to use\nmetadata:\n version: "1.0.0"\n---\n',
324
+ );
325
+ const manifest = await resolveTier('pro', projectRoot);
326
+
327
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
328
+
329
+ // Same version — reason should be "already present (v1.0.0)", NOT a stale-drift message
330
+ assert.strictEqual(result.skipped.length, 1);
331
+ assert.match(result.skipped[0].reason, /already present \(v1\.0\.0\)/);
332
+ assert.doesNotMatch(result.skipped[0].reason, /9\.9\.9/);
333
+ assert.doesNotMatch(result.skipped[0].reason, /stale/);
334
+ });
335
+
336
+ test('skip message reports matching version when source == installed', async () => {
337
+ const projectRoot = path.join(tmpRoot, 'project');
338
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
339
+ await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
340
+ await writeFile(
341
+ path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'),
342
+ '---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n',
343
+ );
344
+ await seedTier(projectRoot, 'pro');
345
+ await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'autopilot'), { recursive: true });
346
+ await writeFile(
347
+ path.join(tmpRoot, 'Pro', 'skills', 'autopilot', 'SKILL.md'),
348
+ '---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n',
349
+ );
350
+ const manifest = await resolveTier('pro', projectRoot);
351
+
352
+ const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
353
+
354
+ assert.match(result.skipped[0].reason, /already present \(v1\.0\.0\)/);
355
+ });
356
+ });
357
+
358
+ describe('runSetup — Pro skill installation (regression: rune:autopilot Unknown skill)', () => {
359
+ test('installs Pro skills into runeRoot/skills as part of setup', async () => {
360
+ const projectRoot = path.join(tmpRoot, 'project');
361
+ await seedClaude(projectRoot);
362
+ await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
363
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
364
+
365
+ const result = await runSetup({
366
+ projectRoot,
367
+ runeRoot,
368
+ args: { here: true, preset: 'gentle', tier: 'pro' },
369
+ });
370
+
371
+ assert.deepStrictEqual(result.tiers, ['pro']);
372
+ assert.strictEqual(result.skillResults.length, 1);
373
+ assert.strictEqual(result.skillResults[0].tier, 'pro');
374
+ assert.deepStrictEqual(result.skillResults[0].installed, ['autopilot']);
375
+ assert.ok(existsSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md')));
376
+ });
377
+
378
+ test('skillResults empty array when no tier selected', async () => {
379
+ const projectRoot = path.join(tmpRoot, 'project');
380
+ await seedClaude(projectRoot);
381
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
382
+
383
+ const result = await runSetup({
384
+ projectRoot,
385
+ runeRoot,
386
+ args: { here: true, preset: 'gentle', 'no-tier': true },
387
+ });
388
+
389
+ assert.deepStrictEqual(result.skillResults, []);
390
+ });
391
+ });
392
+
123
393
  describe('formatSetupResult', () => {
124
394
  test('renders summary with scope and tiers', () => {
125
395
  const out = formatSetupResult({
@@ -149,4 +419,65 @@ describe('formatSetupResult', () => {
149
419
  });
150
420
  assert.match(out, /GLOBAL/);
151
421
  });
422
+
423
+ test('renders skill install summary when skillResults present', () => {
424
+ const out = formatSetupResult({
425
+ scope: 'current',
426
+ targetRoot: '/path/to/project',
427
+ tiers: ['pro'],
428
+ preset: 'gentle',
429
+ platforms: ['claude'],
430
+ written: true,
431
+ notes: [],
432
+ skillResults: [{ tier: 'pro', installed: ['autopilot'], skipped: [], reason: null }],
433
+ });
434
+ assert.match(out, /Skills:.*pro: 1 installed.*autopilot/);
435
+ });
436
+
437
+ test('renders skip + warn lines for tiers with collisions or missing skills/', () => {
438
+ const out = formatSetupResult({
439
+ scope: 'current',
440
+ targetRoot: '/path/to/project',
441
+ tiers: ['pro', 'business'],
442
+ preset: 'gentle',
443
+ platforms: ['claude'],
444
+ written: true,
445
+ notes: [],
446
+ skillResults: [
447
+ { tier: 'pro', installed: [], skipped: [{ skill: 'autopilot', reason: 'already present' }], reason: null },
448
+ { tier: 'business', installed: [], skipped: [], reason: 'no skills/ dir at /tmp/Business/skills' },
449
+ ],
450
+ });
451
+ assert.match(out, /Skipped:.*pro: 1 already present/);
452
+ assert.match(out, /Skill warn:business: no skills\//);
453
+ });
454
+
455
+ test('partitions skipped into benign vs rejected and surfaces rejection details', () => {
456
+ const out = formatSetupResult({
457
+ scope: 'current',
458
+ targetRoot: '/path/to/project',
459
+ tiers: ['pro'],
460
+ preset: 'gentle',
461
+ platforms: ['claude'],
462
+ written: true,
463
+ notes: [],
464
+ skillResults: [
465
+ {
466
+ tier: 'pro',
467
+ installed: ['safe-skill'],
468
+ skipped: [
469
+ { skill: 'autopilot', reason: 'already present (v1.0.0)' },
470
+ { skill: 'evil-link', reason: 'rejected: symlink (would escape sandbox)' },
471
+ { skill: '../escape', reason: 'rejected: unsafe directory name' },
472
+ ],
473
+ reason: null,
474
+ },
475
+ ],
476
+ });
477
+ // Counts partitioned and reported together
478
+ assert.match(out, /Skipped:.*pro: 1 already present, 2 rejected/);
479
+ // Each rejected skill surfaced as a warning line (security visibility)
480
+ assert.match(out, /⚠ evil-link: rejected: symlink/);
481
+ assert.match(out, /⚠ \.\.\/escape: rejected: unsafe directory name/);
482
+ });
152
483
  });
@@ -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]',