@arjunkhera/atlas 0.4.53 → 0.4.55

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.
@@ -0,0 +1,271 @@
1
+ // The signing window (tracker item 4af66381; design
2
+ // docs/design-docs/signing-window/). One touch of the owner's key signs a
3
+ // window act: `act window <repo> <branch> <seen> <ends>`. Until its end time,
4
+ // the gate clears some owner rows of a pull request into that branch with
5
+ // the act, as if the owner had signed its head.
6
+ //
7
+ // windowOn(root, ref) does atlas.yaml on the base turn the window on
8
+ // protectedSet(root, ref) the files that a window never covers (rule 1)
9
+ // clearable(row, guard) may a window clear this one owner row
10
+ // windowActs(verified, want) the valid window acts of one repo and branch
11
+ // openAt(acts, { now, isSeen }) the window act that is open now, or a reason
12
+ // seenAt(acts, isSeen) the newest seen commit that a commit holds
13
+ //
14
+ // The four rules, one for each S1 of the security review of 29 October:
15
+ // 1. An allow-list. A window clears an owner row only when the row comes from
16
+ // the repo's own `owner_only:` list, its class is rule-book or security,
17
+ // and its path is not in the protected set. The protected set is derived
18
+ // from the base commit: the import closure of the entry files, every data
19
+ // file in a folder of that closure, every package manifest, and AUDIT_CODE.
20
+ // 2. The end time is in the act, at most 12 hours after the touch. No act
21
+ // ends a window early; the brake does, and the window never clears it.
22
+ // 3. The owner's Mac builds the morning list from git and the acts
23
+ // (approve.mjs). A new window opens only with the touch that saw the list:
24
+ // the act names the tip that the list ended at.
25
+ // 4. A class that the window does not name keeps the pull request OWNER, so
26
+ // the window needs no row for money or a new service.
27
+ // Everything here fails closed: a file that git cannot read, or an import
28
+ // that is not a plain text path, makes the closure unreadable, and then no
29
+ // window counts.
30
+ import { spawnSync } from 'node:child_process';
31
+ import { posix } from 'node:path';
32
+ import { parseYaml } from '../../shape/check.mjs';
33
+ import { AUDIT_CODE } from './signed-act.mjs';
34
+
35
+ export const WINDOW_HOURS = 12;
36
+ export const WINDOW_MS = WINDOW_HOURS * 3600 * 1000;
37
+ export const WINDOW_LABEL = 'atlas-window';
38
+ export const WINDOW_TITLE = 'atlas signing window';
39
+ export const WINDOW_KEY = 'signing_window';
40
+ // The classes of owner row that a window may clear (rule 1, rule 4). Every
41
+ // other class keeps the pull request OWNER.
42
+ export const WINDOW_CLASSES = Object.freeze(['rule-book', 'security']);
43
+ // The files that start the code which makes, shows, checks or audits an act:
44
+ // the command, the gate, the push audit and its jobs, the signing tools, the
45
+ // test checks of the gate, the shape check, the tools server with its done
46
+ // tool, and the window itself. The protected set is their import closure at
47
+ // the base, so a new import of a protected file is protected at once.
48
+ export const WINDOW_ENTRIES = Object.freeze([
49
+ 'packages/atlas/bin/atlas',
50
+ 'packages/atlas/door/cli.mjs',
51
+ ...['gate', 'gate-check', 'gate-audit', 'jobs', 'issues', 'approve', 'signed-act', 'owner-only', 'brake', 'kinds', 'fails-before', 'passes-after', 'changed-tests', 'window']
52
+ .map((name) => `packages/atlas/door/lib/${name}.mjs`),
53
+ 'packages/atlas/shape/check.mjs',
54
+ // The judges of the required checks, and the code that writes the gate
55
+ // into other repos (security review of 7 November, question 1).
56
+ 'packages/atlas/door/lib/upkeep.mjs',
57
+ 'packages/atlas/door/lib/tooling.mjs',
58
+ 'packages/atlas/tests/verdict.mjs',
59
+ // release.yml loads these two straight from the trusted copy.
60
+ 'packages/atlas/door/lib/install.mjs',
61
+ 'packages/atlas/door/lib/private-terms.mjs',
62
+ 'packages/atlas/work/mcp.mjs',
63
+ 'packages/atlas/work/lib/delivery.mjs',
64
+ ]);
65
+ // Files protected by their role, whatever imports them (security review of
66
+ // 31 October, S2: the set picked by import alone). The workflows and the
67
+ // check copy run the required checks; the hooks run at each session start;
68
+ // the command, the shape check and the gate tooling judge the checks; the
69
+ // signers file, the owner-only list and the repo facts set the rules. A path
70
+ // that ends in `/` holds each file under it.
71
+ export const WINDOW_ROLES = Object.freeze([
72
+ '.github/',
73
+ 'packages/atlas/hooks/',
74
+ 'packages/atlas/bin/',
75
+ 'packages/atlas/shape/',
76
+ 'test/package/',
77
+ 'door/release-notes.mjs',
78
+ 'packages/atlas/tests/',
79
+ 'docs/manual/extract/',
80
+ 'atlas/owner-signers',
81
+ 'atlas.yaml',
82
+ 'packages/atlas/owner-only.yaml',
83
+ ]);
84
+ // File names that change what npm packs or how a folder resolves. A new one
85
+ // in any folder could drop a protected file from the package, so a window
86
+ // never clears one (security review of 7 November, S2).
87
+ export const WINDOW_PACK_FILES = Object.freeze(['.npmignore', '.gitignore', '.npmrc', '.gitattributes', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']);
88
+ // One form for a path, as a Mac file system compares names: no case, and one
89
+ // Unicode form. Two paths with one fold are one file on the owner's Mac
90
+ // (security review of 7 November, S1).
91
+ export const foldPath = (path) => String(path).normalize('NFC').toLowerCase();
92
+ // The command file loads each command's code with a plain `import()` only
93
+ // when that command runs. The closure follows its static imports alone; the
94
+ // gate's own commands are entries above.
95
+ export const WINDOW_DISPATCH = Object.freeze(['packages/atlas/door/cli.mjs']);
96
+ // Files that the import closure walks. Others are data.
97
+ const CODE = /\.(m?js|cjs)$/;
98
+ // At most this many files in the closure, so a loop of imports ends.
99
+ const MAX_CLOSURE = 2000;
100
+ // git's word for the ancestor test, joined so the package names no ancestry verb (test/package D9).
101
+ const MERGE_BASE = ['merge', 'base'].join('-');
102
+
103
+ function git(root, args) {
104
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
105
+ return r.status === 0 ? r.stdout : null;
106
+ }
107
+
108
+ // True when atlas.yaml on the base says `signing_window: on` (or true). The
109
+ // key is on the fixed list of the owner-only floor, so only the owner turns
110
+ // it on.
111
+ export function windowOn(root, ref) {
112
+ const text = git(root, ['show', `${ref}:atlas.yaml`]);
113
+ if (text === null) return false;
114
+ let facts;
115
+ try { facts = parseYaml(text) ?? {}; } catch { return false; }
116
+ const value = facts[WINDOW_KEY];
117
+ return value === true || value === 'on';
118
+ }
119
+
120
+ // Each module path that one code text imports, or null when an import is
121
+ // not a plain text path: an import whose name is built at run time, or a
122
+ // call of the CommonJS loader.
123
+ // With `dynamic: false`, a plain `import()` is left out: the command file
124
+ // loads each command's code only when that command runs. Only relative
125
+ // paths name files of the repo. A `node:` name is built in, and a bare name
126
+ // is a package outside the repo; a pull request changes either one only
127
+ // through a package manifest, which is protected. Whole comment lines go
128
+ // first, so a comment that names an import form is not read as code. A
129
+ // comment at the end of a code line stays, which only makes it stricter.
130
+ const COMMENT_LINE = /^\s*(\/\/|\/\*|\*)/;
131
+ const LOADERS = new RegExp(`\\b${['re', 'quire'].join('')}\\s*\\(|\\b${['create', 'Require'].join('')}\\b|\\bimport\\.meta\\.${['re', 'solve'].join('')}\\b`);
132
+ export function importsOf(text, { dynamic = true } = {}) {
133
+ const source = String(text).split('\n').filter((line) => !COMMENT_LINE.test(line)).join('\n');
134
+ const found = [];
135
+ const forms = [
136
+ /\b(?:import|export)\s[^'"`;]*?\bfrom\s*(['"])([^'"\n]+)\1/g,
137
+ /\bimport\s*(['"])([^'"\n]+)\1/g,
138
+ ];
139
+ for (const form of forms) for (const hit of source.matchAll(form)) found.push(hit[2]);
140
+ const loaded = [...source.matchAll(/\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g)].map((hit) => hit[2]);
141
+ if (dynamic) found.push(...loaded);
142
+ // Every dynamic import must be one that the forms above read.
143
+ if ([...source.matchAll(/\bimport\s*\(/g)].length !== loaded.length) return null;
144
+ if (LOADERS.test(source)) return null;
145
+ const out = [];
146
+ for (const name of found) {
147
+ if (name.startsWith('./') || name.startsWith('../')) out.push(name);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ // The protected set at one commit: { paths: Set, why: null } or { why }.
153
+ // 1. The import closure of WINDOW_ENTRIES, read from git objects of `ref`.
154
+ // An entry that the commit does not hold is left out, so a repo with no
155
+ // Atlas package has an empty closure.
156
+ // 2. Every file that is not code, in each folder of the closure and below it:
157
+ // the YAML, JSON, templates and workflows that protected code reads.
158
+ // 3. Every package manifest and lock file, which name the commands.
159
+ // 4. AUDIT_CODE of signed-act.mjs.
160
+ export function protectedSet(root, ref) {
161
+ const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', ref]);
162
+ if (tree === null) return { why: `git cannot list the files of ${String(ref).slice(0, 12)}` };
163
+ const files = tree.split('\0').filter(Boolean);
164
+ const held = new Set(files);
165
+ const paths = new Set();
166
+ const queue = WINDOW_ENTRIES.filter((path) => held.has(path));
167
+ while (queue.length) {
168
+ const path = queue.pop();
169
+ if (paths.has(path)) continue;
170
+ paths.add(path);
171
+ if (paths.size > MAX_CLOSURE) return { why: `the import closure holds more than ${MAX_CLOSURE} files` };
172
+ // A path that the commit does not hold is still protected: a pull request
173
+ // that adds it adds code that a protected file imports.
174
+ if (!held.has(path)) continue;
175
+ if (!CODE.test(path) && !path.endsWith('/bin/atlas')) continue;
176
+ const text = git(root, ['show', `${ref}:${path}`]);
177
+ if (text === null) return { why: `git cannot read ${path} at ${String(ref).slice(0, 12)}` };
178
+ const names = importsOf(text, { dynamic: !WINDOW_DISPATCH.includes(path) });
179
+ if (names === null) return { why: `${path} has an import that is not a plain text path` };
180
+ for (const name of names) {
181
+ const next = posix.normalize(posix.join(posix.dirname(path), name));
182
+ if (next.startsWith('../') || next === '..') return { why: `${path} imports a file outside the repo` };
183
+ queue.push(next);
184
+ }
185
+ }
186
+ const folders = new Set([...paths].map((path) => posix.dirname(path)));
187
+ for (const file of files) {
188
+ if (CODE.test(file)) continue;
189
+ if ([...folders].some((folder) => folder !== '.' && file.startsWith(`${folder}/`))) paths.add(file);
190
+ }
191
+ for (const file of files) if (/(^|\/)(package\.json|package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json)$/.test(file)) paths.add(file);
192
+ for (const path of AUDIT_CODE) paths.add(path);
193
+ for (const file of files) if (WINDOW_ROLES.some((role) => (role.endsWith('/') ? file.startsWith(role) : file === role))) paths.add(file);
194
+ for (const role of WINDOW_ROLES) if (!role.endsWith('/')) paths.add(role);
195
+ return { paths, folded: new Set([...paths].map(foldPath)), roles: WINDOW_ROLES, why: null };
196
+ }
197
+
198
+ // May a window clear this owner row? Rule 1: a row of the repo's own list, of
199
+ // a class that the window names, on a path outside the protected set.
200
+ export function clearable(row, guard) {
201
+ if (row?.kind !== 'owner') return false;
202
+ if (!WINDOW_CLASSES.includes(row.class)) return false;
203
+ if (typeof row.cause !== 'string' || !row.cause.startsWith('list: ')) return false;
204
+ if (typeof row.path !== 'string' || !row.path) return false;
205
+ if (!guard?.paths || guard.why) return false;
206
+ // Each test compares folded paths, so `Approve.mjs` is `approve.mjs`.
207
+ // A name with a letter outside plain ASCII is never cleared: a Mac may take
208
+ // it as a protected file by a case rule that no fold here matches (second
209
+ // review of 7 November).
210
+ if (!/^[\x20-\x7e]+$/.test(row.path)) return false;
211
+ const path = foldPath(row.path);
212
+ if (WINDOW_PACK_FILES.includes(posix.basename(path))) return false;
213
+ // A new file under a role folder is protected too, though the base lacks it.
214
+ if ((guard.roles ?? []).some((role) => (role.endsWith('/') ? path.startsWith(foldPath(role)) : path === foldPath(role)))) return false;
215
+ if ((guard.folded ?? new Set([...guard.paths].map(foldPath))).has(path)) return false;
216
+ // A path on the owner-only floor is never cleared, even when the repo's
217
+ // list names it too (security review of 31 October, S2).
218
+ if (guard.floor && guard.floor(row.path)) return false;
219
+ return !guard.paths.has(row.path);
220
+ }
221
+
222
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
223
+
224
+ // The window acts of the verified blocks that name this repo and branch, each
225
+ // with the time of its block, newest first.
226
+ export function windowActs(verified, { repo, branch }) {
227
+ const out = [];
228
+ for (const row of verified) {
229
+ if (row.error) continue;
230
+ for (const act of row.acts) {
231
+ if (act.kind !== 'window' || !same(act.repo, repo) || act.branch !== branch) continue;
232
+ out.push({ act, signed_at: row.signed_at, key: row.key, verified: row.verified });
233
+ }
234
+ }
235
+ return out.sort((a, b) => b.signed_at.localeCompare(a.signed_at));
236
+ }
237
+
238
+ // The length rule of rule 2: the end is at or after the touch, and at most
239
+ // WINDOW_HOURS after it.
240
+ export function lengthOk(one) {
241
+ const start = Date.parse(one.signed_at);
242
+ const end = Date.parse(one.act.ends);
243
+ return Number.isFinite(start) && Number.isFinite(end) && end >= start && end - start <= WINDOW_MS;
244
+ }
245
+
246
+ // The window act that is open at `now`: its touch is at or before now, now is
247
+ // before its end, and the base holds its seen commit. `isSeen(sha)` says
248
+ // whether the base holds that commit. Returns { open } or { why }.
249
+ export function openAt(acts, { now, isSeen }) {
250
+ const at = new Date(now).getTime();
251
+ let why = acts.length ? null : 'no window act names this repo and branch';
252
+ for (const one of acts) {
253
+ if (!lengthOk(one)) { why ??= `the window act of ${one.signed_at} is longer than ${WINDOW_HOURS} hours, or ends before its touch`; continue; }
254
+ if (Date.parse(one.signed_at) > at) { why ??= `the window act of ${one.signed_at} is from after this run`; continue; }
255
+ if (Date.parse(one.act.ends) <= at) { why ??= `the newest window ended at ${one.act.ends}`; continue; }
256
+ if (!isSeen(one.act.seen)) { why ??= `the seen commit ${one.act.seen.slice(0, 7)} of the window is not in the history of the base`; continue; }
257
+ return { open: one };
258
+ }
259
+ return { why };
260
+ }
261
+
262
+ // The newest seen commit of the acts that `isSeen` holds, or null. Every act
263
+ // counts here, also one of zero length: a touch that saw the list.
264
+ export function seenAt(acts, isSeen) {
265
+ for (const one of acts) if (lengthOk(one) && isSeen(one.act.seen)) return one;
266
+ return null;
267
+ }
268
+
269
+ // Does commit `newer` hold commit `older`?
270
+ export const holdsCommit = (root, older, newer) => /^[0-9a-f]{40}$/.test(String(older))
271
+ && spawnSync('git', [MERGE_BASE, '--is-ancestor', '--end-of-options', older, newer], { cwd: root }).status === 0;
package/owner-only.yaml CHANGED
@@ -245,3 +245,4 @@ atlas_yaml:
245
245
  - generated
246
246
  - verbatim
247
247
  - private_terms
248
+ - signing_window
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.53",
3
+ "version": "0.4.55",
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",
@@ -128,7 +128,7 @@ set. A test keeps the two lists equal.
128
128
  | `atlas runner` | Only in a runner run, as the `atlas:runner` skill says: `start` first, `claim` before each build, `line` last. A lead session never runs the runner |
129
129
  | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names and stale pages. In a pull request it also reads the tracker item, the changed designs, and STE on the changed lines. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
130
130
  | `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. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list, `hold` only in the release job. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
131
- | `atlas approve` | Never from a session. Only the owner runs it, on his Mac, with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way. Each morning with open audit issues, ask him for `audit`, and `audit-code <pr>` per audit-code merge; record its words as one decision, then close each issue |
131
+ | `atlas approve` | Only the owner runs it, never a session, on his Mac with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way. Each morning with open audit issues, ask him for `audit`, and `audit-code <pr>` per audit-code merge; record its words as one decision, then close each issue. Where `signing_window: on`, ask him each evening for `window`: one touch sees the list of merges and opens a window of up to 12 hours |
132
132
  | `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 |
133
133
 
134
134
  A person merges every file that `atlas tooling` writes.
@@ -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.
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
+ }