@arjunkhera/atlas 0.4.38 → 0.4.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +0 -1
- package/package.json +1 -1
- package/skills/sdlc-task/SKILL.md +8 -5
- package/work/lib/activity.mjs +1 -2
- package/work/lib/capability.mjs +1 -1
- package/work/lib/delivery.mjs +10 -12
- package/work/lib/questions.mjs +1 -3
- package/work/lib/roles.mjs +135 -0
- package/work/lib/verbs.mjs +16 -23
- package/work/mcp.mjs +63 -49
package/.mcp.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
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.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
package/work/lib/activity.mjs
CHANGED
|
@@ -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,
|
|
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);
|
package/work/lib/capability.mjs
CHANGED
|
@@ -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
|
|
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
|
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
|
436
|
-
// decision_record needs
|
|
437
|
-
// could not write itself (security review
|
|
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.
|
|
534
|
-
//
|
|
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
|
|
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 });
|
package/work/lib/questions.mjs
CHANGED
|
@@ -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,
|
|
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
|
+
}
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
582
|
-
//
|
|
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
|
|
657
|
-
//
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
287
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|