@rune-kit/rune 2.11.0 → 2.12.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 +58 -1
- package/compiler/__tests__/detect-invariants.test.js +136 -0
- package/compiler/__tests__/doctor-mesh.test.js +229 -0
- package/compiler/__tests__/hook-dispatch.test.js +91 -0
- package/compiler/__tests__/hooks-antigravity.test.js +118 -0
- package/compiler/__tests__/hooks-cursor.test.js +139 -0
- package/compiler/__tests__/hooks-install.test.js +305 -0
- package/compiler/__tests__/hooks-merge.test.js +204 -0
- package/compiler/__tests__/hooks-tiers.test.js +519 -0
- package/compiler/__tests__/hooks-windsurf.test.js +115 -0
- package/compiler/__tests__/inject-claude-md.test.js +152 -0
- package/compiler/__tests__/load-invariants.test.js +408 -0
- package/compiler/__tests__/onboard-invariants.test.js +240 -0
- package/compiler/adapters/hooks/antigravity.js +140 -0
- package/compiler/adapters/hooks/claude.js +166 -0
- package/compiler/adapters/hooks/cursor.js +191 -0
- package/compiler/adapters/hooks/index.js +82 -0
- package/compiler/adapters/hooks/tier-emitter.js +182 -0
- package/compiler/adapters/hooks/windsurf.js +202 -0
- package/compiler/bin/rune.js +196 -6
- package/compiler/commands/hook-dispatch.js +87 -0
- package/compiler/commands/hooks/install.js +120 -0
- package/compiler/commands/hooks/merge.js +211 -0
- package/compiler/commands/hooks/presets.js +116 -0
- package/compiler/commands/hooks/status.js +112 -0
- package/compiler/commands/hooks/tiers.js +221 -0
- package/compiler/commands/hooks/uninstall.js +94 -0
- package/compiler/doctor.js +236 -0
- package/package.json +2 -2
- package/skills/ba/SKILL.md +85 -1
- package/skills/brainstorm/SKILL.md +39 -1
- package/skills/browser-pilot/SKILL.md +1 -0
- package/skills/context-engine/SKILL.md +6 -2
- package/skills/design/SKILL.md +1 -0
- package/skills/docs-seeker/SKILL.md +1 -0
- package/skills/fix/SKILL.md +4 -2
- package/skills/hallucination-guard/SKILL.md +1 -0
- package/skills/journal/SKILL.md +1 -0
- package/skills/logic-guardian/SKILL.md +22 -4
- package/skills/marketing/SKILL.md +62 -1
- package/skills/neural-memory/SKILL.md +13 -16
- package/skills/onboard/SKILL.md +30 -2
- package/skills/onboard/references/invariants-template.md +76 -0
- package/skills/onboard/scripts/detect-invariants.js +439 -0
- package/skills/onboard/scripts/inject-claude-md.js +150 -0
- package/skills/onboard/scripts/onboard-invariants.js +194 -0
- package/skills/perf/SKILL.md +1 -0
- package/skills/plan/SKILL.md +2 -0
- package/skills/preflight/SKILL.md +1 -1
- package/skills/research/SKILL.md +4 -0
- package/skills/review/SKILL.md +4 -2
- package/skills/scope-guard/SKILL.md +4 -1
- package/skills/scout/SKILL.md +6 -0
- package/skills/sentinel/SKILL.md +2 -0
- package/skills/session-bridge/SKILL.md +53 -1
- package/skills/session-bridge/scripts/load-invariants.js +397 -0
- package/skills/slides/SKILL.md +19 -0
- package/skills/team/SKILL.md +2 -1
- package/skills/test/SKILL.md +6 -0
- package/skills/verification/SKILL.md +8 -0
package/README.md
CHANGED
|
@@ -83,7 +83,18 @@ _Methodology: Claude Code CLI headless mode (`claude -p --output-format json`),
|
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
86
|
-
## What's New (v2.
|
|
86
|
+
## What's New (v2.12.0 — Auto-Discipline)
|
|
87
|
+
|
|
88
|
+
- **Runtime auto-discipline** — `rune hooks install` wires native hooks on Claude Code, Cursor, Windsurf, Antigravity so `preflight`, `sentinel`, `completion-gate` auto-fire before tool use. No more "remember to invoke the skill."
|
|
89
|
+
- **Three presets** — `strict` (blocking gates), `gentle` (warnings, default), `off` (uninstall). Idempotent install / uninstall with full restore of user hooks.
|
|
90
|
+
- **Multi-tier hook layering** — `--tier pro` / `--tier business` stack paid-tier hooks on top of Free using a tier-tagged manifest at `$<TIER>_ROOT/hooks/manifest.json`. Free compiler stays tier-agnostic (MIT-clean).
|
|
91
|
+
- **logic-guardian v0.3.0** — `rune init` now auto-seeds `.rune/INVARIANTS.md` with project-detected rules (money math, state machines, payment flows). Preflight reads it as a hard gate.
|
|
92
|
+
- **session-bridge v0.7.0** — emits `context.compact.imminent` signal; cook/plan/team listen and checkpoint work before compaction.
|
|
93
|
+
- **autopilot v1.1.0** (Pro) — honors the hooks manifest; runs overnight with the same blocking gates your interactive sessions enforce.
|
|
94
|
+
- **Security** — tier name sanitization (path-traversal-safe), precise `statusLine` detection (no false-positive uninstall of user commands), `overrides` migration for legacy hook entries.
|
|
95
|
+
- **1,152 tests** — +31 from v2.11.0 covering hooks adapter, tier manifest loader, override migration, and review regressions.
|
|
96
|
+
|
|
97
|
+
### Previous (v2.11.0)
|
|
87
98
|
|
|
88
99
|
- **Mesh integrity** — 8 dead wires fixed, 5 workflow gaps closed (hotfix chain, API versioning, monorepo mode, feature flags, dependency upgrade campaigns)
|
|
89
100
|
- **audit v0.4.0** — DX Review Mode: Addy Osmani's 8 developer experience principles with scoring rubric and browser-pilot integration
|
|
@@ -248,6 +259,52 @@ This compiles all 62 skills into your IDE's rules format. Same knowledge, same w
|
|
|
248
259
|
/rune mcp-builder "weather API with forecast tools"
|
|
249
260
|
```
|
|
250
261
|
|
|
262
|
+
## Auto-Discipline (Claude Code Hooks)
|
|
263
|
+
|
|
264
|
+
Turn Rune skills into ambient runtime — no more `/rune preflight` every time. Install once, skills auto-fire on relevant tool calls:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# Wire Rune quality gates into Claude Code (.claude/settings.json)
|
|
268
|
+
npx @rune-kit/rune hooks install --preset gentle
|
|
269
|
+
|
|
270
|
+
# Preset options:
|
|
271
|
+
# gentle — advisory, never blocks (default)
|
|
272
|
+
# strict — blocks tool call on BLOCK verdict
|
|
273
|
+
# off — uninstall
|
|
274
|
+
|
|
275
|
+
# Inspect current wiring
|
|
276
|
+
npx @rune-kit/rune hooks status
|
|
277
|
+
|
|
278
|
+
# Remove (preserves user-authored hooks)
|
|
279
|
+
npx @rune-kit/rune hooks uninstall
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
What gets wired:
|
|
283
|
+
|
|
284
|
+
| Event | Skill | When it fires |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| PreToolUse(Edit\|Write) | preflight | Before Claude edits source files |
|
|
287
|
+
| PreToolUse(Bash) | sentinel | Before shell commands (catches `git commit`, secrets) |
|
|
288
|
+
| PostToolUse(Edit\|Write) | dependency-doctor | After dependency manifest edits |
|
|
289
|
+
| Stop | completion-gate | End of session — validates claims against evidence |
|
|
290
|
+
|
|
291
|
+
Rune only manages entries tagged with its command signature. User-authored hooks in the same events are preserved on install/uninstall.
|
|
292
|
+
|
|
293
|
+
### Stacking paid tiers (Pro, Business)
|
|
294
|
+
|
|
295
|
+
Paid tiers ship their own `hooks/manifest.json`. Point Rune at the install root and pass `--tier`:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
export RUNE_PRO_ROOT=~/rune-pro
|
|
299
|
+
rune hooks install --preset gentle --tier pro
|
|
300
|
+
|
|
301
|
+
# Stack Free + Pro + Business in one command
|
|
302
|
+
export RUNE_BUSINESS_ROOT=~/rune-business
|
|
303
|
+
rune hooks install --preset gentle --tier pro --tier business
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Multi-platform: tier hooks compile to Claude Code, Cursor, Windsurf, and Antigravity with the same command — no Claude-only lock-in.
|
|
307
|
+
|
|
251
308
|
## Architecture
|
|
252
309
|
|
|
253
310
|
### 5-Layer Model
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { after, before, describe, test } from 'node:test';
|
|
6
|
+
import { detectInvariants, renderInvariants } from '../../skills/onboard/scripts/detect-invariants.js';
|
|
7
|
+
|
|
8
|
+
async function scaffold(files) {
|
|
9
|
+
const root = await mkdtemp(path.join(tmpdir(), 'rune-invariants-'));
|
|
10
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
11
|
+
const abs = path.join(root, rel);
|
|
12
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
13
|
+
await writeFile(abs, content, 'utf8');
|
|
14
|
+
}
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('detectInvariants — danger zones', () => {
|
|
19
|
+
test('scores deeply-nested source directories as danger zones', async () => {
|
|
20
|
+
const files = {};
|
|
21
|
+
for (let i = 0; i < 12; i++) {
|
|
22
|
+
files[`src/auth/sub/file${i}.ts`] = `export const x${i} = ${i};\n`;
|
|
23
|
+
}
|
|
24
|
+
files['src/auth/session.ts'] = 'export const SESSION_TTL = 900;\n';
|
|
25
|
+
const root = await scaffold(files);
|
|
26
|
+
try {
|
|
27
|
+
const result = await detectInvariants({ root });
|
|
28
|
+
const danger = result.danger.map((r) => r.where[0]);
|
|
29
|
+
assert.ok(
|
|
30
|
+
danger.some((d) => d.startsWith('src/auth')),
|
|
31
|
+
`expected src/auth in danger, got: ${JSON.stringify(danger)}`,
|
|
32
|
+
);
|
|
33
|
+
} finally {
|
|
34
|
+
await rm(root, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('ignores node_modules and .git', async () => {
|
|
39
|
+
const files = {};
|
|
40
|
+
for (let i = 0; i < 20; i++) {
|
|
41
|
+
files[`node_modules/pkg/file${i}.js`] = '// noise\n';
|
|
42
|
+
}
|
|
43
|
+
files['src/app.ts'] = 'export const A = 1;\n';
|
|
44
|
+
const root = await scaffold(files);
|
|
45
|
+
try {
|
|
46
|
+
const result = await detectInvariants({ root });
|
|
47
|
+
const danger = result.danger.map((r) => r.where[0]);
|
|
48
|
+
assert.ok(!danger.some((d) => d.includes('node_modules')), 'node_modules must not appear');
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(root, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('detectInvariants — critical constants', () => {
|
|
56
|
+
test('flags SCREAMING_SNAKE constants used in 3+ files', async () => {
|
|
57
|
+
const files = {
|
|
58
|
+
'src/config.ts': 'export const MAX_RETRIES = 3;\n',
|
|
59
|
+
'src/a.ts': 'import { MAX_RETRIES } from "./config";\nconsole.log(MAX_RETRIES);\n',
|
|
60
|
+
'src/b.ts': 'import { MAX_RETRIES } from "./config";\nif (MAX_RETRIES > 0) {}\n',
|
|
61
|
+
'src/c.ts': 'import { MAX_RETRIES } from "./config";\nconst x = MAX_RETRIES;\n',
|
|
62
|
+
};
|
|
63
|
+
const root = await scaffold(files);
|
|
64
|
+
try {
|
|
65
|
+
const result = await detectInvariants({ root });
|
|
66
|
+
const found = result.critical.some(
|
|
67
|
+
(r) => (r.title ?? '').includes('MAX_RETRIES') || (r.what ?? '').includes('MAX_RETRIES'),
|
|
68
|
+
);
|
|
69
|
+
assert.ok(
|
|
70
|
+
found,
|
|
71
|
+
`expected MAX_RETRIES in critical, got titles: ${JSON.stringify(result.critical.map((r) => r.title))}`,
|
|
72
|
+
);
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(root, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('does NOT flag constants used in fewer than 3 places', async () => {
|
|
79
|
+
const files = {
|
|
80
|
+
'src/config.ts': 'export const LONELY = 1;\n',
|
|
81
|
+
'src/a.ts': 'import { LONELY } from "./config";\n',
|
|
82
|
+
};
|
|
83
|
+
const root = await scaffold(files);
|
|
84
|
+
try {
|
|
85
|
+
const result = await detectInvariants({ root });
|
|
86
|
+
const found = result.critical.some((r) => (r.title ?? '').includes('LONELY'));
|
|
87
|
+
assert.strictEqual(found, false, 'LONELY should not be flagged with only 2 usages');
|
|
88
|
+
} finally {
|
|
89
|
+
await rm(root, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('renderInvariants', () => {
|
|
95
|
+
test('produces markdown headings per non-empty bucket', () => {
|
|
96
|
+
const result = {
|
|
97
|
+
danger: [{ title: 'src/auth', what: '12 files', where: ['src/auth/**'], why: 'high churn' }],
|
|
98
|
+
critical: [],
|
|
99
|
+
state: [],
|
|
100
|
+
cross: [],
|
|
101
|
+
stats: { filesScanned: 10 },
|
|
102
|
+
};
|
|
103
|
+
const md = renderInvariants(result);
|
|
104
|
+
assert.ok(md.includes('### Danger Zones'));
|
|
105
|
+
assert.ok(md.includes('#### src/auth'));
|
|
106
|
+
assert.ok(md.includes('- **WHAT**: 12 files'));
|
|
107
|
+
assert.ok(!md.includes('### Critical Invariants'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('falls back to placeholder when empty', () => {
|
|
111
|
+
const md = renderInvariants({ danger: [], critical: [], state: [], cross: [] });
|
|
112
|
+
assert.match(md, /No invariants detected/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('detectInvariants — performance', () => {
|
|
117
|
+
let root;
|
|
118
|
+
before(async () => {
|
|
119
|
+
const files = {};
|
|
120
|
+
for (let i = 0; i < 120; i++) {
|
|
121
|
+
files[`src/mod${i % 12}/file${i}.ts`] = `export const FOO_${i} = ${i};\n`;
|
|
122
|
+
}
|
|
123
|
+
root = await scaffold(files);
|
|
124
|
+
});
|
|
125
|
+
after(async () => {
|
|
126
|
+
await rm(root, { recursive: true, force: true });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('completes under 5 seconds for a 120-file project', async () => {
|
|
130
|
+
const start = Date.now();
|
|
131
|
+
const result = await detectInvariants({ root });
|
|
132
|
+
const elapsed = Date.now() - start;
|
|
133
|
+
assert.ok(elapsed < 5000, `took ${elapsed}ms, expected <5000ms`);
|
|
134
|
+
assert.ok(result.stats.filesScanned >= 100, `scanned ${result.stats.filesScanned} files`);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for doctor mesh integrity checks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { afterEach, beforeEach, describe, test } from 'node:test';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { checkMeshIntegrity, formatMeshResults } from '../doctor.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const TEST_ROOT = path.join(__dirname, '.test-mesh-skills');
|
|
14
|
+
|
|
15
|
+
describe('checkMeshIntegrity', () => {
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
await mkdir(path.join(TEST_ROOT, 'skills', 'skill-a'), { recursive: true });
|
|
18
|
+
await mkdir(path.join(TEST_ROOT, 'skills', 'skill-b'), { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await rm(TEST_ROOT, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('detects missing reciprocal connections', async () => {
|
|
26
|
+
// skill-a calls skill-b, but skill-b doesn't acknowledge
|
|
27
|
+
await writeFile(
|
|
28
|
+
path.join(TEST_ROOT, 'skills', 'skill-a', 'SKILL.md'),
|
|
29
|
+
`---
|
|
30
|
+
name: skill-a
|
|
31
|
+
metadata:
|
|
32
|
+
version: "1.0.0"
|
|
33
|
+
---
|
|
34
|
+
# skill-a
|
|
35
|
+
## Calls (outbound)
|
|
36
|
+
- \`skill-b\` (L2): some reason
|
|
37
|
+
## Called By (inbound)
|
|
38
|
+
None
|
|
39
|
+
## Sharp Edges
|
|
40
|
+
| Mode | Sev | Mit |
|
|
41
|
+
## Done When
|
|
42
|
+
- done
|
|
43
|
+
## Cost Profile
|
|
44
|
+
~100 tokens
|
|
45
|
+
`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await writeFile(
|
|
49
|
+
path.join(TEST_ROOT, 'skills', 'skill-b', 'SKILL.md'),
|
|
50
|
+
`---
|
|
51
|
+
name: skill-b
|
|
52
|
+
metadata:
|
|
53
|
+
version: "1.0.0"
|
|
54
|
+
---
|
|
55
|
+
# skill-b
|
|
56
|
+
## Calls (outbound)
|
|
57
|
+
None
|
|
58
|
+
## Called By (inbound)
|
|
59
|
+
None
|
|
60
|
+
## Sharp Edges
|
|
61
|
+
| Mode | Sev | Mit |
|
|
62
|
+
## Done When
|
|
63
|
+
- done
|
|
64
|
+
## Cost Profile
|
|
65
|
+
~100 tokens
|
|
66
|
+
`,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const results = await checkMeshIntegrity(TEST_ROOT);
|
|
70
|
+
|
|
71
|
+
assert.strictEqual(results.stats.skills, 2, 'Should find 2 skills');
|
|
72
|
+
assert.strictEqual(results.stats.missingReciprocals, 1, 'Should detect 1 missing reciprocal');
|
|
73
|
+
assert.ok(
|
|
74
|
+
results.warnings.some((w) => w.includes('skill-b') && w.includes('skill-a')),
|
|
75
|
+
'Should warn that skill-b is missing skill-a in Called By',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('passes when reciprocals are correct', async () => {
|
|
80
|
+
await writeFile(
|
|
81
|
+
path.join(TEST_ROOT, 'skills', 'skill-a', 'SKILL.md'),
|
|
82
|
+
`---
|
|
83
|
+
name: skill-a
|
|
84
|
+
metadata:
|
|
85
|
+
version: "1.0.0"
|
|
86
|
+
---
|
|
87
|
+
# skill-a
|
|
88
|
+
## Calls (outbound)
|
|
89
|
+
- \`skill-b\` (L2): some reason
|
|
90
|
+
## Called By (inbound)
|
|
91
|
+
None
|
|
92
|
+
## Sharp Edges
|
|
93
|
+
| Mode | Sev | Mit |
|
|
94
|
+
## Done When
|
|
95
|
+
- done
|
|
96
|
+
## Cost Profile
|
|
97
|
+
~100 tokens
|
|
98
|
+
`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await writeFile(
|
|
102
|
+
path.join(TEST_ROOT, 'skills', 'skill-b', 'SKILL.md'),
|
|
103
|
+
`---
|
|
104
|
+
name: skill-b
|
|
105
|
+
metadata:
|
|
106
|
+
version: "1.0.0"
|
|
107
|
+
---
|
|
108
|
+
# skill-b
|
|
109
|
+
## Calls (outbound)
|
|
110
|
+
None
|
|
111
|
+
## Called By (inbound)
|
|
112
|
+
- \`skill-a\` (L2): caller
|
|
113
|
+
## Sharp Edges
|
|
114
|
+
| Mode | Sev | Mit |
|
|
115
|
+
## Done When
|
|
116
|
+
- done
|
|
117
|
+
## Cost Profile
|
|
118
|
+
~100 tokens
|
|
119
|
+
`,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const results = await checkMeshIntegrity(TEST_ROOT);
|
|
123
|
+
|
|
124
|
+
assert.strictEqual(results.stats.missingReciprocals, 0, 'Should have no missing reciprocals');
|
|
125
|
+
assert.strictEqual(
|
|
126
|
+
results.checks.find((c) => c.name === 'Reciprocal connections')?.status,
|
|
127
|
+
'pass',
|
|
128
|
+
'Reciprocal check should pass',
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('suggests version bump for mature skills', async () => {
|
|
133
|
+
await writeFile(
|
|
134
|
+
path.join(TEST_ROOT, 'skills', 'skill-a', 'SKILL.md'),
|
|
135
|
+
`---
|
|
136
|
+
name: skill-a
|
|
137
|
+
metadata:
|
|
138
|
+
version: "0.9.0"
|
|
139
|
+
---
|
|
140
|
+
# skill-a
|
|
141
|
+
## Calls (outbound)
|
|
142
|
+
None
|
|
143
|
+
## Called By (inbound)
|
|
144
|
+
None
|
|
145
|
+
## Sharp Edges
|
|
146
|
+
| Mode | Sev | Mit |
|
|
147
|
+
## Done When
|
|
148
|
+
- done
|
|
149
|
+
## Cost Profile
|
|
150
|
+
~100 tokens
|
|
151
|
+
## Returns
|
|
152
|
+
| Artifact | Format | Location |
|
|
153
|
+
`,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const results = await checkMeshIntegrity(TEST_ROOT);
|
|
157
|
+
|
|
158
|
+
assert.ok(
|
|
159
|
+
results.warnings.some((w) => w.includes('skill-a') && w.includes('1.0')),
|
|
160
|
+
'Should suggest promoting to 1.0',
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('detects missing required sections', async () => {
|
|
165
|
+
await writeFile(
|
|
166
|
+
path.join(TEST_ROOT, 'skills', 'skill-a', 'SKILL.md'),
|
|
167
|
+
`---
|
|
168
|
+
name: skill-a
|
|
169
|
+
metadata:
|
|
170
|
+
version: "1.0.0"
|
|
171
|
+
---
|
|
172
|
+
# skill-a
|
|
173
|
+
## Calls (outbound)
|
|
174
|
+
None
|
|
175
|
+
## Called By (inbound)
|
|
176
|
+
None
|
|
177
|
+
`,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const results = await checkMeshIntegrity(TEST_ROOT);
|
|
181
|
+
|
|
182
|
+
assert.ok(
|
|
183
|
+
results.warnings.some((w) => w.includes('skill-a') && w.includes('Sharp Edges')),
|
|
184
|
+
'Should warn about missing Sharp Edges',
|
|
185
|
+
);
|
|
186
|
+
assert.ok(
|
|
187
|
+
results.warnings.some((w) => w.includes('skill-a') && w.includes('Done When')),
|
|
188
|
+
'Should warn about missing Done When',
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('formatMeshResults', () => {
|
|
194
|
+
test('formats results for console output', () => {
|
|
195
|
+
const results = {
|
|
196
|
+
checks: [
|
|
197
|
+
{ name: 'Reciprocal connections', status: 'pass' },
|
|
198
|
+
{ name: 'Version maturity', status: 'warn', detail: '2 skills ready for 1.0' },
|
|
199
|
+
],
|
|
200
|
+
warnings: ['skill-a v0.9.0: consider promoting to 1.0'],
|
|
201
|
+
errors: [],
|
|
202
|
+
stats: { skills: 10, connections: 50, missingReciprocals: 0 },
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const output = formatMeshResults(results);
|
|
206
|
+
|
|
207
|
+
assert.ok(output.includes('Skills: 10'), 'Should show skill count');
|
|
208
|
+
assert.ok(output.includes('Connections: 50'), 'Should show connection count');
|
|
209
|
+
assert.ok(output.includes('[✓] Reciprocal'), 'Should show pass icon');
|
|
210
|
+
assert.ok(output.includes('[!] Version'), 'Should show warn icon');
|
|
211
|
+
assert.ok(output.includes('skill-a v0.9.0'), 'Should include warnings');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('integration: real skills', () => {
|
|
216
|
+
test('runs mesh check on actual Rune skills directory', async () => {
|
|
217
|
+
const runeRoot = path.resolve(__dirname, '../..');
|
|
218
|
+
const results = await checkMeshIntegrity(runeRoot);
|
|
219
|
+
|
|
220
|
+
// Should find skills
|
|
221
|
+
assert.ok(results.stats.skills > 50, `Should find 50+ skills, got ${results.stats.skills}`);
|
|
222
|
+
|
|
223
|
+
// Should find connections
|
|
224
|
+
assert.ok(results.stats.connections > 100, `Should find 100+ connections, got ${results.stats.connections}`);
|
|
225
|
+
|
|
226
|
+
// Should not have errors
|
|
227
|
+
assert.strictEqual(results.errors.length, 0, `Should have no errors: ${results.errors.join(', ')}`);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { describe, test } from 'node:test';
|
|
4
|
+
import { dispatchHook } from '../commands/hook-dispatch.js';
|
|
5
|
+
|
|
6
|
+
function makeIO({ stdinData = '' } = {}) {
|
|
7
|
+
const stdin = new PassThrough();
|
|
8
|
+
stdin.setEncoding = () => {};
|
|
9
|
+
stdin.isTTY = false;
|
|
10
|
+
if (stdinData) {
|
|
11
|
+
stdin.write(stdinData);
|
|
12
|
+
}
|
|
13
|
+
stdin.end();
|
|
14
|
+
|
|
15
|
+
const stdout = new PassThrough();
|
|
16
|
+
const stderr = new PassThrough();
|
|
17
|
+
let out = '';
|
|
18
|
+
let err = '';
|
|
19
|
+
stdout.on('data', (chunk) => {
|
|
20
|
+
out += chunk.toString();
|
|
21
|
+
});
|
|
22
|
+
stderr.on('data', (chunk) => {
|
|
23
|
+
err += chunk.toString();
|
|
24
|
+
});
|
|
25
|
+
return { stdin, stdout, stderr, getOut: () => out, getErr: () => err };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('dispatchHook', () => {
|
|
29
|
+
test('unknown skill exits 1 in strict mode', async () => {
|
|
30
|
+
const io = makeIO();
|
|
31
|
+
const code = await dispatchHook(['evil-skill'], io);
|
|
32
|
+
assert.strictEqual(code, 1);
|
|
33
|
+
assert.match(io.getErr(), /unknown skill/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('unknown skill exits 0 in gentle mode', async () => {
|
|
37
|
+
const io = makeIO();
|
|
38
|
+
const code = await dispatchHook(['evil-skill', '--gentle'], io);
|
|
39
|
+
assert.strictEqual(code, 0);
|
|
40
|
+
assert.match(io.getErr(), /unknown skill/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('missing skill name errors', async () => {
|
|
44
|
+
const io = makeIO();
|
|
45
|
+
const code = await dispatchHook([], io);
|
|
46
|
+
assert.strictEqual(code, 1);
|
|
47
|
+
assert.match(io.getErr(), /missing skill/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('known skill succeeds and emits advisory line', async () => {
|
|
51
|
+
const io = makeIO({ stdinData: JSON.stringify({ tool_name: 'Edit' }) });
|
|
52
|
+
const code = await dispatchHook(['preflight', '--gentle'], io);
|
|
53
|
+
assert.strictEqual(code, 0);
|
|
54
|
+
assert.match(io.getOut(), /rune-hook: preflight/);
|
|
55
|
+
assert.match(io.getOut(), /advisory/);
|
|
56
|
+
assert.match(io.getOut(), /Edit/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('strict mode prints enforcing label', async () => {
|
|
60
|
+
const io = makeIO({ stdinData: '{}' });
|
|
61
|
+
const code = await dispatchHook(['sentinel'], io);
|
|
62
|
+
assert.strictEqual(code, 0);
|
|
63
|
+
assert.match(io.getOut(), /enforcing/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('malformed stdin does not crash', async () => {
|
|
67
|
+
const io = makeIO({ stdinData: 'not json at all' });
|
|
68
|
+
const code = await dispatchHook(['completion-gate', '--gentle'], io);
|
|
69
|
+
assert.strictEqual(code, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// T5: invalid skill name (not in allowlist) → exits non-zero, stderr contains rejected name
|
|
73
|
+
test('T5: skill not in allowlist exits 1 in strict mode with name in stderr', async () => {
|
|
74
|
+
const io = makeIO();
|
|
75
|
+
const code = await dispatchHook(['totally-fake-skill'], io);
|
|
76
|
+
assert.strictEqual(code, 1);
|
|
77
|
+
assert.ok(io.getErr().includes('totally-fake-skill'), 'stderr must contain the rejected skill name');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// T6: stdin larger than 1MB → truncates or errors gracefully (no OOM)
|
|
81
|
+
test('T6: stdin larger than 1MB is rejected gracefully', async () => {
|
|
82
|
+
// Build a payload just over the 1MB limit
|
|
83
|
+
const big = 'x'.repeat(1_100_000);
|
|
84
|
+
const io = makeIO({ stdinData: big });
|
|
85
|
+
// Should not throw — must return an exit code (0 or 1, gentle or strict)
|
|
86
|
+
const code = await dispatchHook(['preflight', '--gentle'], io);
|
|
87
|
+
assert.ok(typeof code === 'number', 'must return a numeric exit code');
|
|
88
|
+
// gentle mode: still exits 0 even if stdin was discarded
|
|
89
|
+
assert.strictEqual(code, 0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { afterEach, beforeEach, describe, test } from 'node:test';
|
|
7
|
+
import * as antigravity from '../adapters/hooks/antigravity.js';
|
|
8
|
+
import { installHooks } from '../commands/hooks/install.js';
|
|
9
|
+
import { hookStatus } from '../commands/hooks/status.js';
|
|
10
|
+
import { uninstallHooks } from '../commands/hooks/uninstall.js';
|
|
11
|
+
|
|
12
|
+
const RUNE_ROOT = path.resolve(import.meta.dirname, '..', '..');
|
|
13
|
+
const RULES_DIR = '.antigravity/rules';
|
|
14
|
+
|
|
15
|
+
let tmpRoot;
|
|
16
|
+
|
|
17
|
+
async function seedAntigravity(root) {
|
|
18
|
+
await mkdir(path.join(root, '.antigravity'), { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-antigravity-'));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('antigravity adapter', () => {
|
|
30
|
+
test('detect() true when .antigravity exists', async () => {
|
|
31
|
+
await seedAntigravity(tmpRoot);
|
|
32
|
+
assert.strictEqual(antigravity.detect(tmpRoot), true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('emit(gentle) returns 3 rule markdowns with WARN mode', async () => {
|
|
36
|
+
const plan = await antigravity.emit({ preset: 'gentle', projectRoot: tmpRoot });
|
|
37
|
+
assert.strictEqual(plan.files.length, 3);
|
|
38
|
+
for (const file of plan.files) {
|
|
39
|
+
assert.ok(file.content.includes('rune-managed: true'));
|
|
40
|
+
assert.ok(file.content.includes('mode: WARN'));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('emit(strict) renders BLOCK mode', async () => {
|
|
45
|
+
const plan = await antigravity.emit({ preset: 'strict', projectRoot: tmpRoot });
|
|
46
|
+
for (const file of plan.files) {
|
|
47
|
+
assert.ok(file.content.includes('mode: BLOCK'));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('emit() rejects invalid preset', async () => {
|
|
52
|
+
await assert.rejects(antigravity.emit({ preset: 'weird', projectRoot: tmpRoot }), /invalid preset/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('emit() notes call out experimental status', async () => {
|
|
56
|
+
const plan = await antigravity.emit({ preset: 'gentle', projectRoot: tmpRoot });
|
|
57
|
+
assert.ok(plan.notes.some((n) => n.toLowerCase().includes('experimental')));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('install writes rule files under .antigravity/rules/', async () => {
|
|
61
|
+
await seedAntigravity(tmpRoot);
|
|
62
|
+
const result = await installHooks(tmpRoot, { preset: 'gentle', platform: 'antigravity' });
|
|
63
|
+
assert.strictEqual(result.written, true);
|
|
64
|
+
const files = await readdir(path.join(tmpRoot, RULES_DIR));
|
|
65
|
+
assert.ok(files.includes('rune-preflight.md'));
|
|
66
|
+
assert.ok(files.includes('rune-sentinel.md'));
|
|
67
|
+
assert.ok(files.includes('rune-dependency-doctor.md'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('idempotent re-install', async () => {
|
|
71
|
+
await seedAntigravity(tmpRoot);
|
|
72
|
+
await installHooks(tmpRoot, { preset: 'gentle', platform: 'antigravity' });
|
|
73
|
+
const first = await readFile(path.join(tmpRoot, RULES_DIR, 'rune-preflight.md'), 'utf-8');
|
|
74
|
+
await installHooks(tmpRoot, { preset: 'gentle', platform: 'antigravity' });
|
|
75
|
+
const second = await readFile(path.join(tmpRoot, RULES_DIR, 'rune-preflight.md'), 'utf-8');
|
|
76
|
+
assert.strictEqual(first, second);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('uninstall removes Rune rules, preserves user rules', async () => {
|
|
80
|
+
await seedAntigravity(tmpRoot);
|
|
81
|
+
const rulesDir = path.join(tmpRoot, RULES_DIR);
|
|
82
|
+
await mkdir(rulesDir, { recursive: true });
|
|
83
|
+
await writeFile(path.join(rulesDir, 'user-rule.md'), '# User rule\n', 'utf-8');
|
|
84
|
+
|
|
85
|
+
await installHooks(tmpRoot, { preset: 'gentle', platform: 'antigravity' });
|
|
86
|
+
await uninstallHooks(tmpRoot, { platform: 'antigravity' });
|
|
87
|
+
|
|
88
|
+
const remaining = await readdir(rulesDir);
|
|
89
|
+
assert.ok(remaining.includes('user-rule.md'), 'user rule must survive');
|
|
90
|
+
assert.ok(!remaining.some((f) => f.startsWith('rune-')), 'no rune-* files should remain');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('uninstall leaves non-managed rune-* files alone', async () => {
|
|
94
|
+
await seedAntigravity(tmpRoot);
|
|
95
|
+
const rulesDir = path.join(tmpRoot, RULES_DIR);
|
|
96
|
+
await mkdir(rulesDir, { recursive: true });
|
|
97
|
+
await writeFile(path.join(rulesDir, 'rune-fake.md'), '---\ndescription: imposter\n---\n\nnot ours\n', 'utf-8');
|
|
98
|
+
|
|
99
|
+
await uninstallHooks(tmpRoot, { platform: 'antigravity' });
|
|
100
|
+
assert.ok(existsSync(path.join(rulesDir, 'rune-fake.md')));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('status after install reports wired skills', async () => {
|
|
104
|
+
await seedAntigravity(tmpRoot);
|
|
105
|
+
await installHooks(tmpRoot, { preset: 'strict', platform: 'antigravity' });
|
|
106
|
+
const result = await hookStatus(tmpRoot, RUNE_ROOT, { platform: 'antigravity' });
|
|
107
|
+
const r = result.results.find((x) => x.platform === 'antigravity');
|
|
108
|
+
assert.strictEqual(r.installed, true);
|
|
109
|
+
assert.strictEqual(r.preset, 'strict');
|
|
110
|
+
assert.ok(r.wired.includes('preflight'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('status reports not installed when .antigravity missing', async () => {
|
|
114
|
+
const result = await hookStatus(tmpRoot, RUNE_ROOT, { platform: 'antigravity' });
|
|
115
|
+
const r = result.results.find((x) => x.platform === 'antigravity');
|
|
116
|
+
assert.strictEqual(r.installed, false);
|
|
117
|
+
});
|
|
118
|
+
});
|