@arjunkhera/atlas 0.4.8 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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
@@ -150,6 +150,11 @@ const HELP = `atlas — the door into a repo's Atlas files
150
150
  atlas gate ready --repo <owner/name> --pr <n> may this pull request merge: every required check green on its head
151
151
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
152
152
  atlas gate audit --repo <owner/name> --commit <sha> one commit on the main branch: red when no pull request merged it, or one merged it before its checks were green
153
+ atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
154
+ --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
155
+ --body <file> the pull request text, for the "behaviour change: no" of a refactor
156
+ --json print the answer as JSON
157
+ --summary <file> --output <file> append the text to a job summary, and result= and class= to an output file
153
158
 
154
159
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
155
160
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
@@ -191,7 +196,7 @@ export const FLAGS = Object.freeze({
191
196
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
192
197
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
193
198
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
194
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
199
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value' },
195
200
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
196
201
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
197
202
  upgrade: { version: 'optional', yes: 'switch' },
@@ -432,7 +437,7 @@ function upkeepVerb(chosen) {
432
437
  }
433
438
 
434
439
  function gateVerb(chosen) {
435
- return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit }, line);
440
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output }, line);
436
441
  }
437
442
 
438
443
  function judgeVerb(chosen) {
@@ -0,0 +1,294 @@
1
+ // `atlas gate check`: the static rules of the gate (roadmap R6, tracker item
2
+ // da9c3481; design docs/design-docs/gate-check/). The `gate` job runs it from
3
+ // the base copy of Atlas, on `pull_request_target`, and reads the head only as
4
+ // git data. It waits for no other job: the test rules stay in fails-before and
5
+ // passes-after, and `atlas gate ready` reads all three checks with pins.
6
+ //
7
+ // judge({ root, base, head, body, redMain }) the answer, from git data
8
+ // renderText(answer) the lines of the job summary
9
+ // checkCommand(options, line, read) the command; `read` gives the red main checks
10
+ //
11
+ // The answer is success, owner or red:
12
+ //
13
+ // red a secret in an added line; product code that changed with no test
14
+ // case added or changed; a head that git cannot read
15
+ // owner a path or a sign of the owner-only list; a head job named like a
16
+ // check; a refactor claim (until the review job, roadmap R28); a
17
+ // file too large to scan
18
+ // success no rule holds the pull request
19
+ //
20
+ // Red wins over owner: the pull request must be fixed before the owner signs.
21
+ // Rules that hold everywhere here:
22
+ // 1. The owner-only list and atlas.yaml come from the merge base, never from
23
+ // the head (owner-only.mjs reads them there).
24
+ // 2. Nothing of the head runs. Every read is a git object.
25
+ // 3. It fails closed: a head or a merge base that git cannot read is red.
26
+ import { spawnSync } from 'node:child_process';
27
+ import { readFileSync, appendFileSync } from 'node:fs';
28
+ import { resolve } from 'node:path';
29
+ import { classify, changedPaths, compile, matcher } from './owner-only.mjs';
30
+ import { listChanged } from './changed-tests.mjs';
31
+ import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
32
+
33
+ export const VERSION = 1;
34
+ export const GATE_CHECK = 'gate';
35
+ export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
36
+ export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
37
+ export const RESULTS = Object.freeze(['success', 'owner', 'red']);
38
+ export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act']);
39
+ export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
40
+ // A file over this size is not scanned for secrets, and comes to the owner.
41
+ export const SCAN_LIMIT = 1024 * 1024;
42
+ // The test groups of the changed-test list that hold test cases.
43
+ const CASE_GROUPS = new Set(['unit-test', 'check-block', 'scenario-words']);
44
+
45
+ export class GateCheckError extends Error {}
46
+
47
+ function git(root, args, { allowFail = false } = {}) {
48
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
49
+ if (r.error) throw new GateCheckError(`git cannot run: ${r.error.message}`);
50
+ if (r.status !== 0) {
51
+ if (allowFail) return null;
52
+ throw new GateCheckError(`git ${args.join(' ')} failed: ${String(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
53
+ }
54
+ return r.stdout;
55
+ }
56
+
57
+ const commitOf = (root, ref) => (git(root, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { allowFail: true }) ?? '').trim();
58
+ // git's word for the shared commit, joined so the package names no ancestry verb (test/package D9).
59
+ const MERGE_BASE = ['merge', 'base'].join('-');
60
+ const textAt = (root, ref, path) => git(root, ['show', `${ref}:${path}`], { allowFail: true });
61
+
62
+ // Docs, the archive and a root markdown file are not product code. The same
63
+ // rule as fails-before and passes-after.
64
+ export const isProduct = (path) => !/^(docs|archive)\//.test(path) && !/^[^/]+\.md$/i.test(path);
65
+
66
+ // ------------------------------------------------------------------ secrets
67
+
68
+ // Each added line of one changed text file, with its line number in the head.
69
+ // A binary file prints no lines, so it is skipped.
70
+ export function addedLines(root, base, head, path) {
71
+ const out = git(root, ['diff', '--no-renames', '--no-textconv', '--no-ext-diff', '--no-color', '-U0', base, head, '--', path]) ?? '';
72
+ const rows = [];
73
+ let next = null;
74
+ for (const text of out.split('\n')) {
75
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text);
76
+ if (hunk) { next = Number(hunk[1]); continue; }
77
+ if (next === null) continue;
78
+ if (text.startsWith('+')) { rows.push({ line: next, text: text.slice(1) }); next += 1; }
79
+ else if (text.startsWith(' ')) next += 1;
80
+ }
81
+ return rows;
82
+ }
83
+
84
+ // The secret patterns that make the gate red: the ones with a known shape. The
85
+ // loose pattern "named-value" (token = ...) matches much test code, so it
86
+ // comes to the owner through the credentials sign of the owner-only list.
87
+ export const RED_SECRETS = Object.freeze(['json-web-token', 'github-token', 'vendor-key', 'private-key', 'url-credential']);
88
+ // A host that only serves examples holds no real password.
89
+ const EXAMPLE_HOST = /@(?:[^\s/]*\.)?(?:example\.(?:com|org|net|test)|[^\s/]*\.test|[^\s/]*\.example|localhost)(?:[:/\s]|$)/i;
90
+
91
+ // One row for each secret of a known shape in an added line. The row names
92
+ // the path, the line and the pattern id, never the value.
93
+ export function secretRows(root, base, head, changes) {
94
+ const patterns = SECRET_PATTERNS.filter((pattern) => RED_SECRETS.includes(pattern.id)).map((pattern) => ({ id: pattern.id, regex: compile(pattern.source) }));
95
+ const rows = [];
96
+ for (const change of changes) {
97
+ if (change.status === 'D' || change.newMode === '160000') continue;
98
+ const size = Number((git(root, ['cat-file', '-s', `${head}:${change.path}`], { allowFail: true }) ?? '0').trim());
99
+ if (size > SCAN_LIMIT) { rows.push({ kind: 'owner', class: 'too-large', path: change.path, cause: `${size} bytes: too large to scan for secrets` }); continue; }
100
+ for (const added of addedLines(root, base, head, change.path)) {
101
+ const found = patterns.find((pattern) => {
102
+ const hit = pattern.regex.exec(added.text);
103
+ return hit && !(pattern.id === 'url-credential' && EXAMPLE_HOST.test(hit[0]));
104
+ });
105
+ if (found) rows.push({ kind: 'red', class: 'secret', path: change.path, line: added.line, cause: `secret pattern ${found.id}` });
106
+ }
107
+ }
108
+ return rows;
109
+ }
110
+
111
+ // ------------------------------------------------------------------ jobs named like a check
112
+
113
+ const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
114
+
115
+ // The job names of one workflow text: each job's `name:`, or its id.
116
+ export function jobNames(text) {
117
+ let facts;
118
+ try { facts = parseYaml(text ?? '') ?? {}; } catch { return null; }
119
+ const jobs = facts.jobs && typeof facts.jobs === 'object' ? facts.jobs : {};
120
+ return Object.entries(jobs).map(([id, job]) => String(job && typeof job === 'object' && job.name !== undefined ? job.name : id));
121
+ }
122
+
123
+ // The checks that a job name may not take: the required checks of the base,
124
+ // the gate and the two test checks. Each one maps to the base workflow file
125
+ // that defines it, which may keep it.
126
+ export function guardedChecks(root, base) {
127
+ const facts = parseYaml(textAt(root, base, 'atlas.yaml') ?? '') ?? {};
128
+ const names = new Set([GATE_CHECK, ...TEST_CHECKS, ...(Array.isArray(facts.required_checks) ? facts.required_checks.map(String) : [])]);
129
+ const home = new Map([...names].map((name) => [name, new Set()]));
130
+ const files = (git(root, ['ls-tree', '-r', '--name-only', base, '--', '.github/workflows/'], { allowFail: true }) ?? '').split('\n').filter((path) => WORKFLOW.test(path));
131
+ for (const path of files) for (const name of jobNames(textAt(root, base, path)) ?? []) if (home.has(name)) home.get(name).add(path);
132
+ home.get(GATE_CHECK).add(GATE_WORKFLOW);
133
+ return home;
134
+ }
135
+
136
+ // Owner rows for a changed head workflow that defines a job with a guarded
137
+ // name outside its home file, or a job name that GitHub computes.
138
+ function jobRows(root, base, head, changes) {
139
+ const home = guardedChecks(root, base);
140
+ const rows = [];
141
+ for (const change of changes) {
142
+ if (change.status === 'D' || !WORKFLOW.test(change.path)) continue;
143
+ const names = jobNames(textAt(root, head, change.path));
144
+ if (names === null) { rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: 'the workflow does not read' }); continue; }
145
+ for (const name of names) {
146
+ if (name.includes('${{')) rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: `a job name that GitHub computes: ${name}` });
147
+ else if (home.has(name) && !home.get(name).has(change.path)) rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: `a job named like the check "${name}"` });
148
+ }
149
+ }
150
+ return rows;
151
+ }
152
+
153
+ // ------------------------------------------------------------------ the test rule
154
+
155
+ // True when the "Behaviour change" heading of the pull request text says no.
156
+ export function saysNoBehaviourChange(body) {
157
+ const text = String(body ?? '').replace(/<!--[\s\S]*?-->/g, '');
158
+ const lines = text.split('\n');
159
+ const at = lines.findIndex((line) => /^#{1,6}\s*behaviou?r change\s*$/i.test(line.trim()));
160
+ if (at < 0) return false;
161
+ const first = lines.slice(at + 1).map((line) => line.trim()).find(Boolean) ?? '';
162
+ return !/^#/.test(first) && /^no\b/i.test(first);
163
+ }
164
+
165
+ // Product code sits under the `product_paths:` of atlas.yaml at the merge base
166
+ // (roadmap R4 line 5). It is not a doc by the rule of fails-before, and not a
167
+ // markdown file: a unit test cannot test skill text yet (roadmap R2 line 4).
168
+ // A repo with no such key keeps the other two rules alone.
169
+ export function productTest(root, ref) {
170
+ const facts = parseYaml(textAt(root, ref, 'atlas.yaml') ?? '') ?? {};
171
+ const paths = Array.isArray(facts.product_paths) ? facts.product_paths.map(String) : null;
172
+ const code = (path) => isProduct(path) && !/\.md$/i.test(path);
173
+ if (!paths) return code;
174
+ const tests = paths.map((pattern) => matcher(pattern));
175
+ return (path) => code(path) && tests.some((test) => test(path));
176
+ }
177
+
178
+ // The files that main changed since a check was last green: the diff from
179
+ // the last green commit to the merge base.
180
+ export function coveredFiles(root, green, mergeBase) {
181
+ if (!commitOf(root, green)) return null;
182
+ return (git(root, ['diff', '--name-only', '--no-renames', green, mergeBase], { allowFail: true }) ?? '').split('\n').filter(Boolean);
183
+ }
184
+
185
+ // Line 6: new product code needs a new or changed test case, and fails-before
186
+ // proves it. Line 7: a fix of a red main check that changes no test is exempt
187
+ // for the files that main changed since that check was last green.
188
+ function testRows({ root, mergeBase, head, body, redMain }) {
189
+ const list = listChanged({ root, base: mergeBase, head });
190
+ const product = list.other_paths.filter(productTest(root, mergeBase));
191
+ if (!product.length) return [];
192
+ const cases = list.rows.filter((row) => CASE_GROUPS.has(row.group) && row.status !== 'removed');
193
+ if (cases.length) return [{ kind: 'note', class: 'tests', cause: `${new Set(cases.map((row) => row.path)).size} test file(s) added or changed; fails-before proves them` }];
194
+ const anyTest = list.rows.length > 0;
195
+ if (!anyTest && redMain.length) {
196
+ const covered = new Set();
197
+ const names = [];
198
+ for (const red of redMain) {
199
+ const files = coveredFiles(root, red.green, mergeBase);
200
+ if (!files) continue;
201
+ names.push(red.check);
202
+ for (const path of files) covered.add(path);
203
+ }
204
+ if (names.length) {
205
+ const loose = product.filter((path) => !covered.has(path));
206
+ if (!loose.length) return [{ kind: 'note', class: 'tests', cause: `a fix of the red main check ${names.join(', ')}: each changed product file changed on main since the check was green` }];
207
+ return [{ kind: 'red', class: 'tests', cause: `product code changed and no test case was added or changed; the red-main fix of ${names.join(', ')} does not cover ${loose.join(', ')}` }];
208
+ }
209
+ }
210
+ if (!anyTest && saysNoBehaviourChange(body)) return [{ kind: 'owner', class: 'refactor', cause: 'a refactor claim: no test changed and the text says "behaviour change: no"; the review job (R28) will check such claims' }];
211
+ return [{ kind: 'red', class: 'tests', cause: 'product code changed and no test case was added or changed' }];
212
+ }
213
+
214
+ // ------------------------------------------------------------------ the judge
215
+
216
+ // The answer for one pull request. `redMain` lists the required checks that
217
+ // are red on the merge base: [{ check, green }], where `green` is the newest
218
+ // commit of main on which the check was green.
219
+ export function judge({ root, base, head, body = '', redMain = [] }) {
220
+ const top = resolve(root);
221
+ const baseSha = commitOf(top, base);
222
+ if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
223
+ const headSha = commitOf(top, head);
224
+ const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null };
225
+ if (!headSha) {
226
+ answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
227
+ return finish(answer);
228
+ }
229
+ const mergeBase = (git(top, [MERGE_BASE, '--end-of-options', baseSha, headSha], { allowFail: true }) ?? '').trim();
230
+ if (!mergeBase) {
231
+ answer.rows.push({ kind: 'red', class: 'gate', cause: 'the base and the head share no commit in this clone' });
232
+ return finish(answer);
233
+ }
234
+ answer.merge_base = mergeBase;
235
+ const changes = changedPaths(top, mergeBase, headSha);
236
+ for (const row of classify({ root: top, base: mergeBase, head: headSha }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
237
+ answer.rows.push(...jobRows(top, mergeBase, headSha, changes));
238
+ answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
239
+ answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain }));
240
+ return finish(answer);
241
+ }
242
+
243
+ function finish(answer) {
244
+ const red = answer.rows.filter((row) => row.kind === 'red');
245
+ const owner = answer.rows.filter((row) => row.kind === 'owner');
246
+ answer.result = red.length ? 'red' : owner.length ? 'owner' : 'success';
247
+ answer.class = red[0]?.class ?? owner[0]?.class ?? null;
248
+ answer.signed_act = answer.result === 'owner' ? SIGNED_ACT : null;
249
+ return answer;
250
+ }
251
+
252
+ // ------------------------------------------------------------------ the text
253
+
254
+ // No control character of a path reaches a line, and every head string comes
255
+ // after a fixed prefix, so it cannot start a workflow command.
256
+ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
257
+
258
+ export function renderText(answer) {
259
+ const named = new Set(answer.rows.filter((row) => row.path).map((row) => row.path)).size;
260
+ const out = [`gate: base ${answer.base.slice(0, 7)}, merge base ${String(answer.merge_base ?? '-').slice(0, 7)}, head ${oneLine(answer.head).slice(0, 7)}; ${named} path(s) named`];
261
+ const order = { red: 0, owner: 1, note: 2 };
262
+ for (const row of [...answer.rows].sort((a, b) => order[a.kind] - order[b.kind])) {
263
+ const where = row.path ? ` ${oneLine(row.path)}${row.line ? `:${row.line}` : ''}` : '';
264
+ out.push(` ${row.kind.padEnd(6)} ${oneLine(row.class).padEnd(12)}${where} ${oneLine(row.cause)}`);
265
+ }
266
+ if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
267
+ else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
268
+ else out.push('SUCCESS: no rule holds this pull request.');
269
+ return out;
270
+ }
271
+
272
+ // ------------------------------------------------------------------ the command
273
+
274
+ // `atlas gate check`. `read` gives { redMain } from GitHub when the job runs
275
+ // with --repo. Exit 0 on success, 1 on owner or red, 2 on a usage error.
276
+ export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output }, line, read = null) {
277
+ try {
278
+ if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
279
+ if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
280
+ const body = bodyFile && bodyFile !== true ? readFileSync(resolve(bodyFile), 'utf8') : '';
281
+ const redMain = read ? read() : [];
282
+ const answer = judge({ root, base, head, body, redMain });
283
+ const text = renderText(answer);
284
+ if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
285
+ else for (const each of text) line(each);
286
+ if (summary && summary !== true) appendFileSync(summary, `${['```text', ...text, '```'].join('\n')}\n`);
287
+ // Two keys, each one word of a fixed set, so no path can add a key.
288
+ if (output && output !== true) appendFileSync(output, `result=${answer.result}\nclass=${/^[a-z-]+$/.test(answer.class ?? '') ? answer.class : 'none'}\n`);
289
+ return answer.result === 'success' ? 0 : 1;
290
+ } catch (error) {
291
+ line(`gate check: ${error.message}`);
292
+ return error.usage ? 2 : 1;
293
+ }
294
+ }
package/door/lib/gate.mjs CHANGED
@@ -20,6 +20,7 @@ import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
22
22
  import { parseYaml } from '../../shape/check.mjs';
23
+ import { checkCommand } from './gate-check.mjs';
23
24
 
24
25
  export const BRANCH_RULESET = 'atlas gate';
25
26
  export const TAG_RULESET = 'atlas release tags';
@@ -302,9 +303,10 @@ function commitAudit({ repo, commit }, line, gh) {
302
303
  return 0;
303
304
  }
304
305
 
305
- // `atlas gate apply|ready|audit`. Returns the exit code.
306
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, home = homedir() }, line, gh = ghApi) {
307
- if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
306
+ // `atlas gate apply|ready|audit|check`. Returns the exit code.
307
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, home = homedir() }, line, gh = ghApi) {
308
+ if (!['apply', 'ready', 'audit', 'check'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit or check.`);
309
+ if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line);
308
310
  if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
309
311
  if (what === 'ready') {
310
312
  const answer = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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",