export const meta = {
  name: 'new-card-review',
  description:
    "Per-wave review+fix cluster for /new. Takes 1..N co-located cards (1 = sequential per-card; N = a team-mode group/wave) and runs the whole review fan-out OUTSIDE the orchestrator context: Simplify + cross-model Codex (agent-launched, code-reviewer fallback) + qa-sentinel gates + security-reviewer (high-risk only), each specialist FP-checking its OWN findings; then ONE coder applies all VERIFIED code/perf/security/simplify findings in a single pass (files disjoint by ownership) and re-verifies lint/tsc/build. Doc-review and api-perf are deliberately OUT (doc runs post-E2E in the skill; api-perf is deferred to the Final FULL gate). Returns a compact {perCard:{fixesApplied,residual}, gateTable, summary} — the only object that re-enters the orchestrator prefix. Maps to references/review-cycle.md (Phase 2.55+3.5+3.7) and references/team-mode.md (Step D.2-D.4b). NOT new2: no human gates (returned as residual), no AC-closure, no E2E, no merge/commit/backlog.",
  phases: [
    { title: 'Baseline', detail: 'architecture grounding for the wave scope' },
    { title: 'Discovery', detail: 'parallel finders per card (simplify / codex / qa / security)' },
    { title: 'Verify', detail: 'specialist-owned FP validation; residual routed to domain specialist' },
    { title: 'Fix', detail: 'one coder applies all verified code/perf/security/simplify findings + re-verify' },
  ],
}

// ───────────────────────────────────────────────────────────────────────────
// args contract — supplied by the /new skill (it owns git + tracker + human gates):
//   cards         [{ cardId, cardPath, scopeFiles[], editableFiles[], qaTier,
//                    hasSecurityFiles, runSimplify, archBaselinePath }]
//                 sequential → length 1; team-mode → the whole group/wave.
//                   scopeFiles    files the card's committed diff touched (review surface)
//                   editableFiles ownership-map files the coder MAY write (fix scope)
//                   qaTier        'light' | 'full'  (qa-sentinel runs only at 'full'; else deferred to Final)
//                   archBaselinePath  /tmp/arch-baseline-<id>.md  (or null)
//   worktreePath  string   the batch worktree (agents cd into it)
//   baseBranch    string   trunk the diff is taken against
//   config        object   resolved baldart.config.yml (paths.* … )
//
// Return value (consumed by the skill):
//   { codexEngine, perCard: { <cardId>: { fixesApplied:[…1-line], residual:[…finding] } },
//     gateTable, summary }
// ───────────────────────────────────────────────────────────────────────────

const a = args || {}
const cards = (Array.isArray(a.cards) ? a.cards : []).filter((c) => c && c.cardId)
const cfg = a.config || {}
const highRisk = (cfg.paths && cfg.paths.high_risk_modules) || [] // security-domain hint
const protocolRef = '.claude/skills/new/references/review-cycle.md'

// Per-card result accumulator — built up-front (so the early-return guards can return it) and
// populated with fixesApplied/residual in the Fix phase.
const perCard = {}
for (const c of cards) perCard[c.cardId] = { fixesApplied: [], residual: [] }

if (!cards.length) {
  log('new-card-review: no cards supplied — nothing to review.')
  return { codexEngine: 'none', perCard, gateTable: [], summary: makeSummary({}) }
}

// Union scope across the wave (Codex + qa run once over the union; per-card attribution by file).
const unionScope = dedupe(cards.flatMap((c) => asArr(c.scopeFiles)))
const unionEditable = dedupe(cards.flatMap((c) => asArr(c.editableFiles)))
const maxQaTier = cards.some((c) => String(c.qaTier).toLowerCase() === 'full') ? 'full' : 'light'

if (!unionScope.length) {
  log('new-card-review: empty review scope across all cards — nothing to review.')
  return { codexEngine: 'none', perCard, gateTable: [], summary: makeSummary({}) }
}

// ---- Schemas (parity with new-final-review.js) ------------------------------
const FINDING = {
  type: 'object',
  required: ['finding_id', 'title', 'severity', 'confidence', 'evidence', 'minimal_fix_direction', 'domain'],
  additionalProperties: false,
  properties: {
    finding_id: { type: 'string', description: '<CARD-ID>-F### or <agent>-F###' },
    title: { type: 'string' },
    severity: { enum: ['BLOCKER', 'HIGH', 'MEDIUM', 'LOW'] },
    confidence: { type: 'number', description: '0-100' },
    evidence: { type: 'string', description: 'exact file:line + code quote' },
    minimal_fix_direction: { type: 'string' },
    domain: { enum: ['doc', 'security', 'migration', 'code', 'perf', 'test'], description: 'Domain-Override routing bucket' },
  },
}
const FINDINGS_SCHEMA = {
  type: 'object', required: ['findings'], additionalProperties: false,
  properties: { findings: { type: 'array', items: FINDING }, note: { type: 'string' } },
}
const CODEX_SCHEMA = {
  type: 'object', required: ['codexAvailable', 'findings'], additionalProperties: false,
  properties: {
    codexAvailable: { type: 'boolean', description: 'false if CODEX_NOT_FOUND / TIMED_OUT' },
    findings: { type: 'array', items: FINDING },
    note: { type: 'string' },
  },
}
const GATES_SCHEMA = {
  type: 'object', required: ['gates'], additionalProperties: false,
  properties: {
    gates: {
      type: 'array',
      items: {
        type: 'object', required: ['gate', 'status'], additionalProperties: false,
        properties: { gate: { type: 'string' }, status: { enum: ['PASS', 'FAIL', 'SKIP'] }, detail: { type: 'string' } },
      },
    },
  },
}
const VERDICT_SCHEMA = {
  type: 'object', required: ['classification'], additionalProperties: false,
  properties: {
    classification: { enum: ['VERIFIED', 'FALSE_POSITIVE', 'NEEDS_MANUAL_CONFIRMATION'] },
    rationale: { type: 'string' },
  },
}
const FIX_SCHEMA = {
  type: 'object', required: ['applied', 'unresolved', 'checks'], additionalProperties: false,
  properties: {
    applied: {
      type: 'array',
      items: {
        type: 'object', required: ['finding_id'], additionalProperties: false,
        properties: { finding_id: { type: 'string' }, note: { type: 'string' } },
      },
    },
    unresolved: { type: 'array', items: { type: 'string', description: 'finding_id left unfixed' } },
    checks: {
      type: 'object', required: ['lint', 'tsc', 'build'], additionalProperties: false,
      properties: { lint: { enum: ['PASS', 'FAIL', 'SKIP'] }, tsc: { enum: ['PASS', 'FAIL', 'SKIP'] }, build: { enum: ['PASS', 'FAIL', 'SKIP'] } },
    },
    note: { type: 'string' },
  },
}

// ---- Shared brief fragments -------------------------------------------------
const waveBrief = [
  `Worktree: ${a.worktreePath || '(cwd)'}`,
  `Base branch for the diff: ${a.baseBranch || '(trunk)'}`,
  `Cards under review (Read each YAML for acceptance_criteria + entrypoints):\n${cards.map((c) => `${c.cardId} — ${c.cardPath || '(no path)'}`).join('\n')}`,
  `ALL changed files in this wave (review every one):\n${unionScope.join('\n')}`,
].join('\n\n')

function cardScopeBrief(c) {
  return `Card ${c.cardId} — files changed (review surface):\n${asArr(c.scopeFiles).join('\n') || '(none)'}`
}

// ───────────────────────────────────────────────────────────────────────────
// Phase Baseline — reuse per-card baselines when present, else architect ×1
// ───────────────────────────────────────────────────────────────────────────
phase('Baseline')
const baselinePaths = dedupe(cards.map((c) => c.archBaselinePath).filter(Boolean))
let baselineBrief
if (baselinePaths.length) {
  baselineBrief = `Architecture baseline files (Read each — file paths, type signatures, patterns, high-risk paths):\n${baselinePaths.join('\n')}`
  log(`Baseline: reusing ${baselinePaths.length} per-card baseline file(s).`)
} else {
  const arch = await agent(
    `You are grounding a post-implementation code review. Map the EXISTING architecture, critical patterns, and high-risk code paths relevant to this wave's changed files, so downstream reviewers can spot regressions.\n\n${waveBrief}\n\nReturn a concise baseline (key modules, their contracts, the regression-prone seams touched by this diff). Do not review the changes yet.`,
    { label: 'arch-baseline', phase: 'Baseline', agentType: 'codebase-architect',
      schema: { type: 'object', required: ['baseline'], additionalProperties: false, properties: { baseline: { type: 'string' } } } }
  )
  baselineBrief = `Architecture baseline (from codebase-architect):\n${(arch && arch.baseline) || '(unavailable)'}`
  log('Baseline: spawned codebase-architect (no per-card baselines supplied).')
}

// ───────────────────────────────────────────────────────────────────────────
// Phase Discovery — per-card Simplify/security finders + wave-wide Codex + qa
// ───────────────────────────────────────────────────────────────────────────
phase('Discovery')

// Deterministic Codex pre-flight (parity with new-final-review.js F-040): a minimal Haiku agent runs
// ONLY the resolution glob and returns the path, so the existence decision is taken out of the review
// agent's hands. Found → Codex runs in the BACKGROUND with file-poll. Not found → code-reviewer fallback.
const PREFLIGHT_SCHEMA = {
  type: 'object', required: ['codexScriptPath'], additionalProperties: false,
  properties: { codexScriptPath: { type: 'string', description: 'absolute path to codex-companion.mjs, or "" if none' } },
}
let codexScriptPath = ''
try {
  const pf = await agent(
    `Resolve the Codex companion script path. Run EXACTLY this one Bash command and nothing else:\n` +
      "  ls -d ~/.claude/plugins/marketplaces/openai-codex/plugins/codex/scripts/codex-companion.mjs ~/.claude/plugins/cache/openai-codex/codex/*/scripts/codex-companion.mjs 2>/dev/null | sort -V | tail -1\n" +
      `Return codexScriptPath = the trimmed stdout (a single absolute path), or "" if the command printed nothing. Do NOT run Codex, do NOT read any other file, do NOT reason about availability — just report the command's stdout.`,
    { label: 'codex-preflight', phase: 'Discovery', model: 'haiku', schema: PREFLIGHT_SCHEMA }
  )
  codexScriptPath = (pf && typeof pf.codexScriptPath === 'string') ? pf.codexScriptPath.trim() : ''
} catch (_) { codexScriptPath = '' }
const codexResolved = /codex-companion\.mjs$/.test(codexScriptPath)
log(codexResolved
  ? `Codex companion resolved deterministically: ${codexScriptPath}`
  : 'Codex companion NOT found (deterministic pre-flight) — primary code review via code-reviewer fallback.')

const codexPrompt =
  `Run a deep, cross-model code review over this wave's diff, following the review protocol summarized in ${protocolRef} (Phase 3.7). The code is already written and committed — find bugs, regressions, security issues, and quality problems.\n\n` +
  `The Codex companion script is ALREADY CONFIRMED PRESENT at:\n  ${codexScriptPath}\n` +
  `Launch it in the BACKGROUND and poll for completion — do NOT run it synchronously (a sync run would hit the Bash tool timeout):\n` +
  `  • REVIEW_FILE=a unique /tmp file (e.g. /tmp/codexreview-wave-${cards[0].cardId}-$$.md)\n` +
  `  • node "${codexScriptPath}" task "<your review instructions>" > "$REVIEW_FILE" 2>&1   — run this with run_in_background:true (the launching call returns immediately).\n` +
  `  • Then POLL $REVIEW_FILE (BashOutput / repeated reads) until it holds a terminal result, up to a full 10-minute window.\n` +
  `Return codexAvailable:false ONLY if $REVIEW_FILE ends up containing "CODEX_NOT_FOUND" or stays empty after the FULL 10-minute window — NEVER because a single Bash call returned slowly.\n\n` +
  `${waveBrief}\n\n${baselineBrief}\n\n` +
  `For each finding return: finding_id, title, severity (BLOCKER|HIGH|MEDIUM|LOW), confidence (0-100), evidence (exact file:line + code quote), minimal_fix_direction, and domain (doc|security|migration|code|perf|test). ` +
  `Run the mandatory false-positive check on every finding and suppress the unconvincing ones (your findings are treated as already FP-validated). Set codexAvailable:true when the review ran.`

const qaPrompt =
  `Run MECHANICAL GATES ONLY over the wave scope, per ${protocolRef} (Phase 3.5 qa-sentinel contract): lint, type-check (when stack uses typescript), the full test suite, build, dependency audit, and markdownlint as applicable. You are a GATE RUNNER: do NOT read source for code findings, do NOT emit severities — return only a PASS/FAIL/SKIP gate table.\n\nWorktree: ${a.worktreePath || '(cwd)'} — cd into it first.\nChanged files:\n${unionScope.join('\n')}`

function simplifyPrompt(c) {
  return `Simplify analysis (read-only — you do NOT edit, the workflow applies fixes afterward) over ONE card's committed diff, per ${protocolRef} (Phase 2.55). Cover all THREE lenses and return findings:\n` +
    `  • Reuse — newly written code that duplicates an existing util/helper; inline logic that could use existing code.\n` +
    `  • Quality — redundant state, parameter sprawl, copy-paste with slight variation, leaky abstractions, stringly-typed code where enums exist, unnecessary JSX nesting, WHAT-comments / narration.\n` +
    `  • Efficiency — redundant computation, duplicate API calls, N+1, missed concurrency, hot-path bloat, missing change-detection guards, unbounded structures.\n\n` +
    `${cardScopeBrief(c)}\n\n${baselineBrief}\n\n` +
    `Run a false-positive check on every finding and SUPPRESS the unconvincing ones (your surviving findings are treated as validated). Return findings with domain in {code, perf}. Flag only a finding you genuinely cannot resolve as confidence < 80.`
}

function securityPrompt(c) {
  return `AppSec review (read-only) over ONE card's committed diff — auth, permissions, secrets, webhooks, file upload, infra, multi-tenant isolation, injection. Security-sensitive paths: ${highRisk.join(', ') || '(none configured)'}.\n\n${cardScopeBrief(c)}\n\n${baselineBrief}\n\n` +
    `You OWN the security domain end-to-end: run the mandatory false-positive check on every finding yourself and SUPPRESS the unconvincing ones — your surviving findings are treated as validated and are NOT re-judged by another agent. Flag only a finding you genuinely cannot resolve as confidence < 80. Return findings with domain in {security, migration}.`
}

// Build the finder fan-out. Per-card: simplify (if runSimplify), security (if hasSecurityFiles).
// Wave-wide: ONE Codex pass over the union, ONE qa-sentinel pass at full tier (else skipped → deferred).
const findThunks = []
for (const c of cards) {
  if (c.runSimplify !== false) {
    findThunks.push(() => agent(simplifyPrompt(c), { label: `simplify:${c.cardId}`, phase: 'Discovery', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'simplify', card: c, r })))
  }
  if (c.hasSecurityFiles === true) {
    findThunks.push(() => agent(securityPrompt(c), { label: `security:${c.cardId}`, phase: 'Discovery', agentType: 'security-reviewer', schema: FINDINGS_SCHEMA }).then((r) => ({ kind: 'security', card: c, r })))
  }
}
if (codexResolved) {
  findThunks.push(() => agent(codexPrompt, { label: 'codex', phase: 'Discovery', schema: CODEX_SCHEMA }).then((r) => ({ kind: 'codex', card: null, r })))
}
if (maxQaTier === 'full') {
  findThunks.push(() => agent(qaPrompt, { label: 'qa-sentinel', phase: 'Discovery', agentType: 'qa-sentinel', schema: GATES_SCHEMA }).then((r) => ({ kind: 'qa', card: null, r })))
} else {
  log('Discovery: qa-sentinel SKIPPED (wave max tier ≤ light — full suite deferred to the Final FULL gate).')
}

const findResults = (await parallel(findThunks)).filter(Boolean)

// ---- Fan-in: collect findings + Codex fallback branch -----------------------
let raw = []
let gateTable = []
let codexEngine = codexResolved ? 'codex' : 'code-reviewer (fallback)'
let codexRan = false
for (const item of findResults) {
  if (item.kind === 'codex') {
    if (item.r && item.r.codexAvailable && Array.isArray(item.r.findings)) {
      codexRan = true
      raw.push(...item.r.findings.map((f) => ({ ...f, source: 'codex', preValidated: true })))
    } else {
      log('Discovery: Codex companion resolved but its review did not complete — falling back to code-reviewer.')
    }
  } else if (item.kind === 'qa') {
    gateTable = (item.r && item.r.gates) || []
  } else if (item.r && Array.isArray(item.r.findings)) {
    // simplify / security own their lane and FP-check their OWN findings → already validated.
    raw.push(...item.r.findings.map((f) => ({ ...f, source: item.kind, preValidated: true })))
  }
}

// ONE deterministic fallback: pre-flight found no companion, or it ran but did not complete.
if (!codexRan) {
  codexEngine = 'code-reviewer (fallback)'
  const fb = await agent(
    `Codex was unavailable for this wave's code review. Run the FULL code review yourself over the wave diff, per ${protocolRef} (Phase 3.7).\n\n${waveBrief}\n\n${baselineBrief}\n\nReturn findings using the schema fields, with a self false-positive check applied (your findings are treated as validated).`,
    { label: 'code-reviewer (fallback)', phase: 'Discovery', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
  )
  if (fb && Array.isArray(fb.findings)) raw.push(...fb.findings.map((f) => ({ ...f, source: 'code-reviewer', preValidated: true })))
}

// ───────────────────────────────────────────────────────────────────────────
// Phase Verify — specialist-owned validation (parity with new-final-review.js F.4)
// ───────────────────────────────────────────────────────────────────────────
phase('Verify')
const classified = (await parallel(raw.map((f) => () => verifyFinding(f)))).filter(Boolean)

function domainVerifier(domain) {
  const d = String(domain || 'code').toLowerCase()
  if (/doc|wiki|ssot|readme/.test(d)) return 'doc-reviewer'
  if (/sec|auth|secret|rls|migrat|schema|ddl|\bsql\b/.test(d)) return 'security-reviewer'
  if (/perf|cost|\bapi\b|data|latency|throughput|n\+1/.test(d)) return 'api-perf-cost-auditor'
  if (/\btest|qa\b|spec|coverage/.test(d)) return 'qa-sentinel'
  return 'code-reviewer'
}

async function verifyFinding(f) {
  if (f.preValidated || (typeof f.confidence === 'number' && f.confidence >= 80)) {
    return { ...f, classification: 'VERIFIED' }
  }
  const verifier = domainVerifier(f.domain)
  if (verifier === f.source) {
    // domain specialist IS the finder and already had its pass — a second instance adds no diversity.
    return { ...f, classification: 'NEEDS_MANUAL_CONFIRMATION' }
  }
  const v = await agent(
    `Adversarially validate this ${f.domain || 'code'} finding as the DOMAIN specialist over the cited file:line. Default to FALSE_POSITIVE if the evidence does not hold; use NEEDS_MANUAL_CONFIRMATION only when you genuinely cannot decide from the code.\n\n` +
      `finding_id: ${f.finding_id}\nseverity: ${f.severity}\ntitle: ${f.title}\nevidence: ${f.evidence}\ndomain: ${f.domain}\nsecurity-sensitive paths: ${highRisk.join(', ') || '(none configured)'}`,
    { label: `verify:${f.finding_id}`, phase: 'Verify', agentType: verifier, schema: VERDICT_SCHEMA }
  )
  return { ...f, classification: (v && v.classification) || 'NEEDS_MANUAL_CONFIRMATION' }
}

// Drop FALSE_POSITIVE; attribute each surviving finding to its card by file → card map.
const fileToCard = buildFileToCard(cards)
const surviving = classified
  .filter((f) => f.classification !== 'FALSE_POSITIVE')
  .map((f) => ({ ...f, card: attributeCard(f, fileToCard, cards) }))

// ───────────────────────────────────────────────────────────────────────────
// Phase Fix — ONE coder applies all VERIFIED code/perf/security/simplify findings.
//   doc findings → residual (the skill runs doc-reviewer post-E2E on final code).
//   NEEDS_MANUAL_CONFIRMATION → residual (human gate, owned by the skill).
// ───────────────────────────────────────────────────────────────────────────
phase('Fix')
const isDoc = (f) => /doc|wiki|ssot|readme/.test(String(f.domain).toLowerCase())
const isManual = (f) => f.classification === 'NEEDS_MANUAL_CONFIRMATION'
// Partition `surviving` (= VERIFIED + NEEDS_MANUAL; FALSE_POSITIVE already dropped) with NO overlap:
//   actionable    = VERIFIED non-doc  → the coder fixes these.
//   docResidual   = VERIFIED doc      → the skill runs doc-reviewer post-E2E on final code.
//   manualResidual= NEEDS_MANUAL any  → human gate, owned by the skill (a doc-manual must NOT be
//                   silently auto-re-reviewed: it carries its needs-manual classification out).
const actionable = surviving.filter((f) => f.classification === 'VERIFIED' && !isDoc(f))
const docResidual = surviving.filter((f) => f.classification === 'VERIFIED' && isDoc(f))
const manualResidual = surviving.filter(isManual)

const SKIP_CHECKS = { lint: 'SKIP', tsc: 'SKIP', build: 'SKIP' }
let fixResult = { applied: [], unresolved: [], checks: { ...SKIP_CHECKS } }
if (actionable.length && unionEditable.length) {
  const fixBrief =
    `Apply ALL of the verified review findings below to the worktree, then verify the build. You are the SINGLE fix pass for this wave.\n\n` +
    `Worktree: ${a.worktreePath || '(cwd)'} — cd into it.\n` +
    `You MAY edit ONLY these files (ownership map — touching anything else is a violation):\n${unionEditable.join('\n')}\n\n` +
    `Findings to fix (grouped — fix the code, not the tests unless a test itself is wrong; do NOT expand scope beyond the finding):\n` +
    actionable.map((f) => `- [${f.finding_id}] (${f.card || '?'} / ${f.domain} / ${f.severity}) ${f.title}\n    evidence: ${f.evidence}\n    direction: ${f.minimal_fix_direction}`).join('\n') +
    `\n\nAfter applying: run \`npm run lint\` and (when the project uses typescript) \`npx tsc --noEmit\` and \`npm run build\` in the worktree. If a check fails because of an edit you made, fix the regression — at most 2 retries — staying within the allowed files. ` +
    `Do NOT commit. Do NOT git stash (refs/stash is shared across worktrees). ` +
    `Return: applied (finding_ids you fixed), unresolved (finding_ids you could NOT fix within the allowed files / 2 retries), and checks (PASS/FAIL/SKIP for lint, tsc, build).`
  const r = await agent(fixBrief, { label: 'fix-coder', phase: 'Fix', agentType: 'coder', schema: FIX_SCHEMA })
  // Normalize: the coder may die (null) or return a truthy object missing fields.
  fixResult = (r && typeof r === 'object') ? r : { applied: [], unresolved: actionable.map((f) => f.finding_id), checks: { ...SKIP_CHECKS } }
  if (!Array.isArray(fixResult.applied)) fixResult.applied = []
  if (!Array.isArray(fixResult.unresolved)) fixResult.unresolved = []
  if (!fixResult.checks || typeof fixResult.checks !== 'object') fixResult.checks = { ...SKIP_CHECKS }
  log(`Fix: coder applied ${fixResult.applied.length}/${actionable.length} finding(s); checks lint=${fixResult.checks.lint} tsc=${fixResult.checks.tsc} build=${fixResult.checks.build}.`)
} else if (actionable.length) {
  // Actionable findings exist but NO editable files are mapped → cannot fix; return all as residual
  // (no wasted coder spawn — the skill will route them to a targeted coder with a proper ownership scope).
  fixResult = { applied: [], unresolved: actionable.map((f) => f.finding_id), checks: { ...SKIP_CHECKS } }
  log(`Fix: ${actionable.length} actionable finding(s) but no editable files in scope — returned as residual (coder skipped).`)
} else {
  log('Fix: no actionable code/perf/security/simplify findings — coder skipped.')
}

// Unfixed actionable findings become residual (human/coder follow-up owned by the skill).
const appliedIds = new Set((fixResult.applied || []).map((x) => x.finding_id))
const unresolvedIds = new Set(fixResult.unresolved || [])
const codeResidual = actionable.filter((f) => !appliedIds.has(f.finding_id) || unresolvedIds.has(f.finding_id))
const checksFailed = ['lint', 'tsc', 'build'].some((k) => fixResult.checks && fixResult.checks[k] === 'FAIL')

// ---- Assemble per-card result ----------------------------------------------
function bucket(cardId) { return perCard[cardId] || (perCard[cardId] = { fixesApplied: [], residual: [] }) }
for (const f of actionable) {
  if (appliedIds.has(f.finding_id) && !unresolvedIds.has(f.finding_id)) {
    bucket(f.card || cards[0].cardId).fixesApplied.push(`[${f.finding_id}] ${f.title}`)
  }
}
for (const f of [...codeResidual, ...docResidual, ...manualResidual]) {
  bucket(f.card || cards[0].cardId).residual.push(slimFinding(f))
}

const summary = makeSummary({
  cards: cards.length,
  totalFindings: raw.length,
  verified: surviving.filter((f) => f.classification === 'VERIFIED').length,
  falsePositive: classified.filter((f) => f.classification === 'FALSE_POSITIVE').length,
  needsManual: manualResidual.length,
  fixesApplied: appliedIds.size,
  docResidual: docResidual.length,
  codeResidual: codeResidual.length,
  qaRan: maxQaTier === 'full',          // false ⇒ qa-sentinel deferred to the Final FULL gate (qa_first_attempt = n/a)
  checksFailed,                          // post-fix lint/tsc/build (coder); distinct from the qa-sentinel gateTable
  failingGates: gateTable.filter((g) => g.status === 'FAIL').map((g) => g.gate), // qa-sentinel FAIL gates (test/build/audit/markdownlint)
  blockers: surviving.filter((f) => f.classification === 'VERIFIED' && f.severity === 'BLOCKER').length,
  highs: surviving.filter((f) => f.classification === 'VERIFIED' && f.severity === 'HIGH').length,
})
log(`Wave review done: ${summary.fixesApplied} fixed, ${summary.codeResidual} code-residual, ${summary.docResidual} doc-residual, ${summary.needsManual} needs-manual, ${summary.failingGates.length} failing gate(s)${checksFailed ? ', post-fix checks FAILED' : ''}. Engine: ${codexEngine}.`)

return { codexEngine, perCard, gateTable, summary }

// ---- helpers ----------------------------------------------------------------
function asArr(x) { return Array.isArray(x) ? x.filter(Boolean) : [] }
function dedupe(xs) { return Array.from(new Set(asArr(xs))) }
function makeSummary(o) {
  return Object.assign({ cards: 0, totalFindings: 0, verified: 0, falsePositive: 0, needsManual: 0, fixesApplied: 0, docResidual: 0, codeResidual: 0, qaRan: false, checksFailed: false, failingGates: [], blockers: 0, highs: 0 }, o || {})
}
function slimFinding(f) {
  return { finding_id: f.finding_id, title: f.title, severity: f.severity, domain: f.domain, evidence: f.evidence, minimal_fix_direction: f.minimal_fix_direction, classification: f.classification, card: f.card }
}
function buildFileToCard(cs) {
  const m = new Map()
  for (const c of cs) for (const file of dedupe([...asArr(c.scopeFiles), ...asArr(c.editableFiles)])) if (!m.has(file)) m.set(file, c.cardId)
  return m
}
function attributeCard(f, map, cs) {
  const ev = String(f.evidence || '') + ' ' + String(f.minimal_fix_direction || '')
  // longest known path that appears in the evidence AT A PATH BOUNDARY wins (most specific match).
  // Boundary match avoids `components/Button` mis-matching inside `components/ButtonGroup.tsx`.
  let best = null
  for (const [file, cardId] of map.entries()) {
    if (pathInText(file, ev) && (!best || file.length > best.len)) best = { cardId, len: file.length }
  }
  return best ? best.cardId : cs[0].cardId
}
function pathInText(file, text) {
  if (!file) return false
  const cont = /[A-Za-z0-9._/-]/ // a path-continuation char on either side ⇒ not a real boundary match
  let i = text.indexOf(file)
  while (i !== -1) {
    const before = i === 0 ? '' : text[i - 1]
    const after = i + file.length >= text.length ? '' : text[i + file.length]
    if (!cont.test(before) && !cont.test(after)) return true
    i = text.indexOf(file, i + 1)
  }
  return false
}
