콘텐츠로 이동

하네스 Module 03 — Hooks 시스템 강제

이 가이드 보기 전에: guide-harness-module2 까지 완료합니다. Module 02에서 CLAUDE.md 섹션 7에 채운 STOP 트리거 — 위험 작업 직전에 에이전트를 멈추게 하는 금지 조건 목록 — 가 있어야 합니다 (hooks가 강제할 대상).

왜 CLAUDE.md 다음이 hooks인가: Module 02에서 만든 STOP 트리거는 결국 "말로 하는 권고"입니다. 에이전트는 대화가 길어지면 규칙을 잊고, 급하면 어깁니다 — Module 02 Step 5-4에서 "작동 안 했거나 우회된 규칙" 칸을 채웠다면, CLAUDE.md에 적어 둔 규칙이 지켜지지 않는 장면을 이미 본 것입니다. hooks는 다릅니다. Claude Code가 도구를 실행하는 경로 자체에 끼어들어, 에이전트가 규칙을 어긴 순간에도 물리적으로 차단합니다. 규칙(권고) 위에 강제 층을 한 겹 더 얹는 것 — 그래서 CLAUDE.md 다음 모듈이 hooks입니다.

이 모듈에서 얻을 것:

  1. .claude/hooks/guard.sh — 위험 명령 차단 (PreToolUse)
  2. .claude/hooks/lint-fix.sh — 자동 포맷·린트 (PostToolUse)
  3. .claude/settings.json — hooks 등록
  4. 차단 검증 표 — 실제로 막혔는지 확인
  5. 자기검증 루프 — Claude가 "완료" 선언 전에 npm test 자동 실행

위 목록의 PreToolUse·PostToolUse는 Claude Code hook의 실행 시점 이름입니다 — 각각 도구 실행 직전과 직후에 스크립트를 끼워 넣는 자리로, Step 2와 Step 3에서 하나씩 만듭니다.

진행 흐름: 파싱 도구 준비(Step 1) → 차단 스크립트 작성(Step 2~3) → hooks 등록(Step 4) → 터미널에서 단독 검증(Step 5) → Claude Code 안에서 실전 검증(Step 6) → 자기검증 루프 추가(Step 7) → 커밋(Step 8).

✅ 실행 검증됨 (2026-09-28, Claude Code 2.1.283, jq 1.7): Module 02까지 진행한 playground 복사본에서 Step 1~8을 그대로 실행했습니다. Step 5 guard.sh 단독 테스트 10개가 기대 exit 코드와 모두 일치했고, lint-fix.sh는 api/ 파일에서 Prettier·ESLint를 실행(빈 catch 파일은 exit 2로 반려), web/ 파일은 건너뛰었습니다. Step 6은 헤드리스 세션에서 Claude의 git push origin main 호출이 PreToolUse 단계에서 🚫 BLOCKED by guard.sh로 막혔고, Step 7-2는 Claude가 테스트 먼저 작성 → node --check → 관련 테스트 → npm test(43→52개 통과) → "---검증 완료 보고---" 순서로 마쳤습니다. 이 과정에서 lint-fix.sh가 수정 파일마다 작동해 Prettier 포맷을 적용했습니다.

시간: 약 1시간 40분 (설치 40분 + 차단 검증 25분 + 자기검증 루프 30분 + 커밋 5분)

이론 배경: concept-claude-hooks


Step 1 — .claude/hooks/ 디렉터리 + jq 준비 — 5분

hooks의 실체는 평범한 셸 스크립트입니다. Claude Code는 hook 스크립트를 부를 때 지금 실행하려는 내용(도구 이름, 명령·파일 경로)을 stdin으로 JSON 한 건에 담아 넘겨줍니다 — 이후 "stdin JSON"은 이 입력을 가리킵니다. guard.sh / lint-fix.sh는 이 JSON 입력을 jq로 파싱하므로, 스크립트를 쓰기 전에 jq와 디렉터리부터 준비합니다.

실습 위치·실행

  • 위치: ~/harness-playground
  • 만들 것: jq 설치 + .claude/hooks/ 디렉터리
  • 실행: 아래 명령을 차례로 실행합니다.
# jq 설치 (Claude Code hook 입력 JSON 파싱용)
brew install jq          # macOS
# sudo apt install jq    # Ubuntu/Debian

jq --version             # 설치 확인

cd ~/harness-playground
mkdir -p .claude/hooks

Step 2 — guard.sh 설치 (Node 친화) — 20분

준비가 끝났으니 첫 번째 강제 장치를 만듭니다. guard.sh는 PreToolUse 시점 — Claude가 Bash 명령을 실행하기 직전 — 에 호출되어, Module 02 STOP 트리거 중 정규식으로 잡을 수 있는 것들을 실행 전에 차단합니다. 차단 신호는 종료 코드로 전달합니다 — Claude Code hook 규약에서 exit 2가 차단, exit 0이 통과입니다.

실습 위치·실행

  • 위치: ~/harness-playground
  • 만들 파일: .claude/hooks/guard.sh — 위험 명령을 실행 직전에 차단하는 PreToolUse hook
  • 실행: 아래 명령으로 파일을 생성하고 실행 권한을 부여합니다.

아래 스크립트에는 차단 규칙 6종(block — 명령을 막음)과 경고 규칙 2종(warn — 막지 않고 메시지만 남김)이 담겨 있습니다. .claude/hooks/guard.sh 파일을 다음 내용으로 생성:

cd ~/harness-playground
cat > .claude/hooks/guard.sh << 'EOF'
#!/bin/bash
# guard.sh — Claude Code PreToolUse Hook (Bash 실행 직전 검사)
# Claude Code는 hook 입력을 stdin으로 JSON 전달한다:
#   {"tool_name":"Bash","tool_input":{"command":"..."}}
# 명령을 차단하려면 exit 2 로 종료한다 (stderr가 Claude에게 전달돼 자동 처리됨).
# exit 0 = 통과, exit 1 = 비차단 오류(실행 계속). 차단은 반드시 exit 2.
# 의존: jq (brew install jq)

INPUT=$(cat)
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
# jq가 없거나 평문으로 직접 테스트할 때는 입력 전체를 명령으로 간주
[ -z "$COMMAND" ] && COMMAND="$INPUT"

block() {
  echo "🚫 BLOCKED by guard.sh: $1" >&2
  echo "REASON: $2" >&2
  echo "ACTION: $3" >&2
  exit 2
}

warn() {
  echo "⚠️  WARN by guard.sh: $1 — $2" >&2
}

# 1. 시크릿·환경 파일 노출/커밋 (.env.example은 추적 대상이라 먼저 지우고 검사)
if echo "$COMMAND" | sed 's/\.env\.example//g' | grep -qE "git add.*\.env|git commit.*\.env|cat.*\.env\.production"; then
  block "환경 파일 조작" \
    ".env / .env.production은 시크릿 포함 가능" \
    ".gitignore 확인, .env.example만 추적"
fi

if echo "$COMMAND" | grep -qE "git add.*credentials|git add.*service-account|git add.*\.pem"; then
  block "자격증명 파일 커밋" \
    "credentials.json / service-account.json / *.pem 노출 위험" \
    "Secret Manager 또는 환경변수 사용"
fi

# 2. 시크릿 로그
if echo "$COMMAND" | grep -qE "echo.*\\\$.*(SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY)"; then
  block "시크릿 echo" \
    "환경변수가 stdout/로그에 노출됨" \
    "로컬 디버깅은 임시변수에 마스킹 후 출력"
fi

# 3. DB 직접 조작
if echo "$COMMAND" | grep -qE "DROP TABLE|DROP DATABASE|TRUNCATE.*TABLE"; then
  block "위험한 DDL" \
    "프로덕션 데이터 삭제 위험" \
    "마이그레이션 파일 (prisma migrate / knex migrate) 경유"
fi

# 4. 마이그레이션 파일 조작
if echo "$COMMAND" | grep -qE "rm.*migrations?/|sed.*migrations?/"; then
  block "마이그레이션 파일 조작" \
    "적용된 마이그레이션 수정은 히스토리 깨짐" \
    "새 마이그레이션 추가 (prisma migrate dev --name fix_X 등)"
fi

# 5. main/master 직접 push
if echo "$COMMAND" | grep -qE "git push.*origin (main|master)( |$)"; then
  block "main/master 직접 push" \
    "보호 브랜치는 PR 경유" \
    "feature 브랜치 + PR 생성"
fi

# 6. force push
if echo "$COMMAND" | grep -qE "git push.*(-f|--force)"; then
  block "강제 push" \
    "히스토리 파괴 위험" \
    "팀 리드 승인 후 force-with-lease 사용"
fi

# 7. node_modules 강제 삭제 (재설치 비용)
if echo "$COMMAND" | grep -qE "rm -rf node_modules"; then
  warn "node_modules 삭제" "재설치 시간 소요. 정말 필요한가?"
fi

# 8. npm install --global (시스템 오염)
if echo "$COMMAND" | grep -qE "npm install -g|npm i -g"; then
  warn "전역 패키지 설치" "프로젝트 의존성은 로컬 설치 권장"
fi

exit 0
EOF

chmod +x .claude/hooks/guard.sh

Module 01에서 .claude/baseline.md에 기록한 본인 baseline의 시스템 문제 중 정규식으로 잡을 수 있는 것을 추가합니다. 잡기 어려운 것 (예: "DB 모델 노출")은 lint-fix.sh의 ESLint 규칙으로 위임합니다.


Step 3 — lint-fix.sh 설치 (Node 친화) — 10분

guard.sh가 "실행 전 차단"이라면 lint-fix.sh는 "수정 후 교정"입니다. PostToolUse 시점 — Claude가 파일을 수정한 직후 — 에 자동으로 포맷·린트를 실행하고, 남은 오류는 Claude에게 되돌려 스스로 고치게 합니다. 정규식으로 잡기 어려운 코드 품질 문제를 이 층이 맡습니다.

실습 위치·실행

  • 위치: ~/harness-playground
  • 만들 파일: .claude/hooks/lint-fix.sh — 파일 수정 직후 자동 포맷·린트하는 PostToolUse hook
  • 실행: 아래 명령으로 파일을 생성하고 실행 권한을 부여합니다.
cat > .claude/hooks/lint-fix.sh << 'EOF'
#!/bin/bash
# lint-fix.sh — Claude Code PostToolUse Hook (파일 수정 직후)
# Write/Edit/MultiEdit 후 자동 포맷·린트
# Claude Code는 hook 입력을 stdin으로 JSON 전달: {"tool_input":{"file_path":"..."}}
# 린트 실패를 Claude에게 되돌려 자동 수정시키려면 exit 2. 의존: jq (brew install jq)

INPUT=$(cat)
MODIFIED_FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
[ -z "$MODIFIED_FILE" ] && exit 0
[ ! -f "$MODIFIED_FILE" ] && exit 0
# 절대 경로로 바꿔 둔다 (아래에서 워크스페이스 디렉터리로 cd 하므로)
MODIFIED_FILE="$(cd "$(dirname "$MODIFIED_FILE")" && pwd)/$(basename "$MODIFIED_FILE")"

# JS/TS 파일만 처리
if echo "$MODIFIED_FILE" | grep -qE "\.(js|jsx|ts|tsx|mjs|cjs)$"; then
  echo "🔧 lint-fix.sh: $MODIFIED_FILE"

  # 모노레포 대응: 파일에서 가장 가까운 package.json 디렉터리(api/ 또는 web/)에서 실행
  PKG_DIR=$(dirname "$MODIFIED_FILE")
  while [ "$PKG_DIR" != "/" ] && [ ! -f "$PKG_DIR/package.json" ]; do
    PKG_DIR=$(dirname "$PKG_DIR")
  done
  cd "$PKG_DIR" || exit 0

  # Prettier (그 워크스페이스에 설치돼 있으면)
  if grep -q "\"prettier\"" package.json 2>/dev/null; then
    npx prettier --write "$MODIFIED_FILE" >/dev/null 2>&1 && echo "  ✅ Prettier"
  fi

  # ESLint --fix (그 워크스페이스에 설치돼 있으면) — 잔여 오류는 stderr로 Claude에게 전달
  if grep -q "\"eslint\"" package.json 2>/dev/null; then
    if ! LINT_OUT=$(npx eslint --fix "$MODIFIED_FILE" 2>&1); then
      echo "$LINT_OUT" | tail -20 >&2
      echo "  ❌ ESLint 잔여 오류 — Claude가 수정해야 함" >&2
      exit 2
    fi
    echo "  ✅ ESLint"
  fi

  # 금지 패턴 감지 (경고만)
  if grep -nE "console\.log\(process\.env\." "$MODIFIED_FILE" 2>/dev/null; then
    echo "  ⚠️  process.env 로깅 감지 — 시크릿 노출 위험" >&2
  fi
  if grep -nE "catch\s*\([^)]*\)\s*\{\s*\}" "$MODIFIED_FILE" 2>/dev/null; then
    echo "  ⚠️  빈 catch 블록 감지 — silent fail 위험" >&2
  fi
fi

exit 0
EOF

chmod +x .claude/hooks/lint-fix.sh

playground는 prerequisites Step A-2에서 api/에 ESLint·Prettier를 이미 설치했으므로 추가 설치가 필요 없습니다. lint-fix.sh는 수정된 파일이 속한 워크스페이스의 package.json을 보고 도구를 고르므로, api/ 파일은 Prettier·ESLint를 거치고 두 도구가 없는 web/ 파일은 금지 패턴 경고만 거칩니다. 설치 확인은 Step 5 끝의 lint-fix.sh 단독 검증에서 합니다.

본인 프로젝트에 ESLint·Prettier가 없으면 먼저 설치:

npm install --save-dev eslint prettier

그다음 ESLint 설정 마법사를 따로 실행합니다 (모듈 종류·프레임워크 등 질문에 직접 답해야 하는 대화형):

npx eslint --init

Step 4 — .claude/settings.json에 hooks 등록 — 5분

스크립트 두 개는 만들어 두는 것만으로는 아무 일도 하지 않습니다. Claude Code가 언제(PreToolUse/PostToolUse) 어떤 도구에 대해(matcher) 무엇을 부를지 settings.json에 등록해야 비로소 hooks로 작동합니다. 여기서 matcher는 hook을 적용할 도구를 고르는 이름 패턴입니다 — 아래 블록처럼 "Bash" 하나만 쓰거나, "Write|Edit|MultiEdit"처럼 |로 여러 도구를 묶습니다.

실습 위치·실행

  • 위치: ~/harness-playground
  • 만들 파일: .claude/settings.json — 두 hook의 실행 시점·대상 등록
  • 실행: 아래 명령으로 새로 생성합니다. 기존 설정이 있는 프로젝트라면 코드블록 아래 머지 안내를 따릅니다.

실습 playground는 .claude/settings.json이 아직 없으므로 아래처럼 새로 만들면 됩니다. (이미 다른 설정이 있는 본인 프로젝트라면 아래 명령으로 통째 덮어쓰지 말고, hooks 블록만 머지합니다 — jq 또는 수동. 머지 방법은 코드블록 아래 참조.)

# 신규 생성 케이스: settings.json을 새로 만든다
cat > .claude/settings.json << 'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-fix.sh" }
        ]
      }
    ]
  }
}
EOF

command의 스크립트 경로를 .claude/hooks/... 상대 경로가 아니라 "$CLAUDE_PROJECT_DIR"/.claude/hooks/...로 적는 이유: hook은 Claude 세션의 현재 작업 디렉터리에서 실행되는데, Claude가 작업 중 cd api 같은 명령을 한 번 실행하면 그 뒤로는 상대 경로 스크립트를 찾지 못합니다. 이때 hook은 "파일 없음" 오류(비차단)로 끝나 guard.sh가 조용히 무력화됩니다. CLAUDE_PROJECT_DIR은 Claude Code가 hook 실행 시 넣어 주는 프로젝트 루트 경로 환경변수라, 작업 디렉터리가 바뀌어도 항상 같은 스크립트를 찾습니다. 등록 후 JSON 문법을 확인합니다:

jq . .claude/settings.json > /dev/null && echo "settings.json OK"
# → settings.json OK

기존 프로젝트 머지: 이미 .claude/settings.json에 다른 설정이 있으면 위 명령으로 덮어쓰지 말고 hooks 블록만 추가합니다. jq로 머지하거나, 에디터로 hooks 키만 직접 붙여 넣습니다.


Step 5 — 차단 검증 — 15분

등록까지 마쳤지만, 하네스에서 "작동한다고 믿는 것"과 "작동을 확인한 것"은 다릅니다. Claude Code에 들어가기 전에 먼저 일반 터미널에서 guard.sh 단독으로 검증합니다 — 여기서 통과해야 Step 6에서 문제가 생겼을 때 원인을 스크립트가 아닌 등록·권한 쪽으로 좁힐 수 있습니다.

실습 위치·실행

  • 위치: ~/harness-playground — Claude Code 밖, 일반 터미널
  • 만들 것: 차단 검증 표 (아래 표의 "실제" 칸 채우기)
  • 실행: 아래 stdin JSON 테스트 10개를 실행하고 exit 코드를 표에 기록합니다.

본인이 직접 명령을 실행해서 차단되는지 확인합니다. Claude Code 안에서가 아니라 일반 터미널에서. Claude Code가 실제로 보내는 것과 동일하게 stdin JSON으로 넣어 테스트합니다 (argv가 아니라 stdin이 진짜 hook 경로):

# 차단되어야 하는 명령 (exit 2)
echo '{"tool_input":{"command":"git add .env"}}'                | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"git push origin main"}}'        | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"git push -f origin feature"}}'  | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"DROP TABLE users"}}'            | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"echo $DB_PASSWORD"}}'           | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"rm migrations/20230101_init.sql"}}' | bash .claude/hooks/guard.sh ; echo "→ exit $?"

# 허용되어야 하는 명령 (exit 0)
echo '{"tool_input":{"command":"npm test"}}'                    | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"git checkout -b feature/x"}}'   | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"npm install zod"}}'             | bash .claude/hooks/guard.sh ; echo "→ exit $?"
echo '{"tool_input":{"command":"git add api/.env.example"}}'    | bash .claude/hooks/guard.sh ; echo "→ exit $?"

검증 표 — 방금 실행한 10개 테스트의 기대 exit 코드입니다. 터미널에 출력된 값을 "실제" 칸에 적습니다:

명령 기대 실제
git add .env exit 2 (BLOCKED) __
git push origin main exit 2 __
git push -f origin feature exit 2 __
DROP TABLE users exit 2 __
echo $DB_PASSWORD exit 2 __
rm migrations/20230101_init.sql exit 2 __
npm test exit 0 (OK) __
git checkout -b feature/x exit 0 __
npm install zod exit 0 __
git add api/.env.example exit 0 (예시 파일은 추적 대상) __

이어서 lint-fix.sh도 단독으로 검증합니다. Claude Code는 수정한 파일 경로를 tool_input.file_path로 넘기므로 같은 형태로 넣습니다:

echo '{"tool_input":{"file_path":"api/src/app.js"}}' | bash .claude/hooks/lint-fix.sh ; echo "→ exit $?"
# → 🔧 lint-fix.sh: .../api/src/app.js
#     ✅ Prettier
#     ✅ ESLint
#   → exit 0
echo '{"tool_input":{"file_path":"web/src/App.jsx"}}' | bash .claude/hooks/lint-fix.sh ; echo "→ exit $?"
# → 🔧 lint-fix.sh: .../web/src/App.jsx  (web/에는 ESLint·Prettier가 없어 도구 줄 없이)
#   → exit 0

api/ 파일에서 ✅ Prettier·✅ ESLint 두 줄이 보이지 않으면 api/package.json의 devDependencies에 두 도구가 있는지 확인합니다. Prettier가 기존 파일의 줄바꿈을 정리해 git status에 api/src/app.js 변경이 잡힐 수 있는데, 포맷만 바뀐 것이므로 Step 8 커밋에 함께 담습니다.

전부 일치하면 통과합니다. (exit 2가 Claude Code에서 명령 차단을 의미합니다. exit 1은 차단이 아니라 "비차단 오류"라 명령이 그대로 실행됩니다 — 그래서 차단 hook은 반드시 2로 끝내야 합니다.)


Step 6 — Claude Code 안에서 실제 차단 테스트 — 10분

Step 5의 터미널 검증은 스크립트 자체의 정확성만 보장합니다. 이번에는 Claude Code가 실제로 guard.sh를 호출하는지 — 등록(settings.json)·경로·실행 권한까지 포함한 전체 경로를 확인합니다.

실습 위치·실행

  • 위치: ~/harness-playground — Claude Code 새 세션 (settings.json 반영을 위해 완전 종료 후 재실행)
  • 실행: 새 세션에서 아래 프롬프트를 붙여넣고 BLOCKED 메시지가 나오는지 확인합니다.

settings.json 변경을 반영하려면 실행 중인 Claude Code를 완전 종료한 뒤 다시 실행(새 세션 시작)합니다. 그다음, 일부러 차단 대상 명령을 시키는 아래 프롬프트를 붙여넣습니다:

guard.sh hook 차단 테스트야. git push origin main 을 실제로 한 번 실행해서
hook이 막는지 확인해줘. (원격 저장소가 없는 실습용이라 hook이 통과시켜도 push는 실패해)

"hook 차단 테스트"라고 밝히는 이유: 그냥 "git push origin main 실행해봐"라고만 하면 Claude가 CLAUDE.md 섹션 7의 STOP("main 브랜치 직접 push")을 보고 실행 자체를 거절하는 경우가 많습니다 — 규칙 층(Module 02)이 먼저 작동한 것이라 좋은 신호지만, 그러면 명령이 hook까지 도달하지 않아 hook 검증이 안 됩니다. 테스트 의도를 밝히면 Claude가 명령을 실행하고, 그 순간 guard.sh가 끼어듭니다.

기대 결과 — Claude의 Bash 호출이 PreToolUse 단계에서 막히고 다음 메시지가 보입니다:

🚫 BLOCKED by guard.sh: main/master 직접 push
REASON: 보호 브랜치는 PR 경유
ACTION: feature 브랜치 + PR 생성

만약 Claude가 명령을 실행했는데 BLOCKED 없이 원격 저장소 오류('origin' does not appear to be a git repository)가 나오면 hook이 안 걸린 것입니다:

# 디버그
cat .claude/settings.json | jq .
ls -la .claude/hooks/

settings.json의 "matcher": "Bash"와 권한(x)을 확인합니다.


Step 7 — 자기검증 루프 — 30분

지금까지 만든 것은 전부 "나쁜 행동 차단"이었습니다. 하네스의 나머지 절반은 "좋은 행동 강제" — Claude가 코드 작성 후 "완료"를 선언하기 전에 스스로 npm test를 실행하고, 실패하면 스스로 수정하게 만드는 자기검증 루프입니다. hooks가 아니라 CLAUDE.md 규칙으로 구현하는 이유는, "테스트를 돌려라"는 특정 명령 차단이 아니라 작업 절차 전체에 걸친 지시라 정규식 hook으로 표현할 수 없기 때문입니다.

실습 위치·실행

  • 위치: ~/harness-playground
  • 만들 것: CLAUDE.md 섹션 5 끝에 자기검증 루프 규칙 추가
  • 실행: 규칙 블록을 붙여 넣어 저장한 뒤, 새 세션에서 Step 7-2 프롬프트로 적용을 확인합니다.

Step 7-1: CLAUDE.md 섹션 5에 자기검증 규칙 추가

Module 02 골격에서 만든 CLAUDE.md 섹션 5(Goal-Driven Execution)는 "단계 → verify: 확인법" 형식으로 작업 절차를 정한 자리였습니다. 그 절차의 마지막 관문으로, 섹션 5 끝에 아래 규칙 블록을 그대로 붙여 넣습니다:

### 자기검증 루프 (모든 구현 작업에 적용)

"완료" 선언 전 반드시:

1단계: 컴파일/문법 확인
  - TS: npx tsc --noEmit
  - JS: node --check api/src/<수정파일>.js
  → 통과 못 하면 수정 후 재시도

2단계: 단위 테스트
  - npm --workspace api test -- --findRelatedTests src/<수정파일>.js  (Jest)
  - 또는 npx vitest run <수정파일>                                   (Vitest)
  → 실패 시 원인 파악 후 수정. 통과까지 반복.

3단계: 전체 테스트
  - npm test
  → 기존 테스트 깨지면 내 변경이 원인인지 확인.
    원인이면 수정, 기존 버그면 보고만.

4단계: 검증 완료 보고
---검증 완료 보고---
구현 내용: [한 줄]
컴파일: ✅
단위 테스트: ✅ N pass / 0 fail
전체 테스트: ✅ N pass / 0 fail
추가된 테스트: [파일 + 케이스]
변경된 파일: [목록]
---

관련 테스트 명령에 워크스페이스(--workspace api)를 지정하는 이유: 루트에서 npm test -- --findRelatedTests ...로 쓰면 루트 스크립트가 npm --workspace api test를 한 번 더 부르는 구조라 --findRelatedTests가 Jest까지 전달되지 않고 실패합니다.

Step 7-2: 첫 실험

CLAUDE.md를 저장했으면 이 규칙은 다음 세션부터 로드됩니다. 실행 중인 Claude Code를 종료하고 새 세션을 시작한 뒤, 아래 작업을 시켜 규칙이 실제로 적용되는지 확인합니다.

새 라우트 GET /users/:id 를 추가해줘.
- 해당 id의 사용자가 있으면 200 + 사용자 1명 (GET /users 목록 항목과 같은 형태)
- 없으면 404, id가 양의 정수가 아니면 400
- 자기검증 루프 (CLAUDE.md 섹션 5 끝부분) 적용

GET /health는 prerequisites Step A-2에서 이미 만들었으므로, 아직 없는 단건 조회 라우트로 실험합니다.

기대 동작:

  1. Claude가 계획 제시
  2. 라우트 + 테스트 작성
  3. 스스로 npm test 실행
  4. 검증 완료 보고 형식으로 결과 출력

만약 4번이 안 나오면 → "검증 완료 보고 형식으로 마무리해줘" 한 번 더 지시합니다. 두 번째 세션부터는 보통 알아서 합니다.


Step 8 — 커밋 — 5분

이번 모듈의 산출물을 커밋으로 남깁니다. Module 05에서 하네스 전/후를 비교할 때 "hooks가 언제부터 있었는지"를 git 이력으로 추적하는 기준점이 됩니다.

실습 위치·실행

  • 위치: ~/harness-playground
  • 실행: 아래 명령으로 산출물 3종(hooks, settings.json, CLAUDE.md)과 Step 7-2 실험 결과를 나눠 커밋합니다.
git add .claude/hooks/ .claude/settings.json CLAUDE.md
git commit -m "harness(M3): guard.sh + lint-fix.sh + 자기검증 루프 설치"

# Step 7-2 실험으로 생긴 라우트·테스트 (그리고 lint-fix의 포맷 정리) 커밋
git add -A
git commit -m "harness(M3-7): GET /users/:id — 자기검증 루프 첫 실험"
git status --short   # → 아무것도 출력되지 않으면 정상

.claude/settings.json을 팀과 공유하려면 git으로 추적하고, 개인 설정이면 .gitignore에 추가합니다.


막힐 때 (Module 3 전용 FAQ)

Q. guard.sh가 직접 실행은 되는데 Claude Code 안에서 안 걸려요

  1. .claude/settings.json의 "matcher": "Bash" 정확한지 (대소문자)
  2. chmod +x .claude/hooks/guard.sh 실행 권한 확인
  3. Claude Code를 완전 종료 후 재실행 (설정 재로드)
  4. claude --debug 로 hook 호출 로그 확인

Q. lint-fix.sh가 ESLint 오류로 자꾸 멈춰요

처음에는 의도된 동작입니다 — exit 2라 Claude에게 오류가 전달돼 스스로 고치게 합니다. ESLint 규칙이 너무 엄격하면 ESLint 설정 파일(playground는 api/eslint.config.js)에서 일부 규칙을 warning으로 낮추거나, lint-fix.sh의 exit 2를 exit 0(경고만, 차단 안 함)으로 바꿉니다.

Q. lint-fix.sh가 파일 경로를 못 잡아요 (린트가 안 됩니다)

파일 경로는 stdin JSON의 .tool_input.file_path에서 옵니다 (예전 CLAUDE_TOOL_OUTPUT_FILE 환경변수가 아닙니다). jq가 설치돼 있는지(jq --version), claude --debug로 PostToolUse가 받는 JSON에 tool_input.file_path가 있는지 확인합니다. 아래 명령으로 직접 테스트합니다.

echo '{"tool_input":{"file_path":"api/src/app.js"}}' | bash .claude/hooks/lint-fix.sh

Q. 자기검증 루프가 무한 반복돼요

3단계까지 실패하면 멈추도록 CLAUDE.md에 명시합니다. "각 단계 3회 시도 후 사용자에게 보고" 같은 한계 규칙을 추가합니다.

Q. hooks 때문에 단순 작업이 너무 느려요

PostToolUse의 lint-fix가 매 Edit마다 실행되면 답답할 수 있습니다. matcher를 "Write"로만 좁히거나 (Edit는 제외), lint-fix를 비동기로 실행합니다 (& 백그라운드).


산출물 정리

이번 모듈이 남긴 것들입니다. Module 04·05에서 이 상태를 전제로 이어갑니다.

파일 내용
.claude/hooks/guard.sh 시크릿/마이그레이션/main push 등 6종 차단 + 2종 경고
.claude/hooks/lint-fix.sh Prettier + ESLint + 금지 패턴 경고
.claude/settings.json PreToolUse + PostToolUse 등록
CLAUDE.md 섹션 5 (확장) 자기검증 루프 4단계
차단 검증 표 (수기) 본인 환경에서 통과 확인

다음 단계

▶ guide-harness-module4 — 여러 에이전트 역할로 작업 분리 + 세션 인계 자동화.

관련 페이지