@arjunkhera/atlas 0.4.46 → 0.4.48

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.46",
3
+ "version": "0.4.48",
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
@@ -24,6 +24,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
24
24
  import { checkPaths, LIMITS } from './lib/ste.mjs';
25
25
  import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
26
26
  import { writeDesignPage, readTracker } from './lib/design-build.mjs';
27
+ import { writePage } from './lib/page.mjs';
27
28
  import { writeViewPage, viewCapabilities } from './lib/view.mjs';
28
29
  import { loadPrivateTerms } from './lib/privacy.mjs';
29
30
  import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
@@ -60,6 +61,7 @@ const HELP = `atlas — the door into a repo's Atlas files
60
61
 
61
62
  atlas ste <file or folder> … measure markdown and HTML against the STE limits
62
63
  --share also run the privacy filter, quotes included
64
+ an HTML page also needs a woodcut figure and only Atlas theme colours
63
65
  --terms <file> private terms; default ~/.config/atlas/private-terms.txt
64
66
  atlas design check <folder> … check a design folder: each needed part, no state line, every code in the Key
65
67
  atlas design folder print where this repo keeps its designs: design.folder in atlas.yaml, or docs/design-docs
@@ -69,6 +71,10 @@ const HELP = `atlas — the door into a repo's Atlas files
69
71
  --out <file> the page; default docs/artifacts/<folder name>.html
70
72
  --draft build even when the check has findings, and mark the page a draft
71
73
 
74
+ atlas page build <file or folder> build an explainer page from markdown: woodcut figures, the Atlas theme, light and dark
75
+ --out <file> the page; default <file or folder>.html beside it
76
+ --title <text> the page title; default the title of the first part
77
+
72
78
  atlas view build --product <id> build the project view page of one product: the four views, read live from the tracker
73
79
  --name <name> the product's name on the page; default the id with a capital
74
80
  --out <file> the page; default docs/artifacts/<id>-view.html
@@ -211,6 +217,7 @@ export const FLAGS = Object.freeze({
211
217
  check: { root: 'optional', repo: 'value', 'fail-on': 'value', json: 'switch' },
212
218
  ste: { share: 'switch', terms: 'value' },
213
219
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
220
+ page: { out: 'value', title: 'value' },
214
221
  view: { product: 'value', name: 'value', out: 'value' },
215
222
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
216
223
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
@@ -391,6 +398,26 @@ function designCommand(chosen) {
391
398
  return count ? 1 : 0;
392
399
  }
393
400
 
401
+ function pageCommand(chosen) {
402
+ const sources = chosen._.slice(2);
403
+ if (chosen._[1] !== 'build') throw new Error('use atlas page build <file or folder>');
404
+ if (sources.length !== 1) throw new Error('give one markdown file or one folder: atlas page build <file or folder>');
405
+ const source = resolve(sources[0]);
406
+ const out = resolve(chosen.out ?? `${source.replace(/\.md$/, '').replace(/\/+$/, '')}.html`);
407
+ const result = writePage(source, out, { title: chosen.title ?? null });
408
+ if (!result.html) {
409
+ for (const finding of result.findings) line(`${finding.file}:${finding.line} page ${finding.why}`);
410
+ line('');
411
+ line(`No page written: ${result.findings.length} finding(s). Fix the markdown by hand.`);
412
+ return 1;
413
+ }
414
+ line(`Wrote ${out}: ${result.parts} part(s), ${result.figures} woodcut figure(s), ${result.walks} that walk.`);
415
+ const findings = checkPaths([out]).flatMap((one) => one.findings);
416
+ for (const finding of findings) line(`${out}:${finding.line} ${finding.rule} ${finding.why}`);
417
+ line(findings.length ? `atlas ste: ${findings.length} finding(s) on the page. Fix the markdown by hand and build again.` : 'atlas ste: GREEN. Publish the page.');
418
+ return findings.length ? 1 : 0;
419
+ }
420
+
394
421
  function viewCommand(chosen) {
395
422
  if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
396
423
  const product = chosen.product;
@@ -481,6 +508,7 @@ export async function main(argv = process.argv.slice(2)) {
481
508
  case 'check': return checkCommand(chosen);
482
509
  case 'ste': return steCommand(chosen);
483
510
  case 'design': return designCommand(chosen);
511
+ case 'page': return pageCommand(chosen);
484
512
  case 'view': return viewCommand(chosen);
485
513
  case 'tests': return testsCommand(chosen);
486
514
  case 'jobs': return jobsVerb(chosen);
@@ -14,6 +14,10 @@
14
14
  // for each with one touch, post the block on
15
15
  // each pull request, close each audit issue,
16
16
  // and print the one message for the lead
17
+ // atlas approve audit-code <pr> show the audit-code diff of one merged
18
+ // pull request and its red audit check,
19
+ // sign one accept-code act with one touch,
20
+ // post the block, and print the message
17
21
  // atlas approve verify --file f verify the blocks of a file against the
18
22
  // key file of a repo
19
23
  // atlas approve key-check sign a test text and verify it with the
@@ -30,10 +34,10 @@ import { join, resolve } from 'node:path';
30
34
  import { createInterface } from 'node:readline/promises';
31
35
  import { childEnvironment } from '../../work/lib/child-env.mjs';
32
36
  import { parseYaml } from '../../shape/check.mjs';
33
- import { ghApi, pinnedRuns } from './gate.mjs';
37
+ import { ghApi, pinnedRuns, commitAudit } from './gate.mjs';
34
38
  import { GATE_CHECK } from './gate-check.mjs';
35
39
  import {
36
- ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_TEST, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
40
+ ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, AUDIT_CODE, AUDIT_CODE_TEST, AUDIT_WORKFLOW, auditCodeDiff, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
37
41
  preKeyKeys,
38
42
  } from './signed-act.mjs';
39
43
 
@@ -241,9 +245,8 @@ export async function approvePulls({ prs, repo, root, key, provider, yes = false
241
245
  // (R14 line 3), and its filing job then ends red on the merge commit. That red
242
246
  // job is the check the owner accepts; the test that his words quote is the
243
247
  // kind of the finding (item 0ca9fced, design docs/design-docs/audit-red-sign-off/).
244
- export { AUDIT_CHECK, AUDIT_TEST };
248
+ export { AUDIT_CHECK, AUDIT_TEST, AUDIT_CODE, AUDIT_WORKFLOW };
245
249
  export const AUDIT_ISSUE = /^atlas-job: gate-audit owner-unsigned:([0-9a-f]{40})$/;
246
- export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
247
250
  // The label on an audit issue whose red the owner accepted. The lead closes
248
251
  // the issue after the done tool records the acceptance (security review S3).
249
252
  export const ACCEPTED_LABEL = 'atlas-accepted';
@@ -257,10 +260,6 @@ export function auditWords(prs, at) {
257
260
  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.`;
258
261
  }
259
262
 
260
- // The files whose change lets a merge write its own audit: the audit
261
- // workflow, the audit and the filer. A merge that changed one is never
262
- // signed (security review 2, S3).
263
- 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']);
264
263
  const AUDIT_ANY = /^atlas-job: gate-audit ([a-z-]+):([0-9a-f]{40})$/;
265
264
 
266
265
  // The owner keys that the done tool reads for one merge: the keys on its
@@ -274,6 +273,46 @@ function mergeKeys(deps, repo, parent, head) {
274
273
  return atParent.filter((key) => atFork.has(key.fingerprint));
275
274
  }
276
275
 
276
+ // The owner keys for the act of one merge: the keys of mergeKeys, or, for a
277
+ // merge made before the first owner key, the keys at the tip of the default
278
+ // branch within the limits of preKeyKeys (item 65c6c907).
279
+ async function ownerKeys(deps, repo, { parent, head, main }) {
280
+ const keys = mergeKeys(deps, repo, parent, head);
281
+ if (keys.length) return { keys };
282
+ const read = (path) => { try { return deps.gh('GET', path); } catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null; throw error; } };
283
+ let first;
284
+ try { first = await preKeyKeys(read, repo, { parent, branch: main }); } catch (error) { first = { why: `GitHub refused a read of the key file history (${plainText(error?.message ?? error).slice(0, 120)})` }; }
285
+ return first.keys ? { keys: first.keys, first_key: first.from } : { keys: [], why: first.why };
286
+ }
287
+
288
+ // Every audit issue of each merge, open or closed, by the kinds of its
289
+ // findings.
290
+ function auditKinds(deps, repo) {
291
+ const kinds = new Map();
292
+ for (let page = 1; page <= 10; page += 1) {
293
+ const rows = deps.gh('GET', `repos/${repo}/issues?state=all&labels=job:gate-audit&per_page=100&page=${page}`) ?? [];
294
+ for (const one of Array.isArray(rows) ? rows : []) {
295
+ const hit = AUDIT_ANY.exec(String(one?.title ?? ''));
296
+ if (hit) kinds.set(hit[2], new Set([...(kinds.get(hit[2]) ?? []), hit[1]]));
297
+ }
298
+ if (!Array.isArray(rows) || rows.length < 100) break;
299
+ }
300
+ return kinds;
301
+ }
302
+
303
+ // Why the audit run of a check is not a push run of the audit workflow on
304
+ // the default branch at this merge commit, or null when it is (S3). It
305
+ // answers the job too.
306
+ function auditRunCause(deps, repo, { run, sha, main }) {
307
+ const job = deps.gh('GET', `repos/${repo}/actions/jobs/${run.id}`);
308
+ const workflow = job?.run_id ? deps.gh('GET', `repos/${repo}/actions/runs/${job.run_id}`) : null;
309
+ if (!workflow) return { cause: 'GitHub gave no workflow run for the audit check' };
310
+ if (workflow.path !== AUDIT_WORKFLOW || workflow.event !== 'push' || workflow.head_branch !== main || workflow.head_sha !== sha) {
311
+ return { cause: `the audit check is not from a push run of ${AUDIT_WORKFLOW} on ${main} at this merge` };
312
+ }
313
+ return { cause: null, job };
314
+ }
315
+
277
316
  // Why the red audit run of one merge is not only "owner-unsigned" for this
278
317
  // pull request, or null when it is (security review S1). The run must be a
279
318
  // push run of the audit workflow on the default branch at this merge commit
@@ -281,12 +320,8 @@ function mergeKeys(deps, repo, parent, head) {
281
320
  // FINDINGS line: the state must be "found", not "broken", and each finding
282
321
  // must be owner-unsigned for this merge and this pull request.
283
322
  export function auditCause(deps, repo, { run, sha, pr, main }) {
284
- const job = deps.gh('GET', `repos/${repo}/actions/jobs/${run.id}`);
285
- const workflow = job?.run_id ? deps.gh('GET', `repos/${repo}/actions/runs/${job.run_id}`) : null;
286
- if (!workflow) return 'GitHub gave no workflow run for the audit check';
287
- if (workflow.path !== AUDIT_WORKFLOW || workflow.event !== 'push' || workflow.head_branch !== main || workflow.head_sha !== sha) {
288
- return `the audit check is not from a push run of ${AUDIT_WORKFLOW} on ${main} at this merge`;
289
- }
323
+ const { cause } = auditRunCause(deps, repo, { run, sha, main });
324
+ if (cause) return cause;
290
325
  let log = '';
291
326
  try { log = deps.text(`repos/${repo}/actions/jobs/${run.id}/logs`); } catch { return 'the log of the audit check cannot be read'; }
292
327
  const lines = String(log ?? '').split('\n').map((one) => one.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z ?/, '').replace(/\r$/, ''));
@@ -324,21 +359,16 @@ export async function auditRow(deps, repo, issue, main, kinds = new Map()) {
324
359
  const commit = deps.gh('GET', `repos/${repo}/commits/${sha}`);
325
360
  const files = Array.isArray(commit?.files) ? commit.files.map((one) => String(one?.filename ?? '')) : null;
326
361
  if (!files) return { ...row, skip: 'GitHub gave no file list of the merge commit' };
327
- const own = files.find((path) => AUDIT_CODE.includes(path));
328
- if (own) return { ...row, skip: `the merge changed ${own}, so its audit ran its own code: sign it by hand` };
362
+ const code = auditCodeDiff(commit.files);
363
+ if (code.unknown) return { ...row, skip: 'GitHub gave no full file list of the merge commit' };
364
+ if (code.files.length) return { ...row, skip: `the merge changed ${code.files[0]}, so its audit ran its own code: read that diff and sign it with atlas approve audit-code ${row.pr}` };
329
365
  const parent = String(commit?.parents?.[0]?.sha ?? '');
330
366
  if (!/^[0-9a-f]{40}$/.test(parent)) return { ...row, skip: 'GitHub gave no parent of the merge commit' };
331
- let keys = mergeKeys(deps, repo, parent, head);
332
367
  // Item 65c6c907: a merge made before the first owner key takes the keys
333
368
  // at the tip of the default branch, within the limits of preKeyKeys.
334
- let first = null;
335
- if (!keys.length) {
336
- const read = (path) => { try { return deps.gh('GET', path); } catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null; throw error; } };
337
- try { first = await preKeyKeys(read, repo, { parent, branch: main }); } catch (error) { first = { why: `GitHub refused a read of the key file history (${plainText(error?.message ?? error).slice(0, 120)})` }; }
338
- if (first.keys) keys = first.keys;
339
- }
340
- 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. No first key counts: ${first?.why ?? 'no reason'}` };
341
- return { ...row, run: String(run.id), keys, ...(first?.from ? { first_key: first.from } : {}) };
369
+ const owner = await ownerKeys(deps, repo, { parent, head, main });
370
+ if (!owner.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. No first key counts: ${owner.why ?? 'no reason'}` };
371
+ return { ...row, run: String(run.id), keys: owner.keys, ...(owner.first_key ? { first_key: owner.first_key } : {}) };
342
372
  }
343
373
 
344
374
  // `atlas approve audit [<pr>...]`. Exit 0 when each listed red got its act.
@@ -366,15 +396,7 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
366
396
  issues.sort((a, b) => Number(a.number) - Number(b.number));
367
397
  // Every audit issue of each merge, open or closed: an issue for another
368
398
  // finding of the same merge stops its row.
369
- const kinds = new Map();
370
- for (let page = 1; page <= 10; page += 1) {
371
- const rows = deps.gh('GET', `repos/${name}/issues?state=all&labels=job:gate-audit&per_page=100&page=${page}`) ?? [];
372
- for (const one of Array.isArray(rows) ? rows : []) {
373
- const hit = AUDIT_ANY.exec(String(one?.title ?? ''));
374
- if (hit) kinds.set(hit[2], new Set([...(kinds.get(hit[2]) ?? []), hit[1]]));
375
- }
376
- if (!Array.isArray(rows) || rows.length < 100) break;
377
- }
399
+ const kinds = auditKinds(deps, name);
378
400
  line(` ${issues.length} open audit issue(s) of an owner merge with no touch. Each one can take about 5 seconds.`);
379
401
  const rows = [];
380
402
  for (const issue of issues) {
@@ -437,11 +459,146 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
437
459
  return failed ? 1 : 0;
438
460
  }
439
461
 
462
+ // ------------------------------------------------------- atlas approve audit-code
463
+
464
+ // The owner's words for the audit-code red of one merge. The lead records
465
+ // them, word for word, as one decision. They name the pull request as #N,
466
+ // quote the test, and hold the time, as the words of approveAudit do.
467
+ export function auditCodeWords({ pr, sha, files, check }, at) {
468
+ return `I read the ${AUDIT_CODE_TEST} diff of #${pr} (merge ${sha.slice(0, 7)}): ${files.join(', ')}. I accept its ${AUDIT_CODE_TEST} red of "${check}", listed at ${new Date(at).toISOString().slice(0, 16)}Z.`;
469
+ }
470
+
471
+ const PASSING = ['success', 'skipped', 'neutral'];
472
+ const RED = ['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure'];
473
+
474
+ // The audit of one merge commit with the code of this Mac's Atlas, not the
475
+ // code of the merge. It needs the merge, its head and its parent in the clone
476
+ // at `root`. Returns the findings.
477
+ export function macAudit({ repo, root, sha }) {
478
+ // The clone must be a clone of this repo, or every commit reads as unread
479
+ // (security review 2, S2).
480
+ let origin = '';
481
+ try { origin = execFileSync('git', ['-C', root, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { origin = ''; }
482
+ if (!new RegExp(`^(https://|ssh://git@|git@)github\\.com[:/]${repo.replace(/[.]/g, '\\.')}(\\.git)?$`, 'i').test(origin)) throw new ApproveError(`${root} is not a clone of ${repo}; run the command in the clone`);
483
+ const found = [];
484
+ commitAudit({ repo, root, commit: sha, found }, () => {}, ghApi);
485
+ return found;
486
+ }
487
+
488
+ // `atlas approve audit-code <pr>` (item f36c6b0f). A merge that changed the
489
+ // audit code ran its own audit, so the batch never signs it. The owner reads
490
+ // the audit-code diff of that one merge here, and one touch signs one
491
+ // accept-code act for its one red audit check. Exit 0 when the act is posted.
492
+ export async function approveAuditCode({ prs = [], repo, root, key, provider, yes = false }, line, deps = DEPS) {
493
+ if (prs.length !== 1 || !/^[1-9][0-9]{0,8}$/.test(String(prs[0]))) throw new ApproveError('atlas approve audit-code takes one pull request number.');
494
+ const pr = Number(prs[0]);
495
+ const name = repoOf({ repo, root });
496
+ const main = String(deps.gh('GET', `repos/${name}`)?.default_branch ?? '');
497
+ if (!main) throw new ApproveError(`GitHub gave no default branch of ${name}.`);
498
+ const pull = deps.gh('GET', `repos/${name}/pulls/${pr}`);
499
+ const sha = String(pull?.merge_commit_sha ?? '');
500
+ const head = String(pull?.head?.sha ?? '');
501
+ if (!pull?.merged_at || !/^[0-9a-f]{40}$/.test(sha) || !/^[0-9a-f]{40}$/.test(head)) throw new ApproveError(`#${pr} is not a merged pull request with a merge commit.`);
502
+ if (pull.base?.ref !== main) throw new ApproveError(`#${pr} did not merge into ${main}; the push audit runs only there.`);
503
+ const commit = deps.gh('GET', `repos/${name}/commits/${sha}`);
504
+ const parent = String(commit?.parents?.[0]?.sha ?? '');
505
+ if (!/^[0-9a-f]{40}$/.test(parent)) throw new ApproveError('GitHub gave no parent of the merge commit.');
506
+ // Only a merge commit with two parents lists the whole change of the pull
507
+ // request against the main branch. A rebase merge lists only its last
508
+ // commit, so the diff could hide audit code (security review 2, S1).
509
+ if (!Array.isArray(commit.parents) || commit.parents.length !== 2) throw new ApproveError(`#${pr} did not merge with a merge commit, so its whole diff cannot be shown. Nothing was signed.`);
510
+ const code = auditCodeDiff(commit?.files);
511
+ if (code.unknown) throw new ApproveError('GitHub gave no full file list of the merge commit.');
512
+ if (!code.files.length) throw new ApproveError(`#${pr} changed no audit code. Use atlas approve audit.`);
513
+ const changed = commit.files.filter((one) => AUDIT_CODE.includes(String(one?.filename ?? '')) || AUDIT_CODE.includes(String(one?.previous_filename ?? '')));
514
+ const cut = changed.find((one) => typeof one.patch !== 'string' && one.status !== 'removed');
515
+ if (cut) throw new ApproveError(`GitHub gave no diff of ${plainText(cut.filename)}, so you cannot read it here. Nothing was signed.`);
516
+ // An audit issue for another finding of this merge stops the act: the
517
+ // touch accepts the audit-code red only (security review S3 of 0ca9fced).
518
+ const more = [...(auditKinds(deps, name).get(sha) ?? [])].filter((kind) => kind !== AUDIT_TEST);
519
+ if (more.length) throw new ApproveError(`another audit issue names this merge: ${more.join(', ')}. Nothing was signed.`);
520
+ // Every run of each audit check, not only the newest attempt.
521
+ const all = (deps.gh('GET', `repos/${name}/commits/${sha}/check-runs?filter=all&per_page=100`)?.check_runs ?? []).filter((run) => AUDIT_CHECKS.includes(run?.name));
522
+ const newest = AUDIT_CHECKS.map((check) => all.filter((run) => run.name === check).sort((a, b) => String(b.started_at ?? '').localeCompare(String(a.started_at ?? '')))[0]).filter(Boolean);
523
+ const open = newest.find((run) => run.status !== 'completed');
524
+ if (open) throw new ApproveError(`"${plainText(open.name)}" has not completed on the merge commit. Nothing was signed.`);
525
+ const red = newest.filter((run) => RED.includes(run.conclusion));
526
+ const odd = newest.find((run) => !RED.includes(run.conclusion) && !PASSING.includes(run.conclusion));
527
+ if (odd) throw new ApproveError(`"${plainText(odd.name)}" ended ${plainText(odd.conclusion)}. Nothing was signed.`);
528
+ if (red.length !== 1) throw new ApproveError(`the merge commit has ${red.length} red audit checks; this command accepts exactly one. Nothing was signed.`);
529
+ const run = red[0];
530
+ if (run.app?.id !== ACTIONS_APP_ID || !/^[1-9][0-9]{0,15}$/.test(String(run.id))) throw new ApproveError(`the red "${plainText(run.name)}" is not a GitHub Actions run.`);
531
+ const pinned = auditRunCause(deps, name, { run, sha, main });
532
+ if (pinned.cause) throw new ApproveError(`${pinned.cause}. Nothing was signed.`);
533
+ // The first parent was a main tip of its own push: its push audit ran at
534
+ // it. So the push of this merge brought this merge commit only, and no
535
+ // commit past the gate hides under it (security review 2, S1).
536
+ const before = (deps.gh('GET', `repos/${name}/commits/${parent}/check-runs?filter=all&per_page=100`)?.check_runs ?? [])
537
+ .filter((one) => AUDIT_CHECKS.includes(one?.name) && one.app?.id === ACTIONS_APP_ID && /^[1-9][0-9]{0,15}$/.test(String(one.id)));
538
+ if (!before.some((one) => !auditRunCause(deps, name, { run: one, sha: parent, main }).cause)) throw new ApproveError(`the parent ${parent.slice(0, 7)} has no push audit run of its own, so the push of #${pr} may hold more than its merge. Nothing was signed.`);
539
+ // A force push can put this merge on a parent that drops a later commit
540
+ // of the main branch. The repo may enforce no branch rule, so the command
541
+ // reads the force pushes of the branch (security review 3, S2).
542
+ const forced = deps.gh('GET', `repos/${name}/activity?ref=${encodeURIComponent(`refs/heads/${main}`)}&activity_type=force_push&per_page=100`);
543
+ if (!Array.isArray(forced)) throw new ApproveError(`GitHub gave no list of the force pushes to ${main}. Nothing was signed.`);
544
+ if (forced.some((one) => String(one?.after ?? '').toLowerCase() === sha)) throw new ApproveError(`a force push put the merge of #${pr} on ${main}. Nothing was signed.`);
545
+ // The audit of that run ran the code of this merge, or never ran. So the
546
+ // Atlas on this Mac audits the merge again with its own code (security
547
+ // review S2): it must find this owner merge and nothing else.
548
+ let found;
549
+ try { found = await (deps.audit ?? macAudit)({ repo: name, root: resolve(root ?? '.'), sha }); }
550
+ catch (error) { throw new ApproveError(`the audit on this Mac stopped: ${plainText(error?.message ?? error).slice(0, 200)}. Nothing was signed.`); }
551
+ if (Array.isArray(found) && found.some((one) => one?.pin === 'unread')) throw new ApproveError(`the audit on this Mac could not read the head or the base of #${pr} in this clone. Run git fetch origin pull/${pr}/head, then the command again. Nothing was signed.`);
552
+ const only = Array.isArray(found) && found.length === 1 && found[0]?.kind === AUDIT_TEST && found[0].sha === sha && Number(found[0].pr) === pr && found[0].key === `${AUDIT_TEST}:${sha}` && found[0].pin === undefined;
553
+ if (!only) throw new ApproveError(`the audit on this Mac found ${Array.isArray(found) && found.length ? found.map((one) => plainText(one?.key ?? one?.kind)).join(', ') : 'nothing to accept'}, not only ${AUDIT_TEST} for #${pr}. Nothing was signed.`);
554
+ const owner = await ownerKeys(deps, name, { parent, head, main });
555
+ if (!owner.keys.length) throw new ApproveError(`${SIGNERS_FILE} holds no owner key that counts for this merge: ${owner.why ?? 'no reason'}.`);
556
+ line(`atlas approve audit-code: ${name} #${pr} ${plainText(pull.title).slice(0, 120)}`);
557
+ line(` merged ${plainText(pull.merged_at)} as ${sha}, head ${head.slice(0, 7)}`);
558
+ line(` red: "${plainText(run.name)}" ${plainText(run.conclusion)}, run ${run.id}.`);
559
+ line(` The audit on this Mac, with its own code, found only ${AUDIT_TEST} for #${pr}.`);
560
+ line(` The merge changed ${commit.files.length} file(s): ${commit.files.map((one) => plainText(one?.filename)).join(', ')}`);
561
+ if (owner.first_key) line(` merged before the first owner key: the act counts against the keys at the tip of ${main}, ${owner.first_key.slice(0, 7)}`);
562
+ line(`Read the audit-code diff of this merge before you touch (${changed.length} file(s)):`);
563
+ for (const one of changed) {
564
+ line(`--- ${plainText(one.previous_filename ? `${one.previous_filename} -> ` : '')}${plainText(one.filename)} (${plainText(one.status)})`);
565
+ for (const each of String(one.patch ?? '').split('\n')) line(` ${plainText(each)}`);
566
+ }
567
+ const words = auditCodeWords({ pr, sha, files: code.files, check: run.name }, deps.now());
568
+ line('Your words, which the lead records as one decision:');
569
+ line(` ${words}`);
570
+ if (!yes && !(await deps.ask(`You read this diff. Accept the ${AUDIT_CODE_TEST} red of #${pr} with one touch?`))) { line('Nothing signed.'); return 1; }
571
+ const setup = (deps.setup ?? signingSetup)({ key, provider });
572
+ const trusted = (deps.trusted ?? trustedKeys)(setup);
573
+ const strange = owner.keys.filter((one) => !trusted.has(one.fingerprint)).map((one) => one.fingerprint);
574
+ if (strange.length) {
575
+ line(`STOP: ${SIGNERS_FILE} holds a key that is not yours: ${strange.join(', ')}.`);
576
+ line('Nothing was signed. Remove that key in a pull request, or pin it in ~/.config/atlas/signing.json if it is yours.');
577
+ return 1;
578
+ }
579
+ const act = { kind: 'accept-code', repo: name, pr, head, merge: sha, run: String(run.id), diff: code.print, words: wordsFingerprint(words), check: run.name };
580
+ const payload = payloadText([act], deps.now());
581
+ line(`Touch the key now (${setup.key}${setup.provider ? `, provider ${setup.provider}` : ''}).`);
582
+ const signature = deps.sign(payload, setup);
583
+ const proof = verifyBlock({ payload, signature }, owner.keys);
584
+ if (proof.error) { line(`The signature does not count for #${pr}: ${proof.error}`); line('Nothing was posted.'); return 1; }
585
+ const text = `${blockText(payload, signature)}\nThe owner read the ${AUDIT_CODE_TEST} diff of #${pr} and accepts its red with \`atlas approve audit-code\`. His words:\n\n> ${words}\n`;
586
+ try { deps.gh('POST', `repos/${name}/issues/${pr}/comments`, { body: text }); }
587
+ catch (error) { line(`#${pr}: GitHub refused the write (${plainText(error?.message ?? error).slice(0, 200)}). Run the command again.`); return 1; }
588
+ line(` posted on #${pr}`);
589
+ line('Now send this one message to the lead, word for word:');
590
+ line('');
591
+ line(words);
592
+ line('');
593
+ return 0;
594
+ }
595
+
440
596
  // The act that `atlas approve act <kind>` builds from its flags.
441
597
  export function actFrom(kind, flags, read = (path) => readFileSync(resolve(path), 'utf8')) {
442
- 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>.`);
598
+ if (!Object.hasOwn(ACTS, kind ?? '')) throw new ApproveError(`there is no act "${kind ?? ''}". Use ${Object.keys(ACTS).filter((each) => !['merge', 'accept', 'accept-code'].includes(each)).join(', ')}; a pull request takes atlas approve <pr>.`);
443
599
  if (kind === 'merge') throw new ApproveError('a pull request takes atlas approve <pr>, which reads its gate first.');
444
600
  if (kind === 'accept') throw new ApproveError('an audit red takes atlas approve audit, which reads the audit issues first.');
601
+ if (kind === 'accept-code') throw new ApproveError('the audit red of an audit-code merge takes atlas approve audit-code <pr>, which shows its diff first.');
445
602
  const need = (flag) => {
446
603
  const value = flags[flag];
447
604
  if (value === undefined || value === true || value === '') throw new ApproveError(`atlas approve act ${kind} needs --${flag}.`);
@@ -513,6 +670,7 @@ export async function approveCommand({ words = [], repo, root, key, provider, ye
513
670
  const common = { key, provider, yes };
514
671
  try {
515
672
  if (words[0] === 'act') return await approveAct({ ...common, kind: words[1], flags, out }, line, deps);
673
+ if (words[0] === 'audit-code') return await approveAuditCode({ ...common, prs: words.slice(1), repo, root }, line, deps);
516
674
  if (words[0] === 'audit') return await approveAudit({ ...common, prs: words.slice(1), repo, root }, line, deps);
517
675
  if (words[0] === 'verify') return approveVerify({ file, signers, root }, line);
518
676
  if (words[0] === 'key-check') return await keyCheck(common, line, deps);
@@ -40,7 +40,7 @@ export const TOKENS = Object.freeze({
40
40
 
41
41
  const tokenBlock = (index) => Object.entries(TOKENS).map(([name, pair]) => `--${name}:${pair[index]}`).join(';');
42
42
 
43
- const CSS = `
43
+ export const CSS = `
44
44
  :root{${tokenBlock(0)};color-scheme:light;
45
45
  --wc-paper:var(--paper);--wc-ink:var(--ink);--wc-body:var(--body);--wc-muted:var(--muted);--wc-faint:var(--quiet);--wc-line:var(--line);--wc-line-strong:var(--line-strong);--wc-accent:var(--accent);--wc-accent-soft:var(--accent-bg);--wc-panel:var(--rail);--wc-card:var(--card);--wc-danger:var(--no);--wc-danger-soft:var(--accent-bg);
46
46
  --wc-serif:Lora,Georgia,serif;--wc-mono:'IBM Plex Mono',ui-monospace,Menlo,monospace;--wc-dur-fast:150ms;--wc-dur-base:220ms;--wc-dur-step:450ms;--wc-dur-draw:700ms;
@@ -138,7 +138,7 @@ kbd{border:1px solid var(--line-strong);border-radius:4px;padding:0 5px;font-fam
138
138
  @media print{body{overflow:visible}.app{display:block;height:auto}.rail,.bar.foot{display:none}#scroll{overflow:visible}.part[hidden]{display:flex}}
139
139
  `;
140
140
 
141
- const SCRIPT = `
141
+ export const SCRIPT = `
142
142
  (function () {
143
143
  var data = JSON.parse(document.getElementById('design-data').textContent);
144
144
  var flat = [];
@@ -356,6 +356,7 @@ export function buildDesign(folder, { tracker = null, draft = false, registry =
356
356
  <head>
357
357
  <meta charset="utf-8">
358
358
  <meta name="viewport" content="width=device-width, initial-scale=1">
359
+ <meta name="generator" content="atlas design build">
359
360
  <title>${esc(title)}</title>
360
361
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,500;0,600;1,500&family=Poppins:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
361
362
  <style>${CSS}</style>
@@ -0,0 +1,242 @@
1
+ // The explainer page builder, and the page rules of `atlas ste`.
2
+ //
3
+ // An explainer tells the owner how a technical flow works or where it
4
+ // stands. Owner, 30 October 2026: "I would expect things to be explained
5
+ // visually, the results are very disappointing, there is no theme in
6
+ // pages" (lesson 8678a27b, item 99ccaa71). So one builder makes every
7
+ // explainer, and the STE check refuses a page that skips it.
8
+ //
9
+ // `atlas page build <file or folder>` reads markdown: one file is one part,
10
+ // a folder is one part for each .md file, in name order. The parts use the
11
+ // blocks of the design renderer (markdown.mjs), with woodcut figures. The
12
+ // page is the design page frame: a left rail, Back and Next, the arrow keys,
13
+ // and a light and a dark theme from the design token set. The build refuses
14
+ // a source with no woodcut figure, a figure woodcut cannot draw, or no
15
+ // figure that walks: a scenario with two or more titled steps.
16
+ //
17
+ // The page rules read each HTML page that `atlas ste` reads:
18
+ // page-figure the page draws no woodcut figure. A design page (its own
19
+ // check gates its pictures) and a project view page (a data
20
+ // view, not an explainer) are exempt, by their generator line.
21
+ // page-theme a colour that is in no Atlas theme, or no dark theme.
22
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from 'node:fs';
23
+ import { join, dirname, basename, resolve } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { CSS, SCRIPT, TOKENS, WOODCUT } from './design-build.mjs';
26
+ import { frontMatter, woodcutFault, woodcutWalks } from './design.mjs';
27
+ import { render, esc, slug, fenceOpens, fenceCloses, WOODCUT_TYPES } from './markdown.mjs';
28
+
29
+ export const GENERATOR = 'atlas page build';
30
+ // Builders whose pages answer to their own check, not to page-figure.
31
+ export const FIGURE_EXEMPT = Object.freeze(['atlas design build', 'atlas view build']);
32
+
33
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
34
+
35
+ // ------------------------------------------------------------- the build
36
+
37
+ // The woodcut fences of one markdown body, with their line numbers.
38
+ function woodcutFences(body, firstLine) {
39
+ const out = [];
40
+ let fence = null;
41
+ body.replace(/\r\n?/g, '\n').split('\n').forEach((text, index) => {
42
+ const line = firstLine + index;
43
+ if (fence) {
44
+ if (fenceCloses(text, fence.mark)) { if (fence.woodcut) out.push(fence); fence = null; } else fence.lines.push(text);
45
+ return;
46
+ }
47
+ const mark = fenceOpens(text);
48
+ if (!mark) return;
49
+ const [info, type] = text.trim().slice(mark.length).trim().split(/\s+/);
50
+ fence = { mark, woodcut: info === 'woodcut', type: type ?? null, line, lines: [] };
51
+ });
52
+ if (fence?.woodcut) out.push({ ...fence, open: true });
53
+ return out;
54
+ }
55
+
56
+ export function readSource(source) {
57
+ const root = resolve(source);
58
+ if (!existsSync(root)) throw new Error(`${source} does not exist`);
59
+ const folder = statSync(root).isDirectory();
60
+ const names = folder ? readdirSync(root).filter((name) => name.endsWith('.md')).sort() : [basename(root)];
61
+ if (!names.length) throw new Error(`${source} holds no .md file`);
62
+ const base = folder ? root : dirname(root);
63
+ return {
64
+ root,
65
+ name: basename(root).replace(/\.md$/, ''),
66
+ parts: names.map((file) => {
67
+ const { meta, body, bodyLine } = frontMatter(readFileSync(join(base, file), 'utf8'));
68
+ return { file, id: slug(file.replace(/\.md$/, '')) || 'part', title: typeof meta.title === 'string' ? meta.title : null, body, bodyLine };
69
+ }),
70
+ };
71
+ }
72
+
73
+ // The findings that stop a build. Each one names a file and a line.
74
+ export function checkSource(source) {
75
+ const findings = [];
76
+ let figures = 0;
77
+ let walks = 0;
78
+ for (const part of source.parts) {
79
+ for (const fence of woodcutFences(part.body, part.bodyLine)) {
80
+ const at = { file: part.file, line: fence.line };
81
+ if (fence.open) { findings.push({ ...at, why: 'the woodcut block does not close' }); continue; }
82
+ if (!WOODCUT_TYPES[fence.type]) { findings.push({ ...at, why: `woodcut has no figure "${fence.type ?? ''}"; use ${Object.keys(WOODCUT_TYPES).join(', ')}` }); continue; }
83
+ let data;
84
+ try { data = JSON.parse(fence.lines.join('\n')); } catch (error) { findings.push({ ...at, why: `the woodcut data is not JSON: ${error.message}` }); continue; }
85
+ const fault = woodcutFault(fence.type, data);
86
+ if (fault) { findings.push({ ...at, why: fault }); continue; }
87
+ figures += 1;
88
+ if (woodcutWalks(data)) walks += 1;
89
+ }
90
+ }
91
+ const first = source.parts[0].file;
92
+ if (!figures && !findings.length) findings.push({ file: first, line: 1, why: 'the page has no woodcut figure. Explain the flow with a `woodcut wc-swimlane` (or another woodcut type) block, not prose alone' });
93
+ else if (!walks && !findings.length) findings.push({ file: first, line: 1, why: 'no figure walks the flow. Give one woodcut figure a scenario with two or more steps, each with a title' });
94
+ return { findings, figures, walks };
95
+ }
96
+
97
+ export function buildPage(path, { title = null } = {}) {
98
+ const source = readSource(path);
99
+ const { findings, figures, walks } = checkSource(source);
100
+ if (findings.length) return { html: null, findings, parts: source.parts.length, figures, walks };
101
+ const ids = new Set();
102
+ const byFile = new Map();
103
+ for (const part of source.parts) {
104
+ let id = part.id;
105
+ for (let n = 2; ids.has(id); n += 1) id = `${part.id}-${n}`;
106
+ ids.add(id);
107
+ part.id = id;
108
+ byFile.set(part.file, id);
109
+ }
110
+ const drawn = [];
111
+ const items = [];
112
+ const sections = source.parts.map((part) => {
113
+ const ctx = { file: part.file, page: part.id, figures: drawn, partOf: (href) => byFile.get(href.split('#')[0]) ?? null };
114
+ const out = render(part.body, ctx);
115
+ const heading = part.title ?? out.title ?? part.file.replace(/\.md$/, '');
116
+ items.push({ id: part.id, name: heading, ro: false });
117
+ return `<section class="part" data-part="${part.id}" id="part-${part.id}" hidden><h2>${esc(heading)}</h2>\n${out.html}\n</section>`;
118
+ });
119
+ const name = title ?? items[0].name;
120
+ const data = { groups: [{ id: 'pages', items }] };
121
+ const rail = `<div class="group"><button type="button" class="ghead" aria-expanded="true"><span class="chev">▾</span><span>Pages</span><span class="n">${items.length}</span></button>${items.map((item, index) => `<button type="button" class="item" data-go="${item.id}"><span class="n">${index + 1}</span><span>${esc(item.name)}</span></button>`).join('')}</div>`;
122
+ const mini = items.map((item, index) => `<button type="button" class="mini-item" data-go="${item.id}" title="${esc(item.name)}">${index + 1}</button>`).join('');
123
+ const html = `<!doctype html>
124
+ <html lang="en">
125
+ <head>
126
+ <meta charset="utf-8">
127
+ <meta name="viewport" content="width=device-width, initial-scale=1">
128
+ <meta name="generator" content="${GENERATOR}">
129
+ <title>${esc(name)}</title>
130
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,500;0,600;1,500&family=Poppins:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
131
+ <style>${CSS}</style>
132
+ <noscript><style>body{overflow:auto}.app{display:block;height:auto}.rail,.bar.foot,.pos{display:none}#scroll{overflow:visible}.part[hidden]{display:flex;margin-bottom:40px}</style></noscript>
133
+ </head>
134
+ <body>
135
+ <div class="app">
136
+ <aside class="rail" id="rail" aria-label="Pages">
137
+ <div class="full">
138
+ <div class="railhead"><div class="kv"><span class="lbl">Explainer</span><b>${esc(name)}</b></div><button type="button" class="sq" data-rail aria-label="Fold the side rail" title="Fold">«</button></div>
139
+ <div class="row"><button type="button" class="pill" id="open-all">Expand all</button><button type="button" class="pill" id="close-all">Collapse all</button></div>
140
+ ${rail}
141
+ </div>
142
+ <div class="strip"><button type="button" class="sq" data-rail aria-label="Open the side rail" title="Open" style="margin-bottom:10px">»</button>${mini}</div>
143
+ </aside>
144
+ <main>
145
+ <div class="bar top"><nav class="inner"><b style="font-weight:500">${esc(name)}</b><span class="tag">EXPLAINER</span><button type="button" class="pill" id="theme" aria-label="Switch light or dark">☾ Dark</button></nav></div>
146
+ <div id="scroll"><div class="inner">
147
+ ${name === items[0].name ? '' : `<header><h1>${esc(name)}</h1></header>\n`}<div class="pos" id="pos"></div>
148
+ ${sections.join('\n')}
149
+ </div></div>
150
+ <div class="bar foot"><div class="inner"><button type="button" class="nav" id="prev" aria-label="Previous page"></button><span class="mid"><span id="count"></span><span class="keys"> · <kbd>←</kbd> <kbd>→</kbd> to move</span></span><button type="button" class="nav" id="next" aria-label="Next page"></button></div></div>
151
+ </main>
152
+ </div>
153
+ <script type="application/json" id="design-data">${JSON.stringify(data).replace(/<\//g, '<\\/')}</script>
154
+ <script type="module" src="${WOODCUT}"></script>
155
+ <script>${SCRIPT}</script>
156
+ </body>
157
+ </html>
158
+ `;
159
+ return { html, findings: [], parts: items.length, figures, walks };
160
+ }
161
+
162
+ export function writePage(path, out, options = {}) {
163
+ const result = buildPage(path, options);
164
+ if (!result.html) return result;
165
+ mkdirSync(dirname(out), { recursive: true });
166
+ writeFileSync(out, result.html);
167
+ return result;
168
+ }
169
+
170
+ // ------------------------------------------------------------- the page rules
171
+
172
+ // One spelling for each colour: lower case, no spaces, #abc as #aabbcc.
173
+ export function normalColour(value) {
174
+ const text = String(value).toLowerCase().replace(/\s+/g, '');
175
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/.exec(text);
176
+ if (short) return `#${short.slice(1).filter(Boolean).map((one) => one + one).join('')}`;
177
+ return text;
178
+ }
179
+
180
+ const COLOUR = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/g;
181
+ // Named colours that a hand-made page uses most. A name is in no theme.
182
+ const NAMED = /(?<![\w-])(red|green|blue|black|white|gray|grey|orange|yellow|purple|pink|brown|navy|teal|silver|gold|maroon|olive|lime|aqua|cyan|magenta|crimson|coral|salmon|tomato|indigo|violet|beige|ivory|khaki|lavender|tan|wheat)(?![\w-])/gi;
183
+ // Black and white with an alpha only darken or lighten: a shadow, a scrim.
184
+ const NEUTRAL = /^rgba\((0,0,0|255,255,255),[\d.]+\)$/;
185
+
186
+ let palette = null;
187
+ // The colours of the Atlas themes: the design token set (design and
188
+ // explainer pages), the crew's page format, and the project view page.
189
+ export function themePalette() {
190
+ if (palette) return palette;
191
+ const set = new Set(Object.values(TOKENS).flat().map(normalColour));
192
+ for (const file of [join(PACKAGE_ROOT, 'agents', 'artifact-format', 'system.css'), join(PACKAGE_ROOT, 'view', 'page.html')]) {
193
+ if (!existsSync(file)) continue;
194
+ for (const match of readFileSync(file, 'utf8').matchAll(COLOUR)) set.add(normalColour(match[0]));
195
+ }
196
+ palette = set;
197
+ return set;
198
+ }
199
+
200
+ const lineAt = (text, offset) => text.slice(0, offset).split('\n').length;
201
+
202
+ // The places a page paints a colour: the values of its style blocks, its
203
+ // style attributes, and its colour attributes. Each comes with its offset.
204
+ function paintSpots(text) {
205
+ const spots = [];
206
+ for (const block of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
207
+ const start = block.index + block[0].indexOf(block[1]);
208
+ for (const value of block[1].matchAll(/:([^;{}]+)/g)) spots.push({ at: start + value.index + 1, text: value[1] });
209
+ }
210
+ for (const attr of text.matchAll(/\s(style|fill|stroke|color|bgcolor|stop-color|flood-color)\s*=\s*("[^"]*"|'[^']*')/gi)) {
211
+ spots.push({ at: attr.index + attr[0].indexOf(attr[2]) + 1, text: attr[2].slice(1, -1) });
212
+ }
213
+ return spots;
214
+ }
215
+
216
+ const generatorOf = (text) => /<meta\s+name=["']generator["']\s+content=["']([^"']*)["']/i.exec(text)?.[1] ?? null;
217
+ const DARK = /\[data-theme=["']?dark["']?\]|prefers-color-scheme:\s*dark/i;
218
+
219
+ export function pageFindings(text) {
220
+ const findings = [];
221
+ const generator = generatorOf(text);
222
+ const types = Object.keys(WOODCUT_TYPES).join('|');
223
+ const element = new RegExp(`<(${types})\\b`, 'i').exec(text);
224
+ const loader = /<script\b[^>]*\bsrc=["'][^"']*woodcut[^"']*["']/i.test(text);
225
+ if (!FIGURE_EXEMPT.includes(generator)) {
226
+ if (!element) findings.push({ line: 1, rule: 'page-figure', why: 'the page draws no woodcut figure. Build an explainer with atlas page build, and walk the flow in a woodcut figure' });
227
+ else if (!loader) findings.push({ line: lineAt(text, element.index), rule: 'page-figure', why: `the page has a <${element[1]}> figure but loads no woodcut script, so it draws nothing` });
228
+ }
229
+ const spots = paintSpots(text);
230
+ if (spots.length && !DARK.test(text)) findings.push({ line: 1, rule: 'page-theme', why: 'the page has no dark theme. Build it with atlas page build, which carries both themes' });
231
+ const known = themePalette();
232
+ const seen = new Set();
233
+ for (const spot of spots) {
234
+ for (const match of [...spot.text.matchAll(COLOUR), ...spot.text.matchAll(NAMED)]) {
235
+ const colour = normalColour(match[0]);
236
+ if (known.has(colour) || NEUTRAL.test(colour) || seen.has(colour)) continue;
237
+ seen.add(colour);
238
+ findings.push({ line: lineAt(text, spot.at + match.index), rule: 'page-theme', why: `the colour ${match[0]} is in no Atlas theme. Paint with a theme token, such as var(--accent)` });
239
+ }
240
+ }
241
+ return findings;
242
+ }
@@ -89,6 +89,11 @@ export const ACTS = Object.freeze({
89
89
  // S1). It names the fingerprint of the owner's words, not a decision id, so
90
90
  // one touch comes before the one message that the lead records.
91
91
  accept: { fields: ['repo', 'pr', 'head', 'run', 'words', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) ([1-9][0-9]{0,15}) (${HASH}) ([^\\s].{0,99})` },
92
+ // The acceptance of the red audit check of one merge that changed the
93
+ // audit code, made with `atlas approve audit-code` after the owner read
94
+ // that diff (item f36c6b0f). It names the merge commit and the fingerprint
95
+ // of its audit-code change, so the touch covers only the diff he read.
96
+ 'accept-code': { fields: ['repo', 'pr', 'head', 'merge', 'run', 'diff', 'words', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) (${SHA}) ([1-9][0-9]{0,15}) (${HASH}) (${HASH}) ([^\\s].{0,99})` },
92
97
  });
93
98
 
94
99
  // The fingerprint that a lock or a decision act names: sha256 of the text,
@@ -105,6 +110,40 @@ export const AUDIT_TEST = 'owner-unsigned';
105
110
  // tests, so a release never turns it into a warning (item 9348568f).
106
111
  export const AUDIT_CHECKS = Object.freeze(['gate · push audit', AUDIT_CHECK]);
107
112
 
113
+ // The files whose change lets a merge write its own audit: every repo file
114
+ // of the gate and audit groups of GATE_FILE_GROUPS in gate-audit.mjs (a test
115
+ // keeps this list a superset), the signed act that the gate reads, and the
116
+ // job file of the audit. `atlas approve audit` never signs a merge that
117
+ // changed one (security review 2 of item 0ca9fced, S3). Such a merge takes
118
+ // an accept-code act instead, and its words quote the test AUDIT_CODE_TEST
119
+ // (item f36c6b0f).
120
+ export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
121
+ export const AUDIT_CODE = Object.freeze([
122
+ AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml',
123
+ 'packages/atlas/bin/atlas', 'packages/atlas/door/cli.mjs', 'packages/atlas/owner-only.yaml', 'packages/atlas/door/jobs/gate-audit.yaml',
124
+ ...['gate.mjs', 'gate-audit.mjs', 'gate-check.mjs', 'owner-only.mjs', 'jobs.mjs', 'issues.mjs', 'signed-act.mjs'].map((name) => `packages/atlas/door/lib/${name}`),
125
+ 'packages/atlas/shape/check.mjs', 'packages/atlas/work/lib/child-env.mjs',
126
+ ]);
127
+ export const AUDIT_CODE_TEST = 'audit-code';
128
+ // GitHub lists at most 300 files of one commit.
129
+ const COMMIT_FILES = 300;
130
+
131
+ // The audit-code change of one merge commit, from GitHub's file list of that
132
+ // commit: { files, print }, where print is the fingerprint of each changed
133
+ // audit-code file with its state and its blob, or { files: [] } for none. A
134
+ // rename counts by its old name too. A list that is missing or may be cut
135
+ // gives { unknown: true }.
136
+ export function auditCodeDiff(files) {
137
+ if (!Array.isArray(files) || files.length >= COMMIT_FILES) return { unknown: true };
138
+ const hits = files.filter((one) => AUDIT_CODE.includes(String(one?.filename ?? '')) || AUDIT_CODE.includes(String(one?.previous_filename ?? '')));
139
+ if (!hits.length) return { files: [] };
140
+ const rows = hits.map((one) => `${one.previous_filename ? `${one.previous_filename}>` : ''}${one.filename} ${one.status} ${one.sha ?? '-'}`).sort();
141
+ // Each file by its name in AUDIT_CODE, never by a new name that a rename
142
+ // chose, so the words and the refusals hold only names of the list.
143
+ const named = hits.map((one) => (AUDIT_CODE.includes(String(one.filename)) ? String(one.filename) : String(one.previous_filename)));
144
+ return { files: [...new Set(named)].sort(), print: fingerprint(rows.join('\n')) };
145
+ }
146
+
108
147
  // The fingerprint that an accept act names: the owner's words with each run
109
148
  // of white space made one space, and no space at the ends. A chat can wrap a
110
149
  // line or drop a last line end; that does not change the words.
package/door/lib/ste.mjs CHANGED
@@ -9,11 +9,15 @@
9
9
  //
10
10
  // `--share` adds the privacy filter, with no exemption for quotes: a quoted
11
11
  // email address still leaves with the page.
12
+ //
13
+ // An HTML page also meets the page rules of page.mjs: it draws a woodcut
14
+ // figure, and it paints only with the colours of an Atlas theme.
12
15
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
13
16
  import { dirname, extname, join, relative } from 'node:path';
14
17
  import { fileURLToPath } from 'node:url';
15
18
  import { privacyFindings, loadPrivateTerms } from './privacy.mjs';
16
19
  import { fenceOpens, fenceCloses } from './markdown.mjs';
20
+ import { pageFindings } from './page.mjs';
17
21
 
18
22
  export const LIMITS = Object.freeze({ step: 20, sentence: 25, paragraph: 6 });
19
23
 
@@ -181,8 +185,9 @@ export function pagesUnder(path) {
181
185
  return out;
182
186
  }
183
187
 
184
- export function checkText(text, { html = false, share = false, terms = [] } = {}) {
188
+ export function checkText(text, { html = false, page = false, share = false, terms = [] } = {}) {
185
189
  const findings = measure(html ? htmlUnits(text) : markdownUnits(text));
190
+ if (page) findings.push(...pageFindings(text));
186
191
  if (share) findings.push(...privacyFindings(text, { terms }).map((finding) => ({ ...finding, rule: `privacy:${finding.rule}` })));
187
192
  return findings.sort((a, b) => a.line - b.line);
188
193
  }
@@ -193,7 +198,7 @@ export function checkPaths(paths, { share = false, terms = null, base = process.
193
198
  for (const path of paths) {
194
199
  for (const file of pagesUnder(path)) {
195
200
  const html = /\.html?$/i.test(file);
196
- const findings = checkText(readFileSync(file, 'utf8'), { html, share, terms: own });
201
+ const findings = checkText(readFileSync(file, 'utf8'), { html, page: html, share, terms: own });
197
202
  results.push({ file: relative(base, file) || file, findings });
198
203
  }
199
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.46",
3
+ "version": "0.4.48",
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",
@@ -33,9 +33,20 @@ Follow all eleven in every reply, page and question.
33
33
  9. Link every explainer page from the document it explains.
34
34
  10. Pick the form that is easiest to understand. Put a diagram before
35
35
  prose, and a page before long chat text. Where motion explains a
36
- sequence, animate the diagram in the page.
36
+ sequence, animate the diagram in the page. Explain each technical
37
+ flow on a page from `atlas page build <file or folder>`:
38
+ - A flow is how a thing works, the steps of a process, or where a
39
+ product stands.
40
+ - The page holds markdown and a woodcut figure that the owner steps
41
+ through, such as a `woodcut wc-swimlane` block.
42
+ - Never make an explainer page by hand. Never explain a flow in chat
43
+ text alone.
44
+ - The owner, 30 October 2026: "I would expect things to be explained
45
+ visually".
37
46
  11. Write in ASD-STE100. Run `atlas ste` on each page before you publish
38
- it, and fix what it flags by hand. Quoted words are exempt.
47
+ it, and fix what it flags by hand. Quoted words are exempt. On an
48
+ HTML page, it also flags no woodcut figure, no dark theme, and a
49
+ colour in no Atlas theme.
39
50
 
40
51
  Before a page or an issue goes to anyone other than the owner, run
41
52
  `atlas ste --share` on it. That adds the privacy filter. It reads the
@@ -55,7 +66,8 @@ say that when you hand over a page.
55
66
  | Where a capability stands in the code | The `atlas:capability-reader` crew |
56
67
  | Review a design or a diff | The three reviewer crews, as sdlc-task says |
57
68
  | Prove a pull request against its definition of done | The `atlas:verifier` crew |
58
- | Render a page for the owner | The `atlas:artifact-renderer` crew |
69
+ | Explain a technical flow or a status to the owner | The lead, with `atlas page build <file or folder>` (talk rule 10) |
70
+ | Render a design, a PRD or a review page for the owner | The `atlas:artifact-renderer` crew |
59
71
  | Run the fresh-agent test at the end of onboarding: pick, run and grade three tasks | The `atlas:fresh-agent-test` skill, which sends the `atlas:judge` crew |
60
72
  | Onboard an area to tests, review scenario words, write or convert a test, sort a red test, plant faults | The `atlas:tests` skill |
61
73
 
@@ -110,11 +122,12 @@ set. A test keeps the two lists equal.
110
122
  | `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
111
123
  | `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. `changed`, `before` and `after` run the test checks of the gate: changed tests, new cases fail on old code, old cases pass on new code. `sweep` and `issues` run the daily sweep |
112
124
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
125
+ | `atlas page` | `build <file or folder>` makes an explainer page, as talk rule 10 says. It refuses markdown with no woodcut figure that walks. Run `atlas ste` on the page, then publish it |
113
126
  | `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 |
114
127
  | `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` |
115
128
  | `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 |
116
129
  | `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 |
117
- | `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 |
130
+ | `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`, and `audit-code <pr>` per audit-code merge; record its words as one decision, then close each issue |
118
131
  | `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 |
119
132
 
120
133
  A person merges every file that `atlas tooling` writes.
package/view/page.html CHANGED
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="generator" content="atlas view build">
6
7
  <title>@@TITLE@@</title>
7
8
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&amp;family=IBM+Plex+Mono:wght@400;500&amp;family=Source+Serif+4:opsz,wght@8..60,600&amp;display=swap">
8
9
  <style>
@@ -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, wordsFingerprint, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, preKeyKeys } from '../../door/lib/signed-act.mjs';
19
+ import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct, wordsFingerprint, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, AUDIT_CODE_TEST, auditCodeDiff, preKeyKeys } 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']);
@@ -467,8 +467,22 @@ export function createDeliveryVerbs(core) {
467
467
  // act for the push audit red may count, against the keys at the tip of
468
468
  // the default branch (preKeyKeys in signed-act.mjs holds the limits). The
469
469
  // audit runs only on the default branch, so the merge must be into it.
470
+ // Item f36c6b0f: the red audit check of a merge that changed the audit
471
+ // code takes only an accept-code act, which names the merge and the
472
+ // fingerprint of its audit-code change. An accept act never takes such
473
+ // a merge: the batch of atlas approve audit does not show its diff.
474
+ const auditRed = check === AUDIT_CHECK && testName === AUDIT_TEST;
475
+ const codeRed = AUDIT_CHECKS.includes(check) && testName === AUDIT_CODE_TEST;
476
+ let code = null;
477
+ if (auditRed || codeRed) {
478
+ code = auditCodeDiff(commit?.files);
479
+ if (code.unknown) refuse(`GitHub gave no full file list of the merge commit of ${pull.url}, so no accept act can be checked`);
480
+ if (auditRed && code.files.length) refuse(`${pull.url} changed the audit code (${code.files.join(', ')}), so an accept act does not count; the owner reads that diff and signs with atlas approve audit-code ${pull.number}, and the test is "${AUDIT_CODE_TEST}"`);
481
+ if (codeRed && (!Array.isArray(commit?.parents) || commit.parents.length !== 2)) refuse(`${pull.url} did not merge with a merge commit, so its audit-code diff is not its whole change; an accept-code act does not count`);
482
+ if (codeRed && !code.files.length) refuse(`${pull.url} changed no audit code, so the test "${AUDIT_CODE_TEST}" does not apply; the owner signs with atlas approve audit`);
483
+ }
470
484
  let prekey = null;
471
- if (!keys.length && check === AUDIT_CHECK && testName === AUDIT_TEST) {
485
+ if (!keys.length && (auditRed || codeRed)) {
472
486
  const main = (await github(`repos/${pull.repo}`))?.default_branch;
473
487
  prekey = typeof main === 'string' && main === pull.base
474
488
  ? await preKeyKeys(githubOrNull, pull.repo, { parent, branch: main })
@@ -491,9 +505,11 @@ export function createDeliveryVerbs(core) {
491
505
  // cannot carry the touch to items the owner never named (review S2). The
492
506
  // words hold the time of the list, and a decision in any state counts,
493
507
  // so a replaced decision cannot hand the touch on (review 2, S3).
494
- if (!found && words.trim() && check === AUDIT_CHECK && testName === AUDIT_TEST) {
508
+ if (!found && words.trim() && (auditRed || codeRed)) {
495
509
  const print = wordsFingerprint(words);
496
- found = findAct(verified, { kind: 'accept', repo: pull.repo, pr: pull.number, head, words: print, check });
510
+ found = auditRed
511
+ ? findAct(verified, { kind: 'accept', repo: pull.repo, pr: pull.number, head, words: print, check })
512
+ : findAct(verified, { kind: 'accept-code', repo: pull.repo, pr: pull.number, head, merge: String(pull.merge_commit_sha).toLowerCase(), diff: code.print, words: print, check });
497
513
  if (found) {
498
514
  const same = decisions.filter((row) => typeof row.fields?.text === 'string' && wordsFingerprint(row.fields.text) === print);
499
515
  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`);
@@ -501,9 +517,9 @@ export function createDeliveryVerbs(core) {
501
517
  }
502
518
  if (!found) {
503
519
  const why = verified.find((row) => row.error)?.error;
504
- 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`);
520
+ 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 (atlas approve audit-code <pr> for a merge that changed the audit code), and the decision holds the words it printed`);
505
521
  }
506
- return { key: found.key, signed_at: found.signed_at, act: found.act.kind, ...(found.act.kind === 'accept' ? { run: String(found.act.run) } : {}), ...(prekey?.from ? { first_key: prekey.from } : {}) };
522
+ return { key: found.key, signed_at: found.signed_at, act: found.act.kind, ...(['accept', 'accept-code'].includes(found.act.kind) ? { run: String(found.act.run) } : {}), ...(prekey?.from ? { first_key: prekey.from } : {}) };
507
523
  }
508
524
 
509
525
  async function acceptedRed(value, pulls, item) {