how2claude 녹화 시스템을 다른 Rails 프로젝트로: 4 파일, 5 단계, hook 레이어링.
how2claude에는 Claude Code session을 자동 녹화하는 hooks 세트가 있다—작업 시작 시 자동 녹화 시작, commit마다 checkpoint 추가, session 종료 시 prompt / bash / edit 목록을 docs/notes/<feature>/raw.md에 추출. let-claude-record-itself 글에서 어떻게 만들었는지 다뤘다.
문제는 다른 프로젝트(smarts, 스마트 컨트랙트 문서 사이트)에는 이게 없다는 것. 나중에 글 쓸 때마다 git log랑 기억만 뒤져서 좋은 부분을 놓치는 느낌이었다. 이 글은 녹화 시스템을 거기로 이식하는 얘기—결국 4개 파일, 5분 만에 끝났는데, 도중에 hook 레이어링에 관한 진짜 통찰이 튀어나왔다: Claude Code hooks와 git hooks는 완전히 다른 레이어에서 작동하고, 도구를 섞어 쓸 때(예: Amp + Claude Code) 뭐가 잡히고 뭐가 빠지는지는 어느 레이어에 설치했는지에 달렸다.
how2claude에서 녹화 관련 모든 것:
| 파일 | 레이어 | 역할 |
|---|---|---|
bin/recording-state |
스크립트 | Python helper, .state.json 라이프사이클 관리 |
bin/extract-session-notes |
스크립트 | Python helper, Claude Code transcript 읽어 raw.md에 씀 |
.claude/settings.local.json |
Claude Code hook | PostToolUse / Stop이 위 스크립트 실행 |
.git/hooks/post-commit |
git hook | 매 commit마다 recording-state commit 호출해 checkpoint |
.gitignore |
노이즈 제어 | docs/notes/를 저장소에서 제외(노트는 비공개/임시) |
4개 조각, 각각 4개 다른 레이어에 앉아 있다. 이 구분은 이후에 반복적으로 나온다.
두 hook 시스템이 동시에 도는데 스코프가 완전히 다르다:
Claude Code hooks(.claude/settings.local.json에 정의):
- 스코프: Claude Code 도구 내부에서만 발화
- 트리거: PostToolUse / Stop / PreToolUse 등 Claude Code 라이프사이클 이벤트
- 얻는 정보: tool name, 인자, transcript_path(session 전체 jsonl)—Claude Code 고유
Git hooks(.git/hooks/ 아래 shell 스크립트):
- 스코프: git 도구가 발생시킨 모든 이벤트, 누가 git을 움직였든 상관없음
- 트리거: post-commit / pre-push 등 각종 git 이벤트
- 얻는 정보: git 자체가 아는 것(sha, author, branch, diff)
실제 결과: Claude Code에서 코드 작성 + commit 하면 두 레이어 hook 다 발화, session 정보와 commit 정보 모두 raw.md에 들어감. Amp(또는 Cursor, 손타이핑)로 바꿔 작성 + commit 하면 git hook만 발화, raw.md에는 commit 골격만 남고 session의 prompt / bash / edit 세부는 없음.
이건 버그가 아니라 각 도구의 설계 제약이다. 모든 도구 아래서 session 수준 세부를 얻으려면 각 도구마다 자기 hook 레이어를 설치해야 함. git 백업만으로는 "뭘 했나"는 얻어도 "뭘 생각했나, 어디서 걸렸나"는 못 얻음.
선택 가이드:
- 도구 무관한 골격(commit 정보, 코드 변경) → git hook에 둠
- Claude Code 고유 살(완전 prompt, 사고 과정) → Claude Code hook에 둠
- 둘 다 원함 → 두 레이어에 둠
smarts(/home/bob/Work/smarts, Rails 프로젝트)에 같은 녹화 시스템 설치.
mkdir -p /home/bob/Work/smarts/bin
cp /home/bob/Work/how2claude/bin/recording-state \
/home/bob/Work/smarts/bin/recording-state
cp /home/bob/Work/how2claude/bin/extract-session-notes \
/home/bob/Work/smarts/bin/extract-session-notes
chmod +x /home/bob/Work/smarts/bin/{recording-state,extract-session-notes}
스크립트 무수정—$CLAUDE_PROJECT_DIR 환경 변수로 쓸 곳을 결정:
def project_dir():
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
def state_path():
return pathlib.Path(project_dir()) / "docs/notes/.state.json"
이게 핵심 추상화: Claude Code는 hook 발화 시 CLAUDE_PROJECT_DIR를 자동 설정, git post-commit에서는 수동 설정—양쪽이 같은 환경 변수를 존중, 스크립트는 "내가 어느 프로젝트에 있나"를 판단할 필요 없음.
.claude/settings.local.json 새로 만들기여기서 결정 하나: hooks만 이식, permissions 리스트는 이식하지 않음.
how2claude의 settings.local.json에는 100개 넘는 permissions.allow 항목—전부 how2claude 전용(curl localhost:3000, bin/rails runner, kamal app exec). 이걸 smarts로 가져가는 의미 없음. smarts는 쓰면서 자연스럽게 자기 permissions을 축적할 거임.
hooks는 패턴—프로젝트 가로지르며 동일. permissions는 프로젝트 상태—프로젝트마다 다름.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-stop"
}
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }
]
}
]
}
}
트리거 포인트 셋:
- 파일 편집됨 → 녹화 시작 시도(maybe-start는 자체 체크: 이미 녹화 중이면 스킵, tree clean이어도 스킵)
- bash 명령 실행됨 → 중지 시도(maybe-stop은 엄격: "자동 시작됨 + master로 복귀 + tree clean" 세 조건 동시 성립일 때만 중지)
- session 종료 → transcript 추출해 raw.md에 씀
.git/hooks/post-commit 새로 만들기3줄:
#!/bin/bash
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
CLAUDE_PROJECT_DIR="$ROOT" "$ROOT/bin/recording-state" commit || true
수동 CLAUDE_PROJECT_DIR=$ROOT export가 git 세계와 Claude Code 세계를 같은 환경 변수로 다리놓는 정확한 지점. || true가 hook이 절대 commit을 막지 않게 보장.
chmod +x /home/bob/Work/smarts/.git/hooks/post-commit
.gitignore에 docs/notes/ 추가# Session recording notes (transient, for article material)
docs/notes/
노트는 임시 + 비공개—raw.md가 PR에 commit 되길 원치 않고, .state.json이 git status를 오염시키길 원치 않음. how2claude gitignore도 이렇게 처리.
설치 직후 maybe-start를 수동 트리거:
$ cd /home/bob/Work/smarts && CLAUDE_PROJECT_DIR=$(pwd) ./bin/recording-state maybe-start
[recording] auto-started: contract-to-docs (branch: feat/contract-to-docs)
$ cat docs/notes/.state.json
{
"feature": "contract-to-docs",
"started_at": "2026-04-20T17:47:18-04:00",
"branch": "feat/contract-to-docs",
"auto_started": true
}
스크립트가 smarts가 마침 feat/contract-to-docs 브랜치에 있고 tree dirty인 걸 정확히 식별—자동으로 녹화 시작, 브랜치 feat/contract-to-docs에서 feature 이름 contract-to-docs 추론. 그 로직:
def branch_to_feature(branch):
if not branch or branch in ("master", "main"):
return None
if "/" in branch:
return branch.split("/", 1)[1]
return branch
feat/X → X, feature/X → X, 맨날 fix-y → fix-y, master/main → None(이건 timestamp 붙은 session-YYYYMMDD-HHMM 이름으로 폴백).
이 휴리스틱은 딱 적당한 무뇌함—브랜치 이름이 작업 주제다, 다시 수동으로 이름 붙이라 할 필요 없음.
설치 후 자문: Amp에서 작업할 때도 있는데, 그 부분은 기록되나? 답은 hook 레이어링 이야기대로:
| 시나리오 | Claude Code hook | git hook | 노트에 뭐가 떨어지나 |
|---|---|---|---|
| Claude Code 작업 + commit | ✅ 발화 | ✅ 발화 | session 세부 + commit 골격 |
| Amp 작업 + commit | ❌ 무효 | ✅ 발화 | commit 골격만 |
| 손타이핑 + commit | ❌ 무효 | ✅ 발화 | commit 골격만 |
| Claude Code 작업, commit 아직 | ✅ 녹화 시작 | — | session 세부(commit 항목은 다음 commit 기다림) |
결론: Claude Code가 주력이면 충분. commit 골격은 모든 경우에 얻음, session 세부는 Claude Code 경로에서만 얻음. "뭘 했나" 류 글은 주로 commit body에 의존, session의 prompt / bash 흐름은 플러스—있으면 좋지만 필수 아님.
Amp를 많이 쓰면 Amp에 자체 hook 메커니즘이 있음(구체는 파보지 않았음), recording-state maybe-start/maybe-stop 발화시키는 작은 전달 스크립트로 같은 동작 가능.
Claude Code session recording을 다른 프로젝트에 이식하는 5단계:
cp 타깃 프로젝트의 bin/으로. 무수정—스크립트가 $CLAUDE_PROJECT_DIR 존중, 설계상 프로젝트 가로지르며 이식 가능..claude/settings.local.json 새로 만들기, hooks 부분만. permissions 리스트는 이식 안 함—permissions는 프로젝트 상태(프로젝트마다 다름), hooks는 패턴(프로젝트 가로지르며 동일)..git/hooks/post-commit 새로 만들기(3줄), 수동으로 export CLAUDE_PROJECT_DIR=$ROOT 하고 recording-state commit 호출. 여기가 git 세계와 Claude Code 세계를 같은 환경 변수로 다리놓는 유일한 점..gitignore에 docs/notes/ 추가. 노트는 임시 + 비공개, 저장소의 일부 아님.CLAUDE_PROJECT_DIR=$(pwd) ./bin/recording-state maybe-start 해서 브랜치→feature 추론이 맞는지 확인. tree clean이면 스크립트는 설계상 스킵—버그 아님.진짜 설계 결정은 "어떻게 이식하나"가 아님—이식은 거의 cp임. 녹화 로직을 4개 다른 레이어로 쪼개고, 각 레이어가 분명한 한 가지 일을 하게 하는 것:
CLAUDE_PROJECT_DIR 존중이 4개 레이어는 각각 독립으로 이식/교체/생략 가능(Amp 지원 원함? Amp hook 레이어 추가. 노트 형식 변경? Python 편집. git이 노트 추적하길 원치 않음? post-commit 삭제). Claude는 코드를 정확히 쓸 수 있지만—"이 기능은 어느 레이어에 속하나"의 판단은 네 대신 해주지 않음. 그건 네 도구 경계에 관한 판단이고, 네 거임.