@arjunkhera/atlas 0.4.37 → 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/door/kinds-of-change.yaml +188 -0
- package/door/lib/brake.mjs +42 -7
- package/door/lib/gate-check.mjs +33 -11
- package/door/lib/gate.mjs +15 -1
- package/door/lib/kinds.mjs +128 -0
- package/door/lib/owner-only.mjs +5 -3
- 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/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');
|