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.
 
 

301 lines
12 KiB

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