@jphutchins/code-review 0.1.0-alpha.5 → 0.1.0-alpha.7

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
@@ -53,15 +53,138 @@ var computeCost = (models, prices, warn = defaultWarn) => {
53
53
  };
54
54
  };
55
55
 
56
+ // src/patch.ts
57
+ var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
58
+ var hunkOldStart = (line) => {
59
+ const raw = HUNK_HEADER_RE.exec(line)?.[1];
60
+ return raw !== void 0 ? Number(raw) : null;
61
+ };
62
+ var classifyBodyLine = (line) => {
63
+ if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
64
+ if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
65
+ if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
66
+ return null;
67
+ };
68
+ var trimmedMiddle = (body) => {
69
+ const first = body.findIndex((l) => l.kind !== "context");
70
+ if (first === -1) return [];
71
+ const last = body.findLastIndex((l) => l.kind !== "context");
72
+ return body.slice(first, last + 1);
73
+ };
74
+ var isContiguousChange = (middle) => {
75
+ if (middle.some((l) => l.kind === "context")) return false;
76
+ const firstAdded = middle.findIndex((l) => l.kind === "added");
77
+ if (firstAdded === -1) return true;
78
+ return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
79
+ };
80
+ var removedRange = (body, oldStart) => body.reduce(
81
+ (acc, line) => line.kind === "added" ? acc : {
82
+ lineNumber: acc.lineNumber + 1,
83
+ firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
84
+ lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
85
+ },
86
+ { lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
87
+ );
88
+ var drop = (reason) => ({
89
+ kind: "drop",
90
+ reason
91
+ });
92
+ var parseHunk = (patch) => {
93
+ const rawLines = patch.split("\n");
94
+ const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
95
+ const headerHits = lines.reduce(
96
+ (acc, line, index) => {
97
+ const oldStart = hunkOldStart(line);
98
+ return oldStart !== null ? [...acc, { index, oldStart }] : acc;
99
+ },
100
+ []
101
+ );
102
+ if (headerHits.length !== 1) {
103
+ return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
104
+ }
105
+ const hit = headerHits[0];
106
+ if (hit === void 0) return drop("malformed hunk header");
107
+ const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
108
+ const classified = bodyRaw.map(classifyBodyLine);
109
+ if (classified.some((line) => line === null)) return drop("malformed hunk body line");
110
+ const body = classified.filter((line) => line !== null);
111
+ return { kind: "ok", oldStart: hit.oldStart, body };
112
+ };
113
+ var validatePatch = (patch, fileLines) => {
114
+ const parsed = parseHunk(patch);
115
+ if (parsed.kind === "drop") return parsed;
116
+ const { oldStart, body } = parsed;
117
+ const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
118
+ const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
119
+ const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
120
+ if (!oldSideMatches) {
121
+ return drop(
122
+ `patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
123
+ );
124
+ }
125
+ if (!isContiguousChange(trimmedMiddle(body))) {
126
+ return drop("change is not a single contiguous block");
127
+ }
128
+ const removedCount = body.filter((l) => l.kind === "removed").length;
129
+ const addedCount = body.filter((l) => l.kind === "added").length;
130
+ if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
131
+ if (removedCount === 0) return drop("pure insertion has no range to anchor a suggestion");
132
+ const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
133
+ if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
134
+ return { startLine: firstRemoved, endLine: lastRemoved };
135
+ };
136
+ var patchToSuggestion = (patch) => {
137
+ const parsed = parseHunk(patch);
138
+ if (parsed.kind === "drop") return parsed;
139
+ const { body } = parsed;
140
+ if (!isContiguousChange(trimmedMiddle(body))) {
141
+ return drop("change is not a single contiguous block");
142
+ }
143
+ const removedCount = body.filter((l) => l.kind === "removed").length;
144
+ const addedLines = body.filter((l) => l.kind === "added");
145
+ if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
146
+ if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
147
+ return addedLines.map((l) => l.text).join("\n");
148
+ };
149
+
150
+ // src/surface.ts
151
+ var severityEmoji = (s) => {
152
+ switch (s) {
153
+ case "critical":
154
+ return "\u{1F534}";
155
+ case "major":
156
+ return "\u{1F7E0}";
157
+ case "minor":
158
+ return "\u{1F535}";
159
+ case "nit":
160
+ return "\u26AA";
161
+ default:
162
+ return "\u2753";
163
+ }
164
+ };
165
+ var EMBED_LIMIT = 4e4;
166
+ var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
167
+ var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => {
168
+ const b64 = Buffer.from(JSON.stringify(findings), "utf-8").toString("base64");
169
+ const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
170
+ return marker ? `${AGENTS_STOP_DIRECTIVE}
171
+ ${marker}` : "";
172
+ };
173
+ var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
174
+ var projectPatch = (patch) => {
175
+ if (patch === null || patch === void 0) return { kind: "none" };
176
+ const lowered = patchToSuggestion(patch);
177
+ return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
178
+ };
179
+
56
180
  // src/render.ts
57
- var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
58
181
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
59
182
  var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
60
183
  var sanitizeFinding = (f) => ({
61
184
  ...f,
62
185
  title: escapePipes(f.title),
63
186
  path: escapeCodeBackticks(f.path),
64
- suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
187
+ patchProjection: projectPatch(f.patch)
65
188
  });
66
189
  var emptySeverityCounts = () => ({
67
190
  critical: 0,
@@ -77,6 +200,7 @@ var render = (input) => {
77
200
  const eta = new Eta({ autoTrim: false });
78
201
  const usageAvailable = input.envelope !== null;
79
202
  const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
203
+ const pricesProvided = input.pricesProvided ?? true;
80
204
  const route = input.route ?? input.envelope?.route ?? null;
81
205
  const effort = input.effort ?? input.envelope?.effort ?? null;
82
206
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
@@ -85,6 +209,7 @@ var render = (input) => {
85
209
  envelope: input.envelope,
86
210
  usageAvailable,
87
211
  costReport,
212
+ pricesProvided,
88
213
  route,
89
214
  effort,
90
215
  modelNames,
@@ -93,8 +218,14 @@ var render = (input) => {
93
218
  severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
94
219
  strays: (input.strays ?? []).map(sanitizeFinding),
95
220
  inlineDisposition: input.inlineDisposition ?? null,
221
+ runUrl: input.runUrl ?? null,
222
+ jsonUrl: input.jsonUrl ?? null,
223
+ findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
224
+ reviewUrl: input.reviewUrl ?? null,
96
225
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
97
- formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(3)}` : "\u2014",
226
+ // Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
227
+ // real tokens spent, we simply have no rates to price them (SPEC §6.2).
228
+ formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
98
229
  formatDuration: (ms) => {
99
230
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
100
231
  const s = Math.round(ms / 1e3);
@@ -112,20 +243,7 @@ var render = (input) => {
112
243
  return `\u2753 ${v}`;
113
244
  }
114
245
  },
115
- severityEmoji: (s) => {
116
- switch (s) {
117
- case "critical":
118
- return "\u{1F534}";
119
- case "major":
120
- return "\u{1F7E0}";
121
- case "minor":
122
- return "\u{1F535}";
123
- case "nit":
124
- return "\u26AA";
125
- default:
126
- return "\u2753";
127
- }
128
- }
246
+ severityEmoji
129
247
  });
130
248
  };
131
249
  var key = (path, line) => `${path}:${String(line)}`;
@@ -182,33 +300,32 @@ var partitionFindings = (findings, index) => {
182
300
  };
183
301
 
184
302
  // src/inline.ts
185
- var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
186
- var buildCommentBody = (f) => {
187
- const parts = [f.body];
188
- if (f.suggestion !== null && f.suggestion !== void 0) {
189
- const safe = escapeBackticks2(f.suggestion);
190
- parts.push(`\`\`\`suggestion
191
- ${safe}
192
- \`\`\``);
193
- }
194
- return parts.join("\n\n");
195
- };
196
- var renderCommentBody = (f, eta, template) => {
197
- return eta.renderString(template, {
303
+ var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
304
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
305
+ // Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
306
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
307
+ eta.renderString(template, {
198
308
  ...f,
199
- suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
200
- });
201
- };
202
- var buildInlineComments = (findings, diff, inlineTemplate) => {
309
+ patchProjection: projectPatch(f.patch),
310
+ severityEmoji,
311
+ modelsText,
312
+ jsonUrl: jsonUrl ?? null,
313
+ findingsPointer: pointer
314
+ })
315
+ );
316
+ var buildInlineComments = (findings, diff, context) => {
317
+ const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
203
318
  const index = indexDiff(diff);
204
319
  const { inDiff, strays } = partitionFindings(findings, index);
205
- const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
320
+ const eta = new Eta({ autoTrim: false });
321
+ const modelsText = formatModels(models);
322
+ const pointer = context.findingsPointer ?? (fullFindings ? findingsPointer(fullFindings, jsonUrl) : "");
206
323
  const comments = inDiff.map((f) => {
207
324
  const comment = {
208
325
  path: f.path,
209
326
  line: f.end_line,
210
327
  side: defaultSide(f.side),
211
- body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
328
+ body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
212
329
  };
213
330
  if (f.start_line < f.end_line) {
214
331
  return {
@@ -262,14 +379,16 @@ var FindingShape = t.intersection([
262
379
  end_line: LineNumber,
263
380
  severity: SeverityCodec,
264
381
  title: t.string,
265
- body: t.string
382
+ description: t.string,
383
+ reasoning: t.string,
384
+ confidence: Confidence
266
385
  }),
267
386
  t.partial({
268
387
  side: SideCodec,
269
- suggestion: t.union([t.string, t.null]),
270
- confidence: Confidence,
271
388
  code: t.string,
272
- code_url: t.string
389
+ code_url: t.string,
390
+ recommendation: t.string,
391
+ patch: t.union([t.string, t.null])
273
392
  })
274
393
  ]);
275
394
  var EndGeStart = t.refinement(
@@ -345,7 +464,13 @@ var TestSummaryCodec = t.intersection([
345
464
  failures: t.array(TestFailureCodec)
346
465
  })
347
466
  ]);
348
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
467
+ var DEFAULT_SCHEMA_VERSION = "0.4.0";
468
+ var noticeFindings = (summary) => ({
469
+ schema_version: DEFAULT_SCHEMA_VERSION,
470
+ summary,
471
+ verdict: "comment",
472
+ findings: []
473
+ });
349
474
 
350
475
  // src/validate.ts
351
476
  var addFormats = _addFormats;
@@ -381,10 +506,32 @@ var unsafeUnwrap = (decoded) => {
381
506
  if (decoded._tag === "Right") return decoded.right;
382
507
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
383
508
  };
509
+
510
+ // src/format.ts
511
+ var FENCE_RE = /^\s*```/;
512
+ var scanLine = (state, line) => {
513
+ if (FENCE_RE.test(line)) {
514
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
515
+ }
516
+ if (state.inFence) {
517
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
518
+ }
519
+ const trimmed = line.replace(/[ \t]+$/, "");
520
+ if (trimmed !== "") {
521
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
522
+ }
523
+ const blankRun = state.blankRun + 1;
524
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
525
+ };
526
+ var formatMarkdown = (md) => {
527
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
528
+ return `${lines.join("\n").replace(/\n+$/, "")}
529
+ `;
530
+ };
384
531
  var identity = (decoded) => decoded;
385
532
  var findingsTable = [
386
533
  {
387
- minor: "0.2",
534
+ minor: "0.4",
388
535
  defaultVersion: DEFAULT_SCHEMA_VERSION,
389
536
  schemaFile: "findings.schema.json",
390
537
  codec: FindingsCodec,
@@ -528,12 +675,6 @@ var checkLongSuggestions = (comments) => {
528
675
  });
529
676
  return { comments: adjusted, longFiles };
530
677
  };
531
- var noticeFindings = (message) => ({
532
- schema_version: DEFAULT_SCHEMA_VERSION,
533
- summary: `### \u26A0\uFE0F ${message}`,
534
- verdict: "comment",
535
- findings: []
536
- });
537
678
  var loadFindings = (path) => {
538
679
  let raw;
539
680
  try {
@@ -595,9 +736,22 @@ var loadTestReport = (path) => {
595
736
  }
596
737
  return decoded.right;
597
738
  };
598
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
739
+ var parseHtmlUrl = (raw) => {
740
+ try {
741
+ const parsed = JSON.parse(raw);
742
+ return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
743
+ } catch {
744
+ return void 0;
745
+ }
746
+ };
747
+ var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
748
+ const sha7 = headSha.slice(0, 7);
749
+ const linkLine = stickyUrl ? `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the [summary comment](${stickyUrl}) for the verdict, walkthrough, and cost.` : `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the summary comment for the verdict, walkthrough, and cost.`;
750
+ const pointer = marker ? `${marker}
751
+
752
+ ${linkLine}` : linkLine;
599
753
  const body = JSON.stringify({
600
- body: `\u{1F916} Automated code review for \`${headSha.slice(0, 7)}\` \u2014 verdict, walkthrough, and cost are in the summary comment.`,
754
+ body: pointer,
601
755
  commit_id: headSha,
602
756
  event: "COMMENT",
603
757
  comments: comments.map((c) => ({
@@ -605,10 +759,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
605
759
  line: c.line,
606
760
  side: c.side,
607
761
  ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
608
- body: c.body
762
+ body: formatMarkdown(c.body)
609
763
  }))
610
764
  });
611
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
765
+ const stdout = await ghApi(
766
+ [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
767
+ body
768
+ );
769
+ return parseHtmlUrl(stdout);
612
770
  };
613
771
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
614
772
  const stdout = await ghApi(
@@ -628,30 +786,42 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
628
786
  const parsed = JSON.parse(last);
629
787
  return { id: parsed.id, body: parsed.body };
630
788
  };
789
+ var parseCommentRef = (raw) => {
790
+ try {
791
+ const parsed = JSON.parse(raw);
792
+ return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
793
+ } catch {
794
+ return null;
795
+ }
796
+ };
631
797
  var patchComment = async (repo, commentId, body, ghApi) => {
632
- await ghApi(
798
+ const stdout = await ghApi(
633
799
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
634
800
  JSON.stringify({ body })
635
801
  );
802
+ const htmlUrl = parseHtmlUrl(stdout);
803
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
636
804
  };
637
805
  var postComment = async (repo, prNumber, body, ghApi) => {
638
- await ghApi(
806
+ const stdout = await ghApi(
639
807
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
640
808
  JSON.stringify({ body })
641
809
  );
810
+ return parseCommentRef(stdout);
642
811
  };
643
812
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
644
813
  if (existing !== null) {
645
- await patchComment(repo, existing.id, body, ghApi);
814
+ const patched = await patchComment(repo, existing.id, body, ghApi);
646
815
  process.stderr.write(
647
816
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
648
817
  `
649
818
  );
650
- } else {
651
- await postComment(repo, prNumber, body, ghApi);
652
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
653
- `);
819
+ return { id: existing.id, url: patched?.html_url };
654
820
  }
821
+ const posted = await postComment(repo, prNumber, body, ghApi);
822
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
823
+ `);
824
+ return posted ? { id: posted.id, url: posted.html_url } : null;
655
825
  };
656
826
  var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
657
827
  var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
@@ -719,16 +889,21 @@ var post = async (input, ghApi = runGhApi) => {
719
889
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
720
890
  }
721
891
  const template = readFileSync(input.templatePath, "utf-8");
722
- const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
723
- const renderNotice = (message) => render({
724
- findings: noticeFindings(message),
725
- envelope: null,
726
- prices: decodedPrices.right,
727
- template,
728
- route: input.route,
729
- reviewedSha: input.headSha,
730
- effort: input.effort
731
- });
892
+ const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
893
+ const renderNotice = (message) => formatMarkdown(
894
+ render({
895
+ findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
896
+ envelope: null,
897
+ prices: decodedPrices.right,
898
+ pricesProvided: input.pricesProvided,
899
+ template,
900
+ route: input.route,
901
+ reviewedSha: input.headSha,
902
+ effort: input.effort,
903
+ runUrl: input.runUrl,
904
+ jsonUrl: input.jsonUrl
905
+ })
906
+ );
732
907
  if (isEmptyDiff(diff)) {
733
908
  await upsertSticky(
734
909
  input.repo,
@@ -754,28 +929,34 @@ var post = async (input, ghApi = runGhApi) => {
754
929
  const envelope = loadEnvelope(input.envelopePath);
755
930
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
756
931
  if (envelope === null) {
757
- const body2 = render({
758
- findings,
759
- envelope: null,
760
- prices: decodedPrices.right,
761
- template,
762
- route: input.route,
763
- reviewedSha: input.headSha,
764
- effort: input.effort,
765
- testReport,
766
- inlineDisposition: { kind: "no-envelope" }
767
- });
768
- await upsertSticky(input.repo, prNumber, existingSticky, body2, ghApi);
932
+ const body = formatMarkdown(
933
+ render({
934
+ findings,
935
+ envelope: null,
936
+ prices: decodedPrices.right,
937
+ pricesProvided: input.pricesProvided,
938
+ template,
939
+ route: input.route,
940
+ reviewedSha: input.headSha,
941
+ effort: input.effort,
942
+ testReport,
943
+ inlineDisposition: { kind: "no-envelope" },
944
+ runUrl: input.runUrl,
945
+ jsonUrl: input.jsonUrl
946
+ })
947
+ );
948
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
769
949
  process.stderr.write(
770
950
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
771
951
  );
772
952
  process.exit(0);
773
953
  }
774
- const { comments: rawComments, strays } = buildInlineComments(
775
- findings.findings,
776
- diff,
777
- inlineTemplate
778
- );
954
+ const findingsMarker = findingsPointer(findings, input.jsonUrl);
955
+ const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
956
+ inlineTemplate,
957
+ models: envelope.models.map((m) => m.model),
958
+ findingsPointer: findingsMarker
959
+ });
779
960
  const { comments, longFiles } = checkLongSuggestions(rawComments);
780
961
  for (const wf of longFiles) {
781
962
  process.stderr.write(
@@ -785,11 +966,12 @@ var post = async (input, ghApi = runGhApi) => {
785
966
  }
786
967
  const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
787
968
  const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
788
- const inlineDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : { kind: "posted", count: comments.length, sha: input.headSha } : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
789
- const body = render({
969
+ const initialDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : void 0 : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
970
+ const commonRenderInput = {
790
971
  findings,
791
972
  envelope,
792
973
  prices: decodedPrices.right,
974
+ pricesProvided: input.pricesProvided,
793
975
  template,
794
976
  route: input.route,
795
977
  reviewedSha: input.headSha,
@@ -797,14 +979,24 @@ var post = async (input, ghApi = runGhApi) => {
797
979
  testReport,
798
980
  severityCounts: computeSeverityCounts(findings.findings),
799
981
  strays,
800
- inlineDisposition
801
- }) + (longFiles.length > 0 ? `
982
+ runUrl: input.runUrl,
983
+ jsonUrl: input.jsonUrl,
984
+ findingsPointer: findingsMarker
985
+ };
986
+ const longFilesNote = longFiles.length > 0 ? `
802
987
 
803
988
  ---
804
989
 
805
990
  > **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from the inline comments; the affected findings remain in the review.
806
- ` : "");
807
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
991
+ ` : "";
992
+ const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
993
+ const stickyRef = await upsertSticky(
994
+ input.repo,
995
+ prNumber,
996
+ existingSticky,
997
+ renderBody(initialDisposition),
998
+ ghApi
999
+ );
808
1000
  if (comments.length === 0) return;
809
1001
  if (alreadyReviewedThisSha) {
810
1002
  process.stderr.write(
@@ -817,11 +1009,41 @@ var post = async (input, ghApi = runGhApi) => {
817
1009
  if (stalePriorReviewIds.length > 0) {
818
1010
  await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
819
1011
  }
820
- await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
1012
+ const reviewUrl = await postInlineReview(
1013
+ input.repo,
1014
+ prNumber,
1015
+ input.headSha,
1016
+ comments,
1017
+ stickyRef?.url,
1018
+ findingsMarker,
1019
+ ghApi
1020
+ );
821
1021
  process.stderr.write(
822
1022
  `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
823
1023
  `
824
1024
  );
1025
+ if (stickyRef !== null) {
1026
+ const confirmedDisposition = {
1027
+ kind: "posted",
1028
+ count: comments.length,
1029
+ sha: input.headSha
1030
+ };
1031
+ try {
1032
+ await patchComment(
1033
+ input.repo,
1034
+ stickyRef.id,
1035
+ renderBody(confirmedDisposition, reviewUrl),
1036
+ ghApi
1037
+ );
1038
+ process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
1039
+ `);
1040
+ } catch (err) {
1041
+ process.stderr.write(
1042
+ `Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
1043
+ `
1044
+ );
1045
+ }
1046
+ }
825
1047
  };
826
1048
  var renderOutputs = (result) => {
827
1049
  switch (result.kind) {
@@ -1032,7 +1254,7 @@ var candidateFromJsonText = (kind, text) => {
1032
1254
  };
1033
1255
  var FENCE_OPEN = /^\s*(`{3,})/;
1034
1256
  var FENCE_MARKER_ONLY = /^`+$/;
1035
- var scanLine = (state, line) => {
1257
+ var scanLine2 = (state, line) => {
1036
1258
  if (state.openLength === null) {
1037
1259
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1038
1260
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1041,7 +1263,7 @@ var scanLine = (state, line) => {
1041
1263
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1042
1264
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1043
1265
  };
1044
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
1266
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
1045
1267
  var ladderFailureDiagnostics = (input) => {
1046
1268
  const native = parseNativeForExtraction(input.native);
1047
1269
  const preview = (s) => {
@@ -1144,24 +1366,42 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1144
1366
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1145
1367
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1146
1368
  }));
1369
+ var findingsOutcome = (native, agentFilePath) => {
1370
+ const ladder = extractStructured({ kind: "findings", native, agentFilePath });
1371
+ if (ladder.kind !== "ok")
1372
+ return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
1373
+ const resolution = resolve("findings", ladder.candidate);
1374
+ return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
1375
+ kind: "telemetry-only",
1376
+ reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
1377
+ };
1378
+ };
1147
1379
  var adaptClaudeCode = (native, agentFilePath, meta) => {
1148
- const outcome = extractStructured({ kind: "findings", native, agentFilePath });
1149
- if (outcome.kind !== "ok") {
1150
- return left(describeLadderFailure(outcome));
1151
- }
1152
- const resolution = resolve("findings", outcome.candidate);
1153
- return resolution.kind === "ok" ? right({
1154
- schema_version: resolution.version,
1155
- findings: resolution.value,
1380
+ const telemetry = {
1156
1381
  models: mapModelUsage(native.modelUsage),
1157
1382
  turns: native.num_turns,
1158
1383
  duration_ms: native.duration_ms,
1159
1384
  vendor_cost_usd: native.total_cost_usd ?? null,
1160
1385
  ...meta.route ? { route: meta.route } : {},
1161
1386
  ...meta.effort ? { effort: meta.effort } : {}
1162
- }) : left(
1163
- "internal error: the extraction ladder validated a candidate the registry then rejected"
1164
- );
1387
+ };
1388
+ const outcome = findingsOutcome(native, agentFilePath);
1389
+ switch (outcome.kind) {
1390
+ case "ok":
1391
+ return right({
1392
+ schema_version: outcome.version,
1393
+ findings: outcome.findings,
1394
+ ...telemetry
1395
+ });
1396
+ case "telemetry-only":
1397
+ return right({
1398
+ schema_version: DEFAULT_SCHEMA_VERSION,
1399
+ findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
1400
+
1401
+ ${outcome.reason}`),
1402
+ ...telemetry
1403
+ });
1404
+ }
1165
1405
  };
1166
1406
  var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1167
1407
  switch (adapterName) {
@@ -1175,6 +1415,86 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1175
1415
  }
1176
1416
  }
1177
1417
  };
1418
+ var whatsWrong = (state, draftPath, kind) => {
1419
+ switch (state.kind) {
1420
+ case "missing":
1421
+ return `${draftPath} does not exist yet`;
1422
+ case "unreadable":
1423
+ return `${draftPath} could not be read: ${state.error}`;
1424
+ case "invalid":
1425
+ return `${draftPath} does not validate against the ${kind} schema:
1426
+ ${state.errors.map((e) => ` - ${e}`).join("\n")}`;
1427
+ }
1428
+ };
1429
+ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
1430
+ if (state.kind === "valid") return { kind: "allow" };
1431
+ if (nudges >= maxNudges) return { kind: "allow" };
1432
+ return {
1433
+ kind: "block",
1434
+ reason: [
1435
+ `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
1436
+ `The only deliverable is a ${kind} document that validates against the ${kind} schema \u2014 run "code-review print-schema ${kind}" to see the exact shape.`,
1437
+ `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
1438
+ ].join("\n")
1439
+ };
1440
+ };
1441
+ var draftState = (draftPath, resolveSchema) => {
1442
+ let raw;
1443
+ try {
1444
+ raw = readFileSync(draftPath, "utf-8");
1445
+ } catch (err) {
1446
+ if (err instanceof Error && err.code === "ENOENT") {
1447
+ return { kind: "missing" };
1448
+ }
1449
+ return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
1450
+ }
1451
+ let parsed;
1452
+ try {
1453
+ parsed = JSON.parse(raw);
1454
+ } catch (err) {
1455
+ return {
1456
+ kind: "invalid",
1457
+ errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
1458
+ };
1459
+ }
1460
+ let schemaPath;
1461
+ try {
1462
+ schemaPath = resolveSchema(parsed);
1463
+ } catch (err) {
1464
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
1465
+ }
1466
+ try {
1467
+ const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
1468
+ return valid ? { kind: "valid" } : { kind: "invalid", errors };
1469
+ } catch (err) {
1470
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
1471
+ }
1472
+ };
1473
+ var readNudges = (counterPath) => {
1474
+ try {
1475
+ const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
1476
+ return Number.isInteger(n) && n >= 0 ? n : 0;
1477
+ } catch {
1478
+ return 0;
1479
+ }
1480
+ };
1481
+ var bumpNudges = (counterPath, current) => {
1482
+ writeFileSync(counterPath, `${String(current + 1)}
1483
+ `);
1484
+ };
1485
+ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
1486
+ var defaultHookCommand = (draftPath, opts) => [
1487
+ "code-review stop-gate --draft",
1488
+ shellQuote(draftPath),
1489
+ ...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
1490
+ ...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
1491
+ ...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
1492
+ ...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
1493
+ ...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
1494
+ ].join(" ");
1495
+ var stopHookSettings = (command) => ({
1496
+ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
1497
+ });
1178
1498
 
1179
1499
  // src/index.ts
1180
1500
  var readJSON = (path) => {
@@ -1210,12 +1530,13 @@ var unwrapAdapt = (either) => {
1210
1530
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1211
1531
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1212
1532
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
1213
- var resolvePricesPath = (pricesArg) => {
1214
- if (pricesArg) return resolve$1(pricesArg);
1533
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
1534
+ var resolvePrices = (pricesArg) => {
1535
+ if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
1215
1536
  process.stderr.write(
1216
- "code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
1537
+ "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
1217
1538
  );
1218
- return bundledPath("schema", "prices.example.json");
1539
+ return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
1219
1540
  };
1220
1541
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
1221
1542
  var renderCmd = defineCommand({
@@ -1263,14 +1584,15 @@ var renderCmd = defineCommand({
1263
1584
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1264
1585
  const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
1265
1586
  const templatePath = resolveTemplatePath(args.template);
1266
- const pricesPath = resolvePricesPath(args.prices);
1267
- const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
1587
+ const priceResolution = resolvePrices(args.prices);
1588
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
1268
1589
  const template = readFileSync(templatePath, "utf-8");
1269
1590
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
1270
1591
  const output = render({
1271
1592
  findings,
1272
1593
  envelope,
1273
1594
  prices,
1595
+ pricesProvided: priceResolution.kind === "provided",
1274
1596
  template,
1275
1597
  reviewedSha: args["reviewed-sha"],
1276
1598
  route: args.route,
@@ -1298,14 +1620,17 @@ var inlineCmd = defineCommand({
1298
1620
  },
1299
1621
  template: {
1300
1622
  type: "string",
1301
- description: "Path to inline comment Eta template (default: built-in format)"
1623
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1302
1624
  }
1303
1625
  },
1304
1626
  run: async ({ args }) => {
1305
1627
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1306
1628
  const diff = readFileSync(resolve$1(args.diff), "utf-8");
1307
- const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
1308
- const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
1629
+ const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
1630
+ const { comments, strays } = buildInlineComments(findings.findings, diff, {
1631
+ inlineTemplate,
1632
+ findings
1633
+ });
1309
1634
  process.stdout.write(
1310
1635
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1311
1636
  );
@@ -1335,8 +1660,7 @@ var costCmd = defineCommand({
1335
1660
  process.stdout.write(JSON.stringify(report, null, 2));
1336
1661
  }
1337
1662
  });
1338
- var declaredSchemaVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
1339
- var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
1663
+ var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
1340
1664
  var validateCmd = defineCommand({
1341
1665
  meta: {
1342
1666
  name: "validate",
@@ -1475,6 +1799,66 @@ ${ladderFailureDiagnostics(input)}
1475
1799
  fail(describeLadderFailure(outcome));
1476
1800
  }
1477
1801
  });
1802
+ var withoutPatch = (finding) => {
1803
+ const copy = { ...finding };
1804
+ delete copy.patch;
1805
+ return copy;
1806
+ };
1807
+ var readFileLines = (path) => {
1808
+ try {
1809
+ const rawLines = readFileSync(path, "utf-8").split("\n");
1810
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
1811
+ } catch {
1812
+ return null;
1813
+ }
1814
+ };
1815
+ var validateFinding = (finding, repoRoot) => {
1816
+ if (finding.patch === void 0 || finding.patch === null) return finding;
1817
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
1818
+ if (lines === null) {
1819
+ process.stderr.write(
1820
+ `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
1821
+ `
1822
+ );
1823
+ return withoutPatch(finding);
1824
+ }
1825
+ const result = validatePatch(finding.patch, lines);
1826
+ if ("kind" in result) {
1827
+ process.stderr.write(
1828
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
1829
+ `
1830
+ );
1831
+ return withoutPatch(finding);
1832
+ }
1833
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
1834
+ };
1835
+ var validatePatchesCmd = defineCommand({
1836
+ meta: {
1837
+ name: "validate-patches",
1838
+ description: "Validate each finding's patch against the real PR-head tree, aligning the finding's range to it and keeping the patch, or dropping the patch (issue #10)"
1839
+ },
1840
+ args: {
1841
+ findings: {
1842
+ type: "positional",
1843
+ description: "Path to findings JSON",
1844
+ required: true
1845
+ },
1846
+ "repo-root": {
1847
+ type: "string",
1848
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
1849
+ }
1850
+ },
1851
+ run: async ({ args }) => {
1852
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1853
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
1854
+ const validated = {
1855
+ ...findings,
1856
+ findings: findings.findings.map((f) => validateFinding(f, repoRoot))
1857
+ };
1858
+ process.stdout.write(`${JSON.stringify(validated, null, 2)}
1859
+ `);
1860
+ }
1861
+ });
1478
1862
  var requireAdapterName = (name) => {
1479
1863
  if (isAdapterName(name)) return name;
1480
1864
  fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
@@ -1521,6 +1905,97 @@ var printSchemaCmd = defineCommand({
1521
1905
  `);
1522
1906
  }
1523
1907
  });
1908
+ var MAX_NUDGES_DEFAULT = 5;
1909
+ var drainStdin = () => {
1910
+ if (process.stdin.isTTY) return;
1911
+ try {
1912
+ readFileSync(0);
1913
+ } catch {
1914
+ }
1915
+ };
1916
+ var requireMaxNudges = (raw) => {
1917
+ if (raw === void 0) return MAX_NUDGES_DEFAULT;
1918
+ if (!/^\d+$/.test(raw)) {
1919
+ fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
1920
+ }
1921
+ const n = Number.parseInt(raw, 10);
1922
+ if (n < 1) {
1923
+ fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
1924
+ }
1925
+ return n;
1926
+ };
1927
+ var stopGateCmd = defineCommand({
1928
+ meta: {
1929
+ name: "stop-gate",
1930
+ description: "Claude Code Stop-hook gate: refuse to let the agent end its turn until --draft validates against the schema (bounded by --max-nudges). With --print-settings, emit the --settings JSON that wires this as the Stop hook."
1931
+ },
1932
+ args: {
1933
+ draft: {
1934
+ type: "string",
1935
+ description: "Path to the findings document the agent must produce and keep valid",
1936
+ required: true
1937
+ },
1938
+ kind: {
1939
+ type: "string",
1940
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
1941
+ },
1942
+ schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
1943
+ "schema-version": {
1944
+ type: "string",
1945
+ description: "Schema major.minor to validate against (default: the draft's declared version)"
1946
+ },
1947
+ "max-nudges": {
1948
+ type: "string",
1949
+ description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
1950
+ },
1951
+ counter: {
1952
+ type: "string",
1953
+ description: "Path for the nudge counter (default: <draft>.nudges)"
1954
+ },
1955
+ "print-settings": {
1956
+ type: "boolean",
1957
+ description: "Print the Stop-hook settings JSON that wires this gate, then exit"
1958
+ }
1959
+ },
1960
+ run: async ({ args }) => {
1961
+ const draftPath = resolve$1(args.draft);
1962
+ if (args["print-settings"]) {
1963
+ const command = defaultHookCommand(draftPath, {
1964
+ kind: args.kind,
1965
+ schema: args.schema,
1966
+ schemaVersion: args["schema-version"],
1967
+ maxNudges: args["max-nudges"],
1968
+ counter: args.counter
1969
+ });
1970
+ process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
1971
+ `);
1972
+ return;
1973
+ }
1974
+ drainStdin();
1975
+ const kind = requireSchemaKind(args.kind || "findings");
1976
+ const maxNudges = requireMaxNudges(args["max-nudges"]);
1977
+ const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
1978
+ const state = draftState(
1979
+ draftPath,
1980
+ (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
1981
+ );
1982
+ const nudges = readNudges(counterPath);
1983
+ const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
1984
+ if (decision.kind === "block") {
1985
+ try {
1986
+ bumpNudges(counterPath, nudges);
1987
+ } catch (err) {
1988
+ process.stderr.write(
1989
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
1990
+ `
1991
+ );
1992
+ return;
1993
+ }
1994
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
1995
+ `);
1996
+ }
1997
+ }
1998
+ });
1524
1999
  var gatherCmd = defineCommand({
1525
2000
  meta: {
1526
2001
  name: "gather",
@@ -1605,7 +2080,7 @@ var postCmd = defineCommand({
1605
2080
  },
1606
2081
  "inline-template": {
1607
2082
  type: "string",
1608
- description: "Path to inline comment Eta template (default: built-in format)"
2083
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1609
2084
  },
1610
2085
  route: {
1611
2086
  type: "string",
@@ -1626,22 +2101,34 @@ var postCmd = defineCommand({
1626
2101
  "test-report": {
1627
2102
  type: "string",
1628
2103
  description: TEST_REPORT_DESCRIPTION
2104
+ },
2105
+ "run-url": {
2106
+ type: "string",
2107
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
2108
+ },
2109
+ "json-url": {
2110
+ type: "string",
2111
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
1629
2112
  }
1630
2113
  },
1631
2114
  run: async ({ args }) => {
2115
+ const priceResolution = resolvePrices(args.prices);
1632
2116
  await post({
1633
2117
  repo: args.repo,
1634
2118
  headSha: args["head-sha"],
1635
2119
  botLogin: args["bot-login"] || "github-actions[bot]",
1636
2120
  findingsPath: args.findings,
1637
2121
  envelopePath: args.usage,
1638
- pricesPath: resolvePricesPath(args.prices),
2122
+ pricesPath: priceResolution.path,
2123
+ pricesProvided: priceResolution.kind === "provided",
1639
2124
  templatePath: resolveTemplatePath(args.template),
1640
- inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
2125
+ inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
1641
2126
  route: args.route,
1642
2127
  headBranch: args["head-branch"],
1643
2128
  effort: args.effort,
1644
- testReportPath: args["test-report"]
2129
+ testReportPath: args["test-report"],
2130
+ runUrl: args["run-url"],
2131
+ jsonUrl: args["json-url"]
1645
2132
  });
1646
2133
  }
1647
2134
  });
@@ -1649,7 +2136,7 @@ var main = defineCommand({
1649
2136
  meta: {
1650
2137
  name: "code-review",
1651
2138
  version: packageVersion,
1652
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
2139
+ description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, validate, and stop-gate findings JSON"
1653
2140
  },
1654
2141
  subCommands: {
1655
2142
  gather: gatherCmd,
@@ -1660,7 +2147,9 @@ var main = defineCommand({
1660
2147
  validate: validateCmd,
1661
2148
  adapt: adaptCmd,
1662
2149
  extract: extractCmd,
1663
- "print-schema": printSchemaCmd
2150
+ "validate-patches": validatePatchesCmd,
2151
+ "print-schema": printSchemaCmd,
2152
+ "stop-gate": stopGateCmd
1664
2153
  }
1665
2154
  });
1666
2155
  if (!process.env["VITEST"]) {