workflows/default/systems/mcp/modules/TaskIndexCache.psm1
|
<# .SYNOPSIS Task index module - reads task files fresh on each access .DESCRIPTION Provides functions to query tasks from the filesystem. No caching - always reads fresh data to avoid stale state issues. #> $script:TaskIndex = @{ Todo = @{} # id -> task metadata Analysing = @{} # Tasks currently being analysed NeedsInput = @{} # Tasks waiting for human input Analysed = @{} # Tasks ready for implementation InProgress = @{} Done = @{} Split = @{} # Tasks that were split into sub-tasks Skipped = @{} # Tasks that were skipped Cancelled = @{} # Tasks that were cancelled DoneIds = @() # Quick lookup for dependency checking (by id) DoneNames = @() # Quick lookup for dependency checking (by name) DoneSlugs = @() # Quick lookup for dependency checking (by slug) IgnoreMap = @{} # Effective ignore state for todo tasks BaseDir = $null } function Initialize-TaskIndex { param( [Parameter(Mandatory = $true)] [string]$TasksBaseDir, [array]$TodoTasks ) # Ensure directory exists if (-not (Test-Path $TasksBaseDir)) { New-Item -Path $TasksBaseDir -ItemType Directory -Force | Out-Null } $script:TaskIndex.BaseDir = $TasksBaseDir } function Get-IgnoreTaskPriorityValue { param( [object]$Task ) try { if ($null -ne $Task.priority -and "$($Task.priority)".Trim()) { return [int]$Task.priority } } catch { # Leave malformed priorities at the end of ordinal alias resolution. } return [int]::MaxValue } function Add-IgnoreReferenceAlias { param( [Parameter(Mandatory)] [hashtable]$ReferenceMap, [Parameter(Mandatory)] [string]$TaskId, [object]$Alias ) if ($null -eq $Alias) { return } $normalizedAlias = "$Alias".Trim().ToLower() if (-not $normalizedAlias) { return } $ReferenceMap[$normalizedAlias] = $TaskId } function Get-IgnoreDependencyTokens { param( [object]$Dependency ) if ($null -eq $Dependency) { return @() } $rawDependency = "$Dependency".Trim() if (-not $rawDependency) { return @() } $tokens = [System.Collections.Generic.List[string]]::new() function Add-IgnoreDependencyToken { param( [string]$Value ) if (-not $Value) { return } $normalizedValue = $Value.Trim().ToLower() if ($normalizedValue -and -not $tokens.Contains($normalizedValue)) { $null = $tokens.Add($normalizedValue) } } Add-IgnoreDependencyToken -Value $rawDependency $ordinalMatch = [regex]::Match($rawDependency, '^tasks?\s+(.+)$', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) if ($ordinalMatch.Success) { $ordinalExpression = $ordinalMatch.Groups[1].Value.Trim() Add-IgnoreDependencyToken -Value $ordinalExpression foreach ($part in ($ordinalExpression -split '\s*,\s*')) { $normalizedPart = [regex]::Replace($part, '^tasks?\s+', '', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase).Trim() if (-not $normalizedPart) { continue } Add-IgnoreDependencyToken -Value $normalizedPart Add-IgnoreDependencyToken -Value "task $normalizedPart" } return @($tokens) } foreach ($part in ($rawDependency -split '\s*,\s*')) { Add-IgnoreDependencyToken -Value $part } return @($tokens) } function Get-IgnoreRoadmapDependencyMap { param( [string]$TasksBaseDir ) $workspaceDir = Split-Path -Parent $TasksBaseDir $overviewPath = Join-Path $workspaceDir "product\roadmap-overview.md" $dependencyMap = @{} if (-not (Test-Path $overviewPath)) { return $dependencyMap } foreach ($line in @(Get-Content -Path $overviewPath -ErrorAction SilentlyContinue)) { if ($line -notmatch '^\|\s*\d+\s*\|') { continue } $cells = ($line.Trim().Trim('|') -split '\s*\|\s*') if ($cells.Count -lt 5) { continue } $methodologyMatch = [regex]::Match($cells[2], '`([^`]+)`') if (-not $methodologyMatch.Success) { continue } $methodologyKey = $methodologyMatch.Groups[1].Value.Trim().ToLower() if (-not $methodologyKey) { continue } $dependencyText = $cells[3].Trim() if (-not $dependencyText -or $dependencyText -match '^(none|n/a)$') { $dependencyMap[$methodologyKey] = @() continue } $dependencyMap[$methodologyKey] = @($dependencyText) } return $dependencyMap } function Get-ResolvedIgnoreDependencies { param( [Parameter(Mandatory)] [object]$Task, [Parameter(Mandatory)] [hashtable]$RoadmapDependencyMap ) $explicitDependencies = @(@($Task.dependencies) | Where-Object { $null -ne $_ -and "$($_)".Trim() }) if ($explicitDependencies.Count -gt 0) { return $explicitDependencies } $researchPrompt = "$($Task.research_prompt)".Trim().ToLower() if ($researchPrompt -and $RoadmapDependencyMap.ContainsKey($researchPrompt)) { return @($RoadmapDependencyMap[$researchPrompt]) } return @() } function Get-TaskIgnoreLookup { param( [string]$TasksBaseDir, [array]$TodoTasks ) $todoDir = Join-Path $TasksBaseDir 'todo' if (-not (Test-Path $todoDir)) { return @{} } $tasks = @{} $references = @{} $orderedTasks = [System.Collections.Generic.List[object]]::new() $roadmapDependencyMap = Get-IgnoreRoadmapDependencyMap -TasksBaseDir $TasksBaseDir if ($TodoTasks) { foreach ($task in @($TodoTasks)) { if (-not $task.id) { continue } $tasks[$task.id] = $task $null = $orderedTasks.Add($task) } } else { foreach ($file in @(Get-ChildItem -Path $todoDir -Filter '*.json' -File -ErrorAction SilentlyContinue)) { try { $task = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json if (-not $task.id) { continue } $tasks[$task.id] = $task $null = $orderedTasks.Add($task) } catch { Write-BotLog -Level Warn -Message "[TaskIndex] Failed to read ignore state from '$($file.FullName)'" -Exception $_ } } } $position = 0 foreach ($task in @($orderedTasks) | Sort-Object @{ Expression = { Get-IgnoreTaskPriorityValue -Task $_ } }, @{ Expression = { $_.name } }, @{ Expression = { $_.id } }) { $position += 1 Add-IgnoreReferenceAlias -ReferenceMap $references -TaskId $task.id -Alias $task.id Add-IgnoreReferenceAlias -ReferenceMap $references -TaskId $task.id -Alias $task.name $slug = (($task.name -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-').ToLower()) if ($slug) { Add-IgnoreReferenceAlias -ReferenceMap $references -TaskId $task.id -Alias $slug } Add-IgnoreReferenceAlias -ReferenceMap $references -TaskId $task.id -Alias $position Add-IgnoreReferenceAlias -ReferenceMap $references -TaskId $task.id -Alias "task $position" } $memo = @{} $resolving = @{} function Resolve-TaskIgnoreState { param( [string]$TaskId ) if ($memo.ContainsKey($TaskId)) { return $memo[$TaskId] } if ($resolving.ContainsKey($TaskId)) { return [pscustomobject]@{ task_id = $TaskId manual = $false effective = $false auto = $false blocking_task_ids = @() blocking_task_names = @() updated_at = $null updated_by = $null } } $resolving[$TaskId] = $true $task = $tasks[$TaskId] $manualIgnored = $false $updatedAt = $null $updatedBy = $null if ($task.PSObject.Properties['ignore']) { $manualIgnored = ($task.ignore.manual -eq $true) $updatedAt = $task.ignore.updated_at $updatedBy = $task.ignore.updated_by } $blockingIds = [System.Collections.Generic.List[string]]::new() $dependencies = Get-ResolvedIgnoreDependencies -Task $task -RoadmapDependencyMap $roadmapDependencyMap foreach ($dependency in $dependencies) { if (-not $dependency) { continue } $dependencyTaskIds = [System.Collections.Generic.List[string]]::new() foreach ($lookupKey in (Get-IgnoreDependencyTokens -Dependency $dependency)) { if (-not $references.ContainsKey($lookupKey)) { continue } $resolvedDependencyTaskId = $references[$lookupKey] if (-not $tasks.ContainsKey($resolvedDependencyTaskId) -or $dependencyTaskIds.Contains($resolvedDependencyTaskId)) { continue } $dependencyTaskIds.Add($resolvedDependencyTaskId) } foreach ($dependencyTaskId in $dependencyTaskIds) { $dependencyState = Resolve-TaskIgnoreState -TaskId $dependencyTaskId if (-not $dependencyState.effective) { continue } if ($dependencyState.manual) { if (-not $blockingIds.Contains($dependencyTaskId)) { $blockingIds.Add($dependencyTaskId) } continue } if ($dependencyState.blocking_task_ids.Count -gt 0) { foreach ($blockingTaskId in $dependencyState.blocking_task_ids) { if (-not $blockingIds.Contains($blockingTaskId)) { $blockingIds.Add($blockingTaskId) } } continue } if (-not $blockingIds.Contains($dependencyTaskId)) { $blockingIds.Add($dependencyTaskId) } } } $blockingNames = foreach ($blockingTaskId in $blockingIds) { if ($tasks.ContainsKey($blockingTaskId) -and $tasks[$blockingTaskId].name) { $tasks[$blockingTaskId].name } else { $blockingTaskId } } $state = [pscustomobject]@{ task_id = $TaskId manual = $manualIgnored effective = ($manualIgnored -or $blockingIds.Count -gt 0) auto = (-not $manualIgnored -and $blockingIds.Count -gt 0) blocking_task_ids = @($blockingIds) blocking_task_names = @($blockingNames | Select-Object -Unique) updated_at = $updatedAt updated_by = $updatedBy } $memo[$TaskId] = $state $resolving.Remove($TaskId) | Out-Null return $state } $ignoreMap = @{} foreach ($taskId in @($tasks.Keys)) { $ignoreMap[$taskId] = Resolve-TaskIgnoreState -TaskId $taskId } return $ignoreMap } function Update-TaskIndex { $taskMutationModulePath = (Get-Module TaskMutation | Select-Object -ExpandProperty Path -First 1) $baseDir = $script:TaskIndex.BaseDir if (-not $baseDir) { Write-BotLog -Level Debug -Message "[TaskIndex] BaseDir not set, skipping update" return } $script:TaskIndex.Todo = @{} $script:TaskIndex.Analysing = @{} $script:TaskIndex.NeedsInput = @{} $script:TaskIndex.Analysed = @{} $script:TaskIndex.InProgress = @{} $script:TaskIndex.Done = @{} $script:TaskIndex.Split = @{} $script:TaskIndex.Skipped = @{} $script:TaskIndex.Cancelled = @{} $script:TaskIndex.DoneIds = @() $script:TaskIndex.DoneNames = @() $script:TaskIndex.DoneSlugs = @() $script:TaskIndex.IgnoreMap = @{} foreach ($status in @('todo', 'analysing', 'needs-input', 'analysed', 'in-progress', 'done', 'split', 'skipped', 'cancelled')) { $dir = Join-Path $baseDir $status if (-not (Test-Path $dir)) { continue } $files = Get-ChildItem -Path $dir -Filter "*.json" -File -ErrorAction SilentlyContinue foreach ($file in $files) { try { if (-not (Test-Path $file.FullName)) { continue } $content = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json $entry = [PSCustomObject]@{ id = $content.id name = $content.name description = $content.description category = $content.category priority = [int]$content.priority effort = $content.effort dependencies = $content.dependencies acceptance_criteria = $content.acceptance_criteria steps = $content.steps applicable_agents = $content.applicable_agents applicable_standards = $content.applicable_standards file_path = $file.FullName last_write = $file.LastWriteTimeUtc started_at = $content.started_at completed_at = $content.completed_at needs_interview = $content.needs_interview working_dir = $content.working_dir external_repo = $content.external_repo research_prompt = $content.research_prompt ignore = $content.ignore type = $content.type script_path = $content.script_path prompt = $content.prompt mcp_tool = $content.mcp_tool mcp_args = $content.mcp_args skip_analysis = $content.skip_analysis skip_worktree = $content.skip_worktree workflow = $content.workflow condition = $content.condition } switch ($status) { 'todo' { $script:TaskIndex.Todo[$content.id] = $entry } 'analysing' { $script:TaskIndex.Analysing[$content.id] = $entry } 'needs-input' { $script:TaskIndex.NeedsInput[$content.id] = $entry } 'analysed' { $script:TaskIndex.Analysed[$content.id] = $entry } 'in-progress' { $script:TaskIndex.InProgress[$content.id] = $entry } 'done' { $script:TaskIndex.Done[$content.id] = $entry $script:TaskIndex.DoneIds += $content.id $script:TaskIndex.DoneNames += $content.name # Also store slug version of name for dependency matching $slug = ($content.name -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-').ToLower() $script:TaskIndex.DoneSlugs += $slug } 'split' { $script:TaskIndex.Split[$content.id] = $entry # Split tasks satisfy dependencies — work delegated to sub-tasks $script:TaskIndex.DoneIds += $content.id $script:TaskIndex.DoneNames += $content.name $slug = ($content.name -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-').ToLower() $script:TaskIndex.DoneSlugs += $slug } 'skipped' { $script:TaskIndex.Skipped[$content.id] = $entry } 'cancelled' { $script:TaskIndex.Cancelled[$content.id] = $entry } } } catch { Write-BotLog -Level Warn -Message "[TaskIndex] Failed to read: $($file.FullName)" -Exception $_ } } } # Dedup: if a task exists in multiple state directories (stale copies), # keep only the most advanced state to prevent double-pickup $seenIds = @{} foreach ($bucket in @( $script:TaskIndex.Done, $script:TaskIndex.Skipped, $script:TaskIndex.Cancelled, $script:TaskIndex.Split, $script:TaskIndex.InProgress, $script:TaskIndex.Analysed, $script:TaskIndex.NeedsInput, $script:TaskIndex.Analysing, $script:TaskIndex.Todo )) { foreach ($taskId in @($bucket.Keys)) { if ($seenIds.ContainsKey($taskId)) { $bucket.Remove($taskId) } else { $seenIds[$taskId] = $true } } } $script:TaskIndex.IgnoreMap = Get-TaskIgnoreLookup -TasksBaseDir $baseDir -TodoTasks @($script:TaskIndex.Todo.Values) foreach ($taskId in @($script:TaskIndex.Todo.Keys)) { if (-not $script:TaskIndex.IgnoreMap.ContainsKey($taskId)) { continue } if ($script:TaskIndex.Todo[$taskId].PSObject.Properties['ignore_state']) { $script:TaskIndex.Todo[$taskId].ignore_state = $script:TaskIndex.IgnoreMap[$taskId] } else { $script:TaskIndex.Todo[$taskId] | Add-Member -NotePropertyName 'ignore_state' -NotePropertyValue $script:TaskIndex.IgnoreMap[$taskId] -Force } } if ($taskMutationModulePath -and -not (Get-Module TaskMutation)) { Import-Module $taskMutationModulePath -Global -Force | Out-Null } } function Get-TaskIndex { # Always rebuild - no caching Update-TaskIndex return $script:TaskIndex } function Get-TodoTasks { param( [string]$Category, [string]$Effort, [int]$MinPriority, [int]$MaxPriority, [int]$Limit = 0 ) $index = Get-TaskIndex $tasks = @($index.Todo.Values) if ($Category) { $tasks = $tasks | Where-Object { $_.category -eq $Category } } if ($Effort) { $tasks = $tasks | Where-Object { $_.effort -eq $Effort } } if ($MinPriority -gt 0) { $tasks = $tasks | Where-Object { $_.priority -ge $MinPriority } } if ($MaxPriority -gt 0) { $tasks = $tasks | Where-Object { $_.priority -le $MaxPriority } } $tasks = $tasks | Sort-Object priority if ($Limit -gt 0) { $tasks = $tasks | Select-Object -First $Limit } return @($tasks) } function Get-InProgressTasks { $index = Get-TaskIndex return @($index.InProgress.Values) } function Get-AnalysingTasks { $index = Get-TaskIndex return @($index.Analysing.Values) } function Get-NeedsInputTasks { $index = Get-TaskIndex return @($index.NeedsInput.Values) } function Get-AnalysedTasks { param( [int]$Limit = 0 ) $index = Get-TaskIndex $tasks = @($index.Analysed.Values) | Sort-Object priority if ($Limit -gt 0) { $tasks = $tasks | Select-Object -First $Limit } return @($tasks) } function Get-SplitTasks { $index = Get-TaskIndex return @($index.Split.Values) } function Get-SkippedTasks { $index = Get-TaskIndex return @($index.Skipped.Values) } function Get-CancelledTasks { $index = Get-TaskIndex return @($index.Cancelled.Values) } function Get-DoneTasks { param( [int]$Limit = 0 ) $index = Get-TaskIndex $tasks = @($index.Done.Values) | Sort-Object { [DateTime]$_.completed_at } -Descending if ($Limit -gt 0) { $tasks = $tasks | Select-Object -First $Limit } return @($tasks) } function Get-AllTasks { param( [string]$Status, [string]$Category, [string]$Effort, [int]$MinPriority, [int]$MaxPriority, [int]$Limit = 0 ) $index = Get-TaskIndex $tasks = @() # Determine which collections to include if (-not $Status -or $Status -eq 'todo') { $tasks += @($index.Todo.Values) } if (-not $Status -or $Status -eq 'analysing') { $tasks += @($index.Analysing.Values) } if (-not $Status -or $Status -eq 'needs-input') { $tasks += @($index.NeedsInput.Values) } if (-not $Status -or $Status -eq 'analysed') { $tasks += @($index.Analysed.Values) } if (-not $Status -or $Status -eq 'in-progress') { $tasks += @($index.InProgress.Values) } if (-not $Status -or $Status -eq 'done') { $tasks += @($index.Done.Values) } if (-not $Status -or $Status -eq 'split') { $tasks += @($index.Split.Values) } if (-not $Status -or $Status -eq 'skipped') { $tasks += @($index.Skipped.Values) } if (-not $Status -or $Status -eq 'cancelled') { $tasks += @($index.Cancelled.Values) } # Apply filters if ($Category) { $tasks = $tasks | Where-Object { $_.category -eq $Category } } if ($Effort) { $tasks = $tasks | Where-Object { $_.effort -eq $Effort } } if ($MinPriority -gt 0) { $tasks = $tasks | Where-Object { $_.priority -ge $MinPriority } } if ($MaxPriority -gt 0) { $tasks = $tasks | Where-Object { $_.priority -le $MaxPriority } } $tasks = $tasks | Sort-Object priority if ($Limit -gt 0) { $tasks = $tasks | Select-Object -First $Limit } return @($tasks) } function Test-DependencyMet { param( [string]$Dependency, [array]$DoneNames, [array]$DoneSlugs, [array]$DoneIds ) $depLower = $Dependency.ToLower() # Exact match on ID if ($Dependency -in $DoneIds) { return $true } # Exact match on name if ($Dependency -in $DoneNames) { return $true } # Exact match on slug if ($depLower -in $DoneSlugs) { return $true } # No fuzzy matching - dependencies must be exact # If a dependency doesn't exist, the task should not proceed return $false } function Test-AllDependenciesMet { param( [object]$Task, [array]$DoneNames, [array]$DoneSlugs, [array]$DoneIds ) if (-not $Task.dependencies -or $Task.dependencies.Count -eq 0) { return $true } # Handle both string and array dependencies $deps = if ($Task.dependencies -is [array]) { $Task.dependencies } else { @($Task.dependencies) } $unmet = $deps | Where-Object { -not (Test-DependencyMet -Dependency $_ -DoneNames $DoneNames -DoneSlugs $DoneSlugs -DoneIds $DoneIds) } return $unmet.Count -eq 0 } function Get-NextTask { param([string]$WorkflowFilter) $index = Get-TaskIndex $doneNames = $index.DoneNames $doneSlugs = $index.DoneSlugs $doneIds = $index.DoneIds # Filter tasks with unmet dependencies or effective ignore state $eligible = @($index.Todo.Values) | Where-Object { $ignoreState = if ($index.IgnoreMap.ContainsKey($_.id)) { $index.IgnoreMap[$_.id] } else { $null } (-not $ignoreState -or -not $ignoreState.effective) -and (Test-AllDependenciesMet -Task $_ -DoneNames $doneNames -DoneSlugs $doneSlugs -DoneIds $doneIds) } # Apply workflow filter if specified if ($WorkflowFilter) { $eligible = @($eligible | Where-Object { $_.workflow -eq $WorkflowFilter }) } # Return highest priority (lowest number) return $eligible | Sort-Object priority | Select-Object -First 1 } function Get-NextAnalysedTask { param([string]$WorkflowFilter) $index = Get-TaskIndex $doneNames = $index.DoneNames $doneSlugs = $index.DoneSlugs $doneIds = $index.DoneIds # Filter analysed tasks with unmet dependencies or effective ignore state $eligible = @($index.Analysed.Values) | Where-Object { $ignoreState = if ($index.IgnoreMap.ContainsKey($_.id)) { $index.IgnoreMap[$_.id] } else { $null } (-not $ignoreState -or -not $ignoreState.effective) -and (Test-AllDependenciesMet -Task $_ -DoneNames $doneNames -DoneSlugs $doneSlugs -DoneIds $doneIds) } # Apply workflow filter if specified if ($WorkflowFilter) { $eligible = @($eligible | Where-Object { $_.workflow -eq $WorkflowFilter }) } $total = @($index.Analysed.Values).Count $blockedCount = $total - @($eligible).Count # Return highest priority (lowest number) + blocked count for reporting $next = $eligible | Sort-Object priority | Select-Object -First 1 return @{ Task = $next BlockedCount = $blockedCount TotalCount = $total } } function Get-DeadlockedTasks { <# .SYNOPSIS Returns info about todo tasks that are blocked by at least one skipped dependency. .DESCRIPTION Called when Get-NextTask returns null to distinguish a dependency deadlock from a genuine wait (e.g. analysis still running). Returns a PSCustomObject with BlockedCount (number of blocked todo tasks) and BlockerNames (skipped task names causing the block). #> $index = Get-TaskIndex if ($index.Todo.Count -eq 0) { return [PSCustomObject]@{ BlockedCount = 0; BlockerNames = @() } } if ($index.Skipped.Count -eq 0) { return [PSCustomObject]@{ BlockedCount = 0; BlockerNames = @() } } # Build a case-insensitive lookup set covering id, name, and slug of every # skipped task so dependency strings can be matched in one Contains() call. $skippedLookup = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) # Map from any form (id/name/slug) back to the task name for reporting. $skippedNameMap = @{} foreach ($t in $index.Skipped.Values) { $skippedLookup.Add($t.id) | Out-Null $skippedLookup.Add($t.name) | Out-Null $slug = ($t.name -replace '[^a-zA-Z0-9\s-]', '' -replace '\s+', '-').ToLower() $skippedLookup.Add($slug) | Out-Null $skippedNameMap[$t.id] = $t.name $skippedNameMap[$t.name] = $t.name $skippedNameMap[$slug] = $t.name } $count = 0 $blockerNames = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) foreach ($task in $index.Todo.Values) { $deps = if ($task.dependencies -is [array]) { @($task.dependencies) } elseif ($task.dependencies) { @($task.dependencies) } else { @() } foreach ($dep in $deps) { if (-not $dep) { continue } # If the dependency is already satisfied by a done/split task, skip it. if (Test-DependencyMet -Dependency $dep ` -DoneNames $index.DoneNames ` -DoneSlugs $index.DoneSlugs ` -DoneIds $index.DoneIds) { continue } # The dependency is unmet — is the blocker a skipped task? if ($skippedLookup.Contains($dep)) { $count++ $blockerNames.Add($skippedNameMap[$dep]) | Out-Null break # count each blocked task only once } } } return [PSCustomObject]@{ BlockedCount = $count; BlockerNames = @($blockerNames | Sort-Object) } } function Test-TaskDone { param( [Parameter(Mandatory = $true)] [string]$TaskId ) $index = Get-TaskIndex return $TaskId -in $index.DoneIds } function Get-TaskById { param( [Parameter(Mandatory = $true)] [string]$TaskId ) $index = Get-TaskIndex if ($index.Todo.ContainsKey($TaskId)) { return $index.Todo[$TaskId] } if ($index.Analysing.ContainsKey($TaskId)) { return $index.Analysing[$TaskId] } if ($index.NeedsInput.ContainsKey($TaskId)) { return $index.NeedsInput[$TaskId] } if ($index.Analysed.ContainsKey($TaskId)) { return $index.Analysed[$TaskId] } if ($index.InProgress.ContainsKey($TaskId)) { return $index.InProgress[$TaskId] } if ($index.Done.ContainsKey($TaskId)) { return $index.Done[$TaskId] } if ($index.Split.ContainsKey($TaskId)) { return $index.Split[$TaskId] } if ($index.Skipped.ContainsKey($TaskId)) { return $index.Skipped[$TaskId] } if ($index.Cancelled.ContainsKey($TaskId)) { return $index.Cancelled[$TaskId] } return $null } function Get-TaskStats { $index = Get-TaskIndex $stats = @{ total = $index.Todo.Count + $index.Analysing.Count + $index.NeedsInput.Count + $index.Analysed.Count + $index.InProgress.Count + $index.Done.Count + $index.Split.Count + $index.Skipped.Count + $index.Cancelled.Count todo = $index.Todo.Count analysing = $index.Analysing.Count needs_input = $index.NeedsInput.Count analysed = $index.Analysed.Count in_progress = $index.InProgress.Count done = $index.Done.Count split = $index.Split.Count skipped = $index.Skipped.Count cancelled = $index.Cancelled.Count by_category = @{} by_effort = @{} by_priority_range = @{ high = 0 # 1-20 medium = 0 # 21-50 low = 0 # 51-100 } } $allTasks = @($index.Todo.Values) + @($index.Analysing.Values) + @($index.NeedsInput.Values) + @($index.Analysed.Values) + @($index.InProgress.Values) + @($index.Done.Values) + @($index.Skipped.Values) + @($index.Cancelled.Values) foreach ($task in $allTasks) { # Count by category if ($task.category) { if (-not $stats.by_category[$task.category]) { $stats.by_category[$task.category] = 0 } $stats.by_category[$task.category]++ } # Count by effort if ($task.effort) { if (-not $stats.by_effort[$task.effort]) { $stats.by_effort[$task.effort] = 0 } $stats.by_effort[$task.effort]++ } # Count by priority range if ($task.priority) { $priority = [int]$task.priority if ($priority -le 20) { $stats.by_priority_range.high++ } elseif ($priority -le 50) { $stats.by_priority_range.medium++ } else { $stats.by_priority_range.low++ } } } return $stats } function Get-RemainingEffort { $index = Get-TaskIndex $effort_mapping = @{ 'XS' = 1 'S' = 2.5 'M' = 5 'L' = 10 'XL' = 15 } $days_remaining = 0 # Include all tasks that still need work (not done or split) $allRemaining = @($index.Todo.Values) + @($index.Analysing.Values) + @($index.NeedsInput.Values) + @($index.Analysed.Values) + @($index.InProgress.Values) foreach ($task in $allRemaining) { if ($task.effort -and $effort_mapping[$task.effort]) { $days_remaining += $effort_mapping[$task.effort] } else { $days_remaining += 5 # Default to M if not specified } } return [Math]::Round($days_remaining, 1) } # Keep for backwards compatibility but now a no-op function Reset-TaskIndex { # No-op - index is always fresh } # Keep for backwards compatibility but now a no-op function Stop-TaskIndexWatcher { # No-op - no watcher to stop } Export-ModuleMember -Function @( 'Initialize-TaskIndex', 'Update-TaskIndex', 'Get-TaskIndex', 'Get-TodoTasks', 'Get-AnalysingTasks', 'Get-NeedsInputTasks', 'Get-AnalysedTasks', 'Get-InProgressTasks', 'Get-DoneTasks', 'Get-SplitTasks', 'Get-SkippedTasks', 'Get-CancelledTasks', 'Get-AllTasks', 'Get-NextTask', 'Get-NextAnalysedTask', 'Get-DeadlockedTasks', 'Test-TaskDone', 'Test-DependencyMet', 'Test-AllDependenciesMet', 'Get-TaskById', 'Get-TaskStats', 'Get-RemainingEffort', 'Reset-TaskIndex', 'Stop-TaskIndexWatcher' ) |