@arjunkhera/atlas 0.3.8 → 0.3.10

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.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/artifact-renderer.md +22 -22
  3. package/door/cli.mjs +36 -11
  4. package/door/lib/design-build.mjs +409 -0
  5. package/door/lib/design.mjs +199 -118
  6. package/door/lib/markdown.mjs +160 -0
  7. package/package.json +1 -1
  8. package/skills/lead/SKILL.md +1 -1
  9. package/skills/sdlc-task/SKILL.md +33 -24
  10. package/skills/sdlc-task/design/README.md +187 -0
  11. package/skills/sdlc-task/design/parts/actors.md +26 -0
  12. package/skills/sdlc-task/design/parts/alternatives.md +24 -0
  13. package/skills/sdlc-task/design/parts/build.md +23 -0
  14. package/skills/sdlc-task/design/parts/calls.md +25 -0
  15. package/skills/sdlc-task/design/parts/change.md +27 -0
  16. package/skills/sdlc-task/design/parts/data.md +22 -0
  17. package/skills/sdlc-task/design/parts/done.md +23 -0
  18. package/skills/sdlc-task/design/parts/edges.md +24 -0
  19. package/skills/sdlc-task/design/parts/goals.md +27 -0
  20. package/skills/sdlc-task/design/parts/key.md +25 -0
  21. package/skills/sdlc-task/design/parts/migration.md +22 -0
  22. package/skills/sdlc-task/design/parts/order.md +24 -0
  23. package/skills/sdlc-task/design/parts/problem.md +22 -0
  24. package/skills/sdlc-task/design/parts/proof.md +24 -0
  25. package/skills/sdlc-task/design/parts/proposal.md +24 -0
  26. package/skills/sdlc-task/design/parts/records.md +24 -0
  27. package/skills/sdlc-task/design/parts/repos.md +25 -0
  28. package/skills/sdlc-task/design/parts/risks.md +24 -0
  29. package/skills/sdlc-task/design/parts/rollout.md +24 -0
  30. package/skills/sdlc-task/design/parts/routes.md +24 -0
  31. package/skills/sdlc-task/design/parts/scorecard.md +25 -0
  32. package/skills/sdlc-task/design/parts/security.md +22 -0
  33. package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
  34. package/skills/sdlc-task/design/parts/states.md +25 -0
  35. package/skills/sdlc-task/design/parts/stories.md +26 -0
  36. package/skills/sdlc-task/design/parts/summary.md +33 -0
  37. package/skills/sdlc-task/design/parts/why.md +22 -0
  38. package/skills/sdlc-task/design/parts/words.md +29 -0
  39. package/skills/sdlc-task/design/parts/yardstick.md +25 -0
  40. package/skills/sdlc-task/design/parts.yaml +306 -0
  41. package/skills/sdlc-task/lifecycle.yaml +2 -2
  42. package/work/lib/verbs.mjs +99 -14
  43. package/work/mcp.mjs +1 -1
  44. package/agents/artifact-format/walkthrough.html +0 -706
  45. package/skills/sdlc-task/templates/design-doc.md +0 -126
@@ -31,6 +31,32 @@ export const DRIVERS = Object.freeze(['owner-led', 'agent']);
31
31
  // rows. "item" names another work item by id.
32
32
  export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'item', 'gate', 'nothing']);
33
33
  const MAX_EDITS = 20;
34
+ // The tags that other verbs own (work item additions, slice 1). item_edit
35
+ // never adds or removes them; item_propose drops them from the caller's list.
36
+ export const OWNED_TAG_PREFIXES = Object.freeze(['product:', 'repo:', 'tier:']);
37
+ export const OWNED_TAGS = Object.freeze(['atlas-work', 'no-model', 'atlas-registry']);
38
+ // Engram reads no-model with the spaces trimmed and in any case, so the check
39
+ // does the same: No-Model is owned too.
40
+ export const isOwnedTag = (tag) => {
41
+ const plain = String(tag).trim().toLowerCase();
42
+ return OWNED_TAGS.includes(plain) || OWNED_TAG_PREFIXES.some((prefix) => plain.startsWith(prefix));
43
+ };
44
+ // The planned dates the owner may set on an item (work item additions, slice 1).
45
+ export const PLAN_DATES = Object.freeze(['start', 'end', 'deadline']);
46
+ // A planned date is a calendar day. Date.parse takes 2026-02-30 and moves it
47
+ // to 2 March, so the day must read back the same.
48
+ const planDate = (value, name) => {
49
+ 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`);
50
+ const parsed = new Date(`${value}T00:00:00Z`);
51
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) refuse(`${name} is not a real date: ${value}`);
52
+ return value;
53
+ };
54
+ const tagList = (value, name) => stringList(value, name, { max: 16 }).map((tag) => {
55
+ text(tag, name, { max: 100 });
56
+ if (tag !== tag.trim()) refuse(`${name} holds a tag with spaces at its start or end: "${tag}"`);
57
+ 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'}`); }
58
+ return tag;
59
+ });
34
60
  export const LINK_KINDS = Object.freeze(['pr', 'issue', 'artifact', 'design-doc', 'conversation', 'research', 'slack', 'drive', 'other']);
35
61
  export const AUTHORITY_FIELDS = Object.freeze(['writer', 'session', 'actor', 'owner_present', 'agent', 'token_id']);
36
62
  const MAX_CHILDREN = 32;
@@ -144,7 +170,10 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
144
170
  const next = typeof fields === 'function' ? fields(current) : fields;
145
171
  const receipts = { ...(current.fields?.atlas_receipts ?? {}), [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } };
146
172
  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])];
173
+ // tags as a function replaces the list with what it returns, computed
174
+ // from the newest record; a list is added to the tags already there.
175
+ if (typeof tags === 'function') body.tags = [...new Set(tags(current))];
176
+ else if (tags) body.tags = [...new Set([...(current.tags ?? []), ...tags])];
148
177
  if (title) body.title = title;
149
178
  try { await client.updateEntity(current.id, body); return client.getEntity(current.id); }
150
179
  catch (error) {
@@ -360,7 +389,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
360
389
  const type = await typeNamed('container');
361
390
  const prior = await landed(type, requestId, meta);
362
391
  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)) : [])] });
392
+ 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
393
  return { verb: 'item_propose', item_id: entity.id, version: entity.version, lifecycle: 'proposed', stage: 'capture', product: fields.product, repos };
365
394
  },
366
395
 
@@ -398,7 +427,25 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
398
427
  requireOwner('item split');
399
428
  const parent = await requireItem(args.item_id);
400
429
  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 };
430
+ // split_order is a piece's place in the parent's split_children. That
431
+ // list changes only through a function of the newest parent, so its
432
+ // order holds under a race and a retry. Each piece gets its number after
433
+ // the parent write lands (work item additions, slice 1).
434
+ const stampOrder = async (list, rows) => {
435
+ const out = [];
436
+ for (const row of rows) {
437
+ const order = list.indexOf(row.id);
438
+ const child = order >= 0 && row.fields?.split_order !== order ? await patch(row, 'item_split', requestId, { split_order: order }, { digest: meta?.digest }) : row;
439
+ out.push({ item_id: child.id, title: child.title, repos: child.fields?.repos ?? [], split_order: child.fields?.split_order ?? null });
440
+ }
441
+ return out;
442
+ };
443
+ if (alreadyPatched(parent, requestId, meta)) {
444
+ // Only this request's pieces, in the order of the call.
445
+ 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);
446
+ const children = await stampOrder(Array.isArray(parent.fields.split_children) ? parent.fields.split_children : [], mine);
447
+ return { verb: 'item_split', item_id: parent.id, version: parent.version, reason, children, replayed_from_graph: true };
448
+ }
402
449
  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
450
  if (!Array.isArray(args.children) || !args.children.length) refuse('children must list at least one piece: { title, origin?, repos?, tier?, next_step?, driver? }');
404
451
  if (args.children.length > MAX_CHILDREN) refuse(`children holds more than ${MAX_CHILDREN} pieces`);
@@ -421,14 +468,19 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
421
468
  if (!spec.origin) refuse('each child needs an origin in the owner\'s words when the parent has none');
422
469
  }
423
470
  const already = await findCreated(type, requestId);
424
- const children = [];
471
+ // split_index is the place in this call, so a retry finds its own
472
+ // pieces.
473
+ const created = [];
425
474
  for (const [index, spec] of specs.entries()) {
426
- const existing = already.find((row) => row.fields?.split_index === index);
475
+ const existing = already.find((row) => row.fields?.split_index === index && row.fields?.split_from === parent.id);
427
476
  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
477
  await edge(child, parent, 'spun_out_of', requestId, { reason });
429
- children.push({ item_id: child.id, title: spec.title, repos: spec.repos });
478
+ created.push(child);
430
479
  }
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 });
480
+ // A function of the newest parent, so a split that lands at the same
481
+ // moment keeps its pieces.
482
+ 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 });
483
+ const children = await stampOrder(entity.fields?.split_children ?? [], created);
432
484
  return { verb: 'item_split', item_id: entity.id, version: entity.version, reason, children };
433
485
  },
434
486
 
@@ -479,10 +531,12 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
479
531
  // The next step and the wait are the lead's to keep true, so any
480
532
  // in-session caller may change them. The title is the owner's words, so a
481
533
  // new title needs the owner present. Each edit keeps the old values.
534
+ // Work item additions, slice 1: tags_add and tags_remove change the free
535
+ // tags, and plan holds the owner's planned start, end and deadline.
482
536
  async item_edit(args, requestId, meta) {
483
537
  const item = await requireItem(args.item_id);
484
538
  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 };
539
+ 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
540
  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
541
  live(item);
488
542
  const change = {}, previous = {};
@@ -511,16 +565,47 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
511
565
  change.waits_on = wait;
512
566
  previous.waits_on = fields.waits_on ?? null;
513
567
  }
514
- if (args.title === undefined && !Object.keys(change).length) refuse('item edit needs title, next_step or waits_on');
568
+ const tagsAdd = args.tags_add === undefined ? [] : tagList(args.tags_add, 'tags_add');
569
+ const tagsRemove = args.tags_remove === undefined ? [] : tagList(args.tags_remove, 'tags_remove');
570
+ const both = tagsAdd.filter((tag) => tagsRemove.includes(tag));
571
+ if (both.length) refuse(`a tag cannot be added and removed in one edit: ${both.join(', ')}`);
572
+ const tagsOf = (current) => [...new Set([...(current.tags ?? []).filter((tag) => !tagsRemove.includes(tag)), ...tagsAdd])];
573
+ const sameList = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]);
574
+ let tagEdit = tagsAdd.length || tagsRemove.length;
575
+ let planGiven = null;
576
+ if (args.plan !== undefined) {
577
+ requireOwner('item edit of planned dates');
578
+ 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`);
579
+ const unknown = Object.keys(args.plan).filter((key) => !PLAN_DATES.includes(key));
580
+ if (unknown.length) refuse(`plan holds only ${PLAN_DATES.join(', ')}; not ${unknown.join(', ')}`);
581
+ if (!Object.keys(args.plan).length) refuse(`plan must name at least one of ${PLAN_DATES.join(', ')}`);
582
+ planGiven = Object.fromEntries(Object.entries(args.plan).map(([key, value]) => [key, value === null ? null : planDate(value, `plan.${key}`)]));
583
+ }
584
+ // The dates merge with the ones on the newest record; a null clears one.
585
+ const planOf = (current) => {
586
+ const merged = { ...(current.fields?.plan ?? {}), ...planGiven };
587
+ for (const key of Object.keys(merged)) if (merged[key] === null) delete merged[key];
588
+ if (merged.start && merged.end && merged.end < merged.start) refuse(`the planned end ${merged.end} is before the planned start ${merged.start}`);
589
+ return Object.keys(merged).length ? merged : null;
590
+ };
591
+ const samePlan = (a, b) => JSON.stringify(Object.entries(a ?? {}).sort()) === JSON.stringify(Object.entries(b ?? {}).sort());
592
+ 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');
593
+ // A tag or plan edit that changes nothing is left out. When nothing is
594
+ // left, the item is not written and the history gets no empty entry.
595
+ if (planGiven && samePlan(planOf(item), fields.plan)) planGiven = null;
596
+ if (tagEdit && sameList(tagsOf(item), item.tags ?? [])) tagEdit = false;
597
+ 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
598
  const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
516
- const entry = { at: now(), by: label.agent, previous, ...(reason ? { reason } : {}) };
599
+ const at = now();
517
600
  // 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.
601
+ // owner gave is kept on its own, so a title never falls out of it. The
602
+ // old tags and dates come from the record the write lands on.
603
+ const entryOf = (current) => ({ at, by: label.agent, previous: { ...previous, ...(tagEdit ? { tags: current.tags ?? [] } : {}), ...(planGiven ? { plan: current.fields?.plan ?? null } : {}) }, ...(reason ? { reason } : {}) });
519
604
  const entity = await patch(item, 'item_edit', requestId, (current) => ({
520
- ...change, edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), entry].slice(-MAX_EDITS),
605
+ ...change, ...(planGiven ? { plan: planOf(current) } : {}), edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), entryOf(current)].slice(-MAX_EDITS),
521
606
  ...(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)] };
607
+ }), { digest: meta?.digest, guard: live, ...(title ? { title } : {}), ...(tagEdit ? { tags: tagsOf } : {}) });
608
+ 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'] : [])] };
524
609
  },
525
610
 
526
611
  async item_stage(args, requestId, meta) {
package/work/mcp.mjs CHANGED
@@ -51,7 +51,7 @@ 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(', ')}.`), 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 } },
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
56
  { 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
57
  { 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 } },