<# .SYNOPSIS Launch a gnhf overnight run, detached, with a guaranteed finalize step. .DESCRIPTION Phase "Launch" (default) runs preflight, records the run in the durable registry, and spawns a detached worker. It returns quickly and prints a JSON object for Scout to read. Phase "Worker" is internal. It runs gnhf to completion, re-runs verification, and writes the Obsidian vault record. It takes only a run id -- every other value is read back out of runs.json, which avoids quoting a multi-line prompt through a process boundary. The finalize work lives here, in the detached process, on purpose: the Scout session that launched the run is usually gone by the time gnhf exits, so an agent-side "on exit" hook would silently never fire. .EXAMPLE .\start-run.ps1 -RepoPath D:\Repos\myapp -Prompt "reduce complexity in src/utils" ` -StopWhen "npm test passes and no unrelated files changed" -VerifyCommand "npm test" #> [CmdletBinding()] param( [ValidateSet('Launch', 'Worker')] [string]$Phase = 'Launch', # Worker phase only [string]$RunStateId, # Launch phase [string]$RepoPath, [string]$Prompt, [string]$Title, [string]$Agent = 'copilot', [string]$Model, [string]$StopWhen, [string]$VerifyCommand, [int]$MaxIterations = 40, [long]$MaxTokens = 0, [ValidateSet('hands-off', 'companion')] [string]$Mode = 'hands-off', [switch]$Worktree, [switch]$CurrentBranch, [switch]$Push, [switch]$Mock, [switch]$SkipVerifyPreflight ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'lib.ps1') $paths = Get-LightsOutPaths # Map a gnhf --agent value to the CLI it shells out to, so we fail at launch instead of at 2am. function Get-AgentCommandName { param([string]$AgentName) switch -Regex ($AgentName) { '^acp:' { return $null } # ACP targets are resolved by acpx, skip the check '^cursor$' { return 'cursor-agent' } default { return $AgentName } } } function Invoke-VerifyCommand { param([string]$Command, [string]$WorkingDir, [string]$LogPath) Push-Location -LiteralPath $WorkingDir try { if ($LogPath) { & pwsh -NoProfile -NonInteractive -Command $Command *> $LogPath } else { & pwsh -NoProfile -NonInteractive -Command $Command *> $null } return $LASTEXITCODE } finally { Pop-Location } } # ========================================================================= # WORKER # ========================================================================= if ($Phase -eq 'Worker') { $run = Get-LightsOutRun -Id $RunStateId if (-not $run) { throw "No run registered with id '$RunStateId'." } $logDir = Join-Path $paths.Root 'logs' New-Item -ItemType Directory -Force -Path $logDir | Out-Null $consoleLog = Join-Path $logDir "$($run.id).console.log" $gnhfArgs = @() if ($run.agent) { $gnhfArgs += @('--agent', $run.agent) } if ($run.PSObject.Properties['model'] -and $run.model) { $gnhfArgs += @('--model', $run.model) } if ($run.PSObject.Properties['stopWhen'] -and $run.stopWhen) { $gnhfArgs += @('--stop-when', $run.stopWhen) } if ($run.PSObject.Properties['maxIterations'] -and $run.maxIterations -gt 0) { $gnhfArgs += @('--max-iterations', "$($run.maxIterations)") } if ($run.PSObject.Properties['maxTokens'] -and $run.maxTokens -gt 0) { $gnhfArgs += @('--max-tokens', "$($run.maxTokens)") } if ($run.PSObject.Properties['worktree'] -and $run.worktree) { $gnhfArgs += '--worktree' } if ($run.PSObject.Properties['currentBranch'] -and $run.currentBranch) { $gnhfArgs += '--current-branch' } if ($run.PSObject.Properties['push'] -and $run.push) { $gnhfArgs += '--push' } if ($run.PSObject.Properties['mock'] -and $run.mock) { $gnhfArgs += '--mock' } $gnhfArgs += @('--prevent-sleep', 'on', '--meteor-frequency', '0', $run.prompt) $gnhfCmd = Resolve-GnhfCommand if (-not $gnhfCmd) { # Recorded rather than thrown: the launching session is long gone, so the only way the # user ever learns about this is the registry and the vault note. $run | Add-Member -NotePropertyName status -NotePropertyValue 'failed' -Force $run | Add-Member -NotePropertyName endedAt -NotePropertyValue (Get-Date).ToString('o') -Force $run | Add-Member -NotePropertyName launchError -NotePropertyValue 'gnhf could not be resolved (no override, no vendored copy, not on PATH).' -Force Save-LightsOutRun -Run $run exit 1 } $invokeArgs = @($gnhfCmd.Prefix) + $gnhfArgs # No TTY here, so the star field is pointless and ANSI would just pollute the log. $env:NO_COLOR = '1' $env:TERM = 'dumb' $exitCode = -1 try { Push-Location -LiteralPath $run.repoPath try { & $gnhfCmd.File @invokeArgs *> $consoleLog $exitCode = $LASTEXITCODE } finally { Pop-Location } } catch { $exitCode = -1 Add-Content -LiteralPath $consoleLog -Value "`n[lights-out] launcher exception: $($_.Exception.Message)" } # --- finalize: this must run no matter how gnhf exited -------------- $run | Add-Member -NotePropertyName endedAt -NotePropertyValue (Get-Date).ToString('o') -Force $run | Add-Member -NotePropertyName gnhfExitCode -NotePropertyValue $exitCode -Force $run | Add-Member -NotePropertyName status -NotePropertyValue 'finalizing' -Force Save-LightsOutRun -Run $run $runDir = Resolve-LightsOutRunDir -RepoPath $run.repoPath -RunId $run.id if ($runDir) { $run | Add-Member -NotePropertyName runDir -NotePropertyValue $runDir -Force } # Independent verification. The agent's own claim of success is not evidence. if ($run.PSObject.Properties['verifyCommand'] -and $run.verifyCommand) { $vLog = Join-Path $logDir "$($run.id).verify-after.log" try { $after = Invoke-VerifyCommand -Command $run.verifyCommand -WorkingDir $run.repoPath -LogPath $vLog $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue $after -Force } catch { $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'error' -Force } } $finalStatus = if ($exitCode -eq 0) { 'completed' } else { 'failed' } $run | Add-Member -NotePropertyName status -NotePropertyValue $finalStatus -Force try { $notePath = Write-LightsOutVaultNote -Run $run -RunDir $runDir -ConsoleLogPath $consoleLog $run | Add-Member -NotePropertyName vaultNote -NotePropertyValue $notePath -Force } catch { $run | Add-Member -NotePropertyName vaultNoteError -NotePropertyValue $_.Exception.Message -Force } Save-LightsOutRun -Run $run exit 0 } # ========================================================================= # LAUNCH # ========================================================================= if (-not $RepoPath) { throw 'RepoPath is required.' } if ([string]::IsNullOrWhiteSpace($Prompt)) { # gnhf blocks forever reading stdin when the prompt is empty and stdin is not a TTY. throw 'Prompt is required and must be non-empty. An empty prompt makes a detached gnhf hang on stdin.' } $RepoPath = (Resolve-Path -LiteralPath $RepoPath).Path $problems = @() # 1. gnhf resolvable (vendored copy preferred over any global install) $gnhfCmd = Resolve-GnhfCommand if (-not $gnhfCmd) { $problems += "gnhf could not be resolved. Restore the vendored copy with: npm install gnhf --prefix `"$(Split-Path -Parent $PSScriptRoot)\vendor`" (uses the configured MS proxy registry; forcing registry.npmjs.org fails with a TLS error on this machine)" } elseif ($gnhfCmd.Kind -eq 'vendored' -and -not (Get-Command node -ErrorAction SilentlyContinue)) { $problems += 'The vendored gnhf needs node.exe on PATH, and it was not found.' } # 2. real git repo $insideRepo = (git -C $RepoPath rev-parse --is-inside-work-tree 2>$null) if ($insideRepo -ne 'true') { $problems += "$RepoPath is not a git repository. Run 'git init' first." } $branch = $null $baseCommit = $null if ($insideRepo -eq 'true') { # 3. clean tree -- gnhf refuses to start otherwise $dirty = @(git -C $RepoPath status --porcelain 2>$null) if ($dirty.Count -gt 0) { $problems += "Working tree is dirty ($($dirty.Count) change(s)). gnhf requires a clean tree. Commit or stash first." } # 4. NOT already on a gnhf branch -- this triggers an interactive overwrite prompt that # hard-fails without a TTY, killing a detached run within seconds of launch. $branch = (git -C $RepoPath rev-parse --abbrev-ref HEAD 2>$null) if ($branch -and $branch.StartsWith('gnhf/') -and -not $CurrentBranch) { $problems += "Currently on gnhf branch '$branch'. A detached run cannot answer the overwrite prompt. Switch to the base branch first." } $baseCommit = (git -C $RepoPath rev-parse HEAD 2>$null) } # 5. the agent's own CLI exists (a mock run never shells out to it) $agentCmd = if ($Mock) { $null } else { Get-AgentCommandName -AgentName $Agent } if ($agentCmd -and -not (Get-Command $agentCmd -ErrorAction SilentlyContinue)) { $problems += "Agent CLI '$agentCmd' (for --agent $Agent) is not on PATH. Install and authenticate it before an unattended run." } # 6. the verification command is actually runnable *now* $verifyBefore = $null if ($VerifyCommand -and -not $SkipVerifyPreflight) { $firstToken = ($VerifyCommand.Trim() -split '\s+')[0] if (-not (Get-Command $firstToken -ErrorAction SilentlyContinue)) { $problems += "Verification command '$VerifyCommand' cannot run: '$firstToken' is not on PATH. Pick a command that exists on this machine." } elseif ($problems.Count -eq 0) { $logDir = Join-Path $paths.Root 'logs' New-Item -ItemType Directory -Force -Path $logDir | Out-Null $verifyBefore = Invoke-VerifyCommand -Command $VerifyCommand -WorkingDir $RepoPath -LogPath (Join-Path $logDir 'verify-before.log') } } if ($problems.Count -gt 0) { [pscustomobject]@{ ok = $false problems = $problems } | ConvertTo-Json -Depth 5 exit 1 } $runId = Get-LightsOutRunId -Prompt $Prompt $repoName = Split-Path -Leaf $RepoPath if (-not $Title) { $Title = ($Prompt -split "`n" | Where-Object { $_.Trim() } | Select-Object -First 1) } $run = [pscustomobject]@{ id = $runId schemaVersion = 1 status = 'running' repoPath = $RepoPath repoName = $repoName branch = if ($CurrentBranch) { $branch } else { "gnhf/$runId" } baseCommit = $baseCommit prompt = $Prompt title = (Get-LightsOutSafeName -Text $Title -MaxLength 60) agent = $Agent model = $Model mode = $Mode stopWhen = $StopWhen maxIterations = $MaxIterations maxTokens = $MaxTokens worktree = [bool]$Worktree currentBranch = [bool]$CurrentBranch push = [bool]$Push mock = [bool]$Mock verifyCommand = $VerifyCommand verifyExitCodeBefore = $verifyBefore gnhfKind = $gnhfCmd.Kind gnhfSource = $gnhfCmd.Source gnhfVersion = $gnhfCmd.Version startedAt = (Get-Date).ToString('o') launchedBy = 'scout:lights-out' workerPid = $null } Save-LightsOutRun -Run $run $workerArgs = @( '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, '-Phase', 'Worker', '-RunStateId', $runId ) $proc = Start-Process -FilePath (Get-Process -Id $PID).Path ` -ArgumentList $workerArgs -WindowStyle Hidden -PassThru $run | Add-Member -NotePropertyName workerPid -NotePropertyValue $proc.Id -Force Save-LightsOutRun -Run $run [pscustomobject]@{ ok = $true id = $runId branch = $run.branch repoPath = $RepoPath agent = $Agent mode = $Mode workerPid = $proc.Id stopWhen = $StopWhen verifyCommand = $VerifyCommand verifyBefore = $verifyBefore gnhf = "$($gnhfCmd.Kind): $($gnhfCmd.Source)$(if ($gnhfCmd.Version) { " (v$($gnhfCmd.Version))" })" statePath = $paths.StatePath expectedVault = (Get-LightsOutVaultFolder -Run $run) } | ConvertTo-Json -Depth 5