@arjunkhera/atlas 0.4.38 → 0.4.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.38",
3
+ "version": "0.4.40",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/.mcp.json CHANGED
@@ -8,7 +8,6 @@
8
8
  "env": {
9
9
  "ATLAS_ENGRAM": "",
10
10
  "ATLAS_ENGRAM_CLOUD": "${ATLAS_ENGRAM:-}",
11
- "ATLAS_WORK_OWNER_PRESENT": "1",
12
11
  "ATLAS_WORK_JOURNAL": "",
13
12
  "ATLAS_WORK_SESSION": "",
14
13
  "ATLAS_WORK_AGENT": "",
@@ -455,7 +455,7 @@ function brakeRows(state, undo, named = null) {
455
455
  const until = new Date(Date.parse(newest.time) + state.days * 864e5).toISOString();
456
456
  const owner = { kind: 'owner', class: 'brake', cause: `the brake is on until ${until.slice(0, 16).replace('T', ' ')} UTC: ${tripText(newest)}${more}` };
457
457
  // Done line 6: one signed clear for each trip, as a comment on its commit.
458
- const clear = (holding.length ? holding : [newest]).map((trip) => ({ kind: 'note', class: 'brake', cause: `to end the trip now: atlas approve act brake-clear --repo <owner/repo> --commit ${trip.sha}, then post the block as a comment on commit ${trip.sha.slice(0, 7)}` }));
458
+ const clear = (holding.length ? holding : [newest]).map((trip) => ({ kind: 'note', class: 'brake', cause: `to end the trip now: atlas approve act brake-clear --repo <owner/repo> --commit ${trip.sha}, then post the block as a comment on commit ${trip.sha.slice(0, 7)} and run the atlas-brake-clear workflow` }));
459
459
  return [owner, ...clear];
460
460
  }
461
461
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.38",
3
+ "version": "0.4.40",
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",
@@ -192,20 +192,23 @@ next action. One short paragraph.
192
192
 
193
193
  1. Prove delivery the way the repo's `kind` requires, then call `item_done`. A
194
194
  `library` repo proves it by merged pull requests and a green gate. A
195
- `service` or `schema` repo also needs its live checks, because a schema
195
+ `service` or `schema` repo also needs its live checks. A schema
196
196
  migration reaches the running node after the merge.
197
197
  Each check in the repo's `required_checks` must be green on the pull
198
198
  request head or on the merge commit. When `atlas.yaml` says
199
199
  `live: release`, call `item_done` after the release run: it refuses
200
200
  until a published release holds the merge.
201
+
201
202
  A red check is a last resort, for an extraordinary case with no other
202
203
  option (owner, 22 October 2026). Fix the test first. If the owner still
203
204
  approved a merge with one known red test, record the words
204
205
  with `decision_record` on the item. The words must name the pull request
205
- and quote the exact test. One decision accepts one red check. The owner
206
- then signs a waiver act on his Mac with `atlas approve` and posts the
207
- block on the pull request. Then pass `accepted_red` to `item_done`; it
208
- needs the owner present. Any other red check still refuses the mark.
206
+ and quote the exact test. One decision accepts one red check.
207
+
208
+ The owner signs a waiver act for that check with `atlas approve`. He
209
+ posts the block on the pull request. Then pass `accepted_red` to
210
+ `item_done`; only the lead role may pass it. Any other red check still
211
+ refuses the mark.
209
212
  2. If anything was a struggle, run the `atlas:learnings` skill. It picks the
210
213
  home and the form of the fix, and records the lesson.
211
214
  3. Rebuild the design's page and the hub, so both read "Shipped". The
@@ -76,7 +76,7 @@ export function diffOf(current, next, { title, tags } = {}) {
76
76
  }
77
77
 
78
78
  export function createActivityVerbs(core) {
79
- const { client, label, now, typeNamed, requireItem, requireOwner, create, alreadyPatched } = core;
79
+ const { client, label, now, typeNamed, requireItem, create, alreadyPatched } = core;
80
80
 
81
81
  // The entry type, or null before the owner has run setup for 0.7.0.
82
82
  async function entryType() {
@@ -139,7 +139,6 @@ export function createActivityVerbs(core) {
139
139
  async item_comment(args, requestId, meta) {
140
140
  const item = await requireItem(args.item_id);
141
141
  const voice = oneOf(args.voice, 'voice', VOICES, 'agent');
142
- if (voice === 'owner') requireOwner('item comment in the owner\'s voice');
143
142
  const words = text(args.text, 'text', { max: MAX_COMMENT });
144
143
  const type = await typeNamed('entry');
145
144
  const entries = await entriesOf(type, item.id);
@@ -145,7 +145,7 @@ export function createCapabilityVerbs(core) {
145
145
  });
146
146
  return {
147
147
  verb: 'code_read_brief', read_at: now(), product: productId, capability, repos: wanted, briefs,
148
- note: 'Give one brief to one sub-agent. Sub-agents never receive the verb server or the Engram MCP. Pass each returned digest back in where_are_we.code.',
148
+ note: 'Give one brief to one crew. A crew holds no MCP tool: its tools line names none, so it cannot call the verb server or the Engram MCP. The server cannot tell a sub-agent from the lead in one session (R20). Pass each returned digest back in where_are_we.code.',
149
149
  };
150
150
  },
151
151
 
@@ -266,7 +266,7 @@ export function correctionsOf(marks = []) {
266
266
  }
267
267
 
268
268
  export function createDeliveryVerbs(core) {
269
- const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, ownerPresent, patch, create, edge, landed, alreadyPatched, readGitHub, readLive } = core;
269
+ const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, patch, create, edge, landed, alreadyPatched, readGitHub, readLive } = core;
270
270
 
271
271
  const github = async (path) => {
272
272
  if (typeof readGitHub !== 'function') refuse('this verb needs a GitHub reader; the server supplies one');
@@ -324,9 +324,7 @@ export function createDeliveryVerbs(core) {
324
324
  }
325
325
  return null;
326
326
  }
327
- // The reopen door (R9). The owner-present flag, or the lead in this session.
328
- const requireOwnerOrLead = (verb) => { if (!ownerPresent && label.agent !== 'lead') refuse(`${verb} is for the owner or the lead in session; this session is agent "${label.agent}"`); };
329
-
327
+ // The reopen door (R9) is a row of the role table (roles.mjs, R20): the lead.
330
328
  const markOf = async (id, itemId) => {
331
329
  const type = await typeNamed('mark');
332
330
  let entity;
@@ -432,9 +430,11 @@ export function createDeliveryVerbs(core) {
432
430
  //
433
431
  // A decision record is an Engram entity, and any Engram writer can make one
434
432
  // that looks right. So the acceptance is not taken from the record alone:
435
- // passing accepted_red needs the owner-present flag, the same flag that
436
- // decision_record needs. A forged record then gives a caller nothing it
437
- // could not write itself (security review of this change, finding 1).
433
+ // passing accepted_red needs the lead role, the same role that
434
+ // decision_record needs (the role table, roles.mjs, R20). A forged record
435
+ // then gives a caller nothing it could not write itself (security review
436
+ // of this change, finding 1). The lead role is not the owner's own act; the
437
+ // signed waiver below is.
438
438
  // R17 line 6: a decision record alone does not waive a red check. The owner
439
439
  // signs a waiver act with his security key (R72, design
440
440
  // docs/design-docs/signed-act/) and posts the block on the pull request.
@@ -530,10 +530,9 @@ export function createDeliveryVerbs(core) {
530
530
 
531
531
  const verbs = {
532
532
  // The delivering agent marks its own change done, after the verb runs the
533
- // three live checks itself. No owner flag: the agent that shipped it calls
534
- // this, and the lead catches up a delivery made outside a session (R2).
533
+ // three live checks itself. The role table gives it to the lead, and
534
+ // accepted_red, the owner's words, to the lead too (roles.mjs, R20).
535
535
  async item_done(args, requestId, meta) {
536
- if (args.accepted_red !== undefined && !ownerPresent) refuse('accepted_red needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1): only the owner waives a red check');
537
536
  const item = await requireItem(args.item_id);
538
537
  const fields = item.fields ?? {};
539
538
  const replay = alreadyPatched(item, requestId, meta);
@@ -750,11 +749,10 @@ export function createDeliveryVerbs(core) {
750
749
  };
751
750
  },
752
751
 
753
- // Reopen: the owner or the lead in session, with a reason and the failed
752
+ // Reopen: the lead in session, with a reason and the failed
754
753
  // check as a URL. It appends a mark that corrects the delivery. The
755
754
  // delivery mark is never touched (R9).
756
755
  async item_reopen(args, requestId, meta) {
757
- requireOwnerOrLead('item reopen');
758
756
  const item = await requireItem(args.item_id);
759
757
  const fields = item.fields ?? {};
760
758
  const reason = text(args.reason, 'reason', { max: 4000 });
@@ -119,7 +119,7 @@ export function itemLine(item, product, lifecycle = 'active') {
119
119
  }
120
120
 
121
121
  export function createQuestionVerbs(core) {
122
- const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated } = core;
122
+ const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, patch, create, edge, landed, alreadyPatched, findCreated } = core;
123
123
 
124
124
  async function recordOfKind(id, kind, name) {
125
125
  text(id, name, { max: 200 });
@@ -193,7 +193,6 @@ export function createQuestionVerbs(core) {
193
193
 
194
194
  // The owner answers. One call writes the decision, the receipt, and closes the question.
195
195
  async question_answer(args, requestId, meta) {
196
- requireOwner('question answer');
197
196
  const question = await recordOfKind(args.question_id, 'question', 'question_id');
198
197
  const answer = text(args.text, 'text', { max: MAX_RENDERED });
199
198
  const date = isoDate(args.date, 'date');
@@ -229,7 +228,6 @@ export function createQuestionVerbs(core) {
229
228
 
230
229
  // The owner speaks outside a question. D2: refuses with no verbatim text, no date.
231
230
  async decision_record(args, requestId, meta) {
232
- requireOwner('decision record');
233
231
  const decisionText = text(args.text, 'text', { max: MAX_RENDERED });
234
232
  const date = isoDate(args.date, 'date');
235
233
  const source = text(args.source, 'source', { optional: true, max: 500 }) ?? '';
@@ -0,0 +1,135 @@
1
+ // Roadmap R20: the roles of the tools server, and the one table that says
2
+ // who may call each verb. Every guard reads only this table.
3
+ //
4
+ // The probe of 26 October 2026 found no harness marker that tells a lead, a
5
+ // sub-agent and a scheduled run apart: a sub-agent shares the lead's server
6
+ // process, and a scheduled run carries the same variables as a project
7
+ // thread. So the role comes from two things only:
8
+ // 1. the key type, which gives the widest role: a user key gives lead, an
9
+ // agent key gives routine. Today the server starts with a user key
10
+ // only, so routine comes from the marker until R22 opens the agent key;
11
+ // 2. the launcher's marker ATLAS_WORK_ROLE, which can only narrow it.
12
+ // No role is the owner. The lead writes down the owner's words; the owner's
13
+ // own act is a signed act (R72), which the owner door reads (R27).
14
+ import { refuse } from './verb-fields.mjs';
15
+
16
+ // Widest first. A role may call what a later role may call, and more.
17
+ export const ROLES = Object.freeze(['lead', 'routine', 'crew']);
18
+ const ALL = ROLES;
19
+ const LEAD = Object.freeze(['lead']);
20
+ const LEAD_AND_ROUTINE = Object.freeze(['lead', 'routine']);
21
+ const rank = (role) => ROLES.indexOf(role);
22
+
23
+ export const READ_ROLES = ALL;
24
+
25
+ // Each write verb, and atlas_upgrade, with the roles that may call it.
26
+ // owner_words marks a verb whose record is the owner's words, written down
27
+ // by the lead. The stamp still says lead: it is never the owner's own act.
28
+ export const VERB_ROLES = Object.freeze({
29
+ setup: { roles: LEAD },
30
+ product_add: { roles: LEAD },
31
+ product_edit: { roles: LEAD },
32
+ repo_add: { roles: LEAD },
33
+ repo_edit: { roles: LEAD },
34
+ item_propose: { roles: LEAD_AND_ROUTINE },
35
+ item_lock: { roles: LEAD, owner_words: true },
36
+ item_split: { roles: LEAD },
37
+ item_park: { roles: LEAD, owner_words: true },
38
+ item_resume: { roles: LEAD, owner_words: true },
39
+ item_drop: { roles: LEAD, owner_words: true },
40
+ item_edit: { roles: LEAD },
41
+ item_block: { roles: LEAD },
42
+ item_unblock: { roles: LEAD },
43
+ item_stage: { roles: LEAD },
44
+ item_link: { roles: LEAD },
45
+ item_comment: { roles: LEAD_AND_ROUTINE },
46
+ step_in_name: { roles: LEAD },
47
+ lesson_record: { roles: LEAD },
48
+ question_ask: { roles: LEAD },
49
+ question_answer: { roles: LEAD, owner_words: true },
50
+ decision_record: { roles: LEAD, owner_words: true },
51
+ item_done: { roles: LEAD },
52
+ item_reopen: { roles: LEAD },
53
+ atlas_upgrade: { roles: LEAD },
54
+ });
55
+
56
+ // The owner-only arguments: a verb that one role may call, with an argument
57
+ // that only a narrower set of roles may pass. when says if a call holds it.
58
+ export const ARGUMENT_ROLES = Object.freeze({
59
+ 'item_edit.title': { verb: 'item_edit', roles: LEAD, owner_words: true, when: (args) => args.title !== undefined },
60
+ 'item_edit.plan': { verb: 'item_edit', roles: LEAD, owner_words: true, when: (args) => args.plan !== undefined },
61
+ 'item_comment.voice': { verb: 'item_comment', roles: LEAD, owner_words: true, name: '`voice: owner`', when: (args) => args.voice === 'owner' },
62
+ 'item_done.accepted_red': { verb: 'item_done', roles: LEAD, owner_words: true, when: (args) => args.accepted_red !== undefined },
63
+ 'atlas_upgrade.check': { verb: 'atlas_upgrade', roles: ALL, widens: true, name: '`check: true`', when: (args) => args.check === true },
64
+ });
65
+
66
+ // A widening row replaces the roles of its verb, so it would undo a
67
+ // narrowing row on the same verb. The table refuses to load with both.
68
+ for (const [key, argument] of Object.entries(ARGUMENT_ROLES)) {
69
+ if (!argument.widens) continue;
70
+ const other = Object.keys(ARGUMENT_ROLES).find((name) => name !== key && ARGUMENT_ROLES[name].verb === argument.verb);
71
+ if (other) throw new Error(`role table: ${key} widens ${argument.verb}, so ${other} may not narrow it`);
72
+ }
73
+
74
+ const KEY_ROLES = Object.freeze({ user: 'lead', agent: 'routine' });
75
+
76
+ // The role of a server. keyType is the principal_type of the Engram key;
77
+ // marker is ATLAS_WORK_ROLE. A marker the table does not know narrows to the
78
+ // least role, so a typo never widens. The answer says why, for the
79
+ // instructions and the health file.
80
+ export function roleOf({ keyType, marker } = {}) {
81
+ const fromKey = KEY_ROLES[keyType] ?? 'crew';
82
+ const key = keyType === 'agent' ? 'an agent key' : keyType === 'user' ? 'a user key' : 'no known key';
83
+ const said = typeof marker === 'string' ? marker.trim() : '';
84
+ if (!said) return { role: fromKey, from: key };
85
+ const fromMarker = ROLES.includes(said) ? said : 'crew';
86
+ const role = rank(fromMarker) > rank(fromKey) ? fromMarker : fromKey;
87
+ const why = ROLES.includes(said) ? `ATLAS_WORK_ROLE=${said}` : `ATLAS_WORK_ROLE=${said}, which is not a role`;
88
+ return { role, from: `${key} with ${why}` };
89
+ }
90
+
91
+ // The roles that may make this call. A read verb is open to every role.
92
+ export function rolesFor(verb, args = {}, { readVerbs = [] } = {}) {
93
+ if (readVerbs.includes(verb)) return READ_ROLES;
94
+ const row = VERB_ROLES[verb];
95
+ if (!row) return null;
96
+ let roles = row.roles;
97
+ for (const argument of Object.values(ARGUMENT_ROLES)) {
98
+ if (argument.verb !== verb || !argument.when(args)) continue;
99
+ roles = argument.widens ? argument.roles : roles.filter((role) => argument.roles.includes(role));
100
+ }
101
+ return roles;
102
+ }
103
+
104
+ // The name of an argument row, as the docs and the refusals print it.
105
+ const nameOf = (argument) => argument.name ?? `\`${Object.keys(ARGUMENT_ROLES).find((key) => ARGUMENT_ROLES[key] === argument).split('.')[1]}\``;
106
+
107
+ // The one guard. It refuses a call that the table does not give this role.
108
+ export function allow(role, verb, args = {}, { readVerbs = [], from } = {}) {
109
+ const roles = rolesFor(verb, args, { readVerbs });
110
+ if (!roles) refuse(`${verb} has no row in the role table (packages/atlas/work/lib/roles.mjs); it is refused for every role`);
111
+ if (roles.includes(role)) return;
112
+ const named = Object.values(ARGUMENT_ROLES).find((argument) => argument.verb === verb && argument.when(args) && !argument.widens && !argument.roles.includes(role));
113
+ const what = named ? `${verb} with ${nameOf(named).replaceAll('`', '')}` : verb;
114
+ const who = (named?.roles ?? roles).join(' or ');
115
+ refuse(`${what} is for the ${who}. This server runs as ${role}${from ? `, from ${from}` : ''}`);
116
+ }
117
+
118
+ // The Who cell of work/VERBS.md for one verb, generated from the table
119
+ // (R20 line 7). node work/who.mjs --write puts it in the file.
120
+ export function whoCell(verb, { readVerbs = [] } = {}) {
121
+ if (readVerbs.includes(verb) && !VERB_ROLES[verb]) return 'every role';
122
+ const row = VERB_ROLES[verb];
123
+ if (!row) return null;
124
+ const parts = [row.roles.join(', ')];
125
+ const words = [];
126
+ for (const argument of Object.values(ARGUMENT_ROLES)) {
127
+ if (argument.verb !== verb) continue;
128
+ const same = argument.roles.length === row.roles.length && argument.roles.every((role) => row.roles.includes(role));
129
+ if (!same) parts.push(`${argument.roles.length === ROLES.length ? 'every role' : argument.roles.join(', ')} for ${nameOf(argument)}`);
130
+ if (argument.owner_words) words.push(nameOf(argument));
131
+ }
132
+ if (row.owner_words) parts.push("the owner's words");
133
+ if (words.length) parts.push(`${words.join(' and ')} ${words.length === 1 ? 'is' : 'are'} the owner's words`);
134
+ return parts.join('; ');
135
+ }
@@ -22,6 +22,7 @@ import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capabilit
22
22
  import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
23
23
  import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
24
24
  import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
25
+ import { ROLES, allow } from './roles.mjs';
25
26
  import { createLessonVerbs, LESSON_WRITE_VERBS, LESSON_READ_VERBS } from './lessons.mjs';
26
27
 
27
28
  export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
@@ -106,12 +107,18 @@ const renderHandle = (value, handle) => JSON.parse(JSON.stringify(value).replace
106
107
  // Sortable form of a three-part version, for "is the tenant ahead of us?".
107
108
  const semver = (value) => String(value).split('.').map((part) => String(Number(part) || 0).padStart(6, '0')).join('.');
108
109
 
109
- export function createVerbs({ client, store, writer, session, agent = 'lead', boundClient = null, ownerPresent = false, readGitHub, readLive, now = () => new Date().toISOString() } = {}) {
110
+ // The verbs that write nothing. The role table opens them to every role (R20).
111
+ export 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, ...LESSON_READ_VERBS]);
112
+
113
+ export function createVerbs({ client, store, writer, session, agent, role, roleFrom, boundClient = null, readGitHub, readLive, now = () => new Date().toISOString() } = {}) {
110
114
  if (!client) throw new TypeError('client is required');
111
115
  if (!store) throw new TypeError('store (the request journal) is required');
112
116
  if (typeof writer?.token_id !== 'string' || !writer.token_id) throw new TypeError('writer.token_id is required');
113
117
  if (typeof session !== 'string' || !session.trim()) throw new TypeError('session is required; the server supplies it');
114
- const label = Object.freeze({ token_id: writer.token_id, session, agent, ...(boundClient ? { client: String(boundClient) } : {}) });
118
+ // Roadmap R20: the role decides what this server may call, and the
119
+ // writer stamp names it. The agent label is a name only; it gives no right.
120
+ if (!ROLES.includes(role)) throw new TypeError(`role must be one of ${ROLES.join(', ')}`);
121
+ const label = Object.freeze({ token_id: writer.token_id, session, agent: agent ?? role, role, ...(boundClient ? { client: String(boundClient) } : {}) });
115
122
  let handleCache = null, typesCache = null;
116
123
 
117
124
  async function handle() {
@@ -191,7 +198,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
191
198
  if (entity.state && entity.state !== 'active') refuse(`item ${id} is ${entity.state}, not active in the graph`);
192
199
  return entity;
193
200
  }
194
- const requireOwner = (verb) => { if (!ownerPresent) refuse(`${verb} needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)`); };
195
201
 
196
202
  // fields may be a function of the record as read, so a retry after a 409
197
203
  // recomputes it from the newer record. guard, when given, is checked again on
@@ -264,7 +270,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
264
270
 
265
271
  const verbs = {
266
272
  async setup(args) {
267
- requireOwner('setup');
268
273
  const publisherHandle = await handle();
269
274
  const rendered = renderHandle(manifestCurrent, publisherHandle);
270
275
  if (rendered.name !== 'atlas-work' || rendered.version !== MANIFEST_VERSION) throw new Error(`local manifest must be atlas-work ${MANIFEST_VERSION}`);
@@ -321,7 +326,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
321
326
  },
322
327
 
323
328
  async product_add(args, requestId, meta) {
324
- requireOwner('product add');
325
329
  const registryId = text(args.registry_id, 'registry_id', { max: 64 });
326
330
  if (!PRODUCT_ID.test(registryId)) refuse('registry_id must be a lowercase slug: letters, digits and hyphens, for example "engram"');
327
331
  const name = text(args.name, 'name', { max: 200 });
@@ -347,7 +351,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
347
351
  },
348
352
 
349
353
  async product_edit(args, requestId, meta) {
350
- requireOwner('product edit');
351
354
  const registryId = text(args.registry_id, 'registry_id', { max: 64 });
352
355
  const reg = await registry();
353
356
  const record = await requireProduct(registryId, reg);
@@ -376,7 +379,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
376
379
  },
377
380
 
378
381
  async repo_add(args, requestId, meta) {
379
- requireOwner('repo add');
380
382
  if (typeof readGitHub !== 'function') refuse('repo add needs a GitHub reader for the push-rights check');
381
383
  const registryId = text(args.registry_id, 'registry_id', { max: 200 });
382
384
  if (!REPO_ID.test(registryId)) refuse('registry_id must be a GitHub "owner/repo" name');
@@ -401,7 +403,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
401
403
  },
402
404
 
403
405
  async repo_edit(args, requestId, meta) {
404
- requireOwner('repo edit');
405
406
  const registryId = text(args.registry_id, 'registry_id', { max: 200 });
406
407
  const reg = await registry();
407
408
  const record = reg.repos.get(registryId);
@@ -425,7 +426,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
425
426
  },
426
427
 
427
428
  async item_propose(args, requestId, meta) {
428
- requireOwner('item propose');
429
429
  const title = text(args.title, 'title', { max: 300 });
430
430
  const reg = await registry();
431
431
  const product = await requireProduct(args.product, reg);
@@ -443,7 +443,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
443
443
  },
444
444
 
445
445
  async item_lock(args, requestId, meta) {
446
- requireOwner('item lock');
447
446
  const item = await requireItem(args.item_id);
448
447
  const fields = item.fields ?? {};
449
448
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_lock', item_id: item.id, version: item.version, lifecycle: fields.lifecycle, stage: fields.stage, product: fields.product, repos: fields.repos, scope_hash: fields.scope_hash, locked_at: fields.locked_at, replayed_from_graph: true };
@@ -473,7 +472,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
473
472
  },
474
473
 
475
474
  async item_split(args, requestId, meta) {
476
- requireOwner('item split');
477
475
  const parent = await requireItem(args.item_id);
478
476
  const reason = text(args.reason, 'reason', { max: 2000 });
479
477
  // split_order is a piece's place in the parent's split_children. That
@@ -534,7 +532,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
534
532
  },
535
533
 
536
534
  async item_park(args, requestId, meta) {
537
- requireOwner('item park');
538
535
  const item = await requireItem(args.item_id);
539
536
  const reason = text(args.reason, 'reason', { max: 2000 });
540
537
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_park', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
@@ -544,7 +541,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
544
541
  },
545
542
 
546
543
  async item_resume(args, requestId, meta) {
547
- requireOwner('item resume');
548
544
  const item = await requireItem(args.item_id);
549
545
  const reason = text(args.reason, 'reason', { max: 2000 });
550
546
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_resume', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
@@ -565,7 +561,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
565
561
  // dropped item stays in the graph; no live view lists it. A delivered item
566
562
  // is not dropped: item_reopen is its path.
567
563
  async item_drop(args, requestId, meta) {
568
- requireOwner('item drop');
569
564
  const item = await requireItem(args.item_id);
570
565
  const words = origin(args.origin);
571
566
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_drop', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, dropped_from: item.fields.dropped_from ?? null, replayed_from_graph: true };
@@ -578,8 +573,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
578
573
 
579
574
  // Release 0.2.3: edit an item's next step, what it waits on, and its title.
580
575
  // The next step and the wait are the lead's to keep true, so any
581
- // in-session caller may change them. The title is the owner's words, so a
582
- // new title needs the owner present. Each edit keeps the old values.
576
+ // in-session caller may change them. The title is the owner's words, so only
577
+ // the lead role sets a new title. Each edit keeps the old values.
583
578
  // Work item additions, slice 1: tags_add and tags_remove change the free
584
579
  // tags, and plan holds the owner's planned start, end and deadline.
585
580
  async item_edit(args, requestId, meta) {
@@ -591,7 +586,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
591
586
  const change = {}, previous = {};
592
587
  let title;
593
588
  if (args.title !== undefined) {
594
- requireOwner('item edit of a title');
595
589
  title = text(args.title, 'title', { max: 300 });
596
590
  if (title !== item.title) previous.title = item.title ?? '';
597
591
  }
@@ -618,7 +612,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
618
612
  let tagEdit = tagsAdd.length || tagsRemove.length;
619
613
  let planGiven = null;
620
614
  if (args.plan !== undefined) {
621
- requireOwner('item edit of planned dates');
622
615
  if (!args.plan || typeof args.plan !== 'object' || Array.isArray(args.plan)) refuse(`plan must be { ${PLAN_DATES.join(', ')} }, each a date YYYY-MM-DD or null to clear it`);
623
616
  const unknown = Object.keys(args.plan).filter((key) => !PLAN_DATES.includes(key));
624
617
  if (unknown.length) refuse(`plan holds only ${PLAN_DATES.join(', ')}; not ${unknown.join(', ')}`);
@@ -653,8 +646,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
653
646
 
654
647
  // Work item additions, slice 2: an item names the items that block it.
655
648
  // The list on the item is the one home of the fact; a view works out
656
- // "blocks" from the items it loaded. The lead keeps the links true, so no
657
- // owner flag is needed. A link that would close a loop is refused.
649
+ // "blocks" from the items it loaded. The lead keeps the links true (the role
650
+ // table gives it to the lead). A link that would close a loop is refused.
658
651
  async item_block(args, requestId, meta) {
659
652
  const item = await requireItem(args.item_id);
660
653
  text(args.blocked_by, 'blocked_by', { max: 200 });
@@ -744,9 +737,9 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
744
737
  // registry and this writer stamp. The delivery verbs also get the two
745
738
  // read-only readers: GitHub through the gh CLI, and one GET against the
746
739
  // deployed app. Slice L4 adds three read verbs, which write nothing.
747
- const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated };
740
+ const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, patch, create, edge, landed, alreadyPatched, findCreated };
748
741
  Object.assign(verbs, createQuestionVerbs(core));
749
- Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
742
+ Object.assign(verbs, createDeliveryVerbs({ ...core, readGitHub, readLive }));
750
743
  Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
751
744
  Object.assign(verbs, createRoundupVerbs({ ...core, readGitHub, inFlight: (args) => verbs.in_flight(args), needsMe: (args) => verbs.needs_me(args) }));
752
745
  const activity = createActivityVerbs(core);
@@ -759,12 +752,12 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
759
752
  Object.assign(verbs, createLessonVerbs(core));
760
753
 
761
754
  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, ...LESSON_WRITE_VERBS]);
762
- 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, ...LESSON_READ_VERBS]);
763
755
 
764
756
  async function run(verb, args = {}) {
765
757
  if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
766
758
  if (!args || typeof args !== 'object' || Array.isArray(args)) refuse('arguments must be an object');
767
759
  for (const key of AUTHORITY_FIELDS) if (key in args) refuse(`the request cannot supply ${key}; the server stamps the writer and session`);
760
+ allow(role, verb, args, { readVerbs: READ_VERBS, from: roleFrom });
768
761
  if (READ_VERBS.includes(verb)) return verbs[verb](args);
769
762
  const requestId = text(args.request_id, 'request_id', { max: 200 });
770
763
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(requestId)) refuse('request_id may hold letters, digits, dots, colons, underscores and hyphens');
package/work/mcp.mjs CHANGED
@@ -20,13 +20,14 @@ import { existsSync, realpathSync, readFileSync, writeFileSync, mkdirSync, statS
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { createEngramClient } from './lib/client.mjs';
22
22
  import { createActionStore } from './lib/action-store.mjs';
23
- import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
23
+ import { createVerbs, READ_VERBS, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
24
24
  import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
25
25
  import { STEP_IN_CLASSES, stepInFolder, flushStepIns } from './lib/step-ins.mjs';
26
26
  import { LESSON_HOMES, LESSON_FORMS, LESSON_SOURCES } from './lib/lessons.mjs';
27
27
  import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
28
28
  import { createMcpServer } from './lib/mcp-stdio.mjs';
29
29
  import { childEnvironment, CLOUD_NODE_VARIABLES } from './lib/child-env.mjs';
30
+ import { ROLES, roleOf, allow, whoCell } from './lib/roles.mjs';
30
31
  import { upgrade, proveCopy } from '../door/lib/install.mjs';
31
32
  export { childEnvironment };
32
33
 
@@ -41,44 +42,47 @@ const CODE_FINDING = { type: 'object', required: ['statement', 'path', 'line'],
41
42
  const LIMIT_FINDING = { type: 'object', required: ['statement', 'path', 'line'], properties: { ...CODE_FINDING.properties, kind: { type: 'string', enum: ['cap', 'flag', 'skipped', 'untested', 'other'], description: 'What kind of limit this is.' } } };
42
43
  const CODE_DIGEST = { type: 'object', description: 'One sub-agent digest for one repo. Every claim carries a path and a line, and every cited path must be in files_read.', required: ['repo', 'commit', 'files_read'], properties: { repo: S('Registered repo id the read ran in, for example Arjunkhera/Engram.'), commit: S('The commit the read ran against, 7 to 40 hex characters.'), capability: S('The capability the read was briefed with.'), read_by: S('Which sub-agent read it.'), files_read: { type: 'array', description: 'Every file the read opened. A never-read path is refused.', items: { type: 'string' } }, supports: { type: 'array', description: 'What the code supports today.', items: CODE_FINDING }, limits: { type: 'array', description: 'Caps, flags, skipped paths and untested paths.', items: LIMIT_FINDING }, unknowns: { type: 'array', description: 'What the read could not settle, and where it looked.', items: { type: 'string' } } } };
43
44
 
44
- export const TOOLS = [
45
- { name: 'registry_list', description: `List the registered products and their repos with registry ids and entity ids. When: before any item verb, to find the ids to pass. Scope: read, no owner flag needed. ${RULES}`, inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
46
- { name: 'setup', description: `Publish atlas-work ${MANIFEST_VERSION} to the tenant's Forge and register its types (product, repo with its kinds, container, decision, question, receipt, mark, entry and the surviving edges). Idempotent. When: once per tenant, before the first product add, and again after a manifest change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id'], properties: { request_id: REQUEST_ID }, additionalProperties: false } },
47
- { name: 'product_add', description: `Register a product in the name registry. Refuses when the id exists. When: a product gets its first work item. Owner present only. registry_id is a lowercase slug and is immutable. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'name'], properties: { request_id: REQUEST_ID, registry_id: S('Lowercase slug, for example "engram". Immutable.'), name: S('Display name.'), project_id: S('Entity id of the existing Engram project entity this product links to.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint. The done verb reads the deployed commit there, and every live check must be on this origin.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths on that origin the functional check of item_done may read, for example ["/v1/housekeeping"]. Unset, the check may read only the health endpoint\'s own path.' }, notes: S('Free text.') }, additionalProperties: false } },
48
- { name: 'product_edit', description: `Change a product's name, notes, health endpoint, check paths, design hub link or project link. Refuses an unknown id. The registry id never changes. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing product id.'), name: S('New display name.'), project_id: S('Entity id of an Engram project entity.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths the functional check of item_done may read on that origin.' }, design_hub_url: S('Absolute https URL of the one pinned "Design docs" hub page. Set it once, on any product; registry_list and design_index return it.'), notes: S('Free text.') }, additionalProperties: false } },
49
- { name: 'repo_add', description: `Register a GitHub repo under a product. The server reads GitHub itself: the id must equal the repo's full name and the owner must have push rights, or the add is refused. The default branch comes from GitHub. The kinds say what the repo is; item_done reads them to choose how a delivery is proved. Give kinds, a list; kind, one value, is the older form. Refuses an unknown product id and an existing repo id; repo ids are immutable. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'product'], properties: { request_id: REQUEST_ID, registry_id: S('GitHub "owner/repo", exact spelling.'), product: S('Existing product id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.') }, additionalProperties: false } },
50
- { name: 'repo_edit', description: `Change a repo's kinds or notes, or refresh its default branch from GitHub. registry_id and product never change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing repo id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.'), refresh_default_branch: { type: 'boolean', description: 'Re-read the default branch and push rights from GitHub.' } }, additionalProperties: false } },
51
- { name: 'item_propose', description: `Create a proposed work item (lifecycle proposed, stage capture) for a product, with its repos and the owner's words as origin. When: the owner names a piece of work that is not yet locked. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'title', 'product', 'repos', 'origin'], properties: { request_id: REQUEST_ID, title: S('Short title in the owner\'s words.'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}. Default standard.`), driver: S(`One of ${DRIVERS.join(', ')}. Default owner-led.`), next_step: S('One line: what happens next.'), summary: S('Short summary.'), body: S('Longer body, optional.'), tags: { type: 'array', items: { type: 'string' } } }, additionalProperties: false } },
52
- { name: 'item_lock', description: `Record the owner's approval of a short design: product and repos by registry id, the scope (the definition of done as text, and the design file), and the owner's words of approval as origin. That approval is the lock (target design, flow 4). The verb keeps a fingerprint of the text for the tools; nobody reads or writes a hash. Lifecycle becomes active; stage defaults to build. Refuses an unknown product or repo id, a missing scope or origin, and an item that is already locked; a change after approval is a decision_record on the same item. When: the owner approves the design. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos', 'scope', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Entity id of the work item (container).'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, scope: { type: 'object', required: ['text'], properties: { text: S('The definition of done, in full.'), design_doc: S('URL of the design file the owner approved.'), acceptance_criteria_hash: S('Refused: old records only. A new lock takes no hash; put the definition of done in text.') } }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}.`), stage: S(`One of ${STAGES.join(', ')}. Default build.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
53
- { name: 'item_split', description: `Split an item into pieces. Each child is a proposed item (stage capture) with a spun_out_of relation to the parent and the reason. Children inherit product, repos, tier, origin and driver unless given. Refuses an unknown parent, a missing reason, or a child repo outside the product. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'children'], properties: { request_id: REQUEST_ID, item_id: S('Parent item id.'), reason: S('Why the split, in the owner\'s words.'), children: { type: 'array', items: { type: 'object', required: ['title'], properties: { title: S('Child title.'), repos: { type: 'array', items: { type: 'string' } }, tier: S(`One of ${TIERS.join(', ')}.`), origin: ORIGIN, driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line.'), summary: S('Short summary.') } } } }, additionalProperties: false } },
54
- { name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog: the in_flight rows leave them out, and needs_me still shows their open questions. Refuses a missing reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it is parked.') }, additionalProperties: false } },
55
- { name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. It also undoes a drop: a dropped item goes back to the lifecycle it left, and the drop's words stay on it. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it resumes.') }, additionalProperties: false } },
56
- { name: 'item_drop', description: `Drop an item in the owner's words: lifecycle dropped, with the words, the date and the lifecycle it left. A dropped item stays in the graph, and no live view lists it. Refuses a done item (reopen it instead) and an item already dropped. When: the owner retires a piece of work. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), origin: { ...ORIGIN, description: "The owner's words that drop the item, verbatim, with where and when they were said." } }, additionalProperties: false } },
57
- { name: 'item_edit', description: `Edit a proposed, active or paused item: its next step, what it waits on, its free tags, its planned dates, or its title. Each edit keeps the old and new values in the item's log, which item_activity reads; the first title stays in first_title. When: the next step, the wait or a tag changes; the lead keeps them true at the end of each session. No owner flag for next_step, waits_on and tags; the tags that other verbs own (product:, repo:, tier:, item:, atlas-work, no-model, atlas-registry) are refused, in any case. A new title is the owner's words, and planned dates are the owner's promise, so both need the owner present. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), next_step: S('One line: what happens next.'), waits_on: { type: 'object', required: ['on'], description: 'What the item waits on. Shown on its rows.', properties: { on: S(`One of ${WAITS_ON.join(', ')}. To wait on another item, use item_block.`), note: S('What exactly it waits on, in plain words. Required unless on is nothing.') } }, title: S("A new title, in the owner's words. Owner present only."), tags_add: { type: 'array', items: { type: 'string' }, description: 'Free tags to add.' }, tags_remove: { type: 'array', items: { type: 'string' }, description: 'Free tags to remove.' }, plan: { type: 'object', description: 'Planned dates, set by the owner. Owner present only. Each is YYYY-MM-DD, or null to clear it. Dates not named stay as they are.', properties: { start: { type: ['string', 'null'], description: 'Planned start.' }, end: { type: ['string', 'null'], description: 'Planned end. Not before the start.' }, deadline: { type: ['string', 'null'], description: 'Deadline.' } }, additionalProperties: false }, reason: S('One line on why it changed.') }, additionalProperties: false } },
58
- { name: 'item_stage', description: `Move an active item to a stage (${STAGES.join(', ')}). When: work moves; the lead or the delivering agent calls it. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'stage'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), stage: S(`One of ${STAGES.join(', ')}.`), note: S('One line on what moved.') }, additionalProperties: false } },
59
- { name: 'item_block', description: `Record that an item is blocked by another item. The item keeps a list of its blockers, each with a note; the views show "blocked by" on its row and "blocks" on the blocker's row, and a blocker that is delivered or dropped shows as cleared. Refuses the item itself and a link that would close a loop. When: work cannot move until another item lands. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'blocked_by'], properties: { request_id: REQUEST_ID, item_id: S('The blocked item id.'), blocked_by: S('The item id that blocks it.'), note: S('One line: what exactly it needs from the blocker.') }, additionalProperties: false } },
60
- { name: 'item_unblock', description: `Remove one blocked-by link from an item. The removed link stays in the item's log. When: the blocker no longer holds the item back. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'blocked_by'], properties: { request_id: REQUEST_ID, item_id: S('The blocked item id.'), blocked_by: S('The blocker item id to remove.'), reason: S('One line on why.') }, additionalProperties: false } },
61
- { name: 'item_comment', description: `Add a comment to an item's log: a running note from the lead, or the owner's own words. A comment is never edited; to correct one, add a new comment that names it in corrects. The text goes through the secret filter. When: something about the item is worth keeping that no other verb records. No owner flag for the agent's voice; the owner's voice is his words, word for word, so it needs the owner present. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'text'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), text: S('The comment, at most 4000 characters.'), voice: S('agent (the default) or owner. owner means the text is the owner\'s own words. Owner present only.'), corrects: S('The entry id of an earlier comment on this item that this one corrects.') }, additionalProperties: false } },
62
- { name: 'item_activity', description: 'Answer "what happened on this item?". One list, newest first: the comments, each change an item verb made with its old and new values, the questions, the decisions, the delivery marks, the pieces and the links. A write that kept no values, such as one from before manifest 0.7.0, shows its verb and time with "no values recorded". The edits kept on the item before 0.7.0 show too. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['item_id'], properties: { item_id: S('Item id.'), limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many rows, newest first. Default 50.' } }, additionalProperties: false } },
63
- { name: 'step_in_name', description: `Name what one message of the person was, or link the lesson it led to (roadmap R15). The step-in hook counts each message the person sends, and the server writes each one as a step-in mark; this verb never adds, skips or deletes one. It writes a new mark that corrects the step-in, and the newest name stands. A message with no class is a step-in. The classes: ${STEP_IN_CLASSES.join(', ')}. The first eight are the classes of the owner-only list. "answer" needs question_id, a question that was open when the message came. When: the lead knows what the message was, or a lesson came out of it. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'step_in'], properties: { request_id: REQUEST_ID, step_in: S('The step-in mark id, from step_ins.'), class: { type: 'string', enum: [...STEP_IN_CLASSES], description: 'What the message was. Leave it out to keep it a step-in and only link a lesson.' }, question_id: S('For class answer: the question the message answered.'), item_id: S('The item the message was about. An answer takes the item of its question.'), lesson: S('The lesson the step-in led to: a URL, or the id of a tracker record.') }, additionalProperties: false } },
64
- { name: 'step_ins', description: 'List the step-in marks, newest first, with the name that stands on each: the time, the session, the repos, and the class and lesson the lead named. step_ins counts the messages with no class; not_counted_sessions counts the sessions whose hooks were off. When: before step_in_name, to find the id. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { session: S('Only this session.'), since: S('ISO date or time: only step-ins from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
65
- { name: 'lesson_record', description: `Record one lesson as a mark of kind lesson (roadmap R55): one line, its home, its form, the fix link, the source and the lesson it repeats. A mark is never edited. When: the learnings skill made the fix; call lessons first, and pass repeats when an earlier lesson says the same thing. The line goes through the secret filter. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'product', 'line', 'home', 'form', 'fix', 'source'], properties: { request_id: REQUEST_ID, product: S('Product id.'), line: S('The lesson in one plain sentence: what went wrong and what is right. At most 300 characters.'), home: { type: 'string', enum: [...LESSON_HOMES], description: 'repo: the lesson holds for one repo. atlas: it holds for every repo.' }, repo: S('For home repo: the registered repo id of this product.'), form: { type: 'string', enum: [...LESSON_FORMS], description: 'The form of the fix: a check, a tool, a skill step or a doc line.' }, fix: S('The fix: a pull request URL, or the id of the tracker item the lesson became.'), source: { type: 'string', enum: [...LESSON_SOURCES], description: 'What found the miss: owner (a correction), revert, bug (after a merge), or machine (no owner words).' }, source_ref: S('Where the miss shows: the step-in mark id, the revert pull request, or the bug issue.'), repeats: S('The id of an earlier lesson of this product that this lesson repeats.'), item_id: S('The work item the lesson is about.') }, additionalProperties: false } },
66
- { name: 'lessons', description: 'List the lessons of one product, newest first (roadmap R55). Each row gives the line, the home, the form, the fix, the source, the lesson it repeats and the lessons that repeat it. total counts the lessons; repeats counts the repeats. A failed read is an error, never zero lessons. When: the learnings skill calls it before lesson_record, to find a match. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), since: S('ISO date or time: only lessons from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
67
- { name: 'item_link', description: `Attach a typed link (kind, url, role, note) to an item: a PR, an issue, an artifact, a design doc, a conversation. When: any in-session agent has a link the item should carry, for example a PR it opened. No owner flag needed. A duplicate kind and url is a no-op. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'kind', 'url'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), kind: S(`One of ${LINK_KINDS.join(', ')}.`), url: S('Absolute http or https URL.'), role: S('What the link is to the item, for example "delivers", "design", "evidence".'), note: S('One line.') }, additionalProperties: false } },
68
- { name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. Any in-session agent may call it; no owner flag. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
69
- { name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
70
- { name: 'decision_record', description: `Record what the owner decided outside a question, in their own words. Refuses missing text and a missing date. Pass rendered to keep the view the owner acted on as a receipt in the same call. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'date'], properties: { request_id: REQUEST_ID, text: S("The owner's words, verbatim."), date: S('ISO date the owner said it.'), source: S('Where the words are on record.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default decision.`), product: S('Product id the decision is about.'), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on.'), view: S('Which view the owner read.') }, additionalProperties: false } },
71
- { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. One exception, a last resort for an extraordinary case with no other option: the owner can accept a known red test on one merged pull request. Name it in accepted_red with the decision that records the owner's words; those words must name the pull request and quote the exact test, and one decision accepts one red check. The owner must also sign a waiver act for the repo, the pull request, its head, the decision and the check (atlas approve act waiver) and post the block on the pull request; the key must be in atlas/owner-signers on the base of the merge. accepted_red needs the owner present. The gate names a check, not a test, so the verb proves the check level, and every other red check still refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. When atlas.yaml on the base of a merge lists required_checks, each one needs a successful newest GitHub Actions run on the pull request head or on the merge commit; a skipped check and a check that never ran are named apart. When atlas.yaml says live: release, a published release must hold each merge commit, and the mark stores that version; a red run on the merge commit is then a warning on the mark, because the release was built from a later green main. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.'), accepted_red: { type: 'array', description: 'Red checks the owner accepted, one entry for each pull request and check. Owner present only. The decision must be a decision or an answer on this item, written by decision_record or question_answer and not replaced. Its words must name the pull request and quote the test. One decision accepts one check on one pull request. A listed check that is not red is refused.', items: { type: 'object', required: ['pr', 'check', 'test', 'decision_id'], additionalProperties: false, properties: { pr: S('One of the prs of this delivery.'), check: S('The name of the red check run in the merge gate, for example "test".'), test: S('The exact name of the red test, as the owner\'s words quote it.'), decision_id: S('The decision that records the owner\'s acceptance.') } } } }, additionalProperties: false } },
45
+ const TOOL_LIST = [
46
+ { name: 'registry_list', description: `List the registered products and their repos with registry ids and entity ids. When: before any item verb, to find the ids to pass. Scope: read, every role. ${RULES}`, inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
47
+ { name: 'setup', description: `Publish atlas-work ${MANIFEST_VERSION} to the tenant's Forge and register its types (product, repo with its kinds, container, decision, question, receipt, mark, entry and the surviving edges). Idempotent. When: once per tenant, before the first product add, and again after a manifest change. ${RULES}`, inputSchema: { type: 'object', required: ['request_id'], properties: { request_id: REQUEST_ID }, additionalProperties: false } },
48
+ { name: 'product_add', description: `Register a product in the name registry. Refuses when the id exists. When: a product gets its first work item. registry_id is a lowercase slug and is immutable. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'name'], properties: { request_id: REQUEST_ID, registry_id: S('Lowercase slug, for example "engram". Immutable.'), name: S('Display name.'), project_id: S('Entity id of the existing Engram project entity this product links to.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint. The done verb reads the deployed commit there, and every live check must be on this origin.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths on that origin the functional check of item_done may read, for example ["/v1/housekeeping"]. Unset, the check may read only the health endpoint\'s own path.' }, notes: S('Free text.') }, additionalProperties: false } },
49
+ { name: 'product_edit', description: `Change a product's name, notes, health endpoint, check paths, design hub link or project link. Refuses an unknown id. The registry id never changes. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing product id.'), name: S('New display name.'), project_id: S('Entity id of an Engram project entity.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths the functional check of item_done may read on that origin.' }, design_hub_url: S('Absolute https URL of the one pinned "Design docs" hub page. Set it once, on any product; registry_list and design_index return it.'), notes: S('Free text.') }, additionalProperties: false } },
50
+ { name: 'repo_add', description: `Register a GitHub repo under a product. The server reads GitHub itself: the id must equal the repo's full name and the owner must have push rights, or the add is refused. The default branch comes from GitHub. The kinds say what the repo is; item_done reads them to choose how a delivery is proved. Give kinds, a list; kind, one value, is the older form. Refuses an unknown product id and an existing repo id; repo ids are immutable. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'product'], properties: { request_id: REQUEST_ID, registry_id: S('GitHub "owner/repo", exact spelling.'), product: S('Existing product id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.') }, additionalProperties: false } },
51
+ { name: 'repo_edit', description: `Change a repo's kinds or notes, or refresh its default branch from GitHub. registry_id and product never change. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing repo id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.'), refresh_default_branch: { type: 'boolean', description: 'Re-read the default branch and push rights from GitHub.' } }, additionalProperties: false } },
52
+ { name: 'item_propose', description: `Create a proposed work item (lifecycle proposed, stage capture) for a product, with its repos and the owner's words as origin. When: the owner names a piece of work that is not yet locked. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'title', 'product', 'repos', 'origin'], properties: { request_id: REQUEST_ID, title: S('Short title in the owner\'s words.'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}. Default standard.`), driver: S(`One of ${DRIVERS.join(', ')}. Default owner-led.`), next_step: S('One line: what happens next.'), summary: S('Short summary.'), body: S('Longer body, optional.'), tags: { type: 'array', items: { type: 'string' } } }, additionalProperties: false } },
53
+ { name: 'item_lock', description: `Record the owner's approval of a short design: product and repos by registry id, the scope (the definition of done as text, and the design file), and the owner's words of approval as origin. That approval is the lock (target design, flow 4). The verb keeps a fingerprint of the text for the tools; nobody reads or writes a hash. Lifecycle becomes active; stage defaults to build. Refuses an unknown product or repo id, a missing scope or origin, and an item that is already locked; a change after approval is a decision_record on the same item. When: the owner approves the design. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos', 'scope', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Entity id of the work item (container).'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, scope: { type: 'object', required: ['text'], properties: { text: S('The definition of done, in full.'), design_doc: S('URL of the design file the owner approved.'), acceptance_criteria_hash: S('Refused: old records only. A new lock takes no hash; put the definition of done in text.') } }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}.`), stage: S(`One of ${STAGES.join(', ')}. Default build.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
54
+ { name: 'item_split', description: `Split an item into pieces. Each child is a proposed item (stage capture) with a spun_out_of relation to the parent and the reason. Children inherit product, repos, tier, origin and driver unless given. Refuses an unknown parent, a missing reason, or a child repo outside the product. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'children'], properties: { request_id: REQUEST_ID, item_id: S('Parent item id.'), reason: S('Why the split, in the owner\'s words.'), children: { type: 'array', items: { type: 'object', required: ['title'], properties: { title: S('Child title.'), repos: { type: 'array', items: { type: 'string' } }, tier: S(`One of ${TIERS.join(', ')}.`), origin: ORIGIN, driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line.'), summary: S('Short summary.') } } } }, additionalProperties: false } },
55
+ { name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog: the in_flight rows leave them out, and needs_me still shows their open questions. Refuses a missing reason. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it is parked.') }, additionalProperties: false } },
56
+ { name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. It also undoes a drop: a dropped item goes back to the lifecycle it left, and the drop's words stay on it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it resumes.') }, additionalProperties: false } },
57
+ { name: 'item_drop', description: `Drop an item in the owner's words: lifecycle dropped, with the words, the date and the lifecycle it left. A dropped item stays in the graph, and no live view lists it. Refuses a done item (reopen it instead) and an item already dropped. When: the owner retires a piece of work. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), origin: { ...ORIGIN, description: "The owner's words that drop the item, verbatim, with where and when they were said." } }, additionalProperties: false } },
58
+ { name: 'item_edit', description: `Edit a proposed, active or paused item: its next step, what it waits on, its free tags, its planned dates, or its title. Each edit keeps the old and new values in the item's log, which item_activity reads; the first title stays in first_title. When: the next step, the wait or a tag changes; the lead keeps them true at the end of each session. The tags that other verbs own (product:, repo:, tier:, item:, atlas-work, no-model, atlas-registry) are refused. A new title is the owner's words, and planned dates are the owner's promise. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), next_step: S('One line: what happens next.'), waits_on: { type: 'object', required: ['on'], description: 'What the item waits on. Shown on its rows.', properties: { on: S(`One of ${WAITS_ON.join(', ')}. To wait on another item, use item_block.`), note: S('What exactly it waits on, in plain words. Required unless on is nothing.') } }, title: S("A new title, in the owner's words."), tags_add: { type: 'array', items: { type: 'string' }, description: 'Free tags to add.' }, tags_remove: { type: 'array', items: { type: 'string' }, description: 'Free tags to remove.' }, plan: { type: 'object', description: 'Planned dates, set by the owner. Each is YYYY-MM-DD, or null to clear it. Dates not named stay as they are.', properties: { start: { type: ['string', 'null'], description: 'Planned start.' }, end: { type: ['string', 'null'], description: 'Planned end. Not before the start.' }, deadline: { type: ['string', 'null'], description: 'Deadline.' } }, additionalProperties: false }, reason: S('One line on why it changed.') }, additionalProperties: false } },
59
+ { name: 'item_stage', description: `Move an active item to a stage (${STAGES.join(', ')}). When: work moves; the lead or the delivering agent calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'stage'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), stage: S(`One of ${STAGES.join(', ')}.`), note: S('One line on what moved.') }, additionalProperties: false } },
60
+ { name: 'item_block', description: `Record that an item is blocked by another item. The item keeps a list of its blockers, each with a note; the views show "blocked by" on its row and "blocks" on the blocker's row, and a blocker that is delivered or dropped shows as cleared. Refuses the item itself and a link that would close a loop. When: work cannot move until another item lands. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'blocked_by'], properties: { request_id: REQUEST_ID, item_id: S('The blocked item id.'), blocked_by: S('The item id that blocks it.'), note: S('One line: what exactly it needs from the blocker.') }, additionalProperties: false } },
61
+ { name: 'item_unblock', description: `Remove one blocked-by link from an item. The removed link stays in the item's log. When: the blocker no longer holds the item back. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'blocked_by'], properties: { request_id: REQUEST_ID, item_id: S('The blocked item id.'), blocked_by: S('The blocker item id to remove.'), reason: S('One line on why.') }, additionalProperties: false } },
62
+ { name: 'item_comment', description: `Add a comment to an item's log: a running note from the lead, or the owner's own words. A comment is never edited; to correct one, add a new comment that names it in corrects. The text goes through the secret filter. When: something about the item is worth keeping that no other verb records. The owner's voice is his words, word for word. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'text'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), text: S('The comment, at most 4000 characters.'), voice: S('agent (the default) or owner. owner means the text is the owner\'s own words.'), corrects: S('The entry id of an earlier comment on this item that this one corrects.') }, additionalProperties: false } },
63
+ { name: 'item_activity', description: 'Answer "what happened on this item?". One list, newest first: the comments, each change an item verb made with its old and new values, the questions, the decisions, the delivery marks, the pieces and the links. A write that kept no values, such as one from before manifest 0.7.0, shows its verb and time with "no values recorded". The edits kept on the item before 0.7.0 show too. Scope: read, every role.', inputSchema: { type: 'object', required: ['item_id'], properties: { item_id: S('Item id.'), limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many rows, newest first. Default 50.' } }, additionalProperties: false } },
64
+ { name: 'step_in_name', description: `Name what one message of the person was, or link the lesson it led to (roadmap R15). The step-in hook counts each message the person sends, and the server writes each one as a step-in mark; this verb never adds, skips or deletes one. It writes a new mark that corrects the step-in, and the newest name stands. A message with no class is a step-in. The classes: ${STEP_IN_CLASSES.join(', ')}. The first eight are the classes of the owner-only list. "answer" needs question_id, a question that was open when the message came. When: the lead knows what the message was, or a lesson came out of it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'step_in'], properties: { request_id: REQUEST_ID, step_in: S('The step-in mark id, from step_ins.'), class: { type: 'string', enum: [...STEP_IN_CLASSES], description: 'What the message was. Leave it out to keep it a step-in and only link a lesson.' }, question_id: S('For class answer: the question the message answered.'), item_id: S('The item the message was about. An answer takes the item of its question.'), lesson: S('The lesson the step-in led to: a URL, or the id of a tracker record.') }, additionalProperties: false } },
65
+ { name: 'step_ins', description: 'List the step-in marks, newest first, with the name that stands on each: the time, the session, the repos, and the class and lesson the lead named. step_ins counts the messages with no class; not_counted_sessions counts the sessions whose hooks were off. When: before step_in_name, to find the id. Scope: read, every role.', inputSchema: { type: 'object', properties: { session: S('Only this session.'), since: S('ISO date or time: only step-ins from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
66
+ { name: 'lesson_record', description: `Record one lesson as a mark of kind lesson (roadmap R55): one line, its home, its form, the fix link, the source and the lesson it repeats. A mark is never edited. When: the learnings skill made the fix; call lessons first, and pass repeats when an earlier lesson says the same thing. The line goes through the secret filter. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'product', 'line', 'home', 'form', 'fix', 'source'], properties: { request_id: REQUEST_ID, product: S('Product id.'), line: S('The lesson in one plain sentence: what went wrong and what is right. At most 300 characters.'), home: { type: 'string', enum: [...LESSON_HOMES], description: 'repo: the lesson holds for one repo. atlas: it holds for every repo.' }, repo: S('For home repo: the registered repo id of this product.'), form: { type: 'string', enum: [...LESSON_FORMS], description: 'The form of the fix: a check, a tool, a skill step or a doc line.' }, fix: S('The fix: a pull request URL, or the id of the tracker item the lesson became.'), source: { type: 'string', enum: [...LESSON_SOURCES], description: 'What found the miss: owner (a correction), revert, bug (after a merge), or machine (no owner words).' }, source_ref: S('Where the miss shows: the step-in mark id, the revert pull request, or the bug issue.'), repeats: S('The id of an earlier lesson of this product that this lesson repeats.'), item_id: S('The work item the lesson is about.') }, additionalProperties: false } },
67
+ { name: 'lessons', description: 'List the lessons of one product, newest first (roadmap R55). Each row gives the line, the home, the form, the fix, the source, the lesson it repeats and the lessons that repeat it. total counts the lessons; repeats counts the repeats. A failed read is an error, never zero lessons. When: the learnings skill calls it before lesson_record, to find a match. Scope: read, every role.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), since: S('ISO date or time: only lessons from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
68
+ { name: 'item_link', description: `Attach a typed link (kind, url, role, note) to an item: a PR, an issue, an artifact, a design doc, a conversation. When: any in-session agent has a link the item should carry, for example a PR it opened. A duplicate kind and url is a no-op. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'kind', 'url'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), kind: S(`One of ${LINK_KINDS.join(', ')}.`), url: S('Absolute http or https URL.'), role: S('What the link is to the item, for example "delivers", "design", "evidence".'), note: S('One line.') }, additionalProperties: false } },
69
+ { name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
70
+ { name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
71
+ { name: 'decision_record', description: `Record what the owner decided outside a question, in their own words. Refuses missing text and a missing date. Pass rendered to keep the view the owner acted on as a receipt in the same call. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'date'], properties: { request_id: REQUEST_ID, text: S("The owner's words, verbatim."), date: S('ISO date the owner said it.'), source: S('Where the words are on record.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default decision.`), product: S('Product id the decision is about.'), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on.'), view: S('Which view the owner read.') }, additionalProperties: false } },
72
+ { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. One exception, a last resort for an extraordinary case with no other option: the owner can accept a known red test on one merged pull request. Name it in accepted_red with the decision that records the owner's words; those words must name the pull request and quote the exact test, and one decision accepts one red check. The owner must also sign a waiver act for the repo, the pull request, its head, the decision and the check (atlas approve act waiver) and post the block on the pull request; the key must be in atlas/owner-signers on the base of the merge. The gate names a check, not a test, so the verb proves the check level, and every other red check still refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. When atlas.yaml on the base of a merge lists required_checks, each one needs a successful newest GitHub Actions run on the pull request head or on the merge commit; a skipped check and a check that never ran are named apart. When atlas.yaml says live: release, a published release must hold each merge commit, and the mark stores that version; a red run on the merge commit is then a warning on the mark, because the release was built from a later green main. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.'), accepted_red: { type: 'array', description: 'Red checks the owner accepted, one entry for each pull request and check. The decision must be a decision or an answer on this item, written by decision_record or question_answer and not replaced. Its words must name the pull request and quote the test. One decision accepts one check on one pull request. A listed check that is not red is refused.', items: { type: 'object', required: ['pr', 'check', 'test', 'decision_id'], additionalProperties: false, properties: { pr: S('One of the prs of this delivery.'), check: S('The name of the red check run in the merge gate, for example "test".'), test: S('The exact name of the red test, as the owner\'s words quote it.'), decision_id: S('The decision that records the owner\'s acceptance.') } } } }, additionalProperties: false } },
72
73
  { name: 'item_reopen', description: `Reopen a delivered item: the lifecycle goes back to active and a reopen mark is appended, citing the failed check by URL. The delivery mark is never erased; the reopen points at it with the corrects relation. The owner, or the lead in this session, calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'failed_check'], properties: { request_id: REQUEST_ID, item_id: S('Item id carrying at least one delivery mark.'), reason: S('Why it is reopened, in plain words.'), failed_check: S('Absolute URL of the check that failed: a CI run, a monitor, an issue.'), corrects: S('Mark id of the delivery this reopen corrects. Default: the item\'s latest delivery mark.') }, additionalProperties: false } },
73
- { name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
74
- { name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, no owner flag needed. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
75
- { name: 'roundup', description: 'Answer "where does this product stand, and where is it going?" in five parts: 1 needs you (the needs_me read); 2 where we stand (items tagged stage-<n> in ladder order, a count of every item by lifecycle, and the active items); 3 what moved in the window (delivery marks, merged pull requests, new items); 4 stuck or drifting (active items with no change for 14 days, merged but not marked, open pull requests that name no item, questions waiting 14 days); 5 the trend per week for four weeks; 6 the numbers per repo, per week and per month (merges, merges not on green, reverts, reopens, unchecked test changes, step-ins, owner and platform messages) and the time from input to live of each delivered item, with the median. Part 6 reads every pull request of its period from GitHub; a count GitHub did not give reads refused, never 0. Every row cites an item, a question or a pull request. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), days: { type: 'integer', minimum: 1, maximum: 90, description: 'The window for part 3, in days. Default 7.' }, from: S('Part 6: the first UTC day, YYYY-MM-DD. Default: 28 days before the read.'), to: S('Part 6: the last UTC day, YYYY-MM-DD, counted. Default: the read. At most 92 days in all.') }, additionalProperties: false } },
76
- { name: 'code_read_brief', description: 'The brief for the sub-agents that read the code for "where are we with X", one per registered repo of the product. It carries the never-read and never-quote rules, the citation contract and the JSON shape of the digest. Give one brief to one sub-agent; sub-agents never receive this server or the Engram MCP. Pass each digest back in where_are_we.code. When: before answering "where are we with X". Scope: read, no owner flag needed; it opens no repo and writes nothing.', inputSchema: { type: 'object', required: ['product', 'capability'], properties: { product: S('Product registry id.'), capability: S('The capability to read for, the X of the question.'), repos: { type: 'array', description: 'Limit the briefs to these registered repo ids. Default: every repo of the product.', items: { type: 'string' } }, roots: { type: 'object', description: 'Repo id to the local checkout path the sub-agent reads.', additionalProperties: { type: 'string' } }, commits: { type: 'object', description: 'Repo id to the commit the read runs against.', additionalProperties: { type: 'string' } }, focus: { type: 'array', description: 'Extra things the read should look for.', items: { type: 'string' } } }, additionalProperties: false } },
77
- { name: 'where_are_we', description: 'Answer "where are we with X" in six parts: live support and known limits from the code read at question time, then in progress, delivered and proposed from the items, then the current priority decision. Every row carries a citation: a path and a line, an entity id, a pull request, or a decision date. The code half comes from the sub-agent digests, which are refused when they cite a never-read path or quote a never-quote path, and every string is scrubbed by pattern. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product', 'capability', 'code'], properties: { product: S('Product registry id.'), capability: S('The capability the question is about, the X.'), code: { type: 'array', description: 'One digest per repo, from the sub-agents briefed by code_read_brief. At least one is required: an answer with no code read says nothing about what the code does today.', items: CODE_DIGEST }, items: { type: 'array', description: 'Item ids the lead judges to answer this question.', items: { type: 'string' } }, match: { type: 'array', description: 'Terms to match against item titles, summaries and tags. Give items, match, or both.', items: { type: 'string' } }, movement_repos: { type: 'array', description: "Narrow the movement read to these registered repo ids. Default: every repo of the product. The digests' own repos are always read.", items: { type: 'string' } }, since: S('ISO date. Adds what changed since then.') }, additionalProperties: false } },
78
- { name: 'built_pending_possible', description: 'Answer "what are we building, what is built, what is pending and what can be done": delivered items by date, active items, paused items with their reason, proposed items with their age, and the decisions that set the order. Item records, delivery marks and decisions only; no code read and no GitHub read. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Limits built and the decisions to what came after it.') }, additionalProperties: false } },
79
- { name: 'design_index', description: 'List every design: each work item with a design-doc link, in any lifecycle, newest change first, with its product, its state for the hub ("Waiting on the owner", "Building", "Paused", "Shipped", "Dropped"), its design docs and its pages. Also returns design_hub_url, the one hub page. When: before the lead rebuilds the "Design docs" hub, after each design publish, lock and done. Item records only; computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the list to one product id.') }, additionalProperties: false } },
80
- { name: 'atlas_upgrade', description: 'Move Atlas on this Mac to the newest release: the same steps as the "atlas upgrade --yes" command. It checks that a master release run built the release, installs it and updates the plugin. When: the owner asks to upgrade Atlas. Use this tool and not the command: the Bash sandbox blocks the writes to ~/.npm, ~/.atlas and ~/.claude/plugins, and this server runs outside it. With check true it writes no Atlas state: it reports the loaded and the newest version, and checks that the loaded files are the files of their master release commit (provenance: match, differs, unproven or not checked). The check runs from the copy it checks, so it finds stale or drifted files, not a package made to lie. The check runs anywhere, also in a Claude cloud session and with the owner flag off; the lead calls it at the first reply of a cloud session. Scope: this Mac, no tracker write. The upgrade itself is owner present only, and never in a Claude cloud session: a new cloud session installs the newest release.', inputSchema: { type: 'object', properties: { check: { type: 'boolean', description: 'True: report the versions and the provenance of the loaded copy, and install nothing.' } }, additionalProperties: false } },
74
+ { name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". 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. Adds what was delivered since then.') }, additionalProperties: false } },
75
+ { name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, every role. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
76
+ { name: 'roundup', description: 'Answer "where does this product stand, and where is it going?" in five parts: 1 needs you (the needs_me read); 2 where we stand (items tagged stage-<n> in ladder order, a count of every item by lifecycle, and the active items); 3 what moved in the window (delivery marks, merged pull requests, new items); 4 stuck or drifting (active items with no change for 14 days, merged but not marked, open pull requests that name no item, questions waiting 14 days); 5 the trend per week for four weeks; 6 the numbers per repo, per week and per month (merges, merges not on green, reverts, reopens, unchecked test changes, step-ins, owner and platform messages) and the time from input to live of each delivered item, with the median. Part 6 reads every pull request of its period from GitHub; a count GitHub did not give reads refused, never 0. Every row cites an item, a question or a pull request. Computed at read time and never stored. Scope: read, every role.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), days: { type: 'integer', minimum: 1, maximum: 90, description: 'The window for part 3, in days. Default 7.' }, from: S('Part 6: the first UTC day, YYYY-MM-DD. Default: 28 days before the read.'), to: S('Part 6: the last UTC day, YYYY-MM-DD, counted. Default: the read. At most 92 days in all.') }, additionalProperties: false } },
77
+ { name: 'code_read_brief', description: 'The brief for the sub-agents that read the code for "where are we with X", one per registered repo of the product. It carries the never-read and never-quote rules, the citation contract and the JSON shape of the digest. Give one brief to one crew. The crew holds no MCP tool, because its tools line names none; the server cannot tell a sub-agent from the lead in one session (R20). Pass each digest back in where_are_we.code. When: before answering "where are we with X". Scope: read, every role; it opens no repo and writes nothing.', inputSchema: { type: 'object', required: ['product', 'capability'], properties: { product: S('Product registry id.'), capability: S('The capability to read for, the X of the question.'), repos: { type: 'array', description: 'Limit the briefs to these registered repo ids. Default: every repo of the product.', items: { type: 'string' } }, roots: { type: 'object', description: 'Repo id to the local checkout path the sub-agent reads.', additionalProperties: { type: 'string' } }, commits: { type: 'object', description: 'Repo id to the commit the read runs against.', additionalProperties: { type: 'string' } }, focus: { type: 'array', description: 'Extra things the read should look for.', items: { type: 'string' } } }, additionalProperties: false } },
78
+ { 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 } },
79
+ { 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 } },
80
+ { 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 } },
81
+ { name: 'atlas_upgrade', description: 'Move Atlas on this Mac to the newest release: the same steps as the "atlas upgrade --yes" command. It checks that a master release run built the release, installs it and updates the plugin. When: the owner asks to upgrade Atlas. Use this tool and not the command: the Bash sandbox blocks the writes to ~/.npm, ~/.atlas and ~/.claude/plugins, and this server runs outside it. With check true it writes no Atlas state: it reports the loaded and the newest version, and checks that the loaded files are the files of their master release commit (provenance: match, differs, unproven or not checked). The check runs from the copy it checks, so it finds stale or drifted files, not a package made to lie. The check runs anywhere and in every role, also in a Claude cloud session; the lead calls it at the first reply of a cloud session. Scope: this Mac, no tracker write. The upgrade itself never runs in a Claude cloud session: a new cloud session installs the newest release.', inputSchema: { type: 'object', properties: { check: { type: 'boolean', description: 'True: report the versions and the provenance of the loaded copy, and install nothing.' } }, additionalProperties: false } },
81
82
  ];
83
+ // Roadmap R20: each description ends with who may call the tool, read from
84
+ // the role table, so the text and the guard cannot differ.
85
+ export const TOOLS = TOOL_LIST.map((tool) => ({ ...tool, description: `${tool.description} Who: ${whoCell(tool.name, { readVerbs: READ_VERBS })}.` }));
82
86
 
83
87
  // The Engram connection: the address and the key, in one setting, separated by
84
88
  // white space: "https://<host> <key>". Changing the address means entering the
@@ -146,7 +150,7 @@ export function readConnection(env, { allowLoopbackHttp = false, now = Date.now(
146
150
  const day = new Date(claims.exp * 1000).toISOString().slice(0, 10);
147
151
  return { state: 'expired', message: `The Engram key expired on ${day}. Put a new one in ~/.config/atlas/engram-connection.`, expired: day };
148
152
  }
149
- return { state: 'ok', url: url.origin, token, tokenId: claims.jti, expires: new Date(claims.exp * 1000).toISOString().slice(0, 10) };
153
+ return { state: 'ok', url: url.origin, token, tokenId: claims.jti, keyType: claims.principal_type, expires: new Date(claims.exp * 1000).toISOString().slice(0, 10) };
150
154
  }
151
155
 
152
156
  // Node reads these from the environment it inherits, before any code of ours
@@ -280,11 +284,13 @@ function childExec(env) {
280
284
  return (command, args) => execFileSync(command, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 600_000, stdio: ['pipe', 'pipe', 'pipe'], env: childEnvironment(env) });
281
285
  }
282
286
  const PROOFS = new WeakMap();
283
- export async function runUpgrade({ args = {}, env = process.env, ownerPresent, packageRoot = PACKAGE_ROOT, exec = childExec(env), upgradeFn = upgrade, proveFn = proveCopy } = {}) {
287
+ export async function runUpgrade({ args = {}, env = process.env, role = 'crew', roleFrom, packageRoot = PACKAGE_ROOT, exec = childExec(env), upgradeFn = upgrade, proveFn = proveCopy } = {}) {
284
288
  const refuse = (message) => { const error = new Error(message); error.name = 'VerbError'; throw error; };
285
289
  const loaded = ATLAS_VERSION_OF(packageRoot);
286
- // The check reads only, so it runs in a cloud session and with the owner
287
- // flag off (item 92cfbba3): a cloud session states its lag at its first reply.
290
+ // The check reads only, so it runs in a cloud session and in every role
291
+ // (item 92cfbba3): a cloud session states its lag at its first reply. The
292
+ // role table says so (roles.mjs, R20).
293
+ try { allow(role, 'atlas_upgrade', args, { from: roleFrom }); } catch (error) { refuse(error.message); }
288
294
  if (args.check) {
289
295
  let newest;
290
296
  try { newest = exec('npm', ['view', '@arjunkhera/atlas', 'version']).trim(); } catch (error) { refuse(`npm could not read the newest version: ${String(error.stderr || error.message).trim().split('\n').slice(0, 3).join(' ')}`); }
@@ -295,7 +301,6 @@ export async function runUpgrade({ args = {}, env = process.env, ownerPresent, p
295
301
  if (!proofs.has(packageRoot)) proofs.set(packageRoot, proveFn({ packageRoot, exec }));
296
302
  return { loaded, newest, behind: newest !== loaded, provenance: proofs.get(packageRoot) };
297
303
  }
298
- if (!ownerPresent) refuse('atlas_upgrade needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)');
299
304
  if (inClaudeCloud(env)) refuse('atlas_upgrade does not run in a Claude cloud session. A new cloud session installs the newest release.');
300
305
  const lines = [];
301
306
  let code;
@@ -310,22 +315,31 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
310
315
  if (hostile.length) throw new Error(`refusing to start: ${hostile.join(', ')} ${hostile.length === 1 ? 'is' : 'are'} set, and could send the Engram key elsewhere or turn certificate checks off. Unset ${hostile.length === 1 ? 'it' : 'them'}.`);
311
316
  const connection = readConnection(env, { allowLoopbackHttp });
312
317
  const session = (unset(env.ATLAS_WORK_SESSION) ? '' : env.ATLAS_WORK_SESSION.trim()) || `atlas-work:${randomUUID()}`;
313
- const ownerPresent = env.ATLAS_WORK_OWNER_PRESENT === '1';
318
+ // Roadmap R20: the role comes from the key type and the launcher's
319
+ // marker, which can only narrow it. No fixed flag gives a role:
320
+ // ATLAS_WORK_OWNER_PRESENT is read by nothing.
321
+ const { role, from: roleFrom } = roleOf({ keyType: connection.keyType, marker: env.ATLAS_WORK_ROLE ?? '' });
322
+ // The upgrade reads no Engram: an old or missing key must not block a new
323
+ // release. So it takes the role a user key would give, with the same marker
324
+ // (security review of R20).
325
+ const upgradeRole = connection.keyType ? { role, from: roleFrom } : roleOf({ keyType: 'user', marker: env.ATLAS_WORK_ROLE ?? '' });
314
326
  let bound = null;
315
327
  const store = createActionStore({ directory: journalDirectory(env) });
316
- const agent = unset(env.ATLAS_WORK_AGENT) ? 'lead' : env.ATLAS_WORK_AGENT.trim() || 'lead';
328
+ // The agent label is a name. It may not name another role (security review of R20).
329
+ const named = unset(env.ATLAS_WORK_AGENT) ? '' : env.ATLAS_WORK_AGENT.trim();
330
+ const agent = !named || (ROLES.includes(named) && named !== role) ? role : named;
317
331
  const { url: engramUrl, token: key } = connection;
318
332
  const client = connection.state === 'ok' ? createEngramClient({ baseUrl: engramUrl, token: key }) : null;
319
333
  const live = readLive ?? (connection.state === 'ok' ? liveReader({ engramUrl, token: key }) : null);
320
- const verbsFor = (boundClient) => createVerbs({ client, store, writer: { token_id: connection.tokenId }, session, agent, boundClient, ownerPresent, readGitHub: readGitHub ?? githubReader(), readLive: live });
334
+ const verbsFor = (boundClient) => createVerbs({ client, store, writer: { token_id: connection.tokenId }, session, agent, role, roleFrom, boundClient, readGitHub: readGitHub ?? githubReader(), readLive: live });
321
335
  let verbs = connection.state === 'ok' ? verbsFor(null) : null;
322
- 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, owner_present: ownerPresent, gh: findOnPath('gh', env), engram: connection.state === 'ok' ? 'not checked yet' : connection.message };
336
+ 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 };
323
337
  writeHealth(env, health);
324
338
  if (client) {
325
339
  client.getPublisher().then(() => { health.engram = 'answered'; }, (error) => { health.engram = `did not answer: ${error.message}`; }).finally(() => writeHealth(env, health));
326
340
  }
327
341
  const instructions = connection.state === 'ok'
328
- ? `Atlas work verbs for the owner's session. Session ${session}; owner present: ${ownerPresent ? 'yes' : 'no: the owner-only tools 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. Sub-agents must not receive this server.`
342
+ ? `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.`
329
343
  : `Atlas work verbs. They cannot reach Engram yet: ${connection.message}`;
330
344
  // Roadmap R15: the step-in rows of the hook become marks at the server's
331
345
  // start and before each write verb. A failed flush leaves the rows for the
@@ -349,14 +363,14 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
349
363
  ]) : Promise.resolve();
350
364
  const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions, tools: TOOLS, input, output, log, call: async (name, args, boundTo) => {
351
365
  // The upgrade needs no Engram connection: an old key must not block a new release.
352
- if (name === 'atlas_upgrade') return runUpgrade({ args, env, ownerPresent });
366
+ if (name === 'atlas_upgrade') return runUpgrade({ args, env, role: upgradeRole.role, roleFrom: upgradeRole.from });
353
367
  if (!verbs) throw new Error(connection.message);
354
368
  // The writer names the one client this server is bound to (D6 R3): stamped by the server, never by the caller.
355
369
  if (boundTo && boundTo.session !== bound) { bound = boundTo.session; verbs = verbsFor(bound); }
356
370
  if (!verbs.readVerbs.includes(name)) await flush();
357
371
  return verbs.run(name, args);
358
372
  } });
359
- return { server, session, connection, health, verbs: () => verbs, stepIns: { flush, started } };
373
+ return { server, session, role, connection, health, verbs: () => verbs, stepIns: { flush, started } };
360
374
  }
361
375
 
362
376
  // Real paths on both sides: macOS reaches /var through /private/var, and a