skills/reviewed-learning-inbox/scripts/Invoke-LearningPromotion.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Applies an approved reviewed-learning-inbox promotion to one Customization. .DESCRIPTION Validates the whole proposal before any write: the proposal must belong to the selected project, no path may be reached through a symbolic link or junction, its destination must agree with the candidate scope and stay inside the allowed Skill and Instruction surface, the proposal evidence must be identical to the authoritative candidate evidence, and the candidate record, its evidence, and the destination bytes must all still match the values captured in the preview. Without Approve and the SHA-256 of the reviewed preview nothing is written. Application appends through a write-exclusive handle, so the original bytes are preserved exactly and a concurrent edit is refused instead of overwritten. Repeat application is bound to the verified block content, not to the presence of a marker: an apply that appended the block but never recorded it is reconciled with the same approval, and an edited or forged block is refused. .PARAMETER Path Existing project directory whose Memory Bank holds the learning inbox. .PARAMETER ProposalPath Saved promotion proposal under the learning inbox proposals directory. .PARAMETER Approve Explicit human approval of the previewed change. .PARAMETER ApprovedPreviewSha256 SHA-256 of the preview the approver actually read. .PARAMETER ReferenceTime UTC time recorded with the promotion. .EXAMPLE ./Invoke-LearningPromotion.ps1 -Path C:/Git/MyProject ` -ProposalPath C:/Git/MyProject/.memory-bank/learning-inbox/proposals/promotion-cand-0123456789ab-2026-09-07T101500Z.json Validates the proposal and reports the preview without writing. .OUTPUTS System.Management.Automation.PSCustomObject #> # The approval gate is Approve plus the SHA-256 of the reviewed preview, not an # interactive prompt: a prompt can be answered blind and suppressed wholesale. [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'Preview')] [OutputType([PSCustomObject])] param ( [Parameter(ParameterSetName = 'Preview')] [Parameter(ParameterSetName = 'Approve')] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] [string]$Path = (Get-Location).Path, [Parameter(Mandatory, ParameterSetName = 'Preview')] [Parameter(Mandatory, ParameterSetName = 'Approve')] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] [string]$ProposalPath, [Parameter(Mandatory, ParameterSetName = 'Approve')] [switch]$Approve, [Parameter(Mandatory, ParameterSetName = 'Approve')] [ValidatePattern('^[0-9a-fA-F]{64}$')] [string]$ApprovedPreviewSha256, [Parameter(ParameterSetName = 'Preview')] [Parameter(ParameterSetName = 'Approve')] [ValidateNotNull()] [datetime]$ReferenceTime = [datetime]::UtcNow ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'LearningInboxCommon.ps1') $context = Resolve-LearningInboxContext -Path $Path $absoluteProposalPath = Get-LearningInboxFullPath -LiteralPath ( (Get-Item -LiteralPath $ProposalPath -Force -ErrorAction Stop).FullName ) # Location and name are settled before the file is opened, so a proposal from # another project is refused rather than parsed. $proposalParent = Get-LearningInboxFullPath -LiteralPath ( Split-Path -Parent $absoluteProposalPath ) if (-not (Test-LearningInboxPathEqual -Left $proposalParent -Right ( Get-LearningInboxFullPath -LiteralPath $context.ProposalPath ))) { throw "The promotion proposal '$absoluteProposalPath' does not match the selected project: it must be a direct child of '$($context.ProposalPath)'." } if ([IO.Path]::GetFileName($absoluteProposalPath) -notlike 'promotion-*.json') { throw "Promotion proposal file name must match 'promotion-*.json'." } $null = Assert-LearningInboxRegularPath ` -LiteralPath $absoluteProposalPath ` -RepositoryRoot $context.RepositoryRoot ` -Field 'The promotion proposal' $proposal = Get-Content -LiteralPath $absoluteProposalPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($proposal.schemaVersion -ne $script:LearningInboxSchemaVersion) { throw "Unsupported promotion proposal schema version '$($proposal.schemaVersion)'. Expected $script:LearningInboxSchemaVersion." } $proposedRoot = Get-LearningInboxFullPath -LiteralPath ([string]$proposal.repositoryRoot) if (-not (Test-LearningInboxPathEqual -Left $proposedRoot -Right $context.RepositoryRoot)) { throw "Promotion proposal names project '$proposedRoot', which does not match the selected project '$($context.RepositoryRoot)'." } $store = Read-LearningInboxStore -Context $context if ($null -eq $store) { throw "No reviewed learning inbox was found for '$($context.RepositoryRoot)'." } $candidateId = [string]$proposal.candidateId $candidate = Get-LearningInboxCandidate -Store $store -Id $candidateId # The destination is checked against the candidate before the surface # allow-list so a rewritten proposal reports the contradiction it introduced. $proposedTarget = Assert-LearningInboxRelativePath ` -RelativePath ([string]$proposal.targetPath) ` -Field 'Promotion target' ` -RepositoryRoot $context.RepositoryRoot if ($proposedTarget -cne ([string]$candidate.scope.target)) { throw "Promotion target '$proposedTarget' contradicts the candidate scope target '$([string]$candidate.scope.target)'." } $targetRelativePath = Assert-LearningInboxPromotionTarget ` -Target $proposedTarget ` -ScopeKind ([string]$candidate.scope.kind) ` -RepositoryRoot $context.RepositoryRoot $targetPath = Join-Path $context.RepositoryRoot ( $targetRelativePath.Replace('/', [IO.Path]::DirectorySeparatorChar) ) if (-not (Test-Path -LiteralPath $targetPath -PathType Leaf)) { throw "Promotion destination not found: '$targetRelativePath'." } if (Test-LearningInboxReparsePoint -LiteralPath $targetPath) { throw "Promotion destination must not be a symbolic link or reparse point: '$targetRelativePath'." } $targetContent = (Read-LearningInboxTargetFile -LiteralPath $targetPath).Text $marker = "reviewed-learning-inbox:candidate $candidateId" # Content, evidence identity, and evidence state are checked before any marker # is trusted, so a marker written by hand can never claim a promotion. Assert-LearningInboxCandidateContent ` -Candidate $candidate ` -RepositoryRoot $context.RepositoryRoot Assert-LearningInboxEvidenceIdentity ` -Candidate $candidate ` -ProposalEvidence @($proposal.evidence) Assert-LearningInboxEvidenceState ` -Candidate $candidate ` -RepositoryRoot $context.RepositoryRoot $expectedRegion = Get-LearningInboxBlockRegion ` -Content (New-LearningInboxBlock -Candidate $candidate -Mode 'AppendEntry') ` -CandidateId $candidateId $appliedRegion = Get-LearningInboxBlockRegion ` -Content $targetContent ` -CandidateId $candidateId if ($null -ne $appliedRegion) { $currentTargetHash = Get-LearningInboxFileHash -LiteralPath $targetPath if ($appliedRegion -cne $expectedRegion) { throw "The block already in '$targetRelativePath' for candidate '$candidateId' does not match the candidate record. A promotion never produces that difference, so the destination or the record was edited; resolve it by hand before promoting again." } $proposedRegion = Get-LearningInboxBlockRegion ` -Content ([string]$proposal.block) ` -CandidateId $candidateId if ($proposedRegion -cne $expectedRegion) { throw "The promotion proposal block does not match the candidate record. Generate a new proposal and review it again." } $promotion = $candidate.PSObject.Properties['promotion'] if ([string]$candidate.status -eq 'Promoted') { if ($null -eq $promotion -or $null -eq $promotion.Value -or ([string]$promotion.Value.targetPath) -cne $targetRelativePath) { throw "Candidate '$candidateId' is recorded as promoted but carries no promotion record for '$targetRelativePath'. Resolve the record by hand before promoting again." } return [PSCustomObject]@{ Action = 'AlreadyPromoted' CandidateId = $candidateId TargetPath = $targetRelativePath PreviousSha256 = $currentTargetHash NewSha256 = $currentTargetHash PreviewSha256 = [string]$proposal.previewSha256 ProposalPath = $absoluteProposalPath RecordedSha256 = [string]$promotion.Value.newSha256 } } # The block is verified but unrecorded: an apply that appended and then # failed before the store was saved. Reconcile the store only, and keep the # original human approval requirement for doing so. if ([string]$candidate.status -in @('Rejected', 'Superseded')) { throw "The candidate is $([string]$candidate.status) but its block is already present in '$targetRelativePath'. Remove the block by hand; nothing was changed." } $currentCandidateHash = Get-LearningInboxCandidateHash -Candidate $candidate if ($currentCandidateHash -cne ([string]$proposal.candidateSha256).ToLowerInvariant()) { throw "The block in '$targetRelativePath' matches the candidate record, but the candidate changed after this proposal was approved. Nothing was changed; generate a new proposal and review it again." } if (-not $Approve) { return [PSCustomObject]@{ Action = 'ReconciliationRequired' CandidateId = $candidateId TargetPath = $targetRelativePath PreviousSha256 = $currentTargetHash NewSha256 = $currentTargetHash PreviewSha256 = [string]$proposal.previewSha256 ProposalPath = $absoluteProposalPath Recovery = "The approved block is already present in '$targetRelativePath' but the store never recorded it. Re-run this command with -Approve and -ApprovedPreviewSha256 $([string]$proposal.previewSha256) to record the promotion; the destination will not be written to again." } } if ($ApprovedPreviewSha256.ToLowerInvariant() -cne ([string]$proposal.previewSha256).ToLowerInvariant()) { throw 'The supplied approval does not match the previewed change. Approve the SHA-256 of the preview that was actually reviewed.' } if (-not $PSCmdlet.ShouldProcess($context.StorePath, "Reconcile the recorded promotion of '$candidateId'")) { return [PSCustomObject]@{ Action = 'Planned' CandidateId = $candidateId TargetPath = $targetRelativePath PreviousSha256 = $currentTargetHash NewSha256 = $currentTargetHash PreviewSha256 = [string]$proposal.previewSha256 ProposalPath = $absoluteProposalPath } } $reconciledUtc = $ReferenceTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $candidate.status = 'Promoted' $candidate.updatedUtc = $reconciledUtc $candidate | Add-Member -NotePropertyName 'promotion' -NotePropertyValue ( [PSCustomObject][ordered]@{ targetPath = $targetRelativePath previousSha256 = ([string]$proposal.targetSha256).ToLowerInvariant() newSha256 = $currentTargetHash previewSha256 = [string]$proposal.previewSha256 proposalPath = ([string]$proposal.proposalPath) promotedUtc = $reconciledUtc } ) -Force $store.updatedUtc = $reconciledUtc Write-LearningInboxStore -Context $context -Store $store return [PSCustomObject]@{ Action = 'Reconciled' CandidateId = $candidateId TargetPath = $targetRelativePath PreviousSha256 = $currentTargetHash NewSha256 = $currentTargetHash PreviewSha256 = [string]$proposal.previewSha256 ProposalPath = $absoluteProposalPath } } if ([string]$candidate.status -in @('Rejected', 'Superseded')) { throw "The candidate is $([string]$candidate.status) and cannot be promoted." } $currentCandidateHash = Get-LearningInboxCandidateHash -Candidate $candidate if ($currentCandidateHash -cne ([string]$proposal.candidateSha256).ToLowerInvariant()) { throw "The candidate record changed after the preview was produced. Generate a new proposal and review it again." } if ([string]$candidate.status -eq 'Promoted') { throw "Candidate '$candidateId' is recorded as promoted but no promoted block is present in '$targetRelativePath'. Resolve the record by hand before promoting again." } $currentTargetHash = Get-LearningInboxFileHash -LiteralPath $targetPath if ($currentTargetHash -cne ([string]$proposal.targetSha256).ToLowerInvariant()) { throw "The promotion destination changed after the preview was produced. Generate a new proposal and review it again." } $mode = if ($targetContent -match 'reviewed-learning-inbox:section') { 'AppendEntry' } else { 'AppendSection' } $block = New-LearningInboxBlock -Candidate $candidate -Mode $mode $preview = New-LearningInboxPreview ` -Candidate $candidate ` -TargetPath $targetRelativePath ` -TargetSha256 $currentTargetHash ` -Block $block ` -Mode $mode $previewSha256 = Get-LearningInboxTextHash -Text $preview if ($previewSha256 -cne ([string]$proposal.previewSha256).ToLowerInvariant()) { throw 'The recorded preview no longer matches the change this promotion would make. Generate a new proposal and review it again.' } if (-not $Approve) { return [PSCustomObject]@{ Action = 'PreviewOnly' CandidateId = $candidateId TargetPath = $targetRelativePath PreviousSha256 = $currentTargetHash NewSha256 = $currentTargetHash PreviewSha256 = $previewSha256 ProposalPath = $absoluteProposalPath Preview = $preview } } if ($ApprovedPreviewSha256.ToLowerInvariant() -cne $previewSha256) { throw 'The supplied approval does not match the previewed change. Approve the SHA-256 of the preview that was actually reviewed.' } if (-not $PSCmdlet.ShouldProcess($targetPath, "Append approved lesson '$candidateId'")) { return [PSCustomObject]@{ Action = 'Planned' CandidateId = $candidateId TargetPath = $targetRelativePath PreviousSha256 = $currentTargetHash NewSha256 = $currentTargetHash PreviewSha256 = $previewSha256 ProposalPath = $absoluteProposalPath Preview = $preview } } $applied = Add-LearningInboxTargetContent ` -LiteralPath $targetPath ` -ExpectedSha256 $currentTargetHash ` -Block $block ` -RequiredMarker $marker $timestamp = $ReferenceTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $candidate.status = 'Promoted' $candidate.updatedUtc = $timestamp $candidate | Add-Member -NotePropertyName 'promotion' -NotePropertyValue ( [PSCustomObject][ordered]@{ targetPath = $targetRelativePath previousSha256 = $applied.PreviousSha256 newSha256 = $applied.NewSha256 previewSha256 = $previewSha256 proposalPath = ([string]$proposal.proposalPath) promotedUtc = $timestamp } ) -Force $store.updatedUtc = $timestamp try { Write-LearningInboxStore -Context $context -Store $store } catch { throw "The approved lesson was appended to '$targetRelativePath', but the reviewed learning inbox store could not be updated: $($_.Exception.Message) The destination is correct; re-run this command with the same approved proposal to record the promotion." } return [PSCustomObject]@{ Action = 'Promoted' CandidateId = $candidateId TargetPath = $targetRelativePath PreviousSha256 = $applied.PreviousSha256 NewSha256 = $applied.NewSha256 PreviewSha256 = $previewSha256 ProposalPath = $absoluteProposalPath Preview = $preview } |