# Doc-freshness gate: a hook for coding agents

- Version: 1.1.0
- Released: 2026-08-11
- Source: original work by eigentime, running in this site's own repository
- Platform facts verified: 2026-08-11 (Claude Code 2.1.227; Codex from official docs only)

## What it does

Blocks `git commit` and `git push` when code changed but documentation did not, and tells the agent which document to write in.

You probably already have the rule — in `CLAUDE.md`, `AGENTS.md`, or a team wiki. The problem is that a rule written there is a **request**: requests get forgotten, traded away, and lost as the context grows. This hook turns it into a gate that runs before the action.

## It is a guardrail, not your repository's final policy boundary

This section comes first because putting the gate at the wrong layer costs more than getting the regex wrong.

- Claude Code's docs call the `if` filter best-effort and say to "use the permission system rather than a hook to enforce a hard allow or deny".
- Codex's docs say "Some specialized tool paths can opt out of the default hook path. Treat tool hooks as a useful guardrail, not a complete enforcement boundary."

So the honest layering is:

```text
CLAUDE.md / AGENTS.md        intent, principles, soft constraints
    ↓
lifecycle hook (this kit)    agent-side immediate feedback, a behavioral guardrail
    ↓
Git hooks / tests / CI       actual repository-level enforcement
```

A hard requirement like "every commit must carry its doc" ultimately belongs in a Git hook or CI. What this hook buys you is telling the agent **at the moment it can still fix it**, instead of after CI turns red.

Sources: <https://code.claude.com/docs/en/hooks>, <https://learn.chatgpt.com/docs/hooks>

## Install: three steps

**1. Drop in the script.** Put `hook-doc-freshness.mjs` in your repo's `scripts/`. Needs Node 18+, no dependencies.

**2. Set the criteria.** Open the script and edit the two regexes near the top:

```js
const WORK = /^(src\/|scripts\/|public\/)/;        // changing these counts as "work"
const DOC  = /^(docs\/|AGENTS\.md|README\.md)/;    // changing these counts as "documented"
```

**Do not skip this, and do not fill it in from intuition.** Derive the criteria from rules your repo has already written down; do not invent new ones. Criteria without a source get treated as noise and routed around eventually — including by you.

**3. Wire it up.** Event names and JSON shapes are similar enough across the two hosts that the policy core can be shared. Handler types, matcher behavior, path resolution and some event semantics are not — write and verify each host's config separately.

### Claude Code adapter

In `.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
          }
        ]
      }
    ]
  }
}
```

Three deliberate choices:

- **`${CLAUDE_PROJECT_DIR}` plus exec form.** The presence of `args` selects exec form: Claude Code spawns the executable directly with no shell, passing each argument exactly as written. A relative `node scripts/...` depends on whatever the working directory happens to be when the hook fires, which one `cd` destroys.
- **`if` is a pre-filter, nothing more.** It uses permission-rule syntax, and the docs state that each subcommand of a Bash compound command is checked and that leading environment assignments are stripped before matching — the official example is `Bash(git *)` matching `npm test && git push`. That keeps unrelated Bash calls from spawning a process. But it is best-effort: it fails open when the command cannot be parsed, and a pattern more specific than the command name runs the hook anyway on `$()`, backticks, or `$VAR`. The command-position matching inside the script is the defensive line, not redundancy.
- **No `if` on `Stop`.** `if` is only evaluated on tool events; on any other event, a hook that sets it never runs at all.

Source: <https://code.claude.com/docs/en/hooks>

### Codex adapter

In `<repo>/.codex/hooks.json` (or `~/.codex/hooks.json` — multiple hook sources all load, and higher-precedence layers do not replace lower ones):

```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": "Checking docs against changes"
          },
          {
            "type": "command",
            "command": "node \"$(git rev-parse --show-toplevel)/scripts/hook-doc-freshness.mjs\" push",
            "timeout": 10,
            "statusMessage": "Checking outgoing commits for docs"
          }
        ]
      }
    ]
  }
}
```

Four things differ from Claude:

- **No `${CLAUDE_PROJECT_DIR}`, no `args` exec form.** Codex command hooks run with the session `cwd` as their working directory, and Codex can be started from a subdirectory. The docs recommend resolving repo-local hooks from the git root, and the official example uses `$(git rev-parse --show-toplevel)`.
- **No `if` field.** Codex has `matcher` only, a regex matched against the tool name. Every Bash call therefore spawns a process, which makes the script's early return matter more here.
- **The `Stop` output schema is different, so this adapter does not wire Stop.** Codex's Stop uses `{"decision":"block","reason":"..."}`, and its meaning is *keep going*: Codex turns `reason` into a new continuation prompt. Claude's advisory path is `hookSpecificOutput.additionalContext`. Neither can be copied to the other. A stop-time nudge on Codex needs its own implementation and its own test.
- **The PreToolUse deny shape happens to be portable.** Codex also accepts `hookSpecificOutput.hookEventName` / `permissionDecision: "deny"` / `permissionDecisionReason` (and the older `decision: "block"`). But `permissionDecision: "ask"` is parsed and not supported on Codex — the hook run is marked failed and the tool call proceeds. Do not port Claude's four-value decision wholesale.

Source: <https://learn.chatgpt.com/docs/hooks>

**Codex's trust gate.** A non-managed command hook must be reviewed and trusted in `/hooks` before it runs. Codex records trust against the hook's current hash, so **any edit to the definition sends it back for review**. Project-local hooks load only once the `.codex/` layer is trusted. `--dangerously-bypass-hook-trust` should be treated as exactly what its name says. Claude Code also has first-run trust verification for a codebase (disabled under non-interactive `-p`), it just does not go down to per-hook-definition hashing.

Sources: <https://learn.chatgpt.com/docs/hooks>, <https://code.claude.com/docs/en/security>

## Verify it: four layers, each catching a different class of failure

### Layer 1: is the logic right

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

44 cases, PASS/FAIL per line, exit 0 only if all pass, total printed on the last line. **You do not need to read the regexes; you need to see whether anything says FAIL.**

Every case is labelled:

- **保证 / guarantee** — a semantic guarantee of the design. If it breaks, that is a bug.
- **尽力 / best-effort** — a text heuristic that a crafted input can defeat.
- **边界 / known limitation** — structurally undecidable here. It is a case so that it stays visible, not so that it turns green.

The decision cases run on synthetic paths, so they do not depend on the state of your working tree. A few more build a throwaway git repo in the system temp directory covering non-ASCII filenames, a rename, a wholly untracked directory, and the "selectively staged code passes the commit gate" limitation. **The self-test never touches your project.**

Wire it into your `check` or CI:

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

### Layer 2: is the configuration itself valid

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

It counts only if it exits 0 and prints the commands. **A settings file with a syntax error silently disables everything in that file, not just the hook.**

### Layer 3: did the host actually register it

The self-test proves the logic. It **does not prove the host loaded it**.

- Claude Code: `/hooks` opens a read-only browser listing every event's configured hooks and the full definition of each handler.
- Codex: `/hooks` inspects sources, reviews and trusts new or changed hooks, or disables one. **Untrusted means it does not run**, and Codex warns at startup when a review is pending.

### Layer 4: does it block when it should and pass when it should not

The first three layers prove it fires. This one proves it fires correctly.

Build the failing state, actually run the action, watch it get blocked. Then build the passing state and confirm it goes through. **Do only the first half and you get a gate that blocks everything** — and that gate ends up behind an escape hatch that is always on.

To see what the host actually did, Claude Code writes hook execution details — which hooks matched, exit codes, full stdout and stderr — to the debug log: `claude --debug-file <path>`, or `claude --debug` and read `~/.claude/debug/<session-id>.txt`. `--debug` does not print to the terminal.

## The escape hatch, and why its scope is the point

For changes that genuinely need no doc — pure formatting, a dependency bump, a revert — skip with a command prefix:

```bash
SKIP_DOC_CHECK=1 git commit -m "chore: bump deps"
```

**The switch is bound to the environment prefix of that specific `git commit` / `git push` invocation**, not to any substring of the whole Bash input. So neither of these is exempt:

```bash
echo SKIP_DOC_CHECK=1 && git commit -m x    # switch belongs to a different command
git commit -m "SKIP_DOC_CHECK=1"            # switch is in the commit message
```

v1.0.0 substring-searched the entire command and both of those got through. v1.1.0 fixes it, with a case guarding each.

**Why an env prefix rather than a commit-message marker**: if you commit with `git commit -F -` or `-F file`, the message never appears in the command string the hook can see at all. Before choosing the shape of an escape hatch, confirm your hook can actually see it.

The only remaining option exemption is `git push --delete`, which "deletes [listed refs] from the remote repository" and publishes no tree. It is likewise scoped to that invocation's own arguments.

## Design tradeoffs and known limitations

**1. The commit gate reads the working tree, not the index.** It has to: `PreToolUse` fires *before* the whole `git add -A && git commit` compound command, so nothing is staged and `git diff --cached` would be empty and always allow. The cost: if docs happen to be dirty in the working tree and you `git add` only code files, the commit gate passes. **The commit gate is therefore not equivalent to a Git pre-commit hook.**

**2. The push gate reads a range, not individual commits.** It looks at what `@{u}..HEAD` actually changed, which closes the hole above. But it aggregates: one doc commit plus four code commits passes as a whole. **A range-level gate cannot prove "every commit carries its doc"** — that needs a Git hook or CI.

**3. It judges text, not semantics.** The script only matches in command position (start of line, or after `&&`, `||`, `;`, `|`, a newline, optionally behind env assignments), so `grep "git commit" f` and the words inside a string literal do not trip it. The cost is that a few forms are missed, and they are labelled 边界 in the case table:

| Form | Result | Why it stays |
|---|---|---|
| `command git commit -m x` | missed | The list of prefixes has no end (`env`, `builtin`, absolute paths…) |
| `git -C . commit -m x` | missed | `-C` may point at a different repo, in which case the working-tree evidence is not that repo's — a false block is worse than a miss |

**Prefer a miss to a false positive.** A miss costs one un-gated commit; a false positive gets the whole hook switched off.

**4. `--amend` is no longer exempt.** `git commit --amend` "replace[s] the tip of the current branch by creating a new commit. The recorded tree is prepared as usual" — meaning it can carry brand new code, not just a reworded message. v1.0.0 allowed anything containing `--amend`, which was a reproducible bypass. A message-only amend still passes for free: a clean working tree produces no work paths at all.

**5. `git push --tags` is no longer exempt.** `--tags` pushes tags "in addition to refspecs explicitly listed on the command line", so `git push --tags origin main` still updates the branch. Dropping the exemption costs almost nothing — with nothing unpushed the range is empty and a tag-only push passes anyway.

**6. Any error means allow.** The whole thing is wrapped in `try`, and `catch` exits 0. A hook that blocks commits because git hiccuped is worse than no hook.

**7. It checks whether docs were touched, not whether they are any good.** It defends against forgetting entirely, not against a token line. Use deterministic command hooks for hard constraints; subjective quality checks can use Claude Code's `prompt` / `agent` handlers (`agent` is currently marked experimental), but **model judgment should not carry a hard boundary**.

Sources: <https://git-scm.com/docs/git-commit>, <https://git-scm.com/docs/git-push>, <https://git-scm.com/docs/git-status>

## Hand it to an AI agent to install

Paste this to the agent:

> Install `hook-doc-freshness.mjs` into this repository:
>
> 1. Put it in `scripts/`.
> 2. Read this repo's `CLAUDE.md` / `AGENTS.md` / `README.md` and find the rules already written down of the form "if you change X, update Y". Rewrite the `WORK` and `DOC` regexes at the top of the script from those. **Use only rules the docs already state; do not invent new ones.** If you cannot find any such rule, stop and ask me rather than deciding yourself.
> 3. Write the host config from the matching adapter in the README: Claude Code gets `.claude/settings.json` (exec form + `${CLAUDE_PROJECT_DIR}` + the `if` pre-filter); Codex gets `.codex/hooks.json` (resolve the path from `$(git rev-parse --show-toplevel)`, and do not wire Stop). Merge with existing hooks; do not overwrite them.
> 4. Run `node scripts/hook-doc-freshness.mjs --self-test` and paste me the full output. Fix any FAIL before continuing.
> 5. Use `jq -e` to confirm the config is valid JSON and the commands read back.
> 6. Confirm the host actually registered it: run `/hooks` in Claude Code; run `/hooks` in Codex and complete the trust review. Tell me what you saw.
> 7. Build a state that *should* be blocked and actually run the action; then build a state that should *not* be blocked and run it too. Report both. Testing only the first half does not count.
> 8. Finally, tell me what you set `WORK` and `DOC` to, and for each entry, which sentence in which file of this repo it came from.

Step 8 is the one that matters. **Where a criterion came from matters more than the criterion** — it is how you check whether the agent invented rules of its own.

## CHANGELOG

### 1.1.0 (2026-08-11)

- **Integrated Claude Code's native `if` pre-filter**; config moved to exec form with `${CLAUDE_PROJECT_DIR}` so it no longer depends on the working directory at fire time.
- **Fixed the Stop feedback shape**: `systemMessage` (human-only) became `hookSpecificOutput.additionalContext` (model-visible, conversation continues), guarded by `stop_hook_active` against repeat firing; `systemMessage` is kept for the human. Added shape assertions that check the host will act on the output, not merely that something was returned.
- **Removed the blanket `--amend` exemption** — an amend re-records the tree and can carry new code.
- **Removed the blanket `git push --tags` exemption** — `--tags` is additive, so refspecs on the same command line still apply.
- **Tightened escape-hatch detection** to the env prefix of the target invocation instead of a substring search over the whole command. The `--delete` exemption is likewise scoped to that invocation's own arguments.
- **Added a Codex adapter** (config locations, git-root path resolution, matcher differences, non-portable Stop schema, trust model).
- **Expanded the self-test from 21 to 44 cases**, including adversarial cases and explicitly labelled known limitations; added output-shape assertions and two new throwaway-repo cases (untracked directory, selective staging).
- **Rewrote the host-load verification procedure**: `--self-test` → `jq -e` → `/hooks` → `--debug-file`, dropping the unsourced claim that the config file watcher only watches directories that existed at session start.
- **Rewrote the security narrative**: hooks are a guardrail, not a repository policy boundary, and both hosts' trust mechanisms are described accurately.

### 1.0.0 (2026-08-10)

Initial release: commit gate, push gate, and stop advisory; command-position matching to avoid false positives; `-z` handling for non-ASCII paths; fail-open on any error; 21 self-test cases and an install prompt for AI agents.

## License and boundaries

This kit is licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Copy, modify and use commercially, with attribution and an indication of changes.

Suggested attribution: `CG-X / eigentime.org — Doc-freshness gate v1.1.0`.

- **Tested on Claude Code** (2.1.227, 2026-08-11): exec form and `${CLAUDE_PROJECT_DIR}` work; `if: "Bash(git commit*)"` matches `echo build && git commit ...`, does not spawn on `echo hello`, and fails open on commands containing `$()` or `$VAR`; the deny reason reaches the agent in full; both escape-hatch bypass forms are correctly blocked.
- **Not tested on Codex.** Codex is not installed on the authoring machine. The Codex adapter and every difference described above come from the official docs (verified 2026-08-11) and are **not** empirical results. Run layers 3 and 4 yourself before relying on them.
- Node 18+, and the repo must be a git repository. Windows untested (Claude Code's `${CLAUDE_PROJECT_DIR}` expansion has its own Windows caveats — see the official docs).
- It assumes your default branch resolves through one of `@{u}`, `origin/HEAD`, or `origin/main`; if none resolve, the push gate allows rather than erroring.
- Version-sensitive product behavior was verified on 2026-08-11. Event tables, field names and handler support all keep changing — check the current official docs before relying on any of it.
