@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
@@ -19,6 +19,7 @@ import { resolveScriptsPath } from './transforms/scripts-path.js';
19
19
  * @returns {Promise<string[]>} array of SKILL.md file paths
20
20
  */
21
21
  async function discoverSkills(skillsDir) {
22
+ if (!existsSync(skillsDir)) return [];
22
23
  const entries = await readdir(skillsDir, { withFileTypes: true });
23
24
  const paths = [];
24
25
 
@@ -33,6 +34,38 @@ async function discoverSkills(skillsDir) {
33
34
  return paths.sort();
34
35
  }
35
36
 
37
+ /**
38
+ * Discover standalone skills across Free and paid tiers.
39
+ * Business > Pro > Free when the same skill directory name exists.
40
+ *
41
+ * Tier sources point at `<tier>/extensions`; standalone tier skills live in
42
+ * the sibling `<tier>/skills` directory.
43
+ *
44
+ * @param {string} freeSkillsDir
45
+ * @param {Object<string, string>} [tierSources]
46
+ * @returns {Promise<Array<{path: string, tier: string, name: string}>>}
47
+ */
48
+ export async function discoverTieredSkills(freeSkillsDir, tierSources = {}) {
49
+ const skillMap = new Map();
50
+
51
+ async function scanDir(skillsDir, tier) {
52
+ for (const skillPath of await discoverSkills(skillsDir)) {
53
+ const name = path.basename(path.dirname(skillPath));
54
+ const priority = TIER_PRIORITY[tier] ?? 0;
55
+ const existing = skillMap.get(name);
56
+ if (!existing || priority > existing.priority) {
57
+ skillMap.set(name, { path: skillPath, tier, name, priority });
58
+ }
59
+ }
60
+ }
61
+
62
+ await scanDir(freeSkillsDir, 'free');
63
+ if (tierSources.pro) await scanDir(path.join(path.dirname(tierSources.pro), 'skills'), 'pro');
64
+ if (tierSources.business) await scanDir(path.join(path.dirname(tierSources.business), 'skills'), 'business');
65
+
66
+ return [...skillMap.values()].sort((a, b) => a.name.localeCompare(b.name));
67
+ }
68
+
36
69
  /**
37
70
  * Discover all PACK.md files in the extensions directory
38
71
  *
@@ -435,10 +468,15 @@ export async function buildAll({
435
468
  // Ensure output directory exists
436
469
  await mkdir(outputDir, { recursive: true });
437
470
 
438
- const skillPaths = await discoverSkills(skillsDir);
439
-
440
471
  // Tier-aware pack discovery: if tierSources provided, resolve overrides
441
472
  const hasTiers = tierSources && (tierSources.pro || tierSources.business);
473
+ const skillEntries = hasTiers
474
+ ? await discoverTieredSkills(skillsDir, tierSources)
475
+ : (await discoverSkills(skillsDir)).map((skillPath) => ({
476
+ path: skillPath,
477
+ tier: 'free',
478
+ name: path.basename(path.dirname(skillPath)),
479
+ }));
442
480
  const packEntries = hasTiers
443
481
  ? await discoverTieredPacks(extensionsDir, tierSources, enabledPacks)
444
482
  : (await discoverPacks(extensionsDir, enabledPacks)).map((p) => ({
@@ -450,6 +488,8 @@ export async function buildAll({
450
488
  const stats = {
451
489
  platform: adapter.name,
452
490
  skillCount: 0,
491
+ coreSkillCount: 0,
492
+ tierSkillCount: 0,
453
493
  packCount: 0,
454
494
  crossRefsResolved: 0,
455
495
  toolRefsResolved: 0,
@@ -471,7 +511,8 @@ export async function buildAll({
471
511
  // Build skills — collect parsed data for skill-index + openclaw reuse
472
512
  const parsedSkills = [];
473
513
 
474
- for (const skillPath of skillPaths) {
514
+ for (const skillEntry of skillEntries) {
515
+ const skillPath = skillEntry.path;
475
516
  try {
476
517
  const content = await readFile(skillPath, 'utf-8');
477
518
  const parsed = parseSkill(content, skillPath);
@@ -553,6 +594,8 @@ export async function buildAll({
553
594
 
554
595
  parsedSkills.push(parsed);
555
596
  stats.skillCount++;
597
+ if (skillEntry.tier === 'free') stats.coreSkillCount++;
598
+ else stats.tierSkillCount++;
556
599
  stats.crossRefsResolved += parsed.crossRefs.length;
557
600
  stats.toolRefsResolved += parsed.toolRefs.length;
558
601
  stats.files.push(displayName);
@@ -312,8 +312,9 @@ async function assembleCompliance(runeRoot) {
312
312
  continue; // skip unreadable pack
313
313
  }
314
314
 
315
- // Derive a human-readable pack name from the directory name.
316
- const packName = packDir.replace(/^pro-/, '@rune-business/');
315
+ // Business is the entitlement tier; paid pack manifests retain the
316
+ // canonical @rune-pro/* namespace for mesh compatibility.
317
+ const packName = packDir.replace(/^pro-/, '@rune-pro/');
317
318
 
318
319
  // Extract obligations from ## Constraints. Real Business PACK.md files use
319
320
  // numbered ("1. MUST …") OR bulleted ("- MUST …") lists, so accept both.
@@ -8,6 +8,7 @@
8
8
  import { existsSync } from 'node:fs';
9
9
  import { readdir, readFile } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
+ import { parseSkillConnections } from './doctor.js';
11
12
  import { parseSkill } from './parser.js';
12
13
 
13
14
  // ─── Constants ───
@@ -73,6 +74,7 @@ export async function collectStats(runeRoot, tierSources = {}) {
73
74
  const layers = { L0: 0, L1: 0, L2: 0, L3: 0 };
74
75
  const skillNames = [];
75
76
  let signalCount = 0;
77
+ let totalConnections = 0;
76
78
  const signalMap = { emitters: {}, listeners: {} };
77
79
  const parsedSkills = [];
78
80
 
@@ -85,6 +87,7 @@ export async function collectStats(runeRoot, tierSources = {}) {
85
87
 
86
88
  const content = await readFile(skillFile, 'utf-8');
87
89
  const parsed = parseSkill(content, skillFile);
90
+ totalConnections += parseSkillConnections(content, entry.name).calls.length;
88
91
  parsedSkills.push(parsed);
89
92
  skillNames.push(parsed.name);
90
93
 
@@ -106,11 +109,6 @@ export async function collectStats(runeRoot, tierSources = {}) {
106
109
  signalCount = allSignals.size;
107
110
  }
108
111
 
109
- // Count connections
110
- let totalConnections = 0;
111
- for (const skill of parsedSkills) {
112
- totalConnections += new Set((skill.crossRefs ?? []).map((r) => r.skillName)).size;
113
- }
114
112
  const avgConnections = parsedSkills.length > 0 ? (totalConnections / parsedSkills.length).toFixed(1) : '0';
115
113
 
116
114
  // Count free packs
@@ -302,8 +300,7 @@ export function renderStatusJson(stats, { version = '', platform = '', projectNa
302
300
 
303
301
  function formatPackName(dirName, tier = 'free') {
304
302
  const baseName = dirName.replace(/^(pro|business)-/, '');
305
- if (tier === 'business') return `@rune-biz/${baseName}`.padEnd(26);
306
- if (tier === 'pro') return `@rune-pro/${baseName}`.padEnd(26);
303
+ if (tier === 'business' || tier === 'pro') return `@rune-pro/${baseName}`.padEnd(26);
307
304
  return `@rune/${baseName}`.padEnd(26);
308
305
  }
309
306
 
@@ -12,9 +12,9 @@
12
12
  export const BRANDING_FOOTER = [
13
13
  '',
14
14
  '---',
15
- '> **Rune Skill Mesh** — 64 skills, 204 connections + 40 signals, 14 extension packs',
15
+ '> **Rune Skill Mesh** — 66 skills, 248 connections + 45 signals, 14 extension packs',
16
16
  '> [Landing Page](https://rune-kit.github.io/rune) Ā· [Source](https://github.com/rune-kit/rune) (MIT)',
17
- '> **Rune Pro** ($49 lifetime) — product, sales, data-science, support packs → [rune-kit/rune-pro](https://github.com/rune-kit/rune-pro)',
17
+ '> **Rune Pro** ($49 lifetime) — 9 domain packs + context intelligence → [rune-kit/rune-pro](https://github.com/rune-kit/rune-pro)',
18
18
  '> **Rune Business** ($149 lifetime) — finance, legal, HR, enterprise-search packs → [rune-kit/rune-business](https://github.com/rune-kit/rune-business)',
19
19
  ].join('\n');
20
20
 
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Subagent Transform
3
3
  *
4
- * Converts parallel agent/subagent execution instructions to sequential workflow.
5
- * On non-Claude platforms, there is no multi-agent support.
4
+ * Converts parallel agent/subagent execution instructions to sequential workflow
5
+ * only on platforms without native multi-agent support.
6
6
  */
7
7
 
8
8
  const PARALLEL_PATTERNS = [
@@ -23,7 +23,7 @@ const PARALLEL_PATTERNS = [
23
23
  * @returns {string} transformed body
24
24
  */
25
25
  export function transformSubagents(body, adapter) {
26
- if (adapter.name === 'claude') return body;
26
+ if (adapter.name === 'claude' || adapter.name === 'codex') return body;
27
27
 
28
28
  let result = body;
29
29
  for (const { pattern, replace } of PARALLEL_PATTERNS) {
@@ -0,0 +1,96 @@
1
+ {
2
+ "description": "Rune lifecycle hooks optimized for OpenAI Codex.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|clear|compact",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" session-start"
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "UserPromptSubmit": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" intent-router"
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "PreToolUse": [
26
+ {
27
+ "matcher": "Read|Write|Edit|apply_patch|view_image",
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" pre-tool-guard"
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "matcher": ".*",
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" context-watch"
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "matcher": "Bash|shell_command",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" secrets-scan"
50
+ }
51
+ ]
52
+ }
53
+ ],
54
+ "PostToolUse": [
55
+ {
56
+ "matcher": "Skill|Task|Agent|spawn_agent",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" metrics-collector"
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "matcher": "mcp__.*|WebFetch|Read|web_search",
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" quarantine"
70
+ }
71
+ ]
72
+ }
73
+ ],
74
+ "PreCompact": [
75
+ {
76
+ "matcher": ".*",
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" pre-compact"
81
+ }
82
+ ]
83
+ }
84
+ ],
85
+ "Stop": [
86
+ {
87
+ "hooks": [
88
+ {
89
+ "type": "command",
90
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cjs\" post-session-reflect"
91
+ }
92
+ ]
93
+ }
94
+ ]
95
+ }
96
+ }
@@ -13,6 +13,9 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const { stateFile } = require('../lib/context-key.cjs');
16
+ const { captureConsole } = require('../lib/hook-output.cjs');
17
+
18
+ captureConsole('PreToolUse');
16
19
 
17
20
  // The counter file is keyed by Claude Code session_id (parsed from stdin in the
18
21
  // handler below) so it resets per session and never bleeds across sessions or
@@ -53,7 +56,9 @@ process.stdin.on('end', () => {
53
56
  // Ensure fields exist (upgrade from old format)
54
57
  if (!state.toolCounts) state.toolCounts = {};
55
58
  if (!state.sessionStart) state.sessionStart = new Date().toISOString();
56
- if (!state.sessionId) {
59
+ if (sessionId) {
60
+ state.sessionId = sessionId;
61
+ } else if (!state.sessionId) {
57
62
  const s = state.sessionStart;
58
63
  state.sessionId = `s-${s.slice(0, 10).replace(/-/g, '')}-${s.slice(11, 19).replace(/:/g, '')}`;
59
64
  }
@@ -61,7 +66,8 @@ process.stdin.on('end', () => {
61
66
  // First run or corrupted — start fresh
62
67
  const now = new Date().toISOString();
63
68
  state.sessionStart = now;
64
- state.sessionId = `s-${now.slice(0, 10).replace(/-/g, '')}-${now.slice(11, 19).replace(/:/g, '')}`;
69
+ state.sessionId = sessionId
70
+ || `s-${now.slice(0, 10).replace(/-/g, '')}-${now.slice(11, 19).replace(/:/g, '')}`;
65
71
  }
66
72
 
67
73
  // Increment total (drives context-pressure warnings) and per-tool distribution.
@@ -88,9 +94,6 @@ process.stdin.on('end', () => {
88
94
  state.lastWarning = count;
89
95
  }
90
96
 
91
- // Pass through stdin to stdout (required for PreToolUse hooks)
92
- if (stdinData) process.stdout.write(stdinData);
93
-
94
97
  // Persist
95
98
  try {
96
99
  fs.writeFileSync(counterFile, JSON.stringify(state));
@@ -9,6 +9,9 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { captureConsole } = require('../lib/hook-output.cjs');
13
+
14
+ captureConsole('UserPromptSubmit');
12
15
 
13
16
  // Read user prompt from Claude Code hook stdin
14
17
  let input = '';
@@ -79,16 +79,24 @@ function emit(hookEventName, text) {
79
79
  * `console.error` is untouched — stderr is not parsed as the output contract.
80
80
  *
81
81
  * @param {string} hookEventName
82
+ * @param {{captureError?: boolean}} [options]
82
83
  * @returns {{buffer: ReturnType<typeof outputBuffer>, restore: () => void}}
83
84
  */
84
- function captureConsole(hookEventName) {
85
+ function captureConsole(hookEventName, options = {}) {
85
86
  const buffer = outputBuffer(hookEventName);
86
- const original = console.log;
87
+ const originalLog = console.log;
88
+ const originalError = console.error;
87
89
  console.log = (...args) => {
88
90
  buffer.line(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
89
91
  };
92
+ if (options.captureError) {
93
+ console.error = (...args) => {
94
+ buffer.line(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
95
+ };
96
+ }
90
97
  const restore = () => {
91
- console.log = original;
98
+ console.log = originalLog;
99
+ console.error = originalError;
92
100
  };
93
101
  // Fires for a natural end AND for process.exit(), which is how the BLOCK
94
102
  // paths leave — those messages must survive too.
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const os = require('os');
7
+ const { stateFile } = require('../lib/context-key.cjs');
8
8
  const { captureConsole } = require('../lib/hook-output.cjs');
9
9
  // Hook stdout is a JSON contract, not free text (Codex rejects bare lines and
10
10
  // discards the output). Capture the prints below and emit one envelope on exit.
@@ -12,12 +12,17 @@ captureConsole('Stop');
12
12
 
13
13
 
14
14
  const cwd = process.cwd();
15
- const hash = Buffer.from(cwd).toString('base64url').slice(0, 16);
15
+ let sessionId;
16
+ try {
17
+ sessionId = JSON.parse(fs.readFileSync(0, 'utf-8')).session_id;
18
+ } catch {
19
+ // Older runtimes may not provide hook input.
20
+ }
16
21
 
17
22
  // === H3: Flush Session Metrics ===
18
23
 
19
- const metricsJsonl = path.join(os.tmpdir(), `rune-metrics-${hash}.jsonl`);
20
- const counterFile = path.join(os.tmpdir(), `rune-context-watch-${hash}.json`);
24
+ const metricsJsonl = stateFile('rune-metrics', sessionId, cwd).replace(/\.json$/, '.jsonl');
25
+ const counterFile = stateFile('rune-context-watch', sessionId, cwd);
21
26
  const runeMetricsDir = path.join(cwd, '.rune', 'metrics');
22
27
 
23
28
  // Resolve skill names → expected model from agent frontmatter
@@ -101,33 +106,69 @@ function flushMetrics() {
101
106
  const modelsUsed = resolveSkillModels(skillCounts);
102
107
 
103
108
  // Use session ID from context-watch (shared) or generate new
104
- const sessionId = watchState.sessionId
109
+ const metricSessionId = sessionId
110
+ || watchState.sessionId
105
111
  || `s-${now.slice(0, 10).replace(/-/g, '')}-${now.slice(11, 19).replace(/:/g, '')}`;
106
112
 
107
- // 1. Append to sessions.jsonl
113
+ // 1. Upsert sessions.jsonl. Stop can fire more than once in one session;
114
+ // appending every time inflated session counts and duration metrics.
115
+ let previousSession = null;
116
+ const sessionsFile = path.join(runeMetricsDir, 'sessions.jsonl');
117
+ let sessionEntries = [];
118
+ if (fs.existsSync(sessionsFile)) {
119
+ sessionEntries = fs
120
+ .readFileSync(sessionsFile, 'utf-8')
121
+ .trim()
122
+ .split('\n')
123
+ .filter(Boolean)
124
+ .flatMap((line) => {
125
+ try {
126
+ return [JSON.parse(line)];
127
+ } catch {
128
+ return [];
129
+ }
130
+ });
131
+ previousSession = [...sessionEntries].reverse().find((entry) => entry.id === metricSessionId) || null;
132
+ }
133
+
134
+ const mergedSkillCounts = { ...(previousSession?.skill_counts || {}) };
135
+ for (const [skill, count] of Object.entries(skillCounts)) {
136
+ mergedSkillCounts[skill] = (mergedSkillCounts[skill] || 0) + count;
137
+ }
138
+ const mergedSkillDurations = { ...(previousSession?.skill_durations || {}) };
139
+ for (const [skill, duration] of Object.entries(skillDurations)) {
140
+ mergedSkillDurations[skill] = (mergedSkillDurations[skill] || 0) + duration;
141
+ }
142
+ const mergedModels = { ...(previousSession?.models_used || {}) };
143
+ for (const [model, count] of Object.entries(modelsUsed)) {
144
+ mergedModels[model] = (mergedModels[model] || 0) + count;
145
+ }
146
+ const mergedSkills = Object.keys(mergedSkillCounts);
147
+ const mergedPrimarySkill = Object.entries(mergedSkillCounts)
148
+ .sort((a, b) => b[1] - a[1])[0]?.[0]
149
+ || previousSession?.primary_skill
150
+ || primarySkill;
151
+
108
152
  const sessionEntry = {
109
- id: sessionId,
153
+ id: metricSessionId,
110
154
  date: now.slice(0, 10),
111
155
  duration_min: durationMin,
112
156
  tool_calls: watchState.count,
113
157
  tool_distribution: watchState.toolCounts,
114
- skill_invocations: skillEvents.length,
115
- skills_used: Object.keys(skillCounts),
116
- primary_skill: primarySkill,
117
- models_used: modelsUsed,
118
- skill_durations: Object.keys(skillDurations).length > 0 ? skillDurations : undefined
158
+ skill_invocations: Object.values(mergedSkillCounts).reduce((sum, count) => sum + count, 0),
159
+ skills_used: mergedSkills,
160
+ primary_skill: mergedPrimarySkill,
161
+ models_used: mergedModels,
162
+ skill_counts: Object.keys(mergedSkillCounts).length > 0 ? mergedSkillCounts : undefined,
163
+ skill_durations: Object.keys(mergedSkillDurations).length > 0 ? mergedSkillDurations : undefined
119
164
  };
120
165
 
121
- const sessionsFile = path.join(runeMetricsDir, 'sessions.jsonl');
122
- fs.appendFileSync(sessionsFile, JSON.stringify(sessionEntry) + '\n');
123
-
124
- // Cap at 100 sessions (rotate oldest)
125
- try {
126
- const allLines = fs.readFileSync(sessionsFile, 'utf-8').trim().split('\n').filter(Boolean);
127
- if (allLines.length > 100) {
128
- fs.writeFileSync(sessionsFile, allLines.slice(-100).join('\n') + '\n');
129
- }
130
- } catch { /* cap is best-effort */ }
166
+ sessionEntries = sessionEntries.filter((entry) => entry.id !== metricSessionId);
167
+ sessionEntries.push(sessionEntry);
168
+ fs.writeFileSync(
169
+ sessionsFile,
170
+ `${sessionEntries.slice(-100).map((entry) => JSON.stringify(entry)).join('\n')}\n`,
171
+ );
131
172
 
132
173
  // 2. Merge into skills.json (running totals)
133
174
  const skillsFile = path.join(runeMetricsDir, 'skills.json');
@@ -153,7 +194,7 @@ function flushMetrics() {
153
194
  if (skillChain.length > 0) {
154
195
  const chainsFile = path.join(runeMetricsDir, 'chains.jsonl');
155
196
  const chainEntry = {
156
- session: sessionId,
197
+ session: metricSessionId,
157
198
  chain: skillChain,
158
199
  depth: skillChain.length
159
200
  };
@@ -170,7 +211,7 @@ function flushMetrics() {
170
211
  .map(([s, c]) => `${s}(${c})`)
171
212
  .join(', ');
172
213
 
173
- console.log(`\nšŸ“Š [Rune metrics] Session ${sessionId} — ${durationMin}min, ${watchState.count} tool calls, ${skillEvents.length} skill invocations`);
214
+ console.log(`\nšŸ“Š [Rune metrics] Session ${metricSessionId} — ${durationMin}min, ${watchState.count} tool calls, ${skillEvents.length} new skill invocations`);
174
215
  if (skillList) console.log(` Skills: ${skillList}`);
175
216
  console.log(` Saved to .rune/metrics/\n`);
176
217
  }
@@ -7,20 +7,26 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const os = require('os');
10
+ const { stateFile } = require('../lib/context-key.cjs');
11
11
 
12
12
  const { captureConsole } = require('../lib/hook-output.cjs');
13
13
  // Hook stdout is a JSON contract, not free text (Codex rejects bare lines and
14
14
  // discards the output). Capture the prints below and emit one envelope on exit.
15
- captureConsole('PreCompact');
15
+ captureConsole('PreCompact', { captureError: true });
16
16
 
17
17
 
18
18
  const cwd = process.cwd();
19
19
  const runeDir = path.join(cwd, '.rune');
20
20
 
21
+ let sessionId;
22
+ try {
23
+ sessionId = JSON.parse(fs.readFileSync(0, 'utf-8')).session_id;
24
+ } catch {
25
+ // Older runtimes may not provide hook input.
26
+ }
27
+
21
28
  // Read context-watch state (tool counts, session timing)
22
- const hash = Buffer.from(cwd).toString('base64url').slice(0, 16);
23
- const counterFile = path.join(os.tmpdir(), `rune-context-watch-${hash}.json`);
29
+ const counterFile = stateFile('rune-context-watch', sessionId, cwd);
24
30
 
25
31
  let watchState = null;
26
32
  try {
@@ -66,14 +66,14 @@ function appendGateOutcome(gate, outcome, detail) {
66
66
  *
67
67
  * @param {string} toolName
68
68
  * @param {object} toolInput
69
- * @returns {string} target path, or '' when this is not a patch we understand
69
+ * @returns {string[]} target paths
70
70
  */
71
- function patchTargetPath(toolName, toolInput) {
72
- if (toolName !== 'apply_patch') return '';
73
- const patch = typeof toolInput === 'string' ? toolInput : toolInput.input || toolInput.patch || '';
74
- if (typeof patch !== 'string') return '';
75
- const match = patch.match(/^\*\*\* (?:Update|Add|Delete) File:\s*(.+)$/m);
76
- return match ? match[1].trim() : '';
71
+ function patchTargetPaths(toolName, toolInput) {
72
+ if (toolName !== 'apply_patch') return [];
73
+ const patch =
74
+ typeof toolInput === 'string' ? toolInput : toolInput.command || toolInput.input || toolInput.patch || '';
75
+ if (typeof patch !== 'string') return [];
76
+ return Array.from(patch.matchAll(/^\*\*\* (?:Update|Add|Delete) File:\s*(.+)$/gm), (match) => match[1].trim());
77
77
  }
78
78
 
79
79
  // Read tool_input from Claude Code hook stdin
@@ -92,11 +92,9 @@ process.stdin.on('end', () => {
92
92
  process.exit(0);
93
93
  }
94
94
 
95
- const filePath = toolInput.file_path || toolInput.path || patchTargetPath(toolName, toolInput);
96
- if (!filePath) process.exit(0);
97
-
98
- const basename = path.basename(filePath);
99
- const normalized = filePath.replace(/\\/g, '/');
95
+ const directPath = toolInput.file_path || toolInput.path;
96
+ const filePaths = directPath ? [directPath] : patchTargetPaths(toolName, toolInput);
97
+ if (filePaths.length === 0) process.exit(0);
100
98
 
101
99
  // Load project-specific privacy config
102
100
  const privacyConfig = loadPrivacyConfig();
@@ -147,33 +145,37 @@ process.stdin.on('end', () => {
147
145
  const activeSkill = process.env.RUNE_ACTIVE_SKILL || '';
148
146
  const isElevated = elevatedSkills.has(activeSkill);
149
147
 
150
- const isSafe = safeExceptions.some((p) => p.test(basename) || p.test(normalized));
151
- if (isSafe) process.exit(0);
152
-
153
- const isBlocked = blockPatterns.some((p) => p.test(basename) || p.test(normalized));
154
- if (isBlocked) {
155
- // Append block outcome BEFORE printing/exiting — fail-safe, never affects exit code.
156
- // Only "blocked" is captured here; "passed" and "bypassed" are not observable
157
- // at this layer and intentionally remain uncaptured (see GAP-1 in governance-collector.js).
158
- appendGateOutcome('privacy-mesh', 'blocked', `file matched BLOCK-tier pattern: ${basename}`);
159
-
160
- console.log(`\n🚫 [Rune privacy-mesh] BLOCKED: ${filePath}`);
161
- console.log(' This file matches a BLOCK-tier pattern (private keys, certificates).');
162
- console.log(' Override: add path to .rune/privacy.json "allow" list if intentional.\n');
163
- process.exit(2); // Exit code 2 = BLOCK
164
- }
148
+ for (const filePath of filePaths) {
149
+ const basename = path.basename(filePath);
150
+ const normalized = filePath.replace(/\\/g, '/');
151
+ const isSafe = safeExceptions.some((p) => p.test(basename) || p.test(normalized));
152
+ if (isSafe) continue;
153
+
154
+ const isBlocked = blockPatterns.some((p) => p.test(basename) || p.test(normalized));
155
+ if (isBlocked) {
156
+ // Append block outcome BEFORE printing/exiting — fail-safe, never affects exit code.
157
+ // Only "blocked" is captured here; "passed" and "bypassed" are not observable
158
+ // at this layer and intentionally remain uncaptured (see GAP-1 in governance-collector.js).
159
+ appendGateOutcome('privacy-mesh', 'blocked', `file matched BLOCK-tier pattern: ${basename}`);
160
+
161
+ console.log(`\n🚫 [Rune privacy-mesh] BLOCKED: ${filePath}`);
162
+ console.log(' This file matches a BLOCK-tier pattern (private keys, certificates).');
163
+ console.log(' Override: add path to .rune/privacy.json "allow" list if intentional.\n');
164
+ process.exit(2); // Exit code 2 = BLOCK
165
+ }
165
166
 
166
- const isWarned = warnPatterns.some((p) => p.test(basename) || p.test(normalized));
167
- if (isWarned && !isElevated) {
168
- // Content-aware check: scan first 4KB for secret patterns
169
- const contentWarning = scanContentForSecrets(filePath);
167
+ const isWarned = warnPatterns.some((p) => p.test(basename) || p.test(normalized));
168
+ if (isWarned && !isElevated) {
169
+ // Content-aware check: scan first 4KB for secret patterns
170
+ const contentWarning = scanContentForSecrets(filePath);
170
171
 
171
- console.log(`\n⚠ [Rune privacy-mesh] Sensitive file: ${filePath}`);
172
- if (contentWarning) {
173
- console.log(` Content scan: ${contentWarning}`);
172
+ console.log(`\n⚠ [Rune privacy-mesh] Sensitive file: ${filePath}`);
173
+ if (contentWarning) {
174
+ console.log(` Content scan: ${contentWarning}`);
175
+ }
176
+ console.log(' This file may contain secrets. Confirm access is intentional.');
177
+ console.log(' Elevated skills (sentinel, review, audit) bypass this warning.\n');
174
178
  }
175
- console.log(' This file may contain secrets. Confirm access is intentional.');
176
- console.log(' Elevated skills (sentinel, review, audit) bypass this warning.\n');
177
179
  }
178
180
 
179
181
  process.exit(0);
package/hooks/run-hook CHANGED
@@ -8,7 +8,7 @@ if (!hookName) {
8
8
  process.exit(1);
9
9
  }
10
10
 
11
- const hookPath = path.join(__dirname, hookName, 'index.js');
11
+ const hookPath = path.join(__dirname, hookName, 'index.cjs');
12
12
  try {
13
13
  require(hookPath);
14
14
  } catch (e) {
@@ -1 +1 @@
1
- @node "%~dp0run-hook.js" %*
1
+ @node "%~dp0run-hook.cjs" %*