extensions/specrew-speckit/scripts/scaffold-governance.ps1

# scaffold-governance.ps1
# Creates governance artifacts (constitution, iteration config, role assignments) for a downstream project

<#
.SYNOPSIS
    Scaffolds governance artifacts for a Specrew-managed project.
 
.DESCRIPTION
    Creates the downstream governance files required by the Specrew bootstrap:
    - .specrew\config.yml
    - .specrew\constitution.md
    - .specrew\iteration-config.yml (including Phase 2 routing-strength defaults)
    - .specrew\role-assignments.yml
 
.PARAMETER ProjectPath
    Path to the target project directory.
 
.PARAMETER SpecrewVersion
    Specrew version to record in .specrew\config.yml.
 
.PARAMETER SpecKitVersion
    Detected Spec Kit version to record in .specrew\config.yml.
 
.PARAMETER SquadVersion
    Detected Squad version to record in .specrew\config.yml.
 
.PARAMETER BootstrapMode
    Bootstrap mode to record in .specrew\config.yml.
 
.PARAMETER DryRun
    Show intended changes without writing files.
 
.PARAMETER PassThru
    Return structured action details.
 
.EXAMPLE
    .\scaffold-governance.ps1 -ProjectPath "C:\Projects\MyApp"
#>


[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$ProjectPath,

    [string]$SpecrewVersion = '0.1.0-dev',
    [string]$SpecKitVersion,
    [string]$SquadVersion,
    [ValidateSet('greenfield', 'brownfield')]
    [string]$BootstrapMode = 'greenfield',
    [switch]$DryRun,
    [switch]$PassThru
)

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

$sharedGovernancePath = Join-Path $PSScriptRoot 'shared-governance.ps1'
if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) {
    throw "Missing shared governance helper '$sharedGovernancePath'."
}
. $sharedGovernancePath

function Get-TargetFileContent {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TemplatePath,

        [Parameter(Mandatory = $true)]
        [string]$CreatedDate
    )

    $content = Get-Content -Path $TemplatePath -Raw
    return $content -replace '<!-- YYYY-MM-DD populated at bootstrap -->', $CreatedDate
}

function Save-ManagedFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TargetPath,

        [Parameter(Mandatory = $true)]
        [string]$Content,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions
    )

    $targetExists = Test-Path -LiteralPath $TargetPath
    $action = if ($targetExists) {
        'preserved'
    }
    elseif ($DryRun) {
        'would-create'
    }
    else {
        'created'
    }

    $null = $Actions.Add([pscustomobject]@{
            Path   = $TargetPath
            Action = $action
        })

    if ($targetExists -or $DryRun) {
        return
    }

    $parent = Split-Path -Parent $TargetPath
    if (-not (Test-Path -LiteralPath $parent)) {
        New-Item -Path $parent -ItemType Directory -Force | Out-Null
    }

    [System.IO.File]::WriteAllText($TargetPath, $Content, [System.Text.UTF8Encoding]::new($false))
}

function Ensure-ManagedDirectory {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TargetPath,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions
    )

    $targetExists = Test-Path -LiteralPath $TargetPath
    $action = if ($targetExists) {
        'preserved-directory'
    }
    elseif ($DryRun) {
        'would-create-directory'
    }
    else {
        'created-directory'
    }

    $null = $Actions.Add([pscustomobject]@{
            Path   = $TargetPath
            Action = $action
        })

    if ($targetExists -or $DryRun) {
        return
    }

    New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
}

function Save-ManagedTemplateTree {
    param(
        [Parameter(Mandatory = $true)]
        [string]$SourceRoot,

        [Parameter(Mandatory = $true)]
        [string]$TargetRoot,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions
    )

    if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) {
        return
    }

    Ensure-ManagedDirectory -TargetPath $TargetRoot -Actions $Actions

    $sourceFiles = Get-ChildItem -LiteralPath $SourceRoot -File -Recurse | Sort-Object FullName
    foreach ($sourceFile in $sourceFiles) {
        $relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $sourceFile.FullName)
        $targetPath = Join-Path $TargetRoot $relativePath
        $content = Get-Content -LiteralPath $sourceFile.FullName -Raw
        Save-ManagedFile -TargetPath $targetPath -Content $content -Actions $Actions
    }
}

function Save-ConfigFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TargetPath,

        [Parameter(Mandatory = $true)]
        [string]$Content,

        [Parameter(Mandatory = $true)]
        [string]$QualityBlock,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [System.Collections.ArrayList]$Actions
    )

    if (-not (Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
        Save-ManagedFile -TargetPath $TargetPath -Content $Content -Actions $Actions
        return
    }

    $existingContent = Get-Content -LiteralPath $TargetPath -Raw
    $updatedContent = $existingContent
    if ($updatedContent -notmatch '(?m)^schema:\s*"?(?<value>[^"\r\n]+)"?\s*$') {
        $updatedContent = ('schema: "v1"' + [Environment]::NewLine + $updatedContent.TrimStart())
    }
    if ($updatedContent -match '(?m)^quality:\s*$') {
        $null = $Actions.Add([pscustomobject]@{
                Path   = $TargetPath
                Action = $(if ($updatedContent -eq $existingContent) { 'preserved' } else { if ($DryRun) { 'would-update' } else { 'updated' } })
            })
        if (-not $DryRun -and $updatedContent -ne $existingContent) {
            [System.IO.File]::WriteAllText($TargetPath, $updatedContent, [System.Text.UTF8Encoding]::new($false))
        }
        return
    }

    $separator = if ($updatedContent.EndsWith("`n")) { '' } else { "`r`n" }
    $updatedContent = $updatedContent.TrimEnd() + $separator + "`r`n" + $QualityBlock
    $action = if ($DryRun) { 'would-update' } else { 'updated' }
    $null = $Actions.Add([pscustomobject]@{
            Path   = $TargetPath
            Action = $action
        })

    if ($DryRun) {
        return
    }

    [System.IO.File]::WriteAllText($TargetPath, $updatedContent, [System.Text.UTF8Encoding]::new($false))
}

$resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath
$extensionRoot = Split-Path -Parent $PSScriptRoot
$templateRoot = Join-Path $extensionRoot 'templates'
$specrewRoot = Join-Path $resolvedProjectPath '.specrew'
$qualityTemplateRoot = Join-Path $templateRoot 'quality'
$presetTemplateRoot = Join-Path $qualityTemplateRoot 'presets'
$lensTemplateRoot = Join-Path $qualityTemplateRoot 'lenses'
$presetRoot = Join-Path $specrewRoot 'presets'
$lensRoot = Join-Path $specrewRoot 'lenses'
$createdDate = Get-Date -Format 'yyyy-MM-dd'
$actions = [System.Collections.ArrayList]::new()

if (-not (Test-Path -LiteralPath $resolvedProjectPath)) {
    $null = $actions.Add([pscustomobject]@{
            Path   = $resolvedProjectPath
            Action = $(if ($DryRun) { 'would-create-directory' } else { 'created-directory' })
        })

    if (-not $DryRun) {
        New-Item -Path $resolvedProjectPath -ItemType Directory -Force | Out-Null
    }
}

Ensure-ManagedDirectory -TargetPath $specrewRoot -Actions $actions
Ensure-ManagedDirectory -TargetPath $presetRoot -Actions $actions
Ensure-ManagedDirectory -TargetPath $lensRoot -Actions $actions

$constitutionContent = Get-TargetFileContent -TemplatePath (Join-Path $templateRoot 'downstream-constitution.md') -CreatedDate $createdDate
# The iteration-config template carries the default agent strength metadata used by
# Phase 2 strongest-available review routing.
$iterationConfigContent = Get-Content -Path (Join-Path $templateRoot 'iteration-config.yml') -Raw
$roleAssignmentsContent = Get-Content -Path (Join-Path $templateRoot 'role-assignments.yml') -Raw
$qualityBlock = @"
quality:
  presets_path: ".specrew/presets"
  lenses_path: ".specrew/lenses"
  findings_schema_version: "v1"
  evidence_directory_name: "quality"
"@

$configContent = @"
schema: "v1"
specrew_version: "$SpecrewVersion"
speckit_version: "$SpecKitVersion"
squad_version: "$SquadVersion"
bootstrap_date: "$createdDate"
bootstrap_mode: "$BootstrapMode"
governance:
  constitution_path: ".specrew/constitution.md"
  iteration_config_path: ".specrew/iteration-config.yml"
  role_assignments_path: ".specrew/role-assignments.yml"
$qualityBlock
"@


Save-ManagedFile -TargetPath (Join-Path $specrewRoot 'constitution.md') -Content $constitutionContent -Actions $actions
Save-ManagedFile -TargetPath (Join-Path $specrewRoot 'iteration-config.yml') -Content $iterationConfigContent -Actions $actions
Save-ManagedFile -TargetPath (Join-Path $specrewRoot 'role-assignments.yml') -Content $roleAssignmentsContent -Actions $actions
Save-ConfigFile -TargetPath (Join-Path $specrewRoot 'config.yml') -Content $configContent -QualityBlock $qualityBlock -Actions $actions
Save-ManagedTemplateTree -SourceRoot $presetTemplateRoot -TargetRoot $presetRoot -Actions $actions
Save-ManagedTemplateTree -SourceRoot $lensTemplateRoot -TargetRoot $lensRoot -Actions $actions

if ($PassThru) {
    $actions
    return
}

$actions |
    Select-Object Action, Path |
    Format-Table -AutoSize

Write-Host ("Governance scaffolding {0} for {1}" -f ($(if ($DryRun) { 'previewed' } else { 'completed' }), $resolvedProjectPath)) -ForegroundColor Green
exit 0