---
id: pr-lifecycle-spec
version: 1.0
created: 2026-05-06
authority: chairman (task-2467)
scope: PR 생성 → 승인 → 머지 흐름 + bot token + admin override
---

# PR Lifecycle 명세 (v1.0)

> 회장 절대 기준 (인용):
> "모든 PR은 bot이 생성하고, 인간은 승인만 하며, main 반영은 taskctl만 수행한다."

본 명세는 `taskctl-state-machine-spec.md`를 보완하며, **PR 생성부터 main 반영까지의 외부 시스템 호출 절차**를 정의한다.

## 1. 단계 개요

```
[코드 작성]
    ↓
[git commit + push] (개발자 또는 봇)
    ↓
taskctl commit  ← COMMITTED
    ↓
taskctl pr-open  ← PR_OPEN  (★ bot token 사용, PR author = bot)
    ↓
[CI workflow 자동 실행]
    ↓
taskctl ci-check  ← CI_PENDING
    ↓
[Gemini App review 도착 대기]
    ↓
taskctl gemini-evidence  ← GEMINI_PENDING → (evaluate_gate) → REVIEW_READY 또는 BLOCKED
    ↓
taskctl verify  ← VERIFIED
    ↓
taskctl approve --by <human>  ← HUMAN_APPROVED  (★ self-approve 차단)
    ↓
taskctl merge  ← MERGING → MERGED  (★ taskctl 내부에서만 gh pr merge)
    ↓
taskctl done  ← DONE  (.done 파일 생성)
```

## 2. Bot Token 인증 (회장 §3.2 + bot_authentication 항목)

### 2.1 발급 / 저장

bot 계정 (예: `team-bot`)의 GitHub Personal Access Token (PAT):
- 권한 (scope): `repo`, `workflow` (PR 생성 + CI workflow 트리거 가능)
- 저장 우선순위:
  1. 환경변수 `BOT_GITHUB_TOKEN` (CI 환경에서)
  2. 파일 `.env.keys` (로컬에서, gitignore 강제)
  3. macOS keychain (선택)

본 task는 코드 경로(token 로딩 헬퍼)까지 준비. 실제 PAT 발급은 회장 운영 영역.

### 2.2 PAT 미발급 시 동작 (graceful degradation)

```python
def _load_bot_token() -> str | None:
    # 1. env var
    tok = os.environ.get("BOT_GITHUB_TOKEN")
    if tok:
        return tok
    # 2. .env.keys
    env_keys = WORKSPACE / ".env.keys"
    if env_keys.exists():
        for line in env_keys.read_text().splitlines():
            if line.startswith("BOT_GITHUB_TOKEN="):
                return line.split("=", 1)[1].strip().strip('"').strip("'")
    return None  # 미발급
```

PAT 미발급 시:
- `taskctl pr-open`은 **현재 사용자 token**으로 PR 생성 (단, evidence에 `pr_author=current_user`로 박제)
- `taskctl approve`에서 PR author == approver 검사 시 **ESCALATED 전이** (self-approve 구조 결함 노출)
- chairman은 1회 한정 `taskctl merge --admin`으로 통과 + audit log 박제 + 후속 task로 PAT 발급 명시

### 2.3 PR author 검증

```python
def _verify_pr_author_is_bot(pr_data: dict) -> bool:
    bot_logins = ["team-bot", "claude-bot"]  # 환경변수로 override 가능
    return pr_data.get("user", {}).get("login") in bot_logins
```

PAT 발급 후: PR author != bot → 즉시 FAIL.
PAT 미발급 시 (graceful): warning + ESCALATED 후 admin override 경로.

## 3. PR 생성 단일화 (회장 §3.2)

**허용**: `taskctl pr-open <task-id>` (단 1곳)

**금지**:
- `gh pr create` 직접 호출 (taskctl 내부 + tests/fixtures 제외)
- `worktree_manager.py`에서 PR 생성
- `finish-task.sh`에서 PR 생성
- 사람이 직접 PR 생성 (강제는 GitHub branch protection rule + CI hidden-path-audit으로 보강)

**기록 필드** (`.tasks/evidence/<task-id>/pr-open.json`):
```json
{
  "command": "taskctl pr-open task-2467",
  "task_id": "task-2467",
  "branch": "task/task-2467-dev6",
  "base_sha": "b1b106ad...",
  "head_sha": "abcd1234...",
  "pr_number": 32,
  "pr_url": "https://github.com/.../pull/32",
  "pr_author": "team-bot",
  "created_by": "bot",
  "timestamp": "2026-05-06T05:30:00Z",
  "actor": "team-bot <bot@local>",
  "stdout": "...",
  "stderr": "",
  "exit_code": 0,
  "sha": "abcd1234..."
}
```

## 4. 승인 단일화 (회장 §3.3)

**허용**: `taskctl approve --task-id --by <human>`

**self-approve 차단** (구조적):
```python
if pr_author == approver_login:
    _save_evidence("approval", {
        "result": "FAIL",
        "reason": "self-approve detected",
        "pr_author": pr_author,
        "approver": approver_login,
    })
    _transition(state, "ESCALATED", ...)
    _die("self-approve 차단: PR author == approver", 1)
```

**기록 필드**: `pr_author`, `approver`, `ci_pass`, `gemini_pass`, `verify_pass`, `head_sha`, `ts`.

## 5. Merge 단일화 (회장 §3.4)

**허용**: `taskctl merge` (taskctl 내부에서만 `gh pr merge` 호출, L879 1곳)

**조건** (모두 충족):
- state == HUMAN_APPROVED
- PR state == OPEN
- mergeable == CLEAN
- ci_checks 모두 SUCCESS
- gemini PASS (high_severity == 0, head_sha 일치)
- taskctl verify PASS
- hidden_path_audit PASS
- branch head_sha == state head_sha (force-push 차단)
- PR author != approver (재검증)
- not CANCELLED

**동작 순서**:
1. HUMAN_APPROVED → MERGING 전이 (audit log 시작)
2. (admin override 없을 때) 조건 검증
3. taskctl 내부에서 `gh pr merge {pr_n} --merge --delete-branch --repo {repo}` 호출
4. exit_code 0 검증
5. `gh api /repos/{owner}/{repo}/pulls/{pr_n}` → PR state == MERGED 확인
6. `gh api /repos/{owner}/{repo}/commits/main` → merge commit SHA 확인
7. evidence/merge.json 박제 (merge_commit_sha, pr_state, exit_code)
8. MERGING → MERGED 전이
9. (옵션) taskctl done → DONE 자동 진행

**금지** (모두 차단):
- `gh pr merge` 직접 호출 (taskctl 외부)
- `worktree_manager` 직접 merge
- `finish-task.sh` 직접 merge
- `anu_confirm_bot` 직접 merge
- 모두 hidden_path_audit (grep)으로 단위테스트 강제

## 6. Admin Override (회장 §3.5)

**허용**: `taskctl merge --admin` (chairman 전용)

### 6.1 자격 검증

```python
def _verify_chairman() -> bool:
    email = subprocess.run(["git", "config", "user.email"], ...).stdout.strip()
    chairman_emails = ["jonghyuk.jeon@gmail.com"]  # 환경변수 override 가능
    return email in chairman_emails
```

### 6.2 사용 한도 (rolling window)

```python
def _check_admin_cap() -> tuple[int, int, str]:
    audit_log = WORKSPACE / "memory" / "orchestration-audit" / "admin-override.jsonl"
    now = datetime.now(timezone.utc)
    month_ago = now - timedelta(days=30)
    quarter_ago = now - timedelta(days=90)

    soft_count = 0  # 월 3회
    hard_count = 0  # 분기 5회
    for line in audit_log.read_text().splitlines():
        record = json.loads(line)
        ts = datetime.fromisoformat(record["ts"].replace("Z", "+00:00"))
        if ts > month_ago:
            soft_count += 1
        if ts > quarter_ago:
            hard_count += 1

    if hard_count >= 5:
        return soft_count, hard_count, "HARD_CAP_EXCEEDED"
    if soft_count >= 3:
        return soft_count, hard_count, "SOFT_CAP_WARNING"
    return soft_count, hard_count, "OK"
```

`HARD_CAP_EXCEEDED` 시 admin override 거부 + ESCALATED.
`SOFT_CAP_WARNING` 시 진행하되 warning 출력.

### 6.3 audit log 박제

`memory/orchestration-audit/admin-override.jsonl` (append-only):

```json
{
  "ts": "2026-05-06T05:30:00Z",
  "task_id": "task-2467",
  "pr_number": 32,
  "actor": "chairman",
  "reason": "bot PAT 미발급, drink-your-own-champagne 자체 적용 1회",
  "head_sha": "abcd1234...",
  "bypassed_checks": ["self_approve_constraint"],
  "soft_count_this_month": 1,
  "hard_count_this_quarter": 1,
  "next_action": "task-2468에서 bot PAT 발급 + 100% 자체 흐름 적용"
}
```

박제 후 ADMIN_OVERRIDE_USED 상태로 별도 박제 (DONE과 구분).

## 7. worktree_manager.py 역할 축소 (회장 §3.6)

| 액션 | 변경 전 | 변경 후 |
|------|---------|---------|
| `--action pr` | gh pr create + Gemini 대기 + gh pr merge | **차단** (taskctl pr-open 호출 안내 메시지만 출력) |
| `--action merge` | 로컬 git merge (origin push 없음) | **유지** (시스템 task용 fast-forward 로컬 머지) — 단 origin/main push는 차단 |
| `--action keep` | worktree 유지 | **유지** |
| `--action discard` | worktree 삭제 | **유지** |
| `--action auto` | level 기반 자동 분기 | **차단** (taskctl로 통일) |

`worktree_manager.py`는 worktree 생성/commit 준비/branch push 보조까지만. PR 라이프사이클 진입 시 명시적으로 `taskctl pr-open` 호출 안내.

## 8. finish-task.sh 정리 (회장 §3.6)

```bash
# 변경 후 머지 단계 (L446 부근)
TASKCTL_STATUS=$(python3 "$WORKSPACE/scripts/taskctl.py" status "$TASK_ID" --machine)
CURRENT_STATE=$(echo "$TASKCTL_STATUS" | python3 -c "import json,sys; print(json.load(sys.stdin).get('current_state',''))")

case "$CURRENT_STATE" in
    BLOCKED)
        : > "$EVENTS_DIR/${TASK_ID}.done.blocked"
        echo "[BLOCKED] state=BLOCKED. .done 차단, .done.blocked 생성."
        exit 0
        ;;
    ESCALATED)
        : > "$EVENTS_DIR/${TASK_ID}.done.escalated"
        echo "[ESCALATED] state=ESCALATED. .done 차단, .done.escalated 생성."
        exit 0
        ;;
    MERGED|DONE|ADMIN_OVERRIDE_USED)
        # OK — done 진입 가능
        ;;
    *)
        echo "[FAIL] state=$CURRENT_STATE. .done 차단 (MERGED/DONE 필요)."
        exit 1
        ;;
esac
```

## 9. anu_confirm_bot (회장 §3.4 라우팅)

```python
def _execute_approve(task_num: int, pr_num: int) -> dict:
    _task_id = f"task-{task_num}"
    # 1) approve
    proc1 = subprocess.run(["python3", str(_taskctl), "approve", _task_id, "--by", "chairman"], ...)
    if proc1.returncode != 0:
        return {"ok": False, "step": "approve", ...}
    # 2) merge
    proc2 = subprocess.run(["python3", str(_taskctl), "merge", _task_id], ...)
    return {"ok": proc2.returncode == 0, "step": "merge", ...}
```

## 10. Gemini Evidence Gate 통합 (회장 §3.7)

회장 명령:
- Gemini API 호출 금지 (GEMINI_API_KEY 의존 금지)
- Gemini App review/comment를 **primary evidence**로 사용
- head_sha 일치 필수
- high_severity 0
- evidence 0건 + 5분 미만 = HOLD
- evidence 0건 + 5분 이상 = BLOCK
- HOLD/BLOCK 상태에서 merge 금지

`taskctl verify`는 `scripts/gemini_evidence_verify.py:evaluate_gate(pr, sha, repo)`를 호출하여 결과를 `.tasks/evidence/<task-id>/gemini.json`에 박제. 결과:
- `PASS` (high_severity == 0, evidence 1건+) → REVIEW_READY → VERIFIED 진입 가능
- `HOLD` (evidence 0건, < 5분) → BLOCKED 전이 (5분 후 재시도)
- `BLOCK` (evidence 0건, ≥ 5분 OR high_severity > 0) → BLOCKED 전이 + ESCALATED 옵션

## 11. 완료 인정 기준 (회장 §3.11)

> "완료는 보고서가 아니라 상태로 판단."

`.done` 생성 조건:
- state == MERGED
- merge commit SHA가 origin/main에 존재 (gh api 재확인)
- evidence/done.json 존재
- (옵션) ADMIN_OVERRIDE_USED는 DONE과 구분되는 별도 terminal — 후속 task 명시 필요

**금지**:
- PR open 상태 → done 진입
- `.merge-done` 만으로 done 인정
- 봇 보고만으로 done 인정

## 12. CI Hidden Path Audit (별도 task-2468)

본 task에서는 단위테스트(`tests/taskctl/test_hidden_path_audit.py`)에 박제. CI workflow 통합은 task-2468 (Phase C)로 분리.

## 13. 검증 / 테스트 매트릭스

| 항목 | 검증 명령 | 단위테스트 |
|------|----------|-----------|
| PR 생성 단일화 | `grep -rn "gh pr create"` 외 0건 | test_hidden_path_audit |
| 머지 단일화 | `grep -rn "gh pr merge"` 외 0건 | test_hidden_path_audit |
| main push 차단 | `grep -rn "git push origin main"` 0건 | test_hidden_path_audit |
| state 14+5 | enum 정의 | test_transitions |
| 6개 금지 전이 | `_transition` raise | test_transitions |
| Evidence 9종 | `.tasks/evidence/<id>/{name}.json` 9개 | test_evidence |
| self-approve 차단 | author == approver | test_self_approve |
| admin override audit | jsonl append + cap | test_admin_override |
| bot author 강제 | PR author == bot | test_lifecycle |

---

(끝)
