#!/usr/bin/env bash
# Zero-dep regression tests for run-plan-lib.sh. Builds throwaway git repos in a temp dir,
# asserts each meta-gate FIRES on its bad input and STAYS CLEAN on good input. Each assert
# encodes a real failure the gate exists to catch — green here = the regression is locked.
# Run: bash test-run-plan-lib.sh   (exit 0 = all pass)
set -uo pipefail
LIB="$(cd "$(dirname "$0")" && pwd)/run-plan-lib.sh"
PASS=0; FAIL=0
ok()   { PASS=$((PASS+1)); printf '  ok   %s\n' "$1"; }
bad()  { FAIL=$((FAIL+1)); printf '  FAIL %s\n     %s\n' "$1" "$2"; }

# new throwaway repo; echoes its path. arg: name
mkrepo() {
  local d; d=$(mktemp -d "/tmp/rpl-test-$1-XXXX")
  git -C "$d" init -q
  git -C "$d" config user.email t@t.t; git -C "$d" config user.name t
  git -C "$d" config commit.gpgsign false
  : > "$d/.run-warns"   # gate0-ran-with-zero-warnings baseline; no-warn tests override per-case
  printf '%s' "$d"
}
# assert json field via python (present everywhere here); $1 json $2 jq-ish path expr (python) $3 expected $4 label
jget() { printf '%s' "$1" | python3 -c "import sys,json;d=json.load(sys.stdin);print($2)"; }

run() { bash "$LIB" "$@"; }

# helper: commit a file with content, return sha. $1 repo $2 path $3 content $4 msg
commitf() {
  local r=$1 p=$2; mkdir -p "$r/$(dirname "$p")"
  printf '%s\n' "$3" > "$r/$p"
  git -C "$r" add "$p"
  git -C "$r" commit -q -m "$4"
  git -C "$r" rev-parse HEAD
}

echo "metagate tests:"

# 1. CLEAN: a normal code change → pass=true, zero violations
r=$(mkrepo clean); base=$(commitf "$r" src/a.js "export const a=1" "seed")
head=$(commitf "$r" src/a.js "export const a=2" "feat: bump a")
j=$(run metagate "$r" "$base" "$head")
[[ "$(jget "$j" 'd["pass"]')" == "True" && "$(jget "$j" 'len(d["violations"])')" == "0" ]] \
  && ok "clean diff passes" || bad "clean diff passes" "$j"
rm -rf "$r"

# 2. no-coauthor: commit trailer fires
r=$(mkrepo coauthor); base=$(commitf "$r" src/a.js "x" "seed")
printf 'y\n' > "$r/src/a.js"; git -C "$r" add src/a.js
git -C "$r" commit -q --no-verify -m $'feat: thing\n\nCo-Authored-By: Bot <b@b>'
head=$(git -C "$r" rev-parse HEAD)
j=$(run metagate "$r" "$base" "$head")
[[ "$(jget "$j" 'd["pass"]')" == "False" && "$(jget "$j" 'any(v["gate"]=="no-coauthor" for v in d["violations"])')" == "True" ]] \
  && ok "no-coauthor fires" || bad "no-coauthor fires" "$j"
rm -rf "$r"

# 3. no-stub: TODO/placeholder in code file fires; but NOT in a .md/spec file
r=$(mkrepo stub); base=$(commitf "$r" src/a.js "x" "seed")
head=$(commitf "$r" src/a.js "// TODO: not implemented placeholder" "feat: stub")
j=$(run metagate "$r" "$base" "$head")
[[ "$(jget "$j" 'any(v["gate"]=="no-stub" for v in d["violations"])')" == "True" ]] \
  && ok "no-stub fires on code file" || bad "no-stub fires on code file" "$j"
# same marker in a doc → must NOT fire
r2=$(mkrepo stubmd); b2=$(commitf "$r2" docs/x.md "seed" "seed")
h2=$(commitf "$r2" docs/x.md "TODO: not implemented placeholder" "docs")
j2=$(run metagate "$r2" "$b2" "$h2")
[[ "$(jget "$j2" 'any(v["gate"]=="no-stub" for v in d["violations"])')" == "False" ]] \
  && ok "no-stub ignores .md" || bad "no-stub ignores .md" "$j2"
rm -rf "$r" "$r2"

# 4. no-conflict-marker fires
r=$(mkrepo conflict); base=$(commitf "$r" src/a.js "x" "seed")
head=$(commitf "$r" src/a.js $'<<<<<<< HEAD\na\n=======\nb\n>>>>>>> other' "feat: merge cruft")
j=$(run metagate "$r" "$base" "$head")
[[ "$(jget "$j" 'any(v["gate"]=="no-conflict-marker" for v in d["violations"])')" == "True" ]] \
  && ok "no-conflict-marker fires" || bad "no-conflict-marker fires" "$j"
rm -rf "$r"

# 5. no-secret: real opaque literal fires; env-ref + placeholder do NOT (allowlist); secret never echoed
r=$(mkrepo secret); base=$(commitf "$r" src/a.js "x" "seed")
head=$(commitf "$r" src/a.js 'const apiKey = "A8s9Kf02Lm44ZxQ1pR7tVw"' "feat: leak")
j=$(run metagate "$r" "$base" "$head")
fired=$(jget "$j" 'any(v["gate"]=="no-secret" for v in d["violations"])')
leaked=$(printf '%s' "$j" | grep -c 'A8s9Kf02Lm44ZxQ1pR7tVw' || true)
[[ "$fired" == "True" && "$leaked" == "0" ]] \
  && ok "no-secret fires + does not echo the secret" || bad "no-secret fires + does not echo" "$j leaked=$leaked"
# allowlisted: env ref + placeholder → must NOT fire
r2=$(mkrepo secretok); b2=$(commitf "$r2" src/a.js "x" "seed")
h2=$(commitf "$r2" src/a.js $'const apiKey = process.env.API_KEY\nconst pw = "your-password-changeme"' "feat: safe")
j2=$(run metagate "$r2" "$b2" "$h2")
[[ "$(jget "$j2" 'any(v["gate"]=="no-secret" for v in d["violations"])')" == "False" ]] \
  && ok "no-secret allowlist (env/placeholder) clean" || bad "no-secret allowlist clean" "$j2"
rm -rf "$r" "$r2"

# 6. no-warn: DETERMINISTIC allowlist gate (gate0 wrote $wt/.run-warns; $wt/.warnignore justifies benign).
r=$(mkrepo warn); base=$(commitf "$r" src/a.js "x" "seed"); head=$(commitf "$r" src/a.js "y" "feat")
warns_fire() { jget "$1" 'any(v["gate"]=="no-warn" for v in d["violations"])'; }
# 6a. zero warnings (empty .run-warns) → no-warn does NOT fire
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "False" ]] && ok "no-warn clean when .run-warns empty" || bad "no-warn empty clean" "$j"
# 6b. a warning + NO allowlist → blocks (nothing justified)
printf '%s\n' "src/x.ts(3,1): warning: 'eval' deprecated" > "$r/.run-warns"
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "True" ]] && ok "no-warn blocks unjustified warning" || bad "no-warn blocks unjustified" "$j"
# 6c. same warning matched by a .warnignore pattern → passes (explicitly justified benign)
printf '%s\n' '# benign: upstream pglite eval, not ours' "warning: 'eval' deprecated" > "$r/.warnignore"
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "False" ]] && ok "no-warn passes allowlisted warning" || bad "no-warn allowlist pass" "$j"
# 6d. a SECOND, non-allowlisted warning still blocks (allowlist is not a blanket mute)
printf '%s\n' "src/y.ts(9,2): warning: real new unused var" >> "$r/.run-warns"
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "True" ]] && ok "no-warn blocks residual non-allowlisted warning" || bad "no-warn residual block" "$j"
# 6e. MISSING .run-warns → fail-closed (gate0 handoff failure must never silently pass)
rm -f "$r/.run-warns"
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "True" ]] && ok "no-warn fail-closed on missing .run-warns" || bad "no-warn missing handoff" "$j"
# 6f. invalid .warnignore regex → fail-closed (cannot prove benign)
printf '%s\n' "warning: anything" > "$r/.run-warns"; printf '%s\n' '[' > "$r/.warnignore"
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "True" ]] && ok "no-warn fail-closed on bad .warnignore regex" || bad "no-warn bad regex" "$j"
# 6g. ANTI-RUBBER-STAMP: a pattern with NO '#' justification line above it is IGNORED → warning still blocks
printf '%s\n' "src/x.ts(3,1): warning: 'eval' deprecated" > "$r/.run-warns"
printf '%s\n' "warning: 'eval' deprecated" > "$r/.warnignore"   # bare pattern, no justification comment
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "True" ]] && ok "no-warn ignores unjustified (no-#) suppression" || bad "no-warn requires justification" "$j"
# 6h. ANTI-RUBBER-STAMP: a catch-all pattern is REJECTED even WITH a justification (cannot blanket-mute)
printf '%s\n' '# lazy: silence everything' 'warning:.*' > "$r/.warnignore"
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "True" ]] && ok "no-warn rejects over-broad catch-all pattern" || bad "no-warn rejects catch-all" "$j"
# 6i. a justified + specific pattern still passes (the guards don't block legitimate suppression)
printf '%s\n' '# upstream pglite ships eval() in its wasm loader — unfixable here' "warning: 'eval' deprecated" > "$r/.warnignore"
j=$(run metagate "$r" "$base" "$head")
[[ "$(warns_fire "$j")" == "False" ]] && ok "no-warn passes justified+specific suppression" || bad "no-warn legit suppression pass" "$j"
rm -rf "$r"

# 7. no-amend: zero new commits in range fires (base==head)
r=$(mkrepo amend); base=$(commitf "$r" src/a.js "x" "seed")
j=$(run metagate "$r" "$base" "$base")
[[ "$(jget "$j" 'any(v["gate"]=="no-amend" for v in d["violations"])')" == "True" ]] \
  && ok "no-amend fires on empty range" || bad "no-amend fires on empty range" "$j"
rm -rf "$r"

# ── jsonl_set_task (sourced directly) ────────────────────────────────────────
echo
echo "jsonl_set_task tests:"
source "$LIB"   # source guard means dispatch does NOT run

jdir=$(mktemp -d /tmp/rpl-jsonl-XXXX)
jf="$jdir/p.jsonl"
# include a Hebrew desc + an unparseable line to prove both are preserved untouched
printf '%s\n' \
  '{"type":"meta","slug":"s"}' \
  '{"type":"task","id":"w1.t0","wave":1,"desc":"שלום world","status":"PENDING","deps":[]}' \
  '{"type":"task","id":"w1.t1","wave":1,"desc":"second","status":"PENDING","deps":[]}' \
  'NOT JSON — must survive verbatim' > "$jf"
orig=$(cat "$jf")

jsonl_set_task "$jf" "w1.t0" "WORKING" "1700000000" 2>/dev/null
# target updated, lease set, Hebrew intact
t0=$(grep '"id":"w1.t0"' "$jf")
[[ "$t0" == *'"status":"WORKING"'* && "$t0" == *'"lease_ts":1700000000'* && "$t0" == *'שלום world'* ]] \
  && ok "jsonl set WORKING+lease, Hebrew preserved" || bad "jsonl set WORKING+lease" "$t0"
# sibling task + meta + non-json line byte-identical
[[ "$(grep '"id":"w1.t1"' "$jf")" == '{"type":"task","id":"w1.t1","wave":1,"desc":"second","status":"PENDING","deps":[]}' \
   && "$(grep '^NOT JSON' "$jf")" == 'NOT JSON — must survive verbatim' \
   && "$(grep '"type":"meta"' "$jf")" == '{"type":"meta","slug":"s"}' ]] \
  && ok "jsonl leaves other lines byte-identical" || bad "jsonl other lines identical" "$(cat "$jf")"
# line count unchanged
[[ "$(wc -l < "$jf")" == "4" ]] && ok "jsonl line count stable" || bad "jsonl line count" "$(wc -l < "$jf")"
# DROP removes lease_ts and sets COMMITTED
jsonl_set_task "$jf" "w1.t0" "COMMITTED" "DROP" 2>/dev/null
t0=$(grep '"id":"w1.t0"' "$jf")
[[ "$t0" == *'"status":"COMMITTED"'* && "$t0" != *'lease_ts'* ]] \
  && ok "jsonl DROP removes lease_ts + COMMITTED" || bad "jsonl DROP" "$t0"
# missing id => fail, NO write (file unchanged)
before=$(cat "$jf")
if jsonl_set_task "$jf" "nope" "WORKING" "1" 2>/dev/null; then bad "jsonl missing-id fails" "exit 0"; else
  [[ "$(cat "$jf")" == "$before" ]] && ok "jsonl missing-id fails, file untouched" || bad "jsonl missing-id untouched" "changed"
fi
rm -rf "$jdir"

# ── setup ────────────────────────────────────────────────────────────────────
echo
echo "setup tests:"
# build a repo with an origin so origin/HEAD resolves; declare a base branch carrying a marker file
sup=$(mktemp -d /tmp/rpl-setup-XXXX)
( git -C "$sup" init -q -b main; git -C "$sup" config user.email t@t.t; git -C "$sup" config user.name t; git -C "$sup" config commit.gpgsign false
  mkdir -p "$sup/pkg"; echo dep > "$sup/pkg/dep.txt"; git -C "$sup" add pkg/dep.txt; git -C "$sup" commit -q -m seed ) >/dev/null 2>&1
j=$(bash "$LIB" setup "$sup" "plan/s" "main" "s")
intwt=$(jget "$j" 'd["detail"]')
[[ "$(jget "$j" 'd["ok"]')" == "True" && -d "$intwt" && -f "$intwt/pkg/dep.txt" ]] \
  && ok "setup creates integration worktree carrying declared base's files" || bad "setup creates intWt" "$j"
# re-run is idempotent (reuse, still ok)
j2=$(bash "$LIB" setup "$sup" "plan/s" "main" "s")
[[ "$(jget "$j2" 'd["ok"]')" == "True" ]] && ok "setup idempotent on re-run" || bad "setup idempotent" "$j2"
# unresolvable declared base => fail-closed, ok=false, no crash
j3=$(bash "$LIB" setup "$sup" "plan/s2" "origin/does-not-exist" "s2")
[[ "$(jget "$j3" 'd["ok"]')" == "False" ]] && ok "setup fails closed on unresolvable base" || bad "setup unresolvable base" "$j3"
git -C "$sup" worktree remove "$intwt" --force >/dev/null 2>&1; rm -rf "$sup"

# ── lease (incl. THE critical preserve-committed-work test) ───────────────────
echo
echo "lease tests:"
mk_int() { # build repo + integration branch with one seed commit; echo repo path
  local d; d=$(mktemp -d /tmp/rpl-lease-XXXX)
  git -C "$d" init -q -b main; git -C "$d" config user.email t@t.t; git -C "$d" config user.name t; git -C "$d" config commit.gpgsign false
  echo base > "$d/f.txt"; git -C "$d" add f.txt; git -C "$d" commit -q -m seed
  git -C "$d" branch "plan/s" main
  printf '%s' "$d"
}
# fresh lease: no task branch -> created off integration, resumed=false
R=$(mk_int)
j=$(bash "$LIB" lease "$R" "plan/s" "s" "t0" "")
wt=$(jget "$j" 'd["detail"]')
[[ "$(jget "$j" 'd["ok"]')" == "True" && "$(jget "$j" 'd["resumed"]')" == "False" \
   && "$(git -C "$R" rev-parse --verify refs/heads/plan/s--t0 2>/dev/null)" != "" ]] \
  && ok "lease fresh creates task branch off integration, resumed=false" || bad "lease fresh" "$j"
git -C "$R" worktree remove "$wt" --force >/dev/null 2>&1; rm -rf "$R"

# CRITICAL: task branch EXISTS and is AHEAD of integration with a committed fix.
# lease must REUSE at the task-branch head (preserve the fix), NOT reset to integration.
R=$(mk_int)
git -C "$R" worktree add -b plan/s--t0 /tmp/rpl-ahead-wt "plan/s" >/dev/null 2>&1
echo "THE FIX" > /tmp/rpl-ahead-wt/fix.txt
git -C /tmp/rpl-ahead-wt add fix.txt; git -C /tmp/rpl-ahead-wt commit -q -m "committed fix"
fixsha=$(git -C /tmp/rpl-ahead-wt rev-parse HEAD)
git -C "$R" worktree remove /tmp/rpl-ahead-wt --force >/dev/null 2>&1   # simulate prior run ended, wt gone
j=$(bash "$LIB" lease "$R" "plan/s" "s" "t0" "")
wt=$(jget "$j" 'd["detail"]')
head_after=$(git -C "$wt" rev-parse HEAD 2>/dev/null)
[[ "$head_after" == "$fixsha" && -f "$wt/fix.txt" && "$(jget "$j" 'd["resumed"]')" == "True" ]] \
  && ok "lease PRESERVES committed work on resume (does NOT reset to integration)" \
  || bad "lease preserves committed work" "head_after=$head_after fixsha=$fixsha j=$j"
git -C "$R" worktree remove "$wt" --force >/dev/null 2>&1; rm -rf "$R"

# CRITICAL TWIN: crash-resume — worktree STILL ON DISK (registered) + task branch ahead.
# This exercises the wt-PRESENT path: `checkout -f <taskBranch>` then `reset --hard <taskBranch>` —
# the exact line holding the reset-target invariant. If that target ever flips to integration, the
# committed fix is destroyed; this test must catch it. (wt path is fixed: /tmp/wt-s-t0.)
git -C "$(mk_int)" >/dev/null 2>&1 || true
R=$(mk_int)
git -C "$R" worktree add -b plan/s--t0 /tmp/wt-s-t0 "plan/s" >/dev/null 2>&1
echo "THE FIX" > /tmp/wt-s-t0/fix.txt
git -C /tmp/wt-s-t0 add fix.txt; git -C /tmp/wt-s-t0 commit -q -m "committed fix"
fixsha=$(git -C /tmp/wt-s-t0 rev-parse HEAD)
# leave wt in place — simulate a crashed run resuming
j=$(bash "$LIB" lease "$R" "plan/s" "s" "t0" "")
wt=$(jget "$j" 'd["detail"]')
head_after=$(git -C "$wt" rev-parse HEAD 2>/dev/null)
[[ "$head_after" == "$fixsha" && -f "$wt/fix.txt" && "$(jget "$j" 'd["resumed"]')" == "True" ]] \
  && ok "lease wt-PRESENT resume preserves committed work (reset target = task branch)" \
  || bad "lease wt-present preserves committed work" "head_after=$head_after fixsha=$fixsha j=$j"
git -C "$R" worktree remove /tmp/wt-s-t0 --force >/dev/null 2>&1; rm -rf "$R"

# ── commit (clean merge + conflict-deletes-nothing + wrong-branch refusal) ────
echo
echo "commit tests:"
mk_commit_repo() { # repo with integration worktree on plan/s + a task branch with a non-overlapping change
  local d; d=$(mktemp -d /tmp/rpl-commit-XXXX)
  git -C "$d" init -q -b main; git -C "$d" config user.email t@t.t; git -C "$d" config user.name t; git -C "$d" config commit.gpgsign false
  echo base > "$d/f.txt"; git -C "$d" add f.txt; git -C "$d" commit -q -m seed
  git -C "$d" branch "plan/s" main
  git -C "$d" worktree add "$d/.wt-s-int" "plan/s" >/dev/null 2>&1
  git -C "$d" worktree add -b plan/s--t0 "/tmp/wt-s-t0" "plan/s" >/dev/null 2>&1
  printf '%s' "$d"
}
# clean: non-overlapping file on task branch -> merge, COMMITTED, branch+wt deleted
R=$(mk_commit_repo)
echo new > /tmp/wt-s-t0/g.txt; git -C /tmp/wt-s-t0 add g.txt; git -C /tmp/wt-s-t0 commit -q -m "task work"
jdir=$(mktemp -d); jf="$jdir/p.jsonl"; printf '%s\n' '{"type":"task","id":"t0","wave":1,"desc":"d","status":"WORKING","deps":[],"lease_ts":1}' > "$jf"
j=$(bash "$LIB" commit "$R" "$R/.wt-s-int" "plan/s" "s" "t0" "$jf")
[[ "$(jget "$j" 'd["committed"]')" == "True" \
   && "$(git -C "$R" rev-parse --verify refs/heads/plan/s--t0 2>/dev/null)" == "" \
   && -f "$R/.wt-s-int/g.txt" \
   && "$(grep '"id":"t0"' "$jf")" == *'"status":"COMMITTED"'* && "$(grep '"id":"t0"' "$jf")" != *lease_ts* ]] \
  && ok "commit clean: merges, marks COMMITTED, deletes branch" || bad "commit clean" "$j"
git -C "$R" worktree remove "$R/.wt-s-int" --force >/dev/null 2>&1; rm -rf "$R" "$jdir"

# conflict: task branch edits the SAME file/line as integration advanced -> abort, delete NOTHING
R=$(mk_commit_repo)
echo "task version" > /tmp/wt-s-t0/f.txt; git -C /tmp/wt-s-t0 add f.txt; git -C /tmp/wt-s-t0 commit -q -m "task edits f"
# advance integration to conflict on the same file
echo "int version" > "$R/.wt-s-int/f.txt"; git -C "$R/.wt-s-int" add f.txt; git -C "$R/.wt-s-int" commit -q -m "int edits f"
j=$(bash "$LIB" commit "$R" "$R/.wt-s-int" "plan/s" "s" "t0" "")
[[ "$(jget "$j" 'd["committed"]')" == "False" \
   && "$(git -C "$R" rev-parse --verify refs/heads/plan/s--t0 2>/dev/null)" != "" \
   && -d "/tmp/wt-s-t0" ]] \
  && ok "commit conflict: aborts, deletes NOTHING (branch+wt survive)" || bad "commit conflict deletes nothing" "$j"
git -C "$R" worktree remove "$R/.wt-s-int" --force >/dev/null 2>&1
git -C "$R" worktree remove "/tmp/wt-s-t0" --force >/dev/null 2>&1; rm -rf "$R"

# wrong-branch intWt: integration worktree checked out on a DIFFERENT branch -> refuse to merge
R=$(mk_commit_repo)
git -C "$R/.wt-s-int" checkout -q -b somethingelse
echo new > /tmp/wt-s-t0/g.txt; git -C /tmp/wt-s-t0 add g.txt; git -C /tmp/wt-s-t0 commit -q -m "task work"
j=$(bash "$LIB" commit "$R" "$R/.wt-s-int" "plan/s" "s" "t0" "")
[[ "$(jget "$j" 'd["committed"]')" == "False" && "$(git -C "$R" rev-parse --verify refs/heads/plan/s--t0 2>/dev/null)" != "" ]] \
  && ok "commit refuses when intWt on wrong branch" || bad "commit wrong-branch refusal" "$j"
git -C "$R" worktree remove "$R/.wt-s-int" --force >/dev/null 2>&1
git -C "$R" worktree remove "/tmp/wt-s-t0" --force >/dev/null 2>&1; rm -rf "$R"

# idempotent re-run: first commit succeeds (branch deleted, jsonl COMMITTED); a second commit with the
# SAME args (chain re-ran the executor after a lost relay) must return committed:true, NOT false-HALT on
# "task branch missing". Proves the multi-tier commit chain is safe to double-run.
R=$(mk_commit_repo)
echo new > /tmp/wt-s-t0/g.txt; git -C /tmp/wt-s-t0 add g.txt; git -C /tmp/wt-s-t0 commit -q -m "task work"
jdir=$(mktemp -d); jf="$jdir/p.jsonl"; printf '%s\n' '{"type":"task","id":"t0","wave":1,"desc":"d","status":"WORKING","deps":[],"lease_ts":1}' > "$jf"
j1=$(bash "$LIB" commit "$R" "$R/.wt-s-int" "plan/s" "s" "t0" "$jf")
j2=$(bash "$LIB" commit "$R" "$R/.wt-s-int" "plan/s" "s" "t0" "$jf")
[[ "$(jget "$j1" 'd["committed"]')" == "True" && "$(jget "$j2" 'd["committed"]')" == "True" \
   && "$(jget "$j2" 'd["detail"]')" == *"idempotent"* ]] \
  && ok "commit idempotent re-run returns committed:true (no false-HALT)" || bad "commit idempotent re-run" "j1=$j1 j2=$j2"
git -C "$R" worktree remove "$R/.wt-s-int" --force >/dev/null 2>&1; rm -rf "$R" "$jdir"

# ── financegate (hermetic: fake detector injected via $GATE_CMD) ──────────────
echo
echo "financegate tests:"
# Fake gate.py: decides emit by target basename. Proves the aggregation + fail-closed branches
# WITHOUT a real opus call. *crash* => exits non-zero, writes NO emit (absent). *money* => one
# finding. *degrade* => degraded status, no findings. else => empty findings (clean).
FAKE=$(mktemp /tmp/rpl-fakegate-XXXX.sh)
cat > "$FAKE" <<'FG'
#!/usr/bin/env bash
target=$1; shift; emit=""
while [[ $# -gt 0 ]]; do case "$1" in --emit) emit=$2; shift 2;; *) shift;; esac; done
b=$(basename "$target")
case "$b" in
  *crash*) exit 1 ;;                                                   # absent emit on purpose
  *money*) printf '%s' '{"detector":"finance","status":"ok","findings":[{"rule_id":"finance","level":"error","message":"money rounds before tax","file":"'"$b"'","line":12}],"coverage":{"scanned":["'"$b"'"],"unresolved":[]}}' > "$emit" ;;
  *degrade*) printf '%s' '{"detector":"finance","status":"degraded","findings":[],"coverage":{"scanned":["'"$b"'"],"unresolved":["dep x: dropped"]}}' > "$emit" ;;
  *) printf '%s' '{"detector":"finance","status":"ok","findings":[],"coverage":{"scanned":["'"$b"'"],"unresolved":[]}}' > "$emit" ;;
esac
FG
chmod +x "$FAKE"
runfg() { GATE_CMD="$FAKE" bash "$LIB" financegate "$@"; }

# 1. CLEAN: a normal code file the detector clears → clean=true, zero findings
r=$(mkrepo finclean); base=$(commitf "$r" src/calc.ts "export const a=1" "seed")
head=$(commitf "$r" src/calc.ts "export const a=2" "feat: bump")
j=$(runfg "$r" "$base" "$head" s t0)
[[ "$(jget "$j" 'd["clean"]')" == "True" && "$(jget "$j" 'len(d["findings"])')" == "0" ]] \
  && ok "financegate clean: empty-findings emit → clean, no findings" || bad "financegate clean" "$j"
rm -rf "$r"

# 2. FINDING: detector flags a money file → clean=false, mapped {file,issue=message,severity=level}
r=$(mkrepo finmoney); base=$(commitf "$r" src/money.ts "export const p=1" "seed")
head=$(commitf "$r" src/money.ts "export const p=2" "feat: pricing")
j=$(runfg "$r" "$base" "$head" s t0)
[[ "$(jget "$j" 'd["clean"]')" == "False" \
   && "$(jget "$j" 'len(d["findings"])')" == "1" \
   && "$(jget "$j" 'd["findings"][0]["issue"]')" == "money rounds before tax" \
   && "$(jget "$j" 'd["findings"][0]["severity"]')" == "error" ]] \
  && ok "financegate finding: maps message→issue, level→severity, clean=false" || bad "financegate finding" "$j"
rm -rf "$r"

# 3. CRASH (absent emit) → coverage failure, NOT silent-clean. THE divergence-from-prose test.
r=$(mkrepo fincrash); base=$(commitf "$r" src/crashy.ts "export const a=1" "seed")
head=$(commitf "$r" src/crashy.ts "export const a=2" "feat: x")
j=$(runfg "$r" "$base" "$head" s t0)
[[ "$(jget "$j" 'd["clean"]')" == "False" \
   && "$(jget "$j" 'len(d["findings"])')" == "1" \
   && "$(jget "$j" 'd["findings"][0]["severity"]')" == "error" \
   && "$(jget "$j" '"coverage failure" in d["findings"][0]["issue"]')" == "True" ]] \
  && ok "financegate absent-emit → coverage failure (no false-clean on crash)" || bad "financegate absent=failure" "$j"
rm -rf "$r"

# 4. DEGRADED: detector ran but coverage incomplete → coverage finding (warn), clean=false
r=$(mkrepo findeg); base=$(commitf "$r" src/degrade.ts "export const a=1" "seed")
head=$(commitf "$r" src/degrade.ts "export const a=2" "feat: x")
j=$(runfg "$r" "$base" "$head" s t0)
[[ "$(jget "$j" 'd["clean"]')" == "False" \
   && "$(jget "$j" 'any(f["severity"]=="warn" and "degraded" in f["issue"] for f in d["findings"])')" == "True" ]] \
  && ok "financegate degraded status → coverage finding, clean=false" || bad "financegate degraded" "$j"
rm -rf "$r"

# 5. DELETION SKIP (negative proof): a DELETED money file must NOT be scanned → no false positive.
#    Named *money* so if the deletion guard were absent it WOULD produce a finding.
r=$(mkrepo findel); base=$(commitf "$r" src/moneygone.ts "export const p=1" "seed")
git -C "$r" rm -q src/moneygone.ts; git -C "$r" commit -q -m "remove pricing"
head=$(git -C "$r" rev-parse HEAD)
j=$(runfg "$r" "$base" "$head" s t0)
[[ "$(jget "$j" 'd["clean"]')" == "True" && "$(jget "$j" 'len(d["findings"])')" == "0" ]] \
  && ok "financegate skips deletions (no false positive on removed code file)" || bad "financegate deletion skip" "$j"
rm -rf "$r"

# 6. TEST/SPEC SKIP: a *.test.ts money file is out of scope → not scanned, clean
r=$(mkrepo fintest); base=$(commitf "$r" src/money.test.ts "export const p=1" "seed")
head=$(commitf "$r" src/money.test.ts "export const p=2" "feat: x")
j=$(runfg "$r" "$base" "$head" s t0)
[[ "$(jget "$j" 'd["clean"]')" == "True" && "$(jget "$j" 'len(d["findings"])')" == "0" ]] \
  && ok "financegate skips *.test.* files" || bad "financegate test skip" "$j"
rm -rf "$r"

# 7. MULTI-FILE AGGREGATION: one clean + one money file → exactly one finding, clean=false
r=$(mkrepo finmulti); base=$(commitf "$r" src/plain.ts "export const a=1" "seed")
commitf "$r" src/money.ts "export const p=1" "seed money" >/dev/null
printf 'export const a=2\n' > "$r/src/plain.ts"; printf 'export const p=2\n' > "$r/src/money.ts"
git -C "$r" add -A; git -C "$r" commit -q -m "feat: both"
head=$(git -C "$r" rev-parse HEAD)
j=$(runfg "$r" "$base" "$head" s t0)
[[ "$(jget "$j" 'd["clean"]')" == "False" && "$(jget "$j" 'len(d["findings"])')" == "1" \
   && "$(jget "$j" 'd["findings"][0]["issue"]')" == "money rounds before tax" ]] \
  && ok "financegate aggregates multi-file: only the flagged file contributes" || bad "financegate multi-file" "$j"
rm -rf "$r"

rm -f "$FAKE"

# ── reconcile (deterministic git-truth recovery, PERSISTED to jsonl) ──────────
echo
echo "reconcile tests:"
# build a repo whose integration branch plan/s carries: a feat(<id>): commit for w1, a real --no-ff
# merge of plan/s--w2, a grouped wave commit that lists task ids in the subject, a spec-only decoy
# that mentions a task id, and a decoy commit for w5-render (must NOT match a task "w5").
recrepo() {
  local d; d=$(mktemp -d /tmp/rpl-recon-XXXX)
  git -C "$d" init -q -b main; git -C "$d" config user.email t@t.t; git -C "$d" config user.name t; git -C "$d" config commit.gpgsign false
  commitf "$d" seed.txt seed seed >/dev/null
  git -C "$d" branch plan/s; git -C "$d" checkout -q plan/s
  commitf "$d" w1.txt a "feat(w1): land work" >/dev/null
  commitf "$d" w5r.txt a "feat(w5-render): decoy longer id" >/dev/null
  commitf "$d" wg.txt a "feat(wave-8 leaf9): w4, w6" >/dev/null
  commitf "$d" spec.txt a "feat(specs): w7 spec-only mention" >/dev/null
  # real merge of a task branch → default --no-ff subject contains plan/s--w2
  git -C "$d" checkout -q -b plan/s--w2
  commitf "$d" w2.txt a "feat(w2): branch work" >/dev/null
  git -C "$d" checkout -q plan/s
  git -C "$d" merge --no-ff --no-edit plan/s--w2 >/dev/null 2>&1
  printf '%s' "$d"
}
mk_jsonl() { # $1 path
  printf '%s\n' \
    '{"type":"meta","slug":"s"}' \
    '{"type":"task","id":"w1","wave":1,"desc":"one","status":"PENDING","deps":[]}' \
    '{"type":"task","id":"w2","wave":1,"desc":"two","status":"BLOCKED","deps":[],"blocker":"was stuck"}' \
    '{"type":"task","id":"w3","wave":1,"desc":"three","status":"PENDING","deps":[]}' \
    '{"type":"task","id":"w4","wave":1,"desc":"four","status":"PENDING","deps":[]}' \
    '{"type":"task","id":"w5","wave":1,"desc":"five","status":"PENDING","deps":[]}' \
    '{"type":"task","id":"w6","wave":1,"desc":"six","status":"PENDING","deps":[]}' \
    '{"type":"task","id":"w7","wave":1,"desc":"seven","status":"PENDING","deps":[]}' \
    '{"type":"task","id":"w9","wave":1,"desc":"nine","status":"COMMITTED","deps":[]}' > "$1"
}
r=$(recrepo); jf="$r/p.jsonl"; mk_jsonl "$jf"
j=$(bash "$LIB" reconcile "$r" "plan/s" "s" "$jf")
# w1 (feat scope) + w2 (merge subject) + w4/w6 (grouped subject tokens) upgrade → COMMITTED;
# w9 (no commit) downgrades → PENDING; w7 must stay PENDING because the only mention is spec-only.
[[ "$(jget "$j" 'sorted((t["id"],t["status"]) for t in d["tasks"])')" \
   == "[('w1', 'COMMITTED'), ('w2', 'COMMITTED'), ('w4', 'COMMITTED'), ('w6', 'COMMITTED'), ('w9', 'PENDING')]" ]] \
  && ok "reconcile upgrades merged work + downgrades phantom COMMITTED" || bad "reconcile change set" "$j"
# PERSISTED to disk — not just returned
[[ "$(grep '"id":"w1"' "$jf")" == *'"status":"COMMITTED"'* \
   && "$(grep '"id":"w2"' "$jf")" == *'"status":"COMMITTED"'* \
   && "$(grep '"id":"w4"' "$jf")" == *'"status":"COMMITTED"'* \
   && "$(grep '"id":"w6"' "$jf")" == *'"status":"COMMITTED"'* \
   && "$(grep '"id":"w9"' "$jf")" == *'"status":"PENDING"'* ]] \
  && ok "reconcile persists every change to the jsonl" || bad "reconcile persist" "$(cat "$jf")"
# NO substring collision: "w5" must stay PENDING despite the feat(w5-render) decoy commit
[[ "$(grep '"id":"w5"' "$jf")" == *'"status":"PENDING"'* \
   && "$(jget "$j" 'any(t["id"]=="w5" for t in d["tasks"])')" == "False" ]] \
  && ok "reconcile exact-matches id (no w5-render→w5 collision)" || bad "reconcile id collision" "$j"
# NO spec-only collision: "w7" must stay PENDING despite feat(specs) mentioning it
[[ "$(grep '"id":"w7"' "$jf")" == *'"status":"PENDING"'* \
   && "$(jget "$j" 'any(t["id"]=="w7" for t in d["tasks"])')" == "False" ]] \
  && ok "reconcile ignores spec-only subject mentions" || bad "reconcile spec-only collision" "$j"
# untouched task (w3) absent from output and unchanged on disk
[[ "$(jget "$j" 'any(t["id"]=="w3" for t in d["tasks"])')" == "False" \
   && "$(grep '"id":"w3"' "$jf")" == *'"status":"PENDING"'* ]] \
  && ok "reconcile leaves unchanged tasks alone" || bad "reconcile unchanged" "$j"
# IDEMPOTENT: a second run finds the disk already correct → empty change set
j2=$(bash "$LIB" reconcile "$r" "plan/s" "s" "$jf")
[[ "$(jget "$j2" 'len(d["tasks"])')" == "0" ]] \
  && ok "reconcile idempotent on re-run (persisted truth holds)" || bad "reconcile idempotent" "$j2"
rm -rf "$r"
# fresh run: integration branch absent → empty set, jsonl untouched
r=$(mkrepo reconfresh); commitf "$r" seed.txt s seed >/dev/null; jf="$r/p.jsonl"; mk_jsonl "$jf"; before=$(cat "$jf")
j=$(bash "$LIB" reconcile "$r" "plan/s" "s" "$jf")
[[ "$(jget "$j" 'len(d["tasks"])')" == "0" && "$(cat "$jf")" == "$before" ]] \
  && ok "reconcile no-op when integration branch absent (fresh run)" || bad "reconcile fresh" "$j"
rm -rf "$r"

echo
echo "runstart/warnreport tests:"
# runstart: emits the integration HEAD sha + a numeric ts
r=$(mkrepo runstart); s0=$(commitf "$r" seed.txt seed seed)
j=$(run runstart "$r")
[[ "$(jget "$j" 'd["ok"]')" == "True" && "$(jget "$j" 'd["sha"]')" == "$s0" \
   && "$(jget "$j" 'isinstance(d["ts"],int) and d["ts"]>0')" == "True" ]] \
  && ok "runstart emits integration HEAD sha + ts" || bad "runstart sha/ts" "$j"
# runstart fail-soft on a non-repo path (still emits ok:false + a ts, never crashes the run)
j=$(run runstart "/tmp/rpl-nope-$$")
[[ "$(jget "$j" 'd["ok"]')" == "False" ]] && ok "runstart fail-soft off-repo" || bad "runstart fail-soft" "$j"
rm -rf "$r"

# warnreport: accumulated = full pile (pattern+justification); session = only what was added since startSha
r=$(mkrepo warnrep)
printf '%s\n' '# upstream pglite eval, unfixable here' "warning: 'eval' deprecated" > "$r/.warnignore"
commitf "$r" seed.txt seed seed >/dev/null
git -C "$r" add .warnignore; git -C "$r" commit -q -m "base allowlist"
start=$(git -C "$r" rev-parse HEAD)
printf '%s\n' '# astro md deprecation, upstream' 'Astro.*deprecated' >> "$r/.warnignore"
git -C "$r" add .warnignore; git -C "$r" commit -q -m "add astro entry"
j=$(run warnreport "$r" "$start")
[[ "$(jget "$j" 'd["counts"]["accumulated"]')" == "2" \
   && "$(jget "$j" 'd["counts"]["session"]')" == "1" \
   && "$(jget "$j" 'd["session"][0]["pat"]')" == "Astro.*deprecated" \
   && "$(jget "$j" 'd["session"][0]["why"]')" == "astro md deprecation, upstream" ]] \
  && ok "warnreport pairs justification + isolates session-added" || bad "warnreport session" "$j"
# a bare (un-justified) pattern is reported with an empty why (so the human sees the gap)
printf '%s\n' 'bare-pattern-no-comment' >> "$r/.warnignore"
j=$(run warnreport "$r" "$start")
[[ "$(jget "$j" 'd["counts"]["accumulated"]')" == "3" \
   && "$(jget "$j" '[x["why"] for x in d["accumulated"] if x["pat"]=="bare-pattern-no-comment"][0]')" == "" ]] \
  && ok "warnreport surfaces un-justified pattern (empty why)" || bad "warnreport bare why" "$j"
# fail-soft: empty startSha → accumulated still listed, session empty (no diff anchor)
j=$(run warnreport "$r" "")
[[ "$(jget "$j" 'd["counts"]["session"]')" == "0" \
   && "$(jget "$j" 'd["counts"]["accumulated"]')" == "3" ]] \
  && ok "warnreport fail-soft on empty startSha" || bad "warnreport no-anchor" "$j"
rm -rf "$r"
# no .warnignore ever committed → both empty, never crashes
r=$(mkrepo warnnone); s0=$(commitf "$r" seed.txt seed seed)
j=$(run warnreport "$r" "$s0")
[[ "$(jget "$j" 'd["counts"]["accumulated"]')" == "0" && "$(jget "$j" 'd["counts"]["session"]')" == "0" ]] \
  && ok "warnreport empty when no .warnignore" || bad "warnreport absent file" "$j"
rm -rf "$r"

echo
echo "gate0 baseline-ratchet tests:"

# build a vitest-shaped JSON at $1, test files rooted at <wt>=$2, from spec lines on stdin:
#   "<status>|<relfile>|<fullName>"   status: passed|failed|skipped
#   "SUITE|<relfile>|"                a failed suite with ZERO assertions (import/collection error)
mkvitest() {
  local spec; spec=$(cat)            # drain the <<SPEC heredoc BEFORE python claims stdin for its program
  MV_OUT="$1" MV_WT="$2" MV_SPEC="$spec" python3 - <<'PY'
import os, json, collections
wt = os.environ["MV_WT"]
suites = collections.OrderedDict(); status = {}
for line in os.environ["MV_SPEC"].splitlines():
    if not line.strip(): continue
    st, f, name = line.split("|", 2)
    full = os.path.join(wt, f); suites.setdefault(full, [])
    if st == "SUITE": status[full] = "failed"
    else:
        suites[full].append({"fullName": name, "title": name, "status": st})
        if st == "failed": status.setdefault(full, "failed")
trs = [{"name": f, "status": status.get(f, "passed"), "assertionResults": a} for f, a in suites.items()]
json.dump({"testResults": trs}, open(os.environ["MV_OUT"], "w"))
PY
}
# a test_cmd that copies fixture $1 into vitest's $GATE0_JSON output ($GATE0_JSON must stay LITERAL here —
# the lib expands it inside its own `bash -c`); pair with mkvitest to script a suite state.
tcp() { printf 'cp %q "$GATE0_JSON"' "$1"; }

# ---- gate0-collect: parse buckets + SUITE_ERROR + fail-closed ----
r=$(mkrepo g0collect); commitf "$r" seed.txt seed seed >/dev/null
fix="$r/fix.json"
mkvitest "$fix" "$r" <<SPEC
passed|tests/a.test.ts|A passes
failed|tests/a.test.ts|A fails
skipped|tests/a.test.ts|A skipped
SUITE|tests/broken.test.ts|
SPEC
j=$(run gate0-collect "$r" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["ok"]')" == "True" \
   && "$(jget "$j" '"tests/a.test.ts::A fails" in d["fail"]')" == "True" \
   && "$(jget "$j" '"tests/broken.test.ts::<SUITE_ERROR>" in d["fail"]')" == "True" \
   && "$(jget "$j" '"tests/a.test.ts::A passes" in d["pass"]')" == "True" \
   && "$(jget "$j" '"tests/a.test.ts::A skipped" in d["skip"]')" == "True" ]] \
  && ok "gate0-collect buckets pass/fail/skip + synthetic SUITE_ERROR" || bad "gate0-collect buckets" "$j"
# fail-closed: test_cmd writes NO json (runner crash) → ok=false
j=$(run gate0-collect "$r" "true")
[[ "$(jget "$j" 'd["ok"]')" == "False" ]] \
  && ok "gate0-collect ok=false when no JSON report (fail-closed)" || bad "gate0-collect crash" "$j"

# ---- gate0-baseline: capture ONCE, resume reads back, never re-captures ----
mkvitest "$fix" "$r" <<SPEC
failed|tests/a.test.ts|A fails
failed|tests/b.test.ts|B fails
SUITE|tests/c.test.ts|
SPEC
j=$(run gate0-baseline "$r" myslug "$r" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["ok"]')" == "True" && "$(jget "$j" 'd["captured"]')" == "True" && "$(jget "$j" 'd["count"]')" == "3" ]] \
  && ok "gate0-baseline captures excused set once (count=3)" || bad "gate0-baseline capture" "$j"
# now the suite is DIFFERENT (everything green) — resume must NOT re-capture (would reset the ratchet)
mkvitest "$fix" "$r" <<SPEC
passed|tests/a.test.ts|A fails
passed|tests/b.test.ts|B fails
SPEC
j=$(run gate0-baseline "$r" myslug "$r" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["captured"]')" == "False" && "$(jget "$j" 'd["count"]')" == "3" ]] \
  && ok "gate0-baseline resume reads back, never re-captures (BLOCKER B)" || bad "gate0-baseline resume" "$j"
# first-capture runner crash → ok=false (caller HALTs)
r2=$(mkrepo g0basecrash); commitf "$r2" seed.txt seed seed >/dev/null
j=$(run gate0-baseline "$r2" s "$r2" "true")
[[ "$(jget "$j" 'd["ok"]')" == "False" ]] \
  && ok "gate0-baseline ok=false on first-capture runner crash" || bad "gate0-baseline crash" "$j"; rm -rf "$r2"

# ---- gate0-ratchet: absolute layer, regressions, skip/SUITE, missing baseline, fail-closed ----
# baseline (from above) = {a::A fails, b::B fails, c::<SUITE_ERROR>}.  abs-green stub = "true".
# R-absredred: absolute checks fail → green=false regardless of tests
j=$(run gate0-ratchet "$r" myslug "$r" "false" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"absolute" in d["output"]')" == "True" ]] \
  && ok "gate0-ratchet RED when absolute checks fail" || bad "gate0-ratchet absolute" "$j"
# R-same: current fails == baseline → no NEW regression → green
mkvitest "$fix" "$r" <<SPEC
failed|tests/a.test.ts|A fails
failed|tests/b.test.ts|B fails
SUITE|tests/c.test.ts|
SPEC
j=$(run gate0-ratchet "$r" myslug "$r" "true" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["green"]')" == "True" && "$(jget "$j" 'len(d["new_fails"])')" == "0" ]] \
  && ok "gate0-ratchet GREEN when only baseline failures remain" || bad "gate0-ratchet baseline-only" "$j"
# R-newreg: a NEW failure not in baseline → green=false, listed in new_fails
mkvitest "$fix" "$r" <<SPEC
failed|tests/a.test.ts|A fails
failed|tests/new.test.ts|NEW regression
SPEC
j=$(run gate0-ratchet "$r" myslug "$r" "true" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"tests/new.test.ts::NEW regression" in d["new_fails"]')" == "True" ]] \
  && ok "gate0-ratchet RED on NEW regression vs baseline" || bad "gate0-ratchet new-fail" "$j"
# R-suiteerr: a file that newly breaks (SUITE_ERROR) is a NEW regression, not masked
mkvitest "$fix" "$r" <<SPEC
failed|tests/a.test.ts|A fails
SUITE|tests/newbroke.test.ts|
SPEC
j=$(run gate0-ratchet "$r" myslug "$r" "true" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"tests/newbroke.test.ts::<SUITE_ERROR>" in d["new_fails"]')" == "True" ]] \
  && ok "gate0-ratchet RED on newly-broken suite (no zero-fail masking)" || bad "gate0-ratchet suite-err" "$j"
# R-skip: a baseline test now SKIPPED is NOT a new regression → green (verdict only blocks NEW fails)
mkvitest "$fix" "$r" <<SPEC
skipped|tests/a.test.ts|A fails
failed|tests/b.test.ts|B fails
SUITE|tests/c.test.ts|
SPEC
j=$(run gate0-ratchet "$r" myslug "$r" "true" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["green"]')" == "True" ]] \
  && ok "gate0-ratchet GREEN when a baseline test is now skipped (no new fail)" || bad "gate0-ratchet skip" "$j"
# R-crash: runner writes no JSON but absolute green → fail-closed RED
j=$(run gate0-ratchet "$r" myslug "$r" "true" "true")
[[ "$(jget "$j" 'd["green"]')" == "False" ]] \
  && ok "gate0-ratchet RED when test runner produces no report (fail-closed)" || bad "gate0-ratchet crash" "$j"
# R-nobase: missing baseline (e.g. resume after wipe) → RED, never silently pass
r3=$(mkrepo g0nobase); commitf "$r3" seed.txt seed seed >/dev/null
j=$(run gate0-ratchet "$r3" s "$r3" "true" "$(tcp "$fix")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"baseline" in d["output"]')" == "True" ]] \
  && ok "gate0-ratchet RED when baseline missing (no re-capture, fail-closed)" || bad "gate0-ratchet no-baseline" "$j"; rm -rf "$r3"

# ---- gate0-ratchet-mark: shrink ONLY on explicit pass (BLOCKER A) ----
# reset baseline to {a, b, c-SUITE} via a fresh repo+capture
r4=$(mkrepo g0mark); commitf "$r4" seed.txt seed seed >/dev/null; fix4="$r4/f.json"
mkvitest "$fix4" "$r4" <<SPEC
failed|tests/a.test.ts|A fails
failed|tests/b.test.ts|B fails
SUITE|tests/c.test.ts|
SPEC
run gate0-baseline "$r4" s "$r4" "$(tcp "$fix4")" >/dev/null
# a now PASSES, b now SKIPPED, c DELETED (absent). Only a may leave the baseline.
mkvitest "$fix4" "$r4" <<SPEC
passed|tests/a.test.ts|A fails
skipped|tests/b.test.ts|B fails
SPEC
j=$(run gate0-ratchet-mark "$r4" s "$r4" "$(tcp "$fix4")")
[[ "$(jget "$j" 'd["ok"]')" == "True" && "$(jget "$j" 'd["removed"]')" == "1" && "$(jget "$j" 'd["remaining"]')" == "2" ]] \
  && ok "gate0-ratchet-mark drops ONLY now-passing (skip+delete kept — BLOCKER A)" || bad "gate0-ratchet-mark shrink" "$j"
# the dropped id is gone from baseline; the skipped + deleted ids remain excused
j=$(run gate0-ratchet "$r4" s "$r4" "true" "$(tcp "$fix4")")   # a now skipped again would be NEW regression? no: a was removed → if a fails again it's NEW
mkvitest "$fix4" "$r4" <<SPEC
failed|tests/a.test.ts|A fails
SPEC
j=$(run gate0-ratchet "$r4" s "$r4" "true" "$(tcp "$fix4")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"tests/a.test.ts::A fails" in d["new_fails"]')" == "True" ]] \
  && ok "gate0-ratchet-mark: a re-broken now-protected test is a NEW regression" || bad "gate0-ratchet-mark re-break" "$j"
# collect failure → baseline untouched (no silent widening/shrink)
j=$(run gate0-ratchet-mark "$r4" s "$r4" "true")
[[ "$(jget "$j" 'd["ok"]')" == "False" && "$(jget "$j" 'd["remaining"]')" == "2" ]] \
  && ok "gate0-ratchet-mark no-op on collect failure" || bad "gate0-ratchet-mark crash" "$j"; rm -rf "$r4"
rm -rf "$r"

# ---- disabled-once-green: a test PASSING at baseline, later SKIPPED, is a laundered regression (P1#2) ----
r5=$(mkrepo g0disable); commitf "$r5" seed.txt seed seed >/dev/null; fix5="$r5/f.json"
mkvitest "$fix5" "$r5" <<SPEC
passed|tests/p.test.ts|P ok
failed|tests/f.test.ts|F fails
SPEC
run gate0-baseline "$r5" s "$r5" "$(tcp "$fix5")" >/dev/null   # base fail={f}, pass={p}
# p now SKIPPED (disabled), f unchanged → green=false, p flagged as a regression
mkvitest "$fix5" "$r5" <<SPEC
skipped|tests/p.test.ts|P ok
failed|tests/f.test.ts|F fails
SPEC
j=$(run gate0-ratchet "$r5" s "$r5" "true" "$(tcp "$fix5")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"tests/p.test.ts::P ok" in d["new_fails"]')" == "True" ]] \
  && ok "gate0-ratchet RED when a once-green test is disabled by skip (P1#2)" || bad "gate0-ratchet disabled" "$j"
# contrast: p still passing → green (skip is the abuse signal, not a passing test)
mkvitest "$fix5" "$r5" <<SPEC
passed|tests/p.test.ts|P ok
failed|tests/f.test.ts|F fails
SPEC
j=$(run gate0-ratchet "$r5" s "$r5" "true" "$(tcp "$fix5")")
[[ "$(jget "$j" 'd["green"]')" == "True" ]] \
  && ok "gate0-ratchet GREEN when protected test still passes" || bad "gate0-ratchet protected-ok" "$j"; rm -rf "$r5"

# ---- pass-set promotion: a FIXED test joins the protected set; later disabling it regresses ----
r6=$(mkrepo g0promote); commitf "$r6" seed.txt seed seed >/dev/null; fix6="$r6/f.json"
mkvitest "$fix6" "$r6" <<SPEC
failed|tests/f.test.ts|F fails
SPEC
run gate0-baseline "$r6" s "$r6" "$(tcp "$fix6")" >/dev/null   # base fail={f}, pass={}
mkvitest "$fix6" "$r6" <<SPEC
passed|tests/f.test.ts|F fails
SPEC
run gate0-ratchet-mark "$r6" s "$r6" "$(tcp "$fix6")" >/dev/null  # f fixed → leaves fail, JOINS pass
# now SKIP the just-fixed f → it is protected → regression
mkvitest "$fix6" "$r6" <<SPEC
skipped|tests/f.test.ts|F fails
SPEC
j=$(run gate0-ratchet "$r6" s "$r6" "true" "$(tcp "$fix6")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"tests/f.test.ts::F fails" in d["new_fails"]')" == "True" ]] \
  && ok "gate0-ratchet-mark promotes fixed test to protected (disabling it regresses)" || bad "gate0-ratchet promote" "$j"

# ---- gate0-residual: convergence assertion (P1#1) ----
# r6 baseline fail set is now EMPTY (f was fixed+promoted) → residual remaining=0
j=$(run gate0-residual "$r6" s)
[[ "$(jget "$j" 'd["ok"]')" == "True" && "$(jget "$j" 'd["remaining"]')" == "0" ]] \
  && ok "gate0-residual remaining=0 when baseline emptied" || bad "gate0-residual empty" "$j"; rm -rf "$r6"
# a baseline with standing failures → remaining=N, ids listed (controller HALTs on these)
r7=$(mkrepo g0residual); commitf "$r7" seed.txt seed seed >/dev/null; fix7="$r7/f.json"
mkvitest "$fix7" "$r7" <<SPEC
failed|tests/x.test.ts|X still red
failed|tests/y.test.ts|Y still red
SPEC
run gate0-baseline "$r7" s "$r7" "$(tcp "$fix7")" >/dev/null
j=$(run gate0-residual "$r7" s)
[[ "$(jget "$j" 'd["remaining"]')" == "2" && "$(jget "$j" '"tests/x.test.ts::X still red" in d["fails"]')" == "True" ]] \
  && ok "gate0-residual lists standing failures at convergence" || bad "gate0-residual standing" "$j"
# corrupt baseline → ok=false (fail-closed; controller halts rather than ship)
printf 'not json' > "$r7/.git/run-plan/s/gate0-baseline.json"
j=$(run gate0-residual "$r7" s)
[[ "$(jget "$j" 'd["ok"]')" == "False" ]] \
  && ok "gate0-residual ok=false on corrupt baseline (fail-closed)" || bad "gate0-residual corrupt" "$j"; rm -rf "$r7"

# ---- HOLE 1: a suite that collects ZERO tests reads RED, not GREEN (glob/dir/config break) ----
r8=$(mkrepo g0empty); commitf "$r8" seed.txt seed seed >/dev/null; fix8="$r8/f.json"
mkvitest "$fix8" "$r8" <<SPEC
failed|tests/a.test.ts|A fails
passed|tests/b.test.ts|B ok
SPEC
run gate0-baseline "$r8" s "$r8" "$(tcp "$fix8")" >/dev/null   # baseline knows 2 ids
# now the suite collects NOTHING (empty testResults) — abs green, but suite vanished
mkvitest "$fix8" "$r8" <<SPEC
SPEC
j=$(run gate0-ratchet "$r8" s "$r8" "true" "$(tcp "$fix8")")
[[ "$(jget "$j" 'd["green"]')" == "False" ]] \
  && ok "gate0-ratchet RED when suite collects ZERO tests vs non-empty baseline (HOLE 1)" || bad "gate0-ratchet zero-collect" "$j"
j=$(run gate0-ratchet-mark "$r8" s "$r8" "$(tcp "$fix8")")
[[ "$(jget "$j" 'd["ok"]')" == "False" && "$(jget "$j" 'd["remaining"]')" == "1" ]] \
  && ok "gate0-ratchet-mark no-op (ok=false) on zero-collect (no silent shrink)" || bad "gate0-ratchet-mark zero-collect" "$j"; rm -rf "$r8"

# ---- HOLE 2: a once-green test DELETED/RENAMED is coverage erosion (RED), waivable via G0_ALLOWDEL ----
r9=$(mkrepo g0delete); commitf "$r9" seed.txt seed seed >/dev/null; fix9="$r9/f.json"
mkvitest "$fix9" "$r9" <<SPEC
passed|tests/p.test.ts|P ok
failed|tests/f.test.ts|F fails
SPEC
run gate0-baseline "$r9" s "$r9" "$(tcp "$fix9")" >/dev/null   # base fail={f}, pass={p}
# p DELETED entirely (absent from all buckets), f unchanged → vanished once-green → RED
mkvitest "$fix9" "$r9" <<SPEC
failed|tests/f.test.ts|F fails
SPEC
j=$(run gate0-ratchet "$r9" s "$r9" "true" "$(tcp "$fix9")")
[[ "$(jget "$j" 'd["green"]')" == "False" && "$(jget "$j" '"tests/p.test.ts::P ok" in d["new_fails"]')" == "True" ]] \
  && ok "gate0-ratchet RED when a once-green test is DELETED (HOLE 2)" || bad "gate0-ratchet deleted" "$j"
# same state, but the delete is explicitly waived → GREEN
j=$(G0_ALLOWDEL='["tests/p.test.ts::P ok"]' run gate0-ratchet "$r9" s "$r9" "true" "$(tcp "$fix9")")
[[ "$(jget "$j" 'd["green"]')" == "True" ]] \
  && ok "gate0-ratchet GREEN when deleted once-green test is in G0_ALLOWDEL waiver" || bad "gate0-ratchet allowdel" "$j"; rm -rf "$r9"

echo
echo "ownership-beacon tests:"

# ownerstamp writes <slug>.owner (session id) + <slug>.pid (host pid start) + <slug>.live; atomic.
bdir=$(mktemp -d /tmp/rpl-beacon-XXXX)
j=$(RUN_PLAN_RUN_DIR="$bdir" CLAUDE_CODE_SESSION_ID="sess-X" run ownerstamp myslug)
[[ "$(jget "$j" 'd["owner"]')" == "sess-X" && -f "$bdir/myslug.owner" && -f "$bdir/myslug.live" \
   && -f "$bdir/myslug.pid" && "$(cat "$bdir/myslug.owner")" == "sess-X" ]] \
  && ok "ownerstamp writes owner+pid+live beacons" || bad "ownerstamp writes beacons" "$j"
# .pid is "<host> <pid> <start>": 3 whitespace fields, pid numeric.
read -r _h _p _s < "$bdir/myslug.pid"
[[ -n "$_h" && "$_p" =~ ^[0-9]+$ ]] && ok "ownerstamp .pid carries host+numeric pid" \
  || bad "ownerstamp .pid shape" "host=$_h pid=$_p start=$_s"
ls "$bdir"/.myslug.owner.* "$bdir"/.myslug.pid.* >/dev/null 2>&1 && bad "ownerstamp atomic" "temp file left behind" \
  || ok "ownerstamp atomic (no temp left)"

# _hb beats <slug>.live from the deterministic lease seam (mtime advances after a backdate).
touch -d '1 hour ago' "$bdir/myslug.live"; old=$(stat -c %Y "$bdir/myslug.live")
lr=$(mkrepo beaconlease); git -C "$lr" branch plan/myslug
printf '%s\n' '{"type":"task","id":"t0","status":"PENDING"}' > "$lr/p.jsonl"
RUN_PLAN_RUN_DIR="$bdir" run lease "$lr" plan/myslug myslug t0 "$lr/p.jsonl" >/dev/null 2>&1 || true
new=$(stat -c %Y "$bdir/myslug.live")
[[ "$new" -gt "$old" ]] && ok "_hb beats .live from lease seam" || bad "_hb beats .live" "old=$old new=$new"
rm -rf "$lr"

# ownerclear removes all three beacons.
RUN_PLAN_RUN_DIR="$bdir" run ownerclear myslug >/dev/null
[[ ! -f "$bdir/myslug.owner" && ! -f "$bdir/myslug.live" && ! -f "$bdir/myslug.pid" ]] \
  && ok "ownerclear removes beacons" || bad "ownerclear removes beacons" "files remain"

# ownerstamp / ownerclear reject a missing slug (exit 2 — usage fault, not silent).
RUN_PLAN_RUN_DIR="$bdir" run ownerstamp >/dev/null 2>&1; [[ $? -eq 2 ]] \
  && ok "ownerstamp rejects missing slug" || bad "ownerstamp missing slug" "wrong exit"
rm -rf "$bdir"

echo
echo "load / routecache / runid tests:"

# build a plan repo: docs/plans/<date>-<slug>.jsonl with meta + anchors + tasks + a gated line.
lrepo=$(mkrepo loadcase)
mkdir -p "$lrepo/docs/plans" "$lrepo/docs/specs"
: > "$lrepo/docs/specs/x-design.md"; : > "$lrepo/docs/plans/x-plan.md"
JP="$lrepo/docs/plans/2026-06-27-loadslug.jsonl"
{
  printf '%s\n' '{"type":"meta","slug":"loadslug","exec_mode":"ship","scheduler":"dag-parallel","land_mode":"merge-to-main","base_branch":"main","preflight":{"task_graph_hash":"hashAAA"}}'
  printf '%s\n' '{"type":"anchor","path":"docs/specs/x-design.md","what":"spec"}'
  printf '%s\n' '{"type":"anchor","path":"docs/plans/x-plan.md","what":"plan"}'
  printf '%s\n' '{"type":"task","id":"t1","wave":1,"phase":1,"desc":"alpha","status":"PENDING","deps":[],"requires_decision":null,"blocker":null}'
  printf '%s\n' '{"type":"task","id":"t2","wave":1,"phase":1,"desc":"beta","status":"COMMITTED","deps":["t1"]}'
  printf '%s\n' '{"type":"gated","id":"g1","category":"fork","needs":"pick","why":"w","blast_radius":"b","options":["a","b"],"default":"a","status":"OPEN","answer":null,"resolved_by":null,"source":"scan","binds_meta":null}'
} > "$JP"

# load finds the jsonl by slug, resolves repoRoot, parses meta+tasks+anchors+gated.
j=$(RUN_PLAN_PROJECTS="$lrepo" run load loadslug 2>/dev/null)
[[ "$(jget "$j" "d['repoRoot']")" == "$lrepo" ]] && ok "load resolves repoRoot" || bad "load repoRoot" "$j"
[[ "$(jget "$j" "len(d['tasks'])")" == "2" && "$(jget "$j" "d['tasks'][0]['id']")" == "t1" ]] && ok "load parses tasks" || bad "load tasks" "$j"
[[ "$(jget "$j" "d['planPath'].endswith('docs/plans/x-plan.md')")" == "True" && "$(jget "$j" "d['specPath'].endswith('docs/specs/x-design.md')")" == "True" ]] && ok "load resolves anchors abs" || bad "load anchors" "$j"
[[ "$(jget "$j" "d['meta']['scheduler']")" == "dag-parallel" && "$(jget "$j" "d['meta']['preflight']['task_graph_hash']")" == "hashAAA" ]] && ok "load passes meta through" || bad "load meta" "$j"
[[ "$(jget "$j" "d['gated'][0]['id']")" == "g1" ]] && ok "load parses gated" || bad "load gated" "$j"

# load on a missing slug → schema-valid EMPTY manifest (repoRoot:"") + nonzero exit (caller HALTs, no crash).
j=$(RUN_PLAN_PROJECTS="$lrepo" run load no-such-slug 2>/dev/null); rc=$?
[[ "$(jget "$j" "d['repoRoot']")" == "" && "$(jget "$j" "'tasks' in d")" == "True" && "$rc" -ne 0 ]] && ok "load missing slug → empty manifest, nonzero" || bad "load missing slug" "rc=$rc $j"

# routecache writes meta.preflight.route + route_hash, preserves line count + task_graph_hash, single meta.
before=$(wc -l < "$JP")
j=$(run routecache "$JP" hashAAA '{"t1":"north","t2":"cursor"}')
after=$(wc -l < "$JP")
[[ "$(jget "$j" "d['ok']")" == "True" && "$before" == "$after" ]] && ok "routecache ok + line count preserved" || bad "routecache write" "$j before=$before after=$after"
mj=$(head -1 "$JP")
[[ "$(jget "$mj" "d['preflight']['route']['t1']")" == "north" && "$(jget "$mj" "d['preflight']['route_hash']")" == "hashAAA" && "$(jget "$mj" "d['preflight']['task_graph_hash']")" == "hashAAA" ]] && ok "routecache merges into meta, keeps task_graph_hash" || bad "routecache merge" "$mj"

# routecache rejects a missing jsonl (fail-closed).
j=$(run routecache /no/such/file.jsonl h '{}' 2>/dev/null); rc=$?
[[ "$(jget "$j" "d['ok']")" == "False" && "$rc" -ne 0 ]] && ok "routecache fail-closed on missing file" || bad "routecache missing file" "rc=$rc $j"
rm -rf "$lrepo"

# runid beacon: stamp then read roundtrips; read of an unstamped slug is empty (the skill treats empty as "no fast-path").
rdir=$(mktemp -d)
RUN_PLAN_RUN_DIR="$rdir" run runidstamp rslug wf_xyz789 >/dev/null
got=$(RUN_PLAN_RUN_DIR="$rdir" run runidread rslug)
[[ "$got" == "wf_xyz789" ]] && ok "runidstamp/read roundtrip" || bad "runid roundtrip" "$got"
got=$(RUN_PLAN_RUN_DIR="$rdir" run runidread neverstamped)
[[ -z "$got" ]] && ok "runidread empty for unstamped slug" || bad "runidread unstamped" "$got"
RUN_PLAN_RUN_DIR="$rdir" run runidstamp >/dev/null 2>&1; [[ $? -eq 2 ]] && ok "runidstamp rejects missing slug" || bad "runidstamp missing slug" "wrong exit"
rm -rf "$rdir"

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]]
