Set-StrictMode -Version 3.0 $ErrorActionPreference = "Stop" function Get-RalphRoot { return (Split-Path -Parent $PSScriptRoot) } function Get-RepoRoot { return (Split-Path -Parent (Get-RalphRoot)) } function Ensure-Directory { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path $Path)) { New-Item -ItemType Directory -Force -Path $Path | Out-Null } } function Read-JsonFile { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path $Path)) { throw "JSON file not found: $Path" } return Get-Content -Raw -Path $Path | ConvertFrom-Json } function Get-ProvidersConfig { $ralphRoot = Get-RalphRoot $localPath = Join-Path $ralphRoot "config\providers.local.json" $examplePath = Join-Path $ralphRoot "config\providers.example.json" if (Test-Path $localPath) { return Read-JsonFile -Path $localPath } return Read-JsonFile -Path $examplePath } function Get-ProviderConfig { param([Parameter(Mandatory = $true)][string]$Name) $config = Get-ProvidersConfig if (-not $config.providers.PSObject.Properties.Name.Contains($Name)) { throw "Provider '$Name' not found in providers config." } return $config.providers.$Name } function Get-DefaultImplementer { $config = Get-ProvidersConfig return [string]$config.default_implementer } function Get-DefaultReviewers { $config = Get-ProvidersConfig return @($config.default_reviewers) } function Get-CodexConfig { $ralphRoot = Get-RalphRoot $localPath = Join-Path $ralphRoot "config\codex.local.json" $examplePath = Join-Path $ralphRoot "config\codex.example.json" if (Test-Path $localPath) { return Read-JsonFile -Path $localPath } return Read-JsonFile -Path $examplePath } function Get-RalphAutomationConfig { $ralphRoot = Get-RalphRoot $localPath = Join-Path $ralphRoot "config\automation.local.json" $examplePath = Join-Path $ralphRoot "config\automation.example.json" if (Test-Path $localPath) { return Read-JsonFile -Path $localPath } if (Test-Path $examplePath) { return Read-JsonFile -Path $examplePath } return $null } function Get-TelegramConfig { $ralphRoot = Get-RalphRoot $localPath = Join-Path $ralphRoot "config\telegram.local.json" $examplePath = Join-Path $ralphRoot "config\telegram.example.json" if (Test-Path $localPath) { return Read-JsonFile -Path $localPath } if (Test-Path $examplePath) { return Read-JsonFile -Path $examplePath } return $null } function Get-TelegramChatIds { param($Config) if ($null -eq $Config) { return @() } if ($Config.PSObject.Properties.Name -contains "chat_ids") { return @($Config.chat_ids | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { [string]$_ }) } if ($Config.PSObject.Properties.Name -contains "chat_id" -and -not [string]::IsNullOrWhiteSpace([string]$Config.chat_id)) { return @([string]$Config.chat_id) } return @() } function Test-TelegramEventEnabled { param( [Parameter(Mandatory = $true)]$Config, [Parameter(Mandatory = $true)][string]$EventName ) $enabled = $false try { $enabled = [bool]$Config.enabled } catch { $enabled = $false } if (-not $enabled) { return $false } if ($Config.PSObject.Properties.Name -contains "events" -and $null -ne $Config.events) { $eventProperty = $Config.events.PSObject.Properties[$EventName] if ($null -ne $eventProperty) { try { return [bool]$eventProperty.Value } catch { return $false } } } return $true } function Send-TelegramNotification { param( [Parameter(Mandatory = $true)][string]$EventName, [Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][string]$Message, [string]$RunId = "", [string]$Stage = "", [string]$Status = "" ) try { $config = Get-TelegramConfig } catch { return [ordered]@{ sent = $false reason = $_.Exception.Message delivered = 0 errors = @() } } if ($null -eq $config) { return [ordered]@{ sent = $false reason = "telegram config missing" delivered = 0 errors = @() } } if (-not (Test-TelegramEventEnabled -Config $config -EventName $EventName)) { return [ordered]@{ sent = $false reason = "event disabled" delivered = 0 errors = @() } } $botToken = "" if ($config.PSObject.Properties.Name -contains "bot_token") { $botToken = [string]$config.bot_token } if ([string]::IsNullOrWhiteSpace($botToken)) { return [ordered]@{ sent = $false reason = "bot token missing" delivered = 0 errors = @() } } $chatIds = @(Get-TelegramChatIds -Config $config) if ($chatIds.Count -eq 0) { return [ordered]@{ sent = $false reason = "chat ids missing" delivered = 0 errors = @() } } $timeoutSeconds = 8 if ($config.PSObject.Properties.Name -contains "timeout_seconds") { try { $timeoutSeconds = [int]$config.timeout_seconds } catch { $timeoutSeconds = 8 } } $prefix = "Ralph" if ($config.PSObject.Properties.Name -contains "prefix" -and -not [string]::IsNullOrWhiteSpace([string]$config.prefix)) { $prefix = [string]$config.prefix } $lines = @( ("{0} | {1}" -f $prefix, $Title.Trim()) $Message.Trim() ) if (-not [string]::IsNullOrWhiteSpace($RunId)) { $lines += ("run: " + $RunId) } if (-not [string]::IsNullOrWhiteSpace($Stage) -or -not [string]::IsNullOrWhiteSpace($Status)) { $statusLine = @() if (-not [string]::IsNullOrWhiteSpace($Stage)) { $statusLine += ("stage=" + $Stage) } if (-not [string]::IsNullOrWhiteSpace($Status)) { $statusLine += ("status=" + $Status) } if ($statusLine.Count -gt 0) { $lines += ($statusLine -join " ") } } $text = ($lines -join "`n").Trim() $uri = "https://api.telegram.org/bot{0}/sendMessage" -f $botToken $delivered = 0 $errors = New-Object System.Collections.Generic.List[string] foreach ($chatId in $chatIds) { try { $body = @{ chat_id = $chatId text = $text disable_web_page_preview = $true } Invoke-RestMethod -Method Post -Uri $uri -Body $body -ContentType "application/x-www-form-urlencoded" -TimeoutSec $timeoutSeconds | Out-Null $delivered += 1 } catch { $errors.Add(("{0}: {1}" -f $chatId, $_.Exception.Message)) } } return [ordered]@{ sent = ($delivered -gt 0) reason = $(if ($delivered -gt 0) { "ok" } else { "delivery failed" }) delivered = $delivered errors = @($errors) } } function New-RunId { param([string]$Label = "autopilot") return "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmss"), $Label } function Get-TaskFiles { param([string]$TaskDirectory = $(Join-Path (Get-RalphRoot) "tasks\current")) return @{ Task = Join-Path $TaskDirectory "TASK.md" Acceptance = Join-Path $TaskDirectory "ACCEPTANCE.md" Context = Join-Path $TaskDirectory "CONTEXT.md" } } function Get-RalphInboxDirectory { return (Join-Path (Get-RalphRoot) "tasks\inbox") } function Get-RalphProcessingDirectory { return (Join-Path (Get-RalphRoot) "tasks\processing") } function Get-RalphArchiveDirectory { return (Join-Path (Get-RalphRoot) "tasks\completed") } function Get-RalphFailedDirectory { return (Join-Path (Get-RalphRoot) "tasks\failed") } function Read-TaskPack { param([string]$TaskDirectory = $(Join-Path (Get-RalphRoot) "tasks\current")) $files = Get-TaskFiles -TaskDirectory $TaskDirectory foreach ($entry in $files.GetEnumerator()) { if (-not (Test-Path $entry.Value)) { throw "Task pack file missing: $($entry.Value)" } } return @{ Files = $files Task = Get-Content -Raw -Path $files.Task Acceptance = Get-Content -Raw -Path $files.Acceptance Context = Get-Content -Raw -Path $files.Context } } function Convert-MarkdownToRalphTaskPack { param( [Parameter(Mandatory = $true)][string]$Path ) if (-not (Test-Path $Path)) { throw "Task markdown not found: $Path" } $raw = Get-Content -Raw -Path $Path $lines = $raw -split "`r?`n" $taskLines = New-Object System.Collections.Generic.List[string] $acceptanceLines = New-Object System.Collections.Generic.List[string] $contextLines = New-Object System.Collections.Generic.List[string] $current = "task" $title = "" foreach ($line in $lines) { if ($line -match '^\s*#{1,3}\s+(.*\S)\s*$') { $heading = $matches[1].Trim() if (-not $title -and $line -match '^\s*#\s+') { $title = $heading } $headingLower = $heading.ToLowerInvariant() if ($headingLower -match '^(acceptance|acceptance criteria|criteria|done|definition of done)\b') { $current = "acceptance" } elseif ($headingLower -match '^(context|background|notes|references)\b') { $current = "context" } else { $current = "task" $taskLines.Add($line) } continue } switch ($current) { "acceptance" { $acceptanceLines.Add($line) } "context" { $contextLines.Add($line) } default { $taskLines.Add($line) } } } $taskText = (($taskLines -join "`n").Trim()) $acceptanceText = (($acceptanceLines -join "`n").Trim()) $contextText = (($contextLines -join "`n").Trim()) if ([string]::IsNullOrWhiteSpace($taskText)) { $taskText = $raw.Trim() } if ([string]::IsNullOrWhiteSpace($acceptanceText)) { $acceptanceText = Get-Content -Raw -Path (Join-Path (Get-RalphRoot) "templates\ACCEPTANCE.md") } if ([string]::IsNullOrWhiteSpace($contextText)) { $contextText = Get-Content -Raw -Path (Join-Path (Get-RalphRoot) "templates\CONTEXT.md") } if ([string]::IsNullOrWhiteSpace($title)) { $title = [IO.Path]::GetFileNameWithoutExtension($Path) } return [ordered]@{ Title = $title SourcePath = $Path RawContent = $raw Task = $taskText Acceptance = $acceptanceText Context = $contextText } } function Write-RalphTaskPackDirectory { param( [Parameter(Mandatory = $true)][hashtable]$TaskPack, [Parameter(Mandatory = $true)][string]$DestinationDirectory ) Ensure-Directory -Path $DestinationDirectory Write-Utf8File -Path (Join-Path $DestinationDirectory "TASK.md") -Content (($TaskPack.Task.Trim()) + "`n") Write-Utf8File -Path (Join-Path $DestinationDirectory "ACCEPTANCE.md") -Content (($TaskPack.Acceptance.Trim()) + "`n") Write-Utf8File -Path (Join-Path $DestinationDirectory "CONTEXT.md") -Content (($TaskPack.Context.Trim()) + "`n") if ($TaskPack.ContainsKey("SourcePath") -and (Test-Path $TaskPack.SourcePath)) { Copy-Item -Path $TaskPack.SourcePath -Destination (Join-Path $DestinationDirectory "SOURCE.md") -Force } } function Write-Utf8File { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content ) $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $encoding) } function Write-JsonFile { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)]$Object ) $json = $Object | ConvertTo-Json -Depth 100 Write-Utf8File -Path $Path -Content ($json + "`n") } function Get-RalphStateFile { param([Parameter(Mandatory = $true)][string]$Name) return Join-Path (Get-RalphRoot) ("state\" + $Name) } function Get-RalphTaskRoots { $ralphRoot = Get-RalphRoot return [ordered]@{ TasksRoot = Join-Path $ralphRoot "tasks" Inbox = Join-Path $ralphRoot "tasks\inbox" Processing = Join-Path $ralphRoot "tasks\processing" Completed = Join-Path $ralphRoot "tasks\completed" Failed = Join-Path $ralphRoot "tasks\failed" } } function Convert-ToRalphSlug { param([Parameter(Mandatory = $true)][string]$Text) $slug = [string]$Text $slug = $slug.Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($slug)) { return "task" } $slug = [regex]::Replace($slug, "[^a-z0-9]+", "-") $slug = $slug.Trim("-") if ([string]::IsNullOrWhiteSpace($slug)) { return "task" } if ($slug.Length -gt 48) { $slug = $slug.Substring(0, 48).Trim("-") } return $slug } function Get-RalphTaskTitleFromMarkdown { param( [string]$Markdown = "", [string]$Fallback = "task" ) $lines = @($Markdown -split "`r?`n") foreach ($line in $lines) { $trimmed = [string]$line $trimmed = $trimmed.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } if ($trimmed.StartsWith("#")) { return ($trimmed.TrimStart("#").Trim()) } return $trimmed } return $Fallback } function New-RalphTaskPackFromMarkdown { param( [Parameter(Mandatory = $true)][string]$MarkdownPath, [string]$TargetDirectory = "", [string]$TaskId = "", [string]$Title = "", [hashtable]$AdditionalMetadata = @{} ) if (-not (Test-Path $MarkdownPath)) { throw "Markdown source not found: $MarkdownPath" } $roots = Get-RalphTaskRoots foreach ($path in $roots.Values) { Ensure-Directory -Path $path } $parsedTaskPack = Convert-MarkdownToRalphTaskPack -Path $MarkdownPath $sourceText = [string]$parsedTaskPack.RawContent $sourceName = [System.IO.Path]::GetFileNameWithoutExtension($MarkdownPath) if ([string]::IsNullOrWhiteSpace($Title)) { $Title = [string]$parsedTaskPack.Title } if ([string]::IsNullOrWhiteSpace($TaskId)) { $TaskId = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmss"), (Convert-ToRalphSlug -Text $Title) } if ([string]::IsNullOrWhiteSpace($TargetDirectory)) { $TargetDirectory = Join-Path $roots.Inbox $TaskId } Ensure-Directory -Path $TargetDirectory $taskPath = Join-Path $TargetDirectory "TASK.md" $acceptancePath = Join-Path $TargetDirectory "ACCEPTANCE.md" $contextPath = Join-Path $TargetDirectory "CONTEXT.md" $metadataPath = Join-Path $TargetDirectory "submission.json" $sourceCopyPath = Join-Path $TargetDirectory "SOURCE.md" $acceptance = [string]$parsedTaskPack.Acceptance $context = [string]$parsedTaskPack.Context $taskBody = [string]$parsedTaskPack.Task Write-Utf8File -Path $taskPath -Content ($taskBody.Trim() + "`n") Write-Utf8File -Path $sourceCopyPath -Content ($sourceText.Trim() + "`n") Write-Utf8File -Path $acceptancePath -Content ($acceptance + "`n") Write-Utf8File -Path $contextPath -Content ($context + "`n") $metadata = [ordered]@{ id = $TaskId title = $Title submitted_at = (Get-Date).ToString("o") source_path = (Resolve-Path $MarkdownPath).Path task_directory = $TargetDirectory state = "queued" } foreach ($key in $AdditionalMetadata.Keys) { $metadata[$key] = $AdditionalMetadata[$key] } Write-JsonFile -Path $metadataPath -Object $metadata return [ordered]@{ id = $TaskId title = $Title task_directory = $TargetDirectory task_file = $taskPath acceptance_file = $acceptancePath context_file = $contextPath metadata_file = $metadataPath source_copy = $sourceCopyPath } } function Read-CodexReviewVerdict { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path $Path)) { throw "Codex review file not found: $Path" } $raw = (Get-Content -Raw -Path $Path).Trim() if ([string]::IsNullOrWhiteSpace($raw)) { throw "Codex review file is empty: $Path" } $jsonText = $raw if ($raw -match '(?s)```json\s*(\{.*?\})\s*```') { $jsonText = $matches[1] } elseif ($raw -match '(?s)(\{.*\})') { $jsonText = $matches[1] } try { $parsed = $jsonText | ConvertFrom-Json } catch { throw "Codex review output is not valid JSON: $Path" } $verdict = [string]$parsed.verdict if ([string]::IsNullOrWhiteSpace($verdict)) { throw "Codex review JSON missing 'verdict': $Path" } $normalizedVerdict = $verdict.Trim().ToLowerInvariant() if ($normalizedVerdict -notin @("pass", "needs_fix", "fail")) { throw "Codex review verdict '$verdict' is invalid. Expected: pass, needs_fix, fail." } $acceptancePassed = $false try { $acceptancePassed = [bool]$parsed.acceptance_passed } catch { $acceptancePassed = ($normalizedVerdict -eq "pass") } return [ordered]@{ verdict = $normalizedVerdict acceptance_passed = $acceptancePassed fix_required = [bool]$parsed.fix_required summary = [string]$parsed.summary next_sprint_needed = [bool]$parsed.next_sprint_needed next_sprint_brief = [string]$parsed.next_sprint_brief highest_risk_issues = @($parsed.highest_risk_issues) raw = $parsed } } function Add-RalphEvent { param( [Parameter(Mandatory = $true)][string]$RunId, [Parameter(Mandatory = $true)][string]$Stage, [Parameter(Mandatory = $true)][string]$Status, [Parameter(Mandatory = $true)][string]$Message, [string]$Actor = "system", [hashtable]$Data = @{} ) $eventsPath = Get-RalphStateFile -Name "events.jsonl" Ensure-Directory -Path (Split-Path -Parent $eventsPath) $event = [ordered]@{ timestamp = (Get-Date).ToString("o") run_id = $RunId actor = $Actor stage = $Stage status = $Status message = $Message data = $Data } $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::AppendAllText($eventsPath, (($event | ConvertTo-Json -Depth 20 -Compress) + "`n"), $encoding) } function Set-RalphCurrentRunState { param([Parameter(Mandatory = $true)][hashtable]$State) $statePath = Get-RalphStateFile -Name "current_run.json" Write-JsonFile -Path $statePath -Object $State } function New-PromptDocument { param( [Parameter(Mandatory = $true)][string]$TemplatePath, [Parameter(Mandatory = $true)][hashtable]$TaskPack, [Parameter(Mandatory = $true)][string]$OutputPath, [string[]]$ExtraSections = @() ) $template = Get-Content -Raw -Path $TemplatePath $sections = @( $template, "## TASK`n$($TaskPack.Task)", "## ACCEPTANCE`n$($TaskPack.Acceptance)", "## CONTEXT`n$($TaskPack.Context)" ) + $ExtraSections Write-Utf8File -Path $OutputPath -Content (($sections -join "`n`n").Trim() + "`n") }