clembot-doorman 0.1.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +951 -0
  4. package/WALKTHROUGH.md +224 -0
  5. package/doorman/.claude/hooks/mcp-gate.sh +205 -0
  6. package/doorman/.claude/settings.json +16 -0
  7. package/doorman/.claude-plugin/plugin.json +22 -0
  8. package/doorman/.mcp.json +24 -0
  9. package/doorman/README.md +259 -0
  10. package/doorman/agents/doorman.md +104 -0
  11. package/doorman/cli/agents.mjs +128 -0
  12. package/doorman/cli/allow.mjs +128 -0
  13. package/doorman/cli/cost.mjs +119 -0
  14. package/doorman/cli/discover.mjs +265 -0
  15. package/doorman/cli/doctor.mjs +282 -0
  16. package/doorman/cli/doorman.mjs +345 -0
  17. package/doorman/cli/eval.mjs +320 -0
  18. package/doorman/cli/harness.mjs +179 -0
  19. package/doorman/cli/install.mjs +175 -0
  20. package/doorman/cli/needs.mjs +116 -0
  21. package/doorman/cli/report.mjs +89 -0
  22. package/doorman/cli/sandbox.mjs +177 -0
  23. package/doorman/cli/task.mjs +239 -0
  24. package/doorman/cli/verdict.mjs +199 -0
  25. package/doorman/cli/watch.mjs +218 -0
  26. package/doorman/commands/doorman.md +116 -0
  27. package/doorman/commands/vet.md +69 -0
  28. package/doorman/hooks/hooks.json +30 -0
  29. package/doorman/install.sh +186 -0
  30. package/doorman/package.json +38 -0
  31. package/doorman/recipes/README.md +36 -0
  32. package/doorman/recipes/deepwiki.md +10 -0
  33. package/doorman/recipes/planted-bad.md +27 -0
  34. package/doorman/recipes/scorecard.md +10 -0
  35. package/doorman/registry/allowlist.json +37 -0
  36. package/doorman/registry/denylist.json +23 -0
  37. package/doorman/registry/ledger.jsonl +1 -0
  38. package/doorman/scripts/poller.mjs +292 -0
  39. package/doorman/scripts/resolve-cli.sh +58 -0
  40. package/doorman/scripts/vet.mjs +190 -0
  41. package/doorman/skills/doorman-guide/SKILL.md +69 -0
  42. package/doorman/src/budget.mjs +236 -0
  43. package/doorman/src/candidate.mjs +132 -0
  44. package/doorman/src/fit-review.mjs +255 -0
  45. package/doorman/src/injection.mjs +189 -0
  46. package/doorman/src/instructions.mjs +134 -0
  47. package/doorman/src/inventory.mjs +411 -0
  48. package/doorman/src/llm.mjs +87 -0
  49. package/doorman/src/needs.mjs +491 -0
  50. package/doorman/src/note.mjs +213 -0
  51. package/doorman/src/reviews.mjs +120 -0
  52. package/doorman/src/scorecard.mjs +123 -0
  53. package/doorman/src/vet.mjs +174 -0
  54. package/package.json +54 -0
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * doorman — measure a candidate tool instead of reviewing it.
4
+ *
5
+ * doorman report <link> L1, static implementation report. No key needed.
6
+ * doorman eval <link> --task <file> L3, two-arm benchmark in a throwaway sandbox.
7
+ * doorman allow <server> [--scope user|project] [--why TEXT] [--dry-run]
8
+ Trust a server by NAME, which is what the gate shows you when it blocks
9
+ one. Records a DECISION, not a measurement: basis is operator and the
10
+ grade stays null, because nothing graded it.
11
+
12
+ --scope user (default) writes ~/.doorman/registry and covers every
13
+ project. --scope project writes ./registry and covers this one.
14
+ Refuses to reverse a denylist entry.
15
+
16
+ doorman needs [path] read this build's own prompt history, propose servers.
17
+ * doorman watch [path] poll the feed for candidates new to THIS build.
18
+ * doorman discover sweep a public directory for candidates. Curate, never enqueue.
19
+ *
20
+ * Zero runtime dependencies, per the requirement in package.json. That is why
21
+ * the YAML loader and the arg parser are in-tree rather than installed.
22
+ */
23
+
24
+ import { report } from './report.mjs';
25
+ import { evaluate } from './eval.mjs';
26
+ import { doctor, renderDoctor } from './doctor.mjs';
27
+ import { watch, renderWatch, readState, writeState, DEFAULT_API, DEFAULT_STATE } from './watch.mjs';
28
+ import { discover, renderDiscover, writeCandidates } from './discover.mjs';
29
+ import { needs as readNeeds, render as renderNeedsCli } from './needs.mjs';
30
+ import { install, renderInstall } from './install.mjs';
31
+ import { allow, renderAllow, SCOPES } from './allow.mjs';
32
+
33
+ const VERSION = '0.1.0';
34
+
35
+ const HELP = `
36
+ doorman ${VERSION} — measure a candidate, do not just read it
37
+
38
+ doorman doctor [path]
39
+ L0. What is in YOUR build: which harness, which MCP servers your agents can
40
+ reach, how many subagents hold MCP tools, and whether the gate is installed
41
+ AND wired (those are different, and both are quiet).
42
+ Read-only, local, free. No model, no container, no network.
43
+
44
+ doorman install [path] [--dry-run] [--json]
45
+ Install the security gate (mcp-gate.sh), doorman subagent, /vet command,
46
+ and doorman skill into a project, safely wiring .claude/settings.json
47
+ and initializing the registry without overwriting existing trust lists.
48
+
49
+ doorman needs [path] [--history DIR] [--candidates FILE] [--json]
50
+ L0.5. What this build keeps REACHING for, read from its own prompt
51
+ history, against what it already has. Then the graded feed, matched on
52
+ capability text the candidates published about themselves.
53
+
54
+ Free, keyless, and the history never leaves the machine: the one request
55
+ is the same anonymous GET /feed that watch makes.
56
+
57
+ A need nothing graded covers is printed as a GAP rather than dropped, and
58
+ a match is only ever worth-measuring. Nothing here drove anything, so
59
+ nothing here claims a server will work. Only eval answers that.
60
+
61
+ doorman report <link> [--out DIR] [--needed-for TEXT]
62
+ L1. The static implementation report: protocol, schemas, annotations, and
63
+ a scan-only pass over every description an agent would read before
64
+ choosing a tool. Needs no model key.
65
+
66
+ doorman eval <link> --task <file> [--runs N] [--max-cost USD] [--out DIR]
67
+ L3. Runs the task N times in two images that differ by exactly one install
68
+ layer, and reports success rate, turns, tool calls, tokens, cost and wall
69
+ time per arm. Ends in ADOPT / DECLINE / INCONCLUSIVE.
70
+
71
+ Needs ANTHROPIC_API_KEY and a running Docker. Fewer than 3 runs per arm
72
+ cannot reach ADOPT: one sample cannot be told apart from luck. DECLINE
73
+ stays reachable at any run count, so a cheap run is still worth doing.
74
+
75
+ doorman eval <link> --task <file> --estimate
76
+ What it WOULD cost. Spends nothing, needs no key, needs no Docker.
77
+ Run this first. An agent loop resends the whole conversation every turn,
78
+ so cost grows with the SQUARE of the turn count: on Sonnet, three runs
79
+ per arm is $54 at worst. The ceiling exists because of that number.
80
+
81
+ Candidate links doorman can install as one layer:
82
+ npm:<package> or https://npmjs.com/package/<name>
83
+ pip:<package> or https://pypi.org/project/<name>
84
+ https://github.com/<owner>/<repo>
85
+
86
+ Options
87
+ --agent NAME which harness the arms drive: claude-code (default), builtin,
88
+ or exec. YOUR harness, YOUR key, YOUR machine. doorman passes
89
+ the credential straight into a local container and never
90
+ stores, logs or transmits it.
91
+ --exec CMD the command to run, with --agent exec
92
+ --runs N runs per arm (default 3)
93
+ --max-cost USD hard ceiling for the whole eval (default 5). The turn cap is
94
+ DERIVED from this rather than the other way round, so the
95
+ budget decides how long a run may get. Enforced by the same
96
+ permit ledger the doorman uses on its own outbound spend.
97
+ --estimate print the cost and exit. Spends nothing.
98
+ --ledger PATH permit ledger (default evals/.spend-ledger.jsonl)
99
+ --out DIR write reports here
100
+ --model NAME default claude-sonnet-5
101
+ --allow-network let the sandbox reach the network (default: --network none)
102
+ --json print the result object instead of prose
103
+ --version, -v
104
+ --help, -h
105
+ `;
106
+
107
+ /**
108
+ * Flags that take NO value. Without this list a boolean flag swallows the
109
+ * positional after it, so `doorman allow --dry-run myserver` parsed as
110
+ * `dry-run="myserver"` with no server at all, and `doorman needs --json .`
111
+ * lost the path and then crashed. The flag-then-path order is the one people
112
+ * type, and it was the broken one.
113
+ */
114
+ const BOOLEAN_FLAGS = new Set([
115
+ 'json', 'dry-run', 'all', 'estimate', 'help', 'version', 'allow-network',
116
+ 'static-only', 'no-feed',
117
+ ]);
118
+
119
+ function parseArgs(argv) {
120
+ const out = { _: [] };
121
+ for (let i = 0; i < argv.length; i++) {
122
+ const a = argv[i];
123
+ if (a.startsWith('--')) {
124
+ const key = a.slice(2);
125
+ const next = argv[i + 1];
126
+ if (BOOLEAN_FLAGS.has(key) || next === undefined || next.startsWith('--')) out[key] = true;
127
+ else { out[key] = next; i++; }
128
+ } else if (a === '-h') out.help = true;
129
+ else if (a === '-v') out.version = true;
130
+ else out._.push(a);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ const log = (m) => console.error(`${new Date().toISOString().slice(11, 19)} ${m}`);
136
+
137
+ async function main() {
138
+ const args = parseArgs(process.argv.slice(2));
139
+ if (args.version) { console.log(VERSION); return; }
140
+ if (args.help || args._.length === 0) { console.log(HELP); return; }
141
+
142
+ // `doctor` is the one command that needs no link: it reads the project.
143
+
144
+
145
+ const [cmd, link] = args._;
146
+
147
+ if (cmd === 'doctor') {
148
+ const d = await doctor(args._[1] || process.cwd());
149
+ if (!d.ok) { console.error(`doctor: ${d.why}`); process.exitCode = 1; return; }
150
+ if (args.json) { console.log(JSON.stringify(d, null, 2)); return; }
151
+ console.log(renderDoctor(d));
152
+ return;
153
+ }
154
+
155
+ if (cmd === 'install') {
156
+ const target = args._[1] || process.cwd();
157
+ const dryRun = Boolean(args['dry-run']);
158
+ const r = await install(target, { dryRun });
159
+ if (!r.ok) { console.error(`install: ${r.why}`); process.exitCode = 1; return; }
160
+ if (args.json) { console.log(JSON.stringify(r, null, 2)); return; }
161
+ console.log(renderInstall(r));
162
+ return;
163
+ }
164
+
165
+ if (cmd === 'allow') {
166
+ const r = allow(args._[1], {
167
+ scope: typeof args.scope === 'string' ? args.scope : 'user',
168
+ root: process.cwd(),
169
+ why: typeof args.why === 'string' ? args.why : null,
170
+ dryRun: Boolean(args['dry-run']),
171
+ });
172
+ if (!r.ok) { console.error(renderAllow(r)); process.exitCode = 2; return; }
173
+ if (args.json) console.log(JSON.stringify(r, null, 2));
174
+ else console.log(renderAllow(r));
175
+ return;
176
+ }
177
+
178
+ if (cmd === 'needs') {
179
+ let r;
180
+ try {
181
+ r = await readNeeds({
182
+ root: args._[1] || process.cwd(),
183
+ api: (args.api || DEFAULT_API).replace(/\/+$/, ''),
184
+ historyDir: typeof args.history === 'string' ? args.history : undefined,
185
+ candidateFile: typeof args.candidates === 'string' ? args.candidates : 'candidates/smithery.json',
186
+ });
187
+ } catch (e) {
188
+ console.error(`needs: ${e.message}`);
189
+ process.exitCode = e.code === 3 ? 3 : 1;
190
+ return;
191
+ }
192
+ if (args.json) console.log(JSON.stringify(r, null, 2));
193
+ else console.log(renderNeedsCli(r));
194
+ return;
195
+ }
196
+
197
+ if (cmd === 'discover') {
198
+ const pages = Number(args.pages) > 0 ? Number(args.pages) : 1;
199
+ const out = args.out || 'candidates/smithery.json';
200
+ // Reading the feed is free and only used to avoid re-proposing what is
201
+ // already graded. --no-feed skips it for an offline sweep.
202
+ const feedApi = args['no-feed'] ? null : (args.api || DEFAULT_API).replace(/\/+$/, '');
203
+ let r;
204
+ try {
205
+ r = await discover({ pages, feedApi });
206
+ } catch (e) {
207
+ console.error(`discover: ${e.message}`);
208
+ process.exitCode = e.code === 3 ? 3 : 1;
209
+ return;
210
+ }
211
+ writeCandidates(out, r);
212
+ if (args.json) console.log(JSON.stringify(r, null, 2));
213
+ else console.log(renderDiscover(r, out));
214
+ return;
215
+ }
216
+
217
+ if (cmd === 'watch') {
218
+ const root = args._[1] || process.cwd();
219
+ const api = (args.api || DEFAULT_API).replace(/\/+$/, '');
220
+ const stateFile = args.state || DEFAULT_STATE;
221
+ // --since beats the state file, and --all ignores both. Neither writes a
222
+ // cursor: a one-off look must not move a subscription's place in the feed.
223
+ const oneOff = Boolean(args.since || args.all);
224
+ const since = args.all ? null : (args.since || readState(stateFile).since);
225
+ const limit = Number(args.limit) > 0 ? Number(args.limit) : 50;
226
+
227
+ let r;
228
+ try {
229
+ r = await watch({ root, api, since, limit });
230
+ } catch (e) {
231
+ console.error(`watch: ${e.message}`);
232
+ process.exitCode = e.code === 3 ? 3 : 1;
233
+ return;
234
+ }
235
+
236
+ if (args.json) console.log(JSON.stringify(r, null, 2));
237
+ else console.log(renderWatch(r));
238
+
239
+ // Only advance the cursor when this was a real poll AND the feed moved.
240
+ // An empty page leaves it alone, so nothing can be skipped by a run that
241
+ // happened to arrive between two grades.
242
+ if (!oneOff && !args['dry-run'] && r.next_since) {
243
+ writeState(stateFile, { since: r.next_since, seen: r.candidates.length, updated: new Date().toISOString() });
244
+ if (!args.json) console.log(`\ncursor saved to ${stateFile}`);
245
+ } else if (oneOff && !args.json) {
246
+ console.log('\nOne-off look: the saved cursor was not moved.');
247
+ }
248
+ return;
249
+ }
250
+
251
+ if (!['report', 'eval'].includes(cmd)) {
252
+ console.error(`unknown command "${cmd}". Try: doorman --help`);
253
+ process.exitCode = 2;
254
+ return;
255
+ }
256
+ if (!link) {
257
+ console.error(`${cmd} needs a candidate link. Try: doorman --help`);
258
+ process.exitCode = 2;
259
+ return;
260
+ }
261
+
262
+ if (cmd === 'report') {
263
+ const out = args.out || `./doorman-report-${Date.now()}`;
264
+ const r = await report({ link, out, neededFor: args['needed-for'], log });
265
+ if (!r.ok) {
266
+ console.error(`\nreport failed: ${r.why}`);
267
+ if (r.hint) console.error(`\n${r.hint}`);
268
+ if (r.detail) console.error(`\n${r.detail}`);
269
+ process.exitCode = 1;
270
+ return;
271
+ }
272
+ if (args.json) { console.log(JSON.stringify(r.grade, null, 2)); return; }
273
+ const g = r.grade;
274
+ console.log('');
275
+ console.log(`L1 static report for ${link}`);
276
+ console.log(` grade ${g.grade ?? 'n/a'} ${typeof g.score === 'number' ? `(${g.score})` : ''}`);
277
+ console.log(` hard fail ${g.hard_fail ? 'YES' : 'no'}`);
278
+ console.log(` written to ${r.out}`);
279
+ console.log('');
280
+ console.log('This is the static layer only. It says nothing about whether an agent');
281
+ console.log('can actually use the thing: that is what `doorman eval` measures.');
282
+ return;
283
+ }
284
+
285
+ // eval
286
+ if (!args.task) {
287
+ console.error('eval needs --task <file>. Try: doorman --help');
288
+ process.exitCode = 2;
289
+ return;
290
+ }
291
+ const runs = Number(args.runs ?? 3);
292
+ if (!Number.isInteger(runs) || runs < 1 || runs > 20) {
293
+ console.error('--runs must be an integer between 1 and 20');
294
+ process.exitCode = 2;
295
+ return;
296
+ }
297
+
298
+ const maxCostUsd = Number(args['max-cost'] ?? 5);
299
+ if (!Number.isFinite(maxCostUsd) || maxCostUsd <= 0) {
300
+ console.error('--max-cost must be a positive number of USD');
301
+ process.exitCode = 2;
302
+ return;
303
+ }
304
+
305
+ const r = await evaluate({
306
+ link,
307
+ taskPath: args.task,
308
+ runs,
309
+ out: args.out,
310
+ model: args.model || 'claude-sonnet-5',
311
+ allowNetwork: Boolean(args['allow-network']),
312
+ maxCostUsd,
313
+ estimateOnly: Boolean(args.estimate),
314
+ ledgerPath: args.ledger || 'evals/.spend-ledger.jsonl',
315
+ log,
316
+ });
317
+
318
+ if (r.ok && r.estimateOnly) {
319
+ const { renderEstimate } = await import('./cost.mjs');
320
+ console.log('');
321
+ console.log(renderEstimate(r.estimate));
322
+ console.log('');
323
+ console.log(`Your ceiling is $${maxCostUsd.toFixed(2)}. Nothing was spent to tell you this.`);
324
+ return;
325
+ }
326
+
327
+ if (!r.ok) {
328
+ console.error(`\neval did not run:\n\n${r.why}`);
329
+ if (r.detail) console.error(`\n${r.detail}`);
330
+ // A refusal for a missing prerequisite is not the same as a crash, and the
331
+ // pipeline reads the code: 3 means "could not measure", 1 means "broke".
332
+ process.exitCode = r.blocked ? 3 : 1;
333
+ return;
334
+ }
335
+
336
+ if (args.json) { console.log(JSON.stringify(r, null, 2)); return; }
337
+ const { renderMarkdown } = await import('./eval.mjs');
338
+ console.log('');
339
+ console.log(renderMarkdown(r));
340
+ }
341
+
342
+ main().catch((e) => {
343
+ console.error(`doorman: ${e && e.stack ? e.stack : e}`);
344
+ process.exitCode = 1;
345
+ });
@@ -0,0 +1,320 @@
1
+ /**
2
+ * `doorman eval <link> --task <file>` — L3, the two-arm benchmark.
3
+ *
4
+ * Same task, N times, in two images that differ by exactly one install layer.
5
+ * Reports success rate, turns, tool calls, tokens, cost and wall time per arm,
6
+ * and ends in ADOPT / DECLINE / INCONCLUSIVE.
7
+ *
8
+ * The order of the preflight checks below is deliberate. Every one of them can
9
+ * fail without spending a cent, and they are ordered cheapest-refusal-first, so
10
+ * a run that cannot produce a real number never builds an image, never starts a
11
+ * container, and never bills a token.
12
+ */
13
+
14
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { parseTaskYaml, validateTask } from './task.mjs';
18
+ import { buildArms, cleanup, dockerAvailable, installLayer, runOnce } from './sandbox.mjs';
19
+ import { decide, summariseArm, MIN_RUNS_FOR_ADOPT } from './verdict.mjs';
20
+ import { openBudget, BudgetError } from '../src/budget.mjs';
21
+ import { DEFAULTS, estimateEval, priceFor, renderEstimate, worstCaseRunUsd } from './cost.mjs';
22
+
23
+ /**
24
+ * Fit the run inside the money, rather than fitting the money to the run.
25
+ *
26
+ * The turn cap used to be a constant, which meant the BUDGET was whatever 24
27
+ * turns happened to cost. Backwards: the ceiling is the input, so the turn cap
28
+ * is derived from it. Returns the largest turn cap whose worst case still fits
29
+ * the per-run allowance, or null when even a minimal run does not fit.
30
+ */
31
+ export function turnsWithinBudget(model, perRunAllowanceUsd, floor = 3) {
32
+ if (!priceFor(model)) return null;
33
+ if (worstCaseRunUsd(model, { maxTurns: floor }) > perRunAllowanceUsd) return null;
34
+ let best = floor;
35
+ for (let t = floor; t <= DEFAULTS.maxTurns; t++) {
36
+ if (worstCaseRunUsd(model, { maxTurns: t }) <= perRunAllowanceUsd) best = t;
37
+ else break;
38
+ }
39
+ return best;
40
+ }
41
+
42
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
43
+
44
+ export async function evaluate({ link, taskPath, runs, out, model, log, allowNetwork,
45
+ maxCostUsd, estimateOnly, ledgerPath }) {
46
+ const started = Date.now();
47
+
48
+ // 1. The task must parse and be checkable. Free.
49
+ let task;
50
+ try {
51
+ task = parseTaskYaml(await readFile(taskPath, 'utf8'));
52
+ } catch (e) {
53
+ return { ok: false, why: `task file did not parse: ${e.message}` };
54
+ }
55
+ const problems = validateTask(task, path.basename(taskPath));
56
+ if (problems.length) return { ok: false, why: 'task file is not usable:\n ' + problems.join('\n ') };
57
+ log(`task ${task.id}: ${task.title}`);
58
+
59
+ // 2. The candidate must be expressible as one install layer. Free.
60
+ const layer = installLayer(link);
61
+ if (layer.kind === 'unsupported') return { ok: false, blocked: 'no-install-layer', why: layer.why };
62
+ log(`candidate ${layer.kind}: ${layer.spec}`);
63
+
64
+ // 3. What can this cost, and does it fit? Free, and needs no key: a price
65
+ // you can only see after configuring a credential is not a price you can
66
+ // decide on.
67
+ const estimate = estimateEval({ model, runs });
68
+ if (estimateOnly) {
69
+ return { ok: true, estimateOnly: true, estimate, task, layer };
70
+ }
71
+ if (!estimate.priced) {
72
+ return {
73
+ ok: false, blocked: 'unpriced-model',
74
+ why:
75
+ `model "${model}" has no price in cli/cost.mjs, so no ceiling can be enforced.\n` +
76
+ 'Refusing to run: an unknown price is not a free one. That rule already ' +
77
+ 'governs the doorman spending outward, and it governs spending our own ' +
78
+ 'money too.',
79
+ };
80
+ }
81
+
82
+ const perRunAllowance = maxCostUsd / (runs * 2);
83
+ const maxTurns = turnsWithinBudget(model, perRunAllowance);
84
+ if (maxTurns === null) {
85
+ const floorCost = worstCaseRunUsd(model, { maxTurns: 3 });
86
+ return {
87
+ ok: false, blocked: 'budget-too-small',
88
+ why:
89
+ `a ceiling of $${maxCostUsd} over ${runs * 2} runs allows ` +
90
+ `$${perRunAllowance.toFixed(2)} per run, and even a 3-turn run can cost ` +
91
+ `$${floorCost.toFixed(2)} at worst on ${model}.\n\n` +
92
+ 'Nothing was spent. Options: raise --max-cost, lower --runs, or use a ' +
93
+ 'cheaper model: --model claude-haiku-4-5-20251001 is about a third of ' +
94
+ 'Sonnet. A local-model backend is designed but NOT built; see ' +
95
+ 'evals/ROADMAP.md for the two blockers found on this machine.',
96
+ };
97
+ }
98
+ const perRunWorst = worstCaseRunUsd(model, { maxTurns });
99
+ log(`budget $${maxCostUsd} => ${maxTurns} turns/run, worst $${perRunWorst.toFixed(2)}/run`);
100
+
101
+ // 4. Docker must be up. Free.
102
+ const d = await dockerAvailable();
103
+ if (!d.ok) {
104
+ // A prerequisite that is absent is NOT the candidate failing, and the exit
105
+ // code has to say which. adopt.md reads 3 as 'could not measure' and 1 as
106
+ // 'broke'. Recording a stopped Docker daemon as a DECLINE would libel a
107
+ // tool that was never run.
108
+ return {
109
+ ok: false,
110
+ blocked: 'no-docker',
111
+ why:
112
+ 'docker is not available, so no sandbox could be built.\n' + d.detail + '\n\n' +
113
+ 'Nothing was built and nothing was spent. Start Docker Desktop and re-run.',
114
+ };
115
+ }
116
+ log(`docker ${d.version}`);
117
+
118
+ // 5. A key must exist, or there is nothing to measure.
119
+ // Invariant 9: a missing key stops the run and says so. It does NOT fall
120
+ // back to a cheaper proxy, because a number produced by a different method
121
+ // than the one the report describes is a fabricated number.
122
+ const apiKey = process.env.ANTHROPIC_API_KEY;
123
+ if (!apiKey) {
124
+ return {
125
+ ok: false,
126
+ blocked: 'no-key',
127
+ why:
128
+ 'ANTHROPIC_API_KEY is not set, so no benchmark can run.\n' +
129
+ 'L3 measures an agent doing a task; with no model there is no agent.\n' +
130
+ 'Nothing was built and nothing was spent.\n\n' +
131
+ '`doorman report <link>` still works and needs no key: it produces the ' +
132
+ 'static layer, and the pipeline records the candidate as statically ' +
133
+ 'measured and behaviourally unmeasured rather than as failed.',
134
+ };
135
+ }
136
+
137
+ // 6. Build the arms.
138
+ const harnessSource = await readFile(path.join(HERE, 'harness.mjs'), 'utf8');
139
+ const arms = await buildArms({ link, harnessSource, log });
140
+ if (!arms.ok) {
141
+ if (arms.installFailed) {
142
+ // A real result: DECLINE without running anything.
143
+ return {
144
+ ok: true,
145
+ verdict: {
146
+ verdict: 'DECLINE', confidence: 'high',
147
+ reasons: [`the candidate does not install: ${arms.why}`],
148
+ note: 'No benchmark was run. A tool that cannot be installed reproducibly cannot be adopted.',
149
+ deltas: null,
150
+ },
151
+ baseline: null, candidate: null, task, layer,
152
+ install_error: arms.detail, eval_cost_usd: 0,
153
+ wall_ms: Date.now() - started,
154
+ };
155
+ }
156
+ return { ok: false, why: arms.why, detail: arms.detail };
157
+ }
158
+
159
+ // 7. Run both arms, interleaved.
160
+ // Interleaved rather than all-baseline-then-all-candidate, so that any
161
+ // drift over the run window (model-side latency, rate limiting, a noisy
162
+ // machine) lands on both arms rather than only the second one.
163
+ const baselineRuns = [];
164
+ const candidateRuns = [];
165
+ const netAllowed = Boolean(allowNetwork ?? task.network);
166
+ log(`network inside the sandbox: ${netAllowed ? 'bridge (declared by the task)' : 'none'}`);
167
+
168
+ // The same permit machinery the doorman uses to stop itself overspending
169
+ // outward, pointed at our own bill. Reserve the WORST case before a run and
170
+ // settle the actual after: reserving the expected cost would be a limit that
171
+ // only holds when nothing goes wrong.
172
+ let budget;
173
+ try {
174
+ budget = openBudget({ ledgerPath, perRunUsdc: perRunWorst, perDayUsdc: maxCostUsd });
175
+ } catch (e) {
176
+ if (e instanceof BudgetError) return { ok: false, blocked: 'budget-config', why: e.message };
177
+ throw e;
178
+ }
179
+
180
+ let stoppedEarly = null;
181
+ try {
182
+ for (let i = 1; i <= runs; i++) {
183
+ for (const [armName, image, bucket] of [
184
+ ['baseline', arms.baseTag, baselineRuns],
185
+ ['candidate', arms.candTag, candidateRuns],
186
+ ]) {
187
+ let permit;
188
+ try {
189
+ permit = budget.reserve({ price_usdc: perRunWorst, server: `${armName} run ${i}` });
190
+ } catch (e) {
191
+ // Out of ceiling. Stop cleanly with what we have rather than
192
+ // half-spending into an arm and reporting a lopsided comparison.
193
+ stoppedEarly = `stopped after ${baselineRuns.length + candidateRuns.length} run(s): ${e.message}`;
194
+ log(stoppedEarly);
195
+ break;
196
+ }
197
+ log(`run ${i}/${runs}: ${armName}`);
198
+ const r = await runOnce({ image, task, apiKey, model, netAllowed, maxTurns });
199
+ bucket.push(r);
200
+ // settle() commits the RESERVED price, and the reservation is the worst
201
+ // case. Settling it directly would charge every run $9 when it cost 30
202
+ // cents, and the ceiling would be gone after six runs that spent two
203
+ // dollars. So: release the guard, then reserve and settle the ACTUAL.
204
+ // The guard did its job by existing before the call; the ledger should
205
+ // record what happened, not what was feared.
206
+ budget.release(permit, 'run finished, actual cost known');
207
+ const actual = typeof r.cost_usd === 'number' ? r.cost_usd : 0;
208
+ try {
209
+ budget.settle(budget.reserve({ price_usdc: actual, server: `${armName} run ${i}` }), {});
210
+ } catch (e) {
211
+ // The actual came in over what remained. Record it and stop: the money
212
+ // is already spent, and pretending otherwise would corrupt the ledger.
213
+ stoppedEarly = `actual spend exceeded the ceiling on ${armName} run ${i}: ${e.message}`;
214
+ log(stoppedEarly);
215
+ }
216
+ }
217
+ if (stoppedEarly) break;
218
+ }
219
+ } finally {
220
+ await cleanup(arms, log);
221
+ }
222
+
223
+ // An A/B with unequal arms is not an A/B. If the ceiling cut one arm short,
224
+ // drop the unpaired runs rather than comparing 3 against 2.
225
+ const paired = Math.min(baselineRuns.length, candidateRuns.length);
226
+ if (paired < baselineRuns.length || paired < candidateRuns.length) {
227
+ log(`trimming to ${paired} paired run(s) per arm: an unequal A/B is not a comparison`);
228
+ baselineRuns.length = paired;
229
+ candidateRuns.length = paired;
230
+ }
231
+ if (paired === 0) {
232
+ return {
233
+ ok: false, blocked: 'budget-exhausted',
234
+ why: stoppedEarly || 'the ceiling did not allow a single paired run.',
235
+ };
236
+ }
237
+
238
+ const baseline = summariseArm(baselineRuns);
239
+ const candidate = summariseArm(candidateRuns);
240
+
241
+ // Anything the sandbox saw that the benchmark did not measure. Egress
242
+ // observation is not wired yet (see evals/ROADMAP.md), so this is empty and
243
+ // the report SAYS it is empty rather than implying the check passed.
244
+ const observations = {};
245
+
246
+ const verdict = decide({ baseline, candidate, observations });
247
+ const evalCost =
248
+ [...baselineRuns, ...candidateRuns]
249
+ .map((r) => (typeof r.cost_usd === 'number' ? r.cost_usd : 0))
250
+ .reduce((a, b) => a + b, 0);
251
+
252
+ const result = {
253
+ ok: true, verdict, baseline, candidate, task, layer,
254
+ runs_per_arm: paired,
255
+ runs_requested: runs,
256
+ stopped_early: stoppedEarly,
257
+ max_turns: maxTurns,
258
+ max_cost_usd: maxCostUsd,
259
+ spent_today_usd: budget.spentToday(),
260
+ model,
261
+ network: netAllowed ? 'bridge' : 'none',
262
+ egress_observed: false,
263
+ eval_cost_usd: evalCost,
264
+ wall_ms: Date.now() - started,
265
+ raw: { baseline: baselineRuns, candidate: candidateRuns },
266
+ };
267
+
268
+ if (out) {
269
+ await mkdir(out, { recursive: true });
270
+ await writeFile(path.join(out, 'eval.json'), JSON.stringify(result, null, 2), 'utf8');
271
+ await writeFile(path.join(out, 'eval.md'), renderMarkdown(result), 'utf8');
272
+ log(`wrote ${path.join(out, 'eval.json')} and eval.md`);
273
+ }
274
+ return result;
275
+ }
276
+
277
+ const pct = (n) => (typeof n === 'number' ? `${(n * 100).toFixed(0)}%` : 'n/a');
278
+ const num = (n, d = 1) => (typeof n === 'number' ? n.toFixed(d) : 'n/a');
279
+ const delta = (n) => (typeof n === 'number' ? `${n >= 0 ? '+' : ''}${(n * 100).toFixed(0)}%` : 'n/a');
280
+
281
+ export function renderMarkdown(r) {
282
+ const v = r.verdict;
283
+ const L = [];
284
+ L.push(`# ${v.verdict}: ${r.layer.spec}`);
285
+ L.push('');
286
+ L.push(`**Task** \`${r.task.id}\` · **${r.runs_per_arm} run(s) per arm** · model \`${r.model}\``);
287
+ L.push(`**Eval cost** $${num(r.eval_cost_usd, 4)} · **wall** ${num(r.wall_ms / 1000, 1)}s`);
288
+ L.push('');
289
+ for (const why of v.reasons) L.push(`- ${why}`);
290
+ if (v.note) { L.push(''); L.push(`> ${v.note}`); }
291
+
292
+ if (r.baseline && r.candidate) {
293
+ L.push('');
294
+ L.push('| Metric | Baseline | Candidate | Delta |');
295
+ L.push('|---|---:|---:|---:|');
296
+ L.push(`| success rate | ${pct(r.baseline.success_rate)} | ${pct(r.candidate.success_rate)} | ${delta(v.deltas?.success_rate)} |`);
297
+ L.push(`| turns | ${num(r.baseline.mean_turns)} | ${num(r.candidate.mean_turns)} | ${delta(v.deltas?.turns)} |`);
298
+ L.push(`| tool calls | ${num(r.baseline.mean_tool_calls)} | ${num(r.candidate.mean_tool_calls)} | ${delta(v.deltas?.tool_calls)} |`);
299
+ L.push(`| tokens | ${num(r.baseline.mean_tokens, 0)} | ${num(r.candidate.mean_tokens, 0)} | ${delta(v.deltas?.tokens)} |`);
300
+ L.push(`| cost USD | ${num(r.baseline.mean_cost_usd, 4)} | ${num(r.candidate.mean_cost_usd, 4)} | ${delta(v.deltas?.cost_usd)} |`);
301
+ L.push(`| wall ms | ${num(r.baseline.mean_wall_ms, 0)} | ${num(r.candidate.mean_wall_ms, 0)} | ${delta(v.deltas?.wall_ms)} |`);
302
+ L.push('');
303
+ L.push('Means are over SUCCESSFUL runs only. A failed run either bailed early or');
304
+ L.push('spun to the turn cap, and neither is the cost of doing the job.');
305
+ }
306
+
307
+ L.push('');
308
+ L.push('## What this did not measure');
309
+ L.push('');
310
+ L.push(`- **Network egress was not observed.** The sandbox ran with \`--network ${r.network}\`, but`);
311
+ L.push(' nothing captured what the candidate actually reached. The automatic-DECLINE');
312
+ L.push(' security clause therefore had no input on this run and did not pass; it did not run.');
313
+ if (r.runs_per_arm < MIN_RUNS_FOR_ADOPT) {
314
+ L.push(`- **${r.runs_per_arm} run(s) per arm is below the ${MIN_RUNS_FOR_ADOPT}** needed to separate a real`);
315
+ L.push(' effect from noise, so ADOPT was not reachable on this run by construction.');
316
+ }
317
+ L.push('');
318
+ L.push('_Generated by `doorman eval`. Numbers come from the runs; the verdict line is a proposal._');
319
+ return L.join('\n') + '\n';
320
+ }