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

# SessionStart Hook - Portable Plugin Version
# Captures session initialization intelligence
# Performance target: <50ms execution time
#
# =============================================================================
# Claude Code SessionStart JSON Schema (as of 2026-02, snake_case API)
# Source: https://code.claude.com/docs/en/hooks
# =============================================================================
# {
#   "session_id": "abc123",         # Primary session identifier (snake_case)
#   "transcript_path": "/path/...", # Path to conversation JSON file
#   "cwd": "/path/to/project",      # Current working directory
#   "permission_mode": "default",   # default | plan | acceptEdits | dontAsk | bypassPermissions
#   "hook_event_name": "SessionStart",
#   "source": "startup",            # startup | resume | clear | compact
#   "model": "claude-sonnet-4-6"    # Model identifier
# }
#
# IMPORTANT: Claude Code switched from camelCase (sessionId, projectPath) to
# snake_case (session_id, cwd) around 2026-02-18. The field is NOW session_id.
# Both forms are tried for backwards compatibility but session_id is primary.
#
# KNOWN ISSUE: Some sessions receive EMPTY or non-JSON stdin, producing:
#   "ERROR: Malformed JSON on stdin, using empty object"
# This appears to happen on specific session types (e.g., resume, compact).
# When this occurs, SESSION_ID is empty and all idempotency guards are bypassed.
# Raw input is logged to /tmp/claude-hook-debug-sessionstart.json for diagnosis.
# =============================================================================

set -euo pipefail

# --guard-check-only: run environment guards and exit without starting daemon.
# Used by tests to verify guard behavior in isolation.
# Must run BEFORE error-guard.sh is sourced (error-guard converts exit 1 → exit 0).
_GUARD_CHECK_ONLY=0
for _arg in "$@"; do
  if [ "$_arg" = "--guard-check-only" ]; then
    _GUARD_CHECK_ONLY=1
    break
  fi
done

# Early .env sourcing: load project-level env BEFORE the inmemory guard so that
# ONEX_EVENT_BUS_TYPE set in the repo .env is caught (CodeRabbit #628 fix).
# This mirrors the full PROJECT_ROOT detection at lines 84-92 but runs before
# error-guard.sh to ensure the guard's exit 1 is not swallowed.
_EARLY_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}"
_EARLY_PROJECT_ROOT="${_EARLY_PLUGIN_ROOT}/../.."
if [[ -f "${_EARLY_PROJECT_ROOT}/.env" ]]; then
    set -a
    source "${_EARLY_PROJECT_ROOT}/.env" 2>/dev/null || true
    set +a
elif [[ -n "${CLAUDE_PROJECT_DIR:-}" && -f "${CLAUDE_PROJECT_DIR}/.env" ]]; then
    set -a
    source "${CLAUDE_PROJECT_DIR}/.env" 2>/dev/null || true
    set +a
fi
unset _EARLY_PLUGIN_ROOT _EARLY_PROJECT_ROOT

# --- Mode Resolution (runs before all guards) ---
# Resolve omniclaude operating mode (full vs lite) before heavy initialization.
# In lite mode, session-start emits minimal JSON and exits immediately —
# no Kafka daemon, no intelligence context, no ticket metadata.
# Must run before the inmemory guard: in lite mode there is no Kafka daemon,
# so ONEX_EVENT_BUS_TYPE=inmemory is irrelevant and should not block startup.
_MODE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_MODE_SH="${_MODE_SCRIPT_DIR}/../../lib/mode.sh"
if [[ -f "$_MODE_SH" ]]; then
    source "$_MODE_SH"
    _CURRENT_MODE="$(omniclaude_mode)"
else
    _CURRENT_MODE="full"
fi
export OMNICLAUDE_MODE="$_CURRENT_MODE"
unset _MODE_SCRIPT_DIR _MODE_SH _CURRENT_MODE

if [[ "$OMNICLAUDE_MODE" == "lite" ]]; then
    # Lite-session minimum contract:
    # 1. OMNICLAUDE_MODE=lite exported (done above)
    # 2. Valid JSON output on stdout
    # 3. Exit code 0
    #
    # NOT initialized: Kafka emit daemon, intelligence context, ticket metadata
    echo '{"hookSpecificOutput":{"additionalContext":"omniclaude lite mode active — generic development tooling only"}}'
    exit 0
fi

# HARD GUARD: ONEX_EVENT_BUS_TYPE=inmemory is FORBIDDEN in runtime sessions.
# The emit daemon requires Kafka. This setting silently drops all events.
# Fail loudly so the operator knows to fix it, rather than losing all observability.
# This guard runs BEFORE error-guard.sh so the exit 1 is not swallowed.
if [ "${ONEX_EVENT_BUS_TYPE:-}" = "inmemory" ]; then
    echo "FATAL: ONEX_EVENT_BUS_TYPE=inmemory is forbidden in runtime sessions." >&2
    echo "Unset the variable from your runtime environment and check ~/.omnibase/.env." >&2
    echo "The emit daemon always requires Kafka. Remove or unset ONEX_EVENT_BUS_TYPE." >&2
    exit 1
fi

# Guard check only: exit 0 after all guards pass (test hook, no daemon start)
if [ "$_GUARD_CHECK_ONLY" = "1" ]; then
    exit 0
fi

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

# 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-start.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-start.log"

source "$(dirname "${BASH_SOURCE[0]}")/onex-paths.sh" || { echo "ONEX_STATE_DIR not set" >&2; exit 1; }

# 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")"

# Load environment variables (before sourcing 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

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

# Daemon status file path (used by write_daemon_status for observability)
readonly DAEMON_STATUS_FILE="${HOOKS_DIR}/logs/daemon-status"

# Write daemon status atomically to prevent race conditions
write_daemon_status() {
    local status="$1"
    local tmp_file="${DAEMON_STATUS_FILE}.tmp.$$"

    # Ensure logs directory exists
    mkdir -p "${HOOKS_DIR}/logs" 2>/dev/null || true

    # Atomic write: write to temp file then rename
    if echo "$status" > "$tmp_file" 2>/dev/null; then
        mv "$tmp_file" "$DAEMON_STATUS_FILE" 2>/dev/null || rm -f "$tmp_file"
    fi
}

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

# Boolean normalization: _normalize_bool is provided by common.sh (sourced above)

# SessionStart injection config (OMN-1675)
SESSION_INJECTION_ENABLED="${OMNICLAUDE_SESSION_INJECTION_ENABLED:-true}"
SESSION_INJECTION_ENABLED=$(_normalize_bool "$SESSION_INJECTION_ENABLED")
SESSION_INJECTION_TIMEOUT_MS="${OMNICLAUDE_SESSION_INJECTION_TIMEOUT_MS:-8000}"
SESSION_INJECTION_MAX_PATTERNS="${OMNICLAUDE_SESSION_INJECTION_MAX_PATTERNS:-10}"
SESSION_INJECTION_MIN_CONFIDENCE="${OMNICLAUDE_SESSION_INJECTION_MIN_CONFIDENCE:-0.7}"
SESSION_INJECTION_INCLUDE_FOOTER="${OMNICLAUDE_SESSION_INJECTION_INCLUDE_FOOTER:-false}"
SESSION_INJECTION_INCLUDE_FOOTER=$(_normalize_bool "$SESSION_INJECTION_INCLUDE_FOOTER")

# Define timeout function (portable, works on macOS)
# Uses perl alarm() because GNU coreutils 'timeout' command is not available
# on macOS by default. perl is pre-installed on macOS and provides SIGALRM.
# NOTE: perl alarm() only accepts integers, so we use ceiling (round UP).
run_with_timeout() {
    local timeout_sec="$1"
    shift
    # perl alarm() only accepts integers; use ceiling (round UP) for fractional seconds.
    # IMPORTANT: printf "%.0f" uses banker's rounding which may round 0.5 to 0 (no timeout!)
    # Ceiling ensures: 0.5s -> 1s, 0.1s -> 1s, 1.5s -> 2s (always safe, never zero)
    local int_timeout
    int_timeout=$(awk -v t="$timeout_sec" 'BEGIN { printf "%d", int(t) + (t > int(t) ? 1 : 0) }')
    [[ "$int_timeout" -lt 1 ]] && int_timeout=1
    perl -e 'alarm shift; exec @ARGV' "$int_timeout" "$@"
}

# Preflight check for jq (required for JSON parsing)
JQ_AVAILABLE=1
if ! command -v jq >/dev/null 2>&1; then
    log "WARNING: jq not found, using fallback values and skipping Kafka emission"
    JQ_AVAILABLE=0
fi

# Preflight check for bc (used for timeout calculation)
BC_AVAILABLE=1
if ! command -v bc >/dev/null 2>&1; then
    log "WARNING: bc not found, using shell arithmetic fallback for timeout calculation"
    BC_AVAILABLE=0
fi

# =============================================================================
# Emit Daemon Management
# =============================================================================
# The emit daemon provides fast, non-blocking Kafka emission via Unix socket.
# Starting it in SessionStart ensures no first-prompt latency surprise.

# Socket path can be overridden via OMNICLAUDE_EMIT_SOCKET environment variable
# This enables testing with alternative socket paths and matches emit_client_wrapper.py
# Note: Not exported because emit_client_wrapper.py reads OMNICLAUDE_EMIT_SOCKET directly
# Use $TMPDIR for consistency with Python's tempfile.gettempdir() (both check TMPDIR first)
_TMPDIR="${TMPDIR:-/tmp}"
_TMPDIR="${_TMPDIR%/}"  # Remove trailing slash (macOS TMPDIR often ends with /)
EMIT_DAEMON_SOCKET="${OMNICLAUDE_EMIT_SOCKET:-${_TMPDIR}/omniclaude-emit.sock}"
EMIT_DAEMON_PID_FILE="${_TMPDIR}/omniclaude-emit.pid"

# Check if daemon is responsive via real protocol ping.
#
# Uses emit_client_wrapper.py's ping command with explicit socket path
# passed via OMNICLAUDE_EMIT_SOCKET env var. This ensures we ping the
# ACTUAL daemon at the expected socket path, not whatever DEFAULT_SOCKET_PATH
# resolves to (which caused the silent mismatch bug on macOS).
check_socket_responsive() {
    local socket_path="$1"
    local timeout_sec="${2:-0.5}"
    # Real protocol ping — passes socket path explicitly via env var
    # so we ping the ACTUAL daemon, not whatever DEFAULT_SOCKET_PATH resolves to.
    # Each invocation is a fresh process, so OMNICLAUDE_EMIT_SOCKET is read fresh.
    OMNICLAUDE_EMIT_SOCKET="$socket_path" \
    OMNICLAUDE_EMIT_TIMEOUT="$timeout_sec" \
        "$PYTHON_CMD" "${HOOKS_LIB}/emit_client_wrapper.py" ping >/dev/null 2>&1
}

start_emit_daemon_if_needed() {
    # Check if publisher already running via socket
    if [[ -S "$EMIT_DAEMON_SOCKET" ]]; then
        # Fast path: PID file check via kill -0 (<1ms, no Python spawn).
        # If the PID file exists and the process is alive, skip the expensive
        # Python socket ping entirely (~75-215ms saved on every session start).
        if [[ -f "$EMIT_DAEMON_PID_FILE" ]]; then
            local _pid
            _pid=$(cat "$EMIT_DAEMON_PID_FILE" 2>/dev/null)
            if [[ -n "$_pid" ]] && kill -0 "$_pid" 2>/dev/null; then
                log "Publisher already running (PID $_pid, fast-path skip)"
                return 0
            fi
        fi
        # Slow path: PID file missing or process dead — fall back to Python ping
        if check_socket_responsive "$EMIT_DAEMON_SOCKET" 0.1; then
            log "Publisher already running and responsive"
            return 0
        else
            # Socket exists but publisher not responsive - remove stale socket
            log "Removing stale publisher socket"
            rm -f "$EMIT_DAEMON_SOCKET" 2>/dev/null || true
            rm -f "$EMIT_DAEMON_PID_FILE" 2>/dev/null || true
        fi
    fi

    # Check if publisher module is available (omniclaude.publisher, OMN-1944)
    if ! "$PYTHON_CMD" -c "import omniclaude.publisher" 2>/dev/null; then
        # Fallback: try legacy omnibase_infra emit daemon
        if "$PYTHON_CMD" -c "import omnibase_infra.runtime.emit_daemon" 2>/dev/null; then
            log "Using legacy emit daemon (omnibase_infra)"
            _start_legacy_emit_daemon
            return $?
        fi
        log "Publisher module not available (omniclaude.publisher)"
        return 0  # Non-fatal, continue without publisher
    fi

    log "Starting publisher (omniclaude.publisher)..."

    # Ensure logs directory exists for publisher output
    mkdir -p "${HOOKS_DIR}/logs"

    # Pre-flight: verify omnibase_infra>=0.14.0 is installed. (OMN-3251)
    # omnibase_infra==0.13.0 passes reconnect_backoff_ms to AIOKafkaProducer,
    # which aiokafka==0.11.0 does not accept, causing the daemon to crash at
    # startup and all extraction events to fail silently. The fix shipped in
    # omnibase_infra==0.14.0. When a stale venv is detected we log a targeted
    # error and skip daemon startup rather than letting it fail in the background.
    local _oi_ok
    _oi_ok="$("$PYTHON_CMD" -c "
import importlib.metadata, sys
try:
    v = importlib.metadata.version('omnibase-infra')
    parts = [int(x) for x in v.split('.')[:2]]
    if parts >= [0, 14]:
        print('ok')
    else:
        print('stale:' + v)
except Exception as e:
    print('unknown:' + str(e))
" 2>/dev/null || echo "unknown:import-error")"

    if [[ "$_oi_ok" == ok ]]; then
        : # version is fine, proceed
    elif [[ "$_oi_ok" == stale:* ]]; then
        local _stale_ver="${_oi_ok#stale:}"
        log "ERROR: omnibase_infra==${_stale_ver} in plugin venv is too old (need >=0.14.0). (OMN-3251)"
        log "ERROR: The emit daemon will crash with: AIOKafkaProducer.__init__() got an unexpected keyword argument 'reconnect_backoff_ms'"
        log "ERROR: All extraction events (context.utilization, agent.match, latency.breakdown) will be silently dropped."
        log "FIX: Rebuild the plugin venv to install omnibase_infra>=0.14.0:"
        log "FIX:   ${CLAUDE_PLUGIN_ROOT}/skills/deploy-local-plugin/deploy.sh --repair-venv"
        write_daemon_status "stale_dependency"
        return 0  # Non-fatal: continue without publisher; hook still provides ticket context
    else
        log "WARNING: Could not verify omnibase_infra version (${_oi_ok}); proceeding with daemon startup"
    fi
    unset _oi_ok

    if [[ -z "${KAFKA_BOOTSTRAP_SERVERS:-}" ]]; then
        log "WARNING: KAFKA_BOOTSTRAP_SERVERS not set - Kafka features disabled"
        log "INFO: To enable intelligence gathering, set KAFKA_BOOTSTRAP_SERVERS in your .env file"
        log "INFO: Example: KAFKA_BOOTSTRAP_SERVERS=<kafka-bootstrap-servers>:9092"
        write_daemon_status "kafka_not_configured"
        return 0  # Non-fatal - continue without Kafka, hook still provides ticket context
    fi

    # Start publisher in background, detached from this process (OMN-1944)
    nohup "$PYTHON_CMD" -m omniclaude.publisher start \
        --kafka-servers "$KAFKA_BOOTSTRAP_SERVERS" \
        ${KAFKA_SECONDARY_BOOTSTRAP_SERVERS:+--secondary-kafka-servers "$KAFKA_SECONDARY_BOOTSTRAP_SERVERS"} \
        --socket-path "$EMIT_DAEMON_SOCKET" \
        >> "${HOOKS_DIR}/logs/emit-daemon.log" 2>&1 &

    local daemon_pid=$!
    log "Publisher started with PID $daemon_pid"

    # Wait briefly for publisher to create socket (max 200ms in 20ms increments)
    local wait_count=0
    local max_wait=10
    while [[ ! -S "$EMIT_DAEMON_SOCKET" && $wait_count -lt $max_wait ]]; do
        sleep 0.02
        ((wait_count++)) || true  # || true: post-increment returns old value (0) when starting from 0; prevents set -e from triggering
    done

    # Retry-based socket verification after file appears.
    if [[ -S "$EMIT_DAEMON_SOCKET" ]]; then
        local verify_attempt=0
        # 2 attempts x 0.2s timeout + 10ms gap = ~0.41s worst-case on sync path.
        # The daemon should be responsive almost immediately after creating the
        # socket file; 2 retries is sufficient. Previously 5 (~1.05s worst-case)
        # which violated the <50ms SessionStart budget even in the async portion.
        local max_verify_attempts=2

        while [[ $verify_attempt -lt $max_verify_attempts ]]; do
            if check_socket_responsive "$EMIT_DAEMON_SOCKET" 0.2; then
                log "Publisher ready (verified on attempt $((verify_attempt + 1)))"
                # Write PID file so future sessions can skip the Python ping via kill -0
                echo "$daemon_pid" > "$EMIT_DAEMON_PID_FILE" 2>/dev/null || true
                write_daemon_status "running"
                mkdir -p "${HOOKS_DIR}/logs/emit-health" 2>/dev/null || true
                rm -f "${HOOKS_DIR}/logs/emit-health/warning" 2>/dev/null || true
                return 0
            fi
            ((verify_attempt++)) || true  # || true: post-increment from 0 returns exit code 1 under set -e
            sleep 0.01
        done

        log "WARNING: Publisher socket exists but not responsive after $max_verify_attempts verification attempts"
    else
        log "WARNING: Publisher startup timed out after ${max_wait}x20ms, continuing without publisher"
    fi

    # Publisher failed to start properly - write warning file and continue
    mkdir -p "${HOOKS_DIR}/logs/emit-health" 2>/dev/null || true
    local _tmp="${HOOKS_DIR}/logs/emit-health/warning.tmp.$$"
    cat > "$_tmp" <<WARN  # Note: unquoted delimiter -- variable expansion is intentional
EVENT EMISSION UNHEALTHY: The emit daemon is not responding to health checks. Intelligence gathering and observability events are NOT being captured. Socket: ${EMIT_DAEMON_SOCKET}. Check: ${HOOKS_DIR}/logs/emit-daemon.log
WARN
    mv -f "$_tmp" "${HOOKS_DIR}/logs/emit-health/warning" 2>/dev/null || rm -f "$_tmp"
    # Alert on daemon startup failure (backgrounded, non-blocking)
    ( slack_notify "daemon_startup" "[omniclaude][${_SLACK_HOST}] Emit daemon failed to start. Intelligence gathering is down. repo=${PROJECT_ROOT:-$PWD} socket=${EMIT_DAEMON_SOCKET} log=${HOOKS_DIR}/logs/emit-daemon.log" ) &
    log "Continuing without publisher (session startup not blocked)"
    return 0
}

# Legacy fallback: start omnibase_infra emit daemon (will be removed by OMN-1945)
_start_legacy_emit_daemon() {
    if [[ -z "${KAFKA_BOOTSTRAP_SERVERS:-}" ]]; then
        write_daemon_status "kafka_not_configured"
        return 0
    fi
    nohup "$PYTHON_CMD" -m omnibase_infra.runtime.emit_daemon.cli start \
        --kafka-servers "$KAFKA_BOOTSTRAP_SERVERS" \
        --socket-path "$EMIT_DAEMON_SOCKET" \
        --daemonize \
        >> "${HOOKS_DIR}/logs/emit-daemon.log" 2>&1 &
    local daemon_pid=$!
    log "Legacy daemon started with PID $daemon_pid"
    local wait_count=0
    local max_wait=10
    while [[ ! -S "$EMIT_DAEMON_SOCKET" && $wait_count -lt $max_wait ]]; do
        sleep 0.02
        ((wait_count++)) || true  # || true: post-increment from 0 returns exit code 1 under set -e
    done
    if [[ -S "$EMIT_DAEMON_SOCKET" ]]; then
        write_daemon_status "running"
    fi
    return 0
}

# Performance tracking
START_TIME=$(get_time_ms)

# Read stdin
INPUT=$(cat)

# Log raw input for debugging the actual payload format.
# Only written when OMNICLAUDE_DEBUG_SESSION_INPUT is set to "true" or "1".
# Gated to prevent unbounded growth of the debug file on every SessionStart.
_DEBUG_SESSION_INPUT="${OMNICLAUDE_DEBUG_SESSION_INPUT:-false}"
_DEBUG_FILE="/tmp/claude-hook-debug-sessionstart.json"
if [[ "$_DEBUG_SESSION_INPUT" == "true" || "$_DEBUG_SESSION_INPUT" == "1" ]]; then
    {
        echo "--- $(date -u '+%Y-%m-%dT%H:%M:%SZ') ---"
        echo "$INPUT"
    } >> "$_DEBUG_FILE" 2>/dev/null
fi

if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
    if [[ -z "$INPUT" ]]; then
        log "WARNING: Empty stdin received from Claude Code, using empty object"
        log "  Check /tmp/claude-hook-debug-sessionstart.json for raw payload"
        INPUT='{}'
    elif ! echo "$INPUT" | jq -e . >/dev/null 2>>"$LOG_FILE"; then
        log "ERROR: Malformed JSON on stdin, using empty object"
        log "  Check /tmp/claude-hook-debug-sessionstart.json for raw payload"
        INPUT='{}'
    fi
fi

log "SessionStart hook triggered (plugin mode)"
log "Using Python: $PYTHON_CMD"

# Hook health probe [F32] — verify all Python hook handlers can import
PROBE_RESULT=$("$PYTHON_CMD" -m omniclaude.hooks.lib.hook_health_probe 2>>"$LOG_FILE") || true
PROBE_FAILURES=$(echo "$PROBE_RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('failures',0))" 2>/dev/null || echo "0")
if [[ "$PROBE_FAILURES" != "0" ]]; then
    log "WARNING: $PROBE_FAILURES hook handler(s) failed import check. See hooks.log for details."
fi

# Extract session information
# Claude Code API (2026-02+): snake_case field names (session_id, cwd, etc.)
# Claude Code API (pre-2026-02): camelCase field names (sessionId, projectPath, etc.)
# Both forms are tried for backwards compatibility; snake_case is primary.
if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
    SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // .sessionId // ""')
    PROJECT_PATH=$(echo "$INPUT" | jq -r '.project_path // .projectPath // ""')
    CWD=$(echo "$INPUT" | jq -r '.cwd // ""' || pwd)
    # Log what fields were actually present to aid diagnosis
    _ACTUAL_KEYS=$(echo "$INPUT" | jq -r 'keys | join(",")' 2>/dev/null || echo "unknown")
    log "Input fields present: $_ACTUAL_KEYS"
    # Generate correlation ID for this session (used for pattern injection tracking)
    CORRELATION_ID=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "session-${SESSION_ID:-unknown}-$(date +%s)")
else
    # Fallback values when jq is not available
    SESSION_ID=""
    PROJECT_PATH=""
    CWD=$(pwd)
    CORRELATION_ID=""
fi

if [[ -z "$CWD" ]]; then
    CWD=$(pwd)
fi

log "Session ID: $SESSION_ID"
log "Project Path: $PROJECT_PATH"
log "CWD: $CWD"

# Start emit daemon early (before any Kafka emissions)
# This ensures daemon is ready for downstream hooks (UserPromptSubmit, PostToolUse)
start_emit_daemon_if_needed

# --- Hook Runtime Daemon [OMN-5309] ---
# Lazily launch the hook runtime daemon (pure Python, no Kafka dependency).
# The daemon serves classify_tool / reset_session / ping requests from hook shims.
# Multiple concurrent Claude Code sessions share one daemon instance via the socket.
# Follows the same pattern as start_emit_daemon_if_needed above.
_HOOK_RUNTIME_SOCKET="${_TMPDIR}/omniclaude-hook-runtime.sock"
_HOOK_RUNTIME_PID="${_TMPDIR}/omniclaude-hook-runtime.pid"
_HOOK_RUNTIME_CONFIG="${HOOKS_DIR}/config.yaml"

start_hook_runtime_if_needed() {
    if [[ -S "$_HOOK_RUNTIME_SOCKET" ]]; then
        # Fast path: PID check via kill -0 (no Python spawn)
        if [[ -f "$_HOOK_RUNTIME_PID" ]]; then
            local _hrt_pid
            _hrt_pid=$(cat "$_HOOK_RUNTIME_PID" 2>/dev/null)
            if [[ -n "$_hrt_pid" ]] && kill -0 "$_hrt_pid" 2>/dev/null; then
                log "Hook runtime daemon already running (PID $_hrt_pid, fast-path skip)"
                return 0
            fi
        fi
        # Slow path: PID file missing or process dead — ping socket
        if HOOK_RUNTIME_SOCKET="$_HOOK_RUNTIME_SOCKET" \
           "$PYTHON_CMD" -c "
import socket,sys,json
s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
s.settimeout(0.1)
try:
    s.connect(sys.argv[1])
    s.sendall(b'{\"action\":\"ping\",\"session_id\":\"health\",\"payload\":{}}\n')
    line=s.makefile().readline().strip()
    resp=json.loads(line) if line else {}
    sys.exit(0 if resp.get('decision')=='ack' else 1)
except Exception:
    sys.exit(1)
finally:
    s.close()
" "$_HOOK_RUNTIME_SOCKET" >/dev/null 2>&1; then
            log "Hook runtime daemon already running and responsive"
            return 0
        else
            log "Hook runtime daemon socket stale, restarting"
            rm -f "$_HOOK_RUNTIME_SOCKET" "$_HOOK_RUNTIME_PID" 2>/dev/null || true
        fi
    fi

    # Check that hook_runtime module is available before launching
    if ! "$PYTHON_CMD" -c "import omniclaude.hook_runtime" 2>/dev/null; then
        log "Hook runtime module not available (omniclaude.hook_runtime) — skipping"
        return 0  # Non-fatal; hooks fall back to shell-based enforcement
    fi

    log "Starting hook runtime daemon..."
    mkdir -p "${HOOKS_DIR}/logs"

    local _config_arg=""
    if [[ -f "$_HOOK_RUNTIME_CONFIG" ]]; then
        _config_arg="--config $_HOOK_RUNTIME_CONFIG"
    fi

    # shellcheck disable=SC2086
    nohup "$PYTHON_CMD" -m omniclaude.hook_runtime start \
        --socket-path "$_HOOK_RUNTIME_SOCKET" \
        ${_config_arg} \
        >> "${HOOKS_DIR}/logs/hook-runtime-daemon.log" 2>&1 &

    local _hrt_launch_pid=$!
    log "Hook runtime daemon launched with PID $_hrt_launch_pid"

    # Wait up to 200ms for socket to appear (10 x 20ms)
    local _wait=0
    while [[ ! -S "$_HOOK_RUNTIME_SOCKET" && $_wait -lt 10 ]]; do
        sleep 0.02
        ((_wait++)) || true
    done

    if [[ -S "$_HOOK_RUNTIME_SOCKET" ]]; then
        echo "$_hrt_launch_pid" > "$_HOOK_RUNTIME_PID" 2>/dev/null || true
        log "Hook runtime daemon ready"
    else
        log "WARNING: Hook runtime daemon socket did not appear within 200ms — hooks will use shell fallback"
    fi
}

start_hook_runtime_if_needed

# --- End Hook Runtime Daemon ---

# -----------------------------
# Session State Initialization (OMN-2119)
# -----------------------------
# Sync path: mkdir only (O(1), <5ms).
# Async path: adapter creates run + updates index (backgrounded).
# Idempotency: stamp file (.done) prevents re-init on reconnect.
# Concurrency: PID file (.pid) prevents duplicate concurrent spawns.

_init_session_state() {
    # Sync: ensure state directory exists (O(1))
    mkdir -p "${ONEX_SESSION_STATE_DIR}/runs" 2>/dev/null || true

    # Sanitize SESSION_ID for safe use in filenames
    local safe_id
    safe_id=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9-')

    # Empty SESSION_ID safety: skip idempotency AND PID guards entirely.
    # When safe_id is empty, both the stamp file (.done) and PID guard file (.pid)
    # would collapse to a shared path for ALL empty-ID sessions, causing
    # cross-session interference. Idempotency for empty sessions isn't critical
    # since the data will be overwritten anyway.
    if [[ -z "$safe_id" ]]; then
        log "WARNING: Empty SESSION_ID, skipping idempotency and PID guards"
    else
        # Idempotency guard: prevent duplicate init on reconnect.
        # SessionStart may fire multiple times for the same session (reconnects).
        # The stamp file persists after the adapter completes, so subsequent calls
        # for the same session return immediately (O(1) file existence check).
        # Cleanup: /tmp is cleared on reboot (both Linux and macOS), so stamp
        # files do not accumulate across reboots. No active cleanup needed.
        local stamp_file="/tmp/omniclaude-state-init-${safe_id}.done"
        if [[ -f "$stamp_file" ]]; then
            log "Session state already initialized (stamp: $stamp_file), skipping"
            return 0
        fi

        # PID guard: prevent duplicate concurrent spawns for same session.
        # This protects against rapid-fire SessionStart events before the first
        # background adapter call has finished and written the stamp file.
        local guard_file="/tmp/omniclaude-state-init-${safe_id}.pid"
        local guard_pid
        guard_pid=$(cat "$guard_file" 2>/dev/null) || true
        if [[ "$guard_pid" =~ ^[0-9]+$ ]] && kill -0 "$guard_pid" 2>/dev/null; then
            log "Session state init already running (PID $guard_pid), skipping"
            return 0
        fi
    fi

    # Async: background the adapter call
    if [[ -f "${HOOKS_LIB}/node_session_state_adapter.py" ]]; then
        (
            # Write PID guard only when safe_id is non-empty (guard_file is defined).
            # BASHPID is required for correct PID detection inside subshells.
            # It is only available in bash 4.0+. On bash 3.x (e.g., macOS system bash
            # at /bin/bash which is 3.2), BASHPID is unset and $$ reflects the parent
            # process PID. A subsequent `kill -0 $guard_pid` check would then succeed
            # (parent is running) and incorrectly conclude init is already in progress.
            # Guard: skip the PID file entirely on bash < 4 where BASHPID is unreliable.
            if [[ -n "$safe_id" ]]; then
                if [[ "${BASH_VERSINFO[0]:-0}" -ge 4 ]]; then
                    echo "$BASHPID" > "/tmp/omniclaude-state-init-${safe_id}.pid" 2>/dev/null || true
                else
                    # bash < 4.0: BASHPID unavailable; PID guard skipped to avoid
                    # false-positive "already running" detection via parent PID.
                    # Idempotency is still enforced by the stamp file (.done).
                    true
                fi
            fi
            # Capture adapter stdout separately from stderr so we can parse the
            # JSON result. stderr goes to $LOG_FILE for diagnostics; stdout is
            # captured into adapter_stdout for run_id extraction.
            adapter_stdout=$(echo "$INPUT" | "$PYTHON_CMD" "${HOOKS_LIB}/node_session_state_adapter.py" init 2>>"$LOG_FILE")
            local adapter_exit=$?
            # Log the adapter output for diagnostics
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] [session-state] adapter stdout: ${adapter_stdout:-<empty>}" >> "$LOG_FILE"
            # Extract run_id from adapter JSON output.
            # The adapter outputs {"run_id": "...", "state": "..."} on success
            # and {} on logical failure (missing session_id, lock timeout, etc.).
            # Since the adapter always exits 0 (fail-open design), we must check
            # for a non-empty run_id to distinguish success from logical failure.
            local adapter_run_id=""
            if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
                adapter_run_id=$(echo "$adapter_stdout" | jq -r '.run_id // ""' 2>/dev/null) || adapter_run_id=""
            else
                # Fallback: grep for run_id value (handles {"run_id": "uuid-here", ...})
                adapter_run_id=$(echo "$adapter_stdout" | grep -o '"run_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"run_id"[[:space:]]*:[[:space:]]*"//;s/"$//' 2>/dev/null) || adapter_run_id=""
            fi
            # Clean up PID guard and write stamp only when safe_id is non-empty
            if [[ -n "$safe_id" ]]; then
                rm -f "/tmp/omniclaude-state-init-${safe_id}.pid" 2>/dev/null || true
                # Write stamp file ONLY when adapter returned a valid run_id.
                # The adapter always exits 0 (fail-open), so exit code alone
                # cannot distinguish success from logical failure. A non-empty
                # run_id confirms the init actually completed (run doc written,
                # session index updated). Without this check, a failed init
                # (e.g., lock timeout, missing session_id) would write the stamp
                # and permanently prevent retry on reconnect.
                if [[ $adapter_exit -eq 0 ]] && [[ -n "$adapter_run_id" ]]; then
                    echo "$$" > "/tmp/omniclaude-state-init-${safe_id}.done" 2>/dev/null || true
                elif [[ $adapter_exit -eq 0 ]] && [[ -z "$adapter_run_id" ]]; then
                    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [session-state] WARNING: adapter exited 0 but run_id is empty — logical init failure, stamp NOT written (retry allowed on reconnect)" >> "$LOG_FILE"
                fi
            fi
        ) &
        log "Session state init started in background (PID: $!)"
    fi
}

_init_session_state

# Log session start to database (async, non-blocking)
if [[ -f "${HOOKS_LIB}/session_intelligence.py" ]]; then
    (
        "$PYTHON_CMD" "${HOOKS_LIB}/session_intelligence.py" \
            --mode start \
            --session-id "$SESSION_ID" \
            --project-path "$PROJECT_PATH" \
            --cwd "$CWD" \
            >> "$LOG_FILE" 2>&1
    ) &
    log "Session intelligence logging started"
fi

# Emit session.started event to Kafka (async, non-blocking)
# Uses emit_client_wrapper with daemon fan-out (OMN-1631)
# Requires jq for payload construction
if [[ "$KAFKA_ENABLED" == "true" && "$JQ_AVAILABLE" -eq 1 ]]; then
    (
        GIT_BRANCH=""
        if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
            GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "")
        fi

        # Build action_description per OMN-3297: "Session: {repo}@{branch}"
        # repo = basename of CWD; branch = git branch or "unknown"
        _AD_REPO="${CWD##*/}"
        _AD_BRANCH="${GIT_BRANCH:-unknown}"
        _AD_STR="Session: ${_AD_REPO}@${_AD_BRANCH}"
        # Normalize: strip newlines, cap at 160 chars
        _AD_STR=$(printf '%s' "$_AD_STR" | tr '\n\r' '  ')
        _AD_STR="${_AD_STR:0:160}"

        # Build payload with all fields needed for session.started event
        SESSION_PAYLOAD=$(jq -n \
            --arg session_id "$SESSION_ID" \
            --arg working_directory "$CWD" \
            --arg hook_source "startup" \
            --arg git_branch "$GIT_BRANCH" \
            --arg action_description "$_AD_STR" \
            '{
                session_id: $session_id,
                working_directory: $working_directory,
                hook_source: $hook_source,
                git_branch: $git_branch,
                action_description: $action_description
            }' 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.started" "$SESSION_PAYLOAD" 100
        fi
    ) &
    log "Session event emission started via emit daemon"
else
    if [[ "$JQ_AVAILABLE" -eq 0 ]]; then
        log "Kafka emission skipped (jq not available for payload construction)"
    else
        log "Kafka emission skipped (KAFKA_ENABLED=$KAFKA_ENABLED)"
    fi
fi

# -----------------------------
# Tier Detection Probe (OMN-2782) - ASYNC, NON-BLOCKING
# -----------------------------
# Probes Kafka + intelligence service availability and writes tier to
# $ONEX_STATE_DIR/.onex_capabilities for use by context_injection_wrapper.py.
# Runs in background to keep SessionStart under <50ms budget.

if [[ -f "${HOOKS_LIB}/capability_probe.py" ]]; then
    # Redirect stdout to /dev/null: capability_probe.py prints "tier=<value>" to
    # stdout, which can race with the hook's JSON output and corrupt it.
    # Claude Code reads the hook's stdout pipe and fails JSON parsing when
    # "tier=event_bus" arrives after (or interleaved with) the JSON body,
    # causing the "UserPromptSubmit hook error" on every session restart.
    ( "$PYTHON_CMD" "${HOOKS_LIB}/capability_probe.py" \
        --kafka "${KAFKA_BOOTSTRAP_SERVERS:-}" \
        --intelligence "${INTELLIGENCE_SERVICE_URL:-http://localhost:8053}" \
        >/dev/null 2>>"${LOG_FILE:-/dev/null}" & )
    log "Tier detection probe started in background"
fi

# -----------------------------
# Learned Pattern Injection (OMN-1675) - ASYNC IMPLEMENTATION
# -----------------------------
# PERFORMANCE GUARANTEE: Pattern injection runs in a background subshell via `( ... ) &`.
# This ensures the main hook returns immediately (<50ms target) regardless of:
#   - Pattern retrieval latency from OmniMemory
#   - Network timeout to intelligence services
#   - Any errors in the injection pipeline
#
# The first UserPromptSubmit will handle pattern injection (session not yet marked).
# This async process marks the session after completion to prevent duplicate injection
# on subsequent prompts.
#
# CRITICAL: Session is marked as injected for ALL outcomes:
#   - Success with patterns: marked with injection_id
#   - Control cohort (A/B testing): marked with "cohort-<name>"
#   - Empty patterns (no relevant patterns): marked with "no-patterns"
#   - Error/timeout: marked with "error-exit-<code>" (e.g., error-exit-142 for timeout)
#   - Fallback: marked with "async-completed" (rare edge case)
# This prevents UserPromptSubmit from attempting duplicate injection.
#
# Trade-off: SessionStart returns immediately without patterns in additionalContext.
# UserPromptSubmit provides fallback injection for the first prompt.

if [[ "${SESSION_INJECTION_ENABLED:-true}" == "true" ]] && [[ -f "${HOOKS_LIB}/context_injection_wrapper.py" ]] && [[ "$JQ_AVAILABLE" -eq 1 ]]; then
    log "Starting async pattern injection for SessionStart"

    # Run pattern injection in background subshell (non-blocking)
    (
        _async_log() {
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] [session-start-async] $*" >> "$LOG_FILE"
        }

        _async_log "Async pattern injection started for session ${SESSION_ID:-unknown}"

        # Build injection input
        PATTERN_INPUT="$(jq -n \
            --arg session "${SESSION_ID:-}" \
            --arg project "${PROJECT_PATH:-$(pwd)}" \
            --arg correlation "${CORRELATION_ID:-}" \
            --argjson max_patterns "${SESSION_INJECTION_MAX_PATTERNS:-10}" \
            --argjson min_confidence "${SESSION_INJECTION_MIN_CONFIDENCE:-0.7}" \
            --argjson include_footer "${SESSION_INJECTION_INCLUDE_FOOTER:-false}" \
            '{
                session_id: $session,
                project: $project,
                correlation_id: $correlation,
                max_patterns: $max_patterns,
                min_confidence: $min_confidence,
                injection_context: "session_start",
                include_footer: $include_footer,
                emit_event: true
            }' 2>/dev/null)"

        # Call wrapper with timeout (8000ms default, convert to seconds)
        # Use bc if available, otherwise fall back to shell arithmetic
        if [[ "$BC_AVAILABLE" -eq 1 ]]; then
            TIMEOUT_SEC=$(echo "scale=1; ${SESSION_INJECTION_TIMEOUT_MS:-8000} / 1000" | bc)
        else
            # Shell arithmetic fallback: integer division + one decimal place
            # e.g., 500ms -> 0.5s, 1000ms -> 1.0s, 8000ms -> 8.0s
            _timeout_ms="${SESSION_INJECTION_TIMEOUT_MS:-8000}"
            _timeout_sec=$((_timeout_ms / 1000))
            _timeout_decimal=$(((_timeout_ms % 1000) / 100))
            TIMEOUT_SEC="${_timeout_sec}.${_timeout_decimal}"
            # Ensure minimum timeout of 0.1s to prevent effectively disabling timeout
            if [[ "$TIMEOUT_SEC" == "0.0" ]] || [[ "$TIMEOUT_SEC" == "0" ]]; then
                TIMEOUT_SEC="0.1"
            fi
        fi
        INJECTION_EXIT_CODE=0
        PATTERN_RESULT="$(echo "$PATTERN_INPUT" | run_with_timeout "${TIMEOUT_SEC}" $PYTHON_CMD "${HOOKS_LIB}/context_injection_wrapper.py" 2>>"$LOG_FILE")" || {
            INJECTION_EXIT_CODE=$?
            # Log detailed error context for debugging
            # Exit codes: 142 = SIGALRM (timeout), 1 = general error, other = script-specific
            _async_log "WARNING: Pattern injection failed - exit_code=${INJECTION_EXIT_CODE} timeout_sec=${TIMEOUT_SEC} session=${SESSION_ID:-unknown}"
            if [[ $INJECTION_EXIT_CODE -eq 142 ]]; then
                _async_log "DEBUG: Injection timed out after ${TIMEOUT_SEC}s (SIGALRM). Consider increasing SESSION_INJECTION_TIMEOUT_MS (current: ${SESSION_INJECTION_TIMEOUT_MS:-8000})"
            else
                _async_log "DEBUG: Injection error (check stderr above in log). Wrapper: ${HOOKS_LIB}/context_injection_wrapper.py"
            fi
            PATTERN_RESULT='{}'
        }

        # Extract results
        INJECTION_ID="$(echo "$PATTERN_RESULT" | jq -r '.injection_id // ""' 2>/dev/null)"
        INJECTION_COHORT="$(echo "$PATTERN_RESULT" | jq -r '.cohort // ""' 2>/dev/null)"
        PATTERN_COUNT="$(echo "$PATTERN_RESULT" | jq -r '.pattern_count // 0' 2>/dev/null)"

        _async_log "Pattern injection complete: count=$PATTERN_COUNT cohort=$INJECTION_COHORT"

        # Mark session as injected (for UserPromptSubmit coordination)
        # CRITICAL: Always mark session when injection was ATTEMPTED, regardless of result.
        # This prevents duplicate injection attempts from UserPromptSubmit on subsequent prompts.
        #
        # Marker ID selection (in priority order):
        #   1. injection_id: Successful injection with patterns
        #   2. cohort name: Control cohort (A/B testing) or treatment without patterns
        #   3. "no-patterns": Empty pattern result (no relevant patterns found)
        #   4. "error-exit-<code>": Injection failed with specific exit code
        #   5. "async-completed": Final fallback (should rarely happen)
        if [[ -f "${HOOKS_LIB}/session_marker.py" ]]; then
            # Determine marker_id based on injection outcome
            if [[ -n "$INJECTION_ID" ]]; then
                marker_id="$INJECTION_ID"
                marker_reason="injection_success"
            elif [[ -n "$INJECTION_COHORT" ]]; then
                marker_id="cohort-${INJECTION_COHORT}"
                marker_reason="cohort_assigned"
            elif [[ $INJECTION_EXIT_CODE -ne 0 ]]; then
                marker_id="error-exit-${INJECTION_EXIT_CODE}"
                marker_reason="injection_error"
            elif [[ "$PATTERN_COUNT" -eq 0 ]]; then
                marker_id="no-patterns"
                marker_reason="empty_result"
            else
                marker_id="async-completed"
                marker_reason="fallback"
            fi

            _async_log "Marking session: marker_id=$marker_id reason=$marker_reason pattern_count=$PATTERN_COUNT"

            if $PYTHON_CMD "${HOOKS_LIB}/session_marker.py" mark \
                --session-id "${SESSION_ID}" \
                --injection-id "$marker_id" 2>>"$LOG_FILE"; then
                _async_log "Session marked as injected (marker_id=$marker_id, reason=$marker_reason)"
            else
                _async_log "WARNING: Failed to mark session as injected (marker_id=$marker_id)"
            fi
        fi
    ) &
    # PERFORMANCE: Background subshell (&) ensures main hook returns immediately.
    # The pattern injection runs asynchronously and will NOT block hook completion.
    # Session is marked after async completion to coordinate with UserPromptSubmit.
    log "Async pattern injection started in background (PID: $!) - hook will return immediately"
elif [[ "${SESSION_INJECTION_ENABLED:-true}" != "true" ]]; then
    log "Pattern injection disabled (SESSION_INJECTION_ENABLED=false)"
elif [[ "$JQ_AVAILABLE" -eq 0 ]]; then
    log "Pattern injection skipped (jq not available)"
else
    log "Pattern injection skipped (context_injection_wrapper.py not found)"
fi

# -----------------------------
# Static Context Snapshot (OMN-2237)
# -----------------------------
# Detect changes to CLAUDE.md, memory files, and .local.md since the last session.
# Runs ASYNCHRONOUSLY (backgrounded) to respect the <50ms hook budget.
# Emits static.context.edit.detected event when changes are found.

STATIC_SNAPSHOT_ENABLED="${OMNICLAUDE_STATIC_SNAPSHOT_ENABLED:-true}"
STATIC_SNAPSHOT_ENABLED=$(_normalize_bool "$STATIC_SNAPSHOT_ENABLED")

if [[ "${STATIC_SNAPSHOT_ENABLED}" == "true" ]] && [[ -f "${HOOKS_LIB}/static_context_snapshot.py" ]]; then
    _run_static_snapshot() {
        local _log="${LOG_FILE:-/dev/null}"
        "$PYTHON_CMD" "${HOOKS_LIB}/static_context_snapshot.py" scan \
            --session-id "${SESSION_ID:-unknown}" \
            --project-path "${PROJECT_PATH:-${CWD}}" \
            >> "$_log" 2>&1
    }
    if [[ -z "${SESSION_ID}" ]]; then
        log "WARNING: Empty SESSION_ID — skipping static snapshot idempotency guard"
        ( _run_static_snapshot ) &
        log "Static context snapshot started in background (PID: $!)"
    else
        STATIC_SNAPSHOT_STAMP="/tmp/omniclaude-static-snapshot-$(echo -n "${SESSION_ID}" | shasum -a 256 | cut -c1-16).done"
        if [[ -f "$STATIC_SNAPSHOT_STAMP" ]]; then
            log "Static context snapshot already run for this session, skipping"
        else
            (
                _run_static_snapshot
                touch "$STATIC_SNAPSHOT_STAMP" 2>/dev/null || true
            ) &
            log "Static context snapshot started in background (PID: $!)"
        fi
    fi
elif [[ "${STATIC_SNAPSHOT_ENABLED}" != "true" ]]; then
    log "Static context snapshot disabled (STATIC_SNAPSHOT_ENABLED=false)"
else
    log "Static context snapshot skipped (static_context_snapshot.py not found)"
fi

# -----------------------------
# Pipeline State Cleanup
# -----------------------------
# Remove pipeline state dirs for branches merged into main.
# Runs ASYNCHRONOUSLY (backgrounded) — never blocks the hook.

_cleanup_merged_pipeline_states() {
    local _log="${LOG_FILE:-/dev/null}"
    local pipelines_dir
    pipelines_dir=$(cd "${ONEX_PIPELINES_DIR}" 2>/dev/null && pwd -P || echo "${ONEX_PIPELINES_DIR}")

    [[ -d "$pipelines_dir" ]] || return 0

    local removed=0
    for state_file in "$pipelines_dir"/*/state.yaml; do
        [[ -f "$state_file" ]] || continue

        # Read branch_name and repo_path in one Python call
        local fields branch repo
        fields=$("$PYTHON_CMD" - "$state_file" <<'PYEOF' 2>/dev/null
import yaml, sys
try:
    with open(sys.argv[1], encoding='utf-8') as f:
        d = yaml.safe_load(f) or {}
    print(d.get('branch_name', ''))
    print(d.get('repo_path', ''))
except Exception:
    print('')
    print('')
PYEOF
) || fields=""
        branch=$(echo "$fields" | sed -n '1p')
        repo=$(echo "$fields" | sed -n '2p')

        [[ -n "$branch" && -n "$repo" && -d "$repo" ]] || continue

        # Never delete states for protected/default branches regardless of merged status.
        # git branch --merged main always includes main itself in its output, so without
        # this guard a state file with branch_name=main would be incorrectly deleted.
        case "$branch" in
            main|master|develop|dev)
                continue
                ;;
        esac

        # Check if branch is merged into main or master.
        # git branch --merged outputs names with two leading spaces (e.g., "  feature-x")
        # or "* current-branch" for the active branch. Use grep -qxF with the exact
        # two-space prefix to avoid false positives from substring matches or the
        # active-branch marker ("* main" matching when branch="main").
        local merged=""
        # GIT_TERMINAL_PROMPT=0 prevents interactive credential prompts from
        # hanging this backgrounded subshell indefinitely on repos requiring auth.
        if GIT_TERMINAL_PROMPT=0 git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then
            if GIT_TERMINAL_PROMPT=0 git -C "$repo" branch --merged main 2>/dev/null | grep -qxF "  $branch"; then
                merged="yes"
            elif GIT_TERMINAL_PROMPT=0 git -C "$repo" branch --merged master 2>/dev/null | grep -qxF "  $branch"; then
                merged="yes"
            fi
        fi

        if [[ -n "$merged" ]]; then
            local ticket_dir
            ticket_dir="$(dirname "$state_file")"
            # Canonicalize to resolve symlinks before the prefix guard, matching
            # the worktree cleanup in session-end.sh which uses `pwd -P`.
            # If the directory is already gone (race), skip it entirely.
            ticket_dir="$(cd "$ticket_dir" 2>/dev/null && pwd -P || echo "")"
            [[ -z "$ticket_dir" ]] && continue
            # Defense-in-depth: confirm ticket_dir is a direct child of pipelines_dir
            # before removing it.  A symlink under pipelines_dir pointing outside the
            # tree would otherwise cause rm -rf to delete an unrelated target.
            # Use a case-prefix check (the same pattern the worktree cleanup in
            # session-end.sh uses) to guard against symlink traversal.
            case "$ticket_dir" in
                "${pipelines_dir}"/*)
                    if rm -rf "$ticket_dir" 2>/dev/null; then
                        echo "[$(date '+%Y-%m-%d %H:%M:%S')] [pipeline-cleanup] Removed merged state: $(basename "$ticket_dir") (branch: $branch)" >> "$_log"
                        ((removed++)) || true
                    fi
                    ;;
                *)
                    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [pipeline-cleanup] SKIP: ticket_dir '${ticket_dir}' is outside pipelines_dir '${pipelines_dir}' — refusing to rm -rf" >> "$_log"
                    ;;
            esac
        fi
    done

    [[ $removed -gt 0 ]] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] [pipeline-cleanup] Cleaned $removed merged pipeline state dir(s)" >> "$_log" || true
}

PIPELINE_CLEANUP_ENABLED="${OMNICLAUDE_PIPELINE_CLEANUP_ENABLED:-true}"
PIPELINE_CLEANUP_ENABLED=$(_normalize_bool "$PIPELINE_CLEANUP_ENABLED")

if [[ "${PIPELINE_CLEANUP_ENABLED}" == "true" ]]; then
    ( _cleanup_merged_pipeline_states ) &
    log "Pipeline state cleanup started in background (PID: $!)"
else
    log "Pipeline state cleanup disabled (OMNICLAUDE_PIPELINE_CLEANUP_ENABLED=false)"
fi

# -----------------------------
# Ticket Context Injection (OMN-1830, OMN-3216)
# -----------------------------
# Inject active ticket context for session continuity.
# Runs SYNCHRONOUSLY because it's purely local filesystem (fast).
# This ensures ticket context is available immediately in additionalContext.
#
# OMN-3216: CWD-based extraction replaces the mtime heuristic.
# Ground truth: if CWD is inside OMNI_WORKTREES_DIR/OMN-XXXX/, the ticket is OMN-XXXX.
# The mtime heuristic (find_active_ticket in ticket_context_injector.py) is only
# used as a last-resort fallback when CWD is not inside a worktree directory.
#
# R6: Timeout is configurable via OMNICLAUDE_TICKET_INJECTION_TIMEOUT_SEC (default 4).
# R7: Stale /tmp marker cleanup runs here (files older than 24h).
# R9: Markers are skipped entirely when SESSION_ID is empty (hooks produce different IDs).
# R10: OMNI_WORKTREES_DIR env var controls worktree root (default /Volumes/PRO-G40/Code/omni_worktrees).  # local-path-ok

TICKET_INJECTION_ENABLED="${OMNICLAUDE_TICKET_INJECTION_ENABLED:-true}"
TICKET_INJECTION_ENABLED=$(_normalize_bool "$TICKET_INJECTION_ENABLED")
TICKET_CONTEXT=""
ACTIVE_TICKET=""

# R10: Configurable worktrees root directory
OMNI_WORKTREES_DIR="${OMNI_WORKTREES_DIR:-/Volumes/PRO-G40/Code/omni_worktrees}"  # local-path-ok

# R7: Clean up stale /tmp ticket marker files (older than 24h) at session start.
# Uses -mmin +1440 (1440 minutes = 24 hours). Runs silently in background.
find /tmp -maxdepth 1 -name "omniclaude-ticket-ctx-*" -mmin +1440 -delete 2>/dev/null || true

# OMN-3216 R1/R10: CWD-based ticket extraction.
# Extracts OMN-XXXX from the CWD path when CWD is inside OMNI_WORKTREES_DIR/OMN-XXXX/.
# Uses `OMN-[0-9]\+` (BRE one-or-more) to require at least one digit (R1 fix).
TICKET_FROM_CWD=""
if [[ -n "$CWD" ]] && [[ -n "$OMNI_WORKTREES_DIR" ]]; then
    # Normalize OMNI_WORKTREES_DIR: strip trailing slash for clean prefix matching
    _wt_dir="${OMNI_WORKTREES_DIR%/}"
    if [[ "$CWD" == "${_wt_dir}/"* ]]; then
        # CWD is inside the worktrees directory — extract the ticket segment.
        # Strip the worktrees prefix, then take the first path component.
        _after_wt="${CWD#${_wt_dir}/}"
        _candidate="${_after_wt%%/*}"
        # Validate: must match OMN-<one-or-more-digits> (BRE, not ERE)
        if echo "$_candidate" | grep -q '^OMN-[0-9]\+$'; then
            TICKET_FROM_CWD="$_candidate"
            log "CWD-based ticket extraction: $TICKET_FROM_CWD (from CWD=$CWD)"
        else
            log "CWD inside worktrees dir but segment '$_candidate' is not a valid ticket ID — skipping CWD extraction"
        fi
    else
        log "CWD not inside OMNI_WORKTREES_DIR ($OMNI_WORKTREES_DIR) — no CWD-based ticket"
    fi
    unset _wt_dir _after_wt _candidate
fi

# R3: ACTIVE_TICKET defaults to TICKET_FROM_CWD; updated only if injector output has .ticket_id
ACTIVE_TICKET="$TICKET_FROM_CWD"

if [[ "${TICKET_INJECTION_ENABLED}" == "true" ]] && [[ -f "${HOOKS_LIB}/ticket_context_injector.py" ]] && [[ -n "$TICKET_FROM_CWD" ]]; then
    log "Checking for active ticket context (ticket: $TICKET_FROM_CWD)"

    # Build injector input JSON.
    # TICKET_FROM_CWD is guaranteed non-empty here (guard above).
    # Pass ticket_id directly so the injector fetches context without
    # triggering the mtime fallback (find_active_ticket).
    if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
        TICKET_INPUT=$(jq -n --arg ticket_id "$TICKET_FROM_CWD" \
            '{ticket_id: $ticket_id}' 2>/dev/null) || TICKET_INPUT="{\"ticket_id\": \"${TICKET_FROM_CWD}\"}"
    else
        TICKET_INPUT="{\"ticket_id\": \"${TICKET_FROM_CWD}\"}"
    fi

    # Run ticket context injection synchronously via CLI (fast, local-only).
    # Single Python invocation for better performance within 50ms budget.
    TICKET_OUTPUT=$(echo "$TICKET_INPUT" | "$PYTHON_CMD" "${HOOKS_LIB}/ticket_context_injector.py" 2>>"$LOG_FILE") || TICKET_OUTPUT='{}'

    # R2: Detect jq parse failure vs. legitimate empty-success.
    # When jq exits 0 but .ticket_id is absent, log "invalid JSON" rather than
    # treating it as a silent success with no ticket.
    if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
        if ! echo "$TICKET_OUTPUT" | jq -e . >/dev/null 2>/dev/null; then
            log "WARNING: ticket_context_injector.py returned invalid JSON (parse failure)"
            TICKET_OUTPUT='{}'
        fi

        INJECTOR_TICKET_ID=$(echo "$TICKET_OUTPUT" | jq -r '.ticket_id // empty' 2>/dev/null) || INJECTOR_TICKET_ID=""
        TICKET_CONTEXT=$(echo "$TICKET_OUTPUT" | jq -r '.ticket_context // empty' 2>/dev/null) || TICKET_CONTEXT=""
        TICKET_RETRIEVAL_MS=$(echo "$TICKET_OUTPUT" | jq -r '.retrieval_ms // 0' 2>/dev/null) || TICKET_RETRIEVAL_MS=0
        TICKET_FALLBACK_USED=$(echo "$TICKET_OUTPUT" | jq -r '.fallback_used // false' 2>/dev/null) || TICKET_FALLBACK_USED="false"

        # R3: Update ACTIVE_TICKET only if injector output has .ticket_id
        if [[ -n "$INJECTOR_TICKET_ID" ]]; then
            ACTIVE_TICKET="$INJECTOR_TICKET_ID"
        fi
        # If ACTIVE_TICKET is still empty after injector, it stays as TICKET_FROM_CWD (which may also be empty)

        if [[ -n "$ACTIVE_TICKET" ]] && [[ -n "$TICKET_CONTEXT" ]]; then
            log "Active ticket found: $ACTIVE_TICKET (retrieved in ${TICKET_RETRIEVAL_MS}ms, fallback_used=${TICKET_FALLBACK_USED})"
            log "Ticket context generated (${#TICKET_CONTEXT} chars)"
            # R9: Write coordination marker only when SESSION_ID is non-empty.
            # user-prompt-submit.sh checks this marker to skip first-prompt injection
            # for sessions where SessionStart already injected ticket context.
            if [[ -n "$SESSION_ID" ]]; then
                touch "/tmp/omniclaude-ticket-ctx-${SESSION_ID}" 2>/dev/null || true
                log "Wrote ticket context marker: /tmp/omniclaude-ticket-ctx-${SESSION_ID:0:8}..."
            fi
            # Write .ticket file for statusline tab label (omni_home tabs have no git branch)
            if [[ -n "${ITERM_SESSION_ID:-}" ]]; then
                _ticket_guid="${ITERM_SESSION_ID#*:}"
                mkdir -p "/tmp/omniclaude-tabs" 2>/dev/null || true
                printf '%s' "$ACTIVE_TICKET" > "/tmp/omniclaude-tabs/${_ticket_guid}.ticket" 2>/dev/null || true
            fi
        else
            log "No active ticket found"
        fi
    else
        # Fallback: extract ticket_context using basic string parsing
        TICKET_CONTEXT=$(echo "$TICKET_OUTPUT" | "$PYTHON_CMD" -c "import sys,json; d=json.load(sys.stdin); print(d.get('ticket_context',''))" 2>/dev/null) || TICKET_CONTEXT=""
        log "Ticket context check completed (jq unavailable for detailed parsing)"
    fi
elif [[ "${TICKET_INJECTION_ENABLED}" == "true" ]] && [[ -f "${HOOKS_LIB}/ticket_context_injector.py" ]]; then
    log "Ticket context injection skipped (not in a worktree — mtime fallback disabled)"
elif [[ "${TICKET_INJECTION_ENABLED}" != "true" ]]; then
    log "Ticket context injection disabled (TICKET_INJECTION_ENABLED=false)"
else
    log "Ticket context injection skipped (ticket_context_injector.py not found)"
fi

# -----------------------------
# Combined Injector (OMN-4383)
# -----------------------------
# Runs architecture_handshake_injector + skill_suggestion_injector in a single
# Python subprocess, saving ~64ms cold-start vs two sequential invocations.
# Both features are controlled by their original env-var flags; if either is
# disabled the combined script still runs (skipping that feature internally
# would require more complexity than the flag check below).

HANDSHAKE_INJECTION_ENABLED="${OMNICLAUDE_HANDSHAKE_INJECTION_ENABLED:-true}"
HANDSHAKE_INJECTION_ENABLED=$(_normalize_bool "$HANDSHAKE_INJECTION_ENABLED")
SKILL_SUGGESTIONS_ENABLED="${OMNICLAUDE_SKILL_SUGGESTIONS_ENABLED:-true}"
SKILL_SUGGESTIONS_ENABLED=$(_normalize_bool "$SKILL_SUGGESTIONS_ENABLED")

HANDSHAKE_CONTEXT=""
SKILL_SUGGESTIONS=""

if [[ -f "${HOOKS_LIB}/combined_injector.py" ]]; then
    # Build input JSON with project path (prefer repo root over CWD)
    HANDSHAKE_PROJECT="${PROJECT_PATH:-}"
    if [[ -z "$HANDSHAKE_PROJECT" ]]; then
        HANDSHAKE_PROJECT="${PROJECT_ROOT:-}"
    fi
    if [[ -z "$HANDSHAKE_PROJECT" ]]; then
        HANDSHAKE_PROJECT="$CWD"
    fi
    COMBINED_INPUT=$(jq -n --arg project "$HANDSHAKE_PROJECT" '{"project_path": $project}' 2>/dev/null) || COMBINED_INPUT='{}'

    # Single Python invocation for both injectors (OMN-4383: saves ~64ms cold-start)
    COMBINED_OUTPUT=$(echo "$COMBINED_INPUT" | "$PYTHON_CMD" "${HOOKS_LIB}/combined_injector.py" "${SESSION_ID:-}" 2>>"$LOG_FILE") || COMBINED_OUTPUT='{}'

    # Parse handshake fields
    if [[ "${HANDSHAKE_INJECTION_ENABLED}" == "true" ]]; then
        if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
            HANDSHAKE_PATH=$(echo "$COMBINED_OUTPUT" | jq -r '.handshake_path // empty' 2>/dev/null) || HANDSHAKE_PATH=""
            HANDSHAKE_CONTEXT=$(echo "$COMBINED_OUTPUT" | jq -r '.handshake_context // empty' 2>/dev/null) || HANDSHAKE_CONTEXT=""
            HANDSHAKE_RETRIEVAL_MS=$(echo "$COMBINED_OUTPUT" | jq -r '.retrieval_ms // 0' 2>/dev/null) || HANDSHAKE_RETRIEVAL_MS=0
            if [[ -n "$HANDSHAKE_PATH" ]] && [[ -n "$HANDSHAKE_CONTEXT" ]]; then
                log "Architecture handshake found: $HANDSHAKE_PATH (retrieved in ${HANDSHAKE_RETRIEVAL_MS}ms)"
                log "Handshake context generated (${#HANDSHAKE_CONTEXT} chars)"
            else
                log "No architecture handshake found"
            fi
        else
            # Fallback: extract handshake_context using Python
            HANDSHAKE_CONTEXT=$(echo "$COMBINED_OUTPUT" | "$PYTHON_CMD" -c "import sys,json; d=json.load(sys.stdin); print(d.get('handshake_context',''))" 2>/dev/null) || HANDSHAKE_CONTEXT=""
            log "Handshake check completed (jq unavailable for detailed parsing)"
        fi
    else
        log "Architecture handshake injection disabled (HANDSHAKE_INJECTION_ENABLED=false)"
    fi

    # Parse skill suggestions field
    if [[ "${SKILL_SUGGESTIONS_ENABLED}" == "true" ]]; then
        if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
            _sugg_output=$(echo "$COMBINED_OUTPUT" | jq -r '.skill_suggestions // empty' 2>/dev/null) || _sugg_output=""
        else
            _sugg_output=$(echo "$COMBINED_OUTPUT" | "$PYTHON_CMD" -c "import sys,json; d=json.load(sys.stdin); print(d.get('skill_suggestions',''))" 2>/dev/null) || _sugg_output=""
        fi
        if [[ -n "$_sugg_output" ]]; then
            SKILL_SUGGESTIONS="$_sugg_output"
            log "Skill suggestions injected (${#SKILL_SUGGESTIONS} chars)"
        else
            log "Skill suggestions: no suggestions available"
        fi
    else
        log "Skill suggestion injection disabled (OMNICLAUDE_SKILL_SUGGESTIONS_ENABLED=false)"
    fi
else
    log "Combined injector not found — falling back to individual scripts"

    # Fallback: architecture handshake
    if [[ "${HANDSHAKE_INJECTION_ENABLED}" == "true" ]] && [[ -f "${HOOKS_LIB}/architecture_handshake_injector.py" ]]; then
        HANDSHAKE_PROJECT="${PROJECT_PATH:-${PROJECT_ROOT:-$CWD}}"
        HANDSHAKE_INPUT=$(jq -n --arg project "$HANDSHAKE_PROJECT" '{"project_path": $project}' 2>/dev/null) || HANDSHAKE_INPUT='{}'
        HANDSHAKE_OUTPUT=$(echo "$HANDSHAKE_INPUT" | "$PYTHON_CMD" "${HOOKS_LIB}/architecture_handshake_injector.py" 2>>"$LOG_FILE") || HANDSHAKE_OUTPUT='{}'
        if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
            HANDSHAKE_CONTEXT=$(echo "$HANDSHAKE_OUTPUT" | jq -r '.handshake_context // empty' 2>/dev/null) || HANDSHAKE_CONTEXT=""
        else
            HANDSHAKE_CONTEXT=$(echo "$HANDSHAKE_OUTPUT" | "$PYTHON_CMD" -c "import sys,json; d=json.load(sys.stdin); print(d.get('handshake_context',''))" 2>/dev/null) || HANDSHAKE_CONTEXT=""
        fi
    fi

    # Fallback: skill suggestions
    if [[ "${SKILL_SUGGESTIONS_ENABLED}" == "true" ]] && [[ -f "${HOOKS_LIB}/skill_suggestion_injector.py" ]]; then
        _sugg_output=$("$PYTHON_CMD" "${HOOKS_LIB}/skill_suggestion_injector.py" "${SESSION_ID:-}" 2>>"$LOG_FILE") || _sugg_output=""
        if [[ -n "$_sugg_output" ]]; then
            SKILL_SUGGESTIONS="$_sugg_output"
            log "Skill suggestions injected via fallback (${#SKILL_SUGGESTIONS} chars)"
        fi
    fi
fi

# Performance tracking
END_TIME=$(get_time_ms)
ELAPSED_MS=$((END_TIME - START_TIME))

log "Hook execution time: ${ELAPSED_MS}ms"

if [[ $ELAPSED_MS -gt 50 ]]; then
    log "WARNING: Exceeded 50ms target: ${ELAPSED_MS}ms"
fi

# Build output with additionalContext
# NOTE: Pattern injection is async, so patterns won't be available here.
# UserPromptSubmit will handle pattern injection for the first prompt.
# Architecture handshake, ticket context, and skill suggestions are sync,
# so they ARE available immediately.
# Combined format: handshake first, then ticket context, then skill suggestions.
if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
    # Check for emit health warning
    COMBINED_CONTEXT=""
    if [[ -f "${HOOKS_DIR}/logs/emit-health/warning" ]]; then
        _EMIT_WARN=$(cat "${HOOKS_DIR}/logs/emit-health/warning" 2>/dev/null || true)
        if [[ -n "$_EMIT_WARN" ]]; then
            COMBINED_CONTEXT="$_EMIT_WARN"
        fi
    fi

    # --- Env var health check (non-blocking, OMN-6266) ---
    ENV_HEALTH_WARNING=""
    if [[ -f "${HOOKS_LIB}/env_health_check_wrapper.py" ]]; then
        ENV_HEALTH_JSON=$("$PYTHON_CMD" "${HOOKS_LIB}/env_health_check_wrapper.py" 2>>"${LOG_FILE:-/dev/null}") || ENV_HEALTH_JSON='{}'
        if [[ "$JQ_AVAILABLE" -eq 1 ]]; then
            ENV_HEALTH_WARNING=$(echo "$ENV_HEALTH_JSON" | jq -r '.warning // ""' 2>/dev/null) || ENV_HEALTH_WARNING=""
        else
            ENV_HEALTH_WARNING=$(echo "$ENV_HEALTH_JSON" | "$PYTHON_CMD" -c "import sys,json; print(json.load(sys.stdin).get('warning',''))" 2>/dev/null) || ENV_HEALTH_WARNING=""
        fi
        if [[ -n "$ENV_HEALTH_WARNING" ]]; then
            if [[ -n "$COMBINED_CONTEXT" ]]; then
                COMBINED_CONTEXT="${COMBINED_CONTEXT}

${ENV_HEALTH_WARNING}"
            else
                COMBINED_CONTEXT="$ENV_HEALTH_WARNING"
            fi
        fi
    fi

    # Combine handshake, ticket context, and skill suggestions if present
    HAS_HANDSHAKE="false"
    HAS_TICKET="false"
    HAS_SKILL_SUGGESTIONS="false"

    if [[ -n "$HANDSHAKE_CONTEXT" ]]; then
        if [[ -n "$COMBINED_CONTEXT" ]]; then
            COMBINED_CONTEXT="${COMBINED_CONTEXT}

${HANDSHAKE_CONTEXT}"
        else
            COMBINED_CONTEXT="$HANDSHAKE_CONTEXT"
        fi
        HAS_HANDSHAKE="true"
    fi

    if [[ -n "$TICKET_CONTEXT" ]]; then
        if [[ -n "$COMBINED_CONTEXT" ]]; then
            # Add separator between handshake and ticket context
            COMBINED_CONTEXT="${COMBINED_CONTEXT}

---

${TICKET_CONTEXT}"
        else
            COMBINED_CONTEXT="$TICKET_CONTEXT"
        fi
        HAS_TICKET="true"
    fi

    if [[ -n "$SKILL_SUGGESTIONS" ]]; then
        if [[ -n "$COMBINED_CONTEXT" ]]; then
            COMBINED_CONTEXT="${COMBINED_CONTEXT}

---

${SKILL_SUGGESTIONS}"
        else
            COMBINED_CONTEXT="$SKILL_SUGGESTIONS"
        fi
        HAS_SKILL_SUGGESTIONS="true"
    fi

    # ── Agent chat injection (OMN-3972) ────────────────────────────────────────
    # Inject recent agent chat messages into session context so new sessions
    # see what other agents have been saying.
    # Toggle gate: OMNICLAUDE_CHAT_INJECTION (default ON)
    _CHAT_INJECTION="${OMNICLAUDE_CHAT_INJECTION:-1}"
    if [[ "$_CHAT_INJECTION" == "1" ]]; then
        _CHAT_CONTEXT=""
        if command -v "${PYTHON_CMD:-python3}" &>/dev/null; then
            _CHAT_CONTEXT=$("${PYTHON_CMD:-python3}" -c "
import sys
try:
    from omniclaude.nodes.node_agent_chat import HandlerChatReader
    reader = HandlerChatReader()
    block = reader.read_context_block(n=10)
    if block:
        print(block)
except Exception:
    pass
" 2>/dev/null) || _CHAT_CONTEXT=""
        fi
        if [[ -n "$_CHAT_CONTEXT" ]]; then
            if [[ -n "$COMBINED_CONTEXT" ]]; then
                COMBINED_CONTEXT="${COMBINED_CONTEXT}

---

${_CHAT_CONTEXT}"
            else
                COMBINED_CONTEXT="$_CHAT_CONTEXT"
            fi
            log "Agent chat context injected (OMN-3972)"
        fi
    fi

    # ── Handoff injection (OMN-5118) ─────────────────────────────────────────
    # Toggle gate: OMNICLAUDE_SESSION_HANDOFF (default OFF)
    # When enabled, inject the handoff manifest written by /handoff into
    # COMBINED_CONTEXT. Manifest is consumed (deleted) after successful injection.
    if [[ "${OMNICLAUDE_SESSION_HANDOFF:-0}" == "1" ]]; then
        _HANDOFF_DIR="${ONEX_HANDOFF_DIR}"
        if [[ -d "$_HANDOFF_DIR" && -n "$CWD" ]]; then
            # Compute CWD hash + repo slug to find the manifest
            _HANDOFF_CWD_HASH=$(echo -n "$CWD" | shasum -a 256 2>/dev/null | cut -c1-8) || _HANDOFF_CWD_HASH=""
            _HANDOFF_REPO_SLUG=$(basename "$(git -C "$CWD" remote get-url origin 2>/dev/null)" .git 2>/dev/null || basename "$CWD")
            if [[ -n "$_HANDOFF_CWD_HASH" && -n "$_HANDOFF_REPO_SLUG" ]]; then
                _HANDOFF_MANIFEST="${_HANDOFF_DIR}/${_HANDOFF_CWD_HASH}-${_HANDOFF_REPO_SLUG}.json"
                if [[ -f "$_HANDOFF_MANIFEST" ]]; then
                    # Check staleness (>24h = ignore and clean)
                    _HANDOFF_AGE=0
                    _HANDOFF_MTIME=$(stat -f %m "$_HANDOFF_MANIFEST" 2>/dev/null || stat -c %Y "$_HANDOFF_MANIFEST" 2>/dev/null || echo 0)
                    _HANDOFF_NOW=$(date +%s)
                    _HANDOFF_AGE=$(( _HANDOFF_NOW - _HANDOFF_MTIME ))
                    if [[ $_HANDOFF_AGE -gt 86400 ]]; then
                        log "Handoff manifest stale (${_HANDOFF_AGE}s old), cleaning up: $_HANDOFF_MANIFEST"
                        rm -f "$_HANDOFF_MANIFEST"
                    else
                        # Read and inject
                        _HANDOFF_CTX=""
                        _HANDOFF_MSG=$(jq -r '.message // empty' "$_HANDOFF_MANIFEST" 2>/dev/null) || _HANDOFF_MSG=""
                        _HANDOFF_TICKET=$(jq -r '.context.active_ticket // empty' "$_HANDOFF_MANIFEST" 2>/dev/null) || _HANDOFF_TICKET=""
                        _HANDOFF_BRANCH=$(jq -r '.context.branch // empty' "$_HANDOFF_MANIFEST" 2>/dev/null) || _HANDOFF_BRANCH=""
                        _HANDOFF_COMMITS=$(jq -r '.context.recent_commits[]? // empty' "$_HANDOFF_MANIFEST" 2>/dev/null | head -5) || _HANDOFF_COMMITS=""
                        _HANDOFF_FILES=$(jq -r '.context.working_files[]? // empty' "$_HANDOFF_MANIFEST" 2>/dev/null | head -20) || _HANDOFF_FILES=""

                        _HANDOFF_CTX="## Session Handoff Context"
                        [[ -n "$_HANDOFF_MSG" ]] && _HANDOFF_CTX="${_HANDOFF_CTX}
Message: ${_HANDOFF_MSG}"
                        [[ -n "$_HANDOFF_TICKET" ]] && _HANDOFF_CTX="${_HANDOFF_CTX}
Ticket: ${_HANDOFF_TICKET}"
                        [[ -n "$_HANDOFF_BRANCH" ]] && _HANDOFF_CTX="${_HANDOFF_CTX}
Branch: ${_HANDOFF_BRANCH}"
                        if [[ -n "$_HANDOFF_COMMITS" ]]; then
                            _HANDOFF_CTX="${_HANDOFF_CTX}
Recent commits:
${_HANDOFF_COMMITS}"
                        fi
                        if [[ -n "$_HANDOFF_FILES" ]]; then
                            _HANDOFF_CTX="${_HANDOFF_CTX}
Working files:
${_HANDOFF_FILES}"
                        fi

                        if [[ -n "$_HANDOFF_CTX" ]]; then
                            if [[ -n "$COMBINED_CONTEXT" ]]; then
                                COMBINED_CONTEXT="${_HANDOFF_CTX}

---

${COMBINED_CONTEXT}"
                            else
                                COMBINED_CONTEXT="$_HANDOFF_CTX"
                            fi
                            # Consume manifest (one-shot) — delete after successful read
                            rm -f "$_HANDOFF_MANIFEST"
                            log "Handoff context injected and manifest consumed: $_HANDOFF_MANIFEST"
                        fi
                    fi
                fi
            fi
        fi
    fi
    # ── End handoff injection ─────────────────────────────────────────────

    if [[ -n "$COMBINED_CONTEXT" ]]; then
        # Include combined context in additionalContext (sync injection)
        printf '%s' "$INPUT" | jq \
            --arg ctx "$COMBINED_CONTEXT" \
            --argjson has_handshake "$HAS_HANDSHAKE" \
            --argjson has_ticket "$HAS_TICKET" \
            --argjson has_skill_suggestions "$HAS_SKILL_SUGGESTIONS" \
            '.hookSpecificOutput.hookEventName = "SessionStart" |
             .hookSpecificOutput.additionalContext = $ctx |
             .hookSpecificOutput.metadata.injection_mode = "sync" |
             .hookSpecificOutput.metadata.has_handshake_context = $has_handshake |
             .hookSpecificOutput.metadata.has_ticket_context = $has_ticket |
             .hookSpecificOutput.metadata.has_skill_suggestions = $has_skill_suggestions'
    elif [[ "${SESSION_INJECTION_ENABLED:-true}" == "true" ]]; then
        # Async pattern injection was started - set metadata to indicate this
        printf '%s' "$INPUT" | jq \
            '.hookSpecificOutput.hookEventName = "SessionStart" |
             .hookSpecificOutput.metadata.injection_mode = "async"'
    else
        # Injection disabled, just pass through with hookEventName
        printf '%s' "$INPUT" | jq '.hookSpecificOutput.hookEventName = "SessionStart"'
    fi
else
    # No jq available, echo input as-is
    printf '%s' "$INPUT"
fi

# === Plugin freshness check (non-blocking) ===
# Checks if deployed plugin is behind origin/main and auto-refreshes.
# Runs in background to stay within 50ms budget.
_omniclaude_bare="${OMNI_HOME}/omniclaude"  # OMNI_HOME set by environment
_plugin_cache="${CLAUDE_PLUGIN_ROOT:-}"
if [[ -n "${_plugin_cache}" && -d "${_omniclaude_bare}" ]]; then
  (
    # Compare deployed commit (stamped during deploy) vs current bare clone HEAD
    _deployed_commit_file="${_plugin_cache}/.deployed-commit"
    _current_commit=$(git -C "${_omniclaude_bare}" rev-parse HEAD 2>/dev/null || echo "unknown")
    _deployed_commit=""
    [[ -f "${_deployed_commit_file}" ]] && _deployed_commit=$(cat "${_deployed_commit_file}" 2>/dev/null)

    if [[ "${_current_commit}" != "${_deployed_commit}" && "${_current_commit}" != "unknown" ]]; then
      log "Plugin stale: deployed=${_deployed_commit:-none} current=${_current_commit:0:8}. Refreshing..."
      # Extract updated skills from bare clone to plugin cache
      _skills_dir="${_plugin_cache}/skills"
      if [[ -d "${_skills_dir}" ]]; then
        _tmpdir=$(mktemp -d)
        git -C "${_omniclaude_bare}" archive HEAD plugins/onex/skills/ 2>/dev/null | tar -x -C "${_tmpdir}" 2>/dev/null
        if [[ -d "${_tmpdir}/plugins/onex/skills" ]]; then
          cp -r "${_tmpdir}/plugins/onex/skills/"* "${_skills_dir}/" 2>/dev/null
          echo "${_current_commit}" > "${_deployed_commit_file}"
          log "Plugin refreshed to ${_current_commit:0:8}"
        fi
        rm -rf "${_tmpdir}"
      fi
    fi
  ) &
  disown || true
fi
# === End plugin freshness check ===

# === Opportunistic env sync (non-blocking) ===
# Runs sync-omnibase-env.py in background when Infisical is configured.
# Throttle and flock guards in the script prevent duplicate syncs.
if [[ -n "${INFISICAL_ADDR:-}" ]]; then
  _sync_log="${ONEX_LOG_DIR}/env-sync.log"
  mkdir -p "$(dirname "${_sync_log}")" || true
  _sync_script="${OMNIBASE_INFRA_DIR:-}/scripts/sync-omnibase-env.py"
  if [[ -f "${_sync_script}" ]]; then
    (uv run python "${_sync_script}" >> "${_sync_log}" 2>&1) &
    disown || true
  else
    log "Opportunistic env sync skipped: sync script not found at ${_sync_script}"
  fi
fi
# === End env sync ===

exit 0
