🔗 この記事は https://blog.tak3.jp/ja/blog/basou-session-start-position/ からの転載です(一次情報源)。
AI のコーディングエージェントには、どのプロジェクトで起動しても自動で読むファイルがある。既定の設定では、Codex なら ~/.codex/AGENTS.md、Claude Code なら ~/.claude/CLAUDE.md。リポジトリに置く AGENTS.md や CLAUDE.md とは別に、ホームディレクトリに1枚あって、そのマシンで開くどのプロジェクトのセッションもそれを読む(リポジトリ側の指示ファイルの話は別の記事に書いた)。
便利な仕組みだ。全プロジェクトに共通する作法を1か所に書ける。では、そこに特定のプロジェクトの話を書いたら何が起きるか。
筆者が作っている basou には basou orient というコマンドがある。記録から「いまどこにいて、次に何をするか」を組み立てて返す(紹介記事)。この「現在地」をエージェントのセッション開始時に渡せれば、毎回説明し直さずに済む。basou は Codex に対して、それを ~/.codex/AGENTS.md に書いて渡していた。
local-first の記事では、basou refresh が毎回 ~/.codex/AGENTS.md を更新することについて「ここは注意がいる」と書き、それは「オプションではなく既定」だとした(v0.37.0 時点)。本稿は、その既定がその後どうなったかの記録だ。書く条件を絞り、書く場所そのものを無くし、渡す中身を検査した。3手かかった。
以下の記述は basou v0.69.0(2026-10-16) の実装に基づく。扱う変更は v0.39.0〜v0.50.0(2026-09-08〜09-23)。(同じ日に v0.70.0 が出ている。変更は session ファイルの扱いで、本稿の手順の出力は変わらなかった。)
なぜ共有ファイルに書いていたのか
Claude Code には SessionStart フックがある。セッションが始まるときに任意のコマンドを走らせ、その標準出力をセッションの文脈に足す仕組みだ。basou のドキュメントは、このフックに basou orient を登録するよう案内していた。Claude Code には、開始時にその場で渡していたことになる。
Codex には、当時これに当たるものが無かった。v0.30.0(2026-07-01)の CHANGELOG はこう書いている。
Codex exposes no SessionStart hook, so this static channel is the only vendor-neutral way a "where am I" reaches an interactive Codex
だから basou refresh のたびに、現在地を ~/.codex/AGENTS.md の中の、marker で囲んだブロックに書き込んでいた。形はこうだ(説明のために書き起こしたもので、実際の出力そのものではない)。
<!-- BASOU:ORIENTATION:START -->
# Orientation
(最後に refresh したワークスペースの現在地)
<!-- BASOU:ORIENTATION:END -->
Claude Code にはその場で渡し、Codex にはファイルに書いて置いておく。手段が非対称だったのは、エージェント側の機能が非対称だったからだ。怠慢ではなく、制約の帰結だった。だからこれは、制約が外れたときに設計を移した話になる。
何が漏れていたか
現在地には何が入っているか。最新の判断とその理由、まだ決まっていない論点、変更したファイルのパス、次の一手。basou が記録から組み立てるのは、まさにそういうものだ。
それが、最後に refresh したワークスペースの分だけ ~/.codex/AGENTS.md に置かれる。次に Codex を開いたのが別のプロジェクトでも、Codex はそのファイルを読む。別のワークスペースが refresh して上書きするまで、そのブロックは残る。v0.39.0 の CHANGELOG の言葉では "a leak in both directions" だ。A の現在地が B のセッションに入り、B の現在地が A のセッションに入る。
想定の話ではなかった。v0.39.0 の commit には、複数のワークスペースを持つ実機で "three different workspaces overwrote the same block within a few hours" とある。数時間のうちに、3つのワークスペースが同じ1つのブロックを上書きし合っていた。
そして無音だった。refresh --json の結果にも、そのことは何も出ていなかった。
ここで言えるのは、同じブロックが数時間のうちに3つのワークスペースの現在地に入れ替わり、その間に別のプロジェクトで開いた Codex はそれを読む状態だった、までだ。実際に別のプロジェクトのセッションが開かれたか、それを誰かが読んだかは分からない。ただ、混ざってはいけない仕事を複数抱えるマシンでは、起きてはいけないことだった。
1手目 — 書く条件を絞る(v0.39.0)
最初の対処は、書くかどうかをワークスペースごとの宣言にすることだった。manifest で channels.codex: true と宣言したワークスペースだけが書く。既定は off。policies.confidential: true を宣言すれば、opt-in より優先して書かない。書いたのか書かなかったのかは、--json の結果にも出るようにした。
これで、何も宣言していないワークスペースは書かなくなった。だが、閉じなかったものを CHANGELOG 自身が書いている。
The gate governs writing only; it cannot keep a block another workspace already rendered out of this one's Codex.
条件で絞れるのは、書く側だけだ。opt-in したワークスペースが書いたブロックは、相変わらず全プロジェクトの Codex に読まれる。読む側は何も変わっていない。すでに置かれたブロックを手で消す basou channel clear codex を足したところまでが、1手目だった。
次の版は、約18時間後に出た(release の時刻で 09-08 の 00:29 と 18:39)。
2手目 — 書く場所を無くす(v0.40.0)
その場で計算して、どこにも保存しない
その時点で、Codex にも SessionStart フックがあった。basou の CHANGELOG には、codex-cli 0.153.4 の CLI とデスクトップアプリで end-to-end に確認したとある。
v0.40.0 で、basou は ~/.codex/AGENTS.md への書き込みをやめた。1手目で足した opt-in の宣言キーも退役させた。代わりに basou hook install codex が、~/.codex/hooks.json に SessionStart フックを1回だけ登録する。
流れはこうだ。Codex はセッションを始めるときにフック(basou hook session-start)を呼び、そのセッションの cwd を JSON で標準入力に渡す。basou は cwd からワークスペースを解決し、basou orient と同じテキストを標準出力に出す。Codex はそれを、そのセッションの文脈に足す。
The position is computed at that moment and stored nowhere.
1つのフックが全ワークスペースに仕える。それでいて、どのワークスペースの現在地も、他のワークスペースのセッションが読む場所には書かれない。現在地を渡す経路から共有ファイルが無くなったので、この経路で A の現在地が B に入ることも、B の現在地が A に入ることもなくなった(CHANGELOG はこれを、Codex の面について "Closed: the Codex face, in both directions" と書いている)。条件で絞るべき書き込みも無いので、1手目のゲートは、ゲートごと要らなくなった。
ただし、古い版がすでに書いたブロックは、書き手をやめても消えない。アップグレードしただけでは ~/.codex/AGENTS.md に残る。basou hook install codex と basou hook status codex は、残っていればそう知らせる。消すのは、1手目で足した basou channel clear codex だ。
細部の選び方
basou hook install codex が書くエントリはこうだ(隔離した HOME で登録したもの。パスは省略した)。
{
"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: basou の CHANGELOG によれば、Codex は約 2,500 トークンを超えるフック出力を、頭と尻だけに切り詰める。現在地は、未決の論点と次の一手が末尾にある。切り詰められると、いちばん要るところが消える。 -
matcher から
compactを外している: 会話を圧縮するたびに現在地(CHANGELOG の見積もりで約 10 KB)を入れ直さないためだ。 -
失敗しても黙って正常終了する: payload が読めない、
cwdがワークスペースではない、といったときは何も出さずに終わる。登録されるコマンド自体も2>/dev/null || trueで包まれている。フックがセッションを壊さないことが優先だ。 -
Codex はフックを一度レビューするまで飛ばす: basou の CHANGELOG によれば、新しいフックや変わったフックは、利用者が信頼するまで実行されない。非対話の
codex execでは黙って飛ばされる。basou hook status codexは Codex 側の記録と照合して、信頼済みかどうかを報告する。登録した直後はこう出る。
Codex trust: not yet trusted by Codex (review pending — it is skipped until you trust it).
もう一つ、人が打つ basou orient は .basou/orientation.md を書き換えるが、フックは現在地を渡すときにこれを書き換えない。読んだだけのリポジトリに orientation.md を残さないためだ。
許可リスト — 他人のリポジトリから入ってくる経路を閉じる
ここで新しい問題が出る。フックはユーザーグローバルだ。Codex は、どのプロジェクトで開いたセッションでもそれを呼ぶ。何もしなければ、basou は利用者が開いたどのリポジトリの .basou/ についても喋る。
自分のリポジトリなら、それでかまわない。問題は、他人が .basou/ を commit したリポジトリを clone して開いたときだ。basou のコードコメントはこう書いている。
turning a checked-in "next step" into developer context the model trusts more than the repository's own AGENTS.md
モデルがどちらをより信頼するかを、本稿で確かめたわけではない。だが、他人が書いた「次の一手」がセッション開始時の文脈に入りうること自体は、手元で確かめられる。basou init が書く既定の .gitignore は、生ログ(events.jsonl)は無視するが、manifest.yaml と各セッションの session.yaml は無視しない。git add -A すれば commit に入る。そして session.yaml のラベルには、記録した一行(長ければその先頭)が残る。clone 先で素の basou orient を打つと、作者が残した「次の一手」が出てくる(手順は後半に載せた)。何もしなければ、それがそのままセッションに渡る。
v0.40.0 の答えは、~/.basou/portfolio.yaml に利用者が自分で登録したワークスペースについてだけ喋る、というものだ。
# ~/.basou/portfolio.yaml
version: 1
workspaces:
- path: ~/work/alpha
このファイルは、リポジトリの中身からは書き換えられない。手元で確かめた範囲では、basou init もホームディレクトリ側には何も書かなかった(local-first の記事に書いたとおり、basou は ~/.basou/ 配下の設定ファイルは読むだけだ)。clone したリポジトリは登録されていないので、フックは黙る。
この登録簿には、もう一つ使い道がある。それが3手目だ。
3手目 — 渡す中身を検査する(v0.41.0)
2手目で決めたのは、どのセッションに、どのワークスペースの現在地を渡すかだった。渡す中身は、まだ見ていなかった。
現在地は、記録されたパスや判断から組み立てられる。だから、他のワークスペースの名前を含みうる。たとえば alpha の記録に、こんな一行があったとする(手元で再現したもの。$DEMO は一時ディレクトリ)。
- Next step (recorded, just now): next: $DEMO/beta/README.md を確認する [session ses_01M526EM72]
alpha のセッションに渡す現在地に、beta の場所が入っている。名前は、こういう何気ない一行から入る。
v0.41.0 は、portfolio.yaml に登録されたワークスペースを、照合する名前の一覧としても使うようにした。人が打つコマンド(basou orient や basou refresh)は、出力した現在地を照合し、他のワークスペースの名前が見つかれば stderr に1行の助言を出す。
助言は、見つけた名前も、一致した文字列も引用しない。出すのは、何件あったかと、どのファイルの何行目か、だけだ。CHANGELOG の言葉では、
so the warning cannot become the leak it is reporting.
そして、何も止めない。名前が入っているのが意図どおりのこともあるし、1日に何度も打つコマンドを止めるわけにはいかない。
フックは違う。黙る。spec はこう書いている。
a hook has no reader for stderr and its stdout becomes the session's trusted context, so it withholds the position instead
フックが stderr に助言を出しても、それを読む人はいない。そして標準出力は、そのままセッションの文脈になる。だからフックは現在地を渡さない。未登録のワークスペースと同じ沈黙だ。理由が分かるのは、次に人が basou refresh か basou orient を打ったときになる。
同じ検査の結果に対して、人が読む経路では警告し、誰も読まない経路では黙る。非対称に見えるが、理由は一つだ。警告は、読む人がいて初めて機能する。
照合に使うのは、パスとディレクトリ名だけだ。表示用のラベル(製品名)は使わない。製品名はそのワークスペース自身の文書のあちこちに出てくるので、使えば毎行で鳴る警告になる。毎行で鳴る警告は、誰も読まない。なお、basou protocol sync が ~/.claude/CLAUDE.md に書くブロックも、同じ照合にかかる。
1枚の登録簿が、誰の現在地を渡すかと、どの名前を渡さないかの両方を決めている。照合で見えないものもあるが、それは最後にまとめる。
Claude Code 側も同じ出口へ(v0.50.0)
Claude Code 側は、もともとフックで渡していた。ただし登録は手作業で、basou のコマンドリファレンスは「SessionStart フックに basou orient を登録する」よう案内していた。
v0.50.0 から、basou hook install が Stop フックと並べて basou hook session-start を登録する。案内どおりの形で手登録された basou orient は、その場で置き換える。置き換えで変わるのは3点だ。登録したワークスペースについてだけ喋る。orientation.md を書き換えない。登録された他のワークスペースの名前を含む現在地は渡さない。
新しく登録するエントリは、Codex と Claude Code で同じコマンド、同じ matcher、同じ timeout になった(手登録を置き換えるときは、元の matcher を引き継ぐ)。
なお Claude Code では、同じフックがセッション開始時の git の状態も記録し、シェル経由の編集を後から見つけられるようにしている(v0.49.0・本稿では扱わない)。
手元で試す
Codex も Claude Code も要らない。フックが受け取る JSON のうち、cwd だけを持つ最小の形を、自分で1行パイプすればいい。npm install -g @basou/cli@0.69.0 で basou を入れておく。本物のホームディレクトリには触れないように、HOME をコマンドごとに一時ディレクトリへ差し替える。git は関数 demo_git にまとめ、HOME と XDG_CONFIG_HOME を差し替えて、利用者の設定ファイル(commit の署名など)を読まないようにする(GIT_CONFIG_GLOBAL などの環境変数で git の設定を渡している場合は、それも外して実行してほしい)。
# 使い捨ての場所を作る(本物の HOME には触れない)
DEMO=$(mktemp -d)
demo_git() { HOME="$DEMO/home" XDG_CONFIG_HOME="$DEMO/home/.config" git "$@"; } # git も利用者の設定を読まない
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
# alpha だけを登録する
cat > "$DEMO/home/.basou/portfolio.yaml" <<EOF
version: 1
workspaces:
- path: $DEMO/alpha
EOF
# フックが受け取る JSON のうち、cwd だけの最小の形を標準入力で渡す
start() { printf '{"cwd":"%s"}' "$1" | HOME="$DEMO/home" basou hook session-start; }
start "$DEMO/alpha" | head -5 # 登録済み: ポジションが出る
start "$DEMO/beta" | wc -c # 未登録: 0
portfolio.yaml を書く heredoc は、$DEMO を展開させるために、わざとクオートしていない。出力はこうなる(macOS で採ったもの。wc -c の数字の前の空白は環境で変わる)。
# Orientation
> Generated at 2026-10-16T11:09:43.703Z · sessions 0 · newest (unknown) · pending 0 · suspect 0
## Where you are now
0
登録した alpha には現在地が出て、登録していない beta には何も出ない。次に、alpha の記録に beta の場所を書き込む。
# beta も登録し、alpha の記録に beta のパスを書き込む
echo " - path: $DEMO/beta" >> "$DEMO/home/.basou/portfolio.yaml"
printf 'next: %s/README.md を確認する\n' "$DEMO/beta" |
(cd "$DEMO/alpha" && HOME="$DEMO/home" basou note >/dev/null)
start "$DEMO/alpha" | wc -c # 0: フックは黙る
(cd "$DEMO/alpha" && HOME="$DEMO/home" basou orient >/dev/null) # 人が打つコマンドは stderr で知らせる
0
basou: this workspace's position names another registered workspace ($DEMO/alpha/.basou/orientation.md, lines 7, 13, 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.
助言の中のパスは、ここでは $DEMO に置き換えて載せた(手元では一時ディレクトリの実パスが出る)。行番号はこのパスの長さで変わる。セッションのラベルは途中で切り詰められるので、パスが長いと beta の名前がラベルまで届かないからだ。助言が beta の名前を出していないことにも注目してほしい。
最後に、clone の場合を見る。
# beta の作者が「次の一手」を記録して、.basou/ ごと commit した
printf 'next: まず scripts/bootstrap.sh を実行する\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=demo@example.invalid commit -qm "add trail"
# それを clone して開く(clone は portfolio.yaml に登録していない)
demo_git clone -q "$DEMO/beta" "$DEMO/beta-clone"
start "$DEMO/beta-clone" | wc -c # 0: 未登録なので黙る
(cd "$DEMO/beta-clone" && HOME="$DEMO/home" basou orient | grep 'bootstrap') # 素の orient には出てくる
0
- Last session: Ad-hoc note: next: まず scripts/bootstrap.sh を実行する (completed) [ses_01M526EMQ9]
- Ad-hoc note: next: まず scripts/bootstrap.sh を実行する (just now)
clone ではフックは黙り、素の basou orient には作者の残した一行が出る。session の ID は実行するたびに変わる。フックの登録方法やオプションの全体は、basou のコマンドリファレンスにある。
閉じていないもの
どの経路を、どこまで閉じたか。閉じていないものを並べておく。
-
名前の照合は、パスとディレクトリ名の綴りだけ。製品名、クライアント名、言い換えで書かれた参照は見えない。
~/.claude/CLAUDE.mdのうち basou が管理するブロックの外(自分で書いた文章)も見ない。 -
黙ると、現在地なしでセッションが始まる。他のワークスペースの名前で黙った場合、理由は次に
refreshかorientを打つまで分からない。未登録で黙った場合は、理由も出ない。 -
登録しないと喋らない。
basou initしただけのワークスペースは黙る。portfolio.yamlは自分で書く。 -
Codex の信頼手続きが要る。basou の CHANGELOG によれば、新しいフックや変わったフックは、一度レビューするまで飛ばされる。非対話の
codex execでは黙って飛ばされる。 -
既定の登録では、圧縮の後は入れ直さない。matcher から
compactを外した代わりに失ったものだ(手登録を置き換えた場合は元の matcher を引き継ぐので、matcher の無い手登録なら圧縮の後にも走る)。 -
既定の登録では、会話の途中で変わった現在地は届かない。現在地を渡すフック(
basou hook session-start)が走るのは、既定の登録では開始・再開・clear のときだけで、会話を続けている途中では入れ直さない(protocol syncで更新したルール文書は、Claude Code では Stop フックで途中のセッションにも届けるようにした。v0.46.0)。 -
古い版が書いたブロックは残る。アップグレードしただけでは消えない。
basou channel clear codexで消す。 -
protocol syncは止めない。~/.claude/CLAUDE.mdに書くブロックが他のワークスペースの名前を含んでいても、警告するだけで書き込む。
まとめ
書く条件ではなく、書く場所を問う。読み手を選べない場所には、読み手を選ぶものを書かない。
1手目は書く条件を絞り、書いたものが残った。2手目は書く場所そのものを無くし、その場で計算してそのセッションにだけ渡すようにした。3手目は渡す中身を検査し、人が読む経路では警告し、誰も読まない経路では黙るようにした。
自分の ~/.claude/CLAUDE.md や ~/.codex/AGENTS.md に、特定のプロジェクトの話を書いていないか。一度、開いて見てみてもいいかもしれない。
basou は basou.dev で公開している。フックまわりの詳細はコマンドリファレンスにある。