openmerit 0.1.8 → 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.
@@ -1,10 +1,12 @@
1
1
  import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { homedir, hostname } from 'node:os';
4
- import { createHash } from 'node:crypto';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { openBrowser } from './browser.mjs';
8
+ import { clientVersion, setupHeaders, setupUUID, sendInstallationEvent, setupCorrelationEnabled, telemetryEnabled } from './telemetry.mjs';
9
+ import { configureUpdates, inspectUpdates, recoverUpdates, removeUpdates, runUpdate, withUpdateLock } from './updates.js';
8
10
 
9
11
  // One-command coding-agent setup: device sign-in in the browser, then the MCP
10
12
  // server, startup rule and Scout skill. Built to be run by a person or by a coding agent:
@@ -38,6 +40,12 @@ model. You may continue work that does not commit to a model.
38
40
  Do not consult Scout for unrelated edits, normal inference calls using an unchanged
39
41
  approved model, or this coding agent's own model. Do not reselect approved models
40
42
  unless the task introduces or changes a model decision.
43
+
44
+ When the user says "update openmerit", run
45
+ \`npx -y openmerit@latest update --scope user\` (use \`--scope project\` if this
46
+ rule is in the project's .claude/rules directory). Report the result, including
47
+ any conflict or manual-upgrade requirement. Do not delete edited files, bypass
48
+ permissions or rerun login automatically. A new session loads updated instructions.
41
49
  `;
42
50
 
43
51
  export const skill = `---
@@ -101,16 +109,21 @@ expired or revoked, run \`npx -y openmerit@latest login\` and then
101
109
 
102
110
  export const help = `OpenMerit · coding-agent setup
103
111
 
104
- openmerit install [--client claude-code] [--scope user|project] [--plan] [--json] [--no-open]
112
+ openmerit install [--client claude-code] [--scope user|project] [--setup-id UUID] [--auto-update on|off] [--plan] [--json] [--no-open]
105
113
  openmerit status [--client claude-code] [--scope user|project] [--check] [--json]
106
114
  openmerit remove [--client claude-code] [--scope user|project] [--plan] [--json]
107
- openmerit login [--json] [--no-open] Replace this device's key
115
+ openmerit login [--setup-id UUID] [--json] [--no-open] Replace this device's key
108
116
  openmerit logout [--json] Revoke this device's key
109
117
  openmerit project --policy existing-only|existing-first [--endpoint-id <id> ...] [--plan]
110
118
  openmerit doctor [--json]
119
+ openmerit update [--scope user|project] [--auto on|off] [--json]
111
120
 
112
121
  install signs this device in through the browser (one Approve click), adds the
113
122
  OpenMerit MCP server, startup rule and Scout skill, then verifies the connection.
123
+ Automatic Scout instruction updates default on; --auto-update off opts out.
124
+ Background SessionStart/Stop hooks check npm at most daily (15-minute failure
125
+ retry), verify package integrity, and preserve edited files and credentials.
126
+ Start a new Claude Code session to reliably load updated instructions.
114
127
  It opens the approval page and waits up to 9 minutes for the Approve click
115
128
  (30 seconds when no browser could be opened). If it exits with approval
116
129
  pending, rerun the same command: it resumes the same request.
@@ -144,12 +157,12 @@ async function save(path, value) {
144
157
  await writeFile(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
145
158
  }
146
159
 
147
- async function request(path, { method = 'GET', body, key } = {}) {
160
+ async function request(path, { method = 'GET', body, key, telemetry } = {}) {
148
161
  let response;
149
162
  try {
150
163
  response = await fetch(`${server()}${path}`, {
151
164
  method, redirect: 'error', signal: AbortSignal.timeout(15_000),
152
- headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}), 'X-Scout-Client': 'cli', 'X-Scout-Analytics-Content': 'off' },
165
+ headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}), ...setupHeaders(telemetry?.properties) },
153
166
  ...(body ? { body: JSON.stringify(body) } : {}),
154
167
  });
155
168
  } catch { throw new Stop(`Could not reach ${server()}. Check your connection and retry.`, exitCodes.unreachable); }
@@ -177,7 +190,7 @@ function claudePaths(root, scope) {
177
190
  };
178
191
  }
179
192
  const configuredServer = (config, root, scope) => scope === 'user' ? config.mcpServers?.openmerit : config.projects?.[root]?.mcpServers?.openmerit;
180
- const serverConfig = key => ({ type: 'http', url: `${server()}/scout/mcp`, headers: { Authorization: `Bearer ${key}`, 'X-Scout-Analytics-Content': 'off' } });
193
+ const serverConfig = (key, telemetry) => ({ type: 'http', url: `${server()}/scout/mcp`, headers: { Authorization: `Bearer ${key}`, ...setupHeaders(telemetry.properties, 'agent') } });
181
194
  const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])])) : value;
182
195
  const serverHash = value => value ? hash(JSON.stringify(canonical(value))) : null;
183
196
 
@@ -195,14 +208,16 @@ async function claude(args, root, key) {
195
208
  async function inspect(root, scope) {
196
209
  const paths = claudePaths(root, scope);
197
210
  const [record, config, installedSkill, installedRule, legacyRule] = await Promise.all([object(paths.record), object(paths.mcpFile), optional(paths.skill), optional(paths.rule), optional(paths.legacyRule)]);
211
+ const updates = await inspectUpdates(dirname(paths.record), scope, record);
198
212
  const owned = [2, 3].includes(record.version) && record.scope === scope;
199
213
  const current = configuredServer(config, root, scope);
200
214
  const otherScope = configuredServer(config, root, scope === 'user' ? 'project' : 'user');
201
215
  const projectFile = await object(join(root, '.mcp.json'));
202
216
  return {
203
- paths, record, owned, current, installedSkill, installedRule,
204
- // A pending header hash covers an install interrupted between add and record.
205
- serverOwned: !current || owned && (serverHash(current) === record.serverHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
217
+ paths, record, owned, current, installedSkill, installedRule, updates,
218
+ // A full pending hash covers interruption without accepting edited headers.
219
+ // Keep pendingAuth compatibility for records from the earlier installer.
220
+ serverOwned: !current || owned && (serverHash(current) === record.serverHash || serverHash(current) === record.pendingServerHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
206
221
  skillOwned: installedSkill === null || owned && [record.skillHash, record.pendingSkill].includes(hash(installedSkill)),
207
222
  ruleOwned: installedRule === null || owned && record.version === 3 && [record.ruleHash, record.pendingRule].includes(hash(installedRule)),
208
223
  // The pre-release installer recorded its rule hash; only an unchanged rule is removed.
@@ -212,7 +227,7 @@ async function inspect(root, scope) {
212
227
  }
213
228
 
214
229
  function conflicts(state) {
215
- const found = [];
230
+ const found = [...state.updates.conflicts];
216
231
  if (!state.serverOwned) found.push('Claude Code already has an MCP server named "openmerit" that this installer did not create or that was edited. Remove it with "claude mcp remove openmerit", then retry.');
217
232
  if (!state.skillOwned) found.push(`${state.paths.skill} exists and was not created by this installer or was edited. Move or delete it, then retry.`);
218
233
  if (!state.ruleOwned) found.push(`${state.paths.rule} exists and was not created by this installer or was edited. Move or delete it, then retry.`);
@@ -220,6 +235,15 @@ function conflicts(state) {
220
235
  return found;
221
236
  }
222
237
 
238
+ function diagnostics(state) {
239
+ return {
240
+ installed: state.owned && !!state.current && state.serverOwned && state.installedSkill !== null && state.skillOwned && state.installedRule !== null && state.ruleOwned && !state.shadowed,
241
+ server_status: state.current ? state.serverOwned ? 'matches' : 'modified_or_unowned' : 'missing',
242
+ skill_status: state.installedSkill === null ? 'missing' : state.skillOwned ? 'matches' : 'modified_or_unowned',
243
+ rule_status: state.installedRule === null ? 'missing' : state.ruleOwned ? 'matches' : 'modified_or_unowned',
244
+ };
245
+ }
246
+
223
247
  async function credentials() {
224
248
  const saved = await object(credentialsPath());
225
249
  return saved.version === 1 && saved.server === server() && typeof saved.key === 'string' ? saved : null;
@@ -227,33 +251,55 @@ async function credentials() {
227
251
 
228
252
  // Device login. Starts or resumes a login, waits a bounded time, and stores the
229
253
  // key only on disk. Never prints the key.
230
- async function login({ client, scope, open, log, wait }) {
254
+ async function login({ client, scope, open, log, wait, telemetry }) {
255
+ await telemetry.step('login', 'started');
231
256
  let pending = await object(pendingPath());
232
257
  if (!(pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string')) {
233
258
  const deviceName = (process.env.OPENMERIT_DEVICE_NAME ?? hostname()).replace(/[^\x20-\x7e]/g, '').slice(0, 64) || 'this device';
234
- const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName } });
259
+ const { status, data } = await request('/connect/api/device', { method: 'POST', body: { client, scope, device_name: deviceName }, telemetry });
235
260
  if (status !== 200 || !data.device_code) throw new Stop(data.error ?? `Could not start sign-in (HTTP ${status}).`, status === 429 ? exitCodes.unreachable : exitCodes.error);
236
- pending = { server: server(), deviceCode: data.device_code, userCode: data.user_code, url: data.verification_uri_complete, interval: data.interval, expiresAt: Date.now() + data.expires_in * 1000, opened: false };
261
+ pending = { server: server(), deviceCode: data.device_code, userCode: data.user_code, url: data.verification_uri_complete, interval: data.interval, expiresAt: Date.now() + data.expires_in * 1000, opened: false, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
237
262
  await save(pendingPath(), pending);
238
263
  }
264
+ const approvalURL = new URL(pending.url);
265
+ // Rebuild consent on every resume, so old query parameters cannot override a
266
+ // later refusal. Only analytics-enabled, linked setup IDs reach the page.
267
+ for (const key of ['setup_id', 'analytics', 'analytics_account']) approvalURL.searchParams.delete(key);
268
+ if (setupCorrelationEnabled() && setupUUID(telemetry.properties.setup_id)) approvalURL.searchParams.set('setup_id', telemetry.properties.setup_id);
269
+ // Preserve explicit refusals across surfaces; unlinking must not disable usage.
270
+ if (!telemetryEnabled(true)) approvalURL.searchParams.set('analytics', 'off');
271
+ if (process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || !telemetryEnabled(true)) approvalURL.searchParams.set('analytics_account', 'off');
272
+ pending = { ...pending, url: approvalURL.href, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
273
+ await save(pendingPath(), pending);
239
274
  log(`Approve this device in your browser (opening it now):\n\n ${pending.url}\n\n Code: ${pending.userCode} (expires ${new Date(pending.expiresAt).toLocaleTimeString()})\n`);
275
+ await telemetry.step('browser_open', 'started');
240
276
  if (open && !pending.opened && !pending.openFailed) {
241
277
  // Only the approval page on the configured OpenMerit server may be opened.
242
- const approvalPage = url => { const u = new URL(url); return `${u.origin}` === server() && u.pathname === '/connect' && [...u.searchParams.keys()].join() === 'code' && !u.username && !u.password && !u.hash; };
278
+ const approvalPage = url => {
279
+ const u = new URL(url);
280
+ return u.origin === server() && u.pathname === '/connect' && !!u.searchParams.get('code')
281
+ && [...u.searchParams.keys()].every(key => ['code', 'setup_id', 'analytics', 'analytics_account'].includes(key) && u.searchParams.getAll(key).length === 1)
282
+ && (!u.searchParams.has('setup_id') || setupUUID(u.searchParams.get('setup_id')))
283
+ && ['analytics', 'analytics_account'].every(key => !u.searchParams.has(key) || u.searchParams.get(key) === 'off')
284
+ && !u.username && !u.password && !u.hash;
285
+ };
243
286
  const opened = await openBrowser(pending.url, { allowed: approvalPage }).catch(() => false);
244
287
  pending = { ...pending, ...(opened ? { opened: true } : { openFailed: true }) };
245
288
  await save(pendingPath(), pending);
246
289
  }
290
+ await telemetry.step('browser_open', pending.opened ? 'ok' : pending.openFailed ? 'error' : 'pending');
291
+ await telemetry.step('login', 'started');
247
292
  // With the approval page open, keep waiting for the Approve click (inside a
248
293
  // coding agent's 10-minute command limit) so nobody has to rerun anything.
249
294
  // Without a browser, return soon so the agent can show the link.
250
295
  const deadline = Date.now() + (wait ?? (pending.opened ? 540 : 30)) * 1000;
251
296
  let interval = (pending.interval ?? 5) * 1000;
252
297
  for (;;) {
253
- const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode } });
298
+ const { status, data } = await request('/connect/api/token', { method: 'POST', body: { device_code: pending.deviceCode }, telemetry });
254
299
  if (status === 200 && data.access_token) {
255
- await save(credentialsPath(), { version: 1, server: server(), key: data.access_token, keyId: data.key_id, createdAt: new Date().toISOString() });
300
+ await save(credentialsPath(), { version: 1, server: server(), key: data.access_token, keyId: data.key_id, setup_id: telemetry.properties.setup_id, createdAt: new Date().toISOString() });
256
301
  await rm(pendingPath(), { force: true });
302
+ await telemetry.step('login', 'ok');
257
303
  return data.access_token;
258
304
  }
259
305
  if (data.error === 'slow_down') interval += 5000;
@@ -268,36 +314,49 @@ async function login({ client, scope, open, log, wait }) {
268
314
  }
269
315
 
270
316
  // A real MCP initialize and tool discovery with the stored key; no search.
271
- async function verify(key) {
272
- const call = (id, method, params = {}) => request('/scout/mcp', { method: 'POST', key, body: { jsonrpc: '2.0', id, method, params } });
273
- const init = await call(1, 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'openmerit-cli', version: '1' } });
274
- if (init.status === 401) return 'key_rejected';
275
- if (init.status !== 200 || !init.data.result) return 'failed';
276
- const tools = await call(2, 'tools/list');
277
- return tools.data.result?.tools?.some(tool => tool.name === 'scout_models') ? 'connected' : 'failed';
317
+ async function verify(key, telemetry) {
318
+ await telemetry.step('verification', 'started');
319
+ const call = (id, method, params = {}) => request('/scout/mcp', { method: 'POST', key, telemetry, body: { jsonrpc: '2.0', id, method, params } });
320
+ const init = await call(1, 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'openmerit-cli', version: clientVersion ?? 'unknown' } });
321
+ let connection = init.status === 401 ? 'key_rejected' : 'failed';
322
+ if (init.status === 200 && init.data.result) {
323
+ const tools = await call(2, 'tools/list');
324
+ connection = tools.status === 401 ? 'key_rejected' : tools.status === 200 && tools.data.result?.tools?.some(tool => tool.name === 'scout_models') ? 'connected' : 'failed';
325
+ }
326
+ await telemetry.step('verification', connection === 'connected' ? 'ok' : 'error', { connection, ...(connection === 'connected' ? {} : { code: connection === 'key_rejected' ? 'access_denied' : 'command_error' }) });
327
+ return connection;
278
328
  }
279
329
 
280
- async function install(root, { client, scope, plan, open, log, wait }) {
330
+ async function install(root, { client, scope, plan, open, log, wait, telemetry, autoUpdate, json }) {
281
331
  const state = await inspect(root, scope);
332
+ const enabled = autoUpdate === undefined ? state.updates.saved?.enabled ?? true : autoUpdate === 'on';
282
333
  const found = conflicts(state);
283
334
  const changes = [
284
335
  `${state.current ? 'Update' : 'Add'} MCP server "openmerit" in Claude Code (${state.paths.mcpScope} scope, ${state.paths.mcpFile})`,
285
336
  `${state.installedSkill === null ? 'Create' : 'Update'} ${state.paths.skill}`,
286
337
  `${state.installedRule === null ? 'Create' : 'Update'} ${state.paths.rule} (loaded at session startup)`,
338
+ `Automatic Scout instruction updates ${enabled ? 'on' : 'off'} (SessionStart/Stop background hooks; disable with openmerit update --auto off)`,
339
+ `Write the owned updater at ${state.updates.paths.runner}; merge its hooks in ${state.updates.paths.settings}`,
287
340
  ...(state.legacyRule === 'remove' ? [`Remove the unchanged earlier instructions at ${state.paths.legacyRule}`] : []),
288
341
  ...(await credentials() ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath()} (mode 600)`]),
289
342
  ];
290
343
  if (plan) return { action: 'install', applied: false, client, scope, changes, conflicts: found };
291
344
  if (found.length) throw new Stop(found.join('\n'), exitCodes.conflict, 'Nothing was changed.');
345
+ if (!json) log(changes.find(change => change.startsWith('Automatic Scout')));
292
346
  await claude(['--version'], root);
347
+ if (setupCorrelationEnabled()) telemetry.properties.installation_id ??= randomUUID();
348
+ await telemetry.step('preflight', 'ok');
293
349
  let key = (await credentials())?.key;
294
350
  // A revoked or expired key gets a fresh sign-in instead of a broken install.
295
- if (key && await verify(key) === 'key_rejected') { await rm(credentialsPath(), { force: true }); key = undefined; log('The saved device key is no longer valid; signing in again.'); }
296
- key ??= await login({ client, scope, open, log, wait });
297
- const next = serverConfig(key);
351
+ if (key && await verify(key, telemetry) === 'key_rejected') { await rm(credentialsPath(), { force: true }); key = undefined; log('The saved device key is no longer valid; signing in again.'); }
352
+ key ??= await login({ client, scope, open, log, wait, telemetry });
353
+ await telemetry.step('configuration', 'started');
354
+ const next = serverConfig(key, telemetry);
355
+ const ids = { setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
298
356
  // Record intent first so an interrupted install is repaired by rerunning.
299
- await save(state.paths.record, { version: 3, scope, client, serverHash: serverHash(state.current), skillHash: state.installedSkill === null ? null : hash(state.installedSkill), ruleHash: state.installedRule === null ? null : hash(state.installedRule), pendingAuth: hash(next.headers.Authorization), pendingSkill: hash(skill), pendingRule: hash(rule) });
300
- if (state.current?.url !== next.url || state.current?.headers?.Authorization !== next.headers.Authorization) {
357
+ await save(state.paths.record, { version: 3, scope, client, ...ids, updates: state.record.updates, pendingUpdates: state.record.pendingUpdates, serverHash: serverHash(state.current), skillHash: state.installedSkill === null ? null : hash(state.installedSkill), ruleHash: state.installedRule === null ? null : hash(state.installedRule), pendingServerHash: serverHash(next), pendingSkill: hash(skill), pendingRule: hash(rule) });
358
+ telemetry.properties.applied = true;
359
+ if (serverHash(state.current) !== serverHash(next)) {
301
360
  if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
302
361
  await claude(['mcp', 'add-json', '--scope', state.paths.mcpScope, 'openmerit', JSON.stringify(next)], root, key);
303
362
  }
@@ -307,8 +366,11 @@ async function install(root, { client, scope, plan, open, log, wait }) {
307
366
  // Record what Claude actually wrote, so later edits are detected precisely.
308
367
  const written = configuredServer(await object(state.paths.mcpFile), root, scope);
309
368
  if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
310
- await save(state.paths.record, { version: 3, scope, client, serverHash: serverHash(written), skillHash: hash(skill), ruleHash: hash(rule), installedAt: new Date().toISOString() });
311
- const connection = await verify(key);
369
+ const updates = await configureUpdates(dirname(state.paths.record), scope, state.record, clientVersion, enabled);
370
+ await save(state.paths.record, { version: 3, scope, client, ...ids, serverHash: serverHash(written), skillHash: hash(skill), ruleHash: hash(rule), updates, installedAt: new Date().toISOString() });
371
+ await telemetry.step('configuration', 'ok', diagnostics(await inspect(root, scope)));
372
+ const connection = await verify(key, telemetry);
373
+ if (connection !== 'connected') telemetry.outcome = 'error';
312
374
  if (connection === 'key_rejected') throw new Stop('OpenMerit rejected this device key (expired or revoked).', exitCodes.denied, 'Run "openmerit login", then rerun install.');
313
375
  return { action: 'install', applied: true, client, scope, changes, connection,
314
376
  next: connection === 'connected'
@@ -316,37 +378,56 @@ async function install(root, { client, scope, plan, open, log, wait }) {
316
378
  : 'Installed, but the connection check failed. Run "openmerit doctor".' };
317
379
  }
318
380
 
319
- async function status(root, { client, scope, check }) {
381
+ async function status(root, { client, scope, check, telemetry }) {
382
+ await telemetry.step('status', 'started');
320
383
  const state = await inspect(root, scope), saved = await credentials();
321
- const installed = state.owned && !!state.current && state.serverOwned && state.installedSkill !== null && state.skillOwned && state.installedRule !== null && state.ruleOwned && !state.shadowed;
322
- return { action: 'status', client, scope, installed, signedIn: !!saved,
323
- server: state.current ? state.serverOwned ? 'matches' : 'modified_or_unowned' : 'missing',
324
- skill: state.installedSkill === null ? 'missing' : state.skillOwned ? 'matches' : 'modified_or_unowned',
325
- rule: state.installedRule === null ? 'missing' : state.ruleOwned ? 'matches' : 'modified_or_unowned',
326
- conflicts: conflicts(state), connection: check && saved ? await verify(saved.key) : 'not_checked' };
384
+ const details = diagnostics(state), found = conflicts(state);
385
+ const connection = check && saved ? await verify(saved.key, telemetry) : 'not_checked';
386
+ telemetry.outcome = ['failed', 'key_rejected'].includes(connection) ? 'error' : found.length ? 'conflict' : details.installed ? 'ok' : 'not_installed';
387
+ await telemetry.step('status', telemetry.outcome, { ...details, connection });
388
+ return { action: 'status', client, scope, installed: details.installed, signedIn: !!saved,
389
+ server: details.server_status, skill: details.skill_status, rule: details.rule_status,
390
+ autoUpdates: state.updates.saved?.enabled ?? false, instructionVersion: state.updates.saved?.version ?? null,
391
+ updateHooks: state.updates.saved?.enabled ? state.updates.active && !state.updates.conflicts.length ? 'ready' : 'incomplete' : 'disabled',
392
+ conflicts: found, connection };
327
393
  }
328
394
 
329
- async function revokeKey() {
395
+ async function revokeKey(telemetry) {
330
396
  const saved = await credentials();
331
397
  if (!saved) return false;
332
- const { status } = await request('/connect/api/keys/self', { method: 'DELETE', key: saved.key }).catch(() => ({ status: 0 }));
398
+ await telemetry.step('remove', 'started');
399
+ const { status } = await request('/connect/api/keys/self', { method: 'DELETE', key: saved.key, telemetry }).catch(() => ({ status: 0 }));
333
400
  await rm(credentialsPath(), { force: true });
401
+ if (status !== 200) telemetry.outcome = 'error';
402
+ await telemetry.step('remove', status === 200 ? 'ok' : 'error', status === 200 ? {} : {
403
+ code: status === 0 ? 'openmerit_unreachable' : status === 401 || status === 403 ? 'access_denied' : 'command_error',
404
+ });
334
405
  return status === 200;
335
406
  }
336
407
 
337
- async function remove(root, { client, scope, plan }) {
408
+ async function remove(root, { client, scope, plan, telemetry }) {
409
+ await telemetry.step('remove', 'started');
338
410
  const state = await inspect(root, scope);
339
- if (!state.owned) return { action: 'remove', applied: false, client, scope, changes: [], note: 'No OpenMerit installation by this installer in this scope.' };
411
+ if (!state.owned) {
412
+ telemetry.outcome = 'not_installed';
413
+ await telemetry.step('remove', 'not_installed');
414
+ return { action: 'remove', applied: false, client, scope, changes: [], note: 'No OpenMerit installation by this installer in this scope.' };
415
+ }
340
416
  const changes = [...(state.current && state.serverOwned ? [`Remove MCP server "openmerit" (${state.paths.mcpScope} scope)`] : []),
341
417
  ...(state.installedSkill !== null && state.skillOwned ? [`Delete ${state.paths.skill}`] : []),
342
- ...(state.installedRule !== null && state.ruleOwned ? [`Delete ${state.paths.rule}`] : []), 'Revoke and delete this device key'];
418
+ ...(state.installedRule !== null && state.ruleOwned ? [`Delete ${state.paths.rule}`] : []),
419
+ ...(state.updates.saved ? ['Remove unchanged owned update hooks and updater; retain edited hooks and their runner'] : []), 'Revoke and delete this device key'];
343
420
  const kept = [...(state.serverOwned ? [] : ['the edited "openmerit" MCP server']), ...(state.skillOwned ? [] : [state.paths.skill]), ...(state.ruleOwned ? [] : [state.paths.rule]), policyPath];
421
+ kept.push(...state.updates.conflicts);
344
422
  if (plan) return { action: 'remove', applied: false, client, scope, changes, keeps: kept };
423
+ telemetry.properties.applied = true;
345
424
  if (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
346
425
  if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
347
426
  if (state.installedRule !== null && state.ruleOwned) await rm(state.paths.rule, { force: true });
427
+ await removeUpdates(dirname(state.paths.record), scope, state.record);
348
428
  await rm(state.paths.record, { force: true });
349
- const revoked = await revokeKey();
429
+ const revoked = await revokeKey(telemetry);
430
+ await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope)));
350
431
  return { action: 'remove', applied: true, client, scope, changes, keeps: kept, keyRevoked: revoked };
351
432
  }
352
433
 
@@ -359,26 +440,31 @@ async function project(root, { policy, ids, plan }) {
359
440
  return { action: 'project', applied: !plan, path: policyPath, policy: value, note: 'Endpoint IDs limit discovery only; they do not prove access or authorize spending.' };
360
441
  }
361
442
 
362
- async function doctor(root, { scope }) {
443
+ async function doctor(root, { scope, telemetry }) {
444
+ await telemetry.step('status', 'started');
363
445
  const checks = [];
364
446
  const add = (name, ok, detail) => checks.push({ name, ok, detail });
365
447
  const [major, minor] = process.versions.node.split('.').map(Number);
366
448
  add('node', major > 22 || major === 22 && minor >= 19, `Node.js ${process.versions.node} (22.19+ required)`);
367
449
  try { const { stdout } = await exec('claude', ['--version'], { timeout: 10_000 }); add('claude', true, stdout.trim().slice(0, 80)); }
368
450
  catch { add('claude', false, 'Claude Code (claude) is not on PATH'); }
369
- const reach = await request('/scout/api/status').catch(error => ({ status: 0, error }));
451
+ const reach = await request('/scout/api/status', { telemetry }).catch(error => ({ status: 0, error }));
370
452
  add('openmerit', reach.status === 200, reach.status === 200 ? `${server()} reachable` : `${server()} unreachable`);
371
453
  const saved = await credentials();
372
454
  add('key', !!saved, saved ? `Device key ${saved.keyId ?? ''} stored in ${credentialsPath()}` : 'Not signed in. Run "openmerit install" or "openmerit login".');
373
- if (saved && reach.status === 200) { const connection = await verify(saved.key); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
455
+ if (saved && reach.status === 200) { const connection = await verify(saved.key, telemetry); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
374
456
  const state = await inspect(root, scope).catch(() => null);
375
457
  if (state) add('install', state.owned && !!state.current && state.installedSkill !== null && state.installedRule !== null && !conflicts(state).length, state.owned ? conflicts(state).join(' ') || (state.current && state.installedSkill !== null && state.installedRule !== null ? `Installed (${scope} scope)` : 'Installation incomplete. Rerun "openmerit install".') : `Not installed in ${scope} scope`);
458
+ if (state?.updates.saved?.enabled) add('updater', state.updates.active && !state.updates.conflicts.length, state.updates.active && !state.updates.conflicts.length ? 'Automatic instruction hooks installed' : 'Automatic instruction hooks missing or edited. Rerun install after reviewing conflicts.');
459
+ telemetry.outcome = checks.every(check => check.ok) ? 'ok' : 'error';
460
+ await telemetry.step('status', telemetry.outcome, { ...(state ? diagnostics(state) : {}), ...(reach.status === 200 ? {} : { code: 'openmerit_unreachable', connection: 'failed' }) });
376
461
  return { action: 'doctor', ok: checks.every(check => check.ok), checks };
377
462
  }
378
463
 
379
464
  function render(result) {
380
465
  if (result.action === 'doctor') return result.checks.map(c => `${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}`).join('\n');
381
- if (result.action === 'status') return [`OpenMerit in ${clients[result.client]} (${result.scope} scope): ${result.installed ? 'installed' : 'not installed'}`, `Signed in: ${result.signedIn ? 'yes' : 'no'} · MCP server: ${result.server} · skill: ${result.skill} · rule: ${result.rule} · connection: ${result.connection}`, ...result.conflicts].join('\n');
466
+ if (result.action === 'status') return [`OpenMerit in ${clients[result.client]} (${result.scope} scope): ${result.installed ? 'installed' : 'not installed'}`, `Signed in: ${result.signedIn ? 'yes' : 'no'} · MCP server: ${result.server} · skill: ${result.skill} · rule: ${result.rule} · connection: ${result.connection}`, `Automatic instruction updates: ${result.autoUpdates ? 'on' : 'off'} · hooks: ${result.updateHooks} · instruction version: ${result.instructionVersion ?? 'bootstrap required'}`, ...result.conflicts].join('\n');
467
+ if (result.action === 'update') return result.message;
382
468
  if (result.action === 'project') return `${result.applied ? 'Saved' : 'Would save'} ${result.path}: ${result.policy.policy}, ${result.policy.endpointIds.length} endpoint ID(s). ${result.note}`;
383
469
  if (result.action === 'login') return result.note;
384
470
  if (result.action === 'logout') return result.note;
@@ -389,12 +475,39 @@ function render(result) {
389
475
  return lines.join('\n');
390
476
  }
391
477
 
392
- export async function main(args, { root = process.cwd(), print = console.log, printError = console.error } = {}) {
478
+ export async function main(args, dependencies = {}) {
479
+ const telemetry = {
480
+ stage: 'started', outcome: 'ok',
481
+ properties: { command: ['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor'].includes(args[0]) ? args[0] : 'unknown', agent: 'claude-code', scope: 'user', applied: false, installed: false, connection: 'not_checked' },
482
+ async step(stage, outcome, properties = {}) {
483
+ this.stage = stage;
484
+ if (outcome === 'ok') delete this.properties.code;
485
+ Object.assign(this.properties, properties);
486
+ if (args[0] !== 'update') await sendInstallationEvent({ ...this.properties, stage, outcome });
487
+ },
488
+ async fail(error) {
489
+ this.outcome = ({ 10: 'pending', 11: 'denied', 20: 'conflict' })[error.exit] ?? 'error';
490
+ const code = ({ 10: 'approval_pending', 11: 'access_denied', 20: 'install_conflict', 30: 'openmerit_unreachable' })[error.exit] ?? 'command_error';
491
+ await this.step(this.stage, this.outcome, { code, ...(this.stage === 'verification' ? { connection: error.exit === exitCodes.denied ? 'key_rejected' : 'failed' } : {}) });
492
+ },
493
+ };
494
+ try {
495
+ const code = await runMain(args, dependencies, telemetry);
496
+ await telemetry.step('completed', telemetry.outcome);
497
+ return code;
498
+ } catch (error) {
499
+ await telemetry.fail(error);
500
+ await telemetry.step('completed', telemetry.outcome);
501
+ throw error;
502
+ }
503
+ }
504
+
505
+ async function runMain(args, { root = process.cwd(), print = console.log, printError = console.error }, telemetry) {
393
506
  const [action, ...rest] = args;
394
507
  if (!action || rest.includes('--help') || rest.includes('-h')) { print(help); return exitCodes.ok; }
395
- const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [] };
396
- const allowed = { install: ['--client', '--scope', '--plan', '--json', '--no-open'], status: ['--client', '--scope', '--check', '--json'], remove: ['--client', '--scope', '--plan', '--json'],
397
- login: ['--json', '--no-open'], logout: ['--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--scope', '--json'] }[action];
508
+ const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [], telemetry };
509
+ const allowed = { install: ['--client', '--scope', '--setup-id', '--plan', '--json', '--no-open', '--auto-update'], status: ['--client', '--scope', '--check', '--json'], remove: ['--client', '--scope', '--plan', '--json'],
510
+ login: ['--setup-id', '--json', '--no-open'], logout: ['--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--scope', '--json'], update: ['--scope', '--auto', '--json'] }[action];
398
511
  if (!allowed) throw new Error('Unknown command. Run openmerit --help.');
399
512
  for (let i = 0; i < rest.length; i++) {
400
513
  const option = rest[i];
@@ -402,8 +515,10 @@ export async function main(args, { root = process.cwd(), print = console.log, pr
402
515
  const value = () => { const v = rest[++i]; if (v === undefined || v.startsWith('--')) throw new Error(`${option} needs a value.`); return v; };
403
516
  if (option === '--client') options.client = value();
404
517
  else if (option === '--scope') options.scope = value();
518
+ else if (option === '--setup-id') { options.setup_id = value(); if (!setupUUID(options.setup_id)) throw new Error('--setup-id must be a UUID v4.'); }
405
519
  else if (option === '--policy') options.policy = value();
406
520
  else if (option === '--endpoint-id') options.ids.push(value());
521
+ else if (option === '--auto-update' || option === '--auto') { const setting = value(); if (!['on', 'off'].includes(setting)) throw new Error(`${option} needs on or off.`); options.autoUpdate = setting; }
407
522
  else if (option === '--no-open') options.open = false;
408
523
  else options[option.slice(2)] = true;
409
524
  }
@@ -418,17 +533,45 @@ export async function main(args, { root = process.cwd(), print = console.log, pr
418
533
  const wait = process.env.OPENMERIT_LOGIN_WAIT === undefined ? undefined : Number(process.env.OPENMERIT_LOGIN_WAIT);
419
534
  const log = text => (options.json ? printError : print)(text);
420
535
  try {
421
- const result = action === 'install' ? await install(root, { ...options, log, wait })
536
+ telemetry.properties.scope = options.scope;
537
+ // Hydrate correlation without creating identities or writing anything. A
538
+ // pending approval owns its setup ID even when the resumed command omits it.
539
+ if (['install', 'status', 'remove', 'doctor'].includes(action)) {
540
+ telemetry.stage = 'preflight';
541
+ const state = await inspect(root, options.scope);
542
+ Object.assign(telemetry.properties, diagnostics(state));
543
+ if (state.owned) for (const key of ['setup_id', 'installation_id']) if (setupUUID(state.record[key])) telemetry.properties[key] = state.record[key];
544
+ }
545
+ if (options.setup_id) telemetry.properties.setup_id = options.setup_id;
546
+ if (['install', 'login'].includes(action)) {
547
+ const saved = await credentials();
548
+ if (!telemetry.properties.setup_id && setupUUID(saved?.setup_id)) telemetry.properties.setup_id = saved.setup_id;
549
+ const pending = await object(pendingPath());
550
+ if (pending.server === server() && pending.expiresAt > Date.now() && typeof pending.deviceCode === 'string') {
551
+ if (setupUUID(pending.setup_id)) telemetry.properties.setup_id = pending.setup_id;
552
+ if (!telemetry.properties.installation_id && setupUUID(pending.installation_id)) telemetry.properties.installation_id = pending.installation_id;
553
+ }
554
+ }
555
+ await telemetry.step('started', 'started');
556
+ await telemetry.step('preflight', 'started');
557
+ const base = dirname(claudePaths(root, options.scope).record);
558
+ const maintenance = work => options.plan ? work() : withUpdateLock(base, async () => { await recoverUpdates(base, options.scope); return work(); });
559
+ const result = action === 'install' ? await maintenance(() => install(root, { ...options, log, wait }))
422
560
  : action === 'status' ? await status(root, options)
423
- : action === 'remove' ? await remove(root, options)
561
+ : action === 'remove' ? await maintenance(() => remove(root, options))
562
+ : action === 'update' ? { action, ...await runUpdate(base, options.scope, options.autoUpdate === undefined ? {} : { enabled: options.autoUpdate === 'on' }) }
424
563
  : action === 'project' ? await project(root, options)
425
564
  : action === 'doctor' ? await doctor(root, options)
426
- : action === 'logout' ? { action, revoked: await revokeKey(), note: 'Signed out. This device key was revoked and deleted. Agents configured with it stop working until you run install again.' }
427
- : (await revokeKey(), await rm(pendingPath(), { force: true }), await login({ client: 'claude-code', scope: 'user', open: options.open, log, wait }), { action, signedIn: true, note: 'Signed in. Run "openmerit install" again to give your coding agents the new key.' });
428
- print(options.json ? JSON.stringify({ ok: true, ...result }, null, 2) : render(result));
565
+ : action === 'logout' ? { action, revoked: await revokeKey(telemetry), note: 'Signed out. This device key was revoked and deleted. Agents configured with it stop working until you run install again.' }
566
+ : (await revokeKey(telemetry), await login({ client: 'claude-code', scope: 'user', open: options.open, log, wait, telemetry }), { action, signedIn: true, note: 'Signed in. Run "openmerit install" again to give your coding agents the new key.' });
567
+ if (result.conflicts?.length && telemetry.outcome !== 'error') { telemetry.outcome = 'conflict'; telemetry.properties.code = 'install_conflict'; }
568
+ if (options.plan) await telemetry.step(telemetry.stage, telemetry.outcome, { applied: false });
569
+ print(options.json ? JSON.stringify({ ok: action !== 'update' || !['conflict', 'retry', 'manual_required'].includes(result.status), ...result }, null, 2) : render(result));
570
+ if (action === 'update') return result.status === 'conflict' ? exitCodes.conflict : result.status === 'retry' ? exitCodes.unreachable : result.status === 'manual_required' ? exitCodes.error : exitCodes.ok;
429
571
  return action === 'doctor' && !result.ok ? exitCodes.error : exitCodes.ok;
430
572
  } catch (error) {
431
573
  if (!(error instanceof Stop)) throw error;
574
+ await telemetry.fail(error);
432
575
  if (options.json) print(JSON.stringify({ ok: false, action, code: Object.keys(exitCodes).find(k => exitCodes[k] === error.exit), message: error.message, next: error.next }, null, 2));
433
576
  else printError([error.message, error.next].filter(Boolean).join('\n'));
434
577
  return error.exit;