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.
 
    NOTHING IS TORN DOWN WITHOUT A REPLACEMENT
    ------------------------------------------
    If the requested ring has no matching version - typically the production ring
    before the first stable release - init.ps1 does not fail the workspace. In
    order:
 
      1. .system\<Module> already exists -> it is kept EXACTLY as it is, and its
         .source.json is left alone, so the workspace keeps running the engine it
         was already running and its provenance still names whatever produced it.
      2. otherwise, a version is installed -> that is staged instead.
      3. otherwise -> it fails, naming the module, the
         ring, what is actually published, and both ways out.
 
    Step 1 deliberately beats step 2. Moving a workspace onto an unrelated version
    that happens to be in the module path, because a release has not happened yet,
    would change what it runs without anyone asking - worse than being stale. Every
    fallback is warned about, because it means the run is not on the ring it asked
    for.
 
    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.
#
# Nothing is torn down until a replacement is in hand. If the ring has no matching
# version, an engine that is already materialised keeps working: falling back to what
# is already here beats breaking a workspace over a release that has not happened yet.
# The fallback is always announced, because it means this run is not on the ring it
# was asked for.
$materialiseFromGallery = { param($capability, $resolved, $modulePath)
    $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 }

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

    if (-not $wanted -and -not $NoSync) {
        $ringSaid = if ($resolved.Ring -eq 'production') {
            "'$($capability.name)' has no stable release on the production ring"
        }
        else {
            "nothing matched for '$($capability.name)' on the '$($resolved.Ring)' ring$(if ($resolved.Version) { " at version '$($resolved.Version)'" })"
        }

        # 1. This workspace already has a materialised engine - that is what it has been
        # running, so leave it exactly as it is. This takes priority over anything in
        # the module path: swapping a workspace onto an unrelated installed version
        # because a release has not happened yet would change what it runs without
        # anyone asking, which is worse than being stale.
        if (Test-Path -LiteralPath $modulePath) {
            Write-Warning "$ringSaid; keeping .system\$module exactly as it is. Run '. .\init.ps1 -Source gallery -Ring preview -Engine $($capability.name)' to track prereleases instead."
            return @{ KeepExisting = $true }
        }
        # 2. Nothing materialised, but a version is installed - stage from that.
        elseif ($have) {
            Write-Warning "$ringSaid; falling back to the installed $module $($have.Version). Run '. .\init.ps1 -Source gallery -Ring preview -Engine $($capability.name)' to track prereleases instead."
        }
        # 3. Nothing anywhere - now it is genuinely stuck.
        else {
            $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." }
            throw "$ringSaid, and nothing is installed or already materialised to fall back on. 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)'. $detail"
        }
    }

    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) {
        # -NoSync with nothing installed still works if the workspace already holds a copy.
        if (Test-Path -LiteralPath $modulePath) {
            Write-Warning "'$module' is not installed$(if ($NoSync) { ' and -NoSync skipped the gallery' }); keeping the copy already in .system\$module."
            return @{ KeepExisting = $true }
        }
        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)
    }
    $keepExisting = $false
    if ($resolved.Source -eq 'gallery') {
        $gallery = & $materialiseFromGallery $capability $resolved $modulePath
        if ($gallery.KeepExisting) {
            # Nothing to copy: .system\ already holds a usable engine and there is no
            # newer one to be had. Leave it, and leave its .source.json alone so the
            # provenance still names whatever actually produced it.
            $keepExisting = $true
        }
        else {
            $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 ?? $modulePath)' and re-run. To discard the local edit, delete '$modulePath' and re-run."
        }
    }

    if ($keepExisting) {
        $kept = if (Test-Path -LiteralPath $recordPath) { (Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json) } else { $null }
        $what = if ($kept) { "$($kept.mode) $($kept.version ?? $kept.sha)" } else { 'the existing copy' }
        Write-Host "==> $($capability.name): kept .system\$($capability.module) as it is ($what)" -ForegroundColor DarkYellow
    }
    else {
        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
    }
}