@rulemetric/hooks 0.15.0 → 0.15.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/package.json +1 -1
- package/scripts/_normalize.sh +7 -1
- package/scripts/scoped-live.mjs +111 -0
- package/scripts/session-end.sh +8 -0
- package/scripts/session-start.sh +20 -0
- package/scripts/user-prompt.sh +1 -0
package/package.json
CHANGED
package/scripts/_normalize.sh
CHANGED
|
@@ -32,6 +32,7 @@ fi
|
|
|
32
32
|
# HOOK_TOOL_INPUT - Tool input as JSON
|
|
33
33
|
# HOOK_TOOL_OUTPUT - Tool output as JSON
|
|
34
34
|
# HOOK_TRANSCRIPT_PATH - Path to transcript file (empty if unavailable)
|
|
35
|
+
# HOOK_MODEL - Resolved model at SessionStart when the harness supplies it
|
|
35
36
|
|
|
36
37
|
# Portable short content hash for synthetic session IDs. `shasum` is a perl
|
|
37
38
|
# script — absent on minimal Linux hosts (containers, slim distros), where its
|
|
@@ -91,6 +92,7 @@ _rulemetric_normalize() {
|
|
|
91
92
|
HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.toolCall.args // .tool_input // {}' 2>/dev/null || echo '{}')
|
|
92
93
|
HOOK_TOOL_OUTPUT=$(echo "$input" | jq -c '.toolCall.result // .tool_response // {}' 2>/dev/null || echo '{}')
|
|
93
94
|
HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcriptPath // empty' 2>/dev/null)
|
|
95
|
+
HOOK_MODEL=$(echo "$input" | jq -r '.model // empty' 2>/dev/null)
|
|
94
96
|
|
|
95
97
|
elif [ "$has_toolName" = "true" ] && [ "$has_sessionId" != "true" ]; then
|
|
96
98
|
# Copilot CLI: no session ID, uses toolName/toolArgs/toolResult
|
|
@@ -129,6 +131,7 @@ _rulemetric_normalize() {
|
|
|
129
131
|
fi
|
|
130
132
|
|
|
131
133
|
HOOK_TRANSCRIPT_PATH=""
|
|
134
|
+
HOOK_MODEL=$(echo "$input" | jq -r '.model // empty' 2>/dev/null)
|
|
132
135
|
|
|
133
136
|
elif [ "$has_sessionId" = "true" ]; then
|
|
134
137
|
# VS Code Copilot (agent mode): sessionId (camelCase), tool_name, tool_input, tool_response
|
|
@@ -140,6 +143,7 @@ _rulemetric_normalize() {
|
|
|
140
143
|
HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.tool_input // {}')
|
|
141
144
|
HOOK_TOOL_OUTPUT=$(echo "$input" | jq -c '.tool_response // {}')
|
|
142
145
|
HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // empty')
|
|
146
|
+
HOOK_MODEL=$(echo "$input" | jq -r '.model // empty' 2>/dev/null)
|
|
143
147
|
|
|
144
148
|
elif [ "$has_conversation_id" = "true" ] && [ "$has_hook_event_name" = "true" ]; then
|
|
145
149
|
# Cursor: conversation_id + hook_event_name
|
|
@@ -157,6 +161,7 @@ _rulemetric_normalize() {
|
|
|
157
161
|
HOOK_CWD=$(echo "$input" | jq -r '.workspace_roots[0] // .cwd // empty')
|
|
158
162
|
HOOK_PROMPT=$(echo "$input" | jq -r '.prompt // empty')
|
|
159
163
|
HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // empty')
|
|
164
|
+
HOOK_MODEL=$(echo "$input" | jq -r '.model // empty' 2>/dev/null)
|
|
160
165
|
|
|
161
166
|
# Map hook_event_name to tool fields
|
|
162
167
|
local event_name
|
|
@@ -201,6 +206,7 @@ _rulemetric_normalize() {
|
|
|
201
206
|
HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.tool_input // {}')
|
|
202
207
|
HOOK_TOOL_OUTPUT=$(echo "$input" | jq -c '.tool_response // {}')
|
|
203
208
|
HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // empty')
|
|
209
|
+
HOOK_MODEL=$(echo "$input" | jq -r '.model // empty' 2>/dev/null)
|
|
204
210
|
fi
|
|
205
211
|
|
|
206
212
|
# Truncate large payloads (Read tool output can be massive)
|
|
@@ -224,7 +230,7 @@ _rulemetric_normalize() {
|
|
|
224
230
|
fi
|
|
225
231
|
fi
|
|
226
232
|
|
|
227
|
-
export HOOK_TOOL HOOK_SESSION_ID HOOK_CWD HOOK_PROMPT
|
|
233
|
+
export HOOK_TOOL HOOK_SESSION_ID HOOK_CWD HOOK_PROMPT HOOK_MODEL
|
|
228
234
|
export HOOK_TOOL_NAME HOOK_TOOL_INPUT HOOK_TOOL_OUTPUT HOOK_TRANSCRIPT_PATH
|
|
229
235
|
}
|
|
230
236
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve, join, dirname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
export function readArtifact(root, path) {
|
|
10
|
+
if (!path || path.startsWith('/') || path.includes('\\') || path.split('/').some(s => !s || s === '.' || s === '..')) throw new Error('Unsafe artifact path');
|
|
11
|
+
let current = resolve(root);
|
|
12
|
+
for (const segment of path.split('/')) {
|
|
13
|
+
current = join(current, segment);
|
|
14
|
+
try { if (lstatSync(current).isSymbolicLink()) throw new Error('Artifact symlink refused'); }
|
|
15
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
16
|
+
}
|
|
17
|
+
const stat = lstatSync(current);
|
|
18
|
+
if (!stat.isFile() || stat.size > 200_000) throw new Error('Artifact is not a bounded regular file');
|
|
19
|
+
return readFileSync(current, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function observeEnvironment(root) {
|
|
23
|
+
const contextHashes = {};
|
|
24
|
+
const context = path => {
|
|
25
|
+
try { const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2_000_000) throw new Error('Unsupported context file'); contextHashes[path] = hash(readFileSync(path)); }
|
|
26
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; contextHashes[path] = null; }
|
|
27
|
+
};
|
|
28
|
+
for (let current = resolve(root); ; current = dirname(current)) {
|
|
29
|
+
for (const file of ['CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', '.mcp.json', '.claude/settings.json', '.claude/settings.local.json']) context(join(current, file));
|
|
30
|
+
if (dirname(current) === current) break;
|
|
31
|
+
}
|
|
32
|
+
for (const file of ['settings.json', 'CLAUDE.md']) context(join(homedir(), '.claude', file));
|
|
33
|
+
const userConfig = join(homedir(), '.claude.json');
|
|
34
|
+
context(userConfig);
|
|
35
|
+
if (contextHashes[userConfig]) contextHashes[userConfig] = hash(JSON.stringify(stableClaudeConfig(JSON.parse(readFileSync(userConfig, 'utf8')), resolve(root))));
|
|
36
|
+
return {
|
|
37
|
+
harnessVersion: execFileSync('claude', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim(),
|
|
38
|
+
repositoryRevision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 }).trim(),
|
|
39
|
+
contextHashes: Object.fromEntries(Object.entries(contextHashes).sort(([a], [b]) => a.localeCompare(b))),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// .claude.json mixes session counters/history with configuration. Retain only
|
|
44
|
+
// configuration here; settings files are separately hashed in full above.
|
|
45
|
+
export function stableClaudeConfig(config, root) {
|
|
46
|
+
const keys = ['mcpServers', 'mcpContextUris', 'allowedTools', 'enabledMcpjsonServers', 'disabledMcpjsonServers', 'disabledMcpServers', 'hasClaudeMdExternalIncludesApproved', 'claudeInChromeDefaultEnabled', 'cachedDynamicConfigs', 'cachedGrowthBookFeatures', 'cachedStatsigGates', 'cachedExperimentFeatures', 'cachedExperimentData'];
|
|
47
|
+
const pick = object => Object.fromEntries(keys.map(key => [key, object?.[key] ?? null]));
|
|
48
|
+
return { global: pick(config), project: pick(config.projects?.[root]) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function transcriptEvidence(path, sessionId) {
|
|
52
|
+
const stat = lstatSync(path);
|
|
53
|
+
if (!stat.isFile() || stat.size > 20_000_000) throw new Error('Transcript cannot be inspected');
|
|
54
|
+
const models = new Set();
|
|
55
|
+
const tools = new Set();
|
|
56
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
57
|
+
if (!line.trim()) continue;
|
|
58
|
+
const row = JSON.parse(line);
|
|
59
|
+
if (row.sessionId && row.sessionId !== sessionId) throw new Error('Transcript contains another session');
|
|
60
|
+
if (row.type === 'assistant' && row.message?.model && row.message.model !== '<synthetic>') models.add(row.message.model);
|
|
61
|
+
if (row.type === 'assistant' && Array.isArray(row.message?.content)) {
|
|
62
|
+
for (const block of row.message.content) if (block.type === 'tool_use' && typeof block.name === 'string') tools.add(block.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!models.size) throw new Error('No observed actor model');
|
|
66
|
+
return { observedModels: [...models], observedTools: [...tools] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function transcriptModels(path, sessionId) { return transcriptEvidence(path, sessionId).observedModels; }
|
|
70
|
+
|
|
71
|
+
export async function runScopedHook(mode, sessionId, root, model, transcriptPath) {
|
|
72
|
+
const id = process.env.RULEMETRIC_SCOPED_CONFIRMATION_ID;
|
|
73
|
+
const taskId = process.env.RULEMETRIC_SCOPED_TASK_ID;
|
|
74
|
+
if (!id || !taskId) return;
|
|
75
|
+
const token = process.env.RULEMETRIC_API_KEY || process.env.RULEMETRIC_ACCESS_TOKEN;
|
|
76
|
+
const api = process.env.RULEMETRIC_API_URL;
|
|
77
|
+
if (!api || !token) return;
|
|
78
|
+
const request = async (path, body) => {
|
|
79
|
+
const response = await fetch(`${api}/api${path}`, { method: body ? 'POST' : 'GET',
|
|
80
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
81
|
+
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000) });
|
|
82
|
+
if (!response.ok) throw new Error(`Scoped ${mode} refused (${response.status})`);
|
|
83
|
+
return response.json();
|
|
84
|
+
};
|
|
85
|
+
const { confirmation } = await request(`/scoped-live-confirmations/${id}/contract`);
|
|
86
|
+
const protocol = confirmation.protocol;
|
|
87
|
+
const environment = protocol.verification ? observeEnvironment(root) : undefined;
|
|
88
|
+
if (mode === 'start') {
|
|
89
|
+
const configurationHash = environment ? hash(JSON.stringify(environment)) : process.env.RULEMETRIC_SCOPED_CONFIGURATION_HASH;
|
|
90
|
+
const rendered = await request(`/scoped-live-confirmations/${id}/hook-render`, { taskId, externalSessionId: sessionId, model, projectPath: root, configurationHash, environment });
|
|
91
|
+
const content = Buffer.from(rendered.contentBase64, 'base64');
|
|
92
|
+
if (hash(content) !== rendered.deliveryHash) throw new Error('Scoped delivery hash mismatch');
|
|
93
|
+
// No unhashed headings or wrappers are added to the treatment.
|
|
94
|
+
await new Promise((resolve, reject) => process.stdout.write(content, error => error ? reject(error) : resolve()));
|
|
95
|
+
await request(`/scoped-live-confirmations/${id}/assignments/${rendered.assignment.id}/delivery`, { deliveryHash: rendered.deliveryHash });
|
|
96
|
+
} else if (mode === 'end' && protocol.verification) {
|
|
97
|
+
const task = protocol.verification.tasks.find(task => task.taskId === taskId);
|
|
98
|
+
if (!task) throw new Error('Unknown task');
|
|
99
|
+
const artifacts = [...new Set(task.checks.map(check => check.path))].map(path => {
|
|
100
|
+
try { return { path, content: readArtifact(root, path) }; }
|
|
101
|
+
catch (error) { return { path, content: null, error: error.message }; }
|
|
102
|
+
});
|
|
103
|
+
await request(`/scoped-live-confirmations/${id}/verify`, { externalSessionId: sessionId, projectPath: root, environment, ...transcriptEvidence(transcriptPath, sessionId), artifacts });
|
|
104
|
+
// A non-final task legitimately returns 409; its receipt is already durable.
|
|
105
|
+
await request(`/scoped-live-confirmations/${id}/decide`, {}).catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
110
|
+
runScopedHook(...process.argv.slice(2)).catch(error => { process.stderr.write(`[rulemetric] ${error.message}\n`); process.exitCode = 1; });
|
|
111
|
+
}
|
package/scripts/session-end.sh
CHANGED
|
@@ -92,6 +92,14 @@ fi
|
|
|
92
92
|
|
|
93
93
|
_rulemetric_log "session-end" "success"
|
|
94
94
|
|
|
95
|
+
# Check frozen artifacts after final capture. Missing or cancelled receipt stays
|
|
96
|
+
# visible as missing evidence and cannot become a positive performance claim.
|
|
97
|
+
if [ -n "${RULEMETRIC_SCOPED_CONFIRMATION_ID:-}" ] && command -v node >/dev/null 2>&1; then
|
|
98
|
+
RULEMETRIC_API_URL="${RULEMETRIC_API_URL:-}" \
|
|
99
|
+
RULEMETRIC_API_KEY="${RULEMETRIC_API_KEY:-${RULEMETRIC_ACCESS_TOKEN:-}}" \
|
|
100
|
+
node "$SCRIPT_DIR/scoped-live.mjs" end "$HOOK_SESSION_ID" "$HOOK_CWD" "${HOOK_MODEL:-}" "${HOOK_TRANSCRIPT_PATH:-}" || true
|
|
101
|
+
fi
|
|
102
|
+
|
|
95
103
|
# ── Emit a synthetic instruction snapshot (hooks-first, zero-proxy linking) ──
|
|
96
104
|
# The proxy classifies the rendered system prompt into context_items that
|
|
97
105
|
# link-instructions matches on. Hooks-only sessions have no proxy, so build an
|
package/scripts/session-start.sh
CHANGED
|
@@ -100,6 +100,26 @@ if ! proxy_alive; then
|
|
|
100
100
|
fi
|
|
101
101
|
fi
|
|
102
102
|
|
|
103
|
+
# ── Scoped live-confirmation delivery (fail closed) ──
|
|
104
|
+
# A task identifier and configuration hash are intentionally supplied before
|
|
105
|
+
# launch, not inferred from the prompt/session after an instruction has already
|
|
106
|
+
# been shown. Ordinary sessions do nothing here. The API writes the assignment
|
|
107
|
+
# first; this hook prints precisely the returned bytes and only then acknowledges
|
|
108
|
+
# their hash.
|
|
109
|
+
_rulemetric_scoped_live_confirmation() {
|
|
110
|
+
[ -n "${RULEMETRIC_SCOPED_CONFIRMATION_ID:-}" ] || return 0
|
|
111
|
+
command -v node >/dev/null 2>&1 || return 0
|
|
112
|
+
RULEMETRIC_API_URL="${RULEMETRIC_API_URL:-}" \
|
|
113
|
+
RULEMETRIC_API_KEY="${RULEMETRIC_API_KEY:-${RULEMETRIC_ACCESS_TOKEN:-}}" \
|
|
114
|
+
node "$SCRIPT_DIR/scoped-live.mjs" start "$HOOK_SESSION_ID" "$HOOK_CWD" "${HOOK_MODEL:-}" "${HOOK_TRANSCRIPT_PATH:-}"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_rulemetric_scoped_live_confirmation || true
|
|
118
|
+
|
|
119
|
+
# Frozen confirmations do not receive changing recommendation/memory context.
|
|
120
|
+
# This applies even when their scoped delivery is refused.
|
|
121
|
+
if [ -n "${RULEMETRIC_SCOPED_CONFIRMATION_ID:-}" ]; then exit 0; fi
|
|
122
|
+
|
|
103
123
|
# ── Instruction suggestions (non-blocking) ──
|
|
104
124
|
# Fetch the top precomputed suggestions for this project and surface them to
|
|
105
125
|
# the user. All failures are silent — this block MUST NOT prevent session
|
package/scripts/user-prompt.sh
CHANGED
|
@@ -85,6 +85,7 @@ _rulemetric_log "user-prompt" "success"
|
|
|
85
85
|
# prompt) isn't re-surfaced or double-exposed. All failures are silent — this MUST NOT
|
|
86
86
|
# break prompt submission. Mirrors _rulemetric_inject_memories() in session-start.sh.
|
|
87
87
|
_rulemetric_inject_relevant_memories() {
|
|
88
|
+
[ -z "${RULEMETRIC_SCOPED_CONFIRMATION_ID:-}" ] || return 0
|
|
88
89
|
local api_url="${RULEMETRIC_API_URL:-}"
|
|
89
90
|
[ -n "$api_url" ] && [ -n "$AUTH_HEADER" ] || return 0
|
|
90
91
|
[ -n "$HOOK_PROMPT" ] || return 0
|