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、作者、分支、diff)
實際後果:你用 Claude Code 寫程式 + commit,兩層 hook 都會開火,session 資訊和 commit 資訊都進 raw.md。你改用 Amp(或 Cursor、或手敲)寫程式 + commit,只有 git hook 會開火,raw.md 只留下 commit 骨架,沒有 session 的 prompt / bash / edit 細節。
這不是 bug——是這兩層工具各自的設計約束。想在所有工具下都拿到 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,不搬權限清單。
how2claude 的 settings.local.json 裡有 100 多條 permissions.allow——全是針對 how2claude 專案的(curl localhost:3000、bin/rails runner、kamal app exec)。這些搬到 smarts 毫無意義。smarts 會在用的過程中自然累積自己的權限清單。
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-commit3 行:
#!/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——這就是把 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 跟著 commit 進 PR;你也不希望 .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——自動起了錄,feature 名從分支 feat/contract-to-docs 推導出 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(落回 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 把 session recording 搬到另一個專案的 5 步:
cp 兩個 Python 指令稿到目標專案的 bin/——零修改,指令稿認 $CLAUDE_PROJECT_DIR,跨專案天然可移植。.claude/settings.local.json,只放 hooks 部分。不搬權限清單——permissions 是專案狀態,跨專案各異;hooks 是模式,跨專案一樣。.git/hooks/post-commit(3 行),手動 export CLAUDE_PROJECT_DIR=$ROOT 調 recording-state commit。這是 git 和 Claude Code 兩層靠同一環境變數接上的地方。.gitignore 加 docs/notes/。筆記是臨時 + 私有,不入 git。CLAUDE_PROJECT_DIR=$(pwd) ./bin/recording-state maybe-start 看分支推斷對不對。如果 tree 不是 dirty,指令稿按設計會跳過——不是 bug。真正的設計決定不是「怎麼搬」——幾乎就是 cp。是把錄製邏輯拆在 4 個不同層,每層做一件清晰的事:
CLAUDE_PROJECT_DIR這 4 層分別可以遷移、分別可以替換(把 Amp 當 Claude Code 用?只需補一層 Amp hook;換一個筆記格式?只改 Python 指令稿;不想 git 追筆記?刪 post-commit 就行)。Claude 把程式寫對是容易的事,但「該把功能切在哪一層」這個決定不會有人替你做——那是你自己對工具邊界的清晰判斷。