@arjunkhera/atlas 0.4.40 → 0.4.41

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.4.40",
3
+ "version": "0.4.41",
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
@@ -173,6 +173,7 @@ const HELP = `atlas — the door into a repo's Atlas files
173
173
  --yes sign with no question; the key still needs the touch
174
174
  atlas approve act <kind> sign one other act and print its block: brake-clear --repo --commit, ready --repo --item, lock --item --card <file>, decision --item --words <file>, waiver --repo --pr --head --decision --check
175
175
  --out <file> write the block here; default the terminal
176
+ atlas approve audit [<pr>...] the owner's acceptance of the night's audit reds, on his Mac: list each open audit issue of an owner merge that no touch signed, then one touch signs an accept act for each, the block goes on each pull request, each audit issue closes, and it prints the one message for the lead
176
177
  atlas approve verify --file <file> check each signed block of a file against atlas/owner-signers; exit 0 when one counts
177
178
  --signers <file> the key file; default atlas/owner-signers under --root
178
179
  atlas approve key-check one touch signs a test text, and the key's own public half checks it: does the key count for the gate
@@ -9,6 +9,11 @@
9
9
  // atlas approve act <kind> ... sign one other act: a brake clear, a ready
10
10
  // mark, a lock, a decision or a waiver; print
11
11
  // the block
12
+ // atlas approve audit [<pr>...] list the open audit reds of owner merges
13
+ // that no touch signed, sign one accept act
14
+ // for each with one touch, post the block on
15
+ // each pull request, close each audit issue,
16
+ // and print the one message for the lead
12
17
  // atlas approve verify --file f verify the blocks of a file against the
13
18
  // key file of a repo
14
19
  // atlas approve key-check sign a test text and verify it with the
@@ -28,7 +33,7 @@ import { parseYaml } from '../../shape/check.mjs';
28
33
  import { ghApi, pinnedRuns } from './gate.mjs';
29
34
  import { GATE_CHECK } from './gate-check.mjs';
30
35
  import {
31
- ACTS, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, SignedActError,
36
+ ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_TEST, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
32
37
  } from './signed-act.mjs';
33
38
 
34
39
  export class ApproveError extends Error {}
@@ -229,10 +234,204 @@ export async function approvePulls({ prs, repo, root, key, provider, yes = false
229
234
  return 0;
230
235
  }
231
236
 
237
+ // ------------------------------------------------------------ atlas approve audit
238
+
239
+ // The push audit opens one issue for each owner merge that no touch signed
240
+ // (R14 line 3), and its filing job then ends red on the merge commit. That red
241
+ // job is the check the owner accepts; the test that his words quote is the
242
+ // kind of the finding (item 0ca9fced, design docs/design-docs/audit-red-sign-off/).
243
+ export { AUDIT_CHECK, AUDIT_TEST };
244
+ export const AUDIT_ISSUE = /^atlas-job: gate-audit owner-unsigned:([0-9a-f]{40})$/;
245
+ export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
246
+ // The label on an audit issue whose red the owner accepted. The lead closes
247
+ // the issue after the done tool records the acceptance (security review S3).
248
+ export const ACCEPTED_LABEL = 'atlas-accepted';
249
+ const AUDIT_AUTHOR = 'github-actions[bot]';
250
+
251
+ // The owner's words for one list. The lead records them, word for word, as
252
+ // one decision. Each pull request is named as #N, and the words quote the test.
253
+ // The time of the list makes the words of each list new, so no second
254
+ // decision, in any state, can take the same touch (security review 2, S3).
255
+ export function auditWords(prs, at) {
256
+ return `I accept the ${AUDIT_TEST} audit reds of ${prs.map((pr) => `#${pr}`).join(', ')}, listed at ${new Date(at).toISOString().slice(0, 16)}Z. These pull requests merged without my touch, under my standing approval.`;
257
+ }
258
+
259
+ // The files whose change lets a merge write its own audit: the audit
260
+ // workflow, the audit and the filer. A merge that changed one is never
261
+ // signed (security review 2, S3).
262
+ export const AUDIT_CODE = Object.freeze([AUDIT_WORKFLOW, 'packages/atlas/door/lib/gate-audit.mjs', 'packages/atlas/door/lib/gate.mjs', 'packages/atlas/door/lib/jobs.mjs', 'packages/atlas/bin/atlas']);
263
+ const AUDIT_ANY = /^atlas-job: gate-audit ([a-z-]+):([0-9a-f]{40})$/;
264
+
265
+ // The owner keys that the done tool reads for one merge: the keys on its
266
+ // first parent that are also on the merge base of that parent and the head.
267
+ function mergeKeys(deps, repo, parent, head) {
268
+ const forkPoint = String(deps.gh('GET', `repos/${repo}/compare/${parent}...${head}`)?.merge_base_commit?.sha ?? '');
269
+ if (!/^[0-9a-f]{40}$/.test(forkPoint)) return [];
270
+ const atParent = signersOn(deps, repo, parent).keys;
271
+ if (forkPoint === parent) return atParent;
272
+ const atFork = new Set(signersOn(deps, repo, forkPoint).keys.map((key) => key.fingerprint));
273
+ return atParent.filter((key) => atFork.has(key.fingerprint));
274
+ }
275
+
276
+ // Why the red audit run of one merge is not only "owner-unsigned" for this
277
+ // pull request, or null when it is (security review S1). The run must be a
278
+ // push run of the audit workflow on the default branch at this merge commit
279
+ // (S3). The filing job's log holds the findings it got, as base64 JSON in its
280
+ // FINDINGS line: the state must be "found", not "broken", and each finding
281
+ // must be owner-unsigned for this merge and this pull request.
282
+ export function auditCause(deps, repo, { run, sha, pr, main }) {
283
+ const job = deps.gh('GET', `repos/${repo}/actions/jobs/${run.id}`);
284
+ const workflow = job?.run_id ? deps.gh('GET', `repos/${repo}/actions/runs/${job.run_id}`) : null;
285
+ if (!workflow) return 'GitHub gave no workflow run for the audit check';
286
+ if (workflow.path !== AUDIT_WORKFLOW || workflow.event !== 'push' || workflow.head_branch !== main || workflow.head_sha !== sha) {
287
+ return `the audit check is not from a push run of ${AUDIT_WORKFLOW} on ${main} at this merge`;
288
+ }
289
+ let log = '';
290
+ try { log = deps.text(`repos/${repo}/actions/jobs/${run.id}/logs`); } catch { return 'the log of the audit check cannot be read'; }
291
+ const lines = String(log ?? '').split('\n').map((one) => one.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z ?/, '').replace(/\r$/, ''));
292
+ const coded = lines.map((one) => /^ {2}FINDINGS: ([A-Za-z0-9+/=]{1,200000})$/.exec(one)?.[1]).filter(Boolean);
293
+ if (coded.length !== 1) return 'the log of the audit check holds no single FINDINGS line';
294
+ let envelope;
295
+ try { envelope = JSON.parse(Buffer.from(coded[0], 'base64').toString('utf8')); } catch { return 'the findings of the audit do not read'; }
296
+ if (envelope?.state !== 'found') return `the audit state is ${plainText(envelope?.state).slice(0, 20)}, not found: it may have stopped`;
297
+ const findings = Array.isArray(envelope.findings) ? envelope.findings : [];
298
+ if (!findings.length) return 'the audit names no finding';
299
+ const other = findings.find((one) => one?.job !== 'gate-audit' || one.kind !== AUDIT_TEST || one.sha !== sha || Number(one.pr) !== pr || one.pin !== undefined || one.key !== `${AUDIT_TEST}:${sha}`);
300
+ if (other) return `the audit also found ${plainText(other?.key ?? other?.kind ?? 'a finding').slice(0, 120)}`;
301
+ return null;
302
+ }
303
+
304
+ // One open audit issue, read from GitHub: the pull request that made the
305
+ // merge, its head, and the red audit run. `skip` says why it is not signed.
306
+ export function auditRow(deps, repo, issue, main, kinds = new Map()) {
307
+ const sha = AUDIT_ISSUE.exec(String(issue.title ?? ''))[1];
308
+ const row = { issue: Number(issue.number), sha };
309
+ const pull = (deps.gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each?.merged_at && each.merge_commit_sha === sha);
310
+ if (!pull) return { ...row, skip: 'no pull request merged this commit' };
311
+ const head = String(pull.head?.sha ?? '');
312
+ if (!/^[0-9a-f]{40}$/.test(head)) return { ...row, pr: Number(pull.number), skip: 'GitHub gave no head' };
313
+ Object.assign(row, { pr: Number(pull.number), head, title: plainText(pull.title).slice(0, 120), merged_at: String(pull.merged_at) });
314
+ const runs = (deps.gh('GET', `repos/${repo}/commits/${sha}/check-runs?per_page=100`)?.check_runs ?? [])
315
+ .filter((run) => run?.name === AUDIT_CHECK)
316
+ .sort((a, b) => String(b.started_at ?? '').localeCompare(String(a.started_at ?? '')));
317
+ const run = runs[0];
318
+ if (run?.conclusion !== 'failure' || run.app?.id !== ACTIONS_APP_ID || !/^[1-9][0-9]{0,15}$/.test(String(run.id))) return { ...row, skip: `the newest check "${AUDIT_CHECK}" is ${run?.conclusion ?? 'missing'} on the merge commit, not a red GitHub Actions run` };
319
+ const cause = auditCause(deps, repo, { run, sha, pr: row.pr, main });
320
+ if (cause) return { ...row, skip: `${cause}: open the run and the other audit issues` };
321
+ const more = [...(kinds.get(sha) ?? [])].filter((kind) => kind !== AUDIT_TEST);
322
+ if (more.length) return { ...row, skip: `another audit issue names this merge: ${more.join(', ')}` };
323
+ const commit = deps.gh('GET', `repos/${repo}/commits/${sha}`);
324
+ const files = Array.isArray(commit?.files) ? commit.files.map((one) => String(one?.filename ?? '')) : null;
325
+ if (!files) return { ...row, skip: 'GitHub gave no file list of the merge commit' };
326
+ const own = files.find((path) => AUDIT_CODE.includes(path));
327
+ if (own) return { ...row, skip: `the merge changed ${own}, so its audit ran its own code: sign it by hand` };
328
+ const parent = String(commit?.parents?.[0]?.sha ?? '');
329
+ if (!/^[0-9a-f]{40}$/.test(parent)) return { ...row, skip: 'GitHub gave no parent of the merge commit' };
330
+ const keys = mergeKeys(deps, repo, parent, head);
331
+ if (!keys.length) return { ...row, skip: `${SIGNERS_FILE} on the base of this merge holds no owner key, so no act can count: close the issue by hand` };
332
+ return { ...row, run: String(run.id), keys };
333
+ }
334
+
335
+ // `atlas approve audit [<pr>...]`. Exit 0 when each listed red got its act.
336
+ export async function approveAudit({ prs = [], repo, root, key, provider, yes = false }, line, deps = DEPS) {
337
+ const bad = prs.find((pr) => !/^[1-9][0-9]{0,8}$/.test(String(pr)));
338
+ if (bad !== undefined) throw new ApproveError(`"${bad}" is not a pull request number.`);
339
+ const only = new Set(prs.map(Number));
340
+ const name = repoOf({ repo, root });
341
+ line(`atlas approve audit: reading the open audit issues of ${name}.`);
342
+ const main = String(deps.gh('GET', `repos/${name}`)?.default_branch ?? '');
343
+ if (!main) throw new ApproveError(`GitHub gave no default branch of ${name}.`);
344
+ const issues = [];
345
+ for (let page = 1; page <= 5; page += 1) {
346
+ const rows = deps.gh('GET', `repos/${name}/issues?state=open&per_page=100&page=${page}`) ?? [];
347
+ for (const issue of Array.isArray(rows) ? rows : []) {
348
+ // Only an issue that the audit job opened counts. Anyone can open an
349
+ // issue with the same title.
350
+ if (issue?.pull_request || !AUDIT_ISSUE.test(String(issue?.title ?? ''))) continue;
351
+ if (issue.user?.login !== AUDIT_AUTHOR || issue.user?.type !== 'Bot') { line(` #${issue.number} is not counted: the audit job did not open it`); continue; }
352
+ if (!only.size && (issue.labels ?? []).some((label) => (label?.name ?? label) === ACCEPTED_LABEL)) { line(` #${issue.number} has the label ${ACCEPTED_LABEL}; the lead closes it after the done tool. Name its pull request to sign it again`); continue; }
353
+ issues.push(issue);
354
+ }
355
+ if (!Array.isArray(rows) || rows.length < 100) break;
356
+ }
357
+ issues.sort((a, b) => Number(a.number) - Number(b.number));
358
+ // Every audit issue of each merge, open or closed: an issue for another
359
+ // finding of the same merge stops its row.
360
+ const kinds = new Map();
361
+ for (let page = 1; page <= 10; page += 1) {
362
+ const rows = deps.gh('GET', `repos/${name}/issues?state=all&labels=job:gate-audit&per_page=100&page=${page}`) ?? [];
363
+ for (const one of Array.isArray(rows) ? rows : []) {
364
+ const hit = AUDIT_ANY.exec(String(one?.title ?? ''));
365
+ if (hit) kinds.set(hit[2], new Set([...(kinds.get(hit[2]) ?? []), hit[1]]));
366
+ }
367
+ if (!Array.isArray(rows) || rows.length < 100) break;
368
+ }
369
+ line(` ${issues.length} open audit issue(s) of an owner merge with no touch. Each one can take about 5 seconds.`);
370
+ const rows = [];
371
+ for (const issue of issues) {
372
+ const row = auditRow(deps, name, issue, main, kinds);
373
+ if (only.size && !only.has(row.pr)) continue;
374
+ rows.push(row);
375
+ }
376
+ const sign = rows.filter((row) => !row.skip).slice(0, MAX_ACTS);
377
+ line(`atlas approve audit: ${name}. Each red below merged while the gate sent it to you, and no touch signed it:`);
378
+ for (const row of rows) {
379
+ line(` issue #${row.issue} ${row.pr ? `#${row.pr}` : row.sha.slice(0, 7)}${row.head ? ` at ${row.head.slice(0, 7)}` : ''}${row.merged_at ? ` merged ${row.merged_at}` : ''} ${row.skip ? 'NOT SIGNED' : 'SIGN'} ${row.title ?? ''}`.trimEnd());
380
+ if (row.skip) line(` ${row.skip}`);
381
+ }
382
+ if (only.size) for (const pr of only) if (!rows.some((row) => row.pr === pr)) line(` #${pr} has no open audit issue: nothing to accept.`);
383
+ if (!sign.length) { line('Nothing to sign: no open audit red can take an act.'); return 1; }
384
+ if (rows.filter((row) => !row.skip).length > sign.length) line(`One touch signs at most ${MAX_ACTS} acts. Run the command again for the rest.`);
385
+ const words = auditWords(sign.map((row) => row.pr), deps.now());
386
+ line('Your words, which the lead records as one decision:');
387
+ line(` ${words}`);
388
+ if (!yes && !(await deps.ask(`Accept the audit red of ${sign.map((row) => `#${row.pr}`).join(', ')} with one touch?`))) { line('Nothing signed.'); return 1; }
389
+ const setup = (deps.setup ?? signingSetup)({ key, provider });
390
+ // Every key that the done tool reads must be one the owner trusts on this
391
+ // Mac, as for a merge act (security review S2 of R72).
392
+ const trusted = (deps.trusted ?? trustedKeys)(setup);
393
+ const strange = [...new Set(sign.flatMap((row) => row.keys).filter((one) => !trusted.has(one.fingerprint)).map((one) => one.fingerprint))];
394
+ if (strange.length) {
395
+ line(`STOP: ${SIGNERS_FILE} on the base of a merge holds a key that is not yours: ${strange.join(', ')}.`);
396
+ line('Nothing was signed. Remove that key in a pull request, or pin it in ~/.config/atlas/signing.json if it is yours.');
397
+ return 1;
398
+ }
399
+ const print = wordsFingerprint(words);
400
+ const acts = sign.map((row) => ({ kind: 'accept', repo: name, pr: row.pr, head: row.head, run: row.run, words: print, check: AUDIT_CHECK }));
401
+ const payload = payloadText(acts, deps.now());
402
+ line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
403
+ const signature = deps.sign(payload, setup);
404
+ const block = { payload, signature };
405
+ for (const row of sign) {
406
+ const proof = verifyBlock(block, row.keys);
407
+ if (proof.error) { line(`The signature does not count on the base of #${row.pr}: ${proof.error}`); line('Nothing was posted.'); return 1; }
408
+ }
409
+ line('The signature counts on the base of each merge.');
410
+ const text = `${blockText(payload, signature)}\nThe owner accepts the ${AUDIT_TEST} audit red of ${sign.map((row) => `#${row.pr}`).join(', ')} with \`atlas approve audit\`. His words:\n\n> ${words}\n`;
411
+ let failed = 0;
412
+ for (const row of sign) {
413
+ try {
414
+ deps.gh('POST', `repos/${name}/issues/${row.pr}/comments`, { body: text });
415
+ deps.gh('POST', `repos/${name}/issues/${row.issue}/comments`, { body: `The owner accepted this red with \`atlas approve audit\`. The signed block is on #${row.pr}. The lead closes this issue after the done tool records the acceptance.` });
416
+ deps.gh('POST', `repos/${name}/issues/${row.issue}/labels`, { labels: [ACCEPTED_LABEL] });
417
+ line(` posted on #${row.pr}, labelled issue #${row.issue} ${ACCEPTED_LABEL}`);
418
+ } catch (error) {
419
+ failed += 1;
420
+ line(` #${row.pr}: GitHub refused a write (${plainText(error?.message ?? error).slice(0, 200)}). Run the command again for it.`);
421
+ }
422
+ }
423
+ line('Now send this one message to the lead, word for word:');
424
+ line('');
425
+ line(words);
426
+ line('');
427
+ return failed ? 1 : 0;
428
+ }
429
+
232
430
  // The act that `atlas approve act <kind>` builds from its flags.
233
431
  export function actFrom(kind, flags, read = (path) => readFileSync(resolve(path), 'utf8')) {
234
- if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => each !== 'merge').join(', ')}; a pull request takes atlas approve <pr>.`);
432
+ if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => each !== 'merge' && each !== 'accept').join(', ')}; a pull request takes atlas approve <pr>.`);
235
433
  if (kind === 'merge') throw new ApproveError('a pull request takes atlas approve <pr>, which reads its gate first.');
434
+ if (kind === 'accept') throw new ApproveError('an audit red takes atlas approve audit, which reads the audit issues first.');
236
435
  const need = (flag) => {
237
436
  const value = flags[flag];
238
437
  if (value === undefined || value === true || value === '') throw new ApproveError(`atlas approve act ${kind} needs --${flag}.`);
@@ -304,6 +503,7 @@ export async function approveCommand({ words = [], repo, root, key, provider, ye
304
503
  const common = { key, provider, yes };
305
504
  try {
306
505
  if (words[0] === 'act') return await approveAct({ ...common, kind: words[1], flags, out }, line, deps);
506
+ if (words[0] === 'audit') return await approveAudit({ ...common, prs: words.slice(1), repo, root }, line, deps);
307
507
  if (words[0] === 'verify') return approveVerify({ file, signers, root }, line);
308
508
  if (words[0] === 'key-check') return await keyCheck(common, line, deps);
309
509
  return await approvePulls({ ...common, prs: words, repo, root }, line, deps);
@@ -83,12 +83,29 @@ export const ACTS = Object.freeze({
83
83
  decision: { fields: ['item', 'words'], form: `(${ID}) (${HASH})` },
84
84
  // A waiver of one red check on one pull request head (R17 line 6).
85
85
  waiver: { fields: ['repo', 'pr', 'head', 'decision', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) (${ID}) ([^\\s].{0,99})` },
86
+ // The acceptance of one red audit check run on one merged pull request
87
+ // head, made with `atlas approve audit` (item 0ca9fced). It names the check
88
+ // run, so a later red of the same check is not accepted (security review
89
+ // S1). It names the fingerprint of the owner's words, not a decision id, so
90
+ // one touch comes before the one message that the lead records.
91
+ accept: { fields: ['repo', 'pr', 'head', 'run', 'words', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) ([1-9][0-9]{0,15}) (${HASH}) ([^\\s].{0,99})` },
86
92
  });
87
93
 
88
94
  // The fingerprint that a lock or a decision act names: sha256 of the text,
89
95
  // with line ends made plain.
90
96
  export const fingerprint = (text) => createHash('sha256').update(String(text).replace(/\r\n?/g, '\n')).digest('hex');
91
97
 
98
+ // The one check and the one test that an accept act may accept: the red
99
+ // filing job of the push audit, for an owner merge with no touch (security
100
+ // review S3). The audit job finds; this job opens the issues and ends red.
101
+ export const AUDIT_CHECK = 'gate · push audit issues';
102
+ export const AUDIT_TEST = 'owner-unsigned';
103
+
104
+ // The fingerprint that an accept act names: the owner's words with each run
105
+ // of white space made one space, and no space at the ends. A chat can wrap a
106
+ // line or drop a last line end; that does not change the words.
107
+ export const wordsFingerprint = (text) => fingerprint(String(text).replace(/\s+/g, ' ').trim());
108
+
92
109
  // One act line from its fields, checked against its form.
93
110
  export function actLine(act) {
94
111
  const spec = ACTS[act?.kind];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.40",
3
+ "version": "0.4.41",
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",
@@ -112,7 +112,7 @@ set. A test keeps the two lists equal.
112
112
  | `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
113
113
  | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names and stale pages. In a pull request it also reads the tracker item, the changed designs, and STE on the changed lines. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
114
114
  | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
115
- | `atlas approve` | Never from a session. Only the owner runs it, on his Mac, with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way |
115
+ | `atlas approve` | Never from a session. Only the owner runs it, on his Mac, with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way. Each morning with open audit issues, ask him for `audit`; record its printed words as one decision for `item_done`, then close each issue |
116
116
  | `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
117
117
 
118
118
  A person merges every file that `atlas tooling` writes.
@@ -16,7 +16,7 @@ import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS }
16
16
  import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
17
17
  import { parseYaml } from '../../shape/check.mjs';
18
18
  import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
19
- import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct } from '../../door/lib/signed-act.mjs';
19
+ import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct, wordsFingerprint, AUDIT_CHECK, AUDIT_TEST } from '../../door/lib/signed-act.mjs';
20
20
 
21
21
  export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
22
22
  export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
@@ -96,7 +96,7 @@ export function latestCheckRuns(runs) {
96
96
  // clears it, and any other later conclusion leaves it standing.
97
97
  const superseded = sorted.slice(0, -1).filter((run) => FATAL_CONCLUSIONS.includes(run.conclusion ?? ''))
98
98
  .map((run) => ({ conclusion: run.conclusion, url: run.html_url ?? '', completed_at: run.completed_at ?? null }));
99
- return { name, conclusion: last.conclusion ?? null, status: last.status ?? null, url: last.html_url ?? '', completed_at: last.completed_at ?? null, attempts: sorted.length, ...(superseded.length ? { superseded_failures: superseded } : {}) };
99
+ return { name, id: last.id ?? null, conclusion: last.conclusion ?? null, status: last.status ?? null, url: last.html_url ?? '', completed_at: last.completed_at ?? null, attempts: sorted.length, ...(superseded.length ? { superseded_failures: superseded } : {}) };
100
100
  })
101
101
  .sort((a, b) => a.name.localeCompare(b.name));
102
102
  }
@@ -441,7 +441,11 @@ export function createDeliveryVerbs(core) {
441
441
  // The act names the repo, the pull request, its head, the decision and the
442
442
  // check. The key must be in atlas/owner-signers on the base of the merge,
443
443
  // its first parent, so a key that the pull request added does not count.
444
- async function signedWaiver(pull, decisionId, check) {
444
+ // Item 0ca9fced: an accept act from `atlas approve audit` counts too. It
445
+ // names the fingerprint of the decision's words in place of its id, so the
446
+ // owner touches once before he sends the words that the lead records.
447
+ async function signedWaiver(pull, decision, check, testName, decisions) {
448
+ const decisionId = decision.id;
445
449
  const head = String(pull.head_sha ?? '').toLowerCase();
446
450
  if (!/^[0-9a-f]{40}$/.test(head)) refuse(`GitHub names no head commit of ${pull.url}, so no signed waiver can match it`);
447
451
  const commit = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}`);
@@ -467,12 +471,27 @@ export function createDeliveryVerbs(core) {
467
471
  if (!Array.isArray(rows) || rows.length < 100) break;
468
472
  }
469
473
  const verified = verifyTexts(texts, keys);
470
- const found = findAct(verified, { kind: 'waiver', repo: pull.repo, pr: pull.number, head, decision: decisionId, check });
474
+ const words = typeof decision.fields?.text === 'string' ? decision.fields.text : '';
475
+ let found = findAct(verified, { kind: 'waiver', repo: pull.repo, pr: pull.number, head, decision: decisionId, check });
476
+ // An accept act takes only the push audit's red of an owner merge
477
+ // (security review S3). Its words name no decision, so exactly one
478
+ // standing decision may hold them: a second decision with the same words
479
+ // cannot carry the touch to items the owner never named (review S2). The
480
+ // words hold the time of the list, and a decision in any state counts,
481
+ // so a replaced decision cannot hand the touch on (review 2, S3).
482
+ if (!found && words.trim() && check === AUDIT_CHECK && testName === AUDIT_TEST) {
483
+ const print = wordsFingerprint(words);
484
+ found = findAct(verified, { kind: 'accept', repo: pull.repo, pr: pull.number, head, words: print, check });
485
+ if (found) {
486
+ const same = decisions.filter((row) => typeof row.fields?.text === 'string' && wordsFingerprint(row.fields.text) === print);
487
+ if (same.length !== 1 || same[0].id !== decisionId) refuse(`the accept act on ${pull.url} matches the words of ${same.length} decisions; exactly one decision, in any state, may hold the words the owner signed`);
488
+ }
489
+ }
471
490
  if (!found) {
472
491
  const why = verified.find((row) => row.error)?.error;
473
- refuse(`accepted_red needs the owner's signed waiver on ${pull.url} for check ${check}, head ${head.slice(0, 12)} and decision ${decisionId}; none counts${why ? ` (a block was refused: ${why})` : ''}. The owner runs: atlas approve act waiver, and posts the block on the pull request`);
492
+ refuse(`accepted_red needs the owner's signed waiver on ${pull.url} for check ${check}, head ${head.slice(0, 12)} and decision ${decisionId}; none counts${why ? ` (a block was refused: ${why})` : ''}. The owner runs: atlas approve act waiver, and posts the block on the pull request; or, for an audit red, atlas approve audit, and the decision holds the words it printed`);
474
493
  }
475
- return { key: found.key, signed_at: found.signed_at };
494
+ return { key: found.key, signed_at: found.signed_at, act: found.act.kind, ...(found.act.kind === 'accept' ? { run: String(found.act.run) } : {}) };
476
495
  }
477
496
 
478
497
  async function acceptedRed(value, pulls, item) {
@@ -480,7 +499,7 @@ export function createDeliveryVerbs(core) {
480
499
  if (!Array.isArray(value)) refuse('accepted_red must be an array of { pr, check, test, decision_id }');
481
500
  if (value.length > MAX_ACCEPTED_RED) refuse(`accepted_red holds at most ${MAX_ACCEPTED_RED} entries`);
482
501
  const decisionType = await typeNamed('decision');
483
- let replacedIds = null;
502
+ let replacedIds = null, allDecisions = null;
484
503
  const rows = [], seen = new Set();
485
504
  for (const entry of value) {
486
505
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) refuse('each accepted_red entry must be an object { pr, check, test, decision_id }');
@@ -512,7 +531,8 @@ export function createDeliveryVerbs(core) {
512
531
  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`);
513
532
  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`);
514
533
  // A decision the owner later replaced no longer stands (finding 4).
515
- replacedIds ??= new Set((await client.listAllEntities({ type: decisionType.name, state: 'any' })).map((row) => row.fields?.replaces).filter(Boolean));
534
+ allDecisions ??= await client.listAllEntities({ type: decisionType.name, state: 'any' });
535
+ replacedIds ??= new Set(allDecisions.map((row) => row.fields?.replaces).filter(Boolean));
516
536
  if (replacedIds.has(decision.id)) refuse(`decision ${decisionId} is replaced by a later decision; it no longer stands`);
517
537
  const words = typeof said.text === 'string' ? said.text : '';
518
538
  const source = typeof said.source === 'string' ? said.source : '';
@@ -522,7 +542,7 @@ export function createDeliveryVerbs(core) {
522
542
  || new RegExp(`(?:^|[^A-Za-z0-9_/.-])#${pull.number}(?![0-9])`).test(words);
523
543
  if (!namesPull) refuse(`decision ${decisionId} does not name ${pull.url}; the owner's words must name the pull request (its URL or #${pull.number})`);
524
544
  if (!words.includes(testName)) refuse(`decision ${decisionId} does not quote the test "${testName}"; the owner's own words must name the exact test`);
525
- const waiver = await signedWaiver(pull, decision.id, check);
545
+ const waiver = await signedWaiver(pull, decision, check, testName, allDecisions);
526
546
  rows.push({ pr: pull.url, check, test: testName, decision_id: decision.id, decision_version: decision.version ?? null, waiver, decision });
527
547
  }
528
548
  return rows;
@@ -688,6 +708,9 @@ export function createDeliveryVerbs(core) {
688
708
  for (const row of mine) {
689
709
  const run = failed.find((one) => one.name === row.check);
690
710
  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`);
711
+ // An accept act names the red run the owner's Mac read, so a later
712
+ // red of the same check is not accepted (security review S1).
713
+ if (row.waiver.run && String(run.id) !== row.waiver.run) refuse(`the accept act on ${pull.url} names run ${row.waiver.run} of ${row.check}, but the newest red run is ${run.id}; the owner runs atlas approve audit again`);
691
714
  const { decision: _record, ...kept } = row;
692
715
  acceptedRows.push({ ...kept, conclusion: run.conclusion, run_url: run.url,
693
716
  detail: `${row.check} concluded ${run.conclusion} for ${pull.url}. The owner accepted the red test "${row.test}" in decision ${row.decision_id}, and signed the waiver with key ${row.waiver.key}. The gate names the check, not the test, so the verb proved the check level only.` });
package/work/mcp.mjs CHANGED
@@ -69,7 +69,7 @@ const TOOL_LIST = [
69
69
  { name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
70
70
  { 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. ${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 } },
71
71
  { 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. ${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 } },
72
- { 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. The owner must also sign a waiver act for the repo, the pull request, its head, the decision and the check (atlas approve act waiver) and post the block on the pull request; the key must be in atlas/owner-signers on the base of the merge. 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. When atlas.yaml on the base of a merge lists required_checks, each one needs a successful newest GitHub Actions run on the pull request head or on the merge commit; a skipped check and a check that never ran are named apart. When atlas.yaml says live: release, a published release must hold each merge commit, and the mark stores that version; a red run on the merge commit is then a warning on the mark, because the release was built from a later green main. 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. ${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. 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 } },
72
+ { 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. The owner must also sign a waiver act for the repo, the pull request, its head, the decision and the check (atlas approve act waiver) and post the block on the pull request; for the push audit red of an owner merge, the accept act that atlas approve audit posts counts too, when the decision's words are the words it printed; the key must be in atlas/owner-signers on the base of the merge. 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. When atlas.yaml on the base of a merge lists required_checks, each one needs a successful newest GitHub Actions run on the pull request head or on the merge commit; a skipped check and a check that never ran are named apart. When atlas.yaml says live: release, a published release must hold each merge commit, and the mark stores that version; a red run on the merge commit is then a warning on the mark, because the release was built from a later green main. 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. ${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. 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 } },
73
73
  { 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 } },
74
74
  { 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, every role.', 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 } },
75
75
  { name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, every role. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },