workflows/default/systems/runtime/modules/ProcessTypes/Invoke-KickstartProcess.ps1
|
<# .SYNOPSIS Kickstart process type: manifest-driven multi-phase product setup pipeline. .DESCRIPTION Runs a workflow.yaml-driven pipeline of phases (interview, llm, task-runner, script, barrier) with question detection, git checkpoints, and YAML front matter. Extracted from launch-process.ps1 as part of v4 Phase 03 (#92). #> param( [Parameter(Mandatory)] [hashtable]$Context ) $Type = $Context.Type $botRoot = $Context.BotRoot $procId = $Context.ProcId $processData = $Context.ProcessData $claudeModelName = $Context.ModelName $claudeSessionId = $Context.SessionId $Prompt = $Context.Prompt $Description = $Context.Description $ShowDebug = $Context.ShowDebug $ShowVerbose = $Context.ShowVerbose $projectRoot = $Context.ProjectRoot $controlDir = $Context.ControlDir $settings = $Context.Settings $Model = $Context.Model $NeedsInterview = $Context.NeedsInterview $FromPhase = $Context.FromPhase $skipPhaseIds = $Context.SkipPhaseIds $permissionMode = $Context.PermissionMode if (-not $Description) { $Description = "Kickstart project setup" } $processData.status = 'running' $processData.workflow = "kickstart-pipeline" $processData.description = $Description $processData.heartbeat_status = $Description Write-ProcessFile -Id $procId -Data $processData Write-ProcessActivity -Id $procId -ActivityType "text" -Message "$Description started" $productDir = Join-Path $botRoot "workspace\product" # Ensure repo has at least one commit (required for worktrees and phase commits) $hasCommits = git -C $projectRoot rev-parse --verify HEAD 2>$null if ($LASTEXITCODE -ne 0) { Write-Status "Creating initial commit..." -Type Process git -C $projectRoot add .bot/ 2>$null git -C $projectRoot commit -m "chore: initialize dotbot" --allow-empty 2>$null Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Created initial git commit (repo had no commits)" } try { # ===== Kickstart task pipeline (manifest-driven) ===== # Load manifest helpers . (Join-Path $botRoot "systems\runtime\modules\workflow-manifest.ps1") # Load post-script runner (shared with Invoke-WorkflowProcess) . (Join-Path $botRoot "systems\runtime\modules\post-script-runner.ps1") $kickstartPhases = @() $activeWorkflowDir = $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) # Capture the workflow install dir so script phases can resolve workflow-scoped templates $wfInstallRoot = Join-Path $botRoot "workflows" $firstWf = Get-ChildItem $wfInstallRoot -Directory -ErrorAction SilentlyContinue | Select-Object -First 1 if ($firstWf) { $activeWorkflowDir = $firstWf.FullName } } # Fallback to settings.kickstart.phases for legacy installs if ($kickstartPhases.Count -eq 0 -and $settings.kickstart -and $settings.kickstart.phases) { $kickstartPhases = @($settings.kickstart.phases) } if (-not $kickstartPhases -or $kickstartPhases.Count -eq 0) { throw "No workflow tasks found — ensure a workflow.yaml exists or settings.kickstart.phases is configured" } # ===== Build phase tracking array from config ===== $hasInterviewPhase = $kickstartPhases | Where-Object { $_.type -eq 'interview' } if ($NeedsInterview -and -not $hasInterviewPhase) { # Prepend a synthetic interview phase for tracking $processData.phases = @(@{ id = "interview"; name = "Interview"; type = "interview" status = "pending"; started_at = $null; completed_at = $null; error = $null }) } else { $processData.phases = @() } # Append all config-driven phases $processData.phases += @($kickstartPhases | ForEach-Object { @{ id = $_.id; name = $_.name type = if ($_.type) { $_.type } else { "llm" } status = "pending"; started_at = $null; completed_at = $null; error = $null } }) Write-ProcessFile -Id $procId -Data $processData # ===== Validate FromPhase ===== $fromPhaseActive = $false if ($FromPhase) { $validPhaseIds = @($processData.phases | ForEach-Object { $_.id }) if ($FromPhase -notin $validPhaseIds) { Write-Status "Unknown phase '$FromPhase' — running all phases" -Type Warn $FromPhase = $null } else { $fromPhaseActive = $true } } # ===== Phase 0: Interview (backward compat for profiles without interview-type phase) ===== if ($NeedsInterview -and -not $hasInterviewPhase) { $interviewPhaseIdx = @($processData.phases | ForEach-Object { $_.id }).IndexOf('interview') if ($fromPhaseActive -and $FromPhase -ne 'interview') { $processData.phases[$interviewPhaseIdx].status = 'skipped' $processData.phases[$interviewPhaseIdx].completed_at = 'prior-run' Write-ProcessFile -Id $procId -Data $processData } else { if ($fromPhaseActive) { $fromPhaseActive = $false } $processData.phases[$interviewPhaseIdx].status = 'running' $processData.phases[$interviewPhaseIdx].started_at = (Get-Date).ToUniversalTime().ToString("o") Write-ProcessFile -Id $procId -Data $processData $processData.heartbeat_status = "Phase 0: Interviewing for requirements" Write-ProcessFile -Id $procId -Data $processData Write-ProcessActivity -Id $procId -ActivityType "init" -Message "Phase 0 — interviewing for requirements..." Write-Header "Phase 0: Interview" Invoke-InterviewLoop -ProcessId $procId -ProcessData $processData ` -BotRoot $botRoot -ProductDir $productDir -UserPrompt $Prompt ` -ShowDebugJson:$ShowDebug -ShowVerboseOutput:$ShowVerbose ` -PermissionMode $permissionMode $processData.phases[$interviewPhaseIdx].status = 'completed' $processData.phases[$interviewPhaseIdx].completed_at = (Get-Date).ToUniversalTime().ToString("o") Write-ProcessFile -Id $procId -Data $processData } } # Build briefing context once (shared across LLM phases) $briefingDir = Join-Path $productDir "briefing" $fileRefs = "" if (Test-Path $briefingDir) { $briefingFiles = Get-ChildItem -Path $briefingDir -File if ($briefingFiles.Count -gt 0) { $fileRefs = "`n`nBriefing files have been saved to the briefing/ directory. Read and use these for context:`n" foreach ($bf in $briefingFiles) { $fileRefs += "- $($bf.FullName)`n" } } } # Build interview context once (shared across LLM phases) $interviewContext = "" $interviewSummaryPath = Join-Path $productDir "interview-summary.md" if (Test-Path $interviewSummaryPath) { $interviewContext = @" ## Interview Summary An interview-summary.md file exists in .bot/workspace/product/ containing the user's clarified requirements with both verbatim answers and expanded interpretation. **Read this file** and use it to guide your decisions — it reflects the user's confirmed preferences for platform, architecture, technology, domain model, and other key directions. "@ } $phaseNum = 1 foreach ($phase in $kickstartPhases) { $phaseName = $phase.name $trackIdx = @($processData.phases | ForEach-Object { $_.id }).IndexOf($phase.id) # --- FromPhase skip logic --- if ($fromPhaseActive -and $phase.id -ne $FromPhase) { if ($trackIdx -ge 0) { $processData.phases[$trackIdx].status = 'skipped' $processData.phases[$trackIdx].completed_at = 'prior-run' Write-ProcessFile -Id $procId -Data $processData } Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Skipping phase $phaseNum ($phaseName): before resume point" Write-Status "Skipping phase $phaseNum ($phaseName) — before resume point" -Type Info $phaseNum++; continue } if ($fromPhaseActive) { $fromPhaseActive = $false } # --- Condition check (gitignore-style path patterns) --- if ($phase.condition) { if (-not (Test-ManifestCondition -ProjectRoot $projectRoot -Condition $phase.condition)) { if ($trackIdx -ge 0) { $processData.phases[$trackIdx].status = 'skipped' $processData.phases[$trackIdx].completed_at = (Get-Date).ToUniversalTime().ToString("o") Write-ProcessFile -Id $procId -Data $processData } Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Skipping phase $phaseNum ($phaseName): condition not met ($($phase.condition))" Write-Status "Skipping phase $phaseNum ($phaseName) — condition not met" -Type Info $phaseNum++; continue } } # --- User-requested skip --- if ($phase.id -in $skipPhaseIds) { if ($trackIdx -ge 0) { $processData.phases[$trackIdx].status = 'skipped' $processData.phases[$trackIdx].completed_at = (Get-Date).ToUniversalTime().ToString("o") Write-ProcessFile -Id $procId -Data $processData } Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Skipping phase $phaseNum ($phaseName): user opted out" Write-Status "Skipping phase $phaseNum ($phaseName) — user opted out" -Type Info $phaseNum++; continue } # Determine phase type $phaseType = if ($phase.type) { $phase.type } else { "llm" } # Mark phase as running if ($trackIdx -ge 0) { $processData.phases[$trackIdx].status = 'running' $processData.phases[$trackIdx].started_at = (Get-Date).ToUniversalTime().ToString("o") } $processData.heartbeat_status = "Phase ${phaseNum}: $phaseName" Write-ProcessFile -Id $procId -Data $processData Write-ProcessActivity -Id $procId -ActivityType "init" -Message "Phase $phaseNum — $($phaseName.ToLower())..." Write-Header "Phase ${phaseNum}: $phaseName" if ($phaseType -eq "barrier") { # --- Barrier phase: no-op, marks dependencies as resolved --- Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Barrier phase $phaseNum ($phaseName) complete" Write-Status "Barrier phase $phaseNum ($phaseName) — dependencies resolved" -Type Complete } elseif ($phaseType -eq "task-runner") { # --- Task Runner phase: launch concurrent worker slots --- $wfConcurrency = 1 if ($settings.scoring -and $settings.scoring.max_concurrent_scores) { $wfConcurrency = [int]$settings.scoring.max_concurrent_scores } elseif ($settings.execution -and $settings.execution.max_concurrent) { $wfConcurrency = [int]$settings.execution.max_concurrent } if ($wfConcurrency -lt 1) { $wfConcurrency = 1 } $launchScript = Join-Path $botRoot "systems\runtime\launch-process.ps1" $wfFilter = if ($settings.profile) { $settings.profile } else { "" } Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Launching $wfConcurrency workflow worker(s)$(if ($wfFilter) { " (workflow: $wfFilter)" })" Write-Status "Launching $wfConcurrency workflow worker(s) for phase $phaseNum ($phaseName)" -Type Process $slotLogDir = Join-Path $controlDir "slot-logs" if (-not (Test-Path $slotLogDir)) { New-Item -Path $slotLogDir -ItemType Directory -Force | Out-Null } $childProcs = @() for ($s = 0; $s -lt $wfConcurrency; $s++) { $slotArgs = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$launchScript`"", "-Type", "task-runner", "-Slot", "$s", "-Continue", "-NoWait", "-Model", $Model ) if ($wfFilter) { $slotArgs += @("-Workflow", $wfFilter) } $stdoutLog = Join-Path $slotLogDir "slot-$s-stdout.log" $stderrLog = Join-Path $slotLogDir "slot-$s-stderr.log" $childProc = Start-Process -FilePath "pwsh" ` -ArgumentList $slotArgs ` -WorkingDirectory $projectRoot ` -RedirectStandardOutput $stdoutLog ` -RedirectStandardError $stderrLog ` -PassThru $childProcs += $childProc Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Workflow worker slot $s started (PID: $($childProc.Id))" Write-Status "Slot $s started (PID: $($childProc.Id))" -Type Info } # Poll for completion, relaying heartbeats and checking stop signal while ($true) { if (Test-ProcessStopSignal -Id $procId) { Write-Status "Stop signal — terminating workflow workers" -Type Error Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Stop signal: killing $($childProcs.Count) worker(s)" foreach ($cp in $childProcs) { try { if (-not $cp.HasExited) { Stop-Process -Id $cp.Id -Force -ErrorAction SilentlyContinue } } catch { Write-BotLog -Level Debug -Message "Cleanup: failed to stop process" -Exception $_ } } throw "Process stopped by user during workflow phase" } $wfRunning = @($childProcs | Where-Object { -not $_.HasExited }) if ($wfRunning.Count -eq 0) { break } $processData.last_heartbeat = (Get-Date).ToUniversalTime().ToString("o") $processData.heartbeat_status = "Workflow: $($wfRunning.Count)/$wfConcurrency workers active" Write-ProcessFile -Id $procId -Data $processData Start-Sleep -Seconds 5 } # Report results $wfSucceeded = @($childProcs | Where-Object { $_.ExitCode -eq 0 }).Count $wfFailed = $wfConcurrency - $wfSucceeded $wfMsg = "Workflow phase complete: $wfSucceeded/$wfConcurrency workers succeeded" if ($wfFailed -gt 0) { $wfMsg += " ($wfFailed failed)" } Write-ProcessActivity -Id $procId -ActivityType "text" -Message $wfMsg Write-Status $wfMsg -Type $(if ($wfFailed -gt 0) { 'Warn' } else { 'Complete' }) } elseif ($phaseType -eq "interview") { # --- Interview phase: run interview loop at this point in the pipeline --- if (-not $NeedsInterview) { if ($trackIdx -ge 0) { $processData.phases[$trackIdx].status = 'skipped' $processData.phases[$trackIdx].completed_at = (Get-Date).ToUniversalTime().ToString("o") Write-ProcessFile -Id $procId -Data $processData } Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Skipping interview phase $phaseNum ($phaseName): not requested" Write-Status "Skipping interview phase (not requested)" -Type Info $phaseNum++; continue } Invoke-InterviewLoop -ProcessId $procId -ProcessData $processData ` -BotRoot $botRoot -ProductDir $productDir -UserPrompt $Prompt ` -ShowDebugJson:$ShowDebug -ShowVerboseOutput:$ShowVerbose ` -PermissionMode $permissionMode } elseif ($phase.script) { # --- Script-only phase (no LLM) --- # Resolve script path: if it starts with 'scripts/' resolve from $botRoot, otherwise from systems/runtime/ $rawScript = $phase.script $scriptPath = if ($rawScript -match '^scripts[/\\]') { Join-Path $botRoot $rawScript } else { Join-Path $botRoot "systems\runtime\$rawScript" } $scriptInvokeArgs = @{ BotRoot = $botRoot; Model = $claudeModelName; ProcessId = $procId } if ($activeWorkflowDir) { $scriptInvokeArgs['WorkflowDir'] = $activeWorkflowDir } & $scriptPath @scriptInvokeArgs } else { # --- LLM phase --- # Pre-phase cleanup: remove leftover clarification files from previous phases $phaseQuestionsPath = Join-Path $productDir "clarification-questions.json" $phaseAnswersPath = Join-Path $productDir "clarification-answers.json" if (Test-Path $phaseQuestionsPath) { Remove-Item $phaseQuestionsPath -Force -ErrorAction SilentlyContinue } if (Test-Path $phaseAnswersPath) { Remove-Item $phaseAnswersPath -Force -ErrorAction SilentlyContinue } $wfContent = "" $wfPath = Join-Path $botRoot "recipes\prompts\$($phase.workflow)" if (Test-Path $wfPath) { $wfContent = Get-Content $wfPath -Raw } $phasePrompt = @" $wfContent User's project description: $Prompt $fileRefs $interviewContext Instructions: 1. Read any briefing files listed above and any existing project files (README.md, etc.) for additional context 2. If an interview-summary.md file exists in .bot/workspace/product/, read it carefully — it contains clarified requirements from the user 3. Follow the workflow above to create the required outputs. Write files to .bot/workspace/product/ 4. Do NOT create tasks or use task management tools unless the workflow explicitly instructs you to 5. Write comprehensive, well-structured content based on the user's description and any attached files 6. Make reasonable inferences where details are missing — the user can refine later IMPORTANT: If creating mission.md, it MUST begin with ## Executive Summary as the first content after the title. This is required for the UI to detect that product planning is complete. "@ $claudeSessionId = New-ProviderSession $streamArgs = @{ Prompt = $phasePrompt Model = $claudeModelName SessionId = $claudeSessionId PersistSession = $false } if ($ShowDebug) { $streamArgs['ShowDebugJson'] = $true } if ($ShowVerbose) { $streamArgs['ShowVerbose'] = $true } if ($permissionMode) { $streamArgs['PermissionMode'] = $permissionMode } Invoke-ProviderStream @streamArgs # --- Post-phase question detection (Generate -> Ask -> Adjust) --- if (Test-Path $phaseQuestionsPath) { try { $phaseQData = (Get-Content $phaseQuestionsPath -Raw) | ConvertFrom-Json } catch { Write-Status "Failed to parse phase questions JSON: $($_.Exception.Message)" -Type Warn $phaseQData = $null } if ($phaseQData -and $phaseQData.questions -and $phaseQData.questions.Count -gt 0) { Write-Status "Phase $phaseNum ($phaseName): $($phaseQData.questions.Count) question(s) — waiting for user" -Type Info Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum has $($phaseQData.questions.Count) clarification question(s)" # 1. ASK — Set process to needs-input, poll for answers $processData.status = 'needs-input' $processData.pending_questions = $phaseQData $processData.heartbeat_status = "Waiting for answers (phase ${phaseNum}: $phaseName)" Write-ProcessFile -Id $procId -Data $processData # Send questions to external notification channel (Teams) if configured $phaseNotifications = @{} $phaseNotifSettings = $null try { $notifModule = Join-Path $botRoot "systems\mcp\modules\NotificationClient.psm1" if (Test-Path $notifModule) { Import-Module $notifModule -Force $phaseNotifSettings = Get-NotificationSettings -BotRoot $botRoot if ($phaseNotifSettings.enabled) { foreach ($q in $phaseQData.questions) { $fakeTask = @{ id = "$procId-phase-$phaseNum"; name = "Phase $phaseNum - $phaseName" } $pendingQ = @{ id = "$($q.id)-p$phaseNum" question = $q.question context = $q.context options = @($q.options | ForEach-Object { @{ key = $_.key; label = $_.label; rationale = $_.rationale } }) recommendation = $q.recommendation } $sendResult = Send-TaskNotification -TaskContent $fakeTask -PendingQuestion $pendingQ -Settings $phaseNotifSettings if ($sendResult.success) { $phaseNotifications[$q.id] = @{ question_id = $sendResult.question_id instance_id = $sendResult.instance_id project_id = $sendResult.project_id } } } Write-Status "Sent $($phaseNotifications.Count) phase question(s) to Teams" -Type Info } } } catch { Write-Status "Phase notification send failed (non-fatal): $($_.Exception.Message)" -Type Warn } if (Test-Path $phaseAnswersPath) { Remove-Item $phaseAnswersPath -Force } $phaseTeamsAnswers = @{} $phaseLastPoll = [datetime]::MinValue while (-not (Test-Path $phaseAnswersPath)) { if (Test-ProcessStopSignal -Id $procId) { Write-Status "Stop signal received waiting for phase answers" -Type Error $processData.status = 'stopped' $processData.failed_at = (Get-Date).ToUniversalTime().ToString("o") $processData.pending_questions = $null Write-ProcessFile -Id $procId -Data $processData throw "Process stopped by user during phase $phaseNum questions" } # Check for Teams responses if ($phaseNotifications.Count -gt 0 -and ([datetime]::UtcNow - $phaseLastPoll).TotalSeconds -ge 10) { $phaseLastPoll = [datetime]::UtcNow foreach ($qId in @($phaseNotifications.Keys)) { if ($phaseTeamsAnswers.ContainsKey($qId)) { continue } try { $notif = $phaseNotifications[$qId] $resp = Get-TaskNotificationResponse -Notification $notif -Settings $phaseNotifSettings if ($resp) { $attachDir = Join-Path $productDir "attachments\$qId" $resolved = Resolve-NotificationAnswer -Response $resp -Settings $phaseNotifSettings -AttachDir $attachDir if ($resolved) { $phaseTeamsAnswers[$qId] = $resolved Write-Status "Received Teams answer for $qId : $($resolved.answer)" -Type Info } } } catch { Write-BotLog -Level Warn -Message "Teams polling attempt failed" -Exception $_ } } if ($phaseTeamsAnswers.Count -ge $phaseQData.questions.Count) { $answersObj = @{ answers = @($phaseQData.questions | ForEach-Object { $r = $phaseTeamsAnswers[$_.id] $entry = @{ id = $_.id; question = $_.question; answer = $r.answer } if ($r.attachments -and $r.attachments.Count -gt 0) { $entry['attachments'] = $r.attachments } $entry }) answered_via = "teams" } $answersObj | ConvertTo-Json -Depth 10 | Set-Content -Path $phaseAnswersPath -Encoding UTF8 Write-Status "All $($phaseQData.questions.Count) phase answers received via Teams" -Type Complete break } } Start-Sleep -Seconds 2 } # Read answers try { $phaseAnswersData = (Get-Content $phaseAnswersPath -Raw) | ConvertFrom-Json } catch { Write-Status "Failed to parse phase answers JSON: $($_.Exception.Message)" -Type Warn $phaseAnswersData = $null } # Check if user skipped if ($phaseAnswersData -and $phaseAnswersData.skipped -eq $true) { Write-Status "User skipped phase $phaseNum questions" -Type Info Write-ProcessActivity -Id $procId -ActivityType "text" -Message "User skipped phase $phaseNum questions" } elseif ($phaseAnswersData) { Write-Status "Answers received for phase $phaseNum" -Type Success Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Received answers for phase $phaseNum" # 2. RECORD — Append Q&A to interview-summary.md $summaryPath = Join-Path $productDir "interview-summary.md" $timestamp = (Get-Date).ToUniversalTime().ToString("o") $qaSection = "`n`n### Phase ${phaseNum}: $phaseName`n" $qaSection += "| # | Question | Answer (verbatim) | Interpretation | Timestamp |`n" $qaSection += "|---|----------|--------------------|----------------|-----------|`n" $qIdx = 0 foreach ($ans in $phaseAnswersData.answers) { $qIdx++ $qText = ($ans.question -replace '\|', '\|' -replace "`n", ' ') $aText = ($ans.answer -replace '\|', '\|' -replace "`n", ' ') $qaSection += "| q$qIdx | $qText | $aText | _pending_ | $timestamp |`n" } if (Test-Path $summaryPath) { # Append to existing file $existingContent = Get-Content $summaryPath -Raw if ($existingContent -notmatch '## Clarification Log') { $qaSection = "`n## Clarification Log`n" + $qaSection } Add-Content -Path $summaryPath -Value $qaSection -NoNewline } else { # Create new summary with clarification log $newSummary = "# Interview Summary`n`n## Clarification Log`n" + $qaSection Set-Content -Path $summaryPath -Value $newSummary -NoNewline } # 3. ADJUST — Run holistic artifact correction pass $adjustPromptPath = Join-Path $botRoot "recipes\includes\adjust-after-answers.md" if (Test-Path $adjustPromptPath) { $adjustContent = Get-Content $adjustPromptPath -Raw $adjustPrompt = @" $adjustContent ## Context - **Phase that generated questions**: Phase $phaseNum — $phaseName - **User's project description**: $Prompt $fileRefs $interviewContext Instructions: 1. Read .bot/workspace/product/interview-summary.md for the full Q&A history including the new answers 2. Read ALL existing product artifacts in .bot/workspace/product/ 3. Assess the impact of the new information across all artifacts 4. Enrich/correct any affected artifacts 5. Fill in the Interpretation column for the new Q&A entries in interview-summary.md "@ Write-Status "Running post-answer adjustment for phase $phaseNum..." -Type Process Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Adjusting artifacts based on phase $phaseNum answers" $adjustSessionId = New-ProviderSession $adjustArgs = @{ Prompt = $adjustPrompt Model = $claudeModelName SessionId = $adjustSessionId PersistSession = $false } if ($ShowDebug) { $adjustArgs['ShowDebugJson'] = $true } if ($ShowVerbose) { $adjustArgs['ShowVerbose'] = $true } if ($permissionMode) { $adjustArgs['PermissionMode'] = $permissionMode } Invoke-ProviderStream @adjustArgs Write-Status "Post-answer adjustment complete for phase $phaseNum" -Type Complete } else { Write-Status "Adjust prompt not found at $adjustPromptPath — skipping adjustment" -Type Warn } } # 4. CLEANUP — Remove JSON files, reset process status Remove-Item $phaseQuestionsPath -Force -ErrorAction SilentlyContinue Remove-Item $phaseAnswersPath -Force -ErrorAction SilentlyContinue $processData.status = 'running' $processData.pending_questions = $null $processData.heartbeat_status = "Running phase $phaseNum" Write-ProcessFile -Id $procId -Data $processData } } } # --- Validation (skip for barrier/interview phase types) --- if ($phaseType -notin @("barrier", "interview")) { # Support both manifest-style 'outputs' and legacy 'required_outputs' $validationOutputs = if ($phase.outputs) { $phase.outputs } else { $phase.required_outputs } $validationOutputsDir = if ($phase.outputs_dir) { $phase.outputs_dir } else { $phase.required_outputs_dir } if ($validationOutputs) { foreach ($f in $validationOutputs) { if (-not (Test-Path (Join-Path $productDir $f))) { throw "Phase $phaseNum ($phaseName) failed: $f was not created" } } } elseif ($validationOutputsDir) { $dirPath = Join-Path $botRoot "workspace\$validationOutputsDir" $minCount = if ($phase.min_output_count) { [int]$phase.min_output_count } else { 1 } $fileCount = if (Test-Path $dirPath) { @(Get-ChildItem $dirPath -File | Where-Object { $_.Name -notmatch '^[._]' }).Count } else { 0 } if ($fileCount -lt $minCount) { throw "Phase $phaseNum ($phaseName) failed: expected at least $minCount file(s) in $validationOutputsDir, found $fileCount" } } } # --- Front matter --- if ($phase.front_matter_docs) { $phaseMeta = @{ generated_at = (Get-Date).ToUniversalTime().ToString("o") model = $claudeModelName process_id = $procId phase = "phase-$phaseNum-$($phase.id)" generator = "dotbot-kickstart" } foreach ($docName in $phase.front_matter_docs) { $docPath = Join-Path $productDir $docName if (Test-Path $docPath) { Add-YamlFrontMatter -FilePath $docPath -Metadata $phaseMeta } } } # --- Post-script --- # Delegated to shared helper; raises on non-zero exit so a failing # post-script now fails the phase instead of being silently ignored. if ($phase.post_script) { Invoke-PostScript -BotRoot $botRoot -ProductDir $productDir -Settings $settings ` -Model $claudeModelName -ProcessId $procId -RawPostScript $phase.post_script } # --- Git checkpoint (supports manifest-style commit object and legacy commit_paths/commit_message) --- $commitPaths = if ($phase.commit -and $phase.commit.paths) { $phase.commit.paths } else { $phase.commit_paths } $commitMsg = if ($phase.commit -and $phase.commit.message) { $phase.commit.message } elseif ($phase.commit_message) { $phase.commit_message } else { "chore(kickstart): phase $phaseNum — $($phaseName.ToLower())" } if ($commitPaths) { Write-Status "Committing phase $phaseNum artifacts..." -Type Info foreach ($cp in $commitPaths) { git -C $projectRoot add ".bot/$cp" 2>$null } git -C $projectRoot commit --quiet -m $commitMsg 2>$null if ($LASTEXITCODE -eq 0) { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum checkpoint committed" # Auto-push phase commits so verify hooks (02-git-pushed.ps1) pass on # task_mark_done. Default is ON because the verify hook expects an # up-to-date remote, but users can opt out via the # `auto_push_phase_commits: false` setting for environments without # an `origin` remote, with branch protections, or with other push # constraints. When a push fails we log the stderr explicitly so # users can diagnose rather than silently seeing the verify hook fail. $autoPushPhaseCommits = $true if ($null -ne $settings) { $val = $null if ($settings -is [System.Collections.IDictionary] -and $settings.Contains('auto_push_phase_commits')) { $val = $settings['auto_push_phase_commits'] } elseif ($settings.PSObject -and $settings.PSObject.Properties['auto_push_phase_commits']) { $val = $settings.auto_push_phase_commits } if ($null -ne $val) { $autoPushPhaseCommits = [bool]$val } } if ($autoPushPhaseCommits) { # Skip task branches (merged by framework later). Push everything # else — including main/master — because kickstart runs in fresh # repos where the user chose the starting branch, and the verify # hook (02-git-pushed.ps1) will otherwise block task_mark_done on # unpushed phase commits. Users with branch protection on the # default branch can opt out via `auto_push_phase_commits: false`. $currentBranch = git -C $projectRoot rev-parse --abbrev-ref HEAD 2>$null $branchLookupExit = $LASTEXITCODE if (-not $currentBranch -or $branchLookupExit -ne 0) { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum push skipped: could not determine current branch (git rev-parse --abbrev-ref HEAD failed or returned empty)" } elseif ($currentBranch -notmatch '^task/') { $originUrl = git -C $projectRoot remote get-url origin 2>$null if ($LASTEXITCODE -eq 0 -and $originUrl) { $pushOutput = git -C $projectRoot push --quiet origin $currentBranch 2>&1 if ($LASTEXITCODE -eq 0) { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum pushed to origin/$currentBranch" } else { $pushMessage = if ($pushOutput) { ($pushOutput | Out-String).Trim() } else { "unknown git push failure" } Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum push to origin/$currentBranch failed: $pushMessage" } } else { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum push skipped: git remote 'origin' is not configured" } } else { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum push skipped: branch '$currentBranch' is task-scoped (framework will merge)" } } else { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum push skipped: auto_push_phase_commits setting is disabled" } } else { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum checkpoint: nothing to commit" } } # Mark phase as completed if ($trackIdx -ge 0) { $processData.phases[$trackIdx].status = 'completed' $processData.phases[$trackIdx].completed_at = (Get-Date).ToUniversalTime().ToString("o") Write-ProcessFile -Id $procId -Data $processData } Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Phase $phaseNum complete — $($phaseName.ToLower())" $phaseNum++ } # Done $processData.status = 'completed' $processData.completed_at = (Get-Date).ToUniversalTime().ToString("o") $processData.heartbeat_status = "Completed: $Description" } catch { # Mark the current phase as failed if we have a tracking index if ($trackIdx -ge 0 -and $processData.phases[$trackIdx].status -eq 'running') { $processData.phases[$trackIdx].status = 'failed' $processData.phases[$trackIdx].error = $_.Exception.Message } $processData.status = 'failed' $processData.failed_at = (Get-Date).ToUniversalTime().ToString("o") $processData.error = $_.Exception.Message $processData.heartbeat_status = "Failed: $($_.Exception.Message)" Write-Status "Process failed: $($_.Exception.Message)" -Type Error # C8: Log the error details to activity JSONL so failures aren't silent Write-ProcessActivity -Id $procId -ActivityType "error" -Message "Phase failure: $($_.Exception.Message)" } Write-ProcessFile -Id $procId -Data $processData Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Process $procId finished ($($processData.status))" |