# spec-p1-7-hooks-enforcement.md
# P1-7 Hooks 자동 강제 — 상세 구현 스펙

**작성자:** 아테나 (UX/UI 디자이너, MoAI-ADK)
**작성일:** 2026-03-31
**상태:** DRAFT
**관련 Task:** MoAI-ADK Phase 1

---

## 1. 목적

Write/Edit 훅을 통해 Python 파일 수정 시 pyright(타입 검사)와 ruff(스타일 검사)를 자동으로 실행한다. Circuit Breaker 패턴으로 반복 실패 시 에이전트 실행을 중단(halt)하여 QC 품질선을 자동으로 강제한다.

**핵심 목표:**
- 에이전트가 의도적으로 QC를 우회하는 것 방지
- pyright/ruff를 코드 수정 시점에 즉시 실행
- Circuit Breaker로 반복 실패 감지 및 자동 halt
- "hooks != QC 면제" 원칙 문서화

---

## 2. 상세 스펙

### 2.1 .claude/settings.json PostToolUse 훅 설정

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": {
          "tool_name": ["Write", "Edit"]
        },
        "hooks": [
          {
            "type": "command",
            "command": "bash hooks/post_tool_use.sh"
          }
        ]
      }
    ]
  }
}
```

- `Write` 또는 `Edit` 툴 호출 완료 후 자동으로 `hooks/post_tool_use.sh` 실행
- 훅 실패(exit 1) 시 Claude Code가 해당 사실을 에이전트에게 통보

### 2.2 hooks/post_tool_use.sh

```bash
#!/usr/bin/env bash
# hooks/post_tool_use.sh
# Write/Edit 훅: Python 파일 대상 pyright + ruff 자동 실행

set -euo pipefail

# 환경 변수에서 수정된 파일 경로 수신 (Claude Code가 CLAUDE_TOOL_INPUT_FILE_PATH로 전달)
MODIFIED_FILE="${CLAUDE_TOOL_INPUT_FILE_PATH:-}"

# Python 파일이 아니면 즉시 성공 종료
if [[ -z "$MODIFIED_FILE" ]] || [[ "$MODIFIED_FILE" != *.py ]]; then
    exit 0
fi

echo "[hooks] 검사 대상: $MODIFIED_FILE"

# --- pyright 실행 ---
PYRIGHT_EXIT=0
pyright "$MODIFIED_FILE" 2>&1 | python3 hooks/circuit_breaker.py pyright || PYRIGHT_EXIT=$?

# --- ruff 실행 ---
RUFF_EXIT=0
ruff check "$MODIFIED_FILE" 2>&1 | python3 hooks/circuit_breaker.py ruff || RUFF_EXIT=$?

# pyright critical(exit 1) 또는 circuit breaker halt 시 훅 실패
if [[ $PYRIGHT_EXIT -ne 0 ]]; then
    echo "[hooks] CRITICAL: pyright 타입 오류 감지 — 커밋/완료 보고 불가"
    exit 1
fi

# ruff는 WARNING이므로 exit 0 유지
if [[ $RUFF_EXIT -ne 0 ]]; then
    echo "[hooks] WARNING: ruff 스타일 위반 감지 — 수정 권장"
fi

exit 0
```

### 2.3 심각도 분류

| 도구 | 심각도 | 종료 코드 | 의미 |
|---|---|---|---|
| pyright (타입 오류) | CRITICAL | exit 1 | 훅 실패, 커밋/완료 보고 차단 |
| ruff (스타일 위반) | WARNING | exit 0 | 경고만, 차단 없음 |

```
pyright 오류 → CRITICAL → exit 1 → 에이전트 작업 차단
ruff 위반   → WARNING  → exit 0 → 경고 로그만 출력
```

### 2.4 hooks/circuit_breaker.py

Circuit Breaker는 반복 실패 패턴을 감지하여 에이전트를 자동으로 halt한다.

#### 임계값

| 조건 | 임계값 | 동작 |
|---|---|---|
| WARNING 누적 | 15회 | `CircuitBreakerHalt` raise + halt 신호 |
| CRITICAL 누적 | 30회 | `CircuitBreakerHalt` raise + halt 신호 |
| 3-tuple 연속 (동일 파일·동일 오류·연속 3회) | 3회 연속 | `CircuitBreakerHalt` raise + halt 신호 |

#### 핵심 구현

```python
#!/usr/bin/env python3
# hooks/circuit_breaker.py

import sys
import json
import os
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional

STATE_FILE = Path(".metrics/circuit_breaker_state.json")


@dataclass
class CircuitBreakerState:
    warning_count: int = 0
    critical_count: int = 0
    last_triple: list = field(default_factory=list)  # [(file, error_code, timestamp), ...]
    halted: bool = False


class CircuitBreakerHalt(Exception):
    """Circuit Breaker가 halt를 결정했을 때 raise"""
    pass


class CircuitBreaker:
    WARNING_LIMIT = 15
    CRITICAL_LIMIT = 30
    TRIPLE_CONSECUTIVE_LIMIT = 3

    def __init__(self):
        self.state = self._load_state()

    def _load_state(self) -> CircuitBreakerState:
        if STATE_FILE.exists():
            try:
                data = json.loads(STATE_FILE.read_text())
                return CircuitBreakerState(**data)
            except (json.JSONDecodeError, TypeError):
                return CircuitBreakerState()
        return CircuitBreakerState()

    def _save_state(self) -> None:
        STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
        import tempfile
        tmp = STATE_FILE.with_suffix(".tmp")
        tmp.write_text(json.dumps(self.state.__dict__, indent=2))
        os.replace(tmp, STATE_FILE)

    def record(self, severity: str, file_path: str, error_code: str) -> None:
        """
        severity: 'WARNING' | 'CRITICAL'
        file_path: 검사 대상 파일
        error_code: 오류 코드 (예: 'reportMissingImports', 'E501')
        """
        if severity == "WARNING":
            self.state.warning_count += 1
        elif severity == "CRITICAL":
            self.state.critical_count += 1

        # 3-tuple 연속 감지
        triple = (file_path, error_code)
        self.state.last_triple.append(triple)
        if len(self.state.last_triple) > self.TRIPLE_CONSECUTIVE_LIMIT:
            self.state.last_triple = self.state.last_triple[-self.TRIPLE_CONSECUTIVE_LIMIT:]

        self._save_state()
        self._check_halt()

    def _check_halt(self) -> None:
        if self.state.warning_count >= self.WARNING_LIMIT:
            self._halt(f"WARNING {self.state.warning_count}회 누적")
        if self.state.critical_count >= self.CRITICAL_LIMIT:
            self._halt(f"CRITICAL {self.state.critical_count}회 누적")
        if (
            len(self.state.last_triple) == self.TRIPLE_CONSECUTIVE_LIMIT
            and len(set(self.state.last_triple)) == 1
        ):
            self._halt(f"동일 오류 {self.TRIPLE_CONSECUTIVE_LIMIT}회 연속")

    def _halt(self, reason: str) -> None:
        self.state.halted = True
        self._save_state()
        print(f"[circuit_breaker] HALT: {reason}", file=sys.stderr)
        raise CircuitBreakerHalt(reason)

    def reset(self) -> None:
        self.state = CircuitBreakerState()
        self._save_state()
        print("[circuit_breaker] 상태 리셋 완료")


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "reset":
        CircuitBreaker().reset()
        sys.exit(0)

    tool = sys.argv[1] if len(sys.argv) > 1 else "unknown"
    stdin_output = sys.stdin.read()

    cb = CircuitBreaker()
    try:
        # 입력 파싱 및 심각도 판단
        for line in stdin_output.splitlines():
            if "error:" in line.lower():
                cb.record("CRITICAL", os.environ.get("CLAUDE_TOOL_INPUT_FILE_PATH", ""), line)
            elif "warning:" in line.lower():
                cb.record("WARNING", os.environ.get("CLAUDE_TOOL_INPUT_FILE_PATH", ""), line)
        print(stdin_output)  # 원본 출력 그대로 전달
    except CircuitBreakerHalt:
        sys.exit(1)
```

#### 수동 리셋

```bash
python3 hooks/circuit_breaker.py reset
```

### 2.5 Feature Flag

| 플래그명 | 기본값 |
|---|---|
| `hooks_enforcement_enabled` | `false` |

**플래그 활성화(`true`):** `.claude/settings.json`의 PostToolUse 훅이 활성화되어 pyright/ruff 자동 실행.

**플래그 비활성화(`false`):** 훅을 설치하지 않거나, `post_tool_use.sh` 첫 줄에서 조기 exit 0 처리.

```bash
# post_tool_use.sh 플래그 확인 로직
HOOKS_ENABLED=$(python3 -c "
from utils.feature_flags import FeatureFlagLoader
print(FeatureFlagLoader().get('hooks_enforcement_enabled'))
")
if [[ "$HOOKS_ENABLED" != "True" ]]; then
    exit 0
fi
```

### 2.6 QC-RULES.md 추가 규칙

```markdown
## hooks ≠ QC 면제

- PostToolUse 훅(pyright/ruff)은 보조 안전망이다.
- 훅이 설치되어 있다고 해서 에이전트의 QC 의무가 면제되지 않는다.
- 에이전트는 작업 완료 전 반드시 독립적으로 pyright/ruff를 실행해야 한다.
- 훅 통과 = "최소 기준 충족"이며 "완전한 QC 완료"가 아니다.
- `.done` 파일 생성 전 pyright 오류 0건, ruff 위반 0건을 직접 확인할 의무가 있다.
```

---

## 3. 인터페이스

### 3.1 파일 구조

```
.claude/
  settings.json             ← PostToolUse 훅 설정
hooks/
  post_tool_use.sh          ← 훅 진입점 (pyright/ruff 실행)
  circuit_breaker.py        ← Circuit Breaker 구현
.metrics/
  circuit_breaker_state.json ← Circuit Breaker 상태 파일 (자동 생성)
docs/
  QC-RULES.md               ← hooks≠QC면제 규칙 추가
```

### 3.2 환경 변수

| 변수명 | 제공자 | 내용 |
|---|---|---|
| `CLAUDE_TOOL_INPUT_FILE_PATH` | Claude Code | 수정된 파일의 절대 경로 |

---

## 4. 테스트 기준

### 4.1 단위 테스트

```python
# tests/test_circuit_breaker.py

def test_warning_halt_at_limit(tmp_path, monkeypatch):
    monkeypatch.setenv("STATE_FILE", str(tmp_path / "state.json"))
    cb = CircuitBreaker()
    for i in range(14):
        # 14회까지는 halt 없음
        cb.state.warning_count = i
    cb.state.warning_count = 14
    cb.record("WARNING", "test.py", "W001")  # 15번째 → halt
    # CircuitBreakerHalt 발생 확인은 pytest.raises로

def test_critical_halt_at_limit():
    ...  # 30회 CRITICAL에서 halt

def test_triple_consecutive_halt():
    ...  # 동일 (file, error) 3회 연속에서 halt

def test_reset_clears_state():
    cb = CircuitBreaker()
    cb.state.warning_count = 14
    cb.reset()
    assert cb.state.warning_count == 0
    assert cb.state.halted is False

def test_non_python_file_skipped(tmp_path):
    """Python이 아닌 파일은 훅 실행 안 함"""
    env = {"CLAUDE_TOOL_INPUT_FILE_PATH": str(tmp_path / "README.md")}
    result = subprocess.run(["bash", "hooks/post_tool_use.sh"], env=env)
    assert result.returncode == 0
```

### 4.2 통합 테스트

- 타입 오류가 있는 Python 파일 Write 후 exit 1 반환 확인
- ruff 위반만 있는 파일 Write 후 exit 0 반환, WARNING 로그 출력 확인
- WARNING 15회 누적 후 `circuit_breaker_state.json`에 `halted: true` 기록 확인

---

## 5. DoD (Definition of Done)

- [ ] `.claude/settings.json`에 PostToolUse 훅 설정 완료
- [ ] `hooks/post_tool_use.sh` 구현 완료 (Python 파일 필터링, pyright/ruff 실행)
- [ ] `hooks/circuit_breaker.py` 구현 완료
  - [ ] WARNING 15회 halt
  - [ ] CRITICAL 30회 halt
  - [ ] 3-tuple 연속 3회 halt
  - [ ] `python3 hooks/circuit_breaker.py reset` 정상 동작
- [ ] 심각도 분류: pyright=CRITICAL(exit 1), ruff=WARNING(exit 0)
- [ ] `hooks_enforcement_enabled` 플래그 비활성화 시 훅 동작 없음
- [ ] `QC-RULES.md`에 "hooks≠QC면제" 규칙 추가
- [ ] 단위 테스트 5건 이상 통과 (pytest)
- [ ] pyright 타입 오류 0건
- [ ] ruff 스타일 경고 0건
- [ ] PR 리뷰 승인 후 merge
