@arjunkhera/atlas 0.4.2 → 0.4.4

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.2",
3
+ "version": "0.4.4",
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
@@ -33,6 +33,7 @@ import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunComman
33
33
  import { runKept } from '../tests/kept.mjs';
34
34
  import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
35
35
  import { issuesCommand as testsIssues } from './lib/issues.mjs';
36
+ import { jobsCommand } from './lib/jobs.mjs';
36
37
  import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
37
38
  import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
38
39
  import { afterCommand as testsAfter } from './lib/passes-after.mjs';
@@ -129,6 +130,14 @@ const HELP = `atlas — the door into a repo's Atlas files
129
130
  --tests <dir> the test folder of the area; default test. The test files are in <dir>/scenarios
130
131
  --evidence <dir> where the evidence files are; default <area>/test/evidence
131
132
 
133
+ atlas jobs check --root <repo> --repo <owner/name> the watcher: run the check of each Atlas job that atlas.yaml names; findings as JSON
134
+ --branch <name> the default branch; default GitHub's answer
135
+ --out <file> write the JSON here; default the terminal
136
+ atlas jobs file --findings <file> --repo <owner/name> open one atlas-job issue for each new finding of a jobs check; never closes one
137
+ --max <n> open at most n issues in one run; default 10. The rest are listed, and the exit is 1
138
+ --dry-run print what it would open; change nothing
139
+ The token is only GITHUB_TOKEN. The atlas-jobs workflow runs both, once a day
140
+
132
141
  atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, and in a pull request its tracker item
133
142
  --event <file> the GitHub pull request event; adds the item check
134
143
  --json print JSON
@@ -180,6 +189,7 @@ export const FLAGS = Object.freeze({
180
189
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
181
190
  view: { product: 'value', name: 'value', out: 'value' },
182
191
  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' },
192
+ jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
183
193
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
184
194
  gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
185
195
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
@@ -413,6 +423,10 @@ async function testsCommand(chosen) {
413
423
  throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, changed, before, after, sweep, covers, kept, issues or rerun.`);
414
424
  }
415
425
 
426
+ function jobsVerb(chosen) {
427
+ return jobsCommand({ what: chosen._[1], root: rootOf(chosen), repo: chosen.repo, branch: chosen.branch, out: chosen.out, findings: chosen.findings, max: chosen.max, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
428
+ }
429
+
416
430
  function upkeepVerb(chosen) {
417
431
  return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json) }, rootOf(chosen), line);
418
432
  }
@@ -457,6 +471,7 @@ export async function main(argv = process.argv.slice(2)) {
457
471
  case 'design': return designCommand(chosen);
458
472
  case 'view': return viewCommand(chosen);
459
473
  case 'tests': return testsCommand(chosen);
474
+ case 'jobs': return jobsVerb(chosen);
460
475
  case 'upkeep': return upkeepVerb(chosen);
461
476
  case 'gate': return gateVerb(chosen);
462
477
  case 'judge': return judgeVerb(chosen);
@@ -0,0 +1,15 @@
1
+ # An Atlas job (the routines design, records part). A job does nothing until a
2
+ # repo names it in its atlas.yaml:
3
+ #
4
+ # jobs:
5
+ # atlas: [red-main]
6
+ #
7
+ # The check is Atlas code (door/lib/jobs.mjs). It reads the runs of the main
8
+ # branch only, never a run from a fork. `fields` are the typed fields that a
9
+ # finding of this job may hold. Atlas code drops every other field.
10
+ title: The newest CI run on the main branch passes
11
+ when: red-main
12
+ needs: [tests]
13
+ act: atlas:red-main
14
+ may: pull-request
15
+ fields: [workflow, run_id, head_sha, conclusion]
@@ -47,6 +47,10 @@
47
47
  {
48
48
  "kit_sha256": "8d6ae3c4dede74bafe292a992de3ef650e5c5566fb282081a9de23b8d30d9025",
49
49
  "first_package": "0.3.39"
50
+ },
51
+ {
52
+ "kit_sha256": "41d64800daf644174dd1daef1c95ccc2ca7a8460dbe3e36bcf2625d05fb0479e",
53
+ "first_package": "0.3.42"
50
54
  }
51
55
  ]
52
56
  }
package/door/lib/gate.mjs CHANGED
@@ -15,8 +15,9 @@
15
15
  // The plan is built by pure functions, so a test reads it without GitHub. The
16
16
  // gh calls sit in one place, behind `gh`, which a test replaces.
17
17
  import { execFileSync } from 'node:child_process';
18
- import { readFileSync, existsSync } from 'node:fs';
19
- import { join } from 'node:path';
18
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { dirname, join } from 'node:path';
20
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
21
22
  import { parseYaml } from '../../shape/check.mjs';
22
23
 
@@ -195,9 +196,10 @@ const checkLine = (row) => ` ${row.state === 'green' ? '✓' : '✗'} ${row.sta
195
196
 
196
197
  // `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
197
198
  // every required check is green on its head commit, 1 when not.
198
- export function readyCommand({ repo, pr }, line, gh = ghApi) {
199
+ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
199
200
  if (!pr || pr === true || !/^\d+$/.test(String(pr))) throw new GateError('atlas gate ready needs --pr <number>.');
200
201
  const pull = gh('GET', `repos/${repo}/pulls/${pr}`);
202
+ if (answer && typeof pull?.head?.sha === 'string') answer.sha = pull.head.sha;
201
203
  if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
202
204
  const sha = pull.head.sha;
203
205
  const checks = checksAt(gh, repo, pull.base.ref);
@@ -209,6 +211,24 @@ export function readyCommand({ repo, pr }, line, gh = ghApi) {
209
211
  return ready ? 0 : 1;
210
212
  }
211
213
 
214
+ // The merge door hook (packages/atlas/hooks/door.mjs) reads this file before
215
+ // a merge. It holds the last READY of one pull request: the repo, the number,
216
+ // the head commit and the time. Any answer but READY removes the file, so an
217
+ // old READY never outlives a newer NOT READY. The hook keeps its own copy of
218
+ // the path, because its bytes are pinned.
219
+ export const readyFile = (repo, pr, home = homedir()) => join(home, '.config', 'atlas', 'gate-ready', ...repo.toLowerCase().split('/'), String(pr));
220
+
221
+ export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Date() }) {
222
+ if (repo.split('/').some((part) => /^\.+$/.test(part))) return null;
223
+ const path = readyFile(repo, pr, home);
224
+ if (code !== 0 || !/^[0-9a-f]{40}$/i.test(String(sha ?? ''))) { rmSync(path, { force: true }); return null; }
225
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
226
+ const temp = `${path}.${process.pid}.tmp`;
227
+ writeFileSync(temp, `${JSON.stringify({ repo: repo.toLowerCase(), pr: Number(pr), sha: sha.toLowerCase(), at: now.toISOString() })}\n`, { mode: 0o600 });
228
+ renameSync(temp, path);
229
+ return path;
230
+ }
231
+
212
232
  // `atlas gate audit --since <date>`: each pull request merged since the date
213
233
  // whose required checks were not all green at the moment of the merge.
214
234
  export function auditCommand({ repo, since, commit }, line, gh = ghApi) {
@@ -275,10 +295,22 @@ function commitAudit({ repo, commit }, line, gh) {
275
295
  }
276
296
 
277
297
  // `atlas gate apply|ready|audit`. Returns the exit code.
278
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit }, line, gh = ghApi) {
298
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, home = homedir() }, line, gh = ghApi) {
279
299
  if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
280
300
  if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
281
- if (what === 'ready') return readyCommand({ repo, pr }, line, gh);
301
+ if (what === 'ready') {
302
+ const answer = {};
303
+ let code = 1;
304
+ try { code = readyCommand({ repo, pr, answer }, line, gh); } finally {
305
+ if (/^\d+$/.test(String(pr ?? ''))) {
306
+ try {
307
+ const kept = keepReady({ repo, pr, sha: answer.sha, code, home });
308
+ if (kept) line(`The merge door may merge #${pr} at this head for ten minutes.`);
309
+ } catch (error) { line(`The merge door could not keep this answer (${error.code ?? error.message}), so it will refuse the merge. Run ready again outside the sandbox.`); }
310
+ }
311
+ }
312
+ return code;
313
+ }
282
314
  if (what === 'audit') return auditCommand({ repo, since, commit }, line, gh);
283
315
  if (off && on) throw new GateError('pick --off or --on, not both.');
284
316
  const mode = off ? 'off' : on ? 'on' : 'apply';
@@ -22,7 +22,7 @@ import { resolve } from 'node:path';
22
22
  import { LABEL } from '../../tests/sweep.mjs';
23
23
 
24
24
  const usage = (message) => Object.assign(new Error(message), { usage: true });
25
- const TOKEN_SHAPE = /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\b[0-9a-f]{40}\b)/;
25
+ export const TOKEN_SHAPE = /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\b[0-9a-f]{40}\b)/;
26
26
  const SEGMENT = /^[A-Za-z0-9_.-]+$/;
27
27
  const PAGE = 100;
28
28
  export const DEFAULT_BOT = 'github-actions[bot]';
@@ -43,7 +43,7 @@ export function apiBase(env) {
43
43
  return text.replace(/\/+$/, '');
44
44
  }
45
45
 
46
- async function api(base, key, method, path, body) {
46
+ export async function api(base, key, method, path, body) {
47
47
  const response = await fetch(`${base}${path}`, {
48
48
  method,
49
49
  headers: {
@@ -63,6 +63,12 @@ async function api(base, key, method, path, body) {
63
63
 
64
64
  export const LABEL_COLOR = 'c5def5';
65
65
  export const LABEL_TEXT = 'Opened by the daily sweep of the scenario tests (atlas tests sweep)';
66
+ // Each label an issue may carry, and its line. A label with no line here gets a plain one.
67
+ const LABEL_TEXTS = {
68
+ [LABEL]: LABEL_TEXT,
69
+ 'atlas-job': 'Opened by the Atlas watcher (atlas-jobs.yml); a runner acts on it',
70
+ };
71
+ const labelText = (label) => LABEL_TEXTS[label] ?? (label.startsWith('job:') ? `The Atlas job ${label.slice(4)}` : 'Opened by Atlas');
66
72
 
67
73
  // The label must exist before the first issue: a token that may only write issues may not create a label by
68
74
  // naming it in an issue. GET the label; on 404, create it. A 422 means it exists by now (a race), which is fine.
@@ -72,7 +78,7 @@ async function ensureLabel(base, key, repo, label, line) {
72
78
  const have = await api(base, key, 'GET', `/repos/${repo}/labels/${name}`);
73
79
  if (have.ok) return;
74
80
  if (have.status !== 404) { line(`label GitHub answered ${have.status} when the label ${label} was read; going on`); return; }
75
- const made = await api(base, key, 'POST', `/repos/${repo}/labels`, { name: label, color: LABEL_COLOR, description: LABEL_TEXT });
81
+ const made = await api(base, key, 'POST', `/repos/${repo}/labels`, { name: label, color: LABEL_COLOR, description: labelText(label) });
76
82
  if (made.ok || made.status === 422) line(`label ${made.ok ? 'created' : 'already exists'}: ${label}`);
77
83
  else line(`label GitHub answered ${made.status} when the label ${label} was created; going on`);
78
84
  }
@@ -99,42 +105,50 @@ export function readFindings(text) {
99
105
  return data.findings;
100
106
  }
101
107
 
102
- // An issue of the bot that holds the marker and stops a new one.
103
- const stops = (issue, marker, bot) => !issue.pull_request
108
+ // An issue of the bot that holds the marker and stops a new one. `closedStops` says which closed issue
109
+ // still stops one: for the sweep, one closed as not_planned.
110
+ const sweepClosedStops = (issue) => issue.state_reason === 'not_planned';
111
+ const stops = (issue, marker, bot, closedStops) => !issue.pull_request
104
112
  && issue.user?.login === bot
105
113
  && typeof issue.body === 'string' && issue.body.includes(marker)
106
- && (issue.state === 'open' || (issue.state === 'closed' && issue.state_reason === 'not_planned'));
114
+ && (issue.state === 'open' || (issue.state === 'closed' && closedStops(issue)));
107
115
 
108
- export async function openIssues({ findings, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line }) {
116
+ // `label` is the label whose issues are read for markers: the sweep's own by default. Every label of a
117
+ // finding is made before its first issue, and the issue must carry `label`.
118
+ export async function openIssues({ findings, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, label = LABEL, closedStops = sweepClosedStops }) {
109
119
  const base = apiBase(env);
110
120
  const key = env.GITHUB_TOKEN || '';
111
121
  let known = [];
112
122
  if (!dryRun || key) {
113
123
  if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
114
- known = await labelled(base, key, repo, LABEL);
124
+ known = await labelled(base, key, repo, label);
115
125
  } else line('dry run with no GITHUB_TOKEN: the existing issues are not read, so every finding shows as new.');
116
126
  const opened = [];
117
127
  const skipped = [];
118
128
  const deferred = [];
119
129
  const failed = [];
120
130
  const seen = new Set();
121
- let labelEnsured = false;
131
+ const ensured = new Set();
122
132
  for (const f of findings) {
123
133
  const { title, body, marker, labels } = f.issue;
124
- const hit = known.find((i) => stops(i, marker, bot));
134
+ const hit = known.find((i) => stops(i, marker, bot, closedStops));
125
135
  if (hit || seen.has(marker)) { skipped.push(f.key); line(`skip ${f.key}: ${hit ? `issue #${hit.number} (${hit.state}) of ${bot} holds the marker` : 'a finding of this file has the same marker'}`); continue; }
126
136
  seen.add(marker);
127
137
  if (opened.length >= max) { deferred.push(f.key); line(`later ${f.key}: the limit of ${max} issue(s) for one run is reached`); continue; }
128
138
  const cut = String(title).slice(0, 200);
129
139
  if (dryRun) { opened.push(f.key); line(`would open ${f.key}: ${cut} [${labels.join(', ')}]`); continue; }
130
140
  try {
131
- if (!labelEnsured) { labelEnsured = true; await ensureLabel(base, key, repo, LABEL, line); }
141
+ for (const name of [label, ...labels]) {
142
+ if (ensured.has(name)) continue;
143
+ ensured.add(name);
144
+ await ensureLabel(base, key, repo, name, line);
145
+ }
132
146
  const r = await api(base, key, 'POST', `/repos/${repo}/issues`, { title: cut, body, labels });
133
147
  if (!r.ok) throw new Error(`GitHub answered ${r.status}`);
134
148
  opened.push(f.key);
135
149
  line(`opened #${r.data?.number ?? '?'} ${f.key}: ${cut}${r.data?.html_url ? ` ${r.data.html_url}` : ''}`);
136
- const has = (r.data?.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === LABEL);
137
- if (!has) { failed.push(f.key); line(`label ${f.key}: the new issue does not carry the label ${LABEL}; the token may not create labels. Create the label by hand.`); }
150
+ const has = (r.data?.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === label);
151
+ if (!has) { failed.push(f.key); line(`label ${f.key}: the new issue does not carry the label ${label}; the token may not create labels. Create the label by hand.`); }
138
152
  } catch (error) {
139
153
  failed.push(f.key);
140
154
  line(`failed ${f.key}: ${String(error.message).split(key).join('(token)')}`);
@@ -0,0 +1,241 @@
1
+ // `atlas jobs check` and `atlas jobs file`: the watcher of the routines design
2
+ // (its records and security parts).
3
+ //
4
+ // atlas jobs check --root <repo> --repo <owner/name> [--branch <name>] [--out <file>]
5
+ // atlas jobs file --findings <file> --repo <owner/name> [--max <n>] [--dry-run]
6
+ //
7
+ // The workflow `.github/workflows/atlas-jobs.yml` runs both, from the default branch, once a day.
8
+ // `atlas tooling` writes it from door/templates/atlas-jobs.yml.
9
+ //
10
+ // check reads `jobs: { atlas: [...] }` from atlas.yaml and runs the check of each Atlas job it
11
+ // names. It reads; it writes no issue. Its job in the workflow holds read permissions only.
12
+ // file reads the JSON of the check, keeps the typed fields of each finding and drops the rest, and
13
+ // opens one `atlas-job` issue for each new finding, through openIssues (issues.mjs).
14
+ //
15
+ // The check envelope (records part, "The check envelope"):
16
+ // { "jobs": 1, "state": "none" | "found" | "broken", "findings": [ { "job", "key", <typed fields> } ] }
17
+ // A finding holds ids, paths, versions and numbers only. No prose reaches an issue: every line of an
18
+ // issue is Atlas code, and the finding goes into it as one JSON block.
19
+ //
20
+ // A finding is new when no issue of the bot holds its marker, except for one that is closed. A closed
21
+ // issue with the label `handed-over` still stops a new one for 30 days: the runner gave the finding to
22
+ // the tracker. The token is only GITHUB_TOKEN, as for `atlas tests issues`.
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
24
+ import { dirname, join, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { parseYaml } from '../../shape/check.mjs';
27
+ import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
28
+
29
+ export const JOBS_LABEL = 'atlas-job';
30
+ export const HANDED_OVER = 'handed-over';
31
+ export const HANDED_OVER_DAYS = 30;
32
+ export const JOBS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'jobs');
33
+ export const ENVELOPE = 1;
34
+ export const STATES = Object.freeze(['none', 'found', 'broken']);
35
+ export const MAX_FINDINGS = 50;
36
+
37
+ const usage = (message) => Object.assign(new Error(message), { usage: true });
38
+ const DAY = 24 * 60 * 60 * 1000;
39
+
40
+ // A job id, a key, and each typed value. A value is an id, a path, a version or a number: no space and
41
+ // no prose. A key may hold a path, so it takes a slash and a dot.
42
+ const JOB_ID = /^[a-z][a-z0-9-]{0,40}$/;
43
+ const KEY = /^[A-Za-z0-9._/:@+-]{1,200}$/;
44
+ const VALUE = /^[A-Za-z0-9._/:@+-]{1,200}$/;
45
+ const FIELD = /^[a-z][a-z0-9_]{0,40}$/;
46
+
47
+ // ---------------------------------------------------------------- the Atlas jobs
48
+
49
+ // Every Atlas job of this package: door/jobs/<id>.yaml.
50
+ export function atlasJobs(dir = JOBS_DIR) {
51
+ const out = new Map();
52
+ if (!existsSync(dir)) return out;
53
+ for (const name of readdirSync(dir).sort()) {
54
+ if (!name.endsWith('.yaml')) continue;
55
+ const id = name.slice(0, -'.yaml'.length);
56
+ const job = parseYaml(readFileSync(join(dir, name), 'utf8')) ?? {};
57
+ out.set(id, { id, ...job, fields: Array.isArray(job.fields) ? job.fields.map(String).filter((f) => FIELD.test(f)) : [] });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // The Atlas jobs that the repo's atlas.yaml names, read from the checkout of the default branch.
63
+ export function namedJobs(root) {
64
+ const file = join(root, 'atlas.yaml');
65
+ if (!existsSync(file)) throw usage(`there is no atlas.yaml in ${root}`);
66
+ const facts = parseYaml(readFileSync(file, 'utf8')) ?? {};
67
+ const named = facts.jobs?.atlas;
68
+ if (named === undefined || named === null) return [];
69
+ if (!Array.isArray(named)) throw usage('jobs.atlas in atlas.yaml must be a list of job ids');
70
+ return named.map(String);
71
+ }
72
+
73
+ // ---------------------------------------------------------------- the check of red-main
74
+
75
+ // A red run: it ended and did not pass. A cancelled run is not red: a newer push cancels an older run.
76
+ const RED = new Set(['failure', 'timed_out', 'startup_failure']);
77
+
78
+ // The newest finished run of each workflow on the default branch, from a push to this repo. A run of a
79
+ // pull request, a schedule, or a fork is never read: its event is not push, or its head repo is another.
80
+ export function newestMainRuns(runs, { repo, branch }) {
81
+ const newest = new Map();
82
+ for (const run of runs) {
83
+ if (run?.event !== 'push' || run?.head_branch !== branch || run?.status !== 'completed') continue;
84
+ if (run?.head_repository?.full_name !== repo || run?.repository?.full_name !== repo) continue;
85
+ const path = String(run.path ?? '').replace(/@.*$/, '');
86
+ if (!path || !VALUE.test(path)) continue;
87
+ const have = newest.get(path);
88
+ if (!have || Date.parse(run.created_at) > Date.parse(have.created_at)) newest.set(path, run);
89
+ }
90
+ return newest;
91
+ }
92
+
93
+ export async function checkRedMain({ repo, branch, env }) {
94
+ const base = apiBase(env);
95
+ const key = env.GITHUB_TOKEN || '';
96
+ if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
97
+ const query = `branch=${encodeURIComponent(branch)}&event=push&status=completed&per_page=100`;
98
+ const r = await api(base, key, 'GET', `/repos/${repo}/actions/runs?${query}`);
99
+ if (!r.ok || !Array.isArray(r.data?.workflow_runs)) throw new Error(`GitHub answered ${r.status} when the runs of ${branch} were listed`);
100
+ const findings = [];
101
+ for (const [path, run] of [...newestMainRuns(r.data.workflow_runs, { repo, branch })].sort(([a], [b]) => (a < b ? -1 : 1))) {
102
+ if (!RED.has(run.conclusion)) continue;
103
+ findings.push({ job: 'red-main', key: path, workflow: path, run_id: Number(run.id), head_sha: String(run.head_sha), conclusion: String(run.conclusion) });
104
+ }
105
+ return findings;
106
+ }
107
+
108
+ // The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
109
+ // says so in the notes; it never reads `found` from nothing.
110
+ const CHECKS = { 'red-main': checkRedMain };
111
+
112
+ export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs() }) {
113
+ const named = namedJobs(root);
114
+ const notes = [];
115
+ const ran = [];
116
+ const findings = [];
117
+ let state = 'none';
118
+ let mainBranch = branch;
119
+ for (const id of named) {
120
+ const job = jobs.get(id);
121
+ if (!job) { notes.push(`${JOB_ID.test(id) ? id : '(a bad id)'}: no Atlas job has this id`); continue; }
122
+ const check = CHECKS[job.when];
123
+ if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
124
+ try {
125
+ if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
126
+ const found = (await check({ repo, branch: mainBranch, env })).map((f) => ({ ...f, job: id }));
127
+ findings.push(...found);
128
+ ran.push({ job: id, state: found.length ? 'found' : 'none' });
129
+ } catch (error) {
130
+ if (error.usage) throw error;
131
+ // The message never holds the token: it is built from fixed words and statuses.
132
+ ran.push({ job: id, state: 'broken' });
133
+ notes.push(`${id}: broken: ${String(error.message).split(env.GITHUB_TOKEN || '\u0000').join('(token)')}`);
134
+ }
135
+ }
136
+ if (ran.some((one) => one.state === 'broken')) state = 'broken';
137
+ else if (findings.length) state = 'found';
138
+ return { jobs: ENVELOPE, repo, branch: mainBranch, state, ran, notes, findings };
139
+ }
140
+
141
+ async function defaultBranch({ repo, env }) {
142
+ const r = await api(apiBase(env), env.GITHUB_TOKEN || '', 'GET', `/repos/${repo}`);
143
+ const name = r.data?.default_branch;
144
+ if (!r.ok || typeof name !== 'string' || !VALUE.test(name)) throw new Error(`GitHub answered ${r.status} when the default branch was read`);
145
+ return name;
146
+ }
147
+
148
+ // ---------------------------------------------------------------- the file job
149
+
150
+ // Keep a finding only when its job is an Atlas job, its key is typed, and it has at least one field.
151
+ // Keep only the fields that the job lists, and only typed values. Count what is dropped.
152
+ export function typedFindings(envelope, jobs = atlasJobs()) {
153
+ if (!envelope || envelope.jobs !== ENVELOPE || !STATES.includes(envelope.state) || !Array.isArray(envelope.findings)) {
154
+ throw usage('the findings file is not the output of `atlas jobs check`');
155
+ }
156
+ const kept = [];
157
+ let dropped = 0;
158
+ for (const f of envelope.findings.slice(0, MAX_FINDINGS)) {
159
+ const job = jobs.get(String(f?.job));
160
+ if (!job || !KEY.test(String(f?.key ?? ''))) { dropped += 1; continue; }
161
+ const one = { job: job.id, key: String(f.key) };
162
+ for (const field of job.fields) {
163
+ const value = f[field];
164
+ if (typeof value === 'number' && Number.isFinite(value)) one[field] = value;
165
+ else if (typeof value === 'string' && VALUE.test(value)) one[field] = value;
166
+ }
167
+ kept.push(one);
168
+ }
169
+ dropped += Math.max(0, envelope.findings.length - MAX_FINDINGS);
170
+ return { findings: kept, dropped };
171
+ }
172
+
173
+ // An HTML comment may not hold two hyphens in a row, so the marker spells them another way.
174
+ export const jobMarker = (job, key) => `<!-- atlas-job:${job}:${String(key).replace(/--/g, '-~')} -->`;
175
+
176
+ // The issue of one finding (records part, "The atlas-job issue").
177
+ export function jobIssue(finding) {
178
+ const marker = jobMarker(finding.job, finding.key);
179
+ const body = [
180
+ `The Atlas watcher found this in the job \`${finding.job}\`. Atlas code wrote this issue. It holds typed fields only.`,
181
+ '',
182
+ '```json',
183
+ JSON.stringify(finding, null, 2),
184
+ '```',
185
+ '',
186
+ 'A runner acts on it later. The watcher never edits or closes this issue.',
187
+ '',
188
+ marker,
189
+ ].join('\n');
190
+ return { title: `atlas-job: ${finding.job} ${finding.key}`.slice(0, 200), body, labels: [JOBS_LABEL, `job:${finding.job}`], marker };
191
+ }
192
+
193
+ // A closed issue stops a new one only when the runner handed it over in the last 30 days.
194
+ export const handedOverStops = (now = Date.now()) => (issue) => (issue.labels ?? []).some((l) => (typeof l === 'string' ? l : l?.name) === HANDED_OVER)
195
+ && Number.isFinite(Date.parse(issue.closed_at)) && now - Date.parse(issue.closed_at) <= HANDED_OVER_DAYS * DAY;
196
+
197
+ export async function fileJobs({ envelope, repo, dryRun = false, max = DEFAULT_MAX, bot = DEFAULT_BOT, env = process.env, line, now = Date.now(), jobs = atlasJobs() }) {
198
+ const { findings, dropped } = typedFindings(envelope, jobs);
199
+ if (dropped) line(`dropped ${dropped} finding(s) that are not typed, or that name no Atlas job`);
200
+ if (envelope.state === 'broken') line('the check reads broken, so a check found nothing it can trust. The run ends red.');
201
+ const list = findings.map((f) => ({ key: `${f.job}:${f.key}`, issue: jobIssue(f) }));
202
+ const result = list.length
203
+ ? await openIssues({ findings: list, repo, dryRun, max, bot, env, line, label: JOBS_LABEL, closedStops: handedOverStops(now) })
204
+ : (line('no finding: nothing to file.'), { opened: [], skipped: [], deferred: [], failed: [] });
205
+ return { ...result, broken: envelope.state === 'broken' };
206
+ }
207
+
208
+ // ---------------------------------------------------------------- the command
209
+
210
+ export async function jobsCommand({ what, root, repo, branch, out, findings, max, dryRun = false, bot, args = [] }, line, env = process.env) {
211
+ try {
212
+ if ([...args, findings, repo, out, branch].some((a) => typeof a === 'string' && TOKEN_SHAPE.test(a))) throw usage('a token must never be an argument; put it in GITHUB_TOKEN');
213
+ if (!repoOk(repo)) throw usage('give the repo as --repo <owner/name>');
214
+ if (what === 'check') {
215
+ if (branch !== undefined && (typeof branch !== 'string' || !VALUE.test(branch))) throw usage('--branch must name a branch');
216
+ const result = await jobsCheck({ root, repo, branch: branch ?? null, env });
217
+ const json = `${JSON.stringify(result, null, 2)}\n`;
218
+ if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(resolve(out), json); }
219
+ line(`jobs check: ${result.state}. ${result.ran.length} job(s) ran, ${result.findings.length} finding(s).${out ? ` Written to ${out}.` : ''}`);
220
+ for (const one of result.ran) line(` ${one.job}: ${one.state}`);
221
+ for (const note of result.notes) line(` note: ${note}`);
222
+ if (!out) line(json.trimEnd());
223
+ return result.state === 'broken' ? 1 : 0;
224
+ }
225
+ if (what === 'file') {
226
+ if (!findings) throw usage('give the check file: --findings <file>');
227
+ const limit = max === undefined ? DEFAULT_MAX : Number(max);
228
+ if (!Number.isInteger(limit) || limit < 0) throw usage('--max must be a whole number, 0 or more');
229
+ const file = resolve(findings);
230
+ if (!existsSync(file)) throw usage(`the findings file ${file} does not exist`);
231
+ let envelope;
232
+ try { envelope = JSON.parse(readFileSync(file, 'utf8')); } catch { throw usage('the findings file is not JSON'); }
233
+ const r = await fileJobs({ envelope, repo, dryRun: Boolean(dryRun), max: limit, bot: bot || DEFAULT_BOT, env, line });
234
+ return r.broken || r.deferred.length || r.failed.length ? 1 : 0;
235
+ }
236
+ throw usage(`there is no jobs verb "${what ?? ''}". Use check or file.`);
237
+ } catch (error) {
238
+ line(`jobs: ${String(error.message).split(env.GITHUB_TOKEN || '\u0000').join('(token)')}`);
239
+ return error.usage ? 2 : 1;
240
+ }
241
+ }
@@ -23,7 +23,7 @@ export const TOOLING_LABEL = 'atlas-helper';
23
23
 
24
24
  // The only files the tooling writer may write: the check, its workflow, and
25
25
  // the pull request template when the repo has none.
26
- export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/pull_request_template.md']);
26
+ export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/pull_request_template.md']);
27
27
 
28
28
  export class ChangeError extends Error {}
29
29
 
@@ -24,6 +24,32 @@ import { PR_TEMPLATE_PATH } from './upkeep.mjs';
24
24
 
25
25
  export { CHECK_PATH, WORKFLOW_PATH, PACKAGE_CHECK_SOURCE, PR_TEMPLATE_PATH };
26
26
 
27
+ // The watcher of the routines design (roadmap R24). The writer puts it into each
28
+ // repo, with the version of this package as its pin. It refreshes a copy whose pin
29
+ // is older than this package, and keeps any other copy: a refresh never moves a
30
+ // repo backwards, and never replaces a hand edit at the same version.
31
+ export const JOBS_PATH = '.github/workflows/atlas-jobs.yml';
32
+ export const PACKAGE_JOBS_SOURCE = 'door/templates/atlas-jobs.yml';
33
+ const VERSION_SLOT = '__ATLAS_VERSION__';
34
+ export function packagedJobsWorkflow(packageRoot) {
35
+ const full = join(packageRoot, PACKAGE_JOBS_SOURCE);
36
+ return existsSync(full) ? readFileSync(full, 'utf8').split(VERSION_SLOT).join(packagePackageVersion(packageRoot)) : null;
37
+ }
38
+ const jobsPin = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
39
+ const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
40
+ // written, refreshed, unchanged or kept: what the writer does with the watcher.
41
+ function jobsState(root, packageRoot) {
42
+ const wanted = packagedJobsWorkflow(packageRoot);
43
+ if (wanted === null) return null;
44
+ const full = join(root, JOBS_PATH);
45
+ if (!existsSync(full)) return 'written';
46
+ const was = readFileSync(full, 'utf8');
47
+ if (was === wanted) return 'unchanged';
48
+ const have = jobsPin(was);
49
+ return have && older(have, jobsPin(wanted)) ? 'refreshed' : 'kept';
50
+ }
51
+ const jobsNeedWrite = (root, packageRoot) => ['written', 'refreshed'].includes(jobsState(root, packageRoot));
52
+
27
53
  // The pull request template (roadmap R8). The writer puts it into a repo that
28
54
  // has none, and never replaces a template the repo keeps.
29
55
  export const PACKAGE_TEMPLATE_SOURCE = 'door/templates/pull_request_template.md';
@@ -75,7 +101,7 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
75
101
  throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
76
102
  }
77
103
  const now = status({ root, packageRoot });
78
- if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot)) {
104
+ if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot)) {
79
105
  throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
80
106
  }
81
107
  if (now.state === 'unknown' && !replaceUnknown && !own) {
@@ -101,6 +127,17 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
101
127
  }
102
128
  rows.push({ path, state, sha256: sha256(contents), was: was === null ? null : sha256(was) });
103
129
  }
130
+ const jobs = workflowOnly ? null : jobsState(root, packageRoot);
131
+ if (jobs !== null) {
132
+ const contents = packagedJobsWorkflow(packageRoot);
133
+ const full = join(root, JOBS_PATH);
134
+ const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
135
+ if ((jobs === 'written' || jobs === 'refreshed') && !dryRun) {
136
+ mkdirSync(dirname(full), { recursive: true });
137
+ writeFileSync(full, contents);
138
+ }
139
+ rows.push({ path: JOBS_PATH, state: jobs, sha256: sha256(jobs === 'kept' ? was : contents), was: was === null ? null : sha256(was) });
140
+ }
104
141
  const template = workflowOnly ? null : packagedTemplate(packageRoot);
105
142
  if (template !== null) {
106
143
  const contents = template;
@@ -125,7 +162,7 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
125
162
  if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
126
163
  throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
127
164
  }
128
- const wanted = { ...packagedFiles(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
165
+ const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
129
166
  return rows
130
167
  .filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
131
168
  .map((row) => ({
@@ -137,7 +174,9 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
137
174
  ? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
138
175
  : row.path === PR_TEMPLATE_PATH
139
176
  ? `copied from ${PACKAGE_TEMPLATE_SOURCE}, Atlas ${version}; the repo had no pull request template`
140
- : `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
177
+ : row.path === JOBS_PATH
178
+ ? `written from ${PACKAGE_JOBS_SOURCE}, with its pin set to Atlas ${version}`
179
+ : `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
141
180
  },
142
181
  }));
143
182
  }
@@ -0,0 +1,73 @@
1
+ name: atlas-jobs
2
+ # The Atlas watcher: plain code, no model and no secret. `atlas tooling` wrote
3
+ # this file from the Atlas package; do not edit it by hand.
4
+ #
5
+ # It runs once a day from the default branch, and never on a pull request, so
6
+ # a pull request cannot change what it runs. It has two jobs:
7
+ # check reads the repo and the CI runs of the default branch. It holds only
8
+ # read permissions and the run's own token, and no repo secret.
9
+ # file holds only issues write. It runs Atlas code on the JSON of the
10
+ # check job, and files one `atlas-job` issue for each new finding.
11
+ # A job does nothing until atlas.yaml names it under `jobs: { atlas: [...] }`.
12
+ on:
13
+ schedule:
14
+ - cron: "17 4 * * *"
15
+ workflow_dispatch:
16
+
17
+ permissions: {}
18
+
19
+ concurrency:
20
+ group: atlas-jobs
21
+ cancel-in-progress: false
22
+
23
+ jobs:
24
+ check:
25
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
26
+ runs-on: ubuntu-latest
27
+ timeout-minutes: 10
28
+ permissions:
29
+ contents: read
30
+ actions: read
31
+ outputs:
32
+ findings: ${{ steps.check.outputs.findings }}
33
+ steps:
34
+ # actions/checkout v7.0.1
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
36
+ with:
37
+ persist-credentials: false
38
+ # actions/setup-node v7.0.0
39
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
40
+ with:
41
+ node-version: "22.22.0"
42
+ - name: Run the check of each job that atlas.yaml names
43
+ id: check
44
+ env:
45
+ GITHUB_TOKEN: ${{ github.token }}
46
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
47
+ run: |
48
+ set -euo pipefail
49
+ # A broken check still hands its JSON on, so the file job reads it and ends the run red.
50
+ status=0
51
+ npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ jobs check --root . --repo "$GITHUB_REPOSITORY" --branch "$DEFAULT_BRANCH" --out "$RUNNER_TEMP/jobs.json" | tee -a "$GITHUB_STEP_SUMMARY" || status=$?
52
+ test -s "$RUNNER_TEMP/jobs.json" || { echo "::error::the check wrote no JSON (exit $status)"; exit 1; }
53
+ echo "findings=$(base64 -w0 < "$RUNNER_TEMP/jobs.json")" >> "$GITHUB_OUTPUT"
54
+
55
+ file:
56
+ needs: check
57
+ runs-on: ubuntu-latest
58
+ timeout-minutes: 5
59
+ permissions:
60
+ issues: write
61
+ steps:
62
+ # actions/setup-node v7.0.0
63
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
64
+ with:
65
+ node-version: "22.22.0"
66
+ - name: File one issue for each new finding
67
+ env:
68
+ GITHUB_TOKEN: ${{ github.token }}
69
+ FINDINGS: ${{ needs.check.outputs.findings }}
70
+ run: |
71
+ set -euo pipefail
72
+ printf '%s' "$FINDINGS" | base64 -d > "$RUNNER_TEMP/jobs.json"
73
+ npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ jobs file --findings "$RUNNER_TEMP/jobs.json" --repo "$GITHUB_REPOSITORY" | tee -a "$GITHUB_STEP_SUMMARY"
package/hooks/door.mjs ADDED
@@ -0,0 +1,387 @@
1
+ // The merge door: the second hook Atlas ships. Claude Code runs it before a
2
+ // tool call that can merge, push or write a file. On a plan with no
3
+ // rulesets GitHub blocks nothing, so this hook holds the gate in a session
4
+ // (design docs/design-docs/gate-advisory/, done line 8).
5
+ //
6
+ // It refuses five things, and lets every other call through:
7
+ // 1. A merge or an auto-merge, through a GitHub tool or `gh pr merge`, unless
8
+ // `atlas gate ready` said READY for that pull request in the last ten
9
+ // minutes, and the merge names the same head commit. The auto-merge tool
10
+ // cannot name a head, so it is always refused.
11
+ // 2. A merge through another door: `gh api` on a merge path, a GraphQL merge,
12
+ // `gh pr merge --admin`, or a plain HTTP call to a GitHub merge path.
13
+ // 3. A push to the default branch, a force push, and a push of all branches.
14
+ // 4. A file write to the default branch through a GitHub tool.
15
+ // 5. A change to the READY answers, a change to a Claude settings file by a
16
+ // command or to its hook and plugin switches, and turning the plugin off.
17
+ //
18
+ // Its limits:
19
+ // 1. It makes no network call and starts no program. It reads the tool call,
20
+ // the READY answer file, and the git folder of the repo it pushes from.
21
+ // 2. It writes nothing. It never answers "allow": a call it lets through
22
+ // still goes to the person's own permission rules.
23
+ // 3. It matches text. A command it cannot read as a push or a merge goes
24
+ // through. The push audit job and `atlas gate audit` stay the backstop.
25
+ // 4. ATLAS_HOOKS=off, set outside the session, turns it off.
26
+ import { readFileSync, statSync } from 'node:fs';
27
+ import { homedir } from 'node:os';
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ export const FRESH_MS = 10 * 60 * 1000;
32
+ export const DEFAULTS = ['main', 'master'];
33
+ const SHA = /^[0-9a-f]{40}$/i;
34
+
35
+ // The folder where `atlas gate ready` keeps each READY answer.
36
+ export const readyFolder = (home = homedir()) => join(home, '.config', 'atlas', 'gate-ready');
37
+ export const readyPath = (repo, pr, home = homedir()) => join(readyFolder(home), ...String(repo).toLowerCase().split('/'), String(pr));
38
+
39
+ // ------------------------------------------------------------------ the door
40
+
41
+ // One tool call in, one answer out: null lets it through, a string refuses it.
42
+ export function judge(call, { home = homedir(), now = Date.now() } = {}) {
43
+ const tool = String(call?.tool_name ?? '');
44
+ const input = call?.tool_input ?? {};
45
+ const cwd = typeof call?.cwd === 'string' ? call.cwd : process.cwd();
46
+ if (tool === 'Bash') return judgeCommand(String(input.command ?? ''), { cwd, home, now });
47
+ if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) return judgeWrite(input, { cwd, home });
48
+ if (!tool.startsWith('mcp__')) return null;
49
+ const verb = tool.slice(tool.lastIndexOf('__') + 2);
50
+ const repo = `${input.owner ?? ''}/${input.repo ?? ''}`;
51
+ if (verb === 'merge_pull_request') return needReady({ repo, pr: input.pullNumber, sha: input.expectedHeadSha, home, now, how: 'the expectedHeadSha argument' });
52
+ // This tool cannot name the head, so a later push could merge with no READY.
53
+ if (verb === 'enable_pr_auto_merge') return 'the merge door refuses enable_pr_auto_merge: it cannot name the head that ready read. Run "atlas gate ready", then merge with the head SHA, or use "gh pr merge --auto --match-head-commit <sha>".';
54
+ if (['push_files', 'create_or_update_file', 'delete_file'].includes(verb)) {
55
+ const branch = String(input.branch ?? '').replace(/^refs\/heads\//, '');
56
+ if (!branch) return `the merge door refuses ${verb} with no branch: GitHub would write to the default branch. Name a work branch.`;
57
+ if (defaultsFor(repo, cwd).has(branch)) return `the merge door refuses ${verb} on ${branch}, the default branch. Write to a work branch and open a pull request.`;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // A merge waits for a fresh READY at the same head.
63
+ function needReady({ repo, pr, sha, home, now, how }) {
64
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.split('/').some((part) => /^\.+$/.test(part)) || !/^\d+$/.test(String(pr ?? ''))) return 'the merge door refuses a merge that does not name its repo and pull request number.';
65
+ const run = `atlas gate ready --repo ${repo} --pr ${pr}`;
66
+ let answer;
67
+ try { answer = JSON.parse(readFileSync(readyPath(repo, pr, home), 'utf8')); } catch {
68
+ return `the merge door refuses the merge of ${repo}#${pr}: there is no READY answer for it. Run "${run}" right before the merge.`;
69
+ }
70
+ const at = Date.parse(answer?.at ?? '');
71
+ if (String(answer?.repo ?? '').toLowerCase() !== repo.toLowerCase() || String(answer?.pr) !== String(pr) || !SHA.test(String(answer?.sha ?? '')) || Number.isNaN(at)) {
72
+ return `the merge door refuses the merge of ${repo}#${pr}: its READY answer is not well formed. Run "${run}" again.`;
73
+ }
74
+ if (now - at > FRESH_MS || at - now > 60 * 1000) return `the merge door refuses the merge of ${repo}#${pr}: its READY answer is older than ten minutes. Run "${run}" again.`;
75
+ if (!sha) return `the merge door refuses the merge of ${repo}#${pr}: name the head that ready read (${answer.sha}) in ${how}.`;
76
+ if (String(sha).toLowerCase() !== answer.sha.toLowerCase()) return `the merge door refuses the merge of ${repo}#${pr}: the merge names ${String(sha).slice(0, 7)}, but ready read ${answer.sha.slice(0, 7)}. Run "${run}" on the new head.`;
77
+ return null;
78
+ }
79
+
80
+ // ------------------------------------------------------------- file writes
81
+
82
+ const SWITCH = /ATLAS_HOOKS|disableAllHooks|enabledPlugins|atlas@|"hooks"\s*:/;
83
+ // A command with one of these words can write a file.
84
+ const WRITES = /(>|\b(tee|cp|mv|touch|rm|mkdir|ln|chmod|install|rsync|dd|truncate|node|python3?|perl|ruby|jq)\b|\bsed\b[^|;&]*\s-i)/;
85
+ const SETTINGS = /(^|[\\/])\.claude[\\/]settings(\.local)?\.json$/;
86
+
87
+ function judgeWrite(input, { cwd, home }) {
88
+ const raw = String(input.file_path ?? input.notebook_path ?? '');
89
+ if (!raw) return null;
90
+ const path = resolve(cwd, raw.replace(/^~(?=[\\/])/, home));
91
+ const ready = readyFolder(home);
92
+ if (path === ready || path.startsWith(ready + '/') || path.startsWith(ready + '\\')) return 'the merge door refuses a write to the READY answers (~/.config/atlas/gate-ready): only "atlas gate ready" writes them.';
93
+ if (SETTINGS.test(path)) {
94
+ // The old text counts too: an edit of true to false names no switch word.
95
+ const text = [input.content, input.old_string, input.new_string, ...(input.edits ?? []).flatMap((edit) => [edit?.old_string, edit?.new_string])].filter((part) => typeof part === 'string').join('\n');
96
+ if (SWITCH.test(text)) return 'the merge door refuses a change to the hooks or the plugins in a Claude settings file. Only the person turns the Atlas hooks off, outside the session.';
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // ---------------------------------------------------------------- commands
102
+
103
+ // Split a command line into simple commands, and each one into words. Quotes
104
+ // and backslashes are read the way the shell reads them. A `(` ends a command
105
+ // too. judgeCommand reads the inside of each `$(...)` and backtick pair again,
106
+ // since a shell runs it even inside double quotes.
107
+ export function commandsOf(text) {
108
+ const out = [];
109
+ let words = [];
110
+ let word = null;
111
+ const endWord = () => { if (word !== null) words.push(word); word = null; };
112
+ const endCommand = (op) => { endWord(); if (words.length) out.push({ words, op }); words = []; };
113
+ for (let i = 0; i < text.length; i += 1) {
114
+ const c = text[i];
115
+ if (c === "'") { const end = text.indexOf("'", i + 1); word = (word ?? '') + text.slice(i + 1, end < 0 ? text.length : end); i = end < 0 ? text.length : end; continue; }
116
+ if (c === '"') {
117
+ word = word ?? '';
118
+ for (i += 1; i < text.length && text[i] !== '"'; i += 1) word += text[i] === '\\' && i + 1 < text.length ? text[++i] : text[i];
119
+ continue;
120
+ }
121
+ if (c === '\\' && i + 1 < text.length) { if (text[i + 1] !== '\n') word = (word ?? '') + text[i + 1]; i += 1; continue; }
122
+ if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')') { endCommand(c); continue; }
123
+ if (c === ' ' || c === '\t') { endWord(); continue; }
124
+ if (c === '#' && word === null) { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end - 1; continue; }
125
+ word = (word ?? '') + c;
126
+ }
127
+ endCommand('');
128
+ return out;
129
+ }
130
+
131
+ // Leading words that run the next word as the command. `timeout` also takes
132
+ // a time. A command named by its full path counts by its last name.
133
+ const WRAPPERS = new Set(['sudo', 'command', 'exec', 'nohup', 'time', 'env', 'builtin', 'xargs', 'nice', 'timeout', 'stdbuf']);
134
+ const baseName = (word) => String(word ?? '').replace(/^.*\//, '');
135
+ function commandWords(words) {
136
+ let i = 0;
137
+ for (;;) {
138
+ const word = words[i];
139
+ if (word === undefined) break;
140
+ if (WRAPPERS.has(baseName(word))) {
141
+ i += 1;
142
+ while (words[i]?.startsWith('-')) i += 1;
143
+ if (baseName(word) === 'timeout' && /^\d/.test(words[i] ?? '')) i += 1;
144
+ continue;
145
+ }
146
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) { i += 1; continue; }
147
+ break;
148
+ }
149
+ const out = words.slice(i);
150
+ if (out.length) out[0] = baseName(out[0]);
151
+ return out;
152
+ }
153
+
154
+ // The text a shell reads for `$(...)` and backticks: without single-quoted
155
+ // strings and without here-documents whose end word is quoted.
156
+ const unquoted = (text) => text.replace(/<<-?\s*(['"])(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\n|$)/g, ' ').replace(/'[^']*'/g, "''");
157
+ // The command lines only: a here-document body is data for its command.
158
+ const noHereDocs = (text) => text.replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2(?=\n|$)/g, ' $3');
159
+
160
+ const GH_MERGE_PATH = /(^|\/)repos\/[^/\s]+\/[^/\s]+\/(pulls\/\d+\/merge|merges)\b|(^|\/)pulls\/\d+\/merge\b/;
161
+ const GH_REFS_PATH = /(^|\/)git\/refs\b/;
162
+ const GQL_MERGE = /\b(mergePullRequest|enablePullRequestAutoMerge|mergeBranch|updateRefs?|deleteRef)\b/;
163
+ const HTTP_MERGE = /api\.github\.com\/repos\/[^\s'"]+\/(pulls\/\d+\/merge|merges|git\/refs)/;
164
+
165
+ function judgeCommand(text, { cwd, home, now }) {
166
+ // 5. The READY answers, the key and the hook switch are not the session's
167
+ // to change, by any command.
168
+ if (/gate-ready/.test(text) && WRITES.test(text)) return 'the merge door refuses a command that names the READY answers (~/.config/atlas/gate-ready) and can write a file: only "atlas gate ready" writes them.';
169
+ if (/\.claude[\\/]settings(\.local)?\.json/.test(text) && (SWITCH.test(text) || WRITES.test(text))) return 'the merge door refuses a command that changes a Claude settings file. The person changes the hooks and the plugins, outside the session.';
170
+ if (/\bclaude\s+plugins?\s+(disable|uninstall|remove)\b[^|;&]*\batlas\b/.test(text)) return 'the merge door refuses to turn the Atlas plugin off. The person runs that command, outside the session.';
171
+ if (HTTP_MERGE.test(text) && /\b(curl|wget|fetch|http|requests|urllib)\b/.test(text)) return 'the merge door refuses a merge or a branch write through a plain call to the GitHub API. Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.';
172
+ for (const [, inner] of [...unquoted(text).matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)].map((match) => [match[0], match[1] ?? match[2]])) {
173
+ const why = judgeCommand(inner, { cwd, home, now });
174
+ if (why) return why;
175
+ }
176
+ let here = cwd;
177
+ for (const { words: all } of commandsOf(noHereDocs(text))) {
178
+ const words = commandWords(all);
179
+ if (words[0] === 'cd' && words[1] && words[1] !== '-') { here = resolve(here, words[1].replace(/^~(?=\/|$)/, home)); continue; }
180
+ // A command line handed to a shell or to eval is read the same way.
181
+ const flag = words.findIndex((word, k) => k > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
182
+ const inner = ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(words[0]) && flag > 0 ? words[flag + 1] : words[0] === 'eval' ? words.slice(1).join(' ') : null;
183
+ if (inner) {
184
+ const why = judgeCommand(inner, { cwd: here, home, now });
185
+ if (why) return why;
186
+ }
187
+ if (words[0] === 'git') {
188
+ const why = judgeGit(words, here, home);
189
+ if (why) return why;
190
+ }
191
+ if (words[0] === 'gh') {
192
+ const why = judgeGh(words, { home, now, cwd: here });
193
+ if (why) return why;
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // -------------------------------------------------------------------- git
200
+
201
+ function judgeGit(words, cwd, home) {
202
+ let i = 1;
203
+ let dir = cwd;
204
+ while (i < words.length && words[i].startsWith('-')) {
205
+ if (words[i] === '-C') { dir = resolve(dir, String(words[i + 1] ?? '').replace(/^~(?=\/|$)/, home)); i += 2; continue; }
206
+ if (words[i] === '-c') { i += 2; continue; }
207
+ i += 1;
208
+ }
209
+ if (words[i] !== 'push') return null;
210
+ const args = words.slice(i + 1);
211
+ const spots = [];
212
+ let deleting = false;
213
+ for (let k = 0; k < args.length; k += 1) {
214
+ const arg = args[k];
215
+ if (/^\d*[<>]/.test(arg) || /^&>/.test(arg)) { if (/^\d*[<>]+&?$|^&>$/.test(arg)) k += 1; continue; }
216
+ if (arg === '--') { spots.push(...args.slice(k + 1)); break; }
217
+ if (/^--(force|force-with-lease|force-if-includes|mirror)(=|$)/.test(arg)) return `the merge door refuses a force push (${arg.split('=')[0]}). Merge the main branch into your branch, and push again.`;
218
+ if (/^--(all|branches)$/.test(arg)) return `the merge door refuses "git push ${arg}": it pushes the default branch too. Push one work branch.`;
219
+ if (arg === '--delete') { deleting = true; continue; }
220
+ if (/^--(repo|receive-pack|exec|push-option|signed)$/.test(arg) || arg === '-o') { k += 1; continue; }
221
+ if (arg.startsWith('--')) continue;
222
+ if (/^-[A-Za-z]+$/.test(arg)) {
223
+ if (arg.includes('f')) return 'the merge door refuses a force push (-f). Merge the main branch into your branch, and push again.';
224
+ if (arg.includes('d')) deleting = true;
225
+ continue;
226
+ }
227
+ spots.push(arg);
228
+ }
229
+ const git = gitFolder(dir);
230
+ const defaults = new Set([...DEFAULTS, ...(git ? remoteDefaults(git.common) : [])]);
231
+ const current = () => (git ? currentBranch(git.dir) : null);
232
+ const refs = spots.slice(1);
233
+ if (!refs.length) {
234
+ const branch = current();
235
+ if (!deleting && branch && defaults.has(branch)) return `the merge door refuses a push from ${branch}, the default branch. Make a work branch and open a pull request.`;
236
+ return null;
237
+ }
238
+ for (const ref of refs) {
239
+ if (ref.startsWith('+')) return `the merge door refuses a force push ("${ref}"). Merge the main branch into your branch, and push again.`;
240
+ const colon = ref.indexOf(':');
241
+ const source = colon >= 0 ? ref.slice(0, colon) : ref;
242
+ let target = colon >= 0 ? ref.slice(colon + 1) || source : ref;
243
+ if (target === 'HEAD' || target === '@') target = current() ?? '';
244
+ target = target.replace(/^refs\/heads\//, '');
245
+ if (!target || !defaults.has(target)) continue;
246
+ if (deleting || source === '') return `the merge door refuses a push that deletes ${target}, the default branch.`;
247
+ return `the merge door refuses a push to ${target}, the default branch. Push a work branch and open a pull request.`;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ // The git folder of a work tree: { dir, common }. A linked work tree keeps
253
+ // HEAD in its own folder and the remotes in the common one.
254
+ export function gitFolder(start) {
255
+ for (let dir = start; ; dir = dirname(dir)) {
256
+ const dot = join(dir, '.git');
257
+ try {
258
+ if (statSync(dot).isDirectory()) return { dir: dot, common: dot };
259
+ const link = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf8'))?.[1]?.trim();
260
+ if (link) {
261
+ const own = isAbsolute(link) ? link : resolve(dir, link);
262
+ let common = own;
263
+ try { const shared = readFileSync(join(own, 'commondir'), 'utf8').trim(); common = isAbsolute(shared) ? shared : resolve(own, shared); } catch { /* not a linked work tree */ }
264
+ return { dir: own, common };
265
+ }
266
+ } catch { /* no .git here */ }
267
+ if (dirname(dir) === dir) return null;
268
+ }
269
+ }
270
+
271
+ function currentBranch(gitDir) {
272
+ try { return /^ref:\s*refs\/heads\/(.+)$/m.exec(readFileSync(join(gitDir, 'HEAD'), 'utf8'))?.[1]?.trim() ?? null; } catch { return null; }
273
+ }
274
+
275
+ // The default branch names for one repo: main, master, and the remote HEAD of
276
+ // the session's own copy when its origin is that repo.
277
+ function defaultsFor(repo, cwd) {
278
+ const out = new Set(DEFAULTS);
279
+ const git = gitFolder(cwd);
280
+ if (git && String(originRepo(cwd) ?? '').toLowerCase() === String(repo).toLowerCase()) for (const name of remoteDefaults(git.common)) out.add(name);
281
+ return out;
282
+ }
283
+
284
+ // owner/name of the origin remote, from the git config, when it is on GitHub.
285
+ function originRepo(cwd) {
286
+ const git = gitFolder(cwd);
287
+ if (!git) return null;
288
+ try {
289
+ const config = readFileSync(join(git.common, 'config'), 'utf8');
290
+ const block = /\[remote "origin"\]([^[]*)/.exec(config)?.[1] ?? '';
291
+ const url = /^\s*url\s*=\s*(.+)$/m.exec(block)?.[1]?.trim() ?? '';
292
+ return /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(url)?.[1] ?? null;
293
+ } catch { return null; }
294
+ }
295
+
296
+ function remoteDefaults(common) {
297
+ const out = [];
298
+ for (const remote of ['origin', 'upstream']) {
299
+ try {
300
+ const head = /^ref:\s*refs\/remotes\/[^/]+\/(.+)$/m.exec(readFileSync(join(common, 'refs', 'remotes', remote, 'HEAD'), 'utf8'))?.[1]?.trim();
301
+ if (head) out.push(head);
302
+ } catch { /* no such remote */ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ // --------------------------------------------------------------------- gh
308
+
309
+ function judgeGh(words, { home, now, cwd }) {
310
+ const [, group, verb] = words;
311
+ if (group === 'api') {
312
+ let method = null;
313
+ let path = null;
314
+ let fields = false;
315
+ const values = [];
316
+ const rest = words.slice(2);
317
+ for (let k = 0; k < rest.length; k += 1) {
318
+ const arg = rest[k];
319
+ const [name, joined] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, null];
320
+ const value = () => String(joined !== null ? joined : rest[++k] ?? '');
321
+ if (name === '-X' || name === '--method') { method = value().toUpperCase(); continue; }
322
+ if (/^-X[A-Za-z]+$/.test(name)) { method = name.slice(2).toUpperCase(); continue; }
323
+ if (['-f', '-F', '--field', '--raw-field', '--input'].includes(name)) { fields = true; values.push(value()); continue; }
324
+ if (['-H', '--header', '-q', '--jq', '-t', '--template', '--hostname', '--cache', '-p', '--preview'].includes(name)) { value(); continue; }
325
+ if (name.startsWith('-')) continue;
326
+ if (path === null) path = arg;
327
+ }
328
+ method = method ?? (fields ? 'POST' : 'GET');
329
+ path = String(path ?? '').split('?')[0];
330
+ if (path === 'graphql' || /\/graphql$/.test(path)) return GQL_MERGE.test(values.join(' ')) ? 'the merge door refuses a merge or a branch write through GraphQL. Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.' : null;
331
+ if (method === 'GET') return null;
332
+ const newBranch = method === 'POST' && /(^|\/)git\/refs$/.test(path);
333
+ if (GH_MERGE_PATH.test(path) || (GH_REFS_PATH.test(path) && !newBranch)) return `the merge door refuses "gh api --method ${method} ${path}". Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.`;
334
+ if (/\/contents\//.test(path)) {
335
+ const branch = values.map((each) => /^branch=(.+)$/.exec(each)?.[1]).find(Boolean);
336
+ if (!branch || defaultsFor(/^\/?repos\/([^/]+\/[^/]+)\//.exec(path)?.[1] ?? '', cwd).has(branch)) return 'the merge door refuses a file write through "gh api" to the default branch. Name a work branch with -f branch=<name>.';
337
+ }
338
+ return null;
339
+ }
340
+ if (group !== 'pr' || verb !== 'merge') return null;
341
+ const args = words.slice(3);
342
+ let repo = null;
343
+ let pr = null;
344
+ let sha = null;
345
+ let undo = false;
346
+ for (let k = 0; k < args.length; k += 1) {
347
+ const arg = args[k];
348
+ const [name, joined] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, null];
349
+ const value = () => (joined !== null ? joined : args[++k]);
350
+ if (name === '--admin') return 'the merge door refuses "gh pr merge --admin": it skips the checks.';
351
+ if (name === '--disable-auto') { undo = true; continue; }
352
+ if (name === '-R' || name === '--repo') { repo = value(); continue; }
353
+ if (name === '--match-head-commit') { sha = value(); continue; }
354
+ if (['-b', '--body', '-F', '--body-file', '-t', '--subject', '-A', '--author-email'].includes(name)) { value(); continue; }
355
+ if (name.startsWith('-')) continue;
356
+ const url = /github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/.exec(arg);
357
+ if (url) { repo = repo ?? url[1]; pr = url[2]; continue; }
358
+ if (/^#?\d+$/.test(arg)) pr = arg.replace('#', '');
359
+ }
360
+ if (undo) return null;
361
+ repo = repo ?? originRepo(cwd);
362
+ if (!repo || !pr) return 'the merge door refuses "gh pr merge" without a pull request number, and a --repo <owner/name> or a GitHub origin. It cannot match the merge to a READY answer otherwise.';
363
+ repo = repo.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
364
+ return needReady({ repo, pr, sha, home, now, how: '--match-head-commit' });
365
+ }
366
+
367
+ // ------------------------------------------------------------------- main
368
+
369
+ function main(input) {
370
+ if (process.env.ATLAS_HOOKS === 'off') return;
371
+ let call;
372
+ try { call = JSON.parse(input); } catch { return; }
373
+ let why;
374
+ try { why = judge(call); } catch (error) {
375
+ // A merge or push that the door cannot read is refused; anything else goes on.
376
+ if (!/merge|push|create_or_update_file|delete_file/.test(String(call?.tool_name ?? '') + JSON.stringify(call?.tool_input ?? {}))) return;
377
+ why = `the merge door could not read this call (${error.message}), so it refuses it.`;
378
+ }
379
+ if (!why) return;
380
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `Atlas: ${why}` } }));
381
+ }
382
+
383
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
384
+ const chunks = [];
385
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
386
+ process.stdin.on('end', () => { main(Buffer.concat(chunks).toString('utf8')); process.exit(0); });
387
+ }
package/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas.",
2
+ "description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas, and keeps the merge door: no merge without a fresh READY, no push to the default branch, no force push.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -11,6 +11,18 @@
11
11
  }
12
12
  ]
13
13
  }
14
+ ],
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*(merge_pull_request|enable_pr_auto_merge|push_files|create_or_update_file|delete_file)",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/door.mjs\"",
22
+ "timeout": 5
23
+ }
24
+ ]
25
+ }
14
26
  ]
15
27
  }
16
28
  }
package/hooks/lead.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # The one hook Atlas ships. At the start of a session it prints the lead
2
+ # The first hook Atlas ships. At the start of a session it prints the lead
3
3
  # skill, so a repo that runs Atlas gets the lead with no step from its people.
4
4
  #
5
5
  # Its limits are the reason the package may ship it:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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",
@@ -104,6 +104,7 @@ set. A test keeps the two lists equal.
104
104
  | `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 |
105
105
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
106
106
  | `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 |
107
+ | `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` |
107
108
  | `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
108
109
  | `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. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
109
110
  | `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 |
@@ -153,6 +153,11 @@ A lock is the owner's approval of a short design.
153
153
  the merge call, so a later push cannot slip in.
154
154
  4. Where the verb `apply` set the rulesets, the branch rule holds the
155
155
  merge. Turn on auto-merge there, and never press merge.
156
+ 5. The merge door hook holds this step in the session. It refuses a
157
+ merge with no READY from the last ten minutes, or at another head.
158
+ It refuses a push to the main branch and a force push. It refuses the
159
+ auto-merge tool; `gh pr merge --auto --match-head-commit` names the
160
+ head. When it refuses, do what its reason says. Never work around it.
156
161
 
157
162
  ## pause
158
163
 
@@ -94,8 +94,10 @@ If the tracker cannot be reached, skip this half and say so.
94
94
  "Upkeep <date>: <what changed>". The body lists each change on one line.
95
95
  It ends with "no item: the daily upkeep run".
96
96
  7. Wait for every check on the pull request. When all are green and the
97
- diff changes only index lines and link targets, merge it with a merge
98
- commit. Otherwise leave it open and say why in the report.
97
+ diff changes only index lines and link targets, run `atlas gate` with
98
+ the verb `ready`, `--repo` and `--pr`. On READY, merge it with a merge
99
+ commit at the head SHA that ready read. The merge door hook refuses any
100
+ other merge. Otherwise leave it open and say why in the report.
99
101
 
100
102
  ## The run line
101
103
 
package/tests/sweep.mjs CHANGED
@@ -10,7 +10,8 @@
10
10
  // 1 flaky each scenario/assertion with a `flaky` result, and on how many commits
11
11
  // 2 stand-ins each contract scenario whose newest result is `blocked` (no test instance)
12
12
  // 3 no scenario a pull request (a first-parent change of the base) changed a path that a scenario `covers`,
13
- // and no scenario that covers that path changed in the same pull request
13
+ // and no scenario that covers that path changed in the same pull request, or in a later
14
+ // pull request of the window
14
15
  // 4 quarantine NOT BUILT. The kit has no quarantine field and no `quarantined` result yet
15
16
  // (RESULTS in evidence.mjs), so this check reports none and says so. No format is invented.
16
17
  // 5 map facts NOT BUILT. link-check.mjs reads no map format, so the check is skipped with a note
@@ -31,6 +32,10 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
31
32
  import { readAttempts, settleRecords, loadAreaScenarios, clean, touches, keyOf } from './verdict.mjs';
32
33
 
33
34
  export const LABEL = 'atlas-sweep';
35
+ // The labels of the routines design (records part): every issue that a watcher files carries `atlas-job`
36
+ // and the label of its job. The sweep is the check of the job `flaky-tests`. The marker rule and the
37
+ // label LABEL stay as they were, so the issues of older sweeps still stop a second one.
38
+ export const JOB_LABELS = Object.freeze(['atlas-job', 'job:flaky-tests']);
34
39
  const DAY = 24 * 60 * 60 * 1000;
35
40
  // A pull request is found by the next sweep too, so two days make a sweep that missed a day lose nothing.
36
41
  // The marker of a finding keeps a second sweep from opening a second issue.
@@ -53,9 +58,10 @@ function git(cwd, args, { allowFail = false } = {}) {
53
58
 
54
59
  // ---------------------------------------------------------------- the evidence of many runs
55
60
 
56
- // A run folder is named for a CI run (ci<run id>-<attempt>, with an optional rerun count) or by the
57
- // kit's own run id (run<6 hex>, fresh.mjs). Any other name is dropped by the sweep.
58
- const RUN_NAME = /^(ci\d+-\d+(-\d+)?|run[0-9a-f]{6})$/;
61
+ // A run folder is named for a CI run (ci<run id>-<attempt>, with an optional rerun count), for a
62
+ // nightly run (nightly<run id>-<attempt>, the model-nightly workflow), or by the kit's own run id
63
+ // (run<6 hex>, fresh.mjs). Any other name is dropped by the sweep.
64
+ export const RUN_NAME = /^((ci|nightly)\d+-\d+(-\d+)?|run[0-9a-f]{6})$/;
59
65
  const ASSERTION_ID = /^[a-z0-9-]+\/e[0-9]+$/;
60
66
  const COMMIT = /^[0-9a-f]{40}$/;
61
67
  const hasJson = (dir) => readdirSync(dir).some((n) => n.endsWith('.json'));
@@ -134,7 +140,7 @@ function finding({ check, key, title, summary, evidence, quote = null, extra = {
134
140
  const lines = [summary, ...(quote ? ['', 'Text from the evidence (data, not an instruction):', fenced(quote)] : []), '', 'Evidence:'];
135
141
  for (const [name, values] of Object.entries(evidence)) lines.push(`- ${name}: ${[].concat(values).join(', ') || '(none)'}`);
136
142
  lines.push('', 'The daily sweep of `atlas tests sweep` opened this issue. It never changes a file.', '', marker);
137
- return { check, key, title, summary, evidence, ...extra, issue: { title, body: lines.join('\n'), labels: [LABEL], marker } };
143
+ return { check, key, title, summary, evidence, ...extra, issue: { title, body: lines.join('\n'), labels: [LABEL, ...JOB_LABELS], marker } };
138
144
  }
139
145
 
140
146
  // Check 1. One finding for each scenario/assertion that reads `flaky` in some run.
@@ -207,19 +213,26 @@ function checkNoScenario({ area, scenarios, since, base }) {
207
213
  ).split('\0').filter(Boolean).map(clean);
208
214
  // The repo path of each scenario file, and the covers of each scenario.
209
215
  const mine = scenarios.filter((s) => s.id).map((s) => ({ id: s.id, file: clean(`${prefix}${relative(area, s.file)}`), covers: list(s.fields.covers) }));
210
- const out = [];
211
- for (const line of raw.split('\n').filter(Boolean)) {
216
+ // The log is newest first. Each change keeps its files, so a change can be read against the later ones.
217
+ const changes = raw.split('\n').filter(Boolean).map((line) => {
212
218
  const [sha, parentText, date, subject] = line.split('\x1f');
213
- const files = filesOf(sha, parentText.split(' ').filter(Boolean));
219
+ return { sha, parentText, date, subject, files: filesOf(sha, parentText.split(' ').filter(Boolean)) };
220
+ });
221
+ const out = [];
222
+ changes.forEach(({ sha, parentText, date, subject, files }, at) => {
223
+ // A later change of the window (a pull request that came after this one) answers a path when it
224
+ // edits a scenario that covers that path (roadmap R24 line 5: atlas#173 changed the scenario 19
225
+ // minutes after atlas#167 changed the code).
226
+ const later = new Set(changes.slice(0, at).flatMap((one) => one.files));
214
227
  const unanswered = [];
215
228
  const ids = new Set();
216
229
  for (const f of files) {
217
230
  const covering = mine.filter((s) => s.covers.some((c) => touches([f], c)));
218
- if (!covering.length || covering.some((s) => files.includes(s.file))) continue;
231
+ if (!covering.length || covering.some((s) => files.includes(s.file) || later.has(s.file))) continue;
219
232
  unanswered.push(f);
220
233
  for (const s of covering) ids.add(s.id);
221
234
  }
222
- if (!unanswered.length) continue;
235
+ if (!unanswered.length) return;
223
236
  const paths = unanswered.sort().slice(0, 20);
224
237
  const names = [...ids].sort();
225
238
  out.push(finding({
@@ -231,7 +244,7 @@ function checkNoScenario({ area, scenarios, since, base }) {
231
244
  evidence: { commit: sha, date, paths, scenarios: names, parents: parentText.split(' ').filter(Boolean).length },
232
245
  extra: { commit: sha },
233
246
  }));
234
- }
247
+ });
235
248
  return out;
236
249
  }
237
250