@mmnto/totem 2.5.0 → 2.6.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
@@ -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,61 @@ function asArray(value) {
207
248
  function asString(value) {
208
249
  return typeof value === 'string' ? value : null;
209
250
  }
251
+ /**
252
+ * The per-thread disposition line (mmnto-ai/totem#2861, the operator's ruling
253
+ * of 2026-09-16 on the linkage fork): the review-reply skill's step 2 ends a
254
+ * round disposition with ONE machine line per bot-rooted thread the round
255
+ * answered —
256
+ *
257
+ * disposition: <root comment id> <verb>
258
+ *
259
+ * — the id being the thread root's REST comment id (`databaseId`; the `id=`
260
+ * on a `totem resolve-threads` dry-run row, the one command that prints it —
261
+ * `totem triage-pr` carries it internally and prints none) and the verb the
262
+ * round's word for it (fixed, declined, deferred,
263
+ * nit, extracted, held). Predicate 4 reads the ID: a PR-level comment names
264
+ * a thread when a line carries that thread's root id — and a round
265
+ * disposition that answered OTHER threads does not name this one. That last
266
+ * case is why the line exists: two earlier reads keyed the arm to the ROUND
267
+ * (post-dating alone, then post-dating plus the round's `local-lane:` line),
268
+ * and both let a bare resolve of a HIGH the round never addressed discharge —
269
+ * the two falsification legs' blocking findings, executed on the built core.
270
+ * Naming the thread is what the ruling asked for, and an id is exact where a
271
+ * marker was a shape. By construction no other surface writes a line of this
272
+ * shape: a trigger comment, a gate-read note or a merge note carries no id at
273
+ * line start, and the gate's own deny reason quotes the line inside double
274
+ * quotes mid-sentence (the third leg tried the reason verbatim, fenced,
275
+ * unquoted, and a pasted resolve-threads row, and none read as a line) — an
276
+ * inference about bodies, made safe by the id rather than proven.
277
+ *
278
+ * Read against the raw body with HTML comments removed (a terminated
279
+ * `<!-- … -->`, and an unterminated `<!--` to the end of the body, the way a
280
+ * renderer hides it), at line start with leading blanks allowed, anywhere in
281
+ * the body — fenced included, because the skills render machine lines in text
282
+ * fences. A blockquoted, listed or tabled line, or one inside an inline span,
283
+ * is not at line start and does not count. The id is the decimal the seat
284
+ * copied: no leading zero, no sign, no fraction, nothing glued to it — the
285
+ * third leg's r3-f5 showed `Number()` equating `01001`, `1001.5` and `1001x`
286
+ * to 1001, so the token is matched exactly and never coerced. The verb is not
287
+ * validated here: the gate answers "was this thread dispositioned", not
288
+ * "how".
289
+ */
290
+ const DISPOSITION_LINE = /^[ \t]*disposition:[ \t]+([1-9]\d*)(?=[ \t]|\r?$)/gm;
291
+ /**
292
+ * The root comment ids a PR-level comment body names on disposition lines,
293
+ * in first-seen order, without duplicates. Exported for the tests, so they
294
+ * assert the shipped predicate and not a copy of it.
295
+ */
296
+ export function dispositionedRootIds(body) {
297
+ const ids = [];
298
+ const stripped = body.replace(/<!--[\s\S]*?-->/g, '').replace(/<!--[\s\S]*$/, '');
299
+ for (const match of stripped.matchAll(DISPOSITION_LINE)) {
300
+ const id = Number(match[1]);
301
+ if (Number.isSafeInteger(id) && !ids.includes(id))
302
+ ids.push(id);
303
+ }
304
+ return ids;
305
+ }
210
306
  /**
211
307
  * A CheckRun's conclusion vocabulary. `NEUTRAL` and `SKIPPED` are successes (a
212
308
  * skipped required check is branch protection's business, not this gate's);
@@ -342,6 +438,43 @@ function readReviews(connection) {
342
438
  return unreadable(info.detail);
343
439
  return { entries, hasNext: info.hasNext, cursor: info.cursor };
344
440
  }
441
+ /**
442
+ * A comment's author, read STRICTLY for the evidence rule (mmnto-ai/totem#2861).
443
+ * `null` is a deleted account — NOT a bot: the reply was human when it was
444
+ * written, and deleting the account does not retract it (the resolve-threads
445
+ * rule, item 6 of `.totem/specs/2841.md`). Otherwise `__typename` and `login`
446
+ * must both be strings. `__typename` is REQUIRED, not optional, because it is
447
+ * the only signal that names EVERY GitHub App a bot (`github-actions`, a
448
+ * Copilot reviewer, a scanner): a document that quietly stopped selecting it
449
+ * would fail OPEN, every App reply reading as the human answer — the same
450
+ * guard resolve-threads holds at its zod boundary. The three bot arms are the
451
+ * verb's: GitHub's own `Bot` typename, the `[bot]` App suffix, or core's exact
452
+ * review-bot list.
453
+ */
454
+ function readAuthor(value) {
455
+ if (value === null)
456
+ return { ok: true, login: null, isBot: false };
457
+ const author = asObject(value);
458
+ if (author === null)
459
+ return { ok: false, detail: 'author is neither null nor an object' };
460
+ const typename = asString(author.__typename);
461
+ const login = asString(author.login);
462
+ if (typename === null || login === null) {
463
+ return { ok: false, detail: 'author has no __typename or no login' };
464
+ }
465
+ // On every capture the `Bot` typename is the arm that decides for a review
466
+ // bot (their GraphQL logins carry no suffix); the two login arms are the
467
+ // verb's belt-and-braces, kept so the two consumers of the rule agree. The
468
+ // null-author rule applies on BOTH surfaces: a deleted account's PR-level
469
+ // comment carrying a disposition line discharges the thread it names — not
470
+ // guarded, judged unreachable (a removed App's summary would have to carry
471
+ // this thread's root id at line start; the third leg's r3-f11).
472
+ return {
473
+ ok: true,
474
+ login,
475
+ isBot: typename === 'Bot' || hasBotAppLoginSuffix(login) || isBotReviewerLoginExact(login),
476
+ };
477
+ }
345
478
  /**
346
479
  * The `reviewThreads` connection, read with the same strictness as
347
480
  * {@link readReviews}: a missing connection, a missing `nodes` array or
@@ -352,6 +485,16 @@ function readReviews(connection) {
352
485
  * (mmnto-ai/totem#2844 round 1). A root comment whose author is a deleted
353
486
  * account keeps `rootLogin: null` — it is not a known bot, which is a fact
354
487
  * about the thread, not an unreadable answer.
488
+ *
489
+ * The evidence fields (mmnto-ai/totem#2861) are held to the same bar: the
490
+ * comments window's own `pageInfo`, the root's `createdAt`, and every
491
+ * author's `__typename` — a thread missing any of them is unreadable, never
492
+ * "complete with no reply", because that is the shape a discharge would fail
493
+ * open on. The root's `databaseId` is the one field read the other way: the
494
+ * schema types it nullable, and a root without one cannot be named by any
495
+ * line, so that THREAD stays applying (fail-closed) while the page stays
496
+ * readable — refusing the page would send a PR into the unevaluable class and
497
+ * pilot's `warn` (r3-f4).
355
498
  */
356
499
  function readThreads(connection) {
357
500
  const entries = [];
@@ -370,17 +513,59 @@ function readThreads(connection) {
370
513
  const n = asObject(node);
371
514
  if (n === null)
372
515
  return unreadable('carried a node that is not an object');
373
- const comments = asArray(asObject(n.comments)?.nodes);
516
+ const commentsConnection = asObject(n.comments);
517
+ const comments = asArray(commentsConnection?.nodes);
374
518
  if (comments === null)
375
519
  return unreadable('carried a thread with no comments array');
376
520
  const root = asObject(comments[0]);
377
521
  if (root === null)
378
522
  return unreadable('carried a thread whose root comment is unreadable');
523
+ const window = readPageInfo(commentsConnection);
524
+ if (!window.ok) {
525
+ return unreadable(`carried a thread whose comments connection ${window.detail}`);
526
+ }
527
+ const rootAuthor = readAuthor(root.author);
528
+ if (!rootAuthor.ok)
529
+ return unreadable(`carried a thread whose root ${rootAuthor.detail}`);
530
+ const rootCreatedAt = asString(root.createdAt);
531
+ if (rootCreatedAt === null) {
532
+ return unreadable('carried a thread whose root comment has no createdAt');
533
+ }
534
+ // The root's REST id is what a disposition line names; a root without a
535
+ // safe-integer one cannot be matched to any line and its thread stays
536
+ // applying — thread-scoped, never a page refusal (mmnto-ai/totem#2861).
537
+ const rootId = typeof root.databaseId === 'number' && Number.isSafeInteger(root.databaseId)
538
+ ? root.databaseId
539
+ : null;
540
+ const rootAt = Date.parse(rootCreatedAt);
541
+ let humanReplies = 0;
542
+ for (const reply of comments.slice(1)) {
543
+ const r = asObject(reply);
544
+ if (r === null)
545
+ return unreadable('carried a thread with a reply that is not an object');
546
+ const author = readAuthor(r.author);
547
+ if (!author.ok)
548
+ return unreadable(`carried a thread with a reply whose ${author.detail}`);
549
+ const replyCreatedAt = asString(r.createdAt);
550
+ if (replyCreatedAt === null)
551
+ return unreadable('carried a thread with a reply that has no createdAt');
552
+ // Positional AND temporal: a non-bot comment after the root in the list
553
+ // that also post-dates it. An unparseable instant on either side is not
554
+ // a reply that counts — the conservative direction.
555
+ const replyAt = Date.parse(replyCreatedAt);
556
+ if (!author.isBot && !Number.isNaN(rootAt) && !Number.isNaN(replyAt) && replyAt > rootAt) {
557
+ humanReplies++;
558
+ }
559
+ }
379
560
  entries.push({
380
561
  isResolved: n.isResolved === true,
381
562
  isOutdated: n.isOutdated === true,
382
- rootLogin: asString(asObject(root.author)?.login),
563
+ rootLogin: rootAuthor.login,
383
564
  rootBody: asString(root.body) ?? '',
565
+ rootId,
566
+ rootCreatedAt,
567
+ humanReplies,
568
+ commentsComplete: !window.hasNext,
384
569
  rootCommit: asString(asObject(root.commit)?.oid),
385
570
  rootOriginalCommit: asString(asObject(root.originalCommit)?.oid),
386
571
  });
@@ -390,6 +575,47 @@ function readThreads(connection) {
390
575
  return unreadable(info.detail);
391
576
  return { entries, hasNext: info.hasNext, cursor: info.cursor };
392
577
  }
578
+ /**
579
+ * The PR-level `comments` connection — the second evidence surface
580
+ * (mmnto-ai/totem#2861), read as strictly as the others: a missing connection,
581
+ * a missing `nodes` array or `pageInfo`, a node that is not an object, an
582
+ * author without `__typename` / `login`, or a comment without a string `body`
583
+ * or `createdAt` is an UNREADABLE page, named — never "no evidence", which
584
+ * would deny a dispositioned HIGH for a reason that misnames its cause.
585
+ */
586
+ function readPrComments(connection) {
587
+ const entries = [];
588
+ const unreadable = (why) => ({
589
+ entries,
590
+ hasNext: false,
591
+ cursor: null,
592
+ detail: `the PR comments connection ${why}`,
593
+ });
594
+ if (connection === null)
595
+ return unreadable('was missing from the answer');
596
+ const nodes = asArray(connection.nodes);
597
+ if (nodes === null)
598
+ return unreadable('carried no nodes array');
599
+ for (const node of nodes) {
600
+ const n = asObject(node);
601
+ if (n === null)
602
+ return unreadable('carried a node that is not an object');
603
+ const author = readAuthor(n.author);
604
+ if (!author.ok)
605
+ return unreadable(`carried a comment whose ${author.detail}`);
606
+ const body = asString(n.body);
607
+ if (body === null)
608
+ return unreadable('carried a comment with no body');
609
+ const createdAt = asString(n.createdAt);
610
+ if (createdAt === null)
611
+ return unreadable('carried a comment with no createdAt');
612
+ entries.push({ isBot: author.isBot, createdAt, dispositions: dispositionedRootIds(body) });
613
+ }
614
+ const info = readPageInfo(connection);
615
+ if (!info.ok)
616
+ return unreadable(info.detail);
617
+ return { entries, hasNext: info.hasNext, cursor: info.cursor };
618
+ }
393
619
  /** Read one `gh api graphql` response body into a classified page. */
394
620
  function parsePage(raw, byBranch) {
395
621
  let body;
@@ -488,6 +714,10 @@ function parsePage(raw, byBranch) {
488
714
  const threads = readThreads(asObject(pr.reviewThreads));
489
715
  if (threads.detail !== undefined)
490
716
  return { ok: false, detail: threads.detail };
717
+ // The evidence surface (mmnto-ai/totem#2861) is held to the same bar.
718
+ const prComments = readPrComments(asObject(pr.comments));
719
+ if (prComments.detail !== undefined)
720
+ return { ok: false, detail: prComments.detail };
491
721
  return {
492
722
  ok: true,
493
723
  page: {
@@ -507,6 +737,9 @@ function parsePage(raw, byBranch) {
507
737
  threads: threads.entries,
508
738
  threadsHasNext: threads.hasNext,
509
739
  threadsCursor: threads.cursor,
740
+ prComments: prComments.entries,
741
+ commentsHasNext: prComments.hasNext,
742
+ commentsCursor: prComments.cursor,
510
743
  },
511
744
  };
512
745
  }
@@ -717,15 +950,18 @@ function readPullRequest(payload, runner) {
717
950
  checks: [],
718
951
  reviews: [],
719
952
  threads: [],
953
+ prComments: [],
720
954
  pagesRead: 0,
721
955
  complete: false,
722
956
  };
723
957
  let checksDone = false;
724
958
  let reviewsDone = false;
725
959
  let threadsDone = false;
960
+ let commentsDone = false;
726
961
  let checksCursor = null;
727
962
  let reviewsCursor = null;
728
963
  let threadsCursor = null;
964
+ let commentsCursor = null;
729
965
  for (let page = 0; page < MAX_PAGES; page++) {
730
966
  // The branch-keyed document is used ONLY for the first page of a payload
731
967
  // with no number; once the number is known every later page targets it, so
@@ -748,6 +984,8 @@ function readPullRequest(payload, runner) {
748
984
  variables.push(['reviewsAfter', reviewsCursor]);
749
985
  if (threadsCursor !== null)
750
986
  variables.push(['threadsAfter', threadsCursor]);
987
+ if (commentsCursor !== null)
988
+ variables.push(['commentsAfter', commentsCursor]);
751
989
  const run = runner(graphqlArgs(query, variables));
752
990
  state.pagesRead = page + 1;
753
991
  if (run.exitCode !== 0) {
@@ -794,7 +1032,12 @@ function readPullRequest(payload, runner) {
794
1032
  threadsDone = !p.threadsHasNext;
795
1033
  threadsCursor = p.threadsCursor;
796
1034
  }
797
- if (checksDone && reviewsDone && threadsDone) {
1035
+ if (!commentsDone) {
1036
+ state.prComments.push(...p.prComments);
1037
+ commentsDone = !p.commentsHasNext;
1038
+ commentsCursor = p.commentsCursor;
1039
+ }
1040
+ if (checksDone && reviewsDone && threadsDone && commentsDone) {
798
1041
  // The rollup must ACCOUNT for itself before predicate 1 reads it
799
1042
  // (mmnto-ai/totem#2800 round 4, F3). `totalCount` is judged as the API
800
1043
  // typed it — a string "3", a boolean, a negative or a fractional number
@@ -909,29 +1152,112 @@ function unresolvedBotThreads(threads) {
909
1152
  isBotReviewerLoginExact(t.rootLogin));
910
1153
  }
911
1154
  /**
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).
1155
+ * HIGH/Major bot inlines that CURRENTLY apply to the head commit, before the
1156
+ * discharge read below.
914
1157
  *
915
1158
  * The comparison is `comment.commit.oid`, the commit the finding applies to
916
1159
  * NOW (GitHub re-points it as the diff moves; it is the REST `commit_id` the
917
1160
  * charter's predicate (c) names), never `originalCommit.oid`, the commit it was
918
1161
  * 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.
1162
+ * moment the branch advances, which is exactly the inert state fold F2 of
1163
+ * mmnto-ai/totem#2800 removed.
921
1164
  *
922
1165
  * 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.
1166
+ * finding whose thread is RESOLVED while the comment still applies to the head
1167
+ * commit. Since mmnto-ai/totem#2861 that territory splits by HOW the thread
1168
+ * was resolved — see {@link dischargeOf}: through the disposition path the
1169
+ * finding is DISCHARGED and no longer counts; by a bare resolve it still
1170
+ * applies and the floor still refuses the merge. Predicate 2 keeps its own
1171
+ * rule (unresolved AND non-outdated, any severity) and still fires first when
1172
+ * both match — which is what makes the resolve a REQUIRED step of the
1173
+ * disposition path rather than an optional one.
927
1174
  */
928
- function highSeverityInlines(threads, headSha) {
1175
+ function botHighInlinesOnHead(threads, headSha) {
929
1176
  return threads.filter((t) => t.rootLogin !== null &&
930
1177
  isBotReviewerLoginExact(t.rootLogin) &&
931
1178
  t.rootCommit !== null &&
932
1179
  t.rootCommit.toLowerCase() === headSha &&
933
1180
  hasHighSeverityMarker(t.rootBody));
934
1181
  }
1182
+ /**
1183
+ * The disposition path, read off the same page as the predicate
1184
+ * (mmnto-ai/totem#2861): a thread is DISCHARGED when it is RESOLVED and a
1185
+ * disposition NAMES it — a non-bot reply after the root inside the thread (it
1186
+ * names the thread by being in it), or a non-bot PR-level comment created
1187
+ * STRICTLY after the root that carries a `disposition:` line for this
1188
+ * thread's root comment id ({@link DISPOSITION_LINE}). `none` is the bare
1189
+ * resolve — a click with no disposition naming it, the fail-closed arm the
1190
+ * ruling names — and an unresolved thread alike; predicate 2 catches the
1191
+ * unresolved one first, so the resolve is a REQUIRED step of the path, never
1192
+ * an optional one. A round disposition that answered OTHER threads is `none`
1193
+ * for this one: the id is what closes the class two round-keyed reads left
1194
+ * open (the falsification legs' r-f1 and r2-f1, both executed on the built
1195
+ * core), and the operator ruled for the thread-level line on 2026-09-16.
1196
+ *
1197
+ * Two deliberate differences from the `totem resolve-threads` evidence rule
1198
+ * (mmnto-ai/totem#2841 R2), which this otherwise mirrors. The verb decides
1199
+ * whether a thread MAY be resolved and accepts any non-bot PR-level comment
1200
+ * after the root, disclosing that an operator's trigger comment counts; this
1201
+ * predicate decides whether a resolved HIGH is DISPOSITIONED, and a trigger,
1202
+ * a gate-read note, merge chatter or another thread's disposition must not
1203
+ * discharge it — so the PR-level arm reads the line that names this thread.
1204
+ * A thread the verb resolved on the strength of a trigger therefore stays
1205
+ * applying here until a disposition naming it is posted: the stricter side of
1206
+ * the asymmetry, by design. `createdAt` is a comment's CREATION instant and an
1207
+ * edit does not move it, so a line edited into a comment created after the
1208
+ * root reads as posted then, and a line edited into one created before the
1209
+ * root never counts (r3-f12) — a late line is posted as a new comment, which
1210
+ * is what the calibration replay did. And an unparseable
1211
+ * instant on either side is `none` here as it is there — the conservative
1212
+ * direction — never an unreadable page (the strict reader has already refused
1213
+ * a root without a string `createdAt`).
1214
+ *
1215
+ * A thread with more comments than the ten-comment window is judged on what
1216
+ * was read: evidence found inside the window or at PR level discharges it, and
1217
+ * a window with none is a bare resolve whose reason NAMES the window — a
1218
+ * fact-side deny at every tier, never the unevaluable class, which pilot maps
1219
+ * to `warn` and which would have let the shape pre-cure denied at both tiers
1220
+ * stop blocking (leg f2).
1221
+ */
1222
+ function dischargeOf(thread, prComments) {
1223
+ if (!thread.isResolved)
1224
+ return 'none';
1225
+ if (thread.humanReplies > 0)
1226
+ return 'in-thread-reply';
1227
+ const rootAt = Date.parse(thread.rootCreatedAt);
1228
+ if (Number.isNaN(rootAt))
1229
+ return 'none';
1230
+ if (thread.rootId === null)
1231
+ return 'none';
1232
+ for (const c of prComments) {
1233
+ if (c.isBot || !c.dispositions.includes(thread.rootId))
1234
+ continue;
1235
+ const at = Date.parse(c.createdAt);
1236
+ if (!Number.isNaN(at) && at > rootAt)
1237
+ return 'pr-level-disposition';
1238
+ }
1239
+ return 'none';
1240
+ }
1241
+ /** Predicate 4's read: the bot HIGH inlines on the head, split by {@link dischargeOf}, with the arm counts. */
1242
+ function triageHighInlines(threads, headSha, prComments) {
1243
+ const applying = [];
1244
+ const discharged = [];
1245
+ const by = { inThreadReply: 0, prLevelDisposition: 0 };
1246
+ for (const t of botHighInlinesOnHead(threads, headSha)) {
1247
+ const discharge = dischargeOf(t, prComments);
1248
+ if (discharge === 'none') {
1249
+ applying.push(t);
1250
+ }
1251
+ else {
1252
+ discharged.push(t);
1253
+ if (discharge === 'in-thread-reply')
1254
+ by.inThreadReply++;
1255
+ else
1256
+ by.prLevelDisposition++;
1257
+ }
1258
+ }
1259
+ return { applying, discharged, by };
1260
+ }
935
1261
  /**
936
1262
  * Bot HIGH inlines whose `comment.commit` came back NULL — the predicate's
937
1263
  * input is missing, so whether they apply to the head is UNKNOWN
@@ -986,6 +1312,8 @@ export function evaluateMergeReady(payload, options) {
986
1312
  threads: { unresolvedBot: 0, pagesRead: 0, complete: false },
987
1313
  changesRequestedBy: [],
988
1314
  highInline: 0,
1315
+ dischargedHigh: 0,
1316
+ dischargedBy: { inThreadReply: 0, prLevelDisposition: 0 },
989
1317
  mergeStateStatus: null,
990
1318
  evaluatedAt: checkedAt,
991
1319
  evaluatedBy: 'gh (not read)',
@@ -1058,13 +1386,23 @@ export function evaluateMergeReady(payload, options) {
1058
1386
  complete: state.complete,
1059
1387
  };
1060
1388
  detail.changesRequestedBy = changesRequestedBy(state.reviews);
1061
- detail.highInline = highSeverityInlines(state.threads, state.headSha).length;
1389
+ const high = triageHighInlines(state.threads, state.headSha, state.prComments);
1390
+ detail.highInline = high.applying.length;
1391
+ detail.dischargedHigh = high.discharged.length;
1392
+ detail.dischargedBy = { ...high.by };
1393
+ // The audit breadcrumb for what the predicate RELEASED (mmnto-ai/totem#2861):
1394
+ // a discharged HIGH is a bot finding the round answered, and the record
1395
+ // must show it was read and discharged — and by which arm — never that it
1396
+ // was not seen.
1397
+ if (high.discharged.length > 0) {
1398
+ 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).`);
1399
+ }
1062
1400
  // The predicates are read BEFORE the unreadable-commit arm below: a failure
1063
1401
  // that stands in charter order ahead of predicate 4 is a fact, and a tier
1064
1402
  // never softens a fact — the arm that returned first here turned a failing
1065
1403
  // check plus one unplaceable HIGH inline into the UNEVALUABLE class, which
1066
1404
  // pilot maps to `warn` (mmnto-ai/totem#2844 round 1, CodeRabbit).
1067
- const blocked = firstFailure(state, detail);
1405
+ const blocked = firstFailure(state, detail, high.applying);
1068
1406
  // A bot HIGH inline whose `comment.commit` came back null cannot be placed
1069
1407
  // against the head, so predicate 4's input is missing for it: unevaluable and
1070
1408
  // NAMED, never a silent pass (round 2, F8). It still preempts predicate 5 —
@@ -1134,8 +1472,13 @@ export function evaluateMergeReady(payload, options) {
1134
1472
  notices,
1135
1473
  };
1136
1474
  }
1137
- /** The FIRST predicate that fails, in charter order, or null when the floor is met. */
1138
- function firstFailure(state, detail) {
1475
+ /**
1476
+ * The FIRST predicate that fails, in charter order, or null when the floor is
1477
+ * met. `applyingHigh` is predicate 4's set after the discharge read — the
1478
+ * caller triages once so the provenance counts and the verdict read the same
1479
+ * list.
1480
+ */
1481
+ function firstFailure(state, detail, applyingHigh) {
1139
1482
  // 1. checks
1140
1483
  if (detail.checks.failing > 0) {
1141
1484
  const names = state.checks
@@ -1173,13 +1516,32 @@ function firstFailure(state, detail) {
1173
1516
  evidence: `CHANGES_REQUESTED stands from ${detail.changesRequestedBy.join(', ')}`,
1174
1517
  };
1175
1518
  }
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];
1519
+ // 4. HIGH/Major bot inline on the head commit, not discharged
1520
+ if (applyingHigh.length > 0) {
1521
+ const first = applyingHigh[0];
1522
+ // Name the bare-resolve shape when that is what the first one is — the
1523
+ // operator's cure differs (post the disposition line) from the unanswered
1524
+ // shape's (answer the finding) — and name the window when the thread had
1525
+ // more comments than the read fetched. The LINE comes first in the clause
1526
+ // and the clause before the quoted body, so the id survives the
1527
+ // 160-character bound on `provenance.matched` (the third leg's r3-f3: a
1528
+ // clause that led with prose cut the id off at character 161); the tests
1529
+ // pin the id on `matched` for both clauses. With more than one applying,
1530
+ // the reason names the first and says where the rest are listed.
1531
+ const line = first.rootId === null
1532
+ ? 'no line can name it (its root comment id was not readable)'
1533
+ : `no "disposition: ${first.rootId} <verb>" line after its root`;
1534
+ const bareResolve = !first.isResolved
1535
+ ? ''
1536
+ : first.commentsComplete
1537
+ ? ` — 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)`
1538
+ : ` — resolved, ${line} and no non-bot reply in the ten comments read (the thread has more; mmnto-ai/totem#2861)`;
1539
+ const rest = applyingHigh.length > 1
1540
+ ? ` (the first of ${applyingHigh.length}; a \`totem resolve-threads\` dry run lists every root id)`
1541
+ : '';
1180
1542
  return {
1181
1543
  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)}"`,
1544
+ 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
1545
  };
1184
1546
  }
1185
1547
  // 5. GitHub's own mergeability