@arjunkhera/atlas 0.3.6 → 0.3.8

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.6",
3
+ "version": "0.3.8",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -76,6 +76,35 @@ Verified at <the head commit>, in a fresh worktree.
76
76
  Links made for this run: <none, or each ignored file linked>.
77
77
  ```
78
78
 
79
+ ## Scenario proofs
80
+
81
+ Use this section when the definition of done names scenario assertions,
82
+ such as `week-conflict/e3#a1b2c3`. The steps above still apply. These steps
83
+ replace step 2 for those lines.
84
+
85
+ 1. Make a fresh worktree of the branch, as in the steps above.
86
+ 2. Start the product from `atlas/tests.yaml` with run secrets. The kit in
87
+ `<area>/test/atlas/` does this when the scenarios run. Set `ATLAS_GUARDS_FROM`
88
+ to `atlas/tests.yaml` of the main branch. A scenario that needs a provided
89
+ secret follows the rules of the section "Ignored files and secrets".
90
+ 3. Run the scenarios that the line names, with the runner command of `tests.yaml`.
91
+ Note the run id. Evidence goes to `<area>/test/evidence/<run id>/`.
92
+ 4. For each named assertion, make your own check through a raw driver of
93
+ the kit (`test/atlas/drivers/`). Do not use the actions of the repo or its tests.
94
+ Write a short script in your scratch folder. It starts the environment, sends the
95
+ calls of the assertion, and reads the answers. Keep the answer that proves the result.
96
+ 5. Write the `--own` file in your scratch folder. It is a JSON object with one
97
+ entry for each assertion: `{ "week-conflict/e3": { "result": "pass", "proof": "refused, revision 2" } }`.
98
+ 6. Print the table with `atlas tests proof`. Give it `--root` for the area,
99
+ `--run` for the run id, and `--own` for your file.
100
+ Put the table under the heading of the proof comment. A blocked or failed way
101
+ in shows its reason in the table.
102
+ 7. Run the privacy check on the comment, and post it, as the steps above say.
103
+ In a cloud session `gh pr comment` may be blocked. Then do not retry.
104
+ Return the path of the comment file to the caller. The caller posts it.
105
+ 8. A line is PASS only when the run and your own check both pass. If they
106
+ differ, mark the line FAIL and show both results.
107
+
79
108
  ## Never-read and never-quote paths (rules in `${CLAUDE_PLUGIN_ROOT}/work/protected-paths.json`)
80
109
 
81
110
  These deny rules bind every sub-agent that reads a repo.
package/door/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // The atlas command: install, upgrade, doctor, check, status, the tooling
3
- // writer, the kind drift report, the code-read resolver, the STE check and
4
- // the design check.
3
+ // writer, the kind drift report, the code-read resolver, the STE check, the
4
+ // design check and the tests verbs (the link check and the test kit writer).
5
5
  //
6
6
  // Stage 1 of the target design retired `setup`, `change`, `helpers` and
7
7
  // `test onboarding`: code that guesses meaning retires (decided 28
@@ -24,6 +24,8 @@ import { install, upgrade, doctor } from './lib/install.mjs';
24
24
  import { checkPaths, LIMITS } from './lib/ste.mjs';
25
25
  import { checkDesignFile, MAX_STEP_SENTENCES } from './lib/design.mjs';
26
26
  import { loadPrivateTerms } from './lib/privacy.mjs';
27
+ import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
28
+ import { proofCommand as testsProof } from './lib/proof.mjs';
27
29
 
28
30
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
29
31
  const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
@@ -46,6 +48,16 @@ const HELP = `atlas — the door into a repo's Atlas files
46
48
  --terms <file> private terms; default ~/.config/atlas/private-terms.txt
47
49
  atlas design <file> … check a walkthrough design doc: summary, parts, an example and a picture in each step
48
50
 
51
+ atlas tests check --root <area> run the link check on the tests of an area
52
+ --halves <list> static, lint, run; default all three
53
+ --evidence <dir> where the evidence files are; default <area>/test/evidence
54
+ --guards-from <file> tests.yaml of the main branch; a changed guard part is a finding
55
+ --run <id> read the evidence of this run only; default the newest run
56
+ atlas tests write --root <area> copy the shipped test kit into <area>/test/atlas/
57
+ atlas tests proof --root <area> print the proof table of one run, one row for each assertion
58
+ --run <id> the run; default the newest run in the evidence folder
59
+ --own <file> JSON of the verifier's own checks: { id: { result, proof } }
60
+
49
61
  atlas install install Atlas for every folder on this Mac
50
62
  atlas upgrade [--version <v>] check, show and install a newer release
51
63
  atlas doctor is Atlas installed and working here
@@ -71,6 +83,7 @@ export const FLAGS = Object.freeze({
71
83
  check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
72
84
  ste: { share: 'switch', terms: 'value' },
73
85
  design: {},
86
+ tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value' },
74
87
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
75
88
  upgrade: { version: 'optional', yes: 'switch' },
76
89
  doctor: {},
@@ -220,6 +233,17 @@ function designCommand(chosen) {
220
233
  return count ? 1 : 0;
221
234
  }
222
235
 
236
+ function testsCommand(chosen) {
237
+ const root = rootOf(chosen);
238
+ const what = chosen._[1];
239
+ if (what === 'check') {
240
+ return testsCheck({ halves: chosen.halves, evidence: chosen.evidence, tests: chosen.tests, guardsFrom: chosen['guards-from'], run: chosen.run }, root, line, PACKAGE_ROOT);
241
+ }
242
+ if (what === 'write') return testsWrite({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
243
+ if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence }, root, line);
244
+ throw new Error(`there is no tests verb "${what ?? ''}". Use check, write or proof.`);
245
+ }
246
+
223
247
  function checkCommand(chosen) {
224
248
  const root = rootOf(chosen);
225
249
  const report = scan({ root, repoId: chosen.repo ?? null });
@@ -250,6 +274,7 @@ export async function main(argv = process.argv.slice(2)) {
250
274
  case 'check': return checkCommand(chosen);
251
275
  case 'ste': return steCommand(chosen);
252
276
  case 'design': return designCommand(chosen);
277
+ case 'tests': return testsCommand(chosen);
253
278
  default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
254
279
  }
255
280
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "note": "Every kit of the folder tests/ that a release of Atlas shipped, as the kit hash of its manifest (the sha256 of the sorted lines of path and file hash). The kit of this package is always accepted and need not be listed. When a release changes anything in tests/, add the kit hash of the release before it here. atlas tests check reads this list; git history is never read.",
3
+ "releases": []
4
+ }
@@ -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.6",
3
+ "version": "0.3.8",
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/",
@@ -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 |
103
+ | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof` prints the proof table of one run |
102
104
  | `atlas design` | Before you render a design doc made from the walkthrough template |
103
105
 
104
106
  A person merges every file that `atlas tooling` writes.
107
+ A person merges every file that `atlas tests write` writes.