diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 77cbf613..311f4ff0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -153,6 +153,41 @@ jobs:
             --commit-sha "$SHA" \
             --publish-check
 
+  phase3-merge-gate:
+    name: phase3-merge-gate
+    runs-on: ubuntu-latest
+    if: always()
+    needs: []
+    env:
+      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+    steps:
+      - uses: actions/checkout@v4
+      - name: Verify Gemini review presence on PR
+        if: ${{ github.event.pull_request.number != '' }}
+        run: |
+          set -euo pipefail
+          PR="${{ github.event.pull_request.number }}"
+          if [[ -z "$PR" ]]; then
+            echo "[phase3-merge-gate] PR number not detected — skipping (push event)"
+            exit 0
+          fi
+          REVIEWS=$(gh api "repos/${{ github.repository }}/pulls/$PR/reviews" 2>/dev/null || echo "[]")
+          GEMINI_COUNT=$(echo "$REVIEWS" | python3 -c "
+          import json, sys
+          try:
+              data = json.loads(sys.stdin.read())
+              count = sum(1 for r in data if 'gemini-code-assist' in (r.get('user', {}).get('login', '') or '').lower())
+              print(count)
+          except Exception:
+              print(0)
+          ")
+          echo "[phase3-merge-gate] Gemini review count: $GEMINI_COUNT"
+          if [ "$GEMINI_COUNT" = "0" ]; then
+            echo "::error::phase3-merge-gate: gemini-code-assist review 0건 — merge 차단"
+            exit 1
+          fi
+          echo "[phase3-merge-gate] PASS"
+
   ci-guard:
     name: ci/guard
     runs-on: ubuntu-latest
diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml
index 8c071722..f44624cf 100644
--- a/.github/workflows/guard.yml
+++ b/.github/workflows/guard.yml
@@ -97,5 +97,18 @@ jobs:
               exit 1
             }
           fi
+      - name: Phase 3 marker check (task-2461)
+        if: steps.extract.outputs.task_id != ''
+        env:
+          TASK_ID: ${{ steps.extract.outputs.task_id }}
+        run: |
+          # task-2461 Phase 3: .merge-failed / .done.blocked 마커가 있으면 차단
+          for marker in "memory/events/${TASK_ID}.merge-failed" "memory/events/${TASK_ID}.done.blocked"; do
+            if [[ -f "$marker" ]]; then
+              echo "::error::guard.yml: Phase 3 차단 마커 발견 — $marker"
+              exit 1
+            fi
+          done
+          echo "[guard.yml] Phase 3 marker check PASS"
       - name: Result
         run: echo "[guard.yml] task-state-check PASS"
diff --git a/memory/logs/gemini-calls.jsonl b/memory/logs/gemini-calls.jsonl
new file mode 100644
index 00000000..245d21c0
--- /dev/null
+++ b/memory/logs/gemini-calls.jsonl
@@ -0,0 +1,6 @@
+{"timestamp": "2026-05-05T11:00:21.245577+00:00", "pr_number": 0, "commit_sha": "0000000000000000000000000000000000000000", "tokens_in": 0, "tokens_out": 0, "latency_ms": 0, "status": "failure", "matches": [], "ok": false}
+{"timestamp": "2026-05-05T11:00:21.288221+00:00", "pr_number": 0, "commit_sha": "0000000000000000000000000000000000000000", "tokens_in": 0, "tokens_out": 0, "latency_ms": 0, "status": "neutral", "matches": [], "ok": false}
+{"timestamp": "2026-05-05T11:00:55.080024+00:00", "pr_number": 0, "commit_sha": "0000000000000000000000000000000000000000", "tokens_in": 0, "tokens_out": 0, "latency_ms": 0, "status": "failure", "matches": [], "ok": false}
+{"timestamp": "2026-05-05T11:00:55.124692+00:00", "pr_number": 0, "commit_sha": "0000000000000000000000000000000000000000", "tokens_in": 0, "tokens_out": 0, "latency_ms": 0, "status": "neutral", "matches": [], "ok": false}
+{"timestamp": "2026-05-05T11:01:55.534524+00:00", "pr_number": 0, "commit_sha": "0000000000000000000000000000000000000000", "tokens_in": 0, "tokens_out": 0, "latency_ms": 0, "status": "failure", "matches": [], "ok": false}
+{"timestamp": "2026-05-05T11:01:55.578607+00:00", "pr_number": 0, "commit_sha": "0000000000000000000000000000000000000000", "tokens_in": 0, "tokens_out": 0, "latency_ms": 0, "status": "neutral", "matches": [], "ok": false}
diff --git a/scripts/done-watcher.py b/scripts/done-watcher.py
index d467c4f8..7d57df20 100644
--- a/scripts/done-watcher.py
+++ b/scripts/done-watcher.py
@@ -233,6 +233,67 @@ def validate_done_file(done_file: Path) -> tuple[bool, list[str]]:
     return is_valid, warnings
 
 
+def _verify_main_merge_sha(task_id: str) -> tuple[bool, str]:
+    """task-2461 Phase 3 P0-3: PR 머지 SHA가 origin/main에 존재하는지 검증.
+
+    Returns:
+        (ok, reason) — ok=True이면 통과, False이면 차단 이유 포함.
+    """
+    try:
+        import shutil
+        gh_bin = shutil.which("gh")
+        if not gh_bin:
+            return True, "gh not available — skip SHA verification"
+
+        # gh pr list로 해당 task PR 확인 (브랜치 패턴: task/<task_id>-*)
+        result = subprocess.run(
+            [gh_bin, "pr", "list", "--state", "all", "--search", f"head:task/{task_id}-",
+             "--json", "number,state,mergedAt,mergeCommit"],
+            capture_output=True, text=True, timeout=30
+        )
+        if result.returncode != 0:
+            return True, f"gh pr list failed — skip: {result.stderr[:200]}"
+
+        prs = json.loads(result.stdout or "[]")
+        if not prs:
+            return True, "no PR found — skip SHA verification"
+
+        pr = prs[0]
+        pr_state = pr.get("state", "")
+        merged_at = pr.get("mergedAt") or ""
+        merge_commit = (pr.get("mergeCommit") or {}).get("oid") or ""
+
+        if pr_state == "OPEN":
+            return False, f"PR still open (state=OPEN)"
+
+        if pr_state == "MERGED":
+            if not merged_at or not merge_commit:
+                return False, "PR merged but mergedAt/mergeCommit empty"
+
+            # git으로 origin/main에 SHA 확인
+            project_path = Path(WORKSPACE_ROOT)
+            fetch_result = subprocess.run(
+                ["git", "fetch", "origin", "main"],
+                cwd=project_path, capture_output=True, text=True, timeout=30
+            )
+            if fetch_result.returncode != 0:
+                return True, "git fetch origin main failed — skip SHA check"
+
+            log_result = subprocess.run(
+                ["git", "log", "--oneline", "origin/main"],
+                cwd=project_path, capture_output=True, text=True, timeout=30
+            )
+            if log_result.returncode == 0:
+                short_sha = merge_commit[:7]
+                if short_sha not in log_result.stdout:
+                    return False, f"merge SHA {short_sha} not found on origin/main"
+
+        return True, "ok"
+
+    except Exception as e:
+        return True, f"exception in _verify_main_merge_sha: {e} — skip"
+
+
 def process_done_files() -> int:
     """.done 파일 처리 → bot idle 전환
 
@@ -250,6 +311,31 @@ def process_done_files() -> int:
         if not is_valid:
             log_protocol(f"INTEGRITY_FAIL [{done_file.name}]: 무결성 검증 실패")
 
+        # task-2461 Phase 3 P0-3: main 머지 SHA 검증 (done-watcher 보강)
+        # finish-task.sh:P0-3가 1차 게이트이며, done-watcher는 2차 방어선으로 작동
+        task_id_stem = done_file.stem  # e.g. "task-2461" or "task-2461.dev6"
+        task_id_for_verify = task_id_stem.split(".")[0]  # "task-2461"
+        if re.match(r"^task-\d+", task_id_for_verify):
+            sha_ok, sha_reason = _verify_main_merge_sha(task_id_for_verify)
+            if not sha_ok:
+                blocked_marker = EVENTS_DIR / f"{task_id_for_verify}.done.blocked"
+                if not blocked_marker.exists():
+                    try:
+                        blocked_data = {
+                            "task_id": task_id_for_verify,
+                            "reason": sha_reason,
+                            "timestamp": datetime.now(timezone.utc).isoformat(),
+                            "blocked_layer": "scripts/done-watcher.py:P0-3",
+                        }
+                        blocked_marker.write_text(
+                            json.dumps(blocked_data, ensure_ascii=False, indent=2),
+                            encoding="utf-8"
+                        )
+                    except OSError as e:
+                        log_protocol(f"ERROR: .done.blocked 생성 실패: {e}")
+                log_protocol(f"[HARD-GATE] {done_file.name}: main SHA 검증 실패 — {sha_reason}, bot idle 전환 차단")
+                continue
+
         team_id = extract_team_from_done_file(done_file)
         if team_id:
             if set_bot_idle(team_id):
diff --git a/scripts/finish-task.sh b/scripts/finish-task.sh
index 10dfabab..f105cedd 100755
--- a/scripts/finish-task.sh
+++ b/scripts/finish-task.sh
@@ -444,10 +444,30 @@ else
         exit 1
     fi
     echo "[INFO] 머지 실행: worktree_manager.py finish $PROJECT_PATH $TASK_ID $TEAM_SHORT --action auto"
-    python3 "$WORKSPACE/scripts/worktree_manager.py" finish "$PROJECT_PATH" "$TASK_ID" "$TEAM_SHORT" --action auto 2>&1 || {
-        echo "[WARN] worktree_manager.py finish 실패 — 계속 진행."
-    }
-    echo '{"task_id":"'"$TASK_ID"'","project_path":"'"$PROJECT_PATH"'","timestamp":"'"$(date -Iseconds)"'"}' > "$MERGE_DONE_FILE"
+    FINISH_OUTPUT_FILE="/tmp/finish-${TASK_ID}-$$.json"
+    set +e
+    python3 "$WORKSPACE/scripts/worktree_manager.py" finish "$PROJECT_PATH" "$TASK_ID" "$TEAM_SHORT" --action auto > "$FINISH_OUTPUT_FILE" 2>&1
+    WT_FINISH_EXIT=$?
+    set -e
+    echo "[INFO] worktree_manager.py finish exit=$WT_FINISH_EXIT (output: $FINISH_OUTPUT_FILE)"
+    cat "$FINISH_OUTPUT_FILE" || true
+
+    # Phase 3 P0-1 hard gate: status == "merged"가 아니면 .merge-done 생성 차단
+    WT_STATUS=$(grep -F "@@WORKTREE_FINISH_RESULT@@" "$FINISH_OUTPUT_FILE" 2>/dev/null | tail -1 | sed 's|@@WORKTREE_FINISH_RESULT@@||' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('status',''))" 2>/dev/null || echo "unknown")
+    if [ "$WT_STATUS" != "merged" ]; then
+        MERGE_FAILED_FILE="$EVENTS_DIR/${TASK_ID}.merge-failed"
+        REASON_TS=$(date -Iseconds)
+        python3 -c "
+import json,sys
+data = {'task_id': '$TASK_ID', 'wt_status': '$WT_STATUS', 'wt_exit': $WT_FINISH_EXIT, 'output_file': '$FINISH_OUTPUT_FILE', 'timestamp': '$REASON_TS', 'blocked_layer': 'scripts/finish-task.sh:P0-1'}
+json.dump(data, open('$MERGE_FAILED_FILE', 'w'), ensure_ascii=False, indent=2)
+"
+        echo "[HARD-GATE] worktree_manager status=$WT_STATUS — .merge-done blocked. .merge-failed 생성: $MERGE_FAILED_FILE" >&2
+        exit 1
+    fi
+
+    # 머지 성공 — .merge-done 생성
+    echo '{"task_id":"'"$TASK_ID"'","project_path":"'"$PROJECT_PATH"'","wt_status":"merged","timestamp":"'"$(date -Iseconds)"'"}' > "$MERGE_DONE_FILE"
     echo "[INFO] 머지 완료 — .merge-done 생성: $MERGE_DONE_FILE"
     # task-2367 P1: post-merge health probe (5분 후 background 실행)
     if [ -f "$WORKSPACE/scripts/post_merge_probe.py" ]; then
@@ -970,6 +990,49 @@ except Exception:
     fi
 fi
 
+# Phase 3 P0-3 hard gate: PR open 또는 main 머지 SHA 미확인 시 .done 차단
+if [ -n "$PROJECT_PATH" ] && [ -d "$PROJECT_PATH/.git" ]; then
+    BRANCH_NAME="task/${TASK_ID}-${TEAM_SHORT}"
+    PR_INFO=$(gh pr list --state all --head "$BRANCH_NAME" --json number,state,mergedAt,mergeCommit --jq '.[0]' 2>/dev/null || echo '{}')
+    PR_STATE=$(echo "$PR_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('state',''))" 2>/dev/null || echo "")
+    PR_MERGED_AT=$(echo "$PR_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('mergedAt') or '')" 2>/dev/null || echo "")
+    PR_MERGE_COMMIT=$(echo "$PR_INFO" | python3 -c "import json,sys; d=json.load(sys.stdin); print((d.get('mergeCommit') or {}).get('oid') or '')" 2>/dev/null || echo "")
+
+    DONE_BLOCKED=0
+    DONE_BLOCK_REASON=""
+    if [ "$PR_STATE" = "OPEN" ]; then
+        DONE_BLOCKED=1
+        DONE_BLOCK_REASON="PR still open (state=OPEN)"
+    elif [ "$PR_STATE" = "MERGED" ]; then
+        if [ -z "$PR_MERGED_AT" ] || [ -z "$PR_MERGE_COMMIT" ]; then
+            DONE_BLOCKED=1
+            DONE_BLOCK_REASON="PR merged but mergedAt/mergeCommit empty"
+        else
+            # main에 머지 SHA 존재 여부 확인
+            cd "$PROJECT_PATH"
+            if ! git fetch origin main >/dev/null 2>&1; then
+                echo "[WARN] git fetch origin main 실패 — SHA 검증 스킵"
+            else
+                if ! git log --oneline "origin/main" 2>/dev/null | grep -q "${PR_MERGE_COMMIT:0:7}"; then
+                    DONE_BLOCKED=1
+                    DONE_BLOCK_REASON="merge SHA ${PR_MERGE_COMMIT:0:7} not found on origin/main"
+                fi
+            fi
+            cd "$WORKSPACE"
+        fi
+    fi
+    if [ "$DONE_BLOCKED" = "1" ]; then
+        DONE_BLOCKED_FILE="$EVENTS_DIR/${TASK_ID}.done.blocked"
+        python3 -c "
+import json
+data = {'task_id': '$TASK_ID', 'reason': '$DONE_BLOCK_REASON', 'pr_state': '$PR_STATE', 'pr_merge_commit': '$PR_MERGE_COMMIT', 'timestamp': '$(date -Iseconds)', 'blocked_layer': 'scripts/finish-task.sh:P0-3'}
+json.dump(data, open('$DONE_BLOCKED_FILE', 'w'), ensure_ascii=False, indent=2)
+"
+        echo "[HARD-GATE] .done 생성 차단: $DONE_BLOCK_REASON. .done.blocked 생성: $DONE_BLOCKED_FILE" >&2
+        exit 1
+    fi
+fi
+
 # 3. .done 원자적 생성 (qc_result 포함)
 if ! (set -C; python3 -c "
 import json, sys
diff --git a/scripts/gemini_review_gate.py b/scripts/gemini_review_gate.py
index a89391d5..174bd914 100755
--- a/scripts/gemini_review_gate.py
+++ b/scripts/gemini_review_gate.py
@@ -269,9 +269,17 @@ def gate(args: argparse.Namespace) -> int:
 
     gemini_result = call_gemini(diff_text)
     matches = detect_blocking(gemini_result.get("text", ""))
+    allow_neutral = getattr(args, "allow_neutral", False)
     if not gemini_result["ok"]:
-        conclusion = "neutral"
-        summary = f"gemini call failed: {gemini_result.get('error', 'unknown')}"
+        # task-2461 Phase 3 P1-1: gemini 호출 실패 시 failure로 강제
+        # GEMINI_API_KEY missing / API timeout / network error 모두 차단 대상
+        if allow_neutral:
+            conclusion = "neutral"
+            summary = f"gemini call failed (allow-neutral): {gemini_result.get('error', 'unknown')}"
+        else:
+            conclusion = "failure"
+            summary = f"gemini call failed: {gemini_result.get('error', 'unknown')}"
+            print(f"[GEMINI-GATE] FAIL — review not executed (reason: {gemini_result.get('error', 'unknown')})", file=sys.stderr)
     elif matches:
         conclusion = "failure"
         summary = f"blocking matches: {matches}"
@@ -319,6 +327,7 @@ def main() -> int:
     ap.add_argument("--summary", default="")
     ap.add_argument("--publish-check", action="store_true", help="publish completion as a GitHub check run")
     ap.add_argument("--force", action="store_true", help="bypass dedup/debounce")
+    ap.add_argument("--allow-neutral", action="store_true", help="DEBUG only — gemini fail 시 neutral 허용 (CI에서 사용 금지)")
     args = ap.parse_args()
     return gate(args)
 
diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push
index 097a9088..87603223 100755
--- a/scripts/git-hooks/pre-push
+++ b/scripts/git-hooks/pre-push
@@ -160,24 +160,17 @@ PYEOF
     fi
 fi
 
-# ---------- 검증 8: taskctl_verify fallback ----------
+# ---------- 검증 8: taskctl_verify (task-2461 Phase 3 P2-1: strict 강제) ----------
 TASKCTL_VERIFY="$WORKSPACE/scripts/taskctl_verify.py"
 if [[ -f "$TASKCTL_VERIFY" ]]; then
     if ! python3 "$TASKCTL_VERIFY" "$BRANCH_TASK_ID"; then
-        echo "[BLOCKED] taskctl_verify FAIL (task=$BRANCH_TASK_ID)" >&2
+        echo "[BLOCKED] taskctl_verify FAIL (task=$BRANCH_TASK_ID) — task-2461 P2-1 hard gate" >&2
         exit 1
     fi
 else
-    TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
-    EVIDENCE_FILE="$WORKSPACE/.tasks/evidence/${BRANCH_TASK_ID}/verify-fallback-${TIMESTAMP}.json"
-    write_atomic_evidence "$EVIDENCE_FILE" "{
-  \"taskctl_verify_status\": \"fallback\",
-  \"reason\": \"taskctl_verify.py not present in Phase 2-A\",
-  \"lock_check\": \"PASS\",
-  \"scope_check\": \"${SCOPE_STATUS}\",
-  \"timestamp\": \"${TIMESTAMP}\"
-}
-"
+    # task-2461 Phase 3: taskctl_verify.py 부재는 Phase 2-C rollback 신호 — fail-strict
+    echo "[BLOCKED] scripts/taskctl_verify.py 부재 — Phase 2-C 롤백 가능성, push 차단 (task-2461 P2-1)" >&2
+    exit 1
 fi
 
 # ---------- 검증 9: best-effort taskctl status ----------
diff --git a/scripts/post_merge_probe.py b/scripts/post_merge_probe.py
index 1214c59a..4a618b18 100755
--- a/scripts/post_merge_probe.py
+++ b/scripts/post_merge_probe.py
@@ -95,7 +95,11 @@ def _run_build(project_path: Path) -> tuple[bool, str]:
 
 
 def _changed_paths(project_path: Path, merge_sha: str) -> list[str]:
-    """git diff <merge_sha>~1..<merge_sha> --name-only — failures return []"""
+    """git diff <merge_sha>~1..<merge_sha> --name-only — failures return [].
+
+    task-2461 Phase 3: squash merge 케이스에서 ~1 비교가 빈 결과 반환 시
+    git show <sha> --name-only fallback 추가.
+    """
     if not merge_sha:
         return []
     try:
@@ -106,9 +110,21 @@ def _changed_paths(project_path: Path, merge_sha: str) -> list[str]:
             text=True,
             timeout=30,
         )
-        if r.returncode != 0:
-            return []
-        return [line.strip() for line in r.stdout.splitlines() if line.strip()]
+        if r.returncode == 0:
+            paths = [line.strip() for line in r.stdout.splitlines() if line.strip()]
+            if paths:
+                return paths
+        # task-2461 P0-3 fallback: squash merge 케이스
+        r2 = subprocess.run(
+            ["git", "show", merge_sha, "--name-only", "--format="],
+            cwd=project_path,
+            capture_output=True,
+            text=True,
+            timeout=30,
+        )
+        if r2.returncode == 0:
+            return [line.strip() for line in r2.stdout.splitlines() if line.strip()]
+        return []
     except Exception:
         return []
 
diff --git a/scripts/safe_pr_merge.sh b/scripts/safe_pr_merge.sh
new file mode 100755
index 00000000..7af76dad
--- /dev/null
+++ b/scripts/safe_pr_merge.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# task-2461 Phase 3 P2-2: gh pr merge 단일 wrapper
+# 모든 머지 호출은 본 wrapper를 통해 실행되어야 한다.
+# 직접 `gh pr merge` 호출은 hidden-path-audit + pre-push에서 차단된다.
+#
+# 사용법:
+#   MERGE_CALLER=<caller_name> bash scripts/safe_pr_merge.sh <pr_number> <task_id> [merge_method]
+#
+# 예: MERGE_CALLER=worktree_manager.py bash scripts/safe_pr_merge.sh 42 task-2461 squash
+set -euo pipefail
+
+PR_NUMBER="${1:?pr_number required}"
+TASK_ID="${2:?task_id required}"
+MERGE_METHOD="${3:-merge}"
+WORKSPACE="${WORKSPACE:-$(git rev-parse --show-toplevel 2>/dev/null || echo /home/jay/workspace)}"
+
+# 1) MERGE_CALLER 환경변수 강제
+if [[ -z "${MERGE_CALLER:-}" ]]; then
+    echo "[BLOCKED] safe_pr_merge.sh: MERGE_CALLER 환경변수 미설정 — wrapper 우회 시도 차단" >&2
+    exit 1
+fi
+
+# 2) taskctl_verify 통과
+TASKCTL_VERIFY="$WORKSPACE/scripts/taskctl_verify.py"
+if [[ -f "$TASKCTL_VERIFY" ]]; then
+    if ! python3 "$TASKCTL_VERIFY" "$TASK_ID"; then
+        echo "[BLOCKED] safe_pr_merge.sh: taskctl_verify FAIL (task=$TASK_ID)" >&2
+        exit 1
+    fi
+fi
+
+# 3) Gemini 리뷰 존재 확인
+REVIEWS=$(gh api "repos/$(gh repo view --json nameWithOwner --jq .nameWithOwner)/pulls/${PR_NUMBER}/reviews" 2>/dev/null || echo "[]")
+HAS_GEMINI=$(echo "$REVIEWS" | python3 -c "
+import json, sys
+try:
+    data = json.loads(sys.stdin.read())
+    has = any('gemini-code-assist' in (r.get('user', {}).get('login', '') or '').lower() for r in data)
+    print('1' if has else '0')
+except Exception:
+    print('0')
+")
+if [[ "$HAS_GEMINI" != "1" ]]; then
+    echo "[BLOCKED] safe_pr_merge.sh: gemini-code-assist 리뷰 0건 — merge 차단 (PR=$PR_NUMBER)" >&2
+    exit 1
+fi
+
+# 4) MERGE_CALLER 박제 (evidence)
+EVIDENCE_DIR="$WORKSPACE/.tasks/evidence/${TASK_ID}"
+mkdir -p "$EVIDENCE_DIR"
+TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+EVIDENCE_FILE="$EVIDENCE_DIR/merge-${TS}.json"
+python3 -c "
+import json
+data = {
+    'task_id': '$TASK_ID',
+    'pr_number': '$PR_NUMBER',
+    'merge_method': '$MERGE_METHOD',
+    'merge_caller': '${MERGE_CALLER}',
+    'timestamp': '$TS',
+    'wrapper': 'scripts/safe_pr_merge.sh',
+}
+json.dump(data, open('$EVIDENCE_FILE', 'w'), ensure_ascii=False, indent=2)
+"
+
+# 5) gh pr merge 실행
+case "$MERGE_METHOD" in
+    merge|squash|rebase) ;;
+    *)
+        echo "[BLOCKED] safe_pr_merge.sh: invalid merge method: $MERGE_METHOD" >&2
+        exit 1
+        ;;
+esac
+
+echo "[safe_pr_merge] caller=$MERGE_CALLER pr=$PR_NUMBER method=$MERGE_METHOD evidence=$EVIDENCE_FILE"
+exec gh pr merge "$PR_NUMBER" "--$MERGE_METHOD" --delete-branch
diff --git a/scripts/worktree_manager.py b/scripts/worktree_manager.py
index 2e41a295..39a04bae 100644
--- a/scripts/worktree_manager.py
+++ b/scripts/worktree_manager.py
@@ -1257,6 +1257,8 @@ def build_parser() -> argparse.ArgumentParser:
     p_finish.add_argument("--pr-title", default="", help="PR 제목")
     p_finish.add_argument("--pr-body", default="", help="PR 본문")
     p_finish.add_argument("--gemini-timeout", type=int, default=300, help="Gemini 리뷰 대기 시간(초)")
+    p_finish.add_argument("--exit-on-block", action="store_true",
+                          help="non-merged status 시 exit 1 (CI/finish-task.sh 강제용)")
 
     # cleanup
     p_cleanup = sub.add_parser("cleanup", help="머지 완료된 worktree 자동 정리")
@@ -1312,6 +1314,15 @@ def main() -> None:
         result = {"status": "error", "message": str(exc)}
 
     print(json.dumps(result, ensure_ascii=False, indent=2))
+    # task-2461 Phase 3 P0-2: finish-task.sh가 capture할 단일 라인 sentinel
+    print("@@WORKTREE_FINISH_RESULT@@" + json.dumps(result, ensure_ascii=False))
+
+    # task-2461 Phase 3 P0-2: --exit-on-block 시 non-merged → exit 1
+    if args.command == "finish" and getattr(args, "exit_on_block", False):
+        wt_status = result.get("status", "")
+        if wt_status != "merged":
+            print(f"[HARD-GATE] worktree_manager.py finish: status={wt_status} (not merged) — exit 1", file=sys.stderr)
+            sys.exit(1)
 
     if result.get("status") == "error":
         sys.exit(1)
diff --git a/tests/phase3_hard_gate/__init__.py b/tests/phase3_hard_gate/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/phase3_hard_gate/conftest.py b/tests/phase3_hard_gate/conftest.py
new file mode 100644
index 00000000..3df4857e
--- /dev/null
+++ b/tests/phase3_hard_gate/conftest.py
@@ -0,0 +1,3 @@
+import sys
+WORKSPACE = "/home/jay/workspace"
+sys.path.insert(0, WORKSPACE)
diff --git a/tests/phase3_hard_gate/test_finish_task_hard_gate.py b/tests/phase3_hard_gate/test_finish_task_hard_gate.py
new file mode 100644
index 00000000..5eca3c02
--- /dev/null
+++ b/tests/phase3_hard_gate/test_finish_task_hard_gate.py
@@ -0,0 +1,92 @@
+"""task-2461 Phase 3 P0-1: finish-task.sh hard gate 단위 테스트
+
+finish-task.sh가 worktree_manager.py의 @@WORKTREE_FINISH_RESULT@@ sentinel을
+파싱하여 status != "merged"일 때 .merge-failed를 생성하고 exit 1하는지 검증.
+실제 bash 호출은 복잡하므로, sentinel 파싱 로직을 단위 테스트로 검증한다.
+"""
+import json
+import os
+import subprocess
+import tempfile
+
+
+def _parse_sentinel(output_text: str) -> str:
+    """finish-task.sh의 WT_STATUS 파싱 로직을 Python으로 재현"""
+    for line in output_text.splitlines():
+        if "@@WORKTREE_FINISH_RESULT@@" in line:
+            json_part = line.split("@@WORKTREE_FINISH_RESULT@@", 1)[1]
+            try:
+                d = json.loads(json_part)
+                return d.get("status", "")
+            except json.JSONDecodeError:
+                pass
+    return "unknown"
+
+
+def test_sentinel_parse_blocked_by_timeout():
+    """blocked_by_timeout sentinel은 'merged'가 아님 → 차단 대상"""
+    output = '@@WORKTREE_FINISH_RESULT@@{"status":"blocked_by_timeout","task_id":"task-test"}'
+    status = _parse_sentinel(output)
+    assert status != "merged"
+    assert status == "blocked_by_timeout"
+
+
+def test_sentinel_parse_merged():
+    """status=merged sentinel은 통과"""
+    output = '@@WORKTREE_FINISH_RESULT@@{"status":"merged","task_id":"task-test"}'
+    status = _parse_sentinel(output)
+    assert status == "merged"
+
+
+def test_sentinel_parse_merge_failed():
+    """merge_failed sentinel은 'merged'가 아님 → 차단 대상"""
+    output = '@@WORKTREE_FINISH_RESULT@@{"status":"merge_failed","task_id":"task-test"}'
+    status = _parse_sentinel(output)
+    assert status != "merged"
+    assert status == "merge_failed"
+
+
+def test_sentinel_parse_pending():
+    """pending sentinel은 'merged'가 아님 → 차단 대상"""
+    output = '@@WORKTREE_FINISH_RESULT@@{"status":"pending","task_id":"task-test"}'
+    status = _parse_sentinel(output)
+    assert status != "merged"
+
+
+def test_sentinel_parse_no_sentinel():
+    """sentinel 없는 출력 → unknown → 차단 대상"""
+    output = '{"status":"merged","task_id":"task-test"}\nsome other output'
+    status = _parse_sentinel(output)
+    assert status == "unknown"
+    assert status != "merged"
+
+
+def test_sentinel_parse_multiline_output():
+    """멀티라인 출력에서 sentinel 라인만 파싱"""
+    output = """[INFO] some log
+{"status": "blocked_by_high_severity", "task_id": "task-test"}
+@@WORKTREE_FINISH_RESULT@@{"status":"blocked_by_high_severity","task_id":"task-test"}
+[INFO] more log"""
+    status = _parse_sentinel(output)
+    assert status == "blocked_by_high_severity"
+    assert status != "merged"
+
+
+def test_bash_sentinel_extraction():
+    """실제 bash 파이프라인으로 sentinel 추출 — grep|sed|python3 체인 검증"""
+    sentinel_line = '@@WORKTREE_FINISH_RESULT@@{"status":"blocked_by_timeout","task_id":"task-test"}'
+    with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
+        f.write(sentinel_line + "\n")
+        tmp_path = f.name
+
+    try:
+        cmd = (
+            f'grep -F "@@WORKTREE_FINISH_RESULT@@" "{tmp_path}" 2>/dev/null | tail -1 | '
+            f'sed \'s|@@WORKTREE_FINISH_RESULT@@||\' | '
+            f'python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get(\'status\',\'\'))"'
+        )
+        result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
+        assert result.returncode == 0
+        assert result.stdout.strip() == "blocked_by_timeout"
+    finally:
+        os.unlink(tmp_path)
diff --git a/tests/phase3_hard_gate/test_gemini_neutral_to_failure.py b/tests/phase3_hard_gate/test_gemini_neutral_to_failure.py
new file mode 100644
index 00000000..ddaed9f0
--- /dev/null
+++ b/tests/phase3_hard_gate/test_gemini_neutral_to_failure.py
@@ -0,0 +1,35 @@
+import os, subprocess, sys
+from pathlib import Path
+
+# worktree 내 스크립트 경로 사용 (메인 workspace와 독립적)
+WORKSPACE = Path("/home/jay/workspace/.worktrees/task-2461-dev6")
+
+
+def test_gemini_call_failed_returns_failure_exit_1():
+    """task-2461 P1-1: gemini_review_gate.py 가 GEMINI_API_KEY unset에서 conclusion=failure + exit 1"""
+    env = os.environ.copy()
+    env.pop("GEMINI_API_KEY", None)
+    env["GEMINI_REVIEW_MOCK"] = ""  # disable mock
+    r = subprocess.run(
+        [sys.executable, str(WORKSPACE / "scripts/gemini_review_gate.py"),
+         "--commit-sha", "0000000000000000000000000000000000000000",
+         "--pr-number", "0", "--force"],
+        env=env, capture_output=True, text=True, timeout=30
+    )
+    # exit code 1 = failure
+    assert r.returncode == 1, f"expected exit 1, got {r.returncode}: stderr={r.stderr}"
+    # stderr should have FAIL message
+    assert "FAIL" in r.stderr or "failure" in r.stdout.lower()
+
+
+def test_gemini_call_failed_with_allow_neutral_returns_0():
+    """--allow-neutral 옵션은 디버깅 전용 — exit 0 + neutral"""
+    env = os.environ.copy()
+    env.pop("GEMINI_API_KEY", None)
+    r = subprocess.run(
+        [sys.executable, str(WORKSPACE / "scripts/gemini_review_gate.py"),
+         "--commit-sha", "0000000000000000000000000000000000000000",
+         "--pr-number", "0", "--force", "--allow-neutral"],
+        env=env, capture_output=True, text=True, timeout=30
+    )
+    assert r.returncode == 0, f"expected exit 0 with --allow-neutral, got {r.returncode}"
diff --git a/tests/phase3_hard_gate/test_safe_pr_merge_wrapper.py b/tests/phase3_hard_gate/test_safe_pr_merge_wrapper.py
new file mode 100644
index 00000000..e62e1292
--- /dev/null
+++ b/tests/phase3_hard_gate/test_safe_pr_merge_wrapper.py
@@ -0,0 +1,32 @@
+import os, subprocess
+from pathlib import Path
+
+WRAPPER = Path("/home/jay/workspace/.worktrees/task-2461-dev6/scripts/safe_pr_merge.sh")
+
+
+def test_merge_caller_required():
+    """MERGE_CALLER 없으면 exit 1 + 명시적 메시지"""
+    env = os.environ.copy()
+    env.pop("MERGE_CALLER", None)
+    r = subprocess.run(["bash", str(WRAPPER), "1", "task-test"], env=env, capture_output=True, text=True, timeout=10)
+    assert r.returncode == 1
+    assert "MERGE_CALLER" in r.stderr
+
+
+def test_invalid_merge_method_blocked():
+    """유효하지 않은 merge_method는 차단"""
+    env = os.environ.copy()
+    env["MERGE_CALLER"] = "test"
+    # taskctl_verify.py가 존재하면 pass할 수도 있으나 Gemini 검증에서 먼저 막힘
+    # MERGE_METHOD 검증은 5번째 단계이므로 이전 단계가 먼저 실패할 수 있음
+    # 따라서 exit 1만 검증
+    r = subprocess.run(["bash", str(WRAPPER), "1", "task-test", "invalid_method"], env=env, capture_output=True, text=True, timeout=10)
+    assert r.returncode == 1
+
+
+def test_wrapper_file_is_executable():
+    """wrapper 파일이 존재하고 실행 가능한지"""
+    assert WRAPPER.exists(), f"{WRAPPER} 미존재"
+    import stat
+    mode = WRAPPER.stat().st_mode
+    assert mode & stat.S_IXUSR, "owner execute bit 미설정"
diff --git a/tests/phase3_hard_gate/test_worktree_finish_sentinel.py b/tests/phase3_hard_gate/test_worktree_finish_sentinel.py
new file mode 100644
index 00000000..aca993dc
--- /dev/null
+++ b/tests/phase3_hard_gate/test_worktree_finish_sentinel.py
@@ -0,0 +1,37 @@
+from pathlib import Path
+
+# worktree 내 스크립트 경로 사용 (메인 workspace와 독립적)
+WORKSPACE = Path("/home/jay/workspace/.worktrees/task-2461-dev6")
+WT_MGR = WORKSPACE / "scripts/worktree_manager.py"
+
+
+def test_main_function_includes_sentinel_print():
+    """task-2461 P0-2: main() 에 @@WORKTREE_FINISH_RESULT@@ sentinel 출력이 포함되어야 함"""
+    src = WT_MGR.read_text(encoding="utf-8")
+    assert "@@WORKTREE_FINISH_RESULT@@" in src, "sentinel 출력 누락"
+
+
+def test_exit_on_block_option_exists():
+    """--exit-on-block 옵션이 finish 서브커맨드에 정의되어야 함"""
+    src = WT_MGR.read_text(encoding="utf-8")
+    assert "--exit-on-block" in src or "exit_on_block" in src
+
+
+def test_sentinel_is_single_line():
+    """sentinel 출력은 단일 라인 (개행 없는 JSON)이어야 함"""
+    src = WT_MGR.read_text(encoding="utf-8")
+    # sentinel print문이 indent=2 없이 json.dumps 사용하는지 확인
+    sentinel_lines = [l for l in src.splitlines() if "@@WORKTREE_FINISH_RESULT@@" in l]
+    assert len(sentinel_lines) >= 1, "sentinel print 문 미존재"
+    # indent 파라미터 없이 호출되어야 함 (단일 라인 JSON)
+    for line in sentinel_lines:
+        if "print(" in line:
+            assert "indent=" not in line, "sentinel에 indent= 파라미터가 있으면 안 됨 (멀티라인 금지)"
+
+
+def test_exit_on_block_logic_in_main():
+    """main() 함수에 exit_on_block 분기 로직이 존재해야 함"""
+    src = WT_MGR.read_text(encoding="utf-8")
+    assert "exit_on_block" in src
+    # non-merged 시 sys.exit(1) 호출 확인
+    assert 'wt_status != "merged"' in src or "wt_status != 'merged'" in src
