@rune-kit/rune 2.16.1 → 2.18.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 +68 -16
- package/compiler/__tests__/adapter-model-mapping.test.js +80 -0
- package/compiler/__tests__/adapters.test.js +115 -3
- package/compiler/__tests__/doctor-mesh.test.js +5 -0
- package/compiler/__tests__/hooks-drift.test.js +156 -0
- package/compiler/__tests__/hooks-merge.test.js +2 -1
- package/compiler/__tests__/setup.test.js +152 -0
- package/compiler/adapters/aider.js +120 -0
- package/compiler/adapters/codex.js +33 -0
- package/compiler/adapters/copilot.js +126 -0
- package/compiler/adapters/gemini.js +131 -0
- package/compiler/adapters/index.js +10 -0
- package/compiler/adapters/openclaw.js +1 -1
- package/compiler/adapters/qoder.js +102 -0
- package/compiler/adapters/qwen.js +111 -0
- package/compiler/bin/rune.js +65 -4
- package/compiler/commands/hooks/drift.js +170 -0
- package/compiler/commands/hooks/presets.js +11 -1
- package/compiler/commands/setup.js +242 -0
- package/compiler/doctor.js +48 -2
- package/compiler/emitter.js +38 -41
- package/compiler/transforms/branding.js +1 -1
- package/compiler/transforms/hooks.js +6 -0
- package/hooks/hooks.json +10 -0
- package/hooks/quarantine/index.cjs +256 -0
- package/hooks/session-start/index.cjs +91 -0
- package/package.json +2 -2
- package/skills/asset-creator/SKILL.md +1 -1
- package/skills/audit/SKILL.md +20 -2
- package/skills/autopsy/SKILL.md +173 -2
- package/skills/brainstorm/SKILL.md +24 -1
- package/skills/browser-pilot/SKILL.md +16 -1
- package/skills/debug/SKILL.md +4 -2
- package/skills/deploy/SKILL.md +72 -2
- package/skills/design/SKILL.md +50 -3
- package/skills/integrity-check/SKILL.md +2 -0
- package/skills/launch/SKILL.md +11 -1
- package/skills/marketing/SKILL.md +1 -1
- package/skills/neural-memory/SKILL.md +1 -1
- package/skills/perf/SKILL.md +93 -2
- package/skills/quarantine/SKILL.md +173 -0
- package/skills/quarantine/references/quarantine-discipline.md +97 -0
- package/skills/quarantine/references/trusted-mcp-allowlist.md +77 -0
- package/skills/sentinel/SKILL.md +2 -1
- package/skills/sentinel-env/SKILL.md +2 -2
- package/skills/skill-forge/SKILL.md +47 -1
- package/skills/surgeon/SKILL.md +1 -1
- package/skills/team/SKILL.md +27 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder Adapter
|
|
3
|
+
*
|
|
4
|
+
* Emits per-skill rule files into .qoder/rules/ — Qoder's documented project-level
|
|
5
|
+
* rule directory. Qoder also reads AGENTS.md as its project-context file.
|
|
6
|
+
*
|
|
7
|
+
* Qoder rules dir: .qoder/rules/
|
|
8
|
+
* Qoder rule file: .qoder/rules/rune-{name}.md (one file per skill)
|
|
9
|
+
* Qoder project context: AGENTS.md (open AGENTS.md standard)
|
|
10
|
+
*
|
|
11
|
+
* @see https://docs.qoder.com/user-guide/rules
|
|
12
|
+
* @see https://agents.md/
|
|
13
|
+
*
|
|
14
|
+
* MODEL TIER MAPPING (v2.18+):
|
|
15
|
+
* Qoder is provider-agnostic — emits semantic tier hints rather than concrete
|
|
16
|
+
* model names. Qoder IDE resolves the tier to its configured provider model.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { BRANDING_FOOTER } from '../transforms/branding.js';
|
|
20
|
+
|
|
21
|
+
const MODEL_MAP = {
|
|
22
|
+
opus: 'tier:heavy',
|
|
23
|
+
sonnet: 'tier:mid',
|
|
24
|
+
haiku: 'tier:light',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const TOOL_MAP = {
|
|
28
|
+
Read: 'read the file',
|
|
29
|
+
Write: 'write/create the file',
|
|
30
|
+
Edit: 'edit the file',
|
|
31
|
+
Glob: 'find files by pattern',
|
|
32
|
+
Grep: 'search file contents',
|
|
33
|
+
Bash: 'run a shell command',
|
|
34
|
+
TodoWrite: 'track task progress',
|
|
35
|
+
Skill: 'follow the referenced rune-{name} rule',
|
|
36
|
+
Agent: 'execute the workflow',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
name: 'qoder',
|
|
41
|
+
outputDir: '.qoder/rules',
|
|
42
|
+
fileExtension: '.md',
|
|
43
|
+
skillPrefix: 'rune-',
|
|
44
|
+
skillSuffix: '',
|
|
45
|
+
|
|
46
|
+
// Qoder rules are flat .md files, not directory-per-skill
|
|
47
|
+
useSkillDirectories: false,
|
|
48
|
+
|
|
49
|
+
transformReference(skillName, raw) {
|
|
50
|
+
const isBackticked = raw.startsWith('`') && raw.endsWith('`');
|
|
51
|
+
const ref = `the rune-${skillName} rule`;
|
|
52
|
+
return isBackticked ? `\`${ref}\`` : ref;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
transformToolName(toolName) {
|
|
56
|
+
return TOOL_MAP[toolName] || toolName;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
generateHeader(skill) {
|
|
60
|
+
const desc = (skill.description || '').replace(/"/g, '\\"');
|
|
61
|
+
const lines = ['---', `name: rune-${skill.name}`, `description: "${desc}"`];
|
|
62
|
+
const translatedModel = skill.model ? MODEL_MAP[skill.model] || skill.model : null;
|
|
63
|
+
if (translatedModel) lines.push(`model: ${translatedModel}`);
|
|
64
|
+
lines.push('---', '', '');
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
generateFooter() {
|
|
69
|
+
return BRANDING_FOOTER;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
transformSubagentInstruction(text) {
|
|
73
|
+
return text;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
scriptsDir(skillName) {
|
|
77
|
+
return `rune-${skillName}-scripts`;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
postProcess(content) {
|
|
81
|
+
return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// Qoder reads AGENTS.md at the project root as its high-level context file.
|
|
85
|
+
generateExtraFiles({ stats }) {
|
|
86
|
+
const agentsMd = [
|
|
87
|
+
'# Rune — Project Configuration',
|
|
88
|
+
'',
|
|
89
|
+
'Rune is an interconnected skill ecosystem for AI coding assistants.',
|
|
90
|
+
`${stats.skillCount} core skills + ${stats.packCount} extension packs.`,
|
|
91
|
+
'',
|
|
92
|
+
'Per-skill rules live under `.qoder/rules/rune-<name>.md`. Qoder loads them automatically.',
|
|
93
|
+
'',
|
|
94
|
+
'Reference a skill by name (e.g. "follow the rune-cook rule") inside any chat — the rule file is auto-injected.',
|
|
95
|
+
'',
|
|
96
|
+
'---',
|
|
97
|
+
'> Rune Skill Mesh — https://github.com/rune-kit/rune',
|
|
98
|
+
'',
|
|
99
|
+
].join('\n');
|
|
100
|
+
return [{ path: 'AGENTS.md', content: agentsMd }];
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qwen Coder Adapter
|
|
3
|
+
*
|
|
4
|
+
* Emits per-skill rule files into qwen/skills/ and a top-level QWEN.md that
|
|
5
|
+
* uses Qwen Code's @import syntax to load each rule file. Qwen Code loads
|
|
6
|
+
* QWEN.md hierarchically (cwd → parent → ~/.qwen/QWEN.md).
|
|
7
|
+
*
|
|
8
|
+
* Qwen rules dir: qwen/skills/
|
|
9
|
+
* Qwen rule file: qwen/skills/rune-{name}.md
|
|
10
|
+
* Qwen project context: QWEN.md at project root (with @path imports)
|
|
11
|
+
*
|
|
12
|
+
* @see https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/
|
|
13
|
+
* @see https://github.com/QwenLM/qwen-code
|
|
14
|
+
*
|
|
15
|
+
* MODEL TIER MAPPING (v2.18+):
|
|
16
|
+
* Qwen Coder defaults to Qwen3-Coder family. Anthropic-style tier names
|
|
17
|
+
* translate to Qwen size hints (heavy=qwen3-coder-plus, mid=qwen3-coder,
|
|
18
|
+
* light=qwen3-coder-flash). Hint only — Qwen Code reads model from settings.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { BRANDING_FOOTER } from '../transforms/branding.js';
|
|
22
|
+
|
|
23
|
+
const MODEL_MAP = {
|
|
24
|
+
opus: 'qwen3-coder-plus',
|
|
25
|
+
sonnet: 'qwen3-coder',
|
|
26
|
+
haiku: 'qwen3-coder-flash',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TOOL_MAP = {
|
|
30
|
+
Read: 'read the file',
|
|
31
|
+
Write: 'write/create the file',
|
|
32
|
+
Edit: 'edit the file',
|
|
33
|
+
Glob: 'find files by pattern',
|
|
34
|
+
Grep: 'search file contents',
|
|
35
|
+
Bash: 'run a shell command',
|
|
36
|
+
TodoWrite: 'track task progress',
|
|
37
|
+
Skill: 'follow the imported rune-{name} skill',
|
|
38
|
+
Agent: 'execute the workflow',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
name: 'qwen',
|
|
43
|
+
outputDir: 'qwen/skills',
|
|
44
|
+
fileExtension: '.md',
|
|
45
|
+
skillPrefix: 'rune-',
|
|
46
|
+
skillSuffix: '',
|
|
47
|
+
|
|
48
|
+
// Qwen skills are flat per-skill markdown files imported from QWEN.md.
|
|
49
|
+
useSkillDirectories: false,
|
|
50
|
+
|
|
51
|
+
transformReference(skillName, raw) {
|
|
52
|
+
const isBackticked = raw.startsWith('`') && raw.endsWith('`');
|
|
53
|
+
const ref = `the rune-${skillName} skill`;
|
|
54
|
+
return isBackticked ? `\`${ref}\`` : ref;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
transformToolName(toolName) {
|
|
58
|
+
return TOOL_MAP[toolName] || toolName;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
generateHeader(skill) {
|
|
62
|
+
const desc = (skill.description || '').replace(/"/g, '\\"');
|
|
63
|
+
const lines = ['---', `name: rune-${skill.name}`, `description: "${desc}"`];
|
|
64
|
+
const translatedModel = skill.model ? MODEL_MAP[skill.model] || skill.model : null;
|
|
65
|
+
if (translatedModel) lines.push(`# model-hint: ${translatedModel}`);
|
|
66
|
+
lines.push('---', '', '');
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
generateFooter() {
|
|
71
|
+
return BRANDING_FOOTER;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
transformSubagentInstruction(text) {
|
|
75
|
+
return text;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
scriptsDir(skillName) {
|
|
79
|
+
return `rune-${skillName}-scripts`;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
postProcess(content) {
|
|
83
|
+
return content.replace(/^context: fork\n/gm, '').replace(/^agent: general-purpose\n/gm, '');
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Emit QWEN.md at project root with @import lines for every per-skill file.
|
|
87
|
+
generateExtraFiles({ stats }) {
|
|
88
|
+
const skillFiles = stats.files
|
|
89
|
+
.filter((f) => f.startsWith('rune-') && f.endsWith('.md') && !f.includes('/') && !f.includes('\\'))
|
|
90
|
+
.sort();
|
|
91
|
+
|
|
92
|
+
const qwenMd = [
|
|
93
|
+
'# Rune — Project Configuration',
|
|
94
|
+
'',
|
|
95
|
+
'Rune is an interconnected skill ecosystem for AI coding assistants.',
|
|
96
|
+
`${stats.skillCount} core skills + ${stats.packCount} extension packs.`,
|
|
97
|
+
'',
|
|
98
|
+
'## Loaded Skills',
|
|
99
|
+
'',
|
|
100
|
+
'Qwen Code loads each referenced file as part of this project context.',
|
|
101
|
+
'',
|
|
102
|
+
...skillFiles.map((f) => `@qwen/skills/${f}`),
|
|
103
|
+
'',
|
|
104
|
+
'---',
|
|
105
|
+
'> Rune Skill Mesh — https://github.com/rune-kit/rune',
|
|
106
|
+
'',
|
|
107
|
+
].join('\n');
|
|
108
|
+
|
|
109
|
+
return [{ path: 'QWEN.md', content: qwenMd }];
|
|
110
|
+
},
|
|
111
|
+
};
|
package/compiler/bin/rune.js
CHANGED
|
@@ -13,15 +13,18 @@
|
|
|
13
13
|
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import os from 'node:os';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
import { createInterface } from 'node:readline';
|
|
18
19
|
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { getAdapter, listPlatforms } from '../adapters/index.js';
|
|
20
21
|
import { getAllAnalytics } from '../analytics.js';
|
|
21
22
|
import { dispatchHook } from '../commands/hook-dispatch.js';
|
|
23
|
+
import { checkHookDrift, formatHookDriftResult } from '../commands/hooks/drift.js';
|
|
22
24
|
import { installHooks } from '../commands/hooks/install.js';
|
|
23
25
|
import { hookStatus } from '../commands/hooks/status.js';
|
|
24
26
|
import { uninstallHooks } from '../commands/hooks/uninstall.js';
|
|
27
|
+
import { formatSetupResult, runSetup } from '../commands/setup.js';
|
|
25
28
|
import { generateDashboardHTML } from '../dashboard.js';
|
|
26
29
|
import { checkMeshIntegrity, formatDoctorResults, formatMeshResults, runDoctor } from '../doctor.js';
|
|
27
30
|
import { buildAll } from '../emitter.js';
|
|
@@ -241,6 +244,14 @@ async function cmdBuild(projectRoot, args) {
|
|
|
241
244
|
async function cmdDoctor(projectRoot, args) {
|
|
242
245
|
const config = await readConfig(projectRoot);
|
|
243
246
|
|
|
247
|
+
// --hooks flag: run hook drift report only (reporter, exit 0 always)
|
|
248
|
+
if (args.hooks) {
|
|
249
|
+
log('');
|
|
250
|
+
const driftResult = await checkHookDrift(projectRoot);
|
|
251
|
+
log(formatHookDriftResult(driftResult));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
244
255
|
// --mesh flag: run mesh integrity check only
|
|
245
256
|
if (args.mesh) {
|
|
246
257
|
log('');
|
|
@@ -294,6 +305,45 @@ async function cmdDoctor(projectRoot, args) {
|
|
|
294
305
|
if (!results.healthy) process.exit(1);
|
|
295
306
|
}
|
|
296
307
|
|
|
308
|
+
async function cmdSetup(projectRoot, args) {
|
|
309
|
+
log('');
|
|
310
|
+
log(' Rune Setup Wizard');
|
|
311
|
+
log(' ──────────────────');
|
|
312
|
+
log(` Free version: ${await readVersion()} (cached)`);
|
|
313
|
+
|
|
314
|
+
// Print detected tiers banner
|
|
315
|
+
const { detectTiers } = await import('../commands/setup.js');
|
|
316
|
+
const detected = detectTiers(projectRoot);
|
|
317
|
+
if (detected.pro) {
|
|
318
|
+
log(` Pro detected: ${detected.pro.source} (v${detected.pro.version})`);
|
|
319
|
+
} else {
|
|
320
|
+
log(' Pro: not detected');
|
|
321
|
+
}
|
|
322
|
+
if (detected.business) {
|
|
323
|
+
log(` Business: ${detected.business.source} (v${detected.business.version})`);
|
|
324
|
+
} else {
|
|
325
|
+
log(' Business: not detected');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const result = await runSetup({ projectRoot, runeRoot: RUNE_ROOT, args });
|
|
330
|
+
log(formatSetupResult(result));
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log('');
|
|
333
|
+
log(` ✗ Setup failed: ${err.message}`);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function readVersion() {
|
|
339
|
+
try {
|
|
340
|
+
const pkg = JSON.parse(await readFile(path.join(RUNE_ROOT, 'package.json'), 'utf-8'));
|
|
341
|
+
return pkg.version;
|
|
342
|
+
} catch {
|
|
343
|
+
return 'unknown';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
297
347
|
async function cmdStatus(projectRoot, args) {
|
|
298
348
|
const config = await readConfig(projectRoot);
|
|
299
349
|
const tierSources = resolveTierSources(config?.tiers, projectRoot);
|
|
@@ -458,10 +508,13 @@ async function cmdHooks(projectRoot, args, subcommand) {
|
|
|
458
508
|
switch (subcommand) {
|
|
459
509
|
case 'install': {
|
|
460
510
|
const tierArg = parseTierFlag(args.tier);
|
|
461
|
-
|
|
511
|
+
// --global writes to ~/.claude/settings.json (covers every Claude Code session)
|
|
512
|
+
const targetRoot = args.global ? os.homedir() : projectRoot;
|
|
513
|
+
const platform = args.global ? 'claude' : args.platform;
|
|
514
|
+
const result = await installHooks(targetRoot, {
|
|
462
515
|
preset: args.preset,
|
|
463
516
|
dry: args.dry,
|
|
464
|
-
platform
|
|
517
|
+
platform,
|
|
465
518
|
tier: tierArg,
|
|
466
519
|
});
|
|
467
520
|
log('');
|
|
@@ -636,6 +689,9 @@ async function main() {
|
|
|
636
689
|
case 'doctor':
|
|
637
690
|
await cmdDoctor(projectRoot, args);
|
|
638
691
|
break;
|
|
692
|
+
case 'setup':
|
|
693
|
+
await cmdSetup(projectRoot, args);
|
|
694
|
+
break;
|
|
639
695
|
case 'status':
|
|
640
696
|
await cmdStatus(projectRoot, args);
|
|
641
697
|
break;
|
|
@@ -664,17 +720,22 @@ async function main() {
|
|
|
664
720
|
log(' Rune CLI — Skill mesh for AI coding assistants');
|
|
665
721
|
log('');
|
|
666
722
|
log(' Commands:');
|
|
667
|
-
log(
|
|
723
|
+
log(
|
|
724
|
+
' setup Interactive wizard — auto-detect tiers, pick scope, install hooks (recommended for first-time)',
|
|
725
|
+
);
|
|
726
|
+
log(' [--here|--global] [--tier pro,business] [--preset gentle|strict] [--dry]');
|
|
727
|
+
log(' init Interactive setup for build pipeline (auto-detects platform)');
|
|
668
728
|
log(' build Compile skills for configured platform');
|
|
669
729
|
log(' doctor Validate compiled output + mesh integrity');
|
|
670
730
|
log(' --mesh Mesh integrity only (reciprocals, versions, sections)');
|
|
731
|
+
log(' --hooks Hook drift report — compare installed vs canonical preset (reporter, exit 0)');
|
|
671
732
|
log(' --strict Fail on warnings (for CI)');
|
|
672
733
|
log(' status Project dashboard (skills, signals, packs, health)');
|
|
673
734
|
log(' visualize Interactive mesh graph (opens in browser)');
|
|
674
735
|
log(' analytics Usage analytics dashboard (Business tier)');
|
|
675
736
|
log(' hooks Install/uninstall/status for multi-platform auto-discipline');
|
|
676
737
|
log(
|
|
677
|
-
' hooks install [--preset gentle|strict|off] [--platform claude|cursor|windsurf|antigravity|all]',
|
|
738
|
+
' hooks install [--preset gentle|strict|off] [--platform claude|cursor|windsurf|antigravity|all] [--global]',
|
|
678
739
|
);
|
|
679
740
|
log(' hooks uninstall [--platform <name>|all]');
|
|
680
741
|
log(' hooks status [--platform <name>|all]');
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook drift reporter.
|
|
3
|
+
*
|
|
4
|
+
* Compares Rune-managed Free-preset entries in `.claude/settings.json` against
|
|
5
|
+
* the canonical `buildPreset()` output for the detected preset. Flags missing
|
|
6
|
+
* entries (preset wired more than what's installed) and drifted entries
|
|
7
|
+
* (installed command differs from canonical shape).
|
|
8
|
+
*
|
|
9
|
+
* Reporter, NOT a gate — exit 0 always. Operators legitimately edit settings.json
|
|
10
|
+
* (custom user hooks alongside Rune hooks). Auto-fix would be hostile.
|
|
11
|
+
*
|
|
12
|
+
* Scope: Free preset only. Tier-emitted entries (`${RUNE_PRO_ROOT}` /
|
|
13
|
+
* `${RUNE_BUSINESS_ROOT}`) are filtered out — those are checked separately
|
|
14
|
+
* by tier doctor (out of scope for v2.17.0).
|
|
15
|
+
*
|
|
16
|
+
* Use case: diagnostic before users file "skill is broken" issues. Local drift
|
|
17
|
+
* is a common cause of unexplained hook behavior — a check that points at the
|
|
18
|
+
* actual deviation saves a round-trip with the maintainer.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { detectPlatforms } from '../../adapters/hooks/index.js';
|
|
25
|
+
import { detectPreset } from './merge.js';
|
|
26
|
+
import { buildPreset, isRuneManaged, SETTINGS_REL_PATH } from './presets.js';
|
|
27
|
+
|
|
28
|
+
const TIER_ENV_RE = /\$\{RUNE_[A-Z][A-Z0-9_]*_ROOT\}/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} projectRoot
|
|
32
|
+
* @returns {Promise<{findings: Array, summary: object, platforms: string[]}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function checkHookDrift(projectRoot) {
|
|
35
|
+
const platforms = detectPlatforms(projectRoot);
|
|
36
|
+
const findings = [];
|
|
37
|
+
const checked = [];
|
|
38
|
+
|
|
39
|
+
for (const id of platforms) {
|
|
40
|
+
if (id !== 'claude') continue; // Settings drift is Claude-specific
|
|
41
|
+
const settingsPath = path.join(projectRoot, SETTINGS_REL_PATH);
|
|
42
|
+
if (!existsSync(settingsPath)) continue;
|
|
43
|
+
|
|
44
|
+
let settings;
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(settingsPath, 'utf-8');
|
|
47
|
+
settings = raw.trim() ? JSON.parse(raw) : {};
|
|
48
|
+
} catch (err) {
|
|
49
|
+
findings.push({
|
|
50
|
+
platform: id,
|
|
51
|
+
event: null,
|
|
52
|
+
status: 'error',
|
|
53
|
+
message: `Cannot parse ${SETTINGS_REL_PATH}: ${err.message}`,
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const preset = detectPreset(settings);
|
|
59
|
+
if (preset === 'none') continue; // No Rune hooks installed
|
|
60
|
+
if (preset === 'mixed') {
|
|
61
|
+
findings.push({
|
|
62
|
+
platform: id,
|
|
63
|
+
event: null,
|
|
64
|
+
status: 'mixed-preset',
|
|
65
|
+
message:
|
|
66
|
+
'settings.json contains both gentle and strict Rune entries — re-run `rune hooks install --preset <gentle|strict>` to converge',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// For mixed preset, fall back to gentle for canonical comparison
|
|
71
|
+
const canonicalPreset = preset === 'mixed' ? 'gentle' : preset;
|
|
72
|
+
const canonical = buildPreset(canonicalPreset);
|
|
73
|
+
checked.push({ platform: id, preset });
|
|
74
|
+
|
|
75
|
+
for (const event of ['PreToolUse', 'PostToolUse', 'Stop']) {
|
|
76
|
+
const actualCommands = collectFreePresetCommands(settings.hooks?.[event] || []);
|
|
77
|
+
const canonicalCommands = collectFreePresetCommands(canonical.hooks?.[event] || []);
|
|
78
|
+
|
|
79
|
+
// Missing: canonical entry not present in actual
|
|
80
|
+
for (const cmd of canonicalCommands) {
|
|
81
|
+
if (!actualCommands.includes(cmd)) {
|
|
82
|
+
findings.push({ platform: id, event, status: 'missing', expected: cmd });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Drift: actual entry not in canonical (extra or modified)
|
|
87
|
+
for (const cmd of actualCommands) {
|
|
88
|
+
if (!canonicalCommands.includes(cmd)) {
|
|
89
|
+
findings.push({ platform: id, event, status: 'drift', actual: cmd });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const summary = {
|
|
96
|
+
drifted: findings.filter((f) => f.status === 'drift').length,
|
|
97
|
+
missing: findings.filter((f) => f.status === 'missing').length,
|
|
98
|
+
errors: findings.filter((f) => f.status === 'error' || f.status === 'mixed-preset').length,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return { findings, summary, platforms: checked };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pull Rune-managed Free-preset commands out of a matcher-group list.
|
|
106
|
+
* Tier-emitted entries (`${RUNE_*_ROOT}/...`) are excluded — those are tier-managed,
|
|
107
|
+
* not Free-preset, and have their own check path.
|
|
108
|
+
*/
|
|
109
|
+
function collectFreePresetCommands(matcherGroups) {
|
|
110
|
+
const cmds = [];
|
|
111
|
+
if (!Array.isArray(matcherGroups)) return cmds;
|
|
112
|
+
for (const group of matcherGroups) {
|
|
113
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
114
|
+
for (const entry of group.hooks) {
|
|
115
|
+
if (!isRuneManaged(entry)) continue;
|
|
116
|
+
if (TIER_ENV_RE.test(entry.command)) continue; // tier entry — not our concern here
|
|
117
|
+
cmds.push(entry.command);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return cmds;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format drift result as human-readable lines.
|
|
125
|
+
*
|
|
126
|
+
* @param {Awaited<ReturnType<typeof checkHookDrift>>} result
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function formatHookDriftResult(result) {
|
|
130
|
+
const lines = [];
|
|
131
|
+
lines.push(' Hook Drift Report');
|
|
132
|
+
lines.push(' ──────────────────');
|
|
133
|
+
|
|
134
|
+
if (result.platforms.length === 0) {
|
|
135
|
+
lines.push('');
|
|
136
|
+
lines.push(' ℹ No installed Rune hooks detected on Claude. Run `rune hooks install --preset gentle` first.');
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const { platform, preset } of result.platforms) {
|
|
141
|
+
lines.push(` Platform: ${platform} (preset: ${preset})`);
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
if (result.findings.length === 0) {
|
|
146
|
+
lines.push(' ✓ All Rune-managed entries match canonical preset.');
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const f of result.findings) {
|
|
151
|
+
if (f.status === 'mixed-preset' || f.status === 'error') {
|
|
152
|
+
lines.push(` ⚠ ${f.platform}: ${f.message}`);
|
|
153
|
+
} else if (f.status === 'missing') {
|
|
154
|
+
lines.push(` ✗ ${f.platform} ${f.event}: missing canonical entry`);
|
|
155
|
+
lines.push(` expected: ${f.expected}`);
|
|
156
|
+
} else if (f.status === 'drift') {
|
|
157
|
+
lines.push(` ✗ ${f.platform} ${f.event}: drift — installed command not in canonical preset`);
|
|
158
|
+
lines.push(` actual: ${f.actual}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push(
|
|
164
|
+
` Summary: ${result.summary.drifted} drifted, ${result.summary.missing} missing, ${result.summary.errors} error(s).`,
|
|
165
|
+
);
|
|
166
|
+
lines.push(' Resolution: re-run `rune hooks install --preset <gentle|strict>` to re-converge.');
|
|
167
|
+
lines.push(' This is a reporter — operator decides what to do with the findings.');
|
|
168
|
+
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
@@ -81,6 +81,16 @@ export function buildPreset(preset) {
|
|
|
81
81
|
},
|
|
82
82
|
],
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
matcher: 'mcp__.*|WebFetch|Read',
|
|
86
|
+
hooks: [
|
|
87
|
+
{
|
|
88
|
+
type: 'command',
|
|
89
|
+
command: `${DISPATCH_CMD} quarantine${flag}`,
|
|
90
|
+
async: true,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
84
94
|
],
|
|
85
95
|
Stop: [
|
|
86
96
|
{
|
|
@@ -101,7 +111,7 @@ export function buildPreset(preset) {
|
|
|
101
111
|
/**
|
|
102
112
|
* Skills wired by presets — used by `rune hooks status` to verify skill existence.
|
|
103
113
|
*/
|
|
104
|
-
export const WIRED_SKILLS = ['preflight', 'sentinel', 'dependency-doctor', 'completion-gate'];
|
|
114
|
+
export const WIRED_SKILLS = ['preflight', 'sentinel', 'dependency-doctor', 'completion-gate', 'quarantine'];
|
|
105
115
|
|
|
106
116
|
/**
|
|
107
117
|
* Detect if a hook command entry is Rune-managed.
|