extensions/specrew-speckit/scripts/scaffold-iteration-plan.ps1

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$SpecPath,

    [Parameter(Mandatory = $true)]
    [string]$IterationNumber,

    [string[]]$RequirementScope,
    [string]$IterationConfigPath,
    [switch]$DryRun,
    [switch]$PassThru
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$sharedGovernancePath = Join-Path $PSScriptRoot 'shared-governance.ps1'
if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) {
    throw "Missing shared governance helper '$sharedGovernancePath'."
}
. $sharedGovernancePath

function Add-ScaffoldAction {
    param(
        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions,

        [Parameter(Mandatory = $true)]
        [string]$Action,

        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    $null = $Actions.Add([pscustomobject]@{
            Action = $Action
            Path   = $Path
        })
}

function Ensure-Directory {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions
    )

    if (Test-Path -LiteralPath $Path) {
        Add-ScaffoldAction -Actions $Actions -Action 'preserved-directory' -Path $Path
        return
    }

    Add-ScaffoldAction -Actions $Actions -Action $(if ($DryRun) { 'would-create-directory' } else { 'created-directory' }) -Path $Path
    if (-not $DryRun) {
        New-Item -ItemType Directory -Path $Path -Force | Out-Null
    }
}

function Write-MissingFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TargetPath,

        [Parameter(Mandatory = $true)]
        [string]$Content,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions
    )

    if (Test-Path -LiteralPath $TargetPath) {
        Add-ScaffoldAction -Actions $Actions -Action 'preserved' -Path $TargetPath
        return
    }

    Add-ScaffoldAction -Actions $Actions -Action $(if ($DryRun) { 'would-create' } else { 'created' }) -Path $TargetPath
    if (-not $DryRun) {
        $parent = Split-Path -Parent $TargetPath
        if (-not (Test-Path -LiteralPath $parent)) {
            New-Item -ItemType Directory -Path $parent -Force | Out-Null
        }

        [System.IO.File]::WriteAllText($TargetPath, $Content, [System.Text.UTF8Encoding]::new($false))
    }
}

function Get-MarkdownContent {
    param([string]$Path)

    return @(Get-Content -LiteralPath $Path -Encoding UTF8)
}

function Get-RelativePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$FromDirectory,

        [Parameter(Mandatory = $true)]
        [string]$ToPath
    )

    # Cross-platform safe replacement for the legacy [System.Uri] MakeRelativeUri pattern,
    # which fails on Linux for bare absolute paths.
    $fromFull = [System.IO.Path]::GetFullPath($FromDirectory)
    $toFull = [System.IO.Path]::GetFullPath($ToPath)
    return ([System.IO.Path]::GetRelativePath($fromFull, $toFull)) -replace '\\', '/'
}

function Get-RequirementSummaryMap {
    param(
        [AllowEmptyString()]
        [string[]]$Lines
    )

    $requirements = [ordered]@{}
    foreach ($line in $Lines) {
        if ($line -match '^\s*-\s+\*\*(FR-\d+)\*\*:\s+(.+?)\s*$') {
            $requirements[$Matches[1]] = $Matches[2].Trim()
        }
    }

    return $requirements
}

function Get-RequirementStoryMap {
    param(
        [AllowEmptyString()]
        [string[]]$Lines
    )

    $storyMap = @{}
    foreach ($line in $Lines) {
        if ($line -match '^\s*-\s+(US-\d+)(?:\s*\([^)]+\))?\s+→\s+(.+?)\s*$') {
            $storyId = $Matches[1]
            $requirements = $Matches[2] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^FR-\d+$' }
            foreach ($requirement in $requirements) {
                if (-not $storyMap.ContainsKey($requirement)) {
                    $storyMap[$requirement] = New-Object System.Collections.Generic.List[string]
                }

                if (-not $storyMap[$requirement].Contains($storyId)) {
                    $storyMap[$requirement].Add($storyId)
                }
            }
        }
    }

    return $storyMap
}

function Get-IterationConfig {
    param(
        [AllowNull()]
        [string]$Path
    )

    $config = @{
        effort_unit            = 'story_points'
        capacity_per_iteration = '20'
        iteration_bounding     = 'scope'
        time_limit_hours       = 'null'
        overcommit_threshold   = '1.0'
        calibration_enabled    = 'true'
        defer_strategy         = 'manual'
    }

    if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) {
        return $config
    }

    foreach ($line in Get-MarkdownContent -Path $Path) {
        if ($line -match '^\s*effort_unit:\s*"?([^"#]+?)"?\s*$') {
            $config.effort_unit = $Matches[1].Trim()
        }
        elseif ($line -match '^\s*capacity_per_iteration:\s*("?)([^"#]+)\1\s*$') {
            $config.capacity_per_iteration = $Matches[2].Trim()
        }
        elseif ($line -match '^\s*iteration_bounding:\s*"?([^"#]+?)"?\s*$') {
            $config.iteration_bounding = $Matches[1].Trim()
        }
        elseif ($line -match '^\s*time_limit_hours:\s*("?)([^"#]+)\1\s*$') {
            $config.time_limit_hours = $Matches[2].Trim()
        }
        elseif ($line -match '^\s*overcommit_threshold:\s*("?)([^"#]+)\1\s*$') {
            $config.overcommit_threshold = $Matches[2].Trim()
        }
        elseif ($line -match '^\s*calibration_enabled:\s*("?)([^"#]+)\1\s*$') {
            $config.calibration_enabled = $Matches[2].Trim()
        }
        elseif ($line -match '^\s*defer_strategy:\s*"?([^"#]+?)"?\s*$') {
            $config.defer_strategy = $Matches[1].Trim()
        }
    }

    return $config
}

function Get-MarkdownSectionLines {
    param(
        [AllowEmptyString()]
        [string[]]$Lines,
        [string]$Heading
    )

    $headingPattern = '^##\s+' + [regex]::Escape($Heading) + '\b'
    $startIndex = -1
    for ($index = 0; $index -lt $Lines.Count; $index++) {
        if ($Lines[$index] -match $headingPattern) {
            $startIndex = $index
            break
        }
    }

    if ($startIndex -lt 0) {
        return @()
    }

    $sectionLines = New-Object System.Collections.Generic.List[string]
    for ($index = $startIndex + 1; $index -lt $Lines.Count; $index++) {
        $currentLine = $Lines[$index]
        if ($currentLine -match '^##\s+') {
            break
        }

        $null = $sectionLines.Add($currentLine)
    }

    return $sectionLines.ToArray()
}

function Get-TeamRoleSnapshot {
    param([string]$ProjectRoot)

    $teamPath = Join-Path $ProjectRoot '.squad\team.md'
    if (-not (Test-Path -LiteralPath $teamPath -PathType Leaf)) {
        return @()
    }

    $roles = New-Object System.Collections.Generic.List[string]
    foreach ($line in Get-MarkdownContent -Path $teamPath) {
        if ($line -match '^\|\s*([^|]+?)\s*\|\s*`?\.squad/agents/.+?\|\s*([^|]+?)\s*\|?$') {
            $roleCandidate = $Matches[1].Trim()
            if (-not [string]::IsNullOrWhiteSpace($roleCandidate) -and
                $roleCandidate -notin @('Role', 'Name', '----')) {
                if (-not $roles.Contains($roleCandidate)) {
                    $null = $roles.Add($roleCandidate)
                }
            }
        }
    }

    return $roles.ToArray()
}

function Get-LatestReviewerHotspots {
    param([string]$SpecDirectory)

    $iterationsRoot = Join-Path $SpecDirectory 'iterations'
    if (-not (Test-Path -LiteralPath $iterationsRoot -PathType Container)) {
        return @()
    }

    $latestCodeMap = Get-ChildItem -Path $iterationsRoot -Directory |
        Sort-Object Name -Descending |
        ForEach-Object { Join-Path $_.FullName 'code-map.md' } |
        Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
        Select-Object -First 1

    if ([string]::IsNullOrWhiteSpace($latestCodeMap)) {
        return @()
    }

    $hotspots = New-Object System.Collections.Generic.List[string]
    $hotspotLines = @(Get-MarkdownSectionLines -Lines (Get-MarkdownContent -Path $latestCodeMap) -Heading 'Module Hotspots')
    foreach ($line in $hotspotLines) {
        $trimmed = $line.Trim()
        if ($trimmed -match '^- ' -and $trimmed -notmatch '^- none$' -and $trimmed -notmatch '^- Threshold:') {
            $null = $hotspots.Add(($trimmed -replace '^- ', '').Trim())
        }
    }

    return $hotspots.ToArray()
}

function Get-ConcurrencyRationaleLines {
    param(
        [string]$ProjectRoot,
        [string]$SpecDirectory,
        [string[]]$ScopedRequirements,
        [System.Collections.IDictionary]$RequirementSummaries
    )

    $scopeText = (($ScopedRequirements | ForEach-Object { [string]$RequirementSummaries[$_] }) -join ' ').ToLowerInvariant()
    $rosterRoles = @(Get-TeamRoleSnapshot -ProjectRoot $ProjectRoot)
    $hotspots = @(Get-LatestReviewerHotspots -SpecDirectory $SpecDirectory)

    $frontendSignals = @([regex]::Matches($scopeText, '\b(ui|ux|frontend|dashboard|page|form|react|next|vue|angular|svelte|report|reporting)\b')).Count
    $backendSignals = @([regex]::Matches($scopeText, '\b(api|backend|service|worker|webhook|queue|sync|integration|export|import|database|persist)\b')).Count
    $conflictSignals = @([regex]::Matches($scopeText, '\b(shared|global state|migration|rewrite|cross-cutting|ambiguous|concurrency|lock|conflict)\b')).Count

    $technologySummary = if ($frontendSignals -gt 0 -and $backendSignals -gt 0) {
        'Mixed frontend and backend/service signals are present in the scoped requirements.'
    }
    elseif ($frontendSignals -gt 0) {
        'Frontend-oriented signals dominate the scoped requirements.'
    }
    elseif ($backendSignals -gt 0) {
        'Backend/service-oriented signals dominate the scoped requirements.'
    }
    else {
        'No single specialty dominates yet; treat the slice as general product work until task decomposition adds sharper evidence.'
    }

    $separabilitySummary = if (($frontendSignals -ge 3 -or $backendSignals -ge 3) -and $conflictSignals -eq 0) {
        'The scoped requirements suggest multiple potentially separable workstreams, so same-specialty expansion may be justified after task decomposition.'
    }
    elseif ($conflictSignals -gt 0) {
        'Conflict-heavy signals are present, so keep same-specialty work serial unless ownership boundaries become explicit.'
    }
    else {
        'Current scope does not yet prove enough safe parallelism for same-specialty expansion; default to a smaller serial team until tasks are clearer.'
    }

    $hotspotSummary = if ($hotspots.Count -gt 0) {
        'Latest reviewer hotspots: ' + ($hotspots -join '; ')
    }
    else {
        'No prior reviewer hotspot signals were found for this feature.'
    }

    return @(
        '## Concurrency Rationale'
        ''
        ('- Current roster snapshot: {0}' -f $(if ($rosterRoles.Count -gt 0) { $rosterRoles -join ', ' } else { '(team roster unavailable)' }))
        ('- Technology and scope signals: {0}' -f $technologySummary)
        '- Task dependency graph: detailed dependencies are still pending task decomposition in this stub; revisit once the task table is populated.'
        ('- Workstream separability: {0}' -f $separabilitySummary)
        ('- Shared-surface conflict risk: {0}' -f $(if ($conflictSignals -gt 0) { 'elevated due to shared-state / cross-cutting cues in scope text.' } else { 'no elevated shared-surface warning inferred yet.' }))
        ('- Prior reviewer ownership/hotspot evidence: {0}' -f $hotspotSummary)
        '- Recommendation: do not propose Junior/Senior same-specialty expansion until the task table and ownership boundaries make safe parallelism explicit. If a same-specialty pair is approved later, record `Owner File Globs` for the parallel tasks or keep the work serial.'
    )
}

$resolvedSpecPath = Resolve-ProjectPath -Path $SpecPath
if (-not (Test-Path -LiteralPath $resolvedSpecPath)) {
    throw "Spec file '$resolvedSpecPath' does not exist."
}

$specDirectory = Split-Path -Parent $resolvedSpecPath
$projectSpecsRoot = Split-Path -Parent $specDirectory
$projectRoot = Split-Path -Parent $projectSpecsRoot
$iterationDirectory = Join-Path (Join-Path $specDirectory 'iterations') $IterationNumber
$planPath = Join-Path $iterationDirectory 'plan.md'
$resolvedConfigPath = if ($IterationConfigPath) {
    Resolve-ProjectPath -Path $IterationConfigPath
}
else {
    Join-Path $projectRoot '.specrew\iteration-config.yml'
}

$specLines = @(Get-MarkdownContent -Path $resolvedSpecPath)
$requirementSummaries = Get-RequirementSummaryMap -Lines $specLines
$requirementStories = Get-RequirementStoryMap -Lines $specLines

$scopeList = if ($RequirementScope -and $RequirementScope.Count -gt 0) {
    @($RequirementScope | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
else {
    @($requirementSummaries.Keys)
}

if ($scopeList.Count -eq 0) {
    throw "No functional requirements were found in '$resolvedSpecPath'."
}

$missingRequirements = @($scopeList | Where-Object { -not $requirementSummaries.Contains($_) })
if ($missingRequirements.Count -gt 0) {
    throw "Requirement(s) not found in spec: $($missingRequirements -join ', ')"
}

$iterationConfig = Get-IterationConfig -Path $resolvedConfigPath
$actions = [System.Collections.ArrayList]::new()

Ensure-Directory -Path (Split-Path -Parent $iterationDirectory) -Actions $actions
Ensure-Directory -Path $iterationDirectory -Actions $actions

$scopeRows = @(
    '| Requirement | Summary | Stories |'
    '| ----------- | ------- | ------- |'
)
foreach ($requirementId in $scopeList) {
    $stories = if ($requirementStories.ContainsKey($requirementId)) {
        [string]::Join(', ', $requirementStories[$requirementId])
    }
    else {
        '—'
    }

    $scopeRows += ('| {0} | {1} | {2} |' -f $requirementId, ($requirementSummaries[$requirementId] -replace '\|', '\|'), $stories)
}

$relativeSpecPath = Get-RelativePath -FromDirectory $iterationDirectory -ToPath $resolvedSpecPath
$startedDate = (Get-Date).ToString('yyyy-MM-dd')
$capacityLimit = $iterationConfig.capacity_per_iteration
$overcommitMessage = 'Warn planners when total estimated effort exceeds configured capacity.'

$parsedCapacity = 0.0
$parsedThreshold = 0.0
if ([double]::TryParse([string]$iterationConfig.capacity_per_iteration, [ref]$parsedCapacity) -and
    [double]::TryParse([string]$iterationConfig.overcommit_threshold, [ref]$parsedThreshold)) {
    $warnAt = [math]::Round(($parsedCapacity * $parsedThreshold), 2)
    $overcommitMessage = 'Warn planners when total estimated effort exceeds {0} {1} (capacity {2} x threshold {3}).' -f $warnAt, $iterationConfig.effort_unit, $iterationConfig.capacity_per_iteration, $iterationConfig.overcommit_threshold
}

$timeLimitDisplay = if ([string]::IsNullOrWhiteSpace($iterationConfig.time_limit_hours) -or $iterationConfig.time_limit_hours -eq 'null') {
    'n/a'
}
else {
    $iterationConfig.time_limit_hours
}

$effortModelRows = @(
    '| Setting | Value | Notes |'
    '| ------- | ----- | ----- |'
    ('| Effort Unit | {0} | Unit used in task effort, capacity, and retro variance. |' -f $iterationConfig.effort_unit)
    ('| Capacity per Iteration | {0} | Maximum planned effort before overcommit guidance applies. |' -f $iterationConfig.capacity_per_iteration)
    ('| Iteration Bounding | {0} | `scope` keeps requirements fixed; `time` enforces a time ceiling. |' -f $iterationConfig.iteration_bounding)
    ('| Time Limit (hours) | {0} | Only applies when iteration bounding is `time`. |' -f $timeLimitDisplay)
    ('| Overcommit Threshold | {0} | {1} |' -f $iterationConfig.overcommit_threshold, $overcommitMessage)
    ('| Defer Strategy | {0} | How planning should choose deferrals when the iteration is over capacity. |' -f $iterationConfig.defer_strategy)
    ('| Calibration Enabled | {0} | When true, retrospectives should suggest future capacity adjustments. |' -f $iterationConfig.calibration_enabled)
)

$phaseRows = @(
    '| Phase | Estimated Effort | Notes |'
    '| ----- | ---------------- | ----- |'
    '| Planning | TBD | Populate after task decomposition and approval gating |'
    '| Discovery/Spikes | TBD | Capture any required risk-reduction work revealed during planning |'
    '| Implementation | TBD | Sum planned delivery tasks once the task table is complete |'
    '| Review | TBD | Estimate review/demo effort after verdict flow is defined |'
    '| Rework | TBD | Expected needs-work buffer if review finds gaps |'
)

$planContent = @"
# Iteration Plan: $IterationNumber (Stub)
 
**Schema**: v1
**Spec**: [$relativeSpecPath]($relativeSpecPath)
**Status**: planning
**Capacity**: 0/$($iterationConfig.capacity_per_iteration) $($iterationConfig.effort_unit)
**Started**: $startedDate
**Completed**:
 
## Scope Summary
 
$($scopeRows -join [Environment]::NewLine)
 
## Tasks
 
| Task | Title | Requirement | Story | Effort | Owner | Owner File Globs | Status | Agent | Actual | Verdict |
| ---- | ----- | ----------- | ----- | ------ | ----- | ---------------- | ------ | ----- | ------ | ------- |
 
## Effort Model
 
$($effortModelRows -join [Environment]::NewLine)
 
$((Get-ConcurrencyRationaleLines -ProjectRoot $projectRoot -SpecDirectory $specDirectory -ScopedRequirements $scopeList -RequirementSummaries $requirementSummaries) -join [Environment]::NewLine)
 
## Phase Baseline
 
$($phaseRows -join [Environment]::NewLine)
 
## Traceability Summary
 
- Requirement scope for this stub: $($scopeList -join ', ')
- User stories represented in current scope: $((@($scopeList | ForEach-Object { if ($requirementStories.ContainsKey($_)) { $requirementStories[$_] } }) | Select-Object -Unique) -join ', ')
- Pending detailed planning: populate the task table, then run `specrew-capacity-planning` and `specrew-traceability-check` before approval.
- Overcommit guardrail: compare planned task effort against the configured threshold and record any required deferrals from the lowest-priority requirement slices before leaving `planning`.
 
## Notes
 
- This stub captures the planned scope pending detailed planning in the Specrew Planning ceremony.
- Add task rows only for work that is traceable to the scoped requirements above.
- Keep `Status: planning` until the plan is fully decomposed and approved.
- If task effort exceeds the configured threshold, make the deferral decision explicit in this plan before execution starts and name the lowest-priority requirement slices proposed for deferral.
"@


Write-MissingFile -TargetPath $planPath -Content $planContent -Actions $actions

if ($PassThru) {
    $actions
    return
}

$actions | Select-Object Action, Path | Format-Table -AutoSize
Write-Host ("Iteration plan scaffold {0} for {1}" -f ($(if ($DryRun) { 'previewed' } else { 'completed' }), $planPath)) -ForegroundColor Green
exit 0