@arjunkhera/atlas 0.4.29 → 0.4.31

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.29",
3
+ "version": "0.4.31",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
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",
@@ -22,21 +22,35 @@ and makes it hard to miss again.
22
22
 
23
23
  1. **Name the lesson** in one plain sentence. Say what went wrong and
24
24
  what is right.
25
- 2. **Pick one home.**
25
+ 2. **Look for a repeat.** Call `lessons` for the product. Read each line.
26
+ When an earlier lesson says the same thing, this lesson is a repeat.
27
+ Keep that lesson's id, and read its fix: the fix did not hold, so make
28
+ the new fix stronger.
29
+ 3. **Pick one home.**
26
30
  - The lesson holds only for this repo: it becomes a change here.
27
31
  - The lesson holds for every repo: it becomes an Atlas item.
28
- 3. **For an Atlas lesson,** propose an item in the Atlas tracker, with
32
+ 4. **For an Atlas lesson,** propose an item in the Atlas tracker, with
29
33
  the Atlas tools. Put the owner's words in it, word for word. Tag it
30
34
  `learning`. Link it to the repo where the miss happened. Tell the owner
31
35
  the item and that an Atlas release, on their word, closes it.
32
- 4. **Pick the strongest form that fits,** in this order:
36
+ 5. **Pick the strongest form that fits,** in this order:
33
37
  1. a check that fails when the miss happens again;
34
38
  2. a tool that makes the right way the easy way;
35
39
  3. a skill step;
36
40
  4. a line in a doc, with a short note.
37
- 5. **Prune.** A new rule retires or merges each rule it replaces. Name
41
+ 6. **Prune.** A new rule retires or merges each rule it replaces. Name
38
42
  the rules you removed in the pull request.
39
- 6. **Make the change** in its home, through the normal flow.
43
+ 7. **Make the change** in its home, through the normal flow.
44
+ 8. **Record the lesson.** Call `lesson_record` once, when the fix exists:
45
+ - `line`: the sentence of step 1;
46
+ - `home`: `repo` with the repo id, or `atlas`;
47
+ - `form`: `check`, `tool`, `skill` or `doc`;
48
+ - `fix`: the pull request, or the Atlas item of step 4;
49
+ - `source`: `owner`, `revert`, `bug` or `machine`, and in `source_ref`
50
+ the step-in, the revert or the bug;
51
+ - `repeats`: the id that step 2 found, if any.
52
+ 9. **Link the step-in.** When the owner's message started the lesson, call
53
+ `step_in_name` on that step-in with the lesson id as `lesson`.
40
54
 
41
55
  ## Rules
42
56
 
@@ -46,3 +60,4 @@ and makes it hard to miss again.
46
60
  2. A lesson about one repo's product stays in that repo. It never goes
47
61
  into an Atlas release.
48
62
  3. One lesson is one fix. Do not batch unrelated lessons.
63
+ 4. Every lesson leaves one record, a repo lesson too. Never skip step 8.
@@ -202,11 +202,12 @@ next action. One short paragraph.
202
202
  option (owner, 22 October 2026). Fix the test first. If the owner still
203
203
  approved a merge with one known red test, record the words
204
204
  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. Then pass
206
- `accepted_red` to `item_done`; it needs the owner present. Any other red
207
- check still refuses the mark.
208
- 2. If anything was a struggle, encode the fix where it stops a repeat — a lint,
209
- then a tool, then a skill, then a doc — and say which.
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.
209
+ 2. If anything was a struggle, run the `atlas:learnings` skill. It picks the
210
+ home and the form of the fix, and records the lesson.
210
211
  3. Rebuild the design's page and the hub, so both read "Shipped". The
211
212
  design folder does not change: the tracker holds the delivery. A design
212
213
  in the older format is one file, not a folder. Keep its page, and rebuild
@@ -16,6 +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 } from '../../door/lib/signed-act.mjs';
19
20
 
20
21
  export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
21
22
  export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
@@ -147,10 +148,20 @@ export function requiredStates(names, headRuns, mergeRuns) {
147
148
  });
148
149
  }
149
150
 
151
+ // R17 line 2: a skipped check names what each commit saw. A check can be
152
+ // skipped on the head and never run on the merge commit.
153
+ const SIDE = { head: 'the head', merge: 'the merge commit' };
154
+ function skippedSides(row) {
155
+ const seen = Array.isArray(row.seen) ? row.seen : [];
156
+ if (seen.every((one) => one.state === 'skipped')) return 'was skipped on the head and on the merge commit';
157
+ return seen.map((one) => (one.state === 'skipped' ? `was skipped on ${SIDE[one.on] ?? one.on}`
158
+ : one.state === 'missing' ? `never ran on ${SIDE[one.on] ?? one.on}` : `ended ${one.conclusion ?? one.state} on ${SIDE[one.on] ?? one.on}`)).join(' and ');
159
+ }
160
+
150
161
  export const requiredWhy = (row) => ({
151
162
  pending: `${row.name} is still running`,
152
163
  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`,
164
+ skipped: `${row.name} ${skippedSides(row)}: a path filter or a condition skipped it, so it proved nothing`,
154
165
  missing: `${row.name} never ran on the head or on the merge commit`,
155
166
  }[row.state] ?? `${row.name} ended ${row.state}`);
156
167
 
@@ -424,6 +435,46 @@ export function createDeliveryVerbs(core) {
424
435
  // passing accepted_red needs the owner-present flag, the same flag that
425
436
  // decision_record needs. A forged record then gives a caller nothing it
426
437
  // could not write itself (security review of this change, finding 1).
438
+ // R17 line 6: a decision record alone does not waive a red check. The owner
439
+ // signs a waiver act with his security key (R72, design
440
+ // docs/design-docs/signed-act/) and posts the block on the pull request.
441
+ // The act names the repo, the pull request, its head, the decision and the
442
+ // check. The key must be in atlas/owner-signers on the base of the merge,
443
+ // its first parent, so a key that the pull request added does not count.
444
+ async function signedWaiver(pull, decisionId, check) {
445
+ const head = String(pull.head_sha ?? '').toLowerCase();
446
+ if (!/^[0-9a-f]{40}$/.test(head)) refuse(`GitHub names no head commit of ${pull.url}, so no signed waiver can match it`);
447
+ const commit = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}`);
448
+ const parent = commit?.parents?.[0]?.sha;
449
+ 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}`);
450
+ // A rebase merge makes the first parent a commit of the pull request, so
451
+ // the key must also be on the merge base of that parent and the head: a
452
+ // commit the pull request did not write (verifier of atlas#240).
453
+ const compared = await github(`repos/${pull.repo}/compare/${parent}...${head}`);
454
+ const forkPoint = compared?.merge_base_commit?.sha;
455
+ if (typeof forkPoint !== 'string' || !/^[0-9a-f]{40}$/i.test(forkPoint)) refuse(`GitHub names no merge base of ${pull.url}, so no signed waiver can be checked`);
456
+ const keysAt = async (ref) => {
457
+ const file = await githubOrNull(`repos/${pull.repo}/contents/${SIGNERS_FILE}?ref=${ref}`);
458
+ return parseSigners(file && typeof file.content === 'string' ? Buffer.from(file.content, 'base64').toString('utf8') : '').keys;
459
+ };
460
+ const atFork = forkPoint.toLowerCase() === parent.toLowerCase() ? null : new Set((await keysAt(forkPoint)).map((key) => key.fingerprint));
461
+ const keys = (await keysAt(parent)).filter((key) => !atFork || atFork.has(key.fingerprint));
462
+ 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`);
463
+ const texts = [];
464
+ for (let page = 1; page <= 10; page += 1) {
465
+ const rows = await github(`repos/${pull.repo}/issues/${pull.number}/comments?per_page=100&page=${page}`);
466
+ for (const row of Array.isArray(rows) ? rows : []) if (typeof row?.body === 'string' && row.body.includes(SIGNED_MARKER)) texts.push(row.body);
467
+ if (!Array.isArray(rows) || rows.length < 100) break;
468
+ }
469
+ const verified = verifyTexts(texts, keys);
470
+ const found = findAct(verified, { kind: 'waiver', repo: pull.repo, pr: pull.number, head, decision: decisionId, check });
471
+ if (!found) {
472
+ const why = verified.find((row) => row.error)?.error;
473
+ 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`);
474
+ }
475
+ return { key: found.key, signed_at: found.signed_at };
476
+ }
477
+
427
478
  async function acceptedRed(value, pulls, item) {
428
479
  if (value === undefined) return [];
429
480
  if (!Array.isArray(value)) refuse('accepted_red must be an array of { pr, check, test, decision_id }');
@@ -471,7 +522,8 @@ export function createDeliveryVerbs(core) {
471
522
  || new RegExp(`(?:^|[^A-Za-z0-9_/.-])#${pull.number}(?![0-9])`).test(words);
472
523
  if (!namesPull) refuse(`decision ${decisionId} does not name ${pull.url}; the owner's words must name the pull request (its URL or #${pull.number})`);
473
524
  if (!words.includes(testName)) refuse(`decision ${decisionId} does not quote the test "${testName}"; the owner's own words must name the exact test`);
474
- rows.push({ pr: pull.url, check, test: testName, decision_id: decision.id, decision_version: decision.version ?? null, decision });
525
+ const waiver = await signedWaiver(pull, decision.id, check);
526
+ rows.push({ pr: pull.url, check, test: testName, decision_id: decision.id, decision_version: decision.version ?? null, waiver, decision });
475
527
  }
476
528
  return rows;
477
529
  }
@@ -639,7 +691,7 @@ export function createDeliveryVerbs(core) {
639
691
  if (!run) refuse(`accepted_red names check ${row.check} on ${pull.url}, but that check is not red in the merge gate; drop the entry`);
640
692
  const { decision: _record, ...kept } = row;
641
693
  acceptedRows.push({ ...kept, conclusion: run.conclusion, run_url: run.url,
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.` });
694
+ detail: `${row.check} concluded ${run.conclusion} for ${pull.url}. The owner accepted the red test "${row.test}" in decision ${row.decision_id}, and signed the waiver with key ${row.waiver.key}. The gate names the check, not the test, so the verb proved the check level only.` });
643
695
  }
644
696
  if (!required.length && !passed.length) refuse(`no check run succeeded for ${pull.url}; the merge gate is the independent record and it says nothing`);
645
697
  // A check name whose LATEST run succeeded is green. A name whose earlier
@@ -0,0 +1,133 @@
1
+ // Roadmap R55 (item 8d87c567): every lesson leaves one record. A lesson is
2
+ // a mark of kind lesson, so the tracker needs no new type. It holds one line,
3
+ // the home, the form, the fix link, the source and the lesson it repeats.
4
+ // The learnings skill reads the lessons before it records one, and records a
5
+ // match as a repeat. The roundup counts them per week.
6
+ //
7
+ // Three rules hold it together:
8
+ // 1. A mark is never edited. A lesson that repeats another names it; the
9
+ // first lesson stays as it was.
10
+ // 2. A repeat names a lesson of the same product. A lesson home in a repo
11
+ // names a registered repo of that product.
12
+ // 3. A failed read throws. It never reads as zero lessons.
13
+ import { refuse, text, oneOf, url } from './verb-fields.mjs';
14
+ import { oneLine, scrubSecrets } from './questions.mjs';
15
+ import { standingOf, isStepIn } from './step-ins.mjs';
16
+ import { isRevert } from './numbers.mjs';
17
+
18
+ export const LESSON_WRITE_VERBS = Object.freeze(['lesson_record']);
19
+ export const LESSON_READ_VERBS = Object.freeze(['lessons']);
20
+ export const LESSON_KIND = 'lesson';
21
+ // Where the fix lives: this repo, or Atlas for every repo.
22
+ export const LESSON_HOMES = Object.freeze(['repo', 'atlas']);
23
+ // The strongest form that fits, in the order of the learnings skill.
24
+ export const LESSON_FORMS = Object.freeze(['check', 'tool', 'skill', 'doc']);
25
+ // What found the miss: the owner's correction, a revert, a bug after a
26
+ // merge, or a machine (a red main branch, a revert job) with no owner words.
27
+ export const LESSON_SOURCES = Object.freeze(['owner', 'revert', 'bug', 'machine']);
28
+ const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
+ const MAX_LISTED = 100;
30
+
31
+ // A link is a URL, or the id of a tracker record such as the item a lesson
32
+ // became.
33
+ function linkOf(value, name, { optional = false } = {}) {
34
+ if (value === undefined && optional) return '';
35
+ const plain = text(value, name, { max: 2000 }).trim();
36
+ if (RECORD_ID.test(plain)) return plain.toLowerCase();
37
+ return url(plain, name);
38
+ }
39
+
40
+ // One rule for a revert and one for a step-in, shared with the numbers of
41
+ // R16: a revert pull request (numbers.mjs), and a step-in that still counts,
42
+ // an owner message whose newest name gives no class (step-ins.mjs).
43
+ export { isRevert };
44
+ export const countedStepIns = (marks) => standingOf(marks).filter(isStepIn).map(({ mark }) => mark);
45
+
46
+ const rowOf = (mark, repeatedBy) => ({
47
+ lesson: mark.id, at: mark.fields.marked_at, line: mark.fields.line, home: mark.fields.home, repo: mark.fields.repo || null,
48
+ form: mark.fields.form, fix: mark.fields.fix, source: mark.fields.source, source_ref: mark.fields.source_ref || null,
49
+ repeats: mark.fields.repeats || null, item: mark.fields.item || null, repeated_by: repeatedBy.get(mark.id) ?? [],
50
+ });
51
+
52
+ export function createLessonVerbs(core) {
53
+ const { client, now, typeNamed, registry, requireProduct, requireItem, create, edge, landed } = core;
54
+
55
+ async function lessonMarks(product) {
56
+ const type = await typeNamed('mark');
57
+ const rows = await client.listAllEntities({ type: type.name, state: 'active' });
58
+ return rows.filter((one) => one.fields?.kind === LESSON_KIND && (!product || one.fields?.product === product));
59
+ }
60
+
61
+ return {
62
+ // R55 line 1: one lesson, one mark. The learnings skill calls it once the
63
+ // fix exists, so the fix link is never empty.
64
+ async lesson_record(args, requestId, meta) {
65
+ const reg = await registry();
66
+ const product = (await requireProduct(args.product, reg)).fields.registry_id;
67
+ const scrubbed = scrubSecrets(text(args.line, 'line', { max: 300 }));
68
+ const line = oneLine(scrubbed.text, 300);
69
+ const home = oneOf(args.home, 'home', LESSON_HOMES);
70
+ let repo = '';
71
+ if (home === 'repo') {
72
+ repo = text(args.repo, 'repo', { max: 200 });
73
+ const record = reg.repos.get(repo);
74
+ if (!record) refuse(`unknown repo id: ${repo}. Known: ${[...reg.repos.keys()].sort().join(', ') || 'none'}`);
75
+ if (record.fields?.product !== product) refuse(`repo ${repo} belongs to product ${record.fields?.product}, not ${product}`);
76
+ } else if (args.repo !== undefined) refuse('repo goes with home "repo" only; an Atlas lesson holds for every repo');
77
+ const form = oneOf(args.form, 'form', LESSON_FORMS);
78
+ const fix = linkOf(args.fix, 'fix');
79
+ const source = oneOf(args.source, 'source', LESSON_SOURCES);
80
+ const sourceRef = linkOf(args.source_ref, 'source_ref', { optional: true });
81
+ const item = args.item_id === undefined ? null : await requireItem(args.item_id);
82
+ let repeated = null;
83
+ if (args.repeats !== undefined) {
84
+ const id = text(args.repeats, 'repeats', { max: 200 });
85
+ let entity;
86
+ try { entity = await client.getEntity(id); }
87
+ catch (error) { if (error?.status === 404) refuse(`unknown lesson id: ${id}`); throw error; }
88
+ const type = await typeNamed('mark');
89
+ if (entity.type_id !== type.id || entity.fields?.kind !== LESSON_KIND) refuse(`entity ${id} is not a lesson mark`);
90
+ if (entity.fields?.product !== product) refuse(`lesson ${id} belongs to product ${entity.fields?.product}, not ${product}`);
91
+ repeated = entity;
92
+ }
93
+ const type = await typeNamed('mark');
94
+ const prior = await landed(type, requestId, meta);
95
+ const mark = prior ?? await create(type, requestId, 'lesson_record', {
96
+ digest: meta?.digest,
97
+ title: oneLine(`Lesson${repeated ? ' (repeat)' : ''} · ${line}`, 200),
98
+ body: `${line}\n\nHome: ${home === 'repo' ? repo : 'Atlas'}. Form: ${form}. Fix: ${fix}. Source: ${source}${sourceRef ? ` (${sourceRef})` : ''}.${repeated ? ` It repeats lesson ${repeated.id}.` : ''}`,
99
+ fields: {
100
+ kind: LESSON_KIND, verdict: repeated ? 'repeat' : 'new', item: item?.id ?? '', product, marked_at: now(),
101
+ line, home, repo, form, fix, source, source_ref: sourceRef, repeats: repeated?.id ?? '',
102
+ },
103
+ tags: ['atlas-mark', 'lesson', `product:${product}`, ...(repeated ? ['repeat'] : [])],
104
+ });
105
+ if (repeated) await edge(mark, repeated, 'references', requestId);
106
+ if (item) await edge(mark, item, 'references', requestId);
107
+ return {
108
+ verb: 'lesson_record', lesson: mark.id, product, line, home, repo: repo || null, form, fix, source, source_ref: sourceRef || null,
109
+ repeats: repeated?.id ?? null, item: item?.id ?? null, ...(scrubbed.redactions ? { redactions: scrubbed.redactions } : {}), ...(prior ? { replayed_from_graph: true } : {}),
110
+ };
111
+ },
112
+
113
+ // R55 line 2: a product's lessons, newest first. Each row lists the
114
+ // lessons that repeat it, so the learnings skill sees what came back.
115
+ async lessons(args = {}) {
116
+ const reg = await registry();
117
+ const product = (await requireProduct(args.product, reg)).fields.registry_id;
118
+ const since = args.since === undefined ? null : text(args.since, 'since', { max: 40 });
119
+ if (since !== null && Number.isNaN(Date.parse(since))) refuse('since must be an ISO date or time');
120
+ const limit = args.limit === undefined ? 20 : Number(args.limit);
121
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LISTED) refuse(`limit must be a whole number from 1 to ${MAX_LISTED}`);
122
+ const marks = await lessonMarks(product);
123
+ const repeatedBy = new Map();
124
+ for (const mark of marks) if (mark.fields.repeats) repeatedBy.set(mark.fields.repeats, [...(repeatedBy.get(mark.fields.repeats) ?? []), mark.id]);
125
+ const rows = marks.filter((mark) => !since || String(mark.fields.marked_at) >= new Date(since).toISOString())
126
+ .sort((a, b) => String(b.fields.marked_at).localeCompare(String(a.fields.marked_at)));
127
+ return {
128
+ verb: 'lessons', product, total: rows.length, repeats: rows.filter((mark) => mark.fields.repeats).length,
129
+ rows: rows.slice(0, limit).map((mark) => rowOf(mark, repeatedBy)),
130
+ };
131
+ },
132
+ };
133
+ }
@@ -176,8 +176,9 @@ export function notOnGreenReasons({ required = [], runs = [], mergedAt }) {
176
176
  }
177
177
 
178
178
  // A merged pull request that undoes another: GitHub's revert button writes
179
- // both of these.
180
- export const isRevert = (pull) => /^revert\b/i.test(String(pull.title ?? '').trim()) || /^Reverts [A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#\d+/m.test(String(pull.body ?? ''));
179
+ // both of these. The roundup's rows keep the body inside names
180
+ // (delivery.mjs).
181
+ export const isRevert = (pull) => /^revert\b/i.test(String(pull.title ?? '').trim()) || /^Reverts [A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#\d+/m.test(String(pull.body ?? pull.names ?? ''));
181
182
 
182
183
  // The old tests that one merge changed or removed. An added test is new, so
183
184
  // it is not here.
@@ -12,7 +12,9 @@
12
12
  // but not marked, open pull requests that name no item,
13
13
  // and questions that wait longer than STUCK_DAYS
14
14
  // 5. Trend per week, for TREND_WEEKS weeks: merges, deliveries,
15
- // new items; and the open questions with the oldest age
15
+ // new items, lessons, repeats, step-ins and reverts
16
+ // (roadmap R55); and the open questions with the
17
+ // oldest age. A failed read of the marks is an error.
16
18
  // 6. The numbers per repo, per week and per month: merges, merges not
17
19
  // on green, reverts, reopens, unchecked test changes,
18
20
  // step-ins and owner messages; and the time from input
@@ -24,6 +26,7 @@ import { refuse, isoDate, LIVE_LIFECYCLES } from './verb-fields.mjs';
24
26
  import { oneLine, ROW_LINE_MAX } from './questions.mjs';
25
27
  import { readPullsByRepo, pullsOfItem, daysBetween, listLine } from './delivery.mjs';
26
28
  import { readNumbers, periodOf } from './numbers.mjs';
29
+ import { LESSON_KIND, countedStepIns, isRevert } from './lessons.mjs';
27
30
 
28
31
  export const ROUNDUP_READ_VERBS = Object.freeze(['roundup']);
29
32
 
@@ -174,17 +177,29 @@ export function createRoundupVerbs(core) {
174
177
  const audits = await readAudits(repoIds, readGitHub, readAt);
175
178
 
176
179
  // 5. Trend, per week; week 1 is the last seven days.
177
- const weeks = Array.from({ length: TREND_WEEKS }, (_, index) => ({ week: index + 1, from: dayOf(minus(readAt, (index + 1) * 7)), to: dayOf(minus(readAt, index * 7)), merged: 0, delivered: 0, new_items: 0 }));
178
- const bump = (at, key) => { const week = weekOf(at, readAt); if (week) weeks[week - 1][key] += 1; };
180
+ // Lessons, repeats and step-ins come from the marks; reverts from the
181
+ // merged pull requests. When GitHub did not answer for a repo, the
182
+ // reverts read null, never 0 (R55 line 4).
183
+ // One read of the marks serves part 5 and part 6.
184
+ const markType = await typeNamed('mark');
185
+ const marks = await client.listAllEntities({ type: markType.name, state: 'active' });
186
+ const productMarks = marks.filter((row) => row.fields?.product === product);
187
+ const revertsKnown = !repoErrors.length;
188
+ const weeks = Array.from({ length: TREND_WEEKS }, (_, index) => ({ week: index + 1, from: dayOf(minus(readAt, (index + 1) * 7)), to: dayOf(minus(readAt, index * 7)), merged: 0, delivered: 0, new_items: 0, lessons: 0, repeats: 0, step_ins: 0, reverts: revertsKnown ? 0 : null }));
189
+ const bump = (at, key) => { const week = weekOf(at, readAt); if (week && weeks[week - 1][key] !== null) weeks[week - 1][key] += 1; };
179
190
  for (const pull of pulls) if (pull.state === 'merged') bump(pull.merged_at, 'merged');
191
+ for (const pull of pulls) if (pull.state === 'merged' && isRevert(pull)) bump(pull.merged_at, 'reverts');
180
192
  for (const row of flight.delivered ?? []) bump(row.delivered_at, 'delivered');
181
193
  for (const row of items) bump(row.created_at, 'new_items');
194
+ for (const mark of productMarks.filter((row) => row.fields?.kind === LESSON_KIND)) {
195
+ bump(mark.fields.marked_at, 'lessons');
196
+ if (mark.fields.repeats) bump(mark.fields.marked_at, 'repeats');
197
+ }
198
+ for (const mark of countedStepIns(productMarks)) bump(mark.fields.marked_at, 'step_ins');
182
199
  const oldest = needRows.reduce((max, row) => Math.max(max, row.days_waiting ?? 0), 0);
183
200
 
184
201
  // 6. The numbers. They read every pull request of the period, not only
185
202
  // the newest 100, and the marks of the tracker.
186
- const markType = await typeNamed('mark');
187
- const marks = await client.listAllEntities({ type: markType.name, state: 'active' });
188
203
  const numbers = await readNumbers({
189
204
  product, items, marks, period, readGitHub,
190
205
  repos: repoIds.map((id) => ({ id, default_branch: reg.repos.get(id)?.fields?.default_branch ?? null })),
@@ -241,7 +256,7 @@ function render(view) {
241
256
  out.push(stuck.join('\n'));
242
257
 
243
258
  out.push([`5. Trend, per week (week 1 is the last seven days)`,
244
- ...weeks.map((week) => ` week ${week.week} (${week.from} to ${week.to}): ${plural(week.merged, 'merge')} · ${plural(week.delivered, 'delivery')} · ${plural(week.new_items, 'new item')}`),
259
+ ...weeks.map((week) => ` week ${week.week} (${week.from} to ${week.to}): ${plural(week.merged, 'merge')} · ${plural(week.delivered, 'delivery')} · ${plural(week.new_items, 'new item')} · ${plural(week.lessons, 'lesson')} (${plural(week.repeats, 'repeat')}) · ${plural(week.step_ins, 'step-in')} · ${week.reverts === null ? 'reverts unknown' : plural(week.reverts, 'revert')}`),
245
260
  ` waiting on you: ${plural(needRows.length, 'question')}${needRows.length ? `, oldest ${plural(oldest, 'day')}` : ''}`].join('\n'));
246
261
 
247
262
  out.push(renderNumbers(numbers, more));
@@ -22,6 +22,7 @@ import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capabilit
22
22
  import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
23
23
  import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
24
24
  import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
25
+ import { createLessonVerbs, LESSON_WRITE_VERBS, LESSON_READ_VERBS } from './lessons.mjs';
25
26
 
26
27
  export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
27
28
  export const MANIFEST_VERSION = '0.7.0';
@@ -754,9 +755,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
754
755
  // through record and notCounted, which no tool exposes.
755
756
  const stepIns = createStepInVerbs(core);
756
757
  Object.assign(verbs, stepIns.verbs);
758
+ // Roadmap R55: every lesson leaves one record, a mark of kind lesson.
759
+ Object.assign(verbs, createLessonVerbs(core));
757
760
 
758
- const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS]);
759
- const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS]);
761
+ const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS, ...LESSON_WRITE_VERBS]);
762
+ const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS, ...LESSON_READ_VERBS]);
760
763
 
761
764
  async function run(verb, args = {}) {
762
765
  if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
package/work/mcp.mjs CHANGED
@@ -23,6 +23,7 @@ import { createActionStore } from './lib/action-store.mjs';
23
23
  import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
24
24
  import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
25
25
  import { STEP_IN_CLASSES, stepInFolder, flushStepIns } from './lib/step-ins.mjs';
26
+ import { LESSON_HOMES, LESSON_FORMS, LESSON_SOURCES } from './lib/lessons.mjs';
26
27
  import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
27
28
  import { createMcpServer } from './lib/mcp-stdio.mjs';
28
29
  import { childEnvironment, CLOUD_NODE_VARIABLES } from './lib/child-env.mjs';
@@ -61,11 +62,13 @@ export const TOOLS = [
61
62
  { name: 'item_activity', description: 'Answer "what happened on this item?". One list, newest first: the comments, each change an item verb made with its old and new values, the questions, the decisions, the delivery marks, the pieces and the links. A write that kept no values, such as one from before manifest 0.7.0, shows its verb and time with "no values recorded". The edits kept on the item before 0.7.0 show too. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['item_id'], properties: { item_id: S('Item id.'), limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many rows, newest first. Default 50.' } }, additionalProperties: false } },
62
63
  { name: 'step_in_name', description: `Name what one message of the person was, or link the lesson it led to (roadmap R15). The step-in hook counts each message the person sends, and the server writes each one as a step-in mark; this verb never adds, skips or deletes one. It writes a new mark that corrects the step-in, and the newest name stands. A message with no class is a step-in. The classes: ${STEP_IN_CLASSES.join(', ')}. The first eight are the classes of the owner-only list. "answer" needs question_id, a question that was open when the message came. When: the lead knows what the message was, or a lesson came out of it. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'step_in'], properties: { request_id: REQUEST_ID, step_in: S('The step-in mark id, from step_ins.'), class: { type: 'string', enum: [...STEP_IN_CLASSES], description: 'What the message was. Leave it out to keep it a step-in and only link a lesson.' }, question_id: S('For class answer: the question the message answered.'), item_id: S('The item the message was about. An answer takes the item of its question.'), lesson: S('The lesson the step-in led to: a URL, or the id of a tracker record.') }, additionalProperties: false } },
63
64
  { name: 'step_ins', description: 'List the step-in marks, newest first, with the name that stands on each: the time, the session, the repos, and the class and lesson the lead named. step_ins counts the messages with no class; not_counted_sessions counts the sessions whose hooks were off. When: before step_in_name, to find the id. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { session: S('Only this session.'), since: S('ISO date or time: only step-ins from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
65
+ { name: 'lesson_record', description: `Record one lesson as a mark of kind lesson (roadmap R55): one line, its home, its form, the fix link, the source and the lesson it repeats. A mark is never edited. When: the learnings skill made the fix; call lessons first, and pass repeats when an earlier lesson says the same thing. The line goes through the secret filter. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'product', 'line', 'home', 'form', 'fix', 'source'], properties: { request_id: REQUEST_ID, product: S('Product id.'), line: S('The lesson in one plain sentence: what went wrong and what is right. At most 300 characters.'), home: { type: 'string', enum: [...LESSON_HOMES], description: 'repo: the lesson holds for one repo. atlas: it holds for every repo.' }, repo: S('For home repo: the registered repo id of this product.'), form: { type: 'string', enum: [...LESSON_FORMS], description: 'The form of the fix: a check, a tool, a skill step or a doc line.' }, fix: S('The fix: a pull request URL, or the id of the tracker item the lesson became.'), source: { type: 'string', enum: [...LESSON_SOURCES], description: 'What found the miss: owner (a correction), revert, bug (after a merge), or machine (no owner words).' }, source_ref: S('Where the miss shows: the step-in mark id, the revert pull request, or the bug issue.'), repeats: S('The id of an earlier lesson of this product that this lesson repeats.'), item_id: S('The work item the lesson is about.') }, additionalProperties: false } },
66
+ { name: 'lessons', description: 'List the lessons of one product, newest first (roadmap R55). Each row gives the line, the home, the form, the fix, the source, the lesson it repeats and the lessons that repeat it. total counts the lessons; repeats counts the repeats. A failed read is an error, never zero lessons. When: the learnings skill calls it before lesson_record, to find a match. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), since: S('ISO date or time: only lessons from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
64
67
  { name: 'item_link', description: `Attach a typed link (kind, url, role, note) to an item: a PR, an issue, an artifact, a design doc, a conversation. When: any in-session agent has a link the item should carry, for example a PR it opened. No owner flag needed. A duplicate kind and url is a no-op. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'kind', 'url'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), kind: S(`One of ${LINK_KINDS.join(', ')}.`), url: S('Absolute http or https URL.'), role: S('What the link is to the item, for example "delivers", "design", "evidence".'), note: S('One line.') }, additionalProperties: false } },
65
68
  { 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. Any in-session agent may call it; no owner flag. 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 } },
66
69
  { 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 } },
67
70
  { 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 } },
68
- { 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 } },
71
+ { 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; the key must be in atlas/owner-signers on the base of the merge. 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 } },
69
72
  { 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 } },
70
73
  { 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 } },
71
74
  { 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, no owner flag needed. 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 } },
@@ -210,6 +213,10 @@ export const GITHUB_READS = Object.freeze([
210
213
  new RegExp(`^repos/${REPO}/contents/atlas\\.yaml\\?ref=(?:${SHA}|${BRANCH})$`),
211
214
  new RegExp(`^repos/${REPO}/releases\\?per_page=\\d{1,3}$`),
212
215
  new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${TAG}$`),
216
+ // R17 line 6: the signed waiver on the comments of a pull request, and the
217
+ // owner's key file on the base of its merge.
218
+ new RegExp(`^repos/${REPO}/issues/\\d{1,9}/comments\\?per_page=100&page=\\d{1,2}$`),
219
+ new RegExp(`^repos/${REPO}/contents/atlas/owner-signers\\?ref=[0-9a-fA-F]{40}$`),
213
220
  // Roadmap R16: the numbers page past 100 pull requests, and read the
214
221
  // files of each merge to find a changed test.
215
222
  new RegExp(`^repos/${REPO}/pulls\\?state=all&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,3}$`),