# Feature: Review decision rule (autopilot)

<!-- toc -->
- [The rule](#the-rule)
- [The same finding](#the-same-finding)
- [Reviewers stay blind to each other](#reviewers-stay-blind-to-each-other)
- [Wiring](#wiring)
- [Verify-by-test in autopilot](#verify-by-test-in-autopilot)
- [Mandatory at commit](#mandatory-at-commit)
- [Limits](#limits)
- [Reference](#reference)
<!-- /toc -->

**Pattern**: every reviewer on Claude Code is a Claude model. A panel that
shares a model family shares its blind spots, so one reviewer's `blocking`
finding is a judgement nobody independent has checked, and a panel that
agrees can be agreeing for the same wrong reason. An attended run has a person
at the Step 4 checkpoint to weigh that. An autopilot run has none, so while the
quality gates are active (`lib/unattended.mjs`, `gatesActive`:
`MULTI_AGENT_UNATTENDED=1` or `state.autopilot === true`) a blocker has to be
backed by something other than one model's say-so before it stops the run.
The lack of model diversity is compensated by asking for executable evidence,
not by adding more reviewers of the same family.

Attended review is unchanged: the gate prints its decisions marked advisory,
exits 0 and writes neither the triage file nor the state.

## The rule

An `accepted[]` finding of severity `blocking` keeps its severity only when
one of these holds:

| Basis            | What has to be true                                                                                                                                                                                                                                 |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `corroborated`   | at least two independent reviewer outputs of the iteration carry a `blocking` finding that is the same finding                                                                                                                                      |
| `failing-test`   | its `verification` (Step 3.7, `verify-by-test.md`) is `confirmed`, and `evidencePath` names a non-empty log inside the checkout that shows a failing test and no pass                                                                               |
| `test-integrity` | it is the finding `test-integrity-gate.mjs` produced: the same file and the same `tag` (or the same fingerprint), a command's output, not a model's                                                                                                 |
| `owned-path`     | it is the finding `owned-path-gate.mjs` produced, matched the same way (an edit to a path the repo profile says an automated account owns), for the same reason. A reviewer's own blocker on a file the gate also flagged is not the gate's finding |

Anything else is lowered to `important`. It stays in `accepted[]`: triage
judged it real and in scope, so the rework still fixes it, it just no longer
holds the run on one opinion. It is never dropped. Each one is listed in the
gate's report and in its ledger entry with its fingerprint and the reason
(`raised by 1 reviewer, two needed; no failing test recorded`). `approved` is
recomputed from what is left.

Findings of severity `important` and `suggestion` are not touched.

## The same finding

Two findings are the same finding when they name the same file, with diff
prefixes (`a/`, `b/`, `./`) stripped, and either:

- they have the same fingerprint (`_fingerprint.mjs`, the identity the review
  delta already uses: the file plus the cited `ruleId`, else the file plus the
  normalised issue text), or
- both cite a line above 0 and the lines are at most `LINE_WINDOW` (3) apart.

A whole-file finding (line 0) matches by fingerprint only. Two findings that
cite different `ruleId`s, or different CWEs in their `security` envelope, are
never the same finding, however close their lines. The reviewer schema has no
category field; `ruleId` and the CWE are the categories it does carry.

Independent means a separate reviewer dispatch: one entry of
`state.reviewIterations[i].reviewers[]`, or one `--source` file (the Step 2.7
security audit is a separate dispatch too). One reviewer reporting the same
thing twice is one source. A second reviewer that saw the same line but called
it a suggestion does not corroborate a blocker.

## Reviewers stay blind to each other

Step 2 dispatches the reviewers in parallel from the same shared prefix; no
reviewer prompt carries another reviewer's output, and the outputs are
consumed by Step 3 only. The one same-round exchange is Step 2.5, the rebuttal
round, which shows each reviewer the others' blocker findings and replaces its
output. Multi-round debate converges agents on each other, which is exactly
what corroboration must not measure, so with the gates active the round does
not run:

```bash
node "$HOME/.claude/scripts/review-decision-gate.mjs" --rebuttal-allowed --state "$STATE_FILE" \
  || echo "Step 2.5 skipped: the quality gates are active"
```

Exit 1 means skip it, whatever `prefs.global.reviewDisagreementRound` says.
The gate also holds it after the fact: a reviewer entry with `roundCount`
above 1 fails the decision (exit 1, `rebuttal-round` in the ledger) and the
triage file is left as it was. `test/review-decision-gate.test.mjs` asserts
the dispatch side against `phase-3-review.md` and the persona.

The previous-round block (Step 2.1, iteration 2 and later) is shared context,
not a peer's output: every reviewer sees the same prior triage. A finding that
two reviewers both carry over from it is still two dispatches agreeing, and it
already passed this rule in the round before.

## Wiring

After Step 3.7 (verify-by-test) in every round, and after the Step 3.1 empty
result, before Step 3.8:

```bash
node "$HOME/.claude/scripts/review-decision-gate.mjs" "$TRIAGE_FILE" --state "$STATE_FILE" \
  --integrity "$WORKTREE/.pipeline/test-integrity.json" \
  --integrity "$WORKTREE/.pipeline/owned-path.json" \
  --integrity "$WORKTREE/.pipeline/valven-gate.json" \
  --source "$WORKTREE/.pipeline/security-audit-$ITERATION.json" \
  --json > "$WORKTREE/.pipeline/review-decision-$ITERATION.json"
RD_RC=$?
cp "$TRIAGE_FILE" "$WORKTREE/triage-output.json"
```

`test-integrity.json` is the file Step 1.76 writes (`{}` when it found
nothing); `security-audit-$ITERATION.json` is the file Step 2.7 writes when the
audit runs (`security-audit.md`). Both are files and not process substitutions
of a shell variable with a `{}` brace default: bash 3.2, the stock macOS
`/bin/bash`, expands that to `{\}`, and unparseable input is exit 3.
`owned-path.json` is the file Step 1.761 writes, `valven-gate.json` the one Step 1.762 writes. `--integrity` repeats, one
file per deterministic gate. Both gates always write their file, so a declared
`--integrity` file that does not exist is unreadable (exit 3): the gate failed
before writing. An empty one means no findings from that gate, and a missing or
empty `--source` means the audit did not run.

Merge the JSON into `state.reviewIterations[-1].reviewDecision` through
`write-state.mjs`:

```bash
jq --slurpfile d "$WORKTREE/.pipeline/review-decision-$ITERATION.json" \
  '{reviewIterations: (.reviewIterations | .[-1].reviewDecision = $d[0])}' "$STATE_FILE" \
  | node "$HOME/.claude/scripts/write-state.mjs" "$STATE_FILE"
```

| Exit | Ledger                                                                                        | The run                                                                     |
| ---- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| 0    | `pass`                                                                                        | continues with the rewritten triage                                         |
| 1    | `fail`: a rebuttal round ran                                                                  | `gate-ledger.mjs park --outcome verification-failed --gate review-decision` |
| 2    | nothing                                                                                       | usage error; fix the call                                                   |
| 3    | `fail`: triage unreadable, or an accepted blocker with no reviewer record to check it against | parks the same way                                                          |

The gate records the verdict; it does not park. Parking is the explicit call:

```bash
[ "$RD_RC" = 1 ] || [ "$RD_RC" = 3 ] && node "$HOME/.claude/scripts/gate-ledger.mjs" park \
  --outcome verification-failed --gate review-decision --reason "review-decision exit $RD_RC" --state "$STATE_FILE"
```

A missing reviewer record is a failure and not a pass: with nothing to count,
every blocker would be lowered, and a run that forgot to persist its
reviewers would get a clean review for it. An empty `reviewers: []` is a
missing record, for the same reason.

## Verify-by-test in autopilot

A single-reviewer blocker survives only with a failing verify-by-test log, so
an autopilot run with `prefs.global.verifyByTest.enabled` false keeps only
corroborated and test-integrity blockers. That is the intended trade: without
the empirical step there is nothing but a second opinion to lean on. Turn
verify-by-test on for autopilot work where one reviewer catching a real bug
matters more than the extra single-test runs.

## Mandatory at commit

`review-decision` is in `MANDATORY_AT_COMMIT` (`gate-ledger.mjs`). Every mode
in `phases.json` carries Review (`smoke-review-in-every-mode.sh`), and the gate
runs on the Step 3.1 empty result too, where it records `pass`: a run with no
blocker, or with no finding at all, still gets an entry for HEAD, so the
requirement blocks no clean commit. It is the only check that refuses a
rebuttal round, and a check the commit hook does not ask for is one an agent
can skip. Its sha is HEAD, as for `verify-citations`, which runs on the same
triage output.

## Limits

- All reviewers on Claude Code are Claude-family. Requiring two of them is
  weaker than two vendors agreeing; the failing-test basis is what carries the
  weight, and the rule says so rather than counting same-family agreement as
  independent proof.
- The line window is a heuristic. Two reviewers describing different bugs
  three lines apart in the same file count as one finding; two describing the
  same bug from opposite ends of a long function do not.
- The evidence check reads the log, it does not re-run the test. A log that
  shows a failure for a different reason than the finding claims still counts;
  verify-by-test writes one minimal repro per finding, which is what keeps that
  case narrow.

## Reference

Script: `scripts/review-decision-gate.mjs`. Tests:
`test/review-decision-gate.test.mjs`; smoke
`smoke-gates-interactive-noop.sh`. Related: `verify-by-test.md`,
`review-delta.md`, `unattended-gates.md`, `phases/phase-3-review.md` Steps
2.5, 3.1, 3.6 and 3.7.
