Repository navigation
Review bot: read every non-generated diff, and check that it did - #12
Conversation
On Deco354/factory-farm-em#29 all seven approvals (2026-10-06..08) said they had read only part of the PR; none read `analysis/summary.py`, which computes its headline numbers, and `@claude` reviews of the unread files then found a bug the approvals had missed. 15 of 134 approvals across the consuming repos since 2026-09-01 admit unread code (#11). Cause: `gh pr diff` was the review's only view of the diff. Above Claude Code's tool-output limit it arrives as a 2 KB preview plus a saved file, it takes no path argument, and nothing told the review to open the file. All seven #29 runs tried a per-file diff, were refused, and approved after reading one to three files. Sonnet 5 (the action's default until late September) opened the saved file in 2 of 2 sampled runs, Sonnet 5.5 in 1 of 9. - Write per-file diffs: `git diff` of the pull_request merge commit against its base parent (checkout is now fetch-depth 2), one file per changed path under .claude-review/diffs/. generated_paths become exclude pathspecs (whitespace-separated git globs; a slashless pattern matches at any depth), so generated files get no diff and are listed by name and size only. Binary files are listed, not diffed. - The prompt lists every diff file and asks for each to be read to its last line; it no longer points at `gh pr diff` or the skim rule. - Measure which diffs the review read, from the execution file: a diff is read only when successful Read calls covered every line. - If any are unread, a second action run resumes the review's session (`--resume` with the action's `session_id` output) to read the rest and file one more verdict for the whole PR. continue-on-error, so it cannot sink the first verdict; the verify step checks the latest verdict as before, unchanged. - Report coverage in the job summary; anything still unread gets a PR comment with a ready-to-paste `@claude` prompt (posted with GITHUB_TOKEN, so it triggers nothing). Coverage is report-only by decision: tying the verdict to it would block too many PRs. Not done: pinning the model, re-enabling Task (PR #73's stall), demoting approvals after the fact. Also in CLAUDE.md: the step list gains the new steps and loses the contradiction guard removed on 2026-09-30, and the pipeline repo is no longer described as running its own copies (it calls @v1 since its #169).
There was a problem hiding this comment.
I read all three diffs to the end and found no significant issues. No change weakens the verify step's jq or the tool allowlist. Inputs still enter scripts only through env:, and claude-pr-review.yml adds no permissions: block and no new input. README and CLAUDE.md are updated, including the changelog entry.
I traced the new shell by hand and it holds up:
- The
:(exclude,glob)…pathspec construction is valid. --numstat -zparsing handles renames and binary files.--literal-pathspecsis used for the per-file diff.- The
HEAD^2guard matches what thepull_requestcallers provide.
Not blocking: "Measure which diffs the review read" runs under set -euo pipefail. If its jq, cp or wc failed unexpectedly, the step would fail the job. That would go against the "coverage is report-only" invariant this PR documents. Adding continue-on-error: true to that step would make the invariant hold. "Report review coverage" already degrades to a warning in the same situation.
claude[bot]'s review of #12 noted that "Measure which diffs the review read" runs under set -euo pipefail, so an unexpected jq or cp failure would fail the job - against the report-only rule the PR documents. It is now continue-on-error, and the follow-up pass also requires it to have succeeded, so a half-written step can never start a follow-up with an empty prompt.
There was a problem hiding this comment.
I read all three diffs to the end and found no significant issues.
- Per-file diffs: the numstat parsing is correct, including the
-zrename records. The exclude pathspecs build correctly fromgenerated_paths. The merge-commit guard andfetch-depth: 2fitpull_requestevents. Inputs reach the scripts only throughenv:. - Invariants: the job still declares no
permissions:block. The verify step's jq and the first pass's tool allowlist are unchanged, and the follow-up repeats the allowlist as the new CLAUDE.md note says. - Coverage: the measurement, the follow-up pass and the report step are all report-only. The first two are
continue-on-error, and the report step never exits non-zero. A follow-up verdict is picked up by the existing "latest bodied review" check. - Docs: the README and CLAUDE.md updates and the changelog entry match the change. No input was added, and
generated_pathskeeps its semantics, with the format now documented.
Minor, not blocking: the coverage warning comment is posted on every run that leaves diffs unread, so a PR with several pushes can collect several of them. Read's output-token truncation can also make the line-range count slightly optimistic. Both are acceptable for a report-only feature.
I could only review the YAML and could not run it. The selftest self-skips on this PR, so the first real exercise will be on a consuming repo after release.
Review bot: test sentfutures/devops#12 before it releases (temporary)
On Deco354/factory-farm-em#40 (2026-10-08, 2 runs) the first pass read 12 of 24 diffs, most important first, and skipped all tests and docs. The prompt's older text invited that: "focus on ... the changed code only" and "you do not need [the tests]". The prompt now asks for every diff to be read up front, names tests, docs and configs as in scope, and limits the reporting bar and the CI paragraph to what they were for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
I read all three diffs and found no significant issues.
- Per-file diffs: the pathspec construction (
:(exclude,glob)…), rename handling in thenumstat -zparsing, theHEAD^2guard withfetch-depth: 2, and the quoted heredocs with env-only inputs all hold up. - Coverage jq: the range-merge logic, failed-Read filtering, and offset/limit defaults are correct.
- Follow-up pass: it is
continue-on-error, mirrors the first pass's allowlist, and does not touch the verify step's jq, which still picks the latest bodied review for the head SHA. - Invariants: no
permissions:block was added, and no new inputs were added. Thegenerated_pathssemantics change is documented in the input description, the README, and the changelog. - CLAUDE.md: the file is updated alongside the workflow, as its own rules require.
Minor, not blocking: the coverage-warning comment is posted on every push that leaves files unread, so a long-running PR can accumulate duplicates. I could not verify that the --resume second pass finds the first session on the runner. The report-only, continue-on-error design limits the cost if it does not.
Prompt wording alone does not get every diff read. On Deco354/factory-farm-em#40 (2026-10-08) the first pass read 12, 12 and 7 of 24 diffs under three wordings, and each follow-up pass, handed the list of unread diffs, read all of them. A PreToolUse hook, passed through the action's settings input, now refuses gh pr review while any diff is unread and names those diffs. It refuses at most twice, and lets the verdict through whenever it cannot tell. It controls when the verdict is posted, never what it says, and never fails the check. coverage.jq moves to "Write per-file diffs" so the hook and the coverage steps share one definition of read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
I found no significant issues in this PR. I could not run the workflow or read CI, so this is from reading the diffs only.
- Per-file diffs:
- The
-znumstat parsing handles renames and binary files correctly. - The exclude pathspec built from
generated_pathscomes out well-formed (:(exclude,glob)…). --literal-pathspecsis used when diffing each file.- Workflow inputs enter
run:scripts only throughenv:. - The
claude-pr-review.ymljob still declares nopermissions:block.
- The
- Verify step and tool allowlist: both are untouched. The follow-up pass repeats the allowlist and the diff says so.
- Verdict gate hook:
- It is capped at two refusals.
- It fails open on any error, so it cannot block a verdict permanently.
- The coverage jq reads the transcript and execution-file shapes consistently.
- Docs: README documents the changed
generated_pathssemantics and adds a changelog entry. The CLAUDE.md step list matches the workflow. - No new inputs: callers need no change.
Minor, not blocking:
- The coverage warning comment is posted on every run that leaves diffs unread, so repeated pushes can stack duplicate comments.
- The README says coverage "never changes the verdict", but the follow-up pass can file a new verdict. The wording could say it never gates the verdict.
…w one) From the selftest review of 4987b9c: "never changes the verdict" was wrong, because the follow-up pass files a verdict of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
I read all three diffs to the end and found no significant issues. I could not run any of it, so the notes below come from reading the code.
- Invariants: the job still has no
permissions:block. Inputs reach scripts only throughenv:. The verify step's jq and the tool allowlist are untouched. - New inputs: none. The
generated_pathssemantics changed to whitespace-separated git globs. The input description and README are updated in the same PR, and the changelog says so. - Per-file diffs: I checked the
numstat -zrename handling, the:(exclude,glob)pathspec construction, the empty-array handling underset -u, and the heredoc indentation after YAML strips the block indent. They look correct. - Coverage jq: the range-merge
coverslogic, the exclusion of failed Reads, and theoffset/limitdefaults look correct. - Verdict gate: the hook fails open on every error path. It refuses at most twice, and it only affects when the verdict goes out. This matches the "coverage is report-only" decision from #11.
- Follow-up pass: it is
continue-on-error, and itsclaude_argsallowlist mirrors the first pass. Its verdict is the latest bodied review, so the verify step's existing checks cover it. - Docs: the CLAUDE.md and README changes match the workflow. The changelog entry gives dates, the incident, and the cost.
Minor notes, not blocking:
- The coverage warning comment is posted on every run that leaves diffs unread, so repeated pushes can stack several of them.
- File names go into the prompt unescaped. I don't think this opens a new injection path, because the diff content is already untrusted input to the review.
…mmary Read stops at its 25,000-token cap without an error: on 2026-10-09 a 2,000-line diff came back as lines 1-935 (truncatedByTokenCap). The coverage count trusted the call's default 2,000-line limit, so it scored that diff as read, the gate let the verdict through and no follow-up ran. It now takes each range from the result's startLine/numLines, falling back to offset/limit only when a result has no structured form. #40's largest diff was 543 lines, so the live runs never hit this. The prompt, the gate's message and the follow-up prompt no longer say Read returns 2,000 lines at a time. The review body is told to describe the change, not how it was reviewed: all three gated approvals on factory-farm-em#40 opened with "I read every diff ... to its last line". Inline comments go on the changed file's own lines, not the diff file's.
There was a problem hiding this comment.
This PR replaces gh pr diff as the review's view of the diff with one diff file per changed path. It adds a PreToolUse hook that holds the verdict until every diff is read, a coverage measurement, a resumed follow-up pass, and a PR comment for anything still unread. I found no bugs, security issues or CLAUDE.md violations.
Things I checked:
- Invariants: The job still declares no
permissions:block. Inputs reach scripts only throughenv:, and the prompt text uses quoted heredocs. The verify step's jq and the tool allowlist are untouched. The follow-up step'sclaude_argsallowlist matches the first pass, as the new CLAUDE.md invariant requires. - Inputs: No input is added.
generated_pathschanges meaning from "skim" to "exclude", and the README, the input description and the changelog all say so. - Per-file diff step:
- The pathspec construction is correct:
:(exclude,glob)…is well-formed, and patterns with no slash also get the**/form. read -aavoids glob expansion.- The
--numstat -zparsing handles renames, and binary files are listed separately. fetch-depth: 2is enough forHEAD^1..HEAD, and the step fails with a clear error on a non-merge checkout.
- The pathspec construction is correct:
- Coverage logic:
coverage.jqcounts the lines Read actually returned and merges ranges correctly. The hook is capped at two refusals, and it lets the verdict through whenever it cannot tell. The measurement and the follow-up step arecontinue-on-error, and coverage stays report-only, as the new CLAUDE.md invariant requires. - Docs: The CLAUDE.md step list and the README match the workflow's behaviour.
Two minor notes, neither blocking:
- The changelog entry is dated 2026-10-08 but cites a 2026-10-09 observation.
- The "Write per-file diffs" step fails the whole job on a non-
pull_requesttrigger. That is intentional and documented, but it is a new startup-style failure mode for any caller that deviates from the template.
There was a problem hiding this comment.
This replaces gh pr diff with per-file diffs written from the merge commit, and adds a verdict gate hook, a coverage measurement, a follow-up pass and a PR warning comment. I found no significant issues.
- Diff generation: The rename and binary handling, the NUL-safe parsing, the
exclude/globpathspec construction and the emptygenerated_pathscase all work. Inputs reach scripts only throughenv:. - Invariants: The job still declares no
permissions:block. The verify step's jq, the tool allowlist andTaskbeing disallowed are unchanged. The follow-up step repeats the allowlist verbatim, as CLAUDE.md now requires. - Fail-open paths: The hook, the measurement and the follow-up are
continue-on-errorand fail open. Coverage never touches the verdict, which matches the #11 decision. - Documentation: The README and CLAUDE.md updates and the dated changelog entry are consistent with the code. No input is added, and
generated_pathsis now documented as git globs.
Non-blocking: the coverage-warning comment interpolates changed file names into a fenced block. A file name containing a backtick fence could break the formatting. Only a human pasting the prompt would be affected.
… lines
The gate's refusal and the follow-up prompt now list each unread diff's
unread line ranges ("not read yet: line 4890"), not just "not read to the
end". On factory-farm-em#40 (2026-10-09, 2 runs) Sonnet 5 paged to line
4889 and to 4883 of a 4,890-line diff, re-read the same range when told
only the file name, was held back twice, and needed the follow-up pass
and a second approval each time.
The prompt and both messages ask for long diffs about 800 lines per call,
starting at the first unread line: Sonnet 5.5 asked for 2,000, 1,400 and
1,100 lines of that diff, each over Read's 25,000-token cap, before 900
worked. The prompt also points at the "diff lines" count, not +/−.
There was a problem hiding this comment.
This PR replaces the review's dependence on gh pr diff with per-file diffs written from the merge commit. It adds a capped verdict gate hook, a coverage measurement, a resumed follow-up pass and a coverage report. I found no significant problems.
- Invariants hold. The job still declares no
permissions:block.generated_pathsenters scripts only throughenv:, andread -akeeps it from being glob-expanded. The tool allowlist is unchanged and the verify step's jq is untouched. No new input was added, so the README input documentation is accurate as it stands. - Gate and follow-up are fail-open. The hook refuses at most twice and lets the verdict through on any parse error or missing transcript. The measurement, gate and follow-up steps are
continue-on-error, and the report step never fails the check. This matches the stated report-only decision. - Docs. The README changelog entry and the CLAUDE.md step list and invariants are updated together, as the repo conventions require. The two
claude_argsallowlists are identical and cross-referenced.
Two behaviours to be aware of, neither blocking:
- The "Write per-file diffs" step hard-fails if the checkout is not a
pull_requestmerge commit. This is documented and matches the callers' triggers. A caller on another trigger would get a red check, not a silent skip. - The coverage warning comment is posted on every run that leaves diffs unread, so repeated pushes can stack duplicate comments.
There was a problem hiding this comment.
The change is correct, and I found no bugs, security issues, or CLAUDE.md violations.
- Per-file diffs: the numstat parsing handles renames and binary files correctly, and
generated_pathsbecome exclude pathspecs without shell glob expansion.fetch-depth: 2is enough for diffingHEAD^1..HEAD, and theHEAD^2guard fails clearly when the checkout isn't a merge commit. - Invariants: inputs reach scripts only through
env:or quoted heredocs. The job still declares nopermissions:, and the tool allowlist is unchanged. The verify step's jq is untouched. The follow-up step repeats the first pass's allowlist, as CLAUDE.md requires. - Gate and coverage: the hook caps refusals at two and exits 0 on any parse or jq failure. The coverage and follow-up steps are
continue-on-error, so the verdict is never blocked. The coverage jq counts lines Read actually returned, not the lines requested. - Docs: the changed
generated_pathssemantics are documented in the input description, the README, and the changelog. CLAUDE.md's step list is updated to match.
A note for a follow-up, not a blocker: the coverage comment needs issues: write or pull-requests: write on the caller. If a caller lacks both, the step already degrades to a warning.
Sonnet 5.5 defaults to medium effort and read 9-13 of 25 diffs before its first verdict attempt on factory-farm-em#40; at high it read 24 of 25, as Sonnet 5 did. The prompt named Grep and Glob, which Claude Code no longer has, and claimed every other Bash command is denied, which read-only commands no longer are.
There was a problem hiding this comment.
This replaces the gh pr diff view with one diff file per changed path, adds a verdict gate that holds gh pr review until the diffs are read, adds a follow-up pass and a coverage report, and sets --effort high. I found no bugs or CLAUDE.md violations.
Checked against the invariants in CLAUDE.md:
- Permissions and inputs: the job still declares no
permissions:block.generated_pathsreaches the script throughenv:and is split withread -a, sooutputs/**is never globbed against the checkout. The only${{ }}inside a script body isgithub.repository, which is not user-controlled. - Verdict verification: the verify step's jq, the
commit_idandsubmitted_atfilters, and the tool allowlist are untouched. Both passes use the sameclaude_args, as the new CLAUDE.md invariant requires. - Failure handling: a failed diffs step (no merge commit) skips the review step. The always-running verify and escalation steps then report no verdict and escalate to a human, so the failure is not silent. The gate, coverage and follow-up steps are all
continue-on-error. The hook lets the verdict through when it errors and refuses at most twice. - Shell and jq: the numstat
-zparsing, including renames, the exclude pathspec construction, the heredoc nesting inside the YAMLrun:blocks, and thegapsrange arithmetic incoverage.jqall look correct. - Docs: the README changelog entry and the CLAUDE.md step list and invariants match the workflow. No new input is added. The
generated_pathssemantics change to git globs is documented.
Minor notes, not blocking:
- The coverage warning comment is posted on every run that leaves diffs unread, so repeated pushes will stack warnings on the PR.
- The gate's cap of two refusals is shared across the first pass and the follow-up. If the first pass uses both, the follow-up is not gated. That looks intended, but it is worth a line in the comments.
- Whether the resumed follow-up session keeps the hook and writes to the transcript path the hook expects depends on Claude Code and action behaviour. This PR edits the workflow, so the selftest self-skips it, and the first real PR after merge is the check.
There was a problem hiding this comment.
The change is correct, and I found no significant issues.
What I checked
- Per-file diff step. The
HEAD^1..HEADdiff against the merge commit is sound withfetch-depth: 2, and the step fails with a clear error when HEAD isn't a merge commit. NUL-delimited numstat handling covers renames (empty path, thenfromandto) and binary files.excludepathspecs are built fromgenerated_pathsviaread -a, so nothing is glob-expanded.--literal-pathspecsis used for the per-file diff. coverage.jq. Range merging ingaps, the fallback to the call'soffset/limitwhen a result has nonumLines, and the failed-Read filtering all behave as intended. Zero-line diffs count as read.- Verdict gate. The hook is capped at two refusals and exits 0 whenever it can't tell. It is
continue-on-error, so it only decides when the verdict goes out and never what it says or whether the check fails. - Follow-up pass.
continue-on-errorand report-only. Itsclaude_argsmatch the first pass, as CLAUDE.md requires. - Invariants. There is still no
permissions:block. Inputs reach scripts only throughenv:. The allowlist and the verify step's jq are untouched, and no new input was added. The${{ github.repository }}interpolations match the existing style. - Docs. The README, the changelog entry and the CLAUDE.md step list agree with the workflow. The
generated_pathssemantic change (whitespace-separated git globs) is documented. The changelog also notes that the live values already conform, so no caller changes are needed.
Notes, none blocking
- The README says a verdict is "never withheld" while also saying the gate holds it back at most twice. Both are accurate as written, but a reader could trip on it.
- Because
generated_pathsis now parsed as git pathspecs, a directory-style pattern such asoutputs/with a trailing slash and no**may not match files under it. The documented forms (outputs/**,uv.lock) work. - The diff step now fails the job hard if a caller triggers on something other than
pull_request. The error message says why, and the callers in this repo usepull_request.
…ockfiles Under this PR a generated_paths file gets no diff, so the review cannot see it at all; before, its hunks were in the diff it was told to skim. A lockfile's changes are small (factory-farm-em's uv.lock: 2-56 lines) and are the only place indirect dependency and source changes show, so the input description, README and caller template stop suggesting uv.lock, and the template's 'skim' comment is corrected. Mirrored in sentfutures/.github#4; factory-farm-em#44 stops withholding its uv.lock.
There was a problem hiding this comment.
The change is correct as written, and I found no bugs, security problems or CLAUDE.md violations.
Invariants. The job still declares no permissions: block. generated_paths reaches the diff step only through env:, and every prompt template substitution is guarded by shopt -u patsub_replacement. The tool allowlist and the verify step's jq are unchanged. The new settings input, the follow-up step and the coverage steps all pass through $GITHUB_OUTPUT or env:. There is no new workflow_call input.
Per-file diffs.
fetch-depth: 2is enough forHEAD^1..HEAD, and theHEAD^2guard fails loudly on a non-merge checkout.read -aavoids glob expansion ofoutputs/**.- The exclude pathspec built with
${g#:(}comes out as:(exclude,glob)…, as intended. - The
-znumstat rename parsing and the-Mdiff offrom/pathare consistent. Theread -d ''calls inside the loop consume the same fd as the<"$state/numstat"redirect. - A non-glob value such as
outputs/now matches nothing, so those files get reviewed instead of skimmed. That is the safe direction, and the changelog documents the semantic change.
Coverage and gate.
coverage.jqcounts the lines Read actually returned, with a fallback to the call's offset/limit.- The hook exits 0 on any parse failure, so it cannot block a verdict. It refuses at most twice, which keeps coverage report-only per #11.
- The follow-up step's
claude_argsmatch the first pass, as CLAUDE.md requires. - The verify step still takes the last bodied review, so the follow-up's verdict is the one that counts.
- The coverage comment needs
issues: write, which the caller template already grants. If posting it fails, it degrades to a warning.
Follow-ups, not blocking:
generated_pathsis read with a singleread, so only the first line of a multi-line value is honored. Extra patterns silently fall back to being reviewed. The input description says "whitespace-separated", so this matches the docs, but a YAML|block would surprise someone.- Per CLAUDE.md, the
callers/claude-pr-review.caller.ymlcomment change needs mirroring insentfutures/.github/workflow-templates/. - A PR that adds both
xandx.diff/ywould makemkdir -pcollide ondiffs/x.diffand fail the diff step. It is very unlikely, but it would be a hard failure. - As the CLAUDE.md notes say, this PR edits the workflow, so the selftest will take the
NOT_REVIEWEDpath. The new steps get no live exercise until the change is onmain. Watch the first few PRs after release.
A plain `read -a` stops at the first newline, so a YAML | block with one pattern per line withheld only the first pattern and silently reviewed the rest (devops#12 selftest note). `read -d ''` reads the whole value; the prompt shows the patterns on one line.
There was a problem hiding this comment.
This looks correct, and I found no significant issues.
Per-file diffs. Diffing HEAD^1..HEAD of the pull_request merge commit is the right base, and fetch-depth: 2 is enough to get both parents.
- The NUL-delimited numstat parsing handles renames (the empty path field followed by the old and new paths) and binary files (
-counts). - The inner
read -d ''calls consume the same stdin as the outer loop, which is what rename parsing needs. generated_pathsis split withread -arather than an unquoted expansion, so there is no glob expansion against the checkout.- The
:(exclude,glob)…pathspecs are built correctly, including the**/variant for slash-less patterns. GENERATED_PATHSenters scripts only throughenv:, as the invariants require.
Coverage jq. The gap computation, the use of the result's startLine/numLines over the call's limit, and the fallback for older result formats are consistent with each other. The failed-call filtering is correct.
Verdict gate. It is capped at two refusals, it lets the verdict through on any parse or jq failure, and it never fails the check. That matches the "coverage is report-only" invariant. The step is continue-on-error, and the settings input is empty when the step fails, so a broken gate degrades to the old behaviour.
Unchanged invariants.
- The job still declares no
permissions:block. - The tool allowlist is unchanged.
- The verify step's jq is untouched.
- The follow-up step repeats the first pass's
claude_args, as the new CLAUDE.md note requires. - No new inputs were added. The
generated_pathsdescription and the README and caller-template text were updated together, and the README changelog entry is present.
Notes, not blocking:
Report review coverageruns withoutcontinue-on-error. Its commands run under the defaultbash -e, so an unexpectedjqfailure past the early guard would fail the job. That is not the "never fails the check" behaviour the docs promise. In practice this is unlikely, because the guardedjqcall has already succeeded on the same files.- The coverage warning comment is posted on every run that leaves diffs unread, so a PR that keeps getting pushed can collect one per push. You could edit or replace the previous one later if that gets noisy.
callers/claude-pr-review.caller.ymlchanged, so the mirrored template insentfutures/.github/workflow-templates/needs the same comment update. That repo isn't part of this PR, so I couldn't check it.- A PR that edits this workflow will self-skip in the selftest. The
startup_failureblind spot still applies, so make surereview / claude-reviewactually reports on this PR before it merges.
Note
Written by Claude Code at the author's direction. Do not merge until the pre-release test below has passed: merging moves
v1and releases to every consuming repo.Implements #11. Leave the issue open until the two-week coverage check in its "Done when".
Why
On Deco354/factory-farm-em#29, all seven approvals (2026-10-06..08) said they had read only part of the PR. None of them read
analysis/summary.py, which computes the PR's headline numbers.@claudereviews of the unread files then found a bug the approvals had missed. Across the consuming repos, 15 of 134 approvals since 2026-09-01 admit to unread code.The cause is that
gh pr diffwas the review's only view of the diff, and it fails on large PRs:All seven #29 runs tried to fetch a per-file diff, were refused, and approved after reading one to three files. The full diagnosis, with the Sonnet 5 vs 5.5 comparison, is in #11.
What changes (
claude-pr-review.yml)pull_requestmerge commit against its base parent withgit diff. Checkout moves fromfetch-depth: 1to2for this..claude-review/diffs/.generated_pathspatterns are excluded, so those files get no diff. Patterns may be separated by spaces or newlines, so a YAML|block works (d2dc44a; before, only its first line was used).coverage.jq, the one definition of "read": the lines that successfulReadcalls returned cover every line of the diff. The count uses each result'sstartLine/numLines, not the call'slimit, because Read stops at its 25,000-token cap, and not always with an error:limit: 2000read over the cap returned an error.truncatedByTokenCap. A result with no line counts falls back to the call'soffset/limit.gh pr diffor at the old "skim the generated hunks" rule. Generated and binary files are listed by name only.grepthrough Bash to search, and Read for diffs and everything else, because coverage counts only Read.PreToolUsehook and passes it through the action'ssettingsinput.gh pr reviewand names the unread diffs, with their unread line ranges, e.g. "not read yet: line 4890". The follow-up prompt uses the same list.continue-on-error.--resumewith the action'ssession_idoutput.continue-on-error, so a failure here cannot take the first verdict down.::warning::and a PR comment with a ready-to-paste@claudeprompt. The comment is posted withGITHUB_TOKEN, so it triggers nothing itself; a human pastes the prompt.--effort highon both passes. Claude Code defaults Sonnet 5.5 tomediumand Sonnet 5 tohigh. Athigh, Sonnet 5.5's first pass read 24 of 25 diffs before its first verdict attempt, against 9–13 atmedium(results below).Coverage is report-only by decision: it never fails the check, and a verdict is never withheld or downgraded for unread files. These are unchanged: the verify step's jq, the tool allowlist (repeated verbatim for the follow-up pass), the escalation step, and the job's lack of a
permissions:block. No input is renamed and callers need no change. The one behaviour change for callers is thatgenerated_pathsis now read as whitespace-separated git globs; a pattern with no slash matches at any depth. Both values in use today,outputs/**anduv.lock, already fit.The README (what a repo gets, the inputs, a troubleshooting row, the changelog) and CLAUDE.md are updated to match. CLAUDE.md also loses two outdated statements: the contradiction guard that was removed on 2026-09-30, and the claim that the pipeline repo runs its own copies of these workflows (it has called
@v1since its #169).generated_pathsno longer suggests lockfiles (6b3f221). Under this PR a withheld file gets no diff, so the review cannot see it at all; before, its changes were in the diff it was told to skim. A lockfile's changes are small (factory-farm-em'suv.lock: 2–56 lines over its last 100 PRs, apart from its 4,885-line creation). They are also the only place indirect dependency and source changes show. So the input description, README and caller template dropuv.lockas an example, and the template's out-of-date "skim committed generated data" comment is corrected. Companion PRs:uv.lock.Cost: on the 24-diff replay of #29, a gated first pass that reads everything takes 29 turns and $0.46–0.48 in one pass. With a 4,890-line file added, it takes 35–38 turns and $0.85–0.98. Before the gate, the first pass cost $0.22–0.31 and the follow-up brought the reported total to $0.52–0.59. The September reviews cost $0.49–0.99 on PRs of 2,000–6,000 lines.
Release plan: test on a branch ref, not a new tag
This is not a breaking change, so it stays on
v1. To test it before every consumer picks it up, the PR stays unmerged while one repo calls the branch directly:uses: sentfutures/devops/.github/workflows/claude-pr-review.yml@review-every-file. Fix-up pushes to this branch reach that repo straight away. Once the test passes, merge (which movesv1), point the test repo back at@v1, and only then delete the branch. A caller that names a deleted ref fails at startup and produces no check at all.After merging, also merge sentfutures/.github#4 (this PR's template edit) and #3 (devops#9's template edit, open since 2026-09-30). With both merged, the org template matches
callers/claude-pr-review.caller.ymlbyte for byte.Pre-release results on Deco354/factory-farm-em (2026-10-08)
The caller there points at
@review-every-file(Deco354/factory-farm-em#39). Draft Deco354/factory-farm-em#40 replays #29's final diff: 25 files, +2,526/−137, so 24 diffs plusuv.lockas generated.Without the gate, the first pass skips half or more. Every run read the source diffs it judged important and stopped. It never opened a test file or a doc.
dc952ae(37807910253)c8fa570(37808249622)run.py", meant to force the warning path; the note was ignored and has since been removeda029fe3(37823923183)8bbedcb): read everything first, tests and docs namedEach follow-up, handed the list of what was unread, read all of it in one turn. That is why the gate hands over the list before the verdict.
With the gate (
4987b9c), 3 of 3 runs read 24 of 24 in the first pass:gh pr review2193455(37828965409)ba2a788(37829178598)639447b(37829415543)PreToolUse:Bash hook error: … Not yet: your review has not read these diffs to the end …as a failed tool call. The refused call posts nothing.With one diff over Read's token cap (
5601533, 2026-10-09). #40's largest diff was 543 lines, so the runs above never reached Read's 25,000-token cap. To test it,9f0465eadds a test-onlydocs/archive/snapshot-2026-10.md: 4,884 lines (125 KB) of real repo files concatenated, which makes a 4,890-line diff.gh pr review9f0465e(37911979471)limit: 2000errored at 40,474 tokens; then paged 1–1000 … 4001–4891a06a125(37912287240)Sonnet 5 on the same PR and workflow (2026-10-09, via Deco354/factory-farm-em#41). These runs are on
5601533. Each first pass is compared on what it read before its firstgh pr reviewattempt.9f0465e/a06a1254c3f376(37913331151) /ae963d9(37914095042)max_tokensbug@claudefound on #29 is fixed in its final diff.Unread lines named, 800-line pages (
c89d871, Sonnet 5.5, 2026-10-09). Deco354/factory-farm-em#43 removed themodel:line again.gh pr reviewb52dd90(37916190371)f31f208(37916389367)snapshot-2026-10.mditself, the file's own line rather than the diff's.Why it started now: effort changed with the model. Claude Code's docs (model-config, "Default effort levels") say: "
highon every model that supports effort, except that Opus 5.5, Sonnet 5.5, and Haiku 5.5 default tomedium". Effort controls adaptive reasoning, which "lets the model decide whether and how much to think on each step". So when the action's default moved to Sonnet 5.5, the review also moved fromhightomedium.per_turn_effort_active: true, and Sonnet 5's hasfalse.Sonnet 5.5 with
--effort high(2026-10-09). This separates effort from the model.--effort highwas added toclaude_argsin temporary[skip ci]commits on this branch: 4cefbcb on this workflow, and 5e4c691 onmain's workflow, which has no per-file diffs. f47e495 reverted both, and its tree is identical toc89d871.gh pr reviewa040b2e(37930313238)e7f960d(37930671913)main49a5a47(37930925223)gh pr diffsaved 287.6 KB, 8,363 lines. It read lines 6315–7024 (the diffs ofcli.py,config.py,export/rows.py,export/schema.py,run.py) and 2 source filesmain488c1b6(37931183181)gh pr difffour times (195–355 KB each) and opened none of the saved files. It read 6 source fileshightook Sonnet 5.5 from 9–13 of 25 diffs read before its first verdict attempt to 24 of 25, as Sonnet 5 did. The gate was needed only for the 4,884-line test file, which both runs had sampled.main's workflow,highstill approved after reading 5 of 25 diffs (run 1) or none (run 2). So the per-file diffs are still needed.highon this workflow: $0.99–1.04 and 67–69 s, against $0.93–0.94 and 39–51 s at the default. Adopted inaf20b0f(item 7 above).Found along the way, fixed in
af20b0f's prompt:grepvia the Bash tool instead".gh pr-only allowlist:grep,sed -nandtailall returned output.python3 -cgot "This command requires approval". Sonnet 5 read parts of the long diff withsed -n '1,40p'andtail -40(37914095042).af20b0fon #40 (44aba8e, run 37932473654, Sonnet 5.5,highby default now).head -60through Bash, despite the new wording.grepthrough Bash once. No "No such tool" errors.Anthropic's own setup doesn't cover this either (checked 2026-10-09). Claude Code 2.1.295's
/install-github-appinstalls aclaude-code-review.ymlthat runs thecode-review@claude-code-pluginsplugin.medium.gh pr diff, with "4 agents in parallel to independently review the changes" (two of them Opus). One of them is told to "Focus only on the diff itself".bashOutputMaxCharssetting raises the 30,000-character ceiling to at most 128,000 characters, which is still under #29's 133–193 KB diff.Warning comment: it has not been triggered live; with the gate it should be rarer still. I tested it locally by running the step's script with a stand-in
gh, in two cases: a failed follow-up, and two passes that still left diffs unread. Both produced the::warning::, the job summary and a single comment POST to the PR's issue-comments endpoint.How to test
Already done locally:
[x].txt(taken literally, not as a glob) anddir with space/R&D ü.md.outputs/**anduv.lock(at the root and inpkg/) were listed as generated, and the binary file was listed only.b55bf02#29 run log:harvestbench.pyas read androws.py(lines 1–80 of 461) as unread.Readand aGrepcall as not read, and handles relative paths and string offsets.5601533) was run against #40's execution files. It gives the same results as before: 12, 12 and 7 of 24 for the ungated first passes, 24 over both passes, and 24 for each gated run. Synthetic Read results check each range rule:tool_use_resultin the execution file,toolUseResultin the transcript);offset/limit.gh pr view,gh pr reviewxand other commands.gh pr reviewwith leading spaces and with--request-changes.Selftest on this PR:
6025705(run 37791137234): the action ran the full review. The self-skip seems to apply only when the caller file changes. It read 3 of 3 diffs and approved. Its one note, that the measure step could fail the job, was fixed infc59ec5.4987b9c(run 37828944154): it read 3 of 3, the gate held it back 0 times, and it approved. Its note that the README said coverage "never changes the verdict" was right;87cb7cafixes the wording.5601533(run 37911901330): it read 3 of 3, with no hold, and approved. The body describes the change, not the reading. Its note that the changelog entry was dated 10-08 but cited a 10-09 finding is fixed in40db5ee.c89d871(run 37915653076): it read 3 of 3, with no hold, and approved. Its two notes were both known: the step fails on a non-pull_requesttrigger by design, and warning comments can repeat across pushes.f47e495(run 37931391646), which reverts the effort-test commits and has the same tree asc89d871: approved. The[skip ci]effort commits had no selftest.af20b0f(run 37932462436): approved. The review checked that both passes use the sameclaude_args.5792475(comments only; run 37932942855): approved.6b3f221(run 37938142207) andd2dc44a(run 37938884314): approved. The selftest caller sets nogenerated_paths, sod2dc44a's run also covers the empty value on the runner's bash 5. The one-line, space-separated and|-block forms were tested locally by running the diff and prompt steps against a synthetic merge commit.Before merge:
model:line.replay-29,replay-29-base) and close #40.After merge: point factory-farm-em back at
@v1before deleting this branch. Then watch the "Review coverage" job summaries for two weeks. Record how many runs showed M of M, how often the gate held a verdict back, how often the follow-up ran, and the sample size.