scripts/specrew-handover.ps1
|
<#
.SYNOPSIS `specrew handover` — the agent-callable surface that persists the rolling session-handover BODY (F-174 iteration 011, T001, FR-022 / DF-7; design divergence drift D-005). .DESCRIPTION Subcommands: author [--from <file>] Persist the agent's rich handover BODY into the rolling handover, so a DIFFERENT session / host that resumes this project inherits the AUTHORED context, not placeholders. The INTERPRETIVE sections (open questions + working hypothesis) are the ones NO hook can author — this command is how the agent makes them durable. Reads a markdown file (`--from <file>`) or, with `--stdin`, the piped body — whose `## ` headers name the handover sections (the lead phrases below; short / tolerant headers are accepted). Handover sections (`## ` headers the body may carry — only these are written; others are reported + ignored): - What I just did - Why I'm stopping - Open questions - Working hypothesis - Recommended next step - Context the receiving host needs Flags (Unix-style, parsed from remaining args): --from <file>, --feature <ref>, --boundary <stage>, --host <kind>, --project-path <path>. This is the reachable replacement for the bare `Write-SpecrewHandoverContext` function, which is NOT a module export — agents invoke `specrew ...`, not module functions, so the callable surface is a command (drift D-005). Dispatcher-only (registered in scripts/specrew.ps1): it does NOT gate on project setup and is FAIL-OPEN (a missing section / unresolved feature degrades to a best-effort write, never a throw). The write goes through the SAME atomic writer the Stop hook uses (Write-SpecrewHandoverContext -> Write-SpecrewRollingHandoverContent), so it honors the centralized clobber guard (a hook-captured boundary packet is preserved, never clobbered) and keeps session-handover.md.old as the crash backup. #> [CmdletBinding()] param( [Parameter(Position = 0)] [string]$Command = 'author', [Parameter(ValueFromRemainingArguments = $true)] [string[]]$Rest ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # --- Unix-style flag parse (the CLI dispatcher forwards --flag tokens, which do not bind PowerShell-style) --- # NOTE: a flag-looking token ('--help') does NOT bind the positional $Command — it falls into $Rest — so help # detection must scan $Rest too, not just $Command. And stdin is read ONLY behind an explicit --stdin flag: an # open-but-empty redirected stdin (a harness / inherited pipe) would otherwise block ReadToEnd() forever. $fromFile = $null; $feature = $null; $boundary = $null; $targetHost = $null; $projectPath = $null $useStdin = $false; $showHelp = ($Command -in @('help', '--help', '-h')) $remaining = @($Rest | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) for ($i = 0; $i -lt $remaining.Count; $i++) { $arg = $remaining[$i] if ($arg -in @('--help', '-h', 'help')) { $showHelp = $true } elseif ($arg -ieq '--stdin') { $useStdin = $true } elseif ($arg -match '^--from=(.+)$') { $fromFile = $Matches[1] } elseif ($arg -ieq '--from' -and ($i + 1) -lt $remaining.Count) { $fromFile = $remaining[++$i] } elseif ($arg -match '^--feature=(.+)$') { $feature = $Matches[1] } elseif ($arg -ieq '--feature' -and ($i + 1) -lt $remaining.Count) { $feature = $remaining[++$i] } elseif ($arg -match '^--boundary=(.+)$') { $boundary = $Matches[1] } elseif ($arg -ieq '--boundary' -and ($i + 1) -lt $remaining.Count) { $boundary = $remaining[++$i] } elseif ($arg -match '^--host=(.+)$') { $targetHost = $Matches[1] } elseif ($arg -ieq '--host' -and ($i + 1) -lt $remaining.Count) { $targetHost = $remaining[++$i] } elseif ($arg -match '^--project-path=(.+)$') { $projectPath = $Matches[1] } elseif ($arg -ieq '--project-path' -and ($i + 1) -lt $remaining.Count) { $projectPath = $remaining[++$i] } } if ([string]::IsNullOrWhiteSpace($projectPath)) { $projectPath = (Get-Location).Path } # Normalize to an ABSOLUTE path: the writer's atomic .NET file APIs resolve a relative path against the # PROCESS cwd, not the PowerShell location (a named Windows/PowerShell trap). Fail-open if it does not exist. try { $resolved = (Resolve-Path -LiteralPath $projectPath -ErrorAction Stop).Path; if ($resolved) { $projectPath = $resolved } } catch { $null = $_ } . (Join-Path $PSScriptRoot 'internal/bootstrap/HandoverStore.ps1') function Write-HandoverError { param([string]$Message) Write-Host ("ERROR: {0}" -f $Message) -ForegroundColor Red Write-Host "Usage: specrew handover author [--from <file>] [--feature <ref>] [--boundary <stage>] [--host <kind>]" -ForegroundColor Yellow Write-Host " (use --stdin to read the markdown body piped on stdin instead of --from)" -ForegroundColor Yellow exit 1 } function Show-HandoverHelp { @' specrew handover - author the rolling cross-session handover body (agent-callable) Usage: specrew handover author [--from <file>] [--feature <ref>] [--boundary <stage>] [--host <kind>] <markdown> | specrew handover author --stdin (reads the body from stdin) Persist your re-entry / handover body so the NEXT session or host that resumes this project inherits your AUTHORED context - especially your open questions + working hypothesis, which NO hook can author - instead of artifact-derived placeholders. feature / boundary / host default to the committed session state; override with the flags. The write is atomic and honors the clobber guard (a hook-captured boundary packet is preserved). The body is markdown; each section is a '## ' header. Recognized sections (short / tolerant headers map): ## What I just did ## Why I'm stopping ## Open questions ## Working hypothesis ## Recommended next step ## Context the receiving host needs Unrecognized headers are reported and ignored. '@ | Write-Host } function Get-AuthorableTitles { # The Pillar-2 handover sections the agent can author: the fixed order MINUS the hook-captured section # (the verbatim boundary packet) MINUS the time-scoped conversation tail (both hook-owned, not agent-authored). # @()-wrap the captured set: it returns a single-element list PowerShell unwraps to a bare string (see # Get-SpecrewHandoverCapturedSections's contract note in HandoverStore.ps1). $reserved = @(Get-SpecrewHandoverCapturedSections) + @(Get-SpecrewHandoverTimeScopedSections) return @(Get-SpecrewHandoverSectionOrder | Where-Object { $reserved -notcontains $_ }) } function Resolve-AuthorableTitle { # Map an input '## ' header to a canonical authorable title: exact (normalized) first, then a tolerant # LEAD-PHRASE match (the canonical title up to its first '(' / '/' / '-' delimiter) so a short or # reordered header ('## Open questions', '## Working hypothesis', '## Context') still maps to the full # canonical title the writer expects. $null if nothing matches (the caller reports + skips it). param([string]$Header, [string[]]$Canonical) # Normalize: drop apostrophes (straight + the curly/modifier variants a copy-paste introduces, so a # body's "Agent's"/"Why I'm" still matches the canonical), collapse whitespace, lowercase. $norm = { param($s) ((($s -replace '[‘’ʼ'']', '') -replace '\s+', ' ').Trim()).ToLowerInvariant() } $h = & $norm $Header if ([string]::IsNullOrWhiteSpace($h)) { return $null } foreach ($c in $Canonical) { if ((& $norm $c) -eq $h) { return $c } } foreach ($c in $Canonical) { $cn = & $norm $c $lead = ((($cn -split '[(/-]', 2)[0]).Trim()) if ([string]::IsNullOrWhiteSpace($lead)) { $lead = $cn } if ($cn.StartsWith($h) -or $h.StartsWith($lead) -or $lead.StartsWith($h)) { return $c } if ($h.Length -ge 4 -and ($cn.Contains($h) -or $lead.Contains($h))) { return $c } } return $null } function Invoke-Author { # 1. Get the input markdown. --from <file> is the primary path; stdin is read ONLY behind an explicit # --stdin (so an inherited / harness pipe never blocks ReadToEnd on a body that never arrives). $raw = $null if (-not [string]::IsNullOrWhiteSpace($fromFile)) { $fp = $fromFile if (-not [System.IO.Path]::IsPathRooted($fp)) { $fp = Join-Path $projectPath $fp } if (-not (Test-Path -LiteralPath $fp -PathType Leaf)) { Write-HandoverError ("--from file not found: {0}" -f $fromFile) } $raw = Get-Content -LiteralPath $fp -Raw -Encoding UTF8 } elseif ($useStdin) { try { $raw = [Console]::In.ReadToEnd() } catch { $raw = $null } } else { Write-HandoverError 'No handover body provided. Pass --from <file> (a markdown packet with `## ` sections), or --stdin to read the body piped on stdin.' } if ([string]::IsNullOrWhiteSpace($raw)) { Write-HandoverError 'The handover body is empty. Provide a markdown packet with `## ` section headers.' } # 2. Parse the '## ' sections with the SAME reader a resume uses (it handles a frontmatter-less body: # bodyStart=0). Materialize to a temp file because ConvertFrom-SpecrewHandoverFile reads a -Path. $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("specrew-handover-in-" + [guid]::NewGuid().ToString('N') + '.md') [System.IO.File]::WriteAllText($tmp, $raw, [System.Text.UTF8Encoding]::new($false)) try { $parsed = ConvertFrom-SpecrewHandoverFile -Path $tmp } finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } if ($null -eq $parsed -or $null -eq $parsed.sections -or $parsed.sections.Count -eq 0) { Write-HandoverError 'No `## ` sections found in the input. The body must use `## <section>` markdown headers (run `specrew handover --help` for the section names).' } # 3. Map input headers -> canonical authorable titles. Unrecognized headers are reported + ignored, never written. $authorable = @(Get-AuthorableTitles) $sections = @{} $mapped = @(); $skipped = @(); $collisions = @() foreach ($key in $parsed.sections.Keys) { $canon = Resolve-AuthorableTitle -Header $key -Canonical $authorable $val = [string]$parsed.sections[$key] if ($null -ne $canon -and -not [string]::IsNullOrWhiteSpace($val)) { # Signal (don't silently swallow) two input headers that map to the SAME canonical section — the # second wins, so the author should know one of their headers was overwritten this write. if ($sections.Contains($canon)) { $collisions += ("'{0}' -> {1}" -f $key, $canon) } $sections[$canon] = $val; $mapped += $canon } else { $skipped += $key } } if ($sections.Count -eq 0) { Write-HandoverError 'None of the input `## ` headers matched a handover section. Use the canonical section names (run `specrew handover --help`).' } # 4. Resolve feature / boundary / host from the committed session state unless overridden by a flag. # Safe property reads (Set-StrictMode throws on a missing property) via a null-tolerant getter. $getProp = { param($o, $n) if ($null -eq $o) { return $null } $p = $o.PSObject.Properties[$n] if ($p) { return $p.Value } else { return $null } } $resFeature = $feature; $resBoundary = $boundary; $resHost = $targetHost $ctxPath = Join-Path $projectPath '.specrew/start-context.json' if (Test-Path -LiteralPath $ctxPath) { try { $ctx = Get-Content -LiteralPath $ctxPath -Raw | ConvertFrom-Json $ss = & $getProp $ctx 'session_state' if ([string]::IsNullOrWhiteSpace($resFeature)) { $f = & $getProp $ss 'feature_ref'; if (-not [string]::IsNullOrWhiteSpace([string]$f)) { $resFeature = [string]$f } } if ([string]::IsNullOrWhiteSpace($resBoundary)) { $b = & $getProp $ss 'boundary_type'; if (-not [string]::IsNullOrWhiteSpace([string]$b)) { $resBoundary = [string]$b } } if ([string]::IsNullOrWhiteSpace($resHost)) { $hv = & $getProp $ss 'host'; if ([string]::IsNullOrWhiteSpace([string]$hv)) { $hv = & $getProp $ctx 'host' } if (-not [string]::IsNullOrWhiteSpace([string]$hv)) { $resHost = [string]$hv } } } catch { $null = $_ } } if ([string]::IsNullOrWhiteSpace($resHost)) { # Best-effort live-host detection (the handover provider's helper, co-loaded above) -> else the honest 'host'. $envHost = Get-SpecrewRuntimeHostFromEnv $resHost = if (-not [string]::IsNullOrWhiteSpace($envHost)) { $envHost } else { 'host' } } $head = '' try { $head = ([string](& git -C $projectPath rev-parse --short HEAD 2>$null)).Trim() } catch { $null = $_ } $nowUtc = (Get-Date).ToUniversalTime().ToString('o') $handoverDir = Join-Path $projectPath '.specrew/handover' $written = Write-SpecrewHandoverContext -HandoverDir $handoverDir -FromHost $resHost -RecordedAt $nowUtc ` -Source 'agent' -FromCommit $head -ActiveFeature $resFeature -ActiveBoundary $resBoundary -Sections $sections $featureLabel = if ([string]::IsNullOrWhiteSpace($resFeature)) { '(none)' } else { $resFeature } $boundaryLabel = if ([string]::IsNullOrWhiteSpace($resBoundary)) { '(pre-boundary)' } else { $resBoundary } Write-Host '' Write-Host 'Specrew handover authored' -ForegroundColor Cyan Write-Host '-------------------------' -ForegroundColor Cyan Write-Host (" file: {0}" -f $written) -ForegroundColor Green Write-Host (" feature: {0}" -f $featureLabel) Write-Host (" boundary: {0}" -f $boundaryLabel) Write-Host (" host: {0}" -f $resHost) Write-Host (" sections written: {0}" -f (($mapped | Select-Object -Unique) -join ', ')) -ForegroundColor Green if ($skipped.Count -gt 0) { Write-Host (" sections ignored (unrecognized header): {0}" -f ($skipped -join ', ')) -ForegroundColor DarkGray } if ($collisions.Count -gt 0) { Write-Host (" WARNING: multiple headers mapped to one section (last wins): {0}" -f ($collisions -join '; ')) -ForegroundColor Yellow } Write-Host '' Write-Host 'The next session / host that resumes this project will inherit these sections (not placeholders).' -ForegroundColor Cyan } if ($showHelp) { Show-HandoverHelp; exit 0 } # A flag-looking $Command (e.g. '--help') fell into the help path above; otherwise an unknown subcommand errors. $sub = if ($Command -like '-*') { 'author' } else { $Command.ToLowerInvariant() } switch ($sub) { 'author' { Invoke-Author } default { Write-HandoverError ("Unknown subcommand '{0}'. Supported: author (run 'specrew handover --help')." -f $Command) } } exit 0 |