@arjunkhera/atlas 0.3.9 → 0.3.11
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/package.json +1 -1
- package/skills/sdlc-task/SKILL.md +1 -0
- package/work/lib/capability.mjs +9 -3
- package/work/lib/delivery.mjs +13 -3
- package/work/lib/questions.mjs +36 -0
- package/work/lib/verbs.mjs +179 -24
- package/work/mcp.mjs +3 -1
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@ checks and the transition log; each verb's own description says what it needs.
|
|
|
33
33
|
| Drop it, in the owner's words (`item_resume` undoes a drop) | `item_drop` |
|
|
34
34
|
| Keep its next step, its wait or its title true | `item_edit` |
|
|
35
35
|
| Split it, or link it to other work | `item_split`, `item_link` |
|
|
36
|
+
| Mark that it waits on another item, or clear that | `item_block`, `item_unblock` |
|
|
36
37
|
| Ask the owner something, or record the answer | `question_ask`, `question_answer` |
|
|
37
38
|
| Record a decision in the owner's words | `decision_record` |
|
|
38
39
|
| Mark it delivered, or reopen it | `item_done`, `item_reopen` |
|
package/work/lib/capability.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// scrubs by pattern. The rendered answer is scrubbed once more before it
|
|
16
16
|
// leaves the door (R12).
|
|
17
17
|
import { refuse, text, isoDate, stringList } from './verb-fields.mjs';
|
|
18
|
-
import { oneLine, scrubSecrets, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
|
|
18
|
+
import { oneLine, scrubSecrets, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
|
|
19
19
|
import { listLine, legacySealOf, marksByItem, movementOf, pullsOfItem, readPullsByRepo, correctionsOf, daysBetween } from './delivery.mjs';
|
|
20
20
|
import { acceptCodeRead, codeReadBrief } from './code-read.mjs';
|
|
21
21
|
|
|
@@ -391,6 +391,9 @@ export function createCapabilityVerbs(core) {
|
|
|
391
391
|
const unregisteredTotal = rows.filter(unsorted).length;
|
|
392
392
|
const unregisteredShown = items.filter(unsorted).length;
|
|
393
393
|
const marks = await marksFor(new Set(items.map((row) => row.id)));
|
|
394
|
+
// Blocked-by links read from every item loaded, so a blocker of another
|
|
395
|
+
// product still names its state (work item additions, slice 2).
|
|
396
|
+
const itemById = new Map(rows.map((row) => [row.id, row]));
|
|
394
397
|
const readAt = now();
|
|
395
398
|
let redactions = 0;
|
|
396
399
|
const clean = (value, max = 200) => { const out = scrubSecrets(oneLine(value, max)); redactions += out.redactions; return out.text; };
|
|
@@ -437,7 +440,10 @@ export function createCapabilityVerbs(core) {
|
|
|
437
440
|
}
|
|
438
441
|
const doneAt = fields.delivered_at ?? item.updated_at ?? item.created_at ?? null;
|
|
439
442
|
if (fields.lifecycle === 'done' && !deliveries.length && !(since && String(doneAt ?? '') < since)) built.push({ ...base, mark_id: null, verdict: 'done, no delivery mark', delivered_at: doneAt, prs: [], source: 'no mark yet', note: 'the item record says done; the done verb writes the mark' });
|
|
440
|
-
const
|
|
443
|
+
const shown = waitsShown(fields);
|
|
444
|
+
const blockers = blockersOf(item, (id) => itemById.get(id));
|
|
445
|
+
const blocks = blocksOf(item, rows);
|
|
446
|
+
const waits = { ...(shown && typeof shown === 'object' ? { waits_on: shown } : {}), ...(blockers.length ? { blocked_by: blockers } : {}), ...(blocks.length ? { blocks } : {}) };
|
|
441
447
|
if (fields.lifecycle === 'active') building.push({ ...base, ...waits, stage: clean(fields.stage ?? '', 64), driver: clean(fields.driver ?? '', 64), next_step: clean(fields.next_step ?? '', 200), days_since_last_change: days, last_change_source: clean(last?.source ?? 'nothing recorded', 120), delivery_state: fields.delivery_state ? clean(fields.delivery_state, 64) : null });
|
|
442
448
|
else if (fields.lifecycle === 'paused') pending.push({ ...base, ...waits, reason: clean(fields.pause_reason ?? 'no reason on record', 300), days_since_last_change: days, parked_at: fields.parked_at ?? null });
|
|
443
449
|
else if (fields.lifecycle === 'proposed') {
|
|
@@ -467,7 +473,7 @@ export function createCapabilityVerbs(core) {
|
|
|
467
473
|
building.sort(byPriority(orders, byDays));
|
|
468
474
|
pending.sort(byPriority(orders, byDays));
|
|
469
475
|
possible.sort(byPriority(orders, (a, b) => (b.age_days ?? 0) - (a.age_days ?? 0)));
|
|
470
|
-
const waitsOf = (row) =>
|
|
476
|
+
const waitsOf = (row) => `${row.waits_on ? ` · ${waitsOnLine(row.waits_on)}` : ''}${blockedPhrase(row.blocked_by ?? [], row.blocks ?? []) ? ` · ${blockedPhrase(row.blocked_by ?? [], row.blocks ?? [])}` : ''}`;
|
|
471
477
|
|
|
472
478
|
// D3 rule 2: a row names its product. The board does not group, so every
|
|
473
479
|
// line carries the id (review H6).
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// 3. Movement is read, never stored. The view computes it at question time
|
|
14
14
|
// from the marks and from the repos' pull requests (D3 rule 5, R1).
|
|
15
15
|
import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS } from './verb-fields.mjs';
|
|
16
|
-
import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
|
|
16
|
+
import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
|
|
17
17
|
|
|
18
18
|
export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
|
|
19
19
|
export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
|
|
@@ -544,6 +544,9 @@ export function createDeliveryVerbs(core) {
|
|
|
544
544
|
if (items.length > MAX_ITEMS) refuse(`${items.length} items are registered; the view reads at most ${MAX_ITEMS}. Narrow it with a product id.`);
|
|
545
545
|
const byId = new Map(items.map((row) => [row.id, row]));
|
|
546
546
|
const marks = await marksOf(new Set(byId.keys()));
|
|
547
|
+
// Blocked-by links are read from every item the view loaded, in any
|
|
548
|
+
// lifecycle, so "blocks" and "cleared" need no extra call.
|
|
549
|
+
const anyById = new Map(all.map((row) => [row.id, row]));
|
|
547
550
|
|
|
548
551
|
// Movement from git: every registered repo of the products in view, its
|
|
549
552
|
// recent pull requests, once per repo and shared by every item.
|
|
@@ -587,6 +590,10 @@ export function createDeliveryVerbs(core) {
|
|
|
587
590
|
// D3 rule 1: in flight is active work. Delivered and paused items leave
|
|
588
591
|
// the rows; what was delivered since a date has its own list.
|
|
589
592
|
if (fields.lifecycle !== 'active') continue;
|
|
593
|
+
const blockers = blockersOf(item, (id) => anyById.get(id));
|
|
594
|
+
const blocks = blocksOf(item, all);
|
|
595
|
+
const blocked = blockedPhrase(blockers, blocks);
|
|
596
|
+
const waits = waitsShown(fields);
|
|
590
597
|
rows.push({
|
|
591
598
|
item: item.id, product: fields.product, stage: fields.stage ?? '', driver: fields.driver ?? '', days_since_last_change: days,
|
|
592
599
|
last_change_at: last?.at ?? null, last_change_source: last?.source ?? 'nothing recorded', delivery_state: state,
|
|
@@ -594,10 +601,13 @@ export function createDeliveryVerbs(core) {
|
|
|
594
601
|
// The id is what a reader types back, so the title gives way to it
|
|
595
602
|
// inside the line budget, as the "Needs me" row does (review G6).
|
|
596
603
|
oneLine(`${oneLine(item.title, Math.max(1, ROW_LINE_MAX - item.id.length - 3))} · ${item.id}`),
|
|
597
|
-
oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'}${waitsOnLine(
|
|
604
|
+
oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'}${waitsOnLine(waits) ? ` · ${waitsOnLine(waits)}` : ''} · next: ${fields.next_step || 'not set'}`),
|
|
598
605
|
oneLine(`${days === null ? 'no movement on record' : `${days} day${days === 1 ? '' : 's'} since last change`} (${last?.source ?? 'nothing recorded'}) · ${mine.length} pull request${mine.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} merged, not marked` : ''}${orderNote(orders, { product: fields.product, item: item.id })}`),
|
|
606
|
+
// The blockers get their own line, so a long list never cuts the
|
|
607
|
+
// next step off line two; they come last.
|
|
608
|
+
...(blocked ? [oneLine(blocked)] : []),
|
|
599
609
|
],
|
|
600
|
-
waits_on:
|
|
610
|
+
waits_on: waits, blocked_by: blockers, blocks,
|
|
601
611
|
folded: { title: item.title, next_step: fields.next_step ?? '', scope: fields.scope?.text ?? '', pieces: fields.split_children ?? [], prs: mine.map((pull) => ({ url: pull.url, state: pull.state, matched_by: pull.matched_by, merged_at: pull.merged_at, updated_at: pull.updated_at })), marks: own.map((mark) => ({ id: mark.id, kind: mark.fields.kind, verdict: mark.fields.verdict, at: mark.fields.marked_at })) },
|
|
602
612
|
citation: { item: item.id, read_at: readAt, repos: [...new Set(mine.map((pull) => pull.repo))] },
|
|
603
613
|
});
|
package/work/lib/questions.mjs
CHANGED
|
@@ -98,6 +98,42 @@ export function waitsOnLine(waits) {
|
|
|
98
98
|
return `waits on ${what}${waits.item ? ` ${waits.item}` : ''}${waits.note ? `: ${waits.note}` : ''}`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Work item additions, slice 2: the items that block an item, the items it
|
|
102
|
+
// blocks, and one phrase for a row. lookup gives an item record by id, or
|
|
103
|
+
// undefined when the view did not load it. A blocker that is delivered or
|
|
104
|
+
// dropped no longer blocks; the row shows it as cleared.
|
|
105
|
+
export const CLEARED_LIFECYCLES = Object.freeze(['done', 'dropped']);
|
|
106
|
+
export function blockersOf(item, lookup) {
|
|
107
|
+
const list = Array.isArray(item?.fields?.blocked_by) ? item.fields.blocked_by : [];
|
|
108
|
+
return list.filter((entry) => entry && typeof entry.item === 'string').map((entry) => {
|
|
109
|
+
const other = lookup(entry.item);
|
|
110
|
+
// A blocker the view cannot find is archived or gone, so it blocks no more.
|
|
111
|
+
if (!other) return { item: entry.item, title: null, state: 'gone', cleared: true, ...(entry.note ? { note: entry.note } : {}) };
|
|
112
|
+
const lifecycle = other.fields?.lifecycle ?? 'unknown';
|
|
113
|
+
const cleared = CLEARED_LIFECYCLES.includes(lifecycle);
|
|
114
|
+
return { item: entry.item, title: oneLine(other.title, 60), state: cleared ? 'cleared' : (lifecycle === 'active' ? (other.fields?.stage ?? 'active') : lifecycle), cleared, ...(entry.note ? { note: entry.note } : {}) };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// Only a live item is still held up, so a delivered or dropped one leaves the
|
|
118
|
+
// "blocks" list.
|
|
119
|
+
export function blocksOf(item, all) {
|
|
120
|
+
return all.filter((other) => other.id !== item.id && LIVE_LIFECYCLES.includes(other.fields?.lifecycle) && Array.isArray(other.fields?.blocked_by) && other.fields.blocked_by.some((entry) => entry?.item === item.id))
|
|
121
|
+
.map((other) => ({ item: other.id, title: oneLine(other.title, 60) }));
|
|
122
|
+
}
|
|
123
|
+
export function blockedPhrase(blockers, blocks) {
|
|
124
|
+
const parts = [];
|
|
125
|
+
if (blockers.length) parts.push(`blocked by: ${blockers.map((row) => `${row.title ?? row.item} (${row.state})`).join(', ')}`);
|
|
126
|
+
if (blocks.length) parts.push(`blocks: ${blocks.map((row) => row.title ?? row.item).join(', ')}`);
|
|
127
|
+
return parts.join(' · ');
|
|
128
|
+
}
|
|
129
|
+
// An old waits_on pointer to an item is hidden when a blocked_by link names
|
|
130
|
+
// the same item, so a row does not say it twice.
|
|
131
|
+
export function waitsShown(fields) {
|
|
132
|
+
const waits = fields?.waits_on;
|
|
133
|
+
if (waits?.on === 'item' && Array.isArray(fields?.blocked_by) && fields.blocked_by.some((entry) => entry?.item === waits.item)) return null;
|
|
134
|
+
return waits ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
101
137
|
// Row line two: the id is the citation a reader types back, so the title gives
|
|
102
138
|
// way to it, not the other way round (review G6).
|
|
103
139
|
export function itemLine(item, product, lifecycle = 'active') {
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -28,9 +28,38 @@ export const STAGES = Object.freeze(['capture', 'spec', 'build', 'verify', 'lear
|
|
|
28
28
|
export const TIERS = Object.freeze(['hotfix', 'standard', 'initiative']);
|
|
29
29
|
export const DRIVERS = Object.freeze(['owner-led', 'agent']);
|
|
30
30
|
// What an item waits on (release 0.2.3). One line on the item, shown on its
|
|
31
|
-
// rows. "item"
|
|
32
|
-
|
|
31
|
+
// rows. Work item additions, slice 2: "item" retires for new writes, and
|
|
32
|
+
// item_block keeps the links. A record that already holds it still reads.
|
|
33
|
+
export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'gate', 'nothing']);
|
|
34
|
+
// How far the loop check of item_block walks before it stops.
|
|
35
|
+
const MAX_BLOCK_WALK = 200;
|
|
33
36
|
const MAX_EDITS = 20;
|
|
37
|
+
// The tags that other verbs own (work item additions, slice 1). item_edit
|
|
38
|
+
// never adds or removes them; item_propose drops them from the caller's list.
|
|
39
|
+
export const OWNED_TAG_PREFIXES = Object.freeze(['product:', 'repo:', 'tier:']);
|
|
40
|
+
export const OWNED_TAGS = Object.freeze(['atlas-work', 'no-model', 'atlas-registry']);
|
|
41
|
+
// Engram reads no-model with the spaces trimmed and in any case, so the check
|
|
42
|
+
// does the same: No-Model is owned too.
|
|
43
|
+
export const isOwnedTag = (tag) => {
|
|
44
|
+
const plain = String(tag).trim().toLowerCase();
|
|
45
|
+
return OWNED_TAGS.includes(plain) || OWNED_TAG_PREFIXES.some((prefix) => plain.startsWith(prefix));
|
|
46
|
+
};
|
|
47
|
+
// The planned dates the owner may set on an item (work item additions, slice 1).
|
|
48
|
+
export const PLAN_DATES = Object.freeze(['start', 'end', 'deadline']);
|
|
49
|
+
// A planned date is a calendar day. Date.parse takes 2026-02-30 and moves it
|
|
50
|
+
// to 2 March, so the day must read back the same.
|
|
51
|
+
const planDate = (value, name) => {
|
|
52
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${name} must be a date in the form YYYY-MM-DD, or null to clear it`);
|
|
53
|
+
const parsed = new Date(`${value}T00:00:00Z`);
|
|
54
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) refuse(`${name} is not a real date: ${value}`);
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
const tagList = (value, name) => stringList(value, name, { max: 16 }).map((tag) => {
|
|
58
|
+
text(tag, name, { max: 100 });
|
|
59
|
+
if (tag !== tag.trim()) refuse(`${name} holds a tag with spaces at its start or end: "${tag}"`);
|
|
60
|
+
if (isOwnedTag(tag)) { const plain = tag.toLowerCase(); refuse(`${tag} is owned by the tracker; ${plain.startsWith('product:') || plain.startsWith('repo:') ? 'change the product or repos with item_lock' : plain.startsWith('tier:') ? 'change the tier with item_lock' : 'no verb removes it'}`); }
|
|
61
|
+
return tag;
|
|
62
|
+
});
|
|
34
63
|
export const LINK_KINDS = Object.freeze(['pr', 'issue', 'artifact', 'design-doc', 'conversation', 'research', 'slack', 'drive', 'other']);
|
|
35
64
|
export const AUTHORITY_FIELDS = Object.freeze(['writer', 'session', 'actor', 'owner_present', 'agent', 'token_id']);
|
|
36
65
|
const MAX_CHILDREN = 32;
|
|
@@ -142,9 +171,15 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
142
171
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
143
172
|
if (attempt > 0 && guard) guard(current);
|
|
144
173
|
const next = typeof fields === 'function' ? fields(current) : fields;
|
|
174
|
+
// A fields function returns null when the newest record already holds
|
|
175
|
+
// the change; then nothing is written.
|
|
176
|
+
if (next === null) return current;
|
|
145
177
|
const receipts = { ...(current.fields?.atlas_receipts ?? {}), [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } };
|
|
146
178
|
const body = { version: current.version, fields: { ...(current.fields ?? {}), ...next, writer: label, request_id: requestId, atlas_receipts: receipts } };
|
|
147
|
-
|
|
179
|
+
// tags as a function replaces the list with what it returns, computed
|
|
180
|
+
// from the newest record; a list is added to the tags already there.
|
|
181
|
+
if (typeof tags === 'function') body.tags = [...new Set(tags(current))];
|
|
182
|
+
else if (tags) body.tags = [...new Set([...(current.tags ?? []), ...tags])];
|
|
148
183
|
if (title) body.title = title;
|
|
149
184
|
try { await client.updateEntity(current.id, body); return client.getEntity(current.id); }
|
|
150
185
|
catch (error) {
|
|
@@ -360,7 +395,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
360
395
|
const type = await typeNamed('container');
|
|
361
396
|
const prior = await landed(type, requestId, meta);
|
|
362
397
|
if (prior) return { verb: 'item_propose', item_id: prior.id, version: prior.version, lifecycle: prior.fields.lifecycle, stage: prior.fields.stage, product: prior.fields.product, repos: prior.fields.repos, replayed_from_graph: true };
|
|
363
|
-
const entity = await create(type, requestId, 'item_propose', { digest: meta?.digest, title, body: args.body ? text(args.body, 'body') : undefined, fields, tags: [`product:${product.fields.registry_id}`, ...repos.map((id) => `repo:${id}`), `tier:${fields.tier}`, ...(args.tags ? stringList(args.tags, 'tags', { max: 16 }).filter((tag) =>
|
|
398
|
+
const entity = await create(type, requestId, 'item_propose', { digest: meta?.digest, title, body: args.body ? text(args.body, 'body') : undefined, fields, tags: [`product:${product.fields.registry_id}`, ...repos.map((id) => `repo:${id}`), `tier:${fields.tier}`, ...(args.tags ? stringList(args.tags, 'tags', { max: 16 }).filter((tag) => !isOwnedTag(tag)) : [])] });
|
|
364
399
|
return { verb: 'item_propose', item_id: entity.id, version: entity.version, lifecycle: 'proposed', stage: 'capture', product: fields.product, repos };
|
|
365
400
|
},
|
|
366
401
|
|
|
@@ -398,7 +433,25 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
398
433
|
requireOwner('item split');
|
|
399
434
|
const parent = await requireItem(args.item_id);
|
|
400
435
|
const reason = text(args.reason, 'reason', { max: 2000 });
|
|
401
|
-
|
|
436
|
+
// split_order is a piece's place in the parent's split_children. That
|
|
437
|
+
// list changes only through a function of the newest parent, so its
|
|
438
|
+
// order holds under a race and a retry. Each piece gets its number after
|
|
439
|
+
// the parent write lands (work item additions, slice 1).
|
|
440
|
+
const stampOrder = async (list, rows) => {
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const row of rows) {
|
|
443
|
+
const order = list.indexOf(row.id);
|
|
444
|
+
const child = order >= 0 && row.fields?.split_order !== order ? await patch(row, 'item_split', requestId, { split_order: order }, { digest: meta?.digest }) : row;
|
|
445
|
+
out.push({ item_id: child.id, title: child.title, repos: child.fields?.repos ?? [], split_order: child.fields?.split_order ?? null });
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
};
|
|
449
|
+
if (alreadyPatched(parent, requestId, meta)) {
|
|
450
|
+
// Only this request's pieces, in the order of the call.
|
|
451
|
+
const mine = (await findCreated(await typeNamed('container'), requestId)).filter((row) => row.fields?.split_from === parent.id).sort((a, b) => a.fields.split_index - b.fields.split_index);
|
|
452
|
+
const children = await stampOrder(Array.isArray(parent.fields.split_children) ? parent.fields.split_children : [], mine);
|
|
453
|
+
return { verb: 'item_split', item_id: parent.id, version: parent.version, reason, children, replayed_from_graph: true };
|
|
454
|
+
}
|
|
402
455
|
if (!['proposed', 'active'].includes(parent.fields?.lifecycle)) refuse(`item ${parent.id} is ${parent.fields?.lifecycle}; only a proposed or active item can be split`);
|
|
403
456
|
if (!Array.isArray(args.children) || !args.children.length) refuse('children must list at least one piece: { title, origin?, repos?, tier?, next_step?, driver? }');
|
|
404
457
|
if (args.children.length > MAX_CHILDREN) refuse(`children holds more than ${MAX_CHILDREN} pieces`);
|
|
@@ -421,14 +474,19 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
421
474
|
if (!spec.origin) refuse('each child needs an origin in the owner\'s words when the parent has none');
|
|
422
475
|
}
|
|
423
476
|
const already = await findCreated(type, requestId);
|
|
424
|
-
|
|
477
|
+
// split_index is the place in this call, so a retry finds its own
|
|
478
|
+
// pieces.
|
|
479
|
+
const created = [];
|
|
425
480
|
for (const [index, spec] of specs.entries()) {
|
|
426
|
-
const existing = already.find((row) => row.fields?.split_index === index);
|
|
481
|
+
const existing = already.find((row) => row.fields?.split_index === index && row.fields?.split_from === parent.id);
|
|
427
482
|
const child = existing ?? await create(type, requestId, 'item_split', { digest: meta?.digest, title: spec.title, fields: { lifecycle: 'proposed', stage: 'capture', product: product.fields.registry_id, repos: spec.repos, tier: spec.tier, origin: spec.origin, driver: spec.driver, next_step: spec.next_step, summary: spec.summary, external_refs: [], links: [], split_index: index, split_from: parent.id, split_reason: reason }, tags: [`product:${product.fields.registry_id}`, ...spec.repos.map((id) => `repo:${id}`), `tier:${spec.tier}`] });
|
|
428
483
|
await edge(child, parent, 'spun_out_of', requestId, { reason });
|
|
429
|
-
|
|
484
|
+
created.push(child);
|
|
430
485
|
}
|
|
431
|
-
|
|
486
|
+
// A function of the newest parent, so a split that lands at the same
|
|
487
|
+
// moment keeps its pieces.
|
|
488
|
+
const entity = await patch(parent, 'item_split', requestId, (current) => ({ split_children: [...new Set([...(current.fields?.split_children ?? []), ...created.map((child) => child.id)])] }), { digest: meta?.digest });
|
|
489
|
+
const children = await stampOrder(entity.fields?.split_children ?? [], created);
|
|
432
490
|
return { verb: 'item_split', item_id: entity.id, version: entity.version, reason, children };
|
|
433
491
|
},
|
|
434
492
|
|
|
@@ -479,10 +537,12 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
479
537
|
// The next step and the wait are the lead's to keep true, so any
|
|
480
538
|
// in-session caller may change them. The title is the owner's words, so a
|
|
481
539
|
// new title needs the owner present. Each edit keeps the old values.
|
|
540
|
+
// Work item additions, slice 1: tags_add and tags_remove change the free
|
|
541
|
+
// tags, and plan holds the owner's planned start, end and deadline.
|
|
482
542
|
async item_edit(args, requestId, meta) {
|
|
483
543
|
const item = await requireItem(args.item_id);
|
|
484
544
|
const fields = item.fields ?? {};
|
|
485
|
-
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_edit', item_id: item.id, version: item.version, title: item.title, next_step: fields.next_step ?? '', waits_on: fields.waits_on ?? null, replayed_from_graph: true };
|
|
545
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_edit', item_id: item.id, version: item.version, title: item.title, next_step: fields.next_step ?? '', waits_on: fields.waits_on ?? null, tags: item.tags ?? [], plan: fields.plan ?? null, changed: [], replayed_from_graph: true };
|
|
486
546
|
const live = (current) => { if (!LIVE_LIFECYCLES.includes(current.fields?.lifecycle)) refuse(`item ${current.id} is ${current.fields?.lifecycle}; only a proposed, active or paused item can be edited`); };
|
|
487
547
|
live(item);
|
|
488
548
|
const change = {}, previous = {};
|
|
@@ -498,29 +558,124 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
498
558
|
}
|
|
499
559
|
if (args.waits_on !== undefined) {
|
|
500
560
|
const given = args.waits_on;
|
|
501
|
-
if (!given || typeof given !== 'object' || Array.isArray(given)) refuse(`waits_on must be { on, note
|
|
561
|
+
if (!given || typeof given !== 'object' || Array.isArray(given)) refuse(`waits_on must be { on, note? }, with on one of ${WAITS_ON.join(', ')}`);
|
|
562
|
+
if (given.on === 'item') refuse('waits_on.on "item" is retired; link the items with item_block, which lists every blocker and shows "blocks" on the other row');
|
|
502
563
|
const wait = { on: oneOf(given.on, 'waits_on.on', WAITS_ON) };
|
|
503
564
|
const note = text(given.note, 'waits_on.note', { optional: true, max: 300 });
|
|
504
565
|
if (note) wait.note = note;
|
|
505
|
-
if (
|
|
506
|
-
|
|
507
|
-
if (other.id === item.id) refuse('an item cannot wait on itself');
|
|
508
|
-
wait.item = other.id;
|
|
509
|
-
} else if (given.item !== undefined) refuse('waits_on.item is only for on: item');
|
|
510
|
-
if (wait.on !== 'nothing' && wait.on !== 'item' && !wait.note) refuse(`waits_on.note is required for on: ${wait.on}; say what exactly it waits on`);
|
|
566
|
+
if (given.item !== undefined) refuse('waits_on.item is retired; link the items with item_block');
|
|
567
|
+
if (wait.on !== 'nothing' && !wait.note) refuse(`waits_on.note is required for on: ${wait.on}; say what exactly it waits on`);
|
|
511
568
|
change.waits_on = wait;
|
|
512
569
|
previous.waits_on = fields.waits_on ?? null;
|
|
513
570
|
}
|
|
514
|
-
|
|
571
|
+
const tagsAdd = args.tags_add === undefined ? [] : tagList(args.tags_add, 'tags_add');
|
|
572
|
+
const tagsRemove = args.tags_remove === undefined ? [] : tagList(args.tags_remove, 'tags_remove');
|
|
573
|
+
const both = tagsAdd.filter((tag) => tagsRemove.includes(tag));
|
|
574
|
+
if (both.length) refuse(`a tag cannot be added and removed in one edit: ${both.join(', ')}`);
|
|
575
|
+
const tagsOf = (current) => [...new Set([...(current.tags ?? []).filter((tag) => !tagsRemove.includes(tag)), ...tagsAdd])];
|
|
576
|
+
const sameList = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]);
|
|
577
|
+
let tagEdit = tagsAdd.length || tagsRemove.length;
|
|
578
|
+
let planGiven = null;
|
|
579
|
+
if (args.plan !== undefined) {
|
|
580
|
+
requireOwner('item edit of planned dates');
|
|
581
|
+
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`);
|
|
582
|
+
const unknown = Object.keys(args.plan).filter((key) => !PLAN_DATES.includes(key));
|
|
583
|
+
if (unknown.length) refuse(`plan holds only ${PLAN_DATES.join(', ')}; not ${unknown.join(', ')}`);
|
|
584
|
+
if (!Object.keys(args.plan).length) refuse(`plan must name at least one of ${PLAN_DATES.join(', ')}`);
|
|
585
|
+
planGiven = Object.fromEntries(Object.entries(args.plan).map(([key, value]) => [key, value === null ? null : planDate(value, `plan.${key}`)]));
|
|
586
|
+
}
|
|
587
|
+
// The dates merge with the ones on the newest record; a null clears one.
|
|
588
|
+
const planOf = (current) => {
|
|
589
|
+
const merged = { ...(current.fields?.plan ?? {}), ...planGiven };
|
|
590
|
+
for (const key of Object.keys(merged)) if (merged[key] === null) delete merged[key];
|
|
591
|
+
if (merged.start && merged.end && merged.end < merged.start) refuse(`the planned end ${merged.end} is before the planned start ${merged.start}`);
|
|
592
|
+
return Object.keys(merged).length ? merged : null;
|
|
593
|
+
};
|
|
594
|
+
const samePlan = (a, b) => JSON.stringify(Object.entries(a ?? {}).sort()) === JSON.stringify(Object.entries(b ?? {}).sort());
|
|
595
|
+
if (args.title === undefined && !Object.keys(change).length && !tagEdit && !planGiven) refuse('item edit needs title, next_step or waits_on, or one of tags_add, tags_remove and plan');
|
|
596
|
+
// A tag or plan edit that changes nothing is left out. When nothing is
|
|
597
|
+
// left, the item is not written and the history gets no empty entry.
|
|
598
|
+
if (planGiven && samePlan(planOf(item), fields.plan)) planGiven = null;
|
|
599
|
+
if (tagEdit && sameList(tagsOf(item), item.tags ?? [])) tagEdit = false;
|
|
600
|
+
if (args.title === undefined && !Object.keys(change).length && !tagEdit && !planGiven) return { verb: 'item_edit', item_id: item.id, version: item.version, title: item.title, next_step: fields.next_step ?? '', waits_on: fields.waits_on ?? null, tags: item.tags ?? [], plan: fields.plan ?? null, changed: [], unchanged: true };
|
|
515
601
|
const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
|
|
516
|
-
const
|
|
602
|
+
const at = now();
|
|
517
603
|
// The history keeps the last MAX_EDITS entries. The first title the
|
|
518
|
-
// owner gave is kept on its own, so a title never falls out of it.
|
|
604
|
+
// owner gave is kept on its own, so a title never falls out of it. The
|
|
605
|
+
// old tags and dates come from the record the write lands on.
|
|
606
|
+
const entryOf = (current) => ({ at, by: label.agent, previous: { ...previous, ...(tagEdit ? { tags: current.tags ?? [] } : {}), ...(planGiven ? { plan: current.fields?.plan ?? null } : {}) }, ...(reason ? { reason } : {}) });
|
|
519
607
|
const entity = await patch(item, 'item_edit', requestId, (current) => ({
|
|
520
|
-
...change, edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []),
|
|
608
|
+
...change, ...(planGiven ? { plan: planOf(current) } : {}), edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), entryOf(current)].slice(-MAX_EDITS),
|
|
521
609
|
...(previous.title !== undefined && current.fields?.first_title === undefined ? { first_title: previous.title } : {}),
|
|
522
|
-
}), { digest: meta?.digest, guard: live, ...(title ? { title } : {}) });
|
|
523
|
-
return { verb: 'item_edit', item_id: entity.id, version: entity.version, title: entity.title, next_step: entity.fields?.next_step ?? '', waits_on: entity.fields?.waits_on ?? null, changed: [...(args.title !== undefined ? ['title'] : []), ...Object.keys(change)] };
|
|
610
|
+
}), { digest: meta?.digest, guard: live, ...(title ? { title } : {}), ...(tagEdit ? { tags: tagsOf } : {}) });
|
|
611
|
+
return { verb: 'item_edit', item_id: entity.id, version: entity.version, title: entity.title, next_step: entity.fields?.next_step ?? '', waits_on: entity.fields?.waits_on ?? null, tags: entity.tags ?? [], plan: entity.fields?.plan ?? null, changed: [...(args.title !== undefined ? ['title'] : []), ...Object.keys(change), ...(tagEdit ? ['tags'] : []), ...(planGiven ? ['plan'] : [])] };
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
// Work item additions, slice 2: an item names the items that block it.
|
|
615
|
+
// The list on the item is the one home of the fact; a view works out
|
|
616
|
+
// "blocks" from the items it loaded. The lead keeps the links true, so no
|
|
617
|
+
// owner flag is needed. A link that would close a loop is refused.
|
|
618
|
+
async item_block(args, requestId, meta) {
|
|
619
|
+
const item = await requireItem(args.item_id);
|
|
620
|
+
text(args.blocked_by, 'blocked_by', { max: 200 });
|
|
621
|
+
const note = text(args.note, 'note', { optional: true, max: 300 });
|
|
622
|
+
const listOf = (record) => (Array.isArray(record.fields?.blocked_by) ? record.fields.blocked_by : []);
|
|
623
|
+
// The replay comes before the blocker is read: a blocker archived since
|
|
624
|
+
// the write landed must not refuse a write that already happened.
|
|
625
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_block', item_id: item.id, version: item.version, blocked_by: listOf(item).map((entry) => entry.item), replayed_from_graph: true };
|
|
626
|
+
const blocker = await requireItem(args.blocked_by);
|
|
627
|
+
const live = (current) => { if (!LIVE_LIFECYCLES.includes(current.fields?.lifecycle)) refuse(`item ${current.id} is ${current.fields?.lifecycle}; only a proposed, active or paused item can be blocked`); };
|
|
628
|
+
live(item);
|
|
629
|
+
if (blocker.id === item.id) refuse('an item cannot block itself');
|
|
630
|
+
if (listOf(item).some((entry) => entry.item === blocker.id)) return { verb: 'item_block', item_id: item.id, version: item.version, blocked_by: listOf(item).map((entry) => entry.item), unchanged: true };
|
|
631
|
+
// Walk what the blocker waits on, with an old waits_on pointer to an
|
|
632
|
+
// item counted as a link. Reaching the item means the new link closes a
|
|
633
|
+
// loop. Two sessions can pass this check at the same moment;
|
|
634
|
+
// one owner runs these products, so the design accepts that.
|
|
635
|
+
const seen = new Set([blocker.id]);
|
|
636
|
+
const path = new Map([[blocker.id, [blocker.id]]]);
|
|
637
|
+
const queue = [blocker];
|
|
638
|
+
while (queue.length) {
|
|
639
|
+
const current = queue.shift();
|
|
640
|
+
const links = [...listOf(current), ...(current.fields?.waits_on?.on === 'item' && typeof current.fields.waits_on.item === 'string' ? [{ item: current.fields.waits_on.item }] : [])];
|
|
641
|
+
for (const entry of links) {
|
|
642
|
+
if (entry.item === item.id) refuse(`this link would close a loop: ${[item.id, ...path.get(current.id), item.id].join(' → ')}`);
|
|
643
|
+
if (seen.has(entry.item)) continue;
|
|
644
|
+
if (seen.size >= MAX_BLOCK_WALK) refuse(`the loop check stopped after ${MAX_BLOCK_WALK} items; the blocked-by chain is too long to check`);
|
|
645
|
+
seen.add(entry.item);
|
|
646
|
+
let next;
|
|
647
|
+
try { next = await client.getEntity(entry.item); } catch (error) { if (error instanceof EngramHttpError && error.status === 404) continue; throw error; }
|
|
648
|
+
path.set(next.id, [...path.get(current.id), next.id]);
|
|
649
|
+
queue.push(next);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const at = now();
|
|
653
|
+
const link = { item: blocker.id, at, by: label.agent, ...(note ? { note } : {}) };
|
|
654
|
+
// null tells patch() not to write: another session added the same link.
|
|
655
|
+
const entity = await patch(item, 'item_block', requestId, (current) => (listOf(current).some((entry) => entry.item === blocker.id) ? null : { blocked_by: [...listOf(current), link] }), { digest: meta?.digest, guard: live });
|
|
656
|
+
return { verb: 'item_block', item_id: entity.id, version: entity.version, blocked_by: listOf(entity).map((entry) => entry.item), ...(entity.fields?.atlas_receipts?.[requestId] ? {} : { unchanged: true }) };
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
async item_unblock(args, requestId, meta) {
|
|
660
|
+
const item = await requireItem(args.item_id);
|
|
661
|
+
text(args.blocked_by, 'blocked_by', { max: 200 });
|
|
662
|
+
const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
|
|
663
|
+
const listOf = (record) => (Array.isArray(record.fields?.blocked_by) ? record.fields.blocked_by : []);
|
|
664
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_unblock', item_id: item.id, version: item.version, blocked_by: listOf(item).map((entry) => entry.item), removed: args.blocked_by, replayed_from_graph: true };
|
|
665
|
+
// Live and still blocked, on the first read and on the newest record.
|
|
666
|
+
const guard = (current) => {
|
|
667
|
+
if (!LIVE_LIFECYCLES.includes(current.fields?.lifecycle)) refuse(`item ${current.id} is ${current.fields?.lifecycle}; only a proposed, active or paused item can be unblocked`);
|
|
668
|
+
if (!listOf(current).some((entry) => entry.item === args.blocked_by)) refuse(`item ${current.id} is not blocked by ${args.blocked_by}`);
|
|
669
|
+
};
|
|
670
|
+
guard(item);
|
|
671
|
+
const at = now();
|
|
672
|
+
// The removed link goes into the edits, so the history keeps it. It is
|
|
673
|
+
// taken from the record the write lands on.
|
|
674
|
+
const entity = await patch(item, 'item_unblock', requestId, (current) => ({
|
|
675
|
+
blocked_by: listOf(current).filter((row) => row.item !== args.blocked_by),
|
|
676
|
+
edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), { at, by: label.agent, previous: { blocked_by: listOf(current).find((row) => row.item === args.blocked_by) }, ...(reason ? { reason } : {}) }].slice(-MAX_EDITS),
|
|
677
|
+
}), { digest: meta?.digest, guard });
|
|
678
|
+
return { verb: 'item_unblock', item_id: entity.id, version: entity.version, blocked_by: listOf(entity).map((row) => row.item), removed: args.blocked_by };
|
|
524
679
|
},
|
|
525
680
|
|
|
526
681
|
async item_stage(args, requestId, meta) {
|
|
@@ -561,7 +716,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
561
716
|
Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
|
|
562
717
|
Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
|
|
563
718
|
|
|
564
|
-
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_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
|
|
719
|
+
const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
|
|
565
720
|
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS]);
|
|
566
721
|
|
|
567
722
|
async function run(verb, args = {}) {
|
package/work/mcp.mjs
CHANGED
|
@@ -51,8 +51,10 @@ export const TOOLS = [
|
|
|
51
51
|
{ name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog: the in_flight rows leave them out, and needs_me still shows their open questions. Refuses a missing reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it is parked.') }, additionalProperties: false } },
|
|
52
52
|
{ name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. It also undoes a drop: a dropped item goes back to the lifecycle it left, and the drop's words stay on it. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it resumes.') }, additionalProperties: false } },
|
|
53
53
|
{ name: 'item_drop', description: `Drop an item in the owner's words: lifecycle dropped, with the words, the date and the lifecycle it left. A dropped item stays in the graph, and no live view lists it. Refuses a done item (reopen it instead) and an item already dropped. When: the owner retires a piece of work. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), origin: { ...ORIGIN, description: "The owner's words that drop the item, verbatim, with where and when they were said." } }, additionalProperties: false } },
|
|
54
|
-
{ name: 'item_edit', description: `Edit a proposed, active or paused item: its next step, what it waits on, or its title. Each edit appends the old values to the item's edits, and the last 20 are kept; the first title stays in first_title. When: the next step
|
|
54
|
+
{ 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 appends the old values to the item's edits, and the last 20 are kept; the first title stays in first_title. When: the next step, the wait or a tag changes; the lead keeps them true at the end of each session. No owner flag for next_step, waits_on and tags; the tags that other verbs own (product:, repo:, tier:, atlas-work, no-model, atlas-registry) are refused. A new title is the owner's words, and planned dates are the owner's promise, so both need the owner present. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), next_step: S('One line: what happens next.'), waits_on: { type: 'object', required: ['on'], description: 'What the item waits on. Shown on its rows.', properties: { on: S(`One of ${WAITS_ON.join(', ')}. To wait on another item, use item_block.`), note: S('What exactly it waits on, in plain words. Required unless on is nothing.') } }, title: S("A new title, in the owner's words. Owner present only."), tags_add: { type: 'array', items: { type: 'string' }, description: 'Free tags to add.' }, tags_remove: { type: 'array', items: { type: 'string' }, description: 'Free tags to remove.' }, plan: { type: 'object', description: 'Planned dates, set by the owner. Owner present only. Each is YYYY-MM-DD, or null to clear it. Dates not named stay as they are.', properties: { start: { type: ['string', 'null'], description: 'Planned start.' }, end: { type: ['string', 'null'], description: 'Planned end. Not before the start.' }, deadline: { type: ['string', 'null'], description: 'Deadline.' } }, additionalProperties: false }, reason: S('One line on why it changed.') }, additionalProperties: false } },
|
|
55
55
|
{ name: 'item_stage', description: `Move an active item to a stage (${STAGES.join(', ')}). When: work moves; the lead or the delivering agent calls it. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'stage'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), stage: S(`One of ${STAGES.join(', ')}.`), note: S('One line on what moved.') }, additionalProperties: false } },
|
|
56
|
+
{ name: 'item_block', description: `Record that an item is blocked by another item. The item keeps a list of its blockers, each with a note; the views show "blocked by" on its row and "blocks" on the blocker's row, and a blocker that is delivered or dropped shows as cleared. Refuses the item itself and a link that would close a loop. When: work cannot move until another item lands. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'blocked_by'], properties: { request_id: REQUEST_ID, item_id: S('The blocked item id.'), blocked_by: S('The item id that blocks it.'), note: S('One line: what exactly it needs from the blocker.') }, additionalProperties: false } },
|
|
57
|
+
{ name: 'item_unblock', description: `Remove one blocked-by link from an item. The removed link stays in the item's edits. When: the blocker no longer holds the item back. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'blocked_by'], properties: { request_id: REQUEST_ID, item_id: S('The blocked item id.'), blocked_by: S('The blocker item id to remove.'), reason: S('One line on why.') }, additionalProperties: false } },
|
|
56
58
|
{ name: 'item_link', description: `Attach a typed link (kind, url, role, note) to an item: a PR, an issue, an artifact, a design doc, a conversation. When: any in-session agent has a link the item should carry, for example a PR it opened. No owner flag needed. A duplicate kind and url is a no-op. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'kind', 'url'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), kind: S(`One of ${LINK_KINDS.join(', ')}.`), url: S('Absolute http or https URL.'), role: S('What the link is to the item, for example "delivers", "design", "evidence".'), note: S('One line.') }, additionalProperties: false } },
|
|
57
59
|
{ name: 'item_adopt', description: `Give an existing work item its product and repos. This is the slice L5 sweep: most items in the tenant were made before the registry existed and name no product, so no view can place them. Refuses an item that already names another registered product; an item is named once. The audit stamps adopted_at and adopted_from move only when the product actually changes. When: the owner says which product a piece of work belongs to. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), product: S('Product id the item belongs to.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product. Required. Pass an empty list for an item that has no repo, such as a container root; a repo of another product is dropped and named in dropped_repos.' }, stage: S(`One of ${STAGES.join(', ')}. Active items only.`), tier: S(`One of ${TIERS.join(', ')}.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
|
|
58
60
|
{ name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. Any in-session agent may call it; no owner flag. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
|