skills/reviewed-learning-inbox/scripts/Add-LearningCandidate.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Records a reusable-lesson candidate in a project's reviewed learning inbox. .DESCRIPTION Stores a sanitized summary of a lesson observed in explicitly supplied local artifacts, together with project-relative evidence locators and their SHA-256 content identity. Observations, interpretations, and contradictory evidence are kept apart. An equivalent lesson is deduplicated onto the existing candidate and never resets a recorded rejection. Nothing recorded here changes agent behavior; a candidate is an untrusted observation until a human approves a concrete promotion preview. .PARAMETER Path Existing project directory whose Memory Bank holds the learning inbox. .PARAMETER Lesson Concise proposed lesson, stored as single-line prose. .PARAMETER ScopeKind Intended scope: Skill, Instruction, NewSkill, NewInstruction, or NewAgent. .PARAMETER Target Project-relative destination for a Skill or Instruction scope. .PARAMETER Overlap Required explanation of overlap with existing Customizations for a New scope. .PARAMETER Observation Facts that were observed. Keep interpretation out of these. .PARAMETER Interpretation Inferences drawn from the observations. .PARAMETER Contradiction Evidence that argues against the proposed lesson. .PARAMETER Evidence Locators as hashtables with Path and optional Lines and Summary keys. .PARAMETER Confidence Review label only: Low, Medium, or High. Never a probability. .PARAMETER ReferenceTime UTC time recorded on the candidate. .EXAMPLE ./Add-LearningCandidate.ps1 -Path C:/Git/MyProject -ScopeKind Skill ` -Target skills/example/SKILL.md -Lesson 'Prefer the shared guard.' ` -Observation 'The regression names the shared guard.' ` -Evidence @(@{ Path = 'tests/Guard.Tests.ps1'; Lines = '10-20' }) Records one candidate for later review. .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]$Lesson, [Parameter(Mandatory)] [ValidateSet('Skill', 'Instruction', 'NewSkill', 'NewInstruction', 'NewAgent')] [string]$ScopeKind, [Parameter()] [AllowEmptyString()] [string]$Target = '', [Parameter()] [AllowEmptyString()] [string]$Overlap = '', [Parameter()] [ValidateNotNull()] [string[]]$Observation = @(), [Parameter()] [ValidateNotNull()] [string[]]$Interpretation = @(), [Parameter()] [ValidateNotNull()] [string[]]$Contradiction = @(), [Parameter()] [ValidateNotNull()] [hashtable[]]$Evidence = @(), [Parameter()] [ValidateSet('Low', 'Medium', 'High')] [string]$Confidence = 'Low', [Parameter()] [ValidateNotNull()] [datetime]$ReferenceTime = [datetime]::UtcNow ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'LearningInboxCommon.ps1') $context = Resolve-LearningInboxContext -Path $Path $timestamp = $ReferenceTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $isNewCustomization = $ScopeKind -like 'New*' $scopeTarget = '' $scopeOverlap = '' if ($isNewCustomization) { if ([string]::IsNullOrWhiteSpace($Overlap)) { throw 'Overlap with existing Customizations must be explained before proposing a new Skill, Instruction, or Custom agent. Improving an existing Skill is the preferred outcome.' } $scopeOverlap = Assert-LearningInboxText -Text $Overlap -Field 'Overlap' -MaximumLength 300 } else { $scopeTarget = Assert-LearningInboxPromotionTarget ` -Target $Target ` -ScopeKind $ScopeKind ` -RepositoryRoot $context.RepositoryRoot if (-not [string]::IsNullOrWhiteSpace($Overlap)) { $scopeOverlap = Assert-LearningInboxText -Text $Overlap -Field 'Overlap' -MaximumLength 300 } } $lessonText = Assert-LearningInboxText -Text $Lesson -Field 'Lesson' -MaximumLength 400 $observationText = @( foreach ($item in $Observation) { Assert-LearningInboxText -Text $item -Field 'Observation' -MaximumLength 300 } ) if ($observationText.Count -eq 0) { throw 'At least one observation is required. A candidate without an observed fact is an opinion, not evidence.' } $interpretationText = @( foreach ($item in $Interpretation) { Assert-LearningInboxText -Text $item -Field 'Interpretation' -MaximumLength 300 } ) $contradictionText = @( foreach ($item in $Contradiction) { Assert-LearningInboxText -Text $item -Field 'Contradictory evidence' -MaximumLength 300 } ) if ($Evidence.Count -gt 10) { throw 'A candidate may carry at most ten evidence locators. Summarize instead of accumulating.' } $evidenceEntry = @( foreach ($item in $Evidence) { if (-not $item.ContainsKey('Path')) { throw 'Every evidence entry requires a Path key naming a project-relative file.' } $relativePath = Assert-LearningInboxRelativePath ` -RelativePath ([string]$item['Path']) ` -Field 'Evidence path' ` -RepositoryRoot $context.RepositoryRoot $absolutePath = Join-Path $context.RepositoryRoot ( $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar) ) if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) { throw "Evidence file not found in the selected project: '$relativePath'." } if (Test-LearningInboxReparsePoint -LiteralPath $absolutePath) { throw "Evidence must not be a symbolic link or reparse point: '$relativePath'." } $lines = if ($item.ContainsKey('Lines')) { [string]$item['Lines'] } else { '' } if (-not [string]::IsNullOrWhiteSpace($lines) -and $lines -notmatch '^\d+(-\d+)?$') { throw "Evidence line range must be a single number or a numeric range: '$lines'." } $summary = if ($item.ContainsKey('Summary') -and -not [string]::IsNullOrWhiteSpace([string]$item['Summary'])) { Assert-LearningInboxText -Text ([string]$item['Summary']) -Field 'Evidence summary' -MaximumLength 300 } else { '' } [PSCustomObject][ordered]@{ path = $relativePath sha256 = Get-LearningInboxFileHash -LiteralPath $absolutePath lines = $lines summary = $summary } } ) $scopeKey = '{0}|{1}' -f $ScopeKind, $scopeTarget $candidateId = Get-LearningInboxCandidateId ` -ProjectId $context.ProjectId ` -ScopeKey $scopeKey ` -Lesson $lessonText $store = Read-LearningInboxStore -Context $context if ($null -eq $store) { $store = [PSCustomObject][ordered]@{ schemaVersion = $script:LearningInboxSchemaVersion projectId = $context.ProjectId repositoryRoot = $context.RepositoryRoot updatedUtc = $timestamp candidates = @() } } $existing = @( $store.candidates | Where-Object -FilterScript { [string]$_.id -eq $candidateId } ) if ($existing.Count -gt 0) { $candidate = $existing[0] $action = 'Deduplicated' $candidate.occurrences = [int]$candidate.occurrences + 1 $candidate.updatedUtc = $timestamp $candidate.observations = @( @($candidate.observations) + $observationText | Select-Object -Unique ) $candidate.interpretations = @( @($candidate.interpretations) + $interpretationText | Select-Object -Unique ) $candidate.contradictions = @( @($candidate.contradictions) + $contradictionText | Select-Object -Unique ) $knownEvidence = @( @($candidate.evidence) | ForEach-Object -Process { '{0}|{1}' -f $_.path, $_.sha256 } ) $candidate.evidence = @( @($candidate.evidence) + @( $evidenceEntry | Where-Object -FilterScript { ('{0}|{1}' -f $_.path, $_.sha256) -notin $knownEvidence } ) ) } else { $action = 'Created' $candidate = [PSCustomObject][ordered]@{ id = $candidateId lesson = $lessonText scope = [PSCustomObject][ordered]@{ kind = $ScopeKind target = $scopeTarget overlap = $scopeOverlap } evidence = $evidenceEntry observations = $observationText interpretations = $interpretationText contradictions = $contradictionText confidence = $Confidence confidenceNote = $script:LearningInboxConfidenceNote status = 'New' supersededBy = '' supersedes = @() occurrences = 1 createdUtc = $timestamp updatedUtc = $timestamp decision = $null promotion = $null } $store.candidates = @(@($store.candidates) + $candidate) } $store.updatedUtc = $timestamp if ($PSCmdlet.ShouldProcess($context.StorePath, "Record learning candidate '$candidateId'")) { Write-LearningInboxStore -Context $context -Store $store } return [PSCustomObject]@{ Action = $action Id = $candidateId StorePath = $context.StoreRelativePath Candidate = $candidate } |