Payload/scripts/kaden/harness/lib/HarnessGateProjection.psm1

#Requires -Version 5.1
<#
.SYNOPSIS
    Rebuild schema 1.2 gate projection (.review-loop.json) from the journal.

.DESCRIPTION
    Folds journal/events.jsonl into the review-loop vocabulary gates already speak
    (phases, slices, cycles, sensors, ownership). DNA kinds do not clear gates;
    OwnershipChanged cannot elevate to review_ready. Call Update-HarnessGateProjectionFromJournal
    -Write or harness-journal.ps1 -RebuildProjection. See docs/kaden/harness-journal.md / ADR 0003.
#>

Set-StrictMode -Version Latest

function Get-HarnessGateProjectionSeed {
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$Feature,
        [string]$ProfileName = 'standard',
        [string]$RepoRoot = ''
    )
    $loopPath = Join-Path $FeatureRoot '.review-loop.json'
    if (Test-Path $loopPath) {
        $data = ConvertFrom-HarnessJson -Json (Get-Content $loopPath -Raw)
        return (Repair-HarnessReviewLoopShapes -LoopData $data)
    }
    if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
        $RepoRoot = Get-HarnessRepoRoot -StartPath $FeatureRoot
    }
    $templatePath = Join-Path $RepoRoot '.github\templates\review-loop.json'
    if (-not (Test-Path $templatePath)) {
        throw "Review loop template not found: $templatePath"
    }
    $seed = ConvertFrom-HarnessJson -Json (Get-Content $templatePath -Raw)
    $seed.feature = $Feature
    $seed.profile = $ProfileName
    $seed.started = [DateTime]::UtcNow.ToString('yyyy-MM-dd')
    $seed.updated = Get-HarnessIso8601UtcNow
    $seed.current_phase = 'implementation'
    $seed.current_slice = $null
    return $seed
}

function Initialize-HarnessProjectionSlice {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'ProjectionSlice is the aggregate noun for a gate-projection slice shell.')]
    param(
        [Parameter(Mandatory = $true)]$LoopData,
        [Parameter(Mandatory = $true)][string]$SliceId,
        [string[]]$StoryIds = @()
    )
    $impl = Get-ImplementationPhase -LoopData $LoopData
    if (-not $impl) {
        throw 'implementation phase missing from projection seed'
    }
    # Normalize collapsed one-element slices object -> array before lookup.
    $impl.slices = @(ConvertTo-HarnessObjectArray -Value $impl.slices)

    $slice = @($impl.slices) | Where-Object {
        (Get-HarnessProperty -Object $_ -Name 'id') -eq $SliceId
    } | Select-Object -First 1
    if ($slice) {
        $LoopData.current_slice = $SliceId
        return $slice
    }

    # Adopt a corrupt shell (id null/empty, but has phases) instead of stacking duplicates.
    $orphan = @($impl.slices) | Where-Object {
        $candidate = $_
        ($null -ne $candidate) `
            -and ($candidate.PSObject.Properties.Name -contains 'phases') `
            -and [string]::IsNullOrWhiteSpace([string](Get-HarnessProperty -Object $candidate -Name 'id'))
    } | Select-Object -First 1
    if ($orphan) {
        $orphan.id = $SliceId
        if ([string]::IsNullOrWhiteSpace([string](Get-HarnessProperty -Object $orphan -Name 'owner'))) {
            $orphan.owner = 'atdd-developer'
        }
        if ([string]::IsNullOrWhiteSpace([string](Get-HarnessProperty -Object $orphan -Name 'ownership_status'))) {
            $orphan.ownership_status = 'in_progress'
        }
        if ($null -eq (Get-HarnessProperty -Object $orphan -Name 'story_ids')) {
            $orphan.story_ids = $StoryIds
        }
        $LoopData.current_slice = $SliceId
        $LoopData.current_phase = 'implementation'
        return $orphan
    }

    $phaseTemplate = @(
        @('RED', 'GREEN', 'MUTATE', 'REFACTOR', 'COMMIT') | ForEach-Object {
            [pscustomobject]@{
                id                = $_
                status            = 'pending'
                skip_reason_code  = $null
                skip_rationale    = $null
                evidence_artifact = $null
                git               = $null
                completed_at      = $null
                invocation        = $null
            }
        }
    )
    $newSlice = [pscustomobject]@{
        id               = $SliceId
        owner            = 'atdd-developer'
        ownership_status = 'in_progress'
        story_ids        = $StoryIds
        phases           = $phaseTemplate
        cycles           = @()
        sensors          = @()
        handoffs         = @()
        human_gate       = [pscustomobject]@{
            required   = $true
            verdict    = 'pending'
            actor      = 'human'
            decided_at = $null
            notes      = $null
        }
    }
    $list = [System.Collections.ArrayList]@()
    foreach ($s in @($impl.slices)) {
        if ($null -ne $s) { [void]$list.Add($s) }
    }
    [void]$list.Add($newSlice)
    $impl.slices = @(ConvertTo-HarnessObjectArray -Value $list.ToArray())
    if ($impl.status -eq 'pending') {
        $impl.status = 'in_progress'
    }
    if (-not $impl.started_at) {
        $impl.started_at = Get-HarnessIso8601UtcNow
    }
    $LoopData.current_slice = $SliceId
    $LoopData.current_phase = 'implementation'
    return $newSlice
}

function ConvertTo-HarnessInvocationFromActor {
    param($Actor)
    if ($null -eq $Actor) { return $null }
    $model = Get-HarnessProperty -Object $Actor -Name 'model'
    return [pscustomobject]@{
        agent        = (Get-HarnessProperty -Object $Actor -Name 'agent' -Default 'unknown')
        model        = [pscustomobject]@{
            provider = (Get-HarnessProperty -Object $model -Name 'provider' -Default 'other')
            slug     = (Get-HarnessProperty -Object $model -Name 'slug' -Default 'unknown')
            role     = (Get-HarnessProperty -Object $model -Name 'role' -Default 'primary')
        }
        parent_agent = (Get-HarnessProperty -Object $Actor -Name 'parent_agent')
        tool         = (Get-HarnessProperty -Object $Actor -Name 'tool')
    }
}

function Add-HarnessJournalEventToProjection {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'In-memory projection fold - not a user-facing state cmdlet.')]
    param(
        [Parameter(Mandatory = $true)]$LoopData,
        [Parameter(Mandatory = $true)]$Event
    )

    $kind = [string]$Event.kind
    $sliceId = Get-HarnessProperty -Object $Event -Name 'slice'
    $payload = Get-HarnessProperty -Object $Event -Name 'payload'
    $occurredAt = Get-HarnessProperty -Object $Event -Name 'occurred_at' -Default (Get-HarnessIso8601UtcNow)
    $actor = Get-HarnessProperty -Object $Event -Name 'actor'
    $refs = Get-HarnessProperty -Object $Event -Name 'refs'
    $outcome = Get-HarnessProperty -Object $Event -Name 'outcome'

    switch ($kind) {
        'PhaseStatusChanged' {
            $phaseId = Get-HarnessProperty -Object $payload -Name 'phase'
            $status = Get-HarnessProperty -Object $payload -Name 'status'
            $featurePhase = Get-HarnessProperty -Object $payload -Name 'feature_phase'
            if ($featurePhase) {
                $fp = Get-HarnessLoopPhase -LoopData $LoopData -PhaseId $featurePhase
                if ($fp) {
                    $fp.status = $status
                    if ($status -eq 'in_progress' -and -not $fp.started_at) {
                        $fp.started_at = $occurredAt
                    }
                    if ($status -eq 'complete') {
                        $fp.completed_at = $occurredAt
                        $arts = Get-HarnessProperty -Object $payload -Name 'artifacts'
                        if ($arts) { $fp.artifacts = @($arts) }
                    }
                    $LoopData.current_phase = $featurePhase
                }
            }
            elseif ($sliceId -and $phaseId -and $status) {
                $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
                $sp = $slice.phases | Where-Object { $_.id -eq $phaseId } | Select-Object -First 1
                if ($sp) {
                    $sp.status = $status
                    if ($status -eq 'complete' -or $status -eq 'skipped') {
                        $sp.completed_at = $occurredAt
                    }
                    else {
                        $sp.completed_at = $null
                    }
                    $ev = Get-HarnessProperty -Object $payload -Name 'evidence_artifact'
                    if (-not $ev) {
                        $ev = Get-HarnessProperty -Object $refs -Name 'artifact'
                    }
                    if ($ev) { $sp | Add-Member -NotePropertyName evidence_artifact -NotePropertyValue $ev -Force }
                    $skipCode = Get-HarnessProperty -Object $payload -Name 'skip_reason_code'
                    $skipRat = Get-HarnessProperty -Object $payload -Name 'skip_rationale'
                    if ($status -eq 'skipped') {
                        $sp | Add-Member -NotePropertyName skip_reason_code -NotePropertyValue $skipCode -Force
                        $sp | Add-Member -NotePropertyName skip_rationale -NotePropertyValue $skipRat -Force
                    }
                    elseif ($status -eq 'complete') {
                        $sp | Add-Member -NotePropertyName skip_reason_code -NotePropertyValue $null -Force
                        $sp | Add-Member -NotePropertyName skip_rationale -NotePropertyValue $null -Force
                    }
                    $inv = ConvertTo-HarnessInvocationFromActor -Actor $actor
                    if ($inv) { $sp | Add-Member -NotePropertyName invocation -NotePropertyValue $inv -Force }
                    $testHashes = Get-HarnessProperty -Object $payload -Name 'test_file_hashes'
                    if ($null -ne $testHashes -and $status -eq 'complete') {
                        $sp | Add-Member -NotePropertyName test_file_hashes -NotePropertyValue $testHashes -Force
                    }
                }
                $impl = Get-ImplementationPhase -LoopData $LoopData
                if ($impl -and $impl.status -eq 'pending') {
                    $impl.status = 'in_progress'
                }
                if ($impl -and -not $impl.started_at) {
                    $impl.started_at = $occurredAt
                }
                $LoopData.current_phase = 'implementation'
                $LoopData.current_slice = $sliceId
            }
        }
        'SensorRunRecorded' {
            if (-not $sliceId) { break }
            $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            $name = Get-HarnessProperty -Object $payload -Name 'name'
            $type = Get-HarnessProperty -Object $payload -Name 'type' -Default 'computational'
            $verdict = if ($outcome) { $outcome } else { Get-HarnessProperty -Object $payload -Name 'verdict' -Default 'FAIL' }
            $artifact = Get-HarnessProperty -Object $refs -Name 'artifact'
            if (-not $artifact) {
                $artifact = Get-HarnessProperty -Object $payload -Name 'evidence_artifact'
            }
            $command = Get-HarnessProperty -Object $refs -Name 'command'
            if (-not $command) {
                $command = Get-HarnessProperty -Object $payload -Name 'command'
            }
            $exitCode = Get-HarnessProperty -Object $payload -Name 'exit_code' -Default 0
            $summary = Get-HarnessProperty -Object $Event -Name 'summary' -Default ''
            $supersedesEventId = Get-HarnessProperty -Object $refs -Name 'supersedes_event_id'
            if ($supersedesEventId) {
                foreach ($existing in @($slice.sensors | Where-Object { (Get-HarnessProperty -Object $_ -Name 'journal_event_id') -eq $supersedesEventId })) {
                    $existing.authoritative = $false
                    $existing.superseded_by_event_id = $Event.event_id
                }
            }
            $inv = ConvertTo-HarnessInvocationFromActor -Actor $actor
            if (-not $inv) {
                $inv = Format-HarnessModelInvocation -Agent 'harness-sensor.ps1'
            }
            $entry = Format-HarnessSensorLoopRecord -Invocation $inv -Name $name -Type $type -Verdict $verdict `
                -EvidenceArtifact $(if ($artifact) { $artifact } else { '' }) `
                -EvidenceSummary $summary -ExitCode ([int]$exitCode) -Command $(if ($command) { $command } else { '' }) `
                -SkipReasonCode (Get-HarnessProperty -Object $payload -Name 'skip_reason_code' -Default '') `
                -SkipRationale (Get-HarnessProperty -Object $payload -Name 'skip_rationale' -Default '') `
                -JournalEventId $Event.event_id `
                -SupersedesEventId $supersedesEventId
            # Preserve occurred_at from journal for wall-clock fidelity in projection.
            $entry.timestamp = $occurredAt
            Register-HarnessSensorEntry -Slice $slice -SensorEntry $entry
        }
        'GateCheckRun' {
            # Gate checks are also journaled as SensorRunRecorded by emitters; when only GateCheckRun
            # is present, project a validate-review-loop sensor row for completeness history.
            # Skip artifact-less rows (e.g. non-strict dogfood PASS) - they poison Strict sensor checks.
            if (-not $sliceId) { break }
            $artifact = Get-HarnessProperty -Object $refs -Name 'artifact'
            if (-not $artifact) {
                $artifact = Get-HarnessProperty -Object $payload -Name 'evidence_artifact'
            }
            if ([string]::IsNullOrWhiteSpace([string]$artifact)) {
                break
            }
            $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            $verdict = if ($outcome) { $outcome } else { 'FAIL' }
            $command = Get-HarnessProperty -Object $refs -Name 'command'
            if (-not $command) { $command = '.\scripts\kaden\harness\validate-review-loop.ps1' }
            $supersedesEventId = Get-HarnessProperty -Object $refs -Name 'supersedes_event_id'
            if ($supersedesEventId) {
                foreach ($existing in @($slice.sensors | Where-Object { (Get-HarnessProperty -Object $_ -Name 'journal_event_id') -eq $supersedesEventId })) {
                    $existing.authoritative = $false
                    $existing.superseded_by_event_id = $Event.event_id
                }
            }
            $inv = ConvertTo-HarnessInvocationFromActor -Actor $actor
            if (-not $inv) {
                $inv = Format-HarnessModelInvocation -Agent 'harness-sensor.ps1'
            }
            $entry = Format-HarnessSensorLoopRecord -Invocation $inv -Name 'validate-review-loop' -Type 'computational' `
                -Verdict $verdict -EvidenceArtifact ([string]$artifact) `
                -EvidenceSummary (Get-HarnessProperty -Object $Event -Name 'summary' -Default "gate check $verdict") `
                -ExitCode $(if ($verdict -eq 'PASS') { 0 } else { 1 }) -Command $command `
                -JournalEventId $Event.event_id `
                -SupersedesEventId $supersedesEventId
            $entry.timestamp = $occurredAt
            Register-HarnessSensorEntry -Slice $slice -SensorEntry $entry
        }
        'OwnershipChanged' {
            if (-not $sliceId) { break }
            $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            $status = Get-HarnessProperty -Object $payload -Name 'ownership_status'
            # Journal may advise non-clearance statuses only. review_ready stays evidence-gated
            # (validators / sensors / verdicts) - never forgeable from OwnershipChanged alone.
            $allowedFromJournal = @('in_progress', 'blocked', 'pending')
            if ($status -and ($allowedFromJournal -contains [string]$status)) {
                $slice.ownership_status = [string]$status
            }
        }
        'StoryIdsAssigned' {
            if (-not $sliceId) { break }
            # Lookup only. Initialize-HarnessProjectionSlice also sets current_slice, which would
            # retarget the feature to whichever historical slice was backfilled last.
            $impl = Get-ImplementationPhase -LoopData $LoopData
            if (-not $impl) { break }
            $impl.slices = @(ConvertTo-HarnessObjectArray -Value $impl.slices)
            $slice = @($impl.slices) | Where-Object {
                (Get-HarnessProperty -Object $_ -Name 'id') -eq $sliceId
            } | Select-Object -First 1
            if (-not $slice) {
                $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            }
            $incoming = @(@(Get-HarnessProperty -Object $payload -Name 'story_ids') | Where-Object {
                    -not [string]::IsNullOrWhiteSpace([string]$_)
                })
            if ($incoming.Count -gt 0) {
                $existing = @(@($slice.story_ids) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
                # Union, order-preserving, de-duplicated -- never drops an id a prior event assigned.
                $merged = [System.Collections.ArrayList]@()
                foreach ($id in (@($existing) + @($incoming))) {
                    if (-not ($merged -contains [string]$id)) { [void]$merged.Add([string]$id) }
                }
                $slice.story_ids = @($merged.ToArray())
            }
        }
        'ReviewerCycleRecorded' {
            if (-not $sliceId) { break }
            $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            $cycle = Get-HarnessProperty -Object $payload -Name 'cycle'
            if ($cycle) {
                # Full-journal replay runs on every projection -Write; without a dedup key the
                # same single ReviewerCycleRecorded event gets re-appended on every later rebuild.
                $alreadyPresent = @(@($slice.cycles) | Where-Object {
                        (Get-HarnessProperty -Object $_ -Name 'journal_event_id') -eq $Event.event_id
                    })
                if ($alreadyPresent.Count -eq 0) {
                    if (-not ($cycle.PSObject.Properties.Name -contains 'journal_event_id')) {
                        $cycle | Add-Member -NotePropertyName 'journal_event_id' -NotePropertyValue $Event.event_id -Force
                    }
                    else {
                        $cycle.journal_event_id = $Event.event_id
                    }
                    $list = [System.Collections.ArrayList]@()
                    if ($slice.cycles) {
                        foreach ($c in $slice.cycles) { [void]$list.Add($c) }
                    }
                    [void]$list.Add($cycle)
                    $slice.cycles = $list.ToArray()
                }
            }
        }
        'HandoffRecorded' {
            if (-not $sliceId) { break }
            $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            $handoff = Get-HarnessProperty -Object $payload -Name 'handoff'
            if ($handoff) {
                # Same replay-without-dedup hazard as ReviewerCycleRecorded above.
                $alreadyPresent = @(@($slice.handoffs) | Where-Object {
                        (Get-HarnessProperty -Object $_ -Name 'journal_event_id') -eq $Event.event_id
                    })
                if ($alreadyPresent.Count -eq 0) {
                    if (-not ($handoff.PSObject.Properties.Name -contains 'journal_event_id')) {
                        $handoff | Add-Member -NotePropertyName 'journal_event_id' -NotePropertyValue $Event.event_id -Force
                    }
                    else {
                        $handoff.journal_event_id = $Event.event_id
                    }
                    $list = [System.Collections.ArrayList]@()
                    if ($slice.handoffs) {
                        foreach ($h in $slice.handoffs) { [void]$list.Add($h) }
                    }
                    [void]$list.Add($handoff)
                    $slice.handoffs = $list.ToArray()
                }
            }
        }
        'HumanGateDecided' {
            if (-not $sliceId) { break }
            $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            if ($slice.human_gate) {
                $verdict = Get-HarnessProperty -Object $payload -Name 'verdict'
                if ($verdict) { $slice.human_gate.verdict = $verdict }
                $slice.human_gate.decided_at = $occurredAt
                $notes = Get-HarnessProperty -Object $payload -Name 'notes'
                if ($null -ne $notes) { $slice.human_gate.notes = $notes }
            }
        }
        'BlockerDeclared' {
            if (-not $sliceId) { break }
            $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
            $slice.ownership_status = 'blocked'
        }
        'GateValidationPassed' {
            # Emitted by harness-sensor.ps1 when validate-review-loop -RequireReviewReady passes.
            # This makes review_ready durable across projection rebuilds: any later -Append sensor
            # call that rebuilds from journal will restore review_ready from this event instead of
            # reverting to in_progress (which OwnershipChanged cannot elevate past blocked/pending).
            if (-not $sliceId) { break }
            $gate = Get-HarnessProperty -Object $payload -Name 'gate'
            $flags = Get-HarnessProperty -Object $payload -Name 'flags'
            $flagsArr = if ($flags) { @($flags) } else { @() }
            if ($gate -eq 'validate-review-loop' -and $outcome -eq 'PASS' -and ($flagsArr -contains 'RequireReviewReady')) {
                $slice = Initialize-HarnessProjectionSlice -LoopData $LoopData -SliceId $sliceId
                $slice.ownership_status = 'review_ready'
            }
        }
        default {
            # ActivityStarted / ActivityStopped / ActivityAdjusted / EmissionGapMarked:
            # DNA and audit only - do not clear gates from narrative.
        }
    }

    $LoopData.updated = Get-HarnessIso8601UtcNow
}

function Update-HarnessGateProjectionFromJournal {
    [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,
        [string]$ProfileName = '',
        [string]$RepoRoot = '',
        [switch]$Write
    )

    if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
        $RepoRoot = Get-HarnessRepoRoot -StartPath $FeatureRoot
    }
    if ([string]::IsNullOrWhiteSpace($ProfileName)) {
        $ProfileName = (Get-HarnessProfileRecord -FeatureRoot $FeatureRoot).profile
    }

    $seed = Get-HarnessGateProjectionSeed -FeatureRoot $FeatureRoot -Feature $Feature -ProfileName $ProfileName -RepoRoot $RepoRoot
    # Preserve existing slice scaffold ids (e.g. renamed journal-gate-skeleton) while folding events.
    $events = @(Get-HarnessJournalEvents -FeatureRoot $FeatureRoot)

    # Reset sensors/cycles/phase completions that journal will re-derive; keep slice shells.
    $impl = Get-ImplementationPhase -LoopData $seed
    if ($impl -and $impl.slices) {
        foreach ($slice in @($impl.slices)) {
            $slice.sensors = @()
            # Do not wipe cycles/handoffs that predate journal cutover unless journal has replacements.
            # Skeleton: wipe sensors always (journal SensorRunRecorded / GateCheckRun rebuild them).
            foreach ($sp in @($slice.phases)) {
                # Leave phase rows; PhaseStatusChanged overwrites.
            }
        }
    }

    foreach ($evt in $events) {
        Add-HarnessJournalEventToProjection -LoopData $seed -Event $evt
    }

    if ($Write) {
        $loopPath = Join-Path $FeatureRoot '.review-loop.json'
        Update-ReviewLoopDocument -LoopPath $loopPath -LoopData $seed -RepoRoot $RepoRoot
    }

    return $seed
}

function Save-HarnessGateProjection {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)]$LoopData,
        [string]$RepoRoot = ''
    )
    if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
        $RepoRoot = Get-HarnessRepoRoot -StartPath $FeatureRoot
    }
    $loopPath = Join-Path $FeatureRoot '.review-loop.json'
    Update-ReviewLoopDocument -LoopPath $loopPath -LoopData $LoopData -RepoRoot $RepoRoot
}

Export-ModuleMember -Function *