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