@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.
- package/.codex-plugin/plugin.json +14 -0
- package/README.md +114 -41
- package/compiler/__tests__/adapter-model-mapping.test.js +22 -24
- package/compiler/__tests__/adapters.test.js +4 -1
- package/compiler/__tests__/doctor-mesh.test.js +55 -1
- package/compiler/__tests__/governance-collector.test.js +1 -0
- package/compiler/__tests__/hooks-codex.test.js +85 -0
- package/compiler/__tests__/hooks-doctor-tier.test.js +3 -5
- package/compiler/__tests__/hooks-drift.test.js +2 -2
- package/compiler/__tests__/hooks-install.test.js +2 -2
- package/compiler/__tests__/hooks-tiers.test.js +3 -4
- package/compiler/__tests__/setup.test.js +59 -0
- package/compiler/__tests__/status.test.js +7 -1
- package/compiler/__tests__/tier-override.test.js +42 -1
- package/compiler/__tests__/transforms.test.js +15 -0
- package/compiler/__tests__/update.test.js +416 -0
- package/compiler/adapters/codex.js +88 -11
- package/compiler/adapters/hooks/codex.js +178 -0
- package/compiler/adapters/hooks/index.js +10 -0
- package/compiler/adapters/openclaw.js +2 -2
- package/compiler/bin/rune.js +24 -4
- package/compiler/commands/hooks/install.js +2 -2
- package/compiler/commands/hooks/status.js +1 -1
- package/compiler/commands/setup.js +101 -28
- package/compiler/commands/update.js +354 -0
- package/compiler/doctor.js +12 -6
- package/compiler/emitter.js +46 -3
- package/compiler/governance-collector.js +3 -2
- package/compiler/status.js +4 -7
- package/compiler/transforms/branding.js +2 -2
- package/compiler/transforms/subagents.js +3 -3
- package/hooks/codex-hooks.json +96 -0
- package/hooks/context-watch/index.cjs +8 -5
- package/hooks/intent-router/index.cjs +3 -0
- package/hooks/lib/hook-output.cjs +11 -3
- package/hooks/post-session-reflect/index.cjs +65 -24
- package/hooks/pre-compact/index.cjs +10 -4
- package/hooks/pre-tool-guard/index.cjs +38 -36
- package/hooks/run-hook +1 -1
- package/hooks/run-hook.cmd +1 -1
- package/hooks/secrets-scan/index.cjs +18 -2
- package/package.json +3 -2
- package/skills/browser-pilot/SKILL.md +1 -0
- package/skills/completion-gate/SKILL.md +5 -5
- package/skills/doc-processor/SKILL.md +2 -2
- package/skills/hallucination-guard/SKILL.md +1 -0
- package/skills/journal/SKILL.md +1 -0
- package/skills/retro/SKILL.md +2 -2
- package/skills/session-bridge/SKILL.md +1 -0
- package/skills/session-bridge/scripts/load-invariants.js +1 -1
- package/skills/video-creator/SKILL.md +1 -1
package/compiler/emitter.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
316
|
-
|
|
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.
|
package/compiler/status.js
CHANGED
|
@@ -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-
|
|
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** ā
|
|
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) ā
|
|
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
|
-
*
|
|
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 (
|
|
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 =
|
|
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));
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
20
|
-
const counterFile =
|
|
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
|
|
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.
|
|
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:
|
|
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:
|
|
115
|
-
skills_used:
|
|
116
|
-
primary_skill:
|
|
117
|
-
models_used:
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
|
69
|
+
* @returns {string[]} target paths
|
|
70
70
|
*/
|
|
71
|
-
function
|
|
72
|
-
if (toolName !== 'apply_patch') return
|
|
73
|
-
const patch =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return match
|
|
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
|
|
96
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
package/hooks/run-hook.cmd
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
@node "%~dp0run-hook.
|
|
1
|
+
@node "%~dp0run-hook.cjs" %*
|