@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
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
@@ -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
+ }
@@ -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 !== 'check') throw new Error(`there is no upkeep verb "${what ?? ''}". Use atlas upkeep check, or atlas upkeep replay.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
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",
@@ -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. It says how CI runs
31
- it, if CI can. Today the Atlas check reads only `name` and
32
- `description`, and finds the skill when `procedures` in `atlas.yaml`
33
- names it. Later upkeep checks read the other keys, so keep each true.
34
- 5. **Keep it to this repo.** A step that holds how to work in general
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
- 6. **Follow the repo's own rules.** Read its entry file first. Some repos
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
- 7. **Prove the steps once.** Ask the `atlas:verifier` crew to run them in
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
- 8. **Write the pull request text.** Say what you found, what you kept,
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
- covers: # the code this skill describes
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