workflows/default/systems/ui/modules/ProductAPI.psm1
|
<# .SYNOPSIS Product document management API module .DESCRIPTION Provides product document listing, retrieval, kickstart (Claude-driven doc creation), and roadmap planning functionality. Extracted from server.ps1 for modularity. #> $script:Config = @{ BotRoot = $null ControlDir = $null } $script:McpListCache = $null function Initialize-ProductAPI { param( [Parameter(Mandatory)] [string]$BotRoot, [Parameter(Mandatory)] [string]$ControlDir ) $script:Config.BotRoot = $BotRoot $script:Config.ControlDir = $ControlDir } function Resolve-ProductDocumentInfo { param( [Parameter(Mandatory)] [System.IO.FileInfo]$File, [Parameter(Mandatory)] [string]$ProductDir ) $relativePath = [System.IO.Path]::GetRelativePath($ProductDir, $File.FullName) -replace '\\', '/' $isMd = $File.Extension -eq '.md' $isJson = $File.Extension -eq '.json' # Only strip .md; JSON/binary keep extension to avoid name collisions (foo.md vs foo.json) $name = if ($isMd) { $relativePath -replace '\.md$', '' } else { $relativePath } $segments = @($name -split '/') return [PSCustomObject]@{ Name = $name Filename = $relativePath Depth = [Math]::Max(0, $segments.Count - 1) BaseName = $File.BaseName Type = if ($isMd) { 'md' } elseif ($isJson) { 'json' } else { 'binary' } Size = $File.Length } } function Resolve-ProductDocumentPath { param( [Parameter(Mandatory)] [string]$Name, [Parameter(Mandatory)] [string]$ProductDir ) $decodedName = [System.Web.HttpUtility]::UrlDecode($Name) if ([string]::IsNullOrWhiteSpace($decodedName)) { return $null } $normalizedName = ($decodedName.Trim() -replace '\\', '/').TrimStart('/') # Determine extension search order based on the requested name. # If the request explicitly ends with .json, resolve only .json (honor the caller's intent). # If it ends with .md, strip the extension and try .md first then .json. # Otherwise, try .md first then .json (default priority). $explicitJson = $false if ($normalizedName.EndsWith('.md', [System.StringComparison]::OrdinalIgnoreCase)) { $normalizedName = $normalizedName.Substring(0, $normalizedName.Length - 3) } elseif ($normalizedName.EndsWith('.json', [System.StringComparison]::OrdinalIgnoreCase)) { $explicitJson = $true # Keep normalizedName as-is (includes .json) since JSON names retain their extension } if ([string]::IsNullOrWhiteSpace($normalizedName)) { return $null } $relativePath = ($normalizedName -split '/') -join [System.IO.Path]::DirectorySeparatorChar try { $productDirFull = [System.IO.Path]::GetFullPath($ProductDir) } catch { return $null } $productPrefix = if ($productDirFull.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $productDirFull } else { "$productDirFull$([System.IO.Path]::DirectorySeparatorChar)" } if ($explicitJson) { # Explicit .json request — resolve directly without extension loop $candidatePath = Join-Path $ProductDir $relativePath try { $candidateFull = [System.IO.Path]::GetFullPath($candidatePath) } catch { return $null } if ($candidateFull -notlike "$productPrefix*") { return $null } if (Test-Path -LiteralPath $candidateFull) { return @{ Name = $normalizedName FullPath = $candidateFull } } return @{ Name = $normalizedName FullPath = $candidateFull } } # Try extensions in order: .md then .json foreach ($ext in @('.md', '.json')) { $candidatePath = Join-Path $ProductDir "$relativePath$ext" try { $candidateFull = [System.IO.Path]::GetFullPath($candidatePath) } catch { continue } if ($candidateFull -notlike "$productPrefix*") { continue } if (Test-Path -LiteralPath $candidateFull) { # For .json matches, include extension in the returned name $returnName = if ($ext -eq '.json') { "$normalizedName.json" } else { $normalizedName } return @{ Name = $returnName FullPath = $candidateFull } } } # Fallback: return .md path so Get-ProductDocument can return a 404 $fallbackPath = Join-Path $ProductDir "$relativePath.md" try { $fallbackFull = [System.IO.Path]::GetFullPath($fallbackPath) } catch { return $null } if ($fallbackFull -notlike "$productPrefix*") { return $null } return @{ Name = $normalizedName FullPath = $fallbackFull } } function Get-ProductList { $botRoot = $script:Config.BotRoot $productDir = Join-Path $botRoot "workspace\product" $docs = @() if (Test-Path $productDir) { $allFiles = @(Get-ChildItem -Path $productDir -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne '.gitkeep' }) # Define priority order for product files $priorityOrder = [System.Collections.Generic.List[string]]@( 'mission', 'entity-model', 'tech-stack', 'roadmap', 'roadmap-overview' ) # Separate files into priority root docs, other root docs, and nested docs $priorityFiles = [System.Collections.ArrayList]@() $rootFiles = [System.Collections.ArrayList]@() $nestedFiles = [System.Collections.ArrayList]@() foreach ($file in $allFiles) { if ($null -eq $file) { continue } $doc = Resolve-ProductDocumentInfo -File $file -ProductDir $productDir $priorityIndex = if ($doc.Depth -eq 0 -and $doc.Type -eq 'md') { $priorityOrder.IndexOf($file.BaseName) } else { -1 } if ($priorityIndex -ge 0) { [void]$priorityFiles.Add([PSCustomObject]@{ Doc = $doc Priority = $priorityIndex }) } elseif ($doc.Depth -eq 0) { [void]$rootFiles.Add($doc) } else { [void]$nestedFiles.Add($doc) } } if ($priorityFiles.Count -gt 0) { $priorityFiles = @($priorityFiles | Sort-Object -Property Priority) } if ($rootFiles.Count -gt 0) { $rootFiles = @($rootFiles | Sort-Object -Property Filename) } if ($nestedFiles.Count -gt 0) { $nestedFiles = @($nestedFiles | Sort-Object -Property Filename) } foreach ($pf in $priorityFiles) { if ($null -eq $pf) { continue } $docs += @{ name = $pf.Doc.Name filename = $pf.Doc.Filename depth = $pf.Doc.Depth type = $pf.Doc.Type size = $pf.Doc.Size } } foreach ($file in $rootFiles) { if ($null -eq $file) { continue } $docs += @{ name = $file.Name filename = $file.Filename depth = $file.Depth type = $file.Type size = $file.Size } } foreach ($file in $nestedFiles) { if ($null -eq $file) { continue } $docs += @{ name = $file.Name filename = $file.Filename depth = $file.Depth type = $file.Type size = $file.Size } } } return @{ docs = $docs } } function Get-ProductDocument { param( [Parameter(Mandatory)] [string]$Name ) $botRoot = $script:Config.BotRoot $productDir = Join-Path $botRoot "workspace\product" $resolvedDoc = Resolve-ProductDocumentPath -Name $Name -ProductDir $productDir if ($resolvedDoc -and (Test-Path -LiteralPath $resolvedDoc.FullPath)) { $docContent = Get-Content -LiteralPath $resolvedDoc.FullPath -Raw return @{ success = $true name = $resolvedDoc.Name content = $docContent } } else { return @{ _statusCode = 404 success = $false error = "Document not found: $Name" } } } function Get-PreflightResults { $botRoot = $script:Config.BotRoot $projectRoot = Split-Path -Parent $botRoot # Load manifest helpers . "$botRoot\systems\runtime\modules\workflow-manifest.ps1" # Try manifest first $preflightChecks = @() $manifest = Get-ActiveWorkflowManifest -BotRoot $botRoot if ($manifest -and $manifest.requires) { $preflightChecks = @(Convert-ManifestRequiresToPreflightChecks -Requires $manifest.requires) } # Fallback to settings.kickstart.preflight for legacy installs if ($preflightChecks.Count -eq 0) { $settingsFile = Join-Path $botRoot "settings\settings.default.json" if (Test-Path $settingsFile) { try { $settingsData = Get-Content $settingsFile -Raw | ConvertFrom-Json if ($settingsData.kickstart -and $settingsData.kickstart.preflight) { $preflightChecks = @($settingsData.kickstart.preflight) } } catch { Write-BotLog -Level Debug -Message "Pre-flight settings parse error" -Exception $_ } } } if ($preflightChecks.Count -eq 0) { return @{ success = $true; checks = @() } } $results = @() $allPassed = $true foreach ($check in $preflightChecks) { if (-not $check -or -not $check.type) { continue } $passed = $false $hint = $check.hint if ($check.type -eq 'env_var') { $varName = if ($check.var) { $check.var } else { $check.name } $envLocalPath = Join-Path $projectRoot ".env.local" $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" } } elseif ($check.type -eq 'mcp_server') { $mcpFound = $false # 1) Check .mcp.json (fast path) $mcpJsonPath = Join-Path $projectRoot ".mcp.json" 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-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # 2) Fall back to CLI registry (claude mcp list) — cached at module scope if (-not $mcpFound) { if ($null -eq $script:McpListCache) { try { $script:McpListCache = & claude mcp list 2>&1 | Out-String } catch { $script:McpListCache = "" } } if ($script: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'" } } elseif ($check.type -eq '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 (-not $passed) { $allPassed = $false } $results += @{ type = $check.type name = $check.name passed = $passed message = $check.message hint = if (-not $passed -and $hint) { $hint } else { $null } } } return @{ success = $allPassed; checks = $results } } function Start-ProductKickstart { param( [Parameter(Mandatory)] [string]$UserPrompt, [array]$Files = @(), [bool]$NeedsInterview = $true, [bool]$AutoWorkflow = $true, [string[]]$SkipPhases = @() ) $botRoot = $script:Config.BotRoot $projectRoot = Split-Path -Parent $botRoot # Note: Preflight validation is handled by the GET /preflight endpoint. # The frontend checks preflight before calling POST, so we skip it here # to avoid blocking the HTTP thread with a duplicate `claude mcp list` call. # Create briefing directory $briefingDir = Join-Path $botRoot "workspace\product\briefing" if (-not (Test-Path $briefingDir)) { New-Item -Path $briefingDir -ItemType Directory -Force | Out-Null } # Decode and save files $savedFiles = @() foreach ($file in $Files) { if (-not $file -or -not $file.name -or -not $file.content) { continue } try { $decoded = [Convert]::FromBase64String($file.content) $safeName = $file.name -replace '[^\w\-\.]', '_' $filePath = Join-Path $briefingDir $safeName [System.IO.File]::WriteAllBytes($filePath, $decoded) $savedFiles += $filePath } catch { foreach ($savedFile in $savedFiles) { Remove-Item -LiteralPath $savedFile -Force -ErrorAction SilentlyContinue } return @{ _statusCode = 400 success = $false error = "Invalid base64 content for file '$($file.name)'" } } } # Launch kickstart as tracked process # Write prompt and launcher to .control/launchers/ (gitignored) to avoid # absolute paths in committed files triggering the privacy scan $launcherPath = Join-Path $botRoot "systems\runtime\launch-process.ps1" $launchersDir = Join-Path $script:Config.ControlDir "launchers" if (-not (Test-Path $launchersDir)) { New-Item -Path $launchersDir -ItemType Directory -Force | Out-Null } $promptFile = Join-Path $launchersDir "kickstart-prompt.txt" $UserPrompt | Set-Content -Path $promptFile -Encoding UTF8 -NoNewline $wrapperPath = Join-Path $launchersDir "kickstart-launcher.ps1" $interviewLine = if ($NeedsInterview) { " -NeedsInterview" } else { "" } $autoWorkflowLine = if ($AutoWorkflow) { " -AutoWorkflow" } else { "" } $skipLine = if ($SkipPhases.Count -gt 0) { " -SkipPhases '$($SkipPhases -join ',')'" } else { "" } @" `$prompt = Get-Content -LiteralPath '$promptFile' -Raw & '$launcherPath' -Type kickstart -Prompt `$prompt -Description 'Kickstart: project setup'$interviewLine$autoWorkflowLine$skipLine "@ | Set-Content -Path $wrapperPath -Encoding UTF8 $startParams = @{ ArgumentList = @("-NoProfile", "-File", $wrapperPath); PassThru = $true } if ($IsWindows) { $startParams.WindowStyle = 'Normal' } $proc = Start-Process pwsh @startParams # Find process_id by PID Start-Sleep -Milliseconds 500 $processesDir = Join-Path $script:Config.ControlDir "processes" $launchedProcId = $null $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending foreach ($pf in $procFiles) { try { $pData = Get-Content $pf.FullName -Raw | ConvertFrom-Json if ($pData.pid -eq $proc.Id) { $launchedProcId = $pData.id break } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } Write-Status "Product kickstart launched (PID: $($proc.Id))" -Type Info return @{ success = $true process_id = $launchedProcId message = "Kickstart initiated. Product documents, task groups, and task expansion will run in a tracked process." } } function Start-ProductAnalyse { param( [string]$UserPrompt = "", [ValidateSet('Opus', 'Sonnet', 'Haiku')] [string]$Model = "Sonnet" ) $botRoot = $script:Config.BotRoot # Analyse is now a conditional task in the default workflow. # Launch the standard kickstart pipeline — the condition system # will activate the "Analyse Project" task and skip "Product Documents". $launcherPath = Join-Path $botRoot "systems\runtime\launch-process.ps1" $launchersDir = Join-Path $script:Config.ControlDir "launchers" if (-not (Test-Path $launchersDir)) { New-Item -Path $launchersDir -ItemType Directory -Force | Out-Null } $promptFile = Join-Path $launchersDir "analyse-prompt.txt" $prompt = if ($UserPrompt) { $UserPrompt } else { "Analyse this existing codebase" } $prompt | Set-Content -Path $promptFile -Encoding UTF8 -NoNewline $wrapperPath = Join-Path $launchersDir "analyse-launcher.ps1" @" `$prompt = Get-Content -LiteralPath '$promptFile' -Raw & '$launcherPath' -Type kickstart -Prompt `$prompt -Description 'Analyse: existing project' -Model '$Model' "@ | Set-Content -Path $wrapperPath -Encoding UTF8 $startParams = @{ ArgumentList = @("-NoProfile", "-File", $wrapperPath); PassThru = $true } if ($IsWindows) { $startParams.WindowStyle = 'Normal' } $proc = Start-Process pwsh @startParams Write-Status "Product analyse launched as tracked process (PID: $($proc.Id))" -Type Info return @{ success = $true message = "Analyse initiated. Product documents will be generated from your existing codebase." } } function Start-RoadmapPlanning { $botRoot = $script:Config.BotRoot # Validate product docs exist $productDir = Join-Path $botRoot "workspace\product" $requiredDocs = @("mission.md", "tech-stack.md", "entity-model.md") $missingDocs = @() foreach ($doc in $requiredDocs) { $docPath = Join-Path $productDir $doc if (-not (Test-Path $docPath)) { $missingDocs += $doc } } if ($missingDocs.Count -gt 0) { return @{ _statusCode = 400 success = $false error = "Missing required product docs: $($missingDocs -join ', '). Run kickstart first." } } # Launch via process manager $launcherPath = Join-Path $botRoot "systems\runtime\launch-process.ps1" $launchArgs = @("-File", "`"$launcherPath`"", "-Type", "planning", "-Model", "Sonnet", "-Description", "`"Plan project roadmap`"") $startParams = @{ ArgumentList = $launchArgs } if ($IsWindows) { $startParams.WindowStyle = 'Normal' } Start-Process pwsh @startParams | Out-Null Write-Status "Roadmap planning launched as tracked process" -Type Info return @{ success = $true message = "Roadmap planning initiated via process manager." } } function Resolve-PhaseStatusFromOutputs { param( [Parameter(Mandatory)] [object]$Phase, [Parameter(Mandatory)] [string]$BotRoot ) $productDir = Join-Path $BotRoot "workspace\product" $phaseType = if ($Phase.type) { $Phase.type } else { "llm" } # If the phase has a condition, check it first — unmet means it can't have run if ($Phase.condition) { $cond = $Phase.condition if ($cond -match '^file_exists:(.+)$') { $condPath = Join-Path $BotRoot $Matches[1] if (-not (Test-Path $condPath)) { return "pending" } } } if ($phaseType -eq "interview") { $interviewPath = Join-Path $productDir "interview-summary.md" if (Test-Path $interviewPath) { return "completed" } return "pending" } if ($phaseType -eq "barrier") { # Barrier tasks are considered complete when their dependencies are complete # (resolved by the caller via process file tracking) return "pending" } # LLM, script, or task_gen phases: check required_outputs/outputs if ($Phase.required_outputs) { $allExist = $true foreach ($f in $Phase.required_outputs) { if (-not (Test-Path (Join-Path $productDir $f))) { $allExist = $false; break } } if ($allExist) { return "completed" } return "pending" } if ($Phase.required_outputs_dir) { $dirPath = Join-Path $BotRoot "workspace\$($Phase.required_outputs_dir)" $minCount = if ($Phase.min_output_count) { [int]$Phase.min_output_count } else { 1 } $fileCount = if (Test-Path $dirPath) { @(Get-ChildItem $dirPath -Filter "*.json" -File).Count } else { 0 } if ($fileCount -ge $minCount) { return "completed" } # Tasks may have moved through the pipeline (todo → done) if ($Phase.required_outputs_dir -match '^tasks/') { $taskBaseDir = Join-Path $BotRoot "workspace\tasks" $totalTasks = 0 foreach ($td in @("todo","analysing","analysed","in-progress","done","skipped","cancelled")) { $tdPath = Join-Path $taskBaseDir $td if (Test-Path $tdPath) { $totalTasks += @(Get-ChildItem $tdPath -Filter "*.json" -File -ErrorAction SilentlyContinue).Count } } if ($totalTasks -ge $minCount) { return "completed" } } return "pending" } # Check outputs (manifest-style field name) if ($Phase.outputs) { $allExist = $true foreach ($f in $Phase.outputs) { # Workflow tasks use workspace-relative paths (e.g. workspace/reports/...) # Legacy kickstart phases use product-dir-relative paths (e.g. mission.md) $basePath = if ($f -match '^workspace[/\\]') { $BotRoot } else { $productDir } $fullPath = Join-Path $basePath $f if (-not (Test-Path $fullPath)) { $allExist = $false; break } } if ($allExist) { return "completed" } return "pending" } # Check outputs_dir (manifest-style field name) if ($Phase.outputs_dir) { $dirPath = Join-Path $BotRoot "workspace\$($Phase.outputs_dir)" $minCount = if ($Phase.min_output_count) { [int]$Phase.min_output_count } else { 1 } $fileCount = if (Test-Path $dirPath) { @(Get-ChildItem $dirPath -Filter "*.json" -File).Count } else { 0 } if ($fileCount -ge $minCount) { return "completed" } if ($Phase.outputs_dir -match '^tasks/') { $taskBaseDir = Join-Path $BotRoot "workspace\tasks" $totalTasks = 0 # Canonical task-pipeline status dirs. Keep in sync with the list # in the script-phase probe below and with workflow-manifest.ps1 # (Clear-WorkspaceTaskDirs) which owns the authoritative enumeration. foreach ($td in @('todo','analysing','needs-input','analysed','in-progress','done','skipped','cancelled','split')) { $tdPath = Join-Path $taskBaseDir $td if (Test-Path $tdPath) { $totalTasks += @(Get-ChildItem $tdPath -Filter "*.json" -File -ErrorAction SilentlyContinue).Count } } if ($totalTasks -ge $minCount) { return "completed" } } return "pending" } # No required_outputs defined — assume completed if phase script exists if ($Phase.script) { # Script-only phases: check commit paths for evidence $commitPaths = if ($Phase.commit) { $Phase.commit.paths } else { $Phase.commit_paths } if ($commitPaths) { foreach ($cp in $commitPaths) { $cpPath = Join-Path $BotRoot $cp if (-not (Test-Path $cpPath)) { continue } # Special-case: a commit path of `workspace/tasks/` (or `tasks/`) # means the phase generates task files into the pipeline dirs. # The top level of tasks/ has no files — only subdirs — so a # flat count always returns 0. Probe the pipeline dirs instead, # matching the semantics of the outputs_dir branch above. # Keep this list in sync with the outputs_dir fallback above # and with workflow-manifest.ps1 (Clear-WorkspaceTaskDirs) which # owns the authoritative enumeration — tasks can legitimately # sit in any of these statuses (incl. needs-input / split) # after generation. $normalized = ($cp -replace '\\','/').Trim('/') if ($normalized -match '^(workspace/)?tasks/?$') { $taskDirs = @('todo','analysing','needs-input','analysed','in-progress','done','skipped','cancelled','split') $matched = $false foreach ($td in $taskDirs) { $tdPath = Join-Path $cpPath $td if (Test-Path $tdPath) { # Short-circuit: stop at the first match to avoid # enumerating entire pipeline dirs on every UI poll. $firstTaskFile = Get-ChildItem $tdPath -Filter '*.json' -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $firstTaskFile) { $matched = $true; break } } } if ($matched) { return "completed" } continue } # General case: check for any real file under the commit path, # ignoring .gitkeep sentinels. Recurse so a commit path that # points at a directory-of-directories still registers real # committed artifacts underneath, but stop at the first match # to avoid materializing the full file list on every UI poll. $firstFile = Get-ChildItem $cpPath -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne '.gitkeep' } | Select-Object -First 1 if ($firstFile) { return "completed" } } } } return "pending" } function Resolve-TaskGenChildTasks { param( [Parameter(Mandatory)] [array]$Phases, [Parameter(Mandatory)] [string]$BotRoot, [string]$WorkflowName ) $hasTaskGen = $false foreach ($p in $Phases) { if ($p.type -eq 'task_gen') { $hasTaskGen = $true; break } } if (-not $hasTaskGen) { return $Phases } # Collect all tasks from every status directory $taskBaseDir = Join-Path $BotRoot "workspace\tasks" $statusDirs = @('todo', 'analysing', 'needs-input', 'analysed', 'in-progress', 'done', 'skipped', 'cancelled') $statusMap = @{ 'todo' = 'todo'; 'analysing' = 'analysing'; 'needs-input' = 'needs-input' 'analysed' = 'analysed'; 'in-progress' = 'in-progress'; 'done' = 'done' 'skipped' = 'skipped'; 'cancelled' = 'cancelled' } $allTasks = [System.Collections.ArrayList]::new() foreach ($sd in $statusDirs) { $dir = Join-Path $taskBaseDir $sd if (-not (Test-Path $dir)) { continue } foreach ($f in @(Get-ChildItem -Path $dir -Filter "*.json" -File -ErrorAction SilentlyContinue)) { try { $tc = Get-Content $f.FullName -Raw -ErrorAction Stop | ConvertFrom-Json # Filter by workflow name if available if ($WorkflowName -and $tc.workflow -and $tc.workflow -ne $WorkflowName) { continue } [void]$allTasks.Add(@{ id = $tc.id name = $tc.name status = $statusMap[$sd] }) } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } } # Sort: in-progress first, then analysing, then done, then todo, then rest $sortOrder = @{ 'in-progress' = 0; 'analysing' = 1; 'needs-input' = 2; 'analysed' = 3; 'todo' = 4; 'done' = 5; 'skipped' = 6; 'cancelled' = 7 } $sorted = @($allTasks | Sort-Object { $sortOrder[$_.status] }, { $_.name }) # Compute summary counts $counts = @{ todo = 0; analysing = 0; needs_input = 0; analysed = 0; in_progress = 0; done = 0; skipped = 0; total = 0 } foreach ($t in $sorted) { $counts['total']++ switch ($t.status) { 'todo' { $counts['todo']++ } 'analysing' { $counts['analysing']++ } 'needs-input' { $counts['needs_input']++ } 'analysed' { $counts['analysed']++ } 'in-progress' { $counts['in_progress']++ } 'done' { $counts['done']++ } 'skipped' { $counts['skipped']++ } } } # Attach child data to task_gen phases $enriched = @() foreach ($p in $Phases) { if ($p.type -eq 'task_gen' -and $counts['total'] -gt 0) { $p['child_tasks'] = $sorted $p['child_counts'] = $counts # Synthetic status: 'active' if generation done but tasks remain incomplete if ($p.status -eq 'completed' -and $counts['done'] -lt $counts['total']) { $p['status'] = 'active' } } $enriched += $p } return $enriched } function Get-KickstartStatus { $botRoot = $script:Config.BotRoot $controlDir = $script:Config.ControlDir # Load manifest helpers . "$botRoot\systems\runtime\modules\workflow-manifest.ps1" # Try manifest first (tasks array) $kickstartPhases = @() $workflowName = $null $manifest = Get-ActiveWorkflowManifest -BotRoot $botRoot if ($manifest -and $manifest.tasks -and $manifest.tasks.Count -gt 0) { Ensure-ManifestTaskIds -Tasks $manifest.tasks $kickstartPhases = @($manifest.tasks) $workflowName = $manifest.name } # Fallback to settings.kickstart.phases for legacy installs if ($kickstartPhases.Count -eq 0) { $settingsFile = Join-Path $botRoot "settings\settings.default.json" if (Test-Path $settingsFile) { try { $settingsData = Get-Content $settingsFile -Raw | ConvertFrom-Json if ($settingsData.kickstart -and $settingsData.kickstart.phases) { $kickstartPhases = @($settingsData.kickstart.phases) } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } } if ($kickstartPhases.Count -eq 0) { return @{ status = "not-started"; process_id = $null; phases = @(); resume_from = $null; workflow_name = $workflowName } } # Find most recent kickstart process $processesDir = Join-Path $controlDir "processes" $latestProc = $null if (Test-Path $processesDir) { $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending foreach ($pf in $procFiles) { try { $pData = Get-Content $pf.FullName -Raw | ConvertFrom-Json # Accept both 'kickstart' (UI-launched kickstart flow) and # 'task-runner' processes whose workflow_name matches the # active manifest (generic workflow-runner launching a # kickstart-style workflow). Either one is the authoritative # record for this kickstart run. $isKickstart = $pData.type -eq 'kickstart' $isWorkflowRunner = $pData.type -eq 'task-runner' -and $workflowName -and $pData.workflow_name -eq $workflowName if ($isKickstart -or $isWorkflowRunner) { $latestProc = $pData break } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } } if (-not $latestProc) { # No process found — infer from filesystem $phases = @($kickstartPhases | ForEach-Object { $inferredStatus = Resolve-PhaseStatusFromOutputs -Phase $_ -BotRoot $botRoot @{ id = $_.id; name = $_.name type = if ($_.type) { $_.type } else { "llm" } status = $inferredStatus } }) # Sequential consistency: if a later phase completed, earlier ones must have too $lastCompletedIdx = -1 for ($i = 0; $i -lt $phases.Count; $i++) { if ($phases[$i].status -eq 'completed') { $lastCompletedIdx = $i } } for ($i = 0; $i -lt $lastCompletedIdx; $i++) { if ($phases[$i].status -in @('pending', 'incomplete')) { $phases[$i].status = 'completed' } } $completedCount = @($phases | Where-Object { $_.status -eq 'completed' }).Count $overallStatus = if ($completedCount -eq 0) { "not-started" } elseif ($completedCount -eq $phases.Count) { "completed" } else { "incomplete" } $resumeFrom = ($phases | Where-Object { $_.status -in @('pending', 'failed', 'incomplete') } | Select-Object -First 1).id # Enrich task_gen phases with child task data $phases = @(Resolve-TaskGenChildTasks -Phases $phases -BotRoot $botRoot -WorkflowName $workflowName) return @{ status = $overallStatus process_id = $null phases = $phases resume_from = $resumeFrom workflow_name = $workflowName } } # Process found — merge settings (canonical) with process-file status $procPhaseMap = @{} if ($latestProc.phases -and $latestProc.phases.Count -gt 0) { foreach ($pp in $latestProc.phases) { if ($pp.id) { $procPhaseMap[$pp.id] = $pp } } } $phases = @($kickstartPhases | ForEach-Object { $phaseId = $_.id $phaseName = $_.name $phaseType = if ($_.type) { $_.type } else { "llm" } $procEntry = $procPhaseMap[$phaseId] if ($procEntry -and $procEntry.status -eq 'skipped') { # Skipped = completed in a prior run — show as completed @{ id = $phaseId; name = $phaseName; type = $phaseType; status = 'completed' } } elseif ($procEntry -and $procEntry.status -and $procEntry.status -ne 'pending') { # Process file has real status (running, completed, failed, etc.) — use it @{ id = $phaseId; name = $phaseName; type = $phaseType; status = $procEntry.status } } else { # Not in process file or still pending — infer from filesystem $inferredStatus = Resolve-PhaseStatusFromOutputs -Phase $_ -BotRoot $botRoot @{ id = $phaseId; name = $phaseName; type = $phaseType; status = $inferredStatus } } }) # Preserve synthetic interview phase (in process file but not in settings) if ($procPhaseMap.ContainsKey('interview') -and -not ($kickstartPhases | Where-Object { $_.id -eq 'interview' })) { $iv = $procPhaseMap['interview'] $phases = @(@{ id = 'interview'; name = $iv.name; type = 'interview'; status = $iv.status }) + $phases } # Sequential consistency: if a later phase completed, earlier ones must have too $lastCompletedIdx = -1 for ($i = 0; $i -lt $phases.Count; $i++) { if ($phases[$i].status -eq 'completed') { $lastCompletedIdx = $i } } for ($i = 0; $i -lt $lastCompletedIdx; $i++) { if ($phases[$i].status -in @('pending', 'incomplete')) { $phases[$i].status = 'completed' } } # Compute overall status $completedCount = @($phases | Where-Object { $_.status -eq 'completed' }).Count $skippedCount = @($phases | Where-Object { $_.status -eq 'skipped' }).Count $runningCount = @($phases | Where-Object { $_.status -eq 'running' }).Count $failedCount = @($phases | Where-Object { $_.status -eq 'failed' }).Count $overallStatus = if ($runningCount -gt 0) { "running" } elseif ($latestProc.status -eq 'running') { "running" } elseif (($completedCount + $skippedCount) -eq $phases.Count) { "completed" } elseif ($failedCount -gt 0 -or $completedCount -gt 0) { "incomplete" } else { "not-started" } $resumeFrom = ($phases | Where-Object { $_.status -in @('pending', 'failed', 'incomplete') } | Select-Object -First 1).id # Enrich task_gen phases with child task data $phases = @(Resolve-TaskGenChildTasks -Phases $phases -BotRoot $botRoot -WorkflowName $workflowName) return @{ status = $overallStatus process_id = $latestProc.id phases = $phases resume_from = $resumeFrom workflow_name = $workflowName } } function Resume-ProductKickstart { $botRoot = $script:Config.BotRoot # Get current status $status = Get-KickstartStatus if ($status.status -eq 'completed') { return @{ _statusCode = 400; success = $false; error = "Kickstart already completed — nothing to resume" } } if ($status.status -eq 'running') { return @{ _statusCode = 400; success = $false; error = "Kickstart is currently running" } } if (-not $status.resume_from) { return @{ _statusCode = 400; success = $false; error = "No phase to resume from" } } # Read original prompt (fall back to mission.md if prompt file is missing) $launchersDir = Join-Path $script:Config.ControlDir "launchers" if (-not (Test-Path $launchersDir)) { New-Item -Path $launchersDir -ItemType Directory -Force | Out-Null } $promptFile = Join-Path $launchersDir "kickstart-prompt.txt" if (-not (Test-Path $promptFile)) { $missionFile = Join-Path $botRoot "workspace\product\mission.md" if (Test-Path $missionFile) { $missionContent = Get-Content -LiteralPath $missionFile -Raw $missionContent | Set-Content -Path $promptFile -Encoding UTF8 -NoNewline } else { return @{ _statusCode = 400; success = $false; error = "Cannot resume — no saved prompt or mission document found. Please start a new kickstart." } } } # Preflight: verify prompt file is readable before spawning async wrapper try { $null = Get-Content -LiteralPath $promptFile -Raw -ErrorAction Stop } catch { return @{ _statusCode = 400; success = $false; error = "Cannot resume — saved prompt is not readable." } } # Launch resumed kickstart $launcherPath = Join-Path $botRoot "systems\runtime\launch-process.ps1" $resumePhase = $status.resume_from $wrapperPath = Join-Path $launchersDir "kickstart-resume-launcher.ps1" @" `$prompt = Get-Content -LiteralPath '$promptFile' -Raw & '$launcherPath' -Type kickstart -Prompt `$prompt -Description 'Kickstart: resume from $resumePhase' -AutoWorkflow -FromPhase '$resumePhase' "@ | Set-Content -Path $wrapperPath -Encoding UTF8 $startParams = @{ ArgumentList = @("-NoProfile", "-File", $wrapperPath); PassThru = $true } if ($IsWindows) { $startParams.WindowStyle = 'Normal' } $proc = Start-Process pwsh @startParams # Find process_id by PID Start-Sleep -Milliseconds 500 $processesDir = Join-Path $script:Config.ControlDir "processes" $launchedProcId = $null $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending foreach ($pf in $procFiles) { try { $pData = Get-Content $pf.FullName -Raw | ConvertFrom-Json if ($pData.pid -eq $proc.Id) { $launchedProcId = $pData.id break } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } Write-Status "Kickstart resumed from phase '$resumePhase' (PID: $($proc.Id))" -Type Info return @{ success = $true process_id = $launchedProcId resume_from = $resumePhase message = "Kickstart resumed from phase '$resumePhase'" } } Export-ModuleMember -Function @( 'Initialize-ProductAPI', 'Get-ProductList', 'Get-ProductDocument', 'Get-PreflightResults', 'Start-ProductKickstart', 'Start-ProductAnalyse', 'Start-RoadmapPlanning', 'Get-KickstartStatus', 'Resume-ProductKickstart' ) |