Payload/scripts/kaden/utils/journal-repair.ps1

<#
.SYNOPSIS
    Append a journal correction or a new journal event, then rebuild the gate projection.

.DESCRIPTION
    Operator recovery for a journal the agent must not hand-edit. Existing lines stay
    as they are. adjust appends ActivityAdjusted against an event id. append adds one
    new allowed kind. Both rebuild .review-loop.json from the journal.

    ActivityAdjusted is ledger-only. It does not clear a gate. This script also refuses
    HumanGateDecided (use scripts\kaden\utils\record-slice-approved.ps1), GateValidationPassed
    (that status comes from validate-review-loop), and turn boundaries
    (ActivityStarted, ActivityStopped, and activity names start or stop).

    Nothing is written unless -Apply is present. -Preview prints the same plan and
    also writes nothing. The feature folder and slice must already exist. This script
    refuses to invent a slice.

.PARAMETER Feature
    Feature slug under docs/harness/features.

.PARAMETER Slice
    Existing slice id on that feature's .review-loop.json.

.PARAMETER Action
    adjust or append.

.PARAMETER Kind
    Journal kind for append. Ignored for adjust, which always writes ActivityAdjusted.

.PARAMETER AmendsEventId
    Event id the adjusting entry corrects. Required for adjust.

.PARAMETER Summary
    Journal summary line.

.PARAMETER Correction
    Text stored on the adjusting entry payload. Used by adjust.

.PARAMETER Outcome
    Optional outcome for append. Empty leaves the outcome null.

.PARAMETER PayloadPath
    JSON object file merged as the append payload.

.PARAMETER Preview
    Validate and print the plan. Write nothing. This is also the default when -Apply is omitted.

.PARAMETER Apply
    Append the journal line and rebuild the projection. Required for any write.

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\journal-repair.ps1 -Action adjust -Feature <slug> -Slice <id> -AmendsEventId <event_id> -Summary "Correction: note the real sensor path" -Correction "Operator attached the original path." -Apply

.EXAMPLE
    pwsh -NoProfile -File .\scripts\kaden\utils\journal-repair.ps1 -Action append -Feature <slug> -Slice <id> -Kind BlockerDeclared -Summary "Operator marked the slice blocked"

.EXAMPLE
    Get-Help .\scripts\kaden\utils\journal-repair.ps1 -Parameter Action
#>


#Requires -Version 5.1
[CmdletBinding(DefaultParameterSetName = "Run")]
param(
    [Parameter(ParameterSetName = "Run")]
    [string]$Feature = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$Slice = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$Action = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$Kind = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$AmendsEventId = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$Summary = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$Correction = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$Outcome = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$PayloadPath = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$ActivityType = "other",

    [Parameter(ParameterSetName = "Run")]
    [string]$ActivityName = "operator-repair",

    [Parameter(ParameterSetName = "Run")]
    [string]$RepoRoot = "",

    [Parameter(ParameterSetName = "Run")]
    [string]$FeatureRoot = "",

    [Parameter(ParameterSetName = "Run")]
    [switch]$AllowArbitraryFeatureRoot,

    [Parameter(ParameterSetName = "Run")]
    [string]$Agent = "operator",

    [Parameter(ParameterSetName = "Run")]
    [string]$Tool = "journal-repair",

    [Parameter(ParameterSetName = "Run")]
    [string]$ModelProvider = "other",

    [Parameter(ParameterSetName = "Run")]
    [string]$ModelSlug = "unknown",

    [Parameter(ParameterSetName = "Run")]
    [string]$ModelRole = "primary",

    [Parameter(ParameterSetName = "Run")]
    [switch]$Preview,

    [Parameter(ParameterSetName = "Run")]
    [switch]$Apply,

    [Parameter(ParameterSetName = "Help")]
    [Alias("h", "help")]
    [switch]$ShowHelp
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

function Get-JournalRepairKindCatalog {
    # Keep this list aligned with Add-HarnessJournalEvent, minus kinds this script refuses.
    return @(
        "PhaseStatusChanged", "SensorRunRecorded", "GateCheckRun",
        "GateRelevantValidationRun",
        "ReviewerCycleRecorded", "HandoffRecorded",
        "OwnershipChanged", "BlockerDeclared", "EmissionGapMarked",
        "TaskStarted", "TaskHeartbeat", "TaskTimedOut", "TaskRecovered", "TaskAbandoned",
        "ArtifactRegistered", "StoryIdsAssigned", "AdvisoryContinuityRecorded"
    )
}

function Get-JournalRepairActivityTypeCatalog {
    return @(
        "pipeline.phase", "slice.phase", "review.cycle", "sensor", "validation",
        "gate", "handoff", "blocker", "artifact", "other"
    )
}

function Resolve-JournalRepairRepoRoot {
    param([string]$RequestedRoot)

    if (-not [string]::IsNullOrWhiteSpace($RequestedRoot)) {
        $resolved = (Resolve-Path -LiteralPath $RequestedRoot).Path
        $marker = Join-Path $resolved "scripts\kaden\harness-invoke.ps1"
        if (-not (Test-Path -LiteralPath $marker)) {
            throw "RepoRoot is not a Kaden checkout (missing scripts\kaden\harness-invoke.ps1): $resolved"
        }
        return $resolved
    }

    $current = $PSScriptRoot
    while ($true) {
        $marker = Join-Path $current "scripts\kaden\harness-invoke.ps1"
        if (Test-Path -LiteralPath $marker) {
            return $current
        }
        $parent = Split-Path $current -Parent
        if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) {
            throw "Could not locate repository root (scripts\kaden\harness-invoke.ps1) from this script."
        }
        $current = $parent
    }
}

function Get-JournalRepairProjectionSlice {
    param(
        [Parameter(Mandatory = $true)]$Loop,
        [Parameter(Mandatory = $true)][string]$SliceId
    )

    foreach ($phase in @($Loop.phases)) {
        if ($null -eq $phase) { continue }
        $slices = $phase.PSObject.Properties["slices"]
        if ($null -eq $slices) { continue }
        foreach ($candidate in @($slices.Value)) {
            if ($null -eq $candidate) { continue }
            $idProp = $candidate.PSObject.Properties["id"]
            if ($null -ne $idProp -and [string]$idProp.Value -eq $SliceId) {
                return $candidate
            }
        }
    }
    return $null
}

function Resolve-JournalRepairPayloadPath {
    param(
        [Parameter(Mandatory = $true)][string]$InputPath,
        [Parameter(Mandatory = $true)][string]$Root
    )

    if (Test-Path -LiteralPath $InputPath) {
        return (Resolve-Path -LiteralPath $InputPath).Path
    }
    if (-not [System.IO.Path]::IsPathRooted($InputPath)) {
        $candidate = Join-Path $Root $InputPath
        if (Test-Path -LiteralPath $candidate) {
            return (Resolve-Path -LiteralPath $candidate).Path
        }
    }
    return $null
}

$cliKitManifest = Join-Path $PSScriptRoot "PsCliUiKit\PsCliUiKit.psd1"
if (-not (Test-Path -LiteralPath $cliKitManifest)) {
    throw "PsCliUiKit manifest not found: $cliKitManifest"
}
Import-Module -Name $cliKitManifest -Force

$exitCode = 0
try {
    if ($ShowHelp) {
        (Get-Help -Name $PSCommandPath -Full | Out-String -Width 100) | Out-Host
    } else {
        $allowedActions = @("adjust", "append")
        if ($allowedActions -notcontains $Action) {
            throw "Action must be adjust or append. Get-Help `"$PSCommandPath`" -Parameter Action"
        }

        if ([string]::IsNullOrWhiteSpace($Feature)) {
            throw "Feature is required. Get-Help `"$PSCommandPath`" -Parameter Feature"
        }
        if ($Feature -notmatch "^[A-Za-z0-9][A-Za-z0-9_-]*$") {
            throw "Feature must be a slug of letters, digits, underscore, or hyphen. Get-Help `"$PSCommandPath`" -Parameter Feature"
        }
        if ([string]::IsNullOrWhiteSpace($Slice)) {
            throw "Slice is required. This script will not invent one. Get-Help `"$PSCommandPath`" -Parameter Slice"
        }
        if ($Slice -notmatch "^[A-Za-z0-9][A-Za-z0-9_-]*$") {
            throw "Slice must be a slug of letters, digits, underscore, or hyphen. Get-Help `"$PSCommandPath`" -Parameter Slice"
        }
        if ([string]::IsNullOrWhiteSpace($Summary)) {
            throw "Summary is required. Get-Help `"$PSCommandPath`" -Parameter Summary"
        }

        $activityTypes = @(Get-JournalRepairActivityTypeCatalog)
        if ($activityTypes -notcontains $ActivityType) {
            throw "Unknown activity type '$ActivityType'. This script does not open or close a turn. Get-Help `"$PSCommandPath`" -Parameter ActivityType"
        }
        if ($ActivityName -in @("start", "stop")) {
            throw "start and stop are turn boundaries. Use harness-turn-start.ps1 or harness-turn-stop.ps1. Get-Help `"$PSCommandPath`" -Parameter ActivityName"
        }

        if ($Action -eq "adjust" -and [string]::IsNullOrWhiteSpace($AmendsEventId)) {
            throw "adjust needs the event id being corrected. Get-Help `"$PSCommandPath`" -Parameter AmendsEventId"
        }

        $kindCatalog = @(Get-JournalRepairKindCatalog)
        if ($Action -eq "append") {
            if ([string]::IsNullOrWhiteSpace($Kind)) {
                throw "append needs a journal kind. Get-Help `"$PSCommandPath`" -Parameter Kind"
            } elseif ($Kind -eq "HumanGateDecided") {
                throw "Use scripts\kaden\utils\record-slice-approved.ps1 for an operator approval. Get-Help `"$PSCommandPath`" -Parameter Kind"
            } elseif ($Kind -eq "GateValidationPassed") {
                throw "review_ready comes from validate-review-loop. This script will not write that kind. Get-Help `"$PSCommandPath`" -Parameter Kind"
            } elseif ($Kind -eq "ActivityAdjusted") {
                throw "ActivityAdjusted belongs on -Action adjust. Get-Help `"$PSCommandPath`" -Parameter Action"
            } elseif ($Kind -eq "ActivityStarted" -or $Kind -eq "ActivityStopped") {
                throw "ActivityStarted and ActivityStopped are turn boundaries. Use harness-turn-start.ps1 or harness-turn-stop.ps1. Get-Help `"$PSCommandPath`" -Parameter Kind"
            } elseif ($kindCatalog -notcontains $Kind) {
                throw "Unknown journal kind '$Kind'. Get-Help `"$PSCommandPath`" -Parameter Kind"
            }
        }

        $allowedOutcomes = @("PASS", "FAIL", "BLOCKED", "SKIPPED", "PASS_WITH_FINDINGS", "DENY")
        if (-not [string]::IsNullOrWhiteSpace($Outcome) -and $allowedOutcomes -notcontains $Outcome) {
            throw "Unknown outcome '$Outcome'. Get-Help `"$PSCommandPath`" -Parameter Outcome"
        }

        $root = Resolve-JournalRepairRepoRoot -RequestedRoot $RepoRoot
        Import-Module (Join-Path $root "scripts\kaden\harness\lib\HarnessContract.psm1") -Force
        Import-Module (Join-Path $root "scripts\kaden\harness\lib\HarnessJournal.psm1") -Force
        Import-Module (Join-Path $root "scripts\kaden\harness\lib\HarnessGateProjection.psm1") -Force

        $featureRootPath = Get-HarnessFeatureRoot -RepoRoot $root -Feature $Feature -FeatureRootOverride $FeatureRoot -AllowArbitraryFeatureRoot:$AllowArbitraryFeatureRoot
        $loopPath = Join-Path $featureRootPath ".review-loop.json"
        if (-not (Test-Path -LiteralPath $loopPath)) {
            throw "Projection not found: $loopPath"
        }

        $loop = Get-Content -LiteralPath $loopPath -Raw -Encoding utf8 | ConvertFrom-Json
        $projectionSlice = Get-JournalRepairProjectionSlice -Loop $loop -SliceId $Slice
        if ($null -eq $projectionSlice) {
            throw "Slice '$Slice' is not on the projection for feature '$Feature'. Refusing to invent it. Get-Help `"$PSCommandPath`" -Parameter Slice"
        }

        $payload = $null
        if (-not [string]::IsNullOrWhiteSpace($PayloadPath)) {
            $payloadFull = Resolve-JournalRepairPayloadPath -InputPath $PayloadPath -Root $root
            if ($null -eq $payloadFull) {
                throw "Payload file not found: $PayloadPath. Get-Help `"$PSCommandPath`" -Parameter PayloadPath"
            }
            $payload = Get-Content -LiteralPath $payloadFull -Raw -Encoding utf8 | ConvertFrom-Json
            if ($payload -isnot [System.Management.Automation.PSCustomObject]) {
                throw "Payload JSON must be an object. Get-Help `"$PSCommandPath`" -Parameter PayloadPath"
            }
        }

        if ($Action -eq "adjust") {
            $events = @(Get-HarnessJournalEvents -FeatureRoot $featureRootPath)
            $amended = $null
            foreach ($existing in $events) {
                if ([string]$existing.event_id -eq $AmendsEventId) {
                    $amended = $existing
                    break
                }
            }
            if ($null -eq $amended) {
                throw "That event id is not in this feature journal. Get-Help `"$PSCommandPath`" -Parameter AmendsEventId"
            }
        }

        $actor = New-HarnessJournalActor -Agent $Agent -Tool $Tool -Provider $ModelProvider -Slug $ModelSlug -Role $ModelRole
        $role = if ($Action -eq "adjust") { "adjust" } else { "instant" }
        $activity = New-HarnessJournalActivity -Id (New-HarnessJournalActivityId) -Type $ActivityType -Name $ActivityName -Role $role
        $outcomeValue = if ([string]::IsNullOrWhiteSpace($Outcome)) { $null } else { $Outcome }

        Write-CliSection -Title "Journal repair" -Style "info"
        Write-CliKeyValue -Label "action" -Value $Action -LabelWidth 12
        Write-CliKeyValue -Label "feature" -Value $Feature -LabelWidth 12
        Write-CliKeyValue -Label "slice" -Value $Slice -LabelWidth 12
        Write-CliKeyValue -Label "summary" -Value $Summary -LabelWidth 12
        if ($Action -eq "adjust") {
            Write-CliKeyValue -Label "amends" -Value $AmendsEventId -LabelWidth 12
        } else {
            Write-CliKeyValue -Label "kind" -Value $Kind -LabelWidth 12
        }

        if ($Preview -or -not $Apply) {
            $previewMessage = if ($Preview) {
                "Preview only. Nothing was written."
            } else {
                "Nothing was written. Pass -Apply to append."
            }
            Write-CliStatus -State "Info" -Message $previewMessage
        } else {
            if ($Action -eq "adjust") {
                $correctionValue = if ([string]::IsNullOrWhiteSpace($Correction)) { $null } else { $Correction }
                $evt = Add-HarnessJournalAdjustingEntry -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
                    -AmendsEventId $AmendsEventId -Summary $Summary -Activity $activity -Actor $actor -Correction $correctionValue
            } else {
                $evt = Add-HarnessJournalEvent -FeatureRoot $featureRootPath -Feature $Feature -Slice $Slice `
                    -Kind $Kind -Activity $activity -Actor $actor -Outcome $outcomeValue -Summary $Summary -Payload $payload
            }
            Update-HarnessGateProjectionFromJournal -FeatureRoot $featureRootPath -Feature $Feature -RepoRoot $root -Write | Out-Null
            Write-CliSection -Title "Appended" -Style "success"
            Write-CliKeyValue -Label "event" -Value "[info]$($evt.event_id)[-]" -LabelWidth 12
            Write-CliKeyValue -Label "seq" -Value ([string]$evt.seq) -LabelWidth 12
            if ($Action -eq "adjust") {
                Write-CliColor -Message " [muted]ActivityAdjusted is ledger-only. It does not clear a gate.[-]"
            }
            Write-Output $evt.event_id
        }
    }
} catch {
    Write-CliSection -Title "Journal repair failed" -Style "error"
    Write-CliStatus -State "Error" -Message $_.Exception.Message
    $exitCode = 2
} finally {
    Write-CliEnd
}

if ($exitCode -ne 0) {
    exit $exitCode
}