@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.9",
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.9",
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,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" 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;
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
- if (tags) body.tags = [...new Set([...(current.tags ?? []), ...tags])];
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) => !/^(?:product|repo|tier):|^atlas-registry$/.test(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
- if (alreadyPatched(parent, requestId, meta)) return { verb: 'item_split', item_id: parent.id, version: parent.version, reason, children: (parent.fields.split_children ?? []).map((id) => ({ item_id: id })), replayed_from_graph: true };
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
- const children = [];
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
- children.push({ item_id: child.id, title: spec.title, repos: spec.repos });
484
+ created.push(child);
430
485
  }
431
- const entity = await patch(parent, 'item_split', requestId, { split_children: [...new Set([...(parent.fields.split_children ?? []), ...children.map((child) => child.item_id)])] }, { digest: meta?.digest });
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?, 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');
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 (wait.on === 'item') {
506
- const other = await requireItem(given.item);
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
- if (args.title === undefined && !Object.keys(change).length) refuse('item edit needs title, next_step or waits_on');
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 entry = { at: now(), by: label.agent, previous, ...(reason ? { reason } : {}) };
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 : []), entry].slice(-MAX_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 or the wait changes; the lead keeps them true at the end of each session. No owner flag for next_step and waits_on; a new title is the owner's words, so it needs 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."), 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 } },