src/public/System/Get-AitherModuleRoot.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Get the AitherZero project root directory
 
.DESCRIPTION
    Returns the root directory of the AitherZero project (where config.psd1 is located).
    This is a private helper function that provides a consistent way to get the project
    root without duplicating the path calculation logic across all cmdlets.
 
    Uses the module-scoped $script:ProjectRoot variable if available (set during module
    initialization), otherwise calculates it from the current script location.
 
.NOTES
    This is a private helper function. Use Get-AitherProjectRoot for public access.
#>

function Get-AitherModuleRoot {
    # SELF-HEAL: never trust a value that points into a transient git worktree
    # (…\.claude\worktrees\*) — a parallel workflow's shadow copy loaded off
    # PSModulePath can poison the cache/env, which sends playbook + script lookups
    # to the wrong checkout. Such values are ignored so we fall through to the
    # canonical walk-up below.
    $_isWorktree = { param($p) $p -and ($p -match '[\\/]\.claude[\\/]worktrees[\\/]') }

    # HARD FIX (worktree shadow): if THIS module is running from inside a transient
    # workflow worktree (…/.claude/worktrees/<id>/…), map straight back to the MAIN
    # repo root — the directory that CONTAINS .claude/worktrees. Otherwise the
    # walk-up below re-finds the worktree's OWN .PRODUCTS/.AITHERZERO/config.psd1 and
    # pins every playbook/script lookup to a STALE checkout (the "Playbook not found"
    # bug: a new playbook committed to the main repo is invisible to the worktree copy).
    # D-882: read $script:ProjectRoot DEFENSIVELY. The module runs under
    # StrictMode, where reading a variable that was never set THROWS instead of
    # yielding $null - and $script: does not resolve to the module's scope on
    # every path this function can be reached by (0803's health check hit
    # exactly this and reported its whole Module Status section as failed).
    # A missing root must degrade to the fallbacks below, not detonate.
    $cachedRoot = if (Get-Variable -Name 'ProjectRoot' -Scope Script -ErrorAction SilentlyContinue) { $script:ProjectRoot } else { $null }

    foreach ($p in @($PSScriptRoot, $cachedRoot, $PWD.Path)) {
        if ($p -and ($p -match '^(.*?)[\\/]\.claude[\\/]worktrees[\\/]')) {
            $mainRoot = $matches[1]
            if (Test-Path (Join-Path $mainRoot '.PRODUCTS/.AITHERZERO/config/config.psd1')) {
                return $mainRoot
            }
        }
    }

    # Use cached value if available (set during module initialization)
    if ($cachedRoot -and -not (& $_isWorktree $cachedRoot)) {
        return $cachedRoot
    }

    # Fallback: use environment variable
    if ($env:AITHERZERO_ROOT -and -not (& $_isWorktree $env:AITHERZERO_ROOT)) {
        return $env:AITHERZERO_ROOT
    }

    # Calculate from current script location (goes up from Public/Private to project root)
    $currentPath = $PSScriptRoot
    if (-not $currentPath) { $currentPath = $PWD.Path }

    # Walk up the directory tree looking for EITHER supported layout.
    #
    # TWO LAYOUTS EXIST and both must resolve (2026-07-25):
    # monorepo <root>/.PRODUCTS/.AITHERZERO/config/config.psd1 (project root = <root>)
    # standalone <root>/config/config.psd1 (project root = <root>)
    # The standalone case is what the PUBLIC Aitherium/AitherZero repo ships —
    # there is no .PRODUCTS/ there at all. Testing only the monorepo marker made
    # every walk-up fall through to the depth-2 fallback below, which returned a
    # directory OUTSIDE the repo. Get-AitherConfigs then reported
    # "Base configuration file not found: <parent>/AitherZero/config/config.psd1"
    # and Invoke-AitherScript could not run a single script for a public user.
    # Monorepo is checked FIRST so a nested checkout still resolves to the outer
    # root exactly as before.
    while ($currentPath) {
        if (Test-Path (Join-Path $currentPath ".PRODUCTS/.AITHERZERO/config/config.psd1")) {
            return $currentPath
        }
        # Standalone marker, but NOT when this directory is itself the monorepo's
        # <root>/.PRODUCTS/.AITHERZERO — that directory carries the same two files,
        # so without this guard the walk-up stops there and reports the module dir
        # as the project root instead of the outer repo.
        if ((Test-Path (Join-Path $currentPath "config/config.psd1")) -and
            (Test-Path (Join-Path $currentPath "AitherZero.psd1")) -and
            (Split-Path (Split-Path $currentPath -Parent) -Leaf) -ne '.PRODUCTS') {
            return $currentPath
        }

        $parentPath = Split-Path $currentPath -Parent
        if ($parentPath -eq $currentPath) { break } # Reached root of drive
        $currentPath = $parentPath
    }

    # Fallback: assume standard structure if config not found (e.g. during build)
    if ($PSScriptRoot) {
        # Try to find module root by looking for AitherZero.psd1
        $curr = $PSScriptRoot
        while ($curr -and -not (Test-Path (Join-Path $curr 'AitherZero.psd1'))) {
            $curr = Split-Path $curr -Parent
        }
        if ($curr) {
            # $curr is the directory HOLDING the manifest. In the standalone
            # layout that IS the project root — do not climb out of the repo.
            if (Test-Path (Join-Path $curr 'config/config.psd1')) {
                return $curr
            }
            # Monorepo: manifest sits at <root>/.PRODUCTS/.AITHERZERO, so the
            # project root is two levels up.
            return Split-Path (Split-Path $curr -Parent) -Parent
        }

        # Last resort fallback (module is now at .PRODUCTS/.AITHERZERO/, so go up 2 levels)
        return Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent
    }

    # Last resort: current location
    return Get-Location
}