<# .SYNOPSIS Ralph Wiggum Loop - PRD-driven variant. .DESCRIPTION Iterates through user stories in prd.json, spawning a fresh `claude --print` invocation for each story. Memory persists via filesystem only: git commits, prd.json (passes field), and progress.txt. Each iteration works on ONE user story (in priority order). When all stories pass, the loop completes. Circuit breakers prevent runaway costs: - No git changes for N consecutive iterations (stalled) - Same error repeated N consecutive iterations (stuck) .PARAMETER Model Initial Claude model to use. Default: "opus". The agent can dynamically switch models between iterations via opus|sonnet signals. .PARAMETER MaxNoProgress Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3. .PARAMETER MaxSameError Number of consecutive iterations with the same error before circuit breaker trips. Default: 3. .PARAMETER StartFrom Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed. .EXAMPLE .\.claude\skills\ralph\ralph.ps1 -Model "opus" .EXAMPLE .\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet" #> param( [string]$Model = "opus", [int]$MaxNoProgress = 3, [int]$MaxSameError = 3, [string]$StartFrom = "" ) $ErrorActionPreference = "Stop" $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $prdFile = Join-Path $scriptDir "prd.json" $progressFile = Join-Path $scriptDir "progress.txt" $logDir = Join-Path $scriptDir "logs" # --- Find project root (git repo root) --- $projectRoot = git rev-parse --show-toplevel 2>$null if (-not $projectRoot) { Write-Error "Not inside a git repository. Run from the project directory." exit 1 } $projectRoot = (Resolve-Path $projectRoot).Path # --- Validation --- if (-not (Test-Path $prdFile)) { Write-Error "prd.json not found at $prdFile" exit 1 } # Ensure logs directory exists if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null Write-Host "Created logs directory" } # --- PRD Read/Write --- function Read-Prd { Get-Content -Path $prdFile -Raw | ConvertFrom-Json } function Save-Prd { param($prdObj) $prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8 } $prd = Read-Prd # --- Git Setup --- $BranchName = $prd.branchName if ($BranchName) { $currentBranch = git branch --show-current if ($currentBranch -ne $BranchName) { $branchExists = git branch --list $BranchName if ($branchExists) { Write-Host "Switching to existing branch: $BranchName" git checkout $BranchName } else { Write-Host "Creating branch: $BranchName" git checkout -b $BranchName } } } # --- Handle StartFrom: mark earlier stories as passed --- if ($StartFrom) { $startPriority = [int]($StartFrom -replace 'US-0*', '') $skippedCount = 0 foreach ($story in $prd.userStories) { $storyPriority = [int]($story.id -replace 'US-0*', '') if ($storyPriority -lt $startPriority -and $story.passes -ne $true) { $story.passes = $true $story.notes = "Skipped (--StartFrom $StartFrom)" $skippedCount++ } } if ($skippedCount -gt 0) { Save-Prd $prd Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow } } # --- Circuit Breaker State --- $noProgressCount = 0 $lastErrorSignature = "" $sameErrorCount = 0 # --- Prompt Generation --- function Build-StoryPrompt { param( $story, $prdObj, [array]$completedStories ) # Build completed list $completedSection = "" if ($completedStories.Count -gt 0) { $completedLines = ($completedStories | ForEach-Object { "- $($_.id): $($_.title)" }) -join "`n" $completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n" } # Build criteria list $criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n" # Build prompt using array-join (avoids PS 5.1 here-string indentation issues) $sid = $story.id $stitle = $story.title $sdesc = $story.description $pdesc = $prdObj.description $prompt = @( "# Ralph Iteration: $sid - $stitle" "" "## Project" "$pdesc" "" "Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work." "" "## Your Task" "" "**${sid}: $stitle**" "" "$sdesc" "" "## Acceptance Criteria" "" "$criteriaLines" "" "## Reference Documents" "" "Read these as needed for implementation detail:" "" "- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)" "- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models" "- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns" "- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)" "$completedSection" "## Workflow" "" "1. Read CLAUDE.md to understand project conventions" "2. Read Ralph/depth-design.md sections relevant to this story" "3. Read existing source files you will modify to understand current patterns" "4. Implement ALL acceptance criteria" "5. Run npm run typecheck - fix any type errors" "6. Run npm run build - fix any build errors" "7. Stage and commit your changes:" " git add [specific files] && git commit -m `"${sid}: [descriptive message]`"" "8. When ALL criteria are met, output: $sid" "" "## Rules" "" "- Work ONLY on $sid. Do not modify code for other stories." "- Read files before modifying them." "- Follow existing patterns and conventions in the codebase." "- Use lucide-react for icons, never unicode symbols." "- Use the project's CSS custom properties and Tailwind tokens." "- Commit specific files, not git add -A." "- Do NOT start a dev server (npm run dev). One is already running on port $devServerPort. Do NOT run any background tasks." "- If genuinely blocked, output $sid with explanation." "- To recommend a different model for the NEXT iteration, output opus or sonnet." ) -join "`n" return $prompt } # --- Banner --- $completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count $totalCount = $prd.userStories.Count Write-Host "" Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan Write-Host "Project: $($prd.project)" -ForegroundColor Cyan Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan Write-Host "===========================================" -ForegroundColor Cyan Write-Host "" # Dev server port (assumed to be running externally) $devServerPort = 5173 Write-Host "Dev server assumed running on port $devServerPort" -ForegroundColor DarkGray Write-Host "" # --- Story Loop --- $iterationCount = 0 $originalDir = Get-Location Set-Location $projectRoot try { while ($true) { # Re-read PRD each iteration (in case previous iteration updated it) $prd = Read-Prd # Partition stories $completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true }) $pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority }) # Check if all done if ($pendingStories.Count -eq 0) { Write-Host "" Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green Write-Host "Branch: $BranchName" -ForegroundColor Green break } $currentStory = $pendingStories[0] $iterationCount++ $pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100) $storyLabel = "$($currentStory.id): $($currentStory.title)" $pctStr = "${pctComplete}%" $progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)" Write-Host "" Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow Write-Host $progressMsg -ForegroundColor DarkGray # Record HEAD before this iteration $headBefore = git rev-parse HEAD 2>$null $iterStart = Get-Date Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray Write-Host "" # Generate prompt for this story $promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories # --- Spawn Claude --- $logFile = Join-Path $logDir "$($currentStory.id).log" $rawLogFile = Join-Path $logDir "$($currentStory.id).raw.jsonl" $maxRetries = 10 $retryCount = 0 $outputString = "" $apiOverloaded = $false do { $apiOverloaded = $false $textBuilder = [System.Text.StringBuilder]::new() $toolCount = 0 # Clear raw log file for this attempt if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force } if ($retryCount -gt 0) { $backoffSeconds = [Math]::Pow(2, $retryCount - 1) Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow Start-Sleep -Seconds $backoffSeconds Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray } # --- Spawn Claude via Process.Start for clean shutdown control --- # Using Process.Start instead of pipeline so we can break on the result # event and force-kill the process tree. The pipeline approach hangs when # Claude spawns background tasks (e.g. npm run dev) that keep stdout open. $promptTempFile = Join-Path $logDir "$($currentStory.id).prompt.tmp" $promptContent | Set-Content -Path $promptTempFile -Encoding UTF8 $claudeArgs = "--print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json" $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.FileName = "cmd.exe" $psi.Arguments = "/c type `"$promptTempFile`" | claude $claudeArgs" $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $psi.WorkingDirectory = $projectRoot $claudeProc = [System.Diagnostics.Process]::Start($psi) # Drain stderr async to prevent buffer deadlock $claudeProc.add_ErrorDataReceived({ param($s,$e) }) $claudeProc.BeginErrorReadLine() try { while ($null -ne ($line = $claudeProc.StandardOutput.ReadLine())) { $line = $line.Trim() if (-not $line) { continue } # Save raw event for debugging try { Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch { } $isResultEvent = $false try { $evt = $line | ConvertFrom-Json -ErrorAction Stop # --- Tool use start --- if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') { $toolCount++ $toolName = $evt.content_block.name Write-Host " [$toolName]" -ForegroundColor DarkCyan } # --- Streaming text --- elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) { Write-Host -NoNewline $evt.delta.text [void]$textBuilder.Append($evt.delta.text) } # --- Result event (terminal — stop reading after this) --- elseif ($evt.type -eq 'result') { if ($evt.subtype -eq 'error_result' -and $evt.error) { Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red [void]$textBuilder.AppendLine("ERROR: $($evt.error)") } elseif ($evt.result) { [void]$textBuilder.AppendLine($evt.result) } $isResultEvent = $true } # --- Message-level content --- elseif ($evt.message -and $evt.message.content) { foreach ($block in $evt.message.content) { if ($block.type -eq 'text' -and $block.text) { Write-Host $block.text [void]$textBuilder.AppendLine($block.text) } elseif ($block.type -eq 'tool_use') { $toolCount++ Write-Host " [$($block.name)]" -ForegroundColor DarkCyan } } } } catch { if ($line -and $line -notmatch '^\s*["\{\[\}\]]') { Write-Host $line -ForegroundColor DarkYellow [void]$textBuilder.AppendLine($line) } } # Result is always the final stream event — stop reading if ($isResultEvent) { break } } } finally { # Kill the Claude process tree to prevent orphaned cmd.exe/node processes if ($claudeProc -and -not $claudeProc.HasExited) { try { taskkill /T /F /PID $claudeProc.Id 2>$null | Out-Null } catch { } } Remove-Item -Path $promptTempFile -ErrorAction SilentlyContinue } $outputString = $textBuilder.ToString() # Check for 529 overloaded error if ($outputString -match "529.*overloaded|overloaded_error") { $apiOverloaded = $true $retryCount++ if ($retryCount -ge $maxRetries) { Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red } } # Check for usage limit with cooldown elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") { $resetHour = [int]$Matches[1] $resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 } $resetAmPm = $Matches[3] if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 } elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 } $now = Get-Date $resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0 if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) } $resetTime = $resetTime.AddMinutes(2) $waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds) $waitMinutes = [Math]::Ceiling($waitSeconds / 60) Write-Host "" Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow Start-Sleep -Seconds $waitSeconds Write-Host " [USAGE LIMIT] Cooldown complete. Retrying..." -ForegroundColor Green $apiOverloaded = $true } } while ($apiOverloaded -and $retryCount -lt $maxRetries) # Save log $outputString | Set-Content -Path $logFile -Encoding UTF8 # Show elapsed time $elapsed = (Get-Date) - $iterStart Write-Host "" Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray # --- Detect signals --- $storyComplete = $outputString -match "$([regex]::Escape($currentStory.id))" $storyBlocked = $outputString -match "$([regex]::Escape($currentStory.id))" $headAfter = git rev-parse HEAD 2>$null $hasGitChanges = $headAfter -ne $headBefore # --- Update story status --- if ($storyComplete) { # Mark story as passed in prd.json $prd = Read-Prd $storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id } if ($storyToUpdate) { $alreadyDone = if (-not $hasGitChanges) { " (already committed)" } else { "" } $storyToUpdate.passes = $true $storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model.$alreadyDone" } Save-Prd $prd # Append to progress.txt $ts = Get-Date -Format 'yyyy-MM-dd HH:mm' $el = $elapsed.ToString('mm\:ss') $tag = if ($hasGitChanges) { "PASS" } else { "PASS (no new commits)" } $progressEntry = "$ts | $tag | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount" Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8 Write-Host " [PASSED] $storyLabel" -ForegroundColor Green if (-not $hasGitChanges) { Write-Host " (Work was already committed)" -ForegroundColor DarkGray } $noProgressCount = 0 $sameErrorCount = 0 $lastErrorSignature = "" } elseif ($storyBlocked) { $ts = Get-Date -Format 'yyyy-MM-dd HH:mm' $progressEntry = "$ts | BLOCKED | $storyLabel" Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8 Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red # Blocked counts as no progress $noProgressCount++ } else { # No completion signal if ($hasGitChanges) { Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow $ts = Get-Date -Format 'yyyy-MM-dd HH:mm' $progressEntry = "$ts | PARTIAL | $storyLabel" Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8 $noProgressCount = 0 } else { Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow $noProgressCount++ } } # --- Circuit Breaker: No Progress --- if ($noProgressCount -ge $MaxNoProgress) { Write-Host "" Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red Write-Host "Stuck on: $($currentStory.id) - $($currentStory.title)" -ForegroundColor Red Write-Host "Check $logFile for details." -ForegroundColor Red break } # --- Circuit Breaker: Repeated Error --- $errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches if ($errorLines) { $filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3 $currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|" if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) { $sameErrorCount++ Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow if ($sameErrorCount -ge $MaxSameError) { Write-Host "" Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red Write-Host "Same error for $MaxSameError consecutive iterations:" -ForegroundColor Red Write-Host " $currentErrorSignature" -ForegroundColor Red break } } elseif ($currentErrorSignature) { $sameErrorCount = 0 } $lastErrorSignature = $currentErrorSignature } else { $sameErrorCount = 0 $lastErrorSignature = "" } # --- Dynamic Model Selection --- if ($outputString -match "(opus|sonnet)") { $nextModel = $Matches[1] if ($nextModel -ne $Model) { Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta $Model = $nextModel } } # Brief pause between iterations Start-Sleep -Seconds 2 } } finally { Set-Location $originalDir } # --- Final Summary --- $prd = Read-Prd $finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count $finalTotal = $prd.userStories.Count Write-Host "" Write-Host "===========================================" -ForegroundColor Cyan Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan Write-Host " Branch: $BranchName" -ForegroundColor Cyan Write-Host " Logs: $logDir" -ForegroundColor Cyan Write-Host "===========================================" -ForegroundColor Cyan if ($finalPassed -eq $finalTotal) { exit 0 } else { exit 1 }