@arjunkhera/atlas 0.3.44 → 0.3.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.44",
3
+ "version": "0.3.46",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -36,6 +36,7 @@ import { issuesCommand as testsIssues } from './lib/issues.mjs';
36
36
  import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
37
37
  import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
38
38
  import { upkeepCommand } from './lib/upkeep.mjs';
39
+ import { gateCommand } from './lib/gate.mjs';
39
40
  import { judgeCommand } from './lib/judge.mjs';
40
41
 
41
42
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -124,6 +125,11 @@ const HELP = `atlas — the door into a repo's Atlas files
124
125
  --event <file> the GitHub pull request event; adds the item check
125
126
  --json print JSON
126
127
 
128
+ atlas gate apply --repo <owner/name> set the gate: two rulesets from atlas.yaml required_checks, and auto-merge
129
+ --root <repo> the checkout to read atlas.yaml from (default: this folder)
130
+ --dry-run print each call; change nothing
131
+ --off | --on break glass: turn both rulesets off, or back on
132
+
127
133
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
128
134
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
129
135
  atlas judge run <tasks.md> <id> --out <dir> one fresh agent does one sealed task, in a clean clone at the sealed commit
@@ -134,8 +140,10 @@ const HELP = `atlas — the door into a repo's Atlas files
134
140
  compare the judge's verdict with the sealed tasks and the runs; exit 0 only when all pass
135
141
 
136
142
  atlas install install Atlas for every folder on this Mac
143
+ --cloud in a cloud setup script: skip the checks that need a started session
137
144
  atlas upgrade [--version <v>] check, show and install a newer release
138
145
  atlas doctor is Atlas installed and working here
146
+ --cloud before the first cloud session: skip the checks that need a started session
139
147
 
140
148
  Atlas ${ATLAS_VERSION}. Check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}.
141
149
  A person merges every file the tooling writer writes.
@@ -161,10 +169,11 @@ export const FLAGS = Object.freeze({
161
169
  view: { product: 'value', name: 'value', out: 'value' },
162
170
  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' },
163
171
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
172
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch' },
164
173
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
165
- install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
174
+ install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
166
175
  upgrade: { version: 'optional', yes: 'switch' },
167
- doctor: {},
176
+ doctor: { cloud: 'switch' },
168
177
  });
169
178
 
170
179
  // Every command takes --help.
@@ -371,6 +380,10 @@ function upkeepVerb(chosen) {
371
380
  return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json) }, rootOf(chosen), line);
372
381
  }
373
382
 
383
+ function gateVerb(chosen) {
384
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on) }, line);
385
+ }
386
+
374
387
  function judgeVerb(chosen) {
375
388
  return judgeCommand({ what: chosen._[1], file: chosen._[2], task: chosen._[3], tasks: chosen.tasks, verdict: chosen.verdict, seal: chosen.seal, runs: chosen.runs, out: chosen.out, repo: chosen.repo, budget: chosen.budget, model: chosen.model }, line, PACKAGE_ROOT);
376
389
  }
@@ -408,6 +421,7 @@ export async function main(argv = process.argv.slice(2)) {
408
421
  case 'view': return viewCommand(chosen);
409
422
  case 'tests': return testsCommand(chosen);
410
423
  case 'upkeep': return upkeepVerb(chosen);
424
+ case 'gate': return gateVerb(chosen);
411
425
  case 'judge': return judgeVerb(chosen);
412
426
  default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
413
427
  }
@@ -0,0 +1,193 @@
1
+ // `atlas gate apply`: the setup command of the gate (roadmap R9, design
2
+ // docs/design-docs/gate-apply/). The owner runs it with his own GitHub login.
3
+ // It sets two rulesets from the repo's atlas.yaml and allows auto-merge:
4
+ // 1. "atlas gate" on the default branch: a pull request with no approval,
5
+ // each required check from GitHub Actions, no force push, no delete;
6
+ // 2. "atlas release tags" on v* tags: no update, no delete.
7
+ // The bypass list stays empty. --off and --on are the break glass.
8
+ //
9
+ // The plan is built by pure functions, so a test reads it without GitHub. The
10
+ // gh calls sit in one place, behind `gh`, which a test replaces.
11
+ import { execFileSync } from 'node:child_process';
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { childEnvironment } from '../../work/lib/child-env.mjs';
15
+ import { parseYaml } from '../../shape/check.mjs';
16
+
17
+ export const BRANCH_RULESET = 'atlas gate';
18
+ export const TAG_RULESET = 'atlas release tags';
19
+ // The app that posts GitHub Actions check runs. A status that another app posts
20
+ // under the same name does not count.
21
+ export const ACTIONS_APP_ID = 15368;
22
+
23
+ export class GateError extends Error {}
24
+
25
+ // What the gate reads from atlas.yaml.
26
+ export function gateFacts(root) {
27
+ const path = join(root, 'atlas.yaml');
28
+ if (!existsSync(path)) throw new GateError(`there is no atlas.yaml in ${root}. Nothing was changed.`);
29
+ const facts = parseYaml(readFileSync(path, 'utf8')) ?? {};
30
+ const checks = facts.required_checks;
31
+ if (!Array.isArray(checks) || checks.length === 0 || checks.some((name) => typeof name !== 'string' || !name.trim())) {
32
+ throw new GateError('atlas.yaml lists no required_checks. Nothing was changed.');
33
+ }
34
+ if (facts.up_to_date !== undefined && typeof facts.up_to_date !== 'boolean') throw new GateError('atlas.yaml up_to_date must be true or false. Nothing was changed.');
35
+ return { checks: [...checks], upToDate: facts.up_to_date === true };
36
+ }
37
+
38
+ // The two ruleset bodies, as the GitHub rulesets API takes them.
39
+ export function rulesets({ checks, upToDate }, enforcement = 'active') {
40
+ return [
41
+ {
42
+ name: BRANCH_RULESET,
43
+ target: 'branch',
44
+ enforcement,
45
+ bypass_actors: [],
46
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
47
+ rules: [
48
+ {
49
+ type: 'pull_request',
50
+ parameters: {
51
+ required_approving_review_count: 0,
52
+ dismiss_stale_reviews_on_push: false,
53
+ require_code_owner_review: false,
54
+ require_last_push_approval: false,
55
+ required_review_thread_resolution: false,
56
+ },
57
+ },
58
+ {
59
+ type: 'required_status_checks',
60
+ parameters: {
61
+ strict_required_status_checks_policy: upToDate,
62
+ required_status_checks: checks.map((context) => ({ context, integration_id: ACTIONS_APP_ID })),
63
+ },
64
+ },
65
+ { type: 'non_fast_forward' },
66
+ { type: 'deletion' },
67
+ ],
68
+ },
69
+ {
70
+ name: TAG_RULESET,
71
+ target: 'tag',
72
+ enforcement,
73
+ bypass_actors: [],
74
+ conditions: { ref_name: { include: ['refs/tags/v*'], exclude: [] } },
75
+ rules: [{ type: 'update' }, { type: 'deletion' }],
76
+ },
77
+ ];
78
+ }
79
+
80
+ // The calls of one run. `existing` is the list GitHub gives for the repo's
81
+ // rulesets: [{ id, name }]. A ruleset that exists is updated, never doubled.
82
+ // In a dry run, a ruleset that does not exist yet shows as <id>.
83
+ export function plan({ repo, facts, existing = [], mode = 'apply', dryRun = false }) {
84
+ const ids = new Map(existing.map((ruleset) => [ruleset.name, ruleset.id]));
85
+ if (mode === 'off' || mode === 'on') {
86
+ const enforcement = mode === 'off' ? 'disabled' : 'active';
87
+ return [BRANCH_RULESET, TAG_RULESET].map((name) => {
88
+ if (!ids.has(name) && !dryRun) throw new GateError(`the repo has no ruleset "${name}". Run "atlas gate apply" first.`);
89
+ return { method: 'PUT', path: `repos/${repo}/rulesets/${ids.get(name) ?? '<id>'}`, name, body: { enforcement } };
90
+ });
91
+ }
92
+ const calls = rulesets(facts).map((body) => (ids.has(body.name)
93
+ ? { method: 'PUT', path: `repos/${repo}/rulesets/${ids.get(body.name)}`, name: body.name, body }
94
+ : { method: 'POST', path: `repos/${repo}/rulesets`, name: body.name, body }));
95
+ calls.push({ method: 'PATCH', path: `repos/${repo}`, name: 'allow auto-merge', body: { allow_auto_merge: true } });
96
+ return calls;
97
+ }
98
+
99
+ // One readable block for each call, for the dry run and the log.
100
+ export function describe(call) {
101
+ const lines = [`${call.method.padEnd(6)} ${call.path} ${call.name}`];
102
+ const body = call.body;
103
+ if (body.rules) {
104
+ const on = body.target === 'branch' ? `branch ${body.conditions.ref_name.include.join(', ')}` : `tag ${body.conditions.ref_name.include.join(', ')}`;
105
+ lines.push(` ${on}, enforcement ${body.enforcement}, bypass ${body.bypass_actors.length ? body.bypass_actors.length : 'none'}`);
106
+ for (const rule of body.rules) {
107
+ if (rule.type === 'pull_request') lines.push(` pull_request: ${rule.parameters.required_approving_review_count} approvals`);
108
+ else if (rule.type === 'required_status_checks') {
109
+ lines.push(` required_status_checks (strict ${rule.parameters.strict_required_status_checks_policy}), from GitHub Actions (${ACTIONS_APP_ID}):`);
110
+ for (const check of rule.parameters.required_status_checks) lines.push(` ${check.context}`);
111
+ } else lines.push(` ${rule.type}`);
112
+ }
113
+ } else {
114
+ lines.push(` ${Object.entries(body).map(([key, value]) => `${key} ${value}`).join(', ')}`);
115
+ }
116
+ return lines.join('\n');
117
+ }
118
+
119
+ // What a read-back must hold, compared key by key. Returns the differences.
120
+ export function compare(wanted, got) {
121
+ const problems = [];
122
+ if (got.enforcement !== wanted.enforcement) problems.push(`${wanted.name}: enforcement is ${got.enforcement}, not ${wanted.enforcement}`);
123
+ if ((got.bypass_actors ?? []).length !== 0) problems.push(`${wanted.name}: the bypass list is not empty`);
124
+ if (JSON.stringify(got.conditions?.ref_name?.include ?? []) !== JSON.stringify(wanted.conditions.ref_name.include)) problems.push(`${wanted.name}: it holds for ${JSON.stringify(got.conditions?.ref_name?.include)}`);
125
+ for (const rule of wanted.rules) {
126
+ const found = (got.rules ?? []).find((each) => each.type === rule.type);
127
+ if (!found) { problems.push(`${wanted.name}: the rule ${rule.type} is missing`); continue; }
128
+ if (rule.type === 'pull_request' && found.parameters?.required_approving_review_count !== 0) problems.push(`${wanted.name}: pull_request asks for ${found.parameters?.required_approving_review_count} approvals`);
129
+ if (rule.type === 'required_status_checks') {
130
+ const key = (check) => `${check.context}|${check.integration_id}`;
131
+ const want = rule.parameters.required_status_checks.map(key).sort();
132
+ const have = (found.parameters?.required_status_checks ?? []).map(key).sort();
133
+ if (JSON.stringify(want) !== JSON.stringify(have)) problems.push(`${wanted.name}: the required checks are ${JSON.stringify(have)}, not ${JSON.stringify(want)}`);
134
+ if (Boolean(found.parameters?.strict_required_status_checks_policy) !== rule.parameters.strict_required_status_checks_policy) problems.push(`${wanted.name}: up to date is ${found.parameters?.strict_required_status_checks_policy}`);
135
+ }
136
+ }
137
+ return problems;
138
+ }
139
+
140
+ // The gh door. Each call is `gh api` with a JSON body on stdin.
141
+ export function ghApi(method, path, body) {
142
+ const args = ['api', '--method', method, path, '-H', 'Accept: application/vnd.github+json'];
143
+ if (body !== undefined) args.push('--input', '-');
144
+ const out = execFileSync('gh', args, { encoding: 'utf8', input: body === undefined ? undefined : JSON.stringify(body), env: childEnvironment(), stdio: ['pipe', 'pipe', 'pipe'] });
145
+ return out.trim() ? JSON.parse(out) : null;
146
+ }
147
+
148
+ const explain = (error) => {
149
+ const text = `${error.stderr ?? ''}${error.message ?? ''}`;
150
+ if (/HTTP 403|Upgrade to GitHub Pro|403/.test(text)) return 'GitHub refused the ruleset (403). A private repo needs GitHub Pro for rulesets, and only a repo admin may set one.';
151
+ return text.trim().split('\n').slice(-3).join(' ');
152
+ };
153
+
154
+ // `atlas gate apply`. Returns the exit code.
155
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false }, line, gh = ghApi) {
156
+ if (what !== 'apply') throw new GateError(`there is no gate verb "${what ?? ''}". Use apply.`);
157
+ if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError('atlas gate apply needs --repo <owner/name>.');
158
+ if (off && on) throw new GateError('pick --off or --on, not both.');
159
+ const mode = off ? 'off' : on ? 'on' : 'apply';
160
+ const facts = mode === 'apply' ? gateFacts(root) : null;
161
+ if (dryRun) line('atlas gate apply: dry run, nothing changes.');
162
+ if (facts) line(`atlas.yaml read from ${root}: ${facts.checks.length} required check(s), up_to_date ${facts.upToDate}.`);
163
+ line(`GET repos/${repo}/rulesets`);
164
+ let existing;
165
+ try { existing = gh('GET', `repos/${repo}/rulesets`) ?? []; } catch (error) {
166
+ if (!dryRun) { line(`atlas gate apply: ${explain(error)} Nothing was changed.`); return 1; }
167
+ line(` (the read failed: ${explain(error)} The plan below assumes no ruleset exists.)`);
168
+ existing = [];
169
+ }
170
+ const calls = plan({ repo, facts, existing, mode, dryRun });
171
+ for (const call of calls) line(describe(call));
172
+ if (dryRun) return 0;
173
+ const written = [];
174
+ for (const call of calls) {
175
+ try { written.push({ call, answer: gh(call.method, call.path, call.body) }); } catch (error) {
176
+ line(`atlas gate apply: ${call.method} ${call.path} failed: ${explain(error)}`);
177
+ line(written.length ? `Done before the failure: ${written.map((each) => each.call.name).join(', ')}.` : 'Nothing was changed.');
178
+ return 1;
179
+ }
180
+ }
181
+ if (mode !== 'apply') { line(`atlas gate apply: both rulesets are ${mode === 'off' ? 'disabled' : 'active'}.`); return 0; }
182
+ const problems = [];
183
+ for (const wanted of rulesets(facts)) {
184
+ const id = written.find((each) => each.call.name === wanted.name)?.answer?.id ?? existing.find((ruleset) => ruleset.name === wanted.name)?.id;
185
+ const got = gh('GET', `repos/${repo}/rulesets/${id}`);
186
+ problems.push(...compare(wanted, got ?? {}));
187
+ }
188
+ const settings = gh('GET', `repos/${repo}`);
189
+ if (settings?.allow_auto_merge !== true) problems.push('the repo does not allow auto-merge');
190
+ if (problems.length) { for (const problem of problems) line(` ✗ ${problem}`); line('atlas gate apply: the read-back differs. Look at the repo settings.'); return 1; }
191
+ line(`atlas gate apply: the gate is on for ${repo}. Read back: ${BRANCH_RULESET}, ${TAG_RULESET}, allow_auto_merge true.`);
192
+ return 0;
193
+ }
@@ -140,9 +140,12 @@ export async function install({ packageRoot, options = {}, line = console.log, e
140
140
  const installArgs = ['plugin', 'install', PLUGIN_ID, '--scope', 'user'];
141
141
  exec('claude', installArgs);
142
142
  line('');
143
- for (const text of connectionSteps(env)) line(text);
143
+ // A cloud setup script runs before any session: the key comes from the
144
+ // environment variable, and no tools server has started yet (item 92cfbba3).
145
+ if (options.cloud) line('Installed. In a cloud session the key comes from the environment, and the lead checks the version at its first reply.');
146
+ else for (const text of connectionSteps(env)) line(text);
144
147
  line('');
145
- return doctor({ packageRoot, options, line, env, exec });
148
+ return doctor({ packageRoot, options: { cloud: Boolean(options.cloud) }, line, env, exec });
146
149
  }
147
150
 
148
151
  // ---------------------------------------------------------------- upgrade
@@ -151,13 +154,15 @@ export async function install({ packageRoot, options = {}, line = console.log, e
151
154
  export const blobSha = (bytes) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
152
155
  export const sha512Integrity = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
153
156
 
154
- function filesUnder(root) {
157
+ function filesUnder(root, skip = () => false) {
155
158
  const out = new Map();
156
159
  const walk = (dir) => {
157
160
  for (const entry of readdirSync(dir)) {
158
161
  const full = join(dir, entry);
162
+ const path = relative(root, full).split('\\').join('/');
163
+ if (skip(path)) continue;
159
164
  if (statSync(full).isDirectory()) walk(full);
160
- else out.set(relative(root, full).split('\\').join('/'), readFileSync(full));
165
+ else out.set(path, readFileSync(full));
161
166
  }
162
167
  };
163
168
  walk(root);
@@ -201,6 +206,36 @@ export function matchTree({ tree, files }) {
201
206
  return problems;
202
207
  }
203
208
 
209
+ // Claude Code writes `.in_use/` into a plugin copy to mark it in use. It is
210
+ // not part of the package, so the proof of a loaded copy skips it.
211
+ const COPY_MARKER = /^\.in_use(\/|$)/;
212
+ const firstLines = (error, count = 3) => String(error?.stderr || error?.message || error).trim().split('\n').slice(0, count).join(' ');
213
+
214
+ // Is the copy at packageRoot the copy that a master release run built? The
215
+ // same proof as the upgrade, run on files already loaded. It reads only.
216
+ // The proof runs from the copy it checks, so it catches stale, broken or
217
+ // drifted files, not a package made to lie (security review of atlas#187).
218
+ // "unproven" is the upgrade's refusal: no master run built this version.
219
+ // "not checked" is never a pass: the caller says the reason.
220
+ export function proveCopy({ packageRoot, exec = defaultExec }) {
221
+ try {
222
+ const { version } = packageJson(packageRoot);
223
+ const repo = repoOf(packageRoot);
224
+ const gh = (args) => JSON.parse(exec('gh', args));
225
+ let run;
226
+ try { run = findReleaseRun({ gh, repo, version }); } catch (error) { return { state: 'not checked', reason: `GitHub did not answer: ${firstLines(error)}` }; }
227
+ if (!run) return { state: 'unproven', reason: `no successful release run on a commit of master built ${version}. It may have been published from a branch.` };
228
+ let tree;
229
+ try { tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${run.head_sha}?recursive=1`]).tree; } catch (error) { return { state: 'not checked', reason: `GitHub did not give the file tree: ${firstLines(error)}` }; }
230
+ const files = filesUnder(packageRoot, (path) => COPY_MARKER.test(path));
231
+ const problems = matchTree({ tree, files });
232
+ const found = { run: run.id, commit: run.head_sha };
233
+ return problems.length ? { state: 'differs', ...found, problems: problems.slice(0, 20) } : { state: 'match', ...found, files: files.size };
234
+ } catch (error) {
235
+ return { state: 'not checked', reason: `the proof stopped: ${firstLines(error)}` };
236
+ }
237
+ }
238
+
204
239
  export async function upgrade({ packageRoot, options = {}, line = console.log, env = process.env, exec = defaultExec, ask = defaultAsk }) {
205
240
  const { name, version: installed } = packageJson(packageRoot);
206
241
  const repo = repoOf(packageRoot);
@@ -276,11 +311,18 @@ export function doctor({ packageRoot, options = {}, line = console.log, env = pr
276
311
  }
277
312
 
278
313
  // Off the cloud, the key lives only in the connection file. The cloud passes it as a variable.
279
- if (env.CLAUDE_CODE_REMOTE !== 'true') {
314
+ if (env.CLAUDE_CODE_REMOTE !== 'true' && !options.cloud) {
280
315
  const connection = readConnection({ HOME: env.HOME });
281
316
  check('connection', connection.state === 'ok', connection.state === 'ok' ? `the Engram connection file is in place; the key expires ${connection.expires}` : connection.message);
282
317
  }
283
318
 
319
+ // A cloud setup runs before the first session, so the three checks of a
320
+ // started tools server cannot pass yet. The lead checks at its first reply.
321
+ if (options.cloud) {
322
+ for (const row of rows) line(`${row.pass ? 'PASS' : 'FAIL'} ${row.name.padEnd(12)} ${row.detail}`);
323
+ line('SKIP tools server, engram and gh: no session has started yet');
324
+ return rows.every((row) => row.pass) ? 0 : 1;
325
+ }
284
326
  let health = null;
285
327
  try { health = JSON.parse(readFileSync(join(env.HOME, '.atlas', 'health.json'), 'utf8')); } catch { /* never started */ }
286
328
  check('tools server', Boolean(health), health ? `last started ${health.started_at}, Atlas ${health.package_version}` : 'the Atlas tools server has not started yet. Open a Claude Code session, then run "atlas doctor" again.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.44",
3
+ "version": "0.3.46",
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",
@@ -93,7 +93,7 @@ set. A test keeps the two lists equal.
93
93
  | Command | When to call it |
94
94
  |---|---|
95
95
  | `atlas install` | Once on a new Mac, when the owner asks for Atlas there |
96
- | `atlas upgrade` | When the owner asks to upgrade Atlas. Call the `atlas_upgrade` tool instead: the Bash sandbox blocks this command. Run the command by hand only where that tool is missing |
96
+ | `atlas upgrade` | When the owner asks to upgrade Atlas. Call the `atlas_upgrade` tool instead: the Bash sandbox blocks this command. Run the command by hand only where that tool is missing. In a cloud session, call the tool with `check: true` at the first reply, as section 7 says |
97
97
  | `atlas doctor` | When Atlas seems missing or broken in a session |
98
98
  | `atlas check` | Before you ask for a merge in a repo that has the shape check |
99
99
  | `atlas status` | To learn whether a repo's check is current or behind |
@@ -105,7 +105,30 @@ set. A test keeps the two lists equal.
105
105
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
106
106
  | `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
107
107
  | `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
108
+ | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass |
108
109
  | `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
109
110
 
110
111
  A person merges every file that `atlas tooling` writes.
111
112
  A person merges every file that `atlas tests write` writes.
113
+
114
+ ## 7. The first reply of a cloud session
115
+
116
+ A cloud session can start from an old setup and run an old Atlas. So in a
117
+ Claude cloud session, do these steps before the first reply.
118
+
119
+ 1. Call the `atlas_upgrade` tool with `check: true`. It writes no Atlas
120
+ state.
121
+ 2. When `behind` is true, put one line in the reply: "This session runs
122
+ Atlas <loaded>; npm has <newest>. A new session from the cloud
123
+ environment loads the newest." Use the two versions from the answer.
124
+ 3. When `provenance.state` is `differs` or `unproven`, the loaded Atlas
125
+ is not proven to be its release. Say so, with the first reason. Then
126
+ stop all Atlas work until the owner answers.
127
+ 4. When `provenance.state` is `not checked`, say so in one line, with the
128
+ reason. Then go on.
129
+ 5. When the tool is missing, or its answer has no `provenance`, this Atlas
130
+ is too old for the check. Say so in one line, and go on.
131
+ 6. When the tool refuses the check, give its error in one line, and go on.
132
+
133
+ The check runs from the copy it checks. It finds stale or drifted files. It
134
+ cannot find a package made to lie, so never call it a security proof.
@@ -8,6 +8,11 @@ const CHILD_VARIABLES = ['PATH', 'HOME', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'T
8
8
  // proxy is a loopback address (owner, 7 October 2026: "yes fix it and test").
9
9
  const CLOUD_PROXY_VARIABLES = ['HTTPS_PROXY', 'https_proxy'];
10
10
  const CLOUD_NO_PROXY_VARIABLES = ['NO_PROXY', 'no_proxy'];
11
+ // The two fixed values of Claude's cloud proxy (owner, 7 October 2026, "Narrow
12
+ // exception"). The tools server accepts exactly these values. A child gets the
13
+ // CA file only when it is this exact value: without it, npm behind the proxy
14
+ // waits about 70 seconds before it answers (item 92cfbba3, 24 October 2026).
15
+ export const CLOUD_NODE_VARIABLES = Object.freeze({ NODE_EXTRA_CA_CERTS: '/root/.ccr/ca-bundle.crt', NODE_PATH: '/usr/local/lib/node_modules_global' });
11
16
  const LOOPBACK_PROXY = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d{1,5}\/?$/;
12
17
 
13
18
  export function childEnvironment(env = process.env) {
@@ -19,6 +24,7 @@ export function childEnvironment(env = process.env) {
19
24
  const proxies = CLOUD_PROXY_VARIABLES.filter((name) => env[name] !== undefined);
20
25
  if (proxies.length && proxies.every((name) => LOOPBACK_PROXY.test(env[name]))) {
21
26
  for (const name of [...proxies, ...CLOUD_NO_PROXY_VARIABLES]) if (env[name] !== undefined) out[name] = env[name];
27
+ if (env.NODE_EXTRA_CA_CERTS === CLOUD_NODE_VARIABLES.NODE_EXTRA_CA_CERTS) out.NODE_EXTRA_CA_CERTS = env.NODE_EXTRA_CA_CERTS;
22
28
  }
23
29
  }
24
30
  return out;
package/work/mcp.mjs CHANGED
@@ -24,8 +24,8 @@ import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON,
24
24
  import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
25
25
  import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
26
26
  import { createMcpServer } from './lib/mcp-stdio.mjs';
27
- import { childEnvironment } from './lib/child-env.mjs';
28
- import { upgrade } from '../door/lib/install.mjs';
27
+ import { childEnvironment, CLOUD_NODE_VARIABLES } from './lib/child-env.mjs';
28
+ import { upgrade, proveCopy } from '../door/lib/install.mjs';
29
29
  export { childEnvironment };
30
30
 
31
31
  const ATLAS_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -74,7 +74,7 @@ export const TOOLS = [
74
74
  { 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, no owner flag needed.', 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 } },
75
75
  { 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, no owner flag needed.', 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 } },
76
76
  { 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, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the list to one product id.') }, additionalProperties: false } },
77
- { 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 only reports the loaded and the newest version. Scope: this Mac, no tracker write. Owner present only. Not 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 install nothing.' } }, additionalProperties: false } },
77
+ { 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, also in a Claude cloud session and with the owner flag off; the lead calls it at the first reply of a cloud session. Scope: this Mac, no tracker write. The upgrade itself is owner present only, and never 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 } },
78
78
  ];
79
79
 
80
80
  // The Engram connection: the address and the key, in one setting, separated by
@@ -156,7 +156,7 @@ export const HOSTILE_NODE_VARIABLES = Object.freeze(['NODE_TLS_REJECT_UNAUTHORIZ
156
156
  // that proxy. The owner accepted that proxy on 7 October 2026 ("Narrow
157
157
  // exception"): the server accepts exactly these values, and only when the
158
158
  // cloud's own marker is set. Any other value stays hostile, in the cloud too.
159
- export const CLOUD_NODE_VARIABLES = Object.freeze({ NODE_EXTRA_CA_CERTS: '/root/.ccr/ca-bundle.crt', NODE_PATH: '/usr/local/lib/node_modules_global' });
159
+ export { CLOUD_NODE_VARIABLES };
160
160
  export const inClaudeCloud = (env) => env.CLAUDE_CODE_REMOTE === 'true';
161
161
  export function hostileVariables(env) {
162
162
  const cloud = inClaudeCloud(env);
@@ -246,16 +246,24 @@ const ATLAS_VERSION_OF = (root) => JSON.parse(readFileSync(join(root, 'package.j
246
246
  function childExec(env) {
247
247
  return (command, args) => execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 600_000, stdio: ['pipe', 'pipe', 'pipe'], env: childEnvironment(env) });
248
248
  }
249
- export async function runUpgrade({ args = {}, env = process.env, ownerPresent, packageRoot = PACKAGE_ROOT, exec = childExec(env), upgradeFn = upgrade } = {}) {
249
+ const PROOFS = new WeakMap();
250
+ export async function runUpgrade({ args = {}, env = process.env, ownerPresent, packageRoot = PACKAGE_ROOT, exec = childExec(env), upgradeFn = upgrade, proveFn = proveCopy } = {}) {
250
251
  const refuse = (message) => { const error = new Error(message); error.name = 'VerbError'; throw error; };
251
- if (!ownerPresent) refuse('atlas_upgrade needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)');
252
- if (inClaudeCloud(env)) refuse('atlas_upgrade does not run in a Claude cloud session. A new cloud session installs the newest release.');
253
252
  const loaded = ATLAS_VERSION_OF(packageRoot);
253
+ // The check reads only, so it runs in a cloud session and with the owner
254
+ // flag off (item 92cfbba3): a cloud session states its lag at its first reply.
254
255
  if (args.check) {
255
256
  let newest;
256
257
  try { newest = exec('npm', ['view', '@arjunkhera/atlas', 'version']).trim(); } catch (error) { refuse(`npm could not read the newest version: ${String(error.stderr || error.message).trim().split('\n').slice(0, 3).join(' ')}`); }
257
- return { loaded, newest, behind: newest !== loaded };
258
+ // The loaded copy cannot change within a server's life, so one proof per
259
+ // copy: a repeated check makes no new GitHub reads.
260
+ if (!PROOFS.has(proveFn)) PROOFS.set(proveFn, new Map());
261
+ const proofs = PROOFS.get(proveFn);
262
+ if (!proofs.has(packageRoot)) proofs.set(packageRoot, proveFn({ packageRoot, exec }));
263
+ return { loaded, newest, behind: newest !== loaded, provenance: proofs.get(packageRoot) };
258
264
  }
265
+ if (!ownerPresent) refuse('atlas_upgrade needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)');
266
+ if (inClaudeCloud(env)) refuse('atlas_upgrade does not run in a Claude cloud session. A new cloud session installs the newest release.');
259
267
  const lines = [];
260
268
  let code;
261
269
  try { code = await upgradeFn({ packageRoot, options: { yes: true }, line: (text) => lines.push(text), env, exec }); } catch (error) {