@arjunkhera/atlas 0.0.0 → 0.2.0

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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +16 -0
  2. package/.mcp.json +18 -0
  3. package/README.md +32 -1
  4. package/agents/artifact-format/example.html +71 -0
  5. package/agents/artifact-format/system.css +273 -0
  6. package/agents/artifact-renderer.md +132 -0
  7. package/agents/capability-reader.md +100 -0
  8. package/agents/code-explorer.md +108 -0
  9. package/agents/reviewer-architect.md +78 -0
  10. package/agents/reviewer-pm.md +81 -0
  11. package/agents/reviewer-security.md +82 -0
  12. package/bin/atlas +5 -0
  13. package/door/cli.mjs +280 -0
  14. package/door/lib/helpers.mjs +139 -0
  15. package/door/lib/install.mjs +260 -0
  16. package/door/lib/kind-drift.mjs +76 -0
  17. package/door/lib/onboarding.mjs +130 -0
  18. package/door/lib/pull-request.mjs +115 -0
  19. package/door/lib/releases.mjs +42 -0
  20. package/door/lib/status.mjs +47 -0
  21. package/door/lib/tooling.mjs +119 -0
  22. package/package.json +24 -3
  23. package/shape/check.mjs +1797 -0
  24. package/shape/releases.json +15 -0
  25. package/skills/sdlc-task/SKILL.md +174 -0
  26. package/skills/sdlc-task/lifecycle.yaml +27 -0
  27. package/skills/sdlc-task/templates/design-doc.md +118 -0
  28. package/skills/sdlc-task/templates/resume-anchor.md +25 -0
  29. package/work/code-read.mjs +65 -0
  30. package/work/lib/action-store.mjs +84 -0
  31. package/work/lib/capability.mjs +487 -0
  32. package/work/lib/child-env.mjs +11 -0
  33. package/work/lib/client.mjs +214 -0
  34. package/work/lib/code-read.mjs +238 -0
  35. package/work/lib/delivery.mjs +624 -0
  36. package/work/lib/mcp-stdio.mjs +68 -0
  37. package/work/lib/questions.mjs +278 -0
  38. package/work/lib/sweep.mjs +189 -0
  39. package/work/lib/verb-fields.mjs +53 -0
  40. package/work/lib/verbs.mjs +511 -0
  41. package/work/manifest-0.5.0.json +609 -0
  42. package/work/mcp.mjs +216 -0
  43. package/work/protected-paths.json +38 -0
@@ -0,0 +1,68 @@
1
+ // A small MCP server over stdio: JSON-RPC 2.0, one JSON message per line.
2
+ // No dependency. Supports initialize, notifications/initialized, ping,
3
+ // tools/list and tools/call. The server binds to the first client that
4
+ // initializes it and refuses a second initialize (lock block L1: "refuses a
5
+ // call from another session").
6
+ import { createInterface } from 'node:readline';
7
+
8
+ export const PROTOCOL_VERSIONS = Object.freeze(['2025-06-18', '2025-03-26', '2024-11-05']);
9
+ const MAX_LINE = 4 * 1024 * 1024;
10
+
11
+ export function createMcpServer({ name, version, instructions, tools, call, input = process.stdin, output = process.stdout, log = () => {} }) {
12
+ if (!Array.isArray(tools) || typeof call !== 'function') throw new TypeError('tools and call are required');
13
+ let bound = null;
14
+ const write = (message) => output.write(`${JSON.stringify(message)}\n`);
15
+ const reply = (id, result) => write({ jsonrpc: '2.0', id, result });
16
+ const fail = (id, code, message, data) => write({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });
17
+
18
+ async function handle(message) {
19
+ if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') { fail(message?.id ?? null, -32600, 'invalid JSON-RPC 2.0 request'); return; }
20
+ const { id, method, params } = message;
21
+ const isNotification = id === undefined;
22
+ try {
23
+ if (method === 'initialize') {
24
+ if (bound) { fail(id, -32600, `this server is bound to session ${bound.session}; start a new server for another session`); return; }
25
+ const requested = params?.protocolVersion;
26
+ bound = { session: `${params?.clientInfo?.name ?? 'client'}:${Date.now()}`, client: params?.clientInfo ?? null, at: new Date().toISOString() };
27
+ log(`bound to ${bound.session}`);
28
+ reply(id, { protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], capabilities: { tools: { listChanged: false } }, serverInfo: { name, version }, ...(instructions ? { instructions } : {}) });
29
+ return;
30
+ }
31
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return;
32
+ if (method === 'ping') { if (!isNotification) reply(id, {}); return; }
33
+ if (!bound) { if (!isNotification) fail(id, -32002, 'initialize first; this server serves one session'); return; }
34
+ if (method === 'tools/list') { reply(id, { tools: tools.map(({ name: toolName, description, inputSchema }) => ({ name: toolName, description, inputSchema })) }); return; }
35
+ if (method === 'tools/call') {
36
+ const toolName = params?.name;
37
+ if (!tools.some((tool) => tool.name === toolName)) { fail(id, -32602, `unknown tool: ${toolName}`); return; }
38
+ const args = params?.arguments ?? {};
39
+ try {
40
+ const result = await call(toolName, args, bound);
41
+ reply(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, isError: false });
42
+ } catch (error) {
43
+ const refused = error?.name === 'VerbError';
44
+ reply(id, { content: [{ type: 'text', text: JSON.stringify({ refused, error: error?.message ?? String(error) }, null, 2) }], isError: true });
45
+ }
46
+ return;
47
+ }
48
+ if (!isNotification) fail(id, -32601, `method not found: ${method}`);
49
+ } catch (error) {
50
+ if (!isNotification) fail(id, -32603, error?.message ?? 'internal error');
51
+ }
52
+ }
53
+
54
+ function start() {
55
+ const lines = createInterface({ input, crlfDelay: Infinity });
56
+ let chain = Promise.resolve();
57
+ lines.on('line', (line) => {
58
+ if (!line.trim()) return;
59
+ if (line.length > MAX_LINE) { fail(null, -32600, 'message too large'); return; }
60
+ let message;
61
+ try { message = JSON.parse(line); } catch { fail(null, -32700, 'parse error'); return; }
62
+ const batch = Array.isArray(message) ? message : [message];
63
+ for (const item of batch) chain = chain.then(() => handle(item));
64
+ });
65
+ return new Promise((resolve) => lines.on('close', () => chain.then(resolve)));
66
+ }
67
+ return Object.freeze({ start, handle, bound: () => bound });
68
+ }
@@ -0,0 +1,278 @@
1
+ // Atlas M1 slice L2: questions, decisions, receipts and the "Needs me" view.
2
+ // Design: the Atlas M1 study, D1 (the records), D2
3
+ // (the verbs and the door), D3 (the views), D6 findings R5, R8, R12 and R15,
4
+ // and the frozen L2 paragraph of the lock block.
5
+ // The verbs run behind the same door, the same journal and the same registry
6
+ // checks as slice L1. Every record carries request_id and writer.
7
+ import protectedPaths from '../protected-paths.json' with { type: 'json' };
8
+ import { refuse, text, oneOf, isoDate, url, stringList } from './verb-fields.mjs';
9
+
10
+ // R5: the cost of waiting is a closed vocabulary, ranked. Index 0 costs most.
11
+ export const COSTS = Object.freeze(['blocked-merge', 'paused-item', 'spend', 'date']);
12
+ export const COST_MEANING = Object.freeze({
13
+ 'blocked-merge': 'a merge waits on the answer',
14
+ 'paused-item': 'the item is stopped until the answer comes',
15
+ spend: 'money runs while the answer waits',
16
+ date: 'a date is at risk',
17
+ });
18
+ // 'sweep' is the kind the slice L5 sweep record carries. It is a decision like
19
+ // any other: the owner's words, dated, listing the items (design finding R7).
20
+ export const DECISION_KINDS = Object.freeze(['decision', 'priority', 'answer', 'sweep']);
21
+ export const QUESTION_STATUS = Object.freeze(['open', 'answered']);
22
+ export const QUESTION_WRITE_VERBS = Object.freeze(['question_ask', 'question_answer', 'decision_record', 'receipt_keep']);
23
+ export const QUESTION_READ_VERBS = Object.freeze(['needs_me']);
24
+
25
+ // R8: the row budget. Three visible lines; everything else folds under it.
26
+ export const ROW_LINES = 3;
27
+ export const ROW_LINE_MAX = 160;
28
+ const MAX_OPEN_QUESTIONS = 500;
29
+ const MAX_RENDERED = 20_000;
30
+
31
+ export const oneLine = (value, max = ROW_LINE_MAX) => {
32
+ const flat = String(value ?? '').replace(/\s+/g, ' ').trim();
33
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
34
+ };
35
+
36
+ // R12: a scrub by pattern in receipt keep. The patterns are the ones the
37
+ // sub-agents deny, in work/protected-paths.json. A (?i) prefix is Python and
38
+ // Go spelling; JavaScript takes it as a flag.
39
+ export function scrubSecrets(value) {
40
+ let out = String(value ?? '');
41
+ let redactions = 0;
42
+ for (const source of protectedPaths.secret_patterns ?? []) {
43
+ const insensitive = source.startsWith('(?i)');
44
+ const pattern = new RegExp(insensitive ? source.slice(4) : source, insensitive ? 'gi' : 'g');
45
+ out = out.replace(pattern, () => { redactions += 1; return '[REDACTED]'; });
46
+ }
47
+ return { text: out, redactions };
48
+ }
49
+
50
+ function questionContext(value) {
51
+ if (value === undefined || value === null) return {};
52
+ if (typeof value !== 'object' || Array.isArray(value)) refuse('context must be an object: current_behavior, change, affected, options, consequences');
53
+ const context = {};
54
+ for (const key of ['current_behavior', 'change', 'consequences']) {
55
+ const line = text(value[key], `context.${key}`, { optional: true, max: 4000 });
56
+ if (line !== undefined) context[key] = line;
57
+ }
58
+ for (const key of ['affected', 'options']) {
59
+ if (value[key] !== undefined) context[key] = stringList(value[key], `context.${key}`, { max: 32 }).map((entry) => text(entry, `context.${key}`, { max: 2000 }));
60
+ }
61
+ if (value.sources !== undefined) context.sources = stringList(value.sources, 'context.sources', { max: 16 }).map((entry) => url(entry, 'context.sources'));
62
+ return context;
63
+ }
64
+
65
+ // Row line two: the id is the citation a reader types back, so the title gives
66
+ // way to it, not the other way round (review G6).
67
+ export function itemLine(item, product) {
68
+ // Each part is flattened on its own, so no field in the graph can add a
69
+ // fourth line to a three-line row (review G8). The separator is added after
70
+ // the flattening, because flattening also trims the ends.
71
+ const tail = oneLine(`${product} · ${item.fields?.stage ?? 'unknown stage'} · ${item.id}`);
72
+ const budget = ROW_LINE_MAX - tail.length - 3;
73
+ if (budget < 1) return oneLine(`${oneLine(item.title)} · ${tail}`);
74
+ return `${oneLine(item.title, budget)} · ${tail}`;
75
+ }
76
+
77
+ export function createQuestionVerbs(core) {
78
+ const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated } = core;
79
+
80
+ async function recordOfKind(id, kind, name) {
81
+ text(id, name, { max: 200 });
82
+ let entity;
83
+ try { entity = await client.getEntity(id); }
84
+ catch (error) { if (error?.status === 404) refuse(`unknown ${kind} id: ${id}`); throw error; }
85
+ if ((await kindOf(entity)) !== kind) refuse(`entity ${id} is not a ${kind} record`);
86
+ if (entity.state && entity.state !== 'active') refuse(`${kind} ${id} is ${entity.state}, not active in the graph`);
87
+ return entity;
88
+ }
89
+
90
+ // The sweep of slice L5 lists every active item of a product, so this cap is
91
+ // the sweep's real ceiling, and work/lib/sweep.mjs reads back the same
92
+ // number (architect review of Batch 15, finding 5). Every id is verified,
93
+ // so a long list costs one read each.
94
+ async function itemsTouched(value, reg, fallback) {
95
+ if (value === undefined) return fallback ? [fallback] : [];
96
+ const ids = stringList(value, 'items', { max: 200 });
97
+ for (const id of ids) await requireItem(id);
98
+ return ids;
99
+ }
100
+
101
+ // R15 and the L2 text: the receipt is written in the same call as the answer.
102
+ async function writeReceipt({ decision, question, rendered, view, requestId, meta, product }) {
103
+ const clean = scrubSecrets(text(rendered, 'receipt.rendered', { max: MAX_RENDERED }));
104
+ const type = await typeNamed('receipt');
105
+ const prior = (await findCreated(type, requestId))[0];
106
+ if (prior) return prior;
107
+ const entity = await create(type, requestId, meta?.verb ?? 'receipt_keep', {
108
+ digest: meta?.digest,
109
+ title: `Receipt · ${oneLine(view || 'answer', 80)} · ${(question ?? decision).id.slice(0, 8)}`,
110
+ body: clean.text,
111
+ fields: {
112
+ decision: decision.id, ...(question ? { question: question.id } : {}), ...(product ? { product } : {}),
113
+ view: view || '', rendered: clean.text, kept_at: now(), redactions: clean.redactions,
114
+ },
115
+ tags: ['atlas-receipt', ...(product ? [`product:${product}`] : [])],
116
+ });
117
+ await edge(decision, entity, 'references', requestId);
118
+ return entity;
119
+ }
120
+
121
+ const verbs = {
122
+ // Any in-session agent may ask. D2: refuses with no item id, no product id, no cost of waiting.
123
+ async question_ask(args, requestId, meta) {
124
+ const questionText = text(args.text, 'text', { max: 8000 });
125
+ const itemId = text(args.item_id, 'item_id', { max: 200 });
126
+ const productId = text(args.product, 'product', { max: 64 });
127
+ const cost = oneOf(args.cost, 'cost', COSTS);
128
+ const reg = await registry();
129
+ const product = await requireProduct(productId, reg);
130
+ const item = await requireItem(itemId);
131
+ if (item.fields?.product && item.fields.product !== product.fields.registry_id) refuse(`item ${itemId} belongs to product ${item.fields.product}, not ${productId}`);
132
+ const context = questionContext(args.context);
133
+ const type = await typeNamed('question');
134
+ const prior = await landed(type, requestId, meta);
135
+ // A retry after an interrupted write finds the record and re-makes the edge it lost (review G1).
136
+ if (prior) { await edge(prior, item, 'references', requestId); return { verb: 'question_ask', question_id: prior.id, version: prior.version, status: prior.fields.status, item: prior.fields.item, product: prior.fields.product, cost: prior.fields.cost, replayed_from_graph: true }; }
137
+ const entity = await create(type, requestId, 'question_ask', {
138
+ digest: meta?.digest, title: oneLine(questionText, 200), body: questionText,
139
+ fields: {
140
+ text: questionText, item: item.id, product: product.fields.registry_id,
141
+ asked_by: { agent: label.agent, session: label.session }, asked_at: now(),
142
+ cost, cost_rank: COSTS.indexOf(cost), context, status: 'open',
143
+ },
144
+ tags: ['atlas-question', `product:${product.fields.registry_id}`, `cost:${cost}`],
145
+ });
146
+ await edge(entity, item, 'references', requestId);
147
+ return { verb: 'question_ask', question_id: entity.id, version: entity.version, status: 'open', item: item.id, product: product.fields.registry_id, cost, cost_rank: COSTS.indexOf(cost) };
148
+ },
149
+
150
+ // The owner answers. One call writes the decision, the receipt, and closes the question.
151
+ async question_answer(args, requestId, meta) {
152
+ requireOwner('question answer');
153
+ const question = await recordOfKind(args.question_id, 'question', 'question_id');
154
+ const answer = text(args.text, 'text', { max: MAX_RENDERED });
155
+ const date = isoDate(args.date, 'date');
156
+ const source = text(args.source, 'source', { optional: true, max: 500 }) ?? '';
157
+ const kind = oneOf(args.kind, 'kind', DECISION_KINDS, 'answer');
158
+ if (alreadyPatched(question, requestId, meta)) {
159
+ return { verb: 'question_answer', question_id: question.id, version: question.version, status: question.fields.status, decision_id: question.fields.answer ?? null, receipt_id: question.fields.receipt ?? null, replayed_from_graph: true };
160
+ }
161
+ if (question.fields?.status !== 'open') refuse(`question ${question.id} is already ${question.fields?.status} (decision ${question.fields?.answer ?? 'unknown'}); reopen is not an L2 verb`);
162
+ const reg = await registry();
163
+ const productId = question.fields.product;
164
+ await requireProduct(productId, reg);
165
+ const items = await itemsTouched(args.items, reg, question.fields.item);
166
+ const priorityOrder = args.priority_order === undefined ? undefined : (await itemsTouched(args.priority_order, reg, null));
167
+ let replaced = null;
168
+ if (args.replaces !== undefined) replaced = await recordOfKind(args.replaces, 'decision', 'replaces');
169
+ const decisionType = await typeNamed('decision');
170
+ const decision = (await landed(decisionType, requestId, meta)) ?? await create(decisionType, requestId, 'question_answer', {
171
+ digest: meta?.digest, title: oneLine(`${kind}: ${answer}`, 200), body: answer,
172
+ fields: {
173
+ kind, text: answer, date, source, product: productId, answers: question.id, items,
174
+ ...(priorityOrder ? { priority_order: priorityOrder } : {}), ...(replaced ? { replaces: replaced.id } : {}),
175
+ },
176
+ tags: ['atlas-decision', `product:${productId}`],
177
+ });
178
+ await edge(decision, question, 'references', requestId);
179
+ if (replaced) await edge(replaced, decision, 'replaced_by', requestId);
180
+ const rendered = args.rendered === undefined ? `${question.fields.text}\n\nOwner, ${date}: ${answer}` : args.rendered;
181
+ const receipt = await writeReceipt({ decision, question, rendered, view: args.view ?? 'needs-me', requestId, meta, product: productId });
182
+ const closed = await patch(question, 'question_answer', requestId, { status: 'answered', answer: decision.id, answered_at: now(), receipt: receipt.id }, { digest: meta?.digest });
183
+ return { verb: 'question_answer', question_id: closed.id, version: closed.version, status: 'answered', decision_id: decision.id, receipt_id: receipt.id, items, redactions: receipt.fields.redactions ?? 0 };
184
+ },
185
+
186
+ // The owner speaks outside a question. D2: refuses with no verbatim text, no date.
187
+ async decision_record(args, requestId, meta) {
188
+ requireOwner('decision record');
189
+ const decisionText = text(args.text, 'text', { max: MAX_RENDERED });
190
+ const date = isoDate(args.date, 'date');
191
+ const source = text(args.source, 'source', { optional: true, max: 500 }) ?? '';
192
+ const kind = oneOf(args.kind, 'kind', DECISION_KINDS, 'decision');
193
+ const reg = await registry();
194
+ let productId = '';
195
+ if (args.product !== undefined) productId = (await requireProduct(args.product, reg)).fields.registry_id;
196
+ const items = await itemsTouched(args.items, reg, null);
197
+ const priorityOrder = args.priority_order === undefined ? undefined : (await itemsTouched(args.priority_order, reg, null));
198
+ let replaced = null;
199
+ if (args.replaces !== undefined) replaced = await recordOfKind(args.replaces, 'decision', 'replaces');
200
+ const type = await typeNamed('decision');
201
+ // A retry after an interrupted write reuses the record that landed, then finishes
202
+ // the edge and the receipt the first attempt never reached (review G2).
203
+ const prior = await landed(type, requestId, meta);
204
+ const decision = prior ?? await create(type, requestId, 'decision_record', {
205
+ digest: meta?.digest, title: oneLine(`${kind}: ${decisionText}`, 200), body: decisionText,
206
+ fields: {
207
+ kind, text: decisionText, date, source, ...(productId ? { product: productId } : {}), items,
208
+ ...(priorityOrder ? { priority_order: priorityOrder } : {}), ...(replaced ? { replaces: replaced.id } : {}),
209
+ },
210
+ tags: ['atlas-decision', ...(productId ? [`product:${productId}`] : [])],
211
+ });
212
+ if (replaced) await edge(replaced, decision, 'replaced_by', requestId);
213
+ let receipt = null;
214
+ if (args.rendered !== undefined) receipt = await writeReceipt({ decision, question: null, rendered: args.rendered, view: args.view ?? '', requestId, meta, product: productId });
215
+ 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 } : {}) };
216
+ },
217
+
218
+ // Keep the rendered answer the owner acted on. D2: refuses with no decision to attach to.
219
+ async receipt_keep(args, requestId, meta) {
220
+ requireOwner('receipt keep');
221
+ const decision = await recordOfKind(args.decision_id, 'decision', 'decision_id');
222
+ const rendered = text(args.rendered, 'rendered', { max: MAX_RENDERED });
223
+ const view = text(args.view, 'view', { optional: true, max: 200 }) ?? '';
224
+ const type = await typeNamed('receipt');
225
+ const prior = await landed(type, requestId, meta);
226
+ 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 }; }
227
+ const receipt = await writeReceipt({ decision, question: null, rendered, view, requestId, meta, product: decision.fields?.product ?? '' });
228
+ return { verb: 'receipt_keep', receipt_id: receipt.id, version: receipt.version, decision_id: decision.id, redactions: receipt.fields.redactions ?? 0 };
229
+ },
230
+
231
+ // D3: the "Needs me" view. Computed at question time, per product, never stored.
232
+ async needs_me(args = {}) {
233
+ const reg = await registry();
234
+ let only = null;
235
+ if (args.product !== undefined) only = (await requireProduct(args.product, reg)).fields.registry_id;
236
+ const type = await typeNamed('question');
237
+ const rows = await client.listAllEntities({ type: type.name, state: 'active' });
238
+ const open = rows.filter((row) => row.fields?.status === 'open' && (!only || row.fields?.product === only));
239
+ // The bound is on the open questions. An answered question costs the view nothing (review G3).
240
+ if (open.length > MAX_OPEN_QUESTIONS) refuse(`${open.length} questions are open; the view reads at most ${MAX_OPEN_QUESTIONS}. Answer some, or narrow the view with a product id.`);
241
+ const items = new Map(), unreadable = new Map();
242
+ for (const row of open) {
243
+ const id = row.fields?.item;
244
+ if (typeof id !== 'string' || items.has(id)) continue;
245
+ try { items.set(id, await client.getEntity(id)); }
246
+ catch (error) { items.set(id, null); unreadable.set(id, error?.status === 404 ? 'the item is not in the graph' : `the graph did not answer: ${error?.message ?? String(error)}`); }
247
+ }
248
+ const readAt = now();
249
+ const kept = [], omitted = [];
250
+ for (const row of open) {
251
+ const item = items.get(row.fields.item) ?? null;
252
+ const lifecycle = item?.fields?.lifecycle ?? 'unknown';
253
+ // D3 rule 1: active items only. A question on any other item is named, not dropped in silence.
254
+ if (lifecycle !== 'active') { omitted.push({ question_id: row.id, item: row.fields.item, item_lifecycle: lifecycle, reason: item ? `the item is ${lifecycle}; paused and proposed items are backlog` : (unreadable.get(row.fields.item) ?? 'the item cannot be read') }); continue; }
255
+ const cost = row.fields.cost;
256
+ const rank = COSTS.indexOf(cost);
257
+ const asked = row.fields.asked_by ?? {};
258
+ kept.push({
259
+ question_id: row.id, product: row.fields.product, item: item.id, cost, cost_rank: rank === -1 ? COSTS.length : rank,
260
+ asked_at: row.fields.asked_at ?? row.created_at ?? '', asked_by: asked.agent ?? 'unknown',
261
+ lines: [
262
+ oneLine(row.fields.text),
263
+ itemLine(item, row.fields.product),
264
+ oneLine(`${cost}: ${COST_MEANING[cost] ?? 'cost outside the vocabulary'} · asked by ${asked.agent ?? 'unknown'} on ${(row.fields.asked_at ?? '').slice(0, 10)}`),
265
+ ],
266
+ folded: { question: row.fields.text, context: row.fields.context ?? {}, asked_by: asked, item_title: item.title, item_stage: item.fields.stage ?? '', item_next_step: item.fields.next_step ?? '' },
267
+ citation: { question: row.id, item: item.id, read_at: readAt },
268
+ });
269
+ }
270
+ kept.sort((a, b) => a.cost_rank - b.cost_rank || String(a.asked_at).localeCompare(String(b.asked_at)) || a.question_id.localeCompare(b.question_id));
271
+ const products = [...new Set(kept.map((row) => row.product))].sort();
272
+ const byProduct = products.map((product) => ({ product, rows: kept.filter((row) => row.product === product) }));
273
+ const render = byProduct.map((group) => [`${group.product} — needs you (${group.rows.length})`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n')).join('\n\n');
274
+ return { verb: 'needs_me', read_at: readAt, product: only, row_budget: ROW_LINES, rows: kept.length, products: byProduct, omitted, text: render || 'Nothing needs you: no open question on an active item.' };
275
+ },
276
+ };
277
+ return verbs;
278
+ }
@@ -0,0 +1,189 @@
1
+ // Atlas M1 slice L5: the one-time sweep.
2
+ // Design: the Atlas M1 study, D5 slice L5, design
3
+ // finding R7, and the frozen L5 paragraph of the lock block.
4
+ //
5
+ // The frozen text asks for two things and nothing else:
6
+ // 1. The owner's one-time sweep, written as a decision record that lists the
7
+ // active items. That record is the oracle (R7). It is written by the
8
+ // decision_record verb of slice L2 with kind "sweep"; this module adds no
9
+ // second way to write a decision.
10
+ // 2. The "In flight" view equal to that list. sweep_check reads the view of
11
+ // slice L3 and compares it with the record. It writes nothing.
12
+ //
13
+ // One write verb stands between the two: item_adopt. The sweep gives an item a
14
+ // product, and until now no verb could. item_propose and item_lock name a
15
+ // product when they make or lock an item; items made before the registry
16
+ // existed carry no product at all. Both L3 and L4 views say so on every read.
17
+ import { refuse, text, oneOf, stringList } from './verb-fields.mjs';
18
+ import { oneLine, scrubSecrets, ROW_LINE_MAX } from './questions.mjs';
19
+ import { listLine } from './delivery.mjs';
20
+
21
+ export const SWEEP_WRITE_VERBS = Object.freeze(['item_adopt']);
22
+ export const SWEEP_READ_VERBS = Object.freeze(['sweep_check']);
23
+ // The kind that marks a decision record as a sweep. `kind` is a free string in
24
+ // the manifest, so this costs no manifest version (the M2 lane holds it).
25
+ export const SWEEP_KIND = 'sweep';
26
+
27
+ // A sweep record can hold at most this many item ids, because decision_record
28
+ // verifies each one. The same number bounds what the check reads back, so the
29
+ // two can never disagree (architect review, finding 5).
30
+ export const MAX_SWEPT = 200;
31
+ const MAX_SWEEPS = 300;
32
+ const TITLES_FETCHED = 25;
33
+
34
+ export function createSweepVerbs(core) {
35
+ const { client, now, typeNamed, registry, requireProduct, requireRepos, requireItem, requireOwner, patch, alreadyPatched, inFlight, stages, tiers, drivers } = core;
36
+
37
+ const verbs = {
38
+ // Give an existing item its product and repos. Owner present, because the
39
+ // sweep is the owner's own list (R7) and a product is a name the owner owns.
40
+ async item_adopt(args, requestId, meta) {
41
+ requireOwner('item adopt');
42
+ const item = await requireItem(args.item_id);
43
+ const fields = item.fields ?? {};
44
+ if (alreadyPatched(item, requestId, meta)) {
45
+ return { verb: 'item_adopt', item_id: item.id, version: item.version, product: fields.product ?? null, repos: fields.repos ?? [], lifecycle: fields.lifecycle ?? null, stage: fields.stage ?? null, replayed_from_graph: true };
46
+ }
47
+ const reg = await registry();
48
+ const product = await requireProduct(args.product, reg);
49
+ const productId = product.fields.registry_id;
50
+ const named = typeof fields.product === 'string' ? fields.product : '';
51
+ // An item is named once. Moving it between registered products is not a
52
+ // sweep, and no verb of M1 does it: the reader of any view would see the
53
+ // work change product with nothing on the record to say why.
54
+ if (named && named !== productId && reg.products.has(named)) refuse(`item ${item.id} already names the registered product ${named}, not ${productId}; the sweep names an item once`);
55
+ // `repos` is required, and an empty list is how a caller says "this item
56
+ // has no repo" — a container root is the real case. Leaving the argument
57
+ // out used to fall back to whatever survived the product filter, which
58
+ // could silently empty the field and leave an item no other verb could
59
+ // have written (architect review, finding 4).
60
+ if (args.repos === undefined) {
61
+ const kept = (Array.isArray(fields.repos) ? fields.repos : []).filter((id) => reg.repos.get(id)?.fields?.product === productId);
62
+ refuse(`repos is required: name the repo ids under ${productId}, or pass an empty list for an item with no repo.${kept.length ? ` This item already names ${kept.join(', ')}.` : ''}`);
63
+ }
64
+ const asked = stringList(args.repos, 'repos', { max: 16 });
65
+ const repos = asked.length ? await requireRepos(asked, reg, productId) : [];
66
+ const dropped = (Array.isArray(fields.repos) ? fields.repos : []).filter((id) => !repos.includes(id));
67
+ const change = { product: productId, repos };
68
+ if (args.tier !== undefined) change.tier = oneOf(args.tier, 'tier', tiers);
69
+ if (args.driver !== undefined) change.driver = oneOf(args.driver, 'driver', drivers);
70
+ if (args.next_step !== undefined) change.next_step = text(args.next_step, 'next_step', { max: 1000 });
71
+ if (args.stage !== undefined) {
72
+ const stage = oneOf(args.stage, 'stage', stages);
73
+ if (fields.lifecycle !== 'active') refuse(`item ${item.id} is ${fields.lifecycle ?? 'unknown'}; only an active item carries a stage`);
74
+ change.stage = stage;
75
+ }
76
+ const same = named === productId && [...repos].sort().join(' ') === [...(Array.isArray(fields.repos) ? fields.repos : [])].sort().join(' ');
77
+ if (same && Object.keys(change).length === 2) return { verb: 'item_adopt', item_id: item.id, version: item.version, product: productId, repos, lifecycle: fields.lifecycle ?? null, stage: fields.stage ?? null, unchanged: true };
78
+ // The audit stamps say when the sweep NAMED this item and what it came
79
+ // from. A later call that only sets a stage or a next step must not move
80
+ // them, or "adopted_from: engram" ends up on an item already in engram
81
+ // and the sweep's own date is lost (architect review, finding 3).
82
+ const stamps = named === productId ? {} : { adopted_at: now(), adopted_from: named || null };
83
+ const entity = await patch(item, 'item_adopt', requestId, { ...change, ...stamps }, {
84
+ digest: meta?.digest,
85
+ tags: [`product:${productId}`, ...repos.map((id) => `repo:${id}`), ...(change.tier ? [`tier:${change.tier}`] : [])],
86
+ });
87
+ return { verb: 'item_adopt', item_id: entity.id, version: entity.version, product: productId, repos, lifecycle: entity.fields?.lifecycle ?? null, stage: entity.fields?.stage ?? null, previous_product: named || null, ...(dropped.length ? { dropped_repos: dropped } : {}) };
88
+ },
89
+
90
+ // R7 made mechanical: the sweep record is the oracle, so the check reads
91
+ // the record and the view and names every difference. Read only.
92
+ async sweep_check(args = {}) {
93
+ const reg = await registry();
94
+ let only = null;
95
+ if (args.product !== undefined) only = (await requireProduct(args.product, reg)).fields.registry_id;
96
+ const type = await typeNamed('decision');
97
+ const decisions = await client.listAllEntities({ type: type.name, state: 'active' });
98
+ // Filter to sweeps BEFORE bounding. Every answered question writes a
99
+ // decision, so a count over all decisions would one day refuse the verb
100
+ // with no argument that helps (architect review, finding 6).
101
+ const sweeps = decisions.filter((row) => row.fields?.kind === SWEEP_KIND && typeof row.fields?.product === 'string' && row.fields.product && (!only || row.fields.product === only));
102
+ if (sweeps.length > MAX_SWEEPS) refuse(`${sweeps.length} sweep records are on record; the check reads at most ${MAX_SWEEPS}. Narrow it with a product id.`);
103
+ // A sweep for a product the registry does not hold cannot be checked: the
104
+ // view shows rows only for registered products, so every listed item
105
+ // would read as missing for ever (architect review, finding 7).
106
+ const orphaned = [...new Set(sweeps.filter((row) => !reg.products.has(row.fields.product)).map((row) => row.fields.product))].sort();
107
+ // One sweep stands per product. A sweep that names another with
108
+ // `replaces` supersedes it outright; otherwise the newest by the owner's
109
+ // date wins, then by write time, then by id so a tie is never decided by
110
+ // list order (architect review, findings 12 and 13).
111
+ const replaced = new Set(sweeps.map((row) => row.fields?.replaces).filter(Boolean));
112
+ const latest = new Map();
113
+ for (const row of sweeps) {
114
+ if (!reg.products.has(row.fields.product)) continue;
115
+ if (replaced.has(row.id)) continue;
116
+ const at = `${String(row.fields.date ?? '')} ${String(row.created_at ?? row.updated_at ?? '')} ${row.id}`;
117
+ const held = latest.get(row.fields.product);
118
+ if (!held || at > held.at) latest.set(row.fields.product, { row, at, superseded: held ? [...held.superseded, held.row.id] : [] });
119
+ else held.superseded.push(row.id);
120
+ }
121
+ for (const [product, held] of latest) held.superseded = [...new Set([...held.superseded, ...sweeps.filter((row) => row.fields.product === product && replaced.has(row.id)).map((row) => row.id)])];
122
+ const view = await inFlight(only ? { product: only } : {});
123
+ const inView = new Map(view.products.map((group) => [group.product, group.rows.map((row) => row.item)]));
124
+ const titles = new Map(view.products.flatMap((group) => group.rows.map((row) => [row.item, row.folded?.title ?? row.lines[0]])));
125
+
126
+ const checks = [];
127
+ const unreadable = [];
128
+ for (const productId of [...latest.keys()].sort()) {
129
+ const { row, superseded } = latest.get(productId);
130
+ // A malformed record must not make a read verb refuse: it is reported
131
+ // as unreadable and the other products still answer (finding 12).
132
+ let listed;
133
+ try { listed = [...new Set(stringList(row.fields.items ?? [], 'items', { max: MAX_SWEPT }))]; }
134
+ catch (error) { unreadable.push({ product: productId, decision: row.id, reason: error?.message ?? 'items is not a list of ids' }); continue; }
135
+ const seen = inView.get(productId) ?? [];
136
+ const missing = listed.filter((id) => !seen.includes(id));
137
+ const extra = seen.filter((id) => !listed.includes(id));
138
+ // A missing id is not in the view, so the view carries no title for it.
139
+ // The owner reads titles, not ids, so the check fetches the few it needs.
140
+ for (const [index, id] of missing.entries()) {
141
+ if (titles.has(id)) continue;
142
+ if (index >= TITLES_FETCHED) { titles.set(id, 'title not read: past the fetch bound of this check'); continue; }
143
+ try { const entity = await client.getEntity(id); titles.set(id, entity?.title ?? ''); }
144
+ catch { titles.set(id, 'the graph did not answer for this id'); }
145
+ }
146
+ checks.push({
147
+ product: productId, decision: row.id, date: row.fields.date ?? null, source: row.fields.source ?? '',
148
+ listed: listed.length, in_view: seen.length,
149
+ missing_from_view: missing.map((id) => ({ item: id, title: oneLine(titles.get(id) ?? '', 120) })),
150
+ extra_in_view: extra.map((id) => ({ item: id, title: oneLine(titles.get(id) ?? '', 120) })),
151
+ matches: !missing.length && !extra.length, items: listed, ...(superseded.length ? { superseded } : {}),
152
+ });
153
+ }
154
+ const notSwept = [...reg.products.keys()].filter((id) => (!only || id === only) && !latest.has(id)).sort();
155
+ // `matches` answers one narrow question: does each product's view hold
156
+ // exactly what its sweep lists? `complete` answers the wider one the
157
+ // owner actually asked: is every item in the tenant on a registered
158
+ // product? An item nobody adopted is missing from the record AND from
159
+ // the view, so the two sides agree by omission, and a headline reading
160
+ // "equal" over a fifth of the corpus would be a lie (architect review,
161
+ // finding 2). The two are reported apart, and the text says both.
162
+ const matches = checks.length > 0 && !notSwept.length && !unreadable.length && !orphaned.length && checks.every((check) => check.matches);
163
+ const outside = view.unregistered_total ?? 0;
164
+ const complete = matches && outside === 0;
165
+
166
+ const block = [
167
+ `Sweep check — ${only ?? 'every registered product'} · read ${String(view.read_at).slice(0, 19)}Z · ${matches ? 'the view equals the sweep' : 'the view and the sweep differ'}${matches ? (complete ? ', and every item in the tenant is on a registered product' : `, but ${outside} item${outside === 1 ? '' : 's'} sit outside every product, so the sweep is not complete`) : ''}`,
168
+ ...checks.map((check) => [
169
+ `${check.product} — sweep ${String(check.date ?? 'no date')} lists ${check.listed}, the view shows ${check.in_view} · ${check.matches ? 'equal' : 'not equal'}`,
170
+ ` decision ${check.decision}`,
171
+ ...check.missing_from_view.map((row) => listLine(`listed, not in flight: ${row.title}`, row.item)),
172
+ ...check.extra_in_view.map((row) => listLine(`in flight, not listed: ${row.title}`, row.item)),
173
+ ].join('\n')),
174
+ ...(notSwept.length ? [`no sweep on record (${notSwept.length})\n${notSwept.map((id) => ` ${id}`).join('\n')}`] : []),
175
+ ...(orphaned.length ? [`a sweep names a product the registry does not hold (${orphaned.length})\n${orphaned.map((id) => ` ${id} — the view can show no row for it, so its items would read as missing for ever`).join('\n')}`] : []),
176
+ ...(unreadable.length ? [`unreadable sweep record (${unreadable.length})\n${unreadable.map((row) => ` ${row.product} · ${row.decision} — ${row.reason}`).join('\n')}`] : []),
177
+ ...(outside ? [oneLine(`${outside} item${outside === 1 ? '' : 's'} still name no registered product, so the sweep is not complete. Each one is invisible to both sides of this check.`, ROW_LINE_MAX)] : []),
178
+ ...((view.repo_errors ?? []).length ? [`GitHub did not answer for ${view.repo_errors.length} repo${view.repo_errors.length === 1 ? '' : 's'}; the movement half of the view came from the graph alone\n${view.repo_errors.map((row) => ` ${row.repo} — ${row.reason}`).join('\n')}`] : []),
179
+ ].join('\n\n');
180
+ const rendered = scrubSecrets(block);
181
+ return {
182
+ verb: 'sweep_check', read_at: view.read_at, product: only, matches, complete, checks, not_swept: notSwept,
183
+ ...(orphaned.length ? { product_not_registered: orphaned } : {}), ...(unreadable.length ? { unreadable } : {}),
184
+ unregistered_total: outside, repo_errors: view.repo_errors ?? [], redactions: rendered.redactions, text: rendered.text,
185
+ };
186
+ },
187
+ };
188
+ return verbs;
189
+ }
@@ -0,0 +1,53 @@
1
+ // Field checks shared by every Atlas work verb. Moved here unchanged in M1
2
+ // slice L2 so the registry verbs (verbs.mjs) and the question, decision and
3
+ // receipt verbs (questions.mjs) read the same rules.
4
+ // Design: the Atlas M1 study, pages D1 and D2.
5
+ import { createHash } from 'node:crypto';
6
+
7
+ export const MAX_TEXT = 20_000;
8
+
9
+ export class VerbError extends Error {
10
+ constructor(message, code = 'refused') { super(message); this.name = 'VerbError'; this.code = code; }
11
+ }
12
+ export const refuse = (message) => { throw new VerbError(message); };
13
+
14
+ export function canonicalJson(value) {
15
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
16
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
17
+ return JSON.stringify(value ?? null);
18
+ }
19
+ export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
20
+
21
+ export function text(value, name, { max = MAX_TEXT, optional = false } = {}) {
22
+ if (value === undefined || value === null) { if (optional) return undefined; refuse(`${name} is required`); }
23
+ if (typeof value !== 'string' || !value.trim()) refuse(`${name} must be a non-empty string`);
24
+ if (value.length > max) refuse(`${name} is longer than ${max} characters`);
25
+ return value;
26
+ }
27
+ export function oneOf(value, name, allowed, fallback) {
28
+ if (value === undefined || value === null) { if (fallback !== undefined) return fallback; refuse(`${name} is required`); }
29
+ if (!allowed.includes(value)) refuse(`${name} must be one of: ${allowed.join(', ')}`);
30
+ return value;
31
+ }
32
+ export function isoDate(value, name) {
33
+ text(value, name, { max: 40 });
34
+ if (!/^\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z)?$/.test(value) || Number.isNaN(Date.parse(value))) refuse(`${name} must be an ISO date, for example 2026-09-16`);
35
+ return value;
36
+ }
37
+ export function origin(value) {
38
+ if (!value || typeof value !== 'object' || Array.isArray(value)) refuse('origin must be an object with text, source and date, in the owner\'s words');
39
+ return { text: text(value.text, 'origin.text'), source: text(value.source, 'origin.source', { max: 500 }), date: isoDate(value.date, 'origin.date') };
40
+ }
41
+ export function url(value, name) {
42
+ text(value, name, { max: 2000 });
43
+ let parsed;
44
+ try { parsed = new URL(value); } catch { refuse(`${name} must be an absolute http or https URL`); }
45
+ if (!['http:', 'https:'].includes(parsed.protocol)) refuse(`${name} must be an http or https URL`);
46
+ return value;
47
+ }
48
+ export function stringList(value, name, { max = 64 } = {}) {
49
+ if (!Array.isArray(value)) refuse(`${name} must be an array of strings`);
50
+ if (value.length > max) refuse(`${name} holds more than ${max} entries`);
51
+ for (const item of value) if (typeof item !== 'string' || !item.trim()) refuse(`${name} must hold non-empty strings`);
52
+ return [...new Set(value)];
53
+ }