skills/memory-bank/scripts/New-MemoryBankRoleMigrationPlan.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
    Creates a read-only plan for namespacing legacy Memory Bank role records.
.DESCRIPTION
    Inventories direct child files under a selected repository's .memory-bank,
    classifies them with the bundled migration map, and returns metadata only.
    No file is written unless SavePlan is specified.
.PARAMETER Path
    Existing repository directory whose .memory-bank will be inventoried.
.PARAMETER Assignment
    Decisions for ambiguous file names. Values are career, legal, tax,
    ManualSplit, or Skip.
.PARAMETER SavePlan
    Saves the metadata-only plan under .memory-bank/session.
.PARAMETER ReferenceTime
    UTC time used in plan metadata and the saved plan file name.
.EXAMPLE
    ./New-MemoryBankRoleMigrationPlan.ps1 -Path C:/Git/MyProject
 
    Returns an in-memory plan without writing files.
.EXAMPLE
    ./New-MemoryBankRoleMigrationPlan.ps1 -Path C:/Git/MyProject -Assignment @{
        'deadlines.md' = 'tax'
    } -SavePlan
 
    Saves a plan after assigning the ambiguous deadlines file to tax.
.OUTPUTS
    System.Management.Automation.PSCustomObject
#>


[CmdletBinding(SupportsShouldProcess)]
[OutputType([PSCustomObject])]
param
(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string]$Path = (Get-Location).Path,

    [Parameter()]
    [ValidateNotNull()]
    [hashtable]$Assignment = @{},

    [Parameter()]
    [switch]$SavePlan,

    [Parameter()]
    [ValidateNotNull()]
    [datetime]$ReferenceTime = [datetime]::UtcNow
)

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-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 Assert-LeafName
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Field
    )

    if ($Name -match '[/\\]' -or $Name -in @('.', '..'))
    {
        throw "$Field must be a file-name pattern without a directory: '$Name'."
    }
}

function Get-NormalizedDecision
{
    [CmdletBinding()]
    [OutputType([string])]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Value
    )

    switch ($Value.ToLowerInvariant())
    {
        'career' { return 'career' }
        'legal' { return 'legal' }
        'tax' { return 'tax' }
        'manualsplit' { return 'ManualSplit' }
        'skip' { return 'Skip' }
        default
        {
            throw "Assignment for '$Name' must be career, legal, tax, ManualSplit, or Skip. Received '$Value'."
        }
    }
}

function Write-Utf8FileExclusive
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$LiteralPath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Content
    )

    $encoding = [Text.UTF8Encoding]::new($false)
    $bytes = $encoding.GetBytes(
        ($Content -replace "`r`n", "`n").TrimEnd("`n") + "`n"
    )
    $stream = [IO.File]::Open(
        $LiteralPath,
        [IO.FileMode]::CreateNew,
        [IO.FileAccess]::Write,
        [IO.FileShare]::None
    )
    try
    {
        $stream.Write($bytes, 0, $bytes.Length)
    }
    finally
    {
        $stream.Dispose()
    }
}

$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'. Initialize it before planning role-record migration."
}
if (Test-ReparsePoint -LiteralPath $memoryBankPath)
{
    throw "Memory Bank root must not be a symbolic link, junction, or reparse point: '$memoryBankPath'."
}

$migrationMapPath = Join-Path (Split-Path -Parent $PSScriptRoot) (
    'assets/role-record-migration-map.json'
)
if (-not (Test-Path -LiteralPath $migrationMapPath -PathType Leaf))
{
    throw "Migration map not found: '$migrationMapPath'."
}
$migrationMap = Get-Content -LiteralPath $migrationMapPath -Raw -Encoding UTF8 |
    ConvertFrom-Json -ErrorAction Stop
if ($migrationMap.schemaVersion -ne 1)
{
    throw "Unsupported migration map schema version '$($migrationMap.schemaVersion)'. Expected 1."
}

$allowedRole = @('career', 'legal', 'tax')
$automaticRule = @($migrationMap.automaticRules)
if ($automaticRule.Count -eq 0)
{
    throw 'Migration map must define at least one automatic rule.'
}
foreach ($rule in $automaticRule)
{
    Assert-LeafName -Name ([string]$rule.pattern) -Field 'Automatic rule pattern'
    if ([string]$rule.role -notin $allowedRole)
    {
        throw "Automatic rule '$($rule.pattern)' has unsupported role '$($rule.role)'."
    }
}
$ambiguousName = @($migrationMap.ambiguousNames | ForEach-Object { [string]$_ })
$excludedName = @($migrationMap.excludedNames | ForEach-Object { [string]$_ })
foreach ($name in @($ambiguousName) + @($excludedName))
{
    Assert-LeafName -Name $name -Field 'Migration map name'
}

$normalizedAssignment = @{}
foreach ($entry in $Assignment.GetEnumerator())
{
    $name = [string]$entry.Key
    Assert-LeafName -Name $name -Field 'Assignment name'
    if ($name -notin $ambiguousName)
    {
        throw "Assignment '$name' is not one of the ambiguous migration files."
    }
    $normalizedAssignment[$name] = Get-NormalizedDecision `
        -Name $name `
        -Value ([string]$entry.Value)
}

$planEntry = @(
    foreach ($file in Get-ChildItem -LiteralPath $memoryBankPath -Force |
        Where-Object { -not $_.PSIsContainer } |
        Sort-Object -Property Name)
    {
        if (Test-ReparsePoint -LiteralPath $file.FullName)
        {
            throw "Legacy role record must not be a symbolic link or reparse point: '$($file.FullName)'."
        }

        $classification = 'Unknown'
        $decision = $null
        $role = $null
        $status = 'Unknown'

        if ($file.Name -in $excludedName)
        {
            $classification = 'Excluded'
            $status = 'Excluded'
        }
        elseif ($file.Name -in $ambiguousName)
        {
            $classification = 'Ambiguous'
            if ($normalizedAssignment.ContainsKey($file.Name))
            {
                $decision = $normalizedAssignment[$file.Name]
                switch ($decision)
                {
                    'ManualSplit' { $status = 'ManualSplit' }
                    'Skip' { $status = 'Skipped' }
                    default
                    {
                        $role = $decision
                        $status = 'Ready'
                    }
                }
            }
            else
            {
                $status = 'NeedsAssignment'
            }
        }
        else
        {
            foreach ($rule in $automaticRule)
            {
                if ($file.Name -like [string]$rule.pattern)
                {
                    $classification = 'Automatic'
                    $role = [string]$rule.role
                    $decision = $role
                    $status = 'Ready'
                    break
                }
            }
        }

        $sourceHash = Get-LowerFileHash -LiteralPath $file.FullName
        $destination = $null
        if ($role)
        {
            $rolePath = Join-Path $memoryBankPath $role
            if (Test-Path -LiteralPath $rolePath)
            {
                if (-not (Test-Path -LiteralPath $rolePath -PathType Container))
                {
                    throw "Role destination is not a directory: '$rolePath'."
                }
                if (Test-ReparsePoint -LiteralPath $rolePath)
                {
                    throw "Role destination must not be a symbolic link or reparse point: '$rolePath'."
                }
            }

            $destination = ".memory-bank/$role/$($file.Name)"
            $destinationPath = Join-Path $rolePath $file.Name
            if (Test-Path -LiteralPath $destinationPath)
            {
                if (-not (Test-Path -LiteralPath $destinationPath -PathType Leaf))
                {
                    $status = 'Conflict'
                }
                elseif (Test-ReparsePoint -LiteralPath $destinationPath)
                {
                    throw "Role destination file must not be a symbolic link or reparse point: '$destinationPath'."
                }
                elseif ((Get-LowerFileHash -LiteralPath $destinationPath) -eq $sourceHash)
                {
                    $status = 'AlreadyMigrated'
                }
                else
                {
                    $status = 'Conflict'
                }
            }
        }

        [PSCustomObject][ordered]@{
            name = $file.Name
            source = ".memory-bank/$($file.Name)"
            sourceSha256 = $sourceHash
            size = [long]$file.Length
            classification = $classification
            decision = $decision
            role = $role
            destination = $destination
            status = $status
        }
    }
)

$planPath = $null
if ($SavePlan)
{
    $sessionPath = Join-Path $memoryBankPath 'session'
    if (Test-Path -LiteralPath $sessionPath)
    {
        if (-not (Test-Path -LiteralPath $sessionPath -PathType Container))
        {
            throw "Memory Bank session path is not a directory: '$sessionPath'."
        }
        if (Test-ReparsePoint -LiteralPath $sessionPath)
        {
            throw "Memory Bank session path must not be a symbolic link or reparse point: '$sessionPath'."
        }
    }
    elseif ($PSCmdlet.ShouldProcess($sessionPath, 'Create migration plan directory'))
    {
        New-Item -ItemType Directory -Path $sessionPath -ErrorAction Stop |
            Out-Null
    }

    $fileName = 'role-record-migration-{0}.json' -f (
        $ReferenceTime.ToUniversalTime().ToString('yyyy-MM-ddTHHmmssZ')
    )
    $planPath = ".memory-bank/session/$fileName"
}

$plan = [PSCustomObject][ordered]@{
    schemaVersion = 1
    createdUtc = $ReferenceTime.ToUniversalTime().ToString(
        'yyyy-MM-ddTHH:mm:ss.fffZ'
    )
    repositoryRoot = $repositoryRoot
    planPath = $planPath
    entries = $planEntry
}

if ($SavePlan)
{
    $absolutePlanPath = Join-Path $repositoryRoot $planPath
    if ($PSCmdlet.ShouldProcess($absolutePlanPath, 'Write role-record migration plan'))
    {
        Write-Utf8FileExclusive `
            -LiteralPath $absolutePlanPath `
            -Content ($plan | ConvertTo-Json -Depth 10)
    }
}

return $plan