@jphutchins/code-review 0.1.0-alpha.4 → 0.1.0-alpha.40

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,183 @@
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, copyFileSync, statSync, 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';
7
+ import * as t from 'io-ts';
6
8
  import parseDiff from 'parse-diff';
7
9
  import { Ajv2020 } from 'ajv/dist/2020.js';
8
10
  import _addFormats from 'ajv-formats';
9
- 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';
14
+
15
+ var SeverityCodec = t.union([
16
+ t.literal("critical"),
17
+ t.literal("major"),
18
+ t.literal("minor"),
19
+ t.literal("nit")
20
+ ]);
21
+ var SideCodec = t.union([t.literal("RIGHT"), t.literal("LEFT")]);
22
+ var VerdictCodec = t.union([
23
+ t.literal("approve"),
24
+ t.literal("comment"),
25
+ t.literal("changes"),
26
+ t.literal("error")
27
+ ]);
28
+ var LineNumber = t.refinement(
29
+ t.number,
30
+ (n) => Number.isInteger(n) && n >= 1,
31
+ "LineNumber"
32
+ );
33
+ var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
34
+ var SCHEMA_VERSION_RE = /^(0|[1-9]\d*)\.(\d+)\.(\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
35
+ var SchemaVersion = t.refinement(
36
+ t.string,
37
+ (s) => SCHEMA_VERSION_RE.test(s),
38
+ "SchemaVersion"
39
+ );
40
+ var UriString = t.refinement(
41
+ t.string,
42
+ (s) => !/\s/.test(s) && URL.canParse(s),
43
+ "UriString"
44
+ );
45
+ var FindingRuleCodec = t.partial({
46
+ code: t.string,
47
+ code_url: UriString
48
+ });
49
+ var FindingShape = t.intersection([
50
+ t.type({
51
+ path: t.string,
52
+ start_line: LineNumber,
53
+ end_line: LineNumber,
54
+ severity: SeverityCodec,
55
+ title: t.string,
56
+ description: t.string,
57
+ reasoning: t.string,
58
+ confidence: Confidence
59
+ }),
60
+ FindingRuleCodec,
61
+ t.partial({
62
+ side: SideCodec,
63
+ recommendation: t.string,
64
+ patch: t.string
65
+ })
66
+ ]);
67
+ var EndGeStart = t.refinement(
68
+ FindingShape,
69
+ (f) => f.end_line >= f.start_line,
70
+ "EndGeStart"
71
+ );
72
+ var FindingCodec = t.exact(EndGeStart);
73
+ var SystemicRequired = t.type({
74
+ title: t.string,
75
+ description: t.string,
76
+ severity: SeverityCodec,
77
+ reasoning: t.string,
78
+ confidence: Confidence
79
+ });
80
+ var SystemicOptional = t.partial({
81
+ finding_codes: t.array(t.string),
82
+ paths: t.array(t.string)
83
+ });
84
+ var SystemicProblemShape = t.intersection([SystemicRequired, FindingRuleCodec, SystemicOptional]);
85
+ var SYSTEMIC_KEYS = /* @__PURE__ */ new Set([
86
+ ...Object.keys(SystemicRequired.props),
87
+ ...Object.keys(FindingRuleCodec.props),
88
+ ...Object.keys(SystemicOptional.props)
89
+ ]);
90
+ var SystemicProblemStrict = t.refinement(
91
+ SystemicProblemShape,
92
+ (s) => Object.keys(s).every((k) => SYSTEMIC_KEYS.has(k)),
93
+ "SystemicProblemStrict"
94
+ );
95
+ var SystemicProblemCodec = t.exact(SystemicProblemStrict);
96
+ var FindingsCodec = t.exact(
97
+ t.intersection([
98
+ t.type({
99
+ schema_version: SchemaVersion,
100
+ summary: t.string,
101
+ verdict: VerdictCodec,
102
+ findings: t.array(FindingCodec)
103
+ }),
104
+ t.partial({
105
+ systemic_problems: t.array(SystemicProblemCodec)
106
+ })
107
+ ])
108
+ );
109
+ var TriageCodec = t.type({
110
+ safe: t.boolean,
111
+ reasons: t.string
112
+ });
113
+ var TokenCount = t.refinement(
114
+ t.number,
115
+ (n) => Number.isInteger(n) && n >= 0,
116
+ "TokenCount"
117
+ );
118
+ var ModelUsageEntryCodec = t.intersection([
119
+ t.type({
120
+ model: t.string,
121
+ input_tokens: TokenCount,
122
+ output_tokens: TokenCount
123
+ }),
124
+ t.partial({
125
+ cache_read_tokens: TokenCount,
126
+ cache_write_tokens: TokenCount
127
+ })
128
+ ]);
129
+ var ResultEnvelopeCodec = t.intersection([
130
+ t.type({
131
+ schema_version: t.string,
132
+ findings: FindingsCodec,
133
+ models: t.array(ModelUsageEntryCodec),
134
+ turns: TokenCount,
135
+ duration_ms: TokenCount
136
+ }),
137
+ t.partial({
138
+ vendor_cost_usd: t.union([t.number, t.null]),
139
+ route: t.string,
140
+ effort: t.string,
141
+ // The run produced a notice rather than a completed review (security-gate block, agent kill, no
142
+ // recoverable findings). An empty `findings` array alone can't say this — a genuine clean review
143
+ // is also empty — so the render suppresses "clean review" and the sticky precedence guard refuses
144
+ // to bury a completed review under it. Absent ⇒ a completed review.
145
+ incomplete: t.boolean
146
+ })
147
+ ]);
148
+ var ModelPricesCodec = t.type({
149
+ in: t.number,
150
+ out: t.number,
151
+ cache_read: t.number,
152
+ cache_write: t.number
153
+ });
154
+ var PriceMapCodec = t.type({
155
+ _updated: t.string,
156
+ _unit: t.string,
157
+ models: t.record(t.string, ModelPricesCodec)
158
+ });
159
+ var TestFailureCodec = t.intersection([
160
+ t.type({ name: t.string }),
161
+ t.partial({ message: t.string })
162
+ ]);
163
+ var TestSummaryCodec = t.intersection([
164
+ t.type({
165
+ passed: t.number,
166
+ failed: t.number,
167
+ total: t.number
168
+ }),
169
+ t.partial({
170
+ failures: t.array(TestFailureCodec)
171
+ })
172
+ ]);
173
+ var DEFAULT_SCHEMA_VERSION = "0.6.0";
174
+ var incompleteFindings = (summary) => ({
175
+ schema_version: DEFAULT_SCHEMA_VERSION,
176
+ summary,
177
+ verdict: "error",
178
+ findings: []
179
+ });
180
+ var isIncompleteFindings = (findings) => findings.verdict === "error" && findings.findings.length === 0;
11
181
 
12
182
  // src/cost.ts
13
183
  var defaultWarn = (message) => {
@@ -53,44 +223,498 @@ var computeCost = (models, prices, warn = defaultWarn) => {
53
223
  };
54
224
  };
55
225
 
226
+ // src/patch.ts
227
+ var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
228
+ var hunkOldStart = (line) => {
229
+ const raw = HUNK_HEADER_RE.exec(line)?.[1];
230
+ return raw !== void 0 ? Number(raw) : null;
231
+ };
232
+ var classifyBodyLine = (line) => {
233
+ if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
234
+ if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
235
+ if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
236
+ return null;
237
+ };
238
+ var trimmedMiddle = (body) => {
239
+ const first = body.findIndex((l) => l.kind !== "context");
240
+ if (first === -1) return [];
241
+ const last = body.findLastIndex((l) => l.kind !== "context");
242
+ return body.slice(first, last + 1);
243
+ };
244
+ var isContiguousChange = (middle) => {
245
+ if (middle.some((l) => l.kind === "context")) return false;
246
+ const firstAdded = middle.findIndex((l) => l.kind === "added");
247
+ if (firstAdded === -1) return true;
248
+ return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
249
+ };
250
+ var removedRange = (body, oldStart) => body.reduce(
251
+ (acc, line) => line.kind === "added" ? acc : {
252
+ lineNumber: acc.lineNumber + 1,
253
+ firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
254
+ lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
255
+ },
256
+ { lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
257
+ );
258
+ var drop = (reason) => ({
259
+ kind: "drop",
260
+ reason
261
+ });
262
+ var keep = (reason) => ({
263
+ kind: "keep",
264
+ reason
265
+ });
266
+ var parseHunk = (patch) => {
267
+ const rawLines = patch.split("\n");
268
+ const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
269
+ const headerHits = lines.reduce(
270
+ (acc, line, index) => {
271
+ const oldStart = hunkOldStart(line);
272
+ return oldStart !== null ? [...acc, { index, oldStart }] : acc;
273
+ },
274
+ []
275
+ );
276
+ if (headerHits.length !== 1) {
277
+ return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
278
+ }
279
+ const hit = headerHits[0];
280
+ if (hit === void 0) return drop("malformed hunk header");
281
+ const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
282
+ const classified = bodyRaw.map(classifyBodyLine);
283
+ if (classified.some((line) => line === null)) return drop("malformed hunk body line");
284
+ const body = classified.filter((line) => line !== null);
285
+ return { kind: "ok", oldStart: hit.oldStart, body };
286
+ };
287
+ var validatePatch = (patch, fileLines) => {
288
+ const parsed = parseHunk(patch);
289
+ if (parsed.kind === "drop") return parsed;
290
+ const { oldStart, body } = parsed;
291
+ const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
292
+ const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
293
+ const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
294
+ if (!oldSideMatches) {
295
+ return drop(
296
+ `patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
297
+ );
298
+ }
299
+ if (!isContiguousChange(trimmedMiddle(body))) {
300
+ return drop("change is not a single contiguous block");
301
+ }
302
+ const removedCount = body.filter((l) => l.kind === "removed").length;
303
+ const addedCount = body.filter((l) => l.kind === "added").length;
304
+ if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
305
+ if (removedCount === 0) {
306
+ return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
307
+ }
308
+ const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
309
+ if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
310
+ return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
311
+ };
312
+ var patchToSuggestion = (patch) => {
313
+ const parsed = parseHunk(patch);
314
+ if (parsed.kind === "drop") return parsed;
315
+ const { body } = parsed;
316
+ if (!isContiguousChange(trimmedMiddle(body))) {
317
+ return drop("change is not a single contiguous block");
318
+ }
319
+ const removedCount = body.filter((l) => l.kind === "removed").length;
320
+ const addedLines = body.filter((l) => l.kind === "added");
321
+ if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
322
+ if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
323
+ return addedLines.map((l) => l.text).join("\n");
324
+ };
325
+
326
+ // src/surface.ts
327
+ var severityEmoji = (s) => {
328
+ switch (s) {
329
+ case "critical":
330
+ return "\u{1F534}";
331
+ case "major":
332
+ return "\u{1F7E0}";
333
+ case "minor":
334
+ return "\u{1F535}";
335
+ case "nit":
336
+ return "\u26AA";
337
+ default:
338
+ return "\u2753";
339
+ }
340
+ };
341
+ var EMBED_LIMIT = 40200;
342
+ var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
343
+ var b64LengthOf = (document) => Buffer.from(JSON.stringify(document), "utf-8").toString("base64").length;
344
+ var encodeMarker = (document, jsonUrl, limit) => {
345
+ const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
346
+ const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
347
+ return marker ? `${AGENTS_STOP_DIRECTIVE}
348
+ ${marker}` : "";
349
+ };
350
+ var decodeBase64Json = (b64) => {
351
+ try {
352
+ return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
353
+ } catch {
354
+ return void 0;
355
+ }
356
+ };
357
+ var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
358
+ var findingsMarkerForm = (findings, jsonUrl, limit = EMBED_LIMIT) => {
359
+ if (b64LengthOf(findings) <= limit) return "embedded";
360
+ return jsonUrl ? "link" : "omitted";
361
+ };
362
+ var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
363
+ var ZERO_SHA = "0000000000000000000000000000000000000000";
364
+ var parseReviewedSha = (body) => {
365
+ const sha = /<!-- reviewed-sha: ([0-9a-fA-F]{40}) -->/.exec(body)?.[1]?.toLowerCase();
366
+ return sha && sha !== ZERO_SHA ? sha : null;
367
+ };
368
+ var ROUTE_RE = /<!-- reviewed-route: ([^>]*) -->/;
369
+ var parseReviewedRoute = (body) => ROUTE_RE.exec(body)?.[1] || null;
370
+ var isFullReviewSticky = (body) => {
371
+ const route = parseReviewedRoute(body);
372
+ return route === "full review" || route !== "mechanic" && parseRounds(body).length > 0;
373
+ };
374
+ var COMPLETED_ANCESTOR_MARKER = "<!-- review-complete-ancestor -->";
375
+ var parseCompletedAncestor = (body) => body.includes(COMPLETED_ANCESTOR_MARKER);
376
+ var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
377
+ var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
378
+ var parseFindingsMarker = (body) => {
379
+ const b64 = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body)?.[1];
380
+ if (b64 === void 0) return null;
381
+ return decodeBase64Json(b64) ?? null;
382
+ };
383
+ var ROUNDS_RE = /<!-- code-review:rounds;base64 ([A-Za-z0-9+/=]+) -->/;
384
+ var SEVERITIES = ["critical", "major", "minor", "nit"];
385
+ var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.every((k) => {
386
+ const v = u[k];
387
+ return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
388
+ });
389
+ var MAX_CODES_PER_ROUND = 8;
390
+ var hasCode = (codes, code) => codes !== void 0 && Object.prototype.hasOwnProperty.call(codes, code);
391
+ var escapeCodeBackticks = (code) => code.replace(/`/g, "-").replace(/\r?\n/g, " ");
392
+ var normalizeCodeCounts = (codes, priorCodes) => {
393
+ if (typeof codes !== "object" || codes === null || Array.isArray(codes)) return void 0;
394
+ const entries = Object.entries(codes).filter(
395
+ (e) => typeof e[1] === "number" && Number.isSafeInteger(e[1]) && e[1] > 0
396
+ ).sort((a, b) => {
397
+ if (b[1] !== a[1]) return b[1] - a[1];
398
+ const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
399
+ const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
400
+ if (aPrior !== bPrior) return bPrior - aPrior;
401
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
402
+ });
403
+ if (entries.length === 0) return void 0;
404
+ const sorted = entries.sort((a, b) => {
405
+ if (b[1] !== a[1]) return b[1] - a[1];
406
+ const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
407
+ const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
408
+ if (aPrior !== bPrior) return bPrior - aPrior;
409
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
410
+ });
411
+ const base = sorted.slice(0, MAX_CODES_PER_ROUND);
412
+ const priorKept = sorted.slice(MAX_CODES_PER_ROUND).filter(([code]) => hasCode(priorCodes, code)).slice(0, MAX_CODES_PER_ROUND);
413
+ return Object.fromEntries([...base, ...priorKept]);
414
+ };
415
+ var parseRounds = (body) => {
416
+ const b64 = ROUNDS_RE.exec(body)?.[1];
417
+ if (b64 === void 0) return [];
418
+ const decoded = decodeBase64Json(b64);
419
+ if (!Array.isArray(decoded)) return [];
420
+ return decoded.filter(isSeverityCounts).map((u) => {
421
+ const rec = u;
422
+ const codes = normalizeCodeCounts(rec["codes"]);
423
+ const sha = rec["sha"];
424
+ const shaStr = typeof sha === "string" && sha !== "" ? sha : void 0;
425
+ const round = rec["round"];
426
+ const roundNum = typeof round === "number" && Number.isSafeInteger(round) && round >= 1 ? round : void 0;
427
+ const base = codes === void 0 ? { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit } : { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit, codes };
428
+ const kept = shaStr === void 0 ? base : { ...base, sha: shaStr };
429
+ return roundNum === void 0 ? kept : { ...kept, round: roundNum };
430
+ });
431
+ };
432
+ var ROUNDS_MARKER_LIMIT = 8e3;
433
+ var roundsMarker = (rounds) => {
434
+ if (rounds.length === 0) return "";
435
+ const serialize = (kept2) => `<!-- code-review:rounds;base64 ${Buffer.from(JSON.stringify(kept2), "utf-8").toString("base64")} -->`;
436
+ const stripCodes = (n) => rounds.map(
437
+ (r, i) => i < n ? { critical: r.critical, major: r.major, minor: r.minor, nit: r.nit } : r
438
+ );
439
+ let stripped = 0;
440
+ while (stripped < rounds.length && serialize(stripCodes(stripped)).length > ROUNDS_MARKER_LIMIT) {
441
+ stripped += 1;
442
+ }
443
+ const kept = stripCodes(stripped);
444
+ const bounded = serialize(kept).length > ROUNDS_MARKER_LIMIT ? kept.slice(-8) : kept;
445
+ return serialize(bounded);
446
+ };
447
+ var roundChip = (c) => {
448
+ const parts = SEVERITIES.filter((k) => c[k] > 0).map((k) => `${severityEmoji(k)}${String(c[k])}`);
449
+ return parts.length === 0 ? "clean" : parts.join(" ");
450
+ };
451
+ var TRAJECTORY_CHIPS = 8;
452
+ var roundsSummary = (rounds, count = rounds.length) => {
453
+ if (count === 0) return "";
454
+ const chips = rounds.slice(-TRAJECTORY_CHIPS).map(roundChip);
455
+ const trajectory = chips.length === 0 ? "" : rounds.length > TRAJECTORY_CHIPS ? `\u2026 \u2192 ${chips.join(" \u2192 ")}` : chips.join(" \u2192 ");
456
+ return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
457
+ };
458
+ var computeCodeCounts = (findings, systemic = []) => {
459
+ const counts = /* @__PURE__ */ new Map();
460
+ for (const code of [
461
+ ...findings.map((f) => f.code),
462
+ ...systemic.flatMap((s) => [s.code, ...s.finding_codes ?? []])
463
+ ]) {
464
+ if (code === void 0 || code === "") continue;
465
+ counts.set(code, (counts.get(code) ?? 0) + 1);
466
+ }
467
+ return Object.fromEntries(counts);
468
+ };
469
+ var roundRecord = (counts, codes, priorCodes, sha, round) => {
470
+ const normalized = normalizeCodeCounts(codes, priorCodes);
471
+ const record3 = normalized === void 0 ? { ...counts } : { ...counts, codes: normalized };
472
+ return {
473
+ ...record3,
474
+ ...sha !== void 0 ? { sha } : {},
475
+ ...round !== void 0 ? { round } : {}
476
+ };
477
+ };
478
+ var consecutiveCodeStreaks = (rounds) => {
479
+ const entries = [];
480
+ if (rounds.length === 0) return {};
481
+ const lastCodes = rounds[rounds.length - 1]?.codes;
482
+ if (lastCodes === void 0) return {};
483
+ for (const code of Object.keys(lastCodes)) {
484
+ let streak = 0;
485
+ let startIndex = rounds.length;
486
+ for (let i = rounds.length - 1; i >= 0; i--) {
487
+ const codes = rounds[i]?.codes;
488
+ if (codes === void 0 || !hasCode(codes, code)) break;
489
+ if (i > 0 && rounds[i]?.sha !== void 0 && rounds[i]?.sha === rounds[i - 1]?.sha && hasCode(rounds[i - 1]?.codes, code)) {
490
+ continue;
491
+ }
492
+ streak += 1;
493
+ startIndex = i;
494
+ }
495
+ if (streak > 0) {
496
+ entries.push([code, { streak, startRound: rounds[startIndex]?.round ?? startIndex + 1 }]);
497
+ }
498
+ }
499
+ return Object.fromEntries(entries);
500
+ };
501
+ var DEFAULT_METASTASIS_STREAK = 3;
502
+ var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
503
+ const flagged = Object.entries(consecutiveCodeStreaks(rounds)).filter(([, s]) => s.streak >= minStreak).sort((a, b) => b[1].streak - a[1].streak);
504
+ if (flagged.length === 0) return "";
505
+ const lines = flagged.map(
506
+ ([code, s]) => `> **\`${escapeCodeBackticks(code)}\`** \u2014 findings in ${String(s.streak)} consecutive rounds.`
507
+ );
508
+ return [
509
+ "> [!WARNING]",
510
+ "> **Scope metastasis** \u2014 findings keep recurring in the same mechanism across consecutive rounds; each fix keeps enabling the next finding in that machinery. Consider whether a structural fix (change the shape, not the edge case) or a scope narrowing would converge this faster.",
511
+ ...lines
512
+ ].join("\n");
513
+ };
514
+ var computeSameRootNotes = (priorRounds, findings, currentSha) => {
515
+ const codes = findings.map((f) => f.code).filter((c) => c !== void 0 && c !== "");
516
+ const entries = [];
517
+ for (const code of codes) {
518
+ let lastRound = 0;
519
+ for (let i = priorRounds.length - 1; i >= 0; i--) {
520
+ if (currentSha !== void 0 && priorRounds[i]?.sha === currentSha) continue;
521
+ if (i > 0 && priorRounds[i]?.sha !== void 0 && priorRounds[i]?.sha === priorRounds[i - 1]?.sha && hasCode(priorRounds[i - 1]?.codes, code))
522
+ continue;
523
+ const count = priorRounds[i]?.codes?.[code];
524
+ if (count !== void 0 && count > 0) {
525
+ lastRound = priorRounds[i]?.round ?? i + 1;
526
+ break;
527
+ }
528
+ }
529
+ if (lastRound > 0) {
530
+ entries.push([
531
+ code,
532
+ `Same mechanism as round ${String(lastRound)} (\`${escapeCodeBackticks(code)}\`) \u2014 the prior fix in this area re-opened it; consider a structural fix or a scope narrowing.`
533
+ ]);
534
+ }
535
+ }
536
+ return Object.fromEntries(entries);
537
+ };
538
+ var CONVERGENCE_WEIGHTS = { critical: 4, major: 2, minor: 1, nit: 0 };
539
+ var DEFAULT_CONVERGENCE_THRESHOLD = 1;
540
+ var convergenceScore = (counts) => SEVERITIES.reduce((sum, k) => sum + counts[k] * CONVERGENCE_WEIGHTS[k], 0);
541
+ var convergenceSummary = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
542
+ const { score, converged } = convergenceSignal(counts, threshold);
543
+ return converged ? `**Convergence** \u{1F3C1} ${String(score)} \u2264 ${String(threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${String(score)} > ${String(threshold)} \u2014 iterating`;
544
+ };
545
+ var SURFACE_SCHEMA_VERSION = "0.7.0";
546
+ var convergenceSignal = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
547
+ const score = convergenceScore(counts);
548
+ return { score, threshold, converged: score <= threshold };
549
+ };
550
+ var signalForRound = (round, counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => ({ round, convergence: convergenceSignal(counts, threshold) });
551
+ var surfaceFindings = (findings, signal) => {
552
+ const agentDoc = Object.fromEntries(
553
+ Object.entries(findings).filter(([key2]) => key2 !== "round" && key2 !== "convergence")
554
+ );
555
+ return {
556
+ ...agentDoc,
557
+ schema_version: SURFACE_SCHEMA_VERSION,
558
+ ...signal === null ? {} : signal
559
+ };
560
+ };
561
+ var signalMarker = (signal) => `<!-- code-review:signal;base64 ${Buffer.from(
562
+ JSON.stringify({ schema_version: SURFACE_SCHEMA_VERSION, ...signal }),
563
+ "utf-8"
564
+ ).toString("base64")} -->`;
565
+ var surfacedFindingsPointer = (findings, signal, jsonUrl) => {
566
+ const marker = findingsPointer(surfaceFindings(findings, signal), jsonUrl);
567
+ if (signal === null || marker.includes("<!-- code-review:findings-json;base64 ")) return marker;
568
+ return marker === "" ? signalMarker(signal) : `${marker}
569
+ ${signalMarker(signal)}`;
570
+ };
571
+ var SIGNAL_RE = /<!-- code-review:signal;base64 ([A-Za-z0-9+/=]+) -->/;
572
+ var parseSignalMarker = (body) => {
573
+ const b64 = SIGNAL_RE.exec(body)?.[1];
574
+ if (b64 === void 0) return null;
575
+ return parseSurfaceSignal(decodeBase64Json(b64));
576
+ };
577
+ var parseSurfaceSignal = (doc) => {
578
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return null;
579
+ const o = doc;
580
+ const declared = o["schema_version"];
581
+ if (typeof declared !== "string" || !SURFACE_SCHEMA_VERSIONS.includes(declared)) return null;
582
+ const round = o["round"];
583
+ const convergence = o["convergence"];
584
+ if (typeof round !== "number" || !Number.isSafeInteger(round) || round < 1) return null;
585
+ if (typeof convergence !== "object" || convergence === null) return null;
586
+ const c = convergence;
587
+ if (typeof c["score"] !== "number" || !Number.isFinite(c["score"]) || typeof c["threshold"] !== "number" || !Number.isFinite(c["threshold"]) || typeof c["converged"] !== "boolean") {
588
+ return null;
589
+ }
590
+ return {
591
+ round,
592
+ convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
593
+ };
594
+ };
595
+ var SURFACE_SCHEMA_VERSIONS = [SURFACE_SCHEMA_VERSION];
596
+ var stripSurfaceFields = (doc) => {
597
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return doc;
598
+ const o = doc;
599
+ const declared = o["schema_version"];
600
+ if (typeof declared !== "string" || !SURFACE_SCHEMA_VERSIONS.includes(declared)) return doc;
601
+ const rest = Object.fromEntries(
602
+ Object.entries(o).filter(([key2]) => key2 !== "convergence" && key2 !== "round")
603
+ );
604
+ return { ...rest, schema_version: DEFAULT_SCHEMA_VERSION };
605
+ };
606
+ var carryForwardMarkers = (body) => {
607
+ const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
608
+ const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
609
+ const reviewedRoute = ROUTE_RE.exec(body)?.[0];
610
+ const rounds = ROUNDS_RE.exec(body)?.[0];
611
+ const signal = SIGNAL_RE.exec(body)?.[0];
612
+ const completedAncestor = parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
613
+ const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
614
+ ${findings}` : void 0;
615
+ return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, rounds, signal].filter((m) => m !== void 0).join("\n\n");
616
+ };
617
+ var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
618
+ var projectPatch = (patch) => {
619
+ if (patch === void 0) return { kind: "none" };
620
+ const lowered = patchToSuggestion(patch);
621
+ return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
622
+ };
623
+ var formatConfidence = (n) => n.toFixed(2);
624
+ var reviewBodyPointer = (headSha, stickyUrl, marker) => {
625
+ const sha7 = headSha.slice(0, 7);
626
+ 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.`;
627
+ return marker ? `${marker}
628
+
629
+ ${linkLine}` : linkLine;
630
+ };
631
+
56
632
  // src/render.ts
57
- var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
58
633
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
59
- var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
60
634
  var sanitizeFinding = (f) => ({
61
635
  ...f,
62
636
  title: escapePipes(f.title),
63
637
  path: escapeCodeBackticks(f.path),
64
- suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
638
+ patchProjection: projectPatch(f.patch)
639
+ });
640
+ var sanitizeSystemic = (s) => ({
641
+ ...s,
642
+ title: escapePipes(s.title),
643
+ ...s.paths !== void 0 ? { paths: s.paths.map(escapeCodeBackticks) } : {},
644
+ ...s.finding_codes !== void 0 ? { finding_codes: s.finding_codes.map(escapeCodeBackticks) } : {}
645
+ });
646
+ var emptySeverityCounts = () => ({
647
+ critical: 0,
648
+ major: 0,
649
+ minor: 0,
650
+ nit: 0
65
651
  });
652
+ var computeSeverityCounts = (findings) => findings.reduce(
653
+ (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
654
+ emptySeverityCounts()
655
+ );
656
+ var isReviewVerdict = (verdict) => verdict !== "error";
657
+ var computeRoundCounts = (findings) => (findings.systemic_problems ?? []).reduce(
658
+ (acc, s) => s.severity in acc ? { ...acc, [s.severity]: acc[s.severity] + 1 } : acc,
659
+ computeSeverityCounts(findings.findings)
660
+ );
661
+ var isConvergenceRound = (route, incomplete) => route === "full review" && !incomplete;
66
662
  var render = (input) => {
67
663
  const eta = new Eta({ autoTrim: false });
68
664
  const usageAvailable = input.envelope !== null;
665
+ const hasUsage = input.envelope !== null && input.envelope.models.length > 0;
666
+ const incomplete = (input.incomplete ?? input.envelope?.incomplete ?? false) || isIncompleteFindings(input.findings);
69
667
  const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
668
+ const pricesProvided = input.pricesProvided ?? true;
70
669
  const route = input.route ?? input.envelope?.route ?? null;
71
670
  const effort = input.effort ?? input.envelope?.effort ?? null;
72
671
  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))];
672
+ const severityCounts = input.severityCounts ?? computeSeverityCounts(input.findings.findings);
673
+ const rounds = input.rounds ?? [];
674
+ const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(rounds.slice(0, -1), input.findings.findings);
675
+ const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && rounds.length > 0)) && isReviewVerdict(input.findings.verdict);
676
+ const convergenceCounts = rounds[rounds.length - 1] ?? computeRoundCounts(input.findings);
677
+ const advisoryAllowed = isFullReviewRound;
76
678
  return eta.renderString(input.template, {
77
- findings: safeFindings,
679
+ findings: input.findings,
78
680
  envelope: input.envelope,
79
681
  usageAvailable,
682
+ hasUsage,
683
+ incomplete,
80
684
  costReport,
685
+ pricesProvided,
81
686
  route,
82
687
  effort,
83
688
  modelNames,
84
689
  testReport: input.testReport ?? null,
85
690
  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,
691
+ postedAt: input.postedAt ?? "",
692
+ severityCounts,
693
+ convergenceSummary: isFullReviewRound ? convergenceSummary(convergenceCounts, input.convergenceThreshold) : "",
694
+ strays: (input.strays ?? []).map(sanitizeFinding),
695
+ systemic: (input.findings.systemic_problems ?? []).map(sanitizeSystemic),
696
+ unanchoredCount: input.unanchoredCount ?? 0,
697
+ inlineDisposition: input.inlineDisposition ?? null,
698
+ runUrl: input.runUrl ?? null,
699
+ jsonUrl: input.jsonUrl ?? null,
700
+ findingsPointer: input.findingsPointer ?? surfacedFindingsPointer(
701
+ input.findings,
702
+ // The fallback embeds a signal exactly when the badge renders — never beside a suppressed
703
+ // badge, and from the same counts the badge reads. It assumes a post-style history (the
704
+ // caller appends this run's counts last), numbering the round exactly as the trajectory
705
+ // label does; post always supplies the marker, so this path cannot disagree with it in
706
+ // production (issue #141 review r4).
707
+ isFullReviewRound && rounds.length > 0 ? signalForRound(rounds.length, convergenceCounts, input.convergenceThreshold) : null,
708
+ input.jsonUrl
709
+ ),
710
+ roundsMarker: roundsMarker(rounds),
711
+ roundsSummary: roundsSummary(rounds, input.roundCount),
712
+ metastasisNote: advisoryAllowed ? metastasisNote(rounds) : "",
713
+ sameRootNotes: advisoryAllowed ? sameRootNotes : {},
714
+ reviewUrl: input.reviewUrl ?? null,
92
715
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
93
- formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(3)}` : "\u2014",
716
+ // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
717
+ formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
94
718
  formatDuration: (ms) => {
95
719
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
96
720
  const s = Math.round(ms / 1e3);
@@ -104,24 +728,14 @@ var render = (input) => {
104
728
  return "\u{1F4AC} comment";
105
729
  case "changes":
106
730
  return "\u{1F527} changes requested";
731
+ case "error":
732
+ return "\u{1F6E0}\uFE0F no review verdict";
107
733
  default:
108
734
  return `\u2753 ${v}`;
109
735
  }
110
736
  },
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
- }
737
+ severityEmoji,
738
+ formatConfidence
125
739
  });
126
740
  };
127
741
  var key = (path, line) => `${path}:${String(line)}`;
@@ -178,33 +792,35 @@ var partitionFindings = (findings, index) => {
178
792
  };
179
793
 
180
794
  // 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, {
795
+ var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
796
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRootNote) => (
797
+ // Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
798
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
799
+ eta.renderString(template, {
194
800
  ...f,
195
- suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
196
- });
197
- };
198
- var buildInlineComments = (findings, diff, inlineTemplate) => {
801
+ patchProjection: projectPatch(f.patch),
802
+ severityEmoji,
803
+ formatConfidence,
804
+ modelsText,
805
+ jsonUrl: jsonUrl ?? null,
806
+ findingsPointer: pointer,
807
+ sameRootNote
808
+ })
809
+ );
810
+ var buildInlineComments = (findings, diff, context) => {
811
+ const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
199
812
  const index = indexDiff(diff);
200
813
  const { inDiff, strays } = partitionFindings(findings, index);
201
- const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
814
+ const eta = new Eta({ autoTrim: false });
815
+ const modelsText = formatModels(models);
202
816
  const comments = inDiff.map((f) => {
817
+ const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
818
+ const sameRootNote = f.code !== void 0 && f.code !== "" && context.sameRootNotes !== void 0 && Object.prototype.hasOwnProperty.call(context.sameRootNotes, f.code) ? context.sameRootNotes[f.code] ?? "" : "";
203
819
  const comment = {
204
820
  path: f.path,
205
821
  line: f.end_line,
206
822
  side: defaultSide(f.side),
207
- body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
823
+ body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer, sameRootNote)
208
824
  };
209
825
  if (f.start_line < f.end_line) {
210
826
  return {
@@ -215,7 +831,7 @@ var buildInlineComments = (findings, diff, inlineTemplate) => {
215
831
  }
216
832
  return comment;
217
833
  });
218
- return { comments, strays };
834
+ return { comments, strays, inDiff };
219
835
  };
220
836
  var renderStraysSection = (strays) => {
221
837
  if (strays.length === 0) return "";
@@ -231,119 +847,129 @@ var renderStraysSection = (strays) => {
231
847
  ...items
232
848
  ].join("\n");
233
849
  };
234
- var SeverityCodec = t.union([
235
- t.literal("critical"),
236
- t.literal("major"),
237
- t.literal("minor"),
238
- t.literal("nit")
239
- ]);
240
- var SideCodec = t.union([t.literal("RIGHT"), t.literal("LEFT")]);
241
- var VerdictCodec = t.union([t.literal("approve"), t.literal("comment"), t.literal("changes")]);
242
- var LineNumber = t.refinement(
243
- t.number,
244
- (n) => Number.isInteger(n) && n >= 1,
245
- "LineNumber"
246
- );
247
- var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
248
- var SCHEMA_VERSION_RE = /^(0|[1-9]\d*)\.(\d+)\.(\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
249
- var SchemaVersion = t.refinement(
250
- t.string,
251
- (s) => SCHEMA_VERSION_RE.test(s),
252
- "SchemaVersion"
253
- );
254
- var FindingShape = t.intersection([
255
- t.type({
256
- path: t.string,
257
- start_line: LineNumber,
258
- end_line: LineNumber,
259
- severity: SeverityCodec,
260
- title: t.string,
261
- body: t.string
262
- }),
263
- t.partial({
264
- side: SideCodec,
265
- suggestion: t.union([t.string, t.null]),
266
- confidence: Confidence,
267
- code: t.string,
268
- code_url: t.string
269
- })
270
- ]);
271
- var EndGeStart = t.refinement(
272
- FindingShape,
273
- (f) => f.end_line >= f.start_line,
274
- "EndGeStart"
275
- );
276
- var FindingCodec = t.exact(EndGeStart);
277
- var FindingsCodec = t.exact(
278
- t.type({
279
- schema_version: SchemaVersion,
280
- summary: t.string,
281
- verdict: VerdictCodec,
282
- findings: t.array(FindingCodec)
283
- })
284
- );
285
- var TriageCodec = t.type({
286
- safe: t.boolean,
287
- reasons: t.string
288
- });
289
- var TokenCount = t.refinement(
290
- t.number,
291
- (n) => Number.isInteger(n) && n >= 0,
292
- "TokenCount"
293
- );
294
- var ModelUsageEntryCodec = t.intersection([
295
- t.type({
296
- model: t.string,
297
- input_tokens: TokenCount,
298
- output_tokens: TokenCount
299
- }),
300
- t.partial({
301
- cache_read_tokens: TokenCount,
302
- cache_write_tokens: TokenCount
303
- })
304
- ]);
305
- var ResultEnvelopeCodec = t.intersection([
306
- t.type({
307
- schema_version: t.string,
308
- findings: FindingsCodec,
309
- models: t.array(ModelUsageEntryCodec),
310
- turns: TokenCount,
311
- duration_ms: TokenCount
312
- }),
313
- t.partial({
314
- vendor_cost_usd: t.union([t.number, t.null]),
315
- route: t.string,
316
- effort: t.string
317
- })
318
- ]);
319
- var ModelPricesCodec = t.type({
320
- in: t.number,
321
- out: t.number,
322
- cache_read: t.number,
323
- cache_write: t.number
324
- });
325
- var PriceMapCodec = t.type({
326
- _updated: t.string,
327
- _unit: t.string,
328
- models: t.record(t.string, ModelPricesCodec)
329
- });
330
- var TestFailureCodec = t.intersection([
331
- t.type({ name: t.string }),
332
- t.partial({ message: t.string })
333
- ]);
334
- var TestSummaryCodec = t.intersection([
335
- t.type({
336
- passed: t.number,
337
- failed: t.number,
338
- total: t.number
339
- }),
340
- t.partial({
341
- failures: t.array(TestFailureCodec)
342
- })
343
- ]);
344
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
850
+ var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
851
+ var errMsg = (e) => e instanceof Error ? e.message : String(e);
852
+ var annotationSafe = (msg) => msg.replaceAll(/[\r\n]+/g, " ");
853
+ var tryParseJson = (text) => {
854
+ try {
855
+ return { ok: true, value: JSON.parse(text) };
856
+ } catch {
857
+ return { ok: false };
858
+ }
859
+ };
860
+ var readFileOrNull = (path) => {
861
+ try {
862
+ return readFileSync(path, "utf-8");
863
+ } catch {
864
+ return null;
865
+ }
866
+ };
345
867
 
346
- // src/validate.ts
868
+ // src/transcript.ts
869
+ var numField = (rec, key2) => {
870
+ const v = rec[key2];
871
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
872
+ };
873
+ var messageUsage = (entry) => {
874
+ const rec = asRecord(entry);
875
+ if (rec === null || rec["type"] !== "assistant") return null;
876
+ const msg = asRecord(rec["message"]);
877
+ if (msg === null) return null;
878
+ const model = msg["model"];
879
+ const usage = asRecord(msg["usage"]);
880
+ if (typeof model !== "string" || usage === null) return null;
881
+ const id = msg["id"];
882
+ return {
883
+ id: typeof id === "string" ? id : null,
884
+ model,
885
+ input: numField(usage, "input_tokens"),
886
+ output: numField(usage, "output_tokens"),
887
+ cacheRead: numField(usage, "cache_read_input_tokens"),
888
+ cacheWrite: numField(usage, "cache_creation_input_tokens")
889
+ };
890
+ };
891
+ var tsMsOf = (entry) => {
892
+ const rec = asRecord(entry);
893
+ const ts = rec?.["timestamp"];
894
+ if (typeof ts !== "string") return null;
895
+ const ms = Date.parse(ts);
896
+ return Number.isNaN(ms) ? null : ms;
897
+ };
898
+ var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
899
+ var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
900
+ try {
901
+ return [JSON.parse(line)];
902
+ } catch {
903
+ return [];
904
+ }
905
+ });
906
+ var sumTranscriptUsage = (entries) => {
907
+ const summed = entries.reduce(
908
+ (acc, entry) => {
909
+ const u = messageUsage(entry);
910
+ if (u === null) return acc;
911
+ if (u.id !== null && acc.seen.has(u.id)) return acc;
912
+ if (u.id !== null) acc.seen.add(u.id);
913
+ const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
914
+ acc.totals.set(u.model, {
915
+ input: prev.input + u.input,
916
+ output: prev.output + u.output,
917
+ cacheRead: prev.cacheRead + u.cacheRead,
918
+ cacheWrite: prev.cacheWrite + u.cacheWrite
919
+ });
920
+ return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
921
+ },
922
+ { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
923
+ );
924
+ const models = [...summed.totals].map(([model, t7]) => ({
925
+ model,
926
+ input_tokens: t7.input,
927
+ output_tokens: t7.output,
928
+ cache_read_tokens: t7.cacheRead,
929
+ cache_write_tokens: t7.cacheWrite
930
+ }));
931
+ const bounds = entries.reduce(
932
+ (acc, entry) => {
933
+ const ms = tsMsOf(entry);
934
+ if (ms === null) return acc;
935
+ return {
936
+ min: acc.min === null || ms < acc.min ? ms : acc.min,
937
+ max: acc.max === null || ms > acc.max ? ms : acc.max
938
+ };
939
+ },
940
+ { min: null, max: null }
941
+ );
942
+ return {
943
+ models,
944
+ turns: summed.turns,
945
+ durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
946
+ firstTsMs: bounds.min,
947
+ lastTsMs: bounds.max
948
+ };
949
+ };
950
+ var subagentFiles = (mainPath) => {
951
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
952
+ try {
953
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
954
+ } catch {
955
+ return [];
956
+ }
957
+ };
958
+ var readTranscriptTree = (mainPath) => {
959
+ const mainText = readFileOrNull(mainPath);
960
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
961
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
962
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
963
+ const siblingReads = siblings.flatMap((path) => {
964
+ const text = readFileOrNull(path);
965
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
966
+ });
967
+ return {
968
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
969
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
970
+ missing: mainText === null
971
+ };
972
+ };
347
973
  var addFormats = _addFormats;
348
974
  var validatorCache = /* @__PURE__ */ new Map();
349
975
  var compileSchema = (schemaPath) => {
@@ -377,10 +1003,393 @@ var unsafeUnwrap = (decoded) => {
377
1003
  if (decoded._tag === "Right") return decoded.right;
378
1004
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
379
1005
  };
1006
+
1007
+ // src/stop-gate.ts
1008
+ var whatsWrong = (state, draftPath, kind) => {
1009
+ switch (state.kind) {
1010
+ case "missing":
1011
+ return `${draftPath} does not exist yet`;
1012
+ case "unreadable":
1013
+ return `${draftPath} could not be read: ${state.error}`;
1014
+ case "invalid":
1015
+ return `${draftPath} does not validate against the ${kind} schema:
1016
+ ${state.errors.map((e) => ` - ${e}`).join("\n")}`;
1017
+ }
1018
+ };
1019
+ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
1020
+ if (state.kind === "valid") return { kind: "allow" };
1021
+ if (nudges >= maxNudges) return { kind: "allow" };
1022
+ return {
1023
+ kind: "block",
1024
+ reason: [
1025
+ `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
1026
+ `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.`,
1027
+ `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).`
1028
+ ].join("\n")
1029
+ };
1030
+ };
1031
+ var draftState = (draftPath, resolveSchema) => {
1032
+ let raw;
1033
+ try {
1034
+ raw = readFileSync(draftPath, "utf-8");
1035
+ } catch (err) {
1036
+ if (err instanceof Error && err.code === "ENOENT") {
1037
+ return { kind: "missing" };
1038
+ }
1039
+ return { kind: "unreadable", error: errMsg(err) };
1040
+ }
1041
+ let parsed;
1042
+ try {
1043
+ parsed = JSON.parse(raw);
1044
+ } catch (err) {
1045
+ return {
1046
+ kind: "invalid",
1047
+ errors: [`not valid JSON: ${errMsg(err)}`]
1048
+ };
1049
+ }
1050
+ let schemaPath;
1051
+ try {
1052
+ schemaPath = resolveSchema(parsed);
1053
+ } catch (err) {
1054
+ return { kind: "invalid", errors: [errMsg(err)] };
1055
+ }
1056
+ try {
1057
+ const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
1058
+ return valid ? { kind: "valid" } : { kind: "invalid", errors };
1059
+ } catch (err) {
1060
+ return { kind: "invalid", errors: [errMsg(err)] };
1061
+ }
1062
+ };
1063
+ var readNudges = (counterPath) => {
1064
+ try {
1065
+ const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
1066
+ return Number.isInteger(n) && n >= 0 ? n : 0;
1067
+ } catch {
1068
+ return 0;
1069
+ }
1070
+ };
1071
+ var bumpNudges = (counterPath, current) => {
1072
+ writeFileSync(counterPath, `${String(current + 1)}
1073
+ `);
1074
+ };
1075
+ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
1076
+ var defaultHookCommand = (draftPath, opts) => [
1077
+ "code-review stop-gate --draft",
1078
+ shellQuote(draftPath),
1079
+ ...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
1080
+ ...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
1081
+ ...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
1082
+ ...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
1083
+ ...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
1084
+ ].join(" ");
1085
+ var stopHookSettings = (command) => ({
1086
+ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
1087
+ });
1088
+
1089
+ // src/budget.ts
1090
+ var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
1091
+ var DEFAULT_RESERVE = {
1092
+ frac: 0.15,
1093
+ growth: 0.25,
1094
+ flatUsd: 0.02,
1095
+ flatMs: 12e4
1096
+ };
1097
+ var SOFT_MULTIPLE = 2;
1098
+ var growingReserve = (used, limit, flat, r) => {
1099
+ const usedFrac = Math.min(1, Math.max(0, used / limit));
1100
+ return Math.max(flat, (r.frac + r.growth * usedFrac) * limit);
1101
+ };
1102
+ var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? {
1103
+ used: i.spentUsd,
1104
+ limit: i.budgetUsd,
1105
+ hardReserve: growingReserve(i.spentUsd, i.budgetUsd, i.reserve.flatUsd, i.reserve)
1106
+ } : null;
1107
+ var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? {
1108
+ used: i.elapsedMs,
1109
+ limit: i.wallMs,
1110
+ hardReserve: growingReserve(i.elapsedMs, i.wallMs, i.reserve.flatMs, i.reserve)
1111
+ } : null;
1112
+ var axisSeverity = (a) => {
1113
+ const remaining = a.limit - a.used;
1114
+ if (remaining <= a.hardReserve) return 2;
1115
+ if (remaining <= SOFT_MULTIPLE * a.hardReserve) return 1;
1116
+ return 0;
1117
+ };
1118
+ var decideBudget = (i) => {
1119
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a)), 0);
1120
+ return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
1121
+ };
1122
+ var pct = (n) => `${String(Math.round(n * 100))}%`;
1123
+ var money = (n) => `$${n.toFixed(2)}`;
1124
+ var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
1125
+ 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)}`;
1126
+ 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`;
1127
+ var directive = (phase, draftPath, isSubagent) => {
1128
+ if (isSubagent)
1129
+ 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.`;
1130
+ 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.`;
1131
+ };
1132
+ var budgetMessage = (i, phase, draftPath, isSubagent) => {
1133
+ const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
1134
+ return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
1135
+ };
1136
+ var invokesCodeReviewValidate = (toolInput) => {
1137
+ const cmd = asRecord(toolInput)?.["command"];
1138
+ return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
1139
+ };
1140
+ var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
1141
+ var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
1142
+ var blockedDuringConvergence = (toolName, toolInput) => {
1143
+ if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
1144
+ if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
1145
+ return false;
1146
+ };
1147
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1148
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
1149
+ var writesToDraft = (toolName, toolInput, draftPath) => {
1150
+ const rec = asRecord(toolInput);
1151
+ const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
1152
+ if (WRITE_TOOLS.has(toolName)) {
1153
+ const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
1154
+ if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
1155
+ return true;
1156
+ }
1157
+ if (toolName === "Bash") {
1158
+ const cmd = rec?.["command"];
1159
+ if (typeof cmd !== "string") return false;
1160
+ const alt = targets.map(escapeRegExp).join("|");
1161
+ const end = "(?=$|[\\s|&;)])";
1162
+ const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
1163
+ const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
1164
+ return redirect.test(cmd) || teeArg.test(cmd);
1165
+ }
1166
+ return false;
1167
+ };
1168
+ 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.`;
1169
+ var SEED_SENTINEL = "code-review seed sentinel \u2014 not a review; replace this file with your findings review.";
1170
+ var normalizedDraft = (draftText) => typeof draftText === "string" ? draftText.replace(/^\uFEFF/, "").trim() : "";
1171
+ var isSeedSentinel = (draftText) => normalizedDraft(draftText) === SEED_SENTINEL;
1172
+ var mainHasWrittenDraft = (draftText) => {
1173
+ const normalized = normalizedDraft(draftText);
1174
+ return normalized !== "" && normalized !== SEED_SENTINEL;
1175
+ };
1176
+ var sidecarPath = (draftPath, postfix) => {
1177
+ const ext = extname(draftPath);
1178
+ return join(dirname(draftPath), `${basename(draftPath, ext)}${postfix}${ext}`);
1179
+ };
1180
+ var priorContextPath = (draftPath) => sidecarPath(draftPath, ".prior");
1181
+ var lastValidPath = (draftPath) => sidecarPath(draftPath, ".last-valid");
1182
+ var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and the pre-seeded $DRAFT is a non-review sentinel: it does not count until you have replaced it with your own review. 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.`;
1183
+ var forceBackgroundSpawn = (toolInput) => ({
1184
+ hookSpecificOutput: {
1185
+ hookEventName: "PreToolUse",
1186
+ permissionDecision: "allow",
1187
+ updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
1188
+ }
1189
+ });
1190
+ var denyPreTool = (reason) => ({
1191
+ hookSpecificOutput: {
1192
+ hookEventName: "PreToolUse",
1193
+ permissionDecision: "deny",
1194
+ permissionDecisionReason: reason
1195
+ }
1196
+ });
1197
+ var isSubagentHookInput = (input) => {
1198
+ const agentId = asRecord(input)?.["agent_id"];
1199
+ return typeof agentId === "string" && agentId.length > 0;
1200
+ };
1201
+ var evaluateBudgetHook = (input, params) => {
1202
+ const rec = asRecord(input);
1203
+ const inputs = {
1204
+ spentUsd: params.spentUsd,
1205
+ budgetUsd: params.budgetUsd,
1206
+ elapsedMs: params.elapsedMs,
1207
+ wallMs: params.wallMs,
1208
+ reserve: params.reserve
1209
+ };
1210
+ const phase = decideBudget(inputs);
1211
+ const isSubagent = isSubagentHookInput(input);
1212
+ switch (rec?.["hook_event_name"]) {
1213
+ case "PostToolBatch":
1214
+ return phase.kind === "ok" ? {} : {
1215
+ hookSpecificOutput: {
1216
+ hookEventName: "PostToolBatch",
1217
+ additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
1218
+ }
1219
+ };
1220
+ case "PreToolUse": {
1221
+ const toolName = rec["tool_name"];
1222
+ if (typeof toolName !== "string") return {};
1223
+ if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
1224
+ return denyPreTool(singleWriterMessage(params.draftPath));
1225
+ if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
1226
+ return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
1227
+ if (SPAWN_TOOLS.has(toolName)) {
1228
+ if (!isSubagent && !params.mainDraftWritten())
1229
+ return denyPreTool(spawnFloorMessage(params.draftPath));
1230
+ return forceBackgroundSpawn(rec["tool_input"]);
1231
+ }
1232
+ return {};
1233
+ }
1234
+ default:
1235
+ return {};
1236
+ }
1237
+ };
1238
+ var parseWallMs = (raw) => {
1239
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
1240
+ if (m === null) return null;
1241
+ const [, num = "", unit = "s"] = m;
1242
+ const n = Number.parseFloat(num);
1243
+ if (!Number.isFinite(n)) return null;
1244
+ switch (unit) {
1245
+ case "ms":
1246
+ return n;
1247
+ case "s":
1248
+ return n * 1e3;
1249
+ case "m":
1250
+ return n * 6e4;
1251
+ default:
1252
+ return n * 36e5;
1253
+ }
1254
+ };
1255
+ var parseEpochSecMs = (raw) => {
1256
+ if (raw === void 0) return null;
1257
+ const t7 = raw.trim();
1258
+ if (!/^\d+$/.test(t7)) return null;
1259
+ const n = Number.parseInt(t7, 10);
1260
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
1261
+ };
1262
+ var anchoredElapsedMs = (src) => {
1263
+ if (src.deadlineMs !== null && src.wallMs !== null)
1264
+ return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
1265
+ if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
1266
+ return null;
1267
+ };
1268
+ var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
1269
+ var parseFraction = (raw, fallback) => {
1270
+ if (raw === void 0) return fallback;
1271
+ const n = Number.parseFloat(raw);
1272
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
1273
+ };
1274
+ var budgetHookCommand = (draftPath, opts) => [
1275
+ "code-review budget-hook --draft",
1276
+ shellQuote(draftPath),
1277
+ ...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
1278
+ ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
1279
+ ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
1280
+ ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
1281
+ ...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
1282
+ ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
1283
+ ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
1284
+ ].join(" ");
1285
+
1286
+ // src/format.ts
1287
+ var FENCE_RE = /^\s*```/;
1288
+ var scanLine = (state, line) => {
1289
+ if (FENCE_RE.test(line)) {
1290
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
1291
+ }
1292
+ if (state.inFence) {
1293
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
1294
+ }
1295
+ const trimmed = line.replace(/[ \t]+$/, "");
1296
+ if (trimmed !== "") {
1297
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
1298
+ }
1299
+ const blankRun = state.blankRun + 1;
1300
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
1301
+ };
1302
+ var formatMarkdown = (md) => {
1303
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
1304
+ return `${lines.join("\n").replace(/\n+$/, "")}
1305
+ `;
1306
+ };
1307
+ var pad2 = (n) => String(n).padStart(2, "0");
1308
+ var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
1309
+
1310
+ // src/notice.ts
1311
+ var NOTICE_KINDS = [
1312
+ "security-blocked",
1313
+ "triage-error",
1314
+ "setup-failed",
1315
+ "checkout-failed",
1316
+ "no-output"
1317
+ ];
1318
+ var isNoticeKind = (s) => NOTICE_KINDS.some((k) => k === s);
1319
+ var UNSAFE_IN_SUMMARY = /[\n\r`<>|]/;
1320
+ var MAX_NAMED_HOSTS = 20;
1321
+ var parseAgentAllowlist = (sandboxConfig) => {
1322
+ const domains = sandboxConfig?.network?.allowedDomains;
1323
+ return Array.isArray(domains) ? domains.filter(
1324
+ (d) => typeof d === "string" && d.length <= 256 && !UNSAFE_IN_SUMMARY.test(d)
1325
+ ).slice(0, MAX_NAMED_HOSTS) : [];
1326
+ };
1327
+ var egressNote = (agentAllowlist) => agentAllowlist.length === 0 ? "" : `
1328
+
1329
+ If the review failed because the agent could not reach a host it needed, note that its network egress is jailed to only ${agentAllowlist.map((host) => `\`${host}\``).join(
1330
+ ", "
1331
+ )}. Add any missing host to the agent's egress allowlist \u2014 the reusable workflow's \`extra_endpoints\` input, or a single-file workflow's \`--extra\` flag on \`sandbox-config\`.`;
1332
+ var blockquote = (text) => text.replaceAll("\n", "\n> ");
1333
+ var reasoned = (lead, noReason, reasons) => typeof reasons === "string" && reasons.trim() !== "" ? `${lead}
1334
+
1335
+ > ${blockquote(reasons)}` : noReason;
1336
+ var noticeSummary = (kind, reasons, agentAllowlist) => {
1337
+ switch (kind) {
1338
+ case "security-blocked":
1339
+ return reasoned(
1340
+ "### \u{1F6D1} Code review skipped by the security gate\n\nThe diff was flagged as unsafe to apply and execute:",
1341
+ "### \u{1F6D1} Code review skipped by the security gate\n\nThe security triage returned an unsafe verdict without a reason. See workflow logs.",
1342
+ reasons
1343
+ );
1344
+ case "triage-error":
1345
+ return reasoned(
1346
+ "### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs). The triage step reported:",
1347
+ "### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs).",
1348
+ reasons
1349
+ ) + egressNote(agentAllowlist);
1350
+ case "setup-failed":
1351
+ 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.";
1352
+ case "checkout-failed":
1353
+ 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.";
1354
+ case "no-output":
1355
+ return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs." + egressNote(agentAllowlist);
1356
+ }
1357
+ };
1358
+ var noticeEnvelope = (summary) => ({
1359
+ schema_version: DEFAULT_SCHEMA_VERSION,
1360
+ findings: incompleteFindings(summary),
1361
+ models: [],
1362
+ turns: 0,
1363
+ duration_ms: 0,
1364
+ vendor_cost_usd: null,
1365
+ incomplete: true
1366
+ });
1367
+ var buildNoticeEnvelope = (kind, reasons, agentAllowlist = []) => noticeEnvelope(noticeSummary(kind, reasons, agentAllowlist));
1368
+ var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
1369
+ `### \u26A0\uFE0F Review could not be rendered
1370
+
1371
+ The workflow asked for an unrecognized notice kind (\`${kind}\`) \u2014 the pinned code-review CLI is older than the workflow calling it (check that its version matches). Failing closed. See the workflow logs.`
1372
+ );
380
1373
  var identity = (decoded) => decoded;
381
1374
  var findingsTable = [
382
1375
  {
383
- minor: "0.2",
1376
+ minor: "0.4",
1377
+ defaultVersion: "0.4.0",
1378
+ schemaFile: "findings.schema.json",
1379
+ codec: FindingsCodec,
1380
+ normalize: identity,
1381
+ latest: false
1382
+ },
1383
+ {
1384
+ minor: "0.5",
1385
+ defaultVersion: "0.5.0",
1386
+ schemaFile: "findings.schema.json",
1387
+ codec: FindingsCodec,
1388
+ normalize: identity,
1389
+ latest: false
1390
+ },
1391
+ {
1392
+ minor: "0.6",
384
1393
  defaultVersion: DEFAULT_SCHEMA_VERSION,
385
1394
  schemaFile: "findings.schema.json",
386
1395
  codec: FindingsCodec,
@@ -460,39 +1469,90 @@ var resolvers = {
460
1469
  prices: (raw) => resolveSingleVersion("prices", raw)
461
1470
  };
462
1471
  var resolve = (kind, raw) => resolvers[kind](raw);
463
- var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
1472
+ var MAX_BUFFER = 100 * 1024 * 1024;
1473
+ var MAX_TIMEOUT_MS = 2147483647;
1474
+ var parseTimeoutMs = (raw, fallback) => {
1475
+ if (raw === void 0 || !/^\d+$/.test(raw)) return fallback;
1476
+ const parsed = Number(raw);
1477
+ return parsed > 0 && parsed <= MAX_TIMEOUT_MS ? parsed : fallback;
1478
+ };
1479
+ var SUBPROCESS_TIMEOUT_ENV = "CODE_REVIEW_SUBPROCESS_TIMEOUT_MS";
1480
+ var DEFAULT_SUBPROCESS_TIMEOUT_MS = 12e4;
1481
+ var subprocessTimeoutMs = () => parseTimeoutMs(process.env[SUBPROCESS_TIMEOUT_ENV], DEFAULT_SUBPROCESS_TIMEOUT_MS);
1482
+ var classifyExecError = (err, stderr, timeoutMs) => {
1483
+ const e = err;
1484
+ const stderrStr = stderr.trim();
1485
+ if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
1486
+ return `output exceeded ${String(MAX_BUFFER)} bytes (killed)`;
1487
+ if (e.killed === true)
1488
+ return `no response within ${String(timeoutMs)}ms (killed a hung child)${stderrStr ? `: ${stderrStr}` : ""}`;
1489
+ return stderrStr || errMsg(err);
1490
+ };
1491
+ var execFileWithTimeout = (spec) => new Promise((resolve3, reject) => {
464
1492
  const child = execFile(
465
- "gh",
466
- ["api", ...args],
467
- { env: { ...process.env, ...env }, encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 },
1493
+ spec.command,
1494
+ [...spec.args],
1495
+ {
1496
+ ...spec.env ? { env: { ...process.env, ...spec.env } } : {},
1497
+ encoding: "utf-8",
1498
+ maxBuffer: MAX_BUFFER,
1499
+ timeout: spec.timeoutMs,
1500
+ killSignal: "SIGKILL"
1501
+ },
468
1502
  (err, stdout, stderr) => {
469
- if (err) {
470
- const stderrStr = typeof stderr === "string" && stderr.trim() ? stderr.trim() : "";
471
- const errStr = err instanceof Error ? err.message : "unknown error";
472
- reject(new Error(`gh api failed: ${stderrStr || errStr}`));
473
- } else {
474
- resolve3(stdout);
475
- }
1503
+ if (err)
1504
+ reject(
1505
+ new Error(`${spec.label} failed: ${classifyExecError(err, stderr, spec.timeoutMs)}`)
1506
+ );
1507
+ else resolve3(stdout);
476
1508
  }
477
1509
  );
478
- if (stdin !== void 0) {
479
- child.stdin?.end(stdin);
1510
+ if (spec.stdin !== void 0) {
1511
+ child.stdin?.on("error", () => void 0);
1512
+ child.stdin?.end(spec.stdin);
480
1513
  }
481
1514
  });
482
1515
 
1516
+ // src/gh.ts
1517
+ var describeEndpoint = (args) => args.find((a) => a === "graphql" || a.includes("/") && !a.startsWith("-")) ?? args[0] ?? "(no endpoint)";
1518
+ var runGhApi = (args, stdin, env) => execFileWithTimeout({
1519
+ command: "gh",
1520
+ args: ["api", ...args],
1521
+ label: `gh api ${describeEndpoint(args)}`,
1522
+ timeoutMs: subprocessTimeoutMs(),
1523
+ env,
1524
+ stdin
1525
+ });
1526
+
483
1527
  // src/pr.ts
1528
+ var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
1529
+ var parseCandidates = (stdout) => parseJsonl(stdout);
1530
+ var fetchDirectCandidates = async (repo, headSha, ghApi) => {
1531
+ try {
1532
+ return parseCandidates(
1533
+ await ghApi([`repos/${repo}/commits/${headSha}/pulls`, "--jq", CANDIDATE_JQ])
1534
+ );
1535
+ } catch {
1536
+ return [];
1537
+ }
1538
+ };
484
1539
  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));
1540
+ const direct = await fetchDirectCandidates(repo, headSha, ghApi);
1541
+ if (direct.length > 0) return direct;
1542
+ const open = parseCandidates(
1543
+ await ghApi([
1544
+ `repos/${repo}/pulls?state=open&per_page=100`,
1545
+ "--paginate",
1546
+ "--jq",
1547
+ CANDIDATE_JQ
1548
+ ])
1549
+ );
1550
+ return open.filter((c) => c.headSha === headSha);
491
1551
  };
492
1552
  var resolvePr = (candidates, headBranch) => {
493
1553
  if (candidates.length === 0) return { kind: "none" };
494
1554
  const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
495
- const chosen = scoped[0] ?? candidates[0];
1555
+ const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
496
1556
  if (chosen === void 0) return { kind: "none" };
497
1557
  return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
498
1558
  };
@@ -502,10 +1562,112 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
502
1562
  "Accept: application/vnd.github.v3.diff"
503
1563
  ]);
504
1564
 
1565
+ // src/checkrun.ts
1566
+ var CHECK_RUN_NAME = "Code review";
1567
+ var settled = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
1568
+ var runIdFromUrl = (runUrl) => {
1569
+ const m = /\/actions\/runs\/(\d+)\/?$/.exec(runUrl);
1570
+ return m?.[1] ?? null;
1571
+ };
1572
+ var ownedCheck = (checks, runUrl) => {
1573
+ const runId = runIdFromUrl(runUrl);
1574
+ if (runId === null) return null;
1575
+ return checks.find((c) => c.detailsUrl !== null && c.detailsUrl.endsWith(`/runs/${runId}`)) ?? null;
1576
+ };
1577
+ var decideCheckAction = (checks, intent, runUrl) => {
1578
+ const owned = ownedCheck(checks, runUrl);
1579
+ switch (intent) {
1580
+ case "in_progress":
1581
+ return owned !== null ? { kind: "noop", reason: "this run's check is already in progress" } : { kind: "create", status: "in_progress" };
1582
+ case "neutral":
1583
+ if (owned === null) return { kind: "create", status: "completed", conclusion: "neutral" };
1584
+ return owned.status === "completed" && owned.conclusion === "neutral" ? { kind: "noop", reason: "the check already records this completed review" } : { kind: "patch", id: owned.id, status: "completed", conclusion: "neutral" };
1585
+ case "failure":
1586
+ if (owned === null) return { kind: "create", status: "completed", conclusion: "failure" };
1587
+ if (owned.status === "completed" && owned.conclusion !== null && settled.has(owned.conclusion))
1588
+ return { kind: "noop", reason: "a completed review already recorded this head" };
1589
+ return owned.status === "completed" && owned.conclusion === "failure" ? { kind: "noop", reason: "the check already records this failure" } : { kind: "patch", id: owned.id, status: "completed", conclusion: "failure" };
1590
+ case "cancelled":
1591
+ return { kind: "noop", reason: "cancelled is handled via decideCancelledAction" };
1592
+ }
1593
+ };
1594
+ var decideCancelledAction = (checks, runUrl) => {
1595
+ const owned = ownedCheck(checks, runUrl);
1596
+ if (owned === null) return { kind: "noop", reason: "no check was created by this run to settle" };
1597
+ if (owned.status === "completed" && owned.conclusion === "cancelled")
1598
+ return { kind: "noop", reason: "the check already records this cancelled run" };
1599
+ if (owned.status === "completed" && owned.conclusion !== null && settled.has(owned.conclusion))
1600
+ return { kind: "noop", reason: "a completed review already recorded this head" };
1601
+ return { kind: "patch", id: owned.id, status: "completed", conclusion: "cancelled" };
1602
+ };
1603
+ var CHECK_JQ = ".check_runs[] | {id: .id, status: .status, conclusion: .conclusion, detailsUrl: .details_url}";
1604
+ var fetchChecks = async (repo, headSha, ghApi) => parseJsonl(
1605
+ await ghApi([
1606
+ `repos/${repo}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(CHECK_RUN_NAME)}&per_page=100`,
1607
+ "--paginate",
1608
+ "--jq",
1609
+ CHECK_JQ
1610
+ ])
1611
+ );
1612
+ var output = (intent, runUrl) => {
1613
+ switch (intent) {
1614
+ case "in_progress":
1615
+ return {
1616
+ title: "Code review in progress",
1617
+ summary: `The review is running \u2014 [see the run](${runUrl}).`
1618
+ };
1619
+ case "neutral":
1620
+ return {
1621
+ title: "Code review complete",
1622
+ summary: `The review was posted \u2014 [see the run](${runUrl}).`
1623
+ };
1624
+ case "failure":
1625
+ return {
1626
+ title: "Code review did not complete",
1627
+ summary: `The review job failed \u2014 [see the run](${runUrl}). Re-request the review; do not treat this round as spent.`
1628
+ };
1629
+ case "cancelled":
1630
+ return {
1631
+ title: "Code review superseded",
1632
+ summary: `This review run was cancelled \u2014 [see the run](${runUrl}). No action needed.`
1633
+ };
1634
+ }
1635
+ };
1636
+ var checkRun = async (input, ghApi = runGhApi) => {
1637
+ const checks = await fetchChecks(input.repo, input.headSha, ghApi);
1638
+ const action = input.intent === "cancelled" ? decideCancelledAction(checks, input.runUrl) : decideCheckAction(checks, input.intent, input.runUrl);
1639
+ if (action.kind === "noop") {
1640
+ process.stderr.write(`code-review check-run: ${action.reason} \u2014 leaving it
1641
+ `);
1642
+ return;
1643
+ }
1644
+ const body = action.kind === "create" ? {
1645
+ name: CHECK_RUN_NAME,
1646
+ head_sha: input.headSha,
1647
+ status: action.status,
1648
+ details_url: input.runUrl,
1649
+ ...action.conclusion ? { conclusion: action.conclusion } : {},
1650
+ output: output(input.intent, input.runUrl)
1651
+ } : {
1652
+ status: action.status,
1653
+ conclusion: action.conclusion,
1654
+ details_url: input.runUrl,
1655
+ output: output(input.intent, input.runUrl)
1656
+ };
1657
+ const endpoint = action.kind === "create" ? [`--method`, `POST`, `repos/${input.repo}/check-runs`, `--input`, `-`] : [
1658
+ `--method`,
1659
+ `PATCH`,
1660
+ `repos/${input.repo}/check-runs/${String(action.id)}`,
1661
+ `--input`,
1662
+ `-`
1663
+ ];
1664
+ await ghApi(endpoint, JSON.stringify(body));
1665
+ };
1666
+
505
1667
  // src/post.ts
506
1668
  var DEFAULT_MARKER = "<!-- code-review -->";
1669
+ var EMPTY_MECHANIC_LEAVE_MESSAGE = "The CI-fix pass found no issues and the sticky already reflects a completed full review \u2014 leaving it in place\n";
507
1670
  var MAX_SUGGESTION_LINES = 10;
508
- var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
509
1671
  var countSuggestionLines = (text) => text.split("\n").length;
510
1672
  var checkLongSuggestions = (comments) => {
511
1673
  const longFiles = [];
@@ -525,13 +1687,6 @@ var checkLongSuggestions = (comments) => {
525
1687
  });
526
1688
  return { comments: adjusted, longFiles };
527
1689
  };
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
1690
  var loadFindings = (path) => {
536
1691
  let raw;
537
1692
  try {
@@ -578,7 +1733,7 @@ var loadTestReport = (path) => {
578
1733
  raw = JSON.parse(readFileSync(path, "utf-8"));
579
1734
  } catch (err) {
580
1735
  process.stderr.write(
581
- `Warning: could not read test report at ${path}: ${err instanceof Error ? err.message : String(err)} \u2014 omitting test panel
1736
+ `Warning: could not read test report at ${path}: ${errMsg(err)} \u2014 omitting test panel
582
1737
  `
583
1738
  );
584
1739
  return void 0;
@@ -593,20 +1748,55 @@ var loadTestReport = (path) => {
593
1748
  }
594
1749
  return decoded.right;
595
1750
  };
596
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
597
- const body = JSON.stringify({
598
- body: "",
1751
+ var parseHtmlUrl = (raw) => {
1752
+ const parsed = tryParseJson(raw);
1753
+ const htmlUrl = parsed.ok ? asRecord(parsed.value)?.["html_url"] : void 0;
1754
+ return typeof htmlUrl === "string" ? htmlUrl : void 0;
1755
+ };
1756
+ var commentPayload = (c) => ({
1757
+ path: c.path,
1758
+ line: c.line,
1759
+ side: c.side,
1760
+ ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1761
+ body: formatMarkdown(c.body)
1762
+ });
1763
+ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
1764
+ const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
1765
+ const reviewBody = (withComments) => JSON.stringify({
1766
+ body: pointer,
599
1767
  commit_id: headSha,
600
1768
  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
- }))
1769
+ comments: withComments ? comments.map(commentPayload) : []
608
1770
  });
609
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
1771
+ const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
1772
+ try {
1773
+ const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
1774
+ return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
1775
+ } catch (err) {
1776
+ if (comments.length === 0) throw err;
1777
+ process.stderr.write(
1778
+ `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)
1779
+ `
1780
+ );
1781
+ const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
1782
+ const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
1783
+ const unposted = [];
1784
+ let inlinePosted = 0;
1785
+ for (const [i, c] of comments.entries()) {
1786
+ try {
1787
+ await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
1788
+ inlinePosted += 1;
1789
+ } catch (e) {
1790
+ const finding = inDiff[i];
1791
+ if (finding) unposted.push(finding);
1792
+ process.stderr.write(
1793
+ `Warning: inline comment on ${c.path}:${String(c.line)} rejected (${errMsg(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
1794
+ `
1795
+ );
1796
+ }
1797
+ }
1798
+ return { url, inlinePosted, unposted };
1799
+ }
610
1800
  };
611
1801
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
612
1802
  const stdout = await ghApi(
@@ -626,33 +1816,44 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
626
1816
  const parsed = JSON.parse(last);
627
1817
  return { id: parsed.id, body: parsed.body };
628
1818
  };
1819
+ var parseCommentRef = (raw) => {
1820
+ const parsed = tryParseJson(raw);
1821
+ const rec = parsed.ok ? asRecord(parsed.value) : null;
1822
+ const id = rec?.["id"];
1823
+ const html_url = rec?.["html_url"];
1824
+ return typeof id === "number" && typeof html_url === "string" ? { id, html_url } : null;
1825
+ };
629
1826
  var patchComment = async (repo, commentId, body, ghApi) => {
630
- await ghApi(
1827
+ const stdout = await ghApi(
631
1828
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
632
1829
  JSON.stringify({ body })
633
1830
  );
1831
+ const htmlUrl = parseHtmlUrl(stdout);
1832
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
634
1833
  };
635
1834
  var postComment = async (repo, prNumber, body, ghApi) => {
636
- await ghApi(
1835
+ const stdout = await ghApi(
637
1836
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
638
1837
  JSON.stringify({ body })
639
1838
  );
1839
+ return parseCommentRef(stdout);
640
1840
  };
641
1841
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
642
1842
  if (existing !== null) {
643
- await patchComment(repo, existing.id, body, ghApi);
1843
+ const patched = await patchComment(repo, existing.id, body, ghApi);
644
1844
  process.stderr.write(
645
1845
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
646
1846
  `
647
1847
  );
648
- } else {
649
- await postComment(repo, prNumber, body, ghApi);
650
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
651
- `);
1848
+ return { id: existing.id, url: patched?.html_url };
652
1849
  }
1850
+ const posted = await postComment(repo, prNumber, body, ghApi);
1851
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
1852
+ `);
1853
+ return posted ? { id: posted.id, url: posted.html_url } : null;
653
1854
  };
654
1855
  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) => {
1856
+ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
656
1857
  const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
657
1858
  let reviews;
658
1859
  try {
@@ -661,10 +1862,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
661
1862
  return [];
662
1863
  }
663
1864
  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);
1865
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
665
1866
  };
666
- var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
667
- const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
1867
+ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
668
1868
  for (const id of ids) {
669
1869
  try {
670
1870
  await ghApi(
@@ -679,24 +1879,104 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
679
1879
  );
680
1880
  } catch (err) {
681
1881
  process.stderr.write(
682
- `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1882
+ `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${errMsg(err)}
683
1883
  `
684
1884
  );
685
1885
  }
686
1886
  }
687
1887
  };
688
- var post = async (input, ghApi = runGhApi) => {
689
- const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
690
- const resolution = resolvePr(candidates, input.headBranch);
691
- if (resolution.kind === "none") {
692
- process.stderr.write(`No open PR for ${input.headSha} \u2014 nothing to post
693
- `);
694
- process.exit(0);
1888
+ 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}}}}}}}}";
1889
+ var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1890
+ var priorBotCommentId = (c, logins) => {
1891
+ if (typeof c !== "object" || c === null) return null;
1892
+ const o = c;
1893
+ const login = o.author?.login;
1894
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
1895
+ };
1896
+ var priorBotCommentIds = (raw, botLogin) => {
1897
+ let parsed;
1898
+ try {
1899
+ parsed = JSON.parse(raw);
1900
+ } catch {
1901
+ return { ids: [], truncated: false };
695
1902
  }
696
- if (resolution.kind === "not-open") {
697
- process.stderr.write(
698
- `PR #${String(resolution.prNumber)} for ${input.headSha} is not open (state: ${resolution.state}) \u2014 nothing to post
699
- `
1903
+ const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
1904
+ const truncated = conn?.pageInfo?.hasNextPage === true;
1905
+ const nodes = conn?.nodes;
1906
+ if (!Array.isArray(nodes)) return { ids: [], truncated };
1907
+ const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1908
+ const ids = nodes.flatMap((t7) => {
1909
+ const cnodes = t7.comments?.nodes;
1910
+ return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1911
+ });
1912
+ return { ids, truncated };
1913
+ };
1914
+ var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
1915
+ const slash = repo.indexOf("/");
1916
+ if (slash <= 0) return [];
1917
+ const owner = repo.slice(0, slash);
1918
+ const name = repo.slice(slash + 1);
1919
+ let raw;
1920
+ try {
1921
+ raw = await ghApi([
1922
+ "graphql",
1923
+ "-f",
1924
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
1925
+ "-f",
1926
+ `owner=${owner}`,
1927
+ "-f",
1928
+ `name=${name}`,
1929
+ "-F",
1930
+ `pr=${String(prNumber)}`
1931
+ ]);
1932
+ } catch (err) {
1933
+ process.stderr.write(
1934
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${errMsg(err)}
1935
+ `
1936
+ );
1937
+ return [];
1938
+ }
1939
+ const { ids, truncated } = priorBotCommentIds(raw, botLogin);
1940
+ if (truncated) {
1941
+ process.stderr.write(
1942
+ `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1943
+ `
1944
+ );
1945
+ }
1946
+ return ids;
1947
+ };
1948
+ var minimizeComments = async (prNumber, ids, ghApi) => {
1949
+ let minimized = 0;
1950
+ for (const id of ids) {
1951
+ try {
1952
+ await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
1953
+ minimized += 1;
1954
+ } catch (err) {
1955
+ process.stderr.write(
1956
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${errMsg(err)}
1957
+ `
1958
+ );
1959
+ }
1960
+ }
1961
+ if (minimized > 0) {
1962
+ process.stderr.write(
1963
+ `Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
1964
+ `
1965
+ );
1966
+ }
1967
+ };
1968
+ var post = async (input, ghApi = runGhApi) => {
1969
+ const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
1970
+ const resolution = resolvePr(candidates, input.headBranch);
1971
+ if (resolution.kind === "none") {
1972
+ process.stderr.write(`No open PR for ${input.headSha} \u2014 nothing to post
1973
+ `);
1974
+ process.exit(0);
1975
+ }
1976
+ if (resolution.kind === "not-open") {
1977
+ process.stderr.write(
1978
+ `PR #${String(resolution.prNumber)} for ${input.headSha} is not open (state: ${resolution.state}) \u2014 nothing to post
1979
+ `
700
1980
  );
701
1981
  process.exit(0);
702
1982
  }
@@ -709,25 +1989,74 @@ var post = async (input, ghApi = runGhApi) => {
709
1989
  DEFAULT_MARKER,
710
1990
  ghApi
711
1991
  );
712
- const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
713
- const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
1992
+ const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
1993
+ const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
1994
+ const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
1995
+ const emptyMechanicWouldBury = (route, incomplete) => route === "mechanic" && !incomplete && findings.findings.length === 0 && existingSticky !== null && priorIsFullReview(existingSticky.body);
1996
+ const priorRounds = existingSticky !== null ? parseRounds(existingSticky.body) : [];
1997
+ const priorSignal = existingSticky === null ? null : parseSignalMarker(existingSticky.body) ?? parseSurfaceSignal(parseFindingsMarker(existingSticky.body));
1998
+ const findingsMarkerFor = (findings2, signal2) => {
1999
+ const pointer = surfacedFindingsPointer(findings2, signal2, input.jsonUrl);
2000
+ return signal2 !== null || priorSignal === null ? pointer : `${pointer}
2001
+ ${signalMarker(priorSignal)}`;
2002
+ };
2003
+ const leaveInPlace = (message) => {
2004
+ process.stderr.write(
2005
+ message ?? "Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place\n"
2006
+ );
2007
+ process.exit(0);
2008
+ };
2009
+ const emptyMechanicLeaveOrNote = async (sticky) => {
2010
+ if (existingComplete) leaveInPlace(EMPTY_MECHANIC_LEAVE_MESSAGE);
2011
+ const priorSha = parseReviewedSha(sticky.body);
2012
+ const body = formatMarkdown(
2013
+ noticeBody(
2014
+ `${DEFAULT_MARKER}
2015
+
2016
+ \u26A0\uFE0F **CI-fix pass completed with no findings** for \`${input.headSha.slice(0, 7)}\` \u2014 the completed full review of \`${priorSha ? priorSha.slice(0, 7) : "an earlier commit"}\` is preserved below.`,
2017
+ sticky.body
2018
+ )
2019
+ );
2020
+ await upsertSticky(input.repo, prNumber, sticky, body, ghApi);
2021
+ process.exit(0);
2022
+ };
714
2023
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
715
2024
  const decodedPrices = PriceMapCodec.decode(prices);
716
2025
  if (decodedPrices._tag === "Left") {
717
2026
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
718
2027
  }
719
2028
  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
- });
2029
+ const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
2030
+ const renderNotice = (message) => {
2031
+ const findings2 = incompleteFindings(`### \u26A0\uFE0F ${message}`);
2032
+ return formatMarkdown(
2033
+ render({
2034
+ findings: findings2,
2035
+ envelope: null,
2036
+ incomplete: true,
2037
+ prices: decodedPrices.right,
2038
+ pricesProvided: input.pricesProvided,
2039
+ template,
2040
+ route: input.route,
2041
+ reviewedSha: input.headSha,
2042
+ effort: input.effort,
2043
+ rounds: priorRounds,
2044
+ sameRootNotes: {},
2045
+ roundCount: priorSignal?.round ?? priorRounds.length,
2046
+ convergenceRound: false,
2047
+ runUrl: input.runUrl,
2048
+ jsonUrl: input.jsonUrl,
2049
+ // A notice's own blob stays clean: verdict "error" + a carried "converged" would read as a
2050
+ // stop signal for a run that produced no verdict (issue #141 review r2). The prior signal
2051
+ // survives on the sticky in the compact marker (findingsMarkerFor), and the carried-forward
2052
+ // trajectory (rounds marker) remains the historical record.
2053
+ findingsPointer: findingsMarkerFor(findings2, null),
2054
+ postedAt: input.postedAt
2055
+ })
2056
+ );
2057
+ };
730
2058
  if (isEmptyDiff(diff)) {
2059
+ if (wouldBuryCompleted(true)) leaveInPlace();
731
2060
  await upsertSticky(
732
2061
  input.repo,
733
2062
  prNumber,
@@ -739,6 +2068,7 @@ var post = async (input, ghApi = runGhApi) => {
739
2068
  }
740
2069
  const findingsResult = loadFindings(input.findingsPath);
741
2070
  if (findingsResult.kind !== "ok") {
2071
+ if (wouldBuryCompleted(true)) leaveInPlace();
742
2072
  await upsertSticky(
743
2073
  input.repo,
744
2074
  prNumber,
@@ -751,28 +2081,63 @@ var post = async (input, ghApi = runGhApi) => {
751
2081
  const findings = findingsResult.findings;
752
2082
  const envelope = loadEnvelope(input.envelopePath);
753
2083
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
2084
+ const effectiveRoute = input.route ?? envelope?.route;
754
2085
  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);
2086
+ const envelopelessIncomplete = isIncompleteFindings(findings);
2087
+ if (wouldBuryCompleted(envelopelessIncomplete)) leaveInPlace();
2088
+ if (emptyMechanicWouldBury(effectiveRoute, envelopelessIncomplete) && existingSticky !== null)
2089
+ await emptyMechanicLeaveOrNote(existingSticky);
2090
+ const body = formatMarkdown(
2091
+ render({
2092
+ findings,
2093
+ envelope: null,
2094
+ incomplete: envelopelessIncomplete,
2095
+ prices: decodedPrices.right,
2096
+ pricesProvided: input.pricesProvided,
2097
+ template,
2098
+ route: effectiveRoute,
2099
+ reviewedSha: input.headSha,
2100
+ effort: input.effort,
2101
+ rounds: priorRounds,
2102
+ sameRootNotes: {},
2103
+ roundCount: priorSignal?.round ?? priorRounds.length,
2104
+ convergenceRound: false,
2105
+ testReport,
2106
+ inlineDisposition: { kind: "no-envelope" },
2107
+ runUrl: input.runUrl,
2108
+ jsonUrl: input.jsonUrl,
2109
+ // Same signal rule as the main path: only a completed-review doc carries the prior signal
2110
+ // in its blob; an error-verdict doc preserves it in the compact marker instead.
2111
+ findingsPointer: findingsMarkerFor(
2112
+ findings,
2113
+ isReviewVerdict(findings.verdict) ? priorSignal : null
2114
+ ),
2115
+ postedAt: input.postedAt
2116
+ })
2117
+ );
2118
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
766
2119
  process.stderr.write(
767
2120
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
768
2121
  );
769
2122
  process.exit(0);
770
2123
  }
771
- const { comments: rawComments, strays } = buildInlineComments(
772
- findings.findings,
773
- diff,
774
- inlineTemplate
775
- );
2124
+ const thisIncomplete = envelope.incomplete === true || isIncompleteFindings(findings);
2125
+ if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
2126
+ if (emptyMechanicWouldBury(effectiveRoute, thisIncomplete) && existingSticky !== null)
2127
+ await emptyMechanicLeaveOrNote(existingSticky);
2128
+ const isRound = isConvergenceRound(effectiveRoute, thisIncomplete) && isReviewVerdict(findings.verdict);
2129
+ const sameRootNotes = isRound ? computeSameRootNotes(priorRounds, findings.findings, input.headSha.slice(0, 12)) : {};
2130
+ const {
2131
+ comments: rawComments,
2132
+ strays,
2133
+ inDiff
2134
+ } = buildInlineComments(findings.findings, diff, {
2135
+ inlineTemplate,
2136
+ models: envelope.models.map((m) => m.model),
2137
+ findings,
2138
+ jsonUrl: input.jsonUrl,
2139
+ sameRootNotes
2140
+ });
776
2141
  const { comments, longFiles } = checkLongSuggestions(rawComments);
777
2142
  for (const wf of longFiles) {
778
2143
  process.stderr.write(
@@ -780,45 +2145,482 @@ var post = async (input, ghApi = runGhApi) => {
780
2145
  `
781
2146
  );
782
2147
  }
783
- let body = render({
2148
+ const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
2149
+ const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
2150
+ const currentCounts = computeSeverityCounts(findings.findings);
2151
+ const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
2152
+ const priorLastCodes = priorRounds.length > 0 ? priorRounds[priorRounds.length - 1]?.codes : void 0;
2153
+ const roundNumber = Math.max(priorSignal?.round ?? priorRounds.length, priorRounds.length) + 1;
2154
+ const rounds = isRound ? [
2155
+ ...priorRounds,
2156
+ roundRecord(
2157
+ computeRoundCounts(findings),
2158
+ currentCodes,
2159
+ priorLastCodes,
2160
+ input.headSha.slice(0, 12),
2161
+ roundNumber
2162
+ )
2163
+ ] : priorRounds;
2164
+ const signal = isRound ? signalForRound(roundNumber, computeRoundCounts(findings), input.convergenceThreshold) : thisIncomplete || !isReviewVerdict(findings.verdict) ? null : priorSignal;
2165
+ const findingsMarker = findingsMarkerFor(findings, signal);
2166
+ const markerForm = findingsMarkerForm(surfaceFindings(findings, signal), input.jsonUrl);
2167
+ if (markerForm === "link") {
2168
+ process.stderr.write(
2169
+ "Warning: the findings-json marker exceeds the embed limit \u2014 degraded to the jsonUrl-link form; a decoding agent must fetch the artifact instead of the embedded JSON\n"
2170
+ );
2171
+ } else if (markerForm === "omitted") {
2172
+ process.stderr.write(
2173
+ "Warning: the findings-json marker exceeds the embed limit and no --json-url was given \u2014 the machine-readable channel is omitted from the posted surfaces\n"
2174
+ );
2175
+ }
2176
+ const commonRenderInput = {
784
2177
  findings,
785
2178
  envelope,
2179
+ incomplete: thisIncomplete,
786
2180
  prices: decodedPrices.right,
2181
+ pricesProvided: input.pricesProvided,
787
2182
  template,
788
- route: input.route,
2183
+ route: effectiveRoute,
789
2184
  reviewedSha: input.headSha,
790
2185
  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 += `
2186
+ testReport,
2187
+ severityCounts: currentCounts,
2188
+ rounds,
2189
+ sameRootNotes,
2190
+ roundCount: signal?.round ?? priorSignal?.round ?? priorRounds.length,
2191
+ convergenceThreshold: input.convergenceThreshold,
2192
+ convergenceRound: isRound,
2193
+ strays,
2194
+ runUrl: input.runUrl,
2195
+ jsonUrl: input.jsonUrl,
2196
+ findingsPointer: findingsMarker,
2197
+ postedAt: input.postedAt
2198
+ };
2199
+ const longFilesNote = longFiles.length > 0 ? `
797
2200
 
798
2201
  ---
799
2202
 
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
- `;
2203
+ > **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.
2204
+ ` : "";
2205
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
2206
+ render({
2207
+ ...commonRenderInput,
2208
+ ...straysOverride ? { strays: straysOverride } : {},
2209
+ ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
2210
+ inlineDisposition,
2211
+ reviewUrl: reviewUrl2
2212
+ }) + longFilesNote
2213
+ );
2214
+ const stickyRef = await upsertSticky(
2215
+ input.repo,
2216
+ prNumber,
2217
+ existingSticky,
2218
+ renderBody(initialDisposition),
2219
+ ghApi
2220
+ );
2221
+ const priorInlineComments = await listPriorBotCommentIds(
2222
+ input.repo,
2223
+ prNumber,
2224
+ input.botLogin,
2225
+ ghApi
2226
+ );
2227
+ const {
2228
+ url: reviewUrl,
2229
+ inlinePosted,
2230
+ unposted
2231
+ } = await postInlineReview(
2232
+ input.repo,
2233
+ prNumber,
2234
+ input.headSha,
2235
+ comments,
2236
+ inDiff,
2237
+ stickyRef?.url,
2238
+ findingsMarker,
2239
+ ghApi
2240
+ );
2241
+ process.stderr.write(
2242
+ `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
2243
+ `
2244
+ );
2245
+ const priorReviewIds = botReviews.map((r) => r.id);
2246
+ if (priorReviewIds.length > 0) {
2247
+ await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
802
2248
  }
803
- if (!isRerunOfSameSha && previousReviewedSha !== null) {
804
- await dismissPriorBotReviews(input.repo, prNumber, input.botLogin, ghApi);
2249
+ await minimizeComments(prNumber, priorInlineComments, ghApi);
2250
+ const unanchoredCount = unposted.length;
2251
+ const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
2252
+ if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
2253
+ const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
2254
+ try {
2255
+ await patchComment(
2256
+ input.repo,
2257
+ stickyRef.id,
2258
+ renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
2259
+ ghApi
2260
+ );
2261
+ process.stderr.write(
2262
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
2263
+ `
2264
+ );
2265
+ } catch (err) {
2266
+ process.stderr.write(
2267
+ `Warning: failed to update the sticky summary after the review: ${errMsg(err)}
2268
+ `
2269
+ );
2270
+ }
2271
+ }
2272
+ };
2273
+ var noticeBody = (lead, existingBody) => {
2274
+ const carried = existingBody ? carryForwardMarkers(existingBody) : "";
2275
+ return carried ? `${lead}
2276
+
2277
+ ${carried}` : lead;
2278
+ };
2279
+ var bodyRefsRun = (body, runUrl) => {
2280
+ const runId = runIdFromUrl(runUrl);
2281
+ return runId === null ? body.includes(runUrl) : new RegExp(`/actions/runs/${runId}(?!\\d)`).test(body);
2282
+ };
2283
+ var announceBody = (headSha, runUrl, existingBody) => noticeBody(
2284
+ `${DEFAULT_MARKER}
2285
+
2286
+ \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.`,
2287
+ existingBody
2288
+ );
2289
+ var announce = async (input, ghApi = runGhApi) => {
2290
+ const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
2291
+ const resolution = resolvePr(candidates, input.headBranch);
2292
+ if (resolution.kind !== "open") {
2293
+ process.stderr.write(
2294
+ `No open PR for ${input.headSha} \u2014 nothing to announce (${resolution.kind})
2295
+ `
2296
+ );
2297
+ return;
805
2298
  }
806
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
807
- if (isRerunOfSameSha) {
2299
+ const existing = await findBotComment(
2300
+ input.repo,
2301
+ resolution.prNumber,
2302
+ input.botLogin,
2303
+ DEFAULT_MARKER,
2304
+ ghApi
2305
+ );
2306
+ if (existing !== null && parseReviewComplete(existing.body) && parseReviewedSha(existing.body) === input.headSha.toLowerCase()) {
808
2307
  process.stderr.write(
809
- `Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
2308
+ `Sticky already reflects a completed review of ${input.headSha} \u2014 leaving it in place
810
2309
  `
811
2310
  );
812
2311
  return;
813
2312
  }
814
- if (comments.length > 0) {
815
- await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
2313
+ await upsertSticky(
2314
+ input.repo,
2315
+ resolution.prNumber,
2316
+ existing,
2317
+ announceBody(input.headSha, input.runUrl, existing?.body),
2318
+ ghApi
2319
+ );
2320
+ };
2321
+ var incompleteBody = (headSha, runUrl, existingBody) => noticeBody(
2322
+ `${DEFAULT_MARKER}
2323
+
2324
+ \u26A0\uFE0F **Code review did not complete** for \`${headSha.slice(0, 7)}\` \u2014 the review job failed ([run](${runUrl})). Re-request the review; do not treat this round as spent.`,
2325
+ existingBody
2326
+ );
2327
+ var cancelledBody = (headSha, runUrl, existingBody) => noticeBody(
2328
+ `${DEFAULT_MARKER}
2329
+
2330
+ \u21A9\uFE0F **Code review superseded** for \`${headSha.slice(0, 7)}\` \u2014 this run was cancelled before completing, typically because a newer review run started on this branch. No action needed. [View the cancelled run](${runUrl}) for the record.`,
2331
+ existingBody
2332
+ );
2333
+ var reportIncomplete = async (input, ghApi = runGhApi) => {
2334
+ const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
2335
+ const resolution = resolvePr(candidates, input.headBranch);
2336
+ if (resolution.kind !== "open") {
816
2337
  process.stderr.write(
817
- `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
2338
+ `No open PR for ${input.headSha} \u2014 nothing to report (${resolution.kind})
818
2339
  `
819
2340
  );
2341
+ return;
2342
+ }
2343
+ const existing = await findBotComment(
2344
+ input.repo,
2345
+ resolution.prNumber,
2346
+ input.botLogin,
2347
+ DEFAULT_MARKER,
2348
+ ghApi
2349
+ );
2350
+ if (existing !== null && parseReviewComplete(existing.body)) {
2351
+ process.stderr.write(`Sticky already reflects a completed review \u2014 leaving it in place
2352
+ `);
2353
+ return;
2354
+ }
2355
+ if (existing !== null && !bodyRefsRun(existing.body, input.runUrl)) {
2356
+ process.stderr.write(`Sticky belongs to another run \u2014 leaving it in place
2357
+ `);
2358
+ return;
2359
+ }
2360
+ if (input.cancelled && existing === null) {
2361
+ process.stderr.write(`Cancelled review has no sticky to supersede \u2014 leaving it absent
2362
+ `);
2363
+ return;
2364
+ }
2365
+ await upsertSticky(
2366
+ input.repo,
2367
+ resolution.prNumber,
2368
+ existing,
2369
+ input.cancelled ? cancelledBody(input.headSha, input.runUrl, existing?.body) : incompleteBody(input.headSha, input.runUrl, existing?.body),
2370
+ ghApi
2371
+ );
2372
+ };
2373
+ var DURATION_RE = /^(\d+)(h|m|s)$/;
2374
+ var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
2375
+ var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
2376
+ var stripTrigger = (body, trigger) => {
2377
+ const trimmed = body.replace(/^\s+/, "");
2378
+ if (!trimmed.startsWith(trigger)) return null;
2379
+ const after = trimmed.slice(trigger.length);
2380
+ return after === "" || /^\s/.test(after) ? after : null;
2381
+ };
2382
+ var scanLeading = (s, acc) => {
2383
+ const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
2384
+ if (m === null) return { ...acc, rest: "" };
2385
+ const [, , token = "", tail = ""] = m;
2386
+ const dm = DURATION_RE.exec(token);
2387
+ if (dm && acc.durationSec === null)
2388
+ return scanLeading(tail, {
2389
+ ...acc,
2390
+ durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
2391
+ });
2392
+ const um = USD_RE.exec(token);
2393
+ if (um && acc.usd === null)
2394
+ return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
2395
+ return { ...acc, rest: s };
2396
+ };
2397
+ var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
2398
+ value: ceiling,
2399
+ notes: [
2400
+ `requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
2401
+ ]
2402
+ } : { value: requested, notes: [] };
2403
+ var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
2404
+ value: ceiling,
2405
+ notes: [
2406
+ `requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
2407
+ ]
2408
+ } : { value: requested, notes: [] };
2409
+ var capInstructions = (text, maxLen) => text.length > maxLen ? {
2410
+ value: text.slice(0, maxLen),
2411
+ notes: [
2412
+ `instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
2413
+ ]
2414
+ } : { value: text, notes: [] };
2415
+ var parseCommandArgs = (body, options) => {
2416
+ const afterTrigger = stripTrigger(body, options.trigger);
2417
+ if (afterTrigger === null) return { kind: "not-a-command" };
2418
+ const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
2419
+ const duration = clampDuration(scan.durationSec, options.maxDurationSec);
2420
+ const usd = clampUsd(scan.usd, options.maxUsd);
2421
+ const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
2422
+ return {
2423
+ kind: "command",
2424
+ args: {
2425
+ durationSec: duration.value,
2426
+ usd: usd.value,
2427
+ instructions: instructions.value,
2428
+ notes: [...duration.notes, ...usd.notes, ...instructions.notes]
2429
+ }
2430
+ };
2431
+ };
2432
+ var PrHeadCodec = t.type({
2433
+ head_sha: t.string,
2434
+ head_ref: t.string,
2435
+ head_repo: t.union([t.string, t.null]),
2436
+ state: t.string
2437
+ });
2438
+ var resolvePrHead = async (repo, prNumber, ghApi) => {
2439
+ const stdout = await ghApi([
2440
+ `repos/${repo}/pulls/${String(prNumber)}`,
2441
+ "--jq",
2442
+ "{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
2443
+ ]);
2444
+ const decoded = PrHeadCodec.decode(JSON.parse(stdout));
2445
+ if (decoded._tag === "Left") {
2446
+ throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
2447
+ }
2448
+ return decoded.right;
2449
+ };
2450
+ var parseCommand = async (input, ghApi = runGhApi) => {
2451
+ const parse = parseCommandArgs(input.body, input.options);
2452
+ if (parse.kind === "not-a-command") {
2453
+ return {
2454
+ kind: "skip",
2455
+ reason: `comment does not begin with the trigger "${input.options.trigger}"`
2456
+ };
2457
+ }
2458
+ const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
2459
+ (err) => err instanceof Error ? err : new Error(String(err))
2460
+ );
2461
+ if (head instanceof Error) {
2462
+ return {
2463
+ kind: "skip",
2464
+ reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
2465
+ };
2466
+ }
2467
+ if (head.state !== "open") {
2468
+ return {
2469
+ kind: "skip",
2470
+ reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
2471
+ };
820
2472
  }
2473
+ return {
2474
+ kind: "run",
2475
+ headSha: head.head_sha,
2476
+ headBranch: head.head_ref,
2477
+ headRepo: head.head_repo ?? input.repo,
2478
+ args: parse.args
2479
+ };
2480
+ };
2481
+ var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
2482
+ const candidate = `GHOUT_${randomHex()}`;
2483
+ if (!instructions.split("\n").includes(candidate)) return candidate;
2484
+ if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
2485
+ return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
2486
+ };
2487
+ var renderCommandOutputs = (result, delim) => {
2488
+ if (result.kind === "skip") return "should_run=false\n";
2489
+ const { headSha, headBranch, headRepo, args } = result;
2490
+ return `${[
2491
+ "should_run=true",
2492
+ `head_sha=${headSha}`,
2493
+ `head_branch=${headBranch}`,
2494
+ `head_repo=${headRepo}`,
2495
+ `duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
2496
+ `usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
2497
+ `instructions<<${delim}`,
2498
+ args.instructions,
2499
+ delim
2500
+ ].join("\n")}
2501
+ `;
821
2502
  };
2503
+ var REACTIONS = [
2504
+ "+1",
2505
+ "-1",
2506
+ "laugh",
2507
+ "confused",
2508
+ "heart",
2509
+ "hooray",
2510
+ "rocket",
2511
+ "eyes"
2512
+ ];
2513
+ var isReaction = (s) => REACTIONS.includes(s);
2514
+ var ReactionCodec = t.type({ id: t.number, content: t.string });
2515
+ var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
2516
+ var removeReactions = async (repo, commentId, content, ghApi) => {
2517
+ const stdout = await ghApi([
2518
+ reactionsPath(repo, commentId),
2519
+ "--paginate",
2520
+ "--jq",
2521
+ ".[] | {id, content}"
2522
+ ]);
2523
+ for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
2524
+ const parsed = tryParseJson(line);
2525
+ const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
2526
+ if (decoded === void 0 || decoded._tag === "Left") {
2527
+ process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
2528
+ continue;
2529
+ }
2530
+ if (decoded.right.content !== content) continue;
2531
+ await ghApi([
2532
+ "--method",
2533
+ "DELETE",
2534
+ `${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
2535
+ ]).catch(
2536
+ (err) => process.stderr.write(
2537
+ `code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
2538
+ `
2539
+ )
2540
+ );
2541
+ }
2542
+ };
2543
+ var react = async (input, ghApi = runGhApi) => {
2544
+ if (input.add !== void 0) {
2545
+ await ghApi([
2546
+ "--method",
2547
+ "POST",
2548
+ reactionsPath(input.repo, input.commentId),
2549
+ "-f",
2550
+ `content=${input.add}`
2551
+ ]);
2552
+ }
2553
+ if (input.remove !== void 0) {
2554
+ await removeReactions(input.repo, input.commentId, input.remove, ghApi);
2555
+ }
2556
+ };
2557
+ var RunCodec = t.type({
2558
+ id: t.number,
2559
+ name: t.union([t.string, t.null]),
2560
+ status: t.union([t.string, t.null]),
2561
+ conclusion: t.union([t.string, t.null]),
2562
+ run_number: t.number
2563
+ });
2564
+ var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number}";
2565
+ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
2566
+ const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
2567
+ const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
2568
+ const decoded = rows.map((row) => RunCodec.decode(row));
2569
+ const runs = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
2570
+ const dropped = decoded.length - runs.length;
2571
+ if (dropped > 0) {
2572
+ const firstDrift = decoded.find((d) => d._tag === "Left");
2573
+ const detail = firstDrift === void 0 ? "" : ` (${PathReporter.report(firstDrift).join("; ")})`;
2574
+ process.stderr.write(
2575
+ `Warning: ${String(dropped)} of ${String(rows.length)} workflow-run row(s) from ${endpoint} failed to decode${detail} \u2014 excluded from the lookup
2576
+ `
2577
+ );
2578
+ }
2579
+ const latest = runs.filter((r) => r.name === workflowName).reduce(
2580
+ (best, r) => best === null || r.run_number > best.run_number ? r : best,
2581
+ null
2582
+ );
2583
+ return {
2584
+ run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
2585
+ seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
2586
+ };
2587
+ };
2588
+ var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
2589
+ const safeResolve = async () => {
2590
+ try {
2591
+ return await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
2592
+ } catch (err) {
2593
+ process.stderr.write(
2594
+ `Warning: CI-run lookup for ${headSha} failed (${errMsg(err)}) \u2014 retrying until the timeout
2595
+ `
2596
+ );
2597
+ return { run: null, seenNames: [] };
2598
+ }
2599
+ };
2600
+ const poll = async (lastSeenNames, lastRunId) => {
2601
+ const { run, seenNames } = await safeResolve();
2602
+ if (run !== null && run.status === "completed" && run.conclusion !== null)
2603
+ return { kind: "concluded", conclusion: run.conclusion, runId: run.id };
2604
+ const runId = run === null ? lastRunId : run.id;
2605
+ const names = seenNames.length > 0 ? seenNames : lastSeenNames;
2606
+ if (deps.elapsedMs() >= options.timeoutMs)
2607
+ return { kind: "timed-out", runId, seenNames: names };
2608
+ await deps.sleep(options.pollIntervalMs);
2609
+ return poll(names, runId);
2610
+ };
2611
+ return poll([], null);
2612
+ };
2613
+ var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
2614
+ var monotonicElapsed = () => {
2615
+ const start = performance.now();
2616
+ return () => performance.now() - start;
2617
+ };
2618
+ var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
2619
+ ci_conclusion=${outcome.conclusion}
2620
+ ci_run_id=${String(outcome.runId)}
2621
+ ` : `ci_settled=false
2622
+ ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
2623
+ `;
822
2624
  var renderOutputs = (result) => {
823
2625
  switch (result.kind) {
824
2626
  case "skip":
@@ -827,44 +2629,41 @@ var renderOutputs = (result) => {
827
2629
  return `pr=${String(result.pr)}
828
2630
  conclusion=${result.conclusion}
829
2631
  diff_size=${String(result.diffSize)}
2632
+ stacked=${String(result.stacked)}
830
2633
  `;
831
2634
  }
832
2635
  };
833
- var runGit = (args) => new Promise((resolve3, reject) => {
834
- execFile(
835
- "git",
836
- [...args],
837
- { encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 },
838
- (err, stdout, stderr) => {
839
- if (err) {
840
- const stderrStr = typeof stderr === "string" && stderr.trim() ? stderr.trim() : "";
841
- const errStr = err instanceof Error ? err.message : "unknown error";
842
- reject(new Error(`git ${args.join(" ")} failed: ${stderrStr || errStr}`));
843
- } else {
844
- resolve3(stdout);
845
- }
846
- }
847
- );
2636
+ var runGit = (args) => execFileWithTimeout({
2637
+ command: "git",
2638
+ args,
2639
+ label: `git ${args.join(" ")}`,
2640
+ timeoutMs: subprocessTimeoutMs()
848
2641
  });
849
2642
  var PrMetaCodec = t.type({
850
2643
  changed_files: t.number,
851
2644
  base_sha: t.string,
2645
+ base_ref: t.string,
852
2646
  title: t.string,
853
2647
  body: t.union([t.string, t.null])
854
2648
  });
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);
2649
+ var IssueCommentCodec = t.intersection([
2650
+ t.type({
2651
+ id: t.number,
2652
+ body: t.union([t.string, t.null]),
2653
+ user: t.type({ login: t.string })
2654
+ }),
2655
+ t.partial({
2656
+ created_at: t.union([t.string, t.null]),
2657
+ author_association: t.union([t.string, t.null])
2658
+ })
2659
+ ]);
861
2660
  var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
862
2661
  var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
863
2662
  var fetchPrMeta = async (repo, prNumber, ghApi) => {
864
2663
  const stdout = await ghApi([
865
2664
  `repos/${repo}/pulls/${String(prNumber)}`,
866
2665
  "--jq",
867
- "{changed_files: .changed_files, base_sha: .base.sha, title: .title, body: .body}"
2666
+ "{changed_files: .changed_files, base_sha: .base.sha, base_ref: .base.ref, title: .title, body: .body}"
868
2667
  ]);
869
2668
  const decoded = PrMetaCodec.decode(JSON.parse(stdout));
870
2669
  if (decoded._tag === "Left") {
@@ -879,18 +2678,139 @@ var fetchApiDiff = async (repo, prNumber, ghApi) => {
879
2678
  return null;
880
2679
  }
881
2680
  };
882
- var fetchPriorReview = async (repo, prNumber, botLogin, ghApi) => {
2681
+ var fetchFullDiff = async (repo, defaultBranch, headSha, ghApi, gitRun) => {
883
2682
  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 {
2683
+ const diff = await ghApi([
2684
+ `repos/${repo}/compare/${defaultBranch}...${headSha}`,
2685
+ "-H",
2686
+ "Accept: application/vnd.github.v3.diff"
2687
+ ]);
2688
+ if (diff.length > 0) return diff;
2689
+ process.stderr.write(
2690
+ `compare diff for ${defaultBranch}...${headSha} was empty \u2014 falling back to git diff
2691
+ `
2692
+ );
2693
+ } catch (err) {
2694
+ process.stderr.write(`compare diff fetch failed (${errMsg(err)}) \u2014 falling back to git diff
2695
+ `);
2696
+ }
2697
+ await gitRun(["fetch", "origin", headSha]);
2698
+ const base = (await gitRun(["rev-parse", "HEAD"])).trim();
2699
+ return gitRun(["diff", `${base}...${headSha}`]);
2700
+ };
2701
+ var CommitCodec = t.type({
2702
+ sha: t.string,
2703
+ message: t.string,
2704
+ author: t.union([t.string, t.null]),
2705
+ email: t.union([t.string, t.null])
2706
+ });
2707
+ var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email}";
2708
+ var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
2709
+ const rows = parseJsonl(
2710
+ await ghApi([
2711
+ `repos/${repo}/compare/${defaultBranch}...${headSha}`,
2712
+ "--paginate",
2713
+ "--jq",
2714
+ COMMIT_JQ
2715
+ ])
2716
+ );
2717
+ const decoded = rows.map((row) => CommitCodec.decode(row));
2718
+ const commits = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
2719
+ const dropped = decoded.length - commits.length;
2720
+ if (dropped > 0) {
2721
+ process.stderr.write(
2722
+ `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
2723
+ `
2724
+ );
2725
+ }
2726
+ return commits;
2727
+ };
2728
+ var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
2729
+ var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
2730
+ var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
2731
+ var ReviewCommentCodec = t.intersection([
2732
+ t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
2733
+ t.partial({
2734
+ created_at: t.union([t.string, t.null]),
2735
+ author_association: t.union([t.string, t.null]),
2736
+ path: t.union([t.string, t.null]),
2737
+ line: t.union([t.number, t.null])
2738
+ })
2739
+ ]);
2740
+ var ReviewCodec = t.intersection([
2741
+ t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
2742
+ t.partial({
2743
+ submitted_at: t.union([t.string, t.null]),
2744
+ author_association: t.union([t.string, t.null]),
2745
+ state: t.union([t.string, t.null])
2746
+ })
2747
+ ]);
2748
+ var fetchJsonlRows = async (ghApi, endpoint, jq) => {
2749
+ try {
2750
+ return parseJsonl(await ghApi([endpoint, "--paginate", "--jq", jq]));
2751
+ } catch (err) {
2752
+ process.stderr.write(
2753
+ `Warning: could not fetch ${endpoint} (${errMsg(err)}) \u2014 omitting it from the review context
2754
+ `
2755
+ );
891
2756
  return null;
892
2757
  }
893
2758
  };
2759
+ var decodeArrayOrNull = (codec, rows) => {
2760
+ if (rows === null) return null;
2761
+ return rows.flatMap((row) => {
2762
+ const decoded = codec.decode(row);
2763
+ return decoded._tag === "Right" ? [decoded.right] : [];
2764
+ });
2765
+ };
2766
+ var priorReviewFrom = (comments, botLogin) => {
2767
+ const byBot = comments.filter((c) => c.user.login === botLogin);
2768
+ const last = byBot[byBot.length - 1];
2769
+ return last ? { id: last.id, body: last.body } : null;
2770
+ };
2771
+ var MAX_CONVERSATION_COMMENTS = 50;
2772
+ var MAX_CONVERSATION_BODY_CHARS = 4e3;
2773
+ var clip = (body) => {
2774
+ if (body.length <= MAX_CONVERSATION_BODY_CHARS) return body;
2775
+ const cut = body.slice(0, MAX_CONVERSATION_BODY_CHARS);
2776
+ const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
2777
+ return `${safe}
2778
+ \u2026 [truncated]`;
2779
+ };
2780
+ var boundedHuman = (items, botLogin, label, project) => {
2781
+ const human = items.filter(
2782
+ (a) => a.user.login !== botLogin && typeof a.body === "string" && a.body.trim() !== ""
2783
+ );
2784
+ const kept = human.slice(-MAX_CONVERSATION_COMMENTS);
2785
+ if (kept.length < human.length) {
2786
+ process.stderr.write(
2787
+ `Note: PR has ${String(human.length)} ${label} \u2014 feeding the review the most recent ${String(MAX_CONVERSATION_COMMENTS)}
2788
+ `
2789
+ );
2790
+ }
2791
+ return kept.map(project);
2792
+ };
2793
+ var issueCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "discussion comments", (c) => ({
2794
+ author: c.user.login,
2795
+ author_association: c.author_association ?? null,
2796
+ created_at: c.created_at ?? null,
2797
+ body: clip(c.body)
2798
+ }));
2799
+ var reviewCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "inline review comments", (c) => ({
2800
+ author: c.user.login,
2801
+ author_association: c.author_association ?? null,
2802
+ created_at: c.created_at ?? null,
2803
+ path: c.path ?? null,
2804
+ line: c.line ?? null,
2805
+ body: clip(c.body)
2806
+ }));
2807
+ var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review submissions", (r) => ({
2808
+ author: r.user.login,
2809
+ author_association: r.author_association ?? null,
2810
+ submitted_at: r.submitted_at ?? null,
2811
+ state: r.state ?? null,
2812
+ body: clip(r.body)
2813
+ }));
894
2814
  var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
895
2815
  const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
896
2816
  const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
@@ -903,7 +2823,7 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
903
2823
  writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
904
2824
  } catch (err) {
905
2825
  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
2826
+ `Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
907
2827
  `
908
2828
  );
909
2829
  }
@@ -926,8 +2846,10 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
926
2846
  }
927
2847
  const prNumber = resolution.prNumber;
928
2848
  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 () => {
2849
+ const stacked = meta.base_ref !== input.defaultBranch;
2850
+ const prDiff = await (async () => {
2851
+ const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
2852
+ if (apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0)) return apiDiff;
931
2853
  process.stderr.write(
932
2854
  `PR diff fetch failed or was empty for ${String(meta.changed_files)} changed files \u2014 falling back to git diff
933
2855
  `
@@ -935,16 +2857,40 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
935
2857
  await gitRun(["fetch", "origin", input.headSha]);
936
2858
  return gitRun(["diff", meta.base_sha, input.headSha]);
937
2859
  })();
938
- writeFileSync(join(input.outDir, "pr.diff"), diff);
2860
+ const fullDiff = stacked ? await fetchFullDiff(input.repo, input.defaultBranch, input.headSha, ghApi, gitRun) : prDiff;
2861
+ const commits = await fetchCompareCommits(input.repo, input.defaultBranch, input.headSha, ghApi);
2862
+ writeFileSync(join(input.outDir, "full.diff"), fullDiff);
2863
+ writeFileSync(join(input.outDir, "pr.diff"), prDiff);
2864
+ writeFileSync(join(input.outDir, "commits.json"), JSON.stringify(commits));
939
2865
  writeFileSync(
940
2866
  join(input.outDir, "pr_context.json"),
941
2867
  JSON.stringify({ title: meta.title, body: meta.body })
942
2868
  );
943
- const prior = await fetchPriorReview(input.repo, prNumber, input.botLogin, ghApi);
2869
+ const [issueRows, reviewCommentRows, reviewRows] = await Promise.all([
2870
+ fetchJsonlRows(ghApi, `repos/${input.repo}/issues/${String(prNumber)}/comments`, COMMENT_JQ),
2871
+ fetchJsonlRows(
2872
+ ghApi,
2873
+ `repos/${input.repo}/pulls/${String(prNumber)}/comments`,
2874
+ REVIEW_COMMENT_JQ
2875
+ ),
2876
+ fetchJsonlRows(ghApi, `repos/${input.repo}/pulls/${String(prNumber)}/reviews`, REVIEW_JQ)
2877
+ ]);
2878
+ const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
2879
+ const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
2880
+ const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
2881
+ const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
944
2882
  writeFileSync(
945
2883
  join(input.outDir, "prior_review.json"),
946
2884
  prior === null ? "null" : JSON.stringify(prior)
947
2885
  );
2886
+ writeFileSync(
2887
+ join(input.outDir, "pr_conversation.json"),
2888
+ JSON.stringify({
2889
+ issue_comments: issueComments === null ? [] : issueCommentsFrom(issueComments, input.botLogin),
2890
+ review_comments: reviewComments === null ? [] : reviewCommentsFrom(reviewComments, input.botLogin),
2891
+ reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
2892
+ })
2893
+ );
948
2894
  if (input.conclusion === "failure") {
949
2895
  await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
950
2896
  }
@@ -952,9 +2898,12 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
952
2898
  kind: "gathered",
953
2899
  pr: prNumber,
954
2900
  conclusion: input.conclusion,
955
- diffSize: Buffer.byteLength(diff, "utf8")
2901
+ diffSize: Buffer.byteLength(prDiff, "utf8"),
2902
+ stacked
956
2903
  };
957
2904
  };
2905
+
2906
+ // src/extract.ts
958
2907
  var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
959
2908
  var parseNativeForExtraction = (raw) => ({
960
2909
  result: fieldOf(raw, "result"),
@@ -1007,20 +2956,6 @@ var gateCandidate = (kind, rawCandidate) => {
1007
2956
  const resolution = resolve(kind, candidate);
1008
2957
  return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
1009
2958
  };
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
2959
  var candidateFromJsonText = (kind, text) => {
1025
2960
  if (text === null) return null;
1026
2961
  const parsed = tryParseJson(text);
@@ -1028,7 +2963,7 @@ var candidateFromJsonText = (kind, text) => {
1028
2963
  };
1029
2964
  var FENCE_OPEN = /^\s*(`{3,})/;
1030
2965
  var FENCE_MARKER_ONLY = /^`+$/;
1031
- var scanLine = (state, line) => {
2966
+ var scanLine2 = (state, line) => {
1032
2967
  if (state.openLength === null) {
1033
2968
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1034
2969
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1037,7 +2972,7 @@ var scanLine = (state, line) => {
1037
2972
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1038
2973
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1039
2974
  };
1040
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
2975
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
1041
2976
  var ladderFailureDiagnostics = (input) => {
1042
2977
  const native = parseNativeForExtraction(input.native);
1043
2978
  const preview = (s) => {
@@ -1049,6 +2984,10 @@ var ladderFailureDiagnostics = (input) => {
1049
2984
  lines.push(
1050
2985
  input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
1051
2986
  );
2987
+ if (input.agentFileFallbackPath !== void 0)
2988
+ lines.push(
2989
+ `last-valid rung: ${input.agentFileFallbackPath} did not validate (or was absent)`
2990
+ );
1052
2991
  }
1053
2992
  lines.push(
1054
2993
  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"
@@ -1075,13 +3014,16 @@ var okOutcome = (gated) => ({
1075
3014
  });
1076
3015
  var extractStructured = (input) => {
1077
3016
  const native = parseNativeForExtraction(input.native);
3017
+ if (input.kind === "findings") {
3018
+ for (const path of [input.agentFilePath, input.agentFileFallbackPath]) {
3019
+ if (path === void 0) continue;
3020
+ const fromFile = candidateFromJsonText(input.kind, readFileOrNull(path));
3021
+ if (fromFile) return okOutcome(fromFile);
3022
+ }
3023
+ }
1078
3024
  if (isErrorEnvelope(native)) {
1079
3025
  return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
1080
3026
  }
1081
- if (input.kind === "findings" && input.agentFilePath !== void 0) {
1082
- const fromFile = candidateFromJsonText(input.kind, readFileOrNull(input.agentFilePath));
1083
- if (fromFile) return okOutcome(fromFile);
1084
- }
1085
3027
  if (native.structuredOutput !== void 0) {
1086
3028
  const fromStructured = gateCandidate(input.kind, native.structuredOutput);
1087
3029
  if (fromStructured) return okOutcome(fromStructured);
@@ -1101,9 +3043,10 @@ var extractStructured = (input) => {
1101
3043
  };
1102
3044
  }
1103
3045
  }
3046
+ const fallbackRung = input.agentFileFallbackPath ? ", last-valid snapshot" : "";
1104
3047
  return {
1105
3048
  kind: "none",
1106
- detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
3049
+ detail: `no --agent-file${fallbackRung}, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
1107
3050
  };
1108
3051
  };
1109
3052
 
@@ -1140,80 +3083,272 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1140
3083
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1141
3084
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1142
3085
  }));
1143
- var adaptClaudeCode = (native, agentFilePath, meta) => {
1144
- const outcome = extractStructured({ kind: "findings", native, agentFilePath });
1145
- if (outcome.kind !== "ok") {
1146
- return left(describeLadderFailure(outcome));
1147
- }
1148
- const resolution = resolve("findings", outcome.candidate);
1149
- return resolution.kind === "ok" ? right({
1150
- schema_version: resolution.version,
1151
- findings: resolution.value,
3086
+ var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
3087
+ const ladder = extractStructured({
3088
+ kind: "findings",
3089
+ native,
3090
+ agentFilePath,
3091
+ agentFileFallbackPath
3092
+ });
3093
+ if (ladder.kind !== "ok")
3094
+ return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
3095
+ const resolution = resolve("findings", ladder.candidate);
3096
+ return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
3097
+ kind: "telemetry-only",
3098
+ reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
3099
+ };
3100
+ };
3101
+ var withMeta = (base, meta) => ({
3102
+ ...base,
3103
+ ...meta.route ? { route: meta.route } : {},
3104
+ ...meta.effort ? { effort: meta.effort } : {}
3105
+ });
3106
+ var resolveTelemetry = (native, meta) => {
3107
+ const fb = (() => {
3108
+ try {
3109
+ return meta.transcriptFallback?.();
3110
+ } catch {
3111
+ return void 0;
3112
+ }
3113
+ })();
3114
+ const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
3115
+ return withMeta(
3116
+ {
3117
+ models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
3118
+ ...wallTurns,
3119
+ vendor_cost_usd: native.vendorCostUsd
3120
+ },
3121
+ meta
3122
+ );
3123
+ };
3124
+ var nativeTelemetry = (native, meta) => resolveTelemetry(
3125
+ {
1152
3126
  models: mapModelUsage(native.modelUsage),
1153
3127
  turns: native.num_turns,
1154
- duration_ms: native.duration_ms,
1155
- vendor_cost_usd: native.total_cost_usd ?? null,
1156
- ...meta.route ? { route: meta.route } : {},
1157
- ...meta.effort ? { effort: meta.effort } : {}
1158
- }) : left(
1159
- "internal error: the extraction ladder validated a candidate the registry then rejected"
1160
- );
3128
+ durationMs: native.duration_ms,
3129
+ vendorCostUsd: native.total_cost_usd ?? null
3130
+ },
3131
+ meta
3132
+ );
3133
+ var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
3134
+ var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath, seedUnrevised) => {
3135
+ const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
3136
+ switch (outcome.kind) {
3137
+ case "ok":
3138
+ return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
3139
+ case "telemetry-only": {
3140
+ const reason = seedUnrevised ? "the review agent did not write a review (its draft is still the pre-seeded sentinel)" : outcome.reason;
3141
+ return {
3142
+ schema_version: DEFAULT_SCHEMA_VERSION,
3143
+ findings: incompleteFindings(`### \u26A0\uFE0F Review did not complete
3144
+
3145
+ ${reason}`),
3146
+ incomplete: true,
3147
+ ...telemetry
3148
+ };
3149
+ }
3150
+ }
1161
3151
  };
1162
3152
  var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1163
3153
  switch (adapterName) {
1164
3154
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
1165
3155
  case "claude-code": {
3156
+ if (native === void 0 || native === null)
3157
+ return right(
3158
+ buildEnvelope(
3159
+ absentTelemetry(meta),
3160
+ void 0,
3161
+ agentFilePath,
3162
+ meta.agentFileFallbackPath,
3163
+ meta.seedUnrevised === true
3164
+ )
3165
+ );
1166
3166
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
1167
- if (decoded._tag === "Left") {
3167
+ if (decoded._tag === "Left")
1168
3168
  return left("native envelope does not match the Claude Code output shape");
1169
- }
1170
- return adaptClaudeCode(decoded.right, agentFilePath, meta);
3169
+ return right(
3170
+ buildEnvelope(
3171
+ nativeTelemetry(decoded.right, meta),
3172
+ native,
3173
+ agentFilePath,
3174
+ meta.agentFileFallbackPath,
3175
+ meta.seedUnrevised === true
3176
+ )
3177
+ );
3178
+ }
3179
+ }
3180
+ };
3181
+
3182
+ // src/settings.ts
3183
+ var composeReviewSettings = (opts) => {
3184
+ const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
3185
+ return {
3186
+ hooks: {
3187
+ Stop: [
3188
+ { hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
3189
+ ],
3190
+ PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
3191
+ PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
1171
3192
  }
3193
+ };
3194
+ };
3195
+
3196
+ // src/sandbox.ts
3197
+ var GITHUB_HOSTS = ["api.github.com", "github.com"];
3198
+ var deriveModelHost = (apiBaseUrl) => {
3199
+ const host = URL.canParse(apiBaseUrl) ? new URL(apiBaseUrl).hostname : "";
3200
+ if (host === "") {
3201
+ throw new Error(
3202
+ `could not derive the model host from api_base_url ${JSON.stringify(apiBaseUrl)} \u2014 expected a URL like https://api.deepseek.com/anthropic`
3203
+ );
1172
3204
  }
3205
+ return host;
3206
+ };
3207
+ var KNOWN_MODEL_HOST_SUFFIXES = ["anthropic.com", "deepseek.com"];
3208
+ var isKnownModelHost = (host, declared = []) => {
3209
+ const normalize = (h) => h.replace(/\.$/, "").toLowerCase();
3210
+ const target = normalize(host);
3211
+ return declared.some((d) => normalize(d) === target) || KNOWN_MODEL_HOST_SUFFIXES.some((suffix) => target.endsWith(`.${suffix}`));
3212
+ };
3213
+ var parseExtraEndpoints = (extra) => extra.split(/\s+/).map((token) => token.replace(/:\d+$/, "")).filter((token) => token.length > 0);
3214
+ var buildSandboxConfig = (opts) => ({
3215
+ network: {
3216
+ allowedDomains: [
3217
+ .../* @__PURE__ */ new Set([
3218
+ deriveModelHost(opts.apiBaseUrl),
3219
+ ...GITHUB_HOSTS,
3220
+ ...parseExtraEndpoints(opts.extra ?? "")
3221
+ ])
3222
+ ],
3223
+ deniedDomains: []
3224
+ },
3225
+ filesystem: { allowRead: [], denyRead: [], allowWrite: ["/"], denyWrite: [] }
3226
+ });
3227
+
3228
+ // src/scope.ts
3229
+ var SCOPE_SEPARATOR_RE = /[\s,;]+/;
3230
+ var parseScope = (raw) => {
3231
+ const trimmed = raw?.trim();
3232
+ if (trimmed === void 0 || trimmed === "") return { kind: "absent" };
3233
+ if (UNSAFE_IN_SUMMARY.test(trimmed)) {
3234
+ return {
3235
+ kind: "invalid",
3236
+ reason: 'scope contains a character (newline, carriage return, backtick, "<", ">", or "|") that would corrupt the review prompt \u2014 use plain language names/tags'
3237
+ };
3238
+ }
3239
+ const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t7) => t7 !== "")));
3240
+ return languages.length === 0 ? { kind: "absent" } : { kind: "ok", languages };
1173
3241
  };
1174
3242
 
1175
3243
  // src/index.ts
1176
3244
  var readJSON = (path) => {
1177
3245
  try {
1178
- return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
3246
+ return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
3247
+ } catch (err) {
3248
+ return fail(`Cannot read ${path}: ${errMsg(err)}`);
3249
+ }
3250
+ };
3251
+ var fail = (msg) => {
3252
+ process.stderr.write(`${msg}
3253
+ `);
3254
+ process.exit(1);
3255
+ };
3256
+ var readJSONOrAbsent = (path) => {
3257
+ const read = (() => {
3258
+ try {
3259
+ return { text: readFileSync(resolve$1(path), "utf-8") };
3260
+ } catch (err) {
3261
+ return { error: errMsg(err) };
3262
+ }
3263
+ })();
3264
+ if ("error" in read) {
3265
+ process.stderr.write(
3266
+ `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry
3267
+ `
3268
+ );
3269
+ return void 0;
3270
+ }
3271
+ if (read.text.trim() === "") {
3272
+ process.stderr.write(
3273
+ `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry
3274
+ `
3275
+ );
3276
+ return void 0;
3277
+ }
3278
+ try {
3279
+ return JSON.parse(read.text);
1179
3280
  } catch (err) {
1180
- fail(`Cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
1181
- throw new Error("unreachable", { cause: err });
3281
+ process.stderr.write(
3282
+ `code-review: native envelope ${path} is not valid JSON (${errMsg(err)}) \u2014 proceeding with no native telemetry
3283
+ `
3284
+ );
3285
+ return void 0;
1182
3286
  }
1183
3287
  };
1184
- var fail = (msg) => {
1185
- process.stderr.write(`${msg}
1186
- `);
1187
- process.exit(1);
3288
+ var readSandboxConfigForNotice = (path) => {
3289
+ const text = readFileOrNull(resolve$1(path));
3290
+ if (text === null) return void 0;
3291
+ const parsed = tryParseJson(text);
3292
+ if (!parsed.ok) {
3293
+ process.stderr.write(
3294
+ `::warning::${annotationSafe(`code-review notice: ${path} is present but not valid JSON \u2014 omitting the agent allowlist from the notice`)}
3295
+ `
3296
+ );
3297
+ return void 0;
3298
+ }
3299
+ return parsed.value;
3300
+ };
3301
+ var readStdinJSON = () => {
3302
+ if (process.stdin.isTTY) return null;
3303
+ const raw = (() => {
3304
+ try {
3305
+ return readFileSync(0, "utf-8");
3306
+ } catch {
3307
+ return "";
3308
+ }
3309
+ })();
3310
+ if (raw.trim() === "") return null;
3311
+ const parsed = tryParseJson(raw);
3312
+ return parsed.ok ? parsed.value : null;
1188
3313
  };
1189
3314
  var decode = (either, label) => {
1190
3315
  try {
1191
3316
  return unsafeUnwrap(either);
1192
3317
  } catch {
1193
- fail(`${label} does not match expected shape`);
3318
+ return fail(`${label} does not match expected shape`);
1194
3319
  }
1195
- throw new Error("unreachable");
1196
3320
  };
1197
3321
  var unwrapAdapt = (either) => {
1198
3322
  try {
1199
3323
  if (either._tag === "Left") throw new Error(either.left);
1200
3324
  return either.right;
1201
3325
  } catch (err) {
1202
- fail(err instanceof Error ? err.message : String(err));
3326
+ return fail(errMsg(err));
1203
3327
  }
1204
- throw new Error("unreachable");
3328
+ };
3329
+ var transcriptFallbackFrom = (path) => {
3330
+ const tree = readTranscriptTree(resolve$1(path));
3331
+ if (tree.missing)
3332
+ process.stderr.write(
3333
+ `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
3334
+ `
3335
+ );
3336
+ const usage = sumTranscriptUsage(tree.entries);
3337
+ return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
1205
3338
  };
1206
3339
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1207
3340
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1208
3341
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
1209
- var resolvePricesPath = (pricesArg) => {
1210
- if (pricesArg) return resolve$1(pricesArg);
3342
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
3343
+ var resolvePrices = (pricesArg) => {
3344
+ if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
1211
3345
  process.stderr.write(
1212
- "code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
3346
+ "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
1213
3347
  );
1214
- return bundledPath("schema", "prices.example.json");
3348
+ return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
1215
3349
  };
1216
3350
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
3351
+ var CONVERGENCE_THRESHOLD_DESCRIPTION = "Advisory convergence tolerance: the weighted-severity score (critical 4 \xB7 major 2 \xB7 minor 1 \xB7 nit 0) at or below which the sticky reads as converged (default: 1 \u2014 unlimited nits plus at most one minor)";
1217
3352
  var renderCmd = defineCommand({
1218
3353
  meta: {
1219
3354
  name: "render",
@@ -1253,27 +3388,38 @@ var renderCmd = defineCommand({
1253
3388
  "test-report": {
1254
3389
  type: "string",
1255
3390
  description: TEST_REPORT_DESCRIPTION
3391
+ },
3392
+ "convergence-threshold": {
3393
+ type: "string",
3394
+ description: CONVERGENCE_THRESHOLD_DESCRIPTION
1256
3395
  }
1257
3396
  },
1258
3397
  run: async ({ args }) => {
1259
3398
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1260
3399
  const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
1261
3400
  const templatePath = resolveTemplatePath(args.template);
1262
- const pricesPath = resolvePricesPath(args.prices);
1263
- const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
3401
+ const priceResolution = resolvePrices(args.prices);
3402
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
1264
3403
  const template = readFileSync(templatePath, "utf-8");
1265
3404
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
1266
- const output = render({
3405
+ const route = args.route || envelope.route || null;
3406
+ const isRound = isConvergenceRound(route, envelope.incomplete === true || isIncompleteFindings(findings)) && isReviewVerdict(findings.verdict);
3407
+ const counts = computeRoundCounts(findings);
3408
+ const output2 = render({
1267
3409
  findings,
1268
3410
  envelope,
1269
3411
  prices,
3412
+ pricesProvided: priceResolution.kind === "provided",
1270
3413
  template,
1271
3414
  reviewedSha: args["reviewed-sha"],
1272
3415
  route: args.route,
1273
3416
  effort: args.effort,
1274
- testReport
3417
+ testReport,
3418
+ rounds: isRound ? [counts] : [],
3419
+ convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
3420
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1275
3421
  });
1276
- process.stdout.write(output);
3422
+ process.stdout.write(output2);
1277
3423
  }
1278
3424
  });
1279
3425
  var inlineCmd = defineCommand({
@@ -1294,14 +3440,17 @@ var inlineCmd = defineCommand({
1294
3440
  },
1295
3441
  template: {
1296
3442
  type: "string",
1297
- description: "Path to inline comment Eta template (default: built-in format)"
3443
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1298
3444
  }
1299
3445
  },
1300
3446
  run: async ({ args }) => {
1301
3447
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1302
3448
  const diff = readFileSync(resolve$1(args.diff), "utf-8");
1303
- const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
1304
- const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
3449
+ const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
3450
+ const { comments, strays } = buildInlineComments(findings.findings, diff, {
3451
+ inlineTemplate,
3452
+ findings
3453
+ });
1305
3454
  process.stdout.write(
1306
3455
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1307
3456
  );
@@ -1331,8 +3480,306 @@ var costCmd = defineCommand({
1331
3480
  process.stdout.write(JSON.stringify(report, null, 2));
1332
3481
  }
1333
3482
  });
1334
- var declaredSchemaVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
1335
- var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
3483
+ var checkCostCmd = defineCommand({
3484
+ meta: {
3485
+ name: "check-cost",
3486
+ description: "Sum real USD spend from a Claude Code transcript tree (main + subagents) against a price map"
3487
+ },
3488
+ args: {
3489
+ transcript: {
3490
+ type: "positional",
3491
+ description: "Path to the session transcript JSONL (the hook's transcript_path)",
3492
+ required: true
3493
+ },
3494
+ prices: {
3495
+ type: "string",
3496
+ description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
3497
+ }
3498
+ },
3499
+ run: async ({ args }) => {
3500
+ const tree = readTranscriptTree(resolve$1(args.transcript));
3501
+ if (tree.missing) {
3502
+ process.stderr.write(
3503
+ `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
3504
+ `
3505
+ );
3506
+ }
3507
+ const usage = sumTranscriptUsage(tree.entries);
3508
+ const priceResolution = resolvePrices(args.prices);
3509
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
3510
+ const report = computeCost(usage.models, prices);
3511
+ process.stdout.write(
3512
+ `${JSON.stringify(
3513
+ {
3514
+ ...report,
3515
+ turns: usage.turns,
3516
+ durationMs: usage.durationMs,
3517
+ transcripts: tree.files,
3518
+ pricesProvided: priceResolution.kind === "provided"
3519
+ },
3520
+ null,
3521
+ 2
3522
+ )}
3523
+ `
3524
+ );
3525
+ }
3526
+ });
3527
+ var tryReadPrices = (path) => {
3528
+ try {
3529
+ const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
3530
+ return decoded._tag === "Right" ? decoded.right : null;
3531
+ } catch {
3532
+ return null;
3533
+ }
3534
+ };
3535
+ var parseBudgetUsd = (raw) => {
3536
+ if (raw === void 0) return null;
3537
+ const n = Number.parseFloat(raw);
3538
+ return Number.isFinite(n) && n >= 0 ? n : null;
3539
+ };
3540
+ var parseConvergenceThreshold = (raw) => {
3541
+ const trimmed = raw?.trim();
3542
+ if (trimmed === void 0 || trimmed === "") return void 0;
3543
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) {
3544
+ fail(`--convergence-threshold must be a non-negative number; got "${trimmed}"`);
3545
+ }
3546
+ const n = Number.parseFloat(trimmed);
3547
+ if (!Number.isFinite(n)) {
3548
+ fail(`--convergence-threshold is too large to be a meaningful tolerance; got "${trimmed}"`);
3549
+ }
3550
+ return n;
3551
+ };
3552
+ var transcriptPathOf = (input) => {
3553
+ const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
3554
+ return typeof tp === "string" ? tp : void 0;
3555
+ };
3556
+ var statMtimeMsOrNull = (path) => {
3557
+ try {
3558
+ return statSync(path).mtimeMs;
3559
+ } catch {
3560
+ return null;
3561
+ }
3562
+ };
3563
+ var statSizeOrNull = (path) => {
3564
+ try {
3565
+ return statSync(path).size;
3566
+ } catch {
3567
+ return null;
3568
+ }
3569
+ };
3570
+ var snapshotIfValid = (draftPath) => {
3571
+ try {
3572
+ const snapPath = lastValidPath(draftPath);
3573
+ const draftMtime = statMtimeMsOrNull(draftPath);
3574
+ if (draftMtime === null) return;
3575
+ const snapMtime = statMtimeMsOrNull(snapPath);
3576
+ if (snapMtime !== null && draftMtime <= snapMtime) return;
3577
+ if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
3578
+ copyFileSync(draftPath, snapPath);
3579
+ } catch (err) {
3580
+ process.stderr.write(
3581
+ `code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
3582
+ `
3583
+ );
3584
+ }
3585
+ };
3586
+ var budgetHookCmd = defineCommand({
3587
+ meta: {
3588
+ name: "budget-hook",
3589
+ 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."
3590
+ },
3591
+ args: {
3592
+ draft: {
3593
+ type: "string",
3594
+ description: "Path to the findings draft that is the sole permitted write target under forced convergence",
3595
+ required: true
3596
+ },
3597
+ "budget-usd": {
3598
+ type: "string",
3599
+ description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
3600
+ },
3601
+ wall: {
3602
+ type: "string",
3603
+ description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
3604
+ },
3605
+ prices: {
3606
+ type: "string",
3607
+ description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
3608
+ },
3609
+ "reserve-frac": {
3610
+ type: "string",
3611
+ 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)"
3612
+ },
3613
+ "reserve-growth": {
3614
+ type: "string",
3615
+ 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)"
3616
+ },
3617
+ "reserve-usd": {
3618
+ type: "string",
3619
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
3620
+ },
3621
+ "reserve-wall": {
3622
+ type: "string",
3623
+ description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
3624
+ }
3625
+ },
3626
+ run: async ({ args }) => {
3627
+ try {
3628
+ const draftPath = resolve$1(args.draft);
3629
+ const input = readStdinJSON();
3630
+ const transcriptPath = transcriptPathOf(input);
3631
+ const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
3632
+ const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
3633
+ const prices = args.prices ? tryReadPrices(args.prices) : null;
3634
+ const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
3635
+ const wallMs = args.wall ? parseWallMs(args.wall) : null;
3636
+ const output2 = evaluateBudgetHook(input, {
3637
+ spentUsd,
3638
+ budgetUsd: parseBudgetUsd(args["budget-usd"]),
3639
+ elapsedMs: anchoredElapsedMs({
3640
+ deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
3641
+ wallMs,
3642
+ firstTsMs: usage?.firstTsMs ?? null,
3643
+ nowMs: Date.now()
3644
+ }),
3645
+ wallMs,
3646
+ reserve: {
3647
+ frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
3648
+ growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
3649
+ flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
3650
+ flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
3651
+ },
3652
+ draftPath,
3653
+ // Lazy: the spawn gate is the only consumer, and the hook fires on every tool event.
3654
+ mainDraftWritten: () => mainHasWrittenDraft(readFileOrNull(draftPath))
3655
+ });
3656
+ if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
3657
+ snapshotIfValid(draftPath);
3658
+ process.stdout.write(`${JSON.stringify(output2)}
3659
+ `);
3660
+ } catch (err) {
3661
+ process.stderr.write(`code-review budget-hook: degrading to no-op \u2014 ${errMsg(err)}
3662
+ `);
3663
+ process.stdout.write("{}\n");
3664
+ }
3665
+ }
3666
+ });
3667
+ var printSettingsCmd = defineCommand({
3668
+ meta: {
3669
+ name: "print-settings",
3670
+ 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"
3671
+ },
3672
+ args: {
3673
+ draft: {
3674
+ type: "string",
3675
+ description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
3676
+ required: true
3677
+ },
3678
+ kind: {
3679
+ type: "string",
3680
+ description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
3681
+ },
3682
+ schema: {
3683
+ type: "string",
3684
+ description: "Path to a schema file for the Stop gate (wins over --kind)"
3685
+ },
3686
+ "schema-version": {
3687
+ type: "string",
3688
+ description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
3689
+ },
3690
+ "max-nudges": {
3691
+ type: "string",
3692
+ description: "Stop-gate nudge budget before relenting (default: 5)"
3693
+ },
3694
+ counter: {
3695
+ type: "string",
3696
+ description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
3697
+ },
3698
+ "budget-usd": {
3699
+ type: "string",
3700
+ description: "Dollar budget the cost axis is measured against (needs --prices)"
3701
+ },
3702
+ wall: {
3703
+ type: "string",
3704
+ description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
3705
+ },
3706
+ prices: {
3707
+ type: "string",
3708
+ description: "Price map JSON to recompute real spend from the transcript"
3709
+ },
3710
+ "reserve-frac": {
3711
+ type: "string",
3712
+ description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
3713
+ },
3714
+ "reserve-growth": {
3715
+ type: "string",
3716
+ description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
3717
+ },
3718
+ "reserve-usd": {
3719
+ type: "string",
3720
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
3721
+ },
3722
+ "reserve-wall": {
3723
+ type: "string",
3724
+ description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
3725
+ }
3726
+ },
3727
+ run: async ({ args }) => {
3728
+ if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
3729
+ fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
3730
+ const settings = composeReviewSettings({
3731
+ draftPath: resolve$1(args.draft),
3732
+ stop: {
3733
+ kind: args.kind,
3734
+ schema: args.schema,
3735
+ schemaVersion: args["schema-version"],
3736
+ maxNudges: args["max-nudges"],
3737
+ counter: args.counter
3738
+ },
3739
+ budget: {
3740
+ budgetUsd: args["budget-usd"],
3741
+ wall: args.wall,
3742
+ prices: args.prices,
3743
+ reserveFrac: args["reserve-frac"],
3744
+ reserveGrowth: args["reserve-growth"],
3745
+ reserveUsd: args["reserve-usd"],
3746
+ reserveWall: args["reserve-wall"]
3747
+ }
3748
+ });
3749
+ process.stdout.write(`${JSON.stringify(settings)}
3750
+ `);
3751
+ }
3752
+ });
3753
+ var deadlineCmd = defineCommand({
3754
+ meta: {
3755
+ name: "deadline",
3756
+ 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"
3757
+ },
3758
+ args: {
3759
+ wall: {
3760
+ type: "string",
3761
+ description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
3762
+ required: true
3763
+ }
3764
+ },
3765
+ run: async ({ args }) => {
3766
+ const wallMs = parseWallMs(args.wall);
3767
+ if (wallMs === null) {
3768
+ fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
3769
+ } else {
3770
+ process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
3771
+ `);
3772
+ }
3773
+ }
3774
+ });
3775
+ var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
3776
+ var printableSchema = (schemaPath) => {
3777
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
3778
+ const enforcementSchema = Object.fromEntries(
3779
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
3780
+ );
3781
+ return JSON.stringify(enforcementSchema, null, 2);
3782
+ };
1336
3783
  var validateCmd = defineCommand({
1337
3784
  meta: {
1338
3785
  name: "validate",
@@ -1355,6 +3802,10 @@ var validateCmd = defineCommand({
1355
3802
  "schema-version": {
1356
3803
  type: "string",
1357
3804
  description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
3805
+ },
3806
+ explain: {
3807
+ type: "boolean",
3808
+ 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"
1358
3809
  }
1359
3810
  },
1360
3811
  run: async ({ args }) => {
@@ -1368,14 +3819,132 @@ var validateCmd = defineCommand({
1368
3819
  process.stderr.write("\u274C invalid\n");
1369
3820
  for (const e of errors) process.stderr.write(` - ${e}
1370
3821
  `);
3822
+ if (args.explain) {
3823
+ process.stderr.write(
3824
+ `
3825
+ The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
3826
+ ${printableSchema(schemaPath)}
3827
+ `
3828
+ );
3829
+ }
1371
3830
  process.exit(1);
1372
3831
  }
1373
3832
  }
1374
3833
  });
3834
+ var seedDraftCmd = defineCommand({
3835
+ meta: {
3836
+ name: "seed-draft",
3837
+ description: "Initialize $DRAFT before the review runs: write a NON-REVIEW SENTINEL (no recovery path can validate it as a review), and deliver the decoded findings of a prior review OUT-OF-BAND to a read-only context file beside the draft when one exists and still validates (incremental re-review). Skips a prior that never completed (an error-verdict notice) or that a CI-fix mechanic pass produced \u2014 the seed chain is route-aware. Prints the mode to stdout (prior-same|prior-new when prior context was delivered, 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 sentinel write failed) and always exits 0"
3838
+ },
3839
+ args: {
3840
+ prior: {
3841
+ type: "string",
3842
+ description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and delivered as re-review context when it validates against the schema"
3843
+ },
3844
+ "head-sha": {
3845
+ type: "string",
3846
+ 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"
3847
+ },
3848
+ out: {
3849
+ type: "string",
3850
+ description: "Path to write the sentinel $DRAFT to (an absolute path outside the worktree)",
3851
+ required: true
3852
+ },
3853
+ kind: {
3854
+ type: "string",
3855
+ description: "Schema kind to validate the prior findings against (default: findings)"
3856
+ },
3857
+ schema: {
3858
+ type: "string",
3859
+ description: "Path to a schema file (wins over --kind/--schema-version)"
3860
+ },
3861
+ "schema-version": {
3862
+ type: "string",
3863
+ description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then delivers no context)"
3864
+ }
3865
+ },
3866
+ run: async ({ args }) => {
3867
+ const outPath = resolve$1(args.out);
3868
+ const kindArg = args.kind || "findings";
3869
+ const kind = isSchemaKind(kindArg) ? kindArg : "findings";
3870
+ if (kind !== kindArg) {
3871
+ process.stderr.write(
3872
+ `Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
3873
+ `
3874
+ );
3875
+ }
3876
+ const writeSentinel = () => {
3877
+ try {
3878
+ writeFileSync(outPath, SEED_SENTINEL);
3879
+ process.stderr.write(
3880
+ `Seeded ${outPath} with the non-review sentinel \u2014 the agent must replace it with its review
3881
+ `
3882
+ );
3883
+ return true;
3884
+ } catch (err) {
3885
+ process.stderr.write(
3886
+ `Warning: could not write the seed sentinel to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
3887
+ `
3888
+ );
3889
+ return false;
3890
+ }
3891
+ };
3892
+ const priorBody = (() => {
3893
+ if (!args.prior) return null;
3894
+ const raw = (() => {
3895
+ try {
3896
+ return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
3897
+ } catch {
3898
+ return null;
3899
+ }
3900
+ })();
3901
+ return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
3902
+ })();
3903
+ const priorFindings = priorBody === null ? null : stripSurfaceFields(parseFindingsMarker(priorBody));
3904
+ const seededFromPrior = priorFindings === null ? false : (() => {
3905
+ try {
3906
+ const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
3907
+ if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
3908
+ const resolution = resolve("findings", priorFindings);
3909
+ if (resolution.kind !== "ok") return false;
3910
+ if (isIncompleteFindings(resolution.value)) return false;
3911
+ if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
3912
+ writeFileSync(outPath, SEED_SENTINEL);
3913
+ writeFileSync(
3914
+ priorContextPath(outPath),
3915
+ `${JSON.stringify(priorFindings, null, 2)}
3916
+ `
3917
+ );
3918
+ const count = resolution.value.findings.length;
3919
+ process.stderr.write(
3920
+ `Seeded ${outPath} with the sentinel and wrote the prior review (${String(count)} finding(s)) to ${priorContextPath(outPath)} as context
3921
+ `
3922
+ );
3923
+ return true;
3924
+ } catch (err) {
3925
+ process.stderr.write(
3926
+ `Warning: could not seed from the prior review (${errMsg(err)}) \u2014 seeding the sentinel only
3927
+ `
3928
+ );
3929
+ return false;
3930
+ }
3931
+ })();
3932
+ const mode = (() => {
3933
+ if (seededFromPrior) {
3934
+ const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
3935
+ return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
3936
+ }
3937
+ if (!writeSentinel()) return "none";
3938
+ return priorBody === null ? "empty" : "empty-had-prior";
3939
+ })();
3940
+ process.stdout.write(`${mode}
3941
+ `);
3942
+ }
3943
+ });
1375
3944
  var adaptCmd = defineCommand({
1376
3945
  meta: {
1377
3946
  name: "adapt",
1378
- description: "Map a native agent-CLI result envelope onto the abstract SPEC \xA76.1 envelope"
3947
+ description: "Map a native agent-CLI result envelope onto the abstract SPEC envelope"
1379
3948
  },
1380
3949
  args: {
1381
3950
  native: {
@@ -1392,6 +3961,10 @@ var adaptCmd = defineCommand({
1392
3961
  type: "string",
1393
3962
  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)"
1394
3963
  },
3964
+ "agent-file-fallback": {
3965
+ type: "string",
3966
+ 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"
3967
+ },
1395
3968
  route: {
1396
3969
  type: "string",
1397
3970
  description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
@@ -1399,25 +3972,74 @@ var adaptCmd = defineCommand({
1399
3972
  effort: {
1400
3973
  type: "string",
1401
3974
  description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
3975
+ },
3976
+ transcript: {
3977
+ type: "string",
3978
+ 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)"
1402
3979
  }
1403
3980
  },
1404
3981
  run: async ({ args }) => {
3982
+ const agentFile = args["agent-file"];
3983
+ const agentFileSize = agentFile ? statSizeOrNull(agentFile) : null;
3984
+ const seedUnrevised = agentFileSize !== null && agentFileSize <= Buffer.byteLength(SEED_SENTINEL) + 2 && isSeedSentinel(readFileOrNull(agentFile));
1405
3985
  const envelope = unwrapAdapt(
1406
- adapt(requireAdapterName(args.adapter), readJSON(args.native), args["agent-file"], {
3986
+ adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), agentFile, {
1407
3987
  route: args.route,
1408
- effort: args.effort
3988
+ effort: args.effort,
3989
+ agentFileFallbackPath: args["agent-file-fallback"],
3990
+ seedUnrevised,
3991
+ ...args.transcript ? {
3992
+ transcriptFallback: () => transcriptFallbackFrom(args.transcript)
3993
+ } : {}
1409
3994
  })
1410
3995
  );
1411
3996
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}
1412
3997
  `);
1413
3998
  }
1414
3999
  });
4000
+ var noticeCmd = defineCommand({
4001
+ meta: {
4002
+ name: "notice",
4003
+ description: "Emit an abstract envelope for a run that produced no completed review (security block, triage error, setup failure, checkout failure, or empty run) \u2014 flagged incomplete so the commenter renders it honestly and won't bury a real review"
4004
+ },
4005
+ args: {
4006
+ kind: {
4007
+ type: "positional",
4008
+ description: `One of: ${NOTICE_KINDS.join(", ")} (an unrecognized kind renders a generic incomplete notice rather than failing \u2014 the pinned CLI is older than the workflow)`,
4009
+ required: true
4010
+ },
4011
+ reasons: {
4012
+ type: "string",
4013
+ description: "security-blocked / triage-error only: the triage's fail-closed reason string (empty/omitted \u21D2 the no-reason wording)"
4014
+ },
4015
+ "sandbox-config": {
4016
+ type: "string",
4017
+ description: "no-output / triage-error only: path to the agent's sandbox-runtime settings (sandbox.json); its network.allowedDomains is named in the notice so an egress-blocked review self-diagnoses. Missing or unreadable \u21D2 the allowlist is omitted (an early failure runs before the jail is set up)."
4018
+ }
4019
+ },
4020
+ // An unrecognized kind degrades to a generic incomplete notice instead of exiting non-zero: a
4021
+ // `notice <kind>` call under the workflow's `set -euo pipefail` must never crash the assemble
4022
+ // step into posting nothing, and an unknown kind almost always means version skew, not a typo.
4023
+ run: ({ args }) => {
4024
+ if (!isNoticeKind(args.kind)) {
4025
+ process.stderr.write(
4026
+ `::warning::code-review notice: unrecognized kind "${annotationSafe(args.kind)}" \u2014 the pinned CLI is older than the workflow calling it; rendering a generic incomplete notice
4027
+ `
4028
+ );
4029
+ process.stdout.write(`${JSON.stringify(buildUnknownNoticeEnvelope(args.kind), null, 2)}
4030
+ `);
4031
+ return;
4032
+ }
4033
+ const namesAllowlist = args.kind === "no-output" || args.kind === "triage-error";
4034
+ const agentAllowlist = namesAllowlist && args["sandbox-config"] ? parseAgentAllowlist(readSandboxConfigForNotice(args["sandbox-config"])) : [];
4035
+ process.stdout.write(
4036
+ `${JSON.stringify(buildNoticeEnvelope(args.kind, args.reasons, agentAllowlist), null, 2)}
4037
+ `
4038
+ );
4039
+ }
4040
+ });
1415
4041
  var isExtractSchemaKind = (s) => s === "findings" || s === "triage";
1416
- var requireExtractSchemaKind = (name) => {
1417
- if (isExtractSchemaKind(name)) return name;
1418
- fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
1419
- throw new Error("unreachable");
1420
- };
4042
+ var requireExtractSchemaKind = (name) => isExtractSchemaKind(name) ? name : fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
1421
4043
  var failClosedTriage = (outcome) => ({
1422
4044
  safe: false,
1423
4045
  reasons: describeLadderFailure(outcome)
@@ -1463,32 +4085,87 @@ var extractCmd = defineCommand({
1463
4085
  ${ladderFailureDiagnostics(input)}
1464
4086
  `);
1465
4087
  }
1466
- if (kind === "triage") {
1467
- process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
4088
+ if (kind === "triage") {
4089
+ process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
4090
+ `);
4091
+ return;
4092
+ }
4093
+ fail(describeLadderFailure(outcome));
4094
+ }
4095
+ });
4096
+ var withoutPatch = (finding) => {
4097
+ const copy = { ...finding };
4098
+ delete copy.patch;
4099
+ return copy;
4100
+ };
4101
+ var readFileLines = (path) => {
4102
+ try {
4103
+ const rawLines = readFileSync(path, "utf-8").split("\n");
4104
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
4105
+ } catch {
4106
+ return null;
4107
+ }
4108
+ };
4109
+ var validateFinding = (finding, repoRoot) => {
4110
+ if (finding.patch === void 0) return finding;
4111
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
4112
+ if (lines === null) {
4113
+ process.stderr.write(
4114
+ `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
4115
+ `
4116
+ );
4117
+ return withoutPatch(finding);
4118
+ }
4119
+ const result = validatePatch(finding.patch, lines);
4120
+ switch (result.kind) {
4121
+ case "anchored":
4122
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
4123
+ case "keep":
4124
+ return finding;
4125
+ case "drop":
4126
+ process.stderr.write(
4127
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
4128
+ `
4129
+ );
4130
+ return withoutPatch(finding);
4131
+ }
4132
+ };
4133
+ var validatePatchesCmd = defineCommand({
4134
+ meta: {
4135
+ name: "validate-patches",
4136
+ 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"
4137
+ },
4138
+ args: {
4139
+ findings: {
4140
+ type: "positional",
4141
+ description: "Path to findings JSON",
4142
+ required: true
4143
+ },
4144
+ "repo-root": {
4145
+ type: "string",
4146
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
4147
+ }
4148
+ },
4149
+ run: async ({ args }) => {
4150
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
4151
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
4152
+ const validated = {
4153
+ ...findings,
4154
+ findings: findings.findings.map((f) => validateFinding(f, repoRoot))
4155
+ };
4156
+ process.stdout.write(`${JSON.stringify(validated, null, 2)}
1468
4157
  `);
1469
- return;
1470
- }
1471
- fail(describeLadderFailure(outcome));
1472
4158
  }
1473
4159
  });
1474
- var requireAdapterName = (name) => {
1475
- if (isAdapterName(name)) return name;
1476
- fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
1477
- throw new Error("unreachable");
1478
- };
4160
+ var requireAdapterName = (name) => isAdapterName(name) ? name : fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
1479
4161
  var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
1480
- var requireSchemaKind = (name) => {
1481
- if (isSchemaKind(name)) return name;
1482
- fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
1483
- throw new Error("unreachable");
1484
- };
4162
+ var requireSchemaKind = (name) => isSchemaKind(name) ? name : fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
1485
4163
  var requireSchemaPath = (kind, version) => {
1486
4164
  try {
1487
4165
  return schemaPathFor(kind, version);
1488
4166
  } catch (err) {
1489
- fail(err instanceof Error ? err.message : String(err));
4167
+ return fail(errMsg(err));
1490
4168
  }
1491
- throw new Error("unreachable");
1492
4169
  };
1493
4170
  var printSchemaCmd = defineCommand({
1494
4171
  meta: {
@@ -1509,18 +4186,105 @@ var printSchemaCmd = defineCommand({
1509
4186
  run: async ({ args }) => {
1510
4187
  const schemaKind = requireSchemaKind(args.name);
1511
4188
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
1512
- const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
1513
- const enforcementSchema = Object.fromEntries(
1514
- Object.entries(schema).filter(([key2]) => key2 !== "$schema")
4189
+ process.stdout.write(`${printableSchema(schemaPath)}
4190
+ `);
4191
+ }
4192
+ });
4193
+ var MAX_NUDGES_DEFAULT = 5;
4194
+ var drainStdin = () => {
4195
+ if (process.stdin.isTTY) return;
4196
+ try {
4197
+ readFileSync(0);
4198
+ } catch {
4199
+ }
4200
+ };
4201
+ var requireMaxNudges = (raw) => {
4202
+ if (raw === void 0) return MAX_NUDGES_DEFAULT;
4203
+ if (!/^\d+$/.test(raw)) {
4204
+ fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
4205
+ }
4206
+ const n = Number.parseInt(raw, 10);
4207
+ if (n < 1) {
4208
+ fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
4209
+ }
4210
+ return n;
4211
+ };
4212
+ var stopGateCmd = defineCommand({
4213
+ meta: {
4214
+ name: "stop-gate",
4215
+ 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."
4216
+ },
4217
+ args: {
4218
+ draft: {
4219
+ type: "string",
4220
+ description: "Path to the findings document the agent must produce and keep valid",
4221
+ required: true
4222
+ },
4223
+ kind: {
4224
+ type: "string",
4225
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
4226
+ },
4227
+ schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
4228
+ "schema-version": {
4229
+ type: "string",
4230
+ description: "Schema major.minor to validate against (default: the draft's declared version)"
4231
+ },
4232
+ "max-nudges": {
4233
+ type: "string",
4234
+ description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
4235
+ },
4236
+ counter: {
4237
+ type: "string",
4238
+ description: "Path for the nudge counter (default: <draft>.nudges)"
4239
+ },
4240
+ "print-settings": {
4241
+ type: "boolean",
4242
+ description: "Print the Stop-hook settings JSON that wires this gate, then exit"
4243
+ }
4244
+ },
4245
+ run: async ({ args }) => {
4246
+ const draftPath = resolve$1(args.draft);
4247
+ if (args["print-settings"]) {
4248
+ const command = defaultHookCommand(draftPath, {
4249
+ kind: args.kind,
4250
+ schema: args.schema,
4251
+ schemaVersion: args["schema-version"],
4252
+ maxNudges: args["max-nudges"],
4253
+ counter: args.counter
4254
+ });
4255
+ process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
4256
+ `);
4257
+ return;
4258
+ }
4259
+ drainStdin();
4260
+ const kind = requireSchemaKind(args.kind || "findings");
4261
+ const maxNudges = requireMaxNudges(args["max-nudges"]);
4262
+ const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
4263
+ const state = draftState(
4264
+ draftPath,
4265
+ (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
1515
4266
  );
1516
- process.stdout.write(`${JSON.stringify(enforcementSchema, null, 2)}
4267
+ const nudges = readNudges(counterPath);
4268
+ const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
4269
+ if (decision.kind === "block") {
4270
+ try {
4271
+ bumpNudges(counterPath, nudges);
4272
+ } catch (err) {
4273
+ process.stderr.write(
4274
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${errMsg(err)}
4275
+ `
4276
+ );
4277
+ return;
4278
+ }
4279
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
1517
4280
  `);
4281
+ }
1518
4282
  }
1519
4283
  });
1520
4284
  var gatherCmd = defineCommand({
1521
4285
  meta: {
1522
4286
  name: "gather",
1523
- 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"
4287
+ 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"
1524
4288
  },
1525
4289
  args: {
1526
4290
  repo: { type: "string", description: "Repository (owner/name)", required: true },
@@ -1533,6 +4297,11 @@ var gatherCmd = defineCommand({
1533
4297
  type: "string",
1534
4298
  description: "Head branch to disambiguate the PR when multiple share a commit"
1535
4299
  },
4300
+ "default-branch": {
4301
+ type: "string",
4302
+ 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",
4303
+ required: true
4304
+ },
1536
4305
  "run-id": {
1537
4306
  type: "string",
1538
4307
  description: "CI run id (from workflow_run.id); its failing jobs' logs are downloaded on failure",
@@ -1557,6 +4326,7 @@ var gatherCmd = defineCommand({
1557
4326
  repo: args.repo,
1558
4327
  headSha: args["head-sha"],
1559
4328
  headBranch: args["head-branch"],
4329
+ defaultBranch: args["default-branch"],
1560
4330
  runId: args["run-id"],
1561
4331
  conclusion: args.conclusion,
1562
4332
  botLogin: args["bot-login"] || "github-actions[bot]",
@@ -1601,7 +4371,7 @@ var postCmd = defineCommand({
1601
4371
  },
1602
4372
  "inline-template": {
1603
4373
  type: "string",
1604
- description: "Path to inline comment Eta template (default: built-in format)"
4374
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1605
4375
  },
1606
4376
  route: {
1607
4377
  type: "string",
@@ -1622,47 +4392,457 @@ var postCmd = defineCommand({
1622
4392
  "test-report": {
1623
4393
  type: "string",
1624
4394
  description: TEST_REPORT_DESCRIPTION
4395
+ },
4396
+ "run-url": {
4397
+ type: "string",
4398
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
4399
+ },
4400
+ "json-url": {
4401
+ type: "string",
4402
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
4403
+ },
4404
+ "convergence-threshold": {
4405
+ type: "string",
4406
+ description: CONVERGENCE_THRESHOLD_DESCRIPTION
1625
4407
  }
1626
4408
  },
1627
4409
  run: async ({ args }) => {
4410
+ const priceResolution = resolvePrices(args.prices);
1628
4411
  await post({
1629
4412
  repo: args.repo,
1630
4413
  headSha: args["head-sha"],
1631
4414
  botLogin: args["bot-login"] || "github-actions[bot]",
1632
4415
  findingsPath: args.findings,
1633
4416
  envelopePath: args.usage,
1634
- pricesPath: resolvePricesPath(args.prices),
4417
+ pricesPath: priceResolution.path,
4418
+ pricesProvided: priceResolution.kind === "provided",
1635
4419
  templatePath: resolveTemplatePath(args.template),
1636
- inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
4420
+ inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
1637
4421
  route: args.route,
1638
4422
  headBranch: args["head-branch"],
1639
4423
  effort: args.effort,
1640
- testReportPath: args["test-report"]
4424
+ testReportPath: args["test-report"],
4425
+ runUrl: args["run-url"],
4426
+ jsonUrl: args["json-url"],
4427
+ convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
4428
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
4429
+ });
4430
+ }
4431
+ });
4432
+ var announceCmd = defineCommand({
4433
+ meta: {
4434
+ name: "announce",
4435
+ 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."
4436
+ },
4437
+ args: {
4438
+ "head-sha": {
4439
+ type: "string",
4440
+ description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
4441
+ required: true
4442
+ },
4443
+ repo: {
4444
+ type: "string",
4445
+ description: "Repository (owner/name)",
4446
+ required: true
4447
+ },
4448
+ "run-url": {
4449
+ type: "string",
4450
+ description: "Workflow run URL the placeholder links to",
4451
+ required: true
4452
+ },
4453
+ "bot-login": {
4454
+ type: "string",
4455
+ description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
4456
+ },
4457
+ "head-branch": {
4458
+ type: "string",
4459
+ description: "Head branch to disambiguate the PR when multiple share a commit"
4460
+ }
4461
+ },
4462
+ run: async ({ args }) => {
4463
+ await announce({
4464
+ repo: args.repo,
4465
+ headSha: args["head-sha"],
4466
+ botLogin: args["bot-login"] || "github-actions[bot]",
4467
+ runUrl: args["run-url"],
4468
+ headBranch: args["head-branch"]
4469
+ }).catch(
4470
+ (err) => (
4471
+ // `::warning::` so a persistently broken announce shows up in the run's annotations, not only
4472
+ // buried in the step log — `announce` otherwise reports success while having posted nothing.
4473
+ process.stderr.write(
4474
+ `::warning::code-review announce: could not post the in-progress sticky (${annotationSafe(errMsg(err))}) \u2014 continuing (cosmetic)
4475
+ `
4476
+ )
4477
+ )
4478
+ );
4479
+ }
4480
+ });
4481
+ var isCheckIntent = (s) => s === "in_progress" || s === "neutral" || s === "failure" || s === "cancelled";
4482
+ var checkRunCmd = defineCommand({
4483
+ meta: {
4484
+ name: "check-run",
4485
+ description: "Upsert the native 'Code review' check-run on the head SHA \u2014 the attribution surface that appears in the PR's own checks list and (writing to the base repo) works for fork PRs too. `in_progress` at review start, `neutral` when the review completes, `failure` when it didn't, `cancelled` when a cancelled review settles its own check (matched by details_url, so it never touches a superseding run's check). Forward-only: `failure`/`cancelled` never overwrite a completed review."
4486
+ },
4487
+ args: {
4488
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
4489
+ "head-sha": {
4490
+ type: "string",
4491
+ description: "Head SHA the check-run is anchored to",
4492
+ required: true
4493
+ },
4494
+ status: {
4495
+ type: "positional",
4496
+ description: "One of: in_progress, neutral, failure, cancelled",
4497
+ required: true
4498
+ },
4499
+ "run-url": {
4500
+ type: "string",
4501
+ description: "Workflow run URL the check-run's details link to (also the ownership key for `cancelled`)",
4502
+ required: true
4503
+ }
4504
+ },
4505
+ run: async ({ args }) => {
4506
+ if (!isCheckIntent(args.status)) {
4507
+ process.stderr.write(
4508
+ `::warning::code-review check-run: unrecognized status "${annotationSafe(args.status)}" \u2014 expected in_progress, neutral, failure, or cancelled; skipping
4509
+ `
4510
+ );
4511
+ return;
4512
+ }
4513
+ await checkRun({
4514
+ repo: args.repo,
4515
+ headSha: args["head-sha"],
4516
+ intent: args.status,
4517
+ runUrl: args["run-url"]
4518
+ }).catch(
4519
+ (err) => process.stderr.write(
4520
+ `::warning::code-review check-run: could not upsert the check-run (${annotationSafe(errMsg(err))}) \u2014 continuing (attribution aid)
4521
+ `
4522
+ )
4523
+ );
4524
+ }
4525
+ });
4526
+ var reportIncompleteCmd = defineCommand({
4527
+ meta: {
4528
+ name: "report-incomplete",
4529
+ description: "Post (or update) the sticky when a review job hard-failed and posted nothing \u2014 an attributed 'did not complete' notice linking the run, telling the reader to re-request; with --cancelled, the informational 'superseded \u2014 no action needed' notice instead (issue #139). Never buries a completed review, and never overwrites a superseding run's live in-progress placeholder."
4530
+ },
4531
+ args: {
4532
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
4533
+ "head-sha": {
4534
+ type: "string",
4535
+ description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
4536
+ required: true
4537
+ },
4538
+ "run-url": {
4539
+ type: "string",
4540
+ description: "Workflow run URL the notice links to",
4541
+ required: true
4542
+ },
4543
+ "bot-login": {
4544
+ type: "string",
4545
+ description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
4546
+ },
4547
+ "head-branch": {
4548
+ type: "string",
4549
+ description: "Head branch to disambiguate the PR when multiple share a commit"
4550
+ },
4551
+ cancelled: {
4552
+ type: "boolean",
4553
+ description: "This run was CANCELLED before completing (typically superseded by a newer run on the same branch) \u2014 post the informational 'superseded' notice, not the failure notice (issue #139)"
4554
+ }
4555
+ },
4556
+ run: async ({ args }) => {
4557
+ await reportIncomplete({
4558
+ repo: args.repo,
4559
+ headSha: args["head-sha"],
4560
+ botLogin: args["bot-login"] || "github-actions[bot]",
4561
+ runUrl: args["run-url"],
4562
+ headBranch: args["head-branch"],
4563
+ cancelled: args.cancelled
4564
+ }).catch(
4565
+ (err) => process.stderr.write(
4566
+ `::warning::code-review report-incomplete: could not post the notice (${annotationSafe(errMsg(err))}) \u2014 continuing
4567
+ `
4568
+ )
4569
+ );
4570
+ }
4571
+ });
4572
+ var requireCeilingSec = (raw) => {
4573
+ if (raw === void 0) return null;
4574
+ const ms = parseWallMs(raw);
4575
+ if (ms === null)
4576
+ return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
4577
+ return Math.floor(ms / 1e3);
4578
+ };
4579
+ var requireCeilingUsd = (raw) => {
4580
+ if (raw === void 0) return null;
4581
+ const n = Number.parseFloat(raw.replace(/^\$/, ""));
4582
+ if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
4583
+ return n;
4584
+ };
4585
+ var requireMaxInstructions = (raw) => {
4586
+ if (raw === void 0) return 4e3;
4587
+ if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
4588
+ return Number.parseInt(raw, 10);
4589
+ };
4590
+ var requirePositiveInt = (raw, flag) => {
4591
+ const n = Number.parseInt(raw, 10);
4592
+ return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
4593
+ };
4594
+ var requireWallMs = (raw, flag, fallback) => {
4595
+ const ms = parseWallMs(raw || fallback);
4596
+ return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
4597
+ };
4598
+ var parseCommandCmd = defineCommand({
4599
+ meta: {
4600
+ name: "parse-command",
4601
+ 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.`
4602
+ },
4603
+ args: {
4604
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
4605
+ pr: {
4606
+ type: "string",
4607
+ description: "PR number (from github.event.issue.number \u2014 trusted event data)",
4608
+ required: true
4609
+ },
4610
+ "comment-body": {
4611
+ type: "string",
4612
+ 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)"
4613
+ },
4614
+ trigger: {
4615
+ type: "string",
4616
+ description: 'Trigger token the comment must begin with (default: "/code-review")'
4617
+ },
4618
+ "max-duration": {
4619
+ type: "string",
4620
+ 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"
4621
+ },
4622
+ "max-usd": {
4623
+ type: "string",
4624
+ description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
4625
+ },
4626
+ "max-instructions": {
4627
+ type: "string",
4628
+ description: "Max characters of free-form instructions kept (default: 4000)"
4629
+ }
4630
+ },
4631
+ run: async ({ args }) => {
4632
+ const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
4633
+ const result = await parseCommand({
4634
+ repo: args.repo,
4635
+ prNumber: requirePositiveInt(args.pr, "--pr"),
4636
+ body,
4637
+ options: {
4638
+ trigger: args.trigger || "/code-review",
4639
+ maxDurationSec: requireCeilingSec(args["max-duration"]),
4640
+ maxUsd: requireCeilingUsd(args["max-usd"]),
4641
+ maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
4642
+ }
4643
+ });
4644
+ if (result.kind === "skip") {
4645
+ process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
4646
+ `);
4647
+ process.stdout.write(renderCommandOutputs(result, "UNUSED"));
4648
+ return;
4649
+ }
4650
+ for (const note of result.args.notes)
4651
+ process.stderr.write(`code-review parse-command: ${note}
4652
+ `);
4653
+ const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
4654
+ process.stdout.write(renderCommandOutputs(result, delim));
4655
+ }
4656
+ });
4657
+ var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
4658
+ var reactCmd = defineCommand({
4659
+ meta: {
4660
+ name: "react",
4661
+ 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."
4662
+ },
4663
+ args: {
4664
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
4665
+ "comment-id": {
4666
+ type: "string",
4667
+ description: "Comment id to react on (github.event.comment.id)",
4668
+ required: true
4669
+ },
4670
+ add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
4671
+ remove: {
4672
+ type: "string",
4673
+ description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
4674
+ }
4675
+ },
4676
+ run: async ({ args }) => {
4677
+ const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
4678
+ const add = args.add ? requireReaction(args.add) : void 0;
4679
+ const remove = args.remove ? requireReaction(args.remove) : void 0;
4680
+ if (add === void 0 && remove === void 0)
4681
+ fail("react: nothing to do \u2014 pass --add and/or --remove");
4682
+ await react({ repo: args.repo, commentId, add, remove }).catch(
4683
+ (err) => process.stderr.write(
4684
+ `code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
4685
+ `
4686
+ )
4687
+ );
4688
+ }
4689
+ });
4690
+ var awaitCiCmd = defineCommand({
4691
+ meta: {
4692
+ name: "await-ci",
4693
+ 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)."
4694
+ },
4695
+ args: {
4696
+ repo: { type: "string", description: "Repository (owner/name)", required: true },
4697
+ "head-sha": {
4698
+ type: "string",
4699
+ description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
4700
+ required: true
4701
+ },
4702
+ "ci-workflow": {
4703
+ type: "string",
4704
+ description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
4705
+ },
4706
+ timeout: {
4707
+ type: "string",
4708
+ description: "Give up waiting after this wall (default: 30m)"
4709
+ },
4710
+ "poll-interval": {
4711
+ type: "string",
4712
+ description: "How often to re-check the run status (default: 15s)"
4713
+ }
4714
+ },
4715
+ run: async ({ args }) => {
4716
+ const workflowName = args["ci-workflow"] || "CI";
4717
+ const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
4718
+ workflowName,
4719
+ pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
4720
+ timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
1641
4721
  });
4722
+ process.stderr.write(
4723
+ outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
4724
+ ` : `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."}
4725
+ `
4726
+ );
4727
+ process.stdout.write(renderCiOutputs(outcome));
4728
+ }
4729
+ });
4730
+ var checkScopeCmd = defineCommand({
4731
+ meta: {
4732
+ name: "check-scope",
4733
+ description: "Validate + normalize the workflow's `scope` input \u2014 the languages/inputs the project accepts (issue #139). Prints the normalized space-separated language list for splicing into the review prompt, or nothing when the scope is empty (the reviewer then infers it from the README's first paragraph). Fails loudly on a malformed value, so a config typo never silently corrupts the prompt it is spliced into."
4734
+ },
4735
+ args: {
4736
+ scope: {
4737
+ type: "string",
4738
+ description: 'The raw scope value \u2014 whitespace/comma/semicolon-separated language names (e.g. "C C++"); empty \u21D2 absent'
4739
+ }
4740
+ },
4741
+ run: ({ args }) => {
4742
+ const parsed = parseScope(args.scope);
4743
+ switch (parsed.kind) {
4744
+ case "absent":
4745
+ return;
4746
+ case "invalid":
4747
+ return fail(`check-scope: ${parsed.reason}`);
4748
+ case "ok":
4749
+ process.stdout.write(`${parsed.languages.join(" ")}
4750
+ `);
4751
+ }
4752
+ }
4753
+ });
4754
+ var sandboxConfigCmd = defineCommand({
4755
+ meta: {
4756
+ name: "sandbox-config",
4757
+ description: "Emit the sandbox-runtime (srt) settings that jail the untrusted review agent's egress: allow the model host (derived from api_base_url), the GitHub API/host, and the consumer's extra_endpoints; deny all else; filesystem isolation off"
4758
+ },
4759
+ args: {
4760
+ "api-base-url": {
4761
+ type: "string",
4762
+ description: "The model endpoint the CLI dials (ANTHROPIC_BASE_URL) \u2014 its host is allowlisted",
4763
+ required: true
4764
+ },
4765
+ extra: {
4766
+ type: "string",
4767
+ description: "Whitespace-separated host[:port] list of extra domains to allow (the extra_endpoints input)"
4768
+ },
4769
+ out: {
4770
+ type: "string",
4771
+ description: "Write the settings JSON here instead of stdout"
4772
+ },
4773
+ "known-model-host": {
4774
+ type: "string",
4775
+ description: "Additional model HOST(S) to treat as known (space-separated bare hostnames, not URLs) \u2014 the consumer's declared host(s) for a provider outside the built-in set; a derived host outside the built-ins and this list warns (or fails under --strict-host)"
4776
+ },
4777
+ "strict-host": {
4778
+ type: "boolean",
4779
+ description: "Fail (instead of warning) when the model host derived from api_base_url is not a well-known or declared host \u2014 closes the fail-open-on-typo gap where a mistyped api_base_url would still be allowlisted and sent the key"
4780
+ }
4781
+ },
4782
+ run: ({ args }) => {
4783
+ const modelHost = deriveModelHost(args["api-base-url"]);
4784
+ const declaredHosts = [
4785
+ ...args["known-model-host"] ? parseExtraEndpoints(args["known-model-host"]) : [],
4786
+ ...args.extra ? parseExtraEndpoints(args.extra) : []
4787
+ ];
4788
+ if (!isKnownModelHost(modelHost, declaredHosts)) {
4789
+ const message = `code-review sandbox-config: derived model host "${modelHost}" is not a well-known or declared host \u2014 the jail will allow egress to it and send MODEL_API_KEY there; verify api_base_url is correct`;
4790
+ if (args["strict-host"]) {
4791
+ process.stderr.write(`::error::${annotationSafe(message)}
4792
+ `);
4793
+ process.exit(1);
4794
+ }
4795
+ process.stderr.write(`::warning::${annotationSafe(message)}
4796
+ `);
4797
+ }
4798
+ const config = buildSandboxConfig({ apiBaseUrl: args["api-base-url"], extra: args.extra });
4799
+ const json = `${JSON.stringify(config, null, 2)}
4800
+ `;
4801
+ if (args.out) {
4802
+ writeFileSync(resolve$1(args.out), json);
4803
+ } else {
4804
+ process.stdout.write(json);
4805
+ }
1642
4806
  }
1643
4807
  });
1644
4808
  var main = defineCommand({
1645
4809
  meta: {
1646
4810
  name: "code-review",
1647
4811
  version: packageVersion,
1648
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
4812
+ description: "Deterministic commenter for agentic PR review"
1649
4813
  },
1650
4814
  subCommands: {
1651
4815
  gather: gatherCmd,
4816
+ "parse-command": parseCommandCmd,
4817
+ react: reactCmd,
4818
+ "await-ci": awaitCiCmd,
1652
4819
  render: renderCmd,
1653
4820
  inline: inlineCmd,
1654
4821
  post: postCmd,
4822
+ announce: announceCmd,
4823
+ "check-run": checkRunCmd,
4824
+ "check-scope": checkScopeCmd,
4825
+ "report-incomplete": reportIncompleteCmd,
1655
4826
  cost: costCmd,
4827
+ "check-cost": checkCostCmd,
1656
4828
  validate: validateCmd,
4829
+ "seed-draft": seedDraftCmd,
1657
4830
  adapt: adaptCmd,
4831
+ notice: noticeCmd,
1658
4832
  extract: extractCmd,
1659
- "print-schema": printSchemaCmd
4833
+ "validate-patches": validatePatchesCmd,
4834
+ "print-schema": printSchemaCmd,
4835
+ "stop-gate": stopGateCmd,
4836
+ "budget-hook": budgetHookCmd,
4837
+ "print-settings": printSettingsCmd,
4838
+ deadline: deadlineCmd,
4839
+ "sandbox-config": sandboxConfigCmd
1660
4840
  }
1661
4841
  });
1662
4842
  if (!process.env["VITEST"]) {
1663
4843
  await runMain(main);
1664
4844
  }
1665
4845
 
1666
- export { main };
4846
+ export { main, snapshotIfValid };
1667
4847
  //# sourceMappingURL=index.js.map
1668
4848
  //# sourceMappingURL=index.js.map