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

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);
@@ -469,30 +517,12 @@ var parseRounds = (body) => {
469
517
  }
470
518
  return kept;
471
519
  };
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;
520
+ var formatScore = (score) => formatConfidence(score);
521
+ var TRAJECTORY_SCORES = 8;
492
522
  var roundsSummary = (rounds, count = rounds.length) => {
493
523
  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 ");
524
+ const cells = rounds.slice(-TRAJECTORY_SCORES).map((r) => typeof r.score === "number" ? formatScore(r.score) : "\u2014");
525
+ const trajectory = cells.length === 0 ? "" : rounds.length > TRAJECTORY_SCORES ? `\u2026 \u2192 ${cells.join(" \u2192 ")}` : cells.join(" \u2192 ");
496
526
  return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
497
527
  };
498
528
  var computeCodeCounts = (findings, systemic = []) => {
@@ -506,15 +536,6 @@ var computeCodeCounts = (findings, systemic = []) => {
506
536
  }
507
537
  return Object.fromEntries(counts);
508
538
  };
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
539
  var consecutiveCodeStreaks = (rounds) => {
519
540
  const entries = [];
520
541
  if (rounds.length === 0) return {};
@@ -608,6 +629,18 @@ var convergenceScore = (doc, threshold) => round2(
608
629
  0
609
630
  )
610
631
  );
632
+ var buildConvergence = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD, priorRounds = [], round = 1, codes = {}, sha) => {
633
+ const score = convergenceScore(doc, threshold);
634
+ const normalized = normalizeCodeCounts(codes, priorRounds[priorRounds.length - 1]?.codes);
635
+ const current = {
636
+ round,
637
+ score,
638
+ ...normalized !== void 0 ? { codes: { ...normalized } } : {},
639
+ ...sha !== void 0 ? { sha } : {}
640
+ };
641
+ const rounds = [...priorRounds, current].slice(-64);
642
+ return { score, threshold, converged: score <= threshold, rounds };
643
+ };
611
644
  var DEFAULT_NIT_VISIBILITY_FLOOR = 0.25;
612
645
  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
646
  var priorBelowFloorNits = (priorDoc, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => {
@@ -631,26 +664,13 @@ var priorBelowFloorNits = (priorDoc, floor = DEFAULT_NIT_VISIBILITY_FLOOR) => {
631
664
  }
632
665
  return nits;
633
666
  };
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`;
637
- };
667
+ 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`;
668
+ var convergenceSummary = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => convergenceBadge(convergenceSignal(doc, threshold));
638
669
  var SURFACE_SCHEMA_VERSION = "0.8.0";
639
670
  var convergenceSignal = (doc, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
640
671
  const score = convergenceScore(doc, threshold);
641
672
  return { score, threshold, converged: score <= threshold };
642
673
  };
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
674
  var SIGNAL_RE = /<!-- code-review:signal;base64 ([A-Za-z0-9+/=]+) -->/;
655
675
  var parseSignalMarker = (body) => {
656
676
  const b64 = SIGNAL_RE.exec(body)?.[1];
@@ -674,6 +694,39 @@ var parseSurfaceSignal = (doc) => {
674
694
  convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
675
695
  };
676
696
  };
697
+ var validStampedConvergence = (raw) => {
698
+ const decoded = ConvergenceCodec.decode(raw);
699
+ return decoded._tag === "Right" && decoded.right.rounds !== void 0 && decoded.right.rounds.length > 0 ? decoded.right : null;
700
+ };
701
+ var parseConvergence = (priorDoc) => {
702
+ if (typeof priorDoc !== "object" || priorDoc === null) return null;
703
+ const raw = priorDoc["convergence"];
704
+ return raw === void 0 ? null : validStampedConvergence(raw);
705
+ };
706
+ var CONVERGENCE_RE = /<!-- code-review:convergence;base64 ([A-Za-z0-9+/=]+) -->/;
707
+ var convergenceMarker = (convergence) => `<!-- code-review:convergence;base64 ${Buffer.from(JSON.stringify(convergence), "utf-8").toString(
708
+ "base64"
709
+ )} -->`;
710
+ var parseConvergenceMarker = (body) => {
711
+ const b64 = CONVERGENCE_RE.exec(body)?.[1];
712
+ return b64 === void 0 ? null : validStampedConvergence(decodeBase64Json(b64));
713
+ };
714
+ var roundRecordsToConvergenceRounds = (records) => records.map((r, i) => ({
715
+ round: r.round ?? i + 1,
716
+ ...r.codes !== void 0 ? { codes: { ...r.codes } } : {},
717
+ ...r.sha !== void 0 ? { sha: r.sha } : {}
718
+ }));
719
+ var priorTrajectory = (priorDoc, priorBody) => parseConvergence(priorDoc)?.rounds ?? parseConvergenceMarker(priorBody)?.rounds ?? roundRecordsToConvergenceRounds(parseRounds(priorBody));
720
+ var carriedConvergence = (priorDoc, priorBody) => {
721
+ const stamped = parseConvergence(priorDoc) ?? parseConvergenceMarker(priorBody);
722
+ if (stamped !== null) return stamped;
723
+ const signal = parseSignalMarker(priorBody) ?? parseSurfaceSignal(priorDoc);
724
+ if (signal === null) return null;
725
+ const legacy = roundRecordsToConvergenceRounds(parseRounds(priorBody));
726
+ const last = legacy[legacy.length - 1];
727
+ const rounds = last === void 0 ? [{ round: signal.round }] : last.round < signal.round ? [...legacy.slice(0, -1), { ...last, round: signal.round }] : legacy;
728
+ return { ...signal.convergence, rounds };
729
+ };
677
730
  var SURFACE_SCHEMA_VERSIONS = ["0.7.0", SURFACE_SCHEMA_VERSION];
678
731
  var isSurfaceVersion = (version) => typeof version === "string" && SURFACE_SCHEMA_VERSIONS.includes(version);
679
732
  var stripSurfaceFields = (doc) => {
@@ -689,12 +742,13 @@ var carryForwardMarkers = (body) => {
689
742
  const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
690
743
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
691
744
  const reviewedRoute = ROUTE_RE.exec(body)?.[0];
745
+ const convergence = CONVERGENCE_RE.exec(body)?.[0];
692
746
  const rounds = ROUNDS_RE.exec(body)?.[0];
693
747
  const signal = SIGNAL_RE.exec(body)?.[0];
694
748
  const completedAncestor = parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
695
749
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
696
750
  ${findings}` : void 0;
697
- return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, rounds, signal].filter((m) => m !== void 0).join("\n\n");
751
+ return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, convergence, rounds, signal].filter((m) => m !== void 0).join("\n\n");
698
752
  };
699
753
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
700
754
  var projectPatch = (patch) => {
@@ -1071,10 +1125,6 @@ var computeSeverityCounts = (findings) => findings.reduce(
1071
1125
  emptySeverityCounts()
1072
1126
  );
1073
1127
  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
1128
  var isConvergenceRound = (route, incomplete) => route === "full review" && !incomplete;
1079
1129
  var render = (input) => {
1080
1130
  const eta = new Eta({ autoTrim: false });
@@ -1087,9 +1137,10 @@ var render = (input) => {
1087
1137
  const effort = input.effort ?? input.envelope?.effort ?? null;
1088
1138
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
1089
1139
  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);
1140
+ const convergence = input.findings.convergence;
1141
+ const trajectory = convergence?.rounds ?? input.rounds ?? [];
1142
+ const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(trajectory.slice(0, -1), input.findings.findings);
1143
+ const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && trajectory.length > 0)) && isReviewVerdict(input.findings.verdict);
1093
1144
  const advisoryAllowed = isFullReviewRound;
1094
1145
  return eta.renderString(input.template, {
1095
1146
  findings: input.findings,
@@ -1106,7 +1157,7 @@ var render = (input) => {
1106
1157
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
1107
1158
  postedAt: input.postedAt ?? "",
1108
1159
  severityCounts,
1109
- convergenceSummary: isFullReviewRound ? convergenceSummary(input.findings, input.convergenceThreshold) : "",
1160
+ convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold),
1110
1161
  strays: (input.strays ?? []).map((f) => sanitizeFinding(f, input.answeredNotes)),
1111
1162
  suppressedNits: (input.suppressedNits ?? []).map(sanitizeSuppressedNit),
1112
1163
  nitVisibilityFloor: input.nitVisibilityFloor ?? DEFAULT_NIT_VISIBILITY_FLOOR,
@@ -1115,19 +1166,13 @@ var render = (input) => {
1115
1166
  inlineDisposition: input.inlineDisposition ?? null,
1116
1167
  runUrl: input.runUrl ?? null,
1117
1168
  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) : "",
1169
+ // The blob is the agent's complete document with the pipeline-stamped convergence field inside it
1170
+ // (issue #174) — no separate signal or rounds marker rides beside it. post always supplies the
1171
+ // precomputed marker; the standalone `render` command falls back to encoding the doc here.
1172
+ findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
1173
+ roundsMarker: "",
1174
+ roundsSummary: roundsSummary(trajectory, input.roundCount),
1175
+ metastasisNote: advisoryAllowed ? metastasisNote(trajectory) : "",
1131
1176
  sameRootNotes: advisoryAllowed ? sameRootNotes : {},
1132
1177
  answeredNotes: input.answeredNotes ?? {},
1133
1178
  answeredReRaiseNote: input.answeredReRaiseNote ?? "",
@@ -2010,14 +2055,9 @@ var checkLongSuggestions = (comments) => {
2010
2055
  });
2011
2056
  return { comments: adjusted, longFiles };
2012
2057
  };
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);
2058
+ var PIPELINE_STAMPED_FIELDS = /* @__PURE__ */ new Set(["convergence", "scope_metastasis"]);
2059
+ var decodeFindings = (doc) => {
2060
+ const resolution = resolve("findings", doc);
2021
2061
  switch (resolution.kind) {
2022
2062
  case "ok":
2023
2063
  return { kind: "ok", findings: resolution.value };
@@ -2028,6 +2068,28 @@ var loadFindings = (path) => {
2028
2068
  return { kind: "invalid-shape" };
2029
2069
  }
2030
2070
  };
2071
+ var loadFindings = (path) => {
2072
+ let raw;
2073
+ try {
2074
+ raw = JSON.parse(readFileSync(path, "utf-8"));
2075
+ } catch {
2076
+ return { kind: "corrupt" };
2077
+ }
2078
+ const first = decodeFindings(raw);
2079
+ if (first.kind === "invalid-shape" && typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.keys(raw).some((k) => PIPELINE_STAMPED_FIELDS.has(k))) {
2080
+ const stripped = Object.fromEntries(
2081
+ Object.entries(raw).filter(([key2]) => !PIPELINE_STAMPED_FIELDS.has(key2))
2082
+ );
2083
+ const retry = decodeFindings(stripped);
2084
+ if (retry.kind === "ok") {
2085
+ process.stderr.write(
2086
+ "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"
2087
+ );
2088
+ return retry;
2089
+ }
2090
+ }
2091
+ return first;
2092
+ };
2031
2093
  var noticeMessageFor = (result) => {
2032
2094
  switch (result.kind) {
2033
2095
  case "corrupt":
@@ -2312,11 +2374,24 @@ var post = async (input, ghApi = runGhApi) => {
2312
2374
  const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
2313
2375
  const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
2314
2376
  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));
2377
+ const priorDoc = existingSticky !== null ? parseFindingsMarker(existingSticky.body) : null;
2378
+ const priorBody = existingSticky?.body ?? "";
2379
+ const priorTraj = priorTrajectory(priorDoc, priorBody);
2380
+ const priorConv = carriedConvergence(priorDoc, priorBody);
2381
+ const lastRound = (rounds) => rounds.length > 0 ? rounds[rounds.length - 1]?.round ?? rounds.length : 0;
2382
+ const priorRoundCount = Math.max(lastRound(priorTraj), lastRound(priorConv?.rounds ?? []));
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,
@@ -3794,9 +3853,13 @@ var renderCmd = defineCommand({
3794
3853
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
3795
3854
  const route = args.route || envelope.route || null;
3796
3855
  const isRound = isConvergenceRound(route, envelope.incomplete === true || isIncompleteFindings(findings)) && isReviewVerdict(findings.verdict);
3797
- const counts = computeRoundCounts(findings);
3856
+ const threshold = parseConvergenceThreshold(args["convergence-threshold"]);
3857
+ const stampedFindings = {
3858
+ ...findings,
3859
+ convergence: isRound ? buildConvergence(findings, threshold) : void 0
3860
+ };
3798
3861
  const output2 = render({
3799
- findings,
3862
+ findings: stampedFindings,
3800
3863
  envelope,
3801
3864
  prices,
3802
3865
  pricesProvided: priceResolution.kind === "provided",
@@ -3805,9 +3868,9 @@ var renderCmd = defineCommand({
3805
3868
  route: args.route,
3806
3869
  effort: args.effort,
3807
3870
  testReport,
3808
- rounds: isRound ? [counts] : [],
3809
- convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
3871
+ convergenceThreshold: threshold,
3810
3872
  nitVisibilityFloor: parseNitVisibilityFloor(args["nit-visibility-floor"]),
3873
+ convergenceRound: isRound,
3811
3874
  postedAt: formatUtc(/* @__PURE__ */ new Date())
3812
3875
  });
3813
3876
  process.stdout.write(output2);
@@ -4249,6 +4312,7 @@ ${printableSchema(schemaPath)}
4249
4312
  }
4250
4313
  });
4251
4314
  var isSurfaceStampedDoc = (doc) => typeof doc === "object" && doc !== null && !Array.isArray(doc) && doc["schema_version"] === SURFACE_SCHEMA_VERSION;
4315
+ var PIPELINE_STAMPED_FIELDS2 = /* @__PURE__ */ new Set(["scope_metastasis", "convergence"]);
4252
4316
  var withoutScopeMetastasis = (doc) => typeof doc === "object" && doc !== null && !Array.isArray(doc) ? Object.fromEntries(Object.entries(doc).filter(([key2]) => key2 !== "scope_metastasis")) : doc;
4253
4317
  var seedDraftCmd = defineCommand({
4254
4318
  meta: {
@@ -4337,7 +4401,7 @@ var seedDraftCmd = defineCommand({
4337
4401
  const carried = strippedPrior["scope_metastasis"];
4338
4402
  if (ScopeMetastasisCodec.decode(carried)._tag === "Right") return strippedPrior;
4339
4403
  if (strippedPrior["verdict"] === "error") return strippedPrior;
4340
- const computed = computeScopeMetastasis(parseRounds(priorBody ?? ""));
4404
+ const computed = computeScopeMetastasis(priorTrajectory(parsedPrior, priorBody ?? ""));
4341
4405
  return computed === null ? strippedPrior : { ...strippedPrior, scope_metastasis: computed };
4342
4406
  })();
4343
4407
  if (args["prior-answers"]) {
@@ -4393,7 +4457,7 @@ var seedDraftCmd = defineCommand({
4393
4457
  const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
4394
4458
  const barePrior = typeof priorFindings === "object" && !Array.isArray(priorFindings) ? Object.fromEntries(
4395
4459
  Object.entries(priorFindings).filter(
4396
- ([key2]) => key2 !== "scope_metastasis"
4460
+ ([key2]) => !PIPELINE_STAMPED_FIELDS2.has(key2)
4397
4461
  )
4398
4462
  ) : priorFindings;
4399
4463
  const accepts = (doc) => validateAgainstSchema(doc, schemaPath).valid && resolve("findings", doc).kind === "ok";