validate: make the Claude review opt-in

validate was the last workflow still spending a Claude review by default:
its reviewer set was derived from `skip` alone, so an unconfigured run
scheduled both providers. Every other caller already reached the verifier
through code-verify, which defaults to Codex.

Take the router's own vocabulary — reviewProvider codex|claude|both,
defaulting to codex — and forward it through full-check so /pre-pr can
still ask for the second reviewer.

Skipping a reviewer that was explicitly selected is contradictory input
and now throws: otherwise `reviewProvider: 'claude'` plus skip:['review']
would report green having reviewed nothing. Skipping the default reviewer
stays legal but is logged, since skip:['codex'] used to mean "review with
Claude" and now means no review at all.
diff --git a/.claude/workflows/full-check.js b/.claude/workflows/full-check.js
index 6e353bf..76c98dd 100644
--- a/.claude/workflows/full-check.js
+++ b/.claude/workflows/full-check.js
@@ -5,15 +5,17 @@
   phases: [{ title: 'Software' }, { title: 'Hardware' }],
 }
 
-// args: { boards: string[], hilBoards?: string[], examples?: string, base?: string, skip?: string[] }
+// args: { boards: string[], hilBoards?: string[], examples?: string, base?: string,
+//         skip?: string[], reviewProvider?: 'codex'|'claude'|'both' }
 if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
 if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
-  throw new Error('args must be { boards: string[], hilBoards?, examples?, base?, skip? }')
+  throw new Error('args must be { boards: string[], hilBoards?, examples?, base?, skip?, reviewProvider? }')
 }
 
 phase('Software')
 const software = await workflow('validate', {
   boards: args.boards, examples: args.examples, base: args.base, skip: args.skip,
+  reviewProvider: args.reviewProvider,
 })
 if (!software || !software.pass) {
   log('software validation failed — skipping HIL')
diff --git a/.claude/workflows/test/test-code-verify.mjs b/.claude/workflows/test/test-code-verify.mjs
index 16cbbd6..c776a2a 100644
--- a/.claude/workflows/test/test-code-verify.mjs
+++ b/.claude/workflows/test/test-code-verify.mjs
@@ -189,7 +189,7 @@
   const workflow = async () => { throw new Error('validate cannot nest a workflow') }
   const parallel = thunks => Promise.all(thunks.map(run => run()))
   const result = await fn(
-    { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1 },
+    { boards: ['test'], skip: ['unit', 'size', 'pvs'], reviewProvider: 'both', maxCycles: 1 },
     agent, null, parallel, () => {}, () => {}, workflow, null,
   )
   assert.deepEqual(calls.sort(), ['builder', 'code-verifier', 'codex-code-verifier'])
@@ -198,7 +198,7 @@
 
   blocking = true
   const blocked = await fn(
-    { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1 },
+    { boards: ['test'], skip: ['unit', 'size', 'pvs'], reviewProvider: 'both', maxCycles: 1 },
     agent, null, parallel, () => {}, () => {}, workflow, null,
   )
   assert.equal(blocked.stages.find(s => s.stage === 'review').pass, false)
@@ -206,12 +206,86 @@
 
   failCodex = true
   const partial = await fn(
-    { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1 },
+    { boards: ['test'], skip: ['unit', 'size', 'pvs'], reviewProvider: 'both', maxCycles: 1 },
     agent, null, parallel, () => {}, () => {}, workflow, null,
   )
   assert.equal(partial.stages.find(s => s.stage === 'review').detail, 'claude')
   assert.equal(partial.stages.find(s => s.stage === 'codex').detail, 'stage agent died')
 })
 
+await check('validate reviews with codex unless claude is explicitly selected', async () => {
+  const src = readFileSync(new URL('../validate.js', import.meta.url), 'utf8').replace(/^export /m, '')
+  const fn = new AsyncFunction(
+    'args', 'agent', 'pipeline', 'parallel', 'phase', 'log', 'workflow', 'budget', src)
+  const parallel = thunks => Promise.all(thunks.map(run => run()))
+  const runValidate = async extra => {
+    const calls = []
+    const agent = async (prompt, options) => {
+      calls.push(options.agentType)
+      if (options.agentType === 'builder') return { board: 'test', pass: true, builtCount: 1, failures: [] }
+      return { pass: true, detail: options.agentType, findings: [] }
+    }
+    const result = await fn(
+      { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1, ...extra },
+      agent, null, parallel, () => {}, () => {}, null, null,
+    )
+    return { calls, result }
+  }
+
+  // the default must never spend a Claude review
+  for (const extra of [{}, { reviewProvider: 'codex' }]) {
+    const { calls, result } = await runValidate(extra)
+    assert.deepEqual(calls.sort(), ['builder', 'codex-code-verifier'])
+    assert.equal(result.stages.some(s => s.stage === 'review'), false)
+    assert.equal(result.stages.find(s => s.stage === 'codex').pass, true)
+  }
+
+  const claudeOnly = await runValidate({ reviewProvider: 'claude' })
+  assert.deepEqual(claudeOnly.calls.sort(), ['builder', 'code-verifier'])
+  assert.equal(claudeOnly.result.stages.some(s => s.stage === 'codex'), false)
+
+  const both = await runValidate({ reviewProvider: 'both' })
+  assert.deepEqual(both.calls.sort(), ['builder', 'code-verifier', 'codex-code-verifier'])
+
+  // skip still turns a selected reviewer off
+  const skipped = await runValidate({ reviewProvider: 'both', skip: ['unit', 'size', 'pvs', 'review'] })
+  assert.deepEqual(skipped.calls.sort(), ['builder', 'codex-code-verifier'])
+
+  // selecting a reviewer and skipping it is contradictory input, not silence
+  await assert.rejects(
+    runValidate({ reviewProvider: 'claude', skip: ['unit', 'size', 'pvs', 'review'] }),
+    /reviewProvider "claude" is cancelled by skip/,
+  )
+  await assert.rejects(
+    runValidate({ reviewProvider: 'both', skip: ['unit', 'size', 'pvs', 'review', 'codex'] }),
+    /reviewProvider "both" is cancelled by skip/,
+  )
+
+  // skipping the default reviewer is legal but leaves nothing reviewing the diff
+  const noReview = await runValidate({ skip: ['unit', 'size', 'pvs', 'codex'] })
+  assert.deepEqual(noReview.calls.sort(), ['builder'])
+  assert.equal(noReview.result.stages.some(s => s.stage === 'review' || s.stage === 'codex'), false)
+
+  for (const reviewProvider of ['', 'auto', 'CODEX']) {
+    await assert.rejects(runValidate({ reviewProvider }), /reviewProvider must be codex, claude, or both/)
+  }
+})
+
+await check('full-check forwards the reviewer selection to validate', async () => {
+  const src = readFileSync(new URL('../full-check.js', import.meta.url), 'utf8').replace(/^export /m, '')
+  const fn = new AsyncFunction(
+    'args', 'agent', 'pipeline', 'parallel', 'phase', 'log', 'workflow', 'budget', src)
+  const seen = []
+  const workflow = async (name, workflowArgs) => {
+    seen.push({ name, args: workflowArgs })
+    return { pass: true }
+  }
+  await fn({ boards: ['test'], reviewProvider: 'both' },
+    null, null, null, () => {}, () => {}, workflow, null)
+  assert.deepEqual(seen.map(s => s.name), ['validate'])
+  assert.equal(seen[0].args.reviewProvider, 'both')
+  assert.deepEqual(seen[0].args.boards, ['test'])
+})
+
 console.log(failed ? `\n${failed} FAILED` : '\nall checks passed')
 process.exit(failed ? 1 : 0)
diff --git a/.claude/workflows/validate.js b/.claude/workflows/validate.js
index b946532..ddf8dae 100644
--- a/.claude/workflows/validate.js
+++ b/.claude/workflows/validate.js
@@ -1,6 +1,6 @@
 export const meta = {
   name: 'validate',
-  description: 'Pre-PR software validation loop: unit tests + per-board build sweeps + code-size compare + PVS + diff reviews (claude + codex) in parallel; a red verdict dispatches a fix agent for the confirmed findings, then the affected stages re-run — up to maxCycles (default 5) validation passes; a fix that edits a workflow file stops with restartRequired so the caller re-invokes it',
+  description: 'Pre-PR software validation loop: unit tests + per-board build sweeps + code-size compare + PVS + the diff review (Codex by default; Claude only when reviewProvider selects it) in parallel; a red verdict dispatches a fix agent for the confirmed findings, then the affected stages re-run — up to maxCycles (default 5) validation passes; a fix that edits a workflow file stops with restartRequired so the caller re-invokes it',
   whenToUse: 'Before opening or updating a PR, after any non-trivial change',
   phases: [
     { title: 'Validate', detail: 'unit + builds + size + pvs + reviews in parallel' },
@@ -9,7 +9,8 @@
 }
 
 // args: { boards: string[], examples?: string, base?: string,
-//         skip?: ('unit'|'size'|'pvs'|'review'|'codex')[], maxCycles?: number }
+//         skip?: ('unit'|'size'|'pvs'|'review'|'codex')[],
+//         reviewProvider?: 'codex'|'claude'|'both', maxCycles?: number }
 if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
 if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
   throw new Error('args must be { boards: string[], examples?, base?, skip?, maxCycles? }')
@@ -121,12 +122,27 @@
 // names: 'unit', 'build:<board>', 'size', 'pvs', and the dual-provider
 // 'reviews' scheduler, which emits the public 'review' and 'codex' rows.
 // ---------------------------------------------------------------------------
+// Same default as the code-verify router: Codex reviews, Claude only when
+// asked for. An unconfigured run must never spend a second reviewer silently.
+const requestedProvider = args.reviewProvider ?? 'codex'
+if (!['codex', 'claude', 'both'].includes(requestedProvider)) {
+  throw new Error('reviewProvider must be codex, claude, or both')
+}
 const reviewStageNames = []
-if (!skip.includes('review')) reviewStageNames.push('review')
-if (!skip.includes('codex')) reviewStageNames.push('codex')
+if (requestedProvider !== 'codex' && !skip.includes('review')) reviewStageNames.push('review')
+if (requestedProvider !== 'claude' && !skip.includes('codex')) reviewStageNames.push('codex')
 const reviewProvider = reviewStageNames.length === 2 ? 'both'
   : reviewStageNames[0] === 'review' ? 'claude'
     : reviewStageNames[0] === 'codex' ? 'codex' : null
+// Asking for a reviewer and skipping it is contradictory input, not a request
+// for silence — the run would otherwise report green having reviewed nothing.
+if (args.reviewProvider && !reviewProvider) {
+  throw new Error(`reviewProvider "${requestedProvider}" is cancelled by skip: ${skip.join(', ')}`)
+}
+// 'codex' alone is now the default reviewer, so skipping it leaves no diff
+// review at all — legal (skip means skip), but never silent: under the old
+// contract skip:['codex'] meant "review with Claude".
+if (!reviewProvider) log('no diff review will run — every reviewer is skipped')
 const reviewPrompt =
   `Code-review this branch's diff vs ${base} (git diff ${base}...HEAD), coverage-first: walk every hunk, no spot checks. ` +
   'Find pass — candidate defects across all dimensions: correctness/logic, ISR & concurrency safety, ' +
diff --git a/CLAUDE.md b/CLAUDE.md
index ebe2ba6..ae42228 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,7 +22,7 @@
 - Use `/codex:rescue` for substantial bounded implementation, diagnosis, or a second pass when Claude is stuck; use its `--background`, `--resume`, and `--fresh` controls when needed.
 - When Codex should use a named TinyUSB role, select its `.codex/agents/<role>.toml` adapter; the adapter loads `.claude/agents/<role>.md` as the canonical role.
 - Reviews and research may run beside Claude. For write-capable delegation, use a separate worktree if Claude continues editing; otherwise yield the current worktree to Codex until it finishes. Never let both edit overlapping files in one worktree.
-- `.claude/workflows/*.js` remain the canonical Claude Code orchestration. Root workflows route `code-verifier` work through `code-verify`: Codex by default, or Claude/both when selected. Because workflows nest only one level, `validate` dispatches the same verifier agents directly when called by `full-check`. The Codex subprocess lives only in `.claude/agents/codex-code-verifier.md`; never copy it into a workflow.
+- `.claude/workflows/*.js` remain the canonical Claude Code orchestration. Root workflows route `code-verifier` work through `code-verify`: Codex by default, or Claude/both when selected. Because workflows nest only one level, `validate` dispatches the same verifier agents directly when called by `full-check`, with the same default: pass `reviewProvider: 'claude'|'both'` (through `full-check` too) to spend a Claude review, or nothing runs but Codex. The Codex subprocess lives only in `.claude/agents/codex-code-verifier.md`; never copy it into a workflow.
 
 ## Ground Rules