@jphutchins/code-review 0.1.0-alpha.1 → 0.1.0-alpha.11

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,7 +1,7 @@
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, readdirSync } from 'fs';
4
+ import { resolve as resolve$1, join, dirname, basename } from 'path';
5
5
  import { Eta } from 'eta';
6
6
  import parseDiff from 'parse-diff';
7
7
  import { Ajv2020 } from 'ajv/dist/2020.js';
@@ -53,43 +53,196 @@ var computeCost = (models, prices, warn = defaultWarn) => {
53
53
  };
54
54
  };
55
55
 
56
+ // src/patch.ts
57
+ var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
58
+ var hunkOldStart = (line) => {
59
+ const raw = HUNK_HEADER_RE.exec(line)?.[1];
60
+ return raw !== void 0 ? Number(raw) : null;
61
+ };
62
+ var classifyBodyLine = (line) => {
63
+ if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
64
+ if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
65
+ if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
66
+ return null;
67
+ };
68
+ var trimmedMiddle = (body) => {
69
+ const first = body.findIndex((l) => l.kind !== "context");
70
+ if (first === -1) return [];
71
+ const last = body.findLastIndex((l) => l.kind !== "context");
72
+ return body.slice(first, last + 1);
73
+ };
74
+ var isContiguousChange = (middle) => {
75
+ if (middle.some((l) => l.kind === "context")) return false;
76
+ const firstAdded = middle.findIndex((l) => l.kind === "added");
77
+ if (firstAdded === -1) return true;
78
+ return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
79
+ };
80
+ var removedRange = (body, oldStart) => body.reduce(
81
+ (acc, line) => line.kind === "added" ? acc : {
82
+ lineNumber: acc.lineNumber + 1,
83
+ firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
84
+ lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
85
+ },
86
+ { lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
87
+ );
88
+ var drop = (reason) => ({
89
+ kind: "drop",
90
+ reason
91
+ });
92
+ var keep = (reason) => ({
93
+ kind: "keep",
94
+ reason
95
+ });
96
+ var parseHunk = (patch) => {
97
+ const rawLines = patch.split("\n");
98
+ const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
99
+ const headerHits = lines.reduce(
100
+ (acc, line, index) => {
101
+ const oldStart = hunkOldStart(line);
102
+ return oldStart !== null ? [...acc, { index, oldStart }] : acc;
103
+ },
104
+ []
105
+ );
106
+ if (headerHits.length !== 1) {
107
+ return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
108
+ }
109
+ const hit = headerHits[0];
110
+ if (hit === void 0) return drop("malformed hunk header");
111
+ const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
112
+ const classified = bodyRaw.map(classifyBodyLine);
113
+ if (classified.some((line) => line === null)) return drop("malformed hunk body line");
114
+ const body = classified.filter((line) => line !== null);
115
+ return { kind: "ok", oldStart: hit.oldStart, body };
116
+ };
117
+ var validatePatch = (patch, fileLines) => {
118
+ const parsed = parseHunk(patch);
119
+ if (parsed.kind === "drop") return parsed;
120
+ const { oldStart, body } = parsed;
121
+ const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
122
+ const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
123
+ const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
124
+ if (!oldSideMatches) {
125
+ return drop(
126
+ `patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
127
+ );
128
+ }
129
+ if (!isContiguousChange(trimmedMiddle(body))) {
130
+ return drop("change is not a single contiguous block");
131
+ }
132
+ const removedCount = body.filter((l) => l.kind === "removed").length;
133
+ const addedCount = body.filter((l) => l.kind === "added").length;
134
+ if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
135
+ if (removedCount === 0) {
136
+ return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
137
+ }
138
+ const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
139
+ if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
140
+ return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
141
+ };
142
+ var patchToSuggestion = (patch) => {
143
+ const parsed = parseHunk(patch);
144
+ if (parsed.kind === "drop") return parsed;
145
+ const { body } = parsed;
146
+ if (!isContiguousChange(trimmedMiddle(body))) {
147
+ return drop("change is not a single contiguous block");
148
+ }
149
+ const removedCount = body.filter((l) => l.kind === "removed").length;
150
+ const addedLines = body.filter((l) => l.kind === "added");
151
+ if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
152
+ if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
153
+ return addedLines.map((l) => l.text).join("\n");
154
+ };
155
+
156
+ // src/surface.ts
157
+ var severityEmoji = (s) => {
158
+ switch (s) {
159
+ case "critical":
160
+ return "\u{1F534}";
161
+ case "major":
162
+ return "\u{1F7E0}";
163
+ case "minor":
164
+ return "\u{1F535}";
165
+ case "nit":
166
+ return "\u26AA";
167
+ default:
168
+ return "\u2753";
169
+ }
170
+ };
171
+ var EMBED_LIMIT = 4e4;
172
+ var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
173
+ var encodeMarker = (document, jsonUrl, limit) => {
174
+ const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
175
+ const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
176
+ return marker ? `${AGENTS_STOP_DIRECTIVE}
177
+ ${marker}` : "";
178
+ };
179
+ var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
180
+ var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
181
+ var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
182
+ var projectPatch = (patch) => {
183
+ if (patch === void 0) return { kind: "none" };
184
+ const lowered = patchToSuggestion(patch);
185
+ return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
186
+ };
187
+ var formatConfidence = (n) => n.toFixed(2);
188
+ var reviewBodyPointer = (headSha, stickyUrl, marker) => {
189
+ const sha7 = headSha.slice(0, 7);
190
+ 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.`;
191
+ return marker ? `${marker}
192
+
193
+ ${linkLine}` : linkLine;
194
+ };
195
+
56
196
  // src/render.ts
57
- var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
58
197
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
59
198
  var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
60
199
  var sanitizeFinding = (f) => ({
61
200
  ...f,
62
201
  title: escapePipes(f.title),
63
202
  path: escapeCodeBackticks(f.path),
64
- suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
203
+ patchProjection: projectPatch(f.patch)
65
204
  });
205
+ var emptySeverityCounts = () => ({
206
+ critical: 0,
207
+ major: 0,
208
+ minor: 0,
209
+ nit: 0
210
+ });
211
+ var computeSeverityCounts = (findings) => findings.reduce(
212
+ (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
213
+ emptySeverityCounts()
214
+ );
66
215
  var render = (input) => {
67
216
  const eta = new Eta({ autoTrim: false });
68
217
  const usageAvailable = input.envelope !== null;
69
218
  const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
70
- const route = input.route;
219
+ const pricesProvided = input.pricesProvided ?? true;
220
+ const route = input.route ?? input.envelope?.route ?? null;
221
+ const effort = input.effort ?? input.envelope?.effort ?? null;
71
222
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
72
- const findings = input.findings.findings.map(sanitizeFinding);
73
- const safeFindings = { ...input.findings, findings };
74
- const uniqueFiles = [...new Set(findings.map((f) => f.path))];
75
223
  return eta.renderString(input.template, {
76
- findings: safeFindings,
224
+ findings: input.findings,
77
225
  envelope: input.envelope,
78
226
  usageAvailable,
79
227
  costReport,
228
+ pricesProvided,
80
229
  route,
81
- effort: input.effort ?? null,
230
+ effort,
82
231
  modelNames,
83
232
  testReport: input.testReport ?? null,
84
233
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
85
- totalCount: findings.length,
86
- fileCount: uniqueFiles.length,
87
- // REC-CO-1: nits (and only nits) fold into <details>; everything else stays visible.
88
- visibleFindings: findings.filter((f) => f.severity !== "nit"),
89
- nitFindings: findings.filter((f) => f.severity === "nit"),
90
- suggestionCount: findings.filter((f) => f.suggestion).length,
234
+ postedAt: input.postedAt ?? "",
235
+ severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
236
+ strays: (input.strays ?? []).map(sanitizeFinding),
237
+ inlineDisposition: input.inlineDisposition ?? null,
238
+ runUrl: input.runUrl ?? null,
239
+ jsonUrl: input.jsonUrl ?? null,
240
+ findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
241
+ reviewUrl: input.reviewUrl ?? null,
91
242
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
92
- formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(3)}` : "\u2014",
243
+ // Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
244
+ // real tokens spent, we simply have no rates to price them (SPEC §6.2).
245
+ formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
93
246
  formatDuration: (ms) => {
94
247
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
95
248
  const s = Math.round(ms / 1e3);
@@ -107,20 +260,8 @@ var render = (input) => {
107
260
  return `\u2753 ${v}`;
108
261
  }
109
262
  },
110
- severityEmoji: (s) => {
111
- switch (s) {
112
- case "critical":
113
- return "\u{1F534}";
114
- case "major":
115
- return "\u{1F7E0}";
116
- case "minor":
117
- return "\u{1F535}";
118
- case "nit":
119
- return "\u26AA";
120
- default:
121
- return "\u2753";
122
- }
123
- }
263
+ severityEmoji,
264
+ formatConfidence
124
265
  });
125
266
  };
126
267
  var key = (path, line) => `${path}:${String(line)}`;
@@ -177,33 +318,33 @@ var partitionFindings = (findings, index) => {
177
318
  };
178
319
 
179
320
  // src/inline.ts
180
- var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
181
- var buildCommentBody = (f) => {
182
- const parts = [f.body];
183
- if (f.suggestion !== null && f.suggestion !== void 0) {
184
- const safe = escapeBackticks2(f.suggestion);
185
- parts.push(`\`\`\`suggestion
186
- ${safe}
187
- \`\`\``);
188
- }
189
- return parts.join("\n\n");
190
- };
191
- var renderCommentBody = (f, eta, template) => {
192
- return eta.renderString(template, {
321
+ var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
322
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
323
+ // Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
324
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
325
+ eta.renderString(template, {
193
326
  ...f,
194
- suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
195
- });
196
- };
197
- var buildInlineComments = (findings, diff, inlineTemplate) => {
327
+ patchProjection: projectPatch(f.patch),
328
+ severityEmoji,
329
+ formatConfidence,
330
+ modelsText,
331
+ jsonUrl: jsonUrl ?? null,
332
+ findingsPointer: pointer
333
+ })
334
+ );
335
+ var buildInlineComments = (findings, diff, context) => {
336
+ const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
198
337
  const index = indexDiff(diff);
199
338
  const { inDiff, strays } = partitionFindings(findings, index);
200
- const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
339
+ const eta = new Eta({ autoTrim: false });
340
+ const modelsText = formatModels(models);
201
341
  const comments = inDiff.map((f) => {
342
+ const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
202
343
  const comment = {
203
344
  path: f.path,
204
345
  line: f.end_line,
205
346
  side: defaultSide(f.side),
206
- body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
347
+ body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
207
348
  };
208
349
  if (f.start_line < f.end_line) {
209
350
  return {
@@ -230,6 +371,120 @@ var renderStraysSection = (strays) => {
230
371
  ...items
231
372
  ].join("\n");
232
373
  };
374
+ var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
375
+ var readFileOrNull = (path) => {
376
+ try {
377
+ return readFileSync(path, "utf-8");
378
+ } catch {
379
+ return null;
380
+ }
381
+ };
382
+
383
+ // src/transcript.ts
384
+ var numField = (rec, key2) => {
385
+ const v = rec[key2];
386
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
387
+ };
388
+ var messageUsage = (entry) => {
389
+ const rec = asRecord(entry);
390
+ if (rec === null || rec["type"] !== "assistant") return null;
391
+ const msg = asRecord(rec["message"]);
392
+ if (msg === null) return null;
393
+ const model = msg["model"];
394
+ const usage = asRecord(msg["usage"]);
395
+ if (typeof model !== "string" || usage === null) return null;
396
+ const id = msg["id"];
397
+ return {
398
+ id: typeof id === "string" ? id : null,
399
+ model,
400
+ input: numField(usage, "input_tokens"),
401
+ output: numField(usage, "output_tokens"),
402
+ cacheRead: numField(usage, "cache_read_input_tokens"),
403
+ cacheWrite: numField(usage, "cache_creation_input_tokens")
404
+ };
405
+ };
406
+ var tsMsOf = (entry) => {
407
+ const rec = asRecord(entry);
408
+ const ts = rec?.["timestamp"];
409
+ if (typeof ts !== "string") return null;
410
+ const ms = Date.parse(ts);
411
+ return Number.isNaN(ms) ? null : ms;
412
+ };
413
+ var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
414
+ var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
415
+ try {
416
+ return [JSON.parse(line)];
417
+ } catch {
418
+ return [];
419
+ }
420
+ });
421
+ var sumTranscriptUsage = (entries) => {
422
+ const summed = entries.reduce(
423
+ (acc, entry) => {
424
+ const u = messageUsage(entry);
425
+ if (u === null) return acc;
426
+ if (u.id !== null && acc.seen.has(u.id)) return acc;
427
+ if (u.id !== null) acc.seen.add(u.id);
428
+ const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
429
+ acc.totals.set(u.model, {
430
+ input: prev.input + u.input,
431
+ output: prev.output + u.output,
432
+ cacheRead: prev.cacheRead + u.cacheRead,
433
+ cacheWrite: prev.cacheWrite + u.cacheWrite
434
+ });
435
+ return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
436
+ },
437
+ { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
438
+ );
439
+ const models = [...summed.totals].map(([model, t4]) => ({
440
+ model,
441
+ input_tokens: t4.input,
442
+ output_tokens: t4.output,
443
+ cache_read_tokens: t4.cacheRead,
444
+ cache_write_tokens: t4.cacheWrite
445
+ }));
446
+ const bounds = entries.reduce(
447
+ (acc, entry) => {
448
+ const ms = tsMsOf(entry);
449
+ if (ms === null) return acc;
450
+ return {
451
+ min: acc.min === null || ms < acc.min ? ms : acc.min,
452
+ max: acc.max === null || ms > acc.max ? ms : acc.max
453
+ };
454
+ },
455
+ { min: null, max: null }
456
+ );
457
+ return {
458
+ models,
459
+ turns: summed.turns,
460
+ durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
461
+ firstTsMs: bounds.min,
462
+ lastTsMs: bounds.max
463
+ };
464
+ };
465
+ var subagentFiles = (mainPath) => {
466
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
467
+ try {
468
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
469
+ } catch {
470
+ return [];
471
+ }
472
+ };
473
+ var readTranscriptTree = (mainPath) => {
474
+ const mainText = readFileOrNull(mainPath);
475
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
476
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
477
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
478
+ const siblingReads = siblings.flatMap((path) => {
479
+ const text = readFileOrNull(path);
480
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
481
+ });
482
+ return {
483
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
484
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
485
+ missing: mainText === null
486
+ };
487
+ };
233
488
  var SeverityCodec = t.union([
234
489
  t.literal("critical"),
235
490
  t.literal("major"),
@@ -257,14 +512,16 @@ var FindingShape = t.intersection([
257
512
  end_line: LineNumber,
258
513
  severity: SeverityCodec,
259
514
  title: t.string,
260
- body: t.string
515
+ description: t.string,
516
+ reasoning: t.string,
517
+ confidence: Confidence
261
518
  }),
262
519
  t.partial({
263
520
  side: SideCodec,
264
- suggestion: t.union([t.string, t.null]),
265
- confidence: Confidence,
266
521
  code: t.string,
267
- code_url: t.string
522
+ code_url: t.string,
523
+ recommendation: t.string,
524
+ patch: t.string
268
525
  })
269
526
  ]);
270
527
  var EndGeStart = t.refinement(
@@ -310,7 +567,9 @@ var ResultEnvelopeCodec = t.intersection([
310
567
  duration_ms: TokenCount
311
568
  }),
312
569
  t.partial({
313
- vendor_cost_usd: t.union([t.number, t.null])
570
+ vendor_cost_usd: t.union([t.number, t.null]),
571
+ route: t.string,
572
+ effort: t.string
314
573
  })
315
574
  ]);
316
575
  var ModelPricesCodec = t.type({
@@ -338,7 +597,13 @@ var TestSummaryCodec = t.intersection([
338
597
  failures: t.array(TestFailureCodec)
339
598
  })
340
599
  ]);
341
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
600
+ var DEFAULT_SCHEMA_VERSION = "0.4.0";
601
+ var noticeFindings = (summary) => ({
602
+ schema_version: DEFAULT_SCHEMA_VERSION,
603
+ summary,
604
+ verdict: "comment",
605
+ findings: []
606
+ });
342
607
 
343
608
  // src/validate.ts
344
609
  var addFormats = _addFormats;
@@ -374,10 +639,236 @@ var unsafeUnwrap = (decoded) => {
374
639
  if (decoded._tag === "Right") return decoded.right;
375
640
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
376
641
  };
642
+
643
+ // src/stop-gate.ts
644
+ var whatsWrong = (state, draftPath, kind) => {
645
+ switch (state.kind) {
646
+ case "missing":
647
+ return `${draftPath} does not exist yet`;
648
+ case "unreadable":
649
+ return `${draftPath} could not be read: ${state.error}`;
650
+ case "invalid":
651
+ return `${draftPath} does not validate against the ${kind} schema:
652
+ ${state.errors.map((e) => ` - ${e}`).join("\n")}`;
653
+ }
654
+ };
655
+ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
656
+ if (state.kind === "valid") return { kind: "allow" };
657
+ if (nudges >= maxNudges) return { kind: "allow" };
658
+ return {
659
+ kind: "block",
660
+ reason: [
661
+ `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
662
+ `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.`,
663
+ `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
664
+ ].join("\n")
665
+ };
666
+ };
667
+ var draftState = (draftPath, resolveSchema) => {
668
+ let raw;
669
+ try {
670
+ raw = readFileSync(draftPath, "utf-8");
671
+ } catch (err) {
672
+ if (err instanceof Error && err.code === "ENOENT") {
673
+ return { kind: "missing" };
674
+ }
675
+ return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
676
+ }
677
+ let parsed;
678
+ try {
679
+ parsed = JSON.parse(raw);
680
+ } catch (err) {
681
+ return {
682
+ kind: "invalid",
683
+ errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
684
+ };
685
+ }
686
+ let schemaPath;
687
+ try {
688
+ schemaPath = resolveSchema(parsed);
689
+ } catch (err) {
690
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
691
+ }
692
+ try {
693
+ const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
694
+ return valid ? { kind: "valid" } : { kind: "invalid", errors };
695
+ } catch (err) {
696
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
697
+ }
698
+ };
699
+ var readNudges = (counterPath) => {
700
+ try {
701
+ const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
702
+ return Number.isInteger(n) && n >= 0 ? n : 0;
703
+ } catch {
704
+ return 0;
705
+ }
706
+ };
707
+ var bumpNudges = (counterPath, current) => {
708
+ writeFileSync(counterPath, `${String(current + 1)}
709
+ `);
710
+ };
711
+ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
712
+ var defaultHookCommand = (draftPath, opts) => [
713
+ "code-review stop-gate --draft",
714
+ shellQuote(draftPath),
715
+ ...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
716
+ ...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
717
+ ...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
718
+ ...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
719
+ ...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
720
+ ].join(" ");
721
+ var stopHookSettings = (command) => ({
722
+ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
723
+ });
724
+
725
+ // src/budget.ts
726
+ var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
727
+ var DEFAULT_RESERVE = { frac: 0.15, flatUsd: 0.02, flatMs: 12e4 };
728
+ var SOFT_MULTIPLE = 2;
729
+ var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
730
+ var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
731
+ var axisSeverity = (a, frac) => {
732
+ const hardReserve = Math.max(a.flat, frac * a.limit);
733
+ const remaining = a.limit - a.used;
734
+ if (remaining <= hardReserve) return 2;
735
+ if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
736
+ return 0;
737
+ };
738
+ var decideBudget = (i) => {
739
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve.frac)), 0);
740
+ return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
741
+ };
742
+ var pct = (n) => `${String(Math.round(n * 100))}%`;
743
+ var money = (n) => `$${n.toFixed(2)}`;
744
+ var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
745
+ 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)}`;
746
+ 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`;
747
+ var directive = (phase, draftPath) => phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Write your COMPLETE findings to ${draftPath} and run \`code-review validate ${draftPath}\` until it passes. Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then validate \u2014 you may run out of budget before you finish otherwise.`;
748
+ var budgetMessage = (i, phase, draftPath) => {
749
+ const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
750
+ return `Budget check \u2014 ${status}. ${directive(phase, draftPath)}`;
751
+ };
752
+ var invokesCodeReviewValidate = (toolInput) => {
753
+ const cmd = asRecord(toolInput)?.["command"];
754
+ return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
755
+ };
756
+ var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
757
+ var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
758
+ var blockedDuringConvergence = (toolName, toolInput) => {
759
+ if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
760
+ if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
761
+ return false;
762
+ };
763
+ var evaluateBudgetHook = (input, params) => {
764
+ const rec = asRecord(input);
765
+ const inputs = {
766
+ spentUsd: params.spentUsd,
767
+ budgetUsd: params.budgetUsd,
768
+ elapsedMs: params.elapsedMs,
769
+ wallMs: params.wallMs,
770
+ reserve: params.reserve
771
+ };
772
+ const phase = decideBudget(inputs);
773
+ switch (rec?.["hook_event_name"]) {
774
+ case "PostToolBatch":
775
+ return phase.kind === "ok" ? {} : {
776
+ hookSpecificOutput: {
777
+ hookEventName: "PostToolBatch",
778
+ additionalContext: budgetMessage(inputs, phase, params.draftPath)
779
+ }
780
+ };
781
+ case "PreToolUse": {
782
+ if (phase.kind !== "hard") return {};
783
+ const toolName = rec["tool_name"];
784
+ if (typeof toolName === "string" && blockedDuringConvergence(toolName, rec["tool_input"]))
785
+ return {
786
+ hookSpecificOutput: {
787
+ hookEventName: "PreToolUse",
788
+ permissionDecision: "deny",
789
+ permissionDecisionReason: budgetMessage(inputs, phase, params.draftPath)
790
+ }
791
+ };
792
+ return {};
793
+ }
794
+ default:
795
+ return {};
796
+ }
797
+ };
798
+ var parseWallMs = (raw) => {
799
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
800
+ if (m === null) return null;
801
+ const [, num = "", unit = "s"] = m;
802
+ const n = Number.parseFloat(num);
803
+ if (!Number.isFinite(n)) return null;
804
+ switch (unit) {
805
+ case "ms":
806
+ return n;
807
+ case "s":
808
+ return n * 1e3;
809
+ case "m":
810
+ return n * 6e4;
811
+ default:
812
+ return n * 36e5;
813
+ }
814
+ };
815
+ var parseEpochSecMs = (raw) => {
816
+ if (raw === void 0) return null;
817
+ const t4 = raw.trim();
818
+ if (!/^\d+$/.test(t4)) return null;
819
+ const n = Number.parseInt(t4, 10);
820
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
821
+ };
822
+ var anchoredElapsedMs = (src) => {
823
+ if (src.deadlineMs !== null && src.wallMs !== null)
824
+ return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
825
+ if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
826
+ return null;
827
+ };
828
+ var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
829
+ var parseFraction = (raw, fallback) => {
830
+ if (raw === void 0) return fallback;
831
+ const n = Number.parseFloat(raw);
832
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
833
+ };
834
+ var budgetHookCommand = (draftPath, opts) => [
835
+ "code-review budget-hook --draft",
836
+ shellQuote(draftPath),
837
+ ...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
838
+ ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
839
+ ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
840
+ ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
841
+ ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
842
+ ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
843
+ ].join(" ");
844
+
845
+ // src/format.ts
846
+ var FENCE_RE = /^\s*```/;
847
+ var scanLine = (state, line) => {
848
+ if (FENCE_RE.test(line)) {
849
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
850
+ }
851
+ if (state.inFence) {
852
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
853
+ }
854
+ const trimmed = line.replace(/[ \t]+$/, "");
855
+ if (trimmed !== "") {
856
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
857
+ }
858
+ const blankRun = state.blankRun + 1;
859
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
860
+ };
861
+ var formatMarkdown = (md) => {
862
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
863
+ return `${lines.join("\n").replace(/\n+$/, "")}
864
+ `;
865
+ };
866
+ var pad2 = (n) => String(n).padStart(2, "0");
867
+ var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
377
868
  var identity = (decoded) => decoded;
378
869
  var findingsTable = [
379
870
  {
380
- minor: "0.2",
871
+ minor: "0.4",
381
872
  defaultVersion: DEFAULT_SCHEMA_VERSION,
382
873
  schemaFile: "findings.schema.json",
383
874
  codec: FindingsCodec,
@@ -502,7 +993,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
502
993
  // src/post.ts
503
994
  var DEFAULT_MARKER = "<!-- code-review -->";
504
995
  var MAX_SUGGESTION_LINES = 10;
505
- var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
506
996
  var countSuggestionLines = (text) => text.split("\n").length;
507
997
  var checkLongSuggestions = (comments) => {
508
998
  const longFiles = [];
@@ -522,13 +1012,6 @@ var checkLongSuggestions = (comments) => {
522
1012
  });
523
1013
  return { comments: adjusted, longFiles };
524
1014
  };
525
- var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
526
- var noticeFindings = (message) => ({
527
- schema_version: DEFAULT_SCHEMA_VERSION,
528
- summary: `### \u26A0\uFE0F ${message}`,
529
- verdict: "comment",
530
- findings: []
531
- });
532
1015
  var loadFindings = (path) => {
533
1016
  let raw;
534
1017
  try {
@@ -590,9 +1073,18 @@ var loadTestReport = (path) => {
590
1073
  }
591
1074
  return decoded.right;
592
1075
  };
593
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
1076
+ var parseHtmlUrl = (raw) => {
1077
+ try {
1078
+ const parsed = JSON.parse(raw);
1079
+ return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
1080
+ } catch {
1081
+ return void 0;
1082
+ }
1083
+ };
1084
+ var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
1085
+ const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
594
1086
  const body = JSON.stringify({
595
- body: "",
1087
+ body: pointer,
596
1088
  commit_id: headSha,
597
1089
  event: "COMMENT",
598
1090
  comments: comments.map((c) => ({
@@ -600,10 +1092,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
600
1092
  line: c.line,
601
1093
  side: c.side,
602
1094
  ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
603
- body: c.body
1095
+ body: formatMarkdown(c.body)
604
1096
  }))
605
1097
  });
606
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
1098
+ const stdout = await ghApi(
1099
+ [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
1100
+ body
1101
+ );
1102
+ return parseHtmlUrl(stdout);
607
1103
  };
608
1104
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
609
1105
  const stdout = await ghApi(
@@ -623,33 +1119,45 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
623
1119
  const parsed = JSON.parse(last);
624
1120
  return { id: parsed.id, body: parsed.body };
625
1121
  };
1122
+ var parseCommentRef = (raw) => {
1123
+ try {
1124
+ const parsed = JSON.parse(raw);
1125
+ return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
1126
+ } catch {
1127
+ return null;
1128
+ }
1129
+ };
626
1130
  var patchComment = async (repo, commentId, body, ghApi) => {
627
- await ghApi(
1131
+ const stdout = await ghApi(
628
1132
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
629
1133
  JSON.stringify({ body })
630
1134
  );
1135
+ const htmlUrl = parseHtmlUrl(stdout);
1136
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
631
1137
  };
632
1138
  var postComment = async (repo, prNumber, body, ghApi) => {
633
- await ghApi(
1139
+ const stdout = await ghApi(
634
1140
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
635
1141
  JSON.stringify({ body })
636
1142
  );
1143
+ return parseCommentRef(stdout);
637
1144
  };
638
1145
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
639
1146
  if (existing !== null) {
640
- await patchComment(repo, existing.id, body, ghApi);
1147
+ const patched = await patchComment(repo, existing.id, body, ghApi);
641
1148
  process.stderr.write(
642
1149
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
643
1150
  `
644
1151
  );
645
- } else {
646
- await postComment(repo, prNumber, body, ghApi);
647
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
648
- `);
1152
+ return { id: existing.id, url: patched?.html_url };
649
1153
  }
1154
+ const posted = await postComment(repo, prNumber, body, ghApi);
1155
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
1156
+ `);
1157
+ return posted ? { id: posted.id, url: posted.html_url } : null;
650
1158
  };
651
1159
  var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
652
- var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
1160
+ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
653
1161
  const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
654
1162
  let reviews;
655
1163
  try {
@@ -658,10 +1166,12 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
658
1166
  return [];
659
1167
  }
660
1168
  if (!Array.isArray(reviews)) return [];
661
- return reviews.filter((r) => isBotReview(r)).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => r.id);
1169
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({
1170
+ id: r.id,
1171
+ commitId: typeof r.commit_id === "string" ? r.commit_id : ""
1172
+ }));
662
1173
  };
663
- var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
664
- const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
1174
+ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
665
1175
  for (const id of ids) {
666
1176
  try {
667
1177
  await ghApi(
@@ -682,6 +1192,84 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
682
1192
  }
683
1193
  }
684
1194
  };
1195
+ 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} originalCommit{oid}}}}}}}}";
1196
+ var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1197
+ var supersededCommentId = (c, headSha, logins) => {
1198
+ if (typeof c !== "object" || c === null) return null;
1199
+ const o = c;
1200
+ const login = o.author?.login;
1201
+ const oid = o.originalCommit?.oid;
1202
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) && typeof oid === "string" && oid !== headSha ? o.id : null;
1203
+ };
1204
+ var supersededBotCommentIds = (raw, headSha, botLogin) => {
1205
+ let parsed;
1206
+ try {
1207
+ parsed = JSON.parse(raw);
1208
+ } catch {
1209
+ return { ids: [], truncated: false };
1210
+ }
1211
+ const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
1212
+ const truncated = conn?.pageInfo?.hasNextPage === true;
1213
+ const nodes = conn?.nodes;
1214
+ if (!Array.isArray(nodes)) return { ids: [], truncated };
1215
+ const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1216
+ const ids = nodes.flatMap((t4) => {
1217
+ const cnodes = t4.comments?.nodes;
1218
+ return Array.isArray(cnodes) ? cnodes.map((c) => supersededCommentId(c, headSha, logins)).filter((id) => id !== null) : [];
1219
+ });
1220
+ return { ids, truncated };
1221
+ };
1222
+ var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi) => {
1223
+ const slash = repo.indexOf("/");
1224
+ if (slash <= 0) return;
1225
+ const owner = repo.slice(0, slash);
1226
+ const name = repo.slice(slash + 1);
1227
+ let raw;
1228
+ try {
1229
+ raw = await ghApi([
1230
+ "graphql",
1231
+ "-f",
1232
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
1233
+ "-f",
1234
+ `owner=${owner}`,
1235
+ "-f",
1236
+ `name=${name}`,
1237
+ "-F",
1238
+ `pr=${String(prNumber)}`
1239
+ ]);
1240
+ } catch (err) {
1241
+ process.stderr.write(
1242
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1243
+ `
1244
+ );
1245
+ return;
1246
+ }
1247
+ const { ids, truncated } = supersededBotCommentIds(raw, headSha, botLogin);
1248
+ if (truncated) {
1249
+ process.stderr.write(
1250
+ `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1251
+ `
1252
+ );
1253
+ }
1254
+ let minimized = 0;
1255
+ for (const id of ids) {
1256
+ try {
1257
+ await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
1258
+ minimized += 1;
1259
+ } catch (err) {
1260
+ process.stderr.write(
1261
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1262
+ `
1263
+ );
1264
+ }
1265
+ }
1266
+ if (minimized > 0) {
1267
+ process.stderr.write(
1268
+ `Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
1269
+ `
1270
+ );
1271
+ }
1272
+ };
685
1273
  var post = async (input, ghApi = runGhApi) => {
686
1274
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
687
1275
  const resolution = resolvePr(candidates, input.headBranch);
@@ -706,24 +1294,28 @@ var post = async (input, ghApi = runGhApi) => {
706
1294
  DEFAULT_MARKER,
707
1295
  ghApi
708
1296
  );
709
- const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
710
- const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
711
1297
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
712
1298
  const decodedPrices = PriceMapCodec.decode(prices);
713
1299
  if (decodedPrices._tag === "Left") {
714
1300
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
715
1301
  }
716
1302
  const template = readFileSync(input.templatePath, "utf-8");
717
- const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
718
- const renderNotice = (message) => render({
719
- findings: noticeFindings(message),
720
- envelope: null,
721
- prices: decodedPrices.right,
722
- template,
723
- route: input.route,
724
- reviewedSha: input.headSha,
725
- effort: input.effort
726
- });
1303
+ const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
1304
+ const renderNotice = (message) => formatMarkdown(
1305
+ render({
1306
+ findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
1307
+ envelope: null,
1308
+ prices: decodedPrices.right,
1309
+ pricesProvided: input.pricesProvided,
1310
+ template,
1311
+ route: input.route,
1312
+ reviewedSha: input.headSha,
1313
+ effort: input.effort,
1314
+ runUrl: input.runUrl,
1315
+ jsonUrl: input.jsonUrl,
1316
+ postedAt: input.postedAt
1317
+ })
1318
+ );
727
1319
  if (isEmptyDiff(diff)) {
728
1320
  await upsertSticky(
729
1321
  input.repo,
@@ -749,27 +1341,36 @@ var post = async (input, ghApi = runGhApi) => {
749
1341
  const envelope = loadEnvelope(input.envelopePath);
750
1342
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
751
1343
  if (envelope === null) {
752
- const body2 = render({
753
- findings,
754
- envelope: null,
755
- prices: decodedPrices.right,
756
- template,
757
- route: input.route,
758
- reviewedSha: input.headSha,
759
- effort: input.effort,
760
- testReport
761
- });
762
- await upsertSticky(input.repo, prNumber, existingSticky, body2, ghApi);
1344
+ const body = formatMarkdown(
1345
+ render({
1346
+ findings,
1347
+ envelope: null,
1348
+ prices: decodedPrices.right,
1349
+ pricesProvided: input.pricesProvided,
1350
+ template,
1351
+ route: input.route,
1352
+ reviewedSha: input.headSha,
1353
+ effort: input.effort,
1354
+ testReport,
1355
+ inlineDisposition: { kind: "no-envelope" },
1356
+ runUrl: input.runUrl,
1357
+ jsonUrl: input.jsonUrl,
1358
+ postedAt: input.postedAt
1359
+ })
1360
+ );
1361
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
763
1362
  process.stderr.write(
764
1363
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
765
1364
  );
766
1365
  process.exit(0);
767
1366
  }
768
- const { comments: rawComments, strays } = buildInlineComments(
769
- findings.findings,
770
- diff,
771
- inlineTemplate
772
- );
1367
+ const findingsMarker = findingsPointer(findings, input.jsonUrl);
1368
+ const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
1369
+ inlineTemplate,
1370
+ models: envelope.models.map((m) => m.model),
1371
+ findings,
1372
+ jsonUrl: input.jsonUrl
1373
+ });
773
1374
  const { comments, longFiles } = checkLongSuggestions(rawComments);
774
1375
  for (const wf of longFiles) {
775
1376
  process.stderr.write(
@@ -777,43 +1378,86 @@ var post = async (input, ghApi = runGhApi) => {
777
1378
  `
778
1379
  );
779
1380
  }
780
- let body = render({
1381
+ const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1382
+ const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
1383
+ const initialDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : void 0 : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1384
+ const commonRenderInput = {
781
1385
  findings,
782
1386
  envelope,
783
1387
  prices: decodedPrices.right,
1388
+ pricesProvided: input.pricesProvided,
784
1389
  template,
785
1390
  route: input.route,
786
1391
  reviewedSha: input.headSha,
787
1392
  effort: input.effort,
788
- testReport
789
- });
790
- const straysMd = renderStraysSection(strays);
791
- if (straysMd.length > 0) body += straysMd;
792
- if (longFiles.length > 0) {
793
- body += `
1393
+ testReport,
1394
+ severityCounts: computeSeverityCounts(findings.findings),
1395
+ strays,
1396
+ runUrl: input.runUrl,
1397
+ jsonUrl: input.jsonUrl,
1398
+ findingsPointer: findingsMarker,
1399
+ postedAt: input.postedAt
1400
+ };
1401
+ const longFilesNote = longFiles.length > 0 ? `
794
1402
 
795
1403
  ---
796
1404
 
797
- > **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.
798
- `;
799
- }
800
- if (!isRerunOfSameSha && previousReviewedSha !== null) {
801
- await dismissPriorBotReviews(input.repo, prNumber, input.botLogin, ghApi);
802
- }
803
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
804
- if (isRerunOfSameSha) {
1405
+ > **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.
1406
+ ` : "";
1407
+ const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
1408
+ const stickyRef = await upsertSticky(
1409
+ input.repo,
1410
+ prNumber,
1411
+ existingSticky,
1412
+ renderBody(initialDisposition),
1413
+ ghApi
1414
+ );
1415
+ if (alreadyReviewedThisSha) {
805
1416
  process.stderr.write(
806
- `Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
1417
+ `A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new review
807
1418
  `
808
1419
  );
809
1420
  return;
810
1421
  }
811
- if (comments.length > 0) {
812
- await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
813
- process.stderr.write(
814
- `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
1422
+ const stalePriorReviewIds = botReviews.filter((r) => r.commitId !== input.headSha).map((r) => r.id);
1423
+ if (stalePriorReviewIds.length > 0) {
1424
+ await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
1425
+ }
1426
+ const reviewUrl = await postInlineReview(
1427
+ input.repo,
1428
+ prNumber,
1429
+ input.headSha,
1430
+ comments,
1431
+ stickyRef?.url,
1432
+ findingsMarker,
1433
+ ghApi
1434
+ );
1435
+ process.stderr.write(
1436
+ `Posted a review with ${String(comments.length)} inline comment(s) on PR #${String(prNumber)}
815
1437
  `
816
- );
1438
+ );
1439
+ await minimizeSupersededComments(input.repo, prNumber, input.headSha, input.botLogin, ghApi);
1440
+ if (comments.length > 0 && stickyRef !== null) {
1441
+ const confirmedDisposition = {
1442
+ kind: "posted",
1443
+ count: comments.length,
1444
+ sha: input.headSha
1445
+ };
1446
+ try {
1447
+ await patchComment(
1448
+ input.repo,
1449
+ stickyRef.id,
1450
+ renderBody(confirmedDisposition, reviewUrl),
1451
+ ghApi
1452
+ );
1453
+ process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
1454
+ `);
1455
+ } catch (err) {
1456
+ process.stderr.write(
1457
+ `Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
1458
+ `
1459
+ );
1460
+ }
817
1461
  }
818
1462
  };
819
1463
  var renderOutputs = (result) => {
@@ -952,6 +1596,8 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
952
1596
  diffSize: Buffer.byteLength(diff, "utf8")
953
1597
  };
954
1598
  };
1599
+
1600
+ // src/extract.ts
955
1601
  var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
956
1602
  var parseNativeForExtraction = (raw) => ({
957
1603
  result: fieldOf(raw, "result"),
@@ -1011,13 +1657,6 @@ var tryParseJson = (text) => {
1011
1657
  return { ok: false };
1012
1658
  }
1013
1659
  };
1014
- var readFileOrNull = (path) => {
1015
- try {
1016
- return readFileSync(path, "utf-8");
1017
- } catch {
1018
- return null;
1019
- }
1020
- };
1021
1660
  var candidateFromJsonText = (kind, text) => {
1022
1661
  if (text === null) return null;
1023
1662
  const parsed = tryParseJson(text);
@@ -1025,7 +1664,7 @@ var candidateFromJsonText = (kind, text) => {
1025
1664
  };
1026
1665
  var FENCE_OPEN = /^\s*(`{3,})/;
1027
1666
  var FENCE_MARKER_ONLY = /^`+$/;
1028
- var scanLine = (state, line) => {
1667
+ var scanLine2 = (state, line) => {
1029
1668
  if (state.openLength === null) {
1030
1669
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1031
1670
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1034,7 +1673,27 @@ var scanLine = (state, line) => {
1034
1673
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1035
1674
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1036
1675
  };
1037
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
1676
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
1677
+ var ladderFailureDiagnostics = (input) => {
1678
+ const native = parseNativeForExtraction(input.native);
1679
+ const preview = (s) => {
1680
+ const flat = s.replace(/\s+/g, " ").trim();
1681
+ return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
1682
+ };
1683
+ const lines = [];
1684
+ if (input.kind === "findings") {
1685
+ lines.push(
1686
+ input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
1687
+ );
1688
+ }
1689
+ lines.push(
1690
+ 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"
1691
+ );
1692
+ lines.push(
1693
+ typeof native.result === "string" ? `result rung: ${String(native.result.length)} chars, ${String(scanFencedBlocks(native.result).length)} fenced JSON block(s), none validated; preview: ${preview(native.result)}` : "result rung: absent or not a string"
1694
+ );
1695
+ return lines.join("\n");
1696
+ };
1038
1697
  var describeLadderFailure = (outcome) => {
1039
1698
  switch (outcome.kind) {
1040
1699
  case "error-envelope":
@@ -1117,36 +1776,92 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1117
1776
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1118
1777
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1119
1778
  }));
1120
- var adaptClaudeCode = (native, agentFilePath) => {
1121
- const outcome = extractStructured({ kind: "findings", native, agentFilePath });
1122
- if (outcome.kind !== "ok") {
1123
- return left(describeLadderFailure(outcome));
1124
- }
1125
- const resolution = resolve("findings", outcome.candidate);
1126
- return resolution.kind === "ok" ? right({
1127
- schema_version: resolution.version,
1128
- findings: resolution.value,
1779
+ var findingsOutcome = (native, agentFilePath) => {
1780
+ const ladder = extractStructured({ kind: "findings", native, agentFilePath });
1781
+ if (ladder.kind !== "ok")
1782
+ return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
1783
+ const resolution = resolve("findings", ladder.candidate);
1784
+ return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
1785
+ kind: "telemetry-only",
1786
+ reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
1787
+ };
1788
+ };
1789
+ var withMeta = (base, meta) => ({
1790
+ ...base,
1791
+ ...meta.route ? { route: meta.route } : {},
1792
+ ...meta.effort ? { effort: meta.effort } : {}
1793
+ });
1794
+ var resolveTelemetry = (native, meta) => {
1795
+ const fb = native.models.length === 0 ? meta.transcriptFallback?.() : void 0;
1796
+ const useFallback = fb !== void 0 && fb.models.length > 0;
1797
+ return withMeta(
1798
+ useFallback ? {
1799
+ models: [...fb.models],
1800
+ turns: fb.turns,
1801
+ duration_ms: fb.durationMs,
1802
+ vendor_cost_usd: native.vendorCostUsd
1803
+ } : {
1804
+ models: native.models,
1805
+ turns: native.turns,
1806
+ duration_ms: native.durationMs,
1807
+ vendor_cost_usd: native.vendorCostUsd
1808
+ },
1809
+ meta
1810
+ );
1811
+ };
1812
+ var nativeTelemetry = (native, meta) => resolveTelemetry(
1813
+ {
1129
1814
  models: mapModelUsage(native.modelUsage),
1130
1815
  turns: native.num_turns,
1131
- duration_ms: native.duration_ms,
1132
- vendor_cost_usd: native.total_cost_usd ?? null
1133
- }) : left(
1134
- "internal error: the extraction ladder validated a candidate the registry then rejected"
1135
- );
1816
+ durationMs: native.duration_ms,
1817
+ vendorCostUsd: native.total_cost_usd ?? null
1818
+ },
1819
+ meta
1820
+ );
1821
+ var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
1822
+ var buildEnvelope = (telemetry, native, agentFilePath) => {
1823
+ const outcome = findingsOutcome(native, agentFilePath);
1824
+ switch (outcome.kind) {
1825
+ case "ok":
1826
+ return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
1827
+ case "telemetry-only":
1828
+ return {
1829
+ schema_version: DEFAULT_SCHEMA_VERSION,
1830
+ findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
1831
+
1832
+ ${outcome.reason}`),
1833
+ ...telemetry
1834
+ };
1835
+ }
1136
1836
  };
1137
- var adapt = (adapterName, native, agentFilePath) => {
1837
+ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1138
1838
  switch (adapterName) {
1139
1839
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
1140
1840
  case "claude-code": {
1841
+ if (native === void 0 || native === null)
1842
+ return right(buildEnvelope(absentTelemetry(meta), void 0, agentFilePath));
1141
1843
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
1142
- if (decoded._tag === "Left") {
1844
+ if (decoded._tag === "Left")
1143
1845
  return left("native envelope does not match the Claude Code output shape");
1144
- }
1145
- return adaptClaudeCode(decoded.right, agentFilePath);
1846
+ return right(buildEnvelope(nativeTelemetry(decoded.right, meta), native, agentFilePath));
1146
1847
  }
1147
1848
  }
1148
1849
  };
1149
1850
 
1851
+ // src/settings.ts
1852
+ var composeReviewSettings = (opts) => {
1853
+ const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
1854
+ return {
1855
+ hooks: {
1856
+ Stop: [
1857
+ { hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
1858
+ ],
1859
+ PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
1860
+ PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
1861
+ }
1862
+ };
1863
+ };
1864
+
1150
1865
  // src/index.ts
1151
1866
  var readJSON = (path) => {
1152
1867
  try {
@@ -1161,6 +1876,54 @@ var fail = (msg) => {
1161
1876
  `);
1162
1877
  process.exit(1);
1163
1878
  };
1879
+ var readJSONOrAbsent = (path) => {
1880
+ const read = (() => {
1881
+ try {
1882
+ return { text: readFileSync(resolve$1(path), "utf-8") };
1883
+ } catch (err) {
1884
+ return { error: err instanceof Error ? err.message : String(err) };
1885
+ }
1886
+ })();
1887
+ if ("error" in read) {
1888
+ process.stderr.write(
1889
+ `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry (issue #39)
1890
+ `
1891
+ );
1892
+ return void 0;
1893
+ }
1894
+ if (read.text.trim() === "") {
1895
+ process.stderr.write(
1896
+ `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry (issue #39)
1897
+ `
1898
+ );
1899
+ return void 0;
1900
+ }
1901
+ try {
1902
+ return JSON.parse(read.text);
1903
+ } catch (err) {
1904
+ process.stderr.write(
1905
+ `code-review: native envelope ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 proceeding with no native telemetry (issue #39)
1906
+ `
1907
+ );
1908
+ return void 0;
1909
+ }
1910
+ };
1911
+ var readStdinJSON = () => {
1912
+ if (process.stdin.isTTY) return null;
1913
+ const raw = (() => {
1914
+ try {
1915
+ return readFileSync(0, "utf-8");
1916
+ } catch {
1917
+ return "";
1918
+ }
1919
+ })();
1920
+ if (raw.trim() === "") return null;
1921
+ try {
1922
+ return JSON.parse(raw);
1923
+ } catch {
1924
+ return null;
1925
+ }
1926
+ };
1164
1927
  var decode = (either, label) => {
1165
1928
  try {
1166
1929
  return unsafeUnwrap(either);
@@ -1178,15 +1941,26 @@ var unwrapAdapt = (either) => {
1178
1941
  }
1179
1942
  throw new Error("unreachable");
1180
1943
  };
1944
+ var transcriptFallbackFrom = (path) => {
1945
+ const tree = readTranscriptTree(resolve$1(path));
1946
+ if (tree.missing)
1947
+ process.stderr.write(
1948
+ `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback (issue #36)
1949
+ `
1950
+ );
1951
+ const usage = sumTranscriptUsage(tree.entries);
1952
+ return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
1953
+ };
1181
1954
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1182
1955
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1183
1956
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
1184
- var resolvePricesPath = (pricesArg) => {
1185
- if (pricesArg) return resolve$1(pricesArg);
1957
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
1958
+ var resolvePrices = (pricesArg) => {
1959
+ if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
1186
1960
  process.stderr.write(
1187
- "code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
1961
+ "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
1188
1962
  );
1189
- return bundledPath("schema", "prices.example.json");
1963
+ return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
1190
1964
  };
1191
1965
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
1192
1966
  var renderCmd = defineCommand({
@@ -1219,12 +1993,11 @@ var renderCmd = defineCommand({
1219
1993
  },
1220
1994
  route: {
1221
1995
  type: "string",
1222
- description: 'Review route label (e.g. "full review" or "mechanic")',
1223
- required: true
1996
+ description: "Review route label; overrides the envelope's route when set (default: read from the envelope)"
1224
1997
  },
1225
1998
  effort: {
1226
1999
  type: "string",
1227
- description: 'Effort label to render in the route line (e.g. "max" or "low"); omitted when absent'
2000
+ description: "Effort label; overrides the envelope's effort when set (default: read from the envelope)"
1228
2001
  },
1229
2002
  "test-report": {
1230
2003
  type: "string",
@@ -1235,19 +2008,21 @@ var renderCmd = defineCommand({
1235
2008
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1236
2009
  const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
1237
2010
  const templatePath = resolveTemplatePath(args.template);
1238
- const pricesPath = resolvePricesPath(args.prices);
1239
- const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
2011
+ const priceResolution = resolvePrices(args.prices);
2012
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
1240
2013
  const template = readFileSync(templatePath, "utf-8");
1241
2014
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
1242
2015
  const output = render({
1243
2016
  findings,
1244
2017
  envelope,
1245
2018
  prices,
2019
+ pricesProvided: priceResolution.kind === "provided",
1246
2020
  template,
1247
2021
  reviewedSha: args["reviewed-sha"],
1248
2022
  route: args.route,
1249
2023
  effort: args.effort,
1250
- testReport
2024
+ testReport,
2025
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1251
2026
  });
1252
2027
  process.stdout.write(output);
1253
2028
  }
@@ -1270,14 +2045,17 @@ var inlineCmd = defineCommand({
1270
2045
  },
1271
2046
  template: {
1272
2047
  type: "string",
1273
- description: "Path to inline comment Eta template (default: built-in format)"
2048
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1274
2049
  }
1275
2050
  },
1276
2051
  run: async ({ args }) => {
1277
2052
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1278
2053
  const diff = readFileSync(resolve$1(args.diff), "utf-8");
1279
- const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
1280
- const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
2054
+ const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
2055
+ const { comments, strays } = buildInlineComments(findings.findings, diff, {
2056
+ inlineTemplate,
2057
+ findings
2058
+ });
1281
2059
  process.stdout.write(
1282
2060
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1283
2061
  );
@@ -1307,8 +2085,245 @@ var costCmd = defineCommand({
1307
2085
  process.stdout.write(JSON.stringify(report, null, 2));
1308
2086
  }
1309
2087
  });
1310
- var declaredSchemaVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
1311
- var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
2088
+ var checkCostCmd = defineCommand({
2089
+ meta: {
2090
+ name: "check-cost",
2091
+ description: "Sum real USD spend from a live/finished Claude Code transcript tree (main session + subagents) against a price map \u2014 the correct cost when no clean result envelope exists (issue #36) and the outlook the review agent tracks in flight (issue #38)"
2092
+ },
2093
+ args: {
2094
+ transcript: {
2095
+ type: "positional",
2096
+ description: "Path to the session transcript JSONL (the hook's transcript_path)",
2097
+ required: true
2098
+ },
2099
+ prices: {
2100
+ type: "string",
2101
+ description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
2102
+ }
2103
+ },
2104
+ run: async ({ args }) => {
2105
+ const tree = readTranscriptTree(resolve$1(args.transcript));
2106
+ if (tree.missing) {
2107
+ process.stderr.write(
2108
+ `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend (issue #36)
2109
+ `
2110
+ );
2111
+ }
2112
+ const usage = sumTranscriptUsage(tree.entries);
2113
+ const priceResolution = resolvePrices(args.prices);
2114
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
2115
+ const report = computeCost(usage.models, prices);
2116
+ process.stdout.write(
2117
+ `${JSON.stringify(
2118
+ {
2119
+ ...report,
2120
+ turns: usage.turns,
2121
+ durationMs: usage.durationMs,
2122
+ transcripts: tree.files,
2123
+ pricesProvided: priceResolution.kind === "provided"
2124
+ },
2125
+ null,
2126
+ 2
2127
+ )}
2128
+ `
2129
+ );
2130
+ }
2131
+ });
2132
+ var tryReadPrices = (path) => {
2133
+ try {
2134
+ const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
2135
+ return decoded._tag === "Right" ? decoded.right : null;
2136
+ } catch {
2137
+ return null;
2138
+ }
2139
+ };
2140
+ var parseBudgetUsd = (raw) => {
2141
+ if (raw === void 0) return null;
2142
+ const n = Number.parseFloat(raw);
2143
+ return Number.isFinite(n) && n >= 0 ? n : null;
2144
+ };
2145
+ var transcriptPathOf = (input) => {
2146
+ const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
2147
+ return typeof tp === "string" ? tp : void 0;
2148
+ };
2149
+ var budgetHookCmd = defineCommand({
2150
+ meta: {
2151
+ name: "budget-hook",
2152
+ description: "Self-dispatching Claude Code hook for budget discipline (issue #38): on PostToolBatch, steer the agent to converge once spend or wall-clock enters its soft wind-down reserve; on PreToolUse, inside the hard reserve, deny the budget-burning tools (subagent spawns, arbitrary shell, web) while leaving the draft-delivery path open. Reads the hook payload on stdin, measures spend from its transcript, and decides on $ and/or time. Degrades to a no-op on any error."
2153
+ },
2154
+ args: {
2155
+ draft: {
2156
+ type: "string",
2157
+ description: "Path to the findings draft that is the sole permitted write target under forced convergence",
2158
+ required: true
2159
+ },
2160
+ "budget-usd": {
2161
+ type: "string",
2162
+ description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
2163
+ },
2164
+ wall: {
2165
+ type: "string",
2166
+ description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
2167
+ },
2168
+ prices: {
2169
+ type: "string",
2170
+ description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
2171
+ },
2172
+ "reserve-frac": {
2173
+ type: "string",
2174
+ description: "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)"
2175
+ },
2176
+ "reserve-usd": {
2177
+ type: "string",
2178
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2179
+ },
2180
+ "reserve-wall": {
2181
+ type: "string",
2182
+ description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
2183
+ }
2184
+ },
2185
+ run: async ({ args }) => {
2186
+ try {
2187
+ const draftPath = resolve$1(args.draft);
2188
+ const input = readStdinJSON();
2189
+ const transcriptPath = transcriptPathOf(input);
2190
+ const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
2191
+ const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
2192
+ const prices = args.prices ? tryReadPrices(args.prices) : null;
2193
+ const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
2194
+ const wallMs = args.wall ? parseWallMs(args.wall) : null;
2195
+ const output = evaluateBudgetHook(input, {
2196
+ spentUsd,
2197
+ budgetUsd: parseBudgetUsd(args["budget-usd"]),
2198
+ elapsedMs: anchoredElapsedMs({
2199
+ deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
2200
+ wallMs,
2201
+ firstTsMs: usage?.firstTsMs ?? null,
2202
+ nowMs: Date.now()
2203
+ }),
2204
+ wallMs,
2205
+ reserve: {
2206
+ frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
2207
+ flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
2208
+ flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
2209
+ },
2210
+ draftPath
2211
+ });
2212
+ process.stdout.write(`${JSON.stringify(output)}
2213
+ `);
2214
+ } catch (err) {
2215
+ process.stderr.write(
2216
+ `code-review budget-hook: degrading to no-op \u2014 ${err instanceof Error ? err.message : String(err)}
2217
+ `
2218
+ );
2219
+ process.stdout.write("{}\n");
2220
+ }
2221
+ }
2222
+ });
2223
+ var printSettingsCmd = defineCommand({
2224
+ meta: {
2225
+ name: "print-settings",
2226
+ description: "Emit ONE Claude Code --settings JSON composing the review agent's discipline (issue #38): the Stop deliverable gate plus the budget hooks (PreToolUse forced convergence + PostToolBatch steer) wired from one self-dispatching command. The review job generates this once and passes it as --settings."
2227
+ },
2228
+ args: {
2229
+ draft: {
2230
+ type: "string",
2231
+ description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
2232
+ required: true
2233
+ },
2234
+ kind: {
2235
+ type: "string",
2236
+ description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
2237
+ },
2238
+ schema: {
2239
+ type: "string",
2240
+ description: "Path to a schema file for the Stop gate (wins over --kind)"
2241
+ },
2242
+ "schema-version": {
2243
+ type: "string",
2244
+ description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
2245
+ },
2246
+ "max-nudges": {
2247
+ type: "string",
2248
+ description: "Stop-gate nudge budget before relenting (default: 5)"
2249
+ },
2250
+ counter: {
2251
+ type: "string",
2252
+ description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
2253
+ },
2254
+ "budget-usd": {
2255
+ type: "string",
2256
+ description: "Dollar budget the cost axis is measured against (needs --prices)"
2257
+ },
2258
+ wall: {
2259
+ type: "string",
2260
+ description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
2261
+ },
2262
+ prices: {
2263
+ type: "string",
2264
+ description: "Price map JSON to recompute real spend from the transcript"
2265
+ },
2266
+ "reserve-frac": {
2267
+ type: "string",
2268
+ description: "Wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2269
+ },
2270
+ "reserve-usd": {
2271
+ type: "string",
2272
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2273
+ },
2274
+ "reserve-wall": {
2275
+ type: "string",
2276
+ description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
2277
+ }
2278
+ },
2279
+ run: async ({ args }) => {
2280
+ if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
2281
+ fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
2282
+ const settings = composeReviewSettings({
2283
+ draftPath: resolve$1(args.draft),
2284
+ stop: {
2285
+ kind: args.kind,
2286
+ schema: args.schema,
2287
+ schemaVersion: args["schema-version"],
2288
+ maxNudges: args["max-nudges"],
2289
+ counter: args.counter
2290
+ },
2291
+ budget: {
2292
+ budgetUsd: args["budget-usd"],
2293
+ wall: args.wall,
2294
+ prices: args.prices,
2295
+ reserveFrac: args["reserve-frac"],
2296
+ reserveUsd: args["reserve-usd"],
2297
+ reserveWall: args["reserve-wall"]
2298
+ }
2299
+ });
2300
+ process.stdout.write(`${JSON.stringify(settings)}
2301
+ `);
2302
+ }
2303
+ });
2304
+ var deadlineCmd = defineCommand({
2305
+ meta: {
2306
+ name: "deadline",
2307
+ description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall). The review job exports this as CODE_REVIEW_DEADLINE_EPOCH right before `claude -p` so every budget hook \u2014 the main agent's and each fan-out subagent's \u2014 measures the SAME true remaining wall instead of its own transcript's start, which reads \u22480 in a fresh subagent and leaves the fan-out unsteered (issue #45)."
2308
+ },
2309
+ args: {
2310
+ wall: {
2311
+ type: "string",
2312
+ description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
2313
+ required: true
2314
+ }
2315
+ },
2316
+ run: async ({ args }) => {
2317
+ const wallMs = parseWallMs(args.wall);
2318
+ if (wallMs === null) {
2319
+ fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
2320
+ } else {
2321
+ process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
2322
+ `);
2323
+ }
2324
+ }
2325
+ });
2326
+ var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
1312
2327
  var validateCmd = defineCommand({
1313
2328
  meta: {
1314
2329
  name: "validate",
@@ -1367,11 +2382,29 @@ var adaptCmd = defineCommand({
1367
2382
  "agent-file": {
1368
2383
  type: "string",
1369
2384
  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)"
2385
+ },
2386
+ route: {
2387
+ type: "string",
2388
+ description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
2389
+ },
2390
+ effort: {
2391
+ type: "string",
2392
+ description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
2393
+ },
2394
+ transcript: {
2395
+ type: "string",
2396
+ description: "Path to the session transcript (the main .jsonl); when the native envelope carries no per-model usage \u2014 a wall-clock kill leaves it empty (issue #39) \u2014 telemetry is recovered from the transcript tree (main + subagents) so cost is real, not $0.00 (issue #36)"
1370
2397
  }
1371
2398
  },
1372
2399
  run: async ({ args }) => {
1373
2400
  const envelope = unwrapAdapt(
1374
- adapt(requireAdapterName(args.adapter), readJSON(args.native), args["agent-file"])
2401
+ adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
2402
+ route: args.route,
2403
+ effort: args.effort,
2404
+ ...args.transcript ? {
2405
+ transcriptFallback: () => transcriptFallbackFrom(args.transcript)
2406
+ } : {}
2407
+ })
1375
2408
  );
1376
2409
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}
1377
2410
  `);
@@ -1416,16 +2449,18 @@ var extractCmd = defineCommand({
1416
2449
  run: async ({ args }) => {
1417
2450
  requireAdapterName(args.adapter);
1418
2451
  const kind = requireExtractSchemaKind(args.kind);
1419
- const outcome = extractStructured({
1420
- kind,
1421
- native: readJSON(args.native),
1422
- agentFilePath: args["agent-file"]
1423
- });
2452
+ const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
2453
+ const outcome = extractStructured(input);
1424
2454
  if (outcome.kind === "ok") {
1425
2455
  process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
1426
2456
  `);
1427
2457
  return;
1428
2458
  }
2459
+ if (outcome.kind === "none" || outcome.kind === "ambiguous") {
2460
+ process.stderr.write(`extract: recovery failed \u2014
2461
+ ${ladderFailureDiagnostics(input)}
2462
+ `);
2463
+ }
1429
2464
  if (kind === "triage") {
1430
2465
  process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
1431
2466
  `);
@@ -1434,6 +2469,70 @@ var extractCmd = defineCommand({
1434
2469
  fail(describeLadderFailure(outcome));
1435
2470
  }
1436
2471
  });
2472
+ var withoutPatch = (finding) => {
2473
+ const copy = { ...finding };
2474
+ delete copy.patch;
2475
+ return copy;
2476
+ };
2477
+ var readFileLines = (path) => {
2478
+ try {
2479
+ const rawLines = readFileSync(path, "utf-8").split("\n");
2480
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
2481
+ } catch {
2482
+ return null;
2483
+ }
2484
+ };
2485
+ var validateFinding = (finding, repoRoot) => {
2486
+ if (finding.patch === void 0) return finding;
2487
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
2488
+ if (lines === null) {
2489
+ process.stderr.write(
2490
+ `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
2491
+ `
2492
+ );
2493
+ return withoutPatch(finding);
2494
+ }
2495
+ const result = validatePatch(finding.patch, lines);
2496
+ switch (result.kind) {
2497
+ case "anchored":
2498
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
2499
+ case "keep":
2500
+ return finding;
2501
+ case "drop":
2502
+ process.stderr.write(
2503
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
2504
+ `
2505
+ );
2506
+ return withoutPatch(finding);
2507
+ }
2508
+ };
2509
+ var validatePatchesCmd = defineCommand({
2510
+ meta: {
2511
+ name: "validate-patches",
2512
+ description: "Validate each finding's patch against the real PR-head tree, aligning the finding's range and keeping the patch when it anchors, keeping it unaligned when it's a pure insertion, or dropping it when it doesn't apply (issue #10)"
2513
+ },
2514
+ args: {
2515
+ findings: {
2516
+ type: "positional",
2517
+ description: "Path to findings JSON",
2518
+ required: true
2519
+ },
2520
+ "repo-root": {
2521
+ type: "string",
2522
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
2523
+ }
2524
+ },
2525
+ run: async ({ args }) => {
2526
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
2527
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
2528
+ const validated = {
2529
+ ...findings,
2530
+ findings: findings.findings.map((f) => validateFinding(f, repoRoot))
2531
+ };
2532
+ process.stdout.write(`${JSON.stringify(validated, null, 2)}
2533
+ `);
2534
+ }
2535
+ });
1437
2536
  var requireAdapterName = (name) => {
1438
2537
  if (isAdapterName(name)) return name;
1439
2538
  fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
@@ -1456,7 +2555,7 @@ var requireSchemaPath = (kind, version) => {
1456
2555
  var printSchemaCmd = defineCommand({
1457
2556
  meta: {
1458
2557
  name: "print-schema",
1459
- description: "Print a bundled schema JSON"
2558
+ description: "Print a bundled schema JSON, ready to hand to a CLI's --json-schema (the $schema draft declaration is stripped)"
1460
2559
  },
1461
2560
  args: {
1462
2561
  name: {
@@ -1472,7 +2571,103 @@ var printSchemaCmd = defineCommand({
1472
2571
  run: async ({ args }) => {
1473
2572
  const schemaKind = requireSchemaKind(args.name);
1474
2573
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
1475
- process.stdout.write(readFileSync(schemaPath, "utf-8"));
2574
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
2575
+ const enforcementSchema = Object.fromEntries(
2576
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
2577
+ );
2578
+ process.stdout.write(`${JSON.stringify(enforcementSchema, null, 2)}
2579
+ `);
2580
+ }
2581
+ });
2582
+ var MAX_NUDGES_DEFAULT = 5;
2583
+ var drainStdin = () => {
2584
+ if (process.stdin.isTTY) return;
2585
+ try {
2586
+ readFileSync(0);
2587
+ } catch {
2588
+ }
2589
+ };
2590
+ var requireMaxNudges = (raw) => {
2591
+ if (raw === void 0) return MAX_NUDGES_DEFAULT;
2592
+ if (!/^\d+$/.test(raw)) {
2593
+ fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
2594
+ }
2595
+ const n = Number.parseInt(raw, 10);
2596
+ if (n < 1) {
2597
+ fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
2598
+ }
2599
+ return n;
2600
+ };
2601
+ var stopGateCmd = defineCommand({
2602
+ meta: {
2603
+ name: "stop-gate",
2604
+ 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."
2605
+ },
2606
+ args: {
2607
+ draft: {
2608
+ type: "string",
2609
+ description: "Path to the findings document the agent must produce and keep valid",
2610
+ required: true
2611
+ },
2612
+ kind: {
2613
+ type: "string",
2614
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
2615
+ },
2616
+ schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
2617
+ "schema-version": {
2618
+ type: "string",
2619
+ description: "Schema major.minor to validate against (default: the draft's declared version)"
2620
+ },
2621
+ "max-nudges": {
2622
+ type: "string",
2623
+ description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
2624
+ },
2625
+ counter: {
2626
+ type: "string",
2627
+ description: "Path for the nudge counter (default: <draft>.nudges)"
2628
+ },
2629
+ "print-settings": {
2630
+ type: "boolean",
2631
+ description: "Print the Stop-hook settings JSON that wires this gate, then exit"
2632
+ }
2633
+ },
2634
+ run: async ({ args }) => {
2635
+ const draftPath = resolve$1(args.draft);
2636
+ if (args["print-settings"]) {
2637
+ const command = defaultHookCommand(draftPath, {
2638
+ kind: args.kind,
2639
+ schema: args.schema,
2640
+ schemaVersion: args["schema-version"],
2641
+ maxNudges: args["max-nudges"],
2642
+ counter: args.counter
2643
+ });
2644
+ process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
2645
+ `);
2646
+ return;
2647
+ }
2648
+ drainStdin();
2649
+ const kind = requireSchemaKind(args.kind || "findings");
2650
+ const maxNudges = requireMaxNudges(args["max-nudges"]);
2651
+ const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
2652
+ const state = draftState(
2653
+ draftPath,
2654
+ (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
2655
+ );
2656
+ const nudges = readNudges(counterPath);
2657
+ const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
2658
+ if (decision.kind === "block") {
2659
+ try {
2660
+ bumpNudges(counterPath, nudges);
2661
+ } catch (err) {
2662
+ process.stderr.write(
2663
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
2664
+ `
2665
+ );
2666
+ return;
2667
+ }
2668
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
2669
+ `);
2670
+ }
1476
2671
  }
1477
2672
  });
1478
2673
  var gatherCmd = defineCommand({
@@ -1559,12 +2754,11 @@ var postCmd = defineCommand({
1559
2754
  },
1560
2755
  "inline-template": {
1561
2756
  type: "string",
1562
- description: "Path to inline comment Eta template (default: built-in format)"
2757
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1563
2758
  },
1564
2759
  route: {
1565
2760
  type: "string",
1566
- description: 'Review route label (e.g. "full review" or "mechanic")',
1567
- required: true
2761
+ description: "Review route label; overrides the envelope's route when set (default: read from the envelope)"
1568
2762
  },
1569
2763
  "bot-login": {
1570
2764
  type: "string",
@@ -1576,27 +2770,40 @@ var postCmd = defineCommand({
1576
2770
  },
1577
2771
  effort: {
1578
2772
  type: "string",
1579
- description: 'Effort label to render in the route line (e.g. "max" or "low"); omitted when absent'
2773
+ description: "Effort label; overrides the envelope's effort when set (default: read from the envelope)"
1580
2774
  },
1581
2775
  "test-report": {
1582
2776
  type: "string",
1583
2777
  description: TEST_REPORT_DESCRIPTION
2778
+ },
2779
+ "run-url": {
2780
+ type: "string",
2781
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
2782
+ },
2783
+ "json-url": {
2784
+ type: "string",
2785
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
1584
2786
  }
1585
2787
  },
1586
2788
  run: async ({ args }) => {
2789
+ const priceResolution = resolvePrices(args.prices);
1587
2790
  await post({
1588
2791
  repo: args.repo,
1589
2792
  headSha: args["head-sha"],
1590
2793
  botLogin: args["bot-login"] || "github-actions[bot]",
1591
2794
  findingsPath: args.findings,
1592
2795
  envelopePath: args.usage,
1593
- pricesPath: resolvePricesPath(args.prices),
2796
+ pricesPath: priceResolution.path,
2797
+ pricesProvided: priceResolution.kind === "provided",
1594
2798
  templatePath: resolveTemplatePath(args.template),
1595
- inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
2799
+ inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
1596
2800
  route: args.route,
1597
2801
  headBranch: args["head-branch"],
1598
2802
  effort: args.effort,
1599
- testReportPath: args["test-report"]
2803
+ testReportPath: args["test-report"],
2804
+ runUrl: args["run-url"],
2805
+ jsonUrl: args["json-url"],
2806
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1600
2807
  });
1601
2808
  }
1602
2809
  });
@@ -1604,7 +2811,7 @@ var main = defineCommand({
1604
2811
  meta: {
1605
2812
  name: "code-review",
1606
2813
  version: packageVersion,
1607
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
2814
+ description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, stop-gate, budget-hook, print-settings, and deadline"
1608
2815
  },
1609
2816
  subCommands: {
1610
2817
  gather: gatherCmd,
@@ -1612,10 +2819,16 @@ var main = defineCommand({
1612
2819
  inline: inlineCmd,
1613
2820
  post: postCmd,
1614
2821
  cost: costCmd,
2822
+ "check-cost": checkCostCmd,
1615
2823
  validate: validateCmd,
1616
2824
  adapt: adaptCmd,
1617
2825
  extract: extractCmd,
1618
- "print-schema": printSchemaCmd
2826
+ "validate-patches": validatePatchesCmd,
2827
+ "print-schema": printSchemaCmd,
2828
+ "stop-gate": stopGateCmd,
2829
+ "budget-hook": budgetHookCmd,
2830
+ "print-settings": printSettingsCmd,
2831
+ deadline: deadlineCmd
1619
2832
  }
1620
2833
  });
1621
2834
  if (!process.env["VITEST"]) {