@arjunkhera/atlas 0.3.49 → 0.3.51

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.49",
3
+ "version": "0.3.51",
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
@@ -17,7 +17,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { open } from './lib/pull-request.mjs';
19
19
  import { write as writeTooling, changes as toolingChanges, ToolingRefusal, CHECK_PATH, WORKFLOW_PATH } from './lib/tooling.mjs';
20
- import { status, statusMany } from './lib/status.mjs';
20
+ import { statusMany, copies } from './lib/status.mjs';
21
21
  import { drift, kindsFromRegistry, fileKind } from './lib/kind-drift.mjs';
22
22
  import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
23
23
  import { packagePackageVersion } from './lib/releases.mjs';
@@ -50,6 +50,8 @@ const HELP = `atlas — the door into a repo's Atlas files
50
50
  --replace-unknown also replace a copy that is in no release
51
51
  --workflow rewrite only the workflow, for a repo whose check is current
52
52
  atlas status --root <repo> is the repo's check current, behind, unknown or missing
53
+ --all every Atlas copy in the repo: the check, its workflow, each test kit, each pinned Atlas version
54
+ --json print the rows as JSON: type, path, state, have, want, why
53
55
  atlas kind-drift --root <repo> --record-kind <kinds>
54
56
  does atlas.yaml agree with the repo record
55
57
  atlas check --root <repo> run the repo shape check here
@@ -135,6 +137,9 @@ const HELP = `atlas — the door into a repo's Atlas files
135
137
  --root <repo> the checkout to read atlas.yaml from (default: this folder)
136
138
  --dry-run print each call; change nothing
137
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
138
143
 
139
144
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
140
145
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
@@ -167,7 +172,7 @@ A person merges every file the tooling writer writes.
167
172
  // skipped the privacy filter and printed GREEN (review of PR #107).
168
173
  export const FLAGS = Object.freeze({
169
174
  tooling: { root: 'optional', branch: 'optional', 'replace-unknown': 'switch', workflow: 'switch', 'dry-run': 'switch' },
170
- status: { root: 'optional' },
175
+ status: { root: 'optional', all: 'switch', json: 'switch' },
171
176
  'kind-drift': { root: 'optional', 'record-kind': 'value', registry: 'value', repo: 'value' },
172
177
  check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
173
178
  ste: { share: 'switch', terms: 'value' },
@@ -175,7 +180,7 @@ export const FLAGS = Object.freeze({
175
180
  view: { product: 'value', name: 'value', out: 'value' },
176
181
  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' },
177
182
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
178
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch' },
183
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value' },
179
184
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
180
185
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
181
186
  upgrade: { version: 'optional', yes: 'switch' },
@@ -250,6 +255,7 @@ function toolingCommand(chosen) {
250
255
 
251
256
  function statusCommand(chosen) {
252
257
  const roots = chosen._.slice(1).length ? chosen._.slice(1).map((one) => resolve(one)) : [rootOf(chosen)];
258
+ if (chosen.all || chosen.json) return copiesCommand(roots, { all: Boolean(chosen.all), json: Boolean(chosen.json) });
253
259
  const rows = statusMany(roots, PACKAGE_ROOT);
254
260
  for (const row of rows) {
255
261
  line(`${row.state.toUpperCase().padEnd(8)} ${row.root}`);
@@ -262,6 +268,29 @@ function statusCommand(chosen) {
262
268
  return rows.some((row) => row.state === 'behind') ? 1 : 0;
263
269
  }
264
270
 
271
+ // One row for each Atlas copy (roadmap item R19). Exit 1 when a copy is
272
+ // behind or broken, so a watcher can act on the exit alone.
273
+ function copiesCommand(roots, { all, json }) {
274
+ const repos = roots.map((root) => copies({ root, packageRoot: PACKAGE_ROOT, all }));
275
+ const decided = `Decided by the release lists in Atlas ${ATLAS_VERSION}, never by git history or the network.`;
276
+ if (json) {
277
+ line(JSON.stringify({ atlas: ATLAS_VERSION, decided_by: decided, repos }, null, 2));
278
+ } else {
279
+ for (const repo of repos) {
280
+ line(repo.root);
281
+ for (const one of repo.rows) {
282
+ line(` ${one.state.toUpperCase().padEnd(8)} ${one.type.padEnd(8)} ${one.path}`);
283
+ line(` have ${one.have ?? '—'} · want ${one.want}`);
284
+ if (one.state !== 'current' || one.have !== one.want) line(` ${one.why}`);
285
+ }
286
+ line('');
287
+ }
288
+ line(decided);
289
+ }
290
+ const bad = all ? ['behind', 'broken'] : ['behind'];
291
+ return repos.some((repo) => repo.rows.some((one) => bad.includes(one.state))) ? 1 : 0;
292
+ }
293
+
265
294
  function kindDriftCommand(chosen) {
266
295
  const root = rootOf(chosen);
267
296
  let recordKind = chosen['record-kind'] ?? null;
@@ -388,7 +417,7 @@ function upkeepVerb(chosen) {
388
417
  }
389
418
 
390
419
  function gateVerb(chosen) {
391
- 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);
420
+ 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 }, line);
392
421
  }
393
422
 
394
423
  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 (owner, 24 October
10
+ // 2026: "ignore pro and assume going with that check in place"). 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,100 @@ 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 }, line, gh = ghApi) {
215
+ const from = since && since !== true ? since : new Date(Date.now() - 864e5).toISOString().slice(0, 10);
216
+ if (Number.isNaN(Date.parse(from))) throw new GateError(`--since must be a date, not "${from}".`);
217
+ const closed = gh('GET', `repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`) ?? [];
218
+ const merged = closed.filter((pull) => pull.merged_at && pull.merged_at >= new Date(from).toISOString());
219
+ line(`atlas gate audit: ${merged.length} pull request(s) merged into ${repo} since ${from}.`);
220
+ 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)');
221
+ const lists = new Map();
222
+ const checksOf = (sha) => {
223
+ if (!lists.has(sha)) { try { lists.set(sha, checksAt(gh, repo, sha)); } catch (error) { if (!(error instanceof GateError)) throw error; lists.set(sha, null); } }
224
+ return lists.get(sha);
225
+ };
226
+ let red = 0;
227
+ let ungated = 0;
228
+ for (const pull of merged) {
229
+ const checks = checksOf(pull.base.sha);
230
+ if (!checks) { ungated += 1; continue; }
231
+ const rows = checkStates(checks, checkRuns(gh, repo, pull.head.sha), pull.merged_at);
232
+ const bad = rows.filter((row) => row.state !== 'green');
233
+ if (!bad.length) continue;
234
+ red += 1;
235
+ line(` ✗ #${pull.number} merged ${pull.merged_at} before its checks were green: ${bad.map((row) => `${row.name} ${row.state}`).join(', ')}`);
236
+ }
237
+ if (ungated) line(` (${ungated} merge(s) had a base whose atlas.yaml listed no required_checks; nothing to audit there)`);
238
+ 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.');
239
+ return red ? 1 : 0;
240
+ }
241
+
242
+ // `atlas gate apply|ready|audit`. Returns the exit code.
243
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since }, line, gh = ghApi) {
244
+ if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
245
+ if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
246
+ if (what === 'ready') return readyCommand({ repo, pr }, line, gh);
247
+ if (what === 'audit') return auditCommand({ repo, since }, line, gh);
158
248
  if (off && on) throw new GateError('pick --off or --on, not both.');
159
249
  const mode = off ? 'off' : on ? 'on' : 'apply';
160
250
  const facts = mode === 'apply' ? gateFacts(root) : null;
@@ -163,6 +253,11 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
163
253
  line(`GET repos/${repo}/rulesets`);
164
254
  let existing;
165
255
  try { existing = gh('GET', `repos/${repo}/rulesets`) ?? []; } catch (error) {
256
+ if (mode === 'apply' && PLAN_REFUSAL.test(`${error.stderr ?? ''}${error.message ?? ''}`)) {
257
+ line(`atlas gate apply: ${ADVISORY}`);
258
+ line('Nothing was changed. Auto-merge stays off: with no ruleset, GitHub would merge at once, before the checks end.');
259
+ return 0;
260
+ }
166
261
  if (!dryRun) { line(`atlas gate apply: ${explain(error)} Nothing was changed.`); return 1; }
167
262
  line(` (the read failed: ${explain(error)} The plan below assumes no ruleset exists.)`);
168
263
  existing = [];
@@ -13,10 +13,12 @@
13
13
  //
14
14
  // CI never makes this judgement. A check with no network cannot learn the
15
15
  // package's list. This runs in a session, with the package on disk.
16
- import { readFileSync, existsSync } from 'node:fs';
17
- import { join } from 'node:path';
18
- import { CHECK_PATH, checkStamps, sha256 } from '../../shape/check.mjs';
19
- import { packagedCheck, placeOf } from './releases.mjs';
16
+ import { readFileSync, existsSync, readdirSync, lstatSync } from 'node:fs';
17
+ import { join, relative, sep } from 'node:path';
18
+ import { CHECK_PATH, WORKFLOW_PATH, WORKFLOW_TEMPLATE, checkStamps, sha256 } from '../../shape/check.mjs';
19
+ import { packagedCheck, placeOf, packagePackageVersion } from './releases.mjs';
20
+ import { currentKitHash, KIT_RELEASES_FILE, KIT_TARGET, MANIFEST } from './tests.mjs';
21
+ import { kitHashOf } from '../../tests/link-check.mjs';
20
22
 
21
23
  export const STATES = Object.freeze(['current', 'behind', 'unknown', 'missing']);
22
24
 
@@ -45,3 +47,220 @@ export function status({ root, packageRoot }) {
45
47
  export function statusMany(roots, packageRoot) {
46
48
  return roots.map((root) => { try { return status({ root, packageRoot }); } catch (error) { return { root, state: 'unknown', why: error.message, packaged: null, copy: null }; } });
47
49
  }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Every Atlas copy in a repository, and the age of each one (roadmap item
53
+ // R19, slice 0 of the routines design). The check is one copy of four types:
54
+ //
55
+ // check .github/atlas/check.mjs, by the release list of the check
56
+ // workflow .github/workflows/atlas-check.yml, by the workflow fingerprint
57
+ // in each row of that list
58
+ // kit each <area>/test/atlas/, by its manifest and the kit list
59
+ // pin an Atlas version written in a workflow, a script or package.json
60
+ //
61
+ // Each row has have, want and state. A copy that cannot be read is broken,
62
+ // never missing. Like the check alone, every answer comes from the lists this
63
+ // package carries, never from git history or the network.
64
+ export const COPY_STATES = Object.freeze(['current', 'behind', 'unknown', 'missing', 'broken']);
65
+ export const COPY_TYPES = Object.freeze(['check', 'workflow', 'kit', 'pin']);
66
+
67
+ const posix = (path) => path.split(sep).join('/');
68
+ const SKIP = new Set(['node_modules', '.git']);
69
+ const MAX_DEPTH = 8;
70
+ const MAX_PIN_FILE = 512 * 1024;
71
+
72
+ const row = (type, path, state, have, want, why) => ({ type, path, state, have, want, why });
73
+ const broken = (type, path, want, error) => row(type, path, 'broken', null, want, `it cannot be read: ${error.message}`);
74
+ // A link is never read. A link at a copy's path, even one that points
75
+ // nowhere, is a copy that cannot be read: broken, never missing.
76
+ const linkAt = (full) => { try { return lstatSync(full).isSymbolicLink(); } catch { return false; } };
77
+ const linked = (type, path, want) => row(type, path, 'broken', null, want, `${path} is a link. Atlas never reads a copy through a link. Replace it with a plain file or folder.`);
78
+
79
+ function checkRow(root, packageRoot) {
80
+ const pkg = packagedCheck(packageRoot);
81
+ const want = `check version ${checkStamps(pkg.text).check_version}`;
82
+ if (linkAt(join(root, CHECK_PATH))) return linked('check', CHECK_PATH, want);
83
+ let answer;
84
+ try { answer = status({ root, packageRoot }); } catch (error) { return broken('check', CHECK_PATH, want, error); }
85
+ const have = answer.copy ? (answer.state === 'unknown' ? `sha256 ${answer.copy.sha256.slice(0, 12)}…` : `check version ${answer.copy.check_version}`) : null;
86
+ return row('check', CHECK_PATH, answer.state, have, want, answer.why);
87
+ }
88
+
89
+ // The workflow is written with the check, from the WORKFLOW_TEMPLATE of that
90
+ // check. Each row of the release list keeps the fingerprint of its template.
91
+ function workflowRow(root, packageRoot) {
92
+ const pkg = packagedCheck(packageRoot);
93
+ const version = checkStamps(pkg.text).check_version;
94
+ const want = `workflow of check version ${version}`;
95
+ const full = join(root, WORKFLOW_PATH);
96
+ if (linkAt(full)) return linked('workflow', WORKFLOW_PATH, want);
97
+ if (!existsSync(full)) return row('workflow', WORKFLOW_PATH, 'missing', null, want, `${WORKFLOW_PATH} does not exist in this repo, so its check never runs in CI. "atlas tooling pr" adds it.`);
98
+ let text;
99
+ try { text = readFileSync(full, 'utf8'); } catch (error) { return broken('workflow', WORKFLOW_PATH, want, error); }
100
+ const fingerprint = sha256(text);
101
+ if (fingerprint === sha256(WORKFLOW_TEMPLATE)) return row('workflow', WORKFLOW_PATH, 'current', want, want, 'the workflow is the one this package writes.');
102
+ const rows = pkg.releases.filter((one) => one.workflow_sha256 === fingerprint);
103
+ if (rows.length) {
104
+ const newest = rows.at(-1);
105
+ return row('workflow', WORKFLOW_PATH, 'behind', `workflow of check version ${newest.check_version}`, want, `the workflow was first written by Atlas ${rows[0].first_package}. "atlas tooling pr --workflow" refreshes it.`);
106
+ }
107
+ return row('workflow', WORKFLOW_PATH, 'unknown', `sha256 ${fingerprint.slice(0, 12)}…`, want, `the workflow is in no release. Someone edited it, or it came from an unreleased package. "atlas tooling pr" shows the difference.`);
108
+ }
109
+
110
+ // A walk never follows a link and never enters node_modules or .git. The
111
+ // visit sees a link as a link, and says which other folders it enters: it
112
+ // returns false to stop.
113
+ function walk(dir, visit, depth = 0) {
114
+ if (depth > MAX_DEPTH) return;
115
+ let names;
116
+ try { names = readdirSync(dir).sort(); } catch { return; }
117
+ for (const name of names) {
118
+ if (SKIP.has(name)) continue;
119
+ const full = join(dir, name);
120
+ let stat;
121
+ try { stat = lstatSync(full); } catch { continue; }
122
+ if (visit(full, name, stat) === false) continue;
123
+ if (stat.isDirectory()) walk(full, visit, depth + 1);
124
+ }
125
+ }
126
+
127
+ // Each <area>/test/atlas/ folder, where "atlas tests write" puts the kit. A
128
+ // dot folder holds no area: the check's base tree is one.
129
+ function kitFolders(root) {
130
+ const found = [];
131
+ const [parent, leaf] = KIT_TARGET.split('/');
132
+ walk(root, (full, name, stat) => {
133
+ const isKit = name === leaf && full.split(sep).at(-2) === parent;
134
+ if (isKit && stat.isSymbolicLink()) { found.push(full); return false; }
135
+ if (!stat.isDirectory() || name.startsWith('.')) return false;
136
+ if (isKit) { found.push(full); return false; }
137
+ return true;
138
+ });
139
+ return found;
140
+ }
141
+
142
+ function kitFilesOnDisk(dir, prefix = '') {
143
+ const out = [];
144
+ for (const name of readdirSync(dir).sort()) {
145
+ if (name === '__pycache__') continue;
146
+ const full = join(dir, name);
147
+ const stat = lstatSync(full);
148
+ if (stat.isDirectory()) out.push(...kitFilesOnDisk(full, `${prefix}${name}/`));
149
+ else out.push({ path: `${prefix}${name}`, full, link: stat.isSymbolicLink() });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function kitRow(root, dir, ctx) {
155
+ const path = `${posix(relative(root, dir))}/`;
156
+ const area = posix(relative(root, join(dir, '..', '..'))) || '.';
157
+ const want = `kit of Atlas ${ctx.version}`;
158
+ const refresh = `"atlas tests write --root ${area}" refreshes it.`;
159
+ if (linkAt(dir)) return linked('kit', path, want);
160
+ try {
161
+ const manifestPath = join(dir, MANIFEST);
162
+ if (!existsSync(manifestPath)) return row('kit', path, 'broken', null, want, `the kit has no ${MANIFEST}, so its age cannot be read. ${refresh}`);
163
+ let manifest;
164
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return row('kit', path, 'broken', null, want, `${MANIFEST} is not JSON, so the kit's age cannot be read. ${refresh}`); }
165
+ const listed = manifest?.files;
166
+ if (!listed || typeof listed !== 'object' || Array.isArray(listed)) return row('kit', path, 'broken', null, want, `${MANIFEST} lists no files, so the kit's age cannot be read. ${refresh}`);
167
+ const have = `kit of Atlas ${typeof manifest.atlas === 'string' ? manifest.atlas : '?'}`;
168
+ const onDisk = kitFilesOnDisk(dir).filter((one) => one.path !== MANIFEST);
169
+ const edited = [];
170
+ for (const one of onDisk) {
171
+ if (one.link || !Object.hasOwn(listed, one.path) || sha256(readFileSync(one.full)) !== listed[one.path]) edited.push(one.path);
172
+ }
173
+ const present = new Set(onDisk.map((one) => one.path));
174
+ for (const listedPath of Object.keys(listed)) if (!present.has(listedPath)) edited.push(listedPath);
175
+ if (edited.length) return row('kit', path, 'unknown', have, want, `${edited.length} file(s) differ from ${MANIFEST}: ${edited.slice(0, 3).join(', ')}. Someone edited the kit. ${refresh}`);
176
+ const hash = kitHashOf(listed);
177
+ if (hash === ctx.kitHash) return row('kit', path, 'current', have, want, `the kit is the one this package ships${have === want ? '' : '; only its version label is older'}.`);
178
+ const first = ctx.kitReleases.get(hash);
179
+ if (first) return row('kit', path, 'behind', have, want, `the kit was first shipped in Atlas ${first}. ${refresh}`);
180
+ return row('kit', path, 'unknown', have, want, `the kit is in no release. It came from an unreleased package, or someone edited its ${MANIFEST}. ${refresh}`);
181
+ } catch (error) {
182
+ return broken('kit', path, want, error);
183
+ }
184
+ }
185
+
186
+ // A version number compared part by part: -1, 0 or 1.
187
+ function compare(a, b) {
188
+ const [x, y] = [a, b].map((one) => one.split('.').map(Number));
189
+ for (let index = 0; index < 3; index += 1) if (x[index] !== y[index]) return x[index] < y[index] ? -1 : 1;
190
+ return 0;
191
+ }
192
+
193
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
194
+
195
+ // The places a repo writes an Atlas version: the workflows and actions under
196
+ // .github/, the scripts under scripts/, and package.json. A tag such as
197
+ // "latest", or a version held in a variable, is no pin.
198
+ function pinFiles(root) {
199
+ const files = [];
200
+ for (const top of ['.github', 'scripts']) {
201
+ const dir = join(root, top);
202
+ if (!existsSync(dir)) continue;
203
+ walk(dir, (full, name, stat) => {
204
+ if (stat.isFile() && stat.size <= MAX_PIN_FILE && posix(relative(root, full)) !== CHECK_PATH) files.push(full);
205
+ return stat.isDirectory();
206
+ });
207
+ }
208
+ const manifest = join(root, 'package.json');
209
+ if (existsSync(manifest)) files.push(manifest);
210
+ return files;
211
+ }
212
+
213
+ function pinRows(root, ctx) {
214
+ const name = escape(ctx.name);
215
+ const patterns = [
216
+ new RegExp(`${name}@(\\d+\\.\\d+\\.\\d+)\\b`, 'g'),
217
+ new RegExp(`"${name}"\\s*:\\s*"[~^=]?(\\d+\\.\\d+\\.\\d+)"`, 'g'),
218
+ /\bATLAS_VERSION\s*[:=]\s*["']?(\d+\.\d+\.\d+)\b/g,
219
+ ];
220
+ const rows = [];
221
+ for (const full of pinFiles(root)) {
222
+ const file = posix(relative(root, full));
223
+ let text;
224
+ try { text = readFileSync(full, 'utf8'); } catch (error) { rows.push(broken('pin', file, ctx.version, error)); continue; }
225
+ text.split('\n').forEach((line, index) => {
226
+ const seen = new Set();
227
+ for (const pattern of patterns) {
228
+ for (const match of line.matchAll(pattern)) {
229
+ if (seen.has(match[1])) continue;
230
+ seen.add(match[1]);
231
+ const at = `${file}:${index + 1}`;
232
+ const order = compare(match[1], ctx.version);
233
+ if (order === 0) rows.push(row('pin', at, 'current', match[1], ctx.version, 'the pin names the version of this package.'));
234
+ else if (order < 0) rows.push(row('pin', at, 'behind', match[1], ctx.version, `the pin names Atlas ${match[1]}, and this package is ${ctx.version}. Move the pin in this line.`));
235
+ else rows.push(row('pin', at, 'unknown', match[1], ctx.version, `the pin names Atlas ${match[1]}, which is newer than this package. Upgrade Atlas to judge it.`));
236
+ }
237
+ }
238
+ });
239
+ }
240
+ return rows;
241
+ }
242
+
243
+ function kitReleases(packageRoot) {
244
+ const file = join(packageRoot, KIT_RELEASES_FILE);
245
+ if (!existsSync(file)) return new Map();
246
+ return new Map((JSON.parse(readFileSync(file, 'utf8')).releases ?? []).map((one) => [String(one.kit_sha256), String(one.first_package)]));
247
+ }
248
+
249
+ // One row for each Atlas copy in the repository. With all false, only the
250
+ // check, which is what "atlas status" answered before --all.
251
+ export function copies({ root, packageRoot, all = true }) {
252
+ const version = packagePackageVersion(packageRoot);
253
+ const rows = [checkRow(root, packageRoot)];
254
+ if (all) {
255
+ const ctx = {
256
+ version,
257
+ name: JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).name,
258
+ kitHash: currentKitHash(packageRoot),
259
+ kitReleases: kitReleases(packageRoot),
260
+ };
261
+ rows.push(workflowRow(root, packageRoot));
262
+ for (const dir of kitFolders(root)) rows.push(kitRow(root, dir, ctx));
263
+ rows.push(...pinRows(root, ctx));
264
+ }
265
+ return { root, atlas: version, rows };
266
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.49",
3
+ "version": "0.3.51",
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",
@@ -1,30 +1,35 @@
1
1
  {
2
- "note": "Every copy of shape/check.mjs a release of Atlas has shipped. The last row is the check this package ships. atlas status reads this list; git history is never read.",
2
+ "note": "Every copy of shape/check.mjs a release of Atlas has shipped. The last row is the check this package ships. workflow_sha256 is the fingerprint of the workflow that check writes (its WORKFLOW_TEMPLATE). atlas status reads this list; git history is never read.",
3
3
  "releases": [
4
4
  {
5
5
  "check_version": 1,
6
6
  "sha256": "3dfd659a7533c51dce11bd0e2d7a9312f041c5955d45ac3cc67d3e29d328b5d2",
7
- "first_package": "0.1.1"
7
+ "first_package": "0.1.1",
8
+ "workflow_sha256": "a463b58c30d5d7b1de27b5c7c145dd86d6ea58356ab83d9e6ddc62705902299e"
8
9
  },
9
10
  {
10
11
  "check_version": 2,
11
12
  "sha256": "a4bc5ab8c75be8f216d460df9233670f5ba4ed9d853236f6b55a7f0d10dfe293",
12
- "first_package": "0.2.0"
13
+ "first_package": "0.2.0",
14
+ "workflow_sha256": "b333b57c016791e2b77f983f6fe70c9aaf50f5f6fca28b7b9ca84c222dd7b7e1"
13
15
  },
14
16
  {
15
17
  "check_version": 3,
16
18
  "sha256": "0184db66fd5d5061c21ee7d2d88395b5bac64ab1308e02e06781e6015f3d7608",
17
- "first_package": "0.3.0"
19
+ "first_package": "0.3.0",
20
+ "workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
18
21
  },
19
22
  {
20
23
  "check_version": 4,
21
24
  "sha256": "3357ea247694634c3535998368683eceffaf863221dee5fec9e43a03042afb95",
22
- "first_package": "0.3.36"
25
+ "first_package": "0.3.36",
26
+ "workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
23
27
  },
24
28
  {
25
29
  "check_version": 5,
26
30
  "sha256": "8f36633fe693ebf7a7cc0f7237d49eb4720f67d371d4758d556bab51965ec334",
27
- "first_package": "0.3.48"
31
+ "first_package": "0.3.48",
32
+ "workflow_sha256": "b69efaac2a3e52368ae5e7d7a33e1c88c6c7a7efa6fc1846844f79f9432ecd7d"
28
33
  }
29
34
  ]
30
35
  }
@@ -96,7 +96,7 @@ set. A test keeps the two lists equal.
96
96
  | `atlas upgrade` | When the owner asks to upgrade Atlas. Call the `atlas_upgrade` tool instead: the Bash sandbox blocks this command. Run the command by hand only where that tool is missing. In a cloud session, call the tool with `check: true` at the first reply, as section 7 says |
97
97
  | `atlas doctor` | When Atlas seems missing or broken in a session |
98
98
  | `atlas check` | Before you ask for a merge in a repo that has the shape check |
99
- | `atlas status` | To learn whether a repo's check is current or behind |
99
+ | `atlas status` | To learn whether a repo's check is current or behind. `atlas status --all --json <repo>` lists each Atlas copy in a repo, with have, want and state |
100
100
  | `atlas tooling` | When `atlas status` says behind; `pr` opens the pull request |
101
101
  | `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
102
102
  | `atlas code-read` | To resolve the citations of a code digest |
@@ -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 |
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