extensions/specrew-speckit/scripts/brownfield-merge.ps1
|
# brownfield-merge.ps1 # Merges Specrew configuration into existing Spec Kit / Squad installations <# .SYNOPSIS Handles brownfield scenarios where Spec Kit or Squad are already installed. Preserves existing specs, governance, and team configuration while adding Specrew's baseline roles and governance artifacts. .DESCRIPTION Detects existing project state and safely merges Specrew baseline configuration: - Preserves existing specs, governance artifacts, and user customizations - Merges baseline roles into existing Squad team without overwriting - Reports conflicts when dependencies are incompatible - Supports dry-run mode for reviewable merge preview .PARAMETER ProjectPath Path to the target project directory. .PARAMETER DryRun Show planned changes without writing files. .PARAMETER PassThru Return structured merge analysis results. .EXAMPLE .\brownfield-merge.ps1 -ProjectPath "C:\Projects\ExistingApp" -DryRun #> [CmdletBinding()] param( [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string]$ProjectPath, [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 Get-BrownfieldState { param( [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string]$ProjectPath ) $state = [pscustomobject]@{ HasSpecify = Test-Path -LiteralPath (Join-Path $ProjectPath '.specify') HasSquad = Test-Path -LiteralPath (Join-Path $ProjectPath '.squad') HasSpecrewConfig = Test-Path -LiteralPath (Join-Path $ProjectPath '.specrew\config.yml') HasSpecrewExtension = Test-Path -LiteralPath (Join-Path $ProjectPath '.specify\extensions\specrew-speckit\extension.yml') HasSquadTeam = Test-Path -LiteralPath (Join-Path $ProjectPath '.squad\team.md') HasSquadCeremonies = Test-Path -LiteralPath (Join-Path $ProjectPath '.squad\ceremonies.md') ExistingSpecs = @() ExistingRoles = @() ExistingCeremonies = @() Conflicts = [System.Collections.ArrayList]::new() } if ($state.HasSpecify) { $specsPath = Join-Path $ProjectPath 'specs' if (Test-Path -LiteralPath $specsPath) { $state.ExistingSpecs = @(Get-ChildItem -LiteralPath $specsPath -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name) } } if ($state.HasSquadTeam) { $teamPath = Join-Path $ProjectPath '.squad\team.md' $teamContent = Get-Content -LiteralPath $teamPath -Raw $rolePattern = '(?m)^\|\s*([^|]+)\s*\|' $matches = [regex]::Matches($teamContent, $rolePattern) $roles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($match in $matches) { $roleName = $match.Groups[1].Value.Trim() if ($roleName -notin @('Role', '----', '')) { $null = $roles.Add($roleName) } } $state.ExistingRoles = @($roles) } if ($state.HasSquadCeremonies) { $ceremoniesPath = Join-Path $ProjectPath '.squad\ceremonies.md' $ceremoniesContent = Get-Content -LiteralPath $ceremoniesPath -Raw $ceremonyHeadingPattern = '(?m)^##\s+(.+?)(?:\s*\{[^}]*\})?\s*$' $matches = [regex]::Matches($ceremoniesContent, $ceremonyHeadingPattern) $ceremonies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($match in $matches) { $ceremonyName = $match.Groups[1].Value.Trim() if (-not [string]::IsNullOrWhiteSpace($ceremonyName)) { $null = $ceremonies.Add($ceremonyName) } } $state.ExistingCeremonies = @($ceremonies) } return $state } function Test-HasRoleConflict { param( [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string[]]$ExistingRoles, [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string[]]$BaselineRoles ) $conflicts = @() foreach ($baselineRole in $BaselineRoles) { if ($baselineRole -in $ExistingRoles) { $conflicts += $baselineRole } } return $conflicts } function Test-HasCeremonyConflict { param( [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string[]]$ExistingCeremonies, [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string[]]$SpecrewCeremonies ) $conflicts = @() foreach ($specrew in $SpecrewCeremonies) { if ($specrew -in $ExistingCeremonies) { $conflicts += $specrew } } return $conflicts } function Get-MergeReport { param( [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [pscustomobject]$State, [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string[]]$BaselineRoles, [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [string[]]$SpecrewCeremonies ) $report = [pscustomobject]@{ Status = 'ready' PreservedSpecs = $State.ExistingSpecs PreservedRoles = $State.ExistingRoles PreservedCeremonies = $State.ExistingCeremonies RoleConflicts = @() CeremonyConflicts = @() MergeableRoles = @() MergeableCeremonies = @() Warnings = [System.Collections.ArrayList]::new() Conflicts = [System.Collections.ArrayList]::new() } $report.RoleConflicts = @(Test-HasRoleConflict -ExistingRoles $State.ExistingRoles -BaselineRoles $BaselineRoles) $report.CeremonyConflicts = @(Test-HasCeremonyConflict -ExistingCeremonies $State.ExistingCeremonies -SpecrewCeremonies $SpecrewCeremonies) $report.MergeableRoles = @($BaselineRoles | Where-Object { $_ -notin $report.RoleConflicts }) $report.MergeableCeremonies = @($SpecrewCeremonies | Where-Object { $_ -notin $report.CeremonyConflicts }) if ($report.RoleConflicts.Count -gt 0) { $null = $report.Conflicts.Add([pscustomobject]@{ Type = 'role' Description = "Existing roles conflict with Specrew baseline: $($report.RoleConflicts -join ', ')" Resolution = 'Specrew will preserve existing role definitions in .squad/agents/. Review agent charters after bootstrap to merge Specrew directives manually if needed.' }) } if ($report.CeremonyConflicts.Count -gt 0) { $null = $report.Warnings.Add([pscustomobject]@{ Type = 'ceremony' Description = "Existing ceremonies conflict with Specrew definitions: $($report.CeremonyConflicts -join ', ')" Resolution = 'Specrew will preserve existing ceremony definitions. Review .squad/ceremonies.md to merge Specrew ceremony guidance manually if needed.' }) } if (-not $State.HasSpecify -and $State.HasSquad) { $null = $report.Warnings.Add([pscustomobject]@{ Type = 'partial-platform' Description = 'Squad is initialized but Spec Kit is missing' Resolution = 'Brownfield bootstrap will skip Spec Kit extension deployment. Run `specify init` manually, then re-run `specrew init` to complete the installation.' }) } if ($State.HasSpecify -and -not $State.HasSquad) { $null = $report.Warnings.Add([pscustomobject]@{ Type = 'partial-platform' Description = 'Spec Kit is initialized but Squad is missing' Resolution = 'Brownfield bootstrap will skip Squad runtime deployment. Run `squad init` manually, then re-run `specrew init` to complete the installation.' }) } if ($State.ExistingSpecs.Count -gt 0) { $null = $report.Warnings.Add([pscustomobject]@{ Type = 'existing-specs' Description = "Found existing specs: $($State.ExistingSpecs -join ', ')" Resolution = 'Existing specs will be preserved. Specrew will merge governance artifacts without modifying existing spec content.' }) } if ($report.Conflicts.Count -gt 0) { $report.Status = 'conflicts-detected' } elseif ($report.Warnings.Count -gt 0) { $report.Status = 'warnings-present' } return $report } $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath if (-not (Test-Path -LiteralPath $resolvedProjectPath)) { Write-Error "Project path '$resolvedProjectPath' does not exist." exit 1 } if (-not $PassThru) { Write-Host "==> Analyzing brownfield project state" -ForegroundColor Cyan } $state = Get-BrownfieldState -ProjectPath $resolvedProjectPath $baselineRoles = @('Spec Steward', 'Planner', 'Implementer', 'Reviewer', 'Retro Facilitator') $specrewCeremonies = @('Specrew: Planning', 'Specrew: Review/Demo') $report = Get-MergeReport -State $state -BaselineRoles $baselineRoles -SpecrewCeremonies $specrewCeremonies if ($PassThru) { # Return only the report object as JSON to avoid PowerShell auto-formatting $report | ConvertTo-Json -Depth 10 -Compress exit 0 } Write-Host '' Write-Host "Brownfield merge analysis for: $resolvedProjectPath" -ForegroundColor Green Write-Host "Status: $($report.Status)" -ForegroundColor $(if ($report.Status -eq 'ready') { 'Green' } elseif ($report.Status -eq 'warnings-present') { 'Yellow' } else { 'Red' }) Write-Host '' if ($state.PreservedSpecs.Count -gt 0) { Write-Host "Preserved specs: $($state.PreservedSpecs.Count)" -ForegroundColor Cyan } if ($state.PreservedRoles.Count -gt 0) { Write-Host "Preserved roles: $($state.PreservedRoles -join ', ')" -ForegroundColor Cyan } if ($report.MergeableRoles.Count -gt 0) { Write-Host "Mergeable baseline roles: $($report.MergeableRoles -join ', ')" -ForegroundColor Green } if ($report.RoleConflicts.Count -gt 0) { Write-Host "Role conflicts: $($report.RoleConflicts -join ', ')" -ForegroundColor Yellow } if ($report.MergeableCeremonies.Count -gt 0) { Write-Host "Mergeable ceremonies: $($report.MergeableCeremonies -join ', ')" -ForegroundColor Green } if ($report.CeremonyConflicts.Count -gt 0) { Write-Host "Ceremony conflicts: $($report.CeremonyConflicts -join ', ')" -ForegroundColor Yellow } Write-Host '' if ($report.Conflicts.Count -gt 0) { Write-Host 'Conflicts:' -ForegroundColor Red foreach ($conflict in $report.Conflicts) { Write-Host " - [$($conflict.Type)] $($conflict.Description)" -ForegroundColor Red Write-Host " Resolution: $($conflict.Resolution)" -ForegroundColor Yellow } Write-Host '' } if ($report.Warnings.Count -gt 0) { Write-Host 'Warnings:' -ForegroundColor Yellow foreach ($warning in $report.Warnings) { Write-Host " - [$($warning.Type)] $($warning.Description)" -ForegroundColor Yellow Write-Host " Resolution: $($warning.Resolution)" -ForegroundColor Cyan } Write-Host '' } if ($DryRun) { Write-Host 'Dry run complete. Merge strategy validated.' -ForegroundColor Yellow } exit 0 |