Payload/scripts/kaden/harness/harness-record-phase.ps1

<#
.SYNOPSIS
    Record one ATDD or feature phase; journals PhaseStatusChanged as a byproduct.

.DESCRIPTION
    Call after EACH phase (RED/GREEN/MUTATE/REFACTOR or quality-gates / slice-review) -
    never batch all phases with one timestamp at slice end.

    Pass -ModelSource when the phase should record an explicit model source instead of the resolver default.
    Pass -LinkedArtifact for one of two link shapes: MODEL_SOURCE_INHERITED child evidence under .reviews/ or quality/ on the feature root, or a GREEN MODEL_SOURCE_DELEGATED_UNLINKED correction that names feature-root handoff-<slice-id>.md (not a review or quality file).
    Pass -AmendsEventId to append a GREEN provenance correction for an already-recorded same-slice GREEN event (linked implementation handoff and/or corrected model source). The original row stays in the journal.

    Big picture (docs/kaden/harness-journal.md):
    - Appends PhaseStatusChanged to journal/events.jsonl so the ledger matches the books.
    - Rebuilds the gate-facing .review-loop.json from journal truth.
    - UTC timestamps and invocation blocks come from this script (do not hand-forge Z times).

.EXAMPLE
    .\scripts\kaden\harness-record-phase.ps1 -Feature saved-search-queries -Slice mo-save-list -Phase RED -Status in_progress -ModelProvider copilot -ModelSlug gpt-5.3-codex

.EXAMPLE
    .\scripts\kaden\harness-record-phase.ps1 -Feature saved-search-queries -Slice mo-save-list -Phase RED -Status complete -EvidenceArtifact sensors/red-green-20260730T120000Z.json -ModelProvider copilot

.EXAMPLE
    Multi-artifact (preferred via public entry):
    pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-phase.ps1 -Feature harness-event-journal -FeaturePhase quality-gates -FeaturePhaseStatus complete -ArtifactsCsv "quality/security-assessment-report.md,quality/consistency-check-report.md,quality/documentation-review-report.md"

.PARAMETER ModelSource
    Explicit model_source for this phase event. Omit it to let the resolver pick script-runner for the recorder process, or parent-inherited when the actor is a subagent. Pass parent-inherited when delegated work produced the phase and -LinkedArtifact points at that child's envelope. Other explicit values: host-reported, artifact-attested, unknown-runtime, operator-attested-adjustment.

.PARAMETER LinkedArtifact
    Feature-root relative path to child evidence. For MODEL_SOURCE_INHERITED the path must sit under .reviews/ or quality/ on the feature root; the envelope must be artifact-attested and name a concrete model slug. For MODEL_SOURCE_DELEGATED_UNLINKED on GREEN, the valid implementation link is feature-root handoff-<slice-id>.md (not a review or quality file).

.PARAMETER AmendsEventId
    Event id of an already-recorded GREEN PhaseStatusChanged row for this slice. Appends an amending GREEN event with refs.amends_event_id set to that id. Bypasses duplicate suppression and GREEN evidence checks; copies the original status and evidence. Rejects a non-GREEN target, another slice, and a second amendment of the same row (amend the current chain tip).

.NOTES
    The #Requires -Version 5.1 statement follows param so this comment block stays the first statement. Get-Help ignores the block when that statement is above it. Do not move it above the help without re-checking Get-Help.
#>

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

    [string]$Slice = '',

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

    [ValidateSet('in_progress', 'complete', 'skipped')]
    [string]$Status = 'complete',

    [ValidateSet('quality-gates', 'slice-review')]
    [string]$FeaturePhase = '',

    [ValidateSet('in_progress', 'complete')]
    [string]$FeaturePhaseStatus = 'complete',

    # Prefer single-string forms when calling via the public entry:
    # -ArtifactsCsv "a.md,b.md,c.md"
    # -ArtifactsFile path\to\artifacts.json (JSON string array) or .txt (one path per line)
    # -SkipRationaleFile path\to\rationale.txt (when the rationale has spaces or leading dashes)
    # Named quality-gate reports are merged into the same list.
    [string[]]$Artifacts = @(),

    [string]$ArtifactsCsv = '',

    [string]$ArtifactsFile = '',

    [string]$SecurityReport = '',

    [string]$ConsistencyReport = '',

    [string]$DocumentationReport = '',

    [string]$EvidenceArtifact = '',

    [string]$SkipReasonCode = '',

    [string]$SkipRationale = '',

    # Cmd-safe alternative when the rationale must contain spaces or leading dashes.
    [string]$SkipRationaleFile = '',

    [string]$SourceInvocationId = '',

    [ValidateSet('host-id', 'artifact-attested')]
    [string]$SourceProvenance = 'host-id',

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

    [ValidateSet('', 'host-reported', 'artifact-attested', 'script-runner', 'parent-inherited', 'unknown-runtime', 'operator-attested-adjustment')]
    [string]$ModelSource = '',

    [string]$LinkedArtifact = '',

    [string]$AmendsEventId = '',

    [string]$FeatureRoot = '',

    [string]$RepoRoot = '',

    [switch]$AllowArbitraryFeatureRoot,

    [string]$Summary = ''
)

#Requires -Version 5.1
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
Test-HarnessContractModuleReady

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

$featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot
$resolvedSlug = Get-HarnessResolvedModelSlug -ModelSlug $ModelSlug
Write-Verbose "harness-record-phase modelSlug=$ModelSlug resolved=$resolvedSlug"

if (-not [string]::IsNullOrWhiteSpace($SkipRationaleFile)) {
    if (-not (Test-Path -LiteralPath $SkipRationaleFile)) {
        throw "SkipRationaleFile not found: $SkipRationaleFile"
    }
    $fromFile = ([string](Get-Content -LiteralPath $SkipRationaleFile -Raw)).Trim()
    if ([string]::IsNullOrWhiteSpace($fromFile)) {
        throw "SkipRationaleFile is empty: $SkipRationaleFile"
    }
    if (-not [string]::IsNullOrWhiteSpace($SkipRationale) -and $SkipRationale -ne $fromFile) {
        throw 'Specify only one of -SkipRationale or -SkipRationaleFile'
    }
    $SkipRationale = $fromFile
}

$resolvedModelSource = Resolve-HarnessPhaseSensorModelSource -ModelSource $ModelSource `
    -ModelRole $ModelRole -ParentAgent $ParentAgent -Tool $Tool -Agent $Agent
$actor = New-HarnessJournalActor -Agent $Agent -ParentAgent $(if ($ParentAgent) { $ParentAgent } else { $null }) `
    -Tool $Tool -Provider $ModelProvider -Slug $resolvedSlug -Role $ModelRole -ModelSource $resolvedModelSource
$activityId = New-HarnessJournalActivityId

# Normalize artifact list for cmd-safe invoke (arrays via %* are fragile).
$artifactList = [System.Collections.ArrayList]@()
foreach ($a in @($Artifacts)) {
    if (-not [string]::IsNullOrWhiteSpace([string]$a)) { [void]$artifactList.Add([string]$a.Trim()) }
}
if (-not [string]::IsNullOrWhiteSpace($ArtifactsCsv)) {
    foreach ($part in ($ArtifactsCsv -split ',')) {
        $t = $part.Trim()
        if ($t) { [void]$artifactList.Add($t) }
    }
}
if (-not [string]::IsNullOrWhiteSpace($ArtifactsFile)) {
    if (-not (Test-Path -LiteralPath $ArtifactsFile)) {
        throw "ArtifactsFile not found: $ArtifactsFile"
    }
    $rawArtifacts = Get-Content -LiteralPath $ArtifactsFile -Raw
    if ($ArtifactsFile -match '\.json$') {
        $parsed = ConvertFrom-HarnessJson -Json $rawArtifacts
        foreach ($item in @($parsed)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$item)) { [void]$artifactList.Add([string]$item) }
        }
    } else {
        foreach ($line in ($rawArtifacts -split '\r?\n')) {
            $t = $line.Trim()
            if ($t -and -not $t.StartsWith('#')) { [void]$artifactList.Add($t) }
        }
    }
}
foreach ($named in @($SecurityReport, $ConsistencyReport, $DocumentationReport)) {
    if (-not [string]::IsNullOrWhiteSpace($named)) { [void]$artifactList.Add($named.Trim()) }
}
$Artifacts = @($artifactList | Select-Object -Unique)

if ([string]::IsNullOrWhiteSpace($Slice)) {
    if ($FeaturePhase) {
        throw 'FeaturePhase recording requires -Slice.'
    }
    throw 'Specify -Slice and -Phase for slice phases, or -FeaturePhase for quality-gates / slice-review.'
}

$amendTarget = $null
if (-not [string]::IsNullOrWhiteSpace($AmendsEventId)) {
    if ($FeaturePhase) {
        throw 'GREEN correction -AmendsEventId cannot be used with -FeaturePhase.'
    }
    if (-not [string]::IsNullOrWhiteSpace($Phase) -and $Phase -ne 'GREEN') {
        throw 'GREEN correction cannot amend a non-GREEN phase.'
    }
    $Phase = 'GREEN'
}

if ($FeaturePhase) {
    if ($FeaturePhase -eq 'slice-review' -and $FeaturePhaseStatus -eq 'complete') {
        throw 'slice-review cannot be recorded via harness-record-phase.ps1. Use harness-record-slice-verdict.ps1 -SourceArtifact .reviews/slice-reviewer-<slice>.yaml -SourceInvocationId <task-id>.'
    }
    Assert-HarnessSliceTurnOpenForRecord -FeatureRoot $featureRootPath -Slice $Slice -Feature $Feature -FeaturePhase $FeaturePhase
    if (Test-HarnessDuplicatePhaseStatus -FeatureRoot $featureRootPath -SliceId $Slice -FeaturePhase $FeaturePhase -Status $FeaturePhaseStatus) {
        Write-Output "No-op: feature phase $FeaturePhase already status=$FeaturePhaseStatus (duplicate PhaseStatusChanged suppressed)"
        if (-not [string]::IsNullOrWhiteSpace($Slice)) {
            Sync-HarnessOwnSliceNextArtifact -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice -RepoRoot $RepoRoot | Out-Null
        }
        return
    }
    if ($FeaturePhase -eq 'quality-gates' -and $FeaturePhaseStatus -eq 'complete') {
        if ($SourceProvenance -eq 'host-id' -and [string]::IsNullOrWhiteSpace($SourceInvocationId)) {
            throw 'quality-gates complete 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.'
        }
    }
    $activity = New-HarnessJournalActivity -Id $activityId -Type 'pipeline.phase' -Name $FeaturePhase -Role 'instant'
    $fpPayload = [ordered]@{
        feature_phase        = $FeaturePhase
        status               = $FeaturePhaseStatus
        artifacts            = @($Artifacts)
        source_provenance    = $SourceProvenance
        source_invocation_id = $(if ($SourceInvocationId) { $SourceInvocationId } else { $null })
        model_source         = $resolvedModelSource
    }
    if (-not [string]::IsNullOrWhiteSpace($LinkedArtifact)) {
        $fpPayload['linked_artifact'] = $LinkedArtifact.Trim()
    }
    Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $(if ($Slice) { $Slice } else { $null }) `
        -Kind 'PhaseStatusChanged' -Activity $activity -Actor $actor -Outcome $null `
        -Summary "feature phase $FeaturePhase status=$FeaturePhaseStatus" `
        -Payload ([pscustomobject]$fpPayload) `
        -Refs ([pscustomobject]@{
            amends_event_id = $null
            pair_event_id   = $null
            artifact        = $(if (-not [string]::IsNullOrWhiteSpace($LinkedArtifact)) { $LinkedArtifact.Trim() } elseif ($Artifacts.Count -gt 0) { $Artifacts[0] } else { $null })
            command         = '.\scripts\kaden\harness-record-phase.ps1'
        }) | Out-Null
} else {
    Assert-HarnessSliceTurnOpenForRecord -FeatureRoot $featureRootPath -Slice $Slice -Feature $Feature
    if ([string]::IsNullOrWhiteSpace($Phase)) {
        throw 'Specify -Slice and -Phase for slice phases, or -FeaturePhase for quality-gates / slice-review.'
    }
    if (-not [string]::IsNullOrWhiteSpace($AmendsEventId)) {
        $amendTarget = Assert-HarnessGreenPhaseAmendmentTarget -FeatureRoot $featureRootPath -SliceId $Slice -AmendsEventId $AmendsEventId
        if ([string]::IsNullOrWhiteSpace($ModelSource)) {
            $resolvedModelSource = Get-HarnessEventModelSource -JournalEvent $amendTarget
            $actor = New-HarnessJournalActor -Agent $Agent -ParentAgent $(if ($ParentAgent) { $ParentAgent } else { $null }) `
                -Tool $Tool -Provider $ModelProvider -Slug $resolvedSlug -Role $ModelRole -ModelSource $resolvedModelSource
        }
    }
    if ($null -eq $amendTarget) {
        if ($Status -eq 'complete' -and $Phase -in @('RED', 'GREEN') -and [string]::IsNullOrWhiteSpace($EvidenceArtifact)) {
            throw "Phase '$Phase' complete requires -EvidenceArtifact"
        }
        # Identical consecutive PhaseStatusChanged (same slice/phase/status) is a no-op.
        if (Test-HarnessDuplicatePhaseStatus -FeatureRoot $featureRootPath -SliceId $Slice -Phase $Phase -Status $Status) {
            Write-Output "No-op: slice $Slice phase $Phase already status=$Status (duplicate PhaseStatusChanged suppressed)"
            if (-not [string]::IsNullOrWhiteSpace($Slice)) {
                Sync-HarnessOwnSliceNextArtifact -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice -RepoRoot $RepoRoot | Out-Null
            }
            return
        }
        # RED complete requires FAIL-shaped evidence; GREEN complete requires PASS-shaped evidence.
        if ($Status -eq 'complete' -and $Phase -in @('RED', 'GREEN')) {
            Test-HarnessPhaseEvidenceVerdict -Phase $Phase -FeatureRoot $featureRootPath -EvidenceArtifact $EvidenceArtifact | Out-Null
        }
        # REFACTOR complete requires verify-local PASS after REFACTOR in_progress (not the turn-start baseline).
        if ($Status -eq 'complete' -and $Phase -eq 'REFACTOR') {
            Test-HarnessRefactorCompleteAllowed -FeatureRoot $featureRootPath -SliceId $Slice | Out-Null
        }
        if ($Status -eq 'complete' -and $Phase -eq 'GREEN') {
            Test-HarnessGreenCompleteAllowed -FeatureRoot $featureRootPath -SliceId $Slice -EvidenceArtifact $EvidenceArtifact | Out-Null
        }
        if ($Status -eq 'skipped') {
            if ([string]::IsNullOrWhiteSpace($SkipReasonCode) -or [string]::IsNullOrWhiteSpace($SkipRationale)) {
                throw "Phase '$Phase' skipped requires -SkipReasonCode and -SkipRationale (or -SkipRationaleFile)"
            }
            $requirements = Get-HarnessRequirementConfig -RepoRoot $RepoRoot
            $allowed = Get-HarnessSkipCodeAllowlist -Requirements $requirements
            if ($allowed -notcontains $SkipReasonCode) {
                throw "Invalid skip_reason_code '$SkipReasonCode'. Allowed: $($allowed -join ', ')"
            }
            if ($Phase -eq 'REFACTOR') {
                Assert-HarnessRefactorSkipAllowed -RepoRoot $RepoRoot -SkipReasonCode $SkipReasonCode
            }
        }
    }
    $recordStatus = $Status
    $recordEvidence = $(if ($EvidenceArtifact) { $EvidenceArtifact } else { $null })
    $recordSkipCode = $(if ($SkipReasonCode) { $SkipReasonCode } else { $null })
    $recordSkipRationale = $(if ($SkipRationale) { $SkipRationale } else { $null })
    $recordLinked = $(if (-not [string]::IsNullOrWhiteSpace($LinkedArtifact)) { $LinkedArtifact.Trim() } else { '' })
    $testFileHashes = $null
    $amendsId = $null
    if ($null -ne $amendTarget) {
        $copied = Copy-HarnessGreenPhaseAmendmentPayload -JournalEvent $amendTarget -LinkedArtifact $recordLinked
        $recordStatus = [string]$copied.status
        $recordEvidence = $copied.evidence_artifact
        $recordSkipCode = $copied.skip_reason_code
        $recordSkipRationale = $copied.skip_rationale
        $recordLinked = [string]$copied.linked_artifact
        $testFileHashes = $copied.test_file_hashes
        $amendsId = [string]$copied.amends_event_id
    } elseif ($Status -eq 'complete' -and $Phase -in @('RED', 'GREEN') -and -not [string]::IsNullOrWhiteSpace($EvidenceArtifact)) {
        $testFileHashes = Get-HarnessSliceTestContentHash -RepoRoot $RepoRoot -FeatureRoot $featureRootPath -EvidenceArtifact $EvidenceArtifact
    }
    $activity = New-HarnessJournalActivity -Id $activityId -Type 'slice.phase' -Name $Phase -Role 'instant'
    $summaryLine = if (-not [string]::IsNullOrWhiteSpace($Summary)) {
        $Summary.Trim()
    } elseif ($null -ne $amendTarget) {
        "slice $Slice phase GREEN correction amends $amendsId"
    } else {
        "slice $Slice phase $Phase status=$Status"
    }
    $phasePayload = [ordered]@{
        phase             = $Phase
        status            = $recordStatus
        evidence_artifact = $recordEvidence
        skip_reason_code  = $recordSkipCode
        skip_rationale    = $recordSkipRationale
        test_file_hashes  = $testFileHashes
    }
    if ($null -ne $amendTarget) {
        # RS-B blank-source correction: omit model_source only on the amendment path.
        if (-not [string]::IsNullOrWhiteSpace($resolvedModelSource)) {
            $phasePayload['model_source'] = $resolvedModelSource
        }
    } else {
        $phasePayload['model_source'] = $resolvedModelSource
    }
    if (-not [string]::IsNullOrWhiteSpace($recordLinked)) {
        $phasePayload['linked_artifact'] = $recordLinked
    }
    Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
        -Kind 'PhaseStatusChanged' -Activity $activity -Actor $actor -Outcome $null `
        -Summary $summaryLine `
        -Payload ([pscustomobject]$phasePayload) `
        -Refs ([pscustomobject]@{
            amends_event_id = $amendsId
            pair_event_id   = $null
            artifact        = $(if (-not [string]::IsNullOrWhiteSpace($recordLinked)) { $recordLinked } elseif ($recordEvidence) { $recordEvidence } else { $null })
            command         = '.\scripts\kaden\harness-record-phase.ps1'
        }) | Out-Null
}

# Projection remains the gate-facing schema 1.2 document, rebuilt from journal truth.
Update-HarnessGateProjectionFromJournal -FeatureRoot $featureRootPath -Feature $Feature -RepoRoot $RepoRoot -Write | Out-Null
if (-not [string]::IsNullOrWhiteSpace($Slice)) {
    Sync-HarnessOwnSliceNextArtifact -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice -RepoRoot $RepoRoot | Out-Null
}
$recordedStatus = if ($FeaturePhase) {
    $FeaturePhaseStatus
} elseif ($null -ne $amendTarget) {
    $recordStatus
} else {
    $Status
}
Write-Output "Recorded $(if ($FeaturePhase) { "feature phase $FeaturePhase" } else { "slice $Slice phase $Phase" }) status=$recordedStatus at $(Get-HarnessIso8601UtcNow)"