@jphutchins/code-review 0.1.0-alpha.3 → 0.1.0-alpha.31

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
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
- import { readFileSync, writeFileSync } from 'fs';
4
- import { resolve as resolve$1, join } from 'path';
3
+ import { readFileSync, writeFileSync, statSync, copyFileSync, readdirSync } from 'fs';
4
+ import { randomBytes } from 'crypto';
5
+ import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
5
6
  import { Eta } from 'eta';
6
7
  import parseDiff from 'parse-diff';
7
8
  import { Ajv2020 } from 'ajv/dist/2020.js';
8
9
  import _addFormats from 'ajv-formats';
9
10
  import * as t from 'io-ts';
10
11
  import { execFile } from 'child_process';
12
+ import { performance } from 'perf_hooks';
13
+ import { PathReporter } from 'io-ts/lib/PathReporter.js';
11
14
 
12
15
  // src/cost.ts
13
16
  var defaultWarn = (message) => {
@@ -53,44 +56,224 @@ var computeCost = (models, prices, warn = defaultWarn) => {
53
56
  };
54
57
  };
55
58
 
59
+ // src/patch.ts
60
+ var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
61
+ var hunkOldStart = (line) => {
62
+ const raw = HUNK_HEADER_RE.exec(line)?.[1];
63
+ return raw !== void 0 ? Number(raw) : null;
64
+ };
65
+ var classifyBodyLine = (line) => {
66
+ if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
67
+ if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
68
+ if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
69
+ return null;
70
+ };
71
+ var trimmedMiddle = (body) => {
72
+ const first = body.findIndex((l) => l.kind !== "context");
73
+ if (first === -1) return [];
74
+ const last = body.findLastIndex((l) => l.kind !== "context");
75
+ return body.slice(first, last + 1);
76
+ };
77
+ var isContiguousChange = (middle) => {
78
+ if (middle.some((l) => l.kind === "context")) return false;
79
+ const firstAdded = middle.findIndex((l) => l.kind === "added");
80
+ if (firstAdded === -1) return true;
81
+ return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
82
+ };
83
+ var removedRange = (body, oldStart) => body.reduce(
84
+ (acc, line) => line.kind === "added" ? acc : {
85
+ lineNumber: acc.lineNumber + 1,
86
+ firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
87
+ lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
88
+ },
89
+ { lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
90
+ );
91
+ var drop = (reason) => ({
92
+ kind: "drop",
93
+ reason
94
+ });
95
+ var keep = (reason) => ({
96
+ kind: "keep",
97
+ reason
98
+ });
99
+ var parseHunk = (patch) => {
100
+ const rawLines = patch.split("\n");
101
+ const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
102
+ const headerHits = lines.reduce(
103
+ (acc, line, index) => {
104
+ const oldStart = hunkOldStart(line);
105
+ return oldStart !== null ? [...acc, { index, oldStart }] : acc;
106
+ },
107
+ []
108
+ );
109
+ if (headerHits.length !== 1) {
110
+ return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
111
+ }
112
+ const hit = headerHits[0];
113
+ if (hit === void 0) return drop("malformed hunk header");
114
+ const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
115
+ const classified = bodyRaw.map(classifyBodyLine);
116
+ if (classified.some((line) => line === null)) return drop("malformed hunk body line");
117
+ const body = classified.filter((line) => line !== null);
118
+ return { kind: "ok", oldStart: hit.oldStart, body };
119
+ };
120
+ var validatePatch = (patch, fileLines) => {
121
+ const parsed = parseHunk(patch);
122
+ if (parsed.kind === "drop") return parsed;
123
+ const { oldStart, body } = parsed;
124
+ const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
125
+ const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
126
+ const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
127
+ if (!oldSideMatches) {
128
+ return drop(
129
+ `patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
130
+ );
131
+ }
132
+ if (!isContiguousChange(trimmedMiddle(body))) {
133
+ return drop("change is not a single contiguous block");
134
+ }
135
+ const removedCount = body.filter((l) => l.kind === "removed").length;
136
+ const addedCount = body.filter((l) => l.kind === "added").length;
137
+ if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
138
+ if (removedCount === 0) {
139
+ return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
140
+ }
141
+ const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
142
+ if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
143
+ return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
144
+ };
145
+ var patchToSuggestion = (patch) => {
146
+ const parsed = parseHunk(patch);
147
+ if (parsed.kind === "drop") return parsed;
148
+ const { body } = parsed;
149
+ if (!isContiguousChange(trimmedMiddle(body))) {
150
+ return drop("change is not a single contiguous block");
151
+ }
152
+ const removedCount = body.filter((l) => l.kind === "removed").length;
153
+ const addedLines = body.filter((l) => l.kind === "added");
154
+ if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
155
+ if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
156
+ return addedLines.map((l) => l.text).join("\n");
157
+ };
158
+
159
+ // src/surface.ts
160
+ var severityEmoji = (s) => {
161
+ switch (s) {
162
+ case "critical":
163
+ return "\u{1F534}";
164
+ case "major":
165
+ return "\u{1F7E0}";
166
+ case "minor":
167
+ return "\u{1F535}";
168
+ case "nit":
169
+ return "\u26AA";
170
+ default:
171
+ return "\u2753";
172
+ }
173
+ };
174
+ var EMBED_LIMIT = 4e4;
175
+ var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
176
+ var encodeMarker = (document, jsonUrl, limit) => {
177
+ const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
178
+ const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
179
+ return marker ? `${AGENTS_STOP_DIRECTIVE}
180
+ ${marker}` : "";
181
+ };
182
+ var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
183
+ var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
184
+ var ZERO_SHA = "0000000000000000000000000000000000000000";
185
+ var parseReviewedSha = (body) => {
186
+ const sha = /<!-- reviewed-sha: ([0-9a-fA-F]{40}) -->/.exec(body)?.[1]?.toLowerCase();
187
+ return sha && sha !== ZERO_SHA ? sha : null;
188
+ };
189
+ var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
190
+ var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
191
+ var parseFindingsMarker = (body) => {
192
+ const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
193
+ const b64 = match?.[1];
194
+ 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
+ }
200
+ };
201
+ var carryForwardMarkers = (body) => {
202
+ const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
203
+ const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
204
+ const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
205
+ ${findings}` : void 0;
206
+ return [findingsBlock, reviewedSha].filter((m) => m !== void 0).join("\n\n");
207
+ };
208
+ var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
209
+ var projectPatch = (patch) => {
210
+ if (patch === void 0) return { kind: "none" };
211
+ const lowered = patchToSuggestion(patch);
212
+ return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
213
+ };
214
+ var formatConfidence = (n) => n.toFixed(2);
215
+ var reviewBodyPointer = (headSha, stickyUrl, marker) => {
216
+ const sha7 = headSha.slice(0, 7);
217
+ 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.`;
218
+ return marker ? `${marker}
219
+
220
+ ${linkLine}` : linkLine;
221
+ };
222
+
56
223
  // src/render.ts
57
- var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
58
224
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
59
225
  var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
60
226
  var sanitizeFinding = (f) => ({
61
227
  ...f,
62
228
  title: escapePipes(f.title),
63
229
  path: escapeCodeBackticks(f.path),
64
- suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
230
+ patchProjection: projectPatch(f.patch)
65
231
  });
232
+ var emptySeverityCounts = () => ({
233
+ critical: 0,
234
+ major: 0,
235
+ minor: 0,
236
+ nit: 0
237
+ });
238
+ var computeSeverityCounts = (findings) => findings.reduce(
239
+ (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
240
+ emptySeverityCounts()
241
+ );
66
242
  var render = (input) => {
67
243
  const eta = new Eta({ autoTrim: false });
68
244
  const usageAvailable = input.envelope !== null;
245
+ const hasUsage = input.envelope !== null && input.envelope.models.length > 0;
246
+ const incomplete = input.incomplete ?? input.envelope?.incomplete ?? false;
69
247
  const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
248
+ const pricesProvided = input.pricesProvided ?? true;
70
249
  const route = input.route ?? input.envelope?.route ?? null;
71
250
  const effort = input.effort ?? input.envelope?.effort ?? null;
72
251
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
73
- const findings = input.findings.findings.map(sanitizeFinding);
74
- const safeFindings = { ...input.findings, findings };
75
- const uniqueFiles = [...new Set(findings.map((f) => f.path))];
76
252
  return eta.renderString(input.template, {
77
- findings: safeFindings,
253
+ findings: input.findings,
78
254
  envelope: input.envelope,
79
255
  usageAvailable,
256
+ hasUsage,
257
+ incomplete,
80
258
  costReport,
259
+ pricesProvided,
81
260
  route,
82
261
  effort,
83
262
  modelNames,
84
263
  testReport: input.testReport ?? null,
85
264
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
86
- totalCount: findings.length,
87
- fileCount: uniqueFiles.length,
88
- // REC-CO-1: nits (and only nits) fold into <details>; everything else stays visible.
89
- visibleFindings: findings.filter((f) => f.severity !== "nit"),
90
- nitFindings: findings.filter((f) => f.severity === "nit"),
91
- suggestionCount: findings.filter((f) => f.suggestion).length,
265
+ postedAt: input.postedAt ?? "",
266
+ severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
267
+ strays: (input.strays ?? []).map(sanitizeFinding),
268
+ unanchoredCount: input.unanchoredCount ?? 0,
269
+ inlineDisposition: input.inlineDisposition ?? null,
270
+ runUrl: input.runUrl ?? null,
271
+ jsonUrl: input.jsonUrl ?? null,
272
+ findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
273
+ reviewUrl: input.reviewUrl ?? null,
92
274
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
93
- formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(3)}` : "\u2014",
275
+ // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
276
+ formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
94
277
  formatDuration: (ms) => {
95
278
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
96
279
  const s = Math.round(ms / 1e3);
@@ -108,20 +291,8 @@ var render = (input) => {
108
291
  return `\u2753 ${v}`;
109
292
  }
110
293
  },
111
- severityEmoji: (s) => {
112
- switch (s) {
113
- case "critical":
114
- return "\u{1F534}";
115
- case "major":
116
- return "\u{1F7E0}";
117
- case "minor":
118
- return "\u{1F535}";
119
- case "nit":
120
- return "\u26AA";
121
- default:
122
- return "\u2753";
123
- }
124
- }
294
+ severityEmoji,
295
+ formatConfidence
125
296
  });
126
297
  };
127
298
  var key = (path, line) => `${path}:${String(line)}`;
@@ -178,33 +349,33 @@ var partitionFindings = (findings, index) => {
178
349
  };
179
350
 
180
351
  // src/inline.ts
181
- var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
182
- var buildCommentBody = (f) => {
183
- const parts = [f.body];
184
- if (f.suggestion !== null && f.suggestion !== void 0) {
185
- const safe = escapeBackticks2(f.suggestion);
186
- parts.push(`\`\`\`suggestion
187
- ${safe}
188
- \`\`\``);
189
- }
190
- return parts.join("\n\n");
191
- };
192
- var renderCommentBody = (f, eta, template) => {
193
- return eta.renderString(template, {
352
+ var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
353
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
354
+ // Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
355
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
356
+ eta.renderString(template, {
194
357
  ...f,
195
- suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
196
- });
197
- };
198
- var buildInlineComments = (findings, diff, inlineTemplate) => {
358
+ patchProjection: projectPatch(f.patch),
359
+ severityEmoji,
360
+ formatConfidence,
361
+ modelsText,
362
+ jsonUrl: jsonUrl ?? null,
363
+ findingsPointer: pointer
364
+ })
365
+ );
366
+ var buildInlineComments = (findings, diff, context) => {
367
+ const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
199
368
  const index = indexDiff(diff);
200
369
  const { inDiff, strays } = partitionFindings(findings, index);
201
- const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
370
+ const eta = new Eta({ autoTrim: false });
371
+ const modelsText = formatModels(models);
202
372
  const comments = inDiff.map((f) => {
373
+ const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
203
374
  const comment = {
204
375
  path: f.path,
205
376
  line: f.end_line,
206
377
  side: defaultSide(f.side),
207
- body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
378
+ body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
208
379
  };
209
380
  if (f.start_line < f.end_line) {
210
381
  return {
@@ -215,7 +386,7 @@ var buildInlineComments = (findings, diff, inlineTemplate) => {
215
386
  }
216
387
  return comment;
217
388
  });
218
- return { comments, strays };
389
+ return { comments, strays, inDiff };
219
390
  };
220
391
  var renderStraysSection = (strays) => {
221
392
  if (strays.length === 0) return "";
@@ -231,6 +402,129 @@ var renderStraysSection = (strays) => {
231
402
  ...items
232
403
  ].join("\n");
233
404
  };
405
+ var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
406
+ var errMsg = (e) => e instanceof Error ? e.message : String(e);
407
+ var annotationSafe = (msg) => msg.replaceAll(/[\r\n]+/g, " ");
408
+ var tryParseJson = (text) => {
409
+ try {
410
+ return { ok: true, value: JSON.parse(text) };
411
+ } catch {
412
+ return { ok: false };
413
+ }
414
+ };
415
+ var readFileOrNull = (path) => {
416
+ try {
417
+ return readFileSync(path, "utf-8");
418
+ } catch {
419
+ return null;
420
+ }
421
+ };
422
+
423
+ // src/transcript.ts
424
+ var numField = (rec, key2) => {
425
+ const v = rec[key2];
426
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
427
+ };
428
+ var messageUsage = (entry) => {
429
+ const rec = asRecord(entry);
430
+ if (rec === null || rec["type"] !== "assistant") return null;
431
+ const msg = asRecord(rec["message"]);
432
+ if (msg === null) return null;
433
+ const model = msg["model"];
434
+ const usage = asRecord(msg["usage"]);
435
+ if (typeof model !== "string" || usage === null) return null;
436
+ const id = msg["id"];
437
+ return {
438
+ id: typeof id === "string" ? id : null,
439
+ model,
440
+ input: numField(usage, "input_tokens"),
441
+ output: numField(usage, "output_tokens"),
442
+ cacheRead: numField(usage, "cache_read_input_tokens"),
443
+ cacheWrite: numField(usage, "cache_creation_input_tokens")
444
+ };
445
+ };
446
+ var tsMsOf = (entry) => {
447
+ const rec = asRecord(entry);
448
+ const ts = rec?.["timestamp"];
449
+ if (typeof ts !== "string") return null;
450
+ const ms = Date.parse(ts);
451
+ return Number.isNaN(ms) ? null : ms;
452
+ };
453
+ var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
454
+ var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
455
+ try {
456
+ return [JSON.parse(line)];
457
+ } catch {
458
+ return [];
459
+ }
460
+ });
461
+ var sumTranscriptUsage = (entries) => {
462
+ const summed = entries.reduce(
463
+ (acc, entry) => {
464
+ const u = messageUsage(entry);
465
+ if (u === null) return acc;
466
+ if (u.id !== null && acc.seen.has(u.id)) return acc;
467
+ if (u.id !== null) acc.seen.add(u.id);
468
+ const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
469
+ acc.totals.set(u.model, {
470
+ input: prev.input + u.input,
471
+ output: prev.output + u.output,
472
+ cacheRead: prev.cacheRead + u.cacheRead,
473
+ cacheWrite: prev.cacheWrite + u.cacheWrite
474
+ });
475
+ return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
476
+ },
477
+ { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
478
+ );
479
+ const models = [...summed.totals].map(([model, t7]) => ({
480
+ model,
481
+ input_tokens: t7.input,
482
+ output_tokens: t7.output,
483
+ cache_read_tokens: t7.cacheRead,
484
+ cache_write_tokens: t7.cacheWrite
485
+ }));
486
+ const bounds = entries.reduce(
487
+ (acc, entry) => {
488
+ const ms = tsMsOf(entry);
489
+ if (ms === null) return acc;
490
+ return {
491
+ min: acc.min === null || ms < acc.min ? ms : acc.min,
492
+ max: acc.max === null || ms > acc.max ? ms : acc.max
493
+ };
494
+ },
495
+ { min: null, max: null }
496
+ );
497
+ return {
498
+ models,
499
+ turns: summed.turns,
500
+ durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
501
+ firstTsMs: bounds.min,
502
+ lastTsMs: bounds.max
503
+ };
504
+ };
505
+ var subagentFiles = (mainPath) => {
506
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
507
+ try {
508
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
509
+ } catch {
510
+ return [];
511
+ }
512
+ };
513
+ var readTranscriptTree = (mainPath) => {
514
+ const mainText = readFileOrNull(mainPath);
515
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
516
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
517
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
518
+ const siblingReads = siblings.flatMap((path) => {
519
+ const text = readFileOrNull(path);
520
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
521
+ });
522
+ return {
523
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
524
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
525
+ missing: mainText === null
526
+ };
527
+ };
234
528
  var SeverityCodec = t.union([
235
529
  t.literal("critical"),
236
530
  t.literal("major"),
@@ -258,14 +552,16 @@ var FindingShape = t.intersection([
258
552
  end_line: LineNumber,
259
553
  severity: SeverityCodec,
260
554
  title: t.string,
261
- body: t.string
555
+ description: t.string,
556
+ reasoning: t.string,
557
+ confidence: Confidence
262
558
  }),
263
559
  t.partial({
264
560
  side: SideCodec,
265
- suggestion: t.union([t.string, t.null]),
266
- confidence: Confidence,
267
561
  code: t.string,
268
- code_url: t.string
562
+ code_url: t.string,
563
+ recommendation: t.string,
564
+ patch: t.string
269
565
  })
270
566
  ]);
271
567
  var EndGeStart = t.refinement(
@@ -313,7 +609,12 @@ var ResultEnvelopeCodec = t.intersection([
313
609
  t.partial({
314
610
  vendor_cost_usd: t.union([t.number, t.null]),
315
611
  route: t.string,
316
- effort: 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
317
618
  })
318
619
  ]);
319
620
  var ModelPricesCodec = t.type({
@@ -341,7 +642,13 @@ var TestSummaryCodec = t.intersection([
341
642
  failures: t.array(TestFailureCodec)
342
643
  })
343
644
  ]);
344
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
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
+ });
345
652
 
346
653
  // src/validate.ts
347
654
  var addFormats = _addFormats;
@@ -377,10 +684,348 @@ var unsafeUnwrap = (decoded) => {
377
684
  if (decoded._tag === "Right") return decoded.right;
378
685
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
379
686
  };
687
+
688
+ // src/stop-gate.ts
689
+ var whatsWrong = (state, draftPath, kind) => {
690
+ switch (state.kind) {
691
+ case "missing":
692
+ return `${draftPath} does not exist yet`;
693
+ case "unreadable":
694
+ return `${draftPath} could not be read: ${state.error}`;
695
+ case "invalid":
696
+ return `${draftPath} does not validate against the ${kind} schema:
697
+ ${state.errors.map((e) => ` - ${e}`).join("\n")}`;
698
+ }
699
+ };
700
+ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
701
+ if (state.kind === "valid") return { kind: "allow" };
702
+ if (nudges >= maxNudges) return { kind: "allow" };
703
+ return {
704
+ kind: "block",
705
+ reason: [
706
+ `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
707
+ `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.`,
708
+ `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind} --explain" until it exits 0 before ending your turn (--explain prints the schema when the shape is wrong).`
709
+ ].join("\n")
710
+ };
711
+ };
712
+ var draftState = (draftPath, resolveSchema) => {
713
+ let raw;
714
+ try {
715
+ raw = readFileSync(draftPath, "utf-8");
716
+ } catch (err) {
717
+ if (err instanceof Error && err.code === "ENOENT") {
718
+ return { kind: "missing" };
719
+ }
720
+ return { kind: "unreadable", error: errMsg(err) };
721
+ }
722
+ let parsed;
723
+ try {
724
+ parsed = JSON.parse(raw);
725
+ } catch (err) {
726
+ return {
727
+ kind: "invalid",
728
+ errors: [`not valid JSON: ${errMsg(err)}`]
729
+ };
730
+ }
731
+ let schemaPath;
732
+ try {
733
+ schemaPath = resolveSchema(parsed);
734
+ } catch (err) {
735
+ return { kind: "invalid", errors: [errMsg(err)] };
736
+ }
737
+ try {
738
+ const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
739
+ return valid ? { kind: "valid" } : { kind: "invalid", errors };
740
+ } catch (err) {
741
+ return { kind: "invalid", errors: [errMsg(err)] };
742
+ }
743
+ };
744
+ var readNudges = (counterPath) => {
745
+ try {
746
+ const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
747
+ return Number.isInteger(n) && n >= 0 ? n : 0;
748
+ } catch {
749
+ return 0;
750
+ }
751
+ };
752
+ var bumpNudges = (counterPath, current) => {
753
+ writeFileSync(counterPath, `${String(current + 1)}
754
+ `);
755
+ };
756
+ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
757
+ var defaultHookCommand = (draftPath, opts) => [
758
+ "code-review stop-gate --draft",
759
+ shellQuote(draftPath),
760
+ ...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
761
+ ...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
762
+ ...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
763
+ ...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
764
+ ...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
765
+ ].join(" ");
766
+ var stopHookSettings = (command) => ({
767
+ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
768
+ });
769
+
770
+ // src/budget.ts
771
+ var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
772
+ var DEFAULT_RESERVE = {
773
+ frac: 0.15,
774
+ growth: 0.25,
775
+ flatUsd: 0.02,
776
+ flatMs: 12e4
777
+ };
778
+ var SOFT_MULTIPLE = 2;
779
+ var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
780
+ var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
781
+ var axisSeverity = (a, reserve) => {
782
+ const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
783
+ const effFrac = reserve.frac + reserve.growth * usedFrac;
784
+ const hardReserve = Math.max(a.flat, effFrac * a.limit);
785
+ const remaining = a.limit - a.used;
786
+ if (remaining <= hardReserve) return 2;
787
+ if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
788
+ return 0;
789
+ };
790
+ var decideBudget = (i) => {
791
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
792
+ return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
793
+ };
794
+ var pct = (n) => `${String(Math.round(n * 100))}%`;
795
+ var money = (n) => `$${n.toFixed(2)}`;
796
+ var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
797
+ var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.budgetUsd > 0 ? `spent ${money(i.spentUsd)}/${money(i.budgetUsd)} (${pct(i.spentUsd / i.budgetUsd)})` : `spent ${money(i.spentUsd)}`;
798
+ var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wallMs > 0 ? `${mins(i.elapsedMs)}/${mins(i.wallMs)} elapsed (${pct(i.elapsedMs / i.wallMs)})` : `${mins(i.elapsedMs)} elapsed`;
799
+ var directive = (phase, draftPath, isSubagent) => {
800
+ if (isSubagent)
801
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
802
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Do not wait for subagents still running in the background. Write your COMPLETE findings from what you already have to ${draftPath} and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 fold in the subagent reports you already have rather than waiting on stragglers; you may run out of budget before you finish otherwise.`;
803
+ };
804
+ var budgetMessage = (i, phase, draftPath, isSubagent) => {
805
+ const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
806
+ return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
807
+ };
808
+ var invokesCodeReviewValidate = (toolInput) => {
809
+ const cmd = asRecord(toolInput)?.["command"];
810
+ return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
811
+ };
812
+ var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
813
+ var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
814
+ var blockedDuringConvergence = (toolName, toolInput) => {
815
+ if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
816
+ if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
817
+ return false;
818
+ };
819
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
820
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
821
+ var writesToDraft = (toolName, toolInput, draftPath) => {
822
+ const rec = asRecord(toolInput);
823
+ const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
824
+ if (WRITE_TOOLS.has(toolName)) {
825
+ const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
826
+ if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
827
+ return true;
828
+ }
829
+ if (toolName === "Bash") {
830
+ const cmd = rec?.["command"];
831
+ if (typeof cmd !== "string") return false;
832
+ const alt = targets.map(escapeRegExp).join("|");
833
+ const end = "(?=$|[\\s|&;)])";
834
+ const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
835
+ const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
836
+ return redirect.test(cmd) || teeArg.test(cmd);
837
+ }
838
+ return false;
839
+ };
840
+ var singleWriterMessage = (draftPath) => `Only the main agent may write ${draftPath}. When a subagent writes it too, the concurrent writers clobber each other and the review comes out empty. Do NOT write, edit, or redirect into ${draftPath} \u2014 instead, return the findings you discovered in your reply (the field names are in the schema); the main agent collects every subagent's reported findings and writes the draft itself.`;
841
+ var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
842
+ var lastValidPath = (draftPath) => {
843
+ const ext = extname(draftPath);
844
+ return join(dirname(draftPath), `${basename(draftPath, ext)}.last-valid${ext}`);
845
+ };
846
+ var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
847
+ var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and a pre-seeded draft does not count until you have revised it yourself this run. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
848
+ var forceBackgroundSpawn = (toolInput) => ({
849
+ hookSpecificOutput: {
850
+ hookEventName: "PreToolUse",
851
+ permissionDecision: "allow",
852
+ updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
853
+ }
854
+ });
855
+ var denyPreTool = (reason) => ({
856
+ hookSpecificOutput: {
857
+ hookEventName: "PreToolUse",
858
+ permissionDecision: "deny",
859
+ permissionDecisionReason: reason
860
+ }
861
+ });
862
+ var isSubagentHookInput = (input) => {
863
+ const agentId = asRecord(input)?.["agent_id"];
864
+ return typeof agentId === "string" && agentId.length > 0;
865
+ };
866
+ var evaluateBudgetHook = (input, params) => {
867
+ const rec = asRecord(input);
868
+ const inputs = {
869
+ spentUsd: params.spentUsd,
870
+ budgetUsd: params.budgetUsd,
871
+ elapsedMs: params.elapsedMs,
872
+ wallMs: params.wallMs,
873
+ reserve: params.reserve
874
+ };
875
+ const phase = decideBudget(inputs);
876
+ const isSubagent = isSubagentHookInput(input);
877
+ switch (rec?.["hook_event_name"]) {
878
+ case "PostToolBatch":
879
+ return phase.kind === "ok" ? {} : {
880
+ hookSpecificOutput: {
881
+ hookEventName: "PostToolBatch",
882
+ additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
883
+ }
884
+ };
885
+ case "PreToolUse": {
886
+ const toolName = rec["tool_name"];
887
+ if (typeof toolName !== "string") return {};
888
+ if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
889
+ return denyPreTool(singleWriterMessage(params.draftPath));
890
+ if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
891
+ return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
892
+ if (SPAWN_TOOLS.has(toolName)) {
893
+ if (!isSubagent && !params.mainDraftWritten)
894
+ return denyPreTool(spawnFloorMessage(params.draftPath));
895
+ return forceBackgroundSpawn(rec["tool_input"]);
896
+ }
897
+ return {};
898
+ }
899
+ default:
900
+ return {};
901
+ }
902
+ };
903
+ var parseWallMs = (raw) => {
904
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
905
+ if (m === null) return null;
906
+ const [, num = "", unit = "s"] = m;
907
+ const n = Number.parseFloat(num);
908
+ if (!Number.isFinite(n)) return null;
909
+ switch (unit) {
910
+ case "ms":
911
+ return n;
912
+ case "s":
913
+ return n * 1e3;
914
+ case "m":
915
+ return n * 6e4;
916
+ default:
917
+ return n * 36e5;
918
+ }
919
+ };
920
+ var parseEpochSecMs = (raw) => {
921
+ if (raw === void 0) return null;
922
+ const t7 = raw.trim();
923
+ if (!/^\d+$/.test(t7)) return null;
924
+ const n = Number.parseInt(t7, 10);
925
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
926
+ };
927
+ var anchoredElapsedMs = (src) => {
928
+ if (src.deadlineMs !== null && src.wallMs !== null)
929
+ return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
930
+ if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
931
+ return null;
932
+ };
933
+ var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
934
+ var parseFraction = (raw, fallback) => {
935
+ if (raw === void 0) return fallback;
936
+ const n = Number.parseFloat(raw);
937
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
938
+ };
939
+ var budgetHookCommand = (draftPath, opts) => [
940
+ "code-review budget-hook --draft",
941
+ shellQuote(draftPath),
942
+ ...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
943
+ ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
944
+ ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
945
+ ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
946
+ ...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
947
+ ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
948
+ ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
949
+ ].join(" ");
950
+
951
+ // src/format.ts
952
+ var FENCE_RE = /^\s*```/;
953
+ var scanLine = (state, line) => {
954
+ if (FENCE_RE.test(line)) {
955
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
956
+ }
957
+ if (state.inFence) {
958
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
959
+ }
960
+ const trimmed = line.replace(/[ \t]+$/, "");
961
+ if (trimmed !== "") {
962
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
963
+ }
964
+ const blankRun = state.blankRun + 1;
965
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
966
+ };
967
+ var formatMarkdown = (md) => {
968
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
969
+ return `${lines.join("\n").replace(/\n+$/, "")}
970
+ `;
971
+ };
972
+ var pad2 = (n) => String(n).padStart(2, "0");
973
+ var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
974
+
975
+ // src/notice.ts
976
+ var NOTICE_KINDS = [
977
+ "security-blocked",
978
+ "triage-error",
979
+ "setup-failed",
980
+ "checkout-failed",
981
+ "no-output"
982
+ ];
983
+ var isNoticeKind = (s) => NOTICE_KINDS.some((k) => k === s);
984
+ var blockquote = (text) => text.replaceAll("\n", "\n> ");
985
+ var reasoned = (lead, noReason, reasons) => typeof reasons === "string" && reasons.trim() !== "" ? `${lead}
986
+
987
+ > ${blockquote(reasons)}` : noReason;
988
+ var noticeSummary = (kind, reasons) => {
989
+ switch (kind) {
990
+ case "security-blocked":
991
+ return reasoned(
992
+ "### \u{1F6D1} Code review skipped by the security gate\n\nThe diff was flagged as unsafe to apply and execute:",
993
+ "### \u{1F6D1} Code review skipped by the security gate\n\nThe security triage returned an unsafe verdict without a reason. See workflow logs.",
994
+ reasons
995
+ );
996
+ case "triage-error":
997
+ return reasoned(
998
+ "### \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:",
999
+ "### \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).",
1000
+ reasons
1001
+ );
1002
+ case "setup-failed":
1003
+ 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.";
1004
+ case "checkout-failed":
1005
+ 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.";
1006
+ case "no-output":
1007
+ return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs.";
1008
+ }
1009
+ };
1010
+ var noticeEnvelope = (summary) => ({
1011
+ schema_version: DEFAULT_SCHEMA_VERSION,
1012
+ findings: noticeFindings(summary),
1013
+ models: [],
1014
+ turns: 0,
1015
+ duration_ms: 0,
1016
+ vendor_cost_usd: null,
1017
+ incomplete: true
1018
+ });
1019
+ var buildNoticeEnvelope = (kind, reasons) => noticeEnvelope(noticeSummary(kind, reasons));
1020
+ var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
1021
+ `### \u26A0\uFE0F Review could not be rendered
1022
+
1023
+ The workflow asked for an unrecognized notice kind (\`${kind}\`) \u2014 the pinned code-review CLI is older than the workflow calling it (check that its version matches). Failing closed. See the workflow logs.`
1024
+ );
380
1025
  var identity = (decoded) => decoded;
381
1026
  var findingsTable = [
382
1027
  {
383
- minor: "0.2",
1028
+ minor: "0.4",
384
1029
  defaultVersion: DEFAULT_SCHEMA_VERSION,
385
1030
  schemaFile: "findings.schema.json",
386
1031
  codec: FindingsCodec,
@@ -460,6 +1105,7 @@ var resolvers = {
460
1105
  prices: (raw) => resolveSingleVersion("prices", raw)
461
1106
  };
462
1107
  var resolve = (kind, raw) => resolvers[kind](raw);
1108
+ var describeEndpoint = (args) => args.find((a) => a === "graphql" || a.includes("/") && !a.startsWith("-")) ?? args[0] ?? "(no endpoint)";
463
1109
  var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
464
1110
  const child = execFile(
465
1111
  "gh",
@@ -469,7 +1115,7 @@ var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
469
1115
  if (err) {
470
1116
  const stderrStr = typeof stderr === "string" && stderr.trim() ? stderr.trim() : "";
471
1117
  const errStr = err instanceof Error ? err.message : "unknown error";
472
- reject(new Error(`gh api failed: ${stderrStr || errStr}`));
1118
+ reject(new Error(`gh api ${describeEndpoint(args)} failed: ${stderrStr || errStr}`));
473
1119
  } else {
474
1120
  resolve3(stdout);
475
1121
  }
@@ -481,18 +1127,34 @@ var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
481
1127
  });
482
1128
 
483
1129
  // src/pr.ts
1130
+ var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
1131
+ var parseCandidates = (stdout) => parseJsonl(stdout);
1132
+ var fetchDirectCandidates = async (repo, headSha, ghApi) => {
1133
+ try {
1134
+ return parseCandidates(
1135
+ await ghApi([`repos/${repo}/commits/${headSha}/pulls`, "--jq", CANDIDATE_JQ])
1136
+ );
1137
+ } catch {
1138
+ return [];
1139
+ }
1140
+ };
484
1141
  var fetchPrCandidates = async (repo, headSha, ghApi) => {
485
- const stdout = await ghApi([
486
- `repos/${repo}/commits/${headSha}/pulls`,
487
- "--jq",
488
- ".[] | {number: .number, state: .state, headRef: .head.ref}"
489
- ]);
490
- return stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
1142
+ const direct = await fetchDirectCandidates(repo, headSha, ghApi);
1143
+ if (direct.length > 0) return direct;
1144
+ const open = parseCandidates(
1145
+ await ghApi([
1146
+ `repos/${repo}/pulls?state=open&per_page=100`,
1147
+ "--paginate",
1148
+ "--jq",
1149
+ CANDIDATE_JQ
1150
+ ])
1151
+ );
1152
+ return open.filter((c) => c.headSha === headSha);
491
1153
  };
492
1154
  var resolvePr = (candidates, headBranch) => {
493
1155
  if (candidates.length === 0) return { kind: "none" };
494
1156
  const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
495
- const chosen = scoped[0] ?? candidates[0];
1157
+ const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
496
1158
  if (chosen === void 0) return { kind: "none" };
497
1159
  return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
498
1160
  };
@@ -505,7 +1167,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
505
1167
  // src/post.ts
506
1168
  var DEFAULT_MARKER = "<!-- code-review -->";
507
1169
  var MAX_SUGGESTION_LINES = 10;
508
- var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
509
1170
  var countSuggestionLines = (text) => text.split("\n").length;
510
1171
  var checkLongSuggestions = (comments) => {
511
1172
  const longFiles = [];
@@ -525,13 +1186,6 @@ var checkLongSuggestions = (comments) => {
525
1186
  });
526
1187
  return { comments: adjusted, longFiles };
527
1188
  };
528
- var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
529
- var noticeFindings = (message) => ({
530
- schema_version: DEFAULT_SCHEMA_VERSION,
531
- summary: `### \u26A0\uFE0F ${message}`,
532
- verdict: "comment",
533
- findings: []
534
- });
535
1189
  var loadFindings = (path) => {
536
1190
  let raw;
537
1191
  try {
@@ -578,7 +1232,7 @@ var loadTestReport = (path) => {
578
1232
  raw = JSON.parse(readFileSync(path, "utf-8"));
579
1233
  } catch (err) {
580
1234
  process.stderr.write(
581
- `Warning: could not read test report at ${path}: ${err instanceof Error ? err.message : String(err)} \u2014 omitting test panel
1235
+ `Warning: could not read test report at ${path}: ${errMsg(err)} \u2014 omitting test panel
582
1236
  `
583
1237
  );
584
1238
  return void 0;
@@ -593,20 +1247,55 @@ var loadTestReport = (path) => {
593
1247
  }
594
1248
  return decoded.right;
595
1249
  };
596
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
597
- const body = JSON.stringify({
598
- body: "",
1250
+ var parseHtmlUrl = (raw) => {
1251
+ const parsed = tryParseJson(raw);
1252
+ const htmlUrl = parsed.ok ? asRecord(parsed.value)?.["html_url"] : void 0;
1253
+ return typeof htmlUrl === "string" ? htmlUrl : void 0;
1254
+ };
1255
+ var commentPayload = (c) => ({
1256
+ path: c.path,
1257
+ line: c.line,
1258
+ side: c.side,
1259
+ ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1260
+ body: formatMarkdown(c.body)
1261
+ });
1262
+ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
1263
+ const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
1264
+ const reviewBody = (withComments) => JSON.stringify({
1265
+ body: pointer,
599
1266
  commit_id: headSha,
600
1267
  event: "COMMENT",
601
- comments: comments.map((c) => ({
602
- path: c.path,
603
- line: c.line,
604
- side: c.side,
605
- ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
606
- body: c.body
607
- }))
1268
+ comments: withComments ? comments.map(commentPayload) : []
608
1269
  });
609
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
1270
+ const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
1271
+ try {
1272
+ const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
1273
+ return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
1274
+ } catch (err) {
1275
+ if (comments.length === 0) throw err;
1276
+ process.stderr.write(
1277
+ `Warning: the batched inline review on PR #${String(prNumber)} was rejected (${errMsg(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
1278
+ `
1279
+ );
1280
+ const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
1281
+ const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
1282
+ const unposted = [];
1283
+ let inlinePosted = 0;
1284
+ for (const [i, c] of comments.entries()) {
1285
+ try {
1286
+ await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
1287
+ inlinePosted += 1;
1288
+ } catch (e) {
1289
+ const finding = inDiff[i];
1290
+ if (finding) unposted.push(finding);
1291
+ process.stderr.write(
1292
+ `Warning: inline comment on ${c.path}:${String(c.line)} rejected (${errMsg(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
1293
+ `
1294
+ );
1295
+ }
1296
+ }
1297
+ return { url, inlinePosted, unposted };
1298
+ }
610
1299
  };
611
1300
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
612
1301
  const stdout = await ghApi(
@@ -626,33 +1315,44 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
626
1315
  const parsed = JSON.parse(last);
627
1316
  return { id: parsed.id, body: parsed.body };
628
1317
  };
1318
+ var parseCommentRef = (raw) => {
1319
+ const parsed = tryParseJson(raw);
1320
+ const rec = parsed.ok ? asRecord(parsed.value) : null;
1321
+ const id = rec?.["id"];
1322
+ const html_url = rec?.["html_url"];
1323
+ return typeof id === "number" && typeof html_url === "string" ? { id, html_url } : null;
1324
+ };
629
1325
  var patchComment = async (repo, commentId, body, ghApi) => {
630
- await ghApi(
1326
+ const stdout = await ghApi(
631
1327
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
632
1328
  JSON.stringify({ body })
633
1329
  );
1330
+ const htmlUrl = parseHtmlUrl(stdout);
1331
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
634
1332
  };
635
1333
  var postComment = async (repo, prNumber, body, ghApi) => {
636
- await ghApi(
1334
+ const stdout = await ghApi(
637
1335
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
638
1336
  JSON.stringify({ body })
639
1337
  );
1338
+ return parseCommentRef(stdout);
640
1339
  };
641
1340
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
642
1341
  if (existing !== null) {
643
- await patchComment(repo, existing.id, body, ghApi);
1342
+ const patched = await patchComment(repo, existing.id, body, ghApi);
644
1343
  process.stderr.write(
645
1344
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
646
1345
  `
647
1346
  );
648
- } else {
649
- await postComment(repo, prNumber, body, ghApi);
650
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
651
- `);
1347
+ return { id: existing.id, url: patched?.html_url };
652
1348
  }
1349
+ const posted = await postComment(repo, prNumber, body, ghApi);
1350
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
1351
+ `);
1352
+ return posted ? { id: posted.id, url: posted.html_url } : null;
653
1353
  };
654
1354
  var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
655
- var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
1355
+ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
656
1356
  const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
657
1357
  let reviews;
658
1358
  try {
@@ -661,10 +1361,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
661
1361
  return [];
662
1362
  }
663
1363
  if (!Array.isArray(reviews)) return [];
664
- return reviews.filter((r) => isBotReview(r)).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => r.id);
1364
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
665
1365
  };
666
- var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
667
- const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
1366
+ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
668
1367
  for (const id of ids) {
669
1368
  try {
670
1369
  await ghApi(
@@ -679,11 +1378,91 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
679
1378
  );
680
1379
  } catch (err) {
681
1380
  process.stderr.write(
682
- `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1381
+ `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${errMsg(err)}
1382
+ `
1383
+ );
1384
+ }
1385
+ }
1386
+ };
1387
+ var REVIEW_THREAD_COMMENTS_QUERY = "query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage}nodes{comments(first:100){nodes{id isMinimized author{login}}}}}}}}";
1388
+ var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1389
+ var priorBotCommentId = (c, logins) => {
1390
+ if (typeof c !== "object" || c === null) return null;
1391
+ const o = c;
1392
+ const login = o.author?.login;
1393
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
1394
+ };
1395
+ var priorBotCommentIds = (raw, botLogin) => {
1396
+ let parsed;
1397
+ try {
1398
+ parsed = JSON.parse(raw);
1399
+ } catch {
1400
+ return { ids: [], truncated: false };
1401
+ }
1402
+ const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
1403
+ const truncated = conn?.pageInfo?.hasNextPage === true;
1404
+ const nodes = conn?.nodes;
1405
+ if (!Array.isArray(nodes)) return { ids: [], truncated };
1406
+ const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1407
+ const ids = nodes.flatMap((t7) => {
1408
+ const cnodes = t7.comments?.nodes;
1409
+ return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1410
+ });
1411
+ return { ids, truncated };
1412
+ };
1413
+ var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
1414
+ const slash = repo.indexOf("/");
1415
+ if (slash <= 0) return [];
1416
+ const owner = repo.slice(0, slash);
1417
+ const name = repo.slice(slash + 1);
1418
+ let raw;
1419
+ try {
1420
+ raw = await ghApi([
1421
+ "graphql",
1422
+ "-f",
1423
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
1424
+ "-f",
1425
+ `owner=${owner}`,
1426
+ "-f",
1427
+ `name=${name}`,
1428
+ "-F",
1429
+ `pr=${String(prNumber)}`
1430
+ ]);
1431
+ } catch (err) {
1432
+ process.stderr.write(
1433
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${errMsg(err)}
1434
+ `
1435
+ );
1436
+ return [];
1437
+ }
1438
+ const { ids, truncated } = priorBotCommentIds(raw, botLogin);
1439
+ if (truncated) {
1440
+ process.stderr.write(
1441
+ `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1442
+ `
1443
+ );
1444
+ }
1445
+ return ids;
1446
+ };
1447
+ var minimizeComments = async (prNumber, ids, ghApi) => {
1448
+ let minimized = 0;
1449
+ for (const id of ids) {
1450
+ try {
1451
+ await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
1452
+ minimized += 1;
1453
+ } catch (err) {
1454
+ process.stderr.write(
1455
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${errMsg(err)}
683
1456
  `
684
1457
  );
685
1458
  }
686
1459
  }
1460
+ if (minimized > 0) {
1461
+ process.stderr.write(
1462
+ `Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
1463
+ `
1464
+ );
1465
+ }
687
1466
  };
688
1467
  var post = async (input, ghApi = runGhApi) => {
689
1468
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
@@ -709,25 +1488,40 @@ var post = async (input, ghApi = runGhApi) => {
709
1488
  DEFAULT_MARKER,
710
1489
  ghApi
711
1490
  );
712
- const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
713
- const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
1491
+ const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
1492
+ const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
1493
+ const leaveInPlace = () => {
1494
+ process.stderr.write(
1495
+ `Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place
1496
+ `
1497
+ );
1498
+ process.exit(0);
1499
+ };
714
1500
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
715
1501
  const decodedPrices = PriceMapCodec.decode(prices);
716
1502
  if (decodedPrices._tag === "Left") {
717
1503
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
718
1504
  }
719
1505
  const template = readFileSync(input.templatePath, "utf-8");
720
- const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
721
- const renderNotice = (message) => render({
722
- findings: noticeFindings(message),
723
- envelope: null,
724
- prices: decodedPrices.right,
725
- template,
726
- route: input.route,
727
- reviewedSha: input.headSha,
728
- effort: input.effort
729
- });
1506
+ const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
1507
+ const renderNotice = (message) => formatMarkdown(
1508
+ render({
1509
+ findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
1510
+ envelope: null,
1511
+ incomplete: true,
1512
+ prices: decodedPrices.right,
1513
+ pricesProvided: input.pricesProvided,
1514
+ template,
1515
+ route: input.route,
1516
+ reviewedSha: input.headSha,
1517
+ effort: input.effort,
1518
+ runUrl: input.runUrl,
1519
+ jsonUrl: input.jsonUrl,
1520
+ postedAt: input.postedAt
1521
+ })
1522
+ );
730
1523
  if (isEmptyDiff(diff)) {
1524
+ if (wouldBuryCompleted(true)) leaveInPlace();
731
1525
  await upsertSticky(
732
1526
  input.repo,
733
1527
  prNumber,
@@ -739,6 +1533,7 @@ var post = async (input, ghApi = runGhApi) => {
739
1533
  }
740
1534
  const findingsResult = loadFindings(input.findingsPath);
741
1535
  if (findingsResult.kind !== "ok") {
1536
+ if (wouldBuryCompleted(true)) leaveInPlace();
742
1537
  await upsertSticky(
743
1538
  input.repo,
744
1539
  prNumber,
@@ -752,27 +1547,42 @@ var post = async (input, ghApi = runGhApi) => {
752
1547
  const envelope = loadEnvelope(input.envelopePath);
753
1548
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
754
1549
  if (envelope === null) {
755
- const body2 = render({
756
- findings,
757
- envelope: null,
758
- prices: decodedPrices.right,
759
- template,
760
- route: input.route,
761
- reviewedSha: input.headSha,
762
- effort: input.effort,
763
- testReport
764
- });
765
- await upsertSticky(input.repo, prNumber, existingSticky, body2, ghApi);
1550
+ const body = formatMarkdown(
1551
+ render({
1552
+ findings,
1553
+ envelope: null,
1554
+ prices: decodedPrices.right,
1555
+ pricesProvided: input.pricesProvided,
1556
+ template,
1557
+ route: input.route,
1558
+ reviewedSha: input.headSha,
1559
+ effort: input.effort,
1560
+ testReport,
1561
+ inlineDisposition: { kind: "no-envelope" },
1562
+ runUrl: input.runUrl,
1563
+ jsonUrl: input.jsonUrl,
1564
+ postedAt: input.postedAt
1565
+ })
1566
+ );
1567
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
766
1568
  process.stderr.write(
767
1569
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
768
1570
  );
769
1571
  process.exit(0);
770
1572
  }
771
- const { comments: rawComments, strays } = buildInlineComments(
772
- findings.findings,
773
- diff,
774
- inlineTemplate
775
- );
1573
+ const thisIncomplete = envelope.incomplete === true;
1574
+ if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
1575
+ const findingsMarker = findingsPointer(findings, input.jsonUrl);
1576
+ const {
1577
+ comments: rawComments,
1578
+ strays,
1579
+ inDiff
1580
+ } = buildInlineComments(findings.findings, diff, {
1581
+ inlineTemplate,
1582
+ models: envelope.models.map((m) => m.model),
1583
+ findings,
1584
+ jsonUrl: input.jsonUrl
1585
+ });
776
1586
  const { comments, longFiles } = checkLongSuggestions(rawComments);
777
1587
  for (const wf of longFiles) {
778
1588
  process.stderr.write(
@@ -780,45 +1590,392 @@ var post = async (input, ghApi = runGhApi) => {
780
1590
  `
781
1591
  );
782
1592
  }
783
- let body = render({
1593
+ const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1594
+ const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1595
+ const commonRenderInput = {
784
1596
  findings,
785
1597
  envelope,
1598
+ incomplete: thisIncomplete,
786
1599
  prices: decodedPrices.right,
1600
+ pricesProvided: input.pricesProvided,
787
1601
  template,
788
1602
  route: input.route,
789
1603
  reviewedSha: input.headSha,
790
1604
  effort: input.effort,
791
- testReport
792
- });
793
- const straysMd = renderStraysSection(strays);
794
- if (straysMd.length > 0) body += straysMd;
795
- if (longFiles.length > 0) {
796
- body += `
1605
+ testReport,
1606
+ severityCounts: computeSeverityCounts(findings.findings),
1607
+ strays,
1608
+ runUrl: input.runUrl,
1609
+ jsonUrl: input.jsonUrl,
1610
+ findingsPointer: findingsMarker,
1611
+ postedAt: input.postedAt
1612
+ };
1613
+ const longFilesNote = longFiles.length > 0 ? `
797
1614
 
798
1615
  ---
799
1616
 
800
- > **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments. See the findings above for details.
801
- `;
802
- }
803
- if (!isRerunOfSameSha && previousReviewedSha !== null) {
804
- await dismissPriorBotReviews(input.repo, prNumber, input.botLogin, ghApi);
805
- }
806
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
807
- if (isRerunOfSameSha) {
808
- process.stderr.write(
809
- `Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
1617
+ > **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.
1618
+ ` : "";
1619
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
1620
+ render({
1621
+ ...commonRenderInput,
1622
+ ...straysOverride ? { strays: straysOverride } : {},
1623
+ ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
1624
+ inlineDisposition,
1625
+ reviewUrl: reviewUrl2
1626
+ }) + longFilesNote
1627
+ );
1628
+ const stickyRef = await upsertSticky(
1629
+ input.repo,
1630
+ prNumber,
1631
+ existingSticky,
1632
+ renderBody(initialDisposition),
1633
+ ghApi
1634
+ );
1635
+ const priorInlineComments = await listPriorBotCommentIds(
1636
+ input.repo,
1637
+ prNumber,
1638
+ input.botLogin,
1639
+ ghApi
1640
+ );
1641
+ const {
1642
+ url: reviewUrl,
1643
+ inlinePosted,
1644
+ unposted
1645
+ } = await postInlineReview(
1646
+ input.repo,
1647
+ prNumber,
1648
+ input.headSha,
1649
+ comments,
1650
+ inDiff,
1651
+ stickyRef?.url,
1652
+ findingsMarker,
1653
+ ghApi
1654
+ );
1655
+ process.stderr.write(
1656
+ `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
810
1657
  `
811
- );
812
- return;
813
- }
814
- if (comments.length > 0) {
815
- await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
816
- process.stderr.write(
817
- `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
1658
+ );
1659
+ const priorReviewIds = botReviews.map((r) => r.id);
1660
+ if (priorReviewIds.length > 0) {
1661
+ await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
1662
+ }
1663
+ await minimizeComments(prNumber, priorInlineComments, ghApi);
1664
+ const unanchoredCount = unposted.length;
1665
+ const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
1666
+ if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
1667
+ const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
1668
+ try {
1669
+ await patchComment(
1670
+ input.repo,
1671
+ stickyRef.id,
1672
+ renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
1673
+ ghApi
1674
+ );
1675
+ process.stderr.write(
1676
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
818
1677
  `
819
- );
1678
+ );
1679
+ } catch (err) {
1680
+ process.stderr.write(
1681
+ `Warning: failed to update the sticky summary after the review: ${errMsg(err)}
1682
+ `
1683
+ );
1684
+ }
1685
+ }
1686
+ };
1687
+ var announceBody = (headSha, runUrl, existingBody) => {
1688
+ const notice = `${DEFAULT_MARKER}
1689
+
1690
+ \u{1F504} **Code review in progress** for \`${headSha.slice(0, 7)}\` \u2014 see the [workflow run](${runUrl}) for progress; this comment is updated with the review when it completes.`;
1691
+ const carried = existingBody ? carryForwardMarkers(existingBody) : "";
1692
+ return carried ? `${notice}
1693
+
1694
+ ${carried}` : notice;
1695
+ };
1696
+ var announce = async (input, ghApi = runGhApi) => {
1697
+ const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
1698
+ const resolution = resolvePr(candidates, input.headBranch);
1699
+ if (resolution.kind !== "open") {
1700
+ process.stderr.write(
1701
+ `No open PR for ${input.headSha} \u2014 nothing to announce (${resolution.kind})
1702
+ `
1703
+ );
1704
+ return;
1705
+ }
1706
+ const existing = await findBotComment(
1707
+ input.repo,
1708
+ resolution.prNumber,
1709
+ input.botLogin,
1710
+ DEFAULT_MARKER,
1711
+ ghApi
1712
+ );
1713
+ if (existing !== null && parseReviewComplete(existing.body) && parseReviewedSha(existing.body) === input.headSha.toLowerCase()) {
1714
+ process.stderr.write(
1715
+ `Sticky already reflects a completed review of ${input.headSha} \u2014 leaving it in place
1716
+ `
1717
+ );
1718
+ return;
1719
+ }
1720
+ await upsertSticky(
1721
+ input.repo,
1722
+ resolution.prNumber,
1723
+ existing,
1724
+ announceBody(input.headSha, input.runUrl, existing?.body),
1725
+ ghApi
1726
+ );
1727
+ };
1728
+ var DURATION_RE = /^(\d+)(h|m|s)$/;
1729
+ var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
1730
+ var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
1731
+ var stripTrigger = (body, trigger) => {
1732
+ const trimmed = body.replace(/^\s+/, "");
1733
+ if (!trimmed.startsWith(trigger)) return null;
1734
+ const after = trimmed.slice(trigger.length);
1735
+ return after === "" || /^\s/.test(after) ? after : null;
1736
+ };
1737
+ var scanLeading = (s, acc) => {
1738
+ const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
1739
+ if (m === null) return { ...acc, rest: "" };
1740
+ const [, , token = "", tail = ""] = m;
1741
+ const dm = DURATION_RE.exec(token);
1742
+ if (dm && acc.durationSec === null)
1743
+ return scanLeading(tail, {
1744
+ ...acc,
1745
+ durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
1746
+ });
1747
+ const um = USD_RE.exec(token);
1748
+ if (um && acc.usd === null)
1749
+ return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
1750
+ return { ...acc, rest: s };
1751
+ };
1752
+ var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1753
+ value: ceiling,
1754
+ notes: [
1755
+ `requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
1756
+ ]
1757
+ } : { value: requested, notes: [] };
1758
+ var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1759
+ value: ceiling,
1760
+ notes: [
1761
+ `requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
1762
+ ]
1763
+ } : { value: requested, notes: [] };
1764
+ var capInstructions = (text, maxLen) => text.length > maxLen ? {
1765
+ value: text.slice(0, maxLen),
1766
+ notes: [
1767
+ `instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
1768
+ ]
1769
+ } : { value: text, notes: [] };
1770
+ var parseCommandArgs = (body, options) => {
1771
+ const afterTrigger = stripTrigger(body, options.trigger);
1772
+ if (afterTrigger === null) return { kind: "not-a-command" };
1773
+ const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
1774
+ const duration = clampDuration(scan.durationSec, options.maxDurationSec);
1775
+ const usd = clampUsd(scan.usd, options.maxUsd);
1776
+ const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
1777
+ return {
1778
+ kind: "command",
1779
+ args: {
1780
+ durationSec: duration.value,
1781
+ usd: usd.value,
1782
+ instructions: instructions.value,
1783
+ notes: [...duration.notes, ...usd.notes, ...instructions.notes]
1784
+ }
1785
+ };
1786
+ };
1787
+ var PrHeadCodec = t.type({
1788
+ head_sha: t.string,
1789
+ head_ref: t.string,
1790
+ head_repo: t.union([t.string, t.null]),
1791
+ state: t.string
1792
+ });
1793
+ var resolvePrHead = async (repo, prNumber, ghApi) => {
1794
+ const stdout = await ghApi([
1795
+ `repos/${repo}/pulls/${String(prNumber)}`,
1796
+ "--jq",
1797
+ "{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
1798
+ ]);
1799
+ const decoded = PrHeadCodec.decode(JSON.parse(stdout));
1800
+ if (decoded._tag === "Left") {
1801
+ throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
1802
+ }
1803
+ return decoded.right;
1804
+ };
1805
+ var parseCommand = async (input, ghApi = runGhApi) => {
1806
+ const parse = parseCommandArgs(input.body, input.options);
1807
+ if (parse.kind === "not-a-command") {
1808
+ return {
1809
+ kind: "skip",
1810
+ reason: `comment does not begin with the trigger "${input.options.trigger}"`
1811
+ };
1812
+ }
1813
+ const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
1814
+ (err) => err instanceof Error ? err : new Error(String(err))
1815
+ );
1816
+ if (head instanceof Error) {
1817
+ return {
1818
+ kind: "skip",
1819
+ reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
1820
+ };
1821
+ }
1822
+ if (head.state !== "open") {
1823
+ return {
1824
+ kind: "skip",
1825
+ reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
1826
+ };
1827
+ }
1828
+ return {
1829
+ kind: "run",
1830
+ headSha: head.head_sha,
1831
+ headBranch: head.head_ref,
1832
+ headRepo: head.head_repo ?? input.repo,
1833
+ args: parse.args
1834
+ };
1835
+ };
1836
+ var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
1837
+ const candidate = `GHOUT_${randomHex()}`;
1838
+ if (!instructions.split("\n").includes(candidate)) return candidate;
1839
+ if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
1840
+ return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
1841
+ };
1842
+ var renderCommandOutputs = (result, delim) => {
1843
+ if (result.kind === "skip") return "should_run=false\n";
1844
+ const { headSha, headBranch, headRepo, args } = result;
1845
+ return `${[
1846
+ "should_run=true",
1847
+ `head_sha=${headSha}`,
1848
+ `head_branch=${headBranch}`,
1849
+ `head_repo=${headRepo}`,
1850
+ `duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
1851
+ `usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
1852
+ `instructions<<${delim}`,
1853
+ args.instructions,
1854
+ delim
1855
+ ].join("\n")}
1856
+ `;
1857
+ };
1858
+ var REACTIONS = [
1859
+ "+1",
1860
+ "-1",
1861
+ "laugh",
1862
+ "confused",
1863
+ "heart",
1864
+ "hooray",
1865
+ "rocket",
1866
+ "eyes"
1867
+ ];
1868
+ var isReaction = (s) => REACTIONS.includes(s);
1869
+ var ReactionCodec = t.type({ id: t.number, content: t.string });
1870
+ var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
1871
+ var removeReactions = async (repo, commentId, content, ghApi) => {
1872
+ const stdout = await ghApi([
1873
+ reactionsPath(repo, commentId),
1874
+ "--paginate",
1875
+ "--jq",
1876
+ ".[] | {id, content}"
1877
+ ]);
1878
+ for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
1879
+ const parsed = tryParseJson(line);
1880
+ const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
1881
+ if (decoded === void 0 || decoded._tag === "Left") {
1882
+ process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
1883
+ continue;
1884
+ }
1885
+ if (decoded.right.content !== content) continue;
1886
+ await ghApi([
1887
+ "--method",
1888
+ "DELETE",
1889
+ `${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
1890
+ ]).catch(
1891
+ (err) => process.stderr.write(
1892
+ `code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
1893
+ `
1894
+ )
1895
+ );
1896
+ }
1897
+ };
1898
+ var react = async (input, ghApi = runGhApi) => {
1899
+ if (input.add !== void 0) {
1900
+ await ghApi([
1901
+ "--method",
1902
+ "POST",
1903
+ reactionsPath(input.repo, input.commentId),
1904
+ "-f",
1905
+ `content=${input.add}`
1906
+ ]);
1907
+ }
1908
+ if (input.remove !== void 0) {
1909
+ await removeReactions(input.repo, input.commentId, input.remove, ghApi);
1910
+ }
1911
+ };
1912
+ var RunCodec = t.type({
1913
+ id: t.number,
1914
+ name: t.union([t.string, t.null]),
1915
+ status: t.union([t.string, t.null]),
1916
+ conclusion: t.union([t.string, t.null]),
1917
+ run_number: t.number
1918
+ });
1919
+ var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number}";
1920
+ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
1921
+ const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
1922
+ const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
1923
+ const decoded = rows.map((row) => RunCodec.decode(row));
1924
+ const runs = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
1925
+ const dropped = decoded.length - runs.length;
1926
+ if (dropped > 0) {
1927
+ const firstDrift = decoded.find((d) => d._tag === "Left");
1928
+ const detail = firstDrift === void 0 ? "" : ` (${PathReporter.report(firstDrift).join("; ")})`;
1929
+ process.stderr.write(
1930
+ `Warning: ${String(dropped)} of ${String(rows.length)} workflow-run row(s) from ${endpoint} failed to decode${detail} \u2014 excluded from the lookup
1931
+ `
1932
+ );
820
1933
  }
1934
+ const latest = runs.filter((r) => r.name === workflowName).reduce(
1935
+ (best, r) => best === null || r.run_number > best.run_number ? r : best,
1936
+ null
1937
+ );
1938
+ return {
1939
+ run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
1940
+ seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
1941
+ };
821
1942
  };
1943
+ var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
1944
+ const safeResolve = async () => {
1945
+ try {
1946
+ return await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
1947
+ } catch (err) {
1948
+ process.stderr.write(
1949
+ `Warning: CI-run lookup for ${headSha} failed (${errMsg(err)}) \u2014 retrying until the timeout
1950
+ `
1951
+ );
1952
+ return { run: null, seenNames: [] };
1953
+ }
1954
+ };
1955
+ const poll = async (lastSeenNames, lastRunId) => {
1956
+ const { run, seenNames } = await safeResolve();
1957
+ if (run !== null && run.status === "completed" && run.conclusion !== null)
1958
+ return { kind: "concluded", conclusion: run.conclusion, runId: run.id };
1959
+ const runId = run === null ? lastRunId : run.id;
1960
+ const names = seenNames.length > 0 ? seenNames : lastSeenNames;
1961
+ if (deps.elapsedMs() >= options.timeoutMs)
1962
+ return { kind: "timed-out", runId, seenNames: names };
1963
+ await deps.sleep(options.pollIntervalMs);
1964
+ return poll(names, runId);
1965
+ };
1966
+ return poll([], null);
1967
+ };
1968
+ var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
1969
+ var monotonicElapsed = () => {
1970
+ const start = performance.now();
1971
+ return () => performance.now() - start;
1972
+ };
1973
+ var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
1974
+ ci_conclusion=${outcome.conclusion}
1975
+ ci_run_id=${String(outcome.runId)}
1976
+ ` : `ci_settled=false
1977
+ ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
1978
+ `;
822
1979
  var renderOutputs = (result) => {
823
1980
  switch (result.kind) {
824
1981
  case "skip":
@@ -827,6 +1984,7 @@ var renderOutputs = (result) => {
827
1984
  return `pr=${String(result.pr)}
828
1985
  conclusion=${result.conclusion}
829
1986
  diff_size=${String(result.diffSize)}
1987
+ stacked=${String(result.stacked)}
830
1988
  `;
831
1989
  }
832
1990
  };
@@ -849,22 +2007,28 @@ var runGit = (args) => new Promise((resolve3, reject) => {
849
2007
  var PrMetaCodec = t.type({
850
2008
  changed_files: t.number,
851
2009
  base_sha: t.string,
2010
+ base_ref: t.string,
852
2011
  title: t.string,
853
2012
  body: t.union([t.string, t.null])
854
2013
  });
855
- var IssueCommentCodec = t.type({
856
- id: t.number,
857
- body: t.union([t.string, t.null]),
858
- user: t.type({ login: t.string })
859
- });
860
- var IssueCommentsCodec = t.array(IssueCommentCodec);
2014
+ var IssueCommentCodec = t.intersection([
2015
+ t.type({
2016
+ id: t.number,
2017
+ body: t.union([t.string, t.null]),
2018
+ user: t.type({ login: t.string })
2019
+ }),
2020
+ t.partial({
2021
+ created_at: t.union([t.string, t.null]),
2022
+ author_association: t.union([t.string, t.null])
2023
+ })
2024
+ ]);
861
2025
  var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
862
2026
  var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
863
2027
  var fetchPrMeta = async (repo, prNumber, ghApi) => {
864
2028
  const stdout = await ghApi([
865
2029
  `repos/${repo}/pulls/${String(prNumber)}`,
866
2030
  "--jq",
867
- "{changed_files: .changed_files, base_sha: .base.sha, title: .title, body: .body}"
2031
+ "{changed_files: .changed_files, base_sha: .base.sha, base_ref: .base.ref, title: .title, body: .body}"
868
2032
  ]);
869
2033
  const decoded = PrMetaCodec.decode(JSON.parse(stdout));
870
2034
  if (decoded._tag === "Left") {
@@ -879,18 +2043,139 @@ var fetchApiDiff = async (repo, prNumber, ghApi) => {
879
2043
  return null;
880
2044
  }
881
2045
  };
882
- var fetchPriorReview = async (repo, prNumber, botLogin, ghApi) => {
2046
+ var fetchFullDiff = async (repo, defaultBranch, headSha, ghApi, gitRun) => {
883
2047
  try {
884
- const stdout = await ghApi([`repos/${repo}/issues/${String(prNumber)}/comments`, "--paginate"]);
885
- const decoded = IssueCommentsCodec.decode(JSON.parse(stdout || "[]"));
886
- if (decoded._tag === "Left") return null;
887
- const byBot = decoded.right.filter((c) => c.user.login === botLogin);
888
- const last = byBot[byBot.length - 1];
889
- return last ? { id: last.id, body: last.body } : null;
890
- } catch {
2048
+ const diff = await ghApi([
2049
+ `repos/${repo}/compare/${defaultBranch}...${headSha}`,
2050
+ "-H",
2051
+ "Accept: application/vnd.github.v3.diff"
2052
+ ]);
2053
+ if (diff.length > 0) return diff;
2054
+ process.stderr.write(
2055
+ `compare diff for ${defaultBranch}...${headSha} was empty \u2014 falling back to git diff
2056
+ `
2057
+ );
2058
+ } catch (err) {
2059
+ process.stderr.write(`compare diff fetch failed (${errMsg(err)}) \u2014 falling back to git diff
2060
+ `);
2061
+ }
2062
+ await gitRun(["fetch", "origin", headSha]);
2063
+ const base = (await gitRun(["rev-parse", "HEAD"])).trim();
2064
+ return gitRun(["diff", `${base}...${headSha}`]);
2065
+ };
2066
+ var CommitCodec = t.type({
2067
+ sha: t.string,
2068
+ message: t.string,
2069
+ author: t.union([t.string, t.null]),
2070
+ email: t.union([t.string, t.null])
2071
+ });
2072
+ var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email}";
2073
+ var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
2074
+ const rows = parseJsonl(
2075
+ await ghApi([
2076
+ `repos/${repo}/compare/${defaultBranch}...${headSha}`,
2077
+ "--paginate",
2078
+ "--jq",
2079
+ COMMIT_JQ
2080
+ ])
2081
+ );
2082
+ const decoded = rows.map((row) => CommitCodec.decode(row));
2083
+ const commits = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
2084
+ const dropped = decoded.length - commits.length;
2085
+ if (dropped > 0) {
2086
+ process.stderr.write(
2087
+ `Warning: ${String(dropped)} of ${String(rows.length)} commit row(s) failed to decode \u2014 their messages are NOT in the triage scan though a checkout's git log still exposes them
2088
+ `
2089
+ );
2090
+ }
2091
+ return commits;
2092
+ };
2093
+ var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
2094
+ var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
2095
+ var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
2096
+ var ReviewCommentCodec = t.intersection([
2097
+ t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
2098
+ t.partial({
2099
+ created_at: t.union([t.string, t.null]),
2100
+ author_association: t.union([t.string, t.null]),
2101
+ path: t.union([t.string, t.null]),
2102
+ line: t.union([t.number, t.null])
2103
+ })
2104
+ ]);
2105
+ var ReviewCodec = t.intersection([
2106
+ t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
2107
+ t.partial({
2108
+ submitted_at: t.union([t.string, t.null]),
2109
+ author_association: t.union([t.string, t.null]),
2110
+ state: t.union([t.string, t.null])
2111
+ })
2112
+ ]);
2113
+ var fetchJsonlRows = async (ghApi, endpoint, jq) => {
2114
+ try {
2115
+ return parseJsonl(await ghApi([endpoint, "--paginate", "--jq", jq]));
2116
+ } catch (err) {
2117
+ process.stderr.write(
2118
+ `Warning: could not fetch ${endpoint} (${errMsg(err)}) \u2014 omitting it from the review context
2119
+ `
2120
+ );
891
2121
  return null;
892
2122
  }
893
2123
  };
2124
+ var decodeArrayOrNull = (codec, rows) => {
2125
+ if (rows === null) return null;
2126
+ return rows.flatMap((row) => {
2127
+ const decoded = codec.decode(row);
2128
+ return decoded._tag === "Right" ? [decoded.right] : [];
2129
+ });
2130
+ };
2131
+ var priorReviewFrom = (comments, botLogin) => {
2132
+ const byBot = comments.filter((c) => c.user.login === botLogin);
2133
+ const last = byBot[byBot.length - 1];
2134
+ return last ? { id: last.id, body: last.body } : null;
2135
+ };
2136
+ var MAX_CONVERSATION_COMMENTS = 50;
2137
+ var MAX_CONVERSATION_BODY_CHARS = 4e3;
2138
+ var clip = (body) => {
2139
+ if (body.length <= MAX_CONVERSATION_BODY_CHARS) return body;
2140
+ const cut = body.slice(0, MAX_CONVERSATION_BODY_CHARS);
2141
+ const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
2142
+ return `${safe}
2143
+ \u2026 [truncated]`;
2144
+ };
2145
+ var boundedHuman = (items, botLogin, label, project) => {
2146
+ const human = items.filter(
2147
+ (a) => a.user.login !== botLogin && typeof a.body === "string" && a.body.trim() !== ""
2148
+ );
2149
+ const kept = human.slice(-MAX_CONVERSATION_COMMENTS);
2150
+ if (kept.length < human.length) {
2151
+ process.stderr.write(
2152
+ `Note: PR has ${String(human.length)} ${label} \u2014 feeding the review the most recent ${String(MAX_CONVERSATION_COMMENTS)}
2153
+ `
2154
+ );
2155
+ }
2156
+ return kept.map(project);
2157
+ };
2158
+ var issueCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "discussion comments", (c) => ({
2159
+ author: c.user.login,
2160
+ author_association: c.author_association ?? null,
2161
+ created_at: c.created_at ?? null,
2162
+ body: clip(c.body)
2163
+ }));
2164
+ var reviewCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "inline review comments", (c) => ({
2165
+ author: c.user.login,
2166
+ author_association: c.author_association ?? null,
2167
+ created_at: c.created_at ?? null,
2168
+ path: c.path ?? null,
2169
+ line: c.line ?? null,
2170
+ body: clip(c.body)
2171
+ }));
2172
+ var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review submissions", (r) => ({
2173
+ author: r.user.login,
2174
+ author_association: r.author_association ?? null,
2175
+ submitted_at: r.submitted_at ?? null,
2176
+ state: r.state ?? null,
2177
+ body: clip(r.body)
2178
+ }));
894
2179
  var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
895
2180
  const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
896
2181
  const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
@@ -903,7 +2188,7 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
903
2188
  writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
904
2189
  } catch (err) {
905
2190
  process.stderr.write(
906
- `Warning: failed to download logs for job ${String(job.id)}: ${err instanceof Error ? err.message : String(err)} \u2014 continuing with the logs retrieved so far
2191
+ `Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
907
2192
  `
908
2193
  );
909
2194
  }
@@ -926,8 +2211,10 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
926
2211
  }
927
2212
  const prNumber = resolution.prNumber;
928
2213
  const meta = await fetchPrMeta(input.repo, prNumber, ghApi);
929
- const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
930
- const diff = apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0) ? apiDiff : await (async () => {
2214
+ const stacked = meta.base_ref !== input.defaultBranch;
2215
+ const prDiff = await (async () => {
2216
+ const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
2217
+ if (apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0)) return apiDiff;
931
2218
  process.stderr.write(
932
2219
  `PR diff fetch failed or was empty for ${String(meta.changed_files)} changed files \u2014 falling back to git diff
933
2220
  `
@@ -935,16 +2222,40 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
935
2222
  await gitRun(["fetch", "origin", input.headSha]);
936
2223
  return gitRun(["diff", meta.base_sha, input.headSha]);
937
2224
  })();
938
- writeFileSync(join(input.outDir, "pr.diff"), diff);
2225
+ const fullDiff = stacked ? await fetchFullDiff(input.repo, input.defaultBranch, input.headSha, ghApi, gitRun) : prDiff;
2226
+ const commits = await fetchCompareCommits(input.repo, input.defaultBranch, input.headSha, ghApi);
2227
+ writeFileSync(join(input.outDir, "full.diff"), fullDiff);
2228
+ writeFileSync(join(input.outDir, "pr.diff"), prDiff);
2229
+ writeFileSync(join(input.outDir, "commits.json"), JSON.stringify(commits));
939
2230
  writeFileSync(
940
2231
  join(input.outDir, "pr_context.json"),
941
2232
  JSON.stringify({ title: meta.title, body: meta.body })
942
2233
  );
943
- const prior = await fetchPriorReview(input.repo, prNumber, input.botLogin, ghApi);
2234
+ const [issueRows, reviewCommentRows, reviewRows] = await Promise.all([
2235
+ fetchJsonlRows(ghApi, `repos/${input.repo}/issues/${String(prNumber)}/comments`, COMMENT_JQ),
2236
+ fetchJsonlRows(
2237
+ ghApi,
2238
+ `repos/${input.repo}/pulls/${String(prNumber)}/comments`,
2239
+ REVIEW_COMMENT_JQ
2240
+ ),
2241
+ fetchJsonlRows(ghApi, `repos/${input.repo}/pulls/${String(prNumber)}/reviews`, REVIEW_JQ)
2242
+ ]);
2243
+ const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
2244
+ const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
2245
+ const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
2246
+ const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
944
2247
  writeFileSync(
945
2248
  join(input.outDir, "prior_review.json"),
946
2249
  prior === null ? "null" : JSON.stringify(prior)
947
2250
  );
2251
+ writeFileSync(
2252
+ join(input.outDir, "pr_conversation.json"),
2253
+ JSON.stringify({
2254
+ issue_comments: issueComments === null ? [] : issueCommentsFrom(issueComments, input.botLogin),
2255
+ review_comments: reviewComments === null ? [] : reviewCommentsFrom(reviewComments, input.botLogin),
2256
+ reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
2257
+ })
2258
+ );
948
2259
  if (input.conclusion === "failure") {
949
2260
  await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
950
2261
  }
@@ -952,9 +2263,12 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
952
2263
  kind: "gathered",
953
2264
  pr: prNumber,
954
2265
  conclusion: input.conclusion,
955
- diffSize: Buffer.byteLength(diff, "utf8")
2266
+ diffSize: Buffer.byteLength(prDiff, "utf8"),
2267
+ stacked
956
2268
  };
957
2269
  };
2270
+
2271
+ // src/extract.ts
958
2272
  var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
959
2273
  var parseNativeForExtraction = (raw) => ({
960
2274
  result: fieldOf(raw, "result"),
@@ -1007,20 +2321,6 @@ var gateCandidate = (kind, rawCandidate) => {
1007
2321
  const resolution = resolve(kind, candidate);
1008
2322
  return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
1009
2323
  };
1010
- var tryParseJson = (text) => {
1011
- try {
1012
- return { ok: true, value: JSON.parse(text) };
1013
- } catch {
1014
- return { ok: false };
1015
- }
1016
- };
1017
- var readFileOrNull = (path) => {
1018
- try {
1019
- return readFileSync(path, "utf-8");
1020
- } catch {
1021
- return null;
1022
- }
1023
- };
1024
2324
  var candidateFromJsonText = (kind, text) => {
1025
2325
  if (text === null) return null;
1026
2326
  const parsed = tryParseJson(text);
@@ -1028,7 +2328,7 @@ var candidateFromJsonText = (kind, text) => {
1028
2328
  };
1029
2329
  var FENCE_OPEN = /^\s*(`{3,})/;
1030
2330
  var FENCE_MARKER_ONLY = /^`+$/;
1031
- var scanLine = (state, line) => {
2331
+ var scanLine2 = (state, line) => {
1032
2332
  if (state.openLength === null) {
1033
2333
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1034
2334
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1037,7 +2337,31 @@ var scanLine = (state, line) => {
1037
2337
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1038
2338
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1039
2339
  };
1040
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
2340
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
2341
+ var ladderFailureDiagnostics = (input) => {
2342
+ const native = parseNativeForExtraction(input.native);
2343
+ const preview = (s) => {
2344
+ const flat = s.replace(/\s+/g, " ").trim();
2345
+ return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
2346
+ };
2347
+ const lines = [];
2348
+ if (input.kind === "findings") {
2349
+ lines.push(
2350
+ input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
2351
+ );
2352
+ if (input.agentFileFallbackPath !== void 0)
2353
+ lines.push(
2354
+ `last-valid rung: ${input.agentFileFallbackPath} did not validate (or was absent)`
2355
+ );
2356
+ }
2357
+ lines.push(
2358
+ isNullish(native.structuredOutput) ? "structured_output rung: absent (null) \u2014 the CLI's --json-schema likely did not enforce" : "structured_output rung: present but did not validate against the schema"
2359
+ );
2360
+ lines.push(
2361
+ typeof native.result === "string" ? `result rung: ${String(native.result.length)} chars, ${String(scanFencedBlocks(native.result).length)} fenced JSON block(s), none validated; preview: ${preview(native.result)}` : "result rung: absent or not a string"
2362
+ );
2363
+ return lines.join("\n");
2364
+ };
1041
2365
  var describeLadderFailure = (outcome) => {
1042
2366
  switch (outcome.kind) {
1043
2367
  case "error-envelope":
@@ -1055,13 +2379,16 @@ var okOutcome = (gated) => ({
1055
2379
  });
1056
2380
  var extractStructured = (input) => {
1057
2381
  const native = parseNativeForExtraction(input.native);
2382
+ if (input.kind === "findings") {
2383
+ for (const path of [input.agentFilePath, input.agentFileFallbackPath]) {
2384
+ if (path === void 0) continue;
2385
+ const fromFile = candidateFromJsonText(input.kind, readFileOrNull(path));
2386
+ if (fromFile) return okOutcome(fromFile);
2387
+ }
2388
+ }
1058
2389
  if (isErrorEnvelope(native)) {
1059
2390
  return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
1060
2391
  }
1061
- if (input.kind === "findings" && input.agentFilePath !== void 0) {
1062
- const fromFile = candidateFromJsonText(input.kind, readFileOrNull(input.agentFilePath));
1063
- if (fromFile) return okOutcome(fromFile);
1064
- }
1065
2392
  if (native.structuredOutput !== void 0) {
1066
2393
  const fromStructured = gateCandidate(input.kind, native.structuredOutput);
1067
2394
  if (fromStructured) return okOutcome(fromStructured);
@@ -1081,9 +2408,10 @@ var extractStructured = (input) => {
1081
2408
  };
1082
2409
  }
1083
2410
  }
2411
+ const fallbackRung = input.agentFileFallbackPath ? ", last-valid snapshot" : "";
1084
2412
  return {
1085
2413
  kind: "none",
1086
- detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
2414
+ detail: `no --agent-file${fallbackRung}, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
1087
2415
  };
1088
2416
  };
1089
2417
 
@@ -1120,45 +2448,118 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1120
2448
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1121
2449
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1122
2450
  }));
1123
- var adaptClaudeCode = (native, agentFilePath, meta) => {
1124
- const outcome = extractStructured({ kind: "findings", native, agentFilePath });
1125
- if (outcome.kind !== "ok") {
1126
- return left(describeLadderFailure(outcome));
1127
- }
1128
- const resolution = resolve("findings", outcome.candidate);
1129
- return resolution.kind === "ok" ? right({
1130
- schema_version: resolution.version,
1131
- findings: resolution.value,
2451
+ var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
2452
+ const ladder = extractStructured({
2453
+ kind: "findings",
2454
+ native,
2455
+ agentFilePath,
2456
+ agentFileFallbackPath
2457
+ });
2458
+ if (ladder.kind !== "ok")
2459
+ return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
2460
+ const resolution = resolve("findings", ladder.candidate);
2461
+ return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
2462
+ kind: "telemetry-only",
2463
+ reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
2464
+ };
2465
+ };
2466
+ var withMeta = (base, meta) => ({
2467
+ ...base,
2468
+ ...meta.route ? { route: meta.route } : {},
2469
+ ...meta.effort ? { effort: meta.effort } : {}
2470
+ });
2471
+ var resolveTelemetry = (native, meta) => {
2472
+ const fb = (() => {
2473
+ try {
2474
+ return meta.transcriptFallback?.();
2475
+ } catch {
2476
+ return void 0;
2477
+ }
2478
+ })();
2479
+ const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
2480
+ return withMeta(
2481
+ {
2482
+ models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
2483
+ ...wallTurns,
2484
+ vendor_cost_usd: native.vendorCostUsd
2485
+ },
2486
+ meta
2487
+ );
2488
+ };
2489
+ var nativeTelemetry = (native, meta) => resolveTelemetry(
2490
+ {
1132
2491
  models: mapModelUsage(native.modelUsage),
1133
2492
  turns: native.num_turns,
1134
- duration_ms: native.duration_ms,
1135
- vendor_cost_usd: native.total_cost_usd ?? null,
1136
- ...meta.route ? { route: meta.route } : {},
1137
- ...meta.effort ? { effort: meta.effort } : {}
1138
- }) : left(
1139
- "internal error: the extraction ladder validated a candidate the registry then rejected"
1140
- );
2493
+ durationMs: native.duration_ms,
2494
+ vendorCostUsd: native.total_cost_usd ?? null
2495
+ },
2496
+ meta
2497
+ );
2498
+ var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
2499
+ var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath) => {
2500
+ const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
2501
+ switch (outcome.kind) {
2502
+ case "ok":
2503
+ return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
2504
+ case "telemetry-only":
2505
+ return {
2506
+ schema_version: DEFAULT_SCHEMA_VERSION,
2507
+ findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
2508
+
2509
+ ${outcome.reason}`),
2510
+ incomplete: true,
2511
+ ...telemetry
2512
+ };
2513
+ }
1141
2514
  };
1142
2515
  var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1143
2516
  switch (adapterName) {
1144
2517
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
1145
2518
  case "claude-code": {
2519
+ if (native === void 0 || native === null)
2520
+ return right(
2521
+ buildEnvelope(
2522
+ absentTelemetry(meta),
2523
+ void 0,
2524
+ agentFilePath,
2525
+ meta.agentFileFallbackPath
2526
+ )
2527
+ );
1146
2528
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
1147
- if (decoded._tag === "Left") {
2529
+ if (decoded._tag === "Left")
1148
2530
  return left("native envelope does not match the Claude Code output shape");
1149
- }
1150
- return adaptClaudeCode(decoded.right, agentFilePath, meta);
2531
+ return right(
2532
+ buildEnvelope(
2533
+ nativeTelemetry(decoded.right, meta),
2534
+ native,
2535
+ agentFilePath,
2536
+ meta.agentFileFallbackPath
2537
+ )
2538
+ );
1151
2539
  }
1152
2540
  }
1153
2541
  };
1154
2542
 
2543
+ // src/settings.ts
2544
+ var composeReviewSettings = (opts) => {
2545
+ const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
2546
+ return {
2547
+ hooks: {
2548
+ Stop: [
2549
+ { hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
2550
+ ],
2551
+ PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
2552
+ PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
2553
+ }
2554
+ };
2555
+ };
2556
+
1155
2557
  // src/index.ts
1156
2558
  var readJSON = (path) => {
1157
2559
  try {
1158
2560
  return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
1159
2561
  } catch (err) {
1160
- fail(`Cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
1161
- throw new Error("unreachable", { cause: err });
2562
+ return fail(`Cannot read ${path}: ${errMsg(err)}`);
1162
2563
  }
1163
2564
  };
1164
2565
  var fail = (msg) => {
@@ -1166,32 +2567,86 @@ var fail = (msg) => {
1166
2567
  `);
1167
2568
  process.exit(1);
1168
2569
  };
2570
+ var readJSONOrAbsent = (path) => {
2571
+ const read = (() => {
2572
+ try {
2573
+ return { text: readFileSync(resolve$1(path), "utf-8") };
2574
+ } catch (err) {
2575
+ return { error: errMsg(err) };
2576
+ }
2577
+ })();
2578
+ if ("error" in read) {
2579
+ process.stderr.write(
2580
+ `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry
2581
+ `
2582
+ );
2583
+ return void 0;
2584
+ }
2585
+ if (read.text.trim() === "") {
2586
+ process.stderr.write(
2587
+ `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry
2588
+ `
2589
+ );
2590
+ return void 0;
2591
+ }
2592
+ try {
2593
+ return JSON.parse(read.text);
2594
+ } catch (err) {
2595
+ process.stderr.write(
2596
+ `code-review: native envelope ${path} is not valid JSON (${errMsg(err)}) \u2014 proceeding with no native telemetry
2597
+ `
2598
+ );
2599
+ return void 0;
2600
+ }
2601
+ };
2602
+ var readStdinJSON = () => {
2603
+ if (process.stdin.isTTY) return null;
2604
+ const raw = (() => {
2605
+ try {
2606
+ return readFileSync(0, "utf-8");
2607
+ } catch {
2608
+ return "";
2609
+ }
2610
+ })();
2611
+ if (raw.trim() === "") return null;
2612
+ const parsed = tryParseJson(raw);
2613
+ return parsed.ok ? parsed.value : null;
2614
+ };
1169
2615
  var decode = (either, label) => {
1170
2616
  try {
1171
2617
  return unsafeUnwrap(either);
1172
2618
  } catch {
1173
- fail(`${label} does not match expected shape`);
2619
+ return fail(`${label} does not match expected shape`);
1174
2620
  }
1175
- throw new Error("unreachable");
1176
2621
  };
1177
2622
  var unwrapAdapt = (either) => {
1178
2623
  try {
1179
2624
  if (either._tag === "Left") throw new Error(either.left);
1180
2625
  return either.right;
1181
2626
  } catch (err) {
1182
- fail(err instanceof Error ? err.message : String(err));
2627
+ return fail(errMsg(err));
1183
2628
  }
1184
- throw new Error("unreachable");
2629
+ };
2630
+ var transcriptFallbackFrom = (path) => {
2631
+ const tree = readTranscriptTree(resolve$1(path));
2632
+ if (tree.missing)
2633
+ process.stderr.write(
2634
+ `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
2635
+ `
2636
+ );
2637
+ const usage = sumTranscriptUsage(tree.entries);
2638
+ return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
1185
2639
  };
1186
2640
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1187
2641
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1188
2642
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
1189
- var resolvePricesPath = (pricesArg) => {
1190
- if (pricesArg) return resolve$1(pricesArg);
2643
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
2644
+ var resolvePrices = (pricesArg) => {
2645
+ if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
1191
2646
  process.stderr.write(
1192
- "code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
2647
+ "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
1193
2648
  );
1194
- return bundledPath("schema", "prices.example.json");
2649
+ return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
1195
2650
  };
1196
2651
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
1197
2652
  var renderCmd = defineCommand({
@@ -1239,19 +2694,21 @@ var renderCmd = defineCommand({
1239
2694
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1240
2695
  const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
1241
2696
  const templatePath = resolveTemplatePath(args.template);
1242
- const pricesPath = resolvePricesPath(args.prices);
1243
- const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
2697
+ const priceResolution = resolvePrices(args.prices);
2698
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
1244
2699
  const template = readFileSync(templatePath, "utf-8");
1245
2700
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
1246
2701
  const output = render({
1247
2702
  findings,
1248
2703
  envelope,
1249
2704
  prices,
2705
+ pricesProvided: priceResolution.kind === "provided",
1250
2706
  template,
1251
2707
  reviewedSha: args["reviewed-sha"],
1252
2708
  route: args.route,
1253
2709
  effort: args.effort,
1254
- testReport
2710
+ testReport,
2711
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1255
2712
  });
1256
2713
  process.stdout.write(output);
1257
2714
  }
@@ -1274,14 +2731,17 @@ var inlineCmd = defineCommand({
1274
2731
  },
1275
2732
  template: {
1276
2733
  type: "string",
1277
- description: "Path to inline comment Eta template (default: built-in format)"
2734
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1278
2735
  }
1279
2736
  },
1280
2737
  run: async ({ args }) => {
1281
2738
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1282
2739
  const diff = readFileSync(resolve$1(args.diff), "utf-8");
1283
- const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
1284
- const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
2740
+ const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
2741
+ const { comments, strays } = buildInlineComments(findings.findings, diff, {
2742
+ inlineTemplate,
2743
+ findings
2744
+ });
1285
2745
  process.stdout.write(
1286
2746
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1287
2747
  );
@@ -1311,51 +2771,455 @@ var costCmd = defineCommand({
1311
2771
  process.stdout.write(JSON.stringify(report, null, 2));
1312
2772
  }
1313
2773
  });
1314
- var declaredSchemaVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
1315
- var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
1316
- var validateCmd = defineCommand({
2774
+ var checkCostCmd = defineCommand({
1317
2775
  meta: {
1318
- name: "validate",
1319
- description: "Validate a findings/triage/prices JSON document against the canonical schema"
2776
+ name: "check-cost",
2777
+ description: "Sum real USD spend from a Claude Code transcript tree (main + subagents) against a price map"
1320
2778
  },
1321
2779
  args: {
1322
- document: {
2780
+ transcript: {
1323
2781
  type: "positional",
1324
- description: "Path to the JSON document to validate (of the given --kind)",
2782
+ description: "Path to the session transcript JSONL (the hook's transcript_path)",
1325
2783
  required: true
1326
2784
  },
1327
- kind: {
1328
- type: "string",
1329
- description: "Schema kind to validate against: findings | triage | prices (default: findings)"
1330
- },
1331
- schema: {
1332
- type: "string",
1333
- description: "Path to a schema file (wins over --kind; default: the bundled schema derived from --kind, --schema-version, the document's declared schema_version, or the bundled latest)"
1334
- },
1335
- "schema-version": {
2785
+ prices: {
1336
2786
  type: "string",
1337
- description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
2787
+ description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
1338
2788
  }
1339
2789
  },
1340
2790
  run: async ({ args }) => {
1341
- const kind = requireSchemaKind(args.kind || "findings");
1342
- const documentRaw = readJSON(args.document);
1343
- const schemaPath = args.schema ? resolve$1(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
1344
- const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
1345
- if (valid) {
1346
- process.stdout.write("\u2705 valid\n");
1347
- } else {
1348
- process.stderr.write("\u274C invalid\n");
1349
- for (const e of errors) process.stderr.write(` - ${e}
1350
- `);
1351
- process.exit(1);
2791
+ const tree = readTranscriptTree(resolve$1(args.transcript));
2792
+ if (tree.missing) {
2793
+ process.stderr.write(
2794
+ `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
2795
+ `
2796
+ );
1352
2797
  }
1353
- }
2798
+ const usage = sumTranscriptUsage(tree.entries);
2799
+ const priceResolution = resolvePrices(args.prices);
2800
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
2801
+ const report = computeCost(usage.models, prices);
2802
+ process.stdout.write(
2803
+ `${JSON.stringify(
2804
+ {
2805
+ ...report,
2806
+ turns: usage.turns,
2807
+ durationMs: usage.durationMs,
2808
+ transcripts: tree.files,
2809
+ pricesProvided: priceResolution.kind === "provided"
2810
+ },
2811
+ null,
2812
+ 2
2813
+ )}
2814
+ `
2815
+ );
2816
+ }
2817
+ });
2818
+ var tryReadPrices = (path) => {
2819
+ try {
2820
+ const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
2821
+ return decoded._tag === "Right" ? decoded.right : null;
2822
+ } catch {
2823
+ return null;
2824
+ }
2825
+ };
2826
+ var parseBudgetUsd = (raw) => {
2827
+ if (raw === void 0) return null;
2828
+ const n = Number.parseFloat(raw);
2829
+ return Number.isFinite(n) && n >= 0 ? n : null;
2830
+ };
2831
+ var mtimeMsOf = (path) => {
2832
+ try {
2833
+ return statSync(path).mtimeMs;
2834
+ } catch {
2835
+ return null;
2836
+ }
2837
+ };
2838
+ var transcriptPathOf = (input) => {
2839
+ const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
2840
+ return typeof tp === "string" ? tp : void 0;
2841
+ };
2842
+ var snapshotIfValid = (draftPath) => {
2843
+ try {
2844
+ if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
2845
+ copyFileSync(draftPath, lastValidPath(draftPath));
2846
+ } catch (err) {
2847
+ process.stderr.write(
2848
+ `code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
2849
+ `
2850
+ );
2851
+ }
2852
+ };
2853
+ var budgetHookCmd = defineCommand({
2854
+ meta: {
2855
+ name: "budget-hook",
2856
+ description: "Self-dispatching Claude Code budget hook: on PostToolBatch steer the agent to converge as spend/wall-clock nears the budget; on PreToolUse deny budget-burning tools under the hard reserve, gate subagent spawns until the main agent has drafted, and run permitted spawns in the background. Reads the hook payload on stdin; degrades to a no-op on error."
2857
+ },
2858
+ args: {
2859
+ draft: {
2860
+ type: "string",
2861
+ description: "Path to the findings draft that is the sole permitted write target under forced convergence",
2862
+ required: true
2863
+ },
2864
+ "budget-usd": {
2865
+ type: "string",
2866
+ description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
2867
+ },
2868
+ wall: {
2869
+ type: "string",
2870
+ description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
2871
+ },
2872
+ prices: {
2873
+ type: "string",
2874
+ description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
2875
+ },
2876
+ "reserve-frac": {
2877
+ type: "string",
2878
+ description: "Base wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
2879
+ },
2880
+ "reserve-growth": {
2881
+ type: "string",
2882
+ description: "How much the reserve grows as a budget is spent \u2014 added at full usage, so convergence lands earlier the longer the run has gone (default: 0.25; 0 = flat reserve)"
2883
+ },
2884
+ "reserve-usd": {
2885
+ type: "string",
2886
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2887
+ },
2888
+ "reserve-wall": {
2889
+ type: "string",
2890
+ description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
2891
+ }
2892
+ },
2893
+ run: async ({ args }) => {
2894
+ try {
2895
+ const draftPath = resolve$1(args.draft);
2896
+ const input = readStdinJSON();
2897
+ const transcriptPath = transcriptPathOf(input);
2898
+ const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
2899
+ const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
2900
+ const prices = args.prices ? tryReadPrices(args.prices) : null;
2901
+ const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
2902
+ const wallMs = args.wall ? parseWallMs(args.wall) : null;
2903
+ const output = evaluateBudgetHook(input, {
2904
+ spentUsd,
2905
+ budgetUsd: parseBudgetUsd(args["budget-usd"]),
2906
+ elapsedMs: anchoredElapsedMs({
2907
+ deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
2908
+ wallMs,
2909
+ firstTsMs: usage?.firstTsMs ?? null,
2910
+ nowMs: Date.now()
2911
+ }),
2912
+ wallMs,
2913
+ reserve: {
2914
+ frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
2915
+ growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
2916
+ flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
2917
+ flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
2918
+ },
2919
+ draftPath,
2920
+ mainDraftWritten: mainHasWrittenDraft(
2921
+ mtimeMsOf(draftPath),
2922
+ mtimeMsOf(seedMarkerPath(draftPath))
2923
+ )
2924
+ });
2925
+ if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
2926
+ snapshotIfValid(draftPath);
2927
+ process.stdout.write(`${JSON.stringify(output)}
2928
+ `);
2929
+ } catch (err) {
2930
+ process.stderr.write(`code-review budget-hook: degrading to no-op \u2014 ${errMsg(err)}
2931
+ `);
2932
+ process.stdout.write("{}\n");
2933
+ }
2934
+ }
2935
+ });
2936
+ var printSettingsCmd = defineCommand({
2937
+ meta: {
2938
+ name: "print-settings",
2939
+ description: "Emit one Claude Code --settings JSON composing the Stop deliverable gate and the budget hooks (PreToolUse convergence + PostToolBatch steer) from one self-dispatching command"
2940
+ },
2941
+ args: {
2942
+ draft: {
2943
+ type: "string",
2944
+ description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
2945
+ required: true
2946
+ },
2947
+ kind: {
2948
+ type: "string",
2949
+ description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
2950
+ },
2951
+ schema: {
2952
+ type: "string",
2953
+ description: "Path to a schema file for the Stop gate (wins over --kind)"
2954
+ },
2955
+ "schema-version": {
2956
+ type: "string",
2957
+ description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
2958
+ },
2959
+ "max-nudges": {
2960
+ type: "string",
2961
+ description: "Stop-gate nudge budget before relenting (default: 5)"
2962
+ },
2963
+ counter: {
2964
+ type: "string",
2965
+ description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
2966
+ },
2967
+ "budget-usd": {
2968
+ type: "string",
2969
+ description: "Dollar budget the cost axis is measured against (needs --prices)"
2970
+ },
2971
+ wall: {
2972
+ type: "string",
2973
+ description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
2974
+ },
2975
+ prices: {
2976
+ type: "string",
2977
+ description: "Price map JSON to recompute real spend from the transcript"
2978
+ },
2979
+ "reserve-frac": {
2980
+ type: "string",
2981
+ description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2982
+ },
2983
+ "reserve-growth": {
2984
+ type: "string",
2985
+ description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
2986
+ },
2987
+ "reserve-usd": {
2988
+ type: "string",
2989
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2990
+ },
2991
+ "reserve-wall": {
2992
+ type: "string",
2993
+ description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
2994
+ }
2995
+ },
2996
+ run: async ({ args }) => {
2997
+ if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
2998
+ fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
2999
+ const settings = composeReviewSettings({
3000
+ draftPath: resolve$1(args.draft),
3001
+ stop: {
3002
+ kind: args.kind,
3003
+ schema: args.schema,
3004
+ schemaVersion: args["schema-version"],
3005
+ maxNudges: args["max-nudges"],
3006
+ counter: args.counter
3007
+ },
3008
+ budget: {
3009
+ budgetUsd: args["budget-usd"],
3010
+ wall: args.wall,
3011
+ prices: args.prices,
3012
+ reserveFrac: args["reserve-frac"],
3013
+ reserveGrowth: args["reserve-growth"],
3014
+ reserveUsd: args["reserve-usd"],
3015
+ reserveWall: args["reserve-wall"]
3016
+ }
3017
+ });
3018
+ process.stdout.write(`${JSON.stringify(settings)}
3019
+ `);
3020
+ }
3021
+ });
3022
+ var deadlineCmd = defineCommand({
3023
+ meta: {
3024
+ name: "deadline",
3025
+ description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall) \u2014 exported as CODE_REVIEW_DEADLINE_EPOCH so every budget hook (main and subagents) measures the same remaining wall"
3026
+ },
3027
+ args: {
3028
+ wall: {
3029
+ type: "string",
3030
+ description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
3031
+ required: true
3032
+ }
3033
+ },
3034
+ run: async ({ args }) => {
3035
+ const wallMs = parseWallMs(args.wall);
3036
+ if (wallMs === null) {
3037
+ fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
3038
+ } else {
3039
+ process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
3040
+ `);
3041
+ }
3042
+ }
3043
+ });
3044
+ var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
3045
+ var printableSchema = (schemaPath) => {
3046
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
3047
+ const enforcementSchema = Object.fromEntries(
3048
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
3049
+ );
3050
+ return JSON.stringify(enforcementSchema, null, 2);
3051
+ };
3052
+ var validateCmd = defineCommand({
3053
+ meta: {
3054
+ name: "validate",
3055
+ description: "Validate a findings/triage/prices JSON document against the canonical schema"
3056
+ },
3057
+ args: {
3058
+ document: {
3059
+ type: "positional",
3060
+ description: "Path to the JSON document to validate (of the given --kind)",
3061
+ required: true
3062
+ },
3063
+ kind: {
3064
+ type: "string",
3065
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
3066
+ },
3067
+ schema: {
3068
+ type: "string",
3069
+ description: "Path to a schema file (wins over --kind; default: the bundled schema derived from --kind, --schema-version, the document's declared schema_version, or the bundled latest)"
3070
+ },
3071
+ "schema-version": {
3072
+ type: "string",
3073
+ description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
3074
+ },
3075
+ explain: {
3076
+ type: "boolean",
3077
+ description: "On failure, also print the schema after the errors \u2014 its field descriptions are the authoritative spec, so the document can be fixed in one pass instead of by trial and error"
3078
+ }
3079
+ },
3080
+ run: async ({ args }) => {
3081
+ const kind = requireSchemaKind(args.kind || "findings");
3082
+ const documentRaw = readJSON(args.document);
3083
+ const schemaPath = args.schema ? resolve$1(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
3084
+ const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
3085
+ if (valid) {
3086
+ process.stdout.write("\u2705 valid\n");
3087
+ } else {
3088
+ process.stderr.write("\u274C invalid\n");
3089
+ for (const e of errors) process.stderr.write(` - ${e}
3090
+ `);
3091
+ if (args.explain) {
3092
+ process.stderr.write(
3093
+ `
3094
+ The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
3095
+ ${printableSchema(schemaPath)}
3096
+ `
3097
+ );
3098
+ }
3099
+ process.exit(1);
3100
+ }
3101
+ }
3102
+ });
3103
+ var seedDraftCmd = defineCommand({
3104
+ meta: {
3105
+ name: "seed-draft",
3106
+ description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0. Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself. Prints the mode to stdout (prior-same|prior-new when seeded, by whether the prior review examined this same commit; empty-had-prior when a prior review exists but its findings could not be loaded; empty on a first review; none when even the scaffold write failed) and always exits 0"
3107
+ },
3108
+ args: {
3109
+ prior: {
3110
+ type: "string",
3111
+ description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
3112
+ },
3113
+ "head-sha": {
3114
+ type: "string",
3115
+ description: "Current head SHA, compared against the prior review's embedded reviewed-sha to distinguish a same-commit re-review from a new-commit one; an unknown or mismatched prior SHA is treated as a new commit"
3116
+ },
3117
+ out: {
3118
+ type: "string",
3119
+ description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
3120
+ required: true
3121
+ },
3122
+ kind: {
3123
+ type: "string",
3124
+ description: "Schema kind to validate the prior findings against (default: findings)"
3125
+ },
3126
+ schema: {
3127
+ type: "string",
3128
+ description: "Path to a schema file (wins over --kind/--schema-version)"
3129
+ },
3130
+ "schema-version": {
3131
+ type: "string",
3132
+ description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then falls back to the empty scaffold)"
3133
+ }
3134
+ },
3135
+ run: async ({ args }) => {
3136
+ const outPath = resolve$1(args.out);
3137
+ const kindArg = args.kind || "findings";
3138
+ const kind = isSchemaKind(kindArg) ? kindArg : "findings";
3139
+ if (kind !== kindArg) {
3140
+ process.stderr.write(
3141
+ `Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
3142
+ `
3143
+ );
3144
+ }
3145
+ const writeSeedMarker = () => {
3146
+ try {
3147
+ writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
3148
+ } catch (err) {
3149
+ process.stderr.write(
3150
+ `Warning: could not write the seed marker beside ${outPath} (${errMsg(err)}) \u2014 the seeded draft will count as agent-written
3151
+ `
3152
+ );
3153
+ }
3154
+ };
3155
+ const writeScaffold = () => {
3156
+ try {
3157
+ writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
3158
+ `);
3159
+ writeSeedMarker();
3160
+ process.stderr.write(
3161
+ `Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
3162
+ `
3163
+ );
3164
+ return true;
3165
+ } catch (err) {
3166
+ process.stderr.write(
3167
+ `Warning: could not write the seed scaffold to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
3168
+ `
3169
+ );
3170
+ return false;
3171
+ }
3172
+ };
3173
+ const priorBody = (() => {
3174
+ if (!args.prior) return null;
3175
+ const raw = (() => {
3176
+ try {
3177
+ return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
3178
+ } catch {
3179
+ return null;
3180
+ }
3181
+ })();
3182
+ return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
3183
+ })();
3184
+ const priorFindings = priorBody === null ? null : parseFindingsMarker(priorBody);
3185
+ const seededFromPrior = priorFindings === null ? false : (() => {
3186
+ try {
3187
+ const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
3188
+ if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
3189
+ writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
3190
+ `);
3191
+ writeSeedMarker();
3192
+ const priorList = priorFindings.findings;
3193
+ const count = Array.isArray(priorList) ? priorList.length : 0;
3194
+ process.stderr.write(
3195
+ `Seeded ${outPath} from the prior review (${String(count)} finding(s))
3196
+ `
3197
+ );
3198
+ return true;
3199
+ } catch (err) {
3200
+ process.stderr.write(
3201
+ `Warning: could not seed from the prior review (${errMsg(err)}) \u2014 falling back to the empty scaffold
3202
+ `
3203
+ );
3204
+ return false;
3205
+ }
3206
+ })();
3207
+ const mode = (() => {
3208
+ if (seededFromPrior) {
3209
+ const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
3210
+ return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
3211
+ }
3212
+ if (!writeScaffold()) return "none";
3213
+ return priorBody === null ? "empty" : "empty-had-prior";
3214
+ })();
3215
+ process.stdout.write(`${mode}
3216
+ `);
3217
+ }
1354
3218
  });
1355
3219
  var adaptCmd = defineCommand({
1356
3220
  meta: {
1357
3221
  name: "adapt",
1358
- description: "Map a native agent-CLI result envelope onto the abstract SPEC \xA76.1 envelope"
3222
+ description: "Map a native agent-CLI result envelope onto the abstract SPEC envelope"
1359
3223
  },
1360
3224
  args: {
1361
3225
  native: {
@@ -1372,6 +3236,10 @@ var adaptCmd = defineCommand({
1372
3236
  type: "string",
1373
3237
  description: "Path to a file the agent was told to write its own validated findings JSON to (wins over the native envelope's structured_output/result when it validates)"
1374
3238
  },
3239
+ "agent-file-fallback": {
3240
+ type: "string",
3241
+ description: "Path to the last-valid findings snapshot, tried after --agent-file and before the native envelope; recovers the last valid state when a wall-clock kill left --agent-file truncated, so the review posts that instead of a 'did not complete' notice"
3242
+ },
1375
3243
  route: {
1376
3244
  type: "string",
1377
3245
  description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
@@ -1379,25 +3247,64 @@ var adaptCmd = defineCommand({
1379
3247
  effort: {
1380
3248
  type: "string",
1381
3249
  description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
3250
+ },
3251
+ transcript: {
3252
+ type: "string",
3253
+ description: "Path to the session transcript (main .jsonl). Its tree (main + subagents) gives the true wall + turn count the native envelope under-reports, and refills per-model usage when the native envelope is empty (e.g. after a wall-clock kill)"
1382
3254
  }
1383
3255
  },
1384
3256
  run: async ({ args }) => {
1385
3257
  const envelope = unwrapAdapt(
1386
- adapt(requireAdapterName(args.adapter), readJSON(args.native), args["agent-file"], {
3258
+ adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
1387
3259
  route: args.route,
1388
- effort: args.effort
3260
+ effort: args.effort,
3261
+ agentFileFallbackPath: args["agent-file-fallback"],
3262
+ ...args.transcript ? {
3263
+ transcriptFallback: () => transcriptFallbackFrom(args.transcript)
3264
+ } : {}
1389
3265
  })
1390
3266
  );
1391
3267
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}
1392
3268
  `);
1393
3269
  }
1394
3270
  });
3271
+ var noticeCmd = defineCommand({
3272
+ meta: {
3273
+ name: "notice",
3274
+ description: "Emit an abstract envelope for a run that produced no completed review (security block, triage error, setup failure, checkout failure, or empty run) \u2014 flagged incomplete so the commenter renders it honestly and won't bury a real review"
3275
+ },
3276
+ args: {
3277
+ kind: {
3278
+ type: "positional",
3279
+ description: `One of: ${NOTICE_KINDS.join(", ")} (an unrecognized kind renders a generic incomplete notice rather than failing \u2014 the pinned CLI is older than the workflow)`,
3280
+ required: true
3281
+ },
3282
+ reasons: {
3283
+ type: "string",
3284
+ description: "security-blocked / triage-error only: the triage's fail-closed reason string (empty/omitted \u21D2 the no-reason wording)"
3285
+ }
3286
+ },
3287
+ // An unrecognized kind degrades to a generic incomplete notice instead of exiting non-zero: a
3288
+ // `notice <kind>` call under the workflow's `set -euo pipefail` must never crash the assemble
3289
+ // step into posting nothing, and an unknown kind almost always means version skew, not a typo.
3290
+ run: ({ args }) => {
3291
+ if (!isNoticeKind(args.kind)) {
3292
+ process.stderr.write(
3293
+ `::warning::code-review notice: unrecognized kind "${args.kind}" \u2014 the pinned CLI is older than the workflow calling it; rendering a generic incomplete notice
3294
+ `
3295
+ );
3296
+ process.stdout.write(`${JSON.stringify(buildUnknownNoticeEnvelope(args.kind), null, 2)}
3297
+ `);
3298
+ return;
3299
+ }
3300
+ process.stdout.write(
3301
+ `${JSON.stringify(buildNoticeEnvelope(args.kind, args.reasons), null, 2)}
3302
+ `
3303
+ );
3304
+ }
3305
+ });
1395
3306
  var isExtractSchemaKind = (s) => s === "findings" || s === "triage";
1396
- var requireExtractSchemaKind = (name) => {
1397
- if (isExtractSchemaKind(name)) return name;
1398
- fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
1399
- throw new Error("unreachable");
1400
- };
3307
+ var requireExtractSchemaKind = (name) => isExtractSchemaKind(name) ? name : fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
1401
3308
  var failClosedTriage = (outcome) => ({
1402
3309
  safe: false,
1403
3310
  reasons: describeLadderFailure(outcome)
@@ -1431,16 +3338,18 @@ var extractCmd = defineCommand({
1431
3338
  run: async ({ args }) => {
1432
3339
  requireAdapterName(args.adapter);
1433
3340
  const kind = requireExtractSchemaKind(args.kind);
1434
- const outcome = extractStructured({
1435
- kind,
1436
- native: readJSON(args.native),
1437
- agentFilePath: args["agent-file"]
1438
- });
3341
+ const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
3342
+ const outcome = extractStructured(input);
1439
3343
  if (outcome.kind === "ok") {
1440
3344
  process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
1441
3345
  `);
1442
3346
  return;
1443
3347
  }
3348
+ if (outcome.kind === "none" || outcome.kind === "ambiguous") {
3349
+ process.stderr.write(`extract: recovery failed \u2014
3350
+ ${ladderFailureDiagnostics(input)}
3351
+ `);
3352
+ }
1444
3353
  if (kind === "triage") {
1445
3354
  process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
1446
3355
  `);
@@ -1449,24 +3358,79 @@ var extractCmd = defineCommand({
1449
3358
  fail(describeLadderFailure(outcome));
1450
3359
  }
1451
3360
  });
1452
- var requireAdapterName = (name) => {
1453
- if (isAdapterName(name)) return name;
1454
- fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
1455
- throw new Error("unreachable");
3361
+ var withoutPatch = (finding) => {
3362
+ const copy = { ...finding };
3363
+ delete copy.patch;
3364
+ return copy;
1456
3365
  };
1457
- var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
1458
- var requireSchemaKind = (name) => {
1459
- if (isSchemaKind(name)) return name;
1460
- fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
1461
- throw new Error("unreachable");
3366
+ var readFileLines = (path) => {
3367
+ try {
3368
+ const rawLines = readFileSync(path, "utf-8").split("\n");
3369
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
3370
+ } catch {
3371
+ return null;
3372
+ }
1462
3373
  };
3374
+ var validateFinding = (finding, repoRoot) => {
3375
+ if (finding.patch === void 0) return finding;
3376
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
3377
+ if (lines === null) {
3378
+ process.stderr.write(
3379
+ `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
3380
+ `
3381
+ );
3382
+ return withoutPatch(finding);
3383
+ }
3384
+ const result = validatePatch(finding.patch, lines);
3385
+ switch (result.kind) {
3386
+ case "anchored":
3387
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
3388
+ case "keep":
3389
+ return finding;
3390
+ case "drop":
3391
+ process.stderr.write(
3392
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
3393
+ `
3394
+ );
3395
+ return withoutPatch(finding);
3396
+ }
3397
+ };
3398
+ var validatePatchesCmd = defineCommand({
3399
+ meta: {
3400
+ name: "validate-patches",
3401
+ description: "Validate each finding's patch against the real PR-head tree: align the finding's range and keep the patch when it anchors, keep it unaligned for a pure insertion, or drop it when it doesn't apply"
3402
+ },
3403
+ args: {
3404
+ findings: {
3405
+ type: "positional",
3406
+ description: "Path to findings JSON",
3407
+ required: true
3408
+ },
3409
+ "repo-root": {
3410
+ type: "string",
3411
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
3412
+ }
3413
+ },
3414
+ run: async ({ args }) => {
3415
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
3416
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
3417
+ const validated = {
3418
+ ...findings,
3419
+ findings: findings.findings.map((f) => validateFinding(f, repoRoot))
3420
+ };
3421
+ process.stdout.write(`${JSON.stringify(validated, null, 2)}
3422
+ `);
3423
+ }
3424
+ });
3425
+ var requireAdapterName = (name) => isAdapterName(name) ? name : fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
3426
+ var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
3427
+ var requireSchemaKind = (name) => isSchemaKind(name) ? name : fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
1463
3428
  var requireSchemaPath = (kind, version) => {
1464
3429
  try {
1465
3430
  return schemaPathFor(kind, version);
1466
3431
  } catch (err) {
1467
- fail(err instanceof Error ? err.message : String(err));
3432
+ return fail(errMsg(err));
1468
3433
  }
1469
- throw new Error("unreachable");
1470
3434
  };
1471
3435
  var printSchemaCmd = defineCommand({
1472
3436
  meta: {
@@ -1487,18 +3451,105 @@ var printSchemaCmd = defineCommand({
1487
3451
  run: async ({ args }) => {
1488
3452
  const schemaKind = requireSchemaKind(args.name);
1489
3453
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
1490
- const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
1491
- const enforcementSchema = Object.fromEntries(
1492
- Object.entries(schema).filter(([key2]) => key2 !== "$schema")
3454
+ process.stdout.write(`${printableSchema(schemaPath)}
3455
+ `);
3456
+ }
3457
+ });
3458
+ var MAX_NUDGES_DEFAULT = 5;
3459
+ var drainStdin = () => {
3460
+ if (process.stdin.isTTY) return;
3461
+ try {
3462
+ readFileSync(0);
3463
+ } catch {
3464
+ }
3465
+ };
3466
+ var requireMaxNudges = (raw) => {
3467
+ if (raw === void 0) return MAX_NUDGES_DEFAULT;
3468
+ if (!/^\d+$/.test(raw)) {
3469
+ fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
3470
+ }
3471
+ const n = Number.parseInt(raw, 10);
3472
+ if (n < 1) {
3473
+ fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
3474
+ }
3475
+ return n;
3476
+ };
3477
+ var stopGateCmd = defineCommand({
3478
+ meta: {
3479
+ name: "stop-gate",
3480
+ 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."
3481
+ },
3482
+ args: {
3483
+ draft: {
3484
+ type: "string",
3485
+ description: "Path to the findings document the agent must produce and keep valid",
3486
+ required: true
3487
+ },
3488
+ kind: {
3489
+ type: "string",
3490
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
3491
+ },
3492
+ schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
3493
+ "schema-version": {
3494
+ type: "string",
3495
+ description: "Schema major.minor to validate against (default: the draft's declared version)"
3496
+ },
3497
+ "max-nudges": {
3498
+ type: "string",
3499
+ description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
3500
+ },
3501
+ counter: {
3502
+ type: "string",
3503
+ description: "Path for the nudge counter (default: <draft>.nudges)"
3504
+ },
3505
+ "print-settings": {
3506
+ type: "boolean",
3507
+ description: "Print the Stop-hook settings JSON that wires this gate, then exit"
3508
+ }
3509
+ },
3510
+ run: async ({ args }) => {
3511
+ const draftPath = resolve$1(args.draft);
3512
+ if (args["print-settings"]) {
3513
+ const command = defaultHookCommand(draftPath, {
3514
+ kind: args.kind,
3515
+ schema: args.schema,
3516
+ schemaVersion: args["schema-version"],
3517
+ maxNudges: args["max-nudges"],
3518
+ counter: args.counter
3519
+ });
3520
+ process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
3521
+ `);
3522
+ return;
3523
+ }
3524
+ drainStdin();
3525
+ const kind = requireSchemaKind(args.kind || "findings");
3526
+ const maxNudges = requireMaxNudges(args["max-nudges"]);
3527
+ const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
3528
+ const state = draftState(
3529
+ draftPath,
3530
+ (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
1493
3531
  );
1494
- process.stdout.write(`${JSON.stringify(enforcementSchema, null, 2)}
3532
+ const nudges = readNudges(counterPath);
3533
+ const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
3534
+ if (decision.kind === "block") {
3535
+ try {
3536
+ bumpNudges(counterPath, nudges);
3537
+ } catch (err) {
3538
+ process.stderr.write(
3539
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${errMsg(err)}
3540
+ `
3541
+ );
3542
+ return;
3543
+ }
3544
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
1495
3545
  `);
3546
+ }
1496
3547
  }
1497
3548
  });
1498
3549
  var gatherCmd = defineCommand({
1499
3550
  meta: {
1500
3551
  name: "gather",
1501
- description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, failing-job logs) as files for the review agent"
3552
+ description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, untrusted PR conversation to triage, failing-job logs) as files for the review agent"
1502
3553
  },
1503
3554
  args: {
1504
3555
  repo: { type: "string", description: "Repository (owner/name)", required: true },
@@ -1511,6 +3562,11 @@ var gatherCmd = defineCommand({
1511
3562
  type: "string",
1512
3563
  description: "Head branch to disambiguate the PR when multiple share a commit"
1513
3564
  },
3565
+ "default-branch": {
3566
+ type: "string",
3567
+ description: "The repo's default branch \u2014 the trusted base the review checks out, and the reference for the full (triage) diff; a PR based on any other branch is treated as stacked",
3568
+ required: true
3569
+ },
1514
3570
  "run-id": {
1515
3571
  type: "string",
1516
3572
  description: "CI run id (from workflow_run.id); its failing jobs' logs are downloaded on failure",
@@ -1535,6 +3591,7 @@ var gatherCmd = defineCommand({
1535
3591
  repo: args.repo,
1536
3592
  headSha: args["head-sha"],
1537
3593
  headBranch: args["head-branch"],
3594
+ defaultBranch: args["default-branch"],
1538
3595
  runId: args["run-id"],
1539
3596
  conclusion: args.conclusion,
1540
3597
  botLogin: args["bot-login"] || "github-actions[bot]",
@@ -1579,7 +3636,7 @@ var postCmd = defineCommand({
1579
3636
  },
1580
3637
  "inline-template": {
1581
3638
  type: "string",
1582
- description: "Path to inline comment Eta template (default: built-in format)"
3639
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1583
3640
  },
1584
3641
  route: {
1585
3642
  type: "string",
@@ -1600,47 +3657,279 @@ var postCmd = defineCommand({
1600
3657
  "test-report": {
1601
3658
  type: "string",
1602
3659
  description: TEST_REPORT_DESCRIPTION
3660
+ },
3661
+ "run-url": {
3662
+ type: "string",
3663
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
3664
+ },
3665
+ "json-url": {
3666
+ type: "string",
3667
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
1603
3668
  }
1604
3669
  },
1605
3670
  run: async ({ args }) => {
3671
+ const priceResolution = resolvePrices(args.prices);
1606
3672
  await post({
1607
3673
  repo: args.repo,
1608
3674
  headSha: args["head-sha"],
1609
3675
  botLogin: args["bot-login"] || "github-actions[bot]",
1610
3676
  findingsPath: args.findings,
1611
3677
  envelopePath: args.usage,
1612
- pricesPath: resolvePricesPath(args.prices),
3678
+ pricesPath: priceResolution.path,
3679
+ pricesProvided: priceResolution.kind === "provided",
1613
3680
  templatePath: resolveTemplatePath(args.template),
1614
- inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
3681
+ inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
1615
3682
  route: args.route,
1616
3683
  headBranch: args["head-branch"],
1617
3684
  effort: args.effort,
1618
- testReportPath: args["test-report"]
3685
+ testReportPath: args["test-report"],
3686
+ runUrl: args["run-url"],
3687
+ jsonUrl: args["json-url"],
3688
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1619
3689
  });
1620
3690
  }
1621
3691
  });
3692
+ var announceCmd = defineCommand({
3693
+ meta: {
3694
+ name: "announce",
3695
+ description: "Post (or update) the sticky the moment a review starts \u2014 an in-progress placeholder linking the run \u2014 so a workflow_run review, which runs from the default branch and is otherwise invisible on the PR, is visibly under way. Preserves a prior sticky's embedded findings + reviewed-sha markers so the re-review seed survives the swap."
3696
+ },
3697
+ args: {
3698
+ "head-sha": {
3699
+ type: "string",
3700
+ description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
3701
+ required: true
3702
+ },
3703
+ repo: {
3704
+ type: "string",
3705
+ description: "Repository (owner/name)",
3706
+ required: true
3707
+ },
3708
+ "run-url": {
3709
+ type: "string",
3710
+ description: "Workflow run URL the placeholder links to",
3711
+ required: true
3712
+ },
3713
+ "bot-login": {
3714
+ type: "string",
3715
+ description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
3716
+ },
3717
+ "head-branch": {
3718
+ type: "string",
3719
+ description: "Head branch to disambiguate the PR when multiple share a commit"
3720
+ }
3721
+ },
3722
+ run: async ({ args }) => {
3723
+ await announce({
3724
+ repo: args.repo,
3725
+ headSha: args["head-sha"],
3726
+ botLogin: args["bot-login"] || "github-actions[bot]",
3727
+ runUrl: args["run-url"],
3728
+ headBranch: args["head-branch"]
3729
+ }).catch(
3730
+ (err) => (
3731
+ // `::warning::` so a persistently broken announce shows up in the run's annotations, not only
3732
+ // buried in the step log — `announce` otherwise reports success while having posted nothing.
3733
+ process.stderr.write(
3734
+ `::warning::code-review announce: could not post the in-progress sticky (${annotationSafe(errMsg(err))}) \u2014 continuing (cosmetic)
3735
+ `
3736
+ )
3737
+ )
3738
+ );
3739
+ }
3740
+ });
3741
+ var requireCeilingSec = (raw) => {
3742
+ if (raw === void 0) return null;
3743
+ const ms = parseWallMs(raw);
3744
+ if (ms === null)
3745
+ return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
3746
+ return Math.floor(ms / 1e3);
3747
+ };
3748
+ var requireCeilingUsd = (raw) => {
3749
+ if (raw === void 0) return null;
3750
+ const n = Number.parseFloat(raw.replace(/^\$/, ""));
3751
+ if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
3752
+ return n;
3753
+ };
3754
+ var requireMaxInstructions = (raw) => {
3755
+ if (raw === void 0) return 4e3;
3756
+ if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
3757
+ return Number.parseInt(raw, 10);
3758
+ };
3759
+ var requirePositiveInt = (raw, flag) => {
3760
+ const n = Number.parseInt(raw, 10);
3761
+ return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
3762
+ };
3763
+ var requireWallMs = (raw, flag, fallback) => {
3764
+ const ms = parseWallMs(raw || fallback);
3765
+ return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
3766
+ };
3767
+ var parseCommandCmd = defineCommand({
3768
+ meta: {
3769
+ name: "parse-command",
3770
+ description: `Resolve a PR's head (SHA/branch/repo) from its NUMBER via the API and parse a ChatOps trigger comment ("/code-review [24m] [$1.00] <instructions>") into $GITHUB_OUTPUT lines. The untrusted comment is parsed here in type-safe code, never in workflow bash, and the head is resolved from the trusted PR number, never from the comment text. Emits should_run=false (and nothing else) when the comment is not the trigger, the PR is closed, or resolution fails.`
3771
+ },
3772
+ args: {
3773
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3774
+ pr: {
3775
+ type: "string",
3776
+ description: "PR number (from github.event.issue.number \u2014 trusted event data)",
3777
+ required: true
3778
+ },
3779
+ "comment-body": {
3780
+ type: "string",
3781
+ description: "The comment body to parse (default: the CODE_REVIEW_COMMENT_BODY env var \u2014 the safe way to pass untrusted text without shell interpolation)"
3782
+ },
3783
+ trigger: {
3784
+ type: "string",
3785
+ description: 'Trigger token the comment must begin with (default: "/code-review")'
3786
+ },
3787
+ "max-duration": {
3788
+ type: "string",
3789
+ description: "Ceiling the requested duration is clamped to (e.g. 60m); omit for no clamp \u2014 a comment could then request an unbounded wall, so set this"
3790
+ },
3791
+ "max-usd": {
3792
+ type: "string",
3793
+ description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
3794
+ },
3795
+ "max-instructions": {
3796
+ type: "string",
3797
+ description: "Max characters of free-form instructions kept (default: 4000)"
3798
+ }
3799
+ },
3800
+ run: async ({ args }) => {
3801
+ const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
3802
+ const result = await parseCommand({
3803
+ repo: args.repo,
3804
+ prNumber: requirePositiveInt(args.pr, "--pr"),
3805
+ body,
3806
+ options: {
3807
+ trigger: args.trigger || "/code-review",
3808
+ maxDurationSec: requireCeilingSec(args["max-duration"]),
3809
+ maxUsd: requireCeilingUsd(args["max-usd"]),
3810
+ maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
3811
+ }
3812
+ });
3813
+ if (result.kind === "skip") {
3814
+ process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
3815
+ `);
3816
+ process.stdout.write(renderCommandOutputs(result, "UNUSED"));
3817
+ return;
3818
+ }
3819
+ for (const note of result.args.notes)
3820
+ process.stderr.write(`code-review parse-command: ${note}
3821
+ `);
3822
+ const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
3823
+ process.stdout.write(renderCommandOutputs(result, delim));
3824
+ }
3825
+ });
3826
+ var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
3827
+ var reactCmd = defineCommand({
3828
+ meta: {
3829
+ name: "react",
3830
+ description: "Add and/or remove a GitHub reaction on a PR/issue comment \u2014 the ChatOps acknowledgement (\u{1F440} on receipt, swapped to \u{1F680} on completion). Cosmetic: warns and exits 0 on any API error so a reaction never fails the job."
3831
+ },
3832
+ args: {
3833
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3834
+ "comment-id": {
3835
+ type: "string",
3836
+ description: "Comment id to react on (github.event.comment.id)",
3837
+ required: true
3838
+ },
3839
+ add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
3840
+ remove: {
3841
+ type: "string",
3842
+ description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
3843
+ }
3844
+ },
3845
+ run: async ({ args }) => {
3846
+ const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
3847
+ const add = args.add ? requireReaction(args.add) : void 0;
3848
+ const remove = args.remove ? requireReaction(args.remove) : void 0;
3849
+ if (add === void 0 && remove === void 0)
3850
+ fail("react: nothing to do \u2014 pass --add and/or --remove");
3851
+ await react({ repo: args.repo, commentId, add, remove }).catch(
3852
+ (err) => process.stderr.write(
3853
+ `code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
3854
+ `
3855
+ )
3856
+ );
3857
+ }
3858
+ });
3859
+ var awaitCiCmd = defineCommand({
3860
+ meta: {
3861
+ name: "await-ci",
3862
+ description: "Wait for the PR head's CI workflow run to conclude, then emit its REAL conclusion + run id to $GITHUB_OUTPUT (ci_settled, ci_conclusion, ci_run_id). The on-demand comment trigger uses this so it routes on the same CI result the CI-completion trigger would \u2014 success \u2192 full review, failure \u2192 mechanic with that run's logs \u2014 instead of reviewing blind. Polls until the run completes or the timeout elapses; ci_settled=false \u21D2 no conclusive result (caller should decline to review, not guess)."
3863
+ },
3864
+ args: {
3865
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3866
+ "head-sha": {
3867
+ type: "string",
3868
+ description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
3869
+ required: true
3870
+ },
3871
+ "ci-workflow": {
3872
+ type: "string",
3873
+ description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
3874
+ },
3875
+ timeout: {
3876
+ type: "string",
3877
+ description: "Give up waiting after this wall (default: 30m)"
3878
+ },
3879
+ "poll-interval": {
3880
+ type: "string",
3881
+ description: "How often to re-check the run status (default: 15s)"
3882
+ }
3883
+ },
3884
+ run: async ({ args }) => {
3885
+ const workflowName = args["ci-workflow"] || "CI";
3886
+ const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
3887
+ workflowName,
3888
+ pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
3889
+ timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
3890
+ });
3891
+ process.stderr.write(
3892
+ outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
3893
+ ` : `code-review await-ci: no run named "${workflowName}" concluded before the timeout \u2014 not reviewing.${outcome.seenNames.length > 0 ? ` Workflow names seen for this head SHA: ${outcome.seenNames.join(", ")} \u2014 check --ci-workflow matches one.` : " No workflow runs were seen for this head SHA at all."}
3894
+ `
3895
+ );
3896
+ process.stdout.write(renderCiOutputs(outcome));
3897
+ }
3898
+ });
1622
3899
  var main = defineCommand({
1623
3900
  meta: {
1624
3901
  name: "code-review",
1625
3902
  version: packageVersion,
1626
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
3903
+ description: "Deterministic commenter for agentic PR review"
1627
3904
  },
1628
3905
  subCommands: {
1629
3906
  gather: gatherCmd,
3907
+ "parse-command": parseCommandCmd,
3908
+ react: reactCmd,
3909
+ "await-ci": awaitCiCmd,
1630
3910
  render: renderCmd,
1631
3911
  inline: inlineCmd,
1632
3912
  post: postCmd,
3913
+ announce: announceCmd,
1633
3914
  cost: costCmd,
3915
+ "check-cost": checkCostCmd,
1634
3916
  validate: validateCmd,
3917
+ "seed-draft": seedDraftCmd,
1635
3918
  adapt: adaptCmd,
3919
+ notice: noticeCmd,
1636
3920
  extract: extractCmd,
1637
- "print-schema": printSchemaCmd
3921
+ "validate-patches": validatePatchesCmd,
3922
+ "print-schema": printSchemaCmd,
3923
+ "stop-gate": stopGateCmd,
3924
+ "budget-hook": budgetHookCmd,
3925
+ "print-settings": printSettingsCmd,
3926
+ deadline: deadlineCmd
1638
3927
  }
1639
3928
  });
1640
3929
  if (!process.env["VITEST"]) {
1641
3930
  await runMain(main);
1642
3931
  }
1643
3932
 
1644
- export { main };
3933
+ export { main, snapshotIfValid };
1645
3934
  //# sourceMappingURL=index.js.map
1646
3935
  //# sourceMappingURL=index.js.map