@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,240 @@
1
+ import assert from 'node:assert';
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { describe, test } from 'node:test';
6
+ import {
7
+ collectPointerGlobs,
8
+ loadTemplate,
9
+ mergeInvariantsContent,
10
+ runOnboardInvariants,
11
+ } from '../../skills/onboard/scripts/onboard-invariants.js';
12
+
13
+ describe('mergeInvariantsContent', () => {
14
+ test('seeds from template when existing is empty', () => {
15
+ const template = '# Project Invariants\n\n## Auto-detected (new)\n\n<!-- placeholder -->\n';
16
+ const detected = {
17
+ danger: [{ title: 'core', what: '10 files', where: ['src/core/**'], why: 'hub' }],
18
+ critical: [],
19
+ state: [],
20
+ cross: [],
21
+ };
22
+ const merged = mergeInvariantsContent({ existing: '', autoDetected: detected, template });
23
+ assert.ok(merged.seeded);
24
+ assert.ok(merged.content.includes('# Project Invariants'));
25
+ assert.ok(merged.content.includes('#### core'));
26
+ });
27
+
28
+ test('replaces ONLY the auto-detected block on re-run', () => {
29
+ const first = mergeInvariantsContent({
30
+ existing: '',
31
+ autoDetected: {
32
+ danger: [{ title: 'v1', what: 'old', where: ['src/v1/**'], why: 'first' }],
33
+ critical: [],
34
+ state: [],
35
+ cross: [],
36
+ },
37
+ template: '# Project Invariants\n\n## Auto-detected (new)\n\n<!-- placeholder -->\n',
38
+ });
39
+
40
+ const userEdited = first.content.replace(
41
+ '# Project Invariants',
42
+ '# Project Invariants\n\n## Danger Zones\n\n#### UserRule\n- **WHAT**: custom\n- **WHERE**: `src/custom/**`\n- **WHY**: user-added\n',
43
+ );
44
+
45
+ const second = mergeInvariantsContent({
46
+ existing: userEdited,
47
+ autoDetected: {
48
+ danger: [{ title: 'v2', what: 'new', where: ['src/v2/**'], why: 'second' }],
49
+ critical: [],
50
+ state: [],
51
+ cross: [],
52
+ },
53
+ template: '',
54
+ });
55
+
56
+ assert.ok(second.replaced);
57
+ assert.ok(second.content.includes('UserRule'), 'user edits must be preserved');
58
+ assert.ok(second.content.includes('#### v2'), 'new detection must appear');
59
+ assert.ok(!second.content.includes('#### v1'), 'old detection must be replaced');
60
+ });
61
+
62
+ test('appends Auto-detected header if missing from existing file', () => {
63
+ const existing = '# Project Invariants\n\n## Danger Zones\n\n#### legacy\n';
64
+ const merged = mergeInvariantsContent({
65
+ existing,
66
+ autoDetected: {
67
+ danger: [{ title: 'fresh', what: 'n', where: ['src/x/**'], why: 'y' }],
68
+ critical: [],
69
+ state: [],
70
+ cross: [],
71
+ },
72
+ template: '',
73
+ });
74
+ assert.ok(merged.appended);
75
+ assert.ok(merged.content.includes('## Auto-detected (new)'));
76
+ assert.ok(merged.content.includes('legacy'), 'existing user content preserved');
77
+ });
78
+
79
+ test('renders placeholder line when no detections', () => {
80
+ const merged = mergeInvariantsContent({
81
+ existing: '',
82
+ autoDetected: { danger: [], critical: [], state: [], cross: [] },
83
+ template: '# X\n\n## Auto-detected (new)\n\n<!-- placeholder -->\n',
84
+ });
85
+ assert.match(merged.content, /No new detections on this run/);
86
+ });
87
+
88
+ test('Phase 2 C1: code-fence with `## ` inside auto-detected block does not corrupt merge', () => {
89
+ // User manually edits the file and embeds a markdown example showing a `## `
90
+ // line inside a fenced code block (e.g. illustrating markdown syntax).
91
+ // The old regex-based boundary scan would treat that `## ` as a real section
92
+ // header and drop everything after it. This test locks in the line-walking
93
+ // fence-aware boundary detection.
94
+ const existing = [
95
+ '# Project Invariants',
96
+ '',
97
+ '## Danger Zones',
98
+ '',
99
+ '#### PreservedRule',
100
+ '- **WHAT**: do not touch',
101
+ '',
102
+ '## Auto-detected (new)',
103
+ '',
104
+ '```markdown',
105
+ '## This heading is inside a fence',
106
+ 'example content',
107
+ '```',
108
+ '',
109
+ '#### OldDetection',
110
+ '',
111
+ '## Notes (user section)',
112
+ '',
113
+ 'should survive too',
114
+ '',
115
+ ].join('\n');
116
+
117
+ const merged = mergeInvariantsContent({
118
+ existing,
119
+ autoDetected: {
120
+ danger: [{ title: 'NewDetection', what: 'fresh', where: ['src/fresh/**'], why: 'new' }],
121
+ critical: [],
122
+ state: [],
123
+ cross: [],
124
+ },
125
+ template: '',
126
+ });
127
+
128
+ assert.ok(merged.replaced);
129
+ assert.ok(merged.content.includes('PreservedRule'), 'content above Auto-detected preserved');
130
+ assert.ok(merged.content.includes('NewDetection'), 'new detection inserted');
131
+ assert.ok(!merged.content.includes('OldDetection'), 'old auto-detected content replaced');
132
+ assert.ok(merged.content.includes('Notes (user section)'), 'real `## ` section after block survives');
133
+ assert.ok(merged.content.includes('should survive too'), 'body of real section survives');
134
+ });
135
+ });
136
+
137
+ describe('collectPointerGlobs', () => {
138
+ test('extracts and dedupes globs from danger + critical buckets', () => {
139
+ const globs = collectPointerGlobs({
140
+ danger: [{ where: ['src/a/**'] }, { where: ['src/b/**'] }, { where: ['src/a/**'] }],
141
+ critical: [{ where: ['src/b/**'] }, { where: ['src/c/**'] }],
142
+ state: [],
143
+ cross: [],
144
+ });
145
+ assert.deepStrictEqual(globs.sort(), ['src/a/**', 'src/b/**', 'src/c/**']);
146
+ });
147
+ });
148
+
149
+ describe('loadTemplate', () => {
150
+ test('reads the bundled template file', async () => {
151
+ const content = await loadTemplate();
152
+ assert.ok(content.includes('Project Invariants'));
153
+ assert.ok(content.includes('Auto-detected (new)'));
154
+ });
155
+ });
156
+
157
+ describe('runOnboardInvariants — end-to-end', () => {
158
+ test('seeds INVARIANTS.md and injects CLAUDE.md pointer', async () => {
159
+ const root = await mkdtemp(path.join(tmpdir(), 'rune-onboard-'));
160
+ try {
161
+ await mkdir(path.join(root, 'src', 'core'), { recursive: true });
162
+ for (let i = 0; i < 12; i++) {
163
+ await writeFile(path.join(root, 'src', 'core', `file${i}.ts`), `export const X${i} = ${i};\n`, 'utf8');
164
+ }
165
+ await writeFile(path.join(root, 'CLAUDE.md'), '# Fresh Project\n', 'utf8');
166
+
167
+ const result = await runOnboardInvariants({ root });
168
+
169
+ assert.strictEqual(result.invariants.action, 'seeded');
170
+ assert.ok(['created', 'updated'].includes(result.claudeMd.action));
171
+
172
+ const invariants = await readFile(path.join(root, '.rune', 'INVARIANTS.md'), 'utf8');
173
+ assert.ok(invariants.includes('Project Invariants'));
174
+
175
+ const claudeMd = await readFile(path.join(root, 'CLAUDE.md'), 'utf8');
176
+ assert.ok(claudeMd.includes('@rune-invariants-pointer:start'));
177
+ assert.ok(claudeMd.includes('.rune/INVARIANTS.md'));
178
+ } finally {
179
+ await rm(root, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test('Phase 2 H1: SKILL.md / PACK.md directories surface as danger zones', async () => {
184
+ const root = await mkdtemp(path.join(tmpdir(), 'rune-onboard-skills-'));
185
+ try {
186
+ // Seed an AI-skills-repo shape: directories containing SKILL.md but no
187
+ // SOURCE_EXTS files. Old detector (exts-only scan) missed these entirely.
188
+ await mkdir(path.join(root, 'skills', 'skill-router'), { recursive: true });
189
+ await mkdir(path.join(root, 'skills', 'cook'), { recursive: true });
190
+ await mkdir(path.join(root, 'extensions', 'ui'), { recursive: true });
191
+ await writeFile(path.join(root, 'skills', 'skill-router', 'SKILL.md'), '# Router\n', 'utf8');
192
+ await writeFile(path.join(root, 'skills', 'cook', 'SKILL.md'), '# Cook\n', 'utf8');
193
+ await writeFile(path.join(root, 'extensions', 'ui', 'PACK.md'), '# UI Pack\n', 'utf8');
194
+ await writeFile(path.join(root, 'CLAUDE.md'), '# P\n', 'utf8');
195
+
196
+ const result = await runOnboardInvariants({ root });
197
+ assert.strictEqual(result.invariants.action, 'seeded');
198
+
199
+ const invariants = await readFile(path.join(root, '.rune', 'INVARIANTS.md'), 'utf8');
200
+ const hasSkillRouter = invariants.includes('skills/skill-router');
201
+ const hasCook = invariants.includes('skills/cook');
202
+ const hasUiPack = invariants.includes('extensions/ui');
203
+ assert.ok(
204
+ hasSkillRouter || hasCook || hasUiPack,
205
+ 'at least one signal-file directory must surface as a danger zone',
206
+ );
207
+ assert.ok(invariants.includes('skill/pack artifact') || invariants.includes('High-risk keyword'));
208
+ } finally {
209
+ await rm(root, { recursive: true, force: true });
210
+ }
211
+ });
212
+
213
+ test('second run preserves user edits above Auto-detected', async () => {
214
+ const root = await mkdtemp(path.join(tmpdir(), 'rune-onboard-'));
215
+ try {
216
+ await mkdir(path.join(root, 'src', 'core'), { recursive: true });
217
+ for (let i = 0; i < 10; i++) {
218
+ await writeFile(path.join(root, 'src', 'core', `f${i}.ts`), `export const Y${i} = ${i};\n`, 'utf8');
219
+ }
220
+ await writeFile(path.join(root, 'CLAUDE.md'), '# P\n', 'utf8');
221
+
222
+ await runOnboardInvariants({ root });
223
+
224
+ const invariantsPath = path.join(root, '.rune', 'INVARIANTS.md');
225
+ const original = await readFile(invariantsPath, 'utf8');
226
+ const edited = original.replace(
227
+ '## Auto-detected (new)',
228
+ '## Danger Zones\n\n#### UserRule\n- **WHAT**: sacred\n- **WHERE**: `src/sacred/**`\n- **WHY**: must-not-touch\n\n## Auto-detected (new)',
229
+ );
230
+ await writeFile(invariantsPath, edited, 'utf8');
231
+
232
+ await runOnboardInvariants({ root });
233
+ const after = await readFile(invariantsPath, 'utf8');
234
+ assert.ok(after.includes('UserRule'), 'user section must survive re-run');
235
+ assert.ok(after.includes('## Auto-detected (new)'));
236
+ } finally {
237
+ await rm(root, { recursive: true, force: true });
238
+ }
239
+ });
240
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Google Antigravity hooks adapter (experimental).
3
+ *
4
+ * Antigravity uses `.antigravity/rules/*.md` for persistent agent context.
5
+ * There is no tool-level hook primitive yet, so we mirror Cursor's pattern:
6
+ * emit rule files that cascade into the agent's prompt. This is the weakest
7
+ * fidelity tier — documented so users know what they're getting.
8
+ */
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { readdir, readFile } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { platformEntries, renderTierRule, unsupportedEntries } from './tier-emitter.js';
14
+
15
+ export const id = 'antigravity';
16
+
17
+ const RULES_REL_DIR = '.antigravity/rules';
18
+ const RUNE_PREFIX = 'rune-';
19
+ const AUTO_SIGNATURE = '@rune-kit/rune hook-dispatch';
20
+
21
+ export function detect(projectRoot) {
22
+ return existsSync(path.join(projectRoot, '.antigravity'));
23
+ }
24
+
25
+ export async function emit({ preset, projectRoot, tierManifests = [] }) {
26
+ if (preset === 'off' && tierManifests.length === 0) return uninstall({ projectRoot });
27
+ if (preset !== 'off' && preset !== 'strict' && preset !== 'gentle') {
28
+ throw new Error(`antigravity adapter: invalid preset '${preset}'`);
29
+ }
30
+
31
+ const mode = preset === 'strict' ? 'BLOCK' : 'WARN';
32
+ const rulesDir = path.join(projectRoot, RULES_REL_DIR);
33
+
34
+ const rules = [
35
+ { name: 'preflight', description: 'Run preflight review before any source edit.' },
36
+ { name: 'sentinel', description: 'Security review before shell / infra edits.' },
37
+ { name: 'dependency-doctor', description: 'Dependency audit after manifest edits.' },
38
+ ];
39
+
40
+ const files =
41
+ preset === 'off'
42
+ ? []
43
+ : rules.map((r) => ({
44
+ path: path.join(rulesDir, `${RUNE_PREFIX}${r.name}.md`),
45
+ content: renderRule(r, mode),
46
+ }));
47
+
48
+ const notes =
49
+ preset === 'off'
50
+ ? []
51
+ : [
52
+ 'Antigravity support is experimental — rule-injection only, no true hook.',
53
+ `Preset: ${preset} (${mode}). \`completion-gate\` must be invoked manually.`,
54
+ ];
55
+
56
+ for (const manifest of tierManifests) {
57
+ for (const entry of platformEntries(manifest, 'antigravity')) {
58
+ const content = renderTierRule(entry, 'antigravity', { mode, tier: manifest.tier });
59
+ if (!content) continue;
60
+ files.push({
61
+ path: path.join(rulesDir, `${RUNE_PREFIX}${manifest.tier}-${entry.id}.md`),
62
+ content,
63
+ });
64
+ }
65
+ const skipped = unsupportedEntries(manifest, 'antigravity');
66
+ if (skipped.length > 0) {
67
+ notes.push(
68
+ `antigravity: ${manifest.tier} skipped ${skipped.length} entr${skipped.length === 1 ? 'y' : 'ies'} (${skipped.map((e) => e.id).join(', ')}) — platform-unsupported or Claude-only.`,
69
+ );
70
+ }
71
+ }
72
+
73
+ return { files, notes };
74
+ }
75
+
76
+ export async function uninstall({ projectRoot }) {
77
+ const rulesDir = path.join(projectRoot, RULES_REL_DIR);
78
+ if (!existsSync(rulesDir)) return { files: [], notes: [] };
79
+ const entries = await readdir(rulesDir, { withFileTypes: true });
80
+ const runeFiles = entries.filter((e) => e.isFile() && e.name.startsWith(RUNE_PREFIX) && e.name.endsWith('.md'));
81
+ const files = [];
82
+ for (const file of runeFiles) {
83
+ const abs = path.join(rulesDir, file.name);
84
+ const content = await readFile(abs, 'utf-8');
85
+ if (content.includes(AUTO_SIGNATURE) || content.includes('rune-managed: true')) {
86
+ files.push({ path: abs, content: null });
87
+ }
88
+ }
89
+ return { files, notes: files.length === 0 ? ['no Rune-managed antigravity rules'] : [] };
90
+ }
91
+
92
+ export async function status(projectRoot) {
93
+ const rulesDir = path.join(projectRoot, RULES_REL_DIR);
94
+ if (!existsSync(rulesDir)) {
95
+ return {
96
+ installed: false,
97
+ preset: null,
98
+ wired: [],
99
+ missing: ['preflight', 'sentinel', 'dependency-doctor'],
100
+ notes: ['no .antigravity/rules directory'],
101
+ };
102
+ }
103
+ const entries = await readdir(rulesDir, { withFileTypes: true });
104
+ const runeFiles = entries.filter((e) => e.isFile() && e.name.startsWith(RUNE_PREFIX) && e.name.endsWith('.md'));
105
+ const wired = runeFiles.map((f) => f.name.replace(RUNE_PREFIX, '').replace('.md', ''));
106
+ const expected = ['preflight', 'sentinel', 'dependency-doctor'];
107
+ const missing = expected.filter((s) => !wired.includes(s));
108
+
109
+ let preset = null;
110
+ for (const file of runeFiles) {
111
+ const content = await readFile(path.join(rulesDir, file.name), 'utf-8');
112
+ if (/^mode: BLOCK$/m.test(content)) {
113
+ preset = 'strict';
114
+ break;
115
+ }
116
+ if (/^mode: WARN$/m.test(content)) preset = preset ?? 'gentle';
117
+ }
118
+
119
+ return { installed: runeFiles.length > 0, preset, wired, missing, notes: ['experimental — rule-injection only'] };
120
+ }
121
+
122
+ function renderRule(rule, mode) {
123
+ return [
124
+ '---',
125
+ `description: ${rule.description}`,
126
+ 'rune-managed: true',
127
+ `rune-skill: ${rule.name}`,
128
+ `mode: ${mode}`,
129
+ '---',
130
+ '',
131
+ `# Rune ${rule.name}`,
132
+ '',
133
+ `${rule.description} Mode: ${mode}.`,
134
+ '',
135
+ 'Refer to the Rune skill SKILL.md for the full checklist — apply those checks mentally before each matching edit.',
136
+ '',
137
+ `_Auto-generated by \`rune hooks install\` (${AUTO_SIGNATURE})._`,
138
+ '',
139
+ ].join('\n');
140
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Claude Code hooks adapter.
3
+ *
4
+ * Target: `.claude/settings.json` — native hook primitive (PreToolUse /
5
+ * PostToolUse / Stop). This is the stable reference adapter; other adapters
6
+ * degrade gracefully against Claude's capabilities.
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { readFile } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {
13
+ appendHookBlock,
14
+ detectPreset,
15
+ stripHooksBySkill,
16
+ stripRuneHooks,
17
+ summarizeRuneHooks,
18
+ } from '../../commands/hooks/merge.js';
19
+ import { buildPreset, SETTINGS_REL_PATH, WIRED_SKILLS } from '../../commands/hooks/presets.js';
20
+ import { buildTierBlock } from './tier-emitter.js';
21
+
22
+ export const id = 'claude';
23
+
24
+ export function detect(projectRoot) {
25
+ return existsSync(path.join(projectRoot, '.claude'));
26
+ }
27
+
28
+ export async function emit({ preset, projectRoot, tierManifests = [] }) {
29
+ if (preset === 'off' && tierManifests.length === 0) return uninstall({ projectRoot });
30
+ if (preset !== 'off' && preset !== 'strict' && preset !== 'gentle') {
31
+ throw new Error(`claude adapter: invalid preset '${preset}'`);
32
+ }
33
+
34
+ const settingsPath = path.join(projectRoot, SETTINGS_REL_PATH);
35
+ const existing = await readJson(settingsPath);
36
+
37
+ // Strip ONCE up-front — preset + tier layers then merge additively without
38
+ // the next layer wiping the previous.
39
+ let merged = stripRuneHooks(existing);
40
+ // Clear any existing Rune-managed statusLine so re-install is idempotent.
41
+ if (merged.statusLine?.command && isRuneStatusLine(merged.statusLine.command)) {
42
+ const { statusLine: _unused, ...rest } = merged;
43
+ merged = rest;
44
+ }
45
+ const notes = [];
46
+
47
+ if (preset !== 'off') {
48
+ merged = appendHookBlock(merged, buildPreset(preset));
49
+ }
50
+
51
+ for (const manifest of tierManifests) {
52
+ // Apply `overrides` — strip any surviving entries whose extracted skill name
53
+ // matches an override key. Example: Pro's `context-sense` replaces the older
54
+ // `context-watch` hook, so a pre-migration settings.json that still carries
55
+ // a `context-watch` entry gets cleaned up here.
56
+ const overrideKeys = Object.keys(manifest.overrides || {});
57
+ if (overrideKeys.length > 0) {
58
+ const before = JSON.stringify(merged.hooks || {});
59
+ merged = stripHooksBySkill(merged, overrideKeys);
60
+ const after = JSON.stringify(merged.hooks || {});
61
+ if (before !== after) {
62
+ notes.push(
63
+ `claude: applied ${manifest.tier} overrides — stripped legacy entr${overrideKeys.length === 1 ? 'y' : 'ies'} (${overrideKeys.join(', ')}).`,
64
+ );
65
+ }
66
+ }
67
+
68
+ const tierBlock = buildTierBlock(manifest, 'claude');
69
+ if (tierBlock.hooks && Object.keys(tierBlock.hooks).length > 0) {
70
+ merged = appendHookBlock(merged, { hooks: tierBlock.hooks });
71
+ }
72
+ if (tierBlock.statusLine) {
73
+ const existingStatus = merged.statusLine;
74
+ if (!existingStatus || isRuneStatusLine(existingStatus.command)) {
75
+ merged = { ...merged, statusLine: tierBlock.statusLine };
76
+ } else {
77
+ notes.push(
78
+ `claude: user-owned statusLine detected — skipping Rune tier statusLine ('${tierBlock.statusLine.command}'). Remove your statusLine and re-install to adopt Rune's.`,
79
+ );
80
+ }
81
+ }
82
+ if (tierBlock.notes?.length) notes.push(...tierBlock.notes);
83
+ }
84
+
85
+ return {
86
+ files: [
87
+ {
88
+ path: settingsPath,
89
+ content: `${JSON.stringify(merged, null, 2)}\n`,
90
+ },
91
+ ],
92
+ notes,
93
+ };
94
+ }
95
+
96
+ export async function uninstall({ projectRoot }) {
97
+ const settingsPath = path.join(projectRoot, SETTINGS_REL_PATH);
98
+ if (!existsSync(settingsPath)) {
99
+ return { files: [], notes: ['no .claude/settings.json — nothing to uninstall'] };
100
+ }
101
+ const existing = await readJson(settingsPath);
102
+ const stripped = stripRuneHooks(existing);
103
+ // statusLine with Rune hook-dispatch or a rune-managed tier command is Rune-owned — strip it.
104
+ if (stripped.statusLine?.command && isRuneStatusLine(stripped.statusLine.command)) {
105
+ delete stripped.statusLine;
106
+ }
107
+ return {
108
+ files: [
109
+ {
110
+ path: settingsPath,
111
+ content: `${JSON.stringify(stripped, null, 2)}\n`,
112
+ },
113
+ ],
114
+ notes: [],
115
+ };
116
+ }
117
+
118
+ const RUNE_TIER_STATUSLINE_RE = /\$\{RUNE_[A-Z][A-Z0-9_]*_ROOT\}/;
119
+
120
+ function isRuneStatusLine(command) {
121
+ if (typeof command !== 'string') return false;
122
+ // Match the installer's own output shapes only — not any command that happens
123
+ // to contain the substring "rune-pulse" (a user alias could legitimately contain it).
124
+ // Accepts: (1) npx @rune-kit/rune ..., (2) tier-rendered `${RUNE_*_ROOT}/hooks/...`.
125
+ if (RUNE_TIER_STATUSLINE_RE.test(command)) return true;
126
+ return /(^|\s)npx(\s+--yes)?\s+@rune-kit\/rune\b/.test(command);
127
+ }
128
+
129
+ export async function status(projectRoot) {
130
+ const settingsPath = path.join(projectRoot, SETTINGS_REL_PATH);
131
+ if (!existsSync(settingsPath)) {
132
+ return {
133
+ installed: false,
134
+ preset: null,
135
+ wired: [],
136
+ missing: [...WIRED_SKILLS],
137
+ notes: ['no .claude/settings.json'],
138
+ };
139
+ }
140
+ const settings = await readJson(settingsPath);
141
+ const summary = summarizeRuneHooks(settings);
142
+ const preset = detectPreset(settings);
143
+ const wired = Array.from(new Set(Object.values(summary.events).flat()));
144
+ const missing = WIRED_SKILLS.filter((s) => !wired.includes(s));
145
+ return {
146
+ installed: summary.total > 0,
147
+ preset: preset === 'none' ? null : preset,
148
+ wired,
149
+ missing,
150
+ events: summary.events,
151
+ notes: [],
152
+ };
153
+ }
154
+
155
+ async function readJson(settingsPath) {
156
+ if (!existsSync(settingsPath)) return {};
157
+ const raw = await readFile(settingsPath, 'utf-8');
158
+ if (!raw.trim()) return {};
159
+ try {
160
+ return JSON.parse(raw);
161
+ } catch (err) {
162
+ throw new Error(
163
+ `${settingsPath} is not valid JSON — fix it manually or delete the file and re-run \`rune hooks install\`. (${err.message})`,
164
+ );
165
+ }
166
+ }