scripts/specrew-team.ps1
|
[CmdletBinding(DefaultParameterSetName = 'List')] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateSet('add', 'update', 'remove', 'list')] [string]$Command, [Parameter(Mandatory = $false, Position = 1)] [string]$MemberName, [Parameter(Mandatory = $false)] [string]$Role, [Parameter(Mandatory = $false)] [string]$Charter, [Parameter(Mandatory = $false)] [string]$ProjectPath = '.' ) # Handle Unix-style --flag arguments: Check $MyInvocation.UnboundArguments or re-parse $args # If invoked with --role or --charter, they won't be bound to parameters # Check for unbound arguments and re-invoke if needed $unboundArgs = $MyInvocation.UnboundArguments if ($unboundArgs -and ($unboundArgs -contains '--role' -or $unboundArgs -contains '--charter')) { # Reconstruct argument list with PowerShell-style parameters $newArgs = @($Command) if ($MemberName) { $newArgs += $MemberName } $skipNext = $false for ($i = 0; $i -lt $unboundArgs.Count; $i++) { if ($skipNext) { $skipNext = $false continue } $arg = $unboundArgs[$i] if ($arg -eq '--role') { $newArgs += '-Role' if ($i + 1 -lt $unboundArgs.Count) { $newArgs += $unboundArgs[$i + 1] $skipNext = $true } } elseif ($arg -eq '--charter') { $newArgs += '-Charter' if ($i + 1 -lt $unboundArgs.Count) { $newArgs += $unboundArgs[$i + 1] $skipNext = $true } } else { $newArgs += $arg } } if ($ProjectPath -ne '.') { $newArgs += '-ProjectPath' $newArgs += $ProjectPath } # Re-invoke with corrected arguments & $PSCommandPath @newArgs exit $LASTEXITCODE } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sharedGovernancePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'extensions\specrew-speckit\scripts\shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Missing shared governance helper '$sharedGovernancePath'." } . $sharedGovernancePath $BASELINE_ROLES = @( 'spec-steward', 'planner', 'implementer', 'reviewer', 'retro-facilitator' ) $BASELINE_ROLE_NAMES = @( 'Spec Steward', 'Planner', 'Implementer', 'Reviewer', 'Retro Facilitator' ) function Write-Success { param([string]$Message) Write-Host $Message -ForegroundColor Green } function Write-Error-Message { param([string]$Message) Write-Host "ERROR: $Message" -ForegroundColor Red } function Write-Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan } function Test-IsBaselineRole { param([string]$Name) $normalizedName = $Name.ToLower().Trim() -replace '\s+', '-' return $BASELINE_ROLES -contains $normalizedName } function Get-NormalizedMemberName { param([string]$Name) return $Name.ToLower().Trim() -replace '\s+', '-' } function Get-TeamFilePath { param([string]$Root) return Join-Path $Root '.squad\team.md' } function Get-AgentDirectory { param( [string]$Root, [string]$Name ) $normalized = Get-NormalizedMemberName -Name $Name return Join-Path $Root ".squad\agents\$normalized" } function Test-SquadInitialized { param([string]$Root) $squadRoot = Join-Path $Root '.squad' if (-not (Test-Path -LiteralPath $squadRoot)) { Write-Error-Message "Squad has not been initialized. Missing '$squadRoot'." Write-Error-Message "Run 'specrew init' first to bootstrap the project." return $false } return $true } function Get-TeamContent { param([string]$TeamPath) if (-not (Test-Path -LiteralPath $TeamPath)) { return $null } return Get-Content -LiteralPath $TeamPath -Raw } function Test-MemberExists { param( [string]$TeamPath, [string]$MemberName ) $content = Get-TeamContent -TeamPath $TeamPath if ($null -eq $content) { return $false } # Try exact role name match $escapedName = [regex]::Escape($MemberName) if ($content -match "(?m)^\|\s*$escapedName\s*\|") { return $true } # Try normalized directory name match $normalized = Get-NormalizedMemberName -Name $MemberName $escapedNormalized = [regex]::Escape($normalized) return $content -match "\.squad/agents/$escapedNormalized/" } function Test-MemberInManagedBlock { param( [string]$TeamPath, [string]$MemberName ) $content = Get-TeamContent -TeamPath $TeamPath if ($null -eq $content) { return $false } $startMarker = '<!-- >>> specrew-managed baseline-roles >>> -->' $endMarker = '<!-- <<< specrew-managed baseline-roles <<< -->' if ($content -notmatch [regex]::Escape($startMarker)) { return $false } $pattern = "(?ms)$([regex]::Escape($startMarker)).*?$([regex]::Escape($endMarker))" if ($content -match $pattern) { $managedBlock = $matches[0] $escapedName = [regex]::Escape($MemberName) return $managedBlock -match "(?m)^\|\s*$escapedName\s*\|" } return $false } function Add-TeamMember { param( [string]$ProjectPath, [string]$MemberName, [string]$Role, [string]$Charter ) $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath if (-not (Test-SquadInitialized -Root $resolvedProjectPath)) { return $false } $normalized = Get-NormalizedMemberName -Name $MemberName if (Test-IsBaselineRole -Name $normalized) { Write-Error-Message "Cannot add baseline role '$MemberName'. Baseline roles are protected." return $false } $teamPath = Get-TeamFilePath -Root $resolvedProjectPath if (Test-MemberExists -TeamPath $teamPath -MemberName $Role) { Write-Error-Message "Team member '$Role' already exists in .squad\team.md." return $false } $agentDir = Get-AgentDirectory -Root $resolvedProjectPath -Name $normalized if (Test-Path -LiteralPath $agentDir) { Write-Error-Message "Agent directory already exists: $agentDir" return $false } try { $null = New-Item -ItemType Directory -Path $agentDir -Force $charterPath = Join-Path $agentDir 'charter.md' $charterContent = @" # $Role Charter $Charter "@ [System.IO.File]::WriteAllText($charterPath, $charterContent, [System.Text.UTF8Encoding]::new($false)) $historyPath = Join-Path $agentDir 'history.md' $historyContent = @" # $Role History Session notes and learnings for $Role. "@ [System.IO.File]::WriteAllText($historyPath, $historyContent, [System.Text.UTF8Encoding]::new($false)) $teamContent = Get-TeamContent -TeamPath $teamPath if ($null -eq $teamContent) { $teamContent = "# Squad Team`n`n" } $teamEntry = "| $Role | ``.squad/agents/$normalized/charter.md`` | active |" $endMarker = '<!-- <<< specrew-managed baseline-roles <<< -->' # Always add domain-specific members AFTER the managed block if ($teamContent -match [regex]::Escape($endMarker)) { # Check if Domain-Specific Members section exists after the managed block $afterManagedPattern = "(?ms)$([regex]::Escape($endMarker))(.*)" if ($teamContent -match $afterManagedPattern) { $afterManaged = $matches[1] if ($afterManaged -match '## Domain-Specific Members') { # Section exists, add to it $sectionPattern = '(?ms)(## Domain-Specific Members.*?\| ---- \| ------- \| ------ \|\r?\n)' $updatedContent = [regex]::Replace($teamContent, $sectionPattern, "`${1}$teamEntry`n", 1) } else { # Section doesn't exist, create it after the managed block $updatedContent = [regex]::Replace($teamContent, [regex]::Escape($endMarker), "$endMarker`n`n## Domain-Specific Members`n`n| Role | Charter | Status |`n| ---- | ------- | ------ |`n$teamEntry", 1) } } else { $updatedContent = $teamContent.TrimEnd() + "`n`n## Domain-Specific Members`n`n| Role | Charter | Status |`n| ---- | ------- | ------ |`n$teamEntry`n" } } else { $updatedContent = $teamContent.TrimEnd() + "`n`n## Domain-Specific Members`n`n| Role | Charter | Status |`n| ---- | ------- | ------ |`n$teamEntry`n" } [System.IO.File]::WriteAllText($teamPath, $updatedContent, [System.Text.UTF8Encoding]::new($false)) Write-Success "✓ Added team member '$Role'" Write-Info " Charter: $charterPath" Write-Info " History: $historyPath" Write-Info " Team entry: $teamPath" return $true } catch { Write-Error-Message "Failed to add team member: $_" return $false } } function Update-TeamMember { param( [string]$ProjectPath, [string]$MemberName, [string]$Role, [string]$Charter ) $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath if (-not (Test-SquadInitialized -Root $resolvedProjectPath)) { return $false } $normalized = Get-NormalizedMemberName -Name $MemberName if (Test-IsBaselineRole -Name $normalized) { Write-Error-Message "Cannot update baseline role '$MemberName'. Baseline roles are protected." return $false } $teamPath = Get-TeamFilePath -Root $resolvedProjectPath if (-not (Test-MemberExists -TeamPath $teamPath -MemberName $MemberName)) { Write-Error-Message "Team member '$MemberName' does not exist." return $false } if (Test-MemberInManagedBlock -TeamPath $teamPath -MemberName $MemberName) { Write-Error-Message "Cannot update baseline role '$MemberName'. Baseline roles are protected." return $false } $agentDir = Get-AgentDirectory -Root $resolvedProjectPath -Name $normalized if (-not (Test-Path -LiteralPath $agentDir)) { Write-Error-Message "Agent directory not found: $agentDir" return $false } try { $updated = $false if ($Charter) { $charterPath = Join-Path $agentDir 'charter.md' $existingContent = Get-Content -LiteralPath $charterPath -Raw -ErrorAction SilentlyContinue $roleTitle = if ($Role) { $Role } else { if ($existingContent -match '(?m)^# (.+) Charter') { $matches[1] } else { $MemberName } } $charterContent = @" # $roleTitle Charter $Charter "@ [System.IO.File]::WriteAllText($charterPath, $charterContent, [System.Text.UTF8Encoding]::new($false)) Write-Info " Updated charter: $charterPath" $updated = $true } if ($Role -and -not $Charter) { $teamContent = Get-TeamContent -TeamPath $teamPath $escapedOldName = [regex]::Escape($MemberName) $pattern = "(?m)^(\|\s*)$escapedOldName(\s*\|.+)$" $replacement = "`${1}$Role`${2}" $updatedContent = [regex]::Replace($teamContent, $pattern, $replacement) if ($updatedContent -ne $teamContent) { [System.IO.File]::WriteAllText($teamPath, $updatedContent, [System.Text.UTF8Encoding]::new($false)) Write-Info " Updated role name in team.md" $updated = $true } } if ($updated) { Write-Success "✓ Updated team member '$MemberName'" return $true } else { Write-Error-Message "No updates specified. Use -Role or -Charter to update member." return $false } } catch { Write-Error-Message "Failed to update team member: $_" return $false } } function Remove-TeamMember { param( [string]$ProjectPath, [string]$MemberName ) $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath if (-not (Test-SquadInitialized -Root $resolvedProjectPath)) { return $false } $normalized = Get-NormalizedMemberName -Name $MemberName if (Test-IsBaselineRole -Name $normalized) { Write-Error-Message "Cannot remove baseline role '$MemberName'. Baseline roles are protected." return $false } $teamPath = Get-TeamFilePath -Root $resolvedProjectPath if (-not (Test-MemberExists -TeamPath $teamPath -MemberName $MemberName)) { Write-Error-Message "Team member '$MemberName' does not exist." return $false } if (Test-MemberInManagedBlock -TeamPath $teamPath -MemberName $MemberName) { Write-Error-Message "Cannot remove baseline role '$MemberName'. Baseline roles are protected." return $false } try { $agentDir = Get-AgentDirectory -Root $resolvedProjectPath -Name $normalized if (Test-Path -LiteralPath $agentDir) { Remove-Item -Path $agentDir -Recurse -Force Write-Info " Removed agent directory: $agentDir" } $teamContent = Get-TeamContent -TeamPath $teamPath # Remove by directory path match - backticks need proper escaping # Match: | Role Name | `.squad/agents/normalized/charter.md` | status | $pattern = "(?m)^\|[^|]+\|\s*``\.squad/agents/$normalized/[^``]+``\s*\|[^|]+\|\r?\n" $updatedContent = [regex]::Replace($teamContent, $pattern, '') [System.IO.File]::WriteAllText($teamPath, $updatedContent, [System.Text.UTF8Encoding]::new($false)) Write-Success "✓ Removed team member '$MemberName'" return $true } catch { Write-Error-Message "Failed to remove team member: $_" return $false } } function Get-TeamMembers { param([string]$ProjectPath) $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath if (-not (Test-SquadInitialized -Root $resolvedProjectPath)) { return $false } $teamPath = Get-TeamFilePath -Root $resolvedProjectPath $content = Get-TeamContent -TeamPath $teamPath if ($null -eq $content) { Write-Info "No team members found." return $true } Write-Host "`nSquad Team Members:`n" -ForegroundColor Cyan $startMarker = '<!-- >>> specrew-managed baseline-roles >>> -->' $endMarker = '<!-- <<< specrew-managed baseline-roles <<< -->' if ($content -match "(?ms)$([regex]::Escape($startMarker))(.*?)$([regex]::Escape($endMarker))") { $baselineBlock = $matches[1] Write-Host "Baseline Roles (protected):" -ForegroundColor Yellow $baselineBlock -split '\r?\n' | Where-Object { $_ -match '^\|' -and $_ -notmatch '^\| Role \|' -and $_ -notmatch '^\| ---- \|' } | ForEach-Object { Write-Host " $_" -ForegroundColor White } Write-Host "" } if ($content -match '## Domain-Specific Members') { Write-Host "Domain-Specific Members:" -ForegroundColor Green $domainPattern = '(?ms)## Domain-Specific Members.*?\| ---- \| ------- \| ------ \|(.*?)(?=\r?\n##|\r?\n<!--|\z)' if ($content -match $domainPattern) { $domainBlock = $matches[1] $domainBlock -split '\r?\n' | Where-Object { $_ -match '^\|' } | ForEach-Object { Write-Host " $_" -ForegroundColor White } } } return $true } $resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath switch ($Command) { 'add' { if (-not $MemberName) { Write-Error-Message "Member name is required for 'add' command." Write-Host "Usage: specrew team add <member-name> --role <role> --charter <charter-text>" exit 1 } if (-not $Role) { Write-Error-Message "Role is required for 'add' command." Write-Host "Usage: specrew team add <member-name> --role <role> --charter <charter-text>" exit 1 } if (-not $Charter) { Write-Error-Message "Charter is required for 'add' command." Write-Host "Usage: specrew team add <member-name> --role <role> --charter <charter-text>" exit 1 } $success = Add-TeamMember -ProjectPath $resolvedProjectPath -MemberName $MemberName -Role $Role -Charter $Charter exit $(if ($success) { 0 } else { 1 }) } 'update' { if (-not $MemberName) { Write-Error-Message "Member name is required for 'update' command." Write-Host "Usage: specrew team update <member-name> [--role <role>] [--charter <charter-text>]" exit 1 } if (-not $Role -and -not $Charter) { Write-Error-Message "At least one of --role or --charter is required for 'update' command." Write-Host "Usage: specrew team update <member-name> [--role <role>] [--charter <charter-text>]" exit 1 } $success = Update-TeamMember -ProjectPath $resolvedProjectPath -MemberName $MemberName -Role $Role -Charter $Charter exit $(if ($success) { 0 } else { 1 }) } 'remove' { if (-not $MemberName) { Write-Error-Message "Member name is required for 'remove' command." Write-Host "Usage: specrew team remove <member-name>" exit 1 } $success = Remove-TeamMember -ProjectPath $resolvedProjectPath -MemberName $MemberName exit $(if ($success) { 0 } else { 1 }) } 'list' { $success = Get-TeamMembers -ProjectPath $resolvedProjectPath exit $(if ($success) { 0 } else { 1 }) } } |