@arjunkhera/atlas 0.4.21 → 0.4.22
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 +3 -2
- package/door/lib/procedure-steps.mjs +122 -0
- package/door/lib/upkeep.mjs +4 -2
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/repo-skills/SKILL.md +14 -10
package/door/cli.mjs
CHANGED
|
@@ -141,6 +141,7 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
141
141
|
|
|
142
142
|
atlas upkeep check --root <repo> the upkeep check: the docs index, dead doc links, the code each doc names, and in a pull request its tracker item
|
|
143
143
|
atlas upkeep replay --root <repo> what the doc-sources rule would have asked of the last merges; writes nothing
|
|
144
|
+
atlas upkeep procedures --root <repo> run the procedure steps marked atlas-run; --base <ref> runs only the procedures a change touches
|
|
144
145
|
--last <n> how many merges on the first-parent line; default 30
|
|
145
146
|
--event <file> the GitHub pull request event; adds the item check
|
|
146
147
|
--json print JSON
|
|
@@ -214,7 +215,7 @@ export const FLAGS = Object.freeze({
|
|
|
214
215
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
215
216
|
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' },
|
|
216
217
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
217
|
-
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value' },
|
|
218
|
+
upkeep: { root: 'optional', event: 'value', json: 'switch', last: 'value', base: 'value' },
|
|
218
219
|
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' },
|
|
219
220
|
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' },
|
|
220
221
|
judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
|
|
@@ -461,7 +462,7 @@ function jobsVerb(chosen) {
|
|
|
461
462
|
}
|
|
462
463
|
|
|
463
464
|
function upkeepVerb(chosen) {
|
|
464
|
-
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last }, rootOf(chosen), line);
|
|
465
|
+
return upkeepCommand({ what: chosen._[1], event: chosen.event, json: Boolean(chosen.json), last: chosen.last, base: chosen.base }, rootOf(chosen), line);
|
|
465
466
|
}
|
|
466
467
|
|
|
467
468
|
function gateVerb(chosen) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// CI runs the procedures (roadmap R40, item 38b2a0fe). Target design 4.6: CI
|
|
2
|
+
// runs each procedure step that a clean machine can run.
|
|
3
|
+
//
|
|
4
|
+
// A procedure marks such a step with a fenced block whose info string holds
|
|
5
|
+
// the tag `atlas-run`:
|
|
6
|
+
//
|
|
7
|
+
// ```sh atlas-run
|
|
8
|
+
// npm test
|
|
9
|
+
// ```
|
|
10
|
+
//
|
|
11
|
+
// A step that needs what a clean machine does not have names it after the
|
|
12
|
+
// tag: `atlas-run needs=secret/NPM_TOKEN` or `atlas-run needs=device/macos`.
|
|
13
|
+
// Such a step is listed as "not run here", never as passed.
|
|
14
|
+
//
|
|
15
|
+
// `atlas upkeep procedures` runs the marked steps of each procedure that
|
|
16
|
+
// atlas.yaml `procedures:` names. With --base, it runs only the procedures a
|
|
17
|
+
// pull request changes: its own folder, or a path its `sources:` names. Each
|
|
18
|
+
// block runs in bash, from the repo root, with `set -euo pipefail`. It reads
|
|
19
|
+
// no repo kind, so a repo with no deploy target uses it unchanged.
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { fenceOpens, fenceCloses } from './markdown.mjs';
|
|
24
|
+
import { docUnits, covers, changedPaths } from './doc-sources.mjs';
|
|
25
|
+
|
|
26
|
+
export const TAG = 'atlas-run';
|
|
27
|
+
const SHELLS = new Set(['sh', 'bash', 'shell']);
|
|
28
|
+
const NEEDS = new Set(['secret', 'device', 'network']);
|
|
29
|
+
// The bound on one block. A step that runs longer is a failure. It sits
|
|
30
|
+
// below the 10 minutes of the CI job, so the step fails with a reason.
|
|
31
|
+
export const STEP_TIMEOUT_MS = 8 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
// Line 1: the marked steps of one procedure text. Each step is one fenced
|
|
34
|
+
// block. `problems` holds a tag that cannot run as written.
|
|
35
|
+
export function markedSteps(text) {
|
|
36
|
+
const steps = [];
|
|
37
|
+
const problems = [];
|
|
38
|
+
const lines = String(text ?? '').split('\n');
|
|
39
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
40
|
+
const mark = fenceOpens(lines[i]);
|
|
41
|
+
if (!mark) continue;
|
|
42
|
+
const info = lines[i].trim().slice(mark.length).trim().split(/\s+/).filter(Boolean);
|
|
43
|
+
const start = i;
|
|
44
|
+
const body = [];
|
|
45
|
+
for (i += 1; i < lines.length && !fenceCloses(lines[i], mark); i += 1) body.push(lines[i]);
|
|
46
|
+
if (!info.includes(TAG)) continue;
|
|
47
|
+
const line = start + 1;
|
|
48
|
+
const lang = info[0] === TAG ? null : info[0];
|
|
49
|
+
if (!SHELLS.has(lang)) { problems.push({ line, why: `the block is tagged ${TAG} but its language is ${lang ?? 'missing'}; use sh or bash` }); continue; }
|
|
50
|
+
if (i >= lines.length) { problems.push({ line, why: 'the block never closes' }); continue; }
|
|
51
|
+
const needs = [];
|
|
52
|
+
let bad = null;
|
|
53
|
+
for (const word of info.slice(info.indexOf(TAG) + 1)) {
|
|
54
|
+
const m = /^needs=(.+)$/.exec(word);
|
|
55
|
+
if (!m) { bad = `"${word}" after ${TAG} is not needs=<kind>/<name>`; break; }
|
|
56
|
+
for (const one of m[1].split(',')) {
|
|
57
|
+
const [kind, name] = one.split('/');
|
|
58
|
+
if (!NEEDS.has(kind) || !name) { bad = `"${one}" is not one of ${[...NEEDS].map((k) => `${k}/<name>`).join(', ')}`; break; }
|
|
59
|
+
needs.push({ kind, name });
|
|
60
|
+
}
|
|
61
|
+
if (bad) break;
|
|
62
|
+
}
|
|
63
|
+
if (bad) { problems.push({ line, why: bad }); continue; }
|
|
64
|
+
const script = body.join('\n').trim();
|
|
65
|
+
if (!script) { problems.push({ line, why: 'the block is empty' }); continue; }
|
|
66
|
+
steps.push({ line, script, needs });
|
|
67
|
+
}
|
|
68
|
+
return { steps, problems };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The procedures to run. With `changed`, only those a pull request changes:
|
|
72
|
+
// a path in the procedure's folder, or a path its `sources:` names.
|
|
73
|
+
export function proceduresToRun(root, changed = null) {
|
|
74
|
+
const units = docUnits(root).filter((one) => one.kind === 'procedure');
|
|
75
|
+
if (changed === null) return units;
|
|
76
|
+
return units.filter((one) => changed.some((path) => path.startsWith(`${one.unit}/`)
|
|
77
|
+
|| (one.named?.sources ?? []).some((source) => covers(root, source, path))));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const needsText = (needs) => needs.map((one) => `${one.kind} ${one.name}`).join(', ');
|
|
81
|
+
|
|
82
|
+
// Line 2 and line 3: run each marked step of each chosen procedure. A step
|
|
83
|
+
// that needs a secret, a device or the network is "not run here".
|
|
84
|
+
export function runProcedures(root, { base = null, run = runStep } = {}) {
|
|
85
|
+
const changed = base === null ? null : changedPaths(root, `${base}...HEAD`);
|
|
86
|
+
const chosen = proceduresToRun(root, changed);
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const unit of chosen) {
|
|
89
|
+
const { steps, problems } = markedSteps(readFileSync(join(root, unit.where), 'utf8'));
|
|
90
|
+
for (const one of problems) rows.push({ procedure: unit.unit, where: `${unit.where}:${one.line}`, state: 'bad tag', why: one.why });
|
|
91
|
+
if (!steps.length && !problems.length) rows.push({ procedure: unit.unit, where: unit.where, state: 'no step', why: `it marks no step with ${TAG}` });
|
|
92
|
+
for (const step of steps) {
|
|
93
|
+
const where = `${unit.where}:${step.line}`;
|
|
94
|
+
if (step.needs.length) { rows.push({ procedure: unit.unit, where, state: 'not run here', why: `needs ${needsText(step.needs)}` }); continue; }
|
|
95
|
+
const result = run(root, step.script);
|
|
96
|
+
rows.push({ procedure: unit.unit, where, state: result.ok ? 'passed' : 'failed', why: result.why, tail: result.tail });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const red = rows.filter((row) => row.state === 'failed' || row.state === 'bad tag');
|
|
100
|
+
return { procedures: chosen.map((one) => one.unit), all: changed === null, rows, green: red.length === 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function runStep(root, script) {
|
|
104
|
+
const out = spawnSync('bash', ['-c', `set -euo pipefail\n${script}`], { cwd: root, encoding: 'utf8', timeout: STEP_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
|
|
105
|
+
const tail = `${out.stdout ?? ''}${out.stderr ?? ''}`.trimEnd().split('\n').slice(-20).join('\n');
|
|
106
|
+
if (out.error?.code === 'ETIMEDOUT') return { ok: false, why: `it ran past ${STEP_TIMEOUT_MS / 60000} minutes`, tail };
|
|
107
|
+
if (out.error) return { ok: false, why: out.error.message, tail };
|
|
108
|
+
return out.status === 0 ? { ok: true, why: 'exit 0', tail } : { ok: false, why: `exit ${out.status ?? out.signal}`, tail };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `atlas upkeep procedures`.
|
|
112
|
+
export function proceduresCommand({ base = null, json = false }, root, line) {
|
|
113
|
+
const out = runProcedures(root, { base });
|
|
114
|
+
if (json) { process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); return out.green ? 0 : 1; }
|
|
115
|
+
line(`procedures: ${out.procedures.length} to run${out.all ? '' : ` (the ones this change touches, from ${base})`}${out.procedures.length ? `: ${out.procedures.join(', ')}` : ''}`);
|
|
116
|
+
for (const row of out.rows) {
|
|
117
|
+
line(` ${row.state.padEnd(12)} ${row.where} ${row.why}`);
|
|
118
|
+
if (row.state === 'failed' && row.tail) for (const one of row.tail.split('\n')) line(` | ${one}`);
|
|
119
|
+
}
|
|
120
|
+
line(`procedure steps: ${out.green ? 'GREEN' : 'RED'}`);
|
|
121
|
+
return out.green ? 0 : 1;
|
|
122
|
+
}
|
package/door/lib/upkeep.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
|
23
23
|
import { join, dirname, posix, resolve } from 'node:path';
|
|
24
24
|
import { listChanged } from './changed-tests.mjs';
|
|
25
25
|
import { docSourceFindings, replay } from './doc-sources.mjs';
|
|
26
|
+
import { proceduresCommand } from './procedure-steps.mjs';
|
|
26
27
|
|
|
27
28
|
// R39 line 4 gates only after the replay of line 3. The replay of the last 30
|
|
28
29
|
// merges (atlas#225): rule "file" pulled in 81 docs over 24 merges, 58 not
|
|
@@ -246,9 +247,10 @@ export function upkeepCheck(root, { event = null } = {}) {
|
|
|
246
247
|
return { green: findings.length === 0, findings, notes: sources.notes, index, dead, item, text, sources };
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
export function upkeepCommand({ what, event: eventFile, json = false, last }, root, line) {
|
|
250
|
+
export function upkeepCommand({ what, event: eventFile, json = false, last, base }, root, line) {
|
|
250
251
|
if (what === 'replay') return replayCommand({ last, json }, root, line);
|
|
251
|
-
if (what
|
|
252
|
+
if (what === 'procedures') return proceduresCommand({ base: base ?? null, json }, root, line);
|
|
253
|
+
if (what !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, atlas upkeep replay or atlas upkeep procedures.`);
|
|
252
254
|
let event = null;
|
|
253
255
|
if (eventFile !== undefined) {
|
|
254
256
|
try { event = JSON.parse(readFileSync(resolve(eventFile), 'utf8')); } catch (error) { throw new Error(`cannot read the event file ${eventFile}: ${error.message}`); }
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -110,7 +110,7 @@ set. A test keeps the two lists equal.
|
|
|
110
110
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
111
111
|
| `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 |
|
|
112
112
|
| `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` |
|
|
113
|
-
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates |
|
|
113
|
+
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names, and the tracker item of a pull request. 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 |
|
|
114
114
|
| `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 |
|
|
115
115
|
| `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 |
|
|
116
116
|
| `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 |
|
|
@@ -27,19 +27,24 @@ the form the repo already uses for that kind of thing.
|
|
|
27
27
|
deploy. A rule says how to judge a change. They have different approval
|
|
28
28
|
rules, so split a skill that holds both.
|
|
29
29
|
4. **Write the header Atlas reads,** for a skill file. It names the type,
|
|
30
|
-
the commands and files, and the code it covers
|
|
31
|
-
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
the commands and files, and the code it covers in `sources:`. The
|
|
31
|
+
Atlas check finds the skill when `procedures` in `atlas.yaml` names
|
|
32
|
+
it. `atlas upkeep check` reads `sources:`.
|
|
33
|
+
5. **Tag each step a clean machine can run.** Put its commands in a
|
|
34
|
+
fenced block. Open it with ```` ```sh atlas-run ````. CI runs each
|
|
35
|
+
such block when a pull request changes the skill or its sources. A
|
|
36
|
+
step that needs a secret, a device or the network gets
|
|
37
|
+
`needs=secret/<name>`, `needs=device/<name>` or `needs=network/<name>`
|
|
38
|
+
after the tag. CI lists it as "not run here".
|
|
39
|
+
6. **Keep it to this repo.** A step that holds how to work in general
|
|
35
40
|
belongs in Atlas. Do not put it here. Propose it to Atlas with the
|
|
36
41
|
`atlas:feedback` skill.
|
|
37
|
-
|
|
42
|
+
7. **Follow the repo's own rules.** Read its entry file first. Some repos
|
|
38
43
|
ask for one area folder for each change, or a status file updated
|
|
39
44
|
when the state changes. Keep to them.
|
|
40
|
-
|
|
45
|
+
8. **Prove the steps once.** Ask the `atlas:verifier` crew to run them in
|
|
41
46
|
a fresh worktree, before you ask for the merge.
|
|
42
|
-
|
|
47
|
+
9. **Write the pull request text.** Say what you found, what you kept,
|
|
43
48
|
and the gap you closed.
|
|
44
49
|
|
|
45
50
|
## The header of a skill file
|
|
@@ -51,9 +56,8 @@ description: <when to use it, in one or two sentences>
|
|
|
51
56
|
type: task # or rule
|
|
52
57
|
names: # each command and file the steps name
|
|
53
58
|
- <command or path>
|
|
54
|
-
|
|
59
|
+
sources: # the code this skill describes
|
|
55
60
|
- <path>
|
|
56
|
-
ci: <how CI runs it, or "cannot run in CI">
|
|
57
61
|
---
|
|
58
62
|
```
|
|
59
63
|
|