@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.52",
3
+ "version": "0.4.54",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/.mcp.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "atlas-work": {
4
4
  "command": "node",
5
5
  "args": [
6
- "${CLAUDE_PLUGIN_ROOT}/work/mcp.mjs"
6
+ "${CLAUDE_PLUGIN_ROOT}/work/start.mjs"
7
7
  ],
8
8
  "env": {
9
9
  "ATLAS_ENGRAM": "",
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) {
@@ -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
- exec('npm', ['pack', `${name}@${target}`, '--pack-destination', work]);
349
- const tarball = readdirSync(work).find((entry) => entry.endsWith('.tgz'));
350
- if (!tarball) throw new Error('npm pack wrote no tarball');
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', join(work, tarball)]);
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
+ }
@@ -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
- line(runLine(JSON.parse(readFileSync(from, 'utf8'))));
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
- throw usage('use atlas runner start, atlas runner claim or atlas runner line');
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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.52",
3
+ "version": "0.4.54",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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>. A new session from the cloud
151
- environment loads the newest." Use the two versions from the answer.
152
- 3. When `provenance.state` is `differs` or `unproven`, the loaded Atlas
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
- 4. When `provenance.state` is `not checked`, say so in one line, with the
163
+ 5. When `provenance.state` is `not checked`, say so in one line, with the
156
164
  reason. Then go on.
157
- 5. When the tool is missing, or its answer has no `provenance`, this Atlas
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
- 6. When the tool refuses the check, give its error in one line, and go on.
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.
@@ -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>`. Its output is the last message of
76
- the run, word for word. The notice workflow posts it on the repo's notice
77
- issue.
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: a new cloud session installs the newest release.', inputSchema: { type: 'object', properties: { check: { type: 'boolean', description: 'True: report the versions and the provenance of the loaded copy, and install nothing.' } }, additionalProperties: false } },
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
- return { loaded, newest, behind: newest !== loaded, provenance: proofs.get(packageRoot) };
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. A new cloud session installs the newest release.');
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, tools: TOOLS, input, output, log, call: async (name, args, boundTo) => {
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
+ }