workflows/default/systems/runtime/modules/WorktreeManager.psm1

<#
.SYNOPSIS
Git worktree lifecycle management for per-task isolation.

.DESCRIPTION
Each task gets its own git branch and worktree, created at analysis start
and persisting through execution. On completion, the branch is squash-merged
to main and the worktree is cleaned up.

Worktree path convention:
  {repo-parent}/worktrees/{repo-name}/task-{short-id}-{slug}/

Branch naming:
  task/{short-id}-{slug}

Shared infrastructure via directory links (junctions on Windows, symlinks on macOS/Linux):
  .bot/.control/ -> central control (process registry, settings)
  .bot/workspace/tasks/ -> central task queue (todo, done, etc.)
  .bot/workspace/product/ -> shared research outputs and briefing
  .bot/hooks/ -> verification scripts, commit-bot-state, dev lifecycle
  .bot/systems/ -> MCP server, runtime, UI
  .bot/recipes/ -> agents, skills, prompts, research, standards
  .bot/settings/ -> settings defaults
#>


# --- Internal State ---
$script:WorktreeMapPath = $null

# Large, regenerable directories excluded from gitignored file copying
$script:NoiseDirectories = @(
    'bin', 'obj', 'node_modules', 'packages',
    'Debug', 'Release', 'x64', 'x86',
    '.vs', '.idea', '.vscode',
    '__pycache__', '.mypy_cache',
    '.git', '.control', '.serena',
    'TestResults', 'test-results', 'playwright-report',
    'sessions'
)

# --- Internal Helpers ---

function Assert-PathWithinBounds {
    <#
    .SYNOPSIS
    Validates that a resolved path is within an expected root directory.
    Prevents path traversal attacks when paths are constructed from external data.
    #>

    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][string]$ExpectedRoot
    )
    $resolvedPath = [System.IO.Path]::GetFullPath($Path)
    $resolvedRoot = [System.IO.Path]::GetFullPath($ExpectedRoot)
    if (-not $resolvedPath.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
        throw "Path '$Path' resolves to '$resolvedPath' which is outside expected root '$resolvedRoot'"
    }
}

function Invoke-Git {
    <#
    .SYNOPSIS
    Standardized git invocation with proper stdout/stderr separation and exit code handling.
    #>

    param(
        [Parameter(Mandatory)][string[]]$Arguments,
        [string]$WorkingDirectory,
        [switch]$SilentFail
    )
    # Scoped PS 7.4+ preference: makes git failures throw catchable errors
    $PSNativeCommandUseErrorActionPreference = $true

    $gitArgs = @()
    if ($WorkingDirectory) { $gitArgs += @('-C', $WorkingDirectory) }
    $gitArgs += $Arguments

    $output = & git @gitArgs 2>&1
    $exitCode = $LASTEXITCODE

    if ($exitCode -ne 0) {
        $stderr = @($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) -join "`n"
        if ($SilentFail) {
            Write-BotLog -Level Debug -Message "Git failed (exit $exitCode): git $($Arguments -join ' '): $stderr"
            return $null
        }
        throw "git $($Arguments -join ' ') failed (exit $exitCode): $stderr"
    }
    @($output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] })
}

function Get-BaseBranch {
    param([string]$ProjectRoot)
    $branch = Invoke-Git -Arguments @('symbolic-ref', '--short', 'HEAD') -WorkingDirectory $ProjectRoot -SilentFail
    if ($branch) {
        $verify = Invoke-Git -Arguments @('rev-parse', '--verify', $branch.Trim()) -WorkingDirectory $ProjectRoot -SilentFail
        if ($verify) { return $branch.Trim() }
    }
    foreach ($candidate in @('main', 'master')) {
        $verify = Invoke-Git -Arguments @('rev-parse', '--verify', $candidate) -WorkingDirectory $ProjectRoot -SilentFail
        if ($verify) { return $candidate }
    }
    return $null
}

function Initialize-WorktreeMap {
    param([string]$BotRoot)
    $controlDir = Join-Path $BotRoot ".control"
    $script:WorktreeMapPath = Join-Path $controlDir "worktree-map.json"
}

function Read-WorktreeMap {
    if (-not $script:WorktreeMapPath -or -not (Test-Path $script:WorktreeMapPath)) {
        return @{}
    }
    try {
        $content = Get-Content $script:WorktreeMapPath -Raw
        if ([string]::IsNullOrWhiteSpace($content)) { return @{} }
        $json = $content | ConvertFrom-Json
        $map = @{}
        foreach ($prop in $json.PSObject.Properties) {
            $map[$prop.Name] = $prop.Value
        }
        return $map
    } catch {
        Write-BotLog -Level Debug -Message "Worktree map read failed" -Exception $_
        return @{}
    }
}

function Write-WorktreeMap {
    param([hashtable]$Map)
    if (-not $script:WorktreeMapPath) { return }
    $dir = Split-Path $script:WorktreeMapPath -Parent
    if (-not (Test-Path $dir)) {
        New-Item -Path $dir -ItemType Directory -Force | Out-Null
    }
    $tempFile = "$($script:WorktreeMapPath).tmp"
    $maxRetries = 3
    for ($r = 0; $r -lt $maxRetries; $r++) {
        try {
            $Map | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding utf8NoBOM -NoNewline
            Move-Item -Path $tempFile -Destination $script:WorktreeMapPath -Force -ErrorAction Stop
            return
        } catch {
            if (Test-Path $tempFile) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue }
            if ($r -lt ($maxRetries - 1)) { Start-Sleep -Milliseconds (50 * ($r + 1)) }
        }
    }
}

function Resolve-MainBranch {
    <#
    .SYNOPSIS
    Find the canonical integration branch (main or master) by explicit name lookup.
    Never reads symbolic HEAD — safe to call when the main repo may be on a task branch.
    #>

    param([string]$ProjectRoot)
    foreach ($candidate in @('main', 'master')) {
        git -C $ProjectRoot rev-parse --verify $candidate 2>$null | Out-Null
        if ($LASTEXITCODE -eq 0) { return $candidate }
    }
    return $null
}

function Assert-OnBaseBranch {
    <#
    .SYNOPSIS
    Ensure the main repo is checked out on the specified branch (or the canonical
    main/master if none is specified). Checks out the branch if not already on it.
    Throws if the branch cannot be found or checked out.
    Returns the confirmed base branch name.
    #>

    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [string]$BranchName
    )
    if (-not $BranchName) {
        $BranchName = Resolve-MainBranch -ProjectRoot $ProjectRoot
    }
    if (-not $BranchName) {
        throw "Cannot find base branch in $ProjectRoot"
    }
    $currentBranch = git -C $ProjectRoot rev-parse --abbrev-ref HEAD 2>$null
    if ($currentBranch -ne $BranchName) {
        git -C $ProjectRoot checkout $BranchName 2>&1 | Out-Null
        if ($LASTEXITCODE -ne 0) {
            throw "Failed to checkout $BranchName in $ProjectRoot (currently on: $currentBranch)"
        }
    }
    return $BranchName
}

function Invoke-WorktreeMapLocked {
    <#
    .SYNOPSIS
    Execute a script block with an exclusive lock on the worktree map file.
    Uses [System.IO.File]::Open with FileMode::CreateNew for atomic, cross-platform
    locking (Windows: CreateFile CREATE_NEW; Linux/macOS: open O_CREAT|O_EXCL).
    Retries on contention with linear backoff up to TimeoutSeconds.
    #>

    param(
        [Parameter(Mandatory)][scriptblock]$Action,
        [int]$TimeoutSeconds = 10
    )
    if (-not $script:WorktreeMapPath) { & $Action; return }
    $lockFile = "$($script:WorktreeMapPath).lock"
    $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
    $attempt = 0
    while ($true) {
        $lockStream = $null
        try {
            $lockStream = [System.IO.File]::Open(
                $lockFile,
                [System.IO.FileMode]::CreateNew,
                [System.IO.FileAccess]::ReadWrite,
                [System.IO.FileShare]::None)
            # Lock acquired — run the action
            & $Action
            return
        } catch [System.IO.IOException] {
            # Lock held by another process — wait and retry
            if ([DateTime]::UtcNow -ge $deadline) {
                # Timed out — assume stale lock, remove and retry with proper lock acquisition
                Write-BotLog -Level Warn -Message "Worktree map lock timeout after ${TimeoutSeconds}s — removing stale lock"
                Remove-Item $lockFile -Force -ErrorAction SilentlyContinue
                try {
                    $lockStream = [System.IO.File]::Open(
                        $lockFile,
                        [System.IO.FileMode]::CreateNew,
                        [System.IO.FileAccess]::ReadWrite,
                        [System.IO.FileShare]::None)
                    & $Action
                    return
                } catch [System.IO.IOException] {
                    # Another process grabbed the lock after our removal — run unlocked as last resort
                    Write-BotLog -Level Warn -Message "Worktree map lock contention after stale removal — proceeding without lock"
                    & $Action
                    return
                }
            }
            $attempt++
            Start-Sleep -Milliseconds ([Math]::Min(50 * $attempt, 500))
        } finally {
            if ($lockStream) {
                $lockStream.Dispose()
                Remove-Item $lockFile -Force -ErrorAction SilentlyContinue
            }
        }
    }
}

function Get-TaskSlug {
    param([string]$TaskName)
    $slug = $TaskName.ToLower()
    $slug = $slug -replace '[^a-z0-9]+', '-'
    $slug = $slug -replace '^-|-$', ''
    if ($slug.Length -gt 50) { $slug = $slug.Substring(0, 50) -replace '-$', '' }
    return $slug
}

function Stop-WorktreeProcesses {
    <#
    .SYNOPSIS
    Kill all processes whose command line references a given worktree path.
    Prevents file locks from blocking worktree removal and git operations.
    Returns the number of processes killed.
    #>

    param(
        [Parameter(Mandatory)]
        [string]$WorktreePath
    )

    if (-not $WorktreePath) { return 0 }

    $killed = 0

    try {
        if ($IsWindows) {
            # On Windows, use WMI to query process command lines in all path formats:
            # backslash (PowerShell), forward-slash (Node/npm), Git Bash (/c/Users/...)
            $escapedOriginal = [regex]::Escape($WorktreePath)
            $forwardSlash = $WorktreePath -replace '\\', '/'
            $escapedForward = [regex]::Escape($forwardSlash)
            $gitBashStyle = $forwardSlash -replace '^([A-Za-z]):', { '/' + $_.Groups[1].Value.ToLower() }
            $escapedGitBash = [regex]::Escape($gitBashStyle)

            $candidates = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
                Where-Object {
                    $_.CommandLine -and (
                        $_.CommandLine -match $escapedOriginal -or
                        $_.CommandLine -match $escapedForward -or
                        $_.CommandLine -match $escapedGitBash
                    )
                }

            foreach ($proc in $candidates) {
                if ($proc.ProcessId -eq $PID) { continue }
                try {
                    Stop-Process -Id $proc.ProcessId -Force -ErrorAction Stop
                    $killed++
                } catch { Write-BotLog -Level Debug -Message "Cleanup: failed to stop process $($proc.ProcessId)" -Exception $_ }
            }
        } else {
            # On Linux/macOS, use ps to find processes by command line
            $escapedPath = [regex]::Escape($WorktreePath)
            $psOutput = & /bin/ps -eo pid,args 2>/dev/null
            if ($psOutput) {
                foreach ($psLine in $psOutput) {
                    if ($psLine -match '^\s*(\d+)\s+(.+)$') {
                        $procPid = [int]$Matches[1]
                        $cmdLine = $Matches[2]
                        if ($procPid -eq $PID) { continue }
                        if ($cmdLine -match $escapedPath) {
                            try {
                                Stop-Process -Id $procPid -Force -ErrorAction Stop
                                $killed++
                            } catch { Write-BotLog -Level Debug -Message "Cleanup: failed to stop process ${procPid}" -Exception $_ }
                        }
                    }
                }
            }
        }
    } catch {
        # Query failure - non-fatal, best-effort cleanup
    }

    return $killed
}

function New-DirectoryLink {
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][string]$Target
    )
    # Windows: NTFS junctions (no elevation required)
    # macOS/Linux: symbolic links
    if ($IsWindows) {
        New-Item -ItemType Junction -Path $Path -Target $Target -ErrorAction Stop | Out-Null
    } else {
        New-Item -ItemType SymbolicLink -Path $Path -Target $Target -ErrorAction Stop | Out-Null
    }
}

function Test-JunctionsExist {
    <#
    .SYNOPSIS
    Defense-in-depth check: returns $true if ANY known junction/symlink paths still exist as links.
    Used as a final gate before git worktree remove --force to prevent link-following data loss.
    Detects both Windows junctions (ReparsePoint) and Unix symlinks.
    #>

    param([string]$WorktreePath)

    $botDir = Join-Path $WorktreePath ".bot"
    $junctionPaths = @(
        (Join-Path $botDir ".control"),
        (Join-Path (Join-Path $botDir "workspace") "tasks"),
        (Join-Path (Join-Path $botDir "workspace") "product"),
        (Join-Path $botDir "hooks"),
        (Join-Path $botDir "systems"),
        (Join-Path $botDir "recipes"),
        (Join-Path $botDir "settings")
    )
    foreach ($jp in $junctionPaths) {
        if (Test-Path -LiteralPath $jp) {
            try {
                $item = Get-Item -LiteralPath $jp -Force
            } catch {
                # Best-effort: if Get-Item fails (access denied, transient IO, broken link),
                # treat as "junctions exist" to avoid unsafe --force removal
                return $true
            }
            # Windows: junctions have ReparsePoint attribute
            # Linux/macOS: symlinks have LinkType set
            if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or
                ($item.LinkType)) {
                return $true
            }
        }
    }
    return $false
}

function Remove-Junctions {
    <#
    .SYNOPSIS
    Remove directory junctions (Windows) and symlinks (macOS/Linux) from a worktree without following into shared dirs.
    Returns $true if all links were removed, $false otherwise.
    Throws on failure unless -ErrorOnFailure is $false.
    #>

    param(
        [string]$WorktreePath,
        [bool]$ErrorOnFailure = $true
    )

    $junctionPaths = @(
        (Join-Path $WorktreePath ".bot\.control"),
        (Join-Path $WorktreePath ".bot\workspace\tasks"),
        (Join-Path $WorktreePath ".bot\workspace\product"),
        (Join-Path $WorktreePath ".bot\hooks"),
        (Join-Path $WorktreePath ".bot\systems"),
        (Join-Path $WorktreePath ".bot\recipes"),
        (Join-Path $WorktreePath ".bot\settings")
    )
    $failures = @()
    foreach ($jp in $junctionPaths) {
        if (-not (Test-Path -LiteralPath $jp)) { continue }
        $item = Get-Item -LiteralPath $jp -Force
        $isJunctionOrSymlink = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or $item.LinkType
        if ($isJunctionOrSymlink) {
            if ($IsWindows) {
                # cmd rmdir removes the junction link without following into target
                cmd /c rmdir "$jp" 2>$null
            } else {
                # On Linux/macOS, Remove-Item correctly unlinks symlinks without touching the target
                Remove-Item -LiteralPath $jp -Force -ErrorAction SilentlyContinue
            }

            # Fallback: use .NET to remove the junction
            if (Test-Path -LiteralPath $jp) {
                try {
                    [System.IO.Directory]::Delete($jp, $false)
                } catch {
                    # Last resort failed — record it
                }
            }

            # Final check
            if (Test-Path -LiteralPath $jp) {
                $failures += $jp
            }
        }
    }

    if ($failures.Count -gt 0 -and $ErrorOnFailure) {
        throw "Failed to remove junctions: $($failures -join ', ')"
    }
    return ($failures.Count -eq 0)
}

# --- Exported Functions ---

function New-TaskWorktree {
    <#
    .SYNOPSIS
    Create a git branch and worktree for a task, with junctions and artifact copying.

    .OUTPUTS
    Hashtable with: worktree_path, branch_name, success, message
    #>

    param(
        [Parameter(Mandatory)][string]$TaskId,
        [Parameter(Mandatory)][string]$TaskName,
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][string]$BotRoot
    )

    Initialize-WorktreeMap -BotRoot $BotRoot

    $shortId = $TaskId.Substring(0, [Math]::Min(8, $TaskId.Length))
    $slug = Get-TaskSlug -TaskName $TaskName
    $branchName = "task/$shortId-$slug"

    # Worktree path: {repo-parent}/worktrees/{repo-name}/task-{shortId}-{slug}/
    $repoParent = Split-Path $ProjectRoot -Parent
    $repoName = Split-Path $ProjectRoot -Leaf
    $worktreeDir = Join-Path $repoParent "worktrees\$repoName"
    $worktreePath = Join-Path $worktreeDir "task-$shortId-$slug"

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

    # If worktree directory already exists, validate it's a real worktree
    if (Test-Path $worktreePath) {
        $gitMarker = Join-Path $worktreePath ".git"
        if (Test-Path $gitMarker) {
            # Valid worktree — ensure map entry exists and return it
            $existingBaseBranch = Resolve-MainBranch -ProjectRoot $ProjectRoot
            Invoke-WorktreeMapLocked -Action {
                $lockedMap = Read-WorktreeMap
                if (-not $lockedMap.ContainsKey($TaskId)) {
                    $lockedMap[$TaskId] = @{
                        worktree_path = $worktreePath
                        branch_name   = $branchName
                        base_branch   = $existingBaseBranch
                        task_name     = $TaskName
                        created_at    = (Get-Date).ToUniversalTime().ToString("o")
                    }
                    Write-WorktreeMap -Map $lockedMap
                }
            }
            return @{
                worktree_path = $worktreePath
                branch_name   = $branchName
                success       = $true
                message       = "Worktree already exists"
            }
        } else {
            # Stale leftover directory (no .git marker) — remove and recreate
            Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeDir
            Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue
            # Also prune git's worktree list so it doesn't think it still exists
            git -C $ProjectRoot worktree prune 2>$null
        }
    }

    try {
        # Create branch from the repo's current branch and check it out in the worktree
        $baseBranch = Get-BaseBranch -ProjectRoot $ProjectRoot
        if (-not $baseBranch) {
            throw "Cannot create worktree: repository has no commits. Make an initial commit first."
        }
        $output = git -C $ProjectRoot worktree add -b $branchName $worktreePath $baseBranch 2>&1
        if ($LASTEXITCODE -ne 0) {
            # Branch may already exist from an interrupted run — try without -b
            $output = git -C $ProjectRoot worktree add $worktreePath $branchName 2>&1
            if ($LASTEXITCODE -ne 0) {
                throw "git worktree add failed: $($output -join ' ')"
            }
        }

        # Sanity check: verify worktree was actually created
        $gitMarker = Join-Path $worktreePath ".git"
        if (-not (Test-Path $gitMarker)) {
            throw "git worktree add succeeded but .git marker not found in $worktreePath"
        }

        # --- Set up directory links for shared infrastructure ---
        # Windows: NTFS junctions (no elevation required)
        # macOS/Linux: symbolic links

        # 1. .bot/.control/ — gitignored, won't exist in worktree
        $worktreeControlDir = Join-Path $worktreePath ".bot\.control"
        $mainControlDir = Join-Path $BotRoot ".control"
        if (-not (Test-Path $worktreeControlDir)) {
            $controlParent = Split-Path $worktreeControlDir -Parent
            if (-not (Test-Path $controlParent)) {
                New-Item -Path $controlParent -ItemType Directory -Force | Out-Null
            }
            New-DirectoryLink -Path $worktreeControlDir -Target $mainControlDir
        }

        # 2. .bot/workspace/tasks/ — has tracked .gitkeep files, replace with junction
        $worktreeTasksDir = Join-Path $worktreePath ".bot\workspace\tasks"
        $mainTasksDir = Join-Path $BotRoot "workspace\tasks"
        if (Test-Path $worktreeTasksDir) {
            Assert-PathWithinBounds -Path $worktreeTasksDir -ExpectedRoot $worktreePath
            Remove-Item -Path $worktreeTasksDir -Recurse -Force
        }
        $tasksParent = Split-Path $worktreeTasksDir -Parent
        if (-not (Test-Path $tasksParent)) {
            New-Item -Path $tasksParent -ItemType Directory -Force | Out-Null
        }
        New-DirectoryLink -Path $worktreeTasksDir -Target $mainTasksDir

        # 3. .bot/hooks/ — verify scripts, commit-bot-state, dev lifecycle
        $worktreeHooksDir = Join-Path $worktreePath ".bot\hooks"
        $mainHooksDir = Join-Path $BotRoot "hooks"
        if ((Test-Path $mainHooksDir) -and -not (Test-Path $worktreeHooksDir)) {
            New-DirectoryLink -Path $worktreeHooksDir -Target $mainHooksDir
        }

        # 4. .bot/systems/ — MCP server, runtime, UI
        $worktreeSystemsDir = Join-Path $worktreePath ".bot\systems"
        $mainSystemsDir = Join-Path $BotRoot "systems"
        if ((Test-Path $mainSystemsDir) -and -not (Test-Path $worktreeSystemsDir)) {
            New-DirectoryLink -Path $worktreeSystemsDir -Target $mainSystemsDir
        }

        # 5. .bot/recipes/ — recipes, research methodologies, standards
        $worktreeRecipesDir = Join-Path $worktreePath ".bot\recipes"
        $mainRecipesDir = Join-Path $BotRoot "recipes"
        if ((Test-Path $mainRecipesDir) -and -not (Test-Path $worktreeRecipesDir)) {
            New-DirectoryLink -Path $worktreeRecipesDir -Target $mainRecipesDir
        }

        # 6. .bot/settings/ — settings defaults
        $worktreeSettingsDir = Join-Path $worktreePath ".bot\settings"
        $mainSettingsDir = Join-Path $BotRoot "settings"
        if ((Test-Path $mainSettingsDir) -and -not (Test-Path $worktreeSettingsDir)) {
            New-DirectoryLink -Path $worktreeSettingsDir -Target $mainSettingsDir
        }

        # 7. .bot/workspace/product/ — shared research outputs and briefing
        $worktreeProductDir = Join-Path $worktreePath ".bot\workspace\product"
        $mainProductDir = Join-Path $BotRoot "workspace\product"
        if (Test-Path $mainProductDir) {
            if (Test-Path $worktreeProductDir) {
                Assert-PathWithinBounds -Path $worktreeProductDir -ExpectedRoot $worktreePath
                Remove-Item -Path $worktreeProductDir -Recurse -Force
            }
            $productParent = Split-Path $worktreeProductDir -Parent
            if (-not (Test-Path $productParent)) {
                New-Item -Path $productParent -ItemType Directory -Force | Out-Null
            }
            New-DirectoryLink -Path $worktreeProductDir -Target $mainProductDir
        }

        # Copy non-noisy gitignored build artifacts
        Copy-BuildArtifacts -ProjectRoot $ProjectRoot -WorktreePath $worktreePath

        # Register in worktree map (locked read-modify-write to prevent concurrent entry loss)
        Invoke-WorktreeMapLocked -Action {
            $lockedMap = Read-WorktreeMap
            $lockedMap[$TaskId] = @{
                worktree_path = $worktreePath
                branch_name   = $branchName
                base_branch   = $baseBranch
                task_name     = $TaskName
                created_at    = (Get-Date).ToUniversalTime().ToString("o")
            }
            Write-WorktreeMap -Map $lockedMap
        }

        return @{
            worktree_path = $worktreePath
            branch_name   = $branchName
            success       = $true
            message       = "Worktree created at $worktreePath"
        }
    } catch {
        return @{
            worktree_path = $null
            branch_name   = $branchName
            success       = $false
            message       = "Failed to create worktree: $($_.Exception.Message)"
        }
    }
}

function Complete-TaskWorktree {
    <#
    .SYNOPSIS
    Squash-merge a task branch to main, then clean up the worktree and branch.

    .OUTPUTS
    Hashtable with: success, merge_commit, message, conflict_files
    #>

    param(
        [Parameter(Mandatory)][string]$TaskId,
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][string]$BotRoot
    )

    Initialize-WorktreeMap -BotRoot $BotRoot
    $map = Read-WorktreeMap

    if (-not $map.ContainsKey($TaskId)) {
        return @{
            success        = $true
            merge_commit   = $null
            message        = "No worktree found for task $TaskId (no merge needed)"
            conflict_files = @()
        }
    }

    $entry = $map[$TaskId]
    $worktreePath = $entry.worktree_path
    $branchName = $entry.branch_name
    $taskName = $entry.task_name
    $shortId = $TaskId.Substring(0, [Math]::Min(8, $TaskId.Length))

    try {
        # Determine target base branch — prefer the value recorded at worktree creation
        # (immune to HEAD drift on the main repo); fall back to explicit main/master lookup.
        $baseBranch = $entry.base_branch ?? (Resolve-MainBranch -ProjectRoot $ProjectRoot)
        if (-not $baseBranch) { throw "Cannot determine base branch for task $TaskId" }

        # Assert main repo is on the base branch before any git operation (Fix: wrong-branch merge)
        Assert-OnBaseBranch -ProjectRoot $ProjectRoot -BranchName $baseBranch | Out-Null

        # Kill any processes still running in the worktree (dev servers, file watchers, etc.)
        $killedCount = Stop-WorktreeProcesses -WorktreePath $worktreePath
        if ($killedCount -gt 0) {
            Start-Sleep -Milliseconds 500  # Brief pause for handles to release
        }

        # Remove junctions BEFORE commit/rebase so git sees real tracked files
        $junctionsClean = Remove-Junctions -WorktreePath $worktreePath -ErrorOnFailure $false

        # Restore tracked files that were replaced by junctions
        git -C $worktreePath checkout -- .bot/workspace/tasks 2>$null
        git -C $worktreePath checkout -- .bot/workspace/product 2>$null

        # Auto-commit any uncommitted work left by Claude CLI
        $worktreeStatus = git -C $worktreePath status --porcelain 2>$null
        if ($worktreeStatus) {
            git -C $worktreePath add -A -- ':!.bot/workspace/tasks/' 2>$null
            git -C $worktreePath commit --quiet -m "chore: auto-commit uncommitted work" 2>$null
        }

        # Ensure clean index before rebase — auto-commit may fail silently
        # (e.g. pre-commit hook blocks .env.local with secrets)
        $indexDirty = git -C $worktreePath diff --cached --name-only 2>$null
        if ($indexDirty) {
            git -C $worktreePath reset 2>$null
        }

        # Rebase task branch onto base branch (brings task commits up to date)
        $rebaseOutput = git -C $worktreePath rebase $baseBranch 2>&1
        if ($LASTEXITCODE -ne 0) {
            git -C $worktreePath rebase --abort 2>$null
            $conflictLines = @($rebaseOutput | ForEach-Object { "$_" } | Where-Object { $_ -match 'CONFLICT|error|fatal' })
            return @{
                success        = $false
                merge_commit   = $null
                message        = "Rebase failed - conflicts detected"
                conflict_files = $conflictLines
            }
        }

        # Backup live task state before merge (concurrent processes may have written via junctions)
        $taskBackup = @{}
        foreach ($subDir in @('todo','analysing','analysed','needs-input','in-progress','done','skipped','split','cancelled')) {
            $backupDir = Join-Path $ProjectRoot ".bot\workspace\tasks\$subDir"
            $backupFiles = Get-ChildItem $backupDir -Filter "*.json" -File -ErrorAction SilentlyContinue
            foreach ($bf in $backupFiles) {
                try {
                    $taskBackup["$subDir/$($bf.Name)"] = Get-Content $bf.FullName -Raw
                } catch { Write-BotLog -Level Debug -Message "Failed to read task backup $($bf.FullName)" -Exception $_ }
            }
        }

        # Clean tracked + untracked task files so merge can proceed cleanly
        git -C $ProjectRoot checkout -- .bot/workspace/tasks/ 2>$null
        git -C $ProjectRoot clean -fd -- .bot/workspace/tasks/ 2>$null

        # Stash remaining dirty state EXCLUDING task files (task state is managed by backup-restore).
        # Including task files in the stash causes stale state to be reintroduced after the state commit
        # when git stash pop runs, contaminating the next task's backup.
        $stashOutput = git -C $ProjectRoot stash push -u -m "dotbot-pre-merge-$TaskId" -- ':!.bot/workspace/tasks/' 2>&1
        $wasStashed = $LASTEXITCODE -eq 0 -and "$stashOutput" -notmatch 'No local changes'

        # Validate task branch still exists before attempting merge (Fix: branch_not_found)
        git -C $ProjectRoot rev-parse --verify $branchName 2>$null | Out-Null
        if ($LASTEXITCODE -ne 0) {
            if ($wasStashed) { git -C $ProjectRoot stash pop 2>$null }
            foreach ($key in $taskBackup.Keys) {
                $restorePath = Join-Path $ProjectRoot ".bot\workspace\tasks\$key"
                $restoreDir = Split-Path $restorePath -Parent
                if (-not (Test-Path $restoreDir)) { New-Item $restoreDir -ItemType Directory -Force | Out-Null }
                $taskBackup[$key] | Set-Content $restorePath -Encoding UTF8
            }
            return @{
                success        = $false
                merge_commit   = $null
                message        = "Branch $branchName no longer exists — cannot merge task $TaskId"
                conflict_files = @()
            }
        }

        # Squash merge into main
        $mergeOutput = git -C $ProjectRoot merge --squash $branchName 2>&1
        if ($LASTEXITCODE -ne 0) {
            git -C $ProjectRoot reset --hard HEAD 2>$null
            # Re-assert base branch after reset — leaves repo in a known good state (Fix: wrong-branch merge)
            Assert-OnBaseBranch -ProjectRoot $ProjectRoot -BranchName $baseBranch | Out-Null
            if ($wasStashed) {
                git -C $ProjectRoot stash pop 2>$null
            }
            # Restore backed-up task state after failed merge
            foreach ($key in $taskBackup.Keys) {
                $restorePath = Join-Path $ProjectRoot ".bot\workspace\tasks\$key"
                $restoreDir = Split-Path $restorePath -Parent
                if (-not (Test-Path $restoreDir)) { New-Item $restoreDir -ItemType Directory -Force | Out-Null }
                $taskBackup[$key] | Set-Content $restorePath -Encoding UTF8
            }
            return @{
                success        = $false
                merge_commit   = $null
                message        = "Squash merge failed: $($mergeOutput -join ' ')"
                conflict_files = @()
            }
        }

        # Discard branch's task state, restore live state from backup
        git -C $ProjectRoot checkout HEAD -- .bot/workspace/tasks/ 2>$null
        foreach ($key in $taskBackup.Keys) {
            $restorePath = Join-Path $ProjectRoot ".bot\workspace\tasks\$key"
            $restoreDir = Split-Path $restorePath -Parent
            if (-not (Test-Path $restoreDir)) { New-Item $restoreDir -ItemType Directory -Force | Out-Null }
            $taskBackup[$key] | Set-Content $restorePath -Encoding UTF8
        }

        # Remove any task JSON files from the merge that weren't in the live backup.
        # The branch may carry stale copies of tasks that moved while the branch was alive
        # (e.g., a task split from todo→split while this branch still had the todo copy).
        foreach ($subDir in @('todo','analysing','analysed','needs-input','in-progress','done','skipped','split','cancelled')) {
            $dir = Join-Path $ProjectRoot ".bot\workspace\tasks\$subDir"
            Get-ChildItem $dir -Filter "*.json" -File -ErrorAction SilentlyContinue | ForEach-Object {
                $key = "$subDir/$($_.Name)"
                if (-not $taskBackup.ContainsKey($key)) {
                    Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue
                }
            }
        }

        # Commit if there are staged changes (task may have made no code changes)
        $staged = git -C $ProjectRoot diff --cached --name-only 2>$null
        if ($staged) {
            git -C $ProjectRoot commit -m "feat: $taskName [task:$shortId]" 2>&1 | Out-Null
            if ($LASTEXITCODE -ne 0) {
                git -C $ProjectRoot reset --hard HEAD 2>$null
                # Re-assert base branch after reset (Fix: wrong-branch merge)
                Assert-OnBaseBranch -ProjectRoot $ProjectRoot -BranchName $baseBranch | Out-Null
                if ($wasStashed) { git -C $ProjectRoot stash pop 2>$null }
                foreach ($key in $taskBackup.Keys) {
                    $restorePath = Join-Path $ProjectRoot ".bot\workspace\tasks\$key"
                    $restoreDir = Split-Path $restorePath -Parent
                    if (-not (Test-Path $restoreDir)) { New-Item $restoreDir -ItemType Directory -Force | Out-Null }
                    $taskBackup[$key] | Set-Content $restorePath -Encoding UTF8
                }
                return @{
                    success        = $false
                    merge_commit   = $null
                    message        = "Commit failed after squash merge"
                    conflict_files = @()
                }
            }
        }

        $mergeCommit = git -C $ProjectRoot rev-parse HEAD 2>$null

        # Remove duplicate task files: if a task exists in both a non-terminal directory
        # and done/, the non-terminal copy is stale and must be removed before committing.
        # This is a defensive measure against any mechanism that reintroduces stale files
        # (stash pop, junction race conditions, Reset function edge cases).
        $doneDir = Join-Path $ProjectRoot ".bot\workspace\tasks\done"
        $todoDir = Join-Path $ProjectRoot ".bot\workspace\tasks\todo"
        if ((Test-Path $doneDir) -and (Test-Path $todoDir)) {
            $doneFileNames = @{}
            Get-ChildItem $doneDir -Filter "*.json" -File -ErrorAction SilentlyContinue | ForEach-Object {
                $doneFileNames[$_.Name] = $true
            }
            Get-ChildItem $todoDir -Filter "*.json" -File -ErrorAction SilentlyContinue | ForEach-Object {
                if ($doneFileNames.ContainsKey($_.Name)) {
                    Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue
                }
            }
            foreach ($intermediateDir in @('analysing', 'analysed', 'in-progress', 'needs-input')) {
                $dirPath = Join-Path $ProjectRoot ".bot\workspace\tasks\$intermediateDir"
                if (Test-Path $dirPath) {
                    Get-ChildItem $dirPath -Filter "*.json" -File -ErrorAction SilentlyContinue | ForEach-Object {
                        if ($doneFileNames.ContainsKey($_.Name)) {
                            Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue
                        }
                    }
                }
            }
        }

        # Commit current task state on main — changes accumulate via junctions
        # but were previously only "accidentally" committed via task branches
        git -C $ProjectRoot add .bot/workspace/tasks/ 2>$null
        git -C $ProjectRoot commit --quiet -m "chore: update task state" 2>$null

        # Auto-push to remote if one is configured
        $pushResult = @{ attempted = $false; success = $false; error = $null }
        $remoteUrl = git -C $ProjectRoot remote get-url origin 2>$null
        if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($remoteUrl)) {
            $pushResult.attempted = $true
            $pushOutput = git -C $ProjectRoot push origin $baseBranch 2>&1
            if ($LASTEXITCODE -eq 0) {
                $pushResult.success = $true
            } else {
                $pushResult.error = ($pushOutput | Out-String).Trim()
            }
        }

        # Restore stashed state after successful merge+commit
        if ($wasStashed) {
            git -C $ProjectRoot stash pop 2>$null
            if ($LASTEXITCODE -ne 0) {
                # Stash conflicts with merge result — keep merge, drop stash
                git -C $ProjectRoot checkout --theirs -- . 2>$null
                git -C $ProjectRoot add . 2>$null
                git -C $ProjectRoot stash drop 2>$null
            }
        }

        # Remove worktree and branch — only force-remove if junctions were cleaned
        # Defense-in-depth: re-verify no junctions exist right before --force
        if ($junctionsClean -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) {
            git -C $ProjectRoot worktree remove $worktreePath --force 2>$null
        } else {
            if ($junctionsClean) {
                Write-BotLog -Level Warn -Message "Junction re-check found surviving junctions in $worktreePath — downgrading to safe removal"
            } else {
                Write-BotLog -Level Warn -Message "Skipping force worktree removal — junctions still present in $worktreePath"
            }
            git -C $ProjectRoot worktree remove $worktreePath 2>$null
        }
        # Verify worktree is actually gone (Fix: silent removal failures)
        if (Test-Path $worktreePath) {
            Write-BotLog -Level Warn -Message "Worktree removal incomplete — path still exists: $worktreePath. Will be retried on next startup."
        }
        git -C $ProjectRoot branch -D $branchName 2>$null

        # Remove from registry (locked read-modify-write to prevent concurrent entry loss)
        Invoke-WorktreeMapLocked -Action {
            $lockedMap = Read-WorktreeMap
            $lockedMap.Remove($TaskId)
            Write-WorktreeMap -Map $lockedMap
        }

        return @{
            success        = $true
            merge_commit   = $mergeCommit
            message        = "Squash-merged to $baseBranch and cleaned up"
            conflict_files = @()
            push_result    = $pushResult
        }
    } catch {
        return @{
            success        = $false
            merge_commit   = $null
            message        = "Error during merge: $($_.Exception.Message)"
            conflict_files = @()
        }
    }
}

function Get-TaskWorktreePath {
    <#
    .SYNOPSIS
    Look up the worktree path for a given task ID.

    .OUTPUTS
    Path string or $null if not found / not on disk
    #>

    param(
        [Parameter(Mandatory)][string]$TaskId,
        [Parameter(Mandatory)][string]$BotRoot
    )

    Initialize-WorktreeMap -BotRoot $BotRoot
    $map = Read-WorktreeMap
    if ($map.ContainsKey($TaskId)) {
        $path = $map[$TaskId].worktree_path
        if (Test-Path $path) { return $path }
    }
    return $null
}

function Get-TaskWorktreeInfo {
    <#
    .SYNOPSIS
    Look up the full worktree registry entry for a task ID.

    .OUTPUTS
    PSObject with worktree_path, branch_name, task_name, created_at — or $null
    #>

    param(
        [Parameter(Mandatory)][string]$TaskId,
        [Parameter(Mandatory)][string]$BotRoot
    )

    Initialize-WorktreeMap -BotRoot $BotRoot
    $map = Read-WorktreeMap
    if ($map.ContainsKey($TaskId)) { return $map[$TaskId] }
    return $null
}

function Get-GitignoredCopyPaths {
    <#
    .SYNOPSIS
    Find gitignored files that exist in the repo, excluding noisy regenerable dirs.

    .OUTPUTS
    Array of relative paths (small config files like .env)
    #>

    param(
        [Parameter(Mandatory)][string]$ProjectRoot
    )

    try {
        $ignoredFiles = git -C $ProjectRoot ls-files --others --ignored --exclude-standard 2>$null
        if (-not $ignoredFiles -or $LASTEXITCODE -ne 0) { return @() }

        $paths = @()
        foreach ($relativePath in $ignoredFiles) {
            $parts = $relativePath -split '[/\\]'
            $isNoisy = $false
            foreach ($part in $parts) {
                if ($script:NoiseDirectories -contains $part) {
                    $isNoisy = $true
                    break
                }
            }
            if (-not $isNoisy) {
                $paths += $relativePath
            }
        }
        return $paths
    } catch {
        return @()
    }
}

function Copy-BuildArtifacts {
    <#
    .SYNOPSIS
    Copy non-noisy gitignored files from main repo to worktree.
    #>

    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][string]$WorktreePath
    )

    $paths = Get-GitignoredCopyPaths -ProjectRoot $ProjectRoot
    if ($paths.Count -eq 0) { return }

    foreach ($relativePath in $paths) {
        $sourcePath = Join-Path $ProjectRoot $relativePath
        $destPath = Join-Path $WorktreePath $relativePath

        if (-not (Test-Path $sourcePath)) { continue }

        $destParent = Split-Path $destPath -Parent
        if (-not (Test-Path $destParent)) {
            New-Item -Path $destParent -ItemType Directory -Force | Out-Null
        }

        try {
            if (Test-Path $sourcePath -PathType Container) {
                Copy-Item -Path $sourcePath -Destination $destPath -Recurse -Force
            } else {
                Copy-Item -Path $sourcePath -Destination $destPath -Force
            }
        } catch {
            # Non-critical — skip files that can't be copied
        }
    }
}

function Remove-OrphanWorktrees {
    <#
    .SYNOPSIS
    Clean up worktrees for tasks that are no longer active (done/skipped/cancelled).
    Called on process startup.
    #>

    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][string]$BotRoot
    )

    Initialize-WorktreeMap -BotRoot $BotRoot
    $map = Read-WorktreeMap
    if ($map.Count -eq 0) { return }

    $tasksBaseDir = Join-Path $BotRoot "workspace\tasks"
    # 'done' is included: tasks that just completed execution may still have a live worktree
    # pending squash-merge by Complete-TaskWorktree. Removing them here would race with that.
    $activeDirs = @('todo', 'analysing', 'needs-input', 'analysed', 'in-progress', 'done')
    $orphanIds = @()

    foreach ($taskId in @($map.Keys)) {
        $isActive = $false
        foreach ($dir in $activeDirs) {
            $dirPath = Join-Path $tasksBaseDir $dir
            if (-not (Test-Path $dirPath)) { continue }
            $files = Get-ChildItem -Path $dirPath -Filter "*.json" -File -ErrorAction SilentlyContinue
            foreach ($f in $files) {
                try {
                    $content = Get-Content -Path $f.FullName -Raw | ConvertFrom-Json
                    if ($content.id -eq $taskId) {
                        $isActive = $true
                        break
                    }
                } catch { Write-BotLog -Level Debug -Message "Failed to read task file $($f.FullName)" -Exception $_ }
            }
            if ($isActive) { break }
        }
        if (-not $isActive) { $orphanIds += $taskId }
    }

    foreach ($taskId in $orphanIds) {
        $entry = $map[$taskId]
        $worktreePath = $entry.worktree_path
        $branchName = $entry.branch_name

        # Kill any lingering processes in the orphan worktree before cleanup
        if ($worktreePath -and (Test-Path $worktreePath)) {
            $killedCount = Stop-WorktreeProcesses -WorktreePath $worktreePath
            if ($killedCount -gt 0) {
                Start-Sleep -Milliseconds 500
            }
        }

        # Remove junctions first, then only force-remove if junctions are clean
        $junctionsClean = $true
        if ($worktreePath -and (Test-Path $worktreePath)) {
            $junctionsClean = Remove-Junctions -WorktreePath $worktreePath -ErrorOnFailure $false
        }

        # Defense-in-depth: re-verify no junctions exist right before --force
        # Guard against null/missing worktree paths from stale map entries
        if ($junctionsClean -and $worktreePath -and (Test-Path $worktreePath) -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) {
            git -C $ProjectRoot worktree remove $worktreePath --force 2>$null
        } elseif ($worktreePath -and (Test-Path $worktreePath)) {
            if ($junctionsClean) {
                Write-BotLog -Level Warn -Message "Junction re-check found surviving junctions in orphan $taskId — downgrading to safe removal"
            } else {
                Write-BotLog -Level Warn -Message "Skipping force worktree removal for orphan $taskId — junctions still present"
            }
            git -C $ProjectRoot worktree remove $worktreePath 2>$null
        }
        # Verify worktree is actually gone (Fix: silent removal failures)
        if ($worktreePath -and (Test-Path $worktreePath)) {
            Write-BotLog -Level Warn -Message "Orphan worktree removal incomplete — path still exists: $worktreePath"
        }
        git -C $ProjectRoot branch -D $branchName 2>$null
    }

    if ($orphanIds.Count -gt 0) {
        # Locked read-modify-write — prevents concurrent processes from losing map entries
        Invoke-WorktreeMapLocked -Action {
            $lockedMap = Read-WorktreeMap
            foreach ($id in $orphanIds) { $lockedMap.Remove($id) }
            Write-WorktreeMap -Map $lockedMap
        }
    }
}

# --- Module Exports ---
Export-ModuleMember -Function @(
    'Initialize-WorktreeMap'
    'Read-WorktreeMap'
    'Write-WorktreeMap'
    'Invoke-WorktreeMapLocked'
    'Resolve-MainBranch'
    'Assert-OnBaseBranch'
    'Stop-WorktreeProcesses'
    'Invoke-Git'
    'Remove-Junctions'
    'New-TaskWorktree'
    'Complete-TaskWorktree'
    'Get-TaskWorktreePath'
    'Get-TaskWorktreeInfo'
    'Get-GitignoredCopyPaths'
    'Copy-BuildArtifacts'
    'Remove-OrphanWorktrees'
)