workflows/default/systems/runtime/modules/InterviewLoop.ps1
|
<# .SYNOPSIS Reusable interview loop for kickstart Phase 0 and interview-type phases. .DESCRIPTION Extracted from launch-process.ps1 as part of v4 Phase 03 (#92). Runs a multi-round Q&A loop with Claude, collecting user answers via local files or external Teams notifications. #> function Invoke-InterviewLoop { param( [string]$ProcessId, [hashtable]$ProcessData, [string]$BotRoot, [string]$ProductDir, [string]$UserPrompt, [switch]$ShowDebugJson, [switch]$ShowVerboseOutput, [string]$PermissionMode ) $processData = $ProcessData # Load interview prompt template $interviewWorkflowPath = Join-Path $BotRoot "recipes\prompts\00-kickstart-interview.md" $interviewWorkflow = "" if (Test-Path $interviewWorkflowPath) { $interviewWorkflow = Get-Content $interviewWorkflowPath -Raw } # Check for briefing files $briefingDir = Join-Path $ProductDir "briefing" $interviewFileRefs = "" if (Test-Path $briefingDir) { $briefingFiles = Get-ChildItem -Path $briefingDir -File if ($briefingFiles.Count -gt 0) { $interviewFileRefs = "`n`nBriefing files have been saved to the briefing/ directory. Read and use these for context:`n" foreach ($bf in $briefingFiles) { $interviewFileRefs += "- $($bf.FullName)`n" } } } $interviewRound = 0 $allQandA = @() $questionsPath = Join-Path $ProductDir "clarification-questions.json" $answersPath = Join-Path $ProductDir "clarification-answers.json" $summaryPath = Join-Path $ProductDir "interview-summary.md" # Use Opus for interview quality $interviewModel = Resolve-ProviderModelId -ModelAlias 'Opus' do { $interviewRound++ # Build previous Q&A context $previousContext = "" if ($allQandA.Count -gt 0) { $previousContext = "`n`n## Previous Interview Rounds`n" foreach ($round in $allQandA) { $previousContext += "`n### Round $($round.round)`n" foreach ($qa in $round.pairs) { $previousContext += "**Q:** $($qa.question)`n**A:** $($qa.answer)`n`n" } } } $interviewPrompt = @" $interviewWorkflow ## User's Project Description $UserPrompt $interviewFileRefs $previousContext ## Instructions Review all context above. Decide whether to write clarification-questions.json (more questions needed) or interview-summary.md (all clear). Write exactly one file to .bot/workspace/product/. "@ Write-Status "Interview round $interviewRound..." -Type Process Write-ProcessActivity -Id $ProcessId -ActivityType "text" -Message "Interview round $interviewRound" $interviewSessionId = New-ProviderSession $streamArgs = @{ Prompt = $interviewPrompt Model = $interviewModel SessionId = $interviewSessionId PersistSession = $false } if ($ShowDebugJson) { $streamArgs['ShowDebugJson'] = $true } if ($ShowVerboseOutput) { $streamArgs['ShowVerbose'] = $true } if ($PermissionMode) { $streamArgs['PermissionMode'] = $PermissionMode } Invoke-ProviderStream @streamArgs # Check what Opus wrote if (Test-Path $summaryPath) { Write-Status "Interview complete — summary written" -Type Complete Write-ProcessActivity -Id $ProcessId -ActivityType "text" -Message "Interview complete after $interviewRound round(s)" # Add YAML front matter to interview summary $meta = @{ generated_at = (Get-Date).ToUniversalTime().ToString("o") model = $interviewModel process_id = $ProcessId phase = "interview" generator = "dotbot-kickstart" } Add-YamlFrontMatter -FilePath $summaryPath -Metadata $meta # Clean up any leftover question/answer files now that the interview is fully analysed Remove-Item $questionsPath -Force -ErrorAction SilentlyContinue Remove-Item $answersPath -Force -ErrorAction SilentlyContinue break } if (Test-Path $questionsPath) { try { $questionsRaw = Get-Content $questionsPath -Raw $questionsData = $questionsRaw | ConvertFrom-Json $questions = $questionsData.questions } catch { Write-Status "Failed to parse questions JSON: $($_.Exception.Message)" -Type Warn break } Write-Status "Round ${interviewRound}: $($questions.Count) question(s) — waiting for user" -Type Info # Set process to needs-input $processData.status = 'needs-input' $processData.pending_questions = $questionsData $processData.interview_round = $interviewRound $processData.heartbeat_status = "Waiting for interview answers (round $interviewRound)" Write-ProcessFile -Id $ProcessId -Data $processData Write-ProcessActivity -Id $ProcessId -ActivityType "text" -Message "Waiting for user answers (round $interviewRound, $($questions.Count) questions)" # Send questions to external notification channel (Teams) if configured $interviewNotifications = @{} $interviewNotifSettings = $null try { $notifModule = Join-Path $BotRoot "systems\mcp\modules\NotificationClient.psm1" if (Test-Path $notifModule) { Import-Module $notifModule -Force $interviewNotifSettings = Get-NotificationSettings -BotRoot $BotRoot if ($interviewNotifSettings.enabled) { foreach ($q in $questions) { $fakeTask = @{ id = "$ProcessId-interview"; name = "Kickstart Interview Round $interviewRound" } $pendingQ = @{ id = "$($q.id)-r$interviewRound" 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 $interviewNotifSettings if ($sendResult.success) { $interviewNotifications[$q.id] = @{ question_id = $sendResult.question_id instance_id = $sendResult.instance_id project_id = $sendResult.project_id } } } Write-Status "Sent $($interviewNotifications.Count) question(s) to Teams" -Type Info } } } catch { Write-Status "Notification send failed (non-fatal): $($_.Exception.Message)" -Type Warn } # Poll for answers file OR external Teams responses $answersPath = Join-Path $ProductDir "clarification-answers.json" if (Test-Path $answersPath) { Remove-Item $answersPath -Force } $teamsAnswers = @{} $lastTeamsPoll = [datetime]::MinValue $teamsPollInterval = 10 # seconds between server polls while (-not (Test-Path $answersPath)) { if (Test-ProcessStopSignal -Id $ProcessId) { Write-Status "Stop signal received during interview" -Type Error $processData.status = 'stopped' $processData.failed_at = (Get-Date).ToUniversalTime().ToString("o") $processData.pending_questions = $null Write-ProcessFile -Id $ProcessId -Data $processData throw "Process stopped by user during interview" } # Check for Teams responses if notifications were sent if ($interviewNotifications.Count -gt 0 -and ([datetime]::UtcNow - $lastTeamsPoll).TotalSeconds -ge $teamsPollInterval) { $lastTeamsPoll = [datetime]::UtcNow foreach ($qId in @($interviewNotifications.Keys)) { if ($teamsAnswers.ContainsKey($qId)) { continue } try { $notif = $interviewNotifications[$qId] $resp = Get-TaskNotificationResponse -Notification $notif -Settings $interviewNotifSettings if ($resp) { $attachDir = Join-Path $ProductDir "attachments\$qId" $resolved = Resolve-NotificationAnswer -Response $resp -Settings $interviewNotifSettings -AttachDir $attachDir if ($resolved) { $teamsAnswers[$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 all questions answered via Teams, write the answers file if ($teamsAnswers.Count -ge $questions.Count) { $answersObj = @{ answers = @($questions | ForEach-Object { $r = $teamsAnswers[$_.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 $answersPath -Encoding UTF8 Write-Status "All $($questions.Count) answers received via Teams" -Type Complete break } } Start-Sleep -Seconds 2 } # Read answers try { $answersRaw = Get-Content $answersPath -Raw $answersData = $answersRaw | ConvertFrom-Json } catch { Write-Status "Failed to parse answers JSON: $($_.Exception.Message)" -Type Warn break } # Check if user skipped if ($answersData.skipped -eq $true) { Write-Status "User skipped interview" -Type Info Write-ProcessActivity -Id $ProcessId -ActivityType "text" -Message "User skipped interview at round $interviewRound" # Clean up Remove-Item $questionsPath -Force -ErrorAction SilentlyContinue Remove-Item $answersPath -Force -ErrorAction SilentlyContinue break } # Accumulate Q&A for next round $allQandA += @{ round = $interviewRound pairs = @($answersData.answers) } Write-Status "Answers received for round $interviewRound" -Type Success Write-ProcessActivity -Id $ProcessId -ActivityType "text" -Message "Received answers for round $interviewRound" # Keep clarification-questions.json and clarification-answers.json intact so # Claude can read them in the next round when it processes the answers. # They will be overwritten (questions) or pre-cleared (answers, line below) # naturally when the next round begins. # Reset process status $processData.status = 'running' $processData.pending_questions = $null $processData.interview_round = $null $processData.heartbeat_status = "Processing interview answers" Write-ProcessFile -Id $ProcessId -Data $processData } else { # Neither file written — something went wrong, proceed without Write-Status "Interview round produced no output — proceeding" -Type Warn Write-ProcessActivity -Id $ProcessId -ActivityType "text" -Message "Interview round $interviewRound produced no output — skipping" break } } while ($true) # Ensure status is running after interview $processData.status = 'running' $processData.pending_questions = $null $processData.interview_round = $null Write-ProcessFile -Id $ProcessId -Data $processData } |