Payload/scripts/kaden/harness/harness-step.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Execute one gate-relevant harness step as a single transactional recording action.

.DESCRIPTION
    Runs a named command, writes one evidence artifact, appends journal truth, and rebuilds
    the gate projection from that journal truth in the same invocation.

    Phase completion is only recorded when the observed exit code matches the expected exit code.
    That lets RED treat an expected failing test as a successful step without requiring an
    intermediate commit or a second recording command.

.EXAMPLE
    .\scripts\kaden\harness-step.ps1 -Feature harness-event-journal -Slice tsr-journal-first-skeleton -Phase RED -Name red-test -Command powershell.exe -Arguments '-NoProfile','-Command','exit 1' -ExpectExit 1

.EXAMPLE
    .\scripts\kaden\harness-step.ps1 -Feature harness-event-journal -Slice tsr-journal-first-skeleton -Phase REFACTOR -Name verify-local -Command .\scripts\kaden\verify-local.ps1 -ExpectExit 0
#>

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

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

    [Parameter(Mandatory = $true)]
    [ValidateSet('RED', 'GREEN', 'MUTATE', 'REFACTOR', 'COMMIT')]
    [string]$Phase,

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

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

    [string[]]$Arguments = @(),

    [int]$ExpectExit = 0,

    [string]$TestSelector = '',

    [string[]]$TestFiles = @(),

    [string]$ObligationKey = '',

    [string]$AssertionText = '',

    [string]$EvidenceName = '',

    [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]$IncludeCommandTails,

    [switch]$AllowArbitraryFeatureRoot
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$modulePath = Join-Path $PSScriptRoot 'lib\HarnessContract.psm1'
Import-Module $modulePath -Force
Import-Module (Join-Path $PSScriptRoot 'lib\HarnessJournal.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'lib\HarnessGateProjection.psm1') -Force
$runtimeModule = Join-Path $PSScriptRoot 'lib\RepoRuntime.psm1'
Import-Module $runtimeModule -Force
Test-HarnessContractModuleReady

if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    $RepoRoot = Get-HarnessRepoRoot -StartPath $PSScriptRoot
}

Initialize-RepoShellEnvironment -LogPrefix 'harness-step' | Out-Null

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

# Reject synthetic RED theater (exit 1 with no test obligation) unless operator uses RED_RECOVERY skip on record-phase.
if ($Phase -eq 'RED' -and $ExpectExit -ne 0) {
    $hasObligation = (@($TestFiles).Count -gt 0) `
        -or (-not [string]::IsNullOrWhiteSpace($TestSelector)) `
        -or (-not [string]::IsNullOrWhiteSpace($AssertionText)) `
        -or (-not [string]::IsNullOrWhiteSpace($ObligationKey))
    if (-not $hasObligation) {
        throw @"
harness-step: RED with ExpectExit $ExpectExit requires -TestFiles, -TestSelector, -AssertionText, or -ObligationKey (observed failing tests).
For interrupted recovery without a real failing run, record RED as skipped:
  harness-record-phase.ps1 -Phase RED -Status skipped -SkipReasonCode RED_RECOVERY -SkipRationale <why>
Do not use a bare 'exit 1' wrapper as RED evidence.
"@

    }
}

$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

$snapshotTestFiles = @($TestFiles)
$fileHashes = [ordered]@{}
foreach ($testFile in $snapshotTestFiles) {
    if (-not (Test-Path -LiteralPath $testFile)) {
        throw "Transactional step test file not found: $testFile"
    }
    $fileHashes[$testFile] = (Get-FileHash -LiteralPath $testFile -Algorithm SHA256).Hash
}

$resolvedCommandPath = $null
$commandHash = $null
if (Test-Path -LiteralPath $Command) {
    $resolvedCommandPath = (Resolve-Path -LiteralPath $Command).Path
    $commandHash = (Get-FileHash -LiteralPath $resolvedCommandPath -Algorithm SHA256).Hash
}

$resolvedObligationKey = if ([string]::IsNullOrWhiteSpace($ObligationKey)) {
    if ([string]::IsNullOrWhiteSpace($TestSelector)) { $null } else { $TestSelector }
}
else {
    $ObligationKey
}

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

$startedAt = Get-HarnessIso8601UtcNow
$result = Invoke-HarnessExternalCommand -RepoRoot $RepoRoot -Command $Command -Arguments $Arguments
$completedAt = Get-HarnessIso8601UtcNow
$matched = ($result.ExitCode -eq $ExpectExit)
$stepVerdict = if ($matched) { 'PASS' } else { 'FAIL' }
$summaryText = if ($matched) {
    "step $Name satisfied expected exit $ExpectExit"
}
else {
    "step $Name expected exit $ExpectExit but observed $($result.ExitCode)"
}

$stdoutTail = Format-HarnessSensorCommandTail -Lines $result.Stdout -MaxLines 40 -IncludeCommandTails:$IncludeCommandTails -ExitCode $result.ExitCode
$stderrTail = Format-HarnessSensorCommandTail -Lines $result.Stderr -MaxLines 20 -IncludeCommandTails:$IncludeCommandTails -ExitCode $result.ExitCode
$artifactBody = [ordered]@{
    sensor_version     = '1.0'
    name               = $Name
    type               = 'transactional'
    source             = 'harness-step.ps1'
    phase              = $Phase
    command            = $Command
    arguments          = @($Arguments)
    expected_exit_code = $ExpectExit
    exit_code          = $result.ExitCode
    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 = $ExpectExit
        exit_code          = $result.ExitCode
        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         = $Command
        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\kaden\harness-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 = $ExpectExit
    actual_exit_code   = $result.ExitCode
    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 10

if (-not $matched) {
    exit 1
}

exit 0