← 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 set of rules into your coding agent when a session starts. The Claude Code integration already had a shared configuration resolver, but the Codex integration was doing something rather different. Its .codex/hooks.json contained a single SessionStart command with the entire ruleset pasted directly into an echo statement.

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

It worked, which was part of the problem. The hook ran, Codex received the rules and the session started normally. Nothing in the existing tests complained because there wasn't anything obviously broken during execution. The problem was that the Codex integration had no idea that Caveman supported configurable default modes.

The shared resolver in src/hooks/caveman-config.js already handled four configuration sources: the CAVEMAN_DEFAULT_MODE environment variable, a repository's .caveman/config.json or .caveman.json, the user's configuration directory, and the built-in default. Claude used that resolver, while Codex ignored it entirely. Changing the environment variable or adding a repository configuration made no difference to Codex sessions.

This became particularly noticeable with off. Someone could explicitly configure Caveman not to interfere with their sessions and still have the Codex hook inject the full ruleset. The integration was effectively ignoring the user's opt-out. This was reported in Caveman #185.

Using the existing resolver

My first thought was to add a small check for CAVEMAN_DEFAULT_MODE and fall back to full when it wasn't set. That would have fixed one configuration path, but it would also have introduced another implementation of logic that already existed elsewhere in the project. The next time the resolution rules changed, there would be two places to update.

Instead, I made the Codex hook load caveman-config.js and use its existing getDefaultMode() function and exported valid-mode list. The new .codex/codex-sessionstart.js resolves the selected mode and generates the output Codex expects. For the regular Caveman modes, it reads the same SKILL.md used by the Claude integration and applies the appropriate filtering.

The result is that both integrations now follow the same configuration rules and use the same source for their instructions, rather than maintaining separate copies of the ruleset.

Handling the different modes

The off mode needed particular attention. When the resolved configuration is {"defaultMode": "off"}, the hook should produce no output and exit successfully. It shouldn't inject a default ruleset or treat off as an invalid value. I added coverage for both environment-variable and configuration-file settings so the opt-out works regardless of where it comes from.

There are also modes that don't fit into the usual full, lite and ultra categories. commit, review and compress represent separate skill behaviours, not different amounts of the standard ruleset. The Claude hook already handled these by printing a short pointer to the relevant skill, so I kept the same behaviour in Codex. The wenyan mode also retains its canonical wenyan-full label.

I wanted the new implementation to preserve what those modes actually mean rather than treating every configuration value as an intensity setting.

The fallback and failure cases

The shared configuration module isn't guaranteed to be available beside the hook in every installation layout. The Codex hook therefore has a small fallback resolver with the same configuration precedence and valid-mode list. I also added a test that compares its whitelist against the real exported VALID_MODES list. If someone adds a new mode to Caveman without updating the fallback, the test catches it.

The hook also needs to fail open. A configuration error or a missing rules file shouldn't prevent someone from starting a Codex session. If resolving the mode or loading the rules throws, the hook exits successfully without injecting anything.

While working on that path, I also avoided calling process.exit() immediately after writing to stdout. The hook uses process.stdout.write(), and terminating the process before the output has flushed can result in incomplete data being sent to Codex. Letting the process finish naturally avoids that problem. I added a test that pipes the output and checks that the complete ruleset arrives.

Running from a subdirectory

There was another small issue with how the hook was invoked. Codex runs repository hooks with the session's working directory, which doesn't necessarily mean the repository root. Running node .codex/codex-sessionstart.js directly could therefore fail when a session started inside a nested directory.

I changed the manifest command to resolve the Git repository root first:

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

This also follows the repository-root approach recommended for repo-local Codex hooks. A separate test starts execution from a subdirectory and checks that the hook still runs correctly.

Testing it

I added 15 tests. One of the more useful ones checks the manifest itself: it failed against the old implementation because .codex/hooks.json still contained the hardcoded echo command. That's a better regression test than simply checking that the new JavaScript file produces the expected output.

The tests cover mode resolution and precedence, repository and user configuration, off, commit, review, compress, wenyan, invalid JSON, symlinked configuration files, the fallback resolver and its whitelist, execution from a subdirectory, the Git root command, and stdout flushing.

I kept the fixtures isolated by copying .codex, src and skills into temporary directories. This means the tests don't accidentally read my own configuration or depend on whatever happens to be installed on the machine running them.

The full repository suite had 419 tests: 394 passed, 23 were skipped, and two failed. Both failures also reproduced on unmodified main on Windows. They were related to unprivileged symlink creation and a missing PowerShell executable, rather than the changes in this PR.

I also ran the important runtime cases manually. The default configuration produced the full ruleset, setting CAVEMAN_DEFAULT_MODE=off produced no output, the other modes selected the expected instructions, and starting from a subdirectory gave the same result as starting from the repository root.

Getting it merged

I opened PR #1139, titled feat(codex): SessionStart hook honors defaultMode. I kept the change limited to the repository-local Codex integration: updating the manifest, adding the new hook and adding its tests. I didn't change the installer, native runtime or unrelated shared functionality.

Julius later adopted the work into PR #1103 and cherry-picked it, preserving the original authorship. He specifically called out the shared configuration resolver, avoiding process.exit() after writing to stdout, checking the fallback whitelist against VALID_MODES, and preserving configuration precedence in the fallback path.

PR #1139 was eventually superseded by PR #1160, which carried the same design into the main branch.

Looking back, most of the work was figuring out how to reuse what Caveman already had instead of adding another implementation. The original hook wasn't crashing or producing obviously incorrect output; it was just ignoring configuration that users had every reason to expect it to respect. That kind of issue is easy to miss when you're only checking whether a hook runs successfully.

The changes are now in main, and setting the default mode to off actually leaves Codex alone.

Vishal Gurung← All notes