<#
. 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