@mmnto/totem 2.5.0 → 2.7.0

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,4 +1,4 @@
1
- import { isBotReviewerLoginExact } from './bot-identity.js';
1
+ import { hasBotAppLoginSuffix, isBotReviewerLoginExact } from './bot-identity.js';
2
2
  import { TotemError } from './errors.js';
3
3
  import { safeExec } from './sys/exec.js';
4
4
  /**
@@ -16,8 +16,17 @@ import { safeExec } from './sys/exec.js';
16
16
  * ROOT comment is one of the known review bots
17
17
  * 3. `changes-requested` — no un-superseded CHANGES_REQUESTED review
18
18
  * 4. `high-severity-inline` — no HIGH/Major bot inline that CURRENTLY applies
19
- * to the head commit (`comment.commit.oid`,
20
- * thread resolution ignored)
19
+ * to the head commit (`comment.commit.oid`) and
20
+ * is not DISCHARGED through the disposition path
21
+ * (mmnto-ai/totem#2861): its thread RESOLVED plus
22
+ * a disposition NAMING IT — a non-bot reply
23
+ * after the root inside the thread, or a
24
+ * non-bot PR-level comment created after the
25
+ * root carrying a `disposition: <root comment
26
+ * id> <verb>` line for this thread. A bare
27
+ * resolve, or a round disposition that did not
28
+ * name the thread, still applies at every tier
29
+ * (fail-closed)
21
30
  * 5. `merge-state` — GitHub's own `mergeStateStatus` is mergeable
22
31
  *
23
32
  * THE TIER SPLIT (R1): a predicate that FAILS is `deny` at every tier (the
@@ -52,13 +61,34 @@ export const MERGE_READY_EVIDENCE_MAX = 160;
52
61
  // ─── The read surface (R2: one GraphQL query, explicit cursors, no REST) ────
53
62
  /**
54
63
  * The PR fields every predicate reads, as a fragment so the number-keyed and
55
- * branch-keyed documents cannot drift apart. The three paginated connections
64
+ * branch-keyed documents cannot drift apart. The four paginated connections
56
65
  * each take their own cursor variable, declared by the operation.
57
66
  *
58
- * `comments(first: 10)` is deliberate: only the ROOT comment of a thread is
67
+ * `comments(first: 10)` on a thread is deliberate: only the ROOT comment is
59
68
  * judged (it is the finding; the rest are the discussion), matching how triage
60
- * reads a thread. The later comments are fetched for evidence, not for a
61
- * predicate, so a thread with more than ten comments is not an incomplete read.
69
+ * reads a thread. The later comments are read for EVIDENCE only — a non-bot
70
+ * reply after the root discharges a resolved HIGH (mmnto-ai/totem#2861) — and
71
+ * the window's `pageInfo` is selected too, so a thread with more replies than
72
+ * the window is judged on what was read and SAYS so in the reason, never read
73
+ * as "no reply" silently. The root is `comments.nodes[0]`: the one capture in
74
+ * `gate-fixtures/merge-ready/` with a multi-comment thread (liquid-city-363)
75
+ * answers root first, then the reply, and `resolve-threads` reads the same
76
+ * position — a positional assumption shared by both consumers and resting on
77
+ * that one observation, not a transcribed schema guarantee, which is why the
78
+ * reply test below is ALSO temporal (a reply counts only when its `createdAt`
79
+ * follows the root's; across sixteen live PRs no reply preceded its root). The
80
+ * root's `databaseId` is its REST comment id — the `id=` a `totem
81
+ * resolve-threads` dry-run row prints (`totem triage-pr` carries it internally
82
+ * and prints none: the third leg's r3-f2), and the id a PR-level disposition
83
+ * line names.
84
+ * The PR-level `comments` connection is the other evidence surface: a non-bot
85
+ * comment created after a thread's root whose BODY carries a
86
+ * `disposition: <root comment id> <verb>` line for THAT thread (the review-reply
87
+ * skill's step 2 emits one per bot thread the round answered) — paginated in
88
+ * full like the rest, bodies included. `author { __typename }` is selected on
89
+ * both because it is the one signal that names EVERY GitHub App a bot (the
90
+ * resolve-threads rule): without it an App's reply would read as the human
91
+ * answer.
62
92
  */
63
93
  const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
64
94
  number
@@ -78,7 +108,7 @@ const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
78
108
  pageInfo { hasNextPage endCursor }
79
109
  nodes {
80
110
  __typename
81
- ... on CheckRun { name status conclusion }
111
+ ... on CheckRun { name status conclusion databaseId checkSuite { databaseId app { slug } workflowRun { workflow { databaseId name } } } }
82
112
  ... on StatusContext { context state }
83
113
  }
84
114
  }
@@ -100,17 +130,28 @@ const MERGE_READY_FRAGMENT = `fragment MergeReadyPr on PullRequest {
100
130
  isResolved
101
131
  isOutdated
102
132
  comments(first: 10) {
133
+ pageInfo { hasNextPage }
103
134
  nodes {
104
- author { login }
135
+ databaseId
136
+ author { __typename login }
105
137
  body
138
+ createdAt
106
139
  commit { oid }
107
140
  originalCommit { oid }
108
141
  }
109
142
  }
110
143
  }
111
144
  }
145
+ comments(first: ${PAGE_SIZE}, after: $commentsAfter) {
146
+ pageInfo { hasNextPage endCursor }
147
+ nodes {
148
+ author { __typename login }
149
+ body
150
+ createdAt
151
+ }
152
+ }
112
153
  }`;
113
- const SHARED_VARS = '$owner: String!, $name: String!, $reviewsAfter: String, $threadsAfter: String, $checksAfter: String';
154
+ const SHARED_VARS = '$owner: String!, $name: String!, $reviewsAfter: String, $threadsAfter: String, $checksAfter: String, $commentsAfter: String';
114
155
  /** The read the evaluator sends when the payload names a PR number. Fixtures are captured with THIS string. */
115
156
  export const MERGE_READY_QUERY = `query TotemMergeReady($number: Int!, ${SHARED_VARS}) {
116
157
  repository(owner: $owner, name: $name) {
@@ -207,6 +248,71 @@ function asArray(value) {
207
248
  function asString(value) {
208
249
  return typeof value === 'string' ? value : null;
209
250
  }
251
+ /**
252
+ * A non-negative SAFE integer as the API typed it; anything else is null,
253
+ * never coerced. Safe, not merely integral: an id beyond 2^53 would have been
254
+ * rounded by JSON.parse, and two distinct ids rounded to one number would be
255
+ * ordered wrongly (bot round 1 on mmnto-ai/totem#2879, Greptile P2) — the
256
+ * review-comment id reader already refuses the same way.
257
+ */
258
+ function asNonNegativeInteger(value) {
259
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
260
+ }
261
+ /**
262
+ * The per-thread disposition line (mmnto-ai/totem#2861, the operator's ruling
263
+ * of 2026-09-16 on the linkage fork): the review-reply skill's step 2 ends a
264
+ * round disposition with ONE machine line per bot-rooted thread the round
265
+ * answered —
266
+ *
267
+ * disposition: <root comment id> <verb>
268
+ *
269
+ * — the id being the thread root's REST comment id (`databaseId`; the `id=`
270
+ * on a `totem resolve-threads` dry-run row, the one command that prints it —
271
+ * `totem triage-pr` carries it internally and prints none) and the verb the
272
+ * round's word for it (fixed, declined, deferred,
273
+ * nit, extracted, held). Predicate 4 reads the ID: a PR-level comment names
274
+ * a thread when a line carries that thread's root id — and a round
275
+ * disposition that answered OTHER threads does not name this one. That last
276
+ * case is why the line exists: two earlier reads keyed the arm to the ROUND
277
+ * (post-dating alone, then post-dating plus the round's `local-lane:` line),
278
+ * and both let a bare resolve of a HIGH the round never addressed discharge —
279
+ * the two falsification legs' blocking findings, executed on the built core.
280
+ * Naming the thread is what the ruling asked for, and an id is exact where a
281
+ * marker was a shape. By construction no other surface writes a line of this
282
+ * shape: a trigger comment, a gate-read note or a merge note carries no id at
283
+ * line start, and the gate's own deny reason quotes the line inside double
284
+ * quotes mid-sentence (the third leg tried the reason verbatim, fenced,
285
+ * unquoted, and a pasted resolve-threads row, and none read as a line) — an
286
+ * inference about bodies, made safe by the id rather than proven.
287
+ *
288
+ * Read against the raw body with HTML comments removed (a terminated
289
+ * `<!-- … -->`, and an unterminated `<!--` to the end of the body, the way a
290
+ * renderer hides it), at line start with leading blanks allowed, anywhere in
291
+ * the body — fenced included, because the skills render machine lines in text
292
+ * fences. A blockquoted, listed or tabled line, or one inside an inline span,
293
+ * is not at line start and does not count. The id is the decimal the seat
294
+ * copied: no leading zero, no sign, no fraction, nothing glued to it — the
295
+ * third leg's r3-f5 showed `Number()` equating `01001`, `1001.5` and `1001x`
296
+ * to 1001, so the token is matched exactly and never coerced. The verb is not
297
+ * validated here: the gate answers "was this thread dispositioned", not
298
+ * "how".
299
+ */
300
+ const DISPOSITION_LINE = /^[ \t]*disposition:[ \t]+([1-9]\d*)(?=[ \t]|\r?$)/gm;
301
+ /**
302
+ * The root comment ids a PR-level comment body names on disposition lines,
303
+ * in first-seen order, without duplicates. Exported for the tests, so they
304
+ * assert the shipped predicate and not a copy of it.
305
+ */
306
+ export function dispositionedRootIds(body) {
307
+ const ids = [];
308
+ const stripped = body.replace(/<!--[\s\S]*?-->/g, '').replace(/<!--[\s\S]*$/, '');
309
+ for (const match of stripped.matchAll(DISPOSITION_LINE)) {
310
+ const id = Number(match[1]);
311
+ if (Number.isSafeInteger(id) && !ids.includes(id))
312
+ ids.push(id);
313
+ }
314
+ return ids;
315
+ }
210
316
  /**
211
317
  * A CheckRun's conclusion vocabulary. `NEUTRAL` and `SKIPPED` are successes (a
212
318
  * skipped required check is branch protection's business, not this gate's);
@@ -215,6 +321,15 @@ function asString(value) {
215
321
  */
216
322
  const SUCCESS_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
217
323
  const SUCCESS_CONTEXT_STATES = new Set(['SUCCESS']);
324
+ /**
325
+ * The name a CheckRun gets when GitHub's answer carried none. `CheckRun.name`
326
+ * is NON_NULL in the schema, so this is reachable only from a malformed or
327
+ * mocked payload — and two such nodes must never be read as one check ran
328
+ * twice (leg F10 on mmnto-ai/totem#2879): the latest-run collapse skips it.
329
+ */
330
+ const UNNAMED_CHECK = '(unnamed check)';
331
+ /** The app slug GitHub Actions posts check runs under; its runs carry a workflow, and one without a readable workflow id is a producer that did not read. */
332
+ const ACTIONS_APP = 'github-actions';
218
333
  const PENDING_CONTEXT_STATES = new Set(['PENDING', 'EXPECTED']);
219
334
  /**
220
335
  * A connection's `pageInfo`, read STRICTLY: the query asks for it on every
@@ -255,30 +370,69 @@ function readChecks(rollup) {
255
370
  return { entries, hasNext: false, cursor: null, detail: 'a check node was not an object' };
256
371
  const typename = asString(n.__typename);
257
372
  if (typename === 'CheckRun') {
258
- const name = asString(n.name) ?? '(unnamed check)';
373
+ const readName = asString(n.name);
374
+ const name = readName ?? UNNAMED_CHECK;
375
+ const named = readName !== null;
259
376
  const status = asString(n.status) ?? '';
260
377
  const conclusion = asString(n.conclusion);
378
+ const runId = asNonNegativeInteger(n.databaseId);
379
+ const suite = asObject(n.checkSuite);
380
+ const suiteId = asNonNegativeInteger(suite?.databaseId);
381
+ const appSlug = asString(asObject(suite?.app)?.slug);
382
+ const workflow = asObject(asObject(suite?.workflowRun)?.workflow);
383
+ const workflowId = asNonNegativeInteger(workflow?.databaseId);
384
+ const workflowName = asString(workflow?.name);
385
+ // Actions without a readable workflow id is NOT "one producer for every
386
+ // workflow" — it is a producer that did not read (fail closed).
387
+ const producer = appSlug === null
388
+ ? null
389
+ : workflowId !== null
390
+ ? `${appSlug}/${String(workflowId)}`
391
+ : appSlug === ACTIONS_APP
392
+ ? null
393
+ : appSlug;
394
+ const producerLabel = appSlug === null ? null : workflowName === null ? appSlug : `${appSlug}/${workflowName}`;
395
+ const shared = {
396
+ name,
397
+ typename: 'CheckRun',
398
+ named,
399
+ producer,
400
+ producerLabel,
401
+ suiteId,
402
+ runId,
403
+ };
261
404
  if (status !== 'COMPLETED') {
262
- entries.push({ name, kind: 'pending' });
405
+ entries.push({ ...shared, kind: 'pending' });
263
406
  }
264
407
  else if (conclusion !== null && SUCCESS_CONCLUSIONS.has(conclusion)) {
265
- entries.push({ name, kind: 'success' });
408
+ entries.push({ ...shared, kind: 'success' });
266
409
  }
267
410
  else {
268
- entries.push({ name, kind: 'failing' });
411
+ entries.push({ ...shared, kind: 'failing' });
269
412
  }
270
413
  }
271
414
  else if (typename === 'StatusContext') {
272
- const name = asString(n.context) ?? '(unnamed context)';
415
+ const readName = asString(n.context);
416
+ const name = readName ?? '(unnamed context)';
417
+ const named = readName !== null;
273
418
  const state = asString(n.state) ?? '';
419
+ const shared = {
420
+ name,
421
+ typename: 'StatusContext',
422
+ named,
423
+ producer: null,
424
+ producerLabel: null,
425
+ suiteId: null,
426
+ runId: null,
427
+ };
274
428
  if (SUCCESS_CONTEXT_STATES.has(state)) {
275
- entries.push({ name, kind: 'success' });
429
+ entries.push({ ...shared, kind: 'success' });
276
430
  }
277
431
  else if (PENDING_CONTEXT_STATES.has(state)) {
278
- entries.push({ name, kind: 'pending' });
432
+ entries.push({ ...shared, kind: 'pending' });
279
433
  }
280
434
  else {
281
- entries.push({ name, kind: 'failing' });
435
+ entries.push({ ...shared, kind: 'failing' });
282
436
  }
283
437
  }
284
438
  else {
@@ -342,6 +496,43 @@ function readReviews(connection) {
342
496
  return unreadable(info.detail);
343
497
  return { entries, hasNext: info.hasNext, cursor: info.cursor };
344
498
  }
499
+ /**
500
+ * A comment's author, read STRICTLY for the evidence rule (mmnto-ai/totem#2861).
501
+ * `null` is a deleted account — NOT a bot: the reply was human when it was
502
+ * written, and deleting the account does not retract it (the resolve-threads
503
+ * rule, item 6 of `.totem/specs/2841.md`). Otherwise `__typename` and `login`
504
+ * must both be strings. `__typename` is REQUIRED, not optional, because it is
505
+ * the only signal that names EVERY GitHub App a bot (`github-actions`, a
506
+ * Copilot reviewer, a scanner): a document that quietly stopped selecting it
507
+ * would fail OPEN, every App reply reading as the human answer — the same
508
+ * guard resolve-threads holds at its zod boundary. The three bot arms are the
509
+ * verb's: GitHub's own `Bot` typename, the `[bot]` App suffix, or core's exact
510
+ * review-bot list.
511
+ */
512
+ function readAuthor(value) {
513
+ if (value === null)
514
+ return { ok: true, login: null, isBot: false };
515
+ const author = asObject(value);
516
+ if (author === null)
517
+ return { ok: false, detail: 'author is neither null nor an object' };
518
+ const typename = asString(author.__typename);
519
+ const login = asString(author.login);
520
+ if (typename === null || login === null) {
521
+ return { ok: false, detail: 'author has no __typename or no login' };
522
+ }
523
+ // On every capture the `Bot` typename is the arm that decides for a review
524
+ // bot (their GraphQL logins carry no suffix); the two login arms are the
525
+ // verb's belt-and-braces, kept so the two consumers of the rule agree. The
526
+ // null-author rule applies on BOTH surfaces: a deleted account's PR-level
527
+ // comment carrying a disposition line discharges the thread it names — not
528
+ // guarded, judged unreachable (a removed App's summary would have to carry
529
+ // this thread's root id at line start; the third leg's r3-f11).
530
+ return {
531
+ ok: true,
532
+ login,
533
+ isBot: typename === 'Bot' || hasBotAppLoginSuffix(login) || isBotReviewerLoginExact(login),
534
+ };
535
+ }
345
536
  /**
346
537
  * The `reviewThreads` connection, read with the same strictness as
347
538
  * {@link readReviews}: a missing connection, a missing `nodes` array or
@@ -352,6 +543,16 @@ function readReviews(connection) {
352
543
  * (mmnto-ai/totem#2844 round 1). A root comment whose author is a deleted
353
544
  * account keeps `rootLogin: null` — it is not a known bot, which is a fact
354
545
  * about the thread, not an unreadable answer.
546
+ *
547
+ * The evidence fields (mmnto-ai/totem#2861) are held to the same bar: the
548
+ * comments window's own `pageInfo`, the root's `createdAt`, and every
549
+ * author's `__typename` — a thread missing any of them is unreadable, never
550
+ * "complete with no reply", because that is the shape a discharge would fail
551
+ * open on. The root's `databaseId` is the one field read the other way: the
552
+ * schema types it nullable, and a root without one cannot be named by any
553
+ * line, so that THREAD stays applying (fail-closed) while the page stays
554
+ * readable — refusing the page would send a PR into the unevaluable class and
555
+ * pilot's `warn` (r3-f4).
355
556
  */
356
557
  function readThreads(connection) {
357
558
  const entries = [];
@@ -370,17 +571,59 @@ function readThreads(connection) {
370
571
  const n = asObject(node);
371
572
  if (n === null)
372
573
  return unreadable('carried a node that is not an object');
373
- const comments = asArray(asObject(n.comments)?.nodes);
574
+ const commentsConnection = asObject(n.comments);
575
+ const comments = asArray(commentsConnection?.nodes);
374
576
  if (comments === null)
375
577
  return unreadable('carried a thread with no comments array');
376
578
  const root = asObject(comments[0]);
377
579
  if (root === null)
378
580
  return unreadable('carried a thread whose root comment is unreadable');
581
+ const window = readPageInfo(commentsConnection);
582
+ if (!window.ok) {
583
+ return unreadable(`carried a thread whose comments connection ${window.detail}`);
584
+ }
585
+ const rootAuthor = readAuthor(root.author);
586
+ if (!rootAuthor.ok)
587
+ return unreadable(`carried a thread whose root ${rootAuthor.detail}`);
588
+ const rootCreatedAt = asString(root.createdAt);
589
+ if (rootCreatedAt === null) {
590
+ return unreadable('carried a thread whose root comment has no createdAt');
591
+ }
592
+ // The root's REST id is what a disposition line names; a root without a
593
+ // safe-integer one cannot be matched to any line and its thread stays
594
+ // applying — thread-scoped, never a page refusal (mmnto-ai/totem#2861).
595
+ const rootId = typeof root.databaseId === 'number' && Number.isSafeInteger(root.databaseId)
596
+ ? root.databaseId
597
+ : null;
598
+ const rootAt = Date.parse(rootCreatedAt);
599
+ let humanReplies = 0;
600
+ for (const reply of comments.slice(1)) {
601
+ const r = asObject(reply);
602
+ if (r === null)
603
+ return unreadable('carried a thread with a reply that is not an object');
604
+ const author = readAuthor(r.author);
605
+ if (!author.ok)
606
+ return unreadable(`carried a thread with a reply whose ${author.detail}`);
607
+ const replyCreatedAt = asString(r.createdAt);
608
+ if (replyCreatedAt === null)
609
+ return unreadable('carried a thread with a reply that has no createdAt');
610
+ // Positional AND temporal: a non-bot comment after the root in the list
611
+ // that also post-dates it. An unparseable instant on either side is not
612
+ // a reply that counts — the conservative direction.
613
+ const replyAt = Date.parse(replyCreatedAt);
614
+ if (!author.isBot && !Number.isNaN(rootAt) && !Number.isNaN(replyAt) && replyAt > rootAt) {
615
+ humanReplies++;
616
+ }
617
+ }
379
618
  entries.push({
380
619
  isResolved: n.isResolved === true,
381
620
  isOutdated: n.isOutdated === true,
382
- rootLogin: asString(asObject(root.author)?.login),
621
+ rootLogin: rootAuthor.login,
383
622
  rootBody: asString(root.body) ?? '',
623
+ rootId,
624
+ rootCreatedAt,
625
+ humanReplies,
626
+ commentsComplete: !window.hasNext,
384
627
  rootCommit: asString(asObject(root.commit)?.oid),
385
628
  rootOriginalCommit: asString(asObject(root.originalCommit)?.oid),
386
629
  });
@@ -390,6 +633,47 @@ function readThreads(connection) {
390
633
  return unreadable(info.detail);
391
634
  return { entries, hasNext: info.hasNext, cursor: info.cursor };
392
635
  }
636
+ /**
637
+ * The PR-level `comments` connection — the second evidence surface
638
+ * (mmnto-ai/totem#2861), read as strictly as the others: a missing connection,
639
+ * a missing `nodes` array or `pageInfo`, a node that is not an object, an
640
+ * author without `__typename` / `login`, or a comment without a string `body`
641
+ * or `createdAt` is an UNREADABLE page, named — never "no evidence", which
642
+ * would deny a dispositioned HIGH for a reason that misnames its cause.
643
+ */
644
+ function readPrComments(connection) {
645
+ const entries = [];
646
+ const unreadable = (why) => ({
647
+ entries,
648
+ hasNext: false,
649
+ cursor: null,
650
+ detail: `the PR comments connection ${why}`,
651
+ });
652
+ if (connection === null)
653
+ return unreadable('was missing from the answer');
654
+ const nodes = asArray(connection.nodes);
655
+ if (nodes === null)
656
+ return unreadable('carried no nodes array');
657
+ for (const node of nodes) {
658
+ const n = asObject(node);
659
+ if (n === null)
660
+ return unreadable('carried a node that is not an object');
661
+ const author = readAuthor(n.author);
662
+ if (!author.ok)
663
+ return unreadable(`carried a comment whose ${author.detail}`);
664
+ const body = asString(n.body);
665
+ if (body === null)
666
+ return unreadable('carried a comment with no body');
667
+ const createdAt = asString(n.createdAt);
668
+ if (createdAt === null)
669
+ return unreadable('carried a comment with no createdAt');
670
+ entries.push({ isBot: author.isBot, createdAt, dispositions: dispositionedRootIds(body) });
671
+ }
672
+ const info = readPageInfo(connection);
673
+ if (!info.ok)
674
+ return unreadable(info.detail);
675
+ return { entries, hasNext: info.hasNext, cursor: info.cursor };
676
+ }
393
677
  /** Read one `gh api graphql` response body into a classified page. */
394
678
  function parsePage(raw, byBranch) {
395
679
  let body;
@@ -488,6 +772,10 @@ function parsePage(raw, byBranch) {
488
772
  const threads = readThreads(asObject(pr.reviewThreads));
489
773
  if (threads.detail !== undefined)
490
774
  return { ok: false, detail: threads.detail };
775
+ // The evidence surface (mmnto-ai/totem#2861) is held to the same bar.
776
+ const prComments = readPrComments(asObject(pr.comments));
777
+ if (prComments.detail !== undefined)
778
+ return { ok: false, detail: prComments.detail };
491
779
  return {
492
780
  ok: true,
493
781
  page: {
@@ -507,6 +795,9 @@ function parsePage(raw, byBranch) {
507
795
  threads: threads.entries,
508
796
  threadsHasNext: threads.hasNext,
509
797
  threadsCursor: threads.cursor,
798
+ prComments: prComments.entries,
799
+ commentsHasNext: prComments.hasNext,
800
+ commentsCursor: prComments.cursor,
510
801
  },
511
802
  };
512
803
  }
@@ -674,14 +965,22 @@ export function hasHighSeverityMarker(body) {
674
965
  return HIGH_SEVERITY_MARKERS.some((re) => re.test(prose));
675
966
  }
676
967
  // ─── Evidence helpers ───────────────────────────────────────────────────────
677
- /** Bound and sanitize a fragment for a reason / provenance: no control characters, bounded length. */
678
- function bounded(text) {
968
+ /**
969
+ * One line: C0 control characters and DEL to spaces, runs of whitespace to
970
+ * one, trimmed — never sliced. (C1 controls are left as they are, as they
971
+ * always were in `bounded`.)
972
+ */
973
+ function oneLine(text) {
679
974
  let out = '';
680
975
  for (const ch of text) {
681
976
  const code = ch.charCodeAt(0);
682
977
  out += code < 0x20 || code === 0x7f ? ' ' : ch;
683
978
  }
684
- out = out.replace(/\s+/g, ' ').trim();
979
+ return out.replace(/\s+/g, ' ').trim();
980
+ }
981
+ /** Bound and sanitize a fragment for a reason / provenance: no control characters, bounded length. */
982
+ function bounded(text) {
983
+ const out = oneLine(text);
685
984
  return out.length > MERGE_READY_EVIDENCE_MAX
686
985
  ? out.slice(0, MERGE_READY_EVIDENCE_MAX - 1) + '…'
687
986
  : out;
@@ -715,17 +1014,22 @@ function readPullRequest(payload, runner) {
715
1014
  rollupState: null,
716
1015
  rollupTotalCount: null,
717
1016
  checks: [],
1017
+ judgedChecks: [],
1018
+ supersededRuns: [],
718
1019
  reviews: [],
719
1020
  threads: [],
1021
+ prComments: [],
720
1022
  pagesRead: 0,
721
1023
  complete: false,
722
1024
  };
723
1025
  let checksDone = false;
724
1026
  let reviewsDone = false;
725
1027
  let threadsDone = false;
1028
+ let commentsDone = false;
726
1029
  let checksCursor = null;
727
1030
  let reviewsCursor = null;
728
1031
  let threadsCursor = null;
1032
+ let commentsCursor = null;
729
1033
  for (let page = 0; page < MAX_PAGES; page++) {
730
1034
  // The branch-keyed document is used ONLY for the first page of a payload
731
1035
  // with no number; once the number is known every later page targets it, so
@@ -748,6 +1052,8 @@ function readPullRequest(payload, runner) {
748
1052
  variables.push(['reviewsAfter', reviewsCursor]);
749
1053
  if (threadsCursor !== null)
750
1054
  variables.push(['threadsAfter', threadsCursor]);
1055
+ if (commentsCursor !== null)
1056
+ variables.push(['commentsAfter', commentsCursor]);
751
1057
  const run = runner(graphqlArgs(query, variables));
752
1058
  state.pagesRead = page + 1;
753
1059
  if (run.exitCode !== 0) {
@@ -794,7 +1100,12 @@ function readPullRequest(payload, runner) {
794
1100
  threadsDone = !p.threadsHasNext;
795
1101
  threadsCursor = p.threadsCursor;
796
1102
  }
797
- if (checksDone && reviewsDone && threadsDone) {
1103
+ if (!commentsDone) {
1104
+ state.prComments.push(...p.prComments);
1105
+ commentsDone = !p.commentsHasNext;
1106
+ commentsCursor = p.commentsCursor;
1107
+ }
1108
+ if (checksDone && reviewsDone && threadsDone && commentsDone) {
798
1109
  // The rollup must ACCOUNT for itself before predicate 1 reads it
799
1110
  // (mmnto-ai/totem#2800 round 4, F3). `totalCount` is judged as the API
800
1111
  // typed it — a string "3", a boolean, a negative or a fractional number
@@ -825,6 +1136,20 @@ function readPullRequest(payload, runner) {
825
1136
  };
826
1137
  }
827
1138
  }
1139
+ // Same-named CheckRuns collapse to their LATEST run before predicate 1
1140
+ // reads them (mmnto-ai/totem#2879): a workflow's concurrency group
1141
+ // cancels the run a later push or body edit superseded, and the rollup
1142
+ // lists BOTH — the cancelled one is not a failing check, it is a
1143
+ // replaced one. The judgment needs every duplicate's id; a group with
1144
+ // an unreadable id is an unreadable check state, never "the first one".
1145
+ // Runs AFTER the count check on purpose: the rollup accounts for the
1146
+ // nodes it listed, and the collapse is a read of those nodes.
1147
+ const judged = judgeLatestRuns(state.checks);
1148
+ if (!judged.ok) {
1149
+ return { ok: false, detail: judged.detail, pagesRead: state.pagesRead };
1150
+ }
1151
+ state.judgedChecks = judged.judged;
1152
+ state.supersededRuns = judged.collapsed;
828
1153
  // R5's zero-checks FACT applies ONLY where the rollup is consistent about
829
1154
  // it, and that is exactly two shapes: no rollup at all, or a rollup that
830
1155
  // reports SUCCESS over an empty context list AND counts zero (the count
@@ -862,11 +1187,155 @@ const MERGE_STATE_DENY = new Map([
862
1187
  ['BLOCKED', 'branch protection blocks the merge (a required review or check is missing)'],
863
1188
  ['DRAFT', 'the pull request is still a draft'],
864
1189
  ]);
865
- function summarizeChecks(checks) {
1190
+ /**
1191
+ * Collapse every group of `CheckRun`s that share a NAME and a PRODUCER to its
1192
+ * LATEST run (mmnto-ai/totem#2879). GitHub's rollup `contexts` lists EVERY
1193
+ * check run on the head — a concurrency group's cancelled duplicate beside the
1194
+ * run that superseded it — while `gh pr checks` shows one row per name,
1195
+ * judged by the latest run. The rollup's own order is NOT chronological (on
1196
+ * mmnto-ai/totem#2877's head the later D1 run was listed before the earlier
1197
+ * one), so "last listed wins" is not a rule; the latest run is the one with
1198
+ * the greatest `databaseId`, GitHub's check-run id, a single increasing
1199
+ * sequence.
1200
+ *
1201
+ * The producer is part of the key on purpose: two apps, or two workflows
1202
+ * under one app, may name a job alike, and those are INDEPENDENT checks — a
1203
+ * later success from one must never hide a failure from the other (bot
1204
+ * round 1, Greptile P1). The producer is the app plus the WORKFLOW's id for
1205
+ * Actions, so two workflow files that share a display name stay apart. Same
1206
+ * name, different producer: both judged. Same name, same producer, different
1207
+ * check suites: reruns across workflow runs, the latest judged — and, ruled,
1208
+ * two independent runs of one workflow on one head (a push run beside a
1209
+ * scheduled run) collapse the same way, the way `gh pr checks` and the merge
1210
+ * box take the latest run of a workflow. Same name, same producer, ONE check
1211
+ * suite: two checks of one suite share a name (two jobs of one workflow run,
1212
+ * or two runs of one non-Actions app, which has one suite per head) — the key
1213
+ * cannot tell them apart, an unreadable check state (R2), never a collapse.
1214
+ * Same name, and a member whose producer or suite id did not read: the runs
1215
+ * cannot be placed — unreadable the same way. A group whose members share a
1216
+ * producer and a suite pattern but one lacks a readable id is unreadable too:
1217
+ * the latest cannot be derived, and picking one would be a guess dressed as a
1218
+ * read. A single run needs none of it. Legacy `StatusContext` nodes carry one
1219
+ * state per context already and pass through untouched. Output order is
1220
+ * first-seen order, so the deny reason's name list reads the way the rollup
1221
+ * listed it.
1222
+ */
1223
+ function judgeLatestRuns(checks) {
1224
+ const byName = new Map();
1225
+ const order = [];
1226
+ for (const c of checks) {
1227
+ // A legacy status passes through; so does a run whose name did not read —
1228
+ // grouping the placeholder would fabricate an identity two malformed nodes
1229
+ // never shared (leg F10). Keyed on `named`, not on the placeholder text.
1230
+ if (c.typename !== 'CheckRun' || !c.named) {
1231
+ order.push({ entry: c });
1232
+ continue;
1233
+ }
1234
+ const group = byName.get(c.name);
1235
+ if (group === undefined) {
1236
+ byName.set(c.name, [c]);
1237
+ order.push({ key: c.name });
1238
+ }
1239
+ else {
1240
+ group.push(c);
1241
+ }
1242
+ }
1243
+ const judged = [];
1244
+ const collapsed = [];
1245
+ for (const slot of order) {
1246
+ if ('entry' in slot) {
1247
+ judged.push(slot.entry);
1248
+ continue;
1249
+ }
1250
+ const sameName = byName.get(slot.key);
1251
+ if (sameName.length === 1) {
1252
+ judged.push(sameName[0]);
1253
+ continue;
1254
+ }
1255
+ // Two or more runs of one name: split them by producer, first-seen order.
1256
+ const byProducer = new Map();
1257
+ for (const run of sameName) {
1258
+ if (run.producer === null) {
1259
+ return {
1260
+ ok: false,
1261
+ detail: 'check ' +
1262
+ bounded(JSON.stringify(slot.key)) +
1263
+ ' ran ' +
1264
+ String(sameName.length) +
1265
+ ' times on the head and one of its runs carries no readable producer (the check suite app, or the workflow id of an Actions run) - reruns of one check cannot be told from independent checks that share the name, the check state is unreadable',
1266
+ };
1267
+ }
1268
+ const sub = byProducer.get(run.producer);
1269
+ if (sub === undefined)
1270
+ byProducer.set(run.producer, [run]);
1271
+ else
1272
+ sub.push(run);
1273
+ }
1274
+ for (const group of byProducer.values()) {
1275
+ if (group.length === 1) {
1276
+ judged.push(group[0]);
1277
+ continue;
1278
+ }
1279
+ const label = group[0].producerLabel ?? group[0].producer ?? '(unreadable producer)';
1280
+ const who = 'check ' + bounded(JSON.stringify(slot.key)) + ' from ' + bounded(JSON.stringify(label));
1281
+ // Same name, same producer: reruns sit in DIFFERENT check suites (one
1282
+ // per workflow run). Two members in ONE suite are two distinct jobs of
1283
+ // one run that share a display name — independent checks the key cannot
1284
+ // tell apart — and a member with no readable suite cannot be placed at
1285
+ // all: unreadable either way, never a collapse (the re-armed leg's F1).
1286
+ const suites = new Set();
1287
+ for (const run of group) {
1288
+ if (run.suiteId === null) {
1289
+ return {
1290
+ ok: false,
1291
+ detail: who +
1292
+ ' ran ' +
1293
+ String(group.length) +
1294
+ ' times on the head and one of its runs carries no readable check-suite id - reruns cannot be told from independent jobs, the check state is unreadable',
1295
+ };
1296
+ }
1297
+ if (suites.has(run.suiteId)) {
1298
+ return {
1299
+ ok: false,
1300
+ detail: who +
1301
+ ' ran ' +
1302
+ String(group.length) +
1303
+ ' times on the head and two of its runs sit in one check suite - two checks of one suite share the name (two jobs of one workflow run, or two runs of one app), independent checks the key cannot tell apart, the check state is unreadable',
1304
+ };
1305
+ }
1306
+ suites.add(run.suiteId);
1307
+ }
1308
+ let latest = null;
1309
+ for (const run of group) {
1310
+ if (run.runId === null) {
1311
+ return {
1312
+ ok: false,
1313
+ detail: who +
1314
+ ' ran ' +
1315
+ String(group.length) +
1316
+ ' times on the head and one of its runs carries no readable databaseId - the latest run cannot be derived, the check state is unreadable',
1317
+ };
1318
+ }
1319
+ if (latest === null || run.runId > latest.runId)
1320
+ latest = run;
1321
+ }
1322
+ judged.push(latest);
1323
+ collapsed.push({
1324
+ name: slot.key,
1325
+ producer: label,
1326
+ runs: group.length,
1327
+ judgedId: latest.runId,
1328
+ kind: latest.kind,
1329
+ });
1330
+ }
1331
+ }
1332
+ return { ok: true, judged, collapsed };
1333
+ }
1334
+ function summarizeChecks(judged, materialised) {
866
1335
  let success = 0;
867
1336
  let pending = 0;
868
1337
  let failing = 0;
869
- for (const c of checks) {
1338
+ for (const c of judged) {
870
1339
  if (c.kind === 'success')
871
1340
  success++;
872
1341
  else if (c.kind === 'pending')
@@ -874,7 +1343,13 @@ function summarizeChecks(checks) {
874
1343
  else
875
1344
  failing++;
876
1345
  }
877
- return { total: checks.length, success, pending, failing };
1346
+ return {
1347
+ total: judged.length,
1348
+ success,
1349
+ pending,
1350
+ failing,
1351
+ superseded: materialised - judged.length,
1352
+ };
878
1353
  }
879
1354
  /**
880
1355
  * The authors whose LATEST decision review is CHANGES_REQUESTED. `COMMENTED`
@@ -909,29 +1384,112 @@ function unresolvedBotThreads(threads) {
909
1384
  isBotReviewerLoginExact(t.rootLogin));
910
1385
  }
911
1386
  /**
912
- * HIGH/Major bot inlines that CURRENTLY apply to the head commit — thread
913
- * resolution deliberately IGNORED (ruled on mmnto-ai/totem#2800, fold F2).
1387
+ * HIGH/Major bot inlines that CURRENTLY apply to the head commit, before the
1388
+ * discharge read below.
914
1389
  *
915
1390
  * The comparison is `comment.commit.oid`, the commit the finding applies to
916
1391
  * NOW (GitHub re-points it as the diff moves; it is the REST `commit_id` the
917
1392
  * charter's predicate (c) names), never `originalCommit.oid`, the commit it was
918
1393
  * written against — a predicate keyed on the write-time commit goes inert the
919
- * moment the branch advances, which is exactly the inert state this fold
920
- * removed.
1394
+ * moment the branch advances, which is exactly the inert state fold F2 of
1395
+ * mmnto-ai/totem#2800 removed.
921
1396
  *
922
1397
  * Predicate 4's distinct territory, the one predicate 2 cannot reach: a HIGH
923
- * finding a human RESOLVED by hand without changing the code. The comment still
924
- * applies to the head commit, so the floor still refuses the merge. Predicate 2
925
- * keeps its own rule (unresolved AND non-outdated, any severity) and still
926
- * fires first when both match.
1398
+ * finding whose thread is RESOLVED while the comment still applies to the head
1399
+ * commit. Since mmnto-ai/totem#2861 that territory splits by HOW the thread
1400
+ * was resolved — see {@link dischargeOf}: through the disposition path the
1401
+ * finding is DISCHARGED and no longer counts; by a bare resolve it still
1402
+ * applies and the floor still refuses the merge. Predicate 2 keeps its own
1403
+ * rule (unresolved AND non-outdated, any severity) and still fires first when
1404
+ * both match — which is what makes the resolve a REQUIRED step of the
1405
+ * disposition path rather than an optional one.
927
1406
  */
928
- function highSeverityInlines(threads, headSha) {
1407
+ function botHighInlinesOnHead(threads, headSha) {
929
1408
  return threads.filter((t) => t.rootLogin !== null &&
930
1409
  isBotReviewerLoginExact(t.rootLogin) &&
931
1410
  t.rootCommit !== null &&
932
1411
  t.rootCommit.toLowerCase() === headSha &&
933
1412
  hasHighSeverityMarker(t.rootBody));
934
1413
  }
1414
+ /**
1415
+ * The disposition path, read off the same page as the predicate
1416
+ * (mmnto-ai/totem#2861): a thread is DISCHARGED when it is RESOLVED and a
1417
+ * disposition NAMES it — a non-bot reply after the root inside the thread (it
1418
+ * names the thread by being in it), or a non-bot PR-level comment created
1419
+ * STRICTLY after the root that carries a `disposition:` line for this
1420
+ * thread's root comment id ({@link DISPOSITION_LINE}). `none` is the bare
1421
+ * resolve — a click with no disposition naming it, the fail-closed arm the
1422
+ * ruling names — and an unresolved thread alike; predicate 2 catches the
1423
+ * unresolved one first, so the resolve is a REQUIRED step of the path, never
1424
+ * an optional one. A round disposition that answered OTHER threads is `none`
1425
+ * for this one: the id is what closes the class two round-keyed reads left
1426
+ * open (the falsification legs' r-f1 and r2-f1, both executed on the built
1427
+ * core), and the operator ruled for the thread-level line on 2026-09-16.
1428
+ *
1429
+ * Two deliberate differences from the `totem resolve-threads` evidence rule
1430
+ * (mmnto-ai/totem#2841 R2), which this otherwise mirrors. The verb decides
1431
+ * whether a thread MAY be resolved and accepts any non-bot PR-level comment
1432
+ * after the root, disclosing that an operator's trigger comment counts; this
1433
+ * predicate decides whether a resolved HIGH is DISPOSITIONED, and a trigger,
1434
+ * a gate-read note, merge chatter or another thread's disposition must not
1435
+ * discharge it — so the PR-level arm reads the line that names this thread.
1436
+ * A thread the verb resolved on the strength of a trigger therefore stays
1437
+ * applying here until a disposition naming it is posted: the stricter side of
1438
+ * the asymmetry, by design. `createdAt` is a comment's CREATION instant and an
1439
+ * edit does not move it, so a line edited into a comment created after the
1440
+ * root reads as posted then, and a line edited into one created before the
1441
+ * root never counts (r3-f12) — a late line is posted as a new comment, which
1442
+ * is what the calibration replay did. And an unparseable
1443
+ * instant on either side is `none` here as it is there — the conservative
1444
+ * direction — never an unreadable page (the strict reader has already refused
1445
+ * a root without a string `createdAt`).
1446
+ *
1447
+ * A thread with more comments than the ten-comment window is judged on what
1448
+ * was read: evidence found inside the window or at PR level discharges it, and
1449
+ * a window with none is a bare resolve whose reason NAMES the window — a
1450
+ * fact-side deny at every tier, never the unevaluable class, which pilot maps
1451
+ * to `warn` and which would have let the shape pre-cure denied at both tiers
1452
+ * stop blocking (leg f2).
1453
+ */
1454
+ function dischargeOf(thread, prComments) {
1455
+ if (!thread.isResolved)
1456
+ return 'none';
1457
+ if (thread.humanReplies > 0)
1458
+ return 'in-thread-reply';
1459
+ const rootAt = Date.parse(thread.rootCreatedAt);
1460
+ if (Number.isNaN(rootAt))
1461
+ return 'none';
1462
+ if (thread.rootId === null)
1463
+ return 'none';
1464
+ for (const c of prComments) {
1465
+ if (c.isBot || !c.dispositions.includes(thread.rootId))
1466
+ continue;
1467
+ const at = Date.parse(c.createdAt);
1468
+ if (!Number.isNaN(at) && at > rootAt)
1469
+ return 'pr-level-disposition';
1470
+ }
1471
+ return 'none';
1472
+ }
1473
+ /** Predicate 4's read: the bot HIGH inlines on the head, split by {@link dischargeOf}, with the arm counts. */
1474
+ function triageHighInlines(threads, headSha, prComments) {
1475
+ const applying = [];
1476
+ const discharged = [];
1477
+ const by = { inThreadReply: 0, prLevelDisposition: 0 };
1478
+ for (const t of botHighInlinesOnHead(threads, headSha)) {
1479
+ const discharge = dischargeOf(t, prComments);
1480
+ if (discharge === 'none') {
1481
+ applying.push(t);
1482
+ }
1483
+ else {
1484
+ discharged.push(t);
1485
+ if (discharge === 'in-thread-reply')
1486
+ by.inThreadReply++;
1487
+ else
1488
+ by.prLevelDisposition++;
1489
+ }
1490
+ }
1491
+ return { applying, discharged, by };
1492
+ }
935
1493
  /**
936
1494
  * Bot HIGH inlines whose `comment.commit` came back NULL — the predicate's
937
1495
  * input is missing, so whether they apply to the head is UNKNOWN
@@ -982,10 +1540,12 @@ export function evaluateMergeReady(payload, options) {
982
1540
  repo: parsed.repo,
983
1541
  pr: parsed.pr,
984
1542
  headSha: null,
985
- checks: { total: 0, success: 0, pending: 0, failing: 0 },
1543
+ checks: { total: 0, success: 0, pending: 0, failing: 0, superseded: 0 },
986
1544
  threads: { unresolvedBot: 0, pagesRead: 0, complete: false },
987
1545
  changesRequestedBy: [],
988
1546
  highInline: 0,
1547
+ dischargedHigh: 0,
1548
+ dischargedBy: { inThreadReply: 0, prLevelDisposition: 0 },
989
1549
  mergeStateStatus: null,
990
1550
  evaluatedAt: checkedAt,
991
1551
  evaluatedBy: 'gh (not read)',
@@ -1051,20 +1611,39 @@ export function evaluateMergeReady(payload, options) {
1051
1611
  detail.pr = state.number;
1052
1612
  detail.headSha = state.headSha;
1053
1613
  detail.mergeStateStatus = state.mergeStateStatus;
1054
- detail.checks = summarizeChecks(state.checks);
1614
+ detail.checks = summarizeChecks(state.judgedChecks, state.checks.length);
1615
+ // A superseded run is a check the read SAW and set aside; the record must
1616
+ // say so, name by name, and name the run that stood in for it
1617
+ // (mmnto-ai/totem#2879). One line per collapsed name, on purpose: a single
1618
+ // line under the evidence bound lost its tail at three names (leg F1; a
1619
+ // head on main carried five), and a disclosure that trails off is not one.
1620
+ // Each line is sanitised, never sliced — it is a notice, not `matched`.
1621
+ for (const run of state.supersededRuns) {
1622
+ notices.push(`${MERGE_READY_NOTICE_PREFIX} ${parsed.repo}#${state.number} at ${shortSha(state.headSha)}: check ${oneLine(JSON.stringify(run.name))} from ${oneLine(JSON.stringify(run.producer))} ran ${run.runs} times on the head commit — judged by its latest run ${run.judgedId} (${run.kind}), the greatest check-run id; ${run.runs - 1} superseded run(s) not counted (mmnto-ai/totem#2879).`);
1623
+ }
1055
1624
  detail.threads = {
1056
1625
  unresolvedBot: unresolvedBotThreads(state.threads).length,
1057
1626
  pagesRead: state.pagesRead,
1058
1627
  complete: state.complete,
1059
1628
  };
1060
1629
  detail.changesRequestedBy = changesRequestedBy(state.reviews);
1061
- detail.highInline = highSeverityInlines(state.threads, state.headSha).length;
1630
+ const high = triageHighInlines(state.threads, state.headSha, state.prComments);
1631
+ detail.highInline = high.applying.length;
1632
+ detail.dischargedHigh = high.discharged.length;
1633
+ detail.dischargedBy = { ...high.by };
1634
+ // The audit breadcrumb for what the predicate RELEASED (mmnto-ai/totem#2861):
1635
+ // a discharged HIGH is a bot finding the round answered, and the record
1636
+ // must show it was read and discharged — and by which arm — never that it
1637
+ // was not seen.
1638
+ if (high.discharged.length > 0) {
1639
+ notices.push(`${MERGE_READY_NOTICE_PREFIX} ${parsed.repo}#${state.number} at ${shortSha(state.headSha)}: ${high.discharged.length} HIGH/Major bot inline(s) on the head commit discharged through the disposition path (thread resolved; ${high.by.inThreadReply} by a non-bot in-thread reply, ${high.by.prLevelDisposition} by a PR-level disposition line naming the thread after its root) — no longer applying (mmnto-ai/totem#2861).`);
1640
+ }
1062
1641
  // The predicates are read BEFORE the unreadable-commit arm below: a failure
1063
1642
  // that stands in charter order ahead of predicate 4 is a fact, and a tier
1064
1643
  // never softens a fact — the arm that returned first here turned a failing
1065
1644
  // check plus one unplaceable HIGH inline into the UNEVALUABLE class, which
1066
1645
  // pilot maps to `warn` (mmnto-ai/totem#2844 round 1, CodeRabbit).
1067
- const blocked = firstFailure(state, detail);
1646
+ const blocked = firstFailure(state, detail, high.applying);
1068
1647
  // A bot HIGH inline whose `comment.commit` came back null cannot be placed
1069
1648
  // against the head, so predicate 4's input is missing for it: unevaluable and
1070
1649
  // NAMED, never a silent pass (round 2, F8). It still preempts predicate 5 —
@@ -1134,11 +1713,16 @@ export function evaluateMergeReady(payload, options) {
1134
1713
  notices,
1135
1714
  };
1136
1715
  }
1137
- /** The FIRST predicate that fails, in charter order, or null when the floor is met. */
1138
- function firstFailure(state, detail) {
1716
+ /**
1717
+ * The FIRST predicate that fails, in charter order, or null when the floor is
1718
+ * met. `applyingHigh` is predicate 4's set after the discharge read — the
1719
+ * caller triages once so the provenance counts and the verdict read the same
1720
+ * list.
1721
+ */
1722
+ function firstFailure(state, detail, applyingHigh) {
1139
1723
  // 1. checks
1140
1724
  if (detail.checks.failing > 0) {
1141
- const names = state.checks
1725
+ const names = state.judgedChecks
1142
1726
  .filter((c) => c.kind === 'failing')
1143
1727
  .map((c) => c.name)
1144
1728
  .join(', ');
@@ -1148,7 +1732,7 @@ function firstFailure(state, detail) {
1148
1732
  };
1149
1733
  }
1150
1734
  if (detail.checks.pending > 0) {
1151
- const names = state.checks
1735
+ const names = state.judgedChecks
1152
1736
  .filter((c) => c.kind === 'pending')
1153
1737
  .map((c) => c.name)
1154
1738
  .join(', ');
@@ -1173,13 +1757,32 @@ function firstFailure(state, detail) {
1173
1757
  evidence: `CHANGES_REQUESTED stands from ${detail.changesRequestedBy.join(', ')}`,
1174
1758
  };
1175
1759
  }
1176
- // 4. HIGH/Major bot inline on the head commit
1177
- const high = highSeverityInlines(state.threads, state.headSha);
1178
- if (high.length > 0) {
1179
- const first = high[0];
1760
+ // 4. HIGH/Major bot inline on the head commit, not discharged
1761
+ if (applyingHigh.length > 0) {
1762
+ const first = applyingHigh[0];
1763
+ // Name the bare-resolve shape when that is what the first one is — the
1764
+ // operator's cure differs (post the disposition line) from the unanswered
1765
+ // shape's (answer the finding) — and name the window when the thread had
1766
+ // more comments than the read fetched. The LINE comes first in the clause
1767
+ // and the clause before the quoted body, so the id survives the
1768
+ // 160-character bound on `provenance.matched` (the third leg's r3-f3: a
1769
+ // clause that led with prose cut the id off at character 161); the tests
1770
+ // pin the id on `matched` for both clauses. With more than one applying,
1771
+ // the reason names the first and says where the rest are listed.
1772
+ const line = first.rootId === null
1773
+ ? 'no line can name it (its root comment id was not readable)'
1774
+ : `no "disposition: ${first.rootId} <verb>" line after its root`;
1775
+ const bareResolve = !first.isResolved
1776
+ ? ''
1777
+ : first.commentsComplete
1778
+ ? ` — resolved, ${line} and no non-bot reply in its thread (a bare resolve, or a round disposition that did not name this thread, does not discharge a HIGH; mmnto-ai/totem#2861)`
1779
+ : ` — resolved, ${line} and no non-bot reply in the ten comments read (the thread has more; mmnto-ai/totem#2861)`;
1780
+ const rest = applyingHigh.length > 1
1781
+ ? ` (the first of ${applyingHigh.length}; a \`totem resolve-threads\` dry run lists every root id)`
1782
+ : '';
1180
1783
  return {
1181
1784
  predicate: 'high-severity-inline',
1182
- evidence: `${high.length} HIGH/Major bot inline(s) still applying to the head commit — the first is ${first.rootLogin ?? 'a bot'}: "${bounded(first.rootBody)}"`,
1785
+ evidence: `${applyingHigh.length} HIGH/Major bot inline(s) still applying to the head commit${bareResolve} — the first is ${first.rootLogin ?? 'a bot'}${rest}: "${bounded(first.rootBody)}"`,
1183
1786
  };
1184
1787
  }
1185
1788
  // 5. GitHub's own mergeability