本教程聚焦 Claude Code 三类常被忽视的「上下文生命周期钩子」——SessionStart、UserPromptSubmit、PreCompact,带你从零搭建一个项目记忆管家:会话启动即注入状态、每条提问按关键词补充相关文档、压缩前把目标抢救到磁盘。读完即可让 Claude 在长会话和 -p 批处理中始终保持「记得自己在做什么」。适合已跑通首个 Claude Code 项目、想进阶自动化的开发者。
claude --version 可用),跑通过至少一个交互式或 -p 任务bash、jq、git(jq 是解析钩子 stdin 的核心,没有就 brew install jq / apt install jq)settings.json 基本结构(项目级放在 .claude/settings.json,用户级放在 ~/.claude/settings.json)Claude Code 的 8 类钩子事件,社区教程九成都在讲 PreToolUse / PostToolUse(拦危险命令、自动格式化)。但真正决定 Claude「记不记得住事」的,是另外三类的生命周期钩子:
| 钩子事件 | 触发时机 | 最擅长的事 |
|---|---|---|
SessionStart |
新会话、--resume 恢复、/clear 清屏、/compact 压缩后 |
启动即注入项目状态、上次笔记、git 摘要 |
UserPromptSubmit |
用户按下回车、提问进入 Claude 之前 | 按关键词路由,给这条提问补充相关上下文 |
PreCompact |
/compact 手动或上下文将满自动压缩之前 |
把目标、待办、关键决策抢救到磁盘,防止压缩丢失 |
这三者的共同点是:它们的 stdout(exit 0)能被注入进 Claude 的上下文(PreCompact 例外,它的 stderr 只给用户看,本身不注入——它的活儿是写盘抢救)。这恰好补上了 CLAUDE.md 静态记忆做不到的两件事:动态、按需、跨压缩持续。
CLAUDE.md 是「永远在场」的全局记忆,但它是静态的、全文常驻、不分场景。而上下文管家钩子是「按需召唤」的:启动时给一份当前状态,提问时按内容补一两段相关文档,压缩前把要紧的东西先存盘。两者互补,不是替代。
⚠️ 重要区别:这三类钩子和
PreToolUse/PostToolUse的 exit code 2 行为不同。对SessionStart/UserPromptSubmit/PreCompact,exit code 2 的 stderr 只给用户看,不进上下文,也不阻断流程(UserPromptSubmit的 exit 2 会阻断该提问并擦除)。这是后面调试时最常踩的坑。
先把目录和测试方法备好,避免后面改一次就要重启会话。
目录结构:
your-project/
├── .claude/
│ ├── settings.json # 注册三类钩子
│ ├── hooks/
│ │ ├── on-session-start.sh
│ │ ├── on-prompt-submit.sh
│ │ └── on-precompact.sh
│ ├── context/ # UserPromptSubmit 路由用的知识片段
│ │ ├── testing.md
│ │ ├── deploy.md
│ │ └── database.md
│ └── notes/ # PreCompact 抢救出来的笔记(自动生成)
└── STATUS.md # 人机共维护的项目状态
STATUS.md 是个简单约定,内容示例:
# 当前目标
- 把 src/ 里的错误处理统一改成返回 Result 类型
- 修复 #142:登录偶发 500
# 已知约束
- 不得引入新依赖,预算已锁
三种验证方法(按从快到慢排列):
管道喂数据(最快,不依赖 Claude):直接构造一份 stdin JSON 喂给脚本,看 stdout。
bash
echo '{"hook_event_name":"SessionStart","source":"startup","cwd":"'"$PWD"'","session_id":"t1","transcript_path":""}' \
| bash .claude/hooks/on-session-start.sh
能看到拼接好的上下文片段,就说明逻辑对了。
/hooks 命令:在交互式会话里输入 /hooks,会列出当前生效的所有钩子及其事件,能确认配置有没有被正确加载。
transcript 回看(Ctrl+R):钩子注入的上下文会出现在 transcript 里。按 Ctrl+R 进入 transcript 模式,能看到「这条提问注入了什么」「会话启动注入了什么」,是排查「为什么没生效」的终极手段。
关键心法:对
SessionStart和UserPromptSubmit,脚本的 stdout 会被原样注入 Claude 上下文。这意味着脚本里任何一句调试用的echo "debug..."都会污染上下文。调试一律走 stderr(echo ... >&2),它只给用户看,不进上下文。
会话一开(或 /clear、--resume、/compact 之后),Claude 的上下文是「干净」的。这正是注入项目状态的最佳时机。
.claude/hooks/on-session-start.sh:
#!/usr/bin/env bash
# SessionStart 钩子:会话启动/恢复/清屏/压缩后注入项目上下文
set -euo pipefail
input=$(cat)
source=$(printf '%s' "$input" | jq -r '.source // "startup"')
cwd=$(printf '%s' "$input" | jq -r '.cwd // "."')
cd "$cwd" 2>/dev/null || exit 0 # 进不去项目目录就静默退出
ctx=""
ctx+="## 🧠 项目记忆管家 · 会话上下文(来源: $source)\n\n"
# 1) 上次 PreCompact 抢救的笔记:取最近一份
latest=$(ls -t .claude/notes/*.md 2>/dev/null | head -1)
if [ -n "$latest" ]; then
ctx+="### 🧷 上次压缩前抢救的笔记($latest)\n$(sed -n '1,30p' "$latest")\n\n"
fi
# 2) STATUS.md:人机共维护的当前目标
if [ -f STATUS.md ]; then
ctx+="### 📋 STATUS.md\n$(sed -n '1,40p' STATUS.md)\n\n"
fi
# 3) Git 摘要:当前分支 + 未提交改动数
if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
branch=$(git branch --show-current 2>/dev/null || echo "?")
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
ahead=$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo "0")
ctx+="### 🌿 Git:分支=$branch 未提交=$dirty 领先远端=$ahead\n\n"
fi
# stdout + exit 0 → 这段文本被注入上下文
printf '%b' "$ctx"
exit 0
逐行说明:
set -euo pipefail:任意一步失败立即退出,避免半截错误输出污染上下文。cd "$cwd":钩子默认在项目目录运行,但显式 cd 更稳妥;进不去就 exit 0 静默放行(钩子不应阻断会话启动)。printf '%b':让 \n 真正换行;用 echo -e 在不同 shell 行为不一致。exit 0 是关键:exit 0 + 非空 stdout = 注入上下文。预期结果:会话一启动,Claude 就「知道」当前在哪个分支、有几个未提交改动、上次压缩前在忙什么、当前目标是什么。你不用每次开场都重复「我们在做一个登录修复」。
常见问题:
set -e 让某条命令失败导致脚本中途退出,但前半段 ctx+= 已经拼了——把不可靠的命令(如 git rev-list 依赖远端)用 || true / 2>/dev/null 兜底。startup/resume/clear/compact。可以据此区分行为——比如 resume 时少注入点(用户大概率记得上下文),clear/compact 后多注入点(上下文被清空了)。SessionStart 是「开场白」,UserPromptSubmit 才是「随叫随到」。它在提问进入 Claude 之前触发,收到 prompt 字段,可以分析提问内容并补充相关文档。
这解决一个真实痛点:项目文档全堆进 CLAUDE.md 会撑爆上下文;不堆又导致 Claude「不知道有这套规范」。UserPromptSubmit 让上下文按需进场。
.claude/hooks/on-prompt-submit.sh:
#!/usr/bin/env bash
# UserPromptSubmit 钩子:按提问关键词路由补充相关上下文片段
set -euo pipefail
input=$(cat)
prompt=$(printf '%s' "$input" | jq -r '.prompt // ""')
cwd=$(printf '%s' "$input" | jq -r '.cwd // "."')
cd "$cwd" 2>/dev/null || exit 0
[ -d .claude/context ] || exit 0 # 没有知识目录就不补
add=""
# 关键词 → 上下文片段的路由表
if printf '%s' "$prompt" | grep -qiE '测试|test|单测|tdd|覆盖率'; then
add+="$(cat .claude/context/testing.md 2>/dev/null)\n\n"
fi
if printf '%s' "$prompt" | grep -qiE '部署|deploy|发布|上线|prod|回滚'; then
add+="$(cat .claude/context/deploy.md 2>/dev/null)\n\n"
fi
if printf '%s' "$prompt" | grep -qiE '数据库|db|sql|表结构|schema|migration|索引'; then
add+="$(cat .claude/context/database.md 2>/dev/null)\n\n"
fi
# 没命中任何关键词就静默退出,不污染上下文
[ -z "$(printf '%b' "$add" | tr -d '[:space:]')" ] && exit 0
# 结构化输出:additionalContext 会被附加到这条提问的上下文里
jq -nc --arg ctx "## 📎 相关上下文(按提问关键词自动补充)\n\n${add}" \
'{hookSpecificOutput:{hookEventName:"UserPromptSubmit", additionalContext:$ctx}}'
知识片段示例 .claude/context/testing.md:
# 测试规范
- 单测用 Vitest,文件放 src/__tests__/,命名 *.test.ts
- 禁止 mock 数据库,用 testcontainers 起真实 PG
- 覆盖率门槛:核心模块 ≥ 90%,工具函数 ≥ 80%
- 提交前必须跑 `npm run test:cov`
两种输出方式的选择:
hookSpecificOutput.additionalContext(JSON):推荐,语义明确,且和「对用户的回复」区分清晰。预期结果:你输入「给登录函数补单测」,钩子命中 测试|单测,自动把 testing.md 的规范塞进上下文。Claude 生成的测试会直接遵守「用 Vitest、不 mock 数据库、放 __tests__/」,而不是自作主张用 Jest。
进阶:语义路由而非关键词
关键词 grep 简单可靠,但「帮我把用户表加个索引」里没有「数据库」三个字却命中了 索引。要更准,可以调用一个本地嵌入模型或直接在脚本里 curl 一个轻量分类接口。但先用关键词跑通,80% 场景够用,别过早优化。
常见问题:
UserPromptSubmit 的 exit code 2 会擦除该提问并阻断。脚本里别手滑返回非零。想阻断(如检测到提问含密钥),明确用 exit 2 并把提示写到 stderr。grep -q 退出码 1 被 set -e 杀掉:grep 没匹配返回 1,set -e 会误判为失败。用 if grep -q ...; then 包裹(if 里的命令不受 set -e 影响),或加 || true。长会话里上下文迟早会满,Claude Code 自动 /compact,或你手动压缩。压缩会丢弃大量中间细节,目标和待办尤其容易丢。PreCompact 在压缩执行之前触发,是「抢救黄金窗口」。
.claude/hooks/on-precompact.sh:
#!/usr/bin/env bash
# PreCompact 钩子:压缩前把最近目标/待办抢救到 .claude/notes
set -euo pipefail
input=$(cat)
trigger=$(printf '%s' "$input" | jq -r '.trigger // "manual"') # manual | auto
tpath=$(printf '%s' "$input" | jq -r '.transcript_path // ""')
sid=$(printf '%s' "$input" | jq -r '.session_id // "unknown"')
mkdir -p .claude/notes 2>/dev/null || true
# 从 transcript 抽取最近的若干条 user 消息作为「目标」抢救
rescued=""
if [ -n "$tpath" ] && [ -f "$tpath" ]; then
rescued=$(jq -r 'select(.type=="user") | .message.content
| if type=="array" then (map(select(.type=="text").text)|join(" "))
else . end' "$tpath" 2>/dev/null \
| grep -vE '^\s*$' | tail -n 25 || true)
fi
out=".claude/notes/session-${sid}.md"
{
echo "# 压缩前抢救 · trigger=$trigger · $(date -u +%FT%TZ 2>/dev/null)"
echo
echo "## 最近用户目标(最近 25 条)"
echo "${rescued:-(未抽取到用户消息)}"
} > "$out"
# PreCompact 的 stderr 只给用户看,不进上下文——适合做日志
echo "[记忆管家] trigger=$trigger 已抢救最近目标 → $out" >&2
exit 0
注意 PreCompact 的特殊性:
additionalContext 输出,stdout/stderr 都不注入 Claude 上下文。它的使命是写盘,把信息存到 .claude/notes/,等下一次 SessionStart 把它读回来。PreCompact(压缩前抢救)→ 磁盘 → SessionStart(下次启动读回)。压缩丢的信息,靠磁盘中转回来了。.message.content 在 transcript 里可能是字符串也可能是数组(含图片/工具结果),上面的 jq 用 if type=="array" 兼容两种形态——这是真实 transcript 解析最常踩的坑。预期结果:每次压缩前,最近的目标和指令被存盘。即便会话被压缩多次、甚至重启,SessionStart 都能把上次的「我们在修 #142」捞回来。
常见问题:
transcript_path 是 .jsonl 文件,钩子里直接 jq 逐行解析即可。若文件很大,tail 限制行数,别一次性读全。select(.type=="user") 过滤,并进一步排除 tool_result。三类脚本就绪,统一注册到项目级 .claude/settings.json:
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/on-session-start.sh" } ] }
],
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/on-prompt-submit.sh" } ] }
],
"PreCompact": [
{ "hooks": [ { "type": "command", "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/on-precompact.sh" } ] }
]
}
}
要点:
$CLAUDE_PROJECT_DIR(Claude Code 注入的项目根环境变量)而非相对路径,避免从子目录启动时找不到脚本。bash 前缀,不依赖脚本可执行位(跨平台更稳,Windows/WSL 少踩坑)。PreToolUse/PostToolUse 才用 matcher;这三类事件不识别 matcher,写了也无效。UserPromptSubmit 若挂了 HTTP 调用要确保快返回,或把重活异步化。放好后 claude 重启会话,/hooks 应能看到三个事件。然后跑方法 1 的管道逐个验证,最后做端到端测试:输一句带「测试」的提问,Ctrl+R 看注入。
-p 无头模式联动:批量任务的记忆管道-p(print/headless)模式适合 CI、批处理、脚本编排。但 -p 每次都是「冷启动」,上下文天然为空——这正是 SessionStart 钩子的用武之地。
给一个 -p 审查任务接上记忆管道:
claude -p "按 STATUS.md 的目标,审查 src/ 的错误处理是否统一返回 Result 类型" \
--output-format json | jq -r '.result'
这条命令运行时:
SessionStart 触发 → 注入 STATUS.md 目标 + git 摘要 + 上次抢救的笔记。Claude 一上来就知道「目标是统一 Result 类型」。UserPromptSubmit 命中关键词 → 补 testing.md 规范(注:-p 的 prompt 也会过 UserPromptSubmit)。PreCompact 抢救,下次 -p 还能读到。进一步,把多个 -p 串成批量管道,每个任务都自带项目记忆:
# 批量审查最近 5 个改动文件,各自独立但共享记忆管家
for f in $(git diff --name-only HEAD~5); do
claude -p "审查 $f 的错误处理与测试覆盖,违反 STATUS.md 约束请指出" \
--output-format json | jq -r '.result' > "review-$(basename $f).md"
done
每个 -p 冷启动都被 SessionStart 喂了相同的项目上下文,输出一致、可复现。这比依赖会话历史稳定得多——记忆不在脑子里,在磁盘和钩子里。
组合心法:
SessionStart管「冷启动缺上下文」,UserPromptSubmit管「提问缺相关文档」,PreCompact管「长任务丢目标」。三者覆盖了-p批处理和长交互会话的全部记忆盲区。
把上面的零件组装成一个可复现的最小演示。
步骤 1:建项目骨架。
mkdir mem-butler-demo && cd mem-butler-demo
git init -q
mkdir -p .claude/{hooks,context,notes}
touch STATUS.md
步骤 2:填 STATUS.md 和一个知识片段。
cat > STATUS.md <<'EOF'
# 目标
- 把 login() 改成返回 Result<User, AuthError>
# 约束
- 不引入新依赖
EOF
cat > .claude/context/testing.md <<'EOF'
# 测试规范
- 用 Vitest,文件 *.test.ts
- 禁止 mock 数据库,用 testcontainers
- 覆盖率核心模块 ≥ 90%
EOF
步骤 3:写入第三~五节的三份脚本,chmod +x .claude/hooks/*.sh(虽然用 bash 显式调用不强制,但加上无害)。
步骤 4:写入第六节的 settings.json。
步骤 5:单独验证三脚本(方法 1)。
echo '{"hook_event_name":"SessionStart","source":"startup","cwd":"'"$PWD"'","session_id":"d1","transcript_path":""}' \
| bash .claude/hooks/on-session-start.sh
# 期望:输出含 STATUS.md 目标 + Git 摘要
echo '{"hook_event_name":"UserPromptSubmit","prompt":"给 login 补单测","cwd":"'"$PWD"'","session_id":"d1","transcript_path":""}' \
| bash .claude/hooks/on-prompt-submit.sh
# 期望:输出 JSON,additionalContext 含 testing.md 规范
步骤 6:端到端。启动 claude,/hooks 确认三个事件,输入「给 login 补单测」,Ctrl+R 看注入。再 /compact,检查 .claude/notes/ 是否生成了抢救笔记。重启会话,确认 SessionStart 把抢救笔记读回。
步骤 7(批量):跑第七节的 -p 审查循环,观察每个任务都被喂了 STATUS.md。
跑通后,这套管家可直接复制到任何真实项目:改 STATUS.md、增减 .claude/context/*.md、调整 on-prompt-submit.sh 的关键词路由表即可。
flowchart TD
A[会话启动 / 恢复 / 清屏 / 压缩后] -->|SessionStart 钩子| B[注入: STATUS + git + 上次抢救笔记]
B --> C[Claude 主循环]
U[用户或 -p 提交提问] -->|UserPromptSubmit 钩子| D[关键词路由: 补 testing/deploy/db 片段]
D --> C
C -->|上下文将满| E["手动 /compact 或自动压缩"]
E -->|PreCompact 钩子| F["抢救目标/待办 → .claude/notes/"]
F --> G[压缩执行: 丢弃中间细节]
G --> C
F -.磁盘中转.-> A
C -->|任务完成| H[Stop]
style B fill:#e8f5e9
style D fill:#fff3e0
style F fill:#fce4ec
闭环的核心是 PreCompact → 磁盘 → SessionStart 这条虚线:压缩丢的信息,靠磁盘中转跨压缩存活。
SessionStart/UserPromptSubmit 的 stdout 全进上下文。调试一律 >&2,发布前 grep -n 'echo' 扫一遍。jq 报 parse error:stdin 为空或非 JSON。脚本开头 input=$(cat) 后加 printf '%s' "$input" | jq -e . >/dev/null || exit 0 容错。settings.json 在 .claude/ 下、JSON 合法(jq . .claude/settings.json 能解析)、$CLAUDE_PROJECT_DIR 指向正确。/hooks 是第一排查点。UserPromptSubmit 误阻断提问:脚本返回非零 exit 会擦除提问。用 set +e 或把可能失败的命令包进 if/|| true。.sh;或改写为 .js/.py 脚本,command 用 node/python 前缀。UserPromptSubmit 每条提问都跑,脚本必须快(< 200ms)。重活(调 API、跑 lint)留给 PostToolUse 或 -p 的独立步骤。https://code.claude.com/docs/en/hookshttps://code.claude.com/docs/en/hooks-guidehttps://gist.github.com/FrancisBourre/50dca37124ecc43eaf08328cdcccdb34https://deepwiki.com/disler/claude-code-hooks-mastery/3.2-session-and-context-hookshttps://aiorg.dev/blog/claude-code-hooks-p 批量联动 30 分钟。PreCompact 增加决策抽取(不只是用户消息,还抽 Claude 的关键承诺);把管家打包成可 claude plugin 安装的技能分发。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
