@arjunkhera/atlas 0.4.19 → 0.4.21

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,332 @@
1
+ // The signed act: the owner's one act that an agent cannot make (roadmap R72,
2
+ // tracker item 0c68a105; design docs/design-docs/signed-act/).
3
+ //
4
+ // Agents work under the owner's own GitHub login, so GitHub cannot tell his
5
+ // act from theirs. The signed act is an SSH signature made with a security
6
+ // key: a hardware key, or a Secure Enclave key that needs Touch ID. The
7
+ // signature carries the "user present" flag of the key, so a valid one proves
8
+ // a touch. This file holds the parts that need no GitHub:
9
+ //
10
+ // parseSigners(text) the public keys of atlas/owner-signers
11
+ // payloadText(acts, signedAt) the exact text that one touch signs
12
+ // parseBlocks(text) the signed blocks in a comment or a file
13
+ // verifyBlock(block, signers) the acts of one block, or the reason it fails
14
+ // findAct(verified, want) the first verified act that names `want`
15
+ //
16
+ // The verifier reads the SSH signature format (OpenSSH PROTOCOL.sshsig) and
17
+ // the security key form (PROTOCOL.u2f) with node:crypto alone. It runs no
18
+ // program, so the gate job needs nothing but Node.
19
+ //
20
+ // Rules that hold everywhere here:
21
+ // 1. Only a security key counts: sk-ssh-ed25519 or sk-ecdsa-sha2-nistp256.
22
+ // A plain key can sign with no touch, so it is refused.
23
+ // 2. A signature counts only with the "user present" flag. A key line with
24
+ // `verify-required` also needs the "user verified" flag.
25
+ // 3. The key must be one of the signers that the caller read from the base.
26
+ // 4. It fails closed: any field that does not read is a refusal.
27
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
28
+
29
+ export const NAMESPACE = 'atlas-signed-act';
30
+ export const FORMAT = 'atlas signed act 1';
31
+ export const SIGNERS_FILE = 'atlas/owner-signers';
32
+ export const BEGIN = '-----BEGIN ATLAS SIGNED ACT-----';
33
+ export const END = '-----END ATLAS SIGNED ACT-----';
34
+ const SIG_BEGIN = '-----BEGIN SSH SIGNATURE-----';
35
+ const SIG_END = '-----END SSH SIGNATURE-----';
36
+ export const MARKER = '<!-- atlas:signed-act -->';
37
+ // One touch signs at most this many acts, and a text holds at most this many blocks.
38
+ export const MAX_ACTS = 50;
39
+ export const MAX_BLOCKS = 20;
40
+ const MAX_TEXT = 256 * 1024;
41
+
42
+ // The two security key types. The names are joined from pieces, so the
43
+ // package holds no text that reads as an email address (test/package D3).
44
+ const AT_OPENSSH = (name) => [name, 'openssh.com'].join('@');
45
+ export const ED25519_SK = AT_OPENSSH('sk-ssh-ed25519');
46
+ export const ECDSA_SK = AT_OPENSSH('sk-ecdsa-sha2-nistp256');
47
+ export const KEY_TYPES = Object.freeze([ED25519_SK, ECDSA_SK]);
48
+ const FLAG_USER_PRESENT = 0x01;
49
+ const FLAG_USER_VERIFIED = 0x04;
50
+ const FLAG_EXTENSIONS = 0x80;
51
+
52
+ export class SignedActError extends Error {}
53
+
54
+ // ------------------------------------------------------------------ the acts
55
+
56
+ const REPO = '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+';
57
+ const SHA = '[0-9a-f]{40}';
58
+ const ID = '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}';
59
+ const HASH = '[0-9a-f]{64}';
60
+ const NUMBER = '[1-9][0-9]{0,8}';
61
+ // A branch name: plain characters only, so a line never holds a space.
62
+ const BRANCH = '[A-Za-z0-9._/-]{1,100}';
63
+ export const BRANCH_FORM = new RegExp(`^${BRANCH}$`);
64
+
65
+ // Each kind of act, its fields, and the exact form of its line. The order of
66
+ // the fields is the order of the words after the kind. A check name can hold
67
+ // spaces, so it is the last field of a waiver and takes the rest of the line.
68
+ export const ACTS = Object.freeze({
69
+ // A pull request head that the gate sent to the owner (done line 4). It
70
+ // names the base branch and the merge base too, so a change of the base
71
+ // after the touch does not carry the act onto a diff the owner never saw
72
+ // (security review S1, 26 October 2026).
73
+ merge: { fields: ['repo', 'pr', 'base', 'merge_base', 'head'], form: `(${REPO}) (${NUMBER}) (${BRANCH}) (${SHA}) (${SHA})` },
74
+ // The end of a brake before its time (R13 line 6). The commit is the one
75
+ // that set the brake, so one clear never ends a later brake.
76
+ 'brake-clear': { fields: ['repo', 'commit'], form: `(${REPO}) (${SHA})` },
77
+ // A ready mark for the runner (R25 line 3). It holds no nonce: R25 must
78
+ // say what stops a replay when an item is reopened, before its reader ships.
79
+ ready: { fields: ['repo', 'item'], form: `(${REPO}) (${ID})` },
80
+ // An owner lock: the item and the fingerprint of its card (R27 line 2).
81
+ lock: { fields: ['item', 'card'], form: `(${ID}) (${HASH})` },
82
+ // A decision: the item and the fingerprint of the owner's words.
83
+ decision: { fields: ['item', 'words'], form: `(${ID}) (${HASH})` },
84
+ // A waiver of one red check on one pull request head (R17 line 6).
85
+ waiver: { fields: ['repo', 'pr', 'head', 'decision', 'check'], form: `(${REPO}) (${NUMBER}) (${SHA}) (${ID}) ([^\\s].{0,99})` },
86
+ });
87
+
88
+ // The fingerprint that a lock or a decision act names: sha256 of the text,
89
+ // with line ends made plain.
90
+ export const fingerprint = (text) => createHash('sha256').update(String(text).replace(/\r\n?/g, '\n')).digest('hex');
91
+
92
+ // One act line from its fields, checked against its form.
93
+ export function actLine(act) {
94
+ const spec = ACTS[act?.kind];
95
+ if (!spec) throw new SignedActError(`there is no act "${act?.kind}". Use ${Object.keys(ACTS).join(', ')}.`);
96
+ const line = `act ${act.kind} ${spec.fields.map((field) => String(act[field] ?? '')).join(' ')}`;
97
+ if (!new RegExp(`^act ${act.kind} ${spec.form}$`).test(line)) throw new SignedActError(`the ${act.kind} act does not read: ${line.slice(0, 200)}`);
98
+ return line;
99
+ }
100
+
101
+ // The exact text that one touch signs. It ends with one line end.
102
+ export function payloadText(acts, signedAt) {
103
+ if (!Array.isArray(acts) || !acts.length) throw new SignedActError('a signed act names at least one act.');
104
+ if (acts.length > MAX_ACTS) throw new SignedActError(`one touch signs at most ${MAX_ACTS} acts.`);
105
+ const at = new Date(signedAt);
106
+ if (Number.isNaN(at.getTime())) throw new SignedActError('the time of the signature does not read.');
107
+ return `${[FORMAT, `signed ${at.toISOString()}`, ...acts.map(actLine)].join('\n')}\n`;
108
+ }
109
+
110
+ // The acts of a payload text, or a refusal. Every line must read.
111
+ export function parsePayload(text) {
112
+ const lines = String(text).split('\n');
113
+ if (lines.at(-1) === '') lines.pop();
114
+ if (lines[0] !== FORMAT) throw new SignedActError(`the first line is not "${FORMAT}".`);
115
+ const at = /^signed (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/.exec(lines[1] ?? '');
116
+ if (!at || Number.isNaN(Date.parse(at[1]))) throw new SignedActError('the second line is not "signed <time>".');
117
+ const rows = lines.slice(2);
118
+ if (!rows.length) throw new SignedActError('the block names no act.');
119
+ if (rows.length > MAX_ACTS) throw new SignedActError(`the block names more than ${MAX_ACTS} acts.`);
120
+ const acts = rows.map((line) => {
121
+ const kind = /^act ([a-z-]+) /.exec(line)?.[1];
122
+ const spec = kind && Object.hasOwn(ACTS, kind) ? ACTS[kind] : null;
123
+ const hit = spec && new RegExp(`^act ${kind} ${spec.form}$`).exec(line);
124
+ if (!hit) throw new SignedActError(`an act line does not read: ${line.slice(0, 120)}`);
125
+ const act = { kind };
126
+ spec.fields.forEach((field, index) => { act[field] = field === 'pr' ? Number(hit[index + 1]) : hit[index + 1]; });
127
+ return act;
128
+ });
129
+ return { signed_at: at[1], acts };
130
+ }
131
+
132
+ // ------------------------------------------------------------------ the SSH wire form
133
+
134
+ function reader(buffer) {
135
+ let at = 0;
136
+ const need = (n) => { if (at + n > buffer.length) throw new SignedActError('the signature is cut short.'); };
137
+ return {
138
+ u8() { need(1); return buffer[at++]; },
139
+ u32() { need(4); const value = buffer.readUInt32BE(at); at += 4; return value; },
140
+ raw(n) { need(n); const out = buffer.subarray(at, at + n); at += n; return out; },
141
+ string() { const n = this.u32(); return this.raw(n); },
142
+ text() { return this.string().toString('latin1'); },
143
+ done() { return at === buffer.length; },
144
+ };
145
+ }
146
+
147
+ const sshString = (value) => {
148
+ const body = Buffer.isBuffer(value) ? value : Buffer.from(String(value), 'latin1');
149
+ const size = Buffer.alloc(4);
150
+ size.writeUInt32BE(body.length);
151
+ return Buffer.concat([size, body]);
152
+ };
153
+
154
+ // The node:crypto key of one security key blob, and its application.
155
+ function keyOf(blob) {
156
+ const read = reader(blob);
157
+ const type = read.text();
158
+ if (type === ED25519_SK) {
159
+ const raw = read.string();
160
+ const application = read.text();
161
+ if (raw.length !== 32 || !read.done()) throw new SignedActError('the ed25519 key does not read.');
162
+ return { type, application, key: createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') }, format: 'jwk' }) };
163
+ }
164
+ if (type === ECDSA_SK) {
165
+ const curve = read.text();
166
+ const point = read.string();
167
+ const application = read.text();
168
+ if (curve !== 'nistp256' || point.length !== 65 || point[0] !== 4 || !read.done()) throw new SignedActError('the ecdsa key does not read.');
169
+ return { type, application, key: createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }, format: 'jwk' }) };
170
+ }
171
+ throw new SignedActError(`the key type ${type.slice(0, 60)} is not a security key, so it needs no touch.`);
172
+ }
173
+
174
+ export const keyFingerprint = (blob) => `SHA256:${createHash('sha256').update(blob).digest('base64').replace(/=+$/, '')}`;
175
+
176
+ // The keys of an allowed-signers text: one key on each line,
177
+ // `<name> [options] <type> <base64> [comment]`. The one option that counts is
178
+ // `verify-required`. `no-touch-required` and a plain key are refused, with
179
+ // the line number, and the other lines still count.
180
+ export function parseSigners(text) {
181
+ const keys = [];
182
+ const refused = [];
183
+ String(text ?? '').split('\n').forEach((raw, index) => {
184
+ const line = raw.trim();
185
+ if (!line || line.startsWith('#')) return;
186
+ const words = line.split(/\s+/);
187
+ const name = words.shift();
188
+ let options = '';
189
+ if (!KEY_TYPES.includes(words[0]) && !/^(ssh-|ecdsa-|sk-)/.test(words[0] ?? '')) options = words.shift() ?? '';
190
+ const [type, base64] = words;
191
+ const opts = options ? options.split(',') : [];
192
+ const why = (reason) => refused.push({ line: index + 1, why: reason });
193
+ if (opts.some((opt) => opt === 'no-touch-required')) return why('no-touch-required: a signature with no touch proves nothing');
194
+ const unknown = opts.filter((opt) => opt !== 'verify-required');
195
+ if (unknown.length) return why(`an option that the gate does not read: ${unknown[0].slice(0, 40)}`);
196
+ if (!KEY_TYPES.includes(type)) return why(`${String(type).slice(0, 40)} is not a security key`);
197
+ let blob;
198
+ try { blob = Buffer.from(base64 ?? '', 'base64'); keyOf(blob); } catch (error) { return why(error.message); }
199
+ if (blob.toString('base64').replace(/=+$/, '') !== String(base64).replace(/=+$/, '')) return why('the key is not plain base64');
200
+ keys.push({ name, type, blob, verify_required: opts.includes('verify-required'), fingerprint: keyFingerprint(blob) });
201
+ });
202
+ return { keys, refused };
203
+ }
204
+
205
+ // The parts of an armored SSH signature.
206
+ function sshsigParts(armored) {
207
+ const body = String(armored).replace(SIG_BEGIN, '').replace(SIG_END, '').replace(/\s+/g, '');
208
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) throw new SignedActError('the SSH signature is not base64.');
209
+ const read = reader(Buffer.from(body, 'base64'));
210
+ if (read.raw(6).toString('latin1') !== 'SSHSIG') throw new SignedActError('the signature does not start with SSHSIG.');
211
+ if (read.u32() !== 1) throw new SignedActError('the signature is not version 1.');
212
+ const key = read.string();
213
+ const namespace = read.text();
214
+ const reserved = read.string();
215
+ const hash = read.text();
216
+ const signature = read.string();
217
+ if (!read.done()) throw new SignedActError('the signature has data after its end.');
218
+ return { key, namespace, reserved, hash, signature };
219
+ }
220
+
221
+ // Verify one SSH signature of `message` against the signers. The answer names
222
+ // the key and the flags, or throws the reason.
223
+ export function verifySignature(message, armored, signers, namespace = NAMESPACE) {
224
+ const parts = sshsigParts(armored);
225
+ if (parts.namespace !== namespace) throw new SignedActError(`the signature is for "${parts.namespace.slice(0, 40)}", not "${namespace}".`);
226
+ if (parts.reserved.length) throw new SignedActError('the reserved field of the signature is not empty.');
227
+ if (!['sha256', 'sha512'].includes(parts.hash)) throw new SignedActError(`the hash ${parts.hash.slice(0, 20)} is not sha256 or sha512.`);
228
+ const signer = signers.find((each) => each.blob.equals(parts.key));
229
+ if (!signer) throw new SignedActError(`the key ${keyFingerprint(parts.key)} is not in ${SIGNERS_FILE} on the base.`);
230
+ const { type, application, key } = keyOf(signer.blob);
231
+ // The text that the key signed: PROTOCOL.sshsig, then PROTOCOL.u2f.
232
+ const signed = Buffer.concat([Buffer.from('SSHSIG', 'latin1'), sshString(namespace), sshString(''), sshString(parts.hash), sshString(createHash(parts.hash).update(message).digest())]);
233
+ const read = reader(parts.signature);
234
+ const sigType = read.text();
235
+ if (sigType !== type) throw new SignedActError(`the signature type ${sigType.slice(0, 60)} does not match the key.`);
236
+ const inner = read.string();
237
+ const flags = read.u8();
238
+ const counter = read.u32();
239
+ if (!read.done()) throw new SignedActError('the security key signature has data after its end.');
240
+ if (flags & FLAG_EXTENSIONS) throw new SignedActError('the security key signature carries extensions, which the gate does not read.');
241
+ const counterBytes = Buffer.alloc(4);
242
+ counterBytes.writeUInt32BE(counter);
243
+ const original = Buffer.concat([createHash('sha256').update(application).digest(), Buffer.from([flags]), counterBytes, createHash('sha256').update(signed).digest()]);
244
+ let good;
245
+ if (type === ED25519_SK) {
246
+ if (inner.length !== 64) throw new SignedActError('the ed25519 signature does not read.');
247
+ good = cryptoVerify(null, original, key, inner);
248
+ } else {
249
+ const ecdsa = reader(inner);
250
+ // A canonical mpint only: no needless leading zero, and never negative.
251
+ // Nothing relies on one signature per text; ECDSA allows two.
252
+ const fixed = (n) => {
253
+ if (!n.length || (n[0] & 0x80) || (n[0] === 0 && (n.length === 1 || !(n[1] & 0x80)))) throw new SignedActError('the ecdsa signature does not read.');
254
+ const bytes = n[0] === 0 ? n.subarray(1) : n;
255
+ if (bytes.length > 32) throw new SignedActError('the ecdsa signature does not read.');
256
+ return Buffer.concat([Buffer.alloc(32 - bytes.length), bytes]);
257
+ };
258
+ const r = fixed(ecdsa.string());
259
+ const s = fixed(ecdsa.string());
260
+ if (!ecdsa.done()) throw new SignedActError('the ecdsa signature does not read.');
261
+ good = cryptoVerify('sha256', original, { key, dsaEncoding: 'ieee-p1363' }, Buffer.concat([r, s]));
262
+ }
263
+ if (!good) throw new SignedActError('the signature does not match the text.');
264
+ const touched = Boolean(flags & FLAG_USER_PRESENT);
265
+ const verified = Boolean(flags & FLAG_USER_VERIFIED);
266
+ if (!touched) throw new SignedActError('the key made this signature with no touch.');
267
+ if (signer.verify_required && !verified) throw new SignedActError('the key line says verify-required, and the key did not verify the owner (no Touch ID or PIN).');
268
+ return { key: signer.fingerprint, name: signer.name, touched, verified, counter };
269
+ }
270
+
271
+ // ------------------------------------------------------------------ blocks
272
+
273
+ // The block that a comment or a file carries: the marker, the payload and
274
+ // the signature, in a fence so GitHub shows it as it is.
275
+ export const blockText = (payload, signature) => [MARKER, '```text', BEGIN, payload.replace(/\n$/, ''), END, signature.trim(), '```', ''].join('\n');
276
+
277
+ // Each signed block of a text: { payload, signature }. Line ends are made
278
+ // plain, so a copy from a page with CR LF still reads. A text with no marker
279
+ // holds no block. The scan is linear: each marker is found once with indexOf.
280
+ export function parseBlocks(text) {
281
+ const raw = String(text ?? '');
282
+ if (!raw.includes(MARKER)) return [];
283
+ const plain = raw.slice(0, MAX_TEXT).replace(/\r\n?/g, '\n');
284
+ const out = [];
285
+ let from = 0;
286
+ while (out.length < MAX_BLOCKS) {
287
+ const begin = plain.indexOf(`${BEGIN}\n`, from);
288
+ if (begin < 0) break;
289
+ const end = plain.indexOf(`\n${END}\n`, begin);
290
+ if (end < 0) break;
291
+ const sigStart = end + END.length + 2;
292
+ const sigEnd = plain.indexOf(SIG_END, sigStart);
293
+ if (sigEnd < 0) break;
294
+ from = sigEnd + SIG_END.length;
295
+ const signature = plain.slice(sigStart, from);
296
+ if (!signature.startsWith(`${SIG_BEGIN}\n`) || !/^[A-Za-z0-9+/=\n]+$/.test(signature.slice(SIG_BEGIN.length + 1, -SIG_END.length))) continue;
297
+ out.push({ payload: `${plain.slice(begin + BEGIN.length + 1, end)}\n`, signature });
298
+ }
299
+ return out;
300
+ }
301
+
302
+ // One block, verified: { acts, signed_at, key, verified }, or { error }.
303
+ export function verifyBlock(block, signers) {
304
+ try {
305
+ const parsed = parsePayload(block.payload);
306
+ const proof = verifySignature(Buffer.from(block.payload, 'utf8'), block.signature, signers);
307
+ return { ...parsed, ...proof };
308
+ } catch (error) {
309
+ if (error instanceof SignedActError) return { error: error.message };
310
+ throw error;
311
+ }
312
+ }
313
+
314
+ // Every block of each text, verified. `texts` is a list of strings, such as
315
+ // the comments of a pull request.
316
+ export function verifyTexts(texts, signers) {
317
+ const rows = [];
318
+ for (const text of texts) for (const block of parseBlocks(text)) rows.push(verifyBlock(block, signers));
319
+ return rows;
320
+ }
321
+
322
+ // The first verified act that matches every field of `want`, with its block.
323
+ // Repo names match with no case, as GitHub does.
324
+ export function findAct(verified, want) {
325
+ const same = (field, a, b) => (field === 'repo' ? String(a).toLowerCase() === String(b).toLowerCase() : String(a) === String(b));
326
+ for (const row of verified) {
327
+ if (row.error) continue;
328
+ const act = row.acts.find((each) => Object.entries(want).every(([field, value]) => same(field, each[field], value)));
329
+ if (act) return { act, signed_at: row.signed_at, key: row.key, verified: row.verified };
330
+ }
331
+ return null;
332
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
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",
@@ -112,6 +112,7 @@ set. A test keeps the two lists equal.
112
112
  | `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
113
113
  | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates |
114
114
  | `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. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
115
+ | `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 |
115
116
  | `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 |
116
117
 
117
118
  A person merges every file that `atlas tooling` writes.
@@ -21,8 +21,8 @@ Design: `docs/design-docs/upkeep-routine/` in the Atlas repo.
21
21
 
22
22
  The run never calls these verbs. They are the owner's acts.
23
23
 
24
- 1. `item_lock`, `item_propose`, `item_split`, `item_adopt`.
25
- 2. `decision_record`, `question_answer`, `question_ask`, `receipt_keep`.
24
+ 1. `item_lock`, `item_propose`, `item_split`.
25
+ 2. `decision_record`, `question_answer`, `question_ask`.
26
26
  3. `item_drop`, `item_park`, `item_resume`, `item_reopen`.
27
27
  4. `product_add`, `product_edit`, `repo_add`, `repo_edit`, `setup`.
28
28
 
@@ -62,9 +62,8 @@ Pass the product to every read. Show no other product.
62
62
  that a tracker item links, or that names an item id. Comment the item id
63
63
  and its state, and close the issue as completed. Owner rule, 21 October
64
64
  2026: an issue is not kept open once its item exists.
65
- 4. **Read only, report only.** Call `sweep_check` and `needs_me` for the
66
- product. Put any mismatch, and any question older than seven days, in the
67
- report. Do not act on them.
65
+ 4. **Read only, report only.** Call `needs_me` for the product. Put any
66
+ question older than seven days in the report. Do not act on it.
68
67
  5. **Red merges.** When `atlas.yaml` lists `required_checks`, run
69
68
  `atlas gate` with the verb `audit`, `--repo` and `--since` set to
70
69
  yesterday's date. Put each merge it names in the report, under "Left for
@@ -17,7 +17,7 @@ export const ACTIVITY_READ_VERBS = Object.freeze(['item_activity']);
17
17
  export const VOICES = Object.freeze(['agent', 'owner']);
18
18
  // The item verbs whose writes carry a diff (step 9). item_propose writes a
19
19
  // "created" diff when it makes the item.
20
- export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen', 'item_adopt']);
20
+ export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen']);
21
21
  export const ENTRY_VERBS = Object.freeze(['item_propose', ...LOG_VERBS]);
22
22
  const MAX_COMMENT = 4000;
23
23
  const PREVIEW = 300;
@@ -207,9 +207,14 @@ export function createActivityVerbs(core) {
207
207
  if (row.fields?.item !== item.id) continue;
208
208
  rows.push({ at: row.fields.marked_at, kind: 'mark', mark_id: row.id, mark_kind: row.fields.kind, verdict: row.fields.verdict ?? null });
209
209
  }
210
- const pieces = await Promise.all((Array.isArray(fields.split_children) ? fields.split_children : []).map((id) => client.getEntity(id).catch(() => null)));
210
+ // A piece the feed cannot read keeps its row, with the reason, so a
211
+ // split never hides a piece (roadmap R70 line 1).
212
+ const pieces = await Promise.all((Array.isArray(fields.split_children) ? fields.split_children : []).map((id) => client.getEntity(id).catch((error) => ({ unreadable: id, reason: oneLine(String(error?.message ?? error), 200) }))));
211
213
  for (const piece of pieces) {
212
- if (!piece) continue;
214
+ if (!piece || piece.unreadable) {
215
+ rows.push({ at: null, kind: 'piece', item: piece?.unreadable ?? null, title: null, split_order: null, unreadable: true, reason: piece?.reason ?? 'the graph returned nothing' });
216
+ continue;
217
+ }
213
218
  // The piece's date is the split that made it, not its last write.
214
219
  const made = Object.values(piece.fields?.atlas_receipts ?? {}).filter((receipt) => receipt?.verb === 'item_split').map((receipt) => receipt.at).sort()[0] ?? piece.created_at ?? null;
215
220
  rows.push({ at: made, kind: 'piece', item: piece.id, title: oneLine(piece.title ?? '', 200), split_order: piece.fields?.split_order ?? null });
@@ -239,7 +244,7 @@ function lineOf(row) {
239
244
  case 'question': return oneLine(`${when} question (${row.status}) ${row.text}`);
240
245
  case 'decision': return oneLine(`${when} decision (${row.decision_kind}) "${row.text}"`);
241
246
  case 'mark': return oneLine(`${when} mark ${row.mark_kind}${row.verdict ? `: ${row.verdict}` : ''}`);
242
- case 'piece': return oneLine(`${when} piece ${row.title} · ${row.item}`);
247
+ case 'piece': return row.unreadable ? oneLine(`${when} piece ${row.item} could not be read: ${row.reason}`) : oneLine(`${when} piece ${row.title} · ${row.item}`);
243
248
  case 'link': return oneLine(`${when} link (${row.link_kind}) ${row.url}`);
244
249
  default: return oneLine(`${when} ${row.kind}`);
245
250
  }
@@ -809,7 +809,11 @@ export function createDeliveryVerbs(core) {
809
809
  const { last, days } = movementOf({ item, marks: own, pulls: mine, legacy, readAt });
810
810
  const state = fields.lifecycle === 'done' ? 'delivered' : (fields.delivery_state === 'reopened' ? 'reopened' : fields.lifecycle);
811
811
 
812
- const inScope = fields.lifecycle === 'active' || fields.lifecycle === 'done';
812
+ // Only active work feeds "merged, not marked". A done item's other
813
+ // pull requests (its design, its lock) are history, not a gap; a
814
+ // reopened item is active again, so its pull requests come back
815
+ // (roadmap R70 line 5).
816
+ const inScope = fields.lifecycle === 'active';
813
817
  for (const pull of (inScope ? open : [])) notMarked.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, pr: pull.url, title: pull.title, merged_at: pull.merged_at, days: daysBetween(pull.merged_at, readAt), matched_by: pull.matched_by, note: 'merged, not marked' });
814
818
  for (const mark of deliveries) if (!since || String(mark.fields.marked_at ?? '') >= since) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: mark.id, verdict: correctedBy.has(mark.id) ? 'corrected' : (mark.fields.verdict ?? 'delivered'), ...(correctedBy.has(mark.id) ? { corrected_by: correctedBy.get(mark.id) } : {}), delivered_at: mark.fields.marked_at, prs: (mark.fields.prs ?? []).map((pull) => pull.url), deployed_commit: mark.fields.deployed?.commit ?? null, gate_warnings: mark.fields.gate_warnings ?? [] });
815
819
  if (legacy && (!since || String(legacy.marked_at) >= since)) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: null, verdict: 'legacy', delivered_at: legacy.marked_at, prs: [], deployed_commit: null, source: legacy.source });
@@ -16,7 +16,7 @@ export { COSTS, COST_MEANING, ROW_LINE_MAX, oneLine, CLEARED_LIFECYCLES, blocker
16
16
  // any other: the owner's words, dated, listing the items (design finding R7).
17
17
  export const DECISION_KINDS = Object.freeze(['decision', 'priority', 'answer', 'sweep']);
18
18
  export const QUESTION_STATUS = Object.freeze(['open', 'answered']);
19
- export const QUESTION_WRITE_VERBS = Object.freeze(['question_ask', 'question_answer', 'decision_record', 'receipt_keep']);
19
+ export const QUESTION_WRITE_VERBS = Object.freeze(['question_ask', 'question_answer', 'decision_record']);
20
20
  export const QUESTION_READ_VERBS = Object.freeze(['needs_me']);
21
21
 
22
22
  // R8: the row budget. Three visible lines; everything else folds under it.
@@ -131,10 +131,9 @@ export function createQuestionVerbs(core) {
131
131
  return entity;
132
132
  }
133
133
 
134
- // The sweep of slice L5 lists every active item of a product, so this cap is
135
- // the sweep's real ceiling, and work/lib/sweep.mjs reads back the same
136
- // number (architect review of Batch 15, finding 5). Every id is verified,
137
- // so a long list costs one read each.
134
+ // A sweep record of slice L5 lists every active item of a product, so this
135
+ // cap is the sweep's real ceiling (architect review of Batch 15, finding 5).
136
+ // Every id is verified, so a long list costs one read each.
138
137
  async function itemsTouched(value, reg, fallback) {
139
138
  if (value === undefined) return fallback ? [fallback] : [];
140
139
  const ids = stringList(value, 'items', { max: 200 });
@@ -148,7 +147,7 @@ export function createQuestionVerbs(core) {
148
147
  const type = await typeNamed('receipt');
149
148
  const prior = (await findCreated(type, requestId))[0];
150
149
  if (prior) return prior;
151
- const entity = await create(type, requestId, meta?.verb ?? 'receipt_keep', {
150
+ const entity = await create(type, requestId, meta?.verb ?? 'decision_record', {
152
151
  digest: meta?.digest,
153
152
  title: `Receipt · ${oneLine(view || 'answer', 80)} · ${(question ?? decision).id.slice(0, 8)}`,
154
153
  body: clean.text,
@@ -260,19 +259,6 @@ export function createQuestionVerbs(core) {
260
259
  return { verb: 'decision_record', decision_id: decision.id, version: decision.version, kind: decision.fields.kind, date: decision.fields.date, items: decision.fields.items ?? [], receipt_id: receipt?.id ?? null, redactions: receipt?.fields.redactions ?? 0, ...(prior ? { replayed_from_graph: true } : {}) };
261
260
  },
262
261
 
263
- // Keep the rendered answer the owner acted on. D2: refuses with no decision to attach to.
264
- async receipt_keep(args, requestId, meta) {
265
- requireOwner('receipt keep');
266
- const decision = await recordOfKind(args.decision_id, 'decision', 'decision_id');
267
- const rendered = text(args.rendered, 'rendered', { max: MAX_RENDERED });
268
- const view = text(args.view, 'view', { optional: true, max: 200 }) ?? '';
269
- const type = await typeNamed('receipt');
270
- const prior = await landed(type, requestId, meta);
271
- if (prior) { await edge(decision, prior, 'references', requestId); return { verb: 'receipt_keep', receipt_id: prior.id, version: prior.version, decision_id: decision.id, redactions: prior.fields.redactions ?? 0, replayed_from_graph: true }; }
272
- const receipt = await writeReceipt({ decision, question: null, rendered, view, requestId, meta, product: decision.fields?.product ?? '' });
273
- return { verb: 'receipt_keep', receipt_id: receipt.id, version: receipt.version, decision_id: decision.id, redactions: receipt.fields.redactions ?? 0 };
274
- },
275
-
276
262
  // D3: the "Needs me" view. Computed at question time, per product, never stored.
277
263
  async needs_me(args = {}) {
278
264
  const reg = await registry();
@@ -4,9 +4,9 @@
4
4
  // Slice L3 adds the done and reopen verbs and the "In flight" view from
5
5
  // delivery.mjs, through the same door again. Slice L4 adds the code-read
6
6
  // brief, the six-part answer and the "Built, pending, possible" view from
7
- // capability.mjs: three read verbs that write nothing. Slice L5 adds the
8
- // sweep from sweep.mjs: item_adopt, which gives an existing item a product,
9
- // and sweep_check, which reads the sweep record against the "In flight" view.
7
+ // capability.mjs: three read verbs that write nothing. Slice L5 added the
8
+ // one-time sweep and its two verbs. The sweep was done, and the verbs left
9
+ // the server on 26 October 2026 (roadmap R70 line 3).
10
10
  // Design: the Atlas M1 study, pages D1, D2, D3 and D6,
11
11
  // lock block L1, L2 and L3. Every write carries request_id and writer. The
12
12
  // server, not the caller, supplies the session. Unknown ids are refused.
@@ -19,7 +19,6 @@ import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, str
19
19
  import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS, oneLine, scrubSecrets } from './questions.mjs';
20
20
  import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
21
21
  import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capability.mjs';
22
- import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
23
22
  import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
24
23
  import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
25
24
  import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
@@ -452,15 +451,15 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
452
451
  const reg = await registry();
453
452
  const product = await requireProduct(args.product, reg);
454
453
  const repos = await requireRepos(args.repos, reg, product.fields.registry_id);
455
- if (!args.scope || typeof args.scope !== 'object' || Array.isArray(args.scope)) refuse('scope is required: { text, design_doc?, acceptance_criteria_hash? }');
454
+ if (!args.scope || typeof args.scope !== 'object' || Array.isArray(args.scope)) refuse('scope is required: { text, design_doc? }');
455
+ // A design doc once carried a hash of its criteria. The definition of
456
+ // done is the scope text now, and the verb keeps its own fingerprint.
457
+ // A new lock never takes a hash; an old record that holds one still
458
+ // reads (roadmap R70 line 4).
459
+ if (args.scope.acceptance_criteria_hash !== undefined) refuse('scope.acceptance_criteria_hash is for old records only: a new lock takes no hash. Put the definition of done in scope.text, and the verb keeps its fingerprint');
456
460
  const scope = { text: text(args.scope.text, 'scope.text') };
457
461
  if (args.scope.design_doc !== undefined) scope.design_doc = url(args.scope.design_doc, 'scope.design_doc');
458
- if (args.scope.acceptance_criteria_hash !== undefined) {
459
- text(args.scope.acceptance_criteria_hash, 'scope.acceptance_criteria_hash', { max: 64 });
460
- if (!/^[a-f0-9]{64}$/.test(args.scope.acceptance_criteria_hash)) refuse('scope.acceptance_criteria_hash must be a 64-hex sha256');
461
- scope.acceptance_criteria_hash = args.scope.acceptance_criteria_hash;
462
- }
463
- const scopeHash = scope.acceptance_criteria_hash ?? sha256(scope.text);
462
+ const scopeHash = sha256(scope.text);
464
463
  const stage = oneOf(args.stage, 'stage', STAGES, fields.lifecycle === 'active' && fields.stage ? fields.stage : 'build');
465
464
  const change = {
466
465
  lifecycle: 'active', stage, product: product.fields.registry_id, repos, tier: oneOf(args.tier, 'tier', TIERS, fields.tier ?? 'standard'),
@@ -743,16 +742,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
743
742
  // reopen verbs and the four views share this door, this journal, this
744
743
  // registry and this writer stamp. The delivery verbs also get the two
745
744
  // read-only readers: GitHub through the gh CLI, and one GET against the
746
- // deployed app. Slice L4 adds three read verbs, which write nothing. Slice
747
- // L5 adds item_adopt, the one verb that gives an existing item a product,
748
- // and sweep_check, which reads the sweep record against the "In flight"
749
- // view. The sweep record itself is written by decision_record with kind
750
- // "sweep": slice L5 adds no second way to write a decision.
745
+ // deployed app. Slice L4 adds three read verbs, which write nothing.
751
746
  const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated };
752
747
  Object.assign(verbs, createQuestionVerbs(core));
753
748
  Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
754
749
  Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
755
- Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
756
750
  Object.assign(verbs, createRoundupVerbs({ ...core, readGitHub, inFlight: (args) => verbs.in_flight(args), needsMe: (args) => verbs.needs_me(args) }));
757
751
  const activity = createActivityVerbs(core);
758
752
  Object.assign(verbs, activity.verbs);
@@ -761,8 +755,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
761
755
  const stepIns = createStepInVerbs(core);
762
756
  Object.assign(verbs, stepIns.verbs);
763
757
 
764
- const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS]);
765
- const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS]);
758
+ const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS]);
759
+ const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS]);
766
760
 
767
761
  async function run(verb, args = {}) {
768
762
  if (!verbs[verb]) refuse(`unknown verb: ${verb}`);