@weareikko/code-review 0.8.3 → 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";
@@ -512,7 +515,12 @@ function buildSizeNoticeBlock(notice) {
512
515
  if (sizeSkippedFiles.length > 0) {
513
516
  const fileList = sizeSkippedFiles.map((file) => `- \`${file.path}\` (${formatChars(file.chars)})`).join("\n");
514
517
  const cov = notice.coverage;
515
- const coverageLine = cov && cov.totalLines > 0 ? `> **Partial review — ~${Math.round(cov.reviewedLines / cov.totalLines * 100)}% of changed lines reviewed** (${cov.reviewedLines} of ${cov.totalLines}). The files below were NOT reviewed; their absence from the findings is not a clean bill of health.` : `> **${sizeSkippedFiles.length} file(s) were not reviewed** — the diff exceeded the size budget, so these files were dropped from the review:`;
518
+ const retrieved = notice.retrieved === true;
519
+ let coverageLine;
520
+ if (cov && cov.totalLines > 0) {
521
+ const pct = Math.round(cov.reviewedLines / cov.totalLines * 100);
522
+ coverageLine = retrieved ? `> **Large diff — ~${pct}% of changed lines fit the inline budget** (${cov.reviewedLines} of ${cov.totalLines}). The files below exceeded it and were staged for on-demand retrieval; see the review summary for which were read. Absence from the findings is not a clean bill of health.` : `> **Partial review — ~${pct}% of changed lines reviewed** (${cov.reviewedLines} of ${cov.totalLines}). The files below were NOT reviewed; their absence from the findings is not a clean bill of health.`;
523
+ } else coverageLine = retrieved ? `> **${sizeSkippedFiles.length} file(s) exceeded the size budget** — their diffs were staged for on-demand retrieval; see the review summary for which were read:` : `> **${sizeSkippedFiles.length} file(s) were not reviewed** — the diff exceeded the size budget, so these files were dropped from the review:`;
516
524
  blocks.push([
517
525
  `> [!WARNING]`,
518
526
  coverageLine,
@@ -543,7 +551,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
543
551
  return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
544
552
  }
545
553
  function buildReviewedCommitFooter(commitSha) {
546
- return `Reviewed by ${PRODUCT_LINK} v0.8.3 for commit ${commitSha}.`;
554
+ return `Reviewed by ${PRODUCT_LINK} v0.8.5 for commit ${commitSha}.`;
547
555
  }
548
556
  function extractReviewedCommitSha(body) {
549
557
  return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
@@ -795,6 +803,12 @@ var REVIEW_DEPTHS = [
795
803
  "verify",
796
804
  "full"
797
805
  ];
806
+ var REVIEW_INPUT_MODES = [
807
+ "auto",
808
+ "inline",
809
+ "disk",
810
+ "commits"
811
+ ];
798
812
  var THINKING_LEVELS = [
799
813
  "off",
800
814
  "minimal",
@@ -936,6 +950,7 @@ var BOOLEAN_FLAGS = new Set([
936
950
  "no-summary",
937
951
  "force-review",
938
952
  "retrieve-skipped",
953
+ "no-retrieve-skipped",
939
954
  "verbose",
940
955
  "help",
941
956
  "version"
@@ -994,6 +1009,22 @@ function resolvePostSummary(args, env) {
994
1009
  }
995
1010
  return true;
996
1011
  }
1012
+ function resolveRetrieveSkipped(args, env) {
1013
+ if (args.noRetrieveSkipped === true) return false;
1014
+ if (args.retrieveSkipped === true) return true;
1015
+ const raw = env.CODE_REVIEW_RETRIEVE_SKIPPED;
1016
+ if (typeof raw === "string") {
1017
+ const normalized = raw.trim().toLowerCase();
1018
+ if ([
1019
+ "0",
1020
+ "false",
1021
+ "no",
1022
+ "off"
1023
+ ].includes(normalized)) return false;
1024
+ if (normalized.length > 0) return true;
1025
+ }
1026
+ return true;
1027
+ }
997
1028
  function normalizeChoice(value) {
998
1029
  return String(value ?? "").trim().toLowerCase();
999
1030
  }
@@ -1189,6 +1220,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
1189
1220
  minSeverity: normalizeChoice(args.minSeverity ?? env.CODE_REVIEW_MIN_SEVERITY ?? "info"),
1190
1221
  thinkingLevel: normalizeChoice(args.thinking ?? env.CODE_REVIEW_THINKING_LEVEL ?? "off"),
1191
1222
  reviewDepth: normalizeChoice(args.reviewDepth ?? env.CODE_REVIEW_DEPTH ?? "single"),
1223
+ inputMode: normalizeChoice(args.inputMode ?? env.CODE_REVIEW_INPUT_MODE ?? "auto"),
1192
1224
  verifyModel: String(args.verifyModel ?? env.CODE_REVIEW_VERIFY_MODEL ?? ""),
1193
1225
  postingMode: normalizeChoice(args.postingMode ?? env.CODE_REVIEW_POSTING_MODE ?? "direct"),
1194
1226
  apiKey,
@@ -1197,7 +1229,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
1197
1229
  maxDiffChars,
1198
1230
  decomposeHintLines,
1199
1231
  diffContext,
1200
- retrieveSkipped: toBoolean(args.retrieveSkipped) || toBoolean(env.CODE_REVIEW_RETRIEVE_SKIPPED),
1232
+ retrieveSkipped: resolveRetrieveSkipped(args, env),
1201
1233
  reviewFile: String(args.reviewFile ?? "code-review.md"),
1202
1234
  output: String(args.output ?? "review-comments.json"),
1203
1235
  dryRun: toBoolean(args.dryRun),
@@ -1246,6 +1278,7 @@ function validateConfig(config) {
1246
1278
  ].includes(config.minSeverity)) throw new ConfigError("--min-severity must be one of: info, warn, critical");
1247
1279
  if (!THINKING_LEVELS.includes(config.thinkingLevel)) throw new ConfigError(`--thinking must be one of: ${THINKING_LEVELS.join(", ")}`);
1248
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(", ")}`);
1249
1282
  if (!POSTING_MODES.includes(config.postingMode)) throw new ConfigError(`--posting-mode must be one of: ${POSTING_MODES.join(", ")}`);
1250
1283
  }
1251
1284
  //#endregion
@@ -1379,7 +1412,7 @@ function gitErrorMessage(error) {
1379
1412
  err.stdout
1380
1413
  ].filter(Boolean).join("\n").trim();
1381
1414
  }
1382
- async function git(args, options = {}) {
1415
+ async function git$1(args, options = {}) {
1383
1416
  try {
1384
1417
  const { stdout } = await exec$1("git", args, {
1385
1418
  cwd: options.cwd,
@@ -1421,7 +1454,7 @@ function getMergeDiffCommand(targetBranch, options = {}) {
1421
1454
  ];
1422
1455
  }
1423
1456
  async function fetchBranch(remote, branch, options) {
1424
- await git([
1457
+ await git$1([
1425
1458
  "fetch",
1426
1459
  "--no-tags",
1427
1460
  remote,
@@ -1430,7 +1463,7 @@ async function fetchBranch(remote, branch, options) {
1430
1463
  }
1431
1464
  async function isTracked(path, options) {
1432
1465
  try {
1433
- await git([
1466
+ await git$1([
1434
1467
  "ls-files",
1435
1468
  "--error-unmatch",
1436
1469
  "--",
@@ -1457,7 +1490,7 @@ async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_A
1457
1490
  async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
1458
1491
  const remote = options.remote ?? "origin";
1459
1492
  await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
1460
- await git([
1493
+ await git$1([
1461
1494
  "fetch",
1462
1495
  "--unshallow",
1463
1496
  "--no-tags",
@@ -1471,7 +1504,7 @@ async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
1471
1504
  }
1472
1505
  if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
1473
1506
  try {
1474
- await git([
1507
+ await git$1([
1475
1508
  "merge-base",
1476
1509
  remoteRef(remote, targetBranch),
1477
1510
  "HEAD"
@@ -1485,7 +1518,7 @@ async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
1485
1518
  }
1486
1519
  }
1487
1520
  async function getMergeDiff(targetBranch, options = {}) {
1488
- return git(getMergeDiffCommand(targetBranch, options), options);
1521
+ return git$1(getMergeDiffCommand(targetBranch, options), options);
1489
1522
  }
1490
1523
  function getMergeCommitLogArguments(targetBranch, options = {}) {
1491
1524
  return [
@@ -1496,7 +1529,7 @@ function getMergeCommitLogArguments(targetBranch, options = {}) {
1496
1529
  ];
1497
1530
  }
1498
1531
  async function getMergeCommitLog(targetBranch, options = {}) {
1499
- return git(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
1532
+ return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
1500
1533
  }
1501
1534
  /**
1502
1535
  * Summarize a unified diff into file/line counts for telemetry. Counts one file
@@ -1524,6 +1557,171 @@ function summarizeDiff(diff) {
1524
1557
  };
1525
1558
  }
1526
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
1527
1725
  //#region src/logger.ts
1528
1726
  var LEVELS = {
1529
1727
  debug: 0,
@@ -2206,11 +2404,12 @@ function atEndOfBlockComment(text, i) {
2206
2404
  }
2207
2405
  //#endregion
2208
2406
  //#region src/verify.ts
2209
- function buildVerifySystemPrompt(diff, commitLog) {
2407
+ function buildVerifySystemPrompt(diff, commitLog, staged) {
2408
+ const diskMode = staged !== void 0 && staged.length > 0;
2210
2409
  const parts = [
2211
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.",
2212
2411
  "",
2213
- "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.",
2214
2413
  "",
2215
2414
  "Apply this bar:",
2216
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.",
@@ -2226,7 +2425,10 @@ function buildVerifySystemPrompt(diff, commitLog) {
2226
2425
  "- \"drop\": not a real defect — speculative, stylistic, contradicted by the code/comments, or based on external state not visible in the diff."
2227
2426
  ];
2228
2427
  if (commitLog?.trim()) parts.push("", `Commit messages for this change (oldest first):\n<commits>\n${commitLog.trim()}\n</commits>`);
2229
- 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>`);
2230
2432
  return parts.join("\n");
2231
2433
  }
2232
2434
  function buildVerifyUserPrompt(comment) {
@@ -3015,18 +3217,18 @@ function gitSkillCacheKey(url, ref) {
3015
3217
  * remote's default branch via `HEAD`.
3016
3218
  */
3017
3219
  async function gitShallowClone(url, ref, dir) {
3018
- await git([
3220
+ await git$1([
3019
3221
  "init",
3020
3222
  "--quiet",
3021
3223
  dir
3022
3224
  ]);
3023
- await git([
3225
+ await git$1([
3024
3226
  "remote",
3025
3227
  "add",
3026
3228
  "origin",
3027
3229
  url
3028
3230
  ], { cwd: dir });
3029
- await git([
3231
+ await git$1([
3030
3232
  "fetch",
3031
3233
  "--depth",
3032
3234
  "1",
@@ -3034,7 +3236,7 @@ async function gitShallowClone(url, ref, dir) {
3034
3236
  "origin",
3035
3237
  ref || "HEAD"
3036
3238
  ], { cwd: dir });
3037
- await git([
3239
+ await git$1([
3038
3240
  "checkout",
3039
3241
  "--quiet",
3040
3242
  "FETCH_HEAD"
@@ -3176,11 +3378,18 @@ async function cleanupSkippedDiffs(cwd) {
3176
3378
  });
3177
3379
  }
3178
3380
  /**
3179
- * Render the `<skipped_files>` block for the retrieval mode: each dropped file
3180
- * 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.
3181
3388
  */
3182
- function renderRetrievableSkippedBlock(files) {
3183
- 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.`;
3184
3393
  }
3185
3394
  //#endregion
3186
3395
  //#region src/triage.ts
@@ -3569,13 +3778,22 @@ function filterDiff(raw, maxChars = DEFAULT_MAX_DIFF_CHARS) {
3569
3778
  totalChars += section.length;
3570
3779
  reviewedChangedLines += changedLines;
3571
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);
3572
3789
  return {
3573
3790
  diff: included.join(""),
3574
3791
  noiseSkippedFiles,
3575
3792
  sizeSkippedFiles,
3576
3793
  reviewedChangedLines,
3577
3794
  skippedChangedLines,
3578
- sizeSkippedSections
3795
+ sizeSkippedSections,
3796
+ allSections
3579
3797
  };
3580
3798
  }
3581
3799
  function mergeContent(files) {
@@ -3781,17 +3999,27 @@ function buildAngleSystemPrompt(context, minSeverity, angle) {
3781
3999
  "</review_angle>"
3782
4000
  ].join("\n");
3783
4001
  }
3784
- function buildUserPrompt(diff, skippedFiles = [], commitLog, priorThreads, intent, coverage, retrievableSkipped) {
4002
+ function buildUserPrompt(diff, skippedFiles = [], commitLog, priorThreads, intent, coverage, retrievableSkipped, omitInlineDiff = false, commitExploration) {
3785
4003
  const parts = [];
3786
4004
  const intentBlock = renderIntentBlock(intent);
3787
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}`);
3788
4006
  if (commitLog?.trim()) parts.push(`Commits in this MR (oldest first):\n<commits>\n${commitLog.trim()}\n</commits>`);
3789
- parts.push(`Review this diff:\n<diff>\n${diff}\n</diff>`);
3790
- if (retrievableSkipped && retrievableSkipped.length > 0) parts.push(renderRetrievableSkippedBlock(retrievableSkipped));
3791
- 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. Mention them explicitly in your summary as not reviewed.`);
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"));
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.`);
3792
4020
  if (coverage && coverage.totalLines > 0 && coverage.reviewedLines < coverage.totalLines) {
3793
4021
  const pct = Math.round(coverage.reviewedLines / coverage.totalLines * 100);
3794
- parts.push(`<coverage>You reviewed ${coverage.reviewedLines} of ${coverage.totalLines} changed lines (~${pct}%). The rest were dropped for the size budget and you did NOT see them. State this partial coverage in your summary and do not imply the unreviewed files are clean — their absence from your findings is not a clearance.</coverage>`);
4022
+ parts.push(`<coverage>You reviewed ${coverage.reviewedLines} of ${coverage.totalLines} changed lines (~${pct}%). The rest were dropped for the size budget and you did NOT see them. The MR summary already reports this partial coverage to the reader, so do not restate it; just do not imply the unreviewed files are clean — their absence from your findings is not a clearance.</coverage>`);
3795
4023
  }
3796
4024
  if (priorThreads && priorThreads.length > 0) {
3797
4025
  const block = renderPriorThreadsBlock(priorThreads);
@@ -4007,32 +4235,86 @@ function accumulateUsage(target, message, modelId) {
4007
4235
  addUsageToBucket(bucket, message);
4008
4236
  }
4009
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
+ }
4010
4250
  async function runReview(config, options) {
4011
4251
  const cwd = options.cwd ?? config.cwd;
4012
4252
  const minSeverity = toGitLabReviewSeverity(config.minSeverity);
4013
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;
4014
4262
  const maxDiffChars = config.maxDiffChars > 0 ? config.maxDiffChars : DEFAULT_MAX_DIFF_CHARS;
4015
- const { diff, noiseSkippedFiles, sizeSkippedFiles, reviewedChangedLines, skippedChangedLines, sizeSkippedSections } = filterDiff(options.diff, maxDiffChars);
4016
- 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." });
4017
- const skippedFiles = [...sizeSkippedFiles.map((f) => f.path), ...noiseSkippedFiles];
4018
- const decomposeHint = config.decomposeHintLines > 0 && reviewedChangedLines > config.decomposeHintLines ? {
4019
- lines: reviewedChangedLines,
4020
- threshold: config.decomposeHintLines
4021
- } : void 0;
4022
- const coverage = sizeSkippedFiles.length > 0 ? {
4023
- reviewedLines: reviewedChangedLines,
4024
- totalLines: reviewedChangedLines + skippedChangedLines
4025
- } : void 0;
4026
- const sizeNotice = {
4027
- sizeSkippedFiles,
4028
- decomposeHint,
4029
- coverage
4030
- };
4031
- const retrievableSkipped = config.retrieveSkipped && sizeSkippedSections.length > 0 ? await writeSkippedDiffs(cwd, sizeSkippedSections) : [];
4032
- if (retrievableSkipped.length > 0) logger.info(`Staged ${retrievableSkipped.length} dropped-file diff(s) on disk for retrieval.`);
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
+ }
4033
4315
  const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), { refreshGitSkills: config.refreshGitSkills });
4034
4316
  const systemPrompt = buildJSONSystemPrompt(context, minSeverity);
4035
- 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);
4036
4318
  const skillNames = context.skills.map((s) => s.name);
4037
4319
  if (skillNames.length > 0) logger.debug(`Skills loaded: ${skillNames.join(", ")}`);
4038
4320
  if (context.conventions.length > 0) logger.debug(`Conventions: ${context.conventions.map((f) => f.path).join(", ")}`);
@@ -4040,7 +4322,7 @@ async function runReview(config, options) {
4040
4322
  const pool = buildEffectivePool(config, logger);
4041
4323
  if (pool.length > 1) logger.info(`Model pool: ${pool.map((m) => m.id).join(", ")}.`);
4042
4324
  const primary = pool[0];
4043
- const tools = createReadOnlyTools(cwd);
4325
+ const tools = [...createReadOnlyTools(cwd), ...gitTools];
4044
4326
  const createAgent = options.createAgent ?? defaultCreateAgent;
4045
4327
  const timeoutMs = options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
4046
4328
  const aggregated = emptyUsage();
@@ -4052,7 +4334,9 @@ async function runReview(config, options) {
4052
4334
  timeoutMs,
4053
4335
  logger,
4054
4336
  aggregated,
4055
- verifyMember: resolveVerifyMember(config, primary, logger)
4337
+ verifyMember: resolveVerifyMember(config, primary, logger),
4338
+ attachTelemetry: options.attachTelemetry,
4339
+ verifyStaged: diskMode ? retrievableSkipped : void 0
4056
4340
  };
4057
4341
  let outputText;
4058
4342
  if (config.reviewDepth === "full") {
@@ -4218,6 +4502,7 @@ async function runMultiAngleFind(context, minSeverity, userPrompt, deps) {
4218
4502
  thinkingLevel: deps.thinkingLevel,
4219
4503
  getApiKey: member.getApiKey
4220
4504
  });
4505
+ const detachTelemetry = deps.attachTelemetry?.(agent);
4221
4506
  try {
4222
4507
  const parsed = parseReviewMarkdownWithWarnings(await runAgentToCompletion(agent, userPrompt, {
4223
4508
  timeoutMs: deps.timeoutMs,
@@ -4231,6 +4516,8 @@ async function runMultiAngleFind(context, minSeverity, userPrompt, deps) {
4231
4516
  summaries[index] = parsed.summary;
4232
4517
  } catch (error) {
4233
4518
  deps.logger.warn(`Find angle "${angle.key}" failed: ${error.message}; skipping.`);
4519
+ } finally {
4520
+ detachTelemetry?.();
4234
4521
  }
4235
4522
  }), FIND_CONCURRENCY);
4236
4523
  const raw = groups.reduce((total, group) => total + group.length, 0);
@@ -4256,7 +4543,7 @@ async function verifyAndSynthesize(findings, summary, diff, commitLog, deps) {
4256
4543
  })).filter(({ finding }) => finding.comment.severity === "critical" || finding.comment.severity === "warn");
4257
4544
  const verdicts = /* @__PURE__ */ new Map();
4258
4545
  if (severe.length > 0) {
4259
- const verifySystemPrompt = buildVerifySystemPrompt(diff, commitLog);
4546
+ const verifySystemPrompt = buildVerifySystemPrompt(diff, commitLog, deps.verifyStaged);
4260
4547
  await runBounded(severe.map(({ finding, index }) => async () => {
4261
4548
  const comment = finding.comment;
4262
4549
  const verifierMember = deps.verifyMember ?? pickVerifier(deps.pool, finding.authorModel);
@@ -4267,6 +4554,7 @@ async function verifyAndSynthesize(findings, summary, diff, commitLog, deps) {
4267
4554
  thinkingLevel: deps.thinkingLevel,
4268
4555
  getApiKey: verifierMember.getApiKey
4269
4556
  });
4557
+ const detachTelemetry = deps.attachTelemetry?.(verifier);
4270
4558
  try {
4271
4559
  const text = await runAgentToCompletion(verifier, buildVerifyUserPrompt(comment), {
4272
4560
  timeoutMs: deps.timeoutMs,
@@ -4279,6 +4567,8 @@ async function verifyAndSynthesize(findings, summary, diff, commitLog, deps) {
4279
4567
  decision: "keep",
4280
4568
  reason: "verifier error; finding kept"
4281
4569
  });
4570
+ } finally {
4571
+ detachTelemetry?.();
4282
4572
  }
4283
4573
  }), VERIFY_CONCURRENCY);
4284
4574
  }
@@ -4909,7 +5199,7 @@ async function loadDefaultRuntime() {
4909
5199
  const [sdkNode, resources, semconv] = modules;
4910
5200
  const serviceResource = resources.resourceFromAttributes({
4911
5201
  [semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
4912
- [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.8.3"
5202
+ [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.8.5"
4913
5203
  });
4914
5204
  process.env.OTEL_METRICS_EXPORTER = process.env.OTEL_METRICS_EXPORTER ?? "otlp";
4915
5205
  process.env.OTEL_LOGS_EXPORTER = process.env.OTEL_LOGS_EXPORTER ?? "otlp";
@@ -5341,7 +5631,7 @@ function boldCommentTitle(body) {
5341
5631
  */
5342
5632
  function buildCommentBody(body, commitSha, confidence) {
5343
5633
  const confidenceLine = `_Confidence: ${confidence}._`;
5344
- const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.8.3 for commit ${commitSha}.</sub>`;
5634
+ const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.8.5 for commit ${commitSha}.</sub>`;
5345
5635
  return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
5346
5636
  }
5347
5637
  function buildPayload(comment, body, refs, resolved) {
@@ -6066,15 +6356,21 @@ Options:
6066
6356
  context aids reasoning but inflates tokens and fits fewer files in
6067
6357
  the budget; less fits more. 0 = built-in default (20).
6068
6358
  (env: CODE_REVIEW_DIFF_CONTEXT)
6069
- --retrieve-skipped Stage diffs for files dropped by the size budget on disk so the
6070
- reviewer can read them on demand instead of losing them.
6071
- (env: CODE_REVIEW_RETRIEVE_SKIPPED=true)
6359
+ --no-retrieve-skipped Disable staging diffs for files dropped by the size budget on disk.
6360
+ Retrieval is on by default: dropped diffs are staged so the reviewer
6361
+ can read them on demand instead of losing them.
6362
+ (env: CODE_REVIEW_RETRIEVE_SKIPPED=0)
6072
6363
  --min-severity <level> info, warn, or critical (default: info)
6073
6364
  --thinking <level> off, minimal, low, medium, high, or xhigh (default: off).
6074
6365
  Higher levels add billable thinking tokens at the model output rate.
6075
6366
  --review-depth <depth> single (one pass), verify (adversarial re-check of each
6076
6367
  severe finding), or full (multi-angle finders → triage →
6077
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)
6078
6374
  --verify-model <p/id> Model for the Verify stage (verify/full depth). Pairs a cheap
6079
6375
  finder with a strong, high-precision verifier. Warns if it looks
6080
6376
  cheaper than --model. Empty (default) = pool selection.
@@ -6181,7 +6477,8 @@ async function run(config, bridges) {
6181
6477
  description: mr.description
6182
6478
  },
6183
6479
  logger,
6184
- attachTelemetry: bridges?.otel?.createAgentTelemetry(runId)
6480
+ attachTelemetry: bridges?.otel?.createAgentTelemetry(runId),
6481
+ sinceRef: config.inputMode === "commits" && reviewedCommitSha && reviewedCommitSha !== refs.head_sha ? reviewedCommitSha : void 0
6185
6482
  });
6186
6483
  context.usage = result;
6187
6484
  return result;
@@ -6428,10 +6725,10 @@ async function main(argv = process.argv.slice(2)) {
6428
6725
  return;
6429
6726
  }
6430
6727
  if (argv.includes("--version") || argv.includes("-v")) {
6431
- console.log("0.8.3");
6728
+ console.log("0.8.5");
6432
6729
  return;
6433
6730
  }
6434
- process.stderr.write(`[code-review] @weareikko/code-review v0.8.3\n`);
6731
+ process.stderr.write(`[code-review] @weareikko/code-review v0.8.5\n`);
6435
6732
  assertNodeVersion();
6436
6733
  applyCodeReviewEnvPrefix();
6437
6734
  applyDefaultCacheRetention();
@@ -6454,4 +6751,4 @@ if (isDirectRun()) main().catch((error) => {
6454
6751
  //#endregion
6455
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 };
6456
6753
 
6457
- //# sourceMappingURL=cli-CICuhytH.js.map
6754
+ //# sourceMappingURL=cli-DmzA9PxS.js.map