Hooks 让你可以在 Qoder IDE 和 JetBrains 插件执行的关键节点插入自定义逻辑,而无需修改任何代码。你只需编辑 JSON 配置文件,就能实现诸如:
- 在工具执行前拦截危险操作
- 写文件后自动跑 lint,保持代码风格一致
- Agent 完成任务时弹出桌面通知,不用一直盯着 IDE
与 Prompt 指令不同,Hooks 是确定性的——只要事件触发,脚本就一定执行,不受模型理解偏差的影响。
各入口的 Hooks 能力不同:本页对应 Qoder IDE / JetBrains 插件(12 个事件,支持 command 和 http 两种类型)。Qoder CLI Hooks 支持 25 个事件与 command / http / prompt / agent 四种类型;QoderWork Hooks 另见单独文档。IDE 与 CLI 共用同一份配置文件,但各入口只会执行自身支持的事件。
支持的事件
当前 IDE / JB 插件支持以下十二种 Hook 事件:
| 事件名称 | 触发时机 | 可阻断 |
|---|
| SessionStart | 会话启动或恢复时 | 否 |
| UserPromptSubmit | 用户提交 Prompt 后、Agent 处理前 | 是 |
| PreToolUse | 工具调用执行前 | 是 |
| PermissionRequest | 工具需要用户授权时 | 是 |
| PostToolUse | 工具调用成功后 | 否 |
| PostToolUseFailure | 工具调用失败后 | 否 |
| SubagentStart | 子代理启动时 | 否 |
| SubagentStop | 子代理停止时 | 否 |
| Stop | Agent 完成响应时 | 是 |
| SessionEnd | 会话结束时 | 否 |
| PreCompact | 上下文压缩前 | 否 |
| Notification | 发出面向用户的通知时 | 否 |
快速开始
下面用一个例子,演示如何拦截 rm -rf 这类危险命令。
创建脚本
mkdir -p ~/.qoder/hooks
cat > ~/.qoder/hooks/block-rm.sh << 'EOF'
#!/bin/bash
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command')
if echo "$command" | grep -q 'rm -rf'; then
echo "危险命令已被阻止: $command" >&2
exit 2
fi
exit 0
EOF
chmod +x ~/.qoder/hooks/block-rm.sh
添加配置
在 ~/.qoder/settings.json 中添加:{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/block-rm.sh"
}
]
}
]
}
}
验证效果
打开 IDE,在 Qoder 插件面板中让 Agent 执行包含 rm -rf 的命令。Hook 会阻止执行,并把错误信息反馈给 Agent。
适用场景
| 场景 | 对应事件 | 说明 |
|---|
| 拦截危险命令 | PreToolUse | 在 Agent 执行 rm -rf、DROP TABLE 等命令前阻止 |
| 文件路径校验 | PreToolUse | 限制 Agent 只能在指定目录下创建 / 编辑文件 |
| 自动 Lint / 格式化 | PostToolUse | 每次写文件后自动执行 ESLint / Prettier |
| 日志审计 | PostToolUse | 记录 Agent 的所有工具调用,用于安全审计 |
| 失败监控告警 | PostToolUseFailure | 工具调用失败时发送告警或记录错误日志 |
| Prompt 内容审查 | UserPromptSubmit | 检测用户输入中是否包含敏感信息(密码、密钥等) |
| 自动注入上下文 | UserPromptSubmit | 自动在 Prompt 末尾追加项目规范或编码约定 |
| 桌面通知 | Stop | Agent 完成后弹出系统通知提醒用户 |
工作原理
Hook 的使用流程可以概括为三步:编写脚本 → 注册配置 → 自动生效。
当 Agent 执行到某个生命周期节点时(如工具调用前),插件会检查配置中是否有对应的 Hook:
- 插件在启动时加载所有 Hook 配置
- Agent 运行过程中,到达某个事件节点(如
PreToolUse)
- 插件遍历该事件下所有 Hook 分组,用
matcher 匹配当前上下文
- 匹配成功的 Hook 按顺序执行对应的 shell 脚本
- 脚本通过
stdin 接收事件上下文(JSON),通过 exit code 和 stdout 返回决策
- 插件根据返回结果决定后续行为(放行 / 阻断)
前置条件
- jq:示例脚本依赖 jq 解析 JSON。macOS 下执行
brew install jq,Linux 下执行 apt install jq。
- 脚本权限:所有 Hook 脚本需要有可执行权限(
chmod +x)。
创建 Hooks
1. 确定需求:选择事件和匹配范围
首先明确你要在哪个节点介入,以及匹配什么条件:
我想在「什么时候」拦截/处理「什么操作」?
↓ ↓
选择事件 编写 matcher
| 需求 | 事件 | matcher |
|---|
| Agent 执行任何 Shell 命令前检查 | PreToolUse | "Bash" |
| Agent 写入或编辑文件后做处理 | PostToolUse | "Write|Edit" |
| Agent 工具调用失败时记录 | PostToolUseFailure | "Bash" 或不填 |
| 用户提交的所有 Prompt 做审查 | UserPromptSubmit | 不填(匹配所有) |
| Agent 停止时触发通知 | Stop | 不填(匹配所有) |
| 只拦截 MCP 工具 | PreToolUse | "mcp__.*" |
2. 编写 Hook 脚本
Hook 脚本是一个标准的 shell 脚本,遵循以下协议:
输入:通过 stdin 接收 JSON 格式的事件上下文
输出:通过 exit code 控制行为
exit 0 → 放行(继续执行)
exit 2 → 阻断(停止操作,stderr 注入对话)
其他值 → 错误(继续执行,stderr 展示给用户)
脚本模板:
#!/bin/bash
# 1. 读取 stdin 的 JSON 输入
input=$(cat)
# 2. 用 jq 提取你关心的字段
# 不同事件有不同的字段,具体见「事件参考」章节
tool_name=$(echo "$input" | jq -r '.tool_name')
tool_input=$(echo "$input" | jq -r '.tool_input')
# 3. 编写你的判断逻辑
if [ "$tool_name" = "Bash" ]; then
command=$(echo "$input" | jq -r '.tool_input.command')
# 检查是否包含危险操作
if echo "$command" | grep -qE 'rm\s+-rf|DROP\s+TABLE'; then
# 阻断:exit 2 + stderr 消息会反馈给 Agent
echo "操作被拒绝: $command" >&2
exit 2
fi
fi
# 4. 放行
exit 0
除了 exit code,你还可以在 exit 0 时输出 JSON 来提供更精细的控制:#!/bin/bash
input=$(cat)
# 输出 JSON 实现精细控制(仅 exit 0 时生效)
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"该操作不被允许"}}'
exit 0
3. 注册到配置文件
将脚本路径写入配置文件的对应事件下:
{
"hooks": {
"事件名": [
{
"matcher": "匹配条件(可选)",
"hooks": [
{
"type": "command",
"command": "脚本路径"
}
]
}
]
}
}
4. 测试与调试
可以直接在终端用管道模拟测试:
# 模拟 PreToolUse 事件
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"},"hook_event_name":"PreToolUse"}' \
| ~/.qoder/hooks/block-rm.sh
echo "Exit code: $?"
检查 stderr 输出(阻断消息):
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
| ~/.qoder/hooks/block-rm.sh 2>&1
配置 Hooks
配置文件位置
Hook 配置从以下文件加载,多级配置会被合并执行(优先级从低到高):
| 位置 | 作用域 | 优先级 | 可共享 | 说明 |
|---|
~/.qoder/settings.json | 用户级 | 1(最低) | 否 | 用户个人配置,对所有项目生效 |
.qoder/settings.json | 项目级 | 2 | 是 | 可提交到 Git,团队共享 |
.qoder/settings.local.json | 项目级(本地) | 3 | 否 | gitignore,个人开发配置 |
IDE / JB 插件与 CLI 共享同一套配置文件。当前版本暂不支持热加载,修改配置文件后需要重启 IDE 才能生效。
配置格式
{
"hooks": {
"事件名": [
{
"matcher": "匹配条件",
"hooks": [
{
"type": "command",
"command": "要执行的命令"
}
]
}
]
}
}
| 字段 | 必填 | 说明 |
|---|
type | 是 | "command" 或 "http" |
command | 是 | 要执行的 shell 命令或脚本路径 |
timeout | 否 | 超时时间(秒),默认 30 |
matcher | 否 | 匹配条件,不填则匹配该事件的所有触发 |
if | 否 | 更细粒度的单条 Hook 过滤条件,形如 "ToolName" 或 "ToolName(arg_pattern)" |
async | 否 | 为 true 时 Hook 在后台执行,不阻塞当前操作 |
asyncRewake | 否 | 为 true 时在后台执行,并可用结果唤醒模型——适合耗时检查 |
statusMessage | 否 | Hook 运行时在状态栏显示的自定义描述 |
一个事件下可以配置多个 matcher 分组,每个分组可以包含多个 Hook 命令。
matcher 匹配规则
matcher 用于过滤 Hook 的触发范围,不同事件匹配不同的字段(见各事件说明)。
| 写法 | 含义 | 示例 |
|---|
不填或 "*" | 匹配所有 | 所有工具都会触发 |
| 精确值 | 精确匹配 | "Bash" 只在 Bash 工具时触发 |
| 分隔 | 匹配多个值 | "Write|Edit" 在 Write 或 Edit 时触发 |
| 正则表达式 | 正则匹配 | "mcp__.*" 匹配所有 MCP 工具 |
工具名映射
Qoder 支持两套工具名——原生工具名与 Claude Code 兼容工具名。配置 Hook 时可使用任意一套,插件内部会统一映射后执行匹配。例如 matcher: "Bash" 和 matcher: "run_in_terminal" 等价。
| Qoder 原生名 | 兼容名 | 说明 |
|---|
run_in_terminal | Bash | 执行 shell 命令 |
read_file | Read | 读取文件内容 |
create_file | Write | 创建 / 写入文件 |
search_replace | Edit | 编辑文件 |
edit_file | - | 基于 diff / 代码块的编辑 |
get_terminal_output | - | 获取后台终端输出 |
delete_file | - | 删除文件 |
grep_code | Grep | 搜索文件内容 |
search_file | Glob | 文件名匹配 |
list_dir | LS | 列出目录 |
Agent | Task | 启动子代理(旧称 task) |
Skill | - | 调用技能 |
search_web | WebSearch | 网页搜索 |
fetch_content | WebFetch | 获取网页内容 |
todo_write | TodoWrite | 写入 TODO |
ask_user_question | - | 向用户提问 |
search_memory | - | 搜索记忆 |
update_memory | - | 更新记忆 |
switch_mode | - | 切换模式 |
create_plan | - | 创建计划 |
run_preview | - | 预览 Web 应用 |
get_problems | - | 读取 IDE 诊断信息 |
fetch_rules | - | 加载规则 |
ImageGen | - | 生成图片 |
mcp__<server>__<tool> | 同左 | MCP 工具 |
编写 Hook 脚本
Hook 脚本通过 stdin 接收 JSON 输入,通过 exit code 和 stdout 输出来控制行为。本节说明所有事件通用的输入输出格式,各事件的额外字段见 Hooks 事件。
Hook 脚本通过 stdin 接收 JSON 数据。所有事件都包含以下通用字段:
| 字段 | 说明 | 是否必有 |
|---|
session_id | 当前会话 ID | 是 |
cwd | 当前工作目录 | 是 |
hook_event_name | 触发的事件名称 | 是 |
transcript_path | 会话上下文 JSON 文件路径 | 是 |
request_set_id | 当前 IDE 请求轮次的 ID | 否 |
tool_name | 工具名(仅工具相关事件) | 否 |
tool_input | 工具输入参数 | 否 |
tool_response | 工具执行结果(仅 PostToolUse)。IDE 当前通常为字符串 | 否 |
extra.email | 用户的 Git 邮箱 | 否 |
extra.repo | 仓库路径(group/repo 格式) | 否 |
extra.branch | 当前分支 | 否 |
extra.request_time | 请求时间(RFC3339) | 否 |
extra.response_time | 响应时间(RFC3339) | 否 |
extra.full_diff_text | 本次变更的完整 diff(仅编辑类工具的 PostToolUse) | 否 |
不同事件会在此基础上附加额外字段(见各 Hooks 事件)。所有字段都应按可选消费——即使某字段已声明,特定执行路径下也可能不填充。
用 jq 解析输入:
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
Hook 通过 exit code 和 stdout 控制行为。
exit code 决定基本行为:
| Exit Code | 含义 | 行为 |
|---|
0 | 成功 | 继续执行,尝试解析 stdout JSON |
2 | 阻断 | 停止操作,stderr 内容注入对话(仅对支持阻断的事件生效) |
| 其他 | 错误 | 非阻断错误,stderr 显示给用户,继续执行 |
stdout JSON 可为部分事件提供精细控制,具体支持的字段见各事件说明。IDE 对退出码 0 和 2 都会尝试解析 stdout JSON——不要假设 exit 2 的 stdout 只会被当作纯文本。
公共 stdout 字段
除各事件在 hookSpecificOutput 内的专属字段外,以下顶层字段适用于所有事件:
| 字段 | 说明 |
|---|
systemMessage | 展示给用户的消息 |
continueWithPrompt | Agent 是否继续执行 |
decision | "block" 表示通用阻断决策。权限决策请改用 hookSpecificOutput.permissionDecision |
reason | 决策原因(部分事件定义了自己的 reason 字段) |
updatedToolOutput | 替换工具输出 |
hookSpecificOutput | 事件专属字段的容器(见各事件说明) |
环境变量
Hook 脚本执行时,插件会注入以下环境变量供脚本使用:
| 变量 | 说明 |
|---|
QODER_SESSION_ID | 会话 ID |
QODER_TOOL_NAME | 当前工具名 |
QODER_CWD | 工作目录 |
QODER_TRANSCRIPT_PATH | Transcript 文件路径 |
QODER_TOOL_INPUT_FILE_PATH | 工具操作的文件路径(如适用) |
Hooks 事件
SessionStart
会话启动或恢复时触发。适用于在会话开始时注入启动上下文(项目规范、环境信息等)。
matcher 匹配: 无。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "SessionStart",
"type": "startup",
"model": "Auto"
}
| 字段 | 类型 | 说明 |
|---|
type | string | 会话启动类型。当前 IDE 传入 "startup" |
model | string | 会话的模型设置 |
stdout JSON 输出字段(成功时):
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "## Environment\n..."
}
}
UserPromptSubmit
用户在 IDE 插件面板中提交 Prompt 后、Agent 开始处理前触发。可用于 Prompt 审查、内容过滤、自动补充上下文等场景。
matcher 匹配: 无匹配字段,该事件对所有用户输入统一触发。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "UserPromptSubmit",
"prompt": "帮我写一个排序函数"
}
阻止 Prompt 提交: exit code 2,stderr 内容作为错误信息反馈给用户,Agent 不会处理该 Prompt。
stdout JSON 输出字段(exit 0 时):
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "## 当前 Git 状态\n..."
}
}
| 字段 | 类型 | 说明 |
|---|
hookSpecificOutput.hookEventName | string | 固定为 "UserPromptSubmit" |
hookSpecificOutput.additionalContext | string | 附加上下文信息,会注入到 Agent 的对话中 |
示例:自动给 Prompt 补充项目规范
#!/bin/bash
input=$(cat)
prompt=$(echo "$input" | jq -r '.prompt')
# 自动追加编码规范提醒
echo '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"请遵循项目 .editorconfig 中的编码规范。"}}'
exit 0
工具执行前触发。可以阻止工具执行。 这是最常用的 Hook 事件,适用于危险命令拦截、文件路径校验、权限检查等场景。
matcher 匹配: 工具名(如 Bash、Write、Edit、Read、Glob、Grep,MCP 工具名如 mcp__server__tool)
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /tmp/build" }
}
阻止工具执行: exit code 2,stderr 内容作为错误返回给 Agent。完整示例见快速开始。
stdout JSON 输出字段(exit 0 时):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Safe read operation",
"updatedInput": { "command": "npm test --coverage" },
"additionalContext": "Added coverage flag"
}
}
| 字段 | 类型 | 说明 |
|---|
hookSpecificOutput.hookEventName | string | 固定为 "PreToolUse" |
hookSpecificOutput.permissionDecision | string | "allow"(放行)、"deny"(拒绝)或 "ask"(询问用户) |
hookSpecificOutput.permissionDecisionReason | string | 决策原因,deny 或 ask 时展示给 Agent / 用户 |
hookSpecificOutput.updatedInput | object | 修改后的工具输入参数(可选,用于改写工具调用) |
hookSpecificOutput.additionalContext | string | 附加上下文信息(可选) |
PermissionRequest
工具调用需要用户授权时触发。可以自动做出授权决策——适用于自动放行安全操作,或自动拒绝违反策略的操作,无需弹窗询问用户。
matcher 匹配: 工具名
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "PermissionRequest",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf node_modules" },
"tool_use_id": "call_01ABC123"
}
stdout JSON 输出字段(成功时):
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"permissionDecision": "allow",
"permissionDecisionReason": "Safe cleanup command",
"updatedInput": { "command": "rm -rf node_modules" }
}
}
| 字段 | 类型 | 说明 |
|---|
hookSpecificOutput.permissionDecision | string | "allow"(放行)、"deny"(拒绝)或 "ask"(回退为询问用户) |
hookSpecificOutput.permissionDecisionReason | string | 决策原因 |
hookSpecificOutput.updatedInput | object | 修改后的工具输入参数(可选) |
IDE 与 CLI 在该事件上使用不同的输出结构——CLI 期望的是嵌套的 decision.behavior 对象。请勿在未适配输出的情况下把同一脚本同时用于两个产品。
PostToolUse
工具执行成功后触发。不可阻断。适用于自动 lint、日志记录、结果分析等场景。
matcher 匹配: 工具名
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": { "file_path": "/path/to/file.ts", "file_content": "..." },
"tool_response": "File written successfully"
}
stdout JSON 输出字段(exit 0 时):
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"feedback": "File formatted with Prettier. 3 issues auto-fixed."
}
}
| 字段 | 类型 | 说明 |
|---|
hookSpecificOutput.hookEventName | string | 固定为 "PostToolUse" |
hookSpecificOutput.feedback | string | 反馈信息,会展示给用户(如 lint 结果摘要) |
PostToolUseFailure
工具调用失败后触发。不可阻断。适用于错误监控、失败重试提示、日志记录等场景。
matcher 匹配: 工具名
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "PostToolUseFailure",
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"tool_use_id": "call_01ABC123",
"error": "Command exited with non-zero status code 1",
"is_interrupt": false
}
| 字段 | 类型 | 说明 |
|---|
error | string | 工具执行的错误信息 |
is_interrupt | boolean | 失败是否由中断导致 |
Stop
Agent 完成响应后触发(主 Agent 无待执行的工具调用时)。可阻断 Agent 停止。适用于质量门禁、桌面通知、日志记录、任务状态汇报等场景。
matcher 匹配: 无匹配字段,该事件在 Agent 停止时统一触发。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "Stop",
"stop_hook_active": true,
"last_assistant_message": "我已经完成了排序函数的编写。"
}
| 字段 | 类型 | 说明 |
|---|
stop_hook_active | boolean | 当 Agent 因上一次 Stop Hook 阻断而重试时为 true。脚本必须检查该字段,为 true 时 exit 0,避免死循环 |
last_assistant_message | string | Agent 最后一段文本回复 |
阻断 Agent 停止: exit 2。阻断原因作为用户消息注入对话,Agent 继续工作。
stdout JSON 输出字段(exit 0 时):
{
"decision": "block",
"reason": "Tests failing. Fix them before completing."
}
| 字段 | 类型 | 说明 |
|---|
decision | string | "block"(阻止停止,让 Agent 继续工作) |
reason | string | 阻止的原因,会作为消息注入对话 |
防止无限循环: Stop Hook 阻断 Agent(exit 2)后,Agent 会重试并再次触发 Stop 事件,此时 stop_hook_active: true。脚本必须检查该字段并在其为 true 时 exit 0,否则会无限阻断。
SubagentStart
子代理启动时触发。不可阻断。适用于子代理审计或注入子代理上下文。
matcher 匹配: 无。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "SubagentStart",
"agent_id": "a1b2c3d4",
"agent_type": "task"
}
stdout JSON 输出字段(成功时): hookSpecificOutput.additionalContext——注入到子代理对话中的上下文。
SubagentStop
子代理停止时触发。在 IDE 中不可阻断。适用于记录子代理结果。
matcher 匹配: 无。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "SubagentStop",
"agent_id": "a1b2c3d4",
"agent_type": "task",
"stop_hook_active": false,
"agent_transcript_path": "/path/to/agent-transcript.jsonl",
"last_assistant_message": "Sub-task finished."
}
| 字段 | 类型 | 说明 |
|---|
agent_id | string | 子代理 ID |
agent_type | string | 子代理类型 |
stop_hook_active | boolean | 是否是 Hook 阻断后的重试停止 |
agent_transcript_path | string | 子代理的 transcript 文件路径 |
last_assistant_message | string | 子代理的最后一条文本响应 |
SessionEnd
会话结束时触发。不可阻断——适用于清理或会话归档等副作用操作。
matcher 匹配: 无。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "SessionEnd",
"reason": "exit"
}
请从输入字段 reason 中读取结束原因。(历史 matcher 元数据曾使用 exit_reason 这个名称;脚本应以 stdin 中实际的 reason 字段为准。)
PreCompact
上下文压缩前触发。在 IDE 中该事件仅用于通知和副作用——无法阻断压缩。
matcher 匹配: 触发方式(manual 为用户手动压缩,auto 为接近上下文上限时的自动压缩)。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "PreCompact",
"trigger": "auto",
"custom_instructions": ""
}
Notification
插件发出面向用户的通知时触发。不可阻断。适用于将通知转发到外部渠道(桌面、IM 等)。
matcher 匹配: 通知类型。
额外输入字段:
{
"session_id": "abc-123",
"cwd": "/path/to/project",
"hook_event_name": "Notification",
"notification_type": "permission_prompt",
"title": "Permission Required",
"message": "Agent is requesting permission to run: rm -rf node_modules"
}
stdout JSON 输出字段(成功时): hookSpecificOutput.additionalContext。
注意事项
- 超时处理: Hook 脚本默认超时 30 秒,可通过
timeout 字段按 Hook 配置。超时后脚本将被终止,按放行处理。
- 错误处理: 脚本异常退出(exit code 非 0 且非 2)时,错误信息显示给用户,Agent 流程不受影响继续执行。
- 脚本权限: 确保脚本具有可执行权限(
chmod +x)。
- 配置合并: 多级配置文件中的同一事件 Hook 按优先级从低到高依次执行,任一 Hook 返回阻断(exit 2)则终止后续执行。
- jq 依赖: 示例脚本依赖
jq 命令行工具来解析 JSON。请确保系统已安装 jq(macOS: brew install jq,Linux: apt install jq)。
最佳实践场景
谁适合用 Hooks
Hooks 的价值取决于你的角色,下面是一个快速对照:
个人开发者
| 场景 | 说明 | 实践 |
|---|
| Prompt 增强 | 自动注入项目专属的 Skill 与编码规范——不必每次手动输入 | 场景 1 |
| 敏感数据拦截 | 避免密码、密钥、内网 IP 被发送给模型 | 场景 2 |
| 危险命令阻断 | 阻断 rm -rf、git push --force 等破坏性命令 | 场景 6 |
| Harness 自进化 | 会话结束时自动识别可复用经验,并触发复盘 | 场景 8 |
团队 / 企业
| 场景 | 说明 | 实践 |
|---|
| Rule/Skill 使用统计 | 追踪团队中哪些 Rules 和 Skills 被触发,评估资产质量 | 场景 3 |
| 文件编辑追踪 | 记录 Agent 修改了哪些文件,用于变更审计与影响分析 | 场景 4 |
| 全局使用统计 | 采集对话内容、工具调用、模型回复——用于效能分析的全链路数据 | 场景 5 |
| 安全管控 | 落地团队统一的危险命令黑名单 | 场景 6 |
| 质量门禁 | 在 Agent 完成前自动执行构建 / 测试,未通过则阻断并修复 | 场景 7 |
个人场景通常配置在 ~/.qoder/settings.json(用户级)或 .qoder/settings.local.json(项目级本地配置)中。团队场景应放在 .qoder/settings.json(项目级)并提交到 Git,以确保统一生效。
场景 1:Prompt 增强——自动注入 Skill
痛点:每次都要手动指定 Skill,或者忘记加载项目专属上下文。
方案:用 UserPromptSubmit Hook 自动注入 Prompt 提示,引导 Agent 使用指定的 Skill。
配置:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/inject-skill-hint.sh"
}
]
}
]
}
}
脚本 .qoder/hooks/inject-skill-hint.sh:
#!/bin/sh
# 功能:在 Prompt 提交时,注入 Skill 使用提示(每个会话仅注入一次)
INPUT=$(cat)
# === 会话级去重:同一 session 只注入一次 ===
# 未来可使用 SessionStart 事件(即将支持)替代,届时无需去重逻辑
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
DEDUP_DIR="/tmp/hook-dedup"
mkdir -p "$DEDUP_DIR"
if [ -n "$SESSION_ID" ] && [ -f "$DEDUP_DIR/skill-hint-$SESSION_ID" ]; then
exit 0 # 本会话已注入过,跳过
fi
# ============================================
# 读取项目约定的 skill 使用规则(可根据项目自定义)
SKILL_HINT=""
# === 请根据你的项目实际情况修改以下内容 ===
# 示例1:始终提示使用 git-commit skill
# SKILL_HINT="如果用户要求提交代码,请使用 /git-commit skill"
# ============================================
if [ -z "$SKILL_HINT" ]; then
exit 0
fi
# 标记本会话已注入
[ -n "$SESSION_ID" ] && touch "$DEDUP_DIR/skill-hint-$SESSION_ID"
cat <<EOF
{"hookSpecificOutput": {"additionalContext": "$SKILL_HINT"}}
EOF
exit 0
关键点:
additionalContext 会作为 system-reminder 追加到用户 Prompt 后注入给 Agent
- 使用
session_id + 临时文件实现会话级去重,避免每一轮都注入相同提示
- 脚本可以读取项目配置文件,实现项目专属的 Skill 推荐
exit 0 放行 Prompt;exit 2 阻断 Prompt(例如用于不合规的 Prompt)
场景 2:敏感 Prompt 阻断
痛点:用户可能在 Prompt 中不小心带入密码、密钥、内网 IP 或个人数据,存在信息泄露风险。
方案:用 UserPromptSubmit Hook 检测敏感内容并阻断 Prompt。
配置:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/block-sensitive-prompt.sh"
}
]
}
]
}
}
脚本 .qoder/hooks/block-sensitive-prompt.sh:
#!/bin/sh
# 功能:检测用户 Prompt 中的敏感信息,命中则阻断
INPUT=$(cat)
# 提取用户 Prompt 内容
PROMPT=$(printf '%s' "$INPUT" | jq -r '.prompt // empty')
if [ -z "$PROMPT" ]; then
exit 0
fi
# === 请根据团队安全规范调整敏感词规则 ===
# 1. 密钥/凭证类模式
SECRET_PATTERNS="password=|passwd=|secret_key=|access_key=|AKIA[0-9A-Z]{16}|token=[a-zA-Z0-9]{20,}"
# 2. 内部网络信息
INTERNAL_PATTERNS="10\.[0-9]+\.[0-9]+\.[0-9]+|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\."
# 3. 自定义敏感词(团队内部术语、项目代号等)
# CUSTOM_PATTERNS="项目代号X|内部接口地址"
# 合并所有模式
ALL_PATTERNS="$SECRET_PATTERNS|$INTERNAL_PATTERNS"
# 执行检测
MATCH=$(echo "$PROMPT" | grep -oiE "$ALL_PATTERNS" | head -1)
if [ -n "$MATCH" ]; then
echo "检测到敏感信息: $MATCH" >&2
exit 2 # 阻断 Prompt 提交
fi
# ============================================
exit 0
关键点:
UserPromptSubmit 是可阻断事件,exit 2 会直接阻止 Prompt 到达 Agent
- 敏感模式支持正则表达式,匹配更灵活(例如 AWS AKIA 前缀、内网 IP 段)
- 建议把模式提取到独立配置文件(如
.qoder/hooks/sensitive-patterns.txt),便于团队统一维护
- 如需更精确的检测,可调用
gitleaks、trufflehog 等外部工具
与场景 1 的区别:场景 1 用 exit 0 + additionalContext 做 Prompt 增强(注入上下文),本场景用 exit 2 做 Prompt 阻断(拒绝不合规输入)。两者可以共存于同一个 UserPromptSubmit 事件下,并按配置顺序执行。
场景 3:Rule/Skill 使用统计
痛点:配置了很多 Rules 和 Skills,但实际使用率不明。
方案:用 Transcript 体系 + Stop Hook,在每次对话结束后自动分析并记录 Rule/Skill 的触发数据。
配置:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/analyze-rule-skill-usage.sh"
}
]
}
]
}
}
脚本 .qoder/hooks/analyze-rule-skill-usage.sh:
#!/bin/sh
# 功能:在 Agent 完成响应时,分析本次会话的 Rule/Skill 使用情况
INPUT=$(cat)
# 从 stdin 提取字段
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
exit 0
fi
# === 📝 分析逻辑 — 请根据实际需求调整 ===
# 1. 提取 session_meta 中的 rules 信息(JSONL 逐行解析)
RULES_META=$(jq -c 'select(.data.meta_type == "rules") | .data.content' "$TRANSCRIPT_PATH" 2>/dev/null | head -1)
# 2. 提取 slash_command 信息(用户通过 / 触发的 Skill)
SLASH_SKILL_META=$(jq -c 'select(.data.meta_type == "slash_command") | .data.content' "$TRANSCRIPT_PATH" 2>/dev/null | head -1)
# 3. 提取 tool_name="Skill" 的调用(Agent 主动触发的 Skill 工具调用)
# 说明:除了用户手动 /skill-name 触发外,Agent 也会自行调用 Skill 工具
AUTO_SKILL_CALLS=$(jq -c 'select(.message.content[ ]? | select(.type == "tool_use" and .name == "Skill"))' "$TRANSCRIPT_PATH" 2>/dev/null)
AUTO_SKILL_COUNT=$(printf '%s' "$AUTO_SKILL_CALLS" | grep -c . 2>/dev/null || echo 0)
AUTO_SKILL_NAMES=$(printf '%s' "$AUTO_SKILL_CALLS" | jq -r '.message.content[ ] | select(.type == "tool_use" and .name == "Skill") | .input.skill' 2>/dev/null | sort -u | jq -R -s 'split("\n") | map(select(. != ""))')
# 4. 统计全部工具调用次数
TOOL_COUNT=$(jq -c 'select(.message.content[ ]?.type == "tool_use")' "$TRANSCRIPT_PATH" 2>/dev/null | wc -l | tr -d ' ')
# 5. 记录到统计文件
STATS_DIR="$HOME/.qoder/stats"
mkdir -p "$STATS_DIR"
DATE=$(date +%Y-%m-%d)
STATS_FILE="$STATS_DIR/usage-${DATE}.jsonl"
jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg sid "$SESSION_ID" \
--argjson tc "$TOOL_COUNT" \
--argjson rules "${RULES_META:-null}" \
--argjson slash_skill "${SLASH_SKILL_META:-null}" \
--argjson auto_skill_count "$AUTO_SKILL_COUNT" \
--argjson auto_skill_names "${AUTO_SKILL_NAMES:-null}" \
'{timestamp:$ts, session_id:$sid, tool_calls:$tc, rules:$rules, slash_skill:$slash_skill, auto_skill: {count:$auto_skill_count, names:$auto_skill_names}}' >> "$STATS_FILE"
# ============================================
exit 0
Transcript 自动记录的元数据包括:
- session_meta(rules): 本次会话加载的所有 Rules(名称、触发类型、文件路径)
- session_meta(slash_command): 本次会话使用的 Skills(名称、类型、文件路径)
- session_meta(session_info): 会话模式(agent/plan)与会话类型
进阶用法:编写一个定期汇总脚本,读取 ~/.qoder/stats/ 下的 JSONL 文件,生成 Rule/Skill 使用报告。
场景 4:文件编辑追踪
痛点:不清楚 Agent 在一次会话中修改了哪些文件、修改了多少次。
方案:用 PostToolUse Hook 匹配文件编辑类工具,实时记录每一次文件变更。
配置:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|search_replace|create_file",
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/track-file-changes.sh"
}
]
}
]
}
}
脚本 .qoder/hooks/track-file-changes.sh:
#!/bin/sh
# 功能:追踪 Agent 的文件编辑操作
INPUT=$(cat)
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH="$QODER_TOOL_INPUT_FILE_PATH"
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# === 📝 文件变更追踪逻辑 ===
# 记录变更日志
CHANGE_LOG="$HOME/.qoder/stats/file-changes.jsonl"
mkdir -p "$(dirname "$CHANGE_LOG")"
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
BRANCH=$(printf '%s' "$INPUT" | jq -r '.extra.branch // empty')
REPO=$(printf '%s' "$INPUT" | jq -r '.extra.repo // empty')
echo "{\"ts\":\"$TIMESTAMP\",\"session\":\"$SESSION_ID\",\"tool\":\"$TOOL_NAME\",\"file\":\"$FILE_PATH\",\"branch\":\"$BRANCH\",\"repo\":\"$REPO\"}" >> "$CHANGE_LOG"
# ============================================
exit 0
进阶用法:
- 用
additionalContext 把变更统计反馈给 Agent(例如「本次会话已修改 15 个文件」)
- 对接团队的代码分析系统,追踪 AI 辅助产生的变更量
场景 5:全局使用统计
痛点:缺乏 Agent 整体使用情况的量化数据——无法评估 AI 辅助编程的效能与质量。具体包括:用户问了什么、模型返回了什么文本、调用了哪些工具以及结果如何。
方案:用多事件 Hook 组合构建全链路的使用数据采集。UserPromptSubmit 采集用户提问,PostToolUse 采集工具调用结果,Stop 通过 Transcript 分析完整的会话摘要(模型回复、工具调用分布等)。
配置:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/usage-tracker.sh"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/usage-tracker.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/usage-tracker.sh"
}
]
}
]
}
}
脚本 ~/.qoder/hooks/usage-tracker.sh:
#!/bin/sh
# 功能:全局使用情况追踪(统一入口,按事件类型分发)
INPUT=$(cat)
EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // empty')
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# 获取额外上下文
EMAIL=$(printf '%s' "$INPUT" | jq -r '.extra.email // empty')
REPO=$(printf '%s' "$INPUT" | jq -r '.extra.repo // empty')
BRANCH=$(printf '%s' "$INPUT" | jq -r '.extra.branch // empty')
# === 📝 数据采集逻辑 ===
STATS_DIR="$HOME/.qoder/stats"
mkdir -p "$STATS_DIR"
DATE=$(date +%Y-%m-%d)
case "$EVENT" in
UserPromptSubmit)
# 采集用户提问内容(截取前 200 字符避免日志过大)
PROMPT=$(printf '%s' "$INPUT" | jq -r '.prompt // empty')
PROMPT_PREVIEW=$(printf '%.200s' "$PROMPT")
echo "{\"ts\":\"$TIMESTAMP\",\"event\":\"prompt\",\"session\":\"$SESSION_ID\",\"email\":\"$EMAIL\",\"repo\":\"$REPO\",\"branch\":\"$BRANCH\",\"prompt_preview\":\"$PROMPT_PREVIEW\"}" >> "$STATS_DIR/events-${DATE}.jsonl"
;;
PostToolUse)
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')
# 采集工具调用结果(截取前 500 字符)
TOOL_RESPONSE=$(printf '%s' "$INPUT" | jq -r '.tool_response // empty')
TOOL_RESPONSE_PREVIEW=$(printf '%.500s' "$TOOL_RESPONSE")
echo "{\"ts\":\"$TIMESTAMP\",\"event\":\"tool_use\",\"session\":\"$SESSION_ID\",\"tool\":\"$TOOL_NAME\",\"repo\":\"$REPO\",\"tool_response_preview\":\"$TOOL_RESPONSE_PREVIEW\"}" >> "$STATS_DIR/events-${DATE}.jsonl"
;;
Stop)
# 📊 通过 Transcript 采集完整会话摘要
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
SUMMARY=""
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
USER_MSG_COUNT=$(jq -c 'select(.type == "user" and (.message.content | type == "string"))' "$TRANSCRIPT_PATH" 2>/dev/null | wc -l | tr -d ' ')
USER_PROMPTS=$(jq -r 'select(.type == "user" and (.message.content | type == "string")) | .message.content' "$TRANSCRIPT_PATH" 2>/dev/null | head -20)
ASSISTANT_TEXT_COUNT=$(jq -c 'select(.type == "assistant" and (.message.content[ ]? | select(.type == "text")))' "$TRANSCRIPT_PATH" 2>/dev/null | wc -l | tr -d ' ')
ASSISTANT_TEXTS=$(jq -r 'select(.type == "assistant") | .message.content[ ]? | select(.type == "text") | .text' "$TRANSCRIPT_PATH" 2>/dev/null | head -20)
TOOL_CALL_COUNT=$(jq -c 'select(.type == "assistant") | .message.content[ ]? | select(.type == "tool_use")' "$TRANSCRIPT_PATH" 2>/dev/null | wc -l | tr -d ' ')
TOOL_NAMES=$(jq -r 'select(.type == "assistant") | .message.content[ ]? | select(.type == "tool_use") | .name' "$TRANSCRIPT_PATH" 2>/dev/null | sort | uniq -c | sort -rn | head -10)
TOOL_SUCCESS=$(jq -c 'select(.type == "user") | .message.content[ ]? | select(.type == "tool_result" and .is_error == false)' "$TRANSCRIPT_PATH" 2>/dev/null | wc -l | tr -d ' ')
TOOL_ERROR=$(jq -c 'select(.type == "user") | .message.content[ ]? | select(.type == "tool_result" and .is_error == true)' "$TRANSCRIPT_PATH" 2>/dev/null | wc -l | tr -d ' ')
SUMMARY="user_msgs:${USER_MSG_COUNT},assistant_texts:${ASSISTANT_TEXT_COUNT},tool_calls:${TOOL_CALL_COUNT},tool_success:${TOOL_SUCCESS},tool_error:${TOOL_ERROR}"
fi
echo "{\"ts\":\"$TIMESTAMP\",\"event\":\"stop\",\"session\":\"$SESSION_ID\",\"repo\":\"$REPO\",\"summary\":\"$SUMMARY\"}" >> "$STATS_DIR/events-${DATE}.jsonl"
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
SUMMARY_DIR="$STATS_DIR/sessions"
mkdir -p "$SUMMARY_DIR"
cat <<SUMMARY_EOF > "$SUMMARY_DIR/${SESSION_ID}.json"
{
"session_id": "$SESSION_ID",
"timestamp": "$TIMESTAMP",
"repo": "$REPO",
"branch": "$BRANCH",
"email": "$EMAIL",
"user_message_count": $USER_MSG_COUNT,
"assistant_text_count": $ASSISTANT_TEXT_COUNT,
"tool_call_count": $TOOL_CALL_COUNT,
"tool_success": $TOOL_SUCCESS,
"tool_error": $TOOL_ERROR,
"tool_distribution": "$TOOL_NAMES",
"user_prompts_preview": $(printf '%s' "$USER_PROMPTS" | head -c 2000 | jq -Rs .),
"assistant_texts_preview": $(printf '%s' "$ASSISTANT_TEXTS" | head -c 2000 | jq -Rs .),
"transcript_path": "$TRANSCRIPT_PATH"
}
SUMMARY_EOF
fi
;;
esac
# ============================================
exit 0
数据采集维度:
| 事件 | 采集的数据 | 数据来源 |
|---|
UserPromptSubmit | 用户提问(前 200 字符) | stdin JSON 中的 prompt 字段 |
PostToolUse | 工具名 + 调用结果(前 500 字符) | stdin JSON 中的 tool_name 与 tool_response 字段 |
Stop | 完整会话摘要:用户提问、模型回复、工具调用分布、成功 / 失败率 | transcript_path 指向的 JSONL 文件 |
为何在 Stop 通过 Transcript 分析? UserPromptSubmit 和 PostToolUse 只能采集当前一次交互的数据,而 Stop 时刻的 Transcript 文件已包含完整的会话历史——可以一次性提取模型回复文本、工具调用分布以及成功 / 失败率。详见Transcript 文件格式。
场景 6:安全管控——危险命令阻断
痛点:Agent 可能执行 rm -rf、git push --force 等危险命令。
方案:用 PreToolUse Hook 匹配 Bash|run_in_terminal,在命令执行前拦截。
配置:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|run_in_terminal",
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/block-dangerous-commands.sh"
}
]
}
]
}
}
脚本 .qoder/hooks/block-dangerous-commands.sh:
#!/bin/sh
# 功能:拦截危险 Shell 命令
INPUT=$(cat)
# 提取要执行的命令(从 tool_input.command 中读取)
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
# === 📝 危险命令黑名单 — 请根据团队规范调整 ===
DANGEROUS_PATTERNS="rm -rf|git push --force|git push -f|DROP TABLE|DROP DATABASE|format |mkfs"
if echo "$COMMAND" | grep -qiE "$DANGEROUS_PATTERNS"; then
echo "检测到危险命令: $COMMAND" >&2
exit 2 # 阻断执行
fi
# ============================================
exit 0
关键点:
exit 2 会立即阻断执行,stderr 内容作为阻断原因反馈给 Agent
- Agent 被阻断后会尝试替代方案(例如换一个更安全的命令)
- 如需更丰富的反馈,可在 stdout 返回 JSON:
{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"Command contains rm -rf, blocked for safety"}}
场景 7:Agent 完成前的质量门禁
痛点:Agent 声称任务已完成,但测试仍在失败或还有遗留问题。
方案:用 Stop Hook(可阻断)在 Agent 完成前执行质量检查;检查未通过则阻断,强制 Agent 继续处理。
配置:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/quality-gate.sh",
"timeout": 120
}
]
}
]
}
}
脚本 .qoder/hooks/quality-gate.sh:
#!/bin/sh
# 功能:Agent 完成前的质量门禁检查
INPUT=$(cat)
# 检查是否已经是 Stop Hook 触发的循环(防止无限循环)
STOP_HOOK_ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0 # 已经是 Stop Hook 触发的重试,放行
fi
# === 📝 质量检查逻辑 — 请根据项目实际情况调整 ===
ERRORS=""
# 1. 检查是否有编译错误
# if ! make build 2>/dev/null; then
# ERRORS="${ERRORS}\n- 编译失败,请修复编译错误"
# fi
# 2. 检查是否有测试失败
# if ! make test 2>/dev/null; then
# ERRORS="${ERRORS}\n- 测试未通过,请修复失败的测试"
# fi
# 3. 检查是否有未提交的 TODO 标记
# TODO_COUNT=$(grep -r "TODO(agent)" . --include="*.go" 2>/dev/null | wc -l | tr -d ' ')
# if [ "$TODO_COUNT" -gt 0 ]; then
# ERRORS="${ERRORS}\n- 发现 $TODO_COUNT 个未完成的 TODO 标记"
# fi
# ============================================
if [ -n "$ERRORS" ]; then
cat <<EOF
{"decision":"block","reason":"质量检查未通过:$ERRORS\n请修复以上问题后再完成。"}
EOF
exit 2
fi
exit 0
关键点:
stop_hook_active 用于防止无限循环(Agent 被阻断后重试时该字段为 true)
- Stop Hook 阻断后,阻断原因会作为用户消息注入,Agent 继续工作
- 由于构建和测试可能较耗时,建议设置更长的
timeout(例如 120 秒)
场景 8:Harness 自进化——自动化知识沉淀
痛点:每次任务产生的经验和决策散落在对话历史里,没有自动化的沉淀流程。
方案:用 Stop Hook 自动触发 Harness 自进化流程,分析本次对话是否产生了可复用的经验,并推动资产生命周期运转。
配置:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/harness-evolution.sh"
}
]
}
]
}
}
脚本 .qoder/hooks/harness-evolution.sh:
#!/bin/sh
# 功能:Agent 完成响应时,触发 Harness 自进化流程
INPUT=$(cat)
# ⚠️ 【关键】防止无限循环:检查 stop_hook_active 标志
# 当 Stop Hook 阻断 Agent 后,Agent 会重新尝试完成,此时 stop_hook_active=true
# 必须在此处放行,否则会陷入「阻断→重试→再阻断」的死循环
STOP_HOOK_ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0 # 已经是 Stop Hook 触发的重试,直接放行
fi
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
exit 0
fi
# === 📝 Harness 自进化检测逻辑 ===
# 1. 统计本次会话的工具调用和文件变更
EDIT_COUNT=$(jq -c 'select(.message.content[ ]?.type == "tool_use")' "$TRANSCRIPT_PATH" 2>/dev/null | wc -l | tr -d ' ')
# 2. 检查是否涉及架构设计、决策讨论、规范制定等关键词
# HAS_DECISION=$(jq -r 'select(.message.content | type == "string") | .message.content' "$TRANSCRIPT_PATH" 2>/dev/null | grep -c '架构\|方案\|规范\|最佳实践' || echo 0)
# 3. 记录会话摘要到 inbox,供后续批量复盘
SEDIMENTATION_DIR="$HOME/.ai/inbox"
mkdir -p "$SEDIMENTATION_DIR"
echo "{\"session_id\":\"$SESSION_ID\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"tool_calls\":$EDIT_COUNT,\"transcript\":\"$TRANSCRIPT_PATH\"}" >> "$SEDIMENTATION_DIR/pending-review.jsonl"
# 4. 可选方案 A:调用外部分析总结系统
# 通过 transcript 和会话上下文调用自己的分析服务
# curl -s -X POST http://localhost:8080/api/analyze \
# -H 'Content-Type: application/json' \
# -d "{\"session_id\":\"$SESSION_ID\",\"transcript\":\"$TRANSCRIPT_PATH\"}" \
# > /dev/null 2>&1 &
# 5. 可选方案 B:通过阻断触发分析总结 Skill
# 取消下面的注释,可让 Agent 被阻断后自动进入复盘 Skill 流程
# 注意:/retro 是自定义的沉淀复盘技能,你可以替换为自己团队的复盘 Skill。
# cat <<'EOF'
# {"decision":"block","reason":"任务已完成。检测到本次对话可能有值得沉淀的经验(文件变更较多)。请运行 /retro 进行复盘,提炼可复用的经验并沉淀到资产体系。"}
# EOF
# exit 2
# ============================================
exit 0
关键点:
- 防止无限循环(必须): Stop Hook 脚本必须检查
stop_hook_active。当 Agent 被 Stop Hook 阻断后重试时,该字段为 true,此时应立即 exit 0,避免陷入无限循环
- 用
exit 2 + decision:"block" 阻断 Agent 完成,强制进入复盘流程
pending-review.jsonl 用于记录会话,供后续批量复盘
- 可以与
/retro Skill(自定义的复盘技能)组合,形成自动检测 → 提醒 → 沉淀的闭环
当前的实现方式:
Hooks 目前只支持 command 和 http 两种类型的处理器(不支持 prompt / agent),因此 Harness 自进化有两条实现路径:(A)调用外部分析服务,或(B)阻断 Agent 并触发复盘 Skill。
未来方向:
Hook 体系计划支持 prompt 类型与 agent 类型的处理器。届时 Harness 自进化将从「脚本驱动」升级为「Agent 驱动」,真正实现端到端的自动化知识沉淀。
场景 9:写文件后自动 Lint
每次 Agent 写入或编辑文件后,自动执行 lint 检查。
脚本 ${project}/.qoder/hooks/auto-lint.sh:
#!/bin/bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
# 只检查 JS/TS 文件
case "$file_path" in
*.js|*.ts|*.jsx|*.tsx)
npx eslint "$file_path" --fix 2>/dev/null
;;
esac
exit 0
配置:事件 PostToolUse,matcher Write|Edit,command .qoder/hooks/auto-lint.sh。
场景 10:工具失败时记录日志
当 Agent 调用的工具执行失败时,自动记录到日志文件,便于排查问题。
脚本 ~/.qoder/hooks/log-failure.sh:
#!/bin/bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
error=$(echo "$input" | jq -r '.error')
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] $tool_name 执行失败: $error" >> ~/.qoder/hooks/failure.log
exit 0
配置:事件 PostToolUseFailure,无 matcher(匹配所有工具),command ~/.qoder/hooks/log-failure.sh。
场景 11:Agent 完成后桌面通知
Agent 完成任务后弹出系统通知,适合长时间运行的任务。
脚本 ~/.qoder/hooks/notify-done.sh(macOS):
#!/bin/bash
input=$(cat)
message=$(echo "$input" | jq -r '.last_assistant_message // "任务已完成"' | head -c 100)
osascript -e "display notification \"$message\" with title \"Qoder Agent\""
exit 0
配置:事件 Stop,无 matcher,command ~/.qoder/hooks/notify-done.sh。
Transcript 文件格式
Transcript 是 Qoder 自动生成的会话日志文件,位于 transcript_path 指向的路径(如 ~/.qoder/projects/<project>/transcript/<session-id>.jsonl)。文件中每一行都是一个独立的 JSON 对象,按时间顺序追加,完整记录会话的交互过程。
每行的公共字段
| 字段 | 类型 | 说明 |
|---|
type | string | 记录类型:session_meta / user / assistant / progress |
sessionId | string | 会话 ID |
uuid | string | 本条记录的唯一 ID |
timestamp | string | ISO 8601 时间戳 |
cwd | string | 当前工作目录 |
message | object | 消息内容(user/assistant 类型具有该字段) |
data | object | 元数据(session_meta/progress 类型具有该字段) |
记录类型
1. session_meta — 会话元数据(第一行)
每个 Transcript 文件的第一行记录会话的基本信息:
{
"type": "session_meta",
"sessionId": "86379a0e-...",
"data": {
"meta_type": "session_info",
"content": {
"mode": "agent",
"session_type": "assistant"
}
}
}
data.content.mode:会话模式(agent / plan / ask / debug)
data.content.session_type:会话类型(assistant / inline_chat 等)
2. user — 用户消息
用户消息有两种形式:
用户提问(message.content 为字符串):
{
"type": "user",
"message": {
"role": "user",
"content": "Delete comments from test.py"
}
}
工具结果(message.content 为包含 tool_result 的数组):
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_d498c5988a...",
"content": "Contents of /path/to/file.py, from line 1-23 ...",
"is_error": false
}
]
},
"toolUseResult": "Contents of /path/to/file.py ..."
}
is_error:工具执行是否失败
toolUseResult:工具结果的快捷字段(内容同 content[0].content)
3. assistant — 模型回复
模型回复的 message.content 始终是一个数组,包含两种元素类型:
文本回复(type: "text"):
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll help you delete comments from test.py. Let me read the file first.\n\n"
}
]
}
}
工具调用(type: "tool_use"):
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_d498c5988a...",
"name": "read_file",
"input": {
"file_path": "/path/to/file.py"
}
}
]
}
}
name:工具名(如 read_file、search_replace、run_in_terminal、Skill)
input:工具调用参数
id:与后续 tool_result 中的 tool_use_id 对应
4. progress — Hook 触发记录
记录 Hook 脚本何时触发、执行了哪些命令:
{
"type": "progress",
"data": {
"type": "hook_progress",
"hookEvent": "UserPromptSubmit",
"hookName": "UserPromptSubmit",
"command": ".qoder/hooks/inject-skill-hint.sh"
}
}
会话时间线示例
一个典型会话的 Transcript 记录顺序:
L1 session_meta ← Session start, record mode and type
L2 progress ← UserPromptSubmit Hook fires
L3 user (string) ← User question: "Delete comments from test.py"
L4 assistant (text) ← Model reply: "I'll help you..."
L5 assistant (tool_use) ← Model calls read_file
L6 user (tool_result) ← read_file returns file content
L7 assistant (text) ← Model reply: "Now I'll delete..."
L8 assistant (tool_use) ← Model calls search_replace
L9 user (tool_result) ← search_replace succeeds
L10 assistant (text) ← Model reply: "Successfully deleted..."
L11 progress ← Stop Hook fires
L12 assistant (text) ← Final reply after Stop Hook
常用的 jq 提取命令
TRANSCRIPT="$TRANSCRIPT_PATH"
# Extract all user questions (filter out tool results)
jq -r 'select(.type == "user" and (.message.content | type == "string")) | .message.content' "$TRANSCRIPT"
# Extract all model text replies
jq -r 'select(.type == "assistant") | .message.content[ ]? | select(.type == "text") | .text' "$TRANSCRIPT"
# Extract all tool calls (tool name + parameters)
jq -c 'select(.type == "assistant") | .message.content[ ]? | select(.type == "tool_use") | {name, input}' "$TRANSCRIPT"
# Count tool call distribution (tool name + count)
jq -r 'select(.type == "assistant") | .message.content[ ]? | select(.type == "tool_use") | .name' "$TRANSCRIPT" | sort | uniq -c | sort -rn
# Extract failed tool calls
jq -c 'select(.type == "user") | .message.content[ ]? | select(.type == "tool_result" and .is_error == true)' "$TRANSCRIPT"
# Get session mode
jq -r 'select(.type == "session_meta") | .data.content.mode' "$TRANSCRIPT"
# Get Hook trigger records
jq -c 'select(.type == "progress" and .data.type == "hook_progress") | {event: .data.hookEvent, command: .data.command}' "$TRANSCRIPT"
设计原则
| 原则 | 说明 |
|---|
| 快速失败 | Hook 脚本应该轻量,避免阻塞 Agent 主流程 |
| 优雅降级 | 异常退出码(非 0 也非 2)不阻断 Agent,保证容错能力 |
| 单一职责 | 每个脚本只做一件事,复杂逻辑用多个 Hook 组合实现 |
| 幂等设计 | 同一事件可能多次触发,脚本应该具备幂等性 |
| 防止循环 | Stop Hook 必须检查 stop_hook_active,避免无限重试 |
推荐的 Hook 组合
| 目标 | 推荐组合 |
|---|
| 安全管控 | PreToolUse(Bash) 危险命令阻断 |
| 质量保障 | Stop 质量门禁 |
| 数据驱动洞察 | UserPromptSubmit + PostToolUse + Stop 全链路采集 |
| Harness 自进化 | Stop 沉淀检测 + UserPromptSubmit Skill 引导 |
| 团队協作 | 项目级 .qoder/settings.json 统一配置,通过 Git 共享 |
调试指南
第 1 步:确认 Hook 已触发
当 Hook 没有按预期触发时,在脚本的最开头加上调试日志:
#!/bin/sh
# ===== Debug mode: confirm hook fires =====
INPUT=$(cat)
printf '[HOOK DEBUG] %s hook triggered, event=%s\n' "$(date '+%H:%M:%S')" "$(printf '%s' "$INPUT" | jq -r '.hook_event_name')" >> /tmp/hook-debug.log
printf '%s' "$INPUT" | jq . >> /tmp/hook-debug.log
echo "---" >> /tmp/hook-debug.log
# ===== Remove debug code when done =====
# ... your actual logic ...
exit 0
然后触发一次 Agent 操作,查看日志:
$ tail -f /tmp/hook-debug.log
[HOOK DEBUG] 14:32:01 hook triggered, event=PreToolUse
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "run_in_terminal",
"tool_input": {
"command": "ls -la"
},
...
}
排查思路:如果没有任何输出,说明 Hook 没有触发。请检查配置文件位置、事件名、matcher 匹配模式以及脚本路径。
第 2 步:用真实输入在本地复现
用第 1 步捕获到的 JSON,在 Qoder 之外测试脚本:
# Pipe the captured input to simulate stdin
$ cat /tmp/hook-debug.json | sh .qoder/hooks/pre-tool-check.sh
$ echo $? # Check exit code: 0=allow, 2=block
# Or manually construct test input
$ echo '{"hook_event_name":"PreToolUse","tool_name":"run_in_terminal","tool_input":{"command":"rm -rf /"}}' | sh .qoder/hooks/pre-tool-check.sh
$ echo $? # Expected: 2 (block dangerous command)
小技巧:把常用的测试用例保存为文件,方便反复验证。
第 3 步:其他调试手段
- 查看 Transcript:
~/.qoder/projects/<encoded-path>/transcript/<session>.jsonl,用 jq 逐行解析
- 查看 Hook 日志: 在 Qoder 日志中搜索
[hook] 前缀,可看到执行结果与耗时
- 从简单开始: 先用一个只有
exit 0 的脚本验证 Hook 能触发,再逐步加入业务逻辑
- 及时清理: 调试完成后删除调试代码(写
/tmp/hook-debug.log 的部分),避免影响性能
快速开始模板
最小配置
将以下内容保存到 ~/.qoder/settings.json(用户级)或 <project>/.qoder/settings.json(项目级):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/pre-tool-check.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/post-edit-track.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/on-stop.sh"
}
]
}
]
}
}
项目目录结构
<project>/
├── .qoder/
│ ├── settings.json ← Hook config (Git-shared)
│ ├── settings.local.json ← Local override config (.gitignore)
│ └── hooks/ ← Hook scripts directory
│ ├── pre-tool-check.sh
│ ├── post-edit-track.sh
│ └── on-stop.sh
└── ...
完整配置示例
以下是一个包含五种常用事件的完整配置:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/check-prompt.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/block-dangerous.sh"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/validate-file-path.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".qoder/hooks/auto-lint.sh"
}
]
}
],
"PostToolUseFailure": [
{
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/log-failure.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.qoder/hooks/notify-done.sh"
}
]
}
]
}
}