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

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,128 @@ 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 tryParseJson = (text) => {
408
+ try {
409
+ return { ok: true, value: JSON.parse(text) };
410
+ } catch {
411
+ return { ok: false };
412
+ }
413
+ };
414
+ var readFileOrNull = (path) => {
415
+ try {
416
+ return readFileSync(path, "utf-8");
417
+ } catch {
418
+ return null;
419
+ }
420
+ };
421
+
422
+ // src/transcript.ts
423
+ var numField = (rec, key2) => {
424
+ const v = rec[key2];
425
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
426
+ };
427
+ var messageUsage = (entry) => {
428
+ const rec = asRecord(entry);
429
+ if (rec === null || rec["type"] !== "assistant") return null;
430
+ const msg = asRecord(rec["message"]);
431
+ if (msg === null) return null;
432
+ const model = msg["model"];
433
+ const usage = asRecord(msg["usage"]);
434
+ if (typeof model !== "string" || usage === null) return null;
435
+ const id = msg["id"];
436
+ return {
437
+ id: typeof id === "string" ? id : null,
438
+ model,
439
+ input: numField(usage, "input_tokens"),
440
+ output: numField(usage, "output_tokens"),
441
+ cacheRead: numField(usage, "cache_read_input_tokens"),
442
+ cacheWrite: numField(usage, "cache_creation_input_tokens")
443
+ };
444
+ };
445
+ var tsMsOf = (entry) => {
446
+ const rec = asRecord(entry);
447
+ const ts = rec?.["timestamp"];
448
+ if (typeof ts !== "string") return null;
449
+ const ms = Date.parse(ts);
450
+ return Number.isNaN(ms) ? null : ms;
451
+ };
452
+ var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
453
+ var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
454
+ try {
455
+ return [JSON.parse(line)];
456
+ } catch {
457
+ return [];
458
+ }
459
+ });
460
+ var sumTranscriptUsage = (entries) => {
461
+ const summed = entries.reduce(
462
+ (acc, entry) => {
463
+ const u = messageUsage(entry);
464
+ if (u === null) return acc;
465
+ if (u.id !== null && acc.seen.has(u.id)) return acc;
466
+ if (u.id !== null) acc.seen.add(u.id);
467
+ const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
468
+ acc.totals.set(u.model, {
469
+ input: prev.input + u.input,
470
+ output: prev.output + u.output,
471
+ cacheRead: prev.cacheRead + u.cacheRead,
472
+ cacheWrite: prev.cacheWrite + u.cacheWrite
473
+ });
474
+ return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
475
+ },
476
+ { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
477
+ );
478
+ const models = [...summed.totals].map(([model, t7]) => ({
479
+ model,
480
+ input_tokens: t7.input,
481
+ output_tokens: t7.output,
482
+ cache_read_tokens: t7.cacheRead,
483
+ cache_write_tokens: t7.cacheWrite
484
+ }));
485
+ const bounds = entries.reduce(
486
+ (acc, entry) => {
487
+ const ms = tsMsOf(entry);
488
+ if (ms === null) return acc;
489
+ return {
490
+ min: acc.min === null || ms < acc.min ? ms : acc.min,
491
+ max: acc.max === null || ms > acc.max ? ms : acc.max
492
+ };
493
+ },
494
+ { min: null, max: null }
495
+ );
496
+ return {
497
+ models,
498
+ turns: summed.turns,
499
+ durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
500
+ firstTsMs: bounds.min,
501
+ lastTsMs: bounds.max
502
+ };
503
+ };
504
+ var subagentFiles = (mainPath) => {
505
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
506
+ try {
507
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
508
+ } catch {
509
+ return [];
510
+ }
511
+ };
512
+ var readTranscriptTree = (mainPath) => {
513
+ const mainText = readFileOrNull(mainPath);
514
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
515
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
516
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
517
+ const siblingReads = siblings.flatMap((path) => {
518
+ const text = readFileOrNull(path);
519
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
520
+ });
521
+ return {
522
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
523
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
524
+ missing: mainText === null
525
+ };
526
+ };
234
527
  var SeverityCodec = t.union([
235
528
  t.literal("critical"),
236
529
  t.literal("major"),
@@ -258,14 +551,16 @@ var FindingShape = t.intersection([
258
551
  end_line: LineNumber,
259
552
  severity: SeverityCodec,
260
553
  title: t.string,
261
- body: t.string
554
+ description: t.string,
555
+ reasoning: t.string,
556
+ confidence: Confidence
262
557
  }),
263
558
  t.partial({
264
559
  side: SideCodec,
265
- suggestion: t.union([t.string, t.null]),
266
- confidence: Confidence,
267
560
  code: t.string,
268
- code_url: t.string
561
+ code_url: t.string,
562
+ recommendation: t.string,
563
+ patch: t.string
269
564
  })
270
565
  ]);
271
566
  var EndGeStart = t.refinement(
@@ -313,7 +608,12 @@ var ResultEnvelopeCodec = t.intersection([
313
608
  t.partial({
314
609
  vendor_cost_usd: t.union([t.number, t.null]),
315
610
  route: t.string,
316
- effort: t.string
611
+ effort: t.string,
612
+ // The run produced a notice rather than a completed review (security-gate block, agent kill, no
613
+ // recoverable findings). An empty `findings` array alone can't say this — a genuine clean review
614
+ // is also empty — so the render suppresses "clean review" and the sticky precedence guard refuses
615
+ // to bury a completed review under it. Absent ⇒ a completed review.
616
+ incomplete: t.boolean
317
617
  })
318
618
  ]);
319
619
  var ModelPricesCodec = t.type({
@@ -341,7 +641,13 @@ var TestSummaryCodec = t.intersection([
341
641
  failures: t.array(TestFailureCodec)
342
642
  })
343
643
  ]);
344
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
644
+ var DEFAULT_SCHEMA_VERSION = "0.4.0";
645
+ var noticeFindings = (summary) => ({
646
+ schema_version: DEFAULT_SCHEMA_VERSION,
647
+ summary,
648
+ verdict: "comment",
649
+ findings: []
650
+ });
345
651
 
346
652
  // src/validate.ts
347
653
  var addFormats = _addFormats;
@@ -377,10 +683,326 @@ var unsafeUnwrap = (decoded) => {
377
683
  if (decoded._tag === "Right") return decoded.right;
378
684
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
379
685
  };
686
+
687
+ // src/stop-gate.ts
688
+ var whatsWrong = (state, draftPath, kind) => {
689
+ switch (state.kind) {
690
+ case "missing":
691
+ return `${draftPath} does not exist yet`;
692
+ case "unreadable":
693
+ return `${draftPath} could not be read: ${state.error}`;
694
+ case "invalid":
695
+ return `${draftPath} does not validate against the ${kind} schema:
696
+ ${state.errors.map((e) => ` - ${e}`).join("\n")}`;
697
+ }
698
+ };
699
+ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
700
+ if (state.kind === "valid") return { kind: "allow" };
701
+ if (nudges >= maxNudges) return { kind: "allow" };
702
+ return {
703
+ kind: "block",
704
+ reason: [
705
+ `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
706
+ `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.`,
707
+ `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).`
708
+ ].join("\n")
709
+ };
710
+ };
711
+ var draftState = (draftPath, resolveSchema) => {
712
+ let raw;
713
+ try {
714
+ raw = readFileSync(draftPath, "utf-8");
715
+ } catch (err) {
716
+ if (err instanceof Error && err.code === "ENOENT") {
717
+ return { kind: "missing" };
718
+ }
719
+ return { kind: "unreadable", error: errMsg(err) };
720
+ }
721
+ let parsed;
722
+ try {
723
+ parsed = JSON.parse(raw);
724
+ } catch (err) {
725
+ return {
726
+ kind: "invalid",
727
+ errors: [`not valid JSON: ${errMsg(err)}`]
728
+ };
729
+ }
730
+ let schemaPath;
731
+ try {
732
+ schemaPath = resolveSchema(parsed);
733
+ } catch (err) {
734
+ return { kind: "invalid", errors: [errMsg(err)] };
735
+ }
736
+ try {
737
+ const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
738
+ return valid ? { kind: "valid" } : { kind: "invalid", errors };
739
+ } catch (err) {
740
+ return { kind: "invalid", errors: [errMsg(err)] };
741
+ }
742
+ };
743
+ var readNudges = (counterPath) => {
744
+ try {
745
+ const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
746
+ return Number.isInteger(n) && n >= 0 ? n : 0;
747
+ } catch {
748
+ return 0;
749
+ }
750
+ };
751
+ var bumpNudges = (counterPath, current) => {
752
+ writeFileSync(counterPath, `${String(current + 1)}
753
+ `);
754
+ };
755
+ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
756
+ var defaultHookCommand = (draftPath, opts) => [
757
+ "code-review stop-gate --draft",
758
+ shellQuote(draftPath),
759
+ ...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
760
+ ...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
761
+ ...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
762
+ ...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
763
+ ...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
764
+ ].join(" ");
765
+ var stopHookSettings = (command) => ({
766
+ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
767
+ });
768
+
769
+ // src/budget.ts
770
+ var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
771
+ var DEFAULT_RESERVE = {
772
+ frac: 0.15,
773
+ growth: 0.25,
774
+ flatUsd: 0.02,
775
+ flatMs: 12e4
776
+ };
777
+ var SOFT_MULTIPLE = 2;
778
+ var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
779
+ var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
780
+ var axisSeverity = (a, reserve) => {
781
+ const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
782
+ const effFrac = reserve.frac + reserve.growth * usedFrac;
783
+ const hardReserve = Math.max(a.flat, effFrac * a.limit);
784
+ const remaining = a.limit - a.used;
785
+ if (remaining <= hardReserve) return 2;
786
+ if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
787
+ return 0;
788
+ };
789
+ var decideBudget = (i) => {
790
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
791
+ return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
792
+ };
793
+ var pct = (n) => `${String(Math.round(n * 100))}%`;
794
+ var money = (n) => `$${n.toFixed(2)}`;
795
+ var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
796
+ 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)}`;
797
+ 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`;
798
+ var directive = (phase, draftPath, isSubagent) => {
799
+ if (isSubagent)
800
+ 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.`;
801
+ 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.`;
802
+ };
803
+ var budgetMessage = (i, phase, draftPath, isSubagent) => {
804
+ const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
805
+ return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
806
+ };
807
+ var invokesCodeReviewValidate = (toolInput) => {
808
+ const cmd = asRecord(toolInput)?.["command"];
809
+ return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
810
+ };
811
+ var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
812
+ var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
813
+ var blockedDuringConvergence = (toolName, toolInput) => {
814
+ if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
815
+ if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
816
+ return false;
817
+ };
818
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
819
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
820
+ var writesToDraft = (toolName, toolInput, draftPath) => {
821
+ const rec = asRecord(toolInput);
822
+ const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
823
+ if (WRITE_TOOLS.has(toolName)) {
824
+ const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
825
+ if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
826
+ return true;
827
+ }
828
+ if (toolName === "Bash") {
829
+ const cmd = rec?.["command"];
830
+ if (typeof cmd !== "string") return false;
831
+ const alt = targets.map(escapeRegExp).join("|");
832
+ const end = "(?=$|[\\s|&;)])";
833
+ const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
834
+ const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
835
+ return redirect.test(cmd) || teeArg.test(cmd);
836
+ }
837
+ return false;
838
+ };
839
+ 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.`;
840
+ var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
841
+ var lastValidPath = (draftPath) => {
842
+ const ext = extname(draftPath);
843
+ return join(dirname(draftPath), `${basename(draftPath, ext)}.last-valid${ext}`);
844
+ };
845
+ var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
846
+ 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.`;
847
+ var forceBackgroundSpawn = (toolInput) => ({
848
+ hookSpecificOutput: {
849
+ hookEventName: "PreToolUse",
850
+ permissionDecision: "allow",
851
+ updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
852
+ }
853
+ });
854
+ var denyPreTool = (reason) => ({
855
+ hookSpecificOutput: {
856
+ hookEventName: "PreToolUse",
857
+ permissionDecision: "deny",
858
+ permissionDecisionReason: reason
859
+ }
860
+ });
861
+ var isSubagentHookInput = (input) => {
862
+ const agentId = asRecord(input)?.["agent_id"];
863
+ return typeof agentId === "string" && agentId.length > 0;
864
+ };
865
+ var evaluateBudgetHook = (input, params) => {
866
+ const rec = asRecord(input);
867
+ const inputs = {
868
+ spentUsd: params.spentUsd,
869
+ budgetUsd: params.budgetUsd,
870
+ elapsedMs: params.elapsedMs,
871
+ wallMs: params.wallMs,
872
+ reserve: params.reserve
873
+ };
874
+ const phase = decideBudget(inputs);
875
+ const isSubagent = isSubagentHookInput(input);
876
+ switch (rec?.["hook_event_name"]) {
877
+ case "PostToolBatch":
878
+ return phase.kind === "ok" ? {} : {
879
+ hookSpecificOutput: {
880
+ hookEventName: "PostToolBatch",
881
+ additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
882
+ }
883
+ };
884
+ case "PreToolUse": {
885
+ const toolName = rec["tool_name"];
886
+ if (typeof toolName !== "string") return {};
887
+ if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
888
+ return denyPreTool(singleWriterMessage(params.draftPath));
889
+ if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
890
+ return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
891
+ if (SPAWN_TOOLS.has(toolName)) {
892
+ if (!isSubagent && !params.mainDraftWritten)
893
+ return denyPreTool(spawnFloorMessage(params.draftPath));
894
+ return forceBackgroundSpawn(rec["tool_input"]);
895
+ }
896
+ return {};
897
+ }
898
+ default:
899
+ return {};
900
+ }
901
+ };
902
+ var parseWallMs = (raw) => {
903
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
904
+ if (m === null) return null;
905
+ const [, num = "", unit = "s"] = m;
906
+ const n = Number.parseFloat(num);
907
+ if (!Number.isFinite(n)) return null;
908
+ switch (unit) {
909
+ case "ms":
910
+ return n;
911
+ case "s":
912
+ return n * 1e3;
913
+ case "m":
914
+ return n * 6e4;
915
+ default:
916
+ return n * 36e5;
917
+ }
918
+ };
919
+ var parseEpochSecMs = (raw) => {
920
+ if (raw === void 0) return null;
921
+ const t7 = raw.trim();
922
+ if (!/^\d+$/.test(t7)) return null;
923
+ const n = Number.parseInt(t7, 10);
924
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
925
+ };
926
+ var anchoredElapsedMs = (src) => {
927
+ if (src.deadlineMs !== null && src.wallMs !== null)
928
+ return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
929
+ if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
930
+ return null;
931
+ };
932
+ var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
933
+ var parseFraction = (raw, fallback) => {
934
+ if (raw === void 0) return fallback;
935
+ const n = Number.parseFloat(raw);
936
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
937
+ };
938
+ var budgetHookCommand = (draftPath, opts) => [
939
+ "code-review budget-hook --draft",
940
+ shellQuote(draftPath),
941
+ ...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
942
+ ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
943
+ ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
944
+ ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
945
+ ...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
946
+ ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
947
+ ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
948
+ ].join(" ");
949
+
950
+ // src/format.ts
951
+ var FENCE_RE = /^\s*```/;
952
+ var scanLine = (state, line) => {
953
+ if (FENCE_RE.test(line)) {
954
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
955
+ }
956
+ if (state.inFence) {
957
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
958
+ }
959
+ const trimmed = line.replace(/[ \t]+$/, "");
960
+ if (trimmed !== "") {
961
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
962
+ }
963
+ const blankRun = state.blankRun + 1;
964
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
965
+ };
966
+ var formatMarkdown = (md) => {
967
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
968
+ return `${lines.join("\n").replace(/\n+$/, "")}
969
+ `;
970
+ };
971
+ var pad2 = (n) => String(n).padStart(2, "0");
972
+ var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
973
+
974
+ // src/notice.ts
975
+ var isNoticeKind = (s) => s === "security-blocked" || s === "setup-failed" || s === "checkout-failed" || s === "no-output";
976
+ var blockquote = (text) => text.replaceAll("\n", "\n> ");
977
+ var noticeSummary = (kind, reasons) => {
978
+ switch (kind) {
979
+ case "security-blocked":
980
+ return reasons !== void 0 && reasons.trim() !== "" ? `### \u{1F6D1} Code review skipped by the security gate
981
+
982
+ The diff was flagged as unsafe to apply and execute:
983
+
984
+ > ${blockquote(reasons)}` : "### \u{1F6D1} Code review skipped by the security gate\n\nThe security triage returned an unsafe verdict without a reason. See workflow logs.";
985
+ case "setup-failed":
986
+ 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.";
987
+ case "checkout-failed":
988
+ 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.";
989
+ case "no-output":
990
+ return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs.";
991
+ }
992
+ };
993
+ var buildNoticeEnvelope = (kind, reasons) => ({
994
+ schema_version: DEFAULT_SCHEMA_VERSION,
995
+ findings: noticeFindings(noticeSummary(kind, reasons)),
996
+ models: [],
997
+ turns: 0,
998
+ duration_ms: 0,
999
+ vendor_cost_usd: null,
1000
+ incomplete: true
1001
+ });
380
1002
  var identity = (decoded) => decoded;
381
1003
  var findingsTable = [
382
1004
  {
383
- minor: "0.2",
1005
+ minor: "0.4",
384
1006
  defaultVersion: DEFAULT_SCHEMA_VERSION,
385
1007
  schemaFile: "findings.schema.json",
386
1008
  codec: FindingsCodec,
@@ -481,18 +1103,27 @@ var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
481
1103
  });
482
1104
 
483
1105
  // src/pr.ts
1106
+ var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
1107
+ var parseCandidates = (stdout) => parseJsonl(stdout);
484
1108
  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));
1109
+ const direct = parseCandidates(
1110
+ await ghApi([`repos/${repo}/commits/${headSha}/pulls`, "--jq", CANDIDATE_JQ])
1111
+ );
1112
+ if (direct.length > 0) return direct;
1113
+ const open = parseCandidates(
1114
+ await ghApi([
1115
+ `repos/${repo}/pulls?state=open&per_page=100`,
1116
+ "--paginate",
1117
+ "--jq",
1118
+ CANDIDATE_JQ
1119
+ ])
1120
+ );
1121
+ return open.filter((c) => c.headSha === headSha);
491
1122
  };
492
1123
  var resolvePr = (candidates, headBranch) => {
493
1124
  if (candidates.length === 0) return { kind: "none" };
494
1125
  const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
495
- const chosen = scoped[0] ?? candidates[0];
1126
+ const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
496
1127
  if (chosen === void 0) return { kind: "none" };
497
1128
  return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
498
1129
  };
@@ -505,7 +1136,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
505
1136
  // src/post.ts
506
1137
  var DEFAULT_MARKER = "<!-- code-review -->";
507
1138
  var MAX_SUGGESTION_LINES = 10;
508
- var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
509
1139
  var countSuggestionLines = (text) => text.split("\n").length;
510
1140
  var checkLongSuggestions = (comments) => {
511
1141
  const longFiles = [];
@@ -525,13 +1155,6 @@ var checkLongSuggestions = (comments) => {
525
1155
  });
526
1156
  return { comments: adjusted, longFiles };
527
1157
  };
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
1158
  var loadFindings = (path) => {
536
1159
  let raw;
537
1160
  try {
@@ -578,7 +1201,7 @@ var loadTestReport = (path) => {
578
1201
  raw = JSON.parse(readFileSync(path, "utf-8"));
579
1202
  } catch (err) {
580
1203
  process.stderr.write(
581
- `Warning: could not read test report at ${path}: ${err instanceof Error ? err.message : String(err)} \u2014 omitting test panel
1204
+ `Warning: could not read test report at ${path}: ${errMsg(err)} \u2014 omitting test panel
582
1205
  `
583
1206
  );
584
1207
  return void 0;
@@ -593,20 +1216,55 @@ var loadTestReport = (path) => {
593
1216
  }
594
1217
  return decoded.right;
595
1218
  };
596
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
597
- const body = JSON.stringify({
598
- body: "",
1219
+ var parseHtmlUrl = (raw) => {
1220
+ const parsed = tryParseJson(raw);
1221
+ const htmlUrl = parsed.ok ? asRecord(parsed.value)?.["html_url"] : void 0;
1222
+ return typeof htmlUrl === "string" ? htmlUrl : void 0;
1223
+ };
1224
+ var commentPayload = (c) => ({
1225
+ path: c.path,
1226
+ line: c.line,
1227
+ side: c.side,
1228
+ ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1229
+ body: formatMarkdown(c.body)
1230
+ });
1231
+ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
1232
+ const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
1233
+ const reviewBody = (withComments) => JSON.stringify({
1234
+ body: pointer,
599
1235
  commit_id: headSha,
600
1236
  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
- }))
1237
+ comments: withComments ? comments.map(commentPayload) : []
608
1238
  });
609
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
1239
+ const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
1240
+ try {
1241
+ const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
1242
+ return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
1243
+ } catch (err) {
1244
+ if (comments.length === 0) throw err;
1245
+ process.stderr.write(
1246
+ `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)
1247
+ `
1248
+ );
1249
+ const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
1250
+ const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
1251
+ const unposted = [];
1252
+ let inlinePosted = 0;
1253
+ for (const [i, c] of comments.entries()) {
1254
+ try {
1255
+ await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
1256
+ inlinePosted += 1;
1257
+ } catch (e) {
1258
+ const finding = inDiff[i];
1259
+ if (finding) unposted.push(finding);
1260
+ process.stderr.write(
1261
+ `Warning: inline comment on ${c.path}:${String(c.line)} rejected (${errMsg(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
1262
+ `
1263
+ );
1264
+ }
1265
+ }
1266
+ return { url, inlinePosted, unposted };
1267
+ }
610
1268
  };
611
1269
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
612
1270
  const stdout = await ghApi(
@@ -626,33 +1284,44 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
626
1284
  const parsed = JSON.parse(last);
627
1285
  return { id: parsed.id, body: parsed.body };
628
1286
  };
1287
+ var parseCommentRef = (raw) => {
1288
+ const parsed = tryParseJson(raw);
1289
+ const rec = parsed.ok ? asRecord(parsed.value) : null;
1290
+ const id = rec?.["id"];
1291
+ const html_url = rec?.["html_url"];
1292
+ return typeof id === "number" && typeof html_url === "string" ? { id, html_url } : null;
1293
+ };
629
1294
  var patchComment = async (repo, commentId, body, ghApi) => {
630
- await ghApi(
1295
+ const stdout = await ghApi(
631
1296
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
632
1297
  JSON.stringify({ body })
633
1298
  );
1299
+ const htmlUrl = parseHtmlUrl(stdout);
1300
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
634
1301
  };
635
1302
  var postComment = async (repo, prNumber, body, ghApi) => {
636
- await ghApi(
1303
+ const stdout = await ghApi(
637
1304
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
638
1305
  JSON.stringify({ body })
639
1306
  );
1307
+ return parseCommentRef(stdout);
640
1308
  };
641
1309
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
642
1310
  if (existing !== null) {
643
- await patchComment(repo, existing.id, body, ghApi);
1311
+ const patched = await patchComment(repo, existing.id, body, ghApi);
644
1312
  process.stderr.write(
645
1313
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
646
1314
  `
647
1315
  );
648
- } else {
649
- await postComment(repo, prNumber, body, ghApi);
650
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
651
- `);
1316
+ return { id: existing.id, url: patched?.html_url };
652
1317
  }
1318
+ const posted = await postComment(repo, prNumber, body, ghApi);
1319
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
1320
+ `);
1321
+ return posted ? { id: posted.id, url: posted.html_url } : null;
653
1322
  };
654
1323
  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) => {
1324
+ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
656
1325
  const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
657
1326
  let reviews;
658
1327
  try {
@@ -661,10 +1330,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
661
1330
  return [];
662
1331
  }
663
1332
  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);
1333
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
665
1334
  };
666
- var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
667
- const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
1335
+ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
668
1336
  for (const id of ids) {
669
1337
  try {
670
1338
  await ghApi(
@@ -679,11 +1347,91 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
679
1347
  );
680
1348
  } catch (err) {
681
1349
  process.stderr.write(
682
- `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1350
+ `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${errMsg(err)}
1351
+ `
1352
+ );
1353
+ }
1354
+ }
1355
+ };
1356
+ 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}}}}}}}}";
1357
+ var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1358
+ var priorBotCommentId = (c, logins) => {
1359
+ if (typeof c !== "object" || c === null) return null;
1360
+ const o = c;
1361
+ const login = o.author?.login;
1362
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
1363
+ };
1364
+ var priorBotCommentIds = (raw, botLogin) => {
1365
+ let parsed;
1366
+ try {
1367
+ parsed = JSON.parse(raw);
1368
+ } catch {
1369
+ return { ids: [], truncated: false };
1370
+ }
1371
+ const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
1372
+ const truncated = conn?.pageInfo?.hasNextPage === true;
1373
+ const nodes = conn?.nodes;
1374
+ if (!Array.isArray(nodes)) return { ids: [], truncated };
1375
+ const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1376
+ const ids = nodes.flatMap((t7) => {
1377
+ const cnodes = t7.comments?.nodes;
1378
+ return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1379
+ });
1380
+ return { ids, truncated };
1381
+ };
1382
+ var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
1383
+ const slash = repo.indexOf("/");
1384
+ if (slash <= 0) return [];
1385
+ const owner = repo.slice(0, slash);
1386
+ const name = repo.slice(slash + 1);
1387
+ let raw;
1388
+ try {
1389
+ raw = await ghApi([
1390
+ "graphql",
1391
+ "-f",
1392
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
1393
+ "-f",
1394
+ `owner=${owner}`,
1395
+ "-f",
1396
+ `name=${name}`,
1397
+ "-F",
1398
+ `pr=${String(prNumber)}`
1399
+ ]);
1400
+ } catch (err) {
1401
+ process.stderr.write(
1402
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${errMsg(err)}
1403
+ `
1404
+ );
1405
+ return [];
1406
+ }
1407
+ const { ids, truncated } = priorBotCommentIds(raw, botLogin);
1408
+ if (truncated) {
1409
+ process.stderr.write(
1410
+ `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1411
+ `
1412
+ );
1413
+ }
1414
+ return ids;
1415
+ };
1416
+ var minimizeComments = async (prNumber, ids, ghApi) => {
1417
+ let minimized = 0;
1418
+ for (const id of ids) {
1419
+ try {
1420
+ await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
1421
+ minimized += 1;
1422
+ } catch (err) {
1423
+ process.stderr.write(
1424
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${errMsg(err)}
683
1425
  `
684
1426
  );
685
1427
  }
686
1428
  }
1429
+ if (minimized > 0) {
1430
+ process.stderr.write(
1431
+ `Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
1432
+ `
1433
+ );
1434
+ }
687
1435
  };
688
1436
  var post = async (input, ghApi = runGhApi) => {
689
1437
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
@@ -709,25 +1457,40 @@ var post = async (input, ghApi = runGhApi) => {
709
1457
  DEFAULT_MARKER,
710
1458
  ghApi
711
1459
  );
712
- const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
713
- const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
1460
+ const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
1461
+ const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
1462
+ const leaveInPlace = () => {
1463
+ process.stderr.write(
1464
+ `Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place
1465
+ `
1466
+ );
1467
+ process.exit(0);
1468
+ };
714
1469
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
715
1470
  const decodedPrices = PriceMapCodec.decode(prices);
716
1471
  if (decodedPrices._tag === "Left") {
717
1472
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
718
1473
  }
719
1474
  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
- });
1475
+ const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
1476
+ const renderNotice = (message) => formatMarkdown(
1477
+ render({
1478
+ findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
1479
+ envelope: null,
1480
+ incomplete: true,
1481
+ prices: decodedPrices.right,
1482
+ pricesProvided: input.pricesProvided,
1483
+ template,
1484
+ route: input.route,
1485
+ reviewedSha: input.headSha,
1486
+ effort: input.effort,
1487
+ runUrl: input.runUrl,
1488
+ jsonUrl: input.jsonUrl,
1489
+ postedAt: input.postedAt
1490
+ })
1491
+ );
730
1492
  if (isEmptyDiff(diff)) {
1493
+ if (wouldBuryCompleted(true)) leaveInPlace();
731
1494
  await upsertSticky(
732
1495
  input.repo,
733
1496
  prNumber,
@@ -739,6 +1502,7 @@ var post = async (input, ghApi = runGhApi) => {
739
1502
  }
740
1503
  const findingsResult = loadFindings(input.findingsPath);
741
1504
  if (findingsResult.kind !== "ok") {
1505
+ if (wouldBuryCompleted(true)) leaveInPlace();
742
1506
  await upsertSticky(
743
1507
  input.repo,
744
1508
  prNumber,
@@ -752,27 +1516,42 @@ var post = async (input, ghApi = runGhApi) => {
752
1516
  const envelope = loadEnvelope(input.envelopePath);
753
1517
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
754
1518
  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);
1519
+ const body = formatMarkdown(
1520
+ render({
1521
+ findings,
1522
+ envelope: null,
1523
+ prices: decodedPrices.right,
1524
+ pricesProvided: input.pricesProvided,
1525
+ template,
1526
+ route: input.route,
1527
+ reviewedSha: input.headSha,
1528
+ effort: input.effort,
1529
+ testReport,
1530
+ inlineDisposition: { kind: "no-envelope" },
1531
+ runUrl: input.runUrl,
1532
+ jsonUrl: input.jsonUrl,
1533
+ postedAt: input.postedAt
1534
+ })
1535
+ );
1536
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
766
1537
  process.stderr.write(
767
1538
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
768
1539
  );
769
1540
  process.exit(0);
770
1541
  }
771
- const { comments: rawComments, strays } = buildInlineComments(
772
- findings.findings,
773
- diff,
774
- inlineTemplate
775
- );
1542
+ const thisIncomplete = envelope.incomplete === true;
1543
+ if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
1544
+ const findingsMarker = findingsPointer(findings, input.jsonUrl);
1545
+ const {
1546
+ comments: rawComments,
1547
+ strays,
1548
+ inDiff
1549
+ } = buildInlineComments(findings.findings, diff, {
1550
+ inlineTemplate,
1551
+ models: envelope.models.map((m) => m.model),
1552
+ findings,
1553
+ jsonUrl: input.jsonUrl
1554
+ });
776
1555
  const { comments, longFiles } = checkLongSuggestions(rawComments);
777
1556
  for (const wf of longFiles) {
778
1557
  process.stderr.write(
@@ -780,45 +1559,392 @@ var post = async (input, ghApi = runGhApi) => {
780
1559
  `
781
1560
  );
782
1561
  }
783
- let body = render({
1562
+ const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1563
+ const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1564
+ const commonRenderInput = {
784
1565
  findings,
785
1566
  envelope,
1567
+ incomplete: thisIncomplete,
786
1568
  prices: decodedPrices.right,
1569
+ pricesProvided: input.pricesProvided,
787
1570
  template,
788
1571
  route: input.route,
789
1572
  reviewedSha: input.headSha,
790
1573
  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 += `
1574
+ testReport,
1575
+ severityCounts: computeSeverityCounts(findings.findings),
1576
+ strays,
1577
+ runUrl: input.runUrl,
1578
+ jsonUrl: input.jsonUrl,
1579
+ findingsPointer: findingsMarker,
1580
+ postedAt: input.postedAt
1581
+ };
1582
+ const longFilesNote = longFiles.length > 0 ? `
797
1583
 
798
1584
  ---
799
1585
 
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);
1586
+ > **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.
1587
+ ` : "";
1588
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
1589
+ render({
1590
+ ...commonRenderInput,
1591
+ ...straysOverride ? { strays: straysOverride } : {},
1592
+ ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
1593
+ inlineDisposition,
1594
+ reviewUrl: reviewUrl2
1595
+ }) + longFilesNote
1596
+ );
1597
+ const stickyRef = await upsertSticky(
1598
+ input.repo,
1599
+ prNumber,
1600
+ existingSticky,
1601
+ renderBody(initialDisposition),
1602
+ ghApi
1603
+ );
1604
+ const priorInlineComments = await listPriorBotCommentIds(
1605
+ input.repo,
1606
+ prNumber,
1607
+ input.botLogin,
1608
+ ghApi
1609
+ );
1610
+ const {
1611
+ url: reviewUrl,
1612
+ inlinePosted,
1613
+ unposted
1614
+ } = await postInlineReview(
1615
+ input.repo,
1616
+ prNumber,
1617
+ input.headSha,
1618
+ comments,
1619
+ inDiff,
1620
+ stickyRef?.url,
1621
+ findingsMarker,
1622
+ ghApi
1623
+ );
1624
+ process.stderr.write(
1625
+ `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
1626
+ `
1627
+ );
1628
+ const priorReviewIds = botReviews.map((r) => r.id);
1629
+ if (priorReviewIds.length > 0) {
1630
+ await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
1631
+ }
1632
+ await minimizeComments(prNumber, priorInlineComments, ghApi);
1633
+ const unanchoredCount = unposted.length;
1634
+ const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
1635
+ if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
1636
+ const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
1637
+ try {
1638
+ await patchComment(
1639
+ input.repo,
1640
+ stickyRef.id,
1641
+ renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
1642
+ ghApi
1643
+ );
1644
+ process.stderr.write(
1645
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
1646
+ `
1647
+ );
1648
+ } catch (err) {
1649
+ process.stderr.write(
1650
+ `Warning: failed to update the sticky summary after the review: ${errMsg(err)}
1651
+ `
1652
+ );
1653
+ }
805
1654
  }
806
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
807
- if (isRerunOfSameSha) {
1655
+ };
1656
+ var announceBody = (headSha, runUrl, existingBody) => {
1657
+ const notice = `${DEFAULT_MARKER}
1658
+
1659
+ \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.`;
1660
+ const carried = existingBody ? carryForwardMarkers(existingBody) : "";
1661
+ return carried ? `${notice}
1662
+
1663
+ ${carried}` : notice;
1664
+ };
1665
+ var announce = async (input, ghApi = runGhApi) => {
1666
+ const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
1667
+ const resolution = resolvePr(candidates, input.headBranch);
1668
+ if (resolution.kind !== "open") {
808
1669
  process.stderr.write(
809
- `Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
1670
+ `No open PR for ${input.headSha} \u2014 nothing to announce (${resolution.kind})
810
1671
  `
811
1672
  );
812
1673
  return;
813
1674
  }
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)}
818
- `
1675
+ const existing = await findBotComment(
1676
+ input.repo,
1677
+ resolution.prNumber,
1678
+ input.botLogin,
1679
+ DEFAULT_MARKER,
1680
+ ghApi
1681
+ );
1682
+ if (existing !== null && parseReviewComplete(existing.body) && parseReviewedSha(existing.body) === input.headSha.toLowerCase()) {
1683
+ process.stderr.write(
1684
+ `Sticky already reflects a completed review of ${input.headSha} \u2014 leaving it in place
1685
+ `
1686
+ );
1687
+ return;
1688
+ }
1689
+ await upsertSticky(
1690
+ input.repo,
1691
+ resolution.prNumber,
1692
+ existing,
1693
+ announceBody(input.headSha, input.runUrl, existing?.body),
1694
+ ghApi
1695
+ );
1696
+ };
1697
+ var DURATION_RE = /^(\d+)(h|m|s)$/;
1698
+ var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
1699
+ var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
1700
+ var stripTrigger = (body, trigger) => {
1701
+ const trimmed = body.replace(/^\s+/, "");
1702
+ if (!trimmed.startsWith(trigger)) return null;
1703
+ const after = trimmed.slice(trigger.length);
1704
+ return after === "" || /^\s/.test(after) ? after : null;
1705
+ };
1706
+ var scanLeading = (s, acc) => {
1707
+ const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
1708
+ if (m === null) return { ...acc, rest: "" };
1709
+ const [, , token = "", tail = ""] = m;
1710
+ const dm = DURATION_RE.exec(token);
1711
+ if (dm && acc.durationSec === null)
1712
+ return scanLeading(tail, {
1713
+ ...acc,
1714
+ durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
1715
+ });
1716
+ const um = USD_RE.exec(token);
1717
+ if (um && acc.usd === null)
1718
+ return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
1719
+ return { ...acc, rest: s };
1720
+ };
1721
+ var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1722
+ value: ceiling,
1723
+ notes: [
1724
+ `requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
1725
+ ]
1726
+ } : { value: requested, notes: [] };
1727
+ var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
1728
+ value: ceiling,
1729
+ notes: [
1730
+ `requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
1731
+ ]
1732
+ } : { value: requested, notes: [] };
1733
+ var capInstructions = (text, maxLen) => text.length > maxLen ? {
1734
+ value: text.slice(0, maxLen),
1735
+ notes: [
1736
+ `instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
1737
+ ]
1738
+ } : { value: text, notes: [] };
1739
+ var parseCommandArgs = (body, options) => {
1740
+ const afterTrigger = stripTrigger(body, options.trigger);
1741
+ if (afterTrigger === null) return { kind: "not-a-command" };
1742
+ const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
1743
+ const duration = clampDuration(scan.durationSec, options.maxDurationSec);
1744
+ const usd = clampUsd(scan.usd, options.maxUsd);
1745
+ const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
1746
+ return {
1747
+ kind: "command",
1748
+ args: {
1749
+ durationSec: duration.value,
1750
+ usd: usd.value,
1751
+ instructions: instructions.value,
1752
+ notes: [...duration.notes, ...usd.notes, ...instructions.notes]
1753
+ }
1754
+ };
1755
+ };
1756
+ var PrHeadCodec = t.type({
1757
+ head_sha: t.string,
1758
+ head_ref: t.string,
1759
+ head_repo: t.union([t.string, t.null]),
1760
+ state: t.string
1761
+ });
1762
+ var resolvePrHead = async (repo, prNumber, ghApi) => {
1763
+ const stdout = await ghApi([
1764
+ `repos/${repo}/pulls/${String(prNumber)}`,
1765
+ "--jq",
1766
+ "{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
1767
+ ]);
1768
+ const decoded = PrHeadCodec.decode(JSON.parse(stdout));
1769
+ if (decoded._tag === "Left") {
1770
+ throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
1771
+ }
1772
+ return decoded.right;
1773
+ };
1774
+ var parseCommand = async (input, ghApi = runGhApi) => {
1775
+ const parse = parseCommandArgs(input.body, input.options);
1776
+ if (parse.kind === "not-a-command") {
1777
+ return {
1778
+ kind: "skip",
1779
+ reason: `comment does not begin with the trigger "${input.options.trigger}"`
1780
+ };
1781
+ }
1782
+ const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
1783
+ (err) => err instanceof Error ? err : new Error(String(err))
1784
+ );
1785
+ if (head instanceof Error) {
1786
+ return {
1787
+ kind: "skip",
1788
+ reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
1789
+ };
1790
+ }
1791
+ if (head.state !== "open") {
1792
+ return {
1793
+ kind: "skip",
1794
+ reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
1795
+ };
1796
+ }
1797
+ return {
1798
+ kind: "run",
1799
+ headSha: head.head_sha,
1800
+ headBranch: head.head_ref,
1801
+ headRepo: head.head_repo ?? input.repo,
1802
+ args: parse.args
1803
+ };
1804
+ };
1805
+ var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
1806
+ const candidate = `GHOUT_${randomHex()}`;
1807
+ if (!instructions.split("\n").includes(candidate)) return candidate;
1808
+ if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
1809
+ return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
1810
+ };
1811
+ var renderCommandOutputs = (result, delim) => {
1812
+ if (result.kind === "skip") return "should_run=false\n";
1813
+ const { headSha, headBranch, headRepo, args } = result;
1814
+ return `${[
1815
+ "should_run=true",
1816
+ `head_sha=${headSha}`,
1817
+ `head_branch=${headBranch}`,
1818
+ `head_repo=${headRepo}`,
1819
+ `duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
1820
+ `usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
1821
+ `instructions<<${delim}`,
1822
+ args.instructions,
1823
+ delim
1824
+ ].join("\n")}
1825
+ `;
1826
+ };
1827
+ var REACTIONS = [
1828
+ "+1",
1829
+ "-1",
1830
+ "laugh",
1831
+ "confused",
1832
+ "heart",
1833
+ "hooray",
1834
+ "rocket",
1835
+ "eyes"
1836
+ ];
1837
+ var isReaction = (s) => REACTIONS.includes(s);
1838
+ var ReactionCodec = t.type({ id: t.number, content: t.string });
1839
+ var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
1840
+ var removeReactions = async (repo, commentId, content, ghApi) => {
1841
+ const stdout = await ghApi([
1842
+ reactionsPath(repo, commentId),
1843
+ "--paginate",
1844
+ "--jq",
1845
+ ".[] | {id, content}"
1846
+ ]);
1847
+ for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
1848
+ const parsed = tryParseJson(line);
1849
+ const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
1850
+ if (decoded === void 0 || decoded._tag === "Left") {
1851
+ process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
1852
+ continue;
1853
+ }
1854
+ if (decoded.right.content !== content) continue;
1855
+ await ghApi([
1856
+ "--method",
1857
+ "DELETE",
1858
+ `${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
1859
+ ]).catch(
1860
+ (err) => process.stderr.write(
1861
+ `code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
1862
+ `
1863
+ )
1864
+ );
1865
+ }
1866
+ };
1867
+ var react = async (input, ghApi = runGhApi) => {
1868
+ if (input.add !== void 0) {
1869
+ await ghApi([
1870
+ "--method",
1871
+ "POST",
1872
+ reactionsPath(input.repo, input.commentId),
1873
+ "-f",
1874
+ `content=${input.add}`
1875
+ ]);
1876
+ }
1877
+ if (input.remove !== void 0) {
1878
+ await removeReactions(input.repo, input.commentId, input.remove, ghApi);
1879
+ }
1880
+ };
1881
+ var RunCodec = t.type({
1882
+ id: t.number,
1883
+ name: t.union([t.string, t.null]),
1884
+ status: t.union([t.string, t.null]),
1885
+ conclusion: t.union([t.string, t.null]),
1886
+ run_number: t.number
1887
+ });
1888
+ var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number}";
1889
+ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
1890
+ const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
1891
+ const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
1892
+ const decoded = rows.map((row) => RunCodec.decode(row));
1893
+ const runs = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
1894
+ const dropped = decoded.length - runs.length;
1895
+ if (dropped > 0) {
1896
+ const firstDrift = decoded.find((d) => d._tag === "Left");
1897
+ const detail = firstDrift === void 0 ? "" : ` (${PathReporter.report(firstDrift).join("; ")})`;
1898
+ process.stderr.write(
1899
+ `Warning: ${String(dropped)} of ${String(rows.length)} workflow-run row(s) from ${endpoint} failed to decode${detail} \u2014 excluded from the lookup
1900
+ `
819
1901
  );
820
1902
  }
1903
+ const latest = runs.filter((r) => r.name === workflowName).reduce(
1904
+ (best, r) => best === null || r.run_number > best.run_number ? r : best,
1905
+ null
1906
+ );
1907
+ return {
1908
+ run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
1909
+ seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
1910
+ };
821
1911
  };
1912
+ var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
1913
+ const safeResolve = async () => {
1914
+ try {
1915
+ return await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
1916
+ } catch (err) {
1917
+ process.stderr.write(
1918
+ `Warning: CI-run lookup for ${headSha} failed (${errMsg(err)}) \u2014 retrying until the timeout
1919
+ `
1920
+ );
1921
+ return { run: null, seenNames: [] };
1922
+ }
1923
+ };
1924
+ const poll = async (lastSeenNames, lastRunId) => {
1925
+ const { run, seenNames } = await safeResolve();
1926
+ if (run !== null && run.status === "completed" && run.conclusion !== null)
1927
+ return { kind: "concluded", conclusion: run.conclusion, runId: run.id };
1928
+ const runId = run === null ? lastRunId : run.id;
1929
+ const names = seenNames.length > 0 ? seenNames : lastSeenNames;
1930
+ if (deps.elapsedMs() >= options.timeoutMs)
1931
+ return { kind: "timed-out", runId, seenNames: names };
1932
+ await deps.sleep(options.pollIntervalMs);
1933
+ return poll(names, runId);
1934
+ };
1935
+ return poll([], null);
1936
+ };
1937
+ var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
1938
+ var monotonicElapsed = () => {
1939
+ const start = performance.now();
1940
+ return () => performance.now() - start;
1941
+ };
1942
+ var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
1943
+ ci_conclusion=${outcome.conclusion}
1944
+ ci_run_id=${String(outcome.runId)}
1945
+ ` : `ci_settled=false
1946
+ ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
1947
+ `;
822
1948
  var renderOutputs = (result) => {
823
1949
  switch (result.kind) {
824
1950
  case "skip":
@@ -827,6 +1953,7 @@ var renderOutputs = (result) => {
827
1953
  return `pr=${String(result.pr)}
828
1954
  conclusion=${result.conclusion}
829
1955
  diff_size=${String(result.diffSize)}
1956
+ stacked=${String(result.stacked)}
830
1957
  `;
831
1958
  }
832
1959
  };
@@ -849,22 +1976,28 @@ var runGit = (args) => new Promise((resolve3, reject) => {
849
1976
  var PrMetaCodec = t.type({
850
1977
  changed_files: t.number,
851
1978
  base_sha: t.string,
1979
+ base_ref: t.string,
852
1980
  title: t.string,
853
1981
  body: t.union([t.string, t.null])
854
1982
  });
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);
1983
+ var IssueCommentCodec = t.intersection([
1984
+ t.type({
1985
+ id: t.number,
1986
+ body: t.union([t.string, t.null]),
1987
+ user: t.type({ login: t.string })
1988
+ }),
1989
+ t.partial({
1990
+ created_at: t.union([t.string, t.null]),
1991
+ author_association: t.union([t.string, t.null])
1992
+ })
1993
+ ]);
861
1994
  var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
862
1995
  var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
863
1996
  var fetchPrMeta = async (repo, prNumber, ghApi) => {
864
1997
  const stdout = await ghApi([
865
1998
  `repos/${repo}/pulls/${String(prNumber)}`,
866
1999
  "--jq",
867
- "{changed_files: .changed_files, base_sha: .base.sha, title: .title, body: .body}"
2000
+ "{changed_files: .changed_files, base_sha: .base.sha, base_ref: .base.ref, title: .title, body: .body}"
868
2001
  ]);
869
2002
  const decoded = PrMetaCodec.decode(JSON.parse(stdout));
870
2003
  if (decoded._tag === "Left") {
@@ -879,18 +2012,139 @@ var fetchApiDiff = async (repo, prNumber, ghApi) => {
879
2012
  return null;
880
2013
  }
881
2014
  };
882
- var fetchPriorReview = async (repo, prNumber, botLogin, ghApi) => {
2015
+ var fetchFullDiff = async (repo, defaultBranch, headSha, ghApi, gitRun) => {
883
2016
  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 {
2017
+ const diff = await ghApi([
2018
+ `repos/${repo}/compare/${defaultBranch}...${headSha}`,
2019
+ "-H",
2020
+ "Accept: application/vnd.github.v3.diff"
2021
+ ]);
2022
+ if (diff.length > 0) return diff;
2023
+ process.stderr.write(
2024
+ `compare diff for ${defaultBranch}...${headSha} was empty \u2014 falling back to git diff
2025
+ `
2026
+ );
2027
+ } catch (err) {
2028
+ process.stderr.write(`compare diff fetch failed (${errMsg(err)}) \u2014 falling back to git diff
2029
+ `);
2030
+ }
2031
+ await gitRun(["fetch", "origin", headSha]);
2032
+ const base = (await gitRun(["rev-parse", "HEAD"])).trim();
2033
+ return gitRun(["diff", `${base}...${headSha}`]);
2034
+ };
2035
+ var CommitCodec = t.type({
2036
+ sha: t.string,
2037
+ message: t.string,
2038
+ author: t.union([t.string, t.null]),
2039
+ email: t.union([t.string, t.null])
2040
+ });
2041
+ var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email}";
2042
+ var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
2043
+ const rows = parseJsonl(
2044
+ await ghApi([
2045
+ `repos/${repo}/compare/${defaultBranch}...${headSha}`,
2046
+ "--paginate",
2047
+ "--jq",
2048
+ COMMIT_JQ
2049
+ ])
2050
+ );
2051
+ const decoded = rows.map((row) => CommitCodec.decode(row));
2052
+ const commits = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
2053
+ const dropped = decoded.length - commits.length;
2054
+ if (dropped > 0) {
2055
+ process.stderr.write(
2056
+ `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
2057
+ `
2058
+ );
2059
+ }
2060
+ return commits;
2061
+ };
2062
+ var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
2063
+ var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
2064
+ var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
2065
+ var ReviewCommentCodec = t.intersection([
2066
+ t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
2067
+ t.partial({
2068
+ created_at: t.union([t.string, t.null]),
2069
+ author_association: t.union([t.string, t.null]),
2070
+ path: t.union([t.string, t.null]),
2071
+ line: t.union([t.number, t.null])
2072
+ })
2073
+ ]);
2074
+ var ReviewCodec = t.intersection([
2075
+ t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
2076
+ t.partial({
2077
+ submitted_at: t.union([t.string, t.null]),
2078
+ author_association: t.union([t.string, t.null]),
2079
+ state: t.union([t.string, t.null])
2080
+ })
2081
+ ]);
2082
+ var fetchJsonlRows = async (ghApi, endpoint, jq) => {
2083
+ try {
2084
+ return parseJsonl(await ghApi([endpoint, "--paginate", "--jq", jq]));
2085
+ } catch (err) {
2086
+ process.stderr.write(
2087
+ `Warning: could not fetch ${endpoint} (${errMsg(err)}) \u2014 omitting it from the review context
2088
+ `
2089
+ );
891
2090
  return null;
892
2091
  }
893
2092
  };
2093
+ var decodeArrayOrNull = (codec, rows) => {
2094
+ if (rows === null) return null;
2095
+ return rows.flatMap((row) => {
2096
+ const decoded = codec.decode(row);
2097
+ return decoded._tag === "Right" ? [decoded.right] : [];
2098
+ });
2099
+ };
2100
+ var priorReviewFrom = (comments, botLogin) => {
2101
+ const byBot = comments.filter((c) => c.user.login === botLogin);
2102
+ const last = byBot[byBot.length - 1];
2103
+ return last ? { id: last.id, body: last.body } : null;
2104
+ };
2105
+ var MAX_CONVERSATION_COMMENTS = 50;
2106
+ var MAX_CONVERSATION_BODY_CHARS = 4e3;
2107
+ var clip = (body) => {
2108
+ if (body.length <= MAX_CONVERSATION_BODY_CHARS) return body;
2109
+ const cut = body.slice(0, MAX_CONVERSATION_BODY_CHARS);
2110
+ const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
2111
+ return `${safe}
2112
+ \u2026 [truncated]`;
2113
+ };
2114
+ var boundedHuman = (items, botLogin, label, project) => {
2115
+ const human = items.filter(
2116
+ (a) => a.user.login !== botLogin && typeof a.body === "string" && a.body.trim() !== ""
2117
+ );
2118
+ const kept = human.slice(-MAX_CONVERSATION_COMMENTS);
2119
+ if (kept.length < human.length) {
2120
+ process.stderr.write(
2121
+ `Note: PR has ${String(human.length)} ${label} \u2014 feeding the review the most recent ${String(MAX_CONVERSATION_COMMENTS)}
2122
+ `
2123
+ );
2124
+ }
2125
+ return kept.map(project);
2126
+ };
2127
+ var issueCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "discussion comments", (c) => ({
2128
+ author: c.user.login,
2129
+ author_association: c.author_association ?? null,
2130
+ created_at: c.created_at ?? null,
2131
+ body: clip(c.body)
2132
+ }));
2133
+ var reviewCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "inline review comments", (c) => ({
2134
+ author: c.user.login,
2135
+ author_association: c.author_association ?? null,
2136
+ created_at: c.created_at ?? null,
2137
+ path: c.path ?? null,
2138
+ line: c.line ?? null,
2139
+ body: clip(c.body)
2140
+ }));
2141
+ var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review submissions", (r) => ({
2142
+ author: r.user.login,
2143
+ author_association: r.author_association ?? null,
2144
+ submitted_at: r.submitted_at ?? null,
2145
+ state: r.state ?? null,
2146
+ body: clip(r.body)
2147
+ }));
894
2148
  var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
895
2149
  const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
896
2150
  const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
@@ -903,7 +2157,7 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
903
2157
  writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
904
2158
  } catch (err) {
905
2159
  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
2160
+ `Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
907
2161
  `
908
2162
  );
909
2163
  }
@@ -926,8 +2180,10 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
926
2180
  }
927
2181
  const prNumber = resolution.prNumber;
928
2182
  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 () => {
2183
+ const stacked = meta.base_ref !== input.defaultBranch;
2184
+ const prDiff = await (async () => {
2185
+ const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
2186
+ if (apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0)) return apiDiff;
931
2187
  process.stderr.write(
932
2188
  `PR diff fetch failed or was empty for ${String(meta.changed_files)} changed files \u2014 falling back to git diff
933
2189
  `
@@ -935,16 +2191,40 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
935
2191
  await gitRun(["fetch", "origin", input.headSha]);
936
2192
  return gitRun(["diff", meta.base_sha, input.headSha]);
937
2193
  })();
938
- writeFileSync(join(input.outDir, "pr.diff"), diff);
2194
+ const fullDiff = stacked ? await fetchFullDiff(input.repo, input.defaultBranch, input.headSha, ghApi, gitRun) : prDiff;
2195
+ const commits = await fetchCompareCommits(input.repo, input.defaultBranch, input.headSha, ghApi);
2196
+ writeFileSync(join(input.outDir, "full.diff"), fullDiff);
2197
+ writeFileSync(join(input.outDir, "pr.diff"), prDiff);
2198
+ writeFileSync(join(input.outDir, "commits.json"), JSON.stringify(commits));
939
2199
  writeFileSync(
940
2200
  join(input.outDir, "pr_context.json"),
941
2201
  JSON.stringify({ title: meta.title, body: meta.body })
942
2202
  );
943
- const prior = await fetchPriorReview(input.repo, prNumber, input.botLogin, ghApi);
2203
+ const [issueRows, reviewCommentRows, reviewRows] = await Promise.all([
2204
+ fetchJsonlRows(ghApi, `repos/${input.repo}/issues/${String(prNumber)}/comments`, COMMENT_JQ),
2205
+ fetchJsonlRows(
2206
+ ghApi,
2207
+ `repos/${input.repo}/pulls/${String(prNumber)}/comments`,
2208
+ REVIEW_COMMENT_JQ
2209
+ ),
2210
+ fetchJsonlRows(ghApi, `repos/${input.repo}/pulls/${String(prNumber)}/reviews`, REVIEW_JQ)
2211
+ ]);
2212
+ const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
2213
+ const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
2214
+ const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
2215
+ const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
944
2216
  writeFileSync(
945
2217
  join(input.outDir, "prior_review.json"),
946
2218
  prior === null ? "null" : JSON.stringify(prior)
947
2219
  );
2220
+ writeFileSync(
2221
+ join(input.outDir, "pr_conversation.json"),
2222
+ JSON.stringify({
2223
+ issue_comments: issueComments === null ? [] : issueCommentsFrom(issueComments, input.botLogin),
2224
+ review_comments: reviewComments === null ? [] : reviewCommentsFrom(reviewComments, input.botLogin),
2225
+ reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
2226
+ })
2227
+ );
948
2228
  if (input.conclusion === "failure") {
949
2229
  await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
950
2230
  }
@@ -952,9 +2232,12 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
952
2232
  kind: "gathered",
953
2233
  pr: prNumber,
954
2234
  conclusion: input.conclusion,
955
- diffSize: Buffer.byteLength(diff, "utf8")
2235
+ diffSize: Buffer.byteLength(prDiff, "utf8"),
2236
+ stacked
956
2237
  };
957
2238
  };
2239
+
2240
+ // src/extract.ts
958
2241
  var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
959
2242
  var parseNativeForExtraction = (raw) => ({
960
2243
  result: fieldOf(raw, "result"),
@@ -1007,20 +2290,6 @@ var gateCandidate = (kind, rawCandidate) => {
1007
2290
  const resolution = resolve(kind, candidate);
1008
2291
  return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
1009
2292
  };
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
2293
  var candidateFromJsonText = (kind, text) => {
1025
2294
  if (text === null) return null;
1026
2295
  const parsed = tryParseJson(text);
@@ -1028,7 +2297,7 @@ var candidateFromJsonText = (kind, text) => {
1028
2297
  };
1029
2298
  var FENCE_OPEN = /^\s*(`{3,})/;
1030
2299
  var FENCE_MARKER_ONLY = /^`+$/;
1031
- var scanLine = (state, line) => {
2300
+ var scanLine2 = (state, line) => {
1032
2301
  if (state.openLength === null) {
1033
2302
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1034
2303
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1037,7 +2306,31 @@ var scanLine = (state, line) => {
1037
2306
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1038
2307
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1039
2308
  };
1040
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
2309
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
2310
+ var ladderFailureDiagnostics = (input) => {
2311
+ const native = parseNativeForExtraction(input.native);
2312
+ const preview = (s) => {
2313
+ const flat = s.replace(/\s+/g, " ").trim();
2314
+ return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
2315
+ };
2316
+ const lines = [];
2317
+ if (input.kind === "findings") {
2318
+ lines.push(
2319
+ input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
2320
+ );
2321
+ if (input.agentFileFallbackPath !== void 0)
2322
+ lines.push(
2323
+ `last-valid rung: ${input.agentFileFallbackPath} did not validate (or was absent)`
2324
+ );
2325
+ }
2326
+ lines.push(
2327
+ 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"
2328
+ );
2329
+ lines.push(
2330
+ 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"
2331
+ );
2332
+ return lines.join("\n");
2333
+ };
1041
2334
  var describeLadderFailure = (outcome) => {
1042
2335
  switch (outcome.kind) {
1043
2336
  case "error-envelope":
@@ -1055,13 +2348,16 @@ var okOutcome = (gated) => ({
1055
2348
  });
1056
2349
  var extractStructured = (input) => {
1057
2350
  const native = parseNativeForExtraction(input.native);
2351
+ if (input.kind === "findings") {
2352
+ for (const path of [input.agentFilePath, input.agentFileFallbackPath]) {
2353
+ if (path === void 0) continue;
2354
+ const fromFile = candidateFromJsonText(input.kind, readFileOrNull(path));
2355
+ if (fromFile) return okOutcome(fromFile);
2356
+ }
2357
+ }
1058
2358
  if (isErrorEnvelope(native)) {
1059
2359
  return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
1060
2360
  }
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
2361
  if (native.structuredOutput !== void 0) {
1066
2362
  const fromStructured = gateCandidate(input.kind, native.structuredOutput);
1067
2363
  if (fromStructured) return okOutcome(fromStructured);
@@ -1081,9 +2377,10 @@ var extractStructured = (input) => {
1081
2377
  };
1082
2378
  }
1083
2379
  }
2380
+ const fallbackRung = input.agentFileFallbackPath ? ", last-valid snapshot" : "";
1084
2381
  return {
1085
2382
  kind: "none",
1086
- detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
2383
+ detail: `no --agent-file${fallbackRung}, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
1087
2384
  };
1088
2385
  };
1089
2386
 
@@ -1120,45 +2417,118 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1120
2417
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1121
2418
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1122
2419
  }));
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,
2420
+ var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
2421
+ const ladder = extractStructured({
2422
+ kind: "findings",
2423
+ native,
2424
+ agentFilePath,
2425
+ agentFileFallbackPath
2426
+ });
2427
+ if (ladder.kind !== "ok")
2428
+ return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
2429
+ const resolution = resolve("findings", ladder.candidate);
2430
+ return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
2431
+ kind: "telemetry-only",
2432
+ reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
2433
+ };
2434
+ };
2435
+ var withMeta = (base, meta) => ({
2436
+ ...base,
2437
+ ...meta.route ? { route: meta.route } : {},
2438
+ ...meta.effort ? { effort: meta.effort } : {}
2439
+ });
2440
+ var resolveTelemetry = (native, meta) => {
2441
+ const fb = (() => {
2442
+ try {
2443
+ return meta.transcriptFallback?.();
2444
+ } catch {
2445
+ return void 0;
2446
+ }
2447
+ })();
2448
+ const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
2449
+ return withMeta(
2450
+ {
2451
+ models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
2452
+ ...wallTurns,
2453
+ vendor_cost_usd: native.vendorCostUsd
2454
+ },
2455
+ meta
2456
+ );
2457
+ };
2458
+ var nativeTelemetry = (native, meta) => resolveTelemetry(
2459
+ {
1132
2460
  models: mapModelUsage(native.modelUsage),
1133
2461
  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
- );
2462
+ durationMs: native.duration_ms,
2463
+ vendorCostUsd: native.total_cost_usd ?? null
2464
+ },
2465
+ meta
2466
+ );
2467
+ var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
2468
+ var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath) => {
2469
+ const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
2470
+ switch (outcome.kind) {
2471
+ case "ok":
2472
+ return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
2473
+ case "telemetry-only":
2474
+ return {
2475
+ schema_version: DEFAULT_SCHEMA_VERSION,
2476
+ findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
2477
+
2478
+ ${outcome.reason}`),
2479
+ incomplete: true,
2480
+ ...telemetry
2481
+ };
2482
+ }
1141
2483
  };
1142
2484
  var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1143
2485
  switch (adapterName) {
1144
2486
  // 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
2487
  case "claude-code": {
2488
+ if (native === void 0 || native === null)
2489
+ return right(
2490
+ buildEnvelope(
2491
+ absentTelemetry(meta),
2492
+ void 0,
2493
+ agentFilePath,
2494
+ meta.agentFileFallbackPath
2495
+ )
2496
+ );
1146
2497
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
1147
- if (decoded._tag === "Left") {
2498
+ if (decoded._tag === "Left")
1148
2499
  return left("native envelope does not match the Claude Code output shape");
1149
- }
1150
- return adaptClaudeCode(decoded.right, agentFilePath, meta);
2500
+ return right(
2501
+ buildEnvelope(
2502
+ nativeTelemetry(decoded.right, meta),
2503
+ native,
2504
+ agentFilePath,
2505
+ meta.agentFileFallbackPath
2506
+ )
2507
+ );
1151
2508
  }
1152
2509
  }
1153
2510
  };
1154
2511
 
2512
+ // src/settings.ts
2513
+ var composeReviewSettings = (opts) => {
2514
+ const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
2515
+ return {
2516
+ hooks: {
2517
+ Stop: [
2518
+ { hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
2519
+ ],
2520
+ PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
2521
+ PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
2522
+ }
2523
+ };
2524
+ };
2525
+
1155
2526
  // src/index.ts
1156
2527
  var readJSON = (path) => {
1157
2528
  try {
1158
2529
  return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
1159
2530
  } catch (err) {
1160
- fail(`Cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
1161
- throw new Error("unreachable", { cause: err });
2531
+ return fail(`Cannot read ${path}: ${errMsg(err)}`);
1162
2532
  }
1163
2533
  };
1164
2534
  var fail = (msg) => {
@@ -1166,32 +2536,86 @@ var fail = (msg) => {
1166
2536
  `);
1167
2537
  process.exit(1);
1168
2538
  };
2539
+ var readJSONOrAbsent = (path) => {
2540
+ const read = (() => {
2541
+ try {
2542
+ return { text: readFileSync(resolve$1(path), "utf-8") };
2543
+ } catch (err) {
2544
+ return { error: errMsg(err) };
2545
+ }
2546
+ })();
2547
+ if ("error" in read) {
2548
+ process.stderr.write(
2549
+ `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry
2550
+ `
2551
+ );
2552
+ return void 0;
2553
+ }
2554
+ if (read.text.trim() === "") {
2555
+ process.stderr.write(
2556
+ `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry
2557
+ `
2558
+ );
2559
+ return void 0;
2560
+ }
2561
+ try {
2562
+ return JSON.parse(read.text);
2563
+ } catch (err) {
2564
+ process.stderr.write(
2565
+ `code-review: native envelope ${path} is not valid JSON (${errMsg(err)}) \u2014 proceeding with no native telemetry
2566
+ `
2567
+ );
2568
+ return void 0;
2569
+ }
2570
+ };
2571
+ var readStdinJSON = () => {
2572
+ if (process.stdin.isTTY) return null;
2573
+ const raw = (() => {
2574
+ try {
2575
+ return readFileSync(0, "utf-8");
2576
+ } catch {
2577
+ return "";
2578
+ }
2579
+ })();
2580
+ if (raw.trim() === "") return null;
2581
+ const parsed = tryParseJson(raw);
2582
+ return parsed.ok ? parsed.value : null;
2583
+ };
1169
2584
  var decode = (either, label) => {
1170
2585
  try {
1171
2586
  return unsafeUnwrap(either);
1172
2587
  } catch {
1173
- fail(`${label} does not match expected shape`);
2588
+ return fail(`${label} does not match expected shape`);
1174
2589
  }
1175
- throw new Error("unreachable");
1176
2590
  };
1177
2591
  var unwrapAdapt = (either) => {
1178
2592
  try {
1179
2593
  if (either._tag === "Left") throw new Error(either.left);
1180
2594
  return either.right;
1181
2595
  } catch (err) {
1182
- fail(err instanceof Error ? err.message : String(err));
2596
+ return fail(errMsg(err));
1183
2597
  }
1184
- throw new Error("unreachable");
2598
+ };
2599
+ var transcriptFallbackFrom = (path) => {
2600
+ const tree = readTranscriptTree(resolve$1(path));
2601
+ if (tree.missing)
2602
+ process.stderr.write(
2603
+ `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
2604
+ `
2605
+ );
2606
+ const usage = sumTranscriptUsage(tree.entries);
2607
+ return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
1185
2608
  };
1186
2609
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1187
2610
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1188
2611
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
1189
- var resolvePricesPath = (pricesArg) => {
1190
- if (pricesArg) return resolve$1(pricesArg);
2612
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
2613
+ var resolvePrices = (pricesArg) => {
2614
+ if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
1191
2615
  process.stderr.write(
1192
- "code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
2616
+ "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
1193
2617
  );
1194
- return bundledPath("schema", "prices.example.json");
2618
+ return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
1195
2619
  };
1196
2620
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
1197
2621
  var renderCmd = defineCommand({
@@ -1239,19 +2663,21 @@ var renderCmd = defineCommand({
1239
2663
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1240
2664
  const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
1241
2665
  const templatePath = resolveTemplatePath(args.template);
1242
- const pricesPath = resolvePricesPath(args.prices);
1243
- const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
2666
+ const priceResolution = resolvePrices(args.prices);
2667
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
1244
2668
  const template = readFileSync(templatePath, "utf-8");
1245
2669
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
1246
2670
  const output = render({
1247
2671
  findings,
1248
2672
  envelope,
1249
2673
  prices,
2674
+ pricesProvided: priceResolution.kind === "provided",
1250
2675
  template,
1251
2676
  reviewedSha: args["reviewed-sha"],
1252
2677
  route: args.route,
1253
2678
  effort: args.effort,
1254
- testReport
2679
+ testReport,
2680
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1255
2681
  });
1256
2682
  process.stdout.write(output);
1257
2683
  }
@@ -1274,14 +2700,17 @@ var inlineCmd = defineCommand({
1274
2700
  },
1275
2701
  template: {
1276
2702
  type: "string",
1277
- description: "Path to inline comment Eta template (default: built-in format)"
2703
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1278
2704
  }
1279
2705
  },
1280
2706
  run: async ({ args }) => {
1281
2707
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1282
2708
  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);
2709
+ const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
2710
+ const { comments, strays } = buildInlineComments(findings.findings, diff, {
2711
+ inlineTemplate,
2712
+ findings
2713
+ });
1285
2714
  process.stdout.write(
1286
2715
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1287
2716
  );
@@ -1311,67 +2740,475 @@ var costCmd = defineCommand({
1311
2740
  process.stdout.write(JSON.stringify(report, null, 2));
1312
2741
  }
1313
2742
  });
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({
2743
+ var checkCostCmd = defineCommand({
1317
2744
  meta: {
1318
- name: "validate",
1319
- description: "Validate a findings/triage/prices JSON document against the canonical schema"
2745
+ name: "check-cost",
2746
+ description: "Sum real USD spend from a Claude Code transcript tree (main + subagents) against a price map"
1320
2747
  },
1321
2748
  args: {
1322
- document: {
2749
+ transcript: {
1323
2750
  type: "positional",
1324
- description: "Path to the JSON document to validate (of the given --kind)",
2751
+ description: "Path to the session transcript JSONL (the hook's transcript_path)",
1325
2752
  required: true
1326
2753
  },
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": {
2754
+ prices: {
1336
2755
  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)"
2756
+ description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
1338
2757
  }
1339
2758
  },
1340
2759
  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);
2760
+ const tree = readTranscriptTree(resolve$1(args.transcript));
2761
+ if (tree.missing) {
2762
+ process.stderr.write(
2763
+ `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
2764
+ `
2765
+ );
1352
2766
  }
2767
+ const usage = sumTranscriptUsage(tree.entries);
2768
+ const priceResolution = resolvePrices(args.prices);
2769
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
2770
+ const report = computeCost(usage.models, prices);
2771
+ process.stdout.write(
2772
+ `${JSON.stringify(
2773
+ {
2774
+ ...report,
2775
+ turns: usage.turns,
2776
+ durationMs: usage.durationMs,
2777
+ transcripts: tree.files,
2778
+ pricesProvided: priceResolution.kind === "provided"
2779
+ },
2780
+ null,
2781
+ 2
2782
+ )}
2783
+ `
2784
+ );
1353
2785
  }
1354
2786
  });
1355
- var adaptCmd = defineCommand({
1356
- meta: {
1357
- name: "adapt",
1358
- description: "Map a native agent-CLI result envelope onto the abstract SPEC \xA76.1 envelope"
1359
- },
1360
- args: {
1361
- native: {
1362
- type: "positional",
1363
- description: "Path to the native result envelope JSON (from the agent CLI)",
1364
- required: true
1365
- },
1366
- adapter: {
1367
- type: "string",
1368
- description: 'Adapter to use (currently: "claude-code")',
1369
- required: true
1370
- },
1371
- "agent-file": {
1372
- type: "string",
2787
+ var tryReadPrices = (path) => {
2788
+ try {
2789
+ const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
2790
+ return decoded._tag === "Right" ? decoded.right : null;
2791
+ } catch {
2792
+ return null;
2793
+ }
2794
+ };
2795
+ var parseBudgetUsd = (raw) => {
2796
+ if (raw === void 0) return null;
2797
+ const n = Number.parseFloat(raw);
2798
+ return Number.isFinite(n) && n >= 0 ? n : null;
2799
+ };
2800
+ var mtimeMsOf = (path) => {
2801
+ try {
2802
+ return statSync(path).mtimeMs;
2803
+ } catch {
2804
+ return null;
2805
+ }
2806
+ };
2807
+ var transcriptPathOf = (input) => {
2808
+ const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
2809
+ return typeof tp === "string" ? tp : void 0;
2810
+ };
2811
+ var snapshotIfValid = (draftPath) => {
2812
+ try {
2813
+ if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
2814
+ copyFileSync(draftPath, lastValidPath(draftPath));
2815
+ } catch (err) {
2816
+ process.stderr.write(
2817
+ `code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
2818
+ `
2819
+ );
2820
+ }
2821
+ };
2822
+ var budgetHookCmd = defineCommand({
2823
+ meta: {
2824
+ name: "budget-hook",
2825
+ 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."
2826
+ },
2827
+ args: {
2828
+ draft: {
2829
+ type: "string",
2830
+ description: "Path to the findings draft that is the sole permitted write target under forced convergence",
2831
+ required: true
2832
+ },
2833
+ "budget-usd": {
2834
+ type: "string",
2835
+ description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
2836
+ },
2837
+ wall: {
2838
+ type: "string",
2839
+ description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
2840
+ },
2841
+ prices: {
2842
+ type: "string",
2843
+ description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
2844
+ },
2845
+ "reserve-frac": {
2846
+ type: "string",
2847
+ 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)"
2848
+ },
2849
+ "reserve-growth": {
2850
+ type: "string",
2851
+ 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)"
2852
+ },
2853
+ "reserve-usd": {
2854
+ type: "string",
2855
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2856
+ },
2857
+ "reserve-wall": {
2858
+ type: "string",
2859
+ description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
2860
+ }
2861
+ },
2862
+ run: async ({ args }) => {
2863
+ try {
2864
+ const draftPath = resolve$1(args.draft);
2865
+ const input = readStdinJSON();
2866
+ const transcriptPath = transcriptPathOf(input);
2867
+ const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
2868
+ const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
2869
+ const prices = args.prices ? tryReadPrices(args.prices) : null;
2870
+ const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
2871
+ const wallMs = args.wall ? parseWallMs(args.wall) : null;
2872
+ const output = evaluateBudgetHook(input, {
2873
+ spentUsd,
2874
+ budgetUsd: parseBudgetUsd(args["budget-usd"]),
2875
+ elapsedMs: anchoredElapsedMs({
2876
+ deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
2877
+ wallMs,
2878
+ firstTsMs: usage?.firstTsMs ?? null,
2879
+ nowMs: Date.now()
2880
+ }),
2881
+ wallMs,
2882
+ reserve: {
2883
+ frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
2884
+ growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
2885
+ flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
2886
+ flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
2887
+ },
2888
+ draftPath,
2889
+ mainDraftWritten: mainHasWrittenDraft(
2890
+ mtimeMsOf(draftPath),
2891
+ mtimeMsOf(seedMarkerPath(draftPath))
2892
+ )
2893
+ });
2894
+ if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
2895
+ snapshotIfValid(draftPath);
2896
+ process.stdout.write(`${JSON.stringify(output)}
2897
+ `);
2898
+ } catch (err) {
2899
+ process.stderr.write(`code-review budget-hook: degrading to no-op \u2014 ${errMsg(err)}
2900
+ `);
2901
+ process.stdout.write("{}\n");
2902
+ }
2903
+ }
2904
+ });
2905
+ var printSettingsCmd = defineCommand({
2906
+ meta: {
2907
+ name: "print-settings",
2908
+ 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"
2909
+ },
2910
+ args: {
2911
+ draft: {
2912
+ type: "string",
2913
+ description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
2914
+ required: true
2915
+ },
2916
+ kind: {
2917
+ type: "string",
2918
+ description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
2919
+ },
2920
+ schema: {
2921
+ type: "string",
2922
+ description: "Path to a schema file for the Stop gate (wins over --kind)"
2923
+ },
2924
+ "schema-version": {
2925
+ type: "string",
2926
+ description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
2927
+ },
2928
+ "max-nudges": {
2929
+ type: "string",
2930
+ description: "Stop-gate nudge budget before relenting (default: 5)"
2931
+ },
2932
+ counter: {
2933
+ type: "string",
2934
+ description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
2935
+ },
2936
+ "budget-usd": {
2937
+ type: "string",
2938
+ description: "Dollar budget the cost axis is measured against (needs --prices)"
2939
+ },
2940
+ wall: {
2941
+ type: "string",
2942
+ description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
2943
+ },
2944
+ prices: {
2945
+ type: "string",
2946
+ description: "Price map JSON to recompute real spend from the transcript"
2947
+ },
2948
+ "reserve-frac": {
2949
+ type: "string",
2950
+ description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2951
+ },
2952
+ "reserve-growth": {
2953
+ type: "string",
2954
+ description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
2955
+ },
2956
+ "reserve-usd": {
2957
+ type: "string",
2958
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2959
+ },
2960
+ "reserve-wall": {
2961
+ type: "string",
2962
+ description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
2963
+ }
2964
+ },
2965
+ run: async ({ args }) => {
2966
+ if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
2967
+ fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
2968
+ const settings = composeReviewSettings({
2969
+ draftPath: resolve$1(args.draft),
2970
+ stop: {
2971
+ kind: args.kind,
2972
+ schema: args.schema,
2973
+ schemaVersion: args["schema-version"],
2974
+ maxNudges: args["max-nudges"],
2975
+ counter: args.counter
2976
+ },
2977
+ budget: {
2978
+ budgetUsd: args["budget-usd"],
2979
+ wall: args.wall,
2980
+ prices: args.prices,
2981
+ reserveFrac: args["reserve-frac"],
2982
+ reserveGrowth: args["reserve-growth"],
2983
+ reserveUsd: args["reserve-usd"],
2984
+ reserveWall: args["reserve-wall"]
2985
+ }
2986
+ });
2987
+ process.stdout.write(`${JSON.stringify(settings)}
2988
+ `);
2989
+ }
2990
+ });
2991
+ var deadlineCmd = defineCommand({
2992
+ meta: {
2993
+ name: "deadline",
2994
+ 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"
2995
+ },
2996
+ args: {
2997
+ wall: {
2998
+ type: "string",
2999
+ description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
3000
+ required: true
3001
+ }
3002
+ },
3003
+ run: async ({ args }) => {
3004
+ const wallMs = parseWallMs(args.wall);
3005
+ if (wallMs === null) {
3006
+ fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
3007
+ } else {
3008
+ process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
3009
+ `);
3010
+ }
3011
+ }
3012
+ });
3013
+ var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
3014
+ var printableSchema = (schemaPath) => {
3015
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
3016
+ const enforcementSchema = Object.fromEntries(
3017
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
3018
+ );
3019
+ return JSON.stringify(enforcementSchema, null, 2);
3020
+ };
3021
+ var validateCmd = defineCommand({
3022
+ meta: {
3023
+ name: "validate",
3024
+ description: "Validate a findings/triage/prices JSON document against the canonical schema"
3025
+ },
3026
+ args: {
3027
+ document: {
3028
+ type: "positional",
3029
+ description: "Path to the JSON document to validate (of the given --kind)",
3030
+ required: true
3031
+ },
3032
+ kind: {
3033
+ type: "string",
3034
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
3035
+ },
3036
+ schema: {
3037
+ type: "string",
3038
+ 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)"
3039
+ },
3040
+ "schema-version": {
3041
+ type: "string",
3042
+ description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
3043
+ },
3044
+ explain: {
3045
+ type: "boolean",
3046
+ 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"
3047
+ }
3048
+ },
3049
+ run: async ({ args }) => {
3050
+ const kind = requireSchemaKind(args.kind || "findings");
3051
+ const documentRaw = readJSON(args.document);
3052
+ const schemaPath = args.schema ? resolve$1(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
3053
+ const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
3054
+ if (valid) {
3055
+ process.stdout.write("\u2705 valid\n");
3056
+ } else {
3057
+ process.stderr.write("\u274C invalid\n");
3058
+ for (const e of errors) process.stderr.write(` - ${e}
3059
+ `);
3060
+ if (args.explain) {
3061
+ process.stderr.write(
3062
+ `
3063
+ The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
3064
+ ${printableSchema(schemaPath)}
3065
+ `
3066
+ );
3067
+ }
3068
+ process.exit(1);
3069
+ }
3070
+ }
3071
+ });
3072
+ var seedDraftCmd = defineCommand({
3073
+ meta: {
3074
+ name: "seed-draft",
3075
+ 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"
3076
+ },
3077
+ args: {
3078
+ prior: {
3079
+ type: "string",
3080
+ 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"
3081
+ },
3082
+ "head-sha": {
3083
+ type: "string",
3084
+ 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"
3085
+ },
3086
+ out: {
3087
+ type: "string",
3088
+ description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
3089
+ required: true
3090
+ },
3091
+ kind: {
3092
+ type: "string",
3093
+ description: "Schema kind to validate the prior findings against (default: findings)"
3094
+ },
3095
+ schema: {
3096
+ type: "string",
3097
+ description: "Path to a schema file (wins over --kind/--schema-version)"
3098
+ },
3099
+ "schema-version": {
3100
+ type: "string",
3101
+ 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)"
3102
+ }
3103
+ },
3104
+ run: async ({ args }) => {
3105
+ const outPath = resolve$1(args.out);
3106
+ const kindArg = args.kind || "findings";
3107
+ const kind = isSchemaKind(kindArg) ? kindArg : "findings";
3108
+ if (kind !== kindArg) {
3109
+ process.stderr.write(
3110
+ `Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
3111
+ `
3112
+ );
3113
+ }
3114
+ const writeSeedMarker = () => {
3115
+ try {
3116
+ writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
3117
+ } catch (err) {
3118
+ process.stderr.write(
3119
+ `Warning: could not write the seed marker beside ${outPath} (${errMsg(err)}) \u2014 the seeded draft will count as agent-written
3120
+ `
3121
+ );
3122
+ }
3123
+ };
3124
+ const writeScaffold = () => {
3125
+ try {
3126
+ writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
3127
+ `);
3128
+ writeSeedMarker();
3129
+ process.stderr.write(
3130
+ `Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
3131
+ `
3132
+ );
3133
+ return true;
3134
+ } catch (err) {
3135
+ process.stderr.write(
3136
+ `Warning: could not write the seed scaffold to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
3137
+ `
3138
+ );
3139
+ return false;
3140
+ }
3141
+ };
3142
+ const priorBody = (() => {
3143
+ if (!args.prior) return null;
3144
+ const raw = (() => {
3145
+ try {
3146
+ return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
3147
+ } catch {
3148
+ return null;
3149
+ }
3150
+ })();
3151
+ return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
3152
+ })();
3153
+ const priorFindings = priorBody === null ? null : parseFindingsMarker(priorBody);
3154
+ const seededFromPrior = priorFindings === null ? false : (() => {
3155
+ try {
3156
+ const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
3157
+ if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
3158
+ writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
3159
+ `);
3160
+ writeSeedMarker();
3161
+ const priorList = priorFindings.findings;
3162
+ const count = Array.isArray(priorList) ? priorList.length : 0;
3163
+ process.stderr.write(
3164
+ `Seeded ${outPath} from the prior review (${String(count)} finding(s))
3165
+ `
3166
+ );
3167
+ return true;
3168
+ } catch (err) {
3169
+ process.stderr.write(
3170
+ `Warning: could not seed from the prior review (${errMsg(err)}) \u2014 falling back to the empty scaffold
3171
+ `
3172
+ );
3173
+ return false;
3174
+ }
3175
+ })();
3176
+ const mode = (() => {
3177
+ if (seededFromPrior) {
3178
+ const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
3179
+ return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
3180
+ }
3181
+ if (!writeScaffold()) return "none";
3182
+ return priorBody === null ? "empty" : "empty-had-prior";
3183
+ })();
3184
+ process.stdout.write(`${mode}
3185
+ `);
3186
+ }
3187
+ });
3188
+ var adaptCmd = defineCommand({
3189
+ meta: {
3190
+ name: "adapt",
3191
+ description: "Map a native agent-CLI result envelope onto the abstract SPEC envelope"
3192
+ },
3193
+ args: {
3194
+ native: {
3195
+ type: "positional",
3196
+ description: "Path to the native result envelope JSON (from the agent CLI)",
3197
+ required: true
3198
+ },
3199
+ adapter: {
3200
+ type: "string",
3201
+ description: 'Adapter to use (currently: "claude-code")',
3202
+ required: true
3203
+ },
3204
+ "agent-file": {
3205
+ type: "string",
1373
3206
  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
3207
  },
3208
+ "agent-file-fallback": {
3209
+ type: "string",
3210
+ 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"
3211
+ },
1375
3212
  route: {
1376
3213
  type: "string",
1377
3214
  description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
@@ -1379,25 +3216,53 @@ var adaptCmd = defineCommand({
1379
3216
  effort: {
1380
3217
  type: "string",
1381
3218
  description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
3219
+ },
3220
+ transcript: {
3221
+ type: "string",
3222
+ 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
3223
  }
1383
3224
  },
1384
3225
  run: async ({ args }) => {
1385
3226
  const envelope = unwrapAdapt(
1386
- adapt(requireAdapterName(args.adapter), readJSON(args.native), args["agent-file"], {
3227
+ adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
1387
3228
  route: args.route,
1388
- effort: args.effort
3229
+ effort: args.effort,
3230
+ agentFileFallbackPath: args["agent-file-fallback"],
3231
+ ...args.transcript ? {
3232
+ transcriptFallback: () => transcriptFallbackFrom(args.transcript)
3233
+ } : {}
1389
3234
  })
1390
3235
  );
1391
3236
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}
1392
3237
  `);
1393
3238
  }
1394
3239
  });
3240
+ var noticeCmd = defineCommand({
3241
+ meta: {
3242
+ name: "notice",
3243
+ description: "Emit an abstract envelope for a run that produced no completed review (security block, setup failure, unapplied diff, or empty run) \u2014 flagged incomplete so the commenter renders it honestly and won't bury a real review"
3244
+ },
3245
+ args: {
3246
+ kind: {
3247
+ type: "positional",
3248
+ description: "One of: security-blocked, setup-failed, checkout-failed, no-output",
3249
+ required: true
3250
+ },
3251
+ reasons: {
3252
+ type: "string",
3253
+ description: "security-blocked only: the triage's fail-closed reason string (empty/omitted \u21D2 the no-reason wording)"
3254
+ }
3255
+ },
3256
+ run: ({ args }) => {
3257
+ const kind = isNoticeKind(args.kind) ? args.kind : fail(
3258
+ `Unknown notice kind "${args.kind}" \u2014 expected one of: security-blocked, setup-failed, checkout-failed, no-output`
3259
+ );
3260
+ process.stdout.write(`${JSON.stringify(buildNoticeEnvelope(kind, args.reasons), null, 2)}
3261
+ `);
3262
+ }
3263
+ });
1395
3264
  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
- };
3265
+ var requireExtractSchemaKind = (name) => isExtractSchemaKind(name) ? name : fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
1401
3266
  var failClosedTriage = (outcome) => ({
1402
3267
  safe: false,
1403
3268
  reasons: describeLadderFailure(outcome)
@@ -1431,16 +3296,18 @@ var extractCmd = defineCommand({
1431
3296
  run: async ({ args }) => {
1432
3297
  requireAdapterName(args.adapter);
1433
3298
  const kind = requireExtractSchemaKind(args.kind);
1434
- const outcome = extractStructured({
1435
- kind,
1436
- native: readJSON(args.native),
1437
- agentFilePath: args["agent-file"]
1438
- });
3299
+ const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
3300
+ const outcome = extractStructured(input);
1439
3301
  if (outcome.kind === "ok") {
1440
3302
  process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
1441
3303
  `);
1442
3304
  return;
1443
3305
  }
3306
+ if (outcome.kind === "none" || outcome.kind === "ambiguous") {
3307
+ process.stderr.write(`extract: recovery failed \u2014
3308
+ ${ladderFailureDiagnostics(input)}
3309
+ `);
3310
+ }
1444
3311
  if (kind === "triage") {
1445
3312
  process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
1446
3313
  `);
@@ -1449,24 +3316,79 @@ var extractCmd = defineCommand({
1449
3316
  fail(describeLadderFailure(outcome));
1450
3317
  }
1451
3318
  });
1452
- var requireAdapterName = (name) => {
1453
- if (isAdapterName(name)) return name;
1454
- fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
1455
- throw new Error("unreachable");
3319
+ var withoutPatch = (finding) => {
3320
+ const copy = { ...finding };
3321
+ delete copy.patch;
3322
+ return copy;
1456
3323
  };
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");
3324
+ var readFileLines = (path) => {
3325
+ try {
3326
+ const rawLines = readFileSync(path, "utf-8").split("\n");
3327
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
3328
+ } catch {
3329
+ return null;
3330
+ }
1462
3331
  };
3332
+ var validateFinding = (finding, repoRoot) => {
3333
+ if (finding.patch === void 0) return finding;
3334
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
3335
+ if (lines === null) {
3336
+ process.stderr.write(
3337
+ `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
3338
+ `
3339
+ );
3340
+ return withoutPatch(finding);
3341
+ }
3342
+ const result = validatePatch(finding.patch, lines);
3343
+ switch (result.kind) {
3344
+ case "anchored":
3345
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
3346
+ case "keep":
3347
+ return finding;
3348
+ case "drop":
3349
+ process.stderr.write(
3350
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
3351
+ `
3352
+ );
3353
+ return withoutPatch(finding);
3354
+ }
3355
+ };
3356
+ var validatePatchesCmd = defineCommand({
3357
+ meta: {
3358
+ name: "validate-patches",
3359
+ 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"
3360
+ },
3361
+ args: {
3362
+ findings: {
3363
+ type: "positional",
3364
+ description: "Path to findings JSON",
3365
+ required: true
3366
+ },
3367
+ "repo-root": {
3368
+ type: "string",
3369
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
3370
+ }
3371
+ },
3372
+ run: async ({ args }) => {
3373
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
3374
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
3375
+ const validated = {
3376
+ ...findings,
3377
+ findings: findings.findings.map((f) => validateFinding(f, repoRoot))
3378
+ };
3379
+ process.stdout.write(`${JSON.stringify(validated, null, 2)}
3380
+ `);
3381
+ }
3382
+ });
3383
+ var requireAdapterName = (name) => isAdapterName(name) ? name : fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
3384
+ var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
3385
+ var requireSchemaKind = (name) => isSchemaKind(name) ? name : fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
1463
3386
  var requireSchemaPath = (kind, version) => {
1464
3387
  try {
1465
3388
  return schemaPathFor(kind, version);
1466
3389
  } catch (err) {
1467
- fail(err instanceof Error ? err.message : String(err));
3390
+ return fail(errMsg(err));
1468
3391
  }
1469
- throw new Error("unreachable");
1470
3392
  };
1471
3393
  var printSchemaCmd = defineCommand({
1472
3394
  meta: {
@@ -1487,18 +3409,105 @@ var printSchemaCmd = defineCommand({
1487
3409
  run: async ({ args }) => {
1488
3410
  const schemaKind = requireSchemaKind(args.name);
1489
3411
  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")
3412
+ process.stdout.write(`${printableSchema(schemaPath)}
3413
+ `);
3414
+ }
3415
+ });
3416
+ var MAX_NUDGES_DEFAULT = 5;
3417
+ var drainStdin = () => {
3418
+ if (process.stdin.isTTY) return;
3419
+ try {
3420
+ readFileSync(0);
3421
+ } catch {
3422
+ }
3423
+ };
3424
+ var requireMaxNudges = (raw) => {
3425
+ if (raw === void 0) return MAX_NUDGES_DEFAULT;
3426
+ if (!/^\d+$/.test(raw)) {
3427
+ fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
3428
+ }
3429
+ const n = Number.parseInt(raw, 10);
3430
+ if (n < 1) {
3431
+ fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
3432
+ }
3433
+ return n;
3434
+ };
3435
+ var stopGateCmd = defineCommand({
3436
+ meta: {
3437
+ name: "stop-gate",
3438
+ 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."
3439
+ },
3440
+ args: {
3441
+ draft: {
3442
+ type: "string",
3443
+ description: "Path to the findings document the agent must produce and keep valid",
3444
+ required: true
3445
+ },
3446
+ kind: {
3447
+ type: "string",
3448
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
3449
+ },
3450
+ schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
3451
+ "schema-version": {
3452
+ type: "string",
3453
+ description: "Schema major.minor to validate against (default: the draft's declared version)"
3454
+ },
3455
+ "max-nudges": {
3456
+ type: "string",
3457
+ description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
3458
+ },
3459
+ counter: {
3460
+ type: "string",
3461
+ description: "Path for the nudge counter (default: <draft>.nudges)"
3462
+ },
3463
+ "print-settings": {
3464
+ type: "boolean",
3465
+ description: "Print the Stop-hook settings JSON that wires this gate, then exit"
3466
+ }
3467
+ },
3468
+ run: async ({ args }) => {
3469
+ const draftPath = resolve$1(args.draft);
3470
+ if (args["print-settings"]) {
3471
+ const command = defaultHookCommand(draftPath, {
3472
+ kind: args.kind,
3473
+ schema: args.schema,
3474
+ schemaVersion: args["schema-version"],
3475
+ maxNudges: args["max-nudges"],
3476
+ counter: args.counter
3477
+ });
3478
+ process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
3479
+ `);
3480
+ return;
3481
+ }
3482
+ drainStdin();
3483
+ const kind = requireSchemaKind(args.kind || "findings");
3484
+ const maxNudges = requireMaxNudges(args["max-nudges"]);
3485
+ const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
3486
+ const state = draftState(
3487
+ draftPath,
3488
+ (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
1493
3489
  );
1494
- process.stdout.write(`${JSON.stringify(enforcementSchema, null, 2)}
3490
+ const nudges = readNudges(counterPath);
3491
+ const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
3492
+ if (decision.kind === "block") {
3493
+ try {
3494
+ bumpNudges(counterPath, nudges);
3495
+ } catch (err) {
3496
+ process.stderr.write(
3497
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${errMsg(err)}
3498
+ `
3499
+ );
3500
+ return;
3501
+ }
3502
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
1495
3503
  `);
3504
+ }
1496
3505
  }
1497
3506
  });
1498
3507
  var gatherCmd = defineCommand({
1499
3508
  meta: {
1500
3509
  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"
3510
+ 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
3511
  },
1503
3512
  args: {
1504
3513
  repo: { type: "string", description: "Repository (owner/name)", required: true },
@@ -1511,6 +3520,11 @@ var gatherCmd = defineCommand({
1511
3520
  type: "string",
1512
3521
  description: "Head branch to disambiguate the PR when multiple share a commit"
1513
3522
  },
3523
+ "default-branch": {
3524
+ type: "string",
3525
+ 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",
3526
+ required: true
3527
+ },
1514
3528
  "run-id": {
1515
3529
  type: "string",
1516
3530
  description: "CI run id (from workflow_run.id); its failing jobs' logs are downloaded on failure",
@@ -1535,6 +3549,7 @@ var gatherCmd = defineCommand({
1535
3549
  repo: args.repo,
1536
3550
  headSha: args["head-sha"],
1537
3551
  headBranch: args["head-branch"],
3552
+ defaultBranch: args["default-branch"],
1538
3553
  runId: args["run-id"],
1539
3554
  conclusion: args.conclusion,
1540
3555
  botLogin: args["bot-login"] || "github-actions[bot]",
@@ -1579,7 +3594,7 @@ var postCmd = defineCommand({
1579
3594
  },
1580
3595
  "inline-template": {
1581
3596
  type: "string",
1582
- description: "Path to inline comment Eta template (default: built-in format)"
3597
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1583
3598
  },
1584
3599
  route: {
1585
3600
  type: "string",
@@ -1600,47 +3615,275 @@ var postCmd = defineCommand({
1600
3615
  "test-report": {
1601
3616
  type: "string",
1602
3617
  description: TEST_REPORT_DESCRIPTION
3618
+ },
3619
+ "run-url": {
3620
+ type: "string",
3621
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
3622
+ },
3623
+ "json-url": {
3624
+ type: "string",
3625
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
1603
3626
  }
1604
3627
  },
1605
3628
  run: async ({ args }) => {
3629
+ const priceResolution = resolvePrices(args.prices);
1606
3630
  await post({
1607
3631
  repo: args.repo,
1608
3632
  headSha: args["head-sha"],
1609
3633
  botLogin: args["bot-login"] || "github-actions[bot]",
1610
3634
  findingsPath: args.findings,
1611
3635
  envelopePath: args.usage,
1612
- pricesPath: resolvePricesPath(args.prices),
3636
+ pricesPath: priceResolution.path,
3637
+ pricesProvided: priceResolution.kind === "provided",
1613
3638
  templatePath: resolveTemplatePath(args.template),
1614
- inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
3639
+ inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
1615
3640
  route: args.route,
1616
3641
  headBranch: args["head-branch"],
1617
3642
  effort: args.effort,
1618
- testReportPath: args["test-report"]
3643
+ testReportPath: args["test-report"],
3644
+ runUrl: args["run-url"],
3645
+ jsonUrl: args["json-url"],
3646
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
3647
+ });
3648
+ }
3649
+ });
3650
+ var announceCmd = defineCommand({
3651
+ meta: {
3652
+ name: "announce",
3653
+ 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."
3654
+ },
3655
+ args: {
3656
+ "head-sha": {
3657
+ type: "string",
3658
+ description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
3659
+ required: true
3660
+ },
3661
+ repo: {
3662
+ type: "string",
3663
+ description: "Repository (owner/name)",
3664
+ required: true
3665
+ },
3666
+ "run-url": {
3667
+ type: "string",
3668
+ description: "Workflow run URL the placeholder links to",
3669
+ required: true
3670
+ },
3671
+ "bot-login": {
3672
+ type: "string",
3673
+ description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
3674
+ },
3675
+ "head-branch": {
3676
+ type: "string",
3677
+ description: "Head branch to disambiguate the PR when multiple share a commit"
3678
+ }
3679
+ },
3680
+ run: async ({ args }) => {
3681
+ await announce({
3682
+ repo: args.repo,
3683
+ headSha: args["head-sha"],
3684
+ botLogin: args["bot-login"] || "github-actions[bot]",
3685
+ runUrl: args["run-url"],
3686
+ headBranch: args["head-branch"]
3687
+ }).catch(
3688
+ (err) => process.stderr.write(
3689
+ `code-review announce: could not post the in-progress sticky (${errMsg(err)}) \u2014 continuing (cosmetic)
3690
+ `
3691
+ )
3692
+ );
3693
+ }
3694
+ });
3695
+ var requireCeilingSec = (raw) => {
3696
+ if (raw === void 0) return null;
3697
+ const ms = parseWallMs(raw);
3698
+ if (ms === null)
3699
+ return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
3700
+ return Math.floor(ms / 1e3);
3701
+ };
3702
+ var requireCeilingUsd = (raw) => {
3703
+ if (raw === void 0) return null;
3704
+ const n = Number.parseFloat(raw.replace(/^\$/, ""));
3705
+ if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
3706
+ return n;
3707
+ };
3708
+ var requireMaxInstructions = (raw) => {
3709
+ if (raw === void 0) return 4e3;
3710
+ if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
3711
+ return Number.parseInt(raw, 10);
3712
+ };
3713
+ var requirePositiveInt = (raw, flag) => {
3714
+ const n = Number.parseInt(raw, 10);
3715
+ return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
3716
+ };
3717
+ var requireWallMs = (raw, flag, fallback) => {
3718
+ const ms = parseWallMs(raw || fallback);
3719
+ return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
3720
+ };
3721
+ var parseCommandCmd = defineCommand({
3722
+ meta: {
3723
+ name: "parse-command",
3724
+ 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.`
3725
+ },
3726
+ args: {
3727
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3728
+ pr: {
3729
+ type: "string",
3730
+ description: "PR number (from github.event.issue.number \u2014 trusted event data)",
3731
+ required: true
3732
+ },
3733
+ "comment-body": {
3734
+ type: "string",
3735
+ 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)"
3736
+ },
3737
+ trigger: {
3738
+ type: "string",
3739
+ description: 'Trigger token the comment must begin with (default: "/code-review")'
3740
+ },
3741
+ "max-duration": {
3742
+ type: "string",
3743
+ 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"
3744
+ },
3745
+ "max-usd": {
3746
+ type: "string",
3747
+ description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
3748
+ },
3749
+ "max-instructions": {
3750
+ type: "string",
3751
+ description: "Max characters of free-form instructions kept (default: 4000)"
3752
+ }
3753
+ },
3754
+ run: async ({ args }) => {
3755
+ const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
3756
+ const result = await parseCommand({
3757
+ repo: args.repo,
3758
+ prNumber: requirePositiveInt(args.pr, "--pr"),
3759
+ body,
3760
+ options: {
3761
+ trigger: args.trigger || "/code-review",
3762
+ maxDurationSec: requireCeilingSec(args["max-duration"]),
3763
+ maxUsd: requireCeilingUsd(args["max-usd"]),
3764
+ maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
3765
+ }
3766
+ });
3767
+ if (result.kind === "skip") {
3768
+ process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
3769
+ `);
3770
+ process.stdout.write(renderCommandOutputs(result, "UNUSED"));
3771
+ return;
3772
+ }
3773
+ for (const note of result.args.notes)
3774
+ process.stderr.write(`code-review parse-command: ${note}
3775
+ `);
3776
+ const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
3777
+ process.stdout.write(renderCommandOutputs(result, delim));
3778
+ }
3779
+ });
3780
+ var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
3781
+ var reactCmd = defineCommand({
3782
+ meta: {
3783
+ name: "react",
3784
+ 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."
3785
+ },
3786
+ args: {
3787
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3788
+ "comment-id": {
3789
+ type: "string",
3790
+ description: "Comment id to react on (github.event.comment.id)",
3791
+ required: true
3792
+ },
3793
+ add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
3794
+ remove: {
3795
+ type: "string",
3796
+ description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
3797
+ }
3798
+ },
3799
+ run: async ({ args }) => {
3800
+ const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
3801
+ const add = args.add ? requireReaction(args.add) : void 0;
3802
+ const remove = args.remove ? requireReaction(args.remove) : void 0;
3803
+ if (add === void 0 && remove === void 0)
3804
+ fail("react: nothing to do \u2014 pass --add and/or --remove");
3805
+ await react({ repo: args.repo, commentId, add, remove }).catch(
3806
+ (err) => process.stderr.write(
3807
+ `code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
3808
+ `
3809
+ )
3810
+ );
3811
+ }
3812
+ });
3813
+ var awaitCiCmd = defineCommand({
3814
+ meta: {
3815
+ name: "await-ci",
3816
+ 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)."
3817
+ },
3818
+ args: {
3819
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
3820
+ "head-sha": {
3821
+ type: "string",
3822
+ description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
3823
+ required: true
3824
+ },
3825
+ "ci-workflow": {
3826
+ type: "string",
3827
+ description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
3828
+ },
3829
+ timeout: {
3830
+ type: "string",
3831
+ description: "Give up waiting after this wall (default: 30m)"
3832
+ },
3833
+ "poll-interval": {
3834
+ type: "string",
3835
+ description: "How often to re-check the run status (default: 15s)"
3836
+ }
3837
+ },
3838
+ run: async ({ args }) => {
3839
+ const workflowName = args["ci-workflow"] || "CI";
3840
+ const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
3841
+ workflowName,
3842
+ pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
3843
+ timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
1619
3844
  });
3845
+ process.stderr.write(
3846
+ outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
3847
+ ` : `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."}
3848
+ `
3849
+ );
3850
+ process.stdout.write(renderCiOutputs(outcome));
1620
3851
  }
1621
3852
  });
1622
3853
  var main = defineCommand({
1623
3854
  meta: {
1624
3855
  name: "code-review",
1625
3856
  version: packageVersion,
1626
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
3857
+ description: "Deterministic commenter for agentic PR review"
1627
3858
  },
1628
3859
  subCommands: {
1629
3860
  gather: gatherCmd,
3861
+ "parse-command": parseCommandCmd,
3862
+ react: reactCmd,
3863
+ "await-ci": awaitCiCmd,
1630
3864
  render: renderCmd,
1631
3865
  inline: inlineCmd,
1632
3866
  post: postCmd,
3867
+ announce: announceCmd,
1633
3868
  cost: costCmd,
3869
+ "check-cost": checkCostCmd,
1634
3870
  validate: validateCmd,
3871
+ "seed-draft": seedDraftCmd,
1635
3872
  adapt: adaptCmd,
3873
+ notice: noticeCmd,
1636
3874
  extract: extractCmd,
1637
- "print-schema": printSchemaCmd
3875
+ "validate-patches": validatePatchesCmd,
3876
+ "print-schema": printSchemaCmd,
3877
+ "stop-gate": stopGateCmd,
3878
+ "budget-hook": budgetHookCmd,
3879
+ "print-settings": printSettingsCmd,
3880
+ deadline: deadlineCmd
1638
3881
  }
1639
3882
  });
1640
3883
  if (!process.env["VITEST"]) {
1641
3884
  await runMain(main);
1642
3885
  }
1643
3886
 
1644
- export { main };
3887
+ export { main, snapshotIfValid };
1645
3888
  //# sourceMappingURL=index.js.map
1646
3889
  //# sourceMappingURL=index.js.map