Payload/scripts/harness/lib/HarnessJournal.psm1

#Requires -Version 5.1
<#
.SYNOPSIS
    Append-only harness event journal (ledger SoT).

.DESCRIPTION
    Implements harness.journal.event/v1 under docs/harness/features/<slug>/journal/events.jsonl.
    Exclusive append lock; monotonic seq; ActivityStarted/Stopped/Adjusted helpers.
    Journal answers what happened; gate completeness is HarnessGateProjection + artifacts.
    See docs/kaden/harness-journal.md and ADR 0003.
#>

Set-StrictMode -Version Latest

$script:HarnessJournalSchema = 'harness.journal.event/v1'

# Stamp helpers live in HarnessContract; require the module so journal append cannot fail-open without provenance.
Import-Module (Join-Path $PSScriptRoot 'HarnessContract.psm1') -Force

function Get-HarnessJournalDirectory {
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    return Join-Path $FeatureRoot 'journal'
}

function Get-HarnessJournalPath {
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    return Join-Path (Get-HarnessJournalDirectory -FeatureRoot $FeatureRoot) 'events.jsonl'
}

function Initialize-HarnessJournal {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $dir = Get-HarnessJournalDirectory -FeatureRoot $FeatureRoot
    if (-not (Test-Path $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }
    $path = Get-HarnessJournalPath -FeatureRoot $FeatureRoot
    $created = $false
    if (-not (Test-Path $path)) {
        [System.IO.File]::WriteAllText($path, '')
        $created = $true
    }
    if ($created) {
        Write-HarnessGateFileProvenance -FeatureRoot $FeatureRoot -GateFile journal -Source 'Initialize-HarnessJournal'
    }
    return $path
}

function New-HarnessJournalEventId {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure id factory - no persistent state change.')]
    param()
    # UUID without dashes is acceptable per journal-entry-structure.md (ULID preferred).
    return ([guid]::NewGuid().ToString('N')).ToUpperInvariant()
}

function New-HarnessJournalActivityId {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure id factory - no persistent state change.')]
    param()
    return 'act_' + (New-HarnessJournalEventId)
}

function Get-HarnessJournalEvents {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Returns the append-only event stream - plural noun matches journal vocabulary.')]
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $path = Get-HarnessJournalPath -FeatureRoot $FeatureRoot
    if (-not (Test-Path $path)) {
        return @()
    }
    $lines = Get-Content -Path $path -ErrorAction SilentlyContinue |
        Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
    $events = [System.Collections.ArrayList]@()
    foreach ($line in $lines) {
        [void]$events.Add(($line | ConvertFrom-Json))
    }
    return @($events.ToArray())
}

function Get-HarnessLatestJournalEventByName {
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$SliceId,
        [Parameter(Mandatory = $true)][string]$Name,
        [string[]]$Kinds = @('SensorRunRecorded', 'GateCheckRun'),
        [string]$Phase = ''
    )

    $events = @(Get-HarnessJournalEvents -FeatureRoot $FeatureRoot)
    $matched = @($events | Where-Object {
            $kind = [string]$_.kind
            if ($Kinds.Count -gt 0 -and ($Kinds -notcontains $kind)) { return $false }
            if ([string]$_.slice -ne $SliceId) { return $false }
            $payload = $null
            if ($_.PSObject.Properties['payload']) { $payload = $_.payload }
            if ($null -eq $payload -or $null -eq $payload.PSObject.Properties['name'] -or [string]$payload.name -ne $Name) { return $false }
            if (-not [string]::IsNullOrWhiteSpace($Phase)) {
                if (-not ($payload.PSObject.Properties['phase']) -or [string]$payload.phase -ne $Phase) { return $false }
            }
            return $true
        } | Sort-Object -Property seq -Descending | Select-Object -First 1)
    if ($matched.Count -gt 0) { return $matched[0] }
    return $null
}

function Get-NextHarnessJournalSeq {
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $events = @(Get-HarnessJournalEvents -FeatureRoot $FeatureRoot)
    if ($events.Count -eq 0) { return 1 }
    $max = ($events | Measure-Object -Property seq -Maximum).Maximum
    if ($null -eq $max) { return 1 }
    return [int]$max + 1
}

function New-HarnessJournalActor {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory actor value object - no persistent state change.')]
    param(
        [string]$Agent = 'harness-script',
        # Untyped so absent parent stays JSON null (not empty string).
        $ParentAgent = $null,
        [string]$Tool = 'harness-script',
        [string]$Provider = 'other',
        [string]$Slug = 'unknown',
        [string]$Role = 'primary'
    )
    $resolvedSlug = if (Get-Command Get-HarnessResolvedModelSlug -ErrorAction SilentlyContinue) {
        Get-HarnessResolvedModelSlug -ModelSlug $Slug
    }
    else {
        $Slug
    }
    $parentVal = $null
    if ($null -ne $ParentAgent -and -not [string]::IsNullOrWhiteSpace([string]$ParentAgent)) {
        $parentVal = [string]$ParentAgent
    }
    return [pscustomobject]@{
        agent        = $Agent
        parent_agent = $parentVal
        tool         = $Tool
        model        = [pscustomobject]@{
            provider = $Provider
            slug     = $resolvedSlug
            role     = $Role
        }
    }
}

function Test-HarnessJournalTurnBoundaryActivity {
    <#
    .SYNOPSIS
        True for ownership-turn start/stop activities (type turn or legacy other).
    #>

    param(
        $Activity = $null,
        [ValidateSet('start', 'stop', '')][string]$Name = ''
    )
    if (-not $Activity) {
        return $false
    }
    $type = [string]$Activity.type
    $actName = [string]$Activity.name
    if ($type -notin @('turn', 'other')) {
        return $false
    }
    if ([string]::IsNullOrWhiteSpace($Name)) {
        return ($actName -in @('start', 'stop'))
    }
    return ($actName -eq $Name)
}

function New-HarnessJournalActivity {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory activity value object - no persistent state change.')]
    param(
        [Parameter(Mandatory = $true)][string]$Id,
        [Parameter(Mandatory = $true)][ValidateSet('pipeline.phase', 'slice.phase', 'review.cycle', 'sensor', 'validation', 'gate', 'handoff', 'blocker', 'turn', 'artifact', 'other')][string]$Type,
        [Parameter(Mandatory = $true)][string]$Name,
        [Parameter(Mandatory = $true)][ValidateSet('start', 'stop', 'instant', 'adjust')][string]$Role
    )
    return [pscustomobject]@{
        id   = $Id
        type = $Type
        name = $Name
        role = $Role
    }
}

function Add-HarnessJournalEvent {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$FeatureRoot,

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

        # Untyped so omitted/null slice stays JSON null (typed [string]$null becomes "").
        $Slice = $null,

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

        $Activity = $null,
        $Actor = $null,
        $Refs = $null,

        # Align with journal-entry-structure.md; sensors may use PASS_WITH_FINDINGS.
        # Validated in-body so $null remains legal (PS ValidateScript rejects null binds).
        $Outcome = $null,

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

        $Payload = $null,
        [string]$OccurredAt = '',
        [string]$EventId = ''
    )

    $allowedKinds = @(
        'ActivityStarted', 'ActivityStopped', 'ActivityAdjusted',
        'PhaseStatusChanged', 'SensorRunRecorded', 'GateCheckRun',
        'GateRelevantValidationRun', 'GateValidationPassed',
        'ReviewerCycleRecorded', 'HandoffRecorded',
        'HumanGateDecided', 'OwnershipChanged', 'BlockerDeclared', 'EmissionGapMarked',
        'TaskStarted', 'TaskHeartbeat', 'TaskTimedOut', 'TaskRecovered', 'TaskAbandoned',
        'ArtifactRegistered'
    )
    if ($allowedKinds -notcontains [string]$Kind) {
        throw "Invalid Kind '$Kind'. Allowed: $($allowedKinds -join ', ')."
    }

    if ($null -ne $Outcome) {
        $allowedOutcomes = @('PASS', 'FAIL', 'BLOCKED', 'SKIPPED', 'PASS_WITH_FINDINGS', 'DENY')
        if ($allowedOutcomes -notcontains [string]$Outcome) {
            throw "Invalid Outcome '$Outcome'. Allowed: $($allowedOutcomes -join ', ') or null."
        }
    }

    $path = Initialize-HarnessJournal -FeatureRoot $FeatureRoot
    $now = if (Get-Command Get-HarnessIso8601UtcNow -ErrorAction SilentlyContinue) {
        Get-HarnessIso8601UtcNow
    }
    else {
        [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
    }
    if ([string]::IsNullOrWhiteSpace($OccurredAt)) {
        $OccurredAt = $now
    }
    if ([string]::IsNullOrWhiteSpace($EventId)) {
        $EventId = New-HarnessJournalEventId
    }

    if ($null -eq $Actor) {
        $Actor = New-HarnessJournalActor
    }
    if ($null -eq $Refs) {
        $Refs = [pscustomobject]@{
            amends_event_id = $null
            pair_event_id   = $null
            artifact        = $null
            command         = $null
        }
    }
    if ($null -eq $Payload) {
        $Payload = [pscustomobject]@{}
    }

    # This event opens a turn iff it is the ActivityStarted emitted by harness-turn-start.ps1
    # (activity type 'turn'/'start'; legacy fixtures may still use type 'other'/'start').
    $opensTurn = ($Kind -eq 'ActivityStarted' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $Activity -Name 'start'))
    if ($opensTurn -and $Slice -and (Get-Command Test-HarnessSliceTurnOpen -ErrorAction SilentlyContinue)) {
        if (Test-HarnessSliceTurnOpen -FeatureRoot $FeatureRoot -Slice ([string]$Slice)) {
            throw "ActivityStarted refused: slice $Slice already has an open turn. Do not harness-turn-start again; record FeaturePhase with harness-record-phase.ps1 or use -ExecuteNext."
        }
    }

    # Exclusive lock around max(seq) + append so concurrent writers cannot forge/race seq.
    $utf8 = New-Object System.Text.UTF8Encoding $false
    $lockStream = $null
    $written = $null
    try {
        $lockStream = [System.IO.File]::Open(
            $path,
            [System.IO.FileMode]::OpenOrCreate,
            [System.IO.FileAccess]::ReadWrite,
            [System.IO.FileShare]::None
        )
        $lockStream.Position = 0
        $reader = New-Object System.IO.StreamReader($lockStream, $utf8, $true, 4096, $true)
        $existing = $reader.ReadToEnd()
        $maxSeq = 0
        # Derive this slice's currently-open turn id from journal history so every event recorded
        # mid-turn (sensor/phase/review/handoff) can be grouped without agents passing a new param.
        $openTurnActivityId = $null
        $lastClosedTurnId = $null
        foreach ($existingLine in ($existing -split '\r?\n')) {
            if ([string]::IsNullOrWhiteSpace($existingLine)) { continue }
            try {
                $parsed = $existingLine | ConvertFrom-Json
                if ($null -ne $parsed.seq) {
                    $seqVal = [int]$parsed.seq
                    if ($seqVal -gt $maxSeq) { $maxSeq = $seqVal }
                }
                if ($Slice -and $parsed.PSObject.Properties['slice'] -and [string]$parsed.slice -eq [string]$Slice) {
                    $pActivity = $parsed.activity
                    $pKind = [string]$parsed.kind
                    if ($pKind -eq 'ActivityStarted' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $pActivity -Name 'start')) {
                        $openTurnActivityId = [string]$pActivity.id
                    }
                    elseif ($pKind -eq 'ActivityStopped' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $pActivity -Name 'stop')) {
                        $lastClosedTurnId = $openTurnActivityId
                        $openTurnActivityId = $null
                    }
                }
            }
            catch {
                # Malformed JSONL line: skip for seq/turn-id computation; append remains append-only.
                Write-Verbose "Skipping malformed journal line while computing next seq: $($_.Exception.Message)"
            }
        }
        $nextSeq = $maxSeq + 1

        $turnId = if ($opensTurn) {
            [string]$Activity.id
        }
        elseif ($Slice) {
            if ($openTurnActivityId) { $openTurnActivityId } else { $lastClosedTurnId }
        }
        else {
            $null
        }
        if (-not ($Refs.PSObject.Properties.Name -contains 'turn_id')) {
            $Refs | Add-Member -NotePropertyName 'turn_id' -NotePropertyValue $null -Force
        }
        $Refs.turn_id = $turnId

        $journalEvent = [ordered]@{
            schema      = $script:HarnessJournalSchema
            event_id    = $EventId
            seq         = $nextSeq
            recorded_at = $now
            occurred_at = $OccurredAt
            feature     = $Feature
            slice       = $Slice
            kind        = $Kind
            activity    = $Activity
            actor       = $Actor
            refs        = $Refs
            outcome     = $Outcome
            summary     = $Summary
            payload     = $Payload
        }

        $line = ($journalEvent | ConvertTo-Json -Compress -Depth 20)
        $bytes = $utf8.GetBytes($line + [Environment]::NewLine)
        $lockStream.Seek(0, [System.IO.SeekOrigin]::End) | Out-Null
        $lockStream.Write($bytes, 0, $bytes.Length)
        $lockStream.Flush()
        $written = [pscustomobject]$journalEvent
        # Hash via the open exclusive stream so provenance stamps under the lock without Get-FileHash sharing conflict.
        $lockStream.Position = 0
        $sha = [System.Security.Cryptography.SHA256]::Create()
        try {
            $hashBytes = $sha.ComputeHash($lockStream)
        }
        finally {
            $sha.Dispose()
        }
        $shaHex = -join ($hashBytes | ForEach-Object { $_.ToString('x2') })
        Write-HarnessGateFileProvenance -FeatureRoot $FeatureRoot -GateFile journal -Source 'Add-HarnessJournalEvent' -Sha256 $shaHex
    }
    finally {
        if ($null -ne $lockStream) {
            $lockStream.Dispose()
        }
    }
    return $written
}

function Start-HarnessJournalActivity {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$Feature,
        $Slice = $null,
        [Parameter(Mandatory = $true)][string]$ActivityId,
        [Parameter(Mandatory = $true)][ValidateSet('pipeline.phase', 'slice.phase', 'review.cycle', 'sensor', 'validation', 'gate', 'handoff', 'blocker', 'turn', 'artifact', 'other')][string]$ActivityType,
        [Parameter(Mandatory = $true)][string]$ActivityName,
        $Actor = $null,
        [string]$Summary = '',
        $Payload = $null,
        [string]$OccurredAt = ''
    )
    if ([string]::IsNullOrWhiteSpace($Summary)) {
        $Summary = "$ActivityName started"
    }
    $activity = New-HarnessJournalActivity -Id $ActivityId -Type $ActivityType -Name $ActivityName -Role 'start'
    return Add-HarnessJournalEvent -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice `
        -Kind 'ActivityStarted' -Activity $activity -Actor $Actor -Outcome $null `
        -Summary $Summary -Payload $Payload -OccurredAt $OccurredAt
}

function Stop-HarnessJournalActivity {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$Feature,
        $Slice = $null,
        [Parameter(Mandatory = $true)][string]$ActivityId,
        [Parameter(Mandatory = $true)][ValidateSet('pipeline.phase', 'slice.phase', 'review.cycle', 'sensor', 'validation', 'gate', 'handoff', 'blocker', 'turn', 'artifact', 'other')][string]$ActivityType,
        [Parameter(Mandatory = $true)][string]$ActivityName,
        [ValidateSet('PASS', 'FAIL', 'BLOCKED', 'SKIPPED', 'PASS_WITH_FINDINGS')][string]$Outcome = 'PASS',
        $Actor = $null,
        [string]$Summary = '',
        $Payload = $null,
        [string]$PairEventId = '',
        [string]$OccurredAt = ''
    )
    if ([string]::IsNullOrWhiteSpace($Summary)) {
        $Summary = "$ActivityName stopped ($Outcome)"
    }
    $activity = New-HarnessJournalActivity -Id $ActivityId -Type $ActivityType -Name $ActivityName -Role 'stop'
    $refs = [pscustomobject]@{
        amends_event_id = $null
        pair_event_id   = $(if ($PairEventId) { $PairEventId } else { $null })
        artifact        = $null
        command         = $null
    }
    return Add-HarnessJournalEvent -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice `
        -Kind 'ActivityStopped' -Activity $activity -Actor $Actor -Refs $refs -Outcome $Outcome `
        -Summary $Summary -Payload $Payload -OccurredAt $OccurredAt
}

function Add-HarnessJournalAdjustingEntry {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$Feature,
        $Slice = $null,
        [Parameter(Mandatory = $true)][string]$AmendsEventId,
        [Parameter(Mandatory = $true)][string]$Summary,
        $Activity = $null,
        $Actor = $null,
        $Correction = $null,
        [string]$OccurredAt = ''
    )
    $refs = [pscustomobject]@{
        amends_event_id = $AmendsEventId
        pair_event_id   = $null
        artifact        = $null
        command         = $null
    }
    if ($null -eq $Activity) {
        $Activity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type 'other' -Name 'adjust' -Role 'adjust'
    }
    $payload = [pscustomobject]@{ correction = $Correction }
    return Add-HarnessJournalEvent -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice `
        -Kind 'ActivityAdjusted' -Activity $Activity -Actor $Actor -Refs $refs -Outcome $null `
        -Summary $Summary -Payload $payload -OccurredAt $OccurredAt
}

Export-ModuleMember -Function *