<# .SYNOPSIS Launch a nightshift 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 the engine 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 the engine 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', [string]$RunStateId, [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 function Get-AgentCommandName { param([string]$AgentName) switch -Regex ($AgentName) { '^acp:' { return $null } '^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 } } if ($Phase -eq 'Worker') { $run = Get-LightsOutRun -Id $RunStateId if (-not $run) { throw "No run registered with id '$RunStateId'." } $run | Add-Member -NotePropertyName workerPid -NotePropertyValue $PID -Force $run | Add-Member -NotePropertyName status -NotePropertyValue 'running' -Force Save-LightsOutRun -Run $run $logDir = Join-Path $paths.Root 'logs' New-Item -ItemType Directory -Force -Path $logDir | Out-Null $consoleLogStem = if ($run.PSObject.Properties.Name -contains 'registryId' -and $run.registryId) { $run.registryId } else { $run.id } $consoleLog = Join-Path $logDir "$consoleLogStem.console.log" $engineArgs = @() if ($run.agent) { $engineArgs += @('--agent', $run.agent) } if ($run.PSObject.Properties['model'] -and $run.model) { $engineArgs += @('--model', $run.model) } if ($run.PSObject.Properties['stopWhen'] -and $run.stopWhen) { $engineArgs += @('--stop-when', $run.stopWhen) } if ($run.PSObject.Properties['maxIterations'] -and $run.maxIterations -gt 0) { $engineArgs += @('--max-iterations', "$($run.maxIterations)") } if ($run.PSObject.Properties['maxTokens'] -and $run.maxTokens -gt 0) { $engineArgs += @('--max-tokens', "$($run.maxTokens)") } if ($run.PSObject.Properties['worktree'] -and $run.worktree) { $engineArgs += '--worktree' } if ($run.PSObject.Properties['currentBranch'] -and $run.currentBranch) { $engineArgs += '--current-branch' } if ($run.PSObject.Properties['push'] -and $run.push) { $engineArgs += '--push' } if ($run.PSObject.Properties['mock'] -and $run.mock) { $engineArgs += '--mock' } $engineArgs += @('--prevent-sleep', 'on', '--meteor-frequency', '0', $run.prompt) $engineCommand = Resolve-NightshiftCommand if (-not $engineCommand) { $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 'Nightshift could not be resolved (no override, no vendored copy, not on PATH).' -Force Save-LightsOutRun -Run $run exit 1 } $invokeArgs = @($engineCommand.Prefix) + $engineArgs $env:NO_COLOR = '1' $env:TERM = 'dumb' $exitCode = -1 try { Push-Location -LiteralPath $run.repoPath try { & $engineCommand.File @invokeArgs *> $consoleLog $exitCode = $LASTEXITCODE } finally { Pop-Location } } catch { $exitCode = -1 Add-Content -LiteralPath $consoleLog -Value "`n[lights-out] launcher exception: $($_.Exception.Message)" } $run | Add-Member -NotePropertyName endedAt -NotePropertyValue (Get-Date).ToString('o') -Force $run | Add-Member -NotePropertyName engineExitCode -NotePropertyValue $exitCode -Force $run | Add-Member -NotePropertyName status -NotePropertyValue 'finalizing' -Force Save-LightsOutRun -Run $run $runDir = Resolve-LightsOutRunDirFromConsoleLog -ConsoleLogPath $consoleLog $checkoutPath = if ($runDir) { Get-LightsOutCheckoutPathFromRunDir -RunDir $runDir } else { Resolve-LightsOutCheckoutPath -Run $run } if ($checkoutPath) { $run | Add-Member -NotePropertyName checkoutPath -NotePropertyValue $checkoutPath -Force } if (-not $runDir) { $runDir = if ($checkoutPath) { Resolve-LightsOutRunDir -RepoPath $checkoutPath -RunId $run.id } else { $null } } if ($runDir) { $actualRunId = Split-Path -Leaf $runDir $run | Add-Member -NotePropertyName launchId -NotePropertyValue $(if ($run.PSObject.Properties.Name -contains 'launchId') { $run.launchId } else { $run.id }) -Force $run | Add-Member -NotePropertyName engineRunId -NotePropertyValue $actualRunId -Force $run | Add-Member -NotePropertyName id -NotePropertyValue $actualRunId -Force $run | Add-Member -NotePropertyName runDir -NotePropertyValue $runDir -Force } if ($checkoutPath -and (Test-Path -LiteralPath $checkoutPath)) { try { $actualBranch = (git -C $checkoutPath rev-parse --abbrev-ref HEAD 2>$null) if ($actualBranch) { $run | Add-Member -NotePropertyName branch -NotePropertyValue $actualBranch -Force } } catch { } } if ($run.PSObject.Properties['verifyCommand'] -and $run.verifyCommand) { $verifyLog = Join-Path $logDir "$($run.id).verify-after.log" $verifyDir = if ($checkoutPath) { $checkoutPath } elseif (-not ($run.PSObject.Properties['worktree'] -and $run.worktree)) { $run.repoPath } else { $null } if ($verifyDir) { try { $after = Invoke-VerifyCommand -Command $run.verifyCommand -WorkingDir $verifyDir -LogPath $verifyLog $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue $after -Force } catch { $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'error' -Force } } else { $run | Add-Member -NotePropertyName verifyExitCodeAfter -NotePropertyValue 'skipped-unresolved-checkout' -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 } if (-not $RepoPath) { throw 'RepoPath is required.' } if ([string]::IsNullOrWhiteSpace($Prompt)) { throw 'Prompt is required and must be non-empty. An empty prompt makes a detached nightshift run hang on stdin.' } $RepoPath = (Resolve-Path -LiteralPath $RepoPath).Path $problems = @() $engineCommand = Resolve-NightshiftCommand if (-not $engineCommand) { $problems += 'Nightshift could not be resolved. Restore the complete vendored runtime from a working Lights Out copy, or use the separately stored maintenance archives described in SKILL.md. Do not install an unrelated published package.' } elseif ($engineCommand.Kind -eq 'vendored' -and -not (Get-Command node -ErrorAction SilentlyContinue)) { $problems += 'The vendored nightshift engine needs node.exe on PATH, and it was not found.' } $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') { $dirty = @(git -C $RepoPath status --porcelain 2>$null) if ($dirty.Count -gt 0) { $problems += "Working tree is dirty ($($dirty.Count) change(s)). Nightshift requires a clean tree. Commit or stash first." } $branch = (git -C $RepoPath rev-parse --abbrev-ref HEAD 2>$null) if ($branch -and $branch.StartsWith('nightshift/') -and -not $CurrentBranch) { $problems += "Currently on nightshift 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) } $agentCommand = if ($Mock) { $null } else { Get-AgentCommandName -AgentName $Agent } if ($agentCommand -and -not (Get-Command $agentCommand -ErrorAction SilentlyContinue)) { $problems += "Agent CLI '$agentCommand' (for --agent $Agent) is not on PATH. Install and authenticate it before an unattended run." } $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]@{ registryId = [guid]::NewGuid().ToString('n') id = $runId launchId = $runId schemaVersion = 1 status = 'launching' repoPath = $RepoPath repoName = $repoName branch = if ($CurrentBranch) { $branch } else { "nightshift/$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 engineKind = $engineCommand.Kind engineSource = $engineCommand.Source engineVersion = $engineCommand.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', $run.registryId ) $process = Start-Process -FilePath (Get-Process -Id $PID).Path ` -ArgumentList $workerArgs -WindowStyle Hidden -PassThru [pscustomobject]@{ ok = $true id = $runId registryId = $run.registryId launchId = $run.launchId branch = $run.branch repoPath = $RepoPath agent = $Agent mode = $Mode workerPid = $process.Id stopWhen = $StopWhen verifyCommand = $VerifyCommand verifyBefore = $verifyBefore engine = "$($engineCommand.Kind): $($engineCommand.Source)$(if ($engineCommand.Version) { " (v$($engineCommand.Version))" })" statePath = $paths.StatePath expectedVault = (Get-LightsOutVaultFolder -Run $run) } | ConvertTo-Json -Depth 5