Payload/scripts/kaden/utils/tail-journal.ps1

<#
.SYNOPSIS
    Tail the journal/events.jsonl for a harness feature.

.DESCRIPTION
    Reads the most recent events from docs/harness/features/<Feature>/journal/events.jsonl
    and prints them with aligned columns and CliKit coloring. Gaps between events in the
    same slice that exceed a threshold are highlighted in red so long-running work is easy
    to spot. Reviewer subagents, orchestrator, and human gates are exempt from the gap
    highlight because those rows represent waiting time, not processing time.

.PARAMETER Feature
    Feature slug. The directory docs/harness/features/<Feature> must exist.

.PARAMETER TailSize
    Number of recent events to display. Default: 50.

.PARAMETER LogLevel
    compact or detail. Default: compact.

.PARAMETER Wait
    After printing the tail, keep watching the file for new events.

.PARAMETER ThresholdMinutes
    Gap threshold for red time highlighting. Default: 5.

.PARAMETER FeatureRoot
    Optional override for the directory that contains feature folders.
    Defaults to docs/harness/features under the repository root.

.PARAMETER NoColor
    Print plain text without CliKit color tokens.

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\tail-journal.ps1 -Feature host-surface-consistency

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\tail-journal.ps1 -Feature host-surface-consistency -Wait -LogLevel detail
#>


#Requires -Version 5.1
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true, Position = 0)]
    [string]$Feature,

    [ValidateRange(1, 10000)]
    [int]$TailSize = 50,

    [ValidateSet("compact", "detail")]
    [string]$LogLevel = "compact",

    [switch]$Wait,

    [ValidateRange(0, 1440)]
    [int]$ThresholdMinutes = 5,

    [string]$FeatureRoot = "",

    [switch]$NoColor
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$cliKitManifest = Join-Path $PSScriptRoot "PsCliUiKit\PsCliUiKit.psd1"
if (-not (Test-Path -LiteralPath $cliKitManifest)) {
    throw "PsCliUiKit manifest not found: $cliKitManifest"
}
Import-Module -Name $cliKitManifest -Force

$script:Colorize = -not $NoColor.IsPresent

$script:PausedAgents = @(
    "slice-reviewer"
    "spec-writer-reviewer"
    "test-design-reviewer"
    "atdd-developer-reviewer"
    "code-smell-detector"
    "security-assessor"
    "consistency-checker"
    "documentation-reviewer"
    "feature-orchestrator"
)

$script:PausedKinds = @(
    "HumanGateDecided"
)

function Resolve-TailJournalRepoRoot {
    $current = $PSScriptRoot
    while ($true) {
        $marker = Join-Path $current "scripts\kaden\harness-invoke.ps1"
        if (Test-Path -LiteralPath $marker) {
            return $current
        }
        $parent = Split-Path -Path $current -Parent
        if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) {
            return $null
        }
        $current = $parent
    }
}

function Get-TailJournalConsoleWidth {
    try {
        $width = $Host.UI.RawUI.WindowSize.Width
        if ($width -gt 0) {
            return [int]$width
        }
    } catch {
        [void]$_
    }
    try {
        $width = $Host.UI.RawUI.BufferSize.Width
        if ($width -gt 0) {
            return [int]$width
        }
    } catch {
        [void]$_
    }
    return 120
}

function ConvertTo-TailJournalLocalTime {
    param(
        [Parameter(Mandatory = $true)]
        [string]$OccurredAt
    )

    $utc = [System.DateTime]::Parse($OccurredAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind)
    $local = [System.TimeZoneInfo]::ConvertTimeFromUtc($utc, [System.TimeZoneInfo]::Local)
    return $local
}

function Format-TailJournalTime {
    param(
        [Parameter(Mandatory = $true)]
        [System.DateTime]$LocalTime
    )

    return $LocalTime.ToString("yyyy-MM-dd HH:mm:ss")
}

function Get-TailJournalOutcomeStyle {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Outcome
    )

    switch ($Outcome) {
        "PASS" { return "success" }
        "FAIL" { return "error" }
        "BLOCKED" { return "error" }
        "SKIPPED" { return "warning" }
        default { return $null }
    }
}

function Test-TailJournalPausedEvent {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Entry
    )

    if ($script:PausedKinds -contains $Entry.kind) {
        return $true
    }
    $agent = $Entry.actor.agent
    if ($script:PausedAgents -contains $agent) {
        return $true
    }
    return $false
}

function Get-TailJournalCommandLeaf {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Command
    )

    if ([string]::IsNullOrWhiteSpace($Command)) {
        return ""
    }
    $first = ($Command -split '\s+')[0]
    if ($first -match '\\|/') {
        return [System.IO.Path]::GetFileName($first)
    }
    return $first
}

function Get-TailJournalProviderSlug {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Entry
    )

    $provider = $Entry.actor.model.provider
    $slug = $Entry.actor.model.slug
    if (-not [string]::IsNullOrWhiteSpace($provider) -and -not [string]::IsNullOrWhiteSpace($slug)) {
        return "$provider`:$slug"
    }
    if (-not [string]::IsNullOrWhiteSpace($provider)) {
        return $provider
    }
    if (-not [string]::IsNullOrWhiteSpace($slug)) {
        return $slug
    }
    return ""
}

function Format-TailJournalSummary {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Summary,

        [Parameter(Mandatory = $true)]
        [int]$MaxLength
    )

    if ($MaxLength -lt 3) {
        return ""
    }
    if ($Summary.Length -le $MaxLength) {
        return $Summary
    }
    return $Summary.Substring(0, $MaxLength - 3) + "..."
}

function Format-TailJournalCell {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Value,

        [Parameter(Mandatory = $true)]
        [int]$Width,

        [string]$Style = $null,

        [ValidateSet("left", "right")]
        [string]$Align = "left"
    )

    $displayValue = if ($Value.Length -gt $Width) { $Value.Substring(0, $Width) } else { $Value }
    if ($Align -eq "left") {
        $padded = $displayValue.PadRight($Width)
    } else {
        $padded = $displayValue.PadLeft($Width)
    }
    if (-not $script:Colorize -or [string]::IsNullOrWhiteSpace($Style)) {
        return $padded
    }
    return "[$Style]$padded[-]"
}

function Format-TailJournalCompactRow {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Entry,

        [Parameter(Mandatory = $true)]
        [hashtable]$Widths,

        [Parameter(Mandatory = $true)]
        [bool]$LongGap,

        [Parameter(Mandatory = $true)]
        [int]$ConsoleWidth
    )

    $time = Format-TailJournalTime -LocalTime $Entry._localTime
    $slice = [string]$Entry.slice
    $kind = $Entry.kind
    $outcome = [string]$Entry.outcome
    $command = Get-TailJournalCommandLeaf -Command $Entry.refs.command
    $summary = [string]$Entry.summary

    $timeStyle = if ($LongGap) { "error" } else { $null }
    $outcomeStyle = Get-TailJournalOutcomeStyle -Outcome $outcome

    $used = $Widths.Time + $Widths.Slice + $Widths.Kind + $Widths.Outcome + $Widths.Command + 10
    $summaryMax = [Math]::Max(20, $ConsoleWidth - $used)
    $summary = Format-TailJournalSummary -Summary $summary -MaxLength $summaryMax

    $parts = @(
        Format-TailJournalCell -Value $time -Width $Widths.Time -Style $timeStyle -Align "left"
        Format-TailJournalCell -Value $slice -Width $Widths.Slice -Style "muted" -Align "left"
        Format-TailJournalCell -Value $kind -Width $Widths.Kind -Style "info" -Align "left"
        Format-TailJournalCell -Value $outcome -Width $Widths.Outcome -Style $outcomeStyle -Align "left"
        Format-TailJournalCell -Value $command -Width $Widths.Command -Style $null -Align "left"
        Format-TailJournalCell -Value $summary -Width $summaryMax -Style "muted" -Align "left"
    )

    return ($parts -join " ").TrimEnd()
}

function Format-TailJournalDetailRow {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Entry,

        [Parameter(Mandatory = $true)]
        [hashtable]$Widths,

        [Parameter(Mandatory = $true)]
        [bool]$LongGap,

        [Parameter(Mandatory = $true)]
        [int]$ConsoleWidth
    )

    $time = Format-TailJournalTime -LocalTime $Entry._localTime
    $slice = [string]$Entry.slice
    $kind = $Entry.kind
    $activity = [string]$Entry.activity.name
    $command = Get-TailJournalCommandLeaf -Command $Entry.refs.command
    $outcome = [string]$Entry.outcome
    $model = Get-TailJournalProviderSlug -Entry $Entry
    $summary = [string]$Entry.summary

    $timeStyle = if ($LongGap) { "error" } else { $null }
    $outcomeStyle = Get-TailJournalOutcomeStyle -Outcome $outcome

    $used = $Widths.Time + $Widths.Slice + $Widths.Kind + $Widths.Activity + $Widths.Command + $Widths.Outcome + $Widths.Model + 14
    $summaryMax = [Math]::Max(20, $ConsoleWidth - $used)
    $summary = Format-TailJournalSummary -Summary $summary -MaxLength $summaryMax

    $parts = @(
        Format-TailJournalCell -Value $time -Width $Widths.Time -Style $timeStyle -Align "left"
        Format-TailJournalCell -Value $slice -Width $Widths.Slice -Style "muted" -Align "left"
        Format-TailJournalCell -Value $kind -Width $Widths.Kind -Style "info" -Align "left"
        Format-TailJournalCell -Value $activity -Width $Widths.Activity -Style "muted" -Align "left"
        Format-TailJournalCell -Value $command -Width $Widths.Command -Style $null -Align "left"
        Format-TailJournalCell -Value $outcome -Width $Widths.Outcome -Style $outcomeStyle -Align "left"
        Format-TailJournalCell -Value $model -Width $Widths.Model -Style "muted" -Align "left"
        Format-TailJournalCell -Value $summary -Width $summaryMax -Style "muted" -Align "left"
    )

    return ($parts -join " ").TrimEnd()
}

function Get-TailJournalTotalEventCount {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        return 0
    }
    $count = 0
    foreach ($line in [System.IO.File]::ReadLines($Path)) {
        if (-not [string]::IsNullOrWhiteSpace($line)) {
            $count++
        }
    }
    return $count
}

function Format-TailJournalHeader {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Feature,

        [Parameter(Mandatory = $true)]
        [object[]]$Entries,

        [Parameter(Mandatory = $true)]
        [int]$TotalEvents,

        [switch]$Live
    )

    $sliceCount = @($Entries | Select-Object -ExpandProperty slice -Unique | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count
    $firstUtc = $Entries[0].occurred_at
    $lastUtc = $Entries[-1].occurred_at

    $eventLabel = if ($Live) { "Event Pagesize: $($Entries.Count)" } else { "Events: $($Entries.Count) of $TotalEvents" }
    return "Feature: $Feature | Slices: $sliceCount | $eventLabel | $firstUtc -> $lastUtc"
}

function Format-TailJournalColumnHeader {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Mode,

        [Parameter(Mandatory = $true)]
        [hashtable]$Widths
    )

    $columns = Get-TailJournalColumnOrder -Mode $Mode
    $parts = @()
    foreach ($column in $columns) {
        $parts += Format-TailJournalCell -Value $column -Width $Widths[$column] -Style $null
    }
    return ($parts -join " ").TrimEnd()
}

function Format-TailJournalDivider {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Mode,

        [Parameter(Mandatory = $true)]
        [hashtable]$Widths
    )

    $columns = Get-TailJournalColumnOrder -Mode $Mode
    $char = if ($script:Colorize) { Get-CliGlyph -Name BoxHorizontal } else { "-" }
    $parts = @()
    foreach ($column in $columns) {
        $parts += $char * $Widths[$column]
    }
    return ($parts -join " ").TrimEnd()
}

function Get-TailJournalColumnOrder {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Mode
    )

    if ($Mode -eq "detail") {
        return @("Time", "Slice", "Kind", "Activity", "Command", "Outcome", "Model", "Summary")
    }
    return @("Time", "Slice", "Kind", "Outcome", "Command", "Summary")
}

function Get-TailJournalColumnWidth {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Mode,

        [Parameter(Mandatory = $true)]
        [object[]]$Entries
    )

    $columns = Get-TailJournalColumnOrder -Mode $Mode
    $widths = @{}
    foreach ($column in $columns) {
        $widths[$column] = $column.Length
    }
    $widths["Time"] = 19

    $caps = if ($Mode -eq "detail") {
        @{
            Slice = 18
            Kind = 18
            Activity = 16
            Command = 20
            Outcome = 8
            Model = 18
        }
    } else {
        @{
            Slice = 24
            Kind = 24
            Outcome = 8
            Command = 22
        }
    }

    foreach ($entry in $Entries) {
        $widths["Slice"] = [Math]::Max($widths["Slice"], ([string]$entry.slice).Length)
        $widths["Kind"] = [Math]::Max($widths["Kind"], ([string]$entry.kind).Length)
        $widths["Outcome"] = [Math]::Max($widths["Outcome"], ([string]$entry.outcome).Length)
        $widths["Command"] = [Math]::Max($widths["Command"], (Get-TailJournalCommandLeaf -Command $entry.refs.command).Length)
        if ($Mode -eq "detail") {
            $widths["Activity"] = [Math]::Max($widths["Activity"], ([string]$entry.activity.name).Length)
            $widths["Model"] = [Math]::Max($widths["Model"], (Get-TailJournalProviderSlug -Entry $entry).Length)
        }
    }

    foreach ($column in $caps.Keys) {
        $widths[$column] = [Math]::Min($widths[$column], $caps[$column])
        if ($widths[$column] -lt $column.Length) {
            $widths[$column] = $column.Length
        }
    }

    return $widths
}

function Write-TailJournalEvent {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Entry,

        [Parameter(Mandatory = $true)]
        [hashtable]$Widths,

        [Parameter(Mandatory = $true)]
        [hashtable]$LastTimeBySlice,

        [Parameter(Mandatory = $true)]
        [hashtable]$SeenSlice,

        [Parameter(Mandatory = $true)]
        [System.TimeSpan]$Threshold,

        [Parameter(Mandatory = $true)]
        [int]$ConsoleWidth
    )

    $slice = [string]$Entry.slice
    $localTime = ConvertTo-TailJournalLocalTime -OccurredAt $Entry.occurred_at
    $Entry | Add-Member -NotePropertyName "_localTime" -NotePropertyValue $localTime -Force

    $longGap = $false
    if (-not $SeenSlice.ContainsKey($slice)) {
        $SeenSlice[$slice] = $true
    } else {
        $previous = $LastTimeBySlice[$slice]
        if ($null -ne $previous) {
            $gap = $localTime - $previous
            if ($gap -gt $Threshold -and -not (Test-TailJournalPausedEvent -Entry $Entry)) {
                $longGap = $true
            }
        }
    }
    $LastTimeBySlice[$slice] = $localTime

    if ($LogLevel -eq "detail") {
        $line = Format-TailJournalDetailRow -Entry $Entry -Widths $Widths -LongGap $longGap -ConsoleWidth $ConsoleWidth
    } else {
        $line = Format-TailJournalCompactRow -Entry $Entry -Widths $Widths -LongGap $longGap -ConsoleWidth $ConsoleWidth
    }

    if (-not $script:Colorize) {
        Write-Output $line
    } else {
        Write-CliColor -Message $line
    }
}

function Read-TailJournalEvent {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [int]$TailSize
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        return @()
    }
    $lines = @(Get-Content -LiteralPath $Path -Tail $TailSize)
    $events = [System.Collections.Generic.List[object]]::new()
    foreach ($line in $lines) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        try {
            $obj = $line | ConvertFrom-Json
            $events.Add($obj)
        } catch {
            Write-Warning "Skipped malformed JSONL line: $_"
        }
    }
    return $events
}

function Write-TailJournalStream {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [hashtable]$Widths,

        [Parameter(Mandatory = $true)]
        [hashtable]$LastTimeBySlice,

        [Parameter(Mandatory = $true)]
        [hashtable]$SeenSlice,

        [Parameter(Mandatory = $true)]
        [System.TimeSpan]$Threshold,

        [Parameter(Mandatory = $true)]
        [int]$ConsoleWidth
    )

    Get-Content -LiteralPath $Path -Tail 0 -Wait | ForEach-Object {
        if ([string]::IsNullOrWhiteSpace($_)) { return }
        try {
            $entry = $_ | ConvertFrom-Json
            Write-TailJournalEvent -Entry $entry -Widths $Widths -LastTimeBySlice $LastTimeBySlice -SeenSlice $SeenSlice -Threshold $Threshold -ConsoleWidth $ConsoleWidth
        } catch {
            Write-Warning "Skipped malformed JSONL line: $_"
        }
    }
}

# --- Main ---

$repoRoot = Resolve-TailJournalRepoRoot
if (-not $repoRoot) {
    throw "Could not locate repository root from $PSScriptRoot"
}

$featuresRoot = if (-not [string]::IsNullOrWhiteSpace($FeatureRoot)) { $FeatureRoot } else { Join-Path $repoRoot "docs\harness\features" }
$featurePath = Join-Path $featuresRoot $Feature
if (-not (Test-Path -LiteralPath $featurePath)) {
    Write-CliStatus -State Error -Message "Feature directory not found: $featurePath"
    exit 2
}

$journalPath = Join-Path $featurePath "journal\events.jsonl"
if (-not (Test-Path -LiteralPath $journalPath)) {
    Write-CliStatus -State Warning -Message "No journal found for $Feature"
    exit 0
}

$threshold = [System.TimeSpan]::FromMinutes($ThresholdMinutes)
$consoleWidth = Get-TailJournalConsoleWidth

$lastTimeBySlice = @{}
$seenSlice = @{}

$events = @(Read-TailJournalEvent -Path $journalPath -TailSize $TailSize)
if ($events.Count -eq 0) {
    Write-CliStatus -State Info -Message "Journal is empty for $Feature"
    exit 0
}

$totalEvents = Get-TailJournalTotalEventCount -Path $journalPath
$widths = Get-TailJournalColumnWidth -Mode $LogLevel -Entries $events

$header = Format-TailJournalHeader -Feature $Feature -Entries $events -TotalEvents $totalEvents -Live:$Wait
Write-CliColor -Message $header -Style Info

$columnHeader = Format-TailJournalColumnHeader -Mode $LogLevel -Widths $widths
Write-CliColor -Message $columnHeader -Style Muted

$divider = Format-TailJournalDivider -Mode $LogLevel -Widths $widths
Write-CliColor -Message $divider -Style Muted

foreach ($entry in $events) {
    Write-TailJournalEvent -Entry $entry -Widths $widths -LastTimeBySlice $lastTimeBySlice -SeenSlice $seenSlice -Threshold $threshold -ConsoleWidth $consoleWidth
}

if ($Wait) {
    Write-TailJournalStream -Path $journalPath -Widths $widths -LastTimeBySlice $lastTimeBySlice -SeenSlice $seenSlice -Threshold $threshold -ConsoleWidth $consoleWidth
}