@arjunkhera/atlas 0.3.22 → 0.3.25
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/agents/artifact-renderer.md +2 -0
- package/door/cli.mjs +17 -0
- package/door/lib/design.mjs +199 -7
- package/door/lib/markdown.mjs +33 -5
- package/door/lib/ste.mjs +5 -3
- package/door/lib/view.mjs +46 -0
- package/package.json +2 -1
- package/skills/lead/SKILL.md +1 -0
- package/skills/sdlc-task/SKILL.md +2 -1
- package/skills/sdlc-task/design/README.md +40 -12
- package/skills/sdlc-task/design/parts/problem.md +18 -1
- package/skills/sdlc-task/design/parts/proposal.md +29 -1
- package/skills/sdlc-task/design/parts/states.md +17 -1
- package/skills/sdlc-task/design/parts/stories.md +43 -5
- package/skills/sdlc-task/design/parts/summary.md +2 -1
- package/skills/sdlc-task/design/parts.yaml +12 -0
- package/view/page.html +563 -0
- package/work/lib/questions.mjs +17 -56
- package/work/lib/verb-fields.mjs +3 -3
- package/work/lib/view-rules.mjs +279 -0
|
@@ -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
|
|
package/door/cli.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
|
|
|
25
25
|
import { checkPaths, LIMITS } from './lib/ste.mjs';
|
|
26
26
|
import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
|
|
27
27
|
import { writeDesignPage, readTracker } from './lib/design-build.mjs';
|
|
28
|
+
import { writeViewPage, viewCapabilities } from './lib/view.mjs';
|
|
28
29
|
import { loadPrivateTerms } from './lib/privacy.mjs';
|
|
29
30
|
import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
|
|
30
31
|
import { proofCommand as testsProof } from './lib/proof.mjs';
|
|
@@ -59,6 +60,10 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
59
60
|
--out <file> the page; default docs/artifacts/<folder name>.html
|
|
60
61
|
--draft build even when the check has findings, and mark the page a draft
|
|
61
62
|
|
|
63
|
+
atlas view build --product <id> build the project view page of one product: the four views, read live from the tracker
|
|
64
|
+
--name <name> the product's name on the page; default the id with a capital
|
|
65
|
+
--out <file> the page; default docs/artifacts/<id>-view.html
|
|
66
|
+
|
|
62
67
|
atlas tests check --root <area> run the link check on the tests of an area
|
|
63
68
|
--halves <list> static, lint, run; default all three
|
|
64
69
|
--evidence <dir> where the evidence files are; default <area>/test/evidence
|
|
@@ -120,6 +125,7 @@ export const FLAGS = Object.freeze({
|
|
|
120
125
|
check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
|
|
121
126
|
ste: { share: 'switch', terms: 'value' },
|
|
122
127
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
128
|
+
view: { product: 'value', name: 'value', out: 'value' },
|
|
123
129
|
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch' },
|
|
124
130
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
|
|
125
131
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
@@ -296,6 +302,16 @@ function designCommand(chosen) {
|
|
|
296
302
|
return count ? 1 : 0;
|
|
297
303
|
}
|
|
298
304
|
|
|
305
|
+
function viewCommand(chosen) {
|
|
306
|
+
if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
|
|
307
|
+
const product = chosen.product;
|
|
308
|
+
const out = resolve(chosen.out ?? join('docs', 'artifacts', `${product}-view.html`));
|
|
309
|
+
const page = writeViewPage(out, { packageRoot: PACKAGE_ROOT, product, name: chosen.name, version: ATLAS_VERSION });
|
|
310
|
+
line(`Wrote ${out}: the project view of ${page.name} (product ${page.product}). It holds no records; it reads them at open.`);
|
|
311
|
+
line(`Publish it with this capability, and no other: ${JSON.stringify(viewCapabilities())}`);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
299
315
|
async function testsCommand(chosen) {
|
|
300
316
|
const root = rootOf(chosen);
|
|
301
317
|
const what = chosen._[1];
|
|
@@ -343,6 +359,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
343
359
|
case 'check': return checkCommand(chosen);
|
|
344
360
|
case 'ste': return steCommand(chosen);
|
|
345
361
|
case 'design': return designCommand(chosen);
|
|
362
|
+
case 'view': return viewCommand(chosen);
|
|
346
363
|
case 'tests': return testsCommand(chosen);
|
|
347
364
|
default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
|
|
348
365
|
}
|
package/door/lib/design.mjs
CHANGED
|
@@ -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,
|
|
9
|
-
// close
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
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 {
|
|
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)) {
|
package/door/lib/markdown.mjs
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
|
64
|
-
if (
|
|
88
|
+
const mark = fenceOpens(line);
|
|
89
|
+
if (mark) {
|
|
65
90
|
flush();
|
|
66
|
-
const info =
|
|
91
|
+
const info = line.trim().slice(mark.length).trim();
|
|
67
92
|
const buf = [];
|
|
68
93
|
i += 1;
|
|
69
|
-
while (i < lines.length &&
|
|
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
|
-
|
|
68
|
-
if (fence) { if (
|
|
69
|
-
|
|
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; }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The project view page builder: `atlas view build`. It puts the view rules
|
|
2
|
+
// (work/lib/view-rules.mjs) and one product id into the page template
|
|
3
|
+
// (view/page.html), and writes one page. The page holds no record data: it
|
|
4
|
+
// reads the tracker at open, through the viewer's Engram connector.
|
|
5
|
+
// Design: docs/design-docs/project-view (tracker item 6dd1761a).
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
// The one connector grant the page needs, for the Artifact publish. Both
|
|
10
|
+
// tools only read.
|
|
11
|
+
export const VIEW_CONNECTOR = Object.freeze({ server: 'Engram', tools: Object.freeze(['entities_list', 'get']) });
|
|
12
|
+
export const viewCapabilities = () => ({ mcp: { servers: [{ server: VIEW_CONNECTOR.server, tools: [...VIEW_CONNECTOR.tools] }] } });
|
|
13
|
+
|
|
14
|
+
const PRODUCT_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
15
|
+
const quoted = (value) => JSON.stringify(String(value)).slice(1, -1).replace(/</g, '\\u003c');
|
|
16
|
+
const plain = (value) => String(value).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]);
|
|
17
|
+
|
|
18
|
+
export function buildViewPage({ packageRoot, product, name, version }) {
|
|
19
|
+
if (!PRODUCT_ID.test(String(product ?? ''))) throw new Error('give a product id: atlas view build --product <id>, such as atlas');
|
|
20
|
+
const label = String(name ?? '').trim() || product.charAt(0).toUpperCase() + product.slice(1);
|
|
21
|
+
if (label.length > 80) throw new Error('--name is at most 80 characters');
|
|
22
|
+
const template = readFileSync(join(packageRoot, 'view', 'page.html'), 'utf8');
|
|
23
|
+
const rules = readFileSync(join(packageRoot, 'work', 'lib', 'view-rules.mjs'), 'utf8');
|
|
24
|
+
if (/<\/script/i.test(rules)) throw new Error('view-rules.mjs holds "</script", so it cannot go into a page');
|
|
25
|
+
const swaps = {
|
|
26
|
+
'/*@@VIEW_RULES@@*/': rules,
|
|
27
|
+
'@@PRODUCT@@': quoted(product),
|
|
28
|
+
'@@PRODUCT_NAME@@': quoted(label),
|
|
29
|
+
'@@ATLAS_VERSION@@': quoted(version ?? ''),
|
|
30
|
+
'@@TITLE@@': plain(`${label} project view`),
|
|
31
|
+
};
|
|
32
|
+
let html = template;
|
|
33
|
+
for (const [mark, value] of Object.entries(swaps)) {
|
|
34
|
+
if (!html.includes(mark)) throw new Error(`the page template has no ${mark}`);
|
|
35
|
+
html = html.split(mark).join(value);
|
|
36
|
+
}
|
|
37
|
+
if (/@@[A-Z_]+@@/.test(html)) throw new Error('the page template has a mark the builder does not fill');
|
|
38
|
+
return { html, product, name: label };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeViewPage(out, options) {
|
|
42
|
+
const page = buildViewPage(options);
|
|
43
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
44
|
+
writeFileSync(out, page.html);
|
|
45
|
+
return page;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arjunkhera/atlas",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.25",
|
|
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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"work/",
|
|
22
22
|
"shape/",
|
|
23
23
|
"tests/",
|
|
24
|
+
"view/",
|
|
24
25
|
"skills/",
|
|
25
26
|
"agents/",
|
|
26
27
|
".claude-plugin/",
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -102,6 +102,7 @@ set. A test keeps the two lists equal.
|
|
|
102
102
|
| `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
|
|
103
103
|
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
|
|
104
104
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
105
|
+
| `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
|
|
105
106
|
|
|
106
107
|
A person merges every file that `atlas tooling` writes.
|
|
107
108
|
A person merges every file that `atlas tests write` writes.
|
|
@@ -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.
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
````
|