@jphutchins/code-review 0.1.0-alpha.2 → 0.1.0-alpha.21

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, statSync, 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,44 +53,207 @@ 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 parseFindingsMarker = (body) => {
182
+ const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
183
+ const b64 = match?.[1];
184
+ if (b64 === void 0) return null;
185
+ try {
186
+ return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
187
+ } catch {
188
+ return null;
189
+ }
190
+ };
191
+ var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
192
+ var projectPatch = (patch) => {
193
+ if (patch === void 0) return { kind: "none" };
194
+ const lowered = patchToSuggestion(patch);
195
+ return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
196
+ };
197
+ var formatConfidence = (n) => n.toFixed(2);
198
+ var reviewBodyPointer = (headSha, stickyUrl, marker) => {
199
+ const sha7 = headSha.slice(0, 7);
200
+ 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.`;
201
+ return marker ? `${marker}
202
+
203
+ ${linkLine}` : linkLine;
204
+ };
205
+
56
206
  // src/render.ts
57
- var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
58
207
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
59
208
  var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
60
209
  var sanitizeFinding = (f) => ({
61
210
  ...f,
62
211
  title: escapePipes(f.title),
63
212
  path: escapeCodeBackticks(f.path),
64
- suggestion: f.suggestion ? escapeBackticks(f.suggestion) : f.suggestion
213
+ patchProjection: projectPatch(f.patch)
214
+ });
215
+ var emptySeverityCounts = () => ({
216
+ critical: 0,
217
+ major: 0,
218
+ minor: 0,
219
+ nit: 0
65
220
  });
221
+ var computeSeverityCounts = (findings) => findings.reduce(
222
+ (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
223
+ emptySeverityCounts()
224
+ );
66
225
  var render = (input) => {
67
226
  const eta = new Eta({ autoTrim: false });
68
227
  const usageAvailable = input.envelope !== null;
69
228
  const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
229
+ const pricesProvided = input.pricesProvided ?? true;
70
230
  const route = input.route ?? input.envelope?.route ?? null;
71
231
  const effort = input.effort ?? input.envelope?.effort ?? null;
72
232
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
73
- const findings = input.findings.findings.map(sanitizeFinding);
74
- const safeFindings = { ...input.findings, findings };
75
- const uniqueFiles = [...new Set(findings.map((f) => f.path))];
76
233
  return eta.renderString(input.template, {
77
- findings: safeFindings,
234
+ findings: input.findings,
78
235
  envelope: input.envelope,
79
236
  usageAvailable,
80
237
  costReport,
238
+ pricesProvided,
81
239
  route,
82
240
  effort,
83
241
  modelNames,
84
242
  testReport: input.testReport ?? null,
85
243
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
86
- totalCount: findings.length,
87
- fileCount: uniqueFiles.length,
88
- // REC-CO-1: nits (and only nits) fold into <details>; everything else stays visible.
89
- visibleFindings: findings.filter((f) => f.severity !== "nit"),
90
- nitFindings: findings.filter((f) => f.severity === "nit"),
91
- suggestionCount: findings.filter((f) => f.suggestion).length,
244
+ postedAt: input.postedAt ?? "",
245
+ severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
246
+ strays: (input.strays ?? []).map(sanitizeFinding),
247
+ unanchoredCount: input.unanchoredCount ?? 0,
248
+ inlineDisposition: input.inlineDisposition ?? null,
249
+ runUrl: input.runUrl ?? null,
250
+ jsonUrl: input.jsonUrl ?? null,
251
+ findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
252
+ reviewUrl: input.reviewUrl ?? null,
92
253
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
93
- formatCost: (n) => Number.isFinite(n) ? `$${n.toFixed(3)}` : "\u2014",
254
+ // Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
255
+ // real tokens spent, we simply have no rates to price them (SPEC §6.2).
256
+ formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
94
257
  formatDuration: (ms) => {
95
258
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
96
259
  const s = Math.round(ms / 1e3);
@@ -108,20 +271,8 @@ var render = (input) => {
108
271
  return `\u2753 ${v}`;
109
272
  }
110
273
  },
111
- severityEmoji: (s) => {
112
- switch (s) {
113
- case "critical":
114
- return "\u{1F534}";
115
- case "major":
116
- return "\u{1F7E0}";
117
- case "minor":
118
- return "\u{1F535}";
119
- case "nit":
120
- return "\u26AA";
121
- default:
122
- return "\u2753";
123
- }
124
- }
274
+ severityEmoji,
275
+ formatConfidence
125
276
  });
126
277
  };
127
278
  var key = (path, line) => `${path}:${String(line)}`;
@@ -178,33 +329,33 @@ var partitionFindings = (findings, index) => {
178
329
  };
179
330
 
180
331
  // src/inline.ts
181
- var escapeBackticks2 = (text) => text.replace(/```/g, "`` ` ``");
182
- var buildCommentBody = (f) => {
183
- const parts = [f.body];
184
- if (f.suggestion !== null && f.suggestion !== void 0) {
185
- const safe = escapeBackticks2(f.suggestion);
186
- parts.push(`\`\`\`suggestion
187
- ${safe}
188
- \`\`\``);
189
- }
190
- return parts.join("\n\n");
191
- };
192
- var renderCommentBody = (f, eta, template) => {
193
- return eta.renderString(template, {
332
+ var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
333
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
334
+ // Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
335
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
336
+ eta.renderString(template, {
194
337
  ...f,
195
- suggestion: f.suggestion !== null && f.suggestion !== void 0 ? escapeBackticks2(f.suggestion) : null
196
- });
197
- };
198
- var buildInlineComments = (findings, diff, inlineTemplate) => {
338
+ patchProjection: projectPatch(f.patch),
339
+ severityEmoji,
340
+ formatConfidence,
341
+ modelsText,
342
+ jsonUrl: jsonUrl ?? null,
343
+ findingsPointer: pointer
344
+ })
345
+ );
346
+ var buildInlineComments = (findings, diff, context) => {
347
+ const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
199
348
  const index = indexDiff(diff);
200
349
  const { inDiff, strays } = partitionFindings(findings, index);
201
- const eta = inlineTemplate ? new Eta({ autoTrim: false }) : null;
350
+ const eta = new Eta({ autoTrim: false });
351
+ const modelsText = formatModels(models);
202
352
  const comments = inDiff.map((f) => {
353
+ const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
203
354
  const comment = {
204
355
  path: f.path,
205
356
  line: f.end_line,
206
357
  side: defaultSide(f.side),
207
- body: eta && inlineTemplate ? renderCommentBody(f, eta, inlineTemplate) : buildCommentBody(f)
358
+ body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
208
359
  };
209
360
  if (f.start_line < f.end_line) {
210
361
  return {
@@ -215,7 +366,7 @@ var buildInlineComments = (findings, diff, inlineTemplate) => {
215
366
  }
216
367
  return comment;
217
368
  });
218
- return { comments, strays };
369
+ return { comments, strays, inDiff };
219
370
  };
220
371
  var renderStraysSection = (strays) => {
221
372
  if (strays.length === 0) return "";
@@ -231,6 +382,120 @@ var renderStraysSection = (strays) => {
231
382
  ...items
232
383
  ].join("\n");
233
384
  };
385
+ var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
386
+ var readFileOrNull = (path) => {
387
+ try {
388
+ return readFileSync(path, "utf-8");
389
+ } catch {
390
+ return null;
391
+ }
392
+ };
393
+
394
+ // src/transcript.ts
395
+ var numField = (rec, key2) => {
396
+ const v = rec[key2];
397
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
398
+ };
399
+ var messageUsage = (entry) => {
400
+ const rec = asRecord(entry);
401
+ if (rec === null || rec["type"] !== "assistant") return null;
402
+ const msg = asRecord(rec["message"]);
403
+ if (msg === null) return null;
404
+ const model = msg["model"];
405
+ const usage = asRecord(msg["usage"]);
406
+ if (typeof model !== "string" || usage === null) return null;
407
+ const id = msg["id"];
408
+ return {
409
+ id: typeof id === "string" ? id : null,
410
+ model,
411
+ input: numField(usage, "input_tokens"),
412
+ output: numField(usage, "output_tokens"),
413
+ cacheRead: numField(usage, "cache_read_input_tokens"),
414
+ cacheWrite: numField(usage, "cache_creation_input_tokens")
415
+ };
416
+ };
417
+ var tsMsOf = (entry) => {
418
+ const rec = asRecord(entry);
419
+ const ts = rec?.["timestamp"];
420
+ if (typeof ts !== "string") return null;
421
+ const ms = Date.parse(ts);
422
+ return Number.isNaN(ms) ? null : ms;
423
+ };
424
+ var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
425
+ var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
426
+ try {
427
+ return [JSON.parse(line)];
428
+ } catch {
429
+ return [];
430
+ }
431
+ });
432
+ var sumTranscriptUsage = (entries) => {
433
+ const summed = entries.reduce(
434
+ (acc, entry) => {
435
+ const u = messageUsage(entry);
436
+ if (u === null) return acc;
437
+ if (u.id !== null && acc.seen.has(u.id)) return acc;
438
+ if (u.id !== null) acc.seen.add(u.id);
439
+ const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
440
+ acc.totals.set(u.model, {
441
+ input: prev.input + u.input,
442
+ output: prev.output + u.output,
443
+ cacheRead: prev.cacheRead + u.cacheRead,
444
+ cacheWrite: prev.cacheWrite + u.cacheWrite
445
+ });
446
+ return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
447
+ },
448
+ { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
449
+ );
450
+ const models = [...summed.totals].map(([model, t4]) => ({
451
+ model,
452
+ input_tokens: t4.input,
453
+ output_tokens: t4.output,
454
+ cache_read_tokens: t4.cacheRead,
455
+ cache_write_tokens: t4.cacheWrite
456
+ }));
457
+ const bounds = entries.reduce(
458
+ (acc, entry) => {
459
+ const ms = tsMsOf(entry);
460
+ if (ms === null) return acc;
461
+ return {
462
+ min: acc.min === null || ms < acc.min ? ms : acc.min,
463
+ max: acc.max === null || ms > acc.max ? ms : acc.max
464
+ };
465
+ },
466
+ { min: null, max: null }
467
+ );
468
+ return {
469
+ models,
470
+ turns: summed.turns,
471
+ durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
472
+ firstTsMs: bounds.min,
473
+ lastTsMs: bounds.max
474
+ };
475
+ };
476
+ var subagentFiles = (mainPath) => {
477
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
478
+ try {
479
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
480
+ } catch {
481
+ return [];
482
+ }
483
+ };
484
+ var readTranscriptTree = (mainPath) => {
485
+ const mainText = readFileOrNull(mainPath);
486
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
487
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
488
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
489
+ const siblingReads = siblings.flatMap((path) => {
490
+ const text = readFileOrNull(path);
491
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
492
+ });
493
+ return {
494
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
495
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
496
+ missing: mainText === null
497
+ };
498
+ };
234
499
  var SeverityCodec = t.union([
235
500
  t.literal("critical"),
236
501
  t.literal("major"),
@@ -258,14 +523,16 @@ var FindingShape = t.intersection([
258
523
  end_line: LineNumber,
259
524
  severity: SeverityCodec,
260
525
  title: t.string,
261
- body: t.string
526
+ description: t.string,
527
+ reasoning: t.string,
528
+ confidence: Confidence
262
529
  }),
263
530
  t.partial({
264
531
  side: SideCodec,
265
- suggestion: t.union([t.string, t.null]),
266
- confidence: Confidence,
267
532
  code: t.string,
268
- code_url: t.string
533
+ code_url: t.string,
534
+ recommendation: t.string,
535
+ patch: t.string
269
536
  })
270
537
  ]);
271
538
  var EndGeStart = t.refinement(
@@ -341,7 +608,13 @@ var TestSummaryCodec = t.intersection([
341
608
  failures: t.array(TestFailureCodec)
342
609
  })
343
610
  ]);
344
- var DEFAULT_SCHEMA_VERSION = "0.2.0";
611
+ var DEFAULT_SCHEMA_VERSION = "0.4.0";
612
+ var noticeFindings = (summary) => ({
613
+ schema_version: DEFAULT_SCHEMA_VERSION,
614
+ summary,
615
+ verdict: "comment",
616
+ findings: []
617
+ });
345
618
 
346
619
  // src/validate.ts
347
620
  var addFormats = _addFormats;
@@ -377,10 +650,290 @@ var unsafeUnwrap = (decoded) => {
377
650
  if (decoded._tag === "Right") return decoded.right;
378
651
  throw new Error("io-ts decode failed \u2014 data does not match expected shape");
379
652
  };
653
+
654
+ // src/stop-gate.ts
655
+ var whatsWrong = (state, draftPath, kind) => {
656
+ switch (state.kind) {
657
+ case "missing":
658
+ return `${draftPath} does not exist yet`;
659
+ case "unreadable":
660
+ return `${draftPath} could not be read: ${state.error}`;
661
+ case "invalid":
662
+ return `${draftPath} does not validate against the ${kind} schema:
663
+ ${state.errors.map((e) => ` - ${e}`).join("\n")}`;
664
+ }
665
+ };
666
+ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
667
+ if (state.kind === "valid") return { kind: "allow" };
668
+ if (nudges >= maxNudges) return { kind: "allow" };
669
+ return {
670
+ kind: "block",
671
+ reason: [
672
+ `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
673
+ `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.`,
674
+ `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind} --explain" until it exits 0 before ending your turn (--explain prints the schema when the shape is wrong).`
675
+ ].join("\n")
676
+ };
677
+ };
678
+ var draftState = (draftPath, resolveSchema) => {
679
+ let raw;
680
+ try {
681
+ raw = readFileSync(draftPath, "utf-8");
682
+ } catch (err) {
683
+ if (err instanceof Error && err.code === "ENOENT") {
684
+ return { kind: "missing" };
685
+ }
686
+ return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
687
+ }
688
+ let parsed;
689
+ try {
690
+ parsed = JSON.parse(raw);
691
+ } catch (err) {
692
+ return {
693
+ kind: "invalid",
694
+ errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
695
+ };
696
+ }
697
+ let schemaPath;
698
+ try {
699
+ schemaPath = resolveSchema(parsed);
700
+ } catch (err) {
701
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
702
+ }
703
+ try {
704
+ const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
705
+ return valid ? { kind: "valid" } : { kind: "invalid", errors };
706
+ } catch (err) {
707
+ return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
708
+ }
709
+ };
710
+ var readNudges = (counterPath) => {
711
+ try {
712
+ const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
713
+ return Number.isInteger(n) && n >= 0 ? n : 0;
714
+ } catch {
715
+ return 0;
716
+ }
717
+ };
718
+ var bumpNudges = (counterPath, current) => {
719
+ writeFileSync(counterPath, `${String(current + 1)}
720
+ `);
721
+ };
722
+ var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
723
+ var defaultHookCommand = (draftPath, opts) => [
724
+ "code-review stop-gate --draft",
725
+ shellQuote(draftPath),
726
+ ...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
727
+ ...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
728
+ ...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
729
+ ...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
730
+ ...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
731
+ ].join(" ");
732
+ var stopHookSettings = (command) => ({
733
+ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
734
+ });
735
+
736
+ // src/budget.ts
737
+ var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
738
+ var DEFAULT_RESERVE = {
739
+ frac: 0.15,
740
+ growth: 0.25,
741
+ flatUsd: 0.02,
742
+ flatMs: 12e4
743
+ };
744
+ var SOFT_MULTIPLE = 2;
745
+ var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
746
+ var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
747
+ var axisSeverity = (a, reserve) => {
748
+ const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
749
+ const effFrac = reserve.frac + reserve.growth * usedFrac;
750
+ const hardReserve = Math.max(a.flat, effFrac * a.limit);
751
+ const remaining = a.limit - a.used;
752
+ if (remaining <= hardReserve) return 2;
753
+ if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
754
+ return 0;
755
+ };
756
+ var decideBudget = (i) => {
757
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
758
+ return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
759
+ };
760
+ var pct = (n) => `${String(Math.round(n * 100))}%`;
761
+ var money = (n) => `$${n.toFixed(2)}`;
762
+ var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
763
+ 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)}`;
764
+ 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`;
765
+ var directive = (phase, draftPath, isSubagent) => {
766
+ if (isSubagent)
767
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
768
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Do not wait for subagents still running in the background. Write your COMPLETE findings from what you already have to ${draftPath} and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 fold in the subagent reports you already have rather than waiting on stragglers; you may run out of budget before you finish otherwise.`;
769
+ };
770
+ var budgetMessage = (i, phase, draftPath, isSubagent) => {
771
+ const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
772
+ return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
773
+ };
774
+ var invokesCodeReviewValidate = (toolInput) => {
775
+ const cmd = asRecord(toolInput)?.["command"];
776
+ return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
777
+ };
778
+ var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
779
+ var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
780
+ var blockedDuringConvergence = (toolName, toolInput) => {
781
+ if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
782
+ if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
783
+ return false;
784
+ };
785
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
786
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
787
+ var writesToDraft = (toolName, toolInput, draftPath) => {
788
+ const rec = asRecord(toolInput);
789
+ const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
790
+ if (WRITE_TOOLS.has(toolName)) {
791
+ const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
792
+ if (typeof fp === "string" && targets.some((t4) => fp === t4 || basename(fp) === basename(t4)))
793
+ return true;
794
+ }
795
+ if (toolName === "Bash") {
796
+ const cmd = rec?.["command"];
797
+ if (typeof cmd !== "string") return false;
798
+ const alt = targets.map(escapeRegExp).join("|");
799
+ const end = "(?=$|[\\s|&;)])";
800
+ const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
801
+ const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
802
+ return redirect.test(cmd) || teeArg.test(cmd);
803
+ }
804
+ return false;
805
+ };
806
+ var singleWriterMessage = (draftPath) => `Only the main agent may write ${draftPath}. When a subagent writes it too, the concurrent writers clobber each other and the review comes out empty. Do NOT write, edit, or redirect into ${draftPath} \u2014 instead, return the findings you discovered in your reply (the field names are in the schema); the main agent collects every subagent's reported findings and writes the draft itself.`;
807
+ var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
808
+ var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
809
+ var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and a pre-seeded draft does not count until you have revised it yourself this run. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
810
+ var forceBackgroundSpawn = (toolInput) => ({
811
+ hookSpecificOutput: {
812
+ hookEventName: "PreToolUse",
813
+ permissionDecision: "allow",
814
+ updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
815
+ }
816
+ });
817
+ var denyPreTool = (reason) => ({
818
+ hookSpecificOutput: {
819
+ hookEventName: "PreToolUse",
820
+ permissionDecision: "deny",
821
+ permissionDecisionReason: reason
822
+ }
823
+ });
824
+ var evaluateBudgetHook = (input, params) => {
825
+ const rec = asRecord(input);
826
+ const inputs = {
827
+ spentUsd: params.spentUsd,
828
+ budgetUsd: params.budgetUsd,
829
+ elapsedMs: params.elapsedMs,
830
+ wallMs: params.wallMs,
831
+ reserve: params.reserve
832
+ };
833
+ const phase = decideBudget(inputs);
834
+ const agentId = rec?.["agent_id"];
835
+ const isSubagent = typeof agentId === "string" && agentId.length > 0;
836
+ switch (rec?.["hook_event_name"]) {
837
+ case "PostToolBatch":
838
+ return phase.kind === "ok" ? {} : {
839
+ hookSpecificOutput: {
840
+ hookEventName: "PostToolBatch",
841
+ additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
842
+ }
843
+ };
844
+ case "PreToolUse": {
845
+ const toolName = rec["tool_name"];
846
+ if (typeof toolName !== "string") return {};
847
+ if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
848
+ return denyPreTool(singleWriterMessage(params.draftPath));
849
+ if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
850
+ return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
851
+ if (SPAWN_TOOLS.has(toolName)) {
852
+ if (!isSubagent && !params.mainDraftWritten)
853
+ return denyPreTool(spawnFloorMessage(params.draftPath));
854
+ return forceBackgroundSpawn(rec["tool_input"]);
855
+ }
856
+ return {};
857
+ }
858
+ default:
859
+ return {};
860
+ }
861
+ };
862
+ var parseWallMs = (raw) => {
863
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
864
+ if (m === null) return null;
865
+ const [, num = "", unit = "s"] = m;
866
+ const n = Number.parseFloat(num);
867
+ if (!Number.isFinite(n)) return null;
868
+ switch (unit) {
869
+ case "ms":
870
+ return n;
871
+ case "s":
872
+ return n * 1e3;
873
+ case "m":
874
+ return n * 6e4;
875
+ default:
876
+ return n * 36e5;
877
+ }
878
+ };
879
+ var parseEpochSecMs = (raw) => {
880
+ if (raw === void 0) return null;
881
+ const t4 = raw.trim();
882
+ if (!/^\d+$/.test(t4)) return null;
883
+ const n = Number.parseInt(t4, 10);
884
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
885
+ };
886
+ var anchoredElapsedMs = (src) => {
887
+ if (src.deadlineMs !== null && src.wallMs !== null)
888
+ return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
889
+ if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
890
+ return null;
891
+ };
892
+ var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
893
+ var parseFraction = (raw, fallback) => {
894
+ if (raw === void 0) return fallback;
895
+ const n = Number.parseFloat(raw);
896
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
897
+ };
898
+ var budgetHookCommand = (draftPath, opts) => [
899
+ "code-review budget-hook --draft",
900
+ shellQuote(draftPath),
901
+ ...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
902
+ ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
903
+ ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
904
+ ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
905
+ ...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
906
+ ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
907
+ ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
908
+ ].join(" ");
909
+
910
+ // src/format.ts
911
+ var FENCE_RE = /^\s*```/;
912
+ var scanLine = (state, line) => {
913
+ if (FENCE_RE.test(line)) {
914
+ return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
915
+ }
916
+ if (state.inFence) {
917
+ return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
918
+ }
919
+ const trimmed = line.replace(/[ \t]+$/, "");
920
+ if (trimmed !== "") {
921
+ return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
922
+ }
923
+ const blankRun = state.blankRun + 1;
924
+ return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
925
+ };
926
+ var formatMarkdown = (md) => {
927
+ const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
928
+ return `${lines.join("\n").replace(/\n+$/, "")}
929
+ `;
930
+ };
931
+ var pad2 = (n) => String(n).padStart(2, "0");
932
+ var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
380
933
  var identity = (decoded) => decoded;
381
934
  var findingsTable = [
382
935
  {
383
- minor: "0.2",
936
+ minor: "0.4",
384
937
  defaultVersion: DEFAULT_SCHEMA_VERSION,
385
938
  schemaFile: "findings.schema.json",
386
939
  codec: FindingsCodec,
@@ -505,7 +1058,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
505
1058
  // src/post.ts
506
1059
  var DEFAULT_MARKER = "<!-- code-review -->";
507
1060
  var MAX_SUGGESTION_LINES = 10;
508
- var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
509
1061
  var countSuggestionLines = (text) => text.split("\n").length;
510
1062
  var checkLongSuggestions = (comments) => {
511
1063
  const longFiles = [];
@@ -525,13 +1077,6 @@ var checkLongSuggestions = (comments) => {
525
1077
  });
526
1078
  return { comments: adjusted, longFiles };
527
1079
  };
528
- var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
529
- var noticeFindings = (message) => ({
530
- schema_version: DEFAULT_SCHEMA_VERSION,
531
- summary: `### \u26A0\uFE0F ${message}`,
532
- verdict: "comment",
533
- findings: []
534
- });
535
1080
  var loadFindings = (path) => {
536
1081
  let raw;
537
1082
  try {
@@ -593,20 +1138,58 @@ var loadTestReport = (path) => {
593
1138
  }
594
1139
  return decoded.right;
595
1140
  };
596
- var postInlineReview = async (repo, prNumber, headSha, comments, ghApi) => {
597
- const body = JSON.stringify({
598
- body: "",
1141
+ var parseHtmlUrl = (raw) => {
1142
+ try {
1143
+ const parsed = JSON.parse(raw);
1144
+ return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
1145
+ } catch {
1146
+ return void 0;
1147
+ }
1148
+ };
1149
+ var commentPayload = (c) => ({
1150
+ path: c.path,
1151
+ line: c.line,
1152
+ side: c.side,
1153
+ ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1154
+ body: formatMarkdown(c.body)
1155
+ });
1156
+ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
1157
+ const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
1158
+ const reviewBody = (withComments) => JSON.stringify({
1159
+ body: pointer,
599
1160
  commit_id: headSha,
600
1161
  event: "COMMENT",
601
- comments: comments.map((c) => ({
602
- path: c.path,
603
- line: c.line,
604
- side: c.side,
605
- ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
606
- body: c.body
607
- }))
1162
+ comments: withComments ? comments.map(commentPayload) : []
608
1163
  });
609
- await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"], body);
1164
+ const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
1165
+ try {
1166
+ const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
1167
+ return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
1168
+ } catch (err) {
1169
+ if (comments.length === 0) throw err;
1170
+ process.stderr.write(
1171
+ `Warning: the batched inline review on PR #${String(prNumber)} was rejected (${err instanceof Error ? err.message : String(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
1172
+ `
1173
+ );
1174
+ const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
1175
+ const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
1176
+ const unposted = [];
1177
+ let inlinePosted = 0;
1178
+ for (const [i, c] of comments.entries()) {
1179
+ try {
1180
+ await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
1181
+ inlinePosted += 1;
1182
+ } catch (e) {
1183
+ const finding = inDiff[i];
1184
+ if (finding) unposted.push(finding);
1185
+ process.stderr.write(
1186
+ `Warning: inline comment on ${c.path}:${String(c.line)} rejected (${e instanceof Error ? e.message : String(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
1187
+ `
1188
+ );
1189
+ }
1190
+ }
1191
+ return { url, inlinePosted, unposted };
1192
+ }
610
1193
  };
611
1194
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
612
1195
  const stdout = await ghApi(
@@ -626,33 +1209,45 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
626
1209
  const parsed = JSON.parse(last);
627
1210
  return { id: parsed.id, body: parsed.body };
628
1211
  };
1212
+ var parseCommentRef = (raw) => {
1213
+ try {
1214
+ const parsed = JSON.parse(raw);
1215
+ return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
1216
+ } catch {
1217
+ return null;
1218
+ }
1219
+ };
629
1220
  var patchComment = async (repo, commentId, body, ghApi) => {
630
- await ghApi(
1221
+ const stdout = await ghApi(
631
1222
  [`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
632
1223
  JSON.stringify({ body })
633
1224
  );
1225
+ const htmlUrl = parseHtmlUrl(stdout);
1226
+ return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
634
1227
  };
635
1228
  var postComment = async (repo, prNumber, body, ghApi) => {
636
- await ghApi(
1229
+ const stdout = await ghApi(
637
1230
  [`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
638
1231
  JSON.stringify({ body })
639
1232
  );
1233
+ return parseCommentRef(stdout);
640
1234
  };
641
1235
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
642
1236
  if (existing !== null) {
643
- await patchComment(repo, existing.id, body, ghApi);
1237
+ const patched = await patchComment(repo, existing.id, body, ghApi);
644
1238
  process.stderr.write(
645
1239
  `Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
646
1240
  `
647
1241
  );
648
- } else {
649
- await postComment(repo, prNumber, body, ghApi);
650
- process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
651
- `);
1242
+ return { id: existing.id, url: patched?.html_url };
652
1243
  }
1244
+ const posted = await postComment(repo, prNumber, body, ghApi);
1245
+ process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
1246
+ `);
1247
+ return posted ? { id: posted.id, url: posted.html_url } : null;
653
1248
  };
654
1249
  var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
655
- var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
1250
+ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
656
1251
  const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
657
1252
  let reviews;
658
1253
  try {
@@ -661,10 +1256,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
661
1256
  return [];
662
1257
  }
663
1258
  if (!Array.isArray(reviews)) return [];
664
- return reviews.filter((r) => isBotReview(r)).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => r.id);
1259
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
665
1260
  };
666
- var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
667
- const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
1261
+ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
668
1262
  for (const id of ids) {
669
1263
  try {
670
1264
  await ghApi(
@@ -685,6 +1279,86 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
685
1279
  }
686
1280
  }
687
1281
  };
1282
+ 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}}}}}}}}";
1283
+ var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1284
+ var priorBotCommentId = (c, logins) => {
1285
+ if (typeof c !== "object" || c === null) return null;
1286
+ const o = c;
1287
+ const login = o.author?.login;
1288
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
1289
+ };
1290
+ var priorBotCommentIds = (raw, botLogin) => {
1291
+ let parsed;
1292
+ try {
1293
+ parsed = JSON.parse(raw);
1294
+ } catch {
1295
+ return { ids: [], truncated: false };
1296
+ }
1297
+ const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
1298
+ const truncated = conn?.pageInfo?.hasNextPage === true;
1299
+ const nodes = conn?.nodes;
1300
+ if (!Array.isArray(nodes)) return { ids: [], truncated };
1301
+ const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1302
+ const ids = nodes.flatMap((t4) => {
1303
+ const cnodes = t4.comments?.nodes;
1304
+ return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1305
+ });
1306
+ return { ids, truncated };
1307
+ };
1308
+ var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
1309
+ const slash = repo.indexOf("/");
1310
+ if (slash <= 0) return [];
1311
+ const owner = repo.slice(0, slash);
1312
+ const name = repo.slice(slash + 1);
1313
+ let raw;
1314
+ try {
1315
+ raw = await ghApi([
1316
+ "graphql",
1317
+ "-f",
1318
+ `query=${REVIEW_THREAD_COMMENTS_QUERY}`,
1319
+ "-f",
1320
+ `owner=${owner}`,
1321
+ "-f",
1322
+ `name=${name}`,
1323
+ "-F",
1324
+ `pr=${String(prNumber)}`
1325
+ ]);
1326
+ } catch (err) {
1327
+ process.stderr.write(
1328
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1329
+ `
1330
+ );
1331
+ return [];
1332
+ }
1333
+ const { ids, truncated } = priorBotCommentIds(raw, botLogin);
1334
+ if (truncated) {
1335
+ process.stderr.write(
1336
+ `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1337
+ `
1338
+ );
1339
+ }
1340
+ return ids;
1341
+ };
1342
+ var minimizeComments = async (prNumber, ids, ghApi) => {
1343
+ let minimized = 0;
1344
+ for (const id of ids) {
1345
+ try {
1346
+ await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
1347
+ minimized += 1;
1348
+ } catch (err) {
1349
+ process.stderr.write(
1350
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1351
+ `
1352
+ );
1353
+ }
1354
+ }
1355
+ if (minimized > 0) {
1356
+ process.stderr.write(
1357
+ `Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
1358
+ `
1359
+ );
1360
+ }
1361
+ };
688
1362
  var post = async (input, ghApi = runGhApi) => {
689
1363
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
690
1364
  const resolution = resolvePr(candidates, input.headBranch);
@@ -709,24 +1383,28 @@ var post = async (input, ghApi = runGhApi) => {
709
1383
  DEFAULT_MARKER,
710
1384
  ghApi
711
1385
  );
712
- const previousReviewedSha = existingSticky ? extractReviewedSha(existingSticky.body) : null;
713
- const isRerunOfSameSha = previousReviewedSha !== null && previousReviewedSha === input.headSha;
714
1386
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
715
1387
  const decodedPrices = PriceMapCodec.decode(prices);
716
1388
  if (decodedPrices._tag === "Left") {
717
1389
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
718
1390
  }
719
1391
  const template = readFileSync(input.templatePath, "utf-8");
720
- const inlineTemplate = input.inlineTemplatePath ? readFileSync(input.inlineTemplatePath, "utf-8") : void 0;
721
- const renderNotice = (message) => render({
722
- findings: noticeFindings(message),
723
- envelope: null,
724
- prices: decodedPrices.right,
725
- template,
726
- route: input.route,
727
- reviewedSha: input.headSha,
728
- effort: input.effort
729
- });
1392
+ const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
1393
+ const renderNotice = (message) => formatMarkdown(
1394
+ render({
1395
+ findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
1396
+ envelope: null,
1397
+ prices: decodedPrices.right,
1398
+ pricesProvided: input.pricesProvided,
1399
+ template,
1400
+ route: input.route,
1401
+ reviewedSha: input.headSha,
1402
+ effort: input.effort,
1403
+ runUrl: input.runUrl,
1404
+ jsonUrl: input.jsonUrl,
1405
+ postedAt: input.postedAt
1406
+ })
1407
+ );
730
1408
  if (isEmptyDiff(diff)) {
731
1409
  await upsertSticky(
732
1410
  input.repo,
@@ -752,27 +1430,40 @@ var post = async (input, ghApi = runGhApi) => {
752
1430
  const envelope = loadEnvelope(input.envelopePath);
753
1431
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
754
1432
  if (envelope === null) {
755
- const body2 = render({
756
- findings,
757
- envelope: null,
758
- prices: decodedPrices.right,
759
- template,
760
- route: input.route,
761
- reviewedSha: input.headSha,
762
- effort: input.effort,
763
- testReport
764
- });
765
- await upsertSticky(input.repo, prNumber, existingSticky, body2, ghApi);
1433
+ const body = formatMarkdown(
1434
+ render({
1435
+ findings,
1436
+ envelope: null,
1437
+ prices: decodedPrices.right,
1438
+ pricesProvided: input.pricesProvided,
1439
+ template,
1440
+ route: input.route,
1441
+ reviewedSha: input.headSha,
1442
+ effort: input.effort,
1443
+ testReport,
1444
+ inlineDisposition: { kind: "no-envelope" },
1445
+ runUrl: input.runUrl,
1446
+ jsonUrl: input.jsonUrl,
1447
+ postedAt: input.postedAt
1448
+ })
1449
+ );
1450
+ await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
766
1451
  process.stderr.write(
767
1452
  "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
768
1453
  );
769
1454
  process.exit(0);
770
1455
  }
771
- const { comments: rawComments, strays } = buildInlineComments(
772
- findings.findings,
773
- diff,
774
- inlineTemplate
775
- );
1456
+ const findingsMarker = findingsPointer(findings, input.jsonUrl);
1457
+ const {
1458
+ comments: rawComments,
1459
+ strays,
1460
+ inDiff
1461
+ } = buildInlineComments(findings.findings, diff, {
1462
+ inlineTemplate,
1463
+ models: envelope.models.map((m) => m.model),
1464
+ findings,
1465
+ jsonUrl: input.jsonUrl
1466
+ });
776
1467
  const { comments, longFiles } = checkLongSuggestions(rawComments);
777
1468
  for (const wf of longFiles) {
778
1469
  process.stderr.write(
@@ -780,43 +1471,97 @@ var post = async (input, ghApi = runGhApi) => {
780
1471
  `
781
1472
  );
782
1473
  }
783
- let body = render({
1474
+ const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1475
+ const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1476
+ const commonRenderInput = {
784
1477
  findings,
785
1478
  envelope,
786
1479
  prices: decodedPrices.right,
1480
+ pricesProvided: input.pricesProvided,
787
1481
  template,
788
1482
  route: input.route,
789
1483
  reviewedSha: input.headSha,
790
1484
  effort: input.effort,
791
- testReport
792
- });
793
- const straysMd = renderStraysSection(strays);
794
- if (straysMd.length > 0) body += straysMd;
795
- if (longFiles.length > 0) {
796
- body += `
1485
+ testReport,
1486
+ severityCounts: computeSeverityCounts(findings.findings),
1487
+ strays,
1488
+ runUrl: input.runUrl,
1489
+ jsonUrl: input.jsonUrl,
1490
+ findingsPointer: findingsMarker,
1491
+ postedAt: input.postedAt
1492
+ };
1493
+ const longFilesNote = longFiles.length > 0 ? `
797
1494
 
798
1495
  ---
799
1496
 
800
- > **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments. See the findings above for details.
801
- `;
802
- }
803
- if (!isRerunOfSameSha && previousReviewedSha !== null) {
804
- await dismissPriorBotReviews(input.repo, prNumber, input.botLogin, ghApi);
805
- }
806
- await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
807
- if (isRerunOfSameSha) {
808
- process.stderr.write(
809
- `Head SHA ${input.headSha} matches the previous review \u2014 updated sticky only, no new inline review
1497
+ > **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.
1498
+ ` : "";
1499
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
1500
+ render({
1501
+ ...commonRenderInput,
1502
+ ...straysOverride ? { strays: straysOverride } : {},
1503
+ ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
1504
+ inlineDisposition,
1505
+ reviewUrl: reviewUrl2
1506
+ }) + longFilesNote
1507
+ );
1508
+ const stickyRef = await upsertSticky(
1509
+ input.repo,
1510
+ prNumber,
1511
+ existingSticky,
1512
+ renderBody(initialDisposition),
1513
+ ghApi
1514
+ );
1515
+ const priorInlineComments = await listPriorBotCommentIds(
1516
+ input.repo,
1517
+ prNumber,
1518
+ input.botLogin,
1519
+ ghApi
1520
+ );
1521
+ const {
1522
+ url: reviewUrl,
1523
+ inlinePosted,
1524
+ unposted
1525
+ } = await postInlineReview(
1526
+ input.repo,
1527
+ prNumber,
1528
+ input.headSha,
1529
+ comments,
1530
+ inDiff,
1531
+ stickyRef?.url,
1532
+ findingsMarker,
1533
+ ghApi
1534
+ );
1535
+ process.stderr.write(
1536
+ `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
810
1537
  `
811
- );
812
- return;
813
- }
814
- if (comments.length > 0) {
815
- await postInlineReview(input.repo, prNumber, input.headSha, comments, ghApi);
816
- process.stderr.write(
817
- `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
1538
+ );
1539
+ const priorReviewIds = botReviews.map((r) => r.id);
1540
+ if (priorReviewIds.length > 0) {
1541
+ await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
1542
+ }
1543
+ await minimizeComments(prNumber, priorInlineComments, ghApi);
1544
+ const unanchoredCount = unposted.length;
1545
+ const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
1546
+ if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
1547
+ const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
1548
+ try {
1549
+ await patchComment(
1550
+ input.repo,
1551
+ stickyRef.id,
1552
+ renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
1553
+ ghApi
1554
+ );
1555
+ process.stderr.write(
1556
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
818
1557
  `
819
- );
1558
+ );
1559
+ } catch (err) {
1560
+ process.stderr.write(
1561
+ `Warning: failed to update the sticky summary after the review: ${err instanceof Error ? err.message : String(err)}
1562
+ `
1563
+ );
1564
+ }
820
1565
  }
821
1566
  };
822
1567
  var renderOutputs = (result) => {
@@ -955,6 +1700,8 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
955
1700
  diffSize: Buffer.byteLength(diff, "utf8")
956
1701
  };
957
1702
  };
1703
+
1704
+ // src/extract.ts
958
1705
  var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
959
1706
  var parseNativeForExtraction = (raw) => ({
960
1707
  result: fieldOf(raw, "result"),
@@ -1014,13 +1761,6 @@ var tryParseJson = (text) => {
1014
1761
  return { ok: false };
1015
1762
  }
1016
1763
  };
1017
- var readFileOrNull = (path) => {
1018
- try {
1019
- return readFileSync(path, "utf-8");
1020
- } catch {
1021
- return null;
1022
- }
1023
- };
1024
1764
  var candidateFromJsonText = (kind, text) => {
1025
1765
  if (text === null) return null;
1026
1766
  const parsed = tryParseJson(text);
@@ -1028,7 +1768,7 @@ var candidateFromJsonText = (kind, text) => {
1028
1768
  };
1029
1769
  var FENCE_OPEN = /^\s*(`{3,})/;
1030
1770
  var FENCE_MARKER_ONLY = /^`+$/;
1031
- var scanLine = (state, line) => {
1771
+ var scanLine2 = (state, line) => {
1032
1772
  if (state.openLength === null) {
1033
1773
  const opened = FENCE_OPEN.exec(line)?.[1]?.length;
1034
1774
  return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
@@ -1037,7 +1777,27 @@ var scanLine = (state, line) => {
1037
1777
  const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
1038
1778
  return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
1039
1779
  };
1040
- var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine, { blocks: [], openLength: null, buffer: [] }).blocks;
1780
+ var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
1781
+ var ladderFailureDiagnostics = (input) => {
1782
+ const native = parseNativeForExtraction(input.native);
1783
+ const preview = (s) => {
1784
+ const flat = s.replace(/\s+/g, " ").trim();
1785
+ return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
1786
+ };
1787
+ const lines = [];
1788
+ if (input.kind === "findings") {
1789
+ lines.push(
1790
+ input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
1791
+ );
1792
+ }
1793
+ lines.push(
1794
+ 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"
1795
+ );
1796
+ lines.push(
1797
+ 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"
1798
+ );
1799
+ return lines.join("\n");
1800
+ };
1041
1801
  var describeLadderFailure = (outcome) => {
1042
1802
  switch (outcome.kind) {
1043
1803
  case "error-envelope":
@@ -1120,38 +1880,92 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1120
1880
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1121
1881
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1122
1882
  }));
1123
- var adaptClaudeCode = (native, agentFilePath, meta) => {
1124
- const outcome = extractStructured({ kind: "findings", native, agentFilePath });
1125
- if (outcome.kind !== "ok") {
1126
- return left(describeLadderFailure(outcome));
1127
- }
1128
- const resolution = resolve("findings", outcome.candidate);
1129
- return resolution.kind === "ok" ? right({
1130
- schema_version: resolution.version,
1131
- findings: resolution.value,
1132
- models: mapModelUsage(native.modelUsage),
1133
- turns: native.num_turns,
1134
- duration_ms: native.duration_ms,
1135
- vendor_cost_usd: native.total_cost_usd ?? null,
1136
- ...meta.route ? { route: meta.route } : {},
1137
- ...meta.effort ? { effort: meta.effort } : {}
1138
- }) : left(
1139
- "internal error: the extraction ladder validated a candidate the registry then rejected"
1140
- );
1883
+ var findingsOutcome = (native, agentFilePath) => {
1884
+ const ladder = extractStructured({ kind: "findings", native, agentFilePath });
1885
+ if (ladder.kind !== "ok")
1886
+ return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
1887
+ const resolution = resolve("findings", ladder.candidate);
1888
+ return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
1889
+ kind: "telemetry-only",
1890
+ reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
1891
+ };
1892
+ };
1893
+ var withMeta = (base, meta) => ({
1894
+ ...base,
1895
+ ...meta.route ? { route: meta.route } : {},
1896
+ ...meta.effort ? { effort: meta.effort } : {}
1897
+ });
1898
+ var resolveTelemetry = (native, meta) => {
1899
+ const fb = (() => {
1900
+ try {
1901
+ return meta.transcriptFallback?.();
1902
+ } catch {
1903
+ return void 0;
1904
+ }
1905
+ })();
1906
+ const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
1907
+ return withMeta(
1908
+ {
1909
+ models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
1910
+ ...wallTurns,
1911
+ vendor_cost_usd: native.vendorCostUsd
1912
+ },
1913
+ meta
1914
+ );
1915
+ };
1916
+ var nativeTelemetry = (native, meta) => resolveTelemetry(
1917
+ {
1918
+ models: mapModelUsage(native.modelUsage),
1919
+ turns: native.num_turns,
1920
+ durationMs: native.duration_ms,
1921
+ vendorCostUsd: native.total_cost_usd ?? null
1922
+ },
1923
+ meta
1924
+ );
1925
+ var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
1926
+ var buildEnvelope = (telemetry, native, agentFilePath) => {
1927
+ const outcome = findingsOutcome(native, agentFilePath);
1928
+ switch (outcome.kind) {
1929
+ case "ok":
1930
+ return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
1931
+ case "telemetry-only":
1932
+ return {
1933
+ schema_version: DEFAULT_SCHEMA_VERSION,
1934
+ findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
1935
+
1936
+ ${outcome.reason}`),
1937
+ ...telemetry
1938
+ };
1939
+ }
1141
1940
  };
1142
1941
  var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1143
1942
  switch (adapterName) {
1144
1943
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
1145
1944
  case "claude-code": {
1945
+ if (native === void 0 || native === null)
1946
+ return right(buildEnvelope(absentTelemetry(meta), void 0, agentFilePath));
1146
1947
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
1147
- if (decoded._tag === "Left") {
1948
+ if (decoded._tag === "Left")
1148
1949
  return left("native envelope does not match the Claude Code output shape");
1149
- }
1150
- return adaptClaudeCode(decoded.right, agentFilePath, meta);
1950
+ return right(buildEnvelope(nativeTelemetry(decoded.right, meta), native, agentFilePath));
1151
1951
  }
1152
1952
  }
1153
1953
  };
1154
1954
 
1955
+ // src/settings.ts
1956
+ var composeReviewSettings = (opts) => {
1957
+ const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
1958
+ return {
1959
+ hooks: {
1960
+ Stop: [
1961
+ { hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
1962
+ ],
1963
+ PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
1964
+ PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
1965
+ }
1966
+ };
1967
+ };
1968
+
1155
1969
  // src/index.ts
1156
1970
  var readJSON = (path) => {
1157
1971
  try {
@@ -1166,6 +1980,54 @@ var fail = (msg) => {
1166
1980
  `);
1167
1981
  process.exit(1);
1168
1982
  };
1983
+ var readJSONOrAbsent = (path) => {
1984
+ const read = (() => {
1985
+ try {
1986
+ return { text: readFileSync(resolve$1(path), "utf-8") };
1987
+ } catch (err) {
1988
+ return { error: err instanceof Error ? err.message : String(err) };
1989
+ }
1990
+ })();
1991
+ if ("error" in read) {
1992
+ process.stderr.write(
1993
+ `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry (issue #39)
1994
+ `
1995
+ );
1996
+ return void 0;
1997
+ }
1998
+ if (read.text.trim() === "") {
1999
+ process.stderr.write(
2000
+ `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry (issue #39)
2001
+ `
2002
+ );
2003
+ return void 0;
2004
+ }
2005
+ try {
2006
+ return JSON.parse(read.text);
2007
+ } catch (err) {
2008
+ process.stderr.write(
2009
+ `code-review: native envelope ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 proceeding with no native telemetry (issue #39)
2010
+ `
2011
+ );
2012
+ return void 0;
2013
+ }
2014
+ };
2015
+ var readStdinJSON = () => {
2016
+ if (process.stdin.isTTY) return null;
2017
+ const raw = (() => {
2018
+ try {
2019
+ return readFileSync(0, "utf-8");
2020
+ } catch {
2021
+ return "";
2022
+ }
2023
+ })();
2024
+ if (raw.trim() === "") return null;
2025
+ try {
2026
+ return JSON.parse(raw);
2027
+ } catch {
2028
+ return null;
2029
+ }
2030
+ };
1169
2031
  var decode = (either, label) => {
1170
2032
  try {
1171
2033
  return unsafeUnwrap(either);
@@ -1183,15 +2045,26 @@ var unwrapAdapt = (either) => {
1183
2045
  }
1184
2046
  throw new Error("unreachable");
1185
2047
  };
2048
+ var transcriptFallbackFrom = (path) => {
2049
+ const tree = readTranscriptTree(resolve$1(path));
2050
+ if (tree.missing)
2051
+ process.stderr.write(
2052
+ `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback (issue #36)
2053
+ `
2054
+ );
2055
+ const usage = sumTranscriptUsage(tree.entries);
2056
+ return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
2057
+ };
1186
2058
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1187
2059
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1188
2060
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
1189
- var resolvePricesPath = (pricesArg) => {
1190
- if (pricesArg) return resolve$1(pricesArg);
2061
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
2062
+ var resolvePrices = (pricesArg) => {
2063
+ if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
1191
2064
  process.stderr.write(
1192
- "code-review: no --prices given \u2014 using the bundled example prices (all zero); cost figures will be $0\n"
2065
+ "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
1193
2066
  );
1194
- return bundledPath("schema", "prices.example.json");
2067
+ return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
1195
2068
  };
1196
2069
  var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
1197
2070
  var renderCmd = defineCommand({
@@ -1239,19 +2112,21 @@ var renderCmd = defineCommand({
1239
2112
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1240
2113
  const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
1241
2114
  const templatePath = resolveTemplatePath(args.template);
1242
- const pricesPath = resolvePricesPath(args.prices);
1243
- const prices = decode(PriceMapCodec.decode(readJSON(pricesPath)), "prices");
2115
+ const priceResolution = resolvePrices(args.prices);
2116
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
1244
2117
  const template = readFileSync(templatePath, "utf-8");
1245
2118
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
1246
2119
  const output = render({
1247
2120
  findings,
1248
2121
  envelope,
1249
2122
  prices,
2123
+ pricesProvided: priceResolution.kind === "provided",
1250
2124
  template,
1251
2125
  reviewedSha: args["reviewed-sha"],
1252
2126
  route: args.route,
1253
2127
  effort: args.effort,
1254
- testReport
2128
+ testReport,
2129
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1255
2130
  });
1256
2131
  process.stdout.write(output);
1257
2132
  }
@@ -1274,14 +2149,17 @@ var inlineCmd = defineCommand({
1274
2149
  },
1275
2150
  template: {
1276
2151
  type: "string",
1277
- description: "Path to inline comment Eta template (default: built-in format)"
2152
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1278
2153
  }
1279
2154
  },
1280
2155
  run: async ({ args }) => {
1281
2156
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
1282
2157
  const diff = readFileSync(resolve$1(args.diff), "utf-8");
1283
- const inlineTemplate = args.template ? readFileSync(resolve$1(args.template), "utf-8") : void 0;
1284
- const { comments, strays } = buildInlineComments(findings.findings, diff, inlineTemplate);
2158
+ const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
2159
+ const { comments, strays } = buildInlineComments(findings.findings, diff, {
2160
+ inlineTemplate,
2161
+ findings
2162
+ });
1285
2163
  process.stdout.write(
1286
2164
  JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
1287
2165
  );
@@ -1311,8 +2189,273 @@ var costCmd = defineCommand({
1311
2189
  process.stdout.write(JSON.stringify(report, null, 2));
1312
2190
  }
1313
2191
  });
1314
- var declaredSchemaVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
1315
- var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
2192
+ var checkCostCmd = defineCommand({
2193
+ meta: {
2194
+ name: "check-cost",
2195
+ 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)"
2196
+ },
2197
+ args: {
2198
+ transcript: {
2199
+ type: "positional",
2200
+ description: "Path to the session transcript JSONL (the hook's transcript_path)",
2201
+ required: true
2202
+ },
2203
+ prices: {
2204
+ type: "string",
2205
+ description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
2206
+ }
2207
+ },
2208
+ run: async ({ args }) => {
2209
+ const tree = readTranscriptTree(resolve$1(args.transcript));
2210
+ if (tree.missing) {
2211
+ process.stderr.write(
2212
+ `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend (issue #36)
2213
+ `
2214
+ );
2215
+ }
2216
+ const usage = sumTranscriptUsage(tree.entries);
2217
+ const priceResolution = resolvePrices(args.prices);
2218
+ const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
2219
+ const report = computeCost(usage.models, prices);
2220
+ process.stdout.write(
2221
+ `${JSON.stringify(
2222
+ {
2223
+ ...report,
2224
+ turns: usage.turns,
2225
+ durationMs: usage.durationMs,
2226
+ transcripts: tree.files,
2227
+ pricesProvided: priceResolution.kind === "provided"
2228
+ },
2229
+ null,
2230
+ 2
2231
+ )}
2232
+ `
2233
+ );
2234
+ }
2235
+ });
2236
+ var tryReadPrices = (path) => {
2237
+ try {
2238
+ const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
2239
+ return decoded._tag === "Right" ? decoded.right : null;
2240
+ } catch {
2241
+ return null;
2242
+ }
2243
+ };
2244
+ var parseBudgetUsd = (raw) => {
2245
+ if (raw === void 0) return null;
2246
+ const n = Number.parseFloat(raw);
2247
+ return Number.isFinite(n) && n >= 0 ? n : null;
2248
+ };
2249
+ var mtimeMsOf = (path) => {
2250
+ try {
2251
+ return statSync(path).mtimeMs;
2252
+ } catch {
2253
+ return null;
2254
+ }
2255
+ };
2256
+ var transcriptPathOf = (input) => {
2257
+ const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
2258
+ return typeof tp === "string" ? tp : void 0;
2259
+ };
2260
+ var budgetHookCmd = defineCommand({
2261
+ meta: {
2262
+ name: "budget-hook",
2263
+ 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. At every phase, the main agent's subagent spawns are denied until it has written its own first-pass draft (seed-draft's sidecar marker tells the seed apart), and permitted spawns are rewritten to run in the background so no batch join can block the spawner (issue #73). 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."
2264
+ },
2265
+ args: {
2266
+ draft: {
2267
+ type: "string",
2268
+ description: "Path to the findings draft that is the sole permitted write target under forced convergence",
2269
+ required: true
2270
+ },
2271
+ "budget-usd": {
2272
+ type: "string",
2273
+ description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
2274
+ },
2275
+ wall: {
2276
+ type: "string",
2277
+ description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
2278
+ },
2279
+ prices: {
2280
+ type: "string",
2281
+ description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
2282
+ },
2283
+ "reserve-frac": {
2284
+ type: "string",
2285
+ description: "Base wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
2286
+ },
2287
+ "reserve-growth": {
2288
+ type: "string",
2289
+ description: "How much the reserve grows as a budget is spent \u2014 added at full usage, so convergence lands earlier the longer the run has gone (default: 0.25; 0 = flat reserve)"
2290
+ },
2291
+ "reserve-usd": {
2292
+ type: "string",
2293
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2294
+ },
2295
+ "reserve-wall": {
2296
+ type: "string",
2297
+ description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
2298
+ }
2299
+ },
2300
+ run: async ({ args }) => {
2301
+ try {
2302
+ const draftPath = resolve$1(args.draft);
2303
+ const input = readStdinJSON();
2304
+ const transcriptPath = transcriptPathOf(input);
2305
+ const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
2306
+ const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
2307
+ const prices = args.prices ? tryReadPrices(args.prices) : null;
2308
+ const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
2309
+ const wallMs = args.wall ? parseWallMs(args.wall) : null;
2310
+ const output = evaluateBudgetHook(input, {
2311
+ spentUsd,
2312
+ budgetUsd: parseBudgetUsd(args["budget-usd"]),
2313
+ elapsedMs: anchoredElapsedMs({
2314
+ deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
2315
+ wallMs,
2316
+ firstTsMs: usage?.firstTsMs ?? null,
2317
+ nowMs: Date.now()
2318
+ }),
2319
+ wallMs,
2320
+ reserve: {
2321
+ frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
2322
+ growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
2323
+ flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
2324
+ flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
2325
+ },
2326
+ draftPath,
2327
+ mainDraftWritten: mainHasWrittenDraft(
2328
+ mtimeMsOf(draftPath),
2329
+ mtimeMsOf(seedMarkerPath(draftPath))
2330
+ )
2331
+ });
2332
+ process.stdout.write(`${JSON.stringify(output)}
2333
+ `);
2334
+ } catch (err) {
2335
+ process.stderr.write(
2336
+ `code-review budget-hook: degrading to no-op \u2014 ${err instanceof Error ? err.message : String(err)}
2337
+ `
2338
+ );
2339
+ process.stdout.write("{}\n");
2340
+ }
2341
+ }
2342
+ });
2343
+ var printSettingsCmd = defineCommand({
2344
+ meta: {
2345
+ name: "print-settings",
2346
+ 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."
2347
+ },
2348
+ args: {
2349
+ draft: {
2350
+ type: "string",
2351
+ description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
2352
+ required: true
2353
+ },
2354
+ kind: {
2355
+ type: "string",
2356
+ description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
2357
+ },
2358
+ schema: {
2359
+ type: "string",
2360
+ description: "Path to a schema file for the Stop gate (wins over --kind)"
2361
+ },
2362
+ "schema-version": {
2363
+ type: "string",
2364
+ description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
2365
+ },
2366
+ "max-nudges": {
2367
+ type: "string",
2368
+ description: "Stop-gate nudge budget before relenting (default: 5)"
2369
+ },
2370
+ counter: {
2371
+ type: "string",
2372
+ description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
2373
+ },
2374
+ "budget-usd": {
2375
+ type: "string",
2376
+ description: "Dollar budget the cost axis is measured against (needs --prices)"
2377
+ },
2378
+ wall: {
2379
+ type: "string",
2380
+ description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
2381
+ },
2382
+ prices: {
2383
+ type: "string",
2384
+ description: "Price map JSON to recompute real spend from the transcript"
2385
+ },
2386
+ "reserve-frac": {
2387
+ type: "string",
2388
+ description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2389
+ },
2390
+ "reserve-growth": {
2391
+ type: "string",
2392
+ description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
2393
+ },
2394
+ "reserve-usd": {
2395
+ type: "string",
2396
+ description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
2397
+ },
2398
+ "reserve-wall": {
2399
+ type: "string",
2400
+ description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
2401
+ }
2402
+ },
2403
+ run: async ({ args }) => {
2404
+ if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
2405
+ fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
2406
+ const settings = composeReviewSettings({
2407
+ draftPath: resolve$1(args.draft),
2408
+ stop: {
2409
+ kind: args.kind,
2410
+ schema: args.schema,
2411
+ schemaVersion: args["schema-version"],
2412
+ maxNudges: args["max-nudges"],
2413
+ counter: args.counter
2414
+ },
2415
+ budget: {
2416
+ budgetUsd: args["budget-usd"],
2417
+ wall: args.wall,
2418
+ prices: args.prices,
2419
+ reserveFrac: args["reserve-frac"],
2420
+ reserveGrowth: args["reserve-growth"],
2421
+ reserveUsd: args["reserve-usd"],
2422
+ reserveWall: args["reserve-wall"]
2423
+ }
2424
+ });
2425
+ process.stdout.write(`${JSON.stringify(settings)}
2426
+ `);
2427
+ }
2428
+ });
2429
+ var deadlineCmd = defineCommand({
2430
+ meta: {
2431
+ name: "deadline",
2432
+ 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)."
2433
+ },
2434
+ args: {
2435
+ wall: {
2436
+ type: "string",
2437
+ description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
2438
+ required: true
2439
+ }
2440
+ },
2441
+ run: async ({ args }) => {
2442
+ const wallMs = parseWallMs(args.wall);
2443
+ if (wallMs === null) {
2444
+ fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
2445
+ } else {
2446
+ process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
2447
+ `);
2448
+ }
2449
+ }
2450
+ });
2451
+ var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
2452
+ var printableSchema = (schemaPath) => {
2453
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
2454
+ const enforcementSchema = Object.fromEntries(
2455
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
2456
+ );
2457
+ return JSON.stringify(enforcementSchema, null, 2);
2458
+ };
1316
2459
  var validateCmd = defineCommand({
1317
2460
  meta: {
1318
2461
  name: "validate",
@@ -1335,6 +2478,10 @@ var validateCmd = defineCommand({
1335
2478
  "schema-version": {
1336
2479
  type: "string",
1337
2480
  description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
2481
+ },
2482
+ explain: {
2483
+ type: "boolean",
2484
+ description: "On failure, also print the schema after the errors \u2014 its field descriptions are the authoritative spec, so the document can be fixed in one pass instead of by trial and error"
1338
2485
  }
1339
2486
  },
1340
2487
  run: async ({ args }) => {
@@ -1348,10 +2495,129 @@ var validateCmd = defineCommand({
1348
2495
  process.stderr.write("\u274C invalid\n");
1349
2496
  for (const e of errors) process.stderr.write(` - ${e}
1350
2497
  `);
2498
+ if (args.explain) {
2499
+ process.stderr.write(
2500
+ `
2501
+ The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
2502
+ ${printableSchema(schemaPath)}
2503
+ `
2504
+ );
2505
+ }
1351
2506
  process.exit(1);
1352
2507
  }
1353
2508
  }
1354
2509
  });
2510
+ var seedDraftCmd = defineCommand({
2511
+ meta: {
2512
+ name: "seed-draft",
2513
+ description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0 (issues #52, #53). Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself (issue #73). Prints the mode chosen (prior|empty|none \u2014 none when even the scaffold write failed) to stdout; always exits 0"
2514
+ },
2515
+ args: {
2516
+ prior: {
2517
+ type: "string",
2518
+ description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
2519
+ },
2520
+ out: {
2521
+ type: "string",
2522
+ description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
2523
+ required: true
2524
+ },
2525
+ kind: {
2526
+ type: "string",
2527
+ description: "Schema kind to validate the prior findings against (default: findings)"
2528
+ },
2529
+ schema: {
2530
+ type: "string",
2531
+ description: "Path to a schema file (wins over --kind/--schema-version)"
2532
+ },
2533
+ "schema-version": {
2534
+ type: "string",
2535
+ description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then falls back to the empty scaffold)"
2536
+ }
2537
+ },
2538
+ run: async ({ args }) => {
2539
+ const outPath = resolve$1(args.out);
2540
+ const kindArg = args.kind || "findings";
2541
+ const kind = isSchemaKind(kindArg) ? kindArg : "findings";
2542
+ if (kind !== kindArg) {
2543
+ process.stderr.write(
2544
+ `Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
2545
+ `
2546
+ );
2547
+ }
2548
+ const writeSeedMarker = () => {
2549
+ try {
2550
+ writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
2551
+ } catch (err) {
2552
+ process.stderr.write(
2553
+ `Warning: could not write the seed marker beside ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the seeded draft will count as agent-written
2554
+ `
2555
+ );
2556
+ }
2557
+ };
2558
+ const writeEmptyScaffold = () => {
2559
+ try {
2560
+ writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
2561
+ `);
2562
+ writeSeedMarker();
2563
+ process.stderr.write(
2564
+ `Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
2565
+ `
2566
+ );
2567
+ process.stdout.write("empty\n");
2568
+ } catch (err) {
2569
+ process.stderr.write(
2570
+ `Warning: could not write the seed scaffold to ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the agent will create $DRAFT itself
2571
+ `
2572
+ );
2573
+ process.stdout.write("none\n");
2574
+ }
2575
+ };
2576
+ const priorFindings = (() => {
2577
+ if (!args.prior) return null;
2578
+ const raw = (() => {
2579
+ try {
2580
+ return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
2581
+ } catch {
2582
+ return null;
2583
+ }
2584
+ })();
2585
+ const body = typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
2586
+ return body === null ? null : parseFindingsMarker(body);
2587
+ })();
2588
+ if (priorFindings === null) {
2589
+ writeEmptyScaffold();
2590
+ return;
2591
+ }
2592
+ const seededFromPrior = (() => {
2593
+ try {
2594
+ const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
2595
+ if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
2596
+ writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
2597
+ `);
2598
+ writeSeedMarker();
2599
+ return true;
2600
+ } catch (err) {
2601
+ process.stderr.write(
2602
+ `Warning: could not seed from the prior review (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to the empty scaffold
2603
+ `
2604
+ );
2605
+ return false;
2606
+ }
2607
+ })();
2608
+ if (seededFromPrior) {
2609
+ const priorList = priorFindings.findings;
2610
+ const count = Array.isArray(priorList) ? priorList.length : 0;
2611
+ process.stderr.write(
2612
+ `Seeded ${outPath} from the prior review (${String(count)} finding(s)) \u2014 verify each still holds against the current diff and refine in place
2613
+ `
2614
+ );
2615
+ process.stdout.write("prior\n");
2616
+ } else {
2617
+ writeEmptyScaffold();
2618
+ }
2619
+ }
2620
+ });
1355
2621
  var adaptCmd = defineCommand({
1356
2622
  meta: {
1357
2623
  name: "adapt",
@@ -1379,13 +2645,20 @@ var adaptCmd = defineCommand({
1379
2645
  effort: {
1380
2646
  type: "string",
1381
2647
  description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
2648
+ },
2649
+ transcript: {
2650
+ type: "string",
2651
+ description: "Path to the session transcript (the main .jsonl). Its tree (main + subagents) is the source of the true wall + turn count \u2014 the native envelope only sees the main agent and under-reports a fan-out (issue #59) \u2014 and refills per-model usage too when the native has none (a wall-clock kill leaves it empty, so cost is real not $0.00 \u2014 issues #39/#36)"
1382
2652
  }
1383
2653
  },
1384
2654
  run: async ({ args }) => {
1385
2655
  const envelope = unwrapAdapt(
1386
- adapt(requireAdapterName(args.adapter), readJSON(args.native), args["agent-file"], {
2656
+ adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
1387
2657
  route: args.route,
1388
- effort: args.effort
2658
+ effort: args.effort,
2659
+ ...args.transcript ? {
2660
+ transcriptFallback: () => transcriptFallbackFrom(args.transcript)
2661
+ } : {}
1389
2662
  })
1390
2663
  );
1391
2664
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}
@@ -1431,16 +2704,18 @@ var extractCmd = defineCommand({
1431
2704
  run: async ({ args }) => {
1432
2705
  requireAdapterName(args.adapter);
1433
2706
  const kind = requireExtractSchemaKind(args.kind);
1434
- const outcome = extractStructured({
1435
- kind,
1436
- native: readJSON(args.native),
1437
- agentFilePath: args["agent-file"]
1438
- });
2707
+ const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
2708
+ const outcome = extractStructured(input);
1439
2709
  if (outcome.kind === "ok") {
1440
2710
  process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
1441
2711
  `);
1442
2712
  return;
1443
2713
  }
2714
+ if (outcome.kind === "none" || outcome.kind === "ambiguous") {
2715
+ process.stderr.write(`extract: recovery failed \u2014
2716
+ ${ladderFailureDiagnostics(input)}
2717
+ `);
2718
+ }
1444
2719
  if (kind === "triage") {
1445
2720
  process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
1446
2721
  `);
@@ -1449,6 +2724,70 @@ var extractCmd = defineCommand({
1449
2724
  fail(describeLadderFailure(outcome));
1450
2725
  }
1451
2726
  });
2727
+ var withoutPatch = (finding) => {
2728
+ const copy = { ...finding };
2729
+ delete copy.patch;
2730
+ return copy;
2731
+ };
2732
+ var readFileLines = (path) => {
2733
+ try {
2734
+ const rawLines = readFileSync(path, "utf-8").split("\n");
2735
+ return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
2736
+ } catch {
2737
+ return null;
2738
+ }
2739
+ };
2740
+ var validateFinding = (finding, repoRoot) => {
2741
+ if (finding.patch === void 0) return finding;
2742
+ const lines = readFileLines(resolve$1(repoRoot, finding.path));
2743
+ if (lines === null) {
2744
+ process.stderr.write(
2745
+ `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
2746
+ `
2747
+ );
2748
+ return withoutPatch(finding);
2749
+ }
2750
+ const result = validatePatch(finding.patch, lines);
2751
+ switch (result.kind) {
2752
+ case "anchored":
2753
+ return { ...finding, start_line: result.startLine, end_line: result.endLine };
2754
+ case "keep":
2755
+ return finding;
2756
+ case "drop":
2757
+ process.stderr.write(
2758
+ `validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
2759
+ `
2760
+ );
2761
+ return withoutPatch(finding);
2762
+ }
2763
+ };
2764
+ var validatePatchesCmd = defineCommand({
2765
+ meta: {
2766
+ name: "validate-patches",
2767
+ 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)"
2768
+ },
2769
+ args: {
2770
+ findings: {
2771
+ type: "positional",
2772
+ description: "Path to findings JSON",
2773
+ required: true
2774
+ },
2775
+ "repo-root": {
2776
+ type: "string",
2777
+ description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
2778
+ }
2779
+ },
2780
+ run: async ({ args }) => {
2781
+ const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
2782
+ const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
2783
+ const validated = {
2784
+ ...findings,
2785
+ findings: findings.findings.map((f) => validateFinding(f, repoRoot))
2786
+ };
2787
+ process.stdout.write(`${JSON.stringify(validated, null, 2)}
2788
+ `);
2789
+ }
2790
+ });
1452
2791
  var requireAdapterName = (name) => {
1453
2792
  if (isAdapterName(name)) return name;
1454
2793
  fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
@@ -1471,7 +2810,7 @@ var requireSchemaPath = (kind, version) => {
1471
2810
  var printSchemaCmd = defineCommand({
1472
2811
  meta: {
1473
2812
  name: "print-schema",
1474
- description: "Print a bundled schema JSON"
2813
+ description: "Print a bundled schema JSON, ready to hand to a CLI's --json-schema (the $schema draft declaration is stripped)"
1475
2814
  },
1476
2815
  args: {
1477
2816
  name: {
@@ -1487,7 +2826,99 @@ var printSchemaCmd = defineCommand({
1487
2826
  run: async ({ args }) => {
1488
2827
  const schemaKind = requireSchemaKind(args.name);
1489
2828
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
1490
- process.stdout.write(readFileSync(schemaPath, "utf-8"));
2829
+ process.stdout.write(`${printableSchema(schemaPath)}
2830
+ `);
2831
+ }
2832
+ });
2833
+ var MAX_NUDGES_DEFAULT = 5;
2834
+ var drainStdin = () => {
2835
+ if (process.stdin.isTTY) return;
2836
+ try {
2837
+ readFileSync(0);
2838
+ } catch {
2839
+ }
2840
+ };
2841
+ var requireMaxNudges = (raw) => {
2842
+ if (raw === void 0) return MAX_NUDGES_DEFAULT;
2843
+ if (!/^\d+$/.test(raw)) {
2844
+ fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
2845
+ }
2846
+ const n = Number.parseInt(raw, 10);
2847
+ if (n < 1) {
2848
+ fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
2849
+ }
2850
+ return n;
2851
+ };
2852
+ var stopGateCmd = defineCommand({
2853
+ meta: {
2854
+ name: "stop-gate",
2855
+ 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."
2856
+ },
2857
+ args: {
2858
+ draft: {
2859
+ type: "string",
2860
+ description: "Path to the findings document the agent must produce and keep valid",
2861
+ required: true
2862
+ },
2863
+ kind: {
2864
+ type: "string",
2865
+ description: "Schema kind to validate against: findings | triage | prices (default: findings)"
2866
+ },
2867
+ schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
2868
+ "schema-version": {
2869
+ type: "string",
2870
+ description: "Schema major.minor to validate against (default: the draft's declared version)"
2871
+ },
2872
+ "max-nudges": {
2873
+ type: "string",
2874
+ description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
2875
+ },
2876
+ counter: {
2877
+ type: "string",
2878
+ description: "Path for the nudge counter (default: <draft>.nudges)"
2879
+ },
2880
+ "print-settings": {
2881
+ type: "boolean",
2882
+ description: "Print the Stop-hook settings JSON that wires this gate, then exit"
2883
+ }
2884
+ },
2885
+ run: async ({ args }) => {
2886
+ const draftPath = resolve$1(args.draft);
2887
+ if (args["print-settings"]) {
2888
+ const command = defaultHookCommand(draftPath, {
2889
+ kind: args.kind,
2890
+ schema: args.schema,
2891
+ schemaVersion: args["schema-version"],
2892
+ maxNudges: args["max-nudges"],
2893
+ counter: args.counter
2894
+ });
2895
+ process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
2896
+ `);
2897
+ return;
2898
+ }
2899
+ drainStdin();
2900
+ const kind = requireSchemaKind(args.kind || "findings");
2901
+ const maxNudges = requireMaxNudges(args["max-nudges"]);
2902
+ const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
2903
+ const state = draftState(
2904
+ draftPath,
2905
+ (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
2906
+ );
2907
+ const nudges = readNudges(counterPath);
2908
+ const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
2909
+ if (decision.kind === "block") {
2910
+ try {
2911
+ bumpNudges(counterPath, nudges);
2912
+ } catch (err) {
2913
+ process.stderr.write(
2914
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
2915
+ `
2916
+ );
2917
+ return;
2918
+ }
2919
+ process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
2920
+ `);
2921
+ }
1491
2922
  }
1492
2923
  });
1493
2924
  var gatherCmd = defineCommand({
@@ -1574,7 +3005,7 @@ var postCmd = defineCommand({
1574
3005
  },
1575
3006
  "inline-template": {
1576
3007
  type: "string",
1577
- description: "Path to inline comment Eta template (default: built-in format)"
3008
+ description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
1578
3009
  },
1579
3010
  route: {
1580
3011
  type: "string",
@@ -1595,22 +3026,35 @@ var postCmd = defineCommand({
1595
3026
  "test-report": {
1596
3027
  type: "string",
1597
3028
  description: TEST_REPORT_DESCRIPTION
3029
+ },
3030
+ "run-url": {
3031
+ type: "string",
3032
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
3033
+ },
3034
+ "json-url": {
3035
+ type: "string",
3036
+ description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
1598
3037
  }
1599
3038
  },
1600
3039
  run: async ({ args }) => {
3040
+ const priceResolution = resolvePrices(args.prices);
1601
3041
  await post({
1602
3042
  repo: args.repo,
1603
3043
  headSha: args["head-sha"],
1604
3044
  botLogin: args["bot-login"] || "github-actions[bot]",
1605
3045
  findingsPath: args.findings,
1606
3046
  envelopePath: args.usage,
1607
- pricesPath: resolvePricesPath(args.prices),
3047
+ pricesPath: priceResolution.path,
3048
+ pricesProvided: priceResolution.kind === "provided",
1608
3049
  templatePath: resolveTemplatePath(args.template),
1609
- inlineTemplatePath: args["inline-template"] ? resolve$1(args["inline-template"]) : void 0,
3050
+ inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
1610
3051
  route: args.route,
1611
3052
  headBranch: args["head-branch"],
1612
3053
  effort: args.effort,
1613
- testReportPath: args["test-report"]
3054
+ testReportPath: args["test-report"],
3055
+ runUrl: args["run-url"],
3056
+ jsonUrl: args["json-url"],
3057
+ postedAt: formatUtc(/* @__PURE__ */ new Date())
1614
3058
  });
1615
3059
  }
1616
3060
  });
@@ -1618,7 +3062,7 @@ var main = defineCommand({
1618
3062
  meta: {
1619
3063
  name: "code-review",
1620
3064
  version: packageVersion,
1621
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, cost, and validate findings JSON"
3065
+ description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, seed-draft, stop-gate, budget-hook, print-settings, and deadline"
1622
3066
  },
1623
3067
  subCommands: {
1624
3068
  gather: gatherCmd,
@@ -1626,10 +3070,17 @@ var main = defineCommand({
1626
3070
  inline: inlineCmd,
1627
3071
  post: postCmd,
1628
3072
  cost: costCmd,
3073
+ "check-cost": checkCostCmd,
1629
3074
  validate: validateCmd,
3075
+ "seed-draft": seedDraftCmd,
1630
3076
  adapt: adaptCmd,
1631
3077
  extract: extractCmd,
1632
- "print-schema": printSchemaCmd
3078
+ "validate-patches": validatePatchesCmd,
3079
+ "print-schema": printSchemaCmd,
3080
+ "stop-gate": stopGateCmd,
3081
+ "budget-hook": budgetHookCmd,
3082
+ "print-settings": printSettingsCmd,
3083
+ deadline: deadlineCmd
1633
3084
  }
1634
3085
  });
1635
3086
  if (!process.env["VITEST"]) {