#!/bin/bash
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT

# SessionEnd Hook - Portable Plugin Version
# Captures session completion and aggregate statistics
# Also logs active ticket for audit/observability (OMN-1830)
# Performance target: <50ms execution time
# NOTE: This hook is audit-only - NO context injection, NO contract mutation

set -euo pipefail
_OMNICLAUDE_HOOK_NAME="$(basename "${BASH_SOURCE[0]}")"
source "$(dirname "${BASH_SOURCE[0]}")/error-guard.sh" 2>/dev/null || true

# --- Lite mode guard [OMN-5398] ---
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_MODE_SH="${_SCRIPT_DIR}/../../lib/mode.sh"
if [[ -f "$_MODE_SH" ]]; then source "$_MODE_SH"; [[ "$(omniclaude_mode)" == "lite" ]] && exit 0; fi
unset _SCRIPT_DIR _MODE_SH

# Ensure stable CWD before any Python invocation.
# The session CWD may be on an external drive that disconnects/remounts;
# Python's <frozen getpath> calls os.getcwd() during startup and crashes
# with "failed to make path absolute" if the CWD is unavailable.
cd "$HOME" 2>/dev/null || cd /tmp || true

# Portable Plugin Configuration
# Resolve absolute path of this script, handling relative invocation (e.g. ./session-end.sh).
# Falls back to python3 if realpath is unavailable (non-GNU macOS without coreutils).
_SELF="$(realpath "${BASH_SOURCE[0]}" 2>/dev/null \
    || python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(cd "$(dirname "${_SELF}")" && pwd)"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
unset _SELF SCRIPT_DIR
HOOKS_DIR="${PLUGIN_ROOT}/hooks"
HOOKS_LIB="${HOOKS_DIR}/lib"
LOG_FILE="${HOOKS_DIR}/logs/hook-session-end.log"

# Detect project root
PROJECT_ROOT="${PLUGIN_ROOT}/../.."
if [[ -f "${PROJECT_ROOT}/.env" ]]; then
    PROJECT_ROOT="$(cd "${PROJECT_ROOT}" && pwd)"
elif [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
    PROJECT_ROOT="${CLAUDE_PROJECT_DIR}"
else
    PROJECT_ROOT="$(pwd)"
fi

# Ensure log directory exists
mkdir -p "$(dirname "$LOG_FILE")"

export PYTHONPATH="${PROJECT_ROOT}:${PLUGIN_ROOT}/lib:${HOOKS_LIB}:${PYTHONPATH:-}"

# Load environment variables (before common.sh so KAFKA_BOOTSTRAP_SERVERS is available)
if [[ -f "$PROJECT_ROOT/.env" ]]; then
    set -a
    source "$PROJECT_ROOT/.env" 2>/dev/null || true
    set +a
fi

# OMN-3725: Mark as advisory — exit 0 gracefully if Python is missing
export OMNICLAUDE_HOOK_CRITICALITY="advisory"

# Source shared functions (provides PYTHON_CMD, KAFKA_ENABLED, get_time_ms, log)
source "${HOOKS_DIR}/scripts/common.sh"

# Read stdin (validate JSON; fall back to empty object on malformed input)
INPUT=$(cat)
if ! echo "$INPUT" | jq -e . >/dev/null 2>>"$LOG_FILE"; then
    log "ERROR: Malformed JSON on stdin, using empty object"
    INPUT='{}'
fi

log "SessionEnd hook triggered (plugin mode)"

# Extract session metadata
SESSION_ID=$(echo "$INPUT" | jq -r '.sessionId // ""' 2>/dev/null || echo "")
SESSION_DURATION=$(echo "$INPUT" | jq -r '.durationMs // 0' 2>/dev/null || echo "0")
SESSION_REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other")

# Extract tool call count from session payload (if available)
TOOL_CALLS_COMPLETED=$(echo "$INPUT" | jq -r '.tools_used_count // 0' 2>/dev/null || echo "0")
if [[ "$TOOL_CALLS_COMPLETED" == "0" ]]; then
    log "Phase 1: tools_used_count absent or zero — SUCCESS gate unreachable (see OMN-1892); raw outcome still emitted via OMN-2356"
fi

# Validate reason is one of the allowed values
case "$SESSION_REASON" in
    clear|logout|prompt_input_exit|other) ;;
    *) SESSION_REASON="other" ;;
esac

if [[ -n "$SESSION_ID" ]]; then
    log "Session ID: $SESSION_ID"
fi
log "Duration: ${SESSION_DURATION}ms"
log "Reason: $SESSION_REASON"

# -----------------------------
# Active Ticket Detection (OMN-1830)
# -----------------------------
# Check for active ticket (for audit logging only - NO context injection, NO mutation)
TICKET_INJECTION_ENABLED="${OMNICLAUDE_TICKET_INJECTION_ENABLED:-true}"
TICKET_INJECTION_ENABLED=$(_normalize_bool "$TICKET_INJECTION_ENABLED")
ACTIVE_TICKET=""

if [[ "${TICKET_INJECTION_ENABLED}" == "true" ]] && [[ -f "${HOOKS_LIB}/ticket_context_injector.py" ]]; then
    # Use CLI interface for consistency with session-start.sh (OMN-1830)
    TICKET_OUTPUT=$(echo '{}' | "$PYTHON_CMD" "${HOOKS_LIB}/ticket_context_injector.py" 2>>"$LOG_FILE") || TICKET_OUTPUT='{}'
    ACTIVE_TICKET=$(echo "$TICKET_OUTPUT" | jq -r '.ticket_id // empty' 2>/dev/null) || ACTIVE_TICKET=""

    if [[ -n "$ACTIVE_TICKET" ]]; then
        log "Session ended with active ticket: $ACTIVE_TICKET"
    else
        log "Session ended with no active ticket"
    fi
elif [[ "${TICKET_INJECTION_ENABLED}" != "true" ]]; then
    log "Active ticket detection disabled (TICKET_INJECTION_ENABLED=false)"
else
    log "Ticket context injector not found, skipping active ticket detection"
fi

# Call session intelligence module (async, non-blocking)
(
    $PYTHON_CMD "${HOOKS_LIB}/session_intelligence.py" \
        --mode end \
        --session-id "${SESSION_ID}" \
        --metadata "{\"hook_duration_ms\": ${SESSION_DURATION}}" \
        >> "$LOG_FILE" 2>&1 || { rc=$?; log "Session end logging failed (exit=$rc)"; }
) &

# Convert duration from ms to seconds once (used by all subshells below)
# Uses awk instead of Python to avoid ~30-50ms interpreter startup cost
# on the synchronous path (preserves <50ms SessionEnd budget).
DURATION_SECONDS="0"
if [[ -n "$SESSION_DURATION" && "$SESSION_DURATION" != "0" ]]; then
    # Sanitize: accept integer or float durationMs from Claude Code.
    # Float values (e.g. 45200.5) are valid — truncate to integer to satisfy
    # the schema's duration_ms: int declaration and pass them to awk.
    if [[ "$SESSION_DURATION" =~ ^[0-9]+$ ]]; then
        : # Already a strict integer — no change needed
    elif [[ "$SESSION_DURATION" =~ ^[0-9]+\.[0-9]+$ ]]; then
        # Float: truncate fractional part (e.g. 45200.5 → 45200)
        _raw_duration="$SESSION_DURATION"
        SESSION_DURATION="${SESSION_DURATION%%.*}"
        log "WARNING: durationMs is a float (${_raw_duration}), truncating to integer (${SESSION_DURATION})"
    else
        log "WARNING: durationMs has unexpected format '${SESSION_DURATION}', resetting to 0"
        SESSION_DURATION=0
    fi
    DURATION_SECONDS=$(awk -v ms="$SESSION_DURATION" 'BEGIN{v=ms/1000; printf "%.3f", (v<0?0:v)}' 2>/dev/null || echo "0")
fi

# Read correlation_id from state file BEFORE backgrounded subshells (OMN-2190)
# Must happen in main shell to avoid race with cleanup at end.
# Uses jq instead of Python to avoid ~30-50ms interpreter startup on the
# synchronous path (preserves <50ms SessionEnd budget).
CORRELATION_ID=""
source "$(dirname "${BASH_SOURCE[0]}")/onex-paths.sh" || { echo "ONEX_STATE_DIR not set" >&2; exit 1; }
CORRELATION_STATE_FILE="${ONEX_HOOKS_STATE_DIR}/correlation_id.json"
if [[ -f "$CORRELATION_STATE_FILE" ]]; then
    CORRELATION_ID=$(jq -r '.correlation_id // empty' "$CORRELATION_STATE_FILE" 2>/dev/null) || CORRELATION_ID=""
fi

# Emit session.ended event to Kafka (backgrounded for parallelism)
# Uses emit_client_wrapper with daemon fan-out (OMN-1632)
# PIDs tracked for drain-then-stop at end (no fixed sleep needed at SessionEnd).
EMIT_PIDS=()
if [[ "$KAFKA_ENABLED" == "true" ]]; then
    (
        # Build JSON payload for emit daemon (includes active_ticket for OMN-1830)
        # DURATION_SECONDS pre-computed in main shell; map "0" to null (no duration info)
        SESSION_PAYLOAD=$(jq -n \
            --arg session_id "$SESSION_ID" \
            --arg reason "$SESSION_REASON" \
            --arg duration_seconds "$DURATION_SECONDS" \
            --arg active_ticket "$ACTIVE_TICKET" \
            '{
                session_id: $session_id,
                reason: $reason,
                duration_seconds: (if $duration_seconds == "0" then null else ($duration_seconds | tonumber) end),
                active_ticket: (if $active_ticket == "" then null else $active_ticket end)
            }' 2>/dev/null)

        # Validate payload was constructed successfully
        if [[ -z "$SESSION_PAYLOAD" || "$SESSION_PAYLOAD" == "null" ]]; then
            log "WARNING: Failed to construct session payload (jq failed), skipping emission"
        else
            emit_via_daemon "session.ended" "$SESSION_PAYLOAD" 100
        fi
    ) &
    EMIT_PIDS+=($!)

    # Session outcome derivation + emission + feedback guardrails (OMN-1735, OMN-1892)
    # Consolidated into a single backgrounded subshell so the DERIVED_OUTCOME
    # Python computation (~30-50ms interpreter startup) stays off the sync path.
    (
        # Define accumulator path up front so the trap below can reference it
        # regardless of which exit path is taken.
        SESSION_STATE_FILE="/tmp/omniclaude-session-${SESSION_ID}.json"  # noqa: S108  # nosec B108

        # Validate SESSION_ID once for all outcome/feedback work
        if [[ -z "$SESSION_ID" ]]; then
            log "WARNING: SESSION_ID is empty, skipping session.outcome and feedback"
            exit 0
        fi

        # Ensure accumulator is always cleaned up — including early-exit paths.
        # Registered AFTER the empty-SESSION_ID guard so SESSION_STATE_FILE has a
        # proper session-specific path (not /tmp/omniclaude-session-.json, which could
        # collide between concurrent sessions with empty IDs).
        # NOTE: the trap DOES fire on UUID-format-invalid exit (line 191-194) — this is
        # intentional; a file written under a non-UUID session ID is still cleaned up.
        # The trap also fires on the normal completion path (after the file is read).
        trap 'rm -f "$SESSION_STATE_FILE"' EXIT

        # Validate UUID format (8-4-4-4-12 structure, case-insensitive)
        if [[ ! "$SESSION_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
            log "WARNING: SESSION_ID '$SESSION_ID' is not valid UUID format, skipping session.outcome and feedback"
            exit 0
        fi

        # ===================================================================
        # PHASE 1 PLUMBING (OMN-1892): Outcome derivation inputs are
        # partially hardcoded. Current state:
        #   - exit_code=0: Always 0 (hooks must never exit non-zero per CLAUDE.md)
        #   - session_output: Uses session reason (clear/logout/prompt_input_exit/
        #     other), NOT captured stdout. Outcome will not resolve to FAILED
        #     until session_output carries error markers (Error:, Exception:, etc.)
        #   - tool_calls_completed: Extracted from tools_used_count (best-effort);
        #     may still be 0 if field is absent from SessionEnd payload.
        #     SUCCESS requires tool_calls > 0 AND completion markers.
        # Unreachable gates: SUCCESS (no completion markers in reason codes),
        #   FAILED (exit_code always 0, no error markers in reason codes).
        # Result: Outcome currently resolves to abandoned or unknown only.
        # Future tickets:
        #   - tool_calls_completed: Wire from session aggregation service
        #   - session_output: Wire from captured session output/stdout
        # ===================================================================
        # Derive session outcome (pure Python, no I/O)
        # Python startup is ~30-50ms but runs in this backgrounded subshell,
        # not on the sync path. Falls back to "unknown" on failure.
        DERIVED_OUTCOME=$(HOOKS_LIB="$HOOKS_LIB" SESSION_REASON="$SESSION_REASON" DURATION_SECONDS="$DURATION_SECONDS" TOOL_CALLS_COMPLETED="$TOOL_CALLS_COMPLETED" \
            "$PYTHON_CMD" -c "
import os, sys
sys.path.insert(0, os.environ['HOOKS_LIB'])
from session_outcome import derive_session_outcome
session_reason = os.environ.get('SESSION_REASON', 'other')
duration_str = os.environ.get('DURATION_SECONDS', '0') or '0'
tool_calls_str = os.environ.get('TOOL_CALLS_COMPLETED', '0') or '0'
if not tool_calls_str.isdigit():
    print(f'WARNING: TOOL_CALLS_COMPLETED={tool_calls_str!r} not numeric, using 0', file=sys.stderr)
try:
    duration = float(duration_str)
    if duration < 0:
        print(f'WARNING: DURATION_SECONDS={duration} is negative, using 0', file=sys.stderr)
        duration = 0.0
except ValueError:
    print(f'WARNING: DURATION_SECONDS={duration_str!r} not numeric, using 0', file=sys.stderr)
    duration = 0.0
result = derive_session_outcome(
    exit_code=0,
    session_output=session_reason,
    tool_calls_completed=int(tool_calls_str) if tool_calls_str.isdigit() else 0,
    duration_seconds=duration,
)
print(result.outcome)
" 2>>"$LOG_FILE") || DERIVED_OUTCOME="unknown"

        log "Session outcome derived: ${DERIVED_OUTCOME}"
        if [[ "$DERIVED_OUTCOME" == "unknown" || "$DERIVED_OUTCOME" == "abandoned" ]]; then
            log "Phase 1: outcome=$DERIVED_OUTCOME (SUCCESS/FAILED gates require wired session_output and tool_calls; see OMN-1892)"
        fi

        # --- Emit session.outcome event (OMN-5201 T11b enrichment) ---
        # Payload includes: session_id, outcome, correlation_id, ticket_id,
        # success, dod_pass, pr_url, commit_count, duration_ms,
        # total_tokens_used, files_modified_count, tasks_completed_count.
        # Fields derived here (off the sync path) to preserve <50ms budget.

        # Derive dod_pass: read .evidence/<ticket>/dod_report.json if ticket active.
        DOD_PASS="null"
        SESSION_SUCCESS="null"
        if [[ -n "$ACTIVE_TICKET" ]]; then
            DOD_RECEIPT="${PROJECT_ROOT}/.evidence/${ACTIVE_TICKET}/dod_report.json"
            if [[ -f "$DOD_RECEIPT" ]]; then
                FAILED_COUNT=$(jq -r '.result.failed // 1' "$DOD_RECEIPT" 2>/dev/null || echo "1")
                if [[ "$FAILED_COUNT" == "0" ]]; then
                    DOD_PASS="true"
                    SESSION_SUCCESS="true"
                else
                    DOD_PASS="false"
                    SESSION_SUCCESS="false"
                fi
            fi
        fi

        # Derive pr_url: use gh CLI to find the current branch's open PR (best-effort).
        PR_URL="null"
        if command -v gh >/dev/null 2>&1 && [[ -d "${PROJECT_ROOT}/.git" || -f "${PROJECT_ROOT}/.git" ]]; then
            _pr_url=$(cd "$PROJECT_ROOT" 2>/dev/null && gh pr view --json url --jq '.url' 2>/dev/null) || _pr_url=""
            if [[ -n "$_pr_url" ]]; then
                PR_URL="\"$_pr_url\""
            fi
        fi

        # Derive commit_count: commits on branch not on origin/main (best-effort).
        COMMIT_COUNT="null"
        if [[ -d "${PROJECT_ROOT}/.git" || -f "${PROJECT_ROOT}/.git" ]]; then
            _cc=$(git -C "$PROJECT_ROOT" rev-list --count HEAD ^origin/main 2>/dev/null) || _cc=""
            if [[ "$_cc" =~ ^[0-9]+$ ]]; then
                COMMIT_COUNT="$_cc"
            fi
        fi

        # Derive total_tokens_used from context_window in SessionEnd payload (OMN-5201 T11b).
        # Claude Code provides context_window.current_usage.{input_tokens,output_tokens}.
        # Uses awk to sum the two fields (avoids Python startup on this hot path).
        TOTAL_TOKENS_USED="null"
        _input_tokens=$(echo "$INPUT" | jq -r '.context_window.current_usage.input_tokens // empty' 2>/dev/null) || _input_tokens=""
        _output_tokens=$(echo "$INPUT" | jq -r '.context_window.current_usage.output_tokens // empty' 2>/dev/null) || _output_tokens=""
        if [[ "$_input_tokens" =~ ^[0-9]+$ ]] || [[ "$_output_tokens" =~ ^[0-9]+$ ]]; then
            _in="${_input_tokens:-0}"
            _out="${_output_tokens:-0}"
            if [[ "$_in" =~ ^[0-9]+$ ]] && [[ "$_out" =~ ^[0-9]+$ ]]; then
                TOTAL_TOKENS_USED=$(( _in + _out ))
            fi
        fi

        # Derive files_modified_count from session accumulator (OMN-5201 T11b).
        # Written by post_tool_use_enforcer when Write/Edit tools fire during the session.
        FILES_MODIFIED_COUNT="null"
        if [[ -f "$SESSION_STATE_FILE" ]]; then
            _fmc=$(jq -r '.files_modified_count // empty' "$SESSION_STATE_FILE" 2>/dev/null) || _fmc=""
            if [[ "$_fmc" =~ ^[0-9]+$ ]]; then
                FILES_MODIFIED_COUNT="$_fmc"
            fi
        fi

        # Derive tasks_completed_count from session accumulator (OMN-5201 T11b).
        # Written by task tracking hooks when TaskUpdate completes during the session.
        TASKS_COMPLETED_COUNT="null"
        if [[ -f "$SESSION_STATE_FILE" ]]; then
            _tcc=$(jq -r '.tasks_completed_count // empty' "$SESSION_STATE_FILE" 2>/dev/null) || _tcc=""
            if [[ "$_tcc" =~ ^[0-9]+$ ]]; then
                TASKS_COMPLETED_COUNT="$_tcc"
            fi
        fi

        # Derive treatment_group from environment capabilities (OMN-5551).
        # Classifies session as "treatment" (all intelligence active),
        # "control" (all intelligence stripped), or "unknown" (partial).
        TREATMENT_GROUP="unknown"
        if [[ -f "${HOOKS_LIB}/classify_treatment.py" ]]; then
            TREATMENT_GROUP=$("$PYTHON_CMD" "${HOOKS_LIB}/classify_treatment.py" 2>>"$LOG_FILE") || TREATMENT_GROUP="unknown"
        fi

        if ! OUTCOME_PAYLOAD=$(jq -n \
            --arg session_id "$SESSION_ID" \
            --arg outcome "$DERIVED_OUTCOME" \
            --arg correlation_id "$CORRELATION_ID" \
            --arg ticket_id "$ACTIVE_TICKET" \
            --arg duration_ms "$SESSION_DURATION" \
            --arg treatment_group "$TREATMENT_GROUP" \
            --argjson dod_pass "$DOD_PASS" \
            --argjson success "$SESSION_SUCCESS" \
            --argjson pr_url "$PR_URL" \
            --argjson commit_count "$COMMIT_COUNT" \
            --argjson total_tokens_used "$TOTAL_TOKENS_USED" \
            --argjson files_modified_count "$FILES_MODIFIED_COUNT" \
            --argjson tasks_completed_count "$TASKS_COMPLETED_COUNT" \
            '{
                session_id: $session_id,
                outcome: $outcome,
                correlation_id: (if $correlation_id == "" then null else $correlation_id end),
                ticket_id: (if $ticket_id == "" then null else $ticket_id end),
                duration_ms: (if $duration_ms == "0" or $duration_ms == "" then null else ($duration_ms | tonumber) end),
                success: $success,
                dod_pass: $dod_pass,
                pr_url: $pr_url,
                commit_count: $commit_count,
                total_tokens_used: $total_tokens_used,
                files_modified_count: $files_modified_count,
                tasks_completed_count: $tasks_completed_count,
                treatment_group: $treatment_group
            }' 2>>"$LOG_FILE"); then
            log "WARNING: Failed to construct outcome payload (jq failed), skipping emission"
        elif [[ -z "$OUTCOME_PAYLOAD" || "$OUTCOME_PAYLOAD" == "null" ]]; then
            log "WARNING: outcome payload empty or null, skipping emission"
        else
            emit_via_daemon "session.outcome" "$OUTCOME_PAYLOAD" 100
            log "session.outcome emitted: outcome=$DERIVED_OUTCOME dod_pass=$DOD_PASS success=$SESSION_SUCCESS tokens=${TOTAL_TOKENS_USED} files=${FILES_MODIFIED_COUNT} tasks=${TASKS_COMPLETED_COUNT} treatment_group=${TREATMENT_GROUP}"
        fi

        # NOTE (OMN-2622): routing.outcome.raw emit removed — topic deprecated.
        # No named consumer, raw/unnormalized signals not suitable for long-term storage.
        # Topic tombstoned in TopicBase and removed from event_registry.py.

        # --- Emit routing.feedback event (OMN-2622) ---
        # Replaces the separate routing.skipped topic by folding skip signals into
        # routing-feedback.v1 via the feedback_status field.
        # feedback_status="produced": outcome is meaningful (success/failed) — omniintelligence should learn.
        # feedback_status="skipped": outcome unclear (unknown/abandoned) — log skip_reason, no DB write.
        # All routing feedback outcomes are now on a single topic; consumers filter on feedback_status.
        FEEDBACK_EMITTED_AT=$("$PYTHON_CMD" -c 'from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z")' 2>/dev/null \
            || date -u +"%Y-%m-%dT%H:%M:%SZ")
        if [[ "$DERIVED_OUTCOME" == "success" || "$DERIVED_OUTCOME" == "failed" ]]; then
            FEEDBACK_STATUS="produced"
            FEEDBACK_SKIP_REASON="null"
        else
            FEEDBACK_STATUS="skipped"
            FEEDBACK_SKIP_REASON="\"UNCLEAR_OUTCOME\""
        fi
        if ! FEEDBACK_PAYLOAD=$(jq -n \
            --arg session_id "$SESSION_ID" \
            --arg outcome "$DERIVED_OUTCOME" \
            --arg feedback_status "$FEEDBACK_STATUS" \
            --arg correlation_id "$CORRELATION_ID" \
            --arg emitted_at "$FEEDBACK_EMITTED_AT" \
            --argjson skip_reason "$FEEDBACK_SKIP_REASON" \
            '{
                session_id: $session_id,
                outcome: $outcome,
                feedback_status: $feedback_status,
                skip_reason: $skip_reason,
                correlation_id: (if $correlation_id == "" then null else $correlation_id end),
                emitted_at: $emitted_at
            }' 2>>"$LOG_FILE"); then
            log "WARNING: Failed to construct routing.feedback payload (jq failed), skipping emission"
        elif [[ -z "$FEEDBACK_PAYLOAD" || "$FEEDBACK_PAYLOAD" == "null" ]]; then
            log "WARNING: routing.feedback payload empty or null, skipping emission"
        else
            emit_via_daemon "routing.feedback" "$FEEDBACK_PAYLOAD" 100
            log "routing.feedback emitted: outcome=$DERIVED_OUTCOME feedback_status=$FEEDBACK_STATUS"
        fi

    ) &
    EMIT_PIDS+=($!)

    log "Session event emission started via emit daemon"
else
    log "Kafka emission skipped (KAFKA_ENABLED=$KAFKA_ENABLED)"
fi

# Clean up correlation state
if [[ -f "${HOOKS_LIB}/correlation_manager.py" ]]; then
    HOOKS_LIB="$HOOKS_LIB" $PYTHON_CMD -c "
import os, sys
sys.path.insert(0, os.environ['HOOKS_LIB'])
from correlation_manager import get_registry
get_registry().clear()
" 2>/dev/null || true
fi

# -----------------------------
# Clear session injection marker (OMN-1675)
# -----------------------------
if [[ -f "${HOOKS_LIB}/session_marker.py" ]] && [[ -n "${SESSION_ID}" ]]; then
    $PYTHON_CMD "${HOOKS_LIB}/session_marker.py" clear --session-id "${SESSION_ID}" 2>>"$LOG_FILE" || true
    log "Cleared session injection marker"
fi

# -----------------------------
# Session State Teardown (OMN-2119)
# -----------------------------
# Transition the active run to run_ended via cmd_end.
# Mirrors the cmd_init call in session-start.sh. Reads active_run_id from
# the session index (session.json), then pipes it to the adapter's "end"
# command. Runs in background to stay within the <50ms budget.
# If no active run exists (e.g., init failed), this is a no-op.
if [[ -f "${HOOKS_LIB}/node_session_state_adapter.py" ]]; then
    (
        # Read active_run_id from session index
        SESSION_STATE_DIR="${ONEX_SESSION_STATE_DIR}"
        SESSION_INDEX="${SESSION_STATE_DIR}/session.json"
        ACTIVE_RUN_ID=""
        if [[ -f "$SESSION_INDEX" ]]; then
            ACTIVE_RUN_ID=$(jq -r '.active_run_id // ""' "$SESSION_INDEX" 2>/dev/null) || ACTIVE_RUN_ID=""
        fi

        if [[ -n "$ACTIVE_RUN_ID" ]] && [[ "$ACTIVE_RUN_ID" != "null" ]]; then
            adapter_stdout=$(echo "{\"run_id\": \"${ACTIVE_RUN_ID}\"}" | "$PYTHON_CMD" "${HOOKS_LIB}/node_session_state_adapter.py" end 2>>"$LOG_FILE")
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] [session-state] cmd_end stdout: ${adapter_stdout:-<empty>}" >> "$LOG_FILE"
        else
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] [session-state] No active run to end (active_run_id empty or absent)" >> "$LOG_FILE"
        fi
    ) &
    log "Session state teardown started in background (PID: $!)"
fi

# Output response immediately so Claude Code can proceed with shutdown.
# Worktree cleanup (below) is backgrounded to preserve <50ms SessionEnd budget.
echo "$INPUT"

# -----------------------------
# Worktree Cleanup (OMN-1856)
# -----------------------------
# Clean up agent-created worktrees from this session.
# Only targets $ONEX_WORKTREES_DIR/ with valid .claude-session.json markers.
# Uses git worktree remove (never rm -rf). Idempotent.
#
# Runs in a backgrounded subshell so find/jq/git subprocess invocations
# do not block Claude Code (performance budget: <50ms sync path).

WORKTREE_BASE="${ONEX_WORKTREES_DIR}"

if [[ -d "$WORKTREE_BASE" ]]; then
    (
    # Guard: refuse to run cleanup without a session ID — an empty SESSION_ID
    # would match markers whose session_id field is also empty/missing, leading
    # to unintended removal of worktrees belonging to other sessions.
    if [[ -z "$SESSION_ID" ]]; then
        log "WORKTREE: No session ID — skipping worktree cleanup"
        exit 0
    fi

    # Canonicalize WORKTREE_BASE to its physical path so symlink-based
    # path traversal cannot bypass the case-prefix guard below.
    WORKTREE_BASE=$(cd "$WORKTREE_BASE" 2>/dev/null && pwd -P) || {
        log "WARNING: Cannot canonicalize WORKTREE_BASE, skipping worktree cleanup"
        exit 0
    }

    _wt_candidates=0
    _wt_removed=0
    _wt_skipped=0

    # Scan for markers under $ONEX_WORKTREES_DIR/{repo}/{branch}/.
    # Use -mindepth 2 (at least {repo}/{file}) and -maxdepth 10 to handle
    # branch names containing slashes (e.g., "feature/auth",
    # "jonahgabriel/omn-1856") — git creates nested subdirectories for
    # each path component, so the marker can appear at depth 4+.
    # Safety scoping is provided by the path-prefix guard, session-id
    # matching, and git-state checks below.
    while IFS= read -r -d '' _wt_marker; do
        _wt_dir="$(dirname "$_wt_marker")"
        _wt_candidates=$((_wt_candidates + 1))

        # G1: Read and validate marker
        if ! _wt_data=$(jq -e '.' "$_wt_marker" 2>/dev/null); then
            log "STALE: ${_wt_dir} - malformed .claude-session.json"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        _wt_marker_session=$(echo "$_wt_data" | jq -r '.session_id // empty' 2>/dev/null)
        _wt_parent_repo=$(echo "$_wt_data" | jq -r '.parent_repo_path // empty' 2>/dev/null)
        _wt_cleanup_policy=$(echo "$_wt_data" | jq -r '.cleanup_policy // empty' 2>/dev/null)

        # G1b: cleanup_policy must be "session-end" (per SKILL.md contract)
        if [[ "$_wt_cleanup_policy" != "session-end" ]]; then
            log "WORKTREE: SKIP ${_wt_dir} — cleanup_policy is '${_wt_cleanup_policy}', not session-end"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # G2: Session ID must match current session
        if [[ "$_wt_marker_session" != "$SESSION_ID" ]]; then
            log "SKIP: ${_wt_dir} - session mismatch (marker=${_wt_marker_session}, current=${SESSION_ID})"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # Validate parent repo path exists
        if [[ -z "$_wt_parent_repo" || ! -d "$_wt_parent_repo" ]]; then
            log "STALE: ${_wt_dir} - parent_repo_path missing or invalid: ${_wt_parent_repo}"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # G2b: parent_repo must differ from worktree dir (misconfigured marker guard)
        # If an agent writes the marker from inside the worktree itself (violating
        # SKILL.md), parent_repo_path would equal the worktree path, causing
        # git-worktree-remove to try to remove the parent repo.
        _wt_parent_canon=$(cd "$_wt_parent_repo" 2>/dev/null && pwd -P) || _wt_parent_canon=""
        _wt_dir_canon=$(cd "$_wt_dir" 2>/dev/null && pwd -P) || _wt_dir_canon=""
        if [[ -n "$_wt_parent_canon" && "$_wt_parent_canon" == "$_wt_dir_canon" ]]; then
            log "SKIP: parent_repo == worktree_dir (misconfigured marker): ${_wt_dir}"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # Canonicalize _wt_dir to its physical path so symlinks pointing
        # outside WORKTREE_BASE are caught by the case-prefix guard.
        _wt_dir=$(cd "$_wt_dir" 2>/dev/null && pwd -P) || {
            log "STALE: ${_wt_dir} - cannot canonicalize path"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        }

        # Validate worktree path is under WORKTREE_BASE (path traversal guard)
        case "$_wt_dir" in
            "${WORKTREE_BASE}"/*) ;;
            *)
                log "STALE: ${_wt_dir} - path not under ${WORKTREE_BASE}"
                _wt_skipped=$((_wt_skipped + 1))
                continue
                ;;
        esac

        # G3: No uncommitted changes
        if ! git -C "$_wt_dir" diff --quiet 2>/dev/null; then
            log "STALE: ${_wt_dir} - has uncommitted changes"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # G4: No staged changes
        if ! git -C "$_wt_dir" diff --cached --quiet 2>/dev/null; then
            log "STALE: ${_wt_dir} - has staged changes"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # G5: No untracked files (datasets, generated files not yet git-added)
        _wt_untracked=$(git -C "$_wt_dir" ls-files --others --exclude-standard --directory 2>/dev/null) || _wt_untracked=""
        if [[ -n "$_wt_untracked" ]]; then
            log "SKIP: ${_wt_dir} - untracked files in worktree"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # G6: No unpushed commits
        _wt_upstream=$(git -C "$_wt_dir" rev-parse --abbrev-ref '@{u}' 2>/dev/null) || _wt_upstream=""
        if [[ -z "$_wt_upstream" ]]; then
            # No tracking upstream configured — local commits have no remote
            # backup. Treat as unpushed to avoid silent data loss.
            log "STALE: ${_wt_dir} - no upstream configured, local commits may not be backed up"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi
        _wt_local=$(git -C "$_wt_dir" rev-parse HEAD 2>/dev/null) || _wt_local=""
        if [[ -z "$_wt_local" ]]; then
            log "STALE: ${_wt_dir} - cannot resolve HEAD"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi
        _wt_remote=$(git -C "$_wt_dir" rev-parse '@{u}' 2>/dev/null) || _wt_remote=""
        if [[ -z "$_wt_remote" ]]; then
            log "STALE: ${_wt_dir} - upstream configured but remote ref unavailable"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi
        if [[ "$_wt_local" != "$_wt_remote" ]]; then
            log "STALE: ${_wt_dir} - has unpushed commits (local=${_wt_local:0:8} remote=${_wt_remote:0:8})"
            _wt_skipped=$((_wt_skipped + 1))
            continue
        fi

        # G7: Safe removal via git worktree remove from parent repo.
        # No --force: let git refuse if state changed between guards and removal (TOCTOU safety).
        if git -C "$_wt_parent_repo" worktree remove "$_wt_dir" 2>>"$LOG_FILE"; then
            git -C "$_wt_parent_repo" worktree prune 2>>"$LOG_FILE" || true
            log "REMOVED: ${_wt_dir}"
            _wt_removed=$((_wt_removed + 1))
        else
            log "STALE: ${_wt_dir} - git worktree remove failed (see log)"
            _wt_skipped=$((_wt_skipped + 1))
        fi

    done < <(timeout 30 find "$WORKTREE_BASE" -mindepth 2 -maxdepth 10 -name '.claude-session.json' -print0 2>/dev/null)

    if [[ $_wt_candidates -gt 0 ]]; then
        log "Worktree cleanup: ${_wt_candidates} candidates, ${_wt_removed} removed, ${_wt_skipped} skipped"
    fi
    ) &
    # Worktree cleanup is fire-and-forget — not tracked in EMIT_PIDS.
    # Drain logic (below) is for event emission subshells only.
fi

# Drain emit subshells, then stop publisher (OMN-1944)
# SessionEnd has no downstream UI action — waiting is safe here.
# We wait for emit subshells to finish (bounded by timeout) so events
# are enqueued before the publisher is stopped. No fixed-sleep gamble.
DRAIN_TIMEOUT="${PUBLISHER_DRAIN_TIMEOUT_SECONDS:-3}"
if [[ ${#EMIT_PIDS[@]} -gt 0 ]]; then
    # Timeout guard: kill stragglers after DRAIN_TIMEOUT seconds
    ( sleep "$DRAIN_TIMEOUT" && kill "${EMIT_PIDS[@]}" 2>/dev/null ) &
    DRAIN_GUARD_PID=$!
    wait "${EMIT_PIDS[@]}" 2>/dev/null || true
    kill "$DRAIN_GUARD_PID" 2>/dev/null; wait "$DRAIN_GUARD_PID" 2>/dev/null || true
    log "Emit subshells drained (${#EMIT_PIDS[@]} tracked)"
fi

# Stop publisher ONLY if no other Claude Code sessions are still running.
# The publisher is a shared singleton — killing it when other sessions are
# active causes "EVENT EMISSION DEGRADED" failures for every other session.
# Uses pgrep -x for exact process name matching ("claude" only), avoiding
# false positives from Claude Desktop, Cursor, child shells, and pgrep itself.
# Note: -i (case-insensitive) is omitted — it is Linux-only and errors on macOS.
#
# TOCTOU note: there is an inherent race between the pgrep count and the
# publisher stop — another session could start or stop in the gap. This is
# acceptable: premature kill is recovered by SessionStart (idempotent restart),
# and leaving the publisher running is harmless (next SessionEnd cleans it up).
#
# Binary name assumption: pgrep -x "claude" assumes the CLI binary is named
# exactly "claude". If renamed (e.g. "claude-code"), the count will always be 0,
# causing publisher stop on every SessionEnd — safe (SessionStart restarts) but
# suboptimal.
#
# Fail-safe semantics: if pgrep itself errors (exit >=2: permission denied,
# syntax error, /proc unavailable), we default to 9999 so the publisher stays
# alive. Rationale: a missed stop is harmless (next SessionEnd retries or the
# daemon idles out), but a false stop disrupts every other active session.
#
# pgrep exit codes: 0 = matched, 1 = no matches (normal), 2+ = real error.
# With pipefail, we must capture pgrep's exit code separately — otherwise
# exit 1 (no matches) and exit 2 (error) are both treated as pipeline failure.
_pgrep_rc=0
_pgrep_output=$(pgrep -x "claude" 2>/dev/null) || _pgrep_rc=$?
if [[ $_pgrep_rc -ge 2 ]]; then
    # Real pgrep failure — cannot determine session count; keep publisher alive
    _other_claude_sessions=9999
    log "WARNING: pgrep failed (exit=$_pgrep_rc), assuming other sessions exist (fail-safe)"
elif [[ $_pgrep_rc -eq 1 ]]; then
    # No matches — zero claude processes running
    _other_claude_sessions=0
else
    # Success — count matched PIDs (one per line)
    _other_claude_sessions=$(echo "$_pgrep_output" | wc -l | tr -d ' ')
fi
if [[ "$_other_claude_sessions" -le 1 ]]; then
    # 1 or fewer = only this session (pgrep -x never matches itself); safe to stop
    "$PYTHON_CMD" -m omniclaude.publisher stop >> "$LOG_FILE" 2>&1 || {
        # Fallback: try legacy daemon stop
        "$PYTHON_CMD" -m omnibase_infra.runtime.emit_daemon.cli stop >> "$LOG_FILE" 2>&1 || true
    }
    log "Publisher stop signal sent (last session)"
else
    log "Publisher kept alive (${_other_claude_sessions} Claude processes still running)"
fi

log "SessionEnd hook completed"
# No explicit `wait` needed before exit: emit subshells are already drained
# above (line "wait ${EMIT_PIDS[@]}"), and the worktree cleanup subshell is
# fire-and-forget (reparented to init on exit). Bash does not send SIGHUP
# to backgrounded jobs on non-interactive shell exit.
exit 0
