@arjunkhera/atlas 0.3.50 → 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 +5 -2
- package/door/lib/gate.mjs +101 -6
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/sdlc-task/SKILL.md +9 -0
- package/skills/upkeep/SKILL.md +5 -1
package/door/cli.mjs
CHANGED
|
@@ -137,6 +137,9 @@ 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
|
|
140
143
|
|
|
141
144
|
atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
|
|
142
145
|
atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
|
|
@@ -177,7 +180,7 @@ export const FLAGS = Object.freeze({
|
|
|
177
180
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
178
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' },
|
|
179
182
|
upkeep: { root: 'optional', event: 'value', json: 'switch' },
|
|
180
|
-
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' },
|
|
181
184
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
182
185
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
|
|
183
186
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -414,7 +417,7 @@ function upkeepVerb(chosen) {
|
|
|
414
417
|
}
|
|
415
418
|
|
|
416
419
|
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);
|
|
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);
|
|
418
421
|
}
|
|
419
422
|
|
|
420
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/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -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
|