Payload/scripts/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 } } $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\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 } } '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 * |