@arjunkhera/atlas 0.3.50 → 0.3.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.50",
3
+ "version": "0.3.52",
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
@@ -137,6 +137,10 @@ const HELP = `atlas — the door into a repo's Atlas files
137
137
  --root <repo> the checkout to read atlas.yaml from (default: this folder)
138
138
  --dry-run print each call; change nothing
139
139
  --off | --on break glass: turn both rulesets off, or back on
140
+ On a GitHub plan with no rulesets, apply changes nothing and reports advisory mode.
141
+ atlas gate ready --repo <owner/name> --pr <n> may this pull request merge: every required check green on its head
142
+ atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
143
+ atlas gate audit --repo <owner/name> --commit <sha> one commit on the main branch: red when no pull request merged it, or one merged it before its checks were green
140
144
 
141
145
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
142
146
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
@@ -177,7 +181,7 @@ export const FLAGS = Object.freeze({
177
181
  view: { product: 'value', name: 'value', out: 'value' },
178
182
  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' },
179
183
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
180
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch' },
184
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
181
185
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
182
186
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
183
187
  upgrade: { version: 'optional', yes: 'switch' },
@@ -414,7 +418,7 @@ function upkeepVerb(chosen) {
414
418
  }
415
419
 
416
420
  function gateVerb(chosen) {
417
- return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on) }, line);
421
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit }, line);
418
422
  }
419
423
 
420
424
  function judgeVerb(chosen) {
package/door/lib/gate.mjs CHANGED
@@ -6,6 +6,12 @@
6
6
  // 2. "atlas release tags" on v* tags: no update, no delete.
7
7
  // The bypass list stays empty. --off and --on are the break glass.
8
8
  //
9
+ // A private repo on a free GitHub plan gets no rulesets. The owner chose on 24
10
+ // October 2026 to stay on that plan (decision dd90d8e8). There the
11
+ // gate is advisory: apply changes nothing and says so, `atlas gate ready`
12
+ // says if one pull request may merge, and `atlas gate audit` names each merge
13
+ // that came before its checks were green (design docs/design-docs/gate-advisory/).
14
+ //
9
15
  // The plan is built by pure functions, so a test reads it without GitHub. The
10
16
  // gh calls sit in one place, behind `gh`, which a test replaces.
11
17
  import { execFileSync } from 'node:child_process';
@@ -26,7 +32,11 @@ export class GateError extends Error {}
26
32
  export function gateFacts(root) {
27
33
  const path = join(root, 'atlas.yaml');
28
34
  if (!existsSync(path)) throw new GateError(`there is no atlas.yaml in ${root}. Nothing was changed.`);
29
- const facts = parseYaml(readFileSync(path, 'utf8')) ?? {};
35
+ return factsOf(readFileSync(path, 'utf8'));
36
+ }
37
+
38
+ function factsOf(text) {
39
+ const facts = parseYaml(text) ?? {};
30
40
  const checks = facts.required_checks;
31
41
  if (!Array.isArray(checks) || checks.length === 0 || checks.some((name) => typeof name !== 'string' || !name.trim())) {
32
42
  throw new GateError('atlas.yaml lists no required_checks. Nothing was changed.');
@@ -141,20 +151,135 @@ export function compare(wanted, got) {
141
151
  export function ghApi(method, path, body) {
142
152
  const args = ['api', '--method', method, path, '-H', 'Accept: application/vnd.github+json'];
143
153
  if (body !== undefined) args.push('--input', '-');
144
- const out = execFileSync('gh', args, { encoding: 'utf8', input: body === undefined ? undefined : JSON.stringify(body), env: childEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] });
154
+ const out = execFileSync('gh', args, { encoding: 'utf8', input: body === undefined ? undefined : JSON.stringify(body), env: childEnvironment(), stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
145
155
  return out.trim() ? JSON.parse(out) : null;
146
156
  }
147
157
 
158
+ // GitHub's answer when the repo's plan has no rulesets.
159
+ const PLAN_REFUSAL = /Upgrade to GitHub Pro|make this repository public/i;
160
+ export const ADVISORY = 'advisory mode: the checks run on each pull request, but GitHub does not block a merge on this plan. Merge only when "atlas gate ready" says READY.';
161
+
148
162
  const explain = (error) => {
149
163
  const text = `${error.stderr ?? ''}${error.message ?? ''}`;
150
164
  if (/HTTP 403|Upgrade to GitHub Pro|403/.test(text)) return 'GitHub refused the ruleset (403). A private repo needs GitHub Pro for rulesets, and only a repo admin may set one.';
151
165
  return text.trim().split('\n').slice(-3).join(' ');
152
166
  };
153
167
 
154
- // `atlas gate apply`. Returns the exit code.
155
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false }, line, gh = ghApi) {
156
- if (what !== 'apply') throw new GateError(`there is no gate verb "${what ?? ''}". Use apply.`);
157
- if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError('atlas gate apply needs --repo <owner/name>.');
168
+ // The state of each required check on one commit, from its GitHub Actions
169
+ // check runs. `at` is a time: a run that ended after it does not count.
170
+ // A check is green only when its newest run from GitHub Actions ended in success.
171
+ export function checkStates(checks, runs, at = null) {
172
+ return checks.map((name) => {
173
+ const mine = (runs ?? [])
174
+ .filter((run) => run.name === name && run.app?.id === ACTIONS_APP_ID)
175
+ .filter((run) => at === null || (run.started_at ?? run.completed_at ?? '') <= at)
176
+ .sort((a, b) => String(b.started_at ?? '').localeCompare(String(a.started_at ?? '')));
177
+ const newest = mine[0];
178
+ if (!newest) return { name, state: 'missing' };
179
+ if (newest.status !== 'completed' || (at !== null && (newest.completed_at ?? '') > at)) return { name, state: 'pending' };
180
+ return { name, state: newest.conclusion === 'success' ? 'green' : 'red', conclusion: newest.conclusion };
181
+ });
182
+ }
183
+
184
+ // The required checks as atlas.yaml listed them at one commit on GitHub. Ready
185
+ // reads the base branch, so a pull request cannot drop its own red check, and
186
+ // audit reads the base of each merge, so a later rename gives no false alarm.
187
+ function checksAt(gh, repo, ref) {
188
+ const file = gh('GET', `repos/${repo}/contents/atlas.yaml?ref=${encodeURIComponent(ref)}`);
189
+ if (typeof file?.content !== 'string') throw new GateError(`there is no atlas.yaml in ${repo} at ${ref}.`);
190
+ return factsOf(Buffer.from(file.content, 'base64').toString('utf8')).checks;
191
+ }
192
+
193
+ const checkRuns = (gh, repo, sha) => gh('GET', `repos/${repo}/commits/${sha}/check-runs?per_page=100`)?.check_runs ?? [];
194
+ const checkLine = (row) => ` ${row.state === 'green' ? '✓' : '✗'} ${row.state.padEnd(8)} ${row.name}${row.conclusion && row.state === 'red' ? ` (${row.conclusion})` : ''}`;
195
+
196
+ // `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
197
+ // every required check is green on its head commit, 1 when not.
198
+ export function readyCommand({ repo, pr }, line, gh = ghApi) {
199
+ if (!pr || pr === true || !/^\d+$/.test(String(pr))) throw new GateError('atlas gate ready needs --pr <number>.');
200
+ const pull = gh('GET', `repos/${repo}/pulls/${pr}`);
201
+ if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
202
+ const sha = pull.head.sha;
203
+ const checks = checksAt(gh, repo, pull.base.ref);
204
+ const rows = checkStates(checks, checkRuns(gh, repo, sha));
205
+ line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
206
+ for (const row of rows) line(checkLine(row));
207
+ const ready = rows.every((row) => row.state === 'green');
208
+ line(ready ? 'READY: every required check is green. Merge with a merge commit, at this head.' : 'NOT READY: do not merge. Wait for a pending check; fix a red or missing one.');
209
+ return ready ? 0 : 1;
210
+ }
211
+
212
+ // `atlas gate audit --since <date>`: each pull request merged since the date
213
+ // whose required checks were not all green at the moment of the merge.
214
+ export function auditCommand({ repo, since, commit }, line, gh = ghApi) {
215
+ if (commit !== undefined) return commitAudit({ repo, commit }, line, gh);
216
+ const from = since && since !== true ? since : new Date(Date.now() - 864e5).toISOString().slice(0, 10);
217
+ if (Number.isNaN(Date.parse(from))) throw new GateError(`--since must be a date, not "${from}".`);
218
+ const closed = gh('GET', `repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`) ?? [];
219
+ const merged = closed.filter((pull) => pull.merged_at && pull.merged_at >= new Date(from).toISOString());
220
+ line(`atlas gate audit: ${merged.length} pull request(s) merged into ${repo} since ${from}.`);
221
+ if (closed.length >= 100 && (closed.at(-1)?.updated_at ?? '') >= new Date(from).toISOString()) line(' (only the 100 closed pull requests updated last were read; give a later --since to read all of them)');
222
+ const lists = new Map();
223
+ const checksOf = (sha) => {
224
+ if (!lists.has(sha)) { try { lists.set(sha, checksAt(gh, repo, sha)); } catch (error) { if (!(error instanceof GateError)) throw error; lists.set(sha, null); } }
225
+ return lists.get(sha);
226
+ };
227
+ let red = 0;
228
+ let ungated = 0;
229
+ for (const pull of merged) {
230
+ const checks = checksOf(pull.base.sha);
231
+ if (!checks) { ungated += 1; continue; }
232
+ const rows = checkStates(checks, checkRuns(gh, repo, pull.head.sha), pull.merged_at);
233
+ const bad = rows.filter((row) => row.state !== 'green');
234
+ if (!bad.length) continue;
235
+ red += 1;
236
+ line(` ✗ #${pull.number} merged ${pull.merged_at} before its checks were green: ${bad.map((row) => `${row.name} ${row.state}`).join(', ')}`);
237
+ }
238
+ if (ungated) line(` (${ungated} merge(s) had a base whose atlas.yaml listed no required_checks; nothing to audit there)`);
239
+ line(red ? `RED MERGES: ${red}. Look at each one now; the main branch may hold an untested change.` : 'every merge came after its required checks were green.');
240
+ return red ? 1 : 0;
241
+ }
242
+
243
+ // `atlas gate audit --commit <sha>`: one commit that reached the main branch.
244
+ // The push job runs it on each push. A commit is good when a pull request
245
+ // merged it after that pull request's required checks were green. A commit
246
+ // that no pull request merged was pushed past the gate, so it is red.
247
+ function commitAudit({ repo, commit }, line, gh) {
248
+ if (!commit || commit === true || !/^[0-9a-f]{7,40}$/i.test(String(commit))) throw new GateError('--commit needs a commit SHA.');
249
+ // GitHub expands a short SHA, and names the first parent: the main branch
250
+ // just before this commit, whose atlas.yaml held the gate at that moment.
251
+ const found = gh('GET', `repos/${repo}/commits/${commit}`);
252
+ const sha = found?.sha;
253
+ if (!sha) throw new GateError(`there is no commit ${commit} in ${repo}.`);
254
+ const pull = (gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each.merged_at && each.merge_commit_sha === sha);
255
+ if (!pull) {
256
+ line(`- ✗ ${sha.slice(0, 7)} reached the main branch with no pull request: a push past the gate.`);
257
+ line('RED PUSH: look at it now; the main branch may hold an untested change.');
258
+ return 1;
259
+ }
260
+ const parent = found.parents?.[0]?.sha ?? pull.base.sha;
261
+ let checks;
262
+ try { checks = checksAt(gh, repo, parent); } catch (error) {
263
+ if (!(error instanceof GateError)) throw error;
264
+ line(`- atlas gate audit: ${sha.slice(0, 7)} is the merge of #${pull.number}; atlas.yaml before it listed no required_checks, so nothing to audit.`);
265
+ return 0;
266
+ }
267
+ const bad = checkStates(checks, checkRuns(gh, repo, pull.head.sha), pull.merged_at).filter((row) => row.state !== 'green');
268
+ if (bad.length) {
269
+ line(`- ✗ #${pull.number} merged ${pull.merged_at} before its checks were green: ${bad.map((row) => `${row.name} ${row.state}`).join(', ')}`);
270
+ line('RED MERGES: 1. Look at it now; the main branch may hold an untested change.');
271
+ return 1;
272
+ }
273
+ line(`- atlas gate audit: ${sha.slice(0, 7)} is the merge of #${pull.number}, after its ${checks.length} required check(s) were green.`);
274
+ return 0;
275
+ }
276
+
277
+ // `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) {
279
+ if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
280
+ 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);
282
+ if (what === 'audit') return auditCommand({ repo, since, commit }, line, gh);
158
283
  if (off && on) throw new GateError('pick --off or --on, not both.');
159
284
  const mode = off ? 'off' : on ? 'on' : 'apply';
160
285
  const facts = mode === 'apply' ? gateFacts(root) : null;
@@ -163,6 +288,11 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
163
288
  line(`GET repos/${repo}/rulesets`);
164
289
  let existing;
165
290
  try { existing = gh('GET', `repos/${repo}/rulesets`) ?? []; } catch (error) {
291
+ if (mode === 'apply' && PLAN_REFUSAL.test(`${error.stderr ?? ''}${error.message ?? ''}`)) {
292
+ line(`atlas gate apply: ${ADVISORY}`);
293
+ line('Nothing was changed. Auto-merge stays off: with no ruleset, GitHub would merge at once, before the checks end.');
294
+ return 0;
295
+ }
166
296
  if (!dryRun) { line(`atlas gate apply: ${explain(error)} Nothing was changed.`); return 1; }
167
297
  line(` (the read failed: ${explain(error)} The plan below assumes no ruleset exists.)`);
168
298
  existing = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.50",
3
+ "version": "0.3.52",
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",
@@ -105,7 +105,7 @@ set. A test keeps the two lists equal.
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
107
  | `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
- | `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 |
108
+ | `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
109
  | `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 |
110
110
 
111
111
  A person merges every file that `atlas tooling` writes.
@@ -144,6 +144,15 @@ A lock is the owner's approval of a short design.
144
144
  posts its own proof comment. When it returns a comment file instead,
145
145
  post that file unchanged. Never edit that comment. Ask the owner for the
146
146
  merge only after the proof is there, and show the proof first.
147
+ 8. **Merge only on green.** On a plan with no rulesets, GitHub blocks
148
+ nothing, so this step is the gate (owner, 24 October 2026).
149
+ 1. Right before the merge, run `atlas gate` with the verb `ready`,
150
+ `--repo` and `--pr`.
151
+ 2. A pending check: wait for it. A red or missing check: fix it.
152
+ 3. Merge only when it prints READY. Name the head SHA that it read in
153
+ the merge call, so a later push cannot slip in.
154
+ 4. Where the verb `apply` set the rulesets, the branch rule holds the
155
+ merge. Turn on auto-merge there, and never press merge.
147
156
 
148
157
  ## pause
149
158
 
@@ -65,6 +65,10 @@ Pass the product to every read. Show no other product.
65
65
  4. **Read only, report only.** Call `sweep_check` and `needs_me` for the
66
66
  product. Put any mismatch, and any question older than seven days, in the
67
67
  report. Do not act on them.
68
+ 5. **Red merges.** When `atlas.yaml` lists `required_checks`, run
69
+ `atlas gate` with the verb `audit`, `--repo` and `--since` set to
70
+ yesterday's date. Put each merge it names in the report, under "Left for
71
+ the owner", with its pull request. Do not revert it.
68
72
 
69
73
  If the tracker cannot be reached, skip this half and say so.
70
74
 
@@ -106,7 +110,7 @@ Upkeep <date> · Atlas <version>
106
110
  Marked: <n> (<item ids and pull requests>)
107
111
  Next steps fixed: <n> · Comments: <n> · Issues closed: <n>
108
112
  Docs: <n> fixes, <merged #n | open #n, why | none>
109
- Left for the owner: <sweep mismatch, old questions, unclear links, or none>
113
+ Left for the owner: <red merges, sweep mismatch, old questions, unclear links, or none>
110
114
  ```
111
115
 
112
116
  ## Example