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