@jphutchins/code-review 0.1.0-alpha.36 → 0.1.0-alpha.38

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
@@ -4,14 +4,151 @@ import { readFileSync, writeFileSync, statSync, copyFileSync, readdirSync } from
4
4
  import { randomBytes } from 'crypto';
5
5
  import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
6
6
  import { Eta } from 'eta';
7
+ import * as t from 'io-ts';
7
8
  import parseDiff from 'parse-diff';
8
9
  import { Ajv2020 } from 'ajv/dist/2020.js';
9
10
  import _addFormats from 'ajv-formats';
10
- import * as t from 'io-ts';
11
11
  import { execFile } from 'child_process';
12
12
  import { performance } from 'perf_hooks';
13
13
  import { PathReporter } from 'io-ts/lib/PathReporter.js';
14
14
 
15
+ var SeverityCodec = t.union([
16
+ t.literal("critical"),
17
+ t.literal("major"),
18
+ t.literal("minor"),
19
+ t.literal("nit")
20
+ ]);
21
+ var SideCodec = t.union([t.literal("RIGHT"), t.literal("LEFT")]);
22
+ var VerdictCodec = t.union([
23
+ t.literal("approve"),
24
+ t.literal("comment"),
25
+ t.literal("changes"),
26
+ t.literal("error")
27
+ ]);
28
+ var LineNumber = t.refinement(
29
+ t.number,
30
+ (n) => Number.isInteger(n) && n >= 1,
31
+ "LineNumber"
32
+ );
33
+ var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
34
+ 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-]+)*)?$/;
35
+ var SchemaVersion = t.refinement(
36
+ t.string,
37
+ (s) => SCHEMA_VERSION_RE.test(s),
38
+ "SchemaVersion"
39
+ );
40
+ var FindingShape = t.intersection([
41
+ t.type({
42
+ path: t.string,
43
+ start_line: LineNumber,
44
+ end_line: LineNumber,
45
+ severity: SeverityCodec,
46
+ title: t.string,
47
+ description: t.string,
48
+ reasoning: t.string,
49
+ confidence: Confidence
50
+ }),
51
+ t.partial({
52
+ side: SideCodec,
53
+ code: t.string,
54
+ code_url: t.string,
55
+ recommendation: t.string,
56
+ patch: t.string
57
+ })
58
+ ]);
59
+ var EndGeStart = t.refinement(
60
+ FindingShape,
61
+ (f) => f.end_line >= f.start_line,
62
+ "EndGeStart"
63
+ );
64
+ var FindingCodec = t.exact(EndGeStart);
65
+ var FindingsCodec = t.exact(
66
+ t.type({
67
+ schema_version: SchemaVersion,
68
+ summary: t.string,
69
+ verdict: VerdictCodec,
70
+ findings: t.array(FindingCodec)
71
+ })
72
+ );
73
+ var TriageCodec = t.type({
74
+ safe: t.boolean,
75
+ reasons: t.string
76
+ });
77
+ var TokenCount = t.refinement(
78
+ t.number,
79
+ (n) => Number.isInteger(n) && n >= 0,
80
+ "TokenCount"
81
+ );
82
+ var ModelUsageEntryCodec = t.intersection([
83
+ t.type({
84
+ model: t.string,
85
+ input_tokens: TokenCount,
86
+ output_tokens: TokenCount
87
+ }),
88
+ t.partial({
89
+ cache_read_tokens: TokenCount,
90
+ cache_write_tokens: TokenCount
91
+ })
92
+ ]);
93
+ var ResultEnvelopeCodec = t.intersection([
94
+ t.type({
95
+ schema_version: t.string,
96
+ findings: FindingsCodec,
97
+ models: t.array(ModelUsageEntryCodec),
98
+ turns: TokenCount,
99
+ duration_ms: TokenCount
100
+ }),
101
+ t.partial({
102
+ vendor_cost_usd: t.union([t.number, t.null]),
103
+ route: t.string,
104
+ effort: t.string,
105
+ // The run produced a notice rather than a completed review (security-gate block, agent kill, no
106
+ // recoverable findings). An empty `findings` array alone can't say this — a genuine clean review
107
+ // is also empty — so the render suppresses "clean review" and the sticky precedence guard refuses
108
+ // to bury a completed review under it. Absent ⇒ a completed review.
109
+ incomplete: t.boolean
110
+ })
111
+ ]);
112
+ var ModelPricesCodec = t.type({
113
+ in: t.number,
114
+ out: t.number,
115
+ cache_read: t.number,
116
+ cache_write: t.number
117
+ });
118
+ var PriceMapCodec = t.type({
119
+ _updated: t.string,
120
+ _unit: t.string,
121
+ models: t.record(t.string, ModelPricesCodec)
122
+ });
123
+ var TestFailureCodec = t.intersection([
124
+ t.type({ name: t.string }),
125
+ t.partial({ message: t.string })
126
+ ]);
127
+ var TestSummaryCodec = t.intersection([
128
+ t.type({
129
+ passed: t.number,
130
+ failed: t.number,
131
+ total: t.number
132
+ }),
133
+ t.partial({
134
+ failures: t.array(TestFailureCodec)
135
+ })
136
+ ]);
137
+ var DEFAULT_SCHEMA_VERSION = "0.5.0";
138
+ var emptyFindings = (summary) => ({
139
+ schema_version: DEFAULT_SCHEMA_VERSION,
140
+ summary,
141
+ verdict: "comment",
142
+ findings: []
143
+ });
144
+ var incompleteFindings = (summary) => ({
145
+ schema_version: DEFAULT_SCHEMA_VERSION,
146
+ summary,
147
+ verdict: "error",
148
+ findings: []
149
+ });
150
+ var isIncompleteFindings = (findings) => findings.verdict === "error" && findings.findings.length === 0;
151
+
15
152
  // src/cost.ts
16
153
  var defaultWarn = (message) => {
17
154
  process.stderr.write(`${message}
@@ -179,6 +316,13 @@ var encodeMarker = (document, jsonUrl, limit) => {
179
316
  return marker ? `${AGENTS_STOP_DIRECTIVE}
180
317
  ${marker}` : "";
181
318
  };
319
+ var decodeBase64Json = (b64) => {
320
+ try {
321
+ return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
322
+ } catch {
323
+ return void 0;
324
+ }
325
+ };
182
326
  var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
183
327
  var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
184
328
  var ZERO_SHA = "0000000000000000000000000000000000000000";
@@ -189,21 +333,48 @@ var parseReviewedSha = (body) => {
189
333
  var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
190
334
  var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
191
335
  var parseFindingsMarker = (body) => {
192
- const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
193
- const b64 = match?.[1];
336
+ const b64 = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body)?.[1];
194
337
  if (b64 === void 0) return null;
195
- try {
196
- return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
197
- } catch {
198
- return null;
199
- }
338
+ return decodeBase64Json(b64) ?? null;
339
+ };
340
+ var ROUNDS_RE = /<!-- code-review:rounds;base64 ([A-Za-z0-9+/=]+) -->/;
341
+ var SEVERITIES = ["critical", "major", "minor", "nit"];
342
+ var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.every((k) => {
343
+ const v = u[k];
344
+ return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
345
+ });
346
+ var parseRounds = (body) => {
347
+ const b64 = ROUNDS_RE.exec(body)?.[1];
348
+ if (b64 === void 0) return [];
349
+ const decoded = decodeBase64Json(b64);
350
+ return Array.isArray(decoded) ? decoded.filter(isSeverityCounts) : [];
351
+ };
352
+ var roundsMarker = (rounds) => rounds.length === 0 ? "" : `<!-- code-review:rounds;base64 ${Buffer.from(JSON.stringify(rounds), "utf-8").toString("base64")} -->`;
353
+ var roundChip = (c) => {
354
+ const parts = SEVERITIES.filter((k) => c[k] > 0).map((k) => `${severityEmoji(k)}${String(c[k])}`);
355
+ return parts.length === 0 ? "clean" : parts.join(" ");
356
+ };
357
+ var TRAJECTORY_CHIPS = 8;
358
+ var roundsSummary = (rounds) => {
359
+ if (rounds.length === 0) return "";
360
+ const chips = rounds.slice(-TRAJECTORY_CHIPS).map(roundChip);
361
+ const trajectory = rounds.length > TRAJECTORY_CHIPS ? `\u2026 \u2192 ${chips.join(" \u2192 ")}` : chips.join(" \u2192 ");
362
+ return `**Round ${String(rounds.length)}** \xB7 ${trajectory}`;
363
+ };
364
+ var CONVERGENCE_WEIGHTS = { critical: 4, major: 2, minor: 1, nit: 0 };
365
+ var DEFAULT_CONVERGENCE_THRESHOLD = 1;
366
+ var convergenceScore = (counts) => SEVERITIES.reduce((sum, k) => sum + counts[k] * CONVERGENCE_WEIGHTS[k], 0);
367
+ var convergenceSummary = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
368
+ const score = convergenceScore(counts);
369
+ return score <= threshold ? `**Convergence** \u{1F3C1} ${String(score)} \u2264 ${String(threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${String(score)} > ${String(threshold)} \u2014 iterating`;
200
370
  };
201
371
  var carryForwardMarkers = (body) => {
202
372
  const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
203
373
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
374
+ const rounds = ROUNDS_RE.exec(body)?.[0];
204
375
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
205
376
  ${findings}` : void 0;
206
- return [findingsBlock, reviewedSha].filter((m) => m !== void 0).join("\n\n");
377
+ return [findingsBlock, reviewedSha, rounds].filter((m) => m !== void 0).join("\n\n");
207
378
  };
208
379
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
209
380
  var projectPatch = (patch) => {
@@ -239,16 +410,20 @@ var computeSeverityCounts = (findings) => findings.reduce(
239
410
  (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
240
411
  emptySeverityCounts()
241
412
  );
413
+ var isConvergenceRound = (route, incomplete) => route === "full review" && !incomplete;
242
414
  var render = (input) => {
243
415
  const eta = new Eta({ autoTrim: false });
244
416
  const usageAvailable = input.envelope !== null;
245
417
  const hasUsage = input.envelope !== null && input.envelope.models.length > 0;
246
- const incomplete = input.incomplete ?? input.envelope?.incomplete ?? false;
418
+ const incomplete = (input.incomplete ?? input.envelope?.incomplete ?? false) || isIncompleteFindings(input.findings);
247
419
  const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
248
420
  const pricesProvided = input.pricesProvided ?? true;
249
421
  const route = input.route ?? input.envelope?.route ?? null;
250
422
  const effort = input.effort ?? input.envelope?.effort ?? null;
251
423
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
424
+ const severityCounts = input.severityCounts ?? computeSeverityCounts(input.findings.findings);
425
+ const rounds = input.rounds ?? [];
426
+ const isFullReviewRound = (input.convergenceRound ?? isConvergenceRound(route, incomplete)) && input.findings.verdict !== "error";
252
427
  return eta.renderString(input.template, {
253
428
  findings: input.findings,
254
429
  envelope: input.envelope,
@@ -263,13 +438,16 @@ var render = (input) => {
263
438
  testReport: input.testReport ?? null,
264
439
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
265
440
  postedAt: input.postedAt ?? "",
266
- severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
441
+ severityCounts,
442
+ convergenceSummary: isFullReviewRound ? convergenceSummary(severityCounts, input.convergenceThreshold) : "",
267
443
  strays: (input.strays ?? []).map(sanitizeFinding),
268
444
  unanchoredCount: input.unanchoredCount ?? 0,
269
445
  inlineDisposition: input.inlineDisposition ?? null,
270
446
  runUrl: input.runUrl ?? null,
271
447
  jsonUrl: input.jsonUrl ?? null,
272
448
  findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
449
+ roundsMarker: roundsMarker(rounds),
450
+ roundsSummary: roundsSummary(rounds),
273
451
  reviewUrl: input.reviewUrl ?? null,
274
452
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
275
453
  // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
@@ -287,6 +465,8 @@ var render = (input) => {
287
465
  return "\u{1F4AC} comment";
288
466
  case "changes":
289
467
  return "\u{1F527} changes requested";
468
+ case "error":
469
+ return "\u{1F6E0}\uFE0F no review verdict";
290
470
  default:
291
471
  return `\u2753 ${v}`;
292
472
  }
@@ -525,132 +705,6 @@ var readTranscriptTree = (mainPath) => {
525
705
  missing: mainText === null
526
706
  };
527
707
  };
528
- var SeverityCodec = t.union([
529
- t.literal("critical"),
530
- t.literal("major"),
531
- t.literal("minor"),
532
- t.literal("nit")
533
- ]);
534
- var SideCodec = t.union([t.literal("RIGHT"), t.literal("LEFT")]);
535
- var VerdictCodec = t.union([t.literal("approve"), t.literal("comment"), t.literal("changes")]);
536
- var LineNumber = t.refinement(
537
- t.number,
538
- (n) => Number.isInteger(n) && n >= 1,
539
- "LineNumber"
540
- );
541
- var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
542
- 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-]+)*)?$/;
543
- var SchemaVersion = t.refinement(
544
- t.string,
545
- (s) => SCHEMA_VERSION_RE.test(s),
546
- "SchemaVersion"
547
- );
548
- var FindingShape = t.intersection([
549
- t.type({
550
- path: t.string,
551
- start_line: LineNumber,
552
- end_line: LineNumber,
553
- severity: SeverityCodec,
554
- title: t.string,
555
- description: t.string,
556
- reasoning: t.string,
557
- confidence: Confidence
558
- }),
559
- t.partial({
560
- side: SideCodec,
561
- code: t.string,
562
- code_url: t.string,
563
- recommendation: t.string,
564
- patch: t.string
565
- })
566
- ]);
567
- var EndGeStart = t.refinement(
568
- FindingShape,
569
- (f) => f.end_line >= f.start_line,
570
- "EndGeStart"
571
- );
572
- var FindingCodec = t.exact(EndGeStart);
573
- var FindingsCodec = t.exact(
574
- t.type({
575
- schema_version: SchemaVersion,
576
- summary: t.string,
577
- verdict: VerdictCodec,
578
- findings: t.array(FindingCodec)
579
- })
580
- );
581
- var TriageCodec = t.type({
582
- safe: t.boolean,
583
- reasons: t.string
584
- });
585
- var TokenCount = t.refinement(
586
- t.number,
587
- (n) => Number.isInteger(n) && n >= 0,
588
- "TokenCount"
589
- );
590
- var ModelUsageEntryCodec = t.intersection([
591
- t.type({
592
- model: t.string,
593
- input_tokens: TokenCount,
594
- output_tokens: TokenCount
595
- }),
596
- t.partial({
597
- cache_read_tokens: TokenCount,
598
- cache_write_tokens: TokenCount
599
- })
600
- ]);
601
- var ResultEnvelopeCodec = t.intersection([
602
- t.type({
603
- schema_version: t.string,
604
- findings: FindingsCodec,
605
- models: t.array(ModelUsageEntryCodec),
606
- turns: TokenCount,
607
- duration_ms: TokenCount
608
- }),
609
- t.partial({
610
- vendor_cost_usd: t.union([t.number, t.null]),
611
- route: t.string,
612
- effort: t.string,
613
- // The run produced a notice rather than a completed review (security-gate block, agent kill, no
614
- // recoverable findings). An empty `findings` array alone can't say this — a genuine clean review
615
- // is also empty — so the render suppresses "clean review" and the sticky precedence guard refuses
616
- // to bury a completed review under it. Absent ⇒ a completed review.
617
- incomplete: t.boolean
618
- })
619
- ]);
620
- var ModelPricesCodec = t.type({
621
- in: t.number,
622
- out: t.number,
623
- cache_read: t.number,
624
- cache_write: t.number
625
- });
626
- var PriceMapCodec = t.type({
627
- _updated: t.string,
628
- _unit: t.string,
629
- models: t.record(t.string, ModelPricesCodec)
630
- });
631
- var TestFailureCodec = t.intersection([
632
- t.type({ name: t.string }),
633
- t.partial({ message: t.string })
634
- ]);
635
- var TestSummaryCodec = t.intersection([
636
- t.type({
637
- passed: t.number,
638
- failed: t.number,
639
- total: t.number
640
- }),
641
- t.partial({
642
- failures: t.array(TestFailureCodec)
643
- })
644
- ]);
645
- var DEFAULT_SCHEMA_VERSION = "0.4.0";
646
- var noticeFindings = (summary) => ({
647
- schema_version: DEFAULT_SCHEMA_VERSION,
648
- summary,
649
- verdict: "comment",
650
- findings: []
651
- });
652
-
653
- // src/validate.ts
654
708
  var addFormats = _addFormats;
655
709
  var validatorCache = /* @__PURE__ */ new Map();
656
710
  var compileSchema = (schemaPath) => {
@@ -990,11 +1044,24 @@ var NOTICE_KINDS = [
990
1044
  "no-output"
991
1045
  ];
992
1046
  var isNoticeKind = (s) => NOTICE_KINDS.some((k) => k === s);
1047
+ var UNSAFE_IN_SUMMARY = /[\n\r`<>|]/;
1048
+ var MAX_NAMED_HOSTS = 20;
1049
+ var parseAgentAllowlist = (sandboxConfig) => {
1050
+ const domains = sandboxConfig?.network?.allowedDomains;
1051
+ return Array.isArray(domains) ? domains.filter(
1052
+ (d) => typeof d === "string" && d.length <= 256 && !UNSAFE_IN_SUMMARY.test(d)
1053
+ ).slice(0, MAX_NAMED_HOSTS) : [];
1054
+ };
1055
+ var egressNote = (agentAllowlist) => agentAllowlist.length === 0 ? "" : `
1056
+
1057
+ If the review failed because the agent could not reach a host it needed, note that its network egress is jailed to only ${agentAllowlist.map((host) => `\`${host}\``).join(
1058
+ ", "
1059
+ )}. Add any missing host to the agent's egress allowlist \u2014 the reusable workflow's \`extra_endpoints\` input, or a single-file workflow's \`--extra\` flag on \`sandbox-config\`.`;
993
1060
  var blockquote = (text) => text.replaceAll("\n", "\n> ");
994
1061
  var reasoned = (lead, noReason, reasons) => typeof reasons === "string" && reasons.trim() !== "" ? `${lead}
995
1062
 
996
1063
  > ${blockquote(reasons)}` : noReason;
997
- var noticeSummary = (kind, reasons) => {
1064
+ var noticeSummary = (kind, reasons, agentAllowlist) => {
998
1065
  switch (kind) {
999
1066
  case "security-blocked":
1000
1067
  return reasoned(
@@ -1007,25 +1074,25 @@ var noticeSummary = (kind, reasons) => {
1007
1074
  "### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs). The triage step reported:",
1008
1075
  "### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs).",
1009
1076
  reasons
1010
- );
1077
+ ) + egressNote(agentAllowlist);
1011
1078
  case "setup-failed":
1012
1079
  return "### \u{1F6E0}\uFE0F Review did not run\n\nThe review job failed before the security triage could run (e.g. dependency install or environment setup). See the workflow logs \u2014 this is an infrastructure failure, not a security verdict.";
1013
1080
  case "checkout-failed":
1014
1081
  return "### \u26A0\uFE0F Could not check out the PR head\n\nThe PR head commit could not be fetched or checked out (it may have been force-pushed away, or is otherwise unavailable), so the review was skipped rather than run against the wrong tree. See workflow logs.";
1015
1082
  case "no-output":
1016
- return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs.";
1083
+ return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs." + egressNote(agentAllowlist);
1017
1084
  }
1018
1085
  };
1019
1086
  var noticeEnvelope = (summary) => ({
1020
1087
  schema_version: DEFAULT_SCHEMA_VERSION,
1021
- findings: noticeFindings(summary),
1088
+ findings: incompleteFindings(summary),
1022
1089
  models: [],
1023
1090
  turns: 0,
1024
1091
  duration_ms: 0,
1025
1092
  vendor_cost_usd: null,
1026
1093
  incomplete: true
1027
1094
  });
1028
- var buildNoticeEnvelope = (kind, reasons) => noticeEnvelope(noticeSummary(kind, reasons));
1095
+ var buildNoticeEnvelope = (kind, reasons, agentAllowlist = []) => noticeEnvelope(noticeSummary(kind, reasons, agentAllowlist));
1029
1096
  var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
1030
1097
  `### \u26A0\uFE0F Review could not be rendered
1031
1098
 
@@ -1035,6 +1102,14 @@ var identity = (decoded) => decoded;
1035
1102
  var findingsTable = [
1036
1103
  {
1037
1104
  minor: "0.4",
1105
+ defaultVersion: "0.4.0",
1106
+ schemaFile: "findings.schema.json",
1107
+ codec: FindingsCodec,
1108
+ normalize: identity,
1109
+ latest: false
1110
+ },
1111
+ {
1112
+ minor: "0.5",
1038
1113
  defaultVersion: DEFAULT_SCHEMA_VERSION,
1039
1114
  schemaFile: "findings.schema.json",
1040
1115
  codec: FindingsCodec,
@@ -1533,6 +1608,7 @@ var post = async (input, ghApi = runGhApi) => {
1533
1608
  );
1534
1609
  const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
1535
1610
  const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
1611
+ const priorRounds = existingSticky !== null ? parseRounds(existingSticky.body) : [];
1536
1612
  const leaveInPlace = () => {
1537
1613
  process.stderr.write(
1538
1614
  `Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place
@@ -1549,7 +1625,7 @@ var post = async (input, ghApi = runGhApi) => {
1549
1625
  const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
1550
1626
  const renderNotice = (message) => formatMarkdown(
1551
1627
  render({
1552
- findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
1628
+ findings: incompleteFindings(`### \u26A0\uFE0F ${message}`),
1553
1629
  envelope: null,
1554
1630
  incomplete: true,
1555
1631
  prices: decodedPrices.right,
@@ -1558,6 +1634,8 @@ var post = async (input, ghApi = runGhApi) => {
1558
1634
  route: input.route,
1559
1635
  reviewedSha: input.headSha,
1560
1636
  effort: input.effort,
1637
+ rounds: priorRounds,
1638
+ convergenceRound: false,
1561
1639
  runUrl: input.runUrl,
1562
1640
  jsonUrl: input.jsonUrl,
1563
1641
  postedAt: input.postedAt
@@ -1590,16 +1668,21 @@ var post = async (input, ghApi = runGhApi) => {
1590
1668
  const envelope = loadEnvelope(input.envelopePath);
1591
1669
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
1592
1670
  if (envelope === null) {
1671
+ const envelopelessIncomplete = isIncompleteFindings(findings);
1672
+ if (wouldBuryCompleted(envelopelessIncomplete)) leaveInPlace();
1593
1673
  const body = formatMarkdown(
1594
1674
  render({
1595
1675
  findings,
1596
1676
  envelope: null,
1677
+ incomplete: envelopelessIncomplete,
1597
1678
  prices: decodedPrices.right,
1598
1679
  pricesProvided: input.pricesProvided,
1599
1680
  template,
1600
1681
  route: input.route,
1601
1682
  reviewedSha: input.headSha,
1602
1683
  effort: input.effort,
1684
+ rounds: priorRounds,
1685
+ convergenceRound: false,
1603
1686
  testReport,
1604
1687
  inlineDisposition: { kind: "no-envelope" },
1605
1688
  runUrl: input.runUrl,
@@ -1613,7 +1696,7 @@ var post = async (input, ghApi = runGhApi) => {
1613
1696
  );
1614
1697
  process.exit(0);
1615
1698
  }
1616
- const thisIncomplete = envelope.incomplete === true;
1699
+ const thisIncomplete = envelope.incomplete === true || isIncompleteFindings(findings);
1617
1700
  if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
1618
1701
  const findingsMarker = findingsPointer(findings, input.jsonUrl);
1619
1702
  const {
@@ -1635,6 +1718,10 @@ var post = async (input, ghApi = runGhApi) => {
1635
1718
  }
1636
1719
  const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1637
1720
  const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1721
+ const currentCounts = computeSeverityCounts(findings.findings);
1722
+ const effectiveRoute = input.route ?? envelope.route;
1723
+ const isRound = isConvergenceRound(effectiveRoute, thisIncomplete);
1724
+ const rounds = isRound ? [...priorRounds, currentCounts] : priorRounds;
1638
1725
  const commonRenderInput = {
1639
1726
  findings,
1640
1727
  envelope,
@@ -1646,7 +1733,10 @@ var post = async (input, ghApi = runGhApi) => {
1646
1733
  reviewedSha: input.headSha,
1647
1734
  effort: input.effort,
1648
1735
  testReport,
1649
- severityCounts: computeSeverityCounts(findings.findings),
1736
+ severityCounts: currentCounts,
1737
+ rounds,
1738
+ convergenceThreshold: input.convergenceThreshold,
1739
+ convergenceRound: isRound,
1650
1740
  strays,
1651
1741
  runUrl: input.runUrl,
1652
1742
  jsonUrl: input.jsonUrl,
@@ -2657,7 +2747,16 @@ var nativeTelemetry = (native, meta) => resolveTelemetry(
2657
2747
  meta
2658
2748
  );
2659
2749
  var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
2660
- var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath) => {
2750
+ var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath, seedUnrevised) => {
2751
+ if (seedUnrevised)
2752
+ return {
2753
+ schema_version: DEFAULT_SCHEMA_VERSION,
2754
+ findings: incompleteFindings(
2755
+ "### \u26A0\uFE0F Review did not complete\n\nThe review agent did not write a review (its draft is the untouched pre-seed). See the workflow logs."
2756
+ ),
2757
+ incomplete: true,
2758
+ ...telemetry
2759
+ };
2661
2760
  const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
2662
2761
  switch (outcome.kind) {
2663
2762
  case "ok":
@@ -2665,7 +2764,7 @@ var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath) =>
2665
2764
  case "telemetry-only":
2666
2765
  return {
2667
2766
  schema_version: DEFAULT_SCHEMA_VERSION,
2668
- findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
2767
+ findings: incompleteFindings(`### \u26A0\uFE0F Review did not complete
2669
2768
 
2670
2769
  ${outcome.reason}`),
2671
2770
  incomplete: true,
@@ -2683,7 +2782,8 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
2683
2782
  absentTelemetry(meta),
2684
2783
  void 0,
2685
2784
  agentFilePath,
2686
- meta.agentFileFallbackPath
2785
+ meta.agentFileFallbackPath,
2786
+ meta.seedUnrevised === true
2687
2787
  )
2688
2788
  );
2689
2789
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
@@ -2694,7 +2794,8 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
2694
2794
  nativeTelemetry(decoded.right, meta),
2695
2795
  native,
2696
2796
  agentFilePath,
2697
- meta.agentFileFallbackPath
2797
+ meta.agentFileFallbackPath,
2798
+ meta.seedUnrevised === true
2698
2799
  )
2699
2800
  );
2700
2801
  }
@@ -2726,6 +2827,12 @@ var deriveModelHost = (apiBaseUrl) => {
2726
2827
  }
2727
2828
  return host;
2728
2829
  };
2830
+ var KNOWN_MODEL_HOST_SUFFIXES = ["anthropic.com", "deepseek.com"];
2831
+ var isKnownModelHost = (host, declared = []) => {
2832
+ const normalize = (h) => h.replace(/\.$/, "").toLowerCase();
2833
+ const target = normalize(host);
2834
+ return declared.some((d) => normalize(d) === target) || KNOWN_MODEL_HOST_SUFFIXES.some((suffix) => target.endsWith(`.${suffix}`));
2835
+ };
2729
2836
  var parseExtraEndpoints = (extra) => extra.split(/\s+/).map((token) => token.replace(/:\d+$/, "")).filter((token) => token.length > 0);
2730
2837
  var buildSandboxConfig = (opts) => ({
2731
2838
  network: {
@@ -2786,6 +2893,19 @@ var readJSONOrAbsent = (path) => {
2786
2893
  return void 0;
2787
2894
  }
2788
2895
  };
2896
+ var readSandboxConfigForNotice = (path) => {
2897
+ const text = readFileOrNull(resolve$1(path));
2898
+ if (text === null) return void 0;
2899
+ const parsed = tryParseJson(text);
2900
+ if (!parsed.ok) {
2901
+ process.stderr.write(
2902
+ `::warning::${annotationSafe(`code-review notice: ${path} is present but not valid JSON \u2014 omitting the agent allowlist from the notice`)}
2903
+ `
2904
+ );
2905
+ return void 0;
2906
+ }
2907
+ return parsed.value;
2908
+ };
2789
2909
  var readStdinJSON = () => {
2790
2910
  if (process.stdin.isTTY) return null;
2791
2911
  const raw = (() => {
@@ -2836,6 +2956,7 @@ var resolvePrices = (pricesArg) => {
2836
2956
  return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
2837
2957
  };
2838
2958
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
2959
+ var CONVERGENCE_THRESHOLD_DESCRIPTION = "Advisory convergence tolerance: the weighted-severity score (critical 4 \xB7 major 2 \xB7 minor 1 \xB7 nit 0) at or below which the sticky reads as converged (default: 1 \u2014 unlimited nits plus at most one minor)";
2839
2960
  var renderCmd = defineCommand({
2840
2961
  meta: {
2841
2962
  name: "render",
@@ -2875,6 +2996,10 @@ var renderCmd = defineCommand({
2875
2996
  "test-report": {
2876
2997
  type: "string",
2877
2998
  description: TEST_REPORT_DESCRIPTION
2999
+ },
3000
+ "convergence-threshold": {
3001
+ type: "string",
3002
+ description: CONVERGENCE_THRESHOLD_DESCRIPTION
2878
3003
  }
2879
3004
  },
2880
3005
  run: async ({ args }) => {
@@ -2895,6 +3020,7 @@ var renderCmd = defineCommand({
2895
3020
  route: args.route,
2896
3021
  effort: args.effort,
2897
3022
  testReport,
3023
+ convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
2898
3024
  postedAt: formatUtc(/* @__PURE__ */ new Date())
2899
3025
  });
2900
3026
  process.stdout.write(output2);
@@ -3015,6 +3141,18 @@ var parseBudgetUsd = (raw) => {
3015
3141
  const n = Number.parseFloat(raw);
3016
3142
  return Number.isFinite(n) && n >= 0 ? n : null;
3017
3143
  };
3144
+ var parseConvergenceThreshold = (raw) => {
3145
+ const trimmed = raw?.trim();
3146
+ if (trimmed === void 0 || trimmed === "") return void 0;
3147
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) {
3148
+ fail(`--convergence-threshold must be a non-negative number; got "${trimmed}"`);
3149
+ }
3150
+ const n = Number.parseFloat(trimmed);
3151
+ if (!Number.isFinite(n)) {
3152
+ fail(`--convergence-threshold is too large to be a meaningful tolerance; got "${trimmed}"`);
3153
+ }
3154
+ return n;
3155
+ };
3018
3156
  var mtimeMsOf = (path) => {
3019
3157
  try {
3020
3158
  return statSync(path).mtimeMs;
@@ -3341,7 +3479,7 @@ var seedDraftCmd = defineCommand({
3341
3479
  };
3342
3480
  const writeScaffold = () => {
3343
3481
  try {
3344
- writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
3482
+ writeFileSync(outPath, `${JSON.stringify(emptyFindings(""), null, 2)}
3345
3483
  `);
3346
3484
  writeSeedMarker();
3347
3485
  process.stderr.write(
@@ -3441,11 +3579,14 @@ var adaptCmd = defineCommand({
3441
3579
  }
3442
3580
  },
3443
3581
  run: async ({ args }) => {
3582
+ const agentFile = args["agent-file"];
3583
+ const seedUnrevised = agentFile ? !mainHasWrittenDraft(mtimeMsOf(agentFile), mtimeMsOf(seedMarkerPath(agentFile))) : false;
3444
3584
  const envelope = unwrapAdapt(
3445
- adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
3585
+ adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), agentFile, {
3446
3586
  route: args.route,
3447
3587
  effort: args.effort,
3448
3588
  agentFileFallbackPath: args["agent-file-fallback"],
3589
+ seedUnrevised,
3449
3590
  ...args.transcript ? {
3450
3591
  transcriptFallback: () => transcriptFallbackFrom(args.transcript)
3451
3592
  } : {}
@@ -3469,6 +3610,10 @@ var noticeCmd = defineCommand({
3469
3610
  reasons: {
3470
3611
  type: "string",
3471
3612
  description: "security-blocked / triage-error only: the triage's fail-closed reason string (empty/omitted \u21D2 the no-reason wording)"
3613
+ },
3614
+ "sandbox-config": {
3615
+ type: "string",
3616
+ description: "no-output / triage-error only: path to the agent's sandbox-runtime settings (sandbox.json); its network.allowedDomains is named in the notice so an egress-blocked review self-diagnoses. Missing or unreadable \u21D2 the allowlist is omitted (an early failure runs before the jail is set up)."
3472
3617
  }
3473
3618
  },
3474
3619
  // An unrecognized kind degrades to a generic incomplete notice instead of exiting non-zero: a
@@ -3484,8 +3629,10 @@ var noticeCmd = defineCommand({
3484
3629
  `);
3485
3630
  return;
3486
3631
  }
3632
+ const namesAllowlist = args.kind === "no-output" || args.kind === "triage-error";
3633
+ const agentAllowlist = namesAllowlist && args["sandbox-config"] ? parseAgentAllowlist(readSandboxConfigForNotice(args["sandbox-config"])) : [];
3487
3634
  process.stdout.write(
3488
- `${JSON.stringify(buildNoticeEnvelope(args.kind, args.reasons), null, 2)}
3635
+ `${JSON.stringify(buildNoticeEnvelope(args.kind, args.reasons, agentAllowlist), null, 2)}
3489
3636
  `
3490
3637
  );
3491
3638
  }
@@ -3852,6 +3999,10 @@ var postCmd = defineCommand({
3852
3999
  "json-url": {
3853
4000
  type: "string",
3854
4001
  description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
4002
+ },
4003
+ "convergence-threshold": {
4004
+ type: "string",
4005
+ description: CONVERGENCE_THRESHOLD_DESCRIPTION
3855
4006
  }
3856
4007
  },
3857
4008
  run: async ({ args }) => {
@@ -3872,6 +4023,7 @@ var postCmd = defineCommand({
3872
4023
  testReportPath: args["test-report"],
3873
4024
  runUrl: args["run-url"],
3874
4025
  jsonUrl: args["json-url"],
4026
+ convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
3875
4027
  postedAt: formatUtc(/* @__PURE__ */ new Date())
3876
4028
  });
3877
4029
  }
@@ -4187,9 +4339,32 @@ var sandboxConfigCmd = defineCommand({
4187
4339
  out: {
4188
4340
  type: "string",
4189
4341
  description: "Write the settings JSON here instead of stdout"
4342
+ },
4343
+ "known-model-host": {
4344
+ type: "string",
4345
+ description: "Additional model HOST(S) to treat as known (space-separated bare hostnames, not URLs) \u2014 the consumer's declared host(s) for a provider outside the built-in set; a derived host outside the built-ins and this list warns (or fails under --strict-host)"
4346
+ },
4347
+ "strict-host": {
4348
+ type: "boolean",
4349
+ description: "Fail (instead of warning) when the model host derived from api_base_url is not a well-known or declared host \u2014 closes the fail-open-on-typo gap where a mistyped api_base_url would still be allowlisted and sent the key"
4190
4350
  }
4191
4351
  },
4192
4352
  run: ({ args }) => {
4353
+ const modelHost = deriveModelHost(args["api-base-url"]);
4354
+ const declaredHosts = [
4355
+ ...args["known-model-host"] ? parseExtraEndpoints(args["known-model-host"]) : [],
4356
+ ...args.extra ? parseExtraEndpoints(args.extra) : []
4357
+ ];
4358
+ if (!isKnownModelHost(modelHost, declaredHosts)) {
4359
+ const message = `code-review sandbox-config: derived model host "${modelHost}" is not a well-known or declared host \u2014 the jail will allow egress to it and send MODEL_API_KEY there; verify api_base_url is correct`;
4360
+ if (args["strict-host"]) {
4361
+ process.stderr.write(`::error::${annotationSafe(message)}
4362
+ `);
4363
+ process.exit(1);
4364
+ }
4365
+ process.stderr.write(`::warning::${annotationSafe(message)}
4366
+ `);
4367
+ }
4193
4368
  const config = buildSandboxConfig({ apiBaseUrl: args["api-base-url"], extra: args.extra });
4194
4369
  const json = `${JSON.stringify(config, null, 2)}
4195
4370
  `;