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
    }
    if (Test-Path -LiteralPath ("{0}.specrew-managed" -f $Path) -PathType Leaf) {
        return $true
    }
    $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, "Generated from .specrew/team/agents/. Delete this file to retain a user-customized $Path on next specrew start.`n", [System.Text.UTF8Encoding]::new($false))
}

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
    }
}