@rune-kit/rune 2.18.0 → 2.20.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 (35) hide show
  1. package/README.md +26 -7
  2. package/compiler/__tests__/comprehension.test.js +916 -0
  3. package/compiler/__tests__/governance-collector.test.js +376 -0
  4. package/compiler/__tests__/setup.test.js +338 -7
  5. package/compiler/analytics.js +5 -0
  6. package/compiler/bin/rune.js +165 -1
  7. package/compiler/commands/setup.js +190 -3
  8. package/compiler/comprehension-client.js +2348 -0
  9. package/compiler/comprehension.js +1254 -0
  10. package/compiler/governance-collector.js +382 -0
  11. package/compiler/schemas/comprehension.schema.json +87 -0
  12. package/compiler/schemas/governance.schema.json +78 -0
  13. package/compiler/transforms/branding.js +1 -1
  14. package/hooks/context-watch/index.cjs +24 -13
  15. package/hooks/hooks.json +2 -2
  16. package/hooks/lib/context-key.cjs +37 -0
  17. package/hooks/metrics-collector/index.cjs +37 -8
  18. package/hooks/pre-tool-guard/index.cjs +44 -0
  19. package/hooks/session-start/index.cjs +4 -10
  20. package/package.json +1 -1
  21. package/skills/adversary/SKILL.md +36 -3
  22. package/skills/adversary/references/cross-model-escalation.md +85 -0
  23. package/skills/adversary/references/reasoning-modes.md +95 -0
  24. package/skills/autopsy/SKILL.md +41 -0
  25. package/skills/ba/SKILL.md +44 -2
  26. package/skills/ba/references/ears-format.md +91 -0
  27. package/skills/brainstorm/SKILL.md +32 -7
  28. package/skills/cook/SKILL.md +8 -1
  29. package/skills/debug/SKILL.md +4 -2
  30. package/skills/deploy/SKILL.md +20 -1
  31. package/skills/deploy/references/observability.md +146 -0
  32. package/skills/graft/SKILL.md +24 -8
  33. package/skills/onboard/SKILL.md +45 -0
  34. package/skills/perf/SKILL.md +4 -1
  35. package/skills/review/SKILL.md +4 -2
@@ -21,11 +21,12 @@
21
21
  */
22
22
 
23
23
  import { existsSync, readFileSync } from 'node:fs';
24
+ import { cp, readdir, readFile, rm } from 'node:fs/promises';
24
25
  import os from 'node:os';
25
26
  import path from 'node:path';
26
27
  import { createInterface } from 'node:readline';
27
28
  import { installHooks } from './hooks/install.js';
28
- import { TIER_ENV_VARS } from './hooks/tiers.js';
29
+ import { resolveTier, TIER_ENV_VARS } from './hooks/tiers.js';
29
30
 
30
31
  export const WELL_KNOWN_TIER_PATHS = {
31
32
  pro: ['D:/Project/Rune/Pro', path.join(os.homedir(), 'rune-pro'), path.join(os.homedir(), 'Project', 'Rune', 'Pro')],
@@ -38,9 +39,9 @@ export const WELL_KNOWN_TIER_PATHS = {
38
39
 
39
40
  /**
40
41
  * @param {{ projectRoot: string, runeRoot: string, args: object }} opts
41
- * @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[] }>}
42
+ * @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[], skillResults: Array<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}> }>}
42
43
  */
43
- export async function runSetup({ projectRoot, runeRoot: _runeRoot, args = {} }) {
44
+ export async function runSetup({ projectRoot, runeRoot, args = {} }) {
44
45
  const detected = detectTiers(projectRoot);
45
46
 
46
47
  // Scope resolution
@@ -88,16 +89,180 @@ export async function runSetup({ projectRoot, runeRoot: _runeRoot, args = {} })
88
89
  dry: args.dry,
89
90
  });
90
91
 
92
+ // Install tier skill files into <runeRoot>/skills/ so Claude Code (and any
93
+ // plugin-aware platform) discovers them alongside Free skills. Without this
94
+ // step, paid tiers ship hooks only — `rune:autopilot` returns "Unknown skill"
95
+ // because Pro/skills/autopilot/ is invisible to the plugin runtime.
96
+ const skillResults = [];
97
+ for (const tier of tiers) {
98
+ try {
99
+ const tierManifest = await resolveTier(tier, projectRoot);
100
+ const skillResult = await installTierSkills({
101
+ tier,
102
+ tierManifest,
103
+ runeRoot,
104
+ dry: args.dry,
105
+ });
106
+ skillResults.push(skillResult);
107
+ } catch (err) {
108
+ skillResults.push({ tier, installed: [], skipped: [], reason: err.message });
109
+ }
110
+ }
111
+
91
112
  return {
92
113
  scope,
93
114
  targetRoot,
94
115
  tiers,
95
116
  preset,
96
117
  detected,
118
+ skillResults,
97
119
  ...result,
98
120
  };
99
121
  }
100
122
 
123
+ /**
124
+ * Copy a tier's skill directories into the Free plugin's skills/ folder so
125
+ * the Claude Code plugin runtime discovers them with the `rune:` prefix.
126
+ *
127
+ * Idempotent: existing skill directories are SKIPPED (not overwritten). This
128
+ * protects Free skills from being clobbered by a same-named Pro skill, and
129
+ * protects an in-place edit of a previously-installed Pro skill from being
130
+ * stomped on a re-run.
131
+ *
132
+ * Pre-condition: tierManifest.source points at the absolute path of the tier's
133
+ * manifest.json (set by validateManifest). Tier root = grandparent of that path.
134
+ *
135
+ * @param {object} opts
136
+ * @param {string} opts.tier
137
+ * @param {import('./hooks/tiers.js').TierManifest} opts.tierManifest
138
+ * @param {string} opts.runeRoot — target is `<runeRoot>/skills/`
139
+ * @param {boolean} [opts.dry]
140
+ * @returns {Promise<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}>}
141
+ */
142
+ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
143
+ if (!tierManifest?.source) {
144
+ return { tier, installed: [], skipped: [], reason: 'tier manifest source missing' };
145
+ }
146
+ // Defensive: validateManifest accepts any string; only locateTierManifest guarantees absolute.
147
+ // Reject relative to keep tierRoot derivation honest.
148
+ if (!path.isAbsolute(tierManifest.source)) {
149
+ return {
150
+ tier,
151
+ installed: [],
152
+ skipped: [],
153
+ reason: `tier manifest source must be absolute (got ${tierManifest.source})`,
154
+ };
155
+ }
156
+ const tierRoot = path.dirname(path.dirname(tierManifest.source));
157
+ const sourceDir = path.join(tierRoot, 'skills');
158
+ if (!existsSync(sourceDir)) {
159
+ return { tier, installed: [], skipped: [], reason: `no skills/ dir at ${sourceDir}` };
160
+ }
161
+ if (!runeRoot) {
162
+ return { tier, installed: [], skipped: [], reason: 'runeRoot not provided' };
163
+ }
164
+ const targetDir = path.join(runeRoot, 'skills');
165
+ if (!existsSync(targetDir)) {
166
+ return { tier, installed: [], skipped: [], reason: `target skills/ missing at ${targetDir}` };
167
+ }
168
+ const installed = [];
169
+ const skipped = [];
170
+ const entries = await readdir(sourceDir, { withFileTypes: true });
171
+ for (const entry of entries) {
172
+ if (!entry.isDirectory()) {
173
+ // Reject symlinks even when their target is a directory — `cp` with default
174
+ // dereference=false would recreate the symlink and let the Claude Code runtime
175
+ // follow it outside runeRoot/skills/ at read time (POSIX symlink-escape).
176
+ if (entry.isSymbolicLink()) {
177
+ skipped.push({ skill: entry.name, reason: 'rejected: symlink (would escape sandbox)' });
178
+ }
179
+ continue;
180
+ }
181
+ const skillName = entry.name;
182
+ // Guard against path traversal via adversarial directory names (../, /, \).
183
+ // A compromised tier repo with a skill dir like '../../../etc' would otherwise
184
+ // escape runeRoot/skills/ and overwrite arbitrary files. assertSafeTierName
185
+ // in tiers.js covers the tier name but NOT per-skill directory names.
186
+ if (path.basename(skillName) !== skillName || skillName === '.' || skillName === '..') {
187
+ skipped.push({ skill: skillName, reason: 'rejected: unsafe directory name' });
188
+ continue;
189
+ }
190
+ const src = path.join(sourceDir, skillName);
191
+ const dst = path.join(targetDir, skillName);
192
+ if (existsSync(dst)) {
193
+ const drift = await detectVersionDrift(src, dst);
194
+ skipped.push({ skill: skillName, reason: drift || 'already present' });
195
+ continue;
196
+ }
197
+ if (!dry) {
198
+ try {
199
+ // dereference: true → copy symlink targets as content instead of recreating
200
+ // symlinks at dst. Belt-and-suspenders alongside the isSymbolicLink reject above
201
+ // (nested symlinks inside a skill dir would otherwise still recreate).
202
+ await cp(src, dst, { recursive: true, dereference: true });
203
+ } catch (err) {
204
+ // Clean partial-copy residue so next run isn't silently locked-out by
205
+ // existsSync(dst) on a corrupt half-written directory.
206
+ await rm(dst, { recursive: true, force: true }).catch(() => {});
207
+ skipped.push({ skill: skillName, reason: `copy failed: ${err.message}` });
208
+ continue;
209
+ }
210
+ }
211
+ installed.push(skillName);
212
+ }
213
+ return { tier, installed, skipped, reason: null };
214
+ }
215
+
216
+ /**
217
+ * Compare source SKILL.md version against installed version. Returns a string
218
+ * describing the drift if source is newer (so user knows an upgrade exists),
219
+ * or null when versions match / cannot be parsed. Never throws — best-effort.
220
+ *
221
+ * @param {string} srcSkillDir
222
+ * @param {string} dstSkillDir
223
+ * @returns {Promise<string|null>}
224
+ */
225
+ async function detectVersionDrift(srcSkillDir, dstSkillDir) {
226
+ try {
227
+ const [srcVer, dstVer] = await Promise.all([
228
+ readSkillVersion(path.join(srcSkillDir, 'SKILL.md')),
229
+ readSkillVersion(path.join(dstSkillDir, 'SKILL.md')),
230
+ ]);
231
+ if (!srcVer || !dstVer) return null;
232
+ if (srcVer === dstVer) return `already present (v${dstVer})`;
233
+ return `stale: installed v${dstVer}, source has v${srcVer} — delete target dir to upgrade`;
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Extract version string from a SKILL.md `metadata.version` field. Minimal
241
+ * YAML scan scoped to the `metadata:` block — avoids false positives from
242
+ * multiline description continuation lines that contain `version:` text
243
+ * (e.g. `description: |\n version: 1.0.0 is legacy`). Avoids pulling in a
244
+ * full YAML dependency for one field.
245
+ *
246
+ * Returns null when SKILL.md cannot be read, frontmatter missing, metadata
247
+ * block missing, or version field absent.
248
+ *
249
+ * @param {string} skillMdPath
250
+ * @returns {Promise<string|null>}
251
+ */
252
+ async function readSkillVersion(skillMdPath) {
253
+ try {
254
+ const content = await readFile(skillMdPath, 'utf-8');
255
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
256
+ if (!fmMatch) return null;
257
+ // Require metadata: header followed by an indented version: line.
258
+ // Indent is preserved so a top-level `version:` (outside metadata) doesn't match.
259
+ const verMatch = fmMatch[1].match(/^metadata:\s*\n(?:\s+[^\n]*\n)*?\s+version:\s*["']?([^"'\s#]+)/m);
260
+ return verMatch ? verMatch[1] : null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
101
266
  /**
102
267
  * Auto-detect Pro/Business tiers across env vars, sibling paths, and well-known
103
268
  * locations. Returns { pro: { path, source } | null, business: { ... } | null }.
@@ -228,6 +393,28 @@ export function formatSetupResult(result) {
228
393
  lines.push(` Tiers: Free${result.tiers.length > 0 ? ` + ${result.tiers.join(' + ')}` : ''}`);
229
394
  lines.push(` Preset: ${result.preset}`);
230
395
  lines.push(` Platforms: ${(result.platforms || []).join(', ') || '—'}`);
396
+ for (const sr of result.skillResults || []) {
397
+ if (sr.installed.length > 0) {
398
+ const preview = sr.installed.slice(0, 3).join(', ');
399
+ const more = sr.installed.length > 3 ? `, +${sr.installed.length - 3}` : '';
400
+ lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed (${preview}${more})`);
401
+ }
402
+ if (sr.skipped.length > 0) {
403
+ const rejected = sr.skipped.filter((s) => s.reason.startsWith('rejected:'));
404
+ const benign = sr.skipped.length - rejected.length;
405
+ const parts = [];
406
+ if (benign > 0) parts.push(`${benign} already present`);
407
+ if (rejected.length > 0) parts.push(`${rejected.length} rejected`);
408
+ lines.push(` Skipped: ${sr.tier}: ${parts.join(', ')}`);
409
+ // Surface rejection details so the operator can investigate a compromised tier repo.
410
+ for (const r of rejected) {
411
+ lines.push(` ⚠ ${r.skill}: ${r.reason}`);
412
+ }
413
+ }
414
+ if (sr.reason) {
415
+ lines.push(` Skill warn:${sr.tier}: ${sr.reason}`);
416
+ }
417
+ }
231
418
  if (result.notes?.length) {
232
419
  lines.push('');
233
420
  lines.push(' Notes:');