Templates/customer-repo/governance/init.ps1

# ============================================================================
# MANAGED FILE - DO NOT EDIT IN THE CUSTOMER WORKSPACE
# Source: Templates\customer-repo\governance\init.ps1 inside the
# NKDAgility.AzureDevOps.Governance module.
# This file is overwritten from that template on every run. Edit it there.
# ============================================================================
<#
.SYNOPSIS
    Governance capability loader for a customer workspace.

.DESCRIPTION
    Dot-sourced by the workspace's root init.ps1 once it has materialised
    .system\NKDAgility.AzureDevOps.Governance. Imports that copy and reports the
    programs this workspace governs.

    A program is a folder under governance\programs\<name>\ holding:
      manifest.yaml program identity, organisation, accessToken variable name
      hierarchy.yaml the authored organisation hierarchy
      access.yaml roles and group catalog
      members\ membership per code key

    Build artefacts are written under the workspace output folder, never here.

    Secrets come from the workspace contract, not from a governance-specific file:
    secrets\secrets.json names an environment variable per organisation, the root
    init.ps1 exports them, and manifest.yaml references one by name as accessToken.

.EXAMPLE
    Invoke-Governance build odyssey
    Invoke-Governance plan odyssey
    Invoke-Governance audit odyssey
    Invoke-Governance preflight odyssey -Code PTL-FND
    Invoke-Governance preflight odyssey -Code PTL-FND -Offline # re-analyse, no live calls
    Invoke-Governance preflight odyssey -SkipFresh # only teams with no data file yet
    Invoke-Governance preflight-report odyssey # render every team's fix report (offline)
    Invoke-Governance apply odyssey -WhatIf

    Invoke-Governance resolves the program and resolved.yaml paths from this folder and
    the workspace output folder. The module's own commands remain available if you want
    to be explicit about both.
#>

[CmdletBinding()]
param(
    # Workspace root. The root init.ps1 passes this; it defaults to the parent of
    # this file so the capability can also be loaded on its own.
    [string]$WorkspaceRoot = (Split-Path -Parent $PSScriptRoot)
)

$ErrorActionPreference = 'Stop'

$moduleName = 'NKDAgility.AzureDevOps.Governance'
$modulePath = Join-Path $WorkspaceRoot (Join-Path '.system' $moduleName)
if (-not (Test-Path -LiteralPath $modulePath)) {
    throw "$moduleName is not materialised at '$modulePath'. Run the workspace's init.ps1, which copies it in from the tools clone."
}

# The module declares powershell-yaml as a requirement; importing it fails without.
if (-not (Get-Module -ListAvailable -Name 'powershell-yaml')) {
    Write-Host '==> Installing powershell-yaml (required by the governance module)' -ForegroundColor Cyan
    Install-Module -Name 'powershell-yaml' -Scope CurrentUser -Force -AllowClobber
}

Import-Module $modulePath -Force

# --- Register this capability's safety hook --------------------------------
# .claude\hooks\deny-governance-apply.ps1 is shipped and refreshed by this
# engine, but the file that ACTIVATES it - .claude\settings.json - is a seed
# owned by the workspace (scaffolded by the automation tools). A hook that
# ships and is never wired up is not a control, so register it here, on every
# session, idempotently: add exactly one PreToolUse entry, never remove or
# reorder anything else, and warn rather than throw if the file cannot be read.
$hookRelative = '.claude/hooks/deny-governance-apply.ps1'
$hookFile = Join-Path $WorkspaceRoot ($hookRelative -replace '/', '\')
if (Test-Path -LiteralPath $hookFile) {
    $settingsPath = Join-Path $WorkspaceRoot '.claude\settings.json'
    $hookCommand = 'pwsh -NoProfile -File "$CLAUDE_PROJECT_DIR/' + $hookRelative + '"'
    try {
        $settings = if (Test-Path -LiteralPath $settingsPath) {
            Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
        }
        else { [pscustomobject]@{} }

        if (-not $settings.PSObject.Properties['hooks']) {
            $settings | Add-Member -NotePropertyName 'hooks' -NotePropertyValue ([pscustomobject]@{})
        }
        if (-not $settings.hooks.PSObject.Properties['PreToolUse']) {
            $settings.hooks | Add-Member -NotePropertyName 'PreToolUse' -NotePropertyValue @()
        }

        $already = @($settings.hooks.PreToolUse | Where-Object {
                @($_.hooks | Where-Object { [string]$_.command -like "*deny-governance-apply.ps1*" }).Count -gt 0
            }).Count -gt 0

        if (-not $already) {
            $entry = [pscustomobject]@{
                matcher = 'PowerShell|Bash'
                hooks   = @([pscustomobject]@{ type = 'command'; command = $hookCommand })
            }
            $settings.hooks.PreToolUse = @($settings.hooks.PreToolUse) + $entry
            New-Item -Path (Split-Path -Parent $settingsPath) -ItemType Directory -Force | Out-Null
            $settings | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $settingsPath
            Write-Host "==> Governance: registered the deny-apply hook in .claude\settings.json" -ForegroundColor Cyan
        }
    }
    catch {
        Write-Warning ("Could not register the governance deny-apply hook in '$settingsPath': $_. " +
            "Add a PreToolUse entry with matcher 'PowerShell|Bash' running '$hookRelative' by hand, " +
            'or agents in this workspace will not be blocked from running a governance apply.')
    }
}

$programsRoot = Join-Path $PSScriptRoot 'programs'
# Invoke-Governance is global so it outlives this script, so it cannot close over a
# script-scoped variable - by the time anyone calls it, this scope is gone. The roots go
# in globals it can still reach.
$Global:NkdaGovernanceProgramsRoot = $programsRoot
$Global:NkdaGovernanceFallbackOut = Join-Path $PSScriptRoot 'out'

# Resolve a program name to the two paths every governance command needs, so runbooks and
# CI say 'odyssey' rather than repeating both. This is workspace glue, deliberately not
# engine code: the module's commands stay explicit about what they read and write.
function Global:Invoke-Governance {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateSet('build', 'validate', 'plan', 'apply', 'audit', 'preflight', 'preflight-report')]
        [string]$Command,

        [Parameter(Mandatory, Position = 1)]
        [string]$Program,

        [string]$Org,
        [string]$Code,
        [switch]$WhatIf,
        [switch]$Prune,
        [switch]$Offline,    # preflight: re-analyse the last gathered data file, touch no org
        [switch]$SkipFresh   # preflight: gather only nodes with no data file yet; reuse the rest
    )

    $programPath = Join-Path $Global:NkdaGovernanceProgramsRoot $Program
    if (-not (Test-Path -LiteralPath (Join-Path $programPath 'manifest.yaml'))) {
        throw "No governance program '$Program' at '$programPath' (expected a manifest.yaml)."
    }

    $outputRoot = try { Join-Path (Get-AutomationWorkspace).OutputFolder 'governance' }
    catch { $Global:NkdaGovernanceFallbackOut }
    $resolvedPath = Join-Path (Join-Path $outputRoot $Program) 'resolved.yaml'
    New-Item -Path (Split-Path -Parent $resolvedPath) -ItemType Directory -Force | Out-Null

    switch ($Command) {
        'build' { Invoke-GovernanceBuild -ProgramPath $programPath -OutputPath $resolvedPath }
        'validate' { Test-Governance -ProgramPath $programPath }
        'plan' { Invoke-GovernancePlan -ProgramPath $programPath -ResolvedPath $resolvedPath -Org $Org }
        'audit' { Invoke-GovernanceAudit -ProgramPath $programPath -ResolvedPath $resolvedPath -Org $Org }
        'preflight' { Invoke-GovernancePreflight -ProgramPath $programPath -ResolvedPath $resolvedPath -Org $Org -Code $Code -Offline:$Offline -SkipFresh:$SkipFresh }
        'preflight-report' { Invoke-GovernancePreflightReport -ProgramPath $programPath -ResolvedPath $resolvedPath -Code $Code }
        'apply' { Invoke-GovernanceApply -ProgramPath $programPath -ResolvedPath $resolvedPath -Org $Org -WhatIf:$WhatIf -Prune:$Prune }
    }
}

$programs = @(Get-ChildItem -LiteralPath $programsRoot -Directory -ErrorAction SilentlyContinue |
        Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'manifest.yaml') })

if ($programs.Count) {
    Write-Host "==> Governance: $($programs.Count) program(s) - $($programs.Name -join ', ')" -ForegroundColor Cyan
}
else {
    Write-Host "==> Governance: no programs yet. Create governance\programs\<name>\manifest.yaml to start." -ForegroundColor DarkGray
}