@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.
- package/README.md +26 -7
- package/compiler/__tests__/comprehension.test.js +916 -0
- package/compiler/__tests__/governance-collector.test.js +376 -0
- package/compiler/__tests__/setup.test.js +338 -7
- package/compiler/analytics.js +5 -0
- package/compiler/bin/rune.js +165 -1
- package/compiler/commands/setup.js +190 -3
- 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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
|
16
|
-
await mkdir(
|
|
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(
|
|
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
|
});
|
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]',
|