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.
    With -Wait, the screen stays on the last TailSize events and redraws when the journal changes.
    A clock under the rows shows how long ago the latest event occurred, and checks for new events once a second.

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

.PARAMETER TailSize
    Number of recent events to keep on screen. Default: 50.

.PARAMETER LogLevel
    compact or detail. Default: compact.

.PARAMETER Wait
    Keep redrawing the last TailSize events as the journal changes, and show how long ago the latest event occurred.

.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:TailJournalIdleWidth = 0

$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 Format-TailJournalKindLabel {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Kind
    )

    if ([string]::IsNullOrEmpty($Kind)) {
        return ""
    }
    return [regex]::Replace($Kind, '(?<=[a-z])(?=[A-Z])', ' ')
}

function Format-TailJournalSinceLastEvent {
    param(
        [Parameter(Mandatory = $true)]
        [System.TimeSpan]$Elapsed
    )

    if ($Elapsed.Ticks -lt 0 -or $Elapsed.TotalSeconds -lt 1) {
        return "0s since last event"
    }
    $totalSeconds = [int][Math]::Floor($Elapsed.TotalSeconds)
    $days = [int][Math]::Floor($totalSeconds / 86400)
    $hours = [int][Math]::Floor(($totalSeconds % 86400) / 3600)
    $minutes = [int][Math]::Floor(($totalSeconds % 3600) / 60)
    $seconds = $totalSeconds % 60
    $parts = New-Object System.Collections.Generic.List[string]
    if ($days -gt 0) { [void]$parts.Add("${days}d") }
    if ($hours -gt 0) { [void]$parts.Add("${hours}h") }
    if ($minutes -gt 0) { [void]$parts.Add("${minutes}m") }
    if ($seconds -gt 0 -or $parts.Count -eq 0) { [void]$parts.Add("${seconds}s") }
    return (($parts -join " ") + " since last event")
}

function Get-TailJournalLastEventTime {
    param(
        [Parameter(Mandatory = $true)]
        $Entries
    )

    $list = @($Entries)
    if ($list.Count -eq 0 -or $null -eq $list[-1]) {
        return $null
    }
    $occurred = Get-TailJournalEntryField -Entry $list[-1] -Path @("occurred_at")
    if ([string]::IsNullOrWhiteSpace($occurred)) {
        return $null
    }
    return ConvertTo-TailJournalLocalTime -OccurredAt $occurred
}

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 Get-TailJournalEntryField {
    # Journal events are schema-v1 but older/foreign writers omit optional blocks
    # (actor.model, refs, activity). StrictMode makes any missing hop throw, so
    # every optional read goes through this accessor and degrades to "".
    param(
        [Parameter(Mandatory = $true)]
        [object]$Entry,

        [Parameter(Mandatory = $true)]
        [string[]]$Path
    )

    $current = $Entry
    foreach ($name in $Path) {
        if ($null -eq $current) {
            return ""
        }
        $property = $current.PSObject.Properties[$name]
        if ($null -eq $property) {
            return ""
        }
        $current = $property.Value
    }
    if ($null -eq $current) {
        return ""
    }
    return [string]$current
}

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

    if ($script:PausedKinds -contains (Get-TailJournalEntryField -Entry $Entry -Path @("kind"))) {
        return $true
    }
    $agent = Get-TailJournalEntryField -Entry $Entry -Path @("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]
    # Path.GetFileName keeps '\' on Linux. Journal commands are stored with either separator.
    $normalized = $first.Replace('\', '/')
    $slash = $normalized.LastIndexOf('/')
    if ($slash -ge 0 -and $slash -lt ($normalized.Length - 1)) {
        return $normalized.Substring($slash + 1)
    }
    return $first
}

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

    $provider = Get-TailJournalEntryField -Entry $Entry -Path @("actor", "model", "provider")
    $slug = Get-TailJournalEntryField -Entry $Entry -Path @("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 = Get-TailJournalEntryField -Entry $Entry -Path @("slice")
    $kind = Format-TailJournalKindLabel -Kind (Get-TailJournalEntryField -Entry $Entry -Path @("kind"))
    $outcome = Get-TailJournalEntryField -Entry $Entry -Path @("outcome")
    $command = Get-TailJournalCommandLeaf -Command (Get-TailJournalEntryField -Entry $Entry -Path @("refs", "command"))
    $summary = Get-TailJournalEntryField -Entry $Entry -Path @("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 = Get-TailJournalEntryField -Entry $Entry -Path @("slice")
    $kind = Format-TailJournalKindLabel -Kind (Get-TailJournalEntryField -Entry $Entry -Path @("kind"))
    $activity = Get-TailJournalEntryField -Entry $Entry -Path @("activity", "name")
    $command = Get-TailJournalCommandLeaf -Command (Get-TailJournalEntryField -Entry $Entry -Path @("refs", "command"))
    $outcome = Get-TailJournalEntryField -Entry $Entry -Path @("outcome")
    $model = Get-TailJournalProviderSlug -Entry $Entry
    $summary = Get-TailJournalEntryField -Entry $Entry -Path @("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-TailJournalFileSummary {
    # Single full-file pass so the header reports journal-wide numbers, not just the
    # slice of the journal the tail happened to load (matters for -Wait and small
    # -TailSize, where entries are a partial snapshot).
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        return [pscustomobject]@{ Events = 0; Slices = 0 }
    }
    $count = 0
    $slices = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($line in [System.IO.File]::ReadLines($Path)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        $count++
        try {
            $parsed = $line | ConvertFrom-Json
        } catch {
            continue
        }
        $slice = Get-TailJournalEntryField -Entry $parsed -Path @("slice")
        if (-not [string]::IsNullOrWhiteSpace($slice)) {
            [void]$slices.Add($slice)
        }
    }
    return [pscustomobject]@{ Events = $count; Slices = $slices.Count }
}

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

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

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

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

    $firstLocal = ConvertTo-TailJournalLocalTime -OccurredAt $Entries[0].occurred_at
    $lastLocal = ConvertTo-TailJournalLocalTime -OccurredAt $Entries[-1].occurred_at
    $invariant = [System.Globalization.CultureInfo]::InvariantCulture
    $first = $firstLocal.ToString("HH:mm", $invariant)
    $last = $lastLocal.ToString("HH:mm", $invariant)
    $arrow = Get-CliGlyph -Name Arrow
    $shown = $Entries.Count
    $range = "$first $arrow $last"

    if (-not $script:Colorize) {
        return "Feature: $Feature | Slices: $TotalSlices | Events: $shown of $TotalEvents | $range"
    }

    return "[muted]Feature:[-] [header]$Feature[-] [muted]| Slices:[-] [header]$TotalSlices[-] [muted]| Events:[-] [header]$shown[-] [muted]of[-] [header]$TotalEvents[-] [muted]| $range[-]"
}

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 = 20
            Activity = 16
            Command = 20
            Outcome = 8
            Model = 18
        }
    } else {
        @{
            Slice = 24
            Kind = 26
            Outcome = 8
            Command = 22
        }
    }

    foreach ($entry in $Entries) {
        $widths["Slice"] = [Math]::Max($widths["Slice"], (Get-TailJournalEntryField -Entry $entry -Path @("slice")).Length)
        $kindLabel = Format-TailJournalKindLabel -Kind (Get-TailJournalEntryField -Entry $entry -Path @("kind"))
        $widths["Kind"] = [Math]::Max($widths["Kind"], $kindLabel.Length)
        $widths["Outcome"] = [Math]::Max($widths["Outcome"], (Get-TailJournalEntryField -Entry $entry -Path @("outcome")).Length)
        $widths["Command"] = [Math]::Max($widths["Command"], (Get-TailJournalCommandLeaf -Command (Get-TailJournalEntryField -Entry $entry -Path @("refs", "command"))).Length)
        if ($Mode -eq "detail") {
            $widths["Activity"] = [Math]::Max($widths["Activity"], (Get-TailJournalEntryField -Entry $entry -Path @("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 = Get-TailJournalEntryField -Entry $Entry -Path @("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-CliColor -Message $line -Literal
    } 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-TailJournalFileTail -Path $Path -TailSize $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 Get-TailJournalFileTail {
    # The journal writer (HarnessJournal.psm1) takes a brief exclusive lock
    # (FileShare.None) around every append, and editors/AV can hold one too.
    # A read that collides throws IOException, so retry with backoff before giving up.
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

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

        [int]$MaxAttempts = 5
    )

    $delayMs = 100
    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            return @(Get-Content -LiteralPath $Path -Tail $TailSize)
        } catch [System.IO.IOException] {
            if ($attempt -eq $MaxAttempts) {
                throw
            }
            Start-Sleep -Milliseconds $delayMs
            $delayMs *= 2
        }
    }
}

function Test-TailJournalOutputRedirected {
    try {
        return [Console]::IsOutputRedirected
    } catch {
        return $true
    }
}

function Test-TailJournalVirtualTerminal {
    # PsCliUiKit keeps Get-CliAnsiSupportsVirtualTerminal private. The host flag is the same check.
    $ui = $Host.UI
    if ($null -eq $ui) {
        return $false
    }
    $vtProp = $ui.PSObject.Properties["SupportsVirtualTerminal"]
    if ($null -eq $vtProp) {
        return $false
    }
    return [bool]$vtProp.Value
}

function Test-TailJournalIoException {
    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $current = $ErrorRecord.Exception
    while ($null -ne $current) {
        if ($current -is [System.IO.IOException]) {
            return $true
        }
        $current = $current.InnerException
    }
    return $false
}

function Get-TailJournalSharedLength {
    # Same lock story as Get-TailJournalFileTail: the writer holds FileShare.None
    # for the append, so a colliding length read retries instead of aborting the view.
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [int]$MaxAttempts = 5
    )

    $delayMs = 100
    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            $stream = [System.IO.File]::Open(
                $Path,
                [System.IO.FileMode]::Open,
                [System.IO.FileAccess]::Read,
                [System.IO.FileShare]::ReadWrite
            )
            try {
                return [long]$stream.Length
            } finally {
                $stream.Dispose()
            }
        } catch [System.IO.IOException] {
            if ($attempt -eq $MaxAttempts) {
                throw
            }
            Start-Sleep -Milliseconds $delayMs
            $delayMs *= 2
        }
    }
}

function Write-TailJournalRepaint {
    # A terminal redraws in place. A redirected run (tests, pipes) cannot move the
    # cursor, so each later frame is separated by a form feed and the last frame
    # is the current window.
    param(
        [Parameter(Mandatory = $true)]
        [int]$PreviousLineCount
    )

    if ($PreviousLineCount -le 0) {
        return
    }
    if (Test-TailJournalOutputRedirected) {
        Write-CliColor -Message ([string][char]12) -Literal
        return
    }
    if (Test-TailJournalVirtualTerminal) {
        $esc = [char]27
        $sequence = "{0}[{1}A{0}[J" -f $esc, $PreviousLineCount
        Write-CliColor -Message $sequence -NoNewline -Literal
        return
    }
    Clear-Host
}

function Write-TailJournalSummaryLine {
    # Blank lines above and below the feature summary keep it separate from the prompt and the columns.
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Line
    )

    Write-CliColor -Message ""
    if ($script:Colorize) {
        Write-CliColor -Message $Line
    } else {
        Write-CliColor -Message $Line -Literal
    }
    Write-CliColor -Message ""
}

function Write-TailJournalIdleClock {
    # A blank line separates the clock from the events. Ticks overwrite that one row.
    # Erasing it first blanks the whole line for a paint, which reads as a flash.
    param(
        [Parameter(Mandatory = $true)]
        [System.DateTime]$LastEventTime,

        [switch]$Replace
    )

    $label = Format-TailJournalSinceLastEvent -Elapsed ([System.DateTime]::Now - $LastEventTime)
    $consoleWidth = Get-TailJournalConsoleWidth
    $width = [Math]::Min([Math]::Max($label.Length, $script:TailJournalIdleWidth), [Math]::Max(1, $consoleWidth - 1))
    $script:TailJournalIdleWidth = $width
    $padded = $label.PadRight($width)
    if ($Replace -and -not (Test-TailJournalOutputRedirected) -and (Test-TailJournalVirtualTerminal)) {
        $esc = [char]27
        Write-CliColor -Message ("{0}[1A{0}[1G" -f $esc) -NoNewline -Literal
    } elseif (-not $Replace) {
        Write-CliColor -Message ""
    }
    if ($script:Colorize) {
        Write-CliColor -Message "[muted]$padded[-]"
    } else {
        Write-CliColor -Message $padded -Literal
    }
}

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

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

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

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

        [Parameter(Mandatory = $true)]
        [string]$LogLevel,

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

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

    Write-TailJournalRepaint -PreviousLineCount $PreviousLineCount

    $entryList = @($Entries)
    if ($entryList.Count -eq 1 -and $null -eq $entryList[0]) {
        $entryList = @()
    }

    if ($entryList.Count -eq 0) {
        $emptyLine = if ($script:Colorize) {
            "[muted]Feature:[-] [header]$Feature[-] [muted]| Journal is empty[-]"
        } else {
            "Feature: $Feature | Journal is empty"
        }
        Write-TailJournalSummaryLine -Line $emptyLine
        return 3
    }

    $consoleWidth = Get-TailJournalConsoleWidth
    $widths = Get-TailJournalColumnWidth -Mode $LogLevel -Entries $entryList
    $header = Format-TailJournalHeader -Feature $Feature -Entries $entryList -TotalEvents $TotalEvents -TotalSlices $TotalSlices
    Write-TailJournalSummaryLine -Line $header
    Write-CliColor -Message (Format-TailJournalColumnHeader -Mode $LogLevel -Widths $widths) -Style Muted
    Write-CliColor -Message (Format-TailJournalDivider -Mode $LogLevel -Widths $widths) -Style Muted

    $lastTimeBySlice = @{}
    $seenSlice = @{}
    foreach ($entry in $entryList) {
        Write-TailJournalEvent -Entry $entry -Widths $widths -LastTimeBySlice $lastTimeBySlice -SeenSlice $seenSlice -Threshold $Threshold -ConsoleWidth $consoleWidth
    }
    return (5 + $entryList.Count)
}

function Show-TailJournalView {
    # Re-read the last TailSize lines from disk. If another append lands during the
    # read, paint once more so the offset cannot skip a line that never reached the screen.
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [string]$FeatureName,

        [Parameter(Mandatory = $true)]
        [string]$LogLevel,

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

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

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

    $lineCount = $PreviousLineCount
    $length = [long]0
    $events = @()
    for ($attempt = 0; $attempt -lt 3; $attempt++) {
        $before = Get-TailJournalSharedLength -Path $Path
        $events = @(Read-TailJournalEvent -Path $Path -TailSize $TailSize)
        if ($events.Count -eq 0 -and $attempt -eq 0 -and $PreviousLineCount -eq 0) {
            return [pscustomobject]@{
                LineCount     = 0
                Length        = $before
                Empty         = $true
                LastEventTime = $null
            }
        }
        $stats = Get-TailJournalFileSummary -Path $Path
        $paintFrom = if ($attempt -eq 0) { $PreviousLineCount } else { $lineCount }
        $lineCount = Write-TailJournalSnapshot -Feature $FeatureName -Entries $events -TotalEvents $stats.Events -TotalSlices $stats.Slices -LogLevel $LogLevel -Threshold $Threshold -PreviousLineCount $paintFrom
        $length = Get-TailJournalSharedLength -Path $Path
        if ($length -eq $before) {
            break
        }
    }
    return [pscustomobject]@{
        LineCount     = $lineCount
        Length        = $length
        Empty         = ($events.Count -eq 0)
        LastEventTime = (Get-TailJournalLastEventTime -Entries $events)
    }
}

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

        [Parameter(Mandatory = $true)]
        [string]$FeatureName,

        [Parameter(Mandatory = $true)]
        [string]$LogLevel,

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

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

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

        [Parameter(Mandatory = $true)]
        [long]$Offset,

        $LastEventTime = $null
    )

    # Get-Content -Wait cannot be used here: it reopens the file on every poll, and a
    # poll that collides with the writer's exclusive append lock (HarnessJournal.psm1
    # holds FileShare.None through its full-file seq read) aborts the wait stream even
    # under ErrorActionPreference 'Continue'. This poll opens briefly with a permissive
    # share once a second. A length change re-reads the last TailSize lines and redraws
    # that window. Otherwise only the idle clock under the rows is rewritten.
    $lineCount = $PreviousLineCount
    $offset = $Offset
    $lastEventTime = $LastEventTime
    while ($true) {
        Start-Sleep -Milliseconds 1000
        try {
            $length = Get-TailJournalSharedLength -Path $Path -MaxAttempts 1
        } catch {
            if (-not (Test-TailJournalIoException -ErrorRecord $_)) { throw }
            continue
        }
        if ($length -ne $offset) {
            try {
                $view = Show-TailJournalView -Path $Path -FeatureName $FeatureName -LogLevel $LogLevel -TailSize $TailSize -Threshold $Threshold -PreviousLineCount $lineCount
                $lineCount = $view.LineCount
                $offset = $view.Length
                $lastEventTime = $view.LastEventTime
                if ($null -ne $lastEventTime) {
                    Write-TailJournalIdleClock -LastEventTime $lastEventTime
                    $lineCount += 2
                }
            } catch {
                if (-not (Test-TailJournalIoException -ErrorRecord $_)) { throw }
            }
        } elseif ($null -ne $lastEventTime) {
            Write-TailJournalIdleClock -LastEventTime $lastEventTime -Replace
        }
    }
}

# --- 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)
$view = Show-TailJournalView -Path $journalPath -FeatureName $Feature -LogLevel $LogLevel -TailSize $TailSize -Threshold $threshold -PreviousLineCount 0
if ($view.Empty) {
    Write-CliStatus -State Info -Message "Journal is empty for $Feature"
    exit 0
}

if ($Wait) {
    $lineCount = $view.LineCount
    if ($null -ne $view.LastEventTime) {
        Write-TailJournalIdleClock -LastEventTime $view.LastEventTime
        $lineCount += 2
    }
    Watch-TailJournalFile -Path $journalPath -FeatureName $Feature -LogLevel $LogLevel -TailSize $TailSize -Threshold $threshold -PreviousLineCount $lineCount -Offset $view.Length -LastEventTime $view.LastEventTime
}