@jphutchins/code-review 0.1.0-alpha.65 → 0.1.0-alpha.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { execFile } from 'child_process';
9
9
  import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
10
10
  import { tmpdir } from 'os';
11
11
  import { promisify } from 'util';
12
+ import { isRight } from 'fp-ts/lib/Either.js';
12
13
  import parseDiff from 'parse-diff';
13
14
  import { Ajv2020 } from 'ajv/dist/2020.js';
14
15
  import _addFormats from 'ajv-formats';
@@ -272,11 +273,28 @@ var withoutRebuttals = (doc) => ({
272
273
  findings: doc.findings.map(({ rebuttal: _rebuttal, ...f }) => f),
273
274
  ...doc.systemic_problems !== void 0 ? { systemic_problems: doc.systemic_problems.map(({ rebuttal: _rebuttal, ...s }) => s) } : {}
274
275
  });
276
+ var withSystemicProblems = ({ systemic_problems: _replaced, ...doc }, systemic) => ({
277
+ ...doc,
278
+ ...systemic.length > 0 ? { systemic_problems: [...systemic] } : {}
279
+ });
275
280
  var synthesizedId = (parts, prefix) => `${prefix}${createHash("sha256").update(parts.join("\0")).digest("base64url").slice(0, 12)}`;
276
281
  var synthesizedFindingId = (path, title) => synthesizedId([path, title], "f-");
277
282
  var synthesizedSystemicId = (title) => synthesizedId([title], "s-");
278
283
  var isSynthesizedFindingId = (id) => /^f-[A-Za-z0-9_-]{12}$/.test(id);
279
284
  var resolveFindingId = (f) => f.id !== "" ? f.id : synthesizedFindingId(f.path, f.title);
285
+ var resolveSystemicId = (s) => s.id !== void 0 && s.id !== "" ? s.id : s.title !== "" ? synthesizedSystemicId(s.title) : void 0;
286
+ var findingIdIn = (ids) => (f) => ids.has(resolveFindingId(f));
287
+ var systemicIdIn = (ids) => (s) => {
288
+ const id = resolveSystemicId(s);
289
+ return id !== void 0 && ids.has(id);
290
+ };
291
+ var withoutIds = (doc, ids) => ids.size === 0 ? doc : {
292
+ ...withSystemicProblems(
293
+ doc,
294
+ (doc.systemic_problems ?? []).filter((s) => !systemicIdIn(ids)(s))
295
+ ),
296
+ findings: doc.findings.filter((f) => !findingIdIn(ids)(f))
297
+ };
280
298
  var resolveRuleId = (rec) => rec.id !== void 0 && rec.id !== "" ? rec.id : rec.code !== void 0 && rec.code !== "" ? rec.code : rec.path !== void 0 ? synthesizedFindingId(rec.path, rec.title) : void 0;
281
299
  var usableCountsMap = (v) => {
282
300
  if (typeof v !== "object" || v === null || Array.isArray(v)) return void 0;
@@ -664,6 +682,369 @@ var warnStalePrices = (prices, warn = defaultWarn) => {
664
682
  `::warning:: code-review cost: the price map was last verified ${prices._updated}, before this CLI's release (${RELEASED}) \u2014 the map may miss pricing this CLI ships; re-verify against the provider's pricing page`
665
683
  );
666
684
  };
685
+ var DispositionCodec = t.keyof({ fixed: null, refuted: null, dismissed: null });
686
+ var DISPOSITIONS = Object.keys(DispositionCodec.keys);
687
+ var RulingCodec = t.keyof({ upheld: null, overruled: null });
688
+ var RULINGS = Object.keys(RulingCodec.keys);
689
+ var ResponseDispositionCodec = t.keyof({
690
+ fixed: null,
691
+ refuted: null,
692
+ dismissed: null,
693
+ upheld: null,
694
+ overruled: null,
695
+ unstated: null
696
+ });
697
+ var isAnswerWord = (word) => DispositionCodec.is(word) || RulingCodec.is(word);
698
+ var RESPONSE_FORM = `Review-Response: <id> ${DISPOSITIONS.join("|")} \u2014 <reason>`;
699
+ var RESPONSE_TABLE_COLUMNS = ["id", "disposition", "reason"];
700
+ `| ${RESPONSE_TABLE_COLUMNS.join(" | ")} |`;
701
+ `| ${RESPONSE_TABLE_COLUMNS.map(() => "---").join(" | ")} |`;
702
+ var listed = (words, conjunction) => {
703
+ const quoted = words.map((word) => `\`${word}\``);
704
+ return quoted.length < 2 ? quoted.join("") : `${quoted.slice(0, -1).join(", ")} ${conjunction} ${quoted.at(-1) ?? ""}`;
705
+ };
706
+ var RULING_FORM = `Review-Response: <id> ${RULINGS.join("|")} \u2014 <reason>`;
707
+ var RULING_TEACHING = `A maintainer rules on a contested finding by answering its id, in a table row or a \`${RULING_FORM}\` commit line, with \`upheld\` (the finding stands at full weight until it is fixed) or \`overruled\` (it is closed for good).`;
708
+ var RESPONSE_TEACHING = `To answer findings, post a PR conversation comment holding a rendered markdown table, not one inside a code block, whose columns are ${listed(RESPONSE_TABLE_COLUMNS, "and")}, one row per finding id, its disposition exactly one of ${listed(DISPOSITIONS, "or")}; or put \`${RESPONSE_FORM}\` lines in a commit message. Inline-thread replies are not read. A systemic problem's id, when shown, answers its whole class.`;
709
+
710
+ // src/responses.ts
711
+ var RESPONSE_REASON_CLIP_CHARS = 300;
712
+ var RESPONSES_PER_CHANNEL = 25;
713
+ var ChannelCodec = t.keyof({ comment: null, commit: null });
714
+ var MAINTAINER_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"];
715
+ var CLOSING_DISPOSITIONS = /* @__PURE__ */ new Set(["refuted", "dismissed"]);
716
+ var ResponseShape = t.type({
717
+ id: t.string,
718
+ disposition: ResponseDispositionCodec,
719
+ reason: t.string,
720
+ channel: ChannelCodec,
721
+ source_url: t.string,
722
+ author: t.union([t.string, t.null]),
723
+ author_association: t.union([t.string, t.null]),
724
+ created_at: t.union([t.string, t.null])
725
+ });
726
+ var ResponseCodec = strictExact("Response", ResponseShape, [ResponseShape]);
727
+ var UnmatchedResponseShape = t.type({
728
+ id: t.string,
729
+ channel: ChannelCodec,
730
+ source_url: t.string
731
+ });
732
+ var UnmatchedResponseCodec = strictExact("UnmatchedResponse", UnmatchedResponseShape, [
733
+ UnmatchedResponseShape
734
+ ]);
735
+ var ResponsesFileAnswers = t.type({
736
+ responses: t.array(ResponseCodec),
737
+ unmatched: t.array(UnmatchedResponseCodec)
738
+ });
739
+ var HeldClaimShape = t.type({
740
+ id: t.string,
741
+ title: t.string,
742
+ severity: SeverityCodec,
743
+ since: t.string
744
+ });
745
+ var HeldClaimCodec = strictExact("HeldClaim", HeldClaimShape, [HeldClaimShape]);
746
+ var ResponsesFileDialogue = t.partial({
747
+ contested: t.array(HeldClaimCodec),
748
+ upheld: t.array(HeldClaimCodec),
749
+ overruled: t.array(HeldClaimCodec)
750
+ });
751
+ var ResponsesFileShape = t.intersection([ResponsesFileAnswers, ResponsesFileDialogue]);
752
+ strictExact("ResponsesFile", ResponsesFileShape, [
753
+ ResponsesFileAnswers,
754
+ ResponsesFileDialogue
755
+ ]);
756
+ var RESPONSE_LINE_RE = /^ {0,3}review-response:\s*(\S+)\s+([A-Za-z]+)(?![A-Za-z]|\s*\|)[\s—–:-]*(.*)$/i;
757
+ var FENCE_OPEN_RE = /^ {0,3}(`{3,}(?=[^`]*$)|~{3,})/;
758
+ var LINE_BREAK_RE = /\r\n?|\n|\u2028|\u2029/;
759
+ var unfencedLines = (text) => text.split(LINE_BREAK_RE).reduce(
760
+ (state, line) => {
761
+ const marker = FENCE_OPEN_RE.exec(line)?.[1];
762
+ if (state.fence === null) {
763
+ if (marker !== void 0) return { fence: marker, lines: state.lines };
764
+ state.lines.push(line);
765
+ return state;
766
+ }
767
+ const closes = marker !== void 0 && marker[0] === state.fence[0] && marker.length >= state.fence.length && line.trim() === marker;
768
+ return closes ? { fence: null, lines: state.lines } : state;
769
+ },
770
+ { fence: null, lines: [] }
771
+ ).lines;
772
+ var WHOLE_LINE_CODE_SPAN_RE = /^ {0,3}`([^`]+)`\s*$/;
773
+ var answerLines = (text) => unfencedLines(text).map((line) => WHOLE_LINE_CODE_SPAN_RE.exec(line)?.[1] ?? line);
774
+ var unwrapId = (token) => token.replace(/^[`"']+|[`"'.,:;]+$/g, "");
775
+ var lineAnswers = (lines) => lines.flatMap((line) => {
776
+ const match = RESPONSE_LINE_RE.exec(line);
777
+ const id = unwrapId(match?.[1] ?? "");
778
+ const disposition = match?.[2]?.toLowerCase();
779
+ return id !== "" && isAnswerWord(disposition) ? [
780
+ {
781
+ id,
782
+ disposition,
783
+ reason: clipText((match?.[3] ?? "").trim(), RESPONSE_REASON_CLIP_CHARS)
784
+ }
785
+ ] : [];
786
+ });
787
+ var ID_IN_CELL_RE = /`([^`]+)`/g;
788
+ var ID_HEADERS = /* @__PURE__ */ new Set(["id", "ids", "finding", "findings"]);
789
+ var DISPOSITION_HEADERS = /* @__PURE__ */ new Set(["disposition", "verdict", "resolution"]);
790
+ var CELL_RE = /((?:\\[\s\S]?|[^\\|])*)(\||$)/g;
791
+ var splitCells = (row) => {
792
+ const cells = [...row.matchAll(CELL_RE)];
793
+ return cells.slice(0, cells.findIndex((match) => match[2] !== "|") + 1).map((match) => match[1] ?? "");
794
+ };
795
+ var BLOCK_START_RE = /^ {0,3}([-+*]\s|\d+[.)]\s|#{1,6}(\s|$)|>)/;
796
+ var tableRow = (line) => {
797
+ if (!/^ {0,3}\S/.test(line) || BLOCK_START_RE.test(line)) return null;
798
+ const split = splitCells(line.trim());
799
+ if (split.length < 2) return null;
800
+ const inner = split.slice(line.trim().startsWith("|") ? 1 : 0);
801
+ return (inner.length > 1 && inner[inner.length - 1]?.trim() === "" ? inner.slice(0, -1) : inner).map((cell) => cell.trim().replace(/\\\|/g, "|"));
802
+ };
803
+ var isDelimiter = (cells) => cells !== null && cells.every((cell) => /^:?-+:?$/.test(cell));
804
+ var plainCell = (cell) => cell.replace(/[*_`]/g, "").trim().toLowerCase();
805
+ var columnName = (cell) => plainCell(cell).split(/[\s/]+/)[0] ?? "";
806
+ var idsInCell = (cell) => {
807
+ const ids = cell.replace(/\([^()]*\)/g, "");
808
+ const quoted = [...ids.matchAll(ID_IN_CELL_RE)].map((match) => unwrapId((match[1] ?? "").trim())).filter((id) => id !== "");
809
+ const bare = ids.split(",").map((piece) => unwrapId(piece.trim())).filter((id) => id !== "");
810
+ return quoted.length > 0 ? quoted : bare.every((id) => /^\S+$/.test(id)) ? bare : [];
811
+ };
812
+ var tableDisposition = (cell) => {
813
+ const firstWord = plainCell(cell).replace(/^[^a-z]+/, "").split(/[^a-z]/)[0] ?? "";
814
+ return isAnswerWord(firstWord) ? firstWord : "unstated";
815
+ };
816
+ var rowAnswers = (cells, columns) => {
817
+ const dispositionCell = cells[columns.disposition] ?? "";
818
+ const disposition = tableDisposition(dispositionCell);
819
+ const verdictOnly = isAnswerWord(plainCell(dispositionCell));
820
+ const reason = clipText(
821
+ cells.filter(
822
+ (cell, column) => cell !== "" && column !== columns.id && !(verdictOnly && column === columns.disposition)
823
+ ).join(" \u2014 "),
824
+ RESPONSE_REASON_CLIP_CHARS
825
+ );
826
+ return idsInCell(cells[columns.id] ?? "").map((id) => ({ id, disposition, reason }));
827
+ };
828
+ var namesAColumn = (name) => ID_HEADERS.has(name) || DISPOSITION_HEADERS.has(name);
829
+ var tableAnswers = (lines) => {
830
+ const rows = lines.map(tableRow);
831
+ return rows.reduce(
832
+ (state, cells, index) => {
833
+ if (state.skipDelimiter) return { ...state, skipDelimiter: false };
834
+ if (cells === null) return { ...state, inTable: false, columns: null };
835
+ const opensTable = !isDelimiter(cells) && isDelimiter(rows[index + 1] ?? null) && (!state.inTable || cells.map(columnName).some(namesAColumn));
836
+ if (opensTable) {
837
+ const header = cells.map(columnName);
838
+ const id = header.findIndex((name) => ID_HEADERS.has(name));
839
+ const disposition = header.findIndex((name) => DISPOSITION_HEADERS.has(name));
840
+ return {
841
+ ...state,
842
+ inTable: true,
843
+ skipDelimiter: true,
844
+ columns: id < 0 || disposition < 0 ? null : { id, disposition }
845
+ };
846
+ }
847
+ if (state.inTable && state.columns !== null && !isDelimiter(cells)) {
848
+ state.answers.push(...rowAnswers(cells, state.columns));
849
+ }
850
+ return isDelimiter(rows[index + 1] ?? null) ? { ...state, skipDelimiter: true } : state;
851
+ },
852
+ { columns: null, inTable: false, skipDelimiter: false, answers: [] }
853
+ ).answers;
854
+ };
855
+ var parseResponses = (text) => {
856
+ const lines = answerLines(text);
857
+ const seen = /* @__PURE__ */ new Set();
858
+ return [...lineAnswers(lines), ...tableAnswers(lines)].filter((answer) => {
859
+ const key2 = JSON.stringify([answer.id, answer.disposition, answer.reason]);
860
+ if (seen.has(key2)) return false;
861
+ seen.add(key2);
862
+ return true;
863
+ });
864
+ };
865
+ var isHuman = (login, type9, botLogin) => login !== botLogin && type9 === "User";
866
+ var newestFirst = (a, b) => {
867
+ const [left2, right2] = [a.created_at ?? "", b.created_at ?? ""];
868
+ return left2 < right2 ? 1 : left2 > right2 ? -1 : b.id - a.id;
869
+ };
870
+ var answersFrom = (input) => {
871
+ const comments = [...input.comments].filter((comment) => isHuman(comment.user.login, comment.user.type ?? null, input.botLogin)).sort(newestFirst).flatMap(
872
+ (comment) => parseResponses(comment.body ?? "").map((line) => ({
873
+ ...line,
874
+ channel: "comment",
875
+ source_url: `https://github.com/${input.repo}/pull/${String(input.prNumber)}#issuecomment-${String(comment.id)}`,
876
+ author: comment.user.login,
877
+ author_association: comment.author_association ?? null,
878
+ created_at: comment.created_at ?? null
879
+ }))
880
+ );
881
+ const commits = [...input.commits].reverse().flatMap(
882
+ (commit) => parseResponses(commit.message).map((line) => ({
883
+ ...line,
884
+ channel: "commit",
885
+ source_url: `https://github.com/${input.repo}/commit/${commit.sha}`,
886
+ author: commit.author,
887
+ author_association: null,
888
+ created_at: commit.date ?? null
889
+ }))
890
+ );
891
+ return { comments, commits };
892
+ };
893
+ var harvestResponses = (input) => {
894
+ const { comments: fromComments, commits: fromCommits } = answersFrom(input);
895
+ const matched = (response) => input.priorIds.has(response.id);
896
+ const echoed = (response) => !matched(response) && ID_SHAPE_RE.test(response.id);
897
+ const groups = [fromComments, fromCommits].flatMap((channel) => [
898
+ channel.filter(matched),
899
+ channel.filter(echoed)
900
+ ]);
901
+ const [comments, commentEchoes, commits, commitEchoes] = groups.map(
902
+ (group) => group.slice(0, RESPONSES_PER_CHANNEL)
903
+ );
904
+ return {
905
+ file: {
906
+ responses: [...comments ?? [], ...commits ?? []],
907
+ unmatched: [...commentEchoes ?? [], ...commitEchoes ?? []].map(
908
+ ({ id, channel, source_url }) => ({ id, channel, source_url })
909
+ )
910
+ },
911
+ dropped: groups.reduce(
912
+ (total, group) => total + Math.max(0, group.length - RESPONSES_PER_CHANNEL),
913
+ 0
914
+ )
915
+ };
916
+ };
917
+ var isTrustedResponse = (response, headInBaseRepo) => {
918
+ switch (response.channel) {
919
+ case "comment":
920
+ return MAINTAINER_ASSOCIATIONS.includes(response.author_association ?? "");
921
+ case "commit":
922
+ return headInBaseRepo;
923
+ }
924
+ };
925
+ var answeredInstant = (response) => response.created_at === null ? Number.NaN : Date.parse(response.created_at);
926
+ var closingResponses = (responses, isTrusted) => newestTrustedAnswersIn(responses, isTrusted, CLOSING_DISPOSITIONS);
927
+ var withRulingsOn = (responses, ruledIds, isTrusted) => responses.map(
928
+ (response) => RulingCodec.is(response.disposition) && !(ruledIds.has(response.id) && isTrusted(response)) ? { ...response, disposition: "unstated" } : response
929
+ );
930
+ var newestTrustedAnswersIn = (responses, isTrusted, dispositions) => {
931
+ const byId = responses.filter((response) => response.disposition !== "unstated" && isTrusted(response)).reduce((groups, response) => {
932
+ const timed = { response, at: answeredInstant(response) };
933
+ groups.set(response.id, [...groups.get(response.id) ?? [], timed]);
934
+ return groups;
935
+ }, /* @__PURE__ */ new Map());
936
+ return [...byId.values()].flatMap((answers) => {
937
+ if (answers.some(({ at }) => Number.isNaN(at))) return [];
938
+ const latest = Math.max(...answers.map(({ at }) => at));
939
+ const newest = answers.filter(({ at }) => at === latest).map(({ response }) => response);
940
+ return newest.every((response) => dispositions.has(response.disposition)) ? [...newest].sort((a, b) => a.source_url < b.source_url ? -1 : 1).slice(0, 1) : [];
941
+ });
942
+ };
943
+
944
+ // src/dialogue.ts
945
+ var DialogueStateCodec = t.keyof({
946
+ rebutted: null,
947
+ contested: null,
948
+ upheld: null,
949
+ overruled: null
950
+ });
951
+ var DialogueEntryCodec = t.type({
952
+ id: t.string,
953
+ state: DialogueStateCodec,
954
+ answer: t.string,
955
+ at: t.string,
956
+ title: t.string,
957
+ severity: SeverityCodec
958
+ });
959
+ var DIALOGUE_STATES = Object.keys(DialogueStateCodec.keys);
960
+ var DIALOGUE_ENTRY_CAP = 32;
961
+ var decodeDialogue = (raw) => {
962
+ const items = Array.isArray(raw) ? raw : [];
963
+ const decoded = items.map((item) => ({ item, entry: DialogueEntryCodec.decode(item) }));
964
+ const entries = decoded.flatMap(({ entry }) => isRight(entry) ? [entry.right] : []);
965
+ const undecoded = decoded.flatMap(({ item, entry }) => isRight(entry) ? [] : [item]);
966
+ return { entries, undecoded, skipped: Array.isArray(raw) ? undecoded.length : 1 };
967
+ };
968
+ var dialogueAnswers = (responses, isTrusted) => {
969
+ const newest = (dispositions) => new Map(
970
+ newestTrustedAnswersIn(responses, isTrusted, new Set(dispositions)).map((response) => [
971
+ response.id,
972
+ response
973
+ ])
974
+ );
975
+ return {
976
+ closures: newest(["refuted", "dismissed"]),
977
+ overrulings: newest(["overruled"]),
978
+ upholdings: newest(["upheld"]),
979
+ fixed: new Set(newest(["fixed"]).keys())
980
+ };
981
+ };
982
+ var linked = (entry, answer, state) => answer.created_at === null ? [entry] : [{ ...entry, state, answer: answer.source_url, at: answer.created_at }];
983
+ var isNewer = (answer, entry) => answer.created_at !== null && Date.parse(answer.created_at) > Date.parse(entry.at);
984
+ var isOlder = (answer, entry) => answer.created_at !== null && Date.parse(answer.created_at) < Date.parse(entry.at);
985
+ var advanceEntry = (entry, answers, item) => {
986
+ if (answers.fixed.has(entry.id)) return [];
987
+ const refreshed = item !== void 0 && (entry.state === "contested" || entry.state === "upheld" || item.rebutted) ? { ...entry, title: item.title, severity: item.severity } : entry;
988
+ const closure = answers.closures.get(entry.id);
989
+ switch (entry.state) {
990
+ case "rebutted":
991
+ return closure === void 0 ? [refreshed] : linked(refreshed, closure, isNewer(closure, entry) ? "contested" : "rebutted");
992
+ case "contested": {
993
+ const overruling = answers.overrulings.get(entry.id);
994
+ const upholding = answers.upholdings.get(entry.id);
995
+ return upholding !== void 0 ? linked(refreshed, upholding, "upheld") : overruling !== void 0 ? linked(refreshed, overruling, "overruled") : closure === void 0 ? [refreshed] : linked(refreshed, closure, isOlder(closure, entry) ? "rebutted" : "contested");
996
+ }
997
+ case "upheld": {
998
+ const overruling = answers.overrulings.get(entry.id);
999
+ return overruling !== void 0 && isNewer(overruling, entry) ? linked(refreshed, overruling, "overruled") : [refreshed];
1000
+ }
1001
+ case "overruled": {
1002
+ const upholding = answers.upholdings.get(entry.id);
1003
+ return upholding !== void 0 && isNewer(upholding, entry) ? linked(refreshed, upholding, "upheld") : [refreshed];
1004
+ }
1005
+ }
1006
+ };
1007
+ var advanceDialogue = (prior, answers, current) => {
1008
+ const currentById = new Map(current.map((item) => [item.id, item]));
1009
+ const priorIds = new Set(prior.map((entry) => entry.id));
1010
+ const opened = [...currentById.values()].flatMap((item) => {
1011
+ const closure = answers.closures.get(item.id);
1012
+ const at = closure?.created_at ?? null;
1013
+ return priorIds.has(item.id) || !item.rebutted || closure === void 0 || at === null ? [] : [
1014
+ {
1015
+ id: item.id,
1016
+ state: "rebutted",
1017
+ answer: closure.source_url,
1018
+ at,
1019
+ title: item.title,
1020
+ severity: item.severity
1021
+ }
1022
+ ];
1023
+ });
1024
+ const entries = [
1025
+ ...prior.flatMap((entry) => advanceEntry(entry, answers, currentById.get(entry.id))),
1026
+ ...opened
1027
+ ];
1028
+ const held = entries.filter((entry) => entry.state !== "rebutted");
1029
+ return [
1030
+ ...held,
1031
+ ...entries.filter((entry) => entry.state === "rebutted").slice(0, Math.max(0, DIALOGUE_ENTRY_CAP - held.length))
1032
+ ];
1033
+ };
1034
+ var dialogueMetricsTable = (metrics) => [
1035
+ "### \u{1F4AC} Review dialogue",
1036
+ "",
1037
+ "| | |",
1038
+ "|---|--:|",
1039
+ `| answers read (comments \xB7 commits) | ${String(metrics.commentAnswers)} \xB7 ${String(metrics.commitAnswers)} |`,
1040
+ `| trusted closing answers | ${String(metrics.closures)} |`,
1041
+ `| re-raises dropped (answered \xB7 overruled) | ${String(metrics.droppedAnswered)} \xB7 ${String(metrics.droppedOverruled)} |`,
1042
+ `| re-raises kept and annotated | ${String(metrics.annotated)} |`,
1043
+ `| ${DIALOGUE_STATES.join(" \xB7 ")} | ${DIALOGUE_STATES.map(
1044
+ (state) => String(metrics.entries.filter((entry) => entry.state === state).length)
1045
+ ).join(" \xB7 ")} |`,
1046
+ `| systemic problems no trusted answer names | ${String(metrics.systemicUnanswered)} |`
1047
+ ].join("\n");
667
1048
 
668
1049
  // src/patch.ts
669
1050
  var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
@@ -791,6 +1172,7 @@ var decodeBase64Json = (b64) => {
791
1172
  return void 0;
792
1173
  }
793
1174
  };
1175
+ var noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
794
1176
  var findingsPointer = (jsonUrl) => encodeMarker(jsonUrl);
795
1177
  var warnedValveFindings = /* @__PURE__ */ new Set();
796
1178
  var INLINE_PROSE_CLIP_THRESHOLD_CHARS = 3e4;
@@ -1022,8 +1404,15 @@ var convergenceScore = (doc, threshold) => round2(
1022
1404
  0
1023
1405
  )
1024
1406
  );
1025
- var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, ids = {}, sha) => {
1026
- const score = convergenceScore(doc, threshold);
1407
+ var CONTESTED_MAJOR = 0.51;
1408
+ var contestedWeight = (severity, threshold) => severity === "major" ? CONTESTED_MAJOR : convergenceFloor(severity, threshold);
1409
+ var dialogueScore = (doc, contested, threshold) => {
1410
+ return round2(
1411
+ convergenceScore(withoutIds(doc, new Set(contested.map((entry) => entry.id))), threshold) + contested.reduce((sum, entry) => sum + contestedWeight(entry.severity, threshold), 0)
1412
+ );
1413
+ };
1414
+ var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, ids = {}, sha, contested = []) => {
1415
+ const score = dialogueScore(doc, contested, threshold);
1027
1416
  const normalized = normalizeIdCounts(ids, priorRounds[priorRounds.length - 1]?.ids);
1028
1417
  const current = {
1029
1418
  round,
@@ -1154,6 +1543,13 @@ var findingsMarkerPair = (jsonUrl, convergence) => {
1154
1543
  return marker === "" ? conv : `${marker}
1155
1544
  ${conv}`;
1156
1545
  };
1546
+ var DIALOGUE_RE = /<!-- code-review:dialogue;base64 ([A-Za-z0-9+/=]+) -->/;
1547
+ var dialogueMarker = (entries, undecoded = []) => entries.length + undecoded.length === 0 ? "" : `<!-- code-review:dialogue;base64 ${Buffer.from(JSON.stringify([...entries, ...undecoded]), "utf-8").toString("base64")} -->`;
1548
+ var carriedDialogueMarker = (body) => DIALOGUE_RE.exec(body)?.[0] ?? "";
1549
+ var parseDialogueMarker = (body) => {
1550
+ const b64 = DIALOGUE_RE.exec(body)?.[1];
1551
+ return b64 === void 0 ? { entries: [], undecoded: [], skipped: 0 } : decodeDialogue(decodeBase64Json(b64));
1552
+ };
1157
1553
  var parseConvergenceMarker = (body) => {
1158
1554
  const b64 = CONVERGENCE_RE.exec(body)?.[1];
1159
1555
  return b64 === void 0 ? null : validStampedConvergence(decodeBase64Json(b64));
@@ -1210,11 +1606,20 @@ var carryForwardMarkers = (body) => {
1210
1606
  const findings = carriedFindingsMarker(body);
1211
1607
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
1212
1608
  const convergence = CONVERGENCE_RE.exec(body)?.[0];
1609
+ const dialogue = DIALOGUE_RE.exec(body)?.[0];
1213
1610
  const rounds = ROUNDS_RE.exec(body)?.[0];
1214
1611
  const signal = SIGNAL_RE.exec(body)?.[0];
1215
1612
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
1216
1613
  ${findings}` : void 0;
1217
- return [findingsBlock, reviewedSha, carriedProvenanceMarkers(body), convergence, rounds, signal].filter((m) => m !== void 0 && m !== "").join("\n\n");
1614
+ return [
1615
+ findingsBlock,
1616
+ reviewedSha,
1617
+ carriedProvenanceMarkers(body),
1618
+ convergence,
1619
+ dialogue,
1620
+ rounds,
1621
+ signal
1622
+ ].filter((m) => m !== void 0 && m !== "").join("\n\n");
1218
1623
  };
1219
1624
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
1220
1625
  var projectPatch = (patch, surface) => {
@@ -1392,29 +1797,38 @@ var resolvePriorFindings = async (body, read) => {
1392
1797
  };
1393
1798
 
1394
1799
  // src/answered.ts
1395
- var answeredRegistryFrom = (closures, prior) => closures.flatMap((response) => {
1396
- const finding = prior?.findings.find((f) => resolveFindingId(f) === response.id);
1397
- return finding === void 0 ? [] : [
1398
- {
1399
- code: response.id,
1400
- title: finding.title,
1401
- description: finding.description,
1402
- reasoning: finding.reasoning,
1403
- severity: finding.severity,
1404
- path: finding.path,
1405
- patch: finding.patch ?? null,
1406
- answerUrl: response.source_url,
1407
- answerAuthor: response.author
1408
- }
1409
- ];
1800
+ var closedPriorItems = (closures, items, idOf) => closures.flatMap((response) => {
1801
+ const item = items.find((candidate) => idOf(candidate) === response.id);
1802
+ return item === void 0 ? [] : [[item, response]];
1410
1803
  });
1804
+ var closedClaim = (item, response) => ({
1805
+ code: response.id,
1806
+ title: item.title,
1807
+ description: item.description,
1808
+ reasoning: item.reasoning,
1809
+ severity: item.severity,
1810
+ answerUrl: response.source_url,
1811
+ answerAuthor: response.author
1812
+ });
1813
+ var answeredRegistryFrom = (closures, prior) => closedPriorItems(closures, prior?.findings ?? [], resolveFindingId).map(
1814
+ ([finding, response]) => ({
1815
+ ...closedClaim(finding, response),
1816
+ path: finding.path,
1817
+ patch: finding.patch ?? null
1818
+ })
1819
+ );
1820
+ var answeredSystemicRegistryFrom = (closures, prior) => closedPriorItems(closures, prior?.systemic_problems ?? [], resolveSystemicId).map(
1821
+ ([systemic, response]) => closedClaim(systemic, response)
1822
+ );
1411
1823
  var isSynthesizedTitleMatch = (f, e) => isSynthesizedFindingId(e.code) && e.title === f.title;
1412
1824
  var matches = (resolvedId, f, e) => e.code === resolvedId || isSynthesizedTitleMatch(f, e);
1413
- var couldMatch = (code, findings) => isSynthesizedFindingId(code) || findings.some((f) => resolveFindingId(f) === code);
1414
- var VERBATIM_FIELDS = ["title", "description", "reasoning", "severity", "path", "patch"];
1825
+ var couldMatch = (code, doc) => isSynthesizedFindingId(code) || doc.findings.some((f) => resolveFindingId(f) === code) || (doc.systemic_problems ?? []).some((s) => resolveSystemicId(s) === code);
1826
+ var CLAIM_FIELDS = ["title", "description", "reasoning", "severity"];
1827
+ var VERBATIM_FIELDS = [...CLAIM_FIELDS, "path", "patch"];
1415
1828
  var verbatimFieldEqual = (f, e, field) => field === "patch" ? (f.patch ?? null) === e.patch : f[field] === e[field];
1416
- var isVerbatimReRaise = (f, e) => !hasRebuttal(f) && VERBATIM_FIELDS.every((field) => verbatimFieldEqual(f, e, field));
1417
- var isAnsweredDrop = (resolvedId, f, e) => matches(resolvedId, f, e) && isVerbatimReRaise(f, e) && f.severity !== "critical";
1829
+ var isVerbatimReRaise = (item, fields, fieldEqual) => !hasRebuttal(item) && fields.every(fieldEqual);
1830
+ var isVerbatimNonCritical = (item, fields, fieldEqual) => isVerbatimReRaise(item, fields, fieldEqual) && item.severity !== "critical";
1831
+ var isAnsweredDrop = (resolvedId, f, e) => matches(resolvedId, f, e) && isVerbatimNonCritical(f, VERBATIM_FIELDS, (field) => verbatimFieldEqual(f, e, field));
1418
1832
  var verbatimMatchCount = (f, e) => VERBATIM_FIELDS.reduce((count, field) => count + (verbatimFieldEqual(f, e, field) ? 1 : 0), 0);
1419
1833
  var answeredNoteKey = (f) => f.id !== "" ? f.id : `title:${f.title}`;
1420
1834
  var bestTitleMatch = (f, registry) => {
@@ -1466,34 +1880,61 @@ var applyAnswered = (findings, registry) => {
1466
1880
  droppedCount
1467
1881
  };
1468
1882
  };
1469
- var answeredReRaiseNote = (entries, count) => {
1470
- if (entries.length === 0) return "";
1471
- const label = (e) => e.code !== "" ? `\`${escapeCodeBackticks(e.code)}\`` : `\u201C${escapeCodeBackticks(e.title)}\u201D`;
1472
- const lines = entries.map(
1473
- (e) => `> - ${label(e)} \u2014 [prior answer](${linkSafeUrl(e.answerUrl)})${byAuthor(e)}`
1474
- );
1475
- return [
1476
- `> \u21A9\uFE0F **${String(count)} finding(s) re-raised without new evidence \u2014 treated as answered** (a maintainer refuted or dismissed each):`,
1477
- ...lines
1478
- ].join("\n");
1883
+ var answeredSystemicNoteKey = (s) => `systemic:${resolveSystemicId(s) ?? ""}`;
1884
+ var systemicVerdict = (systemic, registry) => {
1885
+ const resolvedId = resolveSystemicId(systemic);
1886
+ const entry = registry.find((e) => e.code === resolvedId);
1887
+ return entry === void 0 ? { kind: "unanswered", systemic } : {
1888
+ kind: isVerbatimNonCritical(
1889
+ systemic,
1890
+ CLAIM_FIELDS,
1891
+ (field) => systemic[field] === entry[field]
1892
+ ) ? "dropped" : "annotated",
1893
+ systemic,
1894
+ entry
1895
+ };
1479
1896
  };
1480
- var DispositionCodec = t.keyof({ fixed: null, refuted: null, dismissed: null });
1481
- var DISPOSITIONS = Object.keys(DispositionCodec.keys);
1482
- var ResponseDispositionCodec = t.keyof({
1483
- fixed: null,
1484
- refuted: null,
1485
- dismissed: null,
1486
- unstated: null
1487
- });
1488
- var RESPONSE_FORM = `Review-Response: <id> ${DISPOSITIONS.join("|")} \u2014 <reason>`;
1489
- var RESPONSE_TABLE_COLUMNS = ["id", "disposition", "reason"];
1490
- `| ${RESPONSE_TABLE_COLUMNS.join(" | ")} |`;
1491
- `| ${RESPONSE_TABLE_COLUMNS.map(() => "---").join(" | ")} |`;
1492
- var listed = (words, conjunction) => {
1493
- const quoted = words.map((word) => `\`${word}\``);
1494
- return quoted.length < 2 ? quoted.join("") : `${quoted.slice(0, -1).join(", ")} ${conjunction} ${quoted.at(-1) ?? ""}`;
1897
+ var applyAnsweredSystemic = (systemic, registry) => {
1898
+ const verdicts = systemic.map((s) => systemicVerdict(s, registry));
1899
+ const dropped = verdicts.flatMap((v) => v.kind === "dropped" ? [v.entry] : []);
1900
+ return {
1901
+ systemic: verdicts.flatMap((v) => v.kind === "dropped" ? [] : [v.systemic]),
1902
+ reRaisedNotes: Object.fromEntries(
1903
+ verdicts.flatMap(
1904
+ (v) => v.kind === "annotated" ? [[answeredSystemicNoteKey(v.systemic), answeredNote(v.entry)]] : []
1905
+ )
1906
+ ),
1907
+ verbatimReRaised: [...new Map(dropped.map((e) => [e.code, e])).values()],
1908
+ droppedCount: dropped.length
1909
+ };
1495
1910
  };
1496
- var RESPONSE_TEACHING = `To answer findings, post a PR conversation comment holding a rendered markdown table, not one inside a code block, whose columns are ${listed(RESPONSE_TABLE_COLUMNS, "and")}, one row per finding id, its disposition exactly one of ${listed(DISPOSITIONS, "or")}; or put \`${RESPONSE_FORM}\` lines in a commit message. Inline-thread replies are not read. A systemic problem's id, when shown, answers its whole class.`;
1911
+ var applyOverruled = (items, rulings, idOf, noteKey) => {
1912
+ const ruled = items.map((item) => ({ item, ruling: rulings.get(idOf(item) ?? "") }));
1913
+ const drops = (entry) => entry.ruling !== void 0 && entry.item.severity !== "critical";
1914
+ const dropped = ruled.filter(drops).flatMap(({ ruling }) => ruling === void 0 ? [] : [ruling]);
1915
+ return {
1916
+ kept: ruled.filter((entry) => !drops(entry)).map(({ item }) => item),
1917
+ reRaisedNotes: Object.fromEntries(
1918
+ ruled.flatMap(
1919
+ ({ item, ruling }) => ruling !== void 0 && !drops({ item, ruling }) ? [[noteKey(item), answeredNote(ruling)]] : []
1920
+ )
1921
+ ),
1922
+ verbatimReRaised: [...new Map(dropped.map((ruling) => [ruling.code, ruling])).values()],
1923
+ droppedCount: dropped.length
1924
+ };
1925
+ };
1926
+ var closingAnswerLines = (entries, linkText) => entries.map((e) => {
1927
+ const label = e.code !== "" ? `\`${escapeCodeBackticks(e.code)}\`` : `\u201C${escapeCodeBackticks(e.title)}\u201D`;
1928
+ return `> - ${label} \u2014 [${linkText}](${linkSafeUrl(e.answerUrl)})${byAuthor(e)}`;
1929
+ });
1930
+ var answeredReRaiseNote = (entries, count) => entries.length === 0 ? "" : [
1931
+ `> \u21A9\uFE0F **${String(count)} re-raise(s) without new evidence \u2014 treated as answered** (a maintainer's answer closed each):`,
1932
+ ...closingAnswerLines(entries, "prior answer")
1933
+ ].join("\n");
1934
+ var overruledReRaiseNote = (entries, count) => entries.length === 0 ? "" : [
1935
+ `> \u2696\uFE0F **${String(count)} re-raise(s) of an id a maintainer overruled \u2014 closed for good**:`,
1936
+ ...closingAnswerLines(entries, "the ruling")
1937
+ ].join("\n");
1497
1938
 
1498
1939
  // src/render.ts
1499
1940
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
@@ -1587,7 +2028,7 @@ var sanitizeFinding = (f, answeredNotes, discussionByFinding, permalinkBase, una
1587
2028
  rangeLabel: lineRange(f.start_line, f.end_line, "\u2013"),
1588
2029
  ...permalink !== void 0 ? { permalink, permalinkAnchored: anchored } : {},
1589
2030
  patchProjection: projectPatch(f.patch, "comment-body"),
1590
- answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : "",
2031
+ answeredNote: noteFor(answeredNotes, key2),
1591
2032
  discussion: discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(discussionByFinding, f.id) ? discussionByFinding[f.id] ?? [] : [],
1592
2033
  ...discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(discussionTruncated, f.id) ? { discussionTruncated: discussionTruncated[f.id] } : {}
1593
2034
  };
@@ -1617,9 +2058,10 @@ var carriedLines = (f) => [
1617
2058
  `reasoning: ${clipText(f.reasoning, BODY_CLIP_CHARS)}`,
1618
2059
  ...f.patch !== void 0 ? ["patch:", clipText(f.patch, BODY_CLIP_CHARS)] : []
1619
2060
  ].flatMap((block) => commentSafe(block).split("\n")).map((line) => line.trimEnd());
1620
- var sanitizeSystemic = (s, discussion, discussionTruncated) => ({
2061
+ var sanitizeSystemic = (s, answeredNotes, discussion, discussionTruncated) => ({
1621
2062
  ...s,
1622
2063
  title: escapePipes(s.title),
2064
+ answeredNote: noteFor(answeredNotes, answeredSystemicNoteKey(s)),
1623
2065
  discussion,
1624
2066
  ...discussionTruncated !== void 0 ? { discussionTruncated } : {},
1625
2067
  discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncated, "") : "",
@@ -1671,6 +2113,7 @@ var render = (input) => {
1671
2113
  const trajectory = convergence?.rounds ?? input.rounds ?? [];
1672
2114
  const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(trajectory.slice(0, -1), input.findings.findings);
1673
2115
  const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && trajectory.length > 0)) && isReviewVerdict(input.findings.verdict);
2116
+ const contestedSuffix = (input.contested ?? []).length > 0 ? ` \xB7 ${String((input.contested ?? []).length)} contested` : "";
1674
2117
  const advisoryAllowed = isFullReviewRound;
1675
2118
  const discussionLinksFor = (id) => input.discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionByFinding, id) ? input.discussionByFinding[id] ?? [] : [];
1676
2119
  const discussionTruncatedFor = (id) => input.discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionTruncated, id) ? input.discussionTruncated[id] : void 0;
@@ -1750,6 +2193,7 @@ var render = (input) => {
1750
2193
  (input.findings.systemic_problems ?? []).map(
1751
2194
  (s) => sanitizeSystemic(
1752
2195
  s,
2196
+ input.answeredNotes,
1753
2197
  s.id !== void 0 ? discussionLinksFor(s.id) : [],
1754
2198
  s.id !== void 0 ? discussionTruncatedFor(s.id) : void 0
1755
2199
  )
@@ -1758,6 +2202,23 @@ var render = (input) => {
1758
2202
  (s) => s.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + s.discussionHtml.length : 0,
1759
2203
  (s) => ({ ...s, discussion: [], discussionHtml: "" })
1760
2204
  );
2205
+ const contestedBudget = budgetBySize(
2206
+ (input.contested ?? []).map((view) => {
2207
+ const discussion = discussionLinksFor(view.id);
2208
+ return {
2209
+ ...view,
2210
+ ...view.reRaise?.location !== void 0 ? { reRaise: { ...view.reRaise, location: escapeCodeBackticks(view.reRaise.location) } } : {},
2211
+ id: escapeCodeBackticks(view.id),
2212
+ title: escapePipes(view.title),
2213
+ answerUrl: linkSafeUrl(view.answerUrl),
2214
+ discussion,
2215
+ discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncatedFor(view.id), "") : ""
2216
+ };
2217
+ }),
2218
+ DISCUSSION_TOTAL_CHARS - orphanedBudget.used - discussionBudget.used - systemicBudget.used,
2219
+ (view) => view.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + view.discussionHtml.length : 0,
2220
+ (view) => ({ ...view, discussion: [], discussionHtml: "" })
2221
+ );
1761
2222
  const orphanedRender = {
1762
2223
  lines: orphanedBudget.kept.map(([, entry]) => entry.html),
1763
2224
  // The "(showing N of M)" total counts DISTINCT DISPLAYED entries — the pre-budget fold above
@@ -1784,6 +2245,7 @@ var render = (input) => {
1784
2245
  summaryAvailable: input.summaryAvailable ?? false,
1785
2246
  responseForm: RESPONSE_FORM,
1786
2247
  responseTeaching: RESPONSE_TEACHING,
2248
+ rulingTeaching: RULING_TEACHING,
1787
2249
  teachesResponses: isConvergenceRound(route, incomplete),
1788
2250
  shedNote: shedNote(
1789
2251
  input.shedCount ?? 0,
@@ -1800,7 +2262,7 @@ var render = (input) => {
1800
2262
  route: route ?? void 0,
1801
2263
  postedAt: input.postedAt ?? "",
1802
2264
  roundsSummary: roundsSummary(trajectory, input.roundCount),
1803
- convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : "",
2265
+ convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) + contestedSuffix : "",
1804
2266
  inlinePosted: input.inlineDisposition?.kind === "posted" ? input.inlineDisposition.count : 0,
1805
2267
  runUrl: input.runUrl,
1806
2268
  summaryAvailable: input.summaryAvailable ?? false,
@@ -1827,15 +2289,21 @@ var render = (input) => {
1827
2289
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
1828
2290
  postedAt: input.postedAt ?? "",
1829
2291
  severityCounts,
1830
- convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold),
2292
+ convergenceSummary: !isFullReviewRound ? "" : (convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold)) + contestedSuffix,
1831
2293
  strays: discussionBudget.kept,
1832
2294
  orphanedLines: orphanedRender.lines,
1833
2295
  orphanedUnresolvable: input.orphanedUnresolvable === true,
1834
- discussionDropped: discussionBudget.droppedItems.length + systemicBudget.droppedItems.length + orphanedBudget.droppedItems.length,
1835
- discussionDroppedIds: discussionBudget.droppedItems.map((v) => clipText(escapeCodeBackticks(v.idKey), 64)).slice(0, DROPPED_IDS_SHOWN),
2296
+ discussionDropped: discussionBudget.droppedItems.length + systemicBudget.droppedItems.length + contestedBudget.droppedItems.length + orphanedBudget.droppedItems.length,
2297
+ discussionDroppedIds: [
2298
+ ...discussionBudget.droppedItems.map((v) => clipText(escapeCodeBackticks(v.idKey), 64)),
2299
+ ...contestedBudget.droppedItems.map((v) => clipText(v.id, 64))
2300
+ ].slice(0, DROPPED_IDS_SHOWN),
1836
2301
  discussionDroppedSystemicIds: systemicBudget.droppedItems.flatMap((s) => s.id !== void 0 ? [clipText(s.id, 64)] : []).slice(0, DROPPED_IDS_SHOWN),
1837
2302
  discussionDroppedOrphanedIds: orphanedBudget.droppedItems.map(([token]) => clipText(token, 64)).slice(0, DROPPED_IDS_SHOWN),
1838
- discussionDroppedExtra: Math.max(0, discussionBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, systemicBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, orphanedBudget.droppedItems.length - DROPPED_IDS_SHOWN),
2303
+ discussionDroppedExtra: Math.max(
2304
+ 0,
2305
+ discussionBudget.droppedItems.length + contestedBudget.droppedItems.length - DROPPED_IDS_SHOWN
2306
+ ) + Math.max(0, systemicBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, orphanedBudget.droppedItems.length - DROPPED_IDS_SHOWN),
1839
2307
  discussionCap: PER_FINDING_LINKS,
1840
2308
  orphanedTotal: orphanedRender.total,
1841
2309
  suppressedNits: suppressedBudget.kept,
@@ -1857,6 +2325,7 @@ var render = (input) => {
1857
2325
  sameRootNotes: advisoryAllowed ? sameRootNotes : {},
1858
2326
  answeredNotes: input.answeredNotes ?? {},
1859
2327
  answeredReRaiseNote: input.answeredReRaiseNote ?? "",
2328
+ contested: contestedBudget.kept,
1860
2329
  reviewUrl: input.reviewUrl ?? null,
1861
2330
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
1862
2331
  // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to
@@ -1957,7 +2426,6 @@ var buildInlineComments = (findings, diff, context) => {
1957
2426
  const { inDiff, strays } = context.partition ?? partitionFindings(findings, indexDiff(diff));
1958
2427
  const eta = new Eta({ autoTrim: false });
1959
2428
  const modelsText = formatModels(models);
1960
- const noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1961
2429
  const comments = inDiff.map((f) => {
1962
2430
  const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
1963
2431
  const clipProse = fullFindings !== void 0 && findingPayload(f, fullFindings.schema_version).length > INLINE_PROSE_CLIP_THRESHOLD_CHARS;
@@ -2067,12 +2535,12 @@ var sumTranscriptUsage = (entries) => {
2067
2535
  },
2068
2536
  { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
2069
2537
  );
2070
- const models = [...summed.totals].map(([model, t9]) => ({
2538
+ const models = [...summed.totals].map(([model, t10]) => ({
2071
2539
  model,
2072
- input_tokens: t9.input,
2073
- output_tokens: t9.output,
2074
- cache_read_tokens: t9.cacheRead,
2075
- cache_write_tokens: t9.cacheWrite
2540
+ input_tokens: t10.input,
2541
+ output_tokens: t10.output,
2542
+ cache_read_tokens: t10.cacheRead,
2543
+ cache_write_tokens: t10.cacheWrite
2076
2544
  }));
2077
2545
  const bounds = entries.reduce(
2078
2546
  (acc, entry) => {
@@ -2297,7 +2765,7 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
2297
2765
  const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
2298
2766
  if (WRITE_TOOLS.has(toolName)) {
2299
2767
  const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
2300
- if (typeof fp === "string" && targets.some((t9) => fp === t9 || basename(fp) === basename(t9)))
2768
+ if (typeof fp === "string" && targets.some((t10) => fp === t10 || basename(fp) === basename(t10)))
2301
2769
  return true;
2302
2770
  }
2303
2771
  if (toolName === "Bash") {
@@ -2402,9 +2870,9 @@ var parseWallMs = (raw) => {
2402
2870
  };
2403
2871
  var parseEpochSecMs = (raw) => {
2404
2872
  if (raw === void 0) return null;
2405
- const t9 = raw.trim();
2406
- if (!/^\d+$/.test(t9)) return null;
2407
- const n = Number.parseInt(t9, 10);
2873
+ const t10 = raw.trim();
2874
+ if (!/^\d+$/.test(t10)) return null;
2875
+ const n = Number.parseInt(t10, 10);
2408
2876
  return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
2409
2877
  };
2410
2878
  var anchoredElapsedMs = (src) => {
@@ -2817,220 +3285,6 @@ var checkRun = async (input, ghApi = runGhApi) => {
2817
3285
  ];
2818
3286
  await ghApi(endpoint, JSON.stringify(body));
2819
3287
  };
2820
- var RESPONSE_REASON_CLIP_CHARS = 300;
2821
- var RESPONSES_PER_CHANNEL = 25;
2822
- var ChannelCodec = t.keyof({ comment: null, commit: null });
2823
- var MAINTAINER_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"];
2824
- var CLOSING_DISPOSITIONS = /* @__PURE__ */ new Set(["refuted", "dismissed"]);
2825
- var ResponseShape = t.type({
2826
- id: t.string,
2827
- disposition: ResponseDispositionCodec,
2828
- reason: t.string,
2829
- channel: ChannelCodec,
2830
- source_url: t.string,
2831
- author: t.union([t.string, t.null]),
2832
- author_association: t.union([t.string, t.null]),
2833
- created_at: t.union([t.string, t.null])
2834
- });
2835
- var ResponseCodec = strictExact("Response", ResponseShape, [ResponseShape]);
2836
- var UnmatchedResponseShape = t.type({
2837
- id: t.string,
2838
- channel: ChannelCodec,
2839
- source_url: t.string
2840
- });
2841
- var UnmatchedResponseCodec = strictExact("UnmatchedResponse", UnmatchedResponseShape, [
2842
- UnmatchedResponseShape
2843
- ]);
2844
- var ResponsesFileShape = t.type({
2845
- responses: t.array(ResponseCodec),
2846
- unmatched: t.array(UnmatchedResponseCodec)
2847
- });
2848
- strictExact("ResponsesFile", ResponsesFileShape, [
2849
- ResponsesFileShape
2850
- ]);
2851
- var RESPONSE_LINE_RE = /^ {0,3}review-response:\s*(\S+)\s+([A-Za-z]+)(?![A-Za-z]|\s*\|)[\s—–:-]*(.*)$/i;
2852
- var FENCE_OPEN_RE = /^ {0,3}(`{3,}(?=[^`]*$)|~{3,})/;
2853
- var LINE_BREAK_RE = /\r\n?|\n|\u2028|\u2029/;
2854
- var unfencedLines = (text) => text.split(LINE_BREAK_RE).reduce(
2855
- (state, line) => {
2856
- const marker = FENCE_OPEN_RE.exec(line)?.[1];
2857
- if (state.fence === null) {
2858
- if (marker !== void 0) return { fence: marker, lines: state.lines };
2859
- state.lines.push(line);
2860
- return state;
2861
- }
2862
- const closes = marker !== void 0 && marker[0] === state.fence[0] && marker.length >= state.fence.length && line.trim() === marker;
2863
- return closes ? { fence: null, lines: state.lines } : state;
2864
- },
2865
- { fence: null, lines: [] }
2866
- ).lines;
2867
- var WHOLE_LINE_CODE_SPAN_RE = /^ {0,3}`([^`]+)`\s*$/;
2868
- var answerLines = (text) => unfencedLines(text).map((line) => WHOLE_LINE_CODE_SPAN_RE.exec(line)?.[1] ?? line);
2869
- var unwrapId = (token) => token.replace(/^[`"']+|[`"'.,:;]+$/g, "");
2870
- var lineAnswers = (lines) => lines.flatMap((line) => {
2871
- const match = RESPONSE_LINE_RE.exec(line);
2872
- const id = unwrapId(match?.[1] ?? "");
2873
- const disposition = match?.[2]?.toLowerCase();
2874
- return id !== "" && DispositionCodec.is(disposition) ? [
2875
- {
2876
- id,
2877
- disposition,
2878
- reason: clipText((match?.[3] ?? "").trim(), RESPONSE_REASON_CLIP_CHARS)
2879
- }
2880
- ] : [];
2881
- });
2882
- var ID_IN_CELL_RE = /`([^`]+)`/g;
2883
- var ID_HEADERS = /* @__PURE__ */ new Set(["id", "ids", "finding", "findings"]);
2884
- var DISPOSITION_HEADERS = /* @__PURE__ */ new Set(["disposition", "verdict", "resolution"]);
2885
- var CELL_RE = /((?:\\[\s\S]?|[^\\|])*)(\||$)/g;
2886
- var splitCells = (row) => {
2887
- const cells = [...row.matchAll(CELL_RE)];
2888
- return cells.slice(0, cells.findIndex((match) => match[2] !== "|") + 1).map((match) => match[1] ?? "");
2889
- };
2890
- var BLOCK_START_RE = /^ {0,3}([-+*]\s|\d+[.)]\s|#{1,6}(\s|$)|>)/;
2891
- var tableRow = (line) => {
2892
- if (!/^ {0,3}\S/.test(line) || BLOCK_START_RE.test(line)) return null;
2893
- const split = splitCells(line.trim());
2894
- if (split.length < 2) return null;
2895
- const inner = split.slice(line.trim().startsWith("|") ? 1 : 0);
2896
- return (inner.length > 1 && inner[inner.length - 1]?.trim() === "" ? inner.slice(0, -1) : inner).map((cell) => cell.trim().replace(/\\\|/g, "|"));
2897
- };
2898
- var isDelimiter = (cells) => cells !== null && cells.every((cell) => /^:?-+:?$/.test(cell));
2899
- var plainCell = (cell) => cell.replace(/[*_`]/g, "").trim().toLowerCase();
2900
- var columnName = (cell) => plainCell(cell).split(/[\s/]+/)[0] ?? "";
2901
- var idsInCell = (cell) => {
2902
- const ids = cell.replace(/\([^()]*\)/g, "");
2903
- const quoted = [...ids.matchAll(ID_IN_CELL_RE)].map((match) => unwrapId((match[1] ?? "").trim())).filter((id) => id !== "");
2904
- const bare = ids.split(",").map((piece) => unwrapId(piece.trim())).filter((id) => id !== "");
2905
- return quoted.length > 0 ? quoted : bare.every((id) => /^\S+$/.test(id)) ? bare : [];
2906
- };
2907
- var tableDisposition = (cell) => {
2908
- const firstWord = plainCell(cell).replace(/^[^a-z]+/, "").split(/[^a-z]/)[0] ?? "";
2909
- return DispositionCodec.is(firstWord) ? firstWord : "unstated";
2910
- };
2911
- var rowAnswers = (cells, columns) => {
2912
- const dispositionCell = cells[columns.disposition] ?? "";
2913
- const disposition = tableDisposition(dispositionCell);
2914
- const verdictOnly = DispositionCodec.is(plainCell(dispositionCell));
2915
- const reason = clipText(
2916
- cells.filter(
2917
- (cell, column) => cell !== "" && column !== columns.id && !(verdictOnly && column === columns.disposition)
2918
- ).join(" \u2014 "),
2919
- RESPONSE_REASON_CLIP_CHARS
2920
- );
2921
- return idsInCell(cells[columns.id] ?? "").map((id) => ({ id, disposition, reason }));
2922
- };
2923
- var namesAColumn = (name) => ID_HEADERS.has(name) || DISPOSITION_HEADERS.has(name);
2924
- var tableAnswers = (lines) => {
2925
- const rows = lines.map(tableRow);
2926
- return rows.reduce(
2927
- (state, cells, index) => {
2928
- if (state.skipDelimiter) return { ...state, skipDelimiter: false };
2929
- if (cells === null) return { ...state, inTable: false, columns: null };
2930
- const opensTable = !isDelimiter(cells) && isDelimiter(rows[index + 1] ?? null) && (!state.inTable || cells.map(columnName).some(namesAColumn));
2931
- if (opensTable) {
2932
- const header = cells.map(columnName);
2933
- const id = header.findIndex((name) => ID_HEADERS.has(name));
2934
- const disposition = header.findIndex((name) => DISPOSITION_HEADERS.has(name));
2935
- return {
2936
- ...state,
2937
- inTable: true,
2938
- skipDelimiter: true,
2939
- columns: id < 0 || disposition < 0 ? null : { id, disposition }
2940
- };
2941
- }
2942
- if (state.inTable && state.columns !== null && !isDelimiter(cells)) {
2943
- state.answers.push(...rowAnswers(cells, state.columns));
2944
- }
2945
- return isDelimiter(rows[index + 1] ?? null) ? { ...state, skipDelimiter: true } : state;
2946
- },
2947
- { columns: null, inTable: false, skipDelimiter: false, answers: [] }
2948
- ).answers;
2949
- };
2950
- var parseResponses = (text) => {
2951
- const lines = answerLines(text);
2952
- const seen = /* @__PURE__ */ new Set();
2953
- return [...lineAnswers(lines), ...tableAnswers(lines)].filter((answer) => {
2954
- const key2 = JSON.stringify([answer.id, answer.disposition, answer.reason]);
2955
- if (seen.has(key2)) return false;
2956
- seen.add(key2);
2957
- return true;
2958
- });
2959
- };
2960
- var isHuman = (login, type8, botLogin) => login !== botLogin && type8 === "User";
2961
- var newestFirst = (a, b) => {
2962
- const [left2, right2] = [a.created_at ?? "", b.created_at ?? ""];
2963
- return left2 < right2 ? 1 : left2 > right2 ? -1 : b.id - a.id;
2964
- };
2965
- var answersFrom = (input) => {
2966
- const comments = [...input.comments].filter((comment) => isHuman(comment.user.login, comment.user.type ?? null, input.botLogin)).sort(newestFirst).flatMap(
2967
- (comment) => parseResponses(comment.body ?? "").map((line) => ({
2968
- ...line,
2969
- channel: "comment",
2970
- source_url: `https://github.com/${input.repo}/pull/${String(input.prNumber)}#issuecomment-${String(comment.id)}`,
2971
- author: comment.user.login,
2972
- author_association: comment.author_association ?? null,
2973
- created_at: comment.created_at ?? null
2974
- }))
2975
- );
2976
- const commits = [...input.commits].reverse().flatMap(
2977
- (commit) => parseResponses(commit.message).map((line) => ({
2978
- ...line,
2979
- channel: "commit",
2980
- source_url: `https://github.com/${input.repo}/commit/${commit.sha}`,
2981
- author: commit.author,
2982
- author_association: null,
2983
- created_at: commit.date ?? null
2984
- }))
2985
- );
2986
- return { comments, commits };
2987
- };
2988
- var harvestResponses = (input) => {
2989
- const { comments: fromComments, commits: fromCommits } = answersFrom(input);
2990
- const matched = (response) => input.priorIds.has(response.id);
2991
- const echoed = (response) => !matched(response) && ID_SHAPE_RE.test(response.id);
2992
- const groups = [fromComments, fromCommits].flatMap((channel) => [
2993
- channel.filter(matched),
2994
- channel.filter(echoed)
2995
- ]);
2996
- const [comments, commentEchoes, commits, commitEchoes] = groups.map(
2997
- (group) => group.slice(0, RESPONSES_PER_CHANNEL)
2998
- );
2999
- return {
3000
- file: {
3001
- responses: [...comments ?? [], ...commits ?? []],
3002
- unmatched: [...commentEchoes ?? [], ...commitEchoes ?? []].map(
3003
- ({ id, channel, source_url }) => ({ id, channel, source_url })
3004
- )
3005
- },
3006
- dropped: groups.reduce(
3007
- (total, group) => total + Math.max(0, group.length - RESPONSES_PER_CHANNEL),
3008
- 0
3009
- )
3010
- };
3011
- };
3012
- var isTrustedResponse = (response, headInBaseRepo) => {
3013
- switch (response.channel) {
3014
- case "comment":
3015
- return MAINTAINER_ASSOCIATIONS.includes(response.author_association ?? "");
3016
- case "commit":
3017
- return headInBaseRepo;
3018
- }
3019
- };
3020
- var answeredInstant = (response) => response.created_at === null ? Number.NaN : Date.parse(response.created_at);
3021
- var closingResponses = (responses, isTrusted) => {
3022
- const byId = responses.filter((response) => response.disposition !== "unstated" && isTrusted(response)).reduce((groups, response) => {
3023
- const timed = { response, at: answeredInstant(response) };
3024
- groups.set(response.id, [...groups.get(response.id) ?? [], timed]);
3025
- return groups;
3026
- }, /* @__PURE__ */ new Map());
3027
- return [...byId.values()].flatMap((answers) => {
3028
- if (answers.some(({ at }) => Number.isNaN(at))) return [];
3029
- const latest = Math.max(...answers.map(({ at }) => at));
3030
- const newest = answers.filter(({ at }) => at === latest).map(({ response }) => response);
3031
- return newest.every((response) => CLOSING_DISPOSITIONS.has(response.disposition)) ? [...newest].sort((a, b) => a.source_url < b.source_url ? -1 : 1).slice(0, 1) : [];
3032
- });
3033
- };
3034
3288
 
3035
3289
  // src/post.ts
3036
3290
  var STICKY_CHAR_LIMIT = 65536;
@@ -3551,8 +3805,8 @@ var priorBotCommentIds = (raw, botLogin) => {
3551
3805
  const nodes = conn?.nodes;
3552
3806
  if (!Array.isArray(nodes)) return { ids: [], truncated };
3553
3807
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
3554
- const ids = nodes.flatMap((t9) => {
3555
- const cnodes = t9.comments?.nodes;
3808
+ const ids = nodes.flatMap((t10) => {
3809
+ const cnodes = t10.comments?.nodes;
3556
3810
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
3557
3811
  });
3558
3812
  return { ids, truncated };
@@ -3648,7 +3902,7 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3648
3902
  convergence: conv ?? void 0
3649
3903
  });
3650
3904
  let warnedNoJsonUrl = false;
3651
- const findingsBlob = (doc) => {
3905
+ const findingsPair = (doc) => {
3652
3906
  if (input.jsonUrl) return findingsMarkerPair(input.jsonUrl, doc.convergence);
3653
3907
  const carriedLink = existingSticky !== null ? findingsArtifactUrl(existingSticky.body) : null;
3654
3908
  if (carriedLink !== null) {
@@ -3662,6 +3916,8 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3662
3916
  }
3663
3917
  return findingsMarkerPair(void 0, doc.convergence);
3664
3918
  };
3919
+ const carriedDialogue = existingSticky !== null ? carriedDialogueMarker(existingSticky.body) : "";
3920
+ const findingsBlob = (doc, dialogue2 = carriedDialogue) => [findingsPair(doc), dialogue2].filter((marker) => marker !== "").join("\n");
3665
3921
  const leaveInPlace = (message) => {
3666
3922
  process.stderr.write(
3667
3923
  message ?? "Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place\n"
@@ -3670,9 +3926,9 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3670
3926
  process.exit(0);
3671
3927
  };
3672
3928
  const logAnsweredDrops = () => {
3673
- if (verbatimReRaised.length > 0) {
3929
+ if (droppedCount > 0) {
3674
3930
  process.stderr.write(
3675
- `${String(droppedCount)} verbatim re-raise(s) of answered findings were treated as answered \u2014 the preserved sticky shows each finding
3931
+ `${String(droppedCount)} verbatim re-raise(s) of answered findings or systemic problems were treated as answered \u2014 the preserved sticky shows each one
3676
3932
  `
3677
3933
  );
3678
3934
  }
@@ -3750,7 +4006,7 @@ ${dropNote}` : ""}`,
3750
4006
  const carried = existingSticky !== null ? carriedFindingsMarker(existingSticky.body) : null;
3751
4007
  if (carried === null) return findingsBlob(doc);
3752
4008
  const provenance = existingSticky !== null ? carriedAncestryMarkers(existingSticky.body) : "";
3753
- return [carriedMarkerPointer(carried, doc.convergence), provenance].filter((p) => p !== "").join("\n\n");
4009
+ return [carriedMarkerPointer(carried, doc.convergence), carriedDialogue, provenance].filter((p) => p !== "").join("\n\n");
3754
4010
  };
3755
4011
  return formatMarkdown(
3756
4012
  render({
@@ -3797,7 +4053,18 @@ ${dropNote}` : ""}`,
3797
4053
  process.exit(0);
3798
4054
  }
3799
4055
  const loadedFindings = findingsResult.findings;
3800
- const answerable = existingSticky !== null && !priorIsMechanic && loadedFindings.findings.length > 0;
4056
+ const priorDialogue = existingSticky !== null && input.withoutDialogue !== true ? parseDialogueMarker(existingSticky.body) : { entries: [], undecoded: [], skipped: 0 };
4057
+ if (priorDialogue.skipped > 0) {
4058
+ process.stderr.write(
4059
+ `Warning: ${String(priorDialogue.skipped)} entry(ies) of the prior sticky's dialogue state did not decode \u2014 carried unread
4060
+ `
4061
+ );
4062
+ }
4063
+ const dialogueRound = input.withoutDialogue !== true && envelope !== null && isConvergenceRound(
4064
+ effectiveRoute,
4065
+ envelope.incomplete === true || isIncompleteFindings(loadedFindings)
4066
+ ) && isReviewVerdict(loadedFindings.verdict);
4067
+ const answerable = input.withoutDialogue !== true && existingSticky !== null && !priorIsMechanic && (loadedFindings.findings.length > 0 || (loadedFindings.systemic_problems ?? []).length > 0 || dialogueRound && priorDialogue.entries.length > 0);
3801
4068
  const headInBaseRepo = answerable && resolution.headRepo !== null && resolution.headRepo.toLowerCase() === input.repo.toLowerCase();
3802
4069
  const answers = answerable ? answersFrom({
3803
4070
  repo: input.repo,
@@ -3812,10 +4079,46 @@ ${dropNote}` : ""}`,
3812
4079
  })),
3813
4080
  commits: headInBaseRepo ? await fetchCommitAnswersSource(input.repo, prNumber, ghApi) : []
3814
4081
  }) : { comments: [], commits: [] };
3815
- const closures = closingResponses(
4082
+ const isTrusted = (response) => isTrustedResponse(response, headInBaseRepo);
4083
+ const allAnswers = withRulingsOn(
3816
4084
  [...answers.comments, ...answers.commits],
3817
- (response) => isTrustedResponse(response, headInBaseRepo)
3818
- ).filter((closure) => couldMatch(closure.id, loadedFindings.findings));
4085
+ new Set(
4086
+ priorDialogue.entries.filter((entry) => entry.state !== "rebutted").map((entry) => entry.id)
4087
+ ),
4088
+ isTrusted
4089
+ );
4090
+ const unorderedRulings = allAnswers.filter(
4091
+ (answer) => RulingCodec.is(answer.disposition) && Number.isNaN(Date.parse(answer.created_at ?? ""))
4092
+ ).length;
4093
+ if (unorderedRulings > 0) {
4094
+ process.stderr.write(
4095
+ `Warning: ${String(unorderedRulings)} maintainer ruling(s) carry no readable time, so they cannot be ordered against the other answers \u2014 not applied
4096
+ `
4097
+ );
4098
+ }
4099
+ const allClosures = closingResponses(allAnswers, isTrusted);
4100
+ const dialogue = answerable && dialogueRound ? advanceDialogue(priorDialogue.entries, dialogueAnswers(allAnswers, isTrusted), [
4101
+ ...loadedFindings.findings.map((f) => ({
4102
+ id: resolveFindingId(f),
4103
+ title: f.title,
4104
+ severity: f.severity,
4105
+ rebutted: hasRebuttal(f)
4106
+ })),
4107
+ ...(loadedFindings.systemic_problems ?? []).flatMap((s) => {
4108
+ const id = resolveSystemicId(s);
4109
+ return id === void 0 ? [] : [{ id, title: s.title, severity: s.severity, rebutted: hasRebuttal(s) }];
4110
+ })
4111
+ ]) : priorDialogue.entries;
4112
+ const upheldIds = new Set(
4113
+ dialogue.filter((entry) => entry.state === "upheld").map((entry) => entry.id)
4114
+ );
4115
+ const closures = allClosures.filter(
4116
+ (closure) => couldMatch(closure.id, loadedFindings) && !upheldIds.has(closure.id)
4117
+ );
4118
+ const contested = dialogueRound ? dialogue.filter((entry) => entry.state === "contested") : [];
4119
+ const contestedIds = new Set(contested.map((entry) => entry.id));
4120
+ const isContestedFinding = findingIdIn(contestedIds);
4121
+ const isContestedSystemic = systemicIdIn(contestedIds);
3819
4122
  const priorForAnswers = closures.length > 0 && existingSticky !== null ? { value: await resolvePriorFindings(existingSticky.body, readArtifact) } : null;
3820
4123
  const answeredPrior = priorForAnswers === null ? null : resolveTolerantFindings(priorForAnswers.value);
3821
4124
  if (priorForAnswers !== null && answeredPrior === null) {
@@ -3825,37 +4128,101 @@ ${dropNote}` : ""}`,
3825
4128
  );
3826
4129
  }
3827
4130
  const answeredRegistry = answeredRegistryFrom(closures, answeredPrior);
3828
- if (answeredPrior !== null && answeredRegistry.length < closures.length) {
4131
+ const answeredSystemicRegistry = answeredSystemicRegistryFrom(closures, answeredPrior);
4132
+ const boundIds = new Set([...answeredRegistry, ...answeredSystemicRegistry].map((e) => e.code));
4133
+ const unboundClosureCount = answeredPrior === null ? 0 : closures.filter((closure) => !boundIds.has(closure.id)).length;
4134
+ if (unboundClosureCount > 0) {
3829
4135
  process.stderr.write(
3830
- `Warning: ${String(closures.length - answeredRegistry.length)} maintainer answer(s) name no finding the prior review reported \u2014 they close nothing this round
4136
+ `Warning: ${String(unboundClosureCount)} maintainer answer(s) name nothing the prior review reported \u2014 they close nothing this round
3831
4137
  `
3832
4138
  );
3833
4139
  }
3834
- const answeredFilter = applyAnswered(loadedFindings.findings, answeredRegistry);
3835
- const reRaisedNotes = answeredFilter.reRaisedNotes;
4140
+ const rulings = new Map(
4141
+ dialogue.filter((entry) => entry.state === "overruled").map((entry) => [
4142
+ entry.id,
4143
+ {
4144
+ code: entry.id,
4145
+ title: entry.title,
4146
+ answerUrl: entry.answer,
4147
+ answerAuthor: allAnswers.find((answer) => answer.source_url === entry.answer)?.author ?? null
4148
+ }
4149
+ ])
4150
+ );
4151
+ const overruledFilter = applyOverruled(
4152
+ loadedFindings.findings.filter((f) => !isContestedFinding(f)),
4153
+ rulings,
4154
+ resolveFindingId,
4155
+ answeredNoteKey
4156
+ );
4157
+ const overruledSystemicFilter = applyOverruled(
4158
+ (loadedFindings.systemic_problems ?? []).filter((s) => !isContestedSystemic(s)),
4159
+ rulings,
4160
+ resolveSystemicId,
4161
+ answeredSystemicNoteKey
4162
+ );
4163
+ const answeredFilter = applyAnswered(overruledFilter.kept, answeredRegistry);
4164
+ const answeredSystemicFilter = applyAnsweredSystemic(
4165
+ overruledSystemicFilter.kept,
4166
+ answeredSystemicRegistry
4167
+ );
4168
+ const contestedFindings = loadedFindings.findings.filter(isContestedFinding);
4169
+ const contestedSystemic = (loadedFindings.systemic_problems ?? []).filter(isContestedSystemic);
4170
+ const contestedReRaises = new Map([
4171
+ ...contestedSystemic.map((s) => [resolveSystemicId(s) ?? "", s]),
4172
+ ...contestedFindings.map((f) => [resolveFindingId(f), f])
4173
+ ]);
4174
+ const contestedViews = contested.map((entry) => {
4175
+ const reRaise = contestedReRaises.get(entry.id);
4176
+ return {
4177
+ id: entry.id,
4178
+ title: entry.title,
4179
+ severity: entry.severity,
4180
+ answerUrl: entry.answer,
4181
+ ...reRaise === void 0 ? {} : {
4182
+ reRaise: {
4183
+ ..."path" in reRaise ? {
4184
+ location: `${reRaise.path}:${lineRange(reRaise.start_line, reRaise.end_line, "\u2013")}`
4185
+ } : {},
4186
+ description: reRaise.description,
4187
+ reasoning: reRaise.reasoning,
4188
+ ...hasRebuttal(reRaise) ? { rebuttal: reRaise.rebuttal ?? "" } : {}
4189
+ }
4190
+ }
4191
+ };
4192
+ });
4193
+ const reRaisedNotes = {
4194
+ ...answeredFilter.reRaisedNotes,
4195
+ ...answeredSystemicFilter.reRaisedNotes,
4196
+ ...overruledFilter.reRaisedNotes,
4197
+ ...overruledSystemicFilter.reRaisedNotes
4198
+ };
3836
4199
  const verbatimReRaised = answeredFilter.verbatimReRaised;
3837
- const droppedCount = answeredFilter.droppedCount;
4200
+ const answeredDropCount = answeredFilter.droppedCount + answeredSystemicFilter.droppedCount;
4201
+ const overruledDropCount = overruledFilter.droppedCount + overruledSystemicFilter.droppedCount;
4202
+ const droppedCount = answeredDropCount + overruledDropCount;
3838
4203
  const droppedIds = /* @__PURE__ */ new Set([
3839
- ...verbatimReRaised.flatMap((e) => e.code !== "" ? [e.code] : []),
4204
+ ...[...verbatimReRaised, ...overruledFilter.verbatimReRaised].flatMap(
4205
+ (e) => e.code !== "" ? [e.code] : []
4206
+ ),
3840
4207
  ...answeredFilter.droppedFindingIds.filter((id) => id !== "")
3841
4208
  ]);
3842
- const keptCodes = new Set(answeredFilter.findings.map((f) => f.id));
4209
+ const keptCodes = new Set([...answeredFilter.findings, ...contestedFindings].map((f) => f.id));
3843
4210
  const trulyDropped = new Set([...droppedIds].filter((c) => !keptCodes.has(c)));
3844
- const systemic = trulyDropped.size === 0 ? loadedFindings.systemic_problems ?? [] : (loadedFindings.systemic_problems ?? []).map((s) => {
4211
+ const systemic = trulyDropped.size === 0 ? answeredSystemicFilter.systemic : answeredSystemicFilter.systemic.map((s) => {
3845
4212
  if (s.finding_ids === void 0) return s;
3846
4213
  const codes = s.finding_ids.filter((c) => !trulyDropped.has(c));
3847
4214
  return codes.length === s.finding_ids.length ? s : { ...s, finding_ids: codes };
3848
4215
  });
3849
4216
  const findings = {
3850
- ...loadedFindings,
3851
- findings: [...answeredFilter.findings],
3852
- ...systemic.length > 0 ? { systemic_problems: systemic } : {}
4217
+ ...withSystemicProblems(loadedFindings, [...systemic, ...contestedSystemic]),
4218
+ findings: [...answeredFilter.findings, ...contestedFindings]
3853
4219
  };
3854
4220
  const broadCurrentIds = [
3855
4221
  ...findings.findings.map((f) => f.id).filter((id) => id !== ""),
3856
4222
  ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
3857
4223
  ];
3858
- const roundHasNit = findings.findings.some((f) => f.severity === "nit");
4224
+ const argued = findings.findings.filter((f) => !isContestedFinding(f));
4225
+ const roundHasNit = argued.some((f) => f.severity === "nit");
3859
4226
  const nitWantsPrior = roundHasNit && existingSticky !== null && (isFullReviewSticky(existingSticky.body) || isFullReviewAncestry(existingSticky.body));
3860
4227
  const broadWantsPrior = existingSticky !== null && !priorIsMechanic && mentionsOutsideKnown(reachable, broadCurrentIds);
3861
4228
  const resolvedPrior = priorForAnswers !== null ? priorForAnswers.value : existingSticky !== null && (nitWantsPrior || broadWantsPrior) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
@@ -3869,15 +4236,25 @@ ${dropNote}` : ""}`,
3869
4236
  // synthesized prior), the answeredNoteKey form (an empty-id prior), and the bare title: key
3870
4237
  // (a pathless codeless prior).
3871
4238
  priorSuppressedKeys.has(resolveFindingId(f)) || priorSuppressedKeys.has(answeredNoteKey(f)) || priorSuppressedKeys.has(`title:${f.title}`));
3872
- const suppressedNits = findings.findings.filter(isSuppressedNit);
3873
- const visibleFindings = findings.findings.filter((f) => !isSuppressedNit(f));
3874
- const answeredDropNote = answeredReRaiseNote(verbatimReRaised, droppedCount) + (verbatimReRaised.length > 0 && findings.findings.length === 0 ? "\n> _The stop signal reflects the kept findings \u2014 this round carries none._" : "");
4239
+ const suppressedNits = argued.filter(isSuppressedNit);
4240
+ const visibleFindings = argued.filter((f) => !isSuppressedNit(f));
4241
+ const answeredDropNote = [
4242
+ answeredReRaiseNote(
4243
+ [...verbatimReRaised, ...answeredSystemicFilter.verbatimReRaised],
4244
+ answeredDropCount
4245
+ ),
4246
+ overruledReRaiseNote(
4247
+ [...overruledFilter.verbatimReRaised, ...overruledSystemicFilter.verbatimReRaised],
4248
+ overruledDropCount
4249
+ )
4250
+ ].filter((note) => note !== "").join("\n>\n") + (droppedCount > 0 && findings.findings.length === 0 && (findings.systemic_problems ?? []).length === 0 ? "\n> _The stop signal reflects the kept findings \u2014 this round carries none._" : "");
3875
4251
  const inlinePartition = input.inline === true && envelope !== null ? partitionFindings(visibleFindings, indexDiff(diff)) : null;
3876
4252
  const straysForDiscussion = inlinePartition?.strays ?? visibleFindings;
3877
4253
  const currentIds = [
3878
4254
  ...straysForDiscussion.map((f) => f.id).filter((id) => id !== ""),
3879
4255
  ...suppressedNits.map((f) => f.id).filter((id) => id !== ""),
3880
- ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
4256
+ ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== ""),
4257
+ ...contested.map((entry) => entry.id)
3881
4258
  ];
3882
4259
  const orphanResolveFailed = existingSticky !== null && broadWantsPrior && carriedFindingsMarker(existingSticky.body) !== null && resolvedPrior === null;
3883
4260
  const broadLive = new Set(broadCurrentIds);
@@ -3974,6 +4351,7 @@ ${note}
3974
4351
  nitVisibilityFloor: input.nitVisibilityFloor,
3975
4352
  answeredNotes: reRaisedNotes,
3976
4353
  answeredReRaiseNote: answeredDropNote,
4354
+ contested: contestedViews,
3977
4355
  discussionByFinding,
3978
4356
  orphanedDiscussion,
3979
4357
  orphanedTotal,
@@ -4094,7 +4472,8 @@ ${note}
4094
4472
  priorTraj,
4095
4473
  roundNumber,
4096
4474
  currentCodes,
4097
- input.headSha.slice(0, 12)
4475
+ input.headSha.slice(0, 12),
4476
+ contested
4098
4477
  ) : effectiveRoute === "mechanic" ? (
4099
4478
  // A mechanic pass means CI failed — whatever the carried prior says, its own stamp must
4100
4479
  // never read "converged". The threshold-relative critical floor keeps the carried triple
@@ -4103,10 +4482,13 @@ ${note}
4103
4482
  ) : priorConv;
4104
4483
  const stampedFindings = stampConvergence(findings, convergence);
4105
4484
  const currentRoundCount = isRound ? roundNumber : priorRoundCount;
4106
- const findingsMarker = findingsBlob(stampedFindings);
4485
+ const findingsMarker = findingsBlob(
4486
+ stampedFindings,
4487
+ answerable && dialogueRound ? dialogueMarker(dialogue, priorDialogue.undecoded) : carriedDialogue
4488
+ );
4107
4489
  const commonRenderInput = {
4108
4490
  ...sharedRenderInput,
4109
- findings: stampedFindings,
4491
+ findings: withoutIds(stampedFindings, contestedIds),
4110
4492
  envelope,
4111
4493
  incomplete: thisIncomplete,
4112
4494
  sameRootNotes,
@@ -4314,6 +4696,24 @@ ${note}
4314
4696
  finalDiscussionOverride
4315
4697
  )
4316
4698
  );
4699
+ if (answerable && dialogueRound) {
4700
+ appendRunSummary(process.env["GITHUB_STEP_SUMMARY"], () => {
4701
+ const trustedAnswerIds = new Set(allAnswers.filter(isTrusted).map((answer) => answer.id));
4702
+ return dialogueMetricsTable({
4703
+ commentAnswers: answers.comments.length,
4704
+ commitAnswers: answers.commits.length,
4705
+ closures: allClosures.length,
4706
+ droppedAnswered: answeredDropCount,
4707
+ droppedOverruled: overruledDropCount,
4708
+ annotated: Object.keys(reRaisedNotes).length,
4709
+ entries: dialogue,
4710
+ systemicUnanswered: (findings.systemic_problems ?? []).filter((s) => {
4711
+ const id = resolveSystemicId(s);
4712
+ return id === void 0 || !trustedAnswerIds.has(id);
4713
+ }).length
4714
+ });
4715
+ });
4716
+ }
4317
4717
  };
4318
4718
  var noticeBody = (lead, existingBody) => {
4319
4719
  const carried = existingBody ? carryForwardMarkers(existingBody) : "";
@@ -4999,15 +5399,29 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
4999
5399
  join(input.outDir, "prior_findings.json"),
5000
5400
  priorFindings === null ? "null" : JSON.stringify(priorFindings)
5001
5401
  );
5402
+ const held = (seedsFromPrior ? parseDialogueMarker(prior.body).entries : []).filter(
5403
+ (entry) => entry.state !== "rebutted"
5404
+ );
5002
5405
  const harvest = seedsFromPrior ? harvestResponses({
5003
5406
  repo: input.repo,
5004
5407
  prNumber,
5005
5408
  botLogin: input.botLogin,
5006
- priorIds: new Set(priorIdsFrom(priorFindings)),
5409
+ priorIds: /* @__PURE__ */ new Set([...priorIdsFrom(priorFindings), ...held.map((entry) => entry.id)]),
5007
5410
  commits: commits.map((commit) => ({ ...commit, author: commit.login ?? null })),
5008
5411
  comments: issueComments ?? []
5009
5412
  }) : { file: { responses: [], unmatched: [] }, dropped: 0 };
5010
- writeFileSync(join(input.outDir, "responses.json"), JSON.stringify(harvest.file));
5413
+ writeFileSync(
5414
+ join(input.outDir, "responses.json"),
5415
+ JSON.stringify({
5416
+ ...harvest.file,
5417
+ ...Object.fromEntries(
5418
+ ["contested", "upheld", "overruled"].flatMap((state) => {
5419
+ const claims = held.filter((entry) => entry.state === state).map(({ id, title, severity, at }) => ({ id, title, severity, since: at }));
5420
+ return claims.length > 0 ? [[state, claims]] : [];
5421
+ })
5422
+ )
5423
+ })
5424
+ );
5011
5425
  if (seedsFromPrior && priorFindings === null) {
5012
5426
  process.stderr.write(
5013
5427
  "Note: the prior review's findings did not resolve \u2014 every id-shaped answer is staged as unmatched\n"
@@ -5382,7 +5796,7 @@ var parseScope = (raw) => {
5382
5796
  reason: 'scope contains a character (newline, carriage return, backtick, "<", ">", or "|") that would corrupt the review prompt \u2014 use plain language names/tags'
5383
5797
  };
5384
5798
  }
5385
- const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t9) => t9 !== "")));
5799
+ const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t10) => t10 !== "")));
5386
5800
  return languages.length === 0 ? { kind: "absent" } : { kind: "ok", languages };
5387
5801
  };
5388
5802
 
@@ -6754,6 +7168,10 @@ var postCmd = defineCommand({
6754
7168
  "unverified-no-logs": {
6755
7169
  type: "boolean",
6756
7170
  description: "Mark the review unverified: the fast-fix route ran with no failing-job logs staged, so its findings came from the diff alone. The caller decides this \u2014 the logs are staged in the review job, not here"
7171
+ },
7172
+ "without-dialogue": {
7173
+ type: "boolean",
7174
+ description: "Turn the review dialogue off: read no maintainer answers, so none closes, contests or rules on a finding, and carry the sticky's dialogue state untouched"
6757
7175
  }
6758
7176
  },
6759
7177
  run: async ({ args }) => {
@@ -6788,6 +7206,7 @@ var postCmd = defineCommand({
6788
7206
  nitVisibilityFloor: parseNitVisibilityFloor(args["nit-visibility-floor"]),
6789
7207
  inline: args.inline,
6790
7208
  unverifiedNoLogs: args["unverified-no-logs"],
7209
+ withoutDialogue: args["without-dialogue"],
6791
7210
  postedAt: formatUtc(/* @__PURE__ */ new Date()),
6792
7211
  pricedAt: /* @__PURE__ */ new Date()
6793
7212
  });