@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +33 -4
- package/door/lib/gate.mjs +101 -6
- package/door/lib/status.mjs +223 -4
- package/package.json +1 -1
- package/shape/releases.json +11 -6
- package/skills/lead/SKILL.md +2 -2
- package/skills/sdlc-task/SKILL.md +9 -0
- package/skills/upkeep/SKILL.md +5 -1
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 = [];
|
package/door/lib/status.mjs
CHANGED
|
@@ -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
package/shape/releases.json
CHANGED
|
@@ -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
|
}
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -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
|
|
package/skills/upkeep/SKILL.md
CHANGED
|
@@ -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
|