Skip to content

About

VS Code companion for human pull request review: reads the change in the order that matters and checks its claims against real code.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Second Look

Second Look is a VS Code companion for human pull request review: it ranks the change by what matters, checks its claims against real code, and lets the reviewer send comments to GitHub.

  • docs/getting-started.md — the user documentation's entry: installing the extension and the engine, choosing the agent, model, effort and account, and what each agent needs installed.
  • docs/reviewing.md — how a review works: the parts, their importance, the noise, the claims and their verdicts, the asks and the comments.
  • docs/privacy.md — what the companion keeps on the machine, what it sends to GitHub, and what reaches the model provider.
  • CONTEXT.md — the project glossary: the shared words and what they mean.
  • docs/adr/ — the numbered decision records behind the design.
  • docs/tested-models.md — the current list of the agent, model and effort combinations the companion's evaluation has been tested with, and how each scored.
  • docs/agent-safety.md — what each agent the companion drives can read, what it is denied and how each limit is enforced, what the companion fetches itself, and the known gaps.

Repository layout

The repository is a TypeScript workspace with three packages:

  • packages/engine — the engine: a separate local process that fetches a pull request, parses its full diff into files and hunks, reads the changed files' syntax trees from read-only copies of the change, reads the pipeline report in the description, the CI at the head commit and the acceptance criteria of the issues the pull request links, and offers its result two ways: printed as typed, versioned JSON by the review command, and over a JSON-RPC protocol on stdio by the serve command (ADR 0005). It also reads portable PDB files, the .NET debug files that record each source file's hash and Source Link URL. It drives the reviewer's installed coding agent, Pi or Claude Code, through one adapter interface (ADR 0004).
  • packages/extension — the VS Code extension: a thin client that starts the engine as its own process, talks the JSON-RPC protocol to it after a version handshake, and shows the result in a side bar that walks the review in eight steps, with each part readable in the editor's multi-file diff over read-only copies, and the review's overview with the story, the acceptance criteria of the linked issues, the unexplained changes, the claims and their verdicts, the documentation links of the library APIs the change uses, the pipeline report and CI, and the description, and the findings as the companion's own comment threads on the diff, with each library API's documentation link in a hover on the head side of the diff. Its settings pick the agent, the model, the effort, the account label, each agent's executable path, the heading the acceptance-criteria checklist sits under and whether reviewed marks are mirrored to GitHub's "Viewed"; the status bar shows the agent, model, effort and account, and the review's stage and place while one runs.
  • packages/evaluation — the evaluation: runs the engine offline over recorded pull requests and scores it against a stored baseline; see its README.

The protocol types live in packages/engine/src/protocol.ts and packages/engine/src/rpc.ts, carry their versions, and are shared by all three packages.

Building and testing

Requires Node 20 or later. One command installs, type-checks, lints and tests everything:

npm ci        # install all workspace dependencies
npm run check # build (type check) + lint + unit tests

CI runs the same on every pull request and on every push to master, then npm run eval: every case, with no agent, which fails the build when a score drops below the stored baseline.

Individual steps: npm run build, npm run lint, npm test.

The extension integration tests are the one exception: they never run in npm test or npm run check, so no local run can launch anything that opens a VS Code window on a developer machine. CI runs them on Linux under xvfb: the fast stub-based test (npm run test:integration) and the real-host test (npm run test:real-host), which downloads a real VS Code and runs the extension in it end to end against a fake engine process.

Packaging the extension

npm run package builds the one extension package: a single universal .vsix that carries the extension, the bundled engine and the WASM grammars, with no per-OS builds and no native Node modules. The build reads no publishing credential and stores none — it writes the file to packages/extension/dist/ and nothing else. Publishing to the VS Code Marketplace or Open VSX stays a separate, manual release step.

CI builds the package on every pull request, reports its size, and keeps it as a build artifact; every version tag (v*) publishes the same package, built and checked the same way, as an asset of that tag's GitHub Release. To try the companion by hand, download the .vsix from the latest release and follow getting started to install it and run a first review. A smoke test installs the package this way into a clean, downloaded VS Code on macOS, Linux and Windows in CI and runs one review in it (npm run test:package-smoke locally, which like the other editor launches belongs to CI, not to a local check).

Running the review command

Build first (npm run build), then give the engine a GitHub token through the environment and point it at any public pull request:

GITHUB_TOKEN="$(gh auth token)" node packages/engine/dist/main.js \
  review https://github.com/{owner}/{repo}/pull/{number}

The engine package also installs a second-look-engine bin link once its build output exists (npm ci again after npm run build).

The command fetches the pull request's metadata and full diff — the description is kept in full, never truncated, and the diff comes from the diff media type, so a large lockfile keeps every line — parses it into files and hunks, and prints a JSON review result of named, ranked parts. Each file carries a noise label (lockfile, generated, vendored, moved or renamed, snapshot, fixture) that says whether it is confirmed or claimed and gives its one-line blind spot, or says no rule applied. The labels read the repository's linguist attributes from its root .gitattributes at the head commit, without a checkout. A lockfile label moves from claimed to confirmed by a parse-only check: for uv.lock and poetry.lock (manifest pyproject.toml), package-lock.json (package.json), NuGet packages.lock.json (the project files beside it and Directory.Packages.props up the tree) and Cargo.lock (Cargo.toml), both versions of the lock file and its manifest are read from the cached copies, and the label is confirmed only when every changed entry belongs to the dependency closure of the changed manifest entries — a change the manifest does not explain stays claimed and names its entries, and lock files of other ecosystems stay claimed with no check for this lockfile. For an npm, uv or Cargo workspace, the members are the directories the root manifest's own workspace declaration names (workspaces, [tool.uv.workspace] or [workspace]), found by listing and reading files with no package manager run; each member's manifest explains entries too, and the lock's own record of the root or a member is accepted only when the pull request changes that project's manifest and the record names no dependency the manifest does not declare.

Each file's hunks are grouped into parts named after the entities they touch: hunks that share an entity form one part, such as Cart.total in web/cart.ts, the hunks outside every entity form a top-level code in … part, and a file whose entities could not be named keeps its path. The engine fails the run unless every changed line belongs to exactly one part. Each part gets plain signals: new versus changed code, test versus code, its changed lines, the public entities it adds, removes or redeclares, and how many other files in the head copy mention its entity names — a name-based count, labelled so, that cannot tell a call from a same-named word. A fixed rule ranks each part must review, worth reviewing or context with a one-line reason citing the signals it used, keeps at most a third of the parts (rounded up) at must review, and gives the same order for the same input. Noise parts sink to the bottom, except snapshots and fixtures, which are labelled but ranked with the rest. Read the order and the reasons, and compare the parts' hunks and line counts with the GitHub page: no hunk may be missing.

The engine also keeps a read-only copy of the base version (the merge base the diff is computed against) and the head version in a per-pull-request cache, at <cache>/github.com/{owner}/{repo}/pull-{number}/{commit}. The copies are downloaded as archives: nothing is checked out in the reviewer's workspace, nothing from the pull request runs, and no package manager is called. A later run at the same commits reuses them. The cache folder is --cache-dir, else SECOND_LOOK_CACHE_DIR, else the platform's per-user cache folder (~/.cache/second-look, ~/Library/Caches/second-look or %LOCALAPPDATA%\second-look\cache); its files and folders are read-only but for the reviewed marks, the record of the reviewer's last look and the saved review result, so remove it with chmod -R u+w first.

Each changed file is parsed with a tree-sitter grammar bundled as WASM — Python, C#, TypeScript, TSX, JavaScript, Go, Rust and Java. Every hunk names the entities (functions, classes, methods and the like) its changed lines touch, each with whether it is public by its language's visibility rules and whether the hunk adds it, removes it, changes its declaration or only its body, and each part says whether its change is confirmed formatting-only: the base and head syntax trees must match, nesting included, so a Python dedent that moves a statement out of a block is not formatting-only. Files in other languages still flow through at file level, and their part lists the checks that could not run and why. The result records the time spent parsing in parseTimeMs.

The token is passed in by the caller (--token or GITHUB_TOKEN), is used only for the GitHub request, and is never written to disk or logs. Tests run against recorded responses and never touch the network.

Grouping with the agent

With --agent pi or --agent claude-code (and optionally --model, --effort and --agent-timeout), the review command then asks the reviewer's installed agent to group related hunks across files into parts — a function, its caller and its test — named by the entities they touch. The plain parts are announced on stderr while the agent works. Its answer is checked before anything is shown: an answer that misses its schema, names a hunk that was not offered or puts one hunk in two parts is retried once and then dropped, and the plain grouping stays; the hunks a valid answer leaves out go to one part marked not grouped by the agent, so every changed line still belongs to exactly one part. Sinking noise, such as a lockfile, keeps its plain parts and never reaches the agent. The agent's parts get their signals and rank like any other, and a part across files lists its further files in otherFiles. Each part says who grouped it (origin), and the result's grouping says whose parts it shows, with the agent's outcome, the grouping prompt's version and the run's stamp.

GITHUB_TOKEN="$(gh auth token)" node packages/engine/dist/main.js \
  review https://github.com/{owner}/{repo}/pull/{number} --agent pi

The grouping prompt is versioned and evaluated over its own cases (ADR 0006); see the evaluation.

Ranking with the agent

After grouping, the same agent ranks the parts the result shows: each part gets must review, worth reviewing or context with a one-line reason, and must cite, by key, the plain signals its reason uses — public-surface, role, novelty, references, size, formatting-only or noise, offered only where the part has that signal. The agent lists the parts in reading order, which orders the parts within each level. A validator checks the answer before anything is shown: it rejects a missing reason, a reason that cites no signal or cites a signal the part does not have, a part left out or ranked twice, and more than a third of the parts (rounded up) at must review; a rejected answer is retried once, and then the plain ranking stays. Sinking noise keeps its plain rank and stays last. A part's rank.signals holds the phrases of the signals its reason cites, and the result's ranking says whose ranking it shows, with the agent's outcome, the ranking prompt's version and the run's stamp.

The agent ranking is the default only for an agent, model and effort whose evaluation matched or beat the plain ranking — TESTED_RANKINGS in packages/engine/src/ranking.ts, recorded from the evaluation; a run that asks for no effort counts as the agent's own default. Elsewhere the plain ranking stays and the result says why; an agent with no tested model, or a model or effort asked for that is not one, is not asked to rank at all.

The story

Then the same agent writes the story of the parts the result shows: a few sentences that tell what the change does, in the parts' reading order, each part it mentions linked by its id. The engine checks the story before showing it: a story must link every must-review part, first mention the parts in the ranking's order, and name no file or code the change does not show — a name in backticks, a path, or a dotted, snake_case or camelCase name, checked against the change's paths, lines and entities. A story that fails is retried once with the problems named, and then the result says why there is none. The result's story holds the sentences as runs of text, code names and part links (each by its index in parts), with the story prompt's version and the run's stamp. The story prompt is evaluated over its own cases with those plain checks as its scores; see the evaluation.

The unexplained changes

Then the same agent compares what the pull request's description and its linked issues (see the acceptance criteria) say the change does with what it does, in both directions. A part neither explains — an unrelated refactor, rename or reformatting, or a fix, feature, dependency or setting they never mention — is marked unexplained with a one-line reason; a part that plainly serves what they describe, such as its test, its changelog entry or a caller it needs, is explained, and a noise part is never marked. A statement in the description or a linked issue that describes a change the diff does not contain, such as a promised test or document no part makes, is listed with a quote of the statement and a one-line reason saying what the diff lacks. The engine checks the answer before showing any of it: every part must be one it offered and not noise, each once, and every quote must sit in the description or the issue the agent names, where the engine — not the agent — sets its line. An answer that fails is retried once with the problems named, and then the result says why there is no comparison. With neither a description nor a linked issue there is nothing to compare with, so no agent is asked and the result says so. Description and issue text reach the agent only inside untrusted blocks, as data never to be followed. The result's unexplained holds the unexplained parts (each by its index in parts, with its reason) and the described changes (each quote with its location, a description line or a line of a linked issue's body by the issue's index in criteria.issues, and its reason), with the prompt's version, the outcome and the run's stamp. Both directions are findings: the side bar badges each unexplained part, and the overview lists both. The unexplained-changes prompt is evaluated over its own cases, scored by recall and precision in each direction against hand labels; see the evaluation.

The claims

Then the same agent lists the claims the change makes: statements about how code or a library behaves, from the pull request's description, the docstrings and comments in the lines the change adds, and the story the agent wrote, in that priority. Each claim is a quote of its source, as written. The engine checks every quote before listing any: a quote must sit in the description, in the lines the change adds to the file the agent names, or in a sentence of the story, and the engine — not the agent — sets where it sits and, for a docstring or comment, the part holding that line; a claim from the description or the story names its part by id. An answer with a quote its source does not hold is retried once with the problems named, and then the result says why the agent listed none. The result's claims holds each claim's quote, source, location (a description line, a file's head-side lines, a story sentence, or a pipeline finding) and part (by its index in parts), every one not checked yet, with the claims prompt's version and the run's stamp. The claims prompt is evaluated over its own cases, scored by recall and precision against hand-listed claims; see the evaluation. A fresh pipeline report's open findings are claims too, listed before every other, by the engine rather than the agent; see the pipeline report and CI.

The verdicts

Then the same agent judges each listed claim against the change itself — the diff and the read-only head copy — and gives it a verdict: verified, refuted or unverifiable, with its evidence source (the change itself, a CI log when a check failed or the reviewer pressed to read a passing one, or the model's memory), a one-line reason, and the lines it cites as evidence, each a file, a line and a quote. The engine re-reads every citation in the head copy: when the file is not there, the line is not one of its lines, or the quote does not start on that line as written, the verdict drops to unverifiable and says why. The model's memory never yields verified, and a verdict from memory cites no line. Every verdict also lists the lines of the head copy the agent says it read for the claim (looked), each re-checked like a citation and dropped, with a note, when the copy does not have it, so a reviewer can tell a claim the code cannot settle from one nobody looked into; and every unverifiable verdict says what could settle it (settles) — the code itself, which makes it a finding to check, a library's source, a CI log, a sandboxed run, something outside the repository, or only the author — with a one-line note. A claim that turns on how a third-party library behaves names the library whose source it needs, and is never verified without it; fetching that source is the reviewer's choice, never the companion's (ADR 0003). An answer that leaves a claim out is retried once, and then every claim stays not checked and the result says why. Each claim in the result's claims carries its verdict, and claims.judging holds the verdicts prompt's version, the outcome and the run's stamp. When a check failed, the agent also reads each failed job's log, trimmed to its failing step, and may cite its lines with a CI log as the evidence source; the engine re-reads each such citation in that log, and the citation names the check run and is labelled as a CI log's line. Refuted claims are findings, and so are unverifiable ones the code or a library's source could settle; an unverifiable claim only a CI log, a sandboxed run, something outside the repository or the author can settle is listed quietly, with no thread, and counted as no finding. A passing check's log is never read with the review: an unverifiable claim a CI log could settle offers Read the CI log (ciLogFetch) on step 5 and the overview when checks passed at the head commit, and only the reviewer's press, as the engine's readCiLog request names the claim with the reviewer's token, lists the check runs at the reviewed head again, downloads the passing GitHub Actions jobs' logs, trimmed and capped as a failed job's are, and has the agent judge the claim again with them; each line the new verdict cites is labelled as a passing check's (passingCheck), and the verdict names the logs it read (ciLogs). The verdicts prompt is evaluated over its own cases, scored by verdict accuracy, false-verified rate and what settles each unverifiable claim against hand verdicts; see the evaluation.

The acceptance criteria

The review also reads the issues the pull request links, read-only, through one GraphQL query: the closing references — which cover the description's closing keywords and the sidebar's "will close" links, in this repository or another — and the issues the pull request's own timeline shows referencing it, such as a sidebar link or a mention. GitHub returns closing references only for a pull request into the repository's default branch, and the result says so when that is why none came back. The result's criteria holds the issues and each condition from the checklist under a heading — "Acceptance criteria" by default, or the one --criteria-heading names (the second-look.criteriaHeading setting in VS Code) — each quoted and not checked until the agent maps it to the change. Issue text is untrusted: it is parsed and never followed, and the content GitHub hides, such as an HTML comment, stays in the quote for the overview to show and flag. Linked issues that cannot be read never fail the review: the result says why.

Last, the same agent maps each criterion to the change and gives it a verdict: met, partly met, not met, can't tell or needs manual check, with a one-line reason and where the change shows it — the lines of code that implement it and the lines of the automated tests that cover it, each a file, a line and a quote, and the manual checks the description reports for it, such as the steps a person followed, what they saw or a measurement they took, each quoted from the description. A criterion only a person trying the change can settle, such as how something looks on screen, needs a manual check unless the description reports one that settles it. The engine re-reads every citation in the head copy and finds every manual check in the description, where it — not the agent — sets the quote's line; a citation or manual check that does not match drops the verdict to can't tell and says why, and so does a met or partly met verdict that shows no code, test or manual check. An answer that leaves a criterion out is retried once, and then every criterion stays not checked and the result says why. Each criterion in criteria.criteria carries its verdict, and criteria.mapping holds the criteria-mapping prompt's version, the outcome and the run's stamp. Issue and description text reach the agent only inside untrusted blocks, as data never to be followed. Not met and partly met criteria are findings. The criteria-mapping prompt is evaluated over its own cases, scored by verdict accuracy, the share of criteria wrongly called met, and the recall of the labelled code, tests and manual checks; see the evaluation.

The pipeline report and CI

The companion also reads the pull request's pipeline report and CI, read-only. A no-mistakes report in the description carries an attestation naming the head commit it was made at: the report is trusted only when that commit is the pull request's current head (fresh); one made at another commit is stale, one the description lacks is missing, and one that cannot be read is malformed, and each of those is shown but never trusted. A fresh report's open findings — the ones each step leaves open after its fixes — become claims, listed before every other and judged with them, even when the agent's own listing fell back; the result's pipeline holds the report's state, the steps and the findings. The check runs GitHub reports at the head commit are listed with their annotations — at most the first 50 of each — in ci, labelled as run on the pull request's merge commit, which pull request checks run on. A job's log is fetched with the review only when the job failed, and only for a GitHub Actions job, trimmed to the step that failed — at most its last 200 lines, ending at its last error — and those trimmed logs are what the verdicts may cite. CI that cannot be read fails nothing: the result says why. Pipeline reports and CI logs were written by others, so they reach the agent only inside untrusted blocks, and the overview shows them as escaped text.

The library fetch

When a verdict needs a library's source and the head copy pins that library with its hashes — in uv.lock, poetry.lock or a hashed requirements file at the repository's root, Python first — the verdict offers a library fetch (libraryFetch): the library, the version the lock file pins, the lock file, and why the claim needs it. Nothing is downloaded with the review: the fetch runs only when the reviewer presses it, as the engine's fetchLibrary request names the claim. The engine reads the pin again from the head copy, asks PyPI which of the release's files carries a hash the lock file pins, downloads that exact file from PyPI's own file host, and checks its SHA-256 against the pin before unpacking anything; a mismatch aborts with a message naming both hashes, and nothing is unpacked. A wheel is unzipped and a source archive untarred — never built, installed or run — into the pull request's library cache (libraries/ beside its copies), read-only, with links, special files and paths that would leave the folder skipped; when the lock file pins no wheel, the companion says only the source archive was unpacked. Then the same agent judges the claim again with that folder as its only readable root, citing the library's own lines, which the engine re-reads there, and the verdict, with evidence source library source at the pinned version, names the file fetched, its hash and where it landed (library), keeps the offer that was pressed, and is stamped with the library verdicts prompt's version and the run. The library verdicts prompt is evaluated on the Python and C# canaries; see the evaluation.

A .NET library is offered a fetch when no Python lock file pins it and the head copy pins it at one exact version: in a packages.lock.json, first, with the SHA-512 it records, or else in a project file (.csproj, .fsproj, .vbproj) or Directory.Packages.props, searched below the repository's root but never in bin, obj, node_modules or hidden folders. A range, a floating version or an MSBuild property pins nothing. When pressed, the engine downloads that exact package from nuget.org's own API host and checks its SHA-512 against the lock file, or, for a project file's pin, against the hash nuget.org's catalog records for it, before reading anything. It reads the repository commit from the package's nuspec, or else from its PDB's Source Link, and the PDBs from the package — standalone or embedded in an assembly — or, when none of those carries Source Link, from nuget.org's symbol package. It then downloads each source file the PDBs name at that commit, only from the host their Source Link names when it is one of the public forges' own source hosts — GitHub, GitLab, Bitbucket or Azure DevOps — and proves each one against the hash its PDB records for it, as fetched and with LF and with CRLF line endings, since a Git host may serve other line endings than the build checked out. Only a proven file is exact source; any other is unproven (library.unproven), labelled so on its finding, and a verified verdict citing it drops to unverifiable. A package whose PDB's Source Link names a source host outside that allowlist is not fetched: the fetch fails saying so, and the claim keeps its verdict. Nothing is built, installed or run; the files land read-only in the same library cache, with a note saying how many were proven.

A package with no PDB, no Source Link or no commit has no exact source, and nothing is guessed: the press answers with the claim saying which is missing and what the package version's own licence allows. The licence is read from the nuspec of that exact package, which the fetch downloaded and checked, since a licence can change between versions: the nuspec's <license> element, when that is a licence expression, or — only when the nuspec has no <license> element — the licenses.nuget.org link that stands for one. When every licence the expression requires is a known open-source licence, the fetch turns into an offer to decompile the package (libraryFetch.decompile, naming the licence), and a Decompile link replaces the Fetch link on the finding. A licence that is not one of those is restrictive; one given as a file in the package or as another link, a missing one, or an expression the companion cannot read is unknown, and unknown counts as not allowed: the offer is withdrawn and the verdict says plainly why nothing was decompiled (noLibraryFetch). Pressing the decompile downloads and checks the package again, checks its licence again, and runs ILSpy's command line, ilspycmd, as the reviewer installed it — on the PATH or in ~/.dotnet/tools (dotnet tool install --global ilspycmd) — on the assemblies of the package's newest target framework under lib/, each written under a name of the companion's own. The companion, never the agent, runs it, with fixed arguments and with the network cut: under macOS's sandbox with a profile that denies all network, or on Linux in a new network namespace (unshare); on any other system nothing is decompiled, and the message says so. A missing decompiler is reported plainly, naming how to install it. Only the C# it writes is kept, read-only, in its own folder of the same library cache, never built or run; every verdict judged there has the evidence source decompiled library code, and its finding labels each citation and the library decompiled, not the library's source.

An npm, Cargo, Go or Maven library is offered a fetch when no Python or .NET file pins it, from the first file below the repository's root, shallowest first, that pins it at one exact version: in a package-lock.json or npm-shrinkwrap.json, its shallowest install from npm's own registry, with the SHA-512 its integrity records; in a Cargo.lock, a crate from crates.io with its checksum, the highest version when it locks several; in a go.sum, the hash of the version the go.mod beside it requires — a library named by an import path matches the longest module holding it, and a module the go.mod replaces pins nothing; or in a pom.xml, a dependency at a literal version or one of the file's own properties, or in a gradle.lockfile. When pressed, the engine downloads only that exact file from the ecosystem's own host — the package tarball from registry.npmjs.org, the crate from static.crates.io, the module's zip from proxy.golang.org, the sources jar from Maven Central (repo.maven.apache.org) — and checks it before unpacking anything: npm's SHA-512, Cargo's SHA-256, Go's h1: hash of the zip's files, and, since a Maven build records no hash, the SHA-1 Maven Central records for the sources jar. A mismatch aborts naming both hashes, and nothing is unpacked. The file lands read-only in the same library cache, unpacked and never built, installed or run.

When nothing pins the library, the verdicts prompt may name its public repository and the tag of the version the project uses (namedRepository), and the verdict offers a fetch of that tag instead, only from GitHub or GitLab over https: the engine downloads the tag's archive from codeload.github.com or gitlab.com, with no token, and untars it read-only the same way. Nothing pins what it downloads and a tag can move, so a verdict judged there has the evidence source a named repository, weaker than pinned source, and the offer, the fetched library's note and the finding each say so. A verdict that needs a library with neither route says so plainly (noLibraryFetch): the files the companion reads for a pin, and that the agent named no repository it can fetch.

Drafting a comment from a finding

Any finding offers a draft comment: a refuted claim or an unverifiable one the code or a library's source could settle, a part neither the description nor a linked issue explains, a change they describe that the diff does not contain, and a not met or partly met acceptance criterion. Nothing is drafted with the review: a draft is written only when the reviewer asks for one, as the engine's draftComment request names the finding in the engine's latest review of the pull request. The same agent writes a short draft from the finding alone — its statement, where it is made, its reason and the evidence it cites — with the finding's text inside an untrusted block, as data never to be followed, and the engine checks the draft before answering, with plain checks and no model judge: it must cite one of the finding's evidence locations as written (a path:line it cites, else where the statement is made: its file, the description, or the issue's #number), add no claim the finding lacks — name no file or code name and no number the finding does not hold — and stay within 600 characters. A draft that fails is retried once with what to fix, and then no draft is given and the reviewer reads why. The answer carries the draft, the finding it was drafted from, the draft-comment prompt's version and the run's stamp. The draft is never sent on its own: the reviewer edits it and adds it to the pending review, or discards it, and sending stays the Send review page's one press (ADR 0002). The draft-comment prompt is evaluated over its own cases, scored by its plain checks, which agree with every hand label on hand-written drafts; see the evaluation.

Asking about a part

An ask is a fixed, typed request the reviewer makes about one part, never free chat. Every ask is defined in one place, the ASKS registry in packages/engine/src/asks.ts: the words the part's context menu shows, the prompt that answers it, whether it checks a claim the reviewer picks or selects, and the sections its answer reads as; the engine's ask request names an ask by its kind and the part by its index in the engine's latest review of the pull request, and the extension offers one context-menu command per kind, which its manifest declares with the registry's title. The first ask, Explain this part, has the agent say what the part does and why it matters to the change, citing the part's lines, from the part's name, reason and numbered diff, the change's other parts by name and the pull request's title and description, all inside untrusted blocks, as data never to be followed. The engine checks the answer before sending it, with plain checks and no model judge: every line it cites must be one the part shows, on the side it names — head for an added or kept line, base for a removed one — with its quote on that line, and it must name no file or code the change does not show, as the story's names check reads them. An answer that fails is retried once with what to fix, and then there is no answer and the reviewer reads why. The answer carries the part's name, its sections, its checked citations, the prompt's version and the run's stamp, and the overview shows it at the top with that stamp, each cited line a link that opens it read-only in its side's copy. Nothing of an ask reaches GitHub. The explain prompt is evaluated over its own cases, scored by its plain checks, which agree with every hand label on hand-written explanations; see the evaluation.

Verify this claim runs the judging pass on one claim: the text the reviewer selected on the head side of the part's diff, the claim the request names (as the side bar's Verify again does), or else the one of the part's claims they pick — and with no part named, the one of the review's claims they pick, as the side bar's Verify a claim… runs it. A selection must sit on head-side lines the part shows and be on those lines of the head copy as selected; its quote drops each line's comment marker, as a listed claim's does, and it is a claim of its own source, the reviewer. The verdicts prompt judges it alone, against the change, the head copy and any failed check's CI log, its quote inside an untrusted block, and the engine re-reads every citation and holds the verdict to the same rules as the review's: model memory never yields verified. When the claim needs a library the head copy pins, or whose repository and tag the agent named, the verdict offers its library fetch; when a CI log could settle the claim and checks passed at the head commit, it offers Read the CI log; neither fetches anything until the reviewer presses it (ADR 0003). The judged claim joins the review — in the picked claim's place, or after every claim for a selection — marked as judged by the ask, so its checked verdict stands even when the claims pass's judging fell back; the verdict shows on the diff like any other, with its fetch pressed from its finding, and a claim already judged in a fetched library's source is not judged again. The answer shows the claim and where it is made, the verdict with its evidence source and reason, the library fetch offered or why none is and the CI log read offered, each cited line of the head copy a link. Its stamp names the verdicts prompt, which the verify ask's selections are evaluated against as well.

What covers this? has the agent list the automated tests, in the change or anywhere in the head copy, that exercise the part, and the manual checks the pull request's description reports for it, from the part's numbered lines, the change's other parts by name and the pull request's title and description, all inside untrusted blocks. None found is a valid answer: when no test exercises the part and the description reports no manual check of it, the answer says so and where the agent looked. The engine re-reads every cited test line in the head copy and finds every manual check in the description before answering; an answer that fails is retried once. The cover prompt is evaluated over its own cases, none found among them; see the evaluation.

Documentation links

After the review, the library APIs the change's added lines use link to their documentation at the version the project pins. A Python name counts when the file imports it from a library a lock file pins at one exact version (uv.lock, poetry.lock or a requirements file), the module named like the library; a C# name when it sits under one of the file's usings, .NET's own APIs at the target framework the nearest project file names, and a package's at the version a lock or project file pins. Other languages get no links yet, and the result says which files were not read for them (ADR 0001).

The links come first from the libraries' published inventories, which the engine — never the agent — downloads: for a Python library, the Sphinx inventory (objects.inv) under the documentation site PyPI names for the pinned release, tried in Read the Docs' folder for that version first, and read only when the version its header names documents the pinned one (an inventory of 23.1 documents 23.1.0; one of 8.5.x does not document 8.1.7); for .NET, the API reference's cross-reference map, https://learn.microsoft.com/en-us/dotnet/.xrefmap.json, scanned as it streams in for the names the change uses, each linked with the view of the pinned version only when the map lists it in that version. An inventory is untrusted data: it is parsed, never run; every download is https on a named host with no credentials and no port, connects only to public addresses — loopback, private-network and link-local ones are refused as the connection is made — follows no redirect, and is capped in what it downloads and what it inflates to. A library whose inventory cannot be found or read is a note, never a failure.

Then, with an agent, the APIs no inventory linked go to the agent, which suggests the documentation page of each from what it knows: the API's id, its library, the pinned version and where the change uses it reach it inside an untrusted block, and it has no network. The engine checks each suggestion names an API it asked about, once, at an https address on a named public host, and never fetches it; an answer that fails is retried once. The result's docLinks holds every inventory link first, then every suggestion, each with its API, library, pinned version, pinning file, the head-side lines that use it and where it came from (inventory, with the inventory's URL, or agent), the APIs left without a link, a note per library on what was read, a note naming how many APIs the listing cap left out, and the doc-links prompt's version, outcome and stamp. The overview lists the inventory links, then the suggestions, each flagged as suggested and not checked, and a hover on the name on the head side of the diff shows the same, the inventory's link first. The doc-links prompt is evaluated over its own cases; see the evaluation.

The budget

Every review is metered: the result's budget holds the limits it was given and what it used — each agent run started, a retry included, each file downloaded and the bytes downloaded, counted as they stream. In VS Code the limits come from the second-look.budget.agentRuns, second-look.budget.filesFetched and second-look.budget.downloadMiB settings, 0 for no limit, and the review's later library fetches, CI log reads, asks and drafts add to the same use; the review command sets none and prints the use as its last line on stderr. The review stops at its limits: an agent pass, or its retry, starts only while a run is left, else it falls back saying which limit, and the claims and acceptance criteria it leaves stay not checked with that limit as their reason; a documentation inventory past the file or size limit is not downloaded, its APIs left unlinked with the reason in the notes; and a library fetch or CI log read pressed once a limit is reached is refused with a plain message. The review's own reads — the pull request, its diff, both copies, CI and the linked issues — are never refused, and asks and drafts are only counted.

Probing the reviewer's coding agent

The engine does its model work through the coding agent the reviewer already has installed and signed in, never through a model API of its own (ADR 0004). One adapter interface, documented in packages/engine/src/agent.ts, runs an agent non-interactively with the companion's own prompt and a JSON schema for the answer. It first probes the installed version for what it supports. Two adapters exist: Pi and Claude Code, chosen by name (--agent pi or --agent claude-code; the VS Code settings offer the same names). Codex is not supported yet. docs/agent-safety.md lists what each agent can read, what it is denied and how each limit is enforced, and the known gaps.

Every agent run is locked down: file-reading tools only, so no shell and no network; nothing from the pull request configures the agent; no session file; the companion's own system prompt, with the task on stdin; and the GitHub token variables removed from the agent's environment. A version whose help lacks any of the lockdown flags is never run. The agent signs in with its own login: the companion never reads or stores it, and the agent inherits the engine's environment minus the GitHub token.

The two agents reach that lockdown differently. Pi has no sandbox of its own, so the companion's guard (packages/engine/src/pi-guard.ts), loaded as Pi's one extension, checks every tool call before it runs, confining every path to the read-only copy, symbolic links included, and refusing URLs and credential paths by name: SSH keys, cloud credentials, the GitHub login, agents' own logins. Claude Code is driven by the flags it offers plus a guard of the companion's own (packages/engine/src/claude-code.ts): a PreToolUse hook (packages/engine/src/claude-guard.ts) that checks every tool call with the checks it shares with Pi's guard (packages/engine/src/read-guard.ts), the permission mode pinned to default so Claude Code's own working-directory check stands behind it, deny rules for the credential paths, and runs that fail closed when the guard did not see a call. docs/agent-safety.md lists, for each agent, what it can read, what it is denied, how each limit is enforced, and the known gaps.

Each Claude Code run's stamp reports which login it used — the stored subscription sign-in, an OAuth token or cloud credentials from the environment — and warns when an inherited ANTHROPIC_API_KEY silently overrides the subscription. The key itself is never read, printed or copied: only its presence is checked. Anthropic's terms are unclear on third-party tools driving a reviewer's own Claude Code (ADR 0004).

In VS Code, the settings pick the agent, the model, the effort, the account label and each agent's executable path; getting started documents the settings, the status bar and their warnings. Every result is stamped, so the reviewer can always tell which agent, model and effort said what.

Pull request text reaches the agent inside a marked untrusted block, with Unicode tag characters, zero-width characters and bidirectional controls stripped. HTML comments, which GitHub hides from the reviewer, are kept but delimited. Each answer is checked against its schema. An invalid answer is retried once, then reported as a failure, never guessed. Every result is stamped with the agent, its version, the model, the effort, the run date, the tokens and cost when the agent reports them, and the reviewer's account label when the settings gave one. Runs have a timeout and a concurrency limit, and a run that times out keeps what it wrote.

The probe command runs a fixed prompt that is a setup check, not a review prompt, and the contract tests use it; the review prompts, such as grouping, ranking, the story, the unexplained changes, the claims, the verdicts, the library verdicts and the criteria mapping, land with their evaluation (ADR 0006). With Pi installed and signed in:

GITHUB_TOKEN="$(gh auth token)" node packages/engine/dist/main.js \
  probe https://github.com/{owner}/{repo}/pull/{number} \
  --target README.md --target ~/.ssh/id_ed25519 --target https://example.com/

It prints what the installed Pi supports and, for each target, the agent's schema-checked answer and the run's stamp. The credential path and the URL come back refused. --agent picks the adapter (pi by default, claude-code for Claude Code). --model and --effort pick the model and effort level; a model or effort that is not a plain name, or an effort the agent does not accept, is refused before the agent starts. --agent-timeout (seconds, default 300) and --agent-concurrency (default 2) set the pacing. Tests drive each adapter through a shared contract suite against a fake agent executable and never call a model. One opt-in suite does: npm run test:live-claude drives the real Claude Code adapter on the reviewer's signed-in Claude Code, on each model SECOND_LOOK_LIVE_MODELS names (claude-opus-5-5,claude-sonnet-5,haiku by default), and shows every read outside a fixture copy refused, a canary outside it never in any event, in-copy reads working, every tool call audited and the default permission mode; a second case removes the guard after the probe and shows default mode still refusing and the run failing. It spends subscription quota, so it never runs in npm test, npm run check or CI.

Reading a package's portable PDBs

The pdb debug command reads a local NuGet package (.nupkg), symbols package (.snupkg), portable PDB or assembly, and prints every portable PDB in it — standalone .pdb files and PDBs embedded in an assembly — as JSON: each source document with its hash algorithm (SHA-1 or SHA-256), its hash, and its Source Link URL, plus each PDB's Source Link map. It needs no token and reads only the given file.

curl -sSLo dapper.nupkg https://www.nuget.org/api/v2/package/Dapper/2.1.35
node packages/engine/dist/main.js pdb dapper.nupkg

Malformed input — a Windows PDB, a truncated file, corrupt compressed data — fails with a clear error and exit code 1. Tests read PDBs from public packages stored under packages/engine/test/fixtures/pdb.

Reviewing a pull request in VS Code

The extension adds a Second Look: Review pull request command and its own Second Look icon in the Activity Bar, which opens the side bar; its second step lists your open pull requests, and its Review buttons, or a pull request URL pasted into its box, start a review. Installing the companion and choosing its agent, model, effort and account is getting started. What a review shows — the side bar, the ranked parts and the stages they arrive in, the overview, reading a part, the claims and their verdicts, the asks, and how a comment becomes a GitHub review — is how a review works. What stays on the machine and what is sent to GitHub is the privacy page.

To try the extension from source instead, build first (npm run build), then open the repository in VS Code and press F5 (the "Run the companion extension" configuration starts a development host with the extension loaded) and run the command in the development host.

About

VS Code companion for human pull request review: reads the change in the order that matters and checks its claims against real code.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages