@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +37 -4
- package/door/lib/changed-docs.mjs +22 -5
- package/door/lib/design-freeze.mjs +251 -0
- package/door/lib/doc-sources.mjs +11 -16
- package/door/lib/owner-only.mjs +22 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/sdlc-task/SKILL.md +9 -1
- package/skills/sdlc-task/design/README.md +8 -5
- package/skills/upkeep/SKILL.md +26 -12
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 (
|
|
381
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
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
|
+
}
|
package/door/lib/doc-sources.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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/owner-only.mjs
CHANGED
|
@@ -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
package/skills/lead/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
package/skills/upkeep/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
```
|