public/Invoke-WayforgeGate.ps1

function Invoke-WayforgeGate {
    <#
    .SYNOPSIS
        Evaluates a workflow's gates for a stage and reports pass/block.
 
    .DESCRIPTION
        The single gate engine every enforcement point calls — harness hooks,
        git pre-commit/pre-push hooks, and CI. It loads the gate definitions from
        .workflow/definitions, derives the changeset for the stage, evaluates each
        applicable gate, and returns a PSWayforge.GateReport.
 
        With -AsHook, it also writes the caller's dialect (deny JSON for harness
        hooks, a human report for git/ci) and sets the report's ExitCode so a
        calling shim can `exit $report.ExitCode`.
 
        The evaluation is wrapped so that any internal error (for example a
        malformed workflow definition) fails closed: the engine emits a
        block decision through the caller's dialect rather than crashing.
 
    .PARAMETER Stage
        The enforcement stage: pre-tool, stop, pre-commit, pre-push, or ci.
 
    .PARAMETER AsHook
        The caller dialect. Harness values (claude/codex/kimi/grok/copilot,
        cursor, opencode, pi) emit deny output + exit 2; git/ci emit a human
        report + exit 1 on block; 'none' (default) reports only.
 
    .PARAMETER EventJson
        The harness event payload (for pre-tool/stop), typically read from stdin.
 
    .PARAMETER ProjectPath
        A path inside the target repository. Defaults to the current directory;
        the git top-level is resolved from it.
 
    .PARAMETER WorkflowName
        A single workflow definition to evaluate. Defaults to every definition in
        .workflow/definitions.
 
    .PARAMETER ChangeSet
        Overrides changeset derivation (mainly for testing).
 
    .EXAMPLE
        Invoke-WayforgeGate -Stage pre-commit -AsHook git
 
        Evaluates the pre-commit gates for the current repository, prints a
        report, and returns a report whose ExitCode is non-zero if any
        block-severity gate failed (so a git hook can abort the commit).
 
    .EXAMPLE
        '{"tool_name":"Edit","tool_input":{"file_path":"src/a.ps1"}}' |
            & { Invoke-WayforgeGate -Stage pre-tool -AsHook claude -EventJson ([Console]::In.ReadToEnd()) }
 
        Evaluates the mid-session gates for a Claude Edit and emits a Claude
        permission-decision payload (deny + exit 2) when blocked.
    #>

    [CmdletBinding()]
    [OutputType('PSWayforge.GateReport')]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('pre-tool', 'stop', 'pre-commit', 'pre-push', 'ci')]
        [string] $Stage,

        [ValidateSet('claude', 'codex', 'kimi', 'grok', 'copilot', 'cursor', 'opencode', 'pi', 'git', 'ci', 'none')]
        [string] $AsHook = 'none',

        [string] $EventJson,

        [string] $ProjectPath = (Get-Location).Path,

        [string] $WorkflowName,

        [string[]] $ChangeSet
    )

    try {
        $root = Resolve-WayforgeGitRoot -Path $ProjectPath

        $set    = Get-WayforgeGateSet -Root $root -WorkflowName $WorkflowName
        $gates  = $set.Gates
        $scopes = $set.Scopes

        $actionTool    = $null
        $actionCommand = $null
        if (-not $PSBoundParameters.ContainsKey('ChangeSet')) {
            if ($Stage -in @('pre-tool', 'stop')) {
                $ctx           = Get-WayforgeActionContext -EventJson $EventJson -Root $root
                $ChangeSet     = $ctx.Paths
                $actionTool    = $ctx.ToolName
                $actionCommand = $ctx.Command
            }
            else {
                $ChangeSet = Get-WayforgeChangeSet -Stage $Stage -Root $root -EventJson $EventJson
            }
        }

        $results = [System.Collections.Generic.List[object]]::new()
        foreach ($gate in $gates) {
            $on = @(Get-WayforgeField $gate 'on')
            if ($Stage -notin $on) { continue }

            $id       = Get-WayforgeField $gate 'id' '(unnamed)'
            $desc     = Get-WayforgeField $gate 'description' $id
            $severity = Get-WayforgeField $gate 'severity' 'block'
            $when     = Get-WayforgeField $gate 'when' 'always'
            $check    = Get-WayforgeField $gate 'check'

            if (-not (Test-WayforgeChangeCondition -Condition $when -ChangeSet $ChangeSet -Scopes $scopes)) {
                $results.Add((New-WayforgeGateResult -Id $id -Severity $severity -Status 'skip' -Message "skipped ($when = false)")) | Out-Null
                continue
            }

            $eval   = Invoke-WayforgeCheck -Check $check -Root $root -Description $desc -ChangeSet $ChangeSet -ToolName $actionTool -Command $actionCommand
            $status = if ($eval.Ok) { 'pass' } elseif ($severity -eq 'warn') { 'warn' } else { 'fail' }
            $results.Add((New-WayforgeGateResult -Id $id -Severity $severity -Status $status -Message $eval.Message -Detail $eval.Detail)) | Out-Null
        }

        $blocked = @($results | Where-Object Status -eq 'fail').Count -gt 0
        $report = [PSCustomObject]@{
            PSTypeName = 'PSWayforge.GateReport'
            Stage      = $Stage
            Blocked    = $blocked
            Results    = $results.ToArray()
            ExitCode   = 0
        }
    }
    catch {
        # Fail closed within our control: never let an internal error escape past
        # the dialect serializer. The caller receives a well-formed block.
        $errResult = New-WayforgeGateResult -Id 'wayforge-engine-error' -Severity 'block' -Status 'fail' `
            -Message 'Wayforge gate engine error' -Detail $_.Exception.Message
        $report = [PSCustomObject]@{
            PSTypeName = 'PSWayforge.GateReport'
            Stage      = $Stage
            Blocked    = $true
            Results    = @($errResult)
            ExitCode   = 0
        }
    }

    $report.ExitCode = Write-WayforgeGateDialect -Report $report -AsHook $AsHook
    return $report
}