@jphutchins/code-review 0.1.0-alpha.62 → 0.1.0-alpha.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,23 +2,32 @@
2
2
  import { defineCommand, runMain } from 'citty';
3
3
  import { readFileSync, writeFileSync, unlinkSync, copyFileSync, statSync, readdirSync, appendFileSync } from 'fs';
4
4
  import { randomBytes, createHash } from 'crypto';
5
- import { resolve as resolve$1, join, dirname, basename, extname, sep } from 'path';
5
+ import { resolve, join, dirname, basename, extname, sep } from 'path';
6
6
  import { Eta } from 'eta';
7
7
  import * as t from 'io-ts';
8
- import parseDiff from 'parse-diff';
9
- import { Ajv2020 } from 'ajv/dist/2020.js';
10
- import _addFormats from 'ajv-formats';
11
8
  import { execFile } from 'child_process';
12
9
  import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
13
10
  import { tmpdir } from 'os';
14
11
  import { promisify } from 'util';
12
+ import parseDiff from 'parse-diff';
13
+ import { Ajv2020 } from 'ajv/dist/2020.js';
14
+ import _addFormats from 'ajv-formats';
15
15
  import { performance } from 'perf_hooks';
16
16
  import { PathReporter } from 'io-ts/lib/PathReporter.js';
17
17
 
18
18
  var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
19
19
  var errMsg = (e) => e instanceof Error ? e.message : String(e);
20
20
  var annotationSafe = (msg) => msg.replaceAll(/[\r\n]+/g, " ");
21
- var modelIdentity = (configuredModelId) => configuredModelId.replace(/\[[12]m\]$/i, "");
21
+ var CONTEXT_SUFFIX_RE = /\[[12]m\]/i;
22
+ var MODEL_IDENTITY_SUFFIX_RE = new RegExp(CONTEXT_SUFFIX_RE.source, "gi");
23
+ var modelIdentity = (configuredModelId) => configuredModelId.replace(MODEL_IDENTITY_SUFFIX_RE, "");
24
+ var addModelUsage = (prior, entry) => ({
25
+ ...prior,
26
+ input_tokens: prior.input_tokens + entry.input_tokens,
27
+ output_tokens: prior.output_tokens + entry.output_tokens,
28
+ cache_read_tokens: (prior.cache_read_tokens ?? 0) + (entry.cache_read_tokens ?? 0),
29
+ cache_write_tokens: (prior.cache_write_tokens ?? 0) + (entry.cache_write_tokens ?? 0)
30
+ });
22
31
  var clipText = (body, max) => {
23
32
  if (body.length <= max) return body;
24
33
  const cut = body.slice(0, max);
@@ -533,8 +542,7 @@ var resolveFlatPrices = (model, p, pricedAt, warn) => {
533
542
  );
534
543
  return null;
535
544
  };
536
- var computeModelCost = (entry, prices, pricedAt, warn) => {
537
- const p = Object.hasOwn(prices.models, entry.model) ? prices.models[entry.model] : void 0;
545
+ var computeModelCost = (entry, prices, pricedAt, warn, colliding = /* @__PURE__ */ new Set()) => {
538
546
  const cacheRead = entry.cache_read_tokens ?? 0;
539
547
  const cacheWrite = entry.cache_write_tokens ?? 0;
540
548
  const zero = {
@@ -546,6 +554,14 @@ var computeModelCost = (entry, prices, pricedAt, warn) => {
546
554
  costUSD: 0,
547
555
  known: true
548
556
  };
557
+ const canonicalModel = modelIdentity(entry.model);
558
+ if (colliding.has(canonicalModel)) {
559
+ warn(
560
+ `code-review cost: model "${annotationSafe(entry.model)}" is unpriced \u2014 its price map key collides with another key after canonicalization`
561
+ );
562
+ return { ...zero, known: false };
563
+ }
564
+ const p = Object.hasOwn(prices.models, canonicalModel) ? prices.models[canonicalModel] : void 0;
549
565
  if (!p) {
550
566
  warn(
551
567
  // annotationSafe: a model id is an unvalidated t.string — a CR/LF in it must not emit a
@@ -560,7 +576,26 @@ var computeModelCost = (entry, prices, pricedAt, warn) => {
560
576
  return { ...zero, costUSD };
561
577
  };
562
578
  var computeCost = (models, prices, pricedAt, warn = defaultWarn) => {
563
- const lines = models.map((entry) => computeModelCost(entry, prices, pricedAt, warn));
579
+ const colliding = /* @__PURE__ */ new Set();
580
+ const seenCanonical = /* @__PURE__ */ new Map();
581
+ const canonicalModels = Object.fromEntries(
582
+ Object.entries(prices.models).map(([key2, rate]) => {
583
+ const canonical = modelIdentity(key2);
584
+ const prior = seenCanonical.get(canonical);
585
+ if (prior !== void 0) {
586
+ colliding.add(canonical);
587
+ warn(
588
+ `code-review cost: the price map keys "${annotationSafe(prior)}" and "${annotationSafe(key2)}" both canonicalize to "${annotationSafe(canonical)}" \u2014 the model is unpriced; fix the map`
589
+ );
590
+ }
591
+ seenCanonical.set(canonical, key2);
592
+ return [canonical, rate];
593
+ })
594
+ );
595
+ const canonicalPrices = { ...prices, models: canonicalModels };
596
+ const lines = models.map(
597
+ (entry) => computeModelCost(entry, canonicalPrices, pricedAt, warn, colliding)
598
+ );
564
599
  return {
565
600
  lines,
566
601
  totalInputTokens: lines.reduce((s, l) => s + l.inputTokens, 0),
@@ -1159,6 +1194,167 @@ var reviewBodyPointer = (headSha, stickyUrl, runUrl, runHasSummary) => {
1159
1194
  const run2 = runUrl ? ` See the [workflow run](${runUrl}) for ${runHasSummary ? "this round's review in full, its job log," : "the job log"} and the findings artifact.` : "";
1160
1195
  return `\u{1F916} Automated code review for \`${sha7}\` \u2014 see ${summary} for the verdict, walkthrough, and cost.${run2}`;
1161
1196
  };
1197
+ var MAX_BUFFER = 100 * 1024 * 1024;
1198
+ var MAX_TIMEOUT_MS = 2147483647;
1199
+ var parseTimeoutMs = (raw, fallback) => {
1200
+ if (raw === void 0 || !/^\d+$/.test(raw)) return fallback;
1201
+ const parsed = Number(raw);
1202
+ return parsed > 0 && parsed <= MAX_TIMEOUT_MS ? parsed : fallback;
1203
+ };
1204
+ var SUBPROCESS_TIMEOUT_ENV = "CODE_REVIEW_SUBPROCESS_TIMEOUT_MS";
1205
+ var DEFAULT_SUBPROCESS_TIMEOUT_MS = 12e4;
1206
+ var subprocessTimeoutMs = () => parseTimeoutMs(process.env[SUBPROCESS_TIMEOUT_ENV], DEFAULT_SUBPROCESS_TIMEOUT_MS);
1207
+ var classifyExecError = (err, stderr, timeoutMs) => {
1208
+ const e = err;
1209
+ const stderrStr = stderr.trim();
1210
+ if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
1211
+ return `output exceeded ${String(MAX_BUFFER)} bytes (killed)`;
1212
+ if (e.killed === true)
1213
+ return `no response within ${String(timeoutMs)}ms (killed a hung child)${stderrStr ? `: ${stderrStr}` : ""}`;
1214
+ return stderrStr || errMsg(err);
1215
+ };
1216
+ var execFileWithTimeout = (spec) => new Promise((resolve4, reject) => {
1217
+ const child = execFile(
1218
+ spec.command,
1219
+ [...spec.args],
1220
+ {
1221
+ ...spec.env ? { env: { ...process.env, ...spec.env } } : {},
1222
+ encoding: "utf-8",
1223
+ maxBuffer: MAX_BUFFER,
1224
+ timeout: spec.timeoutMs,
1225
+ killSignal: "SIGKILL"
1226
+ },
1227
+ (err, stdout, stderr) => {
1228
+ if (err)
1229
+ reject(
1230
+ new Error(`${spec.label} failed: ${classifyExecError(err, stderr, spec.timeoutMs)}`)
1231
+ );
1232
+ else resolve4(stdout);
1233
+ }
1234
+ );
1235
+ if (spec.stdin !== void 0) {
1236
+ child.stdin?.on("error", () => void 0);
1237
+ child.stdin?.end(spec.stdin);
1238
+ }
1239
+ });
1240
+
1241
+ // src/gh.ts
1242
+ var describeEndpoint = (args) => args.find((a) => a === "graphql" || a.includes("/") && !a.startsWith("-")) ?? args[0] ?? "(no endpoint)";
1243
+ var flagFirst;
1244
+ var withEscapeRetry = async (run2, idempotent) => {
1245
+ if (flagFirst) return run2(true);
1246
+ try {
1247
+ return await run2(false);
1248
+ } catch (err) {
1249
+ if (errMsg(err).includes("--allow-escape-sequences")) {
1250
+ if (!idempotent) throw err;
1251
+ flagFirst = true;
1252
+ return run2(true);
1253
+ }
1254
+ throw err;
1255
+ }
1256
+ };
1257
+ var isIdempotentCall = (args) => !args.includes("--input") && !args.includes("--method") && !args.includes("graphql");
1258
+ var runGhApi = (args, stdin, env) => withEscapeRetry(
1259
+ (withFlag) => execFileWithTimeout({
1260
+ command: "gh",
1261
+ args: ["api", ...withFlag ? ["--allow-escape-sequences"] : [], ...args],
1262
+ label: `gh api ${describeEndpoint(args)}`,
1263
+ timeoutMs: subprocessTimeoutMs(),
1264
+ env,
1265
+ stdin
1266
+ }),
1267
+ isIdempotentCall(args)
1268
+ );
1269
+
1270
+ // src/artifact.ts
1271
+ var run = promisify(execFile);
1272
+ var STEP_TIMEOUT_MS = 6e4;
1273
+ var MARKER_URL = /<!-- code-review:findings-json (https?:\/\/[^\s>]+) -->/;
1274
+ var findingsArtifactUrl = (body) => MARKER_URL.exec(body)?.[1] ?? null;
1275
+ var containedPath = (dir, member) => {
1276
+ const target = resolve(dir, member);
1277
+ return target.startsWith(`${dir}${sep}`) || target === dir ? target : null;
1278
+ };
1279
+ var hasFindingsMarker = (body) => parseFindingsMarker(body) !== null || findingsArtifactUrl(body) !== null;
1280
+ var locateFindingsMember = (listing) => listing.split("\n").map((l) => l.trim()).filter(
1281
+ (l) => l.toLowerCase() === "findings.json" || l.toLowerCase().endsWith("/findings.json")
1282
+ ).sort((a, b) => a.length - b.length)[0] ?? null;
1283
+ var readArtifactFindings = (ghApiPath) => {
1284
+ return async (zipUrl) => {
1285
+ try {
1286
+ const dir = await mkdtemp(join(tmpdir(), "code-review-artifact-"));
1287
+ const zip = join(dir, "findings.zip");
1288
+ try {
1289
+ await ghApiPath(zipUrl, zip);
1290
+ const { stdout: listing } = await run("unzip", ["-Z1", zip], {
1291
+ encoding: "utf-8",
1292
+ maxBuffer: 4 * 1024 * 1024,
1293
+ timeout: STEP_TIMEOUT_MS
1294
+ });
1295
+ const member = locateFindingsMember(listing);
1296
+ if (member === null) return null;
1297
+ await run("unzip", [zip, "-d", dir], {
1298
+ maxBuffer: 64 * 1024 * 1024,
1299
+ timeout: STEP_TIMEOUT_MS
1300
+ });
1301
+ const target = containedPath(dir, member);
1302
+ if (target === null) {
1303
+ process.stderr.write(
1304
+ `::warning::the findings artifact names a member outside the archive directory (${member}) \u2014 the re-review seeds without the prior document`
1305
+ );
1306
+ return null;
1307
+ }
1308
+ return await readFile(target, "utf-8");
1309
+ } finally {
1310
+ await rm(dir, { recursive: true, force: true }).catch(() => void 0);
1311
+ }
1312
+ } catch (err) {
1313
+ process.stderr.write(
1314
+ annotationSafe(
1315
+ `::warning::could not resolve the prior findings artifact ${zipUrl} (${errMsg(err)}) \u2014 the re-review seeds without the prior document`
1316
+ )
1317
+ );
1318
+ return null;
1319
+ }
1320
+ };
1321
+ };
1322
+ var ghArtifactReader = readArtifactFindings(async (url, outPath) => {
1323
+ const { stdout } = await withEscapeRetry(
1324
+ (withFlag) => run("gh", ["api", ...withFlag ? ["--allow-escape-sequences"] : [], url], {
1325
+ encoding: "buffer",
1326
+ maxBuffer: 256 * 1024 * 1024,
1327
+ timeout: STEP_TIMEOUT_MS
1328
+ }),
1329
+ true
1330
+ );
1331
+ await writeFile(outPath, stdout);
1332
+ });
1333
+ var withoutKey = (doc, key2) => Object.fromEntries(Object.entries(doc).filter(([k]) => k !== key2));
1334
+ var withStampedConvergence = (doc, body) => {
1335
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return doc;
1336
+ const stripped = withoutKey(doc, "convergence");
1337
+ const stamped = parseConvergenceMarker(body);
1338
+ return stamped === null ? stripped : { ...stripped, convergence: stamped };
1339
+ };
1340
+ var resolvePriorFindings = async (body, read) => {
1341
+ const embedded = parseFindingsMarker(body);
1342
+ if (embedded !== null) return embedded;
1343
+ const url = findingsArtifactUrl(body);
1344
+ if (url === null) return null;
1345
+ const text = await read(url);
1346
+ if (text === null) return null;
1347
+ try {
1348
+ return withStampedConvergence(JSON.parse(text), body);
1349
+ } catch (err) {
1350
+ process.stderr.write(
1351
+ annotationSafe(
1352
+ `::warning::could not parse the findings document fetched from ${url} (${errMsg(err)}) \u2014 the re-review seeds without the prior document`
1353
+ )
1354
+ );
1355
+ return null;
1356
+ }
1357
+ };
1162
1358
 
1163
1359
  // src/comment-chain.ts
1164
1360
  function* ancestors(start, parentOf) {
@@ -1512,6 +1708,24 @@ var fetchThreadComments = async (ghApi, repo, prNumber) => {
1512
1708
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
1513
1709
  var encodeAutolinkParens = (url) => url.replace(/\(/g, "%28").replace(/\)/g, "%29");
1514
1710
  var linkSafeUrl = (url) => encodeAutolinkParens(escapeCodeBackticks(url));
1711
+ var shedNote = (count, summaryAvailable, runUrl, findingsDocLinked, thisRunArtifact) => count === 0 ? "" : `<sub>**${String(count)} finding${count === 1 ? "" : "s"} left out of this comment**${summaryAvailable && runUrl !== void 0 ? ` \u2014 the [run summary](<${runUrl}>) carries the whole review` : findingsDocLinked && thisRunArtifact ? " \u2014 the machine findings document carries them" : ""}.</sub>`;
1712
+ var terminalNotice = (i) => [
1713
+ `## ${i.verdictBadge}`,
1714
+ "",
1715
+ `<sub>**Reviewed** \`${escapeCodeBackticks(i.reviewedSha.slice(0, 7))}\`${i.postedAt ? ` at ${i.postedAt}` : ""}${i.route ? ` \xB7 **route:** ${i.route}` : ""}</sub>`,
1716
+ ...i.roundsSummary !== "" ? [`<sub>${i.roundsSummary}</sub>`] : [],
1717
+ ...i.convergenceSummary !== "" ? [`<sub>${i.convergenceSummary}</sub>`] : [],
1718
+ ...i.inlinePosted > 0 ? [
1719
+ `<sub>\u2192 ${String(i.inlinePosted)} inline comment${i.inlinePosted === 1 ? "" : "s"} posted on the diff \u2014 the disposition line the terminal round otherwise skips.</sub>`
1720
+ ] : [],
1721
+ "",
1722
+ `> **The review for \`${escapeCodeBackticks(i.reviewedSha.slice(0, 7))}\` exceeds GitHub's comment-size limit.**` + (i.summaryAvailable ? i.runUrl ? ` The whole review lives in the [run summary](<${i.runUrl}>).` : " The whole review lives in the workflow run's summary." : i.findingsDocLinked && i.thisRunArtifact ? " The whole review lives in the machine findings document." : "") + (i.findingsDocLinked && i.thisRunArtifact ? " The machine findings document is linked above." : ""),
1723
+ "",
1724
+ "> [!WARNING]",
1725
+ `> **LLM Disclosure** \u2014 this review was produced by ${i.modelNames || "unknown model"}.`,
1726
+ ">",
1727
+ `> _Generated by [code-review](https://github.com/JPHutchins/code-review)${i.runUrl ? ` \xB7 [view the run & traces](<${i.runUrl}>)` : ""}._`
1728
+ ].join("\n");
1515
1729
  var permalinkFor = (base, f, anchor) => {
1516
1730
  if (f.path === "" || f.side === "LEFT") return void 0;
1517
1731
  const path = f.path.split("/").map(
@@ -1636,6 +1850,20 @@ var computeSeverityCounts = (findings) => findings.reduce(
1636
1850
  );
1637
1851
  var isReviewVerdict = (verdict) => verdict !== "error";
1638
1852
  var isConvergenceRound = (route, incomplete) => route === "full review" && !incomplete;
1853
+ var verdictBadge = (v) => {
1854
+ switch (v) {
1855
+ case "approve":
1856
+ return "\u2705 approved";
1857
+ case "comment":
1858
+ return "\u{1F4AC} comment";
1859
+ case "changes":
1860
+ return "\u{1F527} changes requested";
1861
+ case "error":
1862
+ return "\u{1F6E0}\uFE0F no review verdict";
1863
+ default:
1864
+ return `\u2753 ${v}`;
1865
+ }
1866
+ };
1639
1867
  var render = (input) => {
1640
1868
  const eta = new Eta({ autoTrim: false });
1641
1869
  const usageAvailable = input.envelope !== null;
@@ -1748,6 +1976,8 @@ var render = (input) => {
1748
1976
  };
1749
1977
  return eta.renderString(input.template, {
1750
1978
  findings: input.findings,
1979
+ shedCount: input.shedCount ?? 0,
1980
+ terminal: input.terminal ?? false,
1751
1981
  envelope: input.envelope,
1752
1982
  usageAvailable,
1753
1983
  hasUsage,
@@ -1755,6 +1985,38 @@ var render = (input) => {
1755
1985
  costReport,
1756
1986
  pricesProvided,
1757
1987
  costProvenance,
1988
+ // The terminal branch's "machine findings document is linked above" claim must only fire when
1989
+ // the pointer actually carries a findings-json marker (the convergence-only marker does not).
1990
+ // Derived from the SAME value the template renders (the jsonUrl fallback pair included).
1991
+ findingsDocLinked: hasFindingsMarker(
1992
+ input.findingsPointer ?? findingsMarkerPair(input.jsonUrl, input.findings.convergence)
1993
+ ),
1994
+ summaryAvailable: input.summaryAvailable ?? false,
1995
+ shedNote: shedNote(
1996
+ input.shedCount ?? 0,
1997
+ input.summaryAvailable ?? false,
1998
+ input.runUrl,
1999
+ hasFindingsMarker(
2000
+ input.findingsPointer ?? findingsMarkerPair(input.jsonUrl, input.findings.convergence)
2001
+ ),
2002
+ input.jsonUrl !== void 0
2003
+ ),
2004
+ terminalNotice: terminalNotice({
2005
+ reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
2006
+ verdictBadge: verdictBadge(input.findings.verdict),
2007
+ route: route ?? void 0,
2008
+ postedAt: input.postedAt ?? "",
2009
+ roundsSummary: roundsSummary(trajectory, input.roundCount),
2010
+ convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : "",
2011
+ inlinePosted: input.inlineDisposition?.kind === "posted" ? input.inlineDisposition.count : 0,
2012
+ runUrl: input.runUrl,
2013
+ summaryAvailable: input.summaryAvailable ?? false,
2014
+ findingsDocLinked: hasFindingsMarker(
2015
+ input.findingsPointer ?? findingsMarkerPair(input.jsonUrl, input.findings.convergence)
2016
+ ),
2017
+ thisRunArtifact: input.jsonUrl !== void 0,
2018
+ modelNames
2019
+ }),
1758
2020
  // The house sanitizer for untrusted template fields — collapses line breaks AND escapes
1759
2021
  // backticks, so a hand-edited stamp cannot break the code span or the blockquote.
1760
2022
  // A nonconforming stamp must not be presented as validated: the template shows a note
@@ -1819,20 +2081,7 @@ var render = (input) => {
1819
2081
  const s = Math.round(ms / 1e3);
1820
2082
  return s >= 60 ? `${String(Math.floor(s / 60))}m ${String(s % 60)}s` : `${String(s)}s`;
1821
2083
  },
1822
- verdictBadge: (v) => {
1823
- switch (v) {
1824
- case "approve":
1825
- return "\u2705 approved";
1826
- case "comment":
1827
- return "\u{1F4AC} comment";
1828
- case "changes":
1829
- return "\u{1F527} changes requested";
1830
- case "error":
1831
- return "\u{1F6E0}\uFE0F no review verdict";
1832
- default:
1833
- return `\u2753 ${v}`;
1834
- }
1835
- },
2084
+ verdictBadge,
1836
2085
  severityEmoji,
1837
2086
  formatConfidence
1838
2087
  });
@@ -2470,7 +2719,7 @@ var defaultVersion = (kind) => {
2470
2719
  if (!latest) throw new Error(`Registry invariant violated \u2014 no latest entry for "${kind}"`);
2471
2720
  return latest.defaultVersion;
2472
2721
  };
2473
- var bundledSchemaPath = (relativePath) => resolve$1(import.meta.dirname, "..", "schema", relativePath);
2722
+ var bundledSchemaPath = (relativePath) => resolve(import.meta.dirname, "..", "schema", relativePath);
2474
2723
  var schemaPathFor = (kind, version) => {
2475
2724
  const table = tableFor(kind);
2476
2725
  const entry = version === void 0 ? table.find((v) => v.latest) : table.find((v) => v.minor === majorMinor(version));
@@ -2503,7 +2752,7 @@ var resolvers = {
2503
2752
  triage: (raw) => resolveSingleVersion("triage", raw),
2504
2753
  prices: (raw) => resolveSingleVersion("prices", raw)
2505
2754
  };
2506
- var resolve = (kind, raw) => resolvers[kind](raw);
2755
+ var resolve2 = (kind, raw) => resolvers[kind](raw);
2507
2756
  var resolveTolerantFindings = (doc) => {
2508
2757
  const r = resolveFindings(doc);
2509
2758
  if (r.kind === "ok") return r.value;
@@ -2511,165 +2760,6 @@ var resolveTolerantFindings = (doc) => {
2511
2760
  const legacy = FindingsCodecV09.decode(doc);
2512
2761
  return legacy._tag === "Right" ? normalizeV09(legacy.right) : null;
2513
2762
  };
2514
- var MAX_BUFFER = 100 * 1024 * 1024;
2515
- var MAX_TIMEOUT_MS = 2147483647;
2516
- var parseTimeoutMs = (raw, fallback) => {
2517
- if (raw === void 0 || !/^\d+$/.test(raw)) return fallback;
2518
- const parsed = Number(raw);
2519
- return parsed > 0 && parsed <= MAX_TIMEOUT_MS ? parsed : fallback;
2520
- };
2521
- var SUBPROCESS_TIMEOUT_ENV = "CODE_REVIEW_SUBPROCESS_TIMEOUT_MS";
2522
- var DEFAULT_SUBPROCESS_TIMEOUT_MS = 12e4;
2523
- var subprocessTimeoutMs = () => parseTimeoutMs(process.env[SUBPROCESS_TIMEOUT_ENV], DEFAULT_SUBPROCESS_TIMEOUT_MS);
2524
- var classifyExecError = (err, stderr, timeoutMs) => {
2525
- const e = err;
2526
- const stderrStr = stderr.trim();
2527
- if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
2528
- return `output exceeded ${String(MAX_BUFFER)} bytes (killed)`;
2529
- if (e.killed === true)
2530
- return `no response within ${String(timeoutMs)}ms (killed a hung child)${stderrStr ? `: ${stderrStr}` : ""}`;
2531
- return stderrStr || errMsg(err);
2532
- };
2533
- var execFileWithTimeout = (spec) => new Promise((resolve4, reject) => {
2534
- const child = execFile(
2535
- spec.command,
2536
- [...spec.args],
2537
- {
2538
- ...spec.env ? { env: { ...process.env, ...spec.env } } : {},
2539
- encoding: "utf-8",
2540
- maxBuffer: MAX_BUFFER,
2541
- timeout: spec.timeoutMs,
2542
- killSignal: "SIGKILL"
2543
- },
2544
- (err, stdout, stderr) => {
2545
- if (err)
2546
- reject(
2547
- new Error(`${spec.label} failed: ${classifyExecError(err, stderr, spec.timeoutMs)}`)
2548
- );
2549
- else resolve4(stdout);
2550
- }
2551
- );
2552
- if (spec.stdin !== void 0) {
2553
- child.stdin?.on("error", () => void 0);
2554
- child.stdin?.end(spec.stdin);
2555
- }
2556
- });
2557
-
2558
- // src/gh.ts
2559
- var describeEndpoint = (args) => args.find((a) => a === "graphql" || a.includes("/") && !a.startsWith("-")) ?? args[0] ?? "(no endpoint)";
2560
- var flagFirst;
2561
- var withEscapeRetry = async (run2, idempotent) => {
2562
- if (flagFirst) return run2(true);
2563
- try {
2564
- return await run2(false);
2565
- } catch (err) {
2566
- if (errMsg(err).includes("--allow-escape-sequences")) {
2567
- if (!idempotent) throw err;
2568
- flagFirst = true;
2569
- return run2(true);
2570
- }
2571
- throw err;
2572
- }
2573
- };
2574
- var isIdempotentCall = (args) => !args.includes("--input") && !args.includes("--method") && !args.includes("graphql");
2575
- var runGhApi = (args, stdin, env) => withEscapeRetry(
2576
- (withFlag) => execFileWithTimeout({
2577
- command: "gh",
2578
- args: ["api", ...withFlag ? ["--allow-escape-sequences"] : [], ...args],
2579
- label: `gh api ${describeEndpoint(args)}`,
2580
- timeoutMs: subprocessTimeoutMs(),
2581
- env,
2582
- stdin
2583
- }),
2584
- isIdempotentCall(args)
2585
- );
2586
- var run = promisify(execFile);
2587
- var STEP_TIMEOUT_MS = 6e4;
2588
- var MARKER_URL = /<!-- code-review:findings-json (https?:\/\/[^\s>]+) -->/;
2589
- var findingsArtifactUrl = (body) => MARKER_URL.exec(body)?.[1] ?? null;
2590
- var containedPath = (dir, member) => {
2591
- const target = resolve$1(dir, member);
2592
- return target.startsWith(`${dir}${sep}`) || target === dir ? target : null;
2593
- };
2594
- var hasFindingsMarker = (body) => parseFindingsMarker(body) !== null || findingsArtifactUrl(body) !== null;
2595
- var locateFindingsMember = (listing) => listing.split("\n").map((l) => l.trim()).filter(
2596
- (l) => l.toLowerCase() === "findings.json" || l.toLowerCase().endsWith("/findings.json")
2597
- ).sort((a, b) => a.length - b.length)[0] ?? null;
2598
- var readArtifactFindings = (ghApiPath) => {
2599
- return async (zipUrl) => {
2600
- try {
2601
- const dir = await mkdtemp(join(tmpdir(), "code-review-artifact-"));
2602
- const zip = join(dir, "findings.zip");
2603
- try {
2604
- await ghApiPath(zipUrl, zip);
2605
- const { stdout: listing } = await run("unzip", ["-Z1", zip], {
2606
- encoding: "utf-8",
2607
- maxBuffer: 4 * 1024 * 1024,
2608
- timeout: STEP_TIMEOUT_MS
2609
- });
2610
- const member = locateFindingsMember(listing);
2611
- if (member === null) return null;
2612
- await run("unzip", [zip, "-d", dir], {
2613
- maxBuffer: 64 * 1024 * 1024,
2614
- timeout: STEP_TIMEOUT_MS
2615
- });
2616
- const target = containedPath(dir, member);
2617
- if (target === null) {
2618
- process.stderr.write(
2619
- `::warning::the findings artifact names a member outside the archive directory (${member}) \u2014 the re-review seeds without the prior document`
2620
- );
2621
- return null;
2622
- }
2623
- return await readFile(target, "utf-8");
2624
- } finally {
2625
- await rm(dir, { recursive: true, force: true }).catch(() => void 0);
2626
- }
2627
- } catch (err) {
2628
- process.stderr.write(
2629
- annotationSafe(
2630
- `::warning::could not resolve the prior findings artifact ${zipUrl} (${errMsg(err)}) \u2014 the re-review seeds without the prior document`
2631
- )
2632
- );
2633
- return null;
2634
- }
2635
- };
2636
- };
2637
- var ghArtifactReader = readArtifactFindings(async (url, outPath) => {
2638
- const { stdout } = await withEscapeRetry(
2639
- (withFlag) => run("gh", ["api", ...withFlag ? ["--allow-escape-sequences"] : [], url], {
2640
- encoding: "buffer",
2641
- maxBuffer: 256 * 1024 * 1024,
2642
- timeout: STEP_TIMEOUT_MS
2643
- }),
2644
- true
2645
- );
2646
- await writeFile(outPath, stdout);
2647
- });
2648
- var withoutKey = (doc, key2) => Object.fromEntries(Object.entries(doc).filter(([k]) => k !== key2));
2649
- var withStampedConvergence = (doc, body) => {
2650
- if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return doc;
2651
- const stripped = withoutKey(doc, "convergence");
2652
- const stamped = parseConvergenceMarker(body);
2653
- return stamped === null ? stripped : { ...stripped, convergence: stamped };
2654
- };
2655
- var resolvePriorFindings = async (body, read) => {
2656
- const embedded = parseFindingsMarker(body);
2657
- if (embedded !== null) return embedded;
2658
- const url = findingsArtifactUrl(body);
2659
- if (url === null) return null;
2660
- const text = await read(url);
2661
- if (text === null) return null;
2662
- try {
2663
- return withStampedConvergence(JSON.parse(text), body);
2664
- } catch (err) {
2665
- process.stderr.write(
2666
- annotationSafe(
2667
- `::warning::could not parse the findings document fetched from ${url} (${errMsg(err)}) \u2014 the re-review seeds without the prior document`
2668
- )
2669
- );
2670
- return null;
2671
- }
2672
- };
2673
2763
 
2674
2764
  // src/pr.ts
2675
2765
  var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
@@ -2812,6 +2902,9 @@ var checkRun = async (input, ghApi = runGhApi) => {
2812
2902
  };
2813
2903
 
2814
2904
  // src/post.ts
2905
+ var STICKY_CHAR_LIMIT = 65536;
2906
+ var SHED_RESERVE = 4e3;
2907
+ var SHED_NOTE_BUDGET = 512;
2815
2908
  var DEFAULT_MARKER = "<!-- code-review -->";
2816
2909
  var EMPTY_MECHANIC_LEAVE_MESSAGE = "The CI-fix pass found no issues and the sticky already reflects a completed full review \u2014 leaving it in place\n";
2817
2910
  var MAX_SUGGESTION_LINES = 10;
@@ -2835,7 +2928,7 @@ var checkLongSuggestions = (comments) => {
2835
2928
  return { comments: adjusted, longFiles };
2836
2929
  };
2837
2930
  var decodeFindings = (doc) => {
2838
- const resolution = resolve("findings", doc);
2931
+ const resolution = resolve2("findings", doc);
2839
2932
  switch (resolution.kind) {
2840
2933
  case "ok":
2841
2934
  return { kind: "ok", findings: resolution.value };
@@ -3602,6 +3695,74 @@ ${dropNote}` : ""}`,
3602
3695
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
3603
3696
  const clocDiff = input.clocDiffPath ? loadClocDiff(input.clocDiffPath) : void 0;
3604
3697
  const effectiveRoute = input.route ?? envelope?.route;
3698
+ const shedPriority = (f) => SEVERITIES.length - 1 - SEVERITIES.indexOf(f.severity);
3699
+ const shedOrderOf = (list) => list.map((f, index) => ({ f, index })).sort((a, b) => shedPriority(a.f) - shedPriority(b.f) || a.index - b.index).map((entry) => entry.f);
3700
+ const sizeSticky = (renderAt, total, reserve = SHED_RESERVE) => {
3701
+ const target = STICKY_CHAR_LIMIT - reserve - SHED_NOTE_BUDGET;
3702
+ const base = renderAt(0);
3703
+ if (base.length <= target) return { body: base, shedCount: 0, terminal: false };
3704
+ const allShed = renderAt(total);
3705
+ if (allShed.length > STICKY_CHAR_LIMIT - SHED_NOTE_BUDGET)
3706
+ return { body: "", shedCount: total, terminal: true };
3707
+ let low = 0;
3708
+ let high = total;
3709
+ let fitted = "";
3710
+ while (low < high) {
3711
+ const mid = low + high >> 1;
3712
+ const body = renderAt(mid);
3713
+ if (body.length <= target) {
3714
+ high = mid;
3715
+ fitted = body;
3716
+ } else {
3717
+ low = mid + 1;
3718
+ }
3719
+ }
3720
+ return { body: fitted !== "" ? fitted : allShed, shedCount: low, terminal: false };
3721
+ };
3722
+ const minimalTerminalBody = (findingsPointer2, route, incomplete, summaryAvailable2, modelNames2) => [
3723
+ DEFAULT_MARKER,
3724
+ `<!-- reviewed-sha: ${input.headSha} -->`,
3725
+ ...!incomplete && route !== void 0 ? [`<!-- reviewed-route: ${route.replace(/--/g, "~~").replace(/\s+/g, " ")} -->`] : [],
3726
+ ...!incomplete ? [REVIEW_COMPLETE_MARKER] : [],
3727
+ // The pointer's link form already begins with the directive; the convergence-only marker
3728
+ // does not, and a terminal surface must carry the directive either way.
3729
+ ...findingsPointer2 !== "" ? [
3730
+ findingsPointer2.startsWith(AGENTS_STOP_DIRECTIVE) ? findingsPointer2 : `${AGENTS_STOP_DIRECTIVE}
3731
+ ${findingsPointer2}`
3732
+ ] : [],
3733
+ "",
3734
+ terminalNotice({
3735
+ reviewedSha: input.headSha,
3736
+ verdictBadge: verdictBadge(loadedFindings.verdict),
3737
+ route: route ?? void 0,
3738
+ postedAt: input.postedAt,
3739
+ roundsSummary: "",
3740
+ convergenceSummary: "",
3741
+ inlinePosted: 0,
3742
+ runUrl: input.runUrl,
3743
+ summaryAvailable: summaryAvailable2,
3744
+ findingsDocLinked: hasFindingsMarker(findingsPointer2),
3745
+ thisRunArtifact: input.jsonUrl !== void 0,
3746
+ modelNames: modelNames2
3747
+ })
3748
+ ].join("\n");
3749
+ const terminalOrMinimal = (terminalRender, findingsPointer2, route, incomplete, summaryAvailable2, modelNames2) => terminalRender.length <= STICKY_CHAR_LIMIT ? terminalRender : minimalTerminalBody(findingsPointer2, route, incomplete, summaryAvailable2, modelNames2);
3750
+ const summaryAvailable = (process.env["GITHUB_STEP_SUMMARY"] ?? "") !== "";
3751
+ const modelNames = envelope !== null && envelope.models.length > 0 ? envelope.models.map((m) => m.model).join(", ") : "";
3752
+ const withShedNote = (body, shedCount, findingsPointer2) => {
3753
+ if (shedCount === 0) return body;
3754
+ const note = shedNote(
3755
+ shedCount,
3756
+ summaryAvailable,
3757
+ input.runUrl,
3758
+ hasFindingsMarker(findingsPointer2),
3759
+ input.jsonUrl !== void 0
3760
+ );
3761
+ return body.includes(note) ? body : `${body}
3762
+
3763
+ ${note}
3764
+ `;
3765
+ };
3605
3766
  if (envelope === null) {
3606
3767
  const envelopelessIncomplete = isIncompleteFindings(findings);
3607
3768
  if (wouldBuryCompleted(envelopelessIncomplete)) {
@@ -3617,51 +3778,75 @@ ${dropNote}` : ""}`,
3617
3778
  input.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD
3618
3779
  ) : priorConv
3619
3780
  );
3620
- const body = formatMarkdown(
3781
+ const lostInput = {
3782
+ findings: stampedFindings2,
3783
+ envelope: null,
3784
+ incomplete: envelopelessIncomplete,
3785
+ prices: decodedPrices.right,
3786
+ pricesProvided: input.pricesProvided,
3787
+ template,
3788
+ route: effectiveRoute,
3789
+ reviewedSha: input.headSha,
3790
+ repo: input.headRepo || input.repo,
3791
+ effort: input.effort,
3792
+ sameRootNotes: {},
3793
+ // The answered-state honesty rules apply on EVERY surface that renders the filtered
3794
+ // findings — the lost-envelope branch lists every VISIBLE finding (no inline review exists to
3795
+ // carry them) so the kept re-raises' annotations actually render, and names the drops
3796
+ // exactly like the main path (issues #151 review r1 + r2). The nit visibility floor applies
3797
+ // here too (issue #164): below-floor nits are hidden from the human list and shown only in the
3798
+ // collapsed aside — the floor is a human-visibility policy, not an inline-comment policy, so it
3799
+ // must hold on the surface that lists findings without an inline review.
3800
+ suppressedNits,
3801
+ nitVisibilityFloor: input.nitVisibilityFloor,
3802
+ answeredNotes: reRaisedNotes,
3803
+ answeredReRaiseNote: answeredDropNote,
3804
+ roundCount: priorRoundCount,
3805
+ convergenceRound: false,
3806
+ testReport,
3807
+ clocDiff,
3808
+ runUrl: input.runUrl,
3809
+ unverifiedNoLogs: input.unverifiedNoLogs,
3810
+ jsonUrl: input.jsonUrl,
3811
+ findingsPointer: findingsBlob(stampedFindings2),
3812
+ postedAt: input.postedAt,
3813
+ summaryAvailable,
3814
+ discussionByFinding,
3815
+ orphanedDiscussion,
3816
+ orphanedTotal,
3817
+ discussionTruncated,
3818
+ orphanedTruncated,
3819
+ orphanedUnresolvable: orphanResolveFailed
3820
+ };
3821
+ const lostRender = (opts) => formatMarkdown(
3621
3822
  render({
3622
- findings: stampedFindings2,
3623
- envelope: null,
3624
- incomplete: envelopelessIncomplete,
3625
- prices: decodedPrices.right,
3626
- pricesProvided: input.pricesProvided,
3627
- template,
3628
- route: effectiveRoute,
3629
- reviewedSha: input.headSha,
3630
- repo: input.headRepo || input.repo,
3631
- effort: input.effort,
3632
- sameRootNotes: {},
3633
- // The answered-state honesty rules apply on EVERY surface that renders the filtered
3634
- // findings — the lost-envelope branch lists every VISIBLE finding (no inline review exists to
3635
- // carry them) so the kept re-raises' annotations actually render, and names the drops
3636
- // exactly like the main path (issues #151 review r1 + r2). The nit visibility floor applies
3637
- // here too (issue #164): below-floor nits are hidden from the human list and shown only in the
3638
- // collapsed aside — the floor is a human-visibility policy, not an inline-comment policy, so it
3639
- // must hold on the surface that lists findings without an inline review.
3640
- strays: visibleFindings,
3641
- suppressedNits,
3642
- nitVisibilityFloor: input.nitVisibilityFloor,
3643
- answeredNotes: reRaisedNotes,
3644
- answeredReRaiseNote: answeredDropNote,
3645
- roundCount: priorRoundCount,
3646
- convergenceRound: false,
3647
- testReport,
3648
- clocDiff,
3649
- inlineDisposition: inlineRequested ? { kind: "no-envelope" } : { kind: "disabled" },
3650
- runUrl: input.runUrl,
3651
- unverifiedNoLogs: input.unverifiedNoLogs,
3652
- jsonUrl: input.jsonUrl,
3653
- findingsPointer: findingsBlob(stampedFindings2),
3654
- postedAt: input.postedAt,
3655
- discussionByFinding,
3656
- orphanedDiscussion,
3657
- orphanedTotal,
3658
- discussionTruncated,
3659
- orphanedTruncated,
3660
- orphanedUnresolvable: orphanResolveFailed
3823
+ ...lostInput,
3824
+ ...opts,
3825
+ inlineDisposition: inlineRequested ? { kind: "no-envelope" } : { kind: "disabled" }
3661
3826
  })
3662
3827
  );
3828
+ const lostShedOrder = shedOrderOf(visibleFindings);
3829
+ const lostRenderAt = (shedCount) => shedCount === 0 ? lostRender({ strays: visibleFindings }) : (() => {
3830
+ const dropped = new Set(lostShedOrder.slice(0, shedCount));
3831
+ return lostRender({
3832
+ strays: visibleFindings.filter((f) => !dropped.has(f)),
3833
+ shedCount
3834
+ });
3835
+ })();
3836
+ const lostSized = sizeSticky(lostRenderAt, visibleFindings.length, 0);
3837
+ const body = lostSized.terminal ? terminalOrMinimal(
3838
+ lostRender({ strays: visibleFindings, terminal: true }),
3839
+ findingsBlob(stampedFindings2),
3840
+ effectiveRoute,
3841
+ envelopelessIncomplete,
3842
+ summaryAvailable,
3843
+ ""
3844
+ ) : withShedNote(lostSized.body, lostSized.shedCount, findingsBlob(stampedFindings2));
3663
3845
  await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi, true);
3664
- appendRunSummary(process.env["GITHUB_STEP_SUMMARY"], () => body);
3846
+ appendRunSummary(
3847
+ process.env["GITHUB_STEP_SUMMARY"],
3848
+ () => lostSized.shedCount === 0 ? lostSized.body : lostRender({ strays: visibleFindings })
3849
+ );
3665
3850
  if (inlineRequested) {
3666
3851
  process.stderr.write(
3667
3852
  "Warning: inline: true was requested, but the result envelope is missing \u2014 inline comments cannot be built; the findings are in the sticky and the run summary instead\n"
@@ -3680,6 +3865,11 @@ ${dropNote}` : ""}`,
3680
3865
  }
3681
3866
  if (emptyMechanicWouldBury(effectiveRoute, thisIncomplete) && existingSticky !== null)
3682
3867
  await emptyMechanicLeaveOrNote(existingSticky);
3868
+ if (!input.jsonUrl && existingSticky !== null && parseReviewComplete(existingSticky.body) && parseReviewedRoute(existingSticky.body) === "full review" && hasFindingsMarker(existingSticky.body)) {
3869
+ leaveInPlace(
3870
+ "no --json-url was supplied and the existing sticky still carries the prior findings document's marker (embedded or link) \u2014 leaving it in place rather than severing the seed chain\n"
3871
+ );
3872
+ }
3683
3873
  const isRound = isConvergenceRound(effectiveRoute, thisIncomplete) && isReviewVerdict(findings.verdict);
3684
3874
  const sameRootNotes = isRound ? computeSameRootNotes(priorTraj, findings.findings, input.headSha.slice(0, 12)) : {};
3685
3875
  const {
@@ -3757,7 +3947,8 @@ ${dropNote}` : ""}`,
3757
3947
  jsonUrl: input.jsonUrl,
3758
3948
  findingsPointer: findingsMarker,
3759
3949
  postedAt: input.postedAt,
3760
- pricedAt: input.pricedAt
3950
+ pricedAt: input.pricedAt,
3951
+ summaryAvailable
3761
3952
  };
3762
3953
  const longFilesNote = longFiles.length > 0 ? `
3763
3954
 
@@ -3765,27 +3956,56 @@ ${dropNote}` : ""}`,
3765
3956
 
3766
3957
  > **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.
3767
3958
  ` : "";
3768
- const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2, unanchoredStrays, discussionOverride) => formatMarkdown(
3959
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2, unanchoredStrays, discussionOverride, terminal = false, shedCount = 0) => formatMarkdown(
3769
3960
  render({
3770
3961
  ...commonRenderInput,
3771
3962
  ...straysOverride ? { strays: straysOverride } : {},
3772
3963
  ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
3773
3964
  ...unanchoredStrays !== void 0 && unanchoredStrays.length > 0 ? { unanchoredStrays } : {},
3774
3965
  ...discussionOverride ?? {},
3966
+ ...terminal ? { terminal } : {},
3967
+ ...shedCount > 0 ? { shedCount } : {},
3775
3968
  inlineDisposition,
3776
3969
  reviewUrl: reviewUrl2
3777
3970
  }) + longFilesNote
3778
3971
  );
3779
- if (!input.jsonUrl && existingSticky !== null && parseReviewComplete(existingSticky.body) && parseReviewedRoute(existingSticky.body) === "full review" && hasFindingsMarker(existingSticky.body)) {
3780
- leaveInPlace(
3781
- "no --json-url was supplied and the existing sticky still carries the prior findings document's marker (embedded or link) \u2014 leaving it in place rather than severing the seed chain\n"
3782
- );
3783
- }
3972
+ const mainShedOrder = shedOrderOf(strays);
3973
+ const mainRenderAt = (shedCount) => shedCount === 0 ? renderBody(initialDisposition) : renderBody(
3974
+ initialDisposition,
3975
+ void 0,
3976
+ strays.filter((f) => !new Set(mainShedOrder.slice(0, shedCount)).has(f)),
3977
+ void 0,
3978
+ void 0,
3979
+ void 0,
3980
+ false,
3981
+ shedCount
3982
+ );
3983
+ const mainSized = sizeSticky(
3984
+ mainRenderAt,
3985
+ strays.length,
3986
+ inlineRequested && rawComments.length > 0 ? SHED_RESERVE : 0
3987
+ );
3988
+ const fitStickyBody = () => mainSized.terminal ? terminalOrMinimal(
3989
+ renderBody(
3990
+ initialDisposition,
3991
+ void 0,
3992
+ void 0,
3993
+ void 0,
3994
+ void 0,
3995
+ void 0,
3996
+ true
3997
+ ),
3998
+ findingsMarker,
3999
+ effectiveRoute,
4000
+ thisIncomplete,
4001
+ summaryAvailable,
4002
+ modelNames
4003
+ ) : withShedNote(mainSized.body, mainSized.shedCount, findingsMarker);
3784
4004
  const stickyRef = await upsertSticky(
3785
4005
  input.repo,
3786
4006
  prNumber,
3787
4007
  existingSticky,
3788
- renderBody(initialDisposition),
4008
+ fitStickyBody(),
3789
4009
  ghApi,
3790
4010
  true
3791
4011
  );
@@ -3827,7 +4047,6 @@ ${dropNote}` : ""}`,
3827
4047
  await minimizeComments(prNumber, priorInlineComments, ghApi);
3828
4048
  }
3829
4049
  const unanchoredCount = unposted.length;
3830
- const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
3831
4050
  const finalDiscussion = unanchoredCount > 0 ? buildStickyDiscussion(
3832
4051
  reachable,
3833
4052
  [...currentIds, ...unposted.map((f) => f.id).filter((id) => id !== "")],
@@ -3842,29 +4061,75 @@ ${dropNote}` : ""}`,
3842
4061
  } : void 0;
3843
4062
  if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
3844
4063
  const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
3845
- try {
3846
- await patchComment(
3847
- input.repo,
3848
- stickyRef.id,
3849
- renderBody(
4064
+ if (mainSized.terminal) {
4065
+ try {
4066
+ const terminalPatch = renderBody(
3850
4067
  finalDisposition,
3851
4068
  reviewUrl,
3852
- finalStrays,
4069
+ void 0,
3853
4070
  unanchoredCount,
3854
4071
  unposted,
3855
- finalDiscussionOverride
3856
- ),
3857
- ghApi
3858
- );
3859
- process.stderr.write(
3860
- `Updated sticky comment #${String(stickyRef.id)} to reflect the review
4072
+ finalDiscussionOverride,
4073
+ true
4074
+ );
4075
+ if (terminalPatch.length <= STICKY_CHAR_LIMIT) {
4076
+ await patchComment(input.repo, stickyRef.id, terminalPatch, ghApi);
4077
+ process.stderr.write(
4078
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
3861
4079
  `
3862
- );
3863
- } catch (err) {
3864
- process.stderr.write(
3865
- `Warning: failed to update the sticky summary after the review: ${errMsg(err)}
4080
+ );
4081
+ } else {
4082
+ process.stderr.write(
4083
+ `Warning: the terminal sticky patch exceeds GitHub's comment limit \u2014 the sticky stands; the run summary carries the review
3866
4084
  `
4085
+ );
4086
+ }
4087
+ } catch (err) {
4088
+ process.stderr.write(
4089
+ `Warning: failed to update the terminal sticky after the review: ${errMsg(err)}
4090
+ `
4091
+ );
4092
+ }
4093
+ } else {
4094
+ const patchShedOrder = shedOrderOf(strays);
4095
+ const patchRenderAt = (shedCount) => renderBody(
4096
+ finalDisposition,
4097
+ reviewUrl,
4098
+ [
4099
+ ...unposted,
4100
+ ...shedCount === 0 ? strays : strays.filter((f) => !new Set(patchShedOrder.slice(0, shedCount)).has(f))
4101
+ ],
4102
+ unanchoredCount,
4103
+ unposted,
4104
+ finalDiscussionOverride,
4105
+ false,
4106
+ shedCount
3867
4107
  );
4108
+ const patchSized = sizeSticky(patchRenderAt, strays.length, 0);
4109
+ try {
4110
+ if (patchSized.terminal) {
4111
+ process.stderr.write(
4112
+ `Warning: the final sticky patch still exceeds GitHub's comment limit \u2014 the sticky stands as posted${summaryAvailable ? "; the whole review (rejected findings included) is in the run summary" : ""}
4113
+ `
4114
+ );
4115
+ } else {
4116
+ await patchComment(
4117
+ input.repo,
4118
+ stickyRef.id,
4119
+ withShedNote(patchSized.body, patchSized.shedCount, findingsMarker),
4120
+ ghApi
4121
+ );
4122
+ process.stderr.write(
4123
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
4124
+ `
4125
+ );
4126
+ }
4127
+ } catch (err) {
4128
+ process.stderr.write(
4129
+ `Warning: failed to update the sticky summary after the review: ${errMsg(err)}
4130
+ `
4131
+ );
4132
+ }
3868
4133
  }
3869
4134
  }
3870
4135
  appendRunSummary(
@@ -4633,7 +4898,7 @@ var gateCandidate = (kind, rawCandidate) => {
4633
4898
  const schemaPath = safeSchemaPathFor(kind, candidateVersion(kind, candidate));
4634
4899
  if (schemaPath === null) return null;
4635
4900
  if (!validateAgainstSchema(candidate, schemaPath).valid) return null;
4636
- const resolution = resolve(kind, candidate);
4901
+ const resolution = resolve2(kind, candidate);
4637
4902
  return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
4638
4903
  };
4639
4904
  var candidateFromJsonText = (kind, text) => {
@@ -4756,13 +5021,6 @@ var ClaudeCodeEnvelopeCodec = t.intersection([
4756
5021
  structured_output: t.unknown
4757
5022
  })
4758
5023
  ]);
4759
- var sumUsage = (into, entry) => ({
4760
- ...into,
4761
- input_tokens: into.input_tokens + entry.input_tokens,
4762
- output_tokens: into.output_tokens + entry.output_tokens,
4763
- ...into.cache_read_tokens !== void 0 || entry.cache_read_tokens !== void 0 ? { cache_read_tokens: (into.cache_read_tokens ?? 0) + (entry.cache_read_tokens ?? 0) } : {},
4764
- ...into.cache_write_tokens !== void 0 || entry.cache_write_tokens !== void 0 ? { cache_write_tokens: (into.cache_write_tokens ?? 0) + (entry.cache_write_tokens ?? 0) } : {}
4765
- });
4766
5024
  var mapModelUsage = (modelUsage) => [
4767
5025
  ...Object.entries(modelUsage).reduce((byModel, [configuredModelId, entry]) => {
4768
5026
  const model = modelIdentity(configuredModelId);
@@ -4774,7 +5032,7 @@ var mapModelUsage = (modelUsage) => [
4774
5032
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
4775
5033
  };
4776
5034
  const prior = byModel.get(model);
4777
- return byModel.set(model, prior === void 0 ? mapped : sumUsage(prior, mapped));
5035
+ return byModel.set(model, prior === void 0 ? mapped : addModelUsage(prior, mapped));
4778
5036
  }, /* @__PURE__ */ new Map()).values()
4779
5037
  ];
4780
5038
  var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
@@ -4786,7 +5044,7 @@ var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
4786
5044
  });
4787
5045
  if (ladder.kind !== "ok")
4788
5046
  return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
4789
- const resolution = resolve("findings", ladder.candidate);
5047
+ const resolution = resolve2("findings", ladder.candidate);
4790
5048
  return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
4791
5049
  kind: "telemetry-only",
4792
5050
  reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
@@ -4945,7 +5203,7 @@ var parseScope = (raw) => {
4945
5203
  var resolvedPriorValue = resolveTolerantFindings;
4946
5204
  var readJSON = (path) => {
4947
5205
  try {
4948
- return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
5206
+ return JSON.parse(readFileSync(resolve(path), "utf-8"));
4949
5207
  } catch (err) {
4950
5208
  return fail(`Cannot read ${path}: ${errMsg(err)}`);
4951
5209
  }
@@ -4958,7 +5216,7 @@ var fail = (msg) => {
4958
5216
  var readJSONOrAbsent = (path) => {
4959
5217
  const read = (() => {
4960
5218
  try {
4961
- return { text: readFileSync(resolve$1(path), "utf-8") };
5219
+ return { text: readFileSync(resolve(path), "utf-8") };
4962
5220
  } catch (err) {
4963
5221
  return { error: errMsg(err) };
4964
5222
  }
@@ -4988,7 +5246,7 @@ var readJSONOrAbsent = (path) => {
4988
5246
  }
4989
5247
  };
4990
5248
  var readSandboxConfigForNotice = (path) => {
4991
- const text = readFileOrNull(resolve$1(path));
5249
+ const text = readFileOrNull(resolve(path));
4992
5250
  if (text === null) return void 0;
4993
5251
  const parsed = tryParseJson(text);
4994
5252
  if (!parsed.ok) {
@@ -5029,7 +5287,7 @@ var unwrapAdapt = (either) => {
5029
5287
  }
5030
5288
  };
5031
5289
  var transcriptFallbackFrom = (path) => {
5032
- const tree = readTranscriptTree(resolve$1(path));
5290
+ const tree = readTranscriptTree(resolve(path));
5033
5291
  if (tree.missing)
5034
5292
  process.stderr.write(
5035
5293
  `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
@@ -5038,13 +5296,13 @@ var transcriptFallbackFrom = (path) => {
5038
5296
  const usage = sumTranscriptUsage(tree.entries);
5039
5297
  return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
5040
5298
  };
5041
- var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
5299
+ var bundledPath = (...segments) => resolve(import.meta.dirname, "..", ...segments);
5042
5300
  var readPackageVersion = () => JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
5043
5301
  var packageVersion = readPackageVersion();
5044
- var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
5045
- var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
5302
+ var resolveTemplatePath = (templateArg) => templateArg ? resolve(templateArg) : bundledPath("templates", "comment.eta");
5303
+ var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve(templateArg) : bundledPath("templates", "inline.eta");
5046
5304
  var resolvePrices = (pricesArg) => {
5047
- if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
5305
+ if (pricesArg) return { kind: "provided", path: resolve(pricesArg) };
5048
5306
  process.stderr.write(
5049
5307
  "code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
5050
5308
  );
@@ -5174,7 +5432,7 @@ var inlineCmd = defineCommand({
5174
5432
  },
5175
5433
  run: async ({ args }) => {
5176
5434
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
5177
- const diff = readFileSync(resolve$1(args.diff), "utf-8");
5435
+ const diff = readFileSync(resolve(args.diff), "utf-8");
5178
5436
  const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
5179
5437
  const floor = parseNitVisibilityFloor(args["nit-visibility-floor"]);
5180
5438
  const visibleFindings = findings.findings.filter((f) => !isBelowVisibilityFloor(f, floor));
@@ -5211,6 +5469,8 @@ var costCmd = defineCommand({
5211
5469
  const report = computeCost(
5212
5470
  envelope.models,
5213
5471
  prices,
5472
+ // Price a saved envelope at the RUN's own instant (issue #170), so re-running `cost` later prices
5473
+ // the same envelope to the same slot deterministically — not at whatever wall clock it is re-run at.
5214
5474
  parseInstant(envelope.generated_at) ?? /* @__PURE__ */ new Date()
5215
5475
  );
5216
5476
  process.stdout.write(JSON.stringify(report, null, 2));
@@ -5233,7 +5493,7 @@ var checkCostCmd = defineCommand({
5233
5493
  }
5234
5494
  },
5235
5495
  run: async ({ args }) => {
5236
- const tree = readTranscriptTree(resolve$1(args.transcript));
5496
+ const tree = readTranscriptTree(resolve(args.transcript));
5237
5497
  if (tree.missing) {
5238
5498
  process.stderr.write(
5239
5499
  `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
@@ -5267,7 +5527,7 @@ var checkCostCmd = defineCommand({
5267
5527
  });
5268
5528
  var tryReadPrices = (path) => {
5269
5529
  try {
5270
- const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
5530
+ const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve(path), "utf-8")));
5271
5531
  return decoded._tag === "Right" ? decoded.right : null;
5272
5532
  } catch {
5273
5533
  return null;
@@ -5386,10 +5646,10 @@ var budgetHookCmd = defineCommand({
5386
5646
  },
5387
5647
  run: async ({ args }) => {
5388
5648
  try {
5389
- const draftPath = resolve$1(args.draft);
5649
+ const draftPath = resolve(args.draft);
5390
5650
  const input = readStdinJSON();
5391
5651
  const transcriptPath = transcriptPathOf(input);
5392
- const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
5652
+ const tree = transcriptPath ? readTranscriptTree(resolve(transcriptPath)) : void 0;
5393
5653
  const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
5394
5654
  const prices = args.prices ? tryReadPrices(args.prices) : null;
5395
5655
  const costReport = prices !== null && usage ? (
@@ -5518,7 +5778,7 @@ var printSettingsCmd = defineCommand({
5518
5778
  if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
5519
5779
  fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
5520
5780
  const settings = composeReviewSettings({
5521
- draftPath: resolve$1(args.draft),
5781
+ draftPath: resolve(args.draft),
5522
5782
  stop: {
5523
5783
  kind: args.kind,
5524
5784
  schema: args.schema,
@@ -5610,7 +5870,7 @@ var validateCmd = defineCommand({
5610
5870
  run: async ({ args }) => {
5611
5871
  const kind = requireSchemaKind(args.kind || "findings");
5612
5872
  const documentRaw = readJSON(args.document);
5613
- const schemaPath = args.schema ? resolve$1(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
5873
+ const schemaPath = args.schema ? resolve(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
5614
5874
  const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
5615
5875
  if (valid) {
5616
5876
  process.stdout.write("\u2705 valid\n");
@@ -5677,7 +5937,7 @@ var seedDraftCmd = defineCommand({
5677
5937
  }
5678
5938
  },
5679
5939
  run: async ({ args }) => {
5680
- const outPath = resolve$1(args.out);
5940
+ const outPath = resolve(args.out);
5681
5941
  const kindArg = args.kind || "findings";
5682
5942
  const kind = isSchemaKind(kindArg) ? kindArg : "findings";
5683
5943
  if (kind !== kindArg) {
@@ -5706,7 +5966,7 @@ var seedDraftCmd = defineCommand({
5706
5966
  if (!args.prior) return null;
5707
5967
  const raw = (() => {
5708
5968
  try {
5709
- return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
5969
+ return JSON.parse(readFileSync(resolve(args.prior), "utf-8"));
5710
5970
  } catch {
5711
5971
  return null;
5712
5972
  }
@@ -5716,7 +5976,7 @@ var seedDraftCmd = defineCommand({
5716
5976
  const stagedPrior = (() => {
5717
5977
  if (!args["prior-findings"]) return null;
5718
5978
  try {
5719
- return JSON.parse(readFileSync(resolve$1(args["prior-findings"]), "utf-8"));
5979
+ return JSON.parse(readFileSync(resolve(args["prior-findings"]), "utf-8"));
5720
5980
  } catch {
5721
5981
  return null;
5722
5982
  }
@@ -5728,7 +5988,7 @@ var seedDraftCmd = defineCommand({
5728
5988
  const answeredRegistry = (() => {
5729
5989
  if (!args["prior-answers"]) return null;
5730
5990
  try {
5731
- const raw = JSON.parse(readFileSync(resolve$1(args["prior-answers"]), "utf-8"));
5991
+ const raw = JSON.parse(readFileSync(resolve(args["prior-answers"]), "utf-8"));
5732
5992
  if (!Array.isArray(raw)) throw new Error("expected an array");
5733
5993
  const decoded = raw.flatMap((row) => {
5734
5994
  const entry = decodeAnsweredEntry(row);
@@ -5797,7 +6057,7 @@ var seedDraftCmd = defineCommand({
5797
6057
  }
5798
6058
  const seededFromPrior = priorFindings === null ? false : (() => {
5799
6059
  try {
5800
- const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
6060
+ const schemaPath = args.schema ? resolve(args.schema) : schemaPathFor(kind, args["schema-version"]);
5801
6061
  const barePrior = typeof priorFindings === "object" && !Array.isArray(priorFindings) ? Object.fromEntries(
5802
6062
  Object.entries(priorFindings).filter(
5803
6063
  ([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2)
@@ -6014,7 +6274,7 @@ var readFileLines = (path) => {
6014
6274
  };
6015
6275
  var validateFinding = (finding, repoRoot) => {
6016
6276
  if (finding.patch === void 0) return finding;
6017
- const lines = readFileLines(resolve$1(repoRoot, finding.path));
6277
+ const lines = readFileLines(resolve(repoRoot, finding.path));
6018
6278
  if (lines === null) {
6019
6279
  process.stderr.write(
6020
6280
  `validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
@@ -6054,7 +6314,7 @@ var validatePatchesCmd = defineCommand({
6054
6314
  },
6055
6315
  run: async ({ args }) => {
6056
6316
  const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
6057
- const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
6317
+ const repoRoot = args["repo-root"] ? resolve(args["repo-root"]) : process.cwd();
6058
6318
  const validated = {
6059
6319
  ...findings,
6060
6320
  findings: findings.findings.map((f) => validateFinding(f, repoRoot))
@@ -6168,7 +6428,7 @@ var stopGateCmd = defineCommand({
6168
6428
  }
6169
6429
  },
6170
6430
  run: async ({ args }) => {
6171
- const draftPath = resolve$1(args.draft);
6431
+ const draftPath = resolve(args.draft);
6172
6432
  if (args["print-settings"]) {
6173
6433
  const command = defaultHookCommand(draftPath, {
6174
6434
  kind: args.kind,
@@ -6184,10 +6444,10 @@ var stopGateCmd = defineCommand({
6184
6444
  drainStdin();
6185
6445
  const kind = requireSchemaKind(args.kind || "findings");
6186
6446
  const maxNudges = requireMaxNudges(args["max-nudges"]);
6187
- const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
6447
+ const counterPath = args.counter ? resolve(args.counter) : `${draftPath}.nudges`;
6188
6448
  const state = draftState(
6189
6449
  draftPath,
6190
- (parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
6450
+ (parsed) => args.schema ? resolve(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
6191
6451
  );
6192
6452
  const nudges = readNudges(counterPath);
6193
6453
  const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
@@ -6255,7 +6515,7 @@ var gatherCmd = defineCommand({
6255
6515
  runId: args["run-id"],
6256
6516
  conclusion: args.conclusion,
6257
6517
  botLogin: args["bot-login"] || "github-actions[bot]",
6258
- outDir: args["out-dir"] ? resolve$1(args["out-dir"]) : process.cwd()
6518
+ outDir: args["out-dir"] ? resolve(args["out-dir"]) : process.cwd()
6259
6519
  });
6260
6520
  process.stdout.write(renderOutputs(result));
6261
6521
  }
@@ -6349,6 +6609,11 @@ var postCmd = defineCommand({
6349
6609
  },
6350
6610
  run: async ({ args }) => {
6351
6611
  const priceResolution = resolvePrices(args.prices);
6612
+ if (/--|\s/.test(args["head-sha"])) {
6613
+ throw new Error(
6614
+ '--head-sha must be a commit SHA (no "--" or whitespace): ' + args["head-sha"]
6615
+ );
6616
+ }
6352
6617
  await post({
6353
6618
  repo: args.repo,
6354
6619
  // The workflow's post step threads HEAD_REPO env (the fork's owner/name) — a finding
@@ -6749,7 +7014,7 @@ var sandboxConfigCmd = defineCommand({
6749
7014
  const json = `${JSON.stringify(config, null, 2)}
6750
7015
  `;
6751
7016
  if (args.out) {
6752
- writeFileSync(resolve$1(args.out), json);
7017
+ writeFileSync(resolve(args.out), json);
6753
7018
  } else {
6754
7019
  process.stdout.write(json);
6755
7020
  }