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

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 } 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,119 @@ 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
+ try {
467
+ return readdirSync(join(dirname(mainPath), "subagents")).filter((name) => name.endsWith(".jsonl")).map((name) => join(dirname(mainPath), "subagents", name));
468
+ } catch {
469
+ return [];
470
+ }
471
+ };
472
+ var readTranscriptTree = (mainPath) => {
473
+ const mainText = readFileOrNull(mainPath);
474
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
475
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
476
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
477
+ const siblingReads = siblings.flatMap((path) => {
478
+ const text = readFileOrNull(path);
479
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
480
+ });
481
+ return {
482
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
483
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
484
+ missing: mainText === null
485
+ };
486
+ };
233
487
  var SeverityCodec = t.union([
234
488
  t.literal("critical"),
235
489
  t.literal("major"),
@@ -257,14 +511,16 @@ var FindingShape = t.intersection([
257
511
  end_line: LineNumber,
258
512
  severity: SeverityCodec,
259
513
  title: t.string,
260
- body: t.string
514
+ description: t.string,
515
+ reasoning: t.string,
516
+ confidence: Confidence
261
517
  }),
262
518
  t.partial({
263
519
  side: SideCodec,
264
- suggestion: t.union([t.string, t.null]),
265
- confidence: Confidence,
266
520
  code: t.string,
267
- code_url: t.string
521
+ code_url: t.string,
522
+ recommendation: t.string,
523
+ patch: t.string
268
524
  })
269
525
  ]);
270
526
  var EndGeStart = t.refinement(
@@ -310,7 +566,9 @@ var ResultEnvelopeCodec = t.intersection([
310
566
  duration_ms: TokenCount
311
567
  }),
312
568
  t.partial({
313
- vendor_cost_usd: t.union([t.number, t.null])
569
+ vendor_cost_usd: t.union([t.number, t.null]),
570
+ route: t.string,
571
+ effort: t.string
314
572
  })
315
573
  ]);
316
574
  var ModelPricesCodec = t.type({
@@ -338,7 +596,13 @@ var TestSummaryCodec = t.intersection([
338
596
  failures: t.array(TestFailureCodec)
339
597
  })
340
598
  ]);
341
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
599
+ var DEFAULT_SCHEMA_VERSION = "0.4.0";
600
+ var noticeFindings = (summary) => ({
601
+ schema_version: DEFAULT_SCHEMA_VERSION,
602
+ summary,
603
+ verdict: "comment",
604
+ findings: []
605
+ });
342
606
 
343
607
  // src/validate.ts
344
608
  var addFormats = _addFormats;
@@ -374,10 +638,221 @@ var unsafeUnwrap = (decoded) => {
374
638
  if (decoded._tag === "Right") return decoded.right;
375
639
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
376
640
  };
641
+
642
+ // src/stop-gate.ts
643
+ var whatsWrong = (state, draftPath, kind) => {
644
+ switch (state.kind) {
645
+ case "missing":
646
+ return `${draftPath} does not exist yet`;
647
+ case "unreadable":
648
+ return `${draftPath} could not be read: ${state.error}`;
649
+ case "invalid":
650
+ return `${draftPath} does not validate against the ${kind} schema:
651
+ ${state.errors.map((e) => ` - ${e}`).join("\n")}`;
652
+ }
653
+ };
654
+ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
655
+ if (state.kind === "valid") return { kind: "allow" };
656
+ if (nudges >= maxNudges) return { kind: "allow" };
657
+ return {
658
+ kind: "block",
659
+ reason: [
660
+ `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
661
+ `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.`,
662
+ `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
663
+ ].join("\n")
664
+ };
665
+ };
666
+ var draftState = (draftPath, resolveSchema) => {
667
+ let raw;
668
+ try {
669
+ raw = readFileSync(draftPath, "utf-8");
670
+ } catch (err) {
671
+ if (err instanceof Error && err.code === "ENOENT") {
672
+ return { kind: "missing" };
673
+ }
674
+ return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
675
+ }
676
+ let parsed;
677
+ try {
678
+ parsed = JSON.parse(raw);
679
+ } catch (err) {
680
+ return {
681
+ kind: "invalid",
682
+ errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
683
+ };
684
+ }
685
+ let schemaPath;
686
+ try {
687
+ schemaPath = resolveSchema(parsed);
688
+ } catch (err) {
689
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
690
+ }
691
+ try {
692
+ const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
693
+ return valid ? { kind: "valid" } : { kind: "invalid", errors };
694
+ } catch (err) {
695
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
696
+ }
697
+ };
698
+ var readNudges = (counterPath) => {
699
+ try {
700
+ const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
701
+ return Number.isInteger(n) && n >= 0 ? n : 0;
702
+ } catch {
703
+ return 0;
704
+ }
705
+ };
706
+ var bumpNudges = (counterPath, current) => {
707
+ writeFileSync(counterPath, `${String(current + 1)}
708
+ `);
709
+ };
710
+ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
711
+ var defaultHookCommand = (draftPath, opts) => [
712
+ "code-review stop-gate --draft",
713
+ shellQuote(draftPath),
714
+ ...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
715
+ ...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
716
+ ...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
717
+ ...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
718
+ ...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
719
+ ].join(" ");
720
+ var stopHookSettings = (command) => ({
721
+ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
722
+ });
723
+
724
+ // src/budget.ts
725
+ var DEFAULT_RESERVE = { frac: 0.15, flatUsd: 0.02, flatMs: 12e4 };
726
+ var SOFT_MULTIPLE = 2;
727
+ var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
728
+ var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
729
+ var axisSeverity = (a, frac) => {
730
+ const hardReserve = Math.max(a.flat, frac * a.limit);
731
+ const remaining = a.limit - a.used;
732
+ if (remaining <= hardReserve) return 2;
733
+ if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
734
+ return 0;
735
+ };
736
+ var decideBudget = (i) => {
737
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve.frac)), 0);
738
+ return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
739
+ };
740
+ var pct = (n) => `${String(Math.round(n * 100))}%`;
741
+ var money = (n) => `$${n.toFixed(2)}`;
742
+ var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
743
+ 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)}`;
744
+ 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`;
745
+ 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.`;
746
+ var budgetMessage = (i, phase, draftPath) => {
747
+ const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
748
+ return `Budget check \u2014 ${status}. ${directive(phase, draftPath)}`;
749
+ };
750
+ var invokesCodeReviewValidate = (toolInput) => {
751
+ const cmd = asRecord(toolInput)?.["command"];
752
+ return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
753
+ };
754
+ var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
755
+ var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
756
+ var blockedDuringConvergence = (toolName, toolInput) => {
757
+ if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
758
+ if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
759
+ return false;
760
+ };
761
+ var evaluateBudgetHook = (input, params) => {
762
+ const rec = asRecord(input);
763
+ const inputs = {
764
+ spentUsd: params.spentUsd,
765
+ budgetUsd: params.budgetUsd,
766
+ elapsedMs: params.elapsedMs,
767
+ wallMs: params.wallMs,
768
+ reserve: params.reserve
769
+ };
770
+ const phase = decideBudget(inputs);
771
+ switch (rec?.["hook_event_name"]) {
772
+ case "PostToolBatch":
773
+ return phase.kind === "ok" ? {} : {
774
+ hookSpecificOutput: {
775
+ hookEventName: "PostToolBatch",
776
+ additionalContext: budgetMessage(inputs, phase, params.draftPath)
777
+ }
778
+ };
779
+ case "PreToolUse": {
780
+ if (phase.kind !== "hard") return {};
781
+ const toolName = rec["tool_name"];
782
+ if (typeof toolName === "string" && blockedDuringConvergence(toolName, rec["tool_input"]))
783
+ return {
784
+ hookSpecificOutput: {
785
+ hookEventName: "PreToolUse",
786
+ permissionDecision: "deny",
787
+ permissionDecisionReason: budgetMessage(inputs, phase, params.draftPath)
788
+ }
789
+ };
790
+ return {};
791
+ }
792
+ default:
793
+ return {};
794
+ }
795
+ };
796
+ var parseWallMs = (raw) => {
797
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
798
+ if (m === null) return null;
799
+ const [, num = "", unit = "s"] = m;
800
+ const n = Number.parseFloat(num);
801
+ if (!Number.isFinite(n)) return null;
802
+ switch (unit) {
803
+ case "ms":
804
+ return n;
805
+ case "s":
806
+ return n * 1e3;
807
+ case "m":
808
+ return n * 6e4;
809
+ default:
810
+ return n * 36e5;
811
+ }
812
+ };
813
+ var parseFraction = (raw, fallback) => {
814
+ if (raw === void 0) return fallback;
815
+ const n = Number.parseFloat(raw);
816
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
817
+ };
818
+ var budgetHookCommand = (draftPath, opts) => [
819
+ "code-review budget-hook --draft",
820
+ shellQuote(draftPath),
821
+ ...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
822
+ ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
823
+ ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
824
+ ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
825
+ ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
826
+ ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
827
+ ].join(" ");
828
+
829
+ // src/format.ts
830
+ var FENCE_RE = /^\s*```/;
831
+ var scanLine = (state, line) => {
832
+ if (FENCE_RE.test(line)) {
833
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
834
+ }
835
+ if (state.inFence) {
836
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
837
+ }
838
+ const trimmed = line.replace(/[ \t]+$/, "");
839
+ if (trimmed !== "") {
840
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
841
+ }
842
+ const blankRun = state.blankRun + 1;
843
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
844
+ };
845
+ var formatMarkdown = (md) => {
846
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
847
+ return `${lines.join("\n").replace(/\n+$/, "")}
848
+ `;
849
+ };
850
+ var pad2 = (n) => String(n).padStart(2, "0");
851
+ var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
377
852
  var identity = (decoded) => decoded;
378
853
  var findingsTable = [
379
854
  {
380
- minor: "0.2",
855
+ minor: "0.4",
381
856
  defaultVersion: DEFAULT_SCHEMA_VERSION,
382
857
  schemaFile: "findings.schema.json",
383
858
  codec: FindingsCodec,
@@ -502,7 +977,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
502
977
  // src/post.ts
503
978
  var DEFAULT_MARKER = "<!-- code-review -->";
504
979
  var MAX_SUGGESTION_LINES = 10;
505
- var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
506
980
  var countSuggestionLines = (text) => text.split("\n").length;
507
981
  var checkLongSuggestions = (comments) => {
508
982
  const longFiles = [];
@@ -522,13 +996,6 @@ var checkLongSuggestions = (comments) => {
522
996
  });
523
997
  return { comments: adjusted, longFiles };
524
998
  };
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
999
  var loadFindings = (path) => {
533
1000
  let raw;
534
1001
  try {
@@ -590,9 +1057,18 @@ var loadTestReport = (path) => {
590
1057
  }
591
1058
  return decoded.right;
592
1059
  };
593
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
1060
+ var parseHtmlUrl = (raw) => {
1061
+ try {
1062
+ const parsed = JSON.parse(raw);
1063
+ return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
1064
+ } catch {
1065
+ return void 0;
1066
+ }
1067
+ };
1068
+ var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
1069
+ const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
594
1070
  const body = JSON.stringify({
595
- body: "",
1071
+ body: pointer,
596
1072
  commit_id: headSha,
597
1073
  event: "COMMENT",
598
1074
  comments: comments.map((c) => ({
@@ -600,10 +1076,14 @@ var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
600
1076
  line: c.line,
601
1077
  side: c.side,
602
1078
  ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
603
- body: c.body
1079
+ body: formatMarkdown(c.body)
604
1080
  }))
605
1081
  });
606
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
1082
+ const stdout = await ghApi(
1083
+ [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
1084
+ body
1085
+ );
1086
+ return parseHtmlUrl(stdout);
607
1087
  };
608
1088
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
609
1089
  const stdout = await ghApi(
@@ -623,33 +1103,45 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
623
1103
  const parsed = JSON.parse(last);
624
1104
  return { id: parsed.id, body: parsed.body };
625
1105
  };
1106
+ var parseCommentRef = (raw) => {
1107
+ try {
1108
+ const parsed = JSON.parse(raw);
1109
+ return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
1110
+ } catch {
1111
+ return null;
1112
+ }
1113
+ };
626
1114
  var patchComment = async (repo, commentId, body, ghApi) => {
627
- await ghApi(
1115
+ const stdout = await ghApi(
628
1116
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
629
1117
  JSON.stringify({ body })
630
1118
  );
1119
+ const htmlUrl = parseHtmlUrl(stdout);
1120
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
631
1121
  };
632
1122
  var postComment = async (repo, prNumber, body, ghApi) => {
633
- await ghApi(
1123
+ const stdout = await ghApi(
634
1124
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
635
1125
  JSON.stringify({ body })
636
1126
  );
1127
+ return parseCommentRef(stdout);
637
1128
  };
638
1129
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
639
1130
  if (existing !== null) {
640
- await patchComment(repo, existing.id, body, ghApi);
1131
+ const patched = await patchComment(repo, existing.id, body, ghApi);
641
1132
  process.stderr.write(
642
1133
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
643
1134
  `
644
1135
  );
645
- } else {
646
- await postComment(repo, prNumber, body, ghApi);
647
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
648
- `);
1136
+ return { id: existing.id, url: patched?.html_url };
649
1137
  }
1138
+ const posted = await postComment(repo, prNumber, body, ghApi);
1139
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
1140
+ `);
1141
+ return posted ? { id: posted.id, url: posted.html_url } : null;
650
1142
  };
651
1143
  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) => {
1144
+ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
653
1145
  const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
654
1146
  let reviews;
655
1147
  try {
@@ -658,10 +1150,12 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
658
1150
  return [];
659
1151
  }
660
1152
  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);
1153
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({
1154
+ id: r.id,
1155
+ commitId: typeof r.commit_id === "string" ? r.commit_id : ""
1156
+ }));
662
1157
  };
663
- var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
664
- const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
1158
+ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
665
1159
  for (const id of ids) {
666
1160
  try {
667
1161
  await ghApi(
@@ -682,6 +1176,84 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
682
1176
  }
683
1177
  }
684
1178
  };
1179
+ 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}}}}}}}}";
1180
+ var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1181
+ var supersededCommentId = (c, headSha, logins) => {
1182
+ if (typeof c !== "object" || c === null) return null;
1183
+ const o = c;
1184
+ const login = o.author?.login;
1185
+ const oid = o.originalCommit?.oid;
1186
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) && typeof oid === "string" && oid !== headSha ? o.id : null;
1187
+ };
1188
+ var supersededBotCommentIds = (raw, headSha, botLogin) => {
1189
+ let parsed;
1190
+ try {
1191
+ parsed = JSON.parse(raw);
1192
+ } catch {
1193
+ return { ids: [], truncated: false };
1194
+ }
1195
+ const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
1196
+ const truncated = conn?.pageInfo?.hasNextPage === true;
1197
+ const nodes = conn?.nodes;
1198
+ if (!Array.isArray(nodes)) return { ids: [], truncated };
1199
+ const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1200
+ const ids = nodes.flatMap((t4) => {
1201
+ const cnodes = t4.comments?.nodes;
1202
+ return Array.isArray(cnodes) ? cnodes.map((c) => supersededCommentId(c, headSha, logins)).filter((id) => id !== null) : [];
1203
+ });
1204
+ return { ids, truncated };
1205
+ };
1206
+ var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi) => {
1207
+ const slash = repo.indexOf("/");
1208
+ if (slash <= 0) return;
1209
+ const owner = repo.slice(0, slash);
1210
+ const name = repo.slice(slash + 1);
1211
+ let raw;
1212
+ try {
1213
+ raw = await ghApi([
1214
+ "graphql",
1215
+ "-f",
1216
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
1217
+ "-f",
1218
+ `owner=${owner}`,
1219
+ "-f",
1220
+ `name=${name}`,
1221
+ "-F",
1222
+ `pr=${String(prNumber)}`
1223
+ ]);
1224
+ } catch (err) {
1225
+ process.stderr.write(
1226
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1227
+ `
1228
+ );
1229
+ return;
1230
+ }
1231
+ const { ids, truncated } = supersededBotCommentIds(raw, headSha, botLogin);
1232
+ if (truncated) {
1233
+ process.stderr.write(
1234
+ `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1235
+ `
1236
+ );
1237
+ }
1238
+ let minimized = 0;
1239
+ for (const id of ids) {
1240
+ try {
1241
+ await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
1242
+ minimized += 1;
1243
+ } catch (err) {
1244
+ process.stderr.write(
1245
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1246
+ `
1247
+ );
1248
+ }
1249
+ }
1250
+ if (minimized > 0) {
1251
+ process.stderr.write(
1252
+ `Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
1253
+ `
1254
+ );
1255
+ }
1256
+ };
685
1257
  var post = async (input, ghApi = runGhApi) => {
686
1258
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
687
1259
  const resolution = resolvePr(candidates, input.headBranch);
@@ -706,24 +1278,28 @@ var post = async (input, ghApi = runGhApi) => {
706
1278
  DEFAULT_MARKER,
707
1279
  ghApi
708
1280
  );
709
- const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
710
- const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
711
1281
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
712
1282
  const decodedPrices = PriceMapCodec.decode(prices);
713
1283
  if (decodedPrices._tag === "Left") {
714
1284
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
715
1285
  }
716
1286
  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
- });
1287
+ const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
1288
+ const renderNotice = (message) => formatMarkdown(
1289
+ render({
1290
+ findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
1291
+ envelope: null,
1292
+ prices: decodedPrices.right,
1293
+ pricesProvided: input.pricesProvided,
1294
+ template,
1295
+ route: input.route,
1296
+ reviewedSha: input.headSha,
1297
+ effort: input.effort,
1298
+ runUrl: input.runUrl,
1299
+ jsonUrl: input.jsonUrl,
1300
+ postedAt: input.postedAt
1301
+ })
1302
+ );
727
1303
  if (isEmptyDiff(diff)) {
728
1304
  await upsertSticky(
729
1305
  input.repo,
@@ -749,27 +1325,36 @@ var post = async (input, ghApi = runGhApi) => {
749
1325
  const envelope = loadEnvelope(input.envelopePath);
750
1326
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
751
1327
  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);
1328
+ const body = formatMarkdown(
1329
+ render({
1330
+ findings,
1331
+ envelope: null,
1332
+ prices: decodedPrices.right,
1333
+ pricesProvided: input.pricesProvided,
1334
+ template,
1335
+ route: input.route,
1336
+ reviewedSha: input.headSha,
1337
+ effort: input.effort,
1338
+ testReport,
1339
+ inlineDisposition: { kind: "no-envelope" },
1340
+ runUrl: input.runUrl,
1341
+ jsonUrl: input.jsonUrl,
1342
+ postedAt: input.postedAt
1343
+ })
1344
+ );
1345
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
763
1346
  process.stderr.write(
764
1347
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
765
1348
  );
766
1349
  process.exit(0);
767
1350
  }
768
- const { comments: rawComments, strays } = buildInlineComments(
769
- findings.findings,
770
- diff,
771
- inlineTemplate
772
- );
1351
+ const findingsMarker = findingsPointer(findings, input.jsonUrl);
1352
+ const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
1353
+ inlineTemplate,
1354
+ models: envelope.models.map((m) => m.model),
1355
+ findings,
1356
+ jsonUrl: input.jsonUrl
1357
+ });
773
1358
  const { comments, longFiles } = checkLongSuggestions(rawComments);
774
1359
  for (const wf of longFiles) {
775
1360
  process.stderr.write(
@@ -777,43 +1362,87 @@ var post = async (input, ghApi = runGhApi) => {
777
1362
  `
778
1363
  );
779
1364
  }
780
- let body = render({
1365
+ const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
1366
+ const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
1367
+ const initialDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : void 0 : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1368
+ const commonRenderInput = {
781
1369
  findings,
782
1370
  envelope,
783
1371
  prices: decodedPrices.right,
1372
+ pricesProvided: input.pricesProvided,
784
1373
  template,
785
1374
  route: input.route,
786
1375
  reviewedSha: input.headSha,
787
1376
  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 += `
1377
+ testReport,
1378
+ severityCounts: computeSeverityCounts(findings.findings),
1379
+ strays,
1380
+ runUrl: input.runUrl,
1381
+ jsonUrl: input.jsonUrl,
1382
+ findingsPointer: findingsMarker,
1383
+ postedAt: input.postedAt
1384
+ };
1385
+ const longFilesNote = longFiles.length > 0 ? `
794
1386
 
795
1387
  ---
796
1388
 
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) {
1389
+ > **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.
1390
+ ` : "";
1391
+ const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
1392
+ const stickyRef = await upsertSticky(
1393
+ input.repo,
1394
+ prNumber,
1395
+ existingSticky,
1396
+ renderBody(initialDisposition),
1397
+ ghApi
1398
+ );
1399
+ if (comments.length === 0) return;
1400
+ if (alreadyReviewedThisSha) {
805
1401
  process.stderr.write(
806
- `Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
1402
+ `A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new inline review
807
1403
  `
808
1404
  );
809
1405
  return;
810
1406
  }
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)}
1407
+ const stalePriorReviewIds = botReviews.filter((r) => r.commitId !== input.headSha).map((r) => r.id);
1408
+ if (stalePriorReviewIds.length > 0) {
1409
+ await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
1410
+ }
1411
+ const reviewUrl = await postInlineReview(
1412
+ input.repo,
1413
+ prNumber,
1414
+ input.headSha,
1415
+ comments,
1416
+ stickyRef?.url,
1417
+ findingsMarker,
1418
+ ghApi
1419
+ );
1420
+ process.stderr.write(
1421
+ `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
815
1422
  `
816
- );
1423
+ );
1424
+ await minimizeSupersededComments(input.repo, prNumber, input.headSha, input.botLogin, ghApi);
1425
+ if (stickyRef !== null) {
1426
+ const confirmedDisposition = {
1427
+ kind: "posted",
1428
+ count: comments.length,
1429
+ sha: input.headSha
1430
+ };
1431
+ try {
1432
+ await patchComment(
1433
+ input.repo,
1434
+ stickyRef.id,
1435
+ renderBody(confirmedDisposition, reviewUrl),
1436
+ ghApi
1437
+ );
1438
+ process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
1439
+ `);
1440
+ } catch (err) {
1441
+ process.stderr.write(
1442
+ `Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
1443
+ `
1444
+ );
1445
+ }
817
1446
  }
818
1447
  };
819
1448
  var renderOutputs = (result) => {
@@ -952,6 +1581,8 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
952
1581
  diffSize: Buffer.byteLength(diff, "utf8")
953
1582
  };
954
1583
  };
1584
+
1585
+ // src/extract.ts
955
1586
  var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
956
1587
  var parseNativeForExtraction = (raw) => ({
957
1588
  result: fieldOf(raw, "result"),
@@ -1011,13 +1642,6 @@ var tryParseJson = (text) => {
1011
1642
  return { ok: false };
1012
1643
  }
1013
1644
  };
1014
- var readFileOrNull = (path) => {
1015
- try {
1016
- return readFileSync(path, "utf-8");
1017
- } catch {
1018
- return null;
1019
- }
1020
- };
1021
1645
  var candidateFromJsonText = (kind, text) => {
1022
1646
  if (text === null) return null;
1023
1647
  const parsed = tryParseJson(text);
@@ -1025,7 +1649,7 @@ var candidateFromJsonText = (kind, text) => {
1025
1649
  };
1026
1650
  var FENCE_OPEN = /^\s*(`{3,})/;
1027
1651
  var FENCE_MARKER_ONLY = /^`+$/;
1028
- var scanLine = (state, line) => {
1652
+ var scanLine2 = (state, line) => {
1029
1653
  if (state.openLength === null) {
1030
1654
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1031
1655
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1034,7 +1658,27 @@ var scanLine = (state, line) => {
1034
1658
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1035
1659
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1036
1660
  };
1037
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
1661
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
1662
+ var ladderFailureDiagnostics = (input) => {
1663
+ const native = parseNativeForExtraction(input.native);
1664
+ const preview = (s) => {
1665
+ const flat = s.replace(/\s+/g, " ").trim();
1666
+ return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
1667
+ };
1668
+ const lines = [];
1669
+ if (input.kind === "findings") {
1670
+ lines.push(
1671
+ input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
1672
+ );
1673
+ }
1674
+ lines.push(
1675
+ 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"
1676
+ );
1677
+ lines.push(
1678
+ 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"
1679
+ );
1680
+ return lines.join("\n");
1681
+ };
1038
1682
  var describeLadderFailure = (outcome) => {
1039
1683
  switch (outcome.kind) {
1040
1684
  case "error-envelope":
@@ -1117,36 +1761,74 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1117
1761
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1118
1762
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1119
1763
  }));
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,
1764
+ var findingsOutcome = (native, agentFilePath) => {
1765
+ const ladder = extractStructured({ kind: "findings", native, agentFilePath });
1766
+ if (ladder.kind !== "ok")
1767
+ return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
1768
+ const resolution = resolve("findings", ladder.candidate);
1769
+ return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
1770
+ kind: "telemetry-only",
1771
+ reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
1772
+ };
1773
+ };
1774
+ var withMeta = (base, meta) => ({
1775
+ ...base,
1776
+ ...meta.route ? { route: meta.route } : {},
1777
+ ...meta.effort ? { effort: meta.effort } : {}
1778
+ });
1779
+ var nativeTelemetry = (native, meta) => withMeta(
1780
+ {
1129
1781
  models: mapModelUsage(native.modelUsage),
1130
1782
  turns: native.num_turns,
1131
1783
  duration_ms: native.duration_ms,
1132
1784
  vendor_cost_usd: native.total_cost_usd ?? null
1133
- }) : left(
1134
- "internal error: the extraction ladder validated a candidate the registry then rejected"
1135
- );
1785
+ },
1786
+ meta
1787
+ );
1788
+ var absentTelemetry = (meta) => withMeta({ models: [], turns: 0, duration_ms: 0, vendor_cost_usd: null }, meta);
1789
+ var buildEnvelope = (telemetry, native, agentFilePath) => {
1790
+ const outcome = findingsOutcome(native, agentFilePath);
1791
+ switch (outcome.kind) {
1792
+ case "ok":
1793
+ return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
1794
+ case "telemetry-only":
1795
+ return {
1796
+ schema_version: DEFAULT_SCHEMA_VERSION,
1797
+ findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
1798
+
1799
+ ${outcome.reason}`),
1800
+ ...telemetry
1801
+ };
1802
+ }
1136
1803
  };
1137
- var adapt = (adapterName, native, agentFilePath) => {
1804
+ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1138
1805
  switch (adapterName) {
1139
1806
  // 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
1807
  case "claude-code": {
1808
+ if (native === void 0 || native === null)
1809
+ return right(buildEnvelope(absentTelemetry(meta), void 0, agentFilePath));
1141
1810
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
1142
- if (decoded._tag === "Left") {
1811
+ if (decoded._tag === "Left")
1143
1812
  return left("native envelope does not match the Claude Code output shape");
1144
- }
1145
- return adaptClaudeCode(decoded.right, agentFilePath);
1813
+ return right(buildEnvelope(nativeTelemetry(decoded.right, meta), native, agentFilePath));
1146
1814
  }
1147
1815
  }
1148
1816
  };
1149
1817
 
1818
+ // src/settings.ts
1819
+ var composeReviewSettings = (opts) => {
1820
+ const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
1821
+ return {
1822
+ hooks: {
1823
+ Stop: [
1824
+ { hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
1825
+ ],
1826
+ PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
1827
+ PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
1828
+ }
1829
+ };
1830
+ };
1831
+
1150
1832
  // src/index.ts
1151
1833
  var readJSON = (path) => {
1152
1834
  try {
@@ -1161,6 +1843,54 @@ var fail = (msg) => {
1161
1843
  `);
1162
1844
  process.exit(1);
1163
1845
  };
1846
+ var readJSONOrAbsent = (path) => {
1847
+ const read = (() => {
1848
+ try {
1849
+ return { text: readFileSync(resolve$1(path), "utf-8") };
1850
+ } catch (err) {
1851
+ return { error: err instanceof Error ? err.message : String(err) };
1852
+ }
1853
+ })();
1854
+ if ("error" in read) {
1855
+ process.stderr.write(
1856
+ `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry (issue #39)
1857
+ `
1858
+ );
1859
+ return void 0;
1860
+ }
1861
+ if (read.text.trim() === "") {
1862
+ process.stderr.write(
1863
+ `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry (issue #39)
1864
+ `
1865
+ );
1866
+ return void 0;
1867
+ }
1868
+ try {
1869
+ return JSON.parse(read.text);
1870
+ } catch (err) {
1871
+ process.stderr.write(
1872
+ `code-review: native envelope ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 proceeding with no native telemetry (issue #39)
1873
+ `
1874
+ );
1875
+ return void 0;
1876
+ }
1877
+ };
1878
+ var readStdinJSON = () => {
1879
+ if (process.stdin.isTTY) return null;
1880
+ const raw = (() => {
1881
+ try {
1882
+ return readFileSync(0, "utf-8");
1883
+ } catch {
1884
+ return "";
1885
+ }
1886
+ })();
1887
+ if (raw.trim() === "") return null;
1888
+ try {
1889
+ return JSON.parse(raw);
1890
+ } catch {
1891
+ return null;
1892
+ }
1893
+ };
1164
1894
  var decode = (either, label) => {
1165
1895
  try {
1166
1896
  return unsafeUnwrap(either);
@@ -1181,12 +1911,13 @@ var unwrapAdapt = (either) => {
1181
1911
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1182
1912
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1183
1913
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
1184
- var resolvePricesPath = (pricesArg) => {
1185
- if (pricesArg) return resolve$1(pricesArg);
1914
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
1915
+ var resolvePrices = (pricesArg) => {
1916
+ if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
1186
1917
  process.stderr.write(
1187
- "code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
1918
+ "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
1188
1919
  );
1189
- return bundledPath("schema", "prices.example.json");
1920
+ return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
1190
1921
  };
1191
1922
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
1192
1923
  var renderCmd = defineCommand({
@@ -1219,12 +1950,11 @@ var renderCmd = defineCommand({
1219
1950
  },
1220
1951
  route: {
1221
1952
  type: "string",
1222
- description: 'Review route label (e.g. "full review" or "mechanic")',
1223
- required: true
1953
+ description: "Review route label; overrides the envelope's route when set (default: read from the envelope)"
1224
1954
  },
1225
1955
  effort: {
1226
1956
  type: "string",
1227
- description: 'Effort label to render in the route line (e.g. "max" or "low"); omitted when absent'
1957
+ description: "Effort label; overrides the envelope's effort when set (default: read from the envelope)"
1228
1958
  },
1229
1959
  "test-report": {
1230
1960
  type: "string",
@@ -1235,19 +1965,21 @@ var renderCmd = defineCommand({
1235
1965
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1236
1966
  const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
1237
1967
  const templatePath = resolveTemplatePath(args.template);
1238
- const pricesPath = resolvePricesPath(args.prices);
1239
- const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
1968
+ const priceResolution = resolvePrices(args.prices);
1969
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
1240
1970
  const template = readFileSync(templatePath, "utf-8");
1241
1971
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
1242
1972
  const output = render({
1243
1973
  findings,
1244
1974
  envelope,
1245
1975
  prices,
1976
+ pricesProvided: priceResolution.kind === "provided",
1246
1977
  template,
1247
1978
  reviewedSha: args["reviewed-sha"],
1248
1979
  route: args.route,
1249
1980
  effort: args.effort,
1250
- testReport
1981
+ testReport,
1982
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1251
1983
  });
1252
1984
  process.stdout.write(output);
1253
1985
  }
@@ -1270,14 +2002,17 @@ var inlineCmd = defineCommand({
1270
2002
  },
1271
2003
  template: {
1272
2004
  type: "string",
1273
- description: "Path to inline comment Eta template (default: built-in format)"
2005
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1274
2006
  }
1275
2007
  },
1276
2008
  run: async ({ args }) => {
1277
2009
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1278
2010
  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);
2011
+ const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
2012
+ const { comments, strays } = buildInlineComments(findings.findings, diff, {
2013
+ inlineTemplate,
2014
+ findings
2015
+ });
1281
2016
  process.stdout.write(
1282
2017
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1283
2018
  );
@@ -1307,8 +2042,217 @@ var costCmd = defineCommand({
1307
2042
  process.stdout.write(JSON.stringify(report, null, 2));
1308
2043
  }
1309
2044
  });
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;
2045
+ var checkCostCmd = defineCommand({
2046
+ meta: {
2047
+ name: "check-cost",
2048
+ 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)"
2049
+ },
2050
+ args: {
2051
+ transcript: {
2052
+ type: "positional",
2053
+ description: "Path to the session transcript JSONL (the hook's transcript_path)",
2054
+ required: true
2055
+ },
2056
+ prices: {
2057
+ type: "string",
2058
+ description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
2059
+ }
2060
+ },
2061
+ run: async ({ args }) => {
2062
+ const tree = readTranscriptTree(resolve$1(args.transcript));
2063
+ if (tree.missing) {
2064
+ process.stderr.write(
2065
+ `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend (issue #36)
2066
+ `
2067
+ );
2068
+ }
2069
+ const usage = sumTranscriptUsage(tree.entries);
2070
+ const priceResolution = resolvePrices(args.prices);
2071
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
2072
+ const report = computeCost(usage.models, prices);
2073
+ process.stdout.write(
2074
+ `${JSON.stringify(
2075
+ {
2076
+ ...report,
2077
+ turns: usage.turns,
2078
+ durationMs: usage.durationMs,
2079
+ transcripts: tree.files,
2080
+ pricesProvided: priceResolution.kind === "provided"
2081
+ },
2082
+ null,
2083
+ 2
2084
+ )}
2085
+ `
2086
+ );
2087
+ }
2088
+ });
2089
+ var tryReadPrices = (path) => {
2090
+ try {
2091
+ const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
2092
+ return decoded._tag === "Right" ? decoded.right : null;
2093
+ } catch {
2094
+ return null;
2095
+ }
2096
+ };
2097
+ var parseBudgetUsd = (raw) => {
2098
+ if (raw === void 0) return null;
2099
+ const n = Number.parseFloat(raw);
2100
+ return Number.isFinite(n) && n >= 0 ? n : null;
2101
+ };
2102
+ var transcriptPathOf = (input) => {
2103
+ const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
2104
+ return typeof tp === "string" ? tp : void 0;
2105
+ };
2106
+ var budgetHookCmd = defineCommand({
2107
+ meta: {
2108
+ name: "budget-hook",
2109
+ 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."
2110
+ },
2111
+ args: {
2112
+ draft: {
2113
+ type: "string",
2114
+ description: "Path to the findings draft that is the sole permitted write target under forced convergence",
2115
+ required: true
2116
+ },
2117
+ "budget-usd": {
2118
+ type: "string",
2119
+ description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
2120
+ },
2121
+ wall: {
2122
+ type: "string",
2123
+ description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
2124
+ },
2125
+ prices: {
2126
+ type: "string",
2127
+ description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
2128
+ },
2129
+ "reserve-frac": {
2130
+ type: "string",
2131
+ 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)"
2132
+ },
2133
+ "reserve-usd": {
2134
+ type: "string",
2135
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2136
+ },
2137
+ "reserve-wall": {
2138
+ type: "string",
2139
+ description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
2140
+ }
2141
+ },
2142
+ run: async ({ args }) => {
2143
+ try {
2144
+ const draftPath = resolve$1(args.draft);
2145
+ const input = readStdinJSON();
2146
+ const transcriptPath = transcriptPathOf(input);
2147
+ const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
2148
+ const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
2149
+ const prices = args.prices ? tryReadPrices(args.prices) : null;
2150
+ const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
2151
+ const output = evaluateBudgetHook(input, {
2152
+ spentUsd,
2153
+ budgetUsd: parseBudgetUsd(args["budget-usd"]),
2154
+ elapsedMs: usage?.firstTsMs != null ? Math.max(0, Date.now() - usage.firstTsMs) : null,
2155
+ wallMs: args.wall ? parseWallMs(args.wall) : null,
2156
+ reserve: {
2157
+ frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
2158
+ flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
2159
+ flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
2160
+ },
2161
+ draftPath
2162
+ });
2163
+ process.stdout.write(`${JSON.stringify(output)}
2164
+ `);
2165
+ } catch (err) {
2166
+ process.stderr.write(
2167
+ `code-review budget-hook: degrading to no-op \u2014 ${err instanceof Error ? err.message : String(err)}
2168
+ `
2169
+ );
2170
+ process.stdout.write("{}\n");
2171
+ }
2172
+ }
2173
+ });
2174
+ var printSettingsCmd = defineCommand({
2175
+ meta: {
2176
+ name: "print-settings",
2177
+ 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."
2178
+ },
2179
+ args: {
2180
+ draft: {
2181
+ type: "string",
2182
+ description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
2183
+ required: true
2184
+ },
2185
+ kind: {
2186
+ type: "string",
2187
+ description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
2188
+ },
2189
+ schema: {
2190
+ type: "string",
2191
+ description: "Path to a schema file for the Stop gate (wins over --kind)"
2192
+ },
2193
+ "schema-version": {
2194
+ type: "string",
2195
+ description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
2196
+ },
2197
+ "max-nudges": {
2198
+ type: "string",
2199
+ description: "Stop-gate nudge budget before relenting (default: 5)"
2200
+ },
2201
+ counter: {
2202
+ type: "string",
2203
+ description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
2204
+ },
2205
+ "budget-usd": {
2206
+ type: "string",
2207
+ description: "Dollar budget the cost axis is measured against (needs --prices)"
2208
+ },
2209
+ wall: {
2210
+ type: "string",
2211
+ description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
2212
+ },
2213
+ prices: {
2214
+ type: "string",
2215
+ description: "Price map JSON to recompute real spend from the transcript"
2216
+ },
2217
+ "reserve-frac": {
2218
+ type: "string",
2219
+ description: "Wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2220
+ },
2221
+ "reserve-usd": {
2222
+ type: "string",
2223
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2224
+ },
2225
+ "reserve-wall": {
2226
+ type: "string",
2227
+ description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
2228
+ }
2229
+ },
2230
+ run: async ({ args }) => {
2231
+ if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
2232
+ fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
2233
+ const settings = composeReviewSettings({
2234
+ draftPath: resolve$1(args.draft),
2235
+ stop: {
2236
+ kind: args.kind,
2237
+ schema: args.schema,
2238
+ schemaVersion: args["schema-version"],
2239
+ maxNudges: args["max-nudges"],
2240
+ counter: args.counter
2241
+ },
2242
+ budget: {
2243
+ budgetUsd: args["budget-usd"],
2244
+ wall: args.wall,
2245
+ prices: args.prices,
2246
+ reserveFrac: args["reserve-frac"],
2247
+ reserveUsd: args["reserve-usd"],
2248
+ reserveWall: args["reserve-wall"]
2249
+ }
2250
+ });
2251
+ process.stdout.write(`${JSON.stringify(settings)}
2252
+ `);
2253
+ }
2254
+ });
2255
+ var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
1312
2256
  var validateCmd = defineCommand({
1313
2257
  meta: {
1314
2258
  name: "validate",
@@ -1367,11 +2311,22 @@ var adaptCmd = defineCommand({
1367
2311
  "agent-file": {
1368
2312
  type: "string",
1369
2313
  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)"
2314
+ },
2315
+ route: {
2316
+ type: "string",
2317
+ description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
2318
+ },
2319
+ effort: {
2320
+ type: "string",
2321
+ description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
1370
2322
  }
1371
2323
  },
1372
2324
  run: async ({ args }) => {
1373
2325
  const envelope = unwrapAdapt(
1374
- adapt(requireAdapterName(args.adapter), readJSON(args.native), args["agent-file"])
2326
+ adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
2327
+ route: args.route,
2328
+ effort: args.effort
2329
+ })
1375
2330
  );
1376
2331
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}
1377
2332
  `);
@@ -1416,16 +2371,18 @@ var extractCmd = defineCommand({
1416
2371
  run: async ({ args }) => {
1417
2372
  requireAdapterName(args.adapter);
1418
2373
  const kind = requireExtractSchemaKind(args.kind);
1419
- const outcome = extractStructured({
1420
- kind,
1421
- native: readJSON(args.native),
1422
- agentFilePath: args["agent-file"]
1423
- });
2374
+ const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
2375
+ const outcome = extractStructured(input);
1424
2376
  if (outcome.kind === "ok") {
1425
2377
  process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
1426
2378
  `);
1427
2379
  return;
1428
2380
  }
2381
+ if (outcome.kind === "none" || outcome.kind === "ambiguous") {
2382
+ process.stderr.write(`extract: recovery failed \u2014
2383
+ ${ladderFailureDiagnostics(input)}
2384
+ `);
2385
+ }
1429
2386
  if (kind === "triage") {
1430
2387
  process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
1431
2388
  `);
@@ -1434,6 +2391,70 @@ var extractCmd = defineCommand({
1434
2391
  fail(describeLadderFailure(outcome));
1435
2392
  }
1436
2393
  });
2394
+ var withoutPatch = (finding) => {
2395
+ const copy = { ...finding };
2396
+ delete copy.patch;
2397
+ return copy;
2398
+ };
2399
+ var readFileLines = (path) => {
2400
+ try {
2401
+ const rawLines = readFileSync(path, "utf-8").split("\n");
2402
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
2403
+ } catch {
2404
+ return null;
2405
+ }
2406
+ };
2407
+ var validateFinding = (finding, repoRoot) => {
2408
+ if (finding.patch === void 0) return finding;
2409
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
2410
+ if (lines === null) {
2411
+ process.stderr.write(
2412
+ `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
2413
+ `
2414
+ );
2415
+ return withoutPatch(finding);
2416
+ }
2417
+ const result = validatePatch(finding.patch, lines);
2418
+ switch (result.kind) {
2419
+ case "anchored":
2420
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
2421
+ case "keep":
2422
+ return finding;
2423
+ case "drop":
2424
+ process.stderr.write(
2425
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
2426
+ `
2427
+ );
2428
+ return withoutPatch(finding);
2429
+ }
2430
+ };
2431
+ var validatePatchesCmd = defineCommand({
2432
+ meta: {
2433
+ name: "validate-patches",
2434
+ 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)"
2435
+ },
2436
+ args: {
2437
+ findings: {
2438
+ type: "positional",
2439
+ description: "Path to findings JSON",
2440
+ required: true
2441
+ },
2442
+ "repo-root": {
2443
+ type: "string",
2444
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
2445
+ }
2446
+ },
2447
+ run: async ({ args }) => {
2448
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
2449
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
2450
+ const validated = {
2451
+ ...findings,
2452
+ findings: findings.findings.map((f) => validateFinding(f, repoRoot))
2453
+ };
2454
+ process.stdout.write(`${JSON.stringify(validated, null, 2)}
2455
+ `);
2456
+ }
2457
+ });
1437
2458
  var requireAdapterName = (name) => {
1438
2459
  if (isAdapterName(name)) return name;
1439
2460
  fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
@@ -1456,7 +2477,7 @@ var requireSchemaPath = (kind, version) => {
1456
2477
  var printSchemaCmd = defineCommand({
1457
2478
  meta: {
1458
2479
  name: "print-schema",
1459
- description: "Print a bundled schema JSON"
2480
+ description: "Print a bundled schema JSON, ready to hand to a CLI's --json-schema (the $schema draft declaration is stripped)"
1460
2481
  },
1461
2482
  args: {
1462
2483
  name: {
@@ -1472,7 +2493,103 @@ var printSchemaCmd = defineCommand({
1472
2493
  run: async ({ args }) => {
1473
2494
  const schemaKind = requireSchemaKind(args.name);
1474
2495
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
1475
- process.stdout.write(readFileSync(schemaPath, "utf-8"));
2496
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
2497
+ const enforcementSchema = Object.fromEntries(
2498
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
2499
+ );
2500
+ process.stdout.write(`${JSON.stringify(enforcementSchema, null, 2)}
2501
+ `);
2502
+ }
2503
+ });
2504
+ var MAX_NUDGES_DEFAULT = 5;
2505
+ var drainStdin = () => {
2506
+ if (process.stdin.isTTY) return;
2507
+ try {
2508
+ readFileSync(0);
2509
+ } catch {
2510
+ }
2511
+ };
2512
+ var requireMaxNudges = (raw) => {
2513
+ if (raw === void 0) return MAX_NUDGES_DEFAULT;
2514
+ if (!/^\d+$/.test(raw)) {
2515
+ fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
2516
+ }
2517
+ const n = Number.parseInt(raw, 10);
2518
+ if (n < 1) {
2519
+ fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
2520
+ }
2521
+ return n;
2522
+ };
2523
+ var stopGateCmd = defineCommand({
2524
+ meta: {
2525
+ name: "stop-gate",
2526
+ 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."
2527
+ },
2528
+ args: {
2529
+ draft: {
2530
+ type: "string",
2531
+ description: "Path to the findings document the agent must produce and keep valid",
2532
+ required: true
2533
+ },
2534
+ kind: {
2535
+ type: "string",
2536
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
2537
+ },
2538
+ schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
2539
+ "schema-version": {
2540
+ type: "string",
2541
+ description: "Schema major.minor to validate against (default: the draft's declared version)"
2542
+ },
2543
+ "max-nudges": {
2544
+ type: "string",
2545
+ description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
2546
+ },
2547
+ counter: {
2548
+ type: "string",
2549
+ description: "Path for the nudge counter (default: <draft>.nudges)"
2550
+ },
2551
+ "print-settings": {
2552
+ type: "boolean",
2553
+ description: "Print the Stop-hook settings JSON that wires this gate, then exit"
2554
+ }
2555
+ },
2556
+ run: async ({ args }) => {
2557
+ const draftPath = resolve$1(args.draft);
2558
+ if (args["print-settings"]) {
2559
+ const command = defaultHookCommand(draftPath, {
2560
+ kind: args.kind,
2561
+ schema: args.schema,
2562
+ schemaVersion: args["schema-version"],
2563
+ maxNudges: args["max-nudges"],
2564
+ counter: args.counter
2565
+ });
2566
+ process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
2567
+ `);
2568
+ return;
2569
+ }
2570
+ drainStdin();
2571
+ const kind = requireSchemaKind(args.kind || "findings");
2572
+ const maxNudges = requireMaxNudges(args["max-nudges"]);
2573
+ const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
2574
+ const state = draftState(
2575
+ draftPath,
2576
+ (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
2577
+ );
2578
+ const nudges = readNudges(counterPath);
2579
+ const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
2580
+ if (decision.kind === "block") {
2581
+ try {
2582
+ bumpNudges(counterPath, nudges);
2583
+ } catch (err) {
2584
+ process.stderr.write(
2585
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
2586
+ `
2587
+ );
2588
+ return;
2589
+ }
2590
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
2591
+ `);
2592
+ }
1476
2593
  }
1477
2594
  });
1478
2595
  var gatherCmd = defineCommand({
@@ -1559,12 +2676,11 @@ var postCmd = defineCommand({
1559
2676
  },
1560
2677
  "inline-template": {
1561
2678
  type: "string",
1562
- description: "Path to inline comment Eta template (default: built-in format)"
2679
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1563
2680
  },
1564
2681
  route: {
1565
2682
  type: "string",
1566
- description: 'Review route label (e.g. "full review" or "mechanic")',
1567
- required: true
2683
+ description: "Review route label; overrides the envelope's route when set (default: read from the envelope)"
1568
2684
  },
1569
2685
  "bot-login": {
1570
2686
  type: "string",
@@ -1576,27 +2692,40 @@ var postCmd = defineCommand({
1576
2692
  },
1577
2693
  effort: {
1578
2694
  type: "string",
1579
- description: 'Effort label to render in the route line (e.g. "max" or "low"); omitted when absent'
2695
+ description: "Effort label; overrides the envelope's effort when set (default: read from the envelope)"
1580
2696
  },
1581
2697
  "test-report": {
1582
2698
  type: "string",
1583
2699
  description: TEST_REPORT_DESCRIPTION
2700
+ },
2701
+ "run-url": {
2702
+ type: "string",
2703
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
2704
+ },
2705
+ "json-url": {
2706
+ type: "string",
2707
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
1584
2708
  }
1585
2709
  },
1586
2710
  run: async ({ args }) => {
2711
+ const priceResolution = resolvePrices(args.prices);
1587
2712
  await post({
1588
2713
  repo: args.repo,
1589
2714
  headSha: args["head-sha"],
1590
2715
  botLogin: args["bot-login"] || "github-actions[bot]",
1591
2716
  findingsPath: args.findings,
1592
2717
  envelopePath: args.usage,
1593
- pricesPath: resolvePricesPath(args.prices),
2718
+ pricesPath: priceResolution.path,
2719
+ pricesProvided: priceResolution.kind === "provided",
1594
2720
  templatePath: resolveTemplatePath(args.template),
1595
- inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
2721
+ inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
1596
2722
  route: args.route,
1597
2723
  headBranch: args["head-branch"],
1598
2724
  effort: args.effort,
1599
- testReportPath: args["test-report"]
2725
+ testReportPath: args["test-report"],
2726
+ runUrl: args["run-url"],
2727
+ jsonUrl: args["json-url"],
2728
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1600
2729
  });
1601
2730
  }
1602
2731
  });
@@ -1604,7 +2733,7 @@ var main = defineCommand({
1604
2733
  meta: {
1605
2734
  name: "code-review",
1606
2735
  version: packageVersion,
1607
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
2736
+ description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, stop-gate, budget-hook, and print-settings"
1608
2737
  },
1609
2738
  subCommands: {
1610
2739
  gather: gatherCmd,
@@ -1612,10 +2741,15 @@ var main = defineCommand({
1612
2741
  inline: inlineCmd,
1613
2742
  post: postCmd,
1614
2743
  cost: costCmd,
2744
+ "check-cost": checkCostCmd,
1615
2745
  validate: validateCmd,
1616
2746
  adapt: adaptCmd,
1617
2747
  extract: extractCmd,
1618
- "print-schema": printSchemaCmd
2748
+ "validate-patches": validatePatchesCmd,
2749
+ "print-schema": printSchemaCmd,
2750
+ "stop-gate": stopGateCmd,
2751
+ "budget-hook": budgetHookCmd,
2752
+ "print-settings": printSettingsCmd
1619
2753
  }
1620
2754
  });
1621
2755
  if (!process.env["VITEST"]) {