workflows/default/systems/mcp/modules/TaskMutation.psm1

<#
.SYNOPSIS
Task mutation helpers for todo edits, deletions, restores, and ignore state.

.DESCRIPTION
Provides audited mutations for todo tasks. Edited and deleted snapshots are
archived under todo\edited_tasks and todo\deleted_tasks so operators can view
or restore previous versions.
#>


function Get-TaskMutationProjectRoot {
    if ($global:DotbotProjectRoot) {
        return $global:DotbotProjectRoot
    }

    $cursor = $PSScriptRoot
    while ($cursor) {
        if ((Split-Path -Leaf $cursor) -eq ".bot") {
            return (Split-Path -Parent $cursor)
        }

        $parent = Split-Path -Parent $cursor
        if (-not $parent -or $parent -eq $cursor) {
            break
        }
        $cursor = $parent
    }

    throw "Dotbot project root could not be resolved"
}

function Get-TasksBaseDir {
    param(
        [string]$TasksBaseDir
    )

    if ($TasksBaseDir) {
        return $TasksBaseDir
    }

    $projectRoot = Get-TaskMutationProjectRoot
    return (Join-Path $projectRoot ".bot\workspace\tasks")
}

function Get-TodoDirectories {
    param(
        [string]$TasksBaseDir
    )

    $resolvedBaseDir = Get-TasksBaseDir -TasksBaseDir $TasksBaseDir
    $todoDir = Join-Path $resolvedBaseDir "todo"
    $editedDir = Join-Path $todoDir "edited_tasks"
    $deletedDir = Join-Path $todoDir "deleted_tasks"

    return @{
        TasksBaseDir = $resolvedBaseDir
        TodoDir = $todoDir
        EditedDir = $editedDir
        DeletedDir = $deletedDir
    }
}

function Ensure-TodoDirectories {
    param(
        [string]$TasksBaseDir
    )

    $paths = Get-TodoDirectories -TasksBaseDir $TasksBaseDir
    foreach ($dir in @($paths.TodoDir, $paths.EditedDir, $paths.DeletedDir)) {
        if (-not (Test-Path $dir)) {
            New-Item -ItemType Directory -Path $dir -Force | Out-Null
        }
    }

    return $paths
}

function Get-ArchiveActor {
    param(
        [string]$Actor
    )

    if ($Actor) {
        return $Actor
    }

    $userName = [System.Environment]::UserName
    if ($userName) {
        return $userName
    }

    return "unknown"
}

function Get-AuditUsername {
    if ($IsWindows) {
        try {
            $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
            if ($identity -and $identity.Name) {
                return $identity.Name
            }
        } catch {
            # Fall back to cross-platform APIs below.
        }
    }

    $user = [System.Environment]::UserName

    if ($IsWindows) {
        $domain = [System.Environment]::UserDomainName
        if ($domain -and $user -and $domain -ne $user) {
            return "$domain\$user"
        }
    }

    if ($user) {
        return $user
    }

    return "unknown"
}

function Set-TaskAuditUser {
    param(
        [Parameter(Mandatory)]
        [object]$Target,
        [string]$PropertyName = "updated_by_user",
        [string]$UserName
    )

    $resolvedUserName = if ($UserName) { $UserName } else { Get-AuditUsername }
    if ($Target.PSObject.Properties[$PropertyName]) {
        $Target.$PropertyName = $resolvedUserName
    } else {
        $Target | Add-Member -NotePropertyName $PropertyName -NotePropertyValue $resolvedUserName -Force
    }
}
function Get-UtcTimestamp {
    return (Get-Date).ToUniversalTime().ToString("o")
}

function ConvertTo-DeepClone {
    param(
        [Parameter(Mandatory)]
        [object]$InputObject
    )

    if ($null -eq $InputObject) {
        return $null
    }

    return ($InputObject | ConvertTo-Json -Depth 30 | ConvertFrom-Json)
}

function ConvertTo-TaskArray {
    param(
        [object]$Value
    )

    if ($null -eq $Value) {
        return @()
    }

    if ($Value -is [string]) {
        return @($Value)
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        return @($Value)
    }

    return @($Value)
}

function Get-TaskSlug {
    param(
        [string]$Name
    )

    if (-not $Name) {
        return ""
    }

    return (($Name -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-').ToLower())
}

function Get-TaskPriorityValue {
    param(
        [object]$Task
    )

    try {
        if ($null -ne $Task.priority -and "$($Task.priority)".Trim()) {
            return [int]$Task.priority
        }
    } catch {
        # Keep malformed priorities at the end of the ordinal alias list.
    }

    return [int]::MaxValue
}

function Add-TaskReferenceAlias {
    param(
        [Parameter(Mandatory)]
        [hashtable]$ReferenceMap,
        [Parameter(Mandatory)]
        [string]$TaskId,
        [object]$Alias
    )

    if ($null -eq $Alias) {
        return
    }

    $normalizedAlias = "$Alias".Trim().ToLower()
    if (-not $normalizedAlias) {
        return
    }

    $ReferenceMap[$normalizedAlias] = $TaskId
}

function Get-TaskDependencyReferenceTokens {
    param(
        [object]$Dependency
    )

    if ($null -eq $Dependency) {
        return @()
    }

    $rawDependency = "$Dependency".Trim()
    if (-not $rawDependency) {
        return @()
    }

    $tokens = [System.Collections.Generic.List[string]]::new()

    function Add-DependencyToken {
        param(
            [string]$Value
        )

        if (-not $Value) {
            return
        }

        $normalizedValue = $Value.Trim().ToLower()
        if ($normalizedValue -and -not $tokens.Contains($normalizedValue)) {
            $null = $tokens.Add($normalizedValue)
        }
    }

    Add-DependencyToken -Value $rawDependency

    $ordinalMatch = [regex]::Match($rawDependency, '^tasks?\s+(.+)$', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
    if ($ordinalMatch.Success) {
        $ordinalExpression = $ordinalMatch.Groups[1].Value.Trim()
        Add-DependencyToken -Value $ordinalExpression

        foreach ($part in ($ordinalExpression -split '\s*,\s*')) {
            $normalizedPart = [regex]::Replace($part, '^tasks?\s+', '', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase).Trim()
            if (-not $normalizedPart) {
                continue
            }

            Add-DependencyToken -Value $normalizedPart
            Add-DependencyToken -Value "task $normalizedPart"
        }

        return @($tokens)
    }

    foreach ($part in ($rawDependency -split '\s*,\s*')) {
        Add-DependencyToken -Value $part
    }

    return @($tokens)
}

function Get-RoadmapOverviewDependencyMap {
    param(
        [string]$TasksBaseDir
    )

    $resolvedBaseDir = Get-TasksBaseDir -TasksBaseDir $TasksBaseDir
    $workspaceDir = Split-Path -Parent $resolvedBaseDir
    $overviewPath = Join-Path $workspaceDir "product\roadmap-overview.md"
    $dependencyMap = @{}
    if (-not (Test-Path $overviewPath)) {
        return $dependencyMap
    }

    foreach ($line in @(Get-Content -Path $overviewPath -ErrorAction SilentlyContinue)) {
        if ($line -notmatch '^\|\s*\d+\s*\|') {
            continue
        }

        $cells = ($line.Trim().Trim('|') -split '\s*\|\s*')
        if ($cells.Count -lt 5) {
            continue
        }

        $methodologyMatch = [regex]::Match($cells[2], '`([^`]+)`')
        if (-not $methodologyMatch.Success) {
            continue
        }

        $methodologyKey = $methodologyMatch.Groups[1].Value.Trim().ToLower()
        if (-not $methodologyKey) {
            continue
        }

        $dependencyText = $cells[3].Trim()
        if (-not $dependencyText -or $dependencyText -match '^(none|n/a)$') {
            $dependencyMap[$methodologyKey] = @()
            continue
        }

        $dependencyMap[$methodologyKey] = @($dependencyText)
    }

    return $dependencyMap
}

function Get-ResolvedTaskDependencies {
    param(
        [Parameter(Mandatory)]
        [object]$Task,
        [Parameter(Mandatory)]
        [hashtable]$RoadmapDependencyMap
    )

    $explicitDependencies = @((ConvertTo-TaskArray -Value $Task.dependencies) | Where-Object { $null -ne $_ -and "$($_)".Trim() })
    if ($explicitDependencies.Count -gt 0) {
        return $explicitDependencies
    }

    $researchPrompt = "$($Task.research_prompt)".Trim().ToLower()
    if ($researchPrompt -and $RoadmapDependencyMap.ContainsKey($researchPrompt)) {
        return @($RoadmapDependencyMap[$researchPrompt])
    }

    return @()
}

function Get-TodoTaskRecord {
    param(
        [Parameter(Mandatory)]
        [string]$TaskId,
        [string]$TasksBaseDir
    )

    $paths = Ensure-TodoDirectories -TasksBaseDir $TasksBaseDir
    $files = Get-ChildItem -Path $paths.TodoDir -Filter "*.json" -File -ErrorAction SilentlyContinue

    foreach ($file in $files) {
        try {
            $task = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
            if ($task.id -eq $TaskId) {
                return @{
                    task = $task
                    path = $file.FullName
                    file_name = $file.Name
                    todo_dir = $paths.TodoDir
                    edited_dir = $paths.EditedDir
                    deleted_dir = $paths.DeletedDir
                    tasks_base_dir = $paths.TasksBaseDir
                }
            }
        } catch {
            Write-BotLog -Level Warn -Message "[TaskMutation] Failed to read task file '$($file.FullName)'" -Exception $_
        }
    }

    return $null
}

function Save-TaskFile {
    param(
        [Parameter(Mandatory)]
        [object]$Task,
        [Parameter(Mandatory)]
        [string]$Path
    )

    $Task | ConvertTo-Json -Depth 30 | Set-Content -Path $Path -Encoding UTF8
}

function Write-TaskArchive {
    param(
        [Parameter(Mandatory)]
        [object]$Task,
        [Parameter(Mandatory)]
        [string]$ArchiveDir,
        [Parameter(Mandatory)]
        [ValidateSet("edit", "delete")]
        [string]$ArchiveKind,
        [Parameter(Mandatory)]
        [string]$Actor,
        [string]$SourceStatus = "todo",
        [string]$SourceFileName = ""
    )

    if (-not (Test-Path $ArchiveDir)) {
        New-Item -ItemType Directory -Path $ArchiveDir -Force | Out-Null
    }

    $capturedAt = Get-UtcTimestamp
    $versionId = [guid]::NewGuid().ToString()
    $safeTaskId = ($Task.id -replace '[^a-zA-Z0-9_-]', '_')
    $stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmssfff")
    $archivePath = Join-Path $ArchiveDir "$safeTaskId--$stamp--$($versionId.Substring(0, 8)).json"

    $archiveRecord = [ordered]@{
        version_id = $versionId
        task_id = $Task.id
        archive_kind = $ArchiveKind
        source_status = $SourceStatus
        source_file_name = $SourceFileName
        captured_at = $capturedAt
        captured_by = $Actor
        captured_by_user = Get-AuditUsername
        task = ConvertTo-DeepClone -InputObject $Task
    }

    $archiveRecord | ConvertTo-Json -Depth 30 | Set-Content -Path $archivePath -Encoding UTF8

    return ($archiveRecord | ConvertTo-Json -Depth 30 | ConvertFrom-Json)
}
function Get-NonTodoTaskIds {
    param(
        [string]$TasksBaseDir
    )

    $resolvedBaseDir = Get-TasksBaseDir -TasksBaseDir $TasksBaseDir
    $nonTodoTaskIds = @{}

    foreach ($status in @('analysing', 'needs-input', 'analysed', 'in-progress', 'done', 'split', 'skipped', 'cancelled')) {
        $statusDir = Join-Path $resolvedBaseDir $status
        if (-not (Test-Path $statusDir)) {
            continue
        }

        foreach ($file in @(Get-ChildItem -Path $statusDir -Filter '*.json' -File -ErrorAction SilentlyContinue)) {
            try {
                $task = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
                if ($task.id) {
                    $nonTodoTaskIds[$task.id] = $true
                }
            } catch {
                Write-BotLog -Level Warn -Message "[TaskMutation] Failed to read non-todo task file '$($file.FullName)'" -Exception $_
            }
        }
    }

    return $nonTodoTaskIds
}

function Get-TodoTaskLookup {
    param(
        [string]$TasksBaseDir
    )

    $paths = Ensure-TodoDirectories -TasksBaseDir $TasksBaseDir
    $lookup = @{}
    $referenceMap = @{}
    $orderedTasks = [System.Collections.Generic.List[object]]::new()
    $excludedTaskIds = Get-NonTodoTaskIds -TasksBaseDir $paths.TasksBaseDir

    $files = Get-ChildItem -Path $paths.TodoDir -Filter "*.json" -File -ErrorAction SilentlyContinue
    foreach ($file in $files) {
        try {
            $task = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
            if (-not $task.id) {
                continue
            }

            if ($excludedTaskIds.ContainsKey($task.id)) {
                continue
            }

            $lookup[$task.id] = $task
            $null = $orderedTasks.Add($task)
        } catch {
            Write-BotLog -Level Warn -Message "[TaskMutation] Failed to read task file '$($file.FullName)'" -Exception $_
        }
    }

    $position = 0
    foreach ($task in @($orderedTasks) | Sort-Object @{ Expression = { Get-TaskPriorityValue -Task $_ } }, @{ Expression = { $_.name } }, @{ Expression = { $_.id } }) {
        $position += 1
        Add-TaskReferenceAlias -ReferenceMap $referenceMap -TaskId $task.id -Alias $task.id
        Add-TaskReferenceAlias -ReferenceMap $referenceMap -TaskId $task.id -Alias $task.name
        Add-TaskReferenceAlias -ReferenceMap $referenceMap -TaskId $task.id -Alias (Get-TaskSlug -Name $task.name)
        Add-TaskReferenceAlias -ReferenceMap $referenceMap -TaskId $task.id -Alias $position
        Add-TaskReferenceAlias -ReferenceMap $referenceMap -TaskId $task.id -Alias "task $position"
    }

    return @{
        Tasks = $lookup
        References = $referenceMap
    }
}

function Get-TaskIgnoreStateMap {
    param(
        [string]$TasksBaseDir
    )

    $resolvedBaseDir = Get-TasksBaseDir -TasksBaseDir $TasksBaseDir
    $lookup = Get-TodoTaskLookup -TasksBaseDir $resolvedBaseDir
    $tasks = $lookup.Tasks
    $references = $lookup.References
    $roadmapDependencyMap = Get-RoadmapOverviewDependencyMap -TasksBaseDir $resolvedBaseDir
    $memo = @{}
    $resolving = @{}

    function Resolve-IgnoreState {
        param(
            [Parameter(Mandatory)]
            [string]$TaskId
        )

        if ($memo.ContainsKey($TaskId)) {
            return $memo[$TaskId]
        }

        if ($resolving.ContainsKey($TaskId)) {
            return [pscustomobject]@{
                task_id = $TaskId
                manual = $false
                effective = $false
                auto = $false
                blocking_task_ids = @()
                blocking_task_names = @()
                updated_at = $null
                updated_by = $null
                updated_by_user = $null
            }
        }

        $resolving[$TaskId] = $true

        $task = $tasks[$TaskId]
        $manualIgnored = $false
        $updatedAt = $null
        $updatedBy = $null
        $updatedByUser = $null

        if ($task.PSObject.Properties['ignore']) {
            $manualIgnored = ($task.ignore.manual -eq $true)
            $updatedAt = $task.ignore.updated_at
            $updatedBy = $task.ignore.updated_by
            $updatedByUser = $task.ignore.updated_by_user
        }

        $blockingIds = [System.Collections.Generic.List[string]]::new()
        foreach ($dependency in (Get-ResolvedTaskDependencies -Task $task -RoadmapDependencyMap $roadmapDependencyMap)) {
            if (-not $dependency) {
                continue
            }

            $dependencyTaskIds = [System.Collections.Generic.List[string]]::new()
            foreach ($dependencyKey in (Get-TaskDependencyReferenceTokens -Dependency $dependency)) {
                if (-not $references.ContainsKey($dependencyKey)) {
                    continue
                }

                $resolvedDependencyTaskId = $references[$dependencyKey]
                if (-not $tasks.ContainsKey($resolvedDependencyTaskId) -or $dependencyTaskIds.Contains($resolvedDependencyTaskId)) {
                    continue
                }

                $dependencyTaskIds.Add($resolvedDependencyTaskId)
            }

            foreach ($dependencyTaskId in $dependencyTaskIds) {
                $dependencyState = Resolve-IgnoreState -TaskId $dependencyTaskId
                if (-not $dependencyState.effective) {
                    continue
                }

                if ($dependencyState.manual) {
                    if (-not $blockingIds.Contains($dependencyTaskId)) {
                        $blockingIds.Add($dependencyTaskId)
                    }
                    continue
                }

                if ($dependencyState.blocking_task_ids.Count -gt 0) {
                    foreach ($blockingTaskId in $dependencyState.blocking_task_ids) {
                        if (-not $blockingIds.Contains($blockingTaskId)) {
                            $blockingIds.Add($blockingTaskId)
                        }
                    }
                    continue
                }

                if (-not $blockingIds.Contains($dependencyTaskId)) {
                    $blockingIds.Add($dependencyTaskId)
                }
            }

        }
        $blockingNames = @()
        foreach ($blockingTaskId in $blockingIds) {
            if ($tasks.ContainsKey($blockingTaskId) -and $tasks[$blockingTaskId].name) {
                $blockingNames += $tasks[$blockingTaskId].name
            } else {
                $blockingNames += $blockingTaskId
            }
        }

        $state = [pscustomobject]@{
            task_id = $TaskId
            manual = $manualIgnored
            effective = ($manualIgnored -or $blockingIds.Count -gt 0)
            auto = (-not $manualIgnored -and $blockingIds.Count -gt 0)
            blocking_task_ids = @($blockingIds)
            blocking_task_names = @($blockingNames | Select-Object -Unique)
            updated_at = $updatedAt
            updated_by = $updatedBy
            updated_by_user = $updatedByUser
        }

        $memo[$TaskId] = $state
        $resolving.Remove($TaskId) | Out-Null
        return $state
    }

    $result = @{}
    foreach ($taskId in @($tasks.Keys)) {
        $result[$taskId] = Resolve-IgnoreState -TaskId $taskId
    }

    return $result
}
function Set-TaskIgnoreState {
    param(
        [Parameter(Mandatory)]
        [string]$TaskId,
        [Parameter(Mandatory)]
        [bool]$Ignored,
        [string]$Actor,
        [string]$TasksBaseDir
    )

    $actorName = Get-ArchiveActor -Actor $Actor
    $auditUser = Get-AuditUsername
    $taskRecord = Get-TodoTaskRecord -TaskId $TaskId -TasksBaseDir $TasksBaseDir
    if (-not $taskRecord) {
        throw "Todo task with ID '$TaskId' not found"
    }

    $task = $taskRecord.task
    $timestamp = Get-UtcTimestamp
    $ignoreState = [ordered]@{
        manual = $Ignored
        updated_at = $timestamp
        updated_by = $actorName
        updated_by_user = $auditUser
    }

    if ($task.PSObject.Properties['ignore']) {
        $task.ignore = $ignoreState
    } else {
        $task | Add-Member -NotePropertyName "ignore" -NotePropertyValue $ignoreState -Force
    }

    $task.updated_at = $timestamp
    Set-TaskAuditUser -Target $task -UserName $auditUser
    Save-TaskFile -Task $task -Path $taskRecord.path

    $ignoreMap = Get-TaskIgnoreStateMap -TasksBaseDir $taskRecord.tasks_base_dir

    return @{
        success = $true
        task_id = $TaskId
        ignored = $Ignored
        actor = $actorName
        updated_at = $timestamp
        ignore_state = $ignoreMap[$TaskId]
    }
}
function Update-TaskContent {
    param(
        [Parameter(Mandatory)]
        [string]$TaskId,
        [Parameter(Mandatory)]
        [hashtable]$Updates,
        [string]$Actor,
        [string]$TasksBaseDir
    )

    $actorName = Get-ArchiveActor -Actor $Actor
    $auditUser = Get-AuditUsername
    $taskRecord = Get-TodoTaskRecord -TaskId $TaskId -TasksBaseDir $TasksBaseDir
    if (-not $taskRecord) {
        throw "Todo task with ID '$TaskId' not found"
    }

    $task = $taskRecord.task
    $archive = Write-TaskArchive -Task $task `
        -ArchiveDir $taskRecord.edited_dir `
        -ArchiveKind "edit" `
        -Actor $actorName `
        -SourceStatus "todo" `
        -SourceFileName $taskRecord.file_name

    $blockedFields = @('id', 'status', 'created_at', 'completed_at')
    foreach ($key in $Updates.Keys) {
        if ($key -in $blockedFields) {
            continue
        }

        if ($task.PSObject.Properties[$key]) {
            $task.$key = $Updates[$key]
        } else {
            $task | Add-Member -NotePropertyName $key -NotePropertyValue $Updates[$key] -Force
        }
    }

    $timestamp = Get-UtcTimestamp
    $task.updated_at = $timestamp
    if ($task.PSObject.Properties['updated_by']) {
        $task.updated_by = $actorName
    } else {
        $task | Add-Member -NotePropertyName "updated_by" -NotePropertyValue $actorName -Force
    }
    Set-TaskAuditUser -Target $task -UserName $auditUser

    Save-TaskFile -Task $task -Path $taskRecord.path

    return @{
        success = $true
        task_id = $TaskId
        actor = $actorName
        updated_at = $timestamp
        archived_version_id = $archive.version_id
        file_path = $taskRecord.path
    }
}
function Remove-TaskFromTodo {
    param(
        [Parameter(Mandatory)]
        [string]$TaskId,
        [string]$Actor,
        [string]$TasksBaseDir
    )

    $actorName = Get-ArchiveActor -Actor $Actor
    $taskRecord = Get-TodoTaskRecord -TaskId $TaskId -TasksBaseDir $TasksBaseDir
    if (-not $taskRecord) {
        throw "Todo task with ID '$TaskId' not found"
    }

    $archive = Write-TaskArchive -Task $taskRecord.task `
        -ArchiveDir $taskRecord.deleted_dir `
        -ArchiveKind "delete" `
        -Actor $actorName `
        -SourceStatus "todo" `
        -SourceFileName $taskRecord.file_name

    Remove-Item -Path $taskRecord.path -Force

    return @{
        success = $true
        task_id = $TaskId
        actor = $actorName
        archived_version_id = $archive.version_id
        archived_at = $archive.captured_at
    }
}

function Get-ArchiveVersionsForTask {
    param(
        [Parameter(Mandatory)]
        [string]$ArchiveDir,
        [Parameter(Mandatory)]
        [string]$TaskId
    )

    if (-not (Test-Path $ArchiveDir)) {
        return @()
    }

    $versions = @()
    $files = Get-ChildItem -Path $ArchiveDir -Filter "*.json" -File -ErrorAction SilentlyContinue
    foreach ($file in $files) {
        try {
            $archiveRecord = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
            if ($archiveRecord.task_id -eq $TaskId) {
                $versions += $archiveRecord
            }
        } catch {
            Write-BotLog -Level Warn -Message "[TaskMutation] Failed to read archive '$($file.FullName)'" -Exception $_
        }
    }

    return @(
        $versions |
            Sort-Object {
                try {
                    if ($_.captured_at) {
                        [DateTime]$_.captured_at
                    } else {
                        [DateTime]::MinValue
                    }
                } catch {
                    [DateTime]::MinValue
                }
            } -Descending
    )
}

function Get-TaskVersionHistory {
    param(
        [Parameter(Mandatory)]
        [string]$TaskId,
        [string]$TasksBaseDir
    )

    $paths = Ensure-TodoDirectories -TasksBaseDir $TasksBaseDir

    return @{
        success = $true
        task_id = $TaskId
        edited_versions = Get-ArchiveVersionsForTask -ArchiveDir $paths.EditedDir -TaskId $TaskId
        deleted_versions = Get-ArchiveVersionsForTask -ArchiveDir $paths.DeletedDir -TaskId $TaskId
    }
}

function Restore-TaskVersion {
    param(
        [Parameter(Mandatory)]
        [string]$TaskId,
        [Parameter(Mandatory)]
        [string]$VersionId,
        [string]$Actor,
        [string]$TasksBaseDir
    )

    $actorName = Get-ArchiveActor -Actor $Actor
    $auditUser = Get-AuditUsername
    $paths = Ensure-TodoDirectories -TasksBaseDir $TasksBaseDir
    $history = Get-TaskVersionHistory -TaskId $TaskId -TasksBaseDir $paths.TasksBaseDir
    $archiveVersion = @($history.edited_versions + $history.deleted_versions) |
        Where-Object { $_.version_id -eq $VersionId } |
        Select-Object -First 1

    if (-not $archiveVersion) {
        throw "Archived version '$VersionId' was not found for task '$TaskId'"
    }

    $existingTask = Get-TodoTaskRecord -TaskId $TaskId -TasksBaseDir $paths.TasksBaseDir
    if ($existingTask) {
        Write-TaskArchive -Task $existingTask.task `
            -ArchiveDir $paths.EditedDir `
            -ArchiveKind "edit" `
            -Actor $actorName `
            -SourceStatus "todo" `
            -SourceFileName $existingTask.file_name | Out-Null
    }

    $restoredTask = ConvertTo-DeepClone -InputObject $archiveVersion.task
    $restoredTask.status = "todo"
    $restoredTask.updated_at = Get-UtcTimestamp
    if ($restoredTask.PSObject.Properties['updated_by']) {
        $restoredTask.updated_by = $actorName
    } else {
        $restoredTask | Add-Member -NotePropertyName "updated_by" -NotePropertyValue $actorName -Force
    }
    Set-TaskAuditUser -Target $restoredTask -UserName $auditUser

    $targetFileName = if ($archiveVersion.source_file_name) {
        $archiveVersion.source_file_name
    } elseif ($existingTask) {
        $existingTask.file_name
    } else {
        "$TaskId.json"
    }

    $targetPath = Join-Path $paths.TodoDir $targetFileName
    Save-TaskFile -Task $restoredTask -Path $targetPath

    return @{
        success = $true
        task_id = $TaskId
        restored_version_id = $VersionId
        actor = $actorName
        file_path = $targetPath
        archive_kind = $archiveVersion.archive_kind
    }
}
Export-ModuleMember -Function @(
    'Set-TaskIgnoreState',
    'Update-TaskContent',
    'Remove-TaskFromTodo',
    'Get-TaskVersionHistory',
    'Restore-TaskVersion',
    'Get-TaskIgnoreStateMap'
)