免费

让 Claude 写 hook 记录自己的 session

写实战文章最难的是凑素材:git log 只有 commit message,session 里的关键细节全丢。4 个 hook + 2 个 slash command 把 session 自动变成 raw.md,素材 0 边际成本堆出来。


写一篇"Claude 帮我做了 X"的实战文章,最难的不是写,是凑素材。回头扒 git log 只有 commit message,session 里被你否决的方案、失败的尝试、改过三次的提示词、报错的输出——这些才是文章最值钱的部分,全丢了。

更糟的是:你写文章时已经记不清当时为什么选 A 不选 B。"我记得有个理由"和"那个理由是 X"差着一篇文章的可信度。

写会议记录的成本远高于写文章,所以你不会记。能解决这件事的,只有让记录这件事自己发生——hook。

让 Claude 写了 4 个 hook + 2 个 slash command,把每次开发 session 自动变成 docs/notes/<feature>/raw.md。前一篇《让 Claude 做生产部署》引用的大量 commit、bash 命令、错误片段,全部从这个 pipeline 出来。本文也是。


Pipeline 全景

4 个 hook + 2 个手动 slash command 串起一条流水线:

触发器 干啥
PostToolUse(Edit\ Write\
PostToolUse(Bash) 回 master + 干净就停录
git post-commit 每个 commit 写一笔 checkpoint
Stop session 结束时解析 transcript 抽事件
/record-feature NAME /stop-recording 手动覆盖

状态文件只有一个:docs/notes/.state.json。所有 hook 都读它写它,没有别的协调。

Hook #1:何时开始录

"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 真正"开干"的信号。光问问题、看文件、跑测试都不算。

Hook #2:何时停录

{
  "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 上不停,树脏不停。

挂在 Bash 上而不是 Edit 上的原因:合并回 master 之后通常不会再 Edit,但一定会 git status / git log / bin/rails test——只要敲一下命令,hook 就有机会判断"该收尾了"。

auto_started 这个标记是关键。如果你 /record-feature pro-launch 手动开了一段记录,跨多个 branch 跨多次合并,自动停止规则会误杀。手动开的从不自动停,只能 /stop-recording

Hook #3:commit 即 checkpoint

不是 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 的时候,相当于在帮你写文章的素材摘要——你只要让它写好,不用回头再写一遍。

Hook #4:从 transcript 抽事件

"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% 的 bash 是 ls / cat / grep / head——文章用不到,全过滤掉。剩下的(跑测试、跑 kamal、跑 runner、curl 接口)才是有故事的命令。

用户 prompt 里 <command-*><system-*> 包裹的全部丢掉,只留真实输入。Edit/Write 的路径去重存 set。报错只留前 400 字。Task 子 agent 调用保留(prompt 截到 2000 字符,看清子 agent 干了啥足够)。

一个 session 多次 Stop:用 cursor 去重

Stop hook 不是一个 recording 周期触发一次,是每次 session 结束都触发。同一个 feature branch 上你可能开关 Claude Code 五六次,如果每次都 re-parse 整个 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)

每次只抽游标之后的事件。简单,但漏了就重复,一抽就是几百行。

手动覆盖:两个 slash command

/record-feature NAME

{
  "feature": "NAME",
  "started_at": "ISO timestamp",
  "branch": "current branch",
  "auto_started": false
}

auto_started: false 这一行让自动停止失效。适合:在 master 上写一次性脚本但还想留记录、跨多个 branch 的大功能、明确"这是要写文章的"。

/stop-recording:手动跑一次 final extraction(最近一个 jsonl),清掉 state file。

raw.md 长什么样

真实片段(截自 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:"要找钱包地址那个 bug 是哪个 commit?" → eba9ac9。"那次重构子 agent 的 prompt 是怎么写的?" → 在 ### Sub-agent invocations 里。

docs/notes/.gitignore 里——是给你写文章用的草稿料,不是源码。

三个非显然的设计决策

1. branch 作为 feature 边界,而不是另搞一套元数据。 用户已经在用 branch 切分工作,多套一层"功能名"必然 drift。复用现有边界,0 额外认知负担。

2. bash 白名单而不是黑名单。 transcript 里 95% 的 bash 是噪音。维护"哪些值得记"的列表,比维护"哪些要过滤掉"的列表,长期下来稳定 10 倍。

3. cursor 在 state file 里,不在 transcript 里。 transcript 是 Claude Code 拥有的,state 是 pipeline 拥有的。两边解耦,Claude Code 改 transcript 格式我不影响,pipeline 改逻辑也不需要碰 transcript。

闭环

写完一篇 1500 字的文章大约要 30 个 commit、4 次 session、十几条关键 bash 命令的素材。手动整理一次大概 1 小时,足够让你下次直接编。

让 hook 自动记,0 边际成本。这套 pipeline 装好之后,你只要正常切 branch 写代码,文章素材自己就堆出来了。

本文引用的 commit、bash 命令、错误片段、文件路径,全部来自 docs/notes/pro/raw.md——这份草稿料,是从两天前切 feature/pro 的那一刻起 hook 自己记下来的。等到要写文章才打开看,发现该有的都有。

让 Claude 写文章关于 Claude Code,最好的方式是先让 Claude 写 hook 记录自己。