@jphutchins/code-review 0.1.0-alpha.12 → 0.1.0-alpha.14

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 "";
@@ -751,10 +762,14 @@ var money = (n) => `$${n.toFixed(2)}`;
751
762
  var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
752
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)}`;
753
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`;
754
- 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.`;
755
- var budgetMessage = (i, phase, draftPath) => {
765
+ var directive = (phase, draftPath, isSubagent) => {
766
+ if (isSubagent)
767
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
768
+ return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. 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.`;
769
+ };
770
+ var budgetMessage = (i, phase, draftPath, isSubagent) => {
756
771
  const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
757
- return `Budget check \u2014 ${status}. ${directive(phase, draftPath)}`;
772
+ return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
758
773
  };
759
774
  var invokesCodeReviewValidate = (toolInput) => {
760
775
  const cmd = asRecord(toolInput)?.["command"];
@@ -767,6 +782,35 @@ var blockedDuringConvergence = (toolName, toolInput) => {
767
782
  if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
768
783
  return false;
769
784
  };
785
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
786
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
787
+ var writesToDraft = (toolName, toolInput, draftPath) => {
788
+ const rec = asRecord(toolInput);
789
+ const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
790
+ if (WRITE_TOOLS.has(toolName)) {
791
+ const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
792
+ if (typeof fp === "string" && targets.some((t4) => fp === t4 || basename(fp) === basename(t4)))
793
+ return true;
794
+ }
795
+ if (toolName === "Bash") {
796
+ const cmd = rec?.["command"];
797
+ if (typeof cmd !== "string") return false;
798
+ const alt = targets.map(escapeRegExp).join("|");
799
+ const end = "(?=$|[\\s|&;)])";
800
+ const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
801
+ const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
802
+ return redirect.test(cmd) || teeArg.test(cmd);
803
+ }
804
+ return false;
805
+ };
806
+ var singleWriterMessage = (draftPath) => `Only the main agent may write ${draftPath}. When a subagent writes it too, the concurrent writers clobber each other and the review comes out empty. Do NOT write, edit, or redirect into ${draftPath} \u2014 instead, return the findings you discovered in your reply (the field names are in the schema); the main agent collects every subagent's reported findings and writes the draft itself.`;
807
+ var denyPreTool = (reason) => ({
808
+ hookSpecificOutput: {
809
+ hookEventName: "PreToolUse",
810
+ permissionDecision: "deny",
811
+ permissionDecisionReason: reason
812
+ }
813
+ });
770
814
  var evaluateBudgetHook = (input, params) => {
771
815
  const rec = asRecord(input);
772
816
  const inputs = {
@@ -777,25 +821,23 @@ var evaluateBudgetHook = (input, params) => {
777
821
  reserve: params.reserve
778
822
  };
779
823
  const phase = decideBudget(inputs);
824
+ const agentId = rec?.["agent_id"];
825
+ const isSubagent = typeof agentId === "string" && agentId.length > 0;
780
826
  switch (rec?.["hook_event_name"]) {
781
827
  case "PostToolBatch":
782
828
  return phase.kind === "ok" ? {} : {
783
829
  hookSpecificOutput: {
784
830
  hookEventName: "PostToolBatch",
785
- additionalContext: budgetMessage(inputs, phase, params.draftPath)
831
+ additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
786
832
  }
787
833
  };
788
834
  case "PreToolUse": {
789
- if (phase.kind !== "hard") return {};
790
835
  const toolName = rec["tool_name"];
836
+ if (isSubagent && typeof toolName === "string" && writesToDraft(toolName, rec["tool_input"], params.draftPath))
837
+ return denyPreTool(singleWriterMessage(params.draftPath));
838
+ if (phase.kind !== "hard") return {};
791
839
  if (typeof toolName === "string" && blockedDuringConvergence(toolName, rec["tool_input"]))
792
- return {
793
- hookSpecificOutput: {
794
- hookEventName: "PreToolUse",
795
- permissionDecision: "deny",
796
- permissionDecisionReason: budgetMessage(inputs, phase, params.draftPath)
797
- }
798
- };
840
+ return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
799
841
  return {};
800
842
  }
801
843
  default:
@@ -1089,25 +1131,50 @@ var parseHtmlUrl = (raw) => {
1089
1131
  return void 0;
1090
1132
  }
1091
1133
  };
1092
- var postInlineReview = async (repo, prNumber, headSha, comments, stickyUrl, marker, ghApi) => {
1134
+ var commentPayload = (c) => ({
1135
+ path: c.path,
1136
+ line: c.line,
1137
+ side: c.side,
1138
+ ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1139
+ body: formatMarkdown(c.body)
1140
+ });
1141
+ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
1093
1142
  const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
1094
- const body = JSON.stringify({
1143
+ const reviewBody = (withComments) => JSON.stringify({
1095
1144
  body: pointer,
1096
1145
  commit_id: headSha,
1097
1146
  event: "COMMENT",
1098
- comments: comments.map((c) => ({
1099
- path: c.path,
1100
- line: c.line,
1101
- side: c.side,
1102
- ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
1103
- body: formatMarkdown(c.body)
1104
- }))
1147
+ comments: withComments ? comments.map(commentPayload) : []
1105
1148
  });
1106
- const stdout = await ghApi(
1107
- [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"],
1108
- body
1109
- );
1110
- return parseHtmlUrl(stdout);
1149
+ const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
1150
+ try {
1151
+ const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
1152
+ return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
1153
+ } catch (err) {
1154
+ if (comments.length === 0) throw err;
1155
+ process.stderr.write(
1156
+ `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)
1157
+ `
1158
+ );
1159
+ const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
1160
+ const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
1161
+ const unposted = [];
1162
+ let inlinePosted = 0;
1163
+ for (const [i, c] of comments.entries()) {
1164
+ try {
1165
+ await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
1166
+ inlinePosted += 1;
1167
+ } catch (e) {
1168
+ const finding = inDiff[i];
1169
+ if (finding) unposted.push(finding);
1170
+ process.stderr.write(
1171
+ `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)
1172
+ `
1173
+ );
1174
+ }
1175
+ }
1176
+ return { url, inlinePosted, unposted };
1177
+ }
1111
1178
  };
1112
1179
  var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
1113
1180
  const stdout = await ghApi(
@@ -1174,10 +1241,7 @@ var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
1174
1241
  return [];
1175
1242
  }
1176
1243
  if (!Array.isArray(reviews)) return [];
1177
- return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({
1178
- id: r.id,
1179
- commitId: typeof r.commit_id === "string" ? r.commit_id : ""
1180
- }));
1244
+ return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
1181
1245
  };
1182
1246
  var dismissReviews = async (repo, prNumber, ids, ghApi) => {
1183
1247
  for (const id of ids) {
@@ -1200,16 +1264,15 @@ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
1200
1264
  }
1201
1265
  }
1202
1266
  };
1203
- 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}}}}}}}}";
1267
+ 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}}}}}}}}";
1204
1268
  var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
1205
- var supersededCommentId = (c, headSha, logins) => {
1269
+ var priorBotCommentId = (c, logins) => {
1206
1270
  if (typeof c !== "object" || c === null) return null;
1207
1271
  const o = c;
1208
1272
  const login = o.author?.login;
1209
- const oid = o.originalCommit?.oid;
1210
- return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) && typeof oid === "string" && oid !== headSha ? o.id : null;
1273
+ return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
1211
1274
  };
1212
- var supersededBotCommentIds = (raw, headSha, botLogin) => {
1275
+ var priorBotCommentIds = (raw, botLogin) => {
1213
1276
  let parsed;
1214
1277
  try {
1215
1278
  parsed = JSON.parse(raw);
@@ -1223,13 +1286,13 @@ var supersededBotCommentIds = (raw, headSha, botLogin) => {
1223
1286
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
1224
1287
  const ids = nodes.flatMap((t4) => {
1225
1288
  const cnodes = t4.comments?.nodes;
1226
- return Array.isArray(cnodes) ? cnodes.map((c) => supersededCommentId(c, headSha, logins)).filter((id) => id !== null) : [];
1289
+ return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
1227
1290
  });
1228
1291
  return { ids, truncated };
1229
1292
  };
1230
- var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi) => {
1293
+ var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
1231
1294
  const slash = repo.indexOf("/");
1232
- if (slash <= 0) return;
1295
+ if (slash <= 0) return [];
1233
1296
  const owner = repo.slice(0, slash);
1234
1297
  const name = repo.slice(slash + 1);
1235
1298
  let raw;
@@ -1250,15 +1313,18 @@ var minimizeSupersededComments = async (repo, prNumber, headSha, botLogin, ghApi
1250
1313
  `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1251
1314
  `
1252
1315
  );
1253
- return;
1316
+ return [];
1254
1317
  }
1255
- const { ids, truncated } = supersededBotCommentIds(raw, headSha, botLogin);
1318
+ const { ids, truncated } = priorBotCommentIds(raw, botLogin);
1256
1319
  if (truncated) {
1257
1320
  process.stderr.write(
1258
1321
  `Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
1259
1322
  `
1260
1323
  );
1261
1324
  }
1325
+ return ids;
1326
+ };
1327
+ var minimizeComments = async (prNumber, ids, ghApi) => {
1262
1328
  let minimized = 0;
1263
1329
  for (const id of ids) {
1264
1330
  try {
@@ -1373,7 +1439,11 @@ var post = async (input, ghApi = runGhApi) => {
1373
1439
  process.exit(0);
1374
1440
  }
1375
1441
  const findingsMarker = findingsPointer(findings, input.jsonUrl);
1376
- const { comments: rawComments, strays } = buildInlineComments(findings.findings, diff, {
1442
+ const {
1443
+ comments: rawComments,
1444
+ strays,
1445
+ inDiff
1446
+ } = buildInlineComments(findings.findings, diff, {
1377
1447
  inlineTemplate,
1378
1448
  models: envelope.models.map((m) => m.model),
1379
1449
  findings,
@@ -1387,8 +1457,7 @@ var post = async (input, ghApi = runGhApi) => {
1387
1457
  );
1388
1458
  }
1389
1459
  const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1390
- const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
1391
- const initialDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : void 0 : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1460
+ const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1392
1461
  const commonRenderInput = {
1393
1462
  findings,
1394
1463
  envelope,
@@ -1412,7 +1481,15 @@ var post = async (input, ghApi = runGhApi) => {
1412
1481
 
1413
1482
  > **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.
1414
1483
  ` : "";
1415
- const renderBody = (inlineDisposition, reviewUrl2) => formatMarkdown(render({ ...commonRenderInput, inlineDisposition, reviewUrl: reviewUrl2 }) + longFilesNote);
1484
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
1485
+ render({
1486
+ ...commonRenderInput,
1487
+ ...straysOverride ? { strays: straysOverride } : {},
1488
+ ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
1489
+ inlineDisposition,
1490
+ reviewUrl: reviewUrl2
1491
+ }) + longFilesNote
1492
+ );
1416
1493
  const stickyRef = await upsertSticky(
1417
1494
  input.repo,
1418
1495
  prNumber,
@@ -1420,49 +1497,53 @@ var post = async (input, ghApi = runGhApi) => {
1420
1497
  renderBody(initialDisposition),
1421
1498
  ghApi
1422
1499
  );
1423
- if (alreadyReviewedThisSha) {
1424
- process.stderr.write(
1425
- `A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new review
1426
- `
1427
- );
1428
- return;
1429
- }
1430
- const stalePriorReviewIds = botReviews.filter((r) => r.commitId !== input.headSha).map((r) => r.id);
1431
- if (stalePriorReviewIds.length > 0) {
1432
- await dismissReviews(input.repo, prNumber, stalePriorReviewIds, ghApi);
1433
- }
1434
- const reviewUrl = await postInlineReview(
1500
+ const priorInlineComments = await listPriorBotCommentIds(
1501
+ input.repo,
1502
+ prNumber,
1503
+ input.botLogin,
1504
+ ghApi
1505
+ );
1506
+ const {
1507
+ url: reviewUrl,
1508
+ inlinePosted,
1509
+ unposted
1510
+ } = await postInlineReview(
1435
1511
  input.repo,
1436
1512
  prNumber,
1437
1513
  input.headSha,
1438
1514
  comments,
1515
+ inDiff,
1439
1516
  stickyRef?.url,
1440
1517
  findingsMarker,
1441
1518
  ghApi
1442
1519
  );
1443
1520
  process.stderr.write(
1444
- `Posted a review with ${String(comments.length)} inline comment(s) on PR #${String(prNumber)}
1521
+ `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
1445
1522
  `
1446
1523
  );
1447
- await minimizeSupersededComments(input.repo, prNumber, input.headSha, input.botLogin, ghApi);
1448
- if (comments.length > 0 && stickyRef !== null) {
1449
- const confirmedDisposition = {
1450
- kind: "posted",
1451
- count: comments.length,
1452
- sha: input.headSha
1453
- };
1524
+ const priorReviewIds = botReviews.map((r) => r.id);
1525
+ if (priorReviewIds.length > 0) {
1526
+ await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
1527
+ }
1528
+ await minimizeComments(prNumber, priorInlineComments, ghApi);
1529
+ const unanchoredCount = unposted.length;
1530
+ const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
1531
+ if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
1532
+ const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
1454
1533
  try {
1455
1534
  await patchComment(
1456
1535
  input.repo,
1457
1536
  stickyRef.id,
1458
- renderBody(confirmedDisposition, reviewUrl),
1537
+ renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
1459
1538
  ghApi
1460
1539
  );
1461
- process.stderr.write(`Linked sticky comment #${String(stickyRef.id)} to the review
1462
- `);
1540
+ process.stderr.write(
1541
+ `Updated sticky comment #${String(stickyRef.id)} to reflect the review
1542
+ `
1543
+ );
1463
1544
  } catch (err) {
1464
1545
  process.stderr.write(
1465
- `Warning: failed to link the sticky summary to the review: ${err instanceof Error ? err.message : String(err)}
1546
+ `Warning: failed to update the sticky summary after the review: ${err instanceof Error ? err.message : String(err)}
1466
1547
  `
1467
1548
  );
1468
1549
  }
@@ -1800,18 +1881,18 @@ var withMeta = (base, meta) => ({
1800
1881
  ...meta.effort ? { effort: meta.effort } : {}
1801
1882
  });
1802
1883
  var resolveTelemetry = (native, meta) => {
1803
- const fb = native.models.length === 0 ? meta.transcriptFallback?.() : void 0;
1804
- const useFallback = fb !== void 0 && fb.models.length > 0;
1884
+ const fb = (() => {
1885
+ try {
1886
+ return meta.transcriptFallback?.();
1887
+ } catch {
1888
+ return void 0;
1889
+ }
1890
+ })();
1891
+ const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
1805
1892
  return withMeta(
1806
- useFallback ? {
1807
- models: [...fb.models],
1808
- turns: fb.turns,
1809
- duration_ms: fb.durationMs,
1810
- vendor_cost_usd: native.vendorCostUsd
1811
- } : {
1812
- models: native.models,
1813
- turns: native.turns,
1814
- duration_ms: native.durationMs,
1893
+ {
1894
+ models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
1895
+ ...wallTurns,
1815
1896
  vendor_cost_usd: native.vendorCostUsd
1816
1897
  },
1817
1898
  meta
@@ -2400,6 +2481,105 @@ ${printableSchema(schemaPath)}
2400
2481
  }
2401
2482
  }
2402
2483
  });
2484
+ var seedDraftCmd = defineCommand({
2485
+ meta: {
2486
+ name: "seed-draft",
2487
+ 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"
2488
+ },
2489
+ args: {
2490
+ prior: {
2491
+ type: "string",
2492
+ 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"
2493
+ },
2494
+ out: {
2495
+ type: "string",
2496
+ description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
2497
+ required: true
2498
+ },
2499
+ kind: {
2500
+ type: "string",
2501
+ description: "Schema kind to validate the prior findings against (default: findings)"
2502
+ },
2503
+ schema: {
2504
+ type: "string",
2505
+ description: "Path to a schema file (wins over --kind/--schema-version)"
2506
+ },
2507
+ "schema-version": {
2508
+ type: "string",
2509
+ 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)"
2510
+ }
2511
+ },
2512
+ run: async ({ args }) => {
2513
+ const outPath = resolve$1(args.out);
2514
+ const kindArg = args.kind || "findings";
2515
+ const kind = isSchemaKind(kindArg) ? kindArg : "findings";
2516
+ if (kind !== kindArg) {
2517
+ process.stderr.write(
2518
+ `Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
2519
+ `
2520
+ );
2521
+ }
2522
+ const writeEmptyScaffold = () => {
2523
+ try {
2524
+ writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
2525
+ `);
2526
+ process.stderr.write(
2527
+ `Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
2528
+ `
2529
+ );
2530
+ process.stdout.write("empty\n");
2531
+ } catch (err) {
2532
+ process.stderr.write(
2533
+ `Warning: could not write the seed scaffold to ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the agent will create $DRAFT itself
2534
+ `
2535
+ );
2536
+ process.stdout.write("none\n");
2537
+ }
2538
+ };
2539
+ const priorFindings = (() => {
2540
+ if (!args.prior) return null;
2541
+ const raw = (() => {
2542
+ try {
2543
+ return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
2544
+ } catch {
2545
+ return null;
2546
+ }
2547
+ })();
2548
+ const body = typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
2549
+ return body === null ? null : parseFindingsMarker(body);
2550
+ })();
2551
+ if (priorFindings === null) {
2552
+ writeEmptyScaffold();
2553
+ return;
2554
+ }
2555
+ const seededFromPrior = (() => {
2556
+ try {
2557
+ const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
2558
+ if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
2559
+ writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
2560
+ `);
2561
+ return true;
2562
+ } catch (err) {
2563
+ process.stderr.write(
2564
+ `Warning: could not seed from the prior review (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to the empty scaffold
2565
+ `
2566
+ );
2567
+ return false;
2568
+ }
2569
+ })();
2570
+ if (seededFromPrior) {
2571
+ const priorList = priorFindings.findings;
2572
+ const count = Array.isArray(priorList) ? priorList.length : 0;
2573
+ process.stderr.write(
2574
+ `Seeded ${outPath} from the prior review (${String(count)} finding(s)) \u2014 verify each still holds against the current diff and refine in place
2575
+ `
2576
+ );
2577
+ process.stdout.write("prior\n");
2578
+ } else {
2579
+ writeEmptyScaffold();
2580
+ }
2581
+ }
2582
+ });
2403
2583
  var adaptCmd = defineCommand({
2404
2584
  meta: {
2405
2585
  name: "adapt",
@@ -2430,7 +2610,7 @@ var adaptCmd = defineCommand({
2430
2610
  },
2431
2611
  transcript: {
2432
2612
  type: "string",
2433
- 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)"
2613
+ 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)"
2434
2614
  }
2435
2615
  },
2436
2616
  run: async ({ args }) => {
@@ -2844,7 +3024,7 @@ var main = defineCommand({
2844
3024
  meta: {
2845
3025
  name: "code-review",
2846
3026
  version: packageVersion,
2847
- 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"
3027
+ 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"
2848
3028
  },
2849
3029
  subCommands: {
2850
3030
  gather: gatherCmd,
@@ -2854,6 +3034,7 @@ var main = defineCommand({
2854
3034
  cost: costCmd,
2855
3035
  "check-cost": checkCostCmd,
2856
3036
  validate: validateCmd,
3037
+ "seed-draft": seedDraftCmd,
2857
3038
  adapt: adaptCmd,
2858
3039
  extract: extractCmd,
2859
3040
  "validate-patches": validatePatchesCmd,