The Reviewer With No Memory — Why AI Coding Agents Keep Flagging Code You Already Explained
The same finding came back for the second time.
Not a new bug. The same one. I had already explained it in the PR description. The code was fine, and I could prove it. The reviewer read my explanation, agreed, and approved. Then the next PR touched the same function, and the finding was back, worded almost identically.
At first this felt like stubbornness. It isn’t. It’s arithmetic.
An AI reviewer starts fresh on every pull request. It does not remember what you said last time, because there is no “last time” for it. It reads the diff and whatever context sits next to that diff. If your reasoning lived in a PR description, a Slack thread, or your head, it is simply not there anymore.
So the finding is not being repeated. It is being raised for the first time, again.
Once you see it that way, the fix is obvious and slightly humbling: the context has to move to where the reviewer actually looks. Which is the code.
Before getting to that, a detour for readers who don’t work this way yet. If you already run issues and branches and pull requests every day, skip the next section.
The workflow this all assumes
Everything below assumes a fairly standard way of working on GitHub. It has a name, GitHub Flow, and it is deliberately simple. It replaced older, heavier branching models for most teams, and it works the same whether you’re alone or on a team of thirty.
One repository, one long-lived branch. main is always deployable. Nothing
experimental lives there.
Work starts as an issue. Before writing code, you open an Issue describing what you
intend to change. It gets a number, #1234. This feels like bureaucracy for a one-line
fix. It stops feeling that way the first time you need to know why a line of code
looks the way it does.
Each issue gets a branch. You branch off main and work there. The point is not
ceremony. The point is that one branch holds one subject, so the diff is readable and
reversible. Two unrelated changes in one branch produce a diff nobody can review
honestly.
Pushing opens a pull request. Not automatically, to be precise. Push the branch and GitHub offers a “Compare & pull request” button. You still click it. The pull request is where the change becomes visible: every line added, every line removed, side by side.
Review, then merge. Humans comment, agents comment, you fix or you argue. Merge
only after the review completes. Then the branch dies and main moves forward by one
coherent step.
That’s the whole thing. Five steps, no ceremony beyond what buys you something.
The number is the thread
Here is the part that took me embarrassingly long to appreciate.
That issue number, #1234, is not just a label on a ticket. On GitHub it is a live
reference. Write #1234 in a commit message and it links back to the issue. Write
Closes #1234 in a pull request body and merging the PR closes the issue
automatically. The issue, the branch, the PR, and the merge are all stitched together by
one number.
So the number already travels through your entire workflow. The only place it usually stops is the code itself.
That is exactly where it should keep going.
/* Iterator safety (reviewed, #1234): delete() swaps node links and frees
* only the removed node. A pre-fetched cursor always survives. */
Now the reasoning is not in a PR description that expires. It is next to the code, and it points at the discussion that produced it. Anyone, human or agent, who reads this function reads the reason along with it.
Comments have a second reader now
We were all taught to write comments for the next human. Usually framed as the next human being yourself in six months, which is true and also a little sentimental.
There is now a second reader. And the second reader has a very specific limitation: it reads the code and almost nothing else. Not your PR description. Not the design doc. Not the Slack thread where you settled the argument. Not the thing you’re certain everyone on the team knows.
That single limitation determines where the comment goes. Not whether to write one — where to put it.
It also raises the standard for what a comment has to contain. “Safe” is not a reason. “This is intentional” is not a reason. A reason names the mechanism: what invariant holds, who contends for the lock, why that branch is dead on this target.
And it comes with a trap worth naming out loud.
A comment is not a mute button
The tempting failure mode is to treat comments as a way to make findings go away. Write something plausible near the flagged line, the reviewer quiets down, ship it.
It doesn’t work, and it shouldn’t. A good review verifies the grounds, not the presence of a comment. A rationale that doesn’t match what the code actually does gets flagged again, comment or no comment. A made-up ticket number is a formality, not a rebuttal.
Which leads to the least fun rule in the whole set: a rationale comment is a claim, and claims rot. If you change the locking or the null handling that a comment relies on, that comment is now lying. A stale safety claim is worse than no comment at all, because it silences the review exactly where the code just moved.
The part I didn’t expect
I started writing these comments to stop an agent from repeating itself. That was the whole motivation. It was, honestly, a slightly annoyed motivation.
What actually happened is that the codebase got easier for people to read.
The comments an agent needs and the comments a human needs turn out to be the same comments. Why is this branch unreachable here. What does this function promise about null. Who else touches this structure while I’m walking it. Nobody was writing those down, because everyone currently working on the code already knew. The knowledge was real, it just wasn’t anywhere.
An AI reviewer doesn’t have the tacit context, so it asks. Every time. That turns out to be a fairly precise instrument for measuring how much of your codebase only exists in someone’s head.
The comments were for the agent. It turns out they were overdue for us.
The file
The habits above are collected in an AGENTS.md at the repository root — nine rules and
a pre-push checklist, tool-agnostic and language-neutral. Any coding agent working in
the repo reads it; so does anyone new to the code.
It’s below, and it’s meant to be copied and adapted rather than admired.
Raw file: download AGENTS.md
Appendix: AGENTS.md — Write the intent so reviews converge
Guidance for any coding agent (Cursor, Claude Code, Codex, …) working in this repo. Tool-agnostic and language-neutral.
One principle: move the context out of your head and put it beside the code.
“This branch is dead on our target.” “This function is null-safe.” “I left that out on purpose.” If that reasoning lives only in your head or in a PR description, every review asks again. An AI reviewer is the relentless junior who asks on every PR. The cure is a comment, not an argument.
An AI review reads rationale comments that carry a ticket number, and when the grounds hold, it stops re-flagging. These habits are what close a finding for good instead of letting it recur.
1. Rebuttals belong in code comments, not PR descriptions
A PR description is volatile. The next review cannot read it, so a rebuttal filed there comes back. Put the reason next to the code, with a ticket number.
/* Iterator safety (reviewed, #1234): delete() swaps node links and frees
* only the removed node. A pre-fetched cursor always survives. */
The review verifies the grounds, not the comment’s presence. A rationale that does not match the code gets re-flagged despite the comment. A comment is not a mute button.
The number must reference a real ticket. A made-up number is a formality, not a rebuttal.
2. Never leave a bare TODO
Decided against it? Say so, with the reason. Genuinely deferred? Attach a ticket number.
Bad: /* TODO: logging and alarm */
Good: /* Telemetry intentionally omitted (#1234): deletion is already
* observable through the change-notify event. */
Good: /* TODO(#4001): rate-limited alarm once the alerting infra lands */
3. Concurrency comments say WHY and WHO contends
Let a reviewer verify the locking instead of guessing.
/* The receive path walks the same structure the daemon, the timer, and the
* notifier mutate under the write lock. Take the read lock for the walk and
* decide on a copy. */
4. Invariants belong to the machine, not to the comment
A comment informs a human. An assertion stops the future violator.
| Assumption | Where it belongs |
|---|---|
| Layout, size | compile-time assertion |
| Lock discipline | runtime assertion in the shared accessor |
| Data invariant | a test |
5. State a function’s contract where it is defined
Null tolerance, return conventions, ownership. One line at the definition, so callers (and reviews of callers) do not have to guess or escalate.
/* Null-tolerant: returns null on null input. Safe to call after teardown. */
6. Put the boundary meaning in the name
end reads as exclusive. If it is the inclusive last element, call it last.
A name is a contract, and a mismatch is where off-by-one lives. If you cannot
rename it, comment the convention at the call site.
7. State build and configuration gates at the function head
Reviews do not read config files, and most humans don’t either. If a function compiles only under a flag, or is dead on this target, say so at the top.
/* Compiled only when FEATURE_X is enabled. Not enabled on this target
* (dead code here). */
8. Fix the class, not the instance
When a review finds one case of a pattern, grep the file and the module for its siblings and fix them in the same pass. Otherwise the next review finds the next sibling, forever.
If you add a safety wrapper, audit every caller’s contract in the same commit.
9. A rationale comment is a claim. Keep it true.
Rationale and contract comments are true when written and rot when the code they describe changes. If you modify behavior such a comment relies on (its locking, its null handling, its ownership), re-verify the comment in the same commit.
A stale safety claim is worse than none: it silences the review exactly where the code just moved.
Before you push
- Every finding I dismissed → reason now lives in a code comment with a ticket number?
- No bare TODO left → “intentionally omitted + why”, or
TODO(#1234)? - New or changed locking → does the comment name who contends?
- Layout, lock, and data assumptions → moved into assertions or tests?
- Fixed one case of a pattern → grepped siblings and swept them in this pass?
- Touched code a rationale comment describes → is the comment still true?
- Merged only after the review completed?