skills/reviewed-learning-inbox/scripts/New-LearningPromotionProposal.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
    Builds a concrete promotion preview for one reviewed-learning-inbox candidate.
.DESCRIPTION
    Re-validates the stored candidate against the same content rules that guard
    intake, confirms every evidence locator still matches its recorded SHA-256,
    resolves the destination against the allowed Skill and Instruction surface,
    and renders the exact lines a promotion would append. The command is
    read-only for the project: it changes no Customization, and it writes only
    the optional proposal file the reviewer approves later.
.PARAMETER Path
    Existing project directory whose Memory Bank holds the learning inbox.
.PARAMETER Id
    Candidate identifier to preview.
.PARAMETER SaveProposal
    Saves the proposal under the learning inbox so it can be approved later.
.PARAMETER ReferenceTime
    UTC time used in proposal metadata and the saved proposal file name.
.EXAMPLE
    ./New-LearningPromotionProposal.ps1 -Path C:/Git/MyProject -Id cand-0123456789ab
 
    Renders the preview without writing anything.
.OUTPUTS
    System.Management.Automation.PSCustomObject
#>


[CmdletBinding(SupportsShouldProcess)]
[OutputType([PSCustomObject])]
param
(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string]$Path = (Get-Location).Path,

    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$Id,

    [Parameter()]
    [switch]$SaveProposal,

    [Parameter()]
    [ValidateNotNull()]
    [datetime]$ReferenceTime = [datetime]::UtcNow
)

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

. (Join-Path $PSScriptRoot 'LearningInboxCommon.ps1')

$context = Resolve-LearningInboxContext -Path $Path
$store = Read-LearningInboxStore -Context $context

if ($null -eq $store)
{
    throw "No reviewed learning inbox was found for '$($context.RepositoryRoot)'."
}

$candidate = Get-LearningInboxCandidate -Store $store -Id $Id

if ([string]$candidate.status -in @('Rejected', 'Superseded'))
{
    throw "Candidate '$Id' is $([string]$candidate.status) and cannot be promoted. Record a new candidate instead."
}

Assert-LearningInboxCandidateContent `
    -Candidate $candidate `
    -RepositoryRoot $context.RepositoryRoot

$targetRelativePath = Assert-LearningInboxPromotionTarget `
    -Target ([string]$candidate.scope.target) `
    -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'."
}

$evidenceState = @(
    foreach ($item in @($candidate.evidence))
    {
        $relativePath = [string]$item.path
        $absolutePath = Join-Path $context.RepositoryRoot (
            $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
        )

        if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf))
        {
            throw "Candidate evidence is no longer present: '$relativePath'."
        }

        $currentHash = Get-LearningInboxFileHash -LiteralPath $absolutePath
        if ($currentHash -cne ([string]$item.sha256).ToLowerInvariant())
        {
            throw "Candidate evidence changed since it was recorded: '$relativePath'. Record a new candidate against the current content."
        }

        [PSCustomObject][ordered]@{
            path = $relativePath
            sha256 = $currentHash
        }
    }
)

$targetContent = (Read-LearningInboxTargetFile -LiteralPath $targetPath).Text
$targetSha256 = Get-LearningInboxFileHash -LiteralPath $targetPath

$mode = if ($targetContent -match [regex]::Escape("reviewed-learning-inbox:candidate $Id"))
{
    'AlreadyPromoted'
}
elseif ($targetContent -match 'reviewed-learning-inbox:section')
{
    'AppendEntry'
}
else
{
    'AppendSection'
}

$blockMode = if ($mode -eq 'AlreadyPromoted')
{
    'AppendEntry'
}
else
{
    $mode
}

$block = New-LearningInboxBlock -Candidate $candidate -Mode $blockMode
$preview = New-LearningInboxPreview `
    -Candidate $candidate `
    -TargetPath $targetRelativePath `
    -TargetSha256 $targetSha256 `
    -Block $block `
    -Mode $mode

$timestamp = $ReferenceTime.ToUniversalTime()
$proposalRelativePath = $null

if ($SaveProposal)
{
    $fileName = 'promotion-{0}-{1}.json' -f
        $Id,
        $timestamp.ToString('yyyy-MM-ddTHHmmssZ')
    $proposalRelativePath = "$($context.ProposalRelativePath)/$fileName"
}

$proposal = [PSCustomObject][ordered]@{
    schemaVersion = $script:LearningInboxSchemaVersion
    createdUtc = $timestamp.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
    repositoryRoot = $context.RepositoryRoot
    projectId = $context.ProjectId
    candidateId = $Id
    candidateSha256 = Get-LearningInboxCandidateHash -Candidate $candidate
    scopeKind = [string]$candidate.scope.kind
    targetPath = $targetRelativePath
    targetSha256 = $targetSha256
    mode = $mode
    evidence = $evidenceState
    block = $block
    preview = $preview
    previewSha256 = Get-LearningInboxTextHash -Text $preview
    proposalPath = $proposalRelativePath
}

if ($SaveProposal)
{
    foreach ($directory in @($context.InboxPath, $context.ProposalPath))
    {
        $null = Assert-LearningInboxRegularPath `
            -LiteralPath $directory `
            -RepositoryRoot $context.RepositoryRoot `
            -Field 'The learning inbox directory'

        if (Test-Path -LiteralPath $directory)
        {
            if (-not (Test-Path -LiteralPath $directory -PathType Container))
            {
                throw "Learning inbox path is not a directory: '$directory'."
            }
        }
        elseif ($PSCmdlet.ShouldProcess($directory, 'Create promotion proposal directory'))
        {
            New-Item -ItemType Directory -Path $directory -ErrorAction Stop |
                Out-Null
        }
    }

    $absoluteProposalPath = Assert-LearningInboxRegularPath `
        -LiteralPath (
            Join-Path $context.RepositoryRoot (
                $proposalRelativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
            )
        ) `
        -RepositoryRoot $context.RepositoryRoot `
        -Field 'The promotion proposal'

    if ($PSCmdlet.ShouldProcess($absoluteProposalPath, 'Write promotion proposal'))
    {
        Write-LearningInboxFile `
            -LiteralPath $absoluteProposalPath `
            -Content ($proposal | ConvertTo-Json -Depth 12)
    }
}

return $proposal