@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/hooks",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
+ }
@@ -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
@@ -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
@@ -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