#!/bin/bash
# UserPromptSubmit harness 契约探针 —— 在**真 harness** 里验两条契约，⛔ 不是离线喂 JSON。
#
# ── 为什么有它 ─────────────────────────────────────────────────────────────────
# `.claude/hooks/reject-consumer-design-task.selftest.sh` 的 14/14 全部是**直接跑脚本**
# （离线喂 JSON），它证明的是「脚本自己的分支对不对」，⛔ **不证明 harness 会尊重 exit 2、
# 也不证明 additionalContext 真的进得了模型上下文**。那两条是 harness 契约，必须实测。
# 同族纪律 [[measure-negative-claims-too]]：harness 契约几十秒可验，⛔ 别凭记忆假设。
#
# ── 设计：变量隔离 ─────────────────────────────────────────────────────────────
# 三臂**用完全相同的 prompt**，只换 `PROBE_ARM` 环境变量 ⇒ 读数差只能来自 hook 行为。
#   off    hook exit 0、无输出              （对照臂）
#   block  hook 写 stderr 后 exit 2          （阻塞臂）
#   inject hook 输出 additionalContext + 0   （注入臂）
# 三臂读数必须**互不相同**，否则装置无判别力（`AGENTS §25`：只在故障臂红的才是判别器）。
#
# ── 覆盖边界（这是边界，⛔ 不是 TODO）─────────────────────────────────────────
# ① 只证 **`claude -p` 无头模式**。交互模式（TUI）⛔ 未测 —— 尤其「exit 2 会不会清掉
#    输入框里已打的字」这个形态在 -p 下**结构上不存在对应物**。⛔ 别把本探针读数外推过去。
# ② **本机有三条 claude 通道，⛔ 别只登记两条**（2026-09-10 补 —— 原文只写了前两条，
#    而漏掉的第三条恰恰是**实际执行通道**，同族 `AGENTS §18` 推论一「规定通道 ≠ 实际通道」）：
#      · nvm `~/.nvm/.../bin/claude` = **2.1.251**（`command -v claude` 取到的就是它 ⇒ 本探针缺省档）
#      · ServBay = **2.1.89**（ds-autofix launchd 用）
#      · 🔴 **VSCode 扩展捆绑的二进制**（`~/.vscode/extensions/anthropic.claude-code-<ver>-*/`）——
#        现取装了 **13 个版本**，`ps` 实测在跑的是 **2.1.266 / 2.1.267**
#        ⇒ **交互 session（含 lab 自己这条审查线）走的是这条，⛔ 不是 nvm 那条。**
#    ⇒ 用 `PROBE_CLAUDE_BIN=<路径>` 指定通道；版本进产物 `env.claudeVersion`，
#      ⛔ 不靠读者记得跑的时候用了哪个二进制（`AGENTS §2` 那条「口径进产物本身」）。
# ③ 会真发 3 次 API 调用（`--model haiku`，其中阻塞臂 0 token）。
# ④ **`-p` 无头模式 ≠ 交互 TUI**（同 ①）—— 换通道 ⛔ 不等于覆盖了 TUI，两者是正交的两维。
#
# 用法：bash probes/consumer-task-boundary/harness-contract-probe.sh
#      PROBE_CLAUDE_BIN=/path/to/claude bash probes/.../harness-contract-probe.sh
# 退出码：0 = 全部控制通过；1 = 有控制失败（fail closed，⛔ 不静默降级）
set -u

CLAUDE_BIN="${PROBE_CLAUDE_BIN:-$(command -v claude || true)}"
[ -n "$CLAUDE_BIN" ] || { echo "❌ 找不到 claude 二进制 —— fail closed，⛔ 不报「契约不成立」"; exit 1; }
[ -x "$CLAUDE_BIN" ] || { echo "❌ $CLAUDE_BIN 不可执行 —— fail closed"; exit 1; }
VER="$($CLAUDE_BIN --version 2>&1)"

S="$(mktemp -d)"
mkdir -p "$S/.claude/hooks"

cat > "$S/.claude/hooks/probe.sh" <<'HOOK'
#!/bin/bash
set -u
raw=$(cat)
arm="${PROBE_ARM:-off}"
# must-hit 证据：无论哪臂先落一行日志 —— 它证明 hook 真的被 harness 调用了。
# ⛔ 没有这一行，「没挡住」分不清是「exit 2 无效」还是「hook 压根没触发」（第四种 0）。
meta=$(printf '%s' "$raw" | python3 -c 'import json,sys
try: d=json.load(sys.stdin)
except Exception: print("PARSE_FAIL"); sys.exit(0)
print("prompt_field=%s|event=%s" % ("prompt" in d, d.get("hook_event_name")))' 2>/dev/null || echo PYFAIL)
printf 'INVOKED arm=%s %s\n' "$arm" "$meta" >> "$PROBE_LOG"
case "$arm" in
  block)  printf 'PROBE-STDERR-K9X2 这是 hook 写给用户看的 stderr\n' >&2; exit 2 ;;
  inject) printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"PROBE-INJ-M4T7"}}\n'; exit 0 ;;
  *)      exit 0 ;;
esac
HOOK
chmod +x "$S/.claude/hooks/probe.sh"

cat > "$S/.claude/settings.json" <<'CFG'
{ "hooks": { "UserPromptSubmit": [ { "hooks": [
  { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/probe.sh" }
] } ] } }
CFG

PROMPT='请只输出这一串，不要有任何其他文字：MAGIC-7Q4Z。另外，如果你的上下文里出现了以 PROBE-INJ- 开头的串，就在第二行原样输出它；没有就在第二行输出 NONE。'
export PROBE_LOG="$S/hook.log"; : > "$PROBE_LOG"

for arm in off block inject; do
  # 🔴 必须在 $S 里跑 —— hook 挂载点是 **cwd 的** `.claude/settings.json`。
  # 初版漏了这个 cd ⇒ 读的是 lab 自己的 cwd（lab 无 settings.json ⇒ 零 hook）
  # ⇒ 三臂全裸跑、result 完全相同，而那是一个**看起来合理**的读数（模型照常作答）。
  # MH1「hook 必须被调用 3 次」当场抓到了它 —— fail-closed 起手在这里直接兑现。
  ( cd "$S" && PROBE_ARM="$arm" "$CLAUDE_BIN" -p "$PROMPT" --model haiku --output-format json \
      > "$S/out-$arm.json" 2> "$S/err-$arm.txt" </dev/null )
  # ⛔ 不读 $? 当结论：三臂的进程退出码实测全 0，它在这条判据上不携带信息。
done

python3 - "$S" "$VER" <<'PY'
import json, sys, os
S, VER = sys.argv[1], sys.argv[2]
log = open(os.path.join(S, 'hook.log')).read()
d = {a: json.load(open(os.path.join(S, f'out-{a}.json'))) for a in ('off', 'block', 'inject')}
r = {a: (d[a].get('result') or '') for a in d}

checks, fails = [], 0
def chk(kind, cid, ok, detail):
    global fails
    checks.append({'kind': kind, 'id': cid, 'ok': bool(ok), 'detail': detail})
    if not ok: fails += 1

# ── must-hit ──────────────────────────────────────────────────────────────────
chk('must-hit', 'MH1', log.count('INVOKED') == 3 and log.count('event=UserPromptSubmit') == 3,
    f'hook 必须被调用 3 次且事件名为 UserPromptSubmit —— 实测 INVOKED {log.count("INVOKED")} 次')
chk('must-hit', 'MH2', d['block'].get('num_turns') == 0,
    f'阻塞臂模型必须零调用 —— 实测 num_turns={d["block"].get("num_turns")}')
chk('must-hit', 'MH3', 'PROBE-STDERR-K9X2' in r['block'],
    'hook 的 stderr 必须到达用户可见面（result）')
chk('must-hit', 'MH4', 'PROBE-INJ-M4T7' in r['inject'],
    'additionalContext 必须真进模型上下文（模型复述出该串）')
chk('must-hit', 'MH5', 'MAGIC-7Q4Z' in r['off'] and 'NONE' in r['off'],
    '对照臂模型必须正常作答且报告无注入')
# 钉的是具名事实（`AGENTS §3.2` 推论二）：⛔ 不钉「跑通了没有」。
chk('must-hit', 'MH6', 'Original prompt:' in r['block'],
    '阻塞臂 result 必须回显 `Original prompt:` —— 这是「prompt 未被擦掉」的具名事实')

# ── must-not-hit ──────────────────────────────────────────────────────────────
chk('must-not-hit', 'MN1', d['block']['usage'].get('input_tokens') == 0,
    f'阻塞臂 ⛔ 不得有输入 token —— 实测 {d["block"]["usage"].get("input_tokens")}')
chk('must-not-hit', 'MN2', 'PROBE-INJ' not in r['off'],
    '对照臂 ⛔ 不得出现注入串（两臂串台即装置无效）')
chk('must-not-hit', 'MN3', not (r['off'] == r['block'] or r['off'] == r['inject'] or r['block'] == r['inject']),
    '三臂 result ⛔ 不得两两相等 —— 相等即装置无判别力（恒绿/恒红）')

out = {
    'env': {'claudeVersion': VER, 'claudeBin': os.environ.get('PROBE_CLAUDE_BIN') or 'command -v claude',
            'mode': 'claude -p (headless)',
            'boundary': '⛔ 未覆盖交互 TUI 模式（与「换通道」是正交的两维）；⛔ 未覆盖 ServBay 2.1.89'},
    'arms': {a: {'num_turns': d[a].get('num_turns'), 'is_error': d[a].get('is_error'),
                 'input_tokens': d[a]['usage'].get('input_tokens'),
                 'result': r[a]} for a in d},
    'hookLog': log.strip().split('\n'),
    'controls': checks,
    'controlsPassed': len(checks) - fails, 'controlsTotal': len(checks),
}
print(json.dumps(out, ensure_ascii=False, indent=2))
sys.exit(1 if fails else 0)
PY
rc=$?
# ⛔ 走 stderr —— stdout 必须是纯 JSON，否则下游 `json.load` 报 "Extra data"（初版踩过）。
echo "PROBE_DIR=$S" >&2
exit $rc
