extensions/specrew-speckit/validators/handoff-governance-validator.ps1
|
[CmdletBinding()] param( [AllowEmptyString()] [string]$ResponseText = '', [string]$ProjectRoot = (Get-Location).Path, [string]$IterationPath, [string]$BoundaryName, [ValidateSet('auto', 'boundary-handoff', 'narration')] [string]$ResponseScope = 'auto', [ValidateSet('soft-warning', 'validation-fail')] [string]$BarePathBoundaryHandoffSeverity ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sharedGovernancePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'scripts\shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Missing shared governance helper '$sharedGovernancePath'." } . $sharedGovernancePath $governancePatterns = @( '(?i)before-implement', '(?i)hardening-gate', '(?i)approval ref', '(?i)implementation approval', '(?i)traceability', '(?i)schema', '\bFR-\d+\b', '\bTG-[A-Za-z0-9-]+\b', '(?i)\bgate\b', '(?i)\bvalidator\b' ) $placeholderUserActionPhrases = @( 'Nothing yet', 'No action needed', 'No action required', 'Nothing to do', 'No further action needed' ) $boundaryPhraseMap = [ordered]@{ 'planning' = @('planning boundary', 'authorize planning', 'enter planning') 'hardening-gate-and-implementation-auth' = @('hardening-gate-and-implementation-auth', 'hardening gate and implementation authorization') 'hardening-gate-signoff' = @('hardening-gate-signoff', 'hardening gate sign-off', 'hardening-gate sign-off') 'implementation' = @('implementation boundary', 'implementation authorization', 'authorize implementation', 'implementation') 'review-boundary' = @('review boundary', 'authorize review-boundary', 'enter review-boundary', 'review-boundary') 'review-verdict-signoff' = @('review-verdict-signoff', 'review verdict sign-off', 'review-verdict signoff') 'retro-boundary' = @('retro boundary', 'retrospective boundary', 'retro-boundary') 'iteration-closeout' = @('iteration closeout', 'iteration-closeout') 'feature-closeout' = @('feature closeout', 'feature-closeout') } function Get-NormalizedText { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return '' } $normalized = $Text -replace "`r`n", "`n" $normalized = $normalized -replace "[`t ]+", ' ' $normalized = $normalized -replace " *`n *", "`n" return $normalized.Trim() } function Get-LeadSentence { param([AllowEmptyString()][string]$Section) if ([string]::IsNullOrWhiteSpace($Section)) { return '' } $lines = $Section -split "`n" $skipPatterns = @( '^(?:\*\*)?(Current progress status|Recommended next step|Owner|Reference)(?:\*\*)?$', '^[#>*`\-\s]+$' ) foreach ($line in $lines) { $trimmed = $line.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } $shouldSkip = $false foreach ($pattern in $skipPatterns) { if ($trimmed -match $pattern) { $shouldSkip = $true break } } if ($shouldSkip) { continue } $candidate = ($trimmed -replace '^\*+', '') -replace '\*+$', '' $sentenceMatch = [regex]::Match($candidate, '^.+?(?:[.!?](?:\s|$)|$)') if ($sentenceMatch.Success) { return $sentenceMatch.Value.Trim() } return $candidate.Trim() } return '' } function Get-GovernanceHitCount { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return 0 } $hitCount = 0 foreach ($pattern in $governancePatterns) { $hitCount += [regex]::Matches($Text, $pattern).Count } return $hitCount } function Test-HasPlainLanguageParaphrase { param( [AllowEmptyString()][string]$Lead, [int]$GovernanceHitCount ) if ($GovernanceHitCount -lt 3) { return $false } $leadLower = $Lead.ToLowerInvariant() foreach ($prefix in @('we need', 'you need', 'review ', 'approve', 'confirm', 'run ', 'manually', 'updated', 'completed', 'finished', 'i updated', 'i completed', 'i finished', 'this slice', 'next step', 'provide')) { if ($leadLower.StartsWith($prefix)) { return $true } } return $false } function Test-HasExplicitProgressStatus { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $false } foreach ($pattern in @('(?i)\b(completed|updated|implemented|changed|reviewed|verified|created|recorded|fixed|finished|added)\b', '(?i)no files changed', '(?i)\b(blocked|waiting|pending|stopped|open|remaining)\b')) { if ($Text -match $pattern) { return $true } } return $false } function Test-HasExplicitNextStep { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $false } foreach ($pattern in @('(?i)\bnext step\b', '(?i)\b(no further action needed)\b', '(?i)\b(review|approve|confirm|run|provide|reply|authorize|sign off|continue|restart)\b')) { if ($Text -match $pattern) { return $true } } return $false } function Test-MentionsBlockerOrRisk { param([AllowEmptyString()][string]$Text) return -not [string]::IsNullOrWhiteSpace($Text) -and ($Text -match '(?i)\b(blocked|failed|skipped|risk|deferred|pending)\b|(?<!non-)blocking\b') } function Test-PlainlyDisclosesBlockerOrRisk { param([AllowEmptyString()][string]$Text) return -not [string]::IsNullOrWhiteSpace($Text) -and ($Text -match '(?i)\b(because|cannot|needs|waiting|until|confidence|risk|blocked|skipped|failed)\b') } function Test-HasFileUri { param([AllowEmptyString()][string]$Text) return -not [string]::IsNullOrWhiteSpace($Text) -and ($Text -match '(?i)file:///') } function Test-HasWindowsAbsolutePath { param([AllowEmptyString()][string]$Text) return -not [string]::IsNullOrWhiteSpace($Text) -and ($Text -match '(?i)\b[A-Z]:[\\/][^\s`]+' ) } function Test-HasReviewCue { param([AllowEmptyString()][string]$Text) return -not [string]::IsNullOrWhiteSpace($Text) -and ($Text -match '(?i)\breview\b') } function Test-MissingReviewFileReference { param([AllowEmptyString()][string]$Text) if (-not (Test-HasReviewCue -Text $Text) -or -not (Test-HasWindowsAbsolutePath -Text $Text)) { return $false } return -not (Test-HasFileUri -Text $Text) } function Test-UsesStopMessageFormat { param([hashtable]$SectionMap) return $SectionMap.Contains('What I just did') -and $SectionMap.Contains('Why I stopped') -and $SectionMap.Contains('What I need from you') } function Test-HasEmptyUserActionSection { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $true } foreach ($phrase in $placeholderUserActionPhrases) { if ($Text -match ('(?i)^\s*' + [regex]::Escape($phrase) + '\s*$')) { return $true } } return -not ($Text -match '(?i)\b(review|approve|confirm|run|provide|reply|authorize|sign off|restart|test)\b') } function Test-HasTransitionalStopClaim { param( [AllowEmptyString()][string]$WhyStoppedText, [AllowEmptyString()][string]$UserActionText ) if ([string]::IsNullOrWhiteSpace($WhyStoppedText)) { return $false } $looksTransitional = $WhyStoppedText -match '(?i)\b(waiting|in progress|still running|background|transition|paused for now|slice is complete)\b' $hasSubstantiveAction = -not (Test-HasEmptyUserActionSection -Text $UserActionText) return $looksTransitional -and -not $hasSubstantiveAction } function Get-AuthoredParagraphs { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return @() } $normalized = $Text -replace "`r`n", "`n" $normalized = [regex]::Replace($normalized, '(?ms)```.*?```', '') $normalized = [regex]::Replace($normalized, '(?m)^\s*>.+$', '') return @($normalized -split '(?:\n\s*\n)+' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } function Get-OpaqueReferenceCount { param([AllowEmptyString()][string]$Paragraph) if ([string]::IsNullOrWhiteSpace($Paragraph)) { return 0 } $referencePattern = '(?i)\b(?:feature\s+\d+|iteration\s+\d+|T\d{3,}|FR-\d+|TG-[A-Za-z0-9-]+|[0-9a-f]{7,40})\b' $referenceCount = [regex]::Matches($Paragraph, $referencePattern).Count if ($referenceCount -lt 3) { return 0 } $descriptorPatterns = @( '(?i)\bfeature\s+\d+,\s+\w', '(?i)\biteration\s+\d+,\s+\w', '(?i)\(T\d{3,}\s+(?:and|through|-)\s*T\d{3,}\)', '(?i)\bT\d{3,}(?:\s+(?:and|through|-)\s*T\d{3,})?,\s+(?!T\d|FR-\d|TG-|[0-9a-f]{7,40}\b)(?:the|\w{3,})', '(?i)\(FR-\d+\s+(?:and|through|-)\s*FR-\d+\)', '(?i)\bFR-\d+(?:\s+(?:and|through|-)\s*FR-\d+)?,\s+(?!T\d|FR-\d|TG-|[0-9a-f]{7,40}\b)(?:the|\w{3,})', '(?i)\bTG-[A-Za-z0-9-]+,\s+\w', '(?i)\b[0-9a-f]{7,40},\s+(?!T\d|FR-\d|TG-|[0-9a-f]{7,40}\b)(?:the|\w{3,})' ) $describedPatternCount = 0 foreach ($pattern in $descriptorPatterns) { if ($Paragraph -match $pattern) { $describedPatternCount++ } } if ($describedPatternCount -ge 2) { return 0 } return $referenceCount } function Test-HasOpaqueReferenceWarning { param([AllowEmptyString()][string]$Text) foreach ($paragraph in (Get-AuthoredParagraphs -Text $Text)) { if ((Get-OpaqueReferenceCount -Paragraph $paragraph) -ge 3) { return $true } } return $false } function Get-ResolvedResponseScope { param( [string]$RequestedScope, [hashtable]$SectionMap ) if ($RequestedScope -ne 'auto') { return $RequestedScope } if (Test-UsesStopMessageFormat -SectionMap $SectionMap) { return 'boundary-handoff' } return 'narration' } function Get-IdentifierCount { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return 0 } $patterns = @( '(?i)\bFR-\d+\b', '(?i)\bT\d{3,}\b', '(?i)\b[a-f0-9]{7,40}\b', '(?i)\b(?:authorization|decision|sign-off)-[a-z0-9-]+\b', '(?i)file:///[^\s)]+' ) $count = 0 foreach ($pattern in $patterns) { $count += [regex]::Matches($Text, $pattern).Count } return $count } function Get-WordCount { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return 0 } return [regex]::Matches($Text, '\b[\p{L}\p{N}][\p{L}\p{N}\-/]*\b').Count } function Get-ExpectedBoundary { param( [string]$ResolvedProjectRoot, [string]$ResolvedIterationPath, [string]$ProvidedBoundaryName ) if (-not [string]::IsNullOrWhiteSpace($ProvidedBoundaryName)) { return Normalize-InteractionModelBoundaryName -Boundary $ProvidedBoundaryName } if ([string]::IsNullOrWhiteSpace($ResolvedIterationPath) -or -not (Test-Path -LiteralPath $ResolvedIterationPath -PathType Container)) { return $null } $statePath = Join-Path $ResolvedIterationPath 'state.md' if (Test-Path -LiteralPath $statePath -PathType Leaf) { $stateLines = @(Get-MarkdownContent -Path $statePath) foreach ($label in @('Current Boundary', 'Next Boundary')) { $value = Get-MarkdownMetadataValue -Lines $stateLines -Label $label if (-not [string]::IsNullOrWhiteSpace($value)) { return Normalize-InteractionModelBoundaryName -Boundary $value } } $canonicalStateText = @( Get-MarkdownMetadataValue -Lines $stateLines -Label 'Current Phase' Get-MarkdownMetadataValue -Lines $stateLines -Label 'Iteration Status' ) -join ' ' if (-not [string]::IsNullOrWhiteSpace($canonicalStateText)) { $normalizedStateText = $canonicalStateText.ToLowerInvariant() $boundaryPatterns = @( @{ Pattern = '(?i)\breview-verdict-signoff\b|review verdict sign-?off'; Boundary = 'review-verdict-signoff' } @{ Pattern = '(?i)\breview-boundary\b|\breview boundary\b|\breview authorization\b'; Boundary = 'review-boundary' } @{ Pattern = '(?i)\bretro-boundary\b|\bretrospective boundary\b|\bretrospective\b'; Boundary = 'retro-boundary' } @{ Pattern = '(?i)\biteration-closeout\b|\biteration closeout\b|\bcloseout boundary\b'; Boundary = 'iteration-closeout' } @{ Pattern = '(?i)\bhardening-gate-signoff\b|\bhardening gate sign-?off\b'; Boundary = 'hardening-gate-signoff' } @{ Pattern = '(?i)\bhardening-gate-and-implementation-auth\b|\bhardening gate and implementation authorization\b'; Boundary = 'hardening-gate-and-implementation-auth' } @{ Pattern = '(?i)\bimplementation\b|\bimplementation authorization\b'; Boundary = 'implementation' } @{ Pattern = '(?i)\bplanning\b'; Boundary = 'planning' } ) foreach ($candidate in $boundaryPatterns) { if ($normalizedStateText -match $candidate.Pattern) { return $candidate.Boundary } } } } $planPath = Join-Path $ResolvedIterationPath 'plan.md' if (-not (Test-Path -LiteralPath $planPath -PathType Leaf)) { return $null } $planLines = @(Get-MarkdownContent -Path $planPath) $status = (Get-MarkdownMetadataValue -Lines $planLines -Label 'Status') switch (($status | ForEach-Object { if ($null -eq $_) { '' } else { $_.ToLowerInvariant() } })) { 'planning' { return 'planning' } 'executing' { return 'implementation' } 'reviewing' { return 'review-boundary' } 'retro' { return 'retro-boundary' } 'complete' { return 'iteration-closeout' } default { return $null } } } function Test-MentionsBoundary { param( [AllowEmptyString()][string]$Text, [AllowNull()][string]$Boundary ) $normalizedBoundary = Normalize-InteractionModelBoundaryName -Boundary $Boundary if ([string]::IsNullOrWhiteSpace($Text) -or [string]::IsNullOrWhiteSpace($normalizedBoundary)) { return $false } if (-not $boundaryPhraseMap.Contains($normalizedBoundary)) { return $Text.ToLowerInvariant().Contains($normalizedBoundary) } foreach ($phrase in $boundaryPhraseMap[$normalizedBoundary]) { if ($Text -match ('(?i)\b' + [regex]::Escape($phrase) + '\b')) { return $true } } return $false } function Test-HasVerdictCue { param([AllowEmptyString()][string]$Text) return -not [string]::IsNullOrWhiteSpace($Text) -and ($Text -match '(?i)\b(approve|reject|authorize|sign off|confirm|accept|decline|pass|needs-work|blocked)\b') } function Get-MissingUserRequestComponents { param( [AllowEmptyString()][string]$Text, [AllowNull()][string]$ExpectedBoundary ) $missing = New-Object System.Collections.Generic.List[string] if (-not (Test-MentionsBoundary -Text $Text -Boundary $ExpectedBoundary)) { $missing.Add('boundary-name') | Out-Null } if (-not (Test-HasFileUri -Text $Text)) { $missing.Add('inspection-target') | Out-Null } if (-not (Test-HasVerdictCue -Text $Text)) { $missing.Add('verdict-required') | Out-Null } return $missing.ToArray() } function Get-FileUriMatches { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return @() } return @([regex]::Matches($Text, '(?i)file:///[^\s)`"''<>]+') | ForEach-Object { $_.Value.TrimEnd(',', '.', ';', ':') }) } function Convert-FileUriToWindowsPath { param([Parameter(Mandatory = $true)][string]$FileUri) try { $uri = [System.Uri]$FileUri return [System.Uri]::UnescapeDataString($uri.LocalPath) } catch { return $null } } function Get-BrokenFileUriFindings { param([AllowEmptyString()][string]$Text) $findings = New-Object System.Collections.Generic.List[string] foreach ($fileUri in (Get-FileUriMatches -Text $Text | Select-Object -Unique)) { $windowsPath = Convert-FileUriToWindowsPath -FileUri $fileUri if ([string]::IsNullOrWhiteSpace($windowsPath) -or -not (Test-Path -LiteralPath $windowsPath)) { $findings.Add("soft-warning.broken-file-url-reference :: reference=$fileUri") | Out-Null } } return $findings.ToArray() } function Get-PathScanLines { param([AllowEmptyString()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return @() } $withoutCodeBlocks = [regex]::Replace(($Text -replace "`r`n", "`n"), '(?ms)```.*?```', '') $withoutInlineCode = [regex]::Replace($withoutCodeBlocks, '(?s)`[^`\r\n]+`', '') $withoutUrls = [regex]::Replace($withoutInlineCode, '(?i)\b[a-z][a-z0-9+\.-]*://[^\s)]+', '') return @($withoutUrls -split "`n") } function Test-IsExemptPathLine { param([AllowEmptyString()][string]$Line) if ([string]::IsNullOrWhiteSpace($Line)) { return $true } $trimmed = $Line.Trim() if ($trimmed -match '^(?i)(?:ps>|cmd>|info:|warn:|error:|trace:|\[[A-Z]+\])') { return $true } if ($trimmed -match '^\s*[\{\[].*[:=].*[\\/].*[\}\]]?\s*$') { return $true } if ($trimmed -match '^\s*[\w\.\-"]+\s*:\s*["'']?.+[\\/].*$') { return $true } if ($trimmed -match '^\s*/.+/[a-z]*\s*$') { return $true } if ($trimmed -match '(?i)\b(?:git|pwsh|powershell|npm|node|python|go|dotnet|gh|curl|Get-ChildItem|Select-String|Resolve-Path)\b.+(?:[\\/]|[\*\?])') { return $true } return $false } function Get-BarePathMatches { param( [AllowEmptyString()][string]$Text, [AllowEmptyCollection()][object[]]$ExemptionExtensions ) $pattern = '(?i)(?<path>(?:[A-Z]:[\\/]|\.{1,2}[\\/]|(?:[A-Za-z0-9_.-]+[\\/])+[A-Za-z0-9_.-]+)(?:[\\/][A-Za-z0-9_.-]+)*)' $matches = New-Object System.Collections.Generic.List[string] foreach ($line in (Get-PathScanLines -Text $Text)) { if (Test-IsExemptPathLine -Line $line) { continue } foreach ($match in [regex]::Matches($line, $pattern)) { $candidate = $match.Groups['path'].Value if ([string]::IsNullOrWhiteSpace($candidate)) { continue } $isExtensionExempt = $false foreach ($extension in @($ExemptionExtensions)) { if (-not [string]::IsNullOrWhiteSpace([string]$extension.Pattern) -and $candidate -match [string]$extension.Pattern) { $isExtensionExempt = $true break } } if (-not $isExtensionExempt) { $matches.Add($candidate) | Out-Null } } } return @($matches | Select-Object -Unique) } $normalizedText = Get-NormalizedText -Text $ResponseText $sectionMap = Get-InteractionModelSectionMap -Text $normalizedText $resolvedResponseScope = Get-ResolvedResponseScope -RequestedScope $ResponseScope -SectionMap $sectionMap $resolvedProjectRoot = Resolve-ProjectPath -Path $ProjectRoot $resolvedIterationPath = if ([string]::IsNullOrWhiteSpace($IterationPath)) { $null } else { Resolve-ProjectPath -Path $IterationPath } $settings = Get-InteractionModelSettings -ProjectRoot $resolvedProjectRoot $effectiveBarePathSeverity = if ($PSBoundParameters.ContainsKey('BarePathBoundaryHandoffSeverity')) { $BarePathBoundaryHandoffSeverity } else { $settings.BarePathBoundaryHandoffSeverity } $expectedBoundary = Get-ExpectedBoundary -ResolvedProjectRoot $resolvedProjectRoot -ResolvedIterationPath $resolvedIterationPath -ProvidedBoundaryName $BoundaryName $hasInteractionBoundaryContext = -not [string]::IsNullOrWhiteSpace($expectedBoundary) -or -not [string]::IsNullOrWhiteSpace($BoundaryName) -or -not [string]::IsNullOrWhiteSpace($IterationPath) $warnings = New-Object System.Collections.Generic.List[string] $failures = New-Object System.Collections.Generic.List[string] $summaryLines = New-Object System.Collections.Generic.List[string] if (-not (Test-HasExplicitProgressStatus -Text $normalizedText)) { $warnings.Add('soft-warning.missing-progress-status') | Out-Null } if (-not (Test-HasExplicitNextStep -Text $normalizedText)) { $warnings.Add('soft-warning.missing-next-step') | Out-Null } if (Test-MissingReviewFileReference -Text $normalizedText) { $warnings.Add('soft-warning.review-file-reference-format') | Out-Null } if (Test-HasOpaqueReferenceWarning -Text $normalizedText) { $warnings.Add('soft-warning.opaque-numeric-references') | Out-Null } foreach ($section in (Get-InteractionModelSections -Text $normalizedText)) { $lead = Get-LeadSentence -Section $section if ([string]::IsNullOrWhiteSpace($lead)) { continue } $governanceHitCount = Get-GovernanceHitCount -Text $lead if ($governanceHitCount -ge 3 -and -not (Test-HasPlainLanguageParaphrase -Lead $lead -GovernanceHitCount $governanceHitCount)) { if (-not $warnings.Contains('soft-warning.jargon-first-lead')) { $warnings.Add('soft-warning.jargon-first-lead') | Out-Null } } } if ((Test-MentionsBlockerOrRisk -Text $normalizedText) -and -not (Test-PlainlyDisclosesBlockerOrRisk -Text $normalizedText)) { $warnings.Add('soft-warning.hidden-blocker-or-risk') | Out-Null } if (Test-UsesStopMessageFormat -SectionMap $sectionMap) { $whatIJustDid = [string]$sectionMap['What I just did'] $whyStopped = [string]$sectionMap['Why I stopped'] $whatINeed = [string]$sectionMap['What I need from you'] if (Test-HasEmptyUserActionSection -Text $whatINeed) { $warnings.Add('soft-warning.empty-user-action-section') | Out-Null } if (Test-HasTransitionalStopClaim -WhyStoppedText $whyStopped -UserActionText $whatINeed) { $warnings.Add('soft-warning.transitional-stop-claim') | Out-Null } if ($resolvedResponseScope -eq 'boundary-handoff' -and $hasInteractionBoundaryContext) { $identifierCount = Get-IdentifierCount -Text $whatIJustDid $wordCount = Get-WordCount -Text $whatIJustDid $isCloseoutBoundary = $expectedBoundary -in @('iteration-closeout', 'feature-closeout') $meetsSubstance = if ($isCloseoutBoundary) { $identifierCount -ge 3 -or $wordCount -ge 50 } else { $identifierCount -ge 3 -and $wordCount -ge 50 } if (-not $meetsSubstance) { $warnings.Add(("soft-warning.thin-what-i-just-did :: boundary={0}; identifiers={1}; words={2}" -f $(if ([string]::IsNullOrWhiteSpace($expectedBoundary)) { 'unknown' } else { $expectedBoundary }), $identifierCount, $wordCount)) | Out-Null } if (-not (Test-MentionsBoundary -Text $whyStopped -Boundary $expectedBoundary)) { $warnings.Add(("soft-warning.unspecific-stop-boundary :: expected-boundary={0}" -f $(if ([string]::IsNullOrWhiteSpace($expectedBoundary)) { 'unknown' } else { $expectedBoundary }))) | Out-Null } $missingComponents = @(Get-MissingUserRequestComponents -Text $whatINeed -ExpectedBoundary $expectedBoundary) if ($missingComponents.Count -gt 0) { $warnings.Add(("soft-warning.unactionable-user-request :: missing={0}" -f ($missingComponents -join ','))) | Out-Null } } } foreach ($configIssue in @($settings.ConfigIssues)) { $warnings.Add("soft-warning.bare-path-config-issue :: $configIssue") | Out-Null } foreach ($brokenFileUri in (Get-BrokenFileUriFindings -Text $normalizedText)) { $warnings.Add($brokenFileUri) | Out-Null } $barePathMatches = @(Get-BarePathMatches -Text $normalizedText -ExemptionExtensions $settings.ExemptionExtensions) if ($barePathMatches.Count -gt 0) { $detail = $barePathMatches -join ', ' if ($resolvedResponseScope -eq 'boundary-handoff' -and $hasInteractionBoundaryContext) { $ruleId = "{0}.bare-path-in-boundary-handoff" -f $effectiveBarePathSeverity $finding = "$ruleId :: paths=$detail" if ($effectiveBarePathSeverity -eq 'validation-fail') { $failures.Add($finding) | Out-Null } else { $warnings.Add($finding) | Out-Null } } elseif ($resolvedResponseScope -eq 'narration') { $warnings.Add("soft-warning.bare-path-in-narration :: paths=$detail") | Out-Null } } if ($warnings.Contains('soft-warning.jargon-first-lead')) { $summaryLines.Add('Rewrite the lead sentence in plain language before formal lifecycle references.') | Out-Null } if ($warnings.Contains('soft-warning.missing-progress-status')) { $summaryLines.Add('Add an explicit current progress status statement.') | Out-Null } if ($warnings.Contains('soft-warning.missing-next-step')) { $summaryLines.Add('Add a single explicit next step.') | Out-Null } if ($warnings.Contains('soft-warning.hidden-blocker-or-risk')) { $summaryLines.Add('State blockers or verification gaps plainly when they exist.') | Out-Null } if ($warnings.Contains('soft-warning.review-file-reference-format')) { $summaryLines.Add('Include a file:/// URI with the absolute Windows path when requesting local file review.') | Out-Null } if ($warnings.Contains('soft-warning.opaque-numeric-references')) { $summaryLines.Add('Add descriptive scope when three or more feature, iteration, task, requirement, corpus, or commit references appear in authored prose.') | Out-Null } if ($warnings.Contains('soft-warning.empty-user-action-section')) { $summaryLines.Add('Replace empty or placeholder user-action wording with one substantive immediate human action.') | Out-Null } if ($warnings.Contains('soft-warning.transitional-stop-claim')) { $summaryLines.Add('Use the three-section stop-message format only for real human blockers; keep transitional waiting updates as single-line progress prose.') | Out-Null } if (@($warnings | Where-Object { $_ -like 'soft-warning.thin-what-i-just-did*' }).Count -gt 0) { $summaryLines.Add('Strengthen "What I just did" with at least three concrete identifiers and enough words for the active boundary.') | Out-Null } if (@($warnings | Where-Object { $_ -like 'soft-warning.unspecific-stop-boundary*' }).Count -gt 0) { $summaryLines.Add('Name the exact boundary in "Why I stopped" and keep it aligned with the active iteration state.') | Out-Null } if (@($warnings | Where-Object { $_ -like 'soft-warning.unactionable-user-request*' }).Count -gt 0) { $summaryLines.Add('Name the boundary, provide file:/// inspection targets, and request an explicit verdict in "What I need from you".') | Out-Null } if (@($warnings | Where-Object { $_ -like 'soft-warning.bare-path-in-*' }).Count -gt 0) { $summaryLines.Add('Replace bare paths with file:/// URIs unless the path is inside an approved exempt context.') | Out-Null } if (@($warnings | Where-Object { $_ -like 'soft-warning.broken-file-url-reference*' }).Count -gt 0) { $summaryLines.Add('Repair or remove file:/// references that do not resolve to existing files.') | Out-Null } if ($failures.Count -gt 0) { $summaryLines.Add('Hard validation findings block this handoff until the referenced path or boundary issue is repaired.') | Out-Null } if ($summaryLines.Count -eq 0) { $summaryLines.Add('No soft warnings.') | Out-Null } $status = if ($failures.Count -gt 0) { 'fail' } elseif ($warnings.Count -gt 0) { 'warn' } else { 'pass' } Write-Output ("status: {0}" -f $status) Write-Output 'findings:' if ($failures.Count -eq 0 -and $warnings.Count -eq 0) { Write-Output ' - none' } else { foreach ($finding in @($failures + $warnings)) { Write-Output (" - {0}" -f $finding) } } Write-Output 'summary:' foreach ($summaryLine in $summaryLines) { Write-Output (" - {0}" -f $summaryLine) } if ($failures.Count -gt 0) { exit 1 } exit 0 |