@mrkaran/hodor 0.6.3-rc.2 → 0.6.3-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -233,9 +233,14 @@ See [AUTOMATED_REVIEWS.md](./docs/AUTOMATED_REVIEWS.md) for advanced workflows.
233
233
  Hodor automatically optimizes token usage:
234
234
 
235
235
  - **Diff embedding**: For PRs under 200KB, the diff is embedded directly in the prompt, cutting agent turns from ~60 to ~5.
236
- - **Incremental reviews**: On re-runs, only reviews changes since the last hodor comment (detected via SHA markers in posted comments). Pass `--full` to override this and re-review the entire source-vs-target diff from scratch.
236
+ - **Incremental reviews**: On re-runs, only reviews changes since the last hodor comment. After a force-push or rebase, Hodor compares the last reviewed snapshot directly with the current HEAD instead of reviewing the whole MR again.
237
+ - **Identical-HEAD reuse**: Successful summaries include a versioned, compressed review payload. Pipeline retries with the same HEAD, model, reasoning request, and prompt configuration reuse that result while still regenerating artifacts and retrying delivery.
238
+ - **Adaptive reasoning**: Models that default to `xhigh` use `high` for routine small and incremental diffs, while risky, large, explicitly configured, and `--full` reviews retain the requested depth.
239
+ - **Focused exploration**: Embedded diffs include a changed-file manifest and direct the agent toward bounded context reads without limiting how far it may investigate.
237
240
  - **Compaction**: SDK auto-summarizes older conversation turns when context grows too large.
238
241
 
242
+ Pass `--full` to bypass incremental mode and identical-HEAD reuse. Pass `--reasoning-effort` to override adaptive reasoning.
243
+
239
244
  ## Skills
240
245
 
241
246
  Hodor discovers repository-specific review guidelines from `.agents/skills/`, the cross-client Agent Skills convention:
@@ -15,7 +15,7 @@ import {
15
15
  resolveGitlabDiscussions,
16
16
  summarizeGitlabNotes,
17
17
  summarizeHodorNotes
18
- } from "./chunk-LO2QJD45.js";
18
+ } from "./chunk-W7ZUJIFT.js";
19
19
  import {
20
20
  relativizeWorkspacePath
21
21
  } from "./chunk-AMUK6GDX.js";
@@ -39,6 +39,8 @@ function buildPrReviewPrompt(opts) {
39
39
  customPromptFile,
40
40
  embeddedDiff,
41
41
  previousReviewSha,
42
+ reviewDiffMode,
43
+ changedFiles = [],
42
44
  localMode = false
43
45
  } = opts;
44
46
  let templateFile;
@@ -68,9 +70,10 @@ function buildPrReviewPrompt(opts) {
68
70
  let prDiffCmd;
69
71
  let gitDiffCmd;
70
72
  if (previousReviewSha) {
71
- prDiffCmd = `git --no-pager diff ${previousReviewSha}...HEAD --name-only`;
72
- gitDiffCmd = `git --no-pager diff ${previousReviewSha}...HEAD`;
73
- logger.info(`Incremental review: diffing from ${previousReviewSha.slice(0, 8)} to HEAD`);
73
+ const separator = reviewDiffMode === "snapshot" ? " " : "...";
74
+ prDiffCmd = `git --no-pager diff ${previousReviewSha}${separator}HEAD --name-only`;
75
+ gitDiffCmd = `git --no-pager diff ${previousReviewSha}${separator}HEAD`;
76
+ logger.info(`${reviewDiffMode === "snapshot" ? "Snapshot" : "Incremental"} review: diffing from ${previousReviewSha.slice(0, 8)} to HEAD`);
74
77
  } else if (localMode) {
75
78
  prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
76
79
  gitDiffCmd = `git --no-pager diff ${targetBranch}`;
@@ -89,7 +92,7 @@ function buildPrReviewPrompt(opts) {
89
92
  }
90
93
  let diffExplanation;
91
94
  if (previousReviewSha) {
92
- diffExplanation = `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewSha.slice(0, 8)}\`).`;
95
+ diffExplanation = reviewDiffMode === "snapshot" ? `**Snapshot delta mode**: The MR history was rewritten. This directly compares the last reviewed snapshot (commit \`${previousReviewSha.slice(0, 8)}\`) with the current HEAD; it does not imply ancestry.` : `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewSha.slice(0, 8)}\`).`;
93
96
  } else if (diffBaseSha) {
94
97
  diffExplanation = `**GitLab CI Advantage**: This uses GitLab's pre-calculated merge base SHA (\`CI_MERGE_REQUEST_DIFF_BASE_SHA\`), which matches exactly what the GitLab UI shows. This is more reliable than three-dot syntax because it handles force pushes, rebases, and messy histories correctly.`;
95
98
  } else {
@@ -98,25 +101,20 @@ function buildPrReviewPrompt(opts) {
98
101
  const { contextSection, notesSection, reminderSection } = buildMrSections(mrMetadata);
99
102
  let incrementalSection = "";
100
103
  if (previousReviewSha) {
101
- incrementalSection = `## Incremental Review Mode
104
+ incrementalSection = `## ${reviewDiffMode === "snapshot" ? "Snapshot Delta" : "Incremental Review"} Mode
102
105
 
103
- This is a follow-up review. A previous hodor review was done at commit \`${previousReviewSha.slice(0, 8)}\`. The diff below shows ONLY changes since that review. Your job is to review that delta, not the whole MR again.
104
-
105
- Rules for incremental reviews:
106
- 1. Only report bugs introduced or still affected by the new delta.
107
- 2. Do not re-report issues that are already mentioned in existing notes unless the new delta changes the same code and the issue remains newly relevant.
108
- 3. If the delta is small and self-contained, decide from the embedded diff and submit the review without broad repository exploration.
109
- 4. For mechanical changes like route/path/string renames, verify the direct call sites or tests only when the diff itself leaves a concrete compatibility question.
110
- 5. If the delta does not introduce a production bug, submit no findings.
111
-
112
- `;
106
+ This is a follow-up review. A previous hodor review was done at commit \`${previousReviewSha.slice(0, 8)}\`. ` + (reviewDiffMode === "snapshot" ? "The branch history was rewritten, so the diff below compares that reviewed snapshot directly with the current HEAD. " : "The diff below shows ONLY changes since that review. ") + "Your job is to review that delta, not the whole MR again.\n\nRules for incremental reviews:\n1. Only report bugs introduced or still affected by the new delta.\n2. Do not re-report issues that are already mentioned in existing notes unless the new delta changes the same code and the issue remains newly relevant.\n3. If the delta is small and self-contained, decide from the embedded diff and submit the review without broad repository exploration.\n4. For mechanical changes like route/path/string renames, verify the direct call sites or tests only when the diff itself leaves a concrete compatibility question.\n5. If the delta does not introduce a production bug, submit no findings.\n\n";
113
107
  }
114
108
  let embeddedDiffSection;
115
109
  let diffFetchInstructions;
116
110
  let reviewProcessSection;
117
111
  let startInstruction;
118
112
  if (embeddedDiff) {
119
- embeddedDiffSection = "## Full Diff (Pre-fetched)\n\nThe complete diff for this PR is provided below. Analyze it directly. Use `read` or `grep` only if you need additional file context beyond what the diff shows.\n\n````diff\n" + embeddedDiff + "\n````\n";
113
+ const changedFileManifest = changedFiles.length > 0 ? `
114
+ Changed files (${changedFiles.length}):
115
+ ${changedFiles.map((file) => `- \`${file}\``).join("\n")}
116
+ ` : "";
117
+ embeddedDiffSection = "## Full Diff (Pre-fetched)\n\nThe complete diff for this PR is provided below. Analyze it directly. Do not run another command to list changed files. Use `read` or `grep` only if you need additional file context beyond what the diff shows.\n" + changedFileManifest + "\n````diff\n" + embeddedDiff + "\n````\n";
120
118
  diffFetchInstructions = `## Review the Diff Above
121
119
 
122
120
  ### Critical Rules
@@ -127,7 +125,7 @@ Rules for incremental reviews:
127
125
  - NEVER flag "dependency version downgrade" (branch not rebased)
128
126
  - NEVER compare entire codebase to ${targetBranch} - DIFF ONLY
129
127
  `;
130
- reviewProcessSection = "## Review Process\n\n1. Analyze the embedded diff above thoroughly\n2. Use `grep` to search for patterns when needed\n3. Use `read` only when surrounding context is essential\n4. Submit your review using `submit_review`\n";
128
+ reviewProcessSection = "## Review Process\n\n1. Analyze the embedded diff above thoroughly\n2. Use `grep` to search for patterns when needed\n3. Use bounded line-range reads when surrounding context is essential; avoid reading entire large files\n4. Do not repeat a diff, grep, or read operation whose result is already in context\n5. Submit your review using `submit_review`\n";
131
129
  startInstruction = previousReviewSha ? "Analyze only the incremental diff provided above. If it is self-contained, submit your review without extra tool calls." : "Analyze the diff provided above, then submit your review using `submit_review`.";
132
130
  } else {
133
131
  embeddedDiffSection = "";
@@ -329,8 +327,25 @@ function normalizeModelMatchValue(value) {
329
327
  }
330
328
  function getDefaultReasoningEffortForModel(model) {
331
329
  const values = [model.id, model.name].filter((value) => Boolean(value));
332
- const isOpus47 = values.map(normalizeModelMatchValue).some((value) => value.includes("opus-4-7"));
333
- return isOpus47 ? "xhigh" : void 0;
330
+ const isAdaptiveOpus = values.map(normalizeModelMatchValue).some((value) => value.includes("opus-4-7") || value.includes("opus-4-8"));
331
+ return isAdaptiveOpus ? "xhigh" : void 0;
332
+ }
333
+ var HIGH_RISK_PATH_RE = /(?:^|\/)(?:migrations?|schema|auth|security|permissions?|crypto|iam)(?:\/|\.|$)|\.tf$/im;
334
+ var HIGH_RISK_CHANGE_RE = /^\+.*\b(?:authorization|authentication|permission|transaction|mutex|semaphore|encrypt|decrypt|credential|secret)\b/im;
335
+ function selectReasoningEffort(opts) {
336
+ const requested = mapReasoningEffort(opts.requested);
337
+ if (requested) return requested;
338
+ const modelDefault = opts.modelDefault;
339
+ if (modelDefault !== "xhigh") return modelDefault;
340
+ if (opts.forcedFull) return modelDefault;
341
+ const risky = Boolean(
342
+ opts.diff && (HIGH_RISK_PATH_RE.test(opts.diff) || HIGH_RISK_CHANGE_RE.test(opts.diff))
343
+ );
344
+ if (risky) return modelDefault;
345
+ if (opts.mode === "incremental" || opts.mode === "snapshot") return "high";
346
+ const changedLines = (opts.stats?.additions ?? 0) + (opts.stats?.deletions ?? 0);
347
+ if (opts.stats && opts.stats.files <= 10 && changedLines <= 500) return "high";
348
+ return modelDefault;
334
349
  }
335
350
  function getApiKey(model) {
336
351
  const llmKey = process.env.LLM_API_KEY;
@@ -363,9 +378,12 @@ function formatDuration(seconds) {
363
378
  }
364
379
  function formatMetricsMarkdown(metrics) {
365
380
  const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
366
- const parts = [`in \`${tok(totalInput)}\``];
381
+ const parts = [`in \`${tok(totalInput)}\``, `fresh \`${tok(metrics.inputTokens)}\``];
367
382
  if (metrics.cacheReadTokens > 0) {
368
- parts.push(`cached \`${tok(metrics.cacheReadTokens)}\``);
383
+ parts.push(`cache read \`${tok(metrics.cacheReadTokens)}\``);
384
+ }
385
+ if (metrics.cacheWriteTokens > 0) {
386
+ parts.push(`cache write \`${tok(metrics.cacheWriteTokens)}\``);
369
387
  }
370
388
  parts.push(`out \`${tok(metrics.outputTokens)}\``);
371
389
  const lines = [
@@ -390,6 +408,9 @@ function printMetrics(metrics, stream = process.stderr) {
390
408
  const hitPct = (metrics.cacheReadTokens / totalInput * 100).toFixed(0);
391
409
  tokenLine += dim(` (${tok(metrics.cacheReadTokens)} cached ${hitPct}% \xB7 ${tok(metrics.inputTokens)} fresh)`);
392
410
  }
411
+ if (metrics.cacheWriteTokens > 0) {
412
+ tokenLine += dim(` \xB7 ${tok(metrics.cacheWriteTokens)} cache write`);
413
+ }
393
414
  tokenLine += ` ${bold(tok(metrics.outputTokens))} out`;
394
415
  tokenLine += dim(` (${tok(metrics.totalTokens)} total)`);
395
416
  write(tokenLine);
@@ -447,6 +468,21 @@ async function pushMetrics(opts) {
447
468
  `# HELP hodor_review_duration_seconds Review duration in seconds`,
448
469
  `# TYPE hodor_review_duration_seconds gauge`,
449
470
  `hodor_review_duration_seconds${labelSuffix} ${metrics.durationSeconds}`,
471
+ `# HELP hodor_review_diff_files Number of files included in the reviewed diff`,
472
+ `# TYPE hodor_review_diff_files gauge`,
473
+ `hodor_review_diff_files${labelSuffix} ${metrics.diffFiles ?? 0}`,
474
+ `# HELP hodor_review_diff_additions Added lines included in the reviewed diff`,
475
+ `# TYPE hodor_review_diff_additions gauge`,
476
+ `hodor_review_diff_additions${labelSuffix} ${metrics.diffAdditions ?? 0}`,
477
+ `# HELP hodor_review_diff_deletions Deleted lines included in the reviewed diff`,
478
+ `# TYPE hodor_review_diff_deletions gauge`,
479
+ `hodor_review_diff_deletions${labelSuffix} ${metrics.diffDeletions ?? 0}`,
480
+ `# HELP hodor_review_diff_bytes Size of the reviewed diff in bytes`,
481
+ `# TYPE hodor_review_diff_bytes gauge`,
482
+ `hodor_review_diff_bytes${labelSuffix} ${metrics.diffBytes ?? 0}`,
483
+ `# HELP hodor_review_reused Whether an existing identical-HEAD review was reused`,
484
+ `# TYPE hodor_review_reused gauge`,
485
+ `hodor_review_reused${labelSuffix} ${metrics.reused ? 1 : 0}`,
450
486
  ""
451
487
  ];
452
488
  const body = lines.join("\n");
@@ -827,12 +863,15 @@ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
827
863
  );
828
864
  }
829
865
  async function postReviewComment(opts) {
830
- const { prUrl, reviewText, model, metricsFooter, headSha } = opts;
866
+ const { prUrl, reviewText, model, metricsFooter, headSha, cacheMarker } = opts;
831
867
  const platform = detectPlatform(prUrl);
832
868
  const parsed = parsePrUrl(prUrl);
833
869
  let body = reviewText;
834
870
  if (headSha) body = `<!-- hodor:sha:${headSha} -->
835
871
  ${body}`;
872
+ if (cacheMarker) body = body.replace("\n", `
873
+ ${cacheMarker}
874
+ `);
836
875
  if (model) body += `
837
876
  ---
838
877
 
@@ -893,7 +932,9 @@ async function postReviewStructured(opts) {
893
932
  commitStatus = false,
894
933
  headSha,
895
934
  workspacePath,
896
- reconcileDiscussions = false
935
+ reconcileDiscussions = false,
936
+ cacheMarker,
937
+ skipSummary = false
897
938
  } = opts;
898
939
  const platform = detectPlatform(prUrl);
899
940
  if (platform !== "gitlab" || reviewStyle === "summary") {
@@ -902,7 +943,8 @@ async function postReviewStructured(opts) {
902
943
  reviewText: renderMarkdown(review),
903
944
  model,
904
945
  metricsFooter,
905
- headSha
946
+ headSha,
947
+ cacheMarker
906
948
  });
907
949
  }
908
950
  const parsed = parsePrUrl(prUrl);
@@ -923,7 +965,8 @@ async function postReviewStructured(opts) {
923
965
  reviewText: renderMarkdown(review),
924
966
  model,
925
967
  metricsFooter,
926
- headSha
968
+ headSha,
969
+ cacheMarker
927
970
  });
928
971
  }
929
972
  const existingByFingerprint = /* @__PURE__ */ new Map();
@@ -1018,10 +1061,13 @@ ${finding.suggestion}
1018
1061
  }
1019
1062
  }
1020
1063
  let summaryPosted = false;
1021
- if (reviewStyle === "hybrid" || review.findings.length === 0) {
1064
+ if (!skipSummary && (reviewStyle === "hybrid" || review.findings.length === 0)) {
1022
1065
  let summaryBody = renderSummaryMarkdown(review);
1023
1066
  if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
1024
1067
  ${summaryBody}`;
1068
+ if (cacheMarker) summaryBody = summaryBody.replace("\n", `
1069
+ ${cacheMarker}
1070
+ `);
1025
1071
  if (model) summaryBody += `
1026
1072
  ---
1027
1073
 
@@ -1056,7 +1102,7 @@ ${metricsFooter}`;
1056
1102
  }
1057
1103
  }
1058
1104
  let reconciledDiscussions = 0;
1059
- const baseDeliveryComplete = reviewStyle === "hybrid" ? summaryPosted && inlineFailed === 0 && (inlineCreated === 0 || draftsPublished) : inlineFailed === 0 && (review.findings.length === 0 ? summaryPosted : inlineCreated === 0 || draftsPublished);
1105
+ const baseDeliveryComplete = reviewStyle === "hybrid" ? (summaryPosted || skipSummary) && inlineFailed === 0 && (inlineCreated === 0 || draftsPublished) : inlineFailed === 0 && (review.findings.length === 0 ? summaryPosted : inlineCreated === 0 || draftsPublished);
1060
1106
  if (reconcileDiscussions && baseDeliveryComplete) {
1061
1107
  const currentFingerprints = new Set(
1062
1108
  review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
@@ -1117,6 +1163,7 @@ async function fetchGithubPrInfo(owner, repo, prNumber) {
1117
1163
  "changedFiles",
1118
1164
  "labels",
1119
1165
  "comments",
1166
+ "reviews",
1120
1167
  "state",
1121
1168
  "isDraft",
1122
1169
  "createdAt",
@@ -1144,6 +1191,7 @@ function normalizeGithubMetadata(raw) {
1144
1191
  const author = raw.author ?? {};
1145
1192
  const labels = raw.labels ?? [];
1146
1193
  const comments = raw.comments;
1194
+ const reviews = raw.reviews;
1147
1195
  return {
1148
1196
  title: raw.title,
1149
1197
  description: raw.body ?? "",
@@ -1155,7 +1203,10 @@ function normalizeGithubMetadata(raw) {
1155
1203
  username: author.login ?? author.name,
1156
1204
  name: author.name
1157
1205
  },
1158
- Notes: githubCommentsToNotes(comments)
1206
+ Notes: [
1207
+ ...githubCommentsToNotes(comments),
1208
+ ...githubCommentsToNotes(reviews)
1209
+ ]
1159
1210
  };
1160
1211
  }
1161
1212
  function githubCommentsToNotes(comments) {
@@ -1734,6 +1785,8 @@ var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull reques
1734
1785
  * Use the grep and find tools for code search \u2014 do not shell out to grep/find.
1735
1786
  * Prefer \`git diff\` to see changes for specific files. Only use read when you need surrounding context that the diff alone cannot provide.
1736
1787
  * Do not use cat/head/tail to read files.
1788
+ * Prefer bounded line-range reads. Do not read an entire large file when a changed hunk, symbol, or caller range is sufficient.
1789
+ * Do not repeat diff, grep, or read operations whose results are already in context.
1737
1790
  * Keep reasoning proportional to the task. A small diff does not need extensive deliberation.
1738
1791
  </EFFICIENCY>`;
1739
1792
 
@@ -1762,28 +1815,68 @@ function getHodorReviewShaCandidates(notes) {
1762
1815
  });
1763
1816
  return [...new Set(candidates.map(({ sha }) => sha))];
1764
1817
  }
1765
- async function findLatestValidReviewSha(notes, workspacePath) {
1818
+ async function findLatestReviewBase(notes, workspacePath) {
1766
1819
  const candidates = getHodorReviewShaCandidates(notes);
1767
1820
  if (candidates.length === 0) return null;
1768
1821
  logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
1769
1822
  for (const sha of candidates) {
1770
1823
  try {
1771
- const { stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
1772
- cwd: workspacePath
1773
- });
1824
+ let objectType;
1825
+ try {
1826
+ ({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
1827
+ cwd: workspacePath
1828
+ }));
1829
+ } catch {
1830
+ await exec("git", ["fetch", "--quiet", "origin", sha], {
1831
+ cwd: workspacePath
1832
+ });
1833
+ ({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
1834
+ cwd: workspacePath
1835
+ }));
1836
+ }
1774
1837
  if (objectType.trim() !== "commit") throw new Error("not a commit");
1775
- await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
1776
- cwd: workspacePath
1777
- });
1778
- return sha;
1838
+ try {
1839
+ await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
1840
+ cwd: workspacePath
1841
+ });
1842
+ return { sha, mode: "incremental" };
1843
+ } catch {
1844
+ logger.info(
1845
+ `Previous review SHA ${sha.slice(0, 8)} is not an ancestor; using snapshot delta`
1846
+ );
1847
+ return { sha, mode: "snapshot" };
1848
+ }
1779
1849
  } catch {
1780
1850
  logger.info(
1781
- `Skipping previous review SHA ${sha.slice(0, 8)}; not a valid ancestor of HEAD`
1851
+ `Skipping previous review SHA ${sha.slice(0, 8)}; commit is unavailable`
1782
1852
  );
1783
1853
  }
1784
1854
  }
1785
1855
  return null;
1786
1856
  }
1857
+ function getDiffStats(diff) {
1858
+ let files = 0;
1859
+ let additions = 0;
1860
+ let deletions = 0;
1861
+ for (const line of diff.split("\n")) {
1862
+ if (line.startsWith("diff --git ")) files++;
1863
+ else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
1864
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
1865
+ }
1866
+ return {
1867
+ files,
1868
+ additions,
1869
+ deletions,
1870
+ bytes: Buffer.byteLength(diff, "utf-8")
1871
+ };
1872
+ }
1873
+ function getChangedFiles(diff) {
1874
+ const files = [];
1875
+ for (const match of diff.matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm)) {
1876
+ files.push(match[2]);
1877
+ }
1878
+ return [...new Set(files)];
1879
+ }
1787
1880
  var DIFF_SKIP_PATTERNS = [
1788
1881
  /(?:^|\/)testdata\//,
1789
1882
  /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
@@ -1809,6 +1902,66 @@ function filterEmbeddedDiff(rawDiff) {
1809
1902
  return { filtered: kept.join(""), skippedFiles };
1810
1903
  }
1811
1904
 
1905
+ // src/review-cache.ts
1906
+ import { createHash as createHash2 } from "crypto";
1907
+ import { gzipSync, gunzipSync } from "zlib";
1908
+ import { readFileSync as readFileSync3 } from "fs";
1909
+ var REVIEW_PROMPT_VERSION = "2026-07-16.1";
1910
+ var CACHE_MARKER_RE = /<!--\s*hodor:cache:v1:([A-Za-z0-9_-]+)\s*-->/;
1911
+ function getReviewCacheKey(opts) {
1912
+ let promptFileContents = "";
1913
+ if (opts.promptFile) {
1914
+ promptFileContents = readFileSync3(opts.promptFile, "utf-8");
1915
+ }
1916
+ return createHash2("sha256").update(JSON.stringify({
1917
+ version: REVIEW_PROMPT_VERSION,
1918
+ headSha: opts.headSha,
1919
+ model: opts.model,
1920
+ // "auto" deliberately stays stable when an identical HEAD changes from
1921
+ // a full review to an empty incremental diff on a pipeline retry.
1922
+ reasoning: opts.requestedReasoningEffort?.toLowerCase() ?? "auto",
1923
+ customPrompt: opts.customPrompt ?? "",
1924
+ promptFileContents
1925
+ })).digest("hex");
1926
+ }
1927
+ function buildReviewCacheMarker(key, review, workspacePath) {
1928
+ const portableReview = {
1929
+ ...review,
1930
+ findings: review.findings.map((finding) => ({
1931
+ ...finding,
1932
+ code_location: {
1933
+ ...finding.code_location,
1934
+ absolute_file_path: `/workspace/${relativizeWorkspacePath(
1935
+ finding.code_location.absolute_file_path,
1936
+ workspacePath ?? void 0
1937
+ )}`
1938
+ }
1939
+ }))
1940
+ };
1941
+ const payload = { key, review: portableReview };
1942
+ const encoded = gzipSync(JSON.stringify(payload)).toString("base64url");
1943
+ return `<!-- hodor:cache:v1:${encoded} -->`;
1944
+ }
1945
+ function findCachedReview(notes, key) {
1946
+ if (!notes) return null;
1947
+ const newestFirst = [...notes].sort(
1948
+ (a, b) => Date.parse(b.created_at ?? "") - Date.parse(a.created_at ?? "")
1949
+ );
1950
+ for (const note of newestFirst) {
1951
+ const encoded = note.body?.match(CACHE_MARKER_RE)?.[1];
1952
+ if (!encoded || encoded.length > 5e5) continue;
1953
+ try {
1954
+ const payload = JSON.parse(
1955
+ gunzipSync(Buffer.from(encoded, "base64url"), { maxOutputLength: 1e6 }).toString("utf-8")
1956
+ );
1957
+ if (payload.key !== key || !payload.review) continue;
1958
+ return validateReviewOutput(payload.review);
1959
+ } catch {
1960
+ }
1961
+ }
1962
+ return null;
1963
+ }
1964
+
1812
1965
  // src/review-recovery.ts
1813
1966
  import { Value } from "@sinclair/typebox/value";
1814
1967
  var SUBMIT_REVIEW_RECOVERY_ATTEMPTS = 2;
@@ -1966,10 +2119,7 @@ async function reviewPr(opts) {
1966
2119
  );
1967
2120
  }
1968
2121
  }
1969
- const thinkingLevel = mapReasoningEffort(reasoningEffort) ?? getDefaultReasoningEffortForModel(piModel);
1970
- if (!reasoningEffort && thinkingLevel) {
1971
- logger.info(`Default reasoning effort for ${piModel.name}: ${thinkingLevel}`);
1972
- }
2122
+ const modelDefaultThinkingLevel = getDefaultReasoningEffortForModel(piModel);
1973
2123
  if (parsed.provider !== "amazon-bedrock") {
1974
2124
  const resolvedKey = await modelRegistry.getApiKeyForProvider(parsed.provider);
1975
2125
  if (!resolvedKey) {
@@ -2075,26 +2225,84 @@ async function reviewPr(opts) {
2075
2225
  logger.warn(`Failed to fetch Gitea metadata: ${err}`);
2076
2226
  }
2077
2227
  }
2078
- const previousReviewSha = full ? null : await findLatestValidReviewSha(mrMetadata?.Notes, workspacePath);
2079
- if (full) {
2080
- logger.info("Full review mode: ignoring previous hodor reviews, diffing entire source-vs-target range");
2081
- } else if (previousReviewSha) {
2082
- logger.info(`Incremental mode: previous review at ${previousReviewSha.slice(0, 8)}`);
2083
- }
2084
2228
  let headSha = null;
2085
2229
  if (!localMode) {
2086
2230
  const { stdout: headShaRaw } = await exec("git", ["rev-parse", "HEAD"], { cwd: workspacePath });
2087
2231
  headSha = headShaRaw.trim();
2088
2232
  }
2233
+ let reviewCacheKey = null;
2234
+ if (!localMode && !full && headSha) {
2235
+ reviewCacheKey = getReviewCacheKey({
2236
+ headSha,
2237
+ model,
2238
+ requestedReasoningEffort: reasoningEffort,
2239
+ customPrompt,
2240
+ promptFile
2241
+ });
2242
+ const cachedReview = findCachedReview(mrMetadata?.Notes, reviewCacheKey);
2243
+ if (cachedReview) {
2244
+ logger.info(`Reusing cached Hodor review for HEAD ${headSha.slice(0, 8)}`);
2245
+ const metrics2 = {
2246
+ inputTokens: 0,
2247
+ outputTokens: 0,
2248
+ cacheReadTokens: 0,
2249
+ cacheWriteTokens: 0,
2250
+ totalTokens: 0,
2251
+ cost: 0,
2252
+ turns: 0,
2253
+ toolCalls: 0,
2254
+ durationSeconds: 0,
2255
+ reviewMode: "reused",
2256
+ reasoningEffort: reasoningEffort ?? "auto",
2257
+ diffFiles: 0,
2258
+ diffAdditions: 0,
2259
+ diffDeletions: 0,
2260
+ diffBytes: 0,
2261
+ reused: true
2262
+ };
2263
+ logger.info(`Review telemetry: ${JSON.stringify({
2264
+ reviewMode: metrics2.reviewMode,
2265
+ reasoningEffort: metrics2.reasoningEffort,
2266
+ reused: true,
2267
+ headSha: headSha.slice(0, 12),
2268
+ findings: cachedReview.findings.length
2269
+ })}`);
2270
+ printMetrics(metrics2);
2271
+ return {
2272
+ review: cachedReview,
2273
+ metricsFooter: includeMetricsFooter ? formatMetricsMarkdown(metrics2) : null,
2274
+ headSha,
2275
+ metrics: metrics2,
2276
+ workspacePath,
2277
+ cacheMarker: null,
2278
+ reusedReview: true
2279
+ };
2280
+ }
2281
+ }
2282
+ const previousReviewBase = full || localMode ? null : await findLatestReviewBase(mrMetadata?.Notes, workspacePath);
2283
+ const previousReviewSha = previousReviewBase?.sha ?? null;
2284
+ let reviewMode = localMode ? "local" : previousReviewBase?.mode ?? "full";
2285
+ if (full) {
2286
+ reviewMode = "full";
2287
+ logger.info("Full review mode: ignoring previous hodor reviews, diffing entire source-vs-target range");
2288
+ } else if (previousReviewBase) {
2289
+ logger.info(`${previousReviewBase.mode === "snapshot" ? "Snapshot delta" : "Incremental"} mode: previous review at ${previousReviewSha?.slice(0, 8)}`);
2290
+ }
2089
2291
  const MAX_EMBED_BYTES = 200 * 1024;
2090
2292
  let embeddedDiff = null;
2293
+ let reviewDiff = null;
2294
+ let diffStats = null;
2295
+ let changedFiles = [];
2091
2296
  try {
2092
- const diffArgs = previousReviewSha ? ["--no-pager", "diff", `${previousReviewSha}...HEAD`] : diffBaseSha ? ["--no-pager", "diff", diffBaseSha, "HEAD"] : localMode ? ["--no-pager", "diff", targetBranch] : ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
2297
+ const diffArgs = previousReviewSha ? previousReviewBase?.mode === "snapshot" ? ["--no-pager", "diff", previousReviewSha, "HEAD"] : ["--no-pager", "diff", `${previousReviewSha}...HEAD`] : diffBaseSha ? ["--no-pager", "diff", diffBaseSha, "HEAD"] : localMode ? ["--no-pager", "diff", targetBranch] : ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
2093
2298
  const { stdout: rawDiff } = await exec("git", diffArgs, { cwd: workspacePath });
2094
2299
  const { filtered: filteredDiff, skippedFiles } = filterEmbeddedDiff(rawDiff);
2095
2300
  if (skippedFiles.length > 0) {
2096
2301
  logger.info(`Filtered ${skippedFiles.length} file(s) from embedded diff: ${skippedFiles.join(", ")}`);
2097
2302
  }
2303
+ reviewDiff = filteredDiff;
2304
+ diffStats = getDiffStats(filteredDiff);
2305
+ changedFiles = getChangedFiles(filteredDiff);
2098
2306
  if (Buffer.byteLength(filteredDiff, "utf-8") <= MAX_EMBED_BYTES) {
2099
2307
  embeddedDiff = filteredDiff;
2100
2308
  logger.info(`Embedding diff in prompt (${Buffer.byteLength(filteredDiff, "utf-8")} bytes, raw: ${Buffer.byteLength(rawDiff, "utf-8")} bytes)`);
@@ -2104,6 +2312,17 @@ async function reviewPr(opts) {
2104
2312
  } catch (err) {
2105
2313
  logger.warn(`Failed to pre-fetch diff, falling back to command mode: ${err}`);
2106
2314
  }
2315
+ const thinkingLevel = selectReasoningEffort({
2316
+ requested: reasoningEffort,
2317
+ modelDefault: modelDefaultThinkingLevel,
2318
+ mode: reviewMode,
2319
+ forcedFull: full,
2320
+ diff: reviewDiff,
2321
+ stats: diffStats
2322
+ });
2323
+ if (thinkingLevel) {
2324
+ logger.info(`Reasoning effort for ${piModel.name}: ${thinkingLevel}${reasoningEffort ? " (explicit)" : " (adaptive)"}`);
2325
+ }
2107
2326
  const prompt = buildPrReviewPrompt({
2108
2327
  prUrl: prUrl ?? `local diff (against ${targetBranch})`,
2109
2328
  platform,
@@ -2114,6 +2333,8 @@ async function reviewPr(opts) {
2114
2333
  customPromptFile: promptFile,
2115
2334
  embeddedDiff,
2116
2335
  previousReviewSha,
2336
+ reviewDiffMode: reviewMode,
2337
+ changedFiles,
2117
2338
  localMode
2118
2339
  });
2119
2340
  const startTime = Date.now();
@@ -2345,14 +2566,47 @@ async function reviewPr(opts) {
2345
2566
  cost,
2346
2567
  turns: turnCount,
2347
2568
  toolCalls: toolCallCount,
2348
- durationSeconds: Math.round(durationSeconds)
2569
+ durationSeconds: Math.round(durationSeconds),
2570
+ reviewMode,
2571
+ reasoningEffort: thinkingLevel ?? "none",
2572
+ diffFiles: diffStats?.files ?? 0,
2573
+ diffAdditions: diffStats?.additions ?? 0,
2574
+ diffDeletions: diffStats?.deletions ?? 0,
2575
+ diffBytes: diffStats?.bytes ?? 0,
2576
+ reused: false
2349
2577
  };
2578
+ logger.info(`Review telemetry: ${JSON.stringify({
2579
+ reviewMode: metrics.reviewMode,
2580
+ reasoningEffort: metrics.reasoningEffort,
2581
+ reused: false,
2582
+ diffFiles: metrics.diffFiles,
2583
+ diffAdditions: metrics.diffAdditions,
2584
+ diffDeletions: metrics.diffDeletions,
2585
+ diffBytes: metrics.diffBytes,
2586
+ turns: metrics.turns,
2587
+ toolCalls: metrics.toolCalls,
2588
+ inputTokens: metrics.inputTokens,
2589
+ cacheReadTokens: metrics.cacheReadTokens,
2590
+ cacheWriteTokens: metrics.cacheWriteTokens,
2591
+ outputTokens: metrics.outputTokens,
2592
+ cost: metrics.cost,
2593
+ findings: review.findings.length
2594
+ })}`);
2350
2595
  printMetrics(metrics);
2351
2596
  let metricsFooter = null;
2352
2597
  if (includeMetricsFooter) {
2353
2598
  metricsFooter = formatMetricsMarkdown(metrics);
2354
2599
  }
2355
- return { review, metricsFooter, headSha, metrics, workspacePath };
2600
+ const cacheMarker = reviewCacheKey ? buildReviewCacheMarker(reviewCacheKey, review, workspacePath) : null;
2601
+ return {
2602
+ review,
2603
+ metricsFooter,
2604
+ headSha,
2605
+ metrics,
2606
+ workspacePath,
2607
+ cacheMarker,
2608
+ reusedReview: false
2609
+ };
2356
2610
  } finally {
2357
2611
  activeSession?.dispose();
2358
2612
  for (const [key, val] of Object.entries(envSnapshot)) {
@@ -2385,4 +2639,4 @@ export {
2385
2639
  postReviewStructured,
2386
2640
  reviewPr
2387
2641
  };
2388
- //# sourceMappingURL=chunk-ZUYM6HY3.js.map
2642
+ //# sourceMappingURL=chunk-N7TSXVUE.js.map