### Phase 4: Review (deterministic gates + parallel + triage)

> **TLDR**  -  Three-stage review. Stage 1: deterministic gates (build + lint + test + secret scan) that MUST pass. Stage 2: AI models in parallel  -  reviewer set is **CLI-aware**: Claude Code dispatches 2 reviewers (Fable + Sonnet); Copilot CLI dispatches 3 reviewers (GPT-5.4 + Opus + Sonnet - Fable 5 is not offered on Copilot CLI). Stage 3: Fable triage (Opus on Copilot CLI)  -  evaluates raw findings, filters false-positives/out-of-scope, keeps only actionable items. Only triage-accepted blocking items loop back to Phase 3.

<!-- progress-contract: applied -->
Progress emission per `$HOME/.claude/multi-agent-refs/progress-contract.md`  -  lines for each gate, each reviewer dispatch + finish, triage start, triage verdict, fix dispatch.

#### Step 0  -  Analysis mode branch

`state.mode === "analysis"` has no diff: the artefact is the document. Replace Steps 1-3 with (1) `validate-analysis-doc.mjs <file> --strict` per platform, (2) the same CLI-aware reviewer set reading the document against one question - **could an implementer build the right thing from this alone?** a finding is anything that would force a guess - and (3) `$HOME/.claude/multi-agent-refs/analysis/resolve.md` over the Section 20 rows still `Acik / Open`; deferred rows report `review_blocking`. Then go to Phase 6.

Log: `Phase 4: Document review  -  validator:{pass/fail} | {N} findings | {M} resolved, {K} deferred`

#### Step 1  -  Deterministic Gates (run BEFORE AI review)

If any gate fails → fix first, don't waste AI tokens reviewing broken code.

```bash
# Gate 1: Build (xcodebuild/gradle assemble/tsc/py compile  -  stack-dependent; Xcode uses the build queue lock, see Phase 3)  -  tee output to a log
<build-command> 2>&1 | tee "$WORKTREE/.build.log"
# Gate 2: Lint (swiftlint/ktlint/ruff/eslint  -  stack-dependent)
# Gate 3: Tests pass (xcodebuild test/gradle test/pytest/npm test)  -  tee output to a log
<test-command> 2>&1 | tee "$WORKTREE/.test.log"
# Gate 4: Secrets  -  run the scanner against the staged diff
bash $HOME/.claude/scripts/pre-commit-check.sh
```

**Default-FAIL evidence gate (required before recording any pass):** a green exit code is not enough  -  the captured log must actually show success. Before marking build/test passed, run the evidence gate against the tee'd log; it fails CLOSED when the log is missing, empty, or shows failure markers:

```bash
node $HOME/.claude/scripts/evidence-gate.mjs --claim build --status passed --evidence "$WORKTREE/.build.log" || BUILD_PASS=false
node $HOME/.claude/scripts/evidence-gate.mjs --claim test  --status passed --evidence "$WORKTREE/.test.log"  || TEST_PASS=false
```

This prevents a false "it built" claim with no log behind it. On exit 1, treat the gate as failed (do NOT proceed to AI review) and surface the gate's `reason`.

**Inherited failures (when `state.baseline.tests` exists).** Phase 0 Step 7.6 recorded whether the suite was already red, so Gate 3 blocks on what this work broke, not what it walked into:

| baseline status | Gate 3 |
|---|---|
| `green` | unchanged; every failure is this run's |
| `red` + `failing[]` | subtract those ids. Nothing left -> pass, logged `test:pass (inherited {N})`. A NEW failure still blocks. |
| `red`, empty `failing[]` | do NOT pass and do NOT silently block: report `test:inherited-red (not attributable, <logPath>)` and ask. Inventing a set here masks regressions. |
| `unknown` / absent | unchanged from today |

The subtraction never widens: match on identifier only, and when identifiers cannot be compared fall to the `not attributable` row.

**Gate results:**

- All pass (including the evidence gate) -> proceed to AI review
- Any fail -> fix immediately, re-run gates (no AI review until clean)
  Log: "Phase 4: Gates  -  build:{pass/fail} lint:{pass/fail} test:{pass/fail/inherited-red} secrets:{clean/found} evidence:{ok/unverified}"

##### Gate 5  -  Fortify SSC findings (runs when `state.contextLinks[]` contains a `fortify` entry, or when `prefs.global.fortify.alwaysCheck === true`)

If the task description referenced a Fortify version, or named a bare issue instance id, `~/.claude/lib/fetch-fortify.sh` already populated `state.fortifyFinding` in Phase 0 (`alwaysCheck` needs `prefs.global.fortify.versionIds` to know what to scan). Phase 4 reuses that payload and applies the deterministic gate:

```bash
gate=$(jq -r '.fortifyFinding.gateOutcome // empty' "$STATE_FILE")
if [ -z "$gate" ]; then
  # No fortify entry in contextLinks; gate is N/A.
  echo "→ fortify gate: n/a (no Fortify URL referenced)"
elif [ "$(jq -r '.fortifyFinding.gateOutcome.blocking' "$STATE_FILE")" = "true" ]; then
  reason=$(jq -r '.fortifyFinding.gateOutcome.reason' "$STATE_FILE")
  critical=$(jq -r '.fortifyFinding.severityCounts.Critical' "$STATE_FILE")
  echo "→ fortify gate: BLOCKED ($reason, critical=$critical)"
  # Treat as a deterministic gate failure  -  fix the critical findings before AI review.
  exit 1
else
  high=$(jq -r '.fortifyFinding.severityCounts.High // 0' "$STATE_FILE")
  echo "→ fortify gate: pass (high=$high warnings carry into the channel summary)"
fi
```

Gate semantics:

| `gateOutcome.reason` | Phase 4 action |
|---|---|
| `critical-findings` | **Blocking**  -  Phase 3 re-dispatches with the finding list; pipeline does not advance until Critical count is 0. |
| `high-findings-warning` | Pass-with-warning  -  High findings appear in the channels summary (`## Security Scan (Fortify)` section in PR + Jira); they don't block the merge. |
| `clean` | Pass silently  -  section omitted from the channels summary. |

When `state.fortifyFinding.status === "skipped"` (token missing, VPN unreachable, or host mismatch) the gate logs `→ fortify gate: skipped (<reason>)` and never blocks  -  the user already saw the structured Save Flow signal at Phase 0 and chose to proceed.

#### Step 1.45  -  Test plan coverage cross-check (Phase 1 modes with a test plan)

Every `state.dev.testPlan[]` row must map to a real test in the diff, matched on the planned name. Missing -> `important` ("planned test <name> (BR-<id>) has no implementation"); present but asserting something else -> `blocking`, plan and code disagree about the rule. This is what makes "analysis quality is output quality" measurable. Skipped when `docStatus === "not-applicable"` or the mode has no Phase 1.

Log: `→ test plan coverage: <N>/<M> planned rows implemented`

#### Step 1.5  -  Platform Compliance Review (skill-based, no device needed)

If changes include UI files (iOS: `*View.swift`, `*Screen.swift`, `*Cell.swift`; Android: `*Screen.kt`, `*Content.kt`, `*Composable.kt`), run platform-specific checks:

**Accessibility** (both platforms):
- Missing `.accessibilityLabel` / `contentDescription` → **blocking**
- Tap target < 44×44pt (iOS) / 48×48dp (Android) → **blocking**
- Missing `.accessibilityIdentifier` / `testTag` → **important**
- Dynamic Type / font scaling not supported → **important**

**iOS  -  Apple HIG compliance** (skills: `ai-ios-toolkit:hig-patterns`, `ai-ios-toolkit:hig-components-layout`, `ai-ios-toolkit:hig-foundations`):
- Navigation pattern mismatch (e.g. custom back button instead of system) → **important**
- Non-standard gesture without discoverability hint → **suggestion**
- Missing safe area / keyboard avoidance → **important**
- Hardcoded colors instead of system/semantic colors → **suggestion**

**iOS  -  SwiftUI interaction & accessibility conventions.** Gated to changed SwiftUI files. These are rule-registry territory as of v14.0.0, not a list transcribed here: Step 1.78 resolves them from whichever registry declares SwiftUI scope, so the criteria and their severities live in one place instead of drifting between this doc and the skill. Reviewers receive the resolved rule IDs. Native-SwiftUI-first unless the project's `figma-config` `ui.*` declares a custom system, in which case check against that system. Reference skills, when no registry covers the change: `figma-navigation`, `figma-overlays`, `figma-bottom-sheets`, `figma-to-swiftui`.

**Android  -  Material Design compliance** (skills: `ai-android-toolkit:compose-components`, `ai-android-toolkit:android-architecture`):
- Non-Material3 component when M3 equivalent exists → **suggestion**
- Missing `contentDescription` on icons/images → **blocking**
- Hardcoded dp values instead of Material spacing tokens → **suggestion**

**App Store / Play Store readiness** (skills: `ai-ios-toolkit:app-store-review`, `ai-android-toolkit:play-store-review`):
- Privacy: API usage without purpose string / permission rationale → **blocking**
- Deprecated API usage flagged by latest SDK → **important**

Device-level audit runs in Phase 5 (`audit-guide.md`).

#### Step 1.6  -  Repo Map Injection (advisory, opt-in)

**Gated by `prefs.global.repoMap.enabled`** (default: `false`). Same pattern as Phase 1 Step 2.5  -  runs `$HOME/.claude/scripts/repo-map.mjs` and injects the result as `${REPO_MAP}` into each reviewer's prompt context. Reviewers treat it the same way the priority files block is treated: advisory hint, not gospel.

Phase 4 reuses the cached map from Phase 1 when both phases run in the same task (avoid recomputing the same scan twice). The orchestrator caches under `state.repoMap.<sha>` keyed by `git rev-parse HEAD`; cache miss → regenerate. When `enabled=false`, both phases skip the script entirely and no `${REPO_MAP}` placeholder appears in prompts (no empty-string artefact).

Cost ledger: `phase-4.repo_map_emitted bytes=N budget=B cache_hit=true|false`  -  Phase 7 cost rollup distinguishes a cache hit (free) from a regeneration (~150-300ms wall, 0 LLM cost either way).

#### Step 1.75  -  Diff Risk Scoring (advisory)

Before dispatching reviewers, run the deterministic diff risk scorer and inject the top-N files into each reviewer's prompt as a priority hint. **Advisory only  -  never gates the pipeline.** Heuristic, zero LLM, runs in well under a second.

Score the diff **once, in full** (no `--top`): Steps 1.76/1.77 need every scored file (a shrinking test file ranked 20th; whether ANY file is high-stakes). The top-N hint is derived from that report, not a second git walk.

```bash
RISK_FULL=$(node $HOME/.claude/scripts/diff-risk-score.mjs \
  --base "$BASE_BRANCH" --head HEAD \
  --task-id "$TASK_ID" 2>/dev/null)
echo "$RISK_FULL" | node $HOME/.claude/scripts/validate-diff-risk.mjs - >/dev/null 2>&1 || RISK_FULL=""

# Priority hint for the reviewer prompts: top 5 of the full report.
RISK_JSON=$([ -n "$RISK_FULL" ] && jq -c '.files |= (sort_by(-.score) | .[:5])' <<< "$RISK_FULL" || echo "")
```

**Signals & weights** (see `$HOME/.claude/schemas/diff-risk.schema.json`):

| Signal | Weight | Triggers when |
|---|---|---|
| `security_path` | 3.0 | path matches Auth/Keychain/Credential/Token/Crypto/Networking glob |
| `migration` | 4.0 | path matches DB schema / migration glob (.sql, /Migrations/, alembic/, prisma/migrations) |
| `public_api` | 2.0 | added line declares `public func/class/struct/enum`, `@objc`, `open fun`, `@Composable`, or `export function/class/...` |
| `no_test_change` | 2.5 | source file changed, no paired test file (`{Base}Tests.{ext}`, `{Base}.test.{ext}`, etc.) appears in the diff |
| `test_lines_removed` | 3.0 | test-classified file whose diff removes more lines than it adds (immutable-test backstop, see `$HOME/.claude/multi-agent-refs/rules.md`) |
| `complexity_delta` | 1.5 | added control-flow tokens (`if`/`guard`/`switch`/`while`/`for`/ternary/`&&`/`\|\|`) |
| `ui_critical` | 1.5 | path matches `*View.swift`, `*Screen.kt`, `*Configuration.swift`, etc. |
| `loc_changed` | 1.0 | base sensitivity to total `+/-` lines |

**Reviewer prompt injection**: when `$RISK_JSON` is non-empty, the orchestrator builds a `${PRIORITY_FILES}` block (numbered list of top-N files with their score + signals) and injects it once per reviewer. Reviewer prompt template (`code-reviewer.md`) treats it as advisory and does not echo it back. Triage does not see the priority list  -  its job is to filter the merged findings, not the diff.

**Gate behavior**: this step is **never blocking**. If risk scoring fails (git error, parse error, validator rejection), continue with no priority hint  -  reviewers receive the full diff in their default order. Failures are logged via metrics:

```bash
[ -z "$RISK_JSON" ] && $HOME/.claude/scripts/log-metric.sh "$TASK_ID" 4 review.diff_risk_skipped reason=$REASON
```

On success, emit a single summary metric:

```bash
$HOME/.claude/scripts/log-metric.sh "$TASK_ID" 4 review.diff_risk \
  top_files=$(jq '.files | length' <<< "$RISK_JSON") \
  max_score=$(jq '.totals.max_score' <<< "$RISK_JSON") \
  loc_added=$(jq '.totals.loc_added' <<< "$RISK_JSON")
```

**Opt-out**: `prefs.global.diffRiskAdvisory = false` skips this step entirely (no script invocation, no priority block injection). Default `true` because the cost is bounded and the signal-to-noise has been measured against the golden-task fixture set.

#### Step 1.76  -  Test-integrity gate (produces BLOCKING findings)

Step 1.75 uses `test_lines_removed` as an advisory hint only  -  too weak for what it detects: a suite made green by deleting tests instead of fixing code. This turns the signal into blocking findings triage must adjudicate. Pure function of the full report, no git, no LLM.

```bash
TEST_INTEGRITY_JSON=$(printf '%s' "$RISK_FULL" | node $HOME/.claude/scripts/test-integrity-gate.mjs 2>/dev/null || echo "")
TI_COUNT=$(jq -r '.count // 0' <<< "${TEST_INTEGRITY_JSON:-{\}}" 2>/dev/null || echo 0)
[ "$TI_COUNT" -gt 0 ] && $HOME/.claude/scripts/log-metric.sh "$TASK_ID" 4 review.test_integrity findings="$TI_COUNT"
```

`findings[]` are reviewer-shaped (`test_integrity`, `blocking`), so they merge into the reviewer findings at Step 3.0 and need no triage-prompt or `validate-triage.mjs` change. Triage keeps each blocking unless the removal is justified per the immutable-test rule (spec changed AND commit body names the test) → `deferred[]`.

The gate never blocks the phase; it *emits* blocking findings. Empty or unreadable input yields zero findings and the phase continues. Feed it the FULL report  -  `--top` hides a shrinking test file below the cut. **No opt-out**: a run that can switch off its own anti-reward-hacking control cannot be trusted to report a pass.

#### Step 1.77  -  Reviewer scope (cost gate)

On a trivial diff every reviewer agrees and the extra models plus triage are paid for nothing. Reviewer count comes from the same report, no LLM.

```bash
SCOPE_JSON=$(printf '%s' "$RISK_FULL" | node $HOME/.claude/scripts/review-scope.mjs 2>/dev/null \
  || echo '{"scope":"full","reason":"no risk report - failing safe"}')
REVIEW_SCOPE=$(jq -r '.scope // "full"' <<< "$SCOPE_JSON")
$HOME/.claude/scripts/log-metric.sh "$TASK_ID" 4 review.scope scope="$REVIEW_SCOPE"
```

`single` (Reviewer 1 only) requires **all** of: churn <= 20 lines, `totals.max_score` < 3.0, and no `security_path` / `migration` / `public_api` / `no_test_change` / `test_lines_removed` on any file. Anything else → `full`.

Fails safe in one direction only: empty report, parse failure or validator rejection all resolve to `full`, because skipping a reviewer trades coverage for cost. `consensus.reviewerCount` records what actually ran, so a single-reviewer run never reads as cross-model agreement. Opt-out: `prefs.global.reviewScopeGate = false` forces `full`.

#### Step 1.78  -  Criteria resolution (skill conformance, required)

Resolves WHAT the changed code was supposed to honour, before any reviewer sees it. Zero LLM. Full contract: [`$HOME/.claude/multi-agent-refs/features/skill-conformance.md`]($HOME/.claude/multi-agent-refs/features/skill-conformance.md).

```bash
node $HOME/.claude/scripts/skill-conformance.mjs \
  --diff "$WORKTREE/.review-diff.txt" --state "$WORKTREE/agent-state.json" \
  --repo "$WORKTREE" --out "$WORKTREE/.pipeline/criteria-manifest.json"
CRIT_RC=$?
CRITERIA=$(cat "$WORKTREE/.pipeline/criteria-manifest.json" 2>/dev/null || echo '{}')
$HOME/.claude/scripts/log-metric.sh "$TASK_ID" 4 review.criteria \
  rules="$(jq -r '.selectedRuleCount // 0' <<< "$CRITERIA")" \
  ledger="$(jq -r '.ledger.source // "derived"' <<< "$CRITERIA")"
[ "$CRIT_RC" != "0" ] && HALT "criteria could not be resolved (rc=$CRIT_RC)  -  see resolutionFailure / unparseableRegistries"
```

Output conforms to `$HOME/.claude/schemas/criteria-manifest.schema.json`. The four parts that matter downstream:

- **`selectedRules[]` is the denominator.** Rule IDs from every registry whose declared `scope` matches the diff, persisted BEFORE the reviewers run so the set cannot be renegotiated after one has seen the diff. That is what makes "applied completely" answerable rather than "looks fine": a reviewer finding nothing must still return a verdict per ID. Discovery is declared via `standards-registry:` frontmatter, so no stack-specific skill is named here.
- **`coverage.declaredGaps[]` + `droppedReasons`.** An uncovered language, and every out-of-scope rule, is reported with a reason. "No rule applied", "every rule passed" and "the rule set was narrowed" must never render the same.
- **`ledger`.** `state.telemetry.skillCalls[]` corroborates only; `ledger.source` defaults to `derived` and coverage is never computed from self-report. A declared skill the resolver cannot bind is flagged.
- **`findings[]`.** Reviewer-shaped, merging at Step 3.0 alongside test-integrity: expired / unexplained / unknown-ID exception markers, plus any registry whose delegated linter is not wired here (those rules are unverified, so reporting no violations reports that nothing was measured).

**Any non-zero exit halts** (1 = setup error incl. a bad `--skills-root`; 2 = no root resolved, unparseable registry, or a declared path escaping its skill dir): continuing would drop a rule set from the denominator, and since the reviewer validator skips the checklist when zero rules were selected, the run would report clean over criteria never loaded. A coverage gap halts only under `prefs.global.skillConformance.blockOnCoverageGap`. No opt-out for the stage or the exception-expiry check, on the same grounds as Step 1.76.

#### Step 1.8  -  Figma visual-fidelity context (when task carries a Figma reference)

**Short-run inputs.** Phases 1 and 2 do not run in a Short run, so three inputs this phase was written around are absent. Substitute them and RECORD the substitution  -  a step that could not run and a step that passed must not read the same, or the completeness claim cannot be checked:

| Absent input | Substitute |
|---|---|
| `detectedStack` (Phase 1 Step 2) | the language census in `criteria-manifest.json` → `languages`, from the diff's file extensions |
| Phase 1 analysis summary + Phase 2 plan (triage scope, cache prefix) | the task description plus the inline task list Phase 3 generated for itself |
| `state.evidence.figma[]` (this step), Step 2.8 visual conformance | nothing. Record each as `not-applicable (no Phase 1 evidence)` in `consensus.visualConformance`; never silently omit |

When `state.evidence.figma[]` is non-empty, the reviewer subagents MUST receive the captured screenshot URLs / paths and the canonical-component name (from `state.evidence.figma[i].screenshotUrl` and `state.evidence.figma[i].codeConnectSnippets[0].componentName` when present) so they can compare visual fidelity. Pass them inline in each reviewer prompt under a `## Figma evidence` block, one row per frame.

Visual-fidelity mismatches against the captured screenshot are BLOCKING findings, not nits:

- Canonical component usage: the Code Connect-mapped component is used verbatim  -  a sound-alike substitute, a forked copy, or ad-hoc inline UI where a mapping exists is blocking (Phase 3 "Design fidelity contract")
- Inter-component spacing: gaps, paddings, and alignment BETWEEN components match the design's measured values mapped to spacing tokens  -  invented numeric values are blocking
- Avatar icon (presence, glyph, shape, position)
- Field grouping (one rounded box vs two; separator vs gap)
- Character counter visibility
- Header style (size, weight, alignment)
- Inline error layout (icon presence, text colour, position relative to the input)
- Button height
- Indicator chip placement
- Placeholder copy and position

When `state.figmaAccess.tier === 3` (user-attached screenshot, no Code Connect snippet), the reviewer additionally sets `findings[i].severity = "blocking"` and `findings[i].tag = "review_blocking_tier3"` on every UI atom that lacks a confirmed canonical-component mapping. The triage step preserves these findings unless the user has explicitly cleared the open question.

#### Step 1.9  -  Context economy (cache prefix + diff cap)

Phase 4 sends the same diff to every reviewer and then to triage, so the diff is the dominant token cost. Two measures keep it bounded:

**Shared cache prefix.** Build the reviewer and triage prompts so the large invariant context  -  the full diff, the `${CRITERIA}` block from Step 1.78, the Phase 1 analysis summary, the Phase 2 plan  -  is a byte-identical leading block across all dispatches in this iteration. Only the per-reviewer focus + skill line varies, and it goes AFTER the shared block. `${CRITERIA}` goes in the prefix, identical for every reviewer: subsetting it per reviewer would invalidate the prefix for the whole panel and re-bill the largest block in the phase. Per-reviewer emphasis stays a one-line pointer in the suffix. When the host supports prompt caching, the 2nd/3rd reviewer and the triage call then read that prefix at the discounted cache-read rate instead of re-billing it as fresh input. Forward the host-reported cache-read count as `tokens_cached` per the Token telemetry contract so the saving lands in the cost ledger.

**Single-repo diff cap.** If the diff exceeds the Phase 4 token allowance (`token-budget.json`), truncate the largest files and append a footer `[truncated  -  full diff in file://$WORKTREE/.review-diff.txt]`, writing the full diff to that path. Reviewers and triage receive the same capped view + the marker so they can flag "review the full diff manually." Log `review.diff_truncated bytes_dropped=<N>`. (Multi-repo already caps the combined diff at 80% of budget; this is the single-repo equivalent.)

#### Step 2  -  Parallel AI Review (CLI-aware reviewer set)

Launch Agent instances **in parallel** using the shared `code-reviewer` subagent definition (`~/.claude/agents/code-reviewer.md`). The reviewer set is determined by the host CLI  -  GPT-5.4 is only available on Copilot CLI, so Claude Code skips that reviewer and runs a 2-model parallel review; Copilot CLI runs all three.

**Scope from Step 1.77.** `$REVIEW_SCOPE == "single"` → dispatch **Reviewer 1 only**, and skip Step 2.5 + 3.6 (both no-ops with one reviewer). `"full"` (default + fail-safe) → the whole set below. Either way record the count in `consensus.reviewerCount`.

| Reviewer   | subagent_type   | Claude Code | Copilot CLI | Codex CLI | Focus | Skills Referenced |
| ---------- | --------------- | --- | --- | --- | --- | --- |
| Reviewer 1 | `code-reviewer` | `claude-fable-5` | `claude-opus-5` | `gpt-5.6` @ `xhigh` | Deep security + architecture | `api-security-best-practices`, `architecture` |
| Reviewer 2 | `code-reviewer` | (not dispatched) | `gpt-5.4` | `gpt-5.4` @ `high` | Edge cases, different perspective | cross-model diversity |
| Reviewer 3 | `code-reviewer` | `claude-sonnet-5` | `claude-sonnet-5` | `gpt-5.6` @ `medium` | Quality + correctness + naming | `ai-backend-toolkit:clean-code`, stack-specific skill |
| Triage     | triage persona  | `claude-fable-5` | `claude-opus-5` | `gpt-5.6` @ `max` | Filter false positives + out-of-scope | - |

Reviewer count per host: **Claude Code 2, Copilot CLI 3, Codex CLI 3**.

#### Codex CLI  -  two constraints that fail silently

Both were measured against Codex 0.145, not inferred, and both produce a review that
looks like it ran. The full statement lives in the managed block at `~/.codex/AGENTS.md`
(always loaded on that host, so it is not restated here):

1. **`fork_turns: "none"` on every `spawn_agent` that sets `model` or
   `reasoning_effort`** - a full-history fork discards the override and collapses the
   panel onto one model, silently.
2. **Three concurrent children is the ceiling** - 4 slots including the orchestrator,
   so the reviewer count on Codex is capability-derived, not a preference.

Sub-agent delegation itself is authorized by that same managed block; without it Phase 4
degrades to a single in-thread review.

**Single-vendor caveat.** Every Codex reviewer is an OpenAI model, so the
cross-vendor disagreement that Claude Code and Copilot CLI get for free is absent.
The diversity budget shifts to reasoning effort and persona focus: Reviewer 1 runs
`xhigh` on security and architecture, Reviewer 2 runs a different model family
member on edge cases, Reviewer 3 runs `medium` on quality. Treat consensus among
them as weaker evidence than the same consensus on a two-vendor host, and say so
in the triage note when all three agree on a borderline finding.

Each reviewer inherits the `code-reviewer` agent's focus areas (Security, Architecture, Quality, Performance) and output contract. The orchestrator overrides only the model and the stack-specific skill per-reviewer  -  no prompt duplication.

**Model override wiring:** `code-reviewer.md` declares `preferredModel: fable`, so Reviewer 1 uses the persona default (Fable 5). Reviewer 2 (Copilot-only, `gpt-5.4`) and Reviewer 3 (`claude-sonnet-5`) set `PHASE_MODEL_OVERRIDE=<model>` before dispatch  -  the orchestrator exports `CLAUDE_CODE_SUBAGENT_MODEL` on Claude Code, or passes `--model` on Copilot CLI. Full precedence rule: `skills/shared/core/multi-agent/SKILL.md#agent-dispatch--per-persona-model-routing-v610`. Fable dispatches are subject to the fallback contract (`$HOME/.claude/multi-agent-refs/features/model-fallback.md`): dispatch-error retry walks `fable -> opus -> sonnet` and budget-ceiling downgrade.

**Stack-specific skills loaded per reviewer** (from Phase 1 `detectedStack`). On Claude Code, Reviewer 2 (GPT-5.4) is not dispatched  -  its skill column is ignored. On Copilot CLI all three columns are used.

| Stack | Reviewer 1 (Fable / Opus on Copilot) | Reviewer 2 (GPT-5.4  -  Copilot CLI only) | Reviewer 3 (Sonnet) |
|-------|-------------------|-----------------------------------------|---------------------|
| iOS/Swift | `ai-ios-toolkit:ios-security`, `ai-ios-toolkit:swiftui-performance`, `ai-ios-toolkit:hig-patterns` | `ai-ios-toolkit:swift-concurrency`, `ai-ios-toolkit:ios-accessibility` | `ai-ios-toolkit:swiftui-pro`, `ai-ios-toolkit:swift-testing` |
| Android/Kotlin | `ai-android-toolkit:android-security`, `ai-android-toolkit:android-performance` | `ai-android-toolkit:compose-testing`, `ai-android-toolkit:android-architecture` | `ai-android-toolkit:compose-components`, `ai-android-toolkit:kotlin-coroutines-expert` |
| Python | `ai-backend-toolkit:api-security-best-practices` | `ai-backend-toolkit:fastapi-pro` | `ai-backend-toolkit:python-patterns` |
| Node.js | `ai-backend-toolkit:api-security-best-practices` | `ai-backend-toolkit:nodejs-backend-patterns` | `ai-frontend-toolkit:typescript-patterns` |
| Docker | `ai-backend-toolkit:docker-expert` | `ai-backend-toolkit:docker-expert` | `ai-backend-toolkit:ci-cd-pipelines` |
| Generic | `security-review` | `ai-backend-toolkit:clean-code` | `ai-backend-toolkit:clean-code` |

Skills are injected into reviewer prompt context  -  the reviewer uses them as reference, not as commands.

#### Step 2.8  -  Visual conformance gate (component / screen work only)

Runs when `state.taskType == "component"` **or** the diff touches SwiftUI UI files
AND the task carried a Figma reference. Two checks, in order:

1. **`ai-ios-toolkit:figma-review`** over the implemented frames  -  the
   plugin's own component review, including the 14-item checklist that covers design
   tokens, accessibility identifiers, previews and Code Connect.
2. **`/multi-agent:design-check`** for pixel + spacing + typography + colour
   conformance against the Figma variants, with its coverage gate: a variant that is
   neither audited nor skipped-with-a-reason fails the run.

**Code Connect must be published, not merely written.** A `*.figma.swift` file on
disk with `Code Connect: Not published` in Figma means the binding does not exist for
anyone but the author. Assert the publish step ran; an unpublished binding is a
blocking finding.

Why this is a gate and not advice: `design-check` existed as a command for a while
with **no phase invoking it**, so the only thing standing between a build and visual
drift was the user opening the app and looking. On one run that produced 16pt padding
where the frame said `Spacing/12`, and a full sheet rebuild afterwards. A reviewer
reading a diff cannot see spacing; something has to compare against the design.

Skip only when the diff has no UI change. Record the outcome in
`consensus.visualConformance` so Phase 7 reports whether it ran.

**iOS/Swift  -  interaction & convention checks (conditional).** Step 1.78 resolves these. Where no registry covers the change, reviewers fall back to the analysis doc (Section 14 Code Connect mapping) and, when `ai-ios-toolkit` is enabled, that plugin's navigation / overlay / bottom-sheet + accessibility conventions.

**Module review guides (conditional, all stacks).** Step 1.78 resolves them into `criteria-manifest.json` → `moduleGuides`. Inject with the directive: read each guide, apply its rules to the changed files under its directory  -  a guide governs only its own subtree, and its violations are findings triaged like any other. Same contract as `/multi-agent:review` Step 2b.

**Dispatch timeout (required, mirrors triage 3.3).** Reviewers run in parallel and triage waits on all of them, so one stalled reviewer hangs the phase. Bound each reviewer dispatch by `REVIEWER_TIMEOUT_SECONDS` (default 180). If a reviewer has not returned by the budget: log `review.reviewer_timeout reviewer=<name>`, treat that reviewer as absent, and proceed to triage with the reviewers that did return. The merged-findings count and `consensus.reviewerCount` reflect only the reviewers that returned. If **zero** reviewers return, retry Reviewer 1 once; on a second total failure HALT with `ERR: no reviewer returned within ${REVIEWER_TIMEOUT_SECONDS}s; resume with /multi-agent:resume #N.`. The Step 2.5 rebuttal round uses the same per-dispatch timeout. Never block indefinitely on a slow or dead reviewer dispatch.

#### Output contract  -  reviewer step

Step 2 produces N reviewer-output objects (one per dispatched reviewer), each conforming to `$HOME/.claude/schemas/reviewer-output.schema.json`. They are persisted to `state.reviewIterations[<iteration>].reviewers[]` and consumed by Step 3 (Fable triage)  -  never by Phase 6 directly. The triage step (below) is the producer of the only review artifact Phase 6 reads, conforming to `$HOME/.claude/schemas/triage-output.schema.json`.

**Subagent return format**  -  each reviewer returns JSON conforming to `$HOME/.claude/schemas/reviewer-output.schema.json`:

```json
{"findings":[{"severity":"blocking|important|suggestion","file":"...","line":N,"issue":"...","fix":"...","ruleId":"SEC-01","criteriaSource":"ios-coding-standard"}],
 "conformance":[{"ruleId":"SEC-01","verdict":"conformant|violated|not-applicable","file":"...","line":N,"reason":"..."}],
 "approved":true|false}
```

`ruleId` + `criteriaSource` appear on a finding that cites a rule from `${CRITERIA}`. `conformance` is required whenever Step 1.78 selected at least one rule, with exactly one row per selected ID and none outside the set.

**Required: validator gate (deterministic)  -  run immediately after each reviewer returns, before merging findings.** Persist each reviewer's output and validate the file  -  the validator's exit code decides, not the LLM turn:

```bash
REVIEWER_FILE="$WORKTREE/.pipeline/reviewer-$N.json"
printf '%s' "$REVIEWER_JSON" > "$REVIEWER_FILE"
node $HOME/.claude/scripts/validate-reviewer.mjs "$REVIEWER_FILE" \
  --criteria "$WORKTREE/.pipeline/criteria-manifest.json"
```

Progress line: `    → checking validator validate-reviewer ({reviewer})`

Exit 0 = valid. Exit 2 = contradiction (approved=true with blocking findings)  -  flip `approved` to `false`, continue. With `--criteria`, exit 1 also covers the conformance checklist: a selected rule ID with no verdict, a verdict for an ID that was never selected, a `conformant` row with no file evidence, or a `violated` row with no matching finding. Those are the four ways a review can look complete without being complete, and the validator is what makes the checklist more than decoration  -  it is hand-written and does not apply `additionalProperties`, so an unchecked array would otherwise pass. Exit 1 = malformed; gate protocol (fails CLOSED, same handling as the evidence gate): emit the validator stderr + `errors[]` verbatim, attempt ONE self-correction rework (re-invoke that reviewer with the errors quoted, overwrite the file), re-run the validator. If it fails again -> HALT the phase (no merge, no triage). Recovery hint: `ERR: reviewer output failed validate-reviewer.mjs twice. Inspect $REVIEWER_FILE against $HOME/.claude/schemas/reviewer-output.schema.json, then resume with /multi-agent:resume #N.`

#### Step 2.5  -  Disagreement-round loop (opt-in)

**Rationale:** When reviewers disagree (e.g. 1 blocker vs 2 pass), straight-to-triage discards potential insight  -  the minority reviewer might see something real, or the majority reviewers might all miss a subtle issue. One rebuttal round lifts signal quality without adding a new phase.

**Gated by `prefs.global.reviewDisagreementRound`** (default: `false`). When enabled:

1. Compute disagreement: reviewers agree iff all return `approved=true` with no `blocking` findings, OR all return `approved=false` with overlapping `blocking` findings. Anything else is disagreement.
2. Agreement → skip the rebuttal round, go straight to Step 3 triage.
3. Disagreement → one rebuttal round:
   - For each reviewer, re-prompt with: (a) their original output, (b) the OTHER reviewers' blocker findings **anonymized** through `node $HOME/.claude/scripts/anonymize-findings.mjs` (labels `Source A/B/C`, no model name, order deterministic per `taskId:iteration`), (c) instruction: *"Given the opposing arguments, keep / withdraw / modify each of your findings. You may also newly agree with a finding you previously missed. Return the SAME JSON schema  -  this is a revision, not a new review."*
   - Launch all reviewers in parallel (same CLI-aware set as Step 2).
   - Max one round. Results replace the original outputs.
4. Proceed to Step 3 triage with the round-2 outputs.

**Parity contract:** both CLI sides (Claude 2-model, Copilot 3-model) run the round identically. Telemetry emits `review_round_count={1|2}` per reviewer for Phase 7 rollup.

**Cost ceiling:** rebuttal round consumes ~1× the original Step 2 token budget. Smoke + budget tests treat this as opt-in so the default cost stays the same.

**Off by default reason:** mixed-verdict cases are ~8% of runs in practice; the extra ~$0.20-$0.50 per run isn't worth automating for users who'd rather let triage resolve it cleanly. Users with high-stakes tasks (security-critical, release branches) can flip the flag.

#### Step 3  -  Fable Triage (filter before acting)

**CRITICAL**: Reviewer findings are **raw signals**, not commands. Never auto-loop on every "blocking" tag  -  reviewers hallucinate, misread scope, or repeat each other. Run Fable triage (Opus on Copilot CLI) to evaluate merged findings against task scope.

Optional: when `ai-analyst-toolkit` is enabled and a finding blames a third-party library rather than this diff, ask `evidence-github` whether it is already open upstream. A confirmed one is `deferred` with its `GitHub:<owner>/<repo>#<n>` citation, not `accepted` and handed to Phase 3 to fix code that is not ours.

Opt-in empirical layer: when `prefs.global.verifyByTest.enabled` is `true`, accepted blocking findings additionally go through Step 3.7 (verify-by-test), which tries to reproduce each one with a minimal failing test before the Phase 3 rework loop fires. Full wiring: `$HOME/.claude/multi-agent-refs/features/verify-by-test.md`.

##### 3.0 Anonymize the reviewer findings, then merge the deterministic ones

**Anonymize first (required).** On both CLIs the triage model is also a reviewer (Fable on Claude Code, Opus on Copilot), and a judge that can see which findings are its own is marking its own homework:

```bash
ANON=$(jq -n --argjson r "$REVIEWERS_JSON" --arg t "$TASK_ID" --argjson i "$ITERATION" \
        '{taskId: $t, iteration: $i, reviewers: $r}' \
      | node "$HOME/.claude/scripts/anonymize-findings.mjs" --map "/tmp/review-$TASK_ID-$ITERATION-map.json")
```

`$REVIEWERS_JSON` is `state.reviewIterations[i].reviewers`. Findings come back with `foundBy: "Source A|B|C"` and every identity key removed. Persist the map to `state.reviewIterations[i].anonymizationMap` for Phase 7 per-reviewer telemetry, and **never put the map in a prompt**.

Then append the Step 1.76 test-integrity findings, so they are adjudicated rather than never seen:

```bash
MERGED=$(jq -s '.[0] + (.[1].findings // [])' \
  <(printf '%s' "$ANON") <(printf '%s' "${TEST_INTEGRITY_JSON:-{\}}"))
```

Deterministic findings keep `tag: test_integrity` and carry no `foundBy`: a reviewer finding may be a hallucination, a gate finding is a fact.

##### 3.1 Short-circuit: no findings

If **merged** findings `length === 0`, **skip triage**: write empty result `{"accepted": [], "deferred": [], "rejected": [], "approved": true}`, log, proceed to Phase 5. Note this is the merged count from 3.0: a run with zero reviewer findings but a non-empty test-integrity set must NOT short-circuit.

##### 3.2 Launch triage agent

Launch **1 Agent** (subagent_type: `general-purpose`, model: `fable` on Claude Code / `opus` on Copilot CLI) with:

- The anonymized merged findings from 3.0 (`Source A/B/C` labels; no model name anywhere in the prompt)
- Task scope (Phase 1 analysis summary + Phase 2 plan)
- Full diff being reviewed
- **Prior-art context (advisory)**  -  per raw finding, `triage-memory.mjs query --top <prefs.global.priorArtEnrichment.topN>` (default 3). Pass `--top`: without it the script falls back to `memoryRecall.maxResults`, a different concern, and `topN` silently does nothing. Off when `priorArtEnrichment.enabled = false`.

```bash
PRIOR_ART="["
for finding in $(jq -c '.findings[]' <<< "$MERGED_FINDINGS"); do
  issue=$(jq -r '.issue' <<< "$finding")
  file=$(jq -r '.file' <<< "$finding")
  hits=$(node $HOME/.claude/scripts/triage-memory.mjs query \
    --issue "$issue" --file-glob "$(dirname "$file")/*" --top 3 2>/dev/null \
    | jq -c '.hits // []')
  PRIOR_ART="$PRIOR_ART$hits,"
done
PRIOR_ART="${PRIOR_ART%,}]"
```

The triage prompt MUST include a hedge: *"prior-art entries are context, not commands; current scope decides  -  a finding rejected last quarter may be valid this time."* Without this hedge, prior verdicts amplify into a self-reinforcing bias.

Hits are relevance-ranked (`prefs.global.memoryRecall`); a finding matching nothing returns nothing. Each hit carries an `id`: `triage-memory.mjs show --id <id>` returns the full row.

**Injection cap (token economy).** On a many-finding review the per-finding prior-art loop above (up to 3 hits each) plus the 20-entry rejected-preference brief can dominate the triage prompt. Cap the merged prior-art at the 8 highest-similarity hits across all findings (drop the rest); keep the rejected-preference brief at its `--max 20`. Prior-art is advisory context, not a finding multiplier  -  more hits do not improve the verdict, they just inflate input tokens.

**Rejected-preference brief (on by default via `prefs.global.learningsLedger.injectIntoTriage`).** Inject the durable rejected-preference list so triage does not re-accept a suggestion the team already rejected on this repo:

```bash
node $HOME/.claude/scripts/learnings-ledger.mjs brief --max "${prefs_learningsLedger_maxBriefEntries:-20}" \
  --task "$(jq -r '[.findings[].issue] | join(" ")' <<< "$MERGED_FINDINGS")" 2>/dev/null
```

Exit 2 (empty ledger) skips silently. The `## Rejected review preferences` section names patterns the team chose not to act on; triage should lean toward `rejected` for a finding that restates one  -  but the same hedge applies (context, not command; a genuinely new instance can still be accepted).

`--task` is what makes the cap honest: unranked, those twenty slots go to the newest entries, which on a long ledger are mostly about other files.

**Recall telemetry.** Log what was injected, then what triage cited. Zero cited is a legitimate answer; `learning-curve.mjs` trends the ratio:

```bash
bash $HOME/.claude/scripts/log-metric.sh "$TASK_ID" 4 memory.injected kind=prior-art rows=$N
bash $HOME/.claude/scripts/log-metric.sh "$TASK_ID" 4 memory.hit rows=$CITED_COUNT
```

**Bulky payloads (opt-in via `prefs.global.contextOffload.enabled`).** Test output and whole-file diffs go through the offload filter, which leaves a `[[ref:<node_id>]]` line plus the tail in context and the full text under `.multi-agent/refs/`. Read that file when the tail is not enough; with the pref off it is a pass-through.

```bash
<test-command> 2>&1 | bash $HOME/.claude/scripts/offload-ref.sh --phase 4 --label tests
```

**Triage prompt skeleton:**

```
You are the Review Triage agent. Two reviewers returned findings on this diff.
Your job: separate signal from noise. Do NOT add new findings. Do NOT re-review code.

For each finding, decide:
- ACCEPTED: real issue, in scope, must be fixed now
- DEFERRED: real issue but out of current task scope → log for later, do not block
- REJECTED: false positive, duplicate, style-only nitpick, or already correct

A finding carrying a `ruleId` cites a written rule from a registry the project
adopted, so it is not a matter of taste: it can be DEFERRED or REJECTED as out of
scope for THIS task, but "I would have written it differently" is not available as
a reason. Preserve `ruleId` and `criteriaSource` on every finding you keep.

#### Output contract  -  triage step

Step 3 produces a single triage-output object conforming to `$HOME/.claude/schemas/triage-output.schema.json` and persists it to `state.reviewIterations[<iteration>].triage`. This is the **only** Phase 4 artifact Phase 6 reads. Phase 6 commits MUST cite only `accepted` findings that were resolved; `deferred` items get linked in the PR description as follow-up work; `rejected` items never appear in any user-facing output.

Return ONLY valid JSON conforming to $HOME/.claude/schemas/triage-output.schema.json:
{
  "accepted":  [{ "severity": "blocking|important|suggestion", "file": "...", "line": N, "issue": "...", "fix": "...", "reviewer": "fable|opus|sonnet|gpt" }],
  "deferred":  [{ "finding": {...}, "reason": "..." }],
  "rejected":  [{ "finding": {...}, "reason": "..." }],
  "approved":  true|false,  // true if no accepted blocking items remain
  "consensus": { "reviewerCount": N, "verdict": "unanimous-pass|unanimous-block|split|unverified", "disagreements": [{ "file": "...", "line": N, "issue": "...", "note": "Fable blocking, Sonnet approved" }] }  // optional, see 3.6
}
```

##### 3.2.1 Required: validator gate (deterministic)

Run on the persisted file immediately after the triage agent returns, before acting on the verdict; the validator's exit code decides, not the LLM turn:

```bash
TRIAGE_FILE="$WORKTREE/.pipeline/triage.json"
mkdir -p "$(dirname "$TRIAGE_FILE")"
printf '%s' "$TRIAGE_JSON" > "$TRIAGE_FILE"
node $HOME/.claude/scripts/validate-triage.mjs "$TRIAGE_FILE"
```

Progress line: `    → checking validator validate-triage`

| Exit  | Meaning                      | Action                                            |
| ----- | ---------------------------- | ------------------------------------------------- |
| **0** | Valid and clean              | Act on triage output as-is                        |
| **1** | Invalid structure            | Gate protocol (strict, fails CLOSED): (a) emit the validator's stderr and `errors[]` JSON into the log verbatim; (b) attempt ONE self-correction rework  -  re-prompt triage with the errors quoted, overwrite `$TRIAGE_FILE`; (c) re-run the validator. Second failure -> HALT the phase with the recovery hint: `ERR: triage output failed validate-triage.mjs twice. Inspect $TRIAGE_FILE against $HOME/.claude/schemas/triage-output.schema.json, fix or regenerate it, then resume with /multi-agent:resume #N.` |
| **2** | Over-rejection guard tripped | Pause for human confirm (autopilot: log + accept) |
| **3** | Contradiction auto-corrected | Proceed with `result.corrected`                   |

Capture stdout into `state.reviewIterations[-1].validatorResult` for Phase 7 audit.

##### 3.3 Edge case handling

Failure fallback (timeout >120s, or agent crash before any JSON is produced): retry triage ONCE → on second failure treat ALL raw findings as `accepted`, log cause. Structural validator failures (exit 1) do NOT take this fallback  -  they follow the section 3.2.1 gate protocol (one self-correction rework, then halt with the recovery hint).

| Scenario                                       | Action                                                                      |
| ---------------------------------------------- | --------------------------------------------------------------------------- |
| Over-rejection (>80% rejected, ≥5 findings)    | Pause for user; autopilot: log `triage=high-rejection-rate`, accept verdict |
| Contradiction (`approved: false`, no blockers) | Force `approved: true`, log `triage=contradiction-corrected`                |
| Hallucinated findings (not in raw input)       | Strip; log `triage=hallucinated-finding-stripped`                           |

##### 3.4 Telemetry emission (required)

Emit metrics per review pass for Phase 7 cost rollup:

One `review.reviewer_call` per dispatched reviewer, one `review.triage_call`, one `review.completed` to close the pass:

```bash
M=$HOME/.claude/scripts/log-metric.sh
emit() {  # $1=event $2=model $3=duration $4=tokens_in $5=tokens_out
  LOG_METRIC_FORWARD_TO_TRACKER=1 bash "$M" "$TASK_ID" 4 "$1" \
    model="$2" duration_ms="$3" tokens_in="$4" tokens_out="$5"
}
emit review.reviewer_call fable  "$R1_DURATION"     "$R1_IN"     "$R1_OUT"      # opus on Copilot CLI
emit review.reviewer_call sonnet "$SONNET_DURATION" "$SONNET_IN" "$SONNET_OUT"
# Reviewer 2 is GPT-5.4 and exists only on Copilot CLI:
[ "${CLI_HOST:-claude}" = "copilot" ] && \
  emit review.reviewer_call gpt-5.4 "$GPT_DURATION" "$GPT_IN" "$GPT_OUT"
emit review.triage_call fable "$TRIAGE_DURATION" "$TRIAGE_IN" "$TRIAGE_OUT"
bash "$M" "$TASK_ID" 4 review.completed raw_count=$RAW accepted=$ACC \
  deferred=$DEF rejected=$REJ approved=$APPROVED duration_ms=$DURATION
```

`LOG_METRIC_FORWARD_TO_TRACKER=1` mirrors `tokens_in`/`tokens_out`/`model` into `phase-tracker.sh` (see `$HOME/.claude/multi-agent-refs/progress-contract.md#token-telemetry-forwarding`). On non-zero validator exit (1/2/3), also emit `triage.edge_case` with cause. Omit `tokens_in`/`tokens_out` if unavailable. Best-effort  -  never fails the pipeline.

##### 3.5 Optional cross-check (single-point-of-failure mitigation)

Opt-in via `prefs.global.triageCrossCheck.enabled` (default `false`). Sampled runs dispatch a **Sonnet** triage agent as second opinion, validated via `validate-triage.mjs` (same fallback rules). Disagreements logged as `triage.cross_check_diff`; `blockOnDisagreement` pauses for user (autopilot: proceed with the Fable verdict). Doubles triage cost on sampled runs.

##### 3.6 Consensus surfacing (anti-correlation)

**Rationale:** Reviewer 1 (Fable) and Reviewer 3 (Sonnet) are both Anthropic Claude models, so unanimous agreement on a *judgment call* is not independent confirmation  -  same-family models drift the same way on ambiguous prompts. Treating "both approved" as proof produces false-consensus passes. Triage therefore records a `consensus` block (schema v3.1.0) and surfaces disagreement and unverified agreement to the user rather than burying it.

After the triage verdict is computed, populate `triage.consensus`:

1. `reviewerCount` = number of reviewers dispatched this iteration (`2` on Claude Code, `3` on Copilot CLI).
2. Classify the iteration `verdict`:
   - `unanimous-block` -> all reviewers returned at least one overlapping `blocking` finding.
   - `split` -> reviewers disagreed on existence or severity of one or more findings (the Step 2.5 disagreement definition). List each split in `disagreements[]` with a `note` naming who held which position (e.g. "Fable blocking, Sonnet approved").
   - `unanimous-pass` -> all reviewers approved AND the diff is low-risk (no security/auth/concurrency surface per Phase 1 `touchedAreas`). Clear-cut; trust it.
   - `unverified` -> all reviewers approved BUT the diff touches a judgment-heavy surface (security, auth, concurrency, money, data migration). Agreement here may be correlated; do NOT treat it as a confirmed pass. Surface it.
3. `disagreements[]` is populated for `split` and is also used to carry `unverified` notes (e.g. "both approved a keychain change  -  agreement unverified, confirm manually").

**Surfacing (Step 4 + Phase 7):** When `verdict` is `split` or `unverified`, the disagreements are shown to the user at the Step 4 checkpoint (interactive modes) and always written to the Phase 7 report. Autopilot does not block on `unverified` (it logs `review.consensus=unverified` and proceeds), matching the maturity-check model  -  but the report records it so a human review can catch it. This is additive: omitting `consensus` is valid and means "not computed."

##### 3.7 Verify-by-test (opt-in, empirical validation of blocking findings)

A triage verdict is judgment; a failing repro test is proof. Runs only when `prefs.global.verifyByTest.enabled` is `true` AND `accepted` contains a `blocking` finding; otherwise skip silently. **Full contract (verdict table, cleanup invariant, prompts): `$HOME/.claude/multi-agent-refs/features/verify-by-test.md`  -  read it before executing this step.**

Compressed flow: dispatch ONE verifier agent (model `verifyByTest.model`, default `sonnet`) for up to `maxFindings` (default 3) accepted blocking findings. Per finding it writes ONE minimal repro test and runs ONLY that test (Phase 3 single-test invocation, build lock, log tee'd to `$WORKTREE/.pipeline/verify-<i>.test.log`). Outcomes: test FAILS as predicted -> `confirmed`, finding stays blocking and the test is KEPT in `redTests[]` as the Phase 3 rework RED test; test PASSES -> `not-reproduced` ONLY if `evidence-gate.mjs --claim test --status passed` exits 0 on the log, finding moves to `deferred[]`, test deleted; compile error / timeout / not unit-testable -> `inconclusive`, judgment verdict stands. Stamp findings with `verification` (schema v3.2.0), persist `state.reviewIterations[-1].verifyByTest = {attempted, confirmed, downgraded, inconclusive, redTests[]}`, recompute `approved`, re-run `validate-triage.mjs` under the 3.2.1 gate. Whole step bounded by `stepTimeoutSec` (default 600); on breach or crash remaining findings keep judgment verdicts  -  never blocks. Telemetry per 3.4: `review.verify_by_test attempted= confirmed= downgraded= inconclusive= duration_ms=`.

#### Step 4  -  Consensus + Action (triage-driven)

If `triage.consensus.verdict` is `split` or `unverified`, surface `consensus.disagreements[]` to the user before acting: interactive modes show the split and ask whether to treat the unverified agreement as a pass (picker-contract: Trust / Re-review / Treat-as-blocking); autopilot logs `review.consensus=<verdict>` and proceeds on the triage verdict. Never silently average a split into a pass.

Act **only on triage.accepted**:

- **accepted.blocking** → back to Phase 3 (max 3 iterations, with reflection prompt citing only accepted items). When Step 3.7 ran and `state.reviewIterations[-1].verifyByTest.redTests[]` is non-empty, the reflection prompt cites each red test: "a failing repro test already exists at <testRef>; make it green; do not delete or weaken it."
- **accepted.important** → fix and re-review
- **accepted.suggestion** → apply if reasonable
- **deferred** → append to Phase 7 report as "follow-up items" (do not block)
- **rejected** → log reasons for audit; do not touch

##### Lesson memory loop (required, end of each fix/rework round)

At the end of every fix/rework round (each Phase 3 re-entry that resolved accepted findings, including the final one), append ONE one-line root-cause lesson per resolved blocking/important finding to the existing learnings ledger (`learnings-ledger.mjs`  -  the store Phase 1 and triage already replay; never invent a parallel store):

```bash
node $HOME/.claude/scripts/learnings-ledger.mjs add --kind fact \
  --statement "<one-line rule/what-to-do so this class of finding does not recur (<=140 chars)>" \
  --diagnosis "<the causal WHY the failure happened, one line>" \
  --scope "<file-or-area glob from the finding>" --task "$TASK_ID"
```

Statement shape: the durable rule/root cause, not the symptom  -  "force-unwrapped optional in async callback path", not "fixed crash in LoginView". Always pass `--diagnosis` with the causal why (Reflexion: the verbal reason prevents recurrence; a bare outcome does not). Dedup built in (exit 2 = already stored). Lessons re-enter Phase 1 + triage via `learnings-ledger.mjs brief` (renders diagnosis as "(why: ...)").

Progress line: `    → writing lesson to learnings ledger ({N} entries)`

Log: "Phase 4: Review  -  raw={N1+N2} accepted={Na} deferred={Nd} rejected={Nr} approved={bool} consensus={verdict}"

---

#### Multi-Repo Mode

**Only when `state.projects[].length > 1`.** Load `$HOME/.claude/multi-agent-refs/features/review-multi-repo.md` and follow it: per-repo diff scoping, how one merged finding set is attributed back to the repo that owns each file, and the per-repo `buildStatus` contract. A single-repo run skips this section entirely.

## Token telemetry  -  invoke after every LLM call

```bash
bash $HOME/.claude/scripts/phase-tracker.sh tokens 4 <input_count> <output_count> [cached_count]
```

The optional 4th `cached_count` is the prompt-cache-read token count when the host reports it (Anthropic `cache_read_input_tokens`); it defaults to 0 and is priced at the cheaper `cacheReadPerMtok` rate in the Phase 7 cost ledger. The tracker accumulates the totals additively, so multiple calls in the same phase compound. The render output then shows live cost on the active phase tile (e.g. `Phase 4  Dev   2m 14s · 12.4k tok`). This satisfies the contract in `$HOME/.claude/multi-agent-refs/tracker-contract.md` and the `smoke-tracker-tokens-invocation.sh` enforcement gate. Skipping this call is the #1 cause of "I can't see how much it cost" complaints.

Contract and rationale: `progress-contract.md` -> Token telemetry forwarding.

