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.
726 lines
29 KiB
726 lines
29 KiB
<#
|
|
.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/<slug>-<hash>", 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, '^(?<prefix>[A-Za-z]+)(?<suffix>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 <skill>\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, '(?<path>[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
|
|
}
|
|
|