@arjunkhera/atlas 0.3.10 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/sdlc-task/SKILL.md +2 -0
- package/work/lib/activity.mjs +246 -0
- package/work/lib/capability.mjs +9 -3
- package/work/lib/delivery.mjs +13 -3
- package/work/lib/questions.mjs +40 -1
- package/work/lib/verbs.mjs +149 -35
- package/work/{manifest-0.6.0.json → manifest-0.7.0.json} +74 -2
- package/work/mcp.mjs +6 -2
package/package.json
CHANGED
|
@@ -33,6 +33,8 @@ 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` |
|
|
37
|
+
| Keep a note on it, or read what happened on it | `item_comment`, `item_activity` |
|
|
36
38
|
| Ask the owner something, or record the answer | `question_ask`, `question_answer` |
|
|
37
39
|
| Record a decision in the owner's words | `decision_record` |
|
|
38
40
|
| Mark it delivered, or reopen it | `item_done`, `item_reopen` |
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Work item additions, slice 3: the log of a work item.
|
|
2
|
+
// The design: the work item additions, steps 5, 6, 8 and 9.
|
|
3
|
+
//
|
|
4
|
+
// One record type, entry, holds both a comment and a change. Each entry
|
|
5
|
+
// carries the tag item:<id>, so one list by type and tag finds every entry of
|
|
6
|
+
// an item: no scan of the tenant and no edge walk.
|
|
7
|
+
//
|
|
8
|
+
// The item verbs put a short diff into their own receipt on the item, in the
|
|
9
|
+
// same write (verbs.mjs, patch). After the verb, logEntries copies the diff
|
|
10
|
+
// into an entry. That second write is best effort: a failure is a warning,
|
|
11
|
+
// the diff stays in the receipt, and the next write on the item tries again.
|
|
12
|
+
import { VerbError, refuse, text, oneOf, LIVE_LIFECYCLES } from './verb-fields.mjs';
|
|
13
|
+
import { oneLine, scrubSecrets } from './questions.mjs';
|
|
14
|
+
|
|
15
|
+
export const ACTIVITY_WRITE_VERBS = Object.freeze(['item_comment']);
|
|
16
|
+
export const ACTIVITY_READ_VERBS = Object.freeze(['item_activity']);
|
|
17
|
+
export const VOICES = Object.freeze(['agent', 'owner']);
|
|
18
|
+
// The item verbs whose writes carry a diff (step 9). item_propose writes a
|
|
19
|
+
// "created" diff when it makes the item.
|
|
20
|
+
export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen', 'item_adopt']);
|
|
21
|
+
export const ENTRY_VERBS = Object.freeze(['item_propose', ...LOG_VERBS]);
|
|
22
|
+
const MAX_COMMENT = 4000;
|
|
23
|
+
const PREVIEW = 300;
|
|
24
|
+
// How many missing entries one write repairs, beside its own. Only a diff
|
|
25
|
+
// older than REPAIR_AFTER is repaired, so a write running at the same moment
|
|
26
|
+
// on the same item copies its own diff and no other writer copies it too.
|
|
27
|
+
const MAX_REPAIR = 4;
|
|
28
|
+
const REPAIR_AFTER_MS = 120_000;
|
|
29
|
+
const MAX_LIST_ITEMS = 20;
|
|
30
|
+
// Bookkeeping fields and stamps: the entry has its own time, so these are
|
|
31
|
+
// left out of the diff.
|
|
32
|
+
const NOT_LOGGED = new Set(['writer', 'request_id', 'atlas_receipts', 'edits', 'first_title', 'stage_changed_at', 'parked_at', 'resumed_at', 'undropped_at', 'dropped_at']);
|
|
33
|
+
export const itemTag = (id) => `item:${id}`;
|
|
34
|
+
|
|
35
|
+
// One value as the log keeps it. A text, number, true, false or null up to
|
|
36
|
+
// 300 characters stays whole. A longer text, a list or an object becomes a
|
|
37
|
+
// 300-character preview with cut. The secret filter runs on every value.
|
|
38
|
+
function shown(value) {
|
|
39
|
+
if (value === undefined) return { value: null, cut: false };
|
|
40
|
+
if (value === null || typeof value === 'number' || typeof value === 'boolean') return { value, cut: false };
|
|
41
|
+
const whole = typeof value === 'string' ? value : JSON.stringify(value);
|
|
42
|
+
// Cut by code point: half an emoji makes Postgres refuse the whole write.
|
|
43
|
+
const chars = Array.from(scrubSecrets(whole).text);
|
|
44
|
+
if (typeof value === 'string' && chars.length <= PREVIEW) return { value: chars.join(''), cut: false };
|
|
45
|
+
return { value: chars.length <= PREVIEW ? chars.join('') : `${chars.slice(0, PREVIEW - 1).join('')}…`, cut: true };
|
|
46
|
+
}
|
|
47
|
+
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
48
|
+
const isList = (value) => Array.isArray(value) || value === undefined || value === null;
|
|
49
|
+
// A list is kept as the members it gained and lost, each shown on its own,
|
|
50
|
+
// so a member at the end of a long list is never cut away.
|
|
51
|
+
function pair(from, to) {
|
|
52
|
+
if (isList(from) && isList(to) && (Array.isArray(from) || Array.isArray(to))) {
|
|
53
|
+
const before = from ?? [], after = to ?? [];
|
|
54
|
+
const gone = before.filter((row) => !after.some((other) => same(row, other)));
|
|
55
|
+
const come = after.filter((row) => !before.some((other) => same(row, other)));
|
|
56
|
+
const list = (rows) => rows.slice(0, MAX_LIST_ITEMS).map((row) => shown(row));
|
|
57
|
+
const added = list(come), removed = list(gone);
|
|
58
|
+
const cut = come.length > MAX_LIST_ITEMS || gone.length > MAX_LIST_ITEMS || [...added, ...removed].some((row) => row.cut);
|
|
59
|
+
return { added: added.map((row) => row.value), removed: removed.map((row) => row.value), ...(cut ? { cut: true } : {}) };
|
|
60
|
+
}
|
|
61
|
+
const a = shown(from), b = shown(to);
|
|
62
|
+
return { from: a.value, to: b.value, ...(a.cut || b.cut ? { cut: true } : {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The diff of one write: each field it changes, as from and to. The title
|
|
66
|
+
// and the tags count as fields.
|
|
67
|
+
export function diffOf(current, next, { title, tags } = {}) {
|
|
68
|
+
const change = {};
|
|
69
|
+
for (const [key, value] of Object.entries(next ?? {})) {
|
|
70
|
+
if (NOT_LOGGED.has(key)) continue;
|
|
71
|
+
if (!same(current?.fields?.[key], value)) change[key] = pair(current?.fields?.[key], value);
|
|
72
|
+
}
|
|
73
|
+
if (title !== undefined && title !== current?.title) change.title = pair(current?.title ?? null, title);
|
|
74
|
+
if (tags !== undefined && !same(current?.tags ?? [], tags)) change.tags = pair(current?.tags ?? [], tags);
|
|
75
|
+
return change;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createActivityVerbs(core) {
|
|
79
|
+
const { client, label, now, typeNamed, requireItem, requireOwner, create, alreadyPatched } = core;
|
|
80
|
+
|
|
81
|
+
// The entry type, or null before the owner has run setup for 0.7.0.
|
|
82
|
+
async function entryType() {
|
|
83
|
+
try { return await typeNamed('entry'); }
|
|
84
|
+
catch (error) { if (error instanceof VerbError) return null; throw error; }
|
|
85
|
+
}
|
|
86
|
+
async function entriesOf(type, itemId) {
|
|
87
|
+
const rows = await client.listAllEntities({ type: type.name, tag: itemTag(itemId) });
|
|
88
|
+
return rows.filter((row) => row.fields?.item === itemId);
|
|
89
|
+
}
|
|
90
|
+
// The newest entries of an item, one page. An item holds at most 100
|
|
91
|
+
// receipts, so only its newest entries can match one.
|
|
92
|
+
async function recentEntries(type, itemId) {
|
|
93
|
+
if (typeof client.listEntities !== 'function') return entriesOf(type, itemId);
|
|
94
|
+
const page = await client.listEntities({ type: type.name, tag: itemTag(itemId), order: 'created_at_desc', limit: 200 });
|
|
95
|
+
return (Array.isArray(page?.entities) ? page.entities : []).filter((row) => row.fields?.item === itemId);
|
|
96
|
+
}
|
|
97
|
+
const changeIds = (rows) => new Set(rows.filter((row) => row.fields?.kind === 'change').map((row) => row.fields.request_id));
|
|
98
|
+
// The request ids that already have a change entry on this item. A failed
|
|
99
|
+
// read never stops the item write: the trim then goes by size only.
|
|
100
|
+
async function loggedIds(itemId) {
|
|
101
|
+
try {
|
|
102
|
+
const type = await entryType();
|
|
103
|
+
return type ? changeIds(await recentEntries(type, itemId)) : new Set();
|
|
104
|
+
} catch { return new Set(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Copy the diff of this request into an entry, and repair a few older ones
|
|
108
|
+
// that never landed. Returns a warning, or null. It never throws: the item
|
|
109
|
+
// write already landed, so the verb succeeds.
|
|
110
|
+
async function logEntries(itemId, requestId) {
|
|
111
|
+
const keep = 'The values stay in the item\'s receipt, and the next write on the item tries again.';
|
|
112
|
+
try {
|
|
113
|
+
const type = await entryType();
|
|
114
|
+
if (!type) return `no log entry was written: the entry type is not registered, so the owner runs setup for manifest 0.7.0. ${keep}`;
|
|
115
|
+
const item = await client.getEntity(itemId);
|
|
116
|
+
const receipts = item.fields?.atlas_receipts ?? {};
|
|
117
|
+
if (!receipts[requestId]?.change) return null;
|
|
118
|
+
const have = changeIds(await recentEntries(type, item.id));
|
|
119
|
+
const cutoff = Date.parse(now()) - REPAIR_AFTER_MS;
|
|
120
|
+
const older = Object.entries(receipts).filter(([id, receipt]) => id !== requestId && receipt?.change && !have.has(id) && Date.parse(receipt.at) < cutoff)
|
|
121
|
+
.sort(([, a], [, b]) => String(b.at ?? '').localeCompare(String(a.at ?? ''))).slice(0, MAX_REPAIR);
|
|
122
|
+
const pending = [...(have.has(requestId) ? [] : [[requestId, receipts[requestId]]]), ...older];
|
|
123
|
+
for (const [id, receipt] of pending) {
|
|
124
|
+
await create(type, id, receipt.verb, {
|
|
125
|
+
title: oneLine(`${receipt.verb} · ${item.title ?? item.id}`, 200),
|
|
126
|
+
fields: { item: item.id, ...(item.fields?.product ? { product: item.fields.product } : {}), kind: 'change', verb: receipt.verb, change: receipt.change, ...(receipt.note ? { text: receipt.note } : {}), at: receipt.at },
|
|
127
|
+
tags: [itemTag(item.id), ...(item.fields?.product ? [`product:${item.fields.product}`] : [])],
|
|
128
|
+
readBack: false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return `the log entry was not written: ${error?.message ?? String(error)}. ${keep}`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const verbs = {
|
|
138
|
+
// A comment on an item, from the lead or in the owner's words.
|
|
139
|
+
async item_comment(args, requestId, meta) {
|
|
140
|
+
const item = await requireItem(args.item_id);
|
|
141
|
+
const voice = oneOf(args.voice, 'voice', VOICES, 'agent');
|
|
142
|
+
if (voice === 'owner') requireOwner('item comment in the owner\'s voice');
|
|
143
|
+
const words = text(args.text, 'text', { max: MAX_COMMENT });
|
|
144
|
+
const type = await typeNamed('entry');
|
|
145
|
+
const entries = await entriesOf(type, item.id);
|
|
146
|
+
const prior = entries.find((row) => row.fields?.request_id === requestId);
|
|
147
|
+
if (prior && alreadyPatched(prior, requestId, meta)) return { verb: 'item_comment', item_id: item.id, entry_id: prior.id, voice: prior.fields.voice, redactions: prior.fields.redactions ?? 0, ...(prior.fields.corrects ? { corrects: prior.fields.corrects } : {}), replayed_from_graph: true };
|
|
148
|
+
if (!LIVE_LIFECYCLES.includes(item.fields?.lifecycle)) refuse(`item ${item.id} is ${item.fields?.lifecycle}; only a proposed, active or paused item takes a comment`);
|
|
149
|
+
let corrects;
|
|
150
|
+
if (args.corrects !== undefined) {
|
|
151
|
+
text(args.corrects, 'corrects', { max: 200 });
|
|
152
|
+
const old = entries.find((row) => row.id === args.corrects && row.fields?.kind === 'comment');
|
|
153
|
+
if (!old) refuse(`corrects must name a comment on item ${item.id}`);
|
|
154
|
+
corrects = old.id;
|
|
155
|
+
}
|
|
156
|
+
const clean = scrubSecrets(words);
|
|
157
|
+
const entity = await create(type, requestId, 'item_comment', {
|
|
158
|
+
digest: meta?.digest, title: oneLine(`comment · ${item.title ?? item.id}`, 200), body: clean.text,
|
|
159
|
+
fields: { item: item.id, ...(item.fields?.product ? { product: item.fields.product } : {}), kind: 'comment', voice, text: clean.text, ...(corrects ? { corrects } : {}), redactions: clean.redactions, at: now() },
|
|
160
|
+
tags: [itemTag(item.id), ...(item.fields?.product ? [`product:${item.fields.product}`] : [])],
|
|
161
|
+
});
|
|
162
|
+
return { verb: 'item_comment', item_id: item.id, entry_id: entity.id, voice, redactions: clean.redactions, ...(corrects ? { corrects } : {}) };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// The feed of one item, newest first: its entries, the receipts that have
|
|
166
|
+
// no entry yet, and its questions, decisions, delivery marks, pieces and
|
|
167
|
+
// links.
|
|
168
|
+
async item_activity(args) {
|
|
169
|
+
const item = await requireItem(args.item_id);
|
|
170
|
+
const limit = args.limit === undefined ? 50 : Number(args.limit);
|
|
171
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) refuse('limit must be a whole number from 1 to 200');
|
|
172
|
+
const fields = item.fields ?? {};
|
|
173
|
+
const rows = [];
|
|
174
|
+
const type = await entryType();
|
|
175
|
+
const entries = type ? await entriesOf(type, item.id) : [];
|
|
176
|
+
const logged = new Set();
|
|
177
|
+
for (const row of entries) {
|
|
178
|
+
const f = row.fields ?? {};
|
|
179
|
+
if (f.kind === 'comment') rows.push({ at: f.at, kind: 'comment', entry_id: row.id, voice: f.voice, text: f.text, ...(f.corrects ? { corrects: f.corrects } : {}) });
|
|
180
|
+
else if (f.kind === 'change') {
|
|
181
|
+
// Two writers can copy the same diff; the feed shows it once.
|
|
182
|
+
if (logged.has(f.request_id)) continue;
|
|
183
|
+
logged.add(f.request_id);
|
|
184
|
+
rows.push({ at: f.at, kind: 'change', entry_id: row.id, verb: f.verb, change: f.change ?? {}, ...(f.text ? { note: f.text } : {}), values_kept: true });
|
|
185
|
+
} else rows.push({ at: f.at, kind: 'entry', entry_id: row.id, entry_kind: String(f.kind ?? '') });
|
|
186
|
+
}
|
|
187
|
+
for (const [id, receipt] of Object.entries(fields.atlas_receipts ?? {})) {
|
|
188
|
+
if (logged.has(id) || !receipt?.verb) continue;
|
|
189
|
+
rows.push(receipt.change
|
|
190
|
+
? { at: receipt.at, kind: 'change', verb: receipt.verb, change: receipt.change, ...(receipt.note ? { note: receipt.note } : {}), values_kept: true, from_receipt: true }
|
|
191
|
+
: { at: receipt.at, kind: 'change', verb: receipt.verb, values_kept: false });
|
|
192
|
+
}
|
|
193
|
+
// The history kept on the item before slice 3 stays readable.
|
|
194
|
+
for (const edit of Array.isArray(fields.edits) ? fields.edits : []) rows.push({ at: edit.at, kind: 'edit', previous: edit.previous ?? {}, ...(edit.reason ? { note: edit.reason } : {}) });
|
|
195
|
+
const optional = async (name) => { try { return await typeNamed(name); } catch (error) { if (error instanceof VerbError) return null; throw error; } };
|
|
196
|
+
const listOf = async (name) => { const t = await optional(name); return t ? client.listAllEntities({ type: t.name }) : []; };
|
|
197
|
+
for (const row of await listOf('question')) {
|
|
198
|
+
if (row.fields?.item !== item.id) continue;
|
|
199
|
+
rows.push({ at: row.fields.asked_at, kind: 'question', question_id: row.id, status: row.fields.status, text: oneLine(row.fields.text ?? '', PREVIEW) });
|
|
200
|
+
}
|
|
201
|
+
for (const row of await listOf('decision')) {
|
|
202
|
+
if (!Array.isArray(row.fields?.items) || !row.fields.items.includes(item.id)) continue;
|
|
203
|
+
// A decision holds a day only; the record's own time places it.
|
|
204
|
+
rows.push({ at: row.created_at ?? row.fields.date, kind: 'decision', decision_id: row.id, decision_kind: row.fields.kind, text: oneLine(row.fields.text ?? '', PREVIEW) });
|
|
205
|
+
}
|
|
206
|
+
for (const row of await listOf('mark')) {
|
|
207
|
+
if (row.fields?.item !== item.id) continue;
|
|
208
|
+
rows.push({ at: row.fields.marked_at, kind: 'mark', mark_id: row.id, mark_kind: row.fields.kind, verdict: row.fields.verdict ?? null });
|
|
209
|
+
}
|
|
210
|
+
const pieces = await Promise.all((Array.isArray(fields.split_children) ? fields.split_children : []).map((id) => client.getEntity(id).catch(() => null)));
|
|
211
|
+
for (const piece of pieces) {
|
|
212
|
+
if (!piece) continue;
|
|
213
|
+
// The piece's date is the split that made it, not its last write.
|
|
214
|
+
const made = Object.values(piece.fields?.atlas_receipts ?? {}).filter((receipt) => receipt?.verb === 'item_split').map((receipt) => receipt.at).sort()[0] ?? piece.created_at ?? null;
|
|
215
|
+
rows.push({ at: made, kind: 'piece', item: piece.id, title: oneLine(piece.title ?? '', 200), split_order: piece.fields?.split_order ?? null });
|
|
216
|
+
}
|
|
217
|
+
for (const link of Array.isArray(fields.links) ? fields.links : []) rows.push({ at: link.added_at, kind: 'link', link_kind: link.kind, url: link.url, ...(link.role ? { role: link.role } : {}) });
|
|
218
|
+
rows.sort((a, b) => String(b.at ?? '').localeCompare(String(a.at ?? '')));
|
|
219
|
+
const shownRows = rows.slice(0, limit);
|
|
220
|
+
return { verb: 'item_activity', item_id: item.id, title: item.title, total: rows.length, rows: shownRows, lines: shownRows.map(lineOf),
|
|
221
|
+
...(type ? {} : { note: 'the entry type is not registered yet; the owner runs setup for manifest 0.7.0. The feed reads the receipts on the item.' }) };
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
return { verbs, logEntries, loggedIds };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// One row as one line, for the lead to show.
|
|
228
|
+
function lineOf(row) {
|
|
229
|
+
const when = String(row.at ?? '').replace('T', ' ').slice(0, 16) || 'no date';
|
|
230
|
+
const one = (value) => (value === null || value === undefined ? '—' : typeof value === 'string' ? value : JSON.stringify(value));
|
|
231
|
+
const values = (change) => Object.entries(change ?? {}).map(([key, value]) => (Array.isArray(value?.added)
|
|
232
|
+
? `${key}: ${[...value.added.map((row) => `+${one(row)}`), ...value.removed.map((row) => `−${one(row)}`)].join(', ')}`
|
|
233
|
+
: `${key}: ${one(value?.from)} → ${one(value?.to)}`)).join('; ');
|
|
234
|
+
switch (row.kind) {
|
|
235
|
+
case 'comment': return oneLine(`${when} comment (${row.voice}) "${row.text}"`);
|
|
236
|
+
case 'change': return oneLine(row.values_kept ? `${when} ${row.verb} ${values(row.change) || 'no field changed'}` : `${when} ${row.verb} no values recorded`);
|
|
237
|
+
case 'edit': return oneLine(`${when} edit was ${Object.entries(row.previous ?? {}).map(([key, value]) => `${key}: ${one(value)}`).join('; ')}${row.note ? ` · ${row.note}` : ''}`);
|
|
238
|
+
case 'entry': return oneLine(`${when} entry of kind ${row.entry_kind}`);
|
|
239
|
+
case 'question': return oneLine(`${when} question (${row.status}) ${row.text}`);
|
|
240
|
+
case 'decision': return oneLine(`${when} decision (${row.decision_kind}) "${row.text}"`);
|
|
241
|
+
case 'mark': return oneLine(`${when} mark ${row.mark_kind}${row.verdict ? `: ${row.verdict}` : ''}`);
|
|
242
|
+
case 'piece': return oneLine(`${when} piece ${row.title} · ${row.item}`);
|
|
243
|
+
case 'link': return oneLine(`${when} link (${row.link_kind}) ${row.url}`);
|
|
244
|
+
default: return oneLine(`${when} ${row.kind}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
package/work/lib/capability.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// scrubs by pattern. The rendered answer is scrubbed once more before it
|
|
16
16
|
// leaves the door (R12).
|
|
17
17
|
import { refuse, text, isoDate, stringList } from './verb-fields.mjs';
|
|
18
|
-
import { oneLine, scrubSecrets, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
|
|
18
|
+
import { oneLine, scrubSecrets, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
|
|
19
19
|
import { listLine, legacySealOf, marksByItem, movementOf, pullsOfItem, readPullsByRepo, correctionsOf, daysBetween } from './delivery.mjs';
|
|
20
20
|
import { acceptCodeRead, codeReadBrief } from './code-read.mjs';
|
|
21
21
|
|
|
@@ -391,6 +391,9 @@ export function createCapabilityVerbs(core) {
|
|
|
391
391
|
const unregisteredTotal = rows.filter(unsorted).length;
|
|
392
392
|
const unregisteredShown = items.filter(unsorted).length;
|
|
393
393
|
const marks = await marksFor(new Set(items.map((row) => row.id)));
|
|
394
|
+
// Blocked-by links read from every item loaded, so a blocker of another
|
|
395
|
+
// product still names its state (work item additions, slice 2).
|
|
396
|
+
const itemById = new Map(rows.map((row) => [row.id, row]));
|
|
394
397
|
const readAt = now();
|
|
395
398
|
let redactions = 0;
|
|
396
399
|
const clean = (value, max = 200) => { const out = scrubSecrets(oneLine(value, max)); redactions += out.redactions; return out.text; };
|
|
@@ -437,7 +440,10 @@ export function createCapabilityVerbs(core) {
|
|
|
437
440
|
}
|
|
438
441
|
const doneAt = fields.delivered_at ?? item.updated_at ?? item.created_at ?? null;
|
|
439
442
|
if (fields.lifecycle === 'done' && !deliveries.length && !(since && String(doneAt ?? '') < since)) built.push({ ...base, mark_id: null, verdict: 'done, no delivery mark', delivered_at: doneAt, prs: [], source: 'no mark yet', note: 'the item record says done; the done verb writes the mark' });
|
|
440
|
-
const
|
|
443
|
+
const shown = waitsShown(fields);
|
|
444
|
+
const blockers = blockersOf(item, (id) => itemById.get(id));
|
|
445
|
+
const blocks = blocksOf(item, rows);
|
|
446
|
+
const waits = { ...(shown && typeof shown === 'object' ? { waits_on: shown } : {}), ...(blockers.length ? { blocked_by: blockers } : {}), ...(blocks.length ? { blocks } : {}) };
|
|
441
447
|
if (fields.lifecycle === 'active') building.push({ ...base, ...waits, stage: clean(fields.stage ?? '', 64), driver: clean(fields.driver ?? '', 64), next_step: clean(fields.next_step ?? '', 200), days_since_last_change: days, last_change_source: clean(last?.source ?? 'nothing recorded', 120), delivery_state: fields.delivery_state ? clean(fields.delivery_state, 64) : null });
|
|
442
448
|
else if (fields.lifecycle === 'paused') pending.push({ ...base, ...waits, reason: clean(fields.pause_reason ?? 'no reason on record', 300), days_since_last_change: days, parked_at: fields.parked_at ?? null });
|
|
443
449
|
else if (fields.lifecycle === 'proposed') {
|
|
@@ -467,7 +473,7 @@ export function createCapabilityVerbs(core) {
|
|
|
467
473
|
building.sort(byPriority(orders, byDays));
|
|
468
474
|
pending.sort(byPriority(orders, byDays));
|
|
469
475
|
possible.sort(byPriority(orders, (a, b) => (b.age_days ?? 0) - (a.age_days ?? 0)));
|
|
470
|
-
const waitsOf = (row) =>
|
|
476
|
+
const waitsOf = (row) => `${row.waits_on ? ` · ${waitsOnLine(row.waits_on)}` : ''}${blockedPhrase(row.blocked_by ?? [], row.blocks ?? []) ? ` · ${blockedPhrase(row.blocked_by ?? [], row.blocks ?? [])}` : ''}`;
|
|
471
477
|
|
|
472
478
|
// D3 rule 2: a row names its product. The board does not group, so every
|
|
473
479
|
// line carries the id (review H6).
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// 3. Movement is read, never stored. The view computes it at question time
|
|
14
14
|
// from the marks and from the repos' pull requests (D3 rule 5, R1).
|
|
15
15
|
import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS } from './verb-fields.mjs';
|
|
16
|
-
import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
|
|
16
|
+
import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
|
|
17
17
|
|
|
18
18
|
export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
|
|
19
19
|
export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
|
|
@@ -544,6 +544,9 @@ export function createDeliveryVerbs(core) {
|
|
|
544
544
|
if (items.length > MAX_ITEMS) refuse(`${items.length} items are registered; the view reads at most ${MAX_ITEMS}. Narrow it with a product id.`);
|
|
545
545
|
const byId = new Map(items.map((row) => [row.id, row]));
|
|
546
546
|
const marks = await marksOf(new Set(byId.keys()));
|
|
547
|
+
// Blocked-by links are read from every item the view loaded, in any
|
|
548
|
+
// lifecycle, so "blocks" and "cleared" need no extra call.
|
|
549
|
+
const anyById = new Map(all.map((row) => [row.id, row]));
|
|
547
550
|
|
|
548
551
|
// Movement from git: every registered repo of the products in view, its
|
|
549
552
|
// recent pull requests, once per repo and shared by every item.
|
|
@@ -587,6 +590,10 @@ export function createDeliveryVerbs(core) {
|
|
|
587
590
|
// D3 rule 1: in flight is active work. Delivered and paused items leave
|
|
588
591
|
// the rows; what was delivered since a date has its own list.
|
|
589
592
|
if (fields.lifecycle !== 'active') continue;
|
|
593
|
+
const blockers = blockersOf(item, (id) => anyById.get(id));
|
|
594
|
+
const blocks = blocksOf(item, all);
|
|
595
|
+
const blocked = blockedPhrase(blockers, blocks);
|
|
596
|
+
const waits = waitsShown(fields);
|
|
590
597
|
rows.push({
|
|
591
598
|
item: item.id, product: fields.product, stage: fields.stage ?? '', driver: fields.driver ?? '', days_since_last_change: days,
|
|
592
599
|
last_change_at: last?.at ?? null, last_change_source: last?.source ?? 'nothing recorded', delivery_state: state,
|
|
@@ -594,10 +601,13 @@ export function createDeliveryVerbs(core) {
|
|
|
594
601
|
// The id is what a reader types back, so the title gives way to it
|
|
595
602
|
// inside the line budget, as the "Needs me" row does (review G6).
|
|
596
603
|
oneLine(`${oneLine(item.title, Math.max(1, ROW_LINE_MAX - item.id.length - 3))} · ${item.id}`),
|
|
597
|
-
oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'}${waitsOnLine(
|
|
604
|
+
oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'}${waitsOnLine(waits) ? ` · ${waitsOnLine(waits)}` : ''} · next: ${fields.next_step || 'not set'}`),
|
|
598
605
|
oneLine(`${days === null ? 'no movement on record' : `${days} day${days === 1 ? '' : 's'} since last change`} (${last?.source ?? 'nothing recorded'}) · ${mine.length} pull request${mine.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} merged, not marked` : ''}${orderNote(orders, { product: fields.product, item: item.id })}`),
|
|
606
|
+
// The blockers get their own line, so a long list never cuts the
|
|
607
|
+
// next step off line two; they come last.
|
|
608
|
+
...(blocked ? [oneLine(blocked)] : []),
|
|
599
609
|
],
|
|
600
|
-
waits_on:
|
|
610
|
+
waits_on: waits, blocked_by: blockers, blocks,
|
|
601
611
|
folded: { title: item.title, next_step: fields.next_step ?? '', scope: fields.scope?.text ?? '', pieces: fields.split_children ?? [], prs: mine.map((pull) => ({ url: pull.url, state: pull.state, matched_by: pull.matched_by, merged_at: pull.merged_at, updated_at: pull.updated_at })), marks: own.map((mark) => ({ id: mark.id, kind: mark.fields.kind, verdict: mark.fields.verdict, at: mark.fields.marked_at })) },
|
|
602
612
|
citation: { item: item.id, read_at: readAt, repos: [...new Set(mine.map((pull) => pull.repo))] },
|
|
603
613
|
});
|
package/work/lib/questions.mjs
CHANGED
|
@@ -30,7 +30,10 @@ const MAX_RENDERED = 20_000;
|
|
|
30
30
|
|
|
31
31
|
export const oneLine = (value, max = ROW_LINE_MAX) => {
|
|
32
32
|
const flat = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
33
|
-
|
|
33
|
+
// Cut by code point, so a cut never leaves half an emoji: Postgres refuses
|
|
34
|
+
// a lone surrogate, and the whole write with it.
|
|
35
|
+
const chars = Array.from(flat);
|
|
36
|
+
return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : flat;
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
// R12: a scrub by pattern in receipt keep. The patterns are the ones the
|
|
@@ -98,6 +101,42 @@ export function waitsOnLine(waits) {
|
|
|
98
101
|
return `waits on ${what}${waits.item ? ` ${waits.item}` : ''}${waits.note ? `: ${waits.note}` : ''}`;
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
// Work item additions, slice 2: the items that block an item, the items it
|
|
105
|
+
// blocks, and one phrase for a row. lookup gives an item record by id, or
|
|
106
|
+
// undefined when the view did not load it. A blocker that is delivered or
|
|
107
|
+
// dropped no longer blocks; the row shows it as cleared.
|
|
108
|
+
export const CLEARED_LIFECYCLES = Object.freeze(['done', 'dropped']);
|
|
109
|
+
export function blockersOf(item, lookup) {
|
|
110
|
+
const list = Array.isArray(item?.fields?.blocked_by) ? item.fields.blocked_by : [];
|
|
111
|
+
return list.filter((entry) => entry && typeof entry.item === 'string').map((entry) => {
|
|
112
|
+
const other = lookup(entry.item);
|
|
113
|
+
// A blocker the view cannot find is archived or gone, so it blocks no more.
|
|
114
|
+
if (!other) return { item: entry.item, title: null, state: 'gone', cleared: true, ...(entry.note ? { note: entry.note } : {}) };
|
|
115
|
+
const lifecycle = other.fields?.lifecycle ?? 'unknown';
|
|
116
|
+
const cleared = CLEARED_LIFECYCLES.includes(lifecycle);
|
|
117
|
+
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 } : {}) };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Only a live item is still held up, so a delivered or dropped one leaves the
|
|
121
|
+
// "blocks" list.
|
|
122
|
+
export function blocksOf(item, all) {
|
|
123
|
+
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))
|
|
124
|
+
.map((other) => ({ item: other.id, title: oneLine(other.title, 60) }));
|
|
125
|
+
}
|
|
126
|
+
export function blockedPhrase(blockers, blocks) {
|
|
127
|
+
const parts = [];
|
|
128
|
+
if (blockers.length) parts.push(`blocked by: ${blockers.map((row) => `${row.title ?? row.item} (${row.state})`).join(', ')}`);
|
|
129
|
+
if (blocks.length) parts.push(`blocks: ${blocks.map((row) => row.title ?? row.item).join(', ')}`);
|
|
130
|
+
return parts.join(' · ');
|
|
131
|
+
}
|
|
132
|
+
// An old waits_on pointer to an item is hidden when a blocked_by link names
|
|
133
|
+
// the same item, so a row does not say it twice.
|
|
134
|
+
export function waitsShown(fields) {
|
|
135
|
+
const waits = fields?.waits_on;
|
|
136
|
+
if (waits?.on === 'item' && Array.isArray(fields?.blocked_by) && fields.blocked_by.some((entry) => entry?.item === waits.item)) return null;
|
|
137
|
+
return waits ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
101
140
|
// Row line two: the id is the citation a reader types back, so the title gives
|
|
102
141
|
// way to it, not the other way round (review G6).
|
|
103
142
|
export function itemLine(item, product, lifecycle = 'active') {
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -11,29 +11,61 @@
|
|
|
11
11
|
// lock block L1, L2 and L3. Every write carries request_id and writer. The
|
|
12
12
|
// server, not the caller, supplies the session. Unknown ids are refused.
|
|
13
13
|
// M2 slice L1 adds the repo kind in 0.5.0, which carries 0.4.0 whole.
|
|
14
|
-
// Stage 1 of the target design adds kinds, a list, in 0.6.0.
|
|
15
|
-
|
|
14
|
+
// Stage 1 of the target design adds kinds, a list, in 0.6.0. The work item
|
|
15
|
+
// additions add entry, the log of an item, in 0.7.0.
|
|
16
|
+
import manifestCurrent from '../manifest-0.7.0.json' with { type: 'json' };
|
|
16
17
|
import { EngramHttpError } from './client.mjs';
|
|
17
18
|
import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds, kindsFromArgs } from './verb-fields.mjs';
|
|
18
|
-
import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS } from './questions.mjs';
|
|
19
|
+
import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS, oneLine, scrubSecrets } from './questions.mjs';
|
|
19
20
|
import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
|
|
20
21
|
import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capability.mjs';
|
|
21
22
|
import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
|
|
23
|
+
import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
|
|
22
24
|
|
|
23
25
|
export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
|
|
24
|
-
export const MANIFEST_VERSION = '0.
|
|
26
|
+
export const MANIFEST_VERSION = '0.7.0';
|
|
25
27
|
export const PRODUCT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
26
28
|
export const REPO_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/;
|
|
27
29
|
export const STAGES = Object.freeze(['capture', 'spec', 'build', 'verify', 'learn']);
|
|
28
30
|
export const TIERS = Object.freeze(['hotfix', 'standard', 'initiative']);
|
|
29
31
|
export const DRIVERS = Object.freeze(['owner-led', 'agent']);
|
|
30
32
|
// What an item waits on (release 0.2.3). One line on the item, shown on its
|
|
31
|
-
// rows. "item"
|
|
32
|
-
|
|
33
|
-
const
|
|
33
|
+
// rows. Work item additions, slice 2: "item" retires for new writes, and
|
|
34
|
+
// item_block keeps the links. A record that already holds it still reads.
|
|
35
|
+
export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'gate', 'nothing']);
|
|
36
|
+
// How far the loop check of item_block walks before it stops.
|
|
37
|
+
const MAX_BLOCK_WALK = 200;
|
|
38
|
+
// Work item additions, slice 3. Engram refuses an entity whose fields hold
|
|
39
|
+
// more than 64 KiB of UTF-8 (entity-write-bounds.ts). An item keeps its
|
|
40
|
+
// newest MAX_RECEIPTS receipts. A diff leaves its receipt once its log entry
|
|
41
|
+
// exists. If the whole record is still past FIELDS_LIMIT, the oldest diffs go
|
|
42
|
+
// first, then the oldest receipts. The receipt of this request and the one
|
|
43
|
+
// that made the item are never dropped: a retry needs them.
|
|
44
|
+
const MAX_RECEIPTS = 100;
|
|
45
|
+
const FIELDS_LIMIT = 56 * 1024;
|
|
46
|
+
export function trimReceipts(fields, receipts, logged = new Set(), keep = null) {
|
|
47
|
+
const pinned = (id, receipt) => id === keep || receipt?.verb === 'item_propose';
|
|
48
|
+
let rows = Object.entries(receipts ?? {}).sort(([, a], [, b]) => String(a?.at ?? '').localeCompare(String(b?.at ?? '')));
|
|
49
|
+
while (rows.length > MAX_RECEIPTS) {
|
|
50
|
+
const index = rows.findIndex(([id, receipt]) => !pinned(id, receipt));
|
|
51
|
+
if (index < 0) break;
|
|
52
|
+
rows.splice(index, 1);
|
|
53
|
+
}
|
|
54
|
+
const bare = ([id, receipt]) => { const { change, note, ...rest } = receipt ?? {}; return [id, rest]; };
|
|
55
|
+
rows = rows.map((row) => (row[1]?.change && logged.has(row[0]) ? bare(row) : row));
|
|
56
|
+
const size = () => Buffer.byteLength(JSON.stringify({ ...fields, atlas_receipts: Object.fromEntries(rows) }));
|
|
57
|
+
for (let index = 0; index < rows.length && size() > FIELDS_LIMIT; index += 1) {
|
|
58
|
+
if (rows[index][1]?.change && rows[index][0] !== keep) rows[index] = bare(rows[index]);
|
|
59
|
+
}
|
|
60
|
+
for (let index = 0; index < rows.length && size() > FIELDS_LIMIT;) {
|
|
61
|
+
if (pinned(...rows[index])) index += 1;
|
|
62
|
+
else rows.splice(index, 1);
|
|
63
|
+
}
|
|
64
|
+
return Object.fromEntries(rows);
|
|
65
|
+
}
|
|
34
66
|
// The tags that other verbs own (work item additions, slice 1). item_edit
|
|
35
67
|
// 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:']);
|
|
68
|
+
export const OWNED_TAG_PREFIXES = Object.freeze(['product:', 'repo:', 'tier:', 'item:']);
|
|
37
69
|
export const OWNED_TAGS = Object.freeze(['atlas-work', 'no-model', 'atlas-registry']);
|
|
38
70
|
// Engram reads no-model with the spaces trimmed and in any case, so the check
|
|
39
71
|
// does the same: No-Model is owned too.
|
|
@@ -163,17 +195,32 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
163
195
|
// recomputes it from the newer record. guard, when given, is checked again on
|
|
164
196
|
// that newer record: a write that another writer made wrong is refused, not
|
|
165
197
|
// forced through (release 0.2.3).
|
|
166
|
-
|
|
198
|
+
// An item verb (LOG_VERBS) puts the diff of its write into its own receipt,
|
|
199
|
+
// in the same write; run() then copies it into a log entry. note is a
|
|
200
|
+
// reason the log keeps beside the diff (work item additions, slice 3).
|
|
201
|
+
async function patch(entity, verb, requestId, fields, { tags, title, digest, guard, note, log = LOG_VERBS.includes(verb) } = {}) {
|
|
167
202
|
let current = entity;
|
|
203
|
+
const logged = log ? await activity.loggedIds(entity.id) : undefined;
|
|
168
204
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
169
205
|
if (attempt > 0 && guard) guard(current);
|
|
170
206
|
const next = typeof fields === 'function' ? fields(current) : fields;
|
|
171
|
-
|
|
172
|
-
|
|
207
|
+
// A fields function returns null when the newest record already holds
|
|
208
|
+
// the change; then nothing is written.
|
|
209
|
+
if (next === null) return current;
|
|
173
210
|
// tags as a function replaces the list with what it returns, computed
|
|
174
211
|
// from the newest record; a list is added to the tags already there.
|
|
175
|
-
|
|
176
|
-
|
|
212
|
+
let nextTags;
|
|
213
|
+
if (typeof tags === 'function') nextTags = [...new Set(tags(current))];
|
|
214
|
+
else if (tags) nextTags = [...new Set([...(current.tags ?? []), ...tags])];
|
|
215
|
+
const change = log ? diffOf(current, next, { title: title || undefined, tags: nextTags }) : null;
|
|
216
|
+
// A logged write that changes no field is not written: no receipt, no
|
|
217
|
+
// entry, no new version.
|
|
218
|
+
if (change && !Object.keys(change).length) return current;
|
|
219
|
+
const receipt = { verb, at: now(), ...(digest ? { digest } : {}), ...(change ? { change, ...(note ? { note: scrubSecrets(note).text } : {}) } : {}) };
|
|
220
|
+
const { atlas_receipts: _old, ...rest } = { ...(current.fields ?? {}), ...next, writer: label, request_id: requestId };
|
|
221
|
+
const receipts = trimReceipts(rest, { ...(current.fields?.atlas_receipts ?? {}), [requestId]: receipt }, logged, requestId);
|
|
222
|
+
const body = { version: current.version, fields: { ...rest, atlas_receipts: receipts } };
|
|
223
|
+
if (nextTags) body.tags = nextTags;
|
|
177
224
|
if (title) body.title = title;
|
|
178
225
|
try { await client.updateEntity(current.id, body); return client.getEntity(current.id); }
|
|
179
226
|
catch (error) {
|
|
@@ -183,9 +230,9 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
183
230
|
}
|
|
184
231
|
}
|
|
185
232
|
}
|
|
186
|
-
async function create(type, requestId, verb, { title, body, fields, tags, digest }) {
|
|
187
|
-
const receipt = await client.createEntity({ title, ...(body ? { body } : {}), type_id: type.id, fields: { ...fields, writer: label, request_id: requestId, atlas_receipts: { [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } } }, tags: [...new Set(['atlas-work', 'no-model', ...(tags ?? [])])] });
|
|
188
|
-
return client.getEntity(receipt.id);
|
|
233
|
+
async function create(type, requestId, verb, { title, body, fields, tags, digest, change, readBack = true }) {
|
|
234
|
+
const receipt = await client.createEntity({ title, ...(body ? { body } : {}), type_id: type.id, fields: { ...fields, writer: label, request_id: requestId, atlas_receipts: { [requestId]: { verb, at: now(), ...(digest ? { digest } : {}), ...(change ? { change } : {}) } } }, tags: [...new Set(['atlas-work', 'no-model', ...(tags ?? [])])] });
|
|
235
|
+
return readBack ? client.getEntity(receipt.id) : receipt;
|
|
189
236
|
}
|
|
190
237
|
async function edge(src, dst, suffix, requestId, props = {}) {
|
|
191
238
|
const intent = await edgeIntent(suffix);
|
|
@@ -389,7 +436,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
389
436
|
const type = await typeNamed('container');
|
|
390
437
|
const prior = await landed(type, requestId, meta);
|
|
391
438
|
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 };
|
|
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)) : [])] });
|
|
439
|
+
const entity = await create(type, requestId, 'item_propose', { change: { created: { from: null, to: scrubSecrets(oneLine(title, 300)).text } }, 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)) : [])] });
|
|
393
440
|
return { verb: 'item_propose', item_id: entity.id, version: entity.version, lifecycle: 'proposed', stage: 'capture', product: fields.product, repos };
|
|
394
441
|
},
|
|
395
442
|
|
|
@@ -548,22 +595,17 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
548
595
|
}
|
|
549
596
|
if (args.next_step !== undefined) {
|
|
550
597
|
change.next_step = text(args.next_step, 'next_step', { max: 1000 });
|
|
551
|
-
previous.next_step = fields.next_step ?? '';
|
|
552
598
|
}
|
|
553
599
|
if (args.waits_on !== undefined) {
|
|
554
600
|
const given = args.waits_on;
|
|
555
|
-
if (!given || typeof given !== 'object' || Array.isArray(given)) refuse(`waits_on must be { on, note
|
|
601
|
+
if (!given || typeof given !== 'object' || Array.isArray(given)) refuse(`waits_on must be { on, note? }, with on one of ${WAITS_ON.join(', ')}`);
|
|
602
|
+
if (given.on === 'item') refuse('waits_on.on "item" is retired; link the items with item_block, which lists every blocker and shows "blocks" on the other row');
|
|
556
603
|
const wait = { on: oneOf(given.on, 'waits_on.on', WAITS_ON) };
|
|
557
604
|
const note = text(given.note, 'waits_on.note', { optional: true, max: 300 });
|
|
558
605
|
if (note) wait.note = note;
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
if (other.id === item.id) refuse('an item cannot wait on itself');
|
|
562
|
-
wait.item = other.id;
|
|
563
|
-
} else if (given.item !== undefined) refuse('waits_on.item is only for on: item');
|
|
564
|
-
if (wait.on !== 'nothing' && wait.on !== 'item' && !wait.note) refuse(`waits_on.note is required for on: ${wait.on}; say what exactly it waits on`);
|
|
606
|
+
if (given.item !== undefined) refuse('waits_on.item is retired; link the items with item_block');
|
|
607
|
+
if (wait.on !== 'nothing' && !wait.note) refuse(`waits_on.note is required for on: ${wait.on}; say what exactly it waits on`);
|
|
565
608
|
change.waits_on = wait;
|
|
566
|
-
previous.waits_on = fields.waits_on ?? null;
|
|
567
609
|
}
|
|
568
610
|
const tagsAdd = args.tags_add === undefined ? [] : tagList(args.tags_add, 'tags_add');
|
|
569
611
|
const tagsRemove = args.tags_remove === undefined ? [] : tagList(args.tags_remove, 'tags_remove');
|
|
@@ -596,18 +638,82 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
596
638
|
if (tagEdit && sameList(tagsOf(item), item.tags ?? [])) tagEdit = false;
|
|
597
639
|
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 };
|
|
598
640
|
const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
|
|
599
|
-
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
const entryOf = (current) => ({ at, by: label.agent, previous: { ...previous, ...(tagEdit ? { tags: current.tags ?? [] } : {}), ...(planGiven ? { plan: current.fields?.plan ?? null } : {}) }, ...(reason ? { reason } : {}) });
|
|
641
|
+
// Slice 3: the old values go into the diff in the receipt and then into
|
|
642
|
+
// a log entry, taken from the record the write lands on. edits stops
|
|
643
|
+
// growing and stays readable. The first title the owner gave is still
|
|
644
|
+
// kept on its own.
|
|
604
645
|
const entity = await patch(item, 'item_edit', requestId, (current) => ({
|
|
605
|
-
...change, ...(planGiven ? { plan: planOf(current) } : {}),
|
|
646
|
+
...change, ...(planGiven ? { plan: planOf(current) } : {}),
|
|
606
647
|
...(previous.title !== undefined && current.fields?.first_title === undefined ? { first_title: previous.title } : {}),
|
|
607
|
-
}), { digest: meta?.digest, guard: live, ...(title ? { title } : {}), ...(tagEdit ? { tags: tagsOf } : {}) });
|
|
648
|
+
}), { digest: meta?.digest, guard: live, note: reason, ...(title ? { title } : {}), ...(tagEdit ? { tags: tagsOf } : {}) });
|
|
608
649
|
return { verb: 'item_edit', item_id: entity.id, version: entity.version, title: entity.title, next_step: entity.fields?.next_step ?? '', waits_on: entity.fields?.waits_on ?? null, tags: entity.tags ?? [], plan: entity.fields?.plan ?? null, changed: [...(args.title !== undefined ? ['title'] : []), ...Object.keys(change), ...(tagEdit ? ['tags'] : []), ...(planGiven ? ['plan'] : [])] };
|
|
609
650
|
},
|
|
610
651
|
|
|
652
|
+
// Work item additions, slice 2: an item names the items that block it.
|
|
653
|
+
// The list on the item is the one home of the fact; a view works out
|
|
654
|
+
// "blocks" from the items it loaded. The lead keeps the links true, so no
|
|
655
|
+
// owner flag is needed. A link that would close a loop is refused.
|
|
656
|
+
async item_block(args, requestId, meta) {
|
|
657
|
+
const item = await requireItem(args.item_id);
|
|
658
|
+
text(args.blocked_by, 'blocked_by', { max: 200 });
|
|
659
|
+
const note = text(args.note, 'note', { optional: true, max: 300 });
|
|
660
|
+
const listOf = (record) => (Array.isArray(record.fields?.blocked_by) ? record.fields.blocked_by : []);
|
|
661
|
+
// The replay comes before the blocker is read: a blocker archived since
|
|
662
|
+
// the write landed must not refuse a write that already happened.
|
|
663
|
+
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 };
|
|
664
|
+
const blocker = await requireItem(args.blocked_by);
|
|
665
|
+
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`); };
|
|
666
|
+
live(item);
|
|
667
|
+
if (blocker.id === item.id) refuse('an item cannot block itself');
|
|
668
|
+
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 };
|
|
669
|
+
// Walk what the blocker waits on, with an old waits_on pointer to an
|
|
670
|
+
// item counted as a link. Reaching the item means the new link closes a
|
|
671
|
+
// loop. Two sessions can pass this check at the same moment;
|
|
672
|
+
// one owner runs these products, so the design accepts that.
|
|
673
|
+
const seen = new Set([blocker.id]);
|
|
674
|
+
const path = new Map([[blocker.id, [blocker.id]]]);
|
|
675
|
+
const queue = [blocker];
|
|
676
|
+
while (queue.length) {
|
|
677
|
+
const current = queue.shift();
|
|
678
|
+
const links = [...listOf(current), ...(current.fields?.waits_on?.on === 'item' && typeof current.fields.waits_on.item === 'string' ? [{ item: current.fields.waits_on.item }] : [])];
|
|
679
|
+
for (const entry of links) {
|
|
680
|
+
if (entry.item === item.id) refuse(`this link would close a loop: ${[item.id, ...path.get(current.id), item.id].join(' → ')}`);
|
|
681
|
+
if (seen.has(entry.item)) continue;
|
|
682
|
+
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`);
|
|
683
|
+
seen.add(entry.item);
|
|
684
|
+
let next;
|
|
685
|
+
try { next = await client.getEntity(entry.item); } catch (error) { if (error instanceof EngramHttpError && error.status === 404) continue; throw error; }
|
|
686
|
+
path.set(next.id, [...path.get(current.id), next.id]);
|
|
687
|
+
queue.push(next);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const at = now();
|
|
691
|
+
const link = { item: blocker.id, at, by: label.agent, ...(note ? { note } : {}) };
|
|
692
|
+
// null tells patch() not to write: another session added the same link.
|
|
693
|
+
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 });
|
|
694
|
+
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 }) };
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
async item_unblock(args, requestId, meta) {
|
|
698
|
+
const item = await requireItem(args.item_id);
|
|
699
|
+
text(args.blocked_by, 'blocked_by', { max: 200 });
|
|
700
|
+
const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
|
|
701
|
+
const listOf = (record) => (Array.isArray(record.fields?.blocked_by) ? record.fields.blocked_by : []);
|
|
702
|
+
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 };
|
|
703
|
+
// Live and still blocked, on the first read and on the newest record.
|
|
704
|
+
const guard = (current) => {
|
|
705
|
+
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`);
|
|
706
|
+
if (!listOf(current).some((entry) => entry.item === args.blocked_by)) refuse(`item ${current.id} is not blocked by ${args.blocked_by}`);
|
|
707
|
+
};
|
|
708
|
+
guard(item);
|
|
709
|
+
// The diff keeps the removed link, taken from the record the write
|
|
710
|
+
// lands on, so the log shows it (slice 3).
|
|
711
|
+
const entity = await patch(item, 'item_unblock', requestId, (current) => ({
|
|
712
|
+
blocked_by: listOf(current).filter((row) => row.item !== args.blocked_by),
|
|
713
|
+
}), { digest: meta?.digest, guard, note: reason });
|
|
714
|
+
return { verb: 'item_unblock', item_id: entity.id, version: entity.version, blocked_by: listOf(entity).map((row) => row.item), removed: args.blocked_by };
|
|
715
|
+
},
|
|
716
|
+
|
|
611
717
|
async item_stage(args, requestId, meta) {
|
|
612
718
|
const item = await requireItem(args.item_id);
|
|
613
719
|
const stage = oneOf(args.stage, 'stage', STAGES);
|
|
@@ -645,9 +751,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
645
751
|
Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
|
|
646
752
|
Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
|
|
647
753
|
Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
|
|
754
|
+
const activity = createActivityVerbs(core);
|
|
755
|
+
Object.assign(verbs, activity.verbs);
|
|
648
756
|
|
|
649
|
-
const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
|
|
650
|
-
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS]);
|
|
757
|
+
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, ...ACTIVITY_WRITE_VERBS]);
|
|
758
|
+
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS, ...ACTIVITY_READ_VERBS]);
|
|
651
759
|
|
|
652
760
|
async function run(verb, args = {}) {
|
|
653
761
|
if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
|
|
@@ -674,6 +782,12 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
674
782
|
await store.put(requestId, { ...record, phase: 'interrupted', error: error.message });
|
|
675
783
|
throw error;
|
|
676
784
|
}
|
|
785
|
+
// The item write landed. Its diff goes into a log entry now, best
|
|
786
|
+
// effort: a failure is a warning on the result (slice 3, step 6).
|
|
787
|
+
if (ENTRY_VERBS.includes(verb) && typeof result?.item_id === 'string') {
|
|
788
|
+
const warning = await activity.logEntries(result.item_id, requestId);
|
|
789
|
+
if (warning) result = { ...result, warning };
|
|
790
|
+
}
|
|
677
791
|
await store.put(requestId, { ...record, phase: 'complete', result, completed_at: now() });
|
|
678
792
|
return { ...copy(result), request_id: requestId, writer: label };
|
|
679
793
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atlas-work",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"kind": "extension",
|
|
5
|
-
"description": "Atlas work records: products and repos as the registry, work items, questions, decisions, receipts, delivery and reopen marks, and their relations. Version 0.
|
|
5
|
+
"description": "Atlas work records: products and repos as the registry, work items, questions, decisions, receipts, delivery and reopen marks, and their relations. Version 0.7.0 adds entry, the log of an item: comments and the changes the item verbs make. Version 0.6.0 added kinds, a list, on the repo. Version 0.5.0 added the repo kind. Version 0.4.0 adds mark, the delivery fields on the item and the health endpoint on the product (M1 slice L3, 2026-09-17). Version 0.3.0 added question and receipt. Version 0.2.0 dropped atlas_key and write_class.",
|
|
6
6
|
"capability": {
|
|
7
7
|
"semantics_version": ">=1.0.0 <2.0.0"
|
|
8
8
|
},
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"@{{handle}}/question",
|
|
16
16
|
"@{{handle}}/receipt",
|
|
17
17
|
"@{{handle}}/mark",
|
|
18
|
+
"@{{handle}}/entry",
|
|
18
19
|
"@{{handle}}/part_of",
|
|
19
20
|
"@{{handle}}/spun_out_of",
|
|
20
21
|
"@{{handle}}/replaced_by",
|
|
@@ -430,6 +431,73 @@
|
|
|
430
431
|
}
|
|
431
432
|
}
|
|
432
433
|
},
|
|
434
|
+
{
|
|
435
|
+
"name": "@{{handle}}/entry",
|
|
436
|
+
"kind": "entity",
|
|
437
|
+
"component_spec": {
|
|
438
|
+
"metadata": {
|
|
439
|
+
"fields": {
|
|
440
|
+
"$required": [
|
|
441
|
+
"item",
|
|
442
|
+
"kind",
|
|
443
|
+
"at",
|
|
444
|
+
"writer",
|
|
445
|
+
"request_id"
|
|
446
|
+
],
|
|
447
|
+
"properties": {
|
|
448
|
+
"item": {
|
|
449
|
+
"type": "string"
|
|
450
|
+
},
|
|
451
|
+
"product": {
|
|
452
|
+
"type": "string"
|
|
453
|
+
},
|
|
454
|
+
"kind": {
|
|
455
|
+
"type": "string",
|
|
456
|
+
"enum": [
|
|
457
|
+
"comment",
|
|
458
|
+
"change"
|
|
459
|
+
]
|
|
460
|
+
},
|
|
461
|
+
"voice": {
|
|
462
|
+
"type": "string",
|
|
463
|
+
"enum": [
|
|
464
|
+
"agent",
|
|
465
|
+
"owner"
|
|
466
|
+
]
|
|
467
|
+
},
|
|
468
|
+
"text": {
|
|
469
|
+
"type": "string"
|
|
470
|
+
},
|
|
471
|
+
"corrects": {
|
|
472
|
+
"type": "string"
|
|
473
|
+
},
|
|
474
|
+
"redactions": {
|
|
475
|
+
"type": "number"
|
|
476
|
+
},
|
|
477
|
+
"verb": {
|
|
478
|
+
"type": "string"
|
|
479
|
+
},
|
|
480
|
+
"change": {
|
|
481
|
+
"type": "object"
|
|
482
|
+
},
|
|
483
|
+
"at": {
|
|
484
|
+
"type": "string"
|
|
485
|
+
},
|
|
486
|
+
"writer": {
|
|
487
|
+
"type": "object"
|
|
488
|
+
},
|
|
489
|
+
"request_id": {
|
|
490
|
+
"type": "string"
|
|
491
|
+
},
|
|
492
|
+
"atlas_receipts": {
|
|
493
|
+
"type": "object"
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
"validation": "strict"
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
},
|
|
433
501
|
{
|
|
434
502
|
"name": "@{{handle}}/part_of",
|
|
435
503
|
"kind": "edge",
|
|
@@ -610,6 +678,10 @@
|
|
|
610
678
|
],
|
|
611
679
|
"0.6.0": [
|
|
612
680
|
"Added: kinds on the repo record, a list of what the repo is (service, library, schema, cli, plugin). A monorepo or a plugin that is also a library holds more than one. The scalar kind stays, so a record written under 0.5.0 still validates and an older reader still reads it. Stage 1 of the target design."
|
|
681
|
+
],
|
|
682
|
+
"0.7.0": [
|
|
683
|
+
"Adds entry: one record for each comment on a work item and for each change an item verb makes. It carries the tag item:<id>, so one list by type and tag finds every entry of an item. A comment holds its voice, agent or owner, its text and the comment it corrects. A change holds the verb and each changed field as from and to. Work item additions, slice 3.",
|
|
684
|
+
"Nothing else changes. Every record type of 0.6.0 is carried over whole, so every record written under 0.6.0 still validates."
|
|
613
685
|
]
|
|
614
686
|
}
|
|
615
687
|
}
|
package/work/mcp.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const CODE_DIGEST = { type: 'object', description: 'One sub-agent digest for one
|
|
|
40
40
|
|
|
41
41
|
export const TOOLS = [
|
|
42
42
|
{ name: 'registry_list', description: `List the registered products and their repos with registry ids and entity ids. When: before any item verb, to find the ids to pass. Scope: read, no owner flag needed. ${RULES}`, inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
43
|
-
{ name: 'setup', description: `Publish atlas-work ${MANIFEST_VERSION} to the tenant's Forge and register its types (product, repo with its kinds, container, decision, question, receipt, mark and the surviving edges). Idempotent. When: once per tenant, before the first product add, and again after a manifest change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id'], properties: { request_id: REQUEST_ID }, additionalProperties: false } },
|
|
43
|
+
{ name: 'setup', description: `Publish atlas-work ${MANIFEST_VERSION} to the tenant's Forge and register its types (product, repo with its kinds, container, decision, question, receipt, mark, entry and the surviving edges). Idempotent. When: once per tenant, before the first product add, and again after a manifest change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id'], properties: { request_id: REQUEST_ID }, additionalProperties: false } },
|
|
44
44
|
{ name: 'product_add', description: `Register a product in the name registry. Refuses when the id exists. When: a product gets its first work item. Owner present only. registry_id is a lowercase slug and is immutable. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'name'], properties: { request_id: REQUEST_ID, registry_id: S('Lowercase slug, for example "engram". Immutable.'), name: S('Display name.'), project_id: S('Entity id of the existing Engram project entity this product links to.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint. The done verb reads the deployed commit there, and every live check must be on this origin.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths on that origin the functional check of item_done may read, for example ["/v1/housekeeping"]. Unset, the check may read only the health endpoint\'s own path.' }, notes: S('Free text.') }, additionalProperties: false } },
|
|
45
45
|
{ name: 'product_edit', description: `Change a product's name, notes, health endpoint, check paths, design hub link or project link. Refuses an unknown id. The registry id never changes. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing product id.'), name: S('New display name.'), project_id: S('Entity id of an Engram project entity.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths the functional check of item_done may read on that origin.' }, design_hub_url: S('Absolute https URL of the one pinned "Design docs" hub page. Set it once, on any product; registry_list and design_index return it.'), notes: S('Free text.') }, additionalProperties: false } },
|
|
46
46
|
{ name: 'repo_add', description: `Register a GitHub repo under a product. The server reads GitHub itself: the id must equal the repo's full name and the owner must have push rights, or the add is refused. The default branch comes from GitHub. The kinds say what the repo is; item_done reads them to choose how a delivery is proved. Give kinds, a list; kind, one value, is the older form. Refuses an unknown product id and an existing repo id; repo ids are immutable. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'product'], properties: { request_id: REQUEST_ID, registry_id: S('GitHub "owner/repo", exact spelling.'), product: S('Existing product id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.') }, additionalProperties: false } },
|
|
@@ -51,8 +51,12 @@ export const TOOLS = [
|
|
|
51
51
|
{ name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog: the in_flight rows leave them out, and needs_me still shows their open questions. Refuses a missing reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it is parked.') }, additionalProperties: false } },
|
|
52
52
|
{ name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. It also undoes a drop: a dropped item goes back to the lifecycle it left, and the drop's words stay on it. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it resumes.') }, additionalProperties: false } },
|
|
53
53
|
{ name: 'item_drop', description: `Drop an item in the owner's words: lifecycle dropped, with the words, the date and the lifecycle it left. A dropped item stays in the graph, and no live view lists it. Refuses a done item (reopen it instead) and an item already dropped. When: the owner retires a piece of work. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), origin: { ...ORIGIN, description: "The owner's words that drop the item, verbatim, with where and when they were said." } }, additionalProperties: false } },
|
|
54
|
-
{ name: 'item_edit', description: `Edit a proposed, active or paused item: its next step, what it waits on, its free tags, its planned dates, or its title. Each edit
|
|
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 keeps the old and new values in the item's log, which item_activity reads; 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:, item:, atlas-work, no-model, atlas-registry) are refused, in any case. 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 log. 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 } },
|
|
58
|
+
{ name: 'item_comment', description: `Add a comment to an item's log: a running note from the lead, or the owner's own words. A comment is never edited; to correct one, add a new comment that names it in corrects. The text goes through the secret filter. When: something about the item is worth keeping that no other verb records. No owner flag for the agent's voice; the owner's voice is his words, word for word, so it needs the owner present. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'text'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), text: S('The comment, at most 4000 characters.'), voice: S('agent (the default) or owner. owner means the text is the owner\'s own words. Owner present only.'), corrects: S('The entry id of an earlier comment on this item that this one corrects.') }, additionalProperties: false } },
|
|
59
|
+
{ name: 'item_activity', description: 'Answer "what happened on this item?". One list, newest first: the comments, each change an item verb made with its old and new values, the questions, the decisions, the delivery marks, the pieces and the links. A write that kept no values, such as one from before manifest 0.7.0, shows its verb and time with "no values recorded". The edits kept on the item before 0.7.0 show too. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['item_id'], properties: { item_id: S('Item id.'), limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many rows, newest first. Default 50.' } }, additionalProperties: false } },
|
|
56
60
|
{ 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
61
|
{ 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
62
|
{ 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 } },
|