Invoke-LeadForge.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Run the complete Opportunity Pipeline: gather → analyse → enrich → research → triage → deep-research → score → export.

.DESCRIPTION
    Single entry point for the LeadForge Pipeline. Processes a folder of .eml files
    through structured stages to produce ranked re-engagement opportunities.

    Pipeline stages (8-stage two-pass research architecture):
    1. Gather (deterministic): Scan folder → parse emails → email-manifest.json
    2. Analyse (AI): Per-email opportunity extraction → email-analyses.json
    3. Enrich (OSINT): Website scraping + search → contact-enrichment.json
    4. Research Pass 1 (AI - fast): GPT-5.4-mini + Gemini → contact-research-pass1.json
    5. Triage (AI): Claude Opus 4.8 evaluates who needs deep research → contact-triage.json
    6. Research Pass 2 (AI - deep): GPT-5.5-pro web search → contact-research.json
    7. Score (deterministic + AI): Weighted scoring → scoring-results.json
    8. Export (deterministic): Excel/CSV/JSON output

    Each stage writes output to data/. Completed stages are skipped on re-run unless -Force.
    Each API call is cached in .cache/ for provider-level replay.

.PARAMETER Path
    Path to folder containing .eml files (scanned recursively).

.PARAMETER OutputPath
    Path to output directory. Default: {folder}-LeadForge/ alongside target.

.PARAMETER Steps
    Which pipeline stages to run. Default: all available stages.

.PARAMETER Profile
    Scoring profile name (looked up in profiles/) or path to profile JSON.

.PARAMETER Model
    Override model for AI stages.

.PARAMETER Provider
    Override provider (openai, gemini, anthropic).

.PARAMETER Force
    Re-run all stages even if output already exists.

.PARAMETER DryRun
    Assemble contexts and validate without making LLM calls.

.EXAMPLE
    .\Invoke-LeadForge.ps1 -Path ".\Data\2017 - 2018"

.EXAMPLE
    .\Invoke-LeadForge.ps1 -Path ".\Data\2017 - 2018" -Steps gather,analyse,enrich

.EXAMPLE
    .\Invoke-LeadForge.ps1 -Path ".\Data\2017 - 2018" -Steps gather,analyse -Model gpt-4o
#>

param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path $_ -PathType Container })]
    [string]$Path,

    [string]$OutputPath,

    [ValidateSet('gather', 'analyse', 'enrich', 'research-pass1', 'triage', 'research-pass2', 'score', 'export')]
    [string[]]$Steps = @('gather', 'analyse', 'enrich', 'research-pass1', 'triage', 'research-pass2', 'score', 'export'),

    [string]$Profile = 'Default',

    [string]$Model,

    [string]$Provider,

    [switch]$Force,

    [switch]$DryRun
)

$ErrorActionPreference = 'Stop'
$repoRoot = $PSScriptRoot

# --- Resolve paths ---
$Path = (Resolve-Path $Path).Path

if (-not $OutputPath) {
    $folderName = Split-Path $Path -Leaf
    $parentDir = Split-Path $Path -Parent
    $OutputPath = Join-Path $parentDir "$folderName-LeadForge"
}

# --- Ensure required modules ---
if (-not (Get-Command 'Invoke-PCCompletion' -ErrorAction SilentlyContinue)) {
    Import-Module PowerCraft.AI -ErrorAction Stop
}

# --- Configure search/scrape API keys from secrets (if not already set) ---
if (-not $env:BRAVE_SEARCH_API_KEY) {
    try { $braveKey = Get-PCSecret -Name 'brave-search' } catch { $braveKey = $null }
    if ($braveKey) { $env:BRAVE_SEARCH_API_KEY = $braveKey }
}
if (-not $env:FIRECRAWL_API_KEY) {
    try { $fcKey = Get-PCSecret -Name 'firecrawl' } catch { $fcKey = $null }
    if ($fcKey) { $env:FIRECRAWL_API_KEY = $fcKey }
}

# --- Dot-source pipeline functions ---
. "$repoRoot\tool\Analysis\functions\Repair-JsonResponse.ps1"
. "$repoRoot\tool\Automation\functions\Get-OwnerDomains.ps1"
. "$repoRoot\tool\Automation\functions\Invoke-GatherEmails.ps1"
. "$repoRoot\tool\Analysis\functions\Invoke-AnalyseEmails.ps1"
. "$repoRoot\tool\Enrichment\functions\Invoke-EnrichContacts.ps1"
. "$repoRoot\tool\Analysis\functions\Invoke-ResearchContacts.ps1"
. "$repoRoot\tool\Analysis\functions\Invoke-TriageContacts.ps1"
. "$repoRoot\tool\Analysis\functions\Invoke-DeepResearch.ps1"
. "$repoRoot\tool\Analysis\functions\Invoke-ScoreOpportunities.ps1"
. "$repoRoot\tool\Automation\functions\Export-HtmlReport.ps1"
. "$repoRoot\tool\Automation\functions\Export-OpportunityResults.ps1"

# --- Resolve scoring profile ---
$profilePath = $null
if ($Profile) {
    if (Test-Path $Profile) {
        $profilePath = (Resolve-Path $Profile).Path
    }
    else {
        $profilePath = Join-Path $repoRoot "profiles\$Profile.json"
        if (-not (Test-Path $profilePath)) {
            throw "Scoring profile not found: $Profile"
        }
    }
}

# --- Load owner identity from profile ---
$ownerDomains = @()
$profileData = $null
if ($profilePath) {
    $profileData = Get-Content $profilePath -Raw | ConvertFrom-Json
    $ownerDomains = @(Get-OwnerDomains -Profile $profileData)
    if ($ownerDomains.Count -gt 0) {
        Write-Verbose "Owner domains: $($ownerDomains -join ', ')"
    }
}

# --- Resolve AI configuration from profile ---
$aiConfig = $null
if ($profileData -and $profileData.ai) {
    $aiConfig = $profileData.ai
    Write-Verbose "AI config loaded from profile (defaultProvider: $($aiConfig.defaultProvider))"
}

# Helper: resolve effective AI params for a stage (CLI > profile > hardcoded)
function Get-StageAIParams {
    param([string]$StageName)
    $stageConfig = if ($aiConfig -and $aiConfig.stages.$StageName) { $aiConfig.stages.$StageName } else { $null }
    $params = @{}

    # Provider: CLI > stage config > profile default
    if ($Provider) { $params.Provider = $Provider }
    elseif ($stageConfig -and $stageConfig.provider) { $params.Provider = $stageConfig.provider }
    elseif ($aiConfig -and $aiConfig.defaultProvider) { $params.Provider = $aiConfig.defaultProvider }

    # Model: CLI > stage config
    if ($Model) { $params.Model = $Model }
    elseif ($stageConfig -and $stageConfig.model) { $params.Model = $stageConfig.model }

    # MaxTokens: from stage config
    if ($stageConfig -and $stageConfig.maxTokens) { $params.MaxTokens = [int]$stageConfig.maxTokens }

    # Temperature: from stage config
    if ($stageConfig -and $null -ne $stageConfig.temperature) { $params.Temperature = [double]$stageConfig.temperature }

    # Providers (plural, for research): CLI > stage config
    if ($stageConfig -and $stageConfig.providers) {
        $params.Providers = @($stageConfig.providers)
    }
    # CLI -Provider overrides plural too
    if ($Provider) { $params.Providers = @($Provider) }

    return $params
}

# --- Ensure output directory ---
if (-not (Test-Path $OutputPath -PathType Container)) {
    New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
}
$dataPath = Join-Path $OutputPath 'data'
if (-not (Test-Path $dataPath -PathType Container)) {
    New-Item -ItemType Directory -Path $dataPath -Force | Out-Null
}
$cachePath = Join-Path $OutputPath '.cache'
if (-not (Test-Path $cachePath -PathType Container)) {
    New-Item -ItemType Directory -Path $cachePath -Force | Out-Null
}

# --- Pipeline execution ---
$totalStages = $Steps.Count
$startTime = Get-Date
$pipelineRun = [ordered]@{
    started_at      = $startTime.ToString('o')
    source_folder   = $Path
    output_folder   = $OutputPath
    steps_requested = $Steps
    profile         = $Profile
    model           = $Model
    provider        = $Provider
    stages          = [ordered]@{}
    errors          = @()
}

Write-Host "`n═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " LeadForge Pipeline" -ForegroundColor Cyan
Write-Host " Source: $Path" -ForegroundColor Gray
Write-Host " Output: $OutputPath" -ForegroundColor Gray
Write-Host " Stages: $($Steps -join ' → ')" -ForegroundColor Gray
if ($DryRun) { Write-Host " MODE: DRY RUN (no LLM calls)" -ForegroundColor Yellow }
Write-Host "═══════════════════════════════════════════════════════════════`n" -ForegroundColor Cyan

$stageIndex = 0

# --- Stage 1: Gather ---
if ($Steps -contains 'gather') {
    $stageIndex++
    $manifestPath = Join-Path $dataPath 'email-manifest.json'
    $shouldRun = $Force -or -not (Test-Path $manifestPath)

    if ($shouldRun) {
        Write-Host " [$stageIndex/$totalStages] Gather — Scanning and parsing emails..." -ForegroundColor White
        $stageStart = Get-Date

        try {
            $gatherResult = Invoke-GatherEmails -Path $Path -OutputPath $dataPath
            $stageEnd = Get-Date
            $pipelineRun.stages.gather = [ordered]@{
                status           = 'completed'
                duration_seconds = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
                emails_found     = $gatherResult.total_files
                emails_parsed    = $gatherResult.total_parsed
            }
            Write-Host " Found $($gatherResult.total_files) emails, parsed $($gatherResult.total_parsed)" -ForegroundColor Green
        }
        catch {
            $pipelineRun.stages.gather = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
            $pipelineRun.errors += [ordered]@{ stage = 'gather'; error = $_.Exception.Message }
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
            throw
        }
    }
    else {
        Write-Host " [$stageIndex/$totalStages] Gather — Skipped (manifest exists, use -Force to re-run)" -ForegroundColor DarkGray
        $pipelineRun.stages.gather = [ordered]@{ status = 'skipped' }
    }
}

# --- Stage 2: Analyse ---
if ($Steps -contains 'analyse') {
    $stageIndex++
    $analysesPath = Join-Path $dataPath 'email-analyses.json'
    $manifestPath = Join-Path $dataPath 'email-manifest.json'
    $shouldRun = $Force -or -not (Test-Path $analysesPath)

    if (-not (Test-Path $manifestPath)) {
        throw "Cannot run analyse stage: email-manifest.json not found. Run gather first."
    }

    if ($shouldRun -and -not $DryRun) {
        Write-Host " [$stageIndex/$totalStages] Analyse — Extracting opportunities via AI..." -ForegroundColor White
        $stageStart = Get-Date

        $analyseParams = @{
            ManifestPath = $manifestPath
            OutputPath   = $dataPath
            CachePath    = Join-Path $OutputPath '.cache' 'responses' 'analyse'
        }
        $stageAI = Get-StageAIParams -StageName 'analyse'
        if ($stageAI.Model) { $analyseParams.Model = $stageAI.Model }
        if ($stageAI.Provider) { $analyseParams.Provider = $stageAI.Provider }
        if ($stageAI.MaxTokens) { $analyseParams.MaxTokens = $stageAI.MaxTokens }
        if ($stageAI.Temperature) { $analyseParams.Temperature = $stageAI.Temperature }
        if ($ownerDomains.Count -gt 0) { $analyseParams.OwnerDomains = $ownerDomains }
        if ($profileData.owner) { $analyseParams.OwnerProfile = $profileData.owner }

        try {
            $analyseResult = Invoke-AnalyseEmails @analyseParams
            $stageEnd = Get-Date
            $pipelineRun.stages.analyse = [ordered]@{
                status           = 'completed'
                duration_seconds = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
                emails_analysed  = $analyseResult.total_analysed
                errors           = $analyseResult.total_errors
            }
            Write-Host " Analysed $($analyseResult.total_analysed) emails ($($analyseResult.total_errors) errors)" -ForegroundColor Green
        }
        catch {
            $pipelineRun.stages.analyse = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
            $pipelineRun.errors += [ordered]@{ stage = 'analyse'; error = $_.Exception.Message }
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    }
    elseif ($DryRun) {
        Write-Host " [$stageIndex/$totalStages] Analyse — DRY RUN (would process emails)" -ForegroundColor Yellow
        $pipelineRun.stages.analyse = [ordered]@{ status = 'dry_run' }
    }
    else {
        Write-Host " [$stageIndex/$totalStages] Analyse — Skipped (analyses exist, use -Force to re-run)" -ForegroundColor DarkGray
        $pipelineRun.stages.analyse = [ordered]@{ status = 'skipped' }
    }
}

# --- Stage 3: Enrich ---
if ($Steps -contains 'enrich') {
    $stageIndex++
    $enrichmentPath = Join-Path $dataPath 'contact-enrichment.json'
    $analysesPath = Join-Path $dataPath 'email-analyses.json'
    $shouldRun = $Force -or -not (Test-Path $enrichmentPath)

    if (-not (Test-Path $analysesPath)) {
        throw "Cannot run enrich stage: email-analyses.json not found. Run analyse first."
    }

    if ($shouldRun -and -not $DryRun) {
        Write-Host " [$stageIndex/$totalStages] Enrich — OSINT pre-enrichment (websites + search)..." -ForegroundColor White
        $stageStart = Get-Date

        $enrichParams = @{
            AnalysesPath    = $analysesPath
            OutputPath      = $dataPath
            CachePath       = Join-Path $OutputPath '.cache' 'enrich'
            SearchCachePath = Join-Path $OutputPath '.cache' 'search'
        }
        if ($ownerDomains.Count -gt 0) { $enrichParams.OwnerDomains = $ownerDomains }

        try {
            $enrichResult = Invoke-EnrichContacts @enrichParams
            $stageEnd = Get-Date
            $pipelineRun.stages.enrich = [ordered]@{
                status            = 'completed'
                duration_seconds  = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
                total_contacts    = $enrichResult.total_contacts
                total_domains     = $enrichResult.total_domains
                domains_scraped   = $enrichResult.domains_scraped
                contacts_searched = $enrichResult.contacts_searched
            }
            Write-Host " Enriched $($enrichResult.total_contacts) contacts, scraped $($enrichResult.domains_scraped) domains" -ForegroundColor Green
        }
        catch {
            $pipelineRun.stages.enrich = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
            $pipelineRun.errors += [ordered]@{ stage = 'enrich'; error = $_.Exception.Message }
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    }
    elseif ($DryRun) {
        Write-Host " [$stageIndex/$totalStages] Enrich — DRY RUN (would scrape websites)" -ForegroundColor Yellow
        $pipelineRun.stages.enrich = [ordered]@{ status = 'dry_run' }
    }
    else {
        Write-Host " [$stageIndex/$totalStages] Enrich — Skipped (enrichment exists, use -Force to re-run)" -ForegroundColor DarkGray
        $pipelineRun.stages.enrich = [ordered]@{ status = 'skipped' }
    }
}

# --- Stage 4: Research Pass 1 (Fast Sweep) ---
if ($Steps -contains 'research-pass1') {
    $stageIndex++
    $researchPath = Join-Path $dataPath 'contact-research-pass1.json'
    $analysesPath = Join-Path $dataPath 'email-analyses.json'
    $shouldRun = $Force -or -not (Test-Path $researchPath)

    if (-not (Test-Path $analysesPath)) {
        throw "Cannot run research-pass1 stage: email-analyses.json not found. Run analyse first."
    }

    if ($shouldRun -and -not $DryRun) {
        Write-Host " [$stageIndex/$totalStages] Research Pass 1 — Fast sweep (GPT-5.4-mini + Gemini)..." -ForegroundColor White
        $stageStart = Get-Date

        $researchParams = @{
            AnalysesPath   = $analysesPath
            OutputPath     = $dataPath
            OutputFileName = 'contact-research-pass1.json'
            CachePath      = Join-Path $OutputPath '.cache' 'research'
        }
        $stageAI = Get-StageAIParams -StageName 'research'
        if ($stageAI.Model) { $researchParams.Model = $stageAI.Model }
        if ($stageAI.Providers) { $researchParams.Providers = $stageAI.Providers }
        if ($stageAI.MaxTokens) { $researchParams.MaxTokens = $stageAI.MaxTokens }
        if ($stageAI.Temperature) { $researchParams.Temperature = $stageAI.Temperature }
        if ($profileData.owner) { $researchParams.OwnerProfile = $profileData.owner }

        # Feed enrichment context if available
        $enrichmentPath = Join-Path $dataPath 'contact-enrichment.json'
        if (Test-Path $enrichmentPath) {
            $researchParams.EnrichmentPath = $enrichmentPath
        }

        try {
            $researchResult = Invoke-ResearchContacts @researchParams
            $stageEnd = Get-Date
            $pipelineRun.stages.'research-pass1' = [ordered]@{
                status              = 'completed'
                duration_seconds    = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
                contacts_found      = $researchResult.total_contacts
                contacts_researched = $researchResult.total_researched
                errors              = $researchResult.total_errors
            }
            Write-Host " Researched $($researchResult.total_researched)/$($researchResult.total_contacts) contacts" -ForegroundColor Green
        }
        catch {
            $pipelineRun.stages.'research-pass1' = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
            $pipelineRun.errors += [ordered]@{ stage = 'research-pass1'; error = $_.Exception.Message }
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    }
    elseif ($DryRun) {
        Write-Host " [$stageIndex/$totalStages] Research Pass 1 — DRY RUN (would research contacts)" -ForegroundColor Yellow
        $pipelineRun.stages.'research-pass1' = [ordered]@{ status = 'dry_run' }
    }
    else {
        Write-Host " [$stageIndex/$totalStages] Research Pass 1 — Skipped (pass1 exists, use -Force to re-run)" -ForegroundColor DarkGray
        $pipelineRun.stages.'research-pass1' = [ordered]@{ status = 'skipped' }
    }
}

# --- Stage 5: Triage ---
if ($Steps -contains 'triage') {
    $stageIndex++
    $triagePath = Join-Path $dataPath 'contact-triage.json'
    $pass1Path = Join-Path $dataPath 'contact-research-pass1.json'
    $shouldRun = $Force -or -not (Test-Path $triagePath)

    if (-not (Test-Path $pass1Path)) {
        throw "Cannot run triage stage: contact-research-pass1.json not found. Run research-pass1 first."
    }

    if ($shouldRun -and -not $DryRun) {
        Write-Host " [$stageIndex/$totalStages] Triage — Evaluating deep research candidates (Claude Opus)..." -ForegroundColor White
        $stageStart = Get-Date

        $triageParams = @{
            Pass1Path  = $pass1Path
            OutputPath = $dataPath
            CachePath  = Join-Path $OutputPath '.cache' 'triage'
        }
        $stageAI = Get-StageAIParams -StageName 'triage'
        if ($stageAI.Provider) { $triageParams.Provider = $stageAI.Provider }
        if ($stageAI.Model) { $triageParams.Model = $stageAI.Model }
        if ($stageAI.MaxTokens) { $triageParams.MaxTokens = $stageAI.MaxTokens }
        if ($stageAI.Temperature) { $triageParams.Temperature = $stageAI.Temperature }
        $enrichmentPath = Join-Path $dataPath 'contact-enrichment.json'
        if (Test-Path $enrichmentPath) { $triageParams.EnrichmentPath = $enrichmentPath }
        $analysesPath2 = Join-Path $dataPath 'email-analyses.json'
        if (Test-Path $analysesPath2) { $triageParams.AnalysesPath = $analysesPath2 }
        if ($profileData.owner) { $triageParams.OwnerProfile = $profileData.owner }

        try {
            $triageResult = Invoke-TriageContacts @triageParams
            $stageEnd = Get-Date
            $pipelineRun.stages.triage = [ordered]@{
                status           = 'completed'
                duration_seconds = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
                total_contacts   = $triageResult.total_contacts
                total_selected   = $triageResult.total_selected
                total_skipped    = $triageResult.total_skipped
                selection_rate   = if ($triageResult.total_contacts -gt 0) { [math]::Round($triageResult.total_selected / $triageResult.total_contacts * 100, 1) } else { 0 }
                errors           = $triageResult.total_errors
            }
            Write-Host " Selected $($triageResult.total_selected)/$($triageResult.total_contacts) for deep research" -ForegroundColor Green
        }
        catch {
            $pipelineRun.stages.triage = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
            $pipelineRun.errors += [ordered]@{ stage = 'triage'; error = $_.Exception.Message }
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    }
    elseif ($DryRun) {
        Write-Host " [$stageIndex/$totalStages] Triage — DRY RUN" -ForegroundColor Yellow
        $pipelineRun.stages.triage = [ordered]@{ status = 'dry_run' }
    }
    else {
        Write-Host " [$stageIndex/$totalStages] Triage — Skipped (triage exists, use -Force to re-run)" -ForegroundColor DarkGray
        $pipelineRun.stages.triage = [ordered]@{ status = 'skipped' }
    }
}

# --- Stage 6: Research Pass 2 (Deep Dive) ---
if ($Steps -contains 'research-pass2') {
    $stageIndex++
    $finalResearchPath = Join-Path $dataPath 'contact-research.json'
    $triagePath = Join-Path $dataPath 'contact-triage.json'
    $pass1Path = Join-Path $dataPath 'contact-research-pass1.json'
    $shouldRun = $Force -or -not (Test-Path $finalResearchPath)

    # Pass 2 can run without triage (just uses all contacts from Pass 1)
    if (-not (Test-Path $pass1Path)) {
        throw "Cannot run research-pass2 stage: contact-research-pass1.json not found. Run research-pass1 first."
    }

    if ($shouldRun -and -not $DryRun) {
        Write-Host " [$stageIndex/$totalStages] Research Pass 2 — Deep dive (GPT-5.5-pro web search)..." -ForegroundColor White
        $stageStart = Get-Date

        $deepParams = @{
            TriagePath = $triagePath
            Pass1Path  = $pass1Path
            OutputPath = $dataPath
            CachePath  = Join-Path $OutputPath '.cache' 'research-deep'
        }
        $stageAI = Get-StageAIParams -StageName 'deepResearch'
        if ($stageAI.Provider) { $deepParams.Provider = $stageAI.Provider }
        if ($stageAI.Model) { $deepParams.Model = $stageAI.Model }
        if ($stageAI.MaxTokens) { $deepParams.MaxTokens = $stageAI.MaxTokens }
        if ($stageAI.Temperature) { $deepParams.Temperature = $stageAI.Temperature }
        $enrichmentPath = Join-Path $dataPath 'contact-enrichment.json'
        if (Test-Path $enrichmentPath) { $deepParams.EnrichmentPath = $enrichmentPath }

        # If no triage file exists, skip deep research and promote Pass 1
        if (-not (Test-Path $triagePath)) {
            Write-Host " No triage file found — promoting Pass 1 as final research" -ForegroundColor Yellow
            Copy-Item -Path $pass1Path -Destination $finalResearchPath -Force
            $pipelineRun.stages.'research-pass2' = [ordered]@{ status = 'pass_through'; note = 'No triage; Pass 1 promoted' }
        }
        else {
            try {
                $deepResult = Invoke-DeepResearch @deepParams
                $stageEnd = Get-Date
                $pipelineRun.stages.'research-pass2' = [ordered]@{
                    status           = 'completed'
                    duration_seconds = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
                    deep_researched  = $deepResult.deep_researched
                    pass1_only       = $deepResult.pass1_only
                    total_errors     = $deepResult.total_errors
                }
                Write-Host " Deep: $($deepResult.deep_researched), Pass1-only: $($deepResult.pass1_only)" -ForegroundColor Green
            }
            catch {
                $pipelineRun.stages.'research-pass2' = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
                $pipelineRun.errors += [ordered]@{ stage = 'research-pass2'; error = $_.Exception.Message }
                Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
                # Fallback: promote Pass 1
                if (-not (Test-Path $finalResearchPath)) {
                    Copy-Item -Path $pass1Path -Destination $finalResearchPath -Force
                    Write-Host " (Pass 1 results promoted as fallback)" -ForegroundColor Yellow
                }
            }
        }
    }
    elseif ($DryRun) {
        Write-Host " [$stageIndex/$totalStages] Research Pass 2 — DRY RUN" -ForegroundColor Yellow
        $pipelineRun.stages.'research-pass2' = [ordered]@{ status = 'dry_run' }
    }
    else {
        Write-Host " [$stageIndex/$totalStages] Research Pass 2 — Skipped (final research exists, use -Force to re-run)" -ForegroundColor DarkGray
        $pipelineRun.stages.'research-pass2' = [ordered]@{ status = 'skipped' }
    }
}

# --- Stage 7: Score ---
if ($Steps -contains 'score') {
    $stageIndex++
    $scoringPath = Join-Path $dataPath 'scoring-results.json'
    $analysesPath = Join-Path $dataPath 'email-analyses.json'
    $researchPath = Join-Path $dataPath 'contact-research.json'
    $shouldRun = $Force -or -not (Test-Path $scoringPath)

    if (-not (Test-Path $analysesPath)) {
        throw "Cannot run score stage: email-analyses.json not found. Run analyse first."
    }
    if (-not (Test-Path $researchPath)) {
        throw "Cannot run score stage: contact-research.json not found. Run research first."
    }

    if ($shouldRun -and -not $DryRun) {
        Write-Host " [$stageIndex/$totalStages] Score — Scoring opportunities..." -ForegroundColor White
        $stageStart = Get-Date

        $scoreParams = @{
            AnalysesPath = $analysesPath
            ResearchPath = $researchPath
            ProfilePath  = $profilePath
            OutputPath   = $dataPath
            CachePath    = Join-Path $OutputPath '.cache' 'responses' 'score'
        }
        $stageAI = Get-StageAIParams -StageName 'score'
        if ($stageAI.Model) { $scoreParams.Model = $stageAI.Model }
        if ($stageAI.Provider) { $scoreParams.Provider = $stageAI.Provider }
        if ($stageAI.MaxTokens) { $scoreParams.MaxTokens = $stageAI.MaxTokens }
        if ($stageAI.Temperature) { $scoreParams.Temperature = $stageAI.Temperature }

        try {
            $scoreResult = Invoke-ScoreOpportunities @scoreParams
            $stageEnd = Get-Date
            $pipelineRun.stages.score = [ordered]@{
                status           = 'completed'
                duration_seconds = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
                total_scored     = $scoreResult.total_scored
            }
            Write-Host " Scored $($scoreResult.total_scored) opportunities" -ForegroundColor Green
        }
        catch {
            $pipelineRun.stages.score = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
            $pipelineRun.errors += [ordered]@{ stage = 'score'; error = $_.Exception.Message }
            Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
        }
    }
    elseif ($DryRun) {
        Write-Host " [$stageIndex/$totalStages] Score — DRY RUN (would score opportunities)" -ForegroundColor Yellow
        $pipelineRun.stages.score = [ordered]@{ status = 'dry_run' }
    }
    else {
        Write-Host " [$stageIndex/$totalStages] Score — Skipped (scores exist, use -Force to re-run)" -ForegroundColor DarkGray
        $pipelineRun.stages.score = [ordered]@{ status = 'skipped' }
    }
}

# --- Stage 8: Export ---
if ($Steps -contains 'export') {
    $stageIndex++
    $scoringPath = Join-Path $dataPath 'scoring-results.json'
    $outputDir = Join-Path $OutputPath 'output'

    if (-not (Test-Path $scoringPath)) {
        throw "Cannot run export stage: scoring-results.json not found. Run score first."
    }

    Write-Host " [$stageIndex/$totalStages] Export — Generating reports..." -ForegroundColor White
    $stageStart = Get-Date

    try {
        $exportResult = Export-OpportunityResults -ScoringPath $scoringPath -OutputPath $outputDir
        $stageEnd = Get-Date
        $pipelineRun.stages.export = [ordered]@{
            status           = 'completed'
            duration_seconds = [math]::Round(($stageEnd - $stageStart).TotalSeconds, 1)
            files_created    = $exportResult.files_created
            total_results    = $exportResult.total_results
        }
        Write-Host " Exported $($exportResult.total_results) results to $($exportResult.files_created.Count) files" -ForegroundColor Green
    }
    catch {
        $pipelineRun.stages.export = [ordered]@{ status = 'failed'; error = $_.Exception.Message }
        $pipelineRun.errors += [ordered]@{ stage = 'export'; error = $_.Exception.Message }
        Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
    }
}

# --- Write pipeline run metadata ---
$endTime = Get-Date
$pipelineRun.completed_at = $endTime.ToString('o')
$pipelineRun.total_duration_seconds = [math]::Round(($endTime - $startTime).TotalSeconds, 1)

$runPath = Join-Path $OutputPath 'pipeline-run.json'
$pipelineRun | ConvertTo-Json -Depth 10 | Set-Content -Path $runPath -Encoding UTF8

Write-Host "`n═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Pipeline complete in $($pipelineRun.total_duration_seconds)s" -ForegroundColor Green
Write-Host " Output: $OutputPath" -ForegroundColor Gray
Write-Host "═══════════════════════════════════════════════════════════════`n" -ForegroundColor Cyan

# Return result object
[PSCustomObject]$pipelineRun