@arjunkhera/atlas 0.3.10 → 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 +80 -10
- 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,8 +28,11 @@ 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;
|
|
34
37
|
// The tags that other verbs own (work item additions, slice 1). item_edit
|
|
35
38
|
// never adds or removes them; item_propose drops them from the caller's list.
|
|
@@ -168,6 +171,9 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
168
171
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
169
172
|
if (attempt > 0 && guard) guard(current);
|
|
170
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;
|
|
171
177
|
const receipts = { ...(current.fields?.atlas_receipts ?? {}), [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } };
|
|
172
178
|
const body = { version: current.version, fields: { ...(current.fields ?? {}), ...next, writer: label, request_id: requestId, atlas_receipts: receipts } };
|
|
173
179
|
// tags as a function replaces the list with what it returns, computed
|
|
@@ -552,16 +558,13 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
552
558
|
}
|
|
553
559
|
if (args.waits_on !== undefined) {
|
|
554
560
|
const given = args.waits_on;
|
|
555
|
-
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');
|
|
556
563
|
const wait = { on: oneOf(given.on, 'waits_on.on', WAITS_ON) };
|
|
557
564
|
const note = text(given.note, 'waits_on.note', { optional: true, max: 300 });
|
|
558
565
|
if (note) wait.note = note;
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
if (other.id === item.id) refuse('an item cannot wait on itself');
|
|
562
|
-
wait.item = other.id;
|
|
563
|
-
} else if (given.item !== undefined) refuse('waits_on.item is only for on: item');
|
|
564
|
-
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`);
|
|
565
568
|
change.waits_on = wait;
|
|
566
569
|
previous.waits_on = fields.waits_on ?? null;
|
|
567
570
|
}
|
|
@@ -608,6 +611,73 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
608
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'] : [])] };
|
|
609
612
|
},
|
|
610
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 };
|
|
679
|
+
},
|
|
680
|
+
|
|
611
681
|
async item_stage(args, requestId, meta) {
|
|
612
682
|
const item = await requireItem(args.item_id);
|
|
613
683
|
const stage = oneOf(args.stage, 'stage', STAGES);
|
|
@@ -646,7 +716,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
646
716
|
Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
|
|
647
717
|
Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
|
|
648
718
|
|
|
649
|
-
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]);
|
|
650
720
|
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS]);
|
|
651
721
|
|
|
652
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, 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(', ')}.`), note: S('What exactly it waits on, in plain words. Required unless on is
|
|
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 } },
|