Payload/scripts/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/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/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\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