← Home

Caveman Had a Setting That Silently Did Nothing on Codex

5 Oct 2026

Codex pasted its ruleset in as a literal string, so four separate ways of setting a mode went nowhere — including the one that turns Caveman off.

cavemancodexopen-sourceconfiguration

The kind of bug a test suite cannot see

Caveman injects a ruleset into your coding agent at the start of a session. For Claude Code it reads a shared mode resolver and emits whatever that resolver says. For Codex it did something much simpler. .codex/hooks.json held one SessionStart command that pasted the rules in as a literal string.

{
  "hooks": {
    "SessionStart": [
      { "command": "echo '<the full ruleset, pasted in>'" }
    ]
  }
}

A copy, written once by hand and never touched again. Nothing about that fails a test. The hook runs, the rules arrive, Codex sessions work, everything is green. The problem is what it cannot do.

Caveman resolves a default mode from four places, and the shared resolver at src/hooks/caveman-config.js already knew about all four: the CAVEMAN_DEFAULT_MODE environment variable, a repository-local .caveman/config.json or .caveman.json, a configuration in the user's config directory, and Caveman's own built-in default. The Codex hook consulted none of them. Setting CAVEMAN_DEFAULT_MODE had no effect on a Codex session. A repository default was ignored. A user default was ignored.

The one that mattered most was the opt-out. If someone set the default mode to off, meaning they did not want Caveman touching their sessions, the static hook still injected the rules — because it had never been told there was a setting to consult. The integration could overrule an explicit request to turn it off. It was tracked as Caveman #185, the Codex SessionStart hook ignoring the shared mode resolver.

Reusing what already existed

The tempting fix was a few lines that checked CAVEMAN_DEFAULT_MODE and otherwise used full. That would reproduce part of the behaviour and leave a second copy of the configuration rules in the tree, which is how the two integrations drifted apart in the first place. So the hook loads the shared caveman-config.js module and uses its getDefaultMode() and the valid-mode list it already exports.

.codex/codex-sessionstart.js resolves the current mode and then produces the output Codex needs. For the ordinary Caveman modes it emits the same filtered SKILL.md rules the Claude hook emits, taken from the same source rather than from a third copy. The flow is now:

Codex SessionStart
       ↓
codex-sessionstart.js
       ↓
shared default-mode resolver
       ↓
selected Caveman mode
       ↓
filtered SKILL.md rules
       ↓
Codex

off has to mean off

An explicit opt-out is the case most easily lost when replacing a hardcoded implementation, so it got its own attention. Given {"defaultMode": "off"}, the hook prints nothing and exits successfully. The user gets a real opt-out rather than a value that is technically accepted and then ignored. There are tests for off set both ways, through the environment and through a configuration file.

Not every mode is an intensity

Caveman also has commit, review and compress. These are not levels of the normal ruleset, they are their own skill behaviours, and the Claude hook already handled them by emitting a short pointer to the relevant skill. The Codex hook does the same: commit, for instance, produces a one-line pointer rather than a large ruleset. wenyan keeps its canonical wenyan-full label. The point was to preserve those distinctions instead of collapsing every value into full / lite / ultra.

When the shared module is not there

Caveman can be laid out in more than one way, and caveman-config.js is not guaranteed to sit beside the hook. The hook must not crash in that case, so it carries a small fallback resolver: the valid-mode list and the same resolution order as the real one.

A fallback that quietly diverges from the thing it is standing in for is worse than no fallback, so the fallback's mode whitelist is asserted against the real VALID_MODES list in a test. Adding a mode to Caveman and forgetting the fallback fails the suite rather than silently changing behaviour.

Fail open, and actually flush

A SessionStart hook should not be able to stop a Codex session from starting because Caveman has a problem. If resolving the mode or loading the rules throws, the hook exits successfully without injecting anything.

There is one detail in that path worth naming. The hook writes with process.stdout.write() and deliberately does not call process.exit() afterwards, because stdout can still be holding data waiting to be flushed and exiting immediately can drop it. For a process whose only job is to hand rules to another process, losing the output is the whole failure. Finishing naturally is the fix, and there is a test that pipes the output and checks it arrives intact.

Subdirectories

Codex command hooks run with the session's working directory, which is not necessarily the repository root, so a plain node .codex/codex-sessionstart.js can miss the file. The hook resolves the root first:

$(git rev-parse --show-toplevel)/.codex/codex-sessionstart.js

That also matches the repository-root form Codex recommends for repo-local hooks. It is covered by a test that starts from a subdirectory.

The tests

15 new tests, and the interesting thing about them is that they failed before the change. The manifest test, for one, failed against the old implementation because the manifest still contained the literal static echo. A test written after the fact to confirm the code behaves like itself proves less than that.

They cover default-mode resolution, environment precedence, repository-local configuration, user configuration, off, commit / review / compress, wenyan, invalid JSON, symlinked configuration files, the fallback resolver and its whitelist, running from a subdirectory, the git-root command in the manifest, and stdout surviving a pipe without being truncated.

The fixtures are hermetic: the tests copy .codex, src and skills into a temporary environment instead of letting the suite read my real configuration. An authentication or configuration test that depends on the developer's own machine answers "does this happen to work here", which is not a useful question.

The full repository suite was 419 tests: 394 passing, 23 skipped, and two failures that reproduced on unmodified main on Windows. Those two were environmental — unprivileged symlink creation and a missing PowerShell executable — not caused by this change. I also checked the runtime cases by hand: the default configuration produced the full ruleset, CAVEMAN_DEFAULT_MODE=off produced no output, the other modes selected the rules they should, and launching from a subdirectory gave the same result as launching from the root.

What happened to the PR

PR #1139, feat(codex): SessionStart hook honors defaultMode, scoped to the repo-local Codex behaviour. I did not touch the installer, the native runtime, or any unrelated shared surface. In terms of files it was small — the manifest was pointed at the new hook, and the hook and its tests were added.

Julius adopted the work into #1103 and cherry-picked it so the authorship stayed intact, and said specifically why it was adoptable rather than merely plausible: it used the shared configuration resolver, it avoided process.exit() after writing to stdout, it tested the fallback whitelist against VALID_MODES, and its degraded path still respected the full resolution order. #1139 was later superseded by #1160, which carried the same design into main.

So #1139 is not the PR that merged. The design is what made it in, which is the part that matters.

What I took from it

The amount of code does not tell this story. The bug was that Codex printed a hardcoded message instead of consulting a configured mode, and the fix was small. Getting to it meant reading how Caveman already resolved configuration, separating ordinary modes from independent skills, handling a layout where the shared module is absent, keeping a startup hook safe, resolving paths from a subdirectory, and testing the edges rather than the happy path.

The part I care about is not the hook. It is that the easy version of this change would have left a second copy of the configuration rules in the tree, working on the day it landed and quietly different from the rest of Caveman six months later. Two integrations that are supposed to behave the same way should read from one source of truth. That is obvious once written down. It was not obvious when I opened the issue.

Vishal Gurung ← All notes