extensions/specrew-speckit/scripts/conformance-turn-delta.ps1
|
# T070: host-independent conformance turn-delta core. # # Host adapters supply only lifecycle events. This core owns the live Git snapshot, dirty-path content # fingerprints, owner-scoped baseline record, deterministic delta, and material packet-demand classification. # It intentionally contains no Claude/Codex/Copilot/Cursor/Antigravity branching. $script:SpecrewTurnManagedPathPattern = '^\.(agents|claude|copilot|cursor|github|specify|squad|specrew)[/\\]|^\.(gitattributes|gitignore|markdownlint\.json)$' $script:SpecrewTurnStartEvents = @('userpromptsubmit', 'preinvocation') function Get-SpecrewTurnHash { [CmdletBinding()] param([AllowEmptyString()][string]$Text) $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) return (-join ($hash | ForEach-Object { $_.ToString('x2') })) } function Get-SpecrewTurnPathFingerprint { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProjectRoot, [Parameter(Mandatory)][string]$RelativePath ) try { $nativePath = $RelativePath -replace '/', [System.IO.Path]::DirectorySeparatorChar $fullPath = Join-Path $ProjectRoot $nativePath if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { return 'missing' } return (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() } catch { return 'unreadable' } } function Get-SpecrewTurnSnapshot { [CmdletBinding()] param([Parameter(Mandatory)][string]$ProjectRoot) $empty = [pscustomobject]@{ available = $false reason = 'git-state-unavailable' head = '' key = '' dirty_user_file_count = 0 entries = @() } try { $inside = ([string](& git -C $ProjectRoot rev-parse --is-inside-work-tree 2>$null)).Trim() if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') { return $empty } $prefix = ([string](& git -C $ProjectRoot rev-parse --show-prefix 2>$null)).Trim() -replace '\\', '/' if ($LASTEXITCODE -ne 0) { return $empty } $head = ([string](& git -C $ProjectRoot rev-parse HEAD 2>$null)).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($head)) { $head = '(unborn)' } $statusLines = @(& git -c core.quotepath=false -C $ProjectRoot status --porcelain=v1 --untracked-files=all -- . 2>$null) if ($LASTEXITCODE -ne 0) { return $empty } $entries = [System.Collections.Generic.List[object]]::new() foreach ($rawLine in $statusLines) { $line = [string]$rawLine if ($line.Length -lt 4) { continue } $status = $line.Substring(0, 2) $path = $line.Substring(3).Trim() if ($path -match ' -> ') { $path = ($path -split ' -> ')[-1] } $path = $path.Trim('"') -replace '\\', '/' if (-not [string]::IsNullOrWhiteSpace($prefix) -and $path.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { $path = $path.Substring($prefix.Length) } $path = $path.TrimStart('/') if ([string]::IsNullOrWhiteSpace($path) -or $path -match $script:SpecrewTurnManagedPathPattern) { continue } $entries.Add([pscustomobject]@{ path = $path status = $status fingerprint = Get-SpecrewTurnPathFingerprint -ProjectRoot $ProjectRoot -RelativePath $path }) | Out-Null } $sorted = @($entries | Sort-Object path, status) $canonical = [System.Collections.Generic.List[string]]::new() $canonical.Add(('head={0}' -f $head)) | Out-Null foreach ($entry in $sorted) { $canonical.Add(('{0}|{1}|{2}' -f [string]$entry.path, [string]$entry.status, [string]$entry.fingerprint)) | Out-Null } return [pscustomobject]@{ available = $true reason = 'live-git-snapshot' head = $head key = Get-SpecrewTurnHash -Text ($canonical -join "`n") dirty_user_file_count = $sorted.Count entries = $sorted } } catch { return $empty } } function Write-SpecrewTurnBaseline { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][string]$CaptureEvent ) if ($null -eq $Snapshot -or -not [bool]$Snapshot.available -or [string]::IsNullOrWhiteSpace([string]$Snapshot.key)) { return $false } $temp = $null try { $directory = Split-Path -Parent $Path if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $record = New-SpecrewTurnBaselineRecord -Snapshot $Snapshot -CaptureEvent $CaptureEvent $temp = $Path + '.tmp-' + [guid]::NewGuid().ToString('N') [System.IO.File]::WriteAllText($temp, ($record | ConvertTo-Json -Depth 8 -Compress), [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::Move($temp, $Path, $true) $back = Read-SpecrewTurnBaseline -Path $Path return ($null -ne $back -and [string]$back.key -eq [string]$Snapshot.key -and [string]$back.capture_event -eq $CaptureEvent) } catch { return $false } finally { if (-not [string]::IsNullOrWhiteSpace($temp) -and (Test-Path -LiteralPath $temp -PathType Leaf)) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } } function New-SpecrewTurnBaselineRecord { [CmdletBinding()] param( [Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][string]$CaptureEvent ) if ($null -eq $Snapshot -or -not [bool]$Snapshot.available -or [string]::IsNullOrWhiteSpace([string]$Snapshot.key)) { return $null } return [pscustomobject][ordered]@{ schema_version = '1.0' capture_event = $CaptureEvent captured_at = [DateTimeOffset]::UtcNow.ToString('o') head = [string]$Snapshot.head key = [string]$Snapshot.key dirty_user_file_count = [int]$Snapshot.dirty_user_file_count entries = @($Snapshot.entries) } } function New-SpecrewDegradedTurnBaseline { [CmdletBinding()] param([Parameter(Mandatory)]$Current) if ($null -eq $Current -or -not [bool]$Current.available) { return $null } $head = [string]$Current.head return [pscustomobject][ordered]@{ schema_version = '1.0' capture_event = 'capability-absent' captured_at = [DateTimeOffset]::UtcNow.ToString('o') head = $head key = Get-SpecrewTurnHash -Text ("head={0}" -f $head) dirty_user_file_count = 0 entries = @() } } function Read-SpecrewTurnBaseline { [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) try { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } $record = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ([string]$record.schema_version -ne '1.0' -or [string]::IsNullOrWhiteSpace([string]$record.key) -or [string]::IsNullOrWhiteSpace([string]$record.capture_event) -or $null -eq $record.entries) { return $null } return $record } catch { return $null } } function Compare-SpecrewTurnSnapshot { [CmdletBinding()] param( [Parameter(Mandatory)]$Baseline, [Parameter(Mandatory)]$Current, [Parameter(Mandatory)][string]$ProjectRoot ) $result = [pscustomobject]@{ material = $false reason = 'turn-delta-unavailable' key = '' user_file_count = 0 current_dirty_user_file_count = 0 new_commit_count = 0 changed_paths = @() baseline_key = '' current_key = '' capture_event = '' attribution_mode = 'degraded' } if ($null -eq $Baseline -or $null -eq $Current -or -not [bool]$Current.available) { return $result } try { # ORDINAL path keys. A plain PowerShell hashtable folds case in its string keys, so on a # case-sensitive repository `Foo` and `foo` overwrote each other here: an edit to the # shadowed path could yield an empty changed set, material=false, and NO required # conformance packet. Ordinal is correct in both directions - on a case-folding volume the # two spellings name one file and only one entry is ever produced (co-review finding, run # run-f198-i009-2c6d7cb8-sweep). $baselineMap = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal) foreach ($entry in @($Baseline.entries)) { $baselineMap[[string]$entry.path] = ('{0}|{1}' -f [string]$entry.status, [string]$entry.fingerprint) } $currentMap = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal) foreach ($entry in @($Current.entries)) { $currentMap[[string]$entry.path] = ('{0}|{1}' -f [string]$entry.status, [string]$entry.fingerprint) } # Ordinal, matching the two maps above. `Sort-Object -Unique -CaseSensitive` flips only the # case flag and leaves the comparison culture-aware, so composed versus decomposed Unicode # spellings that the Ordinal maps deliberately keep apart collapsed again right here - and an # edit confined to the dropped path could yield an empty changed set, material=false, and NO # required conformance packet. DRIFT-198-I009-033; the same shape as DRIFT-198-I009-030's # second defect, which fixed these maps and left their union. $pathOrder = [Collections.Generic.List[string]]::new() $pathSeen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) foreach ($pathKey in (@($baselineMap.Keys) + @($currentMap.Keys))) { if ($pathSeen.Add([string]$pathKey)) { $pathOrder.Add([string]$pathKey) } } $pathOrder.Sort([StringComparer]::Ordinal) $paths = @($pathOrder) $changed = @($paths | Where-Object { -not $baselineMap.ContainsKey($_) -or -not $currentMap.ContainsKey($_) -or [string]$baselineMap[$_] -ne [string]$currentMap[$_] }) $commitCount = 0 $baselineHead = [string]$Baseline.head $currentHead = [string]$Current.head if (-not [string]::IsNullOrWhiteSpace($baselineHead) -and $baselineHead -ne '(unborn)' -and -not [string]::IsNullOrWhiteSpace($currentHead) -and $currentHead -ne '(unborn)' -and $baselineHead -ne $currentHead) { try { $countText = ([string](& git -C $ProjectRoot rev-list --count ("{0}..{1}" -f $baselineHead, $currentHead) -- . 2>$null)).Trim() if ($LASTEXITCODE -eq 0 -and $countText -match '^\d+$') { $commitCount = [int]$countText } if ($commitCount -le 0) { $commitCount = 1 } } catch { $commitCount = 1 } } $captureEvent = [string]$Baseline.capture_event $attributionMode = if ($captureEvent.ToLowerInvariant() -in $script:SpecrewTurnStartEvents) { 'exact-turn' } else { 'degraded-worktree' } $material = ($changed.Count -gt 0 -or $commitCount -gt 0) $key = if ($material) { 'material|turn|' + (Get-SpecrewTurnHash -Text ('{0}|{1}' -f [string]$Baseline.key, [string]$Current.key)) } else { '' } return [pscustomobject]@{ material = $material reason = $(if ($material) { 'live-turn-delta' } else { 'no-live-turn-delta' }) key = $key user_file_count = $changed.Count current_dirty_user_file_count = [int]$Current.dirty_user_file_count new_commit_count = $commitCount changed_paths = $changed baseline_key = [string]$Baseline.key current_key = [string]$Current.key capture_event = $captureEvent attribution_mode = $attributionMode } } catch { return $result } } function Resolve-SpecrewTurnPacketDemand { [CmdletBinding()] param( [Parameter(Mandatory)]$Delta, [AllowNull()][string]$SatisfiedKey, [AllowNull()][string]$Owner, [AllowNull()]$OwnerRecord, [int]$OwnerMaxAgeSeconds = 300, [long]$NowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() ) $decision = [pscustomobject]@{ demand = $false reason = 'no-turn-delta' already_satisfied = $false foreign_owner_suppressed = $false } if ($null -eq $Delta -or -not [bool]$Delta.material -or [string]::IsNullOrWhiteSpace([string]$Delta.key)) { return $decision } if (-not [string]::IsNullOrWhiteSpace($SatisfiedKey) -and [string]$Delta.key -eq $SatisfiedKey) { $decision.reason = 'turn-delta-already-satisfied' $decision.already_satisfied = $true return $decision } if (-not [string]::IsNullOrWhiteSpace($Owner) -and $null -ne $OwnerRecord -and [string]$OwnerRecord.key -eq [string]$Delta.key -and [string]$OwnerRecord.owner -ne $Owner) { $age = $NowEpoch - [long]$OwnerRecord.epoch if ($age -ge 0 -and $age -le $OwnerMaxAgeSeconds) { $decision.reason = 'turn-delta-owned-by-foreign-session' $decision.foreign_owner_suppressed = $true return $decision } } $decision.demand = $true $decision.reason = 'turn-delta-demands-packet' return $decision } |