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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
- import { readFileSync, writeFileSync, statSync, readdirSync } from 'fs';
4
- import { resolve as resolve$1, join, dirname, basename } from 'path';
3
+ import { readFileSync, writeFileSync, statSync, copyFileSync, readdirSync } from 'fs';
4
+ import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
5
5
  import { Eta } from 'eta';
6
6
  import parseDiff from 'parse-diff';
7
7
  import { Ajv2020 } from 'ajv/dist/2020.js';
@@ -178,6 +178,11 @@ ${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 ZERO_SHA = "0000000000000000000000000000000000000000";
182
+ var parseReviewedSha = (body) => {
183
+ const sha = /<!-- reviewed-sha: ([0-9a-fA-F]{40}) -->/.exec(body)?.[1]?.toLowerCase();
184
+ return sha && sha !== ZERO_SHA ? sha : null;
185
+ };
181
186
  var parseFindingsMarker = (body) => {
182
187
  const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
183
188
  const b64 = match?.[1];
@@ -251,8 +256,7 @@ var render = (input) => {
251
256
  findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
252
257
  reviewUrl: input.reviewUrl ?? null,
253
258
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
254
- // Cost cells render N/A (never a false $0.00) when no real price map was provided — there are
255
- // real tokens spent, we simply have no rates to price them (SPEC §6.2).
259
+ // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
256
260
  formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
257
261
  formatDuration: (ms) => {
258
262
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
@@ -383,6 +387,14 @@ var renderStraysSection = (strays) => {
383
387
  ].join("\n");
384
388
  };
385
389
  var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
390
+ var errMsg = (e) => e instanceof Error ? e.message : String(e);
391
+ var tryParseJson = (text) => {
392
+ try {
393
+ return { ok: true, value: JSON.parse(text) };
394
+ } catch {
395
+ return { ok: false };
396
+ }
397
+ };
386
398
  var readFileOrNull = (path) => {
387
399
  try {
388
400
  return readFileSync(path, "utf-8");
@@ -683,7 +695,7 @@ var draftState = (draftPath, resolveSchema) => {
683
695
  if (err instanceof Error && err.code === "ENOENT") {
684
696
  return { kind: "missing" };
685
697
  }
686
- return { kind: "unreadable", error: err instanceof Error ? err.message : String(err) };
698
+ return { kind: "unreadable", error: errMsg(err) };
687
699
  }
688
700
  let parsed;
689
701
  try {
@@ -691,20 +703,20 @@ var draftState = (draftPath, resolveSchema) => {
691
703
  } catch (err) {
692
704
  return {
693
705
  kind: "invalid",
694
- errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`]
706
+ errors: [`not valid JSON: ${errMsg(err)}`]
695
707
  };
696
708
  }
697
709
  let schemaPath;
698
710
  try {
699
711
  schemaPath = resolveSchema(parsed);
700
712
  } catch (err) {
701
- return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
713
+ return { kind: "invalid", errors: [errMsg(err)] };
702
714
  }
703
715
  try {
704
716
  const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
705
717
  return valid ? { kind: "valid" } : { kind: "invalid", errors };
706
718
  } catch (err) {
707
- return { kind: "invalid", errors: [err instanceof Error ? err.message : String(err)] };
719
+ return { kind: "invalid", errors: [errMsg(err)] };
708
720
  }
709
721
  };
710
722
  var readNudges = (counterPath) => {
@@ -805,6 +817,10 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
805
817
  };
806
818
  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
819
  var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
820
+ var lastValidPath = (draftPath) => {
821
+ const ext = extname(draftPath);
822
+ return join(dirname(draftPath), `${basename(draftPath, ext)}.last-valid${ext}`);
823
+ };
808
824
  var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
809
825
  var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and a pre-seeded draft does not count until you have revised it yourself this run. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
810
826
  var forceBackgroundSpawn = (toolInput) => ({
@@ -821,6 +837,10 @@ var denyPreTool = (reason) => ({
821
837
  permissionDecisionReason: reason
822
838
  }
823
839
  });
840
+ var isSubagentHookInput = (input) => {
841
+ const agentId = asRecord(input)?.["agent_id"];
842
+ return typeof agentId === "string" && agentId.length > 0;
843
+ };
824
844
  var evaluateBudgetHook = (input, params) => {
825
845
  const rec = asRecord(input);
826
846
  const inputs = {
@@ -831,8 +851,7 @@ var evaluateBudgetHook = (input, params) => {
831
851
  reserve: params.reserve
832
852
  };
833
853
  const phase = decideBudget(inputs);
834
- const agentId = rec?.["agent_id"];
835
- const isSubagent = typeof agentId === "string" && agentId.length > 0;
854
+ const isSubagent = isSubagentHookInput(input);
836
855
  switch (rec?.["hook_event_name"]) {
837
856
  case "PostToolBatch":
838
857
  return phase.kind === "ok" ? {} : {
@@ -1123,7 +1142,7 @@ var loadTestReport = (path) => {
1123
1142
  raw = JSON.parse(readFileSync(path, "utf-8"));
1124
1143
  } catch (err) {
1125
1144
  process.stderr.write(
1126
- `Warning: could not read test report at ${path}: ${err instanceof Error ? err.message : String(err)} \u2014 omitting test panel
1145
+ `Warning: could not read test report at ${path}: ${errMsg(err)} \u2014 omitting test panel
1127
1146
  `
1128
1147
  );
1129
1148
  return void 0;
@@ -1139,12 +1158,9 @@ var loadTestReport = (path) => {
1139
1158
  return decoded.right;
1140
1159
  };
1141
1160
  var parseHtmlUrl = (raw) => {
1142
- try {
1143
- const parsed = JSON.parse(raw);
1144
- return typeof parsed.html_url === "string" ? parsed.html_url : void 0;
1145
- } catch {
1146
- return void 0;
1147
- }
1161
+ const parsed = tryParseJson(raw);
1162
+ const htmlUrl = parsed.ok ? asRecord(parsed.value)?.["html_url"] : void 0;
1163
+ return typeof htmlUrl === "string" ? htmlUrl : void 0;
1148
1164
  };
1149
1165
  var commentPayload = (c) => ({
1150
1166
  path: c.path,
@@ -1168,7 +1184,7 @@ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyU
1168
1184
  } catch (err) {
1169
1185
  if (comments.length === 0) throw err;
1170
1186
  process.stderr.write(
1171
- `Warning: the batched inline review on PR #${String(prNumber)} was rejected (${err instanceof Error ? err.message : String(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
1187
+ `Warning: the batched inline review on PR #${String(prNumber)} was rejected (${errMsg(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
1172
1188
  `
1173
1189
  );
1174
1190
  const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
@@ -1183,7 +1199,7 @@ var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyU
1183
1199
  const finding = inDiff[i];
1184
1200
  if (finding) unposted.push(finding);
1185
1201
  process.stderr.write(
1186
- `Warning: inline comment on ${c.path}:${String(c.line)} rejected (${e instanceof Error ? e.message : String(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
1202
+ `Warning: inline comment on ${c.path}:${String(c.line)} rejected (${errMsg(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
1187
1203
  `
1188
1204
  );
1189
1205
  }
@@ -1210,12 +1226,11 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
1210
1226
  return { id: parsed.id, body: parsed.body };
1211
1227
  };
1212
1228
  var parseCommentRef = (raw) => {
1213
- try {
1214
- const parsed = JSON.parse(raw);
1215
- return typeof parsed.id === "number" && typeof parsed.html_url === "string" ? { id: parsed.id, html_url: parsed.html_url } : null;
1216
- } catch {
1217
- return null;
1218
- }
1229
+ const parsed = tryParseJson(raw);
1230
+ const rec = parsed.ok ? asRecord(parsed.value) : null;
1231
+ const id = rec?.["id"];
1232
+ const html_url = rec?.["html_url"];
1233
+ return typeof id === "number" && typeof html_url === "string" ? { id, html_url } : null;
1219
1234
  };
1220
1235
  var patchComment = async (repo, commentId, body, ghApi) => {
1221
1236
  const stdout = await ghApi(
@@ -1273,7 +1288,7 @@ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
1273
1288
  );
1274
1289
  } catch (err) {
1275
1290
  process.stderr.write(
1276
- `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1291
+ `Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${errMsg(err)}
1277
1292
  `
1278
1293
  );
1279
1294
  }
@@ -1325,7 +1340,7 @@ var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
1325
1340
  ]);
1326
1341
  } catch (err) {
1327
1342
  process.stderr.write(
1328
- `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1343
+ `Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${errMsg(err)}
1329
1344
  `
1330
1345
  );
1331
1346
  return [];
@@ -1347,7 +1362,7 @@ var minimizeComments = async (prNumber, ids, ghApi) => {
1347
1362
  minimized += 1;
1348
1363
  } catch (err) {
1349
1364
  process.stderr.write(
1350
- `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${err instanceof Error ? err.message : String(err)}
1365
+ `Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${errMsg(err)}
1351
1366
  `
1352
1367
  );
1353
1368
  }
@@ -1558,7 +1573,7 @@ var post = async (input, ghApi = runGhApi) => {
1558
1573
  );
1559
1574
  } catch (err) {
1560
1575
  process.stderr.write(
1561
- `Warning: failed to update the sticky summary after the review: ${err instanceof Error ? err.message : String(err)}
1576
+ `Warning: failed to update the sticky summary after the review: ${errMsg(err)}
1562
1577
  `
1563
1578
  );
1564
1579
  }
@@ -1648,7 +1663,7 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
1648
1663
  writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
1649
1664
  } catch (err) {
1650
1665
  process.stderr.write(
1651
- `Warning: failed to download logs for job ${String(job.id)}: ${err instanceof Error ? err.message : String(err)} \u2014 continuing with the logs retrieved so far
1666
+ `Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
1652
1667
  `
1653
1668
  );
1654
1669
  }
@@ -1754,13 +1769,6 @@ var gateCandidate = (kind, rawCandidate) => {
1754
1769
  const resolution = resolve(kind, candidate);
1755
1770
  return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
1756
1771
  };
1757
- var tryParseJson = (text) => {
1758
- try {
1759
- return { ok: true, value: JSON.parse(text) };
1760
- } catch {
1761
- return { ok: false };
1762
- }
1763
- };
1764
1772
  var candidateFromJsonText = (kind, text) => {
1765
1773
  if (text === null) return null;
1766
1774
  const parsed = tryParseJson(text);
@@ -1789,6 +1797,10 @@ var ladderFailureDiagnostics = (input) => {
1789
1797
  lines.push(
1790
1798
  input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
1791
1799
  );
1800
+ if (input.agentFileFallbackPath !== void 0)
1801
+ lines.push(
1802
+ `last-valid rung: ${input.agentFileFallbackPath} did not validate (or was absent)`
1803
+ );
1792
1804
  }
1793
1805
  lines.push(
1794
1806
  isNullish(native.structuredOutput) ? "structured_output rung: absent (null) \u2014 the CLI's --json-schema likely did not enforce" : "structured_output rung: present but did not validate against the schema"
@@ -1815,13 +1827,16 @@ var okOutcome = (gated) => ({
1815
1827
  });
1816
1828
  var extractStructured = (input) => {
1817
1829
  const native = parseNativeForExtraction(input.native);
1830
+ if (input.kind === "findings") {
1831
+ for (const path of [input.agentFilePath, input.agentFileFallbackPath]) {
1832
+ if (path === void 0) continue;
1833
+ const fromFile = candidateFromJsonText(input.kind, readFileOrNull(path));
1834
+ if (fromFile) return okOutcome(fromFile);
1835
+ }
1836
+ }
1818
1837
  if (isErrorEnvelope(native)) {
1819
1838
  return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
1820
1839
  }
1821
- if (input.kind === "findings" && input.agentFilePath !== void 0) {
1822
- const fromFile = candidateFromJsonText(input.kind, readFileOrNull(input.agentFilePath));
1823
- if (fromFile) return okOutcome(fromFile);
1824
- }
1825
1840
  if (native.structuredOutput !== void 0) {
1826
1841
  const fromStructured = gateCandidate(input.kind, native.structuredOutput);
1827
1842
  if (fromStructured) return okOutcome(fromStructured);
@@ -1841,9 +1856,10 @@ var extractStructured = (input) => {
1841
1856
  };
1842
1857
  }
1843
1858
  }
1859
+ const fallbackRung = input.agentFileFallbackPath ? ", last-valid snapshot" : "";
1844
1860
  return {
1845
1861
  kind: "none",
1846
- detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
1862
+ detail: `no --agent-file${fallbackRung}, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
1847
1863
  };
1848
1864
  };
1849
1865
 
@@ -1880,8 +1896,13 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
1880
1896
  ...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
1881
1897
  ...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
1882
1898
  }));
1883
- var findingsOutcome = (native, agentFilePath) => {
1884
- const ladder = extractStructured({ kind: "findings", native, agentFilePath });
1899
+ var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
1900
+ const ladder = extractStructured({
1901
+ kind: "findings",
1902
+ native,
1903
+ agentFilePath,
1904
+ agentFileFallbackPath
1905
+ });
1885
1906
  if (ladder.kind !== "ok")
1886
1907
  return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
1887
1908
  const resolution = resolve("findings", ladder.candidate);
@@ -1923,8 +1944,8 @@ var nativeTelemetry = (native, meta) => resolveTelemetry(
1923
1944
  meta
1924
1945
  );
1925
1946
  var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
1926
- var buildEnvelope = (telemetry, native, agentFilePath) => {
1927
- const outcome = findingsOutcome(native, agentFilePath);
1947
+ var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath) => {
1948
+ const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
1928
1949
  switch (outcome.kind) {
1929
1950
  case "ok":
1930
1951
  return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
@@ -1943,11 +1964,25 @@ var adapt = (adapterName, native, agentFilePath, meta = {}) => {
1943
1964
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
1944
1965
  case "claude-code": {
1945
1966
  if (native === void 0 || native === null)
1946
- return right(buildEnvelope(absentTelemetry(meta), void 0, agentFilePath));
1967
+ return right(
1968
+ buildEnvelope(
1969
+ absentTelemetry(meta),
1970
+ void 0,
1971
+ agentFilePath,
1972
+ meta.agentFileFallbackPath
1973
+ )
1974
+ );
1947
1975
  const decoded = ClaudeCodeEnvelopeCodec.decode(native);
1948
1976
  if (decoded._tag === "Left")
1949
1977
  return left("native envelope does not match the Claude Code output shape");
1950
- return right(buildEnvelope(nativeTelemetry(decoded.right, meta), native, agentFilePath));
1978
+ return right(
1979
+ buildEnvelope(
1980
+ nativeTelemetry(decoded.right, meta),
1981
+ native,
1982
+ agentFilePath,
1983
+ meta.agentFileFallbackPath
1984
+ )
1985
+ );
1951
1986
  }
1952
1987
  }
1953
1988
  };
@@ -1971,8 +2006,7 @@ var readJSON = (path) => {
1971
2006
  try {
1972
2007
  return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
1973
2008
  } catch (err) {
1974
- fail(`Cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
1975
- throw new Error("unreachable", { cause: err });
2009
+ return fail(`Cannot read ${path}: ${errMsg(err)}`);
1976
2010
  }
1977
2011
  };
1978
2012
  var fail = (msg) => {
@@ -1985,19 +2019,19 @@ var readJSONOrAbsent = (path) => {
1985
2019
  try {
1986
2020
  return { text: readFileSync(resolve$1(path), "utf-8") };
1987
2021
  } catch (err) {
1988
- return { error: err instanceof Error ? err.message : String(err) };
2022
+ return { error: errMsg(err) };
1989
2023
  }
1990
2024
  })();
1991
2025
  if ("error" in read) {
1992
2026
  process.stderr.write(
1993
- `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry (issue #39)
2027
+ `code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry
1994
2028
  `
1995
2029
  );
1996
2030
  return void 0;
1997
2031
  }
1998
2032
  if (read.text.trim() === "") {
1999
2033
  process.stderr.write(
2000
- `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry (issue #39)
2034
+ `code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry
2001
2035
  `
2002
2036
  );
2003
2037
  return void 0;
@@ -2006,7 +2040,7 @@ var readJSONOrAbsent = (path) => {
2006
2040
  return JSON.parse(read.text);
2007
2041
  } catch (err) {
2008
2042
  process.stderr.write(
2009
- `code-review: native envelope ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 proceeding with no native telemetry (issue #39)
2043
+ `code-review: native envelope ${path} is not valid JSON (${errMsg(err)}) \u2014 proceeding with no native telemetry
2010
2044
  `
2011
2045
  );
2012
2046
  return void 0;
@@ -2022,34 +2056,29 @@ var readStdinJSON = () => {
2022
2056
  }
2023
2057
  })();
2024
2058
  if (raw.trim() === "") return null;
2025
- try {
2026
- return JSON.parse(raw);
2027
- } catch {
2028
- return null;
2029
- }
2059
+ const parsed = tryParseJson(raw);
2060
+ return parsed.ok ? parsed.value : null;
2030
2061
  };
2031
2062
  var decode = (either, label) => {
2032
2063
  try {
2033
2064
  return unsafeUnwrap(either);
2034
2065
  } catch {
2035
- fail(`${label} does not match expected shape`);
2066
+ return fail(`${label} does not match expected shape`);
2036
2067
  }
2037
- throw new Error("unreachable");
2038
2068
  };
2039
2069
  var unwrapAdapt = (either) => {
2040
2070
  try {
2041
2071
  if (either._tag === "Left") throw new Error(either.left);
2042
2072
  return either.right;
2043
2073
  } catch (err) {
2044
- fail(err instanceof Error ? err.message : String(err));
2074
+ return fail(errMsg(err));
2045
2075
  }
2046
- throw new Error("unreachable");
2047
2076
  };
2048
2077
  var transcriptFallbackFrom = (path) => {
2049
2078
  const tree = readTranscriptTree(resolve$1(path));
2050
2079
  if (tree.missing)
2051
2080
  process.stderr.write(
2052
- `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback (issue #36)
2081
+ `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
2053
2082
  `
2054
2083
  );
2055
2084
  const usage = sumTranscriptUsage(tree.entries);
@@ -2192,7 +2221,7 @@ var costCmd = defineCommand({
2192
2221
  var checkCostCmd = defineCommand({
2193
2222
  meta: {
2194
2223
  name: "check-cost",
2195
- description: "Sum real USD spend from a live/finished Claude Code transcript tree (main session + subagents) against a price map \u2014 the correct cost when no clean result envelope exists (issue #36) and the outlook the review agent tracks in flight (issue #38)"
2224
+ description: "Sum real USD spend from a Claude Code transcript tree (main + subagents) against a price map"
2196
2225
  },
2197
2226
  args: {
2198
2227
  transcript: {
@@ -2209,7 +2238,7 @@ var checkCostCmd = defineCommand({
2209
2238
  const tree = readTranscriptTree(resolve$1(args.transcript));
2210
2239
  if (tree.missing) {
2211
2240
  process.stderr.write(
2212
- `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend (issue #36)
2241
+ `code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
2213
2242
  `
2214
2243
  );
2215
2244
  }
@@ -2257,10 +2286,21 @@ var transcriptPathOf = (input) => {
2257
2286
  const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
2258
2287
  return typeof tp === "string" ? tp : void 0;
2259
2288
  };
2289
+ var snapshotIfValid = (draftPath) => {
2290
+ try {
2291
+ if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
2292
+ copyFileSync(draftPath, lastValidPath(draftPath));
2293
+ } catch (err) {
2294
+ process.stderr.write(
2295
+ `code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
2296
+ `
2297
+ );
2298
+ }
2299
+ };
2260
2300
  var budgetHookCmd = defineCommand({
2261
2301
  meta: {
2262
2302
  name: "budget-hook",
2263
- description: "Self-dispatching Claude Code hook for budget discipline (issue #38): on PostToolBatch, steer the agent to converge once spend or wall-clock enters its soft wind-down reserve; on PreToolUse, inside the hard reserve, deny the budget-burning tools (subagent spawns, arbitrary shell, web) while leaving the draft-delivery path open. At every phase, the main agent's subagent spawns are denied until it has written its own first-pass draft (seed-draft's sidecar marker tells the seed apart), and permitted spawns are rewritten to run in the background so no batch join can block the spawner (issue #73). Reads the hook payload on stdin, measures spend from its transcript, and decides on $ and/or time. Degrades to a no-op on any error."
2303
+ description: "Self-dispatching Claude Code budget hook: on PostToolBatch steer the agent to converge as spend/wall-clock nears the budget; on PreToolUse deny budget-burning tools under the hard reserve, gate subagent spawns until the main agent has drafted, and run permitted spawns in the background. Reads the hook payload on stdin; degrades to a no-op on error."
2264
2304
  },
2265
2305
  args: {
2266
2306
  draft: {
@@ -2329,13 +2369,13 @@ var budgetHookCmd = defineCommand({
2329
2369
  mtimeMsOf(seedMarkerPath(draftPath))
2330
2370
  )
2331
2371
  });
2372
+ if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
2373
+ snapshotIfValid(draftPath);
2332
2374
  process.stdout.write(`${JSON.stringify(output)}
2333
2375
  `);
2334
2376
  } catch (err) {
2335
- process.stderr.write(
2336
- `code-review budget-hook: degrading to no-op \u2014 ${err instanceof Error ? err.message : String(err)}
2337
- `
2338
- );
2377
+ process.stderr.write(`code-review budget-hook: degrading to no-op \u2014 ${errMsg(err)}
2378
+ `);
2339
2379
  process.stdout.write("{}\n");
2340
2380
  }
2341
2381
  }
@@ -2343,7 +2383,7 @@ var budgetHookCmd = defineCommand({
2343
2383
  var printSettingsCmd = defineCommand({
2344
2384
  meta: {
2345
2385
  name: "print-settings",
2346
- description: "Emit ONE Claude Code --settings JSON composing the review agent's discipline (issue #38): the Stop deliverable gate plus the budget hooks (PreToolUse forced convergence + PostToolBatch steer) wired from one self-dispatching command. The review job generates this once and passes it as --settings."
2386
+ description: "Emit one Claude Code --settings JSON composing the Stop deliverable gate and the budget hooks (PreToolUse convergence + PostToolBatch steer) from one self-dispatching command"
2347
2387
  },
2348
2388
  args: {
2349
2389
  draft: {
@@ -2429,7 +2469,7 @@ var printSettingsCmd = defineCommand({
2429
2469
  var deadlineCmd = defineCommand({
2430
2470
  meta: {
2431
2471
  name: "deadline",
2432
- description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall). The review job exports this as CODE_REVIEW_DEADLINE_EPOCH right before `claude -p` so every budget hook \u2014 the main agent's and each fan-out subagent's \u2014 measures the SAME true remaining wall instead of its own transcript's start, which reads \u22480 in a fresh subagent and leaves the fan-out unsteered (issue #45)."
2472
+ description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall) \u2014 exported as CODE_REVIEW_DEADLINE_EPOCH so every budget hook (main and subagents) measures the same remaining wall"
2433
2473
  },
2434
2474
  args: {
2435
2475
  wall: {
@@ -2510,13 +2550,17 @@ ${printableSchema(schemaPath)}
2510
2550
  var seedDraftCmd = defineCommand({
2511
2551
  meta: {
2512
2552
  name: "seed-draft",
2513
- description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0 (issues #52, #53). Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself (issue #73). Prints the mode chosen (prior|empty|none \u2014 none when even the scaffold write failed) to stdout; always exits 0"
2553
+ 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. Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself. Prints the mode to stdout (prior-same|prior-new when seeded, by whether the prior review examined this same commit; empty-had-prior when a prior review exists but its findings could not be loaded; empty on a first review; none when even the scaffold write failed) and always exits 0"
2514
2554
  },
2515
2555
  args: {
2516
2556
  prior: {
2517
2557
  type: "string",
2518
2558
  description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
2519
2559
  },
2560
+ "head-sha": {
2561
+ type: "string",
2562
+ description: "Current head SHA, compared against the prior review's embedded reviewed-sha to distinguish a same-commit re-review from a new-commit one; an unknown or mismatched prior SHA is treated as a new commit"
2563
+ },
2520
2564
  out: {
2521
2565
  type: "string",
2522
2566
  description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
@@ -2550,12 +2594,12 @@ var seedDraftCmd = defineCommand({
2550
2594
  writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
2551
2595
  } catch (err) {
2552
2596
  process.stderr.write(
2553
- `Warning: could not write the seed marker beside ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the seeded draft will count as agent-written
2597
+ `Warning: could not write the seed marker beside ${outPath} (${errMsg(err)}) \u2014 the seeded draft will count as agent-written
2554
2598
  `
2555
2599
  );
2556
2600
  }
2557
2601
  };
2558
- const writeEmptyScaffold = () => {
2602
+ const writeScaffold = () => {
2559
2603
  try {
2560
2604
  writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
2561
2605
  `);
@@ -2564,16 +2608,16 @@ var seedDraftCmd = defineCommand({
2564
2608
  `Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
2565
2609
  `
2566
2610
  );
2567
- process.stdout.write("empty\n");
2611
+ return true;
2568
2612
  } catch (err) {
2569
2613
  process.stderr.write(
2570
- `Warning: could not write the seed scaffold to ${outPath} (${err instanceof Error ? err.message : String(err)}) \u2014 the agent will create $DRAFT itself
2614
+ `Warning: could not write the seed scaffold to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
2571
2615
  `
2572
2616
  );
2573
- process.stdout.write("none\n");
2617
+ return false;
2574
2618
  }
2575
2619
  };
2576
- const priorFindings = (() => {
2620
+ const priorBody = (() => {
2577
2621
  if (!args.prior) return null;
2578
2622
  const raw = (() => {
2579
2623
  try {
@@ -2582,46 +2626,47 @@ var seedDraftCmd = defineCommand({
2582
2626
  return null;
2583
2627
  }
2584
2628
  })();
2585
- const body = typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
2586
- return body === null ? null : parseFindingsMarker(body);
2629
+ return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
2587
2630
  })();
2588
- if (priorFindings === null) {
2589
- writeEmptyScaffold();
2590
- return;
2591
- }
2592
- const seededFromPrior = (() => {
2631
+ const priorFindings = priorBody === null ? null : parseFindingsMarker(priorBody);
2632
+ const seededFromPrior = priorFindings === null ? false : (() => {
2593
2633
  try {
2594
2634
  const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
2595
2635
  if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
2596
2636
  writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
2597
2637
  `);
2598
2638
  writeSeedMarker();
2639
+ const priorList = priorFindings.findings;
2640
+ const count = Array.isArray(priorList) ? priorList.length : 0;
2641
+ process.stderr.write(
2642
+ `Seeded ${outPath} from the prior review (${String(count)} finding(s))
2643
+ `
2644
+ );
2599
2645
  return true;
2600
2646
  } catch (err) {
2601
2647
  process.stderr.write(
2602
- `Warning: could not seed from the prior review (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to the empty scaffold
2648
+ `Warning: could not seed from the prior review (${errMsg(err)}) \u2014 falling back to the empty scaffold
2603
2649
  `
2604
2650
  );
2605
2651
  return false;
2606
2652
  }
2607
2653
  })();
2608
- if (seededFromPrior) {
2609
- const priorList = priorFindings.findings;
2610
- const count = Array.isArray(priorList) ? priorList.length : 0;
2611
- process.stderr.write(
2612
- `Seeded ${outPath} from the prior review (${String(count)} finding(s)) \u2014 verify each still holds against the current diff and refine in place
2613
- `
2614
- );
2615
- process.stdout.write("prior\n");
2616
- } else {
2617
- writeEmptyScaffold();
2618
- }
2654
+ const mode = (() => {
2655
+ if (seededFromPrior) {
2656
+ const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
2657
+ return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
2658
+ }
2659
+ if (!writeScaffold()) return "none";
2660
+ return priorBody === null ? "empty" : "empty-had-prior";
2661
+ })();
2662
+ process.stdout.write(`${mode}
2663
+ `);
2619
2664
  }
2620
2665
  });
2621
2666
  var adaptCmd = defineCommand({
2622
2667
  meta: {
2623
2668
  name: "adapt",
2624
- description: "Map a native agent-CLI result envelope onto the abstract SPEC \xA76.1 envelope"
2669
+ description: "Map a native agent-CLI result envelope onto the abstract SPEC envelope"
2625
2670
  },
2626
2671
  args: {
2627
2672
  native: {
@@ -2638,6 +2683,10 @@ var adaptCmd = defineCommand({
2638
2683
  type: "string",
2639
2684
  description: "Path to a file the agent was told to write its own validated findings JSON to (wins over the native envelope's structured_output/result when it validates)"
2640
2685
  },
2686
+ "agent-file-fallback": {
2687
+ type: "string",
2688
+ description: "Path to the last-valid findings snapshot, tried after --agent-file and before the native envelope; recovers the last valid state when a wall-clock kill left --agent-file truncated, so the review posts that instead of a 'did not complete' notice"
2689
+ },
2641
2690
  route: {
2642
2691
  type: "string",
2643
2692
  description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
@@ -2648,7 +2697,7 @@ var adaptCmd = defineCommand({
2648
2697
  },
2649
2698
  transcript: {
2650
2699
  type: "string",
2651
- description: "Path to the session transcript (the main .jsonl). Its tree (main + subagents) is the source of the true wall + turn count \u2014 the native envelope only sees the main agent and under-reports a fan-out (issue #59) \u2014 and refills per-model usage too when the native has none (a wall-clock kill leaves it empty, so cost is real not $0.00 \u2014 issues #39/#36)"
2700
+ description: "Path to the session transcript (main .jsonl). Its tree (main + subagents) gives the true wall + turn count the native envelope under-reports, and refills per-model usage when the native envelope is empty (e.g. after a wall-clock kill)"
2652
2701
  }
2653
2702
  },
2654
2703
  run: async ({ args }) => {
@@ -2656,6 +2705,7 @@ var adaptCmd = defineCommand({
2656
2705
  adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
2657
2706
  route: args.route,
2658
2707
  effort: args.effort,
2708
+ agentFileFallbackPath: args["agent-file-fallback"],
2659
2709
  ...args.transcript ? {
2660
2710
  transcriptFallback: () => transcriptFallbackFrom(args.transcript)
2661
2711
  } : {}
@@ -2666,11 +2716,7 @@ var adaptCmd = defineCommand({
2666
2716
  }
2667
2717
  });
2668
2718
  var isExtractSchemaKind = (s) => s === "findings" || s === "triage";
2669
- var requireExtractSchemaKind = (name) => {
2670
- if (isExtractSchemaKind(name)) return name;
2671
- fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
2672
- throw new Error("unreachable");
2673
- };
2719
+ var requireExtractSchemaKind = (name) => isExtractSchemaKind(name) ? name : fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
2674
2720
  var failClosedTriage = (outcome) => ({
2675
2721
  safe: false,
2676
2722
  reasons: describeLadderFailure(outcome)
@@ -2764,7 +2810,7 @@ var validateFinding = (finding, repoRoot) => {
2764
2810
  var validatePatchesCmd = defineCommand({
2765
2811
  meta: {
2766
2812
  name: "validate-patches",
2767
- description: "Validate each finding's patch against the real PR-head tree, aligning the finding's range and keeping the patch when it anchors, keeping it unaligned when it's a pure insertion, or dropping it when it doesn't apply (issue #10)"
2813
+ description: "Validate each finding's patch against the real PR-head tree: align the finding's range and keep the patch when it anchors, keep it unaligned for a pure insertion, or drop it when it doesn't apply"
2768
2814
  },
2769
2815
  args: {
2770
2816
  findings: {
@@ -2788,24 +2834,15 @@ var validatePatchesCmd = defineCommand({
2788
2834
  `);
2789
2835
  }
2790
2836
  });
2791
- var requireAdapterName = (name) => {
2792
- if (isAdapterName(name)) return name;
2793
- fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
2794
- throw new Error("unreachable");
2795
- };
2837
+ var requireAdapterName = (name) => isAdapterName(name) ? name : fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
2796
2838
  var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
2797
- var requireSchemaKind = (name) => {
2798
- if (isSchemaKind(name)) return name;
2799
- fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
2800
- throw new Error("unreachable");
2801
- };
2839
+ var requireSchemaKind = (name) => isSchemaKind(name) ? name : fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
2802
2840
  var requireSchemaPath = (kind, version) => {
2803
2841
  try {
2804
2842
  return schemaPathFor(kind, version);
2805
2843
  } catch (err) {
2806
- fail(err instanceof Error ? err.message : String(err));
2844
+ return fail(errMsg(err));
2807
2845
  }
2808
- throw new Error("unreachable");
2809
2846
  };
2810
2847
  var printSchemaCmd = defineCommand({
2811
2848
  meta: {
@@ -2911,7 +2948,7 @@ var stopGateCmd = defineCommand({
2911
2948
  bumpNudges(counterPath, nudges);
2912
2949
  } catch (err) {
2913
2950
  process.stderr.write(
2914
- `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${err instanceof Error ? err.message : String(err)}
2951
+ `stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${errMsg(err)}
2915
2952
  `
2916
2953
  );
2917
2954
  return;
@@ -3062,7 +3099,7 @@ var main = defineCommand({
3062
3099
  meta: {
3063
3100
  name: "code-review",
3064
3101
  version: packageVersion,
3065
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, seed-draft, stop-gate, budget-hook, print-settings, and deadline"
3102
+ description: "Deterministic commenter for agentic PR review"
3066
3103
  },
3067
3104
  subCommands: {
3068
3105
  gather: gatherCmd,
@@ -3087,6 +3124,6 @@ if (!process.env["VITEST"]) {
3087
3124
  await runMain(main);
3088
3125
  }
3089
3126
 
3090
- export { main };
3127
+ export { main, snapshotIfValid };
3091
3128
  //# sourceMappingURL=index.js.map
3092
3129
  //# sourceMappingURL=index.js.map