<# .SYNOPSIS Shared helpers for the lights-out skill. .DESCRIPTION Dot-source this file; it defines functions only and performs no side effects on load. Everything here must keep working with no console attached, because the finalize phase runs inside a detached process long after the launching Scout session is gone. #> Set-StrictMode -Version Latest $script:LightsOutRoot = if ($env:LIGHTS_OUT_ROOT) { $env:LIGHTS_OUT_ROOT } else { Join-Path $env:USERPROFILE '.scout\lights-out' } $script:LightsOutStatePath = Join-Path $script:LightsOutRoot 'runs.json' $script:LightsOutVaultDir = if ($env:LIGHTS_OUT_VAULT_DIR) { $env:LIGHTS_OUT_VAULT_DIR } else { 'D:\Repos\Obsidian\07 - Lights Out' } function Get-LightsOutPaths { [CmdletBinding()] param() [pscustomobject]@{ Root = $script:LightsOutRoot StatePath = $script:LightsOutStatePath VaultDir = $script:LightsOutVaultDir } } <# Mirrors the engine slug behavior exactly: slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,20).replace(/-+$/,'') hash = sha256(prompt).hex.slice(0,6) branch = "nightshift/-", runId = branch after the slash. Deriving this rather than guessing "newest folder" is what makes a run findable months later. #> function Get-LightsOutRunId { [CmdletBinding()] param([Parameter(Mandatory)][string]$Prompt) $slug = $Prompt.ToLowerInvariant() $slug = [regex]::Replace($slug, '[^a-z0-9]+', '-') $slug = [regex]::Replace($slug, '^-+', '') $slug = [regex]::Replace($slug, '-+$', '') if ($slug.Length -gt 20) { $slug = $slug.Substring(0, 20) } $slug = [regex]::Replace($slug, '-+$', '') $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Prompt) $hex = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) } finally { $sha.Dispose() } "$slug-$($hex.Substring(0,6))" } function Get-LightsOutSafeName { [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, [int]$MaxLength = 60) $clean = [regex]::Replace($Text, '[\\/:*?"<>|]', ' ') $clean = [regex]::Replace($clean, '\s+', ' ').Trim() if ($clean.Length -gt $MaxLength) { $clean = $clean.Substring(0, $MaxLength).Trim() } if ([string]::IsNullOrWhiteSpace($clean)) { $clean = 'run' } $clean } function Get-LightsOutKebab { [CmdletBinding()] param([Parameter(Mandatory)][string]$Text) $k = [regex]::Replace($Text.ToLowerInvariant(), '[^a-z0-9]+', '-') [regex]::Replace($k, '^-+|-+$', '') } function Get-RunPropertyValue { [CmdletBinding()] param( [Parameter(Mandatory)][pscustomobject]$InputObject, [Parameter(Mandatory)][string[]]$Names ) foreach ($name in $Names) { if ($InputObject.PSObject.Properties.Name -contains $name) { return $InputObject.$name } } $null } function Get-RunEngineField { [CmdletBinding()] param( [Parameter(Mandatory)][pscustomobject]$Run, [string]$Prefix, [Parameter(Mandatory)][ValidateSet('Kind', 'Source', 'Version', 'ExitCode')][string]$Suffix ) if ([string]::IsNullOrWhiteSpace($Prefix)) { return $null } $name = "$Prefix$Suffix" if ($Run.PSObject.Properties.Name -contains $name) { return $Run.$name } $null } function Get-RunEnginePrefix { [CmdletBinding()] param([Parameter(Mandatory)][pscustomobject]$Run) $propertyNames = @($Run.PSObject.Properties.Name) if ($propertyNames | Where-Object { $_ -cmatch '^engine(Kind|Source|Version|ExitCode)$' } | Select-Object -First 1) { return 'engine' } $prefixesByField = @{} foreach ($name in $propertyNames) { $match = [regex]::Match($name, '^(?[A-Za-z]+)(?Kind|Source|Version|ExitCode)$') if (-not $match.Success) { continue } $prefix = $match.Groups['prefix'].Value $suffix = $match.Groups['suffix'].Value if ($prefix -ceq 'engine') { continue } if (-not $prefixesByField.ContainsKey($prefix)) { $prefixesByField[$prefix] = New-Object 'System.Collections.Generic.HashSet[string]' } $null = $prefixesByField[$prefix].Add($suffix) } $coherentPrefixes = @( $prefixesByField.GetEnumerator() | Where-Object { $_.Value.Contains('Kind') -and $_.Value.Contains('Source') } | ForEach-Object { $_.Key } | Sort-Object ) if ($coherentPrefixes.Count -gt 1) { throw "Ambiguous legacy engine descriptor prefixes: $($coherentPrefixes -join ', ')." } if ($coherentPrefixes.Count -eq 1) { return $coherentPrefixes[0] } $null } function Get-RunEngineDescriptor { [CmdletBinding()] param([Parameter(Mandatory)][pscustomobject]$Run) $prefix = Get-RunEnginePrefix -Run $Run [pscustomobject]@{ Kind = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix Kind Source = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix Source Version = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix Version ExitCode = Get-RunEngineField -Run $Run -Prefix $prefix -Suffix ExitCode } } function Resolve-NightshiftCommand { <# Resolve how to invoke the engine, in priority order: 1. $env:LIGHTS_OUT_NIGHTSHIFT -- explicit override (used by the test suite) 2. the vendored copy in \vendor -- pinned, self-contained, rebuildable from source 3. `nightshift` on PATH -- fallback only Returns a descriptor: Kind, File (executable), Prefix (args to prepend), Source, Version. Invoke as: & $cmd.File @($cmd.Prefix) @userArgs #> [CmdletBinding()] param() $skillRoot = Split-Path -Parent $PSScriptRoot if ($env:LIGHTS_OUT_NIGHTSHIFT) { $override = Get-Command $env:LIGHTS_OUT_NIGHTSHIFT -ErrorAction SilentlyContinue if ($override) { return [pscustomobject]@{ Kind = 'override' File = $override.Source Prefix = @() Source = $override.Source Version = $null } } } $vendored = Join-Path $skillRoot 'vendor\node_modules\nightshift\dist\cli.mjs' if (Test-Path -LiteralPath $vendored) { $node = Get-Command node -ErrorAction SilentlyContinue if ($node) { $version = $null $pkg = Join-Path $skillRoot 'vendor\node_modules\nightshift\package.json' if (Test-Path -LiteralPath $pkg) { try { $version = (Get-Content -LiteralPath $pkg -Raw | ConvertFrom-Json).version } catch { } } return [pscustomobject]@{ Kind = 'vendored' File = $node.Source Prefix = @($vendored) Source = $vendored Version = $version } } } $global = Get-Command nightshift -ErrorAction SilentlyContinue if ($global) { return [pscustomobject]@{ Kind = 'path' File = $global.Source Prefix = @() Source = $global.Source Version = $null } } $null } # --- state registry ------------------------------------------------------- # A single JSON array at ~\.scout\lights-out\runs.json. This is the handoff file that lets a # brand-new Scout session, with zero memory of the launching conversation, find every run. function Read-LightsOutRuns { [CmdletBinding()] param() if (-not (Test-Path -LiteralPath $script:LightsOutStatePath)) { return @() } try { $raw = Get-Content -LiteralPath $script:LightsOutStatePath -Raw -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($raw)) { return @() } $parsed = $raw | ConvertFrom-Json -ErrorAction Stop if ($null -eq $parsed) { return @() } @($parsed) } catch { Write-Warning "runs.json unreadable ($($_.Exception.Message)); treating as empty." @() } } function Save-LightsOutRun { [CmdletBinding()] param([Parameter(Mandatory)][pscustomobject]$Run) if (-not (Test-Path -LiteralPath $script:LightsOutRoot)) { New-Item -ItemType Directory -Force -Path $script:LightsOutRoot | Out-Null } $lockPath = "$script:LightsOutStatePath.lock" $lock = $null for ($i = 0; $i -lt 50 -and $null -eq $lock; $i++) { try { $lock = [System.IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') } catch { Start-Sleep -Milliseconds 100 } } try { $runRegistryId = if ($Run.PSObject.Properties.Name -contains 'registryId') { $Run.registryId } else { $null } $runs = @(Read-LightsOutRuns | Where-Object { $existingRegistryId = if ($_.PSObject.Properties.Name -contains 'registryId') { $_.registryId } else { $null } if ($runRegistryId) { $existingRegistryId -ne $runRegistryId } else { $_.id -ne $Run.id } }) $runs += $Run $json = ($runs | ConvertTo-Json -Depth 12) if ($runs.Count -eq 1) { $json = "[$json]" } Set-Content -LiteralPath $script:LightsOutStatePath -Value $json -Encoding UTF8 } finally { if ($lock) { $lock.Dispose(); Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue } } } function Get-LightsOutRun { [CmdletBinding()] param([Parameter(Mandatory)][string]$Id) $matches = @(Read-LightsOutRuns | Where-Object { $_.id -eq $Id -or ($_.PSObject.Properties.Name -contains 'registryId' -and $_.registryId -eq $Id) -or ($_.PSObject.Properties.Name -contains 'launchId' -and $_.launchId -eq $Id) -or ($_.PSObject.Properties.Name -contains 'engineRunId' -and $_.engineRunId -eq $Id) }) if ($matches.Count -eq 0) { return $null } $launchMatches = @($matches | Where-Object { $_.PSObject.Properties.Name -contains 'launchId' -and $_.launchId -eq $Id } | Sort-Object { [datetime]::Parse($_.startedAt) } -Descending) if ($launchMatches.Count -gt 0) { return $launchMatches[0] } $registryMatch = @($matches | Where-Object { $_.PSObject.Properties.Name -contains 'registryId' -and $_.registryId -eq $Id } | Select-Object -First 1) if ($registryMatch.Count -gt 0) { return $registryMatch[0] } $exact = @($matches | Where-Object { $_.id -eq $Id -and $_.status -notin @('running', 'launching', 'finalizing') } | Select-Object -First 1) if ($exact.Count -gt 0) { return $exact[0] } $finalizedLaunch = @($matches | Where-Object { $_.PSObject.Properties.Name -contains 'launchId' -and $_.launchId -eq $Id -and $_.status -notin @('running', 'launching', 'finalizing') } | Select-Object -First 1) if ($finalizedLaunch.Count -gt 0) { return $finalizedLaunch[0] } $matches | Select-Object -First 1 } function Get-LightsOutRunRoots { [CmdletBinding()] param([Parameter(Mandatory)][string]$RepoPath) $roots = [System.Collections.Generic.List[string]]::new() $preferred = Join-Path $RepoPath '.nightshift\runs' if (Test-Path -LiteralPath $preferred) { [void]$roots.Add($preferred) } Get-ChildItem -LiteralPath $RepoPath -Force -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like '.*' } | ForEach-Object { $candidate = Join-Path $_.FullName 'runs' if ((Test-Path -LiteralPath $candidate) -and -not $roots.Contains($candidate)) { [void]$roots.Add($candidate) } } $roots.ToArray() } # --- run directory resolution -------------------------------------------- # The engine appends -1, -2, ... on runId collision, so the predicted id may not be the real one. # Prefer the exact id; otherwise take the newest suffixed sibling. function Resolve-LightsOutRunDir { [CmdletBinding()] param([Parameter(Mandatory)][string]$RepoPath, [Parameter(Mandatory)][string]$RunId) foreach ($runsRoot in Get-LightsOutRunRoots -RepoPath $RepoPath) { $exact = Join-Path $runsRoot $RunId if (Test-Path -LiteralPath $exact) { return $exact } } foreach ($runsRoot in Get-LightsOutRunRoots -RepoPath $RepoPath) { $match = Get-ChildItem -LiteralPath $runsRoot -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match ('^' + [regex]::Escape($RunId) + '(-\d+)?$') } | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 -ExpandProperty FullName if ($match) { return $match } } $null } function Resolve-LightsOutRunDirFromConsoleLog { [CmdletBinding()] param([string]$ConsoleLogPath) if (-not $ConsoleLogPath -or -not (Test-Path -LiteralPath $ConsoleLogPath)) { return $null } $runDirCandidates = [System.Collections.Generic.List[string]]::new() foreach ($line in @(Get-Content -LiteralPath $ConsoleLogPath -ErrorAction SilentlyContinue)) { $clean = $line -replace '\x1b\[[0-9;?]*[A-Za-z]', '' foreach ($match in [regex]::Matches($clean, '(?[A-Za-z]:\\[^\r\n`"]+?\.nightshift\\runs\\[^\r\n\\`"]+\\nightshift\.log)')) { $path = Split-Path -Parent ([string]$match.Groups['path'].Value.Trim()) if ((Test-Path -LiteralPath $path) -and -not $runDirCandidates.Contains($path)) { [void]$runDirCandidates.Add([string]$path) } } } if ($runDirCandidates.Count -eq 1) { return $runDirCandidates[0] } if ($runDirCandidates.Count -gt 1) { $exactDirs = @($runDirCandidates | Where-Object { Test-Path -LiteralPath (Join-Path $_ 'end-state.json') }) if ($exactDirs.Count -eq 1) { return $exactDirs[0] } } $null } function Get-LightsOutCheckoutPathFromRunDir { [CmdletBinding()] param([string]$RunDir) if (-not $RunDir) { return $null } $marker = '\.nightshift\runs\' $index = $RunDir.IndexOf($marker, [System.StringComparison]::OrdinalIgnoreCase) if ($index -lt 0) { return $null } $checkoutPath = $RunDir.Substring(0, $index) if (Test-Path -LiteralPath $checkoutPath) { return $checkoutPath } $null } function Get-GitWorktreePaths { [CmdletBinding()] param([Parameter(Mandatory)][string]$RepoPath) $paths = [System.Collections.Generic.List[string]]::new() $current = $null foreach ($line in @(git -C $RepoPath worktree list --porcelain 2>$null)) { if ($line -match '^worktree\s+(.+)$') { if ($current) { [void]$paths.Add($current) } $current = $Matches[1] } } if ($current) { [void]$paths.Add($current) } $paths.ToArray() } function Resolve-LightsOutCheckoutPath { [CmdletBinding()] param([Parameter(Mandatory)][pscustomobject]$Run) if (-not (Test-Path -LiteralPath $Run.repoPath)) { return $null } if (-not ($Run.PSObject.Properties['worktree'] -and $Run.worktree)) { return $Run.repoPath } $consoleLogStem = if ($Run.PSObject.Properties.Name -contains 'registryId' -and $Run.registryId) { $Run.registryId } else { $Run.id } $consoleRunDir = Resolve-LightsOutRunDirFromConsoleLog -ConsoleLogPath (Join-Path $script:LightsOutRoot "logs\$consoleLogStem.console.log") if ($consoleRunDir) { $checkoutFromConsole = Get-LightsOutCheckoutPathFromRunDir -RunDir $consoleRunDir if ($checkoutFromConsole) { return $checkoutFromConsole } } $candidates = [System.Collections.Generic.List[string]]::new() $repoParent = Split-Path -Parent $Run.repoPath $repoName = Split-Path -Leaf $Run.repoPath $predicted = Join-Path $repoParent "$repoName-nightshift-worktrees\$($Run.id)" if (Test-Path -LiteralPath $predicted) { [void]$candidates.Add((Resolve-Path -LiteralPath $predicted).Path) } foreach ($path in @(Get-GitWorktreePaths -RepoPath $Run.repoPath)) { if ((Test-Path -LiteralPath $path) -and -not $candidates.Contains($path)) { [void]$candidates.Add($path) } } $matchingRunDirs = [System.Collections.Generic.List[string]]::new() foreach ($candidate in $candidates) { $runDir = Resolve-LightsOutRunDir -RepoPath $candidate -RunId $Run.id if (-not $runDir) { continue } if ($Run.PSObject.Properties.Name -contains 'startedAt' -and $Run.startedAt) { try { $startedAt = [datetime]::Parse($Run.startedAt).ToUniversalTime() $runDirUpdatedAt = (Get-Item -LiteralPath $runDir).LastWriteTimeUtc if ($runDirUpdatedAt -lt $startedAt) { continue } } catch { } } [void]$matchingRunDirs.Add([string]$runDir) } if ($matchingRunDirs.Count -gt 0) { $newestRunDir = $matchingRunDirs | Sort-Object { (Get-Item -LiteralPath $_).LastWriteTimeUtc } -Descending | Select-Object -First 1 $checkoutFromRunDir = Get-LightsOutCheckoutPathFromRunDir -RunDir $newestRunDir if ($checkoutFromRunDir) { return $checkoutFromRunDir } } $null } function Test-LightsOutProcessAlive { [CmdletBinding()] param([Parameter(Mandatory)][int]$ProcessId, [string]$StartedAtIso) $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue if (-not $process) { return $false } if ($StartedAtIso) { try { $expected = [datetime]::Parse($StartedAtIso).ToUniversalTime() if ($process.StartTime.ToUniversalTime() -gt $expected.AddMinutes(2)) { return $false } } catch { } } $true } function Resolve-LightsOutEngineLogPath { [CmdletBinding()] param([Parameter(Mandatory)][string]$RunDir) $preferred = Join-Path $RunDir 'nightshift.log' if (Test-Path -LiteralPath $preferred) { return $preferred } Get-ChildItem -LiteralPath $RunDir -File -Filter '*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 -ExpandProperty FullName } # --- vault write-back ----------------------------------------------------- function Get-LightsOutVaultFolder { [CmdletBinding()] param([Parameter(Mandatory)][pscustomobject]$Run) $date = ([datetime]::Parse($Run.startedAt)).ToString('yyyy-MM-dd') $repo = Get-LightsOutSafeName -Text $Run.repoName -MaxLength 30 $title = Get-LightsOutSafeName -Text $Run.title -MaxLength 50 $identity = if ($Run.PSObject.Properties.Name -contains 'registryId' -and $Run.registryId) { ([string]$Run.registryId).Substring(0, [Math]::Min(8, ([string]$Run.registryId).Length)) } else { $null } $folderName = if ($identity) { "$date - $repo - $title - $identity" } else { "$date - $repo - $title" } Join-Path $script:LightsOutVaultDir $folderName } function Write-LightsOutVaultNote { <# Writes the canonical vault record for a finished run: immutable raw artifacts plus a summary.md wiki page. Mirrors the 02 - Meetings pattern (raw + summary.md). The Assessment section is deliberately left as a stub for Scout to complete. #> [CmdletBinding()] param([Parameter(Mandatory)][pscustomobject]$Run, [string]$RunDir, [string]$ConsoleLogPath) $folder = Get-LightsOutVaultFolder -Run $Run New-Item -ItemType Directory -Force -Path $folder | Out-Null $endState = $null if ($RunDir) { $endStatePath = Join-Path $RunDir 'end-state.json' if (Test-Path -LiteralPath $endStatePath) { try { $endState = Get-Content -LiteralPath $endStatePath -Raw | ConvertFrom-Json } catch { } Copy-Item -LiteralPath $endStatePath -Destination (Join-Path $folder 'end-state.json') -Force -ErrorAction SilentlyContinue } foreach ($fileName in 'notes.md', 'prompt.md') { $path = Join-Path $RunDir $fileName if (Test-Path -LiteralPath $path) { Copy-Item -LiteralPath $path -Destination (Join-Path $folder $fileName) -Force -ErrorAction SilentlyContinue } } } $commits = @() $diffStat = '' $changedFiles = @() $base = $Run.baseCommit $checkoutPath = if ($Run.PSObject.Properties['checkoutPath'] -and $Run.checkoutPath) { $Run.checkoutPath } else { Resolve-LightsOutCheckoutPath -Run $Run } $isWorktreeRun = $Run.PSObject.Properties['worktree'] -and $Run.worktree if (-not $checkoutPath -and -not $isWorktreeRun) { $checkoutPath = $Run.repoPath } $repoResolved = try { (Resolve-Path -LiteralPath $Run.repoPath).Path } catch { $Run.repoPath } $checkoutResolved = try { (Resolve-Path -LiteralPath $checkoutPath).Path } catch { $checkoutPath } $checkoutDiffers = $isWorktreeRun -and $checkoutResolved -ne $repoResolved if ($base -and (Test-Path -LiteralPath $checkoutPath)) { $range = "$base..HEAD" $commits = @(git -C $checkoutPath --no-pager log --oneline $range 2>$null) $diffStat = (git -C $checkoutPath --no-pager diff --stat $range 2>$null) -join "`n" $changedFiles = @(git -C $checkoutPath --no-pager diff --name-only $range 2>$null) $patch = git -C $checkoutPath --no-pager diff $range 2>$null if ($patch) { Set-Content -LiteralPath (Join-Path $folder 'diff.patch') -Value ($patch -join "`n") -Encoding UTF8 } } $status = if ($endState -and $endState.PSObject.Properties['status']) { [string]$endState.status } else { 'unknown' } $failed = $status -notin @('completed', 'stopped', 'stop-condition-met') if ($failed -and $RunDir) { $engineLog = Resolve-LightsOutEngineLogPath -RunDir $RunDir if ($engineLog) { Copy-Item -LiteralPath $engineLog -Destination (Join-Path $folder (Split-Path $engineLog -Leaf)) -Force -ErrorAction SilentlyContinue } } if ($ConsoleLogPath -and (Test-Path -LiteralPath $ConsoleLogPath)) { Get-Content -LiteralPath $ConsoleLogPath -Tail 400 -ErrorAction SilentlyContinue | Set-Content -LiteralPath (Join-Path $folder 'console.tail.log') -Encoding UTF8 } $engine = Get-RunEngineDescriptor -Run $Run $today = (Get-Date).ToString('yyyy-MM-dd') $date = ([datetime]::Parse($Run.startedAt)).ToString('yyyy-MM-dd') $repoTag = Get-LightsOutKebab -Text $Run.repoName $endedAt = if ($Run.PSObject.Properties['endedAt'] -and $Run.endedAt) { $Run.endedAt } else { (Get-Date).ToString('o') } $elapsed = '' try { $span = ([datetime]::Parse($endedAt)) - ([datetime]::Parse($Run.startedAt)) $elapsed = '{0:h\hm\m}' -f $span } catch { } $iterText = if ($endState -and $endState.PSObject.Properties['iterations']) { "$($endState.iterations)" } else { 'unknown' } $okText = if ($endState -and $endState.PSObject.Properties['successCount']) { "$($endState.successCount)" } else { '?' } $failText = if ($endState -and $endState.PSObject.Properties['failCount']) { "$($endState.failCount)" } else { '?' } $stopCondition = if ($endState -and $endState.PSObject.Properties['stopCondition'] -and $endState.stopCondition) { [string]$endState.stopCondition } else { '_none recorded_' } $agentError = if ($endState -and $endState.PSObject.Properties['agentError'] -and $endState.agentError) { [string]$endState.agentError } else { $null } $quoteYaml = { param($s) "'" + ($s -replace "'", "''") + "'" } $sourceList = @((& $quoteYaml $Run.repoPath)) if ($checkoutPath -and $checkoutDiffers) { $sourceList += (& $quoteYaml $checkoutPath) } if ($RunDir) { $sourceList += (& $quoteYaml $RunDir) } $sourcesLine = 'sources: [' + ($sourceList -join ', ') + ']' $toIso = { param($value) $dt = [datetime]$value if ($dt.Kind -eq 'Unspecified') { $dt = [datetime]::SpecifyKind($dt, 'Local') } $dt.ToString('o') } $startedIso = & $toIso $Run.startedAt $endedIso = & $toIso $endedAt $title = "$date $($Run.repoName) — $($Run.title)" $builder = [System.Text.StringBuilder]::new() [void]$builder.AppendLine('---') [void]$builder.AppendLine("title: $(& $quoteYaml $title)") [void]$builder.AppendLine('type: lights-out-run') [void]$builder.AppendLine("date: $date") [void]$builder.AppendLine("created: $date") [void]$builder.AppendLine("updated: $today") [void]$builder.AppendLine('status: draft') [void]$builder.AppendLine("tags: [lights-out, project/$repoTag, status/draft]") [void]$builder.AppendLine($sourcesLine) [void]$builder.AppendLine('owner: agent:lights-out') [void]$builder.AppendLine('sensitivity: internal') [void]$builder.AppendLine('---') [void]$builder.AppendLine() [void]$builder.AppendLine("# $title") [void]$builder.AppendLine() [void]$builder.AppendLine('> [!warning] Assessment pending') [void]$builder.AppendLine('> Facts below are machine-captured. The judgment section is empty until Scout reviews it.') [void]$builder.AppendLine('> Ask Scout: "how did last night''s run go?"') [void]$builder.AppendLine() [void]$builder.AppendLine('## Run') [void]$builder.AppendLine() [void]$builder.AppendLine('| Field | Value |') [void]$builder.AppendLine('|---|---|') [void]$builder.AppendLine("| Run id | ``$($Run.id)`` |") [void]$builder.AppendLine("| Repo | ``$($Run.repoPath)`` |") if ($checkoutPath -and $checkoutDiffers) { [void]$builder.AppendLine("| Checkout | ``$checkoutPath`` |") } [void]$builder.AppendLine("| Branch | ``$($Run.branch)`` |") [void]$builder.AppendLine("| Agent | $($Run.agent) |") if ($engine.Kind) { $engineText = $engine.Kind if ($engine.Version) { $engineText += " v$($engine.Version)" } [void]$builder.AppendLine("| Engine | $engineText |") } [void]$builder.AppendLine("| Mode | $($Run.mode) |") [void]$builder.AppendLine("| Started | $startedIso |") [void]$builder.AppendLine("| Ended | $endedIso |") [void]$builder.AppendLine("| Elapsed | $elapsed |") [void]$builder.AppendLine("| Exit status | **$status** |") [void]$builder.AppendLine("| Iterations | $iterText ($okText ok / $failText failed) |") [void]$builder.AppendLine("| Commits | $($commits.Count) |") [void]$builder.AppendLine() [void]$builder.AppendLine('## Objective') [void]$builder.AppendLine() [void]$builder.AppendLine('```text') [void]$builder.AppendLine($Run.prompt) [void]$builder.AppendLine('```') [void]$builder.AppendLine() [void]$builder.AppendLine('## Stop condition') [void]$builder.AppendLine() [void]$builder.AppendLine($stopCondition) [void]$builder.AppendLine() if ($agentError) { [void]$builder.AppendLine('## Agent error') [void]$builder.AppendLine() [void]$builder.AppendLine('```text') [void]$builder.AppendLine($agentError) [void]$builder.AppendLine('```') [void]$builder.AppendLine() } [void]$builder.AppendLine('## Commits') [void]$builder.AppendLine() if ($commits.Count -gt 0) { [void]$builder.AppendLine('```text') foreach ($commit in $commits) { [void]$builder.AppendLine($commit) } [void]$builder.AppendLine('```') } else { [void]$builder.AppendLine('_No commits were made._') } [void]$builder.AppendLine() [void]$builder.AppendLine('## Changed files') [void]$builder.AppendLine() if ($changedFiles.Count -gt 0) { [void]$builder.AppendLine('```text') [void]$builder.AppendLine($diffStat) [void]$builder.AppendLine('```') } else { [void]$builder.AppendLine('_No files changed._') } [void]$builder.AppendLine() [void]$builder.AppendLine('## Verification') [void]$builder.AppendLine() if ($Run.PSObject.Properties['verifyCommand'] -and $Run.verifyCommand) { [void]$builder.AppendLine("Command: ``$($Run.verifyCommand)``") [void]$builder.AppendLine() $pre = if ($Run.PSObject.Properties['verifyExitCodeBefore']) { $Run.verifyExitCodeBefore } else { 'n/a' } $post = if ($Run.PSObject.Properties['verifyExitCodeAfter']) { $Run.verifyExitCodeAfter } else { 'n/a' } [void]$builder.AppendLine("- Exit code before the run: **$pre**") [void]$builder.AppendLine("- Exit code after the run: **$post**") [void]$builder.AppendLine() [void]$builder.AppendLine('> Captured by the wrapper, independent of anything the agent claimed.') } else { [void]$builder.AppendLine('_No verification command was configured for this run._') } [void]$builder.AppendLine() [void]$builder.AppendLine('## Assessment') [void]$builder.AppendLine() [void]$builder.AppendLine('_Pending Scout review. Replace this section, set `status:` to `active`, swap the') [void]$builder.AppendLine('`status/draft` tag for `status/active`, and bump `updated:`._') [void]$builder.AppendLine() [void]$builder.AppendLine('- Met the stop condition:') [void]$builder.AppendLine('- Quality:') [void]$builder.AppendLine('- Scope discipline:') [void]$builder.AppendLine('- Verdict: Mergeable / Needs follow-up run / Do not merge') [void]$builder.AppendLine('- Recommended next action:') [void]$builder.AppendLine() [void]$builder.AppendLine('## Artifacts') [void]$builder.AppendLine() [void]$builder.AppendLine('Raw, immutable (vault rule 4 — never hand-edit):') [void]$builder.AppendLine() Get-ChildItem -LiteralPath $folder -File -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { [void]$builder.AppendLine("- ``$($_.Name)``") } $summaryPath = Join-Path $folder 'summary.md' Set-Content -LiteralPath $summaryPath -Value $builder.ToString() -Encoding UTF8 $summaryPath }