@rune-kit/rune 2.4.0 → 2.6.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.
@@ -1,247 +1,267 @@
1
- /**
2
- * SKILL.md Parser
3
- *
4
- * Parses SKILL.md files into a structured intermediate representation (IR).
5
- * Extracts frontmatter, cross-references, tool references, HARD-GATE blocks, and sections.
6
- */
7
-
8
- const CROSS_REF_PATTERN = /`?rune:([a-z][\w-]*)`?/g;
9
- const TOOL_REF_PATTERN = /`(Read|Write|Edit|Glob|Grep|Bash|TodoWrite|Skill|Agent)`/g;
10
- const HARD_GATE_PATTERN = /<HARD-GATE>([\s\S]*?)<\/HARD-GATE>/g;
11
- const _SECTION_PATTERN = /^## (.+)$/gm;
12
-
13
- /**
14
- * Parse YAML-like frontmatter from SKILL.md
15
- * Handles nested metadata block
16
- */
17
- function parseFrontmatter(content) {
18
- // Normalize line endings to \n for cross-platform compatibility
19
- const normalized = content.replace(/\r\n/g, '\n');
20
- const match = normalized.match(/^---\n([\s\S]*?)\n---/);
21
- if (!match) return { frontmatter: {}, body: normalized };
22
-
23
- const raw = match[1];
24
- const body = normalized.slice(match[0].length).trim();
25
- const frontmatter = {};
26
-
27
- let currentIndent = null;
28
- let nestedKey = null;
29
- const _nestedObj = {};
30
-
31
- for (const line of raw.split('\n')) {
32
- const trimmed = line.trim();
33
- if (!trimmed) continue;
34
-
35
- // Detect nested block (e.g., metadata:)
36
- if (/^\w+:\s*$/.test(trimmed)) {
37
- nestedKey = trimmed.replace(':', '').trim();
38
- frontmatter[nestedKey] = {};
39
- currentIndent = 'nested';
40
- continue;
41
- }
42
-
43
- if (currentIndent === 'nested' && line.startsWith(' ')) {
44
- const kvMatch = trimmed.match(/^(\w+):\s*(.+)$/);
45
- if (kvMatch) {
46
- const value = kvMatch[2].replace(/^["']|["']$/g, '');
47
- frontmatter[nestedKey][kvMatch[1]] = value;
48
- }
49
- continue;
50
- }
51
-
52
- // Top-level key-value
53
- currentIndent = null;
54
- nestedKey = null;
55
- const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.+)$/);
56
- if (kvMatch) {
57
- const value = kvMatch[2].replace(/^["']|["']$/g, '');
58
- frontmatter[kvMatch[1]] = value;
59
- }
60
- }
61
-
62
- return { frontmatter, body };
63
- }
64
-
65
- /**
66
- * Extract all rune:<name> cross-references from body
67
- */
68
- export function extractCrossRefs(body) {
69
- const refs = [];
70
- const lines = body.split('\n');
71
-
72
- for (let i = 0; i < lines.length; i++) {
73
- const line = lines[i];
74
- let match;
75
- const regex = new RegExp(CROSS_REF_PATTERN.source, 'g');
76
-
77
- while ((match = regex.exec(line)) !== null) {
78
- refs.push({
79
- raw: match[0],
80
- skillName: match[1],
81
- line: i + 1,
82
- context: line.trim(),
83
- });
84
- }
85
- }
86
-
87
- return refs;
88
- }
89
-
90
- /**
91
- * Extract all Claude Code tool references from body
92
- */
93
- export function extractToolRefs(body) {
94
- const refs = [];
95
- const lines = body.split('\n');
96
-
97
- for (let i = 0; i < lines.length; i++) {
98
- const line = lines[i];
99
- let match;
100
- const regex = new RegExp(TOOL_REF_PATTERN.source, 'g');
101
-
102
- while ((match = regex.exec(line)) !== null) {
103
- refs.push({
104
- raw: match[0],
105
- toolName: match[1],
106
- line: i + 1,
107
- context: line.trim(),
108
- });
109
- }
110
- }
111
-
112
- return refs;
113
- }
114
-
115
- /**
116
- * Extract HARD-GATE blocks
117
- */
118
- function extractHardGates(body) {
119
- const gates = [];
120
- let match;
121
- const regex = new RegExp(HARD_GATE_PATTERN.source, 'gs');
122
-
123
- while ((match = regex.exec(body)) !== null) {
124
- gates.push(match[1].trim());
125
- }
126
-
127
- return gates;
128
- }
129
-
130
- /**
131
- * Extract ## sections as a Map<sectionName, content>
132
- */
133
- function extractSections(body) {
134
- const sections = new Map();
135
- const lines = body.split('\n');
136
- let currentSection = null;
137
- let currentContent = [];
138
-
139
- for (const line of lines) {
140
- const sectionMatch = line.match(/^## (.+)$/);
141
- if (sectionMatch) {
142
- if (currentSection) {
143
- sections.set(currentSection, currentContent.join('\n').trim());
144
- }
145
- currentSection = sectionMatch[1];
146
- currentContent = [];
147
- } else if (currentSection) {
148
- currentContent.push(line);
149
- }
150
- }
151
-
152
- if (currentSection) {
153
- sections.set(currentSection, currentContent.join('\n').trim());
154
- }
155
-
156
- return sections;
157
- }
158
-
159
- /**
160
- * Parse a SKILL.md file content into structured IR
161
- *
162
- * @param {string} content - raw SKILL.md content
163
- * @param {string} [filePath] - optional file path for error reporting
164
- * @returns {ParsedSkill}
165
- */
166
- export function parseSkill(content, filePath = '') {
167
- const { frontmatter, body } = parseFrontmatter(content);
168
-
169
- const metadata = frontmatter.metadata || {};
170
-
171
- return {
172
- name: frontmatter.name || '',
173
- description: frontmatter.description || '',
174
- layer: metadata.layer || 'L2',
175
- model: metadata.model || 'sonnet',
176
- group: metadata.group || 'general',
177
- contextFork: frontmatter.context === 'fork',
178
- agentType: frontmatter.agent || null,
179
- body,
180
- crossRefs: extractCrossRefs(body),
181
- toolRefs: extractToolRefs(body),
182
- hardGates: extractHardGates(body),
183
- sections: extractSections(body),
184
- filePath,
185
- frontmatter,
186
- };
187
- }
188
-
189
- /**
190
- * Parse a PACK.md extension file (same format, slightly different metadata)
191
- * Supports both monolith format (all skills inline) and split format (skills in separate files)
192
- *
193
- * Split format detection: metadata.format === "split" OR metadata.skills array present
194
- */
195
- export function parsePack(content, filePath = '') {
196
- const { frontmatter, body } = parseFrontmatter(content);
197
- const metadata = frontmatter.metadata || {};
198
-
199
- // Detect split format
200
- const isSplit = metadata.format === 'split' || Array.isArray(metadata.skills);
201
-
202
- return {
203
- name: frontmatter.name || '',
204
- description: frontmatter.description || '',
205
- version: metadata.version || frontmatter.version || '1.0.0',
206
- layer: 'L4',
207
- group: 'extension',
208
- isSplit,
209
- skillManifest: isSplit ? parseSkillManifest(metadata.skills || []) : [],
210
- body,
211
- crossRefs: extractCrossRefs(body),
212
- toolRefs: extractToolRefs(body),
213
- hardGates: extractHardGates(body),
214
- sections: extractSections(body),
215
- filePath,
216
- frontmatter,
217
- };
218
- }
219
-
220
- /**
221
- * Parse the skills manifest from split PACK.md frontmatter
222
- *
223
- * @param {Array} skills - array of skill entries from frontmatter metadata.skills
224
- * @returns {Array<{name: string, file: string, model: string, description: string}>}
225
- */
226
- function parseSkillManifest(skills) {
227
- if (!Array.isArray(skills)) return [];
228
-
229
- return skills.map((skill) => {
230
- // Handle both string format ("skill-name") and object format ({name, file, model, description})
231
- if (typeof skill === 'string') {
232
- return {
233
- name: skill,
234
- file: `skills/${skill}.md`,
235
- model: 'sonnet',
236
- description: '',
237
- };
238
- }
239
-
240
- return {
241
- name: skill.name || '',
242
- file: skill.file || `skills/${skill.name}.md`,
243
- model: skill.model || 'sonnet',
244
- description: skill.description || '',
245
- };
246
- });
247
- }
1
+ /**
2
+ * SKILL.md Parser
3
+ *
4
+ * Parses SKILL.md files into a structured intermediate representation (IR).
5
+ * Extracts frontmatter, cross-references, tool references, HARD-GATE blocks, and sections.
6
+ */
7
+
8
+ /**
9
+ * Metadata fields that should be parsed as comma-separated arrays
10
+ * e.g. emit: code.changed, tests.passed → ["code.changed", "tests.passed"]
11
+ */
12
+ const COMMA_LIST_FIELDS = new Set(['emit', 'listen']);
13
+
14
+ const CROSS_REF_PATTERN = /`?rune:([a-z][\w-]*)`?/g;
15
+ const TOOL_REF_PATTERN = /`(Read|Write|Edit|Glob|Grep|Bash|TodoWrite|Skill|Agent)`/g;
16
+ const HARD_GATE_PATTERN = /<HARD-GATE>([\s\S]*?)<\/HARD-GATE>/g;
17
+ const _SECTION_PATTERN = /^## (.+)$/gm;
18
+
19
+ /**
20
+ * Parse YAML-like frontmatter from SKILL.md
21
+ * Handles nested metadata block
22
+ */
23
+ function parseFrontmatter(content) {
24
+ // Normalize line endings to \n for cross-platform compatibility
25
+ const normalized = content.replace(/\r\n/g, '\n');
26
+ const match = normalized.match(/^---\n([\s\S]*?)\n---/);
27
+ if (!match) return { frontmatter: {}, body: normalized };
28
+
29
+ const raw = match[1];
30
+ const body = normalized.slice(match[0].length).trim();
31
+ const frontmatter = {};
32
+
33
+ let currentIndent = null;
34
+ let nestedKey = null;
35
+ const _nestedObj = {};
36
+
37
+ for (const line of raw.split('\n')) {
38
+ const trimmed = line.trim();
39
+ if (!trimmed) continue;
40
+
41
+ // Detect nested block (e.g., metadata:)
42
+ if (/^\w+:\s*$/.test(trimmed)) {
43
+ nestedKey = trimmed.replace(':', '').trim();
44
+ frontmatter[nestedKey] = {};
45
+ currentIndent = 'nested';
46
+ continue;
47
+ }
48
+
49
+ if (currentIndent === 'nested' && line.startsWith(' ')) {
50
+ const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.+)$/);
51
+ if (kvMatch) {
52
+ const rawValue = kvMatch[2].replace(/^["']|["']$/g, '');
53
+ // Comma-separated list fields → parse as array
54
+ if (COMMA_LIST_FIELDS.has(kvMatch[1])) {
55
+ frontmatter[nestedKey][kvMatch[1]] = rawValue
56
+ .split(',')
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ } else {
60
+ frontmatter[nestedKey][kvMatch[1]] = rawValue;
61
+ }
62
+ }
63
+ continue;
64
+ }
65
+
66
+ // Top-level key-value
67
+ currentIndent = null;
68
+ nestedKey = null;
69
+ const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.+)$/);
70
+ if (kvMatch) {
71
+ const value = kvMatch[2].replace(/^["']|["']$/g, '');
72
+ frontmatter[kvMatch[1]] = value;
73
+ }
74
+ }
75
+
76
+ return { frontmatter, body };
77
+ }
78
+
79
+ /**
80
+ * Extract all rune:<name> cross-references from body
81
+ */
82
+ export function extractCrossRefs(body) {
83
+ const refs = [];
84
+ const lines = body.split('\n');
85
+
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const line = lines[i];
88
+ let match;
89
+ const regex = new RegExp(CROSS_REF_PATTERN.source, 'g');
90
+
91
+ while ((match = regex.exec(line)) !== null) {
92
+ refs.push({
93
+ raw: match[0],
94
+ skillName: match[1],
95
+ line: i + 1,
96
+ context: line.trim(),
97
+ });
98
+ }
99
+ }
100
+
101
+ return refs;
102
+ }
103
+
104
+ /**
105
+ * Extract all Claude Code tool references from body
106
+ */
107
+ export function extractToolRefs(body) {
108
+ const refs = [];
109
+ const lines = body.split('\n');
110
+
111
+ for (let i = 0; i < lines.length; i++) {
112
+ const line = lines[i];
113
+ let match;
114
+ const regex = new RegExp(TOOL_REF_PATTERN.source, 'g');
115
+
116
+ while ((match = regex.exec(line)) !== null) {
117
+ refs.push({
118
+ raw: match[0],
119
+ toolName: match[1],
120
+ line: i + 1,
121
+ context: line.trim(),
122
+ });
123
+ }
124
+ }
125
+
126
+ return refs;
127
+ }
128
+
129
+ /**
130
+ * Extract HARD-GATE blocks
131
+ */
132
+ function extractHardGates(body) {
133
+ const gates = [];
134
+ let match;
135
+ const regex = new RegExp(HARD_GATE_PATTERN.source, 'gs');
136
+
137
+ while ((match = regex.exec(body)) !== null) {
138
+ gates.push(match[1].trim());
139
+ }
140
+
141
+ return gates;
142
+ }
143
+
144
+ /**
145
+ * Extract ## sections as a Map<sectionName, content>
146
+ */
147
+ function extractSections(body) {
148
+ const sections = new Map();
149
+ const lines = body.split('\n');
150
+ let currentSection = null;
151
+ let currentContent = [];
152
+
153
+ for (const line of lines) {
154
+ const sectionMatch = line.match(/^## (.+)$/);
155
+ if (sectionMatch) {
156
+ if (currentSection) {
157
+ sections.set(currentSection, currentContent.join('\n').trim());
158
+ }
159
+ currentSection = sectionMatch[1];
160
+ currentContent = [];
161
+ } else if (currentSection) {
162
+ currentContent.push(line);
163
+ }
164
+ }
165
+
166
+ if (currentSection) {
167
+ sections.set(currentSection, currentContent.join('\n').trim());
168
+ }
169
+
170
+ return sections;
171
+ }
172
+
173
+ /**
174
+ * Parse a SKILL.md file content into structured IR
175
+ *
176
+ * @param {string} content - raw SKILL.md content
177
+ * @param {string} [filePath] - optional file path for error reporting
178
+ * @returns {ParsedSkill}
179
+ */
180
+ export function parseSkill(content, filePath = '') {
181
+ const { frontmatter, body } = parseFrontmatter(content);
182
+
183
+ const metadata = frontmatter.metadata || {};
184
+
185
+ // Extract signals — emit/listen arrays from metadata
186
+ const emit = Array.isArray(metadata.emit) ? metadata.emit : [];
187
+ const listen = Array.isArray(metadata.listen) ? metadata.listen : [];
188
+ const signals = emit.length > 0 || listen.length > 0 ? { emit, listen } : null;
189
+
190
+ return {
191
+ name: frontmatter.name || '',
192
+ description: frontmatter.description || '',
193
+ layer: metadata.layer || 'L2',
194
+ model: metadata.model || 'sonnet',
195
+ group: metadata.group || 'general',
196
+ signals,
197
+ contextFork: frontmatter.context === 'fork',
198
+ agentType: frontmatter.agent || null,
199
+ body,
200
+ crossRefs: extractCrossRefs(body),
201
+ toolRefs: extractToolRefs(body),
202
+ hardGates: extractHardGates(body),
203
+ sections: extractSections(body),
204
+ filePath,
205
+ frontmatter,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Parse a PACK.md extension file (same format, slightly different metadata)
211
+ * Supports both monolith format (all skills inline) and split format (skills in separate files)
212
+ *
213
+ * Split format detection: metadata.format === "split" OR metadata.skills array present
214
+ */
215
+ export function parsePack(content, filePath = '') {
216
+ const { frontmatter, body } = parseFrontmatter(content);
217
+ const metadata = frontmatter.metadata || {};
218
+
219
+ // Detect split format
220
+ const isSplit = metadata.format === 'split' || Array.isArray(metadata.skills);
221
+
222
+ return {
223
+ name: frontmatter.name || '',
224
+ description: frontmatter.description || '',
225
+ version: metadata.version || frontmatter.version || '1.0.0',
226
+ layer: 'L4',
227
+ group: 'extension',
228
+ isSplit,
229
+ skillManifest: isSplit ? parseSkillManifest(metadata.skills || []) : [],
230
+ body,
231
+ crossRefs: extractCrossRefs(body),
232
+ toolRefs: extractToolRefs(body),
233
+ hardGates: extractHardGates(body),
234
+ sections: extractSections(body),
235
+ filePath,
236
+ frontmatter,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Parse the skills manifest from split PACK.md frontmatter
242
+ *
243
+ * @param {Array} skills - array of skill entries from frontmatter metadata.skills
244
+ * @returns {Array<{name: string, file: string, model: string, description: string}>}
245
+ */
246
+ function parseSkillManifest(skills) {
247
+ if (!Array.isArray(skills)) return [];
248
+
249
+ return skills.map((skill) => {
250
+ // Handle both string format ("skill-name") and object format ({name, file, model, description})
251
+ if (typeof skill === 'string') {
252
+ return {
253
+ name: skill,
254
+ file: `skills/${skill}.md`,
255
+ model: 'sonnet',
256
+ description: '',
257
+ };
258
+ }
259
+
260
+ return {
261
+ name: skill.name || '',
262
+ file: skill.file || `skills/${skill.name}.md`,
263
+ model: skill.model || 'sonnet',
264
+ description: skill.description || '',
265
+ };
266
+ });
267
+ }
package/hooks/hooks.json CHANGED
@@ -12,6 +12,18 @@
12
12
  ]
13
13
  }
14
14
  ],
15
+ "UserPromptSubmit": [
16
+ {
17
+ "matcher": ".*",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" intent-router",
22
+ "async": true
23
+ }
24
+ ]
25
+ }
26
+ ],
15
27
  "PreToolUse": [
16
28
  {
17
29
  "matcher": "Read|Write|Edit",
@@ -0,0 +1,108 @@
1
+ // Rune Intent Router Hook — Compiled Intent Mesh (CIM)
2
+ // Auto-suggests skill routing based on user prompt analysis
3
+ // Runs as UserPromptSubmit hook — scores prompt against compiled skill-index.json
4
+ //
5
+ // Key difference from runtime scoring approaches:
6
+ // - Compile-time generated index (zero runtime deps)
7
+ // - Mesh-aware chain prediction (uses actual connection graph)
8
+ // - Works on all platforms (index ships with every build)
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ // Read user prompt from Claude Code hook stdin
14
+ let input = '';
15
+ process.stdin.setEncoding('utf-8');
16
+ process.stdin.on('data', (chunk) => { input += chunk; });
17
+ process.stdin.on('end', () => {
18
+ let userPrompt = '';
19
+ try {
20
+ const parsed = JSON.parse(input);
21
+ userPrompt = parsed.user_prompt || parsed.prompt || parsed.content || '';
22
+ } catch {
23
+ // Raw text input
24
+ userPrompt = input.trim();
25
+ }
26
+
27
+ if (!userPrompt || userPrompt.length < 3) {
28
+ process.exit(0);
29
+ }
30
+
31
+ // Find skill-index.json — check multiple locations
32
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '../..');
33
+ const candidates = [
34
+ path.join(pluginRoot, '.cursor', 'rules', 'skill-index.json'),
35
+ path.join(pluginRoot, '.windsurf', 'rules', 'skill-index.json'),
36
+ path.join(pluginRoot, 'dist', 'cursor', 'skill-index.json'),
37
+ path.join(pluginRoot, 'dist', 'generic', 'skill-index.json'),
38
+ // Also check .rune/ for locally cached index
39
+ path.join(process.cwd(), '.rune', 'skill-index.json'),
40
+ ];
41
+
42
+ let index = null;
43
+ for (const candidate of candidates) {
44
+ try {
45
+ if (fs.existsSync(candidate)) {
46
+ index = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
47
+ break;
48
+ }
49
+ } catch {
50
+ // Try next candidate
51
+ }
52
+ }
53
+
54
+ if (!index || !index.intents) {
55
+ // No index available — exit silently (not an error, just not built yet)
56
+ process.exit(0);
57
+ }
58
+
59
+ // Score prompt against intent patterns
60
+ const promptLower = userPrompt.toLowerCase();
61
+ const scores = [];
62
+
63
+ for (const [skillName, intent] of Object.entries(index.intents)) {
64
+ let score = 0;
65
+
66
+ for (const keyword of intent.keywords) {
67
+ if (promptLower.includes(keyword)) {
68
+ // Exact word boundary match scores higher
69
+ const wordBoundary = new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
70
+ score += wordBoundary.test(userPrompt) ? 3 : 1;
71
+ }
72
+ }
73
+
74
+ // Layer priority boost: L1 orchestrators get a small bonus (they're entry points)
75
+ if (intent.layer === 'L1') score += 1;
76
+
77
+ if (score > 0) {
78
+ scores.push({ skill: skillName, score, chain: intent.chain, model: intent.model, layer: intent.layer });
79
+ }
80
+ }
81
+
82
+ if (scores.length === 0) {
83
+ process.exit(0);
84
+ }
85
+
86
+ // Sort by score descending, take top match
87
+ scores.sort((a, b) => b.score - a.score);
88
+ const top = scores[0];
89
+
90
+ // Only suggest if confidence is reasonable (score >= 3 = at least one strong keyword match)
91
+ if (top.score < 3) {
92
+ process.exit(0);
93
+ }
94
+
95
+ // Format chain for display
96
+ const chainDisplay = top.chain.slice(0, 4).join(' → ');
97
+ const alternates = scores.slice(1, 3).map((s) => s.skill).join(', ');
98
+
99
+ // Output routing suggestion
100
+ console.log(`\n🧭 [Rune intent-router] Suggested: rune:${top.skill} (${top.layer}, ${top.model})`);
101
+ console.log(` Chain: ${chainDisplay}`);
102
+ if (alternates) {
103
+ console.log(` Also consider: ${alternates}`);
104
+ }
105
+ console.log('');
106
+
107
+ process.exit(0);
108
+ });