extensions/specrew-speckit/scripts/intake/Invoke-SpecifyIntake.ps1
|
<#
.SYNOPSIS Discrete intake engine for /speckit.specify persona-driven intake. .DESCRIPTION Orchestrates persona-cycle logic, per-lens depth-rule application, question-bank traversal, auto-decision resolution, and annotation rendering. Thin orchestrators (prompt, agent, workflow) invoke this engine; they do not contain inline persona definitions, category lists, question banks, depth rules, or auto-decision defaults. Implements FR-028 engine + data architecture for Feature 049 Iteration 003. .PARAMETER TestMode When specified, runs engine in test mode without interactive prompts for validation purposes. .PARAMETER UserProfilePath Path to user-profile.yml. Defaults to cross-platform standard location. .PARAMETER IntakeDataRoot Root path for intake data catalogs (personas, categories, depth-rules, questions, auto-decision-defaults). Defaults to .specify/intake/ in the current project. .PARAMETER UserInput Initial user input describing the feature to specify. .PARAMETER ExpertiseDial Override expertise dials for testing (hashtable with persona IDs as keys, 1-10 values). .EXAMPLE Invoke-SpecifyIntake -UserInput "Build a REST API for user management" .EXAMPLE Invoke-SpecifyIntake -TestMode .NOTES Mirror parity: This file must remain functionally identical to: .specify/extensions/specrew-speckit/scripts/intake/Invoke-SpecifyIntake.ps1 #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [switch]$TestMode, [Parameter(Mandatory = $false)] [string]$UserProfilePath, [Parameter(Mandatory = $false)] [string]$IntakeDataRoot, [Parameter(Mandatory = $false)] [string]$UserInput, [Parameter(Mandatory = $false)] [hashtable]$ExpertiseDial ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Determine script root for helper loading $ScriptRoot = $PSScriptRoot if ([string]::IsNullOrEmpty($ScriptRoot)) { $ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path } # Load helper functions $helpersPath = Join-Path $ScriptRoot 'helpers' $helpers = @( 'Read-IntakeYaml.ps1', 'Load-PersonaCatalog.ps1', 'Load-CategoryCatalog.ps1', 'Resolve-PerLensMode.ps1', 'Traverse-QuestionBank.ps1', 'Resolve-AutoDecision.ps1', 'Render-Annotation.ps1', 'Detect-RepoStack.ps1' ) foreach ($helper in $helpers) { $helperPath = Join-Path $helpersPath $helper if (Test-Path $helperPath) { . $helperPath } else { Write-Warning "Helper not found: $helperPath (engine may be incomplete)" } } function Test-IntakeProfileKey { param( [AllowNull()] [object]$InputObject, [Parameter(Mandatory = $true)] [string]$Key ) if ($null -eq $InputObject) { return $false } if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject.Contains($Key) } return $null -ne $InputObject.PSObject.Properties[$Key] } function Get-IntakeProfileValue { param( [AllowNull()] [object]$InputObject, [Parameter(Mandatory = $true)] [string]$Key ) if (-not (Test-IntakeProfileKey -InputObject $InputObject -Key $Key)) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject[$Key] } return $InputObject.$Key } # Resolve paths if ([string]::IsNullOrEmpty($IntakeDataRoot)) { # Default to project .specify/intake/ directory $projectRoot = Get-Location $IntakeDataRoot = Join-Path $projectRoot '.specify\intake' } if ([string]::IsNullOrEmpty($UserProfilePath)) { # Cross-platform user profile location if ($IsWindows -or $PSVersionTable.PSVersion.Major -lt 6 -or $env:OS -match 'Windows') { $UserProfilePath = Join-Path $env:USERPROFILE '.specrew\user-profile.yml' } else { $UserProfilePath = Join-Path $env:HOME '.specrew/user-profile.yml' } } # Engine execution begins Write-Verbose "Intake Engine: Starting persona-driven intake orchestration" Write-Verbose "Intake Data Root: $IntakeDataRoot" Write-Verbose "User Profile Path: $UserProfilePath" # Load user profile (if exists) $userProfile = $null if (Test-Path $UserProfilePath) { try { Write-Verbose "Loading user profile from: $UserProfilePath" # YAML parsing - using ConvertFrom-Yaml if available, else basic parsing $profileContent = Get-Content $UserProfilePath -Raw if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { $userProfile = $profileContent | ConvertFrom-Yaml } elseif (Get-Command Read-IntakeYamlDocument -ErrorAction SilentlyContinue) { $userProfile = Read-IntakeYamlDocument -Path $UserProfilePath -Kind 'user_profile' } else { Write-Verbose "User profile loading skipped (no YAML parser available)" } } catch { Write-Warning "Failed to load user profile: $_" } } # Override expertise dials if provided (testing) if ($ExpertiseDial) { if (-not $userProfile) { $userProfile = @{} } # Accept direct persona-ID mapping for backward compatibility if ($userProfile -is [System.Collections.IDictionary]) { $userProfile.expertise_dials = $ExpertiseDial } else { $userProfile | Add-Member -NotePropertyName 'expertise_dials' -NotePropertyValue $ExpertiseDial -Force } } # Map FR-024 expertise structure to legacy persona IDs for compatibility if ($userProfile -and (Test-IntakeProfileKey -InputObject $userProfile -Key 'expertise') -and -not (Test-IntakeProfileKey -InputObject $userProfile -Key 'expertise_dials')) { $legacyMapping = @{ 'software_architecture' = 'architect' 'ui_ux' = 'ux-ui-specialist' 'product_management' = 'product-manager' 'ai_research_project_management' = 'ai-researcher-project-manager' } if ($userProfile -is [System.Collections.IDictionary]) { $userProfile.expertise_dials = @{} } else { $userProfile | Add-Member -NotePropertyName 'expertise_dials' -NotePropertyValue @{} -Force } $rawExpertise = Get-IntakeProfileValue -InputObject $userProfile -Key 'expertise' foreach ($field in $legacyMapping.Keys) { $personaId = $legacyMapping[$field] if (Test-IntakeProfileKey -InputObject $rawExpertise -Key $field) { $userProfile.expertise_dials[$personaId] = Get-IntakeProfileValue -InputObject $rawExpertise -Key $field } } } # Load personas catalog Write-Verbose "Loading personas catalog" $personas = Load-PersonaCatalog -IntakeDataRoot $IntakeDataRoot # Load categories catalog Write-Verbose "Loading categories catalog" $categories = Load-CategoryCatalog -IntakeDataRoot $IntakeDataRoot # Load depth rules Write-Verbose "Loading depth rules" $depthRulesPath = Join-Path $IntakeDataRoot 'depth-rules.yml' $depthRules = $null if (Test-Path $depthRulesPath) { $depthRulesContent = Get-Content $depthRulesPath -Raw if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { $depthRules = $depthRulesContent | ConvertFrom-Yaml } elseif (Get-Command Read-IntakeYamlDocument -ErrorAction SilentlyContinue) { $depthRules = Read-IntakeYamlDocument -Path $depthRulesPath -Kind 'depth_rules' } else { Write-Verbose "Depth rules loading skipped (no YAML parser available)" } } # Detect repo stack Write-Verbose "Detecting repository stack" $repoStack = Detect-RepoStack -ProjectRoot (Get-Location) # Load auto-decision defaults (stack-specific or generic fallback) Write-Verbose "Loading auto-decision defaults for stack: $repoStack" $autoDecisions = Resolve-AutoDecision -IntakeDataRoot $IntakeDataRoot -Stack $repoStack # Initialize intake state $intakeState = @{ user_input = $UserInput personas = $personas categories = $categories depth_rules = $depthRules user_profile = $userProfile auto_decisions = $autoDecisions repo_stack = $repoStack test_mode = $TestMode.IsPresent results = @() } # Execute persona-driven intake cycle (4 sequential lenses) Write-Verbose "Beginning persona lens traversal (4 sequential lenses)" foreach ($persona in $personas) { Write-Verbose "Applying lens: $($persona.name) ($($persona.id))" # Get expertise dial for this persona $personaExpertiseDial = $null $runtimeExpertiseDials = if ($userProfile) { Get-IntakeProfileValue -InputObject $userProfile -Key 'expertise_dials' } else { $null } if ($userProfile -and $runtimeExpertiseDials -and (Test-IntakeProfileKey -InputObject $runtimeExpertiseDials -Key $persona.id)) { $dialValue = Get-IntakeProfileValue -InputObject $runtimeExpertiseDials -Key $persona.id # Handle "auto" or "I'm new, you decide" - preserve the string as-is if ($dialValue -eq 'auto') { $personaExpertiseDial = 'auto' Write-Verbose " Expertise dial: auto (system auto-decides)" } elseif ($dialValue -match '^\d+$') { $personaExpertiseDial = [int]$dialValue Write-Verbose " Expertise dial: $personaExpertiseDial" } else { # Treat unrecognized values as auto $personaExpertiseDial = 'auto' Write-Verbose " Expertise dial: auto (unrecognized value '$dialValue', defaulting to auto)" } } else { # Default mid-range when no profile exists $personaExpertiseDial = 5 Write-Verbose " Expertise dial: $personaExpertiseDial (default)" } # Calculate lens completeness (placeholder - would analyze existing content) $lensCompleteness = 0.5 # 50% for now (would be calculated from existing answers) # Resolve per-lens mode (Mode A/B/C) # When expertise is "auto", treat as Mode C (full interview with auto-decisions) if ($personaExpertiseDial -eq 'auto') { $lensMode = 'C' Write-Verbose " Resolved lens mode: C (auto-decide path)" } else { $lensMode = Resolve-PerLensMode -ExpertiseDial $personaExpertiseDial -LensCompleteness $lensCompleteness -DepthRules $depthRules Write-Verbose " Resolved lens mode: $lensMode" } # Traverse question bank for this persona $questions = Traverse-QuestionBank -IntakeDataRoot $IntakeDataRoot -PersonaId $persona.id -Mode $lensMode # Collect results for this lens $lensResult = @{ persona_id = $persona.id persona_name = $persona.name expertise_dial = $personaExpertiseDial lens_completeness = $lensCompleteness lens_mode = $lensMode questions = $questions } # Render annotations for auto-decided items (Proposal 053 transparency) # Auto path OR low-expertise path both get auto-decisions with transparency if ($lensMode -eq 'C' -and ($personaExpertiseDial -eq 'auto' -or $personaExpertiseDial -le 3)) { $annotations = Render-Annotation -LensResult $lensResult -AutoDecisions $autoDecisions $lensResult.annotations = $annotations } $intakeState.results += $lensResult } # Determine overall intake mode (most-conservative-wins: C > B > A) $overallMode = 'A' foreach ($result in $intakeState.results) { if ($result.lens_mode -eq 'C') { $overallMode = 'C' break } elseif ($result.lens_mode -eq 'B') { $overallMode = 'B' } } Write-Verbose "Overall intake mode (most-conservative-wins): $overallMode" # Return intake state if ($TestMode) { Write-Output "ENGINE TEST MODE: Intake orchestration complete" Write-Output " Personas loaded: $($personas.Count)" Write-Output " Categories loaded: $($categories.Count)" Write-Output " Detected stack: $repoStack" Write-Output " Overall mode: $overallMode" Write-Output " Lens results: $($intakeState.results.Count)" } return $intakeState |