@arjunkhera/atlas 0.3.51 → 0.4.0

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.51",
3
+ "version": "0.4.0",
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
@@ -140,6 +140,7 @@ const HELP = `atlas — the door into a repo's Atlas files
140
140
  On a GitHub plan with no rulesets, apply changes nothing and reports advisory mode.
141
141
  atlas gate ready --repo <owner/name> --pr <n> may this pull request merge: every required check green on its head
142
142
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
143
+ atlas gate audit --repo <owner/name> --commit <sha> one commit on the main branch: red when no pull request merged it, or one merged it before its checks were green
143
144
 
144
145
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
145
146
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
@@ -180,7 +181,7 @@ export const FLAGS = Object.freeze({
180
181
  view: { product: 'value', name: 'value', out: 'value' },
181
182
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
182
183
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
183
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value' },
184
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
184
185
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
185
186
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
186
187
  upgrade: { version: 'optional', yes: 'switch' },
@@ -417,7 +418,7 @@ function upkeepVerb(chosen) {
417
418
  }
418
419
 
419
420
  function gateVerb(chosen) {
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);
421
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit }, line);
421
422
  }
422
423
 
423
424
  function judgeVerb(chosen) {
@@ -1,5 +1,5 @@
1
1
  {
2
- "note": "Every kit of the folder tests/ that a release of Atlas shipped, as the kit hash of its manifest (the sha256 of the sorted lines of path and file hash). The kit of this package is always accepted and need not be listed. When a release changes anything in tests/, add the kit hash of the release before it here. atlas tests check reads this list; git history is never read.",
2
+ "note": "Every kit of the folder tests/ that a release of Atlas shipped, as the kit hash of its manifest (the sha256 of the sorted lines of path and file hash). The kit of this package is always accepted and need not be listed. When a release changes anything in tests/, add the kit hash of the release before it here. atlas tests check reads this list; git history is never read. Since 24 October 2026 the release picks the version after the merge, so a new row names no first_package. The row of kit 1e62be9a names none either: it was built as 0.3.28, and npm never held 0.3.28.",
3
3
  "releases": [
4
4
  {
5
5
  "kit_sha256": "ae0f32b8c6694fbffb8bdacd8bd919d27c1c1da6281a46779e521dfea2341565",
@@ -22,8 +22,7 @@
22
22
  "first_package": "0.3.26"
23
23
  },
24
24
  {
25
- "kit_sha256": "1e62be9a3fb632161c593d44ac7ad404ef04186fe2fbcd108645fbe1559bf860",
26
- "first_package": "0.3.28"
25
+ "kit_sha256": "1e62be9a3fb632161c593d44ac7ad404ef04186fe2fbcd108645fbe1559bf860"
27
26
  },
28
27
  {
29
28
  "kit_sha256": "08c868aa39e6c9f471da2571e9a1d5939932a59856cea9c4176e3173f93454db",
package/door/lib/gate.mjs CHANGED
@@ -6,8 +6,8 @@
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
9
+ // A private repo on a free GitHub plan gets no rulesets. The owner chose on 24
10
+ // October 2026 to stay on that plan (decision dd90d8e8). There the
11
11
  // gate is advisory: apply changes nothing and says so, `atlas gate ready`
12
12
  // says if one pull request may merge, and `atlas gate audit` names each merge
13
13
  // that came before its checks were green (design docs/design-docs/gate-advisory/).
@@ -211,7 +211,8 @@ export function readyCommand({ repo, pr }, line, gh = ghApi) {
211
211
 
212
212
  // `atlas gate audit --since <date>`: each pull request merged since the date
213
213
  // whose required checks were not all green at the moment of the merge.
214
- export function auditCommand({ repo, since }, line, gh = ghApi) {
214
+ export function auditCommand({ repo, since, commit }, line, gh = ghApi) {
215
+ if (commit !== undefined) return commitAudit({ repo, commit }, line, gh);
215
216
  const from = since && since !== true ? since : new Date(Date.now() - 864e5).toISOString().slice(0, 10);
216
217
  if (Number.isNaN(Date.parse(from))) throw new GateError(`--since must be a date, not "${from}".`);
217
218
  const closed = gh('GET', `repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`) ?? [];
@@ -239,12 +240,46 @@ export function auditCommand({ repo, since }, line, gh = ghApi) {
239
240
  return red ? 1 : 0;
240
241
  }
241
242
 
243
+ // `atlas gate audit --commit <sha>`: one commit that reached the main branch.
244
+ // The push job runs it on each push. A commit is good when a pull request
245
+ // merged it after that pull request's required checks were green. A commit
246
+ // that no pull request merged was pushed past the gate, so it is red.
247
+ function commitAudit({ repo, commit }, line, gh) {
248
+ if (!commit || commit === true || !/^[0-9a-f]{7,40}$/i.test(String(commit))) throw new GateError('--commit needs a commit SHA.');
249
+ // GitHub expands a short SHA, and names the first parent: the main branch
250
+ // just before this commit, whose atlas.yaml held the gate at that moment.
251
+ const found = gh('GET', `repos/${repo}/commits/${commit}`);
252
+ const sha = found?.sha;
253
+ if (!sha) throw new GateError(`there is no commit ${commit} in ${repo}.`);
254
+ const pull = (gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each.merged_at && each.merge_commit_sha === sha);
255
+ if (!pull) {
256
+ line(`- ✗ ${sha.slice(0, 7)} reached the main branch with no pull request: a push past the gate.`);
257
+ line('RED PUSH: look at it now; the main branch may hold an untested change.');
258
+ return 1;
259
+ }
260
+ const parent = found.parents?.[0]?.sha ?? pull.base.sha;
261
+ let checks;
262
+ try { checks = checksAt(gh, repo, parent); } catch (error) {
263
+ if (!(error instanceof GateError)) throw error;
264
+ line(`- atlas gate audit: ${sha.slice(0, 7)} is the merge of #${pull.number}; atlas.yaml before it listed no required_checks, so nothing to audit.`);
265
+ return 0;
266
+ }
267
+ const bad = checkStates(checks, checkRuns(gh, repo, pull.head.sha), pull.merged_at).filter((row) => row.state !== 'green');
268
+ if (bad.length) {
269
+ line(`- ✗ #${pull.number} merged ${pull.merged_at} before its checks were green: ${bad.map((row) => `${row.name} ${row.state}`).join(', ')}`);
270
+ line('RED MERGES: 1. Look at it now; the main branch may hold an untested change.');
271
+ return 1;
272
+ }
273
+ line(`- atlas gate audit: ${sha.slice(0, 7)} is the merge of #${pull.number}, after its ${checks.length} required check(s) were green.`);
274
+ return 0;
275
+ }
276
+
242
277
  // `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) {
278
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit }, line, gh = ghApi) {
244
279
  if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
245
280
  if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
246
281
  if (what === 'ready') return readyCommand({ repo, pr }, line, gh);
247
- if (what === 'audit') return auditCommand({ repo, since }, line, gh);
282
+ if (what === 'audit') return auditCommand({ repo, since, commit }, line, gh);
248
283
  if (off && on) throw new GateError('pick --off or --on, not both.');
249
284
  const mode = off ? 'off' : on ? 'on' : 'apply';
250
285
  const facts = mode === 'apply' ? gateFacts(root) : null;
@@ -14,6 +14,7 @@ import { tmpdir } from 'node:os';
14
14
  import { join, relative } from 'node:path';
15
15
  import { createInterface } from 'node:readline/promises';
16
16
  import { readConnection } from '../../work/mcp.mjs';
17
+ import { VERSION_FILES, stampVersion } from './releases.mjs';
17
18
 
18
19
  export const CATALOG_NAME = 'atlas';
19
20
  export const PLUGIN_ID = 'atlas@atlas';
@@ -169,38 +170,95 @@ function filesUnder(root, skip = () => false) {
169
170
  return out;
170
171
  }
171
172
 
172
- // Is `sha` a commit of the master branch itself? A run's branch name is not
173
- // enough: a tag named "master" gives a run the same name.
173
+ // Is `sha` in master's history now? A tag named "master" gives a run the
174
+ // branch name too, so the run's branch name is not enough.
174
175
  export function onMaster(gh, repo, sha) {
175
176
  const compare = gh(['api', '--method', 'GET', `repos/${repo}/compare/${sha}...refs/heads/master`]);
176
177
  return ['ahead', 'identical'].includes(compare.status);
177
178
  }
178
179
 
179
- // The master run of release.yml that built this version, or null.
180
- export function findReleaseRun({ gh, repo, version }) {
181
- const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&per_page=100`]).workflow_runs ?? [];
182
- for (const run of runs) {
183
- if (!/^[0-9a-f]{40}$/.test(run.head_sha ?? '')) continue;
184
- let named;
185
- try {
186
- const file = gh(['api', '--method', 'GET', `repos/${repo}/contents/packages/atlas/package.json?ref=${run.head_sha}`]);
187
- named = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')).version;
188
- } catch { continue; }
189
- if (named !== version) continue;
190
- if (!onMaster(gh, repo, run.head_sha)) continue;
191
- return run;
180
+ // Was `sha` once the tip of master, through a merge or a normal push? A commit
181
+ // that reached master only as the second parent of a merge was never the tip,
182
+ // so the run that built it ran on another branch. A force push does not count:
183
+ // with onMaster it would let in code that left master's history. It reads the
184
+ // newest 100 changes of master, the one page this proxy lets through (its
185
+ // next-page links use a path it refuses); an older release is refused.
186
+ const TIP_KINDS = new Set(['pr_merge', 'push', 'merge_queue_merge']);
187
+ export function wasTip(gh, repo, sha) {
188
+ const rows = gh(['api', '--method', 'GET', `repos/${repo}/activity?ref=refs/heads/master&direction=desc&per_page=100`]);
189
+ return Array.isArray(rows) && rows.some((row) => row.after === sha && TIP_KINDS.has(row.activity_type));
190
+ }
191
+
192
+ // The commit that the tag v<version> names, or null. The release job tags the
193
+ // master commit it built (tracker item 339538d3): the commit no longer names
194
+ // the version, because the release picks it after the merge.
195
+ export function tagCommit(gh, repo, version) {
196
+ // Only "not found" means no tag. Any other failure is GitHub not
197
+ // answering, and the caller must say so, not refuse on a guess.
198
+ const read = (path) => {
199
+ try { return gh(['api', '--method', 'GET', path]).object; } catch (error) {
200
+ if (/HTTP 404|Not Found/i.test(`${error.stderr ?? ''}${error.message ?? ''}`)) return null;
201
+ throw error;
202
+ }
203
+ };
204
+ let object = read(`repos/${repo}/git/ref/tags/v${version}`);
205
+ if (object?.type === 'tag') object = read(`repos/${repo}/git/tags/${object.sha}`);
206
+ return object?.type === 'commit' && /^[0-9a-f]{40}$/.test(object.sha ?? '') ? object.sha : null;
207
+ }
208
+
209
+ // The name of the record that a master release run keeps for each version
210
+ // it publishes. Only a run of the tip of master makes one (release.yml). If
211
+ // GitHub stops listing a name once its file expires (90 days), an upgrade to
212
+ // that release is refused, never let through.
213
+ export const recordName = (version, sha256) => `atlas-release-${version}-${sha256}`;
214
+
215
+ // The release of this version. The tag is only a hint to find the commit: the
216
+ // proof is the record of a successful push run of release.yml on that master
217
+ // commit, which names this version and the sha256 of this exact tarball. The
218
+ // commit must once have been the tip of master. A tag that a person made or
219
+ // moved finds no record, so the upgrade refuses. With no sha256 (a loaded
220
+ // copy has no tarball), any record of this version counts, and the byte
221
+ // compare of every file does the rest.
222
+ export function findRelease({ gh, repo, version, sha256 }) {
223
+ const commit = tagCommit(gh, repo, version);
224
+ if (!commit) return { problem: `no tag v${version} names a commit` };
225
+ if (!onMaster(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
226
+ if (!wasTip(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
227
+ const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&head_sha=${commit}&per_page=100`]).workflow_runs ?? [];
228
+ const prefix = recordName(version, '');
229
+ const named = (name) => (sha256 ? name === recordName(version, sha256) : name.startsWith(prefix) && /^[0-9a-f]{64}$/.test(name.slice(prefix.length)));
230
+ // A tag named "master" gives a run on another commit the branch name too;
231
+ // the tip check above rules that out, and these fields rule out the rest.
232
+ const ours = (row) => row.head_sha === commit && row.event === 'push' && row.head_branch === 'master' && row.path === '.github/workflows/release.yml';
233
+ for (const run of runs.filter(ours)) {
234
+ const artifacts = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]).artifacts ?? [];
235
+ if (artifacts.some((artifact) => named(artifact.name ?? ''))) return { run, commit };
192
236
  }
193
- return null;
237
+ return { problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
238
+ }
239
+
240
+ // The blob ids that the version files of the tarball must have: the files of
241
+ // the commit with the stamp of the target version. So the stamp may change the
242
+ // version line and nothing else.
243
+ export function stampedBlobs({ gh, repo, commit, version }) {
244
+ const out = new Map();
245
+ for (const path of VERSION_FILES) {
246
+ const file = gh(['api', '--method', 'GET', `repos/${repo}/contents/packages/atlas/${path}?ref=${commit}`]);
247
+ out.set(path, blobSha(Buffer.from(stampVersion(Buffer.from(file.content, 'base64').toString('utf8'), version))));
248
+ }
249
+ return out;
194
250
  }
195
251
 
196
252
  // Every file of the tarball must be the file at that commit, and every file of
197
- // packages/atlas at that commit must be in the tarball.
198
- export function matchTree({ tree, files }) {
253
+ // packages/atlas at that commit must be in the tarball. `stamped` holds the
254
+ // blob ids that replace the commit's own for the version files.
255
+ export function matchTree({ tree, files, stamped = new Map() }) {
199
256
  const wanted = new Map((tree ?? []).filter((row) => row.type === 'blob' && row.path.startsWith('packages/atlas/')).map((row) => [row.path.slice('packages/atlas/'.length), row.sha]));
257
+ for (const [path, sha] of stamped) if (wanted.has(path)) wanted.set(path, sha);
200
258
  const problems = [];
201
259
  for (const [path, bytes] of files) {
202
260
  if (!wanted.has(path)) problems.push(`${path} is in the tarball and not in the commit`);
203
- else if (wanted.get(path) !== blobSha(bytes)) problems.push(`${path} differs from the commit`);
261
+ else if (wanted.get(path) !== blobSha(bytes)) problems.push(`${path} differs from the commit${stamped.has(path) ? ' with the version stamped' : ''}`);
204
262
  }
205
263
  for (const path of wanted.keys()) if (!files.has(path)) problems.push(`${path} is in the commit and not in the tarball`);
206
264
  return problems;
@@ -222,14 +280,19 @@ export function proveCopy({ packageRoot, exec = defaultExec }) {
222
280
  const { version } = packageJson(packageRoot);
223
281
  const repo = repoOf(packageRoot);
224
282
  const gh = (args) => JSON.parse(exec('gh', args));
225
- let run;
226
- try { run = findReleaseRun({ gh, repo, version }); } catch (error) { return { state: 'not checked', reason: `GitHub did not answer: ${firstLines(error)}` }; }
227
- if (!run) return { state: 'unproven', reason: `no successful release run on a commit of master built ${version}. It may have been published from a branch.` };
283
+ let release;
284
+ try { release = findRelease({ gh, repo, version }); } catch (error) { return { state: 'not checked', reason: `GitHub did not answer: ${firstLines(error)}` }; }
285
+ if (release.problem) return { state: 'unproven', reason: `${release.problem}. It may have been published from a branch.` };
286
+ const { run, commit } = release;
228
287
  let tree;
229
- try { tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${run.head_sha}?recursive=1`]).tree; } catch (error) { return { state: 'not checked', reason: `GitHub did not give the file tree: ${firstLines(error)}` }; }
288
+ let stamped;
289
+ try {
290
+ tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
291
+ stamped = stampedBlobs({ gh, repo, commit, version });
292
+ } catch (error) { return { state: 'not checked', reason: `GitHub did not give the files of the commit: ${firstLines(error)}` }; }
230
293
  const files = filesUnder(packageRoot, (path) => COPY_MARKER.test(path));
231
- const problems = matchTree({ tree, files });
232
- const found = { run: run.id, commit: run.head_sha };
294
+ const problems = matchTree({ tree, files, stamped });
295
+ const found = { run: run.id, commit };
233
296
  return problems.length ? { state: 'differs', ...found, problems: problems.slice(0, 20) } : { state: 'match', ...found, files: files.size };
234
297
  } catch (error) {
235
298
  return { state: 'not checked', reason: `the proof stopped: ${firstLines(error)}` };
@@ -246,9 +309,6 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
246
309
  const integrity = exec('npm', ['view', `${name}@${target}`, 'dist.integrity']).trim();
247
310
 
248
311
  line(`Atlas ${installed} → ${target}`);
249
- const run = findReleaseRun({ gh, repo, version: target });
250
- if (!run) { line(`Refused: no successful release run on a commit of master built ${target}. It may have been published from a branch.`); return 1; }
251
- line(` built by release run ${run.id}, on master commit ${run.head_sha.slice(0, 12)}`);
252
312
 
253
313
  const work = mkdtempSync(join(tmpdir(), 'atlas-upgrade-'));
254
314
  try {
@@ -257,12 +317,17 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
257
317
  if (!tarball) throw new Error('npm pack wrote no tarball');
258
318
  const bytes = readFileSync(join(work, tarball));
259
319
  if (sha512Integrity(bytes) !== integrity) { line('Refused: the tarball does not match the integrity npm records.'); return 1; }
320
+ const { run, commit, problem } = findRelease({ gh, repo, version: target, sha256: createHash('sha256').update(bytes).digest('hex') });
321
+ if (problem) { line(`Refused: ${problem}. It may have been published from a branch.`); return 1; }
322
+ line(` tag v${target}, recorded by release run ${run.id}, on master commit ${commit.slice(0, 12)}`);
260
323
  const unpacked = join(work, 'unpacked');
261
324
  mkdirSync(unpacked);
262
325
  exec('tar', ['-xzf', join(work, tarball), '-C', unpacked]);
263
326
  const files = filesUnder(join(unpacked, 'package'));
264
- const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${run.head_sha}?recursive=1`]).tree;
265
- const problems = matchTree({ tree, files });
327
+ const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
328
+ let stamped;
329
+ try { stamped = stampedBlobs({ gh, repo, commit, version: target }); } catch (error) { line(`Refused: the version files of that commit cannot take the stamp: ${error.message}`); return 1; }
330
+ const problems = matchTree({ tree, files, stamped });
266
331
  if (problems.length) {
267
332
  line('Refused: the tarball is not the commit that run built.');
268
333
  for (const problem of problems.slice(0, 20)) line(` ${problem}`);
@@ -15,7 +15,9 @@ export function readReleases(packageRoot) {
15
15
  const list = JSON.parse(readFileSync(join(packageRoot, RELEASES_PATH), 'utf8'));
16
16
  if (!Array.isArray(list.releases) || !list.releases.length) throw new Error(`${RELEASES_PATH} lists no release`);
17
17
  for (const row of list.releases) {
18
- if (!Number.isInteger(row.check_version) || !/^[0-9a-f]{64}$/.test(row.sha256 ?? '') || typeof row.first_package !== 'string') {
18
+ // A row from before 24 October 2026 names its first package. A newer row
19
+ // names none, because the release picks the version after the merge.
20
+ if (!Number.isInteger(row.check_version) || !/^[0-9a-f]{64}$/.test(row.sha256 ?? '') || !['string', 'undefined'].includes(typeof row.first_package)) {
19
21
  throw new Error(`${RELEASES_PATH} holds a malformed row: ${JSON.stringify(row)}`);
20
22
  }
21
23
  }
@@ -40,3 +42,18 @@ export function packagedCheck(packageRoot) {
40
42
  const fingerprint = sha256(text);
41
43
  return { text, sha256: fingerprint, releases, ...placeOf(releases, fingerprint) };
42
44
  }
45
+
46
+ // The release picks the version (tracker item 339538d3). The release job
47
+ // writes the number into these two files of its own checkout, and into nothing
48
+ // else, and never commits it. `atlas upgrade` makes the same stamp on the
49
+ // files of the tagged commit, so the stamp can change the version and nothing
50
+ // more.
51
+ export const VERSION_FILES = Object.freeze(['package.json', '.claude-plugin/plugin.json']);
52
+ const VERSION_LINE = /^ {2}"version": "[^"\n]*"(,?)$/gm;
53
+
54
+ export function stampVersion(text, version) {
55
+ if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`"${version}" is not an exact version`);
56
+ const found = [...text.matchAll(VERSION_LINE)].length;
57
+ if (found !== 1) throw new Error(`a version file must hold exactly one top-level "version" line, and this one holds ${found}`);
58
+ return text.replace(VERSION_LINE, (_, comma) => ` "version": "${version}"${comma}`);
59
+ }
@@ -22,6 +22,10 @@ import { kitHashOf } from '../../tests/link-check.mjs';
22
22
 
23
23
  export const STATES = Object.freeze(['current', 'behind', 'unknown', 'missing']);
24
24
 
25
+ // A row after 24 October 2026 names no package, because the release picks
26
+ // the version after the merge.
27
+ const from = (row) => (row.first_package ? `, first in Atlas ${row.first_package}` : '');
28
+
25
29
  export function status({ root, packageRoot }) {
26
30
  const pkg = packagedCheck(packageRoot);
27
31
  const latest = pkg.releases[pkg.releases.length - 1];
@@ -35,10 +39,10 @@ export function status({ root, packageRoot }) {
35
39
  const { row, latest: isLatest } = placeOf(pkg.releases, fingerprint);
36
40
  const copy = { sha256: fingerprint, check_version: row?.check_version ?? checkStamps(text).check_version, first_package: row?.first_package ?? null };
37
41
  if (row && isLatest) {
38
- return { root, path: CHECK_PATH, state: 'current', packaged, copy, why: `the copy is check version ${row.check_version}, the newest one Atlas has released (first in Atlas ${row.first_package}).` };
42
+ return { root, path: CHECK_PATH, state: 'current', packaged, copy, why: `the copy is check version ${row.check_version}, the newest one Atlas has released${from(row)}.` };
39
43
  }
40
44
  if (row) {
41
- return { root, path: CHECK_PATH, state: 'behind', packaged, copy, why: `the copy is check version ${row.check_version}, first released in Atlas ${row.first_package}. The newest is check version ${latest.check_version}, from Atlas ${latest.first_package}. "atlas tooling pr" refreshes it.` };
45
+ return { root, path: CHECK_PATH, state: 'behind', packaged, copy, why: `the copy is check version ${row.check_version}${from(row)}. The newest is check version ${latest.check_version}${from(latest)}. "atlas tooling pr" refreshes it.` };
42
46
  }
43
47
  return { root, path: CHECK_PATH, state: 'unknown', packaged, copy, why: `the copy's fingerprint, sha256 ${fingerprint.slice(0, 12)}…, is in no release of the check. Someone edited it, or it came from an unreleased package. "atlas tooling pr --replace-unknown" shows the difference and replaces it.` };
44
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.51",
3
+ "version": "0.4.0",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -105,7 +105,7 @@ set. A test keeps the two lists equal.
105
105
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
106
106
  | `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
107
107
  | `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
108
- | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge. `audit` in the daily upkeep run |
108
+ | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
109
109
  | `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
110
110
 
111
111
  A person merges every file that `atlas tooling` writes.