让 Codex 自动验收代码:Hooks 提效实践

3 阅读11分钟

摘要

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.'

这个脚本没有直接执行所有检查,而是先判断:

  1. 当前目录是否属于 Git 仓库;
  2. 工作区是否存在变更;
  3. 变更是否包含前端源码;
  4. 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 可以按工具名称匹配,例如 Bashapply_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"
}

promptagent 类型虽然可以被解析,但当前不会执行。配置时不要根据字段名称猜测能力。

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 官方文档