Public/Invoke-CcToSitMigration.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Invoke-CcToSitMigration { <# .SYNOPSIS Migrate legacy cc-* situation IDs to fresh sit-* IDs across every authoritative reference surface (t/1308 B-301 Stages 2-3). .DESCRIPTION One-way-door data migration. Renames every cc-NNN situation node to sit-<Offset+NNN> and rewrites every cross-reference in-place. Writes a durable mapping file so historical readers (debate transcripts, calibration logs, .bak snapshots) can resolve cc-* → current sit-*. Default -Offset=200 produces sit-201..sit-446 for cc-001..cc-246, leaving 19 numbers (sit-182..sit-200) as a buffer for organic sit-* creation. This choice was approved by TL + owner in t/1308. AUTHORITATIVE SURFACES REWRITTEN: - taxonomy/Origin/situations.json (246 node IDs + 182 cross-refs) - taxonomy/Origin/edges.json (5,047 cc-touching edges) - taxonomy/Origin/embeddings.json (246 embedding keys) - conflicts/conflicts.json (31 refs) - conflicts/_conflict-index.json (31 refs) - taxonomy/Origin/source_evidence_index.json (5 refs) - summaries/*.json (~157 refs across ~92 files) - chats/*.json (occasional refs) FROZEN SURFACES (NOT rewritten, per t/1308 design): - *.bak files (historical snapshots) - edge_discovery_log.json - debates/*.json (completed transcripts are frozen artifacts) - research-artifacts/** (research corpora) - calibration/* (historical logs) Per-file writes are atomic (temp file → File.Move). If any write fails mid-run the partial state is left in place and the mapping file is NOT written so re-runs can proceed from a known point. .PARAMETER DataRoot Override the data repo root. Defaults to Get-DataRoot. .PARAMETER Offset Numeric offset added to each cc-NNN to produce sit-*. Default 200. .PARAMETER DryRun Print the planned change set (per-surface counts + sample mapping) without writing anything. .PARAMETER MappingOut Path to write the mapping file. Default <DataRoot>/taxonomy/Origin/cc-to-sit-mapping.json. .PARAMETER SkipSurfaces Surface names to skip (e.g. 'summaries','chats'). Available: 'situations','edges','embeddings','conflicts','sources','summaries','chats'. .PARAMETER Force Skip the interactive confirmation prompt. .PARAMETER Reverse Apply the reverse migration from an existing mapping file. Path to cc-to-sit-mapping.json; rewrites sit-201..sit-446 back to cc-001..cc-246. Useful as a soft rollback if a corruption is spotted before the pre-cc-migration tag is force-restored. .OUTPUTS PSCustomObject with per-surface change counts and the resolved mapping. .EXAMPLE Invoke-CcToSitMigration -DryRun .EXAMPLE Invoke-CcToSitMigration # interactive .EXAMPLE Invoke-CcToSitMigration -Force -SkipSurfaces summaries,chats .LINK Show-AITriadHelp .LINK Find-SituationCandidates .LINK Invoke-SchemaMigration #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName='Forward')] [OutputType([PSCustomObject])] param( [Parameter(ParameterSetName='Forward')] [Parameter(ParameterSetName='Reverse')] [string]$DataRoot, [Parameter(ParameterSetName='Forward')] [ValidateRange(0, 9000)] [int]$Offset = 200, [Parameter(ParameterSetName='Forward')] [Parameter(ParameterSetName='Reverse')] [switch]$DryRun, [Parameter(ParameterSetName='Forward')] [string]$MappingOut, [Parameter(ParameterSetName='Forward')] [Parameter(ParameterSetName='Reverse')] [ValidateSet('situations','edges','embeddings','conflicts','sources','summaries','chats')] [string[]]$SkipSurfaces = @(), [Parameter(ParameterSetName='Forward')] [Parameter(ParameterSetName='Reverse')] [switch]$Force, [Parameter(ParameterSetName='Reverse', Mandatory)] [string]$Reverse ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── Resolve paths ───────────────────────────────────────────────────── if (-not $DataRoot) { $DataRoot = Get-DataRoot } if (-not (Test-Path $DataRoot)) { throw (New-ActionableError -PassThru ` -Goal 'Locate data repo for cc→sit migration' ` -Problem "Data root not found at $DataRoot" ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @( 'Verify ../ai-triad-data exists', 'Check .aitriad.json data_root setting', 'Pass an explicit -DataRoot path' )) } $TaxDir = Join-Path (Join-Path $DataRoot 'taxonomy') 'Origin' $ConflictsDir = Join-Path $DataRoot 'conflicts' $SummariesDir = Join-Path $DataRoot 'summaries' $ChatsDir = Join-Path $DataRoot 'chats' if (-not $MappingOut) { $MappingOut = Join-Path $TaxDir 'cc-to-sit-mapping.json' } # ── Build mapping ───────────────────────────────────────────────────── $Mapping = [ordered]@{} $IsReverse = $PSCmdlet.ParameterSetName -eq 'Reverse' if ($IsReverse) { if (-not (Test-Path $Reverse)) { throw (New-ActionableError -PassThru ` -Goal 'Reverse cc→sit migration' ` -Problem "Mapping file not found at $Reverse" ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @("Confirm the path to the cc-to-sit-mapping.json produced by the original migration")) } try { $ExistingMap = Get-Content -Raw -Path $Reverse -Encoding utf8 | ConvertFrom-Json } catch { throw (New-ActionableError -PassThru ` -Goal 'Parse mapping file' ` -Problem "Failed to parse $Reverse : $($_.Exception.Message)" ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @('Validate the mapping file JSON syntax')) } if (-not $ExistingMap.PSObject.Properties['mapping']) { throw (New-ActionableError -PassThru ` -Goal 'Reverse cc→sit migration' ` -Problem 'Mapping file has no "mapping" key' ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @('Verify the mapping file is the one written by an earlier forward run')) } # Invert: sit-201 → cc-001, etc. foreach ($p in $ExistingMap.mapping.PSObject.Properties) { $Mapping[[string]$p.Value] = [string]$p.Name } Write-Host "Reverse mode: $($Mapping.Count) sit-* → cc-* mappings loaded from $Reverse" -ForegroundColor Yellow } else { $SitJsonPath = Join-Path $TaxDir 'situations.json' if (-not (Test-Path $SitJsonPath)) { throw (New-ActionableError -PassThru ` -Goal 'Build cc→sit mapping' ` -Problem "situations.json not found at $SitJsonPath" ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @('Verify the data repo taxonomy/Origin directory')) } try { $SitData = Get-Content -Raw -Path $SitJsonPath -Encoding utf8 | ConvertFrom-Json } catch { throw (New-ActionableError -PassThru ` -Goal 'Parse situations.json' ` -Problem "Failed to parse situations.json: $($_.Exception.Message)" ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @('Validate the JSON syntax')) } $CcIds = @() $ExistingSitIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($n in @($SitData.nodes)) { $id = if ($n.PSObject.Properties['id']) { [string]$n.id } else { '' } if ($id.StartsWith('cc-')) { $CcIds += $id } elseif ($id.StartsWith('sit-')) { $ExistingSitIds.Add($id) | Out-Null } } if ($CcIds.Count -eq 0) { Write-Host "No cc-* nodes found in situations.json — migration is a no-op." -ForegroundColor Green return [PSCustomObject]@{ MappingCount = 0 Applied = $false Reason = 'no cc-* nodes present' } } # Build mapping deterministically ordered by numeric suffix $Sorted = $CcIds | Sort-Object { [int]($_ -replace '^cc-','') } foreach ($cc in $Sorted) { $num = [int]($cc -replace '^cc-','') $sit = 'sit-{0:d3}' -f ($num + $Offset) $Mapping[$cc] = $sit } # Collision check $Collisions = @() foreach ($sit in $Mapping.Values) { if ($ExistingSitIds.Contains($sit)) { $Collisions += $sit } } if ($Collisions.Count -gt 0) { throw (New-ActionableError -PassThru ` -Goal 'Verify cc→sit mapping has no collisions' ` -Problem "$($Collisions.Count) proposed sit-* id(s) already exist in situations.json (sample: $(($Collisions | Select-Object -First 5) -join ', '))" ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @( "Increase -Offset to move the migrated block above the collision range", "Or verify situations.json wasn't extended into the sit-201+ range since design approval" )) } Write-Host "Forward mode: $($Mapping.Count) cc-* → sit-* mappings built (range sit-$($Offset+1) .. sit-$($Offset + $CcIds.Count | ForEach-Object { '{0:d3}' -f $_ }))" -ForegroundColor Cyan } # ── Enumerate change surfaces ───────────────────────────────────────── $Surfaces = [System.Collections.Generic.List[PSCustomObject]]::new() $AddSurface = { param([string]$Name, [string]$Path) if ($SkipSurfaces -contains $Name) { return } if (-not (Test-Path $Path)) { return } $Surfaces.Add([PSCustomObject]@{ Name = $Name Path = $Path }) } & $AddSurface 'situations' (Join-Path $TaxDir 'situations.json') & $AddSurface 'edges' (Join-Path $TaxDir 'edges.json') & $AddSurface 'embeddings' (Join-Path $TaxDir 'embeddings.json') & $AddSurface 'sources' (Join-Path $TaxDir 'source_evidence_index.json') & $AddSurface 'conflicts' (Join-Path $ConflictsDir 'conflicts.json') & $AddSurface 'conflicts' (Join-Path $ConflictsDir '_conflict-index.json') # summaries + chats: directory scans (many files) if (-not ($SkipSurfaces -contains 'summaries') -and (Test-Path $SummariesDir)) { foreach ($f in Get-ChildItem -Path $SummariesDir -Filter '*.json' -File -ErrorAction SilentlyContinue) { $Surfaces.Add([PSCustomObject]@{ Name = 'summaries'; Path = $f.FullName }) } } if (-not ($SkipSurfaces -contains 'chats') -and (Test-Path $ChatsDir)) { foreach ($f in Get-ChildItem -Path $ChatsDir -Filter '*.json' -File -ErrorAction SilentlyContinue) { $Surfaces.Add([PSCustomObject]@{ Name = 'chats'; Path = $f.FullName }) } } # Build regex + callback once (shared across surfaces) $Pattern = [regex]'\bcc-\d{3}\b' $Callback = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $key = $m.Value if ($Mapping.Contains($key)) { return $Mapping[$key] } else { return $key } } if ($IsReverse) { # In reverse mode the sources are sit-* IDs in the migrated range $Pattern = [regex]'\bsit-\d{3}\b' } # ── Dry-run: count planned rewrites per surface ─────────────────────── Write-Host '' Write-Host 'Planned change surfaces:' -ForegroundColor Cyan $SurfaceCounts = @{} $GrandTotal = 0 foreach ($s in $Surfaces) { $content = Get-Content -Raw -Path $s.Path -Encoding utf8 $matchCount = 0 foreach ($m in $Pattern.Matches($content)) { if ($Mapping.Contains($m.Value)) { $matchCount++ } } if (-not $SurfaceCounts.ContainsKey($s.Name)) { $SurfaceCounts[$s.Name] = @{ Files = 0; Refs = 0 } } $SurfaceCounts[$s.Name].Files += 1 $SurfaceCounts[$s.Name].Refs += $matchCount $GrandTotal += $matchCount } foreach ($n in @('situations','edges','embeddings','sources','conflicts','summaries','chats')) { if ($SurfaceCounts.ContainsKey($n)) { $c = $SurfaceCounts[$n] Write-Host (" {0,-12} {1,3} file(s), {2,6} refs to rewrite" -f $n, $c.Files, $c.Refs) -ForegroundColor Gray } } Write-Host (" {0,-12} {1,3} file(s), {2,6} refs total" -f '(TOTAL)', @($Surfaces).Count, $GrandTotal) -ForegroundColor White Write-Host '' Write-Host 'Sample mapping (first 3):' -ForegroundColor Cyan $sampleN = [Math]::Min(3, @($Mapping.Keys).Count) foreach ($k in (@($Mapping.Keys) | Select-Object -First $sampleN)) { Write-Host (" {0} → {1}" -f $k, $Mapping[$k]) -ForegroundColor Gray } if ($DryRun) { Write-Host '' Write-Host 'DRY RUN — no writes performed.' -ForegroundColor Yellow return [PSCustomObject]@{ Mode = if ($IsReverse) { 'reverse-dry-run' } else { 'forward-dry-run' } MappingCount = $Mapping.Count Surfaces = @($Surfaces | Group-Object Name | ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Files = $_.Count } }) SurfaceCounts = $SurfaceCounts TotalRefs = $GrandTotal Applied = $false } } # ── Confirm ─────────────────────────────────────────────────────────── if (-not $Force -and -not $PSCmdlet.ShouldProcess("$($Surfaces.Count) file(s), $GrandTotal ref(s)", 'Apply cc→sit migration')) { return [PSCustomObject]@{ Applied = $false; Reason = 'user declined' } } # ── Apply per-surface (atomic per file) ─────────────────────────────── $Written = [System.Collections.Generic.List[PSObject]]::new() $Failed = [System.Collections.Generic.List[PSObject]]::new() foreach ($s in $Surfaces) { try { $orig = Get-Content -Raw -Path $s.Path -Encoding utf8 $new = $Pattern.Replace($orig, $Callback) if ($new -ne $orig) { $tmp = $s.Path + '.tmp' Set-Content -Path $tmp -Value $new -Encoding utf8NoBOM -NoNewline [System.IO.File]::Move($tmp, $s.Path, $true) $Written.Add([PSCustomObject]@{ Name = $s.Name; Path = $s.Path }) } } catch { $Failed.Add([PSCustomObject]@{ Name = $s.Name; Path = $s.Path; Error = $_.Exception.Message }) Write-Warning "Failed to rewrite $($s.Path): $($_.Exception.Message)" } } if ($Failed.Count -gt 0) { throw (New-ActionableError -PassThru ` -Goal 'Apply cc→sit migration' ` -Problem "$($Failed.Count) surface(s) failed to rewrite. Partial state left in place; mapping file NOT written." ` -Location 'Invoke-CcToSitMigration' ` -NextSteps @( "Inspect $($Failed[0].Path): $($Failed[0].Error)", 'Restore from pre-cc-migration git tag if partial state is broken', 'Fix the failure, then re-run Invoke-CcToSitMigration to complete' )) } # ── Write mapping file (forward mode only; reverse doesn't overwrite it) ── if (-not $IsReverse) { $now = (Get-Date).ToString('yyyy-MM-dd') $mappingObj = [ordered]@{ _schema_version = '1.0.0' _doc = "cc-* → sit-* ID migration record (t/1308). Applied $now. Historical readers (debate transcripts, .bak snapshots, calibration logs) should treat cc-* as an alias for the mapped sit-* per this file." applied_at = $now count = $Mapping.Count range_before = "cc-{0:d3}..cc-{1:d3}" -f 1, $Mapping.Count range_after = "sit-{0:d3}..sit-{1:d3}" -f ($Offset + 1), ($Offset + $Mapping.Count) mapping = [ordered]@{} } foreach ($k in $Mapping.Keys) { $mappingObj.mapping[$k] = $Mapping[$k] } $json = ($mappingObj | ConvertTo-Json -Depth 8) $tmp = $MappingOut + '.tmp' Set-Content -Path $tmp -Value $json -Encoding utf8NoBOM -NoNewline [System.IO.File]::Move($tmp, $MappingOut, $true) Write-Host '' Write-Host "Mapping file written: $MappingOut" -ForegroundColor Green } Write-Host '' Write-Host "Migration applied: $($Written.Count) file(s) rewritten, $GrandTotal ref(s) migrated." -ForegroundColor Green return [PSCustomObject]@{ Mode = if ($IsReverse) { 'reverse-applied' } else { 'forward-applied' } MappingCount = $Mapping.Count Offset = $Offset FilesWritten = $Written.Count TotalRefs = $GrandTotal SurfaceCounts = $SurfaceCounts MappingFile = if ($IsReverse) { $Reverse } else { $MappingOut } Applied = $true } } |