# task-2548 — `scripts/gemini_cli_gate_check.py` 설계 초안 (G4 Pre-PR Gate)

**상태**: 설계 note only (Task A 사전조사 산출물). 실제 구현은 Task B.
**참조 원본**: `scripts/codex_gate_check.py` (524 LOC)
**위치**: G3 independent_verifier PASS 이후, `.done` / `.pr-ready` 생성 이전.

---

## 1. 본질 정의

G4 Pre-PR Gemini CLI Gate = PR open 전 Gemini CLI(OAuth-personal) 단발 호출로 code-changing issue 사전 감지.
**공식 merge gate가 아님** (회장 §명시 1~3). GitHub Gemini App = 공식 CI merge gate 유지.

---

## 2. 인터페이스 (codex_gate_check 1:1 모방)

```python
def gemini_cli_gate_check(
    task_file: str,
    affected_files: list[str | dict],
    workspace_root: str | None = None,
    task_id: str | None = None,
    target_dir: str | None = None,
    expected_files: list[str] | None = None,  # G4 신규 — scope guard
) -> dict[str, Any]:
    """
    Returns dict with keys:
      pass, scope_violation, risks, suggestions,
      source ('gemini_cli' | 'maat_fallback_for_g4'),
      fallback_reason, error, target_dir, target_dir_source,
      fix_loop_count (G4 신규)
    """
```

CLI runner: `__main__` block은 codex_gate_check와 동일 패턴 + `--expected-files` 추가.

---

## 3. 호출 구조 (Gemini CLI 특화)

```python
GEMINI_CLI_PATH = "/home/jay/.nvm/versions/node/v24.14.0/bin/gemini"
GEMINI_TIMEOUT_SECONDS = 120  # codex와 동일

def _run_gemini_cli(prompt: str, workspace_root: str) -> dict | None:
    if not os.path.isfile(GEMINI_CLI_PATH):
        return None  # → maat fallback
    prompt_file = tempfile.NamedTemporaryFile(...)  # Argument list too long 방지
    try:
        result = subprocess.run(
            [GEMINI_CLI_PATH, "-p", prompt, "-o", "text"],
            # NOTE: -o json 는 stream-json 포맷 — codex와 동일하게 text + ```json 추출이 안정적
            capture_output=True,
            text=True,
            timeout=GEMINI_TIMEOUT_SECONDS,
            cwd=workspace_root,
            env={k: v for k, v in os.environ.items()
                 if k not in ("GEMINI_API_KEY", "GOOGLE_API_KEY",
                              "GOOGLE_GENERATIVE_AI_API_KEY")},
            # 회장 §명시 10 + 금지 #10 — API key 환경변수 명시적 제거
        )
        if result.returncode != 0:
            return None
        parsed = _extract_json_from_output(result.stdout)
        # nonzero exit 트랩: Gemini는 invalid flag도 rc=0 → parsed=None이면 무조건 fallback
        return parsed
    except subprocess.TimeoutExpired:
        return None
    finally:
        os.unlink(prompt_file.name)
```

**핵심 차이점 (vs codex_gate_check)**:
- `-p` 비대화형 flag + `-o text` (json 모드는 stream wrapper로 codex 패턴과 불일치)
- `rawOutput` 중첩 unwrap 불필요 (Gemini는 직접 ```json``` fenced block 반환)
- env에서 API key 명시적 제거 → OAuth-personal 강제

---

## 4. 프롬프트 템플릿 (expected_files scope guard 포함)

```
You are a Pre-PR code review gate. Review ONLY the diff below within the allowed expected_files scope.

ALLOWED expected_files (you MUST NOT request changes outside this list):
{expected_files_list}

If you find issues requiring changes OUTSIDE this expected_files list,
set "scope_violation": true and DO NOT propose code changes.

Output ONLY a JSON object inside a ```json fenced block. Schema:
{
  "pass": true|false,
  "scope_violation": true|false,
  "risks": [{"severity": "critical|high|medium|low", "description": "..."}],
  "suggestions": ["..."]
}

설계 문서:
{task_content}

영향받는 코드 diff:
{diff_or_code_content}
```

**sanitize 게이트**: 프롬프트 조합 후 `utils.sanitize_gate.sanitize_text()` 적용 (codex와 동일).

---

## 5. Fail Loop 차단 설계 (회장 §명시 추가 정책)

```python
G4_FIX_LOOP_MAX = 2  # 회장 §명시 "최대 1~2회로 제한"

def _check_fix_loop_budget(task_id: str, workspace_root: str) -> int:
    counter_file = os.path.join(workspace_root, "memory", "events",
                                f"{task_id}.g4-fix-loop-count")
    if not os.path.exists(counter_file):
        return 0
    return int(open(counter_file).read().strip() or "0")

def _increment_fix_loop(task_id: str, workspace_root: str) -> int:
    cur = _check_fix_loop_budget(task_id, workspace_root)
    new = cur + 1
    ...write atomic...
    return new
```

**분기 규칙**:
- `parsed["scope_violation"] == True` → 자동 fix 0회. 즉시 `ESCALATED` 반환, `.pr-ready` 생성 금지.
- `fix_loop_count > G4_FIX_LOOP_MAX` → `OWNER_DECISION_REQUIRED` 반환, `.pr-ready` 생성 금지.
- `parsed["pass"] == True` AND `scope_violation == False` → `.gemini-cli-gate` 파일 생성 + 통과.
- `parsed["pass"] == False` AND `scope_violation == False` AND `fix_loop_count <= MAX` → fix loop 진입 가능 (Task B에서 dispatch와 연동).

---

## 6. Gate 파일 출력

```python
def _save_gemini_cli_gate_file(result, task_id, workspace_root):
    gate_file = os.path.join(workspace_root, "memory", "events",
                             f"{task_id}.gemini-cli-gate")
    json.dump({**result, "task_id": task_id,
               "timestamp": datetime.now(timezone.utc).isoformat()}, ...)

# 추가: pre_pr_gemini_decision.json (회장 §명시 7번)
def _save_pre_pr_decision(result, task_id, workspace_root):
    path = os.path.join(workspace_root, "memory", "events",
                        f"{task_id}.pre_pr_gemini_decision.json")
    decision = {
        "task_id": task_id,
        "decision": "PASS" if (result["pass"] and not result["scope_violation"])
                    else ("ESCALATED" if result["scope_violation"]
                          else "FIX_REQUIRED"),
        "fix_loop_count": result.get("fix_loop_count", 0),
        "g4_required_for_level": True,  # Lv.3 이상
        "timestamp": ...
    }
    json.dump(decision, ...)
```

---

## 7. 폴백 전략 (Gemini 장애 / OAuth 만료 / malformed output)

| 시나리오 | 감지 | 분기 |
|---|---|---|
| binary 미존재 | `os.path.isfile(GEMINI_CLI_PATH) == False` | → maat 폴백 (`source='maat_fallback_for_g4'`) |
| OAuth 만료 + refresh 실패 | stderr에 `auth`/`unauthorized` 패턴 + rc!=0 | → maat 폴백 + `BLOCKED — GEMINI_CLI_UNAVAILABLE` ESCALATE 후보 |
| timeout 120s 초과 | `subprocess.TimeoutExpired` | → maat 폴백 |
| nonzero exit | `result.returncode != 0` | → maat 폴백 |
| **rc=0 이지만 JSON 없음** (Gemini 특유) | `_extract_json_from_output() is None` | → maat 폴백 |
| API key env 강제 요구 (가설) | 미확인 (현재 OAuth 정상) | → `BLOCKED — GEMINI_CLI_REQUIRES_API_KEY` (회장 §명시 위반) |

폴백 결과는 `pass = (no critical risks)` 규칙으로 codex와 동일하게 결정.

---

## 8. Lv.2 PR_READY_GATE 연동 (회장 §명시 8~9, Task C 사전 박제)

```python
# anu_v2 또는 dispatch 측에서:
g4_required = (
    task_level >= 3  # Lv.3 이상 무조건 required
    or (task_level == 2 and risk_triggered)  # Lv.2 + risk-trigger 시 required
)
if g4_required:
    g4_result = gemini_cli_gate_check(...)
    if not g4_result["pass"] or g4_result["scope_violation"]:
        # .pr-ready 생성 금지
        return "BLOCKED" or "ESCALATED"
```

**Lv.2 risk-trigger 조건 후보** (Task C에서 확정):
- expected_files에 보안 민감 파일 포함 (auth/payment/dispatch core)
- affected_files > 5
- task_file에 보안 키워드 포함 (codex의 danger_keywords 재사용)

---

## 9. 미해결 / Task B 검토 필요 항목

1. `--approval-mode plan` (read-only) 사용 위한 `experimental.plan` flag 활성화 여부 — 현재 `default` fallback도 `-p` 모드에선 정상이지만, plan 모드가 더 안전. Task B에서 `~/.gemini/settings.json` 또는 워크스페이스 config로 활성화 검토.
2. Gemini 출력에서 ```json``` fence가 누락된 corner case (예: 모델이 fence 없이 raw JSON만 반환) — codex extractor의 second-pass(brace_match)가 처리하므로 OK. 추가 fixture 확보는 Task B QC.
3. Gemini가 "리뷰 결과 없음" 응답 시 빈 risks 배열 반환 → `pass=True` 처리 정상. 모델 hallucination으로 위조 PASS 가능성은 Task B에서 마아트 cross-check 추가 검토.
4. `pre_pr_gemini_decision.json` 스키마 최종 확정 (회장 §명시 7번) — 본 note 6장 초안 기반, Task B 진입 시 final.

---

## 10. 1:1 재사용 가능 함수 명세

| codex_gate_check.py 함수 | G4 재사용 방식 | 변경점 |
|---|---|---|
| `_extract_json_from_output` | 직접 import | 없음 |
| `_normalize_affected_item` | 직접 import | 없음 |
| `_maat_fallback_check` | 직접 import (또는 source 라벨만 wrapper) | `source='maat_fallback_for_g4'` |
| `_get_callers_context` | 직접 import | 없음 |
| `_detect_workspace_root` | 직접 import | 없음 |
| `_save_gate_file` | 패턴 모방 | gate_file 이름 `.gemini-cli-gate` |
| `_run_codex_companion` | 패턴 모방 (재작성) | `_run_gemini_cli` — CLI 인자 + env API key 제거 |
| `codex_gate_check` main | 구조 모방 (재작성) | `gemini_cli_gate_check` — expected_files scope guard + fix_loop_count + pre_pr_decision 저장 |

추정 신규 LOC: 약 250~300 (codex_gate_check 524의 약 50~60%, 나머지는 import 재사용).
