@weareikko/code-review 0.8.4 → 0.8.5

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.
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import nodeFs, { existsSync, readFileSync } from "node:fs";
5
5
  import { getEnvApiKey, getModel } from "@earendil-works/pi-ai";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { tracingChannel } from "node:diagnostics_channel";
@@ -10,6 +10,9 @@ import { execFile } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
11
  import { Agent } from "@earendil-works/pi-agent-core";
12
12
  import { createReadOnlyTools } from "@earendil-works/pi-coding-agent";
13
+ import { createTwoFilesPatch } from "diff";
14
+ import * as git from "isomorphic-git";
15
+ import { Type } from "typebox";
13
16
  import { homedir } from "node:os";
14
17
  import { parse } from "yaml";
15
18
  import { SpanKind, SpanStatusCode, context, metrics, trace } from "@opentelemetry/api";
@@ -548,7 +551,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
548
551
  return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
549
552
  }
550
553
  function buildReviewedCommitFooter(commitSha) {
551
- return `Reviewed by ${PRODUCT_LINK} v0.8.4 for commit ${commitSha}.`;
554
+ return `Reviewed by ${PRODUCT_LINK} v0.8.5 for commit ${commitSha}.`;
552
555
  }
553
556
  function extractReviewedCommitSha(body) {
554
557
  return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
@@ -800,6 +803,12 @@ var REVIEW_DEPTHS = [
800
803
  "verify",
801
804
  "full"
802
805
  ];
806
+ var REVIEW_INPUT_MODES = [
807
+ "auto",
808
+ "inline",
809
+ "disk",
810
+ "commits"
811
+ ];
803
812
  var THINKING_LEVELS = [
804
813
  "off",
805
814
  "minimal",
@@ -1211,6 +1220,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
1211
1220
  minSeverity: normalizeChoice(args.minSeverity ?? env.CODE_REVIEW_MIN_SEVERITY ?? "info"),
1212
1221
  thinkingLevel: normalizeChoice(args.thinking ?? env.CODE_REVIEW_THINKING_LEVEL ?? "off"),
1213
1222
  reviewDepth: normalizeChoice(args.reviewDepth ?? env.CODE_REVIEW_DEPTH ?? "single"),
1223
+ inputMode: normalizeChoice(args.inputMode ?? env.CODE_REVIEW_INPUT_MODE ?? "auto"),
1214
1224
  verifyModel: String(args.verifyModel ?? env.CODE_REVIEW_VERIFY_MODEL ?? ""),
1215
1225
  postingMode: normalizeChoice(args.postingMode ?? env.CODE_REVIEW_POSTING_MODE ?? "direct"),
1216
1226
  apiKey,
@@ -1268,6 +1278,7 @@ function validateConfig(config) {
1268
1278
  ].includes(config.minSeverity)) throw new ConfigError("--min-severity must be one of: info, warn, critical");
1269
1279
  if (!THINKING_LEVELS.includes(config.thinkingLevel)) throw new ConfigError(`--thinking must be one of: ${THINKING_LEVELS.join(", ")}`);
1270
1280
  if (!REVIEW_DEPTHS.includes(config.reviewDepth)) throw new ConfigError(`--review-depth must be one of: ${REVIEW_DEPTHS.join(", ")}`);
1281
+ if (config.inputMode !== void 0 && !REVIEW_INPUT_MODES.includes(config.inputMode)) throw new ConfigError(`--input-mode must be one of: ${REVIEW_INPUT_MODES.join(", ")}`);
1271
1282
  if (!POSTING_MODES.includes(config.postingMode)) throw new ConfigError(`--posting-mode must be one of: ${POSTING_MODES.join(", ")}`);
1272
1283
  }
1273
1284
  //#endregion
@@ -1401,7 +1412,7 @@ function gitErrorMessage(error) {
1401
1412
  err.stdout
1402
1413
  ].filter(Boolean).join("\n").trim();
1403
1414
  }
1404
- async function git(args, options = {}) {
1415
+ async function git$1(args, options = {}) {
1405
1416
  try {
1406
1417
  const { stdout } = await exec$1("git", args, {
1407
1418
  cwd: options.cwd,
@@ -1443,7 +1454,7 @@ function getMergeDiffCommand(targetBranch, options = {}) {
1443
1454
  ];
1444
1455
  }
1445
1456
  async function fetchBranch(remote, branch, options) {
1446
- await git([
1457
+ await git$1([
1447
1458
  "fetch",
1448
1459
  "--no-tags",
1449
1460
  remote,
@@ -1452,7 +1463,7 @@ async function fetchBranch(remote, branch, options) {
1452
1463
  }
1453
1464
  async function isTracked(path, options) {
1454
1465
  try {
1455
- await git([
1466
+ await git$1([
1456
1467
  "ls-files",
1457
1468
  "--error-unmatch",
1458
1469
  "--",
@@ -1479,7 +1490,7 @@ async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_A
1479
1490
  async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
1480
1491
  const remote = options.remote ?? "origin";
1481
1492
  await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
1482
- await git([
1493
+ await git$1([
1483
1494
  "fetch",
1484
1495
  "--unshallow",
1485
1496
  "--no-tags",
@@ -1493,7 +1504,7 @@ async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
1493
1504
  }
1494
1505
  if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
1495
1506
  try {
1496
- await git([
1507
+ await git$1([
1497
1508
  "merge-base",
1498
1509
  remoteRef(remote, targetBranch),
1499
1510
  "HEAD"
@@ -1507,7 +1518,7 @@ async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
1507
1518
  }
1508
1519
  }
1509
1520
  async function getMergeDiff(targetBranch, options = {}) {
1510
- return git(getMergeDiffCommand(targetBranch, options), options);
1521
+ return git$1(getMergeDiffCommand(targetBranch, options), options);
1511
1522
  }
1512
1523
  function getMergeCommitLogArguments(targetBranch, options = {}) {
1513
1524
  return [
@@ -1518,7 +1529,7 @@ function getMergeCommitLogArguments(targetBranch, options = {}) {
1518
1529
  ];
1519
1530
  }
1520
1531
  async function getMergeCommitLog(targetBranch, options = {}) {
1521
- return git(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
1532
+ return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
1522
1533
  }
1523
1534
  /**
1524
1535
  * Summarize a unified diff into file/line counts for telemetry. Counts one file
@@ -1546,6 +1557,171 @@ function summarizeDiff(diff) {
1546
1557
  };
1547
1558
  }
1548
1559
  //#endregion
1560
+ //#region src/git-tool.ts
1561
+ /**
1562
+ * Custom read-only git tools for the reviewer agent (Mode C: commit exploration).
1563
+ *
1564
+ * The reviewer's default toolbox (`createReadOnlyTools`) has no git access, and a
1565
+ * general bash tool is unsafe here — the reviewer processes attacker-controlled
1566
+ * MR content with CI credentials in the environment. These tools expose only
1567
+ * three read-only operations, backed by isomorphic-git (pure JS, no shell-out,
1568
+ * so there is no command-injection surface) and scoped to a single repo dir:
1569
+ *
1570
+ * - `git_log`: list commits (optionally the range since a base ref).
1571
+ * - `git_show`: a commit's message + unified diff against its first parent.
1572
+ * - `git_diff`: unified diff between two refs.
1573
+ *
1574
+ * Ref arguments are validated to sha/ref shapes; nothing is passed to a shell.
1575
+ */
1576
+ /** Accept 4-40 hex sha prefixes or conservative ref names (branch/tag/HEAD~n). */
1577
+ var REF_RE = /^[0-9a-zA-Z._/~^-]{1,120}$/;
1578
+ var MAX_PATCH_BYTES = 2e5;
1579
+ function assertRef(ref) {
1580
+ if (!REF_RE.test(ref)) throw new Error(`Invalid git ref: ${JSON.stringify(ref)}`);
1581
+ }
1582
+ function text(body) {
1583
+ return {
1584
+ content: [{
1585
+ type: "text",
1586
+ text: body
1587
+ }],
1588
+ details: void 0
1589
+ };
1590
+ }
1591
+ async function resolveOid(dir, fs, ref) {
1592
+ assertRef(ref);
1593
+ return git.resolveRef({
1594
+ fs,
1595
+ dir,
1596
+ ref
1597
+ }).catch(() => git.expandOid({
1598
+ fs,
1599
+ dir,
1600
+ oid: ref
1601
+ }));
1602
+ }
1603
+ function decode(data) {
1604
+ if (data.includes(0)) return null;
1605
+ return Buffer.from(data).toString("utf8");
1606
+ }
1607
+ async function readFileAt(dir, fs, oid, filepath) {
1608
+ try {
1609
+ const { blob } = await git.readBlob({
1610
+ fs,
1611
+ dir,
1612
+ oid,
1613
+ filepath
1614
+ });
1615
+ return decode(blob);
1616
+ } catch {
1617
+ return null;
1618
+ }
1619
+ }
1620
+ /** Unified diff of every changed file between two commit oids. */
1621
+ async function diffCommits(dir, fs, oldOid, newOid) {
1622
+ const trees = oldOid ? [git.TREE({ ref: oldOid }), git.TREE({ ref: newOid })] : [git.TREE({ ref: newOid })];
1623
+ const changed = await git.walk({
1624
+ fs,
1625
+ dir,
1626
+ trees,
1627
+ map: async (filepath, entries) => {
1628
+ if (filepath === ".") return void 0;
1629
+ if (!oldOid) {
1630
+ const [b] = entries;
1631
+ return b && await b.type() === "blob" ? filepath : void 0;
1632
+ }
1633
+ const [a, b] = entries;
1634
+ if ((a ? await a.oid() : void 0) === (b ? await b.oid() : void 0)) return void 0;
1635
+ const aType = a ? await a.type() : void 0;
1636
+ const bType = b ? await b.type() : void 0;
1637
+ if (aType === "tree" || bType === "tree") return void 0;
1638
+ return filepath;
1639
+ }
1640
+ });
1641
+ const patches = [];
1642
+ let patchBytes = 0;
1643
+ for (const filepath of changed.sort()) {
1644
+ const before = oldOid ? await readFileAt(dir, fs, oldOid, filepath) ?? "" : "";
1645
+ const after = await readFileAt(dir, fs, newOid, filepath) ?? "";
1646
+ if (before === after) continue;
1647
+ const patch = createTwoFilesPatch(`a/${filepath}`, `b/${filepath}`, before, after);
1648
+ patches.push(patch);
1649
+ patchBytes += patch.length + 1;
1650
+ if (patchBytes > MAX_PATCH_BYTES) {
1651
+ patches.push(`\n[diff truncated at ${MAX_PATCH_BYTES} bytes — open the file directly for the rest]`);
1652
+ break;
1653
+ }
1654
+ }
1655
+ return patches.join("\n") || "(no textual changes)";
1656
+ }
1657
+ /**
1658
+ * Build the read-only git tools scoped to `dir` (the repo root). Returns an empty
1659
+ * array when `dir` is not a git repository, so callers can wire it
1660
+ * unconditionally.
1661
+ */
1662
+ function createGitTools(dir, options = {}) {
1663
+ const fs = options.fs ?? nodeFs;
1664
+ if (!fs.existsSync(join(dir, ".git"))) return [];
1665
+ return [
1666
+ {
1667
+ name: "git_log",
1668
+ label: "git log",
1669
+ description: "List commits in this repository (newest first). Optionally pass `since` (a ref/sha) to list only commits after it — the range you have not reviewed yet.",
1670
+ parameters: Type.Object({
1671
+ since: Type.Optional(Type.String({ description: "Only list commits after this ref/sha (exclusive)." })),
1672
+ maxCount: Type.Optional(Type.Number({ description: "Maximum number of commits to return (default 50)." }))
1673
+ }),
1674
+ async execute(_id, params) {
1675
+ const depth = Math.min(Math.max(params.maxCount ?? 50, 1), 500);
1676
+ const commits = await git.log({
1677
+ fs,
1678
+ dir,
1679
+ depth
1680
+ });
1681
+ let stop = -1;
1682
+ if (params.since) {
1683
+ const sinceOid = await resolveOid(dir, fs, params.since);
1684
+ stop = commits.findIndex((c) => c.oid === sinceOid);
1685
+ if (stop < 0) return text(`(base ref ${params.since} is not within the ${depth} most recent commits — re-run git_log with a larger maxCount to cover the full unreviewed range)`);
1686
+ }
1687
+ const list = (stop >= 0 ? commits.slice(0, stop) : commits).map((c) => {
1688
+ const subject = c.commit.message.split("\n", 1)[0];
1689
+ return `${c.oid.slice(0, 10)} ${subject} (${c.commit.author.name})`;
1690
+ });
1691
+ return text(list.length ? list.join("\n") : "(no commits in range)");
1692
+ }
1693
+ },
1694
+ {
1695
+ name: "git_show",
1696
+ label: "git show",
1697
+ description: "Show a commit's message and its unified diff against its first parent. Pass `ref` (a sha or ref).",
1698
+ parameters: Type.Object({ ref: Type.String({ description: "The commit sha or ref to show." }) }),
1699
+ async execute(_id, params) {
1700
+ const oid = await resolveOid(dir, fs, params.ref);
1701
+ const { commit } = await git.readCommit({
1702
+ fs,
1703
+ dir,
1704
+ oid
1705
+ });
1706
+ const diff = await diffCommits(dir, fs, commit.parent[0] ?? null, oid);
1707
+ return text(`commit ${oid}\nAuthor: ${commit.author.name} <${commit.author.email}>\n\n${commit.message.trim()}\n\n${diff}`);
1708
+ }
1709
+ },
1710
+ {
1711
+ name: "git_diff",
1712
+ label: "git diff",
1713
+ description: "Unified diff between two refs/shas. Pass `from` and `to`.",
1714
+ parameters: Type.Object({
1715
+ from: Type.String({ description: "Base ref/sha." }),
1716
+ to: Type.String({ description: "Target ref/sha." })
1717
+ }),
1718
+ async execute(_id, params) {
1719
+ return text(await diffCommits(dir, fs, await resolveOid(dir, fs, params.from), await resolveOid(dir, fs, params.to)));
1720
+ }
1721
+ }
1722
+ ];
1723
+ }
1724
+ //#endregion
1549
1725
  //#region src/logger.ts
1550
1726
  var LEVELS = {
1551
1727
  debug: 0,
@@ -2228,11 +2404,12 @@ function atEndOfBlockComment(text, i) {
2228
2404
  }
2229
2405
  //#endregion
2230
2406
  //#region src/verify.ts
2231
- function buildVerifySystemPrompt(diff, commitLog) {
2407
+ function buildVerifySystemPrompt(diff, commitLog, staged) {
2408
+ const diskMode = staged !== void 0 && staged.length > 0;
2232
2409
  const parts = [
2233
2410
  "You are a strict, adversarial verifier of a SINGLE code-review finding. Your job is to REFUTE the finding, not to agree with it.",
2234
2411
  "",
2235
- "Each request gives you one proposed finding (file, line, severity, confidence, and body) to check against the diff below. You may read referenced files to confirm reachability. Decide whether the finding survives scrutiny.",
2412
+ diskMode ? "Each request gives you one proposed finding (file, line, severity, confidence, and body). The change is NOT inline below — every file diff is staged on disk (see <staged_files>). Open the finding's file (and any others you need) with your file-read tool to check reachability. Decide whether the finding survives scrutiny." : "Each request gives you one proposed finding (file, line, severity, confidence, and body) to check against the diff below. You may read referenced files to confirm reachability. Decide whether the finding survives scrutiny.",
2236
2413
  "",
2237
2414
  "Apply this bar:",
2238
2415
  "- The finding must point to a concrete defect demonstrable from the diff (and any file you read): a specific input, state, or execution path triggers it, and a violated contract is visible.",
@@ -2248,7 +2425,10 @@ function buildVerifySystemPrompt(diff, commitLog) {
2248
2425
  "- \"drop\": not a real defect — speculative, stylistic, contradicted by the code/comments, or based on external state not visible in the diff."
2249
2426
  ];
2250
2427
  if (commitLog?.trim()) parts.push("", `Commit messages for this change (oldest first):\n<commits>\n${commitLog.trim()}\n</commits>`);
2251
- parts.push("", `Verify each finding against this diff:\n<diff>\n${diff}\n</diff>`);
2428
+ if (diskMode) {
2429
+ const list = staged.map((f) => `- ${f.path} → ${f.diskPath}`).join("\n");
2430
+ parts.push("", `The change is staged on disk — open the relevant file(s) to verify:\n<staged_files>\n${list}\n</staged_files>`);
2431
+ } else parts.push("", `Verify each finding against this diff:\n<diff>\n${diff}\n</diff>`);
2252
2432
  return parts.join("\n");
2253
2433
  }
2254
2434
  function buildVerifyUserPrompt(comment) {
@@ -3037,18 +3217,18 @@ function gitSkillCacheKey(url, ref) {
3037
3217
  * remote's default branch via `HEAD`.
3038
3218
  */
3039
3219
  async function gitShallowClone(url, ref, dir) {
3040
- await git([
3220
+ await git$1([
3041
3221
  "init",
3042
3222
  "--quiet",
3043
3223
  dir
3044
3224
  ]);
3045
- await git([
3225
+ await git$1([
3046
3226
  "remote",
3047
3227
  "add",
3048
3228
  "origin",
3049
3229
  url
3050
3230
  ], { cwd: dir });
3051
- await git([
3231
+ await git$1([
3052
3232
  "fetch",
3053
3233
  "--depth",
3054
3234
  "1",
@@ -3056,7 +3236,7 @@ async function gitShallowClone(url, ref, dir) {
3056
3236
  "origin",
3057
3237
  ref || "HEAD"
3058
3238
  ], { cwd: dir });
3059
- await git([
3239
+ await git$1([
3060
3240
  "checkout",
3061
3241
  "--quiet",
3062
3242
  "FETCH_HEAD"
@@ -3198,11 +3378,18 @@ async function cleanupSkippedDiffs(cwd) {
3198
3378
  });
3199
3379
  }
3200
3380
  /**
3201
- * Render the `<skipped_files>` block for the retrieval mode: each dropped file
3202
- * with its on-disk diff path and an instruction to read the highest-risk ones.
3381
+ * Render the `<skipped_files>` block: each file with its on-disk diff path and an
3382
+ * instruction to read them.
3383
+ *
3384
+ * - `partial` (retrieval mode): only budget-dropped files are staged; the rest
3385
+ * of the diff is inline, so the agent reads the highest-risk staged files.
3386
+ * - `full` (disk input mode): the ENTIRE change is staged — nothing is inline —
3387
+ * so reading is not optional; the review is only as good as what gets opened.
3203
3388
  */
3204
- function renderRetrievableSkippedBlock(files) {
3205
- return `<skipped_files>\n${files.map((f) => `- ${f.path} → ${f.diskPath}`).join("\n")}\n</skipped_files>\nThese files exceeded the inline size budget, so their diffs are NOT in the prompt above — but each is staged on disk at the path shown. Use your file-read tool to open the diffs most likely to contain defects (start with source files over config/tests) and review them as if they were inline. You may not have budget to read them all; say in your summary which you reviewed and which you did not.`;
3389
+ function renderRetrievableSkippedBlock(files, mode = "partial") {
3390
+ const list = files.map((f) => `- ${f.path} → ${f.diskPath}`).join("\n");
3391
+ if (mode === "full") return `<skipped_files>\n${list}\n</skipped_files>\nThis review has NO diff inline — every changed file's diff is staged on disk at the path shown, and the list above is the complete change. Use your file-read tool to open and review them; a file you do not open is a file you did not review. Prioritise source files over config/tests/generated, but aim to cover the whole change. In your summary, state explicitly which files you reviewed and which you did not open.`;
3392
+ return `<skipped_files>\n${list}\n</skipped_files>\nThese files exceeded the inline size budget, so their diffs are NOT in the prompt above — but each is staged on disk at the path shown. Use your file-read tool to open the diffs most likely to contain defects (start with source files over config/tests) and review them as if they were inline. You may not have budget to read them all; say in your summary which you reviewed and which you did not.`;
3206
3393
  }
3207
3394
  //#endregion
3208
3395
  //#region src/triage.ts
@@ -3591,13 +3778,22 @@ function filterDiff(raw, maxChars = DEFAULT_MAX_DIFF_CHARS) {
3591
3778
  totalChars += section.length;
3592
3779
  reviewedChangedLines += changedLines;
3593
3780
  }
3781
+ const allSections = kept.map((section) => {
3782
+ const filePath = parseFilePath(section.split("\n", 1)[0] ?? "");
3783
+ return filePath ? {
3784
+ path: filePath,
3785
+ section,
3786
+ changedLines: countChangedLines(section)
3787
+ } : null;
3788
+ }).filter((entry) => entry !== null);
3594
3789
  return {
3595
3790
  diff: included.join(""),
3596
3791
  noiseSkippedFiles,
3597
3792
  sizeSkippedFiles,
3598
3793
  reviewedChangedLines,
3599
3794
  skippedChangedLines,
3600
- sizeSkippedSections
3795
+ sizeSkippedSections,
3796
+ allSections
3601
3797
  };
3602
3798
  }
3603
3799
  function mergeContent(files) {
@@ -3803,13 +3999,23 @@ function buildAngleSystemPrompt(context, minSeverity, angle) {
3803
3999
  "</review_angle>"
3804
4000
  ].join("\n");
3805
4001
  }
3806
- function buildUserPrompt(diff, skippedFiles = [], commitLog, priorThreads, intent, coverage, retrievableSkipped) {
4002
+ function buildUserPrompt(diff, skippedFiles = [], commitLog, priorThreads, intent, coverage, retrievableSkipped, omitInlineDiff = false, commitExploration) {
3807
4003
  const parts = [];
3808
4004
  const intentBlock = renderIntentBlock(intent);
3809
4005
  if (intentBlock) parts.push(`The author described the purpose of this change below. Use it as context for reading the diff. If the code omits something promised or adds something never claimed, note it in one line of the summary — do not raise inline findings on the description text itself:\n${intentBlock}`);
3810
4006
  if (commitLog?.trim()) parts.push(`Commits in this MR (oldest first):\n<commits>\n${commitLog.trim()}\n</commits>`);
3811
- parts.push(`Review this diff:\n<diff>\n${diff}\n</diff>`);
3812
- if (retrievableSkipped && retrievableSkipped.length > 0) parts.push(renderRetrievableSkippedBlock(retrievableSkipped));
4007
+ if (commitExploration) {
4008
+ const since = commitExploration.sinceRef;
4009
+ parts.push([
4010
+ "This review has NO diff inline. Explore the change with the read-only git tools:",
4011
+ since ? `- \`git_log\` with since="${since}" lists the commits you have NOT reviewed yet (everything after ${since}). Review exactly those commits.` : "- `git_log` lists the commits in this change. Review all of them.",
4012
+ "- `git_show <sha>` shows a commit's message and full diff. Open every commit in scope.",
4013
+ "- `git_diff <from> <to>` shows the combined diff between two points if you prefer to review the range at once.",
4014
+ "A commit you do not open is code you did not review. In your summary, state which commits you reviewed."
4015
+ ].join("\n"));
4016
+ }
4017
+ if (!omitInlineDiff && !commitExploration) parts.push(`Review this diff:\n<diff>\n${diff}\n</diff>`);
4018
+ if (retrievableSkipped && retrievableSkipped.length > 0) parts.push(renderRetrievableSkippedBlock(retrievableSkipped, omitInlineDiff ? "full" : "partial"));
3813
4019
  else if (skippedFiles.length > 0) parts.push(`<skipped_files>\n${skippedFiles.map((file) => `- ${file}`).join("\n")}\n</skipped_files>\nThe above files were not included because the diff exceeded the size limit. They are already surfaced to the reader in the MR summary, so do not re-list them; just do not assume they are clean, since you did not see them.`);
3814
4020
  if (coverage && coverage.totalLines > 0 && coverage.reviewedLines < coverage.totalLines) {
3815
4021
  const pct = Math.round(coverage.reviewedLines / coverage.totalLines * 100);
@@ -4029,33 +4235,86 @@ function accumulateUsage(target, message, modelId) {
4029
4235
  addUsageToBucket(bucket, message);
4030
4236
  }
4031
4237
  }
4238
+ /**
4239
+ * The "consider decomposing this MR" hint: present when a threshold is set
4240
+ * (`> 0`) and the change exceeds it. `changedLines` is the count the caller
4241
+ * deems reviewed — the budget-fitted subset for inline mode, the whole change
4242
+ * for disk/commits mode.
4243
+ */
4244
+ function resolveDecomposeHint(threshold, reviewedLines) {
4245
+ return threshold > 0 && reviewedLines > threshold ? {
4246
+ lines: reviewedLines,
4247
+ threshold
4248
+ } : void 0;
4249
+ }
4032
4250
  async function runReview(config, options) {
4033
4251
  const cwd = options.cwd ?? config.cwd;
4034
4252
  const minSeverity = toGitLabReviewSeverity(config.minSeverity);
4035
4253
  const logger = options.logger ?? noopLogger;
4254
+ const inputMode = config.inputMode ?? "auto";
4255
+ const gitTools = createGitTools(cwd);
4256
+ let commitsMode = inputMode === "commits";
4257
+ if (commitsMode && gitTools.length === 0) {
4258
+ logger.warn("Commit-exploration input mode requires a git checkout, but none was found in cwd; falling back to auto input mode.");
4259
+ commitsMode = false;
4260
+ }
4261
+ const effectiveMode = commitsMode ? "commits" : inputMode === "commits" ? "auto" : inputMode;
4036
4262
  const maxDiffChars = config.maxDiffChars > 0 ? config.maxDiffChars : DEFAULT_MAX_DIFF_CHARS;
4037
- const { diff, noiseSkippedFiles, sizeSkippedFiles, reviewedChangedLines, skippedChangedLines, sizeSkippedSections } = filterDiff(options.diff, maxDiffChars);
4038
- if (!diff.trim()) throw new ReviewerError("No reviewable diff content after filtering noise files.", { hint: "Ensure the merge request introduces changes outside of generated/lock files." });
4039
- const skippedFiles = [...sizeSkippedFiles.map((f) => f.path), ...noiseSkippedFiles];
4040
- const decomposeHint = config.decomposeHintLines > 0 && reviewedChangedLines > config.decomposeHintLines ? {
4041
- lines: reviewedChangedLines,
4042
- threshold: config.decomposeHintLines
4043
- } : void 0;
4044
- const coverage = sizeSkippedFiles.length > 0 ? {
4045
- reviewedLines: reviewedChangedLines,
4046
- totalLines: reviewedChangedLines + skippedChangedLines
4047
- } : void 0;
4048
- const retrievableSkipped = config.retrieveSkipped && sizeSkippedSections.length > 0 ? await writeSkippedDiffs(cwd, sizeSkippedSections) : [];
4049
- if (retrievableSkipped.length > 0) logger.info(`Staged ${retrievableSkipped.length} dropped-file diff(s) on disk for retrieval.`);
4050
- const sizeNotice = {
4051
- sizeSkippedFiles,
4052
- decomposeHint,
4053
- coverage,
4054
- retrieved: retrievableSkipped.length > 0
4055
- };
4263
+ const { diff, noiseSkippedFiles, sizeSkippedFiles, reviewedChangedLines, skippedChangedLines, sizeSkippedSections, allSections } = filterDiff(options.diff, maxDiffChars);
4264
+ const overflowed = sizeSkippedSections.length > 0;
4265
+ const diskMode = effectiveMode === "disk" || effectiveMode === "auto" && overflowed;
4266
+ if (effectiveMode === "auto") logger.info(diskMode ? `Auto input mode: diff exceeds the ${maxDiffChars}-char budget — staging all files on disk for the agent to read.` : "Auto input mode: diff fits the budget — reviewing inline.");
4267
+ if (!(diskMode || commitsMode ? allSections.length > 0 : diff.trim().length > 0)) throw new ReviewerError("No reviewable diff content after filtering noise files.", { hint: "Ensure the merge request introduces changes outside of generated/lock files." });
4268
+ const decomposeHint = resolveDecomposeHint(config.decomposeHintLines, diskMode || commitsMode ? reviewedChangedLines + skippedChangedLines : reviewedChangedLines);
4269
+ let promptDiff;
4270
+ let promptSkippedFiles;
4271
+ let promptCoverage;
4272
+ let retrievableSkipped;
4273
+ let sizeNotice;
4274
+ if (commitsMode) {
4275
+ retrievableSkipped = [];
4276
+ promptDiff = "";
4277
+ promptSkippedFiles = [];
4278
+ promptCoverage = void 0;
4279
+ sizeNotice = {
4280
+ sizeSkippedFiles: [],
4281
+ decomposeHint,
4282
+ retrieved: false
4283
+ };
4284
+ logger.info(options.sinceRef ? `Commit-exploration input mode: reviewing commits since ${options.sinceRef}.` : "Commit-exploration input mode: reviewing the full commit range.");
4285
+ } else if (diskMode) {
4286
+ retrievableSkipped = allSections.length > 0 ? await writeSkippedDiffs(cwd, allSections.map(({ path, section }) => ({
4287
+ path,
4288
+ section
4289
+ }))) : [];
4290
+ if (retrievableSkipped.length > 0) logger.info(`Disk input mode: staged ${retrievableSkipped.length} file diff(s) for on-demand review.`);
4291
+ promptDiff = "";
4292
+ promptSkippedFiles = [];
4293
+ promptCoverage = void 0;
4294
+ sizeNotice = {
4295
+ sizeSkippedFiles: [],
4296
+ decomposeHint,
4297
+ retrieved: false
4298
+ };
4299
+ } else {
4300
+ promptSkippedFiles = [...sizeSkippedFiles.map((f) => f.path), ...noiseSkippedFiles];
4301
+ promptCoverage = sizeSkippedFiles.length > 0 ? {
4302
+ reviewedLines: reviewedChangedLines,
4303
+ totalLines: reviewedChangedLines + skippedChangedLines
4304
+ } : void 0;
4305
+ retrievableSkipped = config.retrieveSkipped && sizeSkippedSections.length > 0 ? await writeSkippedDiffs(cwd, sizeSkippedSections) : [];
4306
+ if (retrievableSkipped.length > 0) logger.info(`Staged ${retrievableSkipped.length} dropped-file diff(s) on disk for retrieval.`);
4307
+ promptDiff = diff;
4308
+ sizeNotice = {
4309
+ sizeSkippedFiles,
4310
+ decomposeHint,
4311
+ coverage: promptCoverage,
4312
+ retrieved: retrievableSkipped.length > 0
4313
+ };
4314
+ }
4056
4315
  const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), { refreshGitSkills: config.refreshGitSkills });
4057
4316
  const systemPrompt = buildJSONSystemPrompt(context, minSeverity);
4058
- const userPrompt = buildUserPrompt(diff, skippedFiles, options.commitLog, options.priorThreads, options.intent, coverage, retrievableSkipped);
4317
+ const userPrompt = buildUserPrompt(promptDiff, promptSkippedFiles, options.commitLog, options.priorThreads, options.intent, promptCoverage, retrievableSkipped, diskMode, commitsMode ? { sinceRef: options.sinceRef } : void 0);
4059
4318
  const skillNames = context.skills.map((s) => s.name);
4060
4319
  if (skillNames.length > 0) logger.debug(`Skills loaded: ${skillNames.join(", ")}`);
4061
4320
  if (context.conventions.length > 0) logger.debug(`Conventions: ${context.conventions.map((f) => f.path).join(", ")}`);
@@ -4063,7 +4322,7 @@ async function runReview(config, options) {
4063
4322
  const pool = buildEffectivePool(config, logger);
4064
4323
  if (pool.length > 1) logger.info(`Model pool: ${pool.map((m) => m.id).join(", ")}.`);
4065
4324
  const primary = pool[0];
4066
- const tools = createReadOnlyTools(cwd);
4325
+ const tools = [...createReadOnlyTools(cwd), ...gitTools];
4067
4326
  const createAgent = options.createAgent ?? defaultCreateAgent;
4068
4327
  const timeoutMs = options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
4069
4328
  const aggregated = emptyUsage();
@@ -4075,7 +4334,9 @@ async function runReview(config, options) {
4075
4334
  timeoutMs,
4076
4335
  logger,
4077
4336
  aggregated,
4078
- verifyMember: resolveVerifyMember(config, primary, logger)
4337
+ verifyMember: resolveVerifyMember(config, primary, logger),
4338
+ attachTelemetry: options.attachTelemetry,
4339
+ verifyStaged: diskMode ? retrievableSkipped : void 0
4079
4340
  };
4080
4341
  let outputText;
4081
4342
  if (config.reviewDepth === "full") {
@@ -4241,6 +4502,7 @@ async function runMultiAngleFind(context, minSeverity, userPrompt, deps) {
4241
4502
  thinkingLevel: deps.thinkingLevel,
4242
4503
  getApiKey: member.getApiKey
4243
4504
  });
4505
+ const detachTelemetry = deps.attachTelemetry?.(agent);
4244
4506
  try {
4245
4507
  const parsed = parseReviewMarkdownWithWarnings(await runAgentToCompletion(agent, userPrompt, {
4246
4508
  timeoutMs: deps.timeoutMs,
@@ -4254,6 +4516,8 @@ async function runMultiAngleFind(context, minSeverity, userPrompt, deps) {
4254
4516
  summaries[index] = parsed.summary;
4255
4517
  } catch (error) {
4256
4518
  deps.logger.warn(`Find angle "${angle.key}" failed: ${error.message}; skipping.`);
4519
+ } finally {
4520
+ detachTelemetry?.();
4257
4521
  }
4258
4522
  }), FIND_CONCURRENCY);
4259
4523
  const raw = groups.reduce((total, group) => total + group.length, 0);
@@ -4279,7 +4543,7 @@ async function verifyAndSynthesize(findings, summary, diff, commitLog, deps) {
4279
4543
  })).filter(({ finding }) => finding.comment.severity === "critical" || finding.comment.severity === "warn");
4280
4544
  const verdicts = /* @__PURE__ */ new Map();
4281
4545
  if (severe.length > 0) {
4282
- const verifySystemPrompt = buildVerifySystemPrompt(diff, commitLog);
4546
+ const verifySystemPrompt = buildVerifySystemPrompt(diff, commitLog, deps.verifyStaged);
4283
4547
  await runBounded(severe.map(({ finding, index }) => async () => {
4284
4548
  const comment = finding.comment;
4285
4549
  const verifierMember = deps.verifyMember ?? pickVerifier(deps.pool, finding.authorModel);
@@ -4290,6 +4554,7 @@ async function verifyAndSynthesize(findings, summary, diff, commitLog, deps) {
4290
4554
  thinkingLevel: deps.thinkingLevel,
4291
4555
  getApiKey: verifierMember.getApiKey
4292
4556
  });
4557
+ const detachTelemetry = deps.attachTelemetry?.(verifier);
4293
4558
  try {
4294
4559
  const text = await runAgentToCompletion(verifier, buildVerifyUserPrompt(comment), {
4295
4560
  timeoutMs: deps.timeoutMs,
@@ -4302,6 +4567,8 @@ async function verifyAndSynthesize(findings, summary, diff, commitLog, deps) {
4302
4567
  decision: "keep",
4303
4568
  reason: "verifier error; finding kept"
4304
4569
  });
4570
+ } finally {
4571
+ detachTelemetry?.();
4305
4572
  }
4306
4573
  }), VERIFY_CONCURRENCY);
4307
4574
  }
@@ -4932,7 +5199,7 @@ async function loadDefaultRuntime() {
4932
5199
  const [sdkNode, resources, semconv] = modules;
4933
5200
  const serviceResource = resources.resourceFromAttributes({
4934
5201
  [semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
4935
- [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.8.4"
5202
+ [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.8.5"
4936
5203
  });
4937
5204
  process.env.OTEL_METRICS_EXPORTER = process.env.OTEL_METRICS_EXPORTER ?? "otlp";
4938
5205
  process.env.OTEL_LOGS_EXPORTER = process.env.OTEL_LOGS_EXPORTER ?? "otlp";
@@ -5364,7 +5631,7 @@ function boldCommentTitle(body) {
5364
5631
  */
5365
5632
  function buildCommentBody(body, commitSha, confidence) {
5366
5633
  const confidenceLine = `_Confidence: ${confidence}._`;
5367
- const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.8.4 for commit ${commitSha}.</sub>`;
5634
+ const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.8.5 for commit ${commitSha}.</sub>`;
5368
5635
  return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
5369
5636
  }
5370
5637
  function buildPayload(comment, body, refs, resolved) {
@@ -6099,6 +6366,11 @@ Options:
6099
6366
  --review-depth <depth> single (one pass), verify (adversarial re-check of each
6100
6367
  severe finding), or full (multi-angle finders → triage →
6101
6368
  verify). (default: single; env: CODE_REVIEW_DEPTH)
6369
+ --input-mode <mode> How the change is fed to the reviewer: auto (inline while it
6370
+ fits the budget, disk on overflow), inline (diff in the prompt),
6371
+ disk (every file diff staged on disk; the agent reads on demand),
6372
+ or commits (agent explores via read-only git tools).
6373
+ (default: auto; env: CODE_REVIEW_INPUT_MODE)
6102
6374
  --verify-model <p/id> Model for the Verify stage (verify/full depth). Pairs a cheap
6103
6375
  finder with a strong, high-precision verifier. Warns if it looks
6104
6376
  cheaper than --model. Empty (default) = pool selection.
@@ -6205,7 +6477,8 @@ async function run(config, bridges) {
6205
6477
  description: mr.description
6206
6478
  },
6207
6479
  logger,
6208
- attachTelemetry: bridges?.otel?.createAgentTelemetry(runId)
6480
+ attachTelemetry: bridges?.otel?.createAgentTelemetry(runId),
6481
+ sinceRef: config.inputMode === "commits" && reviewedCommitSha && reviewedCommitSha !== refs.head_sha ? reviewedCommitSha : void 0
6209
6482
  });
6210
6483
  context.usage = result;
6211
6484
  return result;
@@ -6452,10 +6725,10 @@ async function main(argv = process.argv.slice(2)) {
6452
6725
  return;
6453
6726
  }
6454
6727
  if (argv.includes("--version") || argv.includes("-v")) {
6455
- console.log("0.8.4");
6728
+ console.log("0.8.5");
6456
6729
  return;
6457
6730
  }
6458
- process.stderr.write(`[code-review] @weareikko/code-review v0.8.4\n`);
6731
+ process.stderr.write(`[code-review] @weareikko/code-review v0.8.5\n`);
6459
6732
  assertNodeVersion();
6460
6733
  applyCodeReviewEnvPrefix();
6461
6734
  applyDefaultCacheRetention();
@@ -6478,4 +6751,4 @@ if (isDirectRun()) main().catch((error) => {
6478
6751
  //#endregion
6479
6752
  export { normalizeBody as $, SUMMARY_HISTORY_END as A, buildSummaryHistoryEntries as B, createDiagnosticContext as C, traceDiagnosticPhase as D, traceDiagnostic as E, SUMMARY_MARKER as F, findExistingSummaryNoteId as G, extractSummaryHistoryEntries as H, buildArchivedSummaryEntry as I, upsertSummaryNote as J, stripSummaryHistory as K, buildReviewedCommitFooter as L, SUMMARY_HISTORY_ENTRY_START as M, SUMMARY_HISTORY_LIMIT as N, normalizeSeverity as O, SUMMARY_HISTORY_START as P, fingerprints as Q, buildSizeNoticeBlock as R, DIAGNOSTIC_CHANNEL_PREFIX as S, diagnosticChannels as T, findExistingReviewedCommitSha as U, extractReviewedCommitSha as V, findExistingSummaryNote as W, extractDiffHunkContext as X, appendFingerprintMarkers as Y, extractExistingFingerprints as Z, resolveNpmSkillDir as _, main as a, parseReviewMarkdownWithWarnings as b, buildGeneratedComments as c, startOtelBridge as d, sha256 as et, filterDiff as f, parseSkillSpec as g, loadNamedSkill as h, formatUsageLine as i, SUMMARY_HISTORY_ENTRY_END as j, toGitLabReviewSeverity as k, buildPayload as l, gitSkillCacheKey as m, formatPerModelUsage as n, run as o, runReview as p, stripSummaryMarker as q, formatSkillsFooter as r, withHttpStamping as s, countPostedBySeverity as t, isOtelEnabled as u, resolveSkillCacheDir as v, createDiagnosticRunId as w, DIAGNOSTIC_CHANNEL_NAMES as x, parseReviewMarkdown as y, buildSummaryBody as z };
6480
6753
 
6481
- //# sourceMappingURL=cli-FDpPZaNn.js.map
6754
+ //# sourceMappingURL=cli-DmzA9PxS.js.map