@rune-kit/rune 2.28.0 → 2.29.1

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 (51) hide show
  1. package/.codex-plugin/plugin.json +14 -0
  2. package/README.md +114 -41
  3. package/compiler/__tests__/adapter-model-mapping.test.js +22 -24
  4. package/compiler/__tests__/adapters.test.js +4 -1
  5. package/compiler/__tests__/doctor-mesh.test.js +55 -1
  6. package/compiler/__tests__/governance-collector.test.js +1 -0
  7. package/compiler/__tests__/hooks-codex.test.js +85 -0
  8. package/compiler/__tests__/hooks-doctor-tier.test.js +3 -5
  9. package/compiler/__tests__/hooks-drift.test.js +2 -2
  10. package/compiler/__tests__/hooks-install.test.js +2 -2
  11. package/compiler/__tests__/hooks-tiers.test.js +3 -4
  12. package/compiler/__tests__/setup.test.js +59 -0
  13. package/compiler/__tests__/status.test.js +7 -1
  14. package/compiler/__tests__/tier-override.test.js +42 -1
  15. package/compiler/__tests__/transforms.test.js +15 -0
  16. package/compiler/__tests__/update.test.js +416 -0
  17. package/compiler/adapters/codex.js +88 -11
  18. package/compiler/adapters/hooks/codex.js +178 -0
  19. package/compiler/adapters/hooks/index.js +10 -0
  20. package/compiler/adapters/openclaw.js +2 -2
  21. package/compiler/bin/rune.js +24 -4
  22. package/compiler/commands/hooks/install.js +2 -2
  23. package/compiler/commands/hooks/status.js +1 -1
  24. package/compiler/commands/setup.js +101 -28
  25. package/compiler/commands/update.js +354 -0
  26. package/compiler/doctor.js +12 -6
  27. package/compiler/emitter.js +46 -3
  28. package/compiler/governance-collector.js +3 -2
  29. package/compiler/status.js +4 -7
  30. package/compiler/transforms/branding.js +2 -2
  31. package/compiler/transforms/subagents.js +3 -3
  32. package/hooks/codex-hooks.json +96 -0
  33. package/hooks/context-watch/index.cjs +8 -5
  34. package/hooks/intent-router/index.cjs +3 -0
  35. package/hooks/lib/hook-output.cjs +11 -3
  36. package/hooks/post-session-reflect/index.cjs +65 -24
  37. package/hooks/pre-compact/index.cjs +10 -4
  38. package/hooks/pre-tool-guard/index.cjs +38 -36
  39. package/hooks/run-hook +1 -1
  40. package/hooks/run-hook.cmd +1 -1
  41. package/hooks/secrets-scan/index.cjs +18 -2
  42. package/package.json +3 -2
  43. package/skills/browser-pilot/SKILL.md +1 -0
  44. package/skills/completion-gate/SKILL.md +5 -5
  45. package/skills/doc-processor/SKILL.md +2 -2
  46. package/skills/hallucination-guard/SKILL.md +1 -0
  47. package/skills/journal/SKILL.md +1 -0
  48. package/skills/retro/SKILL.md +2 -2
  49. package/skills/session-bridge/SKILL.md +1 -0
  50. package/skills/session-bridge/scripts/load-invariants.js +1 -1
  51. package/skills/video-creator/SKILL.md +1 -1
@@ -21,10 +21,14 @@
21
21
  */
22
22
 
23
23
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
- import { cp, readdir, readFile, rm } from 'node:fs/promises';
24
+ import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
25
25
  import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import { createInterface } from 'node:readline';
28
+ import { getAdapter as getCompilerAdapter } from '../adapters/index.js';
29
+ import { parseSkill } from '../parser.js';
30
+ import { transformSkill } from '../transformer.js';
31
+ import { resolveScriptsPath } from '../transforms/scripts-path.js';
28
32
  import { installHooks } from './hooks/install.js';
29
33
  import { resolveTier, TIER_ENV_VARS } from './hooks/tiers.js';
30
34
 
@@ -103,9 +107,10 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
103
107
  // Determine target root
104
108
  const targetRoot = scope === 'global' ? os.homedir() : projectRoot;
105
109
 
106
- // For global scope, claude is the only meaningful platform (cursor/windsurf
107
- // configs typically live per-project). Force claude.
108
- const platform = scope === 'global' ? 'claude' : args.platform;
110
+ // Global defaults to Claude for backward compatibility, but an explicit
111
+ // Codex target installs user-wide hooks under ~/.codex and skills under
112
+ // ~/.agents/skills.
113
+ const platform = args.platform || (scope === 'global' ? 'claude' : undefined);
109
114
 
110
115
  // Set tier env vars from detection so installer can resolve
111
116
  for (const tier of tiers) {
@@ -130,23 +135,44 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
130
135
  // Claude Code plugin cache when present, runeRoot otherwise. Without this
131
136
  // step, paid tiers ship hooks only — `rune:autopilot` returns "Unknown skill"
132
137
  // because Pro/skills/autopilot/ is invisible to the plugin runtime.
133
- const skillTarget =
134
- typeof skillTargetOverride === 'string'
135
- ? { root: skillTargetOverride, source: 'override' }
136
- : (skillTargetOverride ?? resolveSkillInstallRoot(runeRoot));
138
+ const skillPlatforms = result.platforms || [];
139
+ const skillTargets = skillPlatforms.map((skillPlatform) => {
140
+ if (skillPlatform === 'claude') {
141
+ if (typeof skillTargetOverride === 'string') {
142
+ return { platform: skillPlatform, root: skillTargetOverride, source: 'override' };
143
+ }
144
+ if (skillTargetOverride) return { platform: skillPlatform, ...skillTargetOverride };
145
+ return { platform: skillPlatform, ...resolveSkillInstallRoot(runeRoot) };
146
+ }
147
+ return {
148
+ platform: skillPlatform,
149
+ root: targetRoot,
150
+ source: scope === 'global' ? `${skillPlatform}-user` : `${skillPlatform}-project`,
151
+ };
152
+ });
153
+ const skillTarget = skillTargets[0] || null;
137
154
  const skillResults = [];
138
- for (const tier of tiers) {
139
- try {
140
- const tierManifest = await resolveTier(tier, projectRoot);
141
- const skillResult = await installTierSkills({
142
- tier,
143
- tierManifest,
144
- runeRoot: skillTarget.root,
145
- dry: args.dry,
146
- });
147
- skillResults.push(skillResult);
148
- } catch (err) {
149
- skillResults.push({ tier, installed: [], skipped: [], reason: err.message });
155
+ for (const skillTargetEntry of skillTargets) {
156
+ for (const tier of tiers) {
157
+ try {
158
+ const tierManifest = await resolveTier(tier, projectRoot);
159
+ const skillResult = await installTierSkills({
160
+ tier,
161
+ tierManifest,
162
+ runeRoot: skillTargetEntry.root,
163
+ platform: skillTargetEntry.platform,
164
+ dry: args.dry,
165
+ });
166
+ skillResults.push({ platform: skillTargetEntry.platform, ...skillResult });
167
+ } catch (err) {
168
+ skillResults.push({
169
+ platform: skillTargetEntry.platform,
170
+ tier,
171
+ installed: [],
172
+ skipped: [],
173
+ reason: err.message,
174
+ });
175
+ }
150
176
  }
151
177
  }
152
178
 
@@ -158,6 +184,7 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
158
184
  detected,
159
185
  skillResults,
160
186
  skillTarget,
187
+ skillTargets,
161
188
  ...result,
162
189
  };
163
190
  }
@@ -177,11 +204,12 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
177
204
  * @param {object} opts
178
205
  * @param {string} opts.tier
179
206
  * @param {import('./hooks/tiers.js').TierManifest} opts.tierManifest
180
- * @param {string} opts.runeRoot — target is `<runeRoot>/skills/`
207
+ * @param {string} opts.runeRoot — Claude plugin root, or project/user root for other platforms
208
+ * @param {string} [opts.platform='claude']
181
209
  * @param {boolean} [opts.dry]
182
210
  * @returns {Promise<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}>}
183
211
  */
184
- export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
212
+ export async function installTierSkills({ tier, tierManifest, runeRoot, platform = 'claude', dry }) {
185
213
  if (!tierManifest?.source) {
186
214
  return { tier, installed: [], skipped: [], reason: 'tier manifest source missing' };
187
215
  }
@@ -203,9 +231,24 @@ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
203
231
  if (!runeRoot) {
204
232
  return { tier, installed: [], skipped: [], reason: 'runeRoot not provided' };
205
233
  }
206
- const targetDir = path.join(runeRoot, 'skills');
234
+ const adapter = platform === 'claude' ? null : getCompilerAdapter(platform);
235
+ if (adapter && !adapter.useSkillDirectories) {
236
+ return {
237
+ tier,
238
+ installed: [],
239
+ skipped: [],
240
+ reason: `platform '${platform}' does not use directory-based Agent Skills`,
241
+ };
242
+ }
243
+ const targetDir = adapter ? path.join(runeRoot, adapter.outputDir) : path.join(runeRoot, 'skills');
207
244
  if (!existsSync(targetDir)) {
208
- return { tier, installed: [], skipped: [], reason: `target skills/ missing at ${targetDir}` };
245
+ if (dry) {
246
+ // Dry-run should describe what would be installed even on a fresh target.
247
+ } else if (adapter) {
248
+ await mkdir(targetDir, { recursive: true });
249
+ } else {
250
+ return { tier, installed: [], skipped: [], reason: `target skills/ missing at ${targetDir}` };
251
+ }
209
252
  }
210
253
  const installed = [];
211
254
  const skipped = [];
@@ -230,7 +273,33 @@ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
230
273
  continue;
231
274
  }
232
275
  const src = path.join(sourceDir, skillName);
233
- const dst = path.join(targetDir, skillName);
276
+ let parsed = null;
277
+ let compiledOutput = null;
278
+ let targetSkillName = skillName;
279
+ if (adapter) {
280
+ try {
281
+ const skillPath = path.join(src, 'SKILL.md');
282
+ const content = await readFile(skillPath, 'utf-8');
283
+ parsed = parseSkill(content, skillPath);
284
+ targetSkillName = `${adapter.skillPrefix || ''}${parsed.name}${adapter.skillSuffix || ''}`;
285
+ if (path.basename(targetSkillName) !== targetSkillName) {
286
+ skipped.push({ skill: skillName, reason: 'rejected: compiled skill name is unsafe' });
287
+ continue;
288
+ }
289
+ const { header, body: rawBody, footer } = transformSkill(parsed, adapter);
290
+ const scriptsSource = path.join(src, 'scripts');
291
+ const scriptsPath =
292
+ existsSync(scriptsSource) && typeof adapter.scriptsDir === 'function'
293
+ ? path.join(adapter.outputDir, adapter.scriptsDir(parsed.name)).replaceAll('\\', '/')
294
+ : null;
295
+ const body = scriptsPath ? resolveScriptsPath(rawBody, scriptsPath) : rawBody;
296
+ compiledOutput = [header, body, footer].filter(Boolean).join('\n');
297
+ } catch (err) {
298
+ skipped.push({ skill: skillName, reason: `compile failed for ${platform}: ${err.message}` });
299
+ continue;
300
+ }
301
+ }
302
+ const dst = path.join(targetDir, targetSkillName);
234
303
  if (existsSync(dst)) {
235
304
  const drift = await detectVersionDrift(src, dst);
236
305
  skipped.push({ skill: skillName, reason: drift || 'already present' });
@@ -242,6 +311,9 @@ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
242
311
  // symlinks at dst. Belt-and-suspenders alongside the isSymbolicLink reject above
243
312
  // (nested symlinks inside a skill dir would otherwise still recreate).
244
313
  await cp(src, dst, { recursive: true, dereference: true });
314
+ if (compiledOutput !== null) {
315
+ await writeFile(path.join(dst, adapter.skillFileName || 'SKILL.md'), compiledOutput, 'utf-8');
316
+ }
245
317
  } catch (err) {
246
318
  // Clean partial-copy residue so next run isn't silently locked-out by
247
319
  // existsSync(dst) on a corrupt half-written directory.
@@ -436,10 +508,11 @@ export function formatSetupResult(result) {
436
508
  lines.push(` Preset: ${result.preset}`);
437
509
  lines.push(` Platforms: ${(result.platforms || []).join(', ') || '—'}`);
438
510
  for (const sr of result.skillResults || []) {
511
+ const platformLabel = sr.platform ? `/${sr.platform}` : '';
439
512
  if (sr.installed.length > 0) {
440
513
  const preview = sr.installed.slice(0, 3).join(', ');
441
514
  const more = sr.installed.length > 3 ? `, +${sr.installed.length - 3}` : '';
442
- lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed (${preview}${more})`);
515
+ lines.push(` Skills: ${sr.tier}${platformLabel}: ${sr.installed.length} installed (${preview}${more})`);
443
516
  }
444
517
  if (sr.skipped.length > 0) {
445
518
  const rejected = sr.skipped.filter((s) => s.reason.startsWith('rejected:'));
@@ -447,14 +520,14 @@ export function formatSetupResult(result) {
447
520
  const parts = [];
448
521
  if (benign > 0) parts.push(`${benign} already present`);
449
522
  if (rejected.length > 0) parts.push(`${rejected.length} rejected`);
450
- lines.push(` Skipped: ${sr.tier}: ${parts.join(', ')}`);
523
+ lines.push(` Skipped: ${sr.tier}${platformLabel}: ${parts.join(', ')}`);
451
524
  // Surface rejection details so the operator can investigate a compromised tier repo.
452
525
  for (const r of rejected) {
453
526
  lines.push(` ⚠ ${r.skill}: ${r.reason}`);
454
527
  }
455
528
  }
456
529
  if (sr.reason) {
457
- lines.push(` Skill warn:${sr.tier}: ${sr.reason}`);
530
+ lines.push(` Skill warn:${sr.tier}${platformLabel}: ${sr.reason}`);
458
531
  }
459
532
  }
460
533
  if (result.notes?.length) {
@@ -0,0 +1,354 @@
1
+ /**
2
+ * `rune update` — one-shot updater for an already-configured project.
3
+ *
4
+ * Mirrors the manual "Updating" flow from the README in a single command:
5
+ * 1. `git pull --ff-only` any detected paid tier repos (Pro / Business).
6
+ * Detection reuses setup's logic: $RUNE_PRO_ROOT / $RUNE_BUSINESS_ROOT
7
+ * env vars, then sibling dirs (../Pro, ../Business), then well-known
8
+ * paths. Absent tiers and non-git checkouts are skipped with a note;
9
+ * a FAILED pull aborts the update (fail loud — never silently continue).
10
+ * 2. Re-run the managed setup rewrite in place, non-interactively: the
11
+ * installed platforms, preset, and tiers are detected from the project's
12
+ * existing hook config instead of prompting. Delegates to `runSetup`.
13
+ * 3. Verify: compiled-output doctor (when a rune.config.json build exists)
14
+ * + hook drift report, then print a short summary. Codex users are
15
+ * reminded to re-trust hooks via `/hooks` when `.codex/hooks.json`
16
+ * changed.
17
+ *
18
+ * Non-interactive by design — safe to run from scripts. Flags:
19
+ * --no-pull skip step 1 (tier repos managed some other way)
20
+ * --preset <p> override the detected preset
21
+ * --tier <t>[,<t>] override the detected tier list
22
+ * --dry preview: skip pulls, pass dry to setup (no writes)
23
+ */
24
+
25
+ import { execFile } from 'node:child_process';
26
+ import { existsSync } from 'node:fs';
27
+ import { readdir, readFile } from 'node:fs/promises';
28
+ import path from 'node:path';
29
+ import { ADAPTERS, detectPlatforms } from '../adapters/hooks/index.js';
30
+ import { checkHookDrift } from './hooks/drift.js';
31
+ import { TIER_ENV_VARS } from './hooks/tiers.js';
32
+ import { detectTiers, runSetup } from './setup.js';
33
+
34
+ /** Installed hook configs that may carry tier tokens (env var references). */
35
+ const TIER_SCAN_FILES = ['.claude/settings.json', '.codex/hooks.json'];
36
+
37
+ /** Rule/workflow dirs whose Rune-managed files carry `rune-tier:` frontmatter. */
38
+ const TIER_SCAN_DIRS = ['.cursor/rules', '.windsurf/rules', '.windsurf/workflows', '.antigravity/rules'];
39
+
40
+ const CODEX_HOOKS_REL_PATH = '.codex/hooks.json';
41
+
42
+ /**
43
+ * Detect which paid tiers are wired into this project's installed hook config.
44
+ * Reads the config files Rune manages and looks for tier env-var tokens
45
+ * (`RUNE_PRO_ROOT` / `RUNE_BUSINESS_ROOT`) or `rune-tier:` frontmatter.
46
+ * Free preset entries never contain either marker, so a Free-only install
47
+ * returns [].
48
+ *
49
+ * @param {string} projectRoot
50
+ * @returns {Promise<string[]>} tier names, e.g. ['pro']
51
+ */
52
+ export async function detectInstalledTiers(projectRoot) {
53
+ const chunks = [];
54
+ for (const rel of TIER_SCAN_FILES) {
55
+ const filePath = path.join(projectRoot, rel);
56
+ if (existsSync(filePath)) {
57
+ chunks.push(await readFile(filePath, 'utf-8').catch(() => ''));
58
+ }
59
+ }
60
+ for (const rel of TIER_SCAN_DIRS) {
61
+ const dir = path.join(projectRoot, rel);
62
+ if (!existsSync(dir)) continue;
63
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
64
+ for (const entry of entries) {
65
+ if (!entry.isFile() || !/\.(md|mdc)$/.test(entry.name)) continue;
66
+ chunks.push(await readFile(path.join(dir, entry.name), 'utf-8').catch(() => ''));
67
+ }
68
+ }
69
+ const text = chunks.join('\n');
70
+ const tiers = [];
71
+ for (const [tier, envVar] of Object.entries(TIER_ENV_VARS)) {
72
+ const marker = new RegExp(`${envVar}|rune-tier:\\s*${tier}\\b`);
73
+ if (marker.test(text)) tiers.push(tier);
74
+ }
75
+ return tiers;
76
+ }
77
+
78
+ /**
79
+ * Detect the preset the project was installed with, by asking each detected
80
+ * platform adapter for its status. A `mixed` install resolves to `gentle`
81
+ * (same fallback the drift reporter uses for canonical comparison).
82
+ *
83
+ * @param {string} projectRoot
84
+ * @returns {Promise<'gentle'|'strict'|null>} null when nothing is installed
85
+ */
86
+ export async function detectInstalledPreset(projectRoot) {
87
+ for (const id of detectPlatforms(projectRoot)) {
88
+ const status = await ADAPTERS[id].status(projectRoot);
89
+ if (!status.installed || !status.preset) continue;
90
+ if (status.preset === 'mixed') return 'gentle';
91
+ if (status.preset === 'gentle' || status.preset === 'strict') return status.preset;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Default command runner — thin execFile wrapper that never throws.
98
+ * @returns {Promise<{code: number, stdout: string, stderr: string}>}
99
+ */
100
+ function defaultExec(cmd, argv) {
101
+ return new Promise((resolve) => {
102
+ execFile(cmd, argv, { windowsHide: true }, (err, stdout, stderr) => {
103
+ if (err) {
104
+ resolve({
105
+ code: typeof err.code === 'number' ? err.code : 1,
106
+ stdout: stdout ?? '',
107
+ stderr: stderr || err.message,
108
+ });
109
+ } else {
110
+ resolve({ code: 0, stdout: stdout ?? '', stderr: stderr ?? '' });
111
+ }
112
+ });
113
+ });
114
+ }
115
+
116
+ /**
117
+ * `git pull --ff-only` each detected tier repo.
118
+ *
119
+ * Statuses per tier:
120
+ * - absent — tier not detected (no env var / sibling / well-known path)
121
+ * - skipped — detected but not a git checkout (npx/tarball install)
122
+ * - pulled — git pull succeeded
123
+ * - failed — git pull failed (dirty tree, auth, diverged…) → result.ok=false
124
+ *
125
+ * @param {{ detected: ReturnType<typeof detectTiers>, exec?: typeof defaultExec }} opts
126
+ * @returns {Promise<{ ok: boolean, results: Array<{tier: string, root: string|null, status: string, detail: string}> }>}
127
+ */
128
+ export async function pullTierRepos({ detected, exec = defaultExec }) {
129
+ const results = [];
130
+ for (const tier of Object.keys(TIER_ENV_VARS)) {
131
+ const info = detected[tier];
132
+ if (!info) {
133
+ results.push({ tier, root: null, status: 'absent', detail: 'not detected — skipping' });
134
+ continue;
135
+ }
136
+ // detectTiers points at <tierRoot>/hooks/manifest.json — walk back to the root.
137
+ const tierRoot = path.resolve(path.dirname(path.dirname(info.path)));
138
+ if (!existsSync(path.join(tierRoot, '.git'))) {
139
+ results.push({
140
+ tier,
141
+ root: tierRoot,
142
+ status: 'skipped',
143
+ detail: `${tierRoot} is not a git repo — pull skipped (update it the way it was installed)`,
144
+ });
145
+ continue;
146
+ }
147
+ const { code, stdout, stderr } = await exec('git', ['-C', tierRoot, 'pull', '--ff-only']);
148
+ if (code === 0) {
149
+ const summary = (stdout.trim().split('\n').pop() || 'done').trim();
150
+ results.push({ tier, root: tierRoot, status: 'pulled', detail: summary });
151
+ } else {
152
+ results.push({ tier, root: tierRoot, status: 'failed', detail: (stderr || stdout).trim() });
153
+ }
154
+ }
155
+ return { ok: results.every((r) => r.status !== 'failed'), results };
156
+ }
157
+
158
+ /**
159
+ * Compiled-output verification. Only meaningful for build-pipeline projects
160
+ * (rune.config.json with a non-Claude platform) — Claude Code loads source
161
+ * SKILL.md files natively, so there is no compiled output to check.
162
+ */
163
+ async function defaultDoctorCheck(projectRoot, runeRoot) {
164
+ const configPath = path.join(projectRoot, 'rune.config.json');
165
+ if (!existsSync(configPath)) {
166
+ return { skipped: true, reason: 'no rune.config.json — compiled-output check skipped' };
167
+ }
168
+ let config;
169
+ try {
170
+ config = JSON.parse(await readFile(configPath, 'utf-8'));
171
+ } catch (err) {
172
+ return { skipped: true, reason: `rune.config.json unreadable (${err.message}) — compiled-output check skipped` };
173
+ }
174
+ if (!config.platform || config.platform === 'claude') {
175
+ return { skipped: true, reason: 'Claude Code native — no compiled output to check' };
176
+ }
177
+ const { runDoctor } = await import('../doctor.js');
178
+ const { getAdapter } = await import('../adapters/index.js');
179
+ const resolvedRuneRoot = config.source === '@rune-kit/rune' ? runeRoot : config.source || runeRoot;
180
+ const results = await runDoctor({
181
+ outputRoot: projectRoot,
182
+ adapter: getAdapter(config.platform),
183
+ config,
184
+ runeRoot: resolvedRuneRoot,
185
+ });
186
+ return { skipped: false, healthy: results.healthy, results };
187
+ }
188
+
189
+ /**
190
+ * The full update flow. Pure orchestration — every side-effecting collaborator
191
+ * is injectable via `deps` for tests.
192
+ *
193
+ * @param {object} opts
194
+ * @param {string} opts.projectRoot
195
+ * @param {string} opts.runeRoot
196
+ * @param {object} [opts.args] — CLI flags: no-pull, preset, tier, dry
197
+ * @param {object} [opts.deps] — { exec, runSetupFn, checkHookDriftFn, doctorFn, skillTarget, wellKnownPaths }
198
+ * @returns {Promise<object>} structured result for formatUpdateResult
199
+ */
200
+ export async function runUpdate({ projectRoot, runeRoot, args = {}, deps = {} }) {
201
+ const notes = [];
202
+
203
+ // ── 0. Something to update? ──
204
+ const platforms = detectPlatforms(projectRoot);
205
+ if (platforms.length === 0) {
206
+ return {
207
+ ok: false,
208
+ reason:
209
+ 'No Rune installation detected in this project — nothing to update. Run `npx @rune-kit/rune setup` first.',
210
+ pull: { ok: true, results: [] },
211
+ notes,
212
+ };
213
+ }
214
+
215
+ // ── 1. Pull tier repos (fail loud on pull errors) ──
216
+ const detected = detectTiers(projectRoot, deps.wellKnownPaths ? { wellKnownPaths: deps.wellKnownPaths } : {});
217
+ let pull;
218
+ if (args['no-pull'] || args.dry) {
219
+ const why = args['no-pull'] ? '--no-pull' : '--dry';
220
+ pull = {
221
+ ok: true,
222
+ results: Object.keys(TIER_ENV_VARS).map((tier) => ({
223
+ tier,
224
+ root: null,
225
+ status: detected[tier] ? 'skipped' : 'absent',
226
+ detail: detected[tier] ? `pull skipped (${why})` : 'not detected — skipping',
227
+ })),
228
+ };
229
+ } else {
230
+ pull = await pullTierRepos({ detected, exec: deps.exec });
231
+ }
232
+ if (!pull.ok) {
233
+ return {
234
+ ok: false,
235
+ reason: 'tier pull failed — resolve it manually (dirty tree? auth?) then re-run `rune update`',
236
+ pull,
237
+ notes,
238
+ };
239
+ }
240
+
241
+ // ── 2. Re-run managed setup, reusing what is already installed ──
242
+ const preset = args.preset || (await detectInstalledPreset(projectRoot)) || 'gentle';
243
+ let tiers;
244
+ if (args.tier) {
245
+ tiers = String(args.tier)
246
+ .split(',')
247
+ .map((t) => t.trim())
248
+ .filter(Boolean);
249
+ } else {
250
+ const installed = await detectInstalledTiers(projectRoot);
251
+ tiers = installed.filter((tier) => detected[tier]);
252
+ for (const tier of installed) {
253
+ if (!detected[tier]) {
254
+ const envVar = TIER_ENV_VARS[tier] || `RUNE_${tier.toUpperCase()}_ROOT`;
255
+ notes.push(
256
+ `${tier}: hooks are installed but the tier repo was not found — skipping. Set $${envVar} or clone the repo next to Free, then re-run \`rune update\`.`,
257
+ );
258
+ }
259
+ }
260
+ }
261
+
262
+ const codexHooksPath = path.join(projectRoot, CODEX_HOOKS_REL_PATH);
263
+ const codexBefore = existsSync(codexHooksPath) ? await readFile(codexHooksPath, 'utf-8').catch(() => null) : null;
264
+
265
+ const setupArgs = {
266
+ here: true,
267
+ preset,
268
+ dry: args.dry,
269
+ ...(tiers.length > 0 ? { tier: tiers.join(',') } : { 'no-tier': true }),
270
+ };
271
+ const setup = await (deps.runSetupFn || runSetup)({
272
+ projectRoot,
273
+ runeRoot,
274
+ args: setupArgs,
275
+ skillTarget: deps.skillTarget,
276
+ });
277
+
278
+ const codexAfter = existsSync(codexHooksPath) ? await readFile(codexHooksPath, 'utf-8').catch(() => null) : null;
279
+ const codexReTrust = platforms.includes('codex') && codexBefore !== codexAfter;
280
+
281
+ // ── 3. Verify: compiled output + hook drift ──
282
+ const doctor = await (deps.doctorFn || defaultDoctorCheck)(projectRoot, runeRoot);
283
+ const drift = await (deps.checkHookDriftFn || checkHookDrift)(projectRoot);
284
+
285
+ return { ok: true, pull, platforms, preset, tiers, setup, drift, doctor, codexReTrust, notes };
286
+ }
287
+
288
+ const PULL_ICONS = { pulled: '✓', absent: '·', skipped: '·', failed: '✗' };
289
+
290
+ /**
291
+ * Render a runUpdate result for console output.
292
+ * @param {Awaited<ReturnType<typeof runUpdate>>} result
293
+ * @returns {string}
294
+ */
295
+ export function formatUpdateResult(result) {
296
+ const lines = [];
297
+ lines.push('');
298
+ lines.push(' Rune Update');
299
+ lines.push(' ────────────');
300
+
301
+ if (result.pull?.results?.length > 0) {
302
+ lines.push(' Tier repos:');
303
+ for (const r of result.pull.results) {
304
+ const icon = PULL_ICONS[r.status] || '·';
305
+ const label = r.status === 'failed' ? 'pull FAILED' : r.status;
306
+ lines.push(` ${icon} ${r.tier} — ${label}: ${r.detail}`);
307
+ }
308
+ }
309
+
310
+ if (!result.ok) {
311
+ lines.push('');
312
+ lines.push(` ✗ Update aborted: ${result.reason}`);
313
+ lines.push('');
314
+ return lines.join('\n');
315
+ }
316
+
317
+ lines.push(' Setup rewrite:');
318
+ lines.push(
319
+ ` Platforms: ${(result.setup?.platforms || result.platforms || []).join(', ') || '—'} | Preset: ${result.preset} | Tiers: Free${result.tiers?.length ? ` + ${result.tiers.join(' + ')}` : ''}`,
320
+ );
321
+ for (const sr of result.setup?.skillResults || []) {
322
+ if (sr.installed?.length > 0) {
323
+ lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed`);
324
+ } else if (sr.skipped?.length > 0) {
325
+ lines.push(` Skills: ${sr.tier}: ${sr.skipped.length} already present / skipped`);
326
+ }
327
+ }
328
+ if (result.setup && result.setup.written === false) {
329
+ lines.push(' (dry-run — no files written)');
330
+ }
331
+
332
+ lines.push(' Verify:');
333
+ if (result.doctor?.skipped) {
334
+ lines.push(` Doctor: skipped — ${result.doctor.reason}`);
335
+ } else if (result.doctor) {
336
+ lines.push(` Doctor: ${result.doctor.healthy ? '✓ healthy' : '✗ issues found — run `rune doctor` for detail'}`);
337
+ }
338
+ if (result.drift?.summary) {
339
+ const s = result.drift.summary;
340
+ lines.push(` Hook drift: ${s.drifted} drifted, ${s.missing} missing, ${s.errors} error(s)`);
341
+ }
342
+
343
+ for (const note of result.notes || []) {
344
+ lines.push(` ⚠ ${note}`);
345
+ }
346
+
347
+ if (result.codexReTrust) {
348
+ lines.push('');
349
+ lines.push(' ⚠ Codex: .codex/hooks.json changed — open /hooks in Codex to review and re-trust the definitions.');
350
+ }
351
+
352
+ lines.push('');
353
+ return lines.join('\n');
354
+ }
@@ -742,7 +742,7 @@ export async function checkMeshIntegrity(runeRoot) {
742
742
  /**
743
743
  * Parse skill connections from SKILL.md content
744
744
  */
745
- function parseSkillConnections(content, skillName) {
745
+ export function parseSkillConnections(content, skillName) {
746
746
  const result = {
747
747
  name: skillName,
748
748
  calls: [],
@@ -777,13 +777,15 @@ function parseSkillConnections(content, skillName) {
777
777
  }
778
778
 
779
779
  // Extract Calls section
780
- const callsMatch = content.match(/## Calls \(outbound\)\s*\n([\s\S]*?)(?=\n## |\n---|Z)/);
780
+ const callsMatch = content.match(/## Calls \(outbound(?: connections)?\)\s*\r?\n([\s\S]*?)(?=\r?\n## |\r?\n---|$)/);
781
781
  if (callsMatch) {
782
782
  const lines = callsMatch[1].split('\n');
783
783
  for (const line of lines) {
784
784
  // Match patterns like: - `scout` (L2): scan codebase
785
- // Or: - scout (L2): scan codebase
786
- const match = line.match(/^-\s*`?([a-z][\w-]*)`?\s*\(L\d\)/i);
785
+ // Or table rows like: | 1 | `scout` | L2 | scan codebase |
786
+ const match =
787
+ line.match(/^-\s*`?([a-z][\w-]*)`?\s*\((?:L[0-4]|ext)\)/i) ||
788
+ line.match(/^\|(?:[^|]*\|)*?\s*`?([a-z][\w-]*)`?\s*\|\s*(?:L[0-4]|ext)\s*\|/i);
787
789
  if (match) {
788
790
  const skillRef = match[1].toLowerCase();
789
791
  const reason = line
@@ -796,11 +798,15 @@ function parseSkillConnections(content, skillName) {
796
798
  }
797
799
 
798
800
  // Extract Called By section
799
- const calledByMatch = content.match(/## Called By \(inbound\)\s*\n([\s\S]*?)(?=\n## |\n---|Z)/);
801
+ const calledByMatch = content.match(
802
+ /## Called By \(inbound(?: connections)?\)\s*\r?\n([\s\S]*?)(?=\r?\n## |\r?\n---|$)/,
803
+ );
800
804
  if (calledByMatch) {
801
805
  const lines = calledByMatch[1].split('\n');
802
806
  for (const line of lines) {
803
- const match = line.match(/^-\s*`?([a-z][\w-]*)`?\s*\(L\d\)/i);
807
+ const match =
808
+ line.match(/^-\s*`?([a-z][\w-]*)`?\s*\((?:L[0-4]|ext)\)/i) ||
809
+ line.match(/^\|(?:[^|]*\|)*?\s*`?([a-z][\w-]*)`?\s*\|\s*(?:L[0-4]|ext)\s*\|/i);
804
810
  if (match) {
805
811
  const skillRef = match[1].toLowerCase();
806
812
  result.calledBy.push({ skill: skillRef });