@jphutchins/code-review 0.1.0-alpha.40 → 0.1.0-alpha.41

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
@@ -93,6 +93,36 @@ var SystemicProblemStrict = t.refinement(
93
93
  "SystemicProblemStrict"
94
94
  );
95
95
  var SystemicProblemCodec = t.exact(SystemicProblemStrict);
96
+ var RecurringShape = t.type({
97
+ code: t.string,
98
+ consecutive_rounds: t.refinement(
99
+ t.number,
100
+ (n) => Number.isSafeInteger(n) && n >= 1,
101
+ "ConsecutiveRounds"
102
+ ),
103
+ start_round: t.refinement(
104
+ t.number,
105
+ (n) => Number.isSafeInteger(n) && n >= 1,
106
+ "StartRound"
107
+ )
108
+ });
109
+ var RECURRING_KEYS = new Set(Object.keys(RecurringShape.props));
110
+ var RecurringCodec = t.refinement(
111
+ RecurringShape,
112
+ (r) => Object.keys(r).every((k) => RECURRING_KEYS.has(k)),
113
+ "RecurringStrict"
114
+ );
115
+ var ScopeMetastasisShape = t.type({
116
+ decision_prompt: t.string,
117
+ recurring: t.array(RecurringCodec)
118
+ });
119
+ var SCOPE_METASTASIS_KEYS = new Set(Object.keys(ScopeMetastasisShape.props));
120
+ var ScopeMetastasisStrict = t.refinement(
121
+ ScopeMetastasisShape,
122
+ (s) => Object.keys(s).every((k) => SCOPE_METASTASIS_KEYS.has(k)),
123
+ "ScopeMetastasisStrict"
124
+ );
125
+ var ScopeMetastasisCodec = t.exact(ScopeMetastasisStrict);
96
126
  var FindingsCodec = t.exact(
97
127
  t.intersection([
98
128
  t.type({
@@ -102,7 +132,9 @@ var FindingsCodec = t.exact(
102
132
  findings: t.array(FindingCodec)
103
133
  }),
104
134
  t.partial({
105
- systemic_problems: t.array(SystemicProblemCodec)
135
+ systemic_problems: t.array(SystemicProblemCodec),
136
+ // Pipeline-stamped only (issue #150); see ScopeMetastasisCodec.
137
+ scope_metastasis: ScopeMetastasisCodec
106
138
  })
107
139
  ])
108
140
  );
@@ -338,7 +370,7 @@ var severityEmoji = (s) => {
338
370
  return "\u2753";
339
371
  }
340
372
  };
341
- var EMBED_LIMIT = 40200;
373
+ var EMBED_LIMIT = 42700;
342
374
  var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
343
375
  var b64LengthOf = (document) => Buffer.from(JSON.stringify(document), "utf-8").toString("base64").length;
344
376
  var encodeMarker = (document, jsonUrl, limit) => {
@@ -417,17 +449,21 @@ var parseRounds = (body) => {
417
449
  if (b64 === void 0) return [];
418
450
  const decoded = decodeBase64Json(b64);
419
451
  if (!Array.isArray(decoded)) return [];
420
- return decoded.filter(isSeverityCounts).map((u) => {
452
+ const kept = [];
453
+ let priorCodes;
454
+ for (const u of decoded.filter(isSeverityCounts)) {
421
455
  const rec = u;
422
- const codes = normalizeCodeCounts(rec["codes"]);
456
+ const codes = normalizeCodeCounts(rec["codes"], priorCodes);
457
+ priorCodes = codes;
423
458
  const sha = rec["sha"];
424
459
  const shaStr = typeof sha === "string" && sha !== "" ? sha : void 0;
425
460
  const round = rec["round"];
426
461
  const roundNum = typeof round === "number" && Number.isSafeInteger(round) && round >= 1 ? round : void 0;
427
462
  const base = codes === void 0 ? { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit } : { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit, codes };
428
- const kept = shaStr === void 0 ? base : { ...base, sha: shaStr };
429
- return roundNum === void 0 ? kept : { ...kept, round: roundNum };
430
- });
463
+ const record3 = shaStr === void 0 ? base : { ...base, sha: shaStr };
464
+ kept.push(roundNum === void 0 ? record3 : { ...record3, round: roundNum });
465
+ }
466
+ return kept;
431
467
  };
432
468
  var ROUNDS_MARKER_LIMIT = 8e3;
433
469
  var roundsMarker = (rounds) => {
@@ -499,11 +535,13 @@ var consecutiveCodeStreaks = (rounds) => {
499
535
  return Object.fromEntries(entries);
500
536
  };
501
537
  var DEFAULT_METASTASIS_STREAK = 3;
538
+ var SCOPE_METASTASIS_DECISION_PROMPT = "Findings keep recurring in the same mechanism across consecutive rounds \u2014 each fix keeps enabling the next finding in that machinery. This is a decision, not a directive: state in your summary whether you are committing to the expanding scope (plan the remaining facets of the recurring mechanism(s) above as one unit) or narrowing the scope so the recurrence stops.";
539
+ var flaggedCodeStreaks = (rounds, minStreak) => Object.entries(consecutiveCodeStreaks(rounds)).filter(([, s]) => s.streak >= minStreak).sort((a, b) => b[1].streak - a[1].streak).map(([code, streak]) => ({ code, streak }));
502
540
  var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
503
- const flagged = Object.entries(consecutiveCodeStreaks(rounds)).filter(([, s]) => s.streak >= minStreak).sort((a, b) => b[1].streak - a[1].streak);
541
+ const flagged = flaggedCodeStreaks(rounds, minStreak);
504
542
  if (flagged.length === 0) return "";
505
543
  const lines = flagged.map(
506
- ([code, s]) => `> **\`${escapeCodeBackticks(code)}\`** \u2014 findings in ${String(s.streak)} consecutive rounds.`
544
+ ({ code, streak }) => `> **\`${escapeCodeBackticks(code)}\`** \u2014 findings in ${String(streak.streak)} consecutive rounds.`
507
545
  );
508
546
  return [
509
547
  "> [!WARNING]",
@@ -511,6 +549,18 @@ var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
511
549
  ...lines
512
550
  ].join("\n");
513
551
  };
552
+ var computeScopeMetastasis = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
553
+ const flagged = flaggedCodeStreaks(rounds, minStreak);
554
+ if (flagged.length === 0) return null;
555
+ return {
556
+ decision_prompt: SCOPE_METASTASIS_DECISION_PROMPT,
557
+ recurring: flagged.map(({ code, streak }) => ({
558
+ code,
559
+ consecutive_rounds: streak.streak,
560
+ start_round: streak.startRound
561
+ }))
562
+ };
563
+ };
514
564
  var computeSameRootNotes = (priorRounds, findings, currentSha) => {
515
565
  const codes = findings.map((f) => f.code).filter((c) => c !== void 0 && c !== "");
516
566
  const entries = [];
@@ -542,28 +592,31 @@ var convergenceSummary = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) =>
542
592
  const { score, converged } = convergenceSignal(counts, threshold);
543
593
  return converged ? `**Convergence** \u{1F3C1} ${String(score)} \u2264 ${String(threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${String(score)} > ${String(threshold)} \u2014 iterating`;
544
594
  };
545
- var SURFACE_SCHEMA_VERSION = "0.7.0";
595
+ var SURFACE_SCHEMA_VERSION = "0.8.0";
546
596
  var convergenceSignal = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
547
597
  const score = convergenceScore(counts);
548
598
  return { score, threshold, converged: score <= threshold };
549
599
  };
550
600
  var signalForRound = (round, counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => ({ round, convergence: convergenceSignal(counts, threshold) });
551
- var surfaceFindings = (findings, signal) => {
601
+ var surfaceFindings = (findings, signal, scopeMetastasis = null) => {
552
602
  const agentDoc = Object.fromEntries(
553
- Object.entries(findings).filter(([key2]) => key2 !== "round" && key2 !== "convergence")
603
+ Object.entries(findings).filter(
604
+ ([key2]) => key2 !== "round" && key2 !== "convergence" && key2 !== "scope_metastasis"
605
+ )
554
606
  );
555
607
  return {
556
608
  ...agentDoc,
557
609
  schema_version: SURFACE_SCHEMA_VERSION,
558
- ...signal === null ? {} : signal
610
+ ...signal === null ? {} : signal,
611
+ ...scopeMetastasis === null ? {} : { scope_metastasis: scopeMetastasis }
559
612
  };
560
613
  };
561
614
  var signalMarker = (signal) => `<!-- code-review:signal;base64 ${Buffer.from(
562
615
  JSON.stringify({ schema_version: SURFACE_SCHEMA_VERSION, ...signal }),
563
616
  "utf-8"
564
617
  ).toString("base64")} -->`;
565
- var surfacedFindingsPointer = (findings, signal, jsonUrl) => {
566
- const marker = findingsPointer(surfaceFindings(findings, signal), jsonUrl);
618
+ var surfacedFindingsPointer = (findings, signal, jsonUrl, scopeMetastasis = null) => {
619
+ const marker = findingsPointer(surfaceFindings(findings, signal, scopeMetastasis), jsonUrl);
567
620
  if (signal === null || marker.includes("<!-- code-review:findings-json;base64 ")) return marker;
568
621
  return marker === "" ? signalMarker(signal) : `${marker}
569
622
  ${signalMarker(signal)}`;
@@ -592,7 +645,7 @@ var parseSurfaceSignal = (doc) => {
592
645
  convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
593
646
  };
594
647
  };
595
- var SURFACE_SCHEMA_VERSIONS = [SURFACE_SCHEMA_VERSION];
648
+ var SURFACE_SCHEMA_VERSIONS = ["0.7.0", SURFACE_SCHEMA_VERSION];
596
649
  var stripSurfaceFields = (doc) => {
597
650
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return doc;
598
651
  const o = doc;
@@ -628,15 +681,346 @@ var reviewBodyPointer = (headSha, stickyUrl, marker) => {
628
681
 
629
682
  ${linkLine}` : linkLine;
630
683
  };
684
+ var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
685
+ var errMsg = (e) => e instanceof Error ? e.message : String(e);
686
+ var annotationSafe = (msg) => msg.replaceAll(/[\r\n]+/g, " ");
687
+ var clipText = (body, max) => {
688
+ if (body.length <= max) return body;
689
+ const cut = body.slice(0, max);
690
+ const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
691
+ return `${safe}
692
+ \u2026 [truncated]`;
693
+ };
694
+ var tryParseJson = (text) => {
695
+ try {
696
+ return { ok: true, value: JSON.parse(text) };
697
+ } catch {
698
+ return { ok: false };
699
+ }
700
+ };
701
+ var readFileOrNull = (path) => {
702
+ try {
703
+ return readFileSync(path, "utf-8");
704
+ } catch {
705
+ return null;
706
+ }
707
+ };
708
+
709
+ // src/transcript.ts
710
+ var numField = (rec, key2) => {
711
+ const v = rec[key2];
712
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
713
+ };
714
+ var messageUsage = (entry) => {
715
+ const rec = asRecord(entry);
716
+ if (rec === null || rec["type"] !== "assistant") return null;
717
+ const msg = asRecord(rec["message"]);
718
+ if (msg === null) return null;
719
+ const model = msg["model"];
720
+ const usage = asRecord(msg["usage"]);
721
+ if (typeof model !== "string" || usage === null) return null;
722
+ const id = msg["id"];
723
+ return {
724
+ id: typeof id === "string" ? id : null,
725
+ model,
726
+ input: numField(usage, "input_tokens"),
727
+ output: numField(usage, "output_tokens"),
728
+ cacheRead: numField(usage, "cache_read_input_tokens"),
729
+ cacheWrite: numField(usage, "cache_creation_input_tokens")
730
+ };
731
+ };
732
+ var tsMsOf = (entry) => {
733
+ const rec = asRecord(entry);
734
+ const ts = rec?.["timestamp"];
735
+ if (typeof ts !== "string") return null;
736
+ const ms = Date.parse(ts);
737
+ return Number.isNaN(ms) ? null : ms;
738
+ };
739
+ var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
740
+ var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
741
+ try {
742
+ return [JSON.parse(line)];
743
+ } catch {
744
+ return [];
745
+ }
746
+ });
747
+ var sumTranscriptUsage = (entries) => {
748
+ const summed = entries.reduce(
749
+ (acc, entry) => {
750
+ const u = messageUsage(entry);
751
+ if (u === null) return acc;
752
+ if (u.id !== null && acc.seen.has(u.id)) return acc;
753
+ if (u.id !== null) acc.seen.add(u.id);
754
+ const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
755
+ acc.totals.set(u.model, {
756
+ input: prev.input + u.input,
757
+ output: prev.output + u.output,
758
+ cacheRead: prev.cacheRead + u.cacheRead,
759
+ cacheWrite: prev.cacheWrite + u.cacheWrite
760
+ });
761
+ return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
762
+ },
763
+ { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
764
+ );
765
+ const models = [...summed.totals].map(([model, t8]) => ({
766
+ model,
767
+ input_tokens: t8.input,
768
+ output_tokens: t8.output,
769
+ cache_read_tokens: t8.cacheRead,
770
+ cache_write_tokens: t8.cacheWrite
771
+ }));
772
+ const bounds = entries.reduce(
773
+ (acc, entry) => {
774
+ const ms = tsMsOf(entry);
775
+ if (ms === null) return acc;
776
+ return {
777
+ min: acc.min === null || ms < acc.min ? ms : acc.min,
778
+ max: acc.max === null || ms > acc.max ? ms : acc.max
779
+ };
780
+ },
781
+ { min: null, max: null }
782
+ );
783
+ return {
784
+ models,
785
+ turns: summed.turns,
786
+ durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
787
+ firstTsMs: bounds.min,
788
+ lastTsMs: bounds.max
789
+ };
790
+ };
791
+ var subagentFiles = (mainPath) => {
792
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
793
+ try {
794
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
795
+ } catch {
796
+ return [];
797
+ }
798
+ };
799
+ var readTranscriptTree = (mainPath) => {
800
+ const mainText = readFileOrNull(mainPath);
801
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
802
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
803
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
804
+ const siblingReads = siblings.flatMap((path) => {
805
+ const text = readFileOrNull(path);
806
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
807
+ });
808
+ return {
809
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
810
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
811
+ missing: mainText === null
812
+ };
813
+ };
814
+
815
+ // src/answered.ts
816
+ var THREAD_COMMENT_JQ = ".[] | {id, in_reply_to_id, user: {login: .user.login}, user_login: .user.login, user_type: .user.type, body, html_url, path, line, created_at, author_association}";
817
+ var ThreadCommentCodec = t.type({
818
+ id: t.number,
819
+ in_reply_to_id: t.union([t.number, t.null]),
820
+ user_login: t.string,
821
+ user_type: t.union([t.string, t.null]),
822
+ body: t.union([t.string, t.null]),
823
+ html_url: t.string,
824
+ path: t.union([t.string, t.null]),
825
+ line: t.union([t.number, t.null]),
826
+ created_at: t.union([t.string, t.null])
827
+ });
828
+ var AnsweredEntryCodec = t.type({
829
+ code: t.string,
830
+ title: t.string,
831
+ description: t.string,
832
+ reasoning: t.string,
833
+ severity: t.union([
834
+ t.literal("critical"),
835
+ t.literal("major"),
836
+ t.literal("minor"),
837
+ t.literal("nit")
838
+ ]),
839
+ path: t.string,
840
+ patch: t.union([t.string, t.null]),
841
+ replied_at: t.union([t.string, t.null]),
842
+ reply_id: t.number,
843
+ thread_url: t.string,
844
+ reply_url: t.string,
845
+ reply_author: t.string,
846
+ reply_excerpt: t.string
847
+ });
848
+ var encodeAnsweredEntry = (e) => ({
849
+ code: e.code,
850
+ title: e.title,
851
+ description: e.description,
852
+ reasoning: e.reasoning,
853
+ severity: e.severity,
854
+ path: e.path,
855
+ patch: e.patch,
856
+ replied_at: e.repliedAt,
857
+ reply_id: e.replyId,
858
+ thread_url: e.threadUrl,
859
+ reply_url: e.replyUrl,
860
+ reply_author: e.replyAuthor,
861
+ reply_excerpt: e.replyExcerpt
862
+ });
863
+ var decodeAnsweredEntry = (raw) => {
864
+ const decoded = AnsweredEntryCodec.decode(raw);
865
+ return decoded._tag === "Right" ? decoded.right : null;
866
+ };
867
+ var isHuman = (login, type8, botLogin) => login !== botLogin && type8 === "User";
868
+ var EXCERPT_LIMIT = 400;
869
+ var answeredRegistryFrom = (comments, botLogin) => {
870
+ const ordered = [...comments].sort(
871
+ (a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)
872
+ );
873
+ const byId = /* @__PURE__ */ new Map();
874
+ for (const c of ordered) byId.set(c.id, c);
875
+ const rootOf = (c) => {
876
+ let current = c;
877
+ const seen = /* @__PURE__ */ new Set();
878
+ while (current.in_reply_to_id !== null && !seen.has(current.id)) {
879
+ seen.add(current.id);
880
+ const parent = byId.get(current.in_reply_to_id);
881
+ if (parent === void 0) return null;
882
+ current = parent;
883
+ }
884
+ return current.in_reply_to_id === null ? current : null;
885
+ };
886
+ const findingOf = (root) => {
887
+ const decoded = parseFindingsMarker(root.body ?? "");
888
+ const doc = typeof decoded === "object" && decoded !== null ? decoded.findings : void 0;
889
+ const first = Array.isArray(doc) ? doc[0] : void 0;
890
+ if (first === void 0) return null;
891
+ const title = first["title"];
892
+ const description = first["description"];
893
+ const reasoning = first["reasoning"];
894
+ const code = first["code"];
895
+ const severity = first["severity"];
896
+ const path = first["path"];
897
+ const patch = first["patch"];
898
+ return typeof title === "string" && typeof description === "string" && typeof reasoning === "string" && typeof path === "string" && (severity === "critical" || severity === "major" || severity === "minor" || severity === "nit") ? {
899
+ code: typeof code === "string" ? code : "",
900
+ title,
901
+ description,
902
+ reasoning,
903
+ severity,
904
+ path,
905
+ patch: typeof patch === "string" ? patch : null
906
+ } : null;
907
+ };
908
+ const threads = /* @__PURE__ */ new Map();
909
+ for (const c of ordered) {
910
+ const root = rootOf(c);
911
+ if (root === null) continue;
912
+ const group = threads.get(root.id);
913
+ if (group === void 0) threads.set(root.id, [c]);
914
+ else group.push(c);
915
+ }
916
+ const entries = [];
917
+ for (const [rootId, group] of threads) {
918
+ const root = byId.get(rootId);
919
+ if (root === void 0 || root.user_login !== botLogin) continue;
920
+ const finding = findingOf(root);
921
+ if (finding === null) continue;
922
+ const humanReplies = group.filter(
923
+ (c) => c.id !== root.id && isHuman(c.user_login, c.user_type, botLogin)
924
+ );
925
+ const reply = humanReplies[humanReplies.length - 1];
926
+ if (reply === void 0) continue;
927
+ entries.push({
928
+ ...finding,
929
+ repliedAt: reply.created_at,
930
+ replyId: reply.id,
931
+ threadUrl: root.html_url,
932
+ replyUrl: reply.html_url,
933
+ replyAuthor: reply.user_login,
934
+ replyExcerpt: clipText(reply.body ?? "", EXCERPT_LIMIT)
935
+ });
936
+ }
937
+ const byKey = /* @__PURE__ */ new Map();
938
+ for (const entry of [...entries].sort(
939
+ (a, b) => (b.repliedAt ?? "").localeCompare(a.repliedAt ?? "") || b.replyId - a.replyId
940
+ )) {
941
+ const key2 = answeredNoteKey(entry);
942
+ if (!byKey.has(key2)) byKey.set(key2, entry);
943
+ }
944
+ return [...byKey.values()];
945
+ };
946
+ var matches = (f, e) => f.code !== void 0 && f.code !== "" ? e.code === f.code : e.code === "" && e.title === f.title;
947
+ var answeredNoteKey = (f) => f.code !== void 0 && f.code !== "" ? f.code : `title:${f.title}`;
948
+ var answeredNote = (e) => `Re-raised; prior answer at ${e.replyUrl} by ${e.replyAuthor} \u2014 cite the new evidence that invalidates it.`;
949
+ var applyAnswered = (findings, registry) => {
950
+ const kept = [];
951
+ const noteEntries = [];
952
+ const droppedByKey = /* @__PURE__ */ new Map();
953
+ let droppedCount = 0;
954
+ for (const f of findings) {
955
+ const entry = registry.find((e) => matches(f, e));
956
+ if (entry === void 0) {
957
+ kept.push(f);
958
+ continue;
959
+ }
960
+ const verbatim = f.title === entry.title && f.description === entry.description && f.reasoning === entry.reasoning && f.severity === entry.severity && f.path === entry.path && (f.patch ?? null) === entry.patch;
961
+ if (verbatim && f.severity !== "critical") {
962
+ droppedByKey.set(answeredNoteKey(f), entry);
963
+ droppedCount += 1;
964
+ } else {
965
+ kept.push(f);
966
+ noteEntries.push([answeredNoteKey(f), answeredNote(entry)]);
967
+ }
968
+ }
969
+ return {
970
+ findings: kept,
971
+ reRaisedNotes: Object.fromEntries(noteEntries),
972
+ verbatimReRaised: [...droppedByKey.values()],
973
+ droppedCount
974
+ };
975
+ };
976
+ var answeredReRaiseNote = (entries, count) => {
977
+ if (entries.length === 0) return "";
978
+ const label = (e) => e.code !== "" ? `\`${escapeCodeBackticks(e.code)}\`` : `\u201C${escapeCodeBackticks(e.title)}\u201D`;
979
+ const lines = entries.map(
980
+ (e) => `> - ${label(e)} \u2014 [prior answer](${e.replyUrl}) by ${e.replyAuthor}`
981
+ );
982
+ return [
983
+ `> \u21A9\uFE0F **${String(count)} finding(s) re-raised without new evidence \u2014 treated as answered** (each has a human reply on its prior inline thread):`,
984
+ ...lines
985
+ ].join("\n");
986
+ };
987
+ var fetchThreadComments = async (ghApi, repo, prNumber) => {
988
+ try {
989
+ const rows = parseJsonl(
990
+ await ghApi([
991
+ `repos/${repo}/pulls/${String(prNumber)}/comments`,
992
+ "-f",
993
+ "per_page=100",
994
+ "--paginate",
995
+ "--jq",
996
+ THREAD_COMMENT_JQ
997
+ ])
998
+ );
999
+ return rows.flatMap((row) => {
1000
+ const decoded = ThreadCommentCodec.decode(row);
1001
+ return decoded._tag === "Right" ? [decoded.right] : [];
1002
+ });
1003
+ } catch (err) {
1004
+ process.stderr.write(
1005
+ `Warning: could not fetch review threads to detect answered findings (${errMsg(err)}) \u2014 no answered-finding state for this post
1006
+ `
1007
+ );
1008
+ return null;
1009
+ }
1010
+ };
631
1011
 
632
1012
  // src/render.ts
633
1013
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
634
- var sanitizeFinding = (f) => ({
635
- ...f,
636
- title: escapePipes(f.title),
637
- path: escapeCodeBackticks(f.path),
638
- patchProjection: projectPatch(f.patch)
639
- });
1014
+ var sanitizeFinding = (f, answeredNotes) => {
1015
+ const key2 = answeredNoteKey(f);
1016
+ return {
1017
+ ...f,
1018
+ title: escapePipes(f.title),
1019
+ path: escapeCodeBackticks(f.path),
1020
+ patchProjection: projectPatch(f.patch),
1021
+ answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : ""
1022
+ };
1023
+ };
640
1024
  var sanitizeSystemic = (s) => ({
641
1025
  ...s,
642
1026
  title: escapePipes(s.title),
@@ -675,6 +1059,7 @@ var render = (input) => {
675
1059
  const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && rounds.length > 0)) && isReviewVerdict(input.findings.verdict);
676
1060
  const convergenceCounts = rounds[rounds.length - 1] ?? computeRoundCounts(input.findings);
677
1061
  const advisoryAllowed = isFullReviewRound;
1062
+ const scopeMetastasis = input.scopeMetastasis !== void 0 ? input.scopeMetastasis : advisoryAllowed ? computeScopeMetastasis(rounds) : null;
678
1063
  return eta.renderString(input.template, {
679
1064
  findings: input.findings,
680
1065
  envelope: input.envelope,
@@ -691,7 +1076,7 @@ var render = (input) => {
691
1076
  postedAt: input.postedAt ?? "",
692
1077
  severityCounts,
693
1078
  convergenceSummary: isFullReviewRound ? convergenceSummary(convergenceCounts, input.convergenceThreshold) : "",
694
- strays: (input.strays ?? []).map(sanitizeFinding),
1079
+ strays: (input.strays ?? []).map((f) => sanitizeFinding(f, input.answeredNotes)),
695
1080
  systemic: (input.findings.systemic_problems ?? []).map(sanitizeSystemic),
696
1081
  unanchoredCount: input.unanchoredCount ?? 0,
697
1082
  inlineDisposition: input.inlineDisposition ?? null,
@@ -705,12 +1090,15 @@ var render = (input) => {
705
1090
  // label does; post always supplies the marker, so this path cannot disagree with it in
706
1091
  // production (issue #141 review r4).
707
1092
  isFullReviewRound && rounds.length > 0 ? signalForRound(rounds.length, convergenceCounts, input.convergenceThreshold) : null,
708
- input.jsonUrl
1093
+ input.jsonUrl,
1094
+ scopeMetastasis
709
1095
  ),
710
1096
  roundsMarker: roundsMarker(rounds),
711
1097
  roundsSummary: roundsSummary(rounds, input.roundCount),
712
1098
  metastasisNote: advisoryAllowed ? metastasisNote(rounds) : "",
713
1099
  sameRootNotes: advisoryAllowed ? sameRootNotes : {},
1100
+ answeredNotes: input.answeredNotes ?? {},
1101
+ answeredReRaiseNote: input.answeredReRaiseNote ?? "",
714
1102
  reviewUrl: input.reviewUrl ?? null,
715
1103
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
716
1104
  // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
@@ -793,7 +1181,7 @@ var partitionFindings = (findings, index) => {
793
1181
 
794
1182
  // src/inline.ts
795
1183
  var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
796
- var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRootNote) => (
1184
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRootNote, answeredNote2) => (
797
1185
  // Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
798
1186
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
799
1187
  eta.renderString(template, {
@@ -804,7 +1192,8 @@ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRoo
804
1192
  modelsText,
805
1193
  jsonUrl: jsonUrl ?? null,
806
1194
  findingsPointer: pointer,
807
- sameRootNote
1195
+ sameRootNote,
1196
+ answeredNote: answeredNote2
808
1197
  })
809
1198
  );
810
1199
  var buildInlineComments = (findings, diff, context) => {
@@ -813,14 +1202,29 @@ var buildInlineComments = (findings, diff, context) => {
813
1202
  const { inDiff, strays } = partitionFindings(findings, index);
814
1203
  const eta = new Eta({ autoTrim: false });
815
1204
  const modelsText = formatModels(models);
1205
+ const noteFor = (f, notes) => {
1206
+ if (notes === void 0) return "";
1207
+ const key2 = answeredNoteKey(f);
1208
+ return Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
1209
+ };
816
1210
  const comments = inDiff.map((f) => {
817
1211
  const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
818
- const sameRootNote = f.code !== void 0 && f.code !== "" && context.sameRootNotes !== void 0 && Object.prototype.hasOwnProperty.call(context.sameRootNotes, f.code) ? context.sameRootNotes[f.code] ?? "" : "";
1212
+ const sameRootNote = noteFor(f, context.sameRootNotes);
1213
+ const answeredNote2 = noteFor(f, context.answeredNotes);
819
1214
  const comment = {
820
1215
  path: f.path,
821
1216
  line: f.end_line,
822
1217
  side: defaultSide(f.side),
823
- body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer, sameRootNote)
1218
+ body: renderCommentBody(
1219
+ f,
1220
+ eta,
1221
+ inlineTemplate,
1222
+ modelsText,
1223
+ jsonUrl,
1224
+ pointer,
1225
+ sameRootNote,
1226
+ answeredNote2
1227
+ )
824
1228
  };
825
1229
  if (f.start_line < f.end_line) {
826
1230
  return {
@@ -847,129 +1251,6 @@ var renderStraysSection = (strays) => {
847
1251
  ...items
848
1252
  ].join("\n");
849
1253
  };
850
- var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
851
- var errMsg = (e) => e instanceof Error ? e.message : String(e);
852
- var annotationSafe = (msg) => msg.replaceAll(/[\r\n]+/g, " ");
853
- var tryParseJson = (text) => {
854
- try {
855
- return { ok: true, value: JSON.parse(text) };
856
- } catch {
857
- return { ok: false };
858
- }
859
- };
860
- var readFileOrNull = (path) => {
861
- try {
862
- return readFileSync(path, "utf-8");
863
- } catch {
864
- return null;
865
- }
866
- };
867
-
868
- // src/transcript.ts
869
- var numField = (rec, key2) => {
870
- const v = rec[key2];
871
- return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
872
- };
873
- var messageUsage = (entry) => {
874
- const rec = asRecord(entry);
875
- if (rec === null || rec["type"] !== "assistant") return null;
876
- const msg = asRecord(rec["message"]);
877
- if (msg === null) return null;
878
- const model = msg["model"];
879
- const usage = asRecord(msg["usage"]);
880
- if (typeof model !== "string" || usage === null) return null;
881
- const id = msg["id"];
882
- return {
883
- id: typeof id === "string" ? id : null,
884
- model,
885
- input: numField(usage, "input_tokens"),
886
- output: numField(usage, "output_tokens"),
887
- cacheRead: numField(usage, "cache_read_input_tokens"),
888
- cacheWrite: numField(usage, "cache_creation_input_tokens")
889
- };
890
- };
891
- var tsMsOf = (entry) => {
892
- const rec = asRecord(entry);
893
- const ts = rec?.["timestamp"];
894
- if (typeof ts !== "string") return null;
895
- const ms = Date.parse(ts);
896
- return Number.isNaN(ms) ? null : ms;
897
- };
898
- var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
899
- var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
900
- try {
901
- return [JSON.parse(line)];
902
- } catch {
903
- return [];
904
- }
905
- });
906
- var sumTranscriptUsage = (entries) => {
907
- const summed = entries.reduce(
908
- (acc, entry) => {
909
- const u = messageUsage(entry);
910
- if (u === null) return acc;
911
- if (u.id !== null && acc.seen.has(u.id)) return acc;
912
- if (u.id !== null) acc.seen.add(u.id);
913
- const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
914
- acc.totals.set(u.model, {
915
- input: prev.input + u.input,
916
- output: prev.output + u.output,
917
- cacheRead: prev.cacheRead + u.cacheRead,
918
- cacheWrite: prev.cacheWrite + u.cacheWrite
919
- });
920
- return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
921
- },
922
- { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
923
- );
924
- const models = [...summed.totals].map(([model, t7]) => ({
925
- model,
926
- input_tokens: t7.input,
927
- output_tokens: t7.output,
928
- cache_read_tokens: t7.cacheRead,
929
- cache_write_tokens: t7.cacheWrite
930
- }));
931
- const bounds = entries.reduce(
932
- (acc, entry) => {
933
- const ms = tsMsOf(entry);
934
- if (ms === null) return acc;
935
- return {
936
- min: acc.min === null || ms < acc.min ? ms : acc.min,
937
- max: acc.max === null || ms > acc.max ? ms : acc.max
938
- };
939
- },
940
- { min: null, max: null }
941
- );
942
- return {
943
- models,
944
- turns: summed.turns,
945
- durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
946
- firstTsMs: bounds.min,
947
- lastTsMs: bounds.max
948
- };
949
- };
950
- var subagentFiles = (mainPath) => {
951
- const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
952
- try {
953
- return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
954
- } catch {
955
- return [];
956
- }
957
- };
958
- var readTranscriptTree = (mainPath) => {
959
- const mainText = readFileOrNull(mainPath);
960
- const mainEntries = mainText === null ? [] : parseJsonl(mainText);
961
- const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
962
- const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
963
- const siblingReads = siblings.flatMap((path) => {
964
- const text = readFileOrNull(path);
965
- return text === null ? [] : [{ path, entries: parseJsonl(text) }];
966
- });
967
- return {
968
- entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
969
- files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
970
- missing: mainText === null
971
- };
972
- };
973
1254
  var addFormats = _addFormats;
974
1255
  var validatorCache = /* @__PURE__ */ new Map();
975
1256
  var compileSchema = (schemaPath) => {
@@ -1151,7 +1432,7 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
1151
1432
  const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
1152
1433
  if (WRITE_TOOLS.has(toolName)) {
1153
1434
  const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
1154
- if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
1435
+ if (typeof fp === "string" && targets.some((t8) => fp === t8 || basename(fp) === basename(t8)))
1155
1436
  return true;
1156
1437
  }
1157
1438
  if (toolName === "Bash") {
@@ -1178,6 +1459,7 @@ var sidecarPath = (draftPath, postfix) => {
1178
1459
  return join(dirname(draftPath), `${basename(draftPath, ext)}${postfix}${ext}`);
1179
1460
  };
1180
1461
  var priorContextPath = (draftPath) => sidecarPath(draftPath, ".prior");
1462
+ var priorAnswersPath = (draftPath) => sidecarPath(draftPath, ".prior-answers");
1181
1463
  var lastValidPath = (draftPath) => sidecarPath(draftPath, ".last-valid");
1182
1464
  var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and the pre-seeded $DRAFT is a non-review sentinel: it does not count until you have replaced it with your own review. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
1183
1465
  var forceBackgroundSpawn = (toolInput) => ({
@@ -1254,9 +1536,9 @@ var parseWallMs = (raw) => {
1254
1536
  };
1255
1537
  var parseEpochSecMs = (raw) => {
1256
1538
  if (raw === void 0) return null;
1257
- const t7 = raw.trim();
1258
- if (!/^\d+$/.test(t7)) return null;
1259
- const n = Number.parseInt(t7, 10);
1539
+ const t8 = raw.trim();
1540
+ if (!/^\d+$/.test(t8)) return null;
1541
+ const n = Number.parseInt(t8, 10);
1260
1542
  return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
1261
1543
  };
1262
1544
  var anchoredElapsedMs = (src) => {
@@ -1905,8 +2187,8 @@ var priorBotCommentIds = (raw, botLogin) => {
1905
2187
  const nodes = conn?.nodes;
1906
2188
  if (!Array.isArray(nodes)) return { ids: [], truncated };
1907
2189
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1908
- const ids = nodes.flatMap((t7) => {
1909
- const cnodes = t7.comments?.nodes;
2190
+ const ids = nodes.flatMap((t8) => {
2191
+ const cnodes = t8.comments?.nodes;
1910
2192
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1911
2193
  });
1912
2194
  return { ids, truncated };
@@ -1981,22 +2263,18 @@ var post = async (input, ghApi = runGhApi) => {
1981
2263
  process.exit(0);
1982
2264
  }
1983
2265
  const prNumber = resolution.prNumber;
1984
- const diff = await fetchDiff(input.repo, prNumber, ghApi);
1985
- const existingSticky = await findBotComment(
1986
- input.repo,
1987
- prNumber,
1988
- input.botLogin,
1989
- DEFAULT_MARKER,
1990
- ghApi
1991
- );
2266
+ const [diff, existingSticky] = await Promise.all([
2267
+ fetchDiff(input.repo, prNumber, ghApi),
2268
+ findBotComment(input.repo, prNumber, input.botLogin, DEFAULT_MARKER, ghApi)
2269
+ ]);
1992
2270
  const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
1993
2271
  const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
1994
2272
  const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
1995
2273
  const emptyMechanicWouldBury = (route, incomplete) => route === "mechanic" && !incomplete && findings.findings.length === 0 && existingSticky !== null && priorIsFullReview(existingSticky.body);
1996
2274
  const priorRounds = existingSticky !== null ? parseRounds(existingSticky.body) : [];
1997
2275
  const priorSignal = existingSticky === null ? null : parseSignalMarker(existingSticky.body) ?? parseSurfaceSignal(parseFindingsMarker(existingSticky.body));
1998
- const findingsMarkerFor = (findings2, signal2) => {
1999
- const pointer = surfacedFindingsPointer(findings2, signal2, input.jsonUrl);
2276
+ const findingsMarkerFor = (findings2, signal2, scopeMetastasis2) => {
2277
+ const pointer = surfacedFindingsPointer(findings2, signal2, input.jsonUrl, scopeMetastasis2);
2000
2278
  return signal2 !== null || priorSignal === null ? pointer : `${pointer}
2001
2279
  ${signalMarker(priorSignal)}`;
2002
2280
  };
@@ -2006,14 +2284,28 @@ ${signalMarker(priorSignal)}`;
2006
2284
  );
2007
2285
  process.exit(0);
2008
2286
  };
2287
+ const logAnsweredDrops = () => {
2288
+ if (verbatimReRaised.length > 0) {
2289
+ process.stderr.write(
2290
+ `${String(droppedCount)} verbatim re-raise(s) of answered findings were treated as answered \u2014 the preserved sticky shows each finding and its prior reply
2291
+ `
2292
+ );
2293
+ }
2294
+ };
2009
2295
  const emptyMechanicLeaveOrNote = async (sticky) => {
2010
- if (existingComplete) leaveInPlace(EMPTY_MECHANIC_LEAVE_MESSAGE);
2296
+ if (existingComplete) {
2297
+ logAnsweredDrops();
2298
+ leaveInPlace(EMPTY_MECHANIC_LEAVE_MESSAGE);
2299
+ }
2011
2300
  const priorSha = parseReviewedSha(sticky.body);
2301
+ const dropNote = answeredDropNote;
2012
2302
  const body = formatMarkdown(
2013
2303
  noticeBody(
2014
2304
  `${DEFAULT_MARKER}
2015
2305
 
2016
- \u26A0\uFE0F **CI-fix pass completed with no findings** for \`${input.headSha.slice(0, 7)}\` \u2014 the completed full review of \`${priorSha ? priorSha.slice(0, 7) : "an earlier commit"}\` is preserved below.`,
2306
+ \u26A0\uFE0F **CI-fix pass completed with no findings** for \`${input.headSha.slice(0, 7)}\` \u2014 the completed full review of \`${priorSha ? priorSha.slice(0, 7) : "an earlier commit"}\` is preserved below.${dropNote ? `
2307
+
2308
+ ${dropNote}` : ""}`,
2017
2309
  sticky.body
2018
2310
  )
2019
2311
  );
@@ -2078,13 +2370,38 @@ ${signalMarker(priorSignal)}`;
2078
2370
  );
2079
2371
  process.exit(0);
2080
2372
  }
2081
- const findings = findingsResult.findings;
2373
+ const loadedFindings = findingsResult.findings;
2374
+ const threadComments = await fetchThreadComments(ghApi, input.repo, prNumber);
2375
+ const answeredRegistry = threadComments === null ? [] : answeredRegistryFrom(threadComments, input.botLogin);
2376
+ const answeredFilter = applyAnswered(loadedFindings.findings, answeredRegistry);
2377
+ const reRaisedNotes = answeredFilter.reRaisedNotes;
2378
+ const verbatimReRaised = answeredFilter.verbatimReRaised;
2379
+ const droppedCount = answeredFilter.droppedCount;
2380
+ const droppedCodes = new Set(verbatimReRaised.flatMap((e) => e.code !== "" ? [e.code] : []));
2381
+ const keptCodes = new Set(
2382
+ answeredFilter.findings.flatMap((f) => f.code !== void 0 && f.code !== "" ? [f.code] : [])
2383
+ );
2384
+ const trulyDropped = new Set([...droppedCodes].filter((c) => !keptCodes.has(c)));
2385
+ const systemic = trulyDropped.size === 0 ? loadedFindings.systemic_problems ?? [] : (loadedFindings.systemic_problems ?? []).map((s) => {
2386
+ if (s.finding_codes === void 0) return s;
2387
+ const codes = s.finding_codes.filter((c) => !trulyDropped.has(c));
2388
+ return codes.length === s.finding_codes.length ? s : { ...s, finding_codes: codes };
2389
+ });
2390
+ const findings = {
2391
+ ...loadedFindings,
2392
+ findings: [...answeredFilter.findings],
2393
+ ...systemic.length > 0 ? { systemic_problems: systemic } : {}
2394
+ };
2395
+ 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._" : "");
2082
2396
  const envelope = loadEnvelope(input.envelopePath);
2083
2397
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
2084
2398
  const effectiveRoute = input.route ?? envelope?.route;
2085
2399
  if (envelope === null) {
2086
2400
  const envelopelessIncomplete = isIncompleteFindings(findings);
2087
- if (wouldBuryCompleted(envelopelessIncomplete)) leaveInPlace();
2401
+ if (wouldBuryCompleted(envelopelessIncomplete)) {
2402
+ logAnsweredDrops();
2403
+ leaveInPlace();
2404
+ }
2088
2405
  if (emptyMechanicWouldBury(effectiveRoute, envelopelessIncomplete) && existingSticky !== null)
2089
2406
  await emptyMechanicLeaveOrNote(existingSticky);
2090
2407
  const body = formatMarkdown(
@@ -2100,6 +2417,13 @@ ${signalMarker(priorSignal)}`;
2100
2417
  effort: input.effort,
2101
2418
  rounds: priorRounds,
2102
2419
  sameRootNotes: {},
2420
+ // The answered-state honesty rules apply on EVERY surface that renders the filtered
2421
+ // findings — the lost-envelope branch lists every finding (no inline review exists to
2422
+ // carry them) so the kept re-raises' annotations actually render, and names the drops
2423
+ // exactly like the main path (issues #151 review r1 + r2).
2424
+ strays: findings.findings,
2425
+ answeredNotes: reRaisedNotes,
2426
+ answeredReRaiseNote: answeredDropNote,
2103
2427
  roundCount: priorSignal?.round ?? priorRounds.length,
2104
2428
  convergenceRound: false,
2105
2429
  testReport,
@@ -2122,7 +2446,10 @@ ${signalMarker(priorSignal)}`;
2122
2446
  process.exit(0);
2123
2447
  }
2124
2448
  const thisIncomplete = envelope.incomplete === true || isIncompleteFindings(findings);
2125
- if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
2449
+ if (wouldBuryCompleted(thisIncomplete)) {
2450
+ logAnsweredDrops();
2451
+ leaveInPlace();
2452
+ }
2126
2453
  if (emptyMechanicWouldBury(effectiveRoute, thisIncomplete) && existingSticky !== null)
2127
2454
  await emptyMechanicLeaveOrNote(existingSticky);
2128
2455
  const isRound = isConvergenceRound(effectiveRoute, thisIncomplete) && isReviewVerdict(findings.verdict);
@@ -2136,7 +2463,8 @@ ${signalMarker(priorSignal)}`;
2136
2463
  models: envelope.models.map((m) => m.model),
2137
2464
  findings,
2138
2465
  jsonUrl: input.jsonUrl,
2139
- sameRootNotes
2466
+ sameRootNotes,
2467
+ answeredNotes: reRaisedNotes
2140
2468
  });
2141
2469
  const { comments, longFiles } = checkLongSuggestions(rawComments);
2142
2470
  for (const wf of longFiles) {
@@ -2162,8 +2490,12 @@ ${signalMarker(priorSignal)}`;
2162
2490
  )
2163
2491
  ] : priorRounds;
2164
2492
  const signal = isRound ? signalForRound(roundNumber, computeRoundCounts(findings), input.convergenceThreshold) : thisIncomplete || !isReviewVerdict(findings.verdict) ? null : priorSignal;
2165
- const findingsMarker = findingsMarkerFor(findings, signal);
2166
- const markerForm = findingsMarkerForm(surfaceFindings(findings, signal), input.jsonUrl);
2493
+ const scopeMetastasis = isRound ? computeScopeMetastasis(rounds) : null;
2494
+ const findingsMarker = findingsMarkerFor(findings, signal, scopeMetastasis);
2495
+ const markerForm = findingsMarkerForm(
2496
+ surfaceFindings(findings, signal, scopeMetastasis),
2497
+ input.jsonUrl
2498
+ );
2167
2499
  if (markerForm === "link") {
2168
2500
  process.stderr.write(
2169
2501
  "Warning: the findings-json marker exceeds the embed limit \u2014 degraded to the jsonUrl-link form; a decoding agent must fetch the artifact instead of the embedded JSON\n"
@@ -2187,6 +2519,9 @@ ${signalMarker(priorSignal)}`;
2187
2519
  severityCounts: currentCounts,
2188
2520
  rounds,
2189
2521
  sameRootNotes,
2522
+ answeredNotes: reRaisedNotes,
2523
+ answeredReRaiseNote: answeredDropNote,
2524
+ scopeMetastasis,
2190
2525
  roundCount: signal?.round ?? priorSignal?.round ?? priorRounds.length,
2191
2526
  convergenceThreshold: input.convergenceThreshold,
2192
2527
  convergenceRound: isRound,
@@ -2726,7 +3061,7 @@ var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
2726
3061
  return commits;
2727
3062
  };
2728
3063
  var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
2729
- var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
3064
+ var REVIEW_COMMENT_JQ = THREAD_COMMENT_JQ;
2730
3065
  var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
2731
3066
  var ReviewCommentCodec = t.intersection([
2732
3067
  t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
@@ -2770,13 +3105,7 @@ var priorReviewFrom = (comments, botLogin) => {
2770
3105
  };
2771
3106
  var MAX_CONVERSATION_COMMENTS = 50;
2772
3107
  var MAX_CONVERSATION_BODY_CHARS = 4e3;
2773
- var clip = (body) => {
2774
- if (body.length <= MAX_CONVERSATION_BODY_CHARS) return body;
2775
- const cut = body.slice(0, MAX_CONVERSATION_BODY_CHARS);
2776
- const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
2777
- return `${safe}
2778
- \u2026 [truncated]`;
2779
- };
3108
+ var clip = (body) => clipText(body, MAX_CONVERSATION_BODY_CHARS);
2780
3109
  var boundedHuman = (items, botLogin, label, project) => {
2781
3110
  const human = items.filter(
2782
3111
  (a) => a.user.login !== botLogin && typeof a.body === "string" && a.body.trim() !== ""
@@ -2877,12 +3206,18 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
2877
3206
  ]);
2878
3207
  const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
2879
3208
  const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
3209
+ const threadComments = decodeArrayOrNull(ThreadCommentCodec, reviewCommentRows);
2880
3210
  const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
2881
3211
  const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
2882
3212
  writeFileSync(
2883
3213
  join(input.outDir, "prior_review.json"),
2884
3214
  prior === null ? "null" : JSON.stringify(prior)
2885
3215
  );
3216
+ const answered = threadComments === null ? [] : answeredRegistryFrom(threadComments, input.botLogin);
3217
+ writeFileSync(
3218
+ join(input.outDir, "answered.json"),
3219
+ JSON.stringify(answered.map(encodeAnsweredEntry))
3220
+ );
2886
3221
  writeFileSync(
2887
3222
  join(input.outDir, "pr_conversation.json"),
2888
3223
  JSON.stringify({
@@ -3236,7 +3571,7 @@ var parseScope = (raw) => {
3236
3571
  reason: 'scope contains a character (newline, carriage return, backtick, "<", ">", or "|") that would corrupt the review prompt \u2014 use plain language names/tags'
3237
3572
  };
3238
3573
  }
3239
- const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t7) => t7 !== "")));
3574
+ const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t8) => t8 !== "")));
3240
3575
  return languages.length === 0 ? { kind: "absent" } : { kind: "ok", languages };
3241
3576
  };
3242
3577
 
@@ -3841,6 +4176,10 @@ var seedDraftCmd = defineCommand({
3841
4176
  type: "string",
3842
4177
  description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and delivered as re-review context when it validates against the schema"
3843
4178
  },
4179
+ "prior-answers": {
4180
+ type: "string",
4181
+ description: "Path to the gather-staged answered-findings registry (answered.json) \u2014 the prior inline findings whose threads a human reply answered (issue #151). Delivered out-of-band to the .prior-answers sidecar beside the prior context so the next-round agent sees the already-answered state; best-effort, never fails the seed"
4182
+ },
3844
4183
  "head-sha": {
3845
4184
  type: "string",
3846
4185
  description: "Current head SHA, compared against the prior review's embedded reviewed-sha to distinguish a same-commit re-review from a new-commit one; an unknown or mismatched prior SHA is treated as a new commit"
@@ -3900,21 +4239,64 @@ var seedDraftCmd = defineCommand({
3900
4239
  })();
3901
4240
  return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
3902
4241
  })();
3903
- const priorFindings = priorBody === null ? null : stripSurfaceFields(parseFindingsMarker(priorBody));
4242
+ const strippedPrior = priorBody === null ? null : stripSurfaceFields(parseFindingsMarker(priorBody));
4243
+ const priorFindings = (() => {
4244
+ if (strippedPrior === null || typeof strippedPrior !== "object" || Array.isArray(strippedPrior))
4245
+ return strippedPrior;
4246
+ const carried = strippedPrior["scope_metastasis"];
4247
+ if (ScopeMetastasisCodec.decode(carried)._tag === "Right") return strippedPrior;
4248
+ if (strippedPrior["verdict"] === "error") return strippedPrior;
4249
+ const computed = computeScopeMetastasis(parseRounds(priorBody ?? ""));
4250
+ return computed === null ? strippedPrior : { ...strippedPrior, scope_metastasis: computed };
4251
+ })();
4252
+ if (args["prior-answers"]) {
4253
+ try {
4254
+ const raw = JSON.parse(readFileSync(resolve$1(args["prior-answers"]), "utf-8"));
4255
+ if (!Array.isArray(raw)) throw new Error("expected an array");
4256
+ const decoded = raw.flatMap((row) => {
4257
+ const entry = decodeAnsweredEntry(row);
4258
+ return entry === null ? [] : [entry];
4259
+ });
4260
+ if (decoded.length < raw.length) {
4261
+ process.stderr.write(
4262
+ `Warning: ${String(raw.length - decoded.length)} of ${String(raw.length)} answered-registry row(s) failed to decode \u2014 the seed's answered state is incomplete
4263
+ `
4264
+ );
4265
+ }
4266
+ writeFileSync(priorAnswersPath(outPath), `${JSON.stringify(decoded, null, 2)}
4267
+ `);
4268
+ process.stderr.write(
4269
+ `Seeded ${priorAnswersPath(outPath)} with ${String(decoded.length)} answered finding(s) as context
4270
+ `
4271
+ );
4272
+ } catch (err) {
4273
+ process.stderr.write(
4274
+ `Warning: could not read the answered-findings registry ${args["prior-answers"]} (${errMsg(err)}) \u2014 no prior-answers sidecar
4275
+ `
4276
+ );
4277
+ }
4278
+ }
3904
4279
  const seededFromPrior = priorFindings === null ? false : (() => {
3905
4280
  try {
3906
4281
  const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
3907
- if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
3908
- const resolution = resolve("findings", priorFindings);
4282
+ const barePrior = typeof priorFindings === "object" && !Array.isArray(priorFindings) ? Object.fromEntries(
4283
+ Object.entries(priorFindings).filter(
4284
+ ([key2]) => key2 !== "scope_metastasis"
4285
+ )
4286
+ ) : priorFindings;
4287
+ const accepts = (doc) => validateAgainstSchema(doc, schemaPath).valid && resolve("findings", doc).kind === "ok";
4288
+ const seedDoc = accepts(priorFindings) ? priorFindings : accepts(barePrior) ? (process.stderr.write(
4289
+ `Note: the in-force schema rejects the carried scope_metastasis entry \u2014 seeding the prior without it (issue #150 review r2)
4290
+ `
4291
+ ), barePrior) : null;
4292
+ if (seedDoc === null) return false;
4293
+ const resolution = resolve("findings", seedDoc);
3909
4294
  if (resolution.kind !== "ok") return false;
3910
4295
  if (isIncompleteFindings(resolution.value)) return false;
3911
4296
  if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
3912
4297
  writeFileSync(outPath, SEED_SENTINEL);
3913
- writeFileSync(
3914
- priorContextPath(outPath),
3915
- `${JSON.stringify(priorFindings, null, 2)}
3916
- `
3917
- );
4298
+ writeFileSync(priorContextPath(outPath), `${JSON.stringify(seedDoc, null, 2)}
4299
+ `);
3918
4300
  const count = resolution.value.findings.length;
3919
4301
  process.stderr.write(
3920
4302
  `Seeded ${outPath} with the sentinel and wrote the prior review (${String(count)} finding(s)) to ${priorContextPath(outPath)} as context
@@ -4730,7 +5112,7 @@ var awaitCiCmd = defineCommand({
4730
5112
  var checkScopeCmd = defineCommand({
4731
5113
  meta: {
4732
5114
  name: "check-scope",
4733
- description: "Validate + normalize the workflow's `scope` input \u2014 the languages/inputs the project accepts (issue #139). Prints the normalized space-separated language list for splicing into the review prompt, or nothing when the scope is empty (the reviewer then infers it from the README's first paragraph). Fails loudly on a malformed value, so a config typo never silently corrupts the prompt it is spliced into."
5115
+ description: "Validate + normalize the workflow's `scope` input \u2014 the languages/inputs the project accepts (issue #139). Prints the normalized space-separated language list for splicing into the review prompt, or nothing when the scope is empty (the reviewer then infers it from the README's first paragraph). A structurally malformed value is rejected rather than spliced in as-is."
4734
5116
  },
4735
5117
  args: {
4736
5118
  scope: {