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

#Requires -Version 5.1
<#
.SYNOPSIS
    Record a handoff artifact as journal truth and rebuild projection.

.DESCRIPTION
    Validates the markdown handoff front matter before it is written, writes the handoff
    artifact, appends HandoffRecorded to the journal, and rebuilds the gate projection.
#>

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

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

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

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

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

    [string]$HandoffArtifact = '',

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

$featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot
if (-not (Test-Path -LiteralPath $PayloadPath)) {
    throw "Payload not found: $PayloadPath"
}
$payloadText = Get-Content -LiteralPath $PayloadPath -Raw
if ([string]::IsNullOrWhiteSpace($payloadText)) {
    throw 'Handoff payload is empty.'
}

if ($HandoffArtifact) {
    $relativeArtifact = $HandoffArtifact
}
else {
    $relativeArtifact = "handoff-$Slice.md"
}

if ($payloadText -notmatch '(?ms)^---\s*\r?\n.*?\r?\n---\s*\r?\n') {
    throw 'Handoff payload must start with YAML front matter.'
}
foreach ($requiredKey in @('handoff_version', 'from', 'to', 'feature', 'slice', 'ownership_status', 'status', 'timestamp')) {
    if (-not (Test-HarnessYamlBlockPresent -Content $payloadText -Key $requiredKey)) {
        throw "Handoff payload missing required '${requiredKey}:' front matter"
    }
}
if ((Get-HarnessYamlScalarMatch -Content $payloadText -Key 'from') -ne $From) {
    throw "Handoff payload from must be '$From'."
}
if ((Get-HarnessYamlScalarMatch -Content $payloadText -Key 'to') -ne $To) {
    throw "Handoff payload to must be '$To'."
}
if ((Get-HarnessYamlScalarMatch -Content $payloadText -Key 'slice') -ne $Slice) {
    throw "Handoff payload slice must be '$Slice'."
}
if ((Get-HarnessYamlScalarMatch -Content $payloadText -Key 'feature') -ne $Feature) {
    throw "Handoff payload feature must be '$Feature'."
}
if ((Get-HarnessYamlScalarMatch -Content $payloadText -Key 'status') -ne 'complete') {
    throw 'Handoff payload status must be complete.'
}
$handoffOwnership = Get-HarnessYamlScalarMatch -Content $payloadText -Key 'ownership_status'
if ($handoffOwnership -eq 'review_ready') {
    throw "Handoff payload must not claim ownership_status: review_ready (elevation is GateValidationPassed only). Use in_progress or blocked."
}
if (-not (Test-HarnessUtcTimestamp -Value (Get-HarnessYamlScalarMatch -Content $payloadText -Key 'timestamp'))) {
    throw 'Handoff payload timestamp must be RFC 3339 UTC Z.'
}

$fullArtifactPath = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $featureRootPath -RelativePath $relativeArtifact -ThrowOnEscape
New-Item -ItemType Directory -Path (Split-Path $fullArtifactPath -Parent) -Force | Out-Null
[System.IO.File]::WriteAllText($fullArtifactPath, $payloadText, [System.Text.UTF8Encoding]::new($false))

$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
$activity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type 'handoff' -Name $Slice -Role 'instant'
$journalEvent = Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
    -Kind 'HandoffRecorded' -Activity $activity -Actor $actor -Outcome 'PASS' `
    -Summary "handoff recorded for $Slice" `
    -Payload ([pscustomobject]@{
        handoff = [pscustomobject]@{
            from       = $From
            to         = $To
            artifact   = $relativeArtifact
            status     = 'complete'
            timestamp  = Get-HarnessYamlScalarMatch -Content $payloadText -Key 'timestamp'
            invocation = $invocation
        }
    }) `
    -Refs ([pscustomobject]@{
        amends_event_id     = $null
        pair_event_id       = $null
        artifact            = $relativeArtifact
        command             = '.\scripts\kaden\harness-record-handoff.ps1'
        supersedes_event_id = $null
    })

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

Remove-HarnessStagedPayloadIfEligible -CandidatePath $PayloadPath -RepoRoot $RepoRoot -FeatureRoot $featureRootPath

[pscustomobject]@{
    operation         = 'record-handoff'
    feature           = $Feature
    slice             = $Slice
    artifact          = $relativeArtifact
    canonical_path    = $fullArtifactPath
    journal_event_ids = @($journalEvent.event_id)
    status            = 'PASS'
} | ConvertTo-Json -Depth 10