Payload/claude/hooks/invoke-claude-hook.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Run a harness hook and return the Claude host decision.

.DESCRIPTION
    Project Claude settings call this adapter. It runs the same harness script
    the other hosts use, then returns the decision Claude enforces: stdout {}
    when a tool is allowed, exit 2 with a stderr reason when a tool is denied,
    one BLOCKED stop, and a plain session line for an open turn. After a paired
    reviewer, the parent reminder is PostToolUse additionalContext on the Agent
    tool. SubagentStop stays quiet so the finished reviewer is not the audience.
    Direct script output is not host use.

.PARAMETER Hook
    Harness script stem: reviewer-stop, harness-pretooluse-guard, harness-stop-gate,
    or harness-session-start.

.PARAMETER InputJson
    Hook payload. When omitted, the script reads stdin.

.PARAMETER InputJsonFile
    Path to a payload file. Used by tests so the JSON is not re-quoted.

.PARAMETER RepoRoot
    Repository root passed to the harness script.

.PARAMETER FeatureRoot
    Feature folder passed to the guard or session-start script.

.PARAMETER SkipJournal
    Forwarded to the tool-use guard for an isolated feature root under TEMP.

.PARAMETER ShowHelp
    Print operator help and exit.

.EXAMPLE
    pwsh -File ./.claude/hooks/invoke-claude-hook.ps1 -Hook harness-stop-gate

.EXAMPLE
    pwsh -File ./.claude/hooks/invoke-claude-hook.ps1 -Hook harness-pretooluse-guard -InputJsonFile .\payload.json
#>

[CmdletBinding(DefaultParameterSetName = 'Run')]
param(
    [Parameter(Mandatory = $true, ParameterSetName = 'Run')]
    [ValidateSet('reviewer-stop', 'harness-pretooluse-guard', 'harness-stop-gate', 'harness-session-start')]
    [string]$Hook,
    [string]$InputJson = '',
    [string]$InputJsonFile = '',
    [string]$RepoRoot = '',
    [string]$FeatureRoot = '',
    [switch]$SkipJournal,
    [Parameter(ParameterSetName = 'Help')]
    [Alias('h', 'help')]
    [switch]$ShowHelp
)

function Show-ClaudeHookHelp {
    Write-Output 'Translates a harness hook into the Claude allow, deny, stop, reminder, or session-start decision.'
    Write-Output 'Example: pwsh -File ./.claude/hooks/invoke-claude-hook.ps1 -Hook harness-stop-gate'
}

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if ($ShowHelp) {
    Show-ClaudeHookHelp
    exit 0
}

function Invoke-BoundHookProcess {
    param(
        [string]$ScriptPath = '',
        [string]$Stdin = '',
        [string[]]$ScriptArguments = @(),
        [string]$CommandText = ''
    )
    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    $psi.FileName = (Get-Command pwsh -ErrorAction Stop).Source
    $psi.UseShellExecute = $false
    $psi.RedirectStandardInput = $true
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true
    $psi.CreateNoWindow = $true
    $psi.ArgumentList.Add('-NoProfile')
    $psi.ArgumentList.Add('-ExecutionPolicy')
    $psi.ArgumentList.Add('Bypass')
    if (-not [string]::IsNullOrWhiteSpace($CommandText)) {
        $psi.ArgumentList.Add('-Command')
        $psi.ArgumentList.Add($CommandText)
    } else {
        $psi.ArgumentList.Add('-File')
        $psi.ArgumentList.Add($ScriptPath)
        foreach ($argument in @($ScriptArguments)) {
            if (-not [string]::IsNullOrWhiteSpace($argument)) {
                $psi.ArgumentList.Add($argument)
            }
        }
    }
    $process = [System.Diagnostics.Process]::new()
    $process.StartInfo = $psi
    [void]$process.Start()
    if ($null -eq $Stdin) {
        $Stdin = ''
    }
    $process.StandardInput.Write($Stdin)
    $process.StandardInput.Close()
    $stdoutTask = $process.StandardOutput.ReadToEndAsync()
    $stderrTask = $process.StandardError.ReadToEndAsync()
    [void]$process.WaitForExit()
    return [pscustomobject]@{
        ExitCode = $process.ExitCode
        Stdout   = $stdoutTask.Result
        Stderr   = $stderrTask.Result
    }
}

function Get-ClaudePropertyValue {
    param(
        $Object,
        [string]$Name
    )
    if ($null -eq $Object -or $Object -isnot [pscustomobject] -or [string]::IsNullOrWhiteSpace($Name)) {
        return $null
    }
    $prop = $Object.PSObject.Properties[$Name]
    if ($null -eq $prop) {
        return $null
    }
    return $prop.Value
}

# Blank text, invalid JSON, arrays, and scalars are not a hook object.
# Callers must stop before they treat that result as an allow, a reminder, or a skipped block.
function ConvertFrom-ClaudeHookJson {
    param([AllowEmptyString()][string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) {
        return $null
    }
    try {
        $parsed = $Text | ConvertFrom-Json
    } catch {
        return $null
    }
    if ($parsed -isnot [pscustomobject]) {
        return $null
    }
    return $parsed
}

function Get-GuardDecision {
    param([AllowEmptyString()][string]$Text)
    $reason = 'The tool call was denied.'
    $parsed = ConvertFrom-ClaudeHookJson -Text $Text
    if ($null -eq $parsed) {
        return [pscustomobject]@{ Parsed = $false; Denied = $false; Reason = $reason }
    }
    $denied = $false
    foreach ($name in @('permission', 'permissionDecision')) {
        $value = Get-ClaudePropertyValue -Object $parsed -Name $name
        if ($value -is [string] -and $value -eq 'deny') {
            $denied = $true
        }
    }
    $nested = Get-ClaudePropertyValue -Object $parsed -Name 'hookSpecificOutput'
    if ($nested -is [pscustomobject]) {
        $nestedDecision = Get-ClaudePropertyValue -Object $nested -Name 'permissionDecision'
        if ($nestedDecision -is [string] -and $nestedDecision -eq 'deny') {
            $denied = $true
        }
        $nestedReason = Get-ClaudePropertyValue -Object $nested -Name 'permissionDecisionReason'
        if ($nestedReason -is [string] -and -not [string]::IsNullOrWhiteSpace($nestedReason)) {
            $reason = $nestedReason
        }
    }
    foreach ($name in @('agent_message', 'permissionDecisionReason', 'user_message')) {
        $value = Get-ClaudePropertyValue -Object $parsed -Name $name
        if ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) {
            $reason = $value
            break
        }
    }
    return [pscustomobject]@{ Parsed = $true; Denied = $denied; Reason = $reason }
}

function Test-StopHookAlreadyActive {
    param([AllowEmptyString()][string]$Text)
    $parsed = ConvertFrom-ClaudeHookJson -Text $Text
    if ($null -eq $parsed) {
        return $false
    }
    $active = Get-ClaudePropertyValue -Object $parsed -Name 'stop_hook_active'
    # Only a real boolean true releases the second stop. A missing field or the
    # string "true" still runs the gate, so a bad payload cannot skip BLOCKED.
    return ($active -is [bool] -and $active -eq $true)
}

function Get-ClaudeParentReminder {
    param([AllowEmptyString()][string]$Text)
    $quiet = [pscustomobject]@{ SpeakToParent = $false; ReviewerType = '' }
    $parsed = ConvertFrom-ClaudeHookJson -Text $Text
    if ($null -eq $parsed) {
        return $quiet
    }
    $eventName = Get-ClaudePropertyValue -Object $parsed -Name 'hook_event_name'
    if ($eventName -isnot [string]) {
        $eventName = ''
    }
    # SubagentStop additionalContext continues the finished reviewer. The parent
    # hears the reminder on PostToolUse for the Agent tool, and only when this
    # process is not already inside that reviewer.
    if ($eventName -eq 'SubagentStop') {
        return $quiet
    }
    $agentId = Get-ClaudePropertyValue -Object $parsed -Name 'agent_id'
    if ($agentId -is [string] -and -not [string]::IsNullOrWhiteSpace($agentId)) {
        return $quiet
    }
    $toolName = Get-ClaudePropertyValue -Object $parsed -Name 'tool_name'
    if ($eventName -ne 'PostToolUse' -or $toolName -isnot [string] -or $toolName -ne 'Agent') {
        return $quiet
    }
    $reviewerType = ''
    $toolInput = Get-ClaudePropertyValue -Object $parsed -Name 'tool_input'
    if ($toolInput -is [pscustomobject]) {
        $fromInput = Get-ClaudePropertyValue -Object $toolInput -Name 'subagent_type'
        if ($fromInput -is [string]) {
            $reviewerType = $fromInput
        }
    }
    if ([string]::IsNullOrWhiteSpace($reviewerType)) {
        $fromPayload = Get-ClaudePropertyValue -Object $parsed -Name 'subagent_type'
        if ($fromPayload -is [string]) {
            $reviewerType = $fromPayload
        }
    }
    if ([string]::IsNullOrWhiteSpace($reviewerType) -or $reviewerType -notmatch 'reviewer') {
        return $quiet
    }
    return [pscustomobject]@{ SpeakToParent = $true; ReviewerType = $reviewerType }
}

$installRoot = (Resolve-Path (Join-Path $PSScriptRoot (Join-Path '..' '..'))).Path
if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    $RepoRoot = $installRoot
}

$payload = ''
if (-not [string]::IsNullOrWhiteSpace($InputJsonFile)) {
    $payload = Get-Content -LiteralPath $InputJsonFile -Raw
} elseif (-not [string]::IsNullOrWhiteSpace($InputJson)) {
    $payload = $InputJson
} else {
    $payload = [Console]::In.ReadToEnd()
}

function Invoke-ClaudePreToolUseGuard {
    param(
        [string]$InstallRoot,
        [string]$RepoRoot,
        [string]$FeatureRoot,
        [switch]$SkipJournal,
        [AllowEmptyString()][string]$Payload
    )
    $parsed = ConvertFrom-ClaudeHookJson -Text $Payload
    $toolName = Get-ClaudePropertyValue -Object $parsed -Name 'tool_name'
    if ($null -eq $parsed -or $toolName -isnot [string] -or [string]::IsNullOrWhiteSpace($toolName)) {
        [Console]::Error.WriteLine('The tool call was denied.')
        exit 2
    }
    $stdin = $Payload
    if ($toolName -in @('MultiEdit', 'NotebookEdit')) {
        $parsed.tool_name = 'Edit'
        $stdin = $parsed | ConvertTo-Json -Compress -Depth 8
    }
    $scriptPath = Join-Path $InstallRoot 'scripts\kaden\harness\harness-pretooluse-guard.ps1'
    $scriptArguments = [System.Collections.Generic.List[string]]::new()
    if (-not [string]::IsNullOrWhiteSpace($RepoRoot)) {
        $scriptArguments.Add('-RepoRoot')
        $scriptArguments.Add($RepoRoot)
    }
    if (-not [string]::IsNullOrWhiteSpace($FeatureRoot)) {
        $scriptArguments.Add('-FeatureRoot')
        $scriptArguments.Add($FeatureRoot)
    }
    if ($SkipJournal) {
        $scriptArguments.Add('-SkipJournal')
    }
    $result = Invoke-BoundHookProcess -ScriptPath $scriptPath -Stdin $stdin -ScriptArguments @($scriptArguments.ToArray())
    $stdout = ([string]$result.Stdout).Trim()
    $childError = ([string]$result.Stderr).Trim()
    if ($result.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($stdout)) {
        $reason = $childError
        if ([string]::IsNullOrWhiteSpace($reason)) {
            $reason = 'The tool call was denied.'
        }
        [Console]::Error.WriteLine($reason)
        exit 2
    }
    $decision = Get-GuardDecision -Text $stdout
    if (-not $decision.Parsed -or $decision.Denied) {
        [Console]::Error.WriteLine($decision.Reason)
        exit 2
    }
    # {} lets Claude apply permissions.ask. permissionDecision allow would skip those prompts.
    Write-Output '{}'
    exit 0
}

function Invoke-ClaudeStopGate {
    param(
        [string]$InstallRoot,
        [string]$RepoRoot,
        [AllowEmptyString()][string]$Payload
    )
    # Claude sets stop_hook_active on the attempt that follows a block.
    # Exit 0 here so that attempt does not print BLOCKED again.
    # Invalid JSON is not an active stop, so the gate still runs.
    if (Test-StopHookAlreadyActive -Text $Payload) {
        exit 0
    }
    $scriptPath = Join-Path $InstallRoot 'scripts\kaden\harness\harness-stop-gate.ps1'
    # -Command, not -File. A redirected -File run stops on the first non-terminating
    # shape-repair error and never prints BLOCKED.
    $quotedScript = $scriptPath.Replace("'", "''")
    $quotedRoot = $RepoRoot.Replace("'", "''")
    $commandText = "& '$quotedScript' -RepoRoot '$quotedRoot'"
    $result = Invoke-BoundHookProcess -CommandText $commandText
    $stdout = ([string]$result.Stdout).Trim()
    if ($result.ExitCode -eq 0) {
        exit 0
    }
    $message = $stdout
    $childError = ''
    if ($null -ne $result.Stderr) {
        $childError = ([string]$result.Stderr).Trim()
    }
    if (-not [string]::IsNullOrWhiteSpace($childError)) {
        if ([string]::IsNullOrWhiteSpace($message)) {
            $message = $childError
        } else {
            $message = $message + [Environment]::NewLine + $childError
        }
    }
    if (-not [string]::IsNullOrWhiteSpace($message)) {
        Write-Output $message
        [Console]::Error.WriteLine($message)
    }
    exit 2
}

function Invoke-ClaudeSessionStart {
    param(
        [string]$InstallRoot,
        [string]$RepoRoot,
        [string]$FeatureRoot,
        [AllowEmptyString()][string]$Payload
    )
    $scriptPath = Join-Path $InstallRoot 'scripts\kaden\harness\harness-session-start.ps1'
    $scriptArguments = [System.Collections.Generic.List[string]]::new()
    $scriptArguments.Add('-RepoRoot')
    $scriptArguments.Add($RepoRoot)
    if (-not [string]::IsNullOrWhiteSpace($FeatureRoot)) {
        $scriptArguments.Add('-FeatureRoot')
        $scriptArguments.Add($FeatureRoot)
    }
    $result = Invoke-BoundHookProcess -ScriptPath $scriptPath -Stdin $Payload -ScriptArguments @($scriptArguments.ToArray())
    $stdout = ([string]$result.Stdout).Trim()
    if ($result.ExitCode -ne 0) {
        $childError = ''
        if ($null -ne $result.Stderr) {
            $childError = ([string]$result.Stderr).Trim()
        }
        if ([string]::IsNullOrWhiteSpace($childError)) {
            $childError = 'Session start did not finish.'
        }
        [Console]::Error.WriteLine($childError)
        exit 2
    }
    $parsed = ConvertFrom-ClaudeHookJson -Text $stdout
    $context = Get-ClaudePropertyValue -Object $parsed -Name 'additionalContext'
    if ($context -is [string] -and -not [string]::IsNullOrWhiteSpace($context)) {
        Write-Output $context
    }
    exit 0
}

function Invoke-ClaudeReviewerStop {
    param(
        [string]$InstallRoot,
        [AllowEmptyString()][string]$Payload
    )
    $parentReminder = Get-ClaudeParentReminder -Text $Payload
    if (-not $parentReminder.SpeakToParent) {
        exit 0
    }
    $scriptPath = Join-Path $InstallRoot '.cursor\hooks\reviewer-stop.ps1'
    $normalized = @{ subagent_type = $parentReminder.ReviewerType } | ConvertTo-Json -Compress
    $result = Invoke-BoundHookProcess -ScriptPath $scriptPath -Stdin $normalized -ScriptArguments @()
    $stdout = ([string]$result.Stdout).Trim()
    if ($result.ExitCode -ne 0) {
        [Console]::Error.WriteLine('The reviewer finish did not return a parent reminder.')
        exit 2
    }
    $parsed = ConvertFrom-ClaudeHookJson -Text $stdout
    $followup = Get-ClaudePropertyValue -Object $parsed -Name 'followup_message'
    if ($followup -isnot [string] -or [string]::IsNullOrWhiteSpace($followup)) {
        exit 0
    }
    $reminder = [pscustomobject]@{
        hookSpecificOutput = [pscustomobject]@{
            hookEventName     = 'PostToolUse'
            additionalContext = $followup
        }
    }
    Write-Output ($reminder | ConvertTo-Json -Compress -Depth 5)
    exit 0
}

switch ($Hook) {
    'harness-pretooluse-guard' {
        Invoke-ClaudePreToolUseGuard -InstallRoot $installRoot -RepoRoot $RepoRoot -FeatureRoot $FeatureRoot -SkipJournal:$SkipJournal -Payload $payload
    }
    'harness-stop-gate' {
        Invoke-ClaudeStopGate -InstallRoot $installRoot -RepoRoot $RepoRoot -Payload $payload
    }
    'harness-session-start' {
        Invoke-ClaudeSessionStart -InstallRoot $installRoot -RepoRoot $RepoRoot -FeatureRoot $FeatureRoot -Payload $payload
    }
    'reviewer-stop' {
        Invoke-ClaudeReviewerStop -InstallRoot $installRoot -Payload $payload
    }
}