@jphutchins/code-review 0.1.0-alpha.45 → 0.1.0-alpha.47

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
@@ -32,6 +32,7 @@ var LineNumber = t.refinement(
32
32
  );
33
33
  var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
34
34
  var Likelihood = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Likelihood");
35
+ var FiniteNumber = t.refinement(t.number, (n) => Number.isFinite(n), "FiniteNumber");
35
36
  var SCHEMA_VERSION_RE = /^(0|[1-9]\d*)\.(\d+)\.(\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
36
37
  var SchemaVersion = t.refinement(
37
38
  t.string,
@@ -126,6 +127,49 @@ var ScopeMetastasisStrict = t.refinement(
126
127
  "ScopeMetastasisStrict"
127
128
  );
128
129
  var ScopeMetastasisCodec = t.exact(ScopeMetastasisStrict);
130
+ var RoundNumber = t.refinement(
131
+ t.number,
132
+ (n) => Number.isSafeInteger(n) && n >= 1,
133
+ "RoundNumber"
134
+ );
135
+ var CodeFrequency = t.record(
136
+ t.string,
137
+ t.refinement(t.number, (n) => Number.isSafeInteger(n) && n >= 0, "CodeFrequency")
138
+ );
139
+ var ConvergenceRoundRequired = t.type({ round: RoundNumber });
140
+ var ConvergenceRoundOptional = t.partial({
141
+ score: FiniteNumber,
142
+ codes: CodeFrequency,
143
+ sha: t.string
144
+ });
145
+ var ConvergenceRoundShape = t.intersection([ConvergenceRoundRequired, ConvergenceRoundOptional]);
146
+ var CONVERGENCE_ROUND_KEYS = /* @__PURE__ */ new Set([
147
+ ...Object.keys(ConvergenceRoundRequired.props),
148
+ ...Object.keys(ConvergenceRoundOptional.props)
149
+ ]);
150
+ var ConvergenceRoundStrict = t.refinement(
151
+ ConvergenceRoundShape,
152
+ (r) => Object.keys(r).every((k) => CONVERGENCE_ROUND_KEYS.has(k)),
153
+ "ConvergenceRoundStrict"
154
+ );
155
+ var ConvergenceRoundCodec = t.exact(ConvergenceRoundStrict);
156
+ var ConvergenceCoreShape = t.type({
157
+ score: FiniteNumber,
158
+ threshold: FiniteNumber,
159
+ converged: t.boolean
160
+ });
161
+ var ConvergenceOptional = t.partial({ rounds: t.array(ConvergenceRoundCodec) });
162
+ var ConvergenceShape = t.intersection([ConvergenceCoreShape, ConvergenceOptional]);
163
+ var CONVERGENCE_KEYS = /* @__PURE__ */ new Set([
164
+ ...Object.keys(ConvergenceCoreShape.props),
165
+ ...Object.keys(ConvergenceOptional.props)
166
+ ]);
167
+ var ConvergenceStrict = t.refinement(
168
+ ConvergenceShape,
169
+ (c) => Object.keys(c).every((k) => CONVERGENCE_KEYS.has(k)),
170
+ "ConvergenceStrict"
171
+ );
172
+ var ConvergenceCodec = t.exact(ConvergenceStrict);
129
173
  var FindingsCodec = t.exact(
130
174
  t.intersection([
131
175
  t.type({
@@ -137,7 +181,9 @@ var FindingsCodec = t.exact(
137
181
  t.partial({
138
182
  systemic_problems: t.array(SystemicProblemCodec),
139
183
  // Pipeline-stamped only (issue #150); see ScopeMetastasisCodec.
140
- scope_metastasis: ScopeMetastasisCodec
184
+ scope_metastasis: ScopeMetastasisCodec,
185
+ // Pipeline-stamped only (issue #174); see ConvergenceCodec.
186
+ convergence: ConvergenceCodec
141
187
  })
142
188
  ])
143
189
  );
@@ -375,7 +421,7 @@ var severityEmoji = (s) => {
375
421
  };
376
422
  var EMBED_LIMIT = 42700;
377
423
  var FINDINGS_SCHEMA_URL = "https://raw.githubusercontent.com/JPHutchins/code-review/main/schema/findings.schema.json";
378
- var AGENTS_STOP_DIRECTIVE = `<!-- AGENTS: STOP \u2014 this comment is a machine channel; act on the data, not the prose. It embeds a base64 code-review findings JSON (an oversized review instead links the artifact URL in the same marker). Decode or fetch it, read its schema_version, then DOWNLOAD AND READ THE FULL SCHEMA for that version before you act \u2014 the schema's own $id is its canonical URL; the current schema is at ${FINDINGS_SCHEMA_URL} \u2014 read that schema, then parse the WHOLE findings document and every code-review:* marker (findings, signal, rounds), not only the fields you recognize: the recurrence signals an iterating agent needs (round streaks, scope metastasis, same-mechanism) are decodable there. -->`;
424
+ var AGENTS_STOP_DIRECTIVE = `<!-- AGENTS: STOP \u2014 this comment is a machine channel; act on the data, not the prose. It embeds a base64 code-review findings JSON (an oversized review instead links the artifact URL in the same marker). Decode or fetch it, read its schema_version, then DOWNLOAD AND READ THE FULL SCHEMA for that version before you act \u2014 the schema's own $id is its canonical URL; the current schema is at ${FINDINGS_SCHEMA_URL} \u2014 read that schema, then parse the WHOLE findings document, not only the fields you recognize: the convergence score, its threshold, and the per-round trajectory (so you know whether the review has converged and how to read the number), plus the recurrence signals an iterating agent needs (round streaks, scope metastasis, same-mechanism), are all fields inside that document. -->`;
379
425
  var b64LengthOf = (document) => Buffer.from(JSON.stringify(document), "utf-8").toString("base64").length;
380
426
  var encodeMarker = (document, jsonUrl, limit) => {
381
427
  const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
@@ -405,7 +451,9 @@ var ROUTE_RE = /<!-- reviewed-route: ([^>]*) -->/;
405
451
  var parseReviewedRoute = (body) => ROUTE_RE.exec(body)?.[1] || null;
406
452
  var isFullReviewSticky = (body) => {
407
453
  const route = parseReviewedRoute(body);
408
- return route === "full review" || route !== "mechanic" && parseRounds(body).length > 0;
454
+ if (route === "full review") return true;
455
+ if (route === "mechanic") return false;
456
+ return priorTrajectory(parseFindingsMarker(body), body).length > 0;
409
457
  };
410
458
  var COMPLETED_ANCESTOR_MARKER = "<!-- review-complete-ancestor -->";
411
459
  var parseCompletedAncestor = (body) => body.includes(COMPLETED_ANCESTOR_MARKER);
@@ -429,13 +477,7 @@ var normalizeCodeCounts = (codes, priorCodes) => {
429
477
  if (typeof codes !== "object" || codes === null || Array.isArray(codes)) return void 0;
430
478
  const entries = Object.entries(codes).filter(
431
479
  (e) => typeof e[1] === "number" && Number.isSafeInteger(e[1]) && e[1] > 0
432
- ).sort((a, b) => {
433
- if (b[1] !== a[1]) return b[1] - a[1];
434
- const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
435
- const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
436
- if (aPrior !== bPrior) return bPrior - aPrior;
437
- return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
438
- });
480
+ );
439
481
  if (entries.length === 0) return void 0;
440
482
  const sorted = entries.sort((a, b) => {
441
483
  if (b[1] !== a[1]) return b[1] - a[1];
@@ -469,30 +511,12 @@ var parseRounds = (body) => {
469
511
  }
470
512
  return kept;
471
513
  };
472
- var ROUNDS_MARKER_LIMIT = 8e3;
473
- var roundsMarker = (rounds) => {
474
- if (rounds.length === 0) return "";
475
- const serialize = (kept2) => `<!-- code-review:rounds;base64 ${Buffer.from(JSON.stringify(kept2), "utf-8").toString("base64")} -->`;
476
- const stripCodes = (n) => rounds.map(
477
- (r, i) => i < n ? { critical: r.critical, major: r.major, minor: r.minor, nit: r.nit } : r
478
- );
479
- let stripped = 0;
480
- while (stripped < rounds.length && serialize(stripCodes(stripped)).length > ROUNDS_MARKER_LIMIT) {
481
- stripped += 1;
482
- }
483
- const kept = stripCodes(stripped);
484
- const bounded = serialize(kept).length > ROUNDS_MARKER_LIMIT ? kept.slice(-8) : kept;
485
- return serialize(bounded);
486
- };
487
- var roundChip = (c) => {
488
- const parts = SEVERITIES.filter((k) => c[k] > 0).map((k) => `${severityEmoji(k)}${String(c[k])}`);
489
- return parts.length === 0 ? "clean" : parts.join(" ");
490
- };
491
- var TRAJECTORY_CHIPS = 8;
514
+ var formatScore = (score) => formatConfidence(score);
515
+ var TRAJECTORY_SCORES = 8;
492
516
  var roundsSummary = (rounds, count = rounds.length) => {
493
517
  if (count === 0) return "";
494
- const chips = rounds.slice(-TRAJECTORY_CHIPS).map(roundChip);
495
- const trajectory = chips.length === 0 ? "" : rounds.length > TRAJECTORY_CHIPS ? `\u2026 \u2192 ${chips.join(" \u2192 ")}` : chips.join(" \u2192 ");
518
+ const cells = rounds.slice(-TRAJECTORY_SCORES).map((r) => typeof r.score === "number" ? formatScore(r.score) : "\u2014");
519
+ const trajectory = cells.length === 0 ? "" : rounds.length > TRAJECTORY_SCORES ? `\u2026 \u2192 ${cells.join(" \u2192 ")}` : cells.join(" \u2192 ");
496
520
  return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
497
521
  };
498
522
  var computeCodeCounts = (findings, systemic = []) => {
@@ -506,15 +530,6 @@ var computeCodeCounts = (findings, systemic = []) => {
506
530
  }
507
531
  return Object.fromEntries(counts);
508
532
  };
509
- var roundRecord = (counts, codes, priorCodes, sha, round) => {
510
- const normalized = normalizeCodeCounts(codes, priorCodes);
511
- const record3 = normalized === void 0 ? { ...counts } : { ...counts, codes: normalized };
512
- return {
513
- ...record3,
514
- ...sha !== void 0 ? { sha } : {},
515
- ...round !== void 0 ? { round } : {}
516
- };
517
- };
518
533
  var consecutiveCodeStreaks = (rounds) => {
519
534
  const entries = [];
520
535
  if (rounds.length === 0) return {};
@@ -608,6 +623,18 @@ var convergenceScore = (doc, threshold) => round2(
608
623
  0
609
624
  )
610
625
  );
626
+ var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, codes = {}, sha) => {
627
+ const score = convergenceScore(doc, threshold);
628
+ const normalized = normalizeCodeCounts(codes, priorRounds[priorRounds.length - 1]?.codes);
629
+ const current = {
630
+ round,
631
+ score,
632
+ ...normalized !== void 0 ? { codes: { ...normalized } } : {},
633
+ ...sha !== void 0 ? { sha } : {}
634
+ };
635
+ const rounds = [...priorRounds, current].slice(-64);
636
+ return { score, threshold, converged: score <= threshold, rounds };
637
+ };
611
638
  var DEFAULT_NIT_VISIBILITY_FLOOR = 0.25;
612
639
  var isBelowVisibilityFloor = (f, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => f.severity === "nit" && typeof f.confidence === "number" && typeof f.likelihood === "number" && f.confidence * f.likelihood < floor;
613
640
  var priorBelowFloorNits = (priorDoc, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => {
@@ -631,26 +658,21 @@ var priorBelowFloorNits = (priorDoc, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => {
631
658
  }
632
659
  return nits;
633
660
  };
634
- var convergenceSummary = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
635
- const { score, converged } = convergenceSignal(doc, threshold);
636
- return converged ? `**Convergence** \u{1F3C1} ${String(score)} \u2264 ${String(threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${String(score)} > ${String(threshold)} \u2014 iterating`;
661
+ var convergenceBadge = (c) => c.converged ? `**Convergence** \u{1F3C1} ${formatScore(c.score)} \u2264 ${String(c.threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${formatScore(c.score)} > ${String(c.threshold)} \u2014 iterating`;
662
+ var convergenceSummary = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => convergenceBadge(convergenceSignal(doc, threshold));
663
+ var nextRoundNumber = (priorTraj, priorConvRounds) => {
664
+ const last = (rounds) => rounds.length > 0 ? rounds[rounds.length - 1]?.round ?? rounds.length : 0;
665
+ return Math.max(last(priorTraj), last(priorConvRounds)) + 1;
666
+ };
667
+ var inProgressConvergence = (prior, runningRound) => {
668
+ const rounds = prior.rounds ?? [];
669
+ return rounds.length === 0 ? "" : `${roundsSummary(rounds, runningRound)} \u2192 \u23F3`;
637
670
  };
638
671
  var SURFACE_SCHEMA_VERSION = "0.8.0";
639
672
  var convergenceSignal = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
640
673
  const score = convergenceScore(doc, threshold);
641
674
  return { score, threshold, converged: score <= threshold };
642
675
  };
643
- var signalForRound = (round, doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => ({ round, convergence: convergenceSignal(doc, threshold) });
644
- var signalMarker = (signal) => `<!-- code-review:signal;base64 ${Buffer.from(
645
- JSON.stringify({ schema_version: SURFACE_SCHEMA_VERSION, ...signal }),
646
- "utf-8"
647
- ).toString("base64")} -->`;
648
- var joinSignalMarker = (base, marker) => base === "" ? marker : `${base}
649
- ${marker}`;
650
- var surfacedFindingsPointer = (findings, signal, jsonUrl) => {
651
- const marker = findingsPointer(findings, jsonUrl);
652
- return signal === null ? marker : joinSignalMarker(marker, signalMarker(signal));
653
- };
654
676
  var SIGNAL_RE = /<!-- code-review:signal;base64 ([A-Za-z0-9+/=]+) -->/;
655
677
  var parseSignalMarker = (body) => {
656
678
  const b64 = SIGNAL_RE.exec(body)?.[1];
@@ -674,6 +696,39 @@ var parseSurfaceSignal = (doc) => {
674
696
  convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
675
697
  };
676
698
  };
699
+ var validStampedConvergence = (raw) => {
700
+ const decoded = ConvergenceCodec.decode(raw);
701
+ return decoded._tag === "Right" && decoded.right.rounds !== void 0 && decoded.right.rounds.length > 0 ? decoded.right : null;
702
+ };
703
+ var parseConvergence = (priorDoc) => {
704
+ if (typeof priorDoc !== "object" || priorDoc === null) return null;
705
+ const raw = priorDoc["convergence"];
706
+ return raw === void 0 ? null : validStampedConvergence(raw);
707
+ };
708
+ var CONVERGENCE_RE = /<!-- code-review:convergence;base64 ([A-Za-z0-9+/=]+) -->/;
709
+ var convergenceMarker = (convergence) => `<!-- code-review:convergence;base64 ${Buffer.from(JSON.stringify(convergence), "utf-8").toString(
710
+ "base64"
711
+ )} -->`;
712
+ var parseConvergenceMarker = (body) => {
713
+ const b64 = CONVERGENCE_RE.exec(body)?.[1];
714
+ return b64 === void 0 ? null : validStampedConvergence(decodeBase64Json(b64));
715
+ };
716
+ var roundRecordsToConvergenceRounds = (records) => records.map((r, i) => ({
717
+ round: r.round ?? i + 1,
718
+ ...r.codes !== void 0 ? { codes: { ...r.codes } } : {},
719
+ ...r.sha !== void 0 ? { sha: r.sha } : {}
720
+ }));
721
+ var priorTrajectory = (priorDoc, priorBody) => parseConvergence(priorDoc)?.rounds ?? parseConvergenceMarker(priorBody)?.rounds ?? roundRecordsToConvergenceRounds(parseRounds(priorBody));
722
+ var carriedConvergence = (priorDoc, priorBody) => {
723
+ const stamped = parseConvergence(priorDoc) ?? parseConvergenceMarker(priorBody);
724
+ if (stamped !== null) return stamped;
725
+ const signal = parseSignalMarker(priorBody) ?? parseSurfaceSignal(priorDoc);
726
+ if (signal === null) return null;
727
+ const legacy = roundRecordsToConvergenceRounds(parseRounds(priorBody));
728
+ const last = legacy[legacy.length - 1];
729
+ const rounds = last === void 0 ? [{ round: signal.round }] : last.round < signal.round ? [...legacy.slice(0, -1), { ...last, round: signal.round }] : legacy;
730
+ return { ...signal.convergence, rounds };
731
+ };
677
732
  var SURFACE_SCHEMA_VERSIONS = ["0.7.0", SURFACE_SCHEMA_VERSION];
678
733
  var isSurfaceVersion = (version) => typeof version === "string" && SURFACE_SCHEMA_VERSIONS.includes(version);
679
734
  var stripSurfaceFields = (doc) => {
@@ -689,12 +744,13 @@ var carryForwardMarkers = (body) => {
689
744
  const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
690
745
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
691
746
  const reviewedRoute = ROUTE_RE.exec(body)?.[0];
747
+ const convergence = CONVERGENCE_RE.exec(body)?.[0];
692
748
  const rounds = ROUNDS_RE.exec(body)?.[0];
693
749
  const signal = SIGNAL_RE.exec(body)?.[0];
694
750
  const completedAncestor = parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
695
751
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
696
752
  ${findings}` : void 0;
697
- return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, rounds, signal].filter((m) => m !== void 0).join("\n\n");
753
+ return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, convergence, rounds, signal].filter((m) => m !== void 0).join("\n\n");
698
754
  };
699
755
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
700
756
  var projectPatch = (patch) => {
@@ -1071,10 +1127,6 @@ var computeSeverityCounts = (findings) => findings.reduce(
1071
1127
  emptySeverityCounts()
1072
1128
  );
1073
1129
  var isReviewVerdict = (verdict) => verdict !== "error";
1074
- var computeRoundCounts = (findings) => (findings.systemic_problems ?? []).reduce(
1075
- (acc, s) => s.severity in acc ? { ...acc, [s.severity]: acc[s.severity] + 1 } : acc,
1076
- computeSeverityCounts(findings.findings)
1077
- );
1078
1130
  var isConvergenceRound = (route, incomplete) => route === "full review" && !incomplete;
1079
1131
  var render = (input) => {
1080
1132
  const eta = new Eta({ autoTrim: false });
@@ -1087,9 +1139,10 @@ var render = (input) => {
1087
1139
  const effort = input.effort ?? input.envelope?.effort ?? null;
1088
1140
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
1089
1141
  const severityCounts = input.severityCounts ?? computeSeverityCounts(input.findings.findings);
1090
- const rounds = input.rounds ?? [];
1091
- const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(rounds.slice(0, -1), input.findings.findings);
1092
- const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && rounds.length > 0)) && isReviewVerdict(input.findings.verdict);
1142
+ const convergence = input.findings.convergence;
1143
+ const trajectory = convergence?.rounds ?? input.rounds ?? [];
1144
+ const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(trajectory.slice(0, -1), input.findings.findings);
1145
+ const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && trajectory.length > 0)) && isReviewVerdict(input.findings.verdict);
1093
1146
  const advisoryAllowed = isFullReviewRound;
1094
1147
  return eta.renderString(input.template, {
1095
1148
  findings: input.findings,
@@ -1106,7 +1159,7 @@ var render = (input) => {
1106
1159
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
1107
1160
  postedAt: input.postedAt ?? "",
1108
1161
  severityCounts,
1109
- convergenceSummary: isFullReviewRound ? convergenceSummary(input.findings, input.convergenceThreshold) : "",
1162
+ convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold),
1110
1163
  strays: (input.strays ?? []).map((f) => sanitizeFinding(f, input.answeredNotes)),
1111
1164
  suppressedNits: (input.suppressedNits ?? []).map(sanitizeSuppressedNit),
1112
1165
  nitVisibilityFloor: input.nitVisibilityFloor ?? DEFAULT_NIT_VISIBILITY_FLOOR,
@@ -1115,19 +1168,12 @@ var render = (input) => {
1115
1168
  inlineDisposition: input.inlineDisposition ?? null,
1116
1169
  runUrl: input.runUrl ?? null,
1117
1170
  jsonUrl: input.jsonUrl ?? null,
1118
- findingsPointer: input.findingsPointer ?? surfacedFindingsPointer(
1119
- input.findings,
1120
- // The fallback embeds a signal exactly when the badge renders never beside a suppressed
1121
- // badge, and scores the same findings the badge does. It assumes a post-style history (the
1122
- // caller appends this run's counts last), numbering the round exactly as the trajectory
1123
- // label does; post always supplies the marker, so this path cannot disagree with it in
1124
- // production (issue #141 review r4).
1125
- isFullReviewRound && rounds.length > 0 ? signalForRound(rounds.length, input.findings, input.convergenceThreshold) : null,
1126
- input.jsonUrl
1127
- ),
1128
- roundsMarker: roundsMarker(rounds),
1129
- roundsSummary: roundsSummary(rounds, input.roundCount),
1130
- metastasisNote: advisoryAllowed ? metastasisNote(rounds) : "",
1171
+ // The blob is the agent's complete document with the pipeline-stamped convergence field inside it
1172
+ // (issue #174) — no separate signal or rounds marker rides beside it. post always supplies the
1173
+ // precomputed marker; the standalone `render` command falls back to encoding the doc here.
1174
+ findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
1175
+ roundsSummary: roundsSummary(trajectory, input.roundCount),
1176
+ metastasisNote: advisoryAllowed ? metastasisNote(trajectory) : "",
1131
1177
  sameRootNotes: advisoryAllowed ? sameRootNotes : {},
1132
1178
  answeredNotes: input.answeredNotes ?? {},
1133
1179
  answeredReRaiseNote: input.answeredReRaiseNote ?? "",
@@ -2010,14 +2056,9 @@ var checkLongSuggestions = (comments) => {
2010
2056
  });
2011
2057
  return { comments: adjusted, longFiles };
2012
2058
  };
2013
- var loadFindings = (path) => {
2014
- let raw;
2015
- try {
2016
- raw = JSON.parse(readFileSync(path, "utf-8"));
2017
- } catch {
2018
- return { kind: "corrupt" };
2019
- }
2020
- const resolution = resolve("findings", raw);
2059
+ var PIPELINE_STAMPED_FIELDS = /* @__PURE__ */ new Set(["convergence", "scope_metastasis"]);
2060
+ var decodeFindings = (doc) => {
2061
+ const resolution = resolve("findings", doc);
2021
2062
  switch (resolution.kind) {
2022
2063
  case "ok":
2023
2064
  return { kind: "ok", findings: resolution.value };
@@ -2028,6 +2069,28 @@ var loadFindings = (path) => {
2028
2069
  return { kind: "invalid-shape" };
2029
2070
  }
2030
2071
  };
2072
+ var loadFindings = (path) => {
2073
+ let raw;
2074
+ try {
2075
+ raw = JSON.parse(readFileSync(path, "utf-8"));
2076
+ } catch {
2077
+ return { kind: "corrupt" };
2078
+ }
2079
+ const first = decodeFindings(raw);
2080
+ if (first.kind === "invalid-shape" && typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.keys(raw).some((k) => PIPELINE_STAMPED_FIELDS.has(k))) {
2081
+ const stripped = Object.fromEntries(
2082
+ Object.entries(raw).filter(([key2]) => !PIPELINE_STAMPED_FIELDS.has(key2))
2083
+ );
2084
+ const retry = decodeFindings(stripped);
2085
+ if (retry.kind === "ok") {
2086
+ process.stderr.write(
2087
+ "Warning: the review draft carried an invalid pipeline-stamped field (convergence/scope_metastasis) \u2014 stripped it and used the rest; the pipeline re-stamps convergence\n"
2088
+ );
2089
+ return retry;
2090
+ }
2091
+ }
2092
+ return first;
2093
+ };
2031
2094
  var noticeMessageFor = (result) => {
2032
2095
  switch (result.kind) {
2033
2096
  case "corrupt":
@@ -2312,11 +2375,23 @@ var post = async (input, ghApi = runGhApi) => {
2312
2375
  const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
2313
2376
  const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
2314
2377
  const emptyMechanicWouldBury = (route, incomplete) => route === "mechanic" && !incomplete && findings.findings.length === 0 && existingSticky !== null && priorIsFullReview(existingSticky.body);
2315
- const priorRounds = existingSticky !== null ? parseRounds(existingSticky.body) : [];
2316
- const priorSignal = existingSticky === null ? null : parseSignalMarker(existingSticky.body) ?? parseSurfaceSignal(parseFindingsMarker(existingSticky.body));
2317
- const findingsMarkerFor = (findings2, signal2) => {
2318
- const pointer = surfacedFindingsPointer(findings2, signal2, input.jsonUrl);
2319
- return signal2 !== null || priorSignal === null ? pointer : joinSignalMarker(pointer, signalMarker(priorSignal));
2378
+ const priorDoc = existingSticky !== null ? parseFindingsMarker(existingSticky.body) : null;
2379
+ const priorBody = existingSticky?.body ?? "";
2380
+ const priorTraj = priorTrajectory(priorDoc, priorBody);
2381
+ const priorConv = carriedConvergence(priorDoc, priorBody);
2382
+ const priorRoundCount = nextRoundNumber(priorTraj, priorConv?.rounds ?? []) - 1;
2383
+ const stampConvergence = (doc, conv) => ({
2384
+ ...doc,
2385
+ convergence: conv ?? void 0
2386
+ });
2387
+ const findingsBlob = (doc) => {
2388
+ const marker = findingsPointer(doc, input.jsonUrl);
2389
+ if (doc.convergence === void 0 || findingsMarkerForm(doc, input.jsonUrl) === "embedded") {
2390
+ return marker;
2391
+ }
2392
+ const conv = convergenceMarker(doc.convergence);
2393
+ return marker === "" ? conv : `${marker}
2394
+ ${conv}`;
2320
2395
  };
2321
2396
  const leaveInPlace = (message) => {
2322
2397
  process.stderr.write(
@@ -2360,7 +2435,7 @@ ${dropNote}` : ""}`,
2360
2435
  const template = readFileSync(input.templatePath, "utf-8");
2361
2436
  const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
2362
2437
  const renderNotice = (message) => {
2363
- const findings2 = incompleteFindings(`### \u26A0\uFE0F ${message}`);
2438
+ const findings2 = stampConvergence(incompleteFindings(`### \u26A0\uFE0F ${message}`), priorConv);
2364
2439
  return formatMarkdown(
2365
2440
  render({
2366
2441
  findings: findings2,
@@ -2372,17 +2447,12 @@ ${dropNote}` : ""}`,
2372
2447
  route: input.route,
2373
2448
  reviewedSha: input.headSha,
2374
2449
  effort: input.effort,
2375
- rounds: priorRounds,
2376
2450
  sameRootNotes: {},
2377
- roundCount: priorSignal?.round ?? priorRounds.length,
2451
+ roundCount: priorRoundCount,
2378
2452
  convergenceRound: false,
2379
2453
  runUrl: input.runUrl,
2380
2454
  jsonUrl: input.jsonUrl,
2381
- // A notice's own blob stays clean: verdict "error" + a carried "converged" would read as a
2382
- // stop signal for a run that produced no verdict (issue #141 review r2). The prior signal
2383
- // survives on the sticky in the compact marker (findingsMarkerFor), and the carried-forward
2384
- // trajectory (rounds marker) remains the historical record.
2385
- findingsPointer: findingsMarkerFor(findings2, null),
2455
+ findingsPointer: findingsBlob(findings2),
2386
2456
  postedAt: input.postedAt
2387
2457
  })
2388
2458
  );
@@ -2450,9 +2520,10 @@ ${dropNote}` : ""}`,
2450
2520
  }
2451
2521
  if (emptyMechanicWouldBury(effectiveRoute, envelopelessIncomplete) && existingSticky !== null)
2452
2522
  await emptyMechanicLeaveOrNote(existingSticky);
2523
+ const stampedFindings2 = stampConvergence(findings, priorConv);
2453
2524
  const body = formatMarkdown(
2454
2525
  render({
2455
- findings,
2526
+ findings: stampedFindings2,
2456
2527
  envelope: null,
2457
2528
  incomplete: envelopelessIncomplete,
2458
2529
  prices: decodedPrices.right,
@@ -2461,7 +2532,6 @@ ${dropNote}` : ""}`,
2461
2532
  route: effectiveRoute,
2462
2533
  reviewedSha: input.headSha,
2463
2534
  effort: input.effort,
2464
- rounds: priorRounds,
2465
2535
  sameRootNotes: {},
2466
2536
  // The answered-state honesty rules apply on EVERY surface that renders the filtered
2467
2537
  // findings — the lost-envelope branch lists every VISIBLE finding (no inline review exists to
@@ -2475,18 +2545,13 @@ ${dropNote}` : ""}`,
2475
2545
  nitVisibilityFloor: input.nitVisibilityFloor,
2476
2546
  answeredNotes: reRaisedNotes,
2477
2547
  answeredReRaiseNote: answeredDropNote,
2478
- roundCount: priorSignal?.round ?? priorRounds.length,
2548
+ roundCount: priorRoundCount,
2479
2549
  convergenceRound: false,
2480
2550
  testReport,
2481
2551
  inlineDisposition: { kind: "no-envelope" },
2482
2552
  runUrl: input.runUrl,
2483
2553
  jsonUrl: input.jsonUrl,
2484
- // Same signal rule as the main path: a completed-review doc and an error-verdict doc alike
2485
- // ride the prior signal on the compact marker, never inside the blob.
2486
- findingsPointer: findingsMarkerFor(
2487
- findings,
2488
- isReviewVerdict(findings.verdict) ? priorSignal : null
2489
- ),
2554
+ findingsPointer: findingsBlob(stampedFindings2),
2490
2555
  postedAt: input.postedAt
2491
2556
  })
2492
2557
  );
@@ -2504,7 +2569,7 @@ ${dropNote}` : ""}`,
2504
2569
  if (emptyMechanicWouldBury(effectiveRoute, thisIncomplete) && existingSticky !== null)
2505
2570
  await emptyMechanicLeaveOrNote(existingSticky);
2506
2571
  const isRound = isConvergenceRound(effectiveRoute, thisIncomplete) && isReviewVerdict(findings.verdict);
2507
- const sameRootNotes = isRound ? computeSameRootNotes(priorRounds, findings.findings, input.headSha.slice(0, 12)) : {};
2572
+ const sameRootNotes = isRound ? computeSameRootNotes(priorTraj, findings.findings, input.headSha.slice(0, 12)) : {};
2508
2573
  const {
2509
2574
  comments: rawComments,
2510
2575
  strays,
@@ -2528,35 +2593,30 @@ ${dropNote}` : ""}`,
2528
2593
  const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
2529
2594
  const currentCounts = computeSeverityCounts(findings.findings);
2530
2595
  const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
2531
- const priorLastCodes = priorRounds.length > 0 ? priorRounds[priorRounds.length - 1]?.codes : void 0;
2532
- const roundNumber = Math.max(priorSignal?.round ?? priorRounds.length, priorRounds.length) + 1;
2533
- const rounds = isRound ? [
2534
- ...priorRounds,
2535
- roundRecord(
2536
- computeRoundCounts(findings),
2537
- currentCodes,
2538
- priorLastCodes,
2539
- input.headSha.slice(0, 12),
2540
- roundNumber
2541
- )
2542
- ] : priorRounds;
2543
- const signal = isRound ? signalForRound(roundNumber, findings, input.convergenceThreshold) : thisIncomplete || !isReviewVerdict(findings.verdict) ? null : priorSignal;
2544
- const findingsMarker = findingsMarkerFor(findings, signal);
2545
- const markerForm = findingsMarkerForm(findings, input.jsonUrl);
2546
- const signalNote = signal !== null || priorSignal !== null ? " (the stop signal still rides the compact marker)" : "";
2596
+ const roundNumber = priorRoundCount + 1;
2597
+ const convergence = isRound ? buildConvergence(
2598
+ findings,
2599
+ input.convergenceThreshold,
2600
+ priorTraj,
2601
+ roundNumber,
2602
+ currentCodes,
2603
+ input.headSha.slice(0, 12)
2604
+ ) : priorConv;
2605
+ const stampedFindings = stampConvergence(findings, convergence);
2606
+ const currentRoundCount = isRound ? roundNumber : priorRoundCount;
2607
+ const findingsMarker = findingsBlob(stampedFindings);
2608
+ const markerForm = findingsMarkerForm(stampedFindings, input.jsonUrl);
2547
2609
  if (markerForm === "link") {
2548
2610
  process.stderr.write(
2549
- `Warning: the findings-json blob exceeds the embed limit \u2014 degraded to the jsonUrl-link form; a decoding agent must fetch the artifact for the findings${signalNote}
2550
- `
2611
+ "Warning: the findings-json blob exceeds the embed limit \u2014 degraded to the jsonUrl-link form; the convergence rides a compact marker beside it, but a decoding agent (and the next-round seed) must fetch the artifact for the FINDINGS\n"
2551
2612
  );
2552
2613
  } else if (markerForm === "omitted") {
2553
2614
  process.stderr.write(
2554
- `Warning: the findings-json blob exceeds the embed limit and no --json-url was given \u2014 the embedded findings seed is dropped from the posted surfaces${signalNote}
2555
- `
2615
+ "Warning: the findings-json blob exceeds the embed limit and no --json-url was given \u2014 the convergence rides a compact marker but the embedded findings seed is dropped from the posted surfaces\n"
2556
2616
  );
2557
2617
  }
2558
2618
  const commonRenderInput = {
2559
- findings,
2619
+ findings: stampedFindings,
2560
2620
  envelope,
2561
2621
  incomplete: thisIncomplete,
2562
2622
  prices: decodedPrices.right,
@@ -2567,11 +2627,10 @@ ${dropNote}` : ""}`,
2567
2627
  effort: input.effort,
2568
2628
  testReport,
2569
2629
  severityCounts: currentCounts,
2570
- rounds,
2571
2630
  sameRootNotes,
2572
2631
  answeredNotes: reRaisedNotes,
2573
2632
  answeredReRaiseNote: answeredDropNote,
2574
- roundCount: signal?.round ?? priorSignal?.round ?? priorRounds.length,
2633
+ roundCount: currentRoundCount,
2575
2634
  convergenceThreshold: input.convergenceThreshold,
2576
2635
  nitVisibilityFloor: input.nitVisibilityFloor,
2577
2636
  convergenceRound: isRound,
@@ -2665,12 +2724,22 @@ var bodyRefsRun = (body, runUrl) => {
2665
2724
  const runId = runIdFromUrl(runUrl);
2666
2725
  return runId === null ? body.includes(runUrl) : new RegExp(`/actions/runs/${runId}(?!\\d)`).test(body);
2667
2726
  };
2668
- var announceBody = (headSha, runUrl, existingBody) => noticeBody(
2669
- `${DEFAULT_MARKER}
2727
+ var announceBody = (headSha, runUrl, existingBody) => {
2728
+ const priorDoc = existingBody !== void 0 ? parseFindingsMarker(existingBody) : null;
2729
+ const prior = existingBody !== void 0 ? carriedConvergence(priorDoc, existingBody) : null;
2730
+ const progress = prior !== null ? inProgressConvergence(
2731
+ prior,
2732
+ nextRoundNumber(priorTrajectory(priorDoc, existingBody ?? ""), prior.rounds ?? [])
2733
+ ) : "";
2734
+ return noticeBody(
2735
+ `${DEFAULT_MARKER}
2670
2736
 
2671
- \u{1F504} **Code review in progress** for \`${headSha.slice(0, 7)}\` \u2014 see the [workflow run](${runUrl}) for progress; this comment is updated with the review when it completes.`,
2672
- existingBody
2673
- );
2737
+ \u{1F504} **Code review in progress** for \`${headSha.slice(0, 7)}\` \u2014 see the [workflow run](${runUrl}) for progress; this comment is updated with the review when it completes.${progress ? `
2738
+
2739
+ ${progress}` : ""}`,
2740
+ existingBody
2741
+ );
2742
+ };
2674
2743
  var announce = async (input, ghApi = runGhApi) => {
2675
2744
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
2676
2745
  const resolution = resolvePr(candidates, input.headBranch);
@@ -3794,9 +3863,13 @@ var renderCmd = defineCommand({
3794
3863
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
3795
3864
  const route = args.route || envelope.route || null;
3796
3865
  const isRound = isConvergenceRound(route, envelope.incomplete === true || isIncompleteFindings(findings)) && isReviewVerdict(findings.verdict);
3797
- const counts = computeRoundCounts(findings);
3866
+ const threshold = parseConvergenceThreshold(args["convergence-threshold"]);
3867
+ const stampedFindings = {
3868
+ ...findings,
3869
+ convergence: isRound ? buildConvergence(findings, threshold) : void 0
3870
+ };
3798
3871
  const output2 = render({
3799
- findings,
3872
+ findings: stampedFindings,
3800
3873
  envelope,
3801
3874
  prices,
3802
3875
  pricesProvided: priceResolution.kind === "provided",
@@ -3805,9 +3878,9 @@ var renderCmd = defineCommand({
3805
3878
  route: args.route,
3806
3879
  effort: args.effort,
3807
3880
  testReport,
3808
- rounds: isRound ? [counts] : [],
3809
- convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
3881
+ convergenceThreshold: threshold,
3810
3882
  nitVisibilityFloor: parseNitVisibilityFloor(args["nit-visibility-floor"]),
3883
+ convergenceRound: isRound,
3811
3884
  postedAt: formatUtc(/* @__PURE__ */ new Date())
3812
3885
  });
3813
3886
  process.stdout.write(output2);
@@ -4249,6 +4322,7 @@ ${printableSchema(schemaPath)}
4249
4322
  }
4250
4323
  });
4251
4324
  var isSurfaceStampedDoc = (doc) => typeof doc === "object" && doc !== null && !Array.isArray(doc) && doc["schema_version"] === SURFACE_SCHEMA_VERSION;
4325
+ var PIPELINE_STAMPED_FIELDS2 = /* @__PURE__ */ new Set(["scope_metastasis", "convergence"]);
4252
4326
  var withoutScopeMetastasis = (doc) => typeof doc === "object" && doc !== null && !Array.isArray(doc) ? Object.fromEntries(Object.entries(doc).filter(([key2]) => key2 !== "scope_metastasis")) : doc;
4253
4327
  var seedDraftCmd = defineCommand({
4254
4328
  meta: {
@@ -4337,7 +4411,7 @@ var seedDraftCmd = defineCommand({
4337
4411
  const carried = strippedPrior["scope_metastasis"];
4338
4412
  if (ScopeMetastasisCodec.decode(carried)._tag === "Right") return strippedPrior;
4339
4413
  if (strippedPrior["verdict"] === "error") return strippedPrior;
4340
- const computed = computeScopeMetastasis(parseRounds(priorBody ?? ""));
4414
+ const computed = computeScopeMetastasis(priorTrajectory(parsedPrior, priorBody ?? ""));
4341
4415
  return computed === null ? strippedPrior : { ...strippedPrior, scope_metastasis: computed };
4342
4416
  })();
4343
4417
  if (args["prior-answers"]) {
@@ -4393,7 +4467,7 @@ var seedDraftCmd = defineCommand({
4393
4467
  const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
4394
4468
  const barePrior = typeof priorFindings === "object" && !Array.isArray(priorFindings) ? Object.fromEntries(
4395
4469
  Object.entries(priorFindings).filter(
4396
- ([key2]) => key2 !== "scope_metastasis"
4470
+ ([key2]) => !PIPELINE_STAMPED_FIELDS2.has(key2)
4397
4471
  )
4398
4472
  ) : priorFindings;
4399
4473
  const accepts = (doc) => validateAgainstSchema(doc, schemaPath).valid && resolve("findings", doc).kind === "ok";