# 文档滞后闸：给编码代理装的 hook

- 版本：1.1.0
- 发布日期：2026-08-11
- 来源：eigentime 原创，在本站仓库中实际运行
- 平台事实核验日期：2026-08-11（Claude Code 2.1.227；Codex 依官方文档）

## 它做什么

改了代码却没更文档，就拦住 `git commit` 和 `git push`，并告诉代理该往哪个文档里写。

规矩本身你多半已经有了——写在 `CLAUDE.md`、`AGENTS.md` 或者团队 wiki 里。问题是那是一句**请求**：请求会被遗忘，会被权衡掉，会在上下文变长之后失效。这个 hook 把它变成执行前的一道闸。

## 它是 guardrail，不是仓库的最终 policy 边界

这一节放在最前面，因为装错位置比装错正则贵得多。

- Claude Code 官方文档说 `if` 过滤器是 best-effort，并明确建议「use the permission system rather than a hook to enforce a hard allow or deny」。
- Codex 官方文档说「Some specialized tool paths can opt out of the default hook path. Treat tool hooks as a useful guardrail, not a complete enforcement boundary.」

所以正确的分层是：

```text
CLAUDE.md / AGENTS.md        意图、原则、软约束
    ↓
lifecycle hook（本套件）      代理侧即时反馈与行为 guardrail
    ↓
Git hook / 测试 / CI          真正的 repository-level enforcement
```

「每一个提交都必须带对应文档」这种硬要求，最终要靠 Git hook 或 CI。这个 hook 的价值在于**在代理还来得及改的那一刻**告诉它，而不是等 CI 红了。

来源：<https://code.claude.com/docs/en/hooks>、<https://learn.chatgpt.com/docs/hooks>

## 装它：三步

**第一步，放脚本。** 把 `hook-doc-freshness.mjs` 放进你仓库的 `scripts/`。需要 Node 18 以上，无任何依赖。

**第二步，改判据。** 打开脚本，改开头那两个正则：

```js
const WORK = /^(src\/|scripts\/|public\/)/;        // 改了这些算「工作」
const DOC  = /^(docs\/|AGENTS\.md|README\.md)/;    // 改了这些算「补了文档」
```

**这一步不能跳过。** 判据从你仓库已经写下来的规则里推，不要现发明。没有出处的判据迟早会被当成噪音绕过去，绕它的人往往就是写它的人。

**第三步，接线。** 两家的事件名和 JSON 思路高度相似，所以 policy 核心可以共用；但 handler 类型、matcher 行为、路径解析和部分事件语义不同，宿主配置必须分别写、分别验。

### Claude Code adapter

写进 `.claude/settings.json`：

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/scripts/hook-doc-freshness.mjs", "commit"],
            "if": "Bash(git commit*)",
            "timeout": 10
          },
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/scripts/hook-doc-freshness.mjs", "push"],
            "if": "Bash(git push*)",
            "timeout": 10
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/scripts/hook-doc-freshness.mjs", "stop"],
            "timeout": 10
          }
        ]
      }
    ]
  }
}
```

三处是有意的：

- **`${CLAUDE_PROJECT_DIR}` + exec form。** `args` 一出现就是 exec form，Claude Code 直接 spawn，不经 shell，每个参数原样传。相对路径 `node scripts/...` 依赖 hook 触发时的工作目录，代理 `cd` 一下就没了。
- **`if` 只做预过滤。** 它用的是 permission-rule 语法，官方明确说 Bash 复合命令会**逐个 subcommand 检查**，且前置环境变量赋值会被剥离后再匹配——官方例子就是 `Bash(git *)` 对 `npm test && git push` 匹配成功。所以它能把无关 Bash 调用挡在进程之外。但它 best-effort：命令解析不了就 fail open，比 command name 写得更细的 pattern 遇到 `$()`、反引号或 `$VAR` 也会照跑。脚本里的命令位置匹配是防线，不是冗余。
- **`Stop` 不能写 `if`。** `if` 只在工具类事件上求值，写在别的事件上这条 hook 永远不跑。

来源：<https://code.claude.com/docs/en/hooks>

### Codex adapter

写进 `<repo>/.codex/hooks.json`（或 `~/.codex/hooks.json`；多个 hook 源会同时加载，高优先级层不会覆盖低优先级层）：

```json
{
  "description": "Doc-freshness gate. CC BY 4.0 — CG-X / eigentime.org",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$(git rev-parse --show-toplevel)/scripts/hook-doc-freshness.mjs\" commit",
            "timeout": 10,
            "statusMessage": "检查文档是否跟上改动"
          },
          {
            "type": "command",
            "command": "node \"$(git rev-parse --show-toplevel)/scripts/hook-doc-freshness.mjs\" push",
            "timeout": 10,
            "statusMessage": "检查待推提交是否带文档"
          }
        ]
      }
    ]
  }
}
```

四处和 Claude 不一样：

- **没有 `${CLAUDE_PROJECT_DIR}`，也没有 `args` exec form。** Codex 的 command hook 以 session `cwd` 为工作目录，而 Codex 可以从仓库子目录启动。官方建议 repo-local hook 从 git root 解析，官方示例用的就是 `$(git rev-parse --show-toplevel)`。
- **没有 `if` 字段。** Codex 只有 `matcher`，且是正则、只匹配工具名。所以每条 Bash 调用都会起进程，脚本第一行的快速返回在这里更重要。
- **`Stop` 的输出 schema 不一样，所以这份 adapter 不接 Stop。** Codex 的 Stop 用 `{"decision":"block","reason":"..."}`，语义是**让 Codex 继续**并把 `reason` 变成一条新的续跑提示；Claude 的 advisory 走 `hookSpecificOutput.additionalContext`。两者不能互抄。（`systemMessage` 这类通用字段两边都有，但决定机制不通用。）要在 Codex 上做停点提醒，得按 Codex 的 schema 单独实现和单独测。
- **PreToolUse 的 deny 形状恰好通用**：Codex 同样接受 `hookSpecificOutput.hookEventName / permissionDecision:"deny" / permissionDecisionReason`（也接受老的 `decision:"block"`）。但 `permissionDecision:"ask"` 在 Codex 上会被解析、不被支持，hook run 记为失败并继续执行工具调用——所以别把 Claude 的四值决定直接搬过去。

来源：<https://learn.chatgpt.com/docs/hooks>

**Codex 的信任门。** 非托管的 command hook 必须先在 `/hooks` 里逐条审阅并信任才会运行；Codex 按 hook 定义的当前哈希记录信任，**定义一改就重新进入 review**。项目级 hook 只在 `.codex/` 层被信任后才加载。`--dangerously-bypass-hook-trust` 只应视作明确的高风险绕过。Claude Code 那边也有首次 codebase 的 trust verification（`-p` 非交互模式下禁用），只是没有到「每条 hook 定义单独哈希」这个粒度。

来源：<https://learn.chatgpt.com/docs/hooks>、<https://code.claude.com/docs/en/security>

## 验它：四层，缺一层就有一类问题溜过去

### 第 1 层：逻辑对不对

```bash
node scripts/hook-doc-freshness.mjs --self-test
```

44 条用例，逐条打印 PASS / FAIL，全过退出码为 0，末行打印总数。**不用读懂正则，看有没有 FAIL 就够了。**

每条用例都标了类别：

- **保证**：设计上的语义保证，坏了就是 bug。
- **尽力**：文本启发式，能被构造出来的输入绕过。
- **边界**：结构上判不了的情况，写成用例是为了让它一直可见，不是为了让它变绿。

判定逻辑用合成路径跑，不依赖你工作区当前状态；另有几条在系统临时目录里建一次性 git 仓库，覆盖非 ASCII 文件名、重命名、整目录未跟踪、以及「只暂存代码时提交闸会放行」这个已知边界。**整个自检不碰你的项目。**

建议挂进你的 `check` 或 CI：

```json
"check:hooks": "node scripts/hook-doc-freshness.mjs --self-test"
```

### 第 2 层：配置本身合法

```bash
jq -e '.hooks.PreToolUse[] | .hooks[] | .command' .claude/settings.json
```

退出 0 且打印出命令才算数。**一个语法错误的 settings 文件会让该文件里的所有设置静默失效，不只是 hook。**

### 第 3 层：宿主真的注册了它

自检证明逻辑是对的，**证明不了宿主加载了它**。

- Claude Code：`/hooks` 打开只读浏览器，逐事件列出已配置的 hook 和每个 handler 的完整定义。
- Codex：`/hooks` 检查来源、审阅并信任新的或改过的 hook、或禁用某条。**没信任 = 不运行**，且启动时会提示。

### 第 4 层：该拦时真拦，不该拦时真不拦

前三层只证明它会响，第 4 层证明它响得对。

构造失败态真跑一次，看它是否拦住；再构造正常态，确认它放行。**只构造失败态而不构造正常态，验出来的是一个见谁拦谁的闸**；那种闸最后会被加上逃生开关，然后一直开着。

要看宿主到底怎么执行的，Claude Code 用 `claude --debug-file <path>`，或 `claude --debug` 后读 `~/.claude/debug/<session-id>.txt`——匹配了哪些 hook、退出码、完整 stdout/stderr 都在里面（`--debug` 不往终端打印）。

## 逃生开关：作用域是关键

确实不需要文档的改动（纯格式化、依赖升级、回滚）用命令前缀跳过：

```bash
SKIP_DOC_CHECK=1 git commit -m "chore: 依赖升级"
```

**开关绑定在这次 `git commit` / `git push` 调用自己的环境变量前缀上**，不是整条 Bash 输入里的任意子串。所以下面两种都**不会**豁免：

```bash
echo SKIP_DOC_CHECK=1 && git commit -m x    # 开关在另一条命令里
git commit -m "SKIP_DOC_CHECK=1"            # 开关在提交信息里
```

v1.0.0 对整条命令做子串搜索，上面两种都能绕过去。v1.1.0 修掉了，并各有一条用例守着。

**为什么是环境变量前缀而不是提交信息里的标记**：如果你用 `git commit -F -` 或 `-F file` 提交，提交信息压根不出现在 hook 能看到的命令字符串里。逃生开关的形式受限于 hook 能看见什么。

唯一保留的选项豁免是 `git push --delete`：它「deletes [listed refs] from the remote repository」，不发布任何 tree。同样绑定在这次 push 调用自己的参数上。

## 设计取舍与已知边界

**一、提交闸看的是整个工作区，不是暂存区。** 这是被迫的：`PreToolUse` 在 `git add -A && git commit` 整条命令执行**之前**触发，那一刻暂存区还是空的，查 `--cached` 会永远返回空、永远放行。代价是：工作区里文档恰好是脏的、而你只 `git add` 了代码文件时，提交闸会放行。**所以提交闸不等价于 Git 的 pre-commit hook。**

**二、推送闸看的是 range，不是逐个提交。** 它查 `@{u}..HEAD` 即将公开的那批提交实际动了什么，堵住了上面那个洞。但它是聚合的：一个文档提交 + 四个代码提交，整体算过。**「每一个提交都要带文档」这条，range 级的闸证明不了**，要靠 Git hook 或 CI。

**三、判断的是文本，不是语义。** 脚本只在命令位置匹配（行首，或 `&&`、`||`、`;`、`|`、换行之后，允许前置环境变量赋值），所以 `grep "git commit" f` 和把这两个词写进字符串字面量都不会误触发。代价是几种写法会漏判，用例里标成「边界」：

| 写法 | 结果 | 为什么不修 |
|---|---|---|
| `command git commit -m x` | 漏判 | 前缀形式没有尽头（`env`、`builtin`、绝对路径…），补不完 |
| `git -C . commit -m x` | 漏判 | `-C` 可能指向别的仓库，这时工作区证据根本不是那个仓库的，拦下来的代价比漏掉更大 |

**漏判和误判的代价不对称**：漏判少拦一次，误判会让人把整个 hook 关掉，所以这里偏向漏判。

**四、`--amend` 不再豁免。** `git commit --amend` 会「replace the tip of the current branch by creating a new commit. The recorded tree is prepared as usual」，也就是说它可以带上全新的代码改动，不只是改提交信息。v1.0.0 见到 `--amend` 就放行，是一个可复现的旁路。纯改信息的 amend 依然免费通过——工作区是干净的，压根产出不了 work path。

**五、`git push --tags` 不再豁免。** `--tags` 是「in addition to refspecs explicitly listed on the command line」，所以 `git push --tags origin main` 照样更新分支。删掉这条豁免的成本很低：分支已经推干净时 range 是空的，纯推标签本来就放行。

**六、任何异常一律放行。** 整段逻辑包在 `try` 里，`catch` 直接 `exit 0`。一个因为 git 抽风就卡住提交的 hook，比没有 hook 更糟。

**七、它只查「有没有碰文档」，查不了「写得对不对」。** 防的是彻底忘记，不是敷衍。硬约束用确定性的 command hook；主观质量判断可以用 Claude Code 的 `prompt` / `agent` handler（`agent` 目前标为 experimental），但**模型判断不该承担硬边界**。

来源：<https://git-scm.com/docs/git-commit>、<https://git-scm.com/docs/git-push>、<https://git-scm.com/docs/git-status>

## 交给 AI 代理自己装

把下面这段贴给代理：

> 请把 `hook-doc-freshness.mjs` 装进本仓库：
>
> 1. 放到 `scripts/`。
> 2. 读一遍本仓库的 `CLAUDE.md` / `AGENTS.md` / `README.md`，找出已经写下来的「改了 X 要更新 Y」类规则，据此改写脚本开头的 `WORK` 和 `DOC` 两个正则。**只用文档里已有的规则，不要发明新规矩**；如果找不到任何这类规则，停下来问我，不要自己定。
> 3. 按 README 里对应宿主的 adapter 写配置：Claude Code 写 `.claude/settings.json`（exec form + `${CLAUDE_PROJECT_DIR}` + `if` 预过滤），Codex 写 `.codex/hooks.json`（从 `$(git rev-parse --show-toplevel)` 解析路径，不要接 Stop）。注意与已有 hooks 合并而不是覆盖。
> 4. 跑 `node scripts/hook-doc-freshness.mjs --self-test`，把完整输出贴给我。有 FAIL 就先修再继续。
> 5. 用 `jq -e` 确认配置文件是合法 JSON 且能读出命令。
> 6. 在宿主里确认它真的注册了：Claude Code 跑 `/hooks`；Codex 跑 `/hooks` 并完成信任审阅。把结果告诉我。
> 7. 构造一次「该拦」的状态真跑一次，再构造一次「不该拦」的状态真跑一次，两边结果都告诉我。只测前一半不算数。
> 8. 最后告诉我：你把 `WORK` 和 `DOC` 定成了什么，每一条的依据是本仓库哪个文件的哪句话。

第 8 步是重点：**判据的出处是唯一能核对的东西**——它有没有自己编规矩，只能从这里看出来。

## CHANGELOG

### 1.1.0（2026-08-11）

- **接上 Claude Code 原生 `if` 预过滤**，配置改为 exec form + `${CLAUDE_PROJECT_DIR}`，不再依赖 hook 触发时的工作目录。
- **修 Stop 的返回形状**：由只给人看的 `systemMessage` 改为 `hookSpecificOutput.additionalContext`（模型看得到、对话继续），并用 `stop_hook_active` 防止重复触发；`systemMessage` 保留给人。新增形状用例，验的是「宿主会照做的形状」，不只是「有没有返回东西」。
- **删掉 `--amend` 整体豁免**：amend 会重新记录 tree，可以夹带新代码。
- **删掉 `git push --tags` 整体豁免**：`--tags` 是叠加的，同命令行的 refspec 照样生效。
- **收紧逃生开关作用域**：绑定到本次 `git commit` / `git push` 调用自己的环境变量前缀，不再对整条命令做子串搜索。`--delete` 豁免同样收紧到该次调用自己的参数。
- **新增 Codex adapter**（配置位置、git-root 路径解析、matcher 差异、Stop schema 不通用、信任模型）。
- **自检从 21 条扩到 44 条**，含对抗性用例与显式标注的已知边界；新增输出形状断言与「整目录未跟踪」「选择性暂存」两个临时仓库用例。
- **重写宿主加载的验证流程**：改为 `--self-test` → `jq -e` → `/hooks` → `--debug-file`，删掉「文件监视器只监视会话启动时已存在的目录」这一条没有官方依据的说法。
- **重写安全叙述**：明确 hook 是 guardrail 不是仓库 policy 边界，并写清 Claude 与 Codex 各自的信任机制。

### 1.0.0（2026-08-10）

首个公开版本：提交闸、推送闸、停点提醒三种模式，命令位置匹配避免误报，`-z` 处理非 ASCII 路径，异常一律放行，21 条自检用例与给 AI 代理的安装提示词。

## 许可与边界

本套件采用 [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) 许可。可复制、修改、商用，需保留署名并注明是否修改。

建议署名：`CG-X / eigentime.org — 文档滞后闸 v1.1.0`。

- **Claude Code 上实测过**（2.1.227，2026-08-11）：exec form 与 `${CLAUDE_PROJECT_DIR}` 生效；`if: "Bash(git commit*)"` 对 `echo build && git commit ...` 命中、对 `echo hello` 不起进程、对含 `$()` 或 `$VAR` 的命令 fail open 照跑；拦截理由完整回喂给代理；逃生开关的两种旁路写法确实被拦。
- **Codex 上未实测。** 本机没有安装 Codex，上面的 Codex adapter 与差异说明全部来自官方文档（2026-08-11 核验），**不是实测结论**。装之前请自己按第 3、4 层验一遍。
- Node 18 以上，仓库需是 git 仓库。Windows 未测（Claude Code 在 Windows 上对 `${CLAUDE_PROJECT_DIR}` 的展开另有写法差异，见官方文档）。
- 它假设你的默认分支能通过 `@{u}`、`origin/HEAD` 或 `origin/main` 之一解析出来；都解析不出时推送闸放行而不是报错。
- 版本敏感的产品行为核验于 2026-08-11。事件表、字段名和 handler 支持情况都在变，用之前请对一遍当前官方文档。
