scripts/internal/design-analysis-gate.ps1
|
Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:SpecrewDesignAnalysisGateMinimumVersion = [version]'0.30.0' $script:SpecrewDesignAnalysisDefaultIterationNumber = '001' if (-not (Get-Command -Name 'Test-SpecrewWorkshopDecisionBindings' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'bootstrap/ProjectMetadataAccessor.ps1') } if (-not (Get-Command -Name 'Test-SpecrewImplementationRulesManifest' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'code-implementation-lens.ps1') } 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 } $matchedItems = [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 $matchedItems) { $value = $match.Groups[1].Value.Trim() $normalized = if ($value -match '(?i)^option\s+([A-Z])$') { # DRIFT-199-I001-134: W42 renamed the automatic `$Matches` to `$matchedItems` to remove # the collision it was fixing - and the rename reached this line, where the index is # into the CAPTURE GROUP of the `-match` above, not into the match COLLECTION. The # result threw on every "Option X" text: out of range with one match, and a # method-not-found on a Match object with more. Three suites had been failing on it, # all of them outside the verification lanes. The capture is $Matches[1]; the # collection stays $matchedItems. '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) { return $false } $ss = if ($context.PSObject.Properties['session_state']) { $context.session_state } else { $null } $be = if ($context.PSObject.Properties['boundary_enforcement']) { $context.boundary_enforcement } else { $null } # v2 schema migration: a v2 start-context has NO session_state (the active feature lives in # .specify/feature.json, the boundary in boundary_enforcement). The old `session_state -eq null -> # return false` bail silently DISABLED this gate on EVERY v2 project (the session_state-drop broke # more than the boundary readers - Proposal-145 review, iter-007). v1 keeps the full session_state # cross-check (active + feature-match + boundary); v2 resolves the active feature from feature.json # (the schema-independent active-feature source the boundary-sync itself uses) and requires a v2 # boundary state - the pre-plan check below already reads boundary_enforcement.last_authorized_boundary. $currentBoundary = '' if ($null -ne $ss) { if (-not [bool]$ss.active) { return $false } $contextFeature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef ([string]$ss.feature_ref) if ($contextFeature -ne $feature) { return $false } $currentBoundary = [string]$ss.boundary_type } else { if ($null -eq $be) { return $false } # no session_state AND no boundary_enforcement -> no active governed feature $featureJsonPath = Join-Path $ProjectRoot '.specify/feature.json' $activeFeature = '' if (Test-Path -LiteralPath $featureJsonPath -PathType Leaf) { try { $fj = Get-Content -LiteralPath $featureJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($fj.PSObject.Properties['feature_directory'] -and -not [string]::IsNullOrWhiteSpace([string]$fj.feature_directory)) { $activeFeature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef (Split-Path -Leaf ([string]$fj.feature_directory)) } } catch { return $false } } if ([string]::IsNullOrWhiteSpace($activeFeature) -or $activeFeature -ne $feature) { return $false } } $lastAuthorized = if ($null -ne $be) { [string]$be.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}' (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 # Historical records rely on declared scoped evidence. New typed-turns-v1 records additionally join each # claimed agreement to the hook-owned receipt store, so picker dismissal or model-authored provenance cannot # satisfy the gate; behavioral quality beyond that authority join remains a dogfood/review concern. $confirmationRequired = $doc.PSObject.Properties['confirmation_required'] -and [bool]$doc.confirmation_required $typedTurnRequired = $doc.PSObject.Properties['human_turn_contract'] -and [string]$doc.human_turn_contract -eq 'typed-turns-v1' $typedTurnProjectRoot = $null if ($typedTurnRequired) { $featureDir = Split-Path -Parent $ArtifactPath $specsDir = if (-not [string]::IsNullOrWhiteSpace($featureDir)) { Split-Path -Parent $featureDir } else { '' } $typedTurnProjectRoot = if (-not [string]::IsNullOrWhiteSpace($specsDir)) { Split-Path -Parent $specsDir } else { '' } $authorityPath = if (-not [string]::IsNullOrWhiteSpace($typedTurnProjectRoot)) { Join-Path $typedTurnProjectRoot '.specify/extensions/specrew-speckit/scripts/workshop-authority-store.ps1' } else { '' } if (-not (Get-Command Test-SpecrewWorkshopAuthorityReceipt -ErrorAction SilentlyContinue) -and -not [string]::IsNullOrWhiteSpace($authorityPath) -and (Test-Path -LiteralPath $authorityPath -PathType Leaf)) { try { . $authorityPath } catch { $null = $_ } } if (-not (Get-Command Test-SpecrewWorkshopAuthorityReceipt -ErrorAction SilentlyContinue)) { $errors.Add('typed workshop authority cannot be verified because workshop-authority-store.ps1 is missing.') | Out-Null } else { $featureRef = Split-Path -Leaf $featureDir $agendaReceipt = if ($doc.PSObject.Properties['agenda_turn_receipt']) { [string]$doc.agenda_turn_receipt } else { '' } $agendaDigest = if ($doc.PSObject.Properties['agenda_digest']) { [string]$doc.agenda_digest } else { '' } if ([string]::IsNullOrWhiteSpace($agendaReceipt) -or $agendaDigest -cnotmatch '^[a-f0-9]{64}$' -or -not (Test-SpecrewWorkshopAuthorityReceipt -ProjectRoot $typedTurnProjectRoot -FeatureRef $featureRef -Phase 'agenda' -ReceiptId $agendaReceipt -Confirmation 'human-confirmed' -ConfirmationScope 'lens-selection' -AgendaDigest $agendaDigest)) { $errors.Add('the workshop agenda has no matching typed human confirmation receipt; a picker result or model-authored agenda cannot authorize lens selection.') | Out-Null } $productReceipt = Get-SpecrewWorkshopAuthorityReceipt -ProjectRoot $typedTurnProjectRoot -FeatureRef $featureRef -Phase 'product-domain' if ($null -eq $productReceipt -or [string]$productReceipt.confirmation -eq 'invalid') { $errors.Add('product-domain has no typed human reply receipt; Ctrl+O/dismissal is not delegation.') | Out-Null } } } $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}': 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: missing or placeholder {1}." -f $id, ($missing -join ', '))) | Out-Null } # #2904 (iter-007 real-host dogfood): the 'code-implementation' lens's moved_on is HOLLOW unless its # lens ARTIFACT (the implementation-rules.yml manifest) actually exists on disk. The completion signal # (moved_on) was DECOUPLED from the artifact: a host marked the lens moved_on:true / human-confirmed # having written NO manifest and without asking the reviewer question, and this gate trusted the # checkbox and authorized. Assert the artifact's PRESENCE here. Its SCHEMA/provenance is the lens's own # Schema/provenance validation is applied by Invoke-SpecrewSpecifyBoundaryLensGate after this # presence floor succeeds. Keeping the presence check here makes incomplete workshop state visible # to the controller; validating at the real boundary gate prevents a malformed manifest from being # committed as if specify were ready. # Scoped to code-implementation (the only lens with a required on-disk manifest) + gated on moved_on # truthy, so it is grandfather-safe exactly like the rest of this floor (a pre-A4 artifact never reaches # here; a lens that is not moved_on already fails above). Resolved INLINE: the gate is dot-sourced by # sync-boundary-state.ps1, which does NOT load code-implementation-lens.ps1, so calling # Get-SpecrewCodeManifestPath here would throw command-not-found under StrictMode. 'implementation-rules.yml' # is that helper's stable filename contract; the regression test pins gate-literal == helper-contract. if ($id -eq 'code-implementation' -and $null -ne $movedOn -and [bool]$movedOn) { $featureDir = Split-Path -Parent $ArtifactPath $manifestPath = if (-not [string]::IsNullOrWhiteSpace($featureDir)) { Join-Path $featureDir 'implementation-rules.yml' } else { '' } if ([string]::IsNullOrWhiteSpace($manifestPath) -or -not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { $errors.Add(("the 'code-implementation' lens is marked moved_on but its required artifact is missing (#2904): expected the implementation-rules.yml manifest beside lens-applicability.json at '{0}'. A code-writing lens cannot self-certify complete with no manifest - run the code-implementation workshop and persist implementation-rules.yml before sync-specify." -f $manifestPath)) | 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: 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: 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 } } } if ($typedTurnRequired -and (Get-Command Test-SpecrewWorkshopAuthorityReceipt -ErrorAction SilentlyContinue)) { $featureRef = Split-Path -Leaf (Split-Path -Parent $ArtifactPath) $receiptId = if ($rec.PSObject.Properties['human_turn_receipt']) { [string]$rec.human_turn_receipt } else { '' } $confirmation = if ($rec.PSObject.Properties['confirmation']) { [string]$rec.confirmation } else { '' } $confirmationScope = if ($rec.PSObject.Properties['confirmation_scope']) { [string]$rec.confirmation_scope } else { '' } if ([string]::IsNullOrWhiteSpace($receiptId) -or -not (Test-SpecrewWorkshopAuthorityReceipt -ProjectRoot $typedTurnProjectRoot -FeatureRef $featureRef -Phase 'lens' -Lens $id -ReceiptId $receiptId -Confirmation $confirmation -ConfirmationScope $confirmationScope)) { $errors.Add(("workshop record for selected lens '{0}' has no matching typed human reply receipt; dismissed/skipped picker output cannot become human-confirmed or human-delegated." -f $id)) | Out-Null } } } $bindingValidation = Test-SpecrewWorkshopDecisionBindings -Applicability $doc if (-not $bindingValidation.valid) { $conflict = $bindingValidation.conflict if ([string]$bindingValidation.reason -eq 'workshop-decision-binding-conflict') { $errors.Add(("workshop decision binding '{0}' conflicts between lens '{1}' ('{2}') and lens '{3}' ('{4}'). Reconcile the durable lens records before the specify boundary." -f $conflict.binding, $conflict.prior_lens, $conflict.prior_value, $conflict.lens, $conflict.value)) | Out-Null } else { $errors.Add('workshop decision bindings are malformed. Use lowercase stable binding keys and lowercase token values before the specify boundary.') | 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: 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: 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 ($err in $result.Errors) { $messageLines.Add((" - {0}" -f $err)) | 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) } # FR-011 (Feature 176): a LOAD-BEARING research-needed product-domain statement BLOCKS plan.md # until it is researched or explicitly accepted. A non-load-bearing research-needed gap does NOT # block (it is recorded + carried). Fail-closed only on load-bearing gaps; graceful no-op when the # product-domain record or helper is absent. $planBlock = @(Test-SpecrewProductDomainPlanBlock -ProjectRoot $ProjectRoot -FeatureRef $feature) if ($planBlock.Count -gt 0) { throw ($planBlock -join [Environment]::NewLine) } return [pscustomobject]@{ Valid = $true ArtifactPath = $result.ArtifactPath Errors = @() SelectedOption = $result.SelectedOption PacketPath = $packetPath } } function Test-SpecrewProductDomainPlanBlock { # FR-011 (Feature 176): return human-readable block reasons when the feature's product-domain # record carries one or more LOAD-BEARING research-needed statements (these must be researched or # explicitly accepted before plan.md). Returns @() when there is no record, no helper, or only # non-load-bearing research-needed gaps (recorded + carried, not blocking). Deterministic; no # network/LLM. The pre-plan gate throws when this returns a non-empty list. param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return @() } $recordPath = Join-Path $ProjectRoot ("specs\{0}\workshop\product-domain.yml" -f $feature) if (-not (Test-Path -LiteralPath $recordPath -PathType Leaf)) { return @() } $helper = Join-Path $PSScriptRoot 'product-domain-lens.ps1' if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { return @() } . $helper $blocking = @(Test-SpecrewProductDomainResearchBlock -Path $recordPath) if ($blocking.Count -eq 0) { return @() } $lines = New-Object System.Collections.Generic.List[string] $lines.Add(("[product-domain-plan-block] Do not author plan.md for '{0}': {1} load-bearing research-needed product-domain gap(s) must be researched or explicitly accepted first:" -f $feature, $blocking.Count)) | Out-Null foreach ($b in $blocking) { $lines.Add((" - {0}" -f $b)) | Out-Null } return $lines.ToArray() } 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 } # FR-029 (iteration 002, T020): the not-yet-authored stub. While the sentinel stands, the specification # has not been written - and the specify boundary is exactly the boundary that approves what it says. This # runs BEFORE the intake-required short-circuit so a FRESH feature (which that check would let through) # still gets the sentence that fits its actual state. $stubSpecPath = Join-Path (Join-Path (Join-Path $ProjectRoot 'specs') (Split-Path -Leaf $feature)) 'spec.md' if (Test-Path -LiteralPath $stubSpecPath -PathType Leaf) { $stubText = Get-Content -LiteralPath $stubSpecPath -Raw -Encoding UTF8 if ($stubText -match '<!--\s*specrew:spec-not-yet-authored\s*-->') { throw ("[specify-gate] spec.md for '{0}' is still the placeholder created with the feature - the specification has not been authored yet. Your workshop answers are safe and are exactly what the specification will be built from. Author the specification from the completed workshop (the specify step), then run the specify boundary again. File: {1}" -f $feature, $stubSpecPath) } } 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. 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. 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) } # The live afcondemo3 walk wrote implementation-rules.yml only after the first boundary attempt, and # hand-authored repairs could have passed the old presence-only floor. Validate the constrained YAML, # schema, catalog references, dependency policy, reviewer selection and confirmation provenance here. # This runs both for a normal sync and for -PreflightOnly, so the workshop can repair the artifact BEFORE # invoking speckit.specify or creating a boundary commit. $applicability = Get-Content -LiteralPath $artifact -Raw -Encoding UTF8 | ConvertFrom-Json $selectedLenses = @() if ($null -ne $applicability -and $applicability.PSObject.Properties['selected']) { $selectedLenses = @($applicability.selected | ForEach-Object { [string]$_ }) } if ($selectedLenses -contains 'code-implementation') { $featureDir = Split-Path -Parent $artifact $manifestPath = Get-SpecrewCodeManifestPath -FeatureDir $featureDir $catalogDir = $null foreach ($relativeCatalogDir in @( 'extensions\specrew-speckit\knowledge\design-lenses', '.specify\extensions\specrew-speckit\knowledge\design-lenses')) { $candidateCatalogDir = Join-Path $ProjectRoot $relativeCatalogDir if (Test-Path -LiteralPath (Join-Path $candidateCatalogDir 'code-rules.yml') -PathType Leaf) { $catalogDir = $candidateCatalogDir break } } $schemaPath = if ($null -ne $catalogDir) { Join-Path $catalogDir 'implementation-rules.schema.json' } else { $null } $catalogPath = if ($null -ne $catalogDir) { Join-Path $catalogDir 'code-rules.yml' } else { $null } $overlayPath = Join-Path $ProjectRoot 'code-rules.local.yml' $manifestErrors = @(Test-SpecrewImplementationRulesManifest ` -Path $manifestPath ` -SchemaPath $schemaPath ` -CatalogPath $catalogPath ` -OverlayPath $overlayPath) if ($manifestErrors.Count -gt 0) { $lines = [System.Collections.Generic.List[string]]::new() $lines.Add(("[specify-lens-gate] Cannot finalize the specify boundary for substantive feature '{0}': implementation-rules.yml is invalid. Repair it before running speckit.specify or committing the boundary." -f $feature)) | Out-Null foreach ($manifestError in $manifestErrors) { $lines.Add((" - {0}" -f $manifestError)) | Out-Null } throw ($lines -join [Environment]::NewLine) } # A preference is not an authorization. The braces/afcondemo3 walks both reached review with no # command-written reviewer-hosts.json, so the campaign reported an opaque harness preflight failure # and the implementer filled the review vacuum itself. Refuse that predictable wedge at specify: # the workshop must name a reviewer and the catalog must contain the matching human authorization. $manifestText = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 $manifest = ConvertFrom-SpecrewImplementationRulesYaml -Text $manifestText $reviewerPreference = if ($null -ne $manifest) { Get-SpecrewCodeMember -Object $manifest -Key 'reviewer_preference' } else { $null } $reviewerHost = [string](Get-SpecrewCodeMember -Object $reviewerPreference -Key 'host') $reviewerAuthorizationRef = [string](Get-SpecrewCodeMember -Object $reviewerPreference -Key 'authorization_ref') $reviewerErrors = [System.Collections.Generic.List[string]]::new() if ($null -eq $reviewerPreference) { $reviewerErrors.Add('reviewer_preference is required for a code-writing feature.') | Out-Null } if ([string]::IsNullOrWhiteSpace($reviewerHost)) { $reviewerErrors.Add('reviewer_preference.host must name the reviewer chosen during the workshop; an unresolved auto-select is not runnable evidence.') | Out-Null } if ([string]::IsNullOrWhiteSpace($reviewerAuthorizationRef)) { $reviewerErrors.Add('reviewer_preference.authorization_ref must record the workshop authorization reference.') | Out-Null } $reviewerHostsPath = Join-Path $ProjectRoot '.specrew\reviewer-hosts.json' if (-not (Test-Path -LiteralPath $reviewerHostsPath -PathType Leaf)) { $reviewerErrors.Add(("no reviewer is authorized in {0}. Run 'specrew review --list-hosts --code-writer-host <current-host>', present the choices, then run 'specrew review --host <chosen-host> --authorization-ref workshop-<feature>'." -f $reviewerHostsPath)) | Out-Null } elseif (-not [string]::IsNullOrWhiteSpace($reviewerHost) -and -not [string]::IsNullOrWhiteSpace($reviewerAuthorizationRef)) { try { $reviewerCatalog = Get-Content -LiteralPath $reviewerHostsPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 100 $matchingReviewer = @($reviewerCatalog.hosts | Where-Object { [string]::Equals([string]$_.host, $reviewerHost, [System.StringComparison]::OrdinalIgnoreCase) -and [bool]$_.allowed -and [bool]$_.installed -and [string]$_.authorization_ref -ceq $reviewerAuthorizationRef }) if ($matchingReviewer.Count -eq 0) { $reviewerErrors.Add(("reviewer '{0}' is not installed + allowed with authorization_ref '{1}' in {2}. Re-run the explicit reviewer authorization command; do not hand-write the catalog." -f $reviewerHost, $reviewerAuthorizationRef, $reviewerHostsPath)) | Out-Null } } catch { $reviewerErrors.Add(("reviewer authorization catalog is unreadable: {0}" -f $_.Exception.Message)) | Out-Null } } if ($reviewerErrors.Count -gt 0) { $lines = [System.Collections.Generic.List[string]]::new() $lines.Add(("[specify-lens-gate] Cannot finalize the specify boundary for substantive feature '{0}': continuous co-review is not runnable yet. Repair reviewer setup before running speckit.specify or committing the boundary." -f $feature)) | Out-Null foreach ($reviewerError in $reviewerErrors) { $lines.Add((" - {0}" -f $reviewerError)) | Out-Null } throw ($lines -join [Environment]::NewLine) } } # FR-010 (Feature 176): the product-domain record floor. When the product-domain lens is in the # catalog (registered) and the feature is substantive, the first-stage product-domain record MUST # exist + validate (genuine, non-batch confirmation provenance) before specify finalizes. Throws # fail-closed; $null when product-domain does not apply (graceful — no lens in the catalog). $null = Test-SpecrewProductDomainGate -ProjectRoot $ProjectRoot -FeatureRef $feature return [pscustomobject]@{ Valid = $true; ArtifactPath = $artifact } } function Get-SpecrewProductDomainCatalogDir { # Resolve the design-lens catalog directory (repo source first, then the deployed .specify mirror). param([Parameter(Mandatory = $true)][string]$ProjectRoot) foreach ($rel in @( 'extensions\specrew-speckit\knowledge\design-lenses', '.specify\extensions\specrew-speckit\knowledge\design-lenses')) { $candidate = Join-Path $ProjectRoot $rel if (Test-Path -LiteralPath (Join-Path $candidate 'product-domain.md') -PathType Leaf) { return $candidate } } return $null } function Test-SpecrewProductDomainGate { # FR-009 / FR-010 / FR-013 (Feature 176): the deterministic specify-boundary floor for the # product-domain first-stage lens. REQUIRED when the product-domain lens is registered in the # catalog (product-domain.md present) AND the feature is substantive. When required, the feature's # workshop/product-domain.{yml,md} MUST both exist and the .yml MUST validate (depth + evidence # tags + a genuine, non-batch confirmation provenance — a batch 'confirm all' can never satisfy # it, FR-009). Graceful degradation (FR-013, no silent skip): an ABSENT catalog means the lens does # not apply here -> $null (graceful skip for a downstream project without the lens); but once the # lens IS registered, a MISSING or INVALID record on a substantive feature FAILS CLOSED with a loud # reason rather than passing silently. Marker-gated by catalog presence + the substantive test, so # pre-176 features (whose specify boundary already synced before the lens existed) are not # retroactively failed. Deterministic; no network/LLM. param( [Parameter(Mandatory = $true)][string]$ProjectRoot, [AllowNull()][string]$FeatureRef ) $feature = Normalize-SpecrewDesignAnalysisFeatureRef -FeatureRef $FeatureRef if ([string]::IsNullOrWhiteSpace($feature)) { return $null } $catalogDir = Get-SpecrewProductDomainCatalogDir -ProjectRoot $ProjectRoot if ($null -eq $catalogDir) { return $null } # lens not in this project's catalog -> graceful skip if (-not (Test-SpecrewDesignAnalysisSubstantiveFeature -ProjectRoot $ProjectRoot -FeatureRef $feature)) { return $null } $featureDir = Join-Path $ProjectRoot ("specs\{0}" -f $feature) $ymlPath = Join-Path $featureDir 'workshop\product-domain.yml' $mdPath = Join-Path $featureDir 'workshop\product-domain.md' # Resolve the schema: a per-feature contracts/ copy overrides; otherwise fall back to the canonical # schema deployed beside the lens in the catalog, so a downstream feature WITHOUT its own copy still # gets Test-Json validation (not just the in-code invariant backstops). $schemaPath = Join-Path $featureDir 'contracts\product-domain.schema.json' if (-not (Test-Path -LiteralPath $schemaPath -PathType Leaf)) { $canonicalSchema = Join-Path $catalogDir 'product-domain.schema.json' if (Test-Path -LiteralPath $canonicalSchema -PathType Leaf) { $schemaPath = $canonicalSchema } } $missing = New-Object System.Collections.Generic.List[string] if (-not (Test-Path -LiteralPath $ymlPath -PathType Leaf)) { $missing.Add('workshop/product-domain.yml') | Out-Null } if (-not (Test-Path -LiteralPath $mdPath -PathType Leaf)) { $missing.Add('workshop/product-domain.md') | Out-Null } if ($missing.Count -gt 0) { throw ("[product-domain-gate] Cannot finalize the specify boundary for substantive feature '{0}': the first-stage product-domain record is MISSING ({1}). The product-domain lens runs before the technical lenses; run it, capture the evidence-tagged record at the chosen depth, and persist both files before sync-specify. Specrew surfaces this rather than silently skipping the grounding. Expected: {2}" -f $feature, ($missing -join ', '), $ymlPath) } # Load the record validator (sibling helper). If it cannot be loaded, fail CLOSED (no silent skip). $helper = Join-Path $PSScriptRoot 'product-domain-lens.ps1' if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) { throw ("[product-domain-gate] Cannot validate the product-domain record for '{0}': the record validator helper is missing at {1}. Refusing to pass the specify boundary without validation (fail-closed)." -f $feature, $helper) } . $helper $schemaArg = if (Test-Path -LiteralPath $schemaPath -PathType Leaf) { $schemaPath } else { $null } $errs = @(Test-SpecrewProductDomainRecord -Path $ymlPath -SchemaPath $schemaArg) if ($errs.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add(("[product-domain-gate] Cannot finalize the specify boundary for substantive feature '{0}': the product-domain record is INVALID. Fix these before sync-specify (a batch 'confirm all' is NOT valid product-domain confirmation):" -f $feature)) | Out-Null foreach ($e in $errs) { $lines.Add((" - {0}" -f $e)) | Out-Null } throw ($lines -join [Environment]::NewLine) } return [pscustomobject]@{ Valid = $true; RecordPath = $ymlPath } } 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 '\\', '/')) } } } |