@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 +6 -1
- package/dist/{chunk-ZUYM6HY3.js → chunk-N7TSXVUE.js} +306 -52
- package/dist/chunk-N7TSXVUE.js.map +1 -0
- package/dist/{chunk-LO2QJD45.js → chunk-W7ZUJIFT.js} +3 -2
- package/dist/chunk-W7ZUJIFT.js.map +1 -0
- package/dist/cli.js +52 -27
- package/dist/cli.js.map +1 -1
- package/dist/{gitlab-OMV6OJ5Q.js → gitlab-L3VGOT6Q.js} +2 -2
- package/dist/index.d.ts +14 -0
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/templates/tool-review.md +2 -2
- package/dist/chunk-LO2QJD45.js.map +0 -1
- package/dist/chunk-ZUYM6HY3.js.map +0 -1
- /package/dist/{gitlab-OMV6OJ5Q.js.map → gitlab-L3VGOT6Q.js.map} +0 -0
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
|
|
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-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
333
|
-
return
|
|
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(`
|
|
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:
|
|
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
|
|
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
|
-
|
|
1772
|
-
|
|
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
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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)};
|
|
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
|
|
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
|
-
|
|
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-
|
|
2642
|
+
//# sourceMappingURL=chunk-N7TSXVUE.js.map
|