@rulemetric/hooks 0.18.1 → 0.19.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/scoped-live.mjs +18 -6
- package/scripts/scoped-start.mjs +9 -0
package/package.json
CHANGED
package/scripts/scoped-live.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { resolve, join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
|
-
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
7
|
|
|
8
8
|
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
9
|
export function readArtifact(root, path, maxBytes = 200_000) {
|
|
@@ -65,6 +65,18 @@ export function observeEnvironment(root) {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Worktree-specific paths are identity, not instruction content. Retain every
|
|
69
|
+
* actual ancestor/global hash and normalize only this checkout's root keys. */
|
|
70
|
+
export function observeScopedEnvironment(root, protocol) {
|
|
71
|
+
const environment = observeEnvironment(root);
|
|
72
|
+
if (!protocol.execution) return environment;
|
|
73
|
+
const prefix = resolve(root) + '/';
|
|
74
|
+
for (const name of ['scoped-live.mjs', 'scoped-start.mjs']) environment.contextHashes['@collector/' + name] = hash(readFileSync(join(dirname(fileURLToPath(import.meta.url)), name)));
|
|
75
|
+
return { ...environment, contextHashes: Object.fromEntries(Object.entries(environment.contextHashes)
|
|
76
|
+
.map(([path, value]) => [path.startsWith(prefix) ? '@project/' + path.slice(prefix.length) : path, value])
|
|
77
|
+
.sort(([a], [b]) => a.localeCompare(b))) };
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
// .claude.json mixes session counters/history with configuration. Retain only
|
|
69
81
|
// configuration here; settings files are separately hashed in full above.
|
|
70
82
|
export function stableClaudeConfig(config, root) {
|
|
@@ -94,7 +106,7 @@ export function transcriptEvidence(path, sessionId) {
|
|
|
94
106
|
export function transcriptModels(path, sessionId) { return transcriptEvidence(path, sessionId).observedModels; }
|
|
95
107
|
|
|
96
108
|
export function collectScopedOutcome(protocol, taskId, sessionId, root, transcriptPath) {
|
|
97
|
-
const environment =
|
|
109
|
+
const environment = observeScopedEnvironment(root, protocol);
|
|
98
110
|
const task = protocol.verification.tasks.find(task => task.taskId === taskId);
|
|
99
111
|
if (!task) throw new Error('Unknown task');
|
|
100
112
|
const artifacts = [...new Set(task.checks.map(check => check.path))].map(path => {
|
|
@@ -114,7 +126,7 @@ export async function runScopedHook(mode, sessionId, root, model, transcriptPath
|
|
|
114
126
|
const request = async (path, body) => {
|
|
115
127
|
const response = await fetch(`${api}/api${path}`, { method: body ? 'POST' : 'GET',
|
|
116
128
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
117
|
-
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000) });
|
|
129
|
+
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(process.env.RULEMETRIC_SCOPED_ISOLATED === '1' ? 30000 : 5000) });
|
|
118
130
|
if (!response.ok) {
|
|
119
131
|
const detail = await response.text();
|
|
120
132
|
throw new Error(`Scoped ${mode} refused (${response.status}): ${detail.slice(0, 1000)}`);
|
|
@@ -123,9 +135,9 @@ export async function runScopedHook(mode, sessionId, root, model, transcriptPath
|
|
|
123
135
|
};
|
|
124
136
|
const { confirmation } = await request(`/scoped-live-confirmations/${id}/contract`);
|
|
125
137
|
const protocol = confirmation.protocol;
|
|
126
|
-
const environment = protocol.verification ?
|
|
138
|
+
const environment = protocol.verification ? observeScopedEnvironment(root, protocol) : undefined;
|
|
127
139
|
if (mode === 'start') {
|
|
128
|
-
if (!model && protocol.research?.enrollment === 'prospective-launches') {
|
|
140
|
+
if (!model && (protocol.execution || protocol.research?.enrollment === 'prospective-launches')) {
|
|
129
141
|
// SessionStart's model is optional. A requested model is not an observed
|
|
130
142
|
// outcome: retain the launch pin and verify actual transcript models later.
|
|
131
143
|
const requested = process.env.RULEMETRIC_SCOPED_REQUESTED_MODEL;
|
|
@@ -142,7 +154,7 @@ export async function runScopedHook(mode, sessionId, root, model, transcriptPath
|
|
|
142
154
|
process.stderr.write('[rulemetric] Enrollment uses the requested launch model; actual model remains subject to transcript verification.\n');
|
|
143
155
|
}
|
|
144
156
|
const configurationHash = environment ? hash(JSON.stringify({ harnessVersion: environment.harnessVersion, repositoryRevision: protocol.research?.enrollment === 'prospective-launches' ? protocol.verification.environment.repositoryRevision : environment.repositoryRevision, contextHashes: environment.contextHashes })) : process.env.RULEMETRIC_SCOPED_CONFIGURATION_HASH;
|
|
145
|
-
const rendered = await request(`/scoped-live-confirmations/${id}/hook-render`, { taskId, externalSessionId: sessionId, model, projectPath: root, configurationHash, environment, collectorVersion: 2 });
|
|
157
|
+
const rendered = await request(`/scoped-live-confirmations/${id}/hook-render`, { taskId, externalSessionId: sessionId, model, projectPath: root, configurationHash, environment, collectorVersion: protocol.execution ? 3 : 2 });
|
|
146
158
|
const content = Buffer.from(rendered.contentBase64, 'base64');
|
|
147
159
|
if (hash(content) !== rendered.deliveryHash) throw new Error('Scoped delivery hash mismatch');
|
|
148
160
|
// No unhashed headings or wrappers are added to the treatment.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Dedicated hook for isolated launched work. The worker owns final transcript
|
|
2
|
+
// capture and independent verification after exit, even if SessionEnd is cancelled.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { runScopedHook } from './scoped-live.mjs';
|
|
5
|
+
const input = readFileSync(0, 'utf8');
|
|
6
|
+
if (Buffer.byteLength(input) > 100000) throw new Error('Oversized scoped hook input');
|
|
7
|
+
const event = JSON.parse(input);
|
|
8
|
+
process.env.RULEMETRIC_SCOPED_ISOLATED = '1';
|
|
9
|
+
await runScopedHook('start', event.session_id, event.cwd, event.model, event.transcript_path);
|