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. Outer-loop feature phases problem-analysis, specification, and spec-review attempts are advisory: they need no -Slice and no inner ownership turn, and they record through the same journal + projection path. spec-review completion is verdict-gated and belongs to harness-record-review.ps1. 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" .EXAMPLE Late registration of outer-loop analysis that began before the feature was formalized (no inner ownership turn required): pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-phase.ps1 -Feature my-feature -FeaturePhase problem-analysis -FeaturePhaseStatus complete -LateRegistration -ExternalOrigin "scratch checkout before formalization" -OccurredAround "2026-09-20" -ContextNote "analysis developed externally; registered late" -ArtifactsCsv "problem-analysis.md" .EXAMPLE Record completed specification through the advisory outer-loop path (no inner ownership turn required): pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-phase.ps1 -Feature my-feature -FeaturePhase specification -FeaturePhaseStatus complete -ArtifactsCsv "spec.md,user-stories.md,slice-plan.md" -Agent spec-writer -ModelProvider cursor -ModelSlug <slug> .EXAMPLE Late registration of outer-loop specification that began before the feature was formalized: pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-phase.ps1 -Feature my-feature -FeaturePhase specification -FeaturePhaseStatus complete -LateRegistration -ExternalOrigin "scratch checkout before formalization" -OccurredAround "2026-09-22" -ContextNote "specification developed externally; registered late" -ArtifactsCsv "spec.md,user-stories.md,slice-plan.md" -Agent spec-writer .EXAMPLE Record a spec-review attempt (advisory, no inner ownership turn). Completion is verdict-gated: use harness-record-review.ps1 -FeaturePhase spec-review for that: pwsh -File scripts/kaden/harness-invoke.ps1 harness-record-phase.ps1 -Feature my-feature -FeaturePhase spec-review -FeaturePhaseStatus in_progress -Agent atdd-developer -ModelProvider cursor -ModelSlug <slug> .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). .PARAMETER LateRegistration Outer-loop honesty switch for feature phases problem-analysis and specification: truthfully introduces work that began before the feature was formalized. Requires -FeaturePhaseStatus complete and all three context fields together; requires at least one canonical artifact; bypasses the duplicate-suppression no-op so the enrichment always lands in the journal. .PARAMETER ExternalOrigin With -LateRegistration: where and how the registered work originally happened (free text, 512 characters max). .PARAMETER OccurredAround With -LateRegistration: approximate timing of the original work as a year (YYYY), month (YYYY-MM), or date (YYYY-MM-DD); other prose is refused, 512 characters max. .PARAMETER ContextNote With -LateRegistration: why the work is being registered late and who performed it (free text, 512 characters max). .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', 'problem-analysis', 'specification', 'spec-review')] [string]$FeaturePhase = '', [ValidateSet('in_progress', 'complete')] [string]$FeaturePhaseStatus = 'complete', # Outer-loop late registration (problem-analysis, specification): introduces existing work # that began before the feature was formalized. All three context fields are required together. [switch]$LateRegistration, [ValidateLength(0, 512)] [string]$ExternalOrigin = '', [ValidateLength(0, 512)] [string]$OccurredAround = '', [ValidateLength(0, 512)] [string]$ContextNote = '', # 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 = '', [string]$ChildConversationId = '', [string]$EvidenceRoot = '', [string]$EnvelopeSlug = '', [ValidateSet('', 'host-id', 'artifact-attested')] [string]$SourceProvenance = '', [ValidateSet('cursor', 'copilot', 'claude', '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 } $script:HarnessIgnoreHostLedger = $false $script:HarnessChildCorrelation = $null if (-not [string]::IsNullOrWhiteSpace($ChildConversationId)) { $script:HarnessChildCorrelation = Resolve-HarnessCorrelatedChildRegistration -EvidenceRoot $EvidenceRoot -RepoRoot $RepoRoot -TaskId $SourceInvocationId -ChildConversationId $ChildConversationId -RegisteredSlug $ModelSlug -EnvelopeSlug $(if ([string]::IsNullOrWhiteSpace($EnvelopeSlug)) { $ModelSlug } else { $EnvelopeSlug }) $childCorrelation = $script:HarnessChildCorrelation if ($childCorrelation.model_source -eq 'unknown-runtime') { $ModelSlug = 'unknown' $ModelSource = 'unknown-runtime' $script:HarnessIgnoreHostLedger = $true } elseif ($childCorrelation.model_source -eq 'host-reported') { $ModelSlug = [string]$childCorrelation.model_slug if ([string]::IsNullOrWhiteSpace($ModelSource)) { $ModelSource = 'host-reported' } } } $featureRootPath = Get-HarnessFeatureRoot -RepoRoot $RepoRoot -Feature $Feature -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot $resolvedSlug = Get-HarnessResolvedModelSlug -ModelSlug $ModelSlug -IgnoreHostLedger:$script:HarnessIgnoreHostLedger Write-Verbose "harness-record-phase modelSlug=$ModelSlug resolved=$resolvedSlug" # Provenance integrity (PROV-01 / slice provenance-integrity): these rules run before # any journal write so a false-attribution row is never appended. # 1. A host-id claim without an invocation id asserts a host identity the row does # not carry. Refused on every phase state. Provenance resolves honestly from the # arguments: an invocation id implies host identity; without one the row attests # its artifacts instead of claiming a host identity it cannot carry. $effectiveSourceProvenance = if ($SourceProvenance) { $SourceProvenance } elseif ($SourceInvocationId) { 'host-id' } else { 'artifact-attested' } if ($effectiveSourceProvenance -eq 'host-id' -and [string]::IsNullOrWhiteSpace($SourceInvocationId)) { throw 'SourceProvenance host-id requires -SourceInvocationId (the host task or invocation id). Hosts that cannot supply one use -SourceProvenance artifact-attested.' } # 2. Advisory outer-loop phases credit the agent that did the work, never the recorder # default. The parent chat may credit itself as atdd-developer only by passing -Agent # explicitly, which is distinguishable from omitting it. $outerLoopProducerAllowlist = @{ 'problem-analysis' = @('problem-analyst', 'atdd-developer') 'specification' = @('spec-writer', 'atdd-developer') 'spec-review' = @('spec-writer-reviewer', 'spec-writer', 'atdd-developer') } if ($outerLoopProducerAllowlist.ContainsKey($FeaturePhase)) { if (-not $PSBoundParameters.ContainsKey('Agent')) { throw "Feature phase '$FeaturePhase' requires -Agent from the producer allowlist ($($outerLoopProducerAllowlist[$FeaturePhase] -join ', ')); omitting it would credit the default agent instead of the producer." } if ($outerLoopProducerAllowlist[$FeaturePhase] -notcontains $Agent) { throw "Feature phase '$FeaturePhase' requires -Agent from the producer allowlist ($($outerLoopProducerAllowlist[$FeaturePhase] -join ', ')); '$Agent' did not produce this phase." } } # 3. An unknown model identity is labelled unknown-runtime, never host-reported. The # label describes the slug, so an explicit -ModelSource cannot override it. $resolvedModelSourceOverride = $null if ($resolvedSlug -eq 'unknown') { $resolvedModelSourceOverride = 'unknown-runtime' } # 4. A model slug is a token: no whitespace, bounded length. if ($resolvedSlug -match '\s' -or $resolvedSlug.Length -gt 64) { throw "Model slug '$ModelSlug' is not a token (no whitespace, at most 64 characters). Pass the real model slug or 'unknown'." } 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 if ($resolvedModelSourceOverride) { $resolvedModelSource = $resolvedModelSourceOverride } $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) # Advisory outer-loop phases record without -Slice or an inner ownership turn. spec-review # joins that list with the boundary pin; its completion is verdict-gated and lives in # harness-record-review.ps1 (attempt states record here). $outerLoopFeaturePhases = @($outerLoopProducerAllowlist.Keys) $outerLoopPhase = $outerLoopFeaturePhases -contains $FeaturePhase # Late-registration honesty fields: one named map drives validation, payload, and projection keys. $lateRegistrationFields = [ordered]@{ external_origin = $ExternalOrigin occurred_around = $OccurredAround context_note = $ContextNote } $hasLateRegistrationFields = @($lateRegistrationFields.Values | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0 if (-not [string]::IsNullOrWhiteSpace($OccurredAround) -and $OccurredAround.Trim() -notmatch '^\d{4}(-\d{2}(-\d{2})?)?$') { throw '-OccurredAround must be approximate timing as a year (YYYY), month (YYYY-MM), or date (YYYY-MM-DD).' } if ($LateRegistration) { if ($FeaturePhase -eq 'spec-review') { throw '-LateRegistration does not apply to spec-review; verdict-gated completion is recorded through harness-record-review.ps1.' } if (-not $outerLoopPhase) { throw "-LateRegistration currently applies to outer-loop feature phases: $($outerLoopFeaturePhases -join ', ')." } if ($FeaturePhaseStatus -ne 'complete') { throw 'Late registration introduces completed work; use -FeaturePhaseStatus complete.' } foreach ($lateField in $lateRegistrationFields.Values) { if ([string]::IsNullOrWhiteSpace($lateField)) { throw 'Late registration requires -ExternalOrigin, -OccurredAround, and -ContextNote.' } } if ($artifactList.Count -eq 0) { throw 'Late registration requires at least one canonical artifact (-ArtifactsCsv or -Artifacts).' } } elseif ($hasLateRegistrationFields) { throw '-ExternalOrigin, -OccurredAround, and -ContextNote require -LateRegistration.' } if ($outerLoopPhase -and -not $LateRegistration -and $FeaturePhaseStatus -eq 'complete' -and $artifactList.Count -eq 0) { throw "Feature phase '$FeaturePhase' complete requires at least one canonical artifact (-ArtifactsCsv or -Artifacts)." } if ([string]::IsNullOrWhiteSpace($Slice)) { if ($FeaturePhase -and -not $outerLoopPhase) { throw 'FeaturePhase recording requires -Slice.' } if (-not $FeaturePhase) { throw 'Specify -Slice and -Phase for slice phases, or -FeaturePhase for quality-gates / slice-review / advisory outer-loop phases (problem-analysis, specification, spec-review attempts).' } } $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) { # Completion of these feature phases belongs to another recorder. One map keeps the # refusal and the recorder name together. $featurePhaseCompletionRecorder = @{ 'slice-review' = '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>.' 'spec-review' = 'spec-review completion is verdict-gated: use harness-record-review.ps1 -FeaturePhase spec-review -Reviewer spec-writer-reviewer with the reviewer verdict artifact (producer-authored completion is not accepted).' } if ($FeaturePhaseStatus -eq 'complete' -and $featurePhaseCompletionRecorder.ContainsKey($FeaturePhase)) { throw $featurePhaseCompletionRecorder[$FeaturePhase] } # Advisory outer-loop phases do not open or require an inner slice ownership turn. if (-not $outerLoopPhase) { Assert-HarnessSliceTurnOpenForRecord -FeatureRoot $featureRootPath -Slice $Slice -Feature $Feature -FeaturePhase $FeaturePhase } # Late registration is the sanctioned honesty enrichment; it must land even when a # plain complete record for the phase already exists (the suppressed no-op would # silently drop the external-origin, timing, and context fields). $duplicateSuppressed = (Test-HarnessDuplicatePhaseStatus -FeatureRoot $featureRootPath -SliceId $Slice -FeaturePhase $FeaturePhase -Status $FeaturePhaseStatus) -and -not $LateRegistration if ($duplicateSuppressed) { 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') { # Quality-gate completion must carry a real host invocation id, or an explicit # artifact attestation from a host that cannot supply one (PROV-01). The silent # artifact-attested default does not apply here: a gate row with no invocation # identity and no attested artifact is exactly the unattributed shape this slice # exists to refuse. if ([string]::IsNullOrWhiteSpace($SourceInvocationId) -and $SourceProvenance -ne 'artifact-attested') { throw 'quality-gates complete requires -SourceInvocationId, or explicit -SourceProvenance artifact-attested for a Copilot or Claude Code subagent.' } if ($effectiveSourceProvenance -eq 'artifact-attested' -and ($ModelProvider -notin @('copilot', 'claude') -or -not [string]::IsNullOrWhiteSpace($SourceInvocationId))) { throw 'SourceProvenance artifact-attested is only valid for Copilot or Claude Code 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 = $effectiveSourceProvenance source_invocation_id = $(if ($SourceInvocationId) { $SourceInvocationId } else { $null }) model_source = $resolvedModelSource } if ($LateRegistration) { $fpPayload['late_registration'] = $true foreach ($lateEntry in $lateRegistrationFields.GetEnumerator()) { $fpPayload[$lateEntry.Key] = $lateEntry.Value.Trim() } } if (-not [string]::IsNullOrWhiteSpace($LinkedArtifact)) { $fpPayload['linked_artifact'] = $LinkedArtifact.Trim() } $fpSummary = if ($LateRegistration) { "feature phase $FeaturePhase registered late status=$FeaturePhaseStatus" } else { "feature phase $FeaturePhase status=$FeaturePhaseStatus" } Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $(if ($Slice) { $Slice } else { $null }) ` -Kind 'PhaseStatusChanged' -Activity $activity -Actor $actor -Outcome $null ` -Summary $fpSummary ` -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 / advisory outer-loop phases (problem-analysis, specification, spec-review attempts).' } 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 $testFileSnapshots = $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 $testFileSnapshots = $copied.test_file_snapshots $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 if ($null -ne $testFileHashes) { $testFileSnapshots = Save-HarnessSliceTestFileSnapshots -RepoRoot $RepoRoot -FeatureRoot $featureRootPath -Slice $Slice -Phase $Phase -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 test_file_snapshots = $testFileSnapshots } 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 } $phasePayload = Add-HarnessHostCorrelationRegistration -Payload $phasePayload -Correlation $script:HarnessChildCorrelation 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)" |