실전 글의 최대 난제는 소재 모으기—git log엔 commit message뿐, session 속 결정적 디테일은 사라진다. hook 4개 + slash command 2개로 session을 raw.md로 자동 변환. 한계비용 0.
"Claude가 나 대신 X를 해줬다" 같은 실전 글을 쓸 때 제일 힘든 건 쓰기가 아니라 소재 모으기다. 뒤늦게 git log를 뒤져도 commit message뿐. session 안에서 내가 버린 방안, 실패한 시도, 세 번 고친 프롬프트, 에러 출력—글의 가장 값진 부분이 전부 사라진다.
더 나쁜 건, 글 쓸 때쯤엔 왜 A를 골랐는지 기억도 안 난다는 점. "이유가 있었던 것 같은데"와 "그 이유는 X였다"는 글의 신뢰도 한 단계 차이다.
회의록 쓰는 비용이 글 쓰는 비용보다 크다. 그래서 안 쓴다. 해결책은 기록이 스스로 일어나게 하는 것—hook.
Claude에게 hook 4개 + slash command 2개를 짜게 해서 개발 session을 자동으로 docs/notes/<feature>/raw.md로 바꿔둔다. 앞선 글 《Claude에게 프로덕션 배포 맡기기》에서 인용한 수많은 commit, bash 명령, 에러 스니펫은 전부 이 pipeline에서 나왔다. 이 글도 마찬가지.
hook 4개 + 수동 slash command 2개로 한 줄짜리 파이프라인:
| 트리거 | 하는 일 |
|---|---|
| PostToolUse(Edit\ | Write\ |
| PostToolUse(Bash) | master 복귀 + 깨끗하면 녹화 중단 |
| git post-commit | commit마다 checkpoint 한 줄 |
| Stop | session 종료 시 transcript에서 이벤트 추출 |
/record-feature NAME /stop-recording |
수동 오버라이드 |
상태 파일은 단 하나: docs/notes/.state.json. 모든 hook이 이걸 읽고 쓴다. 다른 조율 장치 없음.
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
}]
}
]
maybe-start 핵심 로직:
def cmd_maybe_start():
if load_state(): return # 이미 녹화 중
if not tree_is_dirty(): return # 건드린 게 없으면 스킵
feature = branch_to_feature(current_branch())
if not feature:
feature = "session-" + datetime.now().strftime("%Y%m%d-%H%M")
save_state({"feature": feature, "auto_started": True, ...})
branch_to_feature("feature/pro")는 "pro"를 반환. 개발자는 이미 branch로 작업을 나누고 있으니 그 심리적 경계를 그대로 재활용한다—이 pipeline이 버티는 근본 이유는 사용자에게 새 기억 부담을 요구하지 않는다는 점. master에서 일회성 스크립트 쓸 땐 타임스탬프로 fallback.
Edit/Write에 거는 이유: 코드를 쓰는 행위가 session이 진짜 "시작된" 신호. 질문, 파일 읽기, 테스트 실행—다 안 쳐준다.
{
"matcher": "Bash",
"hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-stop" }]
}
def cmd_maybe_stop():
state = load_state()
if not state or not state.get("auto_started"): return
if current_branch() not in ("master", "main"): return
if tree_is_dirty(): return
clear_state()
세 관문: 수동 녹화는 안 멈춤, feature branch 위면 안 멈춤, 트리 더티면 안 멈춤.
Edit 아니라 Bash에 거는 이유: master로 머지한 뒤에는 보통 Edit 안 쓰지만 git status / git log / bin/rails test는 반드시 친다—아무 명령이나 하나 치면 hook이 "마무리할 때"를 판단할 기회가 생긴다.
auto_started 플래그가 핵심. /record-feature pro-launch로 수동 녹화를 시작해서 여러 branch, 여러 머지를 거치는 경우 자동 중단 규칙이 있으면 오인 종료된다. 수동 녹화는 절대 자동 중단되지 않고, /stop-recording으로만 끈다.
Claude Code hook이 아니라 .git/hooks/post-commit:
#!/bin/bash
ROOT=$(git rev-parse --show-toplevel) || exit 0
CLAUDE_PROJECT_DIR="$ROOT" "$ROOT/bin/recording-state" commit || true
recording-state commit은 commit message를 그대로 raw.md에 추가:
### Commit 2026-04-16 21:55: `71f38a1`
> Add pricing page and expand account UI (P6 phases 1-2)
>
> Pricing (/pricing):
> - Displays all 6 plans in monthly/yearly grid with Stimulus toggle
> - Anonymous users see Subscribe → sign-in flow
> ...
commit message가 중요한 이유: 기능이 막 끝난 그 순간, 컨텍스트가 꽉 찬 상태에서 사람(혹은 Claude)이 손으로 쓴 요약이다. 나중에 회상하는 것보다 정확하고, transcript보다 짧고, diff보다 추상적.
Claude가 commit message를 쓸 때, 사실상 너의 미래 글의 소재 요약을 써주고 있는 셈—처음에 잘 쓰기만 하면 나중에 다시 쓸 필요 없다.
"Stop": [{
"hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }]
}]
session이 끝나면 Claude Code가 transcript_path를 stdin JSON으로 hook에 넘긴다. extract-session-notes는 그 jsonl을 열어 줄 단위로 파싱하고 분류:
keep_patterns = (
"test", "spec", "rspec", "minitest",
"kamal", "git commit", "git push",
"rails db", "rails routes", "rails runner",
"migrate", "curl -X", "curl -s -X",
)
if any(kw in cmd for kw in keep_patterns):
bash_cmds.append({"cmd": cmd[:400], "desc": desc})
화이트리스트에 있는 bash만 뽑는다. 코드 짜는 session 안의 90%는 ls / cat / grep / head—글에 쓸 일 없다, 전부 필터. 남는 것(테스트, kamal, rails runner, curl API 호출)이 이야기 있는 명령.
사용자 프롬프트에서 <command-*>나 <system-*>로 감싸진 건 전부 버린다, 진짜 입력만 남김. Edit/Write 경로는 set에 중복 제거. 에러는 앞 400자만. Task 서브 agent 호출은 보존(프롬프트 2000자로 자르지만 서브 agent가 뭘 했는지 보기엔 충분).
Stop hook은 녹화 주기당 한 번이 아니라 session 종료마다 발화한다. 같은 feature branch에서 Claude Code를 대여섯 번 열고 닫으면, 매번 transcript 전체를 재파싱해서 쓰면 raw.md는 중복으로 폭발한다.
해법: state file에 last_extracted_at 커서를 박는다:
filter_after = state.get("last_extracted_at") or state.get("started_at")
events = extract_events(transcript_path, filter_after)
# ...쓰고 나서...
state["last_extracted_at"] = datetime.now().astimezone().isoformat()
save_state(project_dir, state)
매번 커서 이후 이벤트만 뽑는다. 단순하지만 잊으면 수백 줄이 중복으로 쏟아진다.
/record-feature NAME:
{
"feature": "NAME",
"started_at": "ISO timestamp",
"branch": "current branch",
"auto_started": false
}
auto_started: false 한 줄이 자동 중단을 무력화. 용도: master에서 일회성 스크립트 쓰면서도 기록은 남기고 싶을 때, 여러 branch를 넘나드는 큰 기능, 명확히 "이건 글감이다" 선언할 때.
/stop-recording: 최신 jsonl로 최종 추출 한 번 돌리고 state file 정리.
실제 단편(docs/notes/pro/raw.md에서):
## Session 2026-04-16 21:52 (`7a81bf9d`)
### User prompts
- 根据环境变量直接写好对应只,不用传。
### Files edited/written
- `config/deploy.yml`
- `config/initializers/x402.rb`
### Commit 2026-04-17 00:13: `f87ea8e`
> Add production credentials (Stripe live + x402 mainnet)
> ...
### Commit 2026-04-17 00:57: `eba9ac9`
> Fix production x402 wallet_address (stray fullwidth '?' at end)
글 쓸 땐 그냥 grep: "지갑 주소 버그 고친 commit이 뭐였더라?" → eba9ac9. "리팩터 서브 agent 프롬프트 어떻게 썼었지?" → ### Sub-agent invocations 아래.
docs/notes/는 .gitignore 안에 있다—소스코드가 아니라 글쓰기용 초고 재료.
1. branch를 feature 경계로 쓴다, 별도 메타데이터 체계 안 만듦. 개발자는 이미 branch로 일을 나눈다. "기능명"이라는 레이어를 추가하면 필연적으로 drift. 기존 경계 재활용, 추가 인지부담 0.
2. bash는 화이트리스트, 블랙리스트 아님. transcript bash의 95%는 노이즈. "간직할 가치 있는 것" 목록은 몇 년 안정적이고, "걸러낼 것" 목록은 10배 불안정.
3. cursor는 state file 안에, transcript 쪽에 두지 않는다. transcript는 Claude Code 소유, state는 pipeline 소유. 디커플링—Claude Code가 transcript 포맷 바꿔도 나는 안 깨지고, pipeline 로직 바꿔도 transcript 건드릴 필요 없음.
1500자 글 하나 쓰려면 대략 commit 30개, session 4번, 핵심 bash 명령 십수 개 분량의 소재가 필요하다. 손으로 모으면 한 시간—다음번엔 대충 지어내고 싶은 유혹이 충분히 강하다.
hook에 맡기면 한계비용 0. 이 pipeline을 한 번 박아두면, 그냥 평소대로 branch 자르고 코드 쓰고 배포하면 글 소재가 저절로 쌓인다.
이 글에서 인용한 모든 commit, bash 스니펫, 에러, 파일 경로는 docs/notes/pro/raw.md에서 왔다—이틀 전 feature/pro를 자른 그 순간부터 hook이 스스로 기록한 초고 재료다. 글 쓰려고 파일을 열었을 때 필요한 건 다 있었다.
Claude에게 Claude Code에 관한 글을 쓰게 하는 최선의 방법은, 먼저 Claude에게 자기 자신을 기록하는 hook부터 짜게 하는 것.