@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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` |
@@ -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 waits = fields.waits_on && typeof fields.waits_on === 'object' ? { waits_on: fields.waits_on } : {};
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) => (row.waits_on ? ` · ${waitsOnLine(row.waits_on)}` : '');
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).
@@ -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(fields.waits_on) ? ` · ${waitsOnLine(fields.waits_on)}` : ''} · next: ${fields.next_step || 'not set'}`),
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: fields.waits_on ?? null,
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
  });
@@ -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') {
@@ -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" names another work item by id.
32
- export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'item', 'gate', 'nothing']);
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?, item? }, with on one of ${WAITS_ON.join(', ')}`);
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 (wait.on === 'item') {
560
- const other = await requireItem(given.item);
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 item or nothing.'), item: S('The item id it waits on, for on: item.') } }, 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 } },
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 } },