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 ', ')" } } # --- 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' } if ($Model) { $analyseParams.Model = $Model } if ($Provider) { $analyseParams.Provider = $Provider } 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' 'enrichment' } 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' Providers = @('openai', 'gemini') } if ($Model) { $researchParams.Model = $Model } if ($Provider) { $researchParams.Providers = @($Provider) } 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' } $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' } $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' } if ($Model) { $scoreParams.Model = $Model } if ($Provider) { $scoreParams.Provider = $Provider } 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 |