@arjunkhera/atlas 0.4.50 → 0.4.52

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.52",
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
@@ -167,6 +173,7 @@ const HELP = `atlas — the door into a repo's Atlas files
167
173
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
168
174
  atlas gate audit --repo <owner/name> --commit <sha> [--forced] [--findings <file>] one commit on the main branch: red when no pull request merged it, one merged it before its checks were green, or a gate run is not from the base workflow
169
175
  atlas gate audit --repo <owner/name> --errors [--findings <file>] red when the gate errored on 3 pull requests in a row
176
+ atlas gate hold --repo <owner/name> --root <clone> --commit <sha> --from <sha> the release job only, from the copy on npm: exit 3 holds the release while a merge since --from went to the owner with no act of his
170
177
  atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
171
178
  --pr <n> --base-ref <branch> with --repo: read the comments of the pull request, and let the owner's signed act for this base and head clear owner
172
179
  --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
@@ -224,14 +231,14 @@ export const FLAGS = Object.freeze({
224
231
  status: { root: 'optional', all: 'switch', json: 'switch' },
225
232
  check: { root: 'optional', repo: 'value', 'fail-on': 'value', json: 'switch' },
226
233
  ste: { share: 'switch', terms: 'value' },
227
- design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
234
+ design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional', date: 'value', 'dry-run': 'switch', json: 'switch' },
228
235
  page: { out: 'value', title: 'value' },
229
236
  view: { product: 'value', name: 'value', out: 'value' },
230
237
  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' },
231
238
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
232
239
  runner: { repo: 'value', issue: 'value', holder: 'value', from: 'value', json: 'switch' },
233
240
  upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
234
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value', errors: 'switch', forced: 'switch', findings: 'value' },
241
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', from: 'value', base: 'value', 'base-ref': 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value', days: 'value', branch: 'value', errors: 'switch', forced: 'switch', findings: 'value' },
235
242
  approve: { repo: 'value', root: 'optional', key: 'value', provider: 'value', yes: 'switch', out: 'value', file: 'value', signers: 'value', commit: 'value', item: 'value', card: 'value', words: 'value', pr: 'value', head: 'value', decision: 'value', check: 'value' },
236
243
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
237
244
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
@@ -372,13 +379,40 @@ function designCommand(chosen) {
372
379
  const what = chosen._[1];
373
380
  const folders = chosen._.slice(2);
374
381
  if (what === 'folder') {
375
- if (folders.length || chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft) throw new Error('atlas design folder takes only --root.');
382
+ 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
383
  const where = designFolderOf(chosen.root === undefined || chosen.root === true ? repoRootFrom(process.cwd()) : resolve(chosen.root));
377
384
  line(where.folder);
378
385
  return 0;
379
386
  }
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');
387
+ if (what === 'list' || what === 'freeze') {
388
+ if (chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft) throw new Error(`--tracker, --out and --draft work only with atlas design build.`);
389
+ const root = chosen.root === undefined || chosen.root === true ? repoRootFrom(process.cwd()) : resolve(chosen.root);
390
+ if (what === 'list') {
391
+ if (folders.length || chosen.date !== undefined || chosen['dry-run']) throw new Error('atlas design list takes only --root and --json.');
392
+ const rows = listDesigns(root);
393
+ if (chosen.json) { line(JSON.stringify(rows, null, 2)); return 0; }
394
+ for (const row of rows) line(`${row.state.padEnd(7)} ${(row.item ?? 'no item').slice(0, 8).padEnd(8)} ${row.folder}`);
395
+ line('');
396
+ 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>.`);
397
+ return 0;
398
+ }
399
+ if (chosen.json) throw new Error('--json works only with atlas design list.');
400
+ if (!folders.length) throw new Error('give a design folder: atlas design freeze <folder>');
401
+ 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`);
402
+ for (const folder of folders) {
403
+ // A folder that cannot freeze is a finding about the input, not a
404
+ // wrong command: say why and stop with exit 1.
405
+ let report;
406
+ try { report = freezeDesign(root, folder, { date: chosen.date, dryRun: Boolean(chosen['dry-run']) }); } catch (error) { line(`Not frozen: ${error.message}`); return 1; }
407
+ const links = report.links.reduce((n, one) => n + one.count, 0);
408
+ 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.`);
409
+ }
410
+ if (!chosen['dry-run']) line('Stage the move with git add -A, so git sees each file as moved. Then run atlas upkeep check.');
411
+ return 0;
412
+ }
413
+ if (chosen.root !== undefined) throw new Error('--root works only with atlas design folder, list and freeze.');
414
+ 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.');
415
+ 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
416
  if (!folders.length) throw new Error(`give a design folder: atlas design ${what} <folder>`);
383
417
  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
418
  if (what === 'build') {
@@ -471,7 +505,7 @@ function upkeepVerb(chosen) {
471
505
  }
472
506
 
473
507
  function gateVerb(chosen) {
474
- return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, baseRef: chosen['base-ref'], head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch, errors: Boolean(chosen.errors), forced: Boolean(chosen.forced), findings: chosen.findings }, line);
508
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, from: chosen.from, base: chosen.base, baseRef: chosen['base-ref'], head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output, days: chosen.days, branch: chosen.branch, errors: Boolean(chosen.errors), forced: Boolean(chosen.forced), findings: chosen.findings }, line);
475
509
  }
476
510
 
477
511
  function approveVerb(chosen) {
@@ -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) ?? '';
package/door/lib/gate.mjs CHANGED
@@ -22,7 +22,7 @@ import { childEnvironment } from '../../work/lib/child-env.mjs';
22
22
  import { parseYaml } from '../../shape/check.mjs';
23
23
  import { checkCommand, replayCommand, judge, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
24
24
  import { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit.mjs';
25
- import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
25
+ import { MARKER as SIGNED_MARKER, SIGNERS_FILE, AUDIT_CHECK, parseSigners, verifyTexts, findAct, auditCodeDiff } from './signed-act.mjs';
26
26
  import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
27
27
 
28
28
  export const BRANCH_RULESET = 'atlas gate';
@@ -708,6 +708,93 @@ export function prComments(gh, repo, pr) {
708
708
  return kept;
709
709
  }
710
710
 
711
+ // `atlas gate hold --root <clone> --commit <sha> --from <sha>` (item 4af66381,
712
+ // the release hold). The release job runs it from the trusted copy, the
713
+ // version that npm holds as latest, never from the commit that it judges.
714
+ // `--from` is the commit of that version, from its release record, as
715
+ // `atlas upgrade` finds it. The hold judges
716
+ // each first-parent merge from there to the release commit again, with the
717
+ // rules and the brake of its parent, at the time of its merge. Only a merge
718
+ // that the gate judges success passes: an owner or a red answer holds the
719
+ // release (security review of #290, S1), unless the owner signed one of
720
+ // three acts on the pull request:
721
+ // 1. a merge act for its head (the gate counts it already, so the answer
722
+ // is success);
723
+ // 2. an accept act for its head, from `atlas approve audit`, for a merge
724
+ // that changed no audit code;
725
+ // 3. an accept-code act for its merge commit and its audit-code diff, from
726
+ // `atlas approve audit-code`.
727
+ // It fails closed: a push past the gate, a merge that it cannot judge, a
728
+ // start that the release commit does not hold, or more than HOLD_MAX merges
729
+ // also hold. Exit 0: publish. Exit 3: hold. Any other exit is an error.
730
+ export const HOLD_MAX = 200;
731
+ // git's word for the ancestor test, joined so the package names no ancestry verb (test/package D9).
732
+ const MERGE_BASE = ['merge', 'base'].join('-');
733
+ export const HOLD_EXIT = 3;
734
+ export function holdCommand({ repo, root, commit, from }, line, gh) {
735
+ const full = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value);
736
+ if (!full(commit)) throw new GateError('atlas gate hold needs --commit <full sha>.');
737
+ if (!full(from)) throw new GateError('atlas gate hold needs --from <full sha>: the commit of the version that npm holds as latest.');
738
+ if (!root || root === true) throw new GateError('atlas gate hold needs --root <clone>.');
739
+ const git = (args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 }).trim();
740
+ try { git(['cat-file', '-e', `${commit}^{commit}`]); } catch { throw new GateError(`${commit} is not a commit in ${root}.`); }
741
+ try { git([MERGE_BASE, '--is-ancestor', '--end-of-options', from, commit]); } catch {
742
+ line(`HOLD: the published commit ${from.slice(0, 7)} is not in the history of ${commit.slice(0, 7)}, so the hold cannot tell which merges npm does not hold yet.`);
743
+ return HOLD_EXIT;
744
+ }
745
+ const commits = git(['rev-list', '--first-parent', '--reverse', `--max-count=${HOLD_MAX + 1}`, `${from}..${commit}`]).split('\n').filter(Boolean);
746
+ if (commits.length > HOLD_MAX) { line(`HOLD: more than ${HOLD_MAX} merges since the published commit ${from.slice(0, 7)}.`); return HOLD_EXIT; }
747
+ const held = [];
748
+ for (const sha of commits) {
749
+ const why = holdRow({ repo, root, sha }, gh, git);
750
+ if (why) { held.push(why); line(` ✗ ${why}`); }
751
+ }
752
+ if (held.length) {
753
+ line(`HOLD: ${held.length} of ${commits.length} merge(s) since the published commit ${from.slice(0, 7)} went to the owner, and no act of his clears them. The owner signs them with atlas approve audit (atlas approve audit-code <pr> for a merge that changed the audit code). The next release run after his touch publishes.`);
754
+ return HOLD_EXIT;
755
+ }
756
+ line(`atlas gate hold: ${commits.length} merge(s) since the published commit ${from.slice(0, 7)}, each green or signed by the owner. Publish.`);
757
+ return 0;
758
+ }
759
+
760
+ // Why one first-parent commit holds the release, or null.
761
+ export function holdRow({ repo, root, sha }, gh, git) {
762
+ let pull;
763
+ try { pull = (gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each.merged_at && each.merge_commit_sha === sha); } catch { return `${sha.slice(0, 7)}: GitHub did not say which pull request merged it`; }
764
+ if (!pull) return `${sha.slice(0, 7)} reached the branch with no pull request: a push past the gate`;
765
+ const head = String(pull.head?.sha ?? '');
766
+ let parent;
767
+ try { parent = git(['rev-parse', '--verify', '--end-of-options', `${sha}^1`]); } catch { return `#${pull.number}: the parent of ${sha.slice(0, 7)} is not in this clone`; }
768
+ try { git(['cat-file', '-e', `${head}^{commit}`]); } catch { return `#${pull.number}: its head ${head.slice(0, 7)} is not in this clone`; }
769
+ let texts;
770
+ try { texts = prComments(gh, repo, pull.number); } catch { return `#${pull.number}: GitHub did not give its comments, so no act of the owner can be read`; }
771
+ let answer;
772
+ try {
773
+ const at = new Date(pull.merged_at);
774
+ const brake = { ci: brakeRead({ root, base: parent, now: at }, gh, repo), clears: { repo, commentsOf: (one) => commitComments(gh, repo, one) } };
775
+ // The red main checks of its parent, as the gate read them, so a fix of a red main check is judged as the gate judged it.
776
+ answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: at, brake, redMain: redMain({ root, base: parent }, gh, repo), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
777
+ } catch (error) { return `#${pull.number}: it cannot be judged (${String(error?.message ?? error).split('\n')[0].slice(0, 160)})`; }
778
+ if (answer.result === 'success') return null;
779
+ // The gate did not say success. An accept act, or an accept-code act for a
780
+ // merge that changed audit code, checked with the keys of its parent,
781
+ // clears it now. The audit-code test is the one that `atlas approve audit`
782
+ // keeps, so the hold does not rest on the Mac tool alone.
783
+ let keys = [];
784
+ try { keys = parseSigners(git(['show', `${parent}:${SIGNERS_FILE}`])).keys; } catch { keys = []; }
785
+ const verified = verifyTexts(texts, keys);
786
+ let code;
787
+ try { code = auditCodeDiff(gh('GET', `repos/${repo}/commits/${sha}`)?.files); } catch { code = { unknown: true }; }
788
+ const accepted = code.unknown ? null
789
+ : code.files.length ? findAct(verified, { kind: 'accept-code', repo, pr: String(pull.number), head, merge: sha, diff: code.print })
790
+ : findAct(verified, { kind: 'accept', repo, pr: String(pull.number), head, check: AUDIT_CHECK });
791
+ if (accepted) return null;
792
+ const row = (answer.rows ?? []).find((each) => each.kind === answer.result) ?? (answer.rows ?? []).find((each) => each.kind === 'owner');
793
+ const what = answer.result === 'red' ? 'the gate said red' : 'went to the owner';
794
+ if (code.unknown) return `#${pull.number} (${sha.slice(0, 7)}) ${what}, and GitHub gave no full file list of its merge, so no accept act can be checked`;
795
+ return `#${pull.number} (${sha.slice(0, 7)}) ${what}${row ? `: ${row.class ?? answer.result}${row.path ? ` ${row.path}` : ''}` : ''}, and no ${code.files.length ? 'merge or accept-code' : 'merge or accept'} act names it`;
796
+ }
797
+
711
798
  // The comment texts of one commit on main that carry the signed-act marker:
712
799
  // where the owner posts a brake clear (roadmap R13 line 6). At most three
713
800
  // pages of a hundred. A read that fails gives null, so no clear counts.
@@ -763,9 +850,9 @@ export function brakeRead({ root, base, now = new Date() }, gh, repo, pages = 3)
763
850
  return ciTrips({ commits, runs, homes, checks, live: facts.live, liveChecks }, jobsOf);
764
851
  }
765
852
 
766
- // `atlas gate apply|ready|audit|check|replay`. Returns the exit code.
767
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, baseRef, head, body, json = false, summary, output, days, branch, errors = false, forced = false, findings, home = homedir() }, line, gh = ghApi) {
768
- if (!['apply', 'ready', 'audit', 'check', 'replay'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check or replay.`);
853
+ // `atlas gate apply|ready|audit|check|replay|hold`. Returns the exit code.
854
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, from, base, baseRef, head, body, json = false, summary, output, days, branch, errors = false, forced = false, findings, home = homedir() }, line, gh = ghApi) {
855
+ if (!['apply', 'ready', 'audit', 'check', 'replay', 'hold'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit, check, replay or hold.`);
769
856
  // With --repo, check reads the red main checks from GitHub; without, it judges git data alone.
770
857
  const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
771
858
  if (what === 'check') {
@@ -787,6 +874,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
787
874
  }
788
875
  return code;
789
876
  }
877
+ if (what === 'hold') return holdCommand({ repo, root, commit, from }, line, gh);
790
878
  if (what === 'audit') return auditCommand({ repo, root, since, commit, errors, forced, findings }, line, gh);
791
879
  if (off && on) throw new GateError('pick --off or --on, not both.');
792
880
  const mode = off ? 'off' : on ? 'on' : 'apply';
@@ -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);
@@ -113,13 +113,14 @@ export const AUDIT_CHECKS = Object.freeze(['gate · push audit', AUDIT_CHECK]);
113
113
  // The files whose change lets a merge write its own audit: every repo file
114
114
  // of the gate and audit groups of GATE_FILE_GROUPS in gate-audit.mjs (a test
115
115
  // keeps this list a superset), the signed act that the gate reads, and the
116
- // job file of the audit. `atlas approve audit` never signs a merge that
116
+ // job file of the audit, and the release job, which runs the release hold
117
+ // (item 4af66381). `atlas approve audit` never signs a merge that
117
118
  // changed one (security review 2 of item 0ca9fced, S3). Such a merge takes
118
119
  // an accept-code act instead, and its words quote the test AUDIT_CODE_TEST
119
120
  // (item f36c6b0f).
120
121
  export const AUDIT_WORKFLOW = '.github/workflows/atlas-gate-audit.yml';
121
122
  export const AUDIT_CODE = Object.freeze([
122
- AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml',
123
+ AUDIT_WORKFLOW, '.github/workflows/atlas-gate.yml', '.github/workflows/atlas-jobs.yml', '.github/workflows/release.yml',
123
124
  'packages/atlas/bin/atlas', 'packages/atlas/door/cli.mjs', 'packages/atlas/owner-only.yaml', 'packages/atlas/door/jobs/gate-audit.yaml',
124
125
  ...['gate.mjs', 'gate-audit.mjs', 'gate-check.mjs', 'owner-only.mjs', 'jobs.mjs', 'issues.mjs', 'signed-act.mjs'].map((name) => `packages/atlas/door/lib/${name}`),
125
126
  'packages/atlas/shape/check.mjs', 'packages/atlas/work/lib/child-env.mjs',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.50",
3
+ "version": "0.4.52",
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,13 +121,13 @@ 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` |
128
128
  | `atlas runner` | Only in a runner run, as the `atlas:runner` skill says: `start` first, `claim` before each build, `line` last. A lead session never runs the runner |
129
129
  | `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names and stale pages. In a pull request it also reads the tracker item, the changed designs, and STE on the changed lines. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
130
- | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
130
+ | `atlas gate` | `apply` once per repo, by the owner on his Mac, after the repo's `required_checks` exist. `--dry-run` first. `--off` and `--on` are the break glass. On a plan with no rulesets it reports advisory mode. `ready` before each merge, `check` in the `atlas-gate` workflow, `replay` before a change to the owner-only list, `hold` only in the release job. `audit` in the daily upkeep run, and with `--commit` in the push job of a repo that has one |
131
131
  | `atlas approve` | Never from a session. Only the owner runs it, on his Mac, with his key's touch. When the gate says "owner" for a pull request, ask him once for the signed act: name each pull request, its class and its cause. Put the owner pull requests of one day in one ask, so one touch signs them. `act`, `verify` and `key-check` work the same way. Each morning with open audit issues, ask him for `audit`, and `audit-code <pr>` per audit-code merge; record its words as one decision, then close each issue |
132
132
  | `atlas judge` | In the fresh-agent test only, as the `atlas:fresh-agent-test` skill says. `seal` after the pick, `brief` to read a sealed ask, `run` for each sealed task, `check` after the grade |
133
133
 
@@ -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
  ```