workflows/default/systems/runtime/modules/workflow-manifest.ps1
|
<# .SYNOPSIS Workflow manifest utilities — parse workflow.yaml, create tasks, merge MCP servers .DESCRIPTION Shared functions used by init-project.ps1, workflow-add.ps1, workflow-run.ps1, and launch-process.ps1 for the multi-workflow system. #> function Read-WorkflowManifest { <# .SYNOPSIS Parse a workflow.yaml file into a hashtable. .DESCRIPTION Lightweight YAML parser that handles the workflow manifest schema. Handles scalars, simple lists (inline [...] and block - item), and nested objects (author, requires, form, mcp_servers, tasks). Falls back to profile.yaml if workflow.yaml not found. #> param( [Parameter(Mandatory)] [string]$WorkflowDir ) $yamlPath = Join-Path $WorkflowDir "workflow.yaml" $manifest = @{ name = (Split-Path $WorkflowDir -Leaf) type = "workflow" version = "1.0" description = "" author = @{} icon = "" license = "" tags = @() categories = @() repository = "" homepage = "" readme = "" min_dotbot_version = "" rerun = "fresh" requires = @{ env_vars = @(); mcp_servers = @(); cli_tools = @() } mcp_servers = @{} form = @{} domain = @{} tasks = @() } if (-not (Test-Path $yamlPath)) { return $manifest } # Use powershell-yaml module if available for full parsing $yamlModule = Get-Module -ListAvailable powershell-yaml -ErrorAction SilentlyContinue if ($yamlModule) { try { $raw = Get-Content $yamlPath -Raw $parsed = ConvertFrom-Yaml $raw -Ordered if ($parsed) { # Map parsed YAML to manifest structure foreach ($key in @($parsed.Keys)) { $manifest[$key] = $parsed[$key] } } return $manifest } catch { Write-BotLog -Level Warn -Message "powershell-yaml parse failed, falling back to simple parser" -Exception $_ } } # Simple fallback parser (handles flat scalars + type/name/description/extends) Get-Content $yamlPath | ForEach-Object { if ($_ -match '^\s*(type|name|description|extends|version|rerun|icon|license|repository|homepage|readme|min_dotbot_version)\s*:\s*(.+)$') { $manifest[$Matches[1]] = $Matches[2].Trim().Trim('"').Trim("'") } } return $manifest } function Get-ActiveWorkflowManifest { <# .SYNOPSIS Resolve the workflow manifest for the active profile in a project. .DESCRIPTION Checks installed workflows (.bot/workflows/), then .bot/workflow.yaml, returning the first manifest found. Returns $null if none exists. #> param( [Parameter(Mandatory)] [string]$BotRoot ) # 1. Check installed workflows in .bot/workflows/ $wfDir = Join-Path $BotRoot "workflows" if (Test-Path $wfDir) { $first = Get-ChildItem $wfDir -Directory -ErrorAction SilentlyContinue | Select-Object -First 1 if ($first) { return Read-WorkflowManifest -WorkflowDir $first.FullName } } # 2. Check for workflow.yaml in .bot/ root (profile-installed) $rootManifest = Join-Path $BotRoot "workflow.yaml" if (Test-Path $rootManifest) { return Read-WorkflowManifest -WorkflowDir $BotRoot } # 3. No manifest found return $null } function Convert-ManifestRequiresToPreflightChecks { <# .SYNOPSIS Convert a manifest 'requires' block into flat preflight check objects. .DESCRIPTION Maps requires.env_vars, requires.mcp_servers, requires.cli_tools into the array-of-hashtable format expected by Get-PreflightResults and the UI. #> param( [Parameter(Mandatory)] [object]$Requires ) $checks = @() # env_vars $envVars = if ($Requires -is [System.Collections.IDictionary]) { $Requires['env_vars'] } else { $Requires.env_vars } if ($envVars) { foreach ($ev in @($envVars)) { $varName = if ($ev -is [System.Collections.IDictionary]) { $ev['var'] } else { $ev.var } $name = if ($ev -is [System.Collections.IDictionary]) { $ev['name'] } else { $ev.name } $message = if ($ev -is [System.Collections.IDictionary]) { $ev['message'] } else { $ev.message } $hint = if ($ev -is [System.Collections.IDictionary]) { $ev['hint'] } else { $ev.hint } if ($varName) { $checks += @{ type = 'env_var'; var = $varName; name = if ($name) { $name } else { $varName }; message = $message; hint = $hint } } } } # mcp_servers $mcpServers = if ($Requires -is [System.Collections.IDictionary]) { $Requires['mcp_servers'] } else { $Requires.mcp_servers } if ($mcpServers) { foreach ($ms in @($mcpServers)) { $srvName = if ($ms -is [System.Collections.IDictionary]) { $ms['name'] } else { $ms.name } $message = if ($ms -is [System.Collections.IDictionary]) { $ms['message'] } else { $ms.message } $hint = if ($ms -is [System.Collections.IDictionary]) { $ms['hint'] } else { $ms.hint } if ($srvName) { $checks += @{ type = 'mcp_server'; name = $srvName; message = $message; hint = $hint } } } } # cli_tools $cliTools = if ($Requires -is [System.Collections.IDictionary]) { $Requires['cli_tools'] } else { $Requires.cli_tools } if ($cliTools) { foreach ($ct in @($cliTools)) { $toolName = if ($ct -is [System.Collections.IDictionary]) { $ct['name'] } else { $ct.name } $message = if ($ct -is [System.Collections.IDictionary]) { $ct['message'] } else { $ct.message } $hint = if ($ct -is [System.Collections.IDictionary]) { $ct['hint'] } else { $ct.hint } if ($toolName) { $checks += @{ type = 'cli_tool'; name = $toolName; message = $message; hint = $hint } } } } return $checks } # Import with -Global so Test-ManifestCondition is visible to callers that # dot-source workflow-manifest.ps1 from inside a function/scriptblock scope # (e.g. server.ps1 and task-get-next/script.ps1). Without -Global, the # imported function ends up in a module scope that is not reached by the # lookup chain at some HTTP route handler call sites, producing intermittent # "The term 'Test-ManifestCondition' is not recognized" errors. Import-Module (Join-Path $PSScriptRoot "ManifestCondition.psm1") -Force -DisableNameChecking -Global function Ensure-ManifestTaskIds { <# .SYNOPSIS Ensure every task in the manifest tasks array has an id property. .DESCRIPTION Workflow manifest tasks may omit the id field. This function generates a slug-style id from the task name when missing, mutating the original objects so downstream code can rely on id being present. #> param( [Parameter(Mandatory)] [array]$Tasks ) foreach ($t in $Tasks) { $existingId = if ($t -is [System.Collections.IDictionary]) { $t['id'] } else { $t.id } if (-not $existingId) { $taskName = if ($t -is [System.Collections.IDictionary]) { $t['name'] } else { $t.name } $genId = ($taskName -replace '[^\w\s-]', '' -replace '\s+', '-').ToLower() if ($t -is [System.Collections.IDictionary]) { $t['id'] = $genId } else { $t | Add-Member -NotePropertyName 'id' -NotePropertyValue $genId -Force } } } } function Convert-ManifestTasksToPhases { <# .SYNOPSIS Convert manifest tasks array into phase-compatible objects for the UI. .DESCRIPTION Transforms each task into a hashtable with id, name, type and optional keys. As a side effect, this function calls Ensure-ManifestTaskIds which mutates the original input task objects by adding an 'id' property to any task that lacks one. Callers should be aware that the $Tasks array items will be modified in-place. #> param( [Parameter(Mandatory)] [array]$Tasks ) Ensure-ManifestTaskIds -Tasks $Tasks return @($Tasks | ForEach-Object { $task = $_ $name = if ($task -is [System.Collections.IDictionary]) { $task['name'] } else { $task.name } $type = if ($task -is [System.Collections.IDictionary]) { $task['type'] } else { $task.type } $optional = if ($task -is [System.Collections.IDictionary]) { $task['optional'] } else { $task.optional } @{ id = if ($task -is [System.Collections.IDictionary]) { $task['id'] } else { $task.id } name = $name type = if ($type) { $type } else { 'prompt' } optional = [bool]$optional } }) } function New-WorkflowTask { <# .SYNOPSIS Create a task JSON file from a manifest task definition. .DESCRIPTION Writes a task JSON file into the shared task queue (workspace/tasks/todo/). Sets the workflow field for filtering. Script paths are stored relative to the workflow directory. #> param( [Parameter(Mandatory)] [string]$ProjectBotDir, # .bot/ directory [Parameter(Mandatory)] [string]$WorkflowName, # e.g. "iwg-bs-scoring" [Parameter(Mandatory)] [hashtable]$TaskDef, # from workflow.yaml tasks array [string]$Category = "workflow", [string]$Effort = "XS" ) $tasksDir = Join-Path $ProjectBotDir "workspace\tasks\todo" if (-not (Test-Path $tasksDir)) { New-Item -Path $tasksDir -ItemType Directory -Force | Out-Null } $id = [System.Guid]::NewGuid().ToString() $now = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") # Extract fields — align with task-create MCP tool schema $name = $TaskDef['name'] $type = if ($TaskDef['type']) { $TaskDef['type'] } else { 'prompt' } $priority = if ($null -ne $TaskDef['priority']) { [int]$TaskDef['priority'] } else { 50 } $description = if ($TaskDef['description']) { $TaskDef['description'] } else { $name } $effort = if ($TaskDef['effort']) { $TaskDef['effort'] } else { $Effort } $category = if ($TaskDef['category']) { $TaskDef['category'] } else { $Category } $scriptPath = if ($TaskDef['script']) { $TaskDef['script'] } else { $TaskDef['script_path'] } $mcpTool = $TaskDef['mcp_tool'] $mcpArgs = $TaskDef['mcp_args'] # task_gen with a 'workflow' prompt file but no script_path → prompt_template # workflow.yaml uses type: task_gen + workflow: "02a-foo.md" to mean # "run Claude with this prompt to generate tasks". Map it to prompt_template # so the task-runner dispatches it correctly via the LLM path. $promptFromWorkflow = $null if ($type -eq 'task_gen' -and -not $scriptPath -and $TaskDef['workflow'] -and $TaskDef['workflow'] -match '\.md$') { $type = 'prompt_template' $promptFromWorkflow = "recipes/prompts/$($TaskDef['workflow'])" } # Dependencies: convert from manifest format (string names) $deps = @() if ($TaskDef['depends_on']) { $deps = @($TaskDef['depends_on']) } elseif ($TaskDef['dependencies']) { $deps = @($TaskDef['dependencies']) } # Boolean fields with type-aware defaults $skipAnalysis = if ($null -ne $TaskDef['skip_analysis']) { [bool]$TaskDef['skip_analysis'] } else { $type -ne 'prompt' } $skipWorktree = if ($null -ne $TaskDef['skip_worktree']) { [bool]$TaskDef['skip_worktree'] } else { $type -ne 'prompt' } $task = [ordered]@{ id = $id name = $name description = $description category = $category priority = $priority effort = $effort status = "todo" type = $type workflow = $WorkflowName dependencies = $deps skip_analysis = $skipAnalysis skip_worktree = $skipWorktree created_at = $now updated_at = $now completed_at = $null } # Optional fields — only set if declared (keeps task JSON clean) if ($scriptPath) { $task["script_path"] = $scriptPath } if ($promptFromWorkflow) { $task["prompt"] = $promptFromWorkflow } if ($mcpTool) { $task["mcp_tool"] = $mcpTool } if ($mcpArgs -and $mcpArgs.Count -gt 0) { $task["mcp_args"] = $mcpArgs } if ($TaskDef['acceptance_criteria']) { $task["acceptance_criteria"] = @($TaskDef['acceptance_criteria']) } if ($TaskDef['steps']) { $task["steps"] = @($TaskDef['steps']) } if ($TaskDef['applicable_agents']) { $task["applicable_agents"] = @($TaskDef['applicable_agents']) } if ($TaskDef['applicable_standards']) { $task["applicable_standards"] = @($TaskDef['applicable_standards']) } if ($TaskDef['needs_interview']) { $task["needs_interview"] = [bool]$TaskDef['needs_interview'] } if ($TaskDef['working_dir']) { $task["working_dir"] = $TaskDef['working_dir'] } if ($TaskDef['human_hours']) { $task["human_hours"] = $TaskDef['human_hours'] } if ($TaskDef['ai_hours']) { $task["ai_hours"] = $TaskDef['ai_hours'] } if ($TaskDef['prompt']) { $task["prompt"] = $TaskDef['prompt'] } if ($TaskDef['max_concurrent']) { $task["max_concurrent"] = [int]$TaskDef['max_concurrent'] } if ($TaskDef['timeout']) { $task["timeout"] = [int]$TaskDef['timeout'] } if ($TaskDef['retry']) { $task["retry"] = [int]$TaskDef['retry'] } if ($TaskDef['on_failure']) { $task["on_failure"] = $TaskDef['on_failure'] } if ($TaskDef['condition']) { $task["condition"] = $TaskDef['condition'] } if ($TaskDef['outputs']) { $task["outputs"] = @($TaskDef['outputs']) } if ($TaskDef['env']) { $task["env"] = $TaskDef['env'] } if ($TaskDef['post_script']) { $task["post_script"] = $TaskDef['post_script'] } $slug = ($name -replace '[^\w\s-]', '' -replace '\s+', '-').ToLower() if ($slug.Length -gt 50) { $slug = $slug.Substring(0, 50) } $fileName = "$slug-$($id.Split('-')[0]).json" $filePath = Join-Path $tasksDir $fileName $task | ConvertTo-Json -Depth 10 | Set-Content -Path $filePath -Encoding UTF8 return @{ id = $id; name = $name; file = $fileName } } function Merge-McpServers { <# .SYNOPSIS Merge workflow's mcp_servers into the project's .mcp.json. .DESCRIPTION For each server declared in the workflow manifest, adds it to .mcp.json if a server with that name doesn't already exist. Skips existing entries. #> param( [Parameter(Mandatory)] [string]$McpJsonPath, [Parameter(Mandatory)] [object]$WorkflowServers # hashtable or PSCustomObject from manifest ) $mcpConfig = @{ mcpServers = [ordered]@{} } if (Test-Path $McpJsonPath) { try { $mcpConfig = Get-Content $McpJsonPath -Raw | ConvertFrom-Json if (-not $mcpConfig.mcpServers) { $mcpConfig | Add-Member -NotePropertyName 'mcpServers' -NotePropertyValue ([ordered]@{}) -Force } } catch { $mcpConfig = @{ mcpServers = [ordered]@{} } } } $existing = $mcpConfig.mcpServers $added = 0 # Handle both hashtable and PSCustomObject $serverEntries = if ($WorkflowServers -is [System.Collections.IDictionary]) { $WorkflowServers.GetEnumerator() } elseif ($WorkflowServers.PSObject) { $WorkflowServers.PSObject.Properties } else { @() } foreach ($entry in $serverEntries) { $serverName = $entry.Name $serverDef = $entry.Value # Skip if already exists $existsAlready = $false if ($existing -is [PSCustomObject]) { $existsAlready = $existing.PSObject.Properties.Name -contains $serverName } elseif ($existing -is [System.Collections.IDictionary]) { $existsAlready = $existing.Contains($serverName) } if (-not $existsAlready) { if ($existing -is [PSCustomObject]) { $existing | Add-Member -NotePropertyName $serverName -NotePropertyValue $serverDef -Force } else { $existing[$serverName] = $serverDef } $added++ } } if ($added -gt 0) { $mcpConfig | ConvertTo-Json -Depth 5 | Set-Content -Path $McpJsonPath -Encoding UTF8 } return $added } function Remove-OrphanMcpServers { <# .SYNOPSIS Remove MCP servers from .mcp.json that no installed workflow claims. .DESCRIPTION Reads all installed workflow manifests, collects their declared servers, and removes any server from .mcp.json that isn't claimed by at least one workflow (or is a core server like dotbot, context7, playwright). #> param( [Parameter(Mandatory)] [string]$McpJsonPath, [Parameter(Mandatory)] [string]$WorkflowsDir # .bot/workflows/ ) $coreServers = @('dotbot', 'context7', 'playwright') if (-not (Test-Path $McpJsonPath)) { return 0 } # Collect all servers claimed by installed workflows $claimed = @{} if (Test-Path $WorkflowsDir) { Get-ChildItem $WorkflowsDir -Directory | ForEach-Object { $manifest = Read-WorkflowManifest -WorkflowDir $_.FullName if ($manifest.mcp_servers) { $servers = if ($manifest.mcp_servers -is [System.Collections.IDictionary]) { $manifest.mcp_servers.Keys } elseif ($manifest.mcp_servers.PSObject) { $manifest.mcp_servers.PSObject.Properties.Name } else { @() } foreach ($s in $servers) { $claimed[$s] = $true } } } } # Add core servers as always-claimed foreach ($s in $coreServers) { $claimed[$s] = $true } $mcpConfig = Get-Content $McpJsonPath -Raw | ConvertFrom-Json $existing = $mcpConfig.mcpServers $removed = 0 if ($existing -is [PSCustomObject]) { foreach ($name in @($existing.PSObject.Properties.Name)) { if (-not $claimed.ContainsKey($name)) { $existing.PSObject.Properties.Remove($name) $removed++ } } } if ($removed -gt 0) { $mcpConfig | ConvertTo-Json -Depth 5 | Set-Content -Path $McpJsonPath -Encoding UTF8 } return $removed } function New-EnvLocalScaffold { <# .SYNOPSIS Create or update .env.local with required variables from workflow manifests. #> param( [Parameter(Mandatory)] [string]$EnvLocalPath, [Parameter(Mandatory)] [array]$EnvVars # array of @{ var, name, hint } ) # Read existing values $existing = @{} if (Test-Path $EnvLocalPath) { Get-Content $EnvLocalPath | ForEach-Object { if ($_ -match '^\s*([^#][^=]+)=(.*)$') { $existing[$matches[1].Trim()] = $matches[2].Trim() } } } # Build content: preserve existing values, add missing with hints $lines = @() foreach ($ev in $EnvVars) { $varName = $ev.var if (-not $varName) { $varName = $ev['var'] } $hint = if ($ev.hint) { $ev.hint } elseif ($ev['hint']) { $ev['hint'] } else { "" } $displayName = if ($ev.name) { $ev.name } elseif ($ev['name']) { $ev['name'] } else { $varName } if ($existing.ContainsKey($varName)) { $lines += "$varName=$($existing[$varName])" } else { if ($hint) { $lines += "# $displayName — $hint" } $lines += "$varName=" } } # Preserve any extra vars not in the manifest foreach ($key in $existing.Keys) { $declared = $EnvVars | Where-Object { ($_.var -eq $key) -or ($_['var'] -eq $key) } if (-not $declared) { $lines += "$key=$($existing[$key])" } } Set-Content -Path $EnvLocalPath -Value ($lines -join "`n") -Encoding UTF8 } function Clear-WorkflowTasks { <# .SYNOPSIS Remove all tasks belonging to a specific workflow from all task queues. #> param( [Parameter(Mandatory)] [string]$TasksBaseDir, # .bot/workspace/tasks [Parameter(Mandatory)] [string]$WorkflowName ) $removed = 0 foreach ($status in @('todo', 'analysing', 'needs-input', 'analysed', 'in-progress', 'done', 'skipped', 'cancelled', 'split')) { $dir = Join-Path $TasksBaseDir $status if (-not (Test-Path $dir)) { continue } Get-ChildItem $dir -Filter "*.json" -File | ForEach-Object { try { $content = Get-Content $_.FullName -Raw | ConvertFrom-Json if ($content.workflow -eq $WorkflowName) { Remove-Item $_.FullName -Force $removed++ } } catch { Write-BotLog -Level Debug -Message "Cleanup: failed to remove item" -Exception $_ } } } return $removed } |