@arjunkhera/atlas 0.3.9 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/work/lib/verbs.mjs +99 -14
- package/work/mcp.mjs +1 -1
package/package.json
CHANGED
package/work/lib/verbs.mjs
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
+
created.push(child);
|
|
430
479
|
}
|
|
431
|
-
|
|
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
|
-
|
|
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
|
|
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 : []),
|
|
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
|
|
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 } },
|