scripts/init-project.ps1

#!/usr/bin/env pwsh
<#
.SYNOPSIS
    Initialize .bot in the current project

.DESCRIPTION
    Copies the default .bot structure to the current project directory.
    Optionally installs a workflow and/or tech-specific stacks.
    Checks for required dependencies (git is required; others warn-only).
    Creates .mcp.json with dotbot, Context7, and Playwright MCP servers.
    Installs gitleaks pre-commit hook if gitleaks is available.

    Workflows change HOW dotbot operates (at most one).
    Stacks change WHAT dotbot knows (composable, multiple allowed).
    Stacks may declare 'extends: <parent>' to auto-include a parent stack.

.PARAMETER Workflow
    Workflow to install (e.g., 'kickstart-via-jira'). At most one.

.PARAMETER Stack
    Stack(s) to install (e.g., 'dotnet', 'dotnet-blazor,dotnet-ef').
    Accepts a comma-separated string or multiple -Stack values.

.PARAMETER Force
    Overwrite existing .bot system files (preserves workspace data).

.PARAMETER DryRun
    Preview changes without applying.

.EXAMPLE
    init-project.ps1
    Installs base default only.

.EXAMPLE
    init-project.ps1 -Stack dotnet
    Installs base default + dotnet stack.

.EXAMPLE
    init-project.ps1 -Workflow kickstart-via-jira -Stack dotnet-blazor,dotnet-ef
    Installs default -> kickstart-via-jira (workflow) -> dotnet (auto) -> dotnet-blazor -> dotnet-ef.
#>


[CmdletBinding()]
param(
    [string]$Workflow,
    [string[]]$Stack,
    [switch]$Force,
    [switch]$DryRun
)

$ErrorActionPreference = "Stop"

# Reset strict mode — callers (e.g. setup-iwg-scoring) may set
# Set-StrictMode -Version Latest which propagates here and breaks
# intrinsic .Count on non-collection types like [string].
Set-StrictMode -Off

$DotbotBase = Join-Path $HOME "dotbot"
$DefaultDir = Join-Path $DotbotBase "workflows\default"
$ProjectDir = Get-Location
$BotDir = Join-Path $ProjectDir ".bot"

# Import platform functions
Import-Module (Join-Path $DotbotBase "scripts\Platform-Functions.psm1") -Force

# Deprecated workflow aliases
$workflowAliases = @{
    'multi-repo' = 'kickstart-via-jira'
}
if ($Workflow -and $workflowAliases.ContainsKey($Workflow)) {
    $resolved = $workflowAliases[$Workflow]
    Write-BlankLine
    Write-DotbotWarning "'$Workflow' is deprecated — use '$resolved' instead"
    $Workflow = $resolved
}

Write-DotbotBanner -Title "D O T B O T v3.5" -Subtitle "Project Initialization"

# ---------------------------------------------------------------------------
# Dependency check (git required; others warn-only)
# ---------------------------------------------------------------------------
Write-DotbotSection -Title "DEPENDENCY CHECK"

$depWarnings = 0

if ($PSVersionTable.PSVersion.Major -ge 7) {
    Write-Success "PowerShell 7+ ($($PSVersionTable.PSVersion))"
} else {
    Write-DotbotWarning "PowerShell 7+ is required (current: $($PSVersionTable.PSVersion))"
    Write-DotbotCommand "Download from: https://aka.ms/powershell"
    $depWarnings++
}

if (Get-Command git -ErrorAction SilentlyContinue) {
    Write-Success "Git"
} else {
    Write-DotbotError "Git is required but not installed"
    Write-DotbotCommand "Download from: https://git-scm.com/downloads"
    exit 1
}

if (Get-Command claude -ErrorAction SilentlyContinue) {
    Write-Success "Claude CLI"
} else {
    Write-DotbotWarning "Claude CLI is not installed"
    Write-DotbotCommand "Install: npm install -g @anthropic-ai/claude-code"
    $depWarnings++
}

if (Get-Command codex -ErrorAction SilentlyContinue) {
    Write-Success "Codex CLI"
} else {
    Write-DotbotWarning "Codex CLI is not installed"
    Write-DotbotCommand "Install: npm install -g @openai/codex"
    $depWarnings++
}

if (Get-Command gemini -ErrorAction SilentlyContinue) {
    Write-Success "Gemini CLI"
} else {
    Write-DotbotWarning "Gemini CLI is not installed"
    Write-DotbotCommand "Install: npm install -g @google/gemini-cli"
    $depWarnings++
}

if (Get-Command npx -ErrorAction SilentlyContinue) {
    Write-Success "Node.js / npx (for Context7 and Playwright MCP)"
} else {
    Write-DotbotWarning "Node.js / npx is not installed (needed for MCP servers)"
    Write-DotbotCommand "Download from: https://nodejs.org"
    $depWarnings++
}

if (Get-Command gitleaks -ErrorAction SilentlyContinue) {
    Write-Success "gitleaks"
} else {
    Write-DotbotWarning "gitleaks is not installed (secret scanning)"
    Write-DotbotCommand "Install: winget install Gitleaks.Gitleaks"
    $depWarnings++
}

if ($depWarnings -gt 0) {
    Write-BlankLine
    Write-DotbotWarning "$depWarnings missing dependency/dependencies -- continuing anyway"
}
Write-BlankLine

# Ensure project is a git repository
$gitDir = Join-Path $ProjectDir ".git"
if (-not (Test-Path $gitDir)) {
    Write-Status "No .git directory found -- initializing git repository"
    & git init $ProjectDir
    Write-Success "Initialized git repository"
}

# Check if default exists
if (-not (Test-Path $DefaultDir)) {
    Write-DotbotError "Default directory not found: $DefaultDir"
    Write-DotbotWarning "Run 'dotbot update' to repair installation"
    exit 1
}

# Check if .bot already exists
if ((Test-Path $BotDir) -and -not $Force) {
    Write-DotbotWarning ".bot directory already exists"
    Write-DotbotWarning "Use -Force to overwrite"
    Write-BlankLine
    exit 1
}

Write-Status "Initializing .bot in: $ProjectDir"

if ($DryRun) {
    Write-DotbotWarning "Would copy default from: $DefaultDir"
    Write-DotbotWarning "Would copy to: $BotDir"
    Write-BlankLine
    exit 0
}

# ---------------------------------------------------------------------------
# Migrate legacy folder names (defaults→settings, prompts→recipes, adrs→decisions)
# ---------------------------------------------------------------------------
function Invoke-BotFolderMigration {
    param([string]$Dir)
    if (-not (Test-Path $Dir)) { return }

    # defaults/ → settings/
    $old = Join-Path $Dir "defaults"
    $new = Join-Path $Dir "settings"
    if ((Test-Path $old) -and -not (Test-Path $new)) { Rename-Item $old $new }

    # prompts/workflows/ → prompts/_prompts_tmp, then prompts/ → recipes/, then rename inner
    $oldInner = Join-Path $Dir "prompts\workflows"
    $newInner = Join-Path $Dir "prompts\_prompts_tmp"
    if ((Test-Path $oldInner) -and -not (Test-Path $newInner)) { Rename-Item $oldInner $newInner }
    $oldOuter = Join-Path $Dir "prompts"
    $newOuter = Join-Path $Dir "recipes"
    if ((Test-Path $oldOuter) -and -not (Test-Path $newOuter)) {
        Rename-Item $oldOuter $newOuter
        $tmpInner = Join-Path $newOuter "_prompts_tmp"
        $finalInner = Join-Path $newOuter "prompts"
        if ((Test-Path $tmpInner) -and -not (Test-Path $finalInner)) { Rename-Item $tmpInner $finalInner }
    }

    # workspace/adrs/ → workspace/decisions/
    $oldAdrs = Join-Path $Dir "workspace\adrs"
    $newDec = Join-Path $Dir "workspace\decisions"
    if ((Test-Path $oldAdrs) -and -not (Test-Path $newDec)) { Rename-Item $oldAdrs $newDec }

    # Migrate installed workflow subdirectories
    $wfDir = Join-Path $Dir "workflows"
    if (Test-Path $wfDir) {
        Get-ChildItem $wfDir -Directory | ForEach-Object {
            Invoke-BotFolderMigration -Dir $_.FullName
        }
    }
}

# Run migration on existing .bot if present
if (Test-Path $BotDir) {
    Invoke-BotFolderMigration -Dir $BotDir
}

# ---------------------------------------------------------------------------
# Handle existing .bot with -Force (preserve workspace data)
# ---------------------------------------------------------------------------
$existingInstanceId = $null
if ((Test-Path $BotDir) -and $Force) {
    # Preserve instance_id before replacing settings/
    $existingSettingsPath = Join-Path $BotDir "settings\settings.default.json"
    if (Test-Path $existingSettingsPath) {
        try {
            $existingSettings = Get-Content $existingSettingsPath -Raw | ConvertFrom-Json
            if ($existingSettings.PSObject.Properties['instance_id'] -and $existingSettings.instance_id) {
                $parsedGuid = [guid]::Empty
                if ([guid]::TryParse("$($existingSettings.instance_id)", [ref]$parsedGuid)) {
                    $existingInstanceId = $parsedGuid.ToString()
                }
            }
        } catch { Write-DotbotCommand "Parse skipped: $_" }
    }

    Write-Status "Updating .bot system files (preserving workspace data)"
    # Remove only system/config directories and root files -- never workspace/ or .control/
    $systemDirs = @("systems", "recipes", "hooks", "settings")
    foreach ($dir in $systemDirs) {
        $dirPath = Join-Path $BotDir $dir
        if (Test-Path $dirPath) {
            Remove-Item -Path $dirPath -Recurse -Force
        }
    }
    $rootFiles = @("go.ps1", "init.ps1", "README.md", ".gitignore", "workflow.yaml")
    foreach ($file in $rootFiles) {
        $filePath = Join-Path $BotDir $file
        if (Test-Path $filePath) {
            Remove-Item -Path $filePath -Force
        }
    }
}

# Copy default to .bot
Write-Status "Copying default files"
if (Test-Path $BotDir) {
    # .bot exists (Force path) -- copy contents on top, preserving workspace
    Copy-Item -Path (Join-Path $DefaultDir "*") -Destination $BotDir -Recurse -Force
} else {
    Copy-Item -Path $DefaultDir -Destination $BotDir -Recurse -Force
}

# Create empty workspace directories
$workspaceDirs = @(
    "workspace\tasks\todo",
    "workspace\tasks\todo\edited_tasks",
    "workspace\tasks\todo\deleted_tasks",
    "workspace\tasks\analysing",
    "workspace\tasks\analysed",
    "workspace\tasks\needs-input",
    "workspace\tasks\in-progress",
    "workspace\tasks\done",
    "workspace\tasks\split",
    "workspace\tasks\skipped",
    "workspace\tasks\cancelled",
    "workspace\sessions",
    "workspace\sessions\runs",
    "workspace\sessions\history",
    "workspace\plans",
    "workspace\product",
    "workspace\feedback\pending",
    "workspace\feedback\applied",
    "workspace\feedback\archived"
)

foreach ($dir in $workspaceDirs) {
    $fullPath = Join-Path $BotDir $dir
    if (-not (Test-Path $fullPath)) {
        New-Item -ItemType Directory -Path $fullPath -Force | Out-Null
    }
    # Add .gitkeep to empty directories
    $gitkeep = Join-Path $fullPath ".gitkeep"
    if (-not (Test-Path $gitkeep)) {
        New-Item -ItemType File -Path $gitkeep -Force | Out-Null
    }
}

Write-Success "Created .bot directory structure"

# ---------------------------------------------------------------------------
# Import workflow manifest utilities
# ---------------------------------------------------------------------------
. (Join-Path $BotDir "systems\runtime\modules\workflow-manifest.ps1")

# ---------------------------------------------------------------------------
# Workflow install (new multi-workflow system)
# ---------------------------------------------------------------------------
$installedWorkflows = @()
if ($Workflow) {
    Write-BlankLine
    Write-DotbotSection -Title "WORKFLOW INSTALL"

    # Ensure workflows directory exists
    $workflowsBaseDir = Join-Path $BotDir "workflows"
    if (-not (Test-Path $workflowsBaseDir)) {
        New-Item -Path $workflowsBaseDir -ItemType Directory -Force | Out-Null
    }

    $RegistriesDir = Join-Path $DotbotBase "registries"

    foreach ($wfSpec in $Workflow) {
        foreach ($wfToken in ($wfSpec -split ',')) {
            $wfName = $wfToken.Trim()
            if (-not $wfName) { continue }

            # Resolve workflow source directory (registry or built-in)
            $wfSourceDir = $null
            if ($wfName -match '^([^:]+):(.+)$') {
                $namespace = $Matches[1]
                $wfShortName = $Matches[2]
                $candidate = Join-Path $RegistriesDir "$namespace\workflows\$wfShortName"
                if (Test-Path $candidate) { $wfSourceDir = $candidate }
                $displayName = $wfShortName
            } else {
            # Check built-in workflows dir
                $candidate = Join-Path (Join-Path $DotbotBase "workflows") $wfName
                if (Test-Path $candidate) { $wfSourceDir = $candidate }
                $displayName = $wfName
            }

            if (-not $wfSourceDir) {
                Write-DotbotError "Workflow not found: $wfName"
                continue
            }

            Write-Status "Installing workflow: $displayName"

            # Target directory: .bot/workflows/{name}/
            $wfTargetDir = Join-Path $workflowsBaseDir $displayName
            if ((Test-Path $wfTargetDir) -and $Force) {
                Remove-Item $wfTargetDir -Recurse -Force
            }
            if (-not (Test-Path $wfTargetDir)) {
                New-Item -Path $wfTargetDir -ItemType Directory -Force | Out-Null
            }

            # Copy all workflow files (skip profile metadata)
            $wfSourceDirFull = [System.IO.Path]::GetFullPath($wfSourceDir)
            Get-ChildItem -Path $wfSourceDir -Recurse -File | ForEach-Object {
                $sourceFileFull = [System.IO.Path]::GetFullPath($_.FullName)
                $relativePath = [System.IO.Path]::GetRelativePath($wfSourceDirFull, $sourceFileFull)
                $relativePathKey = $relativePath -replace '\\', '/'

            # Skip metadata files
            if ($relativePathKey -eq "on-install.ps1") { return }
            if ($relativePathKey -eq "manifest.yaml") { return }

                # Remap legacy paths: systems/mcp/tools/* -> tools/*
                if ($relativePathKey -match '^systems/mcp/tools/(.+)$') {
                    $relativePath = "tools/$($Matches[1])"
                }
                # Remap: settings/settings.default.json -> settings.json
                if ($relativePathKey -eq "settings/settings.default.json") {
                    $relativePath = "settings.json"
                }

                $destPath = Join-Path $wfTargetDir $relativePath
                $destDir = Split-Path $destPath -Parent
                if (-not (Test-Path $destDir)) {
                    New-Item -ItemType Directory -Path $destDir -Force | Out-Null
                }
                Copy-Item -Path $_.FullName -Destination $destPath -Force
            }

            # Copy workflow.yaml if it exists (preferred), otherwise generate minimal one
            $wfYamlSource = Join-Path $wfSourceDir "workflow.yaml"
            $wfYamlTarget = Join-Path $wfTargetDir "workflow.yaml"
            if (Test-Path $wfYamlSource) {
                Copy-Item $wfYamlSource $wfYamlTarget -Force
            } elseif (-not (Test-Path $wfYamlTarget)) {
                # Auto-generate from manifest.yaml
                $manifestYaml = Join-Path $wfSourceDir "manifest.yaml"
                if (Test-Path $manifestYaml) {
                    Copy-Item $manifestYaml $wfYamlTarget -Force
                }
            }

            # Parse manifest for env vars and MCP servers
            $manifest = Read-WorkflowManifest -WorkflowDir $wfTargetDir

            # Scaffold .env.local from requires.env_vars
            $envVars = @()
            if ($manifest.requires -and $manifest.requires.env_vars) {
                $envVars = @($manifest.requires.env_vars)
            } elseif ($manifest.requires -and $manifest.requires['env_vars']) {
                $envVars = @($manifest.requires['env_vars'])
            }
            if ($envVars.Count -gt 0) {
                $envLocalPath = Join-Path $ProjectDir ".env.local"
                New-EnvLocalScaffold -EnvLocalPath $envLocalPath -EnvVars $envVars
                # Ensure .env.local is gitignored
                $gi = Join-Path $ProjectDir ".gitignore"
                if (Test-Path $gi) {
                    $giContent = Get-Content $gi -Raw
                    if ($giContent -notmatch '\.env\.local') {
                        Add-Content $gi ".env.local"
                    }
                }
            }

            # Merge MCP servers into .mcp.json
            if ($manifest.mcp_servers -and ($manifest.mcp_servers.Count -gt 0 -or ($manifest.mcp_servers.PSObject -and $manifest.mcp_servers.PSObject.Properties.Count -gt 0))) {
                $mcpJsonPath = Join-Path $ProjectDir ".mcp.json"
                $addedCount = Merge-McpServers -McpJsonPath $mcpJsonPath -WorkflowServers $manifest.mcp_servers
                if ($addedCount -gt 0) {
                    Write-DotbotCommand "Merged $addedCount MCP server(s) into .mcp.json"
                }
            }

            # Run init script if present in source
            $wfInitScript = Join-Path $wfSourceDir "on-install.ps1"
            if (Test-Path $wfInitScript) {
                Write-Status "Running $displayName init script"
                & $wfInitScript
            }

            $installedWorkflows += $displayName
            Write-Success "Installed workflow: $displayName"
        }
    }

    # Record installed workflows in core settings
    $settingsPath = Join-Path $BotDir "settings\settings.default.json"
    if (Test-Path $settingsPath) {
        $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
        $settings | Add-Member -NotePropertyName "installed_workflows" -NotePropertyValue $installedWorkflows -Force
        $settings | ConvertTo-Json -Depth 10 | Set-Content $settingsPath
    }
}

# ---------------------------------------------------------------------------
# Resolve workflow + stacks and install overlays
# ---------------------------------------------------------------------------
$WorkflowsDir = Join-Path $DotbotBase "workflows"
$StacksDir = Join-Path $DotbotBase "stacks"

# Normalise -Stack input: accept comma-separated strings and/or arrays
$requestedStacks = @()
if ($Stack -and $Stack.Count -gt 0) {
    foreach ($entry in $Stack) {
        foreach ($token in ($entry -split ',')) {
            $trimmed = $token.Trim()
            if ($trimmed) { $requestedStacks += $trimmed }
        }
    }

    # Deduplicate while preserving the first-seen order (case-insensitive)
    $dedupedStacks = @()
    $seenStacks = @{}
    foreach ($name in $requestedStacks) {
        $key = $name.ToLowerInvariant()
        if (-not $seenStacks.ContainsKey($key)) {
            $seenStacks[$key] = $true
            $dedupedStacks += $name
        }
    }
    $requestedStacks = $dedupedStacks
}

# --- Helper: parse a manifest.yaml (no external YAML module needed) ---
function Read-ManifestYaml {
    param([string]$Dir)
    $yamlPath = Join-Path $Dir "manifest.yaml"
    $meta = @{ name = (Split-Path $Dir -Leaf); description = ""; extends = $null }
    if (Test-Path $yamlPath) {
        Get-Content $yamlPath | ForEach-Object {
            if ($_ -match '^\s*(name|description|extends)\s*:\s*(.+)$') {
                $meta[$Matches[1]] = $Matches[2].Trim()
            }
        }
    }
    return $meta
}

# --- Helper: deep-merge two PSCustomObjects / hashtables ---
function Merge-DeepSettings {
    param($Base, $Override)
    if ($null -eq $Base) { return $Override }
    if ($null -eq $Override) { return $Base }

    # Convert PSCustomObject to ordered hashtable for mutation
    function ConvertTo-OrderedHash ($obj) {
        if ($obj -is [System.Collections.IDictionary]) { return $obj }
        $h = [ordered]@{}
        foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = $p.Value }
        return $h
    }

    $result = ConvertTo-OrderedHash $Base
    $over = ConvertTo-OrderedHash $Override

    foreach ($key in $over.Keys) {
        $overVal = $over[$key]
        if ($result.Contains($key)) {
            $baseVal = $result[$key]
            if ($baseVal -is [System.Collections.IDictionary] -or ($baseVal -is [PSCustomObject] -and $baseVal.PSObject.Properties.Count -gt 0)) {
                # Recurse into nested objects
                $result[$key] = Merge-DeepSettings $baseVal $overVal
            } elseif ($baseVal -is [System.Collections.IList] -and $overVal -is [System.Collections.IList]) {
                # Arrays of objects (e.g. kickstart phases): replace entirely (ordered pipelines)
                # Arrays of scalars (e.g. task_categories): concat + dedup
                $hasObjects = ($overVal | Where-Object { $_ -is [PSCustomObject] } | Select-Object -First 1)
                if ($hasObjects) {
                    # Ordered pipeline — override replaces base entirely
                    $result[$key] = $overVal
                } else {
                    # Scalar array — concat + dedup
                    $merged = [System.Collections.ArrayList]::new(@($baseVal))
                    foreach ($item in $overVal) {
                        if ($merged -notcontains $item) { $merged.Add($item) | Out-Null }
                    }
                    $result[$key] = @($merged)
                }
            } else {
                # Scalars: last writer wins
                $result[$key] = $overVal
            }
        } else {
            $result[$key] = $overVal
        }
    }
    return $result
}

# --- Helper: resolve stack directory (built-in or registry namespace) ---
function Resolve-StackDir {
    param([string]$Name)
    # Check for namespace prefix (e.g., "myorg:my-stack")
    if ($Name -match '^([^:]+):(.+)$') {
        $namespace = $Matches[1]
        $stackName = $Matches[2]
        $RegistriesDir = Join-Path $DotbotBase "registries"
        $candidate = Join-Path $RegistriesDir "$namespace\stacks\$stackName"
        if (Test-Path $candidate) { return $candidate }
        return $null
    }
    # Built-in stack
    $candidate = Join-Path $StacksDir $Name
    if (Test-Path $candidate) { return $candidate }
    return $null
}

# --- Resolve workflow + extends chains for stacks ---
$resolvedOrder = @()            # final ordered list of names to install
$activeWorkflow = $null         # resolved workflow name (at most one)
$stackNames = @()               # zero or more
$catalogMeta = @{}              # name -> metadata hash
$catalogDirMap = @{}            # name -> resolved directory path

# Resolve workflow (from --Workflow param, at most one)
if ($Workflow) {
    $wfDir = $null
    if ($Workflow -match '^([^:]+):(.+)$') {
        $ns = $Matches[1]; $wfShort = $Matches[2]
        $candidate = Join-Path (Join-Path $DotbotBase "registries") "$ns\workflows\$wfShort"
        if (Test-Path $candidate) { $wfDir = $candidate }
    } else {
        $candidate = Join-Path $WorkflowsDir $Workflow
        if (Test-Path $candidate) { $wfDir = $candidate }
    }
    if (-not $wfDir) {
        Write-DotbotError "Workflow not found: $Workflow"
        Write-DotbotWarning "Available workflows:"
        if (Test-Path $WorkflowsDir) {
            Get-ChildItem -Path $WorkflowsDir -Directory | ForEach-Object { Write-Status "- $($_.Name)" }
        }
        exit 1
    }
    $activeWorkflow = $Workflow
    $catalogDirMap[$Workflow] = $wfDir
    $catalogMeta[$Workflow] = Read-ManifestYaml $wfDir
}

if ($requestedStacks.Count -gt 0 -or $activeWorkflow) {
    Write-BlankLine
    Write-DotbotSection -Title "RESOLUTION"

    if ($activeWorkflow) {
        Write-DotbotLabel -Label "Workflow " -Value "$activeWorkflow"
    }

    # Resolve stacks + extends chains
    $toProcess = [System.Collections.Generic.Queue[string]]::new()
    foreach ($name in $requestedStacks) { $toProcess.Enqueue($name) }
    $seen = @{}

    while ($toProcess.Count -gt 0) {
        $name = $toProcess.Dequeue()
        if ($seen.ContainsKey($name)) { continue }
        $seen[$name] = $true

        $stackDir = Resolve-StackDir $name
        if (-not $stackDir) {
            Write-DotbotError "Stack not found: $name"
            Write-DotbotWarning "Available stacks:"
            if (Test-Path $StacksDir) {
                Get-ChildItem -Path $StacksDir -Directory | ForEach-Object { Write-Status "- $($_.Name)" }
            }
            $RegistriesDir = Join-Path $DotbotBase "registries"
            if (Test-Path $RegistriesDir) {
                Get-ChildItem -Path $RegistriesDir -Directory | ForEach-Object {
                    $ns = $_.Name
                    $ctDir = Join-Path $_.FullName "stacks"
                    if (Test-Path $ctDir) {
                        Get-ChildItem -Path $ctDir -Directory | ForEach-Object {
                            Write-Status "- ${ns}:$($_.Name)"
                        }
                    }
                }
            }
            exit 1
        }
        $catalogDirMap[$name] = $stackDir
        if ($name -match ':') {
            Write-Status "Registry: $name -> $stackDir"
        }

        $meta = Read-ManifestYaml $stackDir
        $catalogMeta[$name] = $meta
        $stackNames += $name

        # If this stack extends another, queue the parent
        if ($meta.extends -and -not $seen.ContainsKey($meta.extends)) {
            $toProcess.Enqueue($meta.extends)
            Write-DotbotCommand "Auto-including '$($meta.extends)' (required by '$name')"
        }

        $label = $name
        if ($meta.extends) { $label += " (extends: $($meta.extends))" }
        Write-DotbotLabel -Label "Stack " -Value "$label"
    }

    # Build final order: workflow first, then stacks in dependency-resolved order
    if ($activeWorkflow) { $resolvedOrder += $activeWorkflow }

    # Topological sort for stacks (parents before children)
    $stackSorted = @()
    $visited = @{}
    function Visit-Stack ($name) {
        if ($visited.ContainsKey($name)) { return }
        $visited[$name] = $true
        $parent = $catalogMeta[$name].extends
        if ($parent -and $catalogMeta.ContainsKey($parent)) {
            Visit-Stack $parent
        }
        $script:stackSorted += $name
    }
    foreach ($name in $stackNames) { Visit-Stack $name }
    $resolvedOrder += $stackSorted

    Write-BlankLine
    Write-Status "Apply order: default -> $($resolvedOrder -join ' -> ')"
}

# --- Install each entry (overlay on top of default) ---
$installedStacks = @()

foreach ($entryName in $resolvedOrder) {
    $entryDir = $catalogDirMap[$entryName]
    $entryDirFull = [System.IO.Path]::GetFullPath($entryDir)
    $meta = $catalogMeta[$entryName]
    $isWorkflow = ($entryName -eq $activeWorkflow)
    $entryType = if ($isWorkflow) { "workflow" } else { "stack" }

    Write-Status "Installing ${entryType}: $entryName"

    # Copy files (overlay on top of default)
    Get-ChildItem -Path $entryDir -Recurse -File | ForEach-Object {
        $sourceFileFull = [System.IO.Path]::GetFullPath($_.FullName)
        $relativePath = [System.IO.Path]::GetRelativePath($entryDirFull, $sourceFileFull)
        $relativePathKey = $relativePath -replace '\\', '/'
        $destPath = Join-Path $BotDir $relativePath
        $destDir = Split-Path $destPath -Parent

        # Skip metadata files (not copied to .bot/)
        if ($relativePathKey -eq "on-install.ps1") { return }
        if ($relativePathKey -eq "manifest.yaml") { return }
        if ($relativePathKey -eq "workflow.yaml") { return }  # Preserve default manifest; installed workflows live in workflows/<name>/

        # Skip workflow-scoped prompts (already installed to .bot/workflows/<name>/)
        if ($isWorkflow -and $relativePathKey -match '^recipes/(agents|skills|includes)/') { return }

        # Handle config.json merging for hooks/verify
        if ($relativePathKey -eq "hooks/verify/config.json") {
            $baseConfigPath = [System.IO.Path]::Combine($BotDir, "hooks", "verify", "config.json")
            if (Test-Path $baseConfigPath) {
                $baseConfig = Get-Content $baseConfigPath -Raw | ConvertFrom-Json
                $overlayConfig = Get-Content $_.FullName -Raw | ConvertFrom-Json

                $existingNames = @{}
                foreach ($s in @($baseConfig.scripts)) { $existingNames[$s.name] = $true }
                $mergedScripts = @($baseConfig.scripts)
                foreach ($s in @($overlayConfig.scripts)) {
                    if (-not $existingNames.ContainsKey($s.name)) {
                        $mergedScripts += $s
                    }
                }
                $baseConfig.scripts = $mergedScripts

                $baseConfig | ConvertTo-Json -Depth 10 | Set-Content $baseConfigPath
                Write-DotbotCommand "Merged: $relativePath"
                return
            }
        }

        # Handle settings.default.json deep-merge
        if ($relativePathKey -eq "settings/settings.default.json") {
            $baseSettingsPath = [System.IO.Path]::Combine($BotDir, "settings", "settings.default.json")
            if (Test-Path $baseSettingsPath) {
                $baseSettings = Get-Content $baseSettingsPath -Raw | ConvertFrom-Json
                $overlaySettings = Get-Content $_.FullName -Raw | ConvertFrom-Json
                $merged = Merge-DeepSettings $baseSettings $overlaySettings
                $merged | ConvertTo-Json -Depth 10 | Set-Content $baseSettingsPath
                Write-DotbotCommand "Merged: $relativePath"
                return
            }
        }

        # Create directory if needed
        if (-not (Test-Path $destDir)) {
            New-Item -ItemType Directory -Path $destDir -Force | Out-Null
        }

        # Copy file
        Copy-Item -Path $_.FullName -Destination $destPath -Force
        Write-DotbotCommand "Copied: $relativePath"
    }

    # Clean stale default workflows when a workflow is installed
    if ($isWorkflow) {
        $workflowDir = Join-Path $BotDir "recipes\prompts"
        if (Test-Path $workflowDir) {
            # Collect filenames the overlay just provided
            $overlayWorkflowDir = Join-Path $entryDir "recipes\prompts"
            $overlayFiles = @{}
            if (Test-Path $overlayWorkflowDir) {
                Get-ChildItem -Path $overlayWorkflowDir -File | ForEach-Object {
                    $overlayFiles[$_.Name] = $true
                }
            }
            # Remove 00-89 range .md files NOT provided by the overlay
            Get-ChildItem -Path $workflowDir -File -Filter "*.md" | Where-Object {
                $_.Name -match '^[0-8]\d' -and -not $overlayFiles.ContainsKey($_.Name)
            } | ForEach-Object {
                Remove-Item -Path $_.FullName -Force
                Write-DotbotWarning "Removed stale default workflow: $($_.Name)"
            }
        }
    }

    # Merge domain.task_categories from workflow manifest into settings
    if ($isWorkflow) {
        $wfManifestDir = Join-Path $BotDir "workflows\$entryName"
        if (-not (Test-Path $wfManifestDir)) { $wfManifestDir = Join-Path $BotDir "" }
        $wfManifest = $null
        try {
            . "$BotDir\systems\runtime\modules\workflow-manifest.ps1"
            $wfManifest = Read-WorkflowManifest -WorkflowDir $wfManifestDir
        } catch { Write-DotbotCommand "Parse skipped: $_" }
        if ($wfManifest -and $wfManifest.domain -and $wfManifest.domain['task_categories']) {
            $wfCategories = @($wfManifest.domain['task_categories'])
            $settingsFile = Join-Path $BotDir "settings\settings.default.json"
            if (Test-Path $settingsFile) {
                $sObj = Get-Content $settingsFile -Raw | ConvertFrom-Json
                $currentCategories = @()
                if ($sObj.PSObject.Properties['task_categories']) { $currentCategories = @($sObj.task_categories) }
                $mergedCategories = @($currentCategories + $wfCategories | Select-Object -Unique)
                $sObj | Add-Member -NotePropertyName "task_categories" -NotePropertyValue $mergedCategories -Force
                $sObj | ConvertTo-Json -Depth 10 | Set-Content $settingsFile
            }
        }
    }

    if (-not $isWorkflow) { $installedStacks += $entryName }
    Write-Success "Installed ${entryType}: $entryName"

    # Run init script if present
    $initScript = Join-Path $entryDir "on-install.ps1"
    if (Test-Path $initScript) {
        Write-Status "Running $entryName init script"
        & $initScript
    }
}

# --- Record workflow + stacks in settings ---
if ($resolvedOrder.Count -gt 0) {
    $settingsPath = Join-Path $BotDir "settings\settings.default.json"
    if (Test-Path $settingsPath) {
        $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
        if ($activeWorkflow) {
            $settings | Add-Member -NotePropertyName "workflow" -NotePropertyValue $activeWorkflow -Force
        }
        if ($installedStacks.Count -gt 0) {
            $settings | Add-Member -NotePropertyName "stacks" -NotePropertyValue $installedStacks -Force
        }
        $settings | ConvertTo-Json -Depth 10 | Set-Content $settingsPath
    }
}

# Ensure workspace instance GUID exists (preserve on -Force re-init)
$workspaceSettingsPath = Join-Path $BotDir "settings\settings.default.json"
if (Test-Path $workspaceSettingsPath) {
    try {
        $settings = Get-Content $workspaceSettingsPath -Raw | ConvertFrom-Json
        $currentInstanceId = if ($settings.PSObject.Properties['instance_id']) { "$($settings.instance_id)" } else { "" }
        $parsedCurrentGuid = [guid]::Empty

        if ([guid]::TryParse($currentInstanceId, [ref]$parsedCurrentGuid)) {
            $finalInstanceId = $parsedCurrentGuid.ToString()
        } elseif ($existingInstanceId) {
            $finalInstanceId = $existingInstanceId
        } else {
            $finalInstanceId = [guid]::NewGuid().ToString()
        }

        $settings | Add-Member -NotePropertyName "instance_id" -NotePropertyValue $finalInstanceId -Force
        $settings | ConvertTo-Json -Depth 10 | Set-Content $workspaceSettingsPath
        Write-Success "Workspace instance: $($finalInstanceId.Substring(0,8))"
    } catch {
        Write-DotbotWarning "Failed to set workspace instance ID: $($_.Exception.Message)"
    }
}
# Run .bot/init.ps1 to set up .claude integration
$initScript = Join-Path $BotDir "init.ps1"
if (Test-Path $initScript) {
    Write-Status "Setting up Claude Code integration"
    & $initScript
}

# ---------------------------------------------------------------------------
# Create .mcp.json with MCP server configuration
# ---------------------------------------------------------------------------
$mcpJsonPath = Join-Path $ProjectDir ".mcp.json"
if (Test-Path $mcpJsonPath) {
    Write-DotbotWarning ".mcp.json already exists -- skipping"
} else {
    Write-Status "Creating .mcp.json (dotbot + Context7 + Playwright)"

    # Playwright MCP output goes to .bot/.control/ (gitignored) — uses a relative
    # path so .mcp.json doesn't contain absolute user paths that trip the privacy scan
    $pwOutputDir = ".bot/.control/playwright-output"

    # On Windows, npx must be invoked via 'cmd /c' for stdio MCP servers
    if ($IsWindows) {
        $npxCommand = "cmd"
        $npxContext7Args = @("/c", "npx", "-y", "@upstash/context7-mcp@latest")
        $npxPlaywrightArgs = @("/c", "npx", "-y", "@playwright/mcp@latest", "--output-dir", $pwOutputDir)
    } else {
        $npxCommand = "npx"
        $npxContext7Args = @("-y", "@upstash/context7-mcp@latest")
        $npxPlaywrightArgs = @("-y", "@playwright/mcp@latest", "--output-dir", $pwOutputDir)
    }

    $mcpConfig = @{
        mcpServers = [ordered]@{
            dotbot = [ordered]@{
                type    = "stdio"
                command = "pwsh"
                args    = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ".bot\systems\mcp\dotbot-mcp.ps1")
                env     = @{}
            }
            context7 = [ordered]@{
                type    = "stdio"
                command = $npxCommand
                args    = $npxContext7Args
                env     = @{}
            }
            playwright = [ordered]@{
                type    = "stdio"
                command = $npxCommand
                args    = $npxPlaywrightArgs
                env     = @{}
            }
        }
    }
    $mcpConfig | ConvertTo-Json -Depth 5 | Set-Content -Path $mcpJsonPath -Encoding UTF8
    Write-Success "Created .mcp.json"
}

# ---------------------------------------------------------------------------
# Set up MCP for Codex and Gemini CLIs (if installed)
# ---------------------------------------------------------------------------
$mcpServerScript = ".bot\systems\mcp\dotbot-mcp.ps1"

if (Get-Command codex -ErrorAction SilentlyContinue) {
    Write-Status "Registering dotbot MCP server with Codex CLI..."
    try {
        Push-Location $ProjectDir
        codex mcp add dotbot -- pwsh -NoProfile -ExecutionPolicy Bypass -File $mcpServerScript 2>$null
        Write-Success "Codex MCP server registered"
    } catch {
        Write-DotbotWarning "Failed to register Codex MCP server: $($_.Exception.Message)"
    } finally {
        Pop-Location
    }
} else {
    Write-DotbotCommand "- Codex CLI not found, skipping MCP registration"
}

if (Get-Command gemini -ErrorAction SilentlyContinue) {
    Write-Status "Registering dotbot MCP server with Gemini CLI..."
    try {
        Push-Location $ProjectDir
        gemini mcp add dotbot -- pwsh -NoProfile -ExecutionPolicy Bypass -File $mcpServerScript 2>$null
        Write-Success "Gemini MCP server registered"
    } catch {
        Write-DotbotWarning "Failed to register Gemini MCP server: $($_.Exception.Message)"
    } finally {
        Pop-Location
    }
} else {
    Write-DotbotCommand "- Gemini CLI not found, skipping MCP registration"
}

# ---------------------------------------------------------------------------
# Ensure common patterns are gitignored in the project root
# ---------------------------------------------------------------------------
$projectGitignore = Join-Path $ProjectDir ".gitignore"
$requiredIgnores = @(
    ".codex/"
    ".gemini/"
    "node_modules/"
    "test-results/"
    "playwright-report/"
    ".vscode/mcp.json"
    ".idea"
    ".DS_Store"
    ".env"
    "sessions/"
)

$existingContent = ""
if (Test-Path $projectGitignore) {
    $existingContent = Get-Content $projectGitignore -Raw
}

$entriesToAdd = @()
foreach ($pattern in $requiredIgnores) {
    $escaped = [regex]::Escape($pattern.TrimEnd('/'))
    if ($existingContent -notmatch "(?m)^\s*$escaped/?(\s|$)") {
        $entriesToAdd += $pattern
    }
}

if ($entriesToAdd.Count -gt 0) {
    $block = "`n# dotbot defaults (auto-added by dotbot init)`n"
    foreach ($pattern in $entriesToAdd) {
        $block += "$pattern`n"
    }
    Add-Content -Path $projectGitignore -Value $block -Encoding UTF8
    Write-Success "Added $($entriesToAdd.Count) entries to .gitignore"
} else {
    Write-DotbotCommand "✓ .gitignore already covers dotbot defaults"
}

# ---------------------------------------------------------------------------
# Install pre-commit hook (gitleaks + dotbot privacy scan)
# ---------------------------------------------------------------------------
$hooksDir = Join-Path $gitDir "hooks"
$preCommitPath = Join-Path $hooksDir "pre-commit"

# Determine if an existing hook is ours (dotbot-managed) or user-created
$existingHookIsOurs = $false
if (Test-Path $preCommitPath) {
    $existingContent = Get-Content $preCommitPath -Raw -ErrorAction SilentlyContinue
    if ($existingContent -and $existingContent -match '# dotbot:') {
        $existingHookIsOurs = $true
    }
}

if ((Test-Path $preCommitPath) -and -not $existingHookIsOurs) {
    Write-DotbotWarning "pre-commit hook already exists (not dotbot-managed) -- skipping"
} else {
    Write-Status "Installing pre-commit hook"
    if (-not (Test-Path $hooksDir)) {
        New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null
    }

    # --- Gitleaks section (conditional on availability) ---
    $gitleaksSection = ""
    if (Get-Command gitleaks -ErrorAction SilentlyContinue) {
        # On Windows, Git Bash cannot execute WinGet app execution aliases (reparse
        # points). Resolve the real binary path so the hook calls it directly.
        $gitleaksCmd = "gitleaks"
        if ($IsWindows) {
            $resolved = Get-Command gitleaks -ErrorAction SilentlyContinue
            if ($resolved) {
                $target = (Get-Item $resolved.Source -ErrorAction SilentlyContinue).Target
                if ($target) {
                    $gitleaksCmd = $target -replace '\\', '/'
                } else {
                    $gitleaksCmd = ($resolved.Source) -replace '\\', '/'
                }
            }
        }
        $gitleaksSection = @"

# --- gitleaks ---
"$gitleaksCmd" git --pre-commit --staged || exit `$?
"@

    }

    # --- Resolve pwsh path for Git Bash on Windows ---
    $pwshCmd = "pwsh"
    if ($IsWindows) {
        $resolvedPwsh = Get-Command pwsh -ErrorAction SilentlyContinue
        if ($resolvedPwsh) {
            $target = (Get-Item $resolvedPwsh.Source -ErrorAction SilentlyContinue).Target
            if ($target) {
                $pwshCmd = $target -replace '\\', '/'
            } else {
                $pwshCmd = ($resolvedPwsh.Source) -replace '\\', '/'
            }
        }
    }

    $hookContent = @"
#!/bin/sh
# dotbot: pre-commit hook (gitleaks + privacy scan + reference check)
# Auto-generated by dotbot init — do not edit manually.
$gitleaksSection
# --- resolve hooks directory (installed .bot/ or source workflows/default/) ---
HOOKS_DIR=".bot/hooks/verify"
if [ ! -d "`$HOOKS_DIR" ] && [ -d "workflows/default/hooks/verify" ]; then
  HOOKS_DIR="workflows/default/hooks/verify"
fi
export HOOKS_DIR
# --- dotbot privacy scan ---
if [ -f "`$HOOKS_DIR/00-privacy-scan.ps1" ]; then
  "$pwshCmd" -NoProfile -ExecutionPolicy Bypass -Command '
    `$r = & "`$env:HOOKS_DIR/00-privacy-scan.ps1" -StagedOnly | ConvertFrom-Json;
    if (-not `$r.success) { exit 1 }'
fi
# --- dotbot reference check ---
if [ -f "`$HOOKS_DIR/03-check-md-refs.ps1" ]; then
  "$pwshCmd" -NoProfile -ExecutionPolicy Bypass -Command '
    `$script = "`$env:HOOKS_DIR/03-check-md-refs.ps1";
    if (Test-Path `$script) {
      `$r = & `$script -StagedOnly | ConvertFrom-Json;
      if (-not `$r.success) { exit 1 }
    }'
fi
"@

    Set-Content -Path $preCommitPath -Value $hookContent -Encoding UTF8 -NoNewline
    # Make executable on non-Windows platforms
    if (-not $IsWindows) {
        & chmod +x $preCommitPath 2>$null
    }
    Write-Success "Installed pre-commit hook"
}

# ---------------------------------------------------------------------------
# Create initial commit so worktrees can branch from it later
# ---------------------------------------------------------------------------
$hasCommits = git -C $ProjectDir rev-parse HEAD 2>$null
if ($LASTEXITCODE -ne 0) {
    Write-DotbotCommand "Creating initial commit..."
    git -C $ProjectDir add .bot/ 2>$null
    if (Test-Path (Join-Path $ProjectDir ".mcp.json")) {
        git -C $ProjectDir add .mcp.json 2>$null
    }
    git -C $ProjectDir commit --quiet -m "chore: initialize dotbot" 2>$null
    if ($LASTEXITCODE -eq 0) {
        Write-Success "Initial commit created"
    } else {
        # Unstage everything so leftover staged files don't contaminate future commits
        git -C $ProjectDir reset 2>$null
        Write-DotbotWarning "Initial commit failed -- files unstaged"
    }
}

# ---------------------------------------------------------------------------
# Show completion message
# ---------------------------------------------------------------------------
Write-DotbotBanner -Title "✓ Project Initialized!"
Write-DotbotSection -Title "WHAT'S INSTALLED"
Write-DotbotLabel -Label ".bot/systems/mcp/ " -Value "MCP server for task management"
Write-DotbotLabel -Label ".bot/systems/ui/ " -Value "Web UI server (default port 8686)"
Write-DotbotLabel -Label ".bot/systems/runtime/" -Value "Autonomous loop for Claude CLI"
Write-DotbotLabel -Label ".bot/recipes/ " -Value "Agents, skills, prompts"
if ($installedWorkflows.Count -gt 0 -or $resolvedOrder.Count -gt 0) {
    Write-BlankLine
    Write-DotbotSection -Title "INSTALLED"
    if ($installedWorkflows.Count -gt 0) {
        foreach ($wf in $installedWorkflows) {
            Write-DotbotLabel -Label "workflow " -Value "$wf"
        }
    }
    if ($activeWorkflow) {
        Write-DotbotLabel -Label "workflow " -Value "$activeWorkflow"
    }
    if ($installedStacks.Count -gt 0) {
        Write-DotbotLabel -Label "stacks " -Value "$($installedStacks -join ', ')"
    }
}

# ---------------------------------------------------------------------------
# Show workflow-specific dependency checks (from kickstart.preflight)
# ---------------------------------------------------------------------------
$settingsDefaultPath = Join-Path $BotDir "settings\settings.default.json"
if (Test-Path $settingsDefaultPath) {
    try {
        $finalSettings = Get-Content $settingsDefaultPath -Raw | ConvertFrom-Json
        $preflightChecks = @()
        if ($finalSettings.kickstart -and $finalSettings.kickstart.preflight) {
            $preflightChecks = @($finalSettings.kickstart.preflight)
        }
    } catch {
        $preflightChecks = @()
    }

    if ($preflightChecks.Count -gt 0) {
        Write-BlankLine
    Write-DotbotSection -Title "WORKFLOW DEPENDENCIES"

        $mcpListCache = $null
        $envLocalPath = Join-Path $ProjectDir ".env.local"
        $depWarningCount = 0

        foreach ($check in $preflightChecks) {
            $label = if ($check.message) { $check.message } else { $check.name }
            $hint  = $check.hint
            $passed = $false

            switch ($check.type) {
                'env_var' {
                    $varName = if ($check.var) { $check.var } else { $check.name }
                    $envValue = $null
                    if (Test-Path $envLocalPath) {
                        $envLines = Get-Content $envLocalPath -ErrorAction SilentlyContinue
                        foreach ($line in $envLines) {
                            if ($line -match "^\s*$([regex]::Escape($varName))\s*=\s*(.+)$") {
                                $envValue = $matches[1].Trim()
                            }
                        }
                    }
                    $passed = [bool]$envValue
                    if (-not $hint -and -not $passed) {
                        $hint = "Set $varName in .env.local"
                    }
                }
                'mcp_server' {
                    $mcpFound = $false
                    if (Test-Path $mcpJsonPath) {
                        try {
                            $mcpData = Get-Content $mcpJsonPath -Raw | ConvertFrom-Json
                            if ($mcpData.mcpServers -and $mcpData.mcpServers.PSObject.Properties.Name -contains $check.name) {
                                $mcpFound = $true
                            }
                        } catch { Write-DotbotCommand "Parse skipped: $_" }
                    }
                    if (-not $mcpFound) {
                        if ($null -eq $mcpListCache) {
                            try { $mcpListCache = & claude mcp list 2>&1 | Out-String }
                            catch { $mcpListCache = "" }
                        }
                        if ($mcpListCache -match "(?m)^$([regex]::Escape($check.name)):") {
                            $mcpFound = $true
                        }
                    }
                    $passed = $mcpFound
                    if (-not $hint -and -not $passed) {
                        $hint = "Register '$($check.name)' server in .mcp.json or via 'claude mcp add'"
                    }
                }
                'cli_tool' {
                    $passed = $null -ne (Get-Command $check.name -ErrorAction SilentlyContinue)
                    if (-not $hint -and -not $passed) {
                        $hint = "Install '$($check.name)' and ensure it is on PATH"
                    }
                }
            }

            if ($passed) {
                Write-Success $label
            } else {
                Write-DotbotWarning $label
                if ($hint) {
                    Write-DotbotCommand "$hint"
                }
                $depWarningCount++
            }
        }

        if ($depWarningCount -gt 0) {
            Write-BlankLine
            Write-DotbotCommand ".env.local is a project-level file (in the same folder as .bot/) for"
            Write-DotbotCommand "secrets and credentials. It is gitignored. Create it and add the missing"
            Write-DotbotCommand "variables as KEY=value pairs, one per line."
        }
    }
}

Write-BlankLine
Write-DotbotSection -Title "GET STARTED"
Write-DotbotCommand ".bot\go.ps1"
Write-BlankLine
Write-DotbotSection -Title "NEXT STEPS"
Write-DotbotLabel -Label "1. Start the UI: " -Value ".bot\go.ps1"
Write-DotbotLabel -Label "2. View docs: " -Value ".bot\README.md"
Write-BlankLine