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' ) |