Payload/scripts/kaden/harness/harness-record-review.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Record a reviewer verdict artifact as journal truth and rebuild projection. .DESCRIPTION Validates a reviewer payload before it is written, writes the review artifact, appends ReviewerCycleRecorded to the journal, and rebuilds the gate projection. YAML is the default transport for flat reviewer payloads; JSON is allowed when the payload is genuinely nested. Recordable verdicts: APPROVED, NEEDS_REVISION, REJECTED. review_ready validation still requires a later APPROVED cycle for the reviewer. Default MinBytes is 800; persist the subagent YAML (timestamp restamped UTC; undersize digests throw). G7 FAIL requires escape_hatch in YAML or -AllowStub to record APPROVED. .EXAMPLE pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-review.ps1 -Feature saved-search-queries -Slice mo-save-list -Reviewer atdd-developer-reviewer -PayloadPath .reviews\payload.yaml -VerdictArtifact .reviews\atdd-developer-reviewer-mo-save-list-iter1.yaml -ModelProvider cursor -ModelSlug composer-2 .EXAMPLE # Register specialist-written verdict (Content alone without on-disk file throws): pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-review.ps1 -Feature saved-search-queries -Slice mo-save-list -Reviewer atdd-developer-reviewer -VerdictContent "<verbatim yaml matching .reviews file>" -VerdictArtifact .reviews\atdd-developer-reviewer-mo-save-list-iter1.yaml -ModelProvider cursor -ModelSlug composer-2 #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Feature, [Parameter(Mandatory = $true)] [string]$Slice, [Parameter(Mandatory = $true)] [string]$Reviewer, # Provide EITHER -PayloadPath (path to an existing file) OR -VerdictContent (inline YAML/JSON text). # -VerdictContent registers an already-written allowlist file (must match on disk unless -AllowStub). [string]$PayloadPath = '', [string]$VerdictContent = '', [Parameter(Mandatory = $true)] [string]$VerdictArtifact, [ValidateSet('auto', 'yaml', 'json')] [string]$PayloadFormat = 'auto', [string]$FeatureRoot = '', [string]$RepoRoot = '', [ValidateSet('cursor', 'copilot', 'other')] [string]$ModelProvider = 'copilot', [string]$ModelSlug = 'unknown', [ValidateSet('primary', 'subagent')] [string]$ModelRole = 'primary', [string]$Agent = 'atdd-developer', [string]$ParentAgent = '', [ValidateSet('cursor-task', 'copilot-agent', 'chat', 'harness-script')] [string]$Tool = 'harness-script', # Immutable host dispatch id when the host exposes one (Cursor Task). Copilot does not expose it. [string]$SourceInvocationId = '', # Copilot subagents must explicitly attest their on-disk canonical artifact when no host id exists. [ValidateSet('host-id', 'artifact-attested')] [string]$SourceProvenance = 'host-id', [int]$MinBytes = 800, [switch]$AllowStub, [switch]$AllowArbitraryFeatureRoot ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'lib\HarnessContract.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'lib\HarnessJournal.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'lib\HarnessGateProjection.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'lib\RepoRuntime.psm1') -Force Test-HarnessContractModuleReady if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = Get-HarnessRepoRoot -StartPath $PSScriptRoot } Initialize-RepoShellEnvironment -LogPrefix 'harness-record-review' | Out-Null $featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot # Resolve payload text: from inline -VerdictContent or from -PayloadPath file. if (-not [string]::IsNullOrWhiteSpace($VerdictContent)) { $payloadText = $VerdictContent.Trim() if ([string]::IsNullOrWhiteSpace($payloadText)) { throw '-VerdictContent was provided but is empty.' } } elseif (-not [string]::IsNullOrWhiteSpace($PayloadPath)) { if (-not (Test-Path -LiteralPath $PayloadPath)) { throw "Payload not found: $PayloadPath" } $payloadText = Get-Content -LiteralPath $PayloadPath -Raw if ([string]::IsNullOrWhiteSpace($payloadText)) { throw 'Reviewer payload file is empty.' } } else { throw 'Specify either -PayloadPath <file> or -VerdictContent <yaml-text>.' } Assert-HarnessReviewerVerdictFileOnDisk -FeatureRoot $featureRootPath -VerdictArtifact $VerdictArtifact ` -VerdictContent $(if (-not [string]::IsNullOrWhiteSpace($VerdictContent)) { $payloadText } else { '' }) ` -AllowStub:$AllowStub # PayloadPath must be the specialist-written allowlist file (same path as VerdictArtifact), not a parent staging copy. if (-not $AllowStub -and -not [string]::IsNullOrWhiteSpace($PayloadPath)) { $verdictFull = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $featureRootPath -RelativePath $VerdictArtifact -ThrowOnEscape $pathFull = [System.IO.Path]::GetFullPath($PayloadPath) if (-not [string]::Equals($pathFull, [System.IO.Path]::GetFullPath($verdictFull), [StringComparison]::OrdinalIgnoreCase)) { throw ("PayloadPath must be the on-disk allowlist VerdictArtifact ({0}); parent staging/transcription paths are not allowed" -f $VerdictArtifact) } } $payloadBytes = [System.Text.Encoding]::UTF8.GetByteCount($payloadText) if (-not $AllowStub -and $payloadBytes -lt $MinBytes) { throw "Reviewer payload is only $payloadBytes bytes (min $MinBytes). Persist the subagent YAML verbatim, not a digest." } $normalizedFormat = $PayloadFormat if ($normalizedFormat -eq 'auto') { if (-not [string]::IsNullOrWhiteSpace($PayloadPath)) { $normalizedFormat = if ([System.IO.Path]::GetExtension($PayloadPath) -eq '.json') { 'json' } else { 'yaml' } } elseif ($payloadText -match '^\s*\{') { $normalizedFormat = 'json' } else { $normalizedFormat = 'yaml' } } $loop = Get-ReviewLoopDocument -FeatureRoot $featureRootPath $sliceEntry = Get-ImplementationSliceEntry -LoopData $loop.Data -SliceId $Slice if (-not $sliceEntry) { throw "Slice '$Slice' not found in review loop." } $producer = if ($sliceEntry.owner) { [string]$sliceEntry.owner } else { 'atdd-developer' } $resolvedSlug = Get-HarnessResolvedModelSlug -ModelSlug $ModelSlug # Recording accepts honest non-APPROVED cycles. review_ready validation still requires # an APPROVED cycle (min_verdict) and re-validates that artifact with APPROVED-only rules. $recordableVerdicts = @('APPROVED', 'NEEDS_REVISION', 'REJECTED') if ($normalizedFormat -eq 'json') { $payloadDoc = ConvertFrom-HarnessJson -Json $payloadText if ((Get-HarnessProperty -Object $payloadDoc -Name 'agent') -ne $Reviewer) { throw "Reviewer payload agent must be '$Reviewer'." } if ((Get-HarnessProperty -Object $payloadDoc -Name 'role') -ne 'reviewer') { throw 'Reviewer payload role must be reviewer.' } $jsonVerdict = [string](Get-HarnessProperty -Object $payloadDoc -Name 'verdict') if ($recordableVerdicts -notcontains $jsonVerdict) { throw "Reviewer payload verdict must be one of: $($recordableVerdicts -join ', ') (found '$jsonVerdict')." } if ((Get-HarnessProperty -Object $payloadDoc -Name 'slice') -and (Get-HarnessProperty -Object $payloadDoc -Name 'slice') -ne $Slice) { throw "Reviewer payload slice must be '$Slice'." } if (-not (Get-HarnessProperty -Object $payloadDoc -Name 'summary')) { throw 'Reviewer payload summary is required.' } if (-not (Get-HarnessProperty -Object $payloadDoc -Name 'model')) { throw 'Reviewer payload model block is required.' } $payloadTimestamp = Get-HarnessProperty -Object $payloadDoc -Name 'timestamp' if (-not (Test-HarnessUtcTimestamp -Value $payloadTimestamp)) { throw "Reviewer payload timestamp must be RFC 3339 UTC Z (found '$payloadTimestamp')." } } else { if (Test-HarnessReviewerG2OnCodeFirstSkip -Content $payloadText) { if (Test-HarnessSliceRedSkippedCodeFirst -FeatureRoot $featureRootPath -SliceId $Slice) { throw 'G2_valid_red_failure must be N/A when RED was skipped CODE_FIRST_BUILD (not PASS).' } } $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('harness-review-validate-' + [guid]::NewGuid().ToString('N')) $tempArtifact = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $tempRoot -RelativePath $VerdictArtifact -ThrowOnEscape New-Item -ItemType Directory -Path (Split-Path $tempArtifact -Parent) -Force | Out-Null [System.IO.File]::WriteAllText($tempArtifact, $payloadText, [System.Text.UTF8Encoding]::new($false)) try { $findings = @(Invoke-HarnessReviewerVerdictArtifactValidation -FeatureRoot $tempRoot -VerdictArtifact $VerdictArtifact ` -ExpectedReviewer $Reviewer -SliceId $Slice -AllowedVerdicts $recordableVerdicts) if ($findings.Count -gt 0) { $messages = @($findings | ForEach-Object { $_.message }) throw "Reviewer payload failed validation: $($messages -join '; ')" } } finally { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue } } $previewVerdict = if ($normalizedFormat -eq 'json') { [string](Get-HarnessProperty -Object $payloadDoc -Name 'verdict') } else { [string](Get-HarnessYamlScalarMatch -Content $payloadText -Key 'verdict') } if ($previewVerdict -eq 'APPROVED' -and -not $AllowStub) { if ($normalizedFormat -eq 'yaml') { if (-not (Test-HarnessYamlBlockPresent -Content $payloadText -Key 'gates') -and -not (Test-HarnessYamlBlockPresent -Content $payloadText -Key 'quality_gates')) { throw 'APPROVED reviewer YAML must include gates: or quality_gates: block; persist verbatim subagent output.' } if (Test-HarnessReviewerYamlHasBlockingGateFail -Content $payloadText) { throw 'APPROVED cannot be recorded when reviewer YAML contains a blocking FAIL gate (digest guard).' } } if (-not (Test-HarnessReviewerReworkEvidenceSatisfied -FeatureRoot $featureRootPath -SliceId $Slice -Reviewer $Reviewer)) { throw 'APPROVED requires harness-run-slice-tests PASS recorded after the latest NEEDS_REVISION cycle for this reviewer (rework evidence).' } } $priorVerified = @() $escapeHatch = $null $yamlIterationRaw = '' if ($normalizedFormat -eq 'json') { $priorVerified = @((Get-HarnessProperty -Object $payloadDoc -Name 'prior_defects_verified' -Default @())) $escapeHatch = Get-HarnessProperty -Object $payloadDoc -Name 'escape_hatch' $yamlIterationRaw = [string](Get-HarnessProperty -Object $payloadDoc -Name 'iteration' -Default '') } else { $priorVerified = @(Get-HarnessYamlStringList -Content $payloadText -Key 'prior_defects_verified') $escapeHatch = Get-HarnessYamlScalarMatch -Content $payloadText -Key 'escape_hatch' $yamlIterationRaw = [string](Get-HarnessYamlScalarMatch -Content $payloadText -Key 'iteration') } $stampUtc = Get-HarnessIso8601UtcNow if ($normalizedFormat -eq 'json') { $payloadDoc.timestamp = $stampUtc $payloadText = ($payloadDoc | ConvertTo-Json -Depth 20) } else { $payloadText = Format-HarnessYamlTimestampField -Content $payloadText -Timestamp $stampUtc } $fullArtifactPath = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $featureRootPath -RelativePath $VerdictArtifact -ThrowOnEscape New-Item -ItemType Directory -Path (Split-Path $fullArtifactPath -Parent) -Force | Out-Null [System.IO.File]::WriteAllText($fullArtifactPath, $payloadText, [System.Text.UTF8Encoding]::new($false)) $existingCycles = @($sliceEntry.cycles | Where-Object { (Get-HarnessProperty -Object $_ -Name 'reviewer') -eq $Reviewer }) if (-not [string]::IsNullOrWhiteSpace($yamlIterationRaw) -and $yamlIterationRaw -match '^\d+$' -and [int]$yamlIterationRaw -ge 1) { $iteration = [int]$yamlIterationRaw } else { $iteration = $existingCycles.Count + 1 } $reviewVerdict = if ($normalizedFormat -eq 'json') { [string](Get-HarnessProperty -Object $payloadDoc -Name 'verdict') } else { [string](Get-HarnessYamlScalarMatch -Content $payloadText -Key 'verdict') } $reviewTimestamp = $stampUtc $defectIds = @() if ($normalizedFormat -eq 'json') { $defects = Get-HarnessProperty -Object $payloadDoc -Name 'defects' if ($defects) { foreach ($d in @($defects)) { $id = [string](Get-HarnessProperty -Object $d -Name 'id') if (-not [string]::IsNullOrWhiteSpace($id)) { $defectIds += $id } } } } else { $defectIds = @(Get-HarnessYamlDefectId -Content $payloadText) } if ($reviewVerdict -eq 'APPROVED') { if ($SourceProvenance -eq 'host-id' -and [string]::IsNullOrWhiteSpace($SourceInvocationId)) { throw 'APPROVED with SourceProvenance host-id requires -SourceInvocationId.' } if ($SourceProvenance -eq 'artifact-attested' -and ($ModelProvider -ne 'copilot' -or -not [string]::IsNullOrWhiteSpace($SourceInvocationId))) { throw 'SourceProvenance artifact-attested is only valid for Copilot subagents without a host invocation id.' } } $journalOutcome = if ($reviewVerdict -eq 'APPROVED') { 'PASS' } else { 'FAIL' } $invocation = Format-HarnessModelInvocation -Agent $Agent -Provider $ModelProvider -Slug $resolvedSlug -Role $ModelRole -ParentAgent $(if ($ParentAgent) { $ParentAgent } else { $null }) -Tool $Tool $actor = New-HarnessJournalActor -Agent $Agent -ParentAgent $(if ($ParentAgent) { $ParentAgent } else { $null }) -Tool $Tool -Provider $ModelProvider -Slug $resolvedSlug -Role $ModelRole $activity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type 'review.cycle' -Name $Reviewer -Role 'instant' $cyclePayload = [pscustomobject]@{ producer = $producer reviewer = $Reviewer iteration = $iteration verdict = $reviewVerdict defect_ids = @($defectIds) prior_defects_verified = @($priorVerified) escape_hatch = $(if ($escapeHatch) { $escapeHatch } else { $null }) verdict_artifact = $VerdictArtifact timestamp = $reviewTimestamp invocation = $invocation source_provenance = $SourceProvenance } if (-not [string]::IsNullOrWhiteSpace($SourceInvocationId)) { $cyclePayload | Add-Member -NotePropertyName 'source_invocation_id' -NotePropertyValue $SourceInvocationId } $journalEvent = Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice ` -Kind 'ReviewerCycleRecorded' -Activity $activity -Actor $actor -Outcome $journalOutcome ` -Summary "review cycle recorded for $Reviewer verdict=$reviewVerdict" ` -Payload ([pscustomobject]@{ cycle = $cyclePayload }) ` -Refs ([pscustomobject]@{ amends_event_id = $null pair_event_id = $null artifact = $VerdictArtifact command = '.\scripts\kaden\harness-record-review.ps1' supersedes_event_id = $null }) Update-HarnessGateProjectionFromJournal -FeatureRoot $featureRootPath -Feature $Feature -RepoRoot $RepoRoot -Write | Out-Null Sync-HarnessOwnSliceNextArtifact -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice -RepoRoot $RepoRoot | Out-Null Remove-HarnessStagedPayloadIfEligible -CandidatePath $PayloadPath -RepoRoot $RepoRoot -FeatureRoot $featureRootPath [pscustomobject]@{ operation = 'record-review' feature = $Feature slice = $Slice reviewer = $Reviewer artifact = $VerdictArtifact canonical_path = $fullArtifactPath journal_event_ids = @($journalEvent.event_id) status = 'PASS' } | ConvertTo-Json -Depth 10 |