hosts/_team-canonical.ps1
|
# Canonical team-location helpers (Proposal 108 Slice 9) # # Single source of truth for the Crew's 5-agent baseline + user-added specialists: # # .specrew/team/ # ├── agents/ # │ ├── spec-steward.md ← canonical charter (host-neutral markdown) # │ ├── planner.md # │ ├── implementer.md # │ ├── reviewer.md # │ ├── retro-facilitator.md # │ └── <user-added>.md ← e.g., security-analyst.md # └── ROADMAP.md ← (future) team-history changelog # # Each host's Install-<Kind>CrewRuntime READS from this canonical location and TRANSLATES # to its host-native format (.claude/agents/*.md, .codex/agents/*.toml, .squad/agents/*/charter.md, .agents/agents/*.md). # # When the user runs `specrew team add SecurityAnalyst`, the change writes here ONLY. # Next `specrew start --host <X>` re-runs Install-<Kind>CrewRuntime to keep the host view in sync. # # The shipped baseline charters live in extensions/specrew-speckit/squad-templates/agents/<role>/charter.md. # Initialize-SpecrewTeamCanonical copies them to .specrew/team/agents/<role>.md on greenfield init # (without the surrounding directory wrapper — flatter shape, one file per agent). Set-StrictMode -Version Latest function Get-SpecrewTeamCanonicalPath { param([Parameter(Mandatory = $true)][string]$ProjectPath) return (Join-Path $ProjectPath '.specrew\team') } function Get-SpecrewTeamAgentsPath { param([Parameter(Mandatory = $true)][string]$ProjectPath) return (Join-Path (Get-SpecrewTeamCanonicalPath -ProjectPath $ProjectPath) 'agents') } function Get-SpecrewBaselineCrewRoles { return @('spec-steward', 'planner', 'implementer', 'reviewer', 'retro-facilitator') } function Get-SpecrewShippedCharterPath { <# .SYNOPSIS Resolve the path to a shipped baseline charter from the Specrew distribution root. Used by Initialize-SpecrewTeamCanonical to seed .specrew/team/ on first init. #> param([Parameter(Mandatory = $true)][string]$RoleName) # Walk up from this file until we find the Specrew distribution root (Specrew.psd1 marker) $root = $PSScriptRoot for ($i = 0; $i -lt 5; $i++) { if (Test-Path -LiteralPath (Join-Path $root 'Specrew.psd1') -PathType Leaf) { break } $parent = Split-Path -Parent $root if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $root) { return $null } $root = $parent } return (Join-Path $root ("extensions/specrew-speckit/squad-templates/agents/{0}/charter.md" -f $RoleName)) } function Get-SpecrewCanonicalCharterContent { <# .SYNOPSIS Read the canonical charter content for a given role from .specrew/team/agents/<role>.md. Falls back to the shipped template if the canonical doesn't exist yet. .OUTPUTS string (raw charter markdown) or $null if neither exists #> param( [Parameter(Mandatory = $true)][string]$ProjectPath, [Parameter(Mandatory = $true)][string]$RoleName ) $canonical = Join-Path (Get-SpecrewTeamAgentsPath -ProjectPath $ProjectPath) ("{0}.md" -f $RoleName) if (Test-Path -LiteralPath $canonical -PathType Leaf) { return (Get-Content -LiteralPath $canonical -Raw -Encoding UTF8) } # Fallback to shipped template $shipped = Get-SpecrewShippedCharterPath -RoleName $RoleName if ($null -ne $shipped -and (Test-Path -LiteralPath $shipped -PathType Leaf)) { return (Get-Content -LiteralPath $shipped -Raw -Encoding UTF8) } return $null } function Get-SpecrewCanonicalAgentRoles { <# .SYNOPSIS Enumerate all agent roles present in the canonical .specrew/team/agents/ dir. Returns the baseline 5 + any user-added specialists. If the canonical dir doesn't exist, returns only the baseline. .OUTPUTS string[] (role names, e.g., 'spec-steward', 'planner', 'security-analyst') #> param([Parameter(Mandatory = $true)][string]$ProjectPath) $agentsDir = Get-SpecrewTeamAgentsPath -ProjectPath $ProjectPath if (-not (Test-Path -LiteralPath $agentsDir -PathType Container)) { return (Get-SpecrewBaselineCrewRoles) } $files = Get-ChildItem -Path $agentsDir -Filter '*.md' -ErrorAction SilentlyContinue if ($null -eq $files -or $files.Count -eq 0) { return (Get-SpecrewBaselineCrewRoles) } return @($files | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.Name) }) } function Get-SpecrewCharterTagline { <# .SYNOPSIS Extract a one-line description from a charter's markdown — the first blockquote line after the title, which by convention is the role's tagline. Used by per-host handlers to derive `description:` frontmatter / TOML fields when translating canonical charters to host-native subagent formats. .OUTPUTS string — the tagline if found, otherwise a generic "Specrew Crew specialist: <role>." fallback. #> param( [Parameter(Mandatory = $true)][string]$Charter, [Parameter(Mandatory = $true)][string]$Role ) $lines = @($Charter -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) foreach ($line in $lines) { if ($line -match '^>\s*(.+?)\s*$') { return $Matches[1] } } return ("Specrew Crew specialist: {0}." -f $Role) } function Test-SpecrewManagedFile { <# .SYNOPSIS Decide whether a host-native subagent file at $Path is safe for Install-<Kind>CrewRuntime to overwrite. .DESCRIPTION Returns $true if any of the following hold: - The file is missing (safe to create). - A sidecar marker exists at `$Path + '.specrew-managed'`. Used for hosts whose native format does not tolerate an inline comment header (e.g., Copilot's `.squad/agents/<role>/charter.md` which Squad CLI parses as the charter body itself). - The file contains a "Specrew-managed" comment (`#`, `--`, or `<!--` syntax). Returns $false if the file exists without any of those markers, indicating user customization. .OUTPUTS [bool] #> param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $true } $sidecarPath = "{0}.specrew-managed" -f $Path if (Test-Path -LiteralPath $sidecarPath -PathType Leaf) { # THE USER'S BY DISPOSITION (PRED-BETA4-036): a sidecar in its owned form (`owner: user`, written by # `specrew team own <role>`) says the file is the user's - never overwritten, never reported. The # decision is persisted here because a deleted marker cannot distinguish an opt-out from missing # metadata (the auditor's sentence): "delete the sidecar to keep it without this notice" brought the # notice straight back as "no Specrew-managed marker". if (Test-SpecrewUserOwnedSidecar -SidecarPath $sidecarPath) { return $false } # OWNERSHIP IS "SPECREW WROTE THIS EXACT CONTENT" (PRED-BETA4-034). A sidecar that records the SHA-256 of # what was written vouches for the file only while the file still hashes to it; a mismatch is a genuine # user edit and is reported as one, never relabeled. A legacy sidecar with no hash keeps its old meaning. $recorded = Get-SpecrewManagedSidecarHash -SidecarPath $sidecarPath if ([string]::IsNullOrWhiteSpace($recorded)) { return $true } return ($recorded -ceq (Get-SpecrewManagedContentHash -Path $Path)) } $content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($content)) { return $true } return ($content -match '(?m)^\s*(#|--|<!--)\s*Specrew-managed') } function Write-SpecrewManagedSidecar { <# .SYNOPSIS Write a sidecar marker (`<Path>.specrew-managed`) signaling that $Path is Specrew-managed without modifying $Path's content. Used by Install-CopilotCrewRuntime so `charter.md` stays byte-identical to the canonical charter (Squad CLI consumes the file as the body). #> param([Parameter(Mandatory = $true)][string]$Path) $marker = "{0}.specrew-managed" -f $Path [System.IO.File]::WriteAllText($marker, (Get-SpecrewManagedSidecarContent -Path $Path), [System.Text.UTF8Encoding]::new($false)) } function Get-SpecrewManagedContentHash { # The SHA-256 of the file's bytes, lower-case hex - the one hash both sidecar writers (this module's # handlers and init's deploy-squad-runtime.ps1) record and the one the ownership test compares. param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' } return ((Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash).ToLowerInvariant() } function Get-SpecrewManagedSidecarContent { # ONE FORMAT: the sentence, then `sha256: <hex>` of the file as it stands. deploy-squad-runtime.ps1 writes # this same text at init (it cannot dot-source this module); a test asserts the two agree byte for byte. # The sentence names the two remedies that CLEAR the notice (PRED-BETA4-036); it no longer says "delete # this file", which only brought the notice back as "no Specrew-managed marker". param([Parameter(Mandatory = $true)][string]$Path) $role = Get-SpecrewCharterRoleFromPath -Path $Path return ("Specrew wrote $Path from .specrew/team/agents/ and keeps it in sync while it hashes to the value below; an edit is preserved and reported (specrew team own $role keeps it yours, specrew team resync $role returns it to canonical).`nsha256: {0}`n" -f (Get-SpecrewManagedContentHash -Path $Path)) } function Get-SpecrewCharterRoleFromPath { # `.squad/agents/<role>/charter.md` -> `<role>`; the name the `specrew team` remedies take. param([Parameter(Mandatory = $true)][string]$Path) $parent = Split-Path -Parent $Path if ([string]::IsNullOrWhiteSpace($parent)) { return '<role>' } $leaf = Split-Path -Leaf $parent if ([string]::IsNullOrWhiteSpace($leaf)) { return '<role>' } return $leaf } function Get-SpecrewUserOwnedSidecarContent { # THE OWNED FORM (PRED-BETA4-036): the persisted disposition `specrew team own <role>` writes. The file # beside it is the user's: preserved as written, never rewritten from canonical, never reported. param([Parameter(Mandatory = $true)][string]$Path) $role = Get-SpecrewCharterRoleFromPath -Path $Path return ("This charter is yours: $Path is preserved as you wrote it; specrew start neither rewrites nor reports it (specrew team resync $role returns it to Specrew's canonical charter).`nowner: user`n") } function Write-SpecrewUserOwnedSidecar { param([Parameter(Mandatory = $true)][string]$Path) $marker = "{0}.specrew-managed" -f $Path [System.IO.File]::WriteAllText($marker, (Get-SpecrewUserOwnedSidecarContent -Path $Path), [System.Text.UTF8Encoding]::new($false)) } function Test-SpecrewUserOwnedSidecar { param([Parameter(Mandatory = $true)][string]$SidecarPath) try { foreach ($line in @(Get-Content -LiteralPath $SidecarPath -Encoding UTF8 -ErrorAction Stop)) { if ($line -match '^\s*owner:\s*user\s*$') { return $true } } } catch { $null = $_ } return $false } function Test-SpecrewUserOwnedFile { # True when the file beside `<Path>.specrew-managed` carries the owned disposition. param([Parameter(Mandatory = $true)][string]$Path) $sidecarPath = "{0}.specrew-managed" -f $Path if (-not (Test-Path -LiteralPath $sidecarPath -PathType Leaf)) { return $false } return (Test-SpecrewUserOwnedSidecar -SidecarPath $sidecarPath) } function Get-SpecrewManagedSidecarHash { param([Parameter(Mandatory = $true)][string]$SidecarPath) try { foreach ($line in @(Get-Content -LiteralPath $SidecarPath -Encoding UTF8 -ErrorAction Stop)) { if ($line -match '^\s*sha256:\s*([0-9a-fA-F]{64})\s*$') { return $Matches[1].ToLowerInvariant() } } } catch { $null = $_ } return '' } function Test-SpecrewManagedDirectivesBlock { # Init's composition: the shipped charter plus a `specrew-managed directives` block. A charter carrying it # is Specrew's own; the host handler keeps it rather than replacing it with the shorter canonical body. param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } $content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue return (-not [string]::IsNullOrEmpty($content) -and $content -match '<!-- >>> specrew-managed directives >>> -->') } $script:SpecrewDirectivesBlockPattern = '(?s)<!-- >>> specrew-managed directives >>> -->.*?<!-- <<< specrew-managed directives <<< -->' function Get-SpecrewManagedDirectivesBlockText { # The directives block as it stands in the file, opener through closer, or $null when the charter has # none. The shared writer carries it across a rewrite from canonical so a canonical change never drops it. param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } $content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($content)) { return $null } $m = [regex]::Match($content, $script:SpecrewDirectivesBlockPattern) if (-not $m.Success) { return $null } return $m.Value } function Get-SpecrewCharterCanonicalPart { # The charter's text before its directives block (the whole text when it has none), trailing whitespace # trimmed - the part that must equal the canonical charter for the file to count as current. param([AllowEmptyString()][string]$Content) if ([string]::IsNullOrEmpty($Content)) { return '' } $index = $Content.IndexOf('<!-- >>> specrew-managed directives >>> -->', [System.StringComparison]::Ordinal) $part = if ($index -ge 0) { $Content.Substring(0, $index) } else { $Content } return $part.TrimEnd() } function Test-SpecrewCharterCurrent { # PRED-BETA4-036 (the auditor's finding A): a matching output hash proves the user did not edit the file; # it does not prove its canonical input is current. Current means the text before the block equals the # canonical charter (line endings and trailing whitespace aside). Missing file: not current. param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$CanonicalContent ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } $content = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue $have = (Get-SpecrewCharterCanonicalPart -Content $content) -replace "`r`n", "`n" $want = ($CanonicalContent.TrimEnd()) -replace "`r`n", "`n" return ($have -ceq $want) } function Write-SpecrewCharterFromCanonical { <# .SYNOPSIS THE ONE SHARED WRITER (PRED-BETA4-036): the canonical charter, then the charter's existing directives block when it has one, then the managed sidecar re-stamped with the hash of what was written. .DESCRIPTION The composition is byte for byte the shape init's Set-ManagedBlock creates (base, blank line, block, newline), so a charter written here reads as current on the next start and init's deploy step reads it as untouched. Used by Install-CopilotCrewRuntime (create and canonical-changed update) and by `specrew team resync`. #> param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$CanonicalContent, [AllowNull()][AllowEmptyString()][string]$DirectivesBlock = $null ) $composed = if ([string]::IsNullOrWhiteSpace($DirectivesBlock)) { $CanonicalContent } else { $CanonicalContent.TrimEnd() + [Environment]::NewLine + [Environment]::NewLine + $DirectivesBlock.Trim() + [Environment]::NewLine } $parent = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent -PathType Container)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } [System.IO.File]::WriteAllText($Path, $composed, [System.Text.UTF8Encoding]::new($false)) Write-SpecrewManagedSidecar -Path $Path } function Get-SpecrewHostAgentRoot { <# .SYNOPSIS Resolve the per-host agent-root directory from the manifest's AgentDir field. Open-Closed: every supported host declares AgentDir in its manifest, so adding a new host adds one manifest line, no edits to the Install-<Kind>CrewRuntime handlers or the host-runtime-inventory iterator. .OUTPUTS string (absolute path with platform-native separators, trailing separator stripped) .NOTES Throws if the manifest is missing or doesn't declare AgentDir — by design. A "supported" host without AgentDir cannot deploy its Crew runtime. #> param( [Parameter(Mandatory = $true)][string]$HostKind, [Parameter(Mandatory = $true)][string]$ProjectPath ) $manifest = Get-HostManifest -Kind $HostKind if (-not $manifest.ContainsKey('AgentDir') -or [string]::IsNullOrWhiteSpace([string]$manifest.AgentDir)) { throw "Host '$HostKind' manifest is missing required AgentDir field. Add AgentDir to hosts/$HostKind/host.psd1." } $rel = ([string]$manifest.AgentDir) -replace '/', [System.IO.Path]::DirectorySeparatorChar return (Join-Path $ProjectPath $rel.TrimEnd([System.IO.Path]::DirectorySeparatorChar)) } function Initialize-SpecrewTeamCanonical { <# .SYNOPSIS Populate .specrew/team/agents/ from the shipped baseline charters. Idempotent — preserves existing files (user customizations + user-added agents). .OUTPUTS pscustomobject @{ Actions[]; CanonicalRoot } #> param( [Parameter(Mandatory = $true)][string]$ProjectPath, [switch]$DryRun ) $actions = New-Object System.Collections.Generic.List[hashtable] $canonicalRoot = Get-SpecrewTeamCanonicalPath -ProjectPath $ProjectPath $agentsDir = Get-SpecrewTeamAgentsPath -ProjectPath $ProjectPath if (-not (Test-Path -LiteralPath $agentsDir -PathType Container)) { if (-not $DryRun) { New-Item -ItemType Directory -Path $agentsDir -Force | Out-Null } $actions.Add(@{ Action = $(if ($DryRun) { 'would-create' } else { 'created' }); Path = $agentsDir }) | Out-Null } foreach ($role in Get-SpecrewBaselineCrewRoles) { $target = Join-Path $agentsDir ("{0}.md" -f $role) if (Test-Path -LiteralPath $target -PathType Leaf) { $actions.Add(@{ Action = 'preserved'; Path = $target }) | Out-Null continue } $shipped = Get-SpecrewShippedCharterPath -RoleName $role if ($null -eq $shipped -or -not (Test-Path -LiteralPath $shipped -PathType Leaf)) { $actions.Add(@{ Action = 'skipped'; Path = $target; Warning = "shipped baseline not found at expected path" }) | Out-Null continue } if ($DryRun) { $actions.Add(@{ Action = 'would-create'; Path = $target }) | Out-Null } else { $content = Get-Content -LiteralPath $shipped -Raw -Encoding UTF8 [System.IO.File]::WriteAllText($target, $content, [System.Text.UTF8Encoding]::new($false)) $actions.Add(@{ Action = 'created'; Path = $target }) | Out-Null } } return [pscustomobject]@{ Actions = $actions.ToArray() CanonicalRoot = $canonicalRoot } } |