@arjunkhera/atlas 0.4.61 → 0.4.62

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.61",
3
+ "version": "0.4.62",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -168,7 +168,14 @@ export function clearOf(root, base, trip, clears) {
168
168
  // and 3, which the gate reads from GitHub; with no GitHub read it is empty.
169
169
  // `clears` reads the owner's signed clears (line 6); a cleared trip holds
170
170
  // nothing, and the answer lists it under `cleared`.
171
- export function brakeState({ root, base, now = new Date(), ci = [], clears = null }) {
171
+ //
172
+ // Normal mode (`strict_mode` off, item bca1e8fb): a trip also ends at the first
173
+ // first-parent commit of the base, at or after the commit that set the trip,
174
+ // that has its check green. `green` is read from GitHub (see greenRead); with none read, no trip ends this way. A
175
+ // live trip ends only on a later commit, because its own commit ran the check
176
+ // that failed. The answer lists each ended trip under `ended`. Strict mode
177
+ // (the default here) keeps every trip for its days.
178
+ export function brakeState({ root, base, now = new Date(), ci = [], clears = null, strict = true, green = null }) {
172
179
  const sha = fullSha(root, base);
173
180
  if (!sha) throw new BrakeError(`the base "${base}" is not a commit in this repo.`);
174
181
  const text = git(root, ['show', `${sha}:atlas.yaml`], { allowFail: true });
@@ -187,9 +194,27 @@ export function brakeState({ root, base, now = new Date(), ci = [], clears = nul
187
194
  const bySha = new Map();
188
195
  for (const trip of all) if (!bySha.has(trip.sha)) bySha.set(trip.sha, clearOf(root, sha, trip, clears));
189
196
  for (const found of bySha.values()) if (found) cleared.push(found);
190
- const trips = all.filter((trip) => !bySha.get(trip.sha));
197
+ let trips = all.filter((trip) => !bySha.get(trip.sha));
198
+ const ended = [];
199
+ if (!strict && green && trips.length) {
200
+ // `green` maps a commit of main to { ok, checks }: ok when at least one
201
+ // required check ran there and each one that ran is green, and checks as
202
+ // the names of the green checks. A plain set of commits means ok only.
203
+ const entry = (sha) => (green instanceof Map ? green.get(sha) : green.has?.(sha) ? { ok: true, checks: new Set() } : null);
204
+ trips = trips.filter((trip) => {
205
+ const at = commits.findIndex((commit) => commit.sha === trip.sha);
206
+ if (at < 0) return true;
207
+ // A trip set by a red check ends only where that check ran and is green.
208
+ // A live trip needs a later commit, because its own commit ran the check that failed.
209
+ const own = trip.check !== undefined && trip.check !== null;
210
+ const found = commits.slice(0, trip.cause === 'live' ? at : at + 1).find((commit) => { const one = entry(commit.sha); return own ? Boolean(one?.checks?.has(trip.check)) : Boolean(one?.ok); });
211
+ if (!found) return true;
212
+ ended.push({ sha: trip.sha, green: found.sha });
213
+ return false;
214
+ });
215
+ }
191
216
  const until = trips.length ? new Date(Date.parse(trips[0].time) + facts.days * 864e5).toISOString() : null;
192
- return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips, cleared };
217
+ return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips, cleared, ended };
193
218
  }
194
219
 
195
220
  // The patch id of a diff, or null for an empty diff. --verbatim keeps every
@@ -44,6 +44,7 @@ import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
44
44
  import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
45
45
  import { brakeState, undoOf, tripText } from './brake.mjs';
46
46
  import { kindsAt, nameKinds, kindRows } from './kinds.mjs';
47
+ import { strictAt, reviewHeads, insider } from './strict.mjs';
47
48
  import { windowOn, protectedSet, clearable, windowActs, openAt, holdsCommit, WINDOW_KEY } from './window.mjs';
48
49
 
49
50
  export const VERSION = 1;
@@ -51,7 +52,7 @@ export const GATE_CHECK = 'gate';
51
52
  export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
52
53
  export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
53
54
  export const RESULTS = Object.freeze(['success', 'owner', 'red']);
54
- export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed', 'kinds']);
55
+ export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed', 'kinds', 'mode', 'review', 'author']);
55
56
  export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
56
57
  // A file over this size is not scanned for secrets, and comes to the owner.
57
58
  export const SCAN_LIMIT = 1024 * 1024;
@@ -274,12 +275,12 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
274
275
  // request, read for a signed act, and baseRef is the name of its base branch.
275
276
  // `terms` is the list of private terms, or undefined to skip that rule (the
276
277
  // replay, which has no secret).
277
- export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined }) {
278
+ export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined, marks = null, author = null, fixes = true }) {
278
279
  const top = resolve(root);
279
280
  const baseSha = commitOf(top, base);
280
281
  if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
281
282
  const headSha = commitOf(top, head);
282
- const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null, kinds: null };
283
+ const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null, kinds: null, mode: 'normal', review: null, author: 'outsider' };
283
284
  if (!headSha) {
284
285
  answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
285
286
  return finish(answer);
@@ -295,29 +296,89 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
295
296
  const list = kindsAt(top, baseSha);
296
297
  const named = nameKinds(list, changes.map((change) => change.path));
297
298
  answer.kinds = { listed: list.listed, names: named.names, unlisted: list.listed ? named.unlisted : [] };
298
- let undo = null;
299
- if (brake !== false) {
300
- const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [], clears: brake?.clears ?? null });
301
- undo = undoOf(top, mergeBase, headSha, state.trips);
302
- answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null, cleared: state.cleared };
303
- for (const one of state.cleared) answer.rows.push({ kind: 'note', class: 'brake', cause: `the owner's signed clear ended the trip set by ${one.sha.slice(0, 7)} (key ${one.key}, ${one.signed_at})` });
304
- answer.rows.push(...brakeRows(state, undo, list.listed ? named : null));
305
- }
306
299
  // The rules come from the base commit, never from the merge base. An agent
307
300
  // picks the merge base when it picks where its branch starts, also a middle
308
301
  // commit of a merged pull request that weakened the list and then took the
309
302
  // change back (item d56a9c03). The diff stays merge base to head.
310
303
  const ruleRef = rules ?? baseSha;
304
+ // The mode comes from the same ref as the rules: the base, never the head.
305
+ const strict = strictAt(top, ruleRef);
306
+ answer.mode = strict ? 'strict' : 'normal';
307
+ // Normal mode is lenient (review mark, atlas-fix) only for a pull request
308
+ // that an insider opened: author association OWNER, MEMBER or COLLABORATOR.
309
+ // With no author read, the author is an outsider, and the controls of strict
310
+ // mode hold: owner rows stay owner, and the signed act path still works.
311
+ const lenient = !strict && insider(author);
312
+ answer.author = insider(author) ? 'insider' : 'outsider';
313
+ let undo = null;
314
+ if (brake !== false) {
315
+ const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [], clears: brake?.clears ?? null, strict, green: brake?.green ?? null });
316
+ undo = undoOf(top, mergeBase, headSha, state.trips);
317
+ const fixed = lenient && fixes ? fixOf(body, state.trips) : [];
318
+ answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null, cleared: state.cleared, ended: state.ended ?? [], fix: fixed[0]?.culprit ?? null };
319
+ for (const one of state.cleared) answer.rows.push({ kind: 'note', class: 'brake', cause: `the owner's signed clear ended the trip set by ${one.sha.slice(0, 7)} (key ${one.key}, ${one.signed_at})` });
320
+ for (const one of state.ended ?? []) answer.rows.push({ kind: 'note', class: 'brake', cause: `the trip set by ${one.sha.slice(0, 7)} ended: ${one.green.slice(0, 7)} on main has every required check green` });
321
+ answer.rows.push(...brakeRows(state, undo, list.listed ? named : null, { lenient, fixed }));
322
+ }
311
323
  for (const row of classify({ root: top, base: mergeBase, head: headSha, rules: ruleRef }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
312
324
  answer.rows.push(...kindRows(list, named));
313
325
  answer.rows.push(...jobRows(top, ruleRef, headSha, changes));
314
326
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
315
327
  if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
316
328
  answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules: ruleRef, undo }));
317
- if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef, now);
329
+ if (lenient) reviewRows(top, baseSha, headSha, marks, answer);
330
+ else if (signed) signedRows(top, baseSha, headSha, signed, answer, ruleRef, now);
318
331
  return finish(answer);
319
332
  }
320
333
 
334
+ // Normal mode (item bca1e8fb): no owner row stays. A head job named like a
335
+ // check and a brake hold are red. Each other owner row is red with class
336
+ // `review` until a second agent posts a review mark for this head. The mark
337
+ // counts from an author with the association OWNER, MEMBER or COLLABORATOR.
338
+ // It carries over a clean merge of the base, by the rule of the signed act. The
339
+ // mark proves that a review ran. It does not stop a session that forges it.
340
+ function reviewRows(root, baseSha, headSha, marks, answer) {
341
+ for (const row of answer.rows) if (row.kind === 'owner' && (row.class === 'gate-checks' || row.class === 'brake' || row.class === 'too-large')) row.kind = 'red';
342
+ const owner = answer.rows.filter((row) => row.kind === 'owner');
343
+ if (!owner.length) return;
344
+ const note = (cause) => answer.rows.push({ kind: 'note', class: 'review', cause });
345
+ const turn = (why) => { for (const row of owner) { row.cause = `${why}: ${row.class}: ${row.cause}`; row.class = 'review'; row.kind = 'red'; } };
346
+ let list = marks;
347
+ if (typeof marks === 'function') { try { list = marks(); } catch { list = null; } }
348
+ const heads = reviewHeads(list);
349
+ if (heads.includes(headSha)) {
350
+ for (const row of owner) row.kind = 'signed';
351
+ answer.review = { head: headSha };
352
+ note(`a review mark names this head ${headSha.slice(0, 7)}`);
353
+ return;
354
+ }
355
+ for (const old of heads.slice(0, CARRY_TRIES)) {
356
+ if (commitOf(root, old) !== old || !contains(root, old, headSha)) continue;
357
+ const base = (git(root, [MERGE_BASE, '--end-of-options', baseSha, old], { allowFail: true }) ?? '').trim();
358
+ if (!base) continue;
359
+ const result = carryOne(root, baseSha, headSha, answer.merge_base, { head: old, merge_base: base }, null);
360
+ if (!result.ok) continue;
361
+ for (const row of owner) row.kind = 'signed';
362
+ answer.review = { head: headSha, carried_from: old };
363
+ note(`a review mark names head ${old.slice(0, 7)}, and this head only adds ${result.merges} clean merge(s) of the base`);
364
+ return;
365
+ }
366
+ turn(heads.length ? 'the review mark names another head, so a new review is needed' : 'no review mark for this head, so a second agent must review it');
367
+ }
368
+
369
+ // Normal mode: a pull request whose text says `atlas-fix: <sha>` names the
370
+ // trips of the brake at that commit: at least 7 hex, a prefix of the sha or
371
+ // the culprit of the trip. The brake lets the pull request pass for those
372
+ // trips only. Other trips still hold. Returns the trips that it names.
373
+ export function fixOf(body, trips) {
374
+ const text = String(body ?? '').replace(/<!--[\s\S]*?-->/g, '');
375
+ const named = new Set();
376
+ for (const hit of text.matchAll(/^atlas-fix:[ \t]*([0-9a-f]{7,40})[ \t]*$/gim)) {
377
+ for (const trip of trips) if ([trip.sha, trip.culprit].some((sha) => sha && sha.startsWith(hit[1].toLowerCase()))) named.add(trip);
378
+ }
379
+ return [...named];
380
+ }
381
+
321
382
  // Done line 4 of R72: a valid signed act for this head clears each owner row.
322
383
  // It never clears red: the pull request must be fixed first. The key file is
323
384
  // read from the base commit, so a key that the pull request adds does not count.
@@ -568,9 +629,11 @@ function offPages(root, a, b, built, pages, checks) {
568
629
  // holds as before.
569
630
  const holds = (trip, named) => !named || !trip.kinds || named.unlisted.length > 0 || trip.kinds.some((kind) => named.names.includes(kind));
570
631
 
571
- function brakeRows(state, undo, named = null) {
632
+ function brakeRows(state, undo, named = null, { lenient = false, fixed = [] } = {}) {
572
633
  if (!state.on) return [];
573
- const holding = state.trips.filter((trip) => holds(trip, named));
634
+ const holding = state.trips.filter((trip) => holds(trip, named) && !fixed.includes(trip));
635
+ const fixNote = fixed.length ? [{ kind: 'note', class: 'brake', cause: `the fix of the trip set by ${fixed[0].sha.slice(0, 7)} (atlas-fix in the text): ${tripText(fixed[0])}` }] : [];
636
+ if (lenient && !undo && fixed.length && !holding.length) return fixNote;
574
637
  if (!undo && !holding.length) {
575
638
  const kinds = [...new Set(state.trips.flatMap((trip) => trip.kinds))].join(', ');
576
639
  return [{ kind: 'note', class: 'brake', cause: `the brake is on until ${state.until.slice(0, 16).replace('T', ' ')} UTC for ${kinds}; this pull request is ${named.names.join(', ') || 'no kind'}` }];
@@ -579,6 +642,7 @@ function brakeRows(state, undo, named = null) {
579
642
  const more = holding.length > 1 ? `; ${holding.length} trips in ${state.days} days` : '';
580
643
  if (undo) return [{ kind: 'note', class: 'brake', cause: `the exact undo of ${undo.culprit.slice(0, 7)}, which tripped the brake: ${tripText(undo)}` }];
581
644
  const until = new Date(Date.parse(newest.time) + state.days * 864e5).toISOString();
645
+ if (lenient) return [...fixNote, { kind: 'red', class: 'brake', cause: `the brake is on: ${tripText(newest)}${more}. It ends at the first commit on main after it where its check is green. The exact undo, or a pull request whose text says atlas-fix: ${newest.sha.slice(0, 7)}, may merge` }];
582
646
  const owner = { kind: 'owner', class: 'brake', cause: `the brake is on until ${until.slice(0, 16).replace('T', ' ')} UTC: ${tripText(newest)}${more}` };
583
647
  // Done line 6: one signed clear for each trip, as a comment on its commit.
584
648
  const clear = (holding.length ? holding : [newest]).map((trip) => ({ kind: 'note', class: 'brake', cause: `to end the trip now: atlas approve act brake-clear --repo <owner/repo> --commit ${trip.sha}, then post the block as a comment on commit ${trip.sha.slice(0, 7)} and run the atlas-brake-clear workflow` }));
@@ -610,6 +674,8 @@ export function renderText(answer) {
610
674
  }
611
675
  if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
612
676
  else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
677
+ else if (answer.review?.carried_from) out.push(`SUCCESS: a review mark names head ${oneLine(answer.review.carried_from).slice(0, 7)}, and this head only merges in the base.`);
678
+ else if (answer.review) out.push('SUCCESS: a second agent reviewed this head.');
613
679
  else if (answer.signed?.carried_from) out.push(`SUCCESS: the owner signed head ${oneLine(answer.signed.carried_from).slice(0, 7)}, and this head only merges in the base.`);
614
680
  else if (answer.signed?.window) out.push(`SUCCESS: the owner's signing window covers this pull request until ${oneLine(answer.signed.window.ends)}.`);
615
681
  else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
@@ -623,7 +689,7 @@ export function renderText(answer) {
623
689
  // job runs with --repo. `comments` gives the comment texts of the pull request
624
690
  // when it also runs with --pr, so a signed act can clear owner. Exit 0 on
625
691
  // success, 1 on owner or red, 2 on a usage error.
626
- export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output, repo = null, pr = null, baseRef = null }, line, read = null, comments = null) {
692
+ export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output, repo = null, pr = null, baseRef = null }, line, read = null, comments = null, reviews = null, authorOf = null) {
627
693
  try {
628
694
  if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
629
695
  if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
@@ -638,8 +704,13 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
638
704
  try { texts = comments(); } catch { texts = null; }
639
705
  signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
640
706
  }
707
+
708
+ // A function, so the gate reads them only in normal mode when a row needs them.
709
+ const marks = reviews && repo && pr ? reviews : null;
641
710
  const { terms } = termsFromEnv();
642
- const answer = hideTerms(judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed, terms }), terms);
711
+ let author = null;
712
+ if (authorOf && repo && pr) { try { author = authorOf(); } catch { author = null; } }
713
+ const answer = hideTerms(judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed, terms, marks, author }), terms);
643
714
  const text = renderText(answer);
644
715
  if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
645
716
  else for (const each of text) line(each);
package/door/lib/gate.mjs CHANGED
@@ -25,6 +25,7 @@ import { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit
25
25
  import { MARKER as SIGNED_MARKER, SIGNERS_FILE, AUDIT_CHECK, parseSigners, verifyTexts, findAct, auditCodeDiff } from './signed-act.mjs';
26
26
  import { WINDOW_LABEL } from './window.mjs';
27
27
  import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
28
+ import { strictAt, insider, REVIEW_MARKER } from './strict.mjs';
28
29
 
29
30
  export const BRANCH_RULESET = 'atlas gate';
30
31
  export const TAG_RULESET = 'atlas release tags';
@@ -608,7 +609,7 @@ export function ownerUnsigned({ root, repo, pull, parent }, gh) {
608
609
  if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
609
610
  let texts = null;
610
611
  try { texts = [...prComments(gh, repo, pull.number), ...windowComments(gh, repo)]; } catch { texts = null; }
611
- const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
612
+ const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), author: pull.author_association ?? null, fixes: false, signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
612
613
  return answer.result === 'owner' ? 'owner' : null;
613
614
  }
614
615
 
@@ -714,6 +715,30 @@ export function prComments(gh, repo, pr) {
714
715
  return kept;
715
716
  }
716
717
 
718
+ // The review marks of one pull request (item bca1e8fb): each comment that
719
+ // holds the review marker, as { body, association }. GitHub states the
720
+ // association of the author itself. At most ten pages of a hundred, newest
721
+ // first, and at most 2 MB of text in all. The gate reads them as data only.
722
+ // Only a comment of an insider (OWNER, MEMBER or COLLABORATOR) is kept, and it
723
+ // is kept before the size cap, so an outsider cannot push a mark out. Each
724
+ // mark keeps its created_at, so the release hold can drop a late mark.
725
+ export function reviewComments(gh, repo, pr) {
726
+ const marks = [];
727
+ for (let page = 1; page <= 10; page += 1) {
728
+ const rows = gh('GET', `repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`) ?? [];
729
+ for (const row of Array.isArray(rows) ? rows : []) if (typeof row?.body === 'string' && row.body.includes(REVIEW_MARKER) && insider(row.author_association)) marks.push({ body: row.body, association: String(row.author_association), created_at: String(row.created_at ?? '') });
730
+ if (!Array.isArray(rows) || rows.length < 100) break;
731
+ }
732
+ const kept = [];
733
+ let bytes = 0;
734
+ for (const mark of marks.reverse()) {
735
+ bytes += mark.body.length;
736
+ if (bytes > 2 * 1024 * 1024) break;
737
+ kept.push(mark);
738
+ }
739
+ return kept;
740
+ }
741
+
717
742
  // The comment texts of the window issues that carry the signed-act marker:
718
743
  // where `atlas approve window` posts each window act (item 4af66381). The
719
744
  // newest five issues with the label, open or closed, and three pages of
@@ -804,9 +829,13 @@ export function holdRow({ repo, root, sha }, gh, git) {
804
829
  let answer;
805
830
  try {
806
831
  const at = new Date(pull.merged_at);
807
- const brake = { ci: brakeRead({ root, base: parent, now: at }, gh, repo), clears: { repo, commentsOf: (one) => commitComments(gh, repo, one) } };
832
+ const brake = brakeInput({ root, base: parent, now: at }, gh, repo);
833
+ // In normal mode the hold reads the review marks too: only a red row holds the release.
834
+ // A mark counts only when it came before the merge. The hold reads no atlas-fix line.
835
+ let marks = null;
836
+ if (!strictAt(root, parent)) { try { marks = reviewComments(gh, repo, pull.number).filter((mark) => mark.created_at && Date.parse(mark.created_at) <= at.getTime()); } catch { marks = null; } }
808
837
  // The red main checks of its parent, as the gate read them, so a fix of a red main check is judged as the gate judged it.
809
- answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: at, brake, redMain: redMain({ root, base: parent }, gh, repo), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
838
+ answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: at, brake, redMain: redMain({ root, base: parent }, gh, repo), marks, author: pull.author_association ?? null, fixes: false, signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
810
839
  } catch (error) { return `#${pull.number}: it cannot be judged (${String(error?.message ?? error).split('\n')[0].slice(0, 160)})`; }
811
840
  if (answer.result === 'success') return null;
812
841
  // The gate did not say success. An accept act, or an accept-code act for a
@@ -883,6 +912,43 @@ export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3)
883
912
  return ciTrips({ commits, runs, homes, checks, live: facts.live, liveChecks }, jobsOf);
884
913
  }
885
914
 
915
+ // Normal mode (item bca1e8fb): the first-parent commits of the base, in the
916
+ // brake window, that have every required check green. The brake ends a trip
917
+ // at the first of them, at or after the trip. A commit whose checks cannot be
918
+ // read counts as not green. The `gate` check is left out, as in brakeRead.
919
+ // Some required checks run only on a pull request (fails-before, the upkeep
920
+ // check), so a commit of main never has them. The answer maps each commit to
921
+ // { ok, checks }: ok when at least one required check ran on it and each one
922
+ // that ran is green, and checks as the names of the green checks. The live
923
+ // checks of the brake (`live: deploy`) are read too, for a live trip.
924
+ export function greenRead({ root, base, now = new Date() }, gh, repo) {
925
+ const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
926
+ const sha = git(['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`]);
927
+ let facts;
928
+ try { facts = brakeFacts(git(['show', `${sha}:atlas.yaml`])); } catch { return new Map(); }
929
+ const checks = facts.checks.filter((name) => name !== GATE_CHECK);
930
+ if (!checks.length) return new Map();
931
+ const live = facts.live === 'deploy' ? facts.liveChecks.filter((name) => !checks.includes(name)) : [];
932
+ const green = new Map();
933
+ for (const commit of windowCommits(root, sha, new Date(now.getTime() - facts.days * 864e5))) {
934
+ try {
935
+ const runs = checkRuns(gh, repo, commit.sha);
936
+ const ran = checkStates(checks, runs).filter((row) => row.state !== 'missing');
937
+ const states = checkStates([...checks, ...live], runs);
938
+ green.set(commit.sha, { ok: ran.length > 0 && ran.every((row) => row.state === 'green'), checks: new Set(states.filter((row) => row.state === 'green').map((row) => row.name)) });
939
+ } catch { /* not green */ }
940
+ }
941
+ return green;
942
+ }
943
+
944
+ // What the brake reads from GitHub for one judgment: the trips, the signed
945
+ // clears, and in normal mode the green commits of main.
946
+ export function brakeInput({ root, base, now }, gh, repo) {
947
+ const input = { ci: brakeRead({ root, base, now }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } };
948
+ if (!strictAt(root, base)) input.green = greenRead({ root, base, now }, gh, repo);
949
+ return input;
950
+ }
951
+
886
952
  // `atlas gate apply|ready|audit|check|replay|hold`. Returns the exit code.
887
953
  export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, from, base, baseRef, head, body, json = false, summary, output, days, branch, errors = false, forced = false, findings, home = homedir() }, line, gh = ghApi) {
888
954
  if (!['apply', 'ready', 'audit', 'check', 'replay', 'hold'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check, replay or hold.`);
@@ -890,7 +956,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
890
956
  const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
891
957
  if (what === 'check') {
892
958
  const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
893
- return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } } }) : null, named && number ? () => [...prComments(gh, repo, number), ...windowComments(gh, repo)] : null);
959
+ return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: brakeInput({ root, base }, gh, repo) }) : null, named && number ? () => [...prComments(gh, repo, number), ...windowComments(gh, repo)] : null, named && number ? () => reviewComments(gh, repo, number) : null, named && number ? () => gh('GET', `repos/${repo}/pulls/${number}`)?.author_association ?? null : null);
894
960
  }
895
961
  if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
896
962
  if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
@@ -38,6 +38,7 @@ import { jobMarker, typedFindings, JOBS_LABEL, atlasJobs } from './jobs.mjs';
38
38
  import { repoOk, TOKEN_SHAPE, DEFAULT_BOT, api, apiBase } from './issues.mjs';
39
39
  import { ghApi } from './gate.mjs';
40
40
  import { PACKAGE_NAME } from './gate-audit.mjs';
41
+ import { strictMode } from './strict.mjs';
41
42
 
42
43
  const usage = (message) => Object.assign(new Error(message), { usage: true });
43
44
  const DAY = 24 * 60 * 60 * 1000;
@@ -47,6 +48,9 @@ const HOUR = 60 * 60 * 1000;
47
48
  // request that the gate sends to him.
48
49
  export const RUNNER_REPOS = Object.freeze(['Arjunkhera/atlas-scratch']);
49
50
  export const RELEASE_DAYS = 2;
51
+ // Normal mode (item bca1e8fb): the repo's atlas.yaml has no `strict_mode: on`,
52
+ // so the runner waits no days for a release.
53
+ export const NORMAL_RELEASE_DAYS = 0;
50
54
  export const MAX_OPEN = 5;
51
55
  export const MAX_WAITING = 3;
52
56
  export const CLAIM_HOURS = 6;
@@ -65,13 +69,13 @@ export function repoAllowed(repo, allowed = RUNNER_REPOS) {
65
69
 
66
70
  // The newest version that is RELEASE_DAYS old, from the npm document of the
67
71
  // package. `newest` is npm latest; `use` is what the runner may load.
68
- export function releaseToUse(doc, now = Date.now()) {
72
+ export function releaseToUse(doc, now = Date.now(), days = RELEASE_DAYS) {
69
73
  const times = doc?.time ?? {};
70
74
  const newest = doc?.['dist-tags']?.latest ?? null;
71
75
  const versions = Object.keys(doc?.versions ?? {})
72
76
  .filter((version) => Number.isFinite(Date.parse(times[version])))
73
77
  .sort((a, b) => Date.parse(times[b]) - Date.parse(times[a]));
74
- const use = versions.find((version) => now - Date.parse(times[version]) >= RELEASE_DAYS * DAY) ?? null;
78
+ const use = versions.find((version) => now - Date.parse(times[version]) >= days * DAY) ?? null;
75
79
  return { newest, use, newest_age_days: newest && times[newest] ? Math.floor((now - Date.parse(times[newest])) / DAY) : null };
76
80
  }
77
81
 
@@ -190,6 +194,16 @@ export function capsOf({ gh, repo }) {
190
194
 
191
195
  // ------------------------------------------------------------ start
192
196
 
197
+ // True unless atlas.yaml on the branch is readable and does not say
198
+ // `strict_mode: on`.
199
+ export function strictRepo({ gh, repo, branch }) {
200
+ try {
201
+ const file = gh('GET', `repos/${repo}/contents/atlas.yaml?ref=${encodeURIComponent(branch)}`);
202
+ if (typeof file?.content !== 'string') return true;
203
+ return strictMode(Buffer.from(file.content, 'base64').toString('utf8'));
204
+ } catch { return true; }
205
+ }
206
+
193
207
  export async function runnerStart({ repo, gh = ghApi, env = process.env, now = Date.now(), fetchImpl = fetch, allowed = RUNNER_REPOS, jobs = atlasJobs() }) {
194
208
  const stop = (why, extra = {}) => ({ repo, go: false, stop: why, ...extra });
195
209
  if (!repoAllowed(repo, allowed)) return stop(`${repo} is not a runner repo. Until the owner records the A2 exit test, the runner works on ${allowed.join(', ')} only (R25 line 13)`);
@@ -203,8 +217,11 @@ export async function runnerStart({ repo, gh = ghApi, env = process.env, now = D
203
217
  const audit = auditOnTip({ gh, repo, branch: main });
204
218
  if (!audit.ok) return stop(`${audit.why} (R25 line 11)`, { audit });
205
219
  let release;
206
- try { release = releaseToUse(await npmDoc(fetchImpl), now); } catch (error) { return stop(`npm could not be read: ${error.message}`); }
207
- if (!release.use) return stop(`no release of ${PACKAGE_NAME} is ${RELEASE_DAYS} days old yet`, { release });
220
+ // The wait comes from the target repo's atlas.yaml on its main branch. A
221
+ // file that GitHub cannot give counts as strict: the longer wait.
222
+ const days = strictRepo({ gh, repo, branch: main }) ? RELEASE_DAYS : NORMAL_RELEASE_DAYS;
223
+ try { release = releaseToUse(await npmDoc(fetchImpl), now, days); } catch (error) { return stop(`npm could not be read: ${error.message}`); }
224
+ if (!release.use) return stop(`no release of ${PACKAGE_NAME} is ${days} days old yet`, { release });
208
225
  const { inputs, outside, held } = readInputs({ gh, repo, branch: main, now, jobs });
209
226
  const caps = capsOf({ gh, repo });
210
227
  return { repo, go: true, stop: null, branch: main, release, audit, caps, inputs, outside, held };
@@ -0,0 +1,67 @@
1
+ // Strict mode (item bca1e8fb; design docs/design-docs/pipeline/controls-off.md).
2
+ // The owner controls are off by default. A repo turns them on with one key in
3
+ // atlas.yaml: `strict_mode: on`. With no key, or with `off` or `false`, the
4
+ // repo is in normal mode. Any other value, or a file that does not read, is
5
+ // strict mode. The gate reads the key from the base commit, never from the
6
+ // head. The key is on the fixed list of the owner-only floor, so only the
7
+ // owner turns the mode off in a strict repo.
8
+ //
9
+ // strictMode(text) true unless the key is missing, off or false
10
+ // strictAt(root, ref) the same, from the file at one commit
11
+ // insider(association) true for OWNER, MEMBER or COLLABORATOR
12
+ // reviewHeads(marks) the heads that a valid review mark names
13
+ //
14
+ // Do not confuse this key with the `strict` option of parseYaml in
15
+ // shape/check.mjs. That option makes the reader refuse odd YAML.
16
+ import { spawnSync } from 'node:child_process';
17
+ import { parseYaml } from '../../shape/check.mjs';
18
+
19
+ export const STRICT_KEY = 'strict_mode';
20
+ // The marker line of a review mark, and the line that names the reviewed head.
21
+ export const REVIEW_MARKER = '<!-- atlas:review -->';
22
+ // Only these authors count. GitHub states the association itself; a session
23
+ // cannot set it on a comment.
24
+ export const REVIEW_AUTHORS = Object.freeze(['OWNER', 'MEMBER', 'COLLABORATOR']);
25
+
26
+ // Fail closed. A missing key, `off` or `false` gives normal mode. Any other
27
+ // value (on, ON, yes, true, odd text) gives strict mode. A text that does not
28
+ // read gives strict mode.
29
+ export function strictMode(text) {
30
+ if (typeof text !== 'string') return true;
31
+ let facts;
32
+ try { facts = parseYaml(text) ?? {}; } catch { return true; }
33
+ if (typeof facts !== 'object' || Array.isArray(facts)) return true;
34
+ if (!(STRICT_KEY in facts)) return false;
35
+ const value = facts[STRICT_KEY];
36
+ return !(value === false || value === 'off');
37
+ }
38
+
39
+ // The mode at one commit. A commit with no atlas.yaml is normal mode. A
40
+ // commit that git cannot read is strict mode.
41
+ export function strictAt(root, ref) {
42
+ const r = spawnSync('git', ['show', `${ref}:atlas.yaml`], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
43
+ if (r.status === 0) return strictMode(r.stdout);
44
+ const has = spawnSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd: root });
45
+ return has.status !== 0 || r.error !== undefined;
46
+ }
47
+
48
+ // True for an author association that counts as an insider.
49
+ export const insider = (association) => REVIEW_AUTHORS.includes(String(association));
50
+
51
+ // The marks that count: each is { body, association }. A mark needs the marker
52
+ // line, and the next non-empty line is the pass line with the 40 hex of a head.
53
+ // A comment with more than one pass line counts for nothing. The author needs
54
+ // the association OWNER, MEMBER or COLLABORATOR. Newest first when the input is.
55
+ export function reviewHeads(marks) {
56
+ const heads = [];
57
+ for (const mark of Array.isArray(marks) ? marks : []) {
58
+ if (!mark || typeof mark.body !== 'string' || !insider(mark.association)) continue;
59
+ const lines = mark.body.split('\n').map((line) => line.trim());
60
+ const at = lines.indexOf(REVIEW_MARKER);
61
+ if (at < 0 || lines.filter((line) => /^atlas-review:\s*pass\b/.test(line)).length !== 1) continue;
62
+ const next = lines.slice(at + 1).find(Boolean) ?? '';
63
+ const hit = /^atlas-review: pass ([0-9a-f]{40})$/.exec(next);
64
+ if (hit && !heads.includes(hit[1])) heads.push(hit[1]);
65
+ }
66
+ return heads;
67
+ }
package/owner-only.yaml CHANGED
@@ -246,3 +246,4 @@ atlas_yaml:
246
246
  - verbatim
247
247
  - private_terms
248
248
  - signing_window
249
+ - strict_mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.61",
3
+ "version": "0.4.62",
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",
@@ -127,10 +127,19 @@ set. A test keeps the two lists equal.
127
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` |
128
128
  | `atlas runner` | Only in a runner run, as the `atlas:runner` skill says: `start` first, `claim` before each build, `line` last. A lead session never runs the runner |
129
129
  | `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 |
130
- | `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, `hold` only in the release job. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
131
- | `atlas approve` | Only the owner runs it, never a session, 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. Where `signing_window: on`, ask him each evening for `window`: one touch sees the list of merges and opens a window of up to 12 hours |
130
+ | `atlas gate` | `apply` once per repo, only where `strict_mode: on`, 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, `hold` only in the release job. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
131
+ | `atlas approve` | Only the owner runs it, never a session, and only where `strict_mode: on`, 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. Where `signing_window: on`, ask him each evening for `window`: one touch sees the list of merges and opens a window of up to 12 hours |
132
132
  | `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 |
133
133
 
134
+ **Strict mode** (item bca1e8fb, 14 November 2026): `atlas.yaml` holds
135
+ `strict_mode: on` or `off`, and no key means off. The gate reads the key from
136
+ the base commit. In normal mode a pull request merges on green. A change to
137
+ the rules of Atlas needs a review mark: a second agent posts a comment with
138
+ `<!-- atlas:review -->` and `atlas-review: pass <head sha>`. The signed act,
139
+ the window, `atlas approve`, the gate `apply` verb, the audit accept and the
140
+ runner wait hold only where the mode is on. Design:
141
+ `docs/design-docs/pipeline/controls-off.md`.
142
+
134
143
  A person merges every file that `atlas tooling` writes.
135
144
  A person merges every file that `atlas tests write` writes.
136
145
 
@@ -163,6 +163,12 @@ A lock is the owner's approval of a short design.
163
163
  It refuses a push to the main branch and a force push. It refuses the
164
164
  auto-merge tool; `gh pr merge --auto --match-head-commit` names the
165
165
  head. When it refuses, do what its reason says. Never work around it.
166
+ 6. In normal mode (`strict_mode` off or missing), a pull request merges
167
+ on green. A change to the rules of Atlas needs a review mark. Send a
168
+ reviewer crew with a fresh context. When it finds no S1, post a comment
169
+ with `<!-- atlas:review -->` and `atlas-review: pass <head sha>`. Never
170
+ ask for a signed act. The signed act, the window, `atlas approve` and
171
+ the gate `apply` verb hold only where `strict_mode: on`.
166
172
 
167
173
  ## pause
168
174