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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.10 — 2026-10-26
4
+
5
+ - Add default-on Claude Code background checks for compatible Scout skill/startup-rule updates, with daily checks, 15-minute failure backoff, non-blocking failure notices with the “update openmerit” fallback, manual `openmerit update`, persistent opt-out and a natural-language update instruction.
6
+ - Verify npm package integrity and read a bounded instruction bundle without executing downloaded code. Preserve edited files, credentials, MCP configuration, privacy settings and project policy; serialize maintenance and recover interrupted instruction writes.
7
+ - Disclose setup behavior, retain other Claude hooks/settings and require a new session to reliably load updated instructions. Existing installations need a one-time installer rerun after publication; incompatible releases require a reviewed manual upgrade. Hosted Scout and npm remain separate releases.
8
+ - Enable Scout setup usage/installation diagnostics by default, honoring telemetry, DNT and account-unlink opt-outs. Terminal/evaluation analytics remain opt-in; MCP prompt-content capture remains off.
9
+
3
10
  ## 0.1.9 — 2026-10-24
4
11
 
5
12
  - Add opt-in coding-agent installation milestones, component/connection diagnostics and consent-gated setup/installation correlation retained across resumed approval and upgrades. Carry browser usage and unlink choices into setup; keep standalone CLI analytics default-off and MCP prompt-content capture off.
@@ -7,13 +7,13 @@ export interface HarnessAdapter {
7
7
  scheduleWakeup?(request: HarnessWakeupRequest): Promise<HarnessDispatchReceipt>;
8
8
  }
9
9
  export interface IntentFactoryOptions {
10
- readonly taskProfileId?: string;
10
+ readonly taskProfileId?: string | undefined;
11
11
  readonly authorizationPolicyId?: string;
12
- readonly policy?: SupervisedGraduationPolicy;
12
+ readonly policy?: SupervisedGraduationPolicy | undefined;
13
13
  readonly id?: string;
14
14
  readonly requestedAt?: string;
15
- readonly evaluationSpend?: number;
16
- readonly targetId?: string;
15
+ readonly evaluationSpend?: number | undefined;
16
+ readonly targetId?: string | undefined;
17
17
  readonly additionalConstraints?: Readonly<Record<string, JsonValue>>;
18
18
  }
19
19
  export declare function createOpenMeritIntent(kind: IntentKind, options?: IntentFactoryOptions): OpenMeritIntent;
@@ -28,8 +28,8 @@ export interface VerificationResult {
28
28
  }
29
29
  export interface ResultInterpretation extends VerificationResult {
30
30
  readonly nextStage?: ImprovementCycleState["stage"];
31
- readonly config?: OpenMeritProjectConfig;
32
- readonly cyclePatch?: Partial<ImprovementCycleState>;
31
+ readonly config?: OpenMeritProjectConfig | undefined;
32
+ readonly cyclePatch?: Partial<ImprovementCycleState> | undefined;
33
33
  }
34
34
  export declare function validateObservabilityCoverage(profile: TaskProfile, coverage: ObservabilityCoverage, requireReady: boolean): readonly string[];
35
35
  export declare function interpretIntentResult(intent: OpenMeritIntent, result: AnyIntentResult, cycle: ImprovementCycleState, config: OpenMeritProjectConfig): ResultInterpretation;
@@ -52,7 +52,7 @@ export interface IssuedIntent {
52
52
  readonly receipt: HarnessDispatchReceipt;
53
53
  }
54
54
  export interface CoordinatorIssueOptions {
55
- readonly additionalConstraints?: Readonly<Record<string, JsonValue>>;
55
+ readonly additionalConstraints?: Readonly<Record<string, JsonValue>> | undefined;
56
56
  }
57
57
  export interface AcceptedIntentResult {
58
58
  readonly intentKind: IntentKind;
@@ -536,7 +536,7 @@ export default function openMeritExtension(pi) {
536
536
  await dispatchIntent(pi, ctx, state, store, "establish_evals", registerCompletionTool, {
537
537
  applicationTargetRequired: true,
538
538
  detectedApplicationTargets: detectedTargetsConstraint(detectedApplicationTargets),
539
- ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0].configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0].configuredModelId } : {}),
539
+ ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0]?.configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0]?.configuredModelId } : {}),
540
540
  });
541
541
  });
542
542
  pi.on("session_start", async (_event, ctx) => {
@@ -573,7 +573,7 @@ export default function openMeritExtension(pi) {
573
573
  await dispatchIntent(pi, ctx, state, store, "establish_evals", registerCompletionTool, {
574
574
  applicationTargetRequired: true,
575
575
  detectedApplicationTargets: detectedTargetsConstraint(detectedApplicationTargets),
576
- ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0].configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0].configuredModelId } : {}),
576
+ ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0]?.configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0]?.configuredModelId } : {}),
577
577
  });
578
578
  }
579
579
  }
@@ -626,7 +626,7 @@ export default function openMeritExtension(pi) {
626
626
  await dispatchIntent(pi, ctx, state, store, "establish_evals", registerCompletionTool, {
627
627
  applicationTargetRequired: true,
628
628
  detectedApplicationTargets: detectedTargetsConstraint(detectedApplicationTargets),
629
- ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0].configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0].configuredModelId } : {}),
629
+ ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0]?.configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0]?.configuredModelId } : {}),
630
630
  });
631
631
  }
632
632
  else {
@@ -652,7 +652,7 @@ export default function openMeritExtension(pi) {
652
652
  await dispatchIntent(pi, ctx, state, store, "establish_evals", registerCompletionTool, {
653
653
  applicationTargetRequired: true,
654
654
  detectedApplicationTargets: detectedTargetsConstraint(detectedApplicationTargets),
655
- ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0].configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0].configuredModelId } : {}),
655
+ ...(detectedApplicationTargets.length === 1 && detectedApplicationTargets[0]?.configuredModelId ? { expectedIncumbentModelId: detectedApplicationTargets[0]?.configuredModelId } : {}),
656
656
  });
657
657
  }
658
658
  return;
@@ -361,16 +361,16 @@ export declare const REQUIRED_CAPABILITIES: Readonly<Record<IntentKind, readonly
361
361
  export type ImprovementStage = "unconfigured" | "establishing_evidence" | "collecting_baseline" | "baseline_ready" | "discovering_candidates" | "candidates_ready" | "running_challenger_trials" | "challengers_ready" | "calculating_frontier" | "frontier_ready" | "awaiting_swap_approval" | "swap_approved" | "applying_swap" | "swap_applied" | "verifying_swap" | "swap_verified" | "verification_failed" | "rolling_back" | "monitoring";
362
362
  export interface ImprovementCycleState {
363
363
  readonly id: string;
364
- readonly targetId?: string;
365
- readonly taskProfileId?: string;
364
+ readonly targetId?: string | undefined;
365
+ readonly taskProfileId?: string | undefined;
366
366
  readonly stage: ImprovementStage;
367
- readonly activeCandidateId?: string;
368
- readonly previousCandidateId?: string;
369
- readonly proposedCandidateId?: string;
367
+ readonly activeCandidateId?: string | undefined;
368
+ readonly previousCandidateId?: string | undefined;
369
+ readonly proposedCandidateId?: string | undefined;
370
370
  readonly verifiedSwapCount: number;
371
371
  readonly baselineEvidenceSufficient: boolean;
372
372
  readonly observabilityReady?: boolean;
373
- readonly policy?: SupervisedGraduationPolicy;
373
+ readonly policy?: SupervisedGraduationPolicy | undefined;
374
374
  }
375
375
  export interface OpenMeritProjectConfig {
376
376
  readonly schemaVersion: 1;
@@ -382,13 +382,13 @@ export interface OpenMeritProjectConfig {
382
382
  export interface OpenMeritProjectState {
383
383
  readonly schemaVersion: 1;
384
384
  readonly cycle: ImprovementCycleState;
385
- readonly activeIntent?: OpenMeritIntent;
386
- readonly lastResult?: IntentResult;
385
+ readonly activeIntent?: OpenMeritIntent | undefined;
386
+ readonly lastResult?: IntentResult | undefined;
387
387
  /** Durable handoffs between discovery, trials, and frontier calculation. */
388
- readonly candidates?: readonly CandidateDescriptor[];
389
- readonly assessments?: readonly CandidateAssessment[];
390
- readonly experimentManifest?: EvidenceReference;
391
- readonly observability?: ObservabilityCoverage;
388
+ readonly candidates?: readonly CandidateDescriptor[] | undefined;
389
+ readonly assessments?: readonly CandidateAssessment[] | undefined;
390
+ readonly experimentManifest?: EvidenceReference | undefined;
391
+ readonly observability?: ObservabilityCoverage | undefined;
392
392
  readonly automation: {
393
393
  readonly paused?: boolean;
394
394
  readonly setupNudgeShown: boolean;
@@ -397,9 +397,9 @@ export interface OpenMeritProjectState {
397
397
  readonly modelCatalogFingerprint?: string;
398
398
  readonly modelCatalogChangedAt?: string;
399
399
  readonly processedSignalIds?: readonly string[];
400
- readonly pendingIntentKind?: IntentKind;
401
- readonly pendingIntentReason?: string;
402
- readonly lastAssessmentAt?: string;
400
+ readonly pendingIntentKind?: IntentKind | undefined;
401
+ readonly pendingIntentReason?: string | undefined;
402
+ readonly lastAssessmentAt?: string | undefined;
403
403
  readonly baselineStartedAt?: string;
404
404
  readonly lastReassessmentTaskCount?: number;
405
405
  readonly lastReassessmentAt?: string;
@@ -0,0 +1 @@
1
+ {"format":1,"contract":"scout-recommend-v1","version":"0.1.10","skill":"---\nname: openmerit-scout\ndescription: Consults OpenMerit Scout before choosing, adding, downloading, replacing or configuring any application inference model, including implicit choices while building features, pretrained browser/local models, SDK defaults, embeddings, OCR, speech, classification, detection, segmentation, LLMs and agents. Not for unrelated edits, ordinary inference calls or this coding agent's own model.\n---\n\n# OpenMerit · Scout\n\nBefore introducing, recommending, replacing or configuring an application\ninference model, call the `scout_models` tool on the `openmerit` MCP server.\nThis applies even when the user never mentions models or Scout. Downloading local\nweights, using a pretrained browser model or accepting an SDK default is a model\ndecision: consult Scout before model-specific code, installs or downloads.\nPreserve the user's explicit model choice; assess it rather than silently replacing\nit. Leave unchanged, approved application selections in place.\n\n## Project policy\n\nRead `.openmerit/scout.json` before searching. If it is absent, ask once whether this\nproject uses **existing-only** (only endpoints it can already reach) or\n**existing-first** (prefer them, ask before searching the whole catalogue), and\nwhich public catalogue endpoint IDs it can access. Save the answer:\n\n```sh\nnpx -y openmerit@latest project --policy existing-only --endpoint-id <id> ...\n```\n\nNever infer access from a model name, SDK or environment-variable name. Never\nrecord credentials, private URLs, prompts or customer data. An empty inventory\nis not permission to search all models.\n\n## Searching\n\nCall `scout_models` with a non-confidential task description and\n`endpointIds` set to the saved inventory, including `[]` when empty.\nExisting-only never broadens. For existing-first, if no existing candidate fits,\nexplain the gap and ask before a separate search without `endpointIds`. Send\nonly task requirements, never source files, secrets or real customer records.\n\nCompare model AND endpoint, evidence, reference cost/time, unknowns and\nwarnings. For multiple tasks read `tasks[].slots[0].recommendations`; the\ntop-level list is empty. Prefer the cheapest usable option the quality evidence\nsupports, without claiming benchmarks prove application accuracy. Use the\nreturned provider model ID and the project's actual SDK and protocol.\n\n## Boundaries\n\nAsk before adding providers or credentials, spending, deploying or external\nwrites. Report recommendation, endpoint connectivity and application checks as\nseparate results, each only after it succeeds. Do not switch this coding agent's\nown model.\n\nIf Scout fails or has no suitable candidate/evidence for the required deployment,\nsay so and pause the model decision; do not substitute a remembered recommendation\nor SDK default. Continue only model-independent work. Local/browser models are not\nexempt when the catalogue lacks coverage. If it reports that the device key is missing,\nexpired or revoked, run `npx -y openmerit@latest login` and then\n`npx -y openmerit@latest install` again. Never ask the user to paste a key.\n","rule":"# Application model decisions · OpenMerit Scout\n\nBefore choosing, recommending, adding, downloading, replacing or configuring\nANY application inference model, load the `openmerit-scout` skill and consult\n`scout_models` on the `openmerit` MCP server before committing to a model.\nThis applies even when the user only asks you to build a feature, never mentions\nmodels or Scout, or names a model themselves (assess it; do not silently replace it).\n\nA pretrained model, local weights, browser inference, an SDK's default model,\nembeddings, OCR, speech, image classification, detection and segmentation all\ncount as model choices, not implementation details. Identify this decision before\nwriting model-specific code, installing a model package or downloading weights.\n\nFollow the skill's project access policy. If it is missing, ask and save it first.\nIf Scout is unavailable, fails or has no suitable evidence/candidate, disclose the\ngap and pause the model decision; do not fall back to a remembered or SDK-default\nmodel. You may continue work that does not commit to a model.\n\nDo not consult Scout for unrelated edits, normal inference calls using an unchanged\napproved model, or this coding agent's own model. Do not reselect approved models\nunless the task introduces or changes a model decision.\n\nWhen the user says \"update openmerit\", run\n`npx -y openmerit@latest update --scope user` (use `--scope project` if this\nrule is in the project's .claude/rules directory). Report the result, including\nany conflict or manual-upgrade requirement. Do not delete edited files, bypass\npermissions or rerun login automatically. A new session loads updated instructions.\n"}
@@ -11,6 +11,7 @@ const help = `OpenMerit · Inference Terminal
11
11
 
12
12
  openmerit dash [path] [options]
13
13
  openmerit install --help Connect OpenMerit to your coding agent
14
+ openmerit update --help Update installed Scout instructions
14
15
  openmerit eval --help Evaluate CLI changes across coding agents
15
16
 
16
17
  --port <number> Require a specific local port (default: 4310 or a free port)
@@ -29,7 +30,7 @@ Coding-harness usage is outside this terminal's scope.
29
30
 
30
31
  async function main() {
31
32
  const args = process.argv.slice(2);
32
- if (['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor'].includes(args[0])) { const { main } = await import('./install.mjs'); process.exitCode = await main(args); return; }
33
+ if (['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor', 'update'].includes(args[0])) { const { main } = await import('./install.mjs'); process.exitCode = await main(args); return; }
33
34
  if (args[0] === 'eval') { const { main } = await import('../../eval/src/cli.mjs'); await main(args.slice(1)); return; }
34
35
  if (!args.length || args.includes('--help') || args.includes('-h')) { console.log(help); return; }
35
36
  if (args.shift() !== 'dash') throw new Error('Unknown command. Run openmerit --help.');
@@ -5,7 +5,8 @@ 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 } from './telemetry.mjs';
8
+ import { clientVersion, setupHeaders, setupUUID, sendInstallationEvent, setupCorrelationEnabled, telemetryEnabled } from './telemetry.mjs';
9
+ import { configureUpdates, inspectUpdates, recoverUpdates, removeUpdates, runUpdate, withUpdateLock } from './updates.js';
9
10
 
10
11
  // One-command coding-agent setup: device sign-in in the browser, then the MCP
11
12
  // server, startup rule and Scout skill. Built to be run by a person or by a coding agent:
@@ -39,6 +40,12 @@ model. You may continue work that does not commit to a model.
39
40
  Do not consult Scout for unrelated edits, normal inference calls using an unchanged
40
41
  approved model, or this coding agent's own model. Do not reselect approved models
41
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.
42
49
  `;
43
50
 
44
51
  export const skill = `---
@@ -102,16 +109,21 @@ expired or revoked, run \`npx -y openmerit@latest login\` and then
102
109
 
103
110
  export const help = `OpenMerit · coding-agent setup
104
111
 
105
- openmerit install [--client claude-code] [--scope user|project] [--setup-id UUID] [--plan] [--json] [--no-open]
112
+ openmerit install [--client claude-code] [--scope user|project] [--setup-id UUID] [--auto-update on|off] [--plan] [--json] [--no-open]
106
113
  openmerit status [--client claude-code] [--scope user|project] [--check] [--json]
107
114
  openmerit remove [--client claude-code] [--scope user|project] [--plan] [--json]
108
115
  openmerit login [--setup-id UUID] [--json] [--no-open] Replace this device's key
109
116
  openmerit logout [--json] Revoke this device's key
110
117
  openmerit project --policy existing-only|existing-first [--endpoint-id <id> ...] [--plan]
111
118
  openmerit doctor [--json]
119
+ openmerit update [--scope user|project] [--auto on|off] [--json]
112
120
 
113
121
  install signs this device in through the browser (one Approve click), adds the
114
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.
115
127
  It opens the approval page and waits up to 9 minutes for the Approve click
116
128
  (30 seconds when no browser could be opened). If it exits with approval
117
129
  pending, rerun the same command: it resumes the same request.
@@ -196,12 +208,13 @@ async function claude(args, root, key) {
196
208
  async function inspect(root, scope) {
197
209
  const paths = claudePaths(root, scope);
198
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);
199
212
  const owned = [2, 3].includes(record.version) && record.scope === scope;
200
213
  const current = configuredServer(config, root, scope);
201
214
  const otherScope = configuredServer(config, root, scope === 'user' ? 'project' : 'user');
202
215
  const projectFile = await object(join(root, '.mcp.json'));
203
216
  return {
204
- paths, record, owned, current, installedSkill, installedRule,
217
+ paths, record, owned, current, installedSkill, installedRule, updates,
205
218
  // A full pending hash covers interruption without accepting edited headers.
206
219
  // Keep pendingAuth compatibility for records from the earlier installer.
207
220
  serverOwned: !current || owned && (serverHash(current) === record.serverHash || serverHash(current) === record.pendingServerHash || !!record.pendingAuth && hash(current.headers?.Authorization ?? '') === record.pendingAuth),
@@ -214,7 +227,7 @@ async function inspect(root, scope) {
214
227
  }
215
228
 
216
229
  function conflicts(state) {
217
- const found = [];
230
+ const found = [...state.updates.conflicts];
218
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.');
219
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.`);
220
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.`);
@@ -250,13 +263,12 @@ async function login({ client, scope, open, log, wait, telemetry }) {
250
263
  }
251
264
  const approvalURL = new URL(pending.url);
252
265
  // Rebuild consent on every resume, so old query parameters cannot override a
253
- // later refusal. Only opt-in setup IDs are ever sent to the approval page.
266
+ // later refusal. Only analytics-enabled, linked setup IDs reach the page.
254
267
  for (const key of ['setup_id', 'analytics', 'analytics_account']) approvalURL.searchParams.delete(key);
255
268
  if (setupCorrelationEnabled() && setupUUID(telemetry.properties.setup_id)) approvalURL.searchParams.set('setup_id', telemetry.properties.setup_id);
256
- // Browser usage consent is independent of CLI opt-in. Only an explicit
257
- // cross-surface refusal disables it; account unlinking must not disable usage.
258
- if (process.env.DO_NOT_TRACK === '1') approvalURL.searchParams.set('analytics', 'off');
259
- if (process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || process.env.DO_NOT_TRACK === '1') approvalURL.searchParams.set('analytics_account', 'off');
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');
260
272
  pending = { ...pending, url: approvalURL.href, setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
261
273
  await save(pendingPath(), pending);
262
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`);
@@ -315,18 +327,22 @@ async function verify(key, telemetry) {
315
327
  return connection;
316
328
  }
317
329
 
318
- async function install(root, { client, scope, plan, open, log, wait, telemetry }) {
330
+ async function install(root, { client, scope, plan, open, log, wait, telemetry, autoUpdate, json }) {
319
331
  const state = await inspect(root, scope);
332
+ const enabled = autoUpdate === undefined ? state.updates.saved?.enabled ?? true : autoUpdate === 'on';
320
333
  const found = conflicts(state);
321
334
  const changes = [
322
335
  `${state.current ? 'Update' : 'Add'} MCP server "openmerit" in Claude Code (${state.paths.mcpScope} scope, ${state.paths.mcpFile})`,
323
336
  `${state.installedSkill === null ? 'Create' : 'Update'} ${state.paths.skill}`,
324
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}`,
325
340
  ...(state.legacyRule === 'remove' ? [`Remove the unchanged earlier instructions at ${state.paths.legacyRule}`] : []),
326
341
  ...(await credentials() ? [] : [`Sign this device in through the browser and store its key in ${credentialsPath()} (mode 600)`]),
327
342
  ];
328
343
  if (plan) return { action: 'install', applied: false, client, scope, changes, conflicts: found };
329
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')));
330
346
  await claude(['--version'], root);
331
347
  if (setupCorrelationEnabled()) telemetry.properties.installation_id ??= randomUUID();
332
348
  await telemetry.step('preflight', 'ok');
@@ -338,7 +354,7 @@ async function install(root, { client, scope, plan, open, log, wait, telemetry }
338
354
  const next = serverConfig(key, telemetry);
339
355
  const ids = { setup_id: telemetry.properties.setup_id, installation_id: telemetry.properties.installation_id };
340
356
  // Record intent first so an interrupted install is repaired by rerunning.
341
- await save(state.paths.record, { version: 3, scope, client, ...ids, 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) });
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) });
342
358
  telemetry.properties.applied = true;
343
359
  if (serverHash(state.current) !== serverHash(next)) {
344
360
  if (state.current) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root, key);
@@ -350,7 +366,8 @@ async function install(root, { client, scope, plan, open, log, wait, telemetry }
350
366
  // Record what Claude actually wrote, so later edits are detected precisely.
351
367
  const written = configuredServer(await object(state.paths.mcpFile), root, scope);
352
368
  if (!written) throw new Stop('Claude Code did not save the "openmerit" MCP server.', exitCodes.error, 'Run "openmerit doctor".');
353
- await save(state.paths.record, { version: 3, scope, client, ...ids, serverHash: serverHash(written), skillHash: hash(skill), ruleHash: hash(rule), installedAt: new Date().toISOString() });
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() });
354
371
  await telemetry.step('configuration', 'ok', diagnostics(await inspect(root, scope)));
355
372
  const connection = await verify(key, telemetry);
356
373
  if (connection !== 'connected') telemetry.outcome = 'error';
@@ -370,6 +387,8 @@ async function status(root, { client, scope, check, telemetry }) {
370
387
  await telemetry.step('status', telemetry.outcome, { ...details, connection });
371
388
  return { action: 'status', client, scope, installed: details.installed, signedIn: !!saved,
372
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',
373
392
  conflicts: found, connection };
374
393
  }
375
394
 
@@ -396,13 +415,16 @@ async function remove(root, { client, scope, plan, telemetry }) {
396
415
  }
397
416
  const changes = [...(state.current && state.serverOwned ? [`Remove MCP server "openmerit" (${state.paths.mcpScope} scope)`] : []),
398
417
  ...(state.installedSkill !== null && state.skillOwned ? [`Delete ${state.paths.skill}`] : []),
399
- ...(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'];
400
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);
401
422
  if (plan) return { action: 'remove', applied: false, client, scope, changes, keeps: kept };
402
423
  telemetry.properties.applied = true;
403
424
  if (state.current && state.serverOwned) await claude(['mcp', 'remove', '--scope', state.paths.mcpScope, 'openmerit'], root);
404
425
  if (state.installedSkill !== null && state.skillOwned) await rm(dirname(state.paths.skill), { recursive: true, force: true });
405
426
  if (state.installedRule !== null && state.ruleOwned) await rm(state.paths.rule, { force: true });
427
+ await removeUpdates(dirname(state.paths.record), scope, state.record);
406
428
  await rm(state.paths.record, { force: true });
407
429
  const revoked = await revokeKey(telemetry);
408
430
  await telemetry.step('remove', telemetry.outcome, diagnostics(await inspect(root, scope)));
@@ -433,6 +455,7 @@ async function doctor(root, { scope, telemetry }) {
433
455
  if (saved && reach.status === 200) { const connection = await verify(saved.key, telemetry); add('mcp', connection === 'connected', `MCP check: ${connection}`); }
434
456
  const state = await inspect(root, scope).catch(() => null);
435
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.');
436
459
  telemetry.outcome = checks.every(check => check.ok) ? 'ok' : 'error';
437
460
  await telemetry.step('status', telemetry.outcome, { ...(state ? diagnostics(state) : {}), ...(reach.status === 200 ? {} : { code: 'openmerit_unreachable', connection: 'failed' }) });
438
461
  return { action: 'doctor', ok: checks.every(check => check.ok), checks };
@@ -440,7 +463,8 @@ async function doctor(root, { scope, telemetry }) {
440
463
 
441
464
  function render(result) {
442
465
  if (result.action === 'doctor') return result.checks.map(c => `${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}`).join('\n');
443
- 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;
444
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}`;
445
469
  if (result.action === 'login') return result.note;
446
470
  if (result.action === 'logout') return result.note;
@@ -459,7 +483,7 @@ export async function main(args, dependencies = {}) {
459
483
  this.stage = stage;
460
484
  if (outcome === 'ok') delete this.properties.code;
461
485
  Object.assign(this.properties, properties);
462
- await sendInstallationEvent({ ...this.properties, stage, outcome });
486
+ if (args[0] !== 'update') await sendInstallationEvent({ ...this.properties, stage, outcome });
463
487
  },
464
488
  async fail(error) {
465
489
  this.outcome = ({ 10: 'pending', 11: 'denied', 20: 'conflict' })[error.exit] ?? 'error';
@@ -482,8 +506,8 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
482
506
  const [action, ...rest] = args;
483
507
  if (!action || rest.includes('--help') || rest.includes('-h')) { print(help); return exitCodes.ok; }
484
508
  const options = { client: undefined, scope: 'user', plan: false, check: false, json: false, open: true, policy: undefined, ids: [], telemetry };
485
- const allowed = { install: ['--client', '--scope', '--setup-id', '--plan', '--json', '--no-open'], status: ['--client', '--scope', '--check', '--json'], remove: ['--client', '--scope', '--plan', '--json'],
486
- login: ['--setup-id', '--json', '--no-open'], logout: ['--json'], project: ['--policy', '--endpoint-id', '--plan', '--json'], doctor: ['--scope', '--json'] }[action];
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];
487
511
  if (!allowed) throw new Error('Unknown command. Run openmerit --help.');
488
512
  for (let i = 0; i < rest.length; i++) {
489
513
  const option = rest[i];
@@ -494,6 +518,7 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
494
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.'); }
495
519
  else if (option === '--policy') options.policy = value();
496
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; }
497
522
  else if (option === '--no-open') options.open = false;
498
523
  else options[option.slice(2)] = true;
499
524
  }
@@ -529,16 +554,20 @@ async function runMain(args, { root = process.cwd(), print = console.log, printE
529
554
  }
530
555
  await telemetry.step('started', 'started');
531
556
  await telemetry.step('preflight', 'started');
532
- const result = action === 'install' ? await install(root, { ...options, log, wait })
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 }))
533
560
  : action === 'status' ? await status(root, options)
534
- : 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' }) }
535
563
  : action === 'project' ? await project(root, options)
536
564
  : action === 'doctor' ? await doctor(root, options)
537
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.' }
538
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.' });
539
567
  if (result.conflicts?.length && telemetry.outcome !== 'error') { telemetry.outcome = 'conflict'; telemetry.properties.code = 'install_conflict'; }
540
568
  if (options.plan) await telemetry.step(telemetry.stage, telemetry.outcome, { applied: false });
541
- print(options.json ? JSON.stringify({ ok: true, ...result }, null, 2) : render(result));
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;
542
571
  return action === 'doctor' && !result.ok ? exitCodes.error : exitCodes.ok;
543
572
  } catch (error) {
544
573
  if (!(error instanceof Stop)) throw error;
@@ -30,19 +30,19 @@ export function errorProperties(error) {
30
30
  const environment = () => ({ ...(clientVersion ? { client_version: clientVersion } : {}), platform: ['darwin', 'linux', 'win32'].includes(process.platform) ? process.platform : 'unknown', runtime_major: Number(process.versions.node.split('.')[0]) });
31
31
 
32
32
  const anonymousId = randomUUID();
33
- export const telemetryEnabled = () => process.env.OPENMERIT_TELEMETRY === '1' && process.env.DO_NOT_TRACK !== '1';
34
- export const setupCorrelationEnabled = () => telemetryEnabled() && process.env.OPENMERIT_ANALYTICS_ACCOUNT !== 'off';
33
+ export const telemetryEnabled = (defaultEnabled = false) => (process.env.OPENMERIT_TELEMETRY ? process.env.OPENMERIT_TELEMETRY === '1' : defaultEnabled) && process.env.DO_NOT_TRACK !== '1';
34
+ export const setupCorrelationEnabled = () => telemetryEnabled(true) && process.env.OPENMERIT_ANALYTICS_ACCOUNT !== 'off';
35
35
  export const setupUUID = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
36
- // Device/verifier traffic is CLI opt-in. Installed MCP usage retains its existing
37
- // default, but explicit privacy refusals must survive into future agent sessions.
36
+ // Scout setup defaults to analytics on; explicit privacy refusals must survive
37
+ // device approval and future agent sessions. Prompt content stays off.
38
38
  export function setupHeaders(ids = {}, client = 'cli') {
39
39
  return {
40
40
  'X-Scout-Client': client,
41
41
  'X-Scout-Agent': 'claude-code',
42
42
  ...(clientVersion ? { 'X-Scout-Client-Version': clientVersion } : {}),
43
43
  'X-Scout-Analytics-Content': 'off',
44
- ...(client === 'cli' ? { 'X-Scout-Analytics': telemetryEnabled() ? 'on' : 'off' } : process.env.DO_NOT_TRACK === '1' ? { 'X-Scout-Analytics': 'off' } : {}),
45
- ...(process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || process.env.DO_NOT_TRACK === '1' ? { 'X-Scout-Analytics-Account': 'off' } : {}),
44
+ ...(client === 'cli' ? { 'X-Scout-Analytics': telemetryEnabled(true) ? 'on' : 'off' } : !telemetryEnabled(true) ? { 'X-Scout-Analytics': 'off' } : {}),
45
+ ...(process.env.OPENMERIT_ANALYTICS_ACCOUNT === 'off' || !telemetryEnabled(true) ? { 'X-Scout-Analytics-Account': 'off' } : {}),
46
46
  ...(setupCorrelationEnabled() && setupUUID(ids.setup_id) ? { 'X-Scout-Setup-Id': ids.setup_id } : {}),
47
47
  ...(setupCorrelationEnabled() && setupUUID(ids.installation_id) ? { 'X-Scout-Installation-Id': ids.installation_id } : {}),
48
48
  };
@@ -71,7 +71,7 @@ export function installationProperties(input) {
71
71
  return out;
72
72
  }
73
73
  export async function sendInstallationEvent(input) {
74
- if (!telemetryEnabled()) return;
74
+ if (!telemetryEnabled(true)) return;
75
75
  const properties = installationProperties(input);
76
76
  if (!properties) return;
77
77
  if (!setupCorrelationEnabled()) { delete properties.setup_id; delete properties.installation_id; }
@@ -83,7 +83,7 @@ export async function sendInstallationEvent(input) {
83
83
  try {
84
84
  await Promise.race([
85
85
  (async () => {
86
- for (let attempt = 0; attempt < 2 && !controller.signal.aborted && telemetryEnabled(); attempt++) {
86
+ for (let attempt = 0; attempt < 2 && !controller.signal.aborted && telemetryEnabled(true); attempt++) {
87
87
  try {
88
88
  const response = await fetch('https://openmerit.ai/scout/api/analytics', {
89
89
  method: 'POST', headers: { 'Content-Type': 'application/json', ...setupHeaders(properties) },
@@ -150,15 +150,16 @@ async function sendAnalytics(event, properties) {
150
150
  // Consent is independent of terminal discovery. Command identity is ephemeral;
151
151
  // the installer separately manages the owned installation's correlation IDs.
152
152
  export function commandTelemetry(args) {
153
- if (process.env.OPENMERIT_TELEMETRY !== '1' || process.env.DO_NOT_TRACK === '1') return async () => {};
153
+ const setup = ['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor'];
154
+ const enabled = () => telemetryEnabled(setup.includes(args[0]));
155
+ if (!enabled()) return async () => {};
154
156
  const started = performance.now();
155
157
  const command_id = randomUUID();
156
158
  const actions = ['init', 'doctor', 'plan', 'run', 'watch', 'report'];
157
- const setup = ['install', 'status', 'remove', 'login', 'logout', 'project', 'doctor'];
158
159
  const command = !args.length || args.includes('--help') || args.includes('-h') || args[0] === 'eval' && args.length === 1
159
160
  ? 'help' : args[0] === 'dash' ? 'dash' : setup.includes(args[0]) ? args[0] : args[0] === 'eval' && actions.includes(args[1]) ? `eval_${args[1]}` : 'unknown';
160
161
  async function send(event, properties) {
161
- if (!telemetryEnabled()) return;
162
+ if (!enabled()) return;
162
163
  const controller = new AbortController();
163
164
  const body = JSON.stringify({ event, event_id: randomUUID(), anonymousId, properties: { ...properties, ...environment(), command_id } });
164
165
  let timer;
@@ -0,0 +1,62 @@
1
+ type ObjectValue = Record<string, unknown>;
2
+ interface UpdateRecord {
3
+ enabled: boolean;
4
+ version: string;
5
+ runnerHash: string;
6
+ hook: ObjectValue;
7
+ }
8
+ export declare const updateContract = "scout-recommend-v1";
9
+ export declare function updatePaths(base: string, scope: string): {
10
+ record: string;
11
+ runner: string;
12
+ settings: string;
13
+ state: string;
14
+ backup: string;
15
+ skill: string;
16
+ rule: string;
17
+ };
18
+ export declare function withUpdateLock<T>(base: string, work: () => Promise<T>): Promise<T>;
19
+ export declare function inspectUpdates(base: string, scope: string, record: ObjectValue): Promise<{
20
+ paths: {
21
+ record: string;
22
+ runner: string;
23
+ settings: string;
24
+ state: string;
25
+ backup: string;
26
+ skill: string;
27
+ rule: string;
28
+ };
29
+ saved: UpdateRecord | null;
30
+ runner: string | null;
31
+ settings: ObjectValue;
32
+ hooks: ObjectValue;
33
+ conflicts: string[];
34
+ active: boolean;
35
+ }>;
36
+ export declare function configureUpdates(base: string, scope: string, record: ObjectValue, version: string, enabled: boolean): Promise<{
37
+ enabled: boolean;
38
+ version: string;
39
+ runnerHash: string;
40
+ hook: {
41
+ hooks: {
42
+ type: string;
43
+ command: string;
44
+ async: boolean;
45
+ timeout: number;
46
+ }[];
47
+ };
48
+ }>;
49
+ export declare function removeUpdates(base: string, scope: string, record: ObjectValue): Promise<void>;
50
+ export declare function recoverUpdates(base: string, scope: string): Promise<void>;
51
+ export interface UpdateResult {
52
+ status: string;
53
+ message: string;
54
+ version?: string;
55
+ }
56
+ export declare function runUpdate(base: string, scope: string, options?: {
57
+ hook?: boolean;
58
+ enabled?: boolean;
59
+ fetcher?: typeof fetch;
60
+ now?: number;
61
+ }): Promise<UpdateResult>;
62
+ export {};