openmerit 0.1.12 → 0.3.0

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +11 -27
  3. package/dist/pi/scout-extension.mjs +23 -10
  4. package/dist/terminal/scout-update.json +1 -1
  5. package/dist/terminal/src/browser.mjs +2 -4
  6. package/dist/terminal/src/cli.mjs +6 -56
  7. package/dist/terminal/src/install.mjs +3 -2
  8. package/dist/terminal/src/scout-instructions.js +23 -10
  9. package/dist/terminal/src/scout-pi.js +1 -1
  10. package/dist/terminal/src/scout.js +181 -0
  11. package/dist/terminal/src/telemetry.mjs +8 -60
  12. package/docs/agent-evaluations.md +8 -148
  13. package/docs/commands.md +4 -86
  14. package/docs/getting-started.md +2 -16
  15. package/docs/index.md +4 -27
  16. package/docs/inference-terminal.md +13 -655
  17. package/docs/pi-extension.md +1 -1
  18. package/docs/roadmap.md +4 -17
  19. package/docs/scout.md +34 -251
  20. package/docs/security.md +8 -33
  21. package/docs/testing.md +7 -47
  22. package/docs/troubleshooting.md +3 -3
  23. package/package.json +13 -46
  24. package/dist/core/src/index.d.ts +0 -101
  25. package/dist/core/src/index.js +0 -1649
  26. package/dist/core/src/store.d.ts +0 -35
  27. package/dist/core/src/store.js +0 -102
  28. package/dist/eval/src/adapters.mjs +0 -112
  29. package/dist/eval/src/cli.mjs +0 -151
  30. package/dist/eval/src/config.mjs +0 -108
  31. package/dist/eval/src/process.mjs +0 -44
  32. package/dist/eval/src/report.mjs +0 -59
  33. package/dist/eval/src/runner.mjs +0 -123
  34. package/dist/eval/src/scout.mjs +0 -48
  35. package/dist/protocol/src/index.d.ts +0 -484
  36. package/dist/protocol/src/index.js +0 -47
  37. package/dist/protocol/src/schemas.d.ts +0 -576
  38. package/dist/protocol/src/schemas.js +0 -280
  39. package/dist/terminal/public/app.js +0 -298
  40. package/dist/terminal/public/brands/anthropic.png +0 -0
  41. package/dist/terminal/public/brands/baai.png +0 -0
  42. package/dist/terminal/public/brands/baseten.png +0 -0
  43. package/dist/terminal/public/brands/cerebras.png +0 -0
  44. package/dist/terminal/public/brands/cohere.png +0 -0
  45. package/dist/terminal/public/brands/deepseek.ico +0 -0
  46. package/dist/terminal/public/brands/google.png +0 -0
  47. package/dist/terminal/public/brands/groq.ico +0 -0
  48. package/dist/terminal/public/brands/huggingface.svg +0 -8
  49. package/dist/terminal/public/brands/lm-studio.png +0 -0
  50. package/dist/terminal/public/brands/meta.ico +0 -0
  51. package/dist/terminal/public/brands/mistral.png +0 -0
  52. package/dist/terminal/public/brands/nomic.png +0 -0
  53. package/dist/terminal/public/brands/ollama.png +0 -0
  54. package/dist/terminal/public/brands/openai.png +0 -0
  55. package/dist/terminal/public/brands/openrouter.png +0 -0
  56. package/dist/terminal/public/brands/qwen.png +0 -0
  57. package/dist/terminal/public/brands/vllm.ico +0 -0
  58. package/dist/terminal/public/brands/vllm.png +0 -0
  59. package/dist/terminal/public/favicon.svg +0 -1
  60. package/dist/terminal/public/flow.css +0 -1
  61. package/dist/terminal/public/flow.js +0 -770
  62. package/dist/terminal/public/index.html +0 -22
  63. package/dist/terminal/public/styles.css +0 -1046
  64. package/dist/terminal/src/activity-merge.mjs +0 -66
  65. package/dist/terminal/src/collect.mjs +0 -370
  66. package/dist/terminal/src/coverage.mjs +0 -33
  67. package/dist/terminal/src/demo-policy.mjs +0 -205
  68. package/dist/terminal/src/discovery.mjs +0 -99
  69. package/dist/terminal/src/enterprise-sample.mjs +0 -151
  70. package/dist/terminal/src/hardware.mjs +0 -57
  71. package/dist/terminal/src/history.mjs +0 -32
  72. package/dist/terminal/src/log-continuity.mjs +0 -26
  73. package/dist/terminal/src/project-activity.mjs +0 -157
  74. package/dist/terminal/src/repository-background.mjs +0 -37
  75. package/dist/terminal/src/repository-index.mjs +0 -267
  76. package/dist/terminal/src/repository-policy.mjs +0 -3
  77. package/dist/terminal/src/repository-syntax.mjs +0 -236
  78. package/dist/terminal/src/repository-worker.mjs +0 -15
  79. package/dist/terminal/src/sample.mjs +0 -171
  80. package/dist/terminal/src/scout-credentials.d.ts +0 -12
  81. package/dist/terminal/src/scout-credentials.ts +0 -44
  82. package/dist/terminal/src/scout-instructions.d.ts +0 -5
  83. package/dist/terminal/src/scout-instructions.ts +0 -106
  84. package/dist/terminal/src/scout-pi.d.ts +0 -44
  85. package/dist/terminal/src/scout-pi.ts +0 -76
  86. package/dist/terminal/src/server.mjs +0 -95
  87. package/dist/terminal/src/service-registry.mjs +0 -78
  88. package/dist/terminal/src/services.mjs +0 -44
  89. package/dist/terminal/src/topology.mjs +0 -30
  90. package/dist/terminal/src/updates.d.ts +0 -62
  91. package/dist/terminal/src/updates.ts +0 -313
  92. package/docs/adapter-guide.md +0 -191
  93. package/docs/architecture.md +0 -61
  94. package/docs/automation.md +0 -76
  95. package/docs/budgets.md +0 -39
  96. package/docs/demo-backfill.md +0 -31
  97. package/docs/demo-fieldkit.md +0 -49
  98. package/docs/demo-placement.md +0 -32
  99. package/docs/demo-spam.md +0 -17
  100. package/docs/demo-support.md +0 -44
  101. package/docs/demo.md +0 -74
  102. package/docs/first-trial.md +0 -62
  103. package/docs/interactive-demo.md +0 -118
  104. package/docs/lifecycle.md +0 -32
  105. package/docs/metrics-and-evidence.md +0 -48
  106. package/docs/operations.md +0 -40
  107. package/docs/pareto-spec.md +0 -76
  108. package/docs/ux-reference.md +0 -32
@@ -1,313 +0,0 @@
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 { realpathSync } from 'node:fs';
6
- import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
7
- import { dirname, join, resolve } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { gunzipSync } from 'node:zlib';
10
-
11
- type ObjectValue = Record<string, unknown>;
12
- interface Bundle { format: number; contract: string; version: string; skill: string; rule: string }
13
- interface UpdateRecord { enabled: boolean; version: string; runnerHash: string; hook: ObjectValue }
14
- export const updateContract = 'scout-recommend-v1';
15
- const registry = 'https://registry.npmjs.org/openmerit/latest';
16
- const day = 24 * 60 * 60 * 1000;
17
- const retry = 15 * 60 * 1000;
18
- const digest = (text: string | Buffer) => createHash('sha256').update(text).digest('hex');
19
- const isObject = (value: unknown): value is ObjectValue => !!value && typeof value === 'object' && !Array.isArray(value);
20
- const parsed = (text: string): ObjectValue => {
21
- const value: unknown = JSON.parse(text);
22
- if (!isObject(value)) throw new Error('Expected a JSON object.');
23
- return value;
24
- };
25
- async function optional(path: string): Promise<string | null> {
26
- try { return await readFile(path, 'utf8'); }
27
- catch (error) { if (isObject(error) && error.code === 'ENOENT') return null; throw error; }
28
- }
29
- async function object(path: string): Promise<ObjectValue> { return parsed(await optional(path) ?? '{}'); }
30
- async function atomic(path: string, value: unknown) {
31
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
32
- const temp = `${path}.${randomUUID()}.tmp`;
33
- try {
34
- await writeFile(temp, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
35
- await rename(temp, path);
36
- } finally { await rm(temp, { force: true }); }
37
- }
38
- export function updatePaths(base: string, scope: string) {
39
- return {
40
- record: join(base, 'openmerit-install.json'), runner: join(base, 'openmerit-updater.mts'),
41
- settings: join(base, scope === 'user' ? 'settings.json' : 'settings.local.json'),
42
- state: join(base, 'openmerit-updates.json'), backup: join(base, 'openmerit-update-backup.json'),
43
- skill: join(base, 'skills/openmerit-scout/SKILL.md'), rule: join(base, 'rules/openmerit-scout.md'),
44
- };
45
- }
46
- function updateRecord(record: ObjectValue): UpdateRecord | null {
47
- const value = record.updates;
48
- if (!isObject(value) || typeof value.enabled !== 'boolean' || typeof value.version !== 'string'
49
- || typeof value.runnerHash !== 'string' || !isObject(value.hook)) return null;
50
- return value as unknown as UpdateRecord;
51
- }
52
-
53
- // Serialize installs, removals and hooks. A dead process's lock is recoverable;
54
- // an active login is not mistaken for a stale updater just because it is slow.
55
- export async function withUpdateLock<T>(base: string, work: () => Promise<T>): Promise<T> {
56
- await mkdir(base, { recursive: true, mode: 0o700 });
57
- const path = join(base, 'openmerit-update.lock');
58
- let handle;
59
- for (let attempt = 0; attempt < 2; attempt++) {
60
- try { handle = await open(path, 'wx', 0o600); break; }
61
- catch (error) {
62
- if (!isObject(error) || error.code !== 'EEXIST') throw error;
63
- // Only one process may reap a dead lock. Without this guard, two stale
64
- // observers can unlink each other's newly acquired live lock.
65
- const recoveryPath = `${path}.recovery`;
66
- let recovery;
67
- try { recovery = await open(recoveryPath, 'wx', 0o600); }
68
- catch { throw new Error('Another OpenMerit lock recovery is running. If it was interrupted, review openmerit-update.lock.recovery before removing it.'); }
69
- try {
70
- await recovery.writeFile(JSON.stringify({ pid: process.pid }));
71
- const text = await optional(path);
72
- if (text === null) continue;
73
- let alive = true;
74
- try {
75
- const pid = parsed(text).pid;
76
- if (typeof pid === 'number' && pid > 0) {
77
- try { process.kill(pid, 0); }
78
- catch (failure) { if (isObject(failure) && failure.code === 'ESRCH') alive = false; }
79
- } else alive = Date.now() - (await stat(path)).mtimeMs < 10 * 60_000;
80
- } catch { alive = Date.now() - (await stat(path)).mtimeMs < 10 * 60_000; }
81
- if (alive) throw new Error('Another OpenMerit install or update is running. Retry shortly.');
82
- await rm(path, { force: true });
83
- } finally { await recovery.close(); await rm(recoveryPath, { force: true }); }
84
- }
85
- }
86
- if (!handle) throw new Error('Another OpenMerit update is running.');
87
- try { await handle.writeFile(JSON.stringify({ pid: process.pid })); return await work(); }
88
- finally { await handle.close(); await rm(path, { force: true }); }
89
- }
90
-
91
- function groups(settings: ObjectValue): ObjectValue {
92
- if (settings.hooks === undefined) return {};
93
- if (!isObject(settings.hooks)) throw new Error('Claude settings.hooks is not an object; nothing changed.');
94
- return settings.hooks;
95
- }
96
- function eventGroups(hooks: ObjectValue, event: string): unknown[] {
97
- const value = hooks[event];
98
- if (value === undefined) return [];
99
- if (!Array.isArray(value)) throw new Error(`Claude ${event} hooks are not an array; nothing changed.`);
100
- return value;
101
- }
102
- function usesRunner(group: unknown, runner: string): boolean {
103
- return isObject(group) && Array.isArray(group.hooks) && group.hooks.some(hook =>
104
- isObject(hook) && typeof hook.command === 'string' && (hook.command.includes(runner) || hook.command.includes(quote(runner))));
105
- }
106
- export async function inspectUpdates(base: string, scope: string, record: ObjectValue) {
107
- const paths = updatePaths(base, scope), saved = updateRecord(record);
108
- const pending = updateRecord({ updates: record.pendingUpdates });
109
- const owners = [saved, pending].filter(value => value !== null);
110
- const runner = await optional(paths.runner), settings = await object(paths.settings), hooks = groups(settings);
111
- const conflicts: string[] = [];
112
- if (runner !== null && !owners.some(owner => digest(runner) === owner.runnerHash)) conflicts.push('OpenMerit updater was edited or is not owned; nothing changed.');
113
- for (const event of ['SessionStart', 'Stop']) {
114
- for (const group of eventGroups(hooks, event)) {
115
- if (usesRunner(group, paths.runner) && !owners.some(owner => JSON.stringify(group) === JSON.stringify(owner.hook))) {
116
- conflicts.push(`OpenMerit ${event} hook was edited or is not owned; nothing changed.`);
117
- }
118
- }
119
- }
120
- const active = runner !== null && ['SessionStart', 'Stop'].every(event =>
121
- eventGroups(hooks, event).some(group => owners.some(owner => JSON.stringify(group) === JSON.stringify(owner.hook))));
122
- return { paths, saved: saved ?? pending, runner, settings, hooks, conflicts, active };
123
- }
124
- // Claude Code uses a shell for command hooks, including Git Bash on Windows.
125
- const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
126
- export async function configureUpdates(base: string, scope: string, record: ObjectValue, version: string, enabled: boolean) {
127
- const state = await inspectUpdates(base, scope, record);
128
- if (state.conflicts.length) throw new Error(state.conflicts.join('\n'));
129
- const source = await readFile(new URL('./updates.ts', import.meta.url), 'utf8');
130
- const hook = { hooks: [{ type: 'command', command: `${quote(process.execPath)} ${quote(state.paths.runner)} --base ${quote(base)} --scope ${scope} --hook`, async: true, timeout: 150 }] };
131
- const hooks = { ...state.hooks };
132
- for (const event of ['SessionStart', 'Stop']) {
133
- const kept = eventGroups(hooks, event).filter(group => !usesRunner(group, state.paths.runner));
134
- if (enabled) kept.push(hook);
135
- if (kept.length) hooks[event] = kept; else delete hooks[event];
136
- }
137
- // Avoid clobbering settings that changed while the installer was signing in.
138
- const latest = await object(state.paths.settings);
139
- if (JSON.stringify(latest) !== JSON.stringify(state.settings)) throw new Error('Claude settings changed during setup. Retry.');
140
- const next = { enabled, version, runnerHash: digest(source), hook };
141
- await atomic(state.paths.record, { ...await object(state.paths.record), pendingUpdates: next });
142
- await atomic(state.paths.runner, source);
143
- await atomic(state.paths.settings, { ...state.settings, hooks });
144
- return next;
145
- }
146
- export async function removeUpdates(base: string, scope: string, record: ObjectValue) {
147
- const state = await inspectUpdates(base, scope, record);
148
- const hooks = { ...state.hooks };
149
- for (const event of ['SessionStart', 'Stop']) {
150
- const kept = eventGroups(hooks, event).filter(group => !state.saved || JSON.stringify(group) !== JSON.stringify(state.saved.hook));
151
- if (kept.length) hooks[event] = kept; else delete hooks[event];
152
- }
153
- if (state.saved) await atomic(state.paths.settings, { ...state.settings, hooks });
154
- const stillReferenced = ['SessionStart', 'Stop'].some(event => eventGroups(hooks, event).some(group => usesRunner(group, state.paths.runner)));
155
- if (!stillReferenced && state.runner !== null && state.saved && digest(state.runner) === state.saved.runnerHash) await rm(state.paths.runner, { force: true });
156
- await rm(state.paths.state, { force: true });
157
- }
158
-
159
- // Read one fixed regular file in memory, never extract archive paths onto disk.
160
- function instructionBundle(bytes: Buffer): ObjectValue | null {
161
- const tar = gunzipSync(bytes, { maxOutputLength: 32 * 1024 * 1024 });
162
- let found: ObjectValue | null = null;
163
- for (let offset = 0; offset + 512 <= tar.length;) {
164
- const header = tar.subarray(offset, offset + 512);
165
- if (header.every(byte => byte === 0)) break;
166
- const name = header.subarray(0, 100).toString().replace(/\0.*$/s, '');
167
- const rawSize = header.subarray(124, 136).toString().replace(/\0.*$/s, '').trim();
168
- if (!/^[0-7]+$/.test(rawSize)) throw new Error('Invalid package archive.');
169
- const size = Number.parseInt(rawSize, 8), start = offset + 512;
170
- if (start + size > tar.length) throw new Error('Truncated package archive.');
171
- if (name === 'package/dist/terminal/scout-update.json') {
172
- if (found || size > 256 * 1024 || ![0, 48].includes(header[156] ?? -1)) throw new Error('Invalid instruction bundle.');
173
- found = parsed(tar.subarray(start, start + size).toString('utf8'));
174
- }
175
- offset = start + Math.ceil(size / 512) * 512;
176
- }
177
- return found;
178
- }
179
- const stableVersion = (version: string) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version);
180
- function newer(next: string, installed: string) {
181
- if (!stableVersion(next) || !stableVersion(installed)) return false;
182
- const a = next.split('.').map(Number), b = installed.split('.').map(Number);
183
- for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i]! > b[i]!; }
184
- return false;
185
- }
186
- async function boundedFetch(url: string, max: number, fetcher: typeof fetch, timeout = 12_000): Promise<Buffer> {
187
- const controller = new AbortController();
188
- let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
189
- let timer: ReturnType<typeof setTimeout> | undefined;
190
- const deadline = new Promise<never>((_, reject) => {
191
- timer = setTimeout(() => { controller.abort(); reject(new Error('Update download timed out.')); }, timeout);
192
- });
193
- const download = async () => {
194
- const response = await fetcher(url, { redirect: 'error', signal: controller.signal, headers: { Accept: 'application/json' } });
195
- if (!response.ok || !response.body || controller.signal.aborted) throw new Error('Update server unavailable.');
196
- const parts: Uint8Array[] = []; let size = 0;
197
- reader = response.body.getReader();
198
- for (;;) {
199
- const { value, done } = await reader.read();
200
- if (done) break;
201
- size += value.length;
202
- if (size > max) throw new Error('Update download exceeded its size limit.');
203
- parts.push(value);
204
- }
205
- return Buffer.concat(parts);
206
- };
207
- try {
208
- return await Promise.race([download(), deadline]);
209
- } finally {
210
- clearTimeout(timer); controller.abort();
211
- // Cancellation itself must not keep a timed-out hook alive.
212
- void reader?.cancel().catch(() => {});
213
- }
214
- }
215
- async function recover(base: string, scope: string) {
216
- const paths = updatePaths(base, scope), text = await optional(paths.backup);
217
- if (!text) return;
218
- const backup = parsed(text);
219
- if (typeof backup.skill !== 'string' || typeof backup.rule !== 'string' || !isObject(backup.record)) throw new Error('Update recovery needs manual review.');
220
- for (const field of ['skill', 'rule'] as const) {
221
- const current = await optional(paths[field]);
222
- if (current !== null && digest(current) !== digest(backup[field] as string) && digest(current) !== backup[`${field}Hash`]) throw new Error('An interrupted update has user edits; nothing changed.');
223
- }
224
- // Recover the previous complete pair before any retry. Atomic per-file writes
225
- // plus a durable journal cover failure between the two renames.
226
- await atomic(paths.skill, backup.skill); await atomic(paths.rule, backup.rule);
227
- await atomic(paths.record, backup.record); await rm(paths.backup, { force: true });
228
- }
229
- export async function recoverUpdates(base: string, scope: string) { await recover(base, scope); }
230
-
231
- export interface UpdateResult { status: string; message: string; version?: string }
232
- export async function runUpdate(base: string, scope: string, options: { hook?: boolean; enabled?: boolean; fetcher?: typeof fetch; now?: number } = {}): Promise<UpdateResult> {
233
- return withUpdateLock(base, async () => {
234
- await recover(base, scope);
235
- const paths = updatePaths(base, scope), record = await object(paths.record), saved = updateRecord(record);
236
- if (record.version !== 3 || record.scope !== scope || !saved) return { status: 'manual_required', message: 'Run npx -y openmerit@latest install --client claude-code to bootstrap updates in this scope.' };
237
- if (options.hook && (!saved.enabled || process.env.OPENMERIT_AUTO_UPDATE === 'off')) return { status: 'disabled', message: 'Automatic updates disabled.' };
238
- const now = options.now ?? Date.now(), state = options.enabled === undefined ? await object(paths.state) : {};
239
- if (options.hook && typeof state.nextCheckAt === 'number' && now < state.nextCheckAt) return { status: 'not_due', message: 'Update check not due.' };
240
- const support = await inspectUpdates(base, scope, record);
241
- if (support.conflicts.length) {
242
- if (options.hook) await atomic(paths.state, { nextCheckAt: now + day, status: 'conflict' });
243
- return { status: 'conflict', message: `${support.conflicts.join(' ')} Say “update openmerit” after resolving the conflict.` };
244
- }
245
- if (options.enabled !== undefined) {
246
- record.updates = await configureUpdates(base, scope, record, saved.version, options.enabled);
247
- delete record.pendingUpdates;
248
- await atomic(paths.record, record);
249
- return { status: options.enabled ? 'enabled' : 'disabled', message: `Automatic Scout instruction updates ${options.enabled ? 'enabled' : 'disabled'}. Start a new Claude Code session to reload hooks.` };
250
- }
251
- // Record the attempt before networking so a crashed hook cannot hammer npm.
252
- await atomic(paths.state, { ...state, nextCheckAt: now + retry });
253
- try {
254
- const fetcher = options.fetcher ?? fetch;
255
- const release = parsed((await boundedFetch(registry, 512 * 1024, fetcher)).toString('utf8'));
256
- const version = release.version;
257
- if (release.name !== 'openmerit' || typeof version !== 'string' || !stableVersion(version)) throw new Error('Invalid stable release metadata.');
258
- if (!newer(version, saved.version)) {
259
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'current' });
260
- return { status: 'current', version: saved.version, message: `Scout instructions ${saved.version} are current.` };
261
- }
262
- const dist = release.dist;
263
- const tarball = `https://registry.npmjs.org/openmerit/-/openmerit-${version}.tgz`;
264
- if (!isObject(dist) || dist.tarball !== tarball || typeof dist.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(dist.integrity)) throw new Error('Release has no trusted SHA-512 package integrity.');
265
- const bytes = await boundedFetch(tarball, 16 * 1024 * 1024, fetcher, 120_000);
266
- if (`sha512-${createHash('sha512').update(bytes).digest('base64')}` !== dist.integrity) throw new Error('Package integrity verification failed.');
267
- const bundle = instructionBundle(bytes);
268
- if (!bundle || bundle.format !== 1 || bundle.contract !== updateContract || bundle.version !== version) {
269
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'manual_required' });
270
- 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.` };
271
- }
272
- if (typeof bundle.skill !== 'string' || typeof bundle.rule !== 'string' || !bundle.skill.startsWith('---\nname: openmerit-scout\n') || !bundle.rule.trim()) throw new Error('Invalid Scout instructions.');
273
- const instructions = bundle as unknown as Bundle;
274
- const skill = await optional(paths.skill), rule = await optional(paths.rule);
275
- if (skill === null || rule === null || digest(skill) !== record.skillHash || digest(rule) !== record.ruleHash) {
276
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'conflict' });
277
- return { status: 'conflict', message: 'Scout instructions are missing or edited. Nothing changed. Say “update openmerit” after reviewing the files.' };
278
- }
279
- const skillHash = digest(instructions.skill), ruleHash = digest(instructions.rule);
280
- await atomic(paths.backup, { record, skill, rule, skillHash, ruleHash });
281
- try {
282
- // Do not overwrite edits made during the download or journal write.
283
- if (await optional(paths.skill) !== skill || await optional(paths.rule) !== rule) throw new Error('Scout instructions changed during the update.');
284
- await atomic(paths.skill, instructions.skill); await atomic(paths.rule, instructions.rule);
285
- await atomic(paths.record, { ...record, skillHash, ruleHash, updates: { ...saved, version } });
286
- await rm(paths.backup, { force: true });
287
- } catch (error) {
288
- try { await recover(base, scope); }
289
- catch { 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.' }; }
290
- throw error;
291
- }
292
- // A failed schedule write must not misreport an already applied update.
293
- await atomic(paths.state, { checkedAt: now, nextCheckAt: now + day, status: 'updated', version }).catch(() => {});
294
- 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.` };
295
- } catch {
296
- await atomic(paths.state, { nextCheckAt: now + retry, status: 'retry' });
297
- 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.' };
298
- }
299
- });
300
- }
301
-
302
- // Hooks deliberately never ask Claude to continue, read transcripts or emit
303
- // blocking decisions. Offline and concurrent-hook failures exit successfully.
304
- if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
305
- const args = process.argv.slice(2), base = args[args.indexOf('--base') + 1], scope = args[args.indexOf('--scope') + 1];
306
- if (base && ['user', 'project'].includes(scope ?? '')) {
307
- process.stdin.resume();
308
- try {
309
- const result = await runUpdate(resolve(base), scope!, { hook: args.includes('--hook') });
310
- if (['updated', 'conflict', 'manual_required', 'retry'].includes(result.status)) console.log(JSON.stringify({ systemMessage: result.message }));
311
- } catch { /* Maintenance cannot block the user's task. */ }
312
- }
313
- }
@@ -1,191 +0,0 @@
1
- # Building a harness adapter
2
-
3
- **Evaluation coordinator boundary:** this guide remains applicable to custom harnesses. Pi-specific examples refer to the historical Pi 0.87 evaluation adapter. The unreleased Pi 1.5.0 `openmerit/pi` export is instead a thin Scout adapter and does not implement `HarnessAdapter`; see [native MCP and SDK embedding](pi-extension.md#sdk-embedding).
4
-
5
- OpenMerit adapters are transport bridges. They do not reproduce OpenMerit's lifecycle, validation, persistence, or Pareto rules, and OpenMerit does not reproduce the harness's intelligence.
6
-
7
- ## Required components
8
-
9
- An adapter provides a `HarnessAdapter` with:
10
-
11
- - a stable harness descriptor;
12
- - the OpenMerit protocol versions it supports;
13
- - declared capabilities;
14
- - supported execution modes;
15
- - a `dispatch` method that accepts a canonical `OpenMeritIntent` and returns an acceptance receipt.
16
-
17
- ```ts
18
- import {
19
- OpenMeritCoordinator,
20
- ProjectStore,
21
- type HarnessAdapter,
22
- } from "openmerit/core";
23
- import { PROTOCOL_VERSION, OPENMERIT_SCHEMA_DIALECT } from "openmerit/protocol";
24
-
25
- const adapter: HarnessAdapter = {
26
- descriptor: {
27
- id: "my-harness",
28
- name: "My coding harness",
29
- version: "1.0.0",
30
- protocolVersions: [PROTOCOL_VERSION],
31
- capabilities: [
32
- "user_confirmation",
33
- "evaluation_authoring",
34
- "observability_instrumentation",
35
- "production_observation",
36
- "candidate_discovery",
37
- "challenger_execution",
38
- "frontier_calculation",
39
- "model_mutation",
40
- "post_swap_verification",
41
- "rollback",
42
- ],
43
- executionModes: ["interactive"],
44
- structuredOutput: {
45
- schemaDialect: OPENMERIT_SCHEMA_DIALECT,
46
- presentation: "adapter_validation",
47
- enforcement: "adapter",
48
- },
49
- automation: {
50
- supportedSignals: ["product_task_completed", "metric_window_available", "scheduled_tick", "model_catalog_changed", "verification_window_completed"],
51
- persistentScheduling: false,
52
- backgroundExecution: false,
53
- wakeupProvisioning: "none",
54
- },
55
- },
56
- async dispatch(intent) {
57
- await myHarness.enqueue(intent);
58
- return { accepted: true, harnessJobId: intent.id };
59
- },
60
- };
61
-
62
- const coordinator = new OpenMeritCoordinator(
63
- new ProjectStore(process.cwd()),
64
- adapter,
65
- );
66
- ```
67
-
68
- `ProjectStore` is the included local-filesystem backend. A remote or hosted integration can implement the `OpenMeritStore` interface instead.
69
-
70
- The confirmed task profile identifies the application LLM call under evaluation. Every metric, assessment, candidate, frontier artifact, and automation signal must carry that target ID; the harness's own model usage is orchestration data and must not be reported as product evidence. Application-provider cost, token, and latency telemetry belongs on the application signal or metric record.
71
-
72
- Setup may return `preliminaryModelLeads`: model IDs with sourced price estimates and reputation summaries. The field is optional under protocol 0.1 for older adapters. Core stores the leads and includes both the confirmed task profile and these leads in later discovery intent constraints, so a headless harness run does not depend on prior chat context. Leads are research priors, never measured candidate assessments.
73
-
74
- ## External telemetry export
75
-
76
- OpenMerit always writes its canonical, redacted audit stream locally. A host integration may pass `eventExporters` to `ProjectStore` to mirror those same safe events to OpenTelemetry, Langfuse, LangSmith, or another observability backend:
77
-
78
- ```ts
79
- const store = new ProjectStore(projectRoot, {
80
- eventExporters: [{
81
- id: "my-otel-bridge",
82
- async export(event) {
83
- await telemetry.emit("openmerit.audit", event);
84
- },
85
- }],
86
- });
87
- ```
88
-
89
- Exporters are not the durable source of truth. They are best effort, and failures are recorded locally without blocking OpenMerit decisions.
90
-
91
- ## Dispatching work
92
-
93
- ```ts
94
- const issued = await coordinator.issue("establish_evals");
95
- ```
96
-
97
- The coordinator builds the canonical intent, checks the descriptor's protocol and capabilities, creates durable state, records audit events, and calls the adapter. The harness decides how to execute the job.
98
-
99
- If a required capability is absent, dispatch is rejected before harness work starts.
100
-
101
- ## Reporting progress
102
-
103
- A harness may send durable lifecycle progress through `recordLifecycle`:
104
-
105
- ```ts
106
- await coordinator.recordLifecycle({
107
- protocolVersion: PROTOCOL_VERSION,
108
- intentId: issued.intent.id,
109
- status: "running",
110
- occurredAt: new Date().toISOString(),
111
- message: "Generating representative evaluation cases",
112
- });
113
- ```
114
-
115
- Use `waiting_for_user` when an interactive confirmation is required but cannot be completed in the current execution mode.
116
-
117
- ## Completing work
118
-
119
- Each intent kind has a typed output in `IntentOutputMap`. Return an `IntentResult<K>` with durable evidence references, then submit it to the coordinator:
120
-
121
- ```ts
122
- await coordinator.acceptResult(result);
123
- ```
124
-
125
- The coordinator validates the active intent, protocol version, evidence references, and intent-specific output, including applicable frontier and candidate-identity checks, before updating lifecycle state. Adapters must not duplicate this logic. The harness remains responsible for performing only the authorized work; result validation does not sandbox its tools or authenticate external evidence.
126
-
127
- ### Present the canonical result schema
128
-
129
- Import `completionToolSchemaFor(intent.kind)` from `openmerit/protocol` and present it through the harness's strongest structured-output mechanism. Do not recreate result schemas inside an adapter. A harness that supports changing tool definitions should specialize the completion tool whenever an intent becomes active; other harnesses may expose intent-specific tools or perform adapter-side validation.
130
-
131
- Declare the behavior in `HarnessDescriptor.structuredOutput`:
132
-
133
- - `schemaDialect` identifies the canonical OpenMerit JSON Schema dialect;
134
- - `presentation` states whether the adapter uses a dynamic tool, a static union, or adapter validation;
135
- - `enforcement` states whether validation occurs at the provider and harness, harness only, or adapter only.
136
-
137
- Schema validation catches malformed structure. The coordinator still owns semantic checks such as authorized candidate identity, metric readiness for frontier comparisons, lifecycle consistency, and Pareto conformance.
138
-
139
- ## Reporting normal work and catalogue changes
140
-
141
- After a real product task completes:
142
-
143
- ```ts
144
- await coordinator.recordTaskObservation({
145
- modelId: "vendor/model",
146
- });
147
-
148
- // Core has already checked baseline
149
- // sufficiency when the confirmed cadence became due. Inspect durable state
150
- // and dispatch only a pending downstream intent, if any.
151
- ```
152
-
153
- `recordTaskObservation` resolves the confirmed application target from project state. New adapters should prefer `recordAutomationSignal` so the task signal can carry the explicit `targetId`, durable evidence, and application-provider telemetry.
154
-
155
- When the harness's available model catalogue changes, compute a stable fingerprint and call `recordModelCatalogFingerprint`. The returned `reassessmentDue` flag tells the adapter whether the lifecycle justifies candidate discovery.
156
-
157
- New adapters should use `recordAutomationSignal` for product tasks, metric windows, scheduled ticks, catalogue observations, and verification windows. Signal IDs must remain stable across delivery retries. If the returned decision issues an intent, dispatch it through the coordinator. `recordTaskObservation` and `recordModelCatalogFingerprint` remain compatibility helpers.
158
-
159
- Call `reconcileAutomation` after configuration changes or when restoring a project. It returns an `AutomationPlan`. Implement `scheduleWakeup` only when the harness can genuinely arrange the requested wakeup. Declare persistent scheduling, background execution, supported signal sources, and provisioning requirements accurately in `HarnessDescriptor.automation`.
160
-
161
- If persistent scheduling is unavailable, expose the plan's active-session or external-scheduler fallback. Do not report a seven-day schedule as configured merely because the adapter checks policy when an interactive session starts.
162
-
163
- ## Capability mapping
164
-
165
- | Intent | Required capability |
166
- | --- | --- |
167
- | `establish_evals` | confirmation, evaluation authoring, observability instrumentation |
168
- | `instrument_observability` | observability instrumentation |
169
- | `run_assessment` | production observation |
170
- | `discover_candidates` | candidate discovery |
171
- | `run_challenger_trials` | challenger execution |
172
- | `calculate_frontier` | frontier calculation |
173
- | `investigate_regression` | production observation |
174
- | `apply_model_swap` | model mutation |
175
- | `verify_model_swap` | post-swap verification |
176
- | `rollback_model_swap` | rollback |
177
-
178
- ## Conformance checklist
179
-
180
- An adapter is ready when it:
181
-
182
- 1. advertises only capabilities it can actually execute;
183
- 2. preserves intent IDs and protocol versions;
184
- 3. never reports success without durable evidence;
185
- 4. returns the typed output for every supported intent;
186
- 5. performs model mutation only when the intent explicitly authorizes it;
187
- 6. reports ordinary product tasks without counting OpenMerit orchestration turns;
188
- 7. passes a durable issue-and-complete cycle using `OpenMeritCoordinator`;
189
- 8. proves the requested side effect, not merely a generated description.
190
-
191
- The fake non-Pi adapter in `packages/core/test/adapter-conformance.test.ts` is the executable reference.
@@ -1,61 +0,0 @@
1
- # Architecture
2
-
3
- **Source status:** the Pi 1.5.0 native Scout replacement is unreleased and unpublished. The protocol/core evaluation coordinator described here remains available for custom harnesses; it is not invoked by the new Pi adapter. The standalone evaluation CLI and Inference Terminal are unchanged.
4
-
5
- ## Design principle
6
-
7
- OpenMerit is an orchestrator, not an intelligence layer. The product application LLM call is the system under test; the coding harness is the evaluator and execution environment.
8
-
9
- ```text
10
- User or lifecycle event
11
- |
12
- v
13
- OpenMerit deterministic orchestration
14
- |
15
- | typed intent: outcome, constraints, evidence
16
- v
17
- Custom evaluation harness
18
- |
19
- | intelligent work, tools, evals, calculation
20
- v
21
- Typed result and durable evidence references
22
- |
23
- v
24
- OpenMerit validation, policy, persistence, next nudge
25
- ```
26
-
27
- An automatic OpenMerit action means automatic issuance of an approved intent. OpenMerit does not perform candidate research, evaluate quality, calculate a frontier, or mutate model configuration.
28
-
29
- ## Packages
30
-
31
- ### `openmerit/protocol`
32
-
33
- Defines the JSON-safe boundary: durable intents, evidence references, metrics, task profiles, candidate assessments, frontier snapshots, budgets, policies, state, and audit events.
34
-
35
- It also owns the versioned runtime schema registry for all intent results. Harness adapters consume these schemas rather than defining their own result shapes, report their structured-output capabilities, and may present the active schema using harness-native tools or adapter validation. A confirmed application LLM target is carried through profiles, metrics, candidates, frontier results, automation signals, and swaps; the harness model is not a valid substitute target.
36
-
37
- ### `openmerit/core`
38
-
39
- Contains deterministic behavior: choosing the next lifecycle intent according to confirmation and rollback policy, persisting state, and checking harness-produced frontiers against the normative definition. The verifier rejects invalid harness results; it is not a model-selection engine.
40
-
41
- `OpenMeritCoordinator` is the reusable adapter boundary. It constructs canonical intents, negotiates capabilities, records lifecycle progress and observations, validates results, and advances durable state. `OpenMeritStore` makes persistence replaceable; `ProjectStore` is the bundled filesystem backend.
42
-
43
- The coordinator also accepts idempotent application-target automation signals and evaluates the user-confirmed `AutomationCheckPolicy`. `buildAutomationPlan` tells an adapter which signals and wakeups are required and reports unsupported persistent scheduling honestly. Provisioning belongs to a custom adapter; the coordinator itself does not create operating-system schedulers or paid cloud workers. The removed Pi 0.87 adapter's LaunchAgent is historical functionality, not part of the new Scout connection.
44
-
45
- ### `openmerit/pi`
46
-
47
- Exports `createScoutPiExtension` and a default native Scout extension for Pi 1.5.0. It registers the authenticated Scout HTTP MCP server through Pi and adds shared rule/skill prompt sections. Pi owns discovery, tools, permissions, model and sessions; SDK hosts must load Pi's `createMcpExtension()` explicitly alongside it.
48
-
49
- The package no longer implements `HarnessAdapter`, exposes lifecycle commands, or schedules evaluation work. It shares credential and instruction code with the installer instead of duplicating an MCP client or evaluation coordinator. See [Pi installation and migration](pi-extension.md).
50
-
51
- ### Inference Terminal
52
-
53
- `packages/terminal` supplies a standalone `openmerit dash` executable, passive collectors, a loopback read-only HTTP API, and bundled browser assets. Stable npm 0.1.6 includes the terminal. The source interface and hosted dashboard follow the device’s light/dark preference; that appearance change is not included in npm 0.1.6. It does not invoke the coordinator or harness. One collector cycle reads existing metadata and runtime inventory; browser clients receive the same cached snapshot. Imported activity, runtime counters, and sampled whole-machine resources retain their separate scopes. Missing measurements remain unknown, and fictional sample data is isolated from the real snapshot. See the [terminal guide](inference-terminal.md) for the supported sources and JSON contract.
54
-
55
- ## Intelligence resources
56
-
57
- Models, web research, tools, evaluators, and fast decision systems such as Jev System One are harness resources. They are configured and invoked by the host harness. This keeps the coordinator provider-neutral and lets custom adapters choose their own implementation.
58
-
59
- ## Trust boundary
60
-
61
- The harness is trusted to perform the work and return honest evidence. OpenMerit reduces unsupported claims through structured outputs, evidence references, compatible profile revisions, complete comparisons, and conservative uncertainty handling. It cannot prove that an arbitrary external evidence URI contains truthful data.
@@ -1,76 +0,0 @@
1
- # Harness-neutral automation
2
-
3
- **Core reference / historical Pi workflow:** the unreleased Pi 1.5.0 Scout adapter replaces the Pi 0.87 lifecycle adapter. It has no signals, `/openmerit` commands, scheduler, headless wakeups, or LaunchAgent. Pi-specific provisioning instructions below are historical, not available in the replacement. The coordinator's policy and signal APIs remain available to custom harnesses. See [current Pi setup and migration](pi-extension.md); the standalone [evaluation watcher](agent-evaluations.md) is unchanged.
4
-
5
- The user defines cadence, thresholds, budget, scheduling mode, and automation permission. OpenMerit stores that policy, evaluates durable signals, and decides whether a bounded intent is due. A harness adapter arranges native wakeups and reports events. The coding harness performs the resulting intelligent work.
6
-
7
- ```text
8
- User-confirmed policy
9
- ↓
10
- OpenMerit due-check engine
11
- ↓
12
- Harness-native wakeup or lifecycle hook
13
- ↓
14
- Standard automation signal
15
- ↓
16
- No work due, or one typed OpenMerit intent
17
- ```
18
-
19
- ## Check policy
20
-
21
- `AutomationCheckPolicy` independently configures:
22
-
23
- - baseline assessment cadence;
24
- - frontier reassessment cadence and catalogue-change behavior;
25
- - regression-check cadence and per-metric relative deterioration thresholds;
26
- - post-swap verification windows;
27
- - a cooldown between mature-system checks;
28
- - active-session or persistent execution;
29
- - whether wakeup provisioning may make infrastructure changes.
30
-
31
- Task-count and elapsed-time triggers use OR semantics. A policy with `afterCompletedTasks: 100` and `afterElapsedSeconds: 604800` becomes due at the first threshold reached. A scheduled wakeup does not directly launch expensive work: it submits `scheduled_tick`, and OpenMerit evaluates policy and durable state before issuing an intent.
32
-
33
- The baseline elapsed-time clock starts when setup or required instrumentation finishes. Ordinary task and metric signals do not restart that clock. OpenMerit retains the latest reported aggregate window for each application metric, including sample count and evidence references. It never sums possibly overlapping windows. At a due baseline check, core requires ready instrumentation and a finite measured value, enough samples, and an evidence reference for every required objective. An insufficient check records the gaps and waits for more data; sufficient evidence advances to candidate discovery.
34
-
35
- ## Signals
36
-
37
- Adapters may report:
38
-
39
- - `product_task_completed`;
40
- - `metric_window_available`;
41
- - `scheduled_tick`;
42
- - `model_catalog_changed`;
43
- - `verification_window_completed`.
44
-
45
- Signal IDs are idempotency keys. OpenMerit persists a bounded history, does not count duplicate task completions twice, and keeps due work pending until an adapter successfully dispatches it.
46
-
47
- ## Capability negotiation
48
-
49
- Every `HarnessDescriptor` declares supported signals, persistent scheduling, background execution, and whether wakeup provisioning is native, unavailable, or requires an infrastructure change. OpenMerit produces an `AutomationPlan` containing required signals, the next wakeup, scheduling strategy, and explicit gaps.
50
-
51
- If persistent scheduling is requested but unsupported, the plan says `external_scheduler_required`. The adapter must not claim that a schedule exists. Active-session adapters can submit `scheduled_tick` when they start. An external scheduler may also wake a non-interactive harness and submit the same signal.
52
-
53
- OpenMerit calls `scheduleWakeup` only when the confirmed policy requests persistent execution, the adapter declares persistent scheduling and background execution, and any infrastructure-changing provisioning has been authorized. Paid agents, system scheduler installation, and cloud infrastructure remain harness implementation details and authorization boundaries.
54
-
55
- ## Intent mapping
56
-
57
- OpenMerit owns deterministic mapping from due conditions to intents:
58
-
59
- | Due condition | Intent |
60
- | --- | --- |
61
- | Baseline cadence | Core checks stored baseline-window sufficiency; if ready, `discover_candidates` |
62
- | Mature frontier cadence or changed catalogue | `discover_candidates` |
63
- | Regression cadence or crossed metric threshold | `investigate_regression` |
64
- | Completed post-swap window | `verify_model_swap` |
65
-
66
- Subsequent lifecycle transitions continue through candidate trials, frontier calculation, supervised swap, verification, or rollback. The harness executes those intents; OpenMerit validates their structured result and evidence.
67
-
68
- ## Pi behavior
69
-
70
- Pi reports catalogue and scheduled-tick signals on session start. Application instrumentation submits product-task, metric-window, and post-swap verification signals through its typed `openmerit_report_signal` tool; Pi's own settled-turn telemetry is not treated as product evidence.
71
-
72
- On macOS, a confirmed policy with `execution.mode: "persistent"` and `infrastructureChangesAllowed: true` allows the Pi adapter to install a per-project user LaunchAgent. It uses the model selected in the interactive Pi session and checks that Pi authentication works without shell-only credentials. The agent checks local state every five minutes without calling a model. When a baseline check is due, the runner submits `scheduled_tick` directly to core: an insufficient result exits without starting Pi; a sufficient result starts Pi for candidate discovery. The Mac must be logged in; this is not a cloud service, an exact-time alarm, or a source of product metrics. Task-count and metric-window checks still depend on application signals.
73
-
74
- Background candidate work remains experimental. A local runner test verifies that an insufficient baseline check needs no Pi process. Candidate discovery, comparison, and swapping from a background Pi run are not yet product-validated. A background Pi run is capped at 15 minutes and writes its exit code and any still-active intent ID to `.openmerit/scheduler-status.json`; an unfinished intent requires inspection and explicit recovery in Pi.
75
-
76
- `/openmerit pause` persists the pause, unloads the project's LaunchAgent, and prevents automatic due decisions while still recording incoming signals. `/openmerit resume` clears the pause and restores an authorized schedule. Neither command cancels already-active work. A declined or failed scheduler installation is reported as a gap, not success. On other platforms, persistent policies still require an external scheduler.