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

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';
@@ -69,6 +70,11 @@ var LineNumber = t.refinement(
69
70
  "LineNumber"
70
71
  );
71
72
  var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
73
+ var NonEmptyString = t.refinement(
74
+ t.string,
75
+ (s) => s.trim().length > 0,
76
+ "NonEmptyString"
77
+ );
72
78
  var Likelihood = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Likelihood");
73
79
  var FiniteNumber = t.refinement(t.number, (n) => Number.isFinite(n), "FiniteNumber");
74
80
  var NonNegativePrice = t.refinement(t.number, (n) => n >= 0, "NonNegativePrice");
@@ -235,7 +241,8 @@ var FindingsCodec = t.exact(
235
241
  // Pipeline-stamped only (issue #174); see ConvergenceCodec.
236
242
  convergence: ConvergenceCodec,
237
243
  // Agent-written best-effort (issue #182); see ChangeSizeCodec.
238
- change_size: ChangeSizeCodec
244
+ change_size: ChangeSizeCodec,
245
+ unfinished: t.array(NonEmptyString)
239
246
  })
240
247
  ])
241
248
  );
@@ -245,8 +252,31 @@ var PIPELINE_STAMPED_FIELDS = /* @__PURE__ */ new Set([
245
252
  ]);
246
253
  var RECOVERABLE_OPTIONAL_FIELDS = /* @__PURE__ */ new Set([
247
254
  ...PIPELINE_STAMPED_FIELDS,
248
- "change_size"
255
+ "change_size",
256
+ "unfinished"
249
257
  ]);
258
+ var withoutBrokenOptionalFields = (doc, accepts) => {
259
+ const bare = Object.fromEntries(
260
+ Object.entries(doc).filter(([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2))
261
+ );
262
+ if (!accepts(bare)) return null;
263
+ const usable = (kept, key2, value) => {
264
+ if (accepts({ ...kept, [key2]: value })) return value;
265
+ if (!Array.isArray(value)) return void 0;
266
+ const entries = value.filter((entry) => accepts({ ...kept, [key2]: [entry] }));
267
+ return entries.length > 0 && accepts({ ...kept, [key2]: entries }) ? entries : void 0;
268
+ };
269
+ const restored = Object.entries(doc).filter(([key2]) => RECOVERABLE_OPTIONAL_FIELDS.has(key2)).reduce((kept, [key2, value]) => {
270
+ const restoredValue = usable(kept, key2, value);
271
+ return restoredValue === void 0 ? kept : { ...kept, [key2]: restoredValue };
272
+ }, bare);
273
+ return {
274
+ doc: restored,
275
+ stripped: Object.keys(doc).filter(
276
+ (key2) => !Object.hasOwn(restored, key2) || restored[key2] !== doc[key2]
277
+ )
278
+ };
279
+ };
250
280
  var ID_SHAPE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
251
281
  var priorIdsFrom = (doc) => {
252
282
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return [];
@@ -272,11 +302,28 @@ var withoutRebuttals = (doc) => ({
272
302
  findings: doc.findings.map(({ rebuttal: _rebuttal, ...f }) => f),
273
303
  ...doc.systemic_problems !== void 0 ? { systemic_problems: doc.systemic_problems.map(({ rebuttal: _rebuttal, ...s }) => s) } : {}
274
304
  });
305
+ var withSystemicProblems = ({ systemic_problems: _replaced, ...doc }, systemic) => ({
306
+ ...doc,
307
+ ...systemic.length > 0 ? { systemic_problems: [...systemic] } : {}
308
+ });
275
309
  var synthesizedId = (parts, prefix) => `${prefix}${createHash("sha256").update(parts.join("\0")).digest("base64url").slice(0, 12)}`;
276
310
  var synthesizedFindingId = (path, title) => synthesizedId([path, title], "f-");
277
311
  var synthesizedSystemicId = (title) => synthesizedId([title], "s-");
278
312
  var isSynthesizedFindingId = (id) => /^f-[A-Za-z0-9_-]{12}$/.test(id);
279
313
  var resolveFindingId = (f) => f.id !== "" ? f.id : synthesizedFindingId(f.path, f.title);
314
+ var resolveSystemicId = (s) => s.id !== void 0 && s.id !== "" ? s.id : s.title !== "" ? synthesizedSystemicId(s.title) : void 0;
315
+ var findingIdIn = (ids) => (f) => ids.has(resolveFindingId(f));
316
+ var systemicIdIn = (ids) => (s) => {
317
+ const id = resolveSystemicId(s);
318
+ return id !== void 0 && ids.has(id);
319
+ };
320
+ var withoutIds = (doc, ids) => ids.size === 0 ? doc : {
321
+ ...withSystemicProblems(
322
+ doc,
323
+ (doc.systemic_problems ?? []).filter((s) => !systemicIdIn(ids)(s))
324
+ ),
325
+ findings: doc.findings.filter((f) => !findingIdIn(ids)(f))
326
+ };
280
327
  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
328
  var usableCountsMap = (v) => {
282
329
  if (typeof v !== "object" || v === null || Array.isArray(v)) return void 0;
@@ -664,6 +711,369 @@ var warnStalePrices = (prices, warn = defaultWarn) => {
664
711
  `::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
712
  );
666
713
  };
714
+ var DispositionCodec = t.keyof({ fixed: null, refuted: null, dismissed: null });
715
+ var DISPOSITIONS = Object.keys(DispositionCodec.keys);
716
+ var RulingCodec = t.keyof({ upheld: null, overruled: null });
717
+ var RULINGS = Object.keys(RulingCodec.keys);
718
+ var ResponseDispositionCodec = t.keyof({
719
+ fixed: null,
720
+ refuted: null,
721
+ dismissed: null,
722
+ upheld: null,
723
+ overruled: null,
724
+ unstated: null
725
+ });
726
+ var isAnswerWord = (word) => DispositionCodec.is(word) || RulingCodec.is(word);
727
+ var RESPONSE_FORM = `Review-Response: <id> ${DISPOSITIONS.join("|")} \u2014 <reason>`;
728
+ var RESPONSE_TABLE_COLUMNS = ["id", "disposition", "reason"];
729
+ `| ${RESPONSE_TABLE_COLUMNS.join(" | ")} |`;
730
+ `| ${RESPONSE_TABLE_COLUMNS.map(() => "---").join(" | ")} |`;
731
+ var listed = (words, conjunction) => {
732
+ const quoted = words.map((word) => `\`${word}\``);
733
+ return quoted.length < 2 ? quoted.join("") : `${quoted.slice(0, -1).join(", ")} ${conjunction} ${quoted.at(-1) ?? ""}`;
734
+ };
735
+ var RULING_FORM = `Review-Response: <id> ${RULINGS.join("|")} \u2014 <reason>`;
736
+ 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).`;
737
+ 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.`;
738
+
739
+ // src/responses.ts
740
+ var RESPONSE_REASON_CLIP_CHARS = 300;
741
+ var RESPONSES_PER_CHANNEL = 25;
742
+ var ChannelCodec = t.keyof({ comment: null, commit: null });
743
+ var MAINTAINER_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"];
744
+ var CLOSING_DISPOSITIONS = /* @__PURE__ */ new Set(["refuted", "dismissed"]);
745
+ var ResponseShape = t.type({
746
+ id: t.string,
747
+ disposition: ResponseDispositionCodec,
748
+ reason: t.string,
749
+ channel: ChannelCodec,
750
+ source_url: t.string,
751
+ author: t.union([t.string, t.null]),
752
+ author_association: t.union([t.string, t.null]),
753
+ created_at: t.union([t.string, t.null])
754
+ });
755
+ var ResponseCodec = strictExact("Response", ResponseShape, [ResponseShape]);
756
+ var UnmatchedResponseShape = t.type({
757
+ id: t.string,
758
+ channel: ChannelCodec,
759
+ source_url: t.string
760
+ });
761
+ var UnmatchedResponseCodec = strictExact("UnmatchedResponse", UnmatchedResponseShape, [
762
+ UnmatchedResponseShape
763
+ ]);
764
+ var ResponsesFileAnswers = t.type({
765
+ responses: t.array(ResponseCodec),
766
+ unmatched: t.array(UnmatchedResponseCodec)
767
+ });
768
+ var HeldClaimShape = t.type({
769
+ id: t.string,
770
+ title: t.string,
771
+ severity: SeverityCodec,
772
+ since: t.string
773
+ });
774
+ var HeldClaimCodec = strictExact("HeldClaim", HeldClaimShape, [HeldClaimShape]);
775
+ var ResponsesFileDialogue = t.partial({
776
+ contested: t.array(HeldClaimCodec),
777
+ upheld: t.array(HeldClaimCodec),
778
+ overruled: t.array(HeldClaimCodec)
779
+ });
780
+ var ResponsesFileShape = t.intersection([ResponsesFileAnswers, ResponsesFileDialogue]);
781
+ strictExact("ResponsesFile", ResponsesFileShape, [
782
+ ResponsesFileAnswers,
783
+ ResponsesFileDialogue
784
+ ]);
785
+ var RESPONSE_LINE_RE = /^ {0,3}review-response:\s*(\S+)\s+([A-Za-z]+)(?![A-Za-z]|\s*\|)[\s—–:-]*(.*)$/i;
786
+ var FENCE_OPEN_RE = /^ {0,3}(`{3,}(?=[^`]*$)|~{3,})/;
787
+ var LINE_BREAK_RE = /\r\n?|\n|\u2028|\u2029/;
788
+ var unfencedLines = (text) => text.split(LINE_BREAK_RE).reduce(
789
+ (state, line) => {
790
+ const marker = FENCE_OPEN_RE.exec(line)?.[1];
791
+ if (state.fence === null) {
792
+ if (marker !== void 0) return { fence: marker, lines: state.lines };
793
+ state.lines.push(line);
794
+ return state;
795
+ }
796
+ const closes = marker !== void 0 && marker[0] === state.fence[0] && marker.length >= state.fence.length && line.trim() === marker;
797
+ return closes ? { fence: null, lines: state.lines } : state;
798
+ },
799
+ { fence: null, lines: [] }
800
+ ).lines;
801
+ var WHOLE_LINE_CODE_SPAN_RE = /^ {0,3}`([^`]+)`\s*$/;
802
+ var answerLines = (text) => unfencedLines(text).map((line) => WHOLE_LINE_CODE_SPAN_RE.exec(line)?.[1] ?? line);
803
+ var unwrapId = (token) => token.replace(/^[`"']+|[`"'.,:;]+$/g, "");
804
+ var lineAnswers = (lines) => lines.flatMap((line) => {
805
+ const match = RESPONSE_LINE_RE.exec(line);
806
+ const id = unwrapId(match?.[1] ?? "");
807
+ const disposition = match?.[2]?.toLowerCase();
808
+ return id !== "" && isAnswerWord(disposition) ? [
809
+ {
810
+ id,
811
+ disposition,
812
+ reason: clipText((match?.[3] ?? "").trim(), RESPONSE_REASON_CLIP_CHARS)
813
+ }
814
+ ] : [];
815
+ });
816
+ var ID_IN_CELL_RE = /`([^`]+)`/g;
817
+ var ID_HEADERS = /* @__PURE__ */ new Set(["id", "ids", "finding", "findings"]);
818
+ var DISPOSITION_HEADERS = /* @__PURE__ */ new Set(["disposition", "verdict", "resolution"]);
819
+ var CELL_RE = /((?:\\[\s\S]?|[^\\|])*)(\||$)/g;
820
+ var splitCells = (row) => {
821
+ const cells = [...row.matchAll(CELL_RE)];
822
+ return cells.slice(0, cells.findIndex((match) => match[2] !== "|") + 1).map((match) => match[1] ?? "");
823
+ };
824
+ var BLOCK_START_RE = /^ {0,3}([-+*]\s|\d+[.)]\s|#{1,6}(\s|$)|>)/;
825
+ var tableRow = (line) => {
826
+ if (!/^ {0,3}\S/.test(line) || BLOCK_START_RE.test(line)) return null;
827
+ const split = splitCells(line.trim());
828
+ if (split.length < 2) return null;
829
+ const inner = split.slice(line.trim().startsWith("|") ? 1 : 0);
830
+ return (inner.length > 1 && inner[inner.length - 1]?.trim() === "" ? inner.slice(0, -1) : inner).map((cell) => cell.trim().replace(/\\\|/g, "|"));
831
+ };
832
+ var isDelimiter = (cells) => cells !== null && cells.every((cell) => /^:?-+:?$/.test(cell));
833
+ var plainCell = (cell) => cell.replace(/[*_`]/g, "").trim().toLowerCase();
834
+ var columnName = (cell) => plainCell(cell).split(/[\s/]+/)[0] ?? "";
835
+ var idsInCell = (cell) => {
836
+ const ids = cell.replace(/\([^()]*\)/g, "");
837
+ const quoted = [...ids.matchAll(ID_IN_CELL_RE)].map((match) => unwrapId((match[1] ?? "").trim())).filter((id) => id !== "");
838
+ const bare = ids.split(",").map((piece) => unwrapId(piece.trim())).filter((id) => id !== "");
839
+ return quoted.length > 0 ? quoted : bare.every((id) => /^\S+$/.test(id)) ? bare : [];
840
+ };
841
+ var tableDisposition = (cell) => {
842
+ const firstWord = plainCell(cell).replace(/^[^a-z]+/, "").split(/[^a-z]/)[0] ?? "";
843
+ return isAnswerWord(firstWord) ? firstWord : "unstated";
844
+ };
845
+ var rowAnswers = (cells, columns) => {
846
+ const dispositionCell = cells[columns.disposition] ?? "";
847
+ const disposition = tableDisposition(dispositionCell);
848
+ const verdictOnly = isAnswerWord(plainCell(dispositionCell));
849
+ const reason = clipText(
850
+ cells.filter(
851
+ (cell, column) => cell !== "" && column !== columns.id && !(verdictOnly && column === columns.disposition)
852
+ ).join(" \u2014 "),
853
+ RESPONSE_REASON_CLIP_CHARS
854
+ );
855
+ return idsInCell(cells[columns.id] ?? "").map((id) => ({ id, disposition, reason }));
856
+ };
857
+ var namesAColumn = (name) => ID_HEADERS.has(name) || DISPOSITION_HEADERS.has(name);
858
+ var tableAnswers = (lines) => {
859
+ const rows = lines.map(tableRow);
860
+ return rows.reduce(
861
+ (state, cells, index) => {
862
+ if (state.skipDelimiter) return { ...state, skipDelimiter: false };
863
+ if (cells === null) return { ...state, inTable: false, columns: null };
864
+ const opensTable = !isDelimiter(cells) && isDelimiter(rows[index + 1] ?? null) && (!state.inTable || cells.map(columnName).some(namesAColumn));
865
+ if (opensTable) {
866
+ const header = cells.map(columnName);
867
+ const id = header.findIndex((name) => ID_HEADERS.has(name));
868
+ const disposition = header.findIndex((name) => DISPOSITION_HEADERS.has(name));
869
+ return {
870
+ ...state,
871
+ inTable: true,
872
+ skipDelimiter: true,
873
+ columns: id < 0 || disposition < 0 ? null : { id, disposition }
874
+ };
875
+ }
876
+ if (state.inTable && state.columns !== null && !isDelimiter(cells)) {
877
+ state.answers.push(...rowAnswers(cells, state.columns));
878
+ }
879
+ return isDelimiter(rows[index + 1] ?? null) ? { ...state, skipDelimiter: true } : state;
880
+ },
881
+ { columns: null, inTable: false, skipDelimiter: false, answers: [] }
882
+ ).answers;
883
+ };
884
+ var parseResponses = (text) => {
885
+ const lines = answerLines(text);
886
+ const seen = /* @__PURE__ */ new Set();
887
+ return [...lineAnswers(lines), ...tableAnswers(lines)].filter((answer) => {
888
+ const key2 = JSON.stringify([answer.id, answer.disposition, answer.reason]);
889
+ if (seen.has(key2)) return false;
890
+ seen.add(key2);
891
+ return true;
892
+ });
893
+ };
894
+ var isHuman = (login, type9, botLogin) => login !== botLogin && type9 === "User";
895
+ var newestFirst = (a, b) => {
896
+ const [left2, right2] = [a.created_at ?? "", b.created_at ?? ""];
897
+ return left2 < right2 ? 1 : left2 > right2 ? -1 : b.id - a.id;
898
+ };
899
+ var answersFrom = (input) => {
900
+ const comments = [...input.comments].filter((comment) => isHuman(comment.user.login, comment.user.type ?? null, input.botLogin)).sort(newestFirst).flatMap(
901
+ (comment) => parseResponses(comment.body ?? "").map((line) => ({
902
+ ...line,
903
+ channel: "comment",
904
+ source_url: `https://github.com/${input.repo}/pull/${String(input.prNumber)}#issuecomment-${String(comment.id)}`,
905
+ author: comment.user.login,
906
+ author_association: comment.author_association ?? null,
907
+ created_at: comment.created_at ?? null
908
+ }))
909
+ );
910
+ const commits = [...input.commits].reverse().flatMap(
911
+ (commit) => parseResponses(commit.message).map((line) => ({
912
+ ...line,
913
+ channel: "commit",
914
+ source_url: `https://github.com/${input.repo}/commit/${commit.sha}`,
915
+ author: commit.author,
916
+ author_association: null,
917
+ created_at: commit.date ?? null
918
+ }))
919
+ );
920
+ return { comments, commits };
921
+ };
922
+ var harvestResponses = (input) => {
923
+ const { comments: fromComments, commits: fromCommits } = answersFrom(input);
924
+ const matched = (response) => input.priorIds.has(response.id);
925
+ const echoed = (response) => !matched(response) && ID_SHAPE_RE.test(response.id);
926
+ const groups = [fromComments, fromCommits].flatMap((channel) => [
927
+ channel.filter(matched),
928
+ channel.filter(echoed)
929
+ ]);
930
+ const [comments, commentEchoes, commits, commitEchoes] = groups.map(
931
+ (group) => group.slice(0, RESPONSES_PER_CHANNEL)
932
+ );
933
+ return {
934
+ file: {
935
+ responses: [...comments ?? [], ...commits ?? []],
936
+ unmatched: [...commentEchoes ?? [], ...commitEchoes ?? []].map(
937
+ ({ id, channel, source_url }) => ({ id, channel, source_url })
938
+ )
939
+ },
940
+ dropped: groups.reduce(
941
+ (total, group) => total + Math.max(0, group.length - RESPONSES_PER_CHANNEL),
942
+ 0
943
+ )
944
+ };
945
+ };
946
+ var isTrustedResponse = (response, headInBaseRepo) => {
947
+ switch (response.channel) {
948
+ case "comment":
949
+ return MAINTAINER_ASSOCIATIONS.includes(response.author_association ?? "");
950
+ case "commit":
951
+ return headInBaseRepo;
952
+ }
953
+ };
954
+ var answeredInstant = (response) => response.created_at === null ? Number.NaN : Date.parse(response.created_at);
955
+ var closingResponses = (responses, isTrusted) => newestTrustedAnswersIn(responses, isTrusted, CLOSING_DISPOSITIONS);
956
+ var withRulingsOn = (responses, ruledIds, isTrusted) => responses.map(
957
+ (response) => RulingCodec.is(response.disposition) && !(ruledIds.has(response.id) && isTrusted(response)) ? { ...response, disposition: "unstated" } : response
958
+ );
959
+ var newestTrustedAnswersIn = (responses, isTrusted, dispositions) => {
960
+ const byId = responses.filter((response) => response.disposition !== "unstated" && isTrusted(response)).reduce((groups, response) => {
961
+ const timed = { response, at: answeredInstant(response) };
962
+ groups.set(response.id, [...groups.get(response.id) ?? [], timed]);
963
+ return groups;
964
+ }, /* @__PURE__ */ new Map());
965
+ return [...byId.values()].flatMap((answers) => {
966
+ if (answers.some(({ at }) => Number.isNaN(at))) return [];
967
+ const latest = Math.max(...answers.map(({ at }) => at));
968
+ const newest = answers.filter(({ at }) => at === latest).map(({ response }) => response);
969
+ return newest.every((response) => dispositions.has(response.disposition)) ? [...newest].sort((a, b) => a.source_url < b.source_url ? -1 : 1).slice(0, 1) : [];
970
+ });
971
+ };
972
+
973
+ // src/dialogue.ts
974
+ var DialogueStateCodec = t.keyof({
975
+ rebutted: null,
976
+ contested: null,
977
+ upheld: null,
978
+ overruled: null
979
+ });
980
+ var DialogueEntryCodec = t.type({
981
+ id: t.string,
982
+ state: DialogueStateCodec,
983
+ answer: t.string,
984
+ at: t.string,
985
+ title: t.string,
986
+ severity: SeverityCodec
987
+ });
988
+ var DIALOGUE_STATES = Object.keys(DialogueStateCodec.keys);
989
+ var DIALOGUE_ENTRY_CAP = 32;
990
+ var decodeDialogue = (raw) => {
991
+ const items = Array.isArray(raw) ? raw : [];
992
+ const decoded = items.map((item) => ({ item, entry: DialogueEntryCodec.decode(item) }));
993
+ const entries = decoded.flatMap(({ entry }) => isRight(entry) ? [entry.right] : []);
994
+ const undecoded = decoded.flatMap(({ item, entry }) => isRight(entry) ? [] : [item]);
995
+ return { entries, undecoded, skipped: Array.isArray(raw) ? undecoded.length : 1 };
996
+ };
997
+ var dialogueAnswers = (responses, isTrusted) => {
998
+ const newest = (dispositions) => new Map(
999
+ newestTrustedAnswersIn(responses, isTrusted, new Set(dispositions)).map((response) => [
1000
+ response.id,
1001
+ response
1002
+ ])
1003
+ );
1004
+ return {
1005
+ closures: newest(["refuted", "dismissed"]),
1006
+ overrulings: newest(["overruled"]),
1007
+ upholdings: newest(["upheld"]),
1008
+ fixed: new Set(newest(["fixed"]).keys())
1009
+ };
1010
+ };
1011
+ var linked = (entry, answer, state) => answer.created_at === null ? [entry] : [{ ...entry, state, answer: answer.source_url, at: answer.created_at }];
1012
+ var isNewer = (answer, entry) => answer.created_at !== null && Date.parse(answer.created_at) > Date.parse(entry.at);
1013
+ var isOlder = (answer, entry) => answer.created_at !== null && Date.parse(answer.created_at) < Date.parse(entry.at);
1014
+ var advanceEntry = (entry, answers, item) => {
1015
+ if (answers.fixed.has(entry.id)) return [];
1016
+ const refreshed = item !== void 0 && (entry.state === "contested" || entry.state === "upheld" || item.rebutted) ? { ...entry, title: item.title, severity: item.severity } : entry;
1017
+ const closure = answers.closures.get(entry.id);
1018
+ switch (entry.state) {
1019
+ case "rebutted":
1020
+ return closure === void 0 ? [refreshed] : linked(refreshed, closure, isNewer(closure, entry) ? "contested" : "rebutted");
1021
+ case "contested": {
1022
+ const overruling = answers.overrulings.get(entry.id);
1023
+ const upholding = answers.upholdings.get(entry.id);
1024
+ 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");
1025
+ }
1026
+ case "upheld": {
1027
+ const overruling = answers.overrulings.get(entry.id);
1028
+ return overruling !== void 0 && isNewer(overruling, entry) ? linked(refreshed, overruling, "overruled") : [refreshed];
1029
+ }
1030
+ case "overruled": {
1031
+ const upholding = answers.upholdings.get(entry.id);
1032
+ return upholding !== void 0 && isNewer(upholding, entry) ? linked(refreshed, upholding, "upheld") : [refreshed];
1033
+ }
1034
+ }
1035
+ };
1036
+ var advanceDialogue = (prior, answers, current) => {
1037
+ const currentById = new Map(current.map((item) => [item.id, item]));
1038
+ const priorIds = new Set(prior.map((entry) => entry.id));
1039
+ const opened = [...currentById.values()].flatMap((item) => {
1040
+ const closure = answers.closures.get(item.id);
1041
+ const at = closure?.created_at ?? null;
1042
+ return priorIds.has(item.id) || !item.rebutted || closure === void 0 || at === null ? [] : [
1043
+ {
1044
+ id: item.id,
1045
+ state: "rebutted",
1046
+ answer: closure.source_url,
1047
+ at,
1048
+ title: item.title,
1049
+ severity: item.severity
1050
+ }
1051
+ ];
1052
+ });
1053
+ const entries = [
1054
+ ...prior.flatMap((entry) => advanceEntry(entry, answers, currentById.get(entry.id))),
1055
+ ...opened
1056
+ ];
1057
+ const held = entries.filter((entry) => entry.state !== "rebutted");
1058
+ return [
1059
+ ...held,
1060
+ ...entries.filter((entry) => entry.state === "rebutted").slice(0, Math.max(0, DIALOGUE_ENTRY_CAP - held.length))
1061
+ ];
1062
+ };
1063
+ var dialogueMetricsTable = (metrics) => [
1064
+ "### \u{1F4AC} Review dialogue",
1065
+ "",
1066
+ "| | |",
1067
+ "|---|--:|",
1068
+ `| answers read (comments \xB7 commits) | ${String(metrics.commentAnswers)} \xB7 ${String(metrics.commitAnswers)} |`,
1069
+ `| trusted closing answers | ${String(metrics.closures)} |`,
1070
+ `| re-raises dropped (answered \xB7 overruled) | ${String(metrics.droppedAnswered)} \xB7 ${String(metrics.droppedOverruled)} |`,
1071
+ `| re-raises kept and annotated | ${String(metrics.annotated)} |`,
1072
+ `| ${DIALOGUE_STATES.join(" \xB7 ")} | ${DIALOGUE_STATES.map(
1073
+ (state) => String(metrics.entries.filter((entry) => entry.state === state).length)
1074
+ ).join(" \xB7 ")} |`,
1075
+ `| systemic problems no trusted answer names | ${String(metrics.systemicUnanswered)} |`
1076
+ ].join("\n");
667
1077
 
668
1078
  // src/patch.ts
669
1079
  var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
@@ -791,6 +1201,7 @@ var decodeBase64Json = (b64) => {
791
1201
  return void 0;
792
1202
  }
793
1203
  };
1204
+ var noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
794
1205
  var findingsPointer = (jsonUrl) => encodeMarker(jsonUrl);
795
1206
  var warnedValveFindings = /* @__PURE__ */ new Set();
796
1207
  var INLINE_PROSE_CLIP_THRESHOLD_CHARS = 3e4;
@@ -1022,8 +1433,15 @@ var convergenceScore = (doc, threshold) => round2(
1022
1433
  0
1023
1434
  )
1024
1435
  );
1025
- var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, ids = {}, sha) => {
1026
- const score = convergenceScore(doc, threshold);
1436
+ var CONTESTED_MAJOR = 0.51;
1437
+ var contestedWeight = (severity, threshold) => severity === "major" ? CONTESTED_MAJOR : convergenceFloor(severity, threshold);
1438
+ var dialogueScore = (doc, contested, threshold) => {
1439
+ return round2(
1440
+ convergenceScore(withoutIds(doc, new Set(contested.map((entry) => entry.id))), threshold) + contested.reduce((sum, entry) => sum + contestedWeight(entry.severity, threshold), 0)
1441
+ );
1442
+ };
1443
+ var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, ids = {}, sha, contested = []) => {
1444
+ const score = dialogueScore(doc, contested, threshold);
1027
1445
  const normalized = normalizeIdCounts(ids, priorRounds[priorRounds.length - 1]?.ids);
1028
1446
  const current = {
1029
1447
  round,
@@ -1154,6 +1572,13 @@ var findingsMarkerPair = (jsonUrl, convergence) => {
1154
1572
  return marker === "" ? conv : `${marker}
1155
1573
  ${conv}`;
1156
1574
  };
1575
+ var DIALOGUE_RE = /<!-- code-review:dialogue;base64 ([A-Za-z0-9+/=]+) -->/;
1576
+ var dialogueMarker = (entries, undecoded = []) => entries.length + undecoded.length === 0 ? "" : `<!-- code-review:dialogue;base64 ${Buffer.from(JSON.stringify([...entries, ...undecoded]), "utf-8").toString("base64")} -->`;
1577
+ var carriedDialogueMarker = (body) => DIALOGUE_RE.exec(body)?.[0] ?? "";
1578
+ var parseDialogueMarker = (body) => {
1579
+ const b64 = DIALOGUE_RE.exec(body)?.[1];
1580
+ return b64 === void 0 ? { entries: [], undecoded: [], skipped: 0 } : decodeDialogue(decodeBase64Json(b64));
1581
+ };
1157
1582
  var parseConvergenceMarker = (body) => {
1158
1583
  const b64 = CONVERGENCE_RE.exec(body)?.[1];
1159
1584
  return b64 === void 0 ? null : validStampedConvergence(decodeBase64Json(b64));
@@ -1210,11 +1635,20 @@ var carryForwardMarkers = (body) => {
1210
1635
  const findings = carriedFindingsMarker(body);
1211
1636
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
1212
1637
  const convergence = CONVERGENCE_RE.exec(body)?.[0];
1638
+ const dialogue = DIALOGUE_RE.exec(body)?.[0];
1213
1639
  const rounds = ROUNDS_RE.exec(body)?.[0];
1214
1640
  const signal = SIGNAL_RE.exec(body)?.[0];
1215
1641
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
1216
1642
  ${findings}` : void 0;
1217
- return [findingsBlock, reviewedSha, carriedProvenanceMarkers(body), convergence, rounds, signal].filter((m) => m !== void 0 && m !== "").join("\n\n");
1643
+ return [
1644
+ findingsBlock,
1645
+ reviewedSha,
1646
+ carriedProvenanceMarkers(body),
1647
+ convergence,
1648
+ dialogue,
1649
+ rounds,
1650
+ signal
1651
+ ].filter((m) => m !== void 0 && m !== "").join("\n\n");
1218
1652
  };
1219
1653
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
1220
1654
  var projectPatch = (patch, surface) => {
@@ -1392,29 +1826,38 @@ var resolvePriorFindings = async (body, read) => {
1392
1826
  };
1393
1827
 
1394
1828
  // 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
- ];
1829
+ var closedPriorItems = (closures, items, idOf) => closures.flatMap((response) => {
1830
+ const item = items.find((candidate) => idOf(candidate) === response.id);
1831
+ return item === void 0 ? [] : [[item, response]];
1832
+ });
1833
+ var closedClaim = (item, response) => ({
1834
+ code: response.id,
1835
+ title: item.title,
1836
+ description: item.description,
1837
+ reasoning: item.reasoning,
1838
+ severity: item.severity,
1839
+ answerUrl: response.source_url,
1840
+ answerAuthor: response.author
1410
1841
  });
1842
+ var answeredRegistryFrom = (closures, prior) => closedPriorItems(closures, prior?.findings ?? [], resolveFindingId).map(
1843
+ ([finding, response]) => ({
1844
+ ...closedClaim(finding, response),
1845
+ path: finding.path,
1846
+ patch: finding.patch ?? null
1847
+ })
1848
+ );
1849
+ var answeredSystemicRegistryFrom = (closures, prior) => closedPriorItems(closures, prior?.systemic_problems ?? [], resolveSystemicId).map(
1850
+ ([systemic, response]) => closedClaim(systemic, response)
1851
+ );
1411
1852
  var isSynthesizedTitleMatch = (f, e) => isSynthesizedFindingId(e.code) && e.title === f.title;
1412
1853
  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"];
1854
+ var couldMatch = (code, doc) => isSynthesizedFindingId(code) || doc.findings.some((f) => resolveFindingId(f) === code) || (doc.systemic_problems ?? []).some((s) => resolveSystemicId(s) === code);
1855
+ var CLAIM_FIELDS = ["title", "description", "reasoning", "severity"];
1856
+ var VERBATIM_FIELDS = [...CLAIM_FIELDS, "path", "patch"];
1415
1857
  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";
1858
+ var isVerbatimReRaise = (item, fields, fieldEqual) => !hasRebuttal(item) && fields.every(fieldEqual);
1859
+ var isVerbatimNonCritical = (item, fields, fieldEqual) => isVerbatimReRaise(item, fields, fieldEqual) && item.severity !== "critical";
1860
+ var isAnsweredDrop = (resolvedId, f, e) => matches(resolvedId, f, e) && isVerbatimNonCritical(f, VERBATIM_FIELDS, (field) => verbatimFieldEqual(f, e, field));
1418
1861
  var verbatimMatchCount = (f, e) => VERBATIM_FIELDS.reduce((count, field) => count + (verbatimFieldEqual(f, e, field) ? 1 : 0), 0);
1419
1862
  var answeredNoteKey = (f) => f.id !== "" ? f.id : `title:${f.title}`;
1420
1863
  var bestTitleMatch = (f, registry) => {
@@ -1466,38 +1909,78 @@ var applyAnswered = (findings, registry) => {
1466
1909
  droppedCount
1467
1910
  };
1468
1911
  };
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");
1912
+ var answeredSystemicNoteKey = (s) => `systemic:${resolveSystemicId(s) ?? ""}`;
1913
+ var systemicVerdict = (systemic, registry) => {
1914
+ const resolvedId = resolveSystemicId(systemic);
1915
+ const entry = registry.find((e) => e.code === resolvedId);
1916
+ return entry === void 0 ? { kind: "unanswered", systemic } : {
1917
+ kind: isVerbatimNonCritical(
1918
+ systemic,
1919
+ CLAIM_FIELDS,
1920
+ (field) => systemic[field] === entry[field]
1921
+ ) ? "dropped" : "annotated",
1922
+ systemic,
1923
+ entry
1924
+ };
1479
1925
  };
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) ?? ""}`;
1926
+ var applyAnsweredSystemic = (systemic, registry) => {
1927
+ const verdicts = systemic.map((s) => systemicVerdict(s, registry));
1928
+ const dropped = verdicts.flatMap((v) => v.kind === "dropped" ? [v.entry] : []);
1929
+ return {
1930
+ systemic: verdicts.flatMap((v) => v.kind === "dropped" ? [] : [v.systemic]),
1931
+ reRaisedNotes: Object.fromEntries(
1932
+ verdicts.flatMap(
1933
+ (v) => v.kind === "annotated" ? [[answeredSystemicNoteKey(v.systemic), answeredNote(v.entry)]] : []
1934
+ )
1935
+ ),
1936
+ verbatimReRaised: [...new Map(dropped.map((e) => [e.code, e])).values()],
1937
+ droppedCount: dropped.length
1938
+ };
1495
1939
  };
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.`;
1940
+ var applyOverruled = (items, rulings, idOf, noteKey) => {
1941
+ const ruled = items.map((item) => ({ item, ruling: rulings.get(idOf(item) ?? "") }));
1942
+ const drops = (entry) => entry.ruling !== void 0 && entry.item.severity !== "critical";
1943
+ const dropped = ruled.filter(drops).flatMap(({ ruling }) => ruling === void 0 ? [] : [ruling]);
1944
+ return {
1945
+ kept: ruled.filter((entry) => !drops(entry)).map(({ item }) => item),
1946
+ reRaisedNotes: Object.fromEntries(
1947
+ ruled.flatMap(
1948
+ ({ item, ruling }) => ruling !== void 0 && !drops({ item, ruling }) ? [[noteKey(item), answeredNote(ruling)]] : []
1949
+ )
1950
+ ),
1951
+ verbatimReRaised: [...new Map(dropped.map((ruling) => [ruling.code, ruling])).values()],
1952
+ droppedCount: dropped.length
1953
+ };
1954
+ };
1955
+ var closingAnswerLines = (entries, linkText) => entries.map((e) => {
1956
+ const label = e.code !== "" ? `\`${escapeCodeBackticks(e.code)}\`` : `\u201C${escapeCodeBackticks(e.title)}\u201D`;
1957
+ return `> - ${label} \u2014 [${linkText}](${linkSafeUrl(e.answerUrl)})${byAuthor(e)}`;
1958
+ });
1959
+ var answeredReRaiseNote = (entries, count) => entries.length === 0 ? "" : [
1960
+ `> \u21A9\uFE0F **${String(count)} re-raise(s) without new evidence \u2014 treated as answered** (a maintainer's answer closed each):`,
1961
+ ...closingAnswerLines(entries, "prior answer")
1962
+ ].join("\n");
1963
+ var overruledReRaiseNote = (entries, count) => entries.length === 0 ? "" : [
1964
+ `> \u2696\uFE0F **${String(count)} re-raise(s) of an id a maintainer overruled \u2014 closed for good**:`,
1965
+ ...closingAnswerLines(entries, "the ruling")
1966
+ ].join("\n");
1497
1967
 
1498
1968
  // src/render.ts
1499
1969
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
1500
1970
  var shedNote = (count, summaryAvailable, runUrl, findingsDocLinked, thisRunArtifact) => count === 0 ? "" : `<sub>**${String(count)} finding${count === 1 ? "" : "s"} left out of this comment**${summaryAvailable && runUrl !== void 0 ? ` \u2014 the [run summary](<${runUrl}>) carries the whole review` : findingsDocLinked && thisRunArtifact ? " \u2014 the machine findings document carries them" : ""}.</sub>`;
1971
+ var UNFINISHED_SHOWN = 8;
1972
+ var UNFINISHED_CLIP_CHARS = 240;
1973
+ var unfinishedLine = (check) => {
1974
+ const escaped = escapeHtml(check.replace(/\s+/g, " ").trim());
1975
+ return escaped.length > UNFINISHED_CLIP_CHARS ? `${escaped.slice(0, UNFINISHED_CLIP_CHARS).replace(/&[#a-z0-9]*$/i, "").replace(/[\uD800-\uDBFF]$/, "")}\u2026` : escaped;
1976
+ };
1977
+ var shownUnfinished = (doc, fullReviewRound) => {
1978
+ const checks = fullReviewRound ? doc.unfinished ?? [] : [];
1979
+ return [
1980
+ ...checks.slice(0, UNFINISHED_SHOWN).map(unfinishedLine),
1981
+ ...checks.length > UNFINISHED_SHOWN ? [`\u2026 and ${String(checks.length - UNFINISHED_SHOWN)} more`] : []
1982
+ ];
1983
+ };
1501
1984
  var terminalNotice = (i) => [
1502
1985
  `## ${i.verdictBadge}`,
1503
1986
  "",
@@ -1509,6 +1992,7 @@ var terminalNotice = (i) => [
1509
1992
  ] : [],
1510
1993
  "",
1511
1994
  `> **The review for \`${escapeCodeBackticks(i.reviewedSha.slice(0, 7))}\` exceeds GitHub's comment-size limit.**` + (i.summaryAvailable ? i.runUrl ? ` The whole review lives in the [run summary](<${i.runUrl}>).` : " The whole review lives in the workflow run's summary." : i.findingsDocLinked && i.thisRunArtifact ? " The whole review lives in the machine findings document." : "") + (i.findingsDocLinked && i.thisRunArtifact ? " The machine findings document is linked above." : ""),
1995
+ ...i.unfinished.length > 0 ? ["", `> **Unfinished checks:** ${i.unfinished.join(" \xB7 ")}`] : [],
1512
1996
  "",
1513
1997
  "> [!WARNING]",
1514
1998
  `> **LLM Disclosure** \u2014 this review was produced by ${i.modelNames || "unknown model"}.`,
@@ -1587,7 +2071,7 @@ var sanitizeFinding = (f, answeredNotes, discussionByFinding, permalinkBase, una
1587
2071
  rangeLabel: lineRange(f.start_line, f.end_line, "\u2013"),
1588
2072
  ...permalink !== void 0 ? { permalink, permalinkAnchored: anchored } : {},
1589
2073
  patchProjection: projectPatch(f.patch, "comment-body"),
1590
- answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : "",
2074
+ answeredNote: noteFor(answeredNotes, key2),
1591
2075
  discussion: discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(discussionByFinding, f.id) ? discussionByFinding[f.id] ?? [] : [],
1592
2076
  ...discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(discussionTruncated, f.id) ? { discussionTruncated: discussionTruncated[f.id] } : {}
1593
2077
  };
@@ -1617,9 +2101,10 @@ var carriedLines = (f) => [
1617
2101
  `reasoning: ${clipText(f.reasoning, BODY_CLIP_CHARS)}`,
1618
2102
  ...f.patch !== void 0 ? ["patch:", clipText(f.patch, BODY_CLIP_CHARS)] : []
1619
2103
  ].flatMap((block) => commentSafe(block).split("\n")).map((line) => line.trimEnd());
1620
- var sanitizeSystemic = (s, discussion, discussionTruncated) => ({
2104
+ var sanitizeSystemic = (s, answeredNotes, discussion, discussionTruncated) => ({
1621
2105
  ...s,
1622
2106
  title: escapePipes(s.title),
2107
+ answeredNote: noteFor(answeredNotes, answeredSystemicNoteKey(s)),
1623
2108
  discussion,
1624
2109
  ...discussionTruncated !== void 0 ? { discussionTruncated } : {},
1625
2110
  discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncated, "") : "",
@@ -1671,6 +2156,9 @@ var render = (input) => {
1671
2156
  const trajectory = convergence?.rounds ?? input.rounds ?? [];
1672
2157
  const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(trajectory.slice(0, -1), input.findings.findings);
1673
2158
  const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && trajectory.length > 0)) && isReviewVerdict(input.findings.verdict);
2159
+ const unfinished = input.findings.unfinished ?? [];
2160
+ const unfinishedSuffix = unfinished.length > 0 ? ` \xB7 ${String(unfinished.length)} unfinished check${unfinished.length === 1 ? "" : "s"}` : "";
2161
+ const contestedSuffix = (input.contested ?? []).length > 0 ? ` \xB7 ${String((input.contested ?? []).length)} contested` : "";
1674
2162
  const advisoryAllowed = isFullReviewRound;
1675
2163
  const discussionLinksFor = (id) => input.discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionByFinding, id) ? input.discussionByFinding[id] ?? [] : [];
1676
2164
  const discussionTruncatedFor = (id) => input.discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionTruncated, id) ? input.discussionTruncated[id] : void 0;
@@ -1750,6 +2238,7 @@ var render = (input) => {
1750
2238
  (input.findings.systemic_problems ?? []).map(
1751
2239
  (s) => sanitizeSystemic(
1752
2240
  s,
2241
+ input.answeredNotes,
1753
2242
  s.id !== void 0 ? discussionLinksFor(s.id) : [],
1754
2243
  s.id !== void 0 ? discussionTruncatedFor(s.id) : void 0
1755
2244
  )
@@ -1758,6 +2247,23 @@ var render = (input) => {
1758
2247
  (s) => s.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + s.discussionHtml.length : 0,
1759
2248
  (s) => ({ ...s, discussion: [], discussionHtml: "" })
1760
2249
  );
2250
+ const contestedBudget = budgetBySize(
2251
+ (input.contested ?? []).map((view) => {
2252
+ const discussion = discussionLinksFor(view.id);
2253
+ return {
2254
+ ...view,
2255
+ ...view.reRaise?.location !== void 0 ? { reRaise: { ...view.reRaise, location: escapeCodeBackticks(view.reRaise.location) } } : {},
2256
+ id: escapeCodeBackticks(view.id),
2257
+ title: escapePipes(view.title),
2258
+ answerUrl: linkSafeUrl(view.answerUrl),
2259
+ discussion,
2260
+ discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncatedFor(view.id), "") : ""
2261
+ };
2262
+ }),
2263
+ DISCUSSION_TOTAL_CHARS - orphanedBudget.used - discussionBudget.used - systemicBudget.used,
2264
+ (view) => view.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + view.discussionHtml.length : 0,
2265
+ (view) => ({ ...view, discussion: [], discussionHtml: "" })
2266
+ );
1761
2267
  const orphanedRender = {
1762
2268
  lines: orphanedBudget.kept.map(([, entry]) => entry.html),
1763
2269
  // The "(showing N of M)" total counts DISTINCT DISPLAYED entries — the pre-budget fold above
@@ -1784,6 +2290,7 @@ var render = (input) => {
1784
2290
  summaryAvailable: input.summaryAvailable ?? false,
1785
2291
  responseForm: RESPONSE_FORM,
1786
2292
  responseTeaching: RESPONSE_TEACHING,
2293
+ rulingTeaching: RULING_TEACHING,
1787
2294
  teachesResponses: isConvergenceRound(route, incomplete),
1788
2295
  shedNote: shedNote(
1789
2296
  input.shedCount ?? 0,
@@ -1795,12 +2302,13 @@ var render = (input) => {
1795
2302
  input.jsonUrl !== void 0
1796
2303
  ),
1797
2304
  terminalNotice: terminalNotice({
2305
+ unfinished: shownUnfinished(input.findings, isFullReviewRound),
1798
2306
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
1799
2307
  verdictBadge: verdictBadge(input.findings.verdict),
1800
2308
  route: route ?? void 0,
1801
2309
  postedAt: input.postedAt ?? "",
1802
2310
  roundsSummary: roundsSummary(trajectory, input.roundCount),
1803
- convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : "",
2311
+ convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) + contestedSuffix + unfinishedSuffix : "",
1804
2312
  inlinePosted: input.inlineDisposition?.kind === "posted" ? input.inlineDisposition.count : 0,
1805
2313
  runUrl: input.runUrl,
1806
2314
  summaryAvailable: input.summaryAvailable ?? false,
@@ -1827,15 +2335,21 @@ var render = (input) => {
1827
2335
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
1828
2336
  postedAt: input.postedAt ?? "",
1829
2337
  severityCounts,
1830
- convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold),
2338
+ convergenceSummary: !isFullReviewRound ? "" : (convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold)) + contestedSuffix + unfinishedSuffix,
1831
2339
  strays: discussionBudget.kept,
1832
2340
  orphanedLines: orphanedRender.lines,
1833
2341
  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),
2342
+ discussionDropped: discussionBudget.droppedItems.length + systemicBudget.droppedItems.length + contestedBudget.droppedItems.length + orphanedBudget.droppedItems.length,
2343
+ discussionDroppedIds: [
2344
+ ...discussionBudget.droppedItems.map((v) => clipText(escapeCodeBackticks(v.idKey), 64)),
2345
+ ...contestedBudget.droppedItems.map((v) => clipText(v.id, 64))
2346
+ ].slice(0, DROPPED_IDS_SHOWN),
1836
2347
  discussionDroppedSystemicIds: systemicBudget.droppedItems.flatMap((s) => s.id !== void 0 ? [clipText(s.id, 64)] : []).slice(0, DROPPED_IDS_SHOWN),
1837
2348
  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),
2349
+ discussionDroppedExtra: Math.max(
2350
+ 0,
2351
+ discussionBudget.droppedItems.length + contestedBudget.droppedItems.length - DROPPED_IDS_SHOWN
2352
+ ) + Math.max(0, systemicBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, orphanedBudget.droppedItems.length - DROPPED_IDS_SHOWN),
1839
2353
  discussionCap: PER_FINDING_LINKS,
1840
2354
  orphanedTotal: orphanedRender.total,
1841
2355
  suppressedNits: suppressedBudget.kept,
@@ -1857,6 +2371,8 @@ var render = (input) => {
1857
2371
  sameRootNotes: advisoryAllowed ? sameRootNotes : {},
1858
2372
  answeredNotes: input.answeredNotes ?? {},
1859
2373
  answeredReRaiseNote: input.answeredReRaiseNote ?? "",
2374
+ contested: contestedBudget.kept,
2375
+ unfinished: shownUnfinished(input.findings, isFullReviewRound),
1860
2376
  reviewUrl: input.reviewUrl ?? null,
1861
2377
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
1862
2378
  // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to
@@ -1957,7 +2473,6 @@ var buildInlineComments = (findings, diff, context) => {
1957
2473
  const { inDiff, strays } = context.partition ?? partitionFindings(findings, indexDiff(diff));
1958
2474
  const eta = new Eta({ autoTrim: false });
1959
2475
  const modelsText = formatModels(models);
1960
- const noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1961
2476
  const comments = inDiff.map((f) => {
1962
2477
  const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
1963
2478
  const clipProse = fullFindings !== void 0 && findingPayload(f, fullFindings.schema_version).length > INLINE_PROSE_CLIP_THRESHOLD_CHARS;
@@ -2067,12 +2582,12 @@ var sumTranscriptUsage = (entries) => {
2067
2582
  },
2068
2583
  { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
2069
2584
  );
2070
- const models = [...summed.totals].map(([model, t9]) => ({
2585
+ const models = [...summed.totals].map(([model, t10]) => ({
2071
2586
  model,
2072
- input_tokens: t9.input,
2073
- output_tokens: t9.output,
2074
- cache_read_tokens: t9.cacheRead,
2075
- cache_write_tokens: t9.cacheWrite
2587
+ input_tokens: t10.input,
2588
+ output_tokens: t10.output,
2589
+ cache_read_tokens: t10.cacheRead,
2590
+ cache_write_tokens: t10.cacheWrite
2076
2591
  }));
2077
2592
  const bounds = entries.reduce(
2078
2593
  (acc, entry) => {
@@ -2272,8 +2787,8 @@ var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.
2272
2787
  var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wallMs > 0 ? `${mins(i.elapsedMs)}/${mins(i.wallMs)} elapsed (${pct(i.elapsedMs / i.wallMs)})` : `${mins(i.elapsedMs)} elapsed`;
2273
2788
  var directive = (phase, draftPath, isSubagent) => {
2274
2789
  if (isSubagent)
2275
- return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
2276
- return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Do not wait for subagents still running in the background. Write your COMPLETE findings from what you already have to ${draftPath} and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 fold in the subagent reports you already have rather than waiting on stragglers; you may run out of budget before you finish otherwise.`;
2790
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply, naming each check you planned but did not finish. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply, naming each check you planned but did not finish \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
2791
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Do not wait for subagents still running in the background. Write your COMPLETE findings from what you already have to ${draftPath}, listing in its \`unfinished\` field, when its schema has one, every check you or a subagent planned but did not finish, and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, listing in its \`unfinished\` field, when its schema has one, every check you or a subagent planned but did not finish, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 fold in the subagent reports you already have rather than waiting on stragglers; you may run out of budget before you finish otherwise.`;
2277
2792
  };
2278
2793
  var budgetMessage = (i, phase, draftPath, isSubagent) => {
2279
2794
  const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
@@ -2297,7 +2812,7 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
2297
2812
  const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
2298
2813
  if (WRITE_TOOLS.has(toolName)) {
2299
2814
  const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
2300
- if (typeof fp === "string" && targets.some((t9) => fp === t9 || basename(fp) === basename(t9)))
2815
+ if (typeof fp === "string" && targets.some((t10) => fp === t10 || basename(fp) === basename(t10)))
2301
2816
  return true;
2302
2817
  }
2303
2818
  if (toolName === "Bash") {
@@ -2402,9 +2917,9 @@ var parseWallMs = (raw) => {
2402
2917
  };
2403
2918
  var parseEpochSecMs = (raw) => {
2404
2919
  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);
2920
+ const t10 = raw.trim();
2921
+ if (!/^\d+$/.test(t10)) return null;
2922
+ const n = Number.parseInt(t10, 10);
2408
2923
  return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
2409
2924
  };
2410
2925
  var anchoredElapsedMs = (src) => {
@@ -2817,220 +3332,6 @@ var checkRun = async (input, ghApi = runGhApi) => {
2817
3332
  ];
2818
3333
  await ghApi(endpoint, JSON.stringify(body));
2819
3334
  };
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
3335
 
3035
3336
  // src/post.ts
3036
3337
  var STICKY_CHAR_LIMIT = 65536;
@@ -3079,15 +3380,16 @@ var loadFindings = (path) => {
3079
3380
  }
3080
3381
  const first = decodeFindings(raw);
3081
3382
  if (first.kind === "invalid-shape" && typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.keys(raw).some((k) => RECOVERABLE_OPTIONAL_FIELDS.has(k))) {
3082
- const stripped = Object.fromEntries(
3083
- Object.entries(raw).filter(([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2))
3383
+ const recovered = withoutBrokenOptionalFields(
3384
+ raw,
3385
+ (candidate) => decodeFindings(candidate).kind === "ok"
3084
3386
  );
3085
- const retry = decodeFindings(stripped);
3086
- if (retry.kind === "ok") {
3387
+ if (recovered !== null) {
3087
3388
  process.stderr.write(
3088
- "Warning: the review draft carried an invalid best-effort field (convergence/scope_metastasis/change_size) \u2014 stripped it and used the rest; the pipeline re-stamps convergence\n"
3389
+ `Warning: the review draft carried an invalid best-effort field (${recovered.stripped.join("/")}) \u2014 stripped it and used the rest; the pipeline re-stamps convergence
3390
+ `
3089
3391
  );
3090
- return retry;
3392
+ return decodeFindings(recovered.doc);
3091
3393
  }
3092
3394
  }
3093
3395
  return first;
@@ -3551,8 +3853,8 @@ var priorBotCommentIds = (raw, botLogin) => {
3551
3853
  const nodes = conn?.nodes;
3552
3854
  if (!Array.isArray(nodes)) return { ids: [], truncated };
3553
3855
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
3554
- const ids = nodes.flatMap((t9) => {
3555
- const cnodes = t9.comments?.nodes;
3856
+ const ids = nodes.flatMap((t10) => {
3857
+ const cnodes = t10.comments?.nodes;
3556
3858
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
3557
3859
  });
3558
3860
  return { ids, truncated };
@@ -3648,7 +3950,7 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3648
3950
  convergence: conv ?? void 0
3649
3951
  });
3650
3952
  let warnedNoJsonUrl = false;
3651
- const findingsBlob = (doc) => {
3953
+ const findingsPair = (doc) => {
3652
3954
  if (input.jsonUrl) return findingsMarkerPair(input.jsonUrl, doc.convergence);
3653
3955
  const carriedLink = existingSticky !== null ? findingsArtifactUrl(existingSticky.body) : null;
3654
3956
  if (carriedLink !== null) {
@@ -3662,6 +3964,8 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3662
3964
  }
3663
3965
  return findingsMarkerPair(void 0, doc.convergence);
3664
3966
  };
3967
+ const carriedDialogue = existingSticky !== null ? carriedDialogueMarker(existingSticky.body) : "";
3968
+ const findingsBlob = (doc, dialogue2 = carriedDialogue) => [findingsPair(doc), dialogue2].filter((marker) => marker !== "").join("\n");
3665
3969
  const leaveInPlace = (message) => {
3666
3970
  process.stderr.write(
3667
3971
  message ?? "Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place\n"
@@ -3670,9 +3974,9 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3670
3974
  process.exit(0);
3671
3975
  };
3672
3976
  const logAnsweredDrops = () => {
3673
- if (verbatimReRaised.length > 0) {
3977
+ if (droppedCount > 0) {
3674
3978
  process.stderr.write(
3675
- `${String(droppedCount)} verbatim re-raise(s) of answered findings were treated as answered \u2014 the preserved sticky shows each finding
3979
+ `${String(droppedCount)} verbatim re-raise(s) of answered findings or systemic problems were treated as answered \u2014 the preserved sticky shows each one
3676
3980
  `
3677
3981
  );
3678
3982
  }
@@ -3750,7 +4054,7 @@ ${dropNote}` : ""}`,
3750
4054
  const carried = existingSticky !== null ? carriedFindingsMarker(existingSticky.body) : null;
3751
4055
  if (carried === null) return findingsBlob(doc);
3752
4056
  const provenance = existingSticky !== null ? carriedAncestryMarkers(existingSticky.body) : "";
3753
- return [carriedMarkerPointer(carried, doc.convergence), provenance].filter((p) => p !== "").join("\n\n");
4057
+ return [carriedMarkerPointer(carried, doc.convergence), carriedDialogue, provenance].filter((p) => p !== "").join("\n\n");
3754
4058
  };
3755
4059
  return formatMarkdown(
3756
4060
  render({
@@ -3797,7 +4101,18 @@ ${dropNote}` : ""}`,
3797
4101
  process.exit(0);
3798
4102
  }
3799
4103
  const loadedFindings = findingsResult.findings;
3800
- const answerable = existingSticky !== null && !priorIsMechanic && loadedFindings.findings.length > 0;
4104
+ const priorDialogue = existingSticky !== null && input.withoutDialogue !== true ? parseDialogueMarker(existingSticky.body) : { entries: [], undecoded: [], skipped: 0 };
4105
+ if (priorDialogue.skipped > 0) {
4106
+ process.stderr.write(
4107
+ `Warning: ${String(priorDialogue.skipped)} entry(ies) of the prior sticky's dialogue state did not decode \u2014 carried unread
4108
+ `
4109
+ );
4110
+ }
4111
+ const dialogueRound = input.withoutDialogue !== true && envelope !== null && isConvergenceRound(
4112
+ effectiveRoute,
4113
+ envelope.incomplete === true || isIncompleteFindings(loadedFindings)
4114
+ ) && isReviewVerdict(loadedFindings.verdict);
4115
+ const answerable = input.withoutDialogue !== true && existingSticky !== null && !priorIsMechanic && (loadedFindings.findings.length > 0 || (loadedFindings.systemic_problems ?? []).length > 0 || dialogueRound && priorDialogue.entries.length > 0);
3801
4116
  const headInBaseRepo = answerable && resolution.headRepo !== null && resolution.headRepo.toLowerCase() === input.repo.toLowerCase();
3802
4117
  const answers = answerable ? answersFrom({
3803
4118
  repo: input.repo,
@@ -3812,10 +4127,46 @@ ${dropNote}` : ""}`,
3812
4127
  })),
3813
4128
  commits: headInBaseRepo ? await fetchCommitAnswersSource(input.repo, prNumber, ghApi) : []
3814
4129
  }) : { comments: [], commits: [] };
3815
- const closures = closingResponses(
4130
+ const isTrusted = (response) => isTrustedResponse(response, headInBaseRepo);
4131
+ const allAnswers = withRulingsOn(
3816
4132
  [...answers.comments, ...answers.commits],
3817
- (response) => isTrustedResponse(response, headInBaseRepo)
3818
- ).filter((closure) => couldMatch(closure.id, loadedFindings.findings));
4133
+ new Set(
4134
+ priorDialogue.entries.filter((entry) => entry.state !== "rebutted").map((entry) => entry.id)
4135
+ ),
4136
+ isTrusted
4137
+ );
4138
+ const unorderedRulings = allAnswers.filter(
4139
+ (answer) => RulingCodec.is(answer.disposition) && Number.isNaN(Date.parse(answer.created_at ?? ""))
4140
+ ).length;
4141
+ if (unorderedRulings > 0) {
4142
+ process.stderr.write(
4143
+ `Warning: ${String(unorderedRulings)} maintainer ruling(s) carry no readable time, so they cannot be ordered against the other answers \u2014 not applied
4144
+ `
4145
+ );
4146
+ }
4147
+ const allClosures = closingResponses(allAnswers, isTrusted);
4148
+ const dialogue = answerable && dialogueRound ? advanceDialogue(priorDialogue.entries, dialogueAnswers(allAnswers, isTrusted), [
4149
+ ...loadedFindings.findings.map((f) => ({
4150
+ id: resolveFindingId(f),
4151
+ title: f.title,
4152
+ severity: f.severity,
4153
+ rebutted: hasRebuttal(f)
4154
+ })),
4155
+ ...(loadedFindings.systemic_problems ?? []).flatMap((s) => {
4156
+ const id = resolveSystemicId(s);
4157
+ return id === void 0 ? [] : [{ id, title: s.title, severity: s.severity, rebutted: hasRebuttal(s) }];
4158
+ })
4159
+ ]) : priorDialogue.entries;
4160
+ const upheldIds = new Set(
4161
+ dialogue.filter((entry) => entry.state === "upheld").map((entry) => entry.id)
4162
+ );
4163
+ const closures = allClosures.filter(
4164
+ (closure) => couldMatch(closure.id, loadedFindings) && !upheldIds.has(closure.id)
4165
+ );
4166
+ const contested = dialogueRound ? dialogue.filter((entry) => entry.state === "contested") : [];
4167
+ const contestedIds = new Set(contested.map((entry) => entry.id));
4168
+ const isContestedFinding = findingIdIn(contestedIds);
4169
+ const isContestedSystemic = systemicIdIn(contestedIds);
3819
4170
  const priorForAnswers = closures.length > 0 && existingSticky !== null ? { value: await resolvePriorFindings(existingSticky.body, readArtifact) } : null;
3820
4171
  const answeredPrior = priorForAnswers === null ? null : resolveTolerantFindings(priorForAnswers.value);
3821
4172
  if (priorForAnswers !== null && answeredPrior === null) {
@@ -3825,37 +4176,101 @@ ${dropNote}` : ""}`,
3825
4176
  );
3826
4177
  }
3827
4178
  const answeredRegistry = answeredRegistryFrom(closures, answeredPrior);
3828
- if (answeredPrior !== null && answeredRegistry.length < closures.length) {
4179
+ const answeredSystemicRegistry = answeredSystemicRegistryFrom(closures, answeredPrior);
4180
+ const boundIds = new Set([...answeredRegistry, ...answeredSystemicRegistry].map((e) => e.code));
4181
+ const unboundClosureCount = answeredPrior === null ? 0 : closures.filter((closure) => !boundIds.has(closure.id)).length;
4182
+ if (unboundClosureCount > 0) {
3829
4183
  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
4184
+ `Warning: ${String(unboundClosureCount)} maintainer answer(s) name nothing the prior review reported \u2014 they close nothing this round
3831
4185
  `
3832
4186
  );
3833
4187
  }
3834
- const answeredFilter = applyAnswered(loadedFindings.findings, answeredRegistry);
3835
- const reRaisedNotes = answeredFilter.reRaisedNotes;
4188
+ const rulings = new Map(
4189
+ dialogue.filter((entry) => entry.state === "overruled").map((entry) => [
4190
+ entry.id,
4191
+ {
4192
+ code: entry.id,
4193
+ title: entry.title,
4194
+ answerUrl: entry.answer,
4195
+ answerAuthor: allAnswers.find((answer) => answer.source_url === entry.answer)?.author ?? null
4196
+ }
4197
+ ])
4198
+ );
4199
+ const overruledFilter = applyOverruled(
4200
+ loadedFindings.findings.filter((f) => !isContestedFinding(f)),
4201
+ rulings,
4202
+ resolveFindingId,
4203
+ answeredNoteKey
4204
+ );
4205
+ const overruledSystemicFilter = applyOverruled(
4206
+ (loadedFindings.systemic_problems ?? []).filter((s) => !isContestedSystemic(s)),
4207
+ rulings,
4208
+ resolveSystemicId,
4209
+ answeredSystemicNoteKey
4210
+ );
4211
+ const answeredFilter = applyAnswered(overruledFilter.kept, answeredRegistry);
4212
+ const answeredSystemicFilter = applyAnsweredSystemic(
4213
+ overruledSystemicFilter.kept,
4214
+ answeredSystemicRegistry
4215
+ );
4216
+ const contestedFindings = loadedFindings.findings.filter(isContestedFinding);
4217
+ const contestedSystemic = (loadedFindings.systemic_problems ?? []).filter(isContestedSystemic);
4218
+ const contestedReRaises = new Map([
4219
+ ...contestedSystemic.map((s) => [resolveSystemicId(s) ?? "", s]),
4220
+ ...contestedFindings.map((f) => [resolveFindingId(f), f])
4221
+ ]);
4222
+ const contestedViews = contested.map((entry) => {
4223
+ const reRaise = contestedReRaises.get(entry.id);
4224
+ return {
4225
+ id: entry.id,
4226
+ title: entry.title,
4227
+ severity: entry.severity,
4228
+ answerUrl: entry.answer,
4229
+ ...reRaise === void 0 ? {} : {
4230
+ reRaise: {
4231
+ ..."path" in reRaise ? {
4232
+ location: `${reRaise.path}:${lineRange(reRaise.start_line, reRaise.end_line, "\u2013")}`
4233
+ } : {},
4234
+ description: reRaise.description,
4235
+ reasoning: reRaise.reasoning,
4236
+ ...hasRebuttal(reRaise) ? { rebuttal: reRaise.rebuttal ?? "" } : {}
4237
+ }
4238
+ }
4239
+ };
4240
+ });
4241
+ const reRaisedNotes = {
4242
+ ...answeredFilter.reRaisedNotes,
4243
+ ...answeredSystemicFilter.reRaisedNotes,
4244
+ ...overruledFilter.reRaisedNotes,
4245
+ ...overruledSystemicFilter.reRaisedNotes
4246
+ };
3836
4247
  const verbatimReRaised = answeredFilter.verbatimReRaised;
3837
- const droppedCount = answeredFilter.droppedCount;
4248
+ const answeredDropCount = answeredFilter.droppedCount + answeredSystemicFilter.droppedCount;
4249
+ const overruledDropCount = overruledFilter.droppedCount + overruledSystemicFilter.droppedCount;
4250
+ const droppedCount = answeredDropCount + overruledDropCount;
3838
4251
  const droppedIds = /* @__PURE__ */ new Set([
3839
- ...verbatimReRaised.flatMap((e) => e.code !== "" ? [e.code] : []),
4252
+ ...[...verbatimReRaised, ...overruledFilter.verbatimReRaised].flatMap(
4253
+ (e) => e.code !== "" ? [e.code] : []
4254
+ ),
3840
4255
  ...answeredFilter.droppedFindingIds.filter((id) => id !== "")
3841
4256
  ]);
3842
- const keptCodes = new Set(answeredFilter.findings.map((f) => f.id));
4257
+ const keptCodes = new Set([...answeredFilter.findings, ...contestedFindings].map((f) => f.id));
3843
4258
  const trulyDropped = new Set([...droppedIds].filter((c) => !keptCodes.has(c)));
3844
- const systemic = trulyDropped.size === 0 ? loadedFindings.systemic_problems ?? [] : (loadedFindings.systemic_problems ?? []).map((s) => {
4259
+ const systemic = trulyDropped.size === 0 ? answeredSystemicFilter.systemic : answeredSystemicFilter.systemic.map((s) => {
3845
4260
  if (s.finding_ids === void 0) return s;
3846
4261
  const codes = s.finding_ids.filter((c) => !trulyDropped.has(c));
3847
4262
  return codes.length === s.finding_ids.length ? s : { ...s, finding_ids: codes };
3848
4263
  });
3849
4264
  const findings = {
3850
- ...loadedFindings,
3851
- findings: [...answeredFilter.findings],
3852
- ...systemic.length > 0 ? { systemic_problems: systemic } : {}
4265
+ ...withSystemicProblems(loadedFindings, [...systemic, ...contestedSystemic]),
4266
+ findings: [...answeredFilter.findings, ...contestedFindings]
3853
4267
  };
3854
4268
  const broadCurrentIds = [
3855
4269
  ...findings.findings.map((f) => f.id).filter((id) => id !== ""),
3856
4270
  ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
3857
4271
  ];
3858
- const roundHasNit = findings.findings.some((f) => f.severity === "nit");
4272
+ const argued = findings.findings.filter((f) => !isContestedFinding(f));
4273
+ const roundHasNit = argued.some((f) => f.severity === "nit");
3859
4274
  const nitWantsPrior = roundHasNit && existingSticky !== null && (isFullReviewSticky(existingSticky.body) || isFullReviewAncestry(existingSticky.body));
3860
4275
  const broadWantsPrior = existingSticky !== null && !priorIsMechanic && mentionsOutsideKnown(reachable, broadCurrentIds);
3861
4276
  const resolvedPrior = priorForAnswers !== null ? priorForAnswers.value : existingSticky !== null && (nitWantsPrior || broadWantsPrior) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
@@ -3869,15 +4284,25 @@ ${dropNote}` : ""}`,
3869
4284
  // synthesized prior), the answeredNoteKey form (an empty-id prior), and the bare title: key
3870
4285
  // (a pathless codeless prior).
3871
4286
  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._" : "");
4287
+ const suppressedNits = argued.filter(isSuppressedNit);
4288
+ const visibleFindings = argued.filter((f) => !isSuppressedNit(f));
4289
+ const answeredDropNote = [
4290
+ answeredReRaiseNote(
4291
+ [...verbatimReRaised, ...answeredSystemicFilter.verbatimReRaised],
4292
+ answeredDropCount
4293
+ ),
4294
+ overruledReRaiseNote(
4295
+ [...overruledFilter.verbatimReRaised, ...overruledSystemicFilter.verbatimReRaised],
4296
+ overruledDropCount
4297
+ )
4298
+ ].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
4299
  const inlinePartition = input.inline === true && envelope !== null ? partitionFindings(visibleFindings, indexDiff(diff)) : null;
3876
4300
  const straysForDiscussion = inlinePartition?.strays ?? visibleFindings;
3877
4301
  const currentIds = [
3878
4302
  ...straysForDiscussion.map((f) => f.id).filter((id) => id !== ""),
3879
4303
  ...suppressedNits.map((f) => f.id).filter((id) => id !== ""),
3880
- ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
4304
+ ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== ""),
4305
+ ...contested.map((entry) => entry.id)
3881
4306
  ];
3882
4307
  const orphanResolveFailed = existingSticky !== null && broadWantsPrior && carriedFindingsMarker(existingSticky.body) !== null && resolvedPrior === null;
3883
4308
  const broadLive = new Set(broadCurrentIds);
@@ -3927,6 +4352,7 @@ ${findingsPointer2}`
3927
4352
  ] : [],
3928
4353
  "",
3929
4354
  terminalNotice({
4355
+ unfinished: [],
3930
4356
  reviewedSha: input.headSha,
3931
4357
  verdictBadge: verdictBadge(loadedFindings.verdict),
3932
4358
  route: route ?? void 0,
@@ -3974,6 +4400,7 @@ ${note}
3974
4400
  nitVisibilityFloor: input.nitVisibilityFloor,
3975
4401
  answeredNotes: reRaisedNotes,
3976
4402
  answeredReRaiseNote: answeredDropNote,
4403
+ contested: contestedViews,
3977
4404
  discussionByFinding,
3978
4405
  orphanedDiscussion,
3979
4406
  orphanedTotal,
@@ -4094,7 +4521,8 @@ ${note}
4094
4521
  priorTraj,
4095
4522
  roundNumber,
4096
4523
  currentCodes,
4097
- input.headSha.slice(0, 12)
4524
+ input.headSha.slice(0, 12),
4525
+ contested
4098
4526
  ) : effectiveRoute === "mechanic" ? (
4099
4527
  // A mechanic pass means CI failed — whatever the carried prior says, its own stamp must
4100
4528
  // never read "converged". The threshold-relative critical floor keeps the carried triple
@@ -4103,10 +4531,13 @@ ${note}
4103
4531
  ) : priorConv;
4104
4532
  const stampedFindings = stampConvergence(findings, convergence);
4105
4533
  const currentRoundCount = isRound ? roundNumber : priorRoundCount;
4106
- const findingsMarker = findingsBlob(stampedFindings);
4534
+ const findingsMarker = findingsBlob(
4535
+ stampedFindings,
4536
+ answerable && dialogueRound ? dialogueMarker(dialogue, priorDialogue.undecoded) : carriedDialogue
4537
+ );
4107
4538
  const commonRenderInput = {
4108
4539
  ...sharedRenderInput,
4109
- findings: stampedFindings,
4540
+ findings: withoutIds(stampedFindings, contestedIds),
4110
4541
  envelope,
4111
4542
  incomplete: thisIncomplete,
4112
4543
  sameRootNotes,
@@ -4314,6 +4745,24 @@ ${note}
4314
4745
  finalDiscussionOverride
4315
4746
  )
4316
4747
  );
4748
+ if (answerable && dialogueRound) {
4749
+ appendRunSummary(process.env["GITHUB_STEP_SUMMARY"], () => {
4750
+ const trustedAnswerIds = new Set(allAnswers.filter(isTrusted).map((answer) => answer.id));
4751
+ return dialogueMetricsTable({
4752
+ commentAnswers: answers.comments.length,
4753
+ commitAnswers: answers.commits.length,
4754
+ closures: allClosures.length,
4755
+ droppedAnswered: answeredDropCount,
4756
+ droppedOverruled: overruledDropCount,
4757
+ annotated: Object.keys(reRaisedNotes).length,
4758
+ entries: dialogue,
4759
+ systemicUnanswered: (findings.systemic_problems ?? []).filter((s) => {
4760
+ const id = resolveSystemicId(s);
4761
+ return id === void 0 || !trustedAnswerIds.has(id);
4762
+ }).length
4763
+ });
4764
+ });
4765
+ }
4317
4766
  };
4318
4767
  var noticeBody = (lead, existingBody) => {
4319
4768
  const carried = existingBody ? carryForwardMarkers(existingBody) : "";
@@ -4999,15 +5448,29 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
4999
5448
  join(input.outDir, "prior_findings.json"),
5000
5449
  priorFindings === null ? "null" : JSON.stringify(priorFindings)
5001
5450
  );
5451
+ const held = (seedsFromPrior ? parseDialogueMarker(prior.body).entries : []).filter(
5452
+ (entry) => entry.state !== "rebutted"
5453
+ );
5002
5454
  const harvest = seedsFromPrior ? harvestResponses({
5003
5455
  repo: input.repo,
5004
5456
  prNumber,
5005
5457
  botLogin: input.botLogin,
5006
- priorIds: new Set(priorIdsFrom(priorFindings)),
5458
+ priorIds: /* @__PURE__ */ new Set([...priorIdsFrom(priorFindings), ...held.map((entry) => entry.id)]),
5007
5459
  commits: commits.map((commit) => ({ ...commit, author: commit.login ?? null })),
5008
5460
  comments: issueComments ?? []
5009
5461
  }) : { file: { responses: [], unmatched: [] }, dropped: 0 };
5010
- writeFileSync(join(input.outDir, "responses.json"), JSON.stringify(harvest.file));
5462
+ writeFileSync(
5463
+ join(input.outDir, "responses.json"),
5464
+ JSON.stringify({
5465
+ ...harvest.file,
5466
+ ...Object.fromEntries(
5467
+ ["contested", "upheld", "overruled"].flatMap((state) => {
5468
+ const claims = held.filter((entry) => entry.state === state).map(({ id, title, severity, at }) => ({ id, title, severity, since: at }));
5469
+ return claims.length > 0 ? [[state, claims]] : [];
5470
+ })
5471
+ )
5472
+ })
5473
+ );
5011
5474
  if (seedsFromPrior && priorFindings === null) {
5012
5475
  process.stderr.write(
5013
5476
  "Note: the prior review's findings did not resolve \u2014 every id-shaped answer is staged as unmatched\n"
@@ -5382,7 +5845,7 @@ var parseScope = (raw) => {
5382
5845
  reason: 'scope contains a character (newline, carriage return, backtick, "<", ">", or "|") that would corrupt the review prompt \u2014 use plain language names/tags'
5383
5846
  };
5384
5847
  }
5385
- const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t9) => t9 !== "")));
5848
+ const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t10) => t10 !== "")));
5386
5849
  return languages.length === 0 ? { kind: "absent" } : { kind: "ok", languages };
5387
5850
  };
5388
5851
 
@@ -6205,11 +6668,6 @@ var seedDraftCmd = defineCommand({
6205
6668
  const seededFromPrior = priorFindings === null ? false : (() => {
6206
6669
  try {
6207
6670
  const schemaPath = args.schema ? resolve(args.schema) : schemaPathFor(kind, args["schema-version"]);
6208
- const barePrior = typeof priorFindings === "object" && !Array.isArray(priorFindings) ? Object.fromEntries(
6209
- Object.entries(priorFindings).filter(
6210
- ([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2)
6211
- )
6212
- ) : priorFindings;
6213
6671
  const accepts = (doc) => {
6214
6672
  const resolved = resolvedPriorValue(doc);
6215
6673
  if (resolved === null) return null;
@@ -6217,13 +6675,18 @@ var seedDraftCmd = defineCommand({
6217
6675
  return validateAgainstSchema(carried, schemaPath).valid ? carried : null;
6218
6676
  };
6219
6677
  const seedDoc = accepts(priorFindings) ?? (() => {
6220
- const bare = accepts(barePrior);
6221
- if (bare === null) return null;
6678
+ if (typeof priorFindings !== "object" || Array.isArray(priorFindings))
6679
+ return null;
6680
+ const recovered = withoutBrokenOptionalFields(
6681
+ priorFindings,
6682
+ (candidate) => accepts(candidate) !== null
6683
+ );
6684
+ if (recovered === null) return null;
6222
6685
  process.stderr.write(
6223
- `Note: the in-force schema rejects a carried recoverable field (scope_metastasis/convergence/change_size) \u2014 seeding the prior without it (issue #150 review r2 / #182 review r2)
6686
+ `Note: the in-force schema rejects a carried recoverable field (${recovered.stripped.join("/")}) \u2014 seeding the prior without it (issue #150 review r2 / #182 review r2)
6224
6687
  `
6225
6688
  );
6226
- return bare;
6689
+ return accepts(recovered.doc);
6227
6690
  })();
6228
6691
  if (seedDoc === null) return false;
6229
6692
  if (isIncompleteFindings(seedDoc)) return false;
@@ -6754,6 +7217,10 @@ var postCmd = defineCommand({
6754
7217
  "unverified-no-logs": {
6755
7218
  type: "boolean",
6756
7219
  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"
7220
+ },
7221
+ "without-dialogue": {
7222
+ type: "boolean",
7223
+ 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
7224
  }
6758
7225
  },
6759
7226
  run: async ({ args }) => {
@@ -6788,6 +7255,7 @@ var postCmd = defineCommand({
6788
7255
  nitVisibilityFloor: parseNitVisibilityFloor(args["nit-visibility-floor"]),
6789
7256
  inline: args.inline,
6790
7257
  unverifiedNoLogs: args["unverified-no-logs"],
7258
+ withoutDialogue: args["without-dialogue"],
6791
7259
  postedAt: formatUtc(/* @__PURE__ */ new Date()),
6792
7260
  pricedAt: /* @__PURE__ */ new Date()
6793
7261
  });