課題:出力先のカオス化
AI は速く動く。速く動くほど、成果物が散らかる。「出力先どこにする?」と聞かれれば AI は何かしら答える。問題は、その「何かしら」が /tmp、~/Desktop、プロジェクト直下、場合によっては ./output/output/ のような二重ディレクトリだったりすることだ。パイプラインが複雑になるほど、誰も指定しなければ成果物は分散していく。
八雲の動画制作パイプライン(Montage)は複数のエージェントがリレー形式で成果物を引き渡す。Claude Code で自律実行させると、3 つの典型的な詰まりパターンに直面した。
/tmp 汚染
エージェントが中間ファイルを /tmp 以下に書く。OS 再起動で消える。パイプラインの途中から再開しようとしても、前ステップの出力がない。ディスク節約を意図した合理的な判断だが、パイプラインの連続性を破壊する。
プロジェクト直下への散乱
指示が曖昧なとき、AI はカレントディレクトリ直下に書く。input.json、analysis.json がプロジェクトルートに転がり、コミット事故が発生しやすくなった。パスの不一致
あるエージェントが output/{topic-id}/final.json に書き、次が outputs/{topic-id}/final.json を読もうとして失敗する。output と outputs の 1 文字差で詰まる。パスが 2 箇所以上に定義されていれば、必ずいつか乖離する。
根本原因は一つだ。成果物のパスが SSOT(Single Source of Truth)として管理されておらず、各エージェントが独自に決定している。
アプローチ:paths SSOT を 1 ファイルに集約
解決策として導入したのが src/config/paths.ts だ。全アウトプットのパスをここで定義し、パイプライン全体がこのファイルを参照する。
// src/config/paths.ts より抜粋
export const MONTAGE_ROOT =
process.env.MONTAGE_ROOT ?? path.join(homedir(), "Desktop", "montage");
export const topicDir = (channelId: string, topicId: string) =>
path.join(MONTAGE_ROOT, channelId, topicId);
export const tempDir = (sub?: string) => path.join(MONTAGE_ROOT, "_temp", sub ?? "");
export const cacheDir = (sub?: string) => path.join(MONTAGE_ROOT, "_cache", sub ?? "");
topicDir("yakumo-tech", "2026-05-001") と呼べば正しいパスが返る。呼び出し側は「どこに書くか」を考える必要がなくなる。CLAUDE.md でこの SSOT を明示することで、エージェントは「パスを自分で考えず src/config/paths.ts を参照する」という動作を安定して取るようになった。
ディレクトリ規則:_ プレフィックスで意味を持たせる
パスの定義と合わせて _ プレフィックス規則を導入した。
~/Desktop/montage/
├── {channelId}/{topic-id}/ ← 永続成果物(保護)
├── _temp/ ← 一時作業領域
├── _cache/ ← 再生成可能なキャッシュ
└── _archive/{YYYY-MM}/ ← 過去散乱物の退避先
_ プレフィックスなし = 保護対象。_ 付き = 一時・キャッシュ・作業領域。 ファイルブラウザで保護対象と一時領域が視覚的に分かれるため、人間オペレーターの判断コストが下がる。エージェントもキャッシュの置き場所に迷わなくなり、/tmp に手を出すケースが削減された。さらに rm -rf ~/Desktop/montage/_* で一括掃除できるため、メンテナンスの手軽さも向上した。
ガードレール:PreToolUse hook でブロック
設計だけでは不十分だ。AI は指示された規則を忘れることがある。特に長いコンテキストの後半では、CLAUDE.md に書いたルールが薄まりやすい。
これに対処するため、pre-no-tmp.sh という PreToolUse hook を導入した。Write ツールが発火する直前に実行され、パイプライン成果物にあたるファイルが /tmp 以下に書かれようとしたときブロックする。
# pre-no-tmp.sh より抜粋
file_path=$(jq -r '.tool_input.file_path // empty' 2>/dev/null)
case "$file_path" in /tmp/*) ;; *) exit 0 ;; esac
blocked=""
case "$file_path" in
*/input.json|*/final.json) blocked="pipeline artifact" ;;
*.mp4) blocked="rendered video" ;;
*-thumb.png) blocked="thumbnail" ;;
esac
[ -z "$blocked" ] && exit 0
echo "Blocked: $blocked must go under ~/Desktop/montage/{channelId}/{topicId}/" >&2
exit 2
ここで重要なのは「ブロックするだけでなく、正しいパスを stderr で示す」設計だ。AI がブロックされたとき、次の手が分かる状態にしないと、エージェントが詰まって処理が止まる。エラーメッセージに「置き先はどこか」を明示することで、リカバリーが自動化される。
運用してわかった効果と落とし穴
効果。 /tmp への成果物書き込みが事実上ゼロになった。「あのファイルはどこ?」の答えが ~/Desktop/montage/{channelId}/{topicId}/ に一本化され、掃除コマンドが rm -rf ~/Desktop/montage/_* 一発で済む。MONTAGE_ROOT 環境変数でルートを差し替えられるため、CI でも MONTAGE_ROOT=/tmp/montage-test と向けるだけで本番ディレクトリを汚染しない。
落とし穴。 hook のブロック条件が広すぎると、テスト用フィクスチャまで止まる false positive が出た。*.json を全部ブロックすればクリーンになるが実用にならない。条件は「止めたい操作を十分に特定できる最小パターン」に絞るのが鉄則だ。保護と開発速度のトレードオフ、および誤検知のリスクは、条件を慎重に設定することで初めて両立する。
もう一点。paths.ts を SSOT にしたつもりでも、Python・Bash スクリプトが別のパス定義を持つケースが出た。言語ごとに paths.ts / paths.py / paths.sh を用意し、いずれも MONTAGE_ROOT 環境変数を読む設計が必要だった。
# paths.sh の例
MONTAGE_ROOT="${MONTAGE_ROOT:-$HOME/Desktop/montage}"
TOPIC_DIR() { echo "$MONTAGE_ROOT/$1/$2"; }
TEMP_DIR() { echo "$MONTAGE_ROOT/_temp/${1:-}"; }
CACHE_DIR() { echo "$MONTAGE_ROOT/_cache/${1:-}"; }
「単一言語のプロジェクトなら SSOT は簡単」という思い込みが落とし穴だった。言語をまたいだ SSOT は単一言語のプロジェクトより一段難しく、各言語での実装コスト・メンテナンス負荷・同期の手間が増える。
まとめ
AI が成果物を散らかさないための設計は、「指示する」より「散らかせない構造を作る」ほうが実効性が高い。
paths SSOT で「どこに書くか」を一元化し、_ プレフィックス規則で「安全に消せるものを明示」し、hook で「違反を事前ブロック」する 3 層の設計が、八雲での運用をシンプルに保てている理由だ。
AI エージェントによる自動実行が増えるほど、成果物の所在地不明は開発体験を損なう。スケール前のいま、パスの SSOT 化を習慣づけておくことをお勧めする。