@arjunkhera/atlas 0.4.50 → 0.4.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.50",
3
+ "version": "0.4.51",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -24,6 +24,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
24
24
  import { checkPaths, LIMITS } from './lib/ste.mjs';
25
25
  import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
26
26
  import { writeDesignPage, readTracker } from './lib/design-build.mjs';
27
+ import { listDesigns, freezeDesign } from './lib/design-freeze.mjs';
27
28
  import { writePage } from './lib/page.mjs';
28
29
  import { writeViewPage, viewCapabilities } from './lib/view.mjs';
29
30
  import { loadPrivateTerms } from './lib/privacy.mjs';
@@ -71,6 +72,11 @@ const HELP = `atlas — the door into a repo's Atlas files
71
72
  --tracker <file> the tracker data, as JSON, from the lead
72
73
  --out <file> the page; default docs/artifacts/<folder name>.html
73
74
  --draft build even when the check has findings, and mark the page a draft
75
+ atlas design list list each design with its item: live in the design folder, or frozen in archive/
76
+ --json print the rows as JSON: folder, item, state
77
+ atlas design freeze <folder> … freeze the design of a done item: move it to archive/ at its old path, point the links there
78
+ --date <YYYY-MM-DD> the frozen date for its page rows; default today
79
+ --dry-run say what would change, and change nothing
74
80
 
75
81
  atlas page build <file or folder> build an explainer page from markdown: woodcut figures, the Atlas theme, light and dark
76
82
  --out <file> the page; default <file or folder>.html beside it
@@ -224,7 +230,7 @@ export const FLAGS = Object.freeze({
224
230
  status: { root: 'optional', all: 'switch', json: 'switch' },
225
231
  check: { root: 'optional', repo: 'value', 'fail-on': 'value', json: 'switch' },
226
232
  ste: { share: 'switch', terms: 'value' },
227
- design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
233
+ design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional', date: 'value', 'dry-run': 'switch', json: 'switch' },
228
234
  page: { out: 'value', title: 'value' },
229
235
  view: { product: 'value', name: 'value', out: 'value' },
230
236
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
@@ -372,13 +378,40 @@ function designCommand(chosen) {
372
378
  const what = chosen._[1];
373
379
  const folders = chosen._.slice(2);
374
380
  if (what === 'folder') {
375
- if (folders.length || chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft) throw new Error('atlas design folder takes only --root.');
381
+ if (folders.length || chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft || chosen.date !== undefined || chosen['dry-run'] || chosen.json) throw new Error('atlas design folder takes only --root.');
376
382
  const where = designFolderOf(chosen.root === undefined || chosen.root === true ? repoRootFrom(process.cwd()) : resolve(chosen.root));
377
383
  line(where.folder);
378
384
  return 0;
379
385
  }
380
- if (chosen.root !== undefined) throw new Error('--root works only with atlas design folder.');
381
- if (what !== 'check' && what !== 'build') throw new Error('use atlas design check <folder>, atlas design build <folder> or atlas design folder');
386
+ if (what === 'list' || what === 'freeze') {
387
+ if (chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft) throw new Error(`--tracker, --out and --draft work only with atlas design build.`);
388
+ const root = chosen.root === undefined || chosen.root === true ? repoRootFrom(process.cwd()) : resolve(chosen.root);
389
+ if (what === 'list') {
390
+ if (folders.length || chosen.date !== undefined || chosen['dry-run']) throw new Error('atlas design list takes only --root and --json.');
391
+ const rows = listDesigns(root);
392
+ if (chosen.json) { line(JSON.stringify(rows, null, 2)); return 0; }
393
+ for (const row of rows) line(`${row.state.padEnd(7)} ${(row.item ?? 'no item').slice(0, 8).padEnd(8)} ${row.folder}`);
394
+ line('');
395
+ line(`${rows.filter((row) => row.state === 'live').length} live, ${rows.filter((row) => row.state === 'frozen').length} frozen. Freeze a live design when the tracker says its item is done: atlas design freeze <folder>.`);
396
+ return 0;
397
+ }
398
+ if (chosen.json) throw new Error('--json works only with atlas design list.');
399
+ if (!folders.length) throw new Error('give a design folder: atlas design freeze <folder>');
400
+ if (chosen.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(chosen.date)) throw new Error(`--date "${chosen.date}": write a date as YYYY-MM-DD`);
401
+ for (const folder of folders) {
402
+ // A folder that cannot freeze is a finding about the input, not a
403
+ // wrong command: say why and stop with exit 1.
404
+ let report;
405
+ try { report = freezeDesign(root, folder, { date: chosen.date, dryRun: Boolean(chosen['dry-run']) }); } catch (error) { line(`Not frozen: ${error.message}`); return 1; }
406
+ const links = report.links.reduce((n, one) => n + one.count, 0);
407
+ line(`${report.dryRun ? 'Would move' : 'Moved'} ${report.from} to ${report.to} (item ${report.item ? report.item.slice(0, 8) : 'none'}): ${links} link(s) in ${report.links.length} file(s), ${report.index ? 'its index entry under Frozen designs' : 'no index entry'}, ${report.pages} page row(s) frozen.`);
408
+ }
409
+ if (!chosen['dry-run']) line('Stage the move with git add -A, so git sees each file as moved. Then run atlas upkeep check.');
410
+ return 0;
411
+ }
412
+ if (chosen.root !== undefined) throw new Error('--root works only with atlas design folder, list and freeze.');
413
+ if (chosen.date !== undefined || chosen['dry-run'] || chosen.json) throw new Error('--date, --dry-run and --json work only with atlas design list and freeze.');
414
+ if (what !== 'check' && what !== 'build') throw new Error('use atlas design check <folder>, atlas design build <folder>, atlas design folder, atlas design list or atlas design freeze <folder>');
382
415
  if (!folders.length) throw new Error(`give a design folder: atlas design ${what} <folder>`);
383
416
  if (what === 'check' && (chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft)) throw new Error('--tracker, --out and --draft work only with atlas design build.');
384
417
  if (what === 'build') {
@@ -58,14 +58,31 @@ export function changedPaths(root, base, head) {
58
58
  }
59
59
 
60
60
  // The line numbers of one file that the pull request adds or changes, from
61
- // the hunks of a diff with no context.
61
+ // the hunks of a diff with no context. A line that the pull request only
62
+ // moves is not new text, and so is a line whose only change is a link
63
+ // target: the words are the same words (item e4df0d33, where
64
+ // `atlas design freeze` moves index entries and points links at the
65
+ // archive). Each removed line can excuse one added line.
66
+ export const sameWords = (line) => line.replace(/\]\(\s*(?:<[^>\n]*>|[^)\s]*)(?:\s+"[^"]*")?\s*\)/g, ']()').replace(/^(\s{0,3}\[[^\]\n]+\]:\s*)\S+/, '$1').trimEnd();
62
67
  export function addedLines(root, base, head, path) {
63
68
  const out = git(root, ['diff', '-U0', '--no-renames', '--no-ext-diff', '--no-textconv', `${base}...${head}`, '--', path]);
69
+ const removed = new Map();
70
+ for (const row of out.split('\n')) {
71
+ if (row.startsWith('-') && !row.startsWith('---')) {
72
+ const key = sameWords(row.slice(1));
73
+ if (key.trim()) removed.set(key, (removed.get(key) ?? 0) + 1);
74
+ }
75
+ }
64
76
  const lines = new Set();
65
- for (const [, start, count] of out.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
66
- const first = Number(start);
67
- const n = count === undefined ? 1 : Number(count);
68
- for (let line = first; line < first + n; line += 1) lines.add(line);
77
+ let at = null;
78
+ for (const row of out.split('\n')) {
79
+ const hunk = row.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
80
+ if (hunk) { at = Number(hunk[1]); continue; }
81
+ if (at === null || !row.startsWith('+') || row.startsWith('+++')) continue;
82
+ const key = sameWords(row.slice(1));
83
+ if (removed.get(key) > 0) removed.set(key, removed.get(key) - 1);
84
+ else if (key.trim()) lines.add(at);
85
+ at += 1;
69
86
  }
70
87
  return lines;
71
88
  }
@@ -0,0 +1,251 @@
1
+ // A design freezes when its item is done (tracker item e4df0d33). Code, no
2
+ // model, no key, no network. `atlas design list` and `atlas design freeze`
3
+ // use it.
4
+ //
5
+ // The owner, 31 October 2026: "Guide and product documentation is the one
6
+ // that is supposed to be kept updated, design is just a fleeting item of work
7
+ // that gets done, delivered and forgotten. But we should be able to read
8
+ // those anytime later".
9
+ //
10
+ // So a design is a decision record. While its item is open, it lives in the
11
+ // design folder. When the item is done, `atlas design freeze` moves it to
12
+ // archive/ at its old path, and it is frozen:
13
+ // 1. The folder moves as it is. Not one byte of it changes, so the gate reads
14
+ // the move as a move into the archive, not as an edit of the archive.
15
+ // 2. Each relative link in a live markdown file that points into the folder
16
+ // points at the new place, and so does each `sources:` entry in its front
17
+ // matter. Only the link target changes, never the words around it.
18
+ // 3. The docs index entry of the folder moves under "## Frozen designs", so a
19
+ // reader still finds it there.
20
+ // 4. Each row of docs/artifacts.json that is built from the folder names the
21
+ // new place and a frozen date, so the page is never called stale.
22
+ // The upkeep check reads no frozen design: archive/ is outside docs/, so the
23
+ // doc-sources rule and the docs index rule never see it.
24
+ //
25
+ // The rule reads no repo kind: a service, a library and a plugin keep designs
26
+ // alike.
27
+ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, renameSync, mkdirSync } from 'node:fs';
28
+ import { join, posix, relative, resolve, sep } from 'node:path';
29
+ import { parseYaml, markdownFiles, DOCS_INDEX_PATH } from '../../shape/check.mjs';
30
+ import { designFolderOf, DESIGN_FILE } from './design.mjs';
31
+
32
+ export const ARCHIVE = 'archive';
33
+ export const FROZEN_HEADING = '## Frozen designs';
34
+ const ARTIFACTS = 'docs/artifacts.json';
35
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
36
+
37
+ const readText = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
38
+ const isDir = (path) => { try { return statSync(path).isDirectory(); } catch { return false; } };
39
+ const toPosix = (path) => path.split(sep).join('/');
40
+
41
+ function itemOf(root, folder) {
42
+ try { return String(parseYaml(readText(join(root, folder, DESIGN_FILE)) ?? '')?.item ?? '') || null; } catch { return null; }
43
+ }
44
+
45
+ function designsUnder(root, folder) {
46
+ const path = join(root, folder);
47
+ if (!isDir(path)) return [];
48
+ return readdirSync(path, { withFileTypes: true })
49
+ .filter((entry) => entry.isDirectory() && existsSync(join(path, entry.name, DESIGN_FILE)))
50
+ .map((entry) => posix.join(folder, entry.name))
51
+ .sort();
52
+ }
53
+
54
+ // Every design of the repo: the live ones in the design folder, and the
55
+ // frozen ones at the same path under archive/. Each row names its item.
56
+ export function listDesigns(root) {
57
+ const { folder } = designFolderOf(root);
58
+ const live = designsUnder(root, folder).map((path) => ({ folder: path, item: itemOf(root, path), state: 'live' }));
59
+ const frozen = designsUnder(root, posix.join(ARCHIVE, folder)).map((path) => ({ folder: path, item: itemOf(root, path), state: 'frozen' }));
60
+ return [...live, ...frozen];
61
+ }
62
+
63
+ // The design whose item id starts with `prefix`: live first, then frozen.
64
+ export function designsOfItem(rows, prefix) {
65
+ const want = String(prefix ?? '').toLowerCase();
66
+ return want ? rows.filter((row) => row.item && row.item.toLowerCase().startsWith(want)) : [];
67
+ }
68
+
69
+ // A relative link target that points into `from` (a posix path from the repo
70
+ // root) is rewritten to point at `to`. `file` is the markdown file that holds
71
+ // the link. A target with a scheme, a bare anchor, or a path outside the
72
+ // folder is kept as it is.
73
+ export function moveTarget(file, target, from, to) {
74
+ if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#') || target.startsWith('/')) return null;
75
+ const cut = target.search(/[#?]/);
76
+ const path = cut === -1 ? target : target.slice(0, cut);
77
+ const tail = cut === -1 ? '' : target.slice(cut);
78
+ if (!path) return null;
79
+ let decoded = path;
80
+ try { decoded = decodeURIComponent(path); } catch { /* keep it as written */ }
81
+ const at = posix.normalize(posix.join(posix.dirname(file), decoded));
82
+ const slash = decoded.endsWith('/');
83
+ const clean = at.replace(/\/$/, '');
84
+ if (clean !== from && !clean.startsWith(`${from}/`)) return null;
85
+ const moved = to + clean.slice(from.length);
86
+ let next = posix.relative(posix.dirname(file), moved) || '.';
87
+ if (slash) next += '/';
88
+ return next + tail;
89
+ }
90
+
91
+ // The markdown text with each link into `from` pointed at `to`. Inline links
92
+ // and reference definitions; text inside code is never changed.
93
+ export function rewriteLinks(text, file, from, to) {
94
+ let count = 0;
95
+ const lines = String(text).split('\n');
96
+ let fence = null;
97
+ const out = lines.map((one) => {
98
+ const open = one.match(/^\s*(`{3,}|~{3,})/);
99
+ if (fence) {
100
+ if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null;
101
+ return one;
102
+ }
103
+ if (open) { fence = open[1]; return one; }
104
+ // Inline code spans stay as they are: split on them, and rewrite the rest.
105
+ const parts = one.split(/(`+[^`\n]*`+)/);
106
+ return parts.map((part, index) => {
107
+ if (index % 2 === 1) return part;
108
+ let next = part.replace(/(\]\(\s*)(<[^>\n]+>|[^)\s]+)((?:\s+"[^"]*")?\s*\))/g, (whole, head, raw, end) => {
109
+ const angled = raw.startsWith('<');
110
+ const moved = moveTarget(file, angled ? raw.slice(1, -1) : raw, from, to);
111
+ if (moved === null) return whole;
112
+ count += 1;
113
+ return `${head}${angled ? `<${moved}>` : moved}${end}`;
114
+ });
115
+ next = next.replace(/^(\s{0,3}\[[^\]\n]+\]:\s*)(\S+)/, (whole, head, raw) => {
116
+ const moved = moveTarget(file, raw, from, to);
117
+ if (moved === null) return whole;
118
+ count += 1;
119
+ return `${head}${moved}`;
120
+ });
121
+ return next;
122
+ }).join('');
123
+ });
124
+ return { text: out.join('\n'), count };
125
+ }
126
+
127
+ // The front matter of a markdown text with each `sources:` entry in `from`
128
+ // pointed at `to`. A doc may name a design as a source; the path must still
129
+ // resolve, or the upkeep check calls the doc stale.
130
+ export function rewriteSources(text, from, to) {
131
+ const match = String(text).match(/^---\n([\s\S]*?)\n---(\n|$)/);
132
+ if (!match) return { text, count: 0 };
133
+ let count = 0;
134
+ const head = match[1].replace(/^(\s*-\s*['"]?)([^'"\s#]+)/gm, (whole, lead, path) => {
135
+ const clean = path.replace(/\/+$/, '');
136
+ if (clean !== from && !clean.startsWith(`${from}/`)) return whole;
137
+ count += 1;
138
+ return `${lead}${to}${path.slice(from.length)}`;
139
+ });
140
+ return { text: count ? `---\n${head}\n---${match[2]}${String(text).slice(match[0].length)}` : text, count };
141
+ }
142
+
143
+ // The docs index with the entry of the moved folder under the heading for
144
+ // frozen designs. An entry is one list line that links the folder's
145
+ // design.yaml, and the indented lines below it.
146
+ export function moveIndexEntry(text, link) {
147
+ const lines = String(text).replace(/\n$/, '').split('\n');
148
+ const at = lines.findIndex((one) => /^- /.test(one) && one.includes(`](${link})`));
149
+ if (at === -1) return { text, moved: false };
150
+ let end = at + 1;
151
+ while (end < lines.length && /^ {2,}\S/.test(lines[end])) end += 1;
152
+ const entry = lines.splice(at, end - at);
153
+ // Drop a blank line that the cut left doubled.
154
+ if (lines[at - 1] === '' && lines[at] === '') lines.splice(at, 1);
155
+ let head = lines.indexOf(FROZEN_HEADING);
156
+ if (head === -1) {
157
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
158
+ lines.push('', FROZEN_HEADING, '', 'Designs of done items, frozen in the archive at their old path. They say why each part works the way it does. The user guide says how it works now.', '');
159
+ head = lines.indexOf(FROZEN_HEADING);
160
+ }
161
+ // The section runs to the next heading. Keep its entries in name order.
162
+ let stop = lines.findIndex((one, index) => index > head && /^#{1,6} /.test(one));
163
+ if (stop === -1) stop = lines.length;
164
+ const key = (one) => (one.match(/^- \[([^\]]*)\]/)?.[1] ?? one).toLowerCase();
165
+ let put = stop;
166
+ for (let index = head + 1; index < stop; index += 1) {
167
+ if (/^- /.test(lines[index]) && key(lines[index]) > key(entry[0])) { put = index; break; }
168
+ }
169
+ if (put === stop) {
170
+ while (put > head + 1 && lines[put - 1] === '') put -= 1;
171
+ const before = lines[put - 1];
172
+ lines.splice(put, 0, ...(before !== undefined && /^\S/.test(before) && !/^- /.test(before) ? ['', ...entry] : entry));
173
+ } else {
174
+ lines.splice(put, 0, ...entry);
175
+ }
176
+ return { text: `${lines.join('\n')}\n`, moved: true };
177
+ }
178
+
179
+ // The rows of docs/artifacts.json built from the folder: each path names the
180
+ // new place, and the row gets a frozen date. The file keeps its form: two
181
+ // spaces, one row per object, a new line at the end.
182
+ export function freezeRows(text, from, to, date) {
183
+ const data = JSON.parse(text);
184
+ const rows = Array.isArray(data?.artifacts) ? data.artifacts : [];
185
+ let count = 0;
186
+ // The folder as a whole path word: at the start or after a space or a
187
+ // quote, and at the end or before a slash, a space or a quote.
188
+ const word = new RegExp(`(^|[\\s'"(])${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s/'")])`, 'g');
189
+ const hit = (value) => typeof value === 'string' && new RegExp(word.source).test(value);
190
+ for (const row of rows) {
191
+ if (!row || typeof row !== 'object') continue;
192
+ if (!Object.values(row).some(hit)) continue;
193
+ for (const [key, value] of Object.entries(row)) {
194
+ if (typeof value === 'string') row[key] = value.replace(word, `$1${to}`);
195
+ }
196
+ if (typeof row.frozen !== 'string') row.frozen = date;
197
+ count += 1;
198
+ }
199
+ return { text: `${JSON.stringify(data, null, 2)}\n`, count };
200
+ }
201
+
202
+ // Move one live design to the archive. `folder` is a path from the repo root
203
+ // or from here. With dryRun, it changes nothing and says what it would do.
204
+ export function freezeDesign(root, folder, { date = new Date().toISOString().slice(0, 10), dryRun = false } = {}) {
205
+ if (!DATE.test(date)) throw new Error(`--date "${date}": write a date as YYYY-MM-DD`);
206
+ const abs = resolve(folder);
207
+ const from = toPosix(relative(root, abs)).replace(/\/+$/, '');
208
+ if (!from || from.startsWith('..') || from.startsWith('/')) throw new Error(`${folder} is not inside the repo ${root}`);
209
+ if (from === ARCHIVE || from.startsWith(`${ARCHIVE}/`)) throw new Error(`${from} is in ${ARCHIVE}/: it is frozen already`);
210
+ if (!existsSync(join(root, from, DESIGN_FILE))) throw new Error(`${from} holds no ${DESIGN_FILE}: it is not a design folder`);
211
+ const { folder: designs } = designFolderOf(root);
212
+ if (posix.dirname(from) !== designs) throw new Error(`${from} is not a design in the design folder ${designs}/. Run atlas design list`);
213
+ const to = posix.join(ARCHIVE, from);
214
+ if (existsSync(join(root, to))) throw new Error(`${to} exists already. Move it away by hand, or keep the design live`);
215
+
216
+ const report = { from, to, item: itemOf(root, from), links: [], index: false, pages: 0, dryRun };
217
+ const writes = [];
218
+ // Links in live markdown files. The files inside the moved folder keep
219
+ // their bytes, so the move stays a pure move.
220
+ for (const file of markdownFiles(root)) {
221
+ if (file === from || file.startsWith(`${from}/`)) continue;
222
+ const text = readText(join(root, file));
223
+ if (text === null) continue;
224
+ const next = rewriteLinks(text, file, from, to);
225
+ const named = rewriteSources(next.text, from, to);
226
+ if (next.count + named.count) { report.links.push({ file, count: next.count + named.count }); writes.push([file, named.text]); }
227
+ }
228
+ // The docs index entry moves under the frozen heading.
229
+ const indexAt = writes.findIndex(([file]) => file === DOCS_INDEX_PATH);
230
+ const indexText = indexAt === -1 ? readText(join(root, DOCS_INDEX_PATH)) : writes[indexAt][1];
231
+ if (indexText !== null) {
232
+ const link = posix.relative(posix.dirname(DOCS_INDEX_PATH), posix.join(to, DESIGN_FILE));
233
+ const moved = moveIndexEntry(indexText, link);
234
+ if (moved.moved) {
235
+ report.index = true;
236
+ if (indexAt === -1) writes.push([DOCS_INDEX_PATH, moved.text]);
237
+ else writes[indexAt][1] = moved.text;
238
+ }
239
+ }
240
+ // The page rows.
241
+ const rowsText = readText(join(root, ARTIFACTS));
242
+ if (rowsText !== null) {
243
+ const next = freezeRows(rowsText, from, to, date);
244
+ if (next.count) { report.pages = next.count; writes.push([ARTIFACTS, next.text]); }
245
+ }
246
+ if (dryRun) return report;
247
+ mkdirSync(join(root, posix.dirname(to)), { recursive: true });
248
+ renameSync(join(root, from), join(root, to));
249
+ for (const [file, text] of writes) writeFileSync(join(root, file), text);
250
+ return report;
251
+ }
@@ -9,10 +9,15 @@
9
9
  // 1. a .md file under docs/, by its front matter: `sources:` (a list of
10
10
  // paths from the repo root; a path that ends in / is a folder), or
11
11
  // `sources_why:` (why it names no code);
12
- // 2. a design folder (a folder under docs/ that holds design.yaml), by the
13
- // same two keys in design.yaml. Its parts are one unit with it;
14
- // 3. a procedure: the SKILL.md that atlas.yaml `procedures:` names, by the
12
+ // 2. a procedure: the SKILL.md that atlas.yaml `procedures:` names, by the
15
13
  // same two keys in its front matter.
14
+ // A design folder (a folder under docs/ that holds design.yaml) is no unit
15
+ // (item e4df0d33). A design is a decision record: the user guide and the
16
+ // reference pages say how the code works now, and they name their code. The
17
+ // owner, 31 October 2026: "Guide and product documentation is the one that
18
+ // is supposed to be kept updated, design is just a fleeting item of work".
19
+ // Its `sources:` key stays legal, and nothing gates on it. When its item is
20
+ // done, `atlas design freeze` moves it to archive/.
16
21
  // A doc that must not change (a locked text, the owner's own words) can name
17
22
  // its code in a `sources.yaml` beside it, or in a folder above it under docs/:
18
23
  // `files: { <name>: { sources: [...] } }` for one file, or `sources:` and
@@ -94,16 +99,8 @@ function procedureFiles(root) {
94
99
  // names neither its code nor a reason.
95
100
  export function docUnits(root) {
96
101
  const units = [];
97
- const folders = new Set();
98
102
  for (const file of markdownFiles(root, 'docs')) {
99
- const folder = designFolderOf(root, file);
100
- if (folder) {
101
- if (folders.has(folder)) continue;
102
- folders.add(folder);
103
- const where = posix.join(folder, DESIGN);
104
- units.push({ unit: folder, kind: 'design', where, named: declared(yamlOf(readText(root, where) ?? ''), 'sources_why') ?? fromSidecar(root, where), generated: false });
105
- continue;
106
- }
103
+ if (designFolderOf(root, file)) continue;
107
104
  const head = headOf(readText(root, file));
108
105
  units.push({ unit: file, kind: 'doc', where: file, named: declared(head, 'sources_why') ?? fromSidecar(root, file), generated: head?.generated === true });
109
106
  }
@@ -141,7 +138,7 @@ export function covers(root, source, path) {
141
138
  const touchedItself = (unit, changed) => changed.some((path) => path === unit.unit || path === unit.where || path.startsWith(`${unit.unit}/`));
142
139
 
143
140
  // The pull request text that marks a doc "still true": one line that names the
144
- // unit (its path, or for a design folder its folder) and says "still true".
141
+ // unit (its path, or for a procedure its folder) and says "still true".
145
142
  export function stillTrue(body, unit) {
146
143
  const text = String(body ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '');
147
144
  return text.split('\n').some((line) => /still true/i.test(line) && (line.includes(unit.unit) || line.includes(unit.where)));
@@ -232,9 +229,7 @@ function changedLines(root, parent, commit) {
232
229
  // then against each folder it names, then against packages/<name>/.
233
230
  const CITE = /`?([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z]{1,5}):(\d+)(?:-(\d+))?/g;
234
231
  function citedLines(root, unit) {
235
- const files = unit.kind === 'design'
236
- ? markdownFiles(root, unit.unit).concat([unit.where])
237
- : [unit.where];
232
+ const files = [unit.where];
238
233
  const out = [];
239
234
  for (const file of files) {
240
235
  const text = readText(root, file) ?? '';
@@ -542,9 +542,30 @@ export function classify({ root, base, head, rules: rulesRef = null, classes = l
542
542
  const signs = classes.classes.flatMap((entry) => (entry.signs ?? []).map((sign) => ({ ...sign, class: entry.id })));
543
543
  const secrets = SECRET_PATTERNS.map((pattern) => ({ id: pattern.id, regex: compile(pattern.source) }));
544
544
 
545
+ // A pure move into the archive (item e4df0d33): a file at P is deleted, and
546
+ // archive/P is added with the same bytes and mode. `atlas design freeze`
547
+ // makes such moves when a design's item is done. The archive floor keeps
548
+ // archived records from change; a move of one unchanged file into it at its
549
+ // own path changes no record, so that floor alone does not send the add to
550
+ // the owner. The delete of P still meets every rule on P, and every other
551
+ // rule and sign still reads archive/P.
552
+ const deleted = new Map(changes.filter((one) => one.status === 'D').map((one) => [one.path, one]));
553
+ const pureMove = (change) => {
554
+ if (change.status !== 'A' || !change.path.startsWith('archive/')) return false;
555
+ const from = change.path.slice('archive/'.length);
556
+ const gone = deleted.get(from);
557
+ if (!gone || from.startsWith('archive/') || gone.oldMode !== change.newMode) return false;
558
+ const blob = (ref, path) => (ref === null ? null : git(root, ['rev-parse', '--verify', '--quiet', `${ref}:${path}`], { allowFail: true })?.toString('utf8').trim() || null);
559
+ const a = blob(base, from), b = blob(head, change.path);
560
+ if (a && b) return a === b;
561
+ const before = readAt(root, base, from), after = readAt(root, head, change.path);
562
+ return before !== null && after !== null && before === after;
563
+ };
564
+
545
565
  for (const change of changes) {
546
566
  const { path } = change;
547
567
  const hits = [];
568
+ const moved = pureMove(change);
548
569
  // A symlink or a submodule can point anywhere, so it always comes to the owner.
549
570
  if ([change.oldMode, change.newMode].includes('120000')) hits.push(['rule-book', 'a symlink changed']);
550
571
  if ([change.oldMode, change.newMode].includes('160000')) hits.push(['rule-book', 'a submodule changed']);
@@ -560,6 +581,7 @@ export function classify({ root, base, head, rules: rulesRef = null, classes = l
560
581
  const kitRelease = change.status === 'M' && signs.some((sign) => sign.version_key && matchesAny(sign.paths, path) && versionOnly(baseText, headText, sign.version_key));
561
582
  for (const rule of release ? [] : rules) {
562
583
  if (!ruleHits(rule, path)) continue;
584
+ if (moved && rule.pattern === 'archive/' && rule.cause === 'floor: archive') { hits.push([null, 'cleared']); continue; }
563
585
  if (kitRelease && rule.cause.startsWith('list: ')) continue;
564
586
  if (rule.atlas_yaml) {
565
587
  const cause = atlasYamlCause(baseText, headText, classes.atlas_yaml);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.50",
3
+ "version": "0.4.51",
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",
@@ -121,7 +121,7 @@ set. A test keeps the two lists equal.
121
121
  | `atlas code-read` | To resolve the citations of a code digest |
122
122
  | `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
123
123
  | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `changed`, `before` and `after` run the test checks of the gate: changed tests, new cases fail on old code, old cases pass on new code. `sweep` and `issues` run the daily sweep |
124
- | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
124
+ | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page. `list` shows each design and its item; `freeze` moves the design of a done item to `archive/` |
125
125
  | `atlas page` | `build <file or folder>` makes an explainer page, as talk rule 10 says. It refuses markdown with no woodcut figure that walks. Run `atlas ste` on the page, then publish it |
126
126
  | `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 |
127
127
  | `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
@@ -212,9 +212,17 @@ next action. One short paragraph.
212
212
  2. If anything was a struggle, run the `atlas:learnings` skill. It picks the
213
213
  home and the form of the fix, and records the lesson.
214
214
  3. Rebuild the design's page and the hub, so both read "Shipped". The
215
- design folder does not change: the tracker holds the delivery. A design
215
+ design's files do not change: the tracker holds the delivery. A design
216
216
  in the older format is one file, not a folder. Keep its page, and rebuild
217
217
  only the hub.
218
+ 4. The design freezes. The next daily upkeep run moves its folder to
219
+ `archive/` at its old path, with `atlas design freeze <folder>`. The guide
220
+ lists it under "Why it works this way". Do not move it yourself, unless
221
+ you open a pull request for this repo anyway. Then run
222
+ `atlas design freeze <folder>`, `git add -A`, and put the move in it.
223
+ Owner, 31 October 2026: "design is just a fleeting item of work that
224
+ gets done, delivered and forgotten. But we should be able to read those
225
+ anytime later".
218
226
 
219
227
  ## The design hub
220
228
 
@@ -27,11 +27,14 @@ Set `shared: true` when many repos use the design, such as Atlas itself.
27
27
  Then "Each kind of repo" is needed. For a design in one product it is
28
28
  optional.
29
29
 
30
- Name the code the design describes under `sources:` in `design.yaml`.
31
- It is a list of paths from the repo root. A path that ends in `/` is a
32
- folder. A design with no code yet says why under `sources_why:`. A pull
33
- request that changes a named path updates the design, or marks it "still
34
- true" (roadmap R39). `atlas upkeep check` reads both keys.
30
+ A design is a record of one decision. It is live in the design folder
31
+ while its item is open. When the item is done, `atlas design freeze <folder>`
32
+ moves it to `archive/` at its old path, and it never changes again.
33
+
34
+ The user guide and the reference pages say how the code works now. Each
35
+ one names its code under `sources:`. A pull request that changes that code
36
+ updates the page, or marks it "still true" (roadmap R39). `atlas upkeep
37
+ check` reads no `sources:` key of a design (item e4df0d33).
35
38
 
36
39
  ## All parts by kind
37
40
 
@@ -3,7 +3,8 @@ name: upkeep
3
3
  description: >
4
4
  The daily upkeep run of a repo that runs Atlas. It keeps the tracker and the docs true:
5
5
  it marks merged deliveries, keeps next steps true, closes issues that have an item, and
6
- fixes the docs index and dead doc links in one pull request that it merges itself.
6
+ freezes the designs of done items and fixes the docs index and dead doc links in one
7
+ pull request that it merges itself.
7
8
  Trigger when a scheduled task says "run the atlas:upkeep skill", or when the owner asks
8
9
  for an upkeep run now.
9
10
  ---
@@ -74,10 +75,20 @@ If the tracker cannot be reached, skip this half and say so.
74
75
  ## The docs half
75
76
 
76
77
  1. Check out a fresh copy of the default branch.
77
- 2. Run `atlas upkeep check --root .`. If it is green, the docs half is done.
78
- 3. If an open pull request from an earlier upkeep run exists, work on its
79
- branch. Otherwise make the branch `claude/upkeep-<YYYY-MM-DD>`.
80
- 4. Fix each finding:
78
+ 2. **Find the designs of done items.** Run `atlas design list`. Read the
79
+ state of the item of each live design in the tracker, for example with
80
+ `built_pending_possible` for the product. Keep each
81
+ design whose item is done. Skip a design with no item, and a design
82
+ whose item is open, paused or dropped. When the tracker cannot be
83
+ reached, keep none.
84
+ 3. Run `atlas upkeep check --root .`. If it is green and step 2 kept no
85
+ design, the docs half is done.
86
+ 4. If an open pull request from an earlier upkeep run exists, work on its
87
+ branch. Otherwise make the branch `claude/upkeep-<YYYY-MM-DD>`. Then run
88
+ `atlas design freeze <folder>` for each design that step 2 kept. It
89
+ moves the folder to `archive/` at its old path. It points each link at
90
+ the new place, and moves the index entry under "Frozen designs".
91
+ 5. Fix each finding:
81
92
  1. An unlisted file: add one line to `docs/index.md`, in the section
82
93
  that fits. Describe the file in a few plain words. For a design
83
94
  folder, list its `design.yaml` once.
@@ -87,14 +98,17 @@ If the tracker cannot be reached, skip this half and say so.
87
98
  3. A dead link: point it at the moved file. When the new target is not
88
99
  clear, leave the link and list it in the report. Change only the link
89
100
  target, never the words around it.
90
- 5. Run `atlas upkeep check` again, and `atlas check` when the repo has the
101
+ 6. Run `atlas upkeep check` again, and `atlas check` when the repo has the
91
102
  shape check. Both must not get worse.
92
- 6. Commit, push, and open or update one pull request. The title is
103
+ 7. Stage with `git add -A`, so git sees each frozen file as moved.
104
+ Commit, push, and open or update one pull request. The title is
93
105
  "Upkeep <date>: <what changed>". The body lists each change on one line.
94
106
  It ends with "no item: the daily upkeep run".
95
- 7. Wait for every check on the pull request. When all are green and the
96
- diff changes only index lines and link targets, run `atlas gate` with
97
- the verb `ready`, `--repo` and `--pr`. On READY, merge it with a merge
107
+ 8. Read the diff. It may change only index lines, link targets, page rows
108
+ and the moves of the freeze. Otherwise leave the pull request open and
109
+ say why in the report.
110
+ 9. Wait for every check on the pull request. When all are green, run
111
+ `atlas gate` with the verb `ready`, `--repo` and `--pr`. On READY, merge it with a merge
98
112
  commit at the head SHA that ready read. The merge door hook refuses any
99
113
  other merge. When the branch is behind the main branch, merge the main
100
114
  branch in, wait for green, and run `ready` again. Otherwise leave it
@@ -112,7 +126,7 @@ its own log entry. The line is plain text:
112
126
  Upkeep <date> · Atlas <version>
113
127
  Marked: <n> (<item ids and pull requests>)
114
128
  Next steps fixed: <n> · Comments: <n> · Issues closed: <n>
115
- Docs: <n> fixes, <merged #n | open #n, why | none>
129
+ Docs: <n> fixes, <n> designs frozen, <merged #n | open #n, why | none>
116
130
  Left for the owner: <red merges, sweep mismatch, old questions, unclear links, or none>
117
131
  ```
118
132
 
@@ -134,6 +148,6 @@ Output:
134
148
  Upkeep 2026-10-22 · Atlas 0.3.25
135
149
  Marked: 1 (6dbd71b0 #148)
136
150
  Next steps fixed: 0 · Comments: 0 · Issues closed: 0
137
- Docs: 1 fix, merged #161
151
+ Docs: 1 fix, 0 designs frozen, merged #161
138
152
  Left for the owner: none
139
153
  ```