@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/door/cli.mjs +65 -57
- package/door/lib/approve.mjs +115 -5
- package/door/lib/gate-audit.mjs +1 -0
- package/door/lib/gate-check.mjs +42 -2
- package/door/lib/gate.mjs +30 -2
- package/door/lib/install.mjs +50 -26
- package/door/lib/refresh-judge.mjs +132 -0
- package/door/lib/refresh.mjs +320 -0
- package/door/lib/signed-act.mjs +9 -1
- package/door/lib/window.mjs +271 -0
- package/owner-only.yaml +1 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +15 -7
- package/work/mcp.mjs +33 -4
- package/work/start.mjs +63 -0
|
@@ -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
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -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` |
|
|
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>.
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
Atlas <loaded>; npm has <newest>." Use the two versions from the answer.
|
|
151
|
+
3. Read `refresh`, the record of the refresh at session start. The tools
|
|
152
|
+
server moves a cloud session to the newest release that the release
|
|
153
|
+
proof and the push audit accept.
|
|
154
|
+
- `moved`: say nothing more. Read the `atlas:lead` skill once, because
|
|
155
|
+
the lead printed at the start can be the old one.
|
|
156
|
+
- `kept`: put one line in the reply, as loud as the line of step 2:
|
|
157
|
+
"The refresh kept Atlas <from>: <reason>." Use `from` and `reason`
|
|
158
|
+
from the record.
|
|
159
|
+
- No `refresh`, or `skipped`: go on.
|
|
160
|
+
4. When `provenance.state` is `differs` or `unproven`, the loaded Atlas
|
|
153
161
|
is not proven to be its release. Say so, with the first reason. Then
|
|
154
162
|
stop all Atlas work until the owner answers.
|
|
155
|
-
|
|
163
|
+
5. When `provenance.state` is `not checked`, say so in one line, with the
|
|
156
164
|
reason. Then go on.
|
|
157
|
-
|
|
165
|
+
6. When the tool is missing, or its answer has no `provenance`, this Atlas
|
|
158
166
|
is too old for the check. Say so in one line, and go on.
|
|
159
|
-
|
|
167
|
+
7. When the tool refuses the check, give its error in one line, and go on.
|
|
160
168
|
|
|
161
169
|
The check runs from the copy it checks. It finds stale or drifted files. It
|
|
162
170
|
cannot find a package made to lie, so never call it a security proof.
|
package/work/mcp.mjs
CHANGED
|
@@ -29,9 +29,30 @@ import { createMcpServer } from './lib/mcp-stdio.mjs';
|
|
|
29
29
|
import { childEnvironment, CLOUD_NODE_VARIABLES } from './lib/child-env.mjs';
|
|
30
30
|
import { ROLES, roleOf, allow, whoCell } from './lib/roles.mjs';
|
|
31
31
|
import { upgrade, proveCopy } from '../door/lib/install.mjs';
|
|
32
|
+
import { REASONS as REFRESH_REASONS } from '../door/lib/refresh.mjs';
|
|
32
33
|
export { childEnvironment };
|
|
33
34
|
|
|
34
35
|
const ATLAS_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
36
|
+
// The record of the cloud refresh (item ade7d96a). work/start.mjs ran the
|
|
37
|
+
// refresh before it started this process, and passes its record here. Only
|
|
38
|
+
// typed fields pass: the record carries no text from npm or GitHub.
|
|
39
|
+
const REFRESH_STATES = new Set(['moved', 'current', 'kept', 'skipped']);
|
|
40
|
+
const VERSION_TEXT = /^\d+\.\d+\.\d+$/;
|
|
41
|
+
export function refreshRecord(env) {
|
|
42
|
+
let raw;
|
|
43
|
+
try { raw = JSON.parse(env.ATLAS_REFRESH_RECORD ?? 'null'); } catch { return null; }
|
|
44
|
+
if (!raw || typeof raw !== 'object' || !REFRESH_STATES.has(raw.state)) return null;
|
|
45
|
+
const out = { state: raw.state };
|
|
46
|
+
if (typeof raw.code === 'string' && /^[a-z-]{1,20}$/.test(raw.code)) out.code = raw.code;
|
|
47
|
+
for (const key of ['from', 'to']) if (typeof raw[key] === 'string' && VERSION_TEXT.test(raw[key])) out[key] = raw[key];
|
|
48
|
+
if (typeof raw.commit === 'string' && /^[0-9a-f]{40}$/.test(raw.commit)) out.commit = raw.commit;
|
|
49
|
+
if (Number.isInteger(raw.run)) out.run = raw.run;
|
|
50
|
+
for (const key of ['runs', 'issues']) if (Array.isArray(raw[key])) out[key] = raw[key].filter(Number.isInteger).slice(0, 10);
|
|
51
|
+
if (Array.isArray(raw.commits)) out.commits = raw.commits.filter((one) => typeof one === 'string' && /^[0-9a-f]{12}$/.test(one)).slice(0, 10);
|
|
52
|
+
if (raw.command === 'moved' || raw.command === 'behind') out.command = raw.command;
|
|
53
|
+
if (typeof raw.seconds === 'number' && Number.isFinite(raw.seconds)) out.seconds = raw.seconds;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
35
56
|
const S = (description, extra = {}) => ({ type: 'string', description, ...extra });
|
|
36
57
|
const ORIGIN = { type: 'object', description: "The owner's words, verbatim, with where and when they were said.", required: ['text', 'source', 'date'], properties: { text: S('Verbatim words of the owner.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), date: S('ISO date, in the form YYYY-MM-DD.') } };
|
|
37
58
|
// Roadmap R23: the origin of a new item. The owner's words, or a typed
|
|
@@ -81,7 +102,7 @@ const TOOL_LIST = [
|
|
|
81
102
|
{ name: 'where_are_we', description: 'Answer "where are we with X" in six parts: live support and known limits from the code read at question time, then in progress, delivered and proposed from the items, then the current priority decision. Every row carries a citation: a path and a line, an entity id, a pull request, or a decision date. The code half comes from the sub-agent digests, which are refused when they cite a never-read path or quote a never-quote path, and every string is scrubbed by pattern. Computed at read time and never stored. Scope: read, every role.', inputSchema: { type: 'object', required: ['product', 'capability', 'code'], properties: { product: S('Product registry id.'), capability: S('The capability the question is about, the X.'), code: { type: 'array', description: 'One digest per repo, from the sub-agents briefed by code_read_brief. At least one is required: an answer with no code read says nothing about what the code does today.', items: CODE_DIGEST }, items: { type: 'array', description: 'Item ids the lead judges to answer this question.', items: { type: 'string' } }, match: { type: 'array', description: 'Terms to match against item titles, summaries and tags. Give items, match, or both.', items: { type: 'string' } }, movement_repos: { type: 'array', description: "Narrow the movement read to these registered repo ids. Default: every repo of the product. The digests' own repos are always read.", items: { type: 'string' } }, since: S('ISO date. Adds what changed since then.') }, additionalProperties: false } },
|
|
82
103
|
{ name: 'built_pending_possible', description: 'Answer "what are we building, what is built, what is pending and what can be done": delivered items by date, active items, paused items with their reason, proposed items with their age, and the decisions that set the order. Item records, delivery marks and decisions only; no code read and no GitHub read. Computed at read time and never stored. Scope: read, every role.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Limits built and the decisions to what came after it.') }, additionalProperties: false } },
|
|
83
104
|
{ name: 'design_index', description: 'List every design: each work item with a design-doc link, in any lifecycle, newest change first, with its product, its state for the hub ("Waiting on the owner", "Building", "Paused", "Shipped", "Dropped"), its design docs and its pages. Also returns design_hub_url, the one hub page. When: before the lead rebuilds the "Design docs" hub, after each design publish, lock and done. Item records only; computed at read time and never stored. Scope: read, every role.', inputSchema: { type: 'object', properties: { product: S('Limit the list to one product id.') }, additionalProperties: false } },
|
|
84
|
-
{ name: 'atlas_upgrade', description: 'Move Atlas on this Mac to the newest release: the same steps as the "atlas upgrade --yes" command. It checks that a master release run built the release, installs it and updates the plugin. When: the owner asks to upgrade Atlas. Use this tool and not the command: the Bash sandbox blocks the writes to ~/.npm, ~/.atlas and ~/.claude/plugins, and this server runs outside it. With check true it writes no Atlas state: it reports the loaded and the newest version, and checks that the loaded files are the files of their master release commit (provenance: match, differs, unproven or not checked). The check runs from the copy it checks, so it finds stale or drifted files, not a package made to lie. The check runs anywhere and in every role, also in a Claude cloud session; the lead calls it at the first reply of a cloud session. Scope: this Mac, no tracker write. The upgrade itself never runs in a Claude cloud session:
|
|
105
|
+
{ name: 'atlas_upgrade', description: 'Move Atlas on this Mac to the newest release: the same steps as the "atlas upgrade --yes" command. It checks that a master release run built the release, installs it and updates the plugin. When: the owner asks to upgrade Atlas. Use this tool and not the command: the Bash sandbox blocks the writes to ~/.npm, ~/.atlas and ~/.claude/plugins, and this server runs outside it. With check true it writes no Atlas state: it reports the loaded and the newest version, and checks that the loaded files are the files of their master release commit (provenance: match, differs, unproven or not checked). The check runs from the copy it checks, so it finds stale or drifted files, not a package made to lie. The check runs anywhere and in every role, also in a Claude cloud session; the lead calls it at the first reply of a cloud session. Scope: this Mac, no tracker write. The upgrade itself never runs in a Claude cloud session: there the tools server refreshes itself at start, and check true returns that record as refresh (state moved, current, kept or skipped, with a code).', inputSchema: { type: 'object', properties: { check: { type: 'boolean', description: 'True: report the versions and the provenance of the loaded copy, and install nothing.' } }, additionalProperties: false } },
|
|
85
106
|
];
|
|
86
107
|
// Roadmap R20: each description ends with who may call the tool, read from
|
|
87
108
|
// the role table, so the text and the guard cannot differ.
|
|
@@ -310,9 +331,10 @@ export async function runUpgrade({ args = {}, env = process.env, role = 'crew',
|
|
|
310
331
|
if (!PROOFS.has(proveFn)) PROOFS.set(proveFn, new Map());
|
|
311
332
|
const proofs = PROOFS.get(proveFn);
|
|
312
333
|
if (!proofs.has(packageRoot)) proofs.set(packageRoot, proveFn({ packageRoot, exec }));
|
|
313
|
-
|
|
334
|
+
const refresh = refreshRecord(env);
|
|
335
|
+
return { loaded, newest, behind: newest !== loaded, provenance: proofs.get(packageRoot), ...(refresh ? { refresh: { ...refresh, ...(REFRESH_REASONS[refresh.code] ? { reason: REFRESH_REASONS[refresh.code] } : {}) } } : {}) };
|
|
314
336
|
}
|
|
315
|
-
if (inClaudeCloud(env)) refuse('atlas_upgrade does not run in a Claude cloud session.
|
|
337
|
+
if (inClaudeCloud(env)) refuse('atlas_upgrade does not run in a Claude cloud session. The tools server refreshes itself at the start of each cloud session; check true gives that record.');
|
|
316
338
|
const lines = [];
|
|
317
339
|
let code;
|
|
318
340
|
try { code = await upgradeFn({ packageRoot, options: { yes: true }, line: (text) => lines.push(text), env, exec }); } catch (error) {
|
|
@@ -345,6 +367,8 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
|
|
|
345
367
|
const verbsFor = (boundClient) => createVerbs({ client, store, writer: { token_id: connection.tokenId }, session, agent, role, roleFrom, boundClient, readGitHub: readGitHub ?? githubReader(), readLive: live });
|
|
346
368
|
let verbs = connection.state === 'ok' ? verbsFor(null) : null;
|
|
347
369
|
const health = { package_version: ATLAS_VERSION, started_at: new Date().toISOString(), pid: process.pid, node: process.version, connection: connection.state, connection_expires: connection.expires ?? connection.expired ?? null, role, role_from: roleFrom, gh: findOnPath('gh', env), engram: connection.state === 'ok' ? 'not checked yet' : connection.message };
|
|
370
|
+
const refresh = refreshRecord(env);
|
|
371
|
+
if (refresh) health.refresh = refresh;
|
|
348
372
|
writeHealth(env, health);
|
|
349
373
|
if (client) {
|
|
350
374
|
client.getPublisher().then(() => { health.engram = 'answered'; }, (error) => { health.engram = `did not answer: ${error.message}`; }).finally(() => writeHealth(env, health));
|
|
@@ -352,6 +376,11 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
|
|
|
352
376
|
const instructions = connection.state === 'ok'
|
|
353
377
|
? `Atlas work verbs. Session ${session}; role: ${role}, from ${roleFrom}. The role table (packages/atlas/work/lib/roles.mjs) says which tools this role may call; the others refuse. Call registry_list first to learn the product and repo ids, needs_me to see the open questions, and in_flight to see the active work. Every write needs a request_id. The server stamps writer.session and the bound client; never pass writer, session, actor or role. Unknown product or repo ids are refused. The server cannot tell a sub-agent from the lead in one session, so an Atlas crew holds no MCP tool: its tools line names none.`
|
|
354
378
|
: `Atlas work verbs. They cannot reach Engram yet: ${connection.message}`;
|
|
379
|
+
// The lead hook printed the lead at the start, maybe from the old copy, at
|
|
380
|
+
// the same time as the refresh ran. So a moved copy asks for one fresh read.
|
|
381
|
+
const refreshLine = refresh?.state === 'moved'
|
|
382
|
+
? ` Atlas moved from ${refresh.from} to ${refresh.to} at the start of this session. The lead text printed at the start may be the old one: read the atlas:lead skill once now.`
|
|
383
|
+
: refresh?.state === 'kept' ? ` The cloud refresh kept Atlas ${refresh.from ?? ''} (${refresh.code ?? 'stopped'}). Call atlas_upgrade with check true, and say so at the first reply.` : '';
|
|
355
384
|
// Roadmap R15: the step-in rows of the hook become marks at the server's
|
|
356
385
|
// start and before each write verb. A failed flush leaves the rows for the
|
|
357
386
|
// next one and never stops the verb. With ATLAS_HOOKS=off nothing counts
|
|
@@ -372,7 +401,7 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
|
|
|
372
401
|
flush(),
|
|
373
402
|
env.ATLAS_HOOKS === 'off' ? verbs.stepIns.notCounted(claudeSession).catch((error) => say(`step-ins: the "not counted" mark was not written: ${error.message}`)) : null,
|
|
374
403
|
]) : Promise.resolve();
|
|
375
|
-
const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions
|
|
404
|
+
const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions: `${instructions}${refreshLine}`, tools: TOOLS, input, output, log, call: async (name, args, boundTo) => {
|
|
376
405
|
// The upgrade needs no Engram connection: an old key must not block a new release.
|
|
377
406
|
if (name === 'atlas_upgrade') return runUpgrade({ args, env, role: upgradeRole.role, roleFrom: upgradeRole.from });
|
|
378
407
|
if (!verbs) throw new Error(connection.message);
|
package/work/start.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The entry of the Atlas tools server (item ade7d96a, design
|
|
2
|
+
// docs/design-docs/cloud-self-refresh/). Claude Code starts this file in
|
|
3
|
+
// every session, from .mcp.json.
|
|
4
|
+
//
|
|
5
|
+
// 1. In a Claude cloud session it first runs the refresh, door/lib/refresh.mjs,
|
|
6
|
+
// as its own process with the short child environment: never the Engram
|
|
7
|
+
// key. The refresh may put a newer, proven and audited release in this
|
|
8
|
+
// folder.
|
|
9
|
+
// 2. Then it starts the tools server, work/mcp.mjs, as a new process from the
|
|
10
|
+
// files now in place, so no module of the old copy runs next to the new
|
|
11
|
+
// one. The server reads the refresh record from ATLAS_REFRESH_RECORD.
|
|
12
|
+
//
|
|
13
|
+
// This process loads one module of the package, the child environment, and
|
|
14
|
+
// never the server code. The server and the refresh each run in a process of
|
|
15
|
+
// their own, so old and new modules never mix in one process.
|
|
16
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
17
|
+
import { realpathSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { childEnvironment } from './lib/child-env.mjs';
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const packageRoot = dirname(here);
|
|
24
|
+
const say = (line) => process.stderr.write(`atlas-work start: ${line}\n`);
|
|
25
|
+
// The refresh has 20 s before its swap and 10 s for each program call. This
|
|
26
|
+
// limit stops a refresh that hangs, before Claude Code gives up on the tools
|
|
27
|
+
// server (security review of item ade7d96a). Claude Code waits about 30 s
|
|
28
|
+
// for a server to start, or MCP_TIMEOUT ms when the environment sets it
|
|
29
|
+
// (probe, 31 October 2026). With MCP_TIMEOUT, the refresh gets that time
|
|
30
|
+
// less 15 s, up to the refresh's own cap.
|
|
31
|
+
const REFRESH_LIMIT_MS = 25_000;
|
|
32
|
+
export function limitsOf(env) {
|
|
33
|
+
const wait = Number(env.MCP_TIMEOUT);
|
|
34
|
+
if (!Number.isInteger(wait) || wait <= 30_000) return { limit: REFRESH_LIMIT_MS, budget: null };
|
|
35
|
+
const limit = Math.min(wait - 15_000, 105_000);
|
|
36
|
+
return { limit: Math.max(limit, REFRESH_LIMIT_MS), budget: Math.max(limit - 5_000, 20_000) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function refreshFirst({ env = process.env, root = packageRoot, run = spawnSync } = {}) {
|
|
40
|
+
if (env.CLAUDE_CODE_REMOTE !== 'true' || env.ATLAS_REFRESH === 'off') return null;
|
|
41
|
+
const { limit, budget } = limitsOf(env);
|
|
42
|
+
const childEnv = { ...childEnvironment(env), CLAUDE_CODE_REMOTE: 'true', ...(budget ? { ATLAS_REFRESH_BUDGET_MS: String(budget) } : {}) };
|
|
43
|
+
const result = run(process.execPath, [join(root, 'door', 'lib', 'refresh.mjs')], { env: childEnv, encoding: 'utf8', timeout: limit, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
const last = String(result.stdout ?? '').trim().split('\n').at(-1) ?? '';
|
|
45
|
+
try { return JSON.parse(last); } catch { return { state: 'kept', code: 'stopped' }; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function startServer({ env = process.env, root = packageRoot, record = null, start = spawn } = {}) {
|
|
49
|
+
const serverEnv = { ...env };
|
|
50
|
+
if (record) serverEnv.ATLAS_REFRESH_RECORD = JSON.stringify(record);
|
|
51
|
+
const child = start(process.execPath, [join(root, 'work', 'mcp.mjs')], { env: serverEnv, stdio: 'inherit' });
|
|
52
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => child.kill(signal));
|
|
53
|
+
child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
|
|
54
|
+
return child;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Real paths on both sides, as in mcp.mjs: a plugin cache can sit behind a link.
|
|
58
|
+
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
59
|
+
let record = null;
|
|
60
|
+
try { record = refreshFirst(); } catch (error) { record = { state: 'kept', code: 'stopped' }; say(`the refresh stopped: ${error.message}`); }
|
|
61
|
+
if (record) say(`refresh ${record.state}${record.code ? ` (${record.code})` : ''}: ${record.from ?? '?'} -> ${record.to ?? record.from ?? '?'}`);
|
|
62
|
+
startServer({ record });
|
|
63
|
+
}
|