param( [string]$Config = (Join-Path $PSScriptRoot "case.config.json"), [string]$Task = "", [string]$TaskFile = "", [int]$MaxRevisions = -1, [switch]$DryRun, [switch]$NoRetro ) Set-StrictMode -Version 2.0 $ErrorActionPreference = "Stop" function Get-CaseProperty { param( [Parameter(Mandatory = $true)]$Object, [Parameter(Mandatory = $true)][string]$Name, $Default = $null ) if ($null -eq $Object) { return $Default } $property = $Object.PSObject.Properties[$Name] if ($null -eq $property -or $null -eq $property.Value) { return $Default } return $property.Value } function Resolve-CasePath { param( [Parameter(Mandatory = $true)][string]$BaseDir, [Parameter(Mandatory = $true)][string]$Path ) if ([System.IO.Path]::IsPathRooted($Path)) { return $Path } return (Join-Path $BaseDir $Path) } function New-CaseRunId { return (Get-Date -Format "yyyyMMdd-HHmmss") } function Get-LogTail { param( [Parameter(Mandatory = $true)][string]$Path, [int]$Lines = 60 ) if (-not (Test-Path -LiteralPath $Path)) { return "" } return (Get-Content -LiteralPath $Path -Tail $Lines -ErrorAction SilentlyContinue) -join [Environment]::NewLine } function Set-CaseEnvironment { param([hashtable]$Values) $previous = @{} foreach ($key in $Values.Keys) { $previous[$key] = [Environment]::GetEnvironmentVariable($key, "Process") [Environment]::SetEnvironmentVariable($key, [string]$Values[$key], "Process") } return $previous } function Restore-CaseEnvironment { param([hashtable]$Previous) foreach ($key in $Previous.Keys) { [Environment]::SetEnvironmentVariable($key, $Previous[$key], "Process") } } function Get-CaseSha256 { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return "" } try { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } catch { return "" } } function Write-CaseFailureData { param( [Parameter(Mandatory = $true)][string]$RunId, [Parameter(Mandatory = $true)][string]$RunDir, [Parameter(Mandatory = $true)][string]$LedgerPath, [Parameter(Mandatory = $true)][string]$FailedPhase, [Parameter(Mandatory = $true)][int]$Revision, [Parameter(Mandatory = $true)][int]$RevisionBudget, [Parameter(Mandatory = $true)][int]$ExitCode, [Parameter(Mandatory = $true)][string]$LogPath ) $script:CaseFailureCounter++ $safePhase = $FailedPhase -replace "[^A-Za-z0-9_.-]", "_" $failurePath = Join-Path $RunDir ("failure-{0:D2}-{1}-r{2}.json" -f $script:CaseFailureCounter, $safePhase, $Revision) $tail = Get-LogTail -Path $LogPath $hash = Get-CaseSha256 -Path $LogPath $record = [pscustomobject]@{ runId = $RunId occurredAt = (Get-Date).ToString("o") failedPhase = $FailedPhase revision = $Revision revisionBudget = $RevisionBudget exitCode = $ExitCode logPath = $LogPath logSha256 = $hash logTail = $tail nextRunHint = "Read the failed phase, log hash, and tail before retrying. Promote recurring causes to gotchas.md." } $record | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $failurePath -Encoding UTF8 $ledgerEntry = [pscustomobject]@{ runId = $RunId occurredAt = $record.occurredAt failedPhase = $FailedPhase revision = $Revision revisionBudget = $RevisionBudget exitCode = $ExitCode logSha256 = $hash failurePath = $failurePath } | ConvertTo-Json -Depth 6 -Compress Add-Content -LiteralPath $LedgerPath -Value $ledgerEntry -Encoding UTF8 return $failurePath } function Write-CaseTraceEvent { param( [Parameter(Mandatory = $true)][string]$BaseDir, [Parameter(Mandatory = $true)][string]$RunDir, [Parameter(Mandatory = $true)][string]$RunId, [Parameter(Mandatory = $true)]$Result ) $tracePath = Join-Path $RunDir "decision-trace.jsonl" $eventObject = [pscustomobject]@{ runId = $RunId timestamp = (Get-Date).ToString("o") phase = $Result.Name revision = $Result.Revision exitCode = $Result.ExitCode startedAt = $Result.StartedAt completedAt = $Result.CompletedAt durationMs = $Result.DurationMs logPath = $Result.LogPath eventType = "phase_completed" } $event = $eventObject | ConvertTo-Json -Compress -Depth 6 Add-Content -LiteralPath $tracePath -Value $event -Encoding UTF8 $appendEvent = Join-Path $BaseDir "ledger/append-event.ps1" if (Test-Path -LiteralPath $appendEvent) { & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $appendEvent ` -RunDir $RunDir ` -RunId $RunId ` -EventType "phase_completed" ` -Phase $Result.Name ` -ObjectPath $Result.LogPath ` -PayloadJson $event | Out-Null if ($LASTEXITCODE -ne 0) { throw "Ledger append failed for phase '$($Result.Name)'." } } } function Invoke-CasePhase { param( [Parameter(Mandatory = $true)]$Phase, [Parameter(Mandatory = $true)][string]$BaseDir, [Parameter(Mandatory = $true)][string]$ConfigPath, [Parameter(Mandatory = $true)][string]$RunDir, [Parameter(Mandatory = $true)][string]$MemoryDir, [Parameter(Mandatory = $true)][string]$FailureLedger, [Parameter(Mandatory = $true)][string]$RunId, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$TaskText, [Parameter(Mandatory = $true)][int]$Revision, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$FeedbackPath, [Parameter(Mandatory = $true)][bool]$DryRunMode, [Parameter(Mandatory = $true)][string]$Outcome ) $phaseName = [string](Get-CaseProperty $Phase "name" "") $label = [string](Get-CaseProperty $Phase "label" $phaseName) $command = [string](Get-CaseProperty $Phase "command" "") $argsValue = Get-CaseProperty $Phase "args" @() $args = @() foreach ($arg in @($argsValue)) { $args += [string]$arg } if ([string]::IsNullOrWhiteSpace($phaseName)) { throw "A phase is missing 'name'." } if ([string]::IsNullOrWhiteSpace($command)) { throw "Phase '$phaseName' is missing 'command'." } $safeName = $phaseName -replace "[^A-Za-z0-9_.-]", "_" $logPath = Join-Path $RunDir ("{0:D2}-{1}-r{2}.log" -f $script:CasePhaseCounter, $safeName, $Revision) $script:CasePhaseCounter++ Write-Host ("[{0}] {1}" -f $phaseName, $label) $phaseStartedAt = Get-Date $envValues = @{ CASE_RUN_ID = $RunId CASE_CONFIG_PATH = $ConfigPath CASE_BASE_DIR = $BaseDir CASE_RUN_DIR = $RunDir CASE_MEMORY_DIR = $MemoryDir CASE_FAILURE_LEDGER = $FailureLedger CASE_PHASE = $phaseName CASE_TASK = $TaskText CASE_REVISION = $Revision CASE_FEEDBACK_PATH = $FeedbackPath CASE_OUTCOME = $Outcome } if ($DryRunMode) { $line = "DRY RUN: " + $command + " " + ($args -join " ") Set-Content -LiteralPath $logPath -Value $line -Encoding UTF8 Write-Host " gate: dry-run pass" $phaseCompletedAt = Get-Date return [pscustomobject]@{ Name = $phaseName Revision = $Revision ExitCode = 0 LogPath = $logPath Skipped = $true StartedAt = $phaseStartedAt.ToString("o") CompletedAt = $phaseCompletedAt.ToString("o") DurationMs = [Math]::Round(($phaseCompletedAt - $phaseStartedAt).TotalMilliseconds, 0) } } $previousEnv = Set-CaseEnvironment -Values $envValues Push-Location $BaseDir try { & $command @args *> $logPath $exitCode = 0 if ($null -ne $global:LASTEXITCODE) { $exitCode = [int]$global:LASTEXITCODE } elseif (-not $?) { $exitCode = 1 } } catch { $exitCode = 1 Add-Content -LiteralPath $logPath -Value ("ERROR: " + $_.Exception.Message) -Encoding UTF8 } finally { Pop-Location Restore-CaseEnvironment -Previous $previousEnv } if ($exitCode -eq 0) { Write-Host " gate: pass" } else { Write-Host (" gate: fail (exit {0})" -f $exitCode) } $phaseCompletedAt = Get-Date return [pscustomobject]@{ Name = $phaseName Revision = $Revision ExitCode = $exitCode LogPath = $logPath Skipped = $false StartedAt = $phaseStartedAt.ToString("o") CompletedAt = $phaseCompletedAt.ToString("o") DurationMs = [Math]::Round(($phaseCompletedAt - $phaseStartedAt).TotalMilliseconds, 0) } } function New-CaseMetrics { param( [Parameter(Mandatory = $true)]$Events, [Parameter(Mandatory = $true)][int]$RevisionBudget ) $eventList = @($Events) $total = $eventList.Count $passed = @($eventList | Where-Object { $_.exitCode -eq 0 }).Count $failed = $total - $passed $passRate = 0 if ($total -gt 0) { $passRate = [Math]::Round($passed / $total, 4) } return [pscustomobject]@{ totalEvents = $total passedEvents = $passed failedEvents = $failed passRate = $passRate revisionBudget = $RevisionBudget } } function Write-CaseSummary { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$RunId, [Parameter(Mandatory = $true)][datetime]$StartedAt, [Parameter(Mandatory = $true)][string]$Outcome, [Parameter(Mandatory = $true)][int]$Revisions, [Parameter(Mandatory = $true)][int]$RevisionBudget, [Parameter(Mandatory = $true)]$Events ) $completedAt = Get-Date [pscustomobject]@{ runId = $RunId startedAt = $StartedAt.ToString("o") completedAt = $completedAt.ToString("o") durationSeconds = [Math]::Round(($completedAt - $StartedAt).TotalSeconds, 3) outcome = $Outcome revisions = $Revisions metrics = New-CaseMetrics -Events $Events -RevisionBudget $RevisionBudget events = $Events } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8 } function Invoke-CaseFinalLedgerCheck { param( [Parameter(Mandatory = $true)][string]$BaseDir, [Parameter(Mandatory = $true)][string]$RunDir ) $ledgerVerifier = Join-Path $BaseDir "ledger/verify-stream.ps1" if (-not (Test-Path -LiteralPath $ledgerVerifier)) { return } $finalLedgerCheck = Join-Path $RunDir "final-ledger-check.json" & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $ledgerVerifier -RunDir $RunDir -Output $finalLedgerCheck | Out-Null if ($LASTEXITCODE -ne 0) { throw "Final ledger verification failed." } } if (-not (Test-Path -LiteralPath $Config)) { throw "Config file not found: $Config" } $configPath = (Resolve-Path -LiteralPath $Config).Path $baseDir = Split-Path -Parent $configPath $configData = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json $taskText = $Task if (-not [string]::IsNullOrWhiteSpace($TaskFile)) { if (-not (Test-Path -LiteralPath $TaskFile)) { throw "Task file not found: $TaskFile" } $taskFromFile = Get-Content -LiteralPath $TaskFile -Raw if ([string]::IsNullOrWhiteSpace($taskText)) { $taskText = $taskFromFile } else { $taskText = $taskText + [Environment]::NewLine + [Environment]::NewLine + $taskFromFile } } $runId = New-CaseRunId $logsDirValue = [string](Get-CaseProperty $configData "logsDir" ".case/runs") $logsDir = Resolve-CasePath -BaseDir $baseDir -Path $logsDirValue $runDir = Join-Path $logsDir $runId New-Item -ItemType Directory -Force -Path $runDir | Out-Null $memoryDirValue = [string](Get-CaseProperty $configData "memoryDir" ".case/memory") $memoryDir = Resolve-CasePath -BaseDir $baseDir -Path $memoryDirValue New-Item -ItemType Directory -Force -Path $memoryDir | Out-Null $failureLedger = Join-Path $memoryDir "failures.jsonl" if (-not (Test-Path -LiteralPath $failureLedger)) { New-Item -ItemType File -Path $failureLedger | Out-Null } $configuredMax = [int](Get-CaseProperty $configData "maxRevisionCycles" 2) if ($MaxRevisions -ge 0) { $configuredMax = $MaxRevisions } $revisionPhases = @() foreach ($phaseName in @(Get-CaseProperty $configData "revisionOnFailurePhases" @("implement", "verify", "review"))) { $revisionPhases += [string]$phaseName } $phases = @() foreach ($phase in @(Get-CaseProperty $configData "phases" @())) { $phases += $phase } if ($phases.Count -eq 0) { throw "Config must include at least one phase." } $phaseNames = @() foreach ($phase in $phases) { $phaseNames += [string](Get-CaseProperty $phase "name" "") } $implementIndex = [Array]::IndexOf($phaseNames, "implement") $retroIndex = [Array]::IndexOf($phaseNames, "retro") if ($implementIndex -lt 0) { throw "Config must include an 'implement' phase so failures can loop back." } $taskFile = Join-Path $runDir "task.txt" Set-Content -LiteralPath $taskFile -Value $taskText -Encoding UTF8 $summaryPath = Join-Path $runDir "summary.json" $events = @() $revision = 0 $phaseIndex = 0 $feedbackPath = "" $outcome = "running" $startedAt = Get-Date $script:CasePhaseCounter = 1 $script:CaseFailureCounter = 0 Write-Host ("Case run: {0}" -f $runId) Write-Host ("Run dir : {0}" -f $runDir) Write-Host ("Memory : {0}" -f $memoryDir) Write-Host ("Max revisions: {0}" -f $configuredMax) while ($phaseIndex -lt $phases.Count) { $phase = $phases[$phaseIndex] $phaseName = [string](Get-CaseProperty $phase "name" "") if ($phaseName -eq "retro" -and $NoRetro) { $phaseIndex++ continue } $result = Invoke-CasePhase ` -Phase $phase ` -BaseDir $baseDir ` -ConfigPath $configPath ` -RunDir $runDir ` -MemoryDir $memoryDir ` -FailureLedger $failureLedger ` -RunId $runId ` -TaskText $taskText ` -Revision $revision ` -FeedbackPath $feedbackPath ` -DryRunMode ([bool]$DryRun) ` -Outcome $outcome $events += [pscustomobject]@{ phase = $result.Name revision = $revision exitCode = $result.ExitCode durationMs = $result.DurationMs logPath = $result.LogPath } Write-CaseTraceEvent -BaseDir $baseDir -RunDir $runDir -RunId $runId -Result $result if ($result.ExitCode -eq 0) { $phaseIndex++ continue } $failureDataPath = Write-CaseFailureData ` -RunId $runId ` -RunDir $runDir ` -LedgerPath $failureLedger ` -FailedPhase $phaseName ` -Revision $revision ` -RevisionBudget $configuredMax ` -ExitCode $result.ExitCode ` -LogPath $result.LogPath Write-Host (" failure data: {0}" -f $failureDataPath) $canRevise = $revisionPhases -contains $phaseName if ($canRevise -and $revision -lt $configuredMax) { $feedbackPath = Join-Path $runDir ("feedback-r{0}.md" -f ($revision + 1)) $tail = Get-LogTail -Path $result.LogPath $feedback = @( "# Revision feedback", "", "Failed phase: $phaseName", "Failed log: $($result.LogPath)", "Failure data: $failureDataPath", "Revision cycle: $($revision + 1) of $configuredMax", "", "## Log tail", "", '```text', $tail, '```' ) -join [Environment]::NewLine Set-Content -LiteralPath $feedbackPath -Value $feedback -Encoding UTF8 $revision++ Write-Host (" revision loop: returning to implement with {0}" -f $feedbackPath) $phaseIndex = $implementIndex continue } $outcome = "failed" Write-Host "Case outcome: failed" if (-not $NoRetro -and $retroIndex -ge 0 -and $phaseName -ne "retro") { $retroPhase = $phases[$retroIndex] $retroResult = Invoke-CasePhase ` -Phase $retroPhase ` -BaseDir $baseDir ` -ConfigPath $configPath ` -RunDir $runDir ` -MemoryDir $memoryDir ` -FailureLedger $failureLedger ` -RunId $runId ` -TaskText $taskText ` -Revision $revision ` -FeedbackPath $feedbackPath ` -DryRunMode ([bool]$DryRun) ` -Outcome $outcome $events += [pscustomobject]@{ phase = $retroResult.Name revision = $revision exitCode = $retroResult.ExitCode durationMs = $retroResult.DurationMs logPath = $retroResult.LogPath } Write-CaseTraceEvent -BaseDir $baseDir -RunDir $runDir -RunId $runId -Result $retroResult } Write-CaseSummary ` -Path $summaryPath ` -RunId $runId ` -StartedAt $startedAt ` -Outcome $outcome ` -Revisions $revision ` -RevisionBudget $configuredMax ` -Events $events exit $result.ExitCode } $outcome = "passed" Invoke-CaseFinalLedgerCheck -BaseDir $baseDir -RunDir $runDir Write-CaseSummary ` -Path $summaryPath ` -RunId $runId ` -StartedAt $startedAt ` -Outcome $outcome ` -Revisions $revision ` -RevisionBudget $configuredMax ` -Events $events # Digital twin: observe this run's real measured cost, predict out-of-sample, # record the error, and re-fit. Calibration is earned from real runs over time. $twinScript = Join-Path $baseDir "verification/digital-twin.js" if (Test-Path -LiteralPath $twinScript) { & node $twinScript observe --run-dir $runDir | Out-Null } Write-Host "Case outcome: passed" Write-Host ("Summary: {0}" -f $summaryPath) exit 0