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