@arjunkhera/atlas 0.4.37 → 0.4.39

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.37",
3
+ "version": "0.4.39",
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/.mcp.json CHANGED
@@ -8,7 +8,6 @@
8
8
  "env": {
9
9
  "ATLAS_ENGRAM": "",
10
10
  "ATLAS_ENGRAM_CLOUD": "${ATLAS_ENGRAM:-}",
11
- "ATLAS_WORK_OWNER_PRESENT": "1",
12
11
  "ATLAS_WORK_JOURNAL": "",
13
12
  "ATLAS_WORK_SESSION": "",
14
13
  "ATLAS_WORK_AGENT": "",
@@ -0,0 +1,188 @@
1
+ # The starting list of the kinds of change, for each kind of repo (roadmap R51,
2
+ # design docs/design-docs/kinds-of-change/ in the Atlas repo). Onboarding
3
+ # (roadmap R63) copies one list into the `kinds_of_change` key of a new repo's
4
+ # atlas.yaml, and the repo changes it to fit its paths.
5
+ #
6
+ # No kind here has a `tried:` date. Locked rule 2: nothing runs alone until its
7
+ # undo was tried once. So the gate sends each kind to the owner until the repo
8
+ # tries its undo and writes the date and the proof.
9
+ #
10
+ # The first kind whose paths hold a path is its kind, so the specific kinds
11
+ # come first. Paths match as in owner-only.yaml: a trailing `/` is a folder,
12
+ # `*` is one name, `**/` is any depth.
13
+ starting:
14
+ service:
15
+ - kind: Agent instructions and repo facts
16
+ paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
17
+ impact: how every agent session works in this repo
18
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
19
+ needs: git and gh only
20
+ - kind: CI
21
+ paths: [.github/]
22
+ impact: what runs on each pull request and on the main branch
23
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
24
+ needs: git and gh only
25
+ - kind: Tests
26
+ paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
27
+ impact: what the checks prove
28
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
29
+ needs: git and gh only
30
+ - kind: Schema migrations
31
+ paths: [migrations/, db/migrations/, "**/*.sql"]
32
+ impact: the data of every tenant, after the deploy runs it
33
+ undo: a new migration that takes the change back, merged and deployed; a dropped column or table cannot be undone
34
+ needs: git, gh and the deploy procedure
35
+ - kind: Deploy
36
+ paths: [deploy/, infra/, Dockerfile, "docker-compose*.yml", "*.tf"]
37
+ impact: where and how the service runs
38
+ undo: git revert -m 1 <merge commit>, merge on green, then run the deploy procedure
39
+ needs: git, gh and the deploy procedure
40
+ - kind: Service code
41
+ paths: [src/, app/, server/, lib/, scripts/]
42
+ impact: every request that the deployed service answers
43
+ undo: git revert -m 1 <merge commit>, merge on green, then run the deploy procedure
44
+ needs: git, gh and the deploy procedure
45
+ - kind: Dependencies
46
+ paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
47
+ impact: the code that builds and runs with this repo
48
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
49
+ needs: git and gh only
50
+ - kind: Docs
51
+ paths: [docs/, "*.md", "**/README.md"]
52
+ impact: what readers and agents read; no code runs it
53
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
54
+ needs: git and gh only
55
+ library:
56
+ - kind: Agent instructions and repo facts
57
+ paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
58
+ impact: how every agent session works in this repo
59
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
60
+ needs: git and gh only
61
+ - kind: CI
62
+ paths: [.github/]
63
+ impact: what runs on each pull request and on the main branch
64
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
65
+ needs: git and gh only
66
+ - kind: Tests
67
+ paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
68
+ impact: what the checks prove
69
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
70
+ needs: git and gh only
71
+ - kind: Library code
72
+ paths: [src/, lib/, packages/, bin/]
73
+ impact: every repo that takes the next release
74
+ undo: git revert -m 1 <merge commit>, merge on green; the release ships the old code under the next number
75
+ needs: git and gh only
76
+ - kind: Dependencies
77
+ paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
78
+ impact: the code that builds and runs with this repo
79
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
80
+ needs: git and gh only
81
+ - kind: Docs
82
+ paths: [docs/, "*.md", "**/README.md"]
83
+ impact: what readers and agents read; no code runs it
84
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
85
+ needs: git and gh only
86
+ schema:
87
+ - kind: Agent instructions and repo facts
88
+ paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
89
+ impact: how every agent session works in this repo
90
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
91
+ needs: git and gh only
92
+ - kind: CI
93
+ paths: [.github/]
94
+ impact: what runs on each pull request and on the main branch
95
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
96
+ needs: git and gh only
97
+ - kind: Tests
98
+ paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
99
+ impact: what the checks prove
100
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
101
+ needs: git and gh only
102
+ - kind: Schemas
103
+ paths: [schemas/, schema/, "**/*.schema.json", "**/*.proto", "**/*.graphql"]
104
+ impact: every reader and writer of the data these schemas name
105
+ undo: git revert -m 1 <merge commit>, merge on green, release; a removed field cannot be undone for data written meanwhile
106
+ needs: git and gh only
107
+ - kind: Schema code
108
+ paths: [src/, lib/, scripts/]
109
+ impact: how the schemas are checked and built
110
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
111
+ needs: git and gh only
112
+ - kind: Dependencies
113
+ paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
114
+ impact: the code that builds and runs with this repo
115
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
116
+ needs: git and gh only
117
+ - kind: Docs
118
+ paths: [docs/, "*.md", "**/README.md"]
119
+ impact: what readers and agents read; no code runs it
120
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
121
+ needs: git and gh only
122
+ cli:
123
+ - kind: Agent instructions and repo facts
124
+ paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
125
+ impact: how every agent session works in this repo
126
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
127
+ needs: git and gh only
128
+ - kind: CI
129
+ paths: [.github/]
130
+ impact: what runs on each pull request and on the main branch
131
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
132
+ needs: git and gh only
133
+ - kind: Tests
134
+ paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
135
+ impact: what the checks prove
136
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
137
+ needs: git and gh only
138
+ - kind: Command code
139
+ paths: [src/, cmd/, bin/, lib/, internal/]
140
+ impact: every person and job that installs the next release
141
+ undo: git revert -m 1 <merge commit>, merge on green; the release ships the old code under the next number
142
+ needs: git and gh only
143
+ - kind: Dependencies
144
+ paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
145
+ impact: the code that builds and runs with this repo
146
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
147
+ needs: git and gh only
148
+ - kind: Docs
149
+ paths: [docs/, "*.md", "**/README.md"]
150
+ impact: what readers and agents read; no code runs it
151
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
152
+ needs: git and gh only
153
+ plugin:
154
+ - kind: Agent instructions and repo facts
155
+ paths: [atlas.yaml, atlas/, .claude/, CLAUDE.md, AGENTS.md, "**/CLAUDE.md", "**/AGENTS.md"]
156
+ impact: how every agent session works in this repo
157
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
158
+ needs: git and gh only
159
+ - kind: CI
160
+ paths: [.github/]
161
+ impact: what runs on each pull request and on the main branch
162
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
163
+ needs: git and gh only
164
+ - kind: Tests
165
+ paths: [test/, tests/, "**/__tests__/", "**/*.test.*", "**/*.spec.*"]
166
+ impact: what the checks prove
167
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
168
+ needs: git and gh only
169
+ - kind: Plugin
170
+ paths: [.claude-plugin/, skills/, agents/, hooks/, commands/, .mcp.json, "plugins/"]
171
+ impact: every session that loads the next release of the plugin
172
+ undo: git revert -m 1 <merge commit>, merge on green; the release ships the old plugin under the next number
173
+ needs: git and gh only
174
+ - kind: Plugin code
175
+ paths: [src/, lib/, bin/, scripts/]
176
+ impact: the tools and scripts the plugin runs
177
+ undo: git revert -m 1 <merge commit>, merge on green; the release ships the old code under the next number
178
+ needs: git and gh only
179
+ - kind: Dependencies
180
+ paths: [package.json, package-lock.json, pnpm-lock.yaml, pnpm-workspace.yaml, yarn.lock, "*.lock", go.mod, go.sum, "requirements*.txt", pyproject.toml, Cargo.toml]
181
+ impact: the code that builds and runs with this repo
182
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
183
+ needs: git and gh only
184
+ - kind: Docs
185
+ paths: [docs/, "*.md", "**/README.md"]
186
+ impact: what readers and agents read; no code runs it
187
+ undo: git revert -m 1 <merge commit> on a new branch, open a pull request, merge on green
188
+ needs: git and gh only
@@ -7,14 +7,21 @@
7
7
  // windowCommits(root, base, since) the first-parent commits of main in the window
8
8
  // revertTrips(root, commits) line 1: a revert of a change that main held
9
9
  // ciTrips({ commits, runs, homes, ... }, jobsOf) lines 2 and 3: a red main that a rerun confirmed, a failed live check
10
- // brakeState({ root, base, now, ci }) the trips, and the date the brake ends
10
+ // brakeState({ root, base, now, ci }) the trips with their kinds, and the date the brake ends
11
11
  // undoOf(root, mergeBase, head, trips) line 4: the trip that this head undoes exactly, or null
12
+ // clearOf(root, base, trip, clears) line 6: the owner's signed clear of one trip, or null
12
13
  //
13
14
  // The brake works per repo (line 5): it reads only the history and the runs
14
- // of the repo that the gate judges. One signed act of the owner ends it early
15
- // (line 6, roadmap R72); that act does not exist yet.
15
+ // of the repo that the gate judges. Where the repo lists its kinds of change,
16
+ // each trip holds only the kinds of its culprit (roadmap R51, kinds.mjs).
17
+ // Line 6: one signed act of the owner ends a trip early (roadmap R72). The act
18
+ // is a `brake-clear` for the repo and the commit that set the trip. It sits in
19
+ // a comment on that commit, and the key comes from atlas/owner-signers on the
20
+ // base. So the clear is no change to atlas.yaml, and never needs the gate.
16
21
  import { spawnSync } from 'node:child_process';
17
22
  import { parseYaml } from '../../shape/check.mjs';
23
+ import { kindsList, kindsOfCommit } from './kinds.mjs';
24
+ import { parseSigners, verifyTexts, findAct, SIGNERS_FILE } from './signed-act.mjs';
18
25
 
19
26
  export const BRAKE_DAYS = 7;
20
27
  // A job that ends like this failed. A cancelled or skipped job did not.
@@ -142,19 +149,47 @@ export function ciTrips({ commits, runs, homes, checks, live = '', liveChecks =
142
149
  return trips;
143
150
  }
144
151
 
152
+ // Line 6: the owner's signed clear of one trip. `clears` is { repo,
153
+ // commentsOf(sha) }: commentsOf gives the comment texts of one commit, or
154
+ // null when GitHub cannot say. It fails closed: no key on the base, a comment
155
+ // that does not read, or an act for another repo or commit clears nothing.
156
+ export function clearOf(root, base, trip, clears) {
157
+ if (!clears?.repo || typeof clears.commentsOf !== 'function') return null;
158
+ const { keys } = parseSigners(git(root, ['show', `${base}:${SIGNERS_FILE}`], { allowFail: true }) ?? '');
159
+ if (!keys.length) return null;
160
+ let texts;
161
+ try { texts = clears.commentsOf(trip.sha); } catch { return null; }
162
+ if (!Array.isArray(texts) || !texts.length) return null;
163
+ const found = findAct(verifyTexts(texts, keys), { kind: 'brake-clear', repo: clears.repo, commit: trip.sha });
164
+ return found ? { sha: trip.sha, key: found.key, signed_at: found.signed_at } : null;
165
+ }
166
+
145
167
  // The brake of the repo at the base commit. `ci` holds the trips of lines 2
146
168
  // and 3, which the gate reads from GitHub; with no GitHub read it is empty.
147
- export function brakeState({ root, base, now = new Date(), ci = [] }) {
169
+ // `clears` reads the owner's signed clears (line 6); a cleared trip holds
170
+ // nothing, and the answer lists it under `cleared`.
171
+ export function brakeState({ root, base, now = new Date(), ci = [], clears = null }) {
148
172
  const sha = fullSha(root, base);
149
173
  if (!sha) throw new BrakeError(`the base "${base}" is not a commit in this repo.`);
150
- const facts = brakeFacts(git(root, ['show', `${sha}:atlas.yaml`], { allowFail: true }));
174
+ const text = git(root, ['show', `${sha}:atlas.yaml`], { allowFail: true });
175
+ const facts = brakeFacts(text);
176
+ const list = kindsList(text);
151
177
  const since = new Date(now.getTime() - facts.days * 864e5);
152
178
  const commits = windowCommits(root, sha, since);
153
179
  // A time after now, from an odd commit date, counts as now.
154
180
  const stamp = now.toISOString();
155
- const trips = [...revertTrips(root, commits), ...ci].map((trip) => (trip.time > stamp ? { ...trip, time: stamp } : trip)).sort((a, b) => b.time.localeCompare(a.time));
181
+ // Each trip holds the kinds of its culprit (roadmap R51); null holds them all.
182
+ const all = [...revertTrips(root, commits), ...ci]
183
+ .map((trip) => ({ ...trip, time: trip.time > stamp ? stamp : trip.time, kinds: kindsOfCommit(root, list, trip.culprit) }))
184
+ .sort((a, b) => b.time.localeCompare(a.time));
185
+ // One clear for each commit that set a trip; a trip with no clear holds.
186
+ const cleared = [];
187
+ const bySha = new Map();
188
+ for (const trip of all) if (!bySha.has(trip.sha)) bySha.set(trip.sha, clearOf(root, sha, trip, clears));
189
+ for (const found of bySha.values()) if (found) cleared.push(found);
190
+ const trips = all.filter((trip) => !bySha.get(trip.sha));
156
191
  const until = trips.length ? new Date(Date.parse(trips[0].time) + facts.days * 864e5).toISOString() : null;
157
- return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips };
192
+ return { on: trips.length > 0, days: facts.days, since: since.toISOString(), until, trips, cleared };
158
193
  }
159
194
 
160
195
  // The patch id of a diff, or null for an empty diff. --verbatim keeps every
@@ -16,7 +16,8 @@
16
16
  // changed; a head that git cannot read
17
17
  // owner a path or a sign of the owner-only list; a head job named like a
18
18
  // check; a refactor claim (until the review job, roadmap R28); a
19
- // file too large to scan; the brake (roadmap R13, brake.mjs)
19
+ // file too large to scan; the brake (roadmap R13, brake.mjs); a
20
+ // kind of change with no tried undo (roadmap R51, kinds.mjs)
20
21
  // success no rule holds the pull request, or the owner signed this head
21
22
  //
22
23
  // An owner result turns into success when a comment of the pull request holds
@@ -41,13 +42,14 @@ import { listChanged } from './changed-tests.mjs';
41
42
  import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
42
43
  import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
43
44
  import { brakeState, undoOf, tripText } from './brake.mjs';
45
+ import { kindsAt, nameKinds, kindRows } from './kinds.mjs';
44
46
 
45
47
  export const VERSION = 1;
46
48
  export const GATE_CHECK = 'gate';
47
49
  export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
48
50
  export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
49
51
  export const RESULTS = Object.freeze(['success', 'owner', 'red']);
50
- export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed']);
52
+ export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act', 'brake', 'signed', 'kinds']);
51
53
  export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
52
54
  // A file over this size is not scanned for secrets, and comes to the owner.
53
55
  export const SCAN_LIMIT = 1024 * 1024;
@@ -275,7 +277,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
275
277
  const baseSha = commitOf(top, base);
276
278
  if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
277
279
  const headSha = commitOf(top, head);
278
- const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null };
280
+ const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null, brake: null, signed: null, kinds: null };
279
281
  if (!headSha) {
280
282
  answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
281
283
  return finish(answer);
@@ -287,12 +289,17 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
287
289
  }
288
290
  answer.merge_base = mergeBase;
289
291
  const changes = changedPaths(top, mergeBase, headSha);
292
+ // The kind of each changed path, by the list on the base (roadmap R51).
293
+ const list = kindsAt(top, baseSha);
294
+ const named = nameKinds(list, changes.map((change) => change.path));
295
+ answer.kinds = { listed: list.listed, names: named.names, unlisted: list.listed ? named.unlisted : [] };
290
296
  let undo = null;
291
297
  if (brake !== false) {
292
- const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [] });
298
+ const state = brakeState({ root: top, base: baseSha, now, ci: brake?.ci ?? [], clears: brake?.clears ?? null });
293
299
  undo = undoOf(top, mergeBase, headSha, state.trips);
294
- answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null };
295
- answer.rows.push(...brakeRows(state, undo));
300
+ answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null, cleared: state.cleared };
301
+ for (const one of state.cleared) answer.rows.push({ kind: 'note', class: 'brake', cause: `the owner's signed clear ended the trip set by ${one.sha.slice(0, 7)} (key ${one.key}, ${one.signed_at})` });
302
+ answer.rows.push(...brakeRows(state, undo, list.listed ? named : null));
296
303
  }
297
304
  // The rules come from the base commit, never from the merge base. An agent
298
305
  // picks the merge base when it picks where its branch starts, also a middle
@@ -300,6 +307,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
300
307
  // change back (item d56a9c03). The diff stays merge base to head.
301
308
  const ruleRef = rules ?? baseSha;
302
309
  for (const row of classify({ root: top, base: mergeBase, head: headSha, rules: ruleRef }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
310
+ answer.rows.push(...kindRows(list, named));
303
311
  answer.rows.push(...jobRows(top, ruleRef, headSha, changes));
304
312
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
305
313
  if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
@@ -428,13 +436,27 @@ function carryOne(root, baseSha, headSha, mergeBase, act) {
428
436
  }
429
437
 
430
438
  // Line 1: while the brake is on, every pull request of the repo comes to the
431
- // owner, except the exact undo of a trip (line 4).
432
- function brakeRows(state, undo) {
439
+ // owner, except the exact undo of a trip (line 4). Where the repo lists its
440
+ // kinds (roadmap R51), a trip holds only a pull request that shares a kind
441
+ // with it. A trip with no kinds, or a pull request with a path of no kind,
442
+ // holds as before.
443
+ const holds = (trip, named) => !named || !trip.kinds || named.unlisted.length > 0 || trip.kinds.some((kind) => named.names.includes(kind));
444
+
445
+ function brakeRows(state, undo, named = null) {
433
446
  if (!state.on) return [];
434
- const newest = state.trips[0];
435
- const more = state.trips.length > 1 ? `; ${state.trips.length} trips in ${state.days} days` : '';
447
+ const holding = state.trips.filter((trip) => holds(trip, named));
448
+ if (!undo && !holding.length) {
449
+ const kinds = [...new Set(state.trips.flatMap((trip) => trip.kinds))].join(', ');
450
+ return [{ kind: 'note', class: 'brake', cause: `the brake is on until ${state.until.slice(0, 16).replace('T', ' ')} UTC for ${kinds}; this pull request is ${named.names.join(', ') || 'no kind'}` }];
451
+ }
452
+ const newest = holding[0] ?? state.trips[0];
453
+ const more = holding.length > 1 ? `; ${holding.length} trips in ${state.days} days` : '';
436
454
  if (undo) return [{ kind: 'note', class: 'brake', cause: `the exact undo of ${undo.culprit.slice(0, 7)}, which tripped the brake: ${tripText(undo)}` }];
437
- return [{ kind: 'owner', class: 'brake', cause: `the brake is on until ${state.until.slice(0, 16).replace('T', ' ')} UTC: ${tripText(newest)}${more}` }];
455
+ const until = new Date(Date.parse(newest.time) + state.days * 864e5).toISOString();
456
+ const owner = { kind: 'owner', class: 'brake', cause: `the brake is on until ${until.slice(0, 16).replace('T', ' ')} UTC: ${tripText(newest)}${more}` };
457
+ // Done line 6: one signed clear for each trip, as a comment on its commit.
458
+ const clear = (holding.length ? holding : [newest]).map((trip) => ({ kind: 'note', class: 'brake', cause: `to end the trip now: atlas approve act brake-clear --repo <owner/repo> --commit ${trip.sha}, then post the block as a comment on commit ${trip.sha.slice(0, 7)}` }));
459
+ return [owner, ...clear];
438
460
  }
439
461
 
440
462
  function finish(answer) {
package/door/lib/gate.mjs CHANGED
@@ -705,6 +705,20 @@ export function prComments(gh, repo, pr) {
705
705
  return kept;
706
706
  }
707
707
 
708
+ // The comment texts of one commit on main that carry the signed-act marker:
709
+ // where the owner posts a brake clear (roadmap R13 line 6). At most three
710
+ // pages of a hundred. A read that fails gives null, so no clear counts.
711
+ export function commitComments(gh, repo, sha) {
712
+ const texts = [];
713
+ for (let page = 1; page <= 3; page += 1) {
714
+ const rows = gh('GET', `repos/${repo}/commits/${sha}/comments?per_page=100&page=${page}`);
715
+ if (!Array.isArray(rows)) return null;
716
+ for (const row of rows) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
717
+ if (rows.length < 100) break;
718
+ }
719
+ return texts;
720
+ }
721
+
708
722
  // The base workflow files that define a job of one of `names`, read from git.
709
723
  function definedAt(git, sha, names) {
710
724
  const files = new Set();
@@ -753,7 +767,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
753
767
  const named = repo && repo !== true && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.split('/').some((part) => /^\.+$/.test(part));
754
768
  if (what === 'check') {
755
769
  const number = /^[1-9][0-9]{0,8}$/.test(String(pr ?? '')) ? Number(pr) : null;
756
- return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo) } }) : null, named && number ? () => prComments(gh, repo, number) : null);
770
+ return checkCommand({ root, base, head, body, json, summary, output, repo: named ? repo : null, pr: pr === undefined ? null : pr, baseRef: baseRef ?? null }, line, named ? () => ({ redMain: redMain({ root, base }, gh, repo), brake: { ci: brakeRead({ root, base }, gh, repo), clears: { repo, commentsOf: (sha) => commitComments(gh, repo, sha) } } }) : null, named && number ? () => prComments(gh, repo, number) : null);
757
771
  }
758
772
  if (what === 'replay') return replayCommand({ root, days, branch, json }, line);
759
773
  if (!named) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
@@ -0,0 +1,128 @@
1
+ // The kinds of change (roadmap R51, tracker item d192ec7d; design
2
+ // docs/design-docs/kinds-of-change/). Each repo lists its kinds in atlas.yaml,
3
+ // under `kinds_of_change`: a set of paths, the impact, the undo, and the day
4
+ // the undo was tried. The gate reads the list from the base, names the kind of
5
+ // each changed path, and sends to the owner a kind whose undo was never tried.
6
+ //
7
+ // kindsList(text) the list of one atlas.yaml text
8
+ // kindsAt(root, ref) the list of atlas.yaml at a commit
9
+ // nameKinds(list, paths) the kind of each path: { names, byKind, unlisted }
10
+ // kindRows(list, named) the gate rows of one pull request
11
+ // kindsOfCommit(root, list, sha) the kind names of one commit on main, or null
12
+ // startingLists(text) the starting list for each kind of repo
13
+ //
14
+ // Locked rule 2 of the target design: nothing runs alone until its undo is
15
+ // written, tried once and takes minutes. So a path that no kind holds, a kind
16
+ // with no undo, and an undo never tried all come to the owner. A repo with no
17
+ // list gets no kind rows: its rules stay as they were.
18
+ import { spawnSync } from 'node:child_process';
19
+ import { readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { parseYaml } from '../../shape/check.mjs';
23
+ import { matcher } from './owner-only.mjs';
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+ export const STARTING_FILE = join(HERE, '..', 'kinds-of-change.yaml');
27
+ export const REPO_KINDS = Object.freeze(['service', 'library', 'schema', 'cli', 'plugin']);
28
+ // The gate names at most this many paths with no kind, then counts the rest.
29
+ const NAMED_PATHS = 20;
30
+
31
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
32
+ const triedDay = (value) => {
33
+ const text = String(value ?? '').trim();
34
+ return DAY.test(text) && !Number.isNaN(Date.parse(`${text}T00:00:00Z`)) ? text : null;
35
+ };
36
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
37
+
38
+ // One list from the entries of `kinds_of_change`. An entry with no name is
39
+ // skipped with a note. An entry with no paths holds no path.
40
+ function fromEntries(entries) {
41
+ if (!Array.isArray(entries)) return { listed: false, kinds: [], notes: [] };
42
+ const kinds = [];
43
+ const notes = [];
44
+ entries.forEach((entry, index) => {
45
+ const kind = entry && typeof entry === 'object' && !Array.isArray(entry) ? text(entry.kind) : null;
46
+ if (!kind) { notes.push(`kinds_of_change entry ${index + 1} has no kind, so the gate skips it`); return; }
47
+ const paths = Array.isArray(entry.paths) ? entry.paths.filter((path) => typeof path === 'string' && path.trim()) : [];
48
+ if (!paths.length) notes.push(`${kind}: it lists no paths, so it holds no path`);
49
+ const except = Array.isArray(entry.except) ? entry.except.filter((path) => typeof path === 'string' && path.trim()) : [];
50
+ kinds.push({ kind, paths, except, undo: text(entry.undo), tried: triedDay(entry.tried), impact: text(entry.impact) });
51
+ });
52
+ // A list in which no kind names its paths is the form from before R51: it
53
+ // says nothing about paths, so the gate treats the repo as one with no list.
54
+ return { listed: kinds.some((one) => one.paths.length > 0), kinds, notes };
55
+ }
56
+
57
+ export function kindsList(source) {
58
+ let facts;
59
+ try { facts = parseYaml(source ?? '') ?? {}; } catch { facts = {}; }
60
+ return fromEntries(facts && typeof facts === 'object' ? facts.kinds_of_change : null);
61
+ }
62
+
63
+ function git(root, args) {
64
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
65
+ return r.status === 0 ? r.stdout : null;
66
+ }
67
+
68
+ export const kindsAt = (root, ref) => kindsList(git(root, ['show', `${ref}:atlas.yaml`]));
69
+
70
+ // Paths match with case here: a match grants the kind's tried undo.
71
+ const exact = (pattern) => matcher(pattern, { caseless: false });
72
+
73
+ // The first kind whose paths hold the path, and whose `except` does not.
74
+ export function kindOf(list, path) {
75
+ for (const one of list.kinds) {
76
+ if (one.paths.some((pattern) => exact(pattern)(path)) && !one.except.some((pattern) => exact(pattern)(path))) return one;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ export function nameKinds(list, paths) {
82
+ const byKind = new Map();
83
+ const unlisted = [];
84
+ for (const path of paths) {
85
+ const one = kindOf(list, path);
86
+ if (!one) { unlisted.push(path); continue; }
87
+ if (!byKind.has(one.kind)) byKind.set(one.kind, { kind: one, paths: [] });
88
+ byKind.get(one.kind).paths.push(path);
89
+ }
90
+ // The kinds in the order of the list, so the rows do not depend on the diff.
91
+ const order = (name) => list.kinds.findIndex((one) => one.kind === name);
92
+ const sorted = new Map([...byKind].sort(([a], [b]) => order(a) - order(b)));
93
+ return { names: [...sorted.keys()], byKind: sorted, unlisted };
94
+ }
95
+
96
+ // The rows of one pull request: a note for each kind it changes, and an owner
97
+ // row for each kind that has no tried undo and for each path with no kind.
98
+ export function kindRows(list, named) {
99
+ if (!list.listed) return [];
100
+ const rows = list.notes.map((cause) => ({ kind: 'note', class: 'kind', cause }));
101
+ for (const { kind, paths } of named.byKind.values()) {
102
+ if (!kind.undo) rows.push({ kind: 'owner', class: 'kind', cause: `${kind.kind}: it names no undo (atlas.yaml kinds_of_change)` });
103
+ else if (!kind.tried) rows.push({ kind: 'owner', class: 'kind', cause: `${kind.kind}: its undo was never tried (atlas.yaml kinds_of_change)` });
104
+ rows.push({ kind: 'note', class: 'kind', cause: `${kind.kind}: ${paths.length} path(s)${kind.tried ? `; undo tried ${kind.tried}` : ''}` });
105
+ }
106
+ for (const path of named.unlisted.slice(0, NAMED_PATHS)) rows.push({ kind: 'owner', class: 'kind', path, cause: 'no kind of change holds this path, so it has no undo' });
107
+ if (named.unlisted.length > NAMED_PATHS) rows.push({ kind: 'owner', class: 'kind', cause: `${named.unlisted.length - NAMED_PATHS} more path(s) that no kind of change holds` });
108
+ return rows;
109
+ }
110
+
111
+ // The kinds of one commit on main, by its change from its first parent. Null
112
+ // means the brake must hold every kind: the base lists no kinds, the commit
113
+ // has no parent here, or one of its paths has no kind.
114
+ export function kindsOfCommit(root, list, sha) {
115
+ if (!list.listed) return null;
116
+ const out = git(root, ['diff', '--name-only', '-z', '--no-renames', `${sha}^1`, sha, '--']);
117
+ if (out === null) return null;
118
+ const named = nameKinds(list, out.split('\0').filter(Boolean));
119
+ return named.unlisted.length || !named.names.length ? null : named.names;
120
+ }
121
+
122
+ // The starting list for each kind of repo, as packages/atlas/door/kinds-of-change.yaml
123
+ // holds it. Onboarding (roadmap R63) copies one into the new repo's atlas.yaml.
124
+ export function startingLists(source = readFileSync(STARTING_FILE, 'utf8')) {
125
+ const facts = parseYaml(source) ?? {};
126
+ const starting = facts.starting && typeof facts.starting === 'object' ? facts.starting : {};
127
+ return Object.fromEntries(REPO_KINDS.map((repoKind) => [repoKind, fromEntries(starting[repoKind] ?? [])]));
128
+ }
@@ -85,8 +85,10 @@ const escape = (text) => text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
85
85
 
86
86
  // A pattern of owner-only.yaml or atlas.yaml. `**/` is any depth, `*` is one
87
87
  // name, `?` one character, and a trailing `/` is a folder. It matches with no
88
- // case and on whole names: `door/` never matches `door-x/`.
89
- export function matcher(pattern) {
88
+ // case and on whole names: `door/` never matches `door-x/`. The kinds of change
89
+ // pass `caseless: false`: there a match grants a tried undo, so `ENGINE/` must
90
+ // not take the undo of `engine/`.
91
+ export function matcher(pattern, { caseless = true } = {}) {
90
92
  const folder = pattern.endsWith('/');
91
93
  const body = clean(pattern);
92
94
  let out = '';
@@ -98,7 +100,7 @@ export function matcher(pattern) {
98
100
  if (character === '?') { out += '[^/]'; continue; }
99
101
  out += escape(character);
100
102
  }
101
- const regex = new RegExp(`^${out}${folder ? '/.+' : '(?:/.+)?'}$`, 'i');
103
+ const regex = new RegExp(`^${out}${folder ? '/.+' : '(?:/.+)?'}$`, caseless ? 'i' : '');
102
104
  return (path) => regex.test(clean(path));
103
105
  }
104
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.37",
3
+ "version": "0.4.39",
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",
@@ -192,20 +192,23 @@ next action. One short paragraph.
192
192
 
193
193
  1. Prove delivery the way the repo's `kind` requires, then call `item_done`. A
194
194
  `library` repo proves it by merged pull requests and a green gate. A
195
- `service` or `schema` repo also needs its live checks, because a schema
195
+ `service` or `schema` repo also needs its live checks. A schema
196
196
  migration reaches the running node after the merge.
197
197
  Each check in the repo's `required_checks` must be green on the pull
198
198
  request head or on the merge commit. When `atlas.yaml` says
199
199
  `live: release`, call `item_done` after the release run: it refuses
200
200
  until a published release holds the merge.
201
+
201
202
  A red check is a last resort, for an extraordinary case with no other
202
203
  option (owner, 22 October 2026). Fix the test first. If the owner still
203
204
  approved a merge with one known red test, record the words
204
205
  with `decision_record` on the item. The words must name the pull request
205
- and quote the exact test. One decision accepts one red check. The owner
206
- then signs a waiver act on his Mac with `atlas approve` and posts the
207
- block on the pull request. Then pass `accepted_red` to `item_done`; it
208
- needs the owner present. Any other red check still refuses the mark.
206
+ and quote the exact test. One decision accepts one red check.
207
+
208
+ The owner signs a waiver act for that check with `atlas approve`. He
209
+ posts the block on the pull request. Then pass `accepted_red` to
210
+ `item_done`; only the lead role may pass it. Any other red check still
211
+ refuses the mark.
209
212
  2. If anything was a struggle, run the `atlas:learnings` skill. It picks the
210
213
  home and the form of the fix, and records the lesson.
211
214
  3. Rebuild the design's page and the hub, so both read "Shipped". The
@@ -76,7 +76,7 @@ export function diffOf(current, next, { title, tags } = {}) {
76
76
  }
77
77
 
78
78
  export function createActivityVerbs(core) {
79
- const { client, label, now, typeNamed, requireItem, requireOwner, create, alreadyPatched } = core;
79
+ const { client, label, now, typeNamed, requireItem, create, alreadyPatched } = core;
80
80
 
81
81
  // The entry type, or null before the owner has run setup for 0.7.0.
82
82
  async function entryType() {
@@ -139,7 +139,6 @@ export function createActivityVerbs(core) {
139
139
  async item_comment(args, requestId, meta) {
140
140
  const item = await requireItem(args.item_id);
141
141
  const voice = oneOf(args.voice, 'voice', VOICES, 'agent');
142
- if (voice === 'owner') requireOwner('item comment in the owner\'s voice');
143
142
  const words = text(args.text, 'text', { max: MAX_COMMENT });
144
143
  const type = await typeNamed('entry');
145
144
  const entries = await entriesOf(type, item.id);
@@ -145,7 +145,7 @@ export function createCapabilityVerbs(core) {
145
145
  });
146
146
  return {
147
147
  verb: 'code_read_brief', read_at: now(), product: productId, capability, repos: wanted, briefs,
148
- note: 'Give one brief to one sub-agent. Sub-agents never receive the verb server or the Engram MCP. Pass each returned digest back in where_are_we.code.',
148
+ note: 'Give one brief to one crew. A crew holds no MCP tool: its tools line names none, so it cannot call the verb server or the Engram MCP. The server cannot tell a sub-agent from the lead in one session (R20). Pass each returned digest back in where_are_we.code.',
149
149
  };
150
150
  },
151
151