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.
 
 

447 lines
18 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 gnhf's slugifyPrompt() exactly (src/utils/slugify.ts):
slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,20).replace(/-+$/,'')
hash = sha256(prompt).hex.slice(0,6)
branch = "gnhf/<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 Resolve-GnhfBinary {
<#
Back-compat shim: returns just the executable path. Prefer Resolve-GnhfCommand.
#>
[CmdletBinding()]
param()
(Resolve-GnhfCommand).File
}
function Resolve-GnhfCommand {
<#
Resolve how to invoke gnhf, in priority order:
1. $env:LIGHTS_OUT_GNHF -- explicit override (used by the test suite)
2. the vendored copy in <skill>\vendor -- pinned, survives `npm uninstall -g gnhf`
3. `gnhf` on PATH -- whatever global install exists
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_GNHF) {
$ovr = Get-Command $env:LIGHTS_OUT_GNHF -ErrorAction SilentlyContinue
if ($ovr) {
return [pscustomobject]@{
Kind = 'override'; File = $ovr.Source; Prefix = @()
Source = $ovr.Source; Version = $null
}
}
}
$vendored = Join-Path $skillRoot 'vendor\node_modules\gnhf\dist\cli.mjs'
if (Test-Path -LiteralPath $vendored) {
$node = Get-Command node -ErrorAction SilentlyContinue
if ($node) {
$ver = $null
$pkg = Join-Path $skillRoot 'vendor\node_modules\gnhf\package.json'
if (Test-Path -LiteralPath $pkg) {
try { $ver = (Get-Content -LiteralPath $pkg -Raw | ConvertFrom-Json).version } catch { }
}
return [pscustomobject]@{
Kind = 'vendored'; File = $node.Source; Prefix = @($vendored)
Source = $vendored; Version = $ver
}
}
}
$global = Get-Command gnhf -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
}
# Crude cross-process lock so two concurrent runs can't clobber the registry.
$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 {
$runs = @(Read-LightsOutRuns | Where-Object { $_.id -ne $Run.id })
$runs += $Run
$json = ($runs | ConvertTo-Json -Depth 12)
# ConvertTo-Json emits a bare object (not an array) for a single element.
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)
Read-LightsOutRuns | Where-Object { $_.id -eq $Id } | Select-Object -First 1
}
# --- run directory resolution --------------------------------------------
# gnhf 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)
$runsRoot = Join-Path $RepoPath '.gnhf\runs'
if (-not (Test-Path -LiteralPath $runsRoot)) { return $null }
$exact = Join-Path $runsRoot $RunId
if (Test-Path -LiteralPath $exact) { return $exact }
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
}
function Test-LightsOutProcessAlive {
[CmdletBinding()]
param([Parameter(Mandatory)][int]$ProcessId,
[string]$StartedAtIso)
$p = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
if (-not $p) { return $false }
# Guard against PID reuse: a recycled PID will have started after we recorded the launch.
if ($StartedAtIso) {
try {
$expected = [datetime]::Parse($StartedAtIso).ToUniversalTime()
if ($p.StartTime.ToUniversalTime() -gt $expected.AddMinutes(2)) { return $false }
}
catch { }
}
$true
}
# --- 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
Join-Path $script:LightsOutVaultDir "$date - $repo - $title"
}
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) {
$esPath = Join-Path $RunDir 'end-state.json'
if (Test-Path -LiteralPath $esPath) {
try { $endState = Get-Content -LiteralPath $esPath -Raw | ConvertFrom-Json } catch { }
Copy-Item -LiteralPath $esPath -Destination (Join-Path $folder 'end-state.json') -Force -ErrorAction SilentlyContinue
}
foreach ($f in 'notes.md', 'prompt.md') {
$p = Join-Path $RunDir $f
if (Test-Path -LiteralPath $p) {
Copy-Item -LiteralPath $p -Destination (Join-Path $folder $f) -Force -ErrorAction SilentlyContinue
}
}
}
# git evidence, captured before any branch can be deleted
$commits = @(); $diffStat = ''; $changedFiles = @()
$base = $Run.baseCommit
if ($base -and (Test-Path -LiteralPath $Run.repoPath)) {
$range = "$base..HEAD"
$commits = @(git -C $Run.repoPath --no-pager log --oneline $range 2>$null)
$diffStat = (git -C $Run.repoPath --no-pager diff --stat $range 2>$null) -join "`n"
$changedFiles = @(git -C $Run.repoPath --no-pager diff --name-only $range 2>$null)
$patch = git -C $Run.repoPath --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')
# The debug log is large JSONL; only worth keeping when something went wrong.
if ($failed -and $RunDir) {
$lg = Join-Path $RunDir 'gnhf.log'
if (Test-Path -LiteralPath $lg) {
Copy-Item -LiteralPath $lg -Destination (Join-Path $folder 'gnhf.log') -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
}
$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 { }
$iterTxt = if ($endState -and $endState.PSObject.Properties['iterations']) { "$($endState.iterations)" } else { 'unknown' }
$okTxt = if ($endState -and $endState.PSObject.Properties['successCount']) { "$($endState.successCount)" } else { '?' }
$failTxt = if ($endState -and $endState.PSObject.Properties['failCount']) { "$($endState.failCount)" } else { '?' }
$stopCond = if ($endState -and $endState.PSObject.Properties['stopCondition'] -and $endState.stopCondition) { [string]$endState.stopCondition } else { '_none recorded_' }
$agentErr = if ($endState -and $endState.PSObject.Properties['agentError'] -and $endState.agentError) { [string]$endState.agentError } else { $null }
# Single-quoted YAML: backslashes in Windows paths are literal there, whereas in
# double-quoted YAML "D:\Repos" is an invalid escape and breaks the parser.
$yq = { param($s) "'" + ($s -replace "'", "''") + "'" }
$sourceList = @((& $yq $Run.repoPath))
if ($RunDir) { $sourceList += (& $yq $RunDir) }
$sourcesLine = 'sources: [' + ($sourceList -join ', ') + ']'
# ConvertFrom-Json turns ISO strings into local-time DateTime with Kind=Unspecified, which
# renders without an offset. Vault conventions require ISO 8601 *with* offset.
$isoFmt = {
param($v)
$dt = [datetime]$v
if ($dt.Kind -eq 'Unspecified') { $dt = [datetime]::SpecifyKind($dt, 'Local') }
$dt.ToString('o')
}
$startedIso = & $isoFmt $Run.startedAt
$endedIso = & $isoFmt $endedAt
$title = "$date $($Run.repoName) — $($Run.title)"
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine('---')
[void]$sb.AppendLine("title: $(& $yq $title)")
[void]$sb.AppendLine('type: lights-out-run')
[void]$sb.AppendLine("date: $date")
[void]$sb.AppendLine("created: $date")
[void]$sb.AppendLine("updated: $today")
[void]$sb.AppendLine('status: draft')
[void]$sb.AppendLine("tags: [lights-out, project/$repoTag, status/draft]")
[void]$sb.AppendLine($sourcesLine)
[void]$sb.AppendLine('owner: agent:lights-out')
[void]$sb.AppendLine('sensitivity: internal')
[void]$sb.AppendLine('---')
[void]$sb.AppendLine()
[void]$sb.AppendLine("# $title")
[void]$sb.AppendLine()
[void]$sb.AppendLine('> [!warning] Assessment pending')
[void]$sb.AppendLine('> Facts below are machine-captured. The judgment section is empty until Scout reviews it.')
[void]$sb.AppendLine('> Ask Scout: "how did last night''s run go?"')
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Run')
[void]$sb.AppendLine()
[void]$sb.AppendLine('| Field | Value |')
[void]$sb.AppendLine('|---|---|')
[void]$sb.AppendLine("| Run id | ``$($Run.id)`` |")
[void]$sb.AppendLine("| Repo | ``$($Run.repoPath)`` |")
[void]$sb.AppendLine("| Branch | ``$($Run.branch)`` |")
[void]$sb.AppendLine("| Agent | $($Run.agent) |")
if ($Run.PSObject.Properties['gnhfKind']) {
$gv = if ($Run.PSObject.Properties['gnhfVersion'] -and $Run.gnhfVersion) { " v$($Run.gnhfVersion)" } else { '' }
[void]$sb.AppendLine("| gnhf | $($Run.gnhfKind)$gv |")
}
[void]$sb.AppendLine("| Mode | $($Run.mode) |")
[void]$sb.AppendLine("| Started | $startedIso |")
[void]$sb.AppendLine("| Ended | $endedIso |")
[void]$sb.AppendLine("| Elapsed | $elapsed |")
[void]$sb.AppendLine("| Exit status | **$status** |")
[void]$sb.AppendLine("| Iterations | $iterTxt ($okTxt ok / $failTxt failed) |")
[void]$sb.AppendLine("| Commits | $($commits.Count) |")
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Objective')
[void]$sb.AppendLine()
[void]$sb.AppendLine('```text')
[void]$sb.AppendLine($Run.prompt)
[void]$sb.AppendLine('```')
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Stop condition')
[void]$sb.AppendLine()
[void]$sb.AppendLine($stopCond)
[void]$sb.AppendLine()
if ($agentErr) {
[void]$sb.AppendLine('## Agent error')
[void]$sb.AppendLine()
[void]$sb.AppendLine('```text')
[void]$sb.AppendLine($agentErr)
[void]$sb.AppendLine('```')
[void]$sb.AppendLine()
}
[void]$sb.AppendLine('## Commits')
[void]$sb.AppendLine()
if ($commits.Count -gt 0) {
[void]$sb.AppendLine('```text')
foreach ($c in $commits) { [void]$sb.AppendLine($c) }
[void]$sb.AppendLine('```')
}
else { [void]$sb.AppendLine('_No commits were made._') }
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Changed files')
[void]$sb.AppendLine()
if ($changedFiles.Count -gt 0) {
[void]$sb.AppendLine('```text')
[void]$sb.AppendLine($diffStat)
[void]$sb.AppendLine('```')
}
else { [void]$sb.AppendLine('_No files changed._') }
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Verification')
[void]$sb.AppendLine()
if ($Run.PSObject.Properties['verifyCommand'] -and $Run.verifyCommand) {
[void]$sb.AppendLine("Command: ``$($Run.verifyCommand)``")
[void]$sb.AppendLine()
$pre = if ($Run.PSObject.Properties['verifyExitCodeBefore']) { $Run.verifyExitCodeBefore } else { 'n/a' }
$post = if ($Run.PSObject.Properties['verifyExitCodeAfter']) { $Run.verifyExitCodeAfter } else { 'n/a' }
[void]$sb.AppendLine("- Exit code before the run: **$pre**")
[void]$sb.AppendLine("- Exit code after the run: **$post**")
[void]$sb.AppendLine()
[void]$sb.AppendLine('> Captured by the wrapper, independent of anything the agent claimed.')
}
else { [void]$sb.AppendLine('_No verification command was configured for this run._') }
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Assessment')
[void]$sb.AppendLine()
[void]$sb.AppendLine('_Pending Scout review. Replace this section, set `status:` to `active`, swap the')
[void]$sb.AppendLine('`status/draft` tag for `status/active`, and bump `updated:`._')
[void]$sb.AppendLine()
[void]$sb.AppendLine('- Met the stop condition:')
[void]$sb.AppendLine('- Quality:')
[void]$sb.AppendLine('- Scope discipline:')
[void]$sb.AppendLine('- Verdict: Mergeable / Needs follow-up run / Do not merge')
[void]$sb.AppendLine('- Recommended next action:')
[void]$sb.AppendLine()
[void]$sb.AppendLine('## Artifacts')
[void]$sb.AppendLine()
[void]$sb.AppendLine('Raw, immutable (vault rule 4 — never hand-edit):')
[void]$sb.AppendLine()
foreach ($a in 'diff.patch', 'notes.md', 'prompt.md', 'end-state.json', 'gnhf.log', 'console.tail.log') {
if (Test-Path -LiteralPath (Join-Path $folder $a)) { [void]$sb.AppendLine("- ``$a``") }
}
$summaryPath = Join-Path $folder 'summary.md'
Set-Content -LiteralPath $summaryPath -Value $sb.ToString() -Encoding UTF8
$summaryPath
}