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

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

    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"
#>

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

    [string]$FeatureRoot = '',

    [string]$RepoRoot = '',

    [switch]$AllowArbitraryFeatureRoot,

    [string]$Summary = ''
)

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
}

$actor = New-HarnessJournalActor -Agent $Agent -ParentAgent $(if ($ParentAgent) { $ParentAgent } else { $null }) `
    -Tool $Tool -Provider $ModelProvider -Slug $resolvedSlug -Role $ModelRole
$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.'
}

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'
    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]@{
            feature_phase         = $FeaturePhase
            status                = $FeaturePhaseStatus
            artifacts             = @($Artifacts)
            source_provenance     = $SourceProvenance
            source_invocation_id  = $(if ($SourceInvocationId) { $SourceInvocationId } else { $null })
        }) `
        -Refs ([pscustomobject]@{
            amends_event_id = $null
            pair_event_id   = $null
            artifact        = $(if ($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 ($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
        }
    }
    $activity = New-HarnessJournalActivity -Id $activityId -Type 'slice.phase' -Name $Phase -Role 'instant'
    $summaryLine = if (-not [string]::IsNullOrWhiteSpace($Summary)) {
        $Summary.Trim()
    }
    else {
        "slice $Slice phase $Phase status=$Status"
    }
    $testFileHashes = $null
    if ($Status -eq 'complete' -and $Phase -in @('RED', 'GREEN') -and -not [string]::IsNullOrWhiteSpace($EvidenceArtifact)) {
        $testFileHashes = Get-HarnessSliceTestContentHash -RepoRoot $RepoRoot -FeatureRoot $featureRootPath -EvidenceArtifact $EvidenceArtifact
    }
    Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
        -Kind 'PhaseStatusChanged' -Activity $activity -Actor $actor -Outcome $null `
        -Summary $summaryLine `
        -Payload ([pscustomobject]@{
            phase             = $Phase
            status            = $Status
            evidence_artifact = $(if ($EvidenceArtifact) { $EvidenceArtifact } else { $null })
            skip_reason_code  = $(if ($SkipReasonCode) { $SkipReasonCode } else { $null })
            skip_rationale    = $(if ($SkipRationale) { $SkipRationale } else { $null })
            test_file_hashes  = $testFileHashes
        }) `
        -Refs ([pscustomobject]@{
            amends_event_id = $null
            pair_event_id   = $null
            artifact        = $(if ($EvidenceArtifact) { $EvidenceArtifact } 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 } else { $Status }
Write-Output "Recorded $(if ($FeaturePhase) { "feature phase $FeaturePhase" } else { "slice $Slice phase $Phase" }) status=$recordedStatus at $(Get-HarnessIso8601UtcNow)"