AI skills in use in my daily
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

308 lines
13 KiB

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