extensions/specrew-speckit/scripts/record-design-decision.ps1

#!/usr/bin/env pwsh
<#
.SYNOPSIS
  Records a co-decided design choice as a declared artifact the gate can validate. Fix 2 item (f).
 
.DESCRIPTION
  THE GAP THIS FILLS. At the design-analysis stop the human chooses between options the crew co-built with
  them. Until now there was nowhere to put that answer: `approved for plan with Option 2` is defined
  nowhere, the boundary approval anchor admits an option only as a leading prefix and caps it at 1-2, and so
  the choice was written into design-analysis.md as prose - the one artifact nothing validates. The gate
  could not see that a decision had been made, between what, or on whose say-so (B4F-045).
 
  ITS OWN PHRASE FAMILY. The human's typed sentence opens with `design decision`, which the boundary anchor
  cannot match under any input because that anchor is built around the verb `approve`. A design decision
  settles WHAT to build; a boundary verdict grants permission to PROCEED. One sentence must never be able to
  do both, and the separation is a property of the two grammars rather than a rule anyone has to remember.
 
  WHAT IT REFUSES, and each refusal is the co-design rule made enforceable rather than restated:
    - fewer than two options - a choice between one thing is a hand-down wearing a choice's clothes;
    - a chosen option that is not among those recorded - naming something the human was never shown;
    - a human turn from the wrong phrase family - a decision minted out of permission to proceed.
 
.EXAMPLE
  pwsh -File record-design-decision.ps1 -FeatureRef 001-mdlink-checker -Key decomposition-style `
      -Question 'How is the checker decomposed?' `
      -OptionId '1','2','3' `
      -OptionSummary 'one script, three functions','a module per concern','a pipeline of filters' `
      -Chosen 2 -Rationale 'the concerns are tested separately and the team already works this way' `
      -HumanTurn 'design decision: option 2 - I want the concerns testable on their own'
#>

[CmdletBinding(PositionalBinding = $false)]
param(
    [Parameter(Mandatory)][string] $FeatureRef,

    # A stable slug for the decision, so a later lens can cite it and a repeat can be detected.
    [Parameter(Mandatory)][string] $Key,

    [Parameter(Mandatory)][string] $Question,

    # The options AS PRESENTED. Parallel arrays, same length - the ids the human saw and what each one said.
    [Parameter(Mandatory)][string[]] $OptionId,
    [Parameter(Mandatory)][string[]] $OptionSummary,

    [Parameter(Mandatory)][string] $Chosen,
    [Parameter(Mandatory)][string] $Rationale,

    # The human's own typed sentence, verbatim. Not a summary of it: the record's authority is that a person
    # typed this, and a paraphrase is the agent's word for the human's.
    [Parameter(Mandatory)][string] $HumanTurn,

    [AllowNull()][string] $ProjectRoot,
    [switch] $AsJson
)

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

$storePath = Join-Path $PSScriptRoot 'design-decision-store.ps1'
if (-not (Test-Path -LiteralPath $storePath -PathType Leaf)) {
    throw "The design-decision store is missing next to this script: '$storePath'."
}
. $storePath

$root = if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { (Get-Location).Path } else { $ProjectRoot }
$root = [System.IO.Path]::GetFullPath($root)

if ($OptionId.Count -ne $OptionSummary.Count) {
    throw "Each option needs both an id and a summary: -OptionId has $($OptionId.Count) entries and -OptionSummary has $($OptionSummary.Count)."
}
if (-not (Test-SpecrewDesignDecisionPhrase -Text $HumanTurn)) {
    throw ("The human's reply is not a design decision. A design decision starts with 'design decision' - for example: " +
        "`"design decision: option 2 - the concerns are testable on their own`". An 'approved for <boundary>' reply is a " +
        'boundary verdict and authorizes proceeding, not a choice between designs; the two are recorded separately on purpose.')
}
if ($Key -cnotmatch '^[a-z][a-z0-9-]{1,63}$') {
    throw "The decision key must be a short lowercase slug (letters, digits and hyphens), for example 'decomposition-style'. Got '$Key'."
}

$options = @()
for ($i = 0; $i -lt $OptionId.Count; $i++) {
    $options += [pscustomobject]@{ id = ([string]$OptionId[$i]).Trim(); summary = ([string]$OptionSummary[$i]).Trim() }
}

$record = [pscustomobject][ordered]@{
    question           = $Question.Trim()
    options            = @($options)
    chosen             = $Chosen.Trim()
    rationale          = $Rationale.Trim()
    confirmation       = 'crew-declared-human-reply'
    confirmation_scope = 'design-decision'
    human_turn         = $HumanTurn.Trim()
    recorded_at        = [DateTimeOffset]::UtcNow.ToString('o')
}

# VALIDATED BEFORE IT IS WRITTEN, with the same function the gate uses. A writer that validates by its own
# lights and a reader that validates by different ones is how a controller ends up well-formed to whoever
# produced it and invalid to everything downstream - the exact shape of the workshop defect this batch spent
# its largest single effort on.
$validation = Test-SpecrewDesignDecisionRecord -Record $record
if (-not $validation.valid) {
    $guidance = switch ([string]$validation.reason) {
        'design-decision-needs-at-least-two-options' {
            'A design decision records a choice, so it needs at least two options as they were presented. If there was only ever one way to do it, that is a constraint to write down, not a decision to record.'
        }
        'design-decision-chosen-not-among-options' {
            ("The chosen option '{0}' is not one of the options recorded ({1}). Record the options the human actually saw, then the one they picked." -f $record.chosen, (($options | ForEach-Object { $_.id }) -join ', '))
        }
        'design-decision-option-ids-not-unique' { 'Two options share an id, so the choice would be ambiguous.' }
        default { 'The decision could not be recorded in a form the gate can read.' }
    }
    throw ("This design decision was not recorded, and nothing else has changed. {0}" -f $guidance)
}

$path = Get-SpecrewDesignDecisionPath -ProjectRoot $root -FeatureRef $FeatureRef
$featureDir = Split-Path -Parent $path
if (-not (Test-Path -LiteralPath $featureDir -PathType Container)) {
    throw ("This feature has no directory yet, so there is nowhere to record the decision: '{0}'. Create the governed feature first." -f $featureDir)
}

$document = [ordered]@{ schema_version = '1.0'; feature_ref = (Split-Path -Leaf $FeatureRef); decisions = [ordered]@{} }
if (Test-Path -LiteralPath $path -PathType Leaf) {
    $existing = Read-SpecrewDesignDecisions -ProjectRoot $root -FeatureRef $FeatureRef
    if (-not $existing.valid) {
        throw ("The recorded design decisions for this feature could not be read, so nothing was changed. Ask for them to be repaired, then record this decision again. ({0})" -f $existing.reason)
    }
    foreach ($entry in $existing.decisions.GetEnumerator()) { $document.decisions[$entry.Key] = $entry.Value }
}
$replaced = $document.decisions.Contains($Key)
$document.decisions[$Key] = $record

$temp = $path + '.tmp-' + [guid]::NewGuid().ToString('N')
try {
    [System.IO.File]::WriteAllText($temp, ($document | ConvertTo-Json -Depth 12), [System.Text.UTF8Encoding]::new($false))
    [System.IO.File]::Move($temp, $path, $true)
}
finally {
    if (Test-Path -LiteralPath $temp -PathType Leaf) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
}

# Read back through the gate's own reader before reporting success. The write is the point of this script;
# reporting it on the strength of no exception is what makes a silent failure possible.
$verify = Read-SpecrewDesignDecisions -ProjectRoot $root -FeatureRef $FeatureRef
if (-not $verify.valid -or -not $verify.decisions.Contains($Key)) {
    throw ("The decision was written but could not be read back as valid, so treat it as not recorded. ({0})" -f $verify.reason)
}

if ($AsJson) {
    [pscustomobject][ordered]@{
        path = $path; key = $Key; chosen = $record.chosen; replaced = $replaced
        options = @($options | ForEach-Object { $_.id }); valid = $true
    } | ConvertTo-Json -Depth 6
    return
}

Write-Output ("Recorded the '{0}' design decision for {1}: option {2} of {3}, with the reason you gave. The gate reads it from {4}." -f
    $Key, (Split-Path -Leaf $FeatureRef), $record.chosen, (($options | ForEach-Object { $_.id }) -join '/'), $path)