I Stopped Writing a Workspace's Position into a File Every Project Reads — basou's three moves to a SessionStart hook
AI coding agents have a file they read automatically, whichever project you start them in. With the default settings, that is ~/.codex/AGENTS.md for Codex and ~/.claude/CLAUDE.md for Claude Code. Apart from the AGENTS.md
AI coding agents have a file they read automatically, whichever project you start them in. With the default settings, that is ~/.codex/AGENTS.md for Codex and ~/.claude/CLAUDE.md for Claude Code. Apart from the AGENTS.md or CLAUDE.md you keep in a repository, there is one in your home directory, and a session in any project you open on that machine reads it. (I wrote about the repository-side instruction files in another post.)
It is a convenient mechanism: conventions shared by all your projects go in one place. So what happens if you write something about one particular project there?
basou, which I build, has a command called basou orient. It assembles, from the trail, where you are and what to do next (introduction post). If this "position" could be handed to an agent when its session starts, you would not have to explain it again every time. For Codex, basou handed it over by writing it into ~/.codex/AGENTS.md.
In the local-first post, I wrote that a plain basou refresh updates ~/.codex/AGENTS.md every time, said "this one needs care", and called it "default behavior, not an option" (as of v0.37.0). This post is the record of what became of that default. I narrowed when it writes, removed the place it writes to, and inspected what it hands over. It took three moves.
Everything below is based on the implementation of basou v0.69.0 (2026-10-16). The changes covered are v0.39.0 through v0.50.0 (2026-09-08 to 09-23). (v0.70.0 shipped the same day. Its change is to how session files are handled, and the output of the steps in this post did not change.)
Why it was writing to a shared file
Claude Code has a SessionStart hook. When a session starts, it runs a command of your choosing and adds that command's standard output to the session's context. basou's documentation told you to register basou orient on this hook. So Claude Code was already getting the position on the spot, at session start.
Codex had nothing equivalent at the time. The CHANGELOG for v0.30.0 (2026-07-01) says:
Codex exposes no SessionStart hook, so this static channel is the only vendor-neutral way a "where am I" reaches an interactive Codex
So on every basou refresh, basou wrote the position into a marker-delimited block inside ~/.codex/AGENTS.md. It looked like this (transcribed for illustration, not actual output):
<!-- BASOU:ORIENTATION:START -->
# Orientation
(the position of the workspace that last ran refresh)
<!-- BASOU:ORIENTATION:END -->
Claude Code got it on the spot; Codex got it written to a file and left there. The means were asymmetric because the agents' capabilities were asymmetric. It was not negligence; it followed from a constraint. So this is the story of moving the design once that constraint went away.
What was leaking
What goes into a position? The latest decisions and the reasons for them, open questions not yet settled, the paths of files that changed, the next step. That is exactly what basou assembles from the trail.
And it sat in ~/.codex/AGENTS.md for whichever workspace last ran refresh. If the next Codex you opened was in a different project, Codex would still read that file. The block stayed until another workspace ran refresh and overwrote it. In the words of the v0.39.0 CHANGELOG, "a leak in both directions": A's position would get into B's sessions, and B's into A's.
This was not hypothetical. The v0.39.0 commit says that on a real machine with several workspaces, "three different workspaces overwrote the same block within a few hours". Within a few hours, three workspaces had taken turns overwriting one and the same block.
And it was silent. The result of refresh --json said nothing about it either.
This is as far as the evidence goes: within a few hours the same block switched among the positions of three workspaces, and in the meantime a Codex opened in another project would read it. Whether a session was actually opened in another project, or whether anyone read it, I cannot tell. But on a machine that holds several jobs that must not mix, it should not have happened.
Move 1 — narrow when to write (v0.39.0)
The first fix turned whether to write into a per-workspace declaration. Only a workspace whose manifest declares channels.codex: true writes. The default is off. Declaring policies.confidential: true takes precedence over the opt-in and keeps it from writing. Whether it wrote or not now also shows up in the --json result.
With that, a workspace that declares nothing stopped writing. But the CHANGELOG itself names what stayed open.
The gate governs writing only; it cannot keep a block another workspace already rendered out of this one's Codex.
A condition can narrow only the writing side. A block written by a workspace that opted in would still be read by Codex in every project. Nothing had changed on the reading side. Move 1 ended with the addition of basou channel clear codex, a command you run by hand to remove a block already in place.
The next release came out about 18 hours later (09-08, 00:29 and 18:39 by release time).
Move 2 — remove the place it writes to (v0.40.0)
Computed at that moment, stored nowhere
By then, Codex had a SessionStart hook too. basou's CHANGELOG says it was verified end to end with codex-cli 0.153.4, in both the CLI and the desktop app.
In v0.40.0, basou stopped writing to ~/.codex/AGENTS.md. It also retired the opt-in declaration key added in move 1. Instead, basou hook install codex registers a SessionStart hook in ~/.codex/hooks.json, once.
The flow goes like this. When Codex starts a session, it calls the hook (basou hook session-start) and passes the session's cwd as JSON on standard input. basou resolves the workspace from the cwd and prints the same text as basou orient to standard output. Codex adds it to the context of that session.
The position is computed at that moment and stored nowhere.
One hook serves every workspace. Yet no workspace's position is written to a place where another workspace's sessions read it. With the shared file gone from the path that hands over the position, this path no longer carries A's position into B, or B's into A. (The CHANGELOG puts it, for the Codex face, as "Closed: the Codex face, in both directions".) And with no write left to gate, move 1's gate itself was no longer needed.
A block that an older version already wrote, however, does not go away when the writer stops. Upgrading alone leaves it in ~/.codex/AGENTS.md. basou hook install codex and basou hook status codex tell you if one is still there. What removes it is basou channel clear codex, added in move 1.
How the details were chosen
The entry basou hook install codex writes looks like this (registered under an isolated HOME; the path is elided):
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "node '…/@basou/cli/dist/index.js' hook session-start 2>/dev/null || true",
"timeout": 30,
"statusMessage": "basou orient",
"additionalContextLimit": 0
}
]
}
]
}
}
-
additionalContextLimit: 0: according to basou's CHANGELOG, Codex truncates hook output longer than about 2,500 tokens to its head and tail. A position keeps its open questions and its next step at the end. Truncate it, and the part you need most is the part that goes. -
compactis left out of the matcher: so the position (about 10 KB, by the CHANGELOG's estimate) is not re-injected every time the conversation is compacted. -
When it fails, it exits normally and prints nothing: in cases such as a payload it cannot read, or a
cwdthat is not a workspace, it ends without printing anything. The registered command itself is also wrapped in2>/dev/null || true. Not breaking the session comes first. -
Codex skips a hook until you have reviewed it once: according to basou's CHANGELOG, a new or changed hook does not run until the user trusts it. A non-interactive
codex execskips it silently.basou hook status codexchecks Codex's own records and reports whether the hook is trusted. Right after registration, it says:
Codex trust: not yet trusted by Codex (review pending — it is skipped until you trust it).
One more thing: a basou orient that a person types rewrites .basou/orientation.md, but the hook does not rewrite it when it hands over the position. The point is not to leave an orientation.md behind in a repository you only read.
The allowlist — closing the way in from someone else's repository
A new problem comes up here. The hook is user-global: Codex calls it from a session opened in any project. Left alone, basou would talk about the .basou/ of whatever repository you open.
For your own repositories, that is fine. The trouble is when you clone and open a repository where someone else committed .basou/. A code comment in basou puts it this way:
turning a checked-in "next step" into developer context the model trusts more than the repository's own AGENTS.md
This post did not test which of the two a model trusts more. But that a "next step" someone else wrote can get into the context at session start is something you can check yourself. The default .gitignore that basou init writes ignores the raw logs (events.jsonl), but not manifest.yaml or each session's session.yaml. A git add -A puts them in the commit. And the label in a session.yaml keeps the line that was recorded (its beginning, if the line is long). Run a plain basou orient in the clone, and the author's "next step" comes out (the steps are later in this post). Left alone, that is what would be handed to the session.
v0.40.0's answer is to talk only about workspaces that you registered yourself in ~/.basou/portfolio.yaml.
# ~/.basou/portfolio.yaml
version: 1
workspaces:
- path: ~/work/alpha
A repository's contents cannot rewrite this file. As far as I checked, basou init did not write anything on the home-directory side either (as I wrote in the local-first post, basou reads the configuration files under ~/.basou/ but does not write them). A cloned repository is not registered, so the hook stays silent.
This registry has one more use. That is move 3.
Move 3 — inspect what gets handed over (v0.41.0)
What move 2 settled was which session gets which workspace's position. What gets handed over had not been looked at yet.
A position is assembled from recorded paths and decisions, so it can contain another workspace's name. Say alpha's trail has a line like this (reproduced locally; $DEMO is a temporary directory):
- Next step (recorded, just now): next: check $DEMO/beta/README.md [session ses_01M6DJ6586]
The position handed to alpha's sessions contains where beta is. Names get in through an ordinary-looking line like that.
v0.41.0 started using the workspaces registered in portfolio.yaml as a list of names to check against as well. Commands that a person types, such as basou orient and basou refresh, check the position they printed, and if they find another workspace's name, they print a one-line advisory on stderr.
The advisory quotes neither the name it found nor the string that matched. All it gives is how many there were and which lines of which file. In the CHANGELOG's words,
so the warning cannot become the leak it is reporting.
And it blocks nothing. Sometimes a name is there on purpose, and blocking a command you type several times a day is not an option.
The hook is different. It goes silent. The spec says:
a hook has no reader for stderr and its stdout becomes the session's trusted context, so it withholds the position instead
If the hook printed an advisory on stderr, no one would read it. And its standard output becomes the session's context as is. So the hook does not hand over the position. It is the same silence as for an unregistered workspace. You find out why the next time you type basou refresh or basou orient.
For the same check result, it warns on the path a person reads and stays silent on the path no one reads. That looks asymmetric, but there is one reason behind it. A warning works only when someone is there to read it.
The check uses only paths and directory names. It does not use the display label (the product name). A product name shows up all over that workspace's own documents, so using it would make a warning that fires on every line. A warning that fires on every line is a warning no one reads. Note also that the block basou protocol sync writes into ~/.claude/CLAUDE.md goes through the same check.
One registry decides both whose position is handed over and which names are not. There are things the check cannot see; I list them at the end.
Claude Code takes the same exit (v0.50.0)
On the Claude Code side, the position had been handed over through a hook from the start. But registration was manual: basou's command reference told you to register basou orient on the SessionStart hook.
From v0.50.0, basou hook install registers basou hook session-start alongside the Stop hook. A basou orient registered by hand in the documented form is replaced in place. The replacement changes three things. It talks only about registered workspaces. It does not rewrite orientation.md. And it does not hand over a position that contains the name of another registered workspace.
Newly registered entries now use the same command, the same matcher and the same timeout on Codex and Claude Code. (When it replaces a hand registration, it keeps the original matcher.)
Also, on Claude Code the same hook records the git state at session start, so that edits made through the shell can be found later (v0.49.0; not covered in this post).
Try it yourself
You need neither Codex nor Claude Code. Of the JSON the hook receives, pipe in the smallest form yourself: one line with only cwd. Install basou with npm install -g @basou/[email protected]. To keep your real home directory out of it, each command swaps HOME for a temporary directory. git goes through a function, demo_git, which swaps HOME and XDG_CONFIG_HOME so that your own config files (commit signing and the like) are not read. (If you pass git configuration through environment variables such as GIT_CONFIG_GLOBAL, unset those before running this too.)
# Make a throwaway place (your real HOME is left alone)
DEMO=$(mktemp -d)
demo_git() { HOME="$DEMO/home" XDG_CONFIG_HOME="$DEMO/home/.config" git "$@"; } # git skips your config too
mkdir -p "$DEMO/home/.basou" "$DEMO/alpha" "$DEMO/beta"
for w in alpha beta; do
demo_git -C "$DEMO/$w" init -q
(cd "$DEMO/$w" && HOME="$DEMO/home" basou init >/dev/null)
done
# Register alpha only
cat > "$DEMO/home/.basou/portfolio.yaml" <<EOF
version: 1
workspaces:
- path: $DEMO/alpha
EOF
# Feed the hook the smallest payload it accepts: just the cwd, on stdin
start() { printf '{"cwd":"%s"}' "$1" | HOME="$DEMO/home" basou hook session-start; }
start "$DEMO/alpha" | head -5 # registered: the position comes out
start "$DEMO/beta" | wc -c # not registered: 0
The heredoc that writes portfolio.yaml is left unquoted on purpose, so that $DEMO expands. The output looks like this (captured on macOS; the whitespace before the wc -c number varies by environment):
# Orientation
> Generated at 2026-11-02T07:22:09.735Z · sessions 0 · newest (unknown) · pending 0 · suspect 0
## Where you are now
0
The registered alpha gets a position, and the unregistered beta gets nothing. Next, write beta's location into alpha's trail.
# Register beta too, and write beta's path into alpha's trail
echo " - path: $DEMO/beta" >> "$DEMO/home/.basou/portfolio.yaml"
printf 'next: check %s/README.md\n' "$DEMO/beta" |
(cd "$DEMO/alpha" && HOME="$DEMO/home" basou note >/dev/null)
start "$DEMO/alpha" | wc -c # 0: the hook stays silent
(cd "$DEMO/alpha" && HOME="$DEMO/home" basou orient >/dev/null) # a command you type says so on stderr
0
basou: this workspace's position names another registered workspace ($DEMO/alpha/.basou/orientation.md, lines 14, 29). The position is handed to the agent session running in this workspace, so those names travel with it. Advisory only: nothing was withheld.
The path in the advisory is shown here with $DEMO in its place (on your machine, it is the real path of the temporary directory). The line numbers depend on the length of that path: session labels are cut off partway, so with a long path, beta's name does not make it into the label. Notice also that the advisory does not print beta's name.
Last, the clone case.
# beta's author records a "next step" and commits it, .basou/ and all
printf 'next: run scripts/bootstrap.sh first\n' |
(cd "$DEMO/beta" && HOME="$DEMO/home" basou note >/dev/null)
demo_git -C "$DEMO/beta" add -A
demo_git -C "$DEMO/beta" -c user.name=demo -c user.email=[email protected] commit -qm "add trail"
# Clone it and open the clone (the clone is not in portfolio.yaml)
demo_git clone -q "$DEMO/beta" "$DEMO/beta-clone"
start "$DEMO/beta-clone" | wc -c # 0: not registered, so silent
(cd "$DEMO/beta-clone" && HOME="$DEMO/home" basou orient | grep 'bootstrap') # plain orient shows it
0
- Last session: Ad-hoc note: next: run scripts/bootstrap.sh first (completed) [ses_01M6DJ65QB]
- Ad-hoc note: next: run scripts/bootstrap.sh first (just now)
In the clone, the hook stays silent, while a plain basou orient shows the line the author left. The session ID changes on every run. For the full set of ways to register the hook and their options, see basou's command reference.
What is still open
Which paths were closed, and how far? Here is what is still open.
-
The name check covers only how paths and directory names are spelled. It does not see product names, client names, or references written in other words. It also does not look at the parts of
~/.claude/CLAUDE.mdoutside the blocks basou manages (the text you wrote yourself). -
Going silent means the session starts without a position. When it goes silent over another workspace's name, you do not learn why until you next type
refreshororient. When it goes silent because the workspace is not registered, it does not even give a reason. -
It says nothing until you register. A workspace that has only been through
basou initstays silent. You writeportfolio.yamlyourself. -
Codex's trust step is required. According to basou's CHANGELOG, a new or changed hook is skipped until you review it once. A non-interactive
codex execskips it silently. -
With the default registration, it is not re-injected after compaction. That is what leaving
compactout of the matcher cost. (A replaced hand registration keeps its original matcher, so a hand registration with no matcher still runs after compaction.) -
With the default registration, a position that changes mid-conversation does not reach the session. The hook that hands over the position (
basou hook session-start) runs, with the default registration, only at start, resume and clear; it does not re-inject while a conversation is going on. (Rule documents updated byprotocol syncare delivered to sessions already running on Claude Code through the Stop hook; v0.46.0.) -
Blocks written by older versions remain. Upgrading alone does not remove them. Remove them with
basou channel clear codex. -
protocol syncdoes not stop. If the block it writes into~/.claude/CLAUDE.mdcontains another workspace's name, it only warns, and writes the block anyway.
Wrap-up
The question is not under what condition to write, but where. A place that cannot choose its readers is no place for something that needs to choose them.
Move 1 narrowed the condition for writing, and what had been written stayed. Move 2 removed the place to write to: the position is computed on the spot and handed only to that session. Move 3 inspected what gets handed over: it warns on the path a person reads and stays silent on the path no one reads.
Is there anything about one particular project in your own ~/.claude/CLAUDE.md or ~/.codex/AGENTS.md? It might be worth opening them once and taking a look.
basou is available at basou.dev. The details around hooks are in the command reference.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.