@arjunkhera/atlas 0.4.49 → 0.4.51
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/door/cli.mjs +51 -4
- package/door/lib/changed-docs.mjs +22 -5
- package/door/lib/design-freeze.mjs +251 -0
- package/door/lib/doc-sources.mjs +11 -16
- package/door/lib/owner-only.mjs +22 -0
- package/door/lib/runner.mjs +286 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +2 -1
- package/skills/runner/SKILL.md +83 -0
- package/skills/runner/trigger.yaml +24 -0
- package/skills/sdlc-task/SKILL.md +9 -1
- package/skills/sdlc-task/design/README.md +8 -5
- package/skills/upkeep/SKILL.md +26 -12
- package/work/lib/delivery.mjs +22 -9
- package/work/lib/questions.mjs +2 -2
- package/work/lib/roles.mjs +5 -2
- package/work/lib/roundup.mjs +26 -5
- package/work/lib/verb-fields.mjs +57 -0
- package/work/lib/verbs.mjs +83 -7
- package/work/lib/view-rules.mjs +19 -0
- package/work/mcp.mjs +11 -4
package/work/lib/delivery.mjs
CHANGED
|
@@ -13,6 +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 { originShown, originNote, isRunnerItem } from './view-rules.mjs';
|
|
16
17
|
import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
|
|
17
18
|
import { parseYaml } from '../../shape/check.mjs';
|
|
18
19
|
import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
|
|
@@ -584,7 +585,9 @@ export function createDeliveryVerbs(core) {
|
|
|
584
585
|
const item = await requireItem(args.item_id);
|
|
585
586
|
const fields = item.fields ?? {};
|
|
586
587
|
const replay = alreadyPatched(item, requestId, meta);
|
|
587
|
-
|
|
588
|
+
// R22 line 5: a runner item stays proposed until its work merges, so
|
|
589
|
+
// it is marked from proposed. Its proof is the same.
|
|
590
|
+
if (!replay && fields.lifecycle !== 'active' && !isRunnerItem(fields)) refuse(`item ${item.id} is ${fields.lifecycle}; only an active item, or a proposed item of a runner, is marked done`);
|
|
588
591
|
const reg = await registry();
|
|
589
592
|
const product = await requireProduct(fields.product, reg);
|
|
590
593
|
// A retry of an interrupted write finishes what landed, and it is read
|
|
@@ -893,7 +896,7 @@ export function createDeliveryVerbs(core) {
|
|
|
893
896
|
const orders = new Map([...new Set(items.map((row) => row.fields.product))].map((productId) => [productId, priorityOrderOf(decisions, productId)]));
|
|
894
897
|
|
|
895
898
|
const readAt = now();
|
|
896
|
-
const rows = [], delivered = [], notMarked = [];
|
|
899
|
+
const rows = [], delivered = [], notMarked = [], runnerDone = [];
|
|
897
900
|
for (const item of items) {
|
|
898
901
|
const fields = item.fields ?? {};
|
|
899
902
|
const own = marks.get(item.id) ?? [];
|
|
@@ -919,32 +922,39 @@ export function createDeliveryVerbs(core) {
|
|
|
919
922
|
// pull requests (its design, its lock) are history, not a gap; a
|
|
920
923
|
// reopened item is active again, so its pull requests come back
|
|
921
924
|
// (roadmap R70 line 5).
|
|
922
|
-
|
|
925
|
+
// R22 line 4: a runner item stays proposed, because a routine moves
|
|
926
|
+
// no stage. Its stage comes from GitHub: an open pull request that
|
|
927
|
+
// names it reads as build, a merged one as verify.
|
|
928
|
+
const runner = isRunnerItem(fields);
|
|
929
|
+
const githubStage = runner ? (mine.some((pull) => pull.state === 'open') ? 'build' : merged.length ? 'verify' : null) : null;
|
|
930
|
+
const inScope = fields.lifecycle === 'active' || Boolean(runner && githubStage);
|
|
931
|
+
if (runner && open.length) runnerDone.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, prs: open.map((pull) => pull.url) });
|
|
923
932
|
for (const pull of (inScope ? open : [])) notMarked.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, pr: pull.url, title: pull.title, merged_at: pull.merged_at, days: daysBetween(pull.merged_at, readAt), matched_by: pull.matched_by, note: 'merged, not marked' });
|
|
924
933
|
for (const mark of deliveries) if (!since || String(mark.fields.marked_at ?? '') >= since) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: mark.id, verdict: correctedBy.has(mark.id) ? 'corrected' : (mark.fields.verdict ?? 'delivered'), ...(correctedBy.has(mark.id) ? { corrected_by: correctedBy.get(mark.id) } : {}), delivered_at: mark.fields.marked_at, prs: (mark.fields.prs ?? []).map((pull) => pull.url), deployed_commit: mark.fields.deployed?.commit ?? null, gate_warnings: mark.fields.gate_warnings ?? [] });
|
|
925
934
|
if (legacy && (!since || String(legacy.marked_at) >= since)) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: null, verdict: 'legacy', delivered_at: legacy.marked_at, prs: [], deployed_commit: null, source: legacy.source });
|
|
926
935
|
|
|
927
936
|
// D3 rule 1: in flight is active work. Delivered and paused items leave
|
|
928
937
|
// the rows; what was delivered since a date has its own list.
|
|
929
|
-
if (
|
|
938
|
+
if (!inScope) continue;
|
|
939
|
+
const stage = githubStage ?? fields.stage ?? '';
|
|
930
940
|
const blockers = blockersOf(item, (id) => anyById.get(id));
|
|
931
941
|
const blocks = blocksOf(item, all);
|
|
932
942
|
const blocked = blockedPhrase(blockers, blocks);
|
|
933
943
|
const waits = waitsShown(fields);
|
|
934
944
|
rows.push({
|
|
935
|
-
item: item.id, product: fields.product, stage
|
|
945
|
+
item: item.id, product: fields.product, stage, ...(githubStage ? { stage_from: 'github', runner: true } : {}), driver: fields.driver ?? '', days_since_last_change: days,
|
|
936
946
|
last_change_at: last?.at ?? null, last_change_source: last?.source ?? 'nothing recorded', delivery_state: state,
|
|
937
947
|
lines: [
|
|
938
948
|
// The id is what a reader types back, so the title gives way to it
|
|
939
949
|
// inside the line budget, as the "Needs me" row does (review G6).
|
|
940
950
|
oneLine(`${oneLine(item.title, Math.max(1, ROW_LINE_MAX - item.id.length - 3))} · ${item.id}`),
|
|
941
|
-
oneLine(`${fields.product} · ${
|
|
942
|
-
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 })}`),
|
|
951
|
+
oneLine(`${fields.product} · ${stage || 'no stage'}${githubStage ? ' (from GitHub, a runner item)' : ''} · ${fields.driver ?? 'no driver'}${waitsOnLine(waits) ? ` · ${waitsOnLine(waits)}` : ''} · next: ${fields.next_step || 'not set'}`),
|
|
952
|
+
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` : ''}${originNote(fields.origin)}${orderNote(orders, { product: fields.product, item: item.id })}`),
|
|
943
953
|
// The blockers get their own line, so a long list never cuts the
|
|
944
954
|
// next step off line two; they come last.
|
|
945
955
|
...(blocked ? [oneLine(blocked)] : []),
|
|
946
956
|
],
|
|
947
|
-
waits_on: waits, blocked_by: blockers, blocks,
|
|
957
|
+
waits_on: waits, blocked_by: blockers, blocks, origin: originShown(fields.origin),
|
|
948
958
|
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 })) },
|
|
949
959
|
citation: { item: item.id, read_at: readAt, repos: [...new Set(mine.map((pull) => pull.repo))] },
|
|
950
960
|
});
|
|
@@ -958,6 +968,9 @@ export function createDeliveryVerbs(core) {
|
|
|
958
968
|
const products = [...new Set(rows.map((row) => row.product))].sort();
|
|
959
969
|
const byProduct = products.map((product) => ({ product, rows: rows.filter((row) => row.product === product) }));
|
|
960
970
|
const block = byProduct.map((group) => [`${group.product} — in flight (${group.rows.length})${orders.get(group.product) ? ` · in your order of ${orders.get(group.product).date}, then by the last change` : ''}`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n'));
|
|
971
|
+
// R22 line 5: the runner's merged work, for the lead to mark in one
|
|
972
|
+
// batch with item_done, which proves each merge as R17 says.
|
|
973
|
+
if (runnerDone.length) block.push([`runner deliveries to mark (${runnerDone.length}): call item_done for each`, ...runnerDone.map((row) => listLine(row.item_title, `${row.item} · ${row.prs.join(' ')}`))].join('\n'));
|
|
961
974
|
if (notMarked.length) block.push([`merged, not marked (${notMarked.length})`, ...notMarked.map((row) => listLine(row.title, `${row.pr} · ${row.item} · merged ${row.days} day${row.days === 1 ? '' : 's'} ago`))].join('\n'));
|
|
962
975
|
if (repoErrors.length) block.push([`GitHub did not answer for ${repoErrors.length} repo${repoErrors.length === 1 ? '' : 's'}; movement below is from the graph only`, ...repoErrors.map((row) => oneLine(` ${row.repo} — ${row.reason}`, ROW_LINE_MAX))].join('\n'));
|
|
963
976
|
if (unregisteredTotal && !only) {
|
|
@@ -976,7 +989,7 @@ export function createDeliveryVerbs(core) {
|
|
|
976
989
|
return {
|
|
977
990
|
verb: 'in_flight', read_at: readAt, product: only, since, rows: rows.length, products: byProduct,
|
|
978
991
|
priority: Object.fromEntries([...orders].filter(([, order]) => order).map(([productId, order]) => [productId, { decision: order.decision, date: order.date }])),
|
|
979
|
-
merged_not_marked: notMarked, delivered: since ? delivered : delivered.slice(0, 20), unregistered, unregistered_total: unregisteredTotal, repos_read: repoIds, repo_errors: repoErrors,
|
|
992
|
+
merged_not_marked: notMarked, runner_deliveries: runnerDone, delivered: since ? delivered : delivered.slice(0, 20), unregistered, unregistered_total: unregisteredTotal, repos_read: repoIds, repo_errors: repoErrors,
|
|
980
993
|
text: block.join('\n\n') || 'Nothing is in flight: no active item on a registered product.',
|
|
981
994
|
};
|
|
982
995
|
},
|
package/work/lib/questions.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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
8
|
import { refuse, text, oneOf, isoDate, url, stringList } from './verb-fields.mjs';
|
|
9
|
-
import { COSTS, COST_MEANING, ROW_LINE_MAX, oneLine, needsYou } from './view-rules.mjs';
|
|
9
|
+
import { COSTS, COST_MEANING, ROW_LINE_MAX, oneLine, needsYou, originShown } from './view-rules.mjs';
|
|
10
10
|
|
|
11
11
|
// R5 (the costs), R8 (the row width), the blocker rules and the needs-you
|
|
12
12
|
// order live in view-rules.mjs, which the project view page also runs.
|
|
@@ -282,7 +282,7 @@ export function createQuestionVerbs(core) {
|
|
|
282
282
|
const kept = ordered.kept.map(({ question: row, item, lifecycle, cost, cost_rank: costRank }) => {
|
|
283
283
|
const asked = row.fields.asked_by ?? {};
|
|
284
284
|
return {
|
|
285
|
-
question_id: row.id, product: row.fields.product, item: item.id, item_lifecycle: lifecycle, cost, cost_rank: costRank,
|
|
285
|
+
question_id: row.id, product: row.fields.product, item: item.id, item_lifecycle: lifecycle, item_origin: originShown(item.fields?.origin), cost, cost_rank: costRank,
|
|
286
286
|
asked_at: row.fields.asked_at ?? row.created_at ?? '', asked_by: asked.agent ?? 'unknown',
|
|
287
287
|
lines: [
|
|
288
288
|
oneLine(row.fields.text),
|
package/work/lib/roles.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// process, and a scheduled run carries the same variables as a project
|
|
7
7
|
// thread. So the role comes from two things only:
|
|
8
8
|
// 1. the key type, which gives the widest role: a user key gives lead, an
|
|
9
|
-
// agent key gives routine.
|
|
10
|
-
// only
|
|
9
|
+
// agent key gives routine. R22 opened the agent key: the runner's
|
|
10
|
+
// own propose-only Engram key, which the owner mints;
|
|
11
11
|
// 2. the launcher's marker ATLAS_WORK_ROLE, which can only narrow it.
|
|
12
12
|
// No role is the owner. The lead writes down the owner's words; the owner's
|
|
13
13
|
// own act is a signed act (R72), which the owner door reads (R27).
|
|
@@ -56,6 +56,9 @@ export const VERB_ROLES = Object.freeze({
|
|
|
56
56
|
// The owner-only arguments: a verb that one role may call, with an argument
|
|
57
57
|
// that only a narrower set of roles may pass. when says if a call holds it.
|
|
58
58
|
export const ARGUMENT_ROLES = Object.freeze({
|
|
59
|
+
// R23: a routine names an input or an outside origin. The owner's words
|
|
60
|
+
// are the lead's to write down, so a routine never fakes them.
|
|
61
|
+
'item_propose.origin': { verb: 'item_propose', roles: LEAD, owner_words: true, name: 'a words origin', when: (args) => !(args.origin && typeof args.origin === 'object' && args.origin.kind && args.origin.kind !== 'words') },
|
|
59
62
|
'item_edit.title': { verb: 'item_edit', roles: LEAD, owner_words: true, when: (args) => args.title !== undefined },
|
|
60
63
|
'item_edit.plan': { verb: 'item_edit', roles: LEAD, owner_words: true, when: (args) => args.plan !== undefined },
|
|
61
64
|
'item_comment.voice': { verb: 'item_comment', roles: LEAD, owner_words: true, name: '`voice: owner`', when: (args) => args.voice === 'owner' },
|
package/work/lib/roundup.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// Every row carries a citation: an item id, a question id or a pull request
|
|
24
24
|
// URL. It is computed at read time and never stored.
|
|
25
25
|
import { refuse, isoDate, LIVE_LIFECYCLES } from './verb-fields.mjs';
|
|
26
|
+
import { originShown, shownNote } from './view-rules.mjs';
|
|
26
27
|
import { oneLine, ROW_LINE_MAX } from './questions.mjs';
|
|
27
28
|
import { readPullsByRepo, pullsOfItem, daysBetween, listLine } from './delivery.mjs';
|
|
28
29
|
import { readNumbers, periodOf } from './numbers.mjs';
|
|
@@ -33,6 +34,23 @@ export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
|
|
|
33
34
|
// A free tag that places an item on its product's ladder. An item tagged
|
|
34
35
|
// stage-2 is the stage 2 item or one of its parts.
|
|
35
36
|
export const STAGE_TAG = /^stage-(\d{1,2})$/;
|
|
37
|
+
// Roadmap R22 line 6: a tracker record that changed with no Atlas receipt
|
|
38
|
+
// just before. Every Atlas write stamps a receipt; a write made straight to
|
|
39
|
+
// Engram, by any key, does not. A record changed more than this long after
|
|
40
|
+
// its newest receipt is listed. A record with no receipt at all is older
|
|
41
|
+
// than the receipts, and is not listed.
|
|
42
|
+
export const RECEIPT_GAP_MINUTES = 2;
|
|
43
|
+
export function writesWithNoReceipt(rows) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
const times = Object.values(row.fields?.atlas_receipts ?? {}).map((receipt) => Date.parse(receipt?.at ?? '')).filter(Number.isFinite);
|
|
47
|
+
const changed = Date.parse(row.updated_at ?? '');
|
|
48
|
+
if (!times.length || !Number.isFinite(changed)) continue;
|
|
49
|
+
const last = Math.max(...times);
|
|
50
|
+
if (changed - last > RECEIPT_GAP_MINUTES * 60 * 1000) out.push({ item: row.id, title: oneLine(row.title ?? '', 120), updated_at: row.updated_at, last_receipt_at: new Date(last).toISOString() });
|
|
51
|
+
}
|
|
52
|
+
return out.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
|
|
53
|
+
}
|
|
36
54
|
export const STUCK_DAYS = 14;
|
|
37
55
|
// Roadmap R14 done line 6: the daily audit runs in the watcher workflow. A
|
|
38
56
|
// repo whose watcher has not run for this long is flagged in part 4.
|
|
@@ -166,7 +184,7 @@ export function createRoundupVerbs(core) {
|
|
|
166
184
|
const deliveries = (flight.delivered ?? []).filter((row) => String(row.delivered_at) >= since)
|
|
167
185
|
.map((row) => ({ item: row.item, title: row.item_title, verdict: row.verdict, delivered_at: row.delivered_at, prs: row.prs }));
|
|
168
186
|
const created = items.filter((row) => String(row.created_at ?? '') >= since)
|
|
169
|
-
.map((row) => ({ item: row.id, title: oneLine(row.title, 120), lifecycle: row.fields?.lifecycle ?? 'unknown', created_at: row.created_at }))
|
|
187
|
+
.map((row) => ({ item: row.id, title: oneLine(row.title, 120), lifecycle: row.fields?.lifecycle ?? 'unknown', created_at: row.created_at, origin: originShown(row.fields?.origin) }))
|
|
170
188
|
.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
|
|
171
189
|
|
|
172
190
|
// 4. Stuck or drifting.
|
|
@@ -184,6 +202,8 @@ export function createRoundupVerbs(core) {
|
|
|
184
202
|
const markType = await typeNamed('mark');
|
|
185
203
|
const marks = await client.listAllEntities({ type: markType.name, state: 'active' });
|
|
186
204
|
const productMarks = marks.filter((row) => row.fields?.product === product);
|
|
205
|
+
// R22 line 6: the items and the marks of the product.
|
|
206
|
+
const noReceipt = writesWithNoReceipt([...items, ...productMarks]);
|
|
187
207
|
const revertsKnown = !repoErrors.length;
|
|
188
208
|
const weeks = Array.from({ length: TREND_WEEKS }, (_, index) => ({ week: index + 1, from: dayOf(minus(readAt, (index + 1) * 7)), to: dayOf(minus(readAt, index * 7)), merged: 0, delivered: 0, new_items: 0, lessons: 0, repeats: 0, step_ins: 0, reverts: revertsKnown ? 0 : null }));
|
|
189
209
|
const bump = (at, key) => { const week = weekOf(at, readAt); if (week && weeks[week - 1][key] !== null) weeks[week - 1][key] += 1; };
|
|
@@ -205,13 +225,13 @@ export function createRoundupVerbs(core) {
|
|
|
205
225
|
repos: repoIds.map((id) => ({ id, default_branch: reg.repos.get(id)?.fields?.default_branch ?? null })),
|
|
206
226
|
});
|
|
207
227
|
|
|
208
|
-
const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits, weeks, oldest, repoErrors, numbers });
|
|
228
|
+
const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits, noReceipt, weeks, oldest, repoErrors, numbers });
|
|
209
229
|
return {
|
|
210
230
|
verb: 'roundup', read_at: readAt, product, window_days: windowDays, since,
|
|
211
231
|
needs_you: needRows,
|
|
212
232
|
where_we_stand: { ladder, lifecycles, active },
|
|
213
233
|
what_moved: { merged, delivered: deliveries, new_items: created },
|
|
214
|
-
stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS, audits_not_run: audits.stale, audits_unread: audits.unread, audit_stale_hours: AUDIT_STALE_HOURS },
|
|
234
|
+
stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, written_with_no_receipt: noReceipt, stuck_days: STUCK_DAYS, audits_not_run: audits.stale, audits_unread: audits.unread, audit_stale_hours: AUDIT_STALE_HOURS },
|
|
215
235
|
trend: { weeks, open_questions: needRows.length, oldest_question_days: oldest },
|
|
216
236
|
numbers,
|
|
217
237
|
repos_read: repoIds, repo_errors: repoErrors,
|
|
@@ -223,7 +243,7 @@ export function createRoundupVerbs(core) {
|
|
|
223
243
|
}
|
|
224
244
|
|
|
225
245
|
function render(view) {
|
|
226
|
-
const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits = { stale: [], unread: [] }, weeks, oldest, repoErrors, numbers } = view;
|
|
246
|
+
const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, audits = { stale: [], unread: [] }, noReceipt = [], weeks, oldest, repoErrors, numbers } = view;
|
|
227
247
|
const more = (list) => (list.length > SHOWN ? [` and ${list.length - SHOWN} more`] : []);
|
|
228
248
|
const out = [`${product} — roundup, ${dayOf(readAt)}`];
|
|
229
249
|
|
|
@@ -243,7 +263,7 @@ function render(view) {
|
|
|
243
263
|
...deliveries.slice(0, SHOWN).map((row) => listLine(`delivered: ${row.title}`, `${row.item} · ${dayOf(row.delivered_at)}`)),
|
|
244
264
|
...merged.slice(0, SHOWN).map((row) => listLine(`merged: ${row.title}`, `${row.pr} · ${dayOf(row.merged_at)}${row.item ? '' : ' · no item'}`)),
|
|
245
265
|
...more(merged),
|
|
246
|
-
...created.slice(0, SHOWN).map((row) => listLine(`new: ${row.title}`, `${row.item} · ${row.lifecycle}`)),
|
|
266
|
+
...created.slice(0, SHOWN).map((row) => listLine(`new: ${row.title}`, `${row.item} · ${row.lifecycle}${shownNote(row.origin)}`)),
|
|
247
267
|
...more(created)].join('\n'));
|
|
248
268
|
|
|
249
269
|
const stuck = [`4. Stuck or drifting`];
|
|
@@ -253,6 +273,7 @@ function render(view) {
|
|
|
253
273
|
stuck.push(` questions waiting ${STUCK_DAYS} days or more (${longWaits.length})`, ...longWaits.slice(0, SHOWN).map((row) => listLine(row.text, `${row.question} · ${row.days_waiting} days`, ' ')));
|
|
254
274
|
stuck.push(` audits with no run in ${AUDIT_STALE_HOURS} hours (${audits.stale.length})`, ...audits.stale.slice(0, SHOWN).map((row) => listLine(row.repo, row.last_run_at ? `last run ${row.last_run_at}, ${row.hours} hours` : 'no run yet', ' ')));
|
|
255
275
|
for (const row of audits.unread.slice(0, SHOWN)) stuck.push(listLine(row.repo, row.reason, ' '));
|
|
276
|
+
stuck.push(` records written with no Atlas receipt (${noReceipt.length})`, ...noReceipt.slice(0, SHOWN).map((row) => listLine(row.title, `${row.item} · changed ${row.updated_at}, last receipt ${row.last_receipt_at}`, ' ')));
|
|
256
277
|
out.push(stuck.join('\n'));
|
|
257
278
|
|
|
258
279
|
out.push([`5. Trend, per week (week 1 is the last seven days)`,
|
package/work/lib/verb-fields.mjs
CHANGED
|
@@ -41,6 +41,63 @@ export function origin(value) {
|
|
|
41
41
|
if (!value || typeof value !== 'object' || Array.isArray(value)) refuse('origin must be an object with text, source and date, in the owner\'s words');
|
|
42
42
|
return { text: text(value.text, 'origin.text'), source: text(value.source, 'origin.source', { max: 500 }), date: isoDate(value.date, 'origin.date') };
|
|
43
43
|
}
|
|
44
|
+
// Roadmap R23: the three kinds of origin an item may have.
|
|
45
|
+
// words the owner's words: { text, source, date }. Old records carry no
|
|
46
|
+
// kind, and read as words.
|
|
47
|
+
// input a typed input that code found, such as a watcher issue:
|
|
48
|
+
// { kind, url, key, date }, and no prose.
|
|
49
|
+
// outside prose that a person or an agent wrote, in an issue body or a
|
|
50
|
+
// comment, under any login: { kind, url, from, date }, and no
|
|
51
|
+
// prose. The text stays on GitHub; the tracker keeps the link.
|
|
52
|
+
// Only the lead may write the owner's words (roles.mjs), so a routine names
|
|
53
|
+
// an input or outside origin and never fakes his words.
|
|
54
|
+
export const ORIGIN_KINDS = Object.freeze(['words', 'input', 'outside']);
|
|
55
|
+
export const OUTSIDE_FROM = Object.freeze(['issue', 'comment']);
|
|
56
|
+
const INPUT_KEY = /^[A-Za-z0-9][A-Za-z0-9._:@/#=+-]{0,199}$/;
|
|
57
|
+
export const originKind = (value) => (value && typeof value === 'object' && value.kind ? value.kind : 'words');
|
|
58
|
+
|
|
59
|
+
export function itemOrigin(value) {
|
|
60
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) refuse('origin must be an object: the owner\'s words { text, source, date }, or { kind: "input", url, key, date }, or { kind: "outside", url, from, date }');
|
|
61
|
+
const kind = oneOf(value.kind, 'origin.kind', ORIGIN_KINDS, 'words');
|
|
62
|
+
if (kind === 'words') {
|
|
63
|
+
if (value.kind !== undefined) refuse('origin.kind words is the default: leave kind out, and give text, source and date');
|
|
64
|
+
return origin(value);
|
|
65
|
+
}
|
|
66
|
+
const allowed = kind === 'input' ? ['kind', 'url', 'key', 'date'] : ['kind', 'url', 'from', 'date'];
|
|
67
|
+
const extra = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
68
|
+
if (extra.length) refuse(`an origin of kind ${kind} holds no prose and no other field; it takes only ${allowed.join(', ')}, not ${extra.join(', ')}`);
|
|
69
|
+
const shaped = { kind, url: url(value.url, 'origin.url') };
|
|
70
|
+
if (kind === 'input') {
|
|
71
|
+
const key = text(value.key, 'origin.key', { max: 200 });
|
|
72
|
+
if (!INPUT_KEY.test(key)) refuse('origin.key must be one typed key with no spaces, for example red-main:checks.yml@a1b2c3d');
|
|
73
|
+
shaped.key = key;
|
|
74
|
+
} else {
|
|
75
|
+
shaped.from = oneOf(value.from, 'origin.from', OUTSIDE_FROM);
|
|
76
|
+
}
|
|
77
|
+
shaped.date = isoDate(value.date, 'origin.date');
|
|
78
|
+
return shaped;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The tag that marks one input URL (R23 line 3). The tracker owns it: no
|
|
82
|
+
// caller adds it or takes it off. The hash is of the URL in one form: no
|
|
83
|
+
// slash at the end, and on github.com, where names have no case, no capitals.
|
|
84
|
+
export const INPUT_TAG_PREFIX = 'input:';
|
|
85
|
+
export function sameUrl(address) {
|
|
86
|
+
const parsed = new URL(String(address).trim());
|
|
87
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
|
88
|
+
if (parsed.hostname === 'github.com') parsed.pathname = parsed.pathname.toLowerCase();
|
|
89
|
+
return parsed.toString();
|
|
90
|
+
}
|
|
91
|
+
export const inputTag = (address) => `${INPUT_TAG_PREFIX}${sha256(sameUrl(address)).slice(0, 16)}`;
|
|
92
|
+
|
|
93
|
+
// R23 line 4: an item from outside text never starts by policy. The policy
|
|
94
|
+
// door (R27) calls this; only a signed ready mark of the owner starts such
|
|
95
|
+
// an item (R72), and item_lock checks that mark.
|
|
96
|
+
export function startsByPolicy(fields) {
|
|
97
|
+
if (originKind(fields?.origin) === 'outside') return { ok: false, why: 'the item came from outside text; only the owner\'s signed ready mark starts it' };
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
44
101
|
export function url(value, name) {
|
|
45
102
|
text(value, name, { max: 2000 });
|
|
46
103
|
let parsed;
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// additions add entry, the log of an item, in 0.7.0.
|
|
16
16
|
import manifestCurrent from '../manifest-0.7.0.json' with { type: 'json' };
|
|
17
17
|
import { EngramHttpError } from './client.mjs';
|
|
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 { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, itemOrigin, originKind, inputTag, INPUT_TAG_PREFIX, url, stringList, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds, kindsFromArgs } from './verb-fields.mjs';
|
|
19
19
|
import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS, oneLine, scrubSecrets } from './questions.mjs';
|
|
20
20
|
import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
|
|
21
21
|
import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capability.mjs';
|
|
@@ -24,9 +24,14 @@ import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VER
|
|
|
24
24
|
import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
|
|
25
25
|
import { ROLES, allow } from './roles.mjs';
|
|
26
26
|
import { createLessonVerbs, LESSON_WRITE_VERBS, LESSON_READ_VERBS } from './lessons.mjs';
|
|
27
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
28
|
+
import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct } from '../../door/lib/signed-act.mjs';
|
|
27
29
|
|
|
28
30
|
export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
|
|
29
31
|
export const MANIFEST_VERSION = '0.7.0';
|
|
32
|
+
// R23 line 7: the default weekly cap on the items a routine proposes.
|
|
33
|
+
export const AGENT_PROPOSALS_PER_WEEK = 10;
|
|
34
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
30
35
|
export const PRODUCT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
31
36
|
export const REPO_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/;
|
|
32
37
|
export const STAGES = Object.freeze(['capture', 'spec', 'build', 'verify', 'learn']);
|
|
@@ -68,7 +73,7 @@ export function trimReceipts(fields, receipts, logged = new Set(), keep = null)
|
|
|
68
73
|
}
|
|
69
74
|
// The tags that other verbs own (work item additions, slice 1). item_edit
|
|
70
75
|
// never adds or removes them; item_propose drops them from the caller's list.
|
|
71
|
-
export const OWNED_TAG_PREFIXES = Object.freeze(['product:', 'repo:', 'tier:', 'item:']);
|
|
76
|
+
export const OWNED_TAG_PREFIXES = Object.freeze(['product:', 'repo:', 'tier:', 'item:', INPUT_TAG_PREFIX]);
|
|
72
77
|
export const OWNED_TAGS = Object.freeze(['atlas-work', 'no-model', 'atlas-registry']);
|
|
73
78
|
// Engram reads no-model with the spaces trimmed and in any case, so the check
|
|
74
79
|
// does the same: No-Model is owned too.
|
|
@@ -89,7 +94,7 @@ const planDate = (value, name) => {
|
|
|
89
94
|
const tagList = (value, name) => stringList(value, name, { max: 16 }).map((tag) => {
|
|
90
95
|
text(tag, name, { max: 100 });
|
|
91
96
|
if (tag !== tag.trim()) refuse(`${name} holds a tag with spaces at its start or end: "${tag}"`);
|
|
92
|
-
if (isOwnedTag(tag)) { const plain = tag.toLowerCase(); refuse(`${tag} is owned by the tracker; ${plain.startsWith('product:') || plain.startsWith('repo:') ? 'change the product or repos with item_lock' : plain.startsWith('tier:') ? 'change the tier with item_lock' : 'no verb removes it'}`); }
|
|
97
|
+
if (isOwnedTag(tag)) { const plain = tag.toLowerCase(); refuse(`${tag} is owned by the tracker; ${plain.startsWith('product:') || plain.startsWith('repo:') ? 'change the product or repos with item_lock' : plain.startsWith('tier:') ? 'change the tier with item_lock' : plain.startsWith(INPUT_TAG_PREFIX) ? 'item_propose sets it from an input URL' : 'no verb removes it'}`); }
|
|
93
98
|
return tag;
|
|
94
99
|
});
|
|
95
100
|
export const LINK_KINDS = Object.freeze(['pr', 'issue', 'artifact', 'design-doc', 'conversation', 'research', 'slack', 'drive', 'other']);
|
|
@@ -268,6 +273,56 @@ export function createVerbs({ client, store, writer, session, agent, role, roleF
|
|
|
268
273
|
};
|
|
269
274
|
const alreadyPatched = (entity, requestId, meta) => sameRequest(entity.fields?.atlas_receipts?.[requestId], requestId, meta);
|
|
270
275
|
|
|
276
|
+
// R23 line 7: the proposals a routine makes stop at a weekly cap, so they
|
|
277
|
+
// never pass what the owner can read in a week (TD:627-630). atlas.yaml of
|
|
278
|
+
// the item's first repo sets it with agent_proposals_per_week; a repo with
|
|
279
|
+
// no such key, or a file GitHub does not give, keeps the default.
|
|
280
|
+
async function weeklyCapOf(repo) {
|
|
281
|
+
if (!repo || typeof readGitHub !== 'function') return AGENT_PROPOSALS_PER_WEEK;
|
|
282
|
+
let file = null;
|
|
283
|
+
try { file = await readGitHub(`repos/${repo.fields.registry_id}/contents/atlas.yaml?ref=${repo.fields.default_branch ?? 'HEAD'}`); } catch { return AGENT_PROPOSALS_PER_WEEK; }
|
|
284
|
+
if (!file || typeof file.content !== 'string') return AGENT_PROPOSALS_PER_WEEK;
|
|
285
|
+
let doc;
|
|
286
|
+
try { doc = parseYaml(Buffer.from(file.content, 'base64').toString('utf8')); } catch { return AGENT_PROPOSALS_PER_WEEK; }
|
|
287
|
+
const value = Number(doc?.agent_proposals_per_week);
|
|
288
|
+
return Number.isInteger(value) && value >= 0 ? value : AGENT_PROPOSALS_PER_WEEK;
|
|
289
|
+
}
|
|
290
|
+
// R23 line 4: the owner's signed ready mark for an outside item: a ready
|
|
291
|
+
// act (R72) that names the issue's repo and the item, posted on the issue
|
|
292
|
+
// the item came from, and signed with a key of atlas/owner-signers on the
|
|
293
|
+
// repo's default branch.
|
|
294
|
+
async function readyMark(item) {
|
|
295
|
+
const address = String(item.fields?.origin?.url ?? '');
|
|
296
|
+
const match = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/(?:issues|pull)\/([1-9][0-9]{0,8})(?:[#?].*)?$/.exec(address);
|
|
297
|
+
if (!match) refuse(`item ${item.id} came from outside text, and only the owner's signed ready mark on its GitHub issue starts it; its origin names no GitHub issue (${address || 'no url'})`);
|
|
298
|
+
if (typeof readGitHub !== 'function') refuse(`item ${item.id} came from outside text; reading the owner's ready mark needs a GitHub reader`);
|
|
299
|
+
const [, repo, number] = match;
|
|
300
|
+
const read = async (path) => { try { return await readGitHub(path); } catch (error) { refuse(`GitHub could not read ${path}: ${error?.message ?? 'unknown error'}`); } };
|
|
301
|
+
const branch = (await read(`repos/${repo}`))?.default_branch;
|
|
302
|
+
if (typeof branch !== 'string' || !/^[A-Za-z0-9._/-]{1,100}$/.test(branch)) refuse(`GitHub names no default branch of ${repo}`);
|
|
303
|
+
let file = null;
|
|
304
|
+
try { file = await readGitHub(`repos/${repo}/contents/${SIGNERS_FILE}?ref=${branch}`); } catch { file = null; }
|
|
305
|
+
const keys = parseSigners(file && typeof file.content === 'string' ? Buffer.from(file.content, 'base64').toString('utf8') : '').keys;
|
|
306
|
+
if (!keys.length) refuse(`item ${item.id} came from outside text and needs the owner's signed ready mark, and ${SIGNERS_FILE} of ${repo} holds no owner key`);
|
|
307
|
+
const texts = [];
|
|
308
|
+
for (let page = 1; page <= 10; page += 1) {
|
|
309
|
+
const rows = await read(`repos/${repo}/issues/${number}/comments?per_page=100&page=${page}`);
|
|
310
|
+
for (const row of Array.isArray(rows) ? rows : []) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
|
|
311
|
+
if (!Array.isArray(rows) || rows.length < 100) break;
|
|
312
|
+
}
|
|
313
|
+
const found = findAct(verifyTexts(texts, keys), { kind: 'ready', repo, item: item.id });
|
|
314
|
+
if (!found) refuse(`item ${item.id} came from outside text; it starts only with the owner's signed ready mark. Ask him to run: atlas approve act ready --repo ${repo} --item ${item.id}, and to post the block as a comment on ${address}`);
|
|
315
|
+
return { repo, issue: Number(number), signed_at: found.signed_at ?? null };
|
|
316
|
+
}
|
|
317
|
+
const proposedAt = (row) => Object.values(row.fields?.atlas_receipts ?? {}).find((receipt) => receipt?.verb === 'item_propose')?.at ?? row.created_at ?? '';
|
|
318
|
+
async function underWeeklyCap(all, repo) {
|
|
319
|
+
const cap = await weeklyCapOf(repo);
|
|
320
|
+
const since = new Date(Date.parse(now()) - WEEK_MS).toISOString();
|
|
321
|
+
const name = repo?.fields?.registry_id;
|
|
322
|
+
const count = all.filter((row) => row.fields?.proposed_by?.role === 'routine' && (!name || (row.fields?.repos ?? []).includes(name)) && String(proposedAt(row)) >= since).length;
|
|
323
|
+
if (count >= cap) refuse(`a routine may propose at most ${cap} item${cap === 1 ? '' : 's'} a week for ${name ?? 'this repo'}, and ${count} came in the last 7 days. Set agent_proposals_per_week in atlas.yaml to change the cap`);
|
|
324
|
+
}
|
|
325
|
+
|
|
271
326
|
const verbs = {
|
|
272
327
|
async setup(args) {
|
|
273
328
|
const publisherHandle = await handle();
|
|
@@ -430,16 +485,30 @@ export function createVerbs({ client, store, writer, session, agent, role, roleF
|
|
|
430
485
|
const reg = await registry();
|
|
431
486
|
const product = await requireProduct(args.product, reg);
|
|
432
487
|
const repos = await requireRepos(args.repos, reg, product.fields.registry_id);
|
|
488
|
+
const shapedOrigin = itemOrigin(args.origin);
|
|
433
489
|
const fields = {
|
|
434
490
|
lifecycle: 'proposed', stage: 'capture', product: product.fields.registry_id, repos, tier: oneOf(args.tier, 'tier', TIERS, 'standard'),
|
|
435
|
-
origin:
|
|
491
|
+
origin: shapedOrigin, driver: oneOf(args.driver, 'driver', DRIVERS, 'owner-led'), next_step: text(args.next_step, 'next_step', { optional: true, max: 1000 }) ?? '',
|
|
436
492
|
summary: text(args.summary, 'summary', { optional: true, max: 4000 }) ?? '', external_refs: [], links: [],
|
|
493
|
+
// R23 line 7: who proposed it. The writer stamp changes with each
|
|
494
|
+
// write, so the weekly cap reads this field instead.
|
|
495
|
+
proposed_by: { role: label.role, agent: label.agent },
|
|
437
496
|
};
|
|
438
497
|
const type = await typeNamed('container');
|
|
439
498
|
const prior = await landed(type, requestId, meta);
|
|
440
499
|
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 };
|
|
441
|
-
|
|
442
|
-
|
|
500
|
+
// R23 lines 3 and 5: an input or outside origin names one URL, and one
|
|
501
|
+
// URL has one open item. A second proposal writes nothing.
|
|
502
|
+
const tag = shapedOrigin.url ? inputTag(shapedOrigin.url) : null;
|
|
503
|
+
let all = null;
|
|
504
|
+
const items = async () => (all ??= await client.listAllEntities({ type: type.name, state: 'any' }));
|
|
505
|
+
if (tag) {
|
|
506
|
+
const open = (await items()).find((row) => Array.isArray(row.tags) && row.tags.includes(tag) && LIVE_LIFECYCLES.includes(row.fields?.lifecycle));
|
|
507
|
+
if (open) return { verb: 'item_propose', item_id: open.id, duplicate_of: open.id, written: false, lifecycle: open.fields.lifecycle, stage: open.fields.stage, product: open.fields.product, repos: open.fields.repos };
|
|
508
|
+
}
|
|
509
|
+
if (label.role === 'routine') await underWeeklyCap(await items(), reg.repos.get(repos[0]));
|
|
510
|
+
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}`, ...(tag ? [tag] : []), ...(args.tags ? stringList(args.tags, 'tags', { max: 16 }).filter((one) => !isOwnedTag(one)) : [])] });
|
|
511
|
+
return { verb: 'item_propose', item_id: entity.id, version: entity.version, lifecycle: 'proposed', stage: 'capture', product: fields.product, repos, origin_kind: originKind(fields.origin) };
|
|
443
512
|
},
|
|
444
513
|
|
|
445
514
|
async item_lock(args, requestId, meta) {
|
|
@@ -461,9 +530,16 @@ export function createVerbs({ client, store, writer, session, agent, role, roleF
|
|
|
461
530
|
if (args.scope.design_doc !== undefined) scope.design_doc = url(args.scope.design_doc, 'scope.design_doc');
|
|
462
531
|
const scopeHash = sha256(scope.text);
|
|
463
532
|
const stage = oneOf(args.stage, 'stage', STAGES, fields.lifecycle === 'active' && fields.stage ? fields.stage : 'build');
|
|
533
|
+
// R23: the lock is the owner's words. An item from an input or from
|
|
534
|
+
// outside text keeps that origin, so the views still say where it came
|
|
535
|
+
// from; the words of the lock go beside it. An outside item starts only
|
|
536
|
+
// with the owner's signed ready mark on its issue (R23 line 4, R72).
|
|
537
|
+
const words = origin(args.origin);
|
|
538
|
+
const typed = originKind(fields.origin) !== 'words';
|
|
539
|
+
const ready = originKind(fields.origin) === 'outside' ? await readyMark(item) : null;
|
|
464
540
|
const change = {
|
|
465
541
|
lifecycle: 'active', stage, product: product.fields.registry_id, repos, tier: oneOf(args.tier, 'tier', TIERS, fields.tier ?? 'standard'),
|
|
466
|
-
scope, scope_hash: scopeHash, origin: origin(
|
|
542
|
+
scope, scope_hash: scopeHash, origin: typed ? fields.origin : words, ...(typed ? { lock_words: words } : {}), ...(ready ? { ready_mark: ready } : {}), locked_at: now(),
|
|
467
543
|
driver: oneOf(args.driver, 'driver', DRIVERS, fields.driver ?? 'owner-led'), next_step: text(args.next_step, 'next_step', { optional: true, max: 1000 }) ?? fields.next_step ?? '',
|
|
468
544
|
links: Array.isArray(fields.links) ? fields.links : [],
|
|
469
545
|
};
|
package/work/lib/view-rules.mjs
CHANGED
|
@@ -11,6 +11,25 @@
|
|
|
11
11
|
// The lifecycles a view counts as live work (release 0.2.3). A dropped item
|
|
12
12
|
// is closed by the owner's word, and no live list names it.
|
|
13
13
|
export const LIVE_LIFECYCLES = Object.freeze(['proposed', 'active', 'paused']);
|
|
14
|
+
|
|
15
|
+
// Roadmap R23 line 6: where an item came from, as the views show it. The
|
|
16
|
+
// owner's words, an input that code found, or outside text. An old record
|
|
17
|
+
// carries no kind, and reads as the owner's words.
|
|
18
|
+
export function originShown(value) {
|
|
19
|
+
if (!value || typeof value !== 'object') return { kind: 'none', label: 'no origin on record' };
|
|
20
|
+
if (value.kind === 'input') return { kind: 'input', url: value.url ?? '', key: value.key ?? '', date: value.date ?? '', label: `input ${value.key ?? ''} · ${value.url ?? ''}` };
|
|
21
|
+
if (value.kind === 'outside') return { kind: 'outside', url: value.url ?? '', from: value.from ?? '', date: value.date ?? '', label: `outside text (${value.from ?? 'issue'}) · ${value.url ?? ''}` };
|
|
22
|
+
return { kind: 'words', source: value.source ?? '', date: value.date ?? '', label: `the owner's words · ${value.source ?? ''}` };
|
|
23
|
+
}
|
|
24
|
+
// The short note a row line carries for an item that did not come from the
|
|
25
|
+
// owner's words.
|
|
26
|
+
export const shownNote = (shown) => (shown?.kind === 'input' ? ' · from an input' : shown?.kind === 'outside' ? ' · from outside text' : '');
|
|
27
|
+
export const originNote = (value) => shownNote(originShown(value));
|
|
28
|
+
// Roadmap R22: an item a routine proposed, which stays proposed because a
|
|
29
|
+
// routine moves no stage. The views read its stage from GitHub.
|
|
30
|
+
// An item from outside text is never one: it starts only with the owner's
|
|
31
|
+
// signed ready mark and a lock (R23 line 4).
|
|
32
|
+
export const isRunnerItem = (fields) => fields?.lifecycle === 'proposed' && fields?.proposed_by?.role === 'routine' && fields?.origin?.kind !== 'outside';
|
|
14
33
|
// A blocker that is delivered or dropped no longer blocks.
|
|
15
34
|
export const CLEARED_LIFECYCLES = Object.freeze(['done', 'dropped']);
|
|
16
35
|
// R5: the cost of waiting is a closed vocabulary, ranked. Index 0 costs most.
|
package/work/mcp.mjs
CHANGED
|
@@ -34,6 +34,9 @@ export { childEnvironment };
|
|
|
34
34
|
const ATLAS_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
35
35
|
const S = (description, extra = {}) => ({ type: 'string', description, ...extra });
|
|
36
36
|
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.') } };
|
|
37
|
+
// Roadmap R23: the origin of a new item. The owner's words, or a typed
|
|
38
|
+
// input that code found, or outside text. The last two hold no prose.
|
|
39
|
+
const ITEM_ORIGIN = { type: 'object', description: "Where the item came from. Give one of three shapes. The owner's words: text, source and date, with no kind; only the lead writes these. An input that code found, such as a watcher issue: kind \"input\", url, key and date. Outside text, an issue body or a comment under any login: kind \"outside\", url, from (issue or comment) and date. An input or outside origin holds no prose. One URL has one open item: a second proposal writes nothing and returns duplicate_of. A routine stops at the weekly cap that agent_proposals_per_week in atlas.yaml sets, 10 by default.", properties: { kind: S('One of words (the default), input, outside.'), text: S('Words only: the verbatim words of the owner.'), source: S('Words only: where the words are on record.'), url: S('Input and outside only: the absolute URL of the issue or comment.'), key: S('Input only: one typed key with no spaces, for example red-main:checks.yml@a1b2c3d.'), from: S('Outside only: issue or comment.'), date: S('ISO date, in the form YYYY-MM-DD.') } };
|
|
37
40
|
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.');
|
|
38
41
|
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.` };
|
|
39
42
|
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.';
|
|
@@ -49,13 +52,13 @@ const TOOL_LIST = [
|
|
|
49
52
|
{ 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. ${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 } },
|
|
50
53
|
{ 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. ${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
54
|
{ name: 'repo_edit', description: `Change a repo's kinds or notes, or refresh its default branch from GitHub. registry_id and product never change. ${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 } },
|
|
52
|
-
{ name: 'item_propose', description: `Create a proposed work item (lifecycle proposed, stage capture) for a product, with its repos and the owner's words
|
|
53
|
-
{ 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. ${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('Refused: old records only. A new lock takes no hash; put the definition of done in text.') } }, 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 } },
|
|
55
|
+
{ name: 'item_propose', description: `Create a proposed work item (lifecycle proposed, stage capture) for a product, with its repos and its origin: the owner's words, or a typed input or outside URL (roadmap R23). When: the owner names a piece of work that is not yet locked, or a routine files a watcher issue. ${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: ITEM_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 } },
|
|
56
|
+
{ 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. An item from an input or from outside text keeps that origin, and the words of the lock go beside it. An item from outside text locks only with the owner's signed ready mark (atlas approve act ready), posted as a comment on its issue and signed with a key of atlas/owner-signers on the repo's default branch (roadmap R23 line 4). When: the owner approves the design. ${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('Refused: old records only. A new lock takes no hash; put the definition of done in text.') } }, 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 } },
|
|
54
57
|
{ 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. ${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 } },
|
|
55
58
|
{ 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. ${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 } },
|
|
56
59
|
{ 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. ${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 } },
|
|
57
60
|
{ 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. ${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 } },
|
|
58
|
-
{ 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. The tags that other verbs own (product:, repo:, tier:, item:, atlas-work, no-model, atlas-registry) are refused. A new title is the owner's words, and planned dates are the owner's promise. ${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."), 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. 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 } },
|
|
61
|
+
{ 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. The tags that other verbs own (product:, repo:, tier:, item:, input:, atlas-work, no-model, atlas-registry) are refused. A new title is the owner's words, and planned dates are the owner's promise. ${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."), 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. 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 } },
|
|
59
62
|
{ name: 'item_stage', description: `Move an active item to a stage (${STAGES.join(', ')}). When: work moves; the lead or the delivering agent calls it. ${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 } },
|
|
60
63
|
{ 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. ${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 } },
|
|
61
64
|
{ 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. ${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 } },
|
|
@@ -99,6 +102,7 @@ export const TOOLS = TOOL_LIST.map((tool) => ({ ...tool, description: `${tool.de
|
|
|
99
102
|
// a throw.
|
|
100
103
|
const CONNECTION_HELP = 'Put it in the file ~/.config/atlas/engram-connection, as "https://<host> <key>". "atlas install" prints the commands.';
|
|
101
104
|
const CONNECTION_FILE_LIMIT = 16 * 1024;
|
|
105
|
+
const KEY_TYPES = Object.freeze(['user', 'agent']);
|
|
102
106
|
export function connectionFile(env) { return env.HOME ? join(env.HOME, '.config', 'atlas', 'engram-connection') : null; }
|
|
103
107
|
function readConnectionFile(env) {
|
|
104
108
|
const path = connectionFile(env);
|
|
@@ -145,7 +149,10 @@ export function readConnection(env, { allowLoopbackHttp = false, now = Date.now(
|
|
|
145
149
|
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return { state: 'bad', message: `The Engram address must be the host only, with no path. ${CONNECTION_HELP}` };
|
|
146
150
|
let claims;
|
|
147
151
|
try { claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url')); } catch { return { state: 'bad', message: `The Engram key is not an Engram token. ${CONNECTION_HELP}` }; }
|
|
148
|
-
|
|
152
|
+
// Roadmap R22: a user key gives the lead; an Engram agent key gives a
|
|
153
|
+
// routine, which only creates records (roles.mjs). Any other principal is
|
|
154
|
+
// refused.
|
|
155
|
+
if (!KEY_TYPES.includes(claims.principal_type) || !claims.jti || !claims.exp) return { state: 'bad', message: `The Engram key must be a user or an agent token with a token id. ${CONNECTION_HELP}` };
|
|
149
156
|
if (claims.exp <= now / 1000) {
|
|
150
157
|
const day = new Date(claims.exp * 1000).toISOString().slice(0, 10);
|
|
151
158
|
return { state: 'expired', message: `The Engram key expired on ${day}. Put a new one in ~/.config/atlas/engram-connection.`, expired: day };
|