scripts/internal/design-analysis-gate.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:SpecrewDesignAnalysisGateMinimumVersion = [version]'0.30.0' $script:SpecrewDesignAnalysisDefaultIterationNumber = '001' function Normalize-SpecrewDesignAnalysisFeatureRef { param([AllowNull()][string]$FeatureRef) if ([string]::IsNullOrWhiteSpace($FeatureRef)) { return $null } $trimmed = $FeatureRef.Trim() if ($trimmed -match '^[A-Za-z]:\\' -or $trimmed.StartsWith('\\') -or $trimmed -match '^specs[\\/]') { return Split-Path -Leaf $trimmed } return $trimmed } function Normalize-SpecrewDesignAnalysisIterationNumber { param([AllowNull()][string]$IterationNumber) if ([string]::IsNullOrWhiteSpace($IterationNumber) -or $IterationNumber -eq '(none)') { return $script:SpecrewDesignAnalysisDefaultIterationNumber } if ($IterationNumber -match '^\d+$') { return ([int]$IterationNumber).ToString('000') } return $IterationNumber.Trim() } function Get-SpecrewDesignAnalysisArtifactPath { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef $iteration = Normalize-SpecrewDesignAnalysisIterationNumber -IterationNumber $IterationNumber return Join-Path $ProjectRoot ("specs\{0}\iterations\{1}\design-analysis.md" -f $feature, $iteration) } function Get-SpecrewDesignAnalysisConfigVersion { param([Parameter(Mandatory = $true)][string]$ProjectRoot) $configPath = Join-Path $ProjectRoot '.specrew\config.yml' if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { return $null } $content = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8 if ($content -notmatch '(?m)^\s*specrew_version:\s*["'']?(?<version>\d+\.\d+\.\d+)') { return $null } try { return [version]$Matches['version'] } catch { return $null } } function Get-SpecrewDesignAnalysisStartContext { param([Parameter(Mandatory = $true)][string]$ProjectRoot) $contextPath = Join-Path $ProjectRoot '.specrew\start-context.json' if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { return $null } try { return Get-Content -LiteralPath $contextPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 20 } catch { return $null } } function Test-SpecrewDesignAnalysisSubstantiveFeature { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef $specPath = Join-Path $ProjectRoot ("specs\{0}\spec.md" -f $feature) if (-not (Test-Path -LiteralPath $specPath -PathType Leaf)) { return $false } $specText = Get-Content -LiteralPath $specPath -Raw -Encoding UTF8 $trivialSignal = $specText -match '(?i)\b(trivial|doc-only|documentation-only|small bug[- ]?fix|small chore|minor copy|typo-only)\b' $substantiveSignal = $specText -match '(?i)\b(lifecycle|governance|architecture|architectural|enforcement|boundary|state|helper|validator|validation|security|compatibility|integration)\b' if ($trivialSignal -and -not $substantiveSignal) { return $false } return $true } function Get-SpecrewDesignAnalysisSection { param( [Parameter(Mandatory = $true)][string]$Content, [Parameter(Mandatory = $true)][string[]]$HeadingPatterns ) foreach ($headingPattern in $HeadingPatterns) { $regex = "(?ims)^#{1,2}\s*(?:$headingPattern)\s*$\r?\n(?<body>.*?)(?=^#{1,2}\s+|\z)" $match = [regex]::Match($Content, $regex) if ($match.Success) { return [pscustomobject]@{ Found = $true Body = $match.Groups['body'].Value.Trim() } } } return [pscustomobject]@{ Found = $false Body = '' } } function Test-SpecrewDesignAnalysisPlaceholderText { param([AllowNull()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $true } $trimmed = $Text.Trim() return ($trimmed -match '^(?:tbd|todo|placeholder|n/a|none|pending|to be decided|to be filled)(?:\s|\.)*$') } function Get-SpecrewDesignAnalysisOptionBlock { param( [Parameter(Mandatory = $true)][string]$AlternativesText, [Parameter(Mandatory = $true)][string]$OptionName ) # FR-022: tolerate normal authored prose for By-the-book ("By the book" with or # without a hyphen) while still enforcing the required option shape. $namePattern = if ($OptionName -match '(?i)^by[-\s]?the[-\s]?book$') { 'By[-\s]+the[-\s]+book' } else { [regex]::Escape($OptionName) } $terminator = '(?:Simplest|Reasonable|By[-\s]+the[-\s]+book)' $regex = "(?ims)^#{2,6}\s*(?:Option\s+[A-Z]\s*[:\-]\s*)?$namePattern\b.*?\r?\n(?<body>.*?)(?=^#{2,6}\s+(?:Option\s+[A-Z]\s*[:\-]\s*)?$terminator\b|\z)" $match = [regex]::Match($AlternativesText, $regex) if (-not $match.Success) { return $null } return $match.Groups['body'].Value.Trim() } function Test-SpecrewDesignAnalysisOptionField { param( [Parameter(Mandatory = $true)][string]$Block, [Parameter(Mandatory = $true)][string]$FieldName ) $escaped = [regex]::Escape($FieldName) return ($Block -match "(?im)^\s*(?:[-*]\s*)?(?:\*\*)?$escaped(?:\*\*)?\s*[:\-]\s*\S") } function Get-SpecrewDesignAnalysisNamedOption { param([AllowNull()][string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } $matches = [regex]::Matches($Text, '(?i)\b(Option\s+[A-Z]|Simplest|Reasonable|By[-\s]?the[-\s]?book)\b') $unique = New-Object System.Collections.Generic.List[string] foreach ($match in $matches) { $value = $match.Groups[1].Value.Trim() $normalized = if ($value -match '(?i)^option\s+([A-Z])$') { 'Option ' + $Matches[1].ToUpperInvariant() } elseif ($value -match '(?i)^by[-\s]?the[-\s]?book$') { 'By-the-book' } elseif ($value -match '(?i)^simplest$') { 'Simplest' } elseif ($value -match '(?i)^reasonable$') { 'Reasonable' } else { $value } if (-not $unique.Contains($normalized)) { $unique.Add($normalized) | Out-Null } } if ($unique.Count -eq 1) { return $unique[0] } if ($unique.Count -gt 1) { return ($unique -join ', ') } return $null } function Get-SpecrewDesignAnalysisMarkedOption { # FR-023: resolve exactly one option from a section that carries an explicit # marker line (e.g., "Recommended: Option B" or "Chosen option: Option B"), # tolerating bold/list markup, so contextual mentions of rejected options in the # surrounding rationale do not make the section look multi-valued. Falls back to # the legacy whole-text single-token detection when no marker line is present. param( [AllowNull()][string]$Text, [Parameter(Mandatory = $true)][string]$Marker ) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } $pattern = '(?im)^\s*[-*>\s]*\**\s*' + $Marker + '\**\s*[:\-]?\s*\**\s*(?<opt>Option\s+[A-Z]|Simplest|Reasonable|By[-\s]?the[-\s]?book)\b' $markerMatch = [regex]::Match($Text, $pattern) if ($markerMatch.Success) { return (Get-SpecrewDesignAnalysisNamedOption -Text $markerMatch.Groups['opt'].Value) } return (Get-SpecrewDesignAnalysisNamedOption -Text $Text) } function Test-SpecrewDesignAnalysisGateRequired { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return $false } $artifactPath = Get-SpecrewDesignAnalysisArtifactPath -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $IterationNumber if (Test-Path -LiteralPath $artifactPath -PathType Leaf) { return $true } $version = Get-SpecrewDesignAnalysisConfigVersion -ProjectRoot $ProjectRoot if ($null -eq $version -or $version -lt $script:SpecrewDesignAnalysisGateMinimumVersion) { return $false } $context = Get-SpecrewDesignAnalysisStartContext -ProjectRoot $ProjectRoot if ($null -eq $context -or $null -eq $context.session_state) { return $false } if (-not [bool]$context.session_state.active) { return $false } $contextFeature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef ([string]$context.session_state.feature_ref) if ($contextFeature -ne $feature) { return $false } $currentBoundary = [string]$context.session_state.boundary_type $lastAuthorized = if ($null -ne $context.boundary_enforcement) { [string]$context.boundary_enforcement.last_authorized_boundary } else { '' } $isPrePlanActiveBoundary = @('specify', 'clarify', 'before-plan') -contains $currentBoundary -or @('specify', 'clarify', 'before-plan') -contains $lastAuthorized if (-not $isPrePlanActiveBoundary) { return $false } return (Test-SpecrewDesignAnalysisSubstantiveFeature -ProjectRoot $ProjectRoot -FeatureRef $feature) } function Test-SpecrewDesignAnalysisLensAddressedPlaceholder { # FR-026: an "Addressed:" coverage value does NOT count as addressing a lens when it is empty, a # TBD-class token, or the unfilled angle-bracket template default emitted by the enriched render. param([AllowNull()][string]$Value) if (Test-SpecrewDesignAnalysisPlaceholderText -Text $Value) { return $true } return ([string]$Value).Trim() -match '^<.*>$' } function Test-SpecrewDesignAnalysisLensCoverage { # FR-026 (Amendment A2): deterministic, LLM/network-free lens-coverage enforcement. For each lens # the FR-025 questionnaire selected (lens-applicability.json `selected`), the design analysis MUST # carry a non-placeholder "Addressed:" coverage entry in its "## Applicable Lenses" section; else # the gate blocks plan, naming the unaddressed lens. # # This is an ANTI-OMISSION backstop, NOT a quality guarantee: it proves no selected lens was # silently dropped from the analysis. It deliberately does NOT judge whether the engagement is # genuine — a deterministic check cannot. Genuine engagement is enforced by the human design- # analysis gate plus the blocking delete-the-`Addressed:`-lines discriminator at review-signoff. # # Enforcement is the DEFAULT whenever a questionnaire recorded selected lenses: absence of the # "Addressed:" entries FAILS (every selected lens is reported unaddressed). Grandfathering is # EXPLICIT, never inferred from missing "Addressed:" lines — a pre-FR-026 artifact must carry an # explicit `fr026_grandfathered: true` marker in its lens-applicability.json to be exempt. This # closes the deleting-all-`Addressed:`-lines bypass that would otherwise silently no-op the gate # (the gate-completeness hole found by the Proposal 145 Phase 5 review). Returns a string[] of # error messages (empty = OK / not applicable). param( [Parameter(Mandatory = $true)][string]$Content, [Parameter(Mandatory = $true)][string]$IterationDirectory ) $errors = New-Object System.Collections.Generic.List[string] # Selected lenses + the explicit grandfather marker both come from the recorded questionnaire # artifact (decoupled; the JSON is the audit record). Resolution order (A3 — the lens intake is # now feature/specify-phase truth, recorded once at the feature level, not copied per iteration): # 1. iterations/<NNN>/lens-applicability.json (override / Iteration 4-5 back-compat) # 2. feature-level lens-applicability.json (the specify-phase truth) # 3. neither present -> graceful no-op (SC-006) # Resolving (not copying) keeps a single source of truth and avoids drift between duplicates. $iterationArtifact = Join-Path $IterationDirectory 'lens-applicability.json' $featureDirectory = Split-Path -Parent (Split-Path -Parent $IterationDirectory) $featureArtifact = if (-not [string]::IsNullOrWhiteSpace($featureDirectory)) { Join-Path $featureDirectory 'lens-applicability.json' } else { $null } $answersPath = if (Test-Path -LiteralPath $iterationArtifact -PathType Leaf) { $iterationArtifact } elseif ($null -ne $featureArtifact -and (Test-Path -LiteralPath $featureArtifact -PathType Leaf)) { $featureArtifact } else { $null } if ($null -eq $answersPath) { return @() } $doc = $null try { $doc = Get-Content -LiteralPath $answersPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return @() } if ($null -eq $doc) { return @() } $selected = @() if ($doc.PSObject.Properties['selected']) { $selected = @($doc.selected | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } if ($selected.Count -eq 0) { return @() } # EXPLICIT grandfather (not inferred): a recorded `fr026_grandfathered: true` marker exempts a # pre-FR-026 artifact. Enforcement is otherwise the default, so deleting every "Addressed:" line # from an FR-026-era artifact does NOT no-op the gate — every selected lens is reported below. if ($doc.PSObject.Properties['fr026_grandfathered'] -and [bool]$doc.fr026_grandfathered) { return @() } $section = Get-SpecrewDesignAnalysisSection -Content $Content -HeadingPatterns @('Applicable\s+Lenses') $body = if ($section.Found) { $section.Body } else { '' } foreach ($id in $selected) { $blockRegex = '(?ims)^[-*]\s*\*\*' + [regex]::Escape($id) + '\*\*.*?(?=^[-*]\s*\*\*|\z)' $blockMatch = [regex]::Match($body, $blockRegex) $addressed = $null if ($blockMatch.Success) { $addressedMatch = [regex]::Match($blockMatch.Value, '(?im)^\s*[-*]?\s*Addressed\s*:\s*(?<v>.*)$') if ($addressedMatch.Success) { $addressed = $addressedMatch.Groups['v'].Value } } if ($null -eq $addressed -or (Test-SpecrewDesignAnalysisLensAddressedPlaceholder -Value $addressed)) { $errors.Add(("design-analysis.md does not address selected lens '{0}' (FR-026 anti-omission): add a non-placeholder 'Addressed:' coverage entry for it in the Applicable Lenses section, pointing into the option comparison." -f $id)) | Out-Null } } return $errors.ToArray() } function Test-SpecrewLensWorkshopRecordPlaceholder { # SC-021 (Amendment A4): a per-lens workshop field does NOT count as recorded when it is null/empty, # a TBD-class placeholder, or the angle-bracket template default. An array field (agenda) is a # placeholder when empty. param([AllowNull()]$Value) if ($null -eq $Value) { return $true } if (($Value -is [System.Collections.IEnumerable]) -and ($Value -isnot [string])) { return (@($Value).Count -eq 0) } $s = [string]$Value if (Test-SpecrewDesignAnalysisPlaceholderText -Text $s) { return $true } return $s.Trim() -match '^<.*>$' } function Test-SpecrewLensWorkshopRecords { # Iteration 7 (SC-021, Amendment A4): the deterministic per-lens-decision FLOOR under the behavioral # workshop. For each selected lens, the given intake artifact MUST carry a non-placeholder `workshop` # record with agenda (non-empty), decision/agreement (non-placeholder), depth (set), and an explicit # moved_on marker (truthy). Enforces PRESENCE only — it does NOT, and cannot, assess quality (that is # SC-020's runtime dogfood). Enforcement is gated by an explicit `workshop_intake: true` marker, so it # applies to A4 workshop runs and NEVER retroactively fails the pre-A4 questionnaire artifacts; # `fr026_grandfathered` also exempts. Takes the EXACT artifact path so the caller controls resolution: # the workshop records live in the FEATURE-level lens-applicability.json (the specify-phase truth, # FR-027) — NOT the iteration-level design-analysis questionnaire — so the specify-boundary gate passes # the feature-level path here. (i007 dogfood fix: the earlier iteration-first resolution no-opped on # the feature-level workshop artifact, so the floor never fired.) Returns a string[] of errors # (empty = OK / not applicable). param( [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$ArtifactPath ) $errors = New-Object System.Collections.Generic.List[string] if ([string]::IsNullOrWhiteSpace($ArtifactPath) -or -not (Test-Path -LiteralPath $ArtifactPath -PathType Leaf)) { return @() } $doc = $null try { $doc = Get-Content -LiteralPath $ArtifactPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return @() } if ($null -eq $doc) { return @() } if ($doc.PSObject.Properties['fr026_grandfathered'] -and [bool]$doc.fr026_grandfathered) { return @() } # SC-021 enforces ONLY for A4 workshop-era artifacts (explicit marker). A pre-A4 questionnaire # artifact (no marker) no-ops, so it is never retroactively failed. An A4 artifact that sets the # marker but omits the records CANNOT bypass the gate (the records are required below). $isWorkshop = $doc.PSObject.Properties['workshop_intake'] -and [bool]$doc.workshop_intake if (-not $isWorkshop) { return @() } $selected = @() if ($doc.PSObject.Properties['selected']) { $selected = @($doc.selected | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } if ($selected.Count -eq 0) { return @() } $workshop = if ($doc.PSObject.Properties['workshop']) { $doc.workshop } else { $null } # SC-026 (Amendment A7, #2212): when the artifact opts in via `confirmation_required: true`, each selected # lens MUST declare both a provenance value (human-confirmed | human-delegated | human-skipped) and its scope. # This prevents a generic lens-agenda approval from being reused as per-lens workshop-question confirmation. # Grandfather-safe: pre-A7 artifacts lack the marker and no-op (the `workshop_intake` precedent). The floor # enforces declared scoped evidence; it cannot verify transcript truthfulness (that is SC-027 dogfood/review). $confirmationRequired = $doc.PSObject.Properties['confirmation_required'] -and [bool]$doc.confirmation_required $validProvenance = @('human-confirmed', 'human-delegated', 'human-skipped') $requiredConfirmationScopes = @{ 'human-confirmed' = 'lens-question' 'human-delegated' = 'explicit-delegation' 'human-skipped' = 'explicit-skip' } foreach ($id in $selected) { $rec = $null if ($null -ne $workshop -and $workshop.PSObject.Properties[$id]) { $rec = $workshop.$id } if ($null -eq $rec) { $errors.Add(("lens-applicability.json has no workshop record for selected lens '{0}' (SC-021): record agenda + decision/agreement + depth + a moved_on marker." -f $id)) | Out-Null continue } $missing = New-Object System.Collections.Generic.List[string] $agenda = if ($rec.PSObject.Properties['agenda']) { $rec.agenda } else { $null } if (Test-SpecrewLensWorkshopRecordPlaceholder -Value $agenda) { $missing.Add('agenda') | Out-Null } $decision = if ($rec.PSObject.Properties['decision']) { $rec.decision } else { $null } if (Test-SpecrewLensWorkshopRecordPlaceholder -Value $decision) { $missing.Add('decision') | Out-Null } $depth = if ($rec.PSObject.Properties['depth']) { $rec.depth } else { $null } if (Test-SpecrewLensWorkshopRecordPlaceholder -Value $depth) { $missing.Add('depth') | Out-Null } $movedOn = if ($rec.PSObject.Properties['moved_on']) { $rec.moved_on } else { $null } if ($null -eq $movedOn -or -not [bool]$movedOn) { $missing.Add('moved_on') | Out-Null } if ($missing.Count -gt 0) { $errors.Add(("workshop record for selected lens '{0}' is incomplete (SC-021): missing or placeholder {1}." -f $id, ($missing -join ', '))) | Out-Null } if ($confirmationRequired) { $confirmation = if ($rec.PSObject.Properties['confirmation']) { [string]$rec.confirmation } else { '' } if ($confirmation -notin $validProvenance) { $errors.Add(("workshop record for selected lens '{0}' is missing a valid confirmation provenance (SC-026): set 'confirmation' to one of human-confirmed | human-delegated | human-skipped (got '{1}'). Record human-confirmed ONLY for a lens the human was surfaced and confirmed; an explicit delegate/skip records human-delegated/human-skipped; an agreement MUST NOT be synthesized for an un-surfaced lens." -f $id, $confirmation)) | Out-Null } else { $confirmationScope = if ($rec.PSObject.Properties['confirmation_scope']) { [string]$rec.confirmation_scope } else { '' } $requiredScope = $requiredConfirmationScopes[$confirmation] if ($confirmationScope -ne $requiredScope) { $errors.Add(("workshop record for selected lens '{0}' has invalid confirmation scope (SC-026, #2212): set 'confirmation_scope' to '{1}' when confirmation is '{2}' (got '{3}'). Lens approval is not workshop-question approval." -f $id, $requiredScope, $confirmation, $confirmationScope)) | Out-Null } } } } return $errors.ToArray() } function Test-SpecrewDesignCoDesignRecord { # SC-025 (Amendment A6): the deterministic, marker-gated FLOOR under the behavioral co-design session. # When the iteration's recorded lens-applicability.json opts in via an explicit `co_design: true` marker, # the design-analysis.md MUST carry a non-placeholder `## Co-Design Record` section with (a) a # component-to-responsibility map, (b) at least one agreed flow, and (c) a truthy human-agreed marker. # Enforces PRESENCE only — it does NOT, and cannot, assess whether the collaboration was genuine (that is # SC-024's runtime dogfood). Marker-gated + grandfather-safe (the SC-021/FR-026 precedent): a pre-A6 # artifact carries no `co_design` marker and no-ops, so it is NEVER retroactively failed; an A6 artifact # that sets the marker but omits the record CANNOT bypass the gate. `fr026_grandfathered` also exempts. # Resolution mirrors FR-026 (iteration-level lens-applicability.json first, else feature-level). Returns a # string[] of error messages (empty = OK / not applicable). param( [Parameter(Mandatory = $true)][string]$Content, [Parameter(Mandatory = $true)][string]$IterationDirectory ) $errors = New-Object System.Collections.Generic.List[string] $iterationArtifact = Join-Path $IterationDirectory 'lens-applicability.json' $featureDirectory = Split-Path -Parent (Split-Path -Parent $IterationDirectory) $featureArtifact = if (-not [string]::IsNullOrWhiteSpace($featureDirectory)) { Join-Path $featureDirectory 'lens-applicability.json' } else { $null } $answersPath = if (Test-Path -LiteralPath $iterationArtifact -PathType Leaf) { $iterationArtifact } elseif ($null -ne $featureArtifact -and (Test-Path -LiteralPath $featureArtifact -PathType Leaf)) { $featureArtifact } else { $null } if ($null -eq $answersPath) { return @() } $doc = $null try { $doc = Get-Content -LiteralPath $answersPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return @() } if ($null -eq $doc) { return @() } if ($doc.PSObject.Properties['fr026_grandfathered'] -and [bool]$doc.fr026_grandfathered) { return @() } # SC-025 enforces ONLY for A6 co-design-era artifacts (explicit marker). A pre-A6 artifact (no marker) # no-ops, so it is never retroactively failed. An A6 artifact that sets the marker but omits the record # CANNOT bypass the gate (the record is required below). $isCoDesign = $doc.PSObject.Properties['co_design'] -and [bool]$doc.co_design if (-not $isCoDesign) { return @() } $selected = @() if ($doc.PSObject.Properties['selected']) { $selected = @($doc.selected | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } $section = Get-SpecrewDesignAnalysisSection -Content $Content -HeadingPatterns @('Co-?Design\s+Record') if (-not $section.Found -or (Test-SpecrewDesignAnalysisPlaceholderText -Text $section.Body)) { $errors.Add("design-analysis.md is missing a populated '## Co-Design Record' section (SC-025): record the co-designed component-to-responsibility map, at least one agreed flow, and a human-agreed marker.") | Out-Null return $errors.ToArray() } $body = $section.Body $missing = New-Object System.Collections.Generic.List[string] if ($body -notmatch '(?i)responsib') { $missing.Add('a component-to-responsibility map') | Out-Null } if ($body -notmatch '(?is)```mermaid' -and $body -notmatch '(?im)\bflow\b') { $missing.Add('at least one agreed flow (a mermaid flow/sequence or a labeled flow)') | Out-Null } $agreed = [regex]::Match($body, '(?im)^\s*[-*]?\s*\**\s*(?:human[- ]?agreed|human\s+agreement|agreed\s+by\s+(?:the\s+)?human)\**\s*[:\-]\s*(?<v>.+)$') if (-not $agreed.Success -or (Test-SpecrewDesignAnalysisPlaceholderText -Text $agreed.Groups['v'].Value) -or ($agreed.Groups['v'].Value -notmatch '(?i)\b(yes|true|confirmed|agreed|approved)\b')) { $missing.Add('a truthy human-agreed marker (for example "Human-agreed: yes")') | Out-Null } # A6 finding D (iteration-9 dogfood): when ui-ux is a selected lens, the agreed UI/screen layout MUST be # captured in the record too — an agreed layout that lives only in the chat scrollback is lost. if (($selected -contains 'ui-ux') -and ($body -notmatch '(?im)(?:ui|screen)[ -]?layout')) { $missing.Add('a captured UI/screen-layout agreement (ui-ux is a selected lens)') | Out-Null } if ($missing.Count -gt 0) { $errors.Add(("'## Co-Design Record' is incomplete (SC-025): missing {0}." -f ($missing -join ', '))) | Out-Null } return $errors.ToArray() } function Test-SpecrewDesignAnalysisArtifact { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef $iteration = Normalize-SpecrewDesignAnalysisIterationNumber -IterationNumber $IterationNumber $artifactPath = Get-SpecrewDesignAnalysisArtifactPath -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $iteration $errors = New-Object System.Collections.Generic.List[string] if (-not (Test-Path -LiteralPath $artifactPath -PathType Leaf)) { $errors.Add("Missing design-analysis artifact: $artifactPath") | Out-Null return [pscustomobject]@{ Valid = $false ArtifactPath = $artifactPath Errors = @($errors) SelectedOption = $null } } $content = Get-Content -LiteralPath $artifactPath -Raw -Encoding UTF8 $problem = Get-SpecrewDesignAnalysisSection -Content $content -HeadingPatterns @('Problem\s+Framing', 'Problem') $decisionPoints = Get-SpecrewDesignAnalysisSection -Content $content -HeadingPatterns @('Key\s+Design\s+Decision\s+Points', 'Decision\s+Points') $alternatives = Get-SpecrewDesignAnalysisSection -Content $content -HeadingPatterns @('Alternatives', 'Design\s+Alternatives') $recommendation = Get-SpecrewDesignAnalysisSection -Content $content -HeadingPatterns @('Crew\s+Recommendation', 'Recommendation') $humanDecision = Get-SpecrewDesignAnalysisSection -Content $content -HeadingPatterns @('Human\s+Decision') foreach ($section in @( @{ Name = 'Problem Framing'; Value = $problem }, @{ Name = 'Key Design Decision Points'; Value = $decisionPoints }, @{ Name = 'Alternatives'; Value = $alternatives }, @{ Name = 'Crew Recommendation'; Value = $recommendation }, @{ Name = 'Human Decision'; Value = $humanDecision } )) { if (-not $section.Value.Found -or (Test-SpecrewDesignAnalysisPlaceholderText -Text $section.Value.Body)) { $errors.Add("design-analysis.md is missing a populated $($section.Name) section.") | Out-Null } } if ($alternatives.Found) { $simplest = Get-SpecrewDesignAnalysisOptionBlock -AlternativesText $alternatives.Body -OptionName 'Simplest' $reasonable = Get-SpecrewDesignAnalysisOptionBlock -AlternativesText $alternatives.Body -OptionName 'Reasonable' $byTheBook = Get-SpecrewDesignAnalysisOptionBlock -AlternativesText $alternatives.Body -OptionName 'By-the-book' if ([string]::IsNullOrWhiteSpace($simplest)) { $errors.Add('Alternatives must include a populated Simplest option.') | Out-Null } if ([string]::IsNullOrWhiteSpace($reasonable)) { $errors.Add('Alternatives must include a populated Reasonable option.') | Out-Null } foreach ($option in @( @{ Name = 'Simplest'; Block = $simplest }, @{ Name = 'Reasonable'; Block = $reasonable }, @{ Name = 'By-the-book'; Block = $byTheBook } )) { if ([string]::IsNullOrWhiteSpace($option.Block)) { continue } foreach ($field in @('Approach', 'Architectural pattern', 'Quality features considered', 'Effort estimate', 'Reversibility cost', 'Trade-offs')) { if (-not (Test-SpecrewDesignAnalysisOptionField -Block $option.Block -FieldName $field)) { $errors.Add("$($option.Name) option is missing required field: $field.") | Out-Null } } if ($option.Block -notmatch '(?is)```mermaid|diagram\s+(?:link|url)\s*[:\-]\s*\S|diagram\s*[:\-]\s*\S') { $errors.Add("$($option.Name) option is missing a Mermaid diagram or diagram link.") | Out-Null } } if ([string]::IsNullOrWhiteSpace($byTheBook) -and $alternatives.Body -notmatch '(?is)by[-\s]?the[-\s]?book.*(?:not\s+meaningfully\s+distinct|not\s+distinct|not-applicable|not\s+applicable|deferred)') { $errors.Add('Alternatives must include By-the-book when distinct, or state why By-the-book is not meaningfully distinct.') | Out-Null } } $recommendedOption = Get-SpecrewDesignAnalysisMarkedOption -Text $recommendation.Body -Marker 'Recommended' if ($recommendation.Found) { if (Test-SpecrewDesignAnalysisPlaceholderText -Text $recommendation.Body) { $errors.Add('Crew Recommendation must be populated and cannot be placeholder text.') | Out-Null } elseif ([string]::IsNullOrWhiteSpace($recommendedOption)) { $errors.Add('Crew Recommendation must name exactly one recommended option.') | Out-Null } elseif ($recommendedOption -like '*,*') { $errors.Add("Crew Recommendation names multiple options ($recommendedOption); name exactly one.") | Out-Null } } $selectedOption = Get-SpecrewDesignAnalysisMarkedOption -Text $humanDecision.Body -Marker 'Chosen\s+Option' if ($humanDecision.Found) { if (Test-SpecrewDesignAnalysisPlaceholderText -Text $humanDecision.Body) { $errors.Add('Human Decision must be populated and cannot be placeholder text.') | Out-Null } if ($humanDecision.Body -notmatch '(?i)approved\s+for\s+plan\s+with\s+Option\s+[A-Z]' -and $humanDecision.Body -notmatch '(?im)^\s*(?:[-*]\s*)?(?:\*\*)?Chosen\s+Option(?:\*\*)?\s*[:\-]\s*\S') { $errors.Add('Human Decision must record a verdict equivalent to "approved for plan with Option <X>" or a Chosen Option field.') | Out-Null } if ([string]::IsNullOrWhiteSpace($selectedOption)) { $errors.Add('Human Decision must name the chosen option.') | Out-Null } if ($humanDecision.Body -notmatch '(?i)\b(reason|rationale|modification|modifications|instruction|instructions)\b') { $errors.Add('Human Decision must record the human reason, modifications, or instructions.') | Out-Null } if ($humanDecision.Body -notmatch '(?i)\b[0-9a-f]{7,40}\b') { $errors.Add('Human Decision must record a commit hash.') | Out-Null } # FR-003 metadata integrity (Fix 3, 2026-06-02 smoke): the decision commit must be # the commit that contains the populated Human Decision, NOT the design-analysis # draft commit. Reject recording the draft commit as the decision commit. $draftCommit = [regex]::Match($humanDecision.Body, '(?im)^\s*[-*]?\s*\**\s*Design-analysis draft commit\**\s*[:\-]\s*`?([0-9a-f]{7,40})`?') $decisionCommit = [regex]::Match($humanDecision.Body, '(?im)^\s*[-*]?\s*\**\s*Decision recorded in commit\**\s*[:\-]\s*`?([0-9a-f]{7,40})`?') if ($draftCommit.Success -and $decisionCommit.Success -and $draftCommit.Groups[1].Value -eq $decisionCommit.Groups[1].Value) { $errors.Add('Human Decision "Decision recorded in commit" must differ from the "Design-analysis draft commit"; record the commit that contains the populated decision, not the draft.') | Out-Null } } # FR-026 (Amendment A2): lens-coverage enforcement. Each questionnaire-selected lens must carry a # non-placeholder "Addressed:" entry (anti-omission); grandfather-safe + LLM/network-free. $iterationDirectory = Split-Path -Parent $artifactPath foreach ($coverageError in @(Test-SpecrewDesignAnalysisLensCoverage -Content $content -IterationDirectory $iterationDirectory)) { $errors.Add($coverageError) | Out-Null } # SC-025 (Amendment A6): co-design-record floor under the behavioral co-design session. Marker-gated by # `co_design` in the iteration's lens-applicability.json + grandfather-safe, so pre-A6 artifacts no-op; # presence-only (collaboration quality is SC-024's runtime dogfood). foreach ($coDesignError in @(Test-SpecrewDesignCoDesignRecord -Content $content -IterationDirectory $iterationDirectory)) { $errors.Add($coDesignError) | Out-Null } # NOTE: the SC-021 per-lens workshop-record floor is enforced at the SPECIFY boundary # (Invoke-SpecrewSpecifyBoundaryLensGate) against the FEATURE-level workshop artifact, NOT here — the # design-analysis gate resolves the iteration-level questionnaire (FR-026), which is the wrong artifact # for the workshop floor (i007 dogfood fix). return [pscustomobject]@{ Valid = ($errors.Count -eq 0) ArtifactPath = $artifactPath Errors = @($errors) SelectedOption = $selectedOption RecommendedOption = $recommendedOption } } function Invoke-SpecrewDesignAnalysisPlanBoundaryGate { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return $null } $required = Test-SpecrewDesignAnalysisGateRequired -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $IterationNumber if (-not $required) { return $null } $result = Test-SpecrewDesignAnalysisArtifact -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $IterationNumber if (-not $result.Valid) { $messageLines = New-Object System.Collections.Generic.List[string] $messageLines.Add(("[design-analysis-gate] Plan boundary cannot advance for active substantive feature '{0}'." -f $feature)) | Out-Null $messageLines.Add(("Required artifact: {0}" -f $result.ArtifactPath)) | Out-Null foreach ($error in $result.Errors) { $messageLines.Add((" - {0}" -f $error)) | Out-Null } throw ($messageLines -join [Environment]::NewLine) } return $result } $script:SpecrewDesignAnalysisPacketSections = @( 'What I Just Did', 'Why I Stopped', 'What Needs Your Review', 'What Happens Next', 'Discussion Prompts', 'What I Need From You' ) function New-SpecrewDesignAnalysisGatePacket { # FR-004: render the design-analysis human gate packet from typed fields, so the # authoritative approval object is Specrew-rendered rather than free-form prose. # Scoped to the design-analysis gate only (FR-006) — not a general packet system. param( [Parameter(Mandatory = $true)][hashtable]$Fields ) $feature = if ($Fields.ContainsKey('Feature')) { [string]$Fields['Feature'] } else { '<feature>' } $iteration = if ($Fields.ContainsKey('Iteration')) { [string]$Fields['Iteration'] } else { '001' } $verdict = if ($Fields.ContainsKey('Verdict')) { [string]$Fields['Verdict'] } else { 'approved for plan with Option <X>' } $lines = New-Object System.Collections.Generic.List[string] $lines.Add('---') | Out-Null $lines.Add('gate: design-analysis') | Out-Null $lines.Add(('feature: {0}' -f $feature)) | Out-Null $lines.Add(('iteration: "{0}"' -f $iteration)) | Out-Null $lines.Add('from_boundary: design-analysis') | Out-Null $lines.Add('to_boundary: plan') | Out-Null $lines.Add(('verdict_shape: "{0}"' -f $verdict)) | Out-Null $lines.Add('---') | Out-Null $lines.Add('') | Out-Null foreach ($section in $script:SpecrewDesignAnalysisPacketSections) { $key = $section -replace '[^A-Za-z]', '' $body = if ($Fields.ContainsKey($key)) { [string]$Fields[$key] } else { '<to be filled>' } $lines.Add(('## {0}' -f $section)) | Out-Null $lines.Add('') | Out-Null $lines.Add($body.Trim()) | Out-Null $lines.Add('') | Out-Null } return (($lines -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine) } function Test-SpecrewDesignAnalysisGatePacket { # FR-005: validate the rendered design-analysis gate packet — six human re-entry # sections present, the `approved for plan with Option <X>` verdict shape present, # and no bare artifact paths in prose (file:/// URLs and code spans are exempt). param( [Parameter(Mandatory = $true)][AllowNull()][string]$PacketText ) $errors = New-Object System.Collections.Generic.List[string] if ([string]::IsNullOrWhiteSpace($PacketText)) { $errors.Add('Packet text is empty.') | Out-Null return [pscustomobject]@{ Valid = $false; Errors = @($errors) } } foreach ($section in $script:SpecrewDesignAnalysisPacketSections) { $pattern = '(?im)^#{1,3}\s*' + [regex]::Escape($section) + '\s*$' if ($PacketText -notmatch $pattern) { $errors.Add(("Packet is missing required section: {0}." -f $section)) | Out-Null } } if ($PacketText -notmatch '(?i)approved for plan with option\b') { $errors.Add('Packet must reference the verdict shape `approved for plan with Option <X>`.') | Out-Null } # Strip code blocks, inline code, and file:/// URLs, then flag any remaining bare # artifact-path reference in prose. $stripped = $PacketText $stripped = [regex]::Replace($stripped, '(?s)```.*?```', '') $stripped = [regex]::Replace($stripped, '`[^`]*`', '') $stripped = [regex]::Replace($stripped, '(?i)file:///\S+', '') $barePaths = [regex]::Matches($stripped, '(?<![\w./])(?:specs|\.specrew|\.squad|tests)[\\/]\S') if ($barePaths.Count -gt 0) { $errors.Add('Packet prose contains a bare artifact path; use a file:/// URL or a code span.') | Out-Null } return [pscustomobject]@{ Valid = ($errors.Count -eq 0); Errors = @($errors) } } function Get-SpecrewDesignAnalysisGatePacketPath { param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef $iteration = Normalize-SpecrewDesignAnalysisIterationNumber -IterationNumber $IterationNumber # FR-020 / FR-006: durable packet scoped to the design-analysis gate only. return Join-Path $ProjectRoot ("specs\{0}\gates\design-analysis-{1}.md" -f $feature, $iteration) } function Save-SpecrewDesignAnalysisGatePacket { # FR-020: persist the validated packet as a narrow durable 155-lite record under # specs/<feature>/gates/ for the design-analysis gate only. Validates before save. param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef, [AllowNull()][string]$IterationNumber, [Parameter(Mandatory = $true)][string]$PacketText ) $validation = Test-SpecrewDesignAnalysisGatePacket -PacketText $PacketText if (-not $validation.Valid) { $messageLines = New-Object System.Collections.Generic.List[string] $messageLines.Add('[design-analysis-gate-packet] Refusing to persist an invalid design-analysis gate packet.') | Out-Null foreach ($err in $validation.Errors) { $messageLines.Add((" - {0}" -f $err)) | Out-Null } throw ($messageLines -join [Environment]::NewLine) } $packetPath = Get-SpecrewDesignAnalysisGatePacketPath -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -IterationNumber $IterationNumber $directory = Split-Path -Parent $packetPath if (-not [string]::IsNullOrWhiteSpace($directory) -and -not (Test-Path -LiteralPath $directory -PathType Container)) { $null = New-Item -ItemType Directory -Path $directory -Force } [System.IO.File]::WriteAllText($packetPath, ($PacketText.TrimEnd() + [Environment]::NewLine), [System.Text.UTF8Encoding]::new($false)) return [pscustomobject]@{ Path = $packetPath; Valid = $true } } function Get-SpecrewDesignAnalysisSelectedOption { # FR-007: expose the human-selected option as authoritative plan input. Returns # the single selected option string, or $null when the artifact is missing/invalid. param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $result = Test-SpecrewDesignAnalysisArtifact -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -IterationNumber $IterationNumber if (-not $result.Valid) { return $null } return $result.SelectedOption } function Get-SpecrewDesignAnalysisTemplatePath { # Resolve the versioned design-analysis template that the scaffold emits. # Reconciled with the validator contract (FR-001 / TG-007). Prefers the # repo/module source path; falls back to the deployed .specify mirror. $moduleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $candidates = @( (Join-Path $moduleRoot 'extensions\specrew-speckit\templates\design-analysis.template.md'), (Join-Path $moduleRoot '.specify\extensions\specrew-speckit\templates\design-analysis.template.md') ) foreach ($candidate in $candidates) { if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } } return $null } function New-SpecrewDesignAnalysisArtifact { # FR-001: scaffold a per-iteration design-analysis.md from the template if it # does not already exist. Never overwrites an existing decision record. param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { throw 'New-SpecrewDesignAnalysisArtifact requires a non-empty feature ref.' } $artifactPath = Get-SpecrewDesignAnalysisArtifactPath -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $IterationNumber if (Test-Path -LiteralPath $artifactPath -PathType Leaf) { return [pscustomobject]@{ Path = $artifactPath; Created = $false; Reason = 'exists' } } $templatePath = Get-SpecrewDesignAnalysisTemplatePath if ($null -eq $templatePath) { throw 'design-analysis.template.md not found under extensions/specrew-speckit/templates; cannot scaffold the design-analysis artifact.' } $directory = Split-Path -Parent $artifactPath if (-not [string]::IsNullOrWhiteSpace($directory) -and -not (Test-Path -LiteralPath $directory -PathType Container)) { $null = New-Item -ItemType Directory -Path $directory -Force } $template = Get-Content -LiteralPath $templatePath -Raw -Encoding UTF8 [System.IO.File]::WriteAllText($artifactPath, $template, [System.Text.UTF8Encoding]::new($false)) return [pscustomobject]@{ Path = $artifactPath; Created = $true; Reason = 'scaffolded-from-template' } } function Invoke-SpecrewDesignAnalysisPrePlanGate { # FR-002 / FR-003 / FR-021: callable pre-plan validator invoked BEFORE plan.md # is authored (in addition to the at-sync plan-boundary gate). Coordinator-prompt # enforcement (no host-native hooks) calls this; it fails closed when the active # substantive iteration's design-analysis artifact or human decision is invalid. param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef, [AllowNull()][string]$IterationNumber ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return $null } $required = Test-SpecrewDesignAnalysisGateRequired -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $IterationNumber if (-not $required) { return $null } $result = Test-SpecrewDesignAnalysisArtifact -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $IterationNumber if (-not $result.Valid) { $messageLines = New-Object System.Collections.Generic.List[string] $messageLines.Add(("[design-analysis-pre-plan-gate] Do not author plan.md for active substantive feature '{0}' until design analysis is valid." -f $feature)) | Out-Null $messageLines.Add(("Required artifact: {0}" -f $result.ArtifactPath)) | Out-Null foreach ($err in $result.Errors) { $messageLines.Add((" - {0}" -f $err)) | Out-Null } throw ($messageLines -join [Environment]::NewLine) } # FR-004/FR-005/FR-020 enforced flow (Fix 1/2, 2026-06-02 smoke): the durable # design-gate packet MUST exist and validate before plan.md is authored, so packet # persistence is a required step in the real flow, not an unused helper. The at-sync # plan-boundary gate stays the artifact/decision backstop if this call is bypassed. $packetPath = Get-SpecrewDesignAnalysisGatePacketPath -ProjectRoot $ProjectRoot -FeatureRef $feature -IterationNumber $IterationNumber if (-not (Test-Path -LiteralPath $packetPath -PathType Leaf)) { throw ("[design-analysis-pre-plan-gate] Do not author plan.md for active substantive feature '{0}': the durable design-gate packet is missing at {1}. Render, validate, and persist it (Save-SpecrewDesignAnalysisGatePacket) before plan.md." -f $feature, $packetPath) } $packetText = Get-Content -LiteralPath $packetPath -Raw -Encoding UTF8 $packetCheck = Test-SpecrewDesignAnalysisGatePacket -PacketText $packetText if (-not $packetCheck.Valid) { $packetLines = New-Object System.Collections.Generic.List[string] $packetLines.Add(("[design-analysis-pre-plan-gate] Durable design-gate packet at {0} is invalid:" -f $packetPath)) | Out-Null foreach ($perr in $packetCheck.Errors) { $packetLines.Add((" - {0}" -f $perr)) | Out-Null } throw ($packetLines -join [Environment]::NewLine) } return [pscustomobject]@{ Valid = $true ArtifactPath = $result.ArtifactPath Errors = @() SelectedOption = $result.SelectedOption PacketPath = $packetPath } } function Get-SpecrewLensApplicabilityMapPath { # The decoupled question->lens applicability map (Iteration 4). Its presence means the lens intake # applies to this project; absent (e.g. a downstream project without lenses) => graceful skip. param([Parameter(Mandatory = $true)][string]$ProjectRoot) foreach ($rel in @( 'extensions\specrew-speckit\knowledge\design-lenses\applicability-map.json', '.specify\extensions\specrew-speckit\knowledge\design-lenses\applicability-map.json')) { $candidate = Join-Path $ProjectRoot $rel if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } } return $null } function Get-SpecrewFeatureLensApplicabilityPath { # FR-027 (A3): the lens intake records its answers ONCE at the feature level (the specify-phase # truth); the FR-026 coverage gate resolves this when no iteration-level artifact is present. param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [Parameter(Mandatory = $true)][string]$FeatureRef ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return $null } return Join-Path $ProjectRoot ("specs\{0}\lens-applicability.json" -f $feature) } function Test-SpecrewSpecifyLensIntakeRequired { # The lens intake is required at the specify boundary when (a) the applicability map is present # (graceful skip for downstream projects without lenses) AND (b) the feature is substantive # (trivial doc/bugfix features are exempt — the same substantive test the design-analysis gate uses). param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return $false } if ($null -eq (Get-SpecrewLensApplicabilityMapPath -ProjectRoot $ProjectRoot)) { return $false } return (Test-SpecrewDesignAnalysisSubstantiveFeature -ProjectRoot $ProjectRoot -FeatureRef $feature) } function Invoke-SpecrewSpecifyBoundaryLensGate { # FR-027 (Amendment A3): the lens-applicability intake MUST complete before the specify boundary # is finalized/synced, so the ACCEPTED spec is lens-informed. This is ENFORCED, not prompt-only: # sync-specify is refused for a substantive feature (in a lens-catalog project) until the # feature-level lens-applicability.json exists (i.e. the interactive intake ran and the spec was # amended before sync). Deterministic + LLM/network-free. Returns $null when the intake does not # apply (graceful skip — no map, or trivial feature). param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return $null } if (-not (Test-SpecrewSpecifyLensIntakeRequired -ProjectRoot $ProjectRoot -FeatureRef $feature)) { return $null } $artifact = Get-SpecrewFeatureLensApplicabilityPath -ProjectRoot $ProjectRoot -FeatureRef $feature if ($null -eq $artifact -or -not (Test-Path -LiteralPath $artifact -PathType Leaf)) { throw ("[specify-lens-gate] Cannot finalize the specify boundary for substantive feature '{0}': the lens-applicability intake has not run. Run the interactive, expertise-adapted lens intake, record the feature-level lens-applicability.json, and amend spec.md + the requirements checklist with the lens-informed requirements BEFORE sync-specify (FR-027 / Amendment A3). Expected artifact: {1}" -f $feature, $artifact) } # SC-021 (Amendment A4): the per-lens workshop-record FLOOR, enforced HERE against the feature-level # workshop artifact (the specify-phase truth). For an A4 workshop intake (explicit `workshop_intake: # true`), each selected lens MUST carry a non-placeholder workshop record (agenda + decision/agreement # + depth + moved_on). Presence-only, marker-gated so a pre-A4 questionnaire artifact no-ops; the # workshop's QUALITY is SC-020's runtime dogfood, not this gate. $workshopErrors = @(Test-SpecrewLensWorkshopRecords -ArtifactPath $artifact) if ($workshopErrors.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add(("[specify-lens-gate] Cannot finalize the specify boundary for substantive feature '{0}': the per-lens workshop records are incomplete (SC-021). Record agenda + decision/agreement + depth + a moved_on marker for each selected lens in {1} before sync-specify." -f $feature, $artifact)) | Out-Null foreach ($wsErr in $workshopErrors) { $lines.Add((" - {0}" -f $wsErr)) | Out-Null } throw ($lines -join [Environment]::NewLine) } return [pscustomobject]@{ Valid = $true; ArtifactPath = $artifact } } function Format-SpecrewFileReference { # FR-028 (Amendment A3): render a file reference for its context. Human-facing console/terminal # prose uses a bare file:/// URL (terminal-clickable); a persisted .md artifact uses a markdown # link [text](relative) so it navigates in an editor; 'both' emits a markdown link whose target is # the file:/// URL. Forward slashes throughout. -RelativeTo (e.g. the artifact's own directory) # produces accurate relative links for the persisted form; otherwise relative to -ProjectRoot. [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][ValidateSet('console', 'persisted', 'both')][string]$Context, [string]$ProjectRoot, [string]$RelativeTo, [string]$LinkText ) $isRooted = [System.IO.Path]::IsPathRooted($Path) $absolute = if (-not $isRooted -and -not [string]::IsNullOrWhiteSpace($ProjectRoot)) { Join-Path $ProjectRoot $Path } else { $Path } $fileUrl = 'file:///' + ($absolute -replace '\\', '/') $text = if ([string]::IsNullOrWhiteSpace($LinkText)) { Split-Path -Leaf $Path } else { $LinkText } switch ($Context) { 'console' { return $fileUrl } 'both' { return ('[{0}]({1})' -f $text, $fileUrl) } 'persisted' { $rel = $Path $relRoot = if (-not [string]::IsNullOrWhiteSpace($RelativeTo)) { $RelativeTo } elseif (-not [string]::IsNullOrWhiteSpace($ProjectRoot)) { $ProjectRoot } else { $null } if ($isRooted -and $null -ne $relRoot) { try { $rel = [System.IO.Path]::GetRelativePath($relRoot, $Path) } catch { $rel = $Path } } return ('[{0}]({1})' -f $text, ($rel -replace '\\', '/')) } } } |