@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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.24",
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/",
@@ -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.
@@ -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` or
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:
@@ -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 `blocked`.
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
- try {
164
- const env = await getEnv();
165
- run = makeRun({ ctx, state, way, env, ev, adapter });
166
- await withLimit(Promise.resolve().then(() => body(run, way)), limitMs, sc.fields.limit);
167
- } catch (thrown) {
168
- error = thrown;
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
- for (const driver of state.drivers.values()) { try { await (await driver).close(); } catch { /* the stop goes on */ } }
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
+ }
@@ -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
  }
@@ -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
- export const RESULTS = Object.freeze(['pass', 'fail', 'not checked', 'not exercised', 'blocked', 'not here', 'flaky']);
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
 
@@ -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);