Duncan Leung
Claude Code Research Tools: A Decision Guide
Published on

Claude Code Research Tools: A Decision Guide

Authors

Claude Code ships with multiple research tools, and the community has built more on top of them. The problem isn't capability — it's knowing which tool to reach for. I kept forgetting the differences, so I mapped them out.

The Cost Ladder

Research tools in Claude Code form a cost ladder. Start at the cheapest tool that answers your question. Escalate only when you need more coverage or more verification.

LevelToolAgentsCostWhat it does
1perplexity_search0 (MCP call)LowQuick facts, URLs, recent news with citations
1WebSearch0 (tool call)LowKeyword search, returns URL list with snippets
1context70 (MCP call)LowCurrent library/framework docs — not stale training data
2perplexity_ask0 (MCP call)LowOne-off AI-answered question with citations
2Explore agent1LowCodebase file/symbol lookup — fast, read-only
3perplexity_research0 (MCP call)MediumDeep multi-source investigation, takes minutes
3perplexity_reason0 (MCP call)MediumStep-by-step analysis grounded in live web
4/spec-research~6 per batchMediumDual-stack codebase investigation (Claude + Codex), file:line evidence
4/vault-news-research7Medium6 ideological-lane news analysis with bias tags
4/plan-review-dual~6+MediumVerify plan claims against codebase with two stacks
5/deep-research~35–85HighBuilt-in multi-agent research pipeline with adversarial verification
5/deep-research-plus~40–98HighCustom fork — adds Perplexity search lane to the built-in

The jump from level 3 to level 5 is large. perplexity_research uses one agent and costs medium. /deep-research-plus spins up 40–98 agents and costs high. The gap is verification — the deep-research pipeline runs 3 independent voters on every extracted claim and requires 2-of-3 refutations to kill one. Use it when getting a wrong answer is expensive, not for every question.

When to Use What

You want to know...Use thisWhy this and not something else
A URL, a fact, recent newsperplexity_search or WebSearchSingle call, instant. No agents.
How a library/framework API workscontext7Fetches current docs. Your training data is stale — context7 is not.
A quick answer with citationsperplexity_askAI-answered, one call. Good enough when you trust the source.
An in-depth explanationperplexity_researchThorough, multi-source, but still one agent. No adversarial verification.
A complex reasoning problemperplexity_reasonChain-of-thought with web grounding. For analysis, not lookup.
How a feature works in your codebase/spec-researchDual-stack (Claude + Codex). Returns file:line evidence with [CX]/[C]/[X] provenance markers.
Where something is defined in codeExplore agentFast, read-only, one subagent.
The full story on a news event/vault-news-research6 ideological lanes (mainstream, left, right, international, grassroots, primary sources). Bias-tagged output.
Whether a plan's claims are correct/plan-review-dualCross-checks each claim against the codebase with independent verifiers.
A broad, verified, multi-angle report/deep-research or /deep-research-plus5–6 search lanes, up to 15 source fetches, 3-vote adversarial verification per claim, synthesis. The heavyweight.

/deep-research vs /deep-research-plus

/deep-research is Claude Code's built-in research workflow. /deep-research-plus is my custom fork.

The difference is one thing: a sixth search lane.

/deep-research (built-in)/deep-research-plus (custom)
Search lanes5 WebSearch5 WebSearch + 1 Perplexity MCP
Total search agents56
Perplexity integrationNoneperplexity_search via MCP — surfaces recent items WebSearch misses
Graceful degradationN/AReturns empty lane if Perplexity MCP is not connected
Everything elseIdenticalIdentical

Both run the same five-phase pipeline:

Scope → Search → Fetch + Extract → Verify → Synthesize
  1        5-6       ≤15           ≤75          1
agent    agents     agents        agents      agent

Scope decomposes your question into 5 search angles. Search runs one agent per angle (plus the Perplexity lane in -plus). Fetch deduplicates URLs across all lanes and fetches up to 15 sources, extracting falsifiable claims from each. Verify runs a 3-vote adversarial panel on each claim — 2-of-3 refutations are required to kill a claim. Synthesize merges semantic duplicates, ranks by confidence, and produces a cited report.

The key design decision in the verification phase: default to refuted if uncertain. Each verifier voter checks whether the claim is actually supported by its quote, searches for contradicting evidence, evaluates source quality against claim strength, checks for outdated information, and flags marketing claims. This means the pipeline has a high bar — claims must survive active attempts to kill them.

The ultracode Keyword

Claude Code has a keyword that changes execution mode: ultracode^[ultracode replaced the deprecated workflow keyword in Claude Code v2.1.160. The old keyword has a known bug (GitHub #64413) where it still fires when "workflow" appears anywhere in a prompt.].

# As a keyword in a prompt
ultracode: audit every API endpoint for missing auth checks

# As a session-wide mode
/effort ultracode
claude --effort ultracode

Without it, Claude runs skills sequentially — one agent, one context window. With it, Claude writes a JavaScript pipeline script and fans out ~16 concurrent subagents. Results live in script variables, not your context window.

For /deep-research-plus, it makes no difference. That skill is already a workflow script — it always runs as a multi-agent pipeline. The keyword matters for skills that have a single-agent path (like /code-review) where you want to force multi-agent execution.

Appendix: deep-research-plus Workflow Script

This is the full source of the custom /deep-research-plus workflow. It lives at ~/.claude/workflows/deep-research-plus.js and adds a Perplexity MCP search lane to the built-in deep-research pipeline.

The script is a Claude Code Workflow — a JavaScript pipeline that orchestrates subagents through agent(), parallel(), pipeline(), and phase() calls.

export const meta = {
  name: 'deep-research-plus',
  description:
    'Deep research harness — fan-out web searches, fetch sources, ' +
    'adversarially verify claims, synthesize a cited report.',
  whenToUse:
    'When the user wants a deep, multi-source, fact-checked research ' +
    'report on any topic. BEFORE invoking, check if the question is ' +
    'specific enough to research directly — if underspecified (e.g., ' +
    '"what car to buy" without budget/use-case/region), ask 2-3 ' +
    'clarifying questions to narrow scope. Then pass the refined ' +
    'question as args, weaving the answers in.',
  phases: [
    { title: 'Scope', detail: 'Decompose question (from args) into 5 search angles' },
    { title: 'Search', detail: '5 parallel WebSearch agents + 1 Perplexity lane' },
    { title: 'Fetch', detail: 'URL-dedup, fetch top 15 sources, extract falsifiable claims' },
    { title: 'Verify', detail: '3-vote adversarial verification per claim (need 2/3 refutes to kill)' },
    { title: 'Synthesize', detail: 'Merge semantic dupes, rank by confidence, cite sources' },
  ],
}

// deep-research: Scope → pipeline(Search → URL-dedup → Fetch+Extract)
//                → 3-vote Verify → Synthesize
// Ported from bughunter architecture. WebSearch/WebFetch instead of git/grep.
// Question is passed via:
//   Workflow({ name: 'deep-research-plus', args: '<question>' })
//
// LOCAL CUSTOMIZATION (perplexity-lane v2, 2026-07-21): a sixth search
// lane queries the Perplexity MCP (`perplexity_search`, web-grounded
// sonar index) alongside the 5 WebSearch angles — it sometimes surfaces
// very recent items WebSearch misses. Its URLs feed the SAME dedup →
// WebFetch → adversarial-verify pipeline (Perplexity's own summaries
// are never trusted as claims). Degrades to an empty lane when the MCP
// server isn't connected (headless/cron runs).

const VOTES_PER_CLAIM = 3
const REFUTATIONS_REQUIRED = 2
const MAX_FETCH = 15
const MAX_VERIFY_CLAIMS = 25

// ─── Schemas ───
const SCOPE_SCHEMA = {
  type: 'object',
  required: ['question', 'angles', 'summary'],
  properties: {
    question: { type: 'string' },
    summary: { type: 'string' },
    angles: {
      type: 'array',
      minItems: 3,
      maxItems: 6,
      items: {
        type: 'object',
        required: ['label', 'query'],
        properties: {
          label: { type: 'string' },
          query: { type: 'string' },
          rationale: { type: 'string' },
        },
      },
    },
  },
}

const SEARCH_SCHEMA = {
  type: 'object',
  required: ['results'],
  properties: {
    results: {
      type: 'array',
      maxItems: 6,
      items: {
        type: 'object',
        required: ['url', 'title', 'relevance'],
        properties: {
          url: { type: 'string' },
          title: { type: 'string' },
          snippet: { type: 'string' },
          relevance: { enum: ['high', 'medium', 'low'] },
        },
      },
    },
  },
}

const EXTRACT_SCHEMA = {
  type: 'object',
  required: ['claims', 'sourceQuality'],
  properties: {
    sourceQuality: {
      enum: ['primary', 'secondary', 'blog', 'forum', 'unreliable'],
    },
    publishDate: { type: 'string' },
    claims: {
      type: 'array',
      maxItems: 5,
      items: {
        type: 'object',
        required: ['claim', 'quote', 'importance'],
        properties: {
          claim: { type: 'string' },
          quote: { type: 'string' },
          importance: { enum: ['central', 'supporting', 'tangential'] },
        },
      },
    },
  },
}

const VERDICT_SCHEMA = {
  type: 'object',
  required: ['refuted', 'evidence', 'confidence'],
  properties: {
    refuted: { type: 'boolean' },
    evidence: { type: 'string' },
    confidence: { enum: ['high', 'medium', 'low'] },
    counterSource: { type: 'string' },
  },
}

const REPORT_SCHEMA = {
  type: 'object',
  required: ['summary', 'findings', 'caveats'],
  properties: {
    summary: { type: 'string' },
    findings: {
      type: 'array',
      items: {
        type: 'object',
        required: ['claim', 'confidence', 'sources', 'evidence'],
        properties: {
          claim: { type: 'string' },
          confidence: { enum: ['high', 'medium', 'low'] },
          sources: { type: 'array', items: { type: 'string' } },
          evidence: { type: 'string' },
          vote: { type: 'string' },
        },
      },
    },
    caveats: { type: 'string' },
    openQuestions: { type: 'array', items: { type: 'string' } },
  },
}

// ─── Phase 0: Scope — decompose question into search angles ───
phase('Scope')
const QUESTION = (typeof args === 'string' && args.trim()) || ''
if (!QUESTION) {
  return {
    error:
      'No research question provided (perplexity-lane v2). ' +
      "Pass it as args: Workflow({ name: 'deep-research-plus', args: '<question>' }).",
  }
}

const scope = await agent(
  'Decompose this research question into complementary search angles.\n\n' +
    '## Question\n' +
    QUESTION +
    '\n\n' +
    '## Task\n' +
    'Generate 5 distinct web search queries that together cover the question ' +
    "from different angles. Pick angles that suit the question's domain. Examples:\n" +
    '- broad/primary  · academic/technical  · recent news  · contrarian/skeptical  · practitioner/implementation\n' +
    '- For medical: anatomy · common causes · serious differentials · authoritative refs · red flags\n' +
    '- For tech: state-of-art · benchmarks · limitations · industry adoption · cost/tradeoffs\n\n' +
    'Make queries specific enough to surface high-signal results. Avoid redundancy.\n' +
    'Return: the question (verbatim or lightly normalized), a 1-2 sentence ' +
    'decomposition strategy, and the angles.\n\nStructured output only.',
  { label: 'scope', schema: SCOPE_SCHEMA }
)

if (!scope) {
  return {
    error: 'Scope agent returned no result — cannot decompose the research question.',
  }
}

log('Q: ' + QUESTION.slice(0, 80) + (QUESTION.length > 80 ? '…' : ''))
log(
  'Decomposed into ' +
    scope.angles.length +
    ' angles: ' +
    scope.angles.map((a) => a.label).join(', ')
)

// ─── Dedup state — accumulates across searchers as they complete ───
const URL_HOST_PATTERN =
  /^[a-z][a-z0-9+.-]*:\/\/(?:[^/?#\\]*@)?(?:www\.)?([^/:?#@\\]+)(?::\d+)?([^?#]*)/i
const normURL = (u) => {
  const m = String(u).match(URL_HOST_PATTERN)
  return m ? (m[1] + m[2].replace(/\/$/, '')).toLowerCase() : String(u).toLowerCase()
}

const LABEL_CAP = 40
const LABEL_STRIP =
  /[\x00-\x1f\x7f-\x9f---"-″‶❝❞〝〞"]/g
const STRICT_HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
const stripLabelChars = (s) => String(s).replace(LABEL_STRIP, '')
const quotedLabel = (s) => {
  const cps = Array.from(stripLabelChars(s))
  return (
    '"' +
    cps.slice(0, LABEL_CAP).join('').trim() +
    (cps.length > LABEL_CAP ? '…' : '') +
    '"'
  )
}

const seen = new Map()
const dupes = []
const budgetDropped = []
const relRank = { high: 0, medium: 1, low: 2 }
let fetchSlots = MAX_FETCH

// ─── Prompts ───
const SEARCH_PROMPT = (angle) =>
  '## Web Searcher: ' +
  angle.label +
  '\n\n' +
  'Research question: "' +
  QUESTION +
  '"\n\n' +
  'Your angle: **' +
  angle.label +
  '** — ' +
  (angle.rationale || '') +
  '\n' +
  'Search query: `' +
  angle.query +
  '`\n\n' +
  '## Task\nUse WebSearch with the query above (or a refined version). ' +
  'Return the top 4-6 most relevant results.\n' +
  'Rank by relevance to the ORIGINAL question, not just the search query. ' +
  'Skip obvious SEO spam/content farms.\n' +
  'Include a short snippet capturing why each result is relevant.\n\nStructured output only.'

const PERPLEXITY_PROMPT = () =>
  '## Perplexity Searcher (sixth modality)\n\n' +
  'Research question: "' +
  QUESTION +
  '"\n\n' +
  'You are one search lane among six. The other five use WebSearch; your job is to surface ' +
  'sources the Perplexity index ranks highly — especially very recent news items.\n\n' +
  '## Task\n' +
  '1. Load the Perplexity MCP tool with ToolSearch: query `select:mcp__perplexity__perplexity_search`.\n' +
  '   If the tool cannot be loaded (server not connected in this session), return results: [] ' +
  'immediately — do NOT fall back to WebSearch; the other lanes already cover it.\n' +
  '2. Run perplexity_search on the question (refine the wording into 1-2 focused queries; ' +
  'apply a recency filter if the question is time-sensitive).\n' +
  '3. Return the top 4-6 results as URL + title + snippet, ranked by relevance to the ORIGINAL ' +
  'question. Only include results with a real, fetchable source URL — never a Perplexity answer ' +
  "page. Skip SEO spam/content farms. Treat Perplexity's own prose as a pointer to sources, " +
  'not as evidence — downstream verification only trusts fetched pages.\n\nStructured output only.'

const FETCH_PROMPT = (source, angle) =>
  '## Source Extractor\n\n' +
  'Research question: "' +
  QUESTION +
  '"\n\n' +
  'Fetch and extract key claims from this source:\n' +
  '**URL:** ' +
  source.url +
  '\n**Title:** ' +
  source.title +
  '\n**Found via:** ' +
  angle +
  ' search\n\n' +
  '## Task\n1. Use WebFetch to retrieve the page content.\n' +
  '2. Assess source quality: primary research/institution? secondary reporting? blog/opinion? forum? unreliable?\n' +
  '3. Extract 2-5 FALSIFIABLE claims that bear on the research question. Each claim must:\n' +
  '   - be a concrete, checkable statement (not vague generalities)\n' +
  '   - include a direct quote from the source as support\n' +
  '   - be rated central/supporting/tangential to the research question\n' +
  '4. Note publish date if available.\n\n' +
  'If the fetch fails or the page is irrelevant/paywalled, return claims: [] ' +
  'and sourceQuality: "unreliable".\n\nStructured output only.'

const VERIFY_PROMPT = (claim, v) =>
  '## Adversarial Claim Verifier (voter ' +
  (v + 1) +
  '/' +
  VOTES_PER_CLAIM +
  ')\n\n' +
  'Be SKEPTICAL. Try to REFUTE this claim. ≥' +
  REFUTATIONS_REQUIRED +
  '/' +
  VOTES_PER_CLAIM +
  ' refutations kill it.\n\n' +
  '## Research question\n' +
  QUESTION +
  '\n\n' +
  '## Claim under review\n"' +
  claim.claim +
  '"\n\n' +
  '**Source:** ' +
  claim.sourceUrl +
  ' (' +
  claim.sourceQuality +
  ')\n' +
  '**Supporting quote:** "' +
  claim.quote +
  '"\n\n' +
  '## Checklist\n' +
  '1. Is the claim actually supported by the quote, or is it an overreach/misread?\n' +
  '2. WebSearch for contradicting evidence — does any credible source dispute or heavily qualify this?\n' +
  '3. Is the source quality sufficient for the claim\'s strength? (extraordinary claims need primary sources)\n' +
  '4. Is the claim outdated? (check dates — old claims about fast-moving fields are suspect)\n' +
  '5. Is this a marketing claim / press release / cherry-picked benchmark / forum speculation?\n\n' +
  '**refuted=true** if: unsupported by quote / contradicted / low-quality source for strong claim / outdated / marketing fluff.\n' +
  '**refuted=false** ONLY if: claim is well-supported, current, and source quality matches claim strength.\n' +
  'Default to refuted=true if uncertain.\n\nStructured output only. Evidence MUST be specific.'

// ─── Pipeline: search → dedup → fetch+extract (no barrier) ───
const lanes = [
  ...scope.angles.map((a) => ({ ...a, kind: 'web' })),
  {
    label: 'perplexity',
    query: QUESTION,
    kind: 'perplexity',
    rationale: 'Perplexity sonar web-grounded index — surfaces recent items WebSearch may miss',
  },
]

const searchResults = await pipeline(
  lanes,

  (angle) =>
    agent(angle.kind === 'perplexity' ? PERPLEXITY_PROMPT() : SEARCH_PROMPT(angle), {
      label: 'search:' + angle.label,
      phase: 'Search',
      schema: SEARCH_SCHEMA,
    }).then((r) => {
      if (!r) return null
      log(angle.label + ': ' + r.results.length + ' results')
      return { angle: angle.label, results: r.results }
    }),

  (searchResult) => {
    const sorted = [...searchResult.results].sort(
      (a, b) => relRank[a.relevance] - relRank[b.relevance]
    )
    const novel = sorted.filter((r) => {
      const key = normURL(r.url)
      if (seen.has(key)) {
        dupes.push({ ...r, angle: searchResult.angle, dupOf: seen.get(key) })
        return false
      }
      if (fetchSlots <= 0 && relRank[r.relevance] >= 1) {
        budgetDropped.push({ ...r, angle: searchResult.angle })
        return false
      }
      seen.set(key, { angle: searchResult.angle, title: r.title })
      fetchSlots--
      return true
    })
    if (novel.length < searchResult.results.length) {
      log(
        searchResult.angle +
          ': ' +
          novel.length +
          ' novel (' +
          (searchResult.results.length - novel.length) +
          ' filtered)'
      )
    }
    return parallel(
      novel.map((source) => () => {
        const capturedHost = String(source.url).match(URL_HOST_PATTERN)?.[1] ?? ''
        const host = capturedHost.toLowerCase()
        const cleanHost = stripLabelChars(host)
        const isCleanBareHost =
          cleanHost === host &&
          host !== '' &&
          Array.from(host).length <= LABEL_CAP &&
          STRICT_HOST.test(host)
        const hostLabel = cleanHost === '' ? '' : isCleanBareHost ? host : quotedLabel(host)
        const sourceLabel =
          hostLabel ||
          (stripLabelChars(source.title).trim() && quotedLabel(source.title)) ||
          'unknown'
        return agent(FETCH_PROMPT(source, searchResult.angle), {
          label: 'fetch:' + sourceLabel,
          phase: 'Fetch',
          schema: EXTRACT_SCHEMA,
        })
          .then((ext) => {
            if (!ext) return null
            return {
              url: source.url,
              title: source.title,
              angle: searchResult.angle,
              sourceQuality: ext.sourceQuality,
              publishDate: ext.publishDate,
              claims: ext.claims.map((c) => ({
                ...c,
                sourceUrl: source.url,
                sourceQuality: ext.sourceQuality,
              })),
            }
          })
          .catch((e) => {
            log('fetch failed: ' + source.url + ' — ' + (e.message || e))
            return {
              url: source.url,
              title: source.title,
              angle: searchResult.angle,
              sourceQuality: 'unreliable',
              claims: [],
            }
          })
      })
    )
  }
)

const allSources = searchResults.flat().filter(Boolean)
const allClaims = allSources.flatMap((s) => s.claims)
const impRank = { central: 0, supporting: 1, tangential: 2 }
const qualRank = { primary: 0, secondary: 1, blog: 2, forum: 3, unreliable: 4 }

const rankedClaims = [...allClaims]
  .sort(
    (a, b) =>
      impRank[a.importance] - impRank[b.importance] ||
      qualRank[a.sourceQuality] - qualRank[b.sourceQuality]
  )
  .slice(0, MAX_VERIFY_CLAIMS)

log(
  'Fetched ' +
    allSources.length +
    ' sources → ' +
    allClaims.length +
    ' claims → verifying top ' +
    rankedClaims.length
)

if (rankedClaims.length === 0) {
  return {
    question: QUESTION,
    summary:
      'No claims extracted. ' +
      allSources.length +
      ' sources fetched, all empty/failed. ' +
      dupes.length +
      ' URL dupes, ' +
      budgetDropped.length +
      ' budget-dropped.',
    findings: [],
    refuted: [],
    unverified: [],
    sources: allSources.map((s) => ({ url: s.url, quality: s.sourceQuality })),
    stats: {
      angles: scope.angles.length,
      sources: allSources.length,
      claims: 0,
      dupes: dupes.length,
    },
  }
}

// ─── Verify: 3-vote adversarial ───
phase('Verify')
const voted = (
  await parallel(
    rankedClaims.map(
      (claim) => () =>
        parallel(
          Array.from({ length: VOTES_PER_CLAIM }, (_, v) => () =>
            agent(VERIFY_PROMPT(claim, v), {
              label: 'v' + v + ':' + claim.claim.slice(0, 40),
              phase: 'Verify',
              schema: VERDICT_SCHEMA,
            })
          )
        ).then((verdicts) => {
          const valid = verdicts.filter(Boolean)
          const refuted = valid.filter((v) => v.refuted).length
          const errored = VOTES_PER_CLAIM - valid.length
          const survives = valid.length >= REFUTATIONS_REQUIRED && refuted < REFUTATIONS_REQUIRED
          const isRefuted = refuted >= REFUTATIONS_REQUIRED
          const mark = survives ? '✓' : isRefuted ? '✗' : '?'
          log(
            '"' +
              claim.claim.slice(0, 50) +
              '…": ' +
              (valid.length - refuted) +
              '-' +
              refuted +
              (errored > 0 ? ' (' + errored + ' errored)' : '') +
              ' ' +
              mark
          )
          return {
            ...claim,
            verdicts: valid,
            refutedVotes: refuted,
            erroredVotes: errored,
            survives,
            isRefuted,
          }
        })
    )
  )
).filter(Boolean)

const confirmed = voted.filter((c) => c.survives)
const killed = voted.filter((c) => c.isRefuted)
const unverified = voted.filter((c) => !c.survives && !c.isRefuted)
log(
  'Verify done: ' +
    voted.length +
    ' claims → ' +
    confirmed.length +
    ' confirmed, ' +
    killed.length +
    ' refuted, ' +
    unverified.length +
    ' unverified'
)

const toRefuted = (c) => ({
  claim: c.claim,
  vote: c.verdicts.length - c.refutedVotes + '-' + c.refutedVotes,
  source: c.sourceUrl,
})
const toUnverified = (c) => ({
  claim: c.claim,
  erroredVotes: c.erroredVotes,
  validVotes: c.verdicts.length,
  source: c.sourceUrl,
})

if (confirmed.length === 0) {
  let summary
  if (killed.length === 0 && unverified.length > 0) {
    summary =
      'Could not verify any claims — all ' +
      unverified.length +
      ' verifier panels failed (likely rate-limiting or API errors). ' +
      'This is an infrastructure failure, not a research finding. ' +
      'Raw extracted claims returned below; retry or verify manually.'
  } else if (unverified.length > 0) {
    summary =
      killed.length +
      ' claims refuted by adversarial verification; ' +
      unverified.length +
      ' could not be verified (verifier agents failed). ' +
      'No claims survived. Research inconclusive.'
  } else {
    summary =
      'All ' +
      killed.length +
      ' claims refuted by adversarial verification. ' +
      'Research inconclusive — sources may be low-quality or claims overstated.'
  }
  return {
    question: QUESTION,
    summary,
    findings: [],
    refuted: killed.map(toRefuted),
    unverified: unverified.map(toUnverified),
    sources: allSources.map((s) => ({
      url: s.url,
      quality: s.sourceQuality,
      claimCount: s.claims.length,
    })),
    stats: {
      angles: scope.angles.length,
      sources: allSources.length,
      claims: allClaims.length,
      verified: voted.length,
      confirmed: 0,
      killed: killed.length,
      unverified: unverified.length,
    },
  }
}

// ─── Synthesize ───
phase('Synthesize')
const confRank = { high: 0, medium: 1, low: 2 }
const block = confirmed
  .map((c, i) => {
    const best = c.verdicts
      .filter((v) => !v.refuted)
      .sort((a, b) => confRank[a.confidence] - confRank[b.confidence])[0]
    return (
      '### [' +
      i +
      '] ' +
      c.claim +
      '\n' +
      'Vote: ' +
      (c.verdicts.length - c.refutedVotes) +
      '-' +
      c.refutedVotes +
      ' · Source: ' +
      c.sourceUrl +
      ' (' +
      c.sourceQuality +
      ')\n' +
      'Quote: "' +
      c.quote +
      '"\nVerifier evidence (' +
      best.confidence +
      '): ' +
      best.evidence +
      '\n'
    )
  })
  .join('\n')

const killedBlock =
  killed.length > 0
    ? '\n## Refuted claims (for transparency)\n' +
      killed
        .map(
          (c) =>
            '- "' +
            c.claim +
            '" (' +
            c.sourceUrl +
            ', vote ' +
            (c.verdicts.length - c.refutedVotes) +
            '-' +
            c.refutedVotes +
            ')'
        )
        .join('\n')
    : ''

const unverifiedBlock =
  unverified.length > 0
    ? '\n## Unverified claims (' +
      unverified.length +
      ' — verifier agents failed; neither confirmed nor refuted)\n' +
      unverified
        .map(
          (c) =>
            '- "' +
            c.claim +
            '" (' +
            c.sourceUrl +
            ', ' +
            c.erroredVotes +
            '/' +
            VOTES_PER_CLAIM +
            ' votes errored)'
        )
        .join('\n') +
      '\n\nMention in caveats that ' +
      unverified.length +
      ' claim(s) could not be verified due to infrastructure errors.'
    : ''

const report = await agent(
  '## Synthesis: research report\n\n' +
    '**Question:** ' +
    QUESTION +
    '\n\n' +
    confirmed.length +
    ' claims survived ' +
    VOTES_PER_CLAIM +
    '-vote adversarial verification. Merge semantic duplicates and synthesize.\n\n' +
    '## Confirmed claims\n' +
    block +
    '\n' +
    killedBlock +
    unverifiedBlock +
    '\n\n' +
    '## Instructions\n' +
    '1. Identify claims that say the same thing — merge them, combine their sources.\n' +
    '2. Group related claims into coherent findings. Each finding should directly address the research question.\n' +
    '3. Assign confidence per finding: high (multiple primary sources, unanimous votes), medium (secondary sources or split votes), low (single source or blog-quality).\n' +
    '4. Write a 3-5 sentence executive summary answering the research question.\n' +
    "5. Note caveats: what's uncertain, what sources were weak, what time-sensitivity applies.\n" +
    "6. List 2-4 open questions that emerged but weren't answered.\n\nStructured output only.",
  { label: 'synthesize', schema: REPORT_SCHEMA }
)

if (!report) {
  return {
    question: QUESTION,
    summary:
      'Synthesis step was skipped or failed — returning ' +
      confirmed.length +
      ' verified claims unmerged.',
    findings: [],
    confirmed: confirmed.map((c) => ({
      claim: c.claim,
      source: c.sourceUrl,
      quote: c.quote,
      vote: c.verdicts.length - c.refutedVotes + '-' + c.refutedVotes,
    })),
    refuted: killed.map(toRefuted),
    unverified: unverified.map(toUnverified),
    sources: allSources.map((s) => ({
      url: s.url,
      quality: s.sourceQuality,
      claimCount: s.claims.length,
    })),
    stats: {
      angles: scope.angles.length,
      sources: allSources.length,
      claims: allClaims.length,
      verified: voted.length,
      confirmed: confirmed.length,
      killed: killed.length,
      unverified: unverified.length,
      afterSynthesis: 0,
    },
  }
}

return {
  question: QUESTION,
  ...report,
  refuted: killed.map(toRefuted),
  unverified: unverified.map(toUnverified),
  sources: allSources.map((s) => ({
    url: s.url,
    quality: s.sourceQuality,
    angle: s.angle,
    claimCount: s.claims.length,
  })),
  stats: {
    angles: scope.angles.length,
    searchLanes: lanes.length,
    sourcesFetched: allSources.length,
    claimsExtracted: allClaims.length,
    claimsVerified: voted.length,
    confirmed: confirmed.length,
    killed: killed.length,
    unverified: unverified.length,
    afterSynthesis: report.findings.length,
    urlDupes: dupes.length,
    budgetDropped: budgetDropped.length,
    agentCalls:
      1 + lanes.length + allSources.length + voted.length * VOTES_PER_CLAIM + 1,
  },
}