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 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 = @() } 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. if ($lower -match '\bapprove(d|s)?\b' -or $lower -match '^\s*[12]\s*[.):]?\s*$') { $r.IsApproval = $true; $r.Action = 'approve'; return $r } return $r } function Get-SpecrewCapturedBoundaryVerdict { # F-174 iteration 011 (T004, FR-026): read the host transcript for the human's verdict on the MOST RECENTLY # 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). Finds the last marker-bearing ASSISTANT turn, then the FIRST human # turn after it, and classifies that turn with Test-SpecrewHumanVerdictToken. Returns captured verdict # evidence ONLY on a CLEAR approval whose human-named boundary (if any) does not CONTRADICT the marker; # otherwise Found=$false so the caller records the crossing un-authorized. Pure read; fail-open (never throws). [OutputType([pscustomobject])] param( [Parameter()][AllowNull()][string]$TranscriptPath, [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 } $lines = $null try { $lines = @(Get-Content -LiteralPath $TranscriptPath -Tail $MaxTailLines -Encoding UTF8 -ErrorAction Stop) } catch { $result.Reason = 'unreadable'; return $result } if ($null -eq $lines -or $lines.Count -eq 0) { $result.Reason = 'empty'; return $result } $turns = New-Object System.Collections.Generic.List[object] foreach ($l in $lines) { $tn = Get-SpecrewConversationTurnFromLine -Line $l; if ($null -ne $tn) { $turns.Add($tn) | 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) # The LAST assistant turn that carries a marker (the most recently gated boundary). $markerIdx = -1; $mFrom = $null; $mTo = $null for ($i = 0; $i -lt $turns.Count; $i++) { if ([string]$turns[$i].role -ne 'assistant') { continue } $mm = $markerRx.Match([string]$turns[$i].text) if ($mm.Success) { $markerIdx = $i; $mFrom = $mm.Groups[1].Value.ToLowerInvariant(); $mTo = $mm.Groups[2].Value.ToLowerInvariant() } } if ($markerIdx -lt 0) { $result.Reason = 'no-marker'; return $result } # The FIRST human turn AFTER that packet (the response to it; before it = the request, not the verdict). $humanText = $null for ($j = $markerIdx + 1; $j -lt $turns.Count; $j++) { if ([string]$turns[$j].role -eq 'user') { $humanText = [string]$turns[$j].text; break } } if ([string]::IsNullOrWhiteSpace($humanText)) { $result.Reason = 'awaiting-response'; return $result } $verdict = Test-SpecrewHumanVerdictToken -Text $humanText if (-not $verdict.IsApproval) { $result.Reason = ("not-approval:{0}" -f $verdict.Action); return $result } # 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) { $result.Reason = 'named-boundary-contradicts-marker'; return $result } } $result.Found = $true $result.FromBoundary = $mFrom $result.ToBoundary = $mTo $result.VerdictText = "approved for $mTo" $result.HumanText = $humanText $result.Reason = 'captured' 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 } $lines = $null try { $lines = @(Get-Content -LiteralPath $TranscriptPath -Tail $MaxTailLines -Encoding UTF8 -ErrorAction Stop) } catch { $result.Reason = 'unreadable'; return $result } if ($null -eq $lines -or $lines.Count -eq 0) { $result.Reason = 'empty'; 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 ($l in $lines) { $tn = Get-SpecrewConversationTurnFromLine -Line $l -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 } 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-174 iter-11 (T002): -Raw 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. Raw still strips the # system-injected wrappers (targeted removals that do not touch packet structure) and joins multiple content # parts with a newline (a part boundary is a block boundary), but never collapses internal whitespace. [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 } $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' 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') } else { return $null } if ($role -notin @('user', 'assistant')) { return $null } # drop developer/system/tool roles # -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 ([string]::IsNullOrWhiteSpace($text)) { return $null } return [pscustomobject]@{ role = $role; text = $text } } 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 } $fileLines = $null if (-not [string]::IsNullOrWhiteSpace($TranscriptPath)) { 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-Content -LiteralPath $TranscriptPath -Tail $MaxTailLines -Encoding UTF8 -ErrorAction Stop) } } catch { $fileLines = $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()) } if ($null -ne $fileLines -and $fileLines.Count -gt 0) { # Tier 1: structured per-host parse. $turns = New-Object System.Collections.Generic.List[object] foreach ($l in $fileLines) { $t = Get-SpecrewConversationTurnFromLine -Line $l; 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) } # 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) } |