@arjunkhera/atlas 0.3.45 → 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.45",
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
@@ -140,8 +140,10 @@ const HELP = `atlas — the door into a repo's Atlas files
140
140
  compare the judge's verdict with the sealed tasks and the runs; exit 0 only when all pass
141
141
 
142
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
143
144
  atlas upgrade [--version <v>] check, show and install a newer release
144
145
  atlas doctor is Atlas installed and working here
146
+ --cloud before the first cloud session: skip the checks that need a started session
145
147
 
146
148
  Atlas ${ATLAS_VERSION}. Check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}.
147
149
  A person merges every file the tooling writer writes.
@@ -169,9 +171,9 @@ export const FLAGS = Object.freeze({
169
171
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
170
172
  gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch' },
171
173
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
172
- install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
174
+ install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
173
175
  upgrade: { version: 'optional', yes: 'switch' },
174
- doctor: {},
176
+ doctor: { cloud: 'switch' },
175
177
  });
176
178
 
177
179
  // Every command takes --help.
@@ -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.45",
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 |
@@ -110,3 +110,25 @@ set. A test keeps the two lists equal.
110
110
 
111
111
  A person merges every file that `atlas tooling` writes.
112
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) {