Cursorエージェントの「宙ぶらりん作業」を防ぐ3条 — todo.mdが嘘をつく日
AI主体で執筆個人開発のリポジトリでCursorエージェントに小さなUI修正を頼んだら、別ブランチの作業ツリーに未コミットのまま置かれていて、それなのにtodo.mdの該当行だけが[x]になっていました。別セッションが走ったときにgit resetの巻き添えで消えかけ、復旧のためにgit reflogを往復することになりました。起きたこと自体は恥ずかしい小事故ですが、「エージェントが仕事を終わらせたと言っているのに、実態は宙ぶらりん」という状態は、ソロ運用でもマルチエージェント運用でも十分起こりえます。再発防止としてルール化した3条と、それをrule/hook/人の確認の3層で守る設計を、同じ事故にあいそうになったときに引ける形でまとめておきます。
姉妹記事の .cursor/rulesだけでスタイルを守ろうとして崩壊した が文体制御の3層設計だったのに対し、今回はワークフロー制御のほうを同じ3層で支えています。
起きたこと:完了したはずの修正が、別ブランチに置き去りだった
前日にCursorエージェントへ頼んでいたのは、iOSアプリのタグ一覧画面でスワイプが引っかかる現象の修正でした。原因はSwiftUIのView内で毎フレーム重い再計算が走っていたことで、修正自体は@StateにキャッシュしてonChangeでだけ作り直す、という素直なやつです。
翌朝、別の作業のためにgit statusを見たら次のようになっていました。
$ git branch --show-current
fix/222-exclude-archived-from-suggested-tags
$ git status
On branch fix/222-exclude-archived-from-suggested-tags
Changes not staged for commit:
modified: 60dMemo/Views/TagBrowseView.swift
ブランチ名は別イシュー(アーカイブ済みメモの提案タグ除外)のもので、そのイシューはすでにmainにマージされていました。にもかかわらずスワイプ修正の変更が同じブランチに未コミットで残っていた、という状態です。さらにtodo.mdを見ると、該当タスクは前日のうちに[x]が付いていました。
# todo.md(抜粋、前日時点)
- [x] タグ一覧のスワイプ引っかかり解消 — #226
そのあと並行で走らせていた別セッションがgit reset --hardを発行した影響で、未コミット分は一度見えなくなりました。git reflogでかろうじて場所を辿りましたが、最終的には別のエージェントが改めてブランチを切り直してPRを出し、mainにマージして事なきを得ました。結果は戻ったものの、todo.mdが嘘をついていたという事実が残ります。
なぜ起きるか:ルールは書いてあったのに守られなかった
このリポジトリにはCursor Rulesがすでに入っていて、「ブランチを切ってから作業する」「PRを作ってからマージする」のような基本は書いてあります。それでも事故になった原因を切り分けると、タスクが後半に進むほどエージェントが指示から外れていくという傾向に行き着きます。
LLMエージェントがルールを忘れる傾向は、Cursor固有の話ではありません。Anthropicのプロンプト設計ドキュメントでも、長いコンテキストでシステム指示の効きが薄れる「recency bias」に近い現象が紹介されていますし、OpenAIもinstruction-followingの評価で同じ系統の失敗を報告しています。自分の観察でも、同じセッションが長引くほど、
- セッション冒頭で指定したブランチ名を途中で忘れる
- コミットやPRを忘れて
todo.mdの更新に走ってしまう - テストやlintが通ったことで「完了しました」と報告する
という3つが高頻度で起きます。つまり、ルールに書くだけではタスクの後半で無効化されやすく、書かれている場所・守らせる仕組み・人が見る場所のどこかが噛み合わないと、ルールは書き手の自己満足に終わります。
メモ:今回のリポジトリにはxcode-project.mdcやgithub-workflow.mdcがもともと入っていました。ただ、どれも「こうするのが正しい」寄りの書き方で、「どの順番で何を満たしたら完了と呼べるか」の定義が抜けていました。抽象ルールは抽象のまま運用されがち、というのはここでも起きていました。
ルール化した3条
再発防止として、PR #229で次の3条を.cursor/rules/とtodo.mdの冒頭に足しました。いずれも「書いてないから起きた」系の穴を直接埋める条文です。
1条:セッション冒頭で、今いるブランチと依頼のトピックが合っているか確認する
セッション開始時に、現在のgit branch --show-currentと、これから着手しようとしているタスクのトピックを突き合わせます。名前が合っていなければ、勝手に作業を始めずに新しいブランチを切る(もしくは切るかどうかを人に確認する)、という流れです。
# .cursor/rules/github-workflow.mdc(抜粋)
## セッション開始時チェック
1. `git branch --show-current` で今のブランチを確認する
2. 今回の依頼の主題(issue 番号・キーワード)とブランチ名が一致しているか照合する
3. 不一致なら新しいブランチを切る。`main` で作業を始めない
(ドキュメントのみの微修正でも `docs/xxx` を切る)
以前の事故は、イシュー#222用のブランチで#226の修正を始めてしまったことが発端でした。ブランチ名とイシュー番号を毎回照合するだけで、同じ種類の混線は起きなくなります。
2条:「完了しました」と言う前に、7ステップを全部満たす
「完了」が何を指すかを、エージェントにも自分にも明確にしました。コードを伴うタスクは次の7つが全部そろうまで「完了しました」と言わない、という運用です。
# .cursor/rules/github-workflow.mdc(抜粋)
## タスク完了の定義(コード変更あり)
1. 編集(該当ファイルを書き換える)
2. ローカル CI パス(lint / build / test)
3. コミット(メッセージは規約どおり)
4. push
5. PR 作成(テンプレに沿って記入)
6. PR マージ(人のレビュー承認後)
7. ローカル `main` の同期(`git pull --prune`)
ドキュメントのみの変更は 2 を省略してよい。
どれか 1 つでも欠けたら「完了しました」と報告しない。
ポイントは、ビルドが通った時点・コミットした時点・PRを作った時点のどれもが「完了」ではない、と明示したところです。エージェントは「完了しました」と言いたがる傾向があり、どうしても手前の区切りで止まりやすくなります。7番目のmain同期までを完了の定義に含めることで、実質的に「マージされるまでは作業中」が運用になります。
3条:todo.mdの[x]はmainにマージされてから付ける
1条と2条を裏から支えるのがこれです。todo.mdの[x]マークを付ける条件をPRがmainにマージされた瞬間まで遅らせる、と決めました。レビュー中のタスクには、行末に— PR #Nを書き添える運用に変えています。
# todo.md(タスク運用ルール 抜粋)
- `[x]` は PR が main にマージされてから付ける
- PR 作成・レビュー中は `[ ]` のまま、行末に `— PR #N` を書き添える
- マージ後は `[x]` を付け、必要なら該当 PR 番号も残す
今回の事故は[x]が先行したことで「終わったもの」に見え、別セッションが安心してgit resetを叩ける状況を作ってしまいました。[x]をmainマージと結んでおけば、todo.md自体が、作業ツリーの状態を間接的に表すチェックリストとして効きます。
3層で守る:rule/hook/人の確認
3条を書いただけで再発が止まったかというと、半分です。ルールは書き手(=自分)が意図したとおりにエージェントが動いてくれることを期待する宣言であって、タスクが後半に進むほど効きが薄れるのは相変わらずです。そこで同じルールを3つの場所に分けて配置しました。姉妹記事の文体制御と同じ構造ですが、守りたい対象がワークフローなので、道具立ても別物になります。
| 層 | 置き場所 | 役割 |
|---|---|---|
| rule | .cursor/rules/github-workflow.mdc/todo.md冒頭 |
3条の条文そのもの。エージェントの判断材料として読ませる |
| hook / 仕組み | PRテンプレ、gh pr createの確認、pre-push、コミットメッセージ規約 |
人が忘れても、ツール側が「次は push/PR だよ」と促す |
| 人の確認 | セッション終わりのgit status、gh pr list、todo.mdとの突き合わせ |
ルールもhookも通り抜けた未完成タスクを、最終的に人が拾う |
rule:条文の置き場を分ける
3条は、性質の違いで置き場を分けています。「セッション冒頭のチェック」と「完了の定義」はコード変更のワークフローなのでgithub-workflow.mdcに入れ、[x]の扱いはタスク管理なのでtodo.mdの冒頭にタスク運用ルールとして書きました。エージェントは両方のファイルを参照するので、ブランチの話とtodoの話が別ファイルに書かれていても、セッション内では自然にクロスリファレンスされます。
条文を増やすこと自体はコストが高くありません。ただ、姉妹記事でも書いたとおり、ルールが肥大化するほど冒頭から読み始めるエージェントは減ります。3条は各ファイルで最上位の見出し直下に置いて、スクロールしないと読めない位置には絶対に置かないのを自分ルールにしています。
hook/仕組み:忘れても止めるやつ
ルールを書くだけでは、タスク後半で効きが薄れるのを止められません。仕組みで補強するのはPRテンプレとghコマンドのフローです。
- PRテンプレに「関連
todo.mdの行」チェック欄を入れる。PR本文を書くときにtodoとの対応を明示する運用 gh pr createをscripts/のラッパーから呼ばせ、呼ぶ前にgit statusの出力を表示する- コミットメッセージ規約(prefix + issue番号)を
commit-msghookで強制。規約違反だとローカルで弾かれる
とくに効いたのはPRテンプレで、「この変更をtodo.mdのどの行で反映するか」を記入必須にしました。PRの作成時点でtodoとの結び付きが言語化されるので、マージ後に[x]を付ける動きが自動化しやすくなります。
人の確認:セッション終わりのチェックリスト
最後にどうしても必要なのが、人の目です。自分で決めたのは次の3行を毎セッションの終わりに必ず叩くこと、でした。
$ git status # dirty ならコミット or stash の判断
$ git branch --show-current # このブランチ名は合っているか
$ gh pr list --author @me # 開きっぱなしの PR はないか
この3つを見れば、todo.mdに嘘が混じっていても検出できます。コストは30秒。セッションを閉じる前に開くターミナルで叩くだけなので、忘れる理由がありません。ルールもhookも抜けてきた小事故を、人の最後の確認で拾うという役割分担です。
メモ:「人の確認」を層に入れるのは後ろ向きに見えますが、ソロ開発のエージェント運用では重要だと思っています。エージェントが自律で判断している以上、どこかで人が外形を見ないと、暴走と正常動作の見分けは付きにくくなります。git status/gh pr list/todo.mdの3点セットは、エージェントがどれだけ頑張っても代替しづらい領域です。
運用してみて
3条と3層で運用を始めて数日ですが、体感で一番変わったのはエージェント自身がセッション冒頭で現在のブランチ名を読み上げるようになったことです。「今はfeat/XXXにいます。これから#YYYの修正をしますが、ブランチを切り直しますか?」という確認が自然に出るようになり、その場でこちらが「そのまま」「新しく切って」を選べます。
副次的な効果として、todo.mdの[x]がほぼ嘘をつかなくなりました。[x]の条件がmainマージと直結しているので、逆に[ ]のままで— PR #Nが書かれている行が増えるのですが、これは「進行中」が可視化されているだけで、嘘ではありません。嘘が減れば、並行セッションがgit resetを叩くときに根拠として信頼できます。
一方で消えない問題もあります。3条は事前ルールと事後確認でできていますが、タスクの途中でエージェントが勝手に別ブランチへ移るような動きは完全には止まりません。これを潰すにはworktreeでエージェントごとに作業ディレクトリを分ける方向が必要で、別記事の stash recoveryの話 がそちらの延長線です。3条が押さえるのは同一作業ツリー内の事故までで、worktreeでエージェントごとに作業ディレクトリを分けるような構造的な対策は、その先で別途必要になります。