openmerit 0.1.9 → 0.1.10
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/CHANGELOG.md +7 -0
- package/dist/core/src/index.d.ts +7 -7
- package/dist/pi/src/index.js +4 -4
- package/dist/protocol/src/index.d.ts +15 -15
- package/dist/terminal/scout-update.json +1 -0
- package/dist/terminal/src/cli.mjs +2 -1
- package/dist/terminal/src/install.mjs +49 -20
- package/dist/terminal/src/telemetry.mjs +12 -11
- package/dist/terminal/src/updates.d.ts +62 -0
- package/dist/terminal/src/updates.js +395 -0
- package/dist/terminal/src/updates.ts +312 -0
- package/docs/inference-terminal.md +2 -0
- package/docs/scout-data-policy.md +7 -5
- package/docs/scout.md +49 -10
- package/docs/security.md +5 -1
- package/docs/testing.md +1 -1
- package/package.json +12 -6
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Standalone updater copied into Claude's configuration directory. Only Node
|
|
2
|
+
// built-ins: it must survive the npx cache disappearing, without installing or
|
|
3
|
+
// executing code from the release it downloads.
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { gunzipSync } from 'node:zlib';
|
|
9
|
+
export const updateContract = 'scout-recommend-v1';
|
|
10
|
+
const registry = 'https://registry.npmjs.org/openmerit/latest';
|
|
11
|
+
const day = 24 * 60 * 60 * 1000;
|
|
12
|
+
const retry = 15 * 60 * 1000;
|
|
13
|
+
const digest = (text) => createHash('sha256').update(text).digest('hex');
|
|
14
|
+
const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
const parsed = (text) => {
|
|
16
|
+
const value = JSON.parse(text);
|
|
17
|
+
if (!isObject(value))
|
|
18
|
+
throw new Error('Expected a JSON object.');
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
async function optional(path) {
|
|
22
|
+
try {
|
|
23
|
+
return await readFile(path, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isObject(error) && error.code === 'ENOENT')
|
|
27
|
+
return null;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function object(path) { return parsed(await optional(path) ?? '{}'); }
|
|
32
|
+
async function atomic(path, value) {
|
|
33
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
35
|
+
try {
|
|
36
|
+
await writeFile(temp, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
37
|
+
await rename(temp, path);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await rm(temp, { force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function updatePaths(base, scope) {
|
|
44
|
+
return {
|
|
45
|
+
record: join(base, 'openmerit-install.json'), runner: join(base, 'openmerit-updater.mts'),
|
|
46
|
+
settings: join(base, scope === 'user' ? 'settings.json' : 'settings.local.json'),
|
|
47
|
+
state: join(base, 'openmerit-updates.json'), backup: join(base, 'openmerit-update-backup.json'),
|
|
48
|
+
skill: join(base, 'skills/openmerit-scout/SKILL.md'), rule: join(base, 'rules/openmerit-scout.md'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function updateRecord(record) {
|
|
52
|
+
const value = record.updates;
|
|
53
|
+
if (!isObject(value) || typeof value.enabled !== 'boolean' || typeof value.version !== 'string'
|
|
54
|
+
|| typeof value.runnerHash !== 'string' || !isObject(value.hook))
|
|
55
|
+
return null;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
// Serialize installs, removals and hooks. A dead process's lock is recoverable;
|
|
59
|
+
// an active login is not mistaken for a stale updater just because it is slow.
|
|
60
|
+
export async function withUpdateLock(base, work) {
|
|
61
|
+
await mkdir(base, { recursive: true, mode: 0o700 });
|
|
62
|
+
const path = join(base, 'openmerit-update.lock');
|
|
63
|
+
let handle;
|
|
64
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
handle = await open(path, 'wx', 0o600);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!isObject(error) || error.code !== 'EEXIST')
|
|
71
|
+
throw error;
|
|
72
|
+
// Only one process may reap a dead lock. Without this guard, two stale
|
|
73
|
+
// observers can unlink each other's newly acquired live lock.
|
|
74
|
+
const recoveryPath = `${path}.recovery`;
|
|
75
|
+
let recovery;
|
|
76
|
+
try {
|
|
77
|
+
recovery = await open(recoveryPath, 'wx', 0o600);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error('Another OpenMerit lock recovery is running. If it was interrupted, review openmerit-update.lock.recovery before removing it.');
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await recovery.writeFile(JSON.stringify({ pid: process.pid }));
|
|
84
|
+
const text = await optional(path);
|
|
85
|
+
if (text === null)
|
|
86
|
+
continue;
|
|
87
|
+
let alive = true;
|
|
88
|
+
try {
|
|
89
|
+
const pid = parsed(text).pid;
|
|
90
|
+
if (typeof pid === 'number' && pid > 0) {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(pid, 0);
|
|
93
|
+
}
|
|
94
|
+
catch (failure) {
|
|
95
|
+
if (isObject(failure) && failure.code === 'ESRCH')
|
|
96
|
+
alive = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
alive = Date.now() - (await stat(path)).mtimeMs < 10 * 60_000;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
alive = Date.now() - (await stat(path)).mtimeMs < 10 * 60_000;
|
|
104
|
+
}
|
|
105
|
+
if (alive)
|
|
106
|
+
throw new Error('Another OpenMerit install or update is running. Retry shortly.');
|
|
107
|
+
await rm(path, { force: true });
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await recovery.close();
|
|
111
|
+
await rm(recoveryPath, { force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!handle)
|
|
116
|
+
throw new Error('Another OpenMerit update is running.');
|
|
117
|
+
try {
|
|
118
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid }));
|
|
119
|
+
return await work();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await handle.close();
|
|
123
|
+
await rm(path, { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function groups(settings) {
|
|
127
|
+
if (settings.hooks === undefined)
|
|
128
|
+
return {};
|
|
129
|
+
if (!isObject(settings.hooks))
|
|
130
|
+
throw new Error('Claude settings.hooks is not an object; nothing changed.');
|
|
131
|
+
return settings.hooks;
|
|
132
|
+
}
|
|
133
|
+
function eventGroups(hooks, event) {
|
|
134
|
+
const value = hooks[event];
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
return [];
|
|
137
|
+
if (!Array.isArray(value))
|
|
138
|
+
throw new Error(`Claude ${event} hooks are not an array; nothing changed.`);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
function usesRunner(group, runner) {
|
|
142
|
+
return isObject(group) && Array.isArray(group.hooks) && group.hooks.some(hook => isObject(hook) && typeof hook.command === 'string' && (hook.command.includes(runner) || hook.command.includes(quote(runner))));
|
|
143
|
+
}
|
|
144
|
+
export async function inspectUpdates(base, scope, record) {
|
|
145
|
+
const paths = updatePaths(base, scope), saved = updateRecord(record);
|
|
146
|
+
const pending = updateRecord({ updates: record.pendingUpdates });
|
|
147
|
+
const owners = [saved, pending].filter(value => value !== null);
|
|
148
|
+
const runner = await optional(paths.runner), settings = await object(paths.settings), hooks = groups(settings);
|
|
149
|
+
const conflicts = [];
|
|
150
|
+
if (runner !== null && !owners.some(owner => digest(runner) === owner.runnerHash))
|
|
151
|
+
conflicts.push('OpenMerit updater was edited or is not owned; nothing changed.');
|
|
152
|
+
for (const event of ['SessionStart', 'Stop']) {
|
|
153
|
+
for (const group of eventGroups(hooks, event)) {
|
|
154
|
+
if (usesRunner(group, paths.runner) && !owners.some(owner => JSON.stringify(group) === JSON.stringify(owner.hook))) {
|
|
155
|
+
conflicts.push(`OpenMerit ${event} hook was edited or is not owned; nothing changed.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const active = runner !== null && ['SessionStart', 'Stop'].every(event => eventGroups(hooks, event).some(group => owners.some(owner => JSON.stringify(group) === JSON.stringify(owner.hook))));
|
|
160
|
+
return { paths, saved: saved ?? pending, runner, settings, hooks, conflicts, active };
|
|
161
|
+
}
|
|
162
|
+
// Claude Code uses a shell for command hooks, including Git Bash on Windows.
|
|
163
|
+
const quote = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
164
|
+
export async function configureUpdates(base, scope, record, version, enabled) {
|
|
165
|
+
const state = await inspectUpdates(base, scope, record);
|
|
166
|
+
if (state.conflicts.length)
|
|
167
|
+
throw new Error(state.conflicts.join('\n'));
|
|
168
|
+
const source = await readFile(new URL('./updates.ts', import.meta.url), 'utf8');
|
|
169
|
+
const hook = { hooks: [{ type: 'command', command: `${quote(process.execPath)} ${quote(state.paths.runner)} --base ${quote(base)} --scope ${scope} --hook`, async: true, timeout: 150 }] };
|
|
170
|
+
const hooks = { ...state.hooks };
|
|
171
|
+
for (const event of ['SessionStart', 'Stop']) {
|
|
172
|
+
const kept = eventGroups(hooks, event).filter(group => !usesRunner(group, state.paths.runner));
|
|
173
|
+
if (enabled)
|
|
174
|
+
kept.push(hook);
|
|
175
|
+
if (kept.length)
|
|
176
|
+
hooks[event] = kept;
|
|
177
|
+
else
|
|
178
|
+
delete hooks[event];
|
|
179
|
+
}
|
|
180
|
+
// Avoid clobbering settings that changed while the installer was signing in.
|
|
181
|
+
const latest = await object(state.paths.settings);
|
|
182
|
+
if (JSON.stringify(latest) !== JSON.stringify(state.settings))
|
|
183
|
+
throw new Error('Claude settings changed during setup. Retry.');
|
|
184
|
+
const next = { enabled, version, runnerHash: digest(source), hook };
|
|
185
|
+
await atomic(state.paths.record, { ...await object(state.paths.record), pendingUpdates: next });
|
|
186
|
+
await atomic(state.paths.runner, source);
|
|
187
|
+
await atomic(state.paths.settings, { ...state.settings, hooks });
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
export async function removeUpdates(base, scope, record) {
|
|
191
|
+
const state = await inspectUpdates(base, scope, record);
|
|
192
|
+
const hooks = { ...state.hooks };
|
|
193
|
+
for (const event of ['SessionStart', 'Stop']) {
|
|
194
|
+
const kept = eventGroups(hooks, event).filter(group => !state.saved || JSON.stringify(group) !== JSON.stringify(state.saved.hook));
|
|
195
|
+
if (kept.length)
|
|
196
|
+
hooks[event] = kept;
|
|
197
|
+
else
|
|
198
|
+
delete hooks[event];
|
|
199
|
+
}
|
|
200
|
+
if (state.saved)
|
|
201
|
+
await atomic(state.paths.settings, { ...state.settings, hooks });
|
|
202
|
+
const stillReferenced = ['SessionStart', 'Stop'].some(event => eventGroups(hooks, event).some(group => usesRunner(group, state.paths.runner)));
|
|
203
|
+
if (!stillReferenced && state.runner !== null && state.saved && digest(state.runner) === state.saved.runnerHash)
|
|
204
|
+
await rm(state.paths.runner, { force: true });
|
|
205
|
+
await rm(state.paths.state, { force: true });
|
|
206
|
+
}
|
|
207
|
+
// Read one fixed regular file in memory, never extract archive paths onto disk.
|
|
208
|
+
function instructionBundle(bytes) {
|
|
209
|
+
const tar = gunzipSync(bytes, { maxOutputLength: 32 * 1024 * 1024 });
|
|
210
|
+
let found = null;
|
|
211
|
+
for (let offset = 0; offset + 512 <= tar.length;) {
|
|
212
|
+
const header = tar.subarray(offset, offset + 512);
|
|
213
|
+
if (header.every(byte => byte === 0))
|
|
214
|
+
break;
|
|
215
|
+
const name = header.subarray(0, 100).toString().replace(/\0.*$/s, '');
|
|
216
|
+
const rawSize = header.subarray(124, 136).toString().replace(/\0.*$/s, '').trim();
|
|
217
|
+
if (!/^[0-7]+$/.test(rawSize))
|
|
218
|
+
throw new Error('Invalid package archive.');
|
|
219
|
+
const size = Number.parseInt(rawSize, 8), start = offset + 512;
|
|
220
|
+
if (start + size > tar.length)
|
|
221
|
+
throw new Error('Truncated package archive.');
|
|
222
|
+
if (name === 'package/dist/terminal/scout-update.json') {
|
|
223
|
+
if (found || size > 256 * 1024 || ![0, 48].includes(header[156] ?? -1))
|
|
224
|
+
throw new Error('Invalid instruction bundle.');
|
|
225
|
+
found = parsed(tar.subarray(start, start + size).toString('utf8'));
|
|
226
|
+
}
|
|
227
|
+
offset = start + Math.ceil(size / 512) * 512;
|
|
228
|
+
}
|
|
229
|
+
return found;
|
|
230
|
+
}
|
|
231
|
+
const stableVersion = (version) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version);
|
|
232
|
+
function newer(next, installed) {
|
|
233
|
+
if (!stableVersion(next) || !stableVersion(installed))
|
|
234
|
+
return false;
|
|
235
|
+
const a = next.split('.').map(Number), b = installed.split('.').map(Number);
|
|
236
|
+
for (let i = 0; i < 3; i++) {
|
|
237
|
+
if (a[i] !== b[i])
|
|
238
|
+
return a[i] > b[i];
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
async function boundedFetch(url, max, fetcher, timeout = 12_000) {
|
|
243
|
+
const controller = new AbortController();
|
|
244
|
+
let reader;
|
|
245
|
+
let timer;
|
|
246
|
+
const deadline = new Promise((_, reject) => {
|
|
247
|
+
timer = setTimeout(() => { controller.abort(); reject(new Error('Update download timed out.')); }, timeout);
|
|
248
|
+
});
|
|
249
|
+
const download = async () => {
|
|
250
|
+
const response = await fetcher(url, { redirect: 'error', signal: controller.signal, headers: { Accept: 'application/json' } });
|
|
251
|
+
if (!response.ok || !response.body || controller.signal.aborted)
|
|
252
|
+
throw new Error('Update server unavailable.');
|
|
253
|
+
const parts = [];
|
|
254
|
+
let size = 0;
|
|
255
|
+
reader = response.body.getReader();
|
|
256
|
+
for (;;) {
|
|
257
|
+
const { value, done } = await reader.read();
|
|
258
|
+
if (done)
|
|
259
|
+
break;
|
|
260
|
+
size += value.length;
|
|
261
|
+
if (size > max)
|
|
262
|
+
throw new Error('Update download exceeded its size limit.');
|
|
263
|
+
parts.push(value);
|
|
264
|
+
}
|
|
265
|
+
return Buffer.concat(parts);
|
|
266
|
+
};
|
|
267
|
+
try {
|
|
268
|
+
return await Promise.race([download(), deadline]);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
controller.abort();
|
|
273
|
+
// Cancellation itself must not keep a timed-out hook alive.
|
|
274
|
+
void reader?.cancel().catch(() => { });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function recover(base, scope) {
|
|
278
|
+
const paths = updatePaths(base, scope), text = await optional(paths.backup);
|
|
279
|
+
if (!text)
|
|
280
|
+
return;
|
|
281
|
+
const backup = parsed(text);
|
|
282
|
+
if (typeof backup.skill !== 'string' || typeof backup.rule !== 'string' || !isObject(backup.record))
|
|
283
|
+
throw new Error('Update recovery needs manual review.');
|
|
284
|
+
for (const field of ['skill', 'rule']) {
|
|
285
|
+
const current = await optional(paths[field]);
|
|
286
|
+
if (current !== null && digest(current) !== digest(backup[field]) && digest(current) !== backup[`${field}Hash`])
|
|
287
|
+
throw new Error('An interrupted update has user edits; nothing changed.');
|
|
288
|
+
}
|
|
289
|
+
// Recover the previous complete pair before any retry. Atomic per-file writes
|
|
290
|
+
// plus a durable journal cover failure between the two renames.
|
|
291
|
+
await atomic(paths.skill, backup.skill);
|
|
292
|
+
await atomic(paths.rule, backup.rule);
|
|
293
|
+
await atomic(paths.record, backup.record);
|
|
294
|
+
await rm(paths.backup, { force: true });
|
|
295
|
+
}
|
|
296
|
+
export async function recoverUpdates(base, scope) { await recover(base, scope); }
|
|
297
|
+
export async function runUpdate(base, scope, options = {}) {
|
|
298
|
+
return withUpdateLock(base, async () => {
|
|
299
|
+
await recover(base, scope);
|
|
300
|
+
const paths = updatePaths(base, scope), record = await object(paths.record), saved = updateRecord(record);
|
|
301
|
+
if (record.version !== 3 || record.scope !== scope || !saved)
|
|
302
|
+
return { status: 'manual_required', message: 'Run npx -y openmerit@latest install --client claude-code to bootstrap updates in this scope.' };
|
|
303
|
+
if (options.hook && (!saved.enabled || process.env.OPENMERIT_AUTO_UPDATE === 'off'))
|
|
304
|
+
return { status: 'disabled', message: 'Automatic updates disabled.' };
|
|
305
|
+
const now = options.now ?? Date.now(), state = options.enabled === undefined ? await object(paths.state) : {};
|
|
306
|
+
if (options.hook && typeof state.nextCheckAt === 'number' && now < state.nextCheckAt)
|
|
307
|
+
return { status: 'not_due', message: 'Update check not due.' };
|
|
308
|
+
const support = await inspectUpdates(base, scope, record);
|
|
309
|
+
if (support.conflicts.length) {
|
|
310
|
+
if (options.hook)
|
|
311
|
+
await atomic(paths.state, { nextCheckAt: now + day, status: 'conflict' });
|
|
312
|
+
return { status: 'conflict', message: `${support.conflicts.join(' ')} Say “update openmerit” after resolving the conflict.` };
|
|
313
|
+
}
|
|
314
|
+
if (options.enabled !== undefined) {
|
|
315
|
+
record.updates = await configureUpdates(base, scope, record, saved.version, options.enabled);
|
|
316
|
+
delete record.pendingUpdates;
|
|
317
|
+
await atomic(paths.record, record);
|
|
318
|
+
return { status: options.enabled ? 'enabled' : 'disabled', message: `Automatic Scout instruction updates ${options.enabled ? 'enabled' : 'disabled'}. Start a new Claude Code session to reload hooks.` };
|
|
319
|
+
}
|
|
320
|
+
// Record the attempt before networking so a crashed hook cannot hammer npm.
|
|
321
|
+
await atomic(paths.state, { ...state, nextCheckAt: now + retry });
|
|
322
|
+
try {
|
|
323
|
+
const fetcher = options.fetcher ?? fetch;
|
|
324
|
+
const release = parsed((await boundedFetch(registry, 512 * 1024, fetcher)).toString('utf8'));
|
|
325
|
+
const version = release.version;
|
|
326
|
+
if (release.name !== 'openmerit' || typeof version !== 'string' || !stableVersion(version))
|
|
327
|
+
throw new Error('Invalid stable release metadata.');
|
|
328
|
+
if (!newer(version, saved.version)) {
|
|
329
|
+
await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'current' });
|
|
330
|
+
return { status: 'current', version: saved.version, message: `Scout instructions ${saved.version} are current.` };
|
|
331
|
+
}
|
|
332
|
+
const dist = release.dist;
|
|
333
|
+
const tarball = `https://registry.npmjs.org/openmerit/-/openmerit-${version}.tgz`;
|
|
334
|
+
if (!isObject(dist) || dist.tarball !== tarball || typeof dist.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(dist.integrity))
|
|
335
|
+
throw new Error('Release has no trusted SHA-512 package integrity.');
|
|
336
|
+
const bytes = await boundedFetch(tarball, 16 * 1024 * 1024, fetcher, 120_000);
|
|
337
|
+
if (`sha512-${createHash('sha512').update(bytes).digest('base64')}` !== dist.integrity)
|
|
338
|
+
throw new Error('Package integrity verification failed.');
|
|
339
|
+
const bundle = instructionBundle(bytes);
|
|
340
|
+
if (!bundle || bundle.format !== 1 || bundle.contract !== updateContract || bundle.version !== version) {
|
|
341
|
+
await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'manual_required' });
|
|
342
|
+
return { status: 'manual_required', version, message: `Release ${version} needs a reviewed installer upgrade. Review its changes, then run npx -y openmerit@latest install --client claude-code --scope ${scope}. Do not bypass permissions or rerun login automatically.` };
|
|
343
|
+
}
|
|
344
|
+
if (typeof bundle.skill !== 'string' || typeof bundle.rule !== 'string' || !bundle.skill.startsWith('---\nname: openmerit-scout\n') || !bundle.rule.trim())
|
|
345
|
+
throw new Error('Invalid Scout instructions.');
|
|
346
|
+
const instructions = bundle;
|
|
347
|
+
const skill = await optional(paths.skill), rule = await optional(paths.rule);
|
|
348
|
+
if (skill === null || rule === null || digest(skill) !== record.skillHash || digest(rule) !== record.ruleHash) {
|
|
349
|
+
await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'conflict' });
|
|
350
|
+
return { status: 'conflict', message: 'Scout instructions are missing or edited. Nothing changed. Say “update openmerit” after reviewing the files.' };
|
|
351
|
+
}
|
|
352
|
+
const skillHash = digest(instructions.skill), ruleHash = digest(instructions.rule);
|
|
353
|
+
await atomic(paths.backup, { record, skill, rule, skillHash, ruleHash });
|
|
354
|
+
try {
|
|
355
|
+
// Do not overwrite edits made during the download or journal write.
|
|
356
|
+
if (await optional(paths.skill) !== skill || await optional(paths.rule) !== rule)
|
|
357
|
+
throw new Error('Scout instructions changed during the update.');
|
|
358
|
+
await atomic(paths.skill, instructions.skill);
|
|
359
|
+
await atomic(paths.rule, instructions.rule);
|
|
360
|
+
await atomic(paths.record, { ...record, skillHash, ruleHash, updates: { ...saved, version } });
|
|
361
|
+
await rm(paths.backup, { force: true });
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
try {
|
|
365
|
+
await recover(base, scope);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return { status: 'conflict', message: 'An interrupted Scout update could not be recovered safely. Review the installed files and openmerit-update-backup.json; no user edits were overwritten.' };
|
|
369
|
+
}
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
// A failed schedule write must not misreport an already applied update.
|
|
373
|
+
await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'updated', version }).catch(() => { });
|
|
374
|
+
return { status: 'updated', version, message: `Scout instructions updated to ${version}. Start a new Claude Code session to load them; the running session may still use its previous instructions.` };
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
await atomic(paths.state, { nextCheckAt: now + retry, status: 'retry' });
|
|
378
|
+
return { status: 'retry', message: 'Could not safely update Scout. The installed version was retained. Automatic checks retry after 15 minutes while Claude Code is in use; say “update openmerit” to retry now.' };
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
// Hooks deliberately never ask Claude to continue, read transcripts or emit
|
|
383
|
+
// blocking decisions. Offline and concurrent-hook failures exit successfully.
|
|
384
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
385
|
+
const args = process.argv.slice(2), base = args[args.indexOf('--base') + 1], scope = args[args.indexOf('--scope') + 1];
|
|
386
|
+
if (base && ['user', 'project'].includes(scope ?? '')) {
|
|
387
|
+
process.stdin.resume();
|
|
388
|
+
try {
|
|
389
|
+
const result = await runUpdate(resolve(base), scope, { hook: args.includes('--hook') });
|
|
390
|
+
if (['updated', 'conflict', 'manual_required', 'retry'].includes(result.status))
|
|
391
|
+
console.log(JSON.stringify({ systemMessage: result.message }));
|
|
392
|
+
}
|
|
393
|
+
catch { /* Maintenance cannot block the user's task. */ }
|
|
394
|
+
}
|
|
395
|
+
}
|