@arjunkhera/atlas 0.4.41 → 0.4.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/door/lib/approve.mjs +15 -5
- package/door/lib/signed-act.mjs +68 -0
- package/package.json +1 -1
- package/work/lib/delivery.mjs +23 -6
- package/work/mcp.mjs +5 -1
package/door/lib/approve.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { ghApi, pinnedRuns } from './gate.mjs';
|
|
|
34
34
|
import { GATE_CHECK } from './gate-check.mjs';
|
|
35
35
|
import {
|
|
36
36
|
ACTS, MAX_ACTS, AUDIT_CHECK, AUDIT_TEST, NAMESPACE, SIGNERS_FILE, payloadText, blockText, parseSigners, verifyBlock, verifyTexts, fingerprint, wordsFingerprint, SignedActError,
|
|
37
|
+
preKeyKeys,
|
|
37
38
|
} from './signed-act.mjs';
|
|
38
39
|
|
|
39
40
|
export class ApproveError extends Error {}
|
|
@@ -303,7 +304,7 @@ export function auditCause(deps, repo, { run, sha, pr, main }) {
|
|
|
303
304
|
|
|
304
305
|
// One open audit issue, read from GitHub: the pull request that made the
|
|
305
306
|
// merge, its head, and the red audit run. `skip` says why it is not signed.
|
|
306
|
-
export function auditRow(deps, repo, issue, main, kinds = new Map()) {
|
|
307
|
+
export async function auditRow(deps, repo, issue, main, kinds = new Map()) {
|
|
307
308
|
const sha = AUDIT_ISSUE.exec(String(issue.title ?? ''))[1];
|
|
308
309
|
const row = { issue: Number(issue.number), sha };
|
|
309
310
|
const pull = (deps.gh('GET', `repos/${repo}/commits/${sha}/pulls`) ?? []).find((each) => each?.merged_at && each.merge_commit_sha === sha);
|
|
@@ -327,9 +328,17 @@ export function auditRow(deps, repo, issue, main, kinds = new Map()) {
|
|
|
327
328
|
if (own) return { ...row, skip: `the merge changed ${own}, so its audit ran its own code: sign it by hand` };
|
|
328
329
|
const parent = String(commit?.parents?.[0]?.sha ?? '');
|
|
329
330
|
if (!/^[0-9a-f]{40}$/.test(parent)) return { ...row, skip: 'GitHub gave no parent of the merge commit' };
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
let keys = mergeKeys(deps, repo, parent, head);
|
|
332
|
+
// Item 65c6c907: a merge made before the first owner key takes the keys
|
|
333
|
+
// at the tip of the default branch, within the limits of preKeyKeys.
|
|
334
|
+
let first = null;
|
|
335
|
+
if (!keys.length) {
|
|
336
|
+
const read = (path) => { try { return deps.gh('GET', path); } catch (error) { if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null; throw error; } };
|
|
337
|
+
try { first = await preKeyKeys(read, repo, { parent, branch: main }); } catch (error) { first = { why: `GitHub refused a read of the key file history (${plainText(error?.message ?? error).slice(0, 120)})` }; }
|
|
338
|
+
if (first.keys) keys = first.keys;
|
|
339
|
+
}
|
|
340
|
+
if (!keys.length) return { ...row, skip: `${SIGNERS_FILE} on the base of this merge holds no owner key, so no act can count: close the issue by hand. No first key counts: ${first?.why ?? 'no reason'}` };
|
|
341
|
+
return { ...row, run: String(run.id), keys, ...(first?.from ? { first_key: first.from } : {}) };
|
|
333
342
|
}
|
|
334
343
|
|
|
335
344
|
// `atlas approve audit [<pr>...]`. Exit 0 when each listed red got its act.
|
|
@@ -369,7 +378,7 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
|
|
|
369
378
|
line(` ${issues.length} open audit issue(s) of an owner merge with no touch. Each one can take about 5 seconds.`);
|
|
370
379
|
const rows = [];
|
|
371
380
|
for (const issue of issues) {
|
|
372
|
-
const row = auditRow(deps, name, issue, main, kinds);
|
|
381
|
+
const row = await auditRow(deps, name, issue, main, kinds);
|
|
373
382
|
if (only.size && !only.has(row.pr)) continue;
|
|
374
383
|
rows.push(row);
|
|
375
384
|
}
|
|
@@ -378,6 +387,7 @@ export async function approveAudit({ prs = [], repo, root, key, provider, yes =
|
|
|
378
387
|
for (const row of rows) {
|
|
379
388
|
line(` issue #${row.issue} ${row.pr ? `#${row.pr}` : row.sha.slice(0, 7)}${row.head ? ` at ${row.head.slice(0, 7)}` : ''}${row.merged_at ? ` merged ${row.merged_at}` : ''} ${row.skip ? 'NOT SIGNED' : 'SIGN'} ${row.title ?? ''}`.trimEnd());
|
|
380
389
|
if (row.skip) line(` ${row.skip}`);
|
|
390
|
+
else if (row.first_key) line(` merged before the first owner key: the act counts against the keys at the tip of ${main}, ${row.first_key.slice(0, 7)}`);
|
|
381
391
|
}
|
|
382
392
|
if (only.size) for (const pr of only) if (!rows.some((row) => row.pr === pr)) line(` #${pr} has no open audit issue: nothing to accept.`);
|
|
383
393
|
if (!sign.length) { line('Nothing to sign: no open audit red can take an act.'); return 1; }
|
package/door/lib/signed-act.mjs
CHANGED
|
@@ -100,6 +100,10 @@ export const fingerprint = (text) => createHash('sha256').update(String(text).re
|
|
|
100
100
|
// review S3). The audit job finds; this job opens the issues and ends red.
|
|
101
101
|
export const AUDIT_CHECK = 'gate · push audit issues';
|
|
102
102
|
export const AUDIT_TEST = 'owner-unsigned';
|
|
103
|
+
// Both jobs of the push audit workflow (.github/workflows/atlas-gate-audit.yml).
|
|
104
|
+
// A red of either one is a question of the owner's consent, not of the
|
|
105
|
+
// tests, so a release never turns it into a warning (item 9348568f).
|
|
106
|
+
export const AUDIT_CHECKS = Object.freeze(['gate · push audit', AUDIT_CHECK]);
|
|
103
107
|
|
|
104
108
|
// The fingerprint that an accept act names: the owner's words with each run
|
|
105
109
|
// of white space made one space, and no space at the ends. A chat can wrap a
|
|
@@ -347,3 +351,67 @@ export function findAct(verified, want) {
|
|
|
347
351
|
}
|
|
348
352
|
return null;
|
|
349
353
|
}
|
|
354
|
+
|
|
355
|
+
// Item 65c6c907: the owner keys for a merge made before the first owner key.
|
|
356
|
+
// Such a merge can take no act against the keys of its base, so its audit
|
|
357
|
+
// red would block its item for good. It may take an accept act against the
|
|
358
|
+
// keys of the default branch at its tip now, within four limits:
|
|
359
|
+
// 1. No version of the key file in the history of the base held a key, so
|
|
360
|
+
// a key that was removed later never opens this path. A version that
|
|
361
|
+
// cannot be read counts as one that held a key.
|
|
362
|
+
// 2. The tip is the branch ref, resolved to a commit (refs/heads only, so a
|
|
363
|
+
// tag with the same name changes nothing). The caller passes the default
|
|
364
|
+
// branch of the repo.
|
|
365
|
+
// 3. Only a tip key that every keyed version of the file in the history of
|
|
366
|
+
// the tip holds counts. A key pushed after the first owner key, by anyone
|
|
367
|
+
// who can push to the branch, never counts here (security round 2, S2).
|
|
368
|
+
// 4. A history that cannot be read in full counts as no history: no keys.
|
|
369
|
+
// GitHub's history of a path leaves out a commit whose change did not reach
|
|
370
|
+
// the branch line (history simplification); limits 1 and 3 hold for the rest.
|
|
371
|
+
// `read(path)` answers GitHub's JSON for a GET, or null for a 404; it may
|
|
372
|
+
// answer a promise. Returns { keys, from } or { why }.
|
|
373
|
+
export const HISTORY_PAGES = 3;
|
|
374
|
+
export const signersHistoryPath = (repo, sha, page) => `repos/${repo}/commits?path=${encodeURIComponent(SIGNERS_FILE)}&sha=${sha}&per_page=100&page=${page}`;
|
|
375
|
+
export async function preKeyKeys(read, repo, { parent, branch }) {
|
|
376
|
+
const fileAt = async (sha) => {
|
|
377
|
+
const file = await read(`repos/${repo}/contents/${SIGNERS_FILE}?ref=${sha}`);
|
|
378
|
+
if (!file || typeof file.content !== 'string' || file.encoding !== 'base64') return null;
|
|
379
|
+
return parseSigners(Buffer.from(file.content, 'base64').toString('utf8')).keys;
|
|
380
|
+
};
|
|
381
|
+
// Each commit that changed the key file, from `sha` back; null when the
|
|
382
|
+
// history cannot be read in full.
|
|
383
|
+
const history = async (sha) => {
|
|
384
|
+
const shas = [];
|
|
385
|
+
for (let page = 1; page <= HISTORY_PAGES; page += 1) {
|
|
386
|
+
const rows = await read(signersHistoryPath(repo, sha, page));
|
|
387
|
+
if (!Array.isArray(rows) || rows.some((row) => !/^[0-9a-f]{40}$/.test(String(row?.sha ?? '')))) return null;
|
|
388
|
+
shas.push(...rows.map((row) => row.sha));
|
|
389
|
+
if (rows.length < 100) return shas;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
};
|
|
393
|
+
if (!/^[0-9a-f]{40}$/.test(String(parent ?? ''))) return { why: 'no base commit is named' };
|
|
394
|
+
if (!/^[A-Za-z0-9._-]{1,100}$/.test(String(branch ?? '')) || String(branch).includes('..')) return { why: 'no branch is named' };
|
|
395
|
+
const before = await history(parent);
|
|
396
|
+
if (!before) return { why: `the history of ${SIGNERS_FILE} before the merge could not be read in full` };
|
|
397
|
+
for (const sha of before) {
|
|
398
|
+
const keys = await fileAt(sha);
|
|
399
|
+
if (keys === null) return { why: `${SIGNERS_FILE} at ${sha.slice(0, 7)}, before the merge, could not be read` };
|
|
400
|
+
if (keys.length) return { why: `${SIGNERS_FILE} held a key at ${sha.slice(0, 7)}, before the merge` };
|
|
401
|
+
}
|
|
402
|
+
const ref = await read(`repos/${repo}/git/ref/heads/${branch}`);
|
|
403
|
+
const tip = String(ref?.object?.sha ?? '');
|
|
404
|
+
if (ref?.ref !== `refs/heads/${branch}` || !/^[0-9a-f]{40}$/.test(tip)) return { why: `the tip of ${branch} could not be read` };
|
|
405
|
+
const atTip = await fileAt(tip);
|
|
406
|
+
if (!atTip?.length) return { why: `${SIGNERS_FILE} at the tip of ${branch} holds no owner key` };
|
|
407
|
+
const since = await history(tip);
|
|
408
|
+
if (!since) return { why: `the history of ${SIGNERS_FILE} at the tip of ${branch} could not be read in full` };
|
|
409
|
+
let keys = atTip;
|
|
410
|
+
for (const sha of since) {
|
|
411
|
+
const held = await fileAt(sha);
|
|
412
|
+
if (held === null) return { why: `${SIGNERS_FILE} at ${sha.slice(0, 7)}, before the tip of ${branch}, could not be read` };
|
|
413
|
+
if (held.length) keys = keys.filter((key) => held.some((one) => one.fingerprint === key.fingerprint));
|
|
414
|
+
}
|
|
415
|
+
if (!keys.length) return { why: `no key at the tip of ${branch} is in every keyed version of ${SIGNERS_FILE}` };
|
|
416
|
+
return { keys, from: tip };
|
|
417
|
+
}
|
package/package.json
CHANGED
package/work/lib/delivery.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS }
|
|
|
16
16
|
import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
|
|
17
17
|
import { parseYaml } from '../../shape/check.mjs';
|
|
18
18
|
import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
|
|
19
|
-
import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct, wordsFingerprint, AUDIT_CHECK, AUDIT_TEST } from '../../door/lib/signed-act.mjs';
|
|
19
|
+
import { MARKER as SIGNED_MARKER, SIGNERS_FILE, parseSigners, verifyTexts, findAct, wordsFingerprint, AUDIT_CHECK, AUDIT_CHECKS, AUDIT_TEST, preKeyKeys } from '../../door/lib/signed-act.mjs';
|
|
20
20
|
|
|
21
21
|
export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
|
|
22
22
|
export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
|
|
@@ -462,8 +462,20 @@ export function createDeliveryVerbs(core) {
|
|
|
462
462
|
return parseSigners(file && typeof file.content === 'string' ? Buffer.from(file.content, 'base64').toString('utf8') : '').keys;
|
|
463
463
|
};
|
|
464
464
|
const atFork = forkPoint.toLowerCase() === parent.toLowerCase() ? null : new Set((await keysAt(forkPoint)).map((key) => key.fingerprint));
|
|
465
|
-
|
|
466
|
-
|
|
465
|
+
let keys = (await keysAt(parent)).filter((key) => !atFork || atFork.has(key.fingerprint));
|
|
466
|
+
// Item 65c6c907: a merge made before the first owner key. Only an accept
|
|
467
|
+
// act for the push audit red may count, against the keys at the tip of
|
|
468
|
+
// the default branch (preKeyKeys in signed-act.mjs holds the limits). The
|
|
469
|
+
// audit runs only on the default branch, so the merge must be into it.
|
|
470
|
+
let prekey = null;
|
|
471
|
+
if (!keys.length && check === AUDIT_CHECK && testName === AUDIT_TEST) {
|
|
472
|
+
const main = (await github(`repos/${pull.repo}`))?.default_branch;
|
|
473
|
+
prekey = typeof main === 'string' && main === pull.base
|
|
474
|
+
? await preKeyKeys(githubOrNull, pull.repo, { parent, branch: main })
|
|
475
|
+
: { why: `${pull.url} did not merge into the default branch` };
|
|
476
|
+
if (prekey.keys) keys = prekey.keys;
|
|
477
|
+
}
|
|
478
|
+
if (!keys.length) refuse(`accepted_red needs the owner's signed waiver, and ${SIGNERS_FILE} on the base of ${pull.url} holds no owner key${prekey?.why ? `; no first key counts either: ${prekey.why}` : ''}`);
|
|
467
479
|
const texts = [];
|
|
468
480
|
for (let page = 1; page <= 10; page += 1) {
|
|
469
481
|
const rows = await github(`repos/${pull.repo}/issues/${pull.number}/comments?per_page=100&page=${page}`);
|
|
@@ -472,7 +484,7 @@ export function createDeliveryVerbs(core) {
|
|
|
472
484
|
}
|
|
473
485
|
const verified = verifyTexts(texts, keys);
|
|
474
486
|
const words = typeof decision.fields?.text === 'string' ? decision.fields.text : '';
|
|
475
|
-
let found = findAct(verified, { kind: 'waiver', repo: pull.repo, pr: pull.number, head, decision: decisionId, check });
|
|
487
|
+
let found = prekey ? null : findAct(verified, { kind: 'waiver', repo: pull.repo, pr: pull.number, head, decision: decisionId, check });
|
|
476
488
|
// An accept act takes only the push audit's red of an owner merge
|
|
477
489
|
// (security review S3). Its words name no decision, so exactly one
|
|
478
490
|
// standing decision may hold them: a second decision with the same words
|
|
@@ -491,7 +503,7 @@ export function createDeliveryVerbs(core) {
|
|
|
491
503
|
const why = verified.find((row) => row.error)?.error;
|
|
492
504
|
refuse(`accepted_red needs the owner's signed waiver on ${pull.url} for check ${check}, head ${head.slice(0, 12)} and decision ${decisionId}; none counts${why ? ` (a block was refused: ${why})` : ''}. The owner runs: atlas approve act waiver, and posts the block on the pull request; or, for an audit red, atlas approve audit, and the decision holds the words it printed`);
|
|
493
505
|
}
|
|
494
|
-
return { key: found.key, signed_at: found.signed_at, act: found.act.kind, ...(found.act.kind === 'accept' ? { run: String(found.act.run) } : {}) };
|
|
506
|
+
return { key: found.key, signed_at: found.signed_at, act: found.act.kind, ...(found.act.kind === 'accept' ? { run: String(found.act.run) } : {}), ...(prekey?.from ? { first_key: prekey.from } : {}) };
|
|
495
507
|
}
|
|
496
508
|
|
|
497
509
|
async function acceptedRed(value, pulls, item) {
|
|
@@ -697,7 +709,12 @@ export function createDeliveryVerbs(core) {
|
|
|
697
709
|
// a release that holds the merge was built from a later main that
|
|
698
710
|
// passed the release tests, so the red is recorded on the mark as a
|
|
699
711
|
// warning and the release proves the change (the atlas#188 case).
|
|
700
|
-
|
|
712
|
+
// A release never proves the owner's consent, so a red of the push
|
|
713
|
+
// audit refuses with or without a release: only accepted_red with
|
|
714
|
+
// the owner's signed act clears it (item 9348568f).
|
|
715
|
+
const consent = unaccepted.filter((run) => AUDIT_CHECKS.includes(run.name));
|
|
716
|
+
const blocking = release ? consent : unaccepted;
|
|
717
|
+
if (blocking.length) refuse(`the merge gate is red for ${pull.url}: ${blocking.map((run) => `${run.name} ${run.conclusion} (${run.url})`).join(', ')}${mine.length ? '; accepted_red does not cover it' : ''}${release && consent.length ? `. Release ${release.version} holds the merge, but a release does not prove the owner's consent: accept the red with accepted_red and the owner's signed act (atlas approve audit)` : ''}`);
|
|
701
718
|
for (const run of release ? unaccepted : []) {
|
|
702
719
|
warnings.push({ pr: pull.url, check: run.name, latest: run.conclusion, latest_url: run.url, release: release.version,
|
|
703
720
|
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.` });
|
package/work/mcp.mjs
CHANGED
|
@@ -69,7 +69,7 @@ const TOOL_LIST = [
|
|
|
69
69
|
{ name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
|
|
70
70
|
{ 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. ${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 } },
|
|
71
71
|
{ 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. ${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 } },
|
|
72
|
-
{ 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. The owner must also sign a waiver act for the repo, the pull request, its head, the decision and the check (atlas approve act waiver) and post the block on the pull request; for the push audit red of an owner merge, the accept act that atlas approve audit posts counts too, when the decision's words are the words it printed; the key must be in atlas/owner-signers on the base of the merge. 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. ${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. 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 } },
|
|
72
|
+
{ 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. The owner must also sign a waiver act for the repo, the pull request, its head, the decision and the check (atlas approve act waiver) and post the block on the pull request; for the push audit red of an owner merge, the accept act that atlas approve audit posts counts too, when the decision's words are the words it printed; the key must be in atlas/owner-signers on the base of the merge. 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. A red of the push audit is never a warning: a release does not prove the owner's consent, so only accepted_red clears it. 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. ${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. 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 } },
|
|
73
73
|
{ 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 } },
|
|
74
74
|
{ 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, every role.', 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 } },
|
|
75
75
|
{ name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, every role. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
|
|
@@ -221,6 +221,10 @@ export const GITHUB_READS = Object.freeze([
|
|
|
221
221
|
// owner's key file on the base of its merge.
|
|
222
222
|
new RegExp(`^repos/${REPO}/issues/\\d{1,9}/comments\\?per_page=100&page=\\d{1,2}$`),
|
|
223
223
|
new RegExp(`^repos/${REPO}/contents/atlas/owner-signers\\?ref=[0-9a-fA-F]{40}$`),
|
|
224
|
+
// Item 65c6c907: for a merge made before the first owner key, the history
|
|
225
|
+
// of the key file on a commit, and the branch ref (heads only) of the tip.
|
|
226
|
+
new RegExp(`^repos/${REPO}/commits\\?path=atlas%2Fowner-signers&sha=[0-9a-f]{40}&per_page=100&page=[1-3]$`),
|
|
227
|
+
new RegExp(`^repos/${REPO}/git/ref/heads/${BRANCH}$`),
|
|
224
228
|
// Roadmap R16: the numbers page past 100 pull requests, and read the
|
|
225
229
|
// files of each merge to find a changed test.
|
|
226
230
|
new RegExp(`^repos/${REPO}/pulls\\?state=all&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,3}$`),
|