Releases: guimatheus92/pr-review
Release list
v0.13.0
Added
INV-FETCH-04— no content is fetched for a file that will not be reviewed. The 500-file guard ran after gather, so everything fetched for a too-large PR was paid for and thrown away. Measured live on a 501-file Azure DevOps PR of additions: 501getItemcalls and 5.1 s, then "PR is too large: 501 changed files"; the same PR of modified files costs two calls each, which the hermetic test pins at 1002. Both are now zero, and the live gather takes 0.65 s. The same shape hit exclusions at any size —applyDiffExclusionssetspatch: undefined, so ADO was fetchingpackage-lock.jsonand every.pngtwice and discarding both patches on the next line. One question,patchPolicy, asked in one place (besideapplyDiffExclusions, where the constants moved becausegather.tscannot importreview.ts), by the two sites that pay: the ADO provider before spending two whole-filegetItemcalls, and gather's truncated-list completion before spawning onegit diff-tree -pper missing file — a path that only runs on the largest PRs there are.fetchChangedFilestakes an optional second argument; GitHub and GitLab ignore it because their listing response already carries the patch, and with no argument every file is fetched, so a provider called directly is unchanged.runGatheritself does apply the guard to every caller, which is deliberate forpr-review gather(a >500-file PR yields the complete path list without content) and wrong forpr-review post, which gathers only to build the valid-line map — it passespatchesRequiredand still gets every patch. The path list is never narrowed — only content — because paths are what the rule-trust, config-trust and MCP gates read (INV-FETCH-01), and patch-less rows were already normal for binaries, pure renames, mode-only changes and ADO deletions. Fixes #27.- The checkout's own
diff_excludesnow reach gather (asrepoExcludes) so the fetch decision is taken over the same in-scope setearlyExitGatefinally counts: a 600-file PR that the repo's.pr-review.yamltrims to 480 stays reviewable, exactly as before. Those globs are branch-authored, so they are used asymmetrically — they may shrink the COUNT, never suppress an individual file. Counting with them is safe in one direction only (extra excludes make a run less likely to be refused, i.e. more likely to fetch), while letting them suppress would meandiff_excludes: ['**/*']in a PR delivers a review of its own code with no diff at all. - The too-many-files guard has tests.
MAX_FILES_GUARD,MAX_PATCH_BYTESand the "everything excluded" clause were the only product contract in the repo with neither an invariant ID nor a single assertion —tests/zero-passes.test.tscovered the description clause of the same function and stopped. Both sides of the boundary are now pinned: 501 in-scope files refused, 501 rows with one lockfile allowed (the gate is>, and exclusions are what it counts over), two 1.2 MB patches refused on the sum, the same bytes undervendor/not counted at all.
Changed
- Azure DevOps patches are
@@hunks instead of whole files. ADO has no diff endpoint, sosynthesizePatchbuilds the patch from two full file bodies — and emitted the whole file too, every unchanged line ascontext with no hunk header anywhere. Nothing broke loudly (validLinesFromPatchstarts its cursor at 0, so a hunkless patch happens to number correctly), which is why it survived from the first release; the cost was that the gather cache, the## Diffblock ofpr-context.mdthat every pass reads, and the 2 MB patch budget all scaled with the size of the files rather than the size of the change.toHunksframes it with git's own 3 lines of context, and all four shapes route through it — the LCS diff, the coarseMAX_LCS_CELLSfallback, and the pure add/delete short-circuits (now@@ -0,0 +1,N @@and@@ -1,N +0,0 @@) — so they cannot drift apart. Identical content yields no patch at all rather than a preamble with nothing under it: ADO lists encoding- and mode-only changes as changed files, and a truthy contentless string is what INV-FETCH-02 counts as content. - The posting shape is decided once per provider, not three times.
snapFindingsToDiffruns for all three providers, and on ADO every line of every changed file used to be "in the diff" — so a finding on line 900 posted on line 900, which is whatAGENTS.mdandREADME.mdhave always promised. That was true only by accident: with real hunks, snapping would have dragged every such finding to the nearest changed line, silently, on every ADO review.postingPolicystates the rule (ADO neither snaps nor re-anchors; GitHub and GitLab do both) andpostingShapeapplies it. The rule previously sat in three files —runPostapplies the shape whileresumeReviewandverifyeach recompute it to recognize the run's own comments — and three copies that must agree are a double-post (INV-POST-05) and a false audit (INV-POST-06) waiting to happen. - A gather that withheld content is never cached, on the same principle as
changedFilesComplete: restored later under a wider exclusion set, a path-only list looks like a whole diff. Nothing is lost — the run such an entry would serve is refused for the same reason the first one was. loadConfigis called once with the repo config rather than twice; the value is needed before gather for the fetch decision and again after it. Which config is trusted is still decided after gather, on the complete file list.
Security
- A glob from the branch under review can no longer hang the reviewer's CLI.
diff_excludesmay come from the checkout's own.pr-review.yaml, and since the change above those globs reach the matcher before the config is rejected as untrusted — a reachability the pipeline did not have, becauserunReviewswapped in the trusted configuration before any exclusion ran.globToRegexcompiles**to.*and anchors the result, so**a**a**a**a**a**a**a**a**bis catastrophic backtracking: measured at 3.7 s against a single 40-character path, andmatchesAnycompiles inside its own.some(), so a 501-file PR would have paid it once per file. Patterns are now compiled once per process and refused past 512 characters or four**segments, with one stderr line naming the pattern. Refusing means the glob matches nothing, which for an exclusion list is the safe direction — the file is reviewed, never silently hidden. The memoization is a plain win everywhere else too: 500 paths against the 31 built-in exclusions went from recompiling 15,500 regexes to 2 ms.
Fixed
- A cache entry that withheld content is no longer served to a run that excludes less. Below the file guard, content is still skipped for excluded paths — and the cache stores raw rows while the hit path re-applies the current run's exclusions, keyed only on
headSha+ last comment id. Sopr-review reviewwithdiff_excludes: ['**/generated/**']followed bypr-review gather(which passes no exclusions) would have pulled those rows back into scope carrying no patch, and the passes would have reviewed them blind with nothing to signal it. Entries now record the globs they were assembled under incontentExcludesand are refetched unless every one is still excluded; entries where nothing was withheld carry no such field and are served as before, so GitHub and GitLab pay nothing for a contract that only binds Azure DevOps. pr-review verifyno longer FAILs two runs that behaved exactly as designed. INV-FETCH-02 failed whenever no in-scope file carried a patch, which the change above made reachable twice over: a run refused as too large (itspr-review-gather.jsonis written before the gate, so the artifact is auditable and deliberately empty of content) now SKIPs with that reason named, and a PR of pure renames, mode changes or binaries — where nothing adds or removes a line anywhere — PASSes. A file that does report changed lines and still carries no patch is still a FAIL, which is the defect the clause exists to catch.- The
nofetchacceptance cell pins--runtime copiloton itsreviewinvocation.resolveRuntimeruns beforeearlyExitGateand only returns early for an explicit choice, so on a CI runner with neither agent CLI installed the cell would have died on "No agent runtime found" and reported a guard failure that never happened. Its artifacts are now uploaded too — the one file naming which rows carried a patch is exactly what a failing run needs. - The oversized-diff message reported
limit 1.9073486328125 MBnext to a rounded2.3 MB: it divided 2,000,000 by 1024² and never rounded. Both figures now share the divisor and the rounding; the limit itself is unchanged. Found by writing the guard's first test. - Azure DevOps folder entries are dropped before the in-scope count is taken, so the ancestors a directory add drags in cannot push a PR over the file guard.
evals/acceptance/README.mdandscripts/acceptance.mjspointed three times attests/gather.test.ts, which does not exist — the file istests/gather-cache.test.ts. INV-FETCH-01'sVerified:list omittedtests/providers/github.test.ts, the count-carrying test the whole gate is built on.
v0.12.0
Added
INVARIANTS.md— the guarantees, stated once. 26 invariants with stable, append-only IDs (INV-POST-01,INV-FETCH-01, …), each carrying what always holds, why (the incident or the reasoning), where the code enforces it, and what verifies it. They were previously spread acrossAGENTS.md,README.md,architecture.md, code comments and test names, with no addressable list — and "never post a summary comment on the PR" was never stated anywhere, only implied by inline-only. That is exactly how thecode-reviewcompanion's top-level "### Code review" verdict reached a live PR.AGENTS.mdnow cites the IDs instead of restating the guarantees, and the README names the no-summary and complete-file-list guarantees explicitly.pr-review verify [run-id] [--pr <url>]— audit a finished run against the charter. Read-only: it never posts, deletes or rewrites anything. One PASS/FAIL/SKIP row per invariant, always the full list (registry order, then the test-only rows); a check that throws renders FAIL rather than vanishing, and a SKIP always carries its reason, so "not checked" can never read as "checked and fine". Exit 0 when every row passed or skipped, 1 when the audit itself could not be completed (a live read-back failure that was not asked for with--offline), 2 on any FAIL.--offlinegrades from the run artifacts alone;--jsonfor CI. Posting identity is derived from confirmed writes — the comments matching a key the run planned to post are the run's, and their modal author is us — so no provider grows awhoami(); with no confirmed write the posting rows skip and fall back to a shape tripwire that can only fail, never pass by accident.tests/invariants-doc.test.tsasserts the document and the check registry hold the same ID set in both directions, so a renamed ID fails the suite instead of silently dropping a row.capabilities.jsonrecords the resolvedruntime. Under--runtime autonothing on disk could say which CLI hosted the session; the runtime only ever reached stderr.scripts/eval.mjsnow prints it per case.- An acceptance matrix against real pull requests: 3 providers × 2 runtimes, posting for real (
npm run acceptance,evals/acceptance/). Until now Azure DevOps and GitLab were exercised only through stubs and the Copilot runtime only at the argv layer — the two riskiest surfaces in the product had no live proof. Each cell resets its fixture PR (before the run, never after — an after-reset is the step a cancelled run skips), reviews from inside a clone of the fixture repo socwdIsPrRepogates actually engage, asserts the findings and routing against a sharedexpected.yaml, reads the PR back to confirm the content landed, and runspr-review verify. A GitLab-only 101-file MR covers the truncated-file-list gate from both sides — completed from git in the right checkout, refused from anywhere else.npm run acceptance:seedcreates the whole estate idempotently..github/workflows/acceptance.ymlruns the copilot cells on manual dispatch with environment-scoped secrets and nopull_requesttrigger, so a fork can never reach them. - A pass that claims an MCP call is flagged, not believed.
capability-<pass>.jsonis written by the dispatched pass itself, andreadCapabilityUsageonly checked that the three arrays were arrays — so a sidecar reporting a successful MCP call was archived as fact under a runtime that denies MCP at the process level, with no warning, no degraded entry and no stderr line. A non-emptyavailable/attempted/usednow raises one named degraded warning per pass, listing which fields came back non-empty and with which servers, echoed to stderr as[mcp]in plain text (the summary copy is entity-escaped bysafeSummaryValue; a terminal renders no entities, and the server names are exactly what an operator greps) and persisted incapabilities.jsonalongside the untouched raw claim. The rendered list is bounded — 10 servers per field with(+N more), each name truncated — because the sidecar is untrusted model output and the escaping amplifies it; the archived arrays stay complete. It stays a warning: Node has no view of the session's tool surface, so a real denial leak and a fabrication are indistinguishable here, and the run completed its job either way — the named servers are what classifies it, cross-checked against themcpServersinventory in the same file anddispatch-plan.json'sruntime/disabledMcpServers.availableis included deliberately: the brief asks a pass that finds a callablemcp__*tool to name it there, so exempting it would blind the check to the one shape a real leak arrives in. Fixes #30. - A re-dispatched pass no longer inherits the previous attempt's capability sidecar.
spawnPlannedBatchalready cleared each reviewer's stale attempt file; it now clearscapabilityPathtoo, so a pass recovered in attempt 2 that writes no sidecar is reported as missing evidence rather than audited on attempt 1's. This covers the initial, automatic-recovery and manual-recovery batches at once — the previous cleanup lived in the legacy orchestrator path, unreachable since planned dispatch became unconditional. The delete gets its owntry, and a failure is reported on stderr rather than swallowed: a stale attempt file is detectable downstream, but a surviving sidecar is byte-identical to fresh evidence, so a silent failure would reinstate exactly the bug the audit above exists to catch.
Changed
- The acceptance matrix distinguishes BLOCKED from FAIL. A cell that cannot run — today, the Copilot runtime with its premium requests exhausted — reports
🚧 blockedwith the reason and is counted apart from the passes, never as a product failure. With zero premium requests the CLI refuses every capable model,autofalls back to one that cannot carry a nine-pass orchestration, andINV-DEL-01then correctly refuses to post a partial review; reporting that as FAIL sends someone hunting a bug that does not exist. The probe is a courtesy — if it cannot answer, the cell runs and the real assertions speak.
Fixed
- A resumed run no longer reports success with incomplete delivery. Neither
--resumepath passedoperationalFailurestofinalizeReview, so a run resumed after losing a companion agent could writecompanions.jsonwith a missing reviewer and still exit 0 — the "a parseable review is not a completed review" rule held on fresh runs only. Both paths now re-read the interrupted run's companion accounting and exit 2 for a shortfall, matching the fresh path. pr-review doctorchecks GitLab. It probed GitHub and Azure DevOps only, so a GitLab-only user got a green preflight and a credential error on their first review. It now also reportsAZURE_DEVOPS_BEARER(previously an ADO user with only a bearer token read as failing unlessazhappened to be installed) and GitHub Enterprise Server tokens, which never fall back to the cloud variables.resolveRuntimetakes an injectable probe, and the documentedautoorder — copilot first, then claude, then throw — is now covered. It previously had none: the only way to exercise it was to change the machine's PATH.ChangedFile.statusmeans the same thing on every provider. GitHub'spulls/:n/filesships seven statuses and a cast pushed them into a four-value union unchanged, so every GitHub deletion carriedremoved— a value the type does not contain — while Azure DevOps, GitLab and the git completion emitted only the documented four. GitHub now maps explicitly (removed→deleted,copied→addedas git's ownCdoes,changed/unchanged→modified); an eighth value GitHub might add degrades tomodifiedwith one stderr line per distinct value rather than diverging in silence.unchangedis mapped rather than dropped on purpose: a dropped row would shortenchangedFilesand the strict length comparison againstchanged_fileswould send the PR down the complete-from-git path, or refuse it. Azure DevOps now labels a renamerenamedand carriespreviousPath— it reported both as a plain modify with no previous path, so the.pr-review.yamltrust gate (which checks the previous path too) could not see a config file renamed away on ADO alone. The rename bit only replaces themodifiedfallback, so delete/add precedence and every base-content fetch stay byte-identical;previousPathis keyed on the source path rather than on the label, soADD|RENAMEandDELETE|RENAME— still renames, still labelled added/deleted — carry it too. The cast is gone, so that call site is type-checked again like every other;tests/changed-file-status.test.tsinventories all four producers' vocabularies, and each provider'sfetchChangedFilesnow covers the call site itself. Gather cache entries written before this release keep the status they were cached with — the field has one consumer, which renders it as prose, so they are not invalidated. Fixes #29.- A network failure is now transient on all three providers.
fetchreports every transport failure as the messagefetch failed, with the real code buried incauseand nostatusproperty — andstatuswas the only thingisTransientGitHubError,isTransientAdoErrorandisTransientGitLabErrorread. So the most transient error there is was classified permanent: a connection reset during posting maderetriablecome back empty, and the reconcile-then-retry loop inrunPostthat exists for exactly this case never ran. Observed live — a GitLab review posted 56 of 57 comments and exited 2 over onefetch failed. A sharedisNetworkErrorinsrc/util/retry.tswalks thecausechain (bounded, since a chain can be circular) and is OR-ed into all three. This does not introduce a blind retry: it only makes the error eligible for the path that re-issues a write afterreadLandedconfirms the comment is genuinely absent, so INV-POST-04 is unchan...
v0.11.0
Added
- A truncated file list is never reviewed.
PrMetadata.changedFileCount/changedFileListTruncatedcarry the provider's own count (GitHubchanged_files; GitLabchanges_count, where"N+"means the stored diff overflowed and/diffsserves exactly the capped set).runGatherrefuses a provider list of any other length, or one declared truncated, and completes it from the reviewer's checkout when that checkout is the PR's repository with base and head already present:git diff-tree -r -M -zfrom the single merge base (plumbing, so reviewer diff config and the PR's own.gitattributescannot reshape the list;-z, so non-ASCII paths arrive raw), one hunks-only patch per missing file, provider entries winning. It never fetches: when a commit is absent, the history has two merge bases, or the clone is shallow, the run fails BEFORE anything is cached — exit 2,error.txtin detached mode — naming the counts and the exactgit fetchto run (git fetch origin <base> refs/pull/N/head,refs/merge-requests/N/head, or the ADO branches). A completion that still falls short of an exact count is refused as well, and a list LONGER than the count is reported as a disagreement rather than a truncation. The fetch command in the message quotes every ref. An incomplete file list is unknown, never empty: the rule-trust, config-trust and MCP gates it feeds would otherwise pass a rule the PR changed. Fixes #23. - The plugin slash command (
commands/pr-review.md) locates the checkout whose git origin matches the PR URL before starting the CLI — the current directory, then its subdirectories, then its siblings, preferring a primary worktree over a linked one — and prints a project-skill count computed with the same rule as the loader. --force-skill <file|dir>is documented as the only bypass: the file, or every.mdunder the directory, is injected whole into every pass with no scope, relevance or rule-trust check (the directory form has always been accepted). It is per run and CLI-only by design — there is deliberately no yaml or env key for forcing, so a committed.pr-review.yamlcan never pre-authorize branch-authored content. Docs that told you to pointextra_skills_dirs/--skills-dir/PR_REVIEW_SKILLS_DIRat a directory to force it now say--force-skill <dir>; those keys are trust-checked, and the warning not to aim a forced directory at rules the PR can edit applies to--force-skillalone.- Brand icon (
assets/icon.svg) and a branded README: centered header, badges, highlights, a mermaid pipeline diagram, a table of contents, and the verbose reference material folded into collapsible sections. Docs only — nothing ships differently.
Changed
- GitHub no longer fetches the whole-PR text diff:
pulls.getwith the diff media type returns 406 above 300 files (undocumented), and nothing readsfullDiff— the per-file patches are the diff. GitHub PRs of 300–500 files are now reviewable;fullDiffstays inpr-review-gather.json, empty for GitHub. - Gather cache entries carry
changedFilesComplete, set only once the file list passed the completeness gate. An entry without it was written before this release and is refetched once, then rewritten in place; nocache clearneeded. - BREAKING:
--skills-dir/extra_skills_dirs/PR_REVIEW_SKILLS_DIRare now selected like repo skill dirs, not injected whole. In 0.10 every file under a configured dir was force-fed into every pass regardless of scope, relevance or rule trust — observed live: 1.4 MB × 8 passes on a 5-file PR. Now a targeted file (applies_to/applyTo/paths) becomes a scoped rule, an untargeted one goes through the name+description relevance heuristic, an unmatched one lands in the on-demand index, and a file the PR changed inside a configured dir is skipped (also when the dir comes from an unchanged.pr-review.yaml). Configured dirs still apply when the cwd is not the PR's repository, their origin isconfigured, andpasses.json/matchedByare unchanged (glob/repo/index). The stderr line names the bypass:--force-skill <dir>injects a directory whole. - Linked skill directories are followed, and trust is by authorship rather than location. Discovery follows a directory link (symlink or NTFS junction) one hop, in every discovery dir (
.claude/skills,.claude/rules,.copilot/skills,.github/skills,.github/instructions,.agents/skills) and in configured dirs — 0.9/0.10 rejected every link and failed closed on anything resolving outside the checkout. A link the PR added or changed (its path, or any parent directory of it, is in the diff) is refused before anything behind it is read and named as degraded coverage; a link met inside a linked directory is not followed. Content whose real path is outside the checkout is trusted only when the PR did not author the link reaching it AND the file is committed and clean in its home git repository (git ls-files+git status; aSKILL.mdneeds its whole directory clean) — the same gate applies to every rule outside the checkout (linked, configured or personal), and a repository git cannot read is skipped, never trusted: on Windowsgit checkoutof a PR branch writes through a junction into the shared directory, so a planted file would otherwise become a trusted rule for every sibling repo's review. Untracked or modified files there are skipped and named; a directory under no git repository at all is trusted as the reviewer's local configuration, with one stderr note per directory reached through a link. Nothing depends on one company's layout — any link, any discovery root, any OS, git or not. The aggregate stderr line now reads[skills] skipped N project rule(s) — changed by this PR, reached through a link it changed, or not committed in their home repository. Fixes #20. - Trust comparisons fold letter case and Unicode (NFC) on every platform. They folded case on win32 only, so a PR committing
.Agents/skillscould bypass the rule-trust check on a macOS reviewer's machine. - The posting guarantee now states exactly what each provider does: GitLab findings post as inline discussions; re-anchoring of unanchorable findings applies on GitHub and GitLab, while Azure DevOps threads post at the reported
file:lineas-is and a location-less finding lands as a resolvable PR-level thread. README and AGENTS.md said "re-anchored" universally; the code (reanchorinsrc/commands/post.ts) never did that on ADO. - Reference docs agree on the pass ceiling (6 stack + up to 2 installed-plugin + every baseline under the 16-pass materialization ceiling; 10 only when no pack passes exist), the
## Skillstotals line, and GitLab inSECURITY.md's scope. - Reference docs now correctly describe post-gather setup, parallel Codex review, conditional verification, post-selection shared context (
skills-project.mdvs the budgetedskills-all.mdfallback), and direct-agent versus slash-command companions. Materialized pass files now label skill source paths as provenance instead of claiming inaccessible sibling references resolve inside the confined runtime.
Fixed
- A dispatched
claudesession no longer boots the MCP servers it is forbidden to call. 0.10 denied themcp__*tools but left--setting-sources userloading every user-level MCP server, so each review started them anyway — on Windows acmd.exe+conhost.exe+npx+nodeper server, each console window leaking permanently because the terminal never reclaims it. Measured on one live run: 20 processes and 4 consoles, of which 16 processes and 3 consoles existed only to start three servers the session could not reach.--strict-mcp-configis now passed alongside the tool denial, and the per-runtime switch lives in a typedMCP_PROCESS_DENIAL: Record<Runtime, string>so a runtime added later fails to compile until it declares one. The two are not symmetric and the docs now say so: claude's is categorical, copilot's--disable-builtin-mcpsis completed per name by--disable-mcp-serverand so is bounded bydiscoverMcpCapabilities. Because no--mcp-configis passed, the run-dir.mcp.jsonis now provenance only — no runtime loads it, and the artifact docs say so. This also corrects the 0.10.0 note below, which claimed "built-in/ambient MCP servers are denied" when that held at the tool level only under claude. No capability is lost: the tools were never in a pass's callable surface — run artifacts recorded passes reporting exactly that. - The shared PR context no longer advertises
## Available MCP Capabilities, and the installed-plugin capability brief no longer tells a pass to call tools it cannot have. Both runtimes deny MCP, so the advertisement only bought a paragraph of each plugin pass explaining why the call it was told to make was impossible.capabilities.jsonand thecapability-<pass>.jsonsidecars are unchanged in shape; the brief now states thatavailable,attemptedandusedare all empty under a runtime that denies MCP, which the previous wording left undefined foravailable. - Azure DevOps PRs with more than 100 changed files were reviewed on their first 100.
getPullRequestIterationChangesdefaults to$top=100and the response cursor was never read; every release from 0.6 to 0.10 issued that one unpaged call and cached the result. Iteration changes are now paged at 2000 per call until a short page, a full page without a cursor is probed once more, and a cursor that does not advance throws instead of looping. - Azure DevOps folder entries (a directory add, or an ancestor of an edited file) no longer become "changed files" with an empty synthesized patch: they counted against the 500-file guard, cost a content fetch each, and would have multiplied under full pagination.
- Foreground reviews now pass the provider resolved from pre-gather trusted configuration into gather, and detached preflight uses the same trusted ...
v0.10.0
Added
- Node-owned resilient reviewer delivery: description-bearing runtime calls write attempt-scoped exact
Finding[]JSON; Node promotes write-once canonical sidecars, assembles Phase 1/final output, gates a direct verifier, and records reviewer-level progress. One automatic selective recovery retries only unresolved reviewers;--resumegets the bounded final targeted attempt. - Schema-v1 runs persist HMAC-authenticated plan, delivery, Codex, and posting authority under
~/.pr-review/control/, with repairable diagnostic mirrors in the run dir. State binds PR/config/bundle/input hashes, attempts, canonical digests, and sticky dry-run/publish mode.
Changed
- Reviewer runtimes lose the tools that let a dispatched agent act outside the CLI: shell (
Bash/PowerShell), web (WebFetch/WebSearch), and built-in/ambient MCP servers are denied, and the session runs from the run directory under user-only setting sources, so checkout-supplied instructions are not auto-loaded. This closes the vector that once let a companion post its own PR comment. Read-only file access is deliberately unchanged — reviewers still open the checkout, which is what keeps findings grounded in the surrounding code. On-demand skill bodies are copied into the run directory and large catalogs split into digest-bound index shards, with original-source provenance retained. Codex remains an OS-sandboxed read-only sibling with strict attempt-scoped output. statusreports planned/valid/missing/invalid reviewer and finding counts, distinguishes recoverable exit 21 from terminal exit 22, and trusts authenticated state over runtime-writable summaries/errors. Legacy Phase 1 output is dry-run diagnostic evidence and cannot be published.- Dry-run is no longer a one-way door. Previewing with
--dry-runand then posting what you saw is the point of a dry run, so--resumenow accepts the dry-run → publish transition. It is admitted ONLY on a complete delivery, which keeps the real invariant — partial findings never post — fully intact; an incomplete run is refused asincomplete-promotion, and publish → dry-run stays refused asmode-mismatchbecause that run may already have posted.
Fixed
- Runtime exit 0 before consolidation no longer discards completed work or falsely reports no parseable findings. Valid reviewer hashes remain unchanged through recovery; malformed/missing outputs and required verifier/Codex failures stay incomplete, and no partial finding reaches dedupe or posting.
- Copilot/Claude task calls use JSON-safe arguments and mandatory descriptions. Windows launcher punctuation, reviewer filename aliases, atomic state replacement, canonical create-only promotion, and schema-v1 posting-marker authentication are covered by regressions.
- Credential-bearing pack URLs are redacted before entering runtime-readable plans. Canonical reviewer output publishes only after complete fsynced bytes exist, control readers consume durable backup bytes without renames, and a crash-recoverable per-run lease serializes recovery and posting through authenticated finalization.
statusemits an executable sticky-mode recovery command, while context-only and benign no-dispatch runs no longer leave half-created schema-v1 recovery control.- A failed
review/postno longer aborts the process on Windows.validateRecoveryPreconditionsreads the PR back before deciding, and the command'scatchended inprocess.exit()— a hard exit while undici's keep-alive handle is still closing tripsAssertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c, replacing a clean exit 2 with exit 127 and a C-level abort message. Both commands now setprocess.exitCodeand let the loop drain. Reproducible in six lines:await fetch(url); process.exit(2). - Only a publishing resume requires the PR to still be open.
metadataMatchesPlanalso assertedlive.state === 'open', so any run gathered against a merged or closed PR could never be resumed — not even--dry-run— and the refusal claimed the PR "no longer matches the saved dispatch plan" while every field matched. Refusal is nowpr-not-openand only on publish. tests/finalize-failure.test.tsno longer finalizes against the developer's real home: the two posting-failure scenarios ran withouthomeOverride, so everynpm run testleft an empty directory behind under~/.pr-review/control/. Still outstanding:tests/status.test.tswrites authenticated control records to the real home (and so mints~/.pr-review/control/control.key) becauserunStatusresolvesRUNS_ROOTitself and has no home seam.
v0.9.0
Added
- Local branch dogfood without opening a PR:
npm run dogfood -- --base origin/main --include-untrackedconverts the complete branch/working-tree diff (including opted-in untracked text files) into a temporaryGatherOutputunder~/.pr-review/runs/and drives the real bundled CLI with--from-gather --dry-run. URL-based companions are disabled for this synthetic PR; exercise them on a real-PR dry-run. The generateddist/cli.cjsremains recorded but is excluded from LLM context;npm run buildis its validation surface. - Synthetic eval fixtures also disable URL-based companions; their pass/stack/finding assertions exercise the bundled pipeline, while companion dispatch is validated on real-PR dry-runs.
--force-skill <file>is the explicit escape hatch for bypassing a skill'sapplyTo/pathsscope.--skill <file>now keeps the declared scope.- Installed plugins are discovered generically from their manifests in both supported hosts — Copilot CLI and Claude Code (whose extra
<version>directory level is resolved through the authoritativeinstallPathininstalled_plugins.json, so a stale side-by-side version is never picked). Relevant review-oriented skills may become capped plugin passes from repository/path/topic evidence, while declared user, repository, and plugin MCP servers are recorded incapabilities.json. Plugin passes write auditedavailable/attempted/usedMCP evidence; no technology-to-reviewer lookup table is involved.
Changed
- Pack routing now distinguishes evidence: canonical Linguist language names, manifest ecosystems, full dependency names, and dependency tokens stay separate. Dependency-backed skills outrank generic language matches; product skills such as Azure Functions, MCP, or Copilot SDK no longer qualify from
*.cs,*.ts,*.csproj, orpackage.jsonalone. Generic manifest globs are weak evidence, andSKILL.mdis container format rather than an identity token. - Product-specific identity tokens must co-occur in one dependency group; unrelated packages can no longer combine
azureandfunctionsinto false Azure Functions evidence. Original package casing is retained while tokenizing, so NuGet compounds such asDurableTaskremain detectable. - Project rules are also discovered from
.github/instructions/(applyTo) and.claude/rules/(paths). Semantically identical mirrors dedupe silently; divergent same-name rules still warn. - A repository rule file added or modified by the PR is excluded from both authoritative context and the on-demand index for that review, then named as degraded coverage. Branch-authored instructions cannot tell reviewers how to judge their own change.
- Rule trust filtering runs before same-name dedupe and checks lexical plus real paths. A changed later mirror cannot evict its unchanged counterpart; in-repo
--skillfiles remain scoped/untrusted when changed,--force-skillis the explicit override, and linked rules resolving outside the checkout fail closed. - Linguist aliases no longer become independent stack technologies. Exact filenames override ambiguous extensions, and manifest evidence resolves shared extensions (for example
.csto C# rather than Smalltalk when a C# project owns the change).
Fixed
- Windows 8.3 short paths no longer make a checkout look like a different directory. Every containment check compared a path from one source (
git rev-parse, a manifest) against one from another (os.tmpdir(), a directory walk), andrealpathSyncfolds symlinks but not 8.3 components — soC:\Users\RUNNER~1\...andC:\Users\runneradmin\...read as different directories and a legitimate--skill, manifest or plugin file was silently refused. All five comparison sites now go throughrealpathCanonical(realpathSync.nativefirst). Invisible to anyone whose username is 8 characters or fewer, which is why it only surfaced on CI. - A repository MCP server is refused when it would launch code from the reviewed checkout. Refusing only a changed
.mcp.jsonleft a hole: the config can be untouched while the PR rewrites the script it points at (node ./scripts/mcp-server.js), and reviewing a PR means the checkout already sits at that branch's head. In-repo launch paths (explicitly relative, absolute-inside-repo, or an existing repo file) are now refused per server, with the reason surfaced; external tooling (npx -y @scope/pkg,docker run …, a bare PATH command) is unaffected. - Installed-plugin discovery is no longer Copilot-only. Under Claude Code the plugin cache adds a
<version>level and can hold several versions at once, so nothing was discovered there at all — pr-review supports both hosts and must behave the same in each. - The
extra_skills_dirsexample no longer points at.claude/skills. A forced directory bypasses the rule-trust check, so recommending the reviewed repo's own rule directory re-admitted exactly the branch-authored input that check exists to reject. - The directory trust gate is case-insensitive.
normalizedRelative()lowercases paths only on win32, so a case-sensitiveskill.mdtest matched nothing on Linux/macOS and silently disabled the gate there. Caught by dogfooding this branch against itself;skillDirPrefixis now unit-tested in both casings so the regression is detectable on any platform. - A
SKILL.mdis now untrusted when the PR changed any file in its directory, not onlySKILL.mditself. OnlySKILL.mdloads as a skill, but every pass is handed aSource:line saying relativereferences/resolve from that directory — so a PR could ship branch-authored instructions beside an unchangedSKILL.mdand have them read as authoritative project context. Found on a live dry-run of Preco-Pratico/PrecoPratico-Docs#269, which changes.claude/skills/backend-guide/create-database.md. Flat<dir>/<name>.mdrules share a directory with unrelated rules, so for those the file stays the unit. - Legacy Azure DevOps remotes such as
https://contoso.visualstudio.com/DefaultCollection/Platform/_git/infra-corenow match canonicaldev.azure.comPR identity, so local manifests and project rules are not discarded. - Checkout identity includes the ADO project and recognizes encoded HTTPS paths plus
ssh.dev.azure.com:v3/<org>/<project>/<repo>remotes, preventing same-name repositories in another project from supplying manifests or rules. - Deep monorepo projects contribute their owning manifests without a full recursive scan: changed manifests and manifests beside/above changed files are read in addition to the shallow root scan. A deeply nested MSTest project now dispatches
csharp-mstestinstead of unrelated C# product guides. - Companion reporting now separates every installed plugin from recognized companion plugins, planned dispatches (six toolkit agents plus one
code-reviewcommand), and completed output rows. Each run persistscompanions.json; missing companions are recorded as degraded coverage in the summary, while detection failures remain unknown rather than being mislabeled as not installed. - Planned companion reviewer IDs are reconciled against delivered outputs. Missing or duplicate outputs, failed/unverified posts, and failed review prerequisites now return exit 2 with
error.txt; parseable findings still receive a diagnostic summary, and detachedstatusreports the run as failed rather than done. - Every pass and companion persists its own
raw-<reviewer>.jsonbefore returning. If the orchestrator ends after its tasks finish but before consolidation, the CLI recovers only when every planned sidecar is valid; partial delivery still fails closed. Valid empty arrays override synthetic “unparseable output” findings. - Copilot dispatches the installed
pr-review-toolkitagents by their registered short names, so all six companion agents run once instead of first failing under Claude-style qualified names and being relaunched generically. - Installed-plugin skill paths are constrained lexically and by real path to the plugin root, and repository MCP configuration is admitted only when checkout identity matches the PR repository. Changed repository MCP files remain untrusted.
- Trusted repository MCP definitions from root
.mcp.jsonand.vscode/mcp.jsonare normalized into the isolated run's.mcp.json(root definitions win duplicate names), so every advertised repo capability is actually available to reviewer processes. - Dogfood derives GitHub fork identity from
origin, rejects unsupported providers and internal/flow-control flags, refuses stale bundles, and includes untracked files only with--include-untracked. Secret-bearing names and high-confidence credential content are refused before artifacts are written; opted-in paths are real-path validated, and binary/empty files are recorded without pretending they contain reviewable lines. - Dogfood checks both
previousPathandpathfor tracked renames before excluding generated bundle content, so renaming.env, credentials, keys, or token files to a benign name cannot place the old sensitive path in local review artifacts. - ADO gather caches are scoped by the authoritative project as well as organization/repository/PR, stale payload identities are refreshed on cache hits, and unresolved project identities bypass cache reuse.
- Project-omitted ADO refs are hydrated inside the shared PR resolver, so changed-file, full-diff, comment-read, and comment-post operations retain the authoritative project even when callers do not invoke metadata first; the hydrated cache alias avoids a second PR fetch.
- Manifest discovery validates real-path containment, ascends past deleted directories to existing owning manifests, and surfaces unexpected discovery/read failures in stack notes. Package diffs only derive dependency evidence from dependency sections or semver-like entries in truncated dependency hunks.
- The verifier renders as
skipped (no HIGH/CRITICAL)when ...
v0.8.0
[0.8.0] — 2026-08-25
Changed
- Project rules are never lost to a budget anymore (observed live on Preco-Pratico Backend#616 / Frontend#1067: a 47-skill repo saturated the injection cap at exactly 10 every run, and the 16KB/64KB delivery caps then cut that to 3–4 skills, two of them mid-body — the summary never said so): the relevance heuristic's
MAX_HEURISTIC_INJECTcap is gone (EVERY relevant untargeted repo skill injects) andskills-project.mdinlines every matched skill body WHOLE (PROJECT_BODY_CAP/PROJECT_FILE_CAPremoved, no[truncated:]/[omitted:]markers possible). The review pays the token cost by design rather than silently dropping a business rule. - Relevance heuristic
THRESHOLDraised 1 → 3 distinct stem hits: measured on real 55/66-file PRs, business skills score 4–115 while non-review content (tool-internal docs, loose readmes) scores 0–2 — threshold 1 marked 47/47 skills relevant on any large diff, making "relevant" meaningless. Small PRs still clear the bar via name+description stems (fixture-backed).
Fixed (self-review of this release with the tool itself — 45 findings triaged, 4 clusters real)
- The no-loss guarantee now holds in the
skill_packs: []fallback too: overflow beyond the 10-pass cap injects whole as CONTEXT (never demoted to the on-demand index), and a project skill running as its own pass is exempt fromPASS_BODY_CAP— only third-party pack bodies still cap at 48KB. - Terse skills are no longer structurally unmatchable: the relevance bar adapts down to the skill's own needle count (a 2-token name+description that fully matches the diff injects; before, nothing under 3 needles could ever match).
- The uncapped
skills-project.mdreports its size at write time (N project rule(s), X KB) so the deliberate cost stays visible; orphaned comments from the removed caps cleaned up.
v0.7.0
v0.7.0 — zero built-in reviewers: review passes from synced skill packs, stack-aware (closes #5)
v0.6.1 — a failed write is not proof that nothing was written
A 56-comment batch got a 504 after GitHub had already created the review. The blind retry then hit the secondary rate limit precisely because the write had succeeded, the batch read as failed, the per-comment fallback re-posted all 56, and the run reported posted 0 / attempted 56; errors 56 with every comment live. Trusting that number, --resume posted a second copy: 112 comments.
The rule
POSTing a comment is not idempotent, so a 5xx or timeout means unknown, not "nothing written". Everything below follows from that.
- Providers make ONE attempt and throw —
postLineCommentandpostBatchComments, on all three providers.withRetryis for reads and other idempotent calls. Retry lives inrunPost, because onlyrunPostcan reconcile first. - The PR is read back before any decision — before a retry, before the per-comment fallback, and before reporting a count.
- Unknown ≠ empty. A failed read-back returns
null, never an empty map: the outage that 504s a write is the one that fails the read. Treating those as the same turned 3 findings into 15 live comments in review. Onnullthe run reports and stops. - Identity is
file:line:body. Two findings can legitimately carry the same body, so a body-only match promoted a finding that was never posted — which then filledposted.markerand locked--resumeout of recovering it. - Reconciliation is one-way. Errors may be promoted to posted, never the reverse; demoting on a stale read would send the next resume out to write a live comment again.
- Every publish attempt writes
posted.marker, carryingverified. An unverified run fails closed on resume. Gating the write onposted > 0is what left the incident run with no guard at all. --resumere-reads the PR before deduping (on--dry-runtoo), unions rather than overwrites, and adopts only comments matching a finding it would post — so a bystander's comment can never suppress a security finding. A failed re-read aborts a publishing resume.
Also
- Azure DevOps and GitLab now get this fix too. The reconciliation was reachable only through the batch path, which only GitHub implements, so both other providers still duplicated inside
postLineComment.isTransientErroris now a requiredPrProvidermember. - Codex failures are diagnosable:
codex-failure.log(argv, exit, timing, bounded stdout + stderr), the synchronous spawn throw is caught where it can still reach that log, stdinEPIPEno longer takes down the review, and exit 0 with no output is an error rather than a silent "found nothing".
Verification
3 findings through the incident shape: 6 live comments on 0.6.0 → 3 on 0.6.1. Under the compound write-and-read outage: 15 → 3. Tests 246 → 270; every reconciliation test asserts no path leaves two comments at the same location with the same text.
Full notes in CHANGELOG.md.
v0.6.0
Added
- Every real-world PR URL shape now parses. URL parsing moved from per-shape regexes to
new URL()+ path-segment walking, anchored on_gitfor Azure DevOps: legacyhttps://<org>.visualstudio.com/[<collection>/][<project>/]_git/…(with or withoutDefaultCollection— the exact shape that failed in the field), the project-omitteddev.azure.com/<org>/_git/<repo>/…form, and trailing paths/query strings/fragments on both providers (…/pull/42/files?diff=split). The duplicated ADO host regexes (URL_RESvsorgHost()) collapsed into one parser that computes the org/collection URL once. - GitHub Enterprise Server and Azure DevOps Server (on-prem) URLs.
PrRefgained an optionalbaseUrlset byparseUrl(GHES:https://<host>/api/v3, fed to Octokit; ADO Server:https://<host>/<virtualdir>/<collection>, fed to the ADO connection); refs lacking it (older serialized caches) re-derive it fromref.url. Self-hosted hosts resolve only through the newhosts:config map (<hostname>: github | azuredevops | gitlab) — an explicit allowlist, never path-shape guessing, so a credential is only ever sent to a host the user named; the unrecognized-URL error prints the exact yaml to add. GHES auth is host-scoped:GH_ENTERPRISE_TOKEN/GITHUB_ENTERPRISE_TOKENorgh auth token --hostname <host>— github.com env tokens are deliberately never sent to an enterprise host. Cloud cache keys and run-dir names are byte-identical to before (guarded by a test). - GitLab provider. Merge-request URLs (
https://gitlab.com/<group>[/<subgroup>]/<project>/-/merge_requests/<iid>, legacy no-/-/form, and self-managed hosts via thehosts:map) now review end to end: MR metadata + linked closes-issues, per-file diffs (paginated/diffs), existing notes for dedupe, and inline posting as resolvable discussions. Implemented with plainfetchagainst REST v4 — zero new dependencies. Auth:GITLAB_TOKEN/GITLAB_ACCESS_TOKEN, withglab config get token -h <host>as the CLI fallback (sent asAuthorization: Bearer, which accepts both PATs and glab OAuth tokens). Discussion positions carryold_linefor context lines via dual-cursor hunk math (positionForLine) — the main cause of GitLab's 400 "position is invalid" — and unanchorable findings re-anchor like GitHub's instead of dropping. GitLab has no batch endpoint, so posting is per-discussion with the existing retry/backoff.
Fixed
- A bad PR URL now fails
--detachimmediately in the foreground — with the accepted shapes listed and, for legacyvisualstudio.comURLs, the canonicaldev.azure.comtip — instead of handing back a run-id whose detached child dies minutes later withstatusexit 22 (the field incident). URL validation runs before the auth pre-flight and before the run dir is minted; the silentadhoc__run-dir fallback for unparsable URLs is gone (newresolvePr()choke point used by review, gather, post, cache, and detach). - Slashed owners (GitLab nested namespaces) no longer nest run dirs and cache paths.
ownerkeeps its namespace slashes inPrRef(the API needs the full path), but run-dir ids and cache paths flatten it via a sharedsafeOwnerhelper — without this,ensureRunDirminted a nested directory,--detachreturnedbasename()of it as the run-id, andstatus <run-id>looked in the wrong place (every detached GitLab run would read as missing). GitHub/ADO names are unchanged. scripts/test.mjsnow discovers tests recursively —tests/providers/*.test.tswas silently ignored by the flatreaddirSync, despiteadd-provider.mdpromising the nested layout.ci-integration.md's ADO pipeline example built a doubled URL (https://dev.azure.com/prefixed ontoSystem.TeamFoundationCollectionUri, which already expands to that) — it now uses the variable directly.
v0.4.2
Fixed
- A pipeline failure is no longer reported as a clean review. When the orchestrator produced no parseable findings (exit code 2),
finalizeReviewstill wrote a normal zero-findingpr-review-summary.mdand adoneprogress event — andstatustreats the summary's existence as "done, exit 0", so a detached run that failed presented as a clean PR. OnfindingsUnavailablethe run now writes the failure toerror.txtinstead of minting the summary, emits anerrorprogress event, andstatusreportsfailed(exit 22) with the message inline. Codex second-opinion findings collected before the failure are still posted, and the exit code stays 2. - Stdout salvage now recovers findings from a narrated orchestrator transcript. The JSON parser extracted exactly one value — the first fenced block or the earliest
[/{, which a prose bracket like[security]could win, defeating the whole parse.parseJsonFindingsnow merges findings from every JSON block in the blob (all fenced blocks — excised so nothing parses twice — then every balanced value, with structural recursion reaching nested payloads) and also understands the orchestrator's own{"reviewers":[{"name":…,"findings":[…]}]}file payload printed to stdout instead of written. Values found loose in prose must pass a strict finding-shape gate, so quoted log objects are not minted into findings. orchestrator-failure.lognow keeps the orchestrator's full stdout/stderr (was: last 8 KB tails). When the contract fails, stdout may hold the only copy of the reviewer findings, and a tail made even manual salvage impossible.statusno longer reportsinterrupted(with a dead-end--resumehint) when the findings file on disk is corrupt. Theinterruptedstate now requires a resumable output file — one that parses to the{reviewers:[…]}shape--resumeactually loads. A truncated/corrupt file falls through tofailedwitherror.txtsurfaced inline; a validphase1-findings.jsonfallback still readsinterruptedbecause resume genuinely recovers it. (Found by running this release's own reviewer against its PR.)
Full details: #10
🤖 Generated with Claude Code