#!/usr/bin/env ruby
# encoding: UTF-8
# frozen_string_literal: true
#
# Usage: codex-hook <gate>
#   file-mutation gates: edit-gates | gate-check
#   live-state hooks (intent 199): session-start | check-update | continue |
#     future-intent-check | auto-arm | power-tools | savepoint
#   shell-tool gate (intent 203): bash-gate
#
# The Codex input adapter (intent 102, extended by 199, 203, and collapsed by
# 251). Reads a Codex hook stdin payload once. Three shapes exist and the gate
# name alone selects which path runs (Codex's own hooks.json registration
# guarantees they never cross): the apply_patch path reads session_id at top
# level and the apply_patch command in tool_input.command [guide Part 4],
# parses the diff envelope once via ApplyPatchEnvelope, and runs all five gates
# (savepoint-pre, lock-gate, code-gate, links-gate, create-gate) IN-PROCESS
# through scripts/lib/codex_edit_gates.rb, which drives the same
# scripts/lib/edit_gates.rb functions Claude's merged dispatcher drives
# (mirroring intent 244). This replaced five registered PreToolUse commands and
# three nested run_core children with one process. The live-state hooks carry
# no tool_input at all (they are not tool calls); the dispatcher execs the SAME
# launcher file Claude already runs for that hook (hooks/<name>), which is
# already harness-agnostic (resolves ~/.plastic off $HOME, reads only the
# common stdin fields the guide confirms Codex shares with Claude for these
# three events), so the body is reused, not rewritten (D3). The shell-tool gate
# (bash-gate) reports tool_name: "Bash" with the command in tool_input.command,
# but MUST NOT be routed through ApplyPatchEnvelope.parse below: a plain shell
# command has no *** Begin Patch/*** End Patch envelope, so it would parse to
# an empty op list and hit this file's own `exit 0 if ops.empty?` line,
# silently allowing every Bash call and reopening the exact hole intent 203
# closes. So it execs the SAME scripts/hook-bash-gate file Claude already runs,
# unmodified, the identical "drive the body, relay its output" pattern used for
# the live-state hooks. The one adaptation shared by both the live-state and
# shell-tool paths is threading the payload's session_id into
# CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus
# a bounded timeout: hooks/check-update backgrounds a real npm network call
# without redirecting its output away from the inherited stdout/stderr pipes,
# and Codex invokes hooks synchronously, so without a bound a slow network call
# could hold this dispatcher's pipe open past the launcher's own (fast) exit.
require "json"
require "open3"
require "rbconfig"
require "timeout"

# Sync stdout so a relayed launcher's stdout and stderr land on the merged
# pipe in call order. Ruby buffers STDOUT (but not STDERR) when it is not a
# TTY; without this, `warn err` below can reach the OS pipe before a
# preceding `print out` is flushed, corrupting the relayed JSON for any
# caller that merges the dispatcher's own stdout and stderr.
$stdout.sync = true

STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm power-tools savepoint].freeze
STATE_TIMEOUT = 5
SHELL_HOOKS = %w[bash-gate].freeze

gate = ARGV[0].to_s
raw = ($stdin.read rescue nil)
exit 0 if raw.nil? || raw.strip.empty?
payload = (JSON.parse(raw) rescue nil)
exit 0 unless payload.is_a?(Hash)

session = payload["session_id"].to_s

if STATE_HOOKS.include?(gate)
  launcher = File.expand_path(File.join(__dir__, "..", "hooks", gate))
  cwd = payload["cwd"].to_s
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
  out, err, status = begin
    Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), launcher, stdin_data: raw, chdir: cwd) }
  rescue StandardError
    ["", "", nil] # fail open: launcher missing, unexecutable, crashed, or too slow
  end
  print out unless out.to_s.empty?
  warn err unless err.to_s.empty?
  exit(status ? status.exitstatus : 0)
end

if SHELL_HOOKS.include?(gate)
  # bash-gate (intent 203): a shell command has no apply_patch diff envelope, so
  # this branch execs the SAME scripts/hook-bash-gate file Claude already runs,
  # unmodified, and relays its exit code and stderr. It must never fall through
  # to ApplyPatchEnvelope.parse below (see header comment).
  cwd = payload["cwd"].to_s
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
  script = File.join(__dir__, "hook-#{gate}")
  argv = [script]
  out, err, status = begin
    Timeout.timeout(STATE_TIMEOUT) { Open3.capture3(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, stdin_data: raw, chdir: cwd) }
  rescue StandardError
    ["", "", nil] # fail open: script missing, unexecutable, crashed, or too slow
  end
  print out unless out.to_s.empty?
  warn err unless err.to_s.empty?
  exit(status ? status.exitstatus : 0)
end

require_relative "lib/apply_patch_envelope"

command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized gate name or non-tool-call payload)
ops = ApplyPatchEnvelope.parse(command)
exit 0 if ops.empty? # fail-open: nothing parseable to gate

CORES = __dir__ # ~/.plastic/scripts
def run_core(name, *argv)
  out = IO.popen([{ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
  [out, $?.exitstatus]
end

case gate
when "edit-gates"
  # The ONE registered PreToolUse command on the apply_patch matcher (intent
  # 251). Five gates, in-process, in Claude's evaluation order, first deny wins,
  # each deny shape reproduced exactly: stderr plus exit 2 for code-gate,
  # links-gate and create-gate; stdout permissionDecision JSON plus exit 0 for
  # lock-gate. Fail-open at both levels, per gate inside EditGates.dispatch and
  # again at this file's own top level.
  begin
    require_relative "lib/codex_edit_gates"
    exit CodexEditGates.dispatch(ops: ops, payload: payload)
  rescue StandardError, ScriptError => e
    # Dispatcher-internal failure: never impersonate a deny. ScriptError (a
    # sibling of StandardError, not a subclass) covers LoadError: a future
    # manifest gap (a lib file required here but not distributed, exactly the
    # bug the full suite caught during this intent's own delivery) must fail
    # open with a gate decision, never exit 1 with no decision at all.
    $stderr.puts "plastic codex edit-gates error: #{e.message}"
    exit 0
  end

when "gate-check"
  last_allow = nil
  ops.each do |o|
    out, code = run_core("hook-gate-check", o.path, session)
    if code == 2
      print out # {"decision":"block",...}
      exit 2
    end
    last_allow = out unless out.to_s.strip.empty?
  end
  print last_allow if last_allow
  exit 0

else
  exit 0
end
