Payload/scripts/kaden/advisory/AdvisoryReportContract.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Pure rules for a non-gating second-opinion advisory report.

.DESCRIPTION
    Allocates an immutable report identity, files the report outside gate
    folders, records the lenses for the selected target, returns accepted
    work through its existing owner, keeps every same-target assessment,
    selects the latest report, shows unresolved resume context after
    canonical routing, and appends decision amendments without
    rewriting the original finding. It also compares the canonical prompt
    and skill with the generated Cursor and Claude entry points, and it
    does not claim consistency while a boundary, report, route,
    distinction, or skill-contract mismatch remains.
#>


$script:AdvisoryNonGatingStatement = 'This report is non-gating guidance. It cannot satisfy review_ready and cannot replace a required review or sensor.'
$script:AdvisoryReadinessValues = @('Proceed', 'Hold For Decision', 'Revise Then Reassess')
$script:AdvisoryDecisions = @('Fix', 'Accept Risk', 'Defer', 'Promote To Backlog', 'More Info')
$script:AdvisoryDestinations = @('specification', 'domain-design', 'implementation', 'investigation', 'deferred-work', 'none')
$script:AdvisoryStatuses = @('Open', 'Routed', 'Resolved', 'Accepted')
$script:AdvisoryDecisionsRequiringTrigger = @('Defer', 'Promote To Backlog', 'More Info')
$script:AdvisoryMaterialClasses = @('user', 'delivery', 'safety', 'governance', 'decision')
$script:AdvisorySpecificationLenses = @(
    'problem fit',
    'intent ambiguity',
    'observable behavior',
    'measurable criteria',
    'business and domain boundaries',
    'dependencies',
    'intentional latitude'
)
$script:AdvisoryFeatureLenses = @(
    'fit for purpose',
    'continuity with stated direction',
    'delivered behavior',
    'unintended consequences',
    'decision ownership',
    'evidence and residual completeness',
    'readiness risk'
)
$script:AdvisoryMilestoneLenses = @(
    'exit criteria',
    'validity of evidence',
    'prerequisites',
    'deferred gaps',
    'safety of the proposed next step'
)
$script:AdvisorySpecificationReentry = 'Updated specification followed by a normal spec-writer-reviewer verdict'
$script:AdvisoryDomainReentry = 'Domain-design artifact followed by a normal ddd-architect-reviewer verdict'
$script:AdvisoryImplementationReentry = 'The planned slice owner continues the normal implementation review'
$script:AdvisorySlugBody = '[a-z0-9]+(-[a-z0-9]+)*'
$script:AdvisoryUuidBody = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
$script:AdvisoryTargetBody = 'spec|feature|milestone'
$script:AdvisorySlugPattern = '^' + $script:AdvisorySlugBody + '$'
$script:AdvisoryUuidPattern = '^' + $script:AdvisoryUuidBody + '$'
$script:AdvisoryFilingPathPattern = '^docs/harness/features/' + $script:AdvisorySlugBody + '/advisory/second-opinion-(' + $script:AdvisoryTargetBody + ')-iter([1-9]\d*)\.md$'
$script:AdvisoryIssuedAtPattern = '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$'
$script:AdvisoryMissingEvidenceLabel = 'Not Scored - Missing Evidence'
$script:AdvisoryContinuityActions = @('issue', 'decision', 'amendment', 'route')
$script:AdvisoryTurnStates = @('open', 'stopped', 'stale', 'abandoned', 'absent')

function Get-AdvisoryProperty {
    param(
        $Record,
        [Parameter(Mandatory = $true)][string]$Name
    )
    if ($null -eq $Record) {
        return $null
    }
    $property = $Record.PSObject.Properties[$Name]
    if ($null -eq $property) {
        return $null
    }
    return $property.Value
}

function Test-AdvisoryBlank {
    param($Value)
    return [string]::IsNullOrWhiteSpace([string]$Value)
}

function Get-AdvisoryIssuedAtNow {
    return ([datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss', [cultureinfo]::InvariantCulture) + 'Z')
}

function Test-AdvisoryIssuedAtText {
    param([string]$IssuedAt)
    return ([string]$IssuedAt) -match $script:AdvisoryIssuedAtPattern
}

function Get-AdvisoryReportClassification {
    <#
    .SYNOPSIS
        Classify one parsed advisory report against the contract identity rules.
    #>

    param(
        [Parameter(Mandatory = $true)]$Record,
        [Parameter(Mandatory = $true)][string]$Feature,
        [Parameter(Mandatory = $true)][string]$FileTarget,
        [Parameter(Mandatory = $true)][int]$FileIteration,
        [hashtable]$SeenKeys = @{}
    )
    $reportId = [string](Get-AdvisoryProperty -Record $Record -Name 'report_id')
    $issuedAt = [string](Get-AdvisoryProperty -Record $Record -Name 'issued_at')
    $bodyFeature = [string](Get-AdvisoryProperty -Record $Record -Name 'feature')
    $bodyTarget = [string](Get-AdvisoryProperty -Record $Record -Name 'target')
    $bodyIteration = 0
    $rawIteration = Get-AdvisoryProperty -Record $Record -Name 'iteration'
    if ($null -ne $rawIteration -and -not (Test-AdvisoryBlank $rawIteration)) {
        $bodyIteration = [int]$rawIteration
    }
    if ((Test-AdvisoryBlank $reportId) -or (Test-AdvisoryBlank $issuedAt) -or (Test-AdvisoryBlank $bodyFeature) -or ($bodyIteration -lt 1)) {
        return [pscustomobject]@{ disposition = 'diagnostic'; code = 'partial'; problem = 'advisory report is partial'; key = $null }
    }
    $uuidOk = $reportId -match $script:AdvisoryUuidPattern
    $issuedOk = Test-AdvisoryIssuedAtText -IssuedAt $issuedAt
    if ((-not $uuidOk) -or (-not $issuedOk) -or ($bodyFeature -ne $Feature) -or ($bodyTarget -ne $FileTarget)) {
        return [pscustomobject]@{ disposition = 'diagnostic'; code = 'invalid-identity'; problem = 'advisory report identity is invalid'; key = $null }
    }
    if ([string](Get-AdvisoryProperty -Record $Record -Name 'artifact_status') -eq 'historical-legacy') {
        return [pscustomobject]@{ disposition = 'exclude'; code = 'excluded'; problem = ''; key = $null }
    }
    $discovery = Get-AdvisoryProperty -Record $Record -Name 'discovery_candidate'
    if (($null -ne $discovery) -and ($discovery -eq $false)) {
        return [pscustomobject]@{ disposition = 'exclude'; code = 'excluded'; problem = ''; key = $null }
    }
    $key = '{0}|{1}|{2}' -f $bodyFeature, $bodyTarget, $bodyIteration
    if ($SeenKeys.ContainsKey($key)) {
        return [pscustomobject]@{ disposition = 'diagnostic'; code = 'duplicate-iteration'; problem = 'duplicate iteration for the same feature and target'; key = $key }
    }
    if ($bodyIteration -ne $FileIteration) {
        return [pscustomobject]@{ disposition = 'diagnostic'; code = 'invalid-identity'; problem = 'advisory report identity is invalid'; key = $null }
    }
    return [pscustomobject]@{ disposition = 'valid'; code = 'valid'; problem = ''; key = $key }
}

function Get-AdvisoryFieldCopy {
    param($Source)
    $copy = [ordered]@{}
    foreach ($property in @($Source.PSObject.Properties)) {
        $copy[$property.Name] = $property.Value
    }
    return [pscustomobject]$copy
}

function Copy-AdvisoryRecordList {
    param($Records)
    $list = [System.Collections.Generic.List[object]]::new()
    foreach ($record in @($Records)) {
        if ($null -eq $record) {
            continue
        }
        $list.Add((Get-AdvisoryFieldCopy -Source $record))
    }
    return @($list.ToArray())
}

function Copy-AdvisoryAssessmentRecord {
    param(
        [Parameter(Mandatory = $true)]$Assessment,
        $Findings,
        $Decisions,
        $Amendments,
        $Routes
    )
    $findingCopy = if ($PSBoundParameters.ContainsKey('Findings')) {
        @($Findings)
    } else {
        Copy-AdvisoryRecordList -Records (Get-AdvisoryProperty -Record $Assessment -Name 'findings')
    }
    $decisionCopy = if ($PSBoundParameters.ContainsKey('Decisions')) {
        @($Decisions)
    } else {
        Copy-AdvisoryRecordList -Records (Get-AdvisoryProperty -Record $Assessment -Name 'decisions')
    }
    $amendmentCopy = if ($PSBoundParameters.ContainsKey('Amendments')) {
        @($Amendments)
    } else {
        Copy-AdvisoryRecordList -Records (Get-AdvisoryProperty -Record $Assessment -Name 'amendments')
    }
    $appliedCopy = $null
    if ($null -ne $Assessment.PSObject.Properties['applied_actions']) {
        $appliedCopy = @(Copy-AdvisoryRecordList -Records (Get-AdvisoryProperty -Record $Assessment -Name 'applied_actions'))
    }
    $routeCopy = $null
    if ($PSBoundParameters.ContainsKey('Routes')) {
        $routeCopy = @($Routes)
    } elseif ($null -ne $Assessment.PSObject.Properties['routes']) {
        $routeCopy = @(Copy-AdvisoryRecordList -Records (Get-AdvisoryProperty -Record $Assessment -Name 'routes'))
    }
    $iterationRaw = Get-AdvisoryProperty -Record $Assessment -Name 'iteration'
    $iteration = 0
    if ($null -ne $iterationRaw -and -not (Test-AdvisoryBlank $iterationRaw)) {
        $iteration = [int]$iterationRaw
    }
    $record = [ordered]@{
        report_id            = [string](Get-AdvisoryProperty -Record $Assessment -Name 'report_id')
        issued_at            = [string](Get-AdvisoryProperty -Record $Assessment -Name 'issued_at')
        feature              = [string](Get-AdvisoryProperty -Record $Assessment -Name 'feature')
        target               = [string](Get-AdvisoryProperty -Record $Assessment -Name 'target')
        iteration            = $iteration
        target_reference     = [string](Get-AdvisoryProperty -Record $Assessment -Name 'target_reference')
        review_question      = [string](Get-AdvisoryProperty -Record $Assessment -Name 'review_question')
        evidence_boundary    = [string](Get-AdvisoryProperty -Record $Assessment -Name 'evidence_boundary')
        intentional_latitude = [string](Get-AdvisoryProperty -Record $Assessment -Name 'intentional_latitude')
        reviewer_provenance  = [string](Get-AdvisoryProperty -Record $Assessment -Name 'reviewer_provenance')
        advisory_readiness   = [string](Get-AdvisoryProperty -Record $Assessment -Name 'advisory_readiness')
        readiness_reason     = [string](Get-AdvisoryProperty -Record $Assessment -Name 'readiness_reason')
        evidence_examined    = [string](Get-AdvisoryProperty -Record $Assessment -Name 'evidence_examined')
        review_lenses        = @(Get-AdvisoryProperty -Record $Assessment -Name 'review_lenses')
        marks_slice_ready    = [bool](Get-AdvisoryProperty -Record $Assessment -Name 'marks_slice_ready')
        non_gating_statement = [string](Get-AdvisoryProperty -Record $Assessment -Name 'non_gating_statement')
        relative_path        = [string](Get-AdvisoryProperty -Record $Assessment -Name 'relative_path')
        findings             = @($findingCopy)
        decisions            = @($decisionCopy)
        amendments           = @($amendmentCopy)
    }
    if ($null -ne $appliedCopy) {
        $record.applied_actions = @($appliedCopy)
    }
    if ($null -ne $routeCopy) {
        $record.routes = @($routeCopy)
    }
    return [pscustomobject]$record
}

function Get-AdvisoryUtcDateText {
    param([Parameter(Mandatory = $true)][string]$IssuedAt)
    if (-not (Test-AdvisoryIssuedAtText -IssuedAt $IssuedAt)) {
        throw 'issued_at must be an RFC 3339 UTC timestamp in the form YYYY-MM-DDTHH:MM:SSZ.'
    }
    return $IssuedAt.Substring(0, 10)
}

function Get-AdvisoryIterationValue {
    param($Assessment)
    $raw = Get-AdvisoryProperty -Record $Assessment -Name 'iteration'
    if ($null -eq $raw -or (Test-AdvisoryBlank $raw)) {
        return 0
    }
    return [int]$raw
}

function Get-AdvisoryNextIteration {
    <#
    .SYNOPSIS
        Return the next unused iteration for one feature and target.
    #>

    [CmdletBinding()]
    param(
        [object[]]$History = @(),
        [Parameter(Mandatory = $true)][string]$FeatureSlug,
        [Parameter(Mandatory = $true)][ValidateSet('spec', 'feature', 'milestone')][string]$Target
    )
    $max = 0
    foreach ($existing in @($History | Where-Object { $null -ne $_ })) {
        $existingFeature = [string](Get-AdvisoryProperty -Record $existing -Name 'feature')
        $existingTarget = [string](Get-AdvisoryProperty -Record $existing -Name 'target')
        if ($existingFeature -ne $FeatureSlug -or $existingTarget -ne $Target) {
            continue
        }
        $iteration = Get-AdvisoryIterationValue -Assessment $existing
        if ($iteration -gt $max) {
            $max = $iteration
        }
    }
    return ($max + 1)
}

function Test-AdvisoryFindingMaterial {
    param($Finding)
    $impact = Get-AdvisoryProperty -Record $Finding -Name 'impact'
    $evidence = Get-AdvisoryProperty -Record $Finding -Name 'evidence'
    $consequence = Get-AdvisoryProperty -Record $Finding -Name 'consequence'
    return -not (Test-AdvisoryBlank $impact) -and -not (Test-AdvisoryBlank $evidence) -and -not (Test-AdvisoryBlank $consequence)
}

function Assert-AdvisoryFinding {
    param($Finding)
    if (-not (Test-AdvisoryFindingMaterial -Finding $Finding)) {
        throw 'A finding without material impact, observed evidence, and consequence is excluded from the report.'
    }
    $consequenceClass = [string](Get-AdvisoryProperty -Record $Finding -Name 'consequence_class')
    if ($script:AdvisoryMaterialClasses -notcontains $consequenceClass) {
        throw 'A consequence class outside user, delivery, safety, governance, and decision is excluded from the report.'
    }
    $inference = Get-AdvisoryProperty -Record $Finding -Name 'inference'
    $evidence = Get-AdvisoryProperty -Record $Finding -Name 'evidence'
    if ((-not (Test-AdvisoryBlank $inference)) -and ([string]$inference -eq [string]$evidence)) {
        throw 'Inference must stay separate from observed evidence.'
    }
    foreach ($name in @('finding_id', 'confidence', 'route_to')) {
        if (Test-AdvisoryBlank (Get-AdvisoryProperty -Record $Finding -Name $name)) {
            throw "A finding requires $name."
        }
    }
    $route = Get-AdvisoryProperty -Record $Finding -Name 'route_to'
    if ($route -is [System.Array]) {
        throw 'A finding has exactly one route_to recommended route.'
    }
    $recommendation = Get-AdvisoryProperty -Record $Finding -Name 'recommendation'
    $options = Get-AdvisoryProperty -Record $Finding -Name 'options'
    if ((Test-AdvisoryBlank $recommendation) -and (Test-AdvisoryBlank $options)) {
        throw 'A finding requires a recommendation or options.'
    }
}

function Assert-AdvisoryDecision {
    param(
        $Decision,
        $Finding
    )
    $decisionName = [string](Get-AdvisoryProperty -Record $Decision -Name 'decision')
    if ($script:AdvisoryDecisions -notcontains $decisionName) {
        throw "Decision '$decisionName' is not an advisory decision."
    }
    if ($null -ne (Get-AdvisoryProperty -Record $Decision -Name 'route_to')) {
        throw 'route_to stays on the finding. The decision register does not repeat it.'
    }
    $destination = [string](Get-AdvisoryProperty -Record $Decision -Name 'selected_work_destination')
    if ($script:AdvisoryDestinations -notcontains $destination) {
        throw "Selected work destination '$destination' is not an advisory destination."
    }
    $status = [string](Get-AdvisoryProperty -Record $Decision -Name 'status')
    if ($script:AdvisoryStatuses -notcontains $status) {
        throw "Decision status '$status' is not an advisory status."
    }
    foreach ($name in @('finding_id', 'decided_by', 'decided_at', 'rationale')) {
        if (Test-AdvisoryBlank (Get-AdvisoryProperty -Record $Decision -Name $name)) {
            throw "A decision requires $name."
        }
    }
    if (-not (Test-AdvisoryIssuedAtText -IssuedAt ([string](Get-AdvisoryProperty -Record $Decision -Name 'decided_at')))) {
        throw 'decided_at must be an RFC 3339 UTC timestamp in the form YYYY-MM-DDTHH:MM:SSZ.'
    }
    $findingId = [string](Get-AdvisoryProperty -Record $Finding -Name 'finding_id')
    if ([string](Get-AdvisoryProperty -Record $Decision -Name 'finding_id') -ne $findingId) {
        throw 'Each decision must match one finding.'
    }
    $nextOwner = Get-AdvisoryProperty -Record $Decision -Name 'next_owner'
    $trigger = Get-AdvisoryProperty -Record $Decision -Name 'trigger_or_needed_information'
    if ($decisionName -eq 'Accept Risk') {
        if ($destination -ne 'none' -or -not (Test-AdvisoryBlank $nextOwner) -or $status -ne 'Accepted') {
            throw 'Accept Risk retains the finding route, sets selected_work_destination to none, has no next owner, and has status Accepted.'
        }
        return
    }
    if ($destination -eq 'none' -or (Test-AdvisoryBlank $nextOwner)) {
        throw 'Fix, Defer, Promote To Backlog, and More Info require a next owner and a selected work destination.'
    }
    if (($script:AdvisoryDecisionsRequiringTrigger -contains $decisionName) -and (Test-AdvisoryBlank $trigger)) {
        throw 'Defer, Promote To Backlog, and More Info require a trigger or named information need.'
    }
}

function Get-AdvisoryLensSet {
    param(
        [Parameter(Mandatory = $true)][string]$Target
    )
    switch ($Target) {
        'spec' { return @($script:AdvisorySpecificationLenses) }
        'feature' { return @($script:AdvisoryFeatureLenses) }
        'milestone' { return @($script:AdvisoryMilestoneLenses) }
        default { throw "Target '$Target' has no advisory review lenses." }
    }
}

function Get-AdvisoryDecisionWithReentry {
    param(
        [Parameter(Mandatory = $true)]$Decision
    )
    $decisionName = [string](Get-AdvisoryProperty -Record $Decision -Name 'decision')
    $destination = [string](Get-AdvisoryProperty -Record $Decision -Name 'selected_work_destination')
    $nextOwner = [string](Get-AdvisoryProperty -Record $Decision -Name 'next_owner')
    $reentry = ''
    if ($decisionName -eq 'Fix' -and $destination -eq 'specification') {
        if ($nextOwner -ne 'spec-writer') {
            throw 'A specification Fix names spec-writer as the next owner.'
        }
        $reentry = $script:AdvisorySpecificationReentry
    } elseif ($decisionName -eq 'Fix' -and $destination -eq 'domain-design') {
        if ($nextOwner -ne 'ddd-architect') {
            throw 'A domain-design Fix names ddd-architect as the next owner.'
        }
        $reentry = $script:AdvisoryDomainReentry
    } elseif ($decisionName -eq 'Fix' -and $destination -eq 'implementation') {
        $reentry = $script:AdvisoryImplementationReentry
    }
    $copy = Get-AdvisoryFieldCopy -Source $Decision
    if (-not (Test-AdvisoryBlank $reentry)) {
        $copy | Add-Member -NotePropertyName 'required_reentry' -NotePropertyValue $reentry -Force
    }
    return $copy
}

function Get-AdvisoryNonGatingStatement {
    <#
    .SYNOPSIS
        Return the required non-gating statement.
    #>

    [CmdletBinding()]
    param()
    return $script:AdvisoryNonGatingStatement
}

function Test-AdvisoryReportNonGating {
    <#
    .SYNOPSIS
        True when a report is guidance and does not claim gate evidence.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Assessment
    )
    $readiness = [string](Get-AdvisoryProperty -Record $Assessment -Name 'advisory_readiness')
    if ($script:AdvisoryReadinessValues -notcontains $readiness) {
        return $false
    }
    $statement = [string](Get-AdvisoryProperty -Record $Assessment -Name 'non_gating_statement')
    if ($statement -ne $script:AdvisoryNonGatingStatement) {
        return $false
    }
    foreach ($forbidden in @('verdict', 'approval', 'sensor_result', 'review_ready')) {
        if ($null -ne (Get-AdvisoryProperty -Record $Assessment -Name $forbidden)) {
            return $false
        }
    }
    return $true
}

function Test-AdvisoryFilingLocationAllowed {
    <#
    .SYNOPSIS
        True when a relative path is an advisory report and not a gate folder.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$RelativePath
    )
    $normalized = ($RelativePath -replace '\\', '/').Trim()
    while ($normalized.StartsWith('./')) {
        $normalized = $normalized.Substring(2)
    }
    if ($normalized -match '(^|/)(\.reviews|quality|sensors|handoffs)(/|$)') {
        return $false
    }
    return $normalized -match $script:AdvisoryFilingPathPattern
}

function Get-AdvisoryReportRelativePath {
    <#
    .SYNOPSIS
        Build the advisory filing path for one date and iteration.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureSlug,
        [Parameter(Mandatory = $true)][ValidateSet('spec', 'feature', 'milestone')][string]$Target,
        [Parameter(Mandatory = $true)][string]$IssuedAt,
        [Parameter(Mandatory = $true)][int]$Iteration
    )
    if ($FeatureSlug -notmatch $script:AdvisorySlugPattern) {
        throw 'Feature slug must be kebab-case.'
    }
    if ($Iteration -lt 1) {
        throw 'iteration must be a positive integer starting at 1.'
    }
    $null = Get-AdvisoryUtcDateText -IssuedAt $IssuedAt
    return "docs/harness/features/$FeatureSlug/advisory/second-opinion-$Target-iter$Iteration.md"
}

function New-AdvisoryAssessment {
    <#
    .SYNOPSIS
        Issue one advisory assessment with a new immutable identity.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns an in-memory assessment. It does not change system state.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureSlug,
        [Parameter(Mandatory = $true)][ValidateSet('spec', 'feature', 'milestone')][string]$Target,
        [Parameter(Mandatory = $true)][string]$TargetReference,
        [Parameter(Mandatory = $true)][string]$ReviewQuestion,
        [Parameter(Mandatory = $true)][string]$EvidenceBoundary,
        [Parameter(Mandatory = $true)][string]$IntentionalLatitude,
        [Parameter(Mandatory = $true)][string]$ReviewerProvenance,
        [Parameter(Mandatory = $true)][ValidateSet('Proceed', 'Hold For Decision', 'Revise Then Reassess')][string]$AdvisoryReadiness,
        [Parameter(Mandatory = $true)][string]$ReadinessReason,
        [string]$IssuedAt = '',
        [object[]]$History = @(),
        [object[]]$Findings = @(),
        [object[]]$Decisions = @()
    )
    foreach ($name in @('TargetReference', 'ReviewQuestion', 'EvidenceBoundary', 'IntentionalLatitude', 'ReviewerProvenance', 'ReadinessReason')) {
        $value = Get-Variable -Name $name -ValueOnly
        if (Test-AdvisoryBlank $value) {
            throw "$name is required on an advisory report."
        }
    }
    if ([string]::IsNullOrWhiteSpace($IssuedAt)) {
        $IssuedAt = Get-AdvisoryIssuedAtNow
    } elseif (-not (Test-AdvisoryIssuedAtText -IssuedAt $IssuedAt)) {
        throw 'issued_at must be an RFC 3339 UTC timestamp in the form YYYY-MM-DDTHH:MM:SSZ.'
    }
    $findingList = @($Findings | Where-Object { $null -ne $_ })
    $decisionList = @($Decisions | Where-Object { $null -ne $_ })
    foreach ($finding in $findingList) {
        Assert-AdvisoryFinding -Finding $finding
    }
    if ($findingList.Count -ne $decisionList.Count) {
        throw 'Every finding has one decision-register entry.'
    }
    $copiedFindings = @()
    $copiedDecisions = @()
    $seenFindingIds = @{}
    for ($index = 0; $index -lt $findingList.Count; $index++) {
        $findingId = [string](Get-AdvisoryProperty -Record $findingList[$index] -Name 'finding_id')
        if ($seenFindingIds.ContainsKey($findingId)) {
            throw "Finding $findingId is already on this advisory report."
        }
        $seenFindingIds[$findingId] = $true
        Assert-AdvisoryDecision -Decision $decisionList[$index] -Finding $findingList[$index]
        $copiedFindings += Get-AdvisoryFieldCopy -Source $findingList[$index]
        $copiedDecisions += Get-AdvisoryDecisionWithReentry -Decision $decisionList[$index]
    }
    $reportId = [guid]::NewGuid().ToString()
    $iteration = Get-AdvisoryNextIteration -History $History -FeatureSlug $FeatureSlug -Target $Target
    return [pscustomobject]@{
        report_id                 = $reportId
        issued_at                 = $IssuedAt
        feature                   = $FeatureSlug
        target                    = $Target
        iteration                 = $iteration
        target_reference          = $TargetReference
        review_question           = $ReviewQuestion
        evidence_boundary         = $EvidenceBoundary
        intentional_latitude      = $IntentionalLatitude
        reviewer_provenance       = $ReviewerProvenance
        advisory_readiness        = $AdvisoryReadiness
        readiness_reason          = $ReadinessReason
        evidence_examined         = $EvidenceBoundary
        review_lenses             = @(Get-AdvisoryLensSet -Target $Target)
        marks_slice_ready         = $false
        non_gating_statement      = $script:AdvisoryNonGatingStatement
        relative_path             = (Get-AdvisoryReportRelativePath -FeatureSlug $FeatureSlug -Target $Target -IssuedAt $IssuedAt -Iteration $iteration)
        findings                  = @($copiedFindings)
        decisions                 = @($copiedDecisions)
        amendments                = @()
    }
}

function Add-AdvisoryAssessmentToHistory {
    <#
    .SYNOPSIS
        Keep a new assessment beside every earlier report for that feature.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a new in-memory history. It does not change system state.')]
    [CmdletBinding()]
    param(
        [object[]]$History = @(),
        [Parameter(Mandatory = $true)]$Assessment
    )
    $reportId = [string](Get-AdvisoryProperty -Record $Assessment -Name 'report_id')
    if ($reportId -notmatch $script:AdvisoryUuidPattern) {
        throw 'report_id must be a UUID and cannot be changed to a non-identity.'
    }
    $iteration = Get-AdvisoryIterationValue -Assessment $Assessment
    $feature = [string](Get-AdvisoryProperty -Record $Assessment -Name 'feature')
    $target = [string](Get-AdvisoryProperty -Record $Assessment -Name 'target')
    $kept = [System.Collections.Generic.List[object]]::new()
    foreach ($existing in @($History | Where-Object { $null -ne $_ })) {
        $existingId = [string](Get-AdvisoryProperty -Record $existing -Name 'report_id')
        if ($existingId -eq $reportId) {
            throw "Advisory report $reportId is already filed and its identity cannot be reused."
        }
        $existingIteration = Get-AdvisoryIterationValue -Assessment $existing
        $existingFeature = [string](Get-AdvisoryProperty -Record $existing -Name 'feature')
        $existingTarget = [string](Get-AdvisoryProperty -Record $existing -Name 'target')
        if ($iteration -gt 0 -and $existingIteration -eq $iteration -and $existingFeature -eq $feature -and $existingTarget -eq $target) {
            throw "Advisory report iteration $iteration is malformed for the same feature and target."
        }
        $kept.Add((Copy-AdvisoryAssessmentRecord -Assessment $existing))
    }
    $kept.Add((Copy-AdvisoryAssessmentRecord -Assessment $Assessment))
    return @($kept.ToArray())
}

function Select-AdvisoryLatestReport {
    <#
    .SYNOPSIS
        Select the latest valid report by issued_at descending, then higher iter.
    #>

    [CmdletBinding()]
    param(
        [object[]]$History = @()
    )
    $byKey = @{}
    $duplicates = @{}
    foreach ($record in @($History | Where-Object { $null -ne $_ })) {
        $reportId = [string](Get-AdvisoryProperty -Record $record -Name 'report_id')
        $issuedAt = [string](Get-AdvisoryProperty -Record $record -Name 'issued_at')
        $iteration = Get-AdvisoryIterationValue -Assessment $record
        $feature = [string](Get-AdvisoryProperty -Record $record -Name 'feature')
        $target = [string](Get-AdvisoryProperty -Record $record -Name 'target')
        if ($reportId -notmatch $script:AdvisoryUuidPattern) {
            continue
        }
        if (-not (Test-AdvisoryIssuedAtText -IssuedAt $issuedAt)) {
            continue
        }
        if ($iteration -lt 1) {
            continue
        }
        $key = '{0}|{1}|{2}' -f $feature, $target, $iteration
        if ($byKey.ContainsKey($key)) {
            $duplicates[$key] = $true
            continue
        }
        $byKey[$key] = $record
    }
    $valid = [System.Collections.Generic.List[object]]::new()
    foreach ($entry in $byKey.GetEnumerator()) {
        if ($duplicates.ContainsKey($entry.Key)) {
            continue
        }
        $valid.Add($entry.Value)
    }
    if ($valid.Count -eq 0) {
        return $null
    }
    $ordered = @($valid | Sort-Object -Property `
        @{ Expression = { [string](Get-AdvisoryProperty -Record $_ -Name 'issued_at') }; Descending = $true }, `
        @{ Expression = { Get-AdvisoryIterationValue -Assessment $_ }; Descending = $true })
    return $ordered[0]
}

function Add-AdvisoryDecisionAmendment {
    <#
    .SYNOPSIS
        Append a decision change and leave the original finding readable.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a new in-memory assessment. It does not change system state.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Assessment,
        [Parameter(Mandatory = $true)][string]$FindingId,
        [Parameter(Mandatory = $true)]$Decision,
        [Parameter(Mandatory = $true)][string]$Reason,
        [Parameter(Mandatory = $true)][string]$Author,
        [Parameter(Mandatory = $true)][string]$AmendedAt
    )
    if (Test-AdvisoryBlank $Reason -or (Test-AdvisoryBlank $Author)) {
        throw 'An amendment requires a reason and an author.'
    }
    if (-not (Test-AdvisoryIssuedAtText -IssuedAt $AmendedAt)) {
        throw 'An amendment timestamp must be an RFC 3339 UTC timestamp in the form YYYY-MM-DDTHH:MM:SSZ.'
    }
    $finding = $null
    foreach ($candidate in @($Assessment.findings)) {
        if ($null -eq $candidate) { continue }
        if ([string](Get-AdvisoryProperty -Record $candidate -Name 'finding_id') -eq $FindingId) {
            $finding = $candidate
            break
        }
    }
    if ($null -eq $finding) {
        throw "Finding $FindingId is not on this advisory report."
    }
    Assert-AdvisoryDecision -Decision $Decision -Finding $finding
    $stampedDecision = Get-AdvisoryDecisionWithReentry -Decision $Decision
    $prior = $null
    $replaced = $false
    $decisions = [System.Collections.Generic.List[object]]::new()
    foreach ($existing in @($Assessment.decisions)) {
        if ($null -eq $existing) { continue }
        if ([string](Get-AdvisoryProperty -Record $existing -Name 'finding_id') -eq $FindingId) {
            if ($replaced) {
                throw "Finding $FindingId has more than one decision."
            }
            $prior = Get-AdvisoryFieldCopy -Source $existing
            $decisions.Add($stampedDecision)
            $replaced = $true
        } else {
            $decisions.Add((Get-AdvisoryFieldCopy -Source $existing))
        }
    }
    if (-not $replaced) {
        throw "Finding $FindingId has no decision to amend."
    }
    $amendments = [System.Collections.Generic.List[object]]::new()
    foreach ($existingAmendment in @($Assessment.amendments)) {
        if ($null -eq $existingAmendment) { continue }
        $amendments.Add((Get-AdvisoryFieldCopy -Source $existingAmendment))
    }
    $amendments.Add([pscustomobject]@{
            finding_id = $FindingId
            prior_state = $prior
            reason      = $Reason
            author      = $Author
            amended_at  = $AmendedAt
        })
    $findings = [System.Collections.Generic.List[object]]::new()
    foreach ($existingFinding in @($Assessment.findings)) {
        if ($null -eq $existingFinding) { continue }
        $findings.Add((Get-AdvisoryFieldCopy -Source $existingFinding))
    }
    return Copy-AdvisoryAssessmentRecord -Assessment $Assessment -Findings @($findings.ToArray()) -Decisions @($decisions.ToArray()) -Amendments @($amendments.ToArray())
}

function Update-AdvisoryAssessmentDecision {
    <#
    .SYNOPSIS
        Replace one finding's decision after the decision assertion.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a new in-memory assessment. It does not change system state.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Assessment,
        [Parameter(Mandatory = $true)]$Decision,
        [Parameter(Mandatory = $true)][string]$FindingId
    )
    $finding = $null
    foreach ($candidate in @($Assessment.findings)) {
        if ($null -eq $candidate) { continue }
        if ([string](Get-AdvisoryProperty -Record $candidate -Name 'finding_id') -eq $FindingId) {
            $finding = $candidate
            break
        }
    }
    if ($null -eq $finding) {
        throw "Finding $FindingId is not on this advisory report."
    }
    Assert-AdvisoryDecision -Decision $Decision -Finding $finding
    $stampedDecision = Get-AdvisoryDecisionWithReentry -Decision $Decision
    $replaced = $false
    $decisions = [System.Collections.Generic.List[object]]::new()
    foreach ($existing in @($Assessment.decisions)) {
        if ($null -eq $existing) { continue }
        if ([string](Get-AdvisoryProperty -Record $existing -Name 'finding_id') -eq $FindingId) {
            if ($replaced) {
                throw "Finding $FindingId has more than one decision."
            }
            $decisions.Add($stampedDecision)
            $replaced = $true
        } else {
            $decisions.Add((Get-AdvisoryFieldCopy -Source $existing))
        }
    }
    if (-not $replaced) {
        throw "Finding $FindingId has no decision on this advisory report."
    }
    return Copy-AdvisoryAssessmentRecord -Assessment $Assessment -Decisions @($decisions.ToArray())
}

function Set-AdvisoryAssessmentDecisionRouted {
    <#
    .SYNOPSIS
        Mark one decision Routed and append the route on a copied assessment.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a new in-memory assessment. It does not change system state.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Assessment,
        [Parameter(Mandatory = $true)][string]$FindingId,
        [Parameter(Mandatory = $true)]$Route
    )
    $destination = [string](Get-AdvisoryProperty -Record $Route -Name 'selected_work_destination')
    if ($script:AdvisoryDestinations -notcontains $destination) {
        throw "Selected work destination '$destination' is not an advisory destination."
    }
    $replaced = $false
    $decisions = [System.Collections.Generic.List[object]]::new()
    foreach ($existing in @($Assessment.decisions)) {
        if ($null -eq $existing) { continue }
        $copy = Get-AdvisoryFieldCopy -Source $existing
        if ([string](Get-AdvisoryProperty -Record $existing -Name 'finding_id') -eq $FindingId) {
            if ($replaced) {
                throw "Finding $FindingId has more than one decision."
            }
            $copy.status = 'Routed'
            $replaced = $true
        }
        $decisions.Add($copy)
    }
    if (-not $replaced) {
        throw "Finding $FindingId has no decision on this advisory report."
    }
    $routes = [System.Collections.Generic.List[object]]::new()
    foreach ($existingRoute in @((Get-AdvisoryProperty -Record $Assessment -Name 'routes'))) {
        if ($null -eq $existingRoute) { continue }
        $routes.Add((Get-AdvisoryFieldCopy -Source $existingRoute))
    }
    $routes.Add($Route)
    return Copy-AdvisoryAssessmentRecord -Assessment $Assessment -Decisions @($decisions.ToArray()) -Routes @($routes.ToArray())
}

function Get-AdvisoryResumePresentation {
    <#
    .SYNOPSIS
        Show unresolved advisory context after canonical resume routing is already chosen.
    .DESCRIPTION
        Completes paired-review lookup before advisory discovery. Advisory files are
        not paired-review candidates. The latest valid report contributes only Open
        or Routed decisions. The mandatory next action is returned unchanged. A
        missing or invalid advisory report does not block resume.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$MandatoryNextAction,
        [object[]]$PairedReviewCandidates = @(),
        [object[]]$AdvisoryReports = @()
    )
    $discoverySteps = [System.Collections.Generic.List[string]]::new()
    $pairedPaths = [System.Collections.Generic.List[string]]::new()
    foreach ($candidate in @($PairedReviewCandidates | Where-Object { $null -ne $_ })) {
        $path = [string](Get-AdvisoryProperty -Record $candidate -Name 'relative_path')
        $normalized = ($path -replace '\\', '/')
        if ($normalized -match '(^|/)\.reviews/') {
            $pairedPaths.Add($path)
        }
    }
    $discoverySteps.Add('canonical-paired-review-lookup')

    $latest = Select-AdvisoryLatestReport -History $AdvisoryReports
    $context = $null
    if ($null -ne $latest) {
        $unresolved = [System.Collections.Generic.List[object]]::new()
        foreach ($decision in @($latest.decisions)) {
            $status = [string](Get-AdvisoryProperty -Record $decision -Name 'status')
            if ($status -eq 'Open' -or $status -eq 'Routed') {
                $unresolved.Add((Get-AdvisoryFieldCopy -Source $decision))
            }
        }
        $context = [pscustomobject]@{
            report_id            = [string](Get-AdvisoryProperty -Record $latest -Name 'report_id')
            issued_at            = [string](Get-AdvisoryProperty -Record $latest -Name 'issued_at')
            unresolved_decisions = @($unresolved.ToArray())
        }
    }
    $discoverySteps.Add('advisory-discovery')

    $presentation = [System.Collections.Generic.List[object]]::new()
    if ($null -ne $context) {
        $presentation.Add([pscustomobject]@{
                kind      = 'advisory-context'
                report_id = $context.report_id
            })
    }
    $presentation.Add([pscustomobject]@{
            kind   = 'mandatory-next-action'
            action = $MandatoryNextAction
        })
    $discoverySteps.Add('mandatory-next-action')

    return [pscustomobject]@{
        discovery_steps                = @($discoverySteps.ToArray())
        paired_review_paths            = @($pairedPaths.ToArray())
        advisory_context               = $context
        mandatory_next_action          = $MandatoryNextAction
        missing_advisory_blocks_resume = $false
        presentation                   = @($presentation.ToArray())
    }
}

$script:AdvisoryHostEntryPoints = @(
    '.github/prompts/second-opinion.prompt.md',
    '.github/skills/second-opinion/SKILL.md',
    '.cursor/commands/second-opinion.md',
    '.cursor/skills/second-opinion/SKILL.md',
    '.claude/skills/second-opinion/SKILL.md'
)
$script:AdvisoryHostRouteMarkers = @(
    'spec-writer',
    'ddd-architect',
    'planned slice owner',
    'Accept Risk'
)
$script:AdvisoryHostDistinctionMarkers = @(
    '/review-slice',
    '/slice-retro',
    'paired review',
    'retrospective'
)
$script:AdvisoryHostReportMarker = 'second-opinion-report.md'
$script:AdvisoryHostSkillContractMarkers = @(
    'append-only iteration',
    'issued_at descending',
    'higher iter',
    'exit criteria',
    'validity of evidence',
    'prerequisites',
    'deferred gaps',
    'safety of the proposed next step'
)

function Get-AdvisoryDecisionRecord {
    param(
        [Parameter(Mandatory = $true)]$Assessment,
        [Parameter(Mandatory = $true)][string]$FindingId
    )
    foreach ($decision in @($Assessment.decisions)) {
        if ([string](Get-AdvisoryProperty -Record $decision -Name 'finding_id') -eq $FindingId) {
            return $decision
        }
    }
    throw "Finding $FindingId has no decision on this advisory report."
}

function New-AdvisoryRouteCorrelation {
    <#
    .SYNOPSIS
        Correlate an advisory route with turn context without mutating turn or phase.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns an in-memory correlation record.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Assessment,
        [Parameter(Mandatory = $true)][string]$FindingId,
        [Parameter(Mandatory = $true)]$TurnContext,
        [string]$MandatoryNextAction = '',
        [string]$SelectedWorkDestination = ''
    )
    $decision = Get-AdvisoryDecisionRecord -Assessment $Assessment -FindingId $FindingId
    $turnState = [string](Get-AdvisoryProperty -Record $TurnContext -Name 'turn_state')
    if ($script:AdvisoryTurnStates -notcontains $turnState) {
        throw "Turn state '$turnState' is not an advisory turn state."
    }
    $destination = $SelectedWorkDestination
    if (Test-AdvisoryBlank $destination) {
        $destination = [string](Get-AdvisoryProperty -Record $decision -Name 'selected_work_destination')
    }
    if (($turnState -eq 'stale' -or $turnState -eq 'abandoned') -and (Test-AdvisoryBlank $SelectedWorkDestination)) {
        throw 'Routing after a stale or abandoned turn requires an explicit selected work destination.'
    }
    if ($script:AdvisoryDestinations -notcontains $destination) {
        throw "Selected work destination '$destination' is not an advisory destination."
    }
    return [pscustomobject]@{
        report_id                       = [string](Get-AdvisoryProperty -Record $Assessment -Name 'report_id')
        finding_id                      = $FindingId
        decision                        = [string](Get-AdvisoryProperty -Record $decision -Name 'decision')
        selected_work_destination       = $destination
        next_owner                      = [string](Get-AdvisoryProperty -Record $decision -Name 'next_owner')
        turn_context                    = (Get-AdvisoryFieldCopy -Source $TurnContext)
        mandatory_next_action           = $MandatoryNextAction
        mutates_turn                    = $false
        mutates_phase                   = $false
        changes_ownership               = $false
        changes_mandatory_next_action   = $false
        infers_active_turn              = $false
        infers_active_slice             = $false
    }
}

function Add-AdvisoryContinuityJournalEvent {
    <#
    .SYNOPSIS
        Append one advisory continuity event that stays outside gate evidence.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Appends an advisory-only journal event through the harness journal API.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$Feature,
        [string]$Slice = '',
        [Parameter(Mandatory = $true)][ValidateSet('Issue', 'Decision', 'Amendment', 'Route')][string]$Action,
        [Parameter(Mandatory = $true)]$Assessment,
        [string]$FindingId = '',
        [string]$ActorName = 'operator',
        [string]$OccurredAt = '',
        [string]$PriorEventId = '',
        [string]$Reason = '',
        $TurnContext = $null,
        [string]$TurnCorrelation = '',
        [string]$SelectedWorkDestination = '',
        [string]$MandatoryNextAction = '',
        [string]$ActionId = '',
        [string]$CanonicalFingerprint = '',
        [string]$CommandName = ''
    )
    if (-not (Get-Command Add-HarnessJournalEvent -ErrorAction SilentlyContinue)) {
        throw 'Add-AdvisoryContinuityJournalEvent requires Add-HarnessJournalEvent from HarnessJournal.psm1.'
    }
    $actionName = $Action.ToLowerInvariant()
    $payload = [ordered]@{
        action      = $actionName
        report_id   = [string](Get-AdvisoryProperty -Record $Assessment -Name 'report_id')
        report_path = [string](Get-AdvisoryProperty -Record $Assessment -Name 'relative_path')
        target      = [string](Get-AdvisoryProperty -Record $Assessment -Name 'target')
        actor_name  = $ActorName
        gate_effect = 'none'
    }
    if (-not (Test-AdvisoryBlank $ActionId)) {
        $payload.action_id = $ActionId
    }
    if (-not (Test-AdvisoryBlank $CanonicalFingerprint)) {
        $payload.canonical_fingerprint = $CanonicalFingerprint
    }
    if ($null -ne $TurnContext) {
        $payload.turn_context = Get-AdvisoryFieldCopy -Source $TurnContext
    }
    if (-not (Test-AdvisoryBlank $TurnCorrelation)) {
        $payload.turn_correlation = $TurnCorrelation
    }
    if (-not (Test-AdvisoryBlank $PriorEventId)) {
        $payload.prior_event_id = $PriorEventId
    }
    if (-not (Test-AdvisoryBlank $Reason)) {
        $payload.reason = $Reason
    }
    if ($actionName -ne 'issue') {
        if (Test-AdvisoryBlank $FindingId) {
            throw "Advisory $actionName events require FindingId."
        }
        $decision = Get-AdvisoryDecisionRecord -Assessment $Assessment -FindingId $FindingId
        $payload.finding_id = $FindingId
        $payload.decision = [string](Get-AdvisoryProperty -Record $decision -Name 'decision')
        $payload.selected_work_destination = if (-not (Test-AdvisoryBlank $SelectedWorkDestination)) {
            $SelectedWorkDestination
        } else {
            [string](Get-AdvisoryProperty -Record $decision -Name 'selected_work_destination')
        }
        $payload.next_owner = [string](Get-AdvisoryProperty -Record $decision -Name 'next_owner')
    }
    if ($actionName -eq 'route') {
        $route = New-AdvisoryRouteCorrelation -Assessment $Assessment -FindingId $FindingId `
            -TurnContext $TurnContext -MandatoryNextAction $MandatoryNextAction `
            -SelectedWorkDestination $SelectedWorkDestination
        $payload.mutates_turn = $route.mutates_turn
        $payload.mutates_phase = $route.mutates_phase
        $payload.changes_ownership = $route.changes_ownership
        $payload.changes_mandatory_next_action = $route.changes_mandatory_next_action
    }
    $activity = [pscustomobject]@{
        id   = ('act_advisory_' + $actionName + '_' + ([guid]::NewGuid().ToString('N').Substring(0, 12)))
        type = 'other'
        name = ('advisory-' + $actionName)
        role = 'instant'
    }
    $actor = [pscustomobject]@{
        agent        = $ActorName
        parent_agent = $null
        tool         = 'advisory-continuity'
        model        = $null
    }
    $refs = [pscustomobject]@{
        amends_event_id = $(if (Test-AdvisoryBlank $PriorEventId) { $null } else { $PriorEventId })
        pair_event_id   = $null
        artifact        = [string](Get-AdvisoryProperty -Record $Assessment -Name 'relative_path')
        command         = $(if (Test-AdvisoryBlank $CommandName) { 'Add-AdvisoryContinuityJournalEvent' } else { $CommandName })
    }
    return Add-HarnessJournalEvent -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice `
        -Kind 'AdvisoryContinuityRecorded' -Activity $activity -Actor $actor -Refs $refs `
        -Outcome $null -Summary ("Advisory $actionName recorded") -Payload ([pscustomobject]$payload) `
        -OccurredAt $OccurredAt
}

function New-AdvisoryIndex {
    <#
    .SYNOPSIS
        Create a journal-derived advisory index that cannot satisfy review-ready.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a derived advisory read model. It does not write the index file.')]
    [CmdletBinding()]
    param(
        [object[]]$JournalEvents = @(),
        [object[]]$Reports = @()
    )
    $sourceEventIds = [System.Collections.Generic.List[string]]::new()
    $timeline = [System.Collections.Generic.List[object]]::new()
    $reportPaths = [System.Collections.Generic.List[string]]::new()
    foreach ($continuityEvent in @($JournalEvents | Where-Object { $null -ne $_ })) {
        if ([string](Get-AdvisoryProperty -Record $continuityEvent -Name 'kind') -ne 'AdvisoryContinuityRecorded') {
            continue
        }
        $sourceEventIds.Add([string](Get-AdvisoryProperty -Record $continuityEvent -Name 'event_id'))
        $payload = Get-AdvisoryProperty -Record $continuityEvent -Name 'payload'
        $path = [string](Get-AdvisoryProperty -Record $payload -Name 'report_path')
        $timeline.Add([pscustomobject]@{
                event_id    = [string](Get-AdvisoryProperty -Record $continuityEvent -Name 'event_id')
                recorded_at = [string](Get-AdvisoryProperty -Record $continuityEvent -Name 'recorded_at')
                action      = [string](Get-AdvisoryProperty -Record $payload -Name 'action')
                report_id   = [string](Get-AdvisoryProperty -Record $payload -Name 'report_id')
                report_path = $path
                finding_id  = [string](Get-AdvisoryProperty -Record $payload -Name 'finding_id')
                actor_name  = [string](Get-AdvisoryProperty -Record $payload -Name 'actor_name')
                prior_event_id = [string](Get-AdvisoryProperty -Record $payload -Name 'prior_event_id')
            })
        if (-not (Test-AdvisoryBlank $path) -and ($reportPaths -notcontains $path)) {
            $reportPaths.Add($path)
        }
    }
    $diagnostics = [System.Collections.Generic.List[object]]::new()
    $byKey = @{}
    foreach ($report in @($Reports | Where-Object { $null -ne $_ })) {
        $reportId = [string](Get-AdvisoryProperty -Record $report -Name 'report_id')
        $issuedAt = [string](Get-AdvisoryProperty -Record $report -Name 'issued_at')
        $iteration = Get-AdvisoryIterationValue -Assessment $report
        $path = [string](Get-AdvisoryProperty -Record $report -Name 'relative_path')
        $feature = [string](Get-AdvisoryProperty -Record $report -Name 'feature')
        $target = [string](Get-AdvisoryProperty -Record $report -Name 'target')
        if ($reportId -notmatch $script:AdvisoryUuidPattern -or -not (Test-AdvisoryIssuedAtText -IssuedAt $issuedAt) -or $iteration -lt 1) {
            $diagnostics.Add([pscustomobject]@{
                    path       = $path
                    reason     = 'partial or malformed advisory report'
                    provenance = 'report-scan'
                })
            continue
        }
        $key = '{0}|{1}|{2}' -f $feature, $target, $iteration
        if ($byKey.ContainsKey($key)) {
            $diagnostics.Add([pscustomobject]@{
                    path       = $path
                    reason     = 'duplicate iteration for the same feature and target'
                    provenance = 'report-scan'
                })
            continue
        }
        $byKey[$key] = $report
    }
    $latest = Select-AdvisoryLatestReport -History @($byKey.Values)
    $featureSlug = 'unknown-feature'
    if ($null -ne $latest) {
        $featureSlug = [string](Get-AdvisoryProperty -Record $latest -Name 'feature')
    } elseif ($Reports.Count -gt 0) {
        $featureSlug = [string](Get-AdvisoryProperty -Record $Reports[0] -Name 'feature')
    }
    return [pscustomobject]@{
        schema_version          = 'advisory-index.v3'
        relative_path            = "docs/harness/features/$featureSlug/advisory/advisory-index.jsonl"
        feature                 = $featureSlug
        generated_from          = 'journal + advisory report scan'
        latest_report_id         = $(if ($null -eq $latest) { $null } else { [string]$latest.report_id })
        latest_report_path       = $(if ($null -eq $latest) { $null } else { [string]$latest.relative_path })
        reports                 = @($byKey.Values | Sort-Object -Property `
            @{ Expression = { [string](Get-AdvisoryProperty -Record $_ -Name 'issued_at') }; Descending = $false }, `
            @{ Expression = { Get-AdvisoryIterationValue -Assessment $_ }; Descending = $false } | ForEach-Object {
                [pscustomobject]@{
                    report_id     = [string]$_.report_id
                    relative_path = [string]$_.relative_path
                    feature       = [string]$_.feature
                    target        = [string]$_.target
                    iteration     = [int]$_.iteration
                    issued_at     = [string]$_.issued_at
                }
            })
        source_event_ids         = @($sourceEventIds.ToArray())
        linked_report_paths      = @($reportPaths.ToArray())
        timeline                 = @($timeline.ToArray())
        diagnostics              = @($diagnostics.ToArray())
        can_satisfy_review_ready = $false
        gate_projection          = 'excluded'
    }
}

function Test-AdvisoryScopeChangeRequiresFreshReport {
    <#
    .SYNOPSIS
        True when the candidate changes assessment scope and needs a new report.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$PriorAssessment,
        [Parameter(Mandatory = $true)]$CandidateAssessment
    )
    foreach ($name in @('target', 'target_reference', 'evidence_boundary', 'review_question')) {
        $prior = [string](Get-AdvisoryProperty -Record $PriorAssessment -Name $name)
        $candidate = [string](Get-AdvisoryProperty -Record $CandidateAssessment -Name $name)
        if ($prior -ne $candidate) {
            return $true
        }
    }
    return $false
}

function New-AdvisoryFreshReassessment {
    <#
    .SYNOPSIS
        Issue a fresh reassessment with a new report identity.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns an in-memory reassessment.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$PriorAssessment,
        [Parameter(Mandatory = $true)][string]$TargetReference,
        [Parameter(Mandatory = $true)][string]$ReviewQuestion,
        [Parameter(Mandatory = $true)][string]$EvidenceBoundary,
        [Parameter(Mandatory = $true)][string]$IssuedAt,
        [ValidateSet('Proceed', 'Hold For Decision', 'Revise Then Reassess')][string]$AdvisoryReadiness = 'Hold For Decision',
        [string]$ReadinessReason = 'The assessment scope changed and needs a fresh report'
    )
    $target = [string](Get-AdvisoryProperty -Record $PriorAssessment -Name 'target')
    $findings = Copy-AdvisoryRecordList -Records (Get-AdvisoryProperty -Record $PriorAssessment -Name 'findings')
    $decisions = Copy-AdvisoryRecordList -Records (Get-AdvisoryProperty -Record $PriorAssessment -Name 'decisions')
    $fresh = New-AdvisoryAssessment -FeatureSlug ([string](Get-AdvisoryProperty -Record $PriorAssessment -Name 'feature')) `
        -Target $target -TargetReference $TargetReference -ReviewQuestion $ReviewQuestion `
        -EvidenceBoundary $EvidenceBoundary `
        -IntentionalLatitude ([string](Get-AdvisoryProperty -Record $PriorAssessment -Name 'intentional_latitude')) `
        -ReviewerProvenance ([string](Get-AdvisoryProperty -Record $PriorAssessment -Name 'reviewer_provenance')) `
        -AdvisoryReadiness $AdvisoryReadiness -ReadinessReason $ReadinessReason -IssuedAt $IssuedAt `
        -History @($PriorAssessment) -Findings $findings -Decisions $decisions
    if (-not (Test-AdvisoryScopeChangeRequiresFreshReport -PriorAssessment $PriorAssessment -CandidateAssessment $fresh)) {
        throw 'A fresh reassessment requires a changed target, target reference, evidence boundary, or review question.'
    }
    return $fresh
}

function New-AdvisoryLensScorecard {
    <#
    .SYNOPSIS
        Record comparative advisory lens scores without thresholds.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns an in-memory scorecard.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][ValidateSet('spec', 'feature', 'milestone')][string]$Target,
        [Parameter(Mandatory = $true)][hashtable]$LensScores,
        [hashtable]$MissingEvidence = @{}
    )
    $lenses = @(Get-AdvisoryLensSet -Target $Target)
    $scoredPoints = 0
    $scoredCount = 0
    $notScored = 0
    $details = [System.Collections.Generic.List[object]]::new()
    foreach ($lens in $lenses) {
        if (-not $LensScores.ContainsKey($lens)) {
            throw "Lens '$lens' is missing from the scorecard."
        }
        $value = $LensScores[$lens]
        if ([string]$value -eq $script:AdvisoryMissingEvidenceLabel) {
            $needed = [string]$MissingEvidence[$lens]
            if (Test-AdvisoryBlank $needed) {
                throw "Missing evidence for lens '$lens' must name the needed evidence."
            }
            $notScored++
            $details.Add([pscustomobject]@{
                    lens            = $lens
                    score           = $script:AdvisoryMissingEvidenceLabel
                    needed_evidence = $needed
                })
            continue
        }
        $numeric = [int]$value
        if ($numeric -lt 0 -or $numeric -gt 4) {
            throw "Lens '$lens' must use the 0 through 4 concern scale."
        }
        $scoredPoints += $numeric
        $scoredCount++
        $details.Add([pscustomobject]@{
                lens  = $lens
                score = $numeric
            })
    }
    $possible = $scoredCount * 4
    return [pscustomobject]@{
        target                             = $Target
        lens_set                           = @($lenses)
        scale_min                          = 0
        scale_max                          = 4
        scored_points                      = $scoredPoints
        possible_points                    = $possible
        not_scored_count                   = $notScored
        summary                            = ('{0}/{1}, {2} lens not scored' -f $scoredPoints, $possible, $notScored)
        details                            = @($details.ToArray())
        has_thresholds                     = $false
        affects_review_ready               = $false
        affects_canonical_routing          = $false
        affects_mandatory_phase_selection  = $false
        affects_selected_work_destination  = $false
        affects_advisory_readiness         = $false
    }
}

function Compare-AdvisoryLensScorecard {
    <#
    .SYNOPSIS
        Compare reassessment scores only for the same target class and lens set.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Earlier,
        [Parameter(Mandatory = $true)]$Later
    )
    $earlierTarget = [string](Get-AdvisoryProperty -Record $Earlier -Name 'target')
    $laterTarget = [string](Get-AdvisoryProperty -Record $Later -Name 'target')
    if ($earlierTarget -ne $laterTarget) {
        throw 'Reassessment comparison requires the same target class and lens set.'
    }
    $earlierLenses = @((Get-AdvisoryProperty -Record $Earlier -Name 'lens_set') | ForEach-Object { [string]$_ })
    $laterLenses = @((Get-AdvisoryProperty -Record $Later -Name 'lens_set') | ForEach-Object { [string]$_ })
    if (($earlierLenses -join '|') -ne ($laterLenses -join '|')) {
        throw 'Reassessment comparison requires the same target class and lens set.'
    }
    $earlierPoints = [int](Get-AdvisoryProperty -Record $Earlier -Name 'scored_points')
    $laterPoints = [int](Get-AdvisoryProperty -Record $Later -Name 'scored_points')
    return [pscustomobject]@{
        comparable     = $true
        target         = $earlierTarget
        total_movement = ($laterPoints - $earlierPoints)
        earlier_summary = [string](Get-AdvisoryProperty -Record $Earlier -Name 'summary')
        later_summary   = [string](Get-AdvisoryProperty -Record $Later -Name 'summary')
    }
}

function Get-AdvisoryHostEntryPoint {
    <#
    .SYNOPSIS
        Return the canonical and generated second-opinion entry points.
    #>

    [CmdletBinding()]
    param()
    return @($script:AdvisoryHostEntryPoints)
}

function Test-AdvisoryHostSkillSurface {
    param(
        [Parameter(Mandatory = $true)][string]$RelativePath
    )
    return $RelativePath -match '/skills/second-opinion/SKILL\.md$'
}

function Add-AdvisoryHostMarkerGap {
    param(
        [Parameter(Mandatory = $true)]$Mismatches,
        [Parameter(Mandatory = $true)][string]$RelativePath,
        [Parameter(Mandatory = $true)][string]$Gap,
        [Parameter(Mandatory = $true)][string[]]$Markers,
        [Parameter(Mandatory = $true)][string]$Text
    )
    foreach ($marker in @($Markers)) {
        if ($Text -notlike ('*' + $marker + '*')) {
            $Mismatches.Add([pscustomobject]@{
                    entry_point = $RelativePath
                    gap         = $Gap
                    marker      = $marker
                })
        }
    }
}

function Get-AdvisoryHostContractMismatch {
    <#
    .SYNOPSIS
        Name each entry point that drops the advisory boundary, report, or route contract.
    .DESCRIPTION
        Checks the canonical prompt, the canonical skill, the Cursor command,
        the Cursor skill, and the Claude skill for the same marker strings.
        A missing file is a mismatch. So is a missing non-gating sentence, a
        missing report-template name, a missing return-route marker, or a
        missing distinction from paired review, /review-slice, and /slice-retro.
        Skill surfaces also keep append-only iteration identity, latest-report order, and
        the named-milestone lenses. Consistency is not claimed while any
        mismatch remains.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$RepoRoot
    )
    $mismatches = [System.Collections.Generic.List[object]]::new()
    foreach ($relativePath in @(Get-AdvisoryHostEntryPoint)) {
        $fullPath = Join-Path $RepoRoot ($relativePath -replace '/', [string][System.IO.Path]::DirectorySeparatorChar)
        if (-not (Test-Path -LiteralPath $fullPath)) {
            $mismatches.Add([pscustomobject]@{
                    entry_point = $relativePath
                    gap         = 'missing'
                })
            continue
        }
        $text = Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
        if ($text -notlike ('*' + $script:AdvisoryNonGatingStatement + '*')) {
            $mismatches.Add([pscustomobject]@{
                    entry_point = $relativePath
                    gap         = 'boundary'
                })
        }
        if ($text -notlike ('*' + $script:AdvisoryHostReportMarker + '*')) {
            $mismatches.Add([pscustomobject]@{
                    entry_point = $relativePath
                    gap         = 'report'
                })
        }
        Add-AdvisoryHostMarkerGap -Mismatches $mismatches -RelativePath $relativePath -Gap 'route' -Markers $script:AdvisoryHostRouteMarkers -Text $text
        Add-AdvisoryHostMarkerGap -Mismatches $mismatches -RelativePath $relativePath -Gap 'distinction' -Markers $script:AdvisoryHostDistinctionMarkers -Text $text
        if (Test-AdvisoryHostSkillSurface -RelativePath $relativePath) {
            Add-AdvisoryHostMarkerGap -Mismatches $mismatches -RelativePath $relativePath -Gap 'contract' -Markers $script:AdvisoryHostSkillContractMarkers -Text $text
        }
    }
    return @($mismatches.ToArray())
}

function Test-AdvisoryHostContractConsistent {
    <#
    .SYNOPSIS
        True only when every supported entry point keeps the advisory contract.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$RepoRoot
    )
    return (@(Get-AdvisoryHostContractMismatch -RepoRoot $RepoRoot)).Count -eq 0
}

Export-ModuleMember -Function @(
    'Get-AdvisoryNonGatingStatement',
    'Test-AdvisoryReportNonGating',
    'Test-AdvisoryFilingLocationAllowed',
    'Get-AdvisoryReportRelativePath',
    'Get-AdvisoryNextIteration',
    'Get-AdvisoryReportClassification',
    'New-AdvisoryAssessment',
    'Update-AdvisoryAssessmentDecision',
    'Set-AdvisoryAssessmentDecisionRouted',
    'Add-AdvisoryAssessmentToHistory',
    'Select-AdvisoryLatestReport',
    'Add-AdvisoryDecisionAmendment',
    'Get-AdvisoryResumePresentation',
    'Get-AdvisoryHostEntryPoint',
    'Get-AdvisoryHostContractMismatch',
    'Test-AdvisoryHostContractConsistent',
    'New-AdvisoryRouteCorrelation',
    'Add-AdvisoryContinuityJournalEvent',
    'New-AdvisoryIndex',
    'Test-AdvisoryScopeChangeRequiresFreshReport',
    'New-AdvisoryFreshReassessment',
    'New-AdvisoryLensScorecard',
    'Compare-AdvisoryLensScorecard'
)