scripts/internal/bootstrap/ConversationCaptureAccessor.ps1
|
<#
.SYNOPSIS F-174 iteration 010 (T002): best-effort conversation capture for the rolling handover (FR-022). .DESCRIPTION Resource accessor (IDesign). Reads a host session transcript - the path the Stop-hook payload hands us (Claude/Codex/Cursor `transcript_path`, Copilot `transcriptPath`, Cursor `CURSOR_TRANSCRIPT_PATH` env) - and renders a BOUNDED "Recent conversation" tail so a resuming agent on ANY hook-capable host sees recent dialogue, not just the git delta. The hook is otherwise transcript-blind by design; this is the additive, best-effort enrichment. FORMAT-RESILIENT 4-tier ladder (the providers explicitly warn their transcript format "is not a stable interface"; a schema change must DEGRADE gracefully, never break): Tier 1 structured per-host parse of the transcript tail (claude/codex/copilot/cursor schemas). Tier 2 raw bounded tail when the file is present but its schema is unrecognized (drift) - with a VISIBLE note so degradation is detectable, not silent. Tier 3 the event payload's last_assistant_message (a documented Codex payload field, immune to file-format drift) when the file itself is unreadable. Floor an honest placeholder naming the host (no transcript exposed / antigravity has no hooks). Bounded by turn-count AND a HARD char cap, INDEPENDENT of session length (the handover is one file overwritten in place, so this never grows with the session - the cap is per-snapshot). The captured text is RAW dialogue for an LLM consumer, not parsed into a durable schema, so it survives format drift. Pure I/O + string building. StrictMode-safe property access throughout; fail-open (never throws). #> Set-StrictMode -Version Latest function Get-SpecrewConversationProp { # StrictMode-safe property read: the value if present on the object, else $null. param([AllowNull()]$Object, [Parameter(Mandatory)][string]$Name) if ($null -eq $Object) { return $null } $m = $Object.PSObject.Properties.Match($Name) if ($m.Count -gt 0) { return $m[0].Value } return $null } function Get-SpecrewConversationContentText { # Pull text from a content value that is EITHER a plain string (copilot data.content) OR an array of # {type,text} parts (claude/cursor/codex content[]). Parts without a `text` field (tool_use/tool_result) # are skipped, so tool noise never lands in the tail. param([AllowNull()]$Content) $parts = New-Object System.Collections.Generic.List[string] if ($null -eq $Content) { return , $parts.ToArray() } if ($Content -is [string]) { if (-not [string]::IsNullOrWhiteSpace($Content)) { $parts.Add([string]$Content) | Out-Null } return , $parts.ToArray() } foreach ($c in @($Content)) { if ($null -eq $c) { continue } if ($c -is [string]) { if (-not [string]::IsNullOrWhiteSpace($c)) { $parts.Add([string]$c) | Out-Null }; continue } $t = Get-SpecrewConversationProp $c 'text' if (-not [string]::IsNullOrWhiteSpace([string]$t)) { $parts.Add([string]$t) | Out-Null } } return , $parts.ToArray() } function Get-SpecrewTranscriptTailLines { # Fast bounded tail reader for hook hot paths. Get-Content -Tail is seconds-scale on large Codex JSONL # transcripts on Windows; Stop hooks call this several times, so use a backward byte window and drop the # partial leading line when the window starts mid-file. Fail-open to the old reader if anything unexpected # happens. [OutputType([string[]])] param( [Parameter()][AllowNull()][string]$Path, [int]$MaxLines = 500, [int]$MaxBytes = 2097152 ) if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return @() } try { $fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)) try { $length = $fs.Length if ($length -le 0) { return @() } $bytesToRead = [int64][Math]::Min($length, [int64][Math]::Max(4096, $MaxBytes)) [void]$fs.Seek(-$bytesToRead, [System.IO.SeekOrigin]::End) $bytes = New-Object byte[] ([int]$bytesToRead) $offset = 0 while ($offset -lt $bytesToRead) { $read = $fs.Read($bytes, $offset, ([int]$bytesToRead - $offset)) if ($read -le 0) { break } $offset += $read } $text = [System.Text.Encoding]::UTF8.GetString($bytes, 0, $offset) $parts = @($text -split "`r?`n") if ($bytesToRead -lt $length -and $parts.Count -gt 1) { $parts = @($parts | Select-Object -Skip 1) } return @($parts | Select-Object -Last $MaxLines) } finally { $fs.Dispose() } } catch { try { return @(Get-Content -LiteralPath $Path -Tail $MaxLines -Encoding UTF8 -ErrorAction Stop) } catch { return @() } } } function Test-SpecrewHumanVerdictToken { # F-174 iteration 011 (T004, FR-026): classify a HUMAN turn's response to a boundary VERDICT packet — # CONSERVATIVELY. The gate-stop packet offers: (1) Approve as-is, (2) Approve with instructions, (3) Send # back, (4) Discuss prompt #N. A human types one of those, a bare option number, an "approve [X -> Y] [with # instructions]" line, or a send-back / discuss / question. SAFETY RULE (the maintainer's): only IsApproval # when the turn CLEARLY approves; anything negated / send-back / discuss / ambiguous / a bare question -> NOT # an approval, so the caller records the crossing un-authorized rather than inventing one. Pure string logic; # never throws. [OutputType([pscustomobject])] param([Parameter()][AllowNull()][string]$Text) $r = [pscustomobject]@{ Action = 'none'; IsApproval = $false; IsSendBack = $false; IsDiscuss = $false; NamedBoundaries = @(); ApprovalOption = $null } if ([string]::IsNullOrWhiteSpace($Text)) { return $r } $t = ($Text -replace '\s+', ' ').Trim() $lower = $t.ToLowerInvariant() # Extract any boundary the human NAMED ("X -> Y", "X → Y", "approve for <b>", "approve <b>"). Used by the # caller only as a cross-check AGAINST the packet marker: a named boundary that contradicts the marker makes # the verdict ambiguous (-> un-authorized). The marker, not this, is the primary tie. $named = New-Object System.Collections.Generic.List[string] foreach ($m in [regex]::Matches($lower, '\b(specify|clarify|plan|tasks|before-implement|implement|review-signoff|review|retro|iteration-closeout|iteration|closeout|feature-closeout|feature)\b')) { if ($named -notcontains $m.Value) { $named.Add($m.Value) | Out-Null } } $r.NamedBoundaries = $named.ToArray() # Send-back / reject FIRST: a turn that says "send back" (even alongside praise) is NOT an approval. # F-174 iter-11 (review-signoff P7-1): the "changes needed/required/requested" clause must NOT fire on a # NEGATED change clause - "approved, no changes needed" / "no further changes required" are APPROVALS, not # send-backs. The negative lookbehind (variable-length, .NET-supported) rejects the clause when a negation # word precedes "changes" within the same sentence; an affirmative "changes needed" still trips send-back. if ($lower -match '\bsend\s*back\b' -or $lower -match '\breject(ed|ing)?\b' -or $lower -match '^\s*3\b' -or $lower -match '(?<!\b(?:no|zero|without|nothing|none|not)\b[^.!?]{0,20})\bchanges?\s+(needed|required|requested)\b') { $r.IsSendBack = $true; $r.Action = 'send-back'; return $r } # Discuss a specific prompt — NOT an authorization (discussion is not approval). if ($lower -match '\bdiscuss\b' -or $lower -match '^\s*4\b' -or $lower -match '\bprompt\s*#?\d') { $r.IsDiscuss = $true; $r.Action = 'discuss'; return $r } # Negated / deferred approval -> NOT an approval (defends "do not approve", "not yet", "hold off ... approve"). if ($lower -match "\b(do\s*not|don'?t|never|not\s+yet|hold\s+off|wait|stop)\b[^.!?]{0,24}\bapprov") { return $r } if ($lower -match "\bapprov\w*\b[^.!?]{0,16}\b(later|after|once|when|unless)\b") { return $r } # F-174 iter-11 (review-signoff P3-1, INTEGRITY): a verdict approval is imperative/declarative, NEVER a # question. An approve-bearing INTERROGATIVE ("approve?", "is this ready to approve?", "can you explain # before I approve?", "should I approve this or not?") is deliberation, not authorization - reject it so the # Stop-hook capture can NEVER fabricate an approval the human did not actually give (FR-026 / SC-013). if ($t.EndsWith('?')) { return $r } # CLEAR approval: an "approve"/"approved" verb (incl. canonical "approved for <boundary>"), OR a bare option # 1/2 where the WHOLE turn is just that number. Deliberately NARROW — "start"/"proceed"/"continue"/"ok"/"yes" # are NOT treated as boundary approvals (too ambiguous against the safety rule); they fall to pending so the # human re-confirms rather than risk an invented approval. $optionOnly = [regex]::Match($lower, '^\s*(?:option\s*)?([12])\s*[.):]?\s*$') if ($lower -match '\bapprove(d|s)?\b' -or $optionOnly.Success) { if ($optionOnly.Success) { $r.ApprovalOption = [int]$optionOnly.Groups[1].Value } $r.IsApproval = $true; $r.Action = 'approve'; return $r } return $r } function Test-SpecrewBoundaryPacketLikeText { # Markerless fallback is allowed only when the preceding assistant turn looks like the re-entry packet the # human was approving. This is intentionally structural and bounded, not a full prose validator. [OutputType([bool])] param( [Parameter()][AllowNull()][string]$Text, [Parameter()][AllowNull()][string]$ApprovalPhrase ) if ([string]::IsNullOrWhiteSpace($Text)) { return $false } $headers = @( 'What I Just Did', 'Why I Stopped', 'What Needs Your Review', 'What Happens Next', 'Discussion Prompts', 'What I Need From You' ) $score = 0 foreach ($h in $headers) { if ($Text -match ('(?im)^\s*#{1,6}\s*' + [regex]::Escape($h) + '\b')) { $score++ } } if ($score -ge 4) { return $true } if (-not [string]::IsNullOrWhiteSpace($ApprovalPhrase) -and $Text -match '(?i)\bWhat\s+I\s+Need\s+From\s+You\b' -and $Text -match [regex]::Escape($ApprovalPhrase)) { return $true } return $false } function Test-SpecrewAssistantApprovalOption { # For concise "1" / "option 1" fallback approvals, prove option 1 in the rendered packet was actually an # approval option. This avoids treating "option 2" rejection/modification choices as authorization. [OutputType([bool])] param( [Parameter()][AllowNull()][string]$AssistantText, [Parameter(Mandatory)][int]$OptionNumber, [Parameter()][AllowNull()][string]$ApprovalPhrase ) if ([string]::IsNullOrWhiteSpace($AssistantText)) { return $false } $optionPattern = '(?is)(?:^|[\r\n]|[•\-\*]\s*)\s*(?:option\s*)?' + [regex]::Escape([string]$OptionNumber) + '\s*(?:[.):\-]|-)\s*(.{0,220})' foreach ($m in [regex]::Matches($AssistantText, $optionPattern)) { $body = [string]$m.Groups[1].Value if ($body -match '(?i)\bapprov') { return $true } if (-not [string]::IsNullOrWhiteSpace($ApprovalPhrase) -and $body -match [regex]::Escape($ApprovalPhrase)) { return $true } } return $false } function Get-SpecrewPendingVerdictFallbackCapture { # Preferred capture is marker-bound. This fallback covers weak hosts/models that rendered a boundary packet but # dropped/mis-targeted the invisible marker. It still requires a real human approval and binds only to the single # pending crossing computed from start-context.json; the agent never supplies the boundary being authorized. [OutputType([pscustomobject])] param( [Parameter()][AllowNull()][object[]]$Turns, [Parameter()][AllowNull()][string]$ProjectRoot ) $result = [pscustomobject]@{ Found = $false; FromBoundary = $null; ToBoundary = $null; VerdictText = $null; HumanText = $null; Reason = 'no-pending-state' } if ($null -eq $Turns -or @($Turns).Count -eq 0) { $result.Reason = 'no-turns'; return $result } if ([string]::IsNullOrWhiteSpace($ProjectRoot) -or -not (Get-Command Get-SpecrewPendingVerdictState -ErrorAction SilentlyContinue)) { return $result } $pending = $null try { $pending = Get-SpecrewPendingVerdictState -ProjectRoot $ProjectRoot } catch { $pending = $null } if ($null -eq $pending -or -not [bool]$pending.HasPendingVerdict) { return $result } $pendingFromMarker = [string]$pending.PendingFromMarkerBoundary $pendingToMarker = [string]$pending.PendingToMarkerBoundary $pendingFrom = [string]$pending.PendingFromBoundary $pendingTo = [string]$pending.PendingToBoundary if ([string]::IsNullOrWhiteSpace($pendingFromMarker) -or [string]::IsNullOrWhiteSpace($pendingToMarker) -or [string]::IsNullOrWhiteSpace($pendingTo)) { $result.Reason = 'pending-state-incomplete' return $result } $approvalPhrase = ('approved for {0}' -f $pendingToMarker) $pendingSet = @($pendingFromMarker, $pendingToMarker, $pendingFrom, $pendingTo) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -Unique for ($i = @($Turns).Count - 1; $i -ge 0; $i--) { $turn = $Turns[$i] if ([string]$turn.role -ne 'user') { continue } $humanText = [string]$turn.text $verdict = Test-SpecrewHumanVerdictToken -Text $humanText if (-not $verdict.IsApproval) { if ($verdict.IsSendBack) { $result.Reason = 'not-approval:send-back'; return $result } if ($verdict.IsDiscuss) { $result.Reason = 'not-approval:discuss'; return $result } continue } $named = @($verdict.NamedBoundaries) $approveForMatches = @([regex]::Matches($humanText.ToLowerInvariant(), '\bapprov\w*\s+for\s+(specify|clarify|plan|tasks|before-implement|implement|review-signoff|review|retro|iteration-closeout|feature-closeout)\b') | ForEach-Object { Normalize-SpecrewCanonicalBoundaryType -Boundary ([string]$_.Groups[1].Value) } | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) if ($approveForMatches.Count -gt 0 -and ($approveForMatches -notcontains $pendingToMarker)) { $result.Reason = 'named-boundary-contradicts-pending' return $result } if ($named.Count -gt 0 -and @($named | Where-Object { $pendingSet -contains $_ }).Count -eq 0) { $result.Reason = 'named-boundary-contradicts-pending' return $result } $priorAssistant = $null for ($j = $i - 1; $j -ge 0; $j--) { if ([string]$Turns[$j].role -eq 'assistant') { $priorAssistant = [string]$Turns[$j].text; break } } $packetLike = Test-SpecrewBoundaryPacketLikeText -Text $priorAssistant -ApprovalPhrase $approvalPhrase $exactNamedApproval = ($approveForMatches -contains $pendingToMarker) -or ($humanText.ToLowerInvariant() -match [regex]::Escape($approvalPhrase)) if ($null -ne $verdict.ApprovalOption) { if ([int]$verdict.ApprovalOption -ne 1) { $result.Reason = 'approval-option-not-authorizing-fallback' return $result } if (-not (Test-SpecrewAssistantApprovalOption -AssistantText $priorAssistant -OptionNumber 1 -ApprovalPhrase $approvalPhrase)) { $result.Reason = 'approval-option-not-proven' return $result } } elseif (-not $exactNamedApproval -and -not $packetLike) { $result.Reason = 'no-boundary-packet-context' continue } $result.Found = $true $result.FromBoundary = $pendingFromMarker $result.ToBoundary = $pendingToMarker $result.VerdictText = $approvalPhrase $result.HumanText = $humanText $result.Reason = 'captured-pending-artifact-fallback' return $result } $result.Reason = 'no-clear-human-approval' return $result } function Get-SpecrewCapturedBoundaryVerdict { # F-174 iteration 011 (T004, FR-026): read the host transcript for the human's verdict on a rendered boundary # VERDICT packet. The verdict is tied to a boundary ONLY via the packet's stable machine marker # <!-- SPECREW-VERDICT-BOUNDARY: <from> -> <to> --> (T002 / the gate-stop skill emits it; it is an HTML # comment, invisible in the rendered markdown, present in the transcript). NO marker -> NO capture (the human # re-confirms via the pending surface). Newer marker/response pairs normally win, but a newer packet with no # response yet must NOT hide an earlier approved packet: the Stop hook records approvals only at end-of-turn, # so an agent can mistakenly render the next boundary before the previous approval is persisted. Scan backward # for the newest marker that has a subsequent CLEAR approval. Pure read; fail-open (never throws). [OutputType([pscustomobject])] param( [Parameter()][AllowNull()][string]$TranscriptPath, [Parameter()][AllowNull()][string]$ProjectRoot, [Parameter()][AllowNull()][string]$LastUserMessage, [int]$MaxTailLines = 500 ) $result = [pscustomobject]@{ Found = $false; FromBoundary = $null; ToBoundary = $null; VerdictText = $null; HumanText = $null; Reason = 'no-transcript' } if ([string]::IsNullOrWhiteSpace($TranscriptPath) -or -not (Test-Path -LiteralPath $TranscriptPath -PathType Leaf)) { return $result } # F-197 iter-004 (T070, #2885): one shared memoized parse (path,mtime,MaxLines); finalize with -Raw here. # The shared extract holds only user/assistant message turns; a tail with no message turns falls through to # the existing $turns.Count==0 -> 'no-turns' guard below. (Diagnostic-only delta vs the pre-refactor path: a # ZERO-BYTE file now reports Reason='no-turns' where the old per-line path reported 'empty'; both give # Found=$false and 'empty' is consumed by no branch, so capture/authorization behavior is unchanged. 145 T070.) $shared = $null try { $shared = @(Get-SpecrewTranscriptParsedTurns -TranscriptPath $TranscriptPath -MaxLines $MaxTailLines) } catch { $result.Reason = 'unreadable'; return $result } $turns = New-Object System.Collections.Generic.List[object] foreach ($rp in $shared) { $tn = Format-SpecrewConversationTurnText -Turn $rp -Raw; if ($null -ne $tn) { $turns.Add($tn) | Out-Null } } if (-not [string]::IsNullOrWhiteSpace($LastUserMessage)) { $syntheticUser = ([string]$LastUserMessage).Trim() $syntheticUser = $syntheticUser -replace '(?is)^\s*<USER_REQUEST>\s*', '' -replace '(?is)\s*</USER_REQUEST>\s*$', '' $syntheticUser = $syntheticUser.Trim() $lastTurn = if ($turns.Count -gt 0) { $turns[$turns.Count - 1] } else { $null } $lastIsSameUser = ($null -ne $lastTurn -and [string]$lastTurn.role -eq 'user' -and [string]$lastTurn.text -eq $syntheticUser) if (-not $lastIsSameUser) { $turns.Add([pscustomobject]@{ role = 'user'; text = $syntheticUser }) | Out-Null } } if ($turns.Count -eq 0) { $result.Reason = 'no-turns'; return $result } # The packet marker: case-insensitive, tolerate '->' / unicode arrow / 'to' and flexible spacing. $markerRx = [regex]::new('SPECREW-VERDICT-BOUNDARY:\s*([a-z-]+)\s*(?:->|→|to)\s*([a-z-]+)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) # Newest marker/response pair with a CLEAR approval wins. A newer marker with no response yet is just awaiting # the next user turn and must not mask an earlier approved marker that the hook has not had a chance to record. $sawMarker = $false $sawAwaiting = $false for ($i = 0; $i -lt $turns.Count; $i++) { # Loop retained for no-marker detection only; the actual selection scans backward below. if ([string]$turns[$i].role -ne 'assistant') { continue } $mm = $markerRx.Match([string]$turns[$i].text) if ($mm.Success) { $sawMarker = $true; break } } if (-not $sawMarker) { $fallback = Get-SpecrewPendingVerdictFallbackCapture -Turns ($turns.ToArray()) -ProjectRoot $ProjectRoot if ($fallback.Found) { return $fallback } $result.Reason = if ($fallback.Reason -ne 'no-pending-state') { $fallback.Reason } else { 'no-marker' } return $result } for ($i = $turns.Count - 1; $i -ge 0; $i--) { if ([string]$turns[$i].role -ne 'assistant') { continue } $mm = $markerRx.Match([string]$turns[$i].text) if (-not $mm.Success) { continue } $mFrom = $mm.Groups[1].Value.ToLowerInvariant() $mTo = $mm.Groups[2].Value.ToLowerInvariant() # The FIRST human turn AFTER that packet (the response to it; before it = the request, not the verdict). $humanText = $null for ($j = $i + 1; $j -lt $turns.Count; $j++) { if ([string]$turns[$j].role -eq 'user') { $humanText = [string]$turns[$j].text; break } } if ([string]::IsNullOrWhiteSpace($humanText)) { $sawAwaiting = $true; continue } $verdict = Test-SpecrewHumanVerdictToken -Text $humanText if (-not $verdict.IsApproval) { if ([string]::IsNullOrWhiteSpace($result.Reason) -or $result.Reason -eq 'no-transcript') { $result.Reason = ("not-approval:{0}" -f $verdict.Action) } continue } # Contradiction cross-check: if the human NAMED boundaries, at least one must match the marker's from/to; # a human who named a DIFFERENT boundary makes the tie ambiguous -> un-authorized (safety rule). $named = @($verdict.NamedBoundaries) if ($named.Count -gt 0) { $markerSet = @($mFrom, $mTo) if (@($named | Where-Object { $markerSet -contains $_ }).Count -eq 0) { if ([string]::IsNullOrWhiteSpace($result.Reason) -or $result.Reason -eq 'no-transcript') { $result.Reason = 'named-boundary-contradicts-marker' } continue } } if (-not [string]::IsNullOrWhiteSpace($ProjectRoot) -and (Get-Command Get-SpecrewPendingVerdictState -ErrorAction SilentlyContinue)) { $pendingForMarker = $null try { $pendingForMarker = Get-SpecrewPendingVerdictState -ProjectRoot $ProjectRoot } catch { $pendingForMarker = $null } if ($null -ne $pendingForMarker -and [bool]$pendingForMarker.HasPendingVerdict) { $expectedFrom = [string]$pendingForMarker.PendingFromMarkerBoundary $expectedTo = [string]$pendingForMarker.PendingToMarkerBoundary if ($mFrom -ne $expectedFrom -or $mTo -ne $expectedTo) { if ([string]::IsNullOrWhiteSpace($result.Reason) -or $result.Reason -eq 'no-transcript') { $result.Reason = 'marker-pending-mismatch' } continue } } } $result.Found = $true $result.FromBoundary = $mFrom $result.ToBoundary = $mTo $result.VerdictText = "approved for $mTo" $result.HumanText = $humanText $result.Reason = 'captured' return $result } if (-not [string]::IsNullOrWhiteSpace($ProjectRoot)) { $fallback = Get-SpecrewPendingVerdictFallbackCapture -Turns ($turns.ToArray()) -ProjectRoot $ProjectRoot if ($fallback.Found) { return $fallback } if ($result.Reason -eq 'no-transcript') { $result.Reason = $fallback.Reason } } if ($sawAwaiting -and ($result.Reason -eq 'no-transcript')) { $result.Reason = 'awaiting-response' } return $result } function Get-SpecrewCapturedBoundaryPacket { # F-174 iteration 011 (T002, FR-022 / DF-3): read the host transcript for the VERBATIM boundary VERDICT packet # the agent ACTUALLY RENDERED at the most recent gate stop - NOT a synthesized replacement (the maintainer's # load-bearing constraint). Tied to a boundary via the SAME stable marker as the verdict reader # <!-- SPECREW-VERDICT-BOUNDARY: <from> -> <to> --> (the gate-stop skill emits it). Returns the packet body RAW # (read with -Raw so the six '## ' headers + newline structure survive), so a resume inherits the AUTHORED # packet instead of placeholders. CONSERVATIVE capture: the marker MUST be present (no marker -> no capture) AND # the turn MUST carry substantive content (a MINIMAL structural floor - a char count, NOT six exact '## ' # headers; demanding the exact form would be a form-without-runtime-compliance trap that a slightly-reworded but # genuine packet would fail). No marker / no substance -> Found=$false; the caller degrades to the placeholder. # Pure read; fail-open (never throws). [OutputType([pscustomobject])] param( [Parameter()][AllowNull()][string]$TranscriptPath, [int]$MaxTailLines = 500, # substantive-content floor: a real six-section packet is well over this; a bare marker comment (~60 chars) # with no packet body is below it -> not captured (we do not persist an empty "packet"). [int]$MinPacketChars = 200 ) $result = [pscustomobject]@{ Found = $false; FromBoundary = $null; ToBoundary = $null; PacketBody = $null; Reason = 'no-transcript' } if ([string]::IsNullOrWhiteSpace($TranscriptPath) -or -not (Test-Path -LiteralPath $TranscriptPath -PathType Leaf)) { return $result } # F-197 iter-004 (T070, #2885): the SAME shared memoized parse the verdict reader used (cache hit here); # finalize with -Raw so the six '## ' headers + newline structure round-trip verbatim. $shared = $null try { $shared = @(Get-SpecrewTranscriptParsedTurns -TranscriptPath $TranscriptPath -MaxLines $MaxTailLines) } catch { $result.Reason = 'unreadable'; return $result } # Same marker grammar as the verdict reader: case-insensitive, '->' / unicode arrow / 'to', flexible spacing. $markerRx = [regex]::new('SPECREW-VERDICT-BOUNDARY:\s*([a-z-]+)\s*(?:->|→|to)\s*([a-z-]+)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) # The LAST assistant turn carrying a marker (the most recently rendered packet), read VERBATIM (-Raw). $found = $null foreach ($rp in $shared) { $tn = Format-SpecrewConversationTurnText -Turn $rp -Raw if ($null -eq $tn -or [string]$tn.role -ne 'assistant') { continue } $mm = $markerRx.Match([string]$tn.text) if ($mm.Success) { $found = [pscustomobject]@{ From = $mm.Groups[1].Value.ToLowerInvariant(); To = $mm.Groups[2].Value.ToLowerInvariant(); Body = [string]$tn.text } } } if ($null -eq $found) { $result.Reason = 'no-marker'; return $result } $body = [string]$found.Body if ($body.Trim().Length -lt $MinPacketChars) { $result.Reason = 'marker-without-substance'; return $result } $result.Found = $true $result.FromBoundary = $found.From $result.ToBoundary = $found.To $result.PacketBody = $body $result.Reason = 'captured' return $result } # F-197 iter-004 (T070, #2885): single-entry transcript-parse memo + a parse counter. # THE LATENCY FIX. Pre-refactor, the three Stop-hook consumers (verdict / packet / conversation-tail) each # read the transcript tail AND ran `ConvertFrom-Json -Depth 40` + role/parts extraction over EVERY line # INDEPENDENTLY - three full parses of the same tail per stop (the ~11s of #2885's ~16s). They all run in # ONE process inside Update-SpecrewRollingHandover, so a single-entry memo keyed by (path, mtime, MaxLines) # collapses the three parses to one; the per-consumer -Raw/flatten join + the verdict's synthetic-user append # stay per-consumer (they operate on a COPY of the shared {role; parts} extract, never on the cache). $script:SpecrewTranscriptParseMemo = $null # single entry: @{ Key; Turns = {role;parts}[] } $script:SpecrewTranscriptParseCount = 0 # number of lines ConvertFrom-Json'd on cache MISSES (parse-once witness) function Reset-SpecrewTranscriptParseCount { # Test/diagnostic seam (T072): zero the parse-once witness so a single "stop" can be measured in isolation. $script:SpecrewTranscriptParseCount = 0 } function Clear-SpecrewTranscriptParseMemo { # Test/diagnostic seam (T072): drop the single memo entry so the NEXT parse is a guaranteed cache MISS. Used # only to measure a single stop COLD in isolation; the production hot path never needs to clear (mtime keys it). $script:SpecrewTranscriptParseMemo = $null } function Get-SpecrewTranscriptParseCount { # Test/diagnostic seam (T072): the number of transcript lines parsed since the last reset. One stop that # shares the memo across all three consumers must equal a SINGLE consumer's count (parse-once), not 3x. [OutputType([int])] param() return [int]$script:SpecrewTranscriptParseCount } function Get-SpecrewConversationTurnRolePartsFromObject { # The pure role/parts EXTRACTION across the host schemas - the part of the per-line parse that is IDENTICAL # regardless of -Raw (only the later text-join differs). Takes the already-deserialized JSON object; returns # @{ role; parts = string[] } for a user/assistant message line, or $null for a non-message line (session # meta / tool / system / developer / unrecognized). No ConvertFrom-Json here - the cost is paid once upstream. [OutputType([pscustomobject])] param([Parameter()][AllowNull()]$Object) if ($null -eq $Object) { return $null } $o = $Object $role = $null; $parts = @() $typeVal = [string](Get-SpecrewConversationProp $o 'type') $topRole = Get-SpecrewConversationProp $o 'role' $msg = Get-SpecrewConversationProp $o 'message' $payload = Get-SpecrewConversationProp $o 'payload' $data = Get-SpecrewConversationProp $o 'data' $sourceVal = [string](Get-SpecrewConversationProp $o 'source') if (-not [string]::IsNullOrWhiteSpace([string]$topRole) -and $null -ne $msg) { # Cursor: top-level role + message.content[] $role = [string]$topRole $parts = Get-SpecrewConversationContentText (Get-SpecrewConversationProp $msg 'content') } elseif ($typeVal -in @('user', 'assistant') -and $null -ne $msg) { # Claude: type in {user,assistant} + message.content[] $role = $typeVal $parts = Get-SpecrewConversationContentText (Get-SpecrewConversationProp $msg 'content') } elseif ($typeVal -eq 'response_item' -and $null -ne $payload -and [string](Get-SpecrewConversationProp $payload 'type') -eq 'message') { # Codex: response_item -> payload.role + payload.content[] (input_text/output_text/text) $role = [string](Get-SpecrewConversationProp $payload 'role') $parts = Get-SpecrewConversationContentText (Get-SpecrewConversationProp $payload 'content') } elseif ($typeVal -match '^(user|assistant)\.message$' -and $null -ne $data) { # Copilot: the TYPE prefix carries the role (data has no role field); data.content is the text string $role = $matches[1] $parts = Get-SpecrewConversationContentText (Get-SpecrewConversationProp $data 'content') } elseif ($sourceVal -eq 'USER_EXPLICIT' -and $typeVal -eq 'USER_INPUT') { # Antigravity: explicit human turns are top-level content wrapped in <USER_REQUEST>...</USER_REQUEST>. $role = 'user' $content = [string](Get-SpecrewConversationProp $o 'content') $m = [regex]::Match($content, '<USER_REQUEST>\s*([\s\S]*?)\s*</USER_REQUEST>', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) if ($m.Success) { $content = [string]$m.Groups[1].Value } $parts = Get-SpecrewConversationContentText $content } elseif ($sourceVal -eq 'MODEL' -and $typeVal -eq 'PLANNER_RESPONSE') { # Antigravity: assistant planner response is top-level content. $role = 'assistant' $parts = Get-SpecrewConversationContentText (Get-SpecrewConversationProp $o 'content') } else { return $null } if ($role -notin @('user', 'assistant')) { return $null } # drop developer/system/tool roles return [pscustomobject]@{ role = $role; parts = @($parts) } } function Format-SpecrewConversationTurnText { # The FINALIZE step: turn a shared {role; parts} extract into the final {role; text} a consumer needs, with # its OWN -Raw flag. -Raw (T002) preserves the message text VERBATIM (newlines + '## ' structure intact) for # the boundary-packet capture, which must round-trip the six-section markdown. The DEFAULT (no -Raw) collapses # all whitespace to single spaces for the bounded conversation TAIL, where structure is noise. Both paths strip # the system-injected wrappers (targeted removals that do not touch packet structure). Returns $null for a # whitespace-only result or a pure hook-prompt user turn (dropped by the caller). Does NOT mutate the input. [OutputType([pscustomobject])] param([Parameter()][AllowNull()]$Turn, [switch]$Raw) if ($null -eq $Turn) { return $null } $role = [string]$Turn.role $parts = @($Turn.parts) if ($role -notin @('user', 'assistant')) { return $null } # -Raw (T002): join parts with a newline to keep block boundaries; DEFAULT joins with a space for the flat tail. $text = if ($Raw) { (@($parts) -join "`n") } else { (@($parts) -join ' ') } # strip query/redaction wrappers + the most obvious system-injected blocks (keep a short marker so the # signal survives without the bulk). These are targeted removals; they do not touch '## ' packet structure. $text = $text -replace '</?user_query>', '' -replace '</?environment_details>', '' -replace '\[REDACTED\]', '' $text = $text -replace '<task-notification>[\s\S]*?</task-notification>', '[task-notification]' $text = $text -replace '<turn_aborted>[\s\S]*?</turn_aborted>', '[turn aborted]' # -Raw preserves internal whitespace (newlines + the markdown structure the packet round-trip needs); the # default flattens it for the bounded tail. Both trim the outer edges. $text = if ($Raw) { $text.Trim() } else { ($text -replace '\s+', ' ').Trim() } if ($role -eq 'user' -and $text -match '^\s*<hook_prompt\b[\s\S]*</hook_prompt>\s*$') { return $null } if ([string]::IsNullOrWhiteSpace($text)) { return $null } return [pscustomobject]@{ role = $role; text = $text } } function Get-SpecrewTranscriptParsedTurns { # F-197 iter-004 (T070, #2885): read the transcript tail + ConvertFrom-Json + role/parts EXTRACT exactly ONCE # per (TranscriptPath, file-mtime, MaxLines), MEMOIZED to a SINGLE entry. The three Stop-hook consumers call # this with the same key in one process, so the expensive parse runs once and the other two are cache hits. # Returns a FRESH array of the shared {role; parts} turns (a COPY of the outer list) so a consumer's own # transform - the verdict reader's synthetic-user append, or any list mutation - can NEVER leak into the cache # or another consumer. The {role; parts} entries are read-only on the finalize path (Format-... only reads), # so a shallow array copy is sufficient; no deep clone needed. Single entry keyed by mtime => unbounded growth # is impossible and a rewritten transcript invalidates correctly. Fail-open: an unreadable file -> empty array. # mtime-resolution assumption (145 T070): the key trusts the filesystem last-write tick to advance on a real # rewrite. A stale hit needs DIFFERENT content at the SAME path with an IDENTICAL tick - which the hot path # cannot produce: the three intended hits are one in-process burst (ms apart, identical content), and two # distinct Stop crossings are a full agent turn apart (seconds), so the tick always advances between contents. # A same-tick rewrite only occurs if a writer deliberately pins the prior timestamp - not a real Stop pattern. [OutputType([pscustomobject[]])] param([Parameter()][AllowNull()][string]$TranscriptPath, [int]$MaxLines = 500) if ([string]::IsNullOrWhiteSpace($TranscriptPath) -or -not (Test-Path -LiteralPath $TranscriptPath -PathType Leaf)) { return @() } $mtime = $null try { $mtime = ([System.IO.File]::GetLastWriteTimeUtc($TranscriptPath)).Ticks } catch { $mtime = $null } $key = ('{0}|{1}|{2}' -f $TranscriptPath, [string]$mtime, [int]$MaxLines) $memo = $script:SpecrewTranscriptParseMemo if ($null -ne $memo -and [string]$memo.Key -eq $key) { # Cache HIT - hand back a fresh outer array so the caller can append/mutate its own list safely. return @($memo.Turns) } # Cache MISS - the one true parse for this key. $lines = @(Get-SpecrewTranscriptTailLines -Path $TranscriptPath -MaxLines $MaxLines) $turns = New-Object System.Collections.Generic.List[object] foreach ($l in $lines) { if ([string]::IsNullOrWhiteSpace($l)) { continue } $o = $null try { $o = $l | ConvertFrom-Json -Depth 40 -ErrorAction Stop } catch { $o = $null } $script:SpecrewTranscriptParseCount = [int]$script:SpecrewTranscriptParseCount + 1 # parse-once witness (one count per ConvertFrom-Json) if ($null -eq $o) { continue } $rp = Get-SpecrewConversationTurnRolePartsFromObject -Object $o if ($null -ne $rp) { $turns.Add($rp) | Out-Null } } $arr = @($turns.ToArray()) $script:SpecrewTranscriptParseMemo = [pscustomobject]@{ Key = $key; Turns = $arr } return @($arr) } function Get-SpecrewConversationTurnFromLine { # Best-effort (role,text) from one transcript JSONL line across the 4 host schemas. Returns $null for a # non-message line (session meta / tool / system / developer / parse failure) -> skipped by the caller. # F-197 iter-004 (T070): now COMPOSES the split helpers - ConvertFrom-Json -> role/parts extract -> finalize - # so other callers that parse a single line ad hoc keep working with IDENTICAL behavior to the pre-refactor # monolith. (The Stop-hook hot path goes through the memoized Get-SpecrewTranscriptParsedTurns instead.) [OutputType([pscustomobject])] param([Parameter()][AllowNull()][string]$Line, [switch]$Raw) if ([string]::IsNullOrWhiteSpace($Line)) { return $null } $o = $null try { $o = $Line | ConvertFrom-Json -Depth 40 -ErrorAction Stop } catch { return $null } if ($null -eq $o) { return $null } $rp = Get-SpecrewConversationTurnRolePartsFromObject -Object $o if ($null -eq $rp) { return $null } return (Format-SpecrewConversationTurnText -Turn $rp -Raw:$Raw) } function Format-SpecrewConversationBullets { # Render the last $MaxTurns turns as `- **role:** text` bullets, per-turn truncated, then drop OLDEST # bullets until the whole block is under the HARD char cap (keep the newest). param([Parameter()][AllowNull()][object[]]$Turns, [int]$MaxTurns = 8, [int]$MaxChars = 4000, [int]$PerTurn = 240) $tail = @(@($Turns) | Select-Object -Last $MaxTurns) $out = New-Object System.Collections.Generic.List[string] foreach ($t in $tail) { $s = [string]$t.text if ($s.Length -gt $PerTurn) { $s = $s.Substring(0, $PerTurn) + '...' } $out.Add(('- **{0}:** {1}' -f $t.role, $s)) | Out-Null } while ((($out -join "`n").Length) -gt $MaxChars -and $out.Count -gt 1) { $out.RemoveAt(0) } return , $out.ToArray() } function Get-SpecrewConversationTail { # The "Recent conversation" handover section body, via the 4-tier resilience ladder. ALWAYS returns a # string (a real tail, a raw-tail-with-note, the payload last message, or an honest floor); never throws. [OutputType([string])] param( [Parameter()][AllowNull()][string]$HostKind, [Parameter()][AllowNull()][string]$TranscriptPath, [Parameter()][AllowNull()][string]$LastAssistantMessage, [int]$MaxTurns = 8, [int]$MaxChars = 4000, [int]$PerTurn = 240, # F-174 iter-10 (T002 fix F3): bound the transcript read to the TAIL. The handover refreshes on Claude # PostToolUse (every tool call); a long session's transcript is tens of thousands of JSONL lines, so a # whole-file read per tool call is an O(session) overhead trap. 500 lines comfortably covers the last # $MaxTurns user/assistant turns even on chatty hosts (many tool/system lines per turn). [int]$MaxTailLines = 500 ) $hostLabel = if ([string]::IsNullOrWhiteSpace($HostKind)) { 'this host' } else { $HostKind } $pointer = if (-not [string]::IsNullOrWhiteSpace($TranscriptPath)) { ('Full transcript (read on-demand for depth): {0}' -f $TranscriptPath) } else { $null } $join = { param($Bullets, $Note) $sb = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace([string]$Note)) { $sb.Add([string]$Note) | Out-Null; $sb.Add('') | Out-Null } foreach ($b in @($Bullets)) { $sb.Add([string]$b) | Out-Null } if (-not [string]::IsNullOrWhiteSpace([string]$pointer)) { $sb.Add('') | Out-Null; $sb.Add([string]$pointer) | Out-Null } return (($sb -join "`n").Trim()) } # Tier 1: structured per-host parse. F-197 iter-004 (T070, #2885): the EXPENSIVE ConvertFrom-Json + role/parts # extract comes from the SHARED memoized parse (a cache hit when the verdict/packet readers ran first this # stop; the one true parse if this consumer runs first). It reads the tail + parses internally, so on the # common Tier-1 path this consumer does NOT separately byte-read the tail; the raw $fileLines below is read # LAZILY only when the structured parse yields nothing (the Tier-2 drift fallback). Finalize with DEFAULT # (non-Raw) flatten. if (-not [string]::IsNullOrWhiteSpace($TranscriptPath)) { $shared = @(Get-SpecrewTranscriptParsedTurns -TranscriptPath $TranscriptPath -MaxLines $MaxTailLines) if ($shared.Count -gt 0) { $turns = New-Object System.Collections.Generic.List[object] foreach ($rp in $shared) { $t = Format-SpecrewConversationTurnText -Turn $rp; if ($null -ne $t) { $turns.Add($t) | Out-Null } } if ($turns.Count -gt 0) { $bullets = Format-SpecrewConversationBullets -Turns ($turns.ToArray()) -MaxTurns $MaxTurns -MaxChars $MaxChars -PerTurn $PerTurn return (& $join $bullets $null) } } # No structured turns -> read the raw byte tail to decide Tier 2 (present-but-unrecognized schema) vs # fall through to Tier 3 / Floor (absent or empty file). This byte read only happens off the happy path. $fileLines = $null try { if (Test-Path -LiteralPath $TranscriptPath -PathType Leaf) { # Read only the TAIL (not the whole file) - see $MaxTailLines. On Codex this also naturally # skips the giant line-1 session_meta header. $fileLines = @(Get-SpecrewTranscriptTailLines -Path $TranscriptPath -MaxLines $MaxTailLines) } } catch { $fileLines = $null } if ($null -ne $fileLines -and $fileLines.Count -gt 0) { # Tier 2: present but unrecognized schema -> raw bounded tail + VISIBLE degradation note. $nonEmpty = @($fileLines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($nonEmpty.Count -gt 0) { $rawTurns = @($nonEmpty | Select-Object -Last $MaxTurns | ForEach-Object { [pscustomobject]@{ role = 'raw'; text = (([string]$_ -replace '\s+', ' ').Trim()) } }) $bullets = @(Format-SpecrewConversationBullets -Turns $rawTurns -MaxTurns $MaxTurns -MaxChars $MaxChars -PerTurn $PerTurn) | ForEach-Object { $_ -replace '^\- \*\*raw:\*\* ', '- ' } $note = ('(transcript present but its format was not recognized - showing a raw tail; the structured parser for {0} may need updating)' -f $hostLabel) return (& $join $bullets $note) } } } # Tier 3: no readable file, but the event payload handed us the last assistant message (Codex). if (-not [string]::IsNullOrWhiteSpace($LastAssistantMessage)) { $s = ([string]$LastAssistantMessage -replace '\s+', ' ').Trim() if ($s.Length -gt $PerTurn) { $s = $s.Substring(0, $PerTurn) + '...' } $note = '(transcript file unavailable this stop - showing the last assistant message from the event payload)' return (& $join @(('- **assistant:** {0}' -f $s)) $note) } # Floor. return ('(no conversation transcript exposed by {0} this stop - the next session relies on the git delta, the artifact-derived orientation, and the agent-authored sections above.)' -f $hostLabel) } |