@arjunkhera/atlas 0.3.30 → 0.3.32

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.30",
3
+ "version": "0.3.32",
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
@@ -30,6 +30,7 @@ import { loadPrivateTerms } from './lib/privacy.mjs';
30
30
  import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
31
31
  import { proofCommand as testsProof } from './lib/proof.mjs';
32
32
  import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunCommand as testsRerun } from '../tests/verdict.mjs';
33
+ import { runKept } from '../tests/kept.mjs';
33
34
  import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
34
35
  import { issuesCommand as testsIssues } from './lib/issues.mjs';
35
36
  import { upkeepCommand } from './lib/upkeep.mjs';
@@ -97,6 +98,10 @@ const HELP = `atlas — the door into a repo's Atlas files
97
98
  --dry-run print what it would open; change nothing
98
99
  The token is only GITHUB_TOKEN. The base URL is GITHUB_API_URL (default https://api.github.com)
99
100
 
101
+ atlas tests kept --root <area> path 1: run the kept tests that a scenario names in kept-tests, and write their evidence
102
+ --evidence <dir> where the evidence files go; default <area>/test/evidence
103
+ --scenario <id> run one scenario only
104
+
100
105
  atlas tests rerun --root <area> --run <id> list the scenario test files whose first attempt did not end well
101
106
  --tests <dir> the test folder of the area; default test. The test files are in <dir>/scenarios
102
107
  --evidence <dir> where the evidence files are; default <area>/test/evidence
@@ -131,7 +136,7 @@ export const FLAGS = Object.freeze({
131
136
  ste: { share: 'switch', terms: 'value' },
132
137
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
133
138
  view: { product: 'value', name: 'value', out: 'value' },
134
- 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' },
139
+ 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', scenario: 'value' },
135
140
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
136
141
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
137
142
  upgrade: { version: 'optional', yes: 'switch' },
@@ -330,9 +335,10 @@ async function testsCommand(chosen) {
330
335
  if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
331
336
  if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
332
337
  if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
338
+ if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
333
339
  if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests }, root, line);
334
340
  if (what === 'issues') return testsIssues({ findings: chosen.findings, repo: chosen.repo, max: chosen.max, bot: chosen.bot, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
335
- throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, sweep, covers, issues or rerun.`);
341
+ throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, sweep, covers, kept, issues or rerun.`);
336
342
  }
337
343
 
338
344
  function upkeepVerb(chosen) {
@@ -24,6 +24,10 @@
24
24
  {
25
25
  "kit_sha256": "1e62be9a3fb632161c593d44ac7ad404ef04186fe2fbcd108645fbe1559bf860",
26
26
  "first_package": "0.3.28"
27
+ },
28
+ {
29
+ "kit_sha256": "08c868aa39e6c9f471da2571e9a1d5939932a59856cea9c4176e3173f93454db",
30
+ "first_package": "0.3.30"
27
31
  }
28
32
  ]
29
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.30",
3
+ "version": "0.3.32",
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",
@@ -100,7 +100,7 @@ set. A test keeps the two lists equal.
100
100
  | `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
101
101
  | `atlas code-read` | To resolve the citations of a code digest |
102
102
  | `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
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 |
103
+ | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `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
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 |
106
106
  | `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
@@ -175,6 +175,13 @@ next action. One short paragraph.
175
175
  `library` repo proves it by merged pull requests and a green gate. A
176
176
  `service` or `schema` repo also needs its live checks, because a schema
177
177
  migration reaches the running node after the merge.
178
+ A red check is a last resort, for an extraordinary case with no other
179
+ option (owner, 22 October 2026). Fix the test first. If the owner still
180
+ approved a merge with one known red test, record the words
181
+ with `decision_record` on the item. The words must name the pull request
182
+ and quote the exact test. One decision accepts one red check. Then pass
183
+ `accepted_red` to `item_done`; it needs the owner present. Any other red
184
+ check still refuses the mark.
178
185
  2. If anything was a struggle, encode the fix where it stops a repeat — a lint,
179
186
  then a tool, then a skill, then a doc — and say which.
180
187
  3. Rebuild the design's page and the hub, so both read "Shipped". The
@@ -192,10 +192,12 @@ CI runs the tests from the main branch, so a pull request cannot change its own
192
192
 
193
193
  ## Onboard an area
194
194
 
195
- Path 1 is for an area that has integration tests which call its routes,
196
- and which people keep using. Old unit tests that call functions do not make path 1.
197
- Use path 2: the area has no integration test that calls its routes. If it has
198
- route tests that people keep, stop. Tell the owner that path 1 comes later.
195
+ Pick the path at step 1 of the map.
196
+
197
+ - Path 1: the area has integration tests that people keep using. They call
198
+ its routes, or start its tool or its server. Follow "Path 1: adopt the
199
+ kept tests" below. Old unit tests that only call functions do not make path 1.
200
+ - Path 2: the area has no such tests. Follow the steps here.
199
201
 
200
202
  1. Send the `atlas:code-explorer` crew to map the area. Ask for routes,
201
203
  tools, pages, storage, calls to outside services and existing tests.
@@ -230,6 +232,56 @@ a note service:
230
232
  and two scenarios.
231
233
  3. Output: one pull request. The owner reads two scenarios and three lines of guards.
232
234
 
235
+ ## Path 1: adopt the kept tests
236
+
237
+ The kept tests stay as they are. Each kept test file gets a scenario: the
238
+ scenario holds the words, and the kept test stays the code that checks them.
239
+ `atlas tests kept` runs the kept runner, reads its JUnit report and writes
240
+ the evidence. The design: `docs/design-docs/kept-tests/` in the Atlas repo.
241
+
242
+ 1. Send the `atlas:code-explorer` crew to map the area. Ask for each kept
243
+ test file and its test case names. Ask for the runner command and the env
244
+ variables it reads. Ask for each outside address that a kept test uses.
245
+ 2. Run the kept tests once as they are. Record the count. Each one must be
246
+ green before you start, and stays green to the end.
247
+ 3. Run `atlas tests write --root <repo>`.
248
+ 4. Find the JUnit form of the runner. Examples: `node --test
249
+ --test-reporter=junit --test-reporter-destination={report} {file}`,
250
+ `pytest --junitxml={report} {file}`, and a TestNG suite file with the
251
+ JUnit reporter.
252
+ 5. Draft `atlas/tests.yaml` with one way in of driver `kept`. Give it the
253
+ `command` with `{file}` and `{report}`, the `files` patterns, and the
254
+ `env` the kept tests need. Give it a local address in the env, or
255
+ `offline: true` when the tests reach no network.
256
+ 6. For each kept file, write one scenario with `through: [kept]` and
257
+ `kept-tests: <file>`. Write the assertions: what the file proves, in
258
+ plain words. Never claim more than its test cases check.
259
+ 7. Write the `## Kept checks` section. Map each assertion to the test cases
260
+ that check it: `- e1: "case name"`. Name each case of the file once.
261
+ List a case that proves no promise as `- not mapped: "case name" (why)`.
262
+ 8. Review the words (the section below). Give the crew the kept test file
263
+ too, so it can check each assertion against the code of its cases.
264
+ 9. Run `atlas tests kept --root <repo>`, then `atlas tests check --root <repo>`.
265
+ 10. Plant a fault in the product for each assertion, in a throwaway copy.
266
+ Run `atlas tests kept` there. Each assertion must go red once.
267
+ 11. Run the kept tests as they are once more. The count and the result
268
+ must match step 2.
269
+ 12. Add a CI step that runs `node test/atlas/kept.mjs --root .` from the
270
+ kit copy of the main branch, with `ATLAS_GUARDS_FROM` set.
271
+ 13. Open one pull request. List the outside addresses that step 1 found.
272
+ Give the guard parts in plain words, and one scenario with its proof
273
+ table. A person merges it.
274
+
275
+ Worked run, on a CLI area with 14 kept test files:
276
+
277
+ 1. Input: the map finds `test/status.test.mjs` with 4 test cases. They start
278
+ the tool as a child process. No test reaches a network.
279
+ 2. Process: one kept way in with `offline: true`. The scenario
280
+ `thread-status` has two assertions. e1 maps 2 cases and e2 maps 1 case.
281
+ The help-text case is not mapped.
282
+ 3. Output: `atlas tests kept` prints `thread-status pass`. A planted fault
283
+ in the state code turns e1 red, and e2 stays green.
284
+
233
285
  ## Write assertions
234
286
 
235
287
  Use these steps when you write or change an assertion.
@@ -64,6 +64,8 @@ export async function makeDriver(ctx) {
64
64
  env: Object.fromEntries(Object.entries(spec.env ?? {}).map(([k, v]) => [k, fill(String(v))])),
65
65
  offline: spec.offline === true, hosts, root, productRoot, runId, redactor, record,
66
66
  });
67
+ case 'kept':
68
+ throw new Blocked(`the way in "${way}" is a kept runner; its scenarios run with atlas tests kept, not in a scenario test`);
67
69
  case 'function': {
68
70
  let target = adapter.functions?.[way] ?? adapter.functions?.function ?? adapter.functions;
69
71
  // No module from the adapter: the `module` of the way in is a path under the
@@ -0,0 +1,64 @@
1
+ // The JUnit report reader, for path 1 of onboarding: an area whose kept tests
2
+ // run on their own runner (design docs/design-docs/kept-tests). Most runners
3
+ // write this report: node --test (--test-reporter=junit), pytest
4
+ // (--junitxml), TestNG, JUnit, Vitest and Jest.
5
+ //
6
+ // readJunit(xml) -> [{ name, classname, file, suite, result, message }]
7
+ // result: "pass", "fail" or "skipped". An <error> is a fail too.
8
+ //
9
+ // The reader has no dependency. It reads only the parts it needs: the
10
+ // <testsuite> nesting, each <testcase> and its <failure>, <error> and
11
+ // <skipped> children. It refuses text that holds no <testsuites> or
12
+ // <testsuite> element, so a broken report is not read as "no tests".
13
+ export class ReportError extends Error {}
14
+
15
+ const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
16
+ export const unescapeXml = (text) => String(text).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
17
+ if (code[0] === '#') return String.fromCodePoint(code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : Number(code.slice(1)));
18
+ return ENTITIES[code.toLowerCase()] ?? whole;
19
+ });
20
+
21
+ function attributes(text) {
22
+ const out = {};
23
+ for (const m of String(text).matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g)) out[m[1]] = unescapeXml(m[3] ?? m[4]);
24
+ return out;
25
+ }
26
+
27
+ // One tag at a time: comments, the XML head and CDATA are skipped.
28
+ const TAG = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[[\s\S]*?\]\]>|<(\/?)([A-Za-z_][\w:.-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g;
29
+
30
+ export function readJunit(xml) {
31
+ const text = String(xml ?? '');
32
+ if (!/<testsuites?[\s>/]/.test(text)) throw new ReportError('the report holds no <testsuites> or <testsuite> element');
33
+ const cases = [];
34
+ const suites = [];
35
+ let current = null;
36
+ for (const m of text.matchAll(TAG)) {
37
+ const [, closing, tag, attrs, selfClosing] = m;
38
+ if (!tag) continue;
39
+ if (tag === 'testsuite') {
40
+ if (closing) suites.pop();
41
+ else if (!selfClosing) suites.push(attributes(attrs));
42
+ continue;
43
+ }
44
+ if (tag === 'testcase') {
45
+ if (closing) { current = null; continue; }
46
+ const a = attributes(attrs);
47
+ const suite = suites.map((s) => s.name).filter(Boolean).join(' > ');
48
+ const file = a.file ?? suites.map((s) => s.file).filter(Boolean).at(-1) ?? null;
49
+ const one = { name: a.name ?? '', classname: a.classname ?? null, file, suite: suite || null, result: 'pass', message: null };
50
+ cases.push(one);
51
+ current = selfClosing ? null : one;
52
+ continue;
53
+ }
54
+ if (!current || closing) continue;
55
+ if (tag === 'failure' || tag === 'error') {
56
+ current.result = 'fail';
57
+ current.message ??= attributes(attrs).message ?? tag;
58
+ } else if (tag === 'skipped' && current.result !== 'fail') {
59
+ current.result = 'skipped';
60
+ current.message ??= attributes(attrs).message ?? null;
61
+ }
62
+ }
63
+ return cases;
64
+ }
package/tests/kept.mjs ADDED
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ // Path 1 of onboarding: the tests an area already keeps (design
3
+ // docs/design-docs/kept-tests). The kept tests are never edited. For each
4
+ // scenario with `kept-tests: <file>` in its front matter, this:
5
+ // 1. runs the kept runner of the scenario's `kept` way in, once, for that
6
+ // file. The way in's command holds {file} and {report}. The run goes
7
+ // through the command driver, so the runner gets the allow-list env, the
8
+ // env of the way in, a throwaway HOME, and the address guards;
9
+ // 2. reads the JUnit report that the runner wrote (junit.mjs);
10
+ // 3. sets the result of each assertion from the test cases that its
11
+ // "Kept checks" lines name: every case passed is `pass`; a failed case is
12
+ // `fail`; a skipped case, a case that is not in the report, or an
13
+ // assertion that names no case is `not checked`;
14
+ // 4. writes one evidence file for the scenario, in the same schema and run
15
+ // folder as a scenario test. `atlas tests check`, `proof`, `verdict`,
16
+ // `covers` and `sweep` read it as they read any other.
17
+ // A case of the report that no line maps, and that no "not mapped" line lists,
18
+ // is a finding: every kept test case is named once.
19
+ //
20
+ // Usage:
21
+ // node kept.mjs --root <area> [--evidence <dir>] [--scenario <id>]
22
+ // ATLAS_ENV, ATLAS_RUN_ID, ATLAS_EVIDENCE_DIR and ATLAS_GUARDS_FROM work as for a scenario test.
23
+ // Exit 0 when every assertion passed and there is no finding, 1 otherwise, 2 for a bad call.
24
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join, relative, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { Blocked } from './errors.mjs';
29
+ import { Redactor } from './redact.mjs';
30
+ import { readTestsYaml, checkTestsYaml, compareGuardParts } from './tests-yaml.mjs';
31
+ import { readScenario, addFingerprints, normalize } from './scenario.mjs';
32
+ import { Evidence } from './evidence.mjs';
33
+ import { makeRunId } from './fresh.mjs';
34
+ import { hostsOf } from './guards.mjs';
35
+ import { parseLimit } from './wait.mjs';
36
+ import { makeDriver } from './drivers/index.mjs';
37
+ import { shellWord } from './drivers/command.mjs';
38
+ import { readJunit } from './junit.mjs';
39
+
40
+ function scenarioFiles(dir) {
41
+ if (!existsSync(dir)) return [];
42
+ const out = [];
43
+ for (const name of readdirSync(dir).sort()) {
44
+ const full = join(dir, name);
45
+ if (statSync(full).isDirectory()) { if (name !== 'node_modules') out.push(...scenarioFiles(full)); continue; }
46
+ if (name.endsWith('.md') && name !== 'map.md' && name !== 'README.md') out.push(full);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ // The result of one assertion from the cases it names.
52
+ export function resultOf(named, byName) {
53
+ if (!named.length) return { result: 'not checked', cases: [], why: 'the assertion names no kept test case' };
54
+ const cases = named.map(({ name }) => {
55
+ const found = byName.get(normalize(name)) ?? [];
56
+ if (!found.length) return { name, result: 'missing', message: 'not in the report' };
57
+ const failed = found.find((c) => c.result === 'fail');
58
+ const skipped = found.find((c) => c.result === 'skipped');
59
+ return { name, result: failed ? 'fail' : skipped ? 'skipped' : 'pass', message: (failed ?? skipped)?.message ?? null };
60
+ });
61
+ if (cases.some((c) => c.result === 'fail')) return { result: 'fail', cases };
62
+ if (cases.some((c) => c.result !== 'pass')) return { result: 'not checked', cases, why: 'a case was skipped or is not in the report' };
63
+ return { result: 'pass', cases };
64
+ }
65
+
66
+ export async function runKept({ root, evidenceDir, environment, runId = makeRunId(), guardsFrom = process.env.ATLAS_GUARDS_FROM, only = null, adapter = {}, write = () => {} } = {}) {
67
+ root = resolve(root);
68
+ evidenceDir = resolve(evidenceDir ?? process.env.ATLAS_EVIDENCE_DIR ?? join(root, 'test', 'evidence'));
69
+ environment ??= process.env.ATLAS_ENV ?? (process.env.CI ? 'ci' : 'local');
70
+ if (!existsSync(join(root, 'atlas', 'tests.yaml'))) {
71
+ write(`there is no atlas/tests.yaml under ${root}; path 1 needs a kept way in there\n`);
72
+ return { runId, results: [], findings: ['no atlas/tests.yaml'], exit: 1 };
73
+ }
74
+ const yaml = readTestsYaml(join(root, 'atlas', 'tests.yaml'));
75
+ const bad = checkTestsYaml(yaml);
76
+ if (bad.length) throw new Error(`atlas/tests.yaml: ${bad.map((f) => `line ${f.line}: ${f.message}`).join('; ')}`);
77
+ const tests = yaml.value;
78
+ let refusal = null;
79
+ if (guardsFrom) {
80
+ try {
81
+ const changes = compareGuardParts(readFileSync(guardsFrom, 'utf8'), tests);
82
+ if (changes.length) refusal = `the guard parts of atlas/tests.yaml differ from the main branch copy (${changes.map((c) => c.part).join(', ')}); the run is refused`;
83
+ } catch (error) { refusal = `ATLAS_GUARDS_FROM names a file that cannot be read as tests.yaml (${error.code ?? error.message}); the run is refused`; }
84
+ }
85
+ const findings = [];
86
+ const results = [];
87
+ for (const file of scenarioFiles(join(root, 'scenarios'))) {
88
+ const sc = readScenario(file);
89
+ if (!sc.keptFile || (only && sc.id !== only)) continue;
90
+ const rel = relative(root, file);
91
+ if (sc.problems.length) { findings.push(`${rel}: ${sc.problems.map((p) => `line ${p.line}: ${p.message}`).join('; ')}`); continue; }
92
+ addFingerprints(sc, tests);
93
+ const way = sc.through.find((w) => tests['ways-in']?.[w]?.driver === 'kept');
94
+ if (!way) { findings.push(`${rel}: through names no way in with driver kept`); continue; }
95
+ const spec = tests['ways-in'][way];
96
+ const redactor = new Redactor();
97
+ const evidence = new Evidence({ evidenceDir, runId, environment, scenario: sc, root, redactor });
98
+ const one = { scenario: sc.id, way, verdict: null, assertions: [] };
99
+ results.push(one);
100
+ const started = new Date().toISOString();
101
+ const block = (reason) => {
102
+ evidence.setWay(way, { verdict: 'blocked', reason, started, ended: new Date().toISOString() });
103
+ for (const a of sc.assertions) one.assertions.push({ id: a.fullId, result: 'blocked' });
104
+ one.verdict = 'blocked';
105
+ findings.push(`${rel}: blocked: ${reason}`);
106
+ };
107
+ if (refusal) { block(refusal); continue; }
108
+ if ([].concat(sc.fields['runs-in'] ?? []).map(String).includes(environment) === false) {
109
+ for (const a of sc.assertions) for (const w of a.ways ?? sc.through) evidence.setResult(a.fullId, w, 'not here', { why: `runs-in does not list ${environment}` });
110
+ evidence.setWay(way, { verdict: 'not here', reason: null, started, ended: new Date().toISOString() });
111
+ one.verdict = 'not here';
112
+ continue;
113
+ }
114
+ const reportDir = mkdtempSync(join(tmpdir(), `atlas-${runId}-kept-`));
115
+ const report = join(reportDir, 'report.xml');
116
+ const command = String(spec.command).replaceAll('{file}', shellWord(sc.keptFile)).replaceAll('{report}', shellWord(report));
117
+ let driver = null;
118
+ try {
119
+ driver = await makeDriver({
120
+ way, spec: { driver: 'command', command, env: spec.env, offline: spec.offline, cwd: spec.cwd },
121
+ env: { vars: {}, standIns: {} }, root, hosts: hostsOf(tests), redactor, runId, adapter,
122
+ record: (call) => evidence.addCall(call),
123
+ });
124
+ const answer = await driver.call({ timeoutMs: Math.max(1, Math.round(parseLimit(sc.fields.limit ?? '5m'))) });
125
+ if (answer.body.timedOut) { block(`the kept runner did not end within ${sc.fields.limit ?? '5m'}`); continue; }
126
+ if (!existsSync(report)) { block(`the kept runner wrote no report (exit ${answer.body.code}). Last output: ${redactor.text(String(answer.body.stderr || answer.body.stdout).trim().slice(-300))}`); continue; }
127
+ let cases;
128
+ try { cases = readJunit(readFileSync(report, 'utf8')); } catch (error) { block(`the report of the kept runner cannot be read: ${error.message}`); continue; }
129
+ const byName = new Map();
130
+ for (const c of cases) { const k = normalize(c.name); if (!byName.has(k)) byName.set(k, []); byName.get(k).push(c); }
131
+ for (const a of sc.assertions) {
132
+ const r = resultOf(sc.keptChecks.get(a.id) ?? [], byName);
133
+ for (const w of a.ways ?? sc.through) evidence.setResult(a.fullId, w, r.result, { kept: sc.keptFile, cases: r.cases, ...(r.why ? { why: r.why } : {}) });
134
+ one.assertions.push({ id: a.fullId, result: r.result });
135
+ for (const c of r.cases) if (c.result === 'missing') findings.push(`${rel}: ${a.id} names "${c.name}", which the report of ${sc.keptFile} does not hold`);
136
+ }
137
+ const named = new Set([...[...sc.keptChecks.values()].flat(), ...sc.notMapped].map((k) => normalize(k.name)));
138
+ for (const k of [...byName.keys()].filter((k) => !named.has(k))) {
139
+ findings.push(`${rel}: the case "${byName.get(k)[0].name}" of ${sc.keptFile} is not mapped; map it to an assertion, or list it under not mapped with a reason`);
140
+ evidence.note(`case not mapped: ${byName.get(k)[0].name}`);
141
+ }
142
+ const all = one.assertions.map((x) => x.result);
143
+ one.verdict = all.every((x) => x === 'pass') ? 'pass' : 'fail';
144
+ evidence.setWay(way, { verdict: one.verdict, reason: one.verdict === 'pass' ? null : 'an assertion did not pass', started, ended: new Date().toISOString() });
145
+ } catch (error) {
146
+ if (!(error instanceof Blocked)) throw error;
147
+ block(error.message);
148
+ } finally {
149
+ await driver?.close();
150
+ rmSync(reportDir, { recursive: true, force: true });
151
+ }
152
+ }
153
+ for (const one of results) {
154
+ write(`${one.scenario} ${one.verdict}\n`);
155
+ for (const a of one.assertions) write(` ${a.id} ${a.result}\n`);
156
+ }
157
+ for (const f of findings) write(`finding ${f}\n`);
158
+ if (!results.length) write('no scenario names kept-tests\n');
159
+ const green = !findings.length && results.every((r) => r.verdict === 'pass' || r.verdict === 'not here');
160
+ return { runId, results, findings, exit: green ? 0 : 1 };
161
+ }
162
+
163
+ export function parseArgs(argv) {
164
+ const out = {};
165
+ for (let i = 0; i < argv.length; i += 1) {
166
+ const m = /^--(root|evidence|scenario)(?:=(.*))?$/.exec(argv[i]);
167
+ if (!m) throw new Error(`unknown argument ${argv[i]}`);
168
+ out[m[1]] = m[2] ?? argv[++i];
169
+ if (out[m[1]] === undefined) throw new Error(`--${m[1]} needs a value`);
170
+ }
171
+ if (!out.root) throw new Error('--root <area> is needed');
172
+ return out;
173
+ }
174
+
175
+ export async function main(argv, write = (text) => process.stdout.write(text)) {
176
+ let args;
177
+ try { args = parseArgs(argv); } catch (error) { write(`${error.message}\nUse: node kept.mjs --root <area> [--evidence <dir>] [--scenario <id>]\n`); return 2; }
178
+ const { exit, runId } = await runKept({ root: args.root, evidenceDir: args.evidence, only: args.scenario ?? null, write });
179
+ write(`run ${runId}\n`);
180
+ return exit;
181
+ }
182
+
183
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
184
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 2; });
185
+ }
@@ -289,11 +289,73 @@ function checkLinks(scenarios, root, testsDir, findings, rel) {
289
289
  for (const s of scenarios) {
290
290
  for (const a of s.assertions) {
291
291
  const named = (by.get(`${s.id}/${a.id}`) ?? []).filter((r) => r.kind !== 'waitFor');
292
+ // Path 1: the kept test cases of the "Kept checks" section are the test of the assertion.
293
+ if (!named.length && s.keptFile) continue;
292
294
  if (!named.length) findings.push({ file: rel(s.file), line: a.line, code: 'no-test', message: `no test names ${s.id}/${a.id}#${a.fp ?? '??????'}` });
293
295
  }
294
296
  }
295
297
  }
296
298
 
299
+ // Path 1 (docs/design-docs/kept-tests). A scenario with kept-tests names a kept file that
300
+ // exists, goes through a kept way in, maps each assertion to at least one test case, and
301
+ // names each case once. A kept way in with `files` has a scenario for each file it matches.
302
+ const keptCodes = { file: 'kept-file-missing', way: 'kept-no-way', unmapped: 'kept-not-mapped', twice: 'kept-mapped-twice' };
303
+ function checkKept(s, tests, root, findings, rel) {
304
+ if (!s.keptFile) return;
305
+ const at = s.fieldLine('kept-tests');
306
+ if (!existsSync(join(root, s.keptFile))) findings.push({ file: rel(s.file), line: at, code: keptCodes.file, message: `kept-tests names ${s.keptFile}, which does not exist under the area` });
307
+ if (tests && !s.through.some((w) => tests['ways-in']?.[w]?.driver === 'kept')) findings.push({ file: rel(s.file), line: s.fieldLine('through'), code: keptCodes.way, message: 'a scenario with kept-tests goes through a way in with driver kept' });
308
+ for (const a of s.assertions) {
309
+ if (!(s.keptChecks.get(a.id) ?? []).length) findings.push({ file: rel(s.file), line: a.line, code: keptCodes.unmapped, message: `${s.id}/${a.id} names no kept test case in "Kept checks"` });
310
+ }
311
+ const seen = new Map();
312
+ for (const [id, list] of s.keptChecks) {
313
+ if (!s.assertions.some((a) => a.id === id)) findings.push({ file: rel(s.file), line: list[0].line, code: 'unknown-assertion', message: `a kept check names ${id}, which ${s.id} does not have` });
314
+ for (const k of list) {
315
+ const key = k.name.normalize('NFC').replace(/\s+/g, ' ').trim();
316
+ if (seen.has(key)) findings.push({ file: rel(s.file), line: k.line, code: keptCodes.twice, message: `the case "${k.name}" is named twice; name each kept case once` });
317
+ seen.set(key, k.line);
318
+ }
319
+ }
320
+ for (const k of s.notMapped) {
321
+ const key = k.name.normalize('NFC').replace(/\s+/g, ' ').trim();
322
+ if (seen.has(key)) findings.push({ file: rel(s.file), line: k.line, code: keptCodes.twice, message: `the case "${k.name}" is mapped and also listed as not mapped` });
323
+ seen.set(key, k.line);
324
+ }
325
+ }
326
+
327
+ // The files that a pattern of a kept way in matches: "*" stays inside one folder, "**" crosses folders.
328
+ export function globToRegExp(pattern) {
329
+ let out = '';
330
+ const text = String(pattern).replace(/^\.\//, '');
331
+ for (let i = 0; i < text.length; i += 1) {
332
+ const c = text[i];
333
+ if (c === '*' && text[i + 1] === '*') { out += text[i + 2] === '/' ? '(?:.+/)?' : '.*'; i += text[i + 2] === '/' ? 2 : 1; }
334
+ else if (c === '*') out += '[^/]*';
335
+ else if (c === '?') out += '[^/]';
336
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
337
+ }
338
+ return new RegExp(`^${out}$`);
339
+ }
340
+
341
+ export function keptFilesOf(root, pattern) {
342
+ const re = globToRegExp(pattern);
343
+ const relOf = (f) => relative(root, f).split('\\').join('/');
344
+ return walk(root, { accept: (f) => re.test(relOf(f)) }).map(relOf);
345
+ }
346
+
347
+ function checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath) {
348
+ const mapped = new Set(scenarios.filter((s) => s.keptFile).map((s) => s.keptFile.replace(/^\.\//, '')));
349
+ for (const [name, way] of Object.entries(tests?.['ways-in'] ?? {})) {
350
+ if (way?.driver !== 'kept' || !Array.isArray(way.files)) continue;
351
+ for (const pattern of way.files) {
352
+ for (const file of keptFilesOf(root, pattern)) {
353
+ if (!mapped.has(file)) findings.push({ file: rel(yamlPath), line: 1, code: 'kept-file-no-scenario', message: `${file} matches files of the kept way in "${name}", and no scenario names it in kept-tests` });
354
+ }
355
+ }
356
+ }
357
+ }
358
+
297
359
  export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
298
360
  const yamlPath = join(root, 'atlas', 'tests.yaml');
299
361
  let tests = null;
@@ -329,6 +391,8 @@ export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
329
391
  }
330
392
  }
331
393
  for (const s of scenarios) checkWaitsAndWhens(s, findings, rel);
394
+ for (const s of scenarios) checkKept(s, tests, root, findings, rel);
395
+ checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath);
332
396
  checkLinks(scenarios, root, testsDir, findings, rel);
333
397
  return { tests, scenarios };
334
398
  }
@@ -271,6 +271,31 @@ export function parseScenario(source, file = 'scenario.md') {
271
271
  seen.add(m[1]);
272
272
  out.assertions.push({ id: m[1], kind: m[2], gate: m[3] === 'gate', words: m[4].trim(), line: item.line, names: names(m[4]), fp: null });
273
273
  }
274
+ // Path 1 (docs/design-docs/kept-tests): `kept-tests` names one kept test file, and the
275
+ // "Kept checks" section maps each assertion to the test cases of that file that check it.
276
+ // - e1: "status prints one JSON line"
277
+ // - not mapped: "the help text lists status" (why it maps to no assertion)
278
+ out.keptFile = typeof f['kept-tests'] === 'string' && f['kept-tests'].trim() ? f['kept-tests'].trim() : null;
279
+ if (f['kept-tests'] !== undefined && !out.keptFile) problem(out.fieldLine('kept-tests'), 'kept-tests names one kept test file, as a path under the area');
280
+ out.keptChecks = new Map();
281
+ out.notMapped = [];
282
+ const keptLines = sectionLines.get('kept checks') ?? [];
283
+ if (keptLines.length && !out.keptFile) problem(out.sections.get('kept checks'), 'a "Kept checks" section needs kept-tests in the front matter');
284
+ if (out.keptFile && !out.sections.has('kept checks')) problem(out.fieldLine('kept-tests'), 'a scenario with kept-tests needs a "## Kept checks" section');
285
+ for (const { text: raw, number } of keptLines) {
286
+ if (!raw.trim()) continue;
287
+ const m = /^-\s+(e\d+|not mapped):\s*"((?:[^"\\]|\\.)+)"\s*(.*)$/.exec(raw);
288
+ if (!m) { problem(number, `a kept check reads - eN: "test case name", or - not mapped: "test case name" (why); found "${raw.trim().slice(0, 60)}"`); continue; }
289
+ const name = m[2].replace(/\\(.)/g, '$1');
290
+ if (m[1] === 'not mapped') {
291
+ if (!m[3].trim()) problem(number, `"${name}" is not mapped and says no reason; add it in brackets`);
292
+ out.notMapped.push({ name, why: m[3].trim().replace(/^\(|\)$/g, ''), line: number });
293
+ continue;
294
+ }
295
+ if (!out.keptChecks.has(m[1])) out.keptChecks.set(m[1], []);
296
+ out.keptChecks.get(m[1]).push({ name, line: number });
297
+ }
298
+
274
299
  if (!out.assertions.length) problem(out.sections.get('assertions') ?? 1, 'the scenario has no assertions');
275
300
  else if (!out.sections.has('assertions')) problem(1, 'the scenario has no "## Assertions" section');
276
301
  return out;
@@ -345,6 +370,9 @@ export function addFingerprints(scenario, tests) {
345
370
  lines.push(`through=${[...scenario.through].sort().join(',')}`, `runs-in=${runsIn(scenario).sort().join(',')}`);
346
371
  // A weaker pass rule is a weaker test, so `repeat` is in the print. It adds a line only when set.
347
372
  if (scenario.repeat) lines.push(`repeat=${scenario.repeat.pass}/${scenario.repeat.runs}`);
373
+ // The kept test cases that check the assertion are part of what it means (path 1).
374
+ const keptNames = scenario.keptChecks?.get(a.id);
375
+ if (scenario.keptFile && keptNames?.length) lines.push(`kept=${scenario.keptFile}:${keptNames.map((k) => normalize(k.name)).sort().join('|')}`);
348
376
  a.fp = fingerprintOf([a.words, ...lines].join('\n'));
349
377
  a.fullId = `${scenario.id}/${a.id}#${a.fp}`;
350
378
  a.ways = waysOf(a, scenario, kept);
@@ -38,7 +38,7 @@ const SCHEMA = {
38
38
  runner: ['language', 'command'],
39
39
  environment: ENV_KEYS,
40
40
  ready: ['get', 'status', 'limit'],
41
- wayIn: ['driver', 'base', 'command', 'module', 'env', 'cwd', 'model', 'key', 'tools-from', 'tools', 'system-prompt', 'offline', 'why'],
41
+ wayIn: ['driver', 'base', 'command', 'module', 'env', 'cwd', 'model', 'key', 'tools-from', 'tools', 'system-prompt', 'offline', 'files', 'why'],
42
42
  standIn: ['start', 'why', 'ready'],
43
43
  persona: ['role', 'secret', 'same-identity', 'sign-in', 'make', 'of', 'why'],
44
44
  setup: ['with', 'steps', 'gives', 'why'],
@@ -170,6 +170,12 @@ export function checkTestsYaml(parsed) {
170
170
  if (!way.driver) say('tests-yaml-missing-field', [...path, 'driver'], `the field "${[...path, 'driver'].join('.')}" is needed`);
171
171
  if (way.key !== undefined && !secretNamed(way.key)) say('unknown-name', [...path, 'key'], `"key" names ${way.key}, which "secrets" does not list`);
172
172
  if (way.driver === 'command' && way.command === undefined) say('tests-yaml-missing-field', [...path, 'command'], 'a command way in needs "command"');
173
+ // Path 1: the kept runner. Its command holds {file} and {report} (docs/design-docs/kept-tests).
174
+ if (way.driver === 'kept') {
175
+ if (typeof way.command !== 'string') say('tests-yaml-missing-field', [...path, 'command'], 'a kept way in needs "command": the kept runner, with {file} and {report}');
176
+ else for (const hole of ['{file}', '{report}']) if (!way.command.includes(hole)) say('tests-yaml', [...path, 'command'], `the command of a kept way in needs ${hole}`);
177
+ if (way.files !== undefined && (!Array.isArray(way.files) || !way.files.length || way.files.some((f) => typeof f !== 'string'))) say('tests-yaml', [...path, 'files'], '"files" is a list of paths or patterns, such as "test/*.test.mjs"');
178
+ } else if (way.files !== undefined) say('tests-yaml', [...path, 'files'], '"files" belongs to a kept way in');
173
179
  if (way.offline !== undefined && way.offline !== true) say('tests-yaml', [...path, 'offline'], '"offline" is true, or it is left out');
174
180
  if (way.driver === 'agent') {
175
181
  for (const key of ['model', 'tools-from', 'tools']) if (way[key] === undefined) say('tests-yaml-missing-field', [...path, key], `an agent way in needs "${key}"`);
@@ -24,6 +24,12 @@ export const FATAL_CONCLUSIONS = Object.freeze(['failure', 'timed_out', 'action_
24
24
  const PASSING_CONCLUSION = 'success';
25
25
 
26
26
  const MAX_PRS = 32;
27
+ const MAX_ACCEPTED_RED = 16;
28
+ // The decision kinds that can carry the owner's acceptance of a red check. A
29
+ // sweep or a priority order says nothing about one merge.
30
+ const ACCEPTING_KINDS = Object.freeze(['decision', 'answer']);
31
+ const OWNER_DECISION_VERBS = Object.freeze(['decision_record', 'question_answer']);
32
+ const MIN_TEST_NAME = 8;
27
33
  const MAX_REPOS = 16;
28
34
  const MAX_ITEMS = 500;
29
35
  const PRS_PER_REPO = 100;
@@ -193,7 +199,7 @@ export function correctionsOf(marks = []) {
193
199
  }
194
200
 
195
201
  export function createDeliveryVerbs(core) {
196
- const { client, label, now, typeNamed, registry, requireProduct, requireItem, ownerPresent, patch, create, edge, landed, alreadyPatched, readGitHub, readLive } = core;
202
+ const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, ownerPresent, patch, create, edge, landed, alreadyPatched, readGitHub, readLive } = core;
197
203
 
198
204
  const github = async (path) => {
199
205
  if (typeof readGitHub !== 'function') refuse('this verb needs a GitHub reader; the server supplies one');
@@ -304,11 +310,76 @@ export function createDeliveryVerbs(core) {
304
310
  };
305
311
  }
306
312
 
313
+ // Issue #158: the owner can accept one known red check on one merged pull
314
+ // request, for example a test that only a run after the merge can turn
315
+ // green. The gate never changes after the merge, so without this the item
316
+ // could never be marked. GitHub's gate names a check run, not a test, so the
317
+ // verb proves the check level, and the owner's own recorded words must name
318
+ // the pull request and the exact test.
319
+ //
320
+ // A decision record is an Engram entity, and any Engram writer can make one
321
+ // that looks right. So the acceptance is not taken from the record alone:
322
+ // passing accepted_red needs the owner-present flag, the same flag that
323
+ // decision_record needs. A forged record then gives a caller nothing it
324
+ // could not write itself (security review of this change, finding 1).
325
+ async function acceptedRed(value, pulls, item) {
326
+ if (value === undefined) return [];
327
+ if (!Array.isArray(value)) refuse('accepted_red must be an array of { pr, check, test, decision_id }');
328
+ if (value.length > MAX_ACCEPTED_RED) refuse(`accepted_red holds at most ${MAX_ACCEPTED_RED} entries`);
329
+ const decisionType = await typeNamed('decision');
330
+ let replacedIds = null;
331
+ const rows = [], seen = new Set();
332
+ for (const entry of value) {
333
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) refuse('each accepted_red entry must be an object { pr, check, test, decision_id }');
334
+ for (const key of Object.keys(entry)) if (!['pr', 'check', 'test', 'decision_id'].includes(key)) refuse(`accepted_red does not take ${key}`);
335
+ const parsed = parsePullUrl(entry.pr, 'accepted_red pr');
336
+ const pull = pulls.find((one) => one.url === parsed.url);
337
+ if (!pull) refuse(`accepted_red names ${parsed.url}, which is not one of the prs of this delivery`);
338
+ const check = text(entry.check, 'accepted_red check', { max: 200 }).trim();
339
+ const testName = text(entry.test, 'accepted_red test', { max: 500 }).trim();
340
+ if (!check) refuse('accepted_red check must not be empty');
341
+ if (testName.length < MIN_TEST_NAME) refuse(`accepted_red test must be the full test name, at least ${MIN_TEST_NAME} characters`);
342
+ if (seen.has(`${pull.url} check ${check}`)) refuse(`accepted_red names check ${check} on ${pull.url} twice`);
343
+ seen.add(`${pull.url} check ${check}`);
344
+ const decisionId = text(entry.decision_id, 'accepted_red decision_id', { max: 200 });
345
+ // One decision accepts one red check on one pull request. A second
346
+ // entry with the same decision would stretch the owner's words over a
347
+ // check the owner never named (finding 2).
348
+ const key = `${pull.url} ${decisionId}`;
349
+ if (seen.has(key)) refuse(`accepted_red uses decision ${decisionId} twice on ${pull.url}; one decision accepts one red check`);
350
+ seen.add(key);
351
+ let decision;
352
+ try { decision = await client.getEntity(decisionId); }
353
+ catch (error) { if (error?.status === 404) refuse(`unknown decision id: ${decisionId}`); throw error; }
354
+ if ((await kindOf(decision)) !== 'decision') refuse(`entity ${decisionId} is not a decision record`);
355
+ if (decision.state && decision.state !== 'active') refuse(`decision ${decisionId} is ${decision.state}, not active in the graph`);
356
+ const said = decision.fields ?? {};
357
+ const madeBy = Object.values(said.atlas_receipts ?? {}).map((receipt) => receipt?.verb);
358
+ if (!madeBy.some((verb) => OWNER_DECISION_VERBS.includes(verb))) refuse(`decision ${decisionId} was not written by decision_record or question_answer`);
359
+ if (!ACCEPTING_KINDS.includes(said.kind)) refuse(`decision ${decisionId} is a ${said.kind} record; the owner accepts a red check with a decision or an answer`);
360
+ if (!(Array.isArray(said.items) ? said.items : []).includes(item.id)) refuse(`decision ${decisionId} does not name item ${item.id}; the owner's acceptance is recorded on the item it delivers`);
361
+ // A decision the owner later replaced no longer stands (finding 4).
362
+ replacedIds ??= new Set((await client.listAllEntities({ type: decisionType.name, state: 'any' })).map((row) => row.fields?.replaces).filter(Boolean));
363
+ if (replacedIds.has(decision.id)) refuse(`decision ${decisionId} is replaced by a later decision; it no longer stands`);
364
+ const words = typeof said.text === 'string' ? said.text : '';
365
+ const source = typeof said.source === 'string' ? said.source : '';
366
+ // The owner's words name the pull request by its URL, by repo#N, or by
367
+ // #N; the source counts only when it holds the full URL (finding 5).
368
+ const namesPull = words.includes(pull.url) || source.includes(pull.url) || words.includes(`${pull.repo}#${pull.number}`)
369
+ || new RegExp(`(?:^|[^A-Za-z0-9_/.-])#${pull.number}(?![0-9])`).test(words);
370
+ if (!namesPull) refuse(`decision ${decisionId} does not name ${pull.url}; the owner's words must name the pull request (its URL or #${pull.number})`);
371
+ if (!words.includes(testName)) refuse(`decision ${decisionId} does not quote the test "${testName}"; the owner's own words must name the exact test`);
372
+ rows.push({ pr: pull.url, check, test: testName, decision_id: decision.id, decision_version: decision.version ?? null, decision });
373
+ }
374
+ return rows;
375
+ }
376
+
307
377
  const verbs = {
308
378
  // The delivering agent marks its own change done, after the verb runs the
309
379
  // three live checks itself. No owner flag: the agent that shipped it calls
310
380
  // this, and the lead catches up a delivery made outside a session (R2).
311
381
  async item_done(args, requestId, meta) {
382
+ if (args.accepted_red !== undefined && !ownerPresent) refuse('accepted_red needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1): only the owner waives a red check');
312
383
  const item = await requireItem(args.item_id);
313
384
  const fields = item.fields ?? {};
314
385
  const replay = alreadyPatched(item, requestId, meta);
@@ -336,6 +407,7 @@ export function createDeliveryVerbs(core) {
336
407
  delivered_at: standing.fields.marked_at, prs: (standing.fields.prs ?? []).map((pull) => pull.url), deployed_commit: standing.fields.deployed?.commit ?? null,
337
408
  checks: (standing.fields.checks ?? []).map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
338
409
  verified_by: (standing.fields.verified_by ?? []).map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: standing.fields.gate_warnings ?? [],
410
+ accepted_red: (standing.fields.accepted_red ?? []).map((row) => ({ pr: row.pr, check: row.check, test: row.test, decision_id: row.decision_id, detail: row.detail })),
339
411
  replayed_from_graph: true };
340
412
  }
341
413
 
@@ -394,6 +466,7 @@ export function createDeliveryVerbs(core) {
394
466
  const mergeCommit = text(pr.merge_commit_sha, `merge_commit_sha of ${parsed.url}`, { max: 64 });
395
467
  pulls.push({ ...parsed, title: oneLine(pr.title ?? '', 200), merged_at: pr.merged_at ?? '', merge_commit_sha: mergeCommit, branch: pr.head?.ref ?? '', base: pr.base?.ref ?? '' });
396
468
  }
469
+ const accepted = await acceptedRed(args.accepted_red, pulls, item);
397
470
 
398
471
  // The three live checks, for a delivery that reaches a running service.
399
472
  let health = null, contains = null, functional = null;
@@ -410,13 +483,25 @@ export function createDeliveryVerbs(core) {
410
483
  }
411
484
 
412
485
  // The gate run, fetched from GitHub for each merge commit (R10).
413
- const verified = [], warnings = [];
486
+ const verified = [], warnings = [], acceptedRows = [];
414
487
  for (const pull of pulls) {
415
488
  const answer = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}/check-runs?per_page=${CHECK_RUNS}`);
416
489
  const runs = latestCheckRuns(answer?.check_runs);
417
490
  const failed = runs.filter((run) => FATAL_CONCLUSIONS.includes(run.conclusion ?? ''));
418
491
  const passed = runs.filter((run) => run.conclusion === PASSING_CONCLUSION);
419
- if (failed.length) refuse(`the merge gate is red for ${pull.url}: ${failed.map((run) => `${run.name} ${run.conclusion} (${run.url})`).join(', ')}`);
492
+ const mine = accepted.filter((row) => row.pr === pull.url);
493
+ const unaccepted = failed.filter((run) => !mine.some((row) => row.check === run.name));
494
+ if (unaccepted.length) refuse(`the merge gate is red for ${pull.url}: ${unaccepted.map((run) => `${run.name} ${run.conclusion} (${run.url})`).join(', ')}${mine.length ? '; accepted_red does not cover it' : ''}`);
495
+ // An acceptance of a check that is not red is stale: the owner
496
+ // accepted a red that the gate does not show, so the verb refuses
497
+ // rather than write an acceptance nobody needed.
498
+ for (const row of mine) {
499
+ const run = failed.find((one) => one.name === row.check);
500
+ if (!run) refuse(`accepted_red names check ${row.check} on ${pull.url}, but that check is not red in the merge gate; drop the entry`);
501
+ const { decision: _record, ...kept } = row;
502
+ acceptedRows.push({ ...kept, conclusion: run.conclusion, run_url: run.url,
503
+ detail: `${row.check} concluded ${run.conclusion} for ${pull.url}. The owner accepted the red test "${row.test}" in decision ${row.decision_id}. The gate names the check, not the test, so the verb proved the check level only.` });
504
+ }
420
505
  if (!passed.length) refuse(`no check run succeeded for ${pull.url}; the merge gate is the independent record and it says nothing`);
421
506
  // A check name whose LATEST run succeeded is green. A name whose earlier
422
507
  // run was red and whose later run was skipped or neutral is not red and
@@ -431,7 +516,7 @@ export function createDeliveryVerbs(core) {
431
516
  warnings.push({ pr: pull.url, check: run.name, earlier: last.conclusion, earlier_url: last.url, latest: run.conclusion, latest_url: run.url,
432
517
  detail: `${run.name} concluded ${last.conclusion} for ${pull.url}, and its later run concluded ${run.conclusion}, not success. The verb cannot tell a fixed gate from a gate that was switched off.` });
433
518
  }
434
- verified.push({ pr: pull.url, commit: pull.merge_commit_sha, runs, passed: passed.map((run) => run.name) });
519
+ verified.push({ pr: pull.url, commit: pull.merge_commit_sha, runs, passed: passed.map((run) => run.name), ...(mine.length ? { accepted_red: mine.map((row) => row.check) } : {}) });
435
520
  }
436
521
 
437
522
  // One row per check that ran. For a repo with no deploy target the row
@@ -451,11 +536,12 @@ export function createDeliveryVerbs(core) {
451
536
  kind: 'delivery', verdict: 'delivered', item: item.id, product: product.fields.registry_id, marked_at: now(),
452
537
  prs: pulls.map((pull) => ({ url: pull.url, repo: pull.repo, number: pull.number, title: pull.title, branch: pull.branch, merged_at: pull.merged_at, merge_commit_sha: pull.merge_commit_sha })),
453
538
  ...(live ? { deployed: { health_url: healthUrl, commit: health.deployed_commit, reported: health.reported, status: health.status, checked_at: health.checked_at } } : {}),
454
- checks, verified_by: verified, ...(warnings.length ? { gate_warnings: warnings } : {}), ...(note ? { note } : {}),
539
+ checks, verified_by: verified, ...(warnings.length ? { gate_warnings: warnings } : {}), ...(acceptedRows.length ? { accepted_red: acceptedRows } : {}), ...(note ? { note } : {}),
455
540
  },
456
541
  tags: ['atlas-mark', `product:${product.fields.registry_id}`, ...pulls.map((pull) => `repo:${pull.repo}`)],
457
542
  });
458
543
  await edge(mark, item, 'references', requestId);
544
+ for (const row of accepted) await edge(mark, row.decision, 'references', requestId);
459
545
  const marks = Array.isArray(fields.delivery_marks) ? fields.delivery_marks : [];
460
546
  const patched = replay ? item : await patch(item, 'item_done', requestId, {
461
547
  lifecycle: 'done', delivery_state: 'delivered', delivered_at: mark.fields.marked_at,
@@ -467,6 +553,7 @@ export function createDeliveryVerbs(core) {
467
553
  proof: live ? 'live-checks' : 'merge-and-gate',
468
554
  checks: checks.map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
469
555
  verified_by: verified.map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: warnings,
556
+ accepted_red: acceptedRows.map((row) => ({ pr: row.pr, check: row.check, test: row.test, decision_id: row.decision_id, detail: row.detail })),
470
557
  ...(replay ? { replayed_from_graph: true } : {}),
471
558
  };
472
559
  },
package/work/mcp.mjs CHANGED
@@ -64,7 +64,7 @@ export const TOOLS = [
64
64
  { name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
65
65
  { name: 'decision_record', description: `Record what the owner decided outside a question, in their own words. Refuses missing text and a missing date. Pass rendered to keep the view the owner acted on as a receipt in the same call. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'date'], properties: { request_id: REQUEST_ID, text: S("The owner's words, verbatim."), date: S('ISO date the owner said it.'), source: S('Where the words are on record.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default decision.`), product: S('Product id the decision is about.'), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on.'), view: S('Which view the owner read.') }, additionalProperties: false } },
66
66
  { name: 'receipt_keep', description: `Keep the rendered answer the owner acted on and attach it to a decision. Refuses when there is no decision to attach to. Secret patterns are redacted before the write. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'decision_id', 'rendered'], properties: { request_id: REQUEST_ID, decision_id: S('Entity id of the decision this receipt belongs to.'), rendered: S('The answer as the owner read it.'), view: S('Which view it came from, for example needs-me.') }, additionalProperties: false } },
67
- { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.') }, additionalProperties: false } },
67
+ { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. One exception, a last resort for an extraordinary case with no other option: the owner can accept a known red test on one merged pull request. Name it in accepted_red with the decision that records the owner's words; those words must name the pull request and quote the exact test, and one decision accepts one red check. accepted_red needs the owner present. The gate names a check, not a test, so the verb proves the check level, and every other red check still refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.'), accepted_red: { type: 'array', description: 'Red checks the owner accepted, one entry for each pull request and check. Owner present only. The decision must be a decision or an answer on this item, written by decision_record or question_answer and not replaced. Its words must name the pull request and quote the test. One decision accepts one check on one pull request. A listed check that is not red is refused.', items: { type: 'object', required: ['pr', 'check', 'test', 'decision_id'], additionalProperties: false, properties: { pr: S('One of the prs of this delivery.'), check: S('The name of the red check run in the merge gate, for example "test".'), test: S('The exact name of the red test, as the owner\'s words quote it.'), decision_id: S('The decision that records the owner\'s acceptance.') } } } }, additionalProperties: false } },
68
68
  { name: 'item_reopen', description: `Reopen a delivered item: the lifecycle goes back to active and a reopen mark is appended, citing the failed check by URL. The delivery mark is never erased; the reopen points at it with the corrects relation. The owner, or the lead in this session, calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'failed_check'], properties: { request_id: REQUEST_ID, item_id: S('Item id carrying at least one delivery mark.'), reason: S('Why it is reopened, in plain words.'), failed_check: S('Absolute URL of the check that failed: a CI run, a monitor, an issue.'), corrects: S('Mark id of the delivery this reopen corrects. Default: the item\'s latest delivery mark.') }, additionalProperties: false } },
69
69
  { name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
70
70
  { name: 'sweep_check', description: 'Check the sweep against the "In flight" view: the newest decision record of kind sweep names the items the owner called in flight, and this reads the view and names every difference — what is listed and not in flight, and what is in flight and not listed. A product with no sweep on record is named too. It answers two things apart: `matches` says each product\'s view holds exactly what its sweep lists, and `complete` says that and that no item in the tenant sits outside every product. An item nobody adopted is missing from both sides, so `matches` alone can read "equal" over an unfinished sweep. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the check to one product id.') }, additionalProperties: false } },