@arjunkhera/atlas 0.4.5 → 0.4.7

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.5",
3
+ "version": "0.4.7",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -17,6 +17,7 @@
17
17
  // It reads the tree and the event file, and writes nothing.
18
18
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
19
19
  import { join, dirname, posix, resolve } from 'node:path';
20
+ import { listChanged } from './changed-tests.mjs';
20
21
 
21
22
  const INDEX = 'docs/index.md';
22
23
  const SKIP_DIRS = new Set(['node_modules', 'archive', 'fixtures']);
@@ -155,6 +156,41 @@ export function prTextFindings(body) {
155
156
  return findings;
156
157
  }
157
158
 
159
+ // The rows of the changed-test list that a person must name under "Changed
160
+ // tests": test cases and scenario words. A helper, a runner config or the kit
161
+ // manifest (whose version moves in each release) may be named, but need not be.
162
+ const NAMED_GROUPS = new Set(['unit-test', 'check-block', 'scenario-words']);
163
+
164
+ // "Changed tests" against the changed-test list of the pull request (roadmap
165
+ // R8 line 4, design docs/design-docs/pr-template/). The list comes from git,
166
+ // between the base and the head commits of the event. With no list, the
167
+ // section says "none"; with a list, it names the path of each row.
168
+ export function changedTestsFindings(body, rows) {
169
+ const section = prSections(body).get('changed tests');
170
+ if (section === undefined) return [];
171
+ const paths = [...new Set(rows.filter((row) => NAMED_GROUPS.has(row.group)).map((row) => row.path))].sort();
172
+ // An empty list needs a section that opens with the word None.
173
+ if (!paths.length) return /^\s*none\b/i.test(section) ? [] : ['pr text: "Changed tests" names tests, but the changed-test list is empty; write "None."'];
174
+ // A path counts only as a whole name: `src/a.test.mjs.old` does not name `src/a.test.mjs`.
175
+ const named = (path) => new RegExp(`(^|[^\\w./-])${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|\\.(?![\\w/-])|[^\\w./-])`).test(section);
176
+ const missing = paths.filter((path) => !named(path));
177
+ if (!missing.length) return [];
178
+ return [`pr text: "Changed tests" does not name ${missing.length} changed test file(s): ${missing.slice(0, MAX_LISTED).join(', ')}`];
179
+ }
180
+
181
+ // The changed-test list of the event's pull request, or why there is none.
182
+ // A root that is no git work tree is not checked. A git tree that lacks a
183
+ // commit of the event is an error, because the check in CI fetches them all.
184
+ function changedRows(root, pr) {
185
+ const base = pr?.base?.sha;
186
+ const head = pr?.head?.sha;
187
+ if (!base || !head) return { checked: false, why: 'the event names no base and head commit' };
188
+ if (!existsSync(join(root, '.git'))) return { checked: false, why: 'the root is no git work tree' };
189
+ try { return { checked: true, rows: listChanged({ root, base, head }).rows }; } catch (error) {
190
+ return { checked: true, error: error.message };
191
+ }
192
+ }
193
+
158
194
  // Does the pull request name its tracker item? Read from a GitHub event file.
159
195
  export function itemNamed(event) {
160
196
  const pr = event?.pull_request;
@@ -188,7 +224,12 @@ export function upkeepCheck(root, { event = null } = {}) {
188
224
  // The heading check holds a pull request that names an item, in a repo that
189
225
  // keeps the Atlas template. A repo without the template is not held yet.
190
226
  const text = { checked: Boolean(item.named) && existsSync(join(root, PR_TEMPLATE_PATH)) };
191
- if (text.checked) findings.push(...prTextFindings(event.pull_request.body));
227
+ if (text.checked) {
228
+ findings.push(...prTextFindings(event.pull_request.body));
229
+ text.tests = changedRows(root, event.pull_request);
230
+ if (text.tests.error) findings.push(`pr text: the changed-test list could not be made: ${text.tests.error}`);
231
+ else if (text.tests.checked) findings.push(...changedTestsFindings(event.pull_request.body, text.tests.rows));
232
+ }
192
233
  return { green: findings.length === 0, findings, index, dead, item, text };
193
234
  }
194
235
 
@@ -204,6 +245,7 @@ export function upkeepCommand({ what, event: eventFile, json = false }, root, li
204
245
  line(`dead links: ${result.dead.length}`);
205
246
  line(`item: ${!result.item.checked ? 'not a pull request' : result.item.ok ? result.item.how : 'none named'}`);
206
247
  line(`pr text: ${result.text.checked ? 'the six headings were read' : 'not checked'}`);
248
+ if (result.text.checked) line(`changed tests: ${result.text.tests?.checked ? (result.text.tests.error ? 'no list' : `${result.text.tests.rows.filter((row) => NAMED_GROUPS.has(row.group)).length} row(s) to name`) : `not checked (${result.text.tests?.why})`}`);
207
249
  for (const finding of result.findings) line(` - ${finding}`);
208
250
  line(`upkeep check: ${result.green ? 'GREEN' : 'RED'}`);
209
251
  return result.green ? 0 : 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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",
@@ -191,6 +191,10 @@ next action. One short paragraph.
191
191
  `library` repo proves it by merged pull requests and a green gate. A
192
192
  `service` or `schema` repo also needs its live checks, because a schema
193
193
  migration reaches the running node after the merge.
194
+ Each check in the repo's `required_checks` must be green on the pull
195
+ request head or on the merge commit. When `atlas.yaml` says
196
+ `live: release`, call `item_done` after the release run: it refuses
197
+ until a published release holds the merge.
194
198
  A red check is a last resort, for an extraordinary case with no other
195
199
  option (owner, 22 October 2026). Fix the test first. If the owner still
196
200
  approved a merge with one known red test, record the words
@@ -14,6 +14,8 @@
14
14
  // from the marks and from the repos' pull requests (D3 rule 5, R1).
15
15
  import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS } from './verb-fields.mjs';
16
16
  import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
17
+ import { parseYaml } from '../../shape/check.mjs';
18
+ import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
17
19
 
18
20
  export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
19
21
  export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
@@ -98,6 +100,60 @@ export function latestCheckRuns(runs) {
98
100
  .sort((a, b) => a.name.localeCompare(b.name));
99
101
  }
100
102
 
103
+ // Roadmap R17 (item 14938416). Where a repo's change counts as live, as
104
+ // atlas.yaml says it with `live:`. release: a published release holds the
105
+ // merge. deploy: the three live checks of a service. merge: the merge itself.
106
+ // A repo that does not say takes the value of its kinds.
107
+ export const LIVE_VALUES = Object.freeze(['release', 'deploy', 'merge']);
108
+ const MAX_RELEASE_COMPARES = 10;
109
+ const RELEASES = 100;
110
+
111
+ // What atlas.yaml said at one commit: the required checks and the live value.
112
+ // A repo with no atlas.yaml, or one that lists no required checks, keeps the
113
+ // older rule of the gate (one check passed, none red).
114
+ export function deliveryFacts(source) {
115
+ const facts = parseYaml(source) ?? {};
116
+ if (!facts || typeof facts !== 'object' || Array.isArray(facts)) return { required: [], live: null };
117
+ const list = Array.isArray(facts.required_checks) ? facts.required_checks : [];
118
+ const required = list.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim());
119
+ const live = facts.live === undefined || facts.live === null || facts.live === '' ? null : String(facts.live).trim();
120
+ return { required, live, invalid: live !== null && !LIVE_VALUES.includes(live) };
121
+ }
122
+
123
+ // The state of each required check of one pull request. A check counts when
124
+ // the newest GitHub Actions run of that name, on the head commit or on the
125
+ // merge commit, ended in success (R17 line 1). Otherwise it says why:
126
+ // pending, red, skipped (a path filter or a condition skipped every run, so
127
+ // it proved nothing) or missing (it never ran on either commit). Line 2.
128
+ export function requiredStates(names, headRuns, mergeRuns) {
129
+ const newest = (runs, name) => (runs ?? [])
130
+ .filter((run) => run?.name === name && run.app?.id === ACTIONS_APP_ID)
131
+ .sort((a, b) => `${b.started_at ?? b.completed_at ?? ''}#${String(b.id ?? 0).padStart(24, '0')}`.localeCompare(`${a.started_at ?? a.completed_at ?? ''}#${String(a.id ?? 0).padStart(24, '0')}`))[0] ?? null;
132
+ const stateOf = (run) => {
133
+ if (!run) return 'missing';
134
+ if (run.status !== 'completed') return 'pending';
135
+ if (run.conclusion === PASSING_CONCLUSION) return 'green';
136
+ if (run.conclusion === 'skipped') return 'skipped';
137
+ return FATAL_CONCLUSIONS.includes(run.conclusion ?? '') ? 'red' : (run.conclusion ?? 'unknown');
138
+ };
139
+ // When the two commits disagree, the reason names the better state: a
140
+ // green on either commit wins, then a run still going, then a red.
141
+ const rank = (state) => { const at = ['green', 'pending', 'red', 'skipped', 'missing'].indexOf(state); return at < 0 ? 2 : at; };
142
+ return names.map((name) => {
143
+ const seen = [['head', newest(headRuns, name)], ['merge', newest(mergeRuns, name)]]
144
+ .map(([on, run]) => ({ on, state: stateOf(run), conclusion: run?.conclusion ?? null, url: run?.html_url ?? '' }));
145
+ const best = [...seen].sort((a, b) => rank(a.state) - rank(b.state))[0];
146
+ return { name, state: best.state, on: best.state === 'green' ? best.on : null, seen };
147
+ });
148
+ }
149
+
150
+ export const requiredWhy = (row) => ({
151
+ pending: `${row.name} is still running`,
152
+ red: `${row.name} is red (${row.seen.map((one) => `${one.on} ${one.conclusion ?? one.state}`).join(', ')})`,
153
+ skipped: `${row.name} was skipped on the head and on the merge commit: a path filter or a condition skipped it, so it proved nothing`,
154
+ missing: `${row.name} never ran on the head or on the merge commit`,
155
+ }[row.state] ?? `${row.name} ended ${row.state}`);
156
+
101
157
  export const daysBetween = (from, to) => {
102
158
  const start = Date.parse(from ?? ''), end = Date.parse(to ?? '');
103
159
  if (Number.isNaN(start) || Number.isNaN(end)) return null;
@@ -211,6 +267,52 @@ export function createDeliveryVerbs(core) {
211
267
  try { return await readLive(target); }
212
268
  catch (error) { refuse(`${name} did not answer: ${error?.message ?? 'unknown error'}`); }
213
269
  };
270
+ // A read that may find nothing. GitHub answers 404 for a file that is not
271
+ // there; any other failure refuses, as every read of this verb does.
272
+ const githubOrNull = async (path) => {
273
+ if (typeof readGitHub !== 'function') refuse('this verb needs a GitHub reader; the server supplies one');
274
+ try { return await readGitHub(path); }
275
+ catch (error) {
276
+ if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null;
277
+ refuse(`GitHub could not read ${path}: ${error?.message ?? 'unknown error'}`);
278
+ }
279
+ };
280
+ // R17: what atlas.yaml says for one merge. The required checks come from
281
+ // the base of the merge, its first parent: the gate as it stood when the
282
+ // pull request merged, which the pull request could not change. The live
283
+ // value comes from the tip of the branch it merged into: it says how the
284
+ // repo ships now, and a change to atlas.yaml goes to the owner.
285
+ async function atlasYamlAt(repo, ref) {
286
+ const file = await githubOrNull(`repos/${repo}/contents/atlas.yaml?ref=${ref}`);
287
+ if (!file || typeof file.content !== 'string') return { required: [], live: null };
288
+ try { return deliveryFacts(Buffer.from(file.content, 'base64').toString('utf8')); }
289
+ catch (error) { refuse(`atlas.yaml of ${repo} at ${ref.slice(0, 12)} cannot be read: ${error?.message ?? error}`); }
290
+ }
291
+ async function factsBefore(pull) {
292
+ const commit = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}`);
293
+ const parent = commit?.parents?.[0]?.sha;
294
+ if (typeof parent !== 'string' || !/^[0-9a-f]{40}$/i.test(parent)) refuse(`GitHub names no parent of the merge commit ${pull.merge_commit_sha} of ${pull.url}`);
295
+ const before = await atlasYamlAt(pull.repo, parent);
296
+ const branch = /^[A-Za-z0-9._-]{1,100}$/.test(pull.base) ? pull.base : null;
297
+ const now = branch ? await atlasYamlAt(pull.repo, branch) : { live: null };
298
+ return { required: before.required, live: now.live, invalid: Boolean(now.invalid), at: parent, live_from: branch };
299
+ }
300
+ // R17 line 4: the first published release, after the merge, whose tag
301
+ // holds the merge commit. Drafts and pre-releases do not count.
302
+ async function releaseHolding(pull) {
303
+ const rows = await github(`repos/${pull.repo}/releases?per_page=${RELEASES}`);
304
+ const after = (Array.isArray(rows) ? rows : [])
305
+ .filter((row) => !row?.draft && !row?.prerelease && typeof row?.tag_name === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(row.tag_name)
306
+ && typeof row.published_at === 'string' && row.published_at >= pull.merged_at)
307
+ .sort((a, b) => a.published_at.localeCompare(b.published_at));
308
+ for (const row of after.slice(0, MAX_RELEASE_COMPARES)) {
309
+ const compare = await github(`repos/${pull.repo}/compare/${pull.merge_commit_sha}...${row.tag_name}`);
310
+ if (compare?.status === 'identical' || compare?.status === 'ahead') {
311
+ return { version: row.tag_name.replace(/^v(?=\d)/, ''), tag: row.tag_name, url: row.html_url ?? '', published_at: row.published_at };
312
+ }
313
+ }
314
+ return null;
315
+ }
214
316
  // The reopen door (R9). The owner-present flag, or the lead in this session.
215
317
  const requireOwnerOrLead = (verb) => { if (!ownerPresent && label.agent !== 'lead') refuse(`${verb} is for the owner or the lead in session; this session is agent "${label.agent}"`); };
216
318
 
@@ -407,6 +509,7 @@ export function createDeliveryVerbs(core) {
407
509
  delivered_at: standing.fields.marked_at, prs: (standing.fields.prs ?? []).map((pull) => pull.url), deployed_commit: standing.fields.deployed?.commit ?? null,
408
510
  checks: (standing.fields.checks ?? []).map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
409
511
  verified_by: (standing.fields.verified_by ?? []).map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: standing.fields.gate_warnings ?? [],
512
+ releases: standing.fields.releases ?? [],
410
513
  accepted_red: (standing.fields.accepted_red ?? []).map((row) => ({ pr: row.pr, check: row.check, test: row.test, decision_id: row.decision_id, detail: row.detail })),
411
514
  replayed_from_graph: true };
412
515
  }
@@ -464,7 +567,7 @@ export function createDeliveryVerbs(core) {
464
567
  const pr = await github(`repos/${parsed.repo}/pulls/${parsed.number}`);
465
568
  if (pr?.merged !== true) refuse(`pull request ${parsed.url} is not merged on GitHub; a delivery is marked after the merge`);
466
569
  const mergeCommit = text(pr.merge_commit_sha, `merge_commit_sha of ${parsed.url}`, { max: 64 });
467
- pulls.push({ ...parsed, title: oneLine(pr.title ?? '', 200), merged_at: pr.merged_at ?? '', merge_commit_sha: mergeCommit, branch: pr.head?.ref ?? '', base: pr.base?.ref ?? '' });
570
+ pulls.push({ ...parsed, title: oneLine(pr.title ?? '', 200), merged_at: pr.merged_at ?? '', merge_commit_sha: mergeCommit, head_sha: typeof pr.head?.sha === 'string' ? pr.head.sha : '', branch: pr.head?.ref ?? '', base: pr.base?.ref ?? '' });
468
571
  }
469
572
  const accepted = await acceptedRed(args.accepted_red, pulls, item);
470
573
 
@@ -483,7 +586,7 @@ export function createDeliveryVerbs(core) {
483
586
  }
484
587
 
485
588
  // The gate run, fetched from GitHub for each merge commit (R10).
486
- const verified = [], warnings = [], acceptedRows = [];
589
+ const verified = [], warnings = [], acceptedRows = [], released = [];
487
590
  for (const pull of pulls) {
488
591
  const answer = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}/check-runs?per_page=${CHECK_RUNS}`);
489
592
  const runs = latestCheckRuns(answer?.check_runs);
@@ -491,7 +594,43 @@ export function createDeliveryVerbs(core) {
491
594
  const passed = runs.filter((run) => run.conclusion === PASSING_CONCLUSION);
492
595
  const mine = accepted.filter((row) => row.pr === pull.url);
493
596
  const unaccepted = failed.filter((run) => !mine.some((row) => row.check === run.name));
494
- if (unaccepted.length) refuse(`the merge gate is red for ${pull.url}: ${unaccepted.map((run) => `${run.name} ${run.conclusion} (${run.url})`).join(', ')}${mine.length ? '; accepted_red does not cover it' : ''}`);
597
+ // R17: what the repo's atlas.yaml said just before this merge. The
598
+ // first parent of the merge commit is the base, so a pull request
599
+ // cannot drop its own check or change its own live value.
600
+ const facts = await factsBefore(pull);
601
+ if (facts.invalid) refuse(`atlas.yaml of ${pull.repo} says live: ${facts.live}; the values are ${LIVE_VALUES.join(', ')}`);
602
+ const repoRecord = reg.repos.get(pull.repo);
603
+ if (facts.live === 'deploy' && repoRecord && !repoKinds(repoRecord.fields).some((kind) => LIVE_KINDS.includes(kind))) {
604
+ refuse(`atlas.yaml of ${pull.repo} says live: deploy, but the repo record has the kinds ${repoKinds(repoRecord.fields).join(', ') || 'none'}; the live checks need a service or a schema kind, which the owner sets with repo edit`);
605
+ }
606
+ // Line 1: each required check has a successful newest run on the head
607
+ // or on the merge commit. Line 2: the reason tells a skipped check
608
+ // from one that never ran.
609
+ let required = [];
610
+ if (facts.required.length) {
611
+ const headRuns = pull.head_sha ? (await github(`repos/${pull.repo}/commits/${pull.head_sha}/check-runs?per_page=${CHECK_RUNS}`))?.check_runs : [];
612
+ required = requiredStates(facts.required, headRuns, answer?.check_runs);
613
+ const short = required.filter((row) => row.state !== 'green');
614
+ if (short.length) refuse(`a required check of ${pull.url} is not green: ${short.map(requiredWhy).join('; ')}. atlas.yaml before the merge lists ${facts.required.length} required check(s).`);
615
+ }
616
+ // Lines 4 and 5: for live: release, a published release must hold
617
+ // the merge commit. A later release holds it too, so a release run
618
+ // that skipped this commit needs no rerun.
619
+ let release = null;
620
+ if (facts.live === 'release') {
621
+ release = await releaseHolding(pull);
622
+ if (!release) refuse(`no published release of ${pull.repo} holds ${pull.url} (merge commit ${pull.merge_commit_sha.slice(0, 7)}) yet; atlas.yaml says live: release, so the change is not live until a release holds it. Wait for the release run, then call item_done again.`);
623
+ released.push({ pr: pull.url, ...release });
624
+ }
625
+ // A red check on the merge commit refuses, as before. One exception:
626
+ // a release that holds the merge was built from a later main that
627
+ // passed the release tests, so the red is recorded on the mark as a
628
+ // warning and the release proves the change (the atlas#188 case).
629
+ if (unaccepted.length && !release) refuse(`the merge gate is red for ${pull.url}: ${unaccepted.map((run) => `${run.name} ${run.conclusion} (${run.url})`).join(', ')}${mine.length ? '; accepted_red does not cover it' : ''}`);
630
+ for (const run of release ? unaccepted : []) {
631
+ warnings.push({ pr: pull.url, check: run.name, latest: run.conclusion, latest_url: run.url, release: release.version,
632
+ detail: `${run.name} concluded ${run.conclusion} on the merge commit of ${pull.url}. Release ${release.version} holds the merge and was built from a later main, so the release proves the change.` });
633
+ }
495
634
  // An acceptance of a check that is not red is stale: the owner
496
635
  // accepted a red that the gate does not show, so the verb refuses
497
636
  // rather than write an acceptance nobody needed.
@@ -502,7 +641,7 @@ export function createDeliveryVerbs(core) {
502
641
  acceptedRows.push({ ...kept, conclusion: run.conclusion, run_url: run.url,
503
642
  detail: `${row.check} concluded ${run.conclusion} for ${pull.url}. The owner accepted the red test "${row.test}" in decision ${row.decision_id}. The gate names the check, not the test, so the verb proved the check level only.` });
504
643
  }
505
- if (!passed.length) refuse(`no check run succeeded for ${pull.url}; the merge gate is the independent record and it says nothing`);
644
+ if (!required.length && !passed.length) refuse(`no check run succeeded for ${pull.url}; the merge gate is the independent record and it says nothing`);
506
645
  // A check name whose LATEST run succeeded is green. A name whose earlier
507
646
  // run was red and whose later run was skipped or neutral is not red and
508
647
  // not green: the verb cannot tell a re-run that fixed it from a gate
@@ -516,7 +655,7 @@ export function createDeliveryVerbs(core) {
516
655
  warnings.push({ pr: pull.url, check: run.name, earlier: last.conclusion, earlier_url: last.url, latest: run.conclusion, latest_url: run.url,
517
656
  detail: `${run.name} concluded ${last.conclusion} for ${pull.url}, and its later run concluded ${run.conclusion}, not success. The verb cannot tell a fixed gate from a gate that was switched off.` });
518
657
  }
519
- verified.push({ pr: pull.url, commit: pull.merge_commit_sha, runs, passed: passed.map((run) => run.name), ...(mine.length ? { accepted_red: mine.map((row) => row.check) } : {}) });
658
+ verified.push({ pr: pull.url, commit: pull.merge_commit_sha, runs, passed: passed.map((run) => run.name), ...(required.length ? { required: required.map(({ name, state, on }) => ({ name, state, on })) } : {}), ...(release ? { release: release.version } : {}), ...(mine.length ? { accepted_red: mine.map((row) => row.check) } : {}) });
520
659
  }
521
660
 
522
661
  // One row per check that ran. For a repo with no deploy target the row
@@ -525,7 +664,7 @@ export function createDeliveryVerbs(core) {
525
664
  const checks = live ? [contains, health, functional] : [{
526
665
  name: 'every named pull request is merged',
527
666
  result: 'pass',
528
- detail: `${pulls.length} pull request${pulls.length === 1 ? '' : 's'} merged on GitHub: ${pulls.map((pull) => `${pull.url} at ${pull.merge_commit_sha.slice(0, 7)}`).join(', ')}. No live check ran: ${named.map(said).join(', ')}, so the product has no deployed endpoint to read (amendment A1.7).`,
667
+ detail: `${pulls.length} pull request${pulls.length === 1 ? '' : 's'} merged on GitHub: ${pulls.map((pull) => `${pull.url} at ${pull.merge_commit_sha.slice(0, 7)}`).join(', ')}. No live check ran: ${named.map(said).join(', ')}, so the product has no deployed endpoint to read (amendment A1.7).${released.length ? ` A published release holds each merge: ${released.map((row) => `${row.pr} in ${row.version}`).join(', ')}.` : ''}`,
529
668
  checked_at: now(),
530
669
  }];
531
670
  const mark = await create(type, requestId, 'item_done', {
@@ -536,7 +675,7 @@ export function createDeliveryVerbs(core) {
536
675
  kind: 'delivery', verdict: 'delivered', item: item.id, product: product.fields.registry_id, marked_at: now(),
537
676
  prs: pulls.map((pull) => ({ url: pull.url, repo: pull.repo, number: pull.number, title: pull.title, branch: pull.branch, merged_at: pull.merged_at, merge_commit_sha: pull.merge_commit_sha })),
538
677
  ...(live ? { deployed: { health_url: healthUrl, commit: health.deployed_commit, reported: health.reported, status: health.status, checked_at: health.checked_at } } : {}),
539
- checks, verified_by: verified, ...(warnings.length ? { gate_warnings: warnings } : {}), ...(acceptedRows.length ? { accepted_red: acceptedRows } : {}), ...(note ? { note } : {}),
678
+ checks, verified_by: verified, ...(released.length ? { releases: released } : {}), ...(warnings.length ? { gate_warnings: warnings } : {}), ...(acceptedRows.length ? { accepted_red: acceptedRows } : {}), ...(note ? { note } : {}),
540
679
  },
541
680
  tags: ['atlas-mark', `product:${product.fields.registry_id}`, ...pulls.map((pull) => `repo:${pull.repo}`)],
542
681
  });
@@ -554,6 +693,7 @@ export function createDeliveryVerbs(core) {
554
693
  checks: checks.map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
555
694
  verified_by: verified.map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: warnings,
556
695
  accepted_red: acceptedRows.map((row) => ({ pr: row.pr, check: row.check, test: row.test, decision_id: row.decision_id, detail: row.detail })),
696
+ releases: released,
557
697
  ...(replay ? { replayed_from_graph: true } : {}),
558
698
  };
559
699
  },
package/work/mcp.mjs CHANGED
@@ -64,7 +64,7 @@ export const TOOLS = [
64
64
  { name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
65
65
  { name: 'decision_record', description: `Record what the owner decided outside a question, in their own words. Refuses missing text and a missing date. Pass rendered to keep the view the owner acted on as a receipt in the same call. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'date'], properties: { request_id: REQUEST_ID, text: S("The owner's words, verbatim."), date: S('ISO date the owner said it.'), source: S('Where the words are on record.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default decision.`), product: S('Product id the decision is about.'), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on.'), view: S('Which view the owner read.') }, additionalProperties: false } },
66
66
  { name: 'receipt_keep', description: `Keep the rendered answer the owner acted on and attach it to a decision. Refuses when there is no decision to attach to. Secret patterns are redacted before the write. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'decision_id', 'rendered'], properties: { request_id: REQUEST_ID, decision_id: S('Entity id of the decision this receipt belongs to.'), rendered: S('The answer as the owner read it.'), view: S('Which view it came from, for example needs-me.') }, additionalProperties: false } },
67
- { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. One exception, a last resort for an extraordinary case with no other option: the owner can accept a known red test on one merged pull request. Name it in accepted_red with the decision that records the owner's words; those words must name the pull request and quote the exact test, and one decision accepts one red check. accepted_red needs the owner present. The gate names a check, not a test, so the verb proves the check level, and every other red check still refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.'), accepted_red: { type: 'array', description: 'Red checks the owner accepted, one entry for each pull request and check. Owner present only. The decision must be a decision or an answer on this item, written by decision_record or question_answer and not replaced. Its words must name the pull request and quote the test. One decision accepts one check on one pull request. A listed check that is not red is refused.', items: { type: 'object', required: ['pr', 'check', 'test', 'decision_id'], additionalProperties: false, properties: { pr: S('One of the prs of this delivery.'), check: S('The name of the red check run in the merge gate, for example "test".'), test: S('The exact name of the red test, as the owner\'s words quote it.'), decision_id: S('The decision that records the owner\'s acceptance.') } } } }, additionalProperties: false } },
67
+ { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. One exception, a last resort for an extraordinary case with no other option: the owner can accept a known red test on one merged pull request. Name it in accepted_red with the decision that records the owner's words; those words must name the pull request and quote the exact test, and one decision accepts one red check. accepted_red needs the owner present. The gate names a check, not a test, so the verb proves the check level, and every other red check still refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. When atlas.yaml on the base of a merge lists required_checks, each one needs a successful newest GitHub Actions run on the pull request head or on the merge commit; a skipped check and a check that never ran are named apart. When atlas.yaml says live: release, a published release must hold each merge commit, and the mark stores that version; a red run on the merge commit is then a warning on the mark, because the release was built from a later green main. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.'), accepted_red: { type: 'array', description: 'Red checks the owner accepted, one entry for each pull request and check. Owner present only. The decision must be a decision or an answer on this item, written by decision_record or question_answer and not replaced. Its words must name the pull request and quote the test. One decision accepts one check on one pull request. A listed check that is not red is refused.', items: { type: 'object', required: ['pr', 'check', 'test', 'decision_id'], additionalProperties: false, properties: { pr: S('One of the prs of this delivery.'), check: S('The name of the red check run in the merge gate, for example "test".'), test: S('The exact name of the red test, as the owner\'s words quote it.'), decision_id: S('The decision that records the owner\'s acceptance.') } } } }, additionalProperties: false } },
68
68
  { name: 'item_reopen', description: `Reopen a delivered item: the lifecycle goes back to active and a reopen mark is appended, citing the failed check by URL. The delivery mark is never erased; the reopen points at it with the corrects relation. The owner, or the lead in this session, calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'failed_check'], properties: { request_id: REQUEST_ID, item_id: S('Item id carrying at least one delivery mark.'), reason: S('Why it is reopened, in plain words.'), failed_check: S('Absolute URL of the check that failed: a CI run, a monitor, an issue.'), corrects: S('Mark id of the delivery this reopen corrects. Default: the item\'s latest delivery mark.') }, additionalProperties: false } },
69
69
  { name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
70
70
  { name: 'sweep_check', description: 'Check the sweep against the "In flight" view: the newest decision record of kind sweep names the items the owner called in flight, and this reads the view and names every difference — what is listed and not in flight, and what is in flight and not listed. A product with no sweep on record is named too. It answers two things apart: `matches` says each product\'s view holds exactly what its sweep lists, and `complete` says that and that no item in the tenant sits outside every product. An item nobody adopted is missing from both sides, so `matches` alone can read "equal" over an unfinished sweep. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the check to one product id.') }, additionalProperties: false } },
@@ -192,12 +192,20 @@ function gh(args) {
192
192
  // "In flight" view needs each repo's recent pull requests (lock block L3).
193
193
  const REPO = '[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+';
194
194
  const SHA = '[0-9a-fA-F]{7,40}';
195
+ const TAG = '[A-Za-z0-9][A-Za-z0-9_.-]{0,99}';
196
+ const BRANCH = '[A-Za-z0-9._-]{1,100}';
195
197
  export const GITHUB_READS = Object.freeze([
196
198
  new RegExp(`^repos/${REPO}$`),
197
199
  new RegExp(`^repos/${REPO}/pulls/\\d{1,9}$`),
198
200
  new RegExp(`^repos/${REPO}/pulls\\?state=all&sort=updated&direction=desc&per_page=\\d{1,3}$`),
199
201
  new RegExp(`^repos/${REPO}/commits/${SHA}/check-runs\\?per_page=\\d{1,3}$`),
200
202
  new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${SHA}$`),
203
+ // Roadmap R17: the base of a merge, atlas.yaml on that base and on the
204
+ // branch tip, the releases, and the compare of a merge with a release tag.
205
+ new RegExp(`^repos/${REPO}/commits/${SHA}$`),
206
+ new RegExp(`^repos/${REPO}/contents/atlas\\.yaml\\?ref=(?:${SHA}|${BRANCH})$`),
207
+ new RegExp(`^repos/${REPO}/releases\\?per_page=\\d{1,3}$`),
208
+ new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${TAG}$`),
201
209
  ]);
202
210
  export function githubReader() {
203
211
  return async (path) => {