@arjunkhera/atlas 0.3.24 → 0.3.26
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/plugin.json +1 -1
- package/door/cli.mjs +17 -0
- package/door/lib/view.mjs +46 -0
- package/package.json +2 -1
- package/skills/lead/SKILL.md +1 -0
- package/skills/tests/SKILL.md +27 -2
- package/tests/contract.mjs +124 -13
- package/tests/drivers/agent.mjs +109 -0
- package/tests/drivers/index.mjs +15 -2
- package/tests/evidence.mjs +2 -1
- package/tests/link-check.mjs +5 -3
- package/tests/model.mjs +263 -0
- package/tests/scenario.mjs +15 -0
- package/tests/tests-yaml.mjs +35 -5
- package/tests/verdict.mjs +12 -3
- package/view/page.html +563 -0
- package/work/lib/questions.mjs +17 -56
- package/work/lib/verb-fields.mjs +3 -3
- package/work/lib/view-rules.mjs +279 -0
package/door/cli.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
|
|
|
25
25
|
import { checkPaths, LIMITS } from './lib/ste.mjs';
|
|
26
26
|
import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
|
|
27
27
|
import { writeDesignPage, readTracker } from './lib/design-build.mjs';
|
|
28
|
+
import { writeViewPage, viewCapabilities } from './lib/view.mjs';
|
|
28
29
|
import { loadPrivateTerms } from './lib/privacy.mjs';
|
|
29
30
|
import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
|
|
30
31
|
import { proofCommand as testsProof } from './lib/proof.mjs';
|
|
@@ -59,6 +60,10 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
59
60
|
--out <file> the page; default docs/artifacts/<folder name>.html
|
|
60
61
|
--draft build even when the check has findings, and mark the page a draft
|
|
61
62
|
|
|
63
|
+
atlas view build --product <id> build the project view page of one product: the four views, read live from the tracker
|
|
64
|
+
--name <name> the product's name on the page; default the id with a capital
|
|
65
|
+
--out <file> the page; default docs/artifacts/<id>-view.html
|
|
66
|
+
|
|
62
67
|
atlas tests check --root <area> run the link check on the tests of an area
|
|
63
68
|
--halves <list> static, lint, run; default all three
|
|
64
69
|
--evidence <dir> where the evidence files are; default <area>/test/evidence
|
|
@@ -120,6 +125,7 @@ export const FLAGS = Object.freeze({
|
|
|
120
125
|
check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
|
|
121
126
|
ste: { share: 'switch', terms: 'value' },
|
|
122
127
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
128
|
+
view: { product: 'value', name: 'value', out: 'value' },
|
|
123
129
|
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch' },
|
|
124
130
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
|
|
125
131
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -296,6 +302,16 @@ function designCommand(chosen) {
|
|
|
296
302
|
return count ? 1 : 0;
|
|
297
303
|
}
|
|
298
304
|
|
|
305
|
+
function viewCommand(chosen) {
|
|
306
|
+
if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
|
|
307
|
+
const product = chosen.product;
|
|
308
|
+
const out = resolve(chosen.out ?? join('docs', 'artifacts', `${product}-view.html`));
|
|
309
|
+
const page = writeViewPage(out, { packageRoot: PACKAGE_ROOT, product, name: chosen.name, version: ATLAS_VERSION });
|
|
310
|
+
line(`Wrote ${out}: the project view of ${page.name} (product ${page.product}). It holds no records; it reads them at open.`);
|
|
311
|
+
line(`Publish it with this capability, and no other: ${JSON.stringify(viewCapabilities())}`);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
299
315
|
async function testsCommand(chosen) {
|
|
300
316
|
const root = rootOf(chosen);
|
|
301
317
|
const what = chosen._[1];
|
|
@@ -343,6 +359,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
343
359
|
case 'check': return checkCommand(chosen);
|
|
344
360
|
case 'ste': return steCommand(chosen);
|
|
345
361
|
case 'design': return designCommand(chosen);
|
|
362
|
+
case 'view': return viewCommand(chosen);
|
|
346
363
|
case 'tests': return testsCommand(chosen);
|
|
347
364
|
default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
|
|
348
365
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The project view page builder: `atlas view build`. It puts the view rules
|
|
2
|
+
// (work/lib/view-rules.mjs) and one product id into the page template
|
|
3
|
+
// (view/page.html), and writes one page. The page holds no record data: it
|
|
4
|
+
// reads the tracker at open, through the viewer's Engram connector.
|
|
5
|
+
// Design: docs/design-docs/project-view (tracker item 6dd1761a).
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
// The one connector grant the page needs, for the Artifact publish. Both
|
|
10
|
+
// tools only read.
|
|
11
|
+
export const VIEW_CONNECTOR = Object.freeze({ server: 'Engram', tools: Object.freeze(['entities_list', 'get']) });
|
|
12
|
+
export const viewCapabilities = () => ({ mcp: { servers: [{ server: VIEW_CONNECTOR.server, tools: [...VIEW_CONNECTOR.tools] }] } });
|
|
13
|
+
|
|
14
|
+
const PRODUCT_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
15
|
+
const quoted = (value) => JSON.stringify(String(value)).slice(1, -1).replace(/</g, '\\u003c');
|
|
16
|
+
const plain = (value) => String(value).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]);
|
|
17
|
+
|
|
18
|
+
export function buildViewPage({ packageRoot, product, name, version }) {
|
|
19
|
+
if (!PRODUCT_ID.test(String(product ?? ''))) throw new Error('give a product id: atlas view build --product <id>, such as atlas');
|
|
20
|
+
const label = String(name ?? '').trim() || product.charAt(0).toUpperCase() + product.slice(1);
|
|
21
|
+
if (label.length > 80) throw new Error('--name is at most 80 characters');
|
|
22
|
+
const template = readFileSync(join(packageRoot, 'view', 'page.html'), 'utf8');
|
|
23
|
+
const rules = readFileSync(join(packageRoot, 'work', 'lib', 'view-rules.mjs'), 'utf8');
|
|
24
|
+
if (/<\/script/i.test(rules)) throw new Error('view-rules.mjs holds "</script", so it cannot go into a page');
|
|
25
|
+
const swaps = {
|
|
26
|
+
'/*@@VIEW_RULES@@*/': rules,
|
|
27
|
+
'@@PRODUCT@@': quoted(product),
|
|
28
|
+
'@@PRODUCT_NAME@@': quoted(label),
|
|
29
|
+
'@@ATLAS_VERSION@@': quoted(version ?? ''),
|
|
30
|
+
'@@TITLE@@': plain(`${label} project view`),
|
|
31
|
+
};
|
|
32
|
+
let html = template;
|
|
33
|
+
for (const [mark, value] of Object.entries(swaps)) {
|
|
34
|
+
if (!html.includes(mark)) throw new Error(`the page template has no ${mark}`);
|
|
35
|
+
html = html.split(mark).join(value);
|
|
36
|
+
}
|
|
37
|
+
if (/@@[A-Z_]+@@/.test(html)) throw new Error('the page template has a mark the builder does not fill');
|
|
38
|
+
return { html, product, name: label };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeViewPage(out, options) {
|
|
42
|
+
const page = buildViewPage(options);
|
|
43
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
44
|
+
writeFileSync(out, page.html);
|
|
45
|
+
return page;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arjunkhera/atlas",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.26",
|
|
4
4
|
"description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"work/",
|
|
22
22
|
"shape/",
|
|
23
23
|
"tests/",
|
|
24
|
+
"view/",
|
|
24
25
|
"skills/",
|
|
25
26
|
"agents/",
|
|
26
27
|
".claude-plugin/",
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -102,6 +102,7 @@ set. A test keeps the two lists equal.
|
|
|
102
102
|
| `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
|
|
103
103
|
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
|
|
104
104
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
105
|
+
| `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
|
|
105
106
|
|
|
106
107
|
A person merges every file that `atlas tooling` writes.
|
|
107
108
|
A person merges every file that `atlas tests write` writes.
|
package/skills/tests/SKILL.md
CHANGED
|
@@ -290,14 +290,39 @@ The adapter binds the library to the product. All its parts are optional:
|
|
|
290
290
|
`standIns`, `warmUp`, `drivers`, `functions`, `loadModule`, `makers`, `signIns`,
|
|
291
291
|
`data` and `actions`. The header of `test/atlas/contract.mjs` describes each one.
|
|
292
292
|
|
|
293
|
-
A result is `pass`, `fail`, `not checked`, `not exercised`, `blocked
|
|
294
|
-
`not here`. A test is red when any assertion is not `pass`. The one
|
|
293
|
+
A result is `pass`, `fail`, `not checked`, `not exercised`, `blocked`,
|
|
294
|
+
`unsure` or `not here`. A test is red when any assertion is not `pass`. The one
|
|
295
295
|
exception is `not here`: the scenario does not run in that environment, it is
|
|
296
296
|
not counted, and the test passes.
|
|
297
297
|
|
|
298
298
|
After the product is ready, a lost connection, a server exit or a time-out is
|
|
299
299
|
`fail`. `blocked` is for a guard refusal, a start-up fault and the first-call check.
|
|
300
300
|
|
|
301
|
+
### A model in the test
|
|
302
|
+
|
|
303
|
+
A model makes a test vary and costs money. Three parts of the kit handle it.
|
|
304
|
+
Each one needs `budget.money` in `tests.yaml` (the cap for one run),
|
|
305
|
+
`guards.model-hosts: [api.anthropic.com]`, and the key `MODEL_API_KEY` in
|
|
306
|
+
`secrets`. These are guard parts, so the owner sets them.
|
|
307
|
+
|
|
308
|
+
1. **`repeat: { runs: 3, pass: 3, why: "..." }`** in the front matter. The body
|
|
309
|
+
runs `runs` times, each time with a new run object (`run.round` is 1, 2, 3).
|
|
310
|
+
An assertion passes when it passed in `pass` runs. Use it only when a model
|
|
311
|
+
is part of the product. A steady subject that fails sometimes is flaky.
|
|
312
|
+
2. **A judged assertion** names one judge of `tests.yaml` in backticks. Settle it
|
|
313
|
+
with `await run.judge(id, { evidence })`, never with `run.check`. Give the
|
|
314
|
+
judge the smallest evidence that answers the words. When the judge's runs
|
|
315
|
+
disagree, the result is `unsure`.
|
|
316
|
+
3. **The agent way in** (`driver: agent`) runs `claude -p`. The agent holds
|
|
317
|
+
no built-in tools, only the MCP tools that the way in lists. `call({ prompt })` returns
|
|
318
|
+
`body.reply` and `body.steps`: each tool call, its input and its result.
|
|
319
|
+
Check the steps and the end state with `run.check`; judge the reply.
|
|
320
|
+
|
|
321
|
+
A product that calls a model itself (a router) reads its own cost. Count it with
|
|
322
|
+
`run.spend(usd)` and check the rest first with `run.budgetLeft()`.
|
|
323
|
+
Model scenarios list `nightly` in `runs-in`, not `ci`. They run each night, not
|
|
324
|
+
on each pull request (Q-C).
|
|
325
|
+
|
|
301
326
|
## Sort a red test
|
|
302
327
|
|
|
303
328
|
Do not edit a red test before you know why it is red. There are four causes:
|
package/tests/contract.mjs
CHANGED
|
@@ -19,7 +19,14 @@
|
|
|
19
19
|
// 4. Writes the evidence file (evidence.mjs): at the start, and after each
|
|
20
20
|
// assertion.
|
|
21
21
|
// 5. A test fails (node:test red) when any assertion is not `pass`. That
|
|
22
|
-
// holds for `fail`, `not checked`, `not exercised` and `
|
|
22
|
+
// holds for `fail`, `not checked`, `not exercised`, `blocked` and `unsure`.
|
|
23
|
+
// 6. With `repeat: { runs, pass, why }` in the front matter, the body runs
|
|
24
|
+
// `runs` times for each way in, each time with a new run object. An
|
|
25
|
+
// assertion passes when it passed in at least `pass` runs. The evidence
|
|
26
|
+
// keeps the result of each run. A block stops the repeat at once.
|
|
27
|
+
// 7. A judged assertion is settled by `await run.judge(id, { evidence })`, never
|
|
28
|
+
// by run.check. The judge of tests.yaml decides: pass, fail or `unsure`.
|
|
29
|
+
// Model calls count against `budget.money` of tests.yaml (model.mjs).
|
|
23
30
|
// When the scenario's `runs-in` excludes the environment, the result is
|
|
24
31
|
// `not here` and the test passes.
|
|
25
32
|
//
|
|
@@ -48,13 +55,14 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
48
55
|
import { fileURLToPath } from 'node:url';
|
|
49
56
|
import { Blocked, GateFailed } from './errors.mjs';
|
|
50
57
|
import { Redactor } from './redact.mjs';
|
|
51
|
-
import { readTestsYaml, checkTestsYaml, compareGuardParts } from './tests-yaml.mjs';
|
|
58
|
+
import { readTestsYaml, checkTestsYaml, compareGuardParts, MODEL_KEY } from './tests-yaml.mjs';
|
|
52
59
|
import { readScenario, addFingerprints, combinations, pairs } from './scenario.mjs';
|
|
53
60
|
import { Evidence, clip } from './evidence.mjs';
|
|
54
61
|
import { makeFresh, makeRunId } from './fresh.mjs';
|
|
55
62
|
import { startEnvironment, provideSecrets } from './environment.mjs';
|
|
56
63
|
import { makeDriver } from './drivers/index.mjs';
|
|
57
64
|
import { parseLimit, waitUntil } from './wait.mjs';
|
|
65
|
+
import { Meter, askJudge, killModelCalls, parseMoney, requireModelHost } from './model.mjs';
|
|
58
66
|
|
|
59
67
|
export { waitUntil };
|
|
60
68
|
export const defineAdapter = (adapter) => adapter;
|
|
@@ -120,7 +128,8 @@ export function scenario(path, body, options = {}) {
|
|
|
120
128
|
describe(`${sc.id}: ${sc.title}`, () => {
|
|
121
129
|
const state = {
|
|
122
130
|
redactor: new Redactor(), evidence: null, envPromise: null, env: null,
|
|
123
|
-
drivers: new Map(), personas: new Map(), cleanups: [], fresh: null,
|
|
131
|
+
drivers: new Map(), personas: new Map(), cleanups: [], fresh: null, provided: {},
|
|
132
|
+
meter: new Meter({ evidenceDir: ctx.evidenceDir, runId: RUN_ID, cap: parseMoney(tests.budget?.money), environment, runIdGiven: Boolean(process.env.ATLAS_RUN_ID || process.env.GITHUB_RUN_ID) }),
|
|
124
133
|
};
|
|
125
134
|
const evidence = () => {
|
|
126
135
|
state.evidence ??= new Evidence({
|
|
@@ -132,11 +141,24 @@ export function scenario(path, body, options = {}) {
|
|
|
132
141
|
const getEnv = () => {
|
|
133
142
|
state.envPromise ??= (async () => {
|
|
134
143
|
if (ctx.guardRefusal) throw new Blocked(ctx.guardRefusal);
|
|
144
|
+
// The model keys: of each judge that a judged assertion names, and of each way in that holds a model.
|
|
145
|
+
const judged = sc.assertions.filter((a) => a.kind === 'judged').flatMap((a) => a.names).filter((n) => Object.hasOwn(tests.judges ?? {}, n));
|
|
146
|
+
const modelKeys = [
|
|
147
|
+
...judged.map((n) => tests.judges[n].key ?? MODEL_KEY),
|
|
148
|
+
// An agent way in uses the model key; any other way in that names a `key` (a product that calls a model) gets it too.
|
|
149
|
+
...sc.ways.map((w) => tests['ways-in']?.[w]).filter((w) => w && (w.driver === 'agent' || w.key)).map((w) => w.key ?? MODEL_KEY),
|
|
150
|
+
].filter(Boolean);
|
|
135
151
|
const named = [...new Set([...sc.start, ...sc.steps, ...sc.assertions].flatMap((x) => x.names))]
|
|
136
152
|
.filter((n) => Object.hasOwn(tests.secrets ?? {}, n));
|
|
137
153
|
const provided = provideSecrets(named, { root, tests });
|
|
154
|
+
// A model key goes to the judge and the driver only, never into the product's environment
|
|
155
|
+
// (section 15, rule 1): the product is not the judge.
|
|
156
|
+
const models = [...new Set(modelKeys)].filter((n) => Object.hasOwn(tests.secrets ?? {}, n) && !named.includes(n));
|
|
157
|
+
const modelProvided = provideSecrets(models, { root, tests });
|
|
158
|
+
for (const value of Object.values(modelProvided)) state.redactor.add(value);
|
|
159
|
+
state.provided = { ...provided, ...modelProvided };
|
|
138
160
|
const env = await startEnvironment({ root, tests, name: environment, runId: RUN_ID, redactor: state.redactor, adapter, provided });
|
|
139
|
-
evidence().record.provided_secrets = named;
|
|
161
|
+
evidence().record.provided_secrets = [...named, ...models];
|
|
140
162
|
state.env = env;
|
|
141
163
|
return env;
|
|
142
164
|
})();
|
|
@@ -160,12 +182,60 @@ export function scenario(path, body, options = {}) {
|
|
|
160
182
|
ev.setWay(way, { started: new Date().toISOString() });
|
|
161
183
|
let run = null;
|
|
162
184
|
let error = null;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
185
|
+
const repeat = sc.repeat;
|
|
186
|
+
const rounds = new Map();
|
|
187
|
+
for (let round = 1; round <= (repeat?.runs ?? 1); round += 1) {
|
|
188
|
+
error = null;
|
|
189
|
+
if (repeat && round > 1) {
|
|
190
|
+
// A new run starts clean: no result, no driver and no persona of the run before.
|
|
191
|
+
run?.close();
|
|
192
|
+
await cleanUp(state, run, ev);
|
|
193
|
+
await closeDrivers(state);
|
|
194
|
+
state.personas.clear();
|
|
195
|
+
for (const a of ownEntries(sc, way, ev)) { a.result = 'not checked'; a.proof = null; }
|
|
196
|
+
ev.write();
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const env = await getEnv();
|
|
200
|
+
run = makeRun({ ctx, state, way, env, ev, adapter, round: repeat ? round : null });
|
|
201
|
+
await withLimit(Promise.resolve().then(() => body(run, way)), limitMs, sc.fields.limit);
|
|
202
|
+
} catch (thrown) {
|
|
203
|
+
error = thrown;
|
|
204
|
+
// A run that stopped (a time limit, say) may leave a model call going. Stop it before the next run.
|
|
205
|
+
killModelCalls();
|
|
206
|
+
}
|
|
207
|
+
if (!repeat) break;
|
|
208
|
+
const stopped = error && !(error instanceof GateFailed) ? state.redactor.text(String(error.message ?? error)) : null;
|
|
209
|
+
for (const a of ownEntries(sc, way, ev)) {
|
|
210
|
+
const list = rounds.get(a) ?? [];
|
|
211
|
+
const proof = a.proof ?? {};
|
|
212
|
+
list.push({
|
|
213
|
+
run: round, result: error instanceof Blocked && a.result === 'not checked' ? 'blocked' : a.result,
|
|
214
|
+
...(proof.error ? { error: proof.error } : proof.why ? { why: proof.why } : {}),
|
|
215
|
+
...(stopped ? { stopped } : {}),
|
|
216
|
+
});
|
|
217
|
+
rounds.set(a, list);
|
|
218
|
+
}
|
|
219
|
+
// A block is no product result: the repeat stops, and settle() marks the way blocked.
|
|
220
|
+
if (error instanceof Blocked) break;
|
|
221
|
+
}
|
|
222
|
+
if (repeat) {
|
|
223
|
+
const blocked = error instanceof Blocked;
|
|
224
|
+
for (const [a, list] of rounds) {
|
|
225
|
+
if (list.every((r) => r.result === 'not here')) continue;
|
|
226
|
+
const passed = list.filter((r) => r.result === 'pass').length;
|
|
227
|
+
const unsure = list.filter((r) => r.result === 'unsure').length;
|
|
228
|
+
const ok = passed >= repeat.pass;
|
|
229
|
+
// The evidence keeps every run, also when a block stopped the repeat. A blocked repeat is never a pass.
|
|
230
|
+
a.result = blocked ? 'blocked' : ok ? 'pass' : unsure && passed + unsure >= repeat.pass ? 'unsure' : 'fail';
|
|
231
|
+
a.proof = {
|
|
232
|
+
repeat: { runs: repeat.runs, pass: repeat.pass, passed }, runs: list,
|
|
233
|
+
...(blocked ? { why: `the repeat stopped in run ${list.length}: ${state.redactor.text(error.message)}` } : ok ? {} : { error: `passed in ${passed} of ${list.length} runs; the rule is ${repeat.pass} of ${repeat.runs}` }),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
ev.write();
|
|
237
|
+
// Each run is settled in its proof. A stop of a run (not a block) is in the proof of its run.
|
|
238
|
+
if (!blocked) error = null;
|
|
169
239
|
}
|
|
170
240
|
const outcome = settle({ sc, way, ev, error });
|
|
171
241
|
// A failed run keeps a picture of each open page (a driver that has one), before the run closes.
|
|
@@ -179,12 +249,18 @@ export function scenario(path, body, options = {}) {
|
|
|
179
249
|
}
|
|
180
250
|
|
|
181
251
|
after(async () => {
|
|
182
|
-
|
|
252
|
+
killModelCalls();
|
|
253
|
+
await closeDrivers(state);
|
|
183
254
|
try { await (await state.envPromise)?.stop(); } catch { /* it did not start */ }
|
|
184
255
|
});
|
|
185
256
|
});
|
|
186
257
|
}
|
|
187
258
|
|
|
259
|
+
async function closeDrivers(state) {
|
|
260
|
+
for (const driver of state.drivers.values()) { try { await (await driver).close(); } catch { /* the stop goes on */ } }
|
|
261
|
+
state.drivers.clear();
|
|
262
|
+
}
|
|
263
|
+
|
|
188
264
|
async function keepOnFail(state, label) {
|
|
189
265
|
for (const pending of state.drivers.values()) {
|
|
190
266
|
try { await (await pending).keepOnFail?.(`fail-${label}`); } catch { /* a missing picture is not the news */ }
|
|
@@ -202,6 +278,11 @@ async function cleanUp(state, run, ev) {
|
|
|
202
278
|
for (const fn of list) { try { await fn(); } catch (error) { ev.note(`clean-up failed: ${error.message}`); } }
|
|
203
279
|
}
|
|
204
280
|
|
|
281
|
+
// The entries that a run through `way` settles: its own, and those of a way in that only a step names.
|
|
282
|
+
function ownEntries(sc, way, ev) {
|
|
283
|
+
return ev.record.assertions.filter((a) => a.way === way || !sc.through.includes(a.way));
|
|
284
|
+
}
|
|
285
|
+
|
|
205
286
|
// Turns the end of a body into results, a verdict and a list of problems.
|
|
206
287
|
function settle({ sc, way, ev, error }) {
|
|
207
288
|
// The entries of this way in, and the entries of a way in that only a step names (the page).
|
|
@@ -251,7 +332,7 @@ function settle({ sc, way, ev, error }) {
|
|
|
251
332
|
|
|
252
333
|
// ---------------------------------------------------------------- the run object
|
|
253
334
|
|
|
254
|
-
function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
335
|
+
function makeRun({ ctx, state, way, env, ev, adapter, round = null }) {
|
|
255
336
|
const { sc, tests, root } = ctx;
|
|
256
337
|
state.fresh ??= makeFresh(RUN_ID, tests.data, adapter.data);
|
|
257
338
|
const cleanups = [];
|
|
@@ -271,7 +352,7 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
271
352
|
// A driver is shared by the runs of a scenario (the page way in has no run of its own), so it reaches the
|
|
272
353
|
// record and the attach of the run that is live now (state.live), not those of the run that made it.
|
|
273
354
|
// attach: where a driver keeps a file (a screenshot) as evidence, in the folder of this scenario.
|
|
274
|
-
state.drivers.set(wayName, makeDriver({ way: wayName, spec, env, root, hosts: env.hosts, redactor: state.redactor, record: (call) => state.live?.record(call), adapter, runId: RUN_ID, attach: (name) => state.live.attach(name) }));
|
|
355
|
+
state.drivers.set(wayName, makeDriver({ way: wayName, spec, env, root, hosts: env.hosts, redactor: state.redactor, record: (call) => state.live?.record(call), adapter, runId: RUN_ID, attach: (name) => state.live.attach(name), tests, secrets: state.provided, meter: state.meter }));
|
|
275
356
|
}
|
|
276
357
|
return state.drivers.get(wayName);
|
|
277
358
|
};
|
|
@@ -288,6 +369,7 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
288
369
|
const settleOne = (full, fn, opts = {}, mode) => {
|
|
289
370
|
open(`run.${mode}`);
|
|
290
371
|
const { a, fp } = find(full);
|
|
372
|
+
if (a.kind === 'judged' && mode !== 'exercise') throw new Error(`${a.id} is a judged assertion: settle it with await run.judge(id, { evidence })`);
|
|
291
373
|
// An assertion that reads a result of a step with its own way in (the page) belongs to that way.
|
|
292
374
|
const target = opts.way ?? (a.ways.includes(way) ? way : a.ways[0]);
|
|
293
375
|
if (!a.ways.includes(target)) throw new Error(`"${target}" is not a way in of ${a.id}: its ways are ${a.ways.join(', ')}`);
|
|
@@ -330,6 +412,8 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
330
412
|
|
|
331
413
|
const run = {
|
|
332
414
|
id: sc.id, title: sc.title, runId: RUN_ID, way, env, scenario: sc, tests, root, cleanups,
|
|
415
|
+
// The number of this run when the scenario has `repeat` (1, 2, ...); otherwise null.
|
|
416
|
+
round,
|
|
333
417
|
redact: (text) => state.redactor.text(text),
|
|
334
418
|
driver,
|
|
335
419
|
|
|
@@ -343,6 +427,33 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
343
427
|
// The test could not run. Never use it for a product result.
|
|
344
428
|
blocked: (why) => { throw new Blocked(why); },
|
|
345
429
|
|
|
430
|
+
// A judged assertion. The judge that its words name (in backticks) gets the
|
|
431
|
+
// words and the evidence, marked as data and redacted, never the test code.
|
|
432
|
+
// pass or fail when `agree` of its runs agree; otherwise `unsure`.
|
|
433
|
+
async judge(full, { evidence, way: wayName } = {}) {
|
|
434
|
+
open('run.judge');
|
|
435
|
+
const { a, fp } = find(full);
|
|
436
|
+
if (a.kind !== 'judged') throw new Error(`${a.id} is an exact assertion: settle it with run.check`);
|
|
437
|
+
const target = wayName ?? (a.ways.includes(way) ? way : a.ways[0]);
|
|
438
|
+
const calls = callsSince();
|
|
439
|
+
if (fp !== a.fp) { ev.setResult(a.fullId, target, 'fail', { error: `the fingerprint ${fp} is old: the words of ${a.id} now print ${a.fp}; convert the test again`, calls, settled_by: way }); return; }
|
|
440
|
+
const names = a.names.filter((n) => Object.hasOwn(tests.judges ?? {}, n));
|
|
441
|
+
if (names.length !== 1) throw new Blocked(`${a.id} must name exactly one judge of tests.yaml in backticks; it names ${names.length}`);
|
|
442
|
+
const judge = tests.judges[names[0]];
|
|
443
|
+
requireModelHost(tests, `the judge "${names[0]}"`);
|
|
444
|
+
const keyName = judge.key ?? MODEL_KEY;
|
|
445
|
+
const key = state.provided[keyName];
|
|
446
|
+
if (!key) throw new Blocked(`the judge "${names[0]}" needs the key ${keyName}: list it in secrets of tests.yaml, and give its value`);
|
|
447
|
+
const out = await askJudge({ name: names[0], judge, words: a.words, evidence, key, meter: state.meter, redactor: state.redactor });
|
|
448
|
+
open('run.judge');
|
|
449
|
+
ev.setResult(a.fullId, target, out.result, { judge: names[0], answers: out.answers, cost: out.cost, calls, settled_by: way, ...(out.result === 'pass' ? {} : { error: out.why }) });
|
|
450
|
+
},
|
|
451
|
+
// Money that a product's own model call spent (the router, say), read from its answer.
|
|
452
|
+
// It counts against budget.money with the judge and the agent calls.
|
|
453
|
+
spend: (usd, what = 'a product model call') => { open('run.spend'); return state.meter.add(usd, what); },
|
|
454
|
+
// What the run may still spend, in US$. A model call of the product checks it first.
|
|
455
|
+
budgetLeft: (what = 'a product model call') => state.meter.ensure(what),
|
|
456
|
+
|
|
346
457
|
// The rows of a rule table, as objects keyed by the header. A test that
|
|
347
458
|
// reads a table gets every row of the file at run time.
|
|
348
459
|
table(name, { fingerprint } = {}) {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The agent way in (design section 7.3): a headless agent that holds only the
|
|
2
|
+
// MCP tools that the way in names, run with `claude -p`.
|
|
3
|
+
//
|
|
4
|
+
// ways-in:
|
|
5
|
+
// agent:
|
|
6
|
+
// driver: agent
|
|
7
|
+
// model: claude-sonnet-5-5 # the product's own model (section 17, "One limit on Haiku 5.5")
|
|
8
|
+
// key: MODEL_API_KEY # a name in `secrets`
|
|
9
|
+
// tools-from: mcp # an mcp-stdio way in: its command and its env start the server
|
|
10
|
+
// tools: [get_meal_week, save_meal_week]
|
|
11
|
+
//
|
|
12
|
+
// The four limits of section 7.3, and how the driver keeps each one:
|
|
13
|
+
// 1. No built-in tools: --tools "" and --setting-sources "", and a new empty HOME.
|
|
14
|
+
// 2. Only the named MCP tools: one server, from the way in of `tools-from`, with
|
|
15
|
+
// --strict-mcp-config, and --allowedTools lists only the named tools.
|
|
16
|
+
// 3. Network to the local product and the model only. The agent has no tool that
|
|
17
|
+
// reaches a network. Its one server gets its addresses from the env of
|
|
18
|
+
// `tools-from`, and each one must be on guards.hosts, as for mcp-stdio. The
|
|
19
|
+
// model host must be on guards.model-hosts. A miss is `blocked`.
|
|
20
|
+
// The server starts with the model key taken out of its environment.
|
|
21
|
+
// 4. The data it reads is test data: the server is the product of this run. An area with
|
|
22
|
+
// outside text (`outside-text: true`) gets no agent until Atlas has its outside-text reader.
|
|
23
|
+
// What this does not limit: the server is product code, and it may reach what any way in of the
|
|
24
|
+
// product may reach. The guards on its addresses are the same as for mcp-stdio.
|
|
25
|
+
// The agent runs in a new empty folder (with --bare), so no CLAUDE.md of the repo reaches it.
|
|
26
|
+
// A call of a tool that the way in does not list is refused by the command, and listed in body.offList.
|
|
27
|
+
//
|
|
28
|
+
// call({ prompt }, { persona }) returns { status, body, isError, raw }:
|
|
29
|
+
// body = { reply, steps: [{ tool, input, result, isError }], turns, cost }
|
|
30
|
+
// `tool` is the bare tool name, without the mcp__<server>__ prefix.
|
|
31
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
32
|
+
import { Blocked } from '../errors.mjs';
|
|
33
|
+
import { requireLocalAddresses } from '../guards.mjs';
|
|
34
|
+
import { clip } from '../evidence.mjs';
|
|
35
|
+
import { modelEnv, requireModelHost, runClaude } from '../model.mjs';
|
|
36
|
+
|
|
37
|
+
const SERVER = 'product';
|
|
38
|
+
const TOOL_NAME = /^[A-Za-z0-9_-]+$/;
|
|
39
|
+
|
|
40
|
+
export function createAgentDriver({ way, spec, toolsSpec, fill, tests, hosts, productRoot, key, meter, record, redactor, commandFor }) {
|
|
41
|
+
const tools = Array.isArray(spec.tools) ? spec.tools.map(String) : [];
|
|
42
|
+
if (!spec.model) throw new Blocked(`the way in "${way}" uses the agent driver and names no model`);
|
|
43
|
+
if (!tools.length) throw new Blocked(`the way in "${way}" names no tools: an agent holds only the MCP tools that the way in lists`);
|
|
44
|
+
for (const t of tools) if (!TOOL_NAME.test(t)) throw new Blocked(`the way in "${way}" names the tool "${t}", which is not a plain tool name`);
|
|
45
|
+
if (!toolsSpec) throw new Blocked(`the way in "${way}" needs tools-from: an mcp-stdio way in that starts the product's MCP server`);
|
|
46
|
+
if (toolsSpec.driver !== 'mcp-stdio') throw new Blocked(`tools-from of "${way}" names a way in with the driver "${toolsSpec.driver}"; it must be mcp-stdio`);
|
|
47
|
+
if (tests?.['outside-text'] === true) throw new Blocked(`the way in "${way}" is an agent in an area with outside text; no agent runs there until Atlas has its outside-text reader (section 15, rule 13)`);
|
|
48
|
+
requireModelHost(tests, `the way in "${way}"`);
|
|
49
|
+
const env = Object.fromEntries(Object.entries(toolsSpec.env ?? {}).map(([k, v]) => [k, fill(String(v))]));
|
|
50
|
+
// The server gets the env of the command (the allow-list) and its own env: check the whole of it.
|
|
51
|
+
requireLocalAddresses(env, hosts, `the MCP server of the way in "${way}"`, { ...modelEnv('/nonexistent', 'x'), ...env });
|
|
52
|
+
const command = commandFor(fill(toolsSpec.command));
|
|
53
|
+
if (!command) throw new Blocked(`tools-from of "${way}" has no command`);
|
|
54
|
+
const cwd = toolsSpec.cwd ? (isAbsolute(toolsSpec.cwd) ? toolsSpec.cwd : resolve(productRoot, toolsSpec.cwd)) : productRoot;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name: 'agent',
|
|
58
|
+
way,
|
|
59
|
+
async call(request = {}, { persona = null } = {}) {
|
|
60
|
+
const prompt = String(request.prompt ?? '');
|
|
61
|
+
if (!prompt) throw new Error('the agent way in needs a prompt');
|
|
62
|
+
// `sh -c` runs the server's command as the mcp-stdio driver does; `env -u` takes the model key out first.
|
|
63
|
+
const config = { mcpServers: { [SERVER]: { type: 'stdio', command: 'env', args: ['-u', 'ANTHROPIC_API_KEY', 'sh', '-c', `cd ${JSON.stringify(cwd)} && exec ${command}`], env } } };
|
|
64
|
+
redactor.add(key);
|
|
65
|
+
const out = await runClaude({
|
|
66
|
+
prompt, key, meter, redactor, what: `the agent of "${way}"`, stream: true,
|
|
67
|
+
args: [
|
|
68
|
+
'--model', String(spec.model),
|
|
69
|
+
'--tools', '',
|
|
70
|
+
'--mcp-config', JSON.stringify(config),
|
|
71
|
+
'--allowedTools', ...tools.map((t) => `mcp__${SERVER}__${t}`),
|
|
72
|
+
...(spec['system-prompt'] ? ['--append-system-prompt', String(spec['system-prompt'])] : []),
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const steps = stepsOf(out.events);
|
|
76
|
+
const offList = steps.filter((s) => !tools.includes(s.tool)).map((s) => s.tool);
|
|
77
|
+
const reply = String(out.envelope?.result ?? '');
|
|
78
|
+
const isError = out.exit !== 0 || Boolean(out.envelope?.is_error) || !out.envelope;
|
|
79
|
+
const body = { reply, steps, offList, turns: out.envelope?.num_turns ?? null, cost: out.cost, ...(isError ? { error: clip(out.stderr || out.stdout, redactor) } : {}) };
|
|
80
|
+
const safe = redactor.deep(body);
|
|
81
|
+
record?.({ way, persona, request: { prompt: redactor.text(prompt), tools }, response: { status: out.exit, body: clip(safe, redactor) } });
|
|
82
|
+
return { status: out.exit, body: safe, isError, raw: { envelope: redactor.deep(out.envelope) } };
|
|
83
|
+
},
|
|
84
|
+
async close() {},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The path of tool calls in a stream-json transcript: each tool_use, joined to its tool_result.
|
|
89
|
+
export function stepsOf(events) {
|
|
90
|
+
const steps = [];
|
|
91
|
+
const byId = new Map();
|
|
92
|
+
for (const e of events) {
|
|
93
|
+
const content = e?.message?.content;
|
|
94
|
+
if (!Array.isArray(content)) continue;
|
|
95
|
+
for (const block of content) {
|
|
96
|
+
if (e.type === 'assistant' && block.type === 'tool_use') {
|
|
97
|
+
const step = { tool: String(block.name ?? '').replace(new RegExp(`^mcp__${SERVER}__`), ''), input: block.input ?? {}, result: null, isError: null };
|
|
98
|
+
steps.push(step);
|
|
99
|
+
byId.set(block.id, step);
|
|
100
|
+
} else if (e.type === 'user' && block.type === 'tool_result') {
|
|
101
|
+
const step = byId.get(block.tool_use_id);
|
|
102
|
+
if (!step) continue;
|
|
103
|
+
step.isError = Boolean(block.is_error);
|
|
104
|
+
step.result = Array.isArray(block.content) ? block.content.map((c) => (c.type === 'text' ? c.text : c)).join('\n') : block.content;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return steps;
|
|
109
|
+
}
|
package/tests/drivers/index.mjs
CHANGED
|
@@ -11,10 +11,12 @@ import { createHttpDriver } from './http.mjs';
|
|
|
11
11
|
import { createMcpStdioDriver } from './mcp-stdio.mjs';
|
|
12
12
|
import { createFunctionDriver } from './function.mjs';
|
|
13
13
|
import { createBrowserDriver } from './browser.mjs';
|
|
14
|
+
import { createAgentDriver } from './agent.mjs';
|
|
14
15
|
|
|
15
|
-
export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, createBrowserDriver };
|
|
16
|
+
export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, createBrowserDriver, createAgentDriver };
|
|
16
17
|
|
|
17
|
-
// ctx: { way, spec, env, root, hosts, redactor, record, adapter, runId, attach }
|
|
18
|
+
// ctx: { way, spec, env, root, hosts, redactor, record, adapter, runId, attach, tests, secrets, meter }
|
|
19
|
+
// `secrets` holds the provided values of the named secrets; `meter` counts model money (model.mjs).
|
|
18
20
|
// `attach(fileName)` gives the path of a file to keep as evidence (a screenshot).
|
|
19
21
|
// `adapter.playwright` is the Playwright module, for a test that has none installed.
|
|
20
22
|
// In a fault run, ATLAS_PRODUCT_ROOT names a throwaway copy. A process that a
|
|
@@ -66,6 +68,17 @@ export async function makeDriver(ctx) {
|
|
|
66
68
|
if (!target) throw new Blocked(`the way in "${way}" uses the function driver and the adapter gives no module: set adapter.functions`);
|
|
67
69
|
return createFunctionDriver({ way, target, record, redactor });
|
|
68
70
|
}
|
|
71
|
+
case 'agent': {
|
|
72
|
+
const keyName = spec.key ?? 'MODEL_API_KEY';
|
|
73
|
+
const key = ctx.secrets?.[keyName];
|
|
74
|
+
if (!Object.hasOwn(ctx.tests?.secrets ?? {}, keyName)) throw new Blocked(`the way in "${way}" needs the model key ${keyName} in secrets of tests.yaml`);
|
|
75
|
+
if (!key) throw new Blocked(`the way in "${way}" needs the key ${keyName}, and no value was given`);
|
|
76
|
+
const toolsSpec = ctx.tests?.['ways-in']?.[spec['tools-from']];
|
|
77
|
+
return createAgentDriver({
|
|
78
|
+
way, spec, toolsSpec, fill, tests: ctx.tests, hosts, productRoot, key, meter: ctx.meter, record, redactor,
|
|
79
|
+
commandFor: (command) => commandFor(command, root, productRoot),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
69
82
|
default:
|
|
70
83
|
throw new Blocked(`the way in "${way}" uses the driver "${spec.driver}", which the kit does not ship; add it to adapter.drivers`);
|
|
71
84
|
}
|
package/tests/evidence.mjs
CHANGED
|
@@ -43,7 +43,8 @@ function commitOf(root) {
|
|
|
43
43
|
|
|
44
44
|
// The results of one assertion for one way in (design section 6.11). `flaky` is never set by a
|
|
45
45
|
// test. `atlas tests verdict` and the proof table read it from two attempts (verdict.mjs).
|
|
46
|
-
|
|
46
|
+
// `unsure` comes from a judge whose runs disagree (model.mjs); it counts as a fail until a person looks.
|
|
47
|
+
export const RESULTS = Object.freeze(['pass', 'fail', 'not checked', 'not exercised', 'blocked', 'not here', 'flaky', 'unsure']);
|
|
47
48
|
|
|
48
49
|
const MAX_BODY = 8000;
|
|
49
50
|
|
package/tests/link-check.mjs
CHANGED
|
@@ -188,7 +188,7 @@ function checkBudget(scenario, tests, findings, rel) {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
// The ids that tests name. Only a literal first argument can be read.
|
|
191
|
-
const CALL = /\brun\.(check|gate|notExercised|waitFor)\s*\(/g;
|
|
191
|
+
const CALL = /\brun\.(check|gate|judge|notExercised|waitFor)\s*\(/g;
|
|
192
192
|
|
|
193
193
|
export function readRefs(file, code, rel, findings) {
|
|
194
194
|
const refs = [];
|
|
@@ -281,6 +281,8 @@ function checkLinks(scenarios, root, testsDir, findings, rel) {
|
|
|
281
281
|
if (!by.has(key)) by.set(key, []);
|
|
282
282
|
by.get(key).push({ ...r, a });
|
|
283
283
|
if (a.fp && r.fp !== a.fp) findings.push({ file: rel(r.file), line: r.line, code: 'old-fingerprint', message: `${r.full} has an old fingerprint; ${key} now prints #${a.fp}` });
|
|
284
|
+
else if (r.kind === 'judge' && a.kind !== 'judged') findings.push({ file: rel(r.file), line: r.line, code: 'judge-mismatch', message: `${key} is an exact assertion; the test must use run.check or run.gate, not run.judge` });
|
|
285
|
+
else if ((r.kind === 'check' || r.kind === 'gate') && a.kind === 'judged') findings.push({ file: rel(r.file), line: r.line, code: 'judge-mismatch', message: `${key} is a judged assertion; the test must use run.judge` });
|
|
284
286
|
else if (r.kind === 'check' && a.gate) findings.push({ file: rel(r.file), line: r.line, code: 'gate-mismatch', message: `${key} is marked gate in the scenario; the test must use run.gate` });
|
|
285
287
|
else if (r.kind === 'gate' && !a.gate) findings.push({ file: rel(r.file), line: r.line, code: 'gate-mismatch', message: `${key} is not marked gate in the scenario; the test must use run.check` });
|
|
286
288
|
}
|
|
@@ -507,8 +509,8 @@ export function checkRun({ evidenceDir, runId = null, environment = null, scenar
|
|
|
507
509
|
runOf.set(data.scenario, id);
|
|
508
510
|
}
|
|
509
511
|
}
|
|
510
|
-
const reached = new Set(['pass', 'fail', 'not exercised', 'not here', 'flaky']);
|
|
511
|
-
const ran = new Set(['pass', 'fail', 'not exercised', 'flaky']);
|
|
512
|
+
const reached = new Set(['pass', 'fail', 'not exercised', 'not here', 'flaky', 'unsure']);
|
|
513
|
+
const ran = new Set(['pass', 'fail', 'not exercised', 'flaky', 'unsure']);
|
|
512
514
|
for (const s of scenarios) {
|
|
513
515
|
if (!s.id) continue;
|
|
514
516
|
const all = records.filter((r) => r.data.scenario === s.id);
|