@rune-kit/rune 2.30.0 → 2.30.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.30.0",
3
+ "version": "2.30.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.30.0Tier Restored)
106
+ ## What's New (v2.30.1Hooks That Land)
107
+
108
+ > **v2.30.1 (2026-07-29):** Five hooks had been running, exiting 0, and reaching nobody — nothing logged an error, so the hook layer looked healthy while contributing nothing. Two independent faults, each fatal on its own: **`process.stdout.write` from an exit handler is not guaranteed to reach a piped stdout** (most hooks emit via `captureConsole`, which flushes on exit — the envelope was built, "written", and lost; now `fs.writeSync(1, …)`), and **a hook that collects stdin with an async listener has its stdout discarded by Claude Code** even when the write is synchronous (new `hooks/lib/hook-stdin.cjs` reads with `readFileSync(0)`; `intent-router`, `context-watch`, `metrics-collector`, `pre-tool-guard`, `quarantine` converted). Both isolated by differential test — identical hooks, one variable at a time — and confirmed by the model quoting hook-injected context back. Separately, `intent-router` never had an index to read: `skill-index.json` is emitted at build time and Claude Code doesn't compile, so all five candidate paths were missing and it exited silently on every prompt since it shipped. New `scripts/build-skill-index.js` generates it at the plugin root with a `--check` staleness gate in CI. It was also registered `"async": true`, which drops `additionalContext` outright. 1,649 tests.
109
+
110
+ ### Previous (v2.30.0 — Tier Restored)
107
111
 
108
112
  > **v2.30.0 (2026-07-29):** Rune's model tier table had quietly stopped applying on Claude Code, and had been wrong in **23 of 66 places** for longer than that. `agents/*.md` is a hand-written parallel copy of each skill's tier; nothing generated it from `skills/` and no check compared the two, so they drifted — `cook` said opus in one file and sonnet in the other, `verification` said sonnet and haiku. `skills/*/SKILL.md` is now the single source of truth, every pair agrees, and a new **`validateAgentSync`** gate fails CI on any future drift, alongside a model-split check and a warning for a top-level `model` that cannot take effect. Where the two disagreed the tier was decided by role, with one rule overriding both old values: **a gate never runs below sonnet** — `completion-gate`, `constraint-check`, `integrity-check`, `hallucination-guard`, and `verification` exist to catch unverified claims, so running them cheaper than the agent they audit defeats the point. Separately: tiering only ever applied through spawned subagents, and on Opus 5 the harness tells the model not to spawn them unless asked — so every tier assignment had become a silent no-op. `scout` and `docs-seeker` now declare `context: fork` + `model: haiku` and route through the Skill tool instead; verified end-to-end on Opus 5 by per-model billing showing `claude-haiku-4-5`. `cook` deliberately declares no model — an orchestrator inherits the session model, because choosing Opus for a session is a deliberate act. 1,647 tests.
109
113
 
110
- ### Previous (v2.29.1 — One-Command Update)
114
+ #### Earlier (v2.29.1 — One-Command Update)
111
115
 
112
116
  > **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,641 tests.
113
117
 
@@ -897,7 +897,7 @@ const INTENT_KEYWORDS = {
897
897
  * @param {Array} parsedSkills - array of parsed skill objects
898
898
  * @returns {object} skill index with graph + intents
899
899
  */
900
- function generateSkillIndex(parsedSkills) {
900
+ export function generateSkillIndex(parsedSkills) {
901
901
  // Build adjacency graph from cross-references
902
902
  const graph = {};
903
903
  const skills = {};
@@ -14,6 +14,7 @@
14
14
  const fs = require('fs');
15
15
  const { stateFile } = require('../lib/context-key.cjs');
16
16
  const { captureConsole } = require('../lib/hook-output.cjs');
17
+ const { readStdinSync } = require('../lib/hook-stdin.cjs');
17
18
 
18
19
  captureConsole('PreToolUse');
19
20
 
@@ -31,9 +32,9 @@ const CRITICAL_THRESHOLD = 120;
31
32
 
32
33
  // Read stdin JSON to get tool name (Claude Code passes hook data via stdin)
33
34
  let stdinData = '';
34
- process.stdin.setEncoding('utf-8');
35
- process.stdin.on('data', chunk => { stdinData += chunk; });
36
- process.stdin.on('end', () => {
35
+ stdinData = readStdinSync();
36
+
37
+ (() => {
37
38
  let toolName = 'unknown';
38
39
  let sessionId;
39
40
  try {
@@ -102,8 +103,4 @@ process.stdin.on('end', () => {
102
103
  }
103
104
 
104
105
  process.exit(0);
105
- });
106
-
107
- // Handle empty stdin (pipe closed immediately)
108
- process.stdin.on('error', () => process.exit(0));
109
- process.stdin.resume();
106
+ })();
package/hooks/hooks.json CHANGED
@@ -19,7 +19,7 @@
19
19
  {
20
20
  "type": "command",
21
21
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" intent-router",
22
- "async": true
22
+ "async": false
23
23
  }
24
24
  ]
25
25
  }
@@ -10,14 +10,14 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { captureConsole } = require('../lib/hook-output.cjs');
13
+ const { readStdinSync } = require('../lib/hook-stdin.cjs');
13
14
 
14
15
  captureConsole('UserPromptSubmit');
15
16
 
16
- // Read user prompt from Claude Code hook stdin
17
- let input = '';
18
- process.stdin.setEncoding('utf-8');
19
- process.stdin.on('data', (chunk) => { input += chunk; });
20
- process.stdin.on('end', () => {
17
+ // Read user prompt from the hook payload. Must be synchronous — see hook-stdin.cjs.
18
+ const input = readStdinSync();
19
+
20
+ (() => {
21
21
  let userPrompt = '';
22
22
  try {
23
23
  const parsed = JSON.parse(input);
@@ -34,6 +34,9 @@ process.stdin.on('end', () => {
34
34
  // Find skill-index.json — check multiple locations
35
35
  const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '../..');
36
36
  const candidates = [
37
+ // Uncompiled plugin root (Claude Code serves the repo directly — nothing to
38
+ // compile, so this is the only index that exists on that runtime).
39
+ path.join(pluginRoot, 'skill-index.json'),
37
40
  path.join(pluginRoot, '.cursor', 'rules', 'skill-index.json'),
38
41
  path.join(pluginRoot, '.windsurf', 'rules', 'skill-index.json'),
39
42
  path.join(pluginRoot, 'dist', 'cursor', 'skill-index.json'),
@@ -108,4 +111,4 @@ process.stdin.on('end', () => {
108
111
  console.log('');
109
112
 
110
113
  process.exit(0);
111
- });
114
+ })();
@@ -22,6 +22,8 @@
22
22
  // Buffer-then-emit, because the envelope is ONE JSON object: a hook that prints
23
23
  // three lines must accumulate them and emit once at exit, not three times.
24
24
 
25
+ const fs = require('node:fs');
26
+
25
27
  /** Events where the model-facing payload is `additionalContext`. */
26
28
  const CONTEXT_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
27
29
 
@@ -50,7 +52,11 @@ function outputBuffer(hookEventName) {
50
52
  const payload = CONTEXT_EVENTS.has(hookEventName)
51
53
  ? { hookSpecificOutput: { hookEventName, additionalContext: text } }
52
54
  : { systemMessage: text };
53
- process.stdout.write(`${JSON.stringify(payload)}\n`);
55
+ // fs.writeSync, not process.stdout.write: most of these emits happen from a
56
+ // `process.on('exit')` handler, where a piped stdout is not guaranteed to
57
+ // flush before the process is gone. The write reports success either way, so
58
+ // the loss is silent — every hook using console.log was landing nowhere.
59
+ fs.writeSync(1, `${JSON.stringify(payload)}\n`);
54
60
  },
55
61
  };
56
62
  }
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // Hook stdin — read it synchronously, always.
4
+ //
5
+ // A hook that collects stdin with `process.stdin.on('data'/'end')` and writes its
6
+ // result from the 'end' callback has its stdout DISCARDED by Claude Code. The hook
7
+ // runs, the payload arrives, the callback fires, the write succeeds, exit code is 0
8
+ // — and the output never reaches the model. Nothing reports an error, which is why
9
+ // five Rune hooks looked healthy while contributing nothing.
10
+ //
11
+ // Verified by differential test on Claude Code 2.1.220: an identical hook writing
12
+ // the identical envelope is seen by the model when stdin is read with
13
+ // `readFileSync(0)`, and unseen when read with an async listener. Switching the
14
+ // write to `fs.writeSync(1, …)` does not rescue the async form, so this is about
15
+ // when the process settles, not about flushing.
16
+ //
17
+ // So: read stdin synchronously, then do the work on the main tick.
18
+
19
+ const fs = require('node:fs');
20
+
21
+ /**
22
+ * Read the hook payload from stdin, synchronously.
23
+ *
24
+ * @returns {string} raw stdin, or '' when there is nothing to read
25
+ */
26
+ function readStdinSync() {
27
+ try {
28
+ return fs.readFileSync(0, 'utf-8');
29
+ } catch {
30
+ // No stdin attached (manual invocation, or a runtime that omits the payload)
31
+ return '';
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Read and parse the payload. Hook payloads are JSON, but a runtime invoking a
37
+ * hook by hand may pipe raw text — callers that want that fallback read `.raw`.
38
+ *
39
+ * @returns {{raw: string, data: object}}
40
+ */
41
+ function readHookInput() {
42
+ const raw = readStdinSync();
43
+ let data = {};
44
+ try {
45
+ data = JSON.parse(raw);
46
+ } catch {
47
+ data = {};
48
+ }
49
+ return { raw, data };
50
+ }
51
+
52
+ module.exports = { readStdinSync, readHookInput };
@@ -10,6 +10,7 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
12
  const { resolveStateKey, stateFile } = require('../lib/context-key.cjs');
13
+ const { readStdinSync } = require('../lib/hook-stdin.cjs');
13
14
 
14
15
  // metricsFile + watchFile are keyed by the Claude Code session_id (parsed from
15
16
  // stdin in the handler) so they reset per session and match the session-keyed
@@ -17,9 +18,9 @@ const { resolveStateKey, stateFile } = require('../lib/context-key.cjs');
17
18
 
18
19
  // Read stdin JSON to get tool input (Claude Code passes hook data via stdin)
19
20
  let stdinData = '';
20
- process.stdin.setEncoding('utf-8');
21
- process.stdin.on('data', chunk => { stdinData += chunk; });
22
- process.stdin.on('end', () => {
21
+ stdinData = readStdinSync();
22
+
23
+ (() => {
23
24
  let skillName = 'unknown';
24
25
  let claudeSessionId; // raw Claude Code session_id — used to key temp files
25
26
 
@@ -108,8 +109,4 @@ process.stdin.on('end', () => {
108
109
  }
109
110
 
110
111
  process.exit(0);
111
- });
112
-
113
- // Handle empty stdin (pipe closed immediately)
114
- process.stdin.on('error', () => process.exit(0));
115
- process.stdin.resume();
112
+ })();
@@ -16,6 +16,7 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
18
  const { captureConsole } = require('../lib/hook-output.cjs');
19
+ const { readStdinSync } = require('../lib/hook-stdin.cjs');
19
20
  // Hook stdout is a JSON contract, not free text (Codex rejects bare lines and
20
21
  // discards the output). Capture the prints below and emit one envelope on exit.
21
22
  captureConsole('PreToolUse');
@@ -76,11 +77,10 @@ function patchTargetPaths(toolName, toolInput) {
76
77
  return Array.from(patch.matchAll(/^\*\*\* (?:Update|Add|Delete) File:\s*(.+)$/gm), (match) => match[1].trim());
77
78
  }
78
79
 
79
- // Read tool_input from Claude Code hook stdin
80
- let input = '';
81
- process.stdin.setEncoding('utf-8');
82
- process.stdin.on('data', (chunk) => { input += chunk; });
83
- process.stdin.on('end', () => {
80
+ // Read tool_input from the hook payload. Must be synchronous — see hook-stdin.cjs.
81
+ const input = readStdinSync();
82
+
83
+ (() => {
84
84
  let toolInput = {};
85
85
  let toolName = '';
86
86
  try {
@@ -179,7 +179,7 @@ process.stdin.on('end', () => {
179
179
  }
180
180
 
181
181
  process.exit(0);
182
- });
182
+ })();
183
183
 
184
184
  /**
185
185
  * Load .rune/privacy.json from project root
@@ -22,6 +22,7 @@
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
24
  const os = require('os');
25
+ const { readStdinSync } = require('../lib/hook-stdin.cjs');
25
26
 
26
27
  const HOOK_TIMEOUT_MS = 5000;
27
28
 
@@ -49,28 +50,18 @@ if (process.env.QUARANTINE_DISABLE === '1') {
49
50
  process.exit(0);
50
51
  }
51
52
 
52
- let stdinBuf = '';
53
- process.stdin.setEncoding('utf-8');
54
- process.stdin.on('data', (chunk) => {
55
- stdinBuf += chunk;
56
- // Cap stdin at 1MB — Claude Code never sends payloads this large
57
- if (stdinBuf.length > 1_000_000) {
58
- process.stdin.destroy();
59
- process.exit(0);
60
- }
61
- });
62
- process.stdin.on('end', () => {
63
- try {
64
- main(stdinBuf);
65
- } catch {
66
- // Any unexpected error → silent advisory exit (never block tool dispatch)
67
- process.exit(0);
68
- }
69
- });
70
- process.stdin.on('error', () => process.exit(0));
53
+ // Read synchronously — see hook-stdin.cjs for why an async listener loses output.
54
+ const stdinBuf = readStdinSync();
55
+
56
+ // Cap stdin at 1MB — Claude Code never sends payloads this large
57
+ if (stdinBuf.length > 1_000_000) {
58
+ process.exit(0);
59
+ }
71
60
 
72
- // If stdin is a TTY (no input), exit clean
73
- if (process.stdin.isTTY) {
61
+ try {
62
+ main(stdinBuf);
63
+ } catch {
64
+ // Any unexpected error → silent advisory exit (never block tool dispatch)
74
65
  process.exit(0);
75
66
  }
76
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rune-kit/rune",
3
- "version": "2.30.0",
3
+ "version": "2.30.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": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "node compiler/bin/rune.js build",
11
- "doctor": "node compiler/bin/rune.js doctor && node scripts/version-sync-check.js",
11
+ "doctor": "node compiler/bin/rune.js doctor && node scripts/version-sync-check.js && node scripts/build-skill-index.js --check",
12
12
  "test": "node --test compiler/__tests__/*.test.js scripts/__tests__/*.test.js",
13
13
  "test:coverage": "c8 --reporter=text --reporter=lcov node --test compiler/__tests__/*.test.js scripts/__tests__/*.test.js",
14
14
  "lint": "biome check .",
@@ -53,7 +53,8 @@
53
53
  "agents/",
54
54
  "hooks/",
55
55
  ".codex-plugin/",
56
- "references/"
56
+ "references/",
57
+ "skill-index.json"
57
58
  ],
58
59
  "homepage": "https://rune-kit.github.io/rune",
59
60
  "bugs": {