dev-loop.psm1
|
# dev-loop.psm1 — Invoke-DevLoop: manifest-driven Plan → Build → Review → Test loop $script:ModuleRoot = $PSScriptRoot function Invoke-DevLoop { <# .SYNOPSIS Automated development loop powered by GitHub Copilot CLI. .DESCRIPTION Processes numbered spec files through plan, build, review, and test phases using GitHub Copilot CLI agents. Each phase shells out to copilot with a crafted prompt. Specs are processed one at a time, all phases to completion. .PARAMETER SpecsDir Path to the directory containing numbered spec files (NN-slug.md) and optional CONSTITUTION.md. .PARAMETER ProjectDir Path to the target project directory. Must be a git repository. .PARAMETER GitPush If specified, git push is performed after each build and review phase. .EXAMPLE Invoke-DevLoop -SpecsDir ./specs -ProjectDir ~/my-project .EXAMPLE Invoke-DevLoop -SpecsDir ./specs -ProjectDir ~/my-project -GitPush #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$SpecsDir, [Parameter(Mandatory)] [string]$ProjectDir, [switch]$GitPush ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 $SpecsDir = (Resolve-Path $SpecsDir).Path $ProjectDir = (Resolve-Path $ProjectDir).Path # ── Validate ProjectDir is a git repository ─────────────────────── if (-not (Test-Path (Join-Path $ProjectDir '.git'))) { throw "ProjectDir '$ProjectDir' is not a git repository. Please run 'git init' first." } # ── Validate git remote exists when -GitPush is requested ───────── if ($GitPush) { $remotes = git -C $ProjectDir remote 2>$null if (-not $remotes) { throw "-GitPush was specified but no git remote is configured in '$ProjectDir'. Add a remote first (e.g., git remote add origin <url>)." } } Push-Location $script:ModuleRoot try { # ── Tracking directory setup ────────────────────────────────────── $trackingRoot = Join-Path $ProjectDir '.dev-loop' if (-not (Test-Path $trackingRoot)) { New-Item -ItemType Directory -Path $trackingRoot | Out-Null Write-Host "Created tracking directory: $trackingRoot" -ForegroundColor DarkGray } # ── Ensure .dev-loop/ is in .gitignore before any commits ───────── $gitignorePath = Join-Path $ProjectDir '.gitignore' $devLoopPattern = '.dev-loop/' $needsEntry = $true if (Test-Path $gitignorePath) { $lines = Get-Content $gitignorePath if ($lines -contains $devLoopPattern) { $needsEntry = $false } } if ($needsEntry) { Add-Content -Path $gitignorePath -Value "`n$devLoopPattern" Write-Host "Added .dev-loop/ to .gitignore" -ForegroundColor DarkGray } # Derive a timestamp for this run and create the run directory $RunTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $runDir = Join-Path $trackingRoot $RunTimestamp New-Item -ItemType Directory -Path $runDir | Out-Null Write-Host "Run directory: $runDir" -ForegroundColor DarkGray # ── Logging setup ───────────────────────────────────────────────── $logFile = Join-Path $runDir 'dev-loop.log' function Log { param([string]$Message, [string]$Color = 'White') Write-Host $Message -ForegroundColor $Color "$(Get-Date -Format 'HH:mm:ss') $Message" | Out-File -FilePath $logFile -Append } Log "Logging to: $logFile" DarkGray # ── Manifest helpers ────────────────────────────────────────────── $manifestFile = Join-Path $runDir 'manifest.json' function Read-Manifest { Get-Content $manifestFile -Raw | ConvertFrom-Json } function Save-Manifest($m) { $m | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestFile -Encoding UTF8 } function Start-Phase($specName, $phaseName) { $m = Read-Manifest $spec = $m.specs | Where-Object { $_.name -eq $specName } $spec.phases.$phaseName.started = (Get-Date -Format 'o') Save-Manifest $m Log " Started $phaseName for $specName" DarkYellow } function Stamp-Phase($specName, $phaseName) { $m = Read-Manifest $spec = $m.specs | Where-Object { $_.name -eq $specName } $spec.phases.$phaseName.completed = (Get-Date -Format 'o') Save-Manifest $m Log " Stamped $phaseName for $specName" Green } # ── Pre-flight (discovers specs, constitution check) ──────────── $preflightLog = Join-Path $runDir 'preflight.log' Log "Preflight log: $preflightLog" DarkGray & "$script:ModuleRoot\agents\preflight.ps1" -SpecsDir $SpecsDir -ProjectDir $ProjectDir -RunDir $runDir -LogFile $preflightLog if ($LASTEXITCODE -ne 0) { throw "Preflight failed." } # ── Build manifest from preflight discovery ─────────────────────── $discoveryFile = Join-Path $runDir 'spec-discovery.json' if (-not (Test-Path $discoveryFile)) { throw "No spec-discovery.json found after preflight — cannot continue." } $discovered = Get-Content $discoveryFile -Raw | ConvertFrom-Json $phaseNames = @('plan', 'plan-eval', 'build', 'review', 'test') $specs = @() foreach ($d in $discovered) { $phases = [ordered]@{} foreach ($p in $phaseNames) { $phases[$p] = [ordered]@{ started = $null; completed = $null } } $specs += @{ name = $d.name file = $d.file phases = $phases } } $manifest = @{ runId = (Split-Path $runDir -Leaf) specsDir = $SpecsDir phases = $phaseNames specs = $specs } Save-Manifest $manifest Log "Manifest written to: $manifestFile" Green # ── Manifest-driven spec loop ───────────────────────────────────── $manifest = Read-Manifest $phaseOrder = @('plan', 'plan-eval', 'build', 'review', 'test') Log "========== STARTING SPEC LOOP ($($manifest.specs.Count) spec(s)) ==========" Cyan foreach ($spec in $manifest.specs) { $specName = $spec.name $specFile = $spec.file Log "────────── SPEC: $specName ──────────" Cyan $specLogFile = Join-Path $runDir "$specName.log" Log "Spec log: $specLogFile" DarkGray foreach ($phase in $phaseOrder) { # Skip phases that are already completed if ($spec.phases.$phase.completed) { Log " [$phase] already completed at $($spec.phases.$phase.completed) — skipping" DarkGreen continue } Log " [$phase] starting..." Yellow switch ($phase) { 'plan' { Start-Phase $specName 'plan' & "$script:ModuleRoot\agents\plan.ps1" -SpecFile $specFile -ProjectDir $ProjectDir -RunDir $runDir -LogFile $specLogFile if ($LASTEXITCODE -ne 0) { throw "PLAN FAILED for $specName" } Stamp-Phase $specName 'plan' } 'plan-eval' { Start-Phase $specName 'plan-eval' & "$script:ModuleRoot\agents\plan-eval.ps1" -SpecFile $specFile -ProjectDir $ProjectDir -RunDir $runDir -LogFile $specLogFile if ($LASTEXITCODE -ne 0) { throw "PLAN-EVAL FAILED for $specName" } Stamp-Phase $specName 'plan-eval' } 'build' { Start-Phase $specName 'build' $planFile = Join-Path $runDir "plan-$specName.md" $buildIteration = 0 while ($true) { $planContent = Get-Content $planFile -Raw if ($planContent -notmatch '- \[ \]') { Log " All plan tasks complete for $specName" Green break } $buildIteration++ Log " [build] iteration $buildIteration — unchecked tasks remain" Yellow & "$script:ModuleRoot\agents\build.ps1" -SpecFile $specFile -ProjectDir $ProjectDir -RunDir $runDir -LogFile $specLogFile -GitPush:$GitPush if ($LASTEXITCODE -ne 0) { throw "BUILD FAILED for $specName (iteration $buildIteration)" } } Stamp-Phase $specName 'build' } 'review' { Start-Phase $specName 'review' & "$script:ModuleRoot\agents\review.ps1" -SpecFile $specFile -ProjectDir $ProjectDir -RunDir $runDir -LogFile $specLogFile -GitPush:$GitPush if ($LASTEXITCODE -ne 0) { throw "REVIEW FAILED for $specName" } Stamp-Phase $specName 'review' } 'test' { # DISABLED — uncomment to re-enable test phase # Start-Phase $specName 'test' # & "$script:ModuleRoot\agents\test.ps1" -SpecFile $specFile -ProjectDir $ProjectDir -RunDir $runDir -LogFile $specLogFile -GitPush:$GitPush # if ($LASTEXITCODE -ne 0) { throw "TEST FAILED for $specName" } # Stamp-Phase $specName 'test' Log " [test] SKIPPED (disabled)" DarkGray } } } Log " All phases complete for $specName" Green } Log "========== ALL SPECS COMPLETE ==========" Magenta } catch { $errMsg = "FATAL [dev-loop]: $_" Write-Host $errMsg -ForegroundColor Red if ($logFile -and (Test-Path (Split-Path $logFile))) { "$(Get-Date -Format 'HH:mm:ss') $errMsg" | Out-File -FilePath $logFile -Append } throw } finally { Pop-Location } } Export-ModuleMember -Function Invoke-DevLoop |