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,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The throwaway Docker sandbox, and the one thing that makes an A/B honest.
|
|
3
|
+
*
|
|
4
|
+
* Two arms. They must be byte-identical except for a single install layer, or
|
|
5
|
+
* the comparison measures the difference between two environments rather than
|
|
6
|
+
* the difference the candidate makes. So both arms are built FROM THE SAME
|
|
7
|
+
* base image digest, and the candidate arm is literally the baseline image plus
|
|
8
|
+
* one `RUN` instruction. Nothing else varies: same task file, same model, same
|
|
9
|
+
* temperature, same seed order, same network policy.
|
|
10
|
+
*
|
|
11
|
+
* `--network none` is the default for the task run itself. A candidate that
|
|
12
|
+
* needs the network has to declare it, and what it actually reaches is then
|
|
13
|
+
* observable rather than assumed. That is the hook the security clause in
|
|
14
|
+
* `verdict.mjs` reads.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
const run = (cmd, args, opts = {}) =>
|
|
23
|
+
new Promise((resolve) => {
|
|
24
|
+
const child = execFile(cmd, args, { maxBuffer: 32 * 1024 * 1024, ...opts },
|
|
25
|
+
(err, stdout, stderr) => resolve({
|
|
26
|
+
ok: !err, code: err?.code ?? 0, stdout: stdout ?? '', stderr: stderr ?? '',
|
|
27
|
+
}));
|
|
28
|
+
if (opts.stdin) { child.stdin.write(opts.stdin); child.stdin.end(); }
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export async function dockerAvailable() {
|
|
32
|
+
const r = await run('docker', ['version', '--format', '{{.Server.Version}}']);
|
|
33
|
+
return { ok: r.ok, version: r.stdout.trim(), detail: r.ok ? null : (r.stderr || r.stdout).trim() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive the ONE install instruction for the candidate arm.
|
|
38
|
+
*
|
|
39
|
+
* Refuses anything it cannot express as a single reproducible layer, rather
|
|
40
|
+
* than guessing. A candidate that needs a compose file or a running service is
|
|
41
|
+
* a different lane (see evals/ROADMAP.md, "Platform-class candidates"), and
|
|
42
|
+
* pretending otherwise would produce an arm that silently installed nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function installLayer(link) {
|
|
45
|
+
const l = String(link).trim();
|
|
46
|
+
|
|
47
|
+
let m = l.match(/^npm:(.+)$/) || l.match(/^https?:\/\/(?:www\.)?npmjs\.com\/package\/(.+?)\/?$/);
|
|
48
|
+
if (m) return { kind: 'npm', spec: m[1], run: `npm install -g ${JSON.stringify(m[1])}` };
|
|
49
|
+
|
|
50
|
+
m = l.match(/^pip:(.+)$/) || l.match(/^https?:\/\/pypi\.org\/project\/(.+?)\/?$/);
|
|
51
|
+
if (m) return { kind: 'pip', spec: m[1], run: `pip install --no-cache-dir ${JSON.stringify(m[1])}` };
|
|
52
|
+
|
|
53
|
+
m = l.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/);
|
|
54
|
+
if (m) {
|
|
55
|
+
return {
|
|
56
|
+
kind: 'git',
|
|
57
|
+
spec: m[1],
|
|
58
|
+
run:
|
|
59
|
+
`git clone --depth 1 https://github.com/${m[1]}.git /opt/candidate && ` +
|
|
60
|
+
`cd /opt/candidate && ` +
|
|
61
|
+
`(test -f package.json && npm install --omit=dev || true) && ` +
|
|
62
|
+
`(test -f requirements.txt && pip install --no-cache-dir -r requirements.txt || true) && ` +
|
|
63
|
+
`(test -f pyproject.toml && pip install --no-cache-dir . || true)`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (/^https?:\/\//.test(l)) {
|
|
68
|
+
return {
|
|
69
|
+
kind: 'unsupported',
|
|
70
|
+
spec: l,
|
|
71
|
+
run: null,
|
|
72
|
+
why:
|
|
73
|
+
'this link is not something a single reproducible install layer can express. ' +
|
|
74
|
+
'Supported: an npm package (npm:name), a PyPI package (pip:name), or a GitHub repo url. ' +
|
|
75
|
+
'A hosted MCP endpoint has nothing to install, so use `doorman report` for its ' +
|
|
76
|
+
'static layer instead, and see evals/ROADMAP.md for the platform-class lane.',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
kind: 'unsupported', spec: l, run: null,
|
|
82
|
+
why: 'not a recognised candidate link',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The base image both arms share. Pinned by digest at build time, not by tag. */
|
|
87
|
+
const BASE_DOCKERFILE = `
|
|
88
|
+
FROM node:22-bookworm-slim
|
|
89
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
|
90
|
+
git ca-certificates python3 python3-pip \\
|
|
91
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
92
|
+
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
|
93
|
+
WORKDIR /work
|
|
94
|
+
# The harness that runs one task and prints one JSON line of metrics.
|
|
95
|
+
COPY harness.mjs /opt/harness.mjs
|
|
96
|
+
ENV NODE_ENV=production
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
export async function buildArms({ link, harnessSource, log }) {
|
|
100
|
+
const layer = installLayer(link);
|
|
101
|
+
if (layer.kind === 'unsupported') {
|
|
102
|
+
return { ok: false, why: layer.why, layer };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'doorman-'));
|
|
106
|
+
const tag = 'doorman-eval-' + Math.random().toString(16).slice(2, 10);
|
|
107
|
+
try {
|
|
108
|
+
await writeFile(path.join(dir, 'harness.mjs'), harnessSource, 'utf8');
|
|
109
|
+
await writeFile(path.join(dir, 'Dockerfile.base'), BASE_DOCKERFILE, 'utf8');
|
|
110
|
+
|
|
111
|
+
log(`building baseline arm (${tag}-base)`);
|
|
112
|
+
const b = await run('docker', ['build', '-f', 'Dockerfile.base', '-t', `${tag}-base`, '.'], { cwd: dir });
|
|
113
|
+
if (!b.ok) return { ok: false, why: 'baseline image build failed', detail: b.stderr.slice(-2000), layer };
|
|
114
|
+
|
|
115
|
+
// The candidate arm IS the baseline image plus exactly one instruction.
|
|
116
|
+
// Written this way on purpose: it is impossible for the two arms to drift,
|
|
117
|
+
// because one is derived from the other rather than built alongside it.
|
|
118
|
+
const candDockerfile = `FROM ${tag}-base\nRUN ${layer.run}\n`;
|
|
119
|
+
await writeFile(path.join(dir, 'Dockerfile.cand'), candDockerfile, 'utf8');
|
|
120
|
+
|
|
121
|
+
log(`building candidate arm (+1 layer: ${layer.kind} ${layer.spec})`);
|
|
122
|
+
const c = await run('docker', ['build', '-f', 'Dockerfile.cand', '-t', `${tag}-cand`, '.'], { cwd: dir });
|
|
123
|
+
if (!c.ok) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
why: `the candidate would not install: ${layer.kind} ${layer.spec}`,
|
|
127
|
+
detail: c.stderr.slice(-2000),
|
|
128
|
+
layer,
|
|
129
|
+
// A candidate that cannot be installed is a real result, not an error.
|
|
130
|
+
installFailed: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { ok: true, baseTag: `${tag}-base`, candTag: `${tag}-cand`, dir, tag, layer };
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return { ok: false, why: e.message, layer };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Run the harness once inside an arm. Returns the parsed metrics line. */
|
|
141
|
+
export async function runOnce({ image, task, apiKey, model, netAllowed, maxTurns = 24,
|
|
142
|
+
timeoutMs = 300_000 }) {
|
|
143
|
+
const args = [
|
|
144
|
+
'run', '--rm', '-i',
|
|
145
|
+
'--network', netAllowed ? 'bridge' : 'none',
|
|
146
|
+
'--memory', '2g', '--cpus', '2',
|
|
147
|
+
'-e', `DOORMAN_MODEL=${model}`,
|
|
148
|
+
'-e', `DOORMAN_MAX_TURNS=${maxTurns}`,
|
|
149
|
+
'-e', 'ANTHROPIC_API_KEY',
|
|
150
|
+
image, 'node', '/opt/harness.mjs',
|
|
151
|
+
];
|
|
152
|
+
const started = Date.now();
|
|
153
|
+
const r = await run('docker', args, {
|
|
154
|
+
stdin: JSON.stringify(task),
|
|
155
|
+
timeout: timeoutMs,
|
|
156
|
+
env: { ...process.env, ANTHROPIC_API_KEY: apiKey },
|
|
157
|
+
});
|
|
158
|
+
const wall_ms = Date.now() - started;
|
|
159
|
+
|
|
160
|
+
const line = r.stdout.split('\n').reverse().find((l) => l.trim().startsWith('{'));
|
|
161
|
+
if (!line) {
|
|
162
|
+
return { success: false, wall_ms, error: 'harness produced no metrics line', stderr: r.stderr.slice(-800) };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return { ...JSON.parse(line), wall_ms };
|
|
166
|
+
} catch {
|
|
167
|
+
return { success: false, wall_ms, error: 'harness metrics line was not JSON' };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function cleanup({ tag, dir }, log) {
|
|
172
|
+
if (tag) {
|
|
173
|
+
await run('docker', ['image', 'rm', '-f', `${tag}-cand`, `${tag}-base`]);
|
|
174
|
+
log('removed both arm images');
|
|
175
|
+
}
|
|
176
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A strict YAML-subset loader for task files.
|
|
3
|
+
*
|
|
4
|
+
* `doorman/package.json` states zero runtime dependencies as a REQUIREMENT, not
|
|
5
|
+
* an achievement: this half is the giveaway, and every dependency is one more
|
|
6
|
+
* thing that can fail to install on somebody else's machine. So there is no
|
|
7
|
+
* yaml package here.
|
|
8
|
+
*
|
|
9
|
+
* The danger with a hand-rolled parser is not that it fails. It is that it
|
|
10
|
+
* SILENTLY MIS-PARSES, and a task file that means something slightly different
|
|
11
|
+
* from what it reads like would corrupt an eval while every number still looked
|
|
12
|
+
* plausible. So this parser refuses everything it does not fully understand,
|
|
13
|
+
* with the line number and the reason. There is no permissive fallback and no
|
|
14
|
+
* best-effort branch.
|
|
15
|
+
*
|
|
16
|
+
* The supported subset, in full:
|
|
17
|
+
*
|
|
18
|
+
* key: scalar strings, integers, true/false, null
|
|
19
|
+
* key: "quoted scalar" single or double quotes, no escapes beyond \" and \\
|
|
20
|
+
* key: followed by an indented block map, or
|
|
21
|
+
* - list item a list of scalars, or
|
|
22
|
+
* - key: value a list of single-level maps
|
|
23
|
+
* # comment whole-line only
|
|
24
|
+
* | literal block scalar, for prompts
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT supported, and rejected rather than approximated: anchors,
|
|
27
|
+
* aliases, tags, flow collections, multi-document streams, folded scalars,
|
|
28
|
+
* nested lists, and any indentation that is not a multiple of two spaces.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const RESERVED = new Set(['<<', '!', '&', '*', '%']);
|
|
32
|
+
|
|
33
|
+
export class TaskParseError extends Error {
|
|
34
|
+
constructor(line, message) {
|
|
35
|
+
super(`line ${line}: ${message}`);
|
|
36
|
+
this.name = 'TaskParseError';
|
|
37
|
+
this.line = line;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Scalars are typed narrowly. An unquoted value that looks numeric IS numeric. */
|
|
42
|
+
function scalar(raw, lineNo) {
|
|
43
|
+
const v = raw.trim();
|
|
44
|
+
if (v === '') return '';
|
|
45
|
+
if (v === 'null' || v === '~') return null;
|
|
46
|
+
if (v === 'true') return true;
|
|
47
|
+
if (v === 'false') return false;
|
|
48
|
+
|
|
49
|
+
const q = v[0];
|
|
50
|
+
if (q === '"' || q === "'") {
|
|
51
|
+
if (v.length < 2 || v[v.length - 1] !== q) {
|
|
52
|
+
throw new TaskParseError(lineNo, 'unterminated quoted string');
|
|
53
|
+
}
|
|
54
|
+
const inner = v.slice(1, -1);
|
|
55
|
+
if (q === '"') {
|
|
56
|
+
if (/\\(?!["\\])/.test(inner)) {
|
|
57
|
+
throw new TaskParseError(lineNo, 'only \\" and \\\\ escapes are supported');
|
|
58
|
+
}
|
|
59
|
+
return inner.replace(/\\(["\\])/g, '$1');
|
|
60
|
+
}
|
|
61
|
+
return inner;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (RESERVED.has(v[0])) {
|
|
65
|
+
throw new TaskParseError(lineNo, `YAML feature "${v[0]}" is not supported here`);
|
|
66
|
+
}
|
|
67
|
+
if (v.includes(': ') || v.endsWith(':')) {
|
|
68
|
+
throw new TaskParseError(lineNo, 'unquoted colon in a value: quote it');
|
|
69
|
+
}
|
|
70
|
+
if (/^-?\d+$/.test(v)) return Number(v);
|
|
71
|
+
if (/^-?\d*\.\d+$/.test(v)) return Number(v);
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Split into meaningful lines, keeping original numbers for error messages. */
|
|
76
|
+
function lines(src) {
|
|
77
|
+
return src.replace(/\r\n/g, '\n').split('\n').map((text, i) => ({ text, no: i + 1 }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function indentOf(text, lineNo) {
|
|
81
|
+
const m = text.match(/^( *)/);
|
|
82
|
+
const n = m[1].length;
|
|
83
|
+
if (text.includes('\t')) throw new TaskParseError(lineNo, 'tabs are not valid YAML indentation');
|
|
84
|
+
if (n % 2 !== 0) throw new TaskParseError(lineNo, `indent of ${n} is not a multiple of two`);
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a block starting at `i` with exactly `indent` leading spaces.
|
|
90
|
+
* Returns [value, nextIndex].
|
|
91
|
+
*/
|
|
92
|
+
function parseBlock(all, i, indent) {
|
|
93
|
+
// A list?
|
|
94
|
+
const first = all[i];
|
|
95
|
+
if (first && indentOf(first.text, first.no) === indent && /^ *- /.test(first.text)) {
|
|
96
|
+
const out = [];
|
|
97
|
+
while (i < all.length) {
|
|
98
|
+
const l = all[i];
|
|
99
|
+
if (l.text.trim() === '' || l.text.trim().startsWith('#')) { i++; continue; }
|
|
100
|
+
const ind = indentOf(l.text, l.no);
|
|
101
|
+
if (ind < indent || !/^ *- /.test(l.text)) break;
|
|
102
|
+
if (ind > indent) throw new TaskParseError(l.no, 'over-indented list item');
|
|
103
|
+
const body = l.text.slice(ind + 2);
|
|
104
|
+
if (/^[A-Za-z_][A-Za-z0-9_-]*:/.test(body)) {
|
|
105
|
+
// A list of single-level maps: `- key: value`, continued by deeper keys.
|
|
106
|
+
const map = {};
|
|
107
|
+
const [k, ...rest] = body.split(':');
|
|
108
|
+
map[k.trim()] = scalar(rest.join(':'), l.no);
|
|
109
|
+
i++;
|
|
110
|
+
while (i < all.length) {
|
|
111
|
+
const n = all[i];
|
|
112
|
+
if (n.text.trim() === '' || n.text.trim().startsWith('#')) { i++; continue; }
|
|
113
|
+
const nInd = indentOf(n.text, n.no);
|
|
114
|
+
if (nInd !== indent + 2 || /^ *- /.test(n.text)) break;
|
|
115
|
+
const mm = n.text.trim().match(/^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
|
|
116
|
+
if (!mm) throw new TaskParseError(n.no, 'expected key: value inside a list item');
|
|
117
|
+
map[mm[1]] = scalar(mm[2], n.no);
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
out.push(map);
|
|
121
|
+
} else {
|
|
122
|
+
out.push(scalar(body, l.no));
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [out, i];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Otherwise a map.
|
|
130
|
+
const out = {};
|
|
131
|
+
let sawKey = false;
|
|
132
|
+
while (i < all.length) {
|
|
133
|
+
const l = all[i];
|
|
134
|
+
if (l.text.trim() === '' || l.text.trim().startsWith('#')) { i++; continue; }
|
|
135
|
+
const ind = indentOf(l.text, l.no);
|
|
136
|
+
if (ind < indent) break;
|
|
137
|
+
if (ind > indent) throw new TaskParseError(l.no, 'unexpected indentation');
|
|
138
|
+
if (/^ *- /.test(l.text)) break;
|
|
139
|
+
|
|
140
|
+
const m = l.text.trim().match(/^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
|
|
141
|
+
if (!m) throw new TaskParseError(l.no, `not a supported key: ${l.text.trim().slice(0, 40)}`);
|
|
142
|
+
const key = m[1];
|
|
143
|
+
const rest = m[2].trim();
|
|
144
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) {
|
|
145
|
+
throw new TaskParseError(l.no, `duplicate key "${key}"`);
|
|
146
|
+
}
|
|
147
|
+
sawKey = true;
|
|
148
|
+
|
|
149
|
+
if (rest === '|') {
|
|
150
|
+
// Literal block scalar. Everything more-indented, verbatim, dedented.
|
|
151
|
+
i++;
|
|
152
|
+
const buf = [];
|
|
153
|
+
let blockIndent = null;
|
|
154
|
+
while (i < all.length) {
|
|
155
|
+
const b = all[i];
|
|
156
|
+
if (b.text.trim() === '') { buf.push(''); i++; continue; }
|
|
157
|
+
const bInd = indentOf(b.text, b.no);
|
|
158
|
+
if (bInd <= ind) break;
|
|
159
|
+
if (blockIndent === null) blockIndent = bInd;
|
|
160
|
+
if (bInd < blockIndent) break;
|
|
161
|
+
buf.push(b.text.slice(blockIndent));
|
|
162
|
+
i++;
|
|
163
|
+
}
|
|
164
|
+
while (buf.length && buf[buf.length - 1] === '') buf.pop();
|
|
165
|
+
out[key] = buf.join('\n');
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (rest === '') {
|
|
170
|
+
i++;
|
|
171
|
+
const [val, next] = parseBlock(all, i, ind + 2);
|
|
172
|
+
// An empty nested block is an error, not an empty object: it is almost
|
|
173
|
+
// always a truncated file rather than an intentional blank.
|
|
174
|
+
if ((Array.isArray(val) && val.length === 0) || (!Array.isArray(val) && Object.keys(val).length === 0)) {
|
|
175
|
+
throw new TaskParseError(l.no, `key "${key}" opens a block and nothing follows it`);
|
|
176
|
+
}
|
|
177
|
+
out[key] = val;
|
|
178
|
+
i = next;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
out[key] = scalar(rest, l.no);
|
|
183
|
+
i++;
|
|
184
|
+
}
|
|
185
|
+
if (!sawKey && i < all.length) throw new TaskParseError(all[i].no, 'expected a mapping');
|
|
186
|
+
return [out, i];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Parse the supported subset, or throw with a line number. Never guesses. */
|
|
190
|
+
export function parseTaskYaml(src) {
|
|
191
|
+
const all = lines(src);
|
|
192
|
+
if (all.some((l) => l.text.trim() === '---' || l.text.trim() === '...')) {
|
|
193
|
+
throw new TaskParseError(
|
|
194
|
+
all.find((l) => l.text.trim() === '---' || l.text.trim() === '...').no,
|
|
195
|
+
'document markers are not supported: one task per file, no front matter',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
let i = 0;
|
|
199
|
+
while (i < all.length && (all[i].text.trim() === '' || all[i].text.trim().startsWith('#'))) i++;
|
|
200
|
+
const [val] = parseBlock(all, i, 0);
|
|
201
|
+
return val;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The fields a task must carry for an eval to mean anything.
|
|
206
|
+
*
|
|
207
|
+
* `success` is required and must be checkable without a model: an eval whose
|
|
208
|
+
* pass condition is itself a judgement call cannot produce a comparable number
|
|
209
|
+
* across arms, which is the entire point of running two.
|
|
210
|
+
*/
|
|
211
|
+
export const REQUIRED = ['id', 'title', 'category', 'prompt', 'success'];
|
|
212
|
+
|
|
213
|
+
export function validateTask(task, where = 'task') {
|
|
214
|
+
const problems = [];
|
|
215
|
+
if (task === null || typeof task !== 'object' || Array.isArray(task)) {
|
|
216
|
+
return [`${where}: the file must be a mapping at the top level`];
|
|
217
|
+
}
|
|
218
|
+
for (const k of REQUIRED) {
|
|
219
|
+
if (!(k in task)) problems.push(`${where}: missing required key "${k}"`);
|
|
220
|
+
}
|
|
221
|
+
if (typeof task.prompt === 'string' && task.prompt.trim().length < 20) {
|
|
222
|
+
problems.push(`${where}: prompt is too short to be a real task`);
|
|
223
|
+
}
|
|
224
|
+
if ('runs' in task && (!Number.isInteger(task.runs) || task.runs < 1 || task.runs > 20)) {
|
|
225
|
+
problems.push(`${where}: runs must be an integer between 1 and 20`);
|
|
226
|
+
}
|
|
227
|
+
const s = task.success;
|
|
228
|
+
if (s !== undefined) {
|
|
229
|
+
if (typeof s !== 'object' || Array.isArray(s)) {
|
|
230
|
+
problems.push(`${where}: success must be a mapping of checkable conditions`);
|
|
231
|
+
} else if (!('file_exists' in s) && !('contains' in s) && !('sections' in s)) {
|
|
232
|
+
problems.push(
|
|
233
|
+
`${where}: success needs at least one machine-checkable condition ` +
|
|
234
|
+
`(file_exists, contains, or sections). A model-judged pass cannot be compared across arms.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return problems;
|
|
239
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADOPT / DECLINE / INCONCLUSIVE.
|
|
3
|
+
*
|
|
4
|
+
* The whole reason to run two arms is to get a number that a code review cannot
|
|
5
|
+
* produce. That only works if the verdict refuses to overclaim, so the rules
|
|
6
|
+
* here are deliberately conservative in one direction and not the other:
|
|
7
|
+
*
|
|
8
|
+
* - DECLINE is cheap to reach. Making a candidate worse, or tripping a
|
|
9
|
+
* security signal, is enough on its own.
|
|
10
|
+
* - ADOPT is expensive to reach. It needs a real effect AND enough runs to
|
|
11
|
+
* tell that effect apart from noise.
|
|
12
|
+
* - INCONCLUSIVE is the default, and it is a RESULT rather than a failure.
|
|
13
|
+
*
|
|
14
|
+
* ## Why one run per arm can never be ADOPT
|
|
15
|
+
*
|
|
16
|
+
* Agent runs are noisy: the same task, same model, temperature 0, still varies
|
|
17
|
+
* in turns and tokens because tool output and timing vary. With a single run
|
|
18
|
+
* per arm there is no way to distinguish a real improvement from one lucky
|
|
19
|
+
* sample, so `MIN_RUNS_FOR_ADOPT` gates it. A cheap one-run eval is still
|
|
20
|
+
* useful, because DECLINE and the security clause both remain reachable: it can
|
|
21
|
+
* tell you a candidate is bad, it just cannot tell you one is good.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Below this, an improvement is not distinguishable from run-to-run noise. */
|
|
25
|
+
export const MIN_RUNS_FOR_ADOPT = 3;
|
|
26
|
+
|
|
27
|
+
/** A cost or turn delta smaller than this is treated as no change. */
|
|
28
|
+
export const MATERIAL_DELTA = 0.10;
|
|
29
|
+
|
|
30
|
+
export const VERDICTS = ['ADOPT', 'DECLINE', 'INCONCLUSIVE'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ClemVault's stricter clause, layered ON TOP of the benchmark numbers.
|
|
34
|
+
*
|
|
35
|
+
* `.claude/rules/security.md`: undeclared network access or permission drift
|
|
36
|
+
* observed in the sandbox is an automatic DECLINE regardless of how good the
|
|
37
|
+
* numbers are. A tool that makes the agent faster while phoning somewhere it
|
|
38
|
+
* never declared is not a tool worth adopting, and the benchmark cannot see
|
|
39
|
+
* that, which is exactly why this is a separate gate rather than a weight.
|
|
40
|
+
*/
|
|
41
|
+
export function securityOverride(observations = {}) {
|
|
42
|
+
const reasons = [];
|
|
43
|
+
const net = observations.undeclared_network;
|
|
44
|
+
if (Array.isArray(net) && net.length) {
|
|
45
|
+
reasons.push(`undeclared network access to ${net.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
const perm = observations.permission_escalation;
|
|
48
|
+
if (Array.isArray(perm) && perm.length) {
|
|
49
|
+
reasons.push(`permission escalation: ${perm.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
return reasons;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
55
|
+
|
|
56
|
+
/** Summarise one arm's runs into the numbers the report compares. */
|
|
57
|
+
export function summariseArm(runs) {
|
|
58
|
+
const ok = runs.filter((r) => r.success);
|
|
59
|
+
return {
|
|
60
|
+
runs: runs.length,
|
|
61
|
+
successes: ok.length,
|
|
62
|
+
success_rate: runs.length ? ok.length / runs.length : 0,
|
|
63
|
+
// Averaged over SUCCESSFUL runs only. A failed run's turn count is not
|
|
64
|
+
// comparable: it may have bailed early or spun until a cap, and either way
|
|
65
|
+
// it is not the cost of doing the job.
|
|
66
|
+
mean_turns: mean(ok.map((r) => r.turns).filter((n) => typeof n === 'number')),
|
|
67
|
+
mean_tool_calls: mean(ok.map((r) => r.tool_calls).filter((n) => typeof n === 'number')),
|
|
68
|
+
mean_tokens: mean(ok.map((r) => r.tokens).filter((n) => typeof n === 'number')),
|
|
69
|
+
mean_cost_usd: mean(ok.map((r) => r.cost_usd).filter((n) => typeof n === 'number')),
|
|
70
|
+
mean_wall_ms: mean(ok.map((r) => r.wall_ms).filter((n) => typeof n === 'number')),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rel = (base, cand) => {
|
|
75
|
+
if (typeof base !== 'number' || typeof cand !== 'number' || base === 0) return null;
|
|
76
|
+
return (cand - base) / base;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compare two summarised arms and return a verdict with its reasoning.
|
|
81
|
+
*
|
|
82
|
+
* `observations` carries anything the sandbox saw that the task did not measure,
|
|
83
|
+
* and it outranks every number below it.
|
|
84
|
+
*/
|
|
85
|
+
export function decide({ baseline, candidate, observations = {} }) {
|
|
86
|
+
const why = [];
|
|
87
|
+
|
|
88
|
+
const blocked = securityOverride(observations);
|
|
89
|
+
if (blocked.length) {
|
|
90
|
+
return {
|
|
91
|
+
verdict: 'DECLINE',
|
|
92
|
+
confidence: 'high',
|
|
93
|
+
reasons: blocked.map((r) => `SECURITY: ${r}`),
|
|
94
|
+
note:
|
|
95
|
+
'ClemVault rule: a security signal in the sandbox is an automatic DECLINE ' +
|
|
96
|
+
'regardless of benchmark numbers. This is not a close call and is not ' +
|
|
97
|
+
'weighed against the deltas.',
|
|
98
|
+
deltas: null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const deltas = {
|
|
103
|
+
success_rate: candidate.success_rate - baseline.success_rate,
|
|
104
|
+
turns: rel(baseline.mean_turns, candidate.mean_turns),
|
|
105
|
+
tool_calls: rel(baseline.mean_tool_calls, candidate.mean_tool_calls),
|
|
106
|
+
tokens: rel(baseline.mean_tokens, candidate.mean_tokens),
|
|
107
|
+
cost_usd: rel(baseline.mean_cost_usd, candidate.mean_cost_usd),
|
|
108
|
+
wall_ms: rel(baseline.mean_wall_ms, candidate.mean_wall_ms),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// 1. Made it worse. Cheap to reach, deliberately.
|
|
112
|
+
if (candidate.success_rate < baseline.success_rate) {
|
|
113
|
+
return {
|
|
114
|
+
verdict: 'DECLINE',
|
|
115
|
+
confidence: candidate.runs >= MIN_RUNS_FOR_ADOPT ? 'high' : 'low',
|
|
116
|
+
reasons: [
|
|
117
|
+
`success rate fell from ${(baseline.success_rate * 100).toFixed(0)}% to ` +
|
|
118
|
+
`${(candidate.success_rate * 100).toFixed(0)}%`,
|
|
119
|
+
],
|
|
120
|
+
note:
|
|
121
|
+
candidate.runs < MIN_RUNS_FOR_ADOPT
|
|
122
|
+
? `Only ${candidate.runs} run(s) per arm, so this is a signal rather than a measurement. ` +
|
|
123
|
+
'It is still a DECLINE: a candidate has to earn adoption, not merely survive it.'
|
|
124
|
+
: null,
|
|
125
|
+
deltas,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Not enough evidence to say anything positive.
|
|
130
|
+
if (candidate.runs < MIN_RUNS_FOR_ADOPT || baseline.runs < MIN_RUNS_FOR_ADOPT) {
|
|
131
|
+
return {
|
|
132
|
+
verdict: 'INCONCLUSIVE',
|
|
133
|
+
confidence: 'low',
|
|
134
|
+
reasons: [
|
|
135
|
+
`${Math.min(candidate.runs, baseline.runs)} run(s) per arm is below the ` +
|
|
136
|
+
`${MIN_RUNS_FOR_ADOPT} needed to tell an improvement from run-to-run noise`,
|
|
137
|
+
],
|
|
138
|
+
note:
|
|
139
|
+
'INCONCLUSIVE never promotes. Re-run with more runs, or with a task that ' +
|
|
140
|
+
'exercises the candidate harder. A cheap run can still DECLINE, which is ' +
|
|
141
|
+
'why it was worth doing.',
|
|
142
|
+
deltas,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 3. Real improvement.
|
|
147
|
+
if (candidate.success_rate > baseline.success_rate) {
|
|
148
|
+
why.push(
|
|
149
|
+
`success rate rose from ${(baseline.success_rate * 100).toFixed(0)}% to ` +
|
|
150
|
+
`${(candidate.success_rate * 100).toFixed(0)}%`,
|
|
151
|
+
);
|
|
152
|
+
return { verdict: 'ADOPT', confidence: 'medium', reasons: why, note: null, deltas };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Same success, materially cheaper or shorter.
|
|
156
|
+
const cheaper = ['cost_usd', 'tokens', 'turns'].filter(
|
|
157
|
+
(k) => typeof deltas[k] === 'number' && deltas[k] <= -MATERIAL_DELTA,
|
|
158
|
+
);
|
|
159
|
+
const dearer = ['cost_usd', 'tokens', 'turns'].filter(
|
|
160
|
+
(k) => typeof deltas[k] === 'number' && deltas[k] >= MATERIAL_DELTA,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (cheaper.length && !dearer.length) {
|
|
164
|
+
return {
|
|
165
|
+
verdict: 'ADOPT',
|
|
166
|
+
confidence: 'medium',
|
|
167
|
+
reasons: [
|
|
168
|
+
'same success rate, and materially cheaper on ' +
|
|
169
|
+
cheaper.map((k) => `${k} ${(deltas[k] * 100).toFixed(0)}%`).join(', '),
|
|
170
|
+
],
|
|
171
|
+
note: null,
|
|
172
|
+
deltas,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (dearer.length && !cheaper.length) {
|
|
177
|
+
return {
|
|
178
|
+
verdict: 'DECLINE',
|
|
179
|
+
confidence: 'medium',
|
|
180
|
+
reasons: [
|
|
181
|
+
'no success-rate gain, and materially more expensive on ' +
|
|
182
|
+
dearer.map((k) => `${k} +${(deltas[k] * 100).toFixed(0)}%`).join(', '),
|
|
183
|
+
],
|
|
184
|
+
note: 'A tool that costs more and achieves the same is a regression with a nicer name.',
|
|
185
|
+
deltas,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
verdict: 'INCONCLUSIVE',
|
|
191
|
+
confidence: 'medium',
|
|
192
|
+
reasons: ['no material difference in success rate, cost, tokens or turns'],
|
|
193
|
+
note:
|
|
194
|
+
'The candidate neither helped nor hurt on this task. That is a real finding: ' +
|
|
195
|
+
'either it is not for this workload, or the task does not exercise it. ' +
|
|
196
|
+
'INCONCLUSIVE never promotes.',
|
|
197
|
+
deltas,
|
|
198
|
+
};
|
|
199
|
+
}
|