結果を決めるのはモデルではなく周囲だ
同じモデルを使っていても、あるリポジトリではエージェントが git stash で他人の編集を消し、あるリポジトリではそんなことは起きない。差はモデルではなく、モデルを取り囲むもの、つまり ハーネス(harness) にある。ルールをどこに書くか、どの命令を止めるか、コミットがどの関門を通るか、セッションが終わっても何が残るか。
この記事は LabHub リポジトリのハーネスをファイル単位で分解した記録だ。このリポジトリでは 2026 年 8 月 20 日の最初のコミット以来、三週間にわたり AI エージェントが人と一緒に働き、その結果が数字として残っている。
| 項目 | 値 | 出典 |
|---|---|---|
| 全コミット | 1,491 | git rev-list --count origin/main |
| AI 共同著作コミット | 497(Opus 354 · Fable 136 · Codex 7) | コミット本文の Co-Authored-By |
| 本番デプロイコミット | 350(開発系 295) | deploy(prod): 接頭辞 |
| 直近 7 日のコミット(デプロイ除く) | 377 | 9 月 4 日〜11 日 |
| 単体試験の数 | 1,697 → 2,346 | 9 月 7 日 10:47 → 9 月 11 日 09:06 のゲートログ |
| この Mac で回ったゲート | 161 回 | 9 月 7 日以降のゲートログファイル数 |
最後の行がこの記事の主題だ。この作業セッションのログで数えると、push 成功 127 回、ゲートが止めたもの 41 回、origin が先に進んでコミットを載せ直したもの 23 回。ハーネスは文書ではなく、一日に何十回も回る機械だ。
原則その一:ルールは一か所に、装置は別に
ハーネス全体は 2026 年 9 月 7 日のコミット一つ(83821fa0、35 ファイル、1,746 行)で入った。コミットメッセージがなぜ作ったかを語る。
ルールは AGENTS.md の一か所にあったが、それを守る装置はセッションごとに
スクラッチパッドで作り直していた(push.sh・gate.sh・bumpdigest.py)。
セッションが終われば消え、次のセッションは同じ事故をもう一度経験してから
同じ道具をまた作っていた。
だから構造の第一原則は ルールの原本は一つ ということだ。ルールは AGENTS.md(707 行)の一か所にあり、Claude Code が読む CLAUDE.md はそのファイルを指すポインタだ。CLAUDE.md の文をそのまま写す。
同じ内容を二か所に分けて書いておくと、必ず片方だけが古いまま残るからです。
.claude/README.md は .claude/ の下に何を置くかをこう定める。
ルールの原本は AGENTS.md だ。ここには Claude Code だけが読む装置を置く —
ルールを書き直すのではなく、ルールが守られるように自動で割り込むものだ。
五つの層と、あと二つ
README はハーネスを五つの層に分ける。実際のファイル数を数えて添えた。
| 層 | 何 | ファイル | 役割 |
|---|---|---|---|
| 恒久的な指針 | AGENTS.md、CLAUDE.md | 2 | すべてのセッションが最初に読むルールと事故記録 |
| パス規則 | .claude/rules/*.md | 6 | 特定のパスに触れるときだけ付く指針 |
| スキル | .claude/skills/*/SKILL.md | 5 | 三回以上繰り返した手順 |
| フック | .claude/hooks/*.py | 2 | ツール呼び出しの前後に割り込む検査 |
| サブエージェント | .claude/agents/*.md | 3 | 読むだけの調査・レビュー役 |
| スラッシュコマンド | .claude/commands/*.md | 4 | スキルへ入る薄い入口 |
| プラグイン | settings.json の enabledPlugins | 13 | 公式マーケットプレイスの道具 |
何をどの層に入れるかの基準も README に書いてある。フックについての文が最も明確だ。
フックに入れる基準は一つだ — 実際に起きて、目では気づきにくかったもの。
一般的なベストプラクティスは入れない。警告がありふれると誰も読まない。
スキルについては「三回以上繰り返した手順」を入れるが、「順序が狂うと事故になる部分は本文ではなく scripts/agent/ のスクリプトに出す」という。プラグイン 13 個のうち commit-commands はわざと外した。/commit が作業ツリーで git commit を打つが、このリポジトリではそれが禁止だからだ。
パス規則:そのファイルを開くときだけ現れる指針
rules/ の 6 ファイルはそれぞれ paths: を持ち、そのパスに触れるときだけ文脈に入る。ルール全体を毎回読ませれば誰も読まない、という同じ原理だ。
app-js.md—backend/static/app.jsはファイル全体の SHA-256 がセキュリティ検査スクリプトに埋め込まれていて、「一文字違うだけでデプロイが止まり、止まる場所のメッセージは見当違いだ(streamSSE 関連の文言が出る)」。pipeline.md— 「push すると Jenkins が毎分ポーリングして承認なしに本番まで上げる(約 20 分)。デプロイは GitOps でのみ。」migrations.md— 「すでに適用されたマイグレーションは絶対に直さない。」generated.md— 手で直すと次の生成で消えるファイルと、その生成器の表。curriculum.md— 「採点器は双方向で試験する — 正解が通ることだけ見るのは半分だ。」tests.md— 「試験のドキュメント文字列に、なぜこの試験があるのか、どんな事故があったのかを書く。」
試験がこれらの規則を監視する。paths: が実在するファイルに一致するか、本文が AGENTS.md や scripts/ を指し返しているかを tests/test_claude_harness.py が確認する。
フック:止めるもの四つ、尋ねるもの二つ
hooks/guard.py はツールが実行される前に標準入力で呼び出し内容を受け取り、標準出力で deny か ask を返す。中にあるのはちょうど四つで、それぞれ実際の事故から来ている。
GIT_STATE = re.compile(
r"\bgit\s+(?:stash|checkout|switch|restore|reset|rebase|merge|pull|clean)\b")
GIT_OK = re.compile(r"\bgit\s+(?:worktree|stash\s+list)\b")
KUBECTL_WRITE = re.compile(
r"\bkubectl\b[^|;&]*\s(?:apply|delete|patch|scale|edit|replace|annotate|label|cordon|drain)\b")
LOCKED = "backend/static/app.js"
- 作業ツリーを変える git 命令は deny。コメントに理由がある:「
git stash一回が他の四人の編集を戻した。」 - クラスタの状態を変える kubectl は deny。読み取りと一時的なポッドだけ許す。
push.shを経ないgit pushは ask。メッセージに根拠を付ける:「今日だけでそのゲートが CI の失敗を四回止めた。」app.jsの編集は ask。ダイジェスト更新を忘れるとデプロイが静かに止まるからだ。
hooks/after_edit.py はファイルを直した直後、そのファイル一つだけを見る。Python は ast.parse、JavaScript は node --check、JSON は json.loads。全試験は 2 分を超えるので編集ごとには回せず、「今このファイルが単独で成立するか」までを捉え、残りはゲートに任せる。
フックの危険は README の一文にある:「フックは静かに死ぬ — 直した後に試験を回さなければ、止めようとした事故がそのまま起きる。」だから tests/test_claude_hooks.py の 13 試験がフックに JSON を直接食わせて deny・ask・通過を確認する。
サブエージェント:読むだけの役割
agents/ の三つの役割はどれも書き込みツールを持たない。
| 名前 | ツール | 役割 | しないこと |
|---|---|---|---|
| safe-researcher | Read, Grep, Glob, Bash | コード・文書・クラスタを読んで要約 | Edit/Write、git 状態変更、kubectl 書き込み |
| grader-reviewer | Read, Grep, Glob, Bash | 採点器を双方向でレビュー | 採点器を直さない、判定と反例だけ |
| deploy-watcher | Read, Bash | push 後のビルド・本番反映を見守る | kubectl 書き込み、再試行 |
grader-reviewer の定義ファイルにある文が、この役割がなぜあるかを語る:「『無条件で通る採点器』が実際に複数あり、それは無いより悪い。」この記事の .claude/ 調査も safe-researcher に任せた。親の文脈を節約し、調査中にうっかり何かを直す道をなくす。
試験はこれも強制する。エージェントが三つ以上か、tools に Edit・Write・NotebookEdit が無いか、説明に「しない」の類の否定表現があるか。
手順はスクリプトへ:push.sh と gate.sh
ハーネスの重心は scripts/agent/ のスクリプト 8 本だ。そのうち二つが核心だ。
push.sh はコミットと push の唯一の道だ。作業ツリーではコミットしない。origin/main から新しい worktree を作り、自分が指名したファイルだけをその中にコピーし、そこでゲートを回してからコミット・push する。他人が同じファイルを直しているときは、ファイルを丸ごとコピーする代わりに APPLY="スクリプト:パス" で自分の変更だけを適用するスクリプトを渡す。ゲートが回る 4 分の間に CI がデプロイコミットを上げて origin が先に進んでいれば、新しい origin/main の上に自分のコミット一つを cherry-pick で載せ直す。最大三回。このセッションのログでその再試行は 23 回あった。
worktree のパスが実行ごとに違う理由もコメントにある:「固定パスを使っていて二つの push が重なり互いのディレクトリを消し、そのとき試験ログが丸ごと嘘になった。」
gate.sh は CI の Verify 段階と同じものをローカルで回す。セキュリティ検査、カリキュラム検査、採点器監査を順に回してから単体試験を全部回す。作った理由は頭のコメントにある。
ビルド 441〜446 が六回同じ場所で失敗した後に作った。push のたびに試験を
手で選んで書いていて、カリキュラムを直しながら test_i18n_catalog を
選ばなかった。選んだ瞬間に漏らす。だから全部回し、ローカルにだけある
失敗(未インストールの依存・DB なし)は gate_baseline.txt と照合して濾す。
基準線との照合は comm -13 でやる。以前は diff | grep だったが、set -o pipefail の下では diff の終了コードがパイプライン全体を失敗にし、新しい失敗を見つけたのにそのまま通していた。そうして test_ko_source_is_current がビルド 451〜453 の三回漏れた。この事故は試験で釘を刺してある:gate.sh が comm -13 を使い、git diff 以外の diff を使っていないかを検査する。
node の試験には 300 秒の監視プロセスが付いている。node --test には既定の時間制限がなく、「activation.test.js が Mac で二回止まって push を丸ごと掴んだ」。
鍵のかかったファイルとデプロイの証拠
bumpdigest.py は app.js のダイジェストを更新するが、更新するとき三つを一緒にやる。信頼境界の数(streamSSE、renderTrustedLessonMarkdown、innerHTML、eval( など 11 パターン)を直す前のファイルと見比べ、違ったものは ALLOW で明示承認しなければならず、何をなぜ変えたかを書いた NOTE を履歴ブロックに付ける。期待値を数字で埋め込まない理由は「他人の正当な変更の後に誤って止めたことがある」からだ。五つの試験がこの判定ロジックに一時ファイルを食わせる:承認なしの変化は拒否、同じ履歴の二重記録は拒否、空の NOTE は拒否。
deploy_status.sh は「ArgoCD の Synced の緑を信じない」。両アプリとも selfHeal なので画面は常に緑で、実際に三日古いイメージが Synced と出ていたことがある。だから四つをそれぞれ読む。Jenkins の直近ビルドの結果と時間、gitops の newTag と実際の Deployment のイメージタグ、ArgoCD 両アプリの状態、そして本番サイトが返す /app.js がそのコミットのファイルと バイト単位で 同じか。静的ファイルがデプロイの最後の証拠だ。
ハーネスが自分自身を試験する
tests/test_claude_harness.py の 21 試験はコードではなくハーネスの構造を検査する。いくつかだけ写す。
- スキルの description は 80 字を超え、「使う」と共に いつ使わないか を含まなければならない。
- スキル本文には
## なぜ、## 手順、## 出力形式、## してはいけないことの四節がすべてなければならない。 - スキルが言及する
scripts/agent/*は実在し、.shは実行権限とbash -nを、.pyはcompile()を通らなければならない。 .claude/とscripts/agent/のどこにも/Users/、/private/tmp/claudeのような機械固有のパスがあってはならない。push.shにgit worktree addがありgit stashがなく、git checkoutは必ずorigin/mainと一緒でなければならない。- README はすべてのスキルディレクトリと
enabledPluginsのすべてのプラグインに言及し、commit-commandsはそこにあってはならない。
これらの試験のおかげで、ハーネスは導入以来構造が変わっていない。.claude/ と scripts/agent/ に触れたコミットは、導入コミットと基準線二行を整理したコミットの二つだけだ。
セッションの外に残るもの:メモリ
リポジトリの中のハーネスが「ルール」なら、リポジトリの外には「経験」が残る。この作業環境のメモリディレクトリにはファイルが 65 個ある。種類別にプロジェクトの事実 53、ユーザーのフィードバック 9、外部参照 3。各ファイルは事実一つと「なぜ」「どう適用するか」を持ち、索引ファイルの一行がセッション開始時に読まれる。
今週書かれたものをいくつか見れば、何が残るかが分かる。「ゲートは worktree ではなく自分のローカル複製の基準線を読む」「作ることと見せることは別の仕事」「手で作った Job はポッドのラベルを失いやすい」「CronJob の失敗は次の成功まで ArgoCD を掴む」。どれもコードには書く場所がなく、文書に書けば古くなる種類の事実だ。
このセッションで実際に経験したこと
ハーネスがあっても事故は起きる。ただ事故が 止められる場所で 起きる。昨日、検証器の一行を直して上げるのに push を四回回した。
push.shが最初の引数にコミットメッセージの ファイル を取るように変わっていた。文字列を渡すと「コミットメッセージファイルがありません」で終わった。- ゲートが
test_production_cli_…_fails_unprovisionedを新しい失敗として捉えた。自分の変更と無関係な環境の失敗だった。この Mac の cryptography 50.0.1 がロック版 50.0.0 と違い、別のエラーが先に出た。 - 同じ失敗がまた捉えられた。原因は gate.sh が worktree ではなく ローカル複製 の
gate_baseline.txtを読むことで、ローカル複製が古くてそのファイルが空だった。 - 基準線を合わせると通り、その間に CI がデプロイコミットを上げていたので「origin が先にいます — 載せ直します(1)」を経て push された。
四回のうち一度も本番に届かなかった。ゲートが止めた 41 回はすべてこういうものだ。そして 3 番の教訓はメモリファイルになり、次のセッションが同じ場所で止まらないようにする。
APPLY スクリプトにも罠が一つあった。スクリプトはまず「すでに適用済みか」を確認するが、その確認の鍵を原本にもある文字列(resolve(demo.map(lang)))にしてしまい、「すでにあります」と言って何もしなかった。鍵は新しく入る文字列でなければならない。
限界
このハーネスは完成ではない。文書自身が明かす限界が二つある。
人の承認段階がない。 docs/CI-CD.md の文だ:「main に押されたものが Verify と dev E2E を通れば、同じジョブがそのまま本番まで行く。本番を守るのはその二つの関門だけで、だから関門に何が掛かっているかが重要だ。」承認ゲートは fail-closed 設計の目標として書かれているだけで、まだ回っていない。
基準線は機械ごとに違う。 gate_baseline.txt はローカル環境の失敗を濾すファイルなのに、リポジトリの中にありながら実際には機械ごとに違わなければならない。上の 3 番の事故はその矛盾から来た。
そしてローリングデプロイ中は二つのポッドが違うビルドを返す。昨日、ブラウザが古いポッドから受け取ったスクリプトをキャッシュに残し、新機能がしばらく見えなかった。ハーネスはコミットとデプロイを守るが、ブラウザのキャッシュまではまだ守らない。
一行で
ハーネスはルールをもっと書くことではなく、実際に起きた事故をその場で自動的に止める装置を一つずつ積むこと だ。ルールは一か所に、装置はツール呼び出し・コミット・デプロイの要所に、経験はセッションの外のメモリに。このリポジトリではその三層が一日に何十回も回り、その結果がゲートが止めた 41 回として残っている。
현재 단락 (1/98)
同じモデルを使っていても、あるリポジトリではエージェントが `git stash` で他人の編集を消し、あるリポジトリではそんなことは起きない。差はモデルではなく、モデルを取り囲むもの、つまり **ハ...