@arjunkhera/atlas 0.3.52 → 0.4.1
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/README.md +1 -1
- package/door/kit-releases.json +2 -3
- package/door/lib/install.mjs +95 -30
- package/door/lib/releases.mjs +18 -1
- package/door/lib/status.mjs +6 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Atlas sets how a repository is worked on. It is one Claude Code plugin with:
|
|
|
4
4
|
|
|
5
5
|
1. **The lead**, `atlas:lead`: the talk rules, the routing and the command
|
|
6
6
|
list. It loads on its own in each session of a repo with `atlas.yaml`.
|
|
7
|
-
|
|
7
|
+
No one has to call it.
|
|
8
8
|
2. **The rulebook**, `atlas:sdlc-task`: start, pause, resume, lock and finish
|
|
9
9
|
one piece of work. Three more skills: `repo-skills` adds a procedure the
|
|
10
10
|
right way, `learnings` turns a miss into one fix, and `feedback` files an
|
package/door/kit-releases.json
CHANGED
|
@@ -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/install.mjs
CHANGED
|
@@ -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`
|
|
173
|
-
//
|
|
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
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
226
|
-
try {
|
|
227
|
-
if (
|
|
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
|
-
|
|
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
|
|
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/${
|
|
265
|
-
|
|
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}`);
|
package/door/lib/releases.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/door/lib/status.mjs
CHANGED
|
@@ -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
|
|
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}
|
|
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