@arjunkhera/atlas 0.4.52 → 0.4.54
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/.mcp.json +1 -1
- package/door/cli.mjs +4 -2
- package/door/lib/install.mjs +50 -26
- package/door/lib/refresh-judge.mjs +132 -0
- package/door/lib/refresh.mjs +320 -0
- package/door/lib/runner.mjs +61 -5
- package/door/lib/tooling.mjs +5 -0
- package/door/templates/atlas-runner-notice.yml +41 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +14 -6
- package/skills/runner/SKILL.md +6 -3
- package/work/mcp.mjs +33 -4
- package/work/start.mjs +63 -0
package/.mcp.json
CHANGED
package/door/cli.mjs
CHANGED
|
@@ -155,6 +155,8 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
155
155
|
--json print the whole answer as JSON
|
|
156
156
|
atlas runner claim --repo <owner/name> --issue <n> --holder <run id> claim one atlas-job issue; the oldest live claim holds it
|
|
157
157
|
atlas runner line --from <file> print the last line of a run, from a JSON file of ids and counts
|
|
158
|
+
--notice print the comment that hands the run facts to the notice workflow
|
|
159
|
+
atlas runner notice --repo <owner/name> --issue <n> --comment <id> in the notice workflow only: post the run line of one run comment as the bot
|
|
158
160
|
Until the A2 exit test, the runner works on the scratch repo only
|
|
159
161
|
|
|
160
162
|
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, stale pages, and in a pull request its tracker item, its changed designs and STE on its changed lines
|
|
@@ -236,7 +238,7 @@ export const FLAGS = Object.freeze({
|
|
|
236
238
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
237
239
|
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' },
|
|
238
240
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
239
|
-
runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', json: 'switch' },
|
|
241
|
+
runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', comment: 'value', notice: 'switch', json: 'switch' },
|
|
240
242
|
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
|
|
241
243
|
gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', from: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value', errors: 'switch', forced: 'switch', findings: 'value' },
|
|
242
244
|
approve: { repo: 'value', root: 'optional', key: 'value', provider: 'value', yes: 'switch', out: 'value', file: 'value', signers: 'value', commit: 'value', item: 'value', card: 'value', words: 'value', pr: 'value', head: 'value', decision: 'value', check: 'value' },
|
|
@@ -497,7 +499,7 @@ function jobsVerb(chosen) {
|
|
|
497
499
|
}
|
|
498
500
|
|
|
499
501
|
function runnerVerb(chosen) {
|
|
500
|
-
return runnerCommand({ what: chosen._[1], repo: chosen.repo, issue: chosen.issue, holder: chosen.holder, from: chosen.from, json: Boolean(chosen.json), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
502
|
+
return runnerCommand({ what: chosen._[1], repo: chosen.repo, issue: chosen.issue, holder: chosen.holder, from: chosen.from, comment: chosen.comment, notice: Boolean(chosen.notice), json: Boolean(chosen.json), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
|
|
501
503
|
}
|
|
502
504
|
|
|
503
505
|
function upkeepVerb(chosen) {
|
package/door/lib/install.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Code to add the catalog and install the plugin. It writes no settings file.
|
|
10
10
|
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
15
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -332,6 +332,51 @@ export function notesLines({ gh, repo, from, to }) {
|
|
|
332
332
|
return out;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// Every entry under root is a plain file or a folder. A link would make the
|
|
336
|
+
// file walk read what it points at (security review of item ade7d96a).
|
|
337
|
+
export function onlyPlainEntries(root) {
|
|
338
|
+
const walk = (dir) => {
|
|
339
|
+
for (const entry of readdirSync(dir)) {
|
|
340
|
+
const stat = lstatSync(join(dir, entry));
|
|
341
|
+
if (stat.isDirectory()) { if (!walk(join(dir, entry))) return false; } else if (!stat.isFile()) return false;
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
};
|
|
345
|
+
return walk(root);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Pack one release from npm into `work` and prove it: the integrity that npm
|
|
349
|
+
// records, a release run record of this exact tarball on a master commit that
|
|
350
|
+
// was once the tip, and every file equal to that commit with the version
|
|
351
|
+
// stamped. The upgrade and the cloud refresh share it (item ade7d96a). It
|
|
352
|
+
// returns the lines of a refusal, or the files, the tarball path and the run.
|
|
353
|
+
// The package folder holds no file until the proof passed: the caller copies
|
|
354
|
+
// `unpacked` only after it.
|
|
355
|
+
export function provenRelease({ name, version, repo, gh, exec, work, line = () => {} }) {
|
|
356
|
+
const integrity = exec('npm', ['view', `${name}@${version}`, 'dist.integrity']).trim();
|
|
357
|
+
exec('npm', ['pack', `${name}@${version}`, '--pack-destination', work]);
|
|
358
|
+
const tarballName = readdirSync(work).find((entry) => entry.endsWith('.tgz'));
|
|
359
|
+
if (!tarballName) throw new Error('npm pack wrote no tarball');
|
|
360
|
+
const tarball = join(work, tarballName);
|
|
361
|
+
const bytes = readFileSync(tarball);
|
|
362
|
+
if (sha512Integrity(bytes) !== integrity) return { code: 'integrity', refused: ['Refused: the tarball does not match the integrity npm records.'] };
|
|
363
|
+
const { run, commit, problem, code } = findRelease({ gh, repo, version, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
364
|
+
if (problem) return { code, refused: [`Refused: ${problem}. It may have been published from a branch.`] };
|
|
365
|
+
line(` tag v${version}, recorded by release run ${run.id}, on master commit ${commit.slice(0, 12)}`);
|
|
366
|
+
const unpacked = join(work, 'unpacked');
|
|
367
|
+
mkdirSync(unpacked);
|
|
368
|
+
exec('tar', ['-xzf', tarball, '-C', unpacked]);
|
|
369
|
+
if (!onlyPlainEntries(unpacked)) return { code: 'special-entry', refused: ['Refused: the tarball holds a link or another entry that is not a plain file.'] };
|
|
370
|
+
const files = filesUnder(join(unpacked, 'package'));
|
|
371
|
+
const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
|
|
372
|
+
let stamped;
|
|
373
|
+
try { stamped = stampedBlobs({ gh, repo, commit, version }); } catch (error) { return { code: 'stamp', refused: [`Refused: the version files of that commit cannot take the stamp: ${error.message}`] }; }
|
|
374
|
+
const problems = matchTree({ tree, files, stamped });
|
|
375
|
+
if (problems.length) return { code: 'files', refused: ['Refused: the tarball is not the commit that run built.', ...problems.slice(0, 20).map((problem) => ` ${problem}`)] };
|
|
376
|
+
line(` every one of ${files.size} files matches that commit`);
|
|
377
|
+
return { files, tarball, unpacked: join(unpacked, 'package'), run, commit };
|
|
378
|
+
}
|
|
379
|
+
|
|
335
380
|
export async function upgrade({ packageRoot, options = {}, line = console.log, env = process.env, exec = defaultExec, ask = defaultAsk }) {
|
|
336
381
|
const { name, version: installed } = packageJson(packageRoot);
|
|
337
382
|
const repo = repoOf(packageRoot);
|
|
@@ -339,34 +384,13 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
|
|
|
339
384
|
const target = options.version && options.version !== true ? String(options.version) : exec('npm', ['view', name, 'version']).trim();
|
|
340
385
|
if (!EXACT_VERSION.test(target)) throw new Error(`npm answered "${target}", which is not a version`);
|
|
341
386
|
if (target === installed) { line(`Atlas ${installed} is the newest release. Nothing to do.`); return 0; }
|
|
342
|
-
const integrity = exec('npm', ['view', `${name}@${target}`, 'dist.integrity']).trim();
|
|
343
|
-
|
|
344
387
|
line(`Atlas ${installed} → ${target}`);
|
|
345
388
|
|
|
346
389
|
const work = mkdtempSync(join(tmpdir(), 'atlas-upgrade-'));
|
|
347
390
|
try {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const bytes = readFileSync(join(work, tarball));
|
|
352
|
-
if (sha512Integrity(bytes) !== integrity) { line('Refused: the tarball does not match the integrity npm records.'); return 1; }
|
|
353
|
-
const { run, commit, problem } = findRelease({ gh, repo, version: target, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
354
|
-
if (problem) { line(`Refused: ${problem}. It may have been published from a branch.`); return 1; }
|
|
355
|
-
line(` tag v${target}, recorded by release run ${run.id}, on master commit ${commit.slice(0, 12)}`);
|
|
356
|
-
const unpacked = join(work, 'unpacked');
|
|
357
|
-
mkdirSync(unpacked);
|
|
358
|
-
exec('tar', ['-xzf', join(work, tarball), '-C', unpacked]);
|
|
359
|
-
const files = filesUnder(join(unpacked, 'package'));
|
|
360
|
-
const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
|
|
361
|
-
let stamped;
|
|
362
|
-
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; }
|
|
363
|
-
const problems = matchTree({ tree, files, stamped });
|
|
364
|
-
if (problems.length) {
|
|
365
|
-
line('Refused: the tarball is not the commit that run built.');
|
|
366
|
-
for (const problem of problems.slice(0, 20)) line(` ${problem}`);
|
|
367
|
-
return 1;
|
|
368
|
-
}
|
|
369
|
-
line(` every one of ${files.size} files matches that commit`);
|
|
391
|
+
const proven = provenRelease({ name, version: target, repo, gh, exec, work, line });
|
|
392
|
+
if (proven.refused) { for (const text of proven.refused) line(text); return 1; }
|
|
393
|
+
const { files, tarball } = proven;
|
|
370
394
|
line('');
|
|
371
395
|
for (const text of notesLines({ gh, repo, from: installed, to: target })) line(text);
|
|
372
396
|
|
|
@@ -376,7 +400,7 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
|
|
|
376
400
|
line('');
|
|
377
401
|
if (!options.yes && !(await ask(`Install Atlas ${target}?`))) { line('Nothing was changed.'); return 1; }
|
|
378
402
|
|
|
379
|
-
exec('npm', ['install', '-g', '--ignore-scripts',
|
|
403
|
+
exec('npm', ['install', '-g', '--ignore-scripts', tarball]);
|
|
380
404
|
writeFileSync(catalogFile(env), `${JSON.stringify(catalogFor({ name, version: target }), null, 2)}\n`);
|
|
381
405
|
exec('claude', ['plugin', 'marketplace', 'update', CATALOG_NAME]);
|
|
382
406
|
exec('claude', ['plugin', 'update', PLUGIN_ID]);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The loaded copy judges the merges before a refresh moves (item ade7d96a;
|
|
2
|
+
// the owner picked "1: The loaded copy judges" on 31 October 2026, decision
|
|
3
|
+
// 6869a363). The push audit workflow runs the code of the commit it judges,
|
|
4
|
+
// so a push can change what it says. Here the loaded Atlas, which this
|
|
5
|
+
// session already trusts, judges each first-parent commit of master after
|
|
6
|
+
// the loaded release, up to the new release commit. Each commit passes three
|
|
7
|
+
// checks, each in a process or call of its own, all at the same time:
|
|
8
|
+
// 1. `atlas gate audit --commit`: a pull request merged it after its
|
|
9
|
+
// required checks were green, and each gate run came from the base copy.
|
|
10
|
+
// 2. `atlas gate hold --commit <sha> --from <its parent>`: the gate judges
|
|
11
|
+
// the merge again and must say success, or the owner signed an act for
|
|
12
|
+
// it. An owner or a red answer holds (security re-review of #292, S1).
|
|
13
|
+
// 3. The pull request that merged it targeted master.
|
|
14
|
+
//
|
|
15
|
+
// refresh.mjs starts this file as its own process group before the release
|
|
16
|
+
// proof, so the two run at the same time. The arguments are one JSON object:
|
|
17
|
+
// { repo, fromCommit, toCommit, work, deadline }; the clone goes in
|
|
18
|
+
// work/clone. At the deadline (epoch ms) the judge ends its whole process
|
|
19
|
+
// group. It prints one JSON line:
|
|
20
|
+
// { code: null } when each merge is green, or { code, commits }.
|
|
21
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
22
|
+
import { mkdirSync, realpathSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
// More merges than this since the loaded release are too many to judge in
|
|
27
|
+
// the start time. The refresh keeps the copy and says so.
|
|
28
|
+
export const MAX_MERGES = 8;
|
|
29
|
+
const CALL_MS = 10_000;
|
|
30
|
+
const HOLD_EXIT = 3;
|
|
31
|
+
const FETCH_MS = 30_000;
|
|
32
|
+
// The folder of the package in the repo, and how far back the start may move.
|
|
33
|
+
export const PACKAGE_DIR = 'packages/atlas';
|
|
34
|
+
const MAX_LOOK_BACK = 200;
|
|
35
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
36
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
37
|
+
const bin = fileURLToPath(new URL('../../bin/atlas', import.meta.url));
|
|
38
|
+
|
|
39
|
+
const git = (clone, args, timeout = CALL_MS) => execFileSync('git', ['-C', clone, ...args], { encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
40
|
+
const has = (clone, sha) => { try { git(clone, ['cat-file', '-e', `${sha}^{commit}`]); return true; } catch { return false; } };
|
|
41
|
+
|
|
42
|
+
// The commits to judge, oldest first, from a full clone of master: a gate
|
|
43
|
+
// judgement needs the merge base of each pull request, so the clone is never
|
|
44
|
+
// shallow.
|
|
45
|
+
export function mergesSince({ repo, fromCommit, toCommit, clone, remote = `https://github.com/${repo}.git` }) {
|
|
46
|
+
mkdirSync(clone, { recursive: true });
|
|
47
|
+
git(clone, ['init', '-q']);
|
|
48
|
+
git(clone, ['-c', 'credential.helper=', '-c', 'credential.helper=!gh auth git-credential', 'fetch', '-q', '--no-tags', remote, '+refs/heads/master:refs/remotes/origin/master'], FETCH_MS);
|
|
49
|
+
if (!has(clone, fromCommit) || !has(clone, toCommit)) return { code: 'audit-silent' };
|
|
50
|
+
// The loaded release must sit on the first-parent line of the new one.
|
|
51
|
+
const line = git(clone, ['rev-list', '--first-parent', '--reverse', toCommit]).split('\n').filter(Boolean);
|
|
52
|
+
let at = line.indexOf(fromCommit);
|
|
53
|
+
if (at < 0) return { code: 'audit-silent' };
|
|
54
|
+
// A tag can move to a later commit with the same package files, so the
|
|
55
|
+
// range starts where those files first appeared on the line: each commit
|
|
56
|
+
// after that is judged (security re-review of #292, S1).
|
|
57
|
+
const treeOf = (sha) => { try { return git(clone, ['rev-parse', '--verify', '-q', `${sha}:${PACKAGE_DIR}`]); } catch { return null; } };
|
|
58
|
+
const loaded = treeOf(fromCommit);
|
|
59
|
+
if (!loaded) return { code: 'audit-silent' };
|
|
60
|
+
let steps = 0;
|
|
61
|
+
while (at > 0 && treeOf(line[at - 1]) === loaded) {
|
|
62
|
+
at -= 1;
|
|
63
|
+
steps += 1;
|
|
64
|
+
if (steps > MAX_LOOK_BACK) return { code: 'audit-silent' };
|
|
65
|
+
}
|
|
66
|
+
const commits = line.slice(at + 1);
|
|
67
|
+
if (!commits.length || commits.at(-1) !== toCommit) return { code: 'audit-silent' };
|
|
68
|
+
if (commits.length > MAX_MERGES) return { code: 'audit-many' };
|
|
69
|
+
return { commits: commits.map((sha) => ({ sha, parent: git(clone, ['rev-parse', '--verify', `${sha}^1`]) })) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One program run; its exit code, or 2 when it could not run.
|
|
73
|
+
function exitOf(run, args, env) {
|
|
74
|
+
return new Promise((done) => {
|
|
75
|
+
const child = run(process.execPath, [bin, ...args], { env, stdio: 'ignore' });
|
|
76
|
+
child.on('error', () => done(2));
|
|
77
|
+
child.on('exit', (code) => done(code ?? 2));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The base branch of the pull request that merged `sha`, or null.
|
|
82
|
+
export function mergedInto({ repo, sha, env }) {
|
|
83
|
+
return new Promise((done) => {
|
|
84
|
+
execFile('gh', ['api', '--method', 'GET', `repos/${repo}/commits/${sha}/pulls`], { encoding: 'utf8', timeout: CALL_MS, env, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
|
|
85
|
+
if (error) return done(null);
|
|
86
|
+
try {
|
|
87
|
+
const pull = (JSON.parse(stdout) ?? []).find((each) => each?.merged_at && each.merge_commit_sha === sha);
|
|
88
|
+
done(typeof pull?.base?.ref === 'string' ? pull.base.ref : null);
|
|
89
|
+
} catch { done(null); }
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Each commit at the same time. Its state is green, red or unread.
|
|
95
|
+
export function judgeAll({ repo, clone, commits, env, run = spawn, base = mergedInto }) {
|
|
96
|
+
return Promise.all(commits.map(async ({ sha, parent }) => {
|
|
97
|
+
const [audit, hold, into] = await Promise.all([
|
|
98
|
+
exitOf(run, ['gate', 'audit', '--repo', repo, '--commit', sha, '--root', clone], env),
|
|
99
|
+
exitOf(run, ['gate', 'hold', '--repo', repo, '--commit', sha, '--from', parent, '--root', clone], env),
|
|
100
|
+
base({ repo, sha, env }),
|
|
101
|
+
]);
|
|
102
|
+
if (audit === 1 || hold === HOLD_EXIT || (into !== null && into !== 'master')) return { sha, state: 'red' };
|
|
103
|
+
if (audit !== 0 || hold !== 0 || into === null) return { sha, state: 'unread' };
|
|
104
|
+
return { sha, state: 'green' };
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function judgeMerges({ repo, fromCommit, toCommit, clone, env = process.env, run, base, remote }) {
|
|
109
|
+
if (!REPO.test(String(repo)) || !SHA.test(String(fromCommit)) || !SHA.test(String(toCommit))) return { code: 'audit-silent' };
|
|
110
|
+
let found;
|
|
111
|
+
try { found = mergesSince({ repo, fromCommit, toCommit, clone, ...(remote ? { remote } : {}) }); } catch { return { code: 'audit-silent' }; }
|
|
112
|
+
if (found.code) return found;
|
|
113
|
+
const results = await judgeAll({ repo, clone, commits: found.commits, env, ...(run ? { run } : {}), ...(base ? { base } : {}) });
|
|
114
|
+
// A red audit, a held merge, or a merge into another branch is red. Any
|
|
115
|
+
// other failure means a check could not judge, never that it is green.
|
|
116
|
+
const red = results.filter((one) => one.state === 'red').map((one) => one.sha);
|
|
117
|
+
if (red.length) return { code: 'audit-judged-red', commits: red };
|
|
118
|
+
if (results.some((one) => one.state !== 'green')) return { code: 'audit-silent' };
|
|
119
|
+
return { code: null, commits: found.commits.map((one) => one.sha) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
123
|
+
let answer;
|
|
124
|
+
try {
|
|
125
|
+
const args = JSON.parse(process.argv[2] ?? '{}');
|
|
126
|
+
// The judge's own deadline: it ends itself and each check it started.
|
|
127
|
+
const left = Number(args.deadline) - Date.now();
|
|
128
|
+
setTimeout(() => { try { process.kill(-process.pid, 'SIGKILL'); } catch { process.exit(2); } }, Number.isFinite(left) ? Math.max(left, 0) : 0).unref();
|
|
129
|
+
answer = await judgeMerges({ repo: args.repo, fromCommit: args.fromCommit, toCommit: args.toCommit, clone: join(String(args.work), 'clone') });
|
|
130
|
+
} catch { answer = { code: 'audit-silent' }; }
|
|
131
|
+
process.stdout.write(`${JSON.stringify(answer)}\n`);
|
|
132
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// The cloud refresh (item ade7d96a; the owner picked "C: Atlas refreshes
|
|
2
|
+
// itself" and "1: Wait for a green push audit" on 31 October 2026, decisions
|
|
3
|
+
// b929c81d and 9e9615f1). The cloud saves the result of its setup script as a
|
|
4
|
+
// checkpoint, and a new session starts from that checkpoint, so a new session
|
|
5
|
+
// can load an old Atlas. At the start of each cloud session, work/start.mjs
|
|
6
|
+
// runs this file as its own process, with the short child environment: never
|
|
7
|
+
// the Engram key. It moves the loaded plugin folder to the newest release on
|
|
8
|
+
// npm, and only to a release that the release proof and the push audit
|
|
9
|
+
// accept. On any failure it keeps the folder it has.
|
|
10
|
+
//
|
|
11
|
+
// Design: docs/design-docs/cloud-self-refresh/.
|
|
12
|
+
//
|
|
13
|
+
// The rules:
|
|
14
|
+
// 1. Only in a Claude cloud session, only for a copy under
|
|
15
|
+
// ~/.claude/plugins/cache/, and never with ATLAS_REFRESH=off.
|
|
16
|
+
// 2. Only forward: npm must name a newer version than the loaded one.
|
|
17
|
+
// 3. The release proof is the upgrade's: provenRelease in install.mjs.
|
|
18
|
+
// 4. The push audit approves the release: its run on the release commit, and
|
|
19
|
+
// each of its runs on a master commit after the loaded release, ended in
|
|
20
|
+
// success, and no gate-audit or gate-files issue is open.
|
|
21
|
+
// 4a. The loaded copy judges too (the owner's pick of 31 October, decision
|
|
22
|
+
// 6869a363): refresh-judge.mjs runs this copy's own `atlas gate audit` on
|
|
23
|
+
// each of those commits, at the same time as the proof. The workflow runs
|
|
24
|
+
// the code it judges, so its green alone is not enough.
|
|
25
|
+
// 5. The folder is written from the checked bytes, and a tarball with a link
|
|
26
|
+
// or another special entry is refused.
|
|
27
|
+
// 6. The swap is two renames on one disk. A failed second rename puts the old
|
|
28
|
+
// folder back.
|
|
29
|
+
// 7. The record holds fixed codes, versions, run ids, commits and issue
|
|
30
|
+
// numbers only: no text from npm or GitHub reaches the lead.
|
|
31
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
32
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, cpSync, openSync, closeSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import { dirname, join, sep } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { newer, onlyPlainEntries, provenRelease, proveCopy, repoOf, tagCommit } from './install.mjs';
|
|
37
|
+
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
38
|
+
|
|
39
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
|
|
40
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
41
|
+
// The refresh before the swap may take this long. Each program call has its
|
|
42
|
+
// own limit too, so one call that hangs cannot hold the session.
|
|
43
|
+
export const BUDGET_MS = 20_000;
|
|
44
|
+
// With MCP_TIMEOUT set in the cloud environment, work/start.mjs gives the
|
|
45
|
+
// refresh a longer budget in ATLAS_REFRESH_BUDGET_MS. It is never shorter
|
|
46
|
+
// than BUDGET_MS and never longer than MAX_BUDGET_MS.
|
|
47
|
+
export const MAX_BUDGET_MS = 100_000;
|
|
48
|
+
export function budgetOf(env) {
|
|
49
|
+
const asked = Number(env.ATLAS_REFRESH_BUDGET_MS);
|
|
50
|
+
return Number.isInteger(asked) ? Math.min(Math.max(asked, BUDGET_MS), MAX_BUDGET_MS) : BUDGET_MS;
|
|
51
|
+
}
|
|
52
|
+
export const CALL_MS = 10_000;
|
|
53
|
+
// Claude Code writes this marker into a plugin copy that is in use.
|
|
54
|
+
const IN_USE = '.in_use';
|
|
55
|
+
// A lock older than this is left from a process that died.
|
|
56
|
+
const STALE_LOCK_MS = 5 * 60_000;
|
|
57
|
+
export const AUDIT_WORKFLOW = 'atlas-gate-audit.yml';
|
|
58
|
+
export const AUDIT_LABELS = Object.freeze(['job:gate-audit', 'job:gate-files']);
|
|
59
|
+
|
|
60
|
+
// Each reason the refresh can give, in fixed words. The lead says the words;
|
|
61
|
+
// the record holds only the code and typed values.
|
|
62
|
+
export const REASONS = Object.freeze({
|
|
63
|
+
'not-cloud': 'not a cloud session',
|
|
64
|
+
off: 'ATLAS_REFRESH=off',
|
|
65
|
+
'not-in-cache': 'the loaded copy is not in ~/.claude/plugins/cache/',
|
|
66
|
+
'no-package': 'the loaded package.json could not be read',
|
|
67
|
+
locked: 'another refresh runs',
|
|
68
|
+
'npm-silent': 'npm did not give the newest version',
|
|
69
|
+
'npm-odd': 'npm did not answer with a version',
|
|
70
|
+
integrity: 'the tarball does not match the integrity npm records',
|
|
71
|
+
'no-tag': 'no tag names the release commit',
|
|
72
|
+
'not-on-master': 'the release commit is not on master',
|
|
73
|
+
'never-tip': 'the release commit was never the tip of master',
|
|
74
|
+
'no-record': 'no master release run recorded this tarball',
|
|
75
|
+
stamp: 'the version files of the release commit cannot take the stamp',
|
|
76
|
+
files: 'the tarball is not the commit that its release run built',
|
|
77
|
+
'proof-stopped': 'the release proof stopped: GitHub or npm did not answer',
|
|
78
|
+
'audit-pending': 'the push audit of the release has not finished',
|
|
79
|
+
'audit-red': 'a push audit run since the loaded release did not end in success',
|
|
80
|
+
'audit-issue': 'an audit issue is open',
|
|
81
|
+
'audit-silent': 'GitHub did not give every push audit run or issue',
|
|
82
|
+
'audit-judged-red': 'the loaded Atlas judged a merge since the loaded release as red',
|
|
83
|
+
'audit-many': 'too many merges came after the loaded release to judge at the start',
|
|
84
|
+
'no-from-tag': 'no tag names the commit of the loaded release, so the audit cannot find the range',
|
|
85
|
+
'from-unproven': 'the loaded copy is not proven to be the release that its tag names, so the range of new merges is not known',
|
|
86
|
+
'special-entry': 'the tarball holds a link or another entry that is not a plain file',
|
|
87
|
+
timeout: 'the refresh used up its start time (MCP_TIMEOUT gives it more)',
|
|
88
|
+
'swap-failed': 'the swap failed, and the old folder is back',
|
|
89
|
+
stopped: 'the refresh stopped',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const JUDGE_FILE = fileURLToPath(new URL('./refresh-judge.mjs', import.meta.url));
|
|
93
|
+
|
|
94
|
+
// Start the loaded copy's judge of the merges as its own process group. The
|
|
95
|
+
// refresh runs only in a cloud session, so the judge and each audit it starts
|
|
96
|
+
// keep the cloud proxy that their `gh` needs (child-env.mjs). It
|
|
97
|
+
// gives { wait(deadline), stop() }: wait blocks until the judge answers or
|
|
98
|
+
// the deadline passes, and stop ends the judge and each audit it started.
|
|
99
|
+
export function startJudge({ repo, fromCommit, toCommit, work, env, deadline, now = Date.now }) {
|
|
100
|
+
const out = join(work, 'judge.json');
|
|
101
|
+
const fd = openSync(out, 'w');
|
|
102
|
+
let child;
|
|
103
|
+
try { child = spawn(process.execPath, [JUDGE_FILE, JSON.stringify({ repo, fromCommit, toCommit, work, deadline })], { env: { ...childEnvironment(env), CLAUDE_CODE_REMOTE: 'true' }, stdio: ['ignore', fd, 'ignore'], detached: true }); } finally { closeSync(fd); }
|
|
104
|
+
child.unref();
|
|
105
|
+
const stop = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* it ended */ } };
|
|
106
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
107
|
+
const wait = (deadline) => {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const text = readFileSync(out, 'utf8');
|
|
110
|
+
if (text.endsWith('\n')) { try { return JSON.parse(text); } catch { return { code: 'audit-silent' }; } }
|
|
111
|
+
if (now() >= deadline) { stop(); return { code: 'timeout' }; }
|
|
112
|
+
Atomics.wait(cell, 0, 0, 100);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return { wait, stop };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const refreshFile = (env) => join(env.HOME, '.atlas', 'refresh.json');
|
|
119
|
+
|
|
120
|
+
function childExec(env) {
|
|
121
|
+
return (command, args) => execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: CALL_MS, stdio: ['pipe', 'pipe', 'pipe'], env: childEnvironment(env) });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Is packageRoot the installed copy that Claude Code loads, and not a working
|
|
125
|
+
// tree, a test copy or a dev copy? Real paths on both sides.
|
|
126
|
+
export function inPluginCache(packageRoot, env) {
|
|
127
|
+
if (typeof env.HOME !== 'string' || !env.HOME) return false;
|
|
128
|
+
try {
|
|
129
|
+
const cache = realpathSync(join(env.HOME, '.claude', 'plugins', 'cache'));
|
|
130
|
+
return realpathSync(packageRoot).startsWith(cache + sep);
|
|
131
|
+
} catch { return false; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The push audit approves the release (the owner's answer to the S1). It
|
|
135
|
+
// returns null, or a refusal with its code and typed values.
|
|
136
|
+
export function auditApproves({ gh, repo, fromCommit, toCommit }) {
|
|
137
|
+
// Only runs made since the loaded release: one page holds them, and a
|
|
138
|
+
// count above the page means a run was not read, so the refresh refuses.
|
|
139
|
+
const since = gh(['api', '--method', 'GET', `repos/${repo}/commits/${fromCommit}`]).commit?.committer?.date;
|
|
140
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(String(since))) return { code: 'audit-silent', runs: [] };
|
|
141
|
+
const page = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/${AUDIT_WORKFLOW}/runs?branch=master&event=push&per_page=100&created=${encodeURIComponent(`>=${since.slice(0, 10)}`)}`]);
|
|
142
|
+
const runs = page.workflow_runs ?? [];
|
|
143
|
+
if (!Number.isInteger(page.total_count) || page.total_count > runs.length) return { code: 'audit-silent', runs: [] };
|
|
144
|
+
const ours = runs.filter((run) => run.event === 'push' && run.head_branch === 'master' && run.path === `.github/workflows/${AUDIT_WORKFLOW}`);
|
|
145
|
+
const judge = (list) => {
|
|
146
|
+
const open = list.filter((run) => run.status !== 'completed');
|
|
147
|
+
if (open.length) return { code: 'audit-pending', runs: open.map((run) => run.id) };
|
|
148
|
+
const red = list.filter((run) => run.conclusion !== 'success');
|
|
149
|
+
if (red.length) return { code: 'audit-red', runs: red.map((run) => run.id) };
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
const target = ours.filter((run) => run.head_sha === toCommit);
|
|
153
|
+
if (!target.length) return { code: 'audit-pending', runs: [] };
|
|
154
|
+
const targetVerdict = judge(target);
|
|
155
|
+
if (targetVerdict) return targetVerdict;
|
|
156
|
+
// Each push after the loaded release has its own audit run. The compare
|
|
157
|
+
// lists at most 250 commits; a longer range is refused, never guessed.
|
|
158
|
+
const compare = gh(['api', '--method', 'GET', `repos/${repo}/compare/${fromCommit}...${toCommit}`]);
|
|
159
|
+
const listed = compare.commits ?? [];
|
|
160
|
+
if (!Number.isInteger(compare.total_commits) || compare.total_commits > listed.length) return { code: 'audit-silent', runs: [] };
|
|
161
|
+
const between = new Set(listed.map((commit) => commit.sha));
|
|
162
|
+
const verdict = judge(ours.filter((run) => between.has(run.head_sha)));
|
|
163
|
+
if (verdict) return verdict;
|
|
164
|
+
const issues = [];
|
|
165
|
+
for (const label of AUDIT_LABELS) {
|
|
166
|
+
for (const issue of gh(['api', '--method', 'GET', `repos/${repo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=20`]) ?? []) {
|
|
167
|
+
if (Number.isInteger(issue.number) && !issues.includes(issue.number)) issues.push(issue.number);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return issues.length ? { code: 'audit-issue', issues } : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Write the checked bytes into `next`, with the file modes of the unpacked
|
|
174
|
+
// tarball, so an executable stays executable.
|
|
175
|
+
export function writeFolder({ files, unpacked, next }) {
|
|
176
|
+
rmSync(next, { recursive: true, force: true });
|
|
177
|
+
for (const [path, bytes] of files) {
|
|
178
|
+
const target = join(next, path);
|
|
179
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
180
|
+
writeFileSync(target, bytes);
|
|
181
|
+
chmodSync(target, statSync(join(unpacked, path)).mode & 0o755);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Put the folder `next` where `packageRoot` is. Two renames on one disk; when
|
|
186
|
+
// the second fails, the first is undone.
|
|
187
|
+
export function swapFolder({ packageRoot, next, rename = renameSync }) {
|
|
188
|
+
const old = `${packageRoot}.old`;
|
|
189
|
+
rmSync(old, { recursive: true, force: true });
|
|
190
|
+
rename(packageRoot, old);
|
|
191
|
+
try { rename(next, packageRoot); } catch (error) {
|
|
192
|
+
rename(old, packageRoot);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
rmSync(old, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function takeLock(lock, now) {
|
|
199
|
+
try { mkdirSync(lock); return true; } catch {
|
|
200
|
+
// A stale lock moves aside under a name of this process first, so two
|
|
201
|
+
// processes cannot both clear it and both take it.
|
|
202
|
+
try {
|
|
203
|
+
if (now() - statSync(lock).mtimeMs > STALE_LOCK_MS) {
|
|
204
|
+
const aside = `${lock}.stale-${process.pid}`;
|
|
205
|
+
renameSync(lock, aside);
|
|
206
|
+
rmSync(aside, { recursive: true, force: true });
|
|
207
|
+
mkdirSync(lock);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
} catch { /* another process has it */ }
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// One refresh. It returns a record: state moved, current, kept or skipped,
|
|
216
|
+
// with from, to, a code and typed values. It never throws.
|
|
217
|
+
export function refresh({ packageRoot, env = process.env, exec = childExec(env), now = Date.now, rename = renameSync, judge = startJudge, proveFrom = proveCopy }) {
|
|
218
|
+
const started = now();
|
|
219
|
+
const budget = budgetOf(env);
|
|
220
|
+
let from = null;
|
|
221
|
+
let to = null;
|
|
222
|
+
const done = (state, extra = {}) => ({ state, from, to, ...extra, seconds: Math.round((now() - started) / 100) / 10, at: new Date(now()).toISOString() });
|
|
223
|
+
const kept = (code, extra = {}) => done('kept', { code, ...extra });
|
|
224
|
+
if (env.CLAUDE_CODE_REMOTE !== 'true') return done('skipped', { code: 'not-cloud' });
|
|
225
|
+
if (env.ATLAS_REFRESH === 'off') return done('skipped', { code: 'off' });
|
|
226
|
+
if (!inPluginCache(packageRoot, env)) return done('skipped', { code: 'not-in-cache' });
|
|
227
|
+
let name;
|
|
228
|
+
try {
|
|
229
|
+
({ name, version: from } = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')));
|
|
230
|
+
if (!EXACT_VERSION.test(from)) throw new Error('no version');
|
|
231
|
+
} catch { from = null; return kept('no-package'); }
|
|
232
|
+
const lock = `${packageRoot}.refresh-lock`;
|
|
233
|
+
if (!takeLock(lock, now)) return kept('locked');
|
|
234
|
+
const work = mkdtempSync(join(tmpdir(), 'atlas-refresh-'));
|
|
235
|
+
const next = `${packageRoot}.next`;
|
|
236
|
+
let judged = null;
|
|
237
|
+
try {
|
|
238
|
+
let newest;
|
|
239
|
+
try { newest = exec('npm', ['view', name, 'version']).trim(); } catch { return kept('npm-silent'); }
|
|
240
|
+
if (!EXACT_VERSION.test(newest)) return kept('npm-odd');
|
|
241
|
+
if (!newer(newest, from)) return done('current', { to: from });
|
|
242
|
+
to = newest;
|
|
243
|
+
const repo = repoOf(packageRoot);
|
|
244
|
+
const gh = (args) => JSON.parse(exec('gh', args));
|
|
245
|
+
// The range to judge: the commits of the two tags. The loaded copy
|
|
246
|
+
// starts to judge it now, while the proofs run. Anyone who can push can
|
|
247
|
+
// move a tag, so the start must be the commit that the loaded copy's own
|
|
248
|
+
// proof finds (security re-review of #292, S1).
|
|
249
|
+
let fromCommit;
|
|
250
|
+
let toCommit;
|
|
251
|
+
try { fromCommit = tagCommit(gh, repo, from); toCommit = tagCommit(gh, repo, to); } catch { return kept('audit-silent'); }
|
|
252
|
+
if (!SHA.test(fromCommit ?? '')) return kept('no-from-tag');
|
|
253
|
+
if (SHA.test(toCommit ?? '')) judged = judge({ repo, fromCommit, toCommit, work, env, deadline: started + budget, now });
|
|
254
|
+
let loaded;
|
|
255
|
+
try { loaded = proveFrom({ packageRoot, exec }); } catch { loaded = null; }
|
|
256
|
+
if (loaded?.state !== 'match' || loaded.commit !== fromCommit) return kept('from-unproven');
|
|
257
|
+
// Any master commit can make a release record with its own release.yml,
|
|
258
|
+
// and the tag can move to it. npm never takes a version twice, so the
|
|
259
|
+
// proven run must have started before npm published the loaded version
|
|
260
|
+
// (third security review of #292, S1).
|
|
261
|
+
try {
|
|
262
|
+
const published = JSON.parse(exec('npm', ['view', `${name}@${from}`, 'time', '--json']))?.[from];
|
|
263
|
+
const run = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${Number(loaded.run)}`]);
|
|
264
|
+
const ran = Date.parse(String(run?.created_at));
|
|
265
|
+
if (!Number.isInteger(loaded.run) || run?.head_sha !== fromCommit || !(ran < Date.parse(String(published)))) return kept('from-unproven');
|
|
266
|
+
} catch { return kept('from-unproven'); }
|
|
267
|
+
let proven;
|
|
268
|
+
try { proven = provenRelease({ name, version: to, repo, gh, exec, work }); } catch { return kept('proof-stopped'); }
|
|
269
|
+
if (proven.refused) return kept(REASONS[proven.code] ? proven.code : 'files');
|
|
270
|
+
if (!judged || proven.commit !== toCommit) return kept('audit-silent', { commit: proven.commit });
|
|
271
|
+
let refusal;
|
|
272
|
+
try { refusal = auditApproves({ gh, repo, fromCommit, toCommit: proven.commit }); } catch { return kept('audit-silent', { commit: proven.commit }); }
|
|
273
|
+
if (refusal) return kept(refusal.code, { commit: proven.commit, ...(refusal.runs ? { runs: refusal.runs.filter(Number.isInteger).slice(0, 10) } : {}), ...(refusal.issues ? { issues: refusal.issues.slice(0, 10) } : {}) });
|
|
274
|
+
const verdict = judged.wait(started + budget);
|
|
275
|
+
if (verdict?.code !== null) return kept(REASONS[verdict?.code] ? verdict.code : 'audit-silent', { commit: proven.commit, ...(Array.isArray(verdict?.commits) ? { commits: verdict.commits.filter((one) => SHA.test(String(one))).map((one) => one.slice(0, 12)).slice(0, 10) } : {}) });
|
|
276
|
+
// provenRelease refused a link already; this is the second look, at the
|
|
277
|
+
// folder that the swap copies from.
|
|
278
|
+
if (!onlyPlainEntries(proven.unpacked)) return kept('special-entry', { commit: proven.commit });
|
|
279
|
+
if (now() - started > budget) return kept('timeout', { commit: proven.commit });
|
|
280
|
+
writeFolder({ files: proven.files, unpacked: proven.unpacked, next });
|
|
281
|
+
if (existsSync(join(packageRoot, IN_USE))) cpSync(join(packageRoot, IN_USE), join(next, IN_USE), { recursive: true });
|
|
282
|
+
try { swapFolder({ packageRoot, next, rename }); } catch { return kept('swap-failed', { commit: proven.commit }); }
|
|
283
|
+
// The plugin moved. The `atlas` command moves to the same checked file.
|
|
284
|
+
let command = 'moved';
|
|
285
|
+
try { exec('npm', ['install', '-g', '--ignore-scripts', proven.tarball]); } catch { command = 'behind'; }
|
|
286
|
+
return done('moved', { run: proven.run.id, commit: proven.commit, files: proven.files.size, command });
|
|
287
|
+
} catch {
|
|
288
|
+
return kept('stopped');
|
|
289
|
+
} finally {
|
|
290
|
+
judged?.stop();
|
|
291
|
+
// The lock goes first, so a folder that will not go away never leaves the
|
|
292
|
+
// lock behind. The refresh never throws.
|
|
293
|
+
try { rmSync(lock, { recursive: true, force: true }); } catch { /* a stale lock clears in 5 min */ }
|
|
294
|
+
for (const folder of [next, work]) { try { rmSync(folder, { recursive: true, force: true, maxRetries: 3 }); } catch { /* a temp folder */ } }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The words of a record, for the lead and the log.
|
|
299
|
+
export function describe(record) {
|
|
300
|
+
if (!record || typeof record !== 'object') return 'no refresh record';
|
|
301
|
+
const reason = REASONS[record.code] ?? '';
|
|
302
|
+
if (record.state === 'moved') return `Atlas moved from ${record.from} to ${record.to} at the start of this session.`;
|
|
303
|
+
if (record.state === 'current') return `Atlas ${record.from} is the newest release.`;
|
|
304
|
+
if (record.state === 'kept') return `The refresh kept Atlas ${record.from ?? '(unknown)'}${record.to ? ` and did not move to ${record.to}` : ''}: ${reason}.`;
|
|
305
|
+
return `No refresh: ${reason}.`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function writeRecord(env, record) {
|
|
309
|
+
if (typeof env.HOME !== 'string' || !env.HOME) return;
|
|
310
|
+
try { mkdirSync(dirname(refreshFile(env)), { recursive: true }); writeFileSync(refreshFile(env), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }); } catch { /* the record is advice */ }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Run as a program by work/start.mjs: one refresh of the package this file is
|
|
314
|
+
// in, one JSON line on stdout.
|
|
315
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
316
|
+
const packageRoot = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '');
|
|
317
|
+
const record = refresh({ packageRoot, env: process.env });
|
|
318
|
+
writeRecord(process.env, record);
|
|
319
|
+
process.stdout.write(`${JSON.stringify(record)}\n`);
|
|
320
|
+
}
|
package/door/lib/runner.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
//
|
|
4
4
|
// atlas runner start --repo <owner/name> [--json]
|
|
5
5
|
// atlas runner claim --repo <owner/name> --issue <n> --holder <run id>
|
|
6
|
-
// atlas runner line --from <file>
|
|
6
|
+
// atlas runner line --from <file> [--notice]
|
|
7
|
+
// atlas runner notice --repo <owner/name> --issue <n> --comment <id>
|
|
7
8
|
//
|
|
8
9
|
// The runner is one scheduled Claude session for each repo. It follows the
|
|
9
10
|
// atlas:runner skill, and that skill calls these commands. The commands read
|
|
@@ -15,6 +16,12 @@
|
|
|
15
16
|
// claim posts one claim on an issue, then reads the claims back. The
|
|
16
17
|
// oldest live claim holds the issue, so two runs build it once.
|
|
17
18
|
// line prints the last line of a run, built by code from ids and counts.
|
|
19
|
+
// With --notice it prints the comment that hands the run facts to
|
|
20
|
+
// the notice workflow.
|
|
21
|
+
// notice runs in the notice workflow, as github-actions[bot]. It reads one
|
|
22
|
+
// run comment of the owner's login, builds the run line again from
|
|
23
|
+
// its facts, and posts the line, so GitHub tells the owner's phone
|
|
24
|
+
// (R25 line 10). It never copies the comment's text.
|
|
18
25
|
//
|
|
19
26
|
// Rules that hold everywhere here:
|
|
20
27
|
// 1. Until the owner records the A2 exit test as passed, the runner works on
|
|
@@ -28,7 +35,7 @@
|
|
|
28
35
|
// 4. No call here merges, pushes or changes a branch.
|
|
29
36
|
import { readFileSync } from 'node:fs';
|
|
30
37
|
import { jobMarker, typedFindings, JOBS_LABEL, atlasJobs } from './jobs.mjs';
|
|
31
|
-
import { repoOk, TOKEN_SHAPE, DEFAULT_BOT } from './issues.mjs';
|
|
38
|
+
import { repoOk, TOKEN_SHAPE, DEFAULT_BOT, api, apiBase } from './issues.mjs';
|
|
32
39
|
import { ghApi } from './gate.mjs';
|
|
33
40
|
import { PACKAGE_NAME } from './gate-audit.mjs';
|
|
34
41
|
|
|
@@ -253,14 +260,56 @@ export function runLine({ repo, at, you = [], landed = [], opened = [], open_job
|
|
|
253
260
|
return parts.join(' · ');
|
|
254
261
|
}
|
|
255
262
|
|
|
263
|
+
// ------------------------------------------------------------ the notice
|
|
264
|
+
|
|
265
|
+
// R25 line 10. The runner session acts as the owner's login, and GitHub does
|
|
266
|
+
// not tell a person about his own comment. So the session posts its run facts
|
|
267
|
+
// in one comment on the notice issue, and the notice workflow posts the run
|
|
268
|
+
// line as the bot. The workflow trusts no text: it reads the facts as JSON,
|
|
269
|
+
// and runLine refuses any field that is not an id, a count or a known form.
|
|
270
|
+
export const RUN_MARKER = '<!-- atlas-runner:run -->';
|
|
271
|
+
export const NOTICE_LABEL = 'atlas-notice';
|
|
272
|
+
export const noticeBody = (facts) => [RUN_MARKER, '```json', JSON.stringify(facts), '```'].join('\n');
|
|
273
|
+
|
|
274
|
+
export function factsOf(body) {
|
|
275
|
+
const text = String(body ?? '');
|
|
276
|
+
if (!text.startsWith(`${RUN_MARKER}\n`)) return null;
|
|
277
|
+
const blocks = [...text.matchAll(/```json\n([\s\S]*?)\n```/g)];
|
|
278
|
+
if (blocks.length !== 1) return null;
|
|
279
|
+
try { return JSON.parse(blocks[0][1]); } catch { return null; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function runnerNotice({ repo, issue, comment, env = process.env, call = null, allowed = RUNNER_REPOS }) {
|
|
283
|
+
if (!repoAllowed(repo, allowed)) throw usage(`${repo} is not a runner repo`);
|
|
284
|
+
const gh = call ?? ((method, path, body) => api(apiBase(env), env.GITHUB_TOKEN, method, path, body));
|
|
285
|
+
if (!call && !env.GITHUB_TOKEN) throw usage('the notice needs GITHUB_TOKEN, the token of the workflow run');
|
|
286
|
+
const got = await gh('GET', `/repos/${repo}/issues/comments/${comment}`);
|
|
287
|
+
if (!got.ok) return { posted: false, why: `GitHub answered ${got.status} for comment ${comment}` };
|
|
288
|
+
const note = got.data;
|
|
289
|
+
if (!String(note?.issue_url ?? '').endsWith(`/repos/${repo}/issues/${issue}`)) return { posted: false, why: `comment ${comment} is not on issue #${issue}` };
|
|
290
|
+
if (note?.author_association !== 'OWNER') return { posted: false, why: `comment ${comment} is not by the owner's login, so no runner wrote it` };
|
|
291
|
+
const thread = await gh('GET', `/repos/${repo}/issues/${issue}`);
|
|
292
|
+
const labels = (thread.data?.labels ?? []).map((one) => (typeof one === 'string' ? one : one?.name));
|
|
293
|
+
if (!thread.ok || !labels.includes(NOTICE_LABEL)) return { posted: false, why: `issue #${issue} is not the notice issue (label ${NOTICE_LABEL})` };
|
|
294
|
+
const facts = factsOf(note.body);
|
|
295
|
+
if (!facts) return { posted: false, why: `comment ${comment} holds no run facts` };
|
|
296
|
+
if (String(facts.repo ?? '').toLowerCase() !== repo.toLowerCase()) return { posted: false, why: `the run facts name ${facts.repo}, not ${repo}` };
|
|
297
|
+
const runLineText = runLine(facts);
|
|
298
|
+
const posted = await gh('POST', `/repos/${repo}/issues/${issue}/comments`, { body: runLineText });
|
|
299
|
+
if (!posted.ok) return { posted: false, why: `GitHub answered ${posted.status} to the notice` };
|
|
300
|
+
return { posted: true, line: runLineText };
|
|
301
|
+
}
|
|
302
|
+
|
|
256
303
|
// ------------------------------------------------------------ the command
|
|
257
304
|
|
|
258
|
-
export async function runnerCommand({ what, repo, issue, holder, from, json = false, args = [] }, line, env = process.env) {
|
|
305
|
+
export async function runnerCommand({ what, repo, issue, holder, from, comment, notice = false, json = false, args = [] }, line, env = process.env) {
|
|
259
306
|
try {
|
|
260
307
|
if ([...args, repo, holder, from].some((a) => typeof a === 'string' && TOKEN_SHAPE.test(a))) throw usage('a token must never be an argument');
|
|
261
308
|
if (what === 'line') {
|
|
262
309
|
if (!from) throw usage('give the run as --from <file>, a JSON object of ids and counts');
|
|
263
|
-
|
|
310
|
+
const facts = JSON.parse(readFileSync(from, 'utf8'));
|
|
311
|
+
const text = runLine(facts);
|
|
312
|
+
line(notice ? noticeBody(facts) : text);
|
|
264
313
|
return 0;
|
|
265
314
|
}
|
|
266
315
|
if (!repoOk(repo)) throw usage('give the repo as --repo <owner/name>');
|
|
@@ -278,7 +327,14 @@ export async function runnerCommand({ what, repo, issue, holder, from, json = fa
|
|
|
278
327
|
line(result.held ? `HELD: issue #${issue} is this run's` : `TAKEN: issue #${issue} is held by ${result.holder}; leave it`);
|
|
279
328
|
return result.held ? 0 : 1;
|
|
280
329
|
}
|
|
281
|
-
|
|
330
|
+
if (what === 'notice') {
|
|
331
|
+
if (!/^[1-9][0-9]{0,8}$/.test(String(issue ?? ''))) throw usage('give the notice issue as --issue <number>');
|
|
332
|
+
if (!/^[1-9][0-9]{0,11}$/.test(String(comment ?? ''))) throw usage('give the run comment as --comment <id>');
|
|
333
|
+
const result = await runnerNotice({ repo, issue: Number(issue), comment: Number(comment), env });
|
|
334
|
+
line(result.posted ? `POSTED: ${result.line}` : `NOT POSTED: ${result.why}`);
|
|
335
|
+
return result.posted ? 0 : 1;
|
|
336
|
+
}
|
|
337
|
+
throw usage('use atlas runner start, claim, line or notice');
|
|
282
338
|
} catch (error) {
|
|
283
339
|
line(`atlas runner: ${error.message}`);
|
|
284
340
|
return error.usage ? 2 : 1;
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -63,6 +63,11 @@ function packagedPinned(packageRoot, source) {
|
|
|
63
63
|
export const packagedJobsWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_JOBS_SOURCE);
|
|
64
64
|
export const packagedUpkeepWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_UPKEEP_SOURCE);
|
|
65
65
|
export const packagedProceduresWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_PROCEDURES_SOURCE);
|
|
66
|
+
// The notice workflow of the runner (R25 line 10). It is not in PINNED: until
|
|
67
|
+
// the owner records the A2 exit test, only the scratch repo gets it, by hand.
|
|
68
|
+
export const RUNNER_NOTICE_PATH = '.github/workflows/atlas-runner-notice.yml';
|
|
69
|
+
export const PACKAGE_RUNNER_NOTICE_SOURCE = 'door/templates/atlas-runner-notice.yml';
|
|
70
|
+
export const packagedRunnerNotice = (packageRoot) => packagedPinned(packageRoot, PACKAGE_RUNNER_NOTICE_SOURCE);
|
|
66
71
|
const pinOf = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
|
|
67
72
|
const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
|
|
68
73
|
// written, refreshed, unchanged or kept: what the writer does with one pinned workflow.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: atlas-runner-notice
|
|
2
|
+
# The notice of the Atlas runner (roadmap R25 line 10). Atlas wrote this file
|
|
3
|
+
# from the Atlas package; do not edit it by hand.
|
|
4
|
+
#
|
|
5
|
+
# A runner session acts as the owner's login, and GitHub does not tell a
|
|
6
|
+
# person about his own comment. So the session posts its run facts in one
|
|
7
|
+
# comment on the notice issue (label atlas-notice), and this job posts the run
|
|
8
|
+
# line as github-actions[bot]. GitHub then tells the owner's phone.
|
|
9
|
+
#
|
|
10
|
+
# It holds no secret, only issues write. It never copies the comment's text:
|
|
11
|
+
# Atlas code reads the facts as JSON and builds the line from ids and counts.
|
|
12
|
+
# Until the owner records the A2 exit test, only the scratch repo has it.
|
|
13
|
+
on:
|
|
14
|
+
issue_comment:
|
|
15
|
+
types: [created]
|
|
16
|
+
|
|
17
|
+
permissions: {}
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
notice:
|
|
21
|
+
if: >-
|
|
22
|
+
github.event.comment.author_association == 'OWNER' &&
|
|
23
|
+
startsWith(github.event.comment.body, '<!-- atlas-runner:run -->') &&
|
|
24
|
+
contains(github.event.issue.labels.*.name, 'atlas-notice')
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
timeout-minutes: 5
|
|
27
|
+
permissions:
|
|
28
|
+
issues: write
|
|
29
|
+
steps:
|
|
30
|
+
# actions/setup-node v7.0.0
|
|
31
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
|
32
|
+
with:
|
|
33
|
+
node-version: "22.22.0"
|
|
34
|
+
- name: Post the run line as the bot
|
|
35
|
+
env:
|
|
36
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
37
|
+
ISSUE: ${{ github.event.issue.number }}
|
|
38
|
+
COMMENT: ${{ github.event.comment.id }}
|
|
39
|
+
run: |
|
|
40
|
+
set -euo pipefail
|
|
41
|
+
npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ runner notice --repo "$GITHUB_REPOSITORY" --issue "$ISSUE" --comment "$COMMENT" | tee -a "$GITHUB_STEP_SUMMARY"
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -147,16 +147,24 @@ Claude cloud session, do these steps before the first reply.
|
|
|
147
147
|
1. Call the `atlas_upgrade` tool with `check: true`. It writes no Atlas
|
|
148
148
|
state.
|
|
149
149
|
2. When `behind` is true, put one line in the reply: "This session runs
|
|
150
|
-
Atlas <loaded>; npm has <newest>.
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
Atlas <loaded>; npm has <newest>." Use the two versions from the answer.
|
|
151
|
+
3. Read `refresh`, the record of the refresh at session start. The tools
|
|
152
|
+
server moves a cloud session to the newest release that the release
|
|
153
|
+
proof and the push audit accept.
|
|
154
|
+
- `moved`: say nothing more. Read the `atlas:lead` skill once, because
|
|
155
|
+
the lead printed at the start can be the old one.
|
|
156
|
+
- `kept`: put one line in the reply, as loud as the line of step 2:
|
|
157
|
+
"The refresh kept Atlas <from>: <reason>." Use `from` and `reason`
|
|
158
|
+
from the record.
|
|
159
|
+
- No `refresh`, or `skipped`: go on.
|
|
160
|
+
4. When `provenance.state` is `differs` or `unproven`, the loaded Atlas
|
|
153
161
|
is not proven to be its release. Say so, with the first reason. Then
|
|
154
162
|
stop all Atlas work until the owner answers.
|
|
155
|
-
|
|
163
|
+
5. When `provenance.state` is `not checked`, say so in one line, with the
|
|
156
164
|
reason. Then go on.
|
|
157
|
-
|
|
165
|
+
6. When the tool is missing, or its answer has no `provenance`, this Atlas
|
|
158
166
|
is too old for the check. Say so in one line, and go on.
|
|
159
|
-
|
|
167
|
+
7. When the tool refuses the check, give its error in one line, and go on.
|
|
160
168
|
|
|
161
169
|
The check runs from the copy it checks. It finds stale or drifted files. It
|
|
162
170
|
cannot find a package made to lie, so never call it a security proof.
|
package/skills/runner/SKILL.md
CHANGED
|
@@ -72,9 +72,12 @@ its environment come from `trigger.yaml` beside this file.
|
|
|
72
72
|
`the merge door is off in the runner`, `start stopped`. A landed pull
|
|
73
73
|
request carries its `number`, its `release`, and the id of its proof
|
|
74
74
|
comment as `proof`. The command refuses any other text.
|
|
75
|
-
8. Run `atlas runner line --from <file
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
8. Run `atlas runner line --from <file> --notice`. Post its output, word for
|
|
76
|
+
word, as one comment on the repo's notice issue (label `atlas-notice`).
|
|
77
|
+
The notice workflow reads the facts and posts the run line as the bot,
|
|
78
|
+
so GitHub tells the owner.
|
|
79
|
+
9. Run `atlas runner line --from <file>`. Its output is the last message of
|
|
80
|
+
the run, word for word.
|
|
78
81
|
|
|
79
82
|
## Until the exit test
|
|
80
83
|
|
package/work/mcp.mjs
CHANGED
|
@@ -29,9 +29,30 @@ import { createMcpServer } from './lib/mcp-stdio.mjs';
|
|
|
29
29
|
import { childEnvironment, CLOUD_NODE_VARIABLES } from './lib/child-env.mjs';
|
|
30
30
|
import { ROLES, roleOf, allow, whoCell } from './lib/roles.mjs';
|
|
31
31
|
import { upgrade, proveCopy } from '../door/lib/install.mjs';
|
|
32
|
+
import { REASONS as REFRESH_REASONS } from '../door/lib/refresh.mjs';
|
|
32
33
|
export { childEnvironment };
|
|
33
34
|
|
|
34
35
|
const ATLAS_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
36
|
+
// The record of the cloud refresh (item ade7d96a). work/start.mjs ran the
|
|
37
|
+
// refresh before it started this process, and passes its record here. Only
|
|
38
|
+
// typed fields pass: the record carries no text from npm or GitHub.
|
|
39
|
+
const REFRESH_STATES = new Set(['moved', 'current', 'kept', 'skipped']);
|
|
40
|
+
const VERSION_TEXT = /^\d+\.\d+\.\d+$/;
|
|
41
|
+
export function refreshRecord(env) {
|
|
42
|
+
let raw;
|
|
43
|
+
try { raw = JSON.parse(env.ATLAS_REFRESH_RECORD ?? 'null'); } catch { return null; }
|
|
44
|
+
if (!raw || typeof raw !== 'object' || !REFRESH_STATES.has(raw.state)) return null;
|
|
45
|
+
const out = { state: raw.state };
|
|
46
|
+
if (typeof raw.code === 'string' && /^[a-z-]{1,20}$/.test(raw.code)) out.code = raw.code;
|
|
47
|
+
for (const key of ['from', 'to']) if (typeof raw[key] === 'string' && VERSION_TEXT.test(raw[key])) out[key] = raw[key];
|
|
48
|
+
if (typeof raw.commit === 'string' && /^[0-9a-f]{40}$/.test(raw.commit)) out.commit = raw.commit;
|
|
49
|
+
if (Number.isInteger(raw.run)) out.run = raw.run;
|
|
50
|
+
for (const key of ['runs', 'issues']) if (Array.isArray(raw[key])) out[key] = raw[key].filter(Number.isInteger).slice(0, 10);
|
|
51
|
+
if (Array.isArray(raw.commits)) out.commits = raw.commits.filter((one) => typeof one === 'string' && /^[0-9a-f]{12}$/.test(one)).slice(0, 10);
|
|
52
|
+
if (raw.command === 'moved' || raw.command === 'behind') out.command = raw.command;
|
|
53
|
+
if (typeof raw.seconds === 'number' && Number.isFinite(raw.seconds)) out.seconds = raw.seconds;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
35
56
|
const S = (description, extra = {}) => ({ type: 'string', description, ...extra });
|
|
36
57
|
const ORIGIN = { type: 'object', description: "The owner's words, verbatim, with where and when they were said.", required: ['text', 'source', 'date'], properties: { text: S('Verbatim words of the owner.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), date: S('ISO date, in the form YYYY-MM-DD.') } };
|
|
37
58
|
// Roadmap R23: the origin of a new item. The owner's words, or a typed
|
|
@@ -81,7 +102,7 @@ const TOOL_LIST = [
|
|
|
81
102
|
{ name: 'where_are_we', description: 'Answer "where are we with X" in six parts: live support and known limits from the code read at question time, then in progress, delivered and proposed from the items, then the current priority decision. Every row carries a citation: a path and a line, an entity id, a pull request, or a decision date. The code half comes from the sub-agent digests, which are refused when they cite a never-read path or quote a never-quote path, and every string is scrubbed by pattern. Computed at read time and never stored. Scope: read, every role.', inputSchema: { type: 'object', required: ['product', 'capability', 'code'], properties: { product: S('Product registry id.'), capability: S('The capability the question is about, the X.'), code: { type: 'array', description: 'One digest per repo, from the sub-agents briefed by code_read_brief. At least one is required: an answer with no code read says nothing about what the code does today.', items: CODE_DIGEST }, items: { type: 'array', description: 'Item ids the lead judges to answer this question.', items: { type: 'string' } }, match: { type: 'array', description: 'Terms to match against item titles, summaries and tags. Give items, match, or both.', items: { type: 'string' } }, movement_repos: { type: 'array', description: "Narrow the movement read to these registered repo ids. Default: every repo of the product. The digests' own repos are always read.", items: { type: 'string' } }, since: S('ISO date. Adds what changed since then.') }, additionalProperties: false } },
|
|
82
103
|
{ name: 'built_pending_possible', description: 'Answer "what are we building, what is built, what is pending and what can be done": delivered items by date, active items, paused items with their reason, proposed items with their age, and the decisions that set the order. Item records, delivery marks and decisions only; no code read and no GitHub read. Computed at read time and never stored. Scope: read, every role.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Limits built and the decisions to what came after it.') }, additionalProperties: false } },
|
|
83
104
|
{ name: 'design_index', description: 'List every design: each work item with a design-doc link, in any lifecycle, newest change first, with its product, its state for the hub ("Waiting on the owner", "Building", "Paused", "Shipped", "Dropped"), its design docs and its pages. Also returns design_hub_url, the one hub page. When: before the lead rebuilds the "Design docs" hub, after each design publish, lock and done. Item records only; computed at read time and never stored. Scope: read, every role.', inputSchema: { type: 'object', properties: { product: S('Limit the list to one product id.') }, additionalProperties: false } },
|
|
84
|
-
{ name: 'atlas_upgrade', description: 'Move Atlas on this Mac to the newest release: the same steps as the "atlas upgrade --yes" command. It checks that a master release run built the release, installs it and updates the plugin. When: the owner asks to upgrade Atlas. Use this tool and not the command: the Bash sandbox blocks the writes to ~/.npm, ~/.atlas and ~/.claude/plugins, and this server runs outside it. With check true it writes no Atlas state: it reports the loaded and the newest version, and checks that the loaded files are the files of their master release commit (provenance: match, differs, unproven or not checked). The check runs from the copy it checks, so it finds stale or drifted files, not a package made to lie. The check runs anywhere and in every role, also in a Claude cloud session; the lead calls it at the first reply of a cloud session. Scope: this Mac, no tracker write. The upgrade itself never runs in a Claude cloud session:
|
|
105
|
+
{ name: 'atlas_upgrade', description: 'Move Atlas on this Mac to the newest release: the same steps as the "atlas upgrade --yes" command. It checks that a master release run built the release, installs it and updates the plugin. When: the owner asks to upgrade Atlas. Use this tool and not the command: the Bash sandbox blocks the writes to ~/.npm, ~/.atlas and ~/.claude/plugins, and this server runs outside it. With check true it writes no Atlas state: it reports the loaded and the newest version, and checks that the loaded files are the files of their master release commit (provenance: match, differs, unproven or not checked). The check runs from the copy it checks, so it finds stale or drifted files, not a package made to lie. The check runs anywhere and in every role, also in a Claude cloud session; the lead calls it at the first reply of a cloud session. Scope: this Mac, no tracker write. The upgrade itself never runs in a Claude cloud session: there the tools server refreshes itself at start, and check true returns that record as refresh (state moved, current, kept or skipped, with a code).', inputSchema: { type: 'object', properties: { check: { type: 'boolean', description: 'True: report the versions and the provenance of the loaded copy, and install nothing.' } }, additionalProperties: false } },
|
|
85
106
|
];
|
|
86
107
|
// Roadmap R20: each description ends with who may call the tool, read from
|
|
87
108
|
// the role table, so the text and the guard cannot differ.
|
|
@@ -310,9 +331,10 @@ export async function runUpgrade({ args = {}, env = process.env, role = 'crew',
|
|
|
310
331
|
if (!PROOFS.has(proveFn)) PROOFS.set(proveFn, new Map());
|
|
311
332
|
const proofs = PROOFS.get(proveFn);
|
|
312
333
|
if (!proofs.has(packageRoot)) proofs.set(packageRoot, proveFn({ packageRoot, exec }));
|
|
313
|
-
|
|
334
|
+
const refresh = refreshRecord(env);
|
|
335
|
+
return { loaded, newest, behind: newest !== loaded, provenance: proofs.get(packageRoot), ...(refresh ? { refresh: { ...refresh, ...(REFRESH_REASONS[refresh.code] ? { reason: REFRESH_REASONS[refresh.code] } : {}) } } : {}) };
|
|
314
336
|
}
|
|
315
|
-
if (inClaudeCloud(env)) refuse('atlas_upgrade does not run in a Claude cloud session.
|
|
337
|
+
if (inClaudeCloud(env)) refuse('atlas_upgrade does not run in a Claude cloud session. The tools server refreshes itself at the start of each cloud session; check true gives that record.');
|
|
316
338
|
const lines = [];
|
|
317
339
|
let code;
|
|
318
340
|
try { code = await upgradeFn({ packageRoot, options: { yes: true }, line: (text) => lines.push(text), env, exec }); } catch (error) {
|
|
@@ -345,6 +367,8 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
|
|
|
345
367
|
const verbsFor = (boundClient) => createVerbs({ client, store, writer: { token_id: connection.tokenId }, session, agent, role, roleFrom, boundClient, readGitHub: readGitHub ?? githubReader(), readLive: live });
|
|
346
368
|
let verbs = connection.state === 'ok' ? verbsFor(null) : null;
|
|
347
369
|
const health = { package_version: ATLAS_VERSION, started_at: new Date().toISOString(), pid: process.pid, node: process.version, connection: connection.state, connection_expires: connection.expires ?? connection.expired ?? null, role, role_from: roleFrom, gh: findOnPath('gh', env), engram: connection.state === 'ok' ? 'not checked yet' : connection.message };
|
|
370
|
+
const refresh = refreshRecord(env);
|
|
371
|
+
if (refresh) health.refresh = refresh;
|
|
348
372
|
writeHealth(env, health);
|
|
349
373
|
if (client) {
|
|
350
374
|
client.getPublisher().then(() => { health.engram = 'answered'; }, (error) => { health.engram = `did not answer: ${error.message}`; }).finally(() => writeHealth(env, health));
|
|
@@ -352,6 +376,11 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
|
|
|
352
376
|
const instructions = connection.state === 'ok'
|
|
353
377
|
? `Atlas work verbs. Session ${session}; role: ${role}, from ${roleFrom}. The role table (packages/atlas/work/lib/roles.mjs) says which tools this role may call; the others refuse. Call registry_list first to learn the product and repo ids, needs_me to see the open questions, and in_flight to see the active work. Every write needs a request_id. The server stamps writer.session and the bound client; never pass writer, session, actor or role. Unknown product or repo ids are refused. The server cannot tell a sub-agent from the lead in one session, so an Atlas crew holds no MCP tool: its tools line names none.`
|
|
354
378
|
: `Atlas work verbs. They cannot reach Engram yet: ${connection.message}`;
|
|
379
|
+
// The lead hook printed the lead at the start, maybe from the old copy, at
|
|
380
|
+
// the same time as the refresh ran. So a moved copy asks for one fresh read.
|
|
381
|
+
const refreshLine = refresh?.state === 'moved'
|
|
382
|
+
? ` Atlas moved from ${refresh.from} to ${refresh.to} at the start of this session. The lead text printed at the start may be the old one: read the atlas:lead skill once now.`
|
|
383
|
+
: refresh?.state === 'kept' ? ` The cloud refresh kept Atlas ${refresh.from ?? ''} (${refresh.code ?? 'stopped'}). Call atlas_upgrade with check true, and say so at the first reply.` : '';
|
|
355
384
|
// Roadmap R15: the step-in rows of the hook become marks at the server's
|
|
356
385
|
// start and before each write verb. A failed flush leaves the rows for the
|
|
357
386
|
// next one and never stops the verb. With ATLAS_HOOKS=off nothing counts
|
|
@@ -372,7 +401,7 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
|
|
|
372
401
|
flush(),
|
|
373
402
|
env.ATLAS_HOOKS === 'off' ? verbs.stepIns.notCounted(claudeSession).catch((error) => say(`step-ins: the "not counted" mark was not written: ${error.message}`)) : null,
|
|
374
403
|
]) : Promise.resolve();
|
|
375
|
-
const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions
|
|
404
|
+
const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions: `${instructions}${refreshLine}`, tools: TOOLS, input, output, log, call: async (name, args, boundTo) => {
|
|
376
405
|
// The upgrade needs no Engram connection: an old key must not block a new release.
|
|
377
406
|
if (name === 'atlas_upgrade') return runUpgrade({ args, env, role: upgradeRole.role, roleFrom: upgradeRole.from });
|
|
378
407
|
if (!verbs) throw new Error(connection.message);
|
package/work/start.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The entry of the Atlas tools server (item ade7d96a, design
|
|
2
|
+
// docs/design-docs/cloud-self-refresh/). Claude Code starts this file in
|
|
3
|
+
// every session, from .mcp.json.
|
|
4
|
+
//
|
|
5
|
+
// 1. In a Claude cloud session it first runs the refresh, door/lib/refresh.mjs,
|
|
6
|
+
// as its own process with the short child environment: never the Engram
|
|
7
|
+
// key. The refresh may put a newer, proven and audited release in this
|
|
8
|
+
// folder.
|
|
9
|
+
// 2. Then it starts the tools server, work/mcp.mjs, as a new process from the
|
|
10
|
+
// files now in place, so no module of the old copy runs next to the new
|
|
11
|
+
// one. The server reads the refresh record from ATLAS_REFRESH_RECORD.
|
|
12
|
+
//
|
|
13
|
+
// This process loads one module of the package, the child environment, and
|
|
14
|
+
// never the server code. The server and the refresh each run in a process of
|
|
15
|
+
// their own, so old and new modules never mix in one process.
|
|
16
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
17
|
+
import { realpathSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { childEnvironment } from './lib/child-env.mjs';
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const packageRoot = dirname(here);
|
|
24
|
+
const say = (line) => process.stderr.write(`atlas-work start: ${line}\n`);
|
|
25
|
+
// The refresh has 20 s before its swap and 10 s for each program call. This
|
|
26
|
+
// limit stops a refresh that hangs, before Claude Code gives up on the tools
|
|
27
|
+
// server (security review of item ade7d96a). Claude Code waits about 30 s
|
|
28
|
+
// for a server to start, or MCP_TIMEOUT ms when the environment sets it
|
|
29
|
+
// (probe, 31 October 2026). With MCP_TIMEOUT, the refresh gets that time
|
|
30
|
+
// less 15 s, up to the refresh's own cap.
|
|
31
|
+
const REFRESH_LIMIT_MS = 25_000;
|
|
32
|
+
export function limitsOf(env) {
|
|
33
|
+
const wait = Number(env.MCP_TIMEOUT);
|
|
34
|
+
if (!Number.isInteger(wait) || wait <= 30_000) return { limit: REFRESH_LIMIT_MS, budget: null };
|
|
35
|
+
const limit = Math.min(wait - 15_000, 105_000);
|
|
36
|
+
return { limit: Math.max(limit, REFRESH_LIMIT_MS), budget: Math.max(limit - 5_000, 20_000) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function refreshFirst({ env = process.env, root = packageRoot, run = spawnSync } = {}) {
|
|
40
|
+
if (env.CLAUDE_CODE_REMOTE !== 'true' || env.ATLAS_REFRESH === 'off') return null;
|
|
41
|
+
const { limit, budget } = limitsOf(env);
|
|
42
|
+
const childEnv = { ...childEnvironment(env), CLAUDE_CODE_REMOTE: 'true', ...(budget ? { ATLAS_REFRESH_BUDGET_MS: String(budget) } : {}) };
|
|
43
|
+
const result = run(process.execPath, [join(root, 'door', 'lib', 'refresh.mjs')], { env: childEnv, encoding: 'utf8', timeout: limit, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
const last = String(result.stdout ?? '').trim().split('\n').at(-1) ?? '';
|
|
45
|
+
try { return JSON.parse(last); } catch { return { state: 'kept', code: 'stopped' }; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function startServer({ env = process.env, root = packageRoot, record = null, start = spawn } = {}) {
|
|
49
|
+
const serverEnv = { ...env };
|
|
50
|
+
if (record) serverEnv.ATLAS_REFRESH_RECORD = JSON.stringify(record);
|
|
51
|
+
const child = start(process.execPath, [join(root, 'work', 'mcp.mjs')], { env: serverEnv, stdio: 'inherit' });
|
|
52
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => child.kill(signal));
|
|
53
|
+
child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
|
|
54
|
+
return child;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Real paths on both sides, as in mcp.mjs: a plugin cache can sit behind a link.
|
|
58
|
+
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
59
|
+
let record = null;
|
|
60
|
+
try { record = refreshFirst(); } catch (error) { record = { state: 'kept', code: 'stopped' }; say(`the refresh stopped: ${error.message}`); }
|
|
61
|
+
if (record) say(`refresh ${record.state}${record.code ? ` (${record.code})` : ''}: ${record.from ?? '?'} -> ${record.to ?? record.from ?? '?'}`);
|
|
62
|
+
startServer({ record });
|
|
63
|
+
}
|