scripts/internal/bootstrap/HostEventAdapter.ps1

<#
.SYNOPSIS
  Normalize a host SessionStart/SessionEnd hook event into a Specrew-internal shape.
.DESCRIPTION
  Volatile adapter (IDesign): isolates per-host hook payload differences behind a stable
  PSCustomObject contract consumed by SessionBootstrapManager. Pure transform - no
  filesystem or git access. Feature 174 (FR-001, FR-005). Iteration 001 targets the Claude
  payload; other hosts are added in iteration 003 (T016).
.OUTPUTS
  [pscustomobject] { host, event_name, source, session_id, safe_session_id, project_root, parsed }
#>


function Get-SpecrewEventField {
    # Defensive multi-key read: hosts vary in casing/naming (snake_case vs camelCase), so try each
    # candidate key in priority order and return the first non-empty. This normalizes the common
    # variants WITHOUT hardcoding unverified per-host schemas - an unknown field simply yields $null
    # and the bootstrap degrades to full mode (fail-open). FR-005.
    param([AllowNull()]$Payload, [Parameter(Mandatory)][string[]] $Names)
    if ($null -eq $Payload) { return $null }
    foreach ($n in $Names) {
        $p = $Payload.PSObject.Properties[$n]
        if (-not $p -or $null -eq $p.Value) { continue }
        if ($p.Value -is [System.Array]) {
            foreach ($item in @($p.Value)) {
                if (-not [string]::IsNullOrWhiteSpace([string]$item)) { return [string]$item }
            }
            continue
        }
        if (-not [string]::IsNullOrWhiteSpace([string]$p.Value)) { return $p.Value }
    }
    return $null
}

function New-SpecrewPerLaunchSessionToken {
    return ('launch-{0}' -f ([guid]::NewGuid().ToString('N')))
}

function ConvertTo-SpecrewFilesystemSafeSessionId {
    param([AllowNull()][string]$RawSessionId)

    if ([string]::IsNullOrWhiteSpace($RawSessionId)) {
        return (New-SpecrewPerLaunchSessionToken)
    }

    $clean = ([string]$RawSessionId) -replace '[^a-zA-Z0-9-]+', '-'
    $clean = $clean.Trim('-')
    if ([string]::IsNullOrWhiteSpace($clean)) {
        return (New-SpecrewPerLaunchSessionToken)
    }
    return $clean
}

function ConvertFrom-SpecrewHostHookEvent {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        # Raw hook event payload as the host emits it (JSON text). Empty/garbage is tolerated.
        [Parameter(Mandatory)][AllowEmptyString()][string] $RawEvent,
        # The launching host. ('Host' is a PowerShell automatic variable, so this is HostName.)
        [Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9_.-]+$')][string] $HostName,
        # Optional explicit project root; falls back to the event's cwd.
        [Parameter()][string] $ProjectRoot
    )

    $payload = $null
    if (-not [string]::IsNullOrWhiteSpace($RawEvent)) {
        try { $payload = $RawEvent | ConvertFrom-Json -ErrorAction Stop } catch { $payload = $null }
    }

    # Per-host field normalization (FR-005): try snake_case + camelCase variants across hosts.
    $sessionId = Get-SpecrewEventField $payload @('session_id', 'sessionId', 'conversation_id', 'conversationId', 'id')
    $source    = Get-SpecrewEventField $payload @('source', 'trigger', 'reason')
    $eventName = Get-SpecrewEventField $payload @('hook_event_name', 'hookEventName', 'event_name')
    $cwd       = Get-SpecrewEventField $payload @('cwd', 'workspace_root', 'workspaceRoot', 'workspacePaths', 'project_root', 'projectRoot', 'workingDirectory')

    # Sanitize the session id before it is ever used in a path. Missing or malformed ids get a
    # per-launch token, never a global "unknown" bucket.
    $safeSessionId = ConvertTo-SpecrewFilesystemSafeSessionId -RawSessionId $sessionId
    $resolvedRoot = if ($ProjectRoot) { $ProjectRoot } elseif ($cwd) { $cwd } else { $null }

    [pscustomobject]@{
        host            = $HostName
        event_name      = $eventName
        source          = $source
        session_id      = $sessionId
        safe_session_id = $safeSessionId
        project_root    = $resolvedRoot
        parsed          = ($null -ne $payload)
    }
}