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.