Payload/scripts/harness/harness-record-task-step.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Normalize trusted task output into transactional harness evidence.

.DESCRIPTION
    Consumes a task-output JSON envelope, writes one transactional sensor artifact,
    appends journal truth, and rebuilds the gate projection from journal state.
    This avoids manual transcription when terminal/task execution is unstable.
#>

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

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

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

    [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',

    [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-task-step' | Out-Null

$featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot

if (-not (Test-Path -LiteralPath $EnvelopePath)) {
    throw "Task envelope not found: $EnvelopePath"
}

$envelopeRaw = Get-Content -LiteralPath $EnvelopePath -Raw
if ([string]::IsNullOrWhiteSpace($envelopeRaw)) {
    throw 'Task envelope is empty.'
}

try {
    $taskEnvelope = ConvertFrom-HarnessJson -Json $envelopeRaw
}
catch {
    throw "Task envelope is not valid JSON: $($_.Exception.Message)"
}

$name = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'name')
$phase = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'phase')
$expectedExit = Get-HarnessProperty -Object $taskEnvelope -Name 'expected_exit_code'
$actualExit = Get-HarnessProperty -Object $taskEnvelope -Name 'exit_code'
$command = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'command')

if ([string]::IsNullOrWhiteSpace($name)) { throw 'Task envelope missing required field: name' }
if ([string]::IsNullOrWhiteSpace($phase)) { throw 'Task envelope missing required field: phase' }
if ($phase -notin @('RED', 'GREEN', 'MUTATE', 'REFACTOR', 'COMMIT')) { throw "Task envelope phase must be one of RED/GREEN/MUTATE/REFACTOR/COMMIT (found '$phase')" }
if ($null -eq $expectedExit) { throw 'Task envelope missing required field: expected_exit_code' }
if ($null -eq $actualExit) { throw 'Task envelope missing required field: exit_code' }
if ([string]::IsNullOrWhiteSpace($command)) { throw 'Task envelope missing required field: command' }

$expectedExitCode = [int]$expectedExit
$actualExitCode = [int]$actualExit

$arguments = @((Get-HarnessProperty -Object $taskEnvelope -Name 'arguments'))
$testSelector = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'test_selector')
$testFiles = @((Get-HarnessProperty -Object $taskEnvelope -Name 'test_files'))
$obligationKey = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'obligation_key')
$assertionText = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'assertion_text')
$evidenceName = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'evidence_name')
$stdoutTail = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'stdout_tail')
$stderrTail = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'stderr_tail')
$startedAt = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'started_at')
$completedAt = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'completed_at')
$providedCommandPath = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'command_path')
$providedCommandHash = [string](Get-HarnessProperty -Object $taskEnvelope -Name 'command_hash')

if ([string]::IsNullOrWhiteSpace($startedAt)) { $startedAt = Get-HarnessIso8601UtcNow }
if ([string]::IsNullOrWhiteSpace($completedAt)) { $completedAt = Get-HarnessIso8601UtcNow }
if (-not (Test-HarnessUtcTimestamp -Value $startedAt)) { throw "Task envelope started_at must be RFC 3339 UTC Z (found '$startedAt')" }
if (-not (Test-HarnessUtcTimestamp -Value $completedAt)) { throw "Task envelope completed_at must be RFC 3339 UTC Z (found '$completedAt')" }

$snapshotTestFiles = @($testFiles | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })
$fileHashes = [ordered]@{}
foreach ($testFile in $snapshotTestFiles) {
    if (-not (Test-Path -LiteralPath $testFile)) {
        throw "Task envelope test file not found: $testFile"
    }
    $fileHashes[$testFile] = (Get-FileHash -LiteralPath $testFile -Algorithm SHA256).Hash
}

$resolvedObligationKey = if ([string]::IsNullOrWhiteSpace($obligationKey)) {
    if ([string]::IsNullOrWhiteSpace($testSelector)) { $null } else { $testSelector }
}
else {
    $obligationKey
}

$resolvedCommandPath = $providedCommandPath
$commandHash = $providedCommandHash
if ([string]::IsNullOrWhiteSpace($resolvedCommandPath) -and (Test-Path -LiteralPath $command)) {
    $resolvedCommandPath = (Resolve-Path -LiteralPath $command).Path
}
if ([string]::IsNullOrWhiteSpace($commandHash) -and -not [string]::IsNullOrWhiteSpace($resolvedCommandPath) -and (Test-Path -LiteralPath $resolvedCommandPath)) {
    $commandHash = (Get-FileHash -LiteralPath $resolvedCommandPath -Algorithm SHA256).Hash
}

$matched = ($actualExitCode -eq $expectedExitCode)
$stepVerdict = if ($matched) { 'PASS' } else { 'FAIL' }
$summaryText = if ($matched) {
    "task step $name satisfied expected exit $expectedExitCode"
}
else {
    "task step $name expected exit $expectedExitCode but observed $actualExitCode"
}

$resolvedSlug = Get-HarnessResolvedModelSlug -ModelSlug $ModelSlug
$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

$sensorName = if ([string]::IsNullOrWhiteSpace($evidenceName)) { $name } else { $evidenceName }
$timestampForFile = Get-HarnessTimestampForFilename
$artifactRelativePath = Get-HarnessSensorArtifactRelativePath -Name $sensorName -TimestampForFile $timestampForFile

$supersedesEvent = Get-HarnessLatestJournalEventByName -FeatureRoot $featureRootPath -SliceId $Slice -Name $name -Phase $phase

$artifactBody = [ordered]@{
    sensor_version     = '1.0'
    name               = $name
    type               = 'transactional'
    source             = 'harness-record-task-step.ps1'
    phase              = $phase
    command            = $command
    arguments          = @($arguments)
    expected_exit_code = $expectedExitCode
    exit_code          = $actualExitCode
    verdict            = $stepVerdict
    started_at         = $startedAt
    completed_at       = $completedAt
    summary            = $summaryText
    stdout_tail        = $stdoutTail
    stderr_tail        = $stderrTail
    invocation         = $invocation
    snapshot           = [ordered]@{
        test_selector       = $(if ([string]::IsNullOrWhiteSpace($testSelector)) { $null } else { $testSelector })
        test_files          = @($snapshotTestFiles)
        file_hashes         = [pscustomobject]$fileHashes
        obligation_key      = $resolvedObligationKey
        assertion_text      = $(if ([string]::IsNullOrWhiteSpace($assertionText)) { $null } else { $assertionText })
        command_path        = $resolvedCommandPath
        command_hash        = $commandHash
        supersedes_event_id = $(if ($supersedesEvent) { $supersedesEvent.event_id } else { $null })
        workspace_head      = $null
        workspace_dirty     = $true
    }
}

Export-HarnessSensorArtifactFile -FeatureRoot $featureRootPath -Payload @{ relative_path = $artifactRelativePath; body = $artifactBody } | Out-Null

$journalEventIds = [System.Collections.ArrayList]@()
$sensorActivity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type 'sensor' -Name $name -Role 'instant'
$sensorKind = if ($name -eq 'validate-review-loop') { 'GateCheckRun' } else { 'SensorRunRecorded' }
$sensorEvent = Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
    -Kind $sensorKind -Activity $sensorActivity -Actor $actor -Outcome $stepVerdict `
    -Summary $summaryText `
    -Payload ([pscustomobject]@{
        name                = $name
        type                = 'transactional'
        phase               = $phase
        verdict             = $stepVerdict
        evidence_artifact   = $artifactRelativePath
        expected_exit_code  = $expectedExitCode
        exit_code           = $actualExitCode
        command             = $command
        arguments           = @($arguments)
        test_selector       = $(if ([string]::IsNullOrWhiteSpace($testSelector)) { $null } else { $testSelector })
        test_files          = @($snapshotTestFiles)
        obligation_key      = $resolvedObligationKey
        command_path        = $resolvedCommandPath
        supersedes_event_id = $(if ($supersedesEvent) { $supersedesEvent.event_id } else { $null })
    }) `
    -Refs ([pscustomobject]@{
        amends_event_id     = $null
        pair_event_id       = $null
        artifact            = $artifactRelativePath
        command             = '.\\scripts\\harness-record-task-step.ps1'
        supersedes_event_id = $(if ($supersedesEvent) { $supersedesEvent.event_id } else { $null })
    })
[void]$journalEventIds.Add($sensorEvent.event_id)

$projectedPhaseStatus = $null
if ($matched) {
    $phaseActivity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type 'slice.phase' -Name $phase -Role 'instant'
    $phaseEvent = Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
        -Kind 'PhaseStatusChanged' -Activity $phaseActivity -Actor $actor -Outcome $null `
        -Summary "slice $Slice phase $phase status=complete" `
        -Payload ([pscustomobject]@{
            phase             = $phase
            status            = 'complete'
            evidence_artifact = $artifactRelativePath
        }) `
        -Refs ([pscustomobject]@{
            amends_event_id = $null
            pair_event_id   = $null
            artifact        = $artifactRelativePath
            command         = '.\\scripts\\harness-record-task-step.ps1'
        })
    [void]$journalEventIds.Add($phaseEvent.event_id)
    $projectedPhaseStatus = 'complete'
}

Update-HarnessGateProjectionFromJournal -FeatureRoot $featureRootPath -Feature $Feature -RepoRoot $RepoRoot -Write | Out-Null

$resultEnvelope = [ordered]@{
    operation          = $name
    feature            = $Feature
    slice              = $Slice
    phase              = $phase
    status             = $stepVerdict
    expected_exit_code = $expectedExitCode
    actual_exit_code   = $actualExitCode
    artifact           = $artifactRelativePath
    journal_event_ids  = @($journalEventIds)
    projection_updates = [ordered]@{
        phase                = $phase
        phase_status         = $projectedPhaseStatus
        authoritative_sensor = $name
        rebuild_source       = 'journal/events.jsonl'
    }
}

$resultEnvelope | ConvertTo-Json -Depth 12

if (-not $matched) {
    exit 1
}

exit 0