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 把代码写对是容易的事,但"该把功能切在哪一层"这个决定不会有人替你做——那是你自己对工具边界的清晰判断。