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 when that source is parent-inherited and the child evidence lives under .reviews/ or quality/ on the feature root. 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 when the phase records parent-inherited or another delegated source. The path must sit under .reviews/ or quality/ on the feature root. The envelope at that path must be artifact-attested and name a concrete model slug. .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]$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.' } 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 ($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 } $phasePayload = [ordered]@{ 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 model_source = $resolvedModelSource } if (-not [string]::IsNullOrWhiteSpace($LinkedArtifact)) { $phasePayload['linked_artifact'] = $LinkedArtifact.Trim() } 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 = $null pair_event_id = $null artifact = $(if (-not [string]::IsNullOrWhiteSpace($LinkedArtifact)) { $LinkedArtifact.Trim() } elseif ($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)" |