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.
- package/.claude-plugin/marketplace.json +17 -0
- package/LICENSE +21 -0
- package/README.md +951 -0
- package/WALKTHROUGH.md +224 -0
- package/doorman/.claude/hooks/mcp-gate.sh +205 -0
- package/doorman/.claude/settings.json +16 -0
- package/doorman/.claude-plugin/plugin.json +22 -0
- package/doorman/.mcp.json +24 -0
- package/doorman/README.md +259 -0
- package/doorman/agents/doorman.md +104 -0
- package/doorman/cli/agents.mjs +128 -0
- package/doorman/cli/allow.mjs +128 -0
- package/doorman/cli/cost.mjs +119 -0
- package/doorman/cli/discover.mjs +265 -0
- package/doorman/cli/doctor.mjs +282 -0
- package/doorman/cli/doorman.mjs +345 -0
- package/doorman/cli/eval.mjs +320 -0
- package/doorman/cli/harness.mjs +179 -0
- package/doorman/cli/install.mjs +175 -0
- package/doorman/cli/needs.mjs +116 -0
- package/doorman/cli/report.mjs +89 -0
- package/doorman/cli/sandbox.mjs +177 -0
- package/doorman/cli/task.mjs +239 -0
- package/doorman/cli/verdict.mjs +199 -0
- package/doorman/cli/watch.mjs +218 -0
- package/doorman/commands/doorman.md +116 -0
- package/doorman/commands/vet.md +69 -0
- package/doorman/hooks/hooks.json +30 -0
- package/doorman/install.sh +186 -0
- package/doorman/package.json +38 -0
- package/doorman/recipes/README.md +36 -0
- package/doorman/recipes/deepwiki.md +10 -0
- package/doorman/recipes/planted-bad.md +27 -0
- package/doorman/recipes/scorecard.md +10 -0
- package/doorman/registry/allowlist.json +37 -0
- package/doorman/registry/denylist.json +23 -0
- package/doorman/registry/ledger.jsonl +1 -0
- package/doorman/scripts/poller.mjs +292 -0
- package/doorman/scripts/resolve-cli.sh +58 -0
- package/doorman/scripts/vet.mjs +190 -0
- package/doorman/skills/doorman-guide/SKILL.md +69 -0
- package/doorman/src/budget.mjs +236 -0
- package/doorman/src/candidate.mjs +132 -0
- package/doorman/src/fit-review.mjs +255 -0
- package/doorman/src/injection.mjs +189 -0
- package/doorman/src/instructions.mjs +134 -0
- package/doorman/src/inventory.mjs +411 -0
- package/doorman/src/llm.mjs +87 -0
- package/doorman/src/needs.mjs +491 -0
- package/doorman/src/note.mjs +213 -0
- package/doorman/src/reviews.mjs +120 -0
- package/doorman/src/scorecard.mjs +123 -0
- package/doorman/src/vet.mjs +174 -0
- 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
|
+
}
|