@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.
Files changed (60) hide show
  1. package/README.md +58 -1
  2. package/compiler/__tests__/detect-invariants.test.js +136 -0
  3. package/compiler/__tests__/doctor-mesh.test.js +229 -0
  4. package/compiler/__tests__/hook-dispatch.test.js +91 -0
  5. package/compiler/__tests__/hooks-antigravity.test.js +118 -0
  6. package/compiler/__tests__/hooks-cursor.test.js +139 -0
  7. package/compiler/__tests__/hooks-install.test.js +305 -0
  8. package/compiler/__tests__/hooks-merge.test.js +204 -0
  9. package/compiler/__tests__/hooks-tiers.test.js +519 -0
  10. package/compiler/__tests__/hooks-windsurf.test.js +115 -0
  11. package/compiler/__tests__/inject-claude-md.test.js +152 -0
  12. package/compiler/__tests__/load-invariants.test.js +408 -0
  13. package/compiler/__tests__/onboard-invariants.test.js +240 -0
  14. package/compiler/adapters/hooks/antigravity.js +140 -0
  15. package/compiler/adapters/hooks/claude.js +166 -0
  16. package/compiler/adapters/hooks/cursor.js +191 -0
  17. package/compiler/adapters/hooks/index.js +82 -0
  18. package/compiler/adapters/hooks/tier-emitter.js +182 -0
  19. package/compiler/adapters/hooks/windsurf.js +202 -0
  20. package/compiler/bin/rune.js +196 -6
  21. package/compiler/commands/hook-dispatch.js +87 -0
  22. package/compiler/commands/hooks/install.js +120 -0
  23. package/compiler/commands/hooks/merge.js +211 -0
  24. package/compiler/commands/hooks/presets.js +116 -0
  25. package/compiler/commands/hooks/status.js +112 -0
  26. package/compiler/commands/hooks/tiers.js +221 -0
  27. package/compiler/commands/hooks/uninstall.js +94 -0
  28. package/compiler/doctor.js +236 -0
  29. package/package.json +2 -2
  30. package/skills/ba/SKILL.md +85 -1
  31. package/skills/brainstorm/SKILL.md +39 -1
  32. package/skills/browser-pilot/SKILL.md +1 -0
  33. package/skills/context-engine/SKILL.md +6 -2
  34. package/skills/design/SKILL.md +1 -0
  35. package/skills/docs-seeker/SKILL.md +1 -0
  36. package/skills/fix/SKILL.md +4 -2
  37. package/skills/hallucination-guard/SKILL.md +1 -0
  38. package/skills/journal/SKILL.md +1 -0
  39. package/skills/logic-guardian/SKILL.md +22 -4
  40. package/skills/marketing/SKILL.md +62 -1
  41. package/skills/neural-memory/SKILL.md +13 -16
  42. package/skills/onboard/SKILL.md +30 -2
  43. package/skills/onboard/references/invariants-template.md +76 -0
  44. package/skills/onboard/scripts/detect-invariants.js +439 -0
  45. package/skills/onboard/scripts/inject-claude-md.js +150 -0
  46. package/skills/onboard/scripts/onboard-invariants.js +194 -0
  47. package/skills/perf/SKILL.md +1 -0
  48. package/skills/plan/SKILL.md +2 -0
  49. package/skills/preflight/SKILL.md +1 -1
  50. package/skills/research/SKILL.md +4 -0
  51. package/skills/review/SKILL.md +4 -2
  52. package/skills/scope-guard/SKILL.md +4 -1
  53. package/skills/scout/SKILL.md +6 -0
  54. package/skills/sentinel/SKILL.md +2 -0
  55. package/skills/session-bridge/SKILL.md +53 -1
  56. package/skills/session-bridge/scripts/load-invariants.js +397 -0
  57. package/skills/slides/SKILL.md +19 -0
  58. package/skills/team/SKILL.md +2 -1
  59. package/skills/test/SKILL.md +6 -0
  60. package/skills/verification/SKILL.md +8 -0
@@ -0,0 +1,139 @@
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 cursor from '../adapters/hooks/cursor.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 = '.cursor/rules';
14
+
15
+ let tmpRoot;
16
+
17
+ async function seedCursor(root) {
18
+ await mkdir(path.join(root, '.cursor'), { recursive: true });
19
+ }
20
+
21
+ beforeEach(async () => {
22
+ tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-cursor-'));
23
+ });
24
+
25
+ afterEach(async () => {
26
+ if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('cursor adapter', () => {
30
+ test('detect() true when .cursor exists', async () => {
31
+ await seedCursor(tmpRoot);
32
+ assert.strictEqual(cursor.detect(tmpRoot), true);
33
+ });
34
+
35
+ test('detect() false when .cursor missing', () => {
36
+ assert.strictEqual(cursor.detect(tmpRoot), false);
37
+ });
38
+
39
+ test('emit(gentle) returns 3 .mdc rule files', async () => {
40
+ const plan = await cursor.emit({ preset: 'gentle', projectRoot: tmpRoot });
41
+ assert.strictEqual(plan.files.length, 3);
42
+ const names = plan.files.map((f) => path.basename(f.path)).sort();
43
+ assert.deepStrictEqual(names, ['rune-dependency-doctor.mdc', 'rune-preflight.mdc', 'rune-sentinel.mdc']);
44
+ for (const file of plan.files) {
45
+ assert.ok(file.content.includes('rune-managed: true'));
46
+ assert.ok(file.content.includes('@rune-kit/rune hook-dispatch'));
47
+ }
48
+ });
49
+
50
+ test('emit(strict) renders WARN → BLOCK guidance', async () => {
51
+ const gentle = await cursor.emit({ preset: 'gentle', projectRoot: tmpRoot });
52
+ const strict = await cursor.emit({ preset: 'strict', projectRoot: tmpRoot });
53
+ const gentlePreflight = gentle.files.find((f) => f.path.endsWith('rune-preflight.mdc')).content;
54
+ const strictPreflight = strict.files.find((f) => f.path.endsWith('rune-preflight.mdc')).content;
55
+ assert.ok(gentlePreflight.includes('WARN'));
56
+ assert.ok(strictPreflight.includes('BLOCK'));
57
+ });
58
+
59
+ test('emit(off) delegates to uninstall', async () => {
60
+ await seedCursor(tmpRoot);
61
+ await installHooks(tmpRoot, { preset: 'gentle', platform: 'cursor' });
62
+ const plan = await cursor.emit({ preset: 'off', projectRoot: tmpRoot });
63
+ assert.ok(plan.files.every((f) => f.content === null));
64
+ assert.ok(plan.files.length >= 1);
65
+ });
66
+
67
+ test('emit() rejects invalid preset', async () => {
68
+ await assert.rejects(cursor.emit({ preset: 'loose', projectRoot: tmpRoot }), /invalid preset/);
69
+ });
70
+
71
+ test('install writes .mdc files to .cursor/rules/', async () => {
72
+ await seedCursor(tmpRoot);
73
+ const result = await installHooks(tmpRoot, { preset: 'gentle', platform: 'cursor' });
74
+ assert.strictEqual(result.written, true);
75
+ const rulesDir = path.join(tmpRoot, RULES_DIR);
76
+ const files = await readdir(rulesDir);
77
+ assert.ok(files.includes('rune-preflight.mdc'));
78
+ assert.ok(files.includes('rune-sentinel.mdc'));
79
+ assert.ok(files.includes('rune-dependency-doctor.mdc'));
80
+ });
81
+
82
+ test('idempotent re-install produces identical content', async () => {
83
+ await seedCursor(tmpRoot);
84
+ await installHooks(tmpRoot, { preset: 'gentle', platform: 'cursor' });
85
+ const rulesDir = path.join(tmpRoot, RULES_DIR);
86
+ const first = await readFile(path.join(rulesDir, 'rune-preflight.mdc'), 'utf-8');
87
+ await installHooks(tmpRoot, { preset: 'gentle', platform: 'cursor' });
88
+ const second = await readFile(path.join(rulesDir, 'rune-preflight.mdc'), 'utf-8');
89
+ assert.strictEqual(first, second);
90
+ });
91
+
92
+ test('uninstall removes only Rune-managed .mdc files', async () => {
93
+ await seedCursor(tmpRoot);
94
+ const rulesDir = path.join(tmpRoot, RULES_DIR);
95
+ await mkdir(rulesDir, { recursive: true });
96
+ await writeFile(path.join(rulesDir, 'user-custom.mdc'), '---\ndescription: user\n---\n\n# User rule\n', 'utf-8');
97
+
98
+ await installHooks(tmpRoot, { preset: 'gentle', platform: 'cursor' });
99
+ await uninstallHooks(tmpRoot, { platform: 'cursor' });
100
+
101
+ const remaining = await readdir(rulesDir);
102
+ assert.ok(remaining.includes('user-custom.mdc'), 'user rule must survive');
103
+ assert.ok(!remaining.some((f) => f.startsWith('rune-')), 'no rune-* files should remain');
104
+ });
105
+
106
+ test('uninstall skips files without rune-managed signature', async () => {
107
+ await seedCursor(tmpRoot);
108
+ const rulesDir = path.join(tmpRoot, RULES_DIR);
109
+ await mkdir(rulesDir, { recursive: true });
110
+ // A file that happens to start with "rune-" but is NOT managed by Rune
111
+ await writeFile(
112
+ path.join(rulesDir, 'rune-fake.mdc'),
113
+ '---\ndescription: user imposter\n---\n\nnot ours\n',
114
+ 'utf-8',
115
+ );
116
+
117
+ await uninstallHooks(tmpRoot, { platform: 'cursor' });
118
+ assert.ok(existsSync(path.join(rulesDir, 'rune-fake.mdc')), 'non-managed rune-* file must survive');
119
+ });
120
+
121
+ test('status reports installed preset after install', async () => {
122
+ await seedCursor(tmpRoot);
123
+ await installHooks(tmpRoot, { preset: 'strict', platform: 'cursor' });
124
+ const result = await hookStatus(tmpRoot, RUNE_ROOT, { platform: 'cursor' });
125
+ const r = result.results.find((x) => x.platform === 'cursor');
126
+ assert.strictEqual(r.installed, true);
127
+ assert.strictEqual(r.preset, 'strict');
128
+ assert.ok(r.wired.includes('preflight'));
129
+ assert.ok(r.wired.includes('sentinel'));
130
+ assert.ok(r.wired.includes('dependency-doctor'));
131
+ });
132
+
133
+ test('status reports not installed when .cursor missing', async () => {
134
+ const result = await hookStatus(tmpRoot, RUNE_ROOT, { platform: 'cursor' });
135
+ const r = result.results.find((x) => x.platform === 'cursor');
136
+ assert.strictEqual(r.installed, false);
137
+ assert.ok(r.missing.includes('preflight'));
138
+ });
139
+ });
@@ -0,0 +1,305 @@
1
+ import assert from 'node:assert';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, mkdtemp, 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 { installHooks } from '../commands/hooks/install.js';
8
+ import { SETTINGS_REL_PATH } from '../commands/hooks/presets.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
+
14
+ let tmpRoot;
15
+
16
+ async function seedClaude(root) {
17
+ await mkdir(path.join(root, '.claude'), { recursive: true });
18
+ }
19
+
20
+ beforeEach(async () => {
21
+ tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-hooks-'));
22
+ });
23
+
24
+ afterEach(async () => {
25
+ if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('installHooks (claude adapter)', () => {
29
+ test('fresh install writes .claude/settings.json with gentle preset', async () => {
30
+ await seedClaude(tmpRoot);
31
+ const result = await installHooks(tmpRoot, { preset: 'gentle' });
32
+ assert.strictEqual(result.preset, 'gentle');
33
+ assert.strictEqual(result.written, true);
34
+ assert.deepStrictEqual(result.platforms, ['claude']);
35
+
36
+ const settingsPath = path.join(tmpRoot, SETTINGS_REL_PATH);
37
+ assert.ok(existsSync(settingsPath));
38
+
39
+ const settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
40
+ assert.ok(settings.hooks?.PreToolUse);
41
+ assert.ok(settings.hooks?.Stop);
42
+ });
43
+
44
+ test('defaults to gentle when preset omitted', async () => {
45
+ await seedClaude(tmpRoot);
46
+ const result = await installHooks(tmpRoot, {});
47
+ assert.strictEqual(result.preset, 'gentle');
48
+ const settings = JSON.parse(await readFile(path.join(tmpRoot, SETTINGS_REL_PATH), 'utf-8'));
49
+ const stopCmd = settings.hooks.Stop[0].hooks[0].command;
50
+ assert.ok(stopCmd.includes('--gentle'));
51
+ });
52
+
53
+ test('re-install with same preset is idempotent', async () => {
54
+ await seedClaude(tmpRoot);
55
+ await installHooks(tmpRoot, { preset: 'gentle' });
56
+ const first = await readFile(path.join(tmpRoot, SETTINGS_REL_PATH), 'utf-8');
57
+ await installHooks(tmpRoot, { preset: 'gentle' });
58
+ const second = await readFile(path.join(tmpRoot, SETTINGS_REL_PATH), 'utf-8');
59
+ assert.strictEqual(first, second);
60
+ });
61
+
62
+ test('upgrade gentle → strict replaces Rune entries', async () => {
63
+ await seedClaude(tmpRoot);
64
+ await installHooks(tmpRoot, { preset: 'gentle' });
65
+ await installHooks(tmpRoot, { preset: 'strict' });
66
+ const settings = JSON.parse(await readFile(path.join(tmpRoot, SETTINGS_REL_PATH), 'utf-8'));
67
+ const preflightCmd = settings.hooks.PreToolUse[0].hooks[0].command;
68
+ assert.ok(!preflightCmd.includes('--gentle'), 'strict should not have --gentle');
69
+ });
70
+
71
+ test('preserves user hooks when installing', async () => {
72
+ const settingsDir = path.join(tmpRoot, '.claude');
73
+ await mkdir(settingsDir, { recursive: true });
74
+ await writeFile(
75
+ path.join(settingsDir, 'settings.json'),
76
+ JSON.stringify(
77
+ {
78
+ env: { MY_VAR: 'keep-me' },
79
+ hooks: {
80
+ PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'user-read-hook.sh' }] }],
81
+ },
82
+ },
83
+ null,
84
+ 2,
85
+ ),
86
+ );
87
+
88
+ await installHooks(tmpRoot, { preset: 'gentle' });
89
+ const settings = JSON.parse(await readFile(path.join(settingsDir, 'settings.json'), 'utf-8'));
90
+ assert.deepStrictEqual(settings.env, { MY_VAR: 'keep-me' }, 'env preserved');
91
+ const readGroup = settings.hooks.PreToolUse.find((g) => g.matcher === 'Read');
92
+ assert.ok(readGroup, 'user Read matcher preserved');
93
+ assert.strictEqual(readGroup.hooks[0].command, 'user-read-hook.sh');
94
+ });
95
+
96
+ test('rejects invalid preset', async () => {
97
+ await seedClaude(tmpRoot);
98
+ await assert.rejects(installHooks(tmpRoot, { preset: 'loose' }), /Invalid preset/);
99
+ });
100
+
101
+ test('dry-run does not write', async () => {
102
+ await seedClaude(tmpRoot);
103
+ const result = await installHooks(tmpRoot, { preset: 'gentle', dry: true });
104
+ assert.strictEqual(result.written, false);
105
+ assert.ok(!existsSync(path.join(tmpRoot, SETTINGS_REL_PATH)));
106
+ });
107
+
108
+ test('preset=off uninstalls rune hooks only', async () => {
109
+ await seedClaude(tmpRoot);
110
+ await installHooks(tmpRoot, { preset: 'gentle' });
111
+ const result = await installHooks(tmpRoot, { preset: 'off' });
112
+ assert.strictEqual(result.preset, 'off');
113
+ const settings = JSON.parse(await readFile(path.join(tmpRoot, SETTINGS_REL_PATH), 'utf-8'));
114
+ assert.strictEqual(settings.hooks, undefined);
115
+ });
116
+
117
+ test('T1: preset=off with no settings.json does not create the file', async () => {
118
+ await seedClaude(tmpRoot);
119
+ const settingsPath = path.join(tmpRoot, SETTINGS_REL_PATH);
120
+ assert.ok(!existsSync(settingsPath), 'precondition: file must not exist');
121
+ const result = await installHooks(tmpRoot, { preset: 'off' });
122
+ // H1 fix: written reflects actual byte writes, not just !dry
123
+ assert.strictEqual(result.written, false);
124
+ assert.ok(result.notes.some((n) => n.includes('no changes')));
125
+ const claudeResult = result.results.find((r) => r.platform === 'claude');
126
+ assert.strictEqual(claudeResult.files.length, 0, 'no files should be written for off+missing');
127
+ assert.strictEqual(claudeResult.writes, 0);
128
+ assert.ok(!existsSync(settingsPath), 'settings.json must NOT have been created');
129
+ });
130
+
131
+ test('T2: malformed settings.json causes actionable error, file unchanged', async () => {
132
+ const settingsDir = path.join(tmpRoot, '.claude');
133
+ await mkdir(settingsDir, { recursive: true });
134
+ const settingsPath = path.join(settingsDir, 'settings.json');
135
+ const malformed = '{ not valid json }';
136
+ await writeFile(settingsPath, malformed, 'utf-8');
137
+ await assert.rejects(installHooks(tmpRoot, { preset: 'gentle' }), (err) => {
138
+ assert.ok(err.message.includes('settings.json'), 'error must mention settings.json');
139
+ return true;
140
+ });
141
+ assert.strictEqual(await readFile(settingsPath, 'utf-8'), malformed);
142
+ });
143
+
144
+ test('auto-detects no platforms → returns empty results with note', async () => {
145
+ const result = await installHooks(tmpRoot, { preset: 'gentle' });
146
+ assert.deepStrictEqual(result.platforms, []);
147
+ assert.strictEqual(result.written, false);
148
+ assert.ok(result.notes.some((n) => n.includes('No target platform')));
149
+ });
150
+
151
+ test('--platform claude forces install even without .claude/', async () => {
152
+ const result = await installHooks(tmpRoot, { preset: 'gentle', platform: 'claude' });
153
+ assert.deepStrictEqual(result.platforms, ['claude']);
154
+ assert.strictEqual(result.written, true);
155
+ assert.ok(existsSync(path.join(tmpRoot, SETTINGS_REL_PATH)));
156
+ });
157
+
158
+ test('--platform all targets only detected platforms (no silent dir creation)', async () => {
159
+ // Bare dir → no platforms detected → all expands to nothing
160
+ const bare = await installHooks(tmpRoot, { preset: 'gentle', platform: 'all' });
161
+ assert.deepStrictEqual(bare.platforms, []);
162
+ assert.strictEqual(bare.written, false);
163
+
164
+ // Seed .claude/ + .cursor/ only → all picks up those two, NOT windsurf/antigravity
165
+ await seedClaude(tmpRoot);
166
+ await mkdir(path.join(tmpRoot, '.cursor'), { recursive: true });
167
+ const result = await installHooks(tmpRoot, { preset: 'gentle', platform: 'all' });
168
+ assert.ok(result.platforms.includes('claude'));
169
+ assert.ok(result.platforms.includes('cursor'));
170
+ assert.ok(!result.platforms.includes('windsurf'), 'must not force-create windsurf dir');
171
+ assert.ok(!result.platforms.includes('antigravity'), 'must not force-create antigravity dir');
172
+ assert.ok(!existsSync(path.join(tmpRoot, '.windsurf')));
173
+ assert.ok(!existsSync(path.join(tmpRoot, '.antigravity')));
174
+ });
175
+
176
+ test('--platform <name> still force-creates the platform dir (explicit opt-in)', async () => {
177
+ const result = await installHooks(tmpRoot, { preset: 'gentle', platform: 'cursor' });
178
+ assert.deepStrictEqual(result.platforms, ['cursor']);
179
+ assert.ok(existsSync(path.join(tmpRoot, '.cursor', 'rules')));
180
+ });
181
+
182
+ test('unknown --platform rejected', async () => {
183
+ await assert.rejects(installHooks(tmpRoot, { preset: 'gentle', platform: 'bogus' }), /Unknown platform/);
184
+ });
185
+ });
186
+
187
+ describe('uninstallHooks (claude adapter)', () => {
188
+ test('no-op when no platforms detected', async () => {
189
+ const result = await uninstallHooks(tmpRoot);
190
+ assert.deepStrictEqual(result.platforms, []);
191
+ assert.strictEqual(result.written, false);
192
+ });
193
+
194
+ test('no-op when .claude/ exists but no settings.json', async () => {
195
+ await seedClaude(tmpRoot);
196
+ const result = await uninstallHooks(tmpRoot);
197
+ assert.deepStrictEqual(result.platforms, ['claude']);
198
+ // H1 fix: written=false when no actual writes happened
199
+ assert.strictEqual(result.written, false);
200
+ const claudeResult = result.results.find((r) => r.platform === 'claude');
201
+ assert.strictEqual(claudeResult.files.length, 0);
202
+ assert.strictEqual(claudeResult.writes, 0);
203
+ });
204
+
205
+ test('removes Rune hooks, preserves user hooks', async () => {
206
+ const settingsDir = path.join(tmpRoot, '.claude');
207
+ await mkdir(settingsDir, { recursive: true });
208
+ await writeFile(
209
+ path.join(settingsDir, 'settings.json'),
210
+ JSON.stringify(
211
+ {
212
+ hooks: {
213
+ PreToolUse: [
214
+ {
215
+ matcher: 'Edit|Write',
216
+ hooks: [
217
+ { type: 'command', command: 'npx --yes @rune-kit/rune hook-dispatch preflight' },
218
+ { type: 'command', command: 'user-lint.sh' },
219
+ ],
220
+ },
221
+ ],
222
+ },
223
+ },
224
+ null,
225
+ 2,
226
+ ),
227
+ );
228
+
229
+ const result = await uninstallHooks(tmpRoot);
230
+ assert.strictEqual(result.written, true);
231
+ const settings = JSON.parse(await readFile(path.join(settingsDir, 'settings.json'), 'utf-8'));
232
+ const editGroup = settings.hooks.PreToolUse.find((g) => g.matcher === 'Edit|Write');
233
+ assert.strictEqual(editGroup.hooks.length, 1);
234
+ assert.strictEqual(editGroup.hooks[0].command, 'user-lint.sh');
235
+ });
236
+
237
+ test('T4: user hook preserved exactly through install/uninstall round-trip', async () => {
238
+ const settingsDir = path.join(tmpRoot, '.claude');
239
+ await mkdir(settingsDir, { recursive: true });
240
+ const userCommand = 'my-rune hook-dispatch-notes.sh';
241
+ const initialSettings = {
242
+ hooks: {
243
+ PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: userCommand }] }],
244
+ },
245
+ };
246
+ await writeFile(path.join(settingsDir, 'settings.json'), JSON.stringify(initialSettings, null, 2));
247
+
248
+ await installHooks(tmpRoot, { preset: 'gentle' });
249
+ await uninstallHooks(tmpRoot);
250
+
251
+ const settings = JSON.parse(await readFile(path.join(settingsDir, 'settings.json'), 'utf-8'));
252
+ const editGroup = settings.hooks?.PreToolUse?.find((g) => g.matcher === 'Edit|Write');
253
+ assert.ok(editGroup, 'user Edit|Write group must survive');
254
+ assert.ok(
255
+ editGroup.hooks.some((h) => h.command === userCommand),
256
+ `user command "${userCommand}" must be preserved exactly`,
257
+ );
258
+ });
259
+
260
+ test('malformed settings.json causes actionable error on uninstall', async () => {
261
+ const settingsDir = path.join(tmpRoot, '.claude');
262
+ await mkdir(settingsDir, { recursive: true });
263
+ await writeFile(path.join(settingsDir, 'settings.json'), '{ broken json', 'utf-8');
264
+ await assert.rejects(uninstallHooks(tmpRoot), (err) => {
265
+ assert.ok(err.message.includes('settings.json') || err.message.includes('JSON'));
266
+ return true;
267
+ });
268
+ });
269
+ });
270
+
271
+ describe('hookStatus (claude adapter)', () => {
272
+ test('reports empty platforms when nothing detected', async () => {
273
+ const result = await hookStatus(tmpRoot, RUNE_ROOT);
274
+ assert.deepStrictEqual(result.platforms, []);
275
+ });
276
+
277
+ test('reports none for .claude/ without settings', async () => {
278
+ await seedClaude(tmpRoot);
279
+ const result = await hookStatus(tmpRoot, RUNE_ROOT);
280
+ const claude = result.results.find((r) => r.platform === 'claude');
281
+ assert.strictEqual(claude.installed, false);
282
+ assert.strictEqual(claude.preset, null);
283
+ });
284
+
285
+ test('reports gentle preset after install', async () => {
286
+ await seedClaude(tmpRoot);
287
+ await installHooks(tmpRoot, { preset: 'gentle' });
288
+ const result = await hookStatus(tmpRoot, RUNE_ROOT);
289
+ const claude = result.results.find((r) => r.platform === 'claude');
290
+ assert.strictEqual(claude.installed, true);
291
+ assert.strictEqual(claude.preset, 'gentle');
292
+ assert.ok(claude.events.PreToolUse.includes('preflight'));
293
+ assert.ok(claude.events.Stop.includes('completion-gate'));
294
+ });
295
+
296
+ test('--platform all surfaces every adapter', async () => {
297
+ const result = await hookStatus(tmpRoot, RUNE_ROOT, { platform: 'all' });
298
+ assert.strictEqual(result.results.length, 4);
299
+ for (const id of ['claude', 'cursor', 'windsurf', 'antigravity']) {
300
+ const r = result.results.find((x) => x.platform === id);
301
+ assert.ok(r, `result for ${id} must be present`);
302
+ assert.ok(r.capability, `capability matrix for ${id} must be present`);
303
+ }
304
+ });
305
+ });
@@ -0,0 +1,204 @@
1
+ import assert from 'node:assert';
2
+ import { describe, test } from 'node:test';
3
+ import { detectPreset, mergePreset, stripRuneHooks, summarizeRuneHooks } from '../commands/hooks/merge.js';
4
+ import { buildPreset, isRuneManaged } from '../commands/hooks/presets.js';
5
+
6
+ describe('isRuneManaged', () => {
7
+ // T3: must reject strings that merely contain "rune" or "hook-dispatch"
8
+ test('T3: rejects commands that contain "rune hook-dispatch" as substring only', () => {
9
+ const falsePositives = [
10
+ 'my-rune hook-dispatch-notes.sh',
11
+ '/usr/local/bin/rune hook-dispatch-runner',
12
+ 'echo "rune hook-dispatch done"',
13
+ 'log-rune hook-dispatch.log',
14
+ ];
15
+ for (const cmd of falsePositives) {
16
+ assert.strictEqual(isRuneManaged({ command: cmd }), false, `should NOT match: ${cmd}`);
17
+ }
18
+ });
19
+
20
+ test('T3: matches the exact @rune-kit/rune hook-dispatch invocation', () => {
21
+ const truePositives = [
22
+ 'npx --yes @rune-kit/rune hook-dispatch preflight',
23
+ 'npx @rune-kit/rune hook-dispatch sentinel',
24
+ 'npx --yes @rune-kit/rune hook-dispatch completion-gate --gentle',
25
+ ];
26
+ for (const cmd of truePositives) {
27
+ assert.strictEqual(isRuneManaged({ command: cmd }), true, `should match: ${cmd}`);
28
+ }
29
+ });
30
+ });
31
+
32
+ describe('buildPreset', () => {
33
+ test('produces valid gentle preset with expected events', () => {
34
+ const preset = buildPreset('gentle');
35
+ assert.ok(preset.hooks.PreToolUse);
36
+ assert.ok(preset.hooks.PostToolUse);
37
+ assert.ok(preset.hooks.Stop);
38
+ const editGroup = preset.hooks.PreToolUse.find((g) => g.matcher === 'Edit|Write');
39
+ assert.ok(editGroup.hooks[0].command.includes('preflight'));
40
+ assert.ok(editGroup.hooks[0].command.includes('--gentle'));
41
+ });
42
+
43
+ test('strict preset omits --gentle flag', () => {
44
+ const preset = buildPreset('strict');
45
+ const editGroup = preset.hooks.PreToolUse.find((g) => g.matcher === 'Edit|Write');
46
+ assert.ok(!editGroup.hooks[0].command.includes('--gentle'));
47
+ });
48
+
49
+ test('rejects unknown preset', () => {
50
+ assert.throws(() => buildPreset('loose'), /Unknown preset/);
51
+ });
52
+
53
+ test('all commands carry Rune-managed signature', () => {
54
+ for (const name of ['gentle', 'strict']) {
55
+ const preset = buildPreset(name);
56
+ for (const groups of Object.values(preset.hooks)) {
57
+ for (const group of groups) {
58
+ for (const entry of group.hooks) {
59
+ assert.ok(isRuneManaged(entry), `entry missing signature: ${JSON.stringify(entry)}`);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ });
65
+ });
66
+
67
+ describe('stripRuneHooks', () => {
68
+ test('removes Rune entries, preserves user entries', () => {
69
+ const settings = {
70
+ hooks: {
71
+ PreToolUse: [
72
+ {
73
+ matcher: 'Edit|Write',
74
+ hooks: [
75
+ { type: 'command', command: 'npx --yes @rune-kit/rune hook-dispatch preflight --gentle' },
76
+ { type: 'command', command: 'my-custom-hook.sh' },
77
+ ],
78
+ },
79
+ ],
80
+ },
81
+ };
82
+ const result = stripRuneHooks(settings);
83
+ const group = result.hooks.PreToolUse.find((g) => g.matcher === 'Edit|Write');
84
+ assert.strictEqual(group.hooks.length, 1);
85
+ assert.strictEqual(group.hooks[0].command, 'my-custom-hook.sh');
86
+ });
87
+
88
+ test('drops event if all entries were Rune-managed', () => {
89
+ const settings = {
90
+ hooks: {
91
+ Stop: [
92
+ {
93
+ matcher: '.*',
94
+ hooks: [{ type: 'command', command: 'npx --yes @rune-kit/rune hook-dispatch completion-gate' }],
95
+ },
96
+ ],
97
+ },
98
+ };
99
+ const result = stripRuneHooks(settings);
100
+ assert.strictEqual(result.hooks, undefined);
101
+ });
102
+
103
+ test('handles missing hooks field', () => {
104
+ assert.deepStrictEqual(stripRuneHooks({ other: 'data' }), { other: 'data' });
105
+ });
106
+
107
+ test('returns {} for null input', () => {
108
+ assert.deepStrictEqual(stripRuneHooks(null), {});
109
+ });
110
+
111
+ test('preserves top-level non-hook fields', () => {
112
+ const settings = {
113
+ $schema: 'https://example.com/schema.json',
114
+ env: { FOO: 'bar' },
115
+ hooks: {
116
+ Stop: [{ matcher: '.*', hooks: [{ command: 'npx --yes @rune-kit/rune hook-dispatch completion-gate' }] }],
117
+ },
118
+ };
119
+ const result = stripRuneHooks(settings);
120
+ assert.strictEqual(result.$schema, 'https://example.com/schema.json');
121
+ assert.deepStrictEqual(result.env, { FOO: 'bar' });
122
+ });
123
+ });
124
+
125
+ describe('mergePreset', () => {
126
+ test('fresh install writes full preset', () => {
127
+ const merged = mergePreset({}, buildPreset('gentle'));
128
+ assert.ok(merged.hooks.PreToolUse);
129
+ assert.ok(merged.hooks.Stop);
130
+ });
131
+
132
+ test('re-install replaces Rune entries, keeps user entries', () => {
133
+ const initial = mergePreset({}, buildPreset('gentle'));
134
+ // user adds their own hook to the same matcher
135
+ initial.hooks.PreToolUse[0].hooks.push({ type: 'command', command: 'user-lint.sh' });
136
+
137
+ const reinstalled = mergePreset(initial, buildPreset('strict'));
138
+ const editGroup = reinstalled.hooks.PreToolUse.find((g) => g.matcher === 'Edit|Write');
139
+ const commands = editGroup.hooks.map((h) => h.command);
140
+ assert.ok(commands.includes('user-lint.sh'), 'user hook preserved');
141
+ assert.ok(
142
+ commands.some((c) => c.includes('preflight') && !c.includes('--gentle')),
143
+ 'strict preflight installed',
144
+ );
145
+ assert.ok(!commands.some((c) => c.includes('--gentle')), 'no gentle entries remain');
146
+ });
147
+
148
+ test('idempotent — same preset twice = same result', () => {
149
+ const once = mergePreset({}, buildPreset('gentle'));
150
+ const twice = mergePreset(once, buildPreset('gentle'));
151
+ assert.deepStrictEqual(once, twice);
152
+ });
153
+
154
+ test('merges into existing user matcher without duplicate groups', () => {
155
+ const userSettings = {
156
+ hooks: {
157
+ PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ command: 'user-guard.sh' }] }],
158
+ },
159
+ };
160
+ const merged = mergePreset(userSettings, buildPreset('gentle'));
161
+ const groups = merged.hooks.PreToolUse.filter((g) => g.matcher === 'Edit|Write');
162
+ assert.strictEqual(groups.length, 1, 'no duplicate matcher group');
163
+ assert.ok(groups[0].hooks.some((h) => h.command === 'user-guard.sh'));
164
+ assert.ok(groups[0].hooks.some((h) => h.command.includes('preflight')));
165
+ });
166
+ });
167
+
168
+ describe('summarizeRuneHooks', () => {
169
+ test('extracts skill names per event', () => {
170
+ const merged = mergePreset({}, buildPreset('gentle'));
171
+ const summary = summarizeRuneHooks(merged);
172
+ assert.strictEqual(summary.total, 4);
173
+ assert.ok(summary.events.PreToolUse.includes('preflight'));
174
+ assert.ok(summary.events.PreToolUse.includes('sentinel'));
175
+ assert.ok(summary.events.PostToolUse.includes('dependency-doctor'));
176
+ assert.ok(summary.events.Stop.includes('completion-gate'));
177
+ });
178
+
179
+ test('empty settings → zero total', () => {
180
+ const summary = summarizeRuneHooks({});
181
+ assert.strictEqual(summary.total, 0);
182
+ });
183
+ });
184
+
185
+ describe('detectPreset', () => {
186
+ test('detects gentle', () => {
187
+ assert.strictEqual(detectPreset(mergePreset({}, buildPreset('gentle'))), 'gentle');
188
+ });
189
+ test('detects strict', () => {
190
+ assert.strictEqual(detectPreset(mergePreset({}, buildPreset('strict'))), 'strict');
191
+ });
192
+ test('detects mixed', () => {
193
+ const mixed = mergePreset({}, buildPreset('gentle'));
194
+ // manually inject a strict entry
195
+ mixed.hooks.Stop[0].hooks.push({
196
+ type: 'command',
197
+ command: 'npx --yes @rune-kit/rune hook-dispatch sentinel',
198
+ });
199
+ assert.strictEqual(detectPreset(mixed), 'mixed');
200
+ });
201
+ test('returns none for empty settings', () => {
202
+ assert.strictEqual(detectPreset({}), 'none');
203
+ });
204
+ });