@mrkaran/hodor 0.6.3-rc.1 → 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
@@ -1,3 +1,5 @@
1
+ <a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
2
+
1
3
  # Hodor
2
4
 
3
5
  > Agentic code reviewer for GitHub PRs, GitLab MRs, Gitea/Forgejo PRs, and local diffs. Powered by the [pi-coding-agent](https://github.com/badlogic/pi-mono) SDK.
@@ -231,9 +233,14 @@ See [AUTOMATED_REVIEWS.md](./docs/AUTOMATED_REVIEWS.md) for advanced workflows.
231
233
  Hodor automatically optimizes token usage:
232
234
 
233
235
  - **Diff embedding**: For PRs under 200KB, the diff is embedded directly in the prompt, cutting agent turns from ~60 to ~5.
234
- - **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.
235
240
  - **Compaction**: SDK auto-summarizes older conversation turns when context grows too large.
236
241
 
242
+ Pass `--full` to bypass incremental mode and identical-HEAD reuse. Pass `--reasoning-effort` to override adaptive reasoning.
243
+
237
244
  ## Skills
238
245
 
239
246
  Hodor discovers repository-specific review guidelines from `.agents/skills/`, the cross-client Agent Skills convention:
@@ -287,7 +294,7 @@ Hodor is written in TypeScript and runs on [Bun](https://bun.sh). Key components
287
294
  | `src/metrics.ts` | Token usage and cost formatting |
288
295
  | `templates/` | Review prompt template (JSON schema) |
289
296
 
290
- The agent runtime is provided by [`@mariozechner/pi-coding-agent`](https://github.com/badlogic/pi-mono) with [`@mariozechner/pi-ai`](https://github.com/badlogic/pi-mono) for LLM access. The agent session gets read-only tools (bash, read, grep, find, ls) and a review prompt, then autonomously analyzes the PR.
297
+ The agent runtime is provided by [`@earendil-works/pi-coding-agent`](https://github.com/earendil-works/pi) with [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) for LLM access. The agent session gets read-only tools (bash, read, grep, find, ls) and a review prompt, then autonomously analyzes the PR.
291
298
 
292
299
  ---
293
300
 
@@ -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 = "";
@@ -266,7 +264,8 @@ function normalizeLabelNames(rawLabels) {
266
264
  }
267
265
 
268
266
  // src/model.ts
269
- import { getEnvApiKey, getProviders } from "@earendil-works/pi-ai";
267
+ import { getEnvApiKey } from "@earendil-works/pi-ai/compat";
268
+ import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
270
269
  var PROVIDER_ALIASES = {
271
270
  bedrock: "amazon-bedrock"
272
271
  };
@@ -277,7 +276,7 @@ function parseModelString(model) {
277
276
  if (parts.length >= 2) {
278
277
  const first = parts[0].toLowerCase();
279
278
  const provider = PROVIDER_ALIASES[first] ?? first;
280
- const knownProviders = new Set(getProviders());
279
+ const knownProviders = new Set(getBuiltinProviders());
281
280
  if (provider === "amazon-bedrock") {
282
281
  let modelId = parts.slice(1).join("/");
283
282
  if (modelId.startsWith("converse/")) {
@@ -317,6 +316,8 @@ function mapReasoningEffort(effort) {
317
316
  return "high";
318
317
  case "xhigh":
319
318
  return "xhigh";
319
+ case "max":
320
+ return "max";
320
321
  default:
321
322
  return void 0;
322
323
  }
@@ -326,8 +327,25 @@ function normalizeModelMatchValue(value) {
326
327
  }
327
328
  function getDefaultReasoningEffortForModel(model) {
328
329
  const values = [model.id, model.name].filter((value) => Boolean(value));
329
- const isOpus47 = values.map(normalizeModelMatchValue).some((value) => value.includes("opus-4-7"));
330
- 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;
331
349
  }
332
350
  function getApiKey(model) {
333
351
  const llmKey = process.env.LLM_API_KEY;
@@ -360,9 +378,12 @@ function formatDuration(seconds) {
360
378
  }
361
379
  function formatMetricsMarkdown(metrics) {
362
380
  const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
363
- const parts = [`in \`${tok(totalInput)}\``];
381
+ const parts = [`in \`${tok(totalInput)}\``, `fresh \`${tok(metrics.inputTokens)}\``];
364
382
  if (metrics.cacheReadTokens > 0) {
365
- 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)}\``);
366
387
  }
367
388
  parts.push(`out \`${tok(metrics.outputTokens)}\``);
368
389
  const lines = [
@@ -387,6 +408,9 @@ function printMetrics(metrics, stream = process.stderr) {
387
408
  const hitPct = (metrics.cacheReadTokens / totalInput * 100).toFixed(0);
388
409
  tokenLine += dim(` (${tok(metrics.cacheReadTokens)} cached ${hitPct}% \xB7 ${tok(metrics.inputTokens)} fresh)`);
389
410
  }
411
+ if (metrics.cacheWriteTokens > 0) {
412
+ tokenLine += dim(` \xB7 ${tok(metrics.cacheWriteTokens)} cache write`);
413
+ }
390
414
  tokenLine += ` ${bold(tok(metrics.outputTokens))} out`;
391
415
  tokenLine += dim(` (${tok(metrics.totalTokens)} total)`);
392
416
  write(tokenLine);
@@ -444,6 +468,21 @@ async function pushMetrics(opts) {
444
468
  `# HELP hodor_review_duration_seconds Review duration in seconds`,
445
469
  `# TYPE hodor_review_duration_seconds gauge`,
446
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}`,
447
486
  ""
448
487
  ];
449
488
  const body = lines.join("\n");
@@ -824,12 +863,15 @@ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
824
863
  );
825
864
  }
826
865
  async function postReviewComment(opts) {
827
- const { prUrl, reviewText, model, metricsFooter, headSha } = opts;
866
+ const { prUrl, reviewText, model, metricsFooter, headSha, cacheMarker } = opts;
828
867
  const platform = detectPlatform(prUrl);
829
868
  const parsed = parsePrUrl(prUrl);
830
869
  let body = reviewText;
831
870
  if (headSha) body = `<!-- hodor:sha:${headSha} -->
832
871
  ${body}`;
872
+ if (cacheMarker) body = body.replace("\n", `
873
+ ${cacheMarker}
874
+ `);
833
875
  if (model) body += `
834
876
  ---
835
877
 
@@ -890,7 +932,9 @@ async function postReviewStructured(opts) {
890
932
  commitStatus = false,
891
933
  headSha,
892
934
  workspacePath,
893
- reconcileDiscussions = false
935
+ reconcileDiscussions = false,
936
+ cacheMarker,
937
+ skipSummary = false
894
938
  } = opts;
895
939
  const platform = detectPlatform(prUrl);
896
940
  if (platform !== "gitlab" || reviewStyle === "summary") {
@@ -899,7 +943,8 @@ async function postReviewStructured(opts) {
899
943
  reviewText: renderMarkdown(review),
900
944
  model,
901
945
  metricsFooter,
902
- headSha
946
+ headSha,
947
+ cacheMarker
903
948
  });
904
949
  }
905
950
  const parsed = parsePrUrl(prUrl);
@@ -920,7 +965,8 @@ async function postReviewStructured(opts) {
920
965
  reviewText: renderMarkdown(review),
921
966
  model,
922
967
  metricsFooter,
923
- headSha
968
+ headSha,
969
+ cacheMarker
924
970
  });
925
971
  }
926
972
  const existingByFingerprint = /* @__PURE__ */ new Map();
@@ -1015,10 +1061,13 @@ ${finding.suggestion}
1015
1061
  }
1016
1062
  }
1017
1063
  let summaryPosted = false;
1018
- if (reviewStyle === "hybrid" || review.findings.length === 0) {
1064
+ if (!skipSummary && (reviewStyle === "hybrid" || review.findings.length === 0)) {
1019
1065
  let summaryBody = renderSummaryMarkdown(review);
1020
1066
  if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
1021
1067
  ${summaryBody}`;
1068
+ if (cacheMarker) summaryBody = summaryBody.replace("\n", `
1069
+ ${cacheMarker}
1070
+ `);
1022
1071
  if (model) summaryBody += `
1023
1072
  ---
1024
1073
 
@@ -1053,7 +1102,7 @@ ${metricsFooter}`;
1053
1102
  }
1054
1103
  }
1055
1104
  let reconciledDiscussions = 0;
1056
- 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);
1057
1106
  if (reconcileDiscussions && baseDeliveryComplete) {
1058
1107
  const currentFingerprints = new Set(
1059
1108
  review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
@@ -1114,6 +1163,7 @@ async function fetchGithubPrInfo(owner, repo, prNumber) {
1114
1163
  "changedFiles",
1115
1164
  "labels",
1116
1165
  "comments",
1166
+ "reviews",
1117
1167
  "state",
1118
1168
  "isDraft",
1119
1169
  "createdAt",
@@ -1141,6 +1191,7 @@ function normalizeGithubMetadata(raw) {
1141
1191
  const author = raw.author ?? {};
1142
1192
  const labels = raw.labels ?? [];
1143
1193
  const comments = raw.comments;
1194
+ const reviews = raw.reviews;
1144
1195
  return {
1145
1196
  title: raw.title,
1146
1197
  description: raw.body ?? "",
@@ -1152,7 +1203,10 @@ function normalizeGithubMetadata(raw) {
1152
1203
  username: author.login ?? author.name,
1153
1204
  name: author.name
1154
1205
  },
1155
- Notes: githubCommentsToNotes(comments)
1206
+ Notes: [
1207
+ ...githubCommentsToNotes(comments),
1208
+ ...githubCommentsToNotes(reviews)
1209
+ ]
1156
1210
  };
1157
1211
  }
1158
1212
  function githubCommentsToNotes(comments) {
@@ -1731,6 +1785,8 @@ var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull reques
1731
1785
  * Use the grep and find tools for code search \u2014 do not shell out to grep/find.
1732
1786
  * Prefer \`git diff\` to see changes for specific files. Only use read when you need surrounding context that the diff alone cannot provide.
1733
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.
1734
1790
  * Keep reasoning proportional to the task. A small diff does not need extensive deliberation.
1735
1791
  </EFFICIENCY>`;
1736
1792
 
@@ -1759,28 +1815,68 @@ function getHodorReviewShaCandidates(notes) {
1759
1815
  });
1760
1816
  return [...new Set(candidates.map(({ sha }) => sha))];
1761
1817
  }
1762
- async function findLatestValidReviewSha(notes, workspacePath) {
1818
+ async function findLatestReviewBase(notes, workspacePath) {
1763
1819
  const candidates = getHodorReviewShaCandidates(notes);
1764
1820
  if (candidates.length === 0) return null;
1765
1821
  logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
1766
1822
  for (const sha of candidates) {
1767
1823
  try {
1768
- const { stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
1769
- cwd: workspacePath
1770
- });
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
+ }
1771
1837
  if (objectType.trim() !== "commit") throw new Error("not a commit");
1772
- await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
1773
- cwd: workspacePath
1774
- });
1775
- 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
+ }
1776
1849
  } catch {
1777
1850
  logger.info(
1778
- `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`
1779
1852
  );
1780
1853
  }
1781
1854
  }
1782
1855
  return null;
1783
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
+ }
1784
1880
  var DIFF_SKIP_PATTERNS = [
1785
1881
  /(?:^|\/)testdata\//,
1786
1882
  /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
@@ -1806,6 +1902,66 @@ function filterEmbeddedDiff(rawDiff) {
1806
1902
  return { filtered: kept.join(""), skippedFiles };
1807
1903
  }
1808
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
+
1809
1965
  // src/review-recovery.ts
1810
1966
  import { Value } from "@sinclair/typebox/value";
1811
1967
  var SUBMIT_REVIEW_RECOVERY_ATTEMPTS = 2;
@@ -1963,10 +2119,7 @@ async function reviewPr(opts) {
1963
2119
  );
1964
2120
  }
1965
2121
  }
1966
- const thinkingLevel = mapReasoningEffort(reasoningEffort) ?? getDefaultReasoningEffortForModel(piModel);
1967
- if (!reasoningEffort && thinkingLevel) {
1968
- logger.info(`Default reasoning effort for ${piModel.name}: ${thinkingLevel}`);
1969
- }
2122
+ const modelDefaultThinkingLevel = getDefaultReasoningEffortForModel(piModel);
1970
2123
  if (parsed.provider !== "amazon-bedrock") {
1971
2124
  const resolvedKey = await modelRegistry.getApiKeyForProvider(parsed.provider);
1972
2125
  if (!resolvedKey) {
@@ -2072,26 +2225,84 @@ async function reviewPr(opts) {
2072
2225
  logger.warn(`Failed to fetch Gitea metadata: ${err}`);
2073
2226
  }
2074
2227
  }
2075
- const previousReviewSha = full ? null : await findLatestValidReviewSha(mrMetadata?.Notes, workspacePath);
2076
- if (full) {
2077
- logger.info("Full review mode: ignoring previous hodor reviews, diffing entire source-vs-target range");
2078
- } else if (previousReviewSha) {
2079
- logger.info(`Incremental mode: previous review at ${previousReviewSha.slice(0, 8)}`);
2080
- }
2081
2228
  let headSha = null;
2082
2229
  if (!localMode) {
2083
2230
  const { stdout: headShaRaw } = await exec("git", ["rev-parse", "HEAD"], { cwd: workspacePath });
2084
2231
  headSha = headShaRaw.trim();
2085
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
+ }
2086
2291
  const MAX_EMBED_BYTES = 200 * 1024;
2087
2292
  let embeddedDiff = null;
2293
+ let reviewDiff = null;
2294
+ let diffStats = null;
2295
+ let changedFiles = [];
2088
2296
  try {
2089
- 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`];
2090
2298
  const { stdout: rawDiff } = await exec("git", diffArgs, { cwd: workspacePath });
2091
2299
  const { filtered: filteredDiff, skippedFiles } = filterEmbeddedDiff(rawDiff);
2092
2300
  if (skippedFiles.length > 0) {
2093
2301
  logger.info(`Filtered ${skippedFiles.length} file(s) from embedded diff: ${skippedFiles.join(", ")}`);
2094
2302
  }
2303
+ reviewDiff = filteredDiff;
2304
+ diffStats = getDiffStats(filteredDiff);
2305
+ changedFiles = getChangedFiles(filteredDiff);
2095
2306
  if (Buffer.byteLength(filteredDiff, "utf-8") <= MAX_EMBED_BYTES) {
2096
2307
  embeddedDiff = filteredDiff;
2097
2308
  logger.info(`Embedding diff in prompt (${Buffer.byteLength(filteredDiff, "utf-8")} bytes, raw: ${Buffer.byteLength(rawDiff, "utf-8")} bytes)`);
@@ -2101,6 +2312,17 @@ async function reviewPr(opts) {
2101
2312
  } catch (err) {
2102
2313
  logger.warn(`Failed to pre-fetch diff, falling back to command mode: ${err}`);
2103
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
+ }
2104
2326
  const prompt = buildPrReviewPrompt({
2105
2327
  prUrl: prUrl ?? `local diff (against ${targetBranch})`,
2106
2328
  platform,
@@ -2111,6 +2333,8 @@ async function reviewPr(opts) {
2111
2333
  customPromptFile: promptFile,
2112
2334
  embeddedDiff,
2113
2335
  previousReviewSha,
2336
+ reviewDiffMode: reviewMode,
2337
+ changedFiles,
2114
2338
  localMode
2115
2339
  });
2116
2340
  const startTime = Date.now();
@@ -2342,14 +2566,47 @@ async function reviewPr(opts) {
2342
2566
  cost,
2343
2567
  turns: turnCount,
2344
2568
  toolCalls: toolCallCount,
2345
- 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
2346
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
+ })}`);
2347
2595
  printMetrics(metrics);
2348
2596
  let metricsFooter = null;
2349
2597
  if (includeMetricsFooter) {
2350
2598
  metricsFooter = formatMetricsMarkdown(metrics);
2351
2599
  }
2352
- 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
+ };
2353
2610
  } finally {
2354
2611
  activeSession?.dispose();
2355
2612
  for (const [key, val] of Object.entries(envSnapshot)) {
@@ -2382,4 +2639,4 @@ export {
2382
2639
  postReviewStructured,
2383
2640
  reviewPr
2384
2641
  };
2385
- //# sourceMappingURL=chunk-AIXGPWDG.js.map
2642
+ //# sourceMappingURL=chunk-N7TSXVUE.js.map