@arjunkhera/atlas 0.2.2 → 0.3.0
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/README.md +15 -9
- package/agents/artifact-renderer.md +7 -2
- package/agents/capability-reader.md +1 -1
- package/agents/code-explorer.md +3 -2
- package/agents/reviewer-architect.md +3 -3
- package/agents/reviewer-pm.md +4 -4
- package/agents/reviewer-security.md +11 -8
- package/agents/verifier.md +97 -0
- package/door/cli.mjs +33 -108
- package/door/lib/kind-drift.mjs +16 -6
- package/door/lib/privacy.mjs +61 -0
- package/door/lib/pull-request.mjs +55 -56
- package/door/lib/ste-words.json +51 -0
- package/door/lib/ste.mjs +199 -0
- package/door/lib/tooling.mjs +2 -2
- package/package.json +1 -1
- package/shape/check.mjs +33 -33
- package/shape/releases.json +5 -0
- package/skills/feedback/SKILL.md +39 -0
- package/skills/lead/SKILL.md +103 -0
- package/skills/learnings/SKILL.md +48 -0
- package/skills/repo-skills/SKILL.md +64 -0
- package/skills/sdlc-task/SKILL.md +62 -69
- package/skills/sdlc-task/lifecycle.yaml +8 -4
- package/skills/sdlc-task/templates/design-doc.md +23 -18
- package/skills/sdlc-task/templates/resume-anchor.md +9 -7
- package/work/lib/capability.mjs +34 -23
- package/work/lib/delivery.mjs +26 -12
- package/work/lib/questions.mjs +54 -10
- package/work/lib/verb-fields.mjs +34 -0
- package/work/lib/verbs.mjs +100 -19
- package/work/{manifest-0.5.0.json → manifest-0.6.0.json} +9 -3
- package/work/mcp.mjs +13 -10
- package/work/protected-paths.json +4 -2
- package/door/lib/helpers.mjs +0 -139
- package/door/lib/onboarding.mjs +0 -130
package/work/lib/questions.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// The verbs run behind the same door, the same journal and the same registry
|
|
6
6
|
// checks as slice L1. Every record carries request_id and writer.
|
|
7
7
|
import protectedPaths from '../protected-paths.json' with { type: 'json' };
|
|
8
|
-
import { refuse, text, oneOf, isoDate, url, stringList } from './verb-fields.mjs';
|
|
8
|
+
import { refuse, text, oneOf, isoDate, url, stringList, LIVE_LIFECYCLES } from './verb-fields.mjs';
|
|
9
9
|
|
|
10
10
|
// R5: the cost of waiting is a closed vocabulary, ranked. Index 0 costs most.
|
|
11
11
|
export const COSTS = Object.freeze(['blocked-merge', 'paused-item', 'spend', 'date']);
|
|
@@ -62,13 +62,50 @@ function questionContext(value) {
|
|
|
62
62
|
return context;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Release 0.2.3: the owner's order. One rule for every view, the same one
|
|
66
|
+
// part six of "where are we with X" uses: the current priority decision is the
|
|
67
|
+
// newest decision of kind priority for the product. When it carries a
|
|
68
|
+
// priority_order, the rows it names come first, in its order; every other row
|
|
69
|
+
// follows, in the view's own order. When it carries none, no order applies:
|
|
70
|
+
// an older list never stands in for the newest decision.
|
|
71
|
+
export function currentPriorityOf(decisions, productId) {
|
|
72
|
+
return decisions
|
|
73
|
+
.filter((row) => row.fields?.product === productId && row.fields?.kind === 'priority')
|
|
74
|
+
.sort((a, b) => String(b.fields?.date ?? '').localeCompare(String(a.fields?.date ?? '')) || String(b.created_at ?? '').localeCompare(String(a.created_at ?? '')))[0] ?? null;
|
|
75
|
+
}
|
|
76
|
+
export function priorityOrderOf(decisions, productId) {
|
|
77
|
+
const latest = currentPriorityOf(decisions, productId);
|
|
78
|
+
if (!latest || !Array.isArray(latest.fields?.priority_order) || !latest.fields.priority_order.length) return null;
|
|
79
|
+
return { decision: latest.id, date: latest.fields?.date ?? '', rank: new Map(latest.fields.priority_order.map((id, index) => [id, index])) };
|
|
80
|
+
}
|
|
81
|
+
// orders maps a product id to priorityOrderOf's result, or to null.
|
|
82
|
+
export const byPriority = (orders, fallback) => (a, b) => {
|
|
83
|
+
const ra = orders.get(a.product)?.rank.get(a.item);
|
|
84
|
+
const rb = orders.get(b.product)?.rank.get(b.item);
|
|
85
|
+
if (ra !== undefined && rb !== undefined) return ra - rb;
|
|
86
|
+
if (ra !== undefined) return -1;
|
|
87
|
+
if (rb !== undefined) return 1;
|
|
88
|
+
return fallback(a, b);
|
|
89
|
+
};
|
|
90
|
+
export const orderNote = (orders, row) => {
|
|
91
|
+
const rank = orders.get(row.product)?.rank.get(row.item);
|
|
92
|
+
return rank === undefined ? '' : ` · your order: ${rank + 1}`;
|
|
93
|
+
};
|
|
94
|
+
// Release 0.2.3: what an item waits on, as one short phrase for a row.
|
|
95
|
+
export function waitsOnLine(waits) {
|
|
96
|
+
if (!waits || typeof waits !== 'object' || !waits.on) return '';
|
|
97
|
+
const what = { 'owner-decision': 'your decision', 'owner-action': 'your action', item: 'another item', gate: 'the gate', nothing: 'nothing' }[waits.on] ?? String(waits.on);
|
|
98
|
+
return `waits on ${what}${waits.item ? ` ${waits.item}` : ''}${waits.note ? `: ${waits.note}` : ''}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
65
101
|
// Row line two: the id is the citation a reader types back, so the title gives
|
|
66
102
|
// way to it, not the other way round (review G6).
|
|
67
|
-
export function itemLine(item, product) {
|
|
103
|
+
export function itemLine(item, product, lifecycle = 'active') {
|
|
68
104
|
// Each part is flattened on its own, so no field in the graph can add a
|
|
69
105
|
// fourth line to a three-line row (review G8). The separator is added after
|
|
70
|
-
// the flattening, because flattening also trims the ends.
|
|
71
|
-
|
|
106
|
+
// the flattening, because flattening also trims the ends. An item that is
|
|
107
|
+
// not active says so in place of its stage (release 0.2.3).
|
|
108
|
+
const tail = oneLine(`${product} · ${lifecycle === 'active' ? (item.fields?.stage ?? 'unknown stage') : `item ${lifecycle}`} · ${item.id}`);
|
|
72
109
|
const budget = ROW_LINE_MAX - tail.length - 3;
|
|
73
110
|
if (budget < 1) return oneLine(`${oneLine(item.title)} · ${tail}`);
|
|
74
111
|
return `${oneLine(item.title, budget)} · ${tail}`;
|
|
@@ -134,6 +171,7 @@ export function createQuestionVerbs(core) {
|
|
|
134
171
|
const prior = await landed(type, requestId, meta);
|
|
135
172
|
// A retry after an interrupted write finds the record and re-makes the edge it lost (review G1).
|
|
136
173
|
if (prior) { await edge(prior, item, 'references', requestId); return { verb: 'question_ask', question_id: prior.id, version: prior.version, status: prior.fields.status, item: prior.fields.item, product: prior.fields.product, cost: prior.fields.cost, replayed_from_graph: true }; }
|
|
174
|
+
if (item.fields?.lifecycle === 'dropped') refuse(`item ${item.id} is dropped; the owner closed it, so a question on it can never need him`);
|
|
137
175
|
const entity = await create(type, requestId, 'question_ask', {
|
|
138
176
|
digest: meta?.digest, title: oneLine(questionText, 200), body: questionText,
|
|
139
177
|
fields: {
|
|
@@ -250,20 +288,23 @@ export function createQuestionVerbs(core) {
|
|
|
250
288
|
for (const row of open) {
|
|
251
289
|
const item = items.get(row.fields.item) ?? null;
|
|
252
290
|
const lifecycle = item?.fields?.lifecycle ?? 'unknown';
|
|
253
|
-
//
|
|
254
|
-
|
|
291
|
+
// Release 0.2.3: a question on a
|
|
292
|
+
// proposed or paused item still needs him, so it is a row. D3 rule 1
|
|
293
|
+
// kept active items only, and it hid real questions. A question on a
|
|
294
|
+
// done or dropped item is named, not dropped in silence.
|
|
295
|
+
if (!LIVE_LIFECYCLES.includes(lifecycle)) { omitted.push({ question_id: row.id, item: row.fields.item, item_lifecycle: lifecycle, reason: item ? `the item is ${lifecycle}; only a proposed, active or paused item can still need you` : (unreadable.get(row.fields.item) ?? 'the item cannot be read') }); continue; }
|
|
255
296
|
const cost = row.fields.cost;
|
|
256
297
|
const rank = COSTS.indexOf(cost);
|
|
257
298
|
const asked = row.fields.asked_by ?? {};
|
|
258
299
|
kept.push({
|
|
259
|
-
question_id: row.id, product: row.fields.product, item: item.id, cost, cost_rank: rank === -1 ? COSTS.length : rank,
|
|
300
|
+
question_id: row.id, product: row.fields.product, item: item.id, item_lifecycle: lifecycle, cost, cost_rank: rank === -1 ? COSTS.length : rank,
|
|
260
301
|
asked_at: row.fields.asked_at ?? row.created_at ?? '', asked_by: asked.agent ?? 'unknown',
|
|
261
302
|
lines: [
|
|
262
303
|
oneLine(row.fields.text),
|
|
263
|
-
itemLine(item, row.fields.product),
|
|
304
|
+
itemLine(item, row.fields.product, lifecycle),
|
|
264
305
|
oneLine(`${cost}: ${COST_MEANING[cost] ?? 'cost outside the vocabulary'} · asked by ${asked.agent ?? 'unknown'} on ${(row.fields.asked_at ?? '').slice(0, 10)}`),
|
|
265
306
|
],
|
|
266
|
-
folded: { question: row.fields.text, context: row.fields.context ?? {}, asked_by: asked, item_title: item.title, item_stage: item.fields.stage ?? '', item_next_step: item.fields.next_step ?? '' },
|
|
307
|
+
folded: { question: row.fields.text, context: row.fields.context ?? {}, asked_by: asked, item_title: item.title, item_stage: item.fields.stage ?? '', item_next_step: item.fields.next_step ?? '', item_waits_on: item.fields.waits_on ?? null },
|
|
267
308
|
citation: { question: row.id, item: item.id, read_at: readAt },
|
|
268
309
|
});
|
|
269
310
|
}
|
|
@@ -271,7 +312,10 @@ export function createQuestionVerbs(core) {
|
|
|
271
312
|
const products = [...new Set(kept.map((row) => row.product))].sort();
|
|
272
313
|
const byProduct = products.map((product) => ({ product, rows: kept.filter((row) => row.product === product) }));
|
|
273
314
|
const render = byProduct.map((group) => [`${group.product} — needs you (${group.rows.length})`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n')).join('\n\n');
|
|
274
|
-
|
|
315
|
+
// A question left open on a done or dropped item is not a row, but the
|
|
316
|
+
// text says it exists, so nothing waits in silence.
|
|
317
|
+
const aside = omitted.length ? `${omitted.length} open question${omitted.length === 1 ? '' : 's'} sit${omitted.length === 1 ? 's' : ''} on done, dropped or unreadable items; they are listed under omitted.` : '';
|
|
318
|
+
return { verb: 'needs_me', read_at: readAt, product: only, row_budget: ROW_LINES, rows: kept.length, products: byProduct, omitted, text: [render || 'Nothing needs you: no open question on a proposed, active or paused item.', aside].filter(Boolean).join('\n\n') };
|
|
275
319
|
},
|
|
276
320
|
};
|
|
277
321
|
return verbs;
|
package/work/lib/verb-fields.mjs
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
export const MAX_TEXT = 20_000;
|
|
8
|
+
// The lifecycles a view counts as live work (release 0.2.3). A dropped item is
|
|
9
|
+
// closed by the owner's word, and no live list names it.
|
|
10
|
+
export const LIVE_LIFECYCLES = Object.freeze(['proposed', 'active', 'paused']);
|
|
8
11
|
|
|
9
12
|
export class VerbError extends Error {
|
|
10
13
|
constructor(message, code = 'refused') { super(message); this.name = 'VerbError'; this.code = code; }
|
|
@@ -45,6 +48,37 @@ export function url(value, name) {
|
|
|
45
48
|
if (!['http:', 'https:'].includes(parsed.protocol)) refuse(`${name} must be an http or https URL`);
|
|
46
49
|
return value;
|
|
47
50
|
}
|
|
51
|
+
// What a repo is (stage 1 of the target design). A record holds a list,
|
|
52
|
+
// because a monorepo or a plugin that is also a library is more than one
|
|
53
|
+
// thing. Manifest 0.5.0 held one scalar kind, and an old record still has
|
|
54
|
+
// only that, so every reader goes through repoKinds.
|
|
55
|
+
export const REPO_KINDS = Object.freeze(['service', 'library', 'schema', 'cli', 'plugin']);
|
|
56
|
+
// The kinds whose merge is not their delivery. A service runs after the
|
|
57
|
+
// merge, and a schema repo applies its migration after the merge, so both
|
|
58
|
+
// keep the live checks of item_done.
|
|
59
|
+
export const LIVE_KINDS = Object.freeze(['service', 'schema']);
|
|
60
|
+
|
|
61
|
+
export function repoKinds(fields) {
|
|
62
|
+
if (Array.isArray(fields?.kinds) && fields.kinds.length) return [...fields.kinds];
|
|
63
|
+
return typeof fields?.kind === 'string' && fields.kind ? [fields.kind] : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The kinds a repo_add or repo_edit call gives, from `kinds` or the older
|
|
67
|
+
// `kind`. Returns null when the call names neither. The scalar written beside
|
|
68
|
+
// the list is the first live kind, else the first kind, so a reader of 0.5.0
|
|
69
|
+
// still takes the strict path when the list needs it.
|
|
70
|
+
export function kindsFromArgs(args) {
|
|
71
|
+
if (args.kinds !== undefined && args.kind !== undefined) refuse('give kinds or kind, not both');
|
|
72
|
+
if (args.kinds !== undefined) {
|
|
73
|
+
const list = stringList(args.kinds, 'kinds', { max: REPO_KINDS.length });
|
|
74
|
+
if (!list.length) refuse('kinds must name at least one kind');
|
|
75
|
+
for (const one of list) oneOf(one, 'each of kinds', REPO_KINDS);
|
|
76
|
+
return { kinds: list, kind: list.find((one) => LIVE_KINDS.includes(one)) ?? list[0] };
|
|
77
|
+
}
|
|
78
|
+
if (args.kind !== undefined) { const kind = oneOf(args.kind, 'kind', REPO_KINDS); return { kinds: [kind], kind }; }
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
export function stringList(value, name, { max = 64 } = {}) {
|
|
49
83
|
if (!Array.isArray(value)) refuse(`${name} must be an array of strings`);
|
|
50
84
|
if (value.length > max) refuse(`${name} holds more than ${max} entries`);
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -11,24 +11,26 @@
|
|
|
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
|
-
|
|
14
|
+
// Stage 1 of the target design adds kinds, a list, in 0.6.0.
|
|
15
|
+
import manifestCurrent from '../manifest-0.6.0.json' with { type: 'json' };
|
|
15
16
|
import { EngramHttpError } from './client.mjs';
|
|
16
|
-
import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList } from './verb-fields.mjs';
|
|
17
|
+
import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds, kindsFromArgs } from './verb-fields.mjs';
|
|
17
18
|
import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS } from './questions.mjs';
|
|
18
19
|
import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
|
|
19
20
|
import { createCapabilityVerbs, CAPABILITY_READ_VERBS } from './capability.mjs';
|
|
20
21
|
import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
|
|
21
22
|
|
|
22
|
-
export { VerbError, canonicalJson, sha256, COSTS };
|
|
23
|
-
export const MANIFEST_VERSION = '0.
|
|
23
|
+
export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
|
|
24
|
+
export const MANIFEST_VERSION = '0.6.0';
|
|
24
25
|
export const PRODUCT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
25
26
|
export const REPO_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/;
|
|
26
27
|
export const STAGES = Object.freeze(['capture', 'spec', 'build', 'verify', 'learn']);
|
|
27
28
|
export const TIERS = Object.freeze(['hotfix', 'standard', 'initiative']);
|
|
28
29
|
export const DRIVERS = Object.freeze(['owner-led', 'agent']);
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
export const
|
|
30
|
+
// What an item waits on (release 0.2.3). One line on the item, shown on its
|
|
31
|
+
// rows. "item" names another work item by id.
|
|
32
|
+
export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'item', 'gate', 'nothing']);
|
|
33
|
+
const MAX_EDITS = 20;
|
|
32
34
|
export const LINK_KINDS = Object.freeze(['pr', 'issue', 'artifact', 'design-doc', 'conversation', 'research', 'slack', 'drive', 'other']);
|
|
33
35
|
export const AUTHORITY_FIELDS = Object.freeze(['writer', 'session', 'actor', 'owner_present', 'agent', 'token_id']);
|
|
34
36
|
const MAX_CHILDREN = 32;
|
|
@@ -131,11 +133,17 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
131
133
|
}
|
|
132
134
|
const requireOwner = (verb) => { if (!ownerPresent) refuse(`${verb} needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)`); };
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
// fields may be a function of the record as read, so a retry after a 409
|
|
137
|
+
// recomputes it from the newer record. guard, when given, is checked again on
|
|
138
|
+
// that newer record: a write that another writer made wrong is refused, not
|
|
139
|
+
// forced through (release 0.2.3).
|
|
140
|
+
async function patch(entity, verb, requestId, fields, { tags, title, digest, guard } = {}) {
|
|
135
141
|
let current = entity;
|
|
136
142
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
143
|
+
if (attempt > 0 && guard) guard(current);
|
|
144
|
+
const next = typeof fields === 'function' ? fields(current) : fields;
|
|
137
145
|
const receipts = { ...(current.fields?.atlas_receipts ?? {}), [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } };
|
|
138
|
-
const body = { version: current.version, fields: { ...(current.fields ?? {}), ...
|
|
146
|
+
const body = { version: current.version, fields: { ...(current.fields ?? {}), ...next, writer: label, request_id: requestId, atlas_receipts: receipts } };
|
|
139
147
|
if (tags) body.tags = [...new Set([...(current.tags ?? []), ...tags])];
|
|
140
148
|
if (title) body.title = title;
|
|
141
149
|
try { await client.updateEntity(current.id, body); return client.getEntity(current.id); }
|
|
@@ -230,7 +238,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
230
238
|
async registry_list() {
|
|
231
239
|
const reg = await registry();
|
|
232
240
|
const products = [...reg.products.values()].map((row) => ({ registry_id: row.fields.registry_id, name: row.fields.name, entity_id: row.id, project_id: row.fields.project_id ?? null, notes: row.fields.notes ?? '',
|
|
233
|
-
repos: [...reg.repos.values()].filter((repo) => repo.fields.product === row.fields.registry_id).map((repo) => ({ registry_id: repo.fields.registry_id, entity_id: repo.id, kind: repo.fields.kind ?? null, default_branch: repo.fields.default_branch, notes: repo.fields.notes ?? '' })) }));
|
|
241
|
+
repos: [...reg.repos.values()].filter((repo) => repo.fields.product === row.fields.registry_id).map((repo) => ({ registry_id: repo.fields.registry_id, entity_id: repo.id, kind: repo.fields.kind ?? null, kinds: repoKinds(repo.fields), default_branch: repo.fields.default_branch, notes: repo.fields.notes ?? '' })) }));
|
|
234
242
|
return { verb: 'registry_list', products, read_at: now() };
|
|
235
243
|
},
|
|
236
244
|
|
|
@@ -288,7 +296,9 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
288
296
|
if (typeof readGitHub !== 'function') refuse('repo add needs a GitHub reader for the push-rights check');
|
|
289
297
|
const registryId = text(args.registry_id, 'registry_id', { max: 200 });
|
|
290
298
|
if (!REPO_ID.test(registryId)) refuse('registry_id must be a GitHub "owner/repo" name');
|
|
291
|
-
const
|
|
299
|
+
const given = kindsFromArgs(args);
|
|
300
|
+
if (!given) refuse('kind is required: give kinds, a list of what the repo is');
|
|
301
|
+
const { kinds, kind } = given;
|
|
292
302
|
const notes = text(args.notes, 'notes', { optional: true, max: 4000 }) ?? '';
|
|
293
303
|
const reg = await registry();
|
|
294
304
|
const product = await requireProduct(args.product, reg);
|
|
@@ -300,10 +310,10 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
300
310
|
const defaultBranch = text(github.default_branch, 'GitHub default_branch', { max: 200 });
|
|
301
311
|
const type = await typeNamed('repo');
|
|
302
312
|
const prior = await landed(type, requestId, meta);
|
|
303
|
-
if (prior) { await edge(prior, product, 'part_of', requestId); return { verb: 'repo_add', registry_id: prior.fields.registry_id, product: prior.fields.product, kind: prior.fields.kind ?? null, default_branch: prior.fields.default_branch, entity_id: prior.id, version: prior.version, push_rights: true, replayed_from_graph: true }; }
|
|
304
|
-
const entity = await create(type, requestId, 'repo_add', { digest: meta?.digest, title: registryId, body: `Atlas repo record for ${registryId}, product ${product.fields.registry_id}.
|
|
313
|
+
if (prior) { await edge(prior, product, 'part_of', requestId); return { verb: 'repo_add', registry_id: prior.fields.registry_id, product: prior.fields.product, kind: prior.fields.kind ?? null, kinds: repoKinds(prior.fields), default_branch: prior.fields.default_branch, entity_id: prior.id, version: prior.version, push_rights: true, replayed_from_graph: true }; }
|
|
314
|
+
const entity = await create(type, requestId, 'repo_add', { digest: meta?.digest, title: registryId, body: `Atlas repo record for ${registryId}, product ${product.fields.registry_id}. Kinds: ${kinds.join(', ')}. Default branch: ${defaultBranch}.`, fields: { registry_id: registryId, product: product.fields.registry_id, kind, kinds, default_branch: defaultBranch, notes, github_url: github.html_url ?? `https://github.com/${registryId}` }, tags: ['atlas-registry', `product:${product.fields.registry_id}`, `repo:${registryId}`] });
|
|
305
315
|
await edge(entity, product, 'part_of', requestId);
|
|
306
|
-
return { verb: 'repo_add', registry_id: registryId, product: product.fields.registry_id, kind, default_branch: defaultBranch, entity_id: entity.id, version: entity.version, push_rights: true };
|
|
316
|
+
return { verb: 'repo_add', registry_id: registryId, product: product.fields.registry_id, kind, kinds, default_branch: defaultBranch, entity_id: entity.id, version: entity.version, push_rights: true };
|
|
307
317
|
},
|
|
308
318
|
|
|
309
319
|
async repo_edit(args, requestId, meta) {
|
|
@@ -315,7 +325,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
315
325
|
await requireProduct(record.fields.product, reg);
|
|
316
326
|
const fields = {};
|
|
317
327
|
if (args.notes !== undefined) fields.notes = text(args.notes, 'notes', { max: 4000 });
|
|
318
|
-
|
|
328
|
+
const given = kindsFromArgs(args);
|
|
329
|
+
if (given) { fields.kind = given.kind; fields.kinds = given.kinds; }
|
|
319
330
|
if (args.refresh_default_branch === true) {
|
|
320
331
|
if (typeof readGitHub !== 'function') refuse('refreshing the default branch needs a GitHub reader');
|
|
321
332
|
const github = await readGitHub(`repos/${registryId}`);
|
|
@@ -323,10 +334,10 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
323
334
|
fields.default_branch = text(github.default_branch, 'GitHub default_branch', { max: 200 });
|
|
324
335
|
}
|
|
325
336
|
if (alreadyPatched(record, requestId, meta)) return { verb: 'repo_edit', registry_id: registryId, entity_id: record.id, version: record.version, changed: [], replayed_from_graph: true };
|
|
326
|
-
if (!Object.keys(fields).length) refuse('repo edit needs kind, notes or refresh_default_branch; registry_id and product are immutable');
|
|
337
|
+
if (!Object.keys(fields).length) refuse('repo edit needs kind, notes or refresh_default_branch (kind may be given as kinds, a list); registry_id and product are immutable');
|
|
327
338
|
// The kind is a field, not a tag: repo edit may change it, and a tag cannot be dropped.
|
|
328
339
|
const entity = await patch(record, 'repo_edit', requestId, fields, { digest: meta?.digest });
|
|
329
|
-
return { verb: 'repo_edit', registry_id: registryId, entity_id: entity.id, version: entity.version, kind: entity.fields.kind ?? null, changed: Object.keys(fields) };
|
|
340
|
+
return { verb: 'repo_edit', registry_id: registryId, entity_id: entity.id, version: entity.version, kind: entity.fields.kind ?? null, kinds: repoKinds(entity.fields), changed: Object.keys(fields) };
|
|
330
341
|
},
|
|
331
342
|
|
|
332
343
|
async item_propose(args, requestId, meta) {
|
|
@@ -430,12 +441,82 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
430
441
|
const item = await requireItem(args.item_id);
|
|
431
442
|
const reason = text(args.reason, 'reason', { max: 2000 });
|
|
432
443
|
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_resume', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
|
|
433
|
-
|
|
444
|
+
// Release 0.2.3: resume also undoes a drop, back to the lifecycle the
|
|
445
|
+
// item left. The drop's words stay on the item.
|
|
446
|
+
if (item.fields?.lifecycle === 'dropped') {
|
|
447
|
+
const back = LIVE_LIFECYCLES.includes(item.fields.dropped_from) ? item.fields.dropped_from : 'proposed';
|
|
448
|
+
const undone = await patch(item, 'item_resume', requestId, { lifecycle: back, resume_reason: reason, resumed_at: now(), undropped_at: now() }, { digest: meta?.digest });
|
|
449
|
+
return { verb: 'item_resume', item_id: undone.id, version: undone.version, lifecycle: back, reason, undid_drop: true };
|
|
450
|
+
}
|
|
451
|
+
if (item.fields?.lifecycle !== 'paused') refuse(`item ${item.id} is ${item.fields?.lifecycle}; only a paused or dropped item can be resumed`);
|
|
434
452
|
const lifecycle = item.fields.parked_from === 'proposed' ? 'proposed' : 'active';
|
|
435
453
|
const entity = await patch(item, 'item_resume', requestId, { lifecycle, pause_reason: '', resume_reason: reason, resumed_at: now(), parked_from: null }, { digest: meta?.digest });
|
|
436
454
|
return { verb: 'item_resume', item_id: entity.id, version: entity.version, lifecycle, reason };
|
|
437
455
|
},
|
|
438
456
|
|
|
457
|
+
// Release 0.2.3: the owner drops a piece of work in his own words. A
|
|
458
|
+
// dropped item stays in the graph; no live view lists it. A delivered item
|
|
459
|
+
// is not dropped: item_reopen is its path.
|
|
460
|
+
async item_drop(args, requestId, meta) {
|
|
461
|
+
requireOwner('item drop');
|
|
462
|
+
const item = await requireItem(args.item_id);
|
|
463
|
+
const words = origin(args.origin);
|
|
464
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_drop', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, dropped_from: item.fields.dropped_from ?? null, replayed_from_graph: true };
|
|
465
|
+
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 dropped`); };
|
|
466
|
+
live(item);
|
|
467
|
+
const at = now();
|
|
468
|
+
const entity = await patch(item, 'item_drop', requestId, (current) => ({ lifecycle: 'dropped', drop_reason: words, dropped_at: at, dropped_from: current.fields.lifecycle }), { digest: meta?.digest, guard: live });
|
|
469
|
+
return { verb: 'item_drop', item_id: entity.id, version: entity.version, lifecycle: 'dropped', dropped_from: entity.fields?.dropped_from ?? item.fields.lifecycle, reason: words };
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
// Release 0.2.3: edit an item's next step, what it waits on, and its title.
|
|
473
|
+
// The next step and the wait are the lead's to keep true, so any
|
|
474
|
+
// in-session caller may change them. The title is the owner's words, so a
|
|
475
|
+
// new title needs the owner present. Each edit keeps the old values.
|
|
476
|
+
async item_edit(args, requestId, meta) {
|
|
477
|
+
const item = await requireItem(args.item_id);
|
|
478
|
+
const fields = item.fields ?? {};
|
|
479
|
+
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 };
|
|
480
|
+
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`); };
|
|
481
|
+
live(item);
|
|
482
|
+
const change = {}, previous = {};
|
|
483
|
+
let title;
|
|
484
|
+
if (args.title !== undefined) {
|
|
485
|
+
requireOwner('item edit of a title');
|
|
486
|
+
title = text(args.title, 'title', { max: 300 });
|
|
487
|
+
if (title !== item.title) previous.title = item.title ?? '';
|
|
488
|
+
}
|
|
489
|
+
if (args.next_step !== undefined) {
|
|
490
|
+
change.next_step = text(args.next_step, 'next_step', { max: 1000 });
|
|
491
|
+
previous.next_step = fields.next_step ?? '';
|
|
492
|
+
}
|
|
493
|
+
if (args.waits_on !== undefined) {
|
|
494
|
+
const given = args.waits_on;
|
|
495
|
+
if (!given || typeof given !== 'object' || Array.isArray(given)) refuse(`waits_on must be { on, note?, item? }, with on one of ${WAITS_ON.join(', ')}`);
|
|
496
|
+
const wait = { on: oneOf(given.on, 'waits_on.on', WAITS_ON) };
|
|
497
|
+
const note = text(given.note, 'waits_on.note', { optional: true, max: 300 });
|
|
498
|
+
if (note) wait.note = note;
|
|
499
|
+
if (wait.on === 'item') {
|
|
500
|
+
const other = await requireItem(given.item);
|
|
501
|
+
if (other.id === item.id) refuse('an item cannot wait on itself');
|
|
502
|
+
wait.item = other.id;
|
|
503
|
+
} else if (given.item !== undefined) refuse('waits_on.item is only for on: item');
|
|
504
|
+
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`);
|
|
505
|
+
change.waits_on = wait;
|
|
506
|
+
previous.waits_on = fields.waits_on ?? null;
|
|
507
|
+
}
|
|
508
|
+
if (args.title === undefined && !Object.keys(change).length) refuse('item edit needs title, next_step or waits_on');
|
|
509
|
+
const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
|
|
510
|
+
const entry = { at: now(), by: label.agent, previous, ...(reason ? { reason } : {}) };
|
|
511
|
+
// The history keeps the last MAX_EDITS entries. The first title the
|
|
512
|
+
// owner gave is kept on its own, so a title never falls out of it.
|
|
513
|
+
const entity = await patch(item, 'item_edit', requestId, (current) => ({
|
|
514
|
+
...change, edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), entry].slice(-MAX_EDITS),
|
|
515
|
+
...(previous.title !== undefined && current.fields?.first_title === undefined ? { first_title: previous.title } : {}),
|
|
516
|
+
}), { digest: meta?.digest, guard: live, ...(title ? { title } : {}) });
|
|
517
|
+
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)] };
|
|
518
|
+
},
|
|
519
|
+
|
|
439
520
|
async item_stage(args, requestId, meta) {
|
|
440
521
|
const item = await requireItem(args.item_id);
|
|
441
522
|
const stage = oneOf(args.stage, 'stage', STAGES);
|
|
@@ -474,7 +555,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
474
555
|
Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
|
|
475
556
|
Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
|
|
476
557
|
|
|
477
|
-
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_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
|
|
558
|
+
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]);
|
|
478
559
|
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS]);
|
|
479
560
|
|
|
480
561
|
async function run(verb, args = {}) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atlas-work",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.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.",
|
|
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.6.0 adds 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
|
},
|
|
@@ -93,6 +93,9 @@
|
|
|
93
93
|
"kind": {
|
|
94
94
|
"type": "string"
|
|
95
95
|
},
|
|
96
|
+
"kinds": {
|
|
97
|
+
"type": "array"
|
|
98
|
+
},
|
|
96
99
|
"notes": {
|
|
97
100
|
"type": "string",
|
|
98
101
|
"default": ""
|
|
@@ -591,7 +594,7 @@
|
|
|
591
594
|
],
|
|
592
595
|
"0.3.0": [
|
|
593
596
|
"Adds question and receipt. A question holds its text, item, product, asker, date, cost of waiting and the context to answer; a receipt holds the rendered answer the owner acted on and the decision it belongs to.",
|
|
594
|
-
"Re-declares decision with the fields of design page D1: the owner
|
|
597
|
+
"Re-declares decision with the fields of design page D1: the owner’s words, date, source, the question it answers, the items it touches, the priority order, and what it replaces. $required is unchanged, so an older decision record still validates.",
|
|
595
598
|
"No new edge type. Questions, decisions and receipts point at their records by field and carry one references edge each."
|
|
596
599
|
],
|
|
597
600
|
"0.4.0": [
|
|
@@ -604,6 +607,9 @@
|
|
|
604
607
|
"kind is not a required field, so every repo record written under 0.2.0, 0.3.0 and 0.4.0 still validates. repo edit fills it.",
|
|
605
608
|
"Nothing else changes. 0.4.0 and its mark record are carried over whole.",
|
|
606
609
|
"Why 0.5.0 and not 0.4.0: M1 slice L3 published 0.4.0 for the mark record on 2026-09-17, in a session that ran beside this one. A published version never changes, so this slice took the next number. Owner decision, 2026-09-17."
|
|
610
|
+
],
|
|
611
|
+
"0.6.0": [
|
|
612
|
+
"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."
|
|
607
613
|
]
|
|
608
614
|
}
|
|
609
615
|
}
|
package/work/mcp.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { existsSync, realpathSync, readFileSync, writeFileSync, mkdirSync } from
|
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { createEngramClient } from './lib/client.mjs';
|
|
16
16
|
import { createActionStore } from './lib/action-store.mjs';
|
|
17
|
-
import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
|
|
17
|
+
import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
|
|
18
18
|
import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
|
|
19
19
|
import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
|
|
20
20
|
import { createMcpServer } from './lib/mcp-stdio.mjs';
|
|
@@ -25,6 +25,7 @@ const ATLAS_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.
|
|
|
25
25
|
const S = (description, extra = {}) => ({ type: 'string', description, ...extra });
|
|
26
26
|
const ORIGIN = { type: 'object', description: "The owner's words, verbatim, with where and when they were said.", required: ['text', 'source', 'date'], properties: { text: S('Verbatim words of the owner.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), date: S('ISO date, in the form YYYY-MM-DD.') } };
|
|
27
27
|
const REQUEST_ID = S('Stable unique id for this request. A retry with the same id and the same arguments is a no-op that returns the first receipt. The same id with different arguments is refused.');
|
|
28
|
+
const KINDS = { type: 'array', items: { type: 'string', enum: [...REPO_KINDS] }, description: `What the repo is, as a list: one or more of ${REPO_KINDS.join(', ')}. A service or a schema repo keeps the live checks of item_done; a library, a cli and a plugin are proved by the merge.` };
|
|
28
29
|
const RULES = 'Rules: every write needs request_id. Never pass writer, session, actor or role; the server stamps them. Unknown product or repo ids are refused.';
|
|
29
30
|
|
|
30
31
|
const CODE_FINDING = { type: 'object', required: ['statement', 'path', 'line'], properties: { name: S('Short label for the finding.'), statement: S('One sentence about what the code does, or what bounds it.'), path: S('Repo-relative path the claim is read from. A never-read path is refused.'), line: { type: 'integer', description: 'The line the claim is read from, 1 or higher.' }, quote: S('The minimal proving line. Never from a never-quote path; a quote from one is refused.') } };
|
|
@@ -33,16 +34,18 @@ const CODE_DIGEST = { type: 'object', description: 'One sub-agent digest for one
|
|
|
33
34
|
|
|
34
35
|
export const TOOLS = [
|
|
35
36
|
{ 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 } },
|
|
36
|
-
{ name: 'setup', description: `Publish atlas-work ${MANIFEST_VERSION} to the tenant's Forge and register its types (product, repo with its
|
|
37
|
+
{ 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 } },
|
|
37
38
|
{ 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 } },
|
|
38
39
|
{ name: 'product_edit', description: `Change a product's name, notes, health endpoint, check paths 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.' }, notes: S('Free text.') }, additionalProperties: false } },
|
|
39
|
-
{ 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
|
|
40
|
-
{ name: 'repo_edit', description: `Change a repo's
|
|
40
|
+
{ 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 } },
|
|
41
|
+
{ name: 'repo_edit', description: `Change a repo's kinds or notes, or refresh its default branch from GitHub. registry_id and product never change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing repo id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.'), refresh_default_branch: { type: 'boolean', description: 'Re-read the default branch and push rights from GitHub.' } }, additionalProperties: false } },
|
|
41
42
|
{ name: 'item_propose', description: `Create a proposed work item (lifecycle proposed, stage capture) for a product, with its repos and the owner's words as origin. When: the owner names a piece of work that is not yet locked. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'title', 'product', 'repos', 'origin'], properties: { request_id: REQUEST_ID, title: S('Short title in the owner\'s words.'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}. Default standard.`), driver: S(`One of ${DRIVERS.join(', ')}. Default owner-led.`), next_step: S('One line: what happens next.'), summary: S('Short summary.'), body: S('Longer body, optional.'), tags: { type: 'array', items: { type: 'string' } } }, additionalProperties: false } },
|
|
42
|
-
{ name: 'item_lock', description: `
|
|
43
|
+
{ name: 'item_lock', description: `Record the owner's approval of a short design: product and repos by registry id, the scope (the definition of done as text, and the design file), and the owner's words of approval as origin. That approval is the lock (target design, flow 4). The verb keeps a fingerprint of the text for the tools; nobody reads or writes a hash. Lifecycle becomes active; stage defaults to build. Refuses an unknown product or repo id, a missing scope or origin, and an item that is already locked; a change after approval is a decision_record on the same item. When: the owner approves the design. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos', 'scope', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Entity id of the work item (container).'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, scope: { type: 'object', required: ['text'], properties: { text: S('The definition of done, in full.'), design_doc: S('URL of the design file the owner approved.'), acceptance_criteria_hash: S('Old records only: a 64-hex sha256 a design doc once carried. Omit it.') } }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}.`), stage: S(`One of ${STAGES.join(', ')}. Default build.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
|
|
43
44
|
{ name: 'item_split', description: `Split an item into pieces. Each child is a proposed item (stage capture) with a spun_out_of relation to the parent and the reason. Children inherit product, repos, tier, origin and driver unless given. Refuses an unknown parent, a missing reason, or a child repo outside the product. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'children'], properties: { request_id: REQUEST_ID, item_id: S('Parent item id.'), reason: S('Why the split, in the owner\'s words.'), children: { type: 'array', items: { type: 'object', required: ['title'], properties: { title: S('Child title.'), repos: { type: 'array', items: { type: 'string' } }, tier: S(`One of ${TIERS.join(', ')}.`), origin: ORIGIN, driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line.'), summary: S('Short summary.') } } } }, additionalProperties: false } },
|
|
44
|
-
{ name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog and
|
|
45
|
-
{ name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a 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 resumes.') }, additionalProperties: false } },
|
|
45
|
+
{ 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 } },
|
|
46
|
+
{ 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 } },
|
|
47
|
+
{ 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 } },
|
|
48
|
+
{ name: 'item_edit', description: `Edit a proposed, active or paused item: its next step, what it waits on, or its title. Each edit appends the old values to the item's edits, and the last 20 are kept; the first title stays in first_title. When: the next step or the wait changes; the lead keeps them true at the end of each session. No owner flag for next_step and waits_on; a new title is the owner's words, so it needs the owner present. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), next_step: S('One line: what happens next.'), waits_on: { type: 'object', required: ['on'], description: 'What the item waits on. Shown on its rows.', properties: { on: S(`One of ${WAITS_ON.join(', ')}.`), note: S('What exactly it waits on, in plain words. Required unless on is item or nothing.'), item: S('The item id it waits on, for on: item.') } }, title: S("A new title, in the owner's words. Owner present only."), reason: S('One line on why it changed.') }, additionalProperties: false } },
|
|
46
49
|
{ 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 } },
|
|
47
50
|
{ 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 } },
|
|
48
51
|
{ 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 } },
|
|
@@ -50,11 +53,11 @@ export const TOOLS = [
|
|
|
50
53
|
{ name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
|
|
51
54
|
{ name: 'decision_record', description: `Record what the owner decided outside a question, in their own words. Refuses missing text and a missing date. Pass rendered to keep the view the owner acted on as a receipt in the same call. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'date'], properties: { request_id: REQUEST_ID, text: S("The owner's words, verbatim."), date: S('ISO date the owner said it.'), source: S('Where the words are on record.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default decision.`), product: S('Product id the decision is about.'), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on.'), view: S('Which view the owner read.') }, additionalProperties: false } },
|
|
52
55
|
{ name: 'receipt_keep', description: `Keep the rendered answer the owner acted on and attach it to a decision. Refuses when there is no decision to attach to. Secret patterns are redacted before the write. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'decision_id', 'rendered'], properties: { request_id: REQUEST_ID, decision_id: S('Entity id of the decision this receipt belongs to.'), rendered: S('The answer as the owner read it.'), view: S('Which view it came from, for example needs-me.') }, additionalProperties: false } },
|
|
53
|
-
{ name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. A library
|
|
56
|
+
{ name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.') }, additionalProperties: false } },
|
|
54
57
|
{ name: 'item_reopen', description: `Reopen a delivered item: the lifecycle goes back to active and a reopen mark is appended, citing the failed check by URL. The delivery mark is never erased; the reopen points at it with the corrects relation. The owner, or the lead in this session, calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'failed_check'], properties: { request_id: REQUEST_ID, item_id: S('Item id carrying at least one delivery mark.'), reason: S('Why it is reopened, in plain words.'), failed_check: S('Absolute URL of the check that failed: a CI run, a monitor, an issue.'), corrects: S('Mark id of the delivery this reopen corrects. Default: the item\'s latest delivery mark.') }, additionalProperties: false } },
|
|
55
|
-
{ name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by days since the last change with the latest on top, and the days shown on each row. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
|
|
58
|
+
{ name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
|
|
56
59
|
{ name: 'sweep_check', description: 'Check the sweep against the "In flight" view: the newest decision record of kind sweep names the items the owner called in flight, and this reads the view and names every difference — what is listed and not in flight, and what is in flight and not listed. A product with no sweep on record is named too. It answers two things apart: `matches` says each product\'s view holds exactly what its sweep lists, and `complete` says that and that no item in the tenant sits outside every product. An item nobody adopted is missing from both sides, so `matches` alone can read "equal" over an unfinished sweep. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the check to one product id.') }, additionalProperties: false } },
|
|
57
|
-
{ name: 'needs_me', description: 'Answer "what needs me?": every open question on
|
|
60
|
+
{ name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, no owner flag needed. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
|
|
58
61
|
{ name: 'code_read_brief', description: 'The brief for the sub-agents that read the code for "where are we with X", one per registered repo of the product. It carries the never-read and never-quote rules, the citation contract and the JSON shape of the digest. Give one brief to one sub-agent; sub-agents never receive this server or the Engram MCP. Pass each digest back in where_are_we.code. When: before answering "where are we with X". Scope: read, no owner flag needed; it opens no repo and writes nothing.', inputSchema: { type: 'object', required: ['product', 'capability'], properties: { product: S('Product registry id.'), capability: S('The capability to read for, the X of the question.'), repos: { type: 'array', description: 'Limit the briefs to these registered repo ids. Default: every repo of the product.', items: { type: 'string' } }, roots: { type: 'object', description: 'Repo id to the local checkout path the sub-agent reads.', additionalProperties: { type: 'string' } }, commits: { type: 'object', description: 'Repo id to the commit the read runs against.', additionalProperties: { type: 'string' } }, focus: { type: 'array', description: 'Extra things the read should look for.', items: { type: 'string' } } }, additionalProperties: false } },
|
|
59
62
|
{ name: 'where_are_we', description: 'Answer "where are we with X" in six parts: live support and known limits from the code read at question time, then in progress, delivered and proposed from the items, then the current priority decision. Every row carries a citation: a path and a line, an entity id, a pull request, or a decision date. The code half comes from the sub-agent digests, which are refused when they cite a never-read path or quote a never-quote path, and every string is scrubbed by pattern. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product', 'capability', 'code'], properties: { product: S('Product registry id.'), capability: S('The capability the question is about, the X.'), code: { type: 'array', description: 'One digest per repo, from the sub-agents briefed by code_read_brief. At least one is required: an answer with no code read says nothing about what the code does today.', items: CODE_DIGEST }, items: { type: 'array', description: 'Item ids the lead judges to answer this question.', items: { type: 'string' } }, match: { type: 'array', description: 'Terms to match against item titles, summaries and tags. Give items, match, or both.', items: { type: 'string' } }, movement_repos: { type: 'array', description: "Narrow the movement read to these registered repo ids. Default: every repo of the product. The digests' own repos are always read.", items: { type: 'string' } }, since: S('ISO date. Adds what changed since then.') }, additionalProperties: false } },
|
|
60
63
|
{ name: 'built_pending_possible', description: 'Answer "what are we building, what is built, what is pending and what can be done": delivered items by date, active items, paused items with their reason, proposed items with their age, and the decisions that set the order. Item records, delivery marks and decisions only; no code read and no GitHub read. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Limits built and the decisions to what came after it.') }, additionalProperties: false } },
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
".stack/",
|
|
9
9
|
".atlas/",
|
|
10
10
|
".mcp.json",
|
|
11
|
-
".sdlc/"
|
|
11
|
+
".sdlc/",
|
|
12
|
+
".dev.vars*"
|
|
12
13
|
],
|
|
13
14
|
"never_quote": [
|
|
14
15
|
"fixtures",
|
|
@@ -30,7 +31,8 @@
|
|
|
30
31
|
"agents/reviewer-architect.md",
|
|
31
32
|
"agents/reviewer-pm.md",
|
|
32
33
|
"agents/reviewer-security.md",
|
|
33
|
-
"agents/artifact-renderer.md"
|
|
34
|
+
"agents/artifact-renderer.md",
|
|
35
|
+
"agents/verifier.md"
|
|
34
36
|
],
|
|
35
37
|
"sub_agents_that_write_files": [
|
|
36
38
|
"agents/artifact-renderer.md"
|