scripts/internal/bootstrap/HandoverStore.ps1
|
<#
.SYNOPSIS Read/write the Proposal 130 session handover (schema:v1) and its index. .DESCRIPTION Resource accessor (IDesign). COMPOSES Proposal 130's already-specified handover schema - it does NOT re-author it. F-174 is Proposal 130's first implementation; the authoritative spec is proposals/130-specrew-switch-to-host-handover.md (Pillar 2 body + Pillar 4a SessionEnd path). Do not diverge from 130: - frontmatter `schema: v1` + source / from_host / recorded_at / from_commit / active_feature / active_boundary - the six Pillar-2 body sections (order fixed by Get-SpecrewHandoverSectionOrder) The LIVE write/read path is the Stop-event ROLLING handover (`.specrew/handover/session-handover.md`, one always-latest file overwritten in place; see below). The timestamped SessionEnd write/read path (`<timestamp>-session-end-...` + an index.yml) is SUPERSEDED and removed (F-174 T041). This accessor only does the I/O; reads fail open. Feature 174 (FR-009, FR-010, FR-021). #> function Get-SpecrewHandoverSectionOrder { # The Proposal 130 Pillar-2 handover body, in order (verbatim section titles). F-174 iter-10 (T002, # FR-022) appends a 7th HOOK-OWNED section, 'Recent conversation ...', extending 130's fixed-6 with the # best-effort transcript tail (recorded as drift D-017 - a 174-authorized additive extension; 174 already # evolved this schema in iter-9 with the mechanical/interpretive ownership split). Mechanical by default # (it is not in the agent-owned set), so the complement logic includes it automatically. return @( 'What I just did (last 3-5 turns or last boundary work)', "Why I'm stopping (the switch trigger)", 'Open questions / pending clarifications', "Agent's working hypothesis / mental model", 'Recommended next-immediate-step', "Context the receiving host needs that artifacts don't carry", 'Recent conversation (last few exchanges, hook-captured)', # F-174 iter-11 (T002, DF-3): the VERBATIM rendered boundary VERDICT packet, captured from the transcript # by the Stop hook so a resume inherits the AUTHORED packet (not placeholders). A THIRD ownership category # (Get-SpecrewHandoverCapturedSections) - written when the hook captures a marker-bearing packet, PRESERVED # otherwise (the clobber guard, T003). It is NEITHER mechanical (it must not be refreshed/placeholdered # every stop) NOR agent-owned (the hook writes it, so the "non-placeholder == agent-authored" provenance # invariant must NOT include it). 'Authored boundary packet (captured at stop)' ) } function Get-SpecrewHandoverAgentOwnedSections { # F-174 iter-9: the INTERPRETIVE sections only the agent can author (via Write-SpecrewHandoverContext); # the hook never writes interpretive content, so a non-placeholder interpretive section IS the agent # provenance (no schema field needed). The hook PRESERVES these across stops within a boundary. return @( 'Open questions / pending clarifications', "Agent's working hypothesis / mental model" ) } function Get-SpecrewHandoverCapturedSections { # F-174 iter-11 (T002, DF-3): the THIRD ownership category - sections the HOOK populates by CAPTURING the # agent's actually-rendered boundary packet verbatim from the transcript (not synthesizing it, not the agent # calling a function). Excluded from BOTH the agent-owned set (so the "non-placeholder == agent-authored" # provenance invariant stays true) AND the mechanical complement (so a generic Stop with no new packet does # NOT refresh/placeholder it - the clobber guard preserves the last captured packet within its boundary). # NOTE: this returns a single-element list; PowerShell unwraps it to a bare string on return, so EVERY caller # must re-wrap with @(...) (or iterate with foreach, which treats a scalar as one item) before indexing [0] or # using -contains. All in-tree callers do; do NOT add a leading-comma "fix" - it nests the array and breaks # -contains (the element becomes Object[], not the title string). return @('Authored boundary packet (captured at stop)') } function Get-SpecrewHandoverMechanicalSections { # F-174 iter-9: the HOOK-owned sections - refreshed every material stop from the git/fs session delta # (they describe "now"). Derived as the complement of the agent-owned set AND the captured set (iter-11 T002) # within the fixed order, so a title rename in Get-SpecrewHandoverSectionOrder cannot silently desync the # lists, and the captured-packet section can never fall back into the refreshed-every-stop mechanical bucket. $reserved = @(Get-SpecrewHandoverAgentOwnedSections) + @(Get-SpecrewHandoverCapturedSections) return @(Get-SpecrewHandoverSectionOrder | Where-Object { $reserved -notcontains $_ }) } function Get-SpecrewHandoverTimeScopedSections { # F-174 iter-10: the HOOK-owned sections that are TIME-scoped, not BOUNDARY-scoped - i.e. "recent # exchanges", which should carry across a boundary change (cross-session continuity is the whole point of # capturing them), unlike the era-scoped narrative mechanicals ("What I just did" / "Context ...") which a # boundary change resets. Used by the agent body-author preserve to boundary-gate the narrative mechanicals # while keeping the conversation tail. Currently just the conversation section (the only hook-ONLY section # the agent never authors). Matched against the canonical title in Get-SpecrewHandoverSectionOrder. return @(Get-SpecrewHandoverSectionOrder | Where-Object { $_ -like 'Recent conversation*' }) } function Get-SpecrewHandoverPlaceholderMarker { # The body-section placeholder the HOOK writes when the agent has not authored a section for the # current boundary. Starts with "(placeholder" so the structural detector recognizes it without an # exact-string dependency (F-174 iter-5, failure-mode B). [OutputType([string])] param([Parameter()][AllowNull()][string] $Boundary) $b = if ([string]::IsNullOrWhiteSpace($Boundary)) { 'this boundary' } else { $Boundary } return ("(placeholder - the agent has not authored this section for {0} yet; the next session falls back to the artifact-derived orientation)" -f $b) } function Test-SpecrewHandoverSectionAuthored { # PURE: is a body section rich AGENT content vs a hook placeholder? Structural (no exact-marker # dependency): empty/whitespace, the iter-5 "(placeholder ..." marker, and the legacy # "(no relevant content)" all count as NOT authored. F-174 iter-5. [OutputType([bool])] param([Parameter()][AllowNull()][string] $Content) if ([string]::IsNullOrWhiteSpace($Content)) { return $false } $t = $Content.Trim() if ($t -like '(placeholder*') { return $false } if ($t -ieq '(no relevant content)') { return $false } return $true } function ConvertFrom-SpecrewHandoverFile { [CmdletBinding()] [OutputType([pscustomobject])] param([Parameter(Mandatory)][string] $Path) try { $lines = @(Get-Content -LiteralPath $Path -ErrorAction Stop) } catch { return $null } $fm = @{} $bodyStart = 0 if ($lines.Count -gt 0 -and $lines[0].Trim() -eq '---') { for ($i = 1; $i -lt $lines.Count; $i++) { if ($lines[$i].Trim() -eq '---') { $bodyStart = $i + 1; break } $kv = $lines[$i] -split ':\s*', 2 if ($kv.Count -eq 2) { $fm[$kv[0].Trim()] = $kv[1].Trim() } } } # Parse the Pillar-2 body sections (## <title> ... until the next ## or EOF). F-174 iter-5: the body # is read back so consumers surface the rich agent-authored content + the detector can flag a # placeholder. The H1 (# Session Handover ...) is skipped (single #; the regex requires exactly ##). # F-174 iter-11 (T002, DF-3): the captured-packet section (Get-SpecrewHandoverCapturedSections) embeds the # agent's VERBATIM boundary packet, which is itself six '## ' headers (## What I Just Did, ...). A flat '^##' # split would shred it on read-back - every inner '## ' starting a bogus section, so the captured section keeps # only the (near-empty) text before its first inner header -> Test-SpecrewHandoverSectionAuthored returns false # -> placeholder -> the clobber guard AND the resume both break. So once INSIDE a captured section, a '## ' line # closes it ONLY when it EXACTLY matches another KNOWN canonical handover title (the packet's own headers like # '## What I Just Did' do NOT match the canonical '## What I just did (last 3-5 turns ...)'); otherwise the line # is captured-body content. Non-captured sections parse EXACTLY as before (no behavior change off this path). $knownTitles = @(Get-SpecrewHandoverSectionOrder) $capturedTitles = @(Get-SpecrewHandoverCapturedSections) $sections = [ordered]@{} $curTitle = $null $inCaptured = $false $capturedIdx = -1 $curLines = New-Object System.Collections.Generic.List[string] for ($i = $bodyStart; $i -lt $lines.Count; $i++) { $line = $lines[$i] if ($line -match '^##\s+(.*\S)\s*$') { $candidate = $Matches[1].Trim() # F-174 iter-11 (review-signoff P2-1) TERMINAL-AWARE captured-section close. Once INSIDE the captured # (verbatim boundary packet) section, a '## ' line closes it ONLY if it is a canonical title that sorts # AFTER the captured section in Get-SpecrewHandoverSectionOrder. The captured section is the LAST entry, # so nothing sorts after it -> the packet's OWN '## ' headers are all swallowed as captured body, even # ones that EXACTLY match a canonical handover title (e.g. '## What I just did (last 3-5 turns ...)'). # The old `-notcontains` guard closed the section on ANY canonical-title collision, shredding the packet # to its bare marker (the resume then inherited a useless stub - the exact SC-012/SC-015 failure). This # self-corrects if the order ever grows a real section after the captured one. if ($inCaptured) { $candIdx = [Array]::IndexOf($knownTitles, $candidate) if ($candIdx -lt 0 -or $candIdx -le $capturedIdx) { $curLines.Add($line) | Out-Null; continue } } if ($null -ne $curTitle) { $sections[$curTitle] = (($curLines -join "`n").Trim()) } $curTitle = $candidate $inCaptured = ($capturedTitles -contains $candidate) $capturedIdx = if ($inCaptured) { [Array]::IndexOf($knownTitles, $candidate) } else { -1 } $curLines = New-Object System.Collections.Generic.List[string] } elseif ($null -ne $curTitle) { $curLines.Add($line) | Out-Null } } if ($null -ne $curTitle) { $sections[$curTitle] = (($curLines -join "`n").Trim()) } [pscustomobject]@{ schema = $fm['schema']; source = $fm['source']; from_host = $fm['from_host'] recorded_at = $fm['recorded_at']; from_commit = $fm['from_commit'] active_feature = $fm['active_feature'] active_boundary = $fm['active_boundary'] # F-174 iter-10 (T003): the AUTHORIZED-gate + workshop-phase frontmatter (distinct from active_boundary, # which is the WORKING position). Present only when applicable; $null otherwise. last_authorized_boundary = $fm['last_authorized_boundary'] last_verdict = $fm['last_verdict'] workshop_done = $fm['workshop_done'] workshop_remaining = $fm['workshop_remaining'] sections = $sections } } # --- F-174 iteration 4: Stop-event ROLLING handover (supersedes the timestamped SessionEnd model) --- function Get-SpecrewRollingHandoverPath { [OutputType([string])] param([Parameter(Mandatory)][string] $HandoverDir) return (Join-Path $HandoverDir 'session-handover.md') } function Write-SpecrewRollingHandoverContent { # Shared writer (F-174 iter-5 floor/body split): frontmatter FLOOR + the 6 Pillar-2 body sections # from $Sections (a missing/blank section -> the placeholder marker). Used by BOTH the hook # floor-writer (Write-SpecrewRollingHandover) and the agent body-author (Write-SpecrewHandoverContext). [OutputType([string])] param( [Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][string] $Source, [Parameter(Mandatory)][string] $FromHost, [Parameter(Mandatory)][string] $RecordedAt, [Parameter()][string] $FromCommit, [Parameter()][string] $ActiveFeature, [Parameter()][string] $ActiveBoundary, # F-174 iter-10 (T003): the AUTHORIZED-gate + workshop-phase frontmatter. HOOK-computed (the hook passes # them explicitly, even empty = clear); the agent body-author does NOT pass them -> they are PRESERVED # from the existing file (so authoring the body never strips this hook-derived state). The # $PSBoundParameters check distinguishes "preserve" (unbound) from "clear" (bound-but-empty). [Parameter()][AllowNull()][string] $LastAuthorizedBoundary, [Parameter()][AllowNull()][string] $LastVerdict, [Parameter()][AllowNull()][string] $WorkshopDone, [Parameter()][AllowNull()][string] $WorkshopRemaining, [Parameter()][System.Collections.IDictionary] $Sections = @{} ) # T003 preserve: a caller that did not supply a gate/workshop field inherits the existing file's value. $prevFm = $null if (Test-Path -LiteralPath $Path) { $prevFm = ConvertFrom-SpecrewHandoverFile -Path $Path if ($null -ne $prevFm) { if (-not $PSBoundParameters.ContainsKey('LastAuthorizedBoundary')) { $LastAuthorizedBoundary = [string]$prevFm.last_authorized_boundary } if (-not $PSBoundParameters.ContainsKey('LastVerdict')) { $LastVerdict = [string]$prevFm.last_verdict } if (-not $PSBoundParameters.ContainsKey('WorkshopDone')) { $WorkshopDone = [string]$prevFm.workshop_done } if (-not $PSBoundParameters.ContainsKey('WorkshopRemaining')) { $WorkshopRemaining = [string]$prevFm.workshop_remaining } } } # F-174 iter-11 (T003, SC-015) CLOBBER GUARD, CENTRALIZED so BOTH callers (the hook floor-writer AND the agent # body-author Write-SpecrewHandoverContext) honor it. The captured-packet section (the THIRD ownership category) # is HOOK-captured verbatim; neither caller's own per-writer preserve touches it (it is excluded from both the # mechanical and agent-owned sets). So if THIS write does not supply a fresh captured packet, carry the existing # one forward UNCHANGED - but ONLY while it is authored AND still belongs to the CURRENT active_boundary. A # forward boundary change leaves it absent -> it falls to the placeholder (the prior boundary's packet is stale); # a later generic Stop / a placeholder refresh / the agent authoring its own sections all PRESERVE it. Mutates a # LOCAL copy, never the caller's dict. $writeSections = @{} foreach ($k in $Sections.Keys) { $writeSections[$k] = $Sections[$k] } if ($null -ne $prevFm -and $prevFm.sections -and $prevFm.sections.Count -gt 0 -and (([string]$prevFm.active_boundary) -eq ([string]$ActiveBoundary))) { foreach ($ct in (Get-SpecrewHandoverCapturedSections)) { $freshSupplied = $writeSections.Contains($ct) -and -not [string]::IsNullOrWhiteSpace([string]$writeSections[$ct]) if ($freshSupplied) { continue } if ($prevFm.sections.Contains($ct) -and (Test-SpecrewHandoverSectionAuthored -Content ([string]$prevFm.sections[$ct]))) { $writeSections[$ct] = [string]$prevFm.sections[$ct] } } } # Frontmatter values are single-line key: value; collapse any newline so a value never breaks the block. $clean = { param($v) if ([string]::IsNullOrWhiteSpace([string]$v)) { '' } else { (([string]$v) -replace '\s+', ' ').Trim() } } $marker = Get-SpecrewHandoverPlaceholderMarker -Boundary $ActiveBoundary $out = New-Object System.Collections.Generic.List[string] foreach ($l in @( '---', 'schema: v1', "source: $Source", "from_host: $FromHost", "recorded_at: $RecordedAt", "from_commit: $FromCommit", "active_feature: $ActiveFeature", "active_boundary: $ActiveBoundary")) { $out.Add($l) | Out-Null } # T003: emit the gate + workshop lines ONLY when present, so the frontmatter stays quiet outside the # intake window / on legacy contexts. active_boundary above is the WORKING position; these are distinct. if (-not [string]::IsNullOrWhiteSpace($LastAuthorizedBoundary)) { $out.Add("last_authorized_boundary: $(& $clean $LastAuthorizedBoundary)") | Out-Null } if (-not [string]::IsNullOrWhiteSpace($LastVerdict)) { $out.Add("last_verdict: $(& $clean $LastVerdict)") | Out-Null } if (-not [string]::IsNullOrWhiteSpace($WorkshopDone)) { $out.Add("workshop_done: $(& $clean $WorkshopDone)") | Out-Null } if (-not [string]::IsNullOrWhiteSpace($WorkshopRemaining)) { $out.Add("workshop_remaining: $(& $clean $WorkshopRemaining)") | Out-Null } foreach ($l in @('---', '', '# Session Handover (rolling)', '')) { $out.Add($l) | Out-Null } foreach ($title in (Get-SpecrewHandoverSectionOrder)) { $content = if ($writeSections.Contains($title) -and -not [string]::IsNullOrWhiteSpace([string]$writeSections[$title])) { [string]$writeSections[$title] } else { $marker } $out.Add("## $title") | Out-Null; $out.Add('') | Out-Null $out.Add($content) | Out-Null; $out.Add('') | Out-Null } # Crash-safe replace (F-174 T050, maintainer finding): a kill landing mid-write (or between an agent's # delete+recreate) must never lose the handover. Write the full content to a sidecar, then promote it # ATOMICALLY, keeping the previous version as session-handover.md.old ([IO.File]::Replace = Win32 # ReplaceFile - swap + backup in one atomic call). A crash mid-write leaves the intact current file; a # crash between write and promote leaves current + a complete .new; after promote, .old is the backup the # reader falls back to. Fallback to plain Set-Content only if the atomic path fails (exotic FS). # M3 (iter-10): per-PROCESS sidecar so two hooks firing at once (e.g. PostToolUse + Stop) don't race on a # shared "$Path.new". The .old backup name stays fixed (best-effort backup). $newPath = "$Path.$PID.new" try { Set-Content -LiteralPath $newPath -Value ($out -join "`n") -Encoding UTF8 if (Test-Path -LiteralPath $Path) { [System.IO.File]::Replace($newPath, $Path, "$Path.old") } else { Move-Item -LiteralPath $newPath -Destination $Path -Force } } catch { $primaryErr = $_ try { # Exotic-FS fallback: plain in-place write (loses the .old backup but keeps the handover current). Set-Content -LiteralPath $Path -Value ($out -join "`n") -Encoding UTF8 Remove-Item -LiteralPath $newPath -Force -ErrorAction SilentlyContinue } catch { # M3: a TOTAL write failure (atomic Replace AND the in-place fallback both failed - read-only / # locked / AV'd / network-locked file) must NOT be swallowed: the handover silently stops updating # and the next session inherits stale content with NO signal. Surface to stderr + the # handover-journal (best-effort), mirroring the hollow path, so a frozen handover is diagnosable. [Console]::Error.WriteLine(("[specrew-handover] WARN HANDOVER_WRITE_FAILED path='{0}' primary='{1}' fallback='{2}' - the handover did NOT update this stop." -f $Path, $primaryErr.Exception.Message, $_.Exception.Message)) try { $jpath = Join-Path (Split-Path -Parent (Split-Path -Parent $Path)) 'runtime/handover-journal.jsonl' $jdir = Split-Path -Parent $jpath if ($jdir -and -not (Test-Path -LiteralPath $jdir)) { New-Item -ItemType Directory -Path $jdir -Force | Out-Null } $rec = [pscustomobject]@{ event = 'handover-write-failed'; recorded_at = $RecordedAt; path = $Path; primary_error = $primaryErr.Exception.Message; fallback_error = $_.Exception.Message } ($rec | ConvertTo-Json -Compress) | Add-Content -LiteralPath $jpath -Encoding UTF8 } catch { $null = $_ } Remove-Item -LiteralPath $newPath -Force -ErrorAction SilentlyContinue } } return $Path } function Write-SpecrewRollingHandover { # The HOOK floor-writer (F-174 iter-5). Refreshes the frontmatter FLOOR on each material Stop and # PRESERVES the agent-authored body WHEN it exists FOR THE CURRENT boundary; otherwise writes the # placeholder body (so a boundary-cross without authoring yields a DETECTABLE hollow body, not stale # content). The agent authors the rich body via Write-SpecrewHandoverContext. ONE local, gitignored, # always-latest file, overwritten in place. Returns the path. (supersedes the iter-4 direct-write) [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string] $HandoverDir, [Parameter(Mandatory)][string] $Source, # the host stop event: stop | agentStop | Stop [Parameter(Mandatory)][string] $FromHost, [Parameter(Mandatory)][string] $RecordedAt, # ISO-8601 (caller-supplied; deterministic) [Parameter()][string] $FromCommit, [Parameter()][string] $ActiveFeature, [Parameter()][string] $ActiveBoundary, # F-174 iter-9 (hook-primary): the hook passes the freshly-computed MECHANICAL section content (the # git/fs session delta) here as title -> content. Mechanical sections are HOOK-OWNED and written # fresh every material stop; a missing/blank mechanical title falls to the placeholder marker (the # truly-empty / git-unavailable case). Interpretive sections stay AGENT-owned (preserved below). [Parameter()][System.Collections.IDictionary] $MechanicalSections = @{}, # F-174 iter-10 (T003): the HOOK-computed gate + workshop frontmatter, passed through (bound, even # empty = authoritative clear) to the shared writer. The agent body-author does not set these. [Parameter()][AllowNull()][string] $LastAuthorizedBoundary, [Parameter()][AllowNull()][string] $LastVerdict, [Parameter()][AllowNull()][string] $WorkshopDone, [Parameter()][AllowNull()][string] $WorkshopRemaining, # F-174 iter-11 (T002, DF-3): the VERBATIM boundary packet the hook captured from the transcript this stop. # Passed ONLY when the caller (Update-SpecrewRollingHandover) judged it a FRESH, CURRENT packet (its marker # range brackets the active boundary). When supplied it OVERWRITES the captured section; when absent the # shared writer's centralized clobber guard PRESERVES the existing one (within its boundary). Blank = no # fresh packet this stop (the common case), not an authoritative clear. [Parameter()][AllowNull()][string] $CapturedPacket ) if (-not (Test-Path -LiteralPath $HandoverDir)) { New-Item -ItemType Directory -Path $HandoverDir -Force | Out-Null } $path = Get-SpecrewRollingHandoverPath -HandoverDir $HandoverDir # F-174 iter-9 SECTION OWNERSHIP (supersedes the iter-5 all-or-nothing preserve). Merge the body by # ownership so it is NEVER hollow as long as the hook captured a delta, while an agent overlay survives: # - MECHANICAL (What I just did / Why I'm stopping / Recommended next / Context): written FRESH from # $MechanicalSections every stop - they describe "now", so the hook owns them. # - INTERPRETIVE (Open questions / Working hypothesis): AGENT-owned - preserve the EXISTING content iff # it is authored (non-placeholder) AND for the CURRENT boundary; else leave it to the placeholder # marker. The hook never writes interpretive content, so non-placeholder == the agent authored it # (the placeholder state IS the provenance; no schema field needed). A boundary change resets them. $bodySections = @{} foreach ($mt in (Get-SpecrewHandoverMechanicalSections)) { if ($MechanicalSections.Contains($mt) -and -not [string]::IsNullOrWhiteSpace([string]$MechanicalSections[$mt])) { $bodySections[$mt] = [string]$MechanicalSections[$mt] } } if ((Test-Path -LiteralPath $path) -or (Test-Path -LiteralPath "$path.old")) { # Same .old crash-fallback as the reader: keep an agent overlay even if the live file was lost mid-write. $existing = if (Test-Path -LiteralPath $path) { ConvertFrom-SpecrewHandoverFile -Path $path } else { $null } if ($null -eq $existing) { $existing = ConvertFrom-SpecrewHandoverFile -Path "$path.old" } if ($null -ne $existing -and $existing.sections -and $existing.sections.Count -gt 0 -and (([string]$existing.active_boundary) -eq ([string]$ActiveBoundary))) { foreach ($it in (Get-SpecrewHandoverAgentOwnedSections)) { if ($existing.sections.Contains($it) -and (Test-SpecrewHandoverSectionAuthored -Content ([string]$existing.sections[$it]))) { $bodySections[$it] = [string]$existing.sections[$it] } } } } # F-174 iter-11 (T002): inject a FRESH captured packet (the caller already judged it current). It is the third # ownership category, so it goes STRAIGHT into the body (not via the mechanical/agent-owned merges above); # when absent, the shared writer PRESERVES the existing captured section (the centralized clobber guard). if (-not [string]::IsNullOrWhiteSpace($CapturedPacket)) { foreach ($ct in (Get-SpecrewHandoverCapturedSections)) { $bodySections[$ct] = [string]$CapturedPacket } } return (Write-SpecrewRollingHandoverContent -Path $path -Source $Source -FromHost $FromHost -RecordedAt $RecordedAt ` -FromCommit $FromCommit -ActiveFeature $ActiveFeature -ActiveBoundary $ActiveBoundary -Sections $bodySections ` -LastAuthorizedBoundary $LastAuthorizedBoundary -LastVerdict $LastVerdict ` -WorkshopDone $WorkshopDone -WorkshopRemaining $WorkshopRemaining) } function Write-SpecrewHandoverContext { # The AGENT body-author (F-174 iter-5, failure-mode B). The agent persists its rich re-entry/boundary # packet AS the handover body, then renders the packet FROM the file - so what the human sees at the # boundary == what the next session inherits (the render==persist integrity property). Writes the # floor + the agent's rich sections via the shared writer. Returns the path. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string] $HandoverDir, [Parameter(Mandatory)][string] $FromHost, [Parameter(Mandatory)][string] $RecordedAt, # ISO-8601 (caller-supplied; deterministic) [Parameter()][string] $Source = 'agent', [Parameter()][string] $FromCommit, [Parameter()][string] $ActiveFeature, [Parameter()][string] $ActiveBoundary, [Parameter(Mandatory)][System.Collections.IDictionary] $Sections # the agent's rich 6-section content ) if (-not (Test-Path -LiteralPath $HandoverDir)) { New-Item -ItemType Directory -Path $HandoverDir -Force | Out-Null } $path = Get-SpecrewRollingHandoverPath -HandoverDir $HandoverDir # F-174 iter-10 (T002 fix F2): the agent body-author does NOT capture the HOOK-owned mechanical sections - # notably 'Recent conversation', which only the Stop/PostToolUse hook reads from the host transcript. Left # alone, the shared writer would placeholder any section the agent's packet omits, so authoring the # boundary packet ERASES the hook-captured conversation. Mirror Write-SpecrewRollingHandover's overlay # preserve in reverse: carry forward any AUTHORED hook-owned mechanical section the agent omitted. Scoped # to the mechanical complement + gated on authored (surgical, NOT a blanket preserve-on-missing - # interpretive sections the agent intentionally clears stay cleared). $merged = @{} foreach ($k in $Sections.Keys) { $merged[$k] = $Sections[$k] } if (Test-Path -LiteralPath $path) { $existing = ConvertFrom-SpecrewHandoverFile -Path $path if ($null -ne $existing -and $existing.sections -and $existing.sections.Count -gt 0) { # BOUNDARY GATING (Prop-145 P2 finding): a narrative mechanical the agent omits is ERA-scoped, so it # must NOT be resurrected from a PRIOR boundary (that would leak stale "What I just did" / "Context" # across a boundary change) - same boundary-gate the sibling hook writer applies to its preserve. # The TIME-scoped conversation tail is the exception: it carries across boundaries (cross-session # continuity is the point), so it is preserved regardless of the existing file's boundary. $sameBoundary = (([string]$existing.active_boundary) -eq ([string]$ActiveBoundary)) $timeScoped = Get-SpecrewHandoverTimeScopedSections foreach ($mt in (Get-SpecrewHandoverMechanicalSections)) { $agentSupplied = $merged.Contains($mt) -and -not [string]::IsNullOrWhiteSpace([string]$merged[$mt]) if ($agentSupplied) { continue } if (-not ($existing.sections.Contains($mt) -and (Test-SpecrewHandoverSectionAuthored -Content ([string]$existing.sections[$mt])))) { continue } if (($timeScoped -contains $mt) -or $sameBoundary) { $merged[$mt] = [string]$existing.sections[$mt] } } } } return (Write-SpecrewRollingHandoverContent -Path $path -Source $Source -FromHost $FromHost -RecordedAt $RecordedAt ` -FromCommit $FromCommit -ActiveFeature $ActiveFeature -ActiveBoundary $ActiveBoundary -Sections $merged) } function Get-SpecrewRollingHandover { # Read the single rolling handover (session-handover.md) with a `fresh` flag. Fail open. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $HandoverDir, [Parameter(Mandatory)][string] $NowUtc, [Parameter()][int] $FreshnessHours = 24 ) $path = Get-SpecrewRollingHandoverPath -HandoverDir $HandoverDir # Crash-recovery fallback (F-174 T050): if the live file is missing or unparseable (a kill landed inside # an agent's delete+recreate window, or mid-write), fall back to the .old backup the atomic writer keeps. # One version stale beats nothing - the resume rides it plus the in-flight disk scan. $parsed = $null if (Test-Path -LiteralPath $path) { $parsed = ConvertFrom-SpecrewHandoverFile -Path $path } # M3 (iter-10): treat a STRUCTURALLY-INVALID parse (a truncated/corrupt file whose frontmatter has no # recorded_at) the SAME as missing/unparseable and fall back to .old - previously only a fully-null parse # triggered the fallback, so a partially-written file was accepted as a valid (but broken) handover. $isValid = { param($p) ($null -ne $p) -and -not [string]::IsNullOrWhiteSpace([string]$p.recorded_at) } if (-not (& $isValid $parsed) -and (Test-Path -LiteralPath "$path.old")) { $oldParsed = ConvertFrom-SpecrewHandoverFile -Path "$path.old" if (& $isValid $oldParsed) { $parsed = $oldParsed } } if (-not (& $isValid $parsed)) { return $null } $fresh = $false try { $rec = [datetime]::Parse($parsed.recorded_at).ToUniversalTime() $now = [datetime]::Parse($NowUtc).ToUniversalTime() $age = ($now - $rec).TotalHours $fresh = ($age -ge 0 -and $age -le $FreshnessHours) } catch { $fresh = $false } $parsed | Add-Member -NotePropertyName fresh -NotePropertyValue $fresh -Force $parsed | Add-Member -NotePropertyName path -NotePropertyValue $path -Force return $parsed } function Get-SpecrewRuntimeHostFromEnv { # F-174 iter-10 (T004): best-effort detection of which AI host is running THIS process, from its env # signals. The design-workshop refresh runs the handover provider WITHOUT --host-kind (it is invoked by # the agent, not the per-host hook dispatcher) and, in the pre-specify window, the anchor has no committed # host either - so from_host fell back to the literal 'host' sentinel (dogfood 2026-06-12). Detecting the # LIVE host here is correct-by-construction across the shared .agents skill root (codex vs antigravity # resolve by their DISTINCT session vars, which per-host skill baking could not), never stale (live env, # not a stored marker), and degrades to $null -> the honest 'host' when nothing matches (never a deceptive # value). Mirrors the per-host signal sets in hosts/<kind>/handlers.ps1 (Get-<Kind>Signals) - keep in sync; # the credential-only vars (CODEX_API_KEY etc., often globally set) are deliberately excluded to avoid a # false match in a different host's session. Returns the host kind or $null. [CmdletBinding()] [OutputType([string])] param() $signals = [ordered]@{ codex = @('CODEX_SESSION_ID', 'OPENAI_CODEX_CLI') claude = @('CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_PROJECT_DIR') copilot = @('COPILOT_AGENT_SESSION_ID', 'COPILOT_CLI', 'COPILOT_CLI_BINARY_VERSION') cursor = @('CURSOR_AGENT', 'CURSOR_TRACE_ID') antigravity = @('ANTIGRAVITY_SESSION_ID') } foreach ($kind in $signals.Keys) { foreach ($var in $signals[$kind]) { if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($var))) { return $kind } } } return $null } function Update-SpecrewRollingHandover { # F-174 iter-9.1: THE single handover-save orchestration. Every trigger source - the Stop hook, the # PostToolUse hook, and the design-workshop skill - calls THIS; none re-implement the save. It resolves # the current feature/boundary/host from committed session state (+ the branch fallback for the # anchorless workshop window), runs the material-change gate (so it is cheap to call on EVERY tool call), # computes the git/fs delta, authors the MECHANICAL sections (accumulating "What I just did" newest-first # across the boundary window, reset on a boundary change), writes via the atomic writer, and records a # true-empty hollow. Composes ClassificationEngine (Test-SpecrewHandoverMaterialChange) + # ProjectMetadataAccessor (Resolve-SpecrewBranchFeatureRef, Get-SpecrewSessionDelta) - all co-loaded by # the caller. Returns a result object; callers stay fail-open. (F-174 iter-9.1.) [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter()][AllowNull()][string] $HostKind, # authoritative current host (--host-kind); else resolved [Parameter()][string] $Source = 'stop', # trigger label: stop | agentStop | PostToolUse | workshop [Parameter()][string] $NowUtc = ((Get-Date).ToUniversalTime().ToString('o')), [Parameter()][AllowNull()][string] $TranscriptPath = $null, # F-174 iter-10 (T002): host transcript_path for conversation capture [Parameter()][AllowNull()][string] $LastAssistantMessage = $null ) $getProp = { param($o, $n) if ($null -eq $o) { return $null } $p = $o.PSObject.Properties[$n] if ($p) { return $p.Value } else { return $null } } # Current context from the committed session state. (F-174 iter-10: T002 adds best-effort transcript # capture and T003 surfaces the authorized gate + workshop phase - all read from this same committed state.) $feature = $null; $boundary = $null; $fromHost = 'host' $lastAuthBoundary = $null; $lastVerdict = $null $ctxPath = Join-Path $ProjectRoot '.specrew/start-context.json' if (Test-Path -LiteralPath $ctxPath) { try { $ctx = Get-Content -LiteralPath $ctxPath -Raw | ConvertFrom-Json $ss = & $getProp $ctx 'session_state' $feature = & $getProp $ss 'feature_ref' $boundary = & $getProp $ss 'boundary_type' $h = & $getProp $ss 'host'; if ([string]::IsNullOrWhiteSpace($h)) { $h = & $getProp $ctx 'host' } if (-not [string]::IsNullOrWhiteSpace($h)) { $fromHost = [string]$h } # T003: the AUTHORIZED gate (deterministic governance state, not agent behavior) - DISTINCT from # session_state.boundary_type (the WORKING position above). last_authorized_boundary + the richest # human-legible proof from verdict_history[-1]. $be = & $getProp $ctx 'boundary_enforcement' if ($null -ne $be) { $lab = & $getProp $be 'last_authorized_boundary' if (-not [string]::IsNullOrWhiteSpace($lab)) { $lastAuthBoundary = [string]$lab } $vhArr = @(& $getProp $be 'verdict_history') if ($vhArr.Count -gt 0) { $lastV = $vhArr[$vhArr.Count - 1] $vtext = & $getProp $lastV 'verdict_text' if (-not [string]::IsNullOrWhiteSpace($vtext)) { $lastVerdict = [string]$vtext $vhuman = & $getProp $lastV 'authorizing_human' $vcommit = & $getProp $lastV 'auth_commit_hash' if (-not [string]::IsNullOrWhiteSpace($vhuman)) { $lastVerdict += " by $vhuman" } if (-not [string]::IsNullOrWhiteSpace($vcommit)) { $lastVerdict += " @$vcommit" } } } } } catch { $null = $_ } } # Anchorless workshop window: resolve the feature from the branch so the handover is surfaceable (T050). if ([string]::IsNullOrWhiteSpace([string]$feature)) { $feature = Resolve-SpecrewBranchFeatureRef -ProjectRoot $ProjectRoot } # T004: when neither the trigger nor the committed session-state named a host (the pre-specify workshop # refresh, where the anchor is not yet committed), detect the LIVE host from its env signals before # falling back to the literal 'host' sentinel. Only fills the gap (runs when $fromHost is still 'host'), # so it never overrides a known session-state host; --host-kind below still wins as the authoritative source. if ($fromHost -eq 'host') { $envHost = Get-SpecrewRuntimeHostFromEnv if (-not [string]::IsNullOrWhiteSpace($envHost)) { $fromHost = $envHost } } # The trigger passes the authoritative host; prefer it over the start-context value or the 'host' default. if (-not [string]::IsNullOrWhiteSpace($HostKind)) { $fromHost = $HostKind } # T003: the workshop phase, surfaced ONLY while in-flight (the pre-specify intake window); quiet otherwise. # Reads the SAME deterministic disk truth the bootstrap directive uses (Get-SpecrewWorkshopProgress); # guarded because the workshop-skill/test paths may not have co-loaded ProjectMetadataAccessor. $workshopDone = $null; $workshopRemaining = $null if (-not [string]::IsNullOrWhiteSpace([string]$feature) -and (Get-Command Get-SpecrewWorkshopProgress -ErrorAction SilentlyContinue)) { try { $wp = Get-SpecrewWorkshopProgress -ProjectRoot $ProjectRoot -FeatureRef ([string]$feature) if ($null -ne $wp -and $wp.in_flight) { $wd = @($wp.done); $wr = @($wp.remaining) if ($wd.Count -gt 0) { $workshopDone = ($wd -join ', ') } if ($wr.Count -gt 0) { $workshopRemaining = ($wr -join ', ') } } } catch { $null = $_ } } $handoverDir = Join-Path $ProjectRoot '.specrew/handover' # Material-change gate (the call-cheapness guarantee - PostToolUse fires on every tool call): refresh only # when the boundary moved OR there is a tracked-file change since the last write. $existing = Get-SpecrewRollingHandover -HandoverDir $handoverDir -NowUtc $NowUtc $lastBoundary = if ($null -ne $existing) { $existing.active_boundary } else { $null } $hasChange = $false # Prop-145 round-6 (HIGH, write-path half of Finding 1): this `git status` is the HOTTER instance of the # parent-repo-scan defect - it fires on EVERY PostToolUse, not once per session-start. Gate it on the SAME # repo-root check Get-SpecrewSessionDelta uses: from a nested / non-repo root under a parent git repo or # worktree, an ungated `git status` walks the whole parent tree (unbounded -> hangs the hook; try/catch # cannot bound a hung process) AND would report the PARENT'S dirty files as this project's change. Not a # repo root -> no tracked change to detect here (consistent with the empty Get-SpecrewSessionDelta below). if (Test-SpecrewIsGitRepoRoot -ProjectRoot $ProjectRoot) { try { $st = (& git -C $ProjectRoot status --porcelain 2>$null); $hasChange = -not [string]::IsNullOrWhiteSpace(($st -join "`n")) } catch { $null = $_ } } $mc = Test-SpecrewHandoverMaterialChange -CurrentBoundary $boundary -LastBoundary $lastBoundary -HasTrackedChange $hasChange -HandoverExists ($null -ne $existing) # Prop-145 round-4 (HIGH): a conversation-only turn (clean tree, same boundary) is NOT a git/boundary "material # change", but it IS new context that T002 promises to capture. END-OF-TURN events (Stop/agentStop/stop) fire # once per turn, so refresh on them regardless - capturing the latest transcript tail + recorded_at. PostToolUse # (per-tool-call) and the workshop refresh STAY gated (the call-cheapness guarantee). The activity-bullet logic # below stays delta-gated so a no-delta refresh never flushes real work out of the 6-bullet window. $isEndOfTurn = $Source -in @('Stop', 'agentStop', 'stop') if (-not $mc.material -and -not $isEndOfTurn) { return [pscustomobject]@{ wrote = $false; reason = $mc.reason; source = $Source; feature = $feature; boundary = $boundary } } $refreshReason = if ($mc.material) { $mc.reason } else { 'end-of-turn conversation refresh (no git/boundary delta)' } $head = '' try { $head = ([string](& git -C $ProjectRoot rev-parse --short HEAD 2>$null)).Trim() } catch { $null = $_ } $sinceCommit = if ($null -ne $existing) { [string]$existing.from_commit } else { $null } $delta = Get-SpecrewSessionDelta -ProjectRoot $ProjectRoot -SinceCommit $sinceCommit $featureLabel = if ([string]::IsNullOrWhiteSpace([string]$feature)) { '(no active feature)' } else { [string]$feature } $boundaryLabel = if ([string]::IsNullOrWhiteSpace([string]$boundary)) { '(pre-boundary / workshop)' } else { [string]$boundary } # One activity line for THIS refresh, accumulated newest-first across the boundary window. Lead with the # user's REAL changed files; the Specrew-managed scaffolding is noted by count, never listed (dogfood: # the ~53 managed paths were drowning the real work + filling the file cap). $userShownList = @($delta.user_files) $userShown = if ($userShownList.Count -gt 0) { ($userShownList -join ', ') } else { '(none)' } if (([int]$delta.user_file_count) -gt $userShownList.Count) { $userShown = "$userShown, +more" } $managedNote = if (([int]$delta.managed_file_count) -gt 0) { (" (+{0} Specrew-managed)" -f $delta.managed_file_count) } else { '' } $fileNote = " [$userShown]$managedNote" $commitNote = if ($delta.new_commit_count -gt 0) { ("; {0} new commit(s): {1}" -f $delta.new_commit_count, ((@($delta.new_commits)) -join ' | ')) } else { '' } $stamp = if ($NowUtc.Length -ge 19) { ($NowUtc.Substring(0, 19) + 'Z') } else { $NowUtc } $stopBullet = ("- [{0}] ({1}) {2} changed user file(s){3}; HEAD {4} ({5}){6}" -f $stamp, $Source, $delta.user_file_count, $fileNote, $delta.head_short, $delta.head_subject, $commitNote) $activityTitle = 'What I just did (last 3-5 turns or last boundary work)' $priorBullets = @() if ($null -ne $existing -and ([string]$existing.active_boundary -eq [string]$boundary) -and $existing.sections -and $existing.sections.Contains($activityTitle)) { $prev = [string]$existing.sections[$activityTitle] if (-not [string]::IsNullOrWhiteSpace($prev) -and $prev -notlike '(placeholder*') { $priorBullets = @($prev -split "`n" | Where-Object { $_ -match '^\s*-\s' }) } } # Only PREPEND a new activity bullet when this refresh did REAL work (changed user files or new commits). A # no-delta end-of-turn refresh (a pure analysis/conversation turn) carries the prior bullets forward UNCHANGED, # so a run of conversation-only turns cannot flush the last real-work bullet out of the 6-bullet window with # "0 changed" noise (Prop-145 round-4). With nothing prior + no real work, the single bullet is the floor. $hasRealWork = (([int]$delta.user_file_count) -gt 0) -or (([int]$delta.new_commit_count) -gt 0) $activity = if ($hasRealWork) { ((@($stopBullet) + $priorBullets) | Select-Object -First 6) -join "`n" } elseif ($priorBullets.Count -gt 0) { (@($priorBullets) | Select-Object -First 6) -join "`n" } else { $stopBullet } $whyStopping = ("Hook-captured at trigger '{0}' (the agent did not author a handover this turn). Boundary: {1}. Refresh reason: {2}." -f $Source, $boundaryLabel, $refreshReason) $recNext = if (([int]$delta.user_file_count) -gt 0) { ("Resume feature {0} at boundary {1}. {2} of YOUR file(s) are uncommitted [{3}]{4} - review/commit them before advancing." -f $featureLabel, $boundaryLabel, $delta.user_file_count, $userShown, $managedNote) } elseif ($delta.has_uncommitted) { ("Resume feature {0} at boundary {1}. Only Specrew-managed scaffolding is uncommitted ({2} file(s)) - that is the init baseline; commit it at a boundary." -f $featureLabel, $boundaryLabel, $delta.managed_file_count) } else { ("Resume feature {0} at boundary {1}. Working tree is clean; continue the next lifecycle step." -f $featureLabel, $boundaryLabel) } $uncommittedNote = if (([int]$delta.user_file_count) -gt 0) { $mn = if (([int]$delta.managed_file_count) -gt 0) { (" ({0} Specrew-managed files also uncommitted.)" -f $delta.managed_file_count) } else { '' } (" Your uncommitted work: {0}.{1}" -f $userShown, $mn) } elseif ($delta.has_uncommitted) { (" No user files changed; {0} Specrew-managed scaffolding file(s) uncommitted." -f $delta.managed_file_count) } else { '' } $context = ("branch {0}, HEAD {1} ({2}). Active feature {3}, boundary {4}.{5}" -f $delta.branch, $delta.head_short, $delta.head_subject, $featureLabel, $boundaryLabel, $uncommittedNote) # F-174 iter-10 (T002, FR-022): the best-effort conversation tail. Fail-open + additive - only when the # capture component is loaded (the handover provider co-loads it; the workshop-skill/test paths may not) # and it yields content. Bounded inside Get-SpecrewConversationTail; never grows with the session. $conversation = $null if (Get-Command Get-SpecrewConversationTail -ErrorAction SilentlyContinue) { try { $conversation = Get-SpecrewConversationTail -HostKind $fromHost -TranscriptPath $TranscriptPath -LastAssistantMessage $LastAssistantMessage } catch { $conversation = $null } } # F-174 iter-11 (T002, FR-022 / DF-3): capture the VERBATIM rendered boundary packet + compute the forward-only # working boundary. The agent renders/authors the packet; PERSISTING it is mechanical here so a resume inherits # the AUTHORED packet, not placeholders (DF-3). Guarded on the same helpers the verdict capture needs (the Stop # handover provider co-loads shared-governance; the workshop-skill / test paths do not and correctly skip both # the packet capture AND the marker-based active-boundary advance, falling back to the session-state boundary). # active_boundary = the forward-MOST of {the session-state working position, the prior file value, the marker's # FROM} - the marker is a forward-only floor (the maintainer's "set active_boundary from the captured marker"), # and it NEVER regresses an already-forward boundary. The packet is WRITTEN only when the new active boundary is # NOT already PAST the marker's TO (the freshness/stale guard): a packet from a boundary we have moved beyond is # dropped to the placeholder, while a forward boundary change naturally REPLACES the prior packet. Fail-open. $activeBoundary = $boundary $capturedPacketBody = $null if (-not [string]::IsNullOrWhiteSpace($TranscriptPath) -and (Get-Command Get-SpecrewCapturedBoundaryPacket -ErrorAction SilentlyContinue) -and (Get-Command Get-SpecrewBoundaryOrder -ErrorAction SilentlyContinue) -and (Get-Command Normalize-SpecrewCanonicalBoundaryType -ErrorAction SilentlyContinue)) { try { $pkt = Get-SpecrewCapturedBoundaryPacket -TranscriptPath $TranscriptPath if ($pkt.Found) { $bOrder = @(Get-SpecrewBoundaryOrder) $idxOf = { param($b) if ([string]::IsNullOrWhiteSpace([string]$b)) { -1 } else { [Array]::IndexOf($bOrder, (Normalize-SpecrewCanonicalBoundaryType -Boundary $b)) } } $boundaryIdx = & $idxOf $boundary $priorIdx = & $idxOf $lastBoundary $fromIdx = & $idxOf $pkt.FromBoundary $toIdx = & $idxOf $pkt.ToBoundary # forward-most; a -1 (unrecognized boundary) never bumps the cursor (mirrors the T004 -ge 0 guards). $newActiveIdx = ([int[]]@($boundaryIdx, $priorIdx, $fromIdx) | Measure-Object -Maximum).Maximum if ($newActiveIdx -ge 0 -and $newActiveIdx -lt $bOrder.Count) { $activeBoundary = $bOrder[$newActiveIdx] } # FRESHNESS: write iff the active boundary is within the marker's [FROM..TO] range, i.e. not already # past TO. A -1 on either side fails OPEN (cannot rank -> do not reject), mirroring T004. $isFresh = ($toIdx -lt 0) -or ($newActiveIdx -lt 0) -or ($newActiveIdx -le $toIdx) if ($isFresh) { $capturedPacketBody = [string]$pkt.PacketBody } } } catch { [Console]::Error.WriteLine("[specrew-handover] WARN PACKET_CAPTURE_FAILED $($_.Exception.Message)") } } $mechanical = @{ $activityTitle = $activity "Why I'm stopping (the switch trigger)" = $whyStopping 'Recommended next-immediate-step' = $recNext "Context the receiving host needs that artifacts don't carry" = $context } if (-not [string]::IsNullOrWhiteSpace($conversation)) { $mechanical['Recent conversation (last few exchanges, hook-captured)'] = $conversation } Write-SpecrewRollingHandover -HandoverDir $handoverDir -Source $Source -FromHost $fromHost ` -RecordedAt $NowUtc -FromCommit $head -ActiveFeature $feature -ActiveBoundary $activeBoundary ` -MechanicalSections $mechanical -CapturedPacket $capturedPacketBody ` -LastAuthorizedBoundary $lastAuthBoundary -LastVerdict $lastVerdict ` -WorkshopDone $workshopDone -WorkshopRemaining $workshopRemaining | Out-Null # F-174 iteration 011 (T004, FR-026 / decision f174-i011-verdict-authority-stop-hook): THE HOOK IS THE # VERDICT AUTHORITY. On an end-of-turn stop, read the transcript for the human's verdict on the most recently # rendered boundary packet (Get-SpecrewCapturedBoundaryVerdict, tied to the packet marker) and, if it is a # CLEAR approval that advances the gate FORWARD, record the authorization with the captured verdict + # evidence-source 'hook-captured-from-transcript'. This is what replaces boundary-sync's DELETED fabrication # (T005): the gate advances ONLY on a real, captured human verdict - never invented. Guarded: runs only when # BOTH the reader and the writer are loaded (the Stop-hook handover provider co-loads shared-governance; the # design-workshop-refresh / test paths do not and correctly skip the authorization). Identity is left # UNATTRIBUTED unless a host surface proves it (none reliably does yet) - honest over invented. CONTIGUOUS # one-boundary-at-a-time (the gate-contiguity guard below; the reader's contradiction/ambiguity guards already # gate Found); fully fail-open - a capture failure degrades to "un-authorized", surfaced by the resume (T006), # and NEVER blocks the stop. if ($isEndOfTurn -and -not [string]::IsNullOrWhiteSpace($TranscriptPath) -and (Get-Command Get-SpecrewCapturedBoundaryVerdict -ErrorAction SilentlyContinue) -and (Get-Command Add-SpecrewBoundaryAuthorization -ErrorAction SilentlyContinue) -and (Get-Command Get-SpecrewBoundaryOrder -ErrorAction SilentlyContinue)) { try { $captured = Get-SpecrewCapturedBoundaryVerdict -TranscriptPath $TranscriptPath if ($captured.Found) { $bOrder = @(Get-SpecrewBoundaryOrder) $fromIdx = [Array]::IndexOf($bOrder, (Normalize-SpecrewCanonicalBoundaryType -Boundary $captured.FromBoundary)) $toIdx = [Array]::IndexOf($bOrder, (Normalize-SpecrewCanonicalBoundaryType -Boundary $captured.ToBoundary)) $authIdx = if ([string]::IsNullOrWhiteSpace([string]$lastAuthBoundary)) { -1 } else { [Array]::IndexOf($bOrder, (Normalize-SpecrewCanonicalBoundaryType -Boundary $lastAuthBoundary)) } # GATE CONTIGUITY (one-boundary-at-a-time). Forward-only ($toIdx > $authIdx) is NECESSARY but NOT # SUFFICIENT: with lastAuth=plan and a marker 'tasks -> before-implement', a forward-only check would # apply the human's REAL before-implement approval while the 'plan -> tasks' gate was NEVER # authorized - skipping a gate. So require the capture to advance EXACTLY one gate from the cursor: # (1) the marker's FROM must EQUAL last_authorized_boundary ($fromIdx == $authIdx), and # (2) the marker's TO must be the IMMEDIATE successor of FROM ($toIdx == $fromIdx + 1). # Anything else (from != cursor, or a multi-gate jump like tasks -> review-signoff) is REJECTED. We # do NOT rewrite FROM to the cursor to force contiguity - that would mask the skip; we reject the # capture as unsafe, leave the gate where it is (the resume surfaces awaiting-verdict so the human # re-confirms the missing boundary), and journal the mismatch. This ALSO gives idempotence: once the # gate has advanced, the same marker's FROM no longer equals the (now-advanced) cursor, so a re-fired # Stop is a no-op. if ($fromIdx -ge 0 -and $fromIdx -eq $authIdx -and $toIdx -eq ($fromIdx + 1)) { Add-SpecrewBoundaryAuthorization -ProjectRoot $ProjectRoot ` -CurrentBoundary $captured.FromBoundary -AuthorizedBoundary $captured.ToBoundary ` -AuthorizingHuman 'unattributed' -VerdictText $captured.VerdictText ` -EvidenceSource 'hook-captured-from-transcript' | Out-Null } elseif ($toIdx -gt $authIdx) { # A CLEAR approval whose marker is forward but NON-CONTIGUOUS with the cursor: refuse to apply it # (applying it would skip an earlier unauthorized gate). Record the mismatch for forensics; the # gate stays put and the resume (T006) surfaces awaiting-verdict for the contiguous boundary. [Console]::Error.WriteLine(("[specrew-handover] WARN MARKER_CURSOR_MISMATCH captured '{0}->{1}' is not contiguous with the authorized cursor '{2}'; NOT authorizing (one-boundary-at-a-time)." -f $captured.FromBoundary, $captured.ToBoundary, $lastAuthBoundary)) try { $mmJournal = Join-Path $ProjectRoot '.specrew/runtime/handover-journal.jsonl' $mmDir = Split-Path -Parent $mmJournal if ($mmDir -and -not (Test-Path -LiteralPath $mmDir)) { New-Item -ItemType Directory -Path $mmDir -Force | Out-Null } (([pscustomobject]@{ event = 'marker-cursor-mismatch'; recorded_at = $NowUtc; captured_from = $captured.FromBoundary; captured_to = $captured.ToBoundary; authorized_cursor = [string]$lastAuthBoundary; source = $Source }) | ConvertTo-Json -Compress) | Add-Content -LiteralPath $mmJournal -Encoding UTF8 } catch { $null = $_ } } } } catch { [Console]::Error.WriteLine("[specrew-handover] WARN VERDICT_CAPTURE_FAILED $($_.Exception.Message)") } } # M2 (iter-10): hollow = the git delta GENUINELY produced nothing (git unavailable / the fail-safe empty # shape), NOT the formatted-section count. The old check counted $mechanical.Values, which are always- # non-empty formatted strings, so $mechAuthored was always >=4 and $hollow was always false -> the WARN + # journal backstop below was dead code, and a genuinely information-poor handover surfaced as authoritative. $hollow = ([string]::IsNullOrWhiteSpace([string]$delta.branch) -and ` [string]::IsNullOrWhiteSpace([string]$delta.head_short) -and ` (-not $delta.has_uncommitted) -and ` (([int]$delta.new_commit_count) -eq 0)) if ($hollow) { [Console]::Error.WriteLine(("[specrew-handover] WARN HOLLOW_HANDOVER boundary='{0}' reason='{1}' - the hook captured no session delta (git unavailable?); the next session inherits a hollow handover." -f $boundary, $mc.reason)) try { $jpath = Join-Path $ProjectRoot '.specrew/runtime/handover-journal.jsonl' $jdir = Split-Path -Parent $jpath if ($jdir -and -not (Test-Path -LiteralPath $jdir)) { New-Item -ItemType Directory -Path $jdir -Force | Out-Null } $rec = [pscustomobject]@{ event = 'hollow-handover-at-stop'; recorded_at = $NowUtc; boundary = $boundary; feature = $feature; from_host = $fromHost; material_reason = $mc.reason; source = $Source } ($rec | ConvertTo-Json -Compress) | Add-Content -LiteralPath $jpath -Encoding UTF8 } catch { $null = $_ } } return [pscustomobject]@{ wrote = $true; reason = $mc.reason; source = $Source; feature = $feature; boundary = $boundary; from_host = $fromHost; hollow = $hollow } } |