@arjunkhera/atlas 0.3.21 → 0.3.24

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.21",
3
+ "version": "0.3.24",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -47,6 +47,8 @@ are in `${CLAUDE_PLUGIN_ROOT}/skills/sdlc-task/design/README.md`, under
47
47
  - A step has a title and one or two sentences. The reader sets the pace.
48
48
  The figure prints as stills, and it works with its script removed.
49
49
  - Meaning is never by colour alone.
50
+ - A figure for a story walks the story: it draws each step in order, so
51
+ the reader sees the flow move. The check fails a story with no such figure.
50
52
  - Close every tag. `atlas design check <folder>` flags a figure that does
51
53
  not.
52
54
 
@@ -5,12 +5,22 @@
5
5
  //
6
6
  // The check flags what a script can see: a needed part that is missing or
7
7
  // empty, a state line (state lives in the tracker), a code with no row in
8
- // the Key, a part the registry does not know, and a figure whose tags do not
9
- // close. It proves the shape, not the quality. It never rewrites a word.
8
+ // the Key, a part the registry does not know, a figure whose tags do not
9
+ // close or that woodcut cannot draw, and a part with no picture where the
10
+ // registry asks for one. It proves the shape, not the quality. It never
11
+ // rewrites a word.
12
+ //
13
+ // Pictures are needed (owner, 21 October 2026): "any user is a visual
14
+ // person, they need to see things flowing to make sense of it." The `picture`
15
+ // key in parts.yaml says which part needs one figure, and that each story
16
+ // needs a walkthrough figure and a worked example.
10
17
  import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
11
18
  import { join, dirname, resolve, basename } from 'node:path';
12
19
  import { fileURLToPath } from 'node:url';
13
20
  import { parseYaml, insideRepo } from '../../shape/check.mjs';
21
+ import { WOODCUT_TYPES, fenceOpens, fenceCloses } from './markdown.mjs';
22
+
23
+ export { WOODCUT_TYPES };
14
24
 
15
25
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
16
26
  export const PARTS_FILE = join(PACKAGE_ROOT, 'skills', 'sdlc-task', 'design', 'parts.yaml');
@@ -20,6 +30,11 @@ export const NEEDS = Object.freeze(['needed', 'optional', 'none', 'shared']);
20
30
  export const DESIGN_KEYS = Object.freeze(['title', 'item', 'product', 'kinds', 'shared', 'look']);
21
31
  export const STATE_KEYS = Object.freeze(['status', 'state', 'stage', 'approved', 'locked', 'lock', 'shipped', 'delivered']);
22
32
  export const PART_KEYS = Object.freeze(['part', 'title']);
33
+ // What a part's `picture` key may say: `one` means the part holds at least one
34
+ // figure; `each-story` means each story holds a walkthrough figure and a
35
+ // worked example.
36
+ export const PICTURES = Object.freeze(['one', 'each-story']);
37
+
23
38
 
24
39
  // A code: capitals then a number, such as M1, A1.7, US-3 or D-6. Common
25
40
  // technical names that look like codes are not codes.
@@ -48,6 +63,7 @@ export function loadRegistry(path = PARTS_FILE) {
48
63
  const data = parseYaml(readFileSync(path, 'utf8'));
49
64
  const kinds = data.kinds.map((one) => one.id);
50
65
  for (const part of data.parts) {
66
+ if (part.picture !== undefined && !PICTURES.includes(part.picture)) throw new Error(`${basename(path)}: part "${part.id}" has picture "${part.picture}"; use ${PICTURES.join(' or ')}`);
51
67
  for (const kind of kinds) {
52
68
  if (!NEEDS.includes(part[kind])) throw new Error(`${basename(path)}: part "${part.id}" has no valid "${kind}" value; use ${NEEDS.join(', ')}`);
53
69
  }
@@ -103,9 +119,9 @@ function scanLines(body, firstLine) {
103
119
  let fence = null;
104
120
  body.split('\n').forEach((text, index) => {
105
121
  const line = firstLine + index;
106
- const open = text.match(/^\s*```+\s*(\S*)(?:\s+(\S+))?/);
107
- if (open && !fence) { fence = { info: open[1], type: open[2] ?? null, line, lines: [] }; return; }
108
- if (fence && /^\s*```+\s*$/.test(text)) { if (fence.info === 'figure' || fence.info === 'woodcut') figures.push(fence); fence = null; return; }
122
+ if (fence && fenceCloses(text, fence.mark)) { if (fence.info === 'figure' || fence.info === 'woodcut') figures.push(fence); fence = null; return; }
123
+ const mark = !fence && fenceOpens(text);
124
+ if (mark) { const [info, type] = text.trim().slice(mark.length).trim().split(/\s+/); fence = { mark, info: info ?? '', type: type ?? null, line, lines: [] }; return; }
109
125
  if (fence) { fence.lines.push(text); return; }
110
126
  prose.push({ text, line });
111
127
  });
@@ -143,6 +159,175 @@ export function figureFault(html) {
143
159
  return stack.length ? `<${stack[stack.length - 1]}> does not close` : null;
144
160
  }
145
161
 
162
+ // Why woodcut cannot draw this figure, or null. It reads the shape the
163
+ // renderer reads: a viewBox, the lists the type draws, the fields each entry
164
+ // needs, and the ids each step names. Any other throw is a finding too: a
165
+ // figure the check cannot read is a figure the page cannot draw.
166
+ export function woodcutFault(type, data) {
167
+ try { return woodcutShapeFault(type, data); } catch (error) { return `woodcut cannot read the figure data: ${error.message}`; }
168
+ }
169
+
170
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
171
+ // A list the renderer walks: absent is an empty list; anything else must be
172
+ // an array of objects.
173
+ function listOf(v, key) {
174
+ if (v[key] === undefined) return { list: [] };
175
+ if (!Array.isArray(v[key])) return { fault: `"${key}" must be a list` };
176
+ if (!v[key].every(isObject)) return { fault: `each entry of "${key}" must be an object` };
177
+ return { list: v[key] };
178
+ }
179
+
180
+ function woodcutShapeFault(type, data) {
181
+ const shape = WOODCUT_TYPES[type];
182
+ if (!shape) return `woodcut has no figure "${type}", so the page draws nothing; use ${Object.keys(WOODCUT_TYPES).join(', ')}, or draw your own in a \`figure\` block`;
183
+ if (!isObject(data)) return 'the woodcut data is not one JSON object';
184
+ if (data.variants !== undefined && !Array.isArray(data.variants)) return 'the woodcut "variants" must be a list';
185
+ const variants = data.variants ?? [data];
186
+ if (!variants.length) return 'the woodcut "variants" list is empty';
187
+ for (const [n, v] of variants.entries()) {
188
+ const at = variants.length > 1 ? ` in variant ${n + 1}` : '';
189
+ if (!isObject(v)) return `variant ${n + 1} is not an object`;
190
+ if (!Array.isArray(v.viewBox) || v.viewBox.length !== 2 || !v.viewBox.every(Number.isFinite)) return `the woodcut data needs "viewBox": [width, height]${at}`;
191
+ const lists = {};
192
+ for (const key of [shape.needs, ...shape.ids, ...(shape.more ?? []), 'scenarios']) {
193
+ const { list, fault } = listOf(v, key);
194
+ if (fault) return fault + at;
195
+ lists[key] = list;
196
+ }
197
+ if (!lists[shape.needs].length) return `a ${type} needs a non-empty "${shape.needs}" list${at}`;
198
+ if (lists[shape.needs].some((one) => one.id === undefined)) return `each entry of "${shape.needs}" needs an "id"${at}`;
199
+ const fault = elementFault(type, v, lists);
200
+ if (fault) return fault + at;
201
+ const ids = new Set(shape.ids.flatMap((key) => lists[key].map((one) => one.id)).filter((id) => id !== undefined));
202
+ if (type === 'wc-state-machine' && v.start) ids.add('start');
203
+ for (const sc of lists.scenarios) {
204
+ if (!Array.isArray(sc.steps)) return `the scenario "${sc.name ?? sc.id ?? '?'}" needs a "steps" list${at}`;
205
+ for (const step of sc.steps) {
206
+ if (!isObject(step)) return `each step of the scenario "${sc.name ?? sc.id ?? '?'}" must be an object${at}`;
207
+ for (const key of ['active', 'denied']) if (step[key] !== undefined && !Array.isArray(step[key])) return `a step's "${key}" must be a list of ids${at}`;
208
+ for (const id of [...(step.active ?? []), ...(step.denied ?? [])]) {
209
+ if (!ids.has(id)) return `a step names "${id}", which is not an element of the figure${at}`;
210
+ }
211
+ }
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+
217
+ // The fields each element needs, or the renderer throws and the figure stays
218
+ // blank: a place for each box, the points of each line, and a place for each
219
+ // label on a line.
220
+ const num = (value) => Number.isFinite(value);
221
+ const pair = (value) => Array.isArray(value) && value.length === 2 && value.every(num);
222
+ function elementFault(type, v, lists) {
223
+ const name = (one) => `"${one.id ?? one.label ?? '?'}"`;
224
+ if (type === 'wc-sequence') {
225
+ const actors = new Set(lists.actors.map((one) => one.id));
226
+ for (const a of lists.actors) if (!num(a.x)) return `the actor ${name(a)} needs a number "x"`;
227
+ for (const m of lists.messages) {
228
+ if (!num(m.y)) return `the message ${name(m)} needs a number "y"`;
229
+ if (!actors.has(m.from) || !actors.has(m.to)) return `the message ${name(m)} names an actor that is not in "actors"`;
230
+ }
231
+ for (const act of lists.activations) if (!actors.has(act.actor) || !num(act.y1) || !num(act.y2)) return `each activation needs an "actor" from "actors" and numbers "y1" and "y2"`;
232
+ return null;
233
+ }
234
+ if (type === 'wc-state-machine') {
235
+ for (const st of lists.states) if (![st.x, st.y, st.w, st.h].every(num)) return `the state ${name(st)} needs numbers "x", "y", "w" and "h"`;
236
+ for (const t of lists.transitions) {
237
+ if (typeof t.path !== 'string' || !pair(t.tip) || !pair(t.tipDir)) return `the transition ${name(t)} needs "path", "tip": [x, y] and "tipDir": [dx, dy]`;
238
+ if (t.label && !pair(t.labelAt)) return `the transition ${name(t)} has a label, so it needs "labelAt": [x, y]`;
239
+ }
240
+ if (v.start !== undefined && !(isObject(v.start) && pair(v.start.at))) return 'the start needs "at": [x, y]';
241
+ return null;
242
+ }
243
+ for (const n of lists.nodes) {
244
+ if (n.shape === 'end' ? ![n.x, n.y].every(num) : ![n.x, n.y, n.w, n.h].every(num)) return `the node ${name(n)} needs numbers "x", "y", "w" and "h"`;
245
+ if (n.shape === 'end' && n.label && !pair(n.labelAt)) return `the end node ${name(n)} has a label, so it needs "labelAt": [x, y]`;
246
+ }
247
+ for (const e of lists.edges) {
248
+ const fewest = e.noTip ? 1 : 2;
249
+ if (!Array.isArray(e.points) || e.points.length < fewest || !e.points.every(pair)) return `the edge ${name(e)} needs "points": ${fewest === 1 ? 'one or more' : 'two or more'} [x, y] pairs`;
250
+ if (e.label && !pair(e.labelAt)) return `the edge ${name(e)} has a label, so it needs "labelAt": [x, y]`;
251
+ }
252
+ for (const lane of lists.lanes) if (![lane.y1, lane.y2].every(num)) return `the lane "${lane.label ?? '?'}" needs numbers "y1" and "y2"`;
253
+ for (const g of lists.groups) if (![g.x, g.y, g.w, g.h].every(num)) return `the group "${g.label ?? '?'}" needs numbers "x", "y", "w" and "h"`;
254
+ return null;
255
+ }
256
+
257
+ // A woodcut figure walks when one of its scenarios has two or more steps and
258
+ // each step has a title: the reader steps through it, and the panel tells
259
+ // what happens at each step. It runs only on data that woodcutFault passed.
260
+ export function woodcutWalks(data) {
261
+ const variants = Array.isArray(data?.variants) ? data.variants : [data];
262
+ return variants.some((v) => (v?.scenarios ?? []).some((sc) => Array.isArray(sc.steps) && sc.steps.length >= 2 && sc.steps.every((step) => step && step.title)));
263
+ }
264
+
265
+ // A custom figure counts as a picture when it draws: an SVG shape, or HTML
266
+ // with at least three elements. An empty <svg> or a line of text is not one.
267
+ const SHAPE = /<(?:rect|circle|ellipse|path|line|polyline|polygon|text|image)\b/i;
268
+ export function figureDraws(html) {
269
+ const text = html.replace(/<!--[\s\S]*?-->/g, '');
270
+ return SHAPE.test(text) || (text.match(/<[a-zA-Z][\w-]*\b/g)?.length ?? 0) >= 3;
271
+ }
272
+
273
+ // A story heading carries a code, such as "### US-1: a test goes red". The
274
+ // stories are the coded headings at the level of the first one; a deeper
275
+ // heading, or an uncoded heading such as "## Overview", stays inside the story
276
+ // before it. HTML comments and fenced blocks hold no stories.
277
+ const STORY_CODE = /(?<![\w./-])[A-Z]{1,4}-?\d+(?:\.\d+)*(?![\w/-])/;
278
+ function storiesOf(body, firstLine) {
279
+ const rows = [];
280
+ let fence = null;
281
+ let comment = false;
282
+ body.split('\n').forEach((text, index) => {
283
+ const line = firstLine + index;
284
+ if (fence) { if (fenceCloses(text, fence)) fence = null; rows.push({ text, line }); return; }
285
+ const open = fenceOpens(text);
286
+ if (open) { fence = open; rows.push({ text, line }); return; }
287
+ if (comment) { if (text.includes('-->')) comment = false; rows.push({ text, line }); return; }
288
+ if (/^\s*<!--/.test(text) && !text.includes('-->')) { comment = true; rows.push({ text, line }); return; }
289
+ const heading = text.match(/^(#{2,6})\s+(.*)$/);
290
+ rows.push({ text, line, heading: heading && STORY_CODE.test(heading[2].replace(/`[^`]*`/g, '')) ? { level: heading[1].length, name: heading[2].trim() } : null });
291
+ });
292
+ const level = rows.find((row) => row.heading)?.heading.level;
293
+ const stories = [];
294
+ for (const row of rows) {
295
+ if (row.heading && row.heading.level === level) stories.push({ name: row.heading.name, line: row.line, lines: [] });
296
+ else if (stories.length) stories[stories.length - 1].lines.push(row.text);
297
+ }
298
+ return stories;
299
+ }
300
+
301
+ // A worked example: a label that names the input and one that names the
302
+ // output, such as "**Input:** ..." and "**Output:** ...". "Ask" and "Answer",
303
+ // "Given" and "Result" count too, and so does a table with Input and Output
304
+ // columns.
305
+ const LABEL = (words) => new RegExp(`(?:^|[^\\w])(?:\\*\\*|__)?(?:${words})(?:\\*\\*|__)?\\s*(?::|—|–)|(?:\\*\\*|__)(?:${words})[:.](?:\\*\\*|__)|^\\s*\\|.*\\b(?:${words})\\b.*\\|`, 'i');
306
+ const EXAMPLE_INPUT = LABEL('inputs?|ask|given|example input');
307
+ const EXAMPLE_OUTPUT = LABEL('outputs?|answer|result|example output');
308
+ // A part that says "No change" and why needs no picture, as the empty rule allows.
309
+ const NO_CHANGE = /^\s*(?:#{1,6}\s.*\n\s*)*(?:\*\*|__)?no change\b/i;
310
+
311
+ function pictureFindings(one, rule, figures, finding) {
312
+ if (NO_CHANGE.test(one.body)) return;
313
+ const drawn = figures.filter((figure) => !figure.open && !figure.fault && (figure.info !== 'figure' || figureDraws(figure.lines.join('\n'))));
314
+ if (rule === 'one') {
315
+ if (!drawn.length) finding(one.file, one.bodyLine, 'picture', `part "${one.part}" needs a figure; a reader must see it, not only read it. Add a \`woodcut\` or a \`figure\` block`);
316
+ return;
317
+ }
318
+ const stories = storiesOf(one.body, one.bodyLine);
319
+ if (!stories.length) { finding(one.file, one.bodyLine, 'picture', 'write each story under a heading with its code, such as "### US-1: a test goes red", with a walkthrough figure and a worked example'); return; }
320
+ for (const story of stories) {
321
+ const inside = drawn.filter((figure) => figure.line > story.line && figure.line <= story.line + story.lines.length);
322
+ const walks = inside.some((figure) => figure.info === 'figure' || figure.walks);
323
+ if (!walks) finding(one.file, story.line, 'picture', `the story "${story.name}" needs a walkthrough figure: a woodcut figure with a scenario whose steps each have a title, or a figure of your own`);
324
+ const words = story.lines;
325
+ if (!words.some((line) => EXAMPLE_INPUT.test(line)) || !words.some((line) => EXAMPLE_OUTPUT.test(line))) {
326
+ finding(one.file, story.line, 'picture', `the story "${story.name}" needs a worked example: an "Input:" line and an "Output:" line with real values`);
327
+ }
328
+ }
329
+ }
330
+
146
331
  export function checkDesign(design, registry = loadRegistry()) {
147
332
  const findings = [];
148
333
  const finding = (file, line, rule, why) => findings.push({ file, line, rule, why });
@@ -196,10 +381,17 @@ export function checkDesign(design, registry = loadRegistry()) {
196
381
  let fault = figure.open ? 'the figure block does not close' : null;
197
382
  if (!fault && figure.info === 'woodcut') {
198
383
  if (!/^wc-[a-z-]+$/.test(figure.type ?? '')) fault = 'a woodcut block names its figure type, such as "woodcut wc-flowchart"';
199
- else { try { JSON.parse(figure.lines.join('\n')); } catch (error) { fault = `the woodcut data is not JSON: ${error.message}`; } }
384
+ else {
385
+ let data;
386
+ try { data = JSON.parse(figure.lines.join('\n')); } catch (error) { fault = `the woodcut data is not JSON: ${error.message}`; }
387
+ if (!fault) fault = woodcutFault(figure.type, data);
388
+ if (!fault) figure.walks = woodcutWalks(data);
389
+ }
200
390
  } else if (!fault) fault = figureFault(figure.lines.join('\n'));
201
- if (fault) finding(one.file, figure.line, 'figure', fault);
391
+ if (fault) { figure.fault = fault; finding(one.file, figure.line, 'figure', fault); }
202
392
  }
393
+ const picture = registry.byId.get(one.part).picture;
394
+ if (picture && !isEmpty(one.body)) pictureFindings(one, picture, figures, finding);
203
395
  }
204
396
  for (const [id, { need, by }] of needs) {
205
397
  if (need === 'needed' && !seen.has(id)) {
@@ -6,6 +6,31 @@
6
6
  //
7
7
  // No dependencies. The text is the repo's own; a custom figure is trusted
8
8
  // the same way a page in the repo is.
9
+ // The figure types that woodcut 0.4.0 defines (WOODCUT in design-build.mjs), and the list each one cannot
10
+ // draw without. A name outside this map is an element the browser does not
11
+ // know, so it draws nothing. The ids lists name the arrays whose ids a
12
+ // walkthrough step may name.
13
+ export const WOODCUT_TYPES = Object.freeze({
14
+ 'wc-block-diagram': { needs: 'nodes', ids: ['nodes', 'edges', 'groups'], more: ['lanes'] },
15
+ 'wc-flowchart': { needs: 'nodes', ids: ['nodes', 'edges', 'groups'], more: ['lanes'] },
16
+ 'wc-swimlane': { needs: 'nodes', ids: ['nodes', 'edges', 'groups'], more: ['lanes'] },
17
+ 'wc-sequence': { needs: 'actors', ids: ['actors', 'messages'], more: ['activations'] },
18
+ 'wc-state-machine': { needs: 'states', ids: ['states', 'transitions'] },
19
+ });
20
+
21
+ // One fence rule for every reader of a page (the check, this renderer and
22
+ // atlas ste), as CommonMark has it: a fence opens with three or more of one
23
+ // mark, and closes only on a run of the same mark at least as long, with
24
+ // nothing after it. So a ```` block may hold a ``` block.
25
+ export function fenceOpens(line) {
26
+ const match = /^\s*(`{3,}|~{3,})/.exec(line);
27
+ return match ? match[1] : null;
28
+ }
29
+ export function fenceCloses(line, mark) {
30
+ const match = /^\s*(`{3,}|~{3,})\s*$/.exec(line);
31
+ return Boolean(match) && match[1][0] === mark[0] && match[1].length >= mark.length;
32
+ }
33
+
9
34
  export const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10
35
  export const slug = (text) => String(text).toLowerCase().replace(/<[^>]+>/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
11
36
 
@@ -60,16 +85,19 @@ export function render(body, ctx) {
60
85
  };
61
86
  while (i < lines.length) {
62
87
  const line = lines[i];
63
- const fence = line.match(/^\s*```+\s*(.*)$/);
64
- if (fence) {
88
+ const mark = fenceOpens(line);
89
+ if (mark) {
65
90
  flush();
66
- const info = fence[1].trim();
91
+ const info = line.trim().slice(mark.length).trim();
67
92
  const buf = [];
68
93
  i += 1;
69
- while (i < lines.length && !/^\s*```+\s*$/.test(lines[i])) buf.push(lines[i++]);
94
+ while (i < lines.length && !fenceCloses(lines[i], mark)) buf.push(lines[i++]);
70
95
  i += 1;
71
96
  const woodcut = info.match(/^woodcut\s+(wc-[a-z-]+)/);
72
- if (woodcut) {
97
+ if (woodcut && !WOODCUT_TYPES[woodcut[1]]) {
98
+ // A draft page shows the fault where the figure should be, never a blank.
99
+ out.push(`<p class="draft">This figure does not draw: woodcut has no figure "${esc(woodcut[1])}". Run atlas design check.</p>`);
100
+ } else if (woodcut) {
73
101
  let data;
74
102
  try { data = JSON.parse(buf.join('\n')); } catch (error) { throw new Error(`${ctx.file}: bad woodcut JSON in ${woodcut[1]}: ${error.message}`); }
75
103
  ctx.figures.push(woodcut[1]);
package/door/lib/ste.mjs CHANGED
@@ -13,6 +13,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
13
13
  import { dirname, extname, join, relative } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { privacyFindings, loadPrivateTerms } from './privacy.mjs';
16
+ import { fenceOpens, fenceCloses } from './markdown.mjs';
16
17
 
17
18
  export const LIMITS = Object.freeze({ step: 20, sentence: 25, paragraph: 6 });
18
19
 
@@ -64,9 +65,10 @@ export function markdownUnits(text) {
64
65
  for (; index < lines.length; index += 1) {
65
66
  const line = lines[index];
66
67
  const number = index + 1;
67
- const fenceMark = /^\s*(```|~~~)/.exec(line);
68
- if (fence) { if (fenceMark && fenceMark[1] === fence) fence = null; continue; }
69
- if (fenceMark) { close(); fence = fenceMark[1]; continue; }
68
+ // The fence rule of the design renderer: a ```` block may hold a ``` block.
69
+ if (fence) { if (fenceCloses(line, fence)) fence = null; continue; }
70
+ const mark = fenceOpens(line);
71
+ if (mark) { close(); fence = mark; continue; }
70
72
  if (!line.trim()) { close(); continue; }
71
73
  if (/^\s*>/.test(line)) { close(); continue; }
72
74
  if (comment) { if (line.includes('-->')) comment = false; continue; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.21",
3
+ "version": "0.3.24",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -38,7 +38,7 @@ checks and the transition log; each verb's own description says what it needs.
38
38
  | Ask the owner something, or record the answer | `question_ask`, `question_answer` |
39
39
  | Record a decision in the owner's words | `decision_record` |
40
40
  | Mark it delivered, or reopen it | `item_done`, `item_reopen` |
41
- | Say where things stand | `where_are_we`, `in_flight`, `needs_me` |
41
+ | Say where things stand | `roundup` for a whole product; `where_are_we`, `in_flight`, `needs_me` for one question |
42
42
 
43
43
  The repo's own facts come from `atlas.yaml` and `atlas/`. Its kinds (`service`,
44
44
  `library`, `schema`, `cli`, `plugin`) decide which procedures it has and what
@@ -114,7 +114,8 @@ A lock is the owner's approval of a short design.
114
114
  shows in bold at the top.
115
115
  2. **Show the design's page before you ask.** Run
116
116
  `atlas design check <folder>` and `atlas ste` on the folder, and fix each
117
- finding by hand. A part that needs a custom figure goes to the
117
+ finding by hand. The check needs pictures, as `design/README.md`
118
+ "Figures" says. A part that needs a custom figure goes to the
118
119
  `atlas:artifact-renderer` crew first. Run
119
120
  `atlas design build <folder> --tracker <item.json>`, with the tracker
120
121
  data that `design/README.md` names. Run `atlas ste` on the page and
@@ -90,12 +90,10 @@ gets. The kind sets which parts the design has. The two are separate: an
90
90
  initiative-tier piece of work can be a Decide design, and an Initiative
91
91
  design holds a family of designs.
92
92
 
93
- At initiative tier, a Decide design goes deep:
94
-
95
- 1. The Problem today part has a figure of today. The Proposal part has a
96
- figure of the target.
97
- 2. At least two options get full depth in Alternatives: their costs, their
98
- risks and why each one lost.
93
+ At every tier, the Problem today part has a figure of today and the
94
+ Proposal part has a figure of the target. At initiative tier, a Decide
95
+ design also goes deep. At least two options get full depth in
96
+ Alternatives: their costs, their risks and why each one lost.
99
97
 
100
98
  At standard tier, one chosen approach and one rejected option are enough.
101
99
 
@@ -167,18 +165,48 @@ Without the key, the design folder is `docs/design-docs`.
167
165
  An empty part fails the check.
168
166
  4. Put every code you use, such as `US-3` or `M1`, in the Key.
169
167
  5. Never write a state line, such as "Status: approved". The check flags it.
168
+ 6. Draw the parts that the Figures section names. The check fails a part
169
+ with no figure where one is needed.
170
170
 
171
171
  ## Figures
172
172
 
173
- 1. Use a woodcut figure when a stock figure fits: a flow chart, a sequence,
174
- a state machine or swim lanes. Use a fenced block with the info string
175
- `woodcut wc-<type>`. It holds the figure data as JSON, in the woodcut
176
- shape.
177
- 2. Draw your own figure when the subject has its own shape. Git lanes, a
173
+ A reader must see the design move, not only read it (owner, 21 October
174
+ 2026: "any user is a visual person, they need to see things flowing to make
175
+ sense of it"). The check enforces it. The `picture` key in
176
+ [parts.yaml](parts.yaml) says where:
177
+
178
+ | Part | The check needs |
179
+ |---|---|
180
+ | Summary | One figure. |
181
+ | Problem today | One figure of today. |
182
+ | Proposal | One figure of the target. |
183
+ | States | One state figure. |
184
+ | Stories | For each story: a walkthrough figure, and a worked example with real values. The example has an `Input:` label and an `Output:` label. `Ask` and `Answer`, or a table with Input and Output columns, also count. |
185
+
186
+ A story starts at a heading that holds its code, such as
187
+ `### US-1: a test goes red`. A deeper heading stays inside the story. A part
188
+ that says "No change" and why needs no figure.
189
+
190
+ A walkthrough figure is a woodcut figure with a scenario of two or more
191
+ steps, each with a title and one or two sentences. The reader clicks or
192
+ presses the arrow keys, and each step lights up the part that moves. A
193
+ figure of your own also counts.
194
+
195
+ 1. Use a woodcut figure when a stock figure fits. Use a fenced block with
196
+ the info string `woodcut <type>`. It holds the figure data as JSON.
197
+ The template for Stories has a full example.
198
+ 2. Pick the type by its question. `wc-sequence`: in what order?
199
+ `wc-swimlane`: who does it? `wc-flowchart`: which way?
200
+ `wc-state-machine`: what state is it in? `wc-block-diagram`: what are
201
+ the parts?
202
+ 3. Any other type name draws nothing, so the check refuses it. The check
203
+ also refuses data that the figure cannot draw. Examples are a missing
204
+ `viewBox`, a box with no place and a line with no points.
205
+ 4. Draw your own figure when the subject has its own shape. Git lanes, a
178
206
  time line and a data diff are examples. Use a fenced block with the info
179
207
  string `figure`. It holds the HTML. Use the page tokens, such as
180
208
  `var(--accent)` and `var(--line)`, so the figure works in both themes.
181
- 3. Both kinds follow the same rules. A step has a title and one or two
209
+ 5. Both kinds follow the same rules. A step has a title and one or two
182
210
  sentences. The reader sets the pace. The figure prints as stills.
183
211
  Meaning is never by colour alone.
184
212
 
@@ -8,7 +8,8 @@ Rules:
8
8
 
9
9
  1. Say what happens today, with one real example.
10
10
  2. Cite the source: a pull request, an issue or a log line.
11
- 3. Do not propose a fix here.
11
+ 3. Draw today with a figure. The check fails the part with no figure.
12
+ 4. Do not propose a fix here.
12
13
 
13
14
  Example, in a file of its own:
14
15
 
@@ -19,4 +20,20 @@ part: problem
19
20
  # Problem today
20
21
 
21
22
  Today the tests run after the merge. On 2 October pull request 88 merged, and its tests went red one hour later.
23
+
24
+ ```woodcut wc-flowchart
25
+ {
26
+ "label": "Today: the tests run after the merge",
27
+ "viewBox": [460, 70],
28
+ "nodes": [
29
+ { "id": "pr", "x": 10, "y": 18, "w": 110, "h": 34, "label": "Pull request", "shape": "pill" },
30
+ { "id": "merge", "x": 175, "y": 18, "w": 110, "h": 34, "label": "Merge" },
31
+ { "id": "tests", "x": 340, "y": 18, "w": 110, "h": 34, "label": "Tests: red", "kind": "accent" }
32
+ ],
33
+ "edges": [
34
+ { "id": "e1", "points": [[120, 35], [173, 35]] },
35
+ { "id": "e2", "points": [[285, 35], [338, 35]] }
36
+ ]
37
+ }
38
+ ```
22
39
  ````
@@ -8,7 +8,7 @@ Rules:
8
8
 
9
9
  1. Name the option we pick, and say how it works in steps.
10
10
  2. Give a worked example: the input, the process and the output.
11
- 3. Link the figure that shows it.
11
+ 3. Draw the target with a figure. The check fails the part with no figure.
12
12
 
13
13
  Example, in a file of its own:
14
14
 
@@ -18,7 +18,35 @@ part: proposal
18
18
  ---
19
19
  # Proposal
20
20
 
21
+ The tests run before the merge.
22
+
23
+ ```woodcut wc-flowchart
24
+ {
25
+ "label": "Target: the tests run before the merge",
26
+ "viewBox": [460, 70],
27
+ "nodes": [
28
+ { "id": "pr", "x": 10, "y": 18, "w": 110, "h": 34, "label": "Pull request", "shape": "pill" },
29
+ { "id": "tests", "x": 175, "y": 18, "w": 110, "h": 34, "label": "Tests", "kind": "accent" },
30
+ { "id": "merge", "x": 340, "y": 18, "w": 110, "h": 34, "label": "Merge" }
31
+ ],
32
+ "edges": [
33
+ { "id": "e1", "points": [[120, 35], [173, 35]] },
34
+ { "id": "e2", "points": [[285, 35], [338, 35]], "label": "green only", "labelAt": [311, 12] }
35
+ ],
36
+ "scenarios": [{
37
+ "name": "Pull request 88",
38
+ "steps": [
39
+ { "title": "A pull request opens", "body": "Pull request 88 changes src/pay.mjs.", "active": ["pr"] },
40
+ { "title": "The gate runs the tests", "body": "The test pay rounds down goes red.", "active": ["e1", "tests"] },
41
+ { "title": "The merge waits", "body": "Only a green run opens the merge.", "active": ["e2", "merge"] }
42
+ ]
43
+ }]
44
+ }
45
+ ```
46
+
21
47
  1. A pull request opens.
22
48
  2. The gate runs the tests on its head.
23
49
  3. A red test blocks the merge button.
50
+
51
+ Input: pull request 88 with one failing test. Output: the merge button stays grey.
24
52
  ````
@@ -8,7 +8,7 @@ Rules:
8
8
 
9
9
  1. List the states a thing moves through.
10
10
  2. Say what moves it from each state to the next.
11
- 3. Draw it as a state figure.
11
+ 3. Draw it as a state figure. The check fails the part with no figure.
12
12
 
13
13
  Example, in a file of its own:
14
14
 
@@ -18,6 +18,22 @@ part: states
18
18
  ---
19
19
  # States
20
20
 
21
+ ```woodcut wc-state-machine
22
+ {
23
+ "label": "A test run",
24
+ "viewBox": [440, 90],
25
+ "states": [
26
+ { "id": "waiting", "x": 10, "y": 30, "w": 100, "h": 32, "label": "Waiting" },
27
+ { "id": "running", "x": 170, "y": 30, "w": 100, "h": 32, "label": "Running" },
28
+ { "id": "red", "x": 330, "y": 30, "w": 100, "h": 32, "label": "Red", "terminal": true }
29
+ ],
30
+ "transitions": [
31
+ { "id": "start", "path": "M 110 46 L 168 46", "tip": [168, 46], "tipDir": [1, 0], "label": "tests start", "labelAt": [139, 22] },
32
+ { "id": "fail", "path": "M 270 46 L 328 46", "tip": [328, 46], "tipDir": [1, 0], "label": "a test fails", "labelAt": [299, 22] }
33
+ ]
34
+ }
35
+ ```
36
+
21
37
  | From | Event | To |
22
38
  |---|---|---|
23
39
  | Waiting | The tests start | Running |
@@ -6,9 +6,18 @@ Needs: Decide: optional, Flow: needed, Contract: optional, Initiative: none.
6
6
 
7
7
  Rules:
8
8
 
9
- 1. Write one walkthrough for each story, step by step.
10
- 2. Each step has a title and one or two sentences.
11
- 3. Give each story a code, such as `US-1`, and put the code in the Key.
9
+ 1. Write each story under its own heading, with a code, such as `US-1`.
10
+ Put the code in the Key.
11
+ 2. Give each story a walkthrough figure. The reader steps through it and
12
+ sees what moves at each step. Use a woodcut figure with a scenario. Give
13
+ each step a title and one or two sentences. A figure of your own also
14
+ counts. The check fails a story with no walkthrough figure.
15
+ 3. Give each story a worked example with real values: an `Input:` line, the
16
+ steps, and an `Output:` line. The check fails a story with no worked
17
+ example.
18
+ 4. Pick the figure by the question it answers: `wc-sequence` for "in what
19
+ order", `wc-swimlane` for "who does it", `wc-flowchart` for "which
20
+ way", `wc-state-machine` for "what state is it in".
12
21
 
13
22
  Example, in a file of its own:
14
23
 
@@ -20,7 +29,36 @@ part: stories
20
29
 
21
30
  ### US-1: a test goes red
22
31
 
23
- 1. The lead opens a pull request.
24
- 2. The gate runs the tests, and one goes red.
32
+ **Input:** pull request 88 changes `src/pay.mjs`. The test `pay rounds down` fails on it.
33
+
34
+ ```woodcut wc-sequence
35
+ {
36
+ "label": "US-1: a test goes red",
37
+ "viewBox": [460, 190],
38
+ "actors": [
39
+ { "id": "lead", "x": 70, "label": "Lead" },
40
+ { "id": "gate", "x": 230, "label": "Gate" },
41
+ { "id": "owner", "x": 390, "label": "Owner" }
42
+ ],
43
+ "messages": [
44
+ { "id": "open", "from": "lead", "to": "gate", "y": 70, "label": "open pull request 88" },
45
+ { "id": "red", "from": "gate", "to": "lead", "y": 110, "label": "1 test red", "kind": "return" },
46
+ { "id": "block", "from": "gate", "to": "owner", "y": 150, "label": "merge blocked" }
47
+ ],
48
+ "scenarios": [{
49
+ "name": "US-1",
50
+ "steps": [
51
+ { "title": "The lead opens a pull request", "body": "Pull request 88 changes src/pay.mjs.", "active": ["lead", "open"] },
52
+ { "title": "The gate runs the tests", "body": "The test pay rounds down fails.", "active": ["gate", "red"] },
53
+ { "title": "The owner sees the block", "body": "The merge button stays grey and names the failing check.", "active": ["owner", "block"] }
54
+ ]
55
+ }]
56
+ }
57
+ ```
58
+
59
+ 1. The lead opens pull request 88.
60
+ 2. The gate runs the tests, and `pay rounds down` goes red.
25
61
  3. The merge button stays blocked, and the owner sees the failing check.
62
+
63
+ **Output:** the merge button is grey. The check list shows `pay rounds down: failed`.
26
64
  ````
@@ -7,7 +7,8 @@ Needs: Decide: needed, Flow: needed, Contract: needed, Initiative: needed.
7
7
  Rules:
8
8
 
9
9
  1. Lead with the answer: what changes, for whom, and why now.
10
- 2. Keep it to two short paragraphs and one picture.
10
+ 2. Keep it to two short paragraphs and one picture. The check fails a
11
+ Summary with no figure.
11
12
  3. A reader who stops here knows the whole design.
12
13
 
13
14
  Example, in a file of its own:
@@ -12,6 +12,13 @@
12
12
  # none the kind does not use it
13
13
  # shared needed when design.yaml says `shared: true`, optional when not
14
14
  #
15
+ # A part may say what picture it needs (owner, 21 October 2026: "any user is
16
+ # a visual person, they need to see things flowing to make sense of it"):
17
+ # picture: one the part holds at least one figure that draws
18
+ # picture: each-story each story holds a walkthrough figure and a worked
19
+ # example, with an Input line and an Output line
20
+ # The check reads it whenever the part is in the folder.
21
+ #
15
22
  # A design keeps no state. The tracker parts at the end are read from the
16
23
  # tracker at build time. No part file may hold them.
17
24
 
@@ -50,6 +57,7 @@ parts:
50
57
  name: Summary
51
58
  group: start
52
59
  holds: The answer in a few lines, and one picture.
60
+ picture: one
53
61
  decide: needed
54
62
  flow: needed
55
63
  contract: needed
@@ -75,6 +83,7 @@ parts:
75
83
  name: Problem today
76
84
  group: decide
77
85
  holds: What happens today, with a real example.
86
+ picture: one
78
87
  decide: needed
79
88
  flow: optional
80
89
  contract: optional
@@ -91,6 +100,7 @@ parts:
91
100
  name: Proposal
92
101
  group: decide
93
102
  holds: The option we pick, and how it works.
103
+ picture: one
94
104
  decide: needed
95
105
  flow: none
96
106
  contract: none
@@ -132,6 +142,7 @@ parts:
132
142
  name: Stories
133
143
  group: flow
134
144
  holds: One walkthrough for each story, step by step.
145
+ picture: each-story
135
146
  decide: optional
136
147
  flow: needed
137
148
  contract: optional
@@ -156,6 +167,7 @@ parts:
156
167
  name: States
157
168
  group: flow
158
169
  holds: The states a thing moves through, and what moves it.
170
+ picture: one
159
171
  decide: none
160
172
  flow: optional
161
173
  contract: none
@@ -0,0 +1,206 @@
1
+ // The roundup: where one product stands, and where it is going (target design
2
+ // flow 9). Tracker item b1610d9b moved it forward from stage 4 (decision
3
+ // 3c268725).
4
+ //
5
+ // It joins the reads that ship already and adds no store:
6
+ // 1. Needs you the same read as needs_me
7
+ // 2. Where we stand items tagged stage-<n>, in ladder order, and a count of
8
+ // every item of the product by lifecycle
9
+ // 3. What moved delivery marks, merged pull requests and new items in
10
+ // the window
11
+ // 4. Stuck or drifting active items with no change for STUCK_DAYS, merged
12
+ // but not marked, open pull requests that name no item,
13
+ // and questions that wait longer than STUCK_DAYS
14
+ // 5. Trend per week, for TREND_WEEKS weeks: merges, deliveries,
15
+ // new items; and the open questions with the oldest age
16
+ // Every row carries a citation: an item id, a question id or a pull request
17
+ // URL. It is computed at read time and never stored.
18
+ import { refuse, isoDate, LIVE_LIFECYCLES } from './verb-fields.mjs';
19
+ import { oneLine, ROW_LINE_MAX } from './questions.mjs';
20
+ import { readPullsByRepo, pullsOfItem, daysBetween, listLine } from './delivery.mjs';
21
+
22
+ export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
23
+
24
+ // A free tag that places an item on its product's ladder. An item tagged
25
+ // stage-2 is the stage 2 item or one of its parts.
26
+ export const STAGE_TAG = /^stage-(\d{1,2})$/;
27
+ export const STUCK_DAYS = 14;
28
+ export const TREND_WEEKS = 4;
29
+ const DEFAULT_WINDOW = 7;
30
+ const MAX_WINDOW = 90;
31
+ const MAX_ITEMS = 500;
32
+ const MAX_REPOS = 16;
33
+ const SHOWN = 8;
34
+ const DAY = 86_400_000;
35
+ const LIFECYCLE_ORDER = ['active', 'proposed', 'paused', 'done', 'dropped'];
36
+
37
+ const plural = (count, word) => `${count} ${count === 1 ? word : (/[^aeiou]y$/.test(word) ? `${word.slice(0, -1)}ies` : `${word}s`)}`;
38
+ const dayOf = (value) => String(value ?? '').slice(0, 10);
39
+ const minus = (readAt, days) => new Date(Date.parse(readAt) - days * DAY).toISOString();
40
+
41
+ export function stageOf(item) {
42
+ const tags = Array.isArray(item?.tags) ? item.tags : [];
43
+ const numbers = tags.map((tag) => STAGE_TAG.exec(String(tag).toLowerCase())).filter(Boolean).map((match) => Number(match[1]));
44
+ return numbers.length ? Math.min(...numbers) : null;
45
+ }
46
+
47
+ // The ladder: each stage number with its items, lowest stage first. A stage
48
+ // reads "done" only when every item on it is done or dropped.
49
+ export function ladderOf(items) {
50
+ const byStage = new Map();
51
+ for (const item of items) {
52
+ const stage = stageOf(item);
53
+ if (stage === null) continue;
54
+ if (!byStage.has(stage)) byStage.set(stage, []);
55
+ byStage.get(stage).push(item);
56
+ }
57
+ return [...byStage.entries()].sort((a, b) => a[0] - b[0]).map(([stage, list]) => {
58
+ const rows = list.map((item) => ({ item: item.id, title: oneLine(item.title, 120), lifecycle: item.fields?.lifecycle ?? 'unknown', stage: item.fields?.stage ?? '' }))
59
+ .sort((a, b) => LIFECYCLE_ORDER.indexOf(a.lifecycle) - LIFECYCLE_ORDER.indexOf(b.lifecycle) || a.title.localeCompare(b.title));
60
+ const counts = countBy(rows.map((row) => row.lifecycle));
61
+ const live = rows.filter((row) => LIVE_LIFECYCLES.includes(row.lifecycle));
62
+ const state = !live.length ? 'done' : (rows.some((row) => row.lifecycle === 'active') ? 'doing' : (rows.some((row) => row.lifecycle === 'done') ? 'started' : 'not started'));
63
+ return { stage, state, counts, rows };
64
+ });
65
+ }
66
+
67
+ export function countBy(values) {
68
+ const counts = {};
69
+ for (const value of values) counts[value] = (counts[value] ?? 0) + 1;
70
+ return Object.fromEntries(Object.entries(counts).sort((a, b) => {
71
+ const rank = (key) => { const at = LIFECYCLE_ORDER.indexOf(key); return at === -1 ? LIFECYCLE_ORDER.length : at; };
72
+ return rank(a[0]) - rank(b[0]) || a[0].localeCompare(b[0]);
73
+ }));
74
+ }
75
+ const countLine = (counts) => Object.entries(counts).map(([key, value]) => `${value} ${key}`).join(', ') || 'none';
76
+
77
+ // The week a moment falls in, counted back from the read: week 1 is the last
78
+ // seven days. A moment outside the trend is null.
79
+ export function weekOf(at, readAt, weeks = TREND_WEEKS) {
80
+ const age = Date.parse(readAt) - Date.parse(at ?? '');
81
+ if (Number.isNaN(age) || age < 0) return null;
82
+ const week = Math.floor(age / (7 * DAY)) + 1;
83
+ return week > weeks ? null : week;
84
+ }
85
+
86
+ export function createRoundupVerbs(core) {
87
+ const { client, now, typeNamed, registry, requireProduct, readGitHub, inFlight, needsMe } = core;
88
+ return {
89
+ async roundup(args = {}) {
90
+ if (args.product === undefined) refuse('product is required: a roundup answers for one product');
91
+ const reg = await registry();
92
+ const product = (await requireProduct(args.product, reg)).fields.registry_id;
93
+ const windowDays = args.days === undefined ? DEFAULT_WINDOW : args.days;
94
+ if (!Number.isInteger(windowDays) || windowDays < 1 || windowDays > MAX_WINDOW) refuse(`days must be a whole number from 1 to ${MAX_WINDOW}`);
95
+ const readAt = now();
96
+ const since = minus(readAt, windowDays);
97
+ const trendFrom = minus(readAt, Math.max(windowDays, TREND_WEEKS * 7));
98
+
99
+ // Parts 1 and 4 reuse the two views that ship, so one rule serves both.
100
+ const needs = await needsMe({ product });
101
+ const flight = await inFlight({ product, since: isoDate(dayOf(trendFrom), 'since') });
102
+
103
+ const container = await typeNamed('container');
104
+ const all = await client.listAllEntities({ type: container.name, state: 'active' });
105
+ const items = all.filter((row) => row.fields?.product === product);
106
+ if (items.length > MAX_ITEMS) refuse(`${items.length} items belong to ${product}; the roundup reads at most ${MAX_ITEMS}`);
107
+
108
+ const repoIds = [...reg.repos.values()].filter((repo) => repo.fields.product === product).map((repo) => repo.fields.registry_id).sort();
109
+ if (repoIds.length > MAX_REPOS) refuse(`${repoIds.length} repos are registered for ${product}; the roundup reads at most ${MAX_REPOS}`);
110
+ const { pullsByRepo, repoErrors } = await readPullsByRepo(repoIds, readGitHub);
111
+
112
+ // Which item each pull request belongs to: an item that links it or
113
+ // whose id it names, as in_flight reads it.
114
+ const owner = new Map();
115
+ for (const item of items) for (const pull of pullsOfItem(item, pullsByRepo, repoIds)) if (!owner.has(pull.url)) owner.set(pull.url, item.id);
116
+ const pulls = [...pullsByRepo.values()].flat();
117
+
118
+ // 1. Needs you.
119
+ const needRows = (needs.products?.[0]?.rows ?? []).map((row) => ({
120
+ question: row.question_id, item: row.item, text: oneLine(row.folded?.question ?? row.lines?.[0] ?? '', 200), item_title: oneLine(row.folded?.item_title ?? '', 120),
121
+ asked_at: row.asked_at, days_waiting: daysBetween(row.asked_at, readAt), cost: row.cost,
122
+ }));
123
+
124
+ // 2. Where we stand.
125
+ const ladder = ladderOf(items);
126
+ const lifecycles = countBy(items.map((row) => row.fields?.lifecycle ?? 'unknown'));
127
+ const active = (flight.products?.[0]?.rows ?? []).map((row) => ({ item: row.item, title: oneLine(row.folded?.title ?? '', 120), stage: row.stage, days_since_last_change: row.days_since_last_change, waits_on: row.waits_on ?? null }));
128
+
129
+ // 3. What moved, in the window.
130
+ const merged = pulls.filter((pull) => pull.state === 'merged' && String(pull.merged_at) >= since)
131
+ .map((pull) => ({ pr: pull.url, title: pull.title, merged_at: pull.merged_at, item: owner.get(pull.url) ?? null }))
132
+ .sort((a, b) => String(b.merged_at).localeCompare(String(a.merged_at)));
133
+ const deliveries = (flight.delivered ?? []).filter((row) => String(row.delivered_at) >= since)
134
+ .map((row) => ({ item: row.item, title: row.item_title, verdict: row.verdict, delivered_at: row.delivered_at, prs: row.prs }));
135
+ const created = items.filter((row) => String(row.created_at ?? '') >= since)
136
+ .map((row) => ({ item: row.id, title: oneLine(row.title, 120), lifecycle: row.fields?.lifecycle ?? 'unknown', created_at: row.created_at }))
137
+ .sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
138
+
139
+ // 4. Stuck or drifting.
140
+ const stale = active.filter((row) => row.days_since_last_change !== null && row.days_since_last_change >= STUCK_DAYS);
141
+ const notMarked = (flight.merged_not_marked ?? []).map((row) => ({ item: row.item, pr: row.pr, title: row.title, days: row.days }));
142
+ const openNoItem = pulls.filter((pull) => pull.state === 'open' && !owner.has(pull.url)).map((pull) => ({ pr: pull.url, title: pull.title, updated_at: pull.updated_at }));
143
+ const longWaits = needRows.filter((row) => row.days_waiting !== null && row.days_waiting >= STUCK_DAYS);
144
+
145
+ // 5. Trend, per week; week 1 is the last seven days.
146
+ 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 }));
147
+ const bump = (at, key) => { const week = weekOf(at, readAt); if (week) weeks[week - 1][key] += 1; };
148
+ for (const pull of pulls) if (pull.state === 'merged') bump(pull.merged_at, 'merged');
149
+ for (const row of flight.delivered ?? []) bump(row.delivered_at, 'delivered');
150
+ for (const row of items) bump(row.created_at, 'new_items');
151
+ const oldest = needRows.reduce((max, row) => Math.max(max, row.days_waiting ?? 0), 0);
152
+
153
+ const text = render({ product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors });
154
+ return {
155
+ verb: 'roundup', read_at: readAt, product, window_days: windowDays, since,
156
+ needs_you: needRows,
157
+ where_we_stand: { ladder, lifecycles, active },
158
+ what_moved: { merged, delivered: deliveries, new_items: created },
159
+ stuck_or_drifting: { stale, merged_not_marked: notMarked, open_prs_with_no_item: openNoItem, long_waits: longWaits, stuck_days: STUCK_DAYS },
160
+ trend: { weeks, open_questions: needRows.length, oldest_question_days: oldest },
161
+ repos_read: repoIds, repo_errors: repoErrors,
162
+ citation: { product, read_at: readAt, items: items.length, repos: repoIds },
163
+ text,
164
+ };
165
+ },
166
+ };
167
+ }
168
+
169
+ function render(view) {
170
+ const { product, readAt, windowDays, needRows, ladder, lifecycles, active, merged, deliveries, created, stale, notMarked, openNoItem, longWaits, weeks, oldest, repoErrors } = view;
171
+ const more = (list) => (list.length > SHOWN ? [` and ${list.length - SHOWN} more`] : []);
172
+ const out = [`${product} — roundup, ${dayOf(readAt)}`];
173
+
174
+ out.push([`1. Needs you (${needRows.length})`,
175
+ ...(needRows.length ? needRows.slice(0, SHOWN).map((row) => listLine(row.text, `${row.question} · asked ${dayOf(row.asked_at)}, ${plural(row.days_waiting ?? 0, 'day')}`)) : [' nothing']),
176
+ ...more(needRows)].join('\n'));
177
+
178
+ const stand = [`2. Where we stand · items: ${countLine(lifecycles)}`];
179
+ if (ladder.length) for (const step of ladder) stand.push(` ${oneLine(`Stage ${step.stage}: ${step.state} · ${countLine(step.counts)} · ${step.rows.filter((row) => LIVE_LIFECYCLES.includes(row.lifecycle)).map((row) => `${row.title} (${row.lifecycle}, ${row.item.slice(0, 8)})`).join('; ') || 'all closed'}`, ROW_LINE_MAX - 3)}`);
180
+ else stand.push(' no item carries a stage-<n> tag');
181
+ stand.push(` in flight (${active.length})`);
182
+ stand.push(...(active.length ? active.slice(0, SHOWN).map((row) => listLine(row.title, `${row.item} · ${row.stage || 'no stage'} · ${row.days_since_last_change ?? '?'} days since last change`, ' ')) : [' nothing']));
183
+ out.push(stand.join('\n'));
184
+
185
+ out.push([`3. What moved, last ${plural(windowDays, 'day')}`,
186
+ ` ${plural(deliveries.length, 'delivery')} · ${plural(merged.length, 'merged pull request')} · ${plural(created.length, 'new item')}`,
187
+ ...deliveries.slice(0, SHOWN).map((row) => listLine(`delivered: ${row.title}`, `${row.item} · ${dayOf(row.delivered_at)}`)),
188
+ ...merged.slice(0, SHOWN).map((row) => listLine(`merged: ${row.title}`, `${row.pr} · ${dayOf(row.merged_at)}${row.item ? '' : ' · no item'}`)),
189
+ ...more(merged),
190
+ ...created.slice(0, SHOWN).map((row) => listLine(`new: ${row.title}`, `${row.item} · ${row.lifecycle}`)),
191
+ ...more(created)].join('\n'));
192
+
193
+ const stuck = [`4. Stuck or drifting`];
194
+ stuck.push(` no change in ${STUCK_DAYS} days or more (${stale.length})`, ...stale.slice(0, SHOWN).map((row) => listLine(row.title, `${row.item} · ${row.days_since_last_change} days`, ' ')));
195
+ stuck.push(` merged, not marked (${notMarked.length})`, ...notMarked.slice(0, SHOWN).map((row) => listLine(row.title, `${row.pr} · ${row.item}`, ' ')));
196
+ stuck.push(` open pull requests with no item (${openNoItem.length})`, ...openNoItem.slice(0, SHOWN).map((row) => listLine(row.title, row.pr, ' ')));
197
+ 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`, ' ')));
198
+ out.push(stuck.join('\n'));
199
+
200
+ out.push([`5. Trend, per week (week 1 is the last seven days)`,
201
+ ...weeks.map((week) => ` week ${week.week} (${week.from} to ${week.to}): ${plural(week.merged, 'merge')} · ${plural(week.delivered, 'delivery')} · ${plural(week.new_items, 'new item')}`),
202
+ ` waiting on you: ${plural(needRows.length, 'question')}${needRows.length ? `, oldest ${plural(oldest, 'day')}` : ''}`].join('\n'));
203
+
204
+ if (repoErrors.length) out.push([`GitHub did not answer for ${plural(repoErrors.length, 'repo')}; parts 3 to 5 count from the graph only`, ...repoErrors.map((row) => oneLine(` ${row.repo} — ${row.reason}`, ROW_LINE_MAX))].join('\n'));
205
+ return out.join('\n\n');
206
+ }
@@ -20,6 +20,7 @@ import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS,
20
20
  import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
21
21
  import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capability.mjs';
22
22
  import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
23
+ import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
23
24
  import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
24
25
 
25
26
  export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
@@ -751,11 +752,12 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
751
752
  Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
752
753
  Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
753
754
  Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
755
+ Object.assign(verbs, createRoundupVerbs({ ...core, readGitHub, inFlight: (args) => verbs.in_flight(args), needsMe: (args) => verbs.needs_me(args) }));
754
756
  const activity = createActivityVerbs(core);
755
757
  Object.assign(verbs, activity.verbs);
756
758
 
757
759
  const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS]);
758
- const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS, ...ACTIVITY_READ_VERBS]);
760
+ const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS]);
759
761
 
760
762
  async function run(verb, args = {}) {
761
763
  if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
package/work/mcp.mjs CHANGED
@@ -69,6 +69,7 @@ export const TOOLS = [
69
69
  { 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 } },
70
70
  { 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 } },
71
71
  { 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 } },
72
+ { name: 'roundup', description: 'Answer "where does this product stand, and where is it going?" in five parts: 1 needs you (the needs_me read); 2 where we stand (items tagged stage-<n> in ladder order, a count of every item by lifecycle, and the active items); 3 what moved in the window (delivery marks, merged pull requests, new items); 4 stuck or drifting (active items with no change for 14 days, merged but not marked, open pull requests that name no item, questions waiting 14 days); 5 the trend per week for four weeks. Every row cites an item, a question or a pull request. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), days: { type: 'integer', minimum: 1, maximum: 90, description: 'The window for part 3, in days. Default 7.' } }, additionalProperties: false } },
72
73
  { 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 } },
73
74
  { 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 } },
74
75
  { 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 } },