Templates/customer-repo/init.ps1

# ============================================================================
# MANAGED FILE - DO NOT EDIT IN THE CUSTOMER WORKSPACE
# Source: Templates\customer-repo\init.ps1 inside the
# NKDAgility.AzureDevOps.AutomationTools module.
# This file is overwritten from that template on every run. Edit it there.
# ============================================================================
<#
.SYNOPSIS
    Initialises this customer workspace: materialises every declared capability,
    refreshes the framework-owned files, and loads the session. Run at the start
    of every session, and dot-source it at the top of runbooks:
 
        . .\init.ps1
 
.DESCRIPTION
    capabilities.json declares which nkdAgility engines this workspace uses. For
    each one, init.ps1:
 
      1. Materialises the engine, from ONE OF TWO SOURCES (see below).
      2. COPIES system\<Module> into .system\<Module> and records what it copied
         in .system\<Module>\.source.json.
      3. Scaffolds the engine's Templates\customer-repo\** into this workspace,
         only-if-missing, then overwrites the files listed in that template's
         .managed file.
      4. Renders the engine's Agents\CAPABILITY.md into the workspace's agent
         guidance files.
      5. Dot-sources <name>\init.ps1 if the engine shipped one.
 
    Finally it imports the automation tools module and calls
    Initialize-AutomationWorkspace against this folder.
 
    THE TWO SOURCES
    ---------------
    gallery (the default)
        Installs the published module from the PowerShell Gallery at a ring:
        'production' for stable releases, 'preview' for prereleases. Versioned
        and reproducible - this is what a scheduled audit should always run.
 
    clone
        Uses a git clone's WORKING TREE, uncommitted edits included, so an engine
        change takes effect in this workspace immediately with no publish step.
        The clone can be of the upstream repo or of your own fork; init.ps1 only
        ever reads the working tree, so a fork is just a clone with a different
        remote. NOT reproducible: two machines can differ.
 
    Switching between them, at any time:
 
        . .\init.ps1 # whatever is configured
        . .\init.ps1 -Source gallery -Ring preview # gallery, prereleases
        . .\init.ps1 -Source gallery -Ring production # gallery, stable
        . .\init.ps1 -Source clone -Engine governance # your clone
        . .\init.ps1 -Source clone -Engine governance -Path C:\src\my-fork
        . .\init.ps1 -Source clone -Engine governance -Repo https://github.com/me/fork.git
 
    WHERE THE CHOICE IS REMEMBERED
    ------------------------------
    capabilities.json committed, shared: the workspace's default 'source',
                          'ring' and optional exact 'version' per engine.
    workspace.local.json gitignored, yours alone: per-engine clone paths and
                          ring overrides written by the switches above.
 
    That split is deliberate. Pointing an engine at your clone must never follow
    you into the shared repo, or your teammates and CI would silently run an
    engine that exists on one laptop.
 
    Resolution order per engine, first match wins:
      1. $env:AZDO_ENGINE_<NAME> -> clone at that path (CI override)
      2. $env:AZDO_AUTOMATION_TOOLS -> clone ('automation' only)
      3. enginePaths.<name> in workspace.local.json -> clone
      4. capabilities.json 'source' -> gallery (default) or clone
      5. nothing set at all -> gallery, production ring
 
    .system\ is GENERATED and read-only. Never edit it: the next run overwrites
    it, and init.ps1 stops with an error if it notices a hand-edit rather than
    discarding the work silently. Change the engine in its clone and re-run -
    the copy takes uncommitted edits, so that is the normal way to test an
    engine change against a real workspace.
 
.PARAMETER Source
    'gallery' or 'clone'. Persisted, so it holds until you change it again.
    Without -Engine, applies to every declared engine.
 
.PARAMETER Ring
    'production' (stable) or 'preview' (prereleases). Gallery source only.
 
.PARAMETER Engine
    Limit -Source / -Ring to one capability, e.g. 'governance'.
 
.PARAMETER Path
    With '-Source clone': the clone to use. Defaults to
    %USERPROFILE%\source\repos\<repo-name>.
 
.PARAMETER Repo
    With '-Source clone': the URL to clone from if -Path does not exist yet.
    Point this at your fork. Defaults to the upstream repo in capabilities.json.
 
.PARAMETER NoSync
    Skip the network step - no git pull, no gallery check. Everything is still
    materialised from whatever is already local.
#>

[CmdletBinding()]
param(
    [ValidateSet('gallery', 'clone')]
    [string]$Source,

    [ValidateSet('production', 'preview')]
    [string]$Ring,

    [string]$Engine,
    [string]$Path,
    [string]$Repo,

    [switch]$NoSync
)

$ErrorActionPreference = 'Stop'
$workspaceRoot = $PSScriptRoot

# --- Local machine overrides ------------------------------------------------
$localFile = Join-Path $workspaceRoot 'workspace.local.json'
$local = if (Test-Path -LiteralPath $localFile) {
    Get-Content -LiteralPath $localFile -Raw | ConvertFrom-Json
}
else { $null }

# --- Which capabilities does this workspace use? ----------------------------
# The registry is the WORKSPACE's, not any engine's: that is what lets a workspace
# take on governance without the automation tools knowing governance exists.
$capabilitiesFile = Join-Path $workspaceRoot 'capabilities.json'
$capabilities = if (Test-Path -LiteralPath $capabilitiesFile) {
    @((Get-Content -LiteralPath $capabilitiesFile -Raw | ConvertFrom-Json).capabilities)
}
else {
    # A workspace scaffolded before capabilities.json existed still works, and a
    # bare folder bootstrapped by downloading this file gets a working default.
    @([pscustomobject]@{
            name   = 'automation'
            module = 'NKDAgility.AzureDevOps.AutomationTools'
            repo   = 'https://github.com/nkdAgility/azure-devops-automation-tools.git'
        })
}

# --- Apply and persist a -Source / -Ring switch -----------------------------
# Written to workspace.local.json, which is gitignored: choosing to run against
# your own clone is a per-machine decision and must not reach the shared repo.
if ($Source -or $Ring) {
    $targets = if ($Engine) {
        $match = @($capabilities | Where-Object { $_.name -eq $Engine })
        if (-not $match) {
            throw "No capability named '$Engine' in capabilities.json. Declared: $(($capabilities.name) -join ', ')."
        }
        $match
    }
    else { $capabilities }

    if (-not $local) { $local = [pscustomobject]@{} }
    foreach ($property in 'enginePaths', 'engineRings') {
        if (-not $local.PSObject.Properties[$property]) {
            $local | Add-Member -NotePropertyName $property -NotePropertyValue ([pscustomobject]@{})
        }
    }

    foreach ($target in $targets) {
        $name = $target.name

        if ($Source -eq 'clone') {
            $repoName = [System.IO.Path]::GetFileNameWithoutExtension(($target.repo -split '/')[-1])
            $clonePath = if ($Path) { $Path } else { Join-Path $env:USERPROFILE (Join-Path 'source\repos' $repoName) }
            $entry = [pscustomobject]@{ path = $clonePath }
            if ($Repo) { $entry | Add-Member -NotePropertyName 'repo' -NotePropertyValue $Repo }
            $local.enginePaths | Add-Member -NotePropertyName $name -NotePropertyValue $entry -Force
            Write-Host "==> $name : development mode, from '$clonePath'" -ForegroundColor Yellow
        }
        elseif ($Source -eq 'gallery') {
            if ($local.enginePaths.PSObject.Properties[$name]) {
                $local.enginePaths.PSObject.Properties.Remove($name)
            }
            Write-Host "==> $name : consumption mode, from the PowerShell Gallery" -ForegroundColor Cyan
        }

        if ($Ring) {
            $local.engineRings | Add-Member -NotePropertyName $name -NotePropertyValue $Ring -Force
            Write-Host " ring: $Ring" -ForegroundColor Cyan
        }
    }

    $local | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $localFile
    Write-Host " remembered in workspace.local.json (gitignored)" -ForegroundColor DarkGray
}

# --- Helpers ----------------------------------------------------------------
$sameContent = { param($a, $b)
    ((Get-Content -LiteralPath $a -Raw) -replace "`r`n", "`n") -eq
    ((Get-Content -LiteralPath $b -Raw) -replace "`r`n", "`n")
}

# Hash of a folder's contents: relative path + file hash for every file, sorted so the
# result is stable. Used to notice a hand-edited .system\ before it gets overwritten.
$treeHash = {
    param([string]$Root)
    if (-not (Test-Path -LiteralPath $Root)) { return $null }
    $rootLength = (Get-Item -LiteralPath $Root).FullName.Length + 1
    $lines = Get-ChildItem -LiteralPath $Root -Recurse -File -Force |
        Where-Object { $_.Name -ne '.source.json' } |
        ForEach-Object { "$($_.FullName.Substring($rootLength))|$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" } |
        Sort-Object
    $stream = [System.IO.MemoryStream]::new([System.Text.Encoding]::UTF8.GetBytes(($lines -join "`n")))
    (Get-FileHash -InputStream $stream -Algorithm SHA256).Hash
}

# Decide where one engine comes from. A clone override always wins: it is only ever
# set deliberately, by an env var or by -Source clone.
$resolveEngine = { param($capability)
    $repoName = [System.IO.Path]::GetFileNameWithoutExtension(($capability.repo -split '/')[-1])
    $envName = "AZDO_ENGINE_$($capability.name.ToUpperInvariant() -replace '[^A-Z0-9]', '_')"
    $name = $capability.name

    $clonePath = $null
    $cloneRepo = $capability.repo

    $fromEnv = [Environment]::GetEnvironmentVariable($envName)
    if ($fromEnv) { $clonePath = $fromEnv }
    elseif ($name -eq 'automation' -and $env:AZDO_AUTOMATION_TOOLS) { $clonePath = $env:AZDO_AUTOMATION_TOOLS }
    elseif ($local -and $local.PSObject.Properties['enginePaths'] -and $local.enginePaths.PSObject.Properties[$name]) {
        $entry = $local.enginePaths.$name
        # Accept the historical plain-string form as a bare path.
        if ($entry -is [string]) { $clonePath = $entry }
        else {
            $clonePath = $entry.path
            if ($entry.repo) { $cloneRepo = $entry.repo }
        }
    }
    elseif ($name -eq 'automation' -and $local -and $local.PSObject.Properties['toolsPath']) { $clonePath = $local.toolsPath }

    # capabilities.json can ask for clone mode without naming a path.
    if (-not $clonePath -and $capability.source -eq 'clone') {
        $clonePath = Join-Path $env:USERPROFILE (Join-Path 'source\repos' $repoName)
    }

    if ($clonePath) {
        return @{ Source = 'clone'; Path = $clonePath; Repo = $cloneRepo; RepoName = $repoName }
    }

    # Otherwise the gallery. Nothing configured at all means production.
    $resolvedRing =
        if ($local -and $local.PSObject.Properties['engineRings'] -and $local.engineRings.PSObject.Properties[$name]) { $local.engineRings.$name }
        elseif ($capability.ring) { $capability.ring }
        else { 'production' }

    @{ Source = 'gallery'; Ring = $resolvedRing; Version = $capability.version; RepoName = $repoName }
}

# Install (or update) the published module and hand back where it landed.
$materialiseFromGallery = { param($capability, $resolved)
    $module = $capability.module
    $allowPrerelease = ($resolved.Ring -eq 'preview')

    $findArgs = @{ Name = $module; Repository = 'PSGallery'; ErrorAction = 'SilentlyContinue' }
    if ($allowPrerelease) { $findArgs['AllowPrerelease'] = $true }
    if ($resolved.Version) { $findArgs['RequiredVersion'] = $resolved.Version }

    $wanted = if ($NoSync) { $null } else { Find-Module @findArgs }

    if (-not $wanted -and -not $NoSync) {
        # Say what IS there rather than leaving a bare "no match found".
        $available = @(Find-Module -Name $module -Repository PSGallery -AllVersions -AllowPrerelease -ErrorAction SilentlyContinue |
                Select-Object -ExpandProperty Version -First 10)
        $detail = if ($available) { "Available versions: $($available -join ', ')." }
                  else { "No versions of '$module' are published to the PowerShell Gallery yet." }
        $hint = if ($resolved.Ring -eq 'production') {
            "The '$($capability.name)' engine has no stable release on the production ring. Use a prerelease with '. .\init.ps1 -Source gallery -Ring preview -Engine $($capability.name)', or work from a clone with '. .\init.ps1 -Source clone -Engine $($capability.name)'."
        }
        else {
            "Nothing matched on the '$($resolved.Ring)' ring$(if ($resolved.Version) { " for version '$($resolved.Version)'" })."
        }
        throw "$hint $detail"
    }

    $installed = @(Get-Module -ListAvailable -Name $module)
    $have = $installed | Sort-Object Version -Descending | Select-Object -First 1

    if ($wanted -and (-not $have -or $have.Version -ne $wanted.Version)) {
        Write-Host "==> $($capability.name): installing $module $($wanted.Version) ($($resolved.Ring) ring)" -ForegroundColor Cyan
        # -AllowClobber because these modules export generically-named helpers
        # (Write-InfoLog and friends) that collide with whatever else is loaded.
        # The workspace runs the .system\ copy anyway, so what lands in the user
        # module path is only a staging area.
        $installArgs = @{ Name = $module; Repository = 'PSGallery'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true; RequiredVersion = $wanted.Version }
        if ($allowPrerelease) { $installArgs['AllowPrerelease'] = $true }
        Install-Module @installArgs
        $have = Get-Module -ListAvailable -Name $module | Sort-Object Version -Descending | Select-Object -First 1
    }

    if (-not $have) {
        throw "Capability '$($capability.name)': '$module' is not installed and could not be fetched$(if ($NoSync) { ' (-NoSync)' }). Remove -NoSync, or switch to a clone with '. .\init.ps1 -Source clone -Engine $($capability.name)'."
    }

    # Get-Module reports ModuleVersion only, so the prerelease tag has to be put
    # back on: '0.1.0' and '0.1.0-Preview1' are different builds, and .source.json
    # is what an audit result is attributed to.
    $tag = $have.PrivateData.PSData.Prerelease
    $full = "$($have.Version)" + $(if ($tag) { "-$($tag.TrimStart('-'))" } else { '' })

    @{ ModuleBase = $have.ModuleBase; Version = $full }
}

$systemRoot = Join-Path $workspaceRoot '.system'
$selfUpdated = $false
$loadedCapabilities = [System.Collections.Generic.List[hashtable]]::new()

# --- Per capability: sync, materialise, scaffold ----------------------------
foreach ($capability in $capabilities) {
    $resolved = & $resolveEngine $capability
    $modulePath = Join-Path $systemRoot $capability.module

    $provenance = @{
        capability = $capability.name
        module     = $capability.module
        mode       = $resolved.Source
    }

    if ($resolved.Source -eq 'clone') {
        $enginePath = $resolved.Path

        # 1. Clone or fast-forward pull. A dirty clone is left alone: the local edits are
        # almost always the point, and clobbering them would be worse than being stale.
        if (-not $NoSync) {
            if (-not (Test-Path -LiteralPath $enginePath)) {
                Write-Host "==> Cloning $($resolved.Repo)" -ForegroundColor Cyan
                git clone $resolved.Repo $enginePath
                if ($LASTEXITCODE -ne 0) { Write-Warning "Clone failed for $($resolved.Repo); continuing." }
            }
            elseif (git -C $enginePath status --porcelain) {
                Write-Warning "$($resolved.RepoName) has local changes; skipping pull."
            }
            else {
                git -C $enginePath pull --ff-only 2>&1 | Out-Null
                if ($LASTEXITCODE -ne 0) { Write-Warning "$($resolved.RepoName) pull failed (offline?); continuing with the existing clone." }
            }
        }

        $moduleSource = Join-Path $enginePath (Join-Path 'system' $capability.module)
        if (-not (Test-Path -LiteralPath $moduleSource)) {
            throw "Capability '$($capability.name)': module not found at '$moduleSource'. Expected a clone of $($resolved.Repo) at '$enginePath'."
        }

        $provenance['source'] = $enginePath
        $provenance['sha'] = $(if ($sha = git -C $enginePath rev-parse HEAD 2>$null) { $sha } else { 'unknown' })
        $provenance['dirty'] = [bool](git -C $enginePath status --porcelain 2>$null)
    }
    else {
        $gallery = & $materialiseFromGallery $capability $resolved
        $moduleSource = $gallery.ModuleBase
        $provenance['source'] = 'PSGallery'
        $provenance['ring'] = $resolved.Ring
        $provenance['version'] = $gallery.Version
        $provenance['dirty'] = $false
    }

    # 2. Materialise. Refuse to discard a hand-edit silently.
    $recordPath = Join-Path $modulePath '.source.json'
    if ((Test-Path -LiteralPath $modulePath) -and (Test-Path -LiteralPath $recordPath)) {
        $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
        if ($record.treeHash -and (& $treeHash $modulePath) -ne $record.treeHash) {
            throw ".system\$($capability.module) has been modified since it was copied. It is generated and must not be edited - move your change into '$moduleSource' and re-run. To discard the local edit, delete '$modulePath' and re-run."
        }
    }

    if (Test-Path -LiteralPath $modulePath) {
        Get-ChildItem -LiteralPath $modulePath -Recurse -File -Force | ForEach-Object { $_.IsReadOnly = $false }
        Remove-Item -LiteralPath $modulePath -Recurse -Force
    }
    New-Item -Path $systemRoot -ItemType Directory -Force | Out-Null
    Copy-Item -LiteralPath $moduleSource -Destination $modulePath -Recurse
    $from = if ($resolved.Source -eq 'clone') { "clone $($resolved.Path)" } else { "gallery $($provenance['version']) ($($resolved.Ring))" }
    Write-Host "==> $($capability.name): copied $($capability.module) into .system\ from $from" -ForegroundColor Cyan

    $provenance['treeHash'] = (& $treeHash $modulePath)
    $provenance['copiedAt'] = (Get-Date).ToString('o')
    $provenance | ConvertTo-Json | Set-Content -LiteralPath $recordPath

    if ($provenance['dirty']) {
        Write-Warning "$($resolved.RepoName) has uncommitted changes; .system\ holds them. This run is NOT reproducible - commit them, or switch to the gallery with '. .\init.ps1 -Source gallery -Engine $($capability.name)', before treating its output as evidence."
    }
    # Read-only so an accidental save in the editor fails loudly instead of being lost.
    Get-ChildItem -LiteralPath $modulePath -Recurse -File -Force | ForEach-Object { $_.IsReadOnly = $true }

    # 3. Scaffold this capability's slice of the workspace. Every engine ships the same
    # shape - Templates\customer-repo\** relative to the workspace root - so the
    # automation tools land at the root and governance lands under governance\.
    $templateRoot = Join-Path $modulePath 'Templates\customer-repo'
    if (Test-Path -LiteralPath $templateRoot) {
        $managedList = @()
        $managedFile = Join-Path $templateRoot '.managed'
        if (Test-Path -LiteralPath $managedFile) {
            $managedList = @(Get-Content -LiteralPath $managedFile |
                    Where-Object { $_.Trim() -and -not $_.Trim().StartsWith('#') } |
                    ForEach-Object { $_.Trim() -replace '/', '\' })
        }
        # A real dot-file in the template would hide it from the engine repo, so
        # .gitignore ships under a neutral name and is renamed on the way out.
        $renameMap = @{ 'gitignore.template' = '.gitignore' }
        # Source material for rendered agent guidance; it stays in the module.
        $doNotScaffold = @('CLAUDE.managed.md', '.managed')

        $templateRootLength = (Get-Item -LiteralPath $templateRoot).FullName.Length + 1
        foreach ($template in (Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Force)) {
            $relative = $template.FullName.Substring($templateRootLength)
            if ($relative -in $doNotScaffold) { continue }
            $isManaged = $relative -in $managedList
            if ($renameMap.ContainsKey($relative)) { $relative = $renameMap[$relative] }
            $target = Join-Path $workspaceRoot $relative

            if (Test-Path -LiteralPath $target) {
                # Seeds are the workspace's own once copied; only managed files are refreshed.
                if (-not $isManaged) { continue }
                if (& $sameContent $template.FullName $target) { continue }
            }
            New-Item -Path (Split-Path -Parent $target) -ItemType Directory -Force | Out-Null
            if (Test-Path -LiteralPath $target) { (Get-Item -LiteralPath $target).IsReadOnly = $false }
            Copy-Item -LiteralPath $template.FullName -Destination $target -Force
            # The template is read from .system\, which is read-only; the workspace's copy
            # is the workspace's own, so clear the attribute Copy-Item carried across.
            (Get-Item -LiteralPath $target).IsReadOnly = $false
            $verb = if ($isManaged) { 'Updated' } else { 'Created' }
            Write-Host " $verb $relative" -ForegroundColor $(if ($isManaged) { 'Cyan' } else { 'Green' })
            if ($relative -eq 'init.ps1') { $selfUpdated = $true }
        }
    }

    $loadedCapabilities.Add(@{
            Name       = $capability.name
            Module     = $capability.module
            ModulePath = $modulePath
            InitScript = Join-Path $workspaceRoot (Join-Path $capability.name 'init.ps1')
        })
}

# This running copy is now the stale one, so hand over to the new file. The
# env guard stops a bad template turning the handover into a loop. The switches
# have already been persisted, so the handover does not need to repeat them.
if ($selfUpdated -and -not $env:AZDO_INIT_RELOADED) {
    $env:AZDO_INIT_RELOADED = '1'
    try { . $PSCommandPath -NoSync:$NoSync }
    finally { Remove-Item Env:\AZDO_INIT_RELOADED -ErrorAction SilentlyContinue }
    return
}

# --- Standard folders -------------------------------------------------------
foreach ($folder in 'data', 'exports', 'migrations', 'output', 'secrets') {
    New-Item -Path (Join-Path $workspaceRoot $folder) -ItemType Directory -Force | Out-Null
}

# --- Scaffold missing local files from their examples ----------------------
# Every committed '<name>.example.<ext>' has a gitignored '<name>.<ext>' sibling
# that each machine owns (secrets/secrets.json, and any per-migration equivalent).
# Create the missing ones from the example so a fresh clone has the right shape,
# with the placeholders left in - never a real value.
$examples = Get-ChildItem -LiteralPath $workspaceRoot -Recurse -File -Filter '*.example.*' -Force |
    Where-Object { $_.FullName -notmatch '\\(\.git|\.system|output)\\' }
$needsValues = [System.Collections.Generic.List[string]]::new()
foreach ($example in $examples) {
    $target = Join-Path $example.DirectoryName ($example.Name -replace '\.example(\.[^.]+)$', '$1')
    $relative = if ($target.StartsWith($workspaceRoot, [StringComparison]::OrdinalIgnoreCase)) {
        $target.Substring($workspaceRoot.Length).TrimStart('\', '/')
    }
    else { $target }
    if (-not (Test-Path -LiteralPath $target)) {
        Copy-Item -LiteralPath $example.FullName -Destination $target
        Write-Host "==> Created $relative from $($example.Name)" -ForegroundColor Yellow
    }
    # Unedited '<placeholder>' markers mean the file has no real values yet.
    if ((Get-Content -LiteralPath $target -Raw) -match '"<[^">\r\n]+>"') { $needsValues.Add($relative) }
}
if ($needsValues.Count) {
    Write-Warning "Placeholders still to fill in: $($needsValues -join ', ')"
}

# --- Render agent guidance --------------------------------------------------
# Each engine ships Agents\CAPABILITY.md describing how to work that capability.
# It is RENDERED into the workspace's agent files rather than referenced, because
# the result is committed: a fresh clone then has full guidance before init.ps1 has
# ever run, and Copilot has no import mechanism at all. Files are co-owned - only
# the marked block is rewritten, so anything the workspace wrote above it survives.
$blockBody = @()
$blockBody += (Get-Content -LiteralPath (Join-Path $systemRoot 'NKDAgility.AzureDevOps.AutomationTools\Templates\customer-repo\CLAUDE.managed.md') -Raw -ErrorAction SilentlyContinue)
foreach ($loaded in $loadedCapabilities) {
    $capabilityDoc = Join-Path $loaded.ModulePath 'Agents\CAPABILITY.md'
    if (Test-Path -LiteralPath $capabilityDoc) {
        $blockBody += (Get-Content -LiteralPath $capabilityDoc -Raw)
    }
}
$blockBody = (($blockBody | Where-Object { $_ }) -join "`n`n").TrimEnd()

$blockStart = '<!-- BEGIN managed: nkdagility -->'
$blockEnd = '<!-- END managed: nkdagility -->'
$block = "$blockStart`n$blockBody`n$blockEnd"

$renderTargets = @(
    @{ Path = 'CLAUDE.md'; Skeleton = $null }
    @{ Path = 'AGENTS.md'; Skeleton = "# Agent guide`n`nSee CLAUDE.md for this workspace's own notes. The block below is generated.`n" }
    @{ Path = '.github\copilot-instructions.md'; Skeleton = "# Copilot instructions`n`nSee CLAUDE.md for this workspace's own notes. The block below is generated.`n" }
)
foreach ($target in $renderTargets) {
    $file = Join-Path $workspaceRoot $target.Path
    if (-not (Test-Path -LiteralPath $file)) {
        if (-not $target.Skeleton) { continue }
        New-Item -Path (Split-Path -Parent $file) -ItemType Directory -Force | Out-Null
        Set-Content -LiteralPath $file -Value $target.Skeleton
    }
    $current = Get-Content -LiteralPath $file -Raw
    # Spliced by index rather than regex: the block spans newlines and can contain '$'
    # and other regex-replacement metacharacters, both of which silently corrupt a
    # -replace here.
    $startAt = $current.IndexOf($blockStart)
    $endAt = $current.IndexOf($blockEnd)
    $updated = if ($startAt -ge 0 -and $endAt -gt $startAt) {
        $current.Substring(0, $startAt) + $block + $current.Substring($endAt + $blockEnd.Length)
    }
    else {
        $current.TrimEnd() + "`n`n" + $block + "`n"
    }
    if (($updated -replace "`r`n", "`n") -ne ($current -replace "`r`n", "`n")) {
        Set-Content -LiteralPath $file -Value $updated -NoNewline
        Write-Host "==> Rendered agent guidance into $($target.Path)" -ForegroundColor Cyan
    }
}

# --- Import the automation tools and initialise the workspace ---------------
# From .system\, never from a clone or the module path: the workspace runs the
# copy it recorded.
$automation = $loadedCapabilities | Where-Object { $_.Module -eq 'NKDAgility.AzureDevOps.AutomationTools' } | Select-Object -First 1
if (-not $automation) {
    throw "capabilities.json must include the 'NKDAgility.AzureDevOps.AutomationTools' module: it owns the workspace itself."
}
Import-Module $automation.ModulePath -Force
Initialize-AutomationWorkspace -Path $workspaceRoot | Out-Null

# --- Export the workspace secrets as environment variables ------------------
# One secrets file serves every capability: the migration tools bind them into .NET
# config, and a governance manifest.yaml names one as its accessToken. -NoClobber means
# a CI-provided secret or a deliberate per-shell override always wins over the file.
$secretsPath = (Get-AutomationWorkspace).SecretsPath
if (Test-Path -LiteralPath $secretsPath) {
    Set-AutomationSecrets -SecretsPath $secretsPath -NoClobber | Out-Null
}

# --- Load each capability ---------------------------------------------------
foreach ($loaded in $loadedCapabilities) {
    if (Test-Path -LiteralPath $loaded.InitScript) {
        . $loaded.InitScript -WorkspaceRoot $workspaceRoot
    }
}