@arjunkhera/atlas 0.3.7 → 0.3.9
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 +22 -22
- package/agents/verifier.md +29 -0
- package/door/cli.mjs +62 -12
- package/door/kit-releases.json +4 -0
- package/door/lib/design-build.mjs +409 -0
- package/door/lib/design.mjs +199 -118
- package/door/lib/markdown.mjs +160 -0
- package/door/lib/proof.mjs +127 -0
- package/door/lib/tests.mjs +190 -0
- package/package.json +2 -1
- package/skills/lead/SKILL.md +4 -1
- package/skills/sdlc-task/SKILL.md +33 -24
- package/skills/sdlc-task/design/README.md +187 -0
- package/skills/sdlc-task/design/parts/actors.md +26 -0
- package/skills/sdlc-task/design/parts/alternatives.md +24 -0
- package/skills/sdlc-task/design/parts/build.md +23 -0
- package/skills/sdlc-task/design/parts/calls.md +25 -0
- package/skills/sdlc-task/design/parts/change.md +27 -0
- package/skills/sdlc-task/design/parts/data.md +22 -0
- package/skills/sdlc-task/design/parts/done.md +23 -0
- package/skills/sdlc-task/design/parts/edges.md +24 -0
- package/skills/sdlc-task/design/parts/goals.md +27 -0
- package/skills/sdlc-task/design/parts/key.md +25 -0
- package/skills/sdlc-task/design/parts/migration.md +22 -0
- package/skills/sdlc-task/design/parts/order.md +24 -0
- package/skills/sdlc-task/design/parts/problem.md +22 -0
- package/skills/sdlc-task/design/parts/proof.md +24 -0
- package/skills/sdlc-task/design/parts/proposal.md +24 -0
- package/skills/sdlc-task/design/parts/records.md +24 -0
- package/skills/sdlc-task/design/parts/repos.md +25 -0
- package/skills/sdlc-task/design/parts/risks.md +24 -0
- package/skills/sdlc-task/design/parts/rollout.md +24 -0
- package/skills/sdlc-task/design/parts/routes.md +24 -0
- package/skills/sdlc-task/design/parts/scorecard.md +25 -0
- package/skills/sdlc-task/design/parts/security.md +22 -0
- package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
- package/skills/sdlc-task/design/parts/states.md +25 -0
- package/skills/sdlc-task/design/parts/stories.md +26 -0
- package/skills/sdlc-task/design/parts/summary.md +33 -0
- package/skills/sdlc-task/design/parts/why.md +22 -0
- package/skills/sdlc-task/design/parts/words.md +29 -0
- package/skills/sdlc-task/design/parts/yardstick.md +25 -0
- package/skills/sdlc-task/design/parts.yaml +306 -0
- package/skills/sdlc-task/lifecycle.yaml +2 -2
- package/skills/tests/SKILL.md +234 -0
- package/tests/contract.mjs +398 -0
- package/tests/drivers/function.mjs +30 -0
- package/tests/drivers/http.mjs +68 -0
- package/tests/drivers/index.mjs +65 -0
- package/tests/drivers/mcp-stdio.mjs +174 -0
- package/tests/environment.mjs +227 -0
- package/tests/errors.mjs +27 -0
- package/tests/evidence.mjs +113 -0
- package/tests/fresh.mjs +42 -0
- package/tests/guards.mjs +159 -0
- package/tests/index.mjs +11 -0
- package/tests/link-check.mjs +576 -0
- package/tests/procs.mjs +43 -0
- package/tests/redact.mjs +58 -0
- package/tests/scenario.mjs +325 -0
- package/tests/stand-in.mjs +74 -0
- package/tests/tests-yaml.mjs +258 -0
- package/tests/wait.mjs +44 -0
- package/tests/yaml.mjs +327 -0
- package/agents/artifact-format/walkthrough.html +0 -706
- package/skills/sdlc-task/templates/design-doc.md +0 -126
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// `atlas tests proof`: the proof table of design section 5.7, made from the
|
|
2
|
+
// evidence of ONE run, or with no --run the newest run of each scenario.
|
|
3
|
+
//
|
|
4
|
+
// atlas tests proof --root <area> [--run <id>] [--own <file>] [--evidence <dir>]
|
|
5
|
+
//
|
|
6
|
+
// One row for each assertion, one column for each way in, a column for the
|
|
7
|
+
// verifier's own check, and a short proof. `--own` is a JSON file of
|
|
8
|
+
// { "<scenario>/e1": { "result": "pass", "proof": "revision 1" } }; a full id
|
|
9
|
+
// (with its #fingerprint) works as a key too. A way that is blocked or failed
|
|
10
|
+
// shows its reason in the proof column. Evidence of a fault run is left out.
|
|
11
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { newestRunPerScenario } from '../../tests/link-check.mjs';
|
|
14
|
+
|
|
15
|
+
const MAX_CELL = 110;
|
|
16
|
+
|
|
17
|
+
const clean = (text) => String(text ?? '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
|
|
18
|
+
const clip = (text, n = MAX_CELL) => { const t = clean(text); return t.length > n ? `${t.slice(0, n - 3)}...` : t; };
|
|
19
|
+
const label = (result) => String(result ?? '').toUpperCase();
|
|
20
|
+
|
|
21
|
+
function summary(value) {
|
|
22
|
+
if (value === undefined || value === null) return '';
|
|
23
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const usage = (message) => Object.assign(new Error(message), { usage: true });
|
|
27
|
+
|
|
28
|
+
// Reads the --own file. Throws an Error with a plain message on a bad file.
|
|
29
|
+
export function readOwn(file) {
|
|
30
|
+
let data;
|
|
31
|
+
try { data = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw usage(`--own ${file} cannot be read as JSON: ${error.code ?? error.message}`); }
|
|
32
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw usage('--own must hold a JSON object of { id: { result, proof } }');
|
|
33
|
+
for (const [id, one] of Object.entries(data)) {
|
|
34
|
+
if (!one || typeof one !== 'object' || typeof one.result !== 'string') throw usage(`--own: the entry "${id}" must be { "result": "pass", "proof": "..." }`);
|
|
35
|
+
}
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildProof({ evidenceDir, runId = null, own = {} }) {
|
|
40
|
+
const perScenario = runId ? null : newestRunPerScenario(evidenceDir);
|
|
41
|
+
if (!runId && !perScenario.size) throw new Error(`there is no run in ${evidenceDir}`);
|
|
42
|
+
const names = runId ? [runId] : [...new Set(perScenario.values())].sort();
|
|
43
|
+
const newest = new Map();
|
|
44
|
+
const used = new Set();
|
|
45
|
+
let faultRuns = 0;
|
|
46
|
+
for (const id of names) {
|
|
47
|
+
const dir = join(evidenceDir, id);
|
|
48
|
+
if (!existsSync(dir)) throw new Error(`the run "${id}" has no folder in ${evidenceDir}`);
|
|
49
|
+
for (const name of readdirSync(dir).sort()) {
|
|
50
|
+
if (!name.endsWith('.json')) continue;
|
|
51
|
+
let data;
|
|
52
|
+
try { data = JSON.parse(readFileSync(join(dir, name), 'utf8')); } catch { continue; }
|
|
53
|
+
if (data?.evidence !== 1) continue;
|
|
54
|
+
if (data.fault_run) { faultRuns += 1; continue; }
|
|
55
|
+
if (perScenario && perScenario.get(data.scenario) !== id) continue;
|
|
56
|
+
used.add(id);
|
|
57
|
+
const before = newest.get(data.scenario);
|
|
58
|
+
if (!before || String(data.started) >= String(before.started)) newest.set(data.scenario, data);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const id = runId ?? [...used].sort().join(', ');
|
|
62
|
+
if (!newest.size) throw new Error(`the run "${id}" has no evidence file that counts${faultRuns ? ' (only fault runs)' : ''}`);
|
|
63
|
+
const scenarios = [...newest.values()].sort((a, b) => (a.scenario < b.scenario ? -1 : 1));
|
|
64
|
+
const ways = [...new Set(scenarios.flatMap((s) => Object.keys(s.ways ?? {})))];
|
|
65
|
+
const rows = [];
|
|
66
|
+
const blocked = [];
|
|
67
|
+
for (const s of scenarios) {
|
|
68
|
+
for (const [way, w] of Object.entries(s.ways ?? {})) {
|
|
69
|
+
if (w.verdict === 'blocked' || w.verdict === 'fail') blocked.push(`${s.scenario} through ${way} is ${w.verdict}${w.reason ? `: ${clip(w.reason, 200)}` : ''}`);
|
|
70
|
+
}
|
|
71
|
+
const ids = [...new Set((s.assertions ?? []).map((a) => a.id))];
|
|
72
|
+
for (const full of ids) {
|
|
73
|
+
const short = full.replace(/#[0-9a-f]+$/, '');
|
|
74
|
+
const entries = (s.assertions ?? []).filter((a) => a.id === full);
|
|
75
|
+
const cells = Object.fromEntries(ways.map((way) => {
|
|
76
|
+
const e = entries.find((x) => x.way === way);
|
|
77
|
+
return [way, e ? label(e.result) : '-'];
|
|
78
|
+
}));
|
|
79
|
+
const problems = entries.filter((e) => e.result !== 'pass' && e.result !== 'not here').map((e) => {
|
|
80
|
+
const why = e.proof?.error ?? e.proof?.why ?? s.ways?.[e.way]?.reason ?? '';
|
|
81
|
+
return `${e.way} ${e.result}${why ? `: ${why}` : ''}`;
|
|
82
|
+
});
|
|
83
|
+
const passed = entries.find((e) => e.result === 'pass' && e.proof?.value !== undefined);
|
|
84
|
+
const mine = own[full] ?? own[short] ?? null;
|
|
85
|
+
const parts = [];
|
|
86
|
+
if (problems.length) parts.push(problems.join('; '));
|
|
87
|
+
else if (passed) parts.push(summary(passed.proof.value));
|
|
88
|
+
if (mine?.proof) parts.push(`own: ${summary(mine.proof)}`);
|
|
89
|
+
rows.push({
|
|
90
|
+
label: `${short.replace('/', ' ')}`, full, cells, own: mine ? label(mine.result) : '-',
|
|
91
|
+
proof: clip(parts.join(' | ')),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { runId: id, environment: scenarios[0].environment, ways, rows, blocked, faultRuns, scenarios: scenarios.map((s) => s.scenario) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function renderProof(proof) {
|
|
99
|
+
const out = [];
|
|
100
|
+
out.push(`${proof.runId.includes(', ') ? 'Runs' : 'Run'} ${proof.runId}, environment ${proof.environment}, ${proof.scenarios.length} scenario(s): ${proof.scenarios.join(', ')}.`);
|
|
101
|
+
out.push('');
|
|
102
|
+
out.push(`| Assertion | ${proof.ways.join(' | ')} | Verifier's own check | Proof |`);
|
|
103
|
+
out.push(`|---|${proof.ways.map(() => '---').join('|')}|---|---|`);
|
|
104
|
+
for (const r of proof.rows) out.push(`| ${r.label} | ${proof.ways.map((w) => r.cells[w]).join(' | ')} | ${r.own} | ${r.proof} |`);
|
|
105
|
+
if (proof.blocked.length) {
|
|
106
|
+
out.push('');
|
|
107
|
+
out.push('Blocked or failed ways in:');
|
|
108
|
+
for (const b of proof.blocked) out.push(`- ${clean(b)}`);
|
|
109
|
+
}
|
|
110
|
+
if (proof.faultRuns) {
|
|
111
|
+
out.push('');
|
|
112
|
+
out.push(`${proof.faultRuns} evidence file(s) of fault runs were left out.`);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function proofCommand(chosen, root, line) {
|
|
118
|
+
try {
|
|
119
|
+
const own = chosen.own ? readOwn(resolve(chosen.own)) : {};
|
|
120
|
+
const evidenceDir = resolve(chosen.evidence ?? join(root, 'test', 'evidence'));
|
|
121
|
+
for (const text of renderProof(buildProof({ evidenceDir, runId: chosen.run ?? null, own }))) line(text);
|
|
122
|
+
return 0;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
line(`proof: ${error.message}`);
|
|
125
|
+
return error.usage ? 2 : 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The two verbs of `atlas tests`.
|
|
2
|
+
//
|
|
3
|
+
// atlas tests check --root <area> run the link check on an area
|
|
4
|
+
// atlas tests write --root <area> copy the shipped test kit into <area>/test/atlas/
|
|
5
|
+
// atlas tests proof --root <area> print the proof table of one run (proof.mjs)
|
|
6
|
+
//
|
|
7
|
+
// The kit is the folder `tests/` of this package: the contract library, the
|
|
8
|
+
// drivers, the stand-in helper and the link check. `write` copies each file
|
|
9
|
+
// byte for byte and writes `manifest.json` beside them, with the package
|
|
10
|
+
// version and the sha256 of each file. It refuses to replace a file whose hash
|
|
11
|
+
// the manifest does not list, so a hand edit is never lost. It refuses to write
|
|
12
|
+
// through a link. It also writes <area>/test/evidence/.gitignore (ignore
|
|
13
|
+
// everything) and, at the repo root, a .gitignore line for .env.test when it
|
|
14
|
+
// is missing, and it says what it added. A person merges every file that it
|
|
15
|
+
// writes.
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
19
|
+
import { checkArea, formatFinding, HALVES, kitHashOf, loadKitReleases } from '../../tests/link-check.mjs';
|
|
20
|
+
import { packagePackageVersion } from './releases.mjs';
|
|
21
|
+
|
|
22
|
+
export const KIT_SOURCE = 'tests';
|
|
23
|
+
export const KIT_TARGET = 'test/atlas';
|
|
24
|
+
export const MANIFEST = 'manifest.json';
|
|
25
|
+
|
|
26
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
27
|
+
|
|
28
|
+
export class KitRefusal extends Error {
|
|
29
|
+
constructor(message) { super(message); this.name = 'KitRefusal'; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function filesUnder(dir, prefix = '') {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const name of readdirSync(dir).sort()) {
|
|
35
|
+
const full = join(dir, name);
|
|
36
|
+
if (statSync(full).isDirectory()) out.push(...filesUnder(full, `${prefix}${name}/`));
|
|
37
|
+
else out.push(`${prefix}${name}`);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The kit hash of this package, and the hashes of every kit a release shipped.
|
|
43
|
+
// The current kit is always accepted, so a release need not list itself.
|
|
44
|
+
export const KIT_RELEASES_FILE = join('door', 'kit-releases.json');
|
|
45
|
+
|
|
46
|
+
export function currentKitHash(packageRoot) {
|
|
47
|
+
return kitHashOf(Object.fromEntries([...kitFiles(packageRoot)].map(([path, bytes]) => [path, sha256(bytes)])));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function acceptedKitHashes(packageRoot) {
|
|
51
|
+
const file = join(packageRoot, KIT_RELEASES_FILE);
|
|
52
|
+
return [...(existsSync(file) ? loadKitReleases(file) : []), currentKitHash(packageRoot)];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A path under `root` must not be a link, and none of its parents under `root`.
|
|
56
|
+
function assertPlain(root, path) {
|
|
57
|
+
const parts = relative(root, path).split(/[\\/]/).filter(Boolean);
|
|
58
|
+
let at = root;
|
|
59
|
+
for (const part of parts) {
|
|
60
|
+
at = join(at, part);
|
|
61
|
+
const stat = lstatSyncSafe(at);
|
|
62
|
+
if (!stat) return;
|
|
63
|
+
if (stat.isSymbolicLink()) throw new KitRefusal(`${relative(root, at) || at} is a link. "atlas tests write" never writes through a link. Nothing was written. Replace the link with a plain folder or file.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function lstatSyncSafe(path) { try { return lstatSync(path); } catch { return null; } }
|
|
67
|
+
|
|
68
|
+
// The repo root of an area: the nearest folder above it with a .git, or the area itself.
|
|
69
|
+
function repoRootOf(root) {
|
|
70
|
+
let dir = resolve(root);
|
|
71
|
+
for (;;) {
|
|
72
|
+
if (existsSync(join(dir, '.git'))) return dir;
|
|
73
|
+
const up = dirname(dir);
|
|
74
|
+
if (up === dir) return resolve(root);
|
|
75
|
+
dir = up;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Makes sure that a .gitignore file has each line. Returns { state, file, added }.
|
|
80
|
+
function ensureLines(file, lines, dryRun) {
|
|
81
|
+
const have = existsSync(file) ? readFileSync(file, 'utf8') : null;
|
|
82
|
+
const present = new Set((have ?? '').split('\n').map((l) => l.trim()));
|
|
83
|
+
const added = lines.filter((l) => !present.has(l));
|
|
84
|
+
if (!added.length) return { state: 'current', file, added };
|
|
85
|
+
if (!dryRun) {
|
|
86
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
87
|
+
const base = have === null ? '' : (have.endsWith('\n') || have === '' ? have : `${have}\n`);
|
|
88
|
+
writeFileSync(file, `${base}${added.join('\n')}\n`);
|
|
89
|
+
}
|
|
90
|
+
return { state: have === null ? 'new' : 'add', file, added };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function kitFiles(packageRoot) {
|
|
94
|
+
const dir = join(packageRoot, KIT_SOURCE);
|
|
95
|
+
return new Map(filesUnder(dir).map((path) => [path, readFileSync(join(dir, path))]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Writes the kit into <root>/test/atlas. Returns { version, rows, changed }.
|
|
99
|
+
export function writeKit(root, packageRoot, { dryRun = false } = {}) {
|
|
100
|
+
const version = packagePackageVersion(packageRoot);
|
|
101
|
+
const target = join(root, KIT_TARGET);
|
|
102
|
+
const manifestPath = join(target, MANIFEST);
|
|
103
|
+
let known = {};
|
|
104
|
+
if (existsSync(manifestPath)) {
|
|
105
|
+
try { known = JSON.parse(readFileSync(manifestPath, 'utf8')).files ?? {}; } catch { throw new KitRefusal(`${KIT_TARGET}/${MANIFEST} is not JSON; move it away and run again`); }
|
|
106
|
+
}
|
|
107
|
+
const files = kitFiles(packageRoot);
|
|
108
|
+
assertPlain(root, join(root, 'test'));
|
|
109
|
+
assertPlain(root, target);
|
|
110
|
+
assertPlain(root, manifestPath);
|
|
111
|
+
for (const path of files.keys()) assertPlain(root, join(target, path));
|
|
112
|
+
const evidenceIgnore = join(root, 'test', 'evidence', '.gitignore');
|
|
113
|
+
const repoRoot = repoRootOf(root);
|
|
114
|
+
const rootIgnore = join(repoRoot, '.gitignore');
|
|
115
|
+
assertPlain(root, join(root, 'test', 'evidence'));
|
|
116
|
+
assertPlain(root, evidenceIgnore);
|
|
117
|
+
if (repoRoot === resolve(root)) assertPlain(root, rootIgnore);
|
|
118
|
+
else if (existsSync(rootIgnore) && lstatSync(rootIgnore).isSymbolicLink()) throw new KitRefusal(`${rootIgnore} is a link. "atlas tests write" never writes through a link. Nothing was written.`);
|
|
119
|
+
const rows = [];
|
|
120
|
+
const refused = [];
|
|
121
|
+
for (const [path, bytes] of files) {
|
|
122
|
+
const hash = sha256(bytes);
|
|
123
|
+
const file = join(target, path);
|
|
124
|
+
if (!existsSync(file)) { rows.push({ state: 'new', path, sha256: hash }); continue; }
|
|
125
|
+
const have = sha256(readFileSync(file));
|
|
126
|
+
if (have === hash) rows.push({ state: 'current', path, sha256: hash });
|
|
127
|
+
else if (known[path] === have) rows.push({ state: 'replace', path, sha256: hash });
|
|
128
|
+
else refused.push(path);
|
|
129
|
+
}
|
|
130
|
+
if (refused.length) {
|
|
131
|
+
throw new KitRefusal(`${refused.length} file(s) in ${KIT_TARGET}/ differ from the manifest, so they were edited by hand or came from an unknown copy: ${refused.slice(0, 5).join(', ')}. Nothing was written. Restore them, or move them away.`);
|
|
132
|
+
}
|
|
133
|
+
for (const path of Object.keys(known)) if (!files.has(path)) rows.push({ state: 'orphan', path, sha256: known[path] });
|
|
134
|
+
const manifest = `${JSON.stringify({ atlas: version, files: Object.fromEntries([...files].map(([p, b]) => [p, sha256(b)])) }, null, 2)}\n`;
|
|
135
|
+
const manifestChanged = !existsSync(manifestPath) || readFileSync(manifestPath, 'utf8') !== manifest;
|
|
136
|
+
const ignores = [
|
|
137
|
+
ensureLines(evidenceIgnore, ['*', '!.gitignore'], dryRun),
|
|
138
|
+
ensureLines(rootIgnore, ['.env.test'], dryRun),
|
|
139
|
+
];
|
|
140
|
+
const changed = rows.some((r) => r.state === 'new' || r.state === 'replace') || manifestChanged || ignores.some((i) => i.state !== 'current');
|
|
141
|
+
if (!dryRun) {
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
if (row.state !== 'new' && row.state !== 'replace') continue;
|
|
144
|
+
const file = join(target, row.path);
|
|
145
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
146
|
+
writeFileSync(file, files.get(row.path));
|
|
147
|
+
}
|
|
148
|
+
mkdirSync(target, { recursive: true });
|
|
149
|
+
if (manifestChanged) writeFileSync(manifestPath, manifest);
|
|
150
|
+
}
|
|
151
|
+
return { version, rows, ignores, changed };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The `check` verb. Prints one line for each finding. Returns the exit code.
|
|
155
|
+
export function checkCommand(chosen, root, line, packageRoot) {
|
|
156
|
+
const halves = chosen.halves ? String(chosen.halves).split(',').map((s) => s.trim()).filter(Boolean) : [...HALVES];
|
|
157
|
+
const result = checkArea({
|
|
158
|
+
root, halves, evidence: chosen.evidence, tests: chosen.tests ?? 'test',
|
|
159
|
+
guardsFrom: chosen.guardsFrom || process.env.ATLAS_GUARDS_FROM || null,
|
|
160
|
+
run: chosen.run ?? null,
|
|
161
|
+
kitReleases: acceptedKitHashes(packageRoot),
|
|
162
|
+
});
|
|
163
|
+
for (const finding of result.findings) line(formatFinding(finding));
|
|
164
|
+
line('');
|
|
165
|
+
line(`${result.scenarios.length} scenario(s) read; halves: ${halves.join(', ')}; ${result.findings.length} finding(s).`);
|
|
166
|
+
line(result.findings.length ? 'RED' : 'GREEN');
|
|
167
|
+
return result.findings.length ? 1 : 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function writeCommand(chosen, root, packageRoot, line) {
|
|
171
|
+
try {
|
|
172
|
+
const result = writeKit(root, packageRoot, { dryRun: chosen.dryRun });
|
|
173
|
+
line(`Atlas ${result.version}: the test kit in ${KIT_TARGET}/`);
|
|
174
|
+
for (const row of result.rows) line(` ${row.state.padEnd(8)} ${row.path} sha256 ${row.sha256.slice(0, 12)}…`);
|
|
175
|
+
for (const one of result.ignores) {
|
|
176
|
+
const name = relative(root, one.file) || one.file;
|
|
177
|
+
if (one.state === 'current') line(` ${'current'.padEnd(8)} ${name} (has its lines)`);
|
|
178
|
+
else line(` ${(one.state === 'new' ? 'new' : 'add').padEnd(8)} ${name} ${chosen.dryRun ? 'would add' : 'added'}: ${one.added.join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
line('');
|
|
181
|
+
line(result.changed
|
|
182
|
+
? `${chosen.dryRun ? 'Dry run. Nothing was written. ' : ''}The files come from the package. No edit by hand afterwards. A person merges them.`
|
|
183
|
+
: 'Nothing changed. Every file already matches the package.');
|
|
184
|
+
return 0;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!(error instanceof KitRefusal)) throw error;
|
|
187
|
+
line(`Refused: ${error.message}`);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arjunkhera/atlas",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"door/",
|
|
21
21
|
"work/",
|
|
22
22
|
"shape/",
|
|
23
|
+
"tests/",
|
|
23
24
|
"skills/",
|
|
24
25
|
"agents/",
|
|
25
26
|
".claude-plugin/",
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -56,6 +56,7 @@ say that when you hand over a page.
|
|
|
56
56
|
| Review a design or a diff | The three reviewer crews, as sdlc-task says |
|
|
57
57
|
| Prove a pull request against its definition of done | The `atlas:verifier` crew |
|
|
58
58
|
| Render a page for the owner | The `atlas:artifact-renderer` crew |
|
|
59
|
+
| Onboard an area to tests, review scenario words, write or convert a test, sort a red test, plant faults | The `atlas:tests` skill |
|
|
59
60
|
|
|
60
61
|
A crew reads and reports. It never holds the Atlas tools.
|
|
61
62
|
|
|
@@ -99,6 +100,8 @@ set. A test keeps the two lists equal.
|
|
|
99
100
|
| `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
|
|
100
101
|
| `atlas code-read` | To resolve the citations of a code digest |
|
|
101
102
|
| `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
|
|
102
|
-
| `atlas
|
|
103
|
+
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof` prints the proof table of one run |
|
|
104
|
+
| `atlas design` | `check` before you show a design; `build` to make its page |
|
|
103
105
|
|
|
104
106
|
A person merges every file that `atlas tooling` writes.
|
|
107
|
+
A person merges every file that `atlas tests write` writes.
|
|
@@ -49,8 +49,9 @@ does not repeat those rules; follow the lead.
|
|
|
49
49
|
**A change after the owner approved a design** is not a new lock. Say so at
|
|
50
50
|
once, and never build against a sentence you know is wrong. Show the owner the
|
|
51
51
|
change in one screen. When the owner approves it, record the owner's words with
|
|
52
|
-
`decision_record` on the same item
|
|
53
|
-
|
|
52
|
+
`decision_record` on the same item. Name the part it touched, and change
|
|
53
|
+
that part in the design folder. (Target design, flow 4: long frozen texts
|
|
54
|
+
with hashes retire.)
|
|
54
55
|
|
|
55
56
|
## start
|
|
56
57
|
|
|
@@ -64,12 +65,12 @@ with the date. (Target design, flow 4: long frozen texts with hashes retire.)
|
|
|
64
65
|
(`registry_list`); it may be `main` or `master`. Branch from it, and never
|
|
65
66
|
commit to it. Local sessions use `feature/`, `fix/` or `chore/`; cloud
|
|
66
67
|
sessions use `claude/`. The pull request targets that branch.
|
|
67
|
-
4. **Design
|
|
68
|
-
|
|
69
|
-
`
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
covers pauses.
|
|
68
|
+
4. **Design** (standard and above): follow [`design/README.md`](design/README.md).
|
|
69
|
+
Pick the kinds, and say why. Make the folder `docs/design-docs/<slug>/`
|
|
70
|
+
with `design.yaml`, then write the Summary, Your words and Goals first.
|
|
71
|
+
Read the template for each part in `design/parts/`. List the folder in
|
|
72
|
+
`docs/index.md` in the commit that first lands it. Hotfix tier skips the design: the definition of done lives on
|
|
73
|
+
the work item, and a resume anchor covers pauses.
|
|
73
74
|
5. Enter the design loop.
|
|
74
75
|
|
|
75
76
|
## The design loop (between start and lock)
|
|
@@ -85,16 +86,17 @@ Talk to the owner by the `atlas:lead` skill. What this loop adds:
|
|
|
85
86
|
they presume was ever decided.** A bug report says the code surprised
|
|
86
87
|
someone; it does not say what the code should do.
|
|
87
88
|
- **User stories before the definition of done.** Never write checks for a
|
|
88
|
-
flow no story walks through.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
`question_ask`, each answer with `question_answer
|
|
92
|
-
`decision_record
|
|
89
|
+
flow no story walks through. When the design has a flow a user walks, write
|
|
90
|
+
its Stories part first, whatever kinds it picks.
|
|
91
|
+
- **The design grows in real time.** Record each question with
|
|
92
|
+
`question_ask`, each answer with `question_answer` and each decision with
|
|
93
|
+
`decision_record`, as it happens. The tracker holds them. Change the part
|
|
94
|
+
the answer touches in the design folder at the same time.
|
|
93
95
|
- **Adversarial review before the owner approves.** Standard tier: at least one
|
|
94
96
|
persona. Initiative: the panel, `atlas:reviewer-pm`, `atlas:reviewer-architect`
|
|
95
97
|
and `atlas:reviewer-security`, each briefed with the doc path, in parallel.
|
|
96
98
|
Brief each one to ask whether the design is the right thing. An unresolved
|
|
97
|
-
finding goes
|
|
99
|
+
finding goes to the tracker with `question_ask`.
|
|
98
100
|
|
|
99
101
|
## lock
|
|
100
102
|
|
|
@@ -105,17 +107,22 @@ A lock is the owner's approval of a short design (target design, flow 4).
|
|
|
105
107
|
`lifecycle.yaml` `lock.holds` lists: the definition of done, written as checks; the kind of change, its impact and its undo; and
|
|
106
108
|
each existing test it expects to change. A change that cannot be undone
|
|
107
109
|
shows in bold at the top.
|
|
108
|
-
2. **Show
|
|
109
|
-
`atlas ste` on the
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
2. **Show the design's page before you ask.** Run
|
|
111
|
+
`atlas design check <folder>` and `atlas ste` on the folder, and fix each
|
|
112
|
+
finding by hand. A part that needs a custom figure goes to the
|
|
113
|
+
`atlas:artifact-renderer` crew first. Run
|
|
114
|
+
`atlas design build <folder> --tracker <item.json>`, with the tracker
|
|
115
|
+
data that `design/README.md` names. Run `atlas ste` on the page and
|
|
116
|
+
publish it. Link the folder and the page with `item_link`. Then
|
|
113
117
|
rebuild the hub (see "The design hub" below).
|
|
114
118
|
3. **The owner approves it in the owner's own words.** Gate on
|
|
115
119
|
`lifecycle.yaml` `lock.preconditions`. Record the words with
|
|
116
|
-
`decision_record
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
`decision_record`. The tracker holds the approval; the design file does
|
|
121
|
+
not.
|
|
122
|
+
4. Call `item_lock` with the definition of done as `scope.text`. Set
|
|
123
|
+
`scope.design_doc` to a link to the design at the approved commit, such
|
|
124
|
+
as `https://github.com/<owner>/<repo>/tree/<sha>/docs/design-docs/<slug>`.
|
|
125
|
+
The verb keeps a fingerprint of that text for
|
|
119
126
|
the tools. No person reads or writes a hash, and no frozen text is copied
|
|
120
127
|
into the doc.
|
|
121
128
|
Rebuild the hub, so the design reads "Building".
|
|
@@ -164,8 +171,10 @@ next action. One short paragraph.
|
|
|
164
171
|
migration reaches the running node after the merge.
|
|
165
172
|
2. If anything was a struggle, encode the fix where it stops a repeat — a lint,
|
|
166
173
|
then a tool, then a skill, then a doc — and say which.
|
|
167
|
-
3.
|
|
168
|
-
|
|
174
|
+
3. Rebuild the design's page and the hub, so both read "Shipped". The
|
|
175
|
+
design folder does not change: the tracker holds the delivery. A design
|
|
176
|
+
from before 9 October 2026 is one file. Keep its page, and rebuild only
|
|
177
|
+
the hub.
|
|
169
178
|
|
|
170
179
|
## The design hub
|
|
171
180
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# How to write a design
|
|
2
|
+
|
|
3
|
+
Every Atlas design has the same spine. It adds the parts of each kind it
|
|
4
|
+
needs. A design keeps no state: the tracker holds anything that changes as
|
|
5
|
+
the work moves. The page shows those facts, read only.
|
|
6
|
+
|
|
7
|
+
The look was agreed with the owner on 9 October 2026. The boards are on the
|
|
8
|
+
canvas at https://claude.ai/artifact/DP7WmrRzqyGKTkQQQ6Ev8A.
|
|
9
|
+
|
|
10
|
+
## One home for each fact
|
|
11
|
+
|
|
12
|
+
| Ask this | If yes, it lives in |
|
|
13
|
+
|---|---|
|
|
14
|
+
| Does it change as the work moves? | The tracker: the stage, splits, questions, answers, decisions, the approval, the lock, changes after approval and the proof. |
|
|
15
|
+
| Does it explain why or how? | The design: the problem, the owner's words, the goals, how it works, the options and their reasons, the contract and the done line. |
|
|
16
|
+
|
|
17
|
+
## Pick the kinds
|
|
18
|
+
|
|
19
|
+
Pick one kind or more. One design can mix kinds: an interface change with a
|
|
20
|
+
hard flow is a Contract and a Flow. A part that two kinds need appears once.
|
|
21
|
+
|
|
22
|
+
| Kind | Pick it when | Example |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Decide | There are several ways, and one must win. | Test before merge |
|
|
25
|
+
| Flow | The behaviour is the hard part. | When a test goes red |
|
|
26
|
+
| Contract | An interface, a schema or a command changes. | A document viewer route |
|
|
27
|
+
| Initiative | Many designs serve one goal. | Support for many file types |
|
|
28
|
+
|
|
29
|
+
Set `shared: true` when many repos use the design, such as Atlas itself.
|
|
30
|
+
Then "Each kind of repo" is needed. For a design in one product it is
|
|
31
|
+
optional.
|
|
32
|
+
|
|
33
|
+
## All parts by kind
|
|
34
|
+
|
|
35
|
+
`needed` means the check fails when the part is missing or empty.
|
|
36
|
+
`shared` means needed when the design is shared. `—` means the kind does
|
|
37
|
+
not use the part. The registry is [parts.yaml](parts.yaml), and a test keeps
|
|
38
|
+
this table equal to it.
|
|
39
|
+
|
|
40
|
+
| Part | File holds | Decide | Flow | Contract | Initiative |
|
|
41
|
+
|---|---|---|---|---|---|
|
|
42
|
+
| Summary | `summary` | needed | needed | needed | needed |
|
|
43
|
+
| Your words | `words` | needed | needed | needed | needed |
|
|
44
|
+
| Goals and non-goals | `goals` | needed | needed | needed | needed |
|
|
45
|
+
| Problem today | `problem` | needed | if it applies | if it applies | — |
|
|
46
|
+
| Yardstick | `yardstick` | needed | — | — | — |
|
|
47
|
+
| Proposal | `proposal` | needed | — | — | — |
|
|
48
|
+
| Alternatives | `alternatives` | needed | if it applies | if it applies | — |
|
|
49
|
+
| Scorecard | `scorecard` | needed | — | — | — |
|
|
50
|
+
| Build list | `build` | needed | if it applies | if it applies | — |
|
|
51
|
+
| Who and what | `actors` | if it applies | needed | if it applies | — |
|
|
52
|
+
| Stories | `stories` | if it applies | needed | if it applies | — |
|
|
53
|
+
| Edge cases | `edges` | if it applies | needed | if it applies | — |
|
|
54
|
+
| Records | `records` | — | needed | if it applies | — |
|
|
55
|
+
| States | `states` | — | if it applies | — | — |
|
|
56
|
+
| Routes and interface | `routes` | — | — | needed | — |
|
|
57
|
+
| Calls and answers | `calls` | — | — | needed | — |
|
|
58
|
+
| Data change | `data` | — | — | needed | — |
|
|
59
|
+
| Migration and undo | `migration` | — | — | needed | — |
|
|
60
|
+
| Security | `security` | if it applies | if it applies | needed | — |
|
|
61
|
+
| Rollout | `rollout` | if it applies | if it applies | needed | — |
|
|
62
|
+
| Why one initiative | `why` | — | — | — | needed |
|
|
63
|
+
| Shared decisions | `shared-decisions` | — | — | — | needed |
|
|
64
|
+
| Order and dependencies | `order` | — | — | — | needed |
|
|
65
|
+
| Risks | `risks` | needed | needed | needed | needed |
|
|
66
|
+
| Test and proof plan | `proof` | needed | needed | needed | — |
|
|
67
|
+
| Each kind of repo | `repos` | shared | shared | shared | — |
|
|
68
|
+
| Change and undo | `change` | needed | needed | needed | if it applies |
|
|
69
|
+
| Done line | `done` | needed | needed | needed | needed |
|
|
70
|
+
| Key | `key` | needed | needed | needed | needed |
|
|
71
|
+
|
|
72
|
+
The page also shows six parts from the tracker: Questions, Decisions,
|
|
73
|
+
Approval and lock, Since approval, Family and Roll-up. No file holds them.
|
|
74
|
+
The builder counts the Roll-up from the Family list.
|
|
75
|
+
|
|
76
|
+
## What the lock needs
|
|
77
|
+
|
|
78
|
+
The lock reads `lock.holds` and `lock.preconditions` in `lifecycle.yaml`.
|
|
79
|
+
Each one has a home:
|
|
80
|
+
|
|
81
|
+
| The lock needs | Where it lives |
|
|
82
|
+
|---|---|
|
|
83
|
+
| The definition of done | The Done line part |
|
|
84
|
+
| The kind of change, its impact, its undo and the tests it changes | The Change and undo part |
|
|
85
|
+
| The chosen approach | The Proposal part of a Decide design. In the other kinds, the design itself is the approach. |
|
|
86
|
+
| No open question | The tracker: `needs_me` shows none for the item |
|
|
87
|
+
| The owner's approval in his own words | The tracker: `decision_record` |
|
|
88
|
+
|
|
89
|
+
## Tier and kind
|
|
90
|
+
|
|
91
|
+
The tier (hotfix, standard or initiative) sets how much process the work
|
|
92
|
+
gets. The kind sets which parts the design has. The two are separate: an
|
|
93
|
+
initiative-tier piece of work can be a Decide design, and an Initiative
|
|
94
|
+
design holds a family of designs.
|
|
95
|
+
|
|
96
|
+
At initiative tier, a Decide design goes deep:
|
|
97
|
+
|
|
98
|
+
1. The Problem today part has a figure of today. The Proposal part has a
|
|
99
|
+
figure of the target.
|
|
100
|
+
2. At least two options get full depth in Alternatives: their costs, their
|
|
101
|
+
risks and why each one lost.
|
|
102
|
+
|
|
103
|
+
At standard tier, one chosen approach and one rejected option are enough.
|
|
104
|
+
|
|
105
|
+
## What we kept from the Engram template
|
|
106
|
+
|
|
107
|
+
Every section of the Engram design template has a home. The sections that
|
|
108
|
+
change as work moves went to the tracker.
|
|
109
|
+
|
|
110
|
+
| Engram section | Its home now |
|
|
111
|
+
|---|---|
|
|
112
|
+
| Status, container, tier | The top strip, read from the tracker |
|
|
113
|
+
| Context and goal | Your words and Summary |
|
|
114
|
+
| Goals, non-goals, success metrics | Goals and non-goals |
|
|
115
|
+
| Flows | Flow kind: Stories and States |
|
|
116
|
+
| User stories | Flow kind: Stories, one walkthrough each |
|
|
117
|
+
| Architecture | The Summary figure, the Proposal, and the Contract parts |
|
|
118
|
+
| Approaches considered | Decide kind: Alternatives and Scorecard |
|
|
119
|
+
| Open and resolved questions | Tracker: Questions |
|
|
120
|
+
| Decision log | Tracker: Decisions. The reasons stay in the design. |
|
|
121
|
+
| Risks and failure modes | Risks |
|
|
122
|
+
| Test and eval plan | Test and proof plan |
|
|
123
|
+
| Acceptance criteria | Done line |
|
|
124
|
+
| Lock block | Tracker: Approval and lock |
|
|
125
|
+
| Deviation log | Tracker: Since approval, with the part each change touched |
|
|
126
|
+
|
|
127
|
+
## The folder
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
docs/design-docs/<slug>/
|
|
131
|
+
design.yaml title, item, product, kinds, shared, look
|
|
132
|
+
summary.md ---
|
|
133
|
+
part: summary
|
|
134
|
+
---
|
|
135
|
+
# Summary
|
|
136
|
+
...
|
|
137
|
+
words.md part: words
|
|
138
|
+
...
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
1. Name each file as you like. The `part:` line in its front matter says
|
|
142
|
+
which part it is.
|
|
143
|
+
2. `design.yaml` holds what the design is, never where it stands. Its keys
|
|
144
|
+
are `title`, `item`, `product`, `kinds`, `shared` and `look`. `look`
|
|
145
|
+
links a mock or a canvas. The tracker holds the link to the published
|
|
146
|
+
page.
|
|
147
|
+
3. When the repo's docs lint needs every page linked from an index, add an
|
|
148
|
+
`index.md` that links each part file. The check then makes sure it links
|
|
149
|
+
all of them.
|
|
150
|
+
4. A part file may have a `title:` line. The page then uses it as the
|
|
151
|
+
heading of the part.
|
|
152
|
+
|
|
153
|
+
## Write each part
|
|
154
|
+
|
|
155
|
+
1. Read the template for the part in [parts/](parts/). It says what the
|
|
156
|
+
part holds, the rules for it and a short example.
|
|
157
|
+
2. Write the part in your own file. Do not copy the template.
|
|
158
|
+
3. Write "No change" and the reason when a needed part does not apply.
|
|
159
|
+
An empty part fails the check.
|
|
160
|
+
4. Put every code you use, such as `US-3` or `M1`, in the Key.
|
|
161
|
+
5. Never write a state line, such as "Status: approved". The check flags it.
|
|
162
|
+
|
|
163
|
+
## Figures
|
|
164
|
+
|
|
165
|
+
1. Use a woodcut figure when a stock figure fits: a flow chart, a sequence,
|
|
166
|
+
a state machine or swim lanes. Use a fenced block with the info string
|
|
167
|
+
`woodcut wc-<type>`. It holds the figure data as JSON, in the woodcut
|
|
168
|
+
shape.
|
|
169
|
+
2. Draw your own figure when the subject has its own shape. Git lanes, a
|
|
170
|
+
time line and a data diff are examples. Use a fenced block with the info
|
|
171
|
+
string `figure`. It holds the HTML. Use the page tokens, such as
|
|
172
|
+
`var(--accent)` and `var(--line)`, so the figure works in both themes.
|
|
173
|
+
3. Both kinds follow the same rules. A step has a title and one or two
|
|
174
|
+
sentences. The reader sets the pace. The figure prints as stills.
|
|
175
|
+
Meaning is never by colour alone.
|
|
176
|
+
|
|
177
|
+
## Check and build
|
|
178
|
+
|
|
179
|
+
1. Run `atlas design check <folder>`. Fix each finding by hand.
|
|
180
|
+
2. Read the item, its questions, decisions and links with the Atlas tools.
|
|
181
|
+
Copy their fields into one JSON file, as the tools return them. The
|
|
182
|
+
shape is at the top of `door/lib/design-build.mjs`. The builder
|
|
183
|
+
refuses a field that the tracker does not hold.
|
|
184
|
+
3. Run `atlas design build <folder> --tracker <item.json>`. It writes the
|
|
185
|
+
page only when the check is GREEN. `--draft` builds a page marked as a
|
|
186
|
+
draft.
|
|
187
|
+
4. Run `atlas ste` on the page, then publish it.
|