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

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
@@ -178,6 +178,16 @@ ${marker}` : "";
178
178
  };
179
179
  var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
180
180
  var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
181
+ var parseFindingsMarker = (body) => {
182
+ const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
183
+ const b64 = match?.[1];
184
+ if (b64 === void 0) return null;
185
+ try {
186
+ return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
187
+ } catch {
188
+ return null;
189
+ }
190
+ };
181
191
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
182
192
  var projectPatch = (patch) => {
183
193
  if (patch === void 0) return { kind: "none" };
@@ -234,6 +244,7 @@ var render = (input) => {
234
244
  postedAt: input.postedAt ?? "",
235
245
  severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
236
246
  strays: (input.strays ?? []).map(sanitizeFinding),
247
+ unanchoredCount: input.unanchoredCount ?? 0,
237
248
  inlineDisposition: input.inlineDisposition ?? null,
238
249
  runUrl: input.runUrl ?? null,
239
250
  jsonUrl: input.jsonUrl ?? null,
@@ -355,7 +366,7 @@ var buildInlineComments = (findings, diff, context) => {
355
366
  }
356
367
  return comment;
357
368
  });
358
- return { comments, strays };
369
+ return { comments, strays, inDiff };
359
370
  };
360
371
  var renderStraysSection = (strays) => {
361
372
  if (strays.length === 0) return "";
@@ -660,7 +671,7 @@ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
660
671
  reason: [
661
672
  `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
662
673
  `The only deliverable is a ${kind} document that validates against the ${kind} schema \u2014 run "code-review print-schema ${kind}" to see the exact shape.`,
663
- `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
674
+ `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind} --explain" until it exits 0 before ending your turn (--explain prints the schema when the shape is wrong).`
664
675
  ].join("\n")
665
676
  };
666
677
  };
@@ -724,19 +735,26 @@ var stopHookSettings = (command) => ({
724
735
 
725
736
  // src/budget.ts
726
737
  var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
727
- var DEFAULT_RESERVE = { frac: 0.15, flatUsd: 0.02, flatMs: 12e4 };
738
+ var DEFAULT_RESERVE = {
739
+ frac: 0.15,
740
+ growth: 0.25,
741
+ flatUsd: 0.02,
742
+ flatMs: 12e4
743
+ };
728
744
  var SOFT_MULTIPLE = 2;
729
745
  var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
730
746
  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);
747
+ var axisSeverity = (a, reserve) => {
748
+ const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
749
+ const effFrac = reserve.frac + reserve.growth * usedFrac;
750
+ const hardReserve = Math.max(a.flat, effFrac * a.limit);
733
751
  const remaining = a.limit - a.used;
734
752
  if (remaining <= hardReserve) return 2;
735
753
  if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
736
754
  return 0;
737
755
  };
738
756
  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);
757
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
740
758
  return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
741
759
  };
742
760
  var pct = (n) => `${String(Math.round(n * 100))}%`;
@@ -744,7 +762,7 @@ var money = (n) => `$${n.toFixed(2)}`;
744
762
  var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
745
763
  var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.budgetUsd > 0 ? `spent ${money(i.spentUsd)}/${money(i.budgetUsd)} (${pct(i.spentUsd / i.budgetUsd)})` : `spent ${money(i.spentUsd)}`;
746
764
  var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wallMs > 0 ? `${mins(i.elapsedMs)}/${mins(i.wallMs)} elapsed (${pct(i.elapsedMs / i.wallMs)})` : `${mins(i.elapsedMs)} elapsed`;
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.`;
765
+ 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} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 you may run out of budget before you finish otherwise.`;
748
766
  var budgetMessage = (i, phase, draftPath) => {
749
767
  const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
750
768
  return `Budget check \u2014 ${status}. ${directive(phase, draftPath)}`;
@@ -838,6 +856,7 @@ var budgetHookCommand = (draftPath, opts) => [
838
856
  ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
839
857
  ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
840
858
  ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
859
+ ...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
841
860
  ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
842
861
  ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
843
862
  ].join(" ");
@@ -1081,25 +1100,50 @@ var parseHtmlUrl = (raw) => {
1081
1100
  return void 0;
1082
1101
  }
1083
1102
  };
1084
- var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
1103
+ var commentPayload = (c) => ({
1104
+ path: c.path,
1105
+ line: c.line,
1106
+ side: c.side,
1107
+ ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1108
+ body: formatMarkdown(c.body)
1109
+ });
1110
+ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
1085
1111
  const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
1086
- const body = JSON.stringify({
1112
+ const reviewBody = (withComments) => JSON.stringify({
1087
1113
  body: pointer,
1088
1114
  commit_id: headSha,
1089
1115
  event: "COMMENT",
1090
- comments: comments.map((c) => ({
1091
- path: c.path,
1092
- line: c.line,
1093
- side: c.side,
1094
- ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1095
- body: formatMarkdown(c.body)
1096
- }))
1116
+ comments: withComments ? comments.map(commentPayload) : []
1097
1117
  });
1098
- const stdout = await ghApi(
1099
- [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
1100
- body
1101
- );
1102
- return parseHtmlUrl(stdout);
1118
+ const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
1119
+ try {
1120
+ const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
1121
+ return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
1122
+ } catch (err) {
1123
+ if (comments.length === 0) throw err;
1124
+ process.stderr.write(
1125
+ `Warning: the batched inline review on PR #${String(prNumber)} was rejected (${err instanceof Error ? err.message : String(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
1126
+ `
1127
+ );
1128
+ const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
1129
+ const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
1130
+ const unposted = [];
1131
+ let inlinePosted = 0;
1132
+ for (const [i, c] of comments.entries()) {
1133
+ try {
1134
+ await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
1135
+ inlinePosted += 1;
1136
+ } catch (e) {
1137
+ const finding = inDiff[i];
1138
+ if (finding) unposted.push(finding);
1139
+ process.stderr.write(
1140
+ `Warning: inline comment on ${c.path}:${String(c.line)} rejected (${e instanceof Error ? e.message : String(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
1141
+ `
1142
+ );
1143
+ }
1144
+ }
1145
+ return { url, inlinePosted, unposted };
1146
+ }
1103
1147
  };
1104
1148
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
1105
1149
  const stdout = await ghApi(
@@ -1166,10 +1210,7 @@ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
1166
1210
  return [];
1167
1211
  }
1168
1212
  if (!Array.isArray(reviews)) return [];
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
- }));
1213
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
1173
1214
  };
1174
1215
  var dismissReviews = async (repo, prNumber, ids, ghApi) => {
1175
1216
  for (const id of ids) {
@@ -1192,16 +1233,15 @@ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
1192
1233
  }
1193
1234
  }
1194
1235
  };
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}}}}}}}}";
1236
+ 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}}}}}}}}";
1196
1237
  var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1197
- var supersededCommentId = (c, headSha, logins) => {
1238
+ var priorBotCommentId = (c, logins) => {
1198
1239
  if (typeof c !== "object" || c === null) return null;
1199
1240
  const o = c;
1200
1241
  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;
1242
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
1203
1243
  };
1204
- var supersededBotCommentIds = (raw, headSha, botLogin) => {
1244
+ var priorBotCommentIds = (raw, botLogin) => {
1205
1245
  let parsed;
1206
1246
  try {
1207
1247
  parsed = JSON.parse(raw);
@@ -1215,13 +1255,13 @@ var supersededBotCommentIds = (raw, headSha, botLogin) => {
1215
1255
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1216
1256
  const ids = nodes.flatMap((t4) => {
1217
1257
  const cnodes = t4.comments?.nodes;
1218
- return Array.isArray(cnodes) ? cnodes.map((c) => supersededCommentId(c, headSha, logins)).filter((id) => id !== null) : [];
1258
+ return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1219
1259
  });
1220
1260
  return { ids, truncated };
1221
1261
  };
1222
- var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi) => {
1262
+ var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
1223
1263
  const slash = repo.indexOf("/");
1224
- if (slash <= 0) return;
1264
+ if (slash <= 0) return [];
1225
1265
  const owner = repo.slice(0, slash);
1226
1266
  const name = repo.slice(slash + 1);
1227
1267
  let raw;
@@ -1242,15 +1282,18 @@ var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi
1242
1282
  `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1243
1283
  `
1244
1284
  );
1245
- return;
1285
+ return [];
1246
1286
  }
1247
- const { ids, truncated } = supersededBotCommentIds(raw, headSha, botLogin);
1287
+ const { ids, truncated } = priorBotCommentIds(raw, botLogin);
1248
1288
  if (truncated) {
1249
1289
  process.stderr.write(
1250
1290
  `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1251
1291
  `
1252
1292
  );
1253
1293
  }
1294
+ return ids;
1295
+ };
1296
+ var minimizeComments = async (prNumber, ids, ghApi) => {
1254
1297
  let minimized = 0;
1255
1298
  for (const id of ids) {
1256
1299
  try {
@@ -1365,7 +1408,11 @@ var post = async (input, ghApi = runGhApi) => {
1365
1408
  process.exit(0);
1366
1409
  }
1367
1410
  const findingsMarker = findingsPointer(findings, input.jsonUrl);
1368
- const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
1411
+ const {
1412
+ comments: rawComments,
1413
+ strays,
1414
+ inDiff
1415
+ } = buildInlineComments(findings.findings, diff, {
1369
1416
  inlineTemplate,
1370
1417
  models: envelope.models.map((m) => m.model),
1371
1418
  findings,
@@ -1379,8 +1426,7 @@ var post = async (input, ghApi = runGhApi) => {
1379
1426
  );
1380
1427
  }
1381
1428
  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;
1429
+ const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1384
1430
  const commonRenderInput = {
1385
1431
  findings,
1386
1432
  envelope,
@@ -1404,7 +1450,15 @@ var post = async (input, ghApi = runGhApi) => {
1404
1450
 
1405
1451
  > **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
1452
  ` : "";
1407
- const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
1453
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
1454
+ render({
1455
+ ...commonRenderInput,
1456
+ ...straysOverride ? { strays: straysOverride } : {},
1457
+ ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
1458
+ inlineDisposition,
1459
+ reviewUrl: reviewUrl2
1460
+ }) + longFilesNote
1461
+ );
1408
1462
  const stickyRef = await upsertSticky(
1409
1463
  input.repo,
1410
1464
  prNumber,
@@ -1412,49 +1466,53 @@ var post = async (input, ghApi = runGhApi) => {
1412
1466
  renderBody(initialDisposition),
1413
1467
  ghApi
1414
1468
  );
1415
- if (alreadyReviewedThisSha) {
1416
- process.stderr.write(
1417
- `A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new review
1418
- `
1419
- );
1420
- return;
1421
- }
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(
1469
+ const priorInlineComments = await listPriorBotCommentIds(
1470
+ input.repo,
1471
+ prNumber,
1472
+ input.botLogin,
1473
+ ghApi
1474
+ );
1475
+ const {
1476
+ url: reviewUrl,
1477
+ inlinePosted,
1478
+ unposted
1479
+ } = await postInlineReview(
1427
1480
  input.repo,
1428
1481
  prNumber,
1429
1482
  input.headSha,
1430
1483
  comments,
1484
+ inDiff,
1431
1485
  stickyRef?.url,
1432
1486
  findingsMarker,
1433
1487
  ghApi
1434
1488
  );
1435
1489
  process.stderr.write(
1436
- `Posted a review with ${String(comments.length)} inline comment(s) on PR #${String(prNumber)}
1490
+ `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
1437
1491
  `
1438
1492
  );
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
- };
1493
+ const priorReviewIds = botReviews.map((r) => r.id);
1494
+ if (priorReviewIds.length > 0) {
1495
+ await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
1496
+ }
1497
+ await minimizeComments(prNumber, priorInlineComments, ghApi);
1498
+ const unanchoredCount = unposted.length;
1499
+ const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
1500
+ if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
1501
+ const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
1446
1502
  try {
1447
1503
  await patchComment(
1448
1504
  input.repo,
1449
1505
  stickyRef.id,
1450
- renderBody(confirmedDisposition, reviewUrl),
1506
+ renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
1451
1507
  ghApi
1452
1508
  );
1453
- process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
1454
- `);
1509
+ process.stderr.write(
1510
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
1511
+ `
1512
+ );
1455
1513
  } catch (err) {
1456
1514
  process.stderr.write(
1457
- `Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
1515
+ `Warning: failed to update the sticky summary after the review: ${err instanceof Error ? err.message : String(err)}
1458
1516
  `
1459
1517
  );
1460
1518
  }
@@ -1792,18 +1850,18 @@ var withMeta = (base, meta) => ({
1792
1850
  ...meta.effort ? { effort: meta.effort } : {}
1793
1851
  });
1794
1852
  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;
1853
+ const fb = (() => {
1854
+ try {
1855
+ return meta.transcriptFallback?.();
1856
+ } catch {
1857
+ return void 0;
1858
+ }
1859
+ })();
1860
+ const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
1797
1861
  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,
1862
+ {
1863
+ models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
1864
+ ...wallTurns,
1807
1865
  vendor_cost_usd: native.vendorCostUsd
1808
1866
  },
1809
1867
  meta
@@ -2171,7 +2229,11 @@ var budgetHookCmd = defineCommand({
2171
2229
  },
2172
2230
  "reserve-frac": {
2173
2231
  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)"
2232
+ description: "Base wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
2233
+ },
2234
+ "reserve-growth": {
2235
+ type: "string",
2236
+ description: "How much the reserve grows as a budget is spent \u2014 added at full usage, so convergence lands earlier the longer the run has gone (default: 0.25; 0 = flat reserve)"
2175
2237
  },
2176
2238
  "reserve-usd": {
2177
2239
  type: "string",
@@ -2204,6 +2266,7 @@ var budgetHookCmd = defineCommand({
2204
2266
  wallMs,
2205
2267
  reserve: {
2206
2268
  frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
2269
+ growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
2207
2270
  flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
2208
2271
  flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
2209
2272
  },
@@ -2265,7 +2328,11 @@ var printSettingsCmd = defineCommand({
2265
2328
  },
2266
2329
  "reserve-frac": {
2267
2330
  type: "string",
2268
- description: "Wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2331
+ description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2332
+ },
2333
+ "reserve-growth": {
2334
+ type: "string",
2335
+ description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
2269
2336
  },
2270
2337
  "reserve-usd": {
2271
2338
  type: "string",
@@ -2293,6 +2360,7 @@ var printSettingsCmd = defineCommand({
2293
2360
  wall: args.wall,
2294
2361
  prices: args.prices,
2295
2362
  reserveFrac: args["reserve-frac"],
2363
+ reserveGrowth: args["reserve-growth"],
2296
2364
  reserveUsd: args["reserve-usd"],
2297
2365
  reserveWall: args["reserve-wall"]
2298
2366
  }
@@ -2324,6 +2392,13 @@ var deadlineCmd = defineCommand({
2324
2392
  }
2325
2393
  });
2326
2394
  var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
2395
+ var printableSchema = (schemaPath) => {
2396
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
2397
+ const enforcementSchema = Object.fromEntries(
2398
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
2399
+ );
2400
+ return JSON.stringify(enforcementSchema, null, 2);
2401
+ };
2327
2402
  var validateCmd = defineCommand({
2328
2403
  meta: {
2329
2404
  name: "validate",
@@ -2346,6 +2421,10 @@ var validateCmd = defineCommand({
2346
2421
  "schema-version": {
2347
2422
  type: "string",
2348
2423
  description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
2424
+ },
2425
+ explain: {
2426
+ type: "boolean",
2427
+ description: "On failure, also print the schema after the errors \u2014 its field descriptions are the authoritative spec, so the document can be fixed in one pass instead of by trial and error"
2349
2428
  }
2350
2429
  },
2351
2430
  run: async ({ args }) => {
@@ -2359,10 +2438,117 @@ var validateCmd = defineCommand({
2359
2438
  process.stderr.write("\u274C invalid\n");
2360
2439
  for (const e of errors) process.stderr.write(` - ${e}
2361
2440
  `);
2441
+ if (args.explain) {
2442
+ process.stderr.write(
2443
+ `
2444
+ The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
2445
+ ${printableSchema(schemaPath)}
2446
+ `
2447
+ );
2448
+ }
2362
2449
  process.exit(1);
2363
2450
  }
2364
2451
  }
2365
2452
  });
2453
+ var seedDraftCmd = defineCommand({
2454
+ meta: {
2455
+ name: "seed-draft",
2456
+ description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0 (issues #52, #53). Prints the mode chosen (prior|empty|none \u2014 none when even the scaffold write failed) to stdout; always exits 0"
2457
+ },
2458
+ args: {
2459
+ prior: {
2460
+ type: "string",
2461
+ description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
2462
+ },
2463
+ out: {
2464
+ type: "string",
2465
+ description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
2466
+ required: true
2467
+ },
2468
+ kind: {
2469
+ type: "string",
2470
+ description: "Schema kind to validate the prior findings against (default: findings)"
2471
+ },
2472
+ schema: {
2473
+ type: "string",
2474
+ description: "Path to a schema file (wins over --kind/--schema-version)"
2475
+ },
2476
+ "schema-version": {
2477
+ type: "string",
2478
+ description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then falls back to the empty scaffold)"
2479
+ }
2480
+ },
2481
+ run: async ({ args }) => {
2482
+ const outPath = resolve$1(args.out);
2483
+ const kindArg = args.kind || "findings";
2484
+ const kind = isSchemaKind(kindArg) ? kindArg : "findings";
2485
+ if (kind !== kindArg) {
2486
+ process.stderr.write(
2487
+ `Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
2488
+ `
2489
+ );
2490
+ }
2491
+ const writeEmptyScaffold = () => {
2492
+ try {
2493
+ writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
2494
+ `);
2495
+ process.stderr.write(
2496
+ `Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
2497
+ `
2498
+ );
2499
+ process.stdout.write("empty\n");
2500
+ } catch (err) {
2501
+ process.stderr.write(
2502
+ `Warning: could not write the seed scaffold to ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the agent will create $DRAFT itself
2503
+ `
2504
+ );
2505
+ process.stdout.write("none\n");
2506
+ }
2507
+ };
2508
+ const priorFindings = (() => {
2509
+ if (!args.prior) return null;
2510
+ const raw = (() => {
2511
+ try {
2512
+ return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
2513
+ } catch {
2514
+ return null;
2515
+ }
2516
+ })();
2517
+ const body = typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
2518
+ return body === null ? null : parseFindingsMarker(body);
2519
+ })();
2520
+ if (priorFindings === null) {
2521
+ writeEmptyScaffold();
2522
+ return;
2523
+ }
2524
+ const seededFromPrior = (() => {
2525
+ try {
2526
+ const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
2527
+ if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
2528
+ writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
2529
+ `);
2530
+ return true;
2531
+ } catch (err) {
2532
+ process.stderr.write(
2533
+ `Warning: could not seed from the prior review (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to the empty scaffold
2534
+ `
2535
+ );
2536
+ return false;
2537
+ }
2538
+ })();
2539
+ if (seededFromPrior) {
2540
+ const priorList = priorFindings.findings;
2541
+ const count = Array.isArray(priorList) ? priorList.length : 0;
2542
+ process.stderr.write(
2543
+ `Seeded ${outPath} from the prior review (${String(count)} finding(s)) \u2014 verify each still holds against the current diff and refine in place
2544
+ `
2545
+ );
2546
+ process.stdout.write("prior\n");
2547
+ } else {
2548
+ writeEmptyScaffold();
2549
+ }
2550
+ }
2551
+ });
2366
2552
  var adaptCmd = defineCommand({
2367
2553
  meta: {
2368
2554
  name: "adapt",
@@ -2393,7 +2579,7 @@ var adaptCmd = defineCommand({
2393
2579
  },
2394
2580
  transcript: {
2395
2581
  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)"
2582
+ description: "Path to the session transcript (the main .jsonl). Its tree (main + subagents) is the source of the true wall + turn count \u2014 the native envelope only sees the main agent and under-reports a fan-out (issue #59) \u2014 and refills per-model usage too when the native has none (a wall-clock kill leaves it empty, so cost is real not $0.00 \u2014 issues #39/#36)"
2397
2583
  }
2398
2584
  },
2399
2585
  run: async ({ args }) => {
@@ -2571,11 +2757,7 @@ var printSchemaCmd = defineCommand({
2571
2757
  run: async ({ args }) => {
2572
2758
  const schemaKind = requireSchemaKind(args.name);
2573
2759
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
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)}
2760
+ process.stdout.write(`${printableSchema(schemaPath)}
2579
2761
  `);
2580
2762
  }
2581
2763
  });
@@ -2811,7 +2993,7 @@ var main = defineCommand({
2811
2993
  meta: {
2812
2994
  name: "code-review",
2813
2995
  version: packageVersion,
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"
2996
+ description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, seed-draft, stop-gate, budget-hook, print-settings, and deadline"
2815
2997
  },
2816
2998
  subCommands: {
2817
2999
  gather: gatherCmd,
@@ -2821,6 +3003,7 @@ var main = defineCommand({
2821
3003
  cost: costCmd,
2822
3004
  "check-cost": checkCostCmd,
2823
3005
  validate: validateCmd,
3006
+ "seed-draft": seedDraftCmd,
2824
3007
  adapt: adaptCmd,
2825
3008
  extract: extractCmd,
2826
3009
  "validate-patches": validatePatchesCmd,