#!/usr/bin/env bash
# Validation gate for session_workspace.sh — runs against a LOCAL bare origin
# (no writes to the real github origin). Exercises the live push-to-claim path
# AND the failure modes (adversarial-review hardening, 2026-06-03).
# NOT `set -o pipefail`: several gates pipe a by-design non-zero command (e.g.
# `create` refusing = exit 1) into grep; pipefail would mask grep's match.
set -u
SW="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/session_workspace.sh"
ROOT="$(mktemp -d)"; SBX="$ROOT/sw-test"; mkdir -p "$SBX"
ORIGIN="$SBX/origin.git"; CLONE="$SBX/clone"
export SESSIONS_ROOT="$SBX/sessions" LEDGER="$SBX/ledger/events.jsonl" HOST="testhost"
export SESSION_WS_ALLOW_ANY_REPO=1   # REPO_ROOT is a throwaway clone, not ~/CLAUDE_PROJECTS
PASS=0; FAIL=0
ok(){ echo "  OK: $1"; PASS=$((PASS+1)); }
no(){ echo "  FAIL: $1"; FAIL=$((FAIL+1)); }

git init -q --bare "$ORIGIN"
git init -q "$CLONE"
( cd "$CLONE"; git config user.email t@t; git config user.name t; echo hi>README; git add -A; \
  git commit -qm init; git branch -M main; git remote add origin "$ORIGIN"; git push -q -u origin main )
export REPO_ROOT="$CLONE"

# 1. observe: one valid event, working tree untouched, exit 0
( cd "$CLONE"; git status --porcelain > "$SBX/before" )
SID=obs bash "$SW" observe >/dev/null 2>&1 && ok "observe exits 0" || no "observe exit"
( cd "$CLONE"; git status --porcelain > "$SBX/after" )
diff -q "$SBX/before" "$SBX/after" >/dev/null && ok "observe leaves working tree unchanged" || no "observe mutated repo"
test "$(wc -l < "$LEDGER")" -eq 1 && ok "observe appended exactly one event" || no "observe event count"
python3 -c "import json;[json.loads(l) for l in open('$LEDGER')]" >/dev/null 2>&1 && ok "ledger is valid jsonl" || no "invalid jsonl"
( cd /tmp && SID=obs2 bash "$SW" observe >/dev/null 2>&1 ) && ok "observe exit 0 outside any repo" || no "observe outside repo"

# 2. first claimant creates worktree + pushes branch (lease claimed)
SID=sidA bash "$SW" create --actor claude --issue REC-91 --slug spectest >/dev/null 2>&1 && ok "create exits 0" || no "create exit"
ls -d "$SESSIONS_ROOT"/*/REC-91--claude--spectest--* >/dev/null 2>&1 && ok "worktree created outside repo" || no "worktree missing"
git -C "$CLONE" ls-remote --heads origin claude/REC-91-spectest 2>/dev/null | grep -q spectest && ok "branch pushed = lease claimed" || no "branch not pushed"

# 2b. adhoc create: issue-less branch, same push-to-claim lease contract
SID=adhocA bash "$SW" create --actor claude --adhoc --slug session-test123 >/dev/null 2>&1 && ok "adhoc create exits 0" || no "adhoc create exit"
ADHOC_WT="$(ls -d "$SESSIONS_ROOT"/*/adhoc--claude--session-test123--* 2>/dev/null | head -1)"
test -n "$ADHOC_WT" && ok "adhoc worktree created outside repo" || no "adhoc worktree missing"
git -C "$CLONE" ls-remote --heads origin claude/session-test123 2>/dev/null | grep -q session-test123 && ok "adhoc branch pushed = lease claimed" || no "adhoc branch not pushed"
git -C "$CLONE" fetch -q origin refs/heads/claude/session-test123:refs/remotes/origin/claude/session-test123
python3 - "$CLONE" <<'PY' && ok "adhoc pushed lease names this session" || no "adhoc lease did not name this session"
import json, subprocess, sys
clone = sys.argv[1]
blob = subprocess.check_output(["git", "-C", clone, "show", "origin/claude/session-test123:.session-lease.json"], text=True)
assert json.loads(blob)["sid"] == "adhocA"
PY
BEFORE_ADHOC_N="$(ls -d "$SESSIONS_ROOT"/*/adhoc--claude--session-test123--* 2>/dev/null | wc -l | tr -d ' ')"
SID=adhocA bash "$SW" create --actor claude --adhoc --slug session-test123 2>&1 | grep -qi adopt
AFTER_ADHOC_N="$(ls -d "$SESSIONS_ROOT"/*/adhoc--claude--session-test123--* 2>/dev/null | wc -l | tr -d ' ')"
test "$BEFORE_ADHOC_N" = "1" && test "$AFTER_ADHOC_N" = "1" \
  && ok "adhoc resume adopts existing worktree by branch" \
  || no "adhoc resume minted a duplicate worktree"
SID=adhocBad bash "$SW" create --actor claude --adhoc --issue REC-9 --slug foo 2>&1 | grep -qi "mutually exclusive" \
  && ok "adhoc rejects --issue as mutually exclusive" || no "adhoc accepted --issue"
SID=normal9 bash "$SW" create --actor claude --issue REC-9 --slug foo >/dev/null 2>&1 \
  && git -C "$CLONE" ls-remote --heads origin claude/REC-9-foo 2>/dev/null | grep -q REC-9-foo \
  && ok "normal create with issue still works" || no "normal create with issue regressed"

# 3. second DIFFERENT live sid refused
SID=sidB bash "$SW" create --actor claude --issue REC-91 --slug spectest >/dev/null 2>&1
test $? -ne 0 && ok "second live sid refused (nonzero exit)" || no "second sid not refused"
SID=sidB bash "$SW" create --actor claude --issue REC-91 --slug spectest 2>&1 | grep -qi refus && ok "refusal message present" || no "no refusal msg"

# 4. same sid (resume) adopts
SID=sidA bash "$SW" create --actor claude --issue REC-91 --slug spectest 2>&1 | grep -qi adopt && ok "same sid adopts on resume" || no "same sid did not adopt"

# 5. heartbeat refreshes the lease
WT=$(ls -d "$SESSIONS_ROOT"/*/REC-91--claude--spectest--* | head -1)
SID=sidA bash "$SW" heartbeat --worktree "$WT" 2>&1 | grep -qi refresh && ok "heartbeat refreshes lease" || no "heartbeat"

# 6. dirty close pushes WIP (survives handoff), removes worktree
echo "scratch" > "$WT/scratch.tmp"
SID=sidA bash "$SW" close --worktree "$WT" --reason dirty >/dev/null 2>&1 && ok "dirty close exits 0" || no "dirty close exit"
git -C "$CLONE" log origin/claude/REC-91-spectest --oneline 2>/dev/null | grep -qi WIP && ok "WIP pushed (survives handoff)" || no "WIP not pushed"

# 6b. dirty close refuses to WIP-push onto a branch held by a different live session
HOST=fhA SID=dirtyA bash "$SW" create --actor claude --issue REC-101 --slug foreign >/dev/null 2>&1
FHWT=$(ls -d "$SESSIONS_ROOT"/fhA/REC-101--claude--foreign--* | head -1)
LEASECLONE="$SBX/lease-holder"; git clone -q "$ORIGIN" "$LEASECLONE"
( cd "$LEASECLONE"; git config user.email t@t; git config user.name t; git checkout -q claude/REC-101-foreign; \
  python3 - > .session-lease.json <<'PY'
import json, time
print(json.dumps({"sid":"foreign","host":"fhB","heartbeat":"2099-01-01T00:00:00Z","expires_at":time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time()+86400))}))
PY
  git add .session-lease.json; git commit -qm foreign-lease; git push -q origin HEAD:claude/REC-101-foreign )
git -C "$FHWT" fetch -q origin claude/REC-101-foreign
git -C "$FHWT" reset -q --hard origin/claude/REC-101-foreign
echo "foreign scratch" > "$FHWT/foreign.tmp"
HOST=fhA SID=dirtyA bash "$SW" close --worktree "$FHWT" --reason dirty >/dev/null 2>&1
test $? -ne 0 && test -d "$FHWT" && ! git -C "$CLONE" log origin/claude/REC-101-foreign --oneline 2>/dev/null | grep -qi "WIP: session close dirtyA" \
  && ok "dirty close refuses foreign-holder WIP push" || no "dirty close pushed onto foreign-held branch"

# 7. close REFUSES a non-owned path (cannot touch ~/CLAUDE_PROJECTS or any non-SESSIONS_ROOT checkout)
bash "$SW" close --worktree "$CLONE" 2>&1 | grep -qi "not under SESSIONS_ROOT\|REFUSE" && ok "close refuses non-owned path" || no "close touched non-owned path"

# 8. per-branch mutex: author + reviewer-fix on the SAME issue both succeed (no deadlock)
SID=auth bash "$SW" create --actor claude --issue REC-92 --slug p2  >/dev/null 2>&1 && ok "author branch create" || no "author create"
SID=rev  bash "$SW" create --actor codex  --issue REC-92 --slug fix >/dev/null 2>&1 && ok "reviewer-fix create (NO deadlock)" || no "per-branch deadlock"
git -C "$CLONE" ls-remote --heads origin 2>/dev/null | grep -q 'REC-92-p2' \
  && git -C "$CLONE" ls-remote --heads origin 2>/dev/null | grep -q 'REC-92-fix' && ok "two disjoint live leases on one issue" || no "disjoint leases missing"
SID=other bash "$SW" create --actor claude --issue REC-92 --slug p2 2>&1 | grep -qi refus && ok "exact-branch mutex still holds" || no "exact-branch mutex broken"

# 8b. SAFE close RELEASES the lease -> a different sid can adopt immediately (handoff)
SID=relA bash "$SW" create --actor claude --issue REC-93 --slug rel >/dev/null 2>&1
RELWT=$(ls -d "$SESSIONS_ROOT"/*/REC-93--claude--rel--* | head -1)
SID=relA bash "$SW" close --worktree "$RELWT" >/dev/null 2>&1
SID=relB bash "$SW" create --actor claude --issue REC-93 --slug rel 2>&1 | grep -qi adopt && ok "safe close releases lease (next session adopts, not refused 24h)" || no "lease NOT released on safe close"

# 8bb. dirty close RELEASES the lease after confirmed WIP push -> different host/sid can adopt immediately
HOST=hA SID=relA bash "$SW" create --actor claude --issue REC-103 --slug dirtyhandoff >/dev/null 2>&1
DHWT=$(ls -d "$SESSIONS_ROOT"/hA/REC-103--claude--dirtyhandoff--* | head -1)
echo dirty >> "$DHWT/README"
HOST=hA SID=relA bash "$SW" close --worktree "$DHWT" --reason dirty >/dev/null 2>&1
DHCLOSE=$?
DHADOPT="$(HOST=hB SID=relB bash "$SW" create --actor claude --issue REC-103 --slug dirtyhandoff 2>&1)"
test "$DHCLOSE" -eq 0 \
  && printf '%s' "$DHADOPT" | grep -qi adopt \
  && git -C "$CLONE" log origin/claude/REC-103-dirtyhandoff --oneline 2>/dev/null | grep -qi "WIP: session close relA" \
  && ok "dirty close releases lease after WIP push (handoff adopts, WIP preserved)" || no "dirty close did not release lease/preserve WIP for handoff"

# 8c. safe close with origin UNREACHABLE must NOT falsely claim release — keeps the worktree
SID=rfA bash "$SW" create --actor codex --issue REC-97 --slug rf >/dev/null 2>&1
RFWT=$(ls -d "$SESSIONS_ROOT"/*/REC-97--codex--rf--* | head -1)
git -C "$CLONE" remote set-url origin /nonexistent-origin.git
SID=rfA bash "$SW" close --worktree "$RFWT" >/dev/null 2>&1
test $? -ne 0 && test -d "$RFWT" && ok "safe close keeps worktree when release push fails (no false release)" || no "safe close false-released on origin-down"
git -C "$CLONE" remote set-url origin "$ORIGIN"

# 8d. cross-host SAME-SID must NOT adopt a LIVE lease (two-writer prevention across machines)
HOST=hostX SID=samesid bash "$SW" create --actor claude --issue REC-98 --slug xh >/dev/null 2>&1
HOST=hostY SID=samesid bash "$SW" create --actor claude --issue REC-98 --slug xh 2>&1 | grep -qi refus && ok "cross-host same-SID refused on live lease (no two writers)" || no "cross-host same-SID adopted a LIVE lease (TWO WRITERS)"

# 8e. stale idempotent RESUME is refused after another host adopted the (expired) branch
LEASE_TTL_HRS=0 HOST=hA SID=sx bash "$SW" create --actor claude --issue REC-99 --slug re >/dev/null 2>&1
sleep 1
HOST=hB SID=sy bash "$SW" create --actor claude --issue REC-99 --slug re >/dev/null 2>&1   # hB adopts expired + re-claims (now live)
HOST=hA SID=sx bash "$SW" create --actor claude --issue REC-99 --slug re 2>&1 | grep -qiE "stale|refus" && ok "stale resume refused after another host took the branch (no reanimated writer)" || no "stale idempotent resume REANIMATED a writer"

# 8f. heartbeat REFUSED when a different live session holds the branch
HOST=hc SID=s1 bash "$SW" create --actor claude --issue REC-100 --slug hb >/dev/null 2>&1
HCWT=$(ls -d "$SESSIONS_ROOT"/hc/REC-100--claude--hb--* | head -1)
# a different host's worktree pointer cannot heartbeat s1's live lease
HOST=hd SID=s2 bash "$SW" create --actor claude --issue REC-100 --slug hb >/dev/null 2>&1   # refused (s1 live); no hd worktree
HOST=hd SID=s2 bash "$SW" heartbeat --worktree "$HCWT" 2>&1 | grep -qiE "REFUSE|different live" && ok "heartbeat REFUSED across holders (no two-writer reclaim)" || no "heartbeat reclaimed a live lease (TWO WRITERS)"

# 9. EXPIRED-lease adoption RE-CLAIMS the lease (sequential double-writer closed)
LEASE_TTL_HRS=0 SID=oldA bash "$SW" create --actor claude --issue REC-94 --slug exp >/dev/null 2>&1
OLDWT=$(ls -d "$SESSIONS_ROOT"/*/REC-94--claude--exp--* | head -1); git -C "$CLONE" worktree remove "$OLDWT" 2>/dev/null
sleep 1
SID=newB bash "$SW" create --actor claude --issue REC-94 --slug exp 2>&1 | grep -qi adopt && ok "expired lease adopted by new sid" || no "expired adopt"
# after newB re-claimed, a THIRD sid must be REFUSED (lease now names newB, fresh)
SID=thirdC bash "$SW" create --actor claude --issue REC-94 --slug exp 2>&1 | grep -qi refus && ok "re-claimed lease is NOT stealable (sequential double-writer closed)" || no "double-writer NOT closed"

# 9b. stale local branch ref does not wedge adopt when origin has advanced
STALE_BRANCH="claude/REC-106-staleadopt"
( cd "$CLONE"; git checkout -q main; git checkout -q -b "$STALE_BRANCH"; echo old > stale.txt; git add stale.txt; git commit -qm stale-local-base; git push -q -u origin "$STALE_BRANCH"; git checkout -q main )
STALE_CLONE="$SBX/stale-origin"; git clone -q "$ORIGIN" "$STALE_CLONE"
( cd "$STALE_CLONE"; git config user.email t@t; git config user.name t; git checkout -q "$STALE_BRANCH"; \
  echo new >> stale.txt; \
  python3 - > .session-lease.json <<'PY'
import json
print(json.dumps({"sid":"","host":"stale","heartbeat":"1970-01-01T00:00:00Z","expires_at":"1970-01-01T00:00:00Z"}))
PY
  git add -A; git commit -qm origin-advanced-free-lease; git push -q )
STALE_OUT="$(HOST=stalehost SID=staleB bash "$SW" create --actor claude --issue REC-106 --slug staleadopt 2>&1)"
STALE_RC=$?
test "$STALE_RC" -eq 0 && printf '%s\n' "$STALE_OUT" | grep -qi adopt \
  && ! printf '%s\n' "$STALE_OUT" | grep -qi "lost the adopt race" \
  && ok "stale local branch adopts from advanced origin" || no "stale local branch caused lost adopt race"

# 10. reap actually REMOVES a clean+expired worktree (non-dry)
LEASE_TTL_HRS=0 SID=reapme bash "$SW" create --actor codex --issue REC-95 --slug rp >/dev/null 2>&1
RWT=$(ls -d "$SESSIONS_ROOT"/*/REC-95--codex--rp--* | head -1); sleep 1
LIVE_WINDOW_SEC=0 SID=reaper bash "$SW" reap --ttl-hours 0 >/dev/null 2>&1
test ! -d "$RWT" && ok "reap removed clean+expired worktree" || no "reap did not remove"

# 10b. reap --ttl-hours overrides default lease expiry using heartbeat age
SID=ttlold bash "$SW" create --actor claude --issue REC-104 --slug ttlold >/dev/null 2>&1
TTLOWT=$(ls -d "$SESSIONS_ROOT"/*/REC-104--claude--ttlold--* | head -1)
python3 - "$TTLOWT/.session-lease.json" <<'PY'
import json, sys, time
path = sys.argv[1]
with open(path) as f:
    lease = json.load(f)
lease["heartbeat"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 7200))
lease["expires_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + 86400))
with open(path, "w") as f:
    json.dump(lease, f)
PY
git -C "$TTLOWT" add .session-lease.json
git -C "$TTLOWT" commit -qm old-heartbeat
git -C "$TTLOWT" push -q
SID=ttlfresh bash "$SW" create --actor claude --issue REC-105 --slug ttlfresh >/dev/null 2>&1
TTL_OUT="$(SID=ttlreaper bash "$SW" reap --ttl-hours 1 --dry-run 2>&1)"
printf '%s\n' "$TTL_OUT" | grep -q "reap: removed  claude/REC-104-ttlold" \
  && printf '%s\n' "$TTL_OUT" | grep -q "reap: kept-live  claude/REC-105-ttlfresh" \
  && ok "reap ttl-hours uses heartbeat age" || no "reap ttl-hours ignored heartbeat age"

# 10c. reap rejects non-numeric TTL
TTL_BAD_OUT="$(SID=ttlreaper bash "$SW" reap --ttl-hours notanumber 2>&1)"
TTL_BAD_RC=$?
test "$TTL_BAD_RC" -ne 0 && printf '%s\n' "$TTL_BAD_OUT" | grep -q "invalid --ttl-hours" \
  && ok "reap rejects non-numeric ttl" || no "reap accepted non-numeric ttl"

# 10d. hostile LEASE_TTL_HRS cannot execute code and fails cleanly
PWNED="/tmp/sw_pwned_$$"; rm -f "$PWNED"
HOSTILE_TTL="__import__(\"os\").system(\"touch $PWNED\")"
HOSTILE_OUT="$(LEASE_TTL_HRS="$HOSTILE_TTL" SID=pwn bash "$SW" create --actor claude --issue REC-107 --slug pwn 2>&1)"
HOSTILE_RC=$?
test "$HOSTILE_RC" -eq 2 && test ! -e "$PWNED" \
  && printf '%s\n' "$HOSTILE_OUT" | grep -q "invalid LEASE_TTL_HRS" \
  && ok "hostile LEASE_TTL_HRS rejected without code execution" || no "hostile LEASE_TTL_HRS executed or was not rejected cleanly"
rm -f "$PWNED"

# 11. invalid inputs rejected
SID=x bash "$SW" create --actor bogus --issue REC-1 --slug s 2>&1 | grep -qi "invalid --actor" && ok "rejects bad actor" || no "bad actor not rejected"
SID=x bash "$SW" create --actor claude --issue NOPE --slug s 2>&1 | grep -qi "invalid --issue" && ok "rejects bad issue" || no "bad issue not rejected"
SID=x bash "$SW" create --actor claude --issue REC-1 --slug "Bad Slug" 2>&1 | grep -qi "invalid --slug" && ok "rejects bad slug" || no "bad slug not rejected"
SID='bad/sid' bash "$SW" create --actor claude --issue REC-1 --slug s 2>&1 | grep -qi "invalid --sid" \
  && SID='bad..sid' bash "$SW" create --actor claude --issue REC-1 --slug s 2>&1 | grep -qi "invalid --sid" \
  && ok "rejects SID path traversal" || no "bad SID not rejected"

# 12. origin-unreachable FAILS CLOSED (no worktree/branch created)
git -C "$CLONE" remote set-url origin /nonexistent-origin.git
SID=down bash "$SW" create --actor claude --issue REC-96 --slug off 2>&1 | grep -qi "unreachable\|CLOSED" && ok "origin-down fails closed" || no "origin-down not fail-closed"
git -C "$CLONE" remote set-url origin "$ORIGIN"

# 13. outside-repo assertion: refuse a SESSIONS_ROOT inside the repo
SESSIONS_ROOT="$CLONE/inside" SID=x bash "$SW" create --actor claude --issue REC-90 --slug t 2>&1 | grep -qi "OUTSIDE the repo" && ok "refuses inside-repo SESSIONS_ROOT" || no "inside-repo assertion"

# 14. pr-metadata block
bash "$SW" pr-metadata --issue REC-92 --actor claude --worktree /tmp/x 2>/dev/null | grep -q "linear_issue: REC-92" && ok "pr-metadata has linear_issue" || no "pr-metadata"

# 15. converge-codex: no-upstream branch -> ABORT (never rm unpushed commits)
FAKE="$SBX/fake-codex"; git init -q "$FAKE"; ( cd "$FAKE"; git config user.email t@t; git config user.name t; echo x>f; git add -A; git commit -qm c )
CONVERGE_TARGET="$FAKE" bash "$SW" converge-codex --dry-run 2>&1 | grep -qi "no upstream\|refusing to destroy" && ok "converge-codex aborts on no-upstream (no data loss)" || no "converge-codex unsafe on no-upstream"
test -d "$FAKE/.git" && ok "converge-codex did NOT delete the no-upstream repo" || no "converge-codex DELETED unpushed repo"

# 16. ignored local files block safe-close removal
HOST=ig SID=igA bash "$SW" create --actor claude --issue REC-102 --slug ignored >/dev/null 2>&1
IGWT=$(ls -d "$SESSIONS_ROOT"/ig/REC-102--claude--ignored--* | head -1)
echo ignored.log > "$IGWT/.gitignore"
git -C "$IGWT" add .gitignore
git -C "$IGWT" commit -qm ignore-log
git -C "$IGWT" push -q
echo local > "$IGWT/ignored.log"
HOST=ig SID=igA bash "$SW" close --worktree "$IGWT" --reason dirty >/dev/null 2>&1
test $? -ne 0 && test -d "$IGWT" && ok "ignored file blocks dirty-close removal" || no "dirty close removed ignored local file"

# 17. converge-codex aborts when any local branch has commits absent from origin
FAKE2="$SBX/fake-codex-branches"; git clone -q "$ORIGIN" "$FAKE2"
( cd "$FAKE2"; git config user.email t@t; git config user.name t; git checkout -q -b local-only; echo local>local-only.txt; git add -A; git commit -qm local-only; git checkout -q main )
CONVERGE_TARGET="$FAKE2" bash "$SW" converge-codex --dry-run 2>&1 | grep -qi "local branch.*unpushed\|absent from origin\|refusing to destroy" \
  && test -d "$FAKE2/.git" && ok "converge-codex aborts on unpushed non-current branch" || no "converge-codex ignored unpushed non-current branch"

# 18. converge-codex aborts on stashed work even when tree is clean and pushed
FAKE3="$SBX/fake-codex-stash"; git clone -q "$ORIGIN" "$FAKE3"
( cd "$FAKE3"; git config user.email t@t; git config user.name t; echo stash >> README; git stash push -q -m keep-stash README )
STASH_OUT="$(CONVERGE_TARGET="$FAKE3" bash "$SW" converge-codex 2>&1)"
STASH_RC=$?
test "$STASH_RC" -eq 1 && printf '%s' "$STASH_OUT" | grep -qi "stashed work" \
  && test -d "$FAKE3/.git" && ok "converge-codex aborts on stash and keeps target" || no "converge-codex ignored stash or removed target"

# 19. SID-naming regression: a LEAKED ambient SID must NOT poison the worktree dir name.
SID=REC-88-leaked bash "$SW" create --actor codex --issue REC-98 --slug nightwatch >/dev/null 2>&1
NW="$(ls -d "$SESSIONS_ROOT"/*/REC-98--codex--nightwatch--* 2>/dev/null | head -1)"
test -n "$NW" \
  && case "$NW" in *REC-98--codex--nightwatch--*) true;; *) false;; esac \
  && ! printf '%s' "$NW" | grep -q "REC-88" \
  && ok "leaked ambient SID does not poison worktree dir name (REC-98, never REC-88)" \
  || no "leaked SID poisoned worktree dir name"

# 20. lease/ledger STILL record the explicit --sid (split preserved SID as lease identity)
SID=ignored bash "$SW" create --actor claude --issue REC-108 --slug sidsplit --sid sidA >/dev/null 2>&1
SS_WT="$(ls -d "$SESSIONS_ROOT"/*/REC-108--claude--sidsplit--* 2>/dev/null | head -1)"
# Whitespace-tolerant: session_workspace.sh writes the lease via json.dumps(...) with
# default spaces -> the file is `"sid": "sidA"`, NOT compact. Assert via JSON parse.
python3 -c "import json,sys; d=json.load(open('$SS_WT/.session-lease.json')); assert d['sid']=='sidA'" \
  && ok "explicit --sid recorded in lease (SID stays lease identity)" || no "lease lost explicit sid"

# 21. resume finds the existing worktree BY BRANCH (not recomputed path), adopts (no 2nd dir)
BEFORE_N="$(ls -d "$SESSIONS_ROOT"/*/REC-108--claude--sidsplit--* 2>/dev/null | wc -l | tr -d ' ')"
SID=ignored bash "$SW" create --actor claude --issue REC-108 --slug sidsplit --sid sidA 2>&1 | grep -qi adopt
AFTER_N="$(ls -d "$SESSIONS_ROOT"/*/REC-108--claude--sidsplit--* 2>/dev/null | wc -l | tr -d ' ')"
test "$BEFORE_N" = "1" && test "$AFTER_N" = "1" \
  && ok "resume adopts existing worktree by branch (no duplicate dir minted)" \
  || no "resume minted a duplicate worktree dir (branch-discovery broken)"

# 21b. MIGRATION: a pre-existing OLD-pattern (no fresh-id suffix) worktree is adopted BY BRANCH.
#      Simulate one by adding a worktree at the legacy dir name, then resume and assert no 2nd dir.
SID=ignored bash "$SW" create --actor claude --issue REC-118 --slug legacy --sid legA >/dev/null 2>&1
NEWWT="$(ls -d "$SESSIONS_ROOT"/*/REC-118--claude--legacy--* | head -1)"
LEGACY_DIR="$(dirname "$NEWWT")/REC-118--claude--legacy"          # old scheme: no --<freshid>
git -C "$CLONE" worktree move "$NEWWT" "$LEGACY_DIR" >/dev/null 2>&1
ADOPT_OUT="$(SID=ignored bash "$SW" create --actor claude --issue REC-118 --slug legacy --sid legA 2>&1)"
N_DIRS="$(ls -d "$SESSIONS_ROOT"/*/REC-118--claude--legacy* 2>/dev/null | wc -l | tr -d ' ')"
printf '%s' "$ADOPT_OUT" | grep -qi adopt && [ "$N_DIRS" = "1" ] \
  && ok "old-pattern worktree adopted by branch (no duplicate dir)" \
  || no "old-pattern worktree not adopted by branch (migration gap)"

# 22. safe reap removes a clean+pushed+expired session worktree (rev-list==0)
LEASE_TTL_HRS=0 SID=srclean bash "$SW" create --actor codex --issue REC-110 --slug srclean >/dev/null 2>&1
SR1="$(ls -d "$SESSIONS_ROOT"/*/REC-110--codex--srclean--* | head -1)"; sleep 1
LIVE_WINDOW_SEC=0 LEASE_TTL_HRS=0 bash "$SW" reap-one --worktree "$SR1" >/dev/null 2>&1
test ! -d "$SR1" && ok "safe reap-one removes clean+pushed worktree" || no "safe reap-one did not remove clean tree"

# 23. unpushed commit -> kept + recovery warning
SID=srunp bash "$SW" create --actor codex --issue REC-111 --slug srunp >/dev/null 2>&1
SR2="$(ls -d "$SESSIONS_ROOT"/*/REC-111--codex--srunp--* | head -1)"
( cd "$SR2"; echo x > unp.txt; git add -A; git commit -qm "unpushed local" )
OUT="$(LIVE_WINDOW_SEC=0 LEASE_TTL_HRS=0 bash "$SW" reap-one --worktree "$SR2" 2>&1)"; RC=$?
test "$RC" -ne 0 && test -d "$SR2" && printf '%s' "$OUT" | grep -qi "unpushed" \
  && ok "safe reap keeps tree with unpushed commits" || no "safe reap removed unpushed work"

# 24. modified tracked file -> kept
SID=srmod bash "$SW" create --actor codex --issue REC-112 --slug srmod >/dev/null 2>&1
SR3="$(ls -d "$SESSIONS_ROOT"/*/REC-112--codex--srmod--* | head -1)"
echo dirty >> "$SR3/README"
LIVE_WINDOW_SEC=0 LEASE_TTL_HRS=0 bash "$SW" reap-one --worktree "$SR3" >/dev/null 2>&1
test -d "$SR3" && ok "safe reap keeps tree with modified tracked file" || no "safe reap removed dirty tree"

# 25. untracked notes.md -> kept (not build-log.md)
SID=srnotes bash "$SW" create --actor codex --issue REC-113 --slug srnotes >/dev/null 2>&1
SR4="$(ls -d "$SESSIONS_ROOT"/*/REC-113--codex--srnotes--* | head -1)"
echo note > "$SR4/notes.md"
LIVE_WINDOW_SEC=0 LEASE_TTL_HRS=0 bash "$SW" reap-one --worktree "$SR4" >/dev/null 2>&1
test -d "$SR4" && ok "safe reap keeps tree with untracked notes.md" || no "safe reap removed tree with real untracked file"

# 26. untracked top-level build-log.md ONLY -> removed (disposable cruft)
SID=srbl bash "$SW" create --actor codex --issue REC-114 --slug srbl >/dev/null 2>&1
SR5="$(ls -d "$SESSIONS_ROOT"/*/REC-114--codex--srbl--* | head -1)"
echo log > "$SR5/build-log.md"
LIVE_WINDOW_SEC=0 LEASE_TTL_HRS=0 bash "$SW" reap-one --worktree "$SR5" >/dev/null 2>&1
test ! -d "$SR5" && ok "safe reap removes tree whose only cruft is build-log.md" || no "build-log.md cruft blocked safe reap"

# 27. branch outside session convention -> kept
SID=srbad bash "$SW" create --actor codex --issue REC-115 --slug srbad >/dev/null 2>&1
SR6="$(ls -d "$SESSIONS_ROOT"/*/REC-115--codex--srbad--* | head -1)"
git -C "$SR6" checkout -q -b notasession
OUT="$(bash "$SW" reap-one --worktree "$SR6" 2>&1)"
test -d "$SR6" && printf '%s' "$OUT" | grep -qi "not a session branch" \
  && ok "safe reap keeps non-session-branch worktree" || no "safe reap removed non-session branch"

# 28. reap-one REFUSES a path outside SESSIONS_ROOT (assert_owned)
bash "$SW" reap-one --worktree "$CLONE" 2>&1 | grep -qi "not under SESSIONS_ROOT\|REFUSE" \
  && ok "reap-one refuses non-owned path" || no "reap-one touched non-owned path"

# 29. PERIODIC SWEEP routes through the safe predicate: a clean+pushed+expired worktree whose ONLY
#     cruft is build-log.md must be REMOVED by `reap` (not kept-dirty). This is the periodic-sweep regression:
#     the old cmd_reap precomputed dirty and would have kept it.
SID=swbl bash "$SW" create --actor codex --issue REC-116 --slug swbl >/dev/null 2>&1
SW7="$(ls -d "$SESSIONS_ROOT"/*/REC-116--codex--swbl--* | head -1)"
echo log > "$SW7/build-log.md"; sleep 1
# Reap with a DISTINCT SID (so REC-116 is "not held by me") and ttl=0 (so it is expired). NOT dry-run:
# we assert actual removal. (ttl=0 expires every clean+pushed worktree in SESSIONS_ROOT; this is the
# last destructive test, so collateral removal of finished fixtures is harmless and the grep is specific.)
SWEEP_OUT="$(LIVE_WINDOW_SEC=0 SID=swreaper bash "$SW" reap --ttl-hours 0 2>&1)"
printf '%s\n' "$SWEEP_OUT" | grep -q "reap: removed  codex/REC-116-swbl" && test ! -d "$SW7" \
  && ok "periodic sweep removes build-log-only worktree via safe predicate" \
  || no "periodic sweep kept a build-log-only worktree (periodic-sweep regression)"

# 30. NUMERIC-ONLY branch regex: a non-numeric REC id (e.g. REC-1abc) is NOT a session branch -> kept.
#     Proves the safe predicate uses `[[ =~ ^(claude|codex)/REC-[0-9]+- ]]`, not a loose glob.
SID=swre bash "$SW" create --actor codex --issue REC-117 --slug swre >/dev/null 2>&1
SW8="$(ls -d "$SESSIONS_ROOT"/*/REC-117--codex--swre--* | head -1)"
git -C "$SW8" checkout -q -b codex/REC-1abc-bogus
OUT="$(bash "$SW" reap-one --worktree "$SW8" 2>&1)"
test -d "$SW8" && printf '%s' "$OUT" | grep -qi "not a session branch" \
  && ok "safe predicate rejects non-numeric REC id (strict regex)" \
  || no "safe predicate accepted a non-numeric REC id (glob too loose)"

# 31. fresh live lease -> kept even when clean+pushed (terminal/stale run record is not enough proof)
SID=srlive bash "$SW" create --actor codex --issue REC-118 --slug srlive >/dev/null 2>&1
SW9="$(ls -d "$SESSIONS_ROOT"/*/REC-118--codex--srlive--* | head -1)"
OUT="$(SID=otherreaper bash "$SW" reap-one --worktree "$SW9" 2>&1)"; RC=$?
test "$RC" -ne 0 && test -d "$SW9" && printf '%s' "$OUT" | grep -qi "held-live\|heartbeat fresh" \
  && ok "safe reap keeps worktree with fresh live lease" \
  || no "safe reap removed a fresh-live leased worktree"

# 32. fetch failure -> kept (cannot confirm pushed)
SID=srfetch bash "$SW" create --actor codex --issue REC-119 --slug srfetch >/dev/null 2>&1
SW10="$(ls -d "$SESSIONS_ROOT"/*/REC-119--codex--srfetch--* | head -1)"
ORIGIN_URL="$(git -C "$CLONE" remote get-url origin)"
git -C "$CLONE" remote set-url origin "$SBX/missing-origin.git"
OUT="$(LIVE_WINDOW_SEC=0 LEASE_TTL_HRS=0 bash "$SW" reap-one --worktree "$SW10" 2>&1)"; RC=$?
git -C "$CLONE" remote set-url origin "$ORIGIN_URL"
test "$RC" -ne 0 && test -d "$SW10" && printf '%s' "$OUT" | grep -qi "fetch .*failed\|cannot confirm pushed" \
  && ok "safe reap keeps worktree when fetch cannot confirm pushed" \
  || no "safe reap removed when fetch failed"

# 33. periodic sweep preserves the safe_reap failure reason (not generic kept-dirty)
SID=swreason bash "$SW" create --actor codex --issue REC-120 --slug swreason >/dev/null 2>&1
SW11="$(ls -d "$SESSIONS_ROOT"/*/REC-120--codex--swreason--* | head -1)"
( cd "$SW11"; echo local > reason.txt; git add -A; git commit -qm "local unpushed" )
OUT="$(LIVE_WINDOW_SEC=0 SID=reasonreaper bash "$SW" reap --ttl-hours 0 2>&1)"
test -d "$SW11" && printf '%s' "$OUT" | grep -q "reap: kept-unsafe  codex/REC-120-swreason" \
  && printf '%s' "$OUT" | grep -qi "unpushed commits" \
  && ok "periodic sweep preserves safe_reap reason for unsafe keep" \
  || no "periodic sweep collapsed unsafe keep reason"

# 34. close-on-completion reaps a QUIET (heartbeat older than LIVE_WINDOW) clean+pushed worktree
#     at the DEFAULT 24h LEASE_TTL_HRS — proves the live-lease guard keys on the short activity
#     window, NOT the reap TTL. (With the old `hbeat + LEASE_TTL_HRS*3600 > now` guard this worktree
#     would be wrongly KEPT for ~24h, defeating close-on-completion. No TTL/window overrides here.)
SID=srquiet bash "$SW" create --actor codex --issue REC-121 --slug srquiet >/dev/null 2>&1
SW12="$(ls -d "$SESSIONS_ROOT"/*/REC-121--codex--srquiet--* | head -1)"
python3 - "$SW12/.session-lease.json" <<'PY'
import json, sys, time
p = sys.argv[1]
with open(p) as f: lease = json.load(f)
# heartbeat 700s ago: older than LIVE_WINDOW_SEC(600) but far younger than LEASE_TTL_HRS(24h)
lease["heartbeat"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 700))
with open(p, "w") as f: json.dump(lease, f)
PY
git -C "$SW12" add .session-lease.json; git -C "$SW12" commit -qm quiet-heartbeat; git -C "$SW12" push -q
SID=quietreaper bash "$SW" reap-one --worktree "$SW12" >/dev/null 2>&1
test ! -d "$SW12" \
  && ok "reap-one reaps a quiet (>LIVE_WINDOW) clean+pushed worktree at default 24h TTL" \
  || no "reap-one kept a quiet completed worktree (live-guard wrongly using the 24h reap TTL)"

echo "--------"; echo "PASS=$PASS  FAIL=$FAIL"
rm -rf "$ROOT"
[ "$FAIL" -eq 0 ]
