skills/memory-bank/scripts/Invoke-MemoryBankRoleMigration.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Applies a validated Memory Bank role-record migration plan by copying files. .DESCRIPTION Validates the complete saved plan before writing, copies ready source files into career, legal, or tax namespaces without overwriting destinations, verifies SHA-256 after every copy, and never removes a source file. .PARAMETER Path Existing repository directory that must match the plan repository root. .PARAMETER PlanPath Saved role-record migration plan under .memory-bank/session. .EXAMPLE ./Invoke-MemoryBankRoleMigration.ps1 -Path C:/Git/MyProject ` -PlanPath C:/Git/MyProject/.memory-bank/session/role-record-migration-2026-09-04T100000Z.json ` -WhatIf Validates and previews the complete migration without writing files. .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] [OutputType([PSCustomObject])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] [string]$Path = (Get-Location).Path, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] [string]$PlanPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Get-NormalizedFullPath { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) $fullPath = [IO.Path]::GetFullPath($LiteralPath) $pathRoot = [IO.Path]::GetPathRoot($fullPath) if ($fullPath.Length -eq $pathRoot.Length) { return $fullPath } return $fullPath.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) } function Test-PathEqual { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Left, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Right ) $comparison = if ($env:OS -eq 'Windows_NT') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } return [string]::Equals($Left, $Right, $comparison) } function Test-ReparsePoint { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) $item = Get-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop return [bool]( $item.Attributes -band [IO.FileAttributes]::ReparsePoint ) } function Get-LowerFileHash { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) return (Get-FileHash -LiteralPath $LiteralPath -Algorithm SHA256).Hash. ToLowerInvariant() } function Get-RepositoryPath { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RepositoryRoot, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$RelativePath ) $platformPath = $RelativePath.Replace( '/', [IO.Path]::DirectorySeparatorChar ) return Get-NormalizedFullPath -LiteralPath ( Join-Path $RepositoryRoot $platformPath ) } $repositoryItem = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if (Test-ReparsePoint -LiteralPath $repositoryItem.FullName) { throw "Repository root must not be a symbolic link, junction, or reparse point: '$($repositoryItem.FullName)'." } $repositoryRoot = Get-NormalizedFullPath -LiteralPath $repositoryItem.FullName $memoryBankPath = Join-Path $repositoryRoot '.memory-bank' if (-not (Test-Path -LiteralPath $memoryBankPath -PathType Container)) { throw "Memory Bank not found: '$memoryBankPath'." } if (Test-ReparsePoint -LiteralPath $memoryBankPath) { throw "Memory Bank root must not be a symbolic link, junction, or reparse point: '$memoryBankPath'." } $absolutePlanPath = Get-NormalizedFullPath -LiteralPath ( (Get-Item -LiteralPath $PlanPath -Force -ErrorAction Stop).FullName ) if (Test-ReparsePoint -LiteralPath $absolutePlanPath) { throw "Migration plan must not be a symbolic link or reparse point: '$absolutePlanPath'." } $plan = Get-Content -LiteralPath $absolutePlanPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($plan.schemaVersion -ne 1) { throw "Unsupported migration plan schema version '$($plan.schemaVersion)'. Expected 1." } $plannedRepositoryRoot = Get-NormalizedFullPath -LiteralPath ( [string]$plan.repositoryRoot ) if (-not (Test-PathEqual -Left $repositoryRoot -Right $plannedRepositoryRoot)) { throw "Migration plan repository root '$plannedRepositoryRoot' does not match selected repository root '$repositoryRoot'." } $sessionPath = Join-Path $memoryBankPath 'session' if (-not (Test-Path -LiteralPath $sessionPath -PathType Container)) { throw "Memory Bank session directory not found: '$sessionPath'." } if (Test-ReparsePoint -LiteralPath $sessionPath) { throw "Memory Bank session directory must not be a symbolic link or reparse point: '$sessionPath'." } $planParent = Get-NormalizedFullPath -LiteralPath ( Split-Path -Parent $absolutePlanPath ) if (-not (Test-PathEqual -Left $planParent -Right ( Get-NormalizedFullPath -LiteralPath $sessionPath ))) { throw "Migration plan must be a direct child of '$sessionPath'." } if ([IO.Path]::GetFileName($absolutePlanPath) -notlike ( 'role-record-migration-*.json' )) { throw "Migration plan file name must match 'role-record-migration-*.json'." } $allowedRole = @('career', 'legal', 'tax') $passiveStatus = @('Excluded', 'Unknown', 'ManualSplit', 'Skipped') $candidateStatus = @('Ready', 'AlreadyMigrated') $blockingMessage = [Collections.Generic.List[string]]::new() $validatedEntry = [Collections.Generic.List[object]]::new() $sourceKey = @{} $destinationKey = @{} foreach ($entry in @($plan.entries)) { $name = [string]$entry.name $source = ([string]$entry.source).Replace('\', '/') $status = [string]$entry.status if ([string]::IsNullOrWhiteSpace($name) -or $name -match '[/\\]' -or $name -in @('.', '..')) { $blockingMessage.Add("Invalid migration entry name '$name'.") continue } if ($source -notmatch '^\.memory-bank/[^/]+$' -or [IO.Path]::GetFileName($source) -cne $name) { $blockingMessage.Add( "Source '$source' must identify the named direct child of .memory-bank." ) continue } $normalizedSourceKey = if ($env:OS -eq 'Windows_NT') { $source.ToLowerInvariant() } else { $source } if ($sourceKey.ContainsKey($normalizedSourceKey)) { $blockingMessage.Add("Duplicate source '$source'.") continue } $sourceKey[$normalizedSourceKey] = $true $sourcePath = Get-RepositoryPath ` -RepositoryRoot $repositoryRoot ` -RelativePath $source $sourceParent = Get-NormalizedFullPath -LiteralPath ( Split-Path -Parent $sourcePath ) if (-not (Test-PathEqual -Left $sourceParent -Right ( Get-NormalizedFullPath -LiteralPath $memoryBankPath ))) { $blockingMessage.Add( "Source '$source' must remain a direct child of the Memory Bank root." ) continue } if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { $blockingMessage.Add("Source file not found: '$sourcePath'.") continue } if (Test-ReparsePoint -LiteralPath $sourcePath) { $blockingMessage.Add( "Source must not be a symbolic link or reparse point: '$sourcePath'." ) continue } $sourceHash = Get-LowerFileHash -LiteralPath $sourcePath if ($sourceHash -cne ([string]$entry.sourceSha256).ToLowerInvariant()) { $blockingMessage.Add( "Migration source hash changed after planning: '$source'." ) continue } if ([long](Get-Item -LiteralPath $sourcePath).Length -ne [long]$entry.size) { $blockingMessage.Add( "Migration source size changed after planning: '$source'." ) continue } if ($status -eq 'NeedsAssignment') { $blockingMessage.Add("NeedsAssignment: '$source'.") continue } if ($status -eq 'Conflict') { $blockingMessage.Add("Conflict: '$source'.") continue } if ($status -notin @($passiveStatus) + @($candidateStatus)) { $blockingMessage.Add( "Unsupported migration entry status '$status' for '$source'." ) continue } if ($status -in $passiveStatus) { $validatedEntry.Add([PSCustomObject]@{ Entry = $entry SourcePath = $sourcePath DestinationPath = $null Action = 'Preserved' }) continue } $role = [string]$entry.role if ($role -notin $allowedRole) { $blockingMessage.Add( "Migration role for '$source' must be career, legal, or tax. Received '$role'." ) continue } $expectedDestination = ".memory-bank/$role/$name" $destination = ([string]$entry.destination).Replace('\', '/') if ($destination -cne $expectedDestination) { $blockingMessage.Add( "Destination '$destination' does not match expected '$expectedDestination'." ) continue } $normalizedDestinationKey = if ($env:OS -eq 'Windows_NT') { $destination.ToLowerInvariant() } else { $destination } if ($destinationKey.ContainsKey($normalizedDestinationKey)) { $blockingMessage.Add("Duplicate destination '$destination'.") continue } $destinationKey[$normalizedDestinationKey] = $true $rolePath = Join-Path $memoryBankPath $role if (Test-Path -LiteralPath $rolePath) { if (-not (Test-Path -LiteralPath $rolePath -PathType Container)) { $blockingMessage.Add("Role destination is not a directory: '$rolePath'.") continue } if (Test-ReparsePoint -LiteralPath $rolePath) { $blockingMessage.Add( "Role destination must not be a symbolic link or reparse point: '$rolePath'." ) continue } } $destinationPath = Get-RepositoryPath ` -RepositoryRoot $repositoryRoot ` -RelativePath $destination $currentAction = 'Ready' if (Test-Path -LiteralPath $destinationPath) { if (-not (Test-Path -LiteralPath $destinationPath -PathType Leaf)) { $blockingMessage.Add("Conflict: destination is not a file '$destinationPath'.") continue } if (Test-ReparsePoint -LiteralPath $destinationPath) { $blockingMessage.Add( "Destination must not be a symbolic link or reparse point: '$destinationPath'." ) continue } if ((Get-LowerFileHash -LiteralPath $destinationPath) -eq $sourceHash) { $currentAction = 'AlreadyMigrated' } else { $blockingMessage.Add("Conflict: destination content differs '$destinationPath'.") continue } } $validatedEntry.Add([PSCustomObject]@{ Entry = $entry SourcePath = $sourcePath DestinationPath = $destinationPath Action = $currentAction }) } if ($blockingMessage.Count -gt 0) { throw "Migration plan validation failed:`n- $($blockingMessage -join "`n- ")" } foreach ($validated in $validatedEntry) { $entry = $validated.Entry if ($validated.Action -eq 'Preserved') { [PSCustomObject]@{ Source = [string]$entry.source Destination = [string]$entry.destination Action = 'Preserved' Status = [string]$entry.status } continue } if ($validated.Action -eq 'AlreadyMigrated') { [PSCustomObject]@{ Source = [string]$entry.source Destination = [string]$entry.destination Action = 'AlreadyMigrated' Status = 'Verified' } continue } $destinationPath = $validated.DestinationPath $destinationDirectory = Split-Path -Parent $destinationPath if (-not (Test-Path -LiteralPath $destinationDirectory -PathType Container)) { if ($PSCmdlet.ShouldProcess( $destinationDirectory, 'Create role-record directory' )) { New-Item ` -ItemType Directory ` -Path $destinationDirectory ` -ErrorAction Stop | Out-Null } } if (-not $PSCmdlet.ShouldProcess( $destinationPath, "Copy role record from '$($validated.SourcePath)'" )) { [PSCustomObject]@{ Source = [string]$entry.source Destination = [string]$entry.destination Action = 'Planned' Status = 'Ready' } continue } if (Test-ReparsePoint -LiteralPath $destinationDirectory) { throw "Role destination became a symbolic link or reparse point: '$destinationDirectory'." } $destinationCreated = $false try { [IO.File]::Copy( $validated.SourcePath, $destinationPath, $false ) $destinationCreated = $true $expectedHash = ([string]$entry.sourceSha256).ToLowerInvariant() $sourceHashAfterCopy = Get-LowerFileHash ` -LiteralPath $validated.SourcePath $destinationHash = Get-LowerFileHash -LiteralPath $destinationPath if ($sourceHashAfterCopy -cne $expectedHash -or $destinationHash -cne $expectedHash) { throw "Hash verification failed after copying '$($entry.source)'." } } catch { $copyError = $_ if ($destinationCreated -and (Test-Path -LiteralPath $destinationPath -PathType Leaf)) { [IO.File]::Delete($destinationPath) } throw $copyError } [PSCustomObject]@{ Source = [string]$entry.source Destination = [string]$entry.destination Action = 'Copied' Status = 'Verified' } } |