@rune-kit/rune 2.29.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rune",
3
- "version": "2.29.0",
3
+ "version": "2.29.1",
4
4
  "description": "66-skill engineering mesh with Codex-native skills, subagents, synchronous lifecycle hooks, security gates, MCP-aware workflows, and project memory.",
5
5
  "author": {
6
6
  "name": "Rune Contributors"
package/README.md CHANGED
@@ -103,11 +103,15 @@ Plus **9 domain packs** (product, sales, data-science, support, growth, media, p
103
103
 
104
104
  ---
105
105
 
106
- ## What's New (v2.29.0Codex Native)
106
+ ## What's New (v2.29.1One-Command Update)
107
+
108
+ > **v2.29.1 (2026-07-24):** v2.29.0 documented the update flow; this release automates it. New **`rune update`** command — one-shot updater for an already-configured project: `git pull --ff-only` any detected Pro/Business tier repos (env var → sibling dir, same detection as setup; a failed pull **aborts loudly**, never a silent half-update), re-runs the managed setup rewrite in place non-interactively (your installed platforms, preset, and tiers are detected from the existing hook config — no prompts), then verifies with doctor + hook drift and reminds Codex users to re-trust `/hooks` only when `.codex/hooks.json` actually changed. Flags: `--no-pull`, `--preset`, `--tier`, `--dry`. Plus: "Updating" sections in all three tier READMEs. Docs-and-CLI patch — no skill or mesh changes. 1,638 tests.
109
+
110
+ ### Previous (v2.29.0 — Codex Native)
107
111
 
108
112
  > **v2.29.0 (2026-07-23):** Codex stops being a compile target that happens to work and becomes a **first-class native runtime**. The Codex adapter now emits everything current Codex supports natively — `.agents/skills/`, project-scoped agent TOML (`.codex/agents/rune-{heavy,standard,fast}.toml`), a dedicated synchronous hook adapter targeting `.codex/hooks.json` (Codex silently skips async handlers), MCP config, and tier-aware compilation so Free/Pro/Business stacks resolve exactly as on Claude Code. The mesh validator was fixed at the root — `Calls (outbound)` is now the single authoritative edge inventory, acknowledged by the target's `Called By` — moving the canonical count from 209 to **248 connections** (same mesh, honest count; doctor now fails CI on any stale claim). Doctor also grew a cross-tier audit: Business metadata (28 pack skills, 4 orchestrators), $149 pricing, 13-platform count, and JSON-schema validation of the new `docs/config-schema.json` + hooks manifest schema. All XLabs remote-MCP references now go through `XLABS_MCP_TOKEN` — plaintext bearer values are forbidden. 1,615 tests pass.
109
113
 
110
- ### Previous (v2.28.0 — Reasoner's Blind Spots)
114
+ #### Earlier (v2.28.0 — Reasoner's Blind Spots)
111
115
 
112
116
  > **v2.28.0 (2026-07-22):** Completes the reasoning wave. Every addition targets one failure class: **a check that feels done because the model re-read its own work and agreed with itself.** `problem-solver` (v0.6.0) gains a **model failure-mode table** beside its human-bias table — pattern-match satisfaction, template hijack, fluent≠true, prior-as-fact, completion pressure, surface blindness — plus three tells that you are inside one right now (instant confident answer; a stated detail your draft never used; two failed attempts in the same framing). `verification` (v0.8.0) gains the **Constraint Loop** for deliverables carrying a mechanically checkable constraint on their own surface form (banned characters, exact counts, strict formats) — a class Rune had no coverage for: expand the constraint before drafting, verify with a tool, re-scan the whole artifact, ship byte-for-byte. `design` (v0.9.0) gains **render blindness** — a checklist item ticked from source is a prediction, and the imagined render is always flattering; visual items are marked 👁 and are ticked from a render or marked ASSUMED. Advisory throughout, no new skills.
113
117
 
@@ -407,6 +411,45 @@ This compiles all 66 skills into your IDE's rules format. Same knowledge, same w
407
411
  lifecycle hooks; the remaining adapters preserve the workflows and constraints in the best
408
412
  format each runtime supports.
409
413
 
414
+ ## Updating
415
+
416
+ New releases are announced on [Telegram](https://t.me/xlabs_updates) and [GitHub Releases](https://github.com/rune-kit/rune/releases); see [`CHANGELOG.md`](CHANGELOG.md) for what changed.
417
+
418
+ > **One-liner alternative**: `npx @rune-kit/rune@latest update` runs the whole flow below for an already-configured project — git-pulls detected Pro/Business repos, re-runs the managed setup rewrite in place (reusing your installed platforms/preset/tiers), and verifies with doctor. The platform-specific commands below remain the canonical path.
419
+
420
+ ### Claude Code (plugin)
421
+
422
+ ```bash
423
+ claude plugin update rune
424
+ ```
425
+
426
+ Restart Claude Code to load the new version, then re-run the wizard once so hooks and tier skills match the new release:
427
+
428
+ ```bash
429
+ npx @rune-kit/rune@latest setup
430
+ ```
431
+
432
+ ### Codex / Cursor / Windsurf / other compiled platforms
433
+
434
+ Re-running setup with `@latest` fetches the newest compiler and rewrites everything Rune manages **in place** — it is idempotent, and never touches your own config or the human-authored parts of `AGENTS.md`:
435
+
436
+ ```bash
437
+ # same flags you installed with, e.g.:
438
+ npx @rune-kit/rune@latest setup --here
439
+ npx @rune-kit/rune@latest setup --here --platform codex --tier pro
440
+ ```
441
+
442
+ If you use the build pipeline (`rune init` route) instead of the wizard: `npx @rune-kit/rune@latest init --force` regenerates platform files without touching your `.rune/` state.
443
+
444
+ ### Verify after updating
445
+
446
+ ```bash
447
+ npx @rune-kit/rune@latest doctor # compiled output + mesh integrity
448
+ npx @rune-kit/rune@latest doctor --hooks # hook drift vs the canonical preset
449
+ ```
450
+
451
+ On Codex, review and re-trust changed hooks with `/hooks`.
452
+
410
453
  ## Quick Start
411
454
 
412
455
  ```bash
@@ -0,0 +1,416 @@
1
+ import assert from 'node:assert';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, describe, test } from 'node:test';
6
+ import { installHooks } from '../commands/hooks/install.js';
7
+ import { detectTiers } from '../commands/setup.js';
8
+ import {
9
+ detectInstalledPreset,
10
+ detectInstalledTiers,
11
+ formatUpdateResult,
12
+ pullTierRepos,
13
+ runUpdate,
14
+ } from '../commands/update.js';
15
+
16
+ let tmpRoot;
17
+
18
+ /** Seed a tier repo sibling (../Pro or ../Business) with a manifest whose
19
+ * entries reference the tier env var — matching what real tier manifests emit. */
20
+ async function seedTierRepo(projectRoot, tier, opts = {}) {
21
+ const tierRoot = path.join(projectRoot, '..', tier === 'pro' ? 'Pro' : 'Business');
22
+ await mkdir(path.join(tierRoot, 'hooks'), { recursive: true });
23
+ const envVar = tier === 'pro' ? 'RUNE_PRO_ROOT' : 'RUNE_BUSINESS_ROOT';
24
+ await writeFile(
25
+ path.join(tierRoot, 'hooks', 'manifest.json'),
26
+ JSON.stringify({
27
+ tier,
28
+ version: '1.0.0',
29
+ minFreeVersion: '2.0.0',
30
+ requires: [envVar],
31
+ entries: [
32
+ {
33
+ id: `${tier}-pulse`,
34
+ event: 'PreToolUse',
35
+ matcher: 'Edit|Write',
36
+ command: `node "\${${envVar}}/hooks/pulse.js"`,
37
+ },
38
+ ],
39
+ }),
40
+ );
41
+ if (opts.git) {
42
+ await mkdir(path.join(tierRoot, '.git'), { recursive: true });
43
+ }
44
+ return tierRoot;
45
+ }
46
+
47
+ async function seedFakeRuneRoot(root) {
48
+ const runeRoot = path.join(root, 'fake-rune');
49
+ await mkdir(path.join(runeRoot, 'skills'), { recursive: true });
50
+ return runeRoot;
51
+ }
52
+
53
+ /** Install real Free hooks (optionally + tier) into the tmp project so the
54
+ * "already installed" detection paths have something real to read. */
55
+ async function installProjectHooks(projectRoot, { preset = 'gentle', tier } = {}) {
56
+ await mkdir(path.join(projectRoot, '.claude'), { recursive: true });
57
+ await installHooks(projectRoot, { preset, tier, platform: 'claude' });
58
+ }
59
+
60
+ beforeEach(async () => {
61
+ delete process.env.RUNE_PRO_ROOT;
62
+ delete process.env.RUNE_BUSINESS_ROOT;
63
+ tmpRoot = await mkdtemp(path.join(tmpdir(), 'rune-update-'));
64
+ await mkdir(path.join(tmpRoot, 'project'), { recursive: true });
65
+ });
66
+
67
+ afterEach(async () => {
68
+ if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
69
+ delete process.env.RUNE_PRO_ROOT;
70
+ delete process.env.RUNE_BUSINESS_ROOT;
71
+ });
72
+
73
+ describe('detectInstalledTiers', () => {
74
+ test('returns [] for a project with no Rune hook configs', async () => {
75
+ const projectRoot = path.join(tmpRoot, 'project');
76
+ assert.deepStrictEqual(await detectInstalledTiers(projectRoot), []);
77
+ });
78
+
79
+ test('returns [] when only Free preset hooks are installed (no false positive)', async () => {
80
+ const projectRoot = path.join(tmpRoot, 'project');
81
+ await installProjectHooks(projectRoot, { preset: 'gentle' });
82
+ assert.deepStrictEqual(await detectInstalledTiers(projectRoot), []);
83
+ });
84
+
85
+ test('detects pro from RUNE_PRO_ROOT tokens in .claude/settings.json', async () => {
86
+ const projectRoot = path.join(tmpRoot, 'project');
87
+ await seedTierRepo(projectRoot, 'pro');
88
+ await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
89
+ assert.deepStrictEqual(await detectInstalledTiers(projectRoot), ['pro']);
90
+ });
91
+
92
+ test('detects business from tier token in .codex/hooks.json', async () => {
93
+ const projectRoot = path.join(tmpRoot, 'project');
94
+ await mkdir(path.join(projectRoot, '.codex'), { recursive: true });
95
+ await writeFile(
96
+ path.join(projectRoot, '.codex', 'hooks.json'),
97
+ JSON.stringify({
98
+ hooks: {
99
+ PreToolUse: [
100
+ {
101
+ matcher: '.*',
102
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal env token by design
103
+ hooks: [{ type: 'command', command: 'node "${RUNE_BUSINESS_ROOT}/hooks/gate.js"' }],
104
+ },
105
+ ],
106
+ },
107
+ }),
108
+ );
109
+ assert.deepStrictEqual(await detectInstalledTiers(projectRoot), ['business']);
110
+ });
111
+
112
+ test('detects pro from rune-tier frontmatter in .cursor/rules', async () => {
113
+ const projectRoot = path.join(tmpRoot, 'project');
114
+ const rulesDir = path.join(projectRoot, '.cursor', 'rules');
115
+ await mkdir(rulesDir, { recursive: true });
116
+ await writeFile(path.join(rulesDir, 'rune-pro-pulse.mdc'), '---\nrune-managed: true\nrune-tier: pro\n---\n# x\n');
117
+ assert.deepStrictEqual(await detectInstalledTiers(projectRoot), ['pro']);
118
+ });
119
+ });
120
+
121
+ describe('detectInstalledPreset', () => {
122
+ test('returns null when nothing is installed', async () => {
123
+ const projectRoot = path.join(tmpRoot, 'project');
124
+ assert.strictEqual(await detectInstalledPreset(projectRoot), null);
125
+ });
126
+
127
+ test('returns the installed preset (strict)', async () => {
128
+ const projectRoot = path.join(tmpRoot, 'project');
129
+ await installProjectHooks(projectRoot, { preset: 'strict' });
130
+ assert.strictEqual(await detectInstalledPreset(projectRoot), 'strict');
131
+ });
132
+
133
+ test('returns gentle for a gentle install', async () => {
134
+ const projectRoot = path.join(tmpRoot, 'project');
135
+ await installProjectHooks(projectRoot, { preset: 'gentle' });
136
+ assert.strictEqual(await detectInstalledPreset(projectRoot), 'gentle');
137
+ });
138
+ });
139
+
140
+ describe('pullTierRepos', () => {
141
+ test('reports absent tiers as skipped without calling git', async () => {
142
+ const projectRoot = path.join(tmpRoot, 'project');
143
+ const calls = [];
144
+ const exec = async (...args) => {
145
+ calls.push(args);
146
+ return { code: 0, stdout: '', stderr: '' };
147
+ };
148
+ const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
149
+ const result = await pullTierRepos({ detected, exec });
150
+ assert.strictEqual(result.ok, true);
151
+ assert.strictEqual(calls.length, 0);
152
+ for (const r of result.results) {
153
+ assert.strictEqual(r.status, 'absent');
154
+ }
155
+ });
156
+
157
+ test('skips (with note) a detected tier that is not a git repo', async () => {
158
+ const projectRoot = path.join(tmpRoot, 'project');
159
+ await seedTierRepo(projectRoot, 'pro'); // no .git
160
+ const exec = async () => {
161
+ throw new Error('git must not be called');
162
+ };
163
+ const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
164
+ const result = await pullTierRepos({ detected, exec });
165
+ assert.strictEqual(result.ok, true);
166
+ const pro = result.results.find((r) => r.tier === 'pro');
167
+ assert.strictEqual(pro.status, 'skipped');
168
+ assert.match(pro.detail, /not a git repo/i);
169
+ });
170
+
171
+ test('pulls a detected git tier with git -C <root> pull --ff-only', async () => {
172
+ const projectRoot = path.join(tmpRoot, 'project');
173
+ const tierRoot = await seedTierRepo(projectRoot, 'pro', { git: true });
174
+ const calls = [];
175
+ const exec = async (cmd, argv) => {
176
+ calls.push([cmd, argv]);
177
+ return { code: 0, stdout: 'Already up to date.\n', stderr: '' };
178
+ };
179
+ const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
180
+ const result = await pullTierRepos({ detected, exec });
181
+ assert.strictEqual(result.ok, true);
182
+ const pro = result.results.find((r) => r.tier === 'pro');
183
+ assert.strictEqual(pro.status, 'pulled');
184
+ assert.strictEqual(calls.length, 1);
185
+ assert.strictEqual(calls[0][0], 'git');
186
+ assert.deepStrictEqual(calls[0][1], ['-C', path.resolve(tierRoot), 'pull', '--ff-only']);
187
+ });
188
+
189
+ test('fails loud when git pull fails (dirty tree, auth, ...)', async () => {
190
+ const projectRoot = path.join(tmpRoot, 'project');
191
+ await seedTierRepo(projectRoot, 'pro', { git: true });
192
+ const exec = async () => ({ code: 1, stdout: '', stderr: 'error: Your local changes would be overwritten' });
193
+ const detected = detectTiers(projectRoot, { wellKnownPaths: { pro: [], business: [] } });
194
+ const result = await pullTierRepos({ detected, exec });
195
+ assert.strictEqual(result.ok, false);
196
+ const pro = result.results.find((r) => r.tier === 'pro');
197
+ assert.strictEqual(pro.status, 'failed');
198
+ assert.match(pro.detail, /local changes/);
199
+ });
200
+ });
201
+
202
+ describe('runUpdate', () => {
203
+ test('fails with guidance when no Rune installation is detected', async () => {
204
+ const projectRoot = path.join(tmpRoot, 'project');
205
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
206
+ const result = await runUpdate({
207
+ projectRoot,
208
+ runeRoot,
209
+ args: {},
210
+ deps: { wellKnownPaths: { pro: [], business: [] } },
211
+ });
212
+ assert.strictEqual(result.ok, false);
213
+ assert.match(result.reason, /setup/i);
214
+ });
215
+
216
+ test('re-runs setup reusing installed preset + tier, pulls tier repo', async () => {
217
+ const projectRoot = path.join(tmpRoot, 'project');
218
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
219
+ await seedTierRepo(projectRoot, 'pro', { git: true });
220
+ await installProjectHooks(projectRoot, { preset: 'strict', tier: ['pro'] });
221
+ const execCalls = [];
222
+ const exec = async (cmd, argv) => {
223
+ execCalls.push([cmd, argv]);
224
+ return { code: 0, stdout: 'Already up to date.\n', stderr: '' };
225
+ };
226
+
227
+ const result = await runUpdate({
228
+ projectRoot,
229
+ runeRoot,
230
+ args: {},
231
+ deps: { exec, skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
232
+ });
233
+
234
+ assert.strictEqual(result.ok, true);
235
+ assert.strictEqual(result.preset, 'strict');
236
+ assert.deepStrictEqual(result.tiers, ['pro']);
237
+ assert.strictEqual(execCalls.length, 1);
238
+ assert.ok(result.setup, 'setup result expected');
239
+ assert.deepStrictEqual(result.setup.tiers, ['pro']);
240
+ assert.strictEqual(result.setup.preset, 'strict');
241
+ assert.ok(result.drift, 'drift result expected');
242
+ const pro = result.pull.results.find((r) => r.tier === 'pro');
243
+ assert.strictEqual(pro.status, 'pulled');
244
+ });
245
+
246
+ test('does NOT run setup when a tier pull fails', async () => {
247
+ const projectRoot = path.join(tmpRoot, 'project');
248
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
249
+ await seedTierRepo(projectRoot, 'pro', { git: true });
250
+ await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
251
+ let setupRan = false;
252
+ const result = await runUpdate({
253
+ projectRoot,
254
+ runeRoot,
255
+ args: {},
256
+ deps: {
257
+ exec: async () => ({ code: 128, stdout: '', stderr: 'fatal: could not read Username' }),
258
+ runSetupFn: async () => {
259
+ setupRan = true;
260
+ return {};
261
+ },
262
+ wellKnownPaths: { pro: [], business: [] },
263
+ },
264
+ });
265
+ assert.strictEqual(result.ok, false);
266
+ assert.strictEqual(setupRan, false);
267
+ assert.match(result.reason, /pull failed/i);
268
+ });
269
+
270
+ test('--no-pull skips git pulls entirely', async () => {
271
+ const projectRoot = path.join(tmpRoot, 'project');
272
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
273
+ await seedTierRepo(projectRoot, 'pro', { git: true });
274
+ await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
275
+ const exec = async () => {
276
+ throw new Error('git must not be called with --no-pull');
277
+ };
278
+ const result = await runUpdate({
279
+ projectRoot,
280
+ runeRoot,
281
+ args: { 'no-pull': true },
282
+ deps: { exec, skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
283
+ });
284
+ assert.strictEqual(result.ok, true);
285
+ for (const r of result.pull.results) {
286
+ assert.notStrictEqual(r.status, 'pulled');
287
+ }
288
+ });
289
+
290
+ test('skips (with note) an installed tier whose repo is no longer found', async () => {
291
+ const projectRoot = path.join(tmpRoot, 'project');
292
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
293
+ // Install hooks WITH the pro tier while the repo exists...
294
+ await seedTierRepo(projectRoot, 'pro');
295
+ await installProjectHooks(projectRoot, { preset: 'gentle', tier: ['pro'] });
296
+ // ...then the repo disappears (user deleted the clone).
297
+ await rm(path.join(projectRoot, '..', 'Pro'), { recursive: true, force: true });
298
+
299
+ const result = await runUpdate({
300
+ projectRoot,
301
+ runeRoot,
302
+ args: {},
303
+ deps: { skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
304
+ });
305
+
306
+ assert.strictEqual(result.ok, true);
307
+ assert.deepStrictEqual(result.tiers, []);
308
+ assert.ok(
309
+ result.notes.some((n) => /pro/.test(n) && /not found/i.test(n)),
310
+ `expected a "repo not found" note, got: ${JSON.stringify(result.notes)}`,
311
+ );
312
+ });
313
+
314
+ test('--dry passes dry through to setup (no writes)', async () => {
315
+ const projectRoot = path.join(tmpRoot, 'project');
316
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
317
+ await installProjectHooks(projectRoot, { preset: 'gentle' });
318
+ const result = await runUpdate({
319
+ projectRoot,
320
+ runeRoot,
321
+ args: { dry: true, 'no-pull': true },
322
+ deps: { skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
323
+ });
324
+ assert.strictEqual(result.ok, true);
325
+ assert.strictEqual(result.setup.written, false);
326
+ });
327
+
328
+ test('flags Codex re-trust when .codex/hooks.json content changed', async () => {
329
+ const projectRoot = path.join(tmpRoot, 'project');
330
+ const runeRoot = await seedFakeRuneRoot(tmpRoot);
331
+ await mkdir(path.join(projectRoot, '.codex'), { recursive: true });
332
+ // Stale/no hooks file → setup rewrite will change it.
333
+ const result = await runUpdate({
334
+ projectRoot,
335
+ runeRoot,
336
+ args: { 'no-pull': true },
337
+ deps: { skillTarget: runeRoot, wellKnownPaths: { pro: [], business: [] } },
338
+ });
339
+ assert.strictEqual(result.ok, true);
340
+ assert.strictEqual(result.codexReTrust, true);
341
+ });
342
+ });
343
+
344
+ describe('formatUpdateResult', () => {
345
+ test('renders pull, setup, and verification summary', () => {
346
+ const out = formatUpdateResult({
347
+ ok: true,
348
+ pull: {
349
+ ok: true,
350
+ results: [
351
+ { tier: 'pro', status: 'pulled', detail: 'Already up to date.' },
352
+ { tier: 'business', status: 'absent', detail: 'not detected' },
353
+ ],
354
+ },
355
+ platforms: ['claude'],
356
+ preset: 'gentle',
357
+ tiers: ['pro'],
358
+ setup: {
359
+ scope: 'current',
360
+ targetRoot: '/p',
361
+ tiers: ['pro'],
362
+ preset: 'gentle',
363
+ platforms: ['claude'],
364
+ written: true,
365
+ skillResults: [],
366
+ },
367
+ drift: {
368
+ findings: [],
369
+ summary: { drifted: 0, missing: 0, errors: 0 },
370
+ platforms: [{ platform: 'claude', preset: 'gentle' }],
371
+ },
372
+ doctor: { skipped: true, reason: 'no rune.config.json' },
373
+ codexReTrust: false,
374
+ notes: [],
375
+ });
376
+ assert.match(out, /Rune Update/);
377
+ assert.match(out, /pro.*pulled/i);
378
+ assert.match(out, /business.*not detected/i);
379
+ assert.match(out, /preset.*gentle/i);
380
+ assert.match(out, /0 drifted, 0 missing/);
381
+ assert.doesNotMatch(out, /\/hooks/);
382
+ });
383
+
384
+ test('renders the Codex re-trust reminder when hooks.json changed', () => {
385
+ const out = formatUpdateResult({
386
+ ok: true,
387
+ pull: { ok: true, results: [] },
388
+ platforms: ['codex'],
389
+ preset: 'gentle',
390
+ tiers: [],
391
+ setup: { platforms: ['codex'], written: true, skillResults: [] },
392
+ drift: { findings: [], summary: { drifted: 0, missing: 0, errors: 0 }, platforms: [] },
393
+ doctor: { skipped: true, reason: 'no rune.config.json' },
394
+ codexReTrust: true,
395
+ notes: [],
396
+ });
397
+ assert.match(out, /\/hooks/);
398
+ assert.match(out, /re-trust/i);
399
+ });
400
+
401
+ test('renders loud failure for a failed pull', () => {
402
+ const out = formatUpdateResult({
403
+ ok: false,
404
+ reason: 'tier pull failed — resolve manually and re-run `rune update`',
405
+ pull: {
406
+ ok: false,
407
+ results: [{ tier: 'pro', status: 'failed', detail: 'error: Your local changes would be overwritten' }],
408
+ },
409
+ notes: [],
410
+ });
411
+ assert.match(out, /✗/);
412
+ assert.match(out, /pro/);
413
+ assert.match(out, /local changes/);
414
+ assert.match(out, /re-run/i);
415
+ });
416
+ });
@@ -25,6 +25,7 @@ import { installHooks } from '../commands/hooks/install.js';
25
25
  import { hookStatus } from '../commands/hooks/status.js';
26
26
  import { uninstallHooks } from '../commands/hooks/uninstall.js';
27
27
  import { formatSetupResult, runSetup } from '../commands/setup.js';
28
+ import { formatUpdateResult, runUpdate } from '../commands/update.js';
28
29
  import { generateComprehensionHTML } from '../comprehension.js';
29
30
  import { generateDashboardHTML } from '../dashboard.js';
30
31
  import { checkMeshIntegrity, formatDoctorResults, formatMeshResults, runDoctor } from '../doctor.js';
@@ -338,6 +339,18 @@ async function cmdSetup(projectRoot, args) {
338
339
  }
339
340
  }
340
341
 
342
+ async function cmdUpdate(projectRoot, args) {
343
+ try {
344
+ const result = await runUpdate({ projectRoot, runeRoot: RUNE_ROOT, args });
345
+ log(formatUpdateResult(result));
346
+ if (!result.ok) process.exit(1);
347
+ } catch (err) {
348
+ log('');
349
+ log(` ✗ Update failed: ${err.message}`);
350
+ process.exit(1);
351
+ }
352
+ }
353
+
341
354
  async function readVersion() {
342
355
  try {
343
356
  const pkg = JSON.parse(await readFile(path.join(RUNE_ROOT, 'package.json'), 'utf-8'));
@@ -852,6 +865,9 @@ async function main() {
852
865
  case 'setup':
853
866
  await cmdSetup(projectRoot, args);
854
867
  break;
868
+ case 'update':
869
+ await cmdUpdate(projectRoot, args);
870
+ break;
855
871
  case 'status':
856
872
  await cmdStatus(projectRoot, args);
857
873
  break;
@@ -887,6 +903,9 @@ async function main() {
887
903
  ' setup Interactive wizard — auto-detect tiers, pick scope, install hooks (recommended for first-time)',
888
904
  );
889
905
  log(' [--here|--global] [--tier pro,business] [--preset gentle|strict] [--dry]');
906
+ log(' update Update an existing install — git-pull detected tier repos, re-run the managed');
907
+ log(' setup rewrite in place (reuses installed platforms/preset/tiers), verify with doctor');
908
+ log(' [--no-pull] [--preset gentle|strict] [--tier pro,business] [--dry]');
890
909
  log(' init Interactive setup for build pipeline (auto-detects platform)');
891
910
  log(' build Compile skills for configured platform');
892
911
  log(' doctor Validate compiled output + mesh integrity');
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rune-kit/rune",
3
- "version": "2.29.0",
3
+ "version": "2.29.1",
4
4
  "description": "66-skill mesh for AI coding assistants — native lifecycle hooks for Claude Code and Codex, 5-layer architecture, 248 connections + 45 signals, and a 13-platform compiler.",
5
5
  "type": "module",
6
6
  "bin": {