摘要
Codex Hooks 可以在代理工作流的固定节点运行项目脚本。把轻量验收放到任务结束阶段,能够自动检查 lint、类型和差异格式,减少“代码改完了,但验证被遗漏”的情况。
问题背景
使用 Codex 修改项目代码后,我经常还会补一句:
检查一下刚才的修改,运行 lint 和类型检查,确认没有影响其他页面。
这句话本身没有问题。真正的问题是,它完全依赖当前任务有没有写清楚、开发者有没有想起来,以及代理是否准确找到了项目对应的验证命令。
在一个同时包含前端、移动端和后端模块的工作区里,这种遗漏更容易发生:
- 页面样式修改后,只检查了语法,没有执行类型检查;
- 接口字段调整后,只验证了当前页面,没有检查共享类型;
- Codex 完成代码修改后给出了总结,但没有运行仓库约定的命令;
- 同一个项目的不同会话,对“完成”的理解不一致。
短期可以依靠提示词补充验证要求,长期则会变得很脆。只要换一个会话、换一个开发者,或者任务上下文变长,验收步骤就可能再次丢失。
我最后采用的思路是:
AGENTS.md 负责说明应该验证什么
项目脚本负责执行具体检查
Codex Hook 负责在固定时机触发脚本
CI 负责最终合并与发布门禁
Hook 不替代测试,也不替代 CI。它解决的是更靠前的一层问题:让代理完成任务时,自动执行一组低成本、确定性的本地检查。
Codex Hooks 解决了什么问题
Codex Hooks 是代理工作循环中的生命周期扩展点。它可以在会话开始、工具调用前后、上下文压缩、子代理启停或任务停止等事件发生时运行自定义命令。
常见事件包括:
PreToolUse:工具执行前;PostToolUse:工具执行后;UserPromptSubmit:用户提交提示词后;SessionStart:会话启动或恢复时;Stop:当前任务停止时。
对于“修改代码后自动验收”这个场景,最容易想到的是 PostToolUse:只要 Codex 调用了文件编辑工具,就执行检查。
但实际使用后会发现,完整 lint 或类型检查不适合跟在每一次编辑后面。一个任务可能包含十几次小修改,如果每次都运行全量检查,等待时间反而会抵消自动化带来的收益。
因此我更倾向于这样分配:
PostToolUse:格式化单个文件、记录修改、执行轻量检查
Stop:运行 lint、typecheck、git diff --check 等任务级验收
CI:执行完整测试、构建、端到端测试和部署检查
这次示例选择 Stop。它触发频率更低,也更接近“Codex 准备结束本轮工作,现在统一验收”的真实需求。
项目目录
示例项目采用下面的结构:
D:\workspace\app
├─ .codex
│ ├─ hooks.json
│ └─ hooks
│ └─ validate.ps1
├─ src
├─ package.json
└─ AGENTS.md
这里把 Hook 配置和脚本都放在项目内,而不是用户全局目录中。
原因很直接:不同项目的技术栈、包管理器和验收命令并不相同。Vue 项目可能执行 ESLint 和 vue-tsc,Next.js 项目可能执行 ESLint、TypeScript 和构建检查,Java 项目则可能使用 Maven。
项目级配置更容易跟随仓库维护,也不会把某个项目的规则错误应用到其他目录。
配置 Stop Hook
在项目根目录创建 .codex/hooks.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash -lc 'root=$(git rev-parse --show-toplevel) && \"$root/.codex/hooks/validate.sh\"'",
"commandWindows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$root = git rev-parse --show-toplevel; & (Join-Path $root '.codex/hooks/validate.ps1')\"",
"timeout": 180,
"statusMessage": "Running project validation"
}
]
}
]
}
}
几个字段分别表示:
type:当前可执行的处理器类型使用command;command:非 Windows 环境执行的命令;commandWindows:Windows 下使用的覆盖命令;timeout:脚本最大执行时间,单位为秒;statusMessage:执行期间展示的状态文字。
官方文档特别建议:项目 Hook 不要假设 Codex 一定从仓库根目录启动。开发者可能从 src、某个子包或其他下级目录打开会话,因此示例先通过 git rev-parse --show-toplevel 获取真实 Git 根目录,再定位验证脚本。
还需要注意,Stop 事件不支持通过 matcher 过滤。也就是说,不要给它增加一个看起来能匹配文件编辑工具、实际却不会生效的 matcher。是否需要执行检查,应放到脚本内部判断。
编写 Windows 验收脚本
创建 .codex/hooks/validate.ps1:
$ErrorActionPreference = 'Stop'
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) {
Write-Host 'Skip validation: current directory is not a Git repository.'
exit 0
}
Set-Location $root
$changedFiles = @(
git diff --name-only --diff-filter=ACMRT
git diff --cached --name-only --diff-filter=ACMRT
) | Where-Object { $_ } | Sort-Object -Unique
if ($changedFiles.Count -eq 0) {
Write-Host 'Skip validation: no changed files.'
exit 0
}
Write-Host "Changed files: $($changedFiles.Count)"
git diff --check
if ($LASTEXITCODE -ne 0) {
Write-Error 'git diff --check failed.'
exit $LASTEXITCODE
}
$frontendChanged = $changedFiles | Where-Object {
$_ -match '\.(js|jsx|ts|tsx|vue|css|scss)$'
}
if (-not $frontendChanged) {
Write-Host 'No frontend source changes. Basic diff check passed.'
exit 0
}
if (-not (Test-Path 'package.json')) {
Write-Host 'package.json not found. Skip npm script checks.'
exit 0
}
$packageJson = Get-Content 'package.json' -Raw | ConvertFrom-Json
$scriptNames = @($packageJson.scripts.PSObject.Properties.Name)
if ($scriptNames -contains 'lint') {
Write-Host 'Running lint...'
npm run lint
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
if ($scriptNames -contains 'typecheck') {
Write-Host 'Running typecheck...'
npm run typecheck
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
Write-Host 'Project validation passed.'
这个脚本没有直接执行所有检查,而是先判断:
- 当前目录是否属于 Git 仓库;
- 工作区是否存在变更;
- 变更是否包含前端源码;
package.json是否定义了对应命令。
这样做比在 Hook 中直接写一条 npm test 更稳妥。
真实项目里的测试可能需要数据库、浏览器、设备或其他服务。把全部测试都塞进 Stop Hook,容易造成每轮任务结束都等待很久,甚至因为环境不完整产生与当前修改无关的失败。
Hook 最适合运行的是“快、稳定、与大多数改动相关”的检查。
不要把包管理器写死
上面的脚本为了便于阅读使用了 npm run。如果团队统一使用 pnpm,可以直接替换为:
pnpm lint
pnpm typecheck
如果一个工作区内存在不同包管理器,可以通过锁文件判断:
if (Test-Path 'pnpm-lock.yaml') {
$runner = 'pnpm'
} elseif (Test-Path 'yarn.lock') {
$runner = 'yarn'
} else {
$runner = 'npm'
}
之后统一执行:
if ($runner -eq 'npm') {
npm run lint
} else {
& $runner lint
}
更进一步,还可以根据修改目录决定执行哪个子项目的命令:
$webChanged = $changedFiles | Where-Object { $_ -like 'apps/web/*' }
$mobileChanged = $changedFiles | Where-Object { $_ -like 'apps/mobile/*' }
if ($webChanged) {
pnpm --dir apps/web lint
}
if ($mobileChanged) {
pnpm --dir apps/mobile lint
}
这比对整个 monorepo 做全量检查更符合本地开发节奏。
Hook 和 AGENTS.md 应该配合使用
Hook 只负责机械执行,不负责理解“为什么要执行这些检查”。
项目的 AGENTS.md 仍然应该写清楚验证要求:
## Verification
- 修改前端源码后运行 `pnpm lint` 和 `pnpm typecheck`。
- 修改共享组件后检查至少一个调用页面。
- 提交前运行 `git diff --check`。
- 无法执行检查时必须说明原因,不得直接写成验证通过。
这样可以形成两层约束:
AGENTS.md给 Codex 提供判断规则,让它知道哪些检查属于任务完成条件;- Hook 在固定节点执行脚本,降低步骤被遗忘的概率。
如果只配置 Hook,不写项目规则,Codex可能知道命令失败,却不理解哪些失败需要修复、哪些属于环境限制。
如果只写 AGENTS.md,验证仍然依赖代理在每次任务中主动执行。
两者结合后,语义规则和机械执行才真正分开。
为什么不直接使用 PostToolUse
PostToolUse 可以按工具名称匹配,例如 Bash、apply_patch 或 MCP 工具名。它适合这些操作:
- 文件修改后运行格式化;
- 命令执行后记录输出;
- 检查某类工具是否违反项目规则;
- 对单文件执行低成本扫描。
但如果把完整项目检查挂在 apply_patch 后面,任务可能变成:
修改模板 → 执行 lint
修改样式 → 再执行 lint
调整脚本 → 再执行 lint
补充类型 → 再执行 lint
这不是自动化越多越好,而是触发粒度选错了。
一个更合理的原则是:
单次成本低于一秒:可以考虑 PostToolUse
需要扫描项目或启动编译:优先放到 Stop
依赖完整环境或耗时较长:留给 CI
真正的问题不在于 Hook 能不能运行命令,而在于命令应该在哪个生命周期运行。
第一次使用需要确认信任
项目 Hook 本质上会执行仓库中的命令,因此不能把它当成普通配置文件忽略安全风险。
Codex 会要求用户检查并信任非托管 Hook。可以在 CLI 中使用:
/hooks
查看 Hook 来源、审核配置并决定是否信任。
信任记录与 Hook 定义的内容相关。配置发生变化后,需要重新检查,而不是让旧的信任无限覆盖新命令。
项目级 Hook 还依赖项目 .codex 配置层处于可信状态。打开陌生仓库时,不应该为了省一次确认就直接信任它的 Hook,更不应该运行来源不明的脚本。
在团队项目中,建议评审 Hook 时重点检查:
- 是否上传源码、提示词、环境变量或日志;
- 是否执行删除、覆盖或发布操作;
- 是否访问工作区之外的目录;
- 是否包含真实 token、账号或固定机器路径;
- 是否会在每次会话中执行高成本命令。
自动化的前提是命令本身可审查。
Hooks 当前的几个边界
Codex Hooks 很适合本地工作流自动化,但目前仍有明确边界:
只有 command 处理器实际执行
当前应使用:
{
"type": "command",
"command": "your-command"
}
prompt 和 agent 类型虽然可以被解析,但当前不会执行。配置时不要根据字段名称猜测能力。
async 暂不支持
async 字段可以被解析,但异步命令 Hook 当前会被跳过。因此验收脚本应该控制好执行时间,并设置合理的 timeout。
Stop 不支持 matcher
Stop 每次触发时都会进入配置的处理器。是否跳过检查,要由脚本根据 Git 状态、修改文件或项目类型自行决定。
Hooks 不能替代 CI
本地 Hook 可以被禁用,也可能因为依赖未安装、设备不在线或环境服务未启动而无法完成检查。
因此它适合提供快速反馈,不适合成为合并主分支或生产部署的唯一门禁。
一个更实用的分层验收方案
我最终保留的是三层检查:
第一层:Codex Stop Hook
运行时间控制在几十秒内:
git diff --check
lint
typecheck
按改动目录执行局部测试
第二层:任务内主动验证
由 Codex 根据需求执行:
复现具体 Bug
启动项目
检查真实页面
调用接口
运行针对性测试
第三层:CI
负责完整门禁:
全量测试
生产构建
端到端测试
安全扫描
部署检查
三层的目标不同。Hook 负责减少遗忘,任务验证负责确认本次需求,CI 负责保护共享分支。
适合使用 Hooks 的场景
除了代码验收,Hooks 还适合这些稳定、重复、规则清晰的工作:
- 在提交提示词前扫描疑似 API Key;
- 会话启动时加载项目状态摘要;
- 工具调用前检查危险命令;
- 上下文压缩前保存关键决策;
- 子代理结束时收集结构化结果;
- 任务停止时生成验证记录。
判断一个流程是否适合 Hook,可以问三个问题:
触发时机是否稳定?
执行规则是否确定?
脚本结果是否容易判断?
如果三个答案都是肯定的,Hook 通常比在每个提示词里重复说明更可靠。
如果流程依赖大量临时判断,应该继续交给 Codex 或封装成 Skill,而不是硬塞进生命周期脚本。
最后总结
Codex 提效不只是让模型更快生成代码,也包括减少任务结束后的重复确认和遗漏。
Hooks 最有价值的地方,是把稳定的工程规则放进固定生命周期:代理负责理解和修改代码,脚本负责执行确定性检查,开发者负责审查结果和最终决策。
对于代码验收,我更推荐从一个克制的 Stop Hook 开始,只运行 git diff --check、lint 和类型检查。等脚本稳定后,再按目录增加局部测试,而不是一开始就把完整构建和所有测试塞进去。
自动化应该缩短反馈链路,而不是制造新的等待和误报。
参考资料:Codex Hooks 官方文档