#!/usr/bin/env bash
# OpenLogos Guard Check — PreToolUse hook for Claude Code.
# Blocks Edit/Write/MultiEdit/NotebookEdit/Bash/PowerShell tool calls when the project
# is in "launched" lifecycle but no active change proposal (logos/.openlogos-guard) exists.
#
# Input:  JSON on stdin  { "tool_name": "Edit"|"Write"|"MultiEdit"|"NotebookEdit"|"Bash"|"PowerShell", "tool_input": { ... } }
# Output: JSON on stdout { "reason": "..." }  (only when blocking)
# Exit:   0 = allow,  2 = block

set -euo pipefail

# 事后检查引擎（guard-versioned-content-scope）：与本脚本同目录的 guard-post-check.cjs（托管副本
# .claude/openlogos/bin/guard-post-check.cjs；分发源 plugin/bin/guard-post-check.cjs）。须在 Step 0 cd 之前解析。
GUARD_ENGINE="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/guard-post-check.cjs" || GUARD_ENGINE=""

# ── Read stdin ────────────────────────────────────────────────────────────────
INPUT="$(cat)"

# ── Runtime probing（fix-windows-platform-compat，spec/pretooluse-guard.md §跨平台可移植性）──
# Python 候选依次 python3 → python → py -3，以执行 `-c "import sys"` 退出 0 为可用判据——不以
# PATH 可发现性为准（Windows 上 python3 常为可被发现、运行即失败的商店占位别名）。探测结果在一次
# hook 调用内复用：主流程在任何命令替换（子 shell）之前调用一次 detect_python。
GUARD_PY="${GUARD_PY:-}"
detect_python() {
  [ -n "${GUARD_PY:-}" ] && return 0
  GUARD_PY="none"
  local cand
  for cand in "python3" "python" "py -3"; do
    # shellcheck disable=SC2086
    if $cand -c "import sys" </dev/null >/dev/null 2>&1; then
      GUARD_PY="$cand"
      return 0
    fi
  done
  return 0
}

has_python() {
  detect_python
  [ "$GUARD_PY" != "none" ]
}

run_python() {
  # shellcheck disable=SC2086
  $GUARD_PY "$@"
}

# json_field <json> <dotted.key> — 全部字段读取的唯一解析实现。node 为主路径（stdin 以
# readFileSync(0) 读取，禁止读 stdin 设备文件路径——Windows 版 Node 会解析为 <盘>:\dev\stdin）；node 不可用时才用真实可执行的 Python。
# 成功解析输出字段字符串值（缺失或非字符串为空）并返回 0；所有解析路径均失败返回 1。
json_field() {
  local json="$1" key="$2" out
  if out="$(printf '%s' "$json" | node -e "
let d;
try { d = JSON.parse(require('fs').readFileSync(0, 'utf-8')); } catch { process.exit(3); }
let v = d;
for (const k of process.argv[1].split('.')) v = (v && typeof v === 'object') ? v[k] : undefined;
process.stdout.write(typeof v === 'string' ? v : '');
" "$key" 2>/dev/null)"; then
    printf '%s' "$out"
    return 0
  fi
  if has_python && out="$(printf '%s' "$json" | run_python -c "
import sys, json
try:
    d = json.loads(sys.stdin.buffer.read().decode('utf-8'))
except Exception:
    sys.exit(3)
v = d
for k in sys.argv[1].split('.'):
    v = v.get(k) if isinstance(v, dict) else None
sys.stdout.buffer.write((v if isinstance(v, str) else '').encode('utf-8'))
" "$key" 2>/dev/null)"; then
    printf '%s' "$out"
    return 0
  fi
  return 1
}

block() {
  # Emit reason on both channels and exit 2 to block the tool call.
  # stdout keeps the legacy {"reason":...} JSON (old-protocol hosts); Claude Code
  # reads stderr for exit 2, so the actionable guidance must also go there.
  local msg="$1"
  printf '{"reason":"%s"}' "$msg"
  printf '%b\n' "$msg" >&2
  exit 2
}

# ── Step 0: Resolve project root（fix-claude-guard-hook-project-dir-and-sync-deploy）──
# 一切文件判定以项目根为基准：CLAUDE_PROJECT_DIR 在场即收敛；缺失时仅当 cwd 恰为项目根
# 可按 cwd 回退（旧版 Claude Code 兼容窗口）；否则 fail-closed——「不知道项目根在哪」
# 不得落入下方「非 OpenLogos 项目放行」分支。
if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
  if ! cd "$CLAUDE_PROJECT_DIR" 2>/dev/null; then
    block "openlogos guard: 无法进入 CLAUDE_PROJECT_DIR，fail-closed（请检查该环境变量指向的目录）"
  fi
elif [ ! -f "logos/logos.config.json" ]; then
  block "openlogos guard: CLAUDE_PROJECT_DIR 缺失且当前目录不是 OpenLogos 项目根，fail-closed（请升级 Claude Code 或从项目根启动会话）"
fi

# ── Step 1: Check if logos project exists ────────────────────────────────────
YAML_FILE="logos/logos-project.yaml"
CONFIG_FILE="logos/logos.config.json"

if [ ! -f "$CONFIG_FILE" ]; then
  # Not an OpenLogos project — allow everything（前提：项目根已由 Step 0 确认）
  exit 0
fi

# ── Step 2: Determine lifecycle ───────────────────────────────────────────────
# Check if any module has lifecycle: launched
# 纯 bash 解析（不依赖 python / node）：输入不可解析时的 fail-closed 必须在无运行时环境下也能到达。
IS_LAUNCHED=0
if [ -f "$YAML_FILE" ]; then
  _in_modules=0
  _top_re='^[a-zA-Z]'
  _modules_re='^modules[[:space:]]*:'
  _launched_re="lifecycle[[:space:]]*:[[:space:]]*[\"']?launched"
  while IFS= read -r _line || [ -n "$_line" ]; do
    _line="${_line%$'\r'}"
    if [[ "$_line" =~ $_modules_re ]]; then _in_modules=1; continue; fi
    if [ "$_in_modules" -eq 1 ]; then
      [[ "$_line" =~ $_top_re ]] && break
      if [[ "$_line" =~ $_launched_re ]]; then IS_LAUNCHED=1; break; fi
    fi
  done < "$YAML_FILE"
fi

# 全部 initial：放行一切，唯一例外是 guard 自有状态（logos/.openlogos-runtime/）的文件工具写入
# （spec/pretooluse-guard.md §受保护判定 第 2 步，任何 lifecycle 与提案状态下恒阻断）；该检查在下方
# 函数定义之后执行，initial 下输入不可解析仍按既有语义放行。

# ── Step 2.5: Parse hook input（fail-closed）────────────────────────────────────
# stdin 非空但无法解析出 tool_name（JSON 非法、解析器全部不可用、字段缺失）：写入门禁已失明，
# 放行等于关闭门禁——exit 2 双通道，与 ZCode / Qoder / Cursor 适配层同口径。
detect_python
TOOL_NAME=""
if [ "$IS_LAUNCHED" -eq 0 ]; then
  TOOL_NAME="$(json_field "$INPUT" "tool_name" 2>/dev/null || true)"
elif [ -n "$INPUT" ]; then
  if ! TOOL_NAME="$(json_field "$INPUT" "tool_name")" || [ -z "$TOOL_NAME" ]; then
    block "⛔ openlogos guard: 无法解析 hook 输入（tool_name 缺失或 JSON 不可解析），写入门禁无法判定，fail-closed。\\n\\n请检查 node 是否可用（node 是 OpenLogos 运行前提）。"
  fi
fi

# hook_target_path — 文件路径类工具的目标路径字段（NotebookEdit 为 notebook_path）
hook_target_path() {
  case "$TOOL_NAME" in
    NotebookEdit) json_field "$INPUT" "tool_input.notebook_path" || true ;;
    *) json_field "$INPUT" "tool_input.file_path" || true ;;
  esac
}

# ── Whitelist & path normalization（Step 3 plan 收窄与 Step 4 共用）──────────────────

# Whitelist: path prefixes always allowed regardless of guard（受保护判定第 5 步）。
# .gitignore 已移出（guard-versioned-content-scope C14：它是保护范围来源，两种模式下均受保护）；
# 不可豁免项（guard 自有状态、保护范围来源、git 元数据）先于本表判定，位于白名单目录下同样受保护。
WHITELIST_PREFIXES=(
  "logos/changes/"
  "logos/.openlogos-guard"
  "logos/logos-project.yaml"
  ".claude/"
  ".opencode/"
  ".codex-plugin/"
  ".cursor/"
  "logos/skills/"
  "logos/spec/"
  "README"
  "CLAUDE.md"
  "AGENTS.md"
  "opencode.json"
  ".openlogos"
)

# collapse_dot_segments <path> — 按词法消解 `.` / `..` 段（输入须已统一为 `/` 分隔）。
# 保留盘符（C:/）或根（/）前缀；绝对路径的 `..` 不越过根；相对路径越出起点的 `..` 保留在开头。
collapse_dot_segments() {
  local p="$1" prefix="" seg out=""
  local -a parts=() stack=()
  local drive_re='^[A-Za-z]:/'
  if [[ "$p" =~ $drive_re ]]; then
    prefix="${p:0:3}"; p="${p:3}"
  elif [[ "$p" == /* ]]; then
    prefix="/"; p="${p#/}"
  fi
  IFS='/' read -r -a parts <<< "$p"
  for seg in ${parts[@]+"${parts[@]}"}; do
    case "$seg" in
      ''|.) continue ;;
      ..)
        if [ "${#stack[@]}" -gt 0 ] && [ "${stack[${#stack[@]}-1]}" != ".." ]; then
          unset "stack[${#stack[@]}-1]"
          stack=(${stack[@]+"${stack[@]}"})
        elif [ -z "$prefix" ]; then
          stack+=("..")
        fi
        ;;
      *) stack+=("$seg") ;;
    esac
  done
  out="$(IFS=/; printf '%s' "${stack[*]:-}")"
  if [ -z "$prefix" ] && [ -z "$out" ]; then out="."; fi
  printf '%s\n' "${prefix}${out}"
}

# has_dot_segments <raw-path> — 原始输入（`\` 视同 `/`）含 `.` / `..` 段返回 0。
# 仅用于标记「相对路径由词法消解得出」（盘符分支与 bash 兜底）：此时点段只用于判定管辖边界与相对路径，
# 项目内目标不再享受白名单 / 默认豁免 / plan 原型放行（符号链接下词法消解与 OS 解析可能不一致，只可更保守）。
# python / node 分支经 realpath / relative 归一化，维持既有「按归一化结果判定」语义。
has_dot_segments() {
  local raw="${1//\\//}"
  case "/$raw/" in
    */./*|*/../*) return 0 ;;
  esac
  return 1
}

# _rel_of_cwd_tagged <path> — 输出「<词法点段标记 0|1> <相对路径>」。相对路径为目标相对项目根（cwd）的
# posix 相对路径；白名单与 plan 收窄共用同一实现。
# 输出一律把 `\` 统一为 `/`（原生 Windows 下 relpath / path.relative 产出 `\` 分隔）。
# Windows 绝对路径（C:\... / C:/...）按项目根的 win32 形态（Git Bash `pwd -W`）大小写不敏感前缀比较；
# 取不到 win32 形态或不同盘符 → 项目根之外（输出 `../` 前缀，管辖外放行语义），不回退为绝对路径。
# 其余路径依次尝试 python（真实可执行时）→ node → bash 兜底；三条分支同判。
_rel_of_cwd_tagged() {
  local p="$1" rel="" lexical=0
  local win_abs_re='^[A-Za-z]:[/\\]'
  if [[ "$p" =~ $win_abs_re ]]; then
    # 先消解点段再比较：C:/Root/.claude/../src/a.ts 实指 src/a.ts，不得以字面前缀命中白名单或误判管辖外
    local p_w root_w=""
    p_w="$(collapse_dot_segments "${p//\\//}")"
    root_w="$(pwd -W 2>/dev/null || true)"
    if [ -n "$root_w" ]; then
      root_w="$(collapse_dot_segments "${root_w//\\//}")"
      root_w="${root_w%/}"
    fi
    local nocase_on=0
    shopt -q nocasematch && nocase_on=1
    shopt -s nocasematch
    if [ -n "$root_w" ] && [[ "$p_w" == "$root_w" ]]; then
      rel="."
    elif [ -n "$root_w" ] && [[ "$p_w" == "$root_w"/* ]]; then
      rel="${p_w:$((${#root_w} + 1))}"
    else
      rel="../${p_w}"
    fi
    [ "$nocase_on" -eq 1 ] || shopt -u nocasematch
    has_dot_segments "$p" && lexical=1
    printf '%s %s\n' "$lexical" "$rel"
    return 0
  fi

  if declare -F has_python >/dev/null && has_python; then
    rel="$(run_python - "$p" <<'PYEOF' 2>/dev/null
import sys, os
path = sys.argv[1]
cwd = os.getcwd()
real_cwd = os.path.realpath(cwd)
# Resolve the deepest existing ancestor of the (possibly not-yet-created) path,
# so the /tmp -> /private/tmp symlink is normalized even when the leaf dirs
# do not exist yet.
anc = path
tail = []
while anc and not os.path.exists(anc):
    anc, base = os.path.split(anc)
    tail.append(base)
    if not base:
        break
try:
    real_anc = os.path.realpath(anc) if anc else anc
except Exception:
    real_anc = anc
real_path = os.path.join(real_anc, *reversed(tail)) if tail else real_anc
try:
    r = os.path.relpath(real_path, real_cwd)
    if r.startswith('..'):
        r2 = os.path.relpath(path, cwd)
        if not r2.startswith('..'):
            r = r2
except ValueError:
    r = '../' + path
print(r)
PYEOF
)" || rel=""
  fi

  if [ -z "$rel" ]; then
    rel="$(node -e "
const path = require('path');
const fs = require('fs');
const p = process.argv[1];
const cwd = process.cwd();
let realCwd = cwd;
try { realCwd = fs.realpathSync(cwd); } catch {}
let anc = p; const tail = [];
while (anc && !fs.existsSync(anc)) { const parsed = path.parse(anc); if (anc === parsed.root) { break; } tail.push(path.basename(anc)); anc = path.dirname(anc); }
let realAnc = anc;
try { realAnc = fs.realpathSync(anc); } catch {}
const realP = tail.length ? path.join(realAnc, ...tail.reverse()) : realAnc;
let rel = path.relative(realCwd, realP);
if (rel.startsWith('..')) {
  const rel2 = path.relative(cwd, p);
  if (!rel2.startsWith('..')) rel = rel2;
}
if (path.isAbsolute(rel)) rel = '../' + rel;
process.stdout.write(rel);
" "$p" 2>/dev/null)" || rel=""
  fi

  # Fallback: simple bash stripping. Jurisdiction first: an absolute path not
  # under "$(pwd)/" is outside the project root (host permission system owns
  # everything outside the project; the guard only protects this project).
  if [ -z "$rel" ]; then
    has_dot_segments "$p" && lexical=1
    case "$p" in
      /*)
        local cwd_s
        cwd_s="$(pwd)"
        if [[ "$p" == "$cwd_s" ]]; then
          rel="."
        elif [[ "$p" == "$cwd_s/"* ]]; then
          rel="${p#"$cwd_s/"}"
        else
          rel="..${p}"
        fi
        ;;
      *)
        rel="$p"
        ;;
    esac
  fi

  rel="${rel//\\//}"
  # 点段统一消解（python / node 分支输出已无点段，此处主要约束 bash 兜底）：
  # logos/changes/../../src/a.ts 不得以字面前缀命中白名单
  rel="$(collapse_dot_segments "$rel")"
  while [[ "$rel" == ./* ]]; do rel="${rel#./}"; done
  printf '%s %s\n' "$lexical" "$rel"
}

# rel_of_cwd <path> — 只输出相对路径（plan 收窄与白名单共用 _rel_of_cwd_tagged 同一实现）。
rel_of_cwd() {
  local out
  out="$(_rel_of_cwd_tagged "$1")"
  printf '%s\n' "${out#? }"
}


# ── guard.exempt 读取（guard-versioned-content-scope，spec/pretooluse-guard.md §guard.exempt 与内置默认）──
# 取代 R-REF / R-STAGING 硬编码：豁免条目一律来自 logos/logos.config.json 的 guard.exempt。
#   字段缺省（guard 或 guard.exempt 不存在）→ 内置默认两条（与原 R-REF / R-STAGING 命中集合逐项一致）；
#   显式数组（含 []）→ 按原样，不回落内置默认；
#   配置损坏（无法解析、guard / guard.exempt 类型不符）→ 视为 []，stderr 警告；
#   单条非法条目 → 跳过并 stderr 警告，其余照常生效。
# git 判据生效与非 git 回落两种模式同一实现；node → python → 纯 bash 依次尝试，纯 bash 无法解析 JSON，
# 只能区分「文本中不含 guard 键（字段缺省）」与「含 guard 键（按 [] 保守处理）」，只会更保守。
GUARD_CONFIG_REL="logos/logos.config.json"
GUARD_RUNTIME_REL="logos/.openlogos-runtime"
GUARD_BUILTIN_EXEMPT=(
  "logos/resources/reference/"
  "logos/resources/verify/baseline-seed-runs/*/staging/"
)
GUARD_EXEMPT_LOADED=0
GUARD_EXEMPT_ENTRIES=()

guard_warn() {
  printf 'openlogos guard 警告：%s\n' "$1" >&2
}

# exempt_entry_invalid_reason <entry> — 条目合法时无输出并返回 1；非法时输出原因并返回 0。
exempt_entry_invalid_reason() {
  local e="$1" body seg
  local -a segs=()
  case "$e" in
    ''|'/'|'*') printf '空串、/ 或单独 *'; return 0 ;;
  esac
  case "$e" in
    *\\*) printf '含反斜杠'; return 0 ;;
    /*) printf '绝对路径'; return 0 ;;
  esac
  local drive_re='^[A-Za-z]:'
  if [[ "$e" =~ $drive_re ]]; then printf '绝对路径'; return 0; fi
  body="${e%/}"
  [ -n "$body" ] || { printf '空路径'; return 0; }
  IFS='/' read -r -a segs <<< "$body"
  [ "${#segs[@]}" -gt 0 ] || { printf '空路径'; return 0; }
  case "/$body/" in
    *//*) printf '含空段'; return 0 ;;
  esac
  for seg in "${segs[@]}"; do
    case "$seg" in
      ''|.|..) printf '含 . / .. / 空段'; return 0 ;;
      '*') ;;
      *'*'*) printf '* 须独占一个路径段'; return 0 ;;
      .gitignore) printf '忽略规则来源（.gitignore）不可豁免'; return 0 ;;
    esac
  done
  if [ "$body" = '*' ]; then printf '单独 *'; return 0; fi
  if [ "${segs[0]}" = ".git" ]; then printf 'git 元数据（.git）不可豁免'; return 0; fi
  if [[ "$body" == "$GUARD_RUNTIME_REL" || "$body" == "$GUARD_RUNTIME_REL"/* ]]; then
    printf 'guard 自有状态（%s/）不可豁免' "$GUARD_RUNTIME_REL"; return 0
  fi
  if [ "$body" = "$GUARD_CONFIG_REL" ]; then printf '%s 不可豁免' "$GUARD_CONFIG_REL"; return 0; fi
  return 1
}

# load_guard_exempt — 读取 guard.exempt 到 GUARD_EXEMPT_ENTRIES（每次进程只读一次）。
load_guard_exempt() {
  [ "$GUARD_EXEMPT_LOADED" = "1" ] && return 0
  GUARD_EXEMPT_LOADED=1
  GUARD_EXEMPT_ENTRIES=()
  local cfg="$GUARD_CONFIG_REL" out="" status="" line kind val why
  if [ ! -f "$cfg" ]; then
    GUARD_EXEMPT_ENTRIES=(${GUARD_BUILTIN_EXEMPT[@]+"${GUARD_BUILTIN_EXEMPT[@]}"})
    return 0
  fi
  # 输出协议：首行 default | explicit | corrupt:<原因>；其后每行 E<TAB><条目> 或 X<TAB><非字符串条目描述>
  if command -v node >/dev/null 2>&1; then
    out="$(node -e '
const fs = require("fs");
let d;
try { d = JSON.parse(fs.readFileSync(process.argv[1], "utf-8")); } catch { process.stdout.write("corrupt:无法解析\n"); process.exit(0); }
if (!d || typeof d !== "object" || Array.isArray(d)) { process.stdout.write("corrupt:顶层不是对象\n"); process.exit(0); }
if (!Object.prototype.hasOwnProperty.call(d, "guard")) { process.stdout.write("default\n"); process.exit(0); }
const g = d.guard;
if (!g || typeof g !== "object" || Array.isArray(g)) { process.stdout.write("corrupt:guard 类型不符\n"); process.exit(0); }
if (!Object.prototype.hasOwnProperty.call(g, "exempt")) { process.stdout.write("default\n"); process.exit(0); }
const e = g.exempt;
if (!Array.isArray(e)) { process.stdout.write("corrupt:guard.exempt 类型不符\n"); process.exit(0); }
let o = "explicit\n";
for (const it of e) {
  if (typeof it === "string" && !/[\n\r\t\0]/.test(it)) o += "E\t" + it + "\n";
  else o += "X\t" + String(JSON.stringify(it)).replace(/[\n\r\t]/g, " ") + "\n";
}
process.stdout.write(o);
' "$cfg" 2>/dev/null)" || out=""
  fi
  if [ -z "$out" ] && declare -F has_python >/dev/null && has_python; then
    out="$(run_python -c '
import sys, json
try:
    d = json.loads(open(sys.argv[1], "rb").read().decode("utf-8"))
except Exception:
    sys.stdout.write("corrupt:无法解析\n"); sys.exit(0)
if not isinstance(d, dict):
    sys.stdout.write("corrupt:顶层不是对象\n"); sys.exit(0)
if "guard" not in d:
    sys.stdout.write("default\n"); sys.exit(0)
g = d["guard"]
if not isinstance(g, dict):
    sys.stdout.write("corrupt:guard 类型不符\n"); sys.exit(0)
if "exempt" not in g:
    sys.stdout.write("default\n"); sys.exit(0)
e = g["exempt"]
if not isinstance(e, list):
    sys.stdout.write("corrupt:guard.exempt 类型不符\n"); sys.exit(0)
o = "explicit\n"
for it in e:
    if isinstance(it, str) and not any(c in it for c in "\n\r\t\0"):
        o += "E\t" + it + "\n"
    else:
        o += "X\t" + json.dumps(it, ensure_ascii=False).replace("\n", " ") + "\n"
sys.stdout.buffer.write(o.encode("utf-8"))
' "$cfg" 2>/dev/null)" || out=""
  fi
  if [ -z "$out" ]; then
    # 纯 bash：不含 guard 键视为字段缺省；含 guard 键而无法解析 → 按 [] 保守处理
    local content=""
    content="$(<"$cfg")" 2>/dev/null || content=""
    if [[ "$content" == *'"guard"'* ]]; then
      out="corrupt:node / python 均不可用，无法解析 guard 配置"
    else
      out="default"
    fi
  fi
  status="${out%%$'\n'*}"
  case "$status" in
    default)
      GUARD_EXEMPT_ENTRIES=(${GUARD_BUILTIN_EXEMPT[@]+"${GUARD_BUILTIN_EXEMPT[@]}"})
      return 0
      ;;
    corrupt:*)
      guard_warn "$GUARD_CONFIG_REL 配置异常（${status#corrupt:}），guard.exempt 视为 []，不豁免任何路径"
      return 0
      ;;
  esac
  local rest=""
  [[ "$out" == *$'\n'* ]] && rest="${out#*$'\n'}"
  while IFS= read -r line; do
    [ -n "$line" ] || continue
    kind="${line%%$'\t'*}"
    val="${line#*$'\t'}"
    if [ "$kind" = "E" ]; then
      if why="$(exempt_entry_invalid_reason "$val")"; then
        guard_warn "guard.exempt 非法条目已跳过：${val}（${why}）"
        continue
      fi
      GUARD_EXEMPT_ENTRIES+=("$val")
    elif [ "$kind" = "X" ]; then
      guard_warn "guard.exempt 非法条目已跳过：${val}（不是字符串）"
    fi
  done <<< "$rest"
  return 0
}

# exempt_entry_matches <entry> <rel> — 完整段匹配；以 / 结尾匹配目录本身及后代，否则只匹配单个文件；
# `*` 恰好匹配一个满足 run_id 标识符约束（且不含 ..）的路径段。
exempt_entry_matches() {
  local e="$1" rel="$2" is_dir=0 body i
  local -a es=() rs=()
  [[ "$e" == */ ]] && is_dir=1
  body="${e%/}"
  IFS='/' read -r -a es <<< "$body"
  IFS='/' read -r -a rs <<< "$rel"
  if [ "$is_dir" -eq 1 ]; then
    [ "${#rs[@]}" -ge "${#es[@]}" ] || return 1
  else
    [ "${#rs[@]}" -eq "${#es[@]}" ] || return 1
  fi
  local id_re='^[A-Za-z0-9][A-Za-z0-9._-]*$'
  for ((i = 0; i < ${#es[@]}; i++)); do
    if [ "${es[$i]}" = '*' ]; then
      [[ "${rs[$i]}" =~ $id_re ]] || return 1
      [[ "${rs[$i]}" == *..* ]] && return 1
    elif [ "${es[$i]}" != "${rs[$i]}" ]; then
      return 1
    fi
  done
  return 0
}

# whitelist_prefix_hit <rel> — 命中硬编码白名单前缀（已去掉 .gitignore）返回 0
whitelist_prefix_hit() {
  local rel="$1" prefix
  for prefix in "${WHITELIST_PREFIXES[@]}"; do
    if [[ "$rel" == "$prefix"* ]]; then
      return 0
    fi
  done
  return 1
}

# Built-in default exemptions → guard.exempt (guard-versioned-content-scope):
#   原 R-REF / R-STAGING 不再硬编码，改由 guard.exempt 表达（字段缺省时为内置默认两条，
#   logos/resources/reference/ 与 logos/resources/verify/baseline-seed-runs/*/staging/）。
# Full-segment matching only; any ".", ".." or empty segment never matches (the
# bash fallback does not normalize, so it may only be more conservative).
# Symlinks never match either: the normalizers only resolve the deepest
# *existing* ancestor, so a dangling link (e.g. reference/new.ts -> src/new.ts)
# would keep its exempt-looking path while writes follow it into protected
# files. Any path component (relative to the project root) that is a symlink —
# dangling or not — makes the exemption decline (fall back to the old rules).
is_default_exempt_path() {
  local rel="$1"
  case "/$rel/" in
    *//*|*/./*|*/../*) return 1 ;;
  esac
  local walk="" seg
  local -a segs=()
  IFS='/' read -r -a segs <<< "$rel"
  for seg in "${segs[@]}"; do
    walk="${walk:+$walk/}$seg"
    if [ -L "$walk" ]; then
      return 1
    fi
  done
  load_guard_exempt
  local entry
  for entry in ${GUARD_EXEMPT_ENTRIES[@]+"${GUARD_EXEMPT_ENTRIES[@]}"}; do
    if exempt_entry_matches "$entry" "$rel"; then
      return 0
    fi
  done
  return 1
}

is_whitelisted_path() {
  local path="$1"

  # 归一化（含 macOS /var → /private/var 符号链接、`\` → `/`、盘符与跨盘）统一经 rel_of_cwd。
  local rel_path="" tagged
  tagged="$(_rel_of_cwd_tagged "$path")"
  rel_path="${tagged#? }"

  # Jurisdiction boundary (fix-guard-check-external-path-and-stderr): a
  # normalized rel_path of ".." or "../*" targets outside the project root —
  # allow before any whitelist prefix matching.
  if [[ "$rel_path" == ".." || "$rel_path" == ../* ]]; then
    return 0
  fi
  # 词法消解得出的项目内点段目标（如 C:/Root/.claude/../src/a.ts；bash 兜底下的 logos/changes/../../src/a.ts）：
  # 不以任何前缀放行
  if [ "${tagged:0:1}" = "1" ]; then
    return 1
  fi

  if whitelist_prefix_hit "$rel_path"; then
    return 0
  fi
  if is_default_exempt_path "$rel_path"; then
    return 0
  fi
  return 1
}

# ── 受保护判定（guard-versioned-content-scope，spec/pretooluse-guard.md §受保护判定（is_protected））──
# GUARD_MODE：git（git 判据生效）| fallback（非 git 回落）。判据：项目根位于 git 工作树内、git 可执行、
# node 可执行、事后检查引擎（GUARD_ENGINE）存在（launched 由主流程保证）。
GUARD_ENGINE="${GUARD_ENGINE:-}"
GUARD_MODE=""
GUARD_FALLBACK_WHY=""
GUARD_GIT_DIRS_REL=()
GUARD_EXCLUDES_REL=""
PROTECT_STEP=""
PROTECT_REASON=""
PROTECT_REL=""

# _rel_inside_root <abs-path> — 位于项目根之内时输出相对路径并返回 0
_rel_inside_root() {
  local r
  r="$(rel_of_cwd "$1")"
  [ -n "$r" ] || return 1
  [[ "$r" == ".." || "$r" == ../* ]] && return 1
  printf '%s\n' "$r"
}

detect_guard_mode() {
  [ -n "${GUARD_MODE:-}" ] && return 0
  GUARD_MODE="fallback"
  GUARD_GIT_DIRS_REL=()
  GUARD_EXCLUDES_REL=""
  if ! command -v git >/dev/null 2>&1; then GUARD_FALLBACK_WHY="git 不可执行"; return 0; fi
  if ! command -v node >/dev/null 2>&1 || ! node -e "" >/dev/null 2>&1; then
    GUARD_FALLBACK_WHY="node 不可执行"; return 0
  fi
  if [ "$(git -C . rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]; then
    GUARD_FALLBACK_WHY="项目根不在 git 工作树内"; return 0
  fi
  if [ -z "${GUARD_ENGINE:-}" ] || [ ! -f "$GUARD_ENGINE" ]; then
    GUARD_FALLBACK_WHY="事后检查引擎缺失"; return 0
  fi
  local d r ex top
  for d in "$(git -C . rev-parse --absolute-git-dir 2>/dev/null || true)" \
           "$(git -C . rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"; do
    [ -n "$d" ] || continue
    if r="$(_rel_inside_root "$d")" && [ "$r" != "." ]; then GUARD_GIT_DIRS_REL+=("$r"); fi
  done
  # git 版本过旧取不到绝对 git 目录时，按字面 .git 保守识别
  [ "${#GUARD_GIT_DIRS_REL[@]}" -gt 0 ] || GUARD_GIT_DIRS_REL=(".git")
  ex="$(git -C . config --path --get core.excludesFile 2>/dev/null || true)"
  if [ -n "$ex" ]; then
    if [[ "$ex" != /* ]]; then
      top="$(git -C . rev-parse --show-toplevel 2>/dev/null || pwd)"
      ex="$top/$ex"
    fi
    if r="$(_rel_inside_root "$ex")"; then GUARD_EXCLUDES_REL="$r"; fi
  fi
  GUARD_MODE="git"
  return 0
}

# _has_symlink_component <rel> — 项目根相对路径的任一已存在分量为符号链接返回 0
_has_symlink_component() {
  local walk="" seg
  local -a segs=()
  IFS='/' read -r -a segs <<< "$1"
  for seg in "${segs[@]}"; do
    walk="${walk:+$walk/}$seg"
    [ -L "$walk" ] && return 0
  done
  return 1
}

_protect() { PROTECT_STEP="$1"; PROTECT_REASON="$2"; return 0; }
_unprotect() { PROTECT_STEP="$1"; PROTECT_REASON="$2"; return 0; }

# is_protected <path> — 返回 0 = 受保护（launched 无提案时写入须立案），1 = 不保护；
# 置 PROTECT_STEP（命中序号 1–10，非 git 回落未命中放行规则时为 fallback）与 PROTECT_REASON。先命中先返回。
is_protected() {
  local path="$1" tagged rel lexical seg rc base
  local -a segs=()
  detect_guard_mode
  tagged="$(_rel_of_cwd_tagged "$path")"
  rel="${tagged#? }"
  lexical="${tagged:0:1}"
  PROTECT_REL="$rel"
  # 1 管辖边界
  if [[ "$rel" == ".." || "$rel" == ../* ]]; then _unprotect 1 "项目根之外"; return 1; fi
  # 2 guard 自有状态
  if [[ "$rel" == "$GUARD_RUNTIME_REL" || "$rel" == "$GUARD_RUNTIME_REL"/* ]]; then
    _protect 2 "guard 自有状态（$GUARD_RUNTIME_REL/）"; return 0
  fi
  # 3 保护范围来源
  base="${rel##*/}"
  if [ "$base" = ".gitignore" ]; then _protect 3 "忽略规则来源（.gitignore）"; return 0; fi
  if [ "$rel" = "$GUARD_CONFIG_REL" ]; then _protect 3 "保护范围来源（${GUARD_CONFIG_REL}）"; return 0; fi
  IFS='/' read -r -a segs <<< "$rel"
  if [ "$GUARD_MODE" = "git" ]; then
    local gd
    for gd in "${GUARD_GIT_DIRS_REL[@]}"; do
      if [ "$rel" = "$gd/info/exclude" ]; then _protect 3 "忽略规则来源（info/exclude）"; return 0; fi
    done
    if [ -n "$GUARD_EXCLUDES_REL" ] && [ "$rel" = "$GUARD_EXCLUDES_REL" ]; then
      _protect 3 "忽略规则来源（core.excludesFile）"; return 0
    fi
    # 4 git 元数据
    for gd in "${GUARD_GIT_DIRS_REL[@]}"; do
      if [[ "$rel" == "$gd" || "$rel" == "$gd"/* ]]; then _protect 4 "git 元数据"; return 0; fi
    done
  else
    # 非 git 回落：不读 core.excludesFile；git 元数据按字面 .git 路径段识别（均不调用 git）
    if [[ "$rel" == ".git/info/exclude" || "$rel" == */.git/info/exclude ]]; then
      _protect 3 "忽略规则来源（info/exclude）"; return 0
    fi
    for seg in "${segs[@]}"; do
      if [ "$seg" = ".git" ]; then _protect 4 "git 元数据"; return 0; fi
    done
  fi
  # 5 硬编码白名单 / 6 guard.exempt：两种模式都沿用既有 is_whitelisted_path（同一归一化、点段与符号链接约束；
  # 词法点段得出的目标不享受放行，只可更保守）
  if is_whitelisted_path "$path"; then
    if [ "$lexical" != "1" ] && whitelist_prefix_hit "$rel"; then _unprotect 5 "白名单"; return 1; fi
    _unprotect 6 "guard.exempt"; return 1
  fi
  if [ "$GUARD_MODE" != "git" ]; then
    _protect fallback "非 git 回落：不在白名单与 guard.exempt 内"; return 0
  fi
  # 7 规格目录
  if [[ "$rel" == "logos/resources" || "$rel" == logos/resources/* ]]; then
    _protect 7 "规格目录（logos/resources/）"; return 0
  fi
  # 8 已跟踪（目录下存在任一已跟踪文件同样成立）；查询失败按受保护处理
  rc=0
  git -c core.quotePath=false --literal-pathspecs ls-files --error-unmatch -- "$rel" >/dev/null 2>&1 || rc=$?
  if [ "$rc" -eq 0 ]; then _protect 8 "已跟踪"; return 0; fi
  if [ "$rc" -ne 1 ]; then _protect 8 "git 查询失败（ls-files），按受保护处理"; return 0; fi
  # 9 被忽略且未跟踪（符号链接分量与点段目标不享受忽略放行）
  if [ "$lexical" != "1" ] && ! _has_symlink_component "$rel"; then
    rc=0
    git -c core.quotePath=false check-ignore -q -- "$rel" >/dev/null 2>&1 || rc=$?
    if [ "$rc" -eq 0 ]; then _unprotect 9 "被 git 忽略且未跟踪"; return 1; fi
    if [ "$rc" -ne 1 ]; then _protect 9 "git 查询失败（check-ignore），按受保护处理"; return 0; fi
  fi
  # 10 其余
  _protect 10 "未被忽略的新文件"
  return 0
}

# path_write_allowed <raw-path> — Bash / PowerShell 已确定写入目标的逐路径判定（两种模式共用 is_protected）
path_write_allowed() {
  ! is_protected "$1"
}

# protect_block_detail — 阻断 reason 中的命中原因与提示
protect_block_detail() {
  local d="命中原因：${PROTECT_REASON}"
  if [ "$PROTECT_STEP" = "10" ]; then
    local first="${PROTECT_REL%%/*}"
    case "$first" in
      node_modules|dist|build|out|target|coverage|.next|.nuxt|vendor|.venv|venv|__pycache__|.cache|tmp)
        d="${d}\\n\\n如该目录不应入库，可执行 \`openlogos ignore add <pattern>\`；该命令会改变保护范围，须经宿主原生审批由用户批准。"
        ;;
    esac
  fi
  d="${d}$(fallback_engine_hint)"
  printf '%s' "$d"
}

# fallback_engine_hint — 引擎缺失或 node 不可用导致非 git 回落时，阻断 reason 附带补齐提示（其余回落原因无输出）
fallback_engine_hint() {
  if [ "$GUARD_MODE" = "fallback" ]; then
    case "$GUARD_FALLBACK_WHY" in
      "node 不可执行"|"事后检查引擎缺失")
        printf '%s' "\\n\\n（当前为非 git 回落：${GUARD_FALLBACK_WHY}；运行 \`openlogos sync\` 补齐事后检查引擎）"
        ;;
    esac
  fi
  return 0
}


# Whitelist: Bash command patterns that are always safe (read-only or openlogos CLI)
BASH_SAFE_PATTERNS=(
  "^openlogos "
  "^openlogos$"
  "^git (status|log|diff|show|branch|fetch|pull|stash|tag|describe|rev-parse|ls-files|shortlog|blame|remote)"
  "^git (add|commit|push|tag )"
  "^git commit"
  "^git add"
  "^git push"
  "^git tag v"
  "^npm (test|run test|run build|run dev|run generate|run check|run lint|run typecheck|run start|pack)"
  "^npx (vitest|jest|mocha|ts-node|tsc)"
  "^vitest"
  "^node --test"
  "^ls |^ls$"
  "^cat "
  "^find "
  "^grep "
  "^head "
  "^tail "
  "^wc "
  "^echo "
  "^printf "
  "^pwd$"
  "^cd "
  "^which "
  "^type "
  "^env$"
  "^printenv"
  "^python3 -c"
  "^node -e"
  "^node -p"
  "^curl "
  "^wget "
  "^jq "
  "^sort "
  "^uniq "
  "^awk "
  "^sed [^-]"
  "^tr "
  "^cut "
  "^diff "
  "^test "
  "^\\[ "
  "^true$"
  "^false$"
  "^sleep "
  "^date$"
  "^date "
  "^uname"
  "^sw_vers"
  "^nvm "
  "^node --version"
  "^npm --version"
  "^openlogos --version"
  "^gh "
)

# Bash write patterns that require a guard
BASH_WRITE_PATTERNS=(
  "sed -i"
  " > "
  " >> "
  "\\| tee "
  "^tee "
  "^mv "
  "^cp "
  "^rm "
  "^mkdir"
  "^touch "
  "^chmod "
  "^chown "
  "^npm install"
  "^npm uninstall"
  "^npm ci"
  "^git push"
  "^git reset"
  "^git checkout "
  "^git restore"
  "^git rm"
  "^git stash pop"
  "^git stash drop"
  "^git merge"
  "^git rebase"
  "^git cherry-pick"
  "writeFileSync"
  "mkdirSync"
)

# ── Windows shell 输入（PowerShell 工具）判定（spec/pretooluse-guard.md §PowerShell / cmd 写入检测模式、
#    §Windows shell 输入的判定顺序）。只作用于 tool_name=PowerShell；Bash 工具维持「安全白名单先判」。
PS_WRITE_WORD_RE='^(set-content|add-content|out-file|new-item|remove-item|copy-item|move-item|rename-item|sc|ac|ni|ri|rm|del|erase|rd|rmdir|cp|copy|mv|move|ren)$'
PS_CONTENT_WRITER_RE='^(set-content|add-content|out-file|sc|ac)$'
PS_DOTNET_WRITE_RE='\[(system\.)?io\.file\]::write'
PS_PATH_FLAG_RE='^-(path|filepath|literalpath|destination|newname)$'
PS_VALUE_FLAG_RE='^-(itemtype|value|encoding|inputobject|type|name)$'
PS_BASH_PATH_CMD_RE='^(rm|cp|mv|mkdir|touch|chmod|chown)$'
PS_COPY_WORD_RE='^(copy-item|cp|copy)$'

ci_match() {
  # ci_match <text> <ERE> — 大小写不敏感匹配
  printf '%s\n' "$1" | grep -qiE -- "$2" 2>/dev/null
}

# ws_segment_is_write <segment> — 段首命令词命中 PowerShell / cmd 写入模式、.NET 写文件，或既有 BASH_WRITE_PATTERNS
ws_segment_is_write() {
  local seg="$1" word pattern
  word="${seg%%[[:space:]]*}"
  ci_match "$word" "$PS_WRITE_WORD_RE" && return 0
  ci_match "$seg" "$PS_DOTNET_WRITE_RE" && return 0
  for pattern in "${BASH_WRITE_PATTERNS[@]}"; do
    if printf '%s\n' "$seg" | grep -qE -- "$pattern" 2>/dev/null; then
      return 0
    fi
  done
  return 1
}

# ws_segment_targets <segment> — 输出写入段的目标路径（每行一个）；无法结构化提取时返回 1
ws_segment_targets() {
  local seg="$1" word tok next_is_path=0 next_is_value=0 named=0 positional=""
  local -a toks=()
  ci_match "$seg" "$PS_DOTNET_WRITE_RE" && return 1
  set -f
  # shellcheck disable=SC2206
  toks=($seg)
  set +f
  word="${toks[0]:-}"
  if ci_match "$word" "$PS_WRITE_WORD_RE"; then
    local i
    for ((i = 1; i < ${#toks[@]}; i++)); do
      tok="${toks[$i]}"
      if [ "$next_is_path" -eq 1 ]; then printf '%s\n' "$tok"; named=1; next_is_path=0; continue; fi
      if [ "$next_is_value" -eq 1 ]; then next_is_value=0; continue; fi
      if ci_match "$tok" "$PS_PATH_FLAG_RE"; then next_is_path=1; continue; fi
      if ci_match "$tok" "$PS_VALUE_FLAG_RE"; then next_is_value=1; continue; fi
      case "$tok" in -*) continue ;; esac
      if ci_match "$word" "$PS_CONTENT_WRITER_RE"; then
        # 写内容类：路径为命名参数或首个位置参数，其余位置参数是写入值
        [ -z "$positional" ] && positional="$tok"
      else
        printf '%s\n' "$tok"
        named=1
      fi
    done
    if ci_match "$word" "$PS_CONTENT_WRITER_RE" && [ "$named" -eq 0 ]; then
      [ -n "$positional" ] || return 1
      printf '%s\n' "$positional"
    fi
    return 0
  fi
  if ci_match "$word" "$PS_BASH_PATH_CMD_RE"; then
    local i
    for ((i = 1; i < ${#toks[@]}; i++)); do
      case "${toks[$i]}" in -*) continue ;; esac
      printf '%s\n' "${toks[$i]}"
    done
    return 0
  fi
  return 1
}

# ws_target_allowed <raw-target> — 目标可解析且在白名单 / 项目根之外返回 0
ws_target_allowed() {
  local t="$1"
  t="${t#\"}"; t="${t%\"}"; t="${t#\'}"; t="${t%\'}"
  [ -n "$t" ] || return 1
  # 逗号为 PowerShell 数组分隔（Set-Content a.md,src/a.ts 写两个目标）：未实现有界数组解析前按不可解析阻断，
  # 不得把整串交给路径判定而让首个白名单目标掩盖后续受保护目标
  case "$t" in
    *'$'*|*'('*|*')'*|*'`'*|*'*'*|*'?'*|*'@'*|*'{'*|*','*) return 1 ;;
  esac
  path_write_allowed "$t"
}

windows_shell_block() {
  block "⛔ 变更管理拦截：项目处于 launched 生命周期，但没有活跃的变更提案。\\n\\n检测到写入操作：$(printf '%s' "$1" | head -c 120)$(fallback_engine_hint)\\n\\n请先运行 \`openlogos change <slug>\` 创建变更提案后再执行此命令。"
}

# ws_scan <command> — 写入信号扫描（两种模式共用）：置 WS_SIGNAL、WS_REDIRECTS（重定向目标）、
# WS_SEGMENTS（全部段）、WS_WRITE_SEGMENTS（命中写入模式的段）
WS_SIGNAL=0
WS_REDIRECTS=()
WS_SEGMENTS=()
WS_WRITE_SEGMENTS=()
ws_scan() {
  local cmd="$1" stripped merged rest target
  WS_SIGNAL=0; WS_REDIRECTS=(); WS_SEGMENTS=(); WS_WRITE_SEGMENTS=()
  # ① 剥离单 / 双引号字面量后扫描写入信号；流合并（2>&1）先移除
  stripped="$(printf '%s' "$cmd" | sed -E "s/'[^']*'/''/g; s/\"[^\"]*\"/\"\"/g")"
  merged="$(printf '%s' "$stripped" | sed -E 's/[0-9*]?>&[0-9]//g')"
  local redirect_re='[0-9*]?>>?[[:space:]]*([^[:space:];|&<>]*)'
  rest="$merged"
  while [[ "$rest" =~ $redirect_re ]]; do
    target="${BASH_REMATCH[1]}"
    rest="${rest#*"${BASH_REMATCH[0]}"}"
    if ci_match "$target" '^(\$null|nul)$'; then
      continue
    fi
    WS_REDIRECTS+=("$target")
    WS_SIGNAL=1
  done
  local split="$merged"
  split="${split//&&/$'\n'}"
  split="${split//||/$'\n'}"
  split="${split//;/$'\n'}"
  split="${split//|/$'\n'}"
  split="${split//&/$'\n'}"
  local seg
  while IFS= read -r seg; do
    seg="${seg#"${seg%%[![:space:]]*}"}"
    # 段内重定向由上方单独处理，段首命令判定只看重定向之前的部分
    seg="${seg%%>*}"
    seg="${seg%"${seg##*[![:space:]]}"}"
    [ -n "$seg" ] || continue
    WS_SEGMENTS+=("$seg")
    if ws_segment_is_write "$seg"; then WS_WRITE_SEGMENTS+=("$seg"); fi
  done <<< "$split"
  if [ "${#WS_SEGMENTS[@]}" -gt 1 ] && [ "${#WS_WRITE_SEGMENTS[@]}" -gt 0 ]; then
    WS_SIGNAL=1
  fi
  return 0
}

windows_shell_verdict() {
  local cmd="$1" target pattern seg
  ws_scan "$cmd"

  # ③ 无写入信号：按既有顺序先判安全白名单，再判写入模式
  if [ "$WS_SIGNAL" -eq 0 ]; then
    for pattern in "${BASH_SAFE_PATTERNS[@]}"; do
      if printf '%s\n' "$cmd" | grep -qE -- "$pattern" 2>/dev/null; then
        exit 0
      fi
    done
    [ "${#WS_WRITE_SEGMENTS[@]}" -gt 0 ] || exit 0
  fi

  # ② 有写入信号（或无信号但命中写入模式）：安全白名单不适用，逐目标判定
  local any=0
  for target in ${WS_REDIRECTS[@]+"${WS_REDIRECTS[@]}"}; do
    any=1
    ws_target_allowed "$target" || windows_shell_block "$cmd"
  done
  for seg in ${WS_WRITE_SEGMENTS[@]+"${WS_WRITE_SEGMENTS[@]}"}; do
    local targets
    if ! targets="$(ws_segment_targets "$seg")" || [ -z "$targets" ]; then
      windows_shell_block "$cmd"
    fi
    while IFS= read -r target; do
      any=1
      ws_target_allowed "$target" || windows_shell_block "$cmd"
    done <<< "$targets"
  done
  [ "$any" -eq 1 ] || windows_shell_block "$cmd"
  exit 0
}

# ps_git_targets <command> — git 判据生效时 PowerShell 的事前目标判定：可提取且受保护 → exit 2；
# 目标经变量 / 子表达式 / 通配 / 数组给出或无法结构化提取 → 不阻断，交给事后检查
ps_git_targets() {
  local cmd="$1" target seg targets
  ws_scan "$cmd"
  for target in ${WS_REDIRECTS[@]+"${WS_REDIRECTS[@]}"}; do
    ps_git_target_check "$target" "$cmd"
  done
  for seg in ${WS_WRITE_SEGMENTS[@]+"${WS_WRITE_SEGMENTS[@]}"}; do
    if ci_match "${seg%%[[:space:]]*}" "$PS_COPY_WORD_RE"; then
      targets="$(ws_copy_git_targets "$seg")"
    else
      targets="$(ws_segment_targets "$seg")" || continue
    fi
    [ -n "$targets" ] || continue
    while IFS= read -r target; do
      ps_git_target_check "$target" "$cmd"
    done <<< "$targets"
  done
  return 0
}

# ws_copy_git_targets <segment> — git 判据下 PowerShell 复制段（Copy-Item / cp / copy）的写入目标（与引擎 cpTargets、
# Cursor runtime wsCopyGitTargets 同口径）：源只被读取，不算写入目标；目标取 -Destination，或位置实参中的目标
# （未以 -Path / -LiteralPath 命名源时为第二个位置实参，否则为第一个）。目标是目录（以 / 或 \ 结尾，或判定时已存在
# 的目录）时写入 目标/<源末段>，否则目标本身即写入路径。目标或源无法确定时不输出，交给事后检查。
# 非 git 回落仍按 ws_segment_targets 的全量实参口径。
ws_copy_git_targets() {
  local seg="$1" tok next="" dest="" named_src=0 i src leaf d
  local -a toks=() pos=() srcs=()
  set -f
  # shellcheck disable=SC2206
  toks=($seg)
  set +f
  for ((i = 1; i < ${#toks[@]}; i++)); do
    tok="${toks[$i]}"
    case "$next" in
      dest) dest="$tok"; next=""; continue ;;
      src) srcs+=("$tok"); next=""; continue ;;
      value) next=""; continue ;;
    esac
    if ci_match "$tok" '^-destination$'; then next=dest; continue; fi
    if ci_match "$tok" '^-(path|literalpath)$'; then next=src; named_src=1; continue; fi
    if ci_match "$tok" '^-(filter|include|exclude|credential|fromsession|tosession)$'; then next=value; continue; fi
    case "$tok" in -*) continue ;; esac
    pos+=("$tok")
  done
  if [ -z "$dest" ]; then
    if [ "$named_src" -eq 1 ]; then
      dest="${pos[0]:-}"
    else
      [ "${#pos[@]}" -gt 0 ] && srcs+=("${pos[0]}")
      dest="${pos[1]:-}"
    fi
  fi
  dest="${dest#\"}"; dest="${dest%\"}"; dest="${dest#\'}"; dest="${dest%\'}"
  [ -n "$dest" ] || return 0
  case "$dest" in *'$'*|*'('*|*')'*|*'`'*|*'*'*|*'?'*|*'@'*|*'{'*|*','*) return 0 ;; esac
  d="${dest//\\//}"
  if [[ "$d" == */ ]] || [ -d "$d" ]; then
    d="${d%/}"
    for src in ${srcs[@]+"${srcs[@]}"}; do
      src="${src#\"}"; src="${src%\"}"; src="${src#\'}"; src="${src%\'}"
      case "$src" in ''|*'$'*|*'('*|*')'*|*'`'*|*'*'*|*'?'*|*'@'*|*'{'*|*','*) continue ;; esac
      src="${src//\\//}"
      src="${src%/}"
      leaf="${src##*/}"
      case "$leaf" in ''|.|..) continue ;; esac
      printf '%s\n' "$d/$leaf"
    done
    return 0
  fi
  printf '%s\n' "$dest"
}

ps_git_target_check() {
  local t="$1"
  t="${t#\"}"; t="${t%\"}"; t="${t#\'}"; t="${t%\'}"
  [ -n "$t" ] || return 0
  case "$t" in
    *'$'*|*'('*|*')'*|*'`'*|*'*'*|*'?'*|*'@'*|*'{'*|*','*) return 0 ;;
  esac
  if is_protected "$t"; then shell_target_block "$2" "$t"; fi
  return 0
}

# ── 保护范围变更受限命令（guard-versioned-content-scope C14，spec/pretooluse-guard.md
#    「保护范围变更的宿主原生审批（C14）」）──────────────────────────────────────────────
# 受限命令：openlogos exempt add|remove、openlogos ignore add|remove（list 不受限）。任何 lifecycle、任何提案
# 状态、git 判据与非 git 回落两种模式下都先于安全白名单与其他判定识别：
#   独立调用（只允许 `cd <目录> &&` 前缀段）→ 按 hook 输入 permission_mode：default / acceptEdits 输出
#     permissionDecision: ask（exit 0，由宿主对这一次调用弹出原生审批）；其他值、字段缺失、未知值 → exit 2；
#   复合形态（与其他命令组合、管道 / 重定向 / 命令替换 / 子 shell / 后台 / 换行等）→ 任何模式下 exit 2。
# guard 不读写任何授权文件，也不采信对话中的口头同意；每次调用独立判定、不留状态。

# _json_str <text> — 输出 JSON 字符串字面量（纯 bash，回落环境下也可用）
_json_str() {
  local s="$1"
  s="${s//\\/\\\\}"
  s="${s//\"/\\\"}"
  s="${s//$'\n'/\\n}"
  s="${s//$'\r'/\\r}"
  s="${s//$'\t'/\\t}"
  printf '"%s"' "$s"
}

# scope_block <text> — 双通道阻断（stderr 原样文本；stdout {"reason":...}），exit 2
scope_block() {
  printf '{"reason":%s}' "$(_json_str "$1")"
  printf '%s\n' "$1" >&2
  exit 2
}

# classify_scope_command <command> — 输出 none | standalone | compound。
# node 主路径：引号感知切分（单 / 双引号内的字面量不参与识别；双引号内的 $( ) 与反引号按代码处理）；
# node 不可用时退化为纯 bash 正则：文本中出现受限命令字样即按复合形态处理（只会多拦）。
classify_scope_command() {
  local cmd="$1" out=""
  if command -v node >/dev/null 2>&1; then
    out="$(node -e 'const cmd = process.argv[1];
// 引号感知切分：segs 为按 && / || / ; / 换行 以及其他运算符切开的词序列；forbidden 记录引号外出现的复合信号
const segs = [[]]; const seps = []; let forbidden = false;
let word = null; const stack = ['\''n'\''];
const top = () => stack[stack.length - 1];
const flush = () => { if (word !== null) { segs[segs.length - 1].push(word); word = null; } };
const brk = (sep) => { flush(); seps.push(sep); segs.push([]); };
for (let i = 0; i < cmd.length; i++) {
  const c = cmd[i], d = cmd[i + 1], m = top();
  if (m === '\''s'\'') { if (c === "'\''") stack.pop(); else word += c; continue; }
  if (m === '\''d'\'') {
    if (c === '\''"'\'') { stack.pop(); continue; }
    if (c === '\''\\'\'' && i + 1 < cmd.length) { word += d; i++; continue; }
    if (c === '\''$'\'' && d === '\''('\'') { forbidden = true; stack.push('\''p'\''); brk('\''$('\''); i++; continue; }
    if (c === '\''`'\'') { forbidden = true; stack.push('\''b'\''); brk('\''`'\''); continue; }
    word += c; continue;
  }
  // 代码上下文：n（顶层）/ p（$( 或 ( 内）/ b（反引号内）
  if (c === "'\''") { if (word === null) word = '\'''\''; stack.push('\''s'\''); continue; }
  if (c === '\''"'\'') { if (word === null) word = '\'''\''; stack.push('\''d'\''); continue; }
  if (c === '\''\\'\'' && i + 1 < cmd.length) { if (d === '\''\n'\'') { i++; continue; } word = (word ?? '\'''\'') + d; i++; continue; }
  if (c === '\'' '\'' || c === '\''\t'\'') { flush(); continue; }
  if (c === '\''\n'\'') { forbidden = true; brk('\''\n'\''); continue; }
  if (c === '\''&'\'' && d === '\''&'\'') { brk('\''&&'\''); i++; continue; }
  if (c === '\''|'\'' && d === '\''|'\'') { brk('\''||'\''); i++; continue; }
  if (c === '\'';'\'') { brk('\'';'\''); continue; }
  if (c === '\''$'\'' && d === '\''('\'') { forbidden = true; stack.push('\''p'\''); brk('\''$('\''); i++; continue; }
  if (c === '\''`'\'') { forbidden = true; if (m === '\''b'\'') stack.pop(); else stack.push('\''b'\''); brk('\''`'\''); continue; }
  if (c === '\''('\'') { forbidden = true; stack.push('\''p'\''); brk('\''('\''); continue; }
  if (c === '\'')'\'') { forbidden = true; if (m === '\''p'\'') stack.pop(); brk('\'')'\''); continue; }
  if (c === '\''|'\'' || c === '\''&'\'' || c === '\''<'\'' || c === '\''>'\'') { forbidden = true; brk(c); continue; }
  word = (word ?? '\'''\'') + c;
}
flush();
const isRestricted = (ws) => {
  for (let i = 0; i < ws.length; i++) {
    const w = ws[i];
    if (w !== '\''openlogos'\'' && !/[\\/]openlogos(\.(c?js|cmd|exe|ps1))?$/i.test(w)) continue;
    const rest = ws.slice(i + 1).filter(x => !x.startsWith('\''-'\''));
    if ((rest[0] === '\''exempt'\'' || rest[0] === '\''ignore'\'') && (rest[1] === '\''add'\'' || rest[1] === '\''remove'\'')) return true;
  }
  return false;
};
const nonEmpty = segs.map((ws, i) => ({ ws, i })).filter(s => s.ws.length > 0);
const hits = nonEmpty.filter(s => isRestricted(s.ws));
if (hits.length === 0) { process.stdout.write('\''none'\''); process.exit(0); }
const last = nonEmpty[nonEmpty.length - 1];
const standalone = !forbidden && hits.length === 1 && hits[0] === last && last.ws[0] === '\''openlogos'\''
  && seps.every(s => s === '\''&&'\'') && segs.every(ws => ws.length > 0)
  && nonEmpty.slice(0, -1).every(s => s.ws[0] === '\''cd'\'' && s.ws.length <= 2);
process.stdout.write(standalone ? '\''standalone'\'' : '\''compound'\'');' "$cmd" 2>/dev/null)" || out=""
  fi
  case "$out" in
    none|standalone|compound) printf '%s' "$out"; return 0 ;;
  esac
  local re='openlogos[^;&|]*[[:space:]](exempt|ignore)[[:space:]]+[^;&|]*(add|remove)'
  if [[ "$cmd" =~ $re ]]; then printf 'compound'; else printf 'none'; fi
}

scope_change_check() {
  case "$TOOL_NAME" in
    Bash|PowerShell) ;;
    *) return 0 ;;
  esac
  local cmd kind mode mode_label
  cmd="$(json_field "$INPUT" "tool_input.command" 2>/dev/null || true)"
  cmd="${cmd#"${cmd%%[![:space:]]*}"}"
  cmd="${cmd%"${cmd##*[![:space:]]}"}"
  [ -n "$cmd" ] || return 0
  # 快速路径：不含 openlogos 字样的命令与本检查无关
  [[ "$cmd" == *openlogos* ]] || return 0
  kind="$(classify_scope_command "$cmd")"
  case "$kind" in
    standalone) ;;
    compound)
      scope_block "⛔ 保护范围变更命令必须单独执行：一次调用只能包含一条 openlogos exempt / ignore 的 add / remove，只允许 \`cd <目录> &&\` 前缀，不能与其他命令组合。

命令：${cmd}

请拆成单独的调用后重试。"
      ;;
    *) return 0 ;;
  esac
  mode="$(json_field "$INPUT" "permission_mode" 2>/dev/null || true)"
  case "$mode" in
    default|acceptEdits)
      printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":%s}}\n' \
        "$(_json_str "该命令会改变 guard 的保护范围（${cmd}），需要您确认后执行")"
      exit 0
      ;;
  esac
  if [ -z "$mode" ]; then mode_label="未知：hook 输入缺少 permission_mode 字段"; else mode_label="$mode"; fi
  scope_block "⛔ 保护范围变更需要用户在宿主界面批准：该命令会修改 guard 的保护范围（exempt / ignore 清单与 .gitignore 托管区块）。

命令：${cmd}

当前权限模式（${mode_label}）下宿主不会弹出确认，无法证明由用户批准。请让用户在终端用 \`! ${cmd}\` 自行执行，或切换到默认权限模式后重试；不要用对话中的口头同意替代。"
}

# runtime_dir_file_write_check — 文件工具写入 guard 自有状态时阻断（任何 lifecycle 与提案状态）
runtime_dir_file_write_check() {
  case "$TOOL_NAME" in
    Edit|Write|MultiEdit|NotebookEdit) ;;
    *) return 0 ;;
  esac
  local fp rel
  fp="$(hook_target_path)"
  [ -n "$fp" ] || return 0
  rel="$(rel_of_cwd "$fp")"
  if [[ "$rel" == "$GUARD_RUNTIME_REL" || "$rel" == "$GUARD_RUNTIME_REL"/* ]]; then
    block "⛔ 变更管理拦截：$GUARD_RUNTIME_REL/ 是 guard 自有状态（执行记录与待报告项），任何状态下都不允许经 $TOOL_NAME 直接写入。\\n\\n目标文件：$fp\\n\\n如需立案修改受保护内容，请运行 \`openlogos change <slug>\`。"
  fi
  return 0
}

# ── 事后检查引擎接线（guard-versioned-content-scope，spec/pretooluse-guard.md
#    「Bash / PowerShell 判定顺序（事前轻判 + 事后检查）」）──────────────────────────────────
# 引擎 classify 给出：kind（independent 独立 git / openlogos 调用 | restore 独立形态的引擎 restore 调用（R9a）|
# readonly 只读命令 | other）、可确定的写入目标（target=）与命中 guard 自有状态的目标（runtime=）。

# block_text <text> — 双通道阻断（text 含真实换行；stdout JSON 经转义，命令原文含引号也可解析）
block_text() {
  printf '{"reason":%s}' "$(_json_str "$1")"
  printf '%s\n' "$1" >&2
  exit 2
}

has_open_records() {
  compgen -G "$GUARD_RUNTIME_REL/guard-records/*.json" >/dev/null 2>&1
}

engine_available() {
  [ -n "${GUARD_ENGINE:-}" ] && [ -f "$GUARD_ENGINE" ] && command -v node >/dev/null 2>&1
}

# engine_run <subcommand...> — 以 hook 输入为 stdin 调用引擎（stderr 静默）
engine_run() {
  printf '%s' "$INPUT" | node "$GUARD_ENGINE" "$@" 2>/dev/null
}

# engine_recheck — PreToolUse 补查：对全部未关闭执行记录做一次对比，新发现写入待报告项；不影响当前调用的结论
engine_recheck() {
  if engine_available && has_open_records; then
    engine_run check --pre-tool-use >/dev/null || true
  fi
  return 0
}

SHELL_KIND=""
SHELL_TARGETS=()
SHELL_RUNTIME=()
# engine_classify <bash|powershell> — 置 SHELL_KIND / SHELL_TARGETS / SHELL_RUNTIME；引擎不可用或失败返回 1
engine_classify() {
  local out line
  SHELL_KIND=""; SHELL_TARGETS=(); SHELL_RUNTIME=()
  engine_available || return 1
  out="$(engine_run classify --host claude --shell "$1")" || return 1
  while IFS= read -r line; do
    case "$line" in
      kind=*) SHELL_KIND="${line#kind=}" ;;
      target=*) SHELL_TARGETS+=("${line#target=}") ;;
      runtime=*) SHELL_RUNTIME+=("${line#runtime=}") ;;
    esac
  done <<< "$out"
  [ -n "$SHELL_KIND" ]
}

runtime_shell_block() {
  block_text "⛔ 变更管理拦截：$GUARD_RUNTIME_REL/ 是 guard 自有状态（执行记录与待报告项），任何状态下都不允许经 $TOOL_NAME 直接写入。

检测到写入目标：$2
命令：$(printf '%s' "$1" | head -c 200)

如需立案修改受保护内容，请运行 \`openlogos change <slug>\`。"
}

# runtime_shell_write_check — Bash / PowerShell 可确定目标写入 guard 自有状态时阻断（任何 lifecycle 与提案状态）。
# 引擎可用时按 classify 的 runtime= 结论；PowerShell 或引擎不可用时按文本保守识别（只会多拦）。
runtime_shell_write_check() {
  case "$TOOL_NAME" in
    Bash|PowerShell) ;;
    *) return 0 ;;
  esac
  local cmd
  cmd="$(json_field "$INPUT" "tool_input.command" 2>/dev/null || true)"
  [[ "$cmd" == *logos* ]] || return 0
  if [ "$TOOL_NAME" = "Bash" ] && engine_classify bash; then
    if [ "${#SHELL_RUNTIME[@]}" -gt 0 ]; then runtime_shell_block "$cmd" "${SHELL_RUNTIME[0]}"; fi
    return 0
  fi
  if [[ "$cmd" == *.openlogos-runtime* ]] \
    && printf '%s\n' "$cmd" | grep -qiE '(^|[;&|(`[:space:]])(rm|mv|cp|touch|mkdir|chmod|chown|tee|sed|remove-item|set-content|add-content|out-file|new-item|copy-item|move-item|ri|del|erase|sc|ac|ni)([[:space:]]|$)|>' 2>/dev/null; then
    runtime_shell_block "$cmd" "$GUARD_RUNTIME_REL/"
  fi
  return 0
}

shell_target_block() {
  local detail
  detail="$(protect_block_detail)"
  detail="${detail//\\n/$'\n'}"
  block_text "⛔ 变更管理拦截：项目处于 launched 生命周期，但没有活跃的变更提案。

检测到写入目标：${PROTECT_REL:-$2}
${detail}

命令：$(printf '%s' "$1" | head -c 200)

请先运行 \`openlogos change <slug>\` 创建变更提案后再执行此命令。"
}

# shell_git_verdict <command> — git 判据生效、launched、无活跃提案时的 Bash / PowerShell 事前轻判：
#   独立 git / openlogos 调用 → 放行（存在未关闭记录时 snapshot --rebase-only）；
#   独立形态的引擎 restore 调用 → 放行且不拍快照；
#   可确定写入目标受保护 → exit 2；只读命令 → 放行不拍快照；其余 → 放行并拍快照。
# 引擎不可用、classify 失败或快照失败（整体采集失败）返回 1，由调用方按非 git 回落判定。
shell_git_verdict() {
  local cmd="$1" shell=bash t
  [ "$TOOL_NAME" = "PowerShell" ] && shell=powershell
  engine_classify "$shell" || return 1
  if [ "${#SHELL_RUNTIME[@]}" -gt 0 ]; then runtime_shell_block "$cmd" "${SHELL_RUNTIME[0]}"; fi
  case "$SHELL_KIND" in
    independent)
      if has_open_records; then engine_run snapshot --rebase-only >/dev/null || return 1; fi
      exit 0
      ;;
    restore)
      engine_recheck
      exit 0
      ;;
  esac
  if [ "$shell" = "powershell" ]; then
    ps_git_targets "$cmd"
  else
    for t in ${SHELL_TARGETS[@]+"${SHELL_TARGETS[@]}"}; do
      if is_protected "$t"; then shell_target_block "$cmd" "$t"; fi
    done
  fi
  if [ "$SHELL_KIND" = "readonly" ]; then
    engine_recheck
    exit 0
  fi
  engine_run snapshot >/dev/null || return 1
  exit 0
}

# _bash_single_verdict <command> — 单条命令（或拆段后只有一段）的回落判定，沿用修改前的判定顺序逐字不变：
# 安全白名单先判 → 写入模式 → 路径提取与逐路径管辖判定 → 解析不出即阻断。复合命令见 bash_fallback_verdict。
_bash_single_verdict() {
  local COMMAND_TRIMMED="$1" pattern NEEDS_BLOCK WRITE_TARGET ANY_PATH ALL_ALLOWED FIRST_WORD ARG

  # Check safe patterns first
  for pattern in "${BASH_SAFE_PATTERNS[@]}"; do
    if echo "$COMMAND_TRIMMED" | grep -qE "$pattern" 2>/dev/null; then
      exit 0
    fi
  done

  # Check write patterns
  NEEDS_BLOCK=0
  for pattern in "${BASH_WRITE_PATTERNS[@]}"; do
    if echo "$COMMAND_TRIMMED" | grep -qE "$pattern" 2>/dev/null; then
      NEEDS_BLOCK=1
      break
    fi
  done

  if [ "$NEEDS_BLOCK" -eq 1 ]; then
    # Check if the write target is whitelisted
    # Extract potential file path from common patterns
    WRITE_TARGET=""
    if echo "$COMMAND_TRIMMED" | grep -qE " > |>> " 2>/dev/null; then
      WRITE_TARGET="$(echo "$COMMAND_TRIMMED" | sed -E 's/.*[>]{1,2} *([^ ]+).*/\1/' 2>/dev/null || echo "")"
    fi
    if [ -n "$WRITE_TARGET" ] && path_write_allowed "$WRITE_TARGET"; then
      exit 0
    fi

    # Path-argument jurisdiction (fix-guard-check-bash-write-target-jurisdiction):
    # structured single-command write forms extract every path argument (skipping
    # leading "-" option flags; cp/mv include source and destination) and run each
    # through is_whitelisted_path — all outside the project root or whitelisted →
    # allow; any inside and non-whitelisted → keep blocking. Unparseable forms
    # (variable expansion, command substitution, backticks, pipes, compound
    # commands, redirects) stay unconditionally blocked (fail-closed).
    if [ -z "$WRITE_TARGET" ] \
      && echo "$COMMAND_TRIMMED" | grep -qE '^(rm|cp|mv|mkdir|touch|chmod|chown) ' 2>/dev/null \
      && ! echo "$COMMAND_TRIMMED" | grep -qE '[$`|;&<>]' 2>/dev/null; then
      ANY_PATH=0
      ALL_ALLOWED=1
      FIRST_WORD=1
      set -f
      for ARG in $COMMAND_TRIMMED; do
        if [ "$FIRST_WORD" -eq 1 ]; then FIRST_WORD=0; continue; fi
        case "$ARG" in
          -*) continue ;;
        esac
        # Strip simple surrounding quotes; a quoted path with spaces word-splits
        # into fragments that resolve inside the project → conservative block.
        ARG="${ARG#\"}"; ARG="${ARG%\"}"; ARG="${ARG#\'}"; ARG="${ARG%\'}"
        [ -n "$ARG" ] || continue
        ANY_PATH=1
        if ! path_write_allowed "$ARG"; then
          ALL_ALLOWED=0
          break
        fi
      done
      set +f
      if [ "$ANY_PATH" -eq 1 ] && [ "$ALL_ALLOWED" -eq 1 ]; then
        exit 0
      fi
    fi

    block "⛔ 变更管理拦截：项目处于 launched 生命周期，但没有活跃的变更提案。\\n\\n检测到写入操作：$(echo "$COMMAND_TRIMMED" | head -c 120)$(fallback_engine_hint)\\n\\n请先运行 \`openlogos change <slug>\` 创建变更提案后再执行此命令。"
  fi

  # Default: allow (unknown commands pass through)
  exit 0
}

# ── 非 git 回落的复合命令逐段判定（verify-smoke-guard-fixes-0-15-20，决策 C04；
#    根规范 spec/pretooluse-guard.md「Bash 非 git 回落的复合命令逐段判定（规范性）」）──
# 修改前回落路径拿**整条命令**匹配安全白名单前缀、写入模式也只锚定开头，`ls && rm -rf src`、
# `true; rm src/x`、`cd <根> && sed -i … src/x` 均被放行。现按顶层分隔符拆段逐段判定，并维护有效工作目录。

_bash_fallback_block() {
  block "⛔ 变更管理拦截：项目处于 launched 生命周期，但没有活跃的变更提案。\\n\\n检测到写入操作：$(echo "$1" | head -c 120)$(fallback_engine_hint)\\n\\n请先运行 \`openlogos change <slug>\` 创建变更提案后再执行此命令。"
}

_bash_safe_hit() {
  local p
  for p in "${BASH_SAFE_PATTERNS[@]}"; do
    if echo "$1" | grep -qE "$p" 2>/dev/null; then return 0; fi
  done
  return 1
}

_bash_write_hit() {
  local p
  for p in "${BASH_WRITE_PATTERNS[@]}"; do
    if echo "$1" | grep -qE "$p" 2>/dev/null; then return 0; fi
  done
  return 1
}

# 写入模式出现在命令任意位置（含命令替换 / 反引号 / 进程替换 / heredoc 内部）：锚定开头的模式放宽为
# 「行首或空白、反引号、括号、分隔符之后」——命令替换内部的命令不解析，只要出现写入即 fail-closed。
_bash_write_anywhere() {
  local p re
  for p in "${BASH_WRITE_PATTERNS[@]}"; do
    if [ "${p:0:1}" = "^" ]; then re="(^|[[:space:]\`(;&|])${p:1}"; else re="$p"; fi
    if echo "$1" | grep -qE "$re" 2>/dev/null; then return 0; fi
  done
  return 1
}

# _bash_has_substitution <command> — 引号感知识别会执行的替换结构（review r1 F2）：单引号内与反斜杠转义的字符
# 是字面量；`$(` 与反引号在双引号内仍会执行；`<(` `>(` `<<` 只在引号外才是结构。超长或引号未闭合时按原文保守识别。
_bash_has_substitution() {
  local LC_ALL=C s="$1" n i=0 c next q=""
  n=${#s}
  if [ "$n" -gt 16384 ]; then
    case "$s" in *'$('*|*'`'*|*'<('*|*'>('*|*'<<'*) return 0 ;; esac
    return 1
  fi
  while [ "$i" -lt "$n" ]; do
    c="${s:$i:1}"; next="${s:$((i + 1)):1}"
    if [ "$q" = "'" ]; then
      [ "$c" = "'" ] && q=""
      i=$((i + 1)); continue
    fi
    if [ "$c" = "\\" ]; then i=$((i + 2)); continue; fi
    if [ "$c" = '`' ]; then return 0; fi
    if [ "$c" = '$' ] && [ "$next" = '(' ]; then return 0; fi
    if [ "$q" = '"' ]; then
      [ "$c" = '"' ] && q=""
      i=$((i + 1)); continue
    fi
    case "$c" in
      "'"|'"') q="$c" ;;
      '<'|'>')
        if [ "$next" = '(' ]; then return 0; fi
        if [ "$c" = '<' ] && [ "$next" = '<' ]; then return 0; fi ;;
    esac
    i=$((i + 1))
  done
  if [ -n "$q" ]; then
    case "$s" in *'$('*|*'`'*|*'<('*|*'>('*|*'<<'*) return 0 ;; esac
  fi
  return 1
}

# _bash_redirect_scan <segment> — 引号感知提取段内引号外的输出重定向（review r1 F1）：置 BRS_TARGETS（目标数组）、
# BRS_BAD（目标解析不出：空、含变量 / 通配 / 命令替换）与 BRS_REST（去掉重定向后的段文本，供命令形态判定）。
# `>&N` / `>&-` 是描述符复制而非写文件；`<` 输入重定向不是写入。
_bash_redirect_scan() {
  local LC_ALL=C s="$1" n i=0 c next q="" rest="" word wq
  n=${#s}
  BRS_TARGETS=(); BRS_BAD=0; BRS_REST=""
  while [ "$i" -lt "$n" ]; do
    c="${s:$i:1}"; next="${s:$((i + 1)):1}"
    if [ -n "$q" ]; then
      rest="$rest$c"
      if [ "$q" = "'" ]; then
        [ "$c" = "'" ] && q=""
      elif [ "$c" = "\\" ]; then
        rest="$rest$next"; i=$((i + 2)); continue
      elif [ "$c" = '"' ]; then
        q=""
      fi
      i=$((i + 1)); continue
    fi
    case "$c" in
      "'"|'"') q="$c"; rest="$rest$c"; i=$((i + 1)); continue ;;
      "\\") rest="$rest$c$next"; i=$((i + 2)); continue ;;
      ">") ;;
      *) rest="$rest$c"; i=$((i + 1)); continue ;;
    esac
    # 输出重定向：吃掉紧邻的描述符数字 / &（2> &> 1>>）
    while [ -n "$rest" ] && case "${rest: -1}" in [0-9]|"&") true ;; *) false ;; esac; do rest="${rest%?}"; done
    i=$((i + 1))
    case "${s:$i:1}" in ">"|"|") i=$((i + 1)) ;; esac
    if [ "${s:$i:1}" = "&" ]; then
      # 描述符复制：>&2 >&- 不写文件；>&file 等同写文件
      word="${s:$((i + 1))}"; word="${word%%[!0-9-]*}"
      if [ -n "$word" ]; then i=$((i + 1 + ${#word})); rest="$rest "; continue; fi
      i=$((i + 1))
    fi
    while [ "${s:$i:1}" = " " ] || [ "${s:$i:1}" = $'\t' ]; do i=$((i + 1)); done
    word=""; wq=""
    while [ "$i" -lt "$n" ]; do
      c="${s:$i:1}"
      if [ -n "$wq" ]; then
        if [ "$c" = "$wq" ]; then wq=""; else word="$word$c"; fi
        i=$((i + 1)); continue
      fi
      case "$c" in
        "'"|'"') wq="$c" ;;
        " "|$'\t'|";"|"|"|"&"|"<"|">") break ;;
        *) word="$word$c" ;;
      esac
      i=$((i + 1))
    done
    case "$word" in
      ""|*'$'*|*'`'*|*'*'*|*'?'*|*'['*|*'~'*) BRS_BAD=1 ;;
      *) BRS_TARGETS[${#BRS_TARGETS[@]}]="$word" ;;
    esac
    rest="$rest "
  done
  BRS_REST="$rest"
}

# 文件写入信号（review r1 F1）：重定向之外、已识别的文件写入命令与写入 API。命中时安全白名单前缀不构成豁免。
_bash_file_write_hit() {
  local t="$1"
  t="${t#"${t%%[![:space:]]*}"}"
  echo "$t" | grep -qE '^(rm|cp|mv|mkdir|touch|chmod|chown|tee)( |$)|sed -i|writeFileSync|mkdirSync' 2>/dev/null
}

# _bash_segment_redirects_allowed <segment> <base> <uncertain> — 复合命令中**每一段**（含 cd / pushd / popd 目录切换段，
# review r2 F1）的引号外输出重定向目标逐一做管辖判定，0 放行 / 1 阻断（/dev/null 恒非写入）。重定向在该段命令执行前
# 就已打开目标，故按**该段执行前**的有效目录解析；有效目录不确定时阻断。置 BSEG_REST 为去掉重定向后的段文本。
_bash_segment_redirects_allowed() {
  local seg="$1" base="$2" uncertain="$3" t
  _bash_redirect_scan "$seg"
  BSEG_REST="${BRS_REST#"${BRS_REST%%[![:space:]]*}"}"; BSEG_REST="${BSEG_REST%"${BSEG_REST##*[![:space:]]}"}"
  [ "$BRS_BAD" -eq 1 ] && return 1
  for t in ${BRS_TARGETS[@]+"${BRS_TARGETS[@]}"}; do
    [ "$t" = "/dev/null" ] && continue
    [ "$uncertain" -eq 1 ] && return 1
    path_write_allowed "$(_bash_abs_target "$t" "$base")" || return 1
  done
  return 0
}

# _bash_compound_segment_allowed <rest> <base> <uncertain> — 复合命令中非目录切换段（已去掉重定向）的命令判定
# （review r1 F1），0 放行 / 1 阻断：① 已识别的文件写入命令 / API 做路径提取判定；② 没有时才凭安全白名单放行
# （保留 git push 等既有豁免）；③ 其余写入模式（git reset、npm install …）沿用路径判定、解析不出即阻断。
# 有效目录不确定时，任何需要路径判定的写入一律阻断。
_bash_compound_segment_allowed() {
  local t="$1" base="$2" uncertain="$3"
  if _bash_file_write_hit "$t"; then
    [ "$uncertain" -eq 1 ] && return 1
    _bash_segment_write_allowed "$t" "$base"
    return $?
  fi
  _bash_safe_hit "$t" && return 0
  if _bash_write_hit "$t"; then
    [ "$uncertain" -eq 1 ] && return 1
    _bash_segment_write_allowed "$t" "$base"
    return $?
  fi
  return 0
}

# _bash_split_records <command> — 引号感知的顶层拆段（单 / 双引号字面量内的分隔符不拆；`>&` `&>` 不是后台符）。
# 输出记录：<段文本>\x1f<其后分隔符：&& || ; NL | & 或空>\x1e。在子 shell 中以 C locale 逐字节扫描。
_bash_split_records() {
  local LC_ALL=C s="$1" n i=0 c next cur="" q="" US=$'\x1f' RS=$'\x1e'
  n=${#s}
  _emit() { local t="$1"; t="${t#"${t%%[![:space:]]*}"}"; t="${t%"${t##*[![:space:]]}"}"; printf '%s%s%s%s' "$t" "$US" "$2" "$RS"; }
  while [ "$i" -lt "$n" ]; do
    c="${s:$i:1}"; next="${s:$((i + 1)):1}"
    if [ -n "$q" ]; then
      cur="$cur$c"
      if [ "$q" = "'" ]; then
        [ "$c" = "'" ] && q=""
      elif [ "$c" = "\\" ]; then
        cur="$cur$next"; i=$((i + 2)); continue
      elif [ "$c" = '"' ]; then
        q=""
      fi
      i=$((i + 1)); continue
    fi
    case "$c" in
      "'"|'"') q="$c"; cur="$cur$c" ;;
      "\\") cur="$cur$c$next"; i=$((i + 2)); continue ;;
      ";") _emit "$cur" ";"; cur="" ;;
      $'\n') _emit "$cur" "NL"; cur="" ;;
      "&")
        if [ "$next" = "&" ]; then _emit "$cur" "&&"; cur=""; i=$((i + 2)); continue
        elif [ "${cur: -1}" = ">" ] || [ "${cur: -1}" = "<" ] || [ "$next" = ">" ]; then cur="$cur$c"
        else _emit "$cur" "&"; cur=""
        fi ;;
      "|")
        if [ "$next" = "|" ]; then _emit "$cur" "||"; cur=""; i=$((i + 2)); continue
        else _emit "$cur" "|"; cur=""
        fi ;;
      *) cur="$cur$c" ;;
    esac
    i=$((i + 1))
  done
  _emit "$cur" ""
}

# _bash_dir_change <segment> — 置 BDC_KIND（none / literal / uncertain）与 BDC_ARG。
# 只有 cd / pushd 后跟**单个**字面量参数（无变量、通配、波浪号、命令替换、选项、引号内空白）才是 literal。
_bash_dir_change() {
  local -a words=()
  local arg
  BDC_KIND="none"; BDC_ARG=""
  read -r -a words <<< "$1"
  case "${words[0]:-}" in
    cd|pushd) ;;
    popd) BDC_KIND="uncertain"; return 0 ;;
    *) return 0 ;;
  esac
  if [ "${#words[@]}" -ne 2 ]; then BDC_KIND="uncertain"; return 0; fi
  arg="${words[1]}"
  if [ "${#arg}" -ge 2 ] && { { [ "${arg:0:1}" = '"' ] && [ "${arg: -1}" = '"' ]; } || { [ "${arg:0:1}" = "'" ] && [ "${arg: -1}" = "'" ]; }; }; then
    arg="${arg:1:$((${#arg} - 2))}"
  fi
  case "$arg" in
    ""|-*|*'$'*|*'*'*|*'?'*|*'['*|*'~'*|*'`'*|*'"'*|*"'"*) BDC_KIND="uncertain"; return 0 ;;
  esac
  BDC_KIND="literal"; BDC_ARG="$arg"
}

# _bash_abs_target <path> <base> — base 非空时把相对目标按 base 解析为绝对路径；绝对目标原样。
_bash_abs_target() {
  case "$1" in
    /*|[A-Za-z]:[/\\]*) printf '%s' "$1" ;;
    *) if [ -n "$2" ]; then printf '%s/%s' "$2" "$1"; else printf '%s' "$1"; fi ;;
  esac
}

# _bash_segment_write_allowed <segment> <base> — 命中写入模式的单段：沿用既有路径提取与逐路径管辖判定
# （重定向目标 / rm cp mv mkdir touch chmod chown 的全量路径实参）。0 = 放行，1 = 阻断；解析不出一律阻断。
_bash_segment_write_allowed() {
  local seg="$1" base="$2" target="" arg any=0 first=1
  if echo "$seg" | grep -qE " > |>> " 2>/dev/null; then
    target="$(echo "$seg" | sed -E 's/.*[>]{1,2} *([^ ]+).*/\1/' 2>/dev/null || echo "")"
  fi
  if [ -n "$target" ]; then
    path_write_allowed "$(_bash_abs_target "$target" "$base")" && return 0
    return 1
  fi
  if echo "$seg" | grep -qE '^(rm|cp|mv|mkdir|touch|chmod|chown) ' 2>/dev/null \
    && ! echo "$seg" | grep -qE '[$`|;&<>]' 2>/dev/null; then
    set -f
    for arg in $seg; do
      if [ "$first" -eq 1 ]; then first=0; continue; fi
      case "$arg" in -*) continue ;; esac
      arg="${arg#\"}"; arg="${arg%\"}"; arg="${arg#\'}"; arg="${arg%\'}"
      [ -n "$arg" ] || continue
      any=1
      if ! path_write_allowed "$(_bash_abs_target "$arg" "$base")"; then set +f; return 1; fi
    done
    set +f
    [ "$any" -eq 1 ] && return 0
  fi
  return 1
}

# bash_fallback_verdict <command> — 非 git 回落（或整体采集失败）时的 Bash 判定入口。
bash_fallback_verdict() {
  local cmd="$1" text sep eff="" uncertain=0 chain=0 i n
  local -a seg_text=() seg_sep=()
  # ① 命令替换 / 反引号 / 进程替换 / heredoc：任意位置出现写入模式即按解析不出阻断；否则沿用单条判定。
  if _bash_has_substitution "$cmd"; then
    if _bash_write_anywhere "$cmd"; then _bash_fallback_block "$cmd"; fi
    _bash_single_verdict "$cmd"
  fi
  # 超长命令不逐字节扫描：出现写入即保守阻断，否则放行（与单条判定的「未命中模式放行」同向）。
  if [ "${#cmd}" -gt 16384 ]; then
    if _bash_write_anywhere "$cmd"; then _bash_fallback_block "$cmd"; fi
    exit 0
  fi
  # ② 引号感知拆段
  while IFS=$'\x1f' read -r -d $'\x1e' text sep; do
    if [ -z "$text" ]; then
      # 空段：把分隔符并入前一段（任何非 && 边界都更保守）
      if [ "${#seg_text[@]}" -gt 0 ] && [ -n "$sep" ] && [ "$sep" != "&&" ]; then
        seg_sep[$((${#seg_sep[@]} - 1))]="$sep"
      fi
      continue
    fi
    seg_text[${#seg_text[@]}]="$text"
    seg_sep[${#seg_sep[@]}]="$sep"
  done < <(_bash_split_records "$cmd")
  n=${#seg_text[@]}
  # ③ 只有一段：判定结果与修改前逐字一致
  if [ "$n" -le 1 ]; then _bash_single_verdict "$cmd"; fi
  # ④ 逐段：维护有效工作目录；每段先判重定向与文件写入，再凭白名单放行（review r1 F1：不设整组白名单早退——
  #    `^echo ` `^cat ` 等前缀不排除重定向，早退会让写入判定与不确定目录检查不可达）。
  #    review r2 F1：重定向检查先于目录切换分支、对每一段都执行，并以该段执行前的有效目录为基准；
  #    目录切换由去掉重定向后的段文本判定，之后才推进或失效有效目录。
  for ((i = 0; i < n; i++)); do
    text="${seg_text[$i]}"; sep="${seg_sep[$i]}"
    _bash_segment_redirects_allowed "$text" "$eff" "$uncertain" || _bash_fallback_block "$cmd"
    text="$BSEG_REST"
    _bash_dir_change "$text"
    if [ "$BDC_KIND" = "literal" ]; then
      if [ "$uncertain" -eq 0 ]; then
        case "$BDC_ARG" in
          /*|[A-Za-z]:[/\\]*) eff="$BDC_ARG" ;;
          *) eff="${eff:-$(pwd)}/$BDC_ARG" ;;
        esac
        chain=1
      fi
    elif [ "$BDC_KIND" = "uncertain" ]; then
      uncertain=1
    else
      _bash_compound_segment_allowed "$text" "$eff" "$uncertain" || _bash_fallback_block "$cmd"
    fi
    # 有效目录的推进只在其后连续 && 条件链内有效：链被 ; 换行 || | & 结束后基准不唯一
    if [ "$chain" -eq 1 ] && [ "$sep" != "&&" ]; then uncertain=1; fi
  done
  exit 0
}

# 保护范围变更受限命令：先于 lifecycle、提案状态与安全白名单（C14）
scope_change_check

if [ "$IS_LAUNCHED" -eq 0 ]; then
  runtime_dir_file_write_check
  runtime_shell_write_check
  # All modules are initial — allow everything
  exit 0
fi

# ── Step 3: Check guard file ──────────────────────────────────────────────────
GUARD_FILE="logos/.openlogos-guard"
if [ -f "$GUARD_FILE" ]; then
  runtime_dir_file_write_check
  # 有活跃提案：Bash / PowerShell 沿用提案期判定放行；git 判据生效时照常拍快照建立执行记录
  # （各检查点只维护记录、不报告新变化），guard 自有状态的可确定目标写入恒阻断。
  case "$TOOL_NAME" in
    Bash|PowerShell)
      runtime_shell_write_check
      detect_guard_mode
      if [ "$GUARD_MODE" = "git" ] && [ "$SHELL_KIND" != "readonly" ]; then
        engine_run snapshot >/dev/null || true
      fi
      exit 0
      ;;
  esac
  # Active change proposal exists. Normally allow everything within scope, but
  # while the proposal is still in the plan stage (before plan-exit, i.e. the
  # PLAN_APPROVED marker does not yet exist) we narrow delta writes: only the
  # GUI page-design prototype (deltas/prd/2-product-design/2-page-design/*.html)
  # may be produced in plan stage; all other deltas/** writes are deferred to
  # delta-writing (after PLAN_APPROVED). Non-delta scope is unaffected.
  ACTIVE_SLUG="$(json_field "$(cat "$GUARD_FILE" 2>/dev/null || true)" "activeChange" || true)"

  PLAN_STAGE=0
  if [ -n "$ACTIVE_SLUG" ] && [ ! -f "logos/changes/$ACTIVE_SLUG/PLAN_APPROVED" ]; then
    PLAN_STAGE=1
  fi

  if [ "$PLAN_STAGE" -eq 0 ]; then
    # delta-writing (or no resolvable slug) — regular behavior, allow within scope.
    exit 0
  fi

  # Plan stage: enforce the page-design-only allowlist for delta writes（路径归一化复用 rel_of_cwd）。
  # is_plan_stage_delta_blocked <path> — return 0 (blocked) if path is a delta
  # write that is NOT the allowed page-design .html prototype.
  is_plan_stage_delta_blocked() {
    local rel tagged
    tagged="$(_rel_of_cwd_tagged "$1")"
    rel="${tagged#? }"
    # Only concerned with this proposal's deltas/ tree.
    local delta_prefix="logos/changes/$ACTIVE_SLUG/deltas/"
    if [[ "$rel" != "$delta_prefix"* ]]; then
      return 1  # not a delta write — not our concern here
    fi
    # Allowed: deltas/prd/2-product-design/2-page-design/<name>.html
    if [ "${tagged:0:1}" != "1" ] && [[ "$rel" == "${delta_prefix}prd/2-product-design/2-page-design/"*.html ]]; then
      return 1  # allowed prototype
    fi
    return 0  # any other delta in plan stage → blocked
  }

  # If not a delta write at all, fall through to normal handling below.
  case "$TOOL_NAME" in
    Edit|Write|MultiEdit|NotebookEdit)
      _FP="$(hook_target_path)"
      if [ -n "$_FP" ] && is_plan_stage_delta_blocked "$_FP"; then
        block "⛔ 变更管理拦截（plan 阶段）：当前提案尚未通过 plan-exit（PLAN_APPROVED 不存在）。\\n\\n目标文件：$_FP\\n\\nplan 阶段仅允许产出 page-design 原型 delta（deltas/prd/2-product-design/2-page-design/*.html）；其余 delta 请在 plan 通过后（delta-writing）再产出。"
      fi
      ;;
  esac

  # Plan-stage but not a blocked delta write — allow within scope.
  exit 0
fi

# ── Handle by tool type ───────────────────────────────────────────────────────

case "$TOOL_NAME" in
  Edit|Write|MultiEdit|NotebookEdit)
    FILE_PATH="$(hook_target_path)"
    if [ -z "$FILE_PATH" ]; then
      # 文件路径类工具解析不出目标路径：fail-closed（不放行）
      block "⛔ openlogos guard: 无法从 $TOOL_NAME 输入解析目标路径（file_path / notebook_path 缺失），写入门禁无法判定，fail-closed。"
    fi
    # 补查（git 判据生效时）：对未关闭执行记录做一次对比，结果不影响本次放行结论
    detect_guard_mode
    if [ "$GUARD_MODE" = "git" ]; then engine_recheck; fi
    # 受保护判定（git 判据生效时十步判定；非 git 回落时不可豁免项 + 既有白名单 / guard.exempt）
    if ! is_protected "$FILE_PATH"; then
      exit 0
    fi
    # 非 git 回落且未命中不可豁免项时，拦截文案与修改前逐字一致（回落回归锚）
    _DETAIL=""
    if [ "$PROTECT_STEP" != "fallback" ] || [ -n "$(fallback_engine_hint)" ]; then
      _DETAIL="\\n$(protect_block_detail)"
    fi
    block "⛔ 变更管理拦截：项目处于 launched 生命周期，但没有活跃的变更提案。\\n\\n目标文件：$FILE_PATH${_DETAIL}\\n\\n请先运行 \`openlogos change <slug>\` 创建变更提案后再修改代码。"
    ;;

  Bash|PowerShell)
    COMMAND="$(json_field "$INPUT" "tool_input.command" || true)"
    if [ -z "$COMMAND" ]; then
      exit 0
    fi
    # Trim leading whitespace
    COMMAND_TRIMMED="${COMMAND#"${COMMAND%%[![:space:]]*}"}"

    # git 判据生效：事前轻判 + 快照（成功即退出）；整体采集失败时本次调用按非 git 回落判定
    detect_guard_mode
    if [ "$GUARD_MODE" = "git" ]; then
      shell_git_verdict "$COMMAND_TRIMMED" || true
      GUARD_MODE="fallback"
      GUARD_FALLBACK_WHY="快照采集失败"
    fi
    if [ "$TOOL_NAME" = "PowerShell" ]; then
      windows_shell_verdict "$COMMAND_TRIMMED"
    fi
    bash_fallback_verdict "$COMMAND_TRIMMED"
    ;;

  *)
    # Other tools (Read, Glob, Grep, etc.) — always allow
    exit 0
    ;;
esac
