Featured image of post AIに続けて7件直させたら、5件目の修正が戻った。会話を区切って引き継ぐ【1タスク1セッション】

AIに続けて7件直させたら、5件目の修正が戻った。会話を区切って引き継ぐ【1タスク1セッション】

AI に一つの会話で不具合を7件続けて直させたら、7件目の修正で5件目が元に戻った。会話をタスクごとに区切り、決めたこととやめたことだけを次の会話に渡す引き継ぎ書(1タスク1セッション)を TypeScript と node --test で書きます。

第1幕: 現場 ── 再発

差し戻された不具合票を持って、自分は整理券の発券機の前に立っていた。昨夜、AI に一つの会話のまま、不具合を七件続けて直させた。七件目を直したら、五件目に直したはずの不具合が戻っていた。票のいちばん上に、赤いボールペンで二文字、書いてある。再発。

大槻さんの字だった。

夜明け前の遊園地は、音がしない。入口のゲートの前に、発券機が四台並んでいる。画面はまだ暗い。自分は一台の前にしゃがんで、操作パネルの蓋を開け、点検用の画面を出した。運休の操作を試すためのボタンがある。押した。画面の表示が「運休」に変わった。そのあと、その回の整理券の番号を読ませてみた。「有効」と出た。

運休したアトラクションの整理券が、使えるまま残っている。昨日の夕方のプレオープンで出た不具合の、五番だ。昨夜、三回やり直して、やっと直したところだった。

不具合票というのは、運営チーフの大槻さんが、見つけた不具合を一件ずつ書いて番号を振る、紙の票のことだ。昨日、招待客だけを入れたプレオープンのあと、大槻さんは票を七枚、自分に渡した。七枚の票の右下には、それぞれ大槻さんの字で「決まり」が一行ずつ書いてある。五番の決まりは、こうだった。運休の操作のあと、その回の整理券はすぐ使えなくする。

明日が、グランドオープンだ。この遊園地は改装で半年休んでいて、明日、もう一度開く。

空が、観覧車の向こうから少しずつ白くなっていた。フードコートのテーブルの上に、ポップコーンの袋がいくつも散らばっている。昨夜のうちに片づけたはずの袋だ。ゴミ箱の蓋が一つ、外れて転がっていた。袋のまわりに、黒い羽が一枚落ちていた。

カラスだ。改装のあいだ、誰もいない遊園地は、カラスの住みかになっていたらしい。大槻さんが、鳥の追い払いの業者を頼んだと言っていた。鷹の人が来るから、と。

そのとき、頭の上を何かが低く抜けた。

風が、首の後ろを撫でていった。見上げると、茶色い鳥が、フードコートの屋根のほうへ滑っていくところだった。屋根の上に並んでいたカラスが、一斉に飛び立った。ぎゃあぎゃあと鳴きながら、観覧車のほうへ逃げた。茶色い鳥は、屋根の縁にとまった。翼をたたむと、尾の先に白い帯が一本見えた。

鷹だった。

少しして、通用口のほうから、人が走ってきた。若い女の人だった。走ってくる途中で、まず左腕が目に入った。肘のあたりまで、使い込んだ革の手袋をはめている。次に、首から紐で下げた小さな笛が、胸の前で跳ねているのが見えた。薄手の上着に、作業用の太いズボン。目つきが鋭い。走ってきたのに、息はほとんど上がっていなかった。

その人は、フードコートの屋根を見上げてから、自分のほうを見た。

「屋根に上がるはしご、どこですか」

いきなりだった。自分は、立ち上がりながら、倉庫のほうを指さした。

「あ、あっちの、倉庫に。脚立なら」

「借ります」

それだけ言って、倉庫のほうへ歩き出す。自分はあわてて追いかけて、倉庫の鍵を開けた。脚立を一緒に運んで、フードコートの裏に立てた。自分が脚立の脚を押さえているあいだに、その人は身軽に上っていった。

脚立の下から見上げると、屋根の上で、その人が左の拳を差し出していた。鷹が、屋根の縁から拳へ跳び移った。その人は、腰の小さな袋から、肉の切れ端を一つつまんで、鷹にやった。鷹は、それを一口で飲み込んだ。ほうびなんだろうな、と思った。眠い頭では、そこまでだった。

降りてきたその人は、脚立をたたむのを手伝ってから、言った。

「鷹宮です。大槻さんに呼ばれて」

「早瀬です。ここの、システムの……」

自分は、何の担当だと言えばいいのか迷った。アプリもサーバーも、全部自分だった。この遊園地で、エンジニアは自分一人だ。

鷹宮さんは、続きを待たなかった。拳の上の鷹を見て、それから観覧車のほうを見上げた。観覧車の骨組みに、カラスがまた何羽か戻ってきていた。

自分はフードコートのテーブルにノートパソコンを開いた。画面の明るさが、夜明け前の目に痛かった。キーボードに指を置いたまま、昨夜の六時間を、一件目から辿り直した。

来園者向けのスマホアプリを作っているのは、自分だ。アトラクションの待ち時間を出す画面と、整理券を発行する画面がある。整理券は、乗る時刻の枠を先に取っておける券で、その時刻に乗り場へ行けば、列に並ばずに乗れる。アプリの裏のサーバーも、自分が見ている。コードの多くは、AI に書かせている。使っているのは Claude Code という、コーディングエージェントだ。こっちが頼んだことを、AI がファイルを読んで、コードを書いて、テストを走らせて、片づけていく。

昨夜の十時に、七枚の票を机に並べた。一枚目から順に、AI に頼んだ。会話は一つのままにした。新しく始め直さなかった。

そのほうが早いと思ったからだ。同じアプリの話だから、前の修正のことを AI が覚えていてくれたほうが、いちいち説明しなくて済む。実際、二件目からは、頼む文が短くなった。さっきの番号の画面で、とか、同じ関数で、とか書けば、AI はそれで分かってくれた。

一件目から四件目は、すんなり直った。整理券の番号の桁。待ち時間の単位。閉園まぎわの整理券の締め切り。再発行の回数。

五件目で、つまずいた。

運休した回の整理券が、使えるまま残る。雨や点検でアトラクションを運休にしても、その回の整理券を持っている来園者は、乗り場へ向かってしまう。

一回目、AI は画面の側で運休の操作を見て、整理券を消すように直した。試したら、運休してから五分のあいだ、整理券がまだ使えた。

調べると、整理券の一覧はキャッシュされていた。キャッシュというのは、一度サーバーから取ってきたデータを、しばらく取り直さずに使い回すことだ。毎回取りに行かないので速くなる。そのかわり、そのあいだに元のデータが変わっても気づかない。整理券の一覧は、五分間キャッシュされていた。だから、運休の知らせが五分遅れて届いていた。

二回目、AI はキャッシュの時間を一分に縮めた。テストは通った。

念のため、夜中に大槻さんに電話した。大槻さんは、寝ていたはずなのに、すぐ出た。一分でも駄目だ、と言われた。運休の放送から一分あれば、来園者は乗り場に着く。乗り場に着いてから「その券はもう使えません」と言われたら、と大槻さんは言った。言われたほうは、二度とうちの整理券を信じない。

キャッシュの時間を短くするのは、やめた。何秒にしても、その秒数のあいだは、運休したあとの整理券が使えてしまう。

三回目に、整理券の状態だけは、キャッシュせずに毎回サーバーから読むことにした。待ち時間の表示だけをキャッシュする。それで直った。整理券の状態はキャッシュしない。そう決めて、AI に伝えた。

六件目の途中、夜中の二時半ごろだったと思う。画面に、会話が長くなったので要約して詰めた、という知らせが出た。道具が自動でやったことだ。自分は、整理されたなら助かる、くらいに思った。それどころじゃなかった。

七件目は、来園者が増えると待ち時間の画面が重い、という票だった。明日は、プレオープンの何倍も人が来る。AI は、待ち時間と整理券の読み込みを一つの関数にまとめて、両方をキャッシュに通した。画面は速くなった。テストも通った。朝の四時に、自分は寝た。

五時半に、大槻さんからの電話で起こされた。開園前の点検で、五番がまた出た、と。

駆けつけて、七件目の差分を読んだ。整理券の読み込みが、またキャッシュに入っていた。五件目で外したはずの行が、七件目の修正の中で戻っていた。

何が戻ったのかは、分かった。なぜ戻ったのかが、分からない。五件目で決めたことは、AI に伝えてある。同じ会話の中の、数時間前のことだ。

寝不足で、頭が回っていなかった。それは自分でも分かっていた。分かっていて、どこから考えればいいのかが分からなかった。

九時に、開門の放送のテストがある。グランドオープンの前の日の、最後の通し練習だ。それまでに、五番を直したい。あと二時間半。

鷹宮さんは、観覧車の骨組みに並んだカラスを見上げていた。拳の上で、鷹の向きを少しだけ直した。

第2幕: 再現 ── 四回と一回

鷹宮さんは、フードコートの前に立って、観覧車のほうへ拳を振った。

鷹が、拳を蹴って飛び立った。低く、速かった。自分はテーブルから、その背中を目で追った。観覧車の骨組みのカラスが、気配に気づいて首を伸ばした。

鷹は、観覧車へは行かなかった。

途中で弧を描いて向きを変え、フードコートの屋根へ戻っていった。さっき、ほうびをもらった屋根だ。縁にとまって、翼をたたんだ。観覧車のカラスは、首を引っ込めて、また並び直した。

鷹宮さんは、黙って左腕を高く掲げた。鷹が屋根から降りてきて、拳にとまった。鷹宮さんは、鷹の胸のあたりを少しのあいだ見ていた。それから、フードコートの外のフェンスまで歩いて、鷹をフェンスの上にとまらせた。

「疲れてる。少し休ませます」

それだけ言って、フェンスの横に立った。

自分は、ノートパソコンに目を戻した。待つあいだに、昨夜の会話の履歴をさかのぼることにした。

ここで、会話というものを、自分の言葉で整理しておく。Claude Code で AI に頼みごとをすると、そのやりとりは一つの会話(セッション)としてつながっていく。こっちが新しい会話を始めないかぎり、前のやりとりは全部、同じ会話の中に残っている。AI が答えるときに読んでいるのは、いま書いた一文だけではない。その会話でこれまでにやりとりしたこと、全部だ。

そして、AI が一つの会話で一度に見渡せる量には、上限がある。それをコンテキストウィンドウと呼ぶ。会話のやりとりも、AI が読んだファイルの中身も、走らせたコマンドの出力も、指示書も、全部この中に入る。昨夜の会話には、七件分の依頼と、修正の説明と、テストのログが、全部入っていた。

Claude Code の公式のドキュメントの、使い方のコツのページに、こう書いてある。

LLM performance degrades as context fills. When the context window is getting full, Claude may start “forgetting” earlier instructions or making more mistakes.

会話がいっぱいになるほど、AI の調子は落ちる。いっぱいに近づくと、前の指示を「忘れ」たり、間違いが増えたりしうる。

一晩の会話だ。さぞ長かったんだろう、と思った。長すぎたから、五件目のことを忘れた。そういうことなんだろう。

自分は、昨夜の会話の中で、キャッシュの話がどこに何回出てくるかを、上から目で追い始めた。五件目の一回目の説明に一回。二回目に一回。三回目の説明にも、外した行として一回出てくる。七件目に一回。

途中で、数え間違えている気がしてきた。眠いせいだ。手で数えるのをやめて、小さなコードを書くことにした。

会話の一往復を、型にしておく。types.ts に、こう書いた。

1
2
// 会話の一往復。tool はコマンドの出力やテストのログ
export type Turn = { role: 'user' | 'assistant' | 'tool'; text: string };

role は、誰の発言かだ。'user' が自分、'assistant' が AI、'tool' は AI が走らせたコマンドの出力やテストのログになる。'user' | 'assistant' | 'tool' の | は、三つの文字列のどれか一つ、という TypeScript のユニオン型だ。

昨夜の会話を、この形の配列に写した。本物の会話の履歴ファイルの形ではなく、この記事のために、昨夜の会話から要る分だけを写した架空のものだ。五件目のところは、こうなる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const turns5: Turn[] = [
  { role: 'user', text: `5件目。運休した回の整理券が使えるまま残る(票の決まり: ${TICKET_DECISION})` },
  { role: 'assistant', text: `${ATTEMPT_1}整理券の読み込みは、既存の ${MARK}, 5 * 60_000, readTickets) のままです。` },
  { role: 'user', text: '運休してから5分、まだ使える。5分遅れてる' },
  { role: 'assistant', text: `${ATTEMPT_2}${MARK}, 60_000, readTickets) にしました。` },
  { role: 'tool', text: 'ok 3 - 60秒後に整理券は読み直される' },
  { role: 'user', text: '大槻さんに電話した。1分でも駄目。1分あれば、来園者は乗り場に着く' },
  { role: 'user', text: 'キャッシュを短くするのはやめる。何秒にしても、そのあいだは使える' },
  { role: 'assistant', text: `${MARK}, 60_000, readTickets) の行を外して、整理券は毎回 readTickets を呼ぶ形にしました。待ち時間だけ cached('waits', 5 * 60_000, readWaits) のままです。` },
  { role: 'tool', text: LOG_LINE },
  { role: 'user', text: `了解。${IMPL_DECISION}。これで決める` },
];

バッククォート(`)で囲んだ文字列の中に ${...} と書くと、そこに値が埋め込まれる。${MARK} や ${TICKET_DECISION} は、同じファイルの上のほうで決めた文字列だ。ATTEMPT_1 と ATTEMPT_2 は、一回目と二回目の AI の説明の文で、LOG_LINE は、三回目のあとのテストのログの一行だ。${MARK}, 60_000, readTickets) をつなげて読むと、cached('tickets', 60_000, readTickets) になる。MARK は cached('tickets' という文字列で、整理券の読み込みをキャッシュに通しているコードの目印だ。cached(key, ms, fn) は、うちのサーバーにある関数で、fn で取ってきた結果を ms ミリ秒のあいだ使い回す。5 * 60_000 は五分、60_000 は一分だ(数字の中の _ は、読みやすくするための区切りで、値は変わらない)。TICKET_DECISION は、票の決まりの文(運休の操作のあと、その回の整理券はすぐ使えなくする)で、IMPL_DECISION は、三回目に決めた実装の文(整理券の状態はキャッシュしない)だ。

七件目を頼んだとき、AI が読んでいたのは、その夜の会話の全部だ。それを一つの文字列につなぐ関数を、before.ts に書いた。ここでは、七件目の修正の説明まで含めて、その夜の会話を最後までつなぐ。

1
2
3
4
5
6
import type { Turn } from './types.ts';

// 7件目を頼むときに AI が読む文脈。その夜の会話の全部を、順につなぐ
export function contextBefore(turns: Turn[]): string {
  return turns.map((t) => `${t.role}: ${t.text}`).join('\n\n');
}

import type は、型だけを読み込む書き方だ。.ts の拡張子まで書くのは、Node が TypeScript のファイルをそのまま走らせるときに、拡張子を補ってくれないからだ。map で一往復ずつ「誰: 本文」の形にして、join で空行をはさんでつなぐ。

数えるほうは、count.ts に書いた。

1
2
3
4
// text の中に needle が何回出てくるか
export function countOccurrences(text: string, needle: string): number {
  return text.split(needle).length - 1;
}

文字列を needle で切ると、切れ目の数より一つ多い数のかけらになる。だから、かけらの数から一を引けば、出てきた回数になる。

その夜の会話の全部をつないで、整理券をキャッシュするコードの目印と、決めたことの文を数えた。

1
2
cached('tickets'  4回
整理券の状態はキャッシュしない  1回

四回と一回だった。

やめたやり方のコードは、四回書いてある。決めたことは、一回しか書いていない。四回のうち一回は、七件目の修正そのものだ。七件目を頼んだ時点では、三回と一回だった。七件目の AI は、それを全部読んだうえで、整理券の読み込みをキャッシュに通し、四回目を書いた。

それで AI が五件目を戻した、と言い切れるわけではない。AI が何を見てそう書いたのかは、外からは分からない。ただ、会話の中に残っているものの割合は、こうだった。やめたことのほうが、決めたことより多く書いてある。

フェンスのほうで、羽の音がした。

鷹宮さんが、鷹をフェンスから拳に戻していた。十分間ほど休ませただろうか。さっきと同じ、フードコートの前に立って、観覧車のほうへ拳を振った。

鷹は、また屋根へ行った。

今度は迷いもしなかった。まっすぐフードコートの屋根へ向かって、さっきと同じ縁にとまった。

鷹宮さんは、屋根の鷹を少しのあいだ見上げていた。それから、小さく息を吐いた。

「違った」

それだけだった。拳を上げて鷹を呼び戻すと、拳に据えたまま、歩き出した。フードコートを離れて、観覧車のほうへ。自分はノートパソコンを抱えて、あとを追った。

鷹宮さんは、観覧車の乗り場の前を通りすぎ、乗り場の建物の裏へ回り込んだ。そこからは、フードコートの屋根が見えなかった。乗り場の建物が、ちょうど間に立っている。

鷹宮さんは、そこで足を止めた。観覧車の骨組みを見上げて、拳を振った。

鷹は、骨組みへ一直線に飛んだ。カラスが、今度は一羽残らず飛び立った。観覧車の向こうの雑木林のほうへ、黒い影が散っていく。鷹は骨組みの上を一回りして、鷹宮さんの拳に戻ってきた。

自分は、乗り場の裏の、塗り替えたばかりのベンチの横に立っていた。

「休ませたのに、なんでまた屋根に行ったんですか」

鷹宮さんの目は、拳の上の鷹から離れなかった。

「休ませても、見えてる」

「見えてる?」

「屋根。今朝、あそこでほうびをやった」

脚立の下から見た、あの肉の切れ端のことだ。

「じゃあ、ここまで歩いたのは」

「見えないところから、放す」

鷹宮さんは、乗り場の建物のほうへ顎をしゃくった。建物の向こうに、フードコートの屋根がある。ここからは見えない。

「最初は、疲れだと思った。違った」

言い訳はしなかった。外した見立てを、外したまま、すぐ捨てて、歩いた。それだけだった。

自分は、ベンチに腰を下ろした。休ませても、屋根は見えている。なにかが、昨夜の自分と重なりかけた。でも、まだ言葉にならなかった。

膝にノートパソコンを載せた。分かっていることから、テストにしておくことにした。テストというのは、コードがこう振る舞うはずだ、という期待を、走らせて確かめられる形に書いたものだ。Node には、node --test という、テストを走らせる仕組みが最初から入っている。

いまの作りでは赤になるはずのテストを先に書いて、本当に赤になるのを確かめておく。確かめたいのは、七件目を頼んだときに AI が読むものだ。そこに、やめたコードが入っていてほしくない。決めたことは、入っていてほしい。テストの名前を書こうとして、手が止まった。「七件目の会話に渡さない」と書きかけて、消した。七件目だけの話じゃない。これから先、AI に何かを頼むたびに、AI はその会話を読む。「次の会話に渡さない」に書き直した。ここで言う次の会話は、この会話の続きで、次に何かを頼むときに AI が読むもののことだ。

1
2
3
4
5
6
7
8
9
const context = contextBefore(nightConversation());

test('却下したコードは、次の会話に渡さない', () => {
  assert.equal(countOccurrences(context, MARK), 0);
});

test('決めたことは、次の会話に渡す', () => {
  assert.ok(context.includes(TICKET_DECISION));
});

nightConversation() は、その夜の七件分の会話を、さっきの形の配列で返す関数だ。テストに渡す固定の入力を、fixture と呼ぶ。本物の AI は呼ばない。昨夜の会話を写した、決まった配列を渡すだけだ。assert.equal は、二つの値が等しいことを確かめる。assert.ok は、値が真であることを確かめる。

走らせた。

1
2
3
4
5
✖ 却下したコードは、次の会話に渡さない (0.745875ms)
✔ 決めたことは、次の会話に渡す (0.116042ms)
ℹ tests 2
ℹ pass 1
ℹ fail 1

一本目が赤で、二本目が緑だった。赤は失敗、緑は成功だ。決めたことは、ちゃんと会話の中にある。やめたコードも、四回、会話の中にある。忘れて消えたのではなかった。決めたことは残っていて、そのうえで、やめたやり方のほうが多く見えていた。

公式のページを、さらに読み進めた。こういう一節があった。

If you’ve corrected Claude more than twice on the same issue in one session, the context is cluttered with failed approaches.

一つの会話の中で、同じ問題を二回より多く直したなら、会話は失敗したやり方で散らかっている。

五件目だ。一回目は五分遅れた。二回目は一分でも駄目だった。三回目で、やっと決めた。自分のことだった。

この会話のまま、AI に「整理券はキャッシュするな」と、もう一回言い直せば済むんじゃないか。そうも思った。でも、この会話の中では、やめたコードが四回で、決めたことが一回だ。言い直せば、決めたことが二回になる。四回のほうは、そのまま残る。公式も、同じ問題を二回より多く直した会話は、失敗したやり方で散らかっている、と書いている。ここで同じ会話に言い直しを積むのは、昨夜と同じことをもう一回やるだけだ。同じ会話の中で言い直すのは、やめた。

では、昨夜の二時半に走った、あの要約は何だったのか。

Claude Code には、会話が長くなったときに、それまでの会話を要約に置き換えて、空きを作る仕組みがある。こちらから頼むときのコマンドと、道具が自動でやる仕組みの両方だ。日付つきで、手元のメモに写しておく。2026年10月18日時点の Claude Code の設定で、名前や動きは改訂で変わることがある。

  • /compact: それまでの会話を要約に置き換えて、コンテキストウィンドウに空きを作る。/compact のあとに一言添えると、要約で何を残すかの焦点を指示できる
  • 自動の圧縮(auto-compact): 会話がいっぱいに近づくと、道具が自動で要約する。/autocompact で、どこまで埋まったら走るかを変えられる
  • /context: いま、何がコンテキストウィンドウを埋めているかを表示する
  • 圧縮のあとも、プロジェクトの CLAUDE.md(指示書)は、ディスクから読み直されて残る。会話そのものは、要約に置き換わる

公式の説明には、要約が残すものも挙がっていた。頼んだことと、その意図。大事な技術の考え方。読んだり書き換えたりしたファイルと、大事なコードの断片。起きたエラーと、その直し方。残っているタスクと、いまの作業。

やめたやり方を、どう扱うかは書いていなかった。残すとも、残さないとも書いていない。「起きたエラーと、その直し方」として、五件目の三回のやり直しが要約に入ることもあるかもしれない。入らないかもしれない。それは、こちらからは決められない。

要約して詰めるのは、鷹を休ませたのと同じだった。休ませても、屋根は見えていた。会話を詰めても、同じ会話の中にいるかぎり、やめたやり方の名残は会話の中にある。何をどれだけ残すかを決めているのは、要約のほうだ。

第3幕: 手順を整える ── 持っていくもの

公式のページには、会話を区切るための一行もあった。

/clear: reset context between unrelated tasks. Long sessions with irrelevant context can reduce performance.

関係のないタスクのあいだで、会話を空にする。関係のない文脈を抱えた長いセッションは、性能を落としうる。

これを、1タスク1セッションと呼ぶ。一つのタスクを一つの会話でやる。タスクが終わったら、会話を空にして、次のタスクは新しい会話で始める。前のタスクのやりとりを、次のタスクに持ち込まない。

これも、日付つきでメモに写した。2026年10月18日時点の Claude Code の設定だ。

  • /clear: 会話を空にして、新しい会話を始める。別名は /new と /reset
  • 空にした前の会話は、/resume で呼び戻すこともできる
  • 新しい会話は、毎回、指示書(CLAUDE.md)を読み込んで始まる。公式のコマンドの一覧には、こう書いてある

/clear starts fresh on a new task while keeping project memory.

七件は、七つの会話にすればよかった。一件終わるごとに、会話を空にして、新しく始める。鷹宮さんが、屋根の見えないところまで歩いたのと同じだ。

そこまで考えて、すぐに詰まった。

新しい会話は、昨夜のことを何も知らない。五件目で決めたことも、知らない。空にした会話で、いきなり「五番を直して」と頼んだら、AI はまた整理券をキャッシュに通すかもしれない。何も渡さなければ、知らないのと同じだ。会話を空にすれば、やめたコードは見えなくなる。でも、決めたことまで見えなくなる。

さっきの公式の一文には、続きがあった。

Run /clear and start fresh with a more specific prompt that incorporates what you learned.

会話を空にして、学んだことを入れた、もっと具体的なプロンプトで始め直す。

学んだことを、入れる。それを、どうやって新しい会話に持っていくか。

ベンチの横で、鷹宮さんが、鷹を拳に据えたまま、手帳に何か書いていた。表紙の角が丸くすり切れた、小さな手帳だった。

「何、書いてるんですか」

「今朝の分」

鷹宮さんは、書く手を止めずに、手帳をこっちへ少し傾けた。読んでいい、ということらしかった。

頁の上に、今日の日付と、遊園地の名前。その下に、短い行が並んでいた。フードコートの屋根。追い払った。観覧車の骨組み。追い払った。一行ずつ、場所と、やったことだけ。

何回やり直したかは、書いていなかった。屋根に二回行かれたことも、休ませたことも、見立てを外したことも、どこにもなかった。

「失敗したことは、書かないんですか」

「回数は書かない」

鷹宮さんは、手帳を引き寄せて、また書き始めた。

「やめたことは、書く」

頁の下のほうに、もう一行あった気がした。メリーゴーラウンド、とあった気がする。でも自分は、そこまで読まなかった。腕の時計を見たら、八時を回っていた。開門の放送のテストまで、一時間を切りかけている。早く、新しい会話を試したかった。

回数は書かない。何回失敗したかは、持っていかない。決めたことだけ、紙に書いて持っていく。会話は全部捨てて、決めたことだけを新しい会話に渡せばいい。

自分は、そう受け取った。

引き継ぎ書を作ることにした。引き継ぎ書は、新しい会話の最初に AI に読ませる、短い文書だ。前の会話を丸ごと渡すかわりに、続きをやるのに要ることだけを書く。

その材料として、まず自分の記録を書く。NOTES.md というファイルを作って、昨夜の七件を、票の番号ごとに書き出した。時間がないので、決まりの文は、大槻さんが票に書いた言葉をそのまま写した。失敗したことは、全部捨てた。決めたことだけを、理由つきで一行ずつ。

1
2
3
4
## 5 運休した回の整理券が使えるまま残る
- 決定: 運休の操作のあと、その回の整理券はすぐ使えなくする / 理由: 運休の放送から1分あれば、来園者は乗り場に着く
## 6 運休中の乗り物にも待ち時間が出ている
- 決定: 運休中の乗り物は、待ち時間の表示を灰色にする / 理由: 数字を出すと、待てば乗れると読まれる

一件目から七件目まで、どの票もこの形にした。## で始まる行が票の番号と件名で、その下の - 決定: の行が決めたこと、/ 理由: のあとが理由だ。形を決めておけば、プログラムで読める。決めたことは、七件分を全部書く。五件目を直し直す会話にも、ほかの票で決めたことを渡す。七件目の修正が、五件目のコードに触って戻したからだ。どの票で決めたことが、次の会話でどのコードに触るかは、前もって分からない。決めたことは一行ずつで短いので、全部渡しても長くならない。

読むほうを書いた。決めたことの一行を、型にする。

1
2
// 決めたことの一行
export type Decision = { task: string; what: string; why: string };

task は票の番号、what は決めたこと、why は理由だ。読む関数は、こうなった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export function parseDecisions(md: string): Decision[] {
  const decisions: Decision[] = [];
  let task = '';
  for (const line of md.split('\n')) {
    const head = line.match(/^## (\d+)\b/);
    if (head) {
      task = head[1];
      continue;
    }
    const m = line.match(/^- 決定: (.*?)(?: \/ 理由: (.*))?$/);
    if (m && task) {
      decisions.push({ task, what: m[1].trim(), why: (m[2] ?? '').trim() });
    }
  }
  return decisions;
}

一行ずつ見ていく。match は、正規表現に合った部分を取り出す。正規表現の ^ は行の頭、$ は行の終わりを表す。/^## (\d+)\b/ は、行の頭が ## で、そのあとに数字が続く行に合う。\d+ が一つ以上の数字で、\b は数字の切れ目だ。合えば、head[1] に一つ目の丸かっこ (\d+) の部分、つまり票の番号が入る。それを、いまの票の番号として覚えておく。

- 決定: で始まる行なら、決めたことの一件にする。m[1] が決めたことの文で、m[2] が理由だ。(.*?) の ? は、なるべく短く合わせる、という印で、これがあるから決めたことの文は「 / 理由: 」の手前で切れる。? がないと、理由まで決めたことの文に飲み込まれてしまう。\/ は、スラッシュそのものを表す書き方だ(正規表現の外枠にスラッシュを使っているので、中では印を付ける)。(?: \/ 理由: (.*))? は、「 / 理由: 」から後ろがあれば理由として取り出し、なければないままにする。理由がなければ m[2] は空なので、?? '' で空の文字列にしておく。それ以外の行は読まない。記録の中に自由に書いたメモは、引き継ぎ書には入らない。

組み立てる関数は、決めたことを並べるだけにした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 途中段階。失敗は全部捨てて、決めたことだけを渡す
export function buildHandoffDecisionsOnly(task: Task, decisions: Decision[]): string {
  return [
    `# 引き継ぎ: ${task.id} ${task.goal}`,
    '## このタスク',
    task.goal,
    '## 決めたこと(理由つき)',
    ...decisions.map((d) => `- ${d.what}(${d.why})`),
    '## 範囲外',
    ...task.outOfScope.map((s) => `- ${s}`),
    '## 次の一手',
    task.next,
    '',
  ].join('\n');
}

Task は、新しい会話でやるタスクの型で、types.ts に置いた。

1
2
// 次の会話のタスク
export type Task = { id: string; goal: string; outOfScope: string[]; next: string };

目的と、範囲外(今回はやらないこと)と、次の一手を持つ。公式のページにも、いちばん役に立つ仕様書は、関わるファイルを名指しして、範囲外を書いて、確かめ方で終わる、とあった。引き継ぎ書も、それに寄せた。map で決めたことを一行ずつの形にする。... は、配列の中身を、外側の配列に一つずつ広げて並べる書き方だ。

最初に直すのは、五件目だ。七件目の修正で戻ったのだから、先に五件目を直し直す。tasks.ts に、五件目のタスクを書いた。

1
2
3
4
5
6
export const task5: Task = {
  id: '5',
  goal: '運休のあと、その回の整理券をすぐ使えなくする',
  outOfScope: ['待ち時間の表示の速さは扱わない'],
  next: 'loadBoard の整理券の読み込みを確かめる',
};

loadBoard は、待ち時間の画面に出すデータを読み込むサーバーの関数だ。七件目で AI が、整理券と待ち時間の読み込みをここにまとめた。tasks.ts には、七件目のタスクの task7 も同じ形で書いて、票の番号から引ける表 tasks にまとめてある。

乗り場の裏で書いた二本のテストを、この組み立てで作った引き継ぎ書に向け直した。

1
2
3
4
5
6
7
8
9
const handoff = buildHandoffDecisionsOnly(task5, parseDecisions(notesDecisionsOnly));

test('却下したコードは、次の会話に渡さない', () => {
  assert.equal(countOccurrences(handoff, MARK), 0);
});

test('決めたことは、次の会話に渡す', () => {
  assert.ok(handoff.includes(TICKET_DECISION));
});

notesDecisionsOnly は、さっきの決めたことだけを書いた NOTES.md の中身だ。乗り場の裏で書いたときは、「次の会話」と言っても、同じ会話の続きで次に頼むことを指していた。いまは、空にしたあとの新しい会話を指す。テストの名前は同じままで、向ける先が変わった。走らせると、二本とも緑だった。やめたコードは入っていない。決めたことは入っている。

自分は、ノートパソコンを抱えて、鷹宮さんのところへ行った。鷹宮さんは、まだ手帳に何か書き足していた。

「会話は捨てて、決めたことだけ持っていけばいいんですね。失敗したことは、全部捨てる」

鷹宮さんは、ボールペンを動かしたままだった。

「会話は、捨てる」

言ったのは、前半だけだった。

自分は小さく頷いて、ベンチに戻った。

会話を空にした。新しい会話で、最初にこう頼んだ。この引き継ぎ書を読んで、五番を直してほしい。プロンプトには @HANDOFF.md と書いた。@ のあとにファイル名を書くと、AI はそのファイルを読んでから答える(2026年10月18日時点の Claude Code の使い方。公式のページには「Reference files with @ instead of describing where code lives.」とある)。HANDOFF.md は、さっきの関数で組み立てた文字列を、その場は手で書き出したファイルだ(書き出すスクリプトは、あとでまとめた)。

組み立てた引き継ぎ書は、こうなった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 引き継ぎ: 5 運休のあと、その回の整理券をすぐ使えなくする
## このタスク
運休のあと、その回の整理券をすぐ使えなくする
## 決めたこと(理由つき)
- 整理券の番号は、画面に3桁で出す(券面と同じ桁にそろえると、読み間違えない)
- 待ち時間は、分単位で出す(秒まで出しても、来園者は分でしか判断しない)
- 閉園30分前から、整理券の発行を終える(30分あれば、最後の回に乗れる人が決まる)
- 整理券の再発行は、1人1回までにする(何度でも出せると、同じ回の枠が埋まる)
- 運休の操作のあと、その回の整理券はすぐ使えなくする(運休の放送から1分あれば、来園者は乗り場に着く)
- 運休中の乗り物は、待ち時間の表示を灰色にする(数字を出すと、待てば乗れると読まれる)
- 待ち時間の画面は、来園者が増えても1秒以内に出す(重いと、来園者が発券機の前で立ち止まる)
## 範囲外
- 待ち時間の表示の速さは扱わない
## 次の一手
loadBoard の整理券の読み込みを確かめる

新しい会話の AI は、引き継ぎ書を読み、loadBoard のコードを読んだ。そこには、七件目で入った cached('tickets', 60_000, readTickets) の行がある。AI は、こういう趣旨の案を返してきた。決まりには、運休のあとすぐ使えなくする、とある。整理券のキャッシュの時間を、いまの一分から十秒に縮めれば、運休はすぐに届く。画面の速さもあまり落ちない。

手が止まった。

それは、五件目で、もうやめたやり方だった。夜中に大槻さんに電話して、一分でも駄目だと言われて、やめた。十秒でも同じだ。その十秒のあいだ、運休したあとの整理券は使える。

AI が引き継ぎ書を読まなかったわけではなかった。決めたことの文は、ちゃんと読んでいる。そのうえで、「すぐ」を十秒のキャッシュで満たそうとした。決めたことの文には、反していない。決めたことは、どう作るかまでは縛っていなかった。昨夜の会話の中では、それを縛っていたのは、やめたことのほうだった。キャッシュの時間を縮めるのは、何秒でも駄目だ、という結論だ。

新しい会話は、それを知らない。引き継ぎ書には、決めたことしか書いていない。

なら、決めたことを、もっと細かく書けばいいのか。整理券の状態はキャッシュしない、と。それなら十秒の案は出なかったかもしれない。でも、決めたことの文は、何をするかを言うだけだ。その一つ手前にある、もっともらしい近道を、もう試して駄目だったとは言わない。次にまた別の近道が、たとえば、運休のときだけキャッシュを捨てる、といった形で出てきたら、決めたことの文だけでは、それが昨夜検討済みなのかどうかが分からない。やめたことの行は、その近道を、理由ごと閉じておく。だから、両方要る。

自分は、五件目の却下を忘れていたわけではなかった。昨夜の会話の中では、それは決めたことの一部だと思っていた。キャッシュしないと決めた、その裏側にあるものとして。だから「失敗は全部捨てる」と書いたとき、却下の結論まで一緒に捨てていた。経緯を捨てたつもりで、結論まで捨てていた。

さっき、鷹宮さんの手帳の頁の下のほうに、もう一行あった。

ベンチの向こうで、鷹宮さんが、鷹を拳に据えて、メリーゴーラウンドの前を歩いていくところだった。メリーゴーラウンドの屋根には、改装で新しくした電飾の線が、何本も渡してある。その上に、カラスが一羽とまっていた。

鷹が、屋根のほうへ、一度だけ首を向けた。

鷹宮さんは、放さなかった。拳を少し下げて、そのまま歩き続けた。メリーゴーラウンドの前を通りすぎてから、ようやく、売店の屋根のカラスに向けて鷹を放った。

さっき読み流した一行が、メリーゴーラウンド、だったのを思い出した。何と書いてあったのかは、読んでいない。でも、いま見た電飾の線と、鷹宮さんが下げた拳で、見当はついた。あの屋根では、放さない。線に、鷹の脚が絡むから。

あの一行は、失敗の記録じゃない。もうやらない、という決定だ。書いておかなければ、次にここで鷹を放す人は、あの屋根のカラスを見て、放しうる。今朝、鷹宮さんが一度でもそこで放したかどうかは、知らない。知らなくていい。回数は書いていない。書いてあるのは、やめたことと、その理由だけだった。

調べてみると、同じことを書いている人がいた。AI のコーディングエージェントを毎日使っている開発者が、記録の付け方を書いた記事だ(Dan Hopwood「The Memory Bank Framework」)。

Git records what changed but it’s blind to what you decided not to do.

git は、変えたことを記録する。でも、やらないと決めたことは見ていない。何かを検討してやめても、何も変わらないから、コミットは残らない。だから、どこかに書いておかないと、AI は会話を新しくするたびに、同じ案を出してくる。そう書いてあった。実験ではなく、一年使ってきた人の経験として。

公式の「学んだことを入れる」は、こういうことだったんだと思う。やり直した経緯ではなく、学んだことの結論。その結論には、決めたことだけじゃなく、やめたことも入る。

自分は、NOTES.md に却下の行を足した。

最初は、念のため、全部残そうとした。やめたことを残すと決めたのだから、何をやってなぜ駄目だったのかも、そのまま残したほうがいいと思った。五件目の二回目の、キャッシュを一分にしたコードと、そのときのテストのログを、却下の行の下に貼りつけた。

貼りつけてから、乗り場の裏で書いたテストのことを思い出した。却下したコードは、次の会話に渡さない。あのテストは、まだここにある。やめたコードを記録に貼れば、記録は昨夜の会話と同じものになっていく。parseNotes は自由な行を読まないから、貼ったコードは引き継ぎ書には入らない。それでも、記録には残る。記録は、次の会話を始めるたびに自分が開くし、AI に直接読ませることもあるかもしれない。そのたびに、やめたコードがまた目に入る。昨夜の会話の中で、やめたコードが四回、決めたことが一回だったのと同じことが、記録の中で起きる。記録は、次の会話の材料だ。日記じゃない。

まず、記録を読む関数を、やめたことの行も読めるように広げた。notes.ts に、こう書いた。記録の一行の型も、決めたことか、やめたことかを持つようにした。

1
2
// NOTES.md の一行。task は不具合票の番号の文字列
export type Note = { task: string; kind: 'decision' | 'rejected'; what: string; why: string };
1
2
3
4
    const m = line.match(/^- (決定|却下): (.*?)(?: \/ 理由: (.*))?$/);
    if (m && task) {
      notes.push({ task, kind: m[1] === '決定' ? 'decision' : 'rejected', what: m[2].trim(), why: (m[3] ?? '').trim() });
    }

parseDecisions との違いは、この三行だけだ。(決定|却下) は、どちらかの語に合う。合った語が m[1] に入るので、それで kind を決める。中身は m[2]、理由は m[3] にずれる。? : は、条件が真なら前、偽なら後ろの値になる書き方だ。

記録の不備を見つける関数も、notes.ts に足した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 理由が空の行を見つける
export function checkReasons(notes: Note[]): string[] {
  return notes.filter((n) => n.why === '').map((n) => `${n.task}番の${n.kind === 'decision' ? '決定' : '却下'}に理由がない: ${n.what}`);
}

// 記録の不備を見つける。空なら不備なし
// ponytail: 目印だけ(``` ・行頭の ok / not ok ・却下の下の字下げ)。貼られたものを全部見分けるものではない
export function checkNotes(md: string): string[] {
  const problems = checkReasons(parseNotes(md));
  const lines = md.split('\n');
  lines.forEach((line, i) => {
    if (line.startsWith('```')) problems.push(`${i + 1}行目: コードのかたまりがある`);
    if (/^(not ok|ok) /.test(line)) problems.push(`${i + 1}行目: テストのログの行がある`);
    if (i > 0 && lines[i - 1].startsWith('- 却下:') && /^\s+\S/.test(line)) problems.push(`${i + 1}行目: 却下の続きの行がある(結論は1行に収める)`);
  });
  return problems;
}

checkReasons は、理由のない行を見つける。理由のない決定は、もっともらしい別の案と並べられたとき、守る根拠がない。理由のない却下は、状況が変わったときに、外してよいのかどうかを誰も判断できない。たとえば、運休の放送の仕組みが変わって、来園者に知らせが届くのが遅くなったら、一分のキャッシュは許されるのか。理由が「一分あれば乗り場に着く」と書いてあれば、そこから考え直せる。だから、決定にも却下にも理由が要る。

checkNotes は、それに加えて、記録の中のコードのかたまり(行頭の ```)と、テストのログの行(行頭の ok か not ok )と、却下の行の下に字下げして続けた行を見つける。forEach は配列の要素を一つずつ順に処理する書き方で、i は行の番号(0から数える)だ。見つけたら、何行目に何があったかを、説明の一覧に足していく。/^\s+\S/ は、行の頭に空白が一つ以上あって、そのあとに空白でない文字が続く行、つまり字下げした行に合う。コメントの ponytail: は、自分がこのプロジェクトで決めている印で、わざと簡単にしてあるところに付ける。ここでは、目印しか見ていないことを書いてある。貼られたものを全部見分けるものではない。

貼りつけた記録に当ててみた。

1
2
3
4
5
NOTES.md に不備がある:
12行目: コードのかたまりがある
14行目: コードのかたまりがある
15行目: テストのログの行がある
16行目: テストのログの行がある

全部、見つかった。自分は貼ったものを消して、却下を一行に書き直した。鷹宮さんの手帳の一行と、同じくらいの長さに。

1
2
3
## 5 運休した回の整理券が使えるまま残る
- 決定: 運休の操作のあと、その回の整理券はすぐ使えなくする / 理由: 運休の放送から1分あれば、来園者は乗り場に着く
- 却下: キャッシュの時間を短くして間に合わせる / 理由: 何秒にしても、そのあいだは運休のあとの整理券が使える

組み立てる関数も、書き直した。after.ts に、こう書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { checkReasons } from './notes.ts';
import type { Note, Task } from './types.ts';

// 引き継ぎ書を、会話ではなく記録から組み立てる。会話(Turn[])は受け取らない
export function buildHandoff(task: Task, notes: Note[]): string {
  const problems = checkReasons(notes);
  if (problems.length > 0) throw new Error(`記録に不備がある:\n${problems.join('\n')}`);
  const line = (n: Note) => `- ${n.what}(${n.why})`;
  return [
    `# 引き継ぎ: ${task.id} ${task.goal}`,
    '## このタスク',
    task.goal,
    '## 決めたこと(理由つき)',
    ...notes.filter((n) => n.kind === 'decision').map(line),
    '## やめたこと(もう試さない)',
    ...notes.filter((n) => n.kind === 'rejected').map(line),
    '## 範囲外',
    ...task.outOfScope.map((s) => `- ${s}`),
    '## 次の一手',
    task.next,
    '',
  ].join('\n');
}

決めたことの節の下に、やめたことの節を足した。理由のない行が一つでもあれば、throw で例外を投げて、組み立てない。例外は、呼んだ側の処理をそこで打ち切って、何が起きたかを知らせる仕組みだ。コードのかたまりやログの行は、記録のファイルを読んだところで、checkNotes が先にはじく(あとで書く handoff.ts がそうする)。どちらかに引っかかった記録からは、引き継ぎ書を作らない。

そして、この関数は、会話を受け取らない。引数は、タスクと、記録だけだ。

テストを一本足した。やめたことの結論を、理由つきで、一行ずつ渡すこと。

1
2
3
4
test('却下の結論を、理由つきで1行ずつ渡す', () => {
  assert.ok(handoff.includes(REJECTED_LINE));
  assert.equal(countOccurrences(handoff, REJECTED_LINE), 1);
});

REJECTED_LINE は、引き継ぎ書に出るはずの一行(「- キャッシュの時間を短くして間に合わせる(何秒にしても、そのあいだは運休のあとの整理券が使える)」)だ。同じテストを、さっきの決めたことだけの組み立てに向けると、赤になった。やめたことの節が、そもそもないからだ。新しい buildHandoff に向けると、緑になった。

もう一本、確かめておきたいことがあった。昨夜の七件のうち、一件目から四件目と六件目は、失敗せずに一回で直った。AI が正しく仕事をした作業だ。そういう作業では、会話を続けていたときに AI が読んでいたものと、引き継ぎ書とで、決めたことは同じく入っていてほしい。違うのは、捨てるもの、つまりやり直しの経緯とログとやめたコードだけのはずだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
test('失敗のないタスクだけの夜では、決めたことは会話にも引き継ぎ書にも同じく入る', () => {
  const before = contextBefore(quietConversation());
  const after = buildHandoff(task5, parseNotes(notesQuiet));
  const decisions = parseNotes(notesQuiet).filter((n) => n.kind === 'decision');
  assert.equal(decisions.length, 5);
  for (const d of decisions) {
    assert.ok(before.includes(d.what), `会話: ${d.what}`);
    assert.ok(after.includes(d.what), `引き継ぎ書: ${d.what}`);
  }
});

quietConversation() は、失敗のなかった五件だけの会話で、notesQuiet は、その五件の記録だ。タスクに task5 を渡しているのは、どれでもいいからで、ここで見ているのは決めたことの節だけだ。五件の決めたことが、どれも会話のほうにも、引き継ぎ書のほうにも入っている。会話を区切っても、正しくやった仕事の決めたことは、一つも落ちない。

ほかにも、引き継ぎ書にテストのログの行や、五件目の一回目・二回目の AI の説明の文が入らないこと、範囲外と次の一手が入ることを、テストにした。

自分は、メモの新しい行に書いた。

引き継ぎ書を、会話から作らない。会話を詰めた要約からも作らない。自分が書き留めた記録から作る。持っていくのは、決めたことと、やめたこと。どちらにも、理由をつける。何回やり直したかは、持っていかない。それは git と、空にする前の会話の中にある。

AI に、昨夜の会話を要約させて、引き継ぎ書を書かせることもできる。でもそれでは、何を残すかを決めるのが、また AI のほうになる。要約して詰めるのと、同じことだ。だから、記録は自分で書く。

昨夜の会話のまま頼む道と、記録から組み立てて新しい会話へ渡す道を、並べて描いてみた。

次の会話に何が入るかを、二つの道で比べた流れ図。一つ目の道では、灰の点線の札「昨夜は七件を一つの会話で続け、依頼もやり直しもログも同じ会話に積み重なった。」から橙の破線矢印が出る。矢印は橙の破線の札「会話が長くなると道具が自動で要約するが、何を残すかを決めるのは要約のほうだ。」を通り、灰の点線の札「その夜の会話をつなぐと、やめたコードの目印は4回、決めたことの文は1回出てくる。」に行き着く。二つ目の道では、紺の矢印が順につながる。紺の札「自分が、票ごとに決めたこととやめたことを、理由つきで一行ずつNOTES.mdに書く。」から、紺で塗った「checkNotesが、理由のない行やコードのかたまり、テストのログの行を見つけたら、組み立てを止める。」、紺の札「buildHandoffが、会話は受け取らず、記録とタスクだけから引き継ぎ書を組み立てる。」、紺の札「/clearで会話を空にして、新しい会話に@HANDOFF.mdを読ませる。」を経て、橙の破線の札「新しい会話のAIが、引き継ぎ書を読んでコードを直す。」へ進む。最後の札の横には、灰の点線の札「AIが前の修正を戻さなくなるかどうかは、テストでは確かめていない。」が点線で添えてある

記録から渡す道で、自分が書くのは記録だけだ。そこから先は決まった手順で組み立てて渡し、記録が崩れていれば途中で止まる。破線から先、AI が引き継ぎ書を読んでどう直すかは、テストの外にある。

会話を空にした。新しい会話で、直した記録から組み立てた引き継ぎ書を読ませて、もう一度、五番を頼んだ。AI は、引き継ぎ書のやめたことの節を読んで、整理券の読み込みをキャッシュから外し、毎回サーバーから読む形に戻した。今朝のこの一回は、そうだった、というだけだ。

続けて、会話をもう一度空にした。七件目の引き継ぎ書を組み立てて、新しい会話で、待ち時間の画面を速くする件を頼んだ。AI は、待ち時間だけをキャッシュに通し、整理券は毎回読む形のまま、画面を速くした。これも、このときの一例だ。

売店の屋根のカラスがいなくなっていた。鷹宮さんは、観覧車のほうへ、もう一度、鷹を放った。

第4幕: 検める ── 毎回、書き直す

テストを、全部走らせた。

1
2
3
ℹ tests 28
ℹ pass 28
ℹ fail 0

全部、緑だった。

テストが本当に効いているかを確かめるために、仕掛けを一つずつわざと壊してみた。壊したのに緑のままなら、そのテストは何も見ていない。組み立てる関数がやめたことの行を読まないようにする。会話も一緒につなぐようにする。記録の不備を見つける関数が、何を渡されても空の一覧を返すようにする。理由の区切りを無視して、行全体を決めたことの文にしてしまう。決めたことを、最後の票の分だけにする。どれを壊しても、そのたびに赤が出た。壊したところは、元に戻した。

自分は、鷹宮さんと一緒に入口のほうへ戻った。八時半すぎに、大槻さんが事務所から出てきた。何も言わずに、発券機の一台の前にしゃがんで、点検用の画面を出した。運休のボタンを押す。その回の整理券の番号を読ませる。画面に「無効」と出た。

大槻さんは、五番の票の、赤い「再発」の横に、ボールペンで何か短く書き足した。何と書いたのかは、自分の位置からは見えなかった。大槻さんは票を自分に返して、事務所へ戻っていった。

自分は、ゲートの脇の植え込みの縁に座って、記録から引き継ぎ書を書き出すスクリプトを仕上げた。handoff.ts という名前にした。ファイルの先頭に、この組み立てが確かめていないことを、コメントで並べた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 走らせ方: node handoff.ts 7
// カレントディレクトリの NOTES.md を読み、HANDOFF.md を書き出す。使ったら消す。
//
// これが確かめていないこと
// - 組み立てるのは NOTES.md の決まった形の行だけ。会話の中で決めたのに書き忘れたことは渡らない
// - AI が引き継ぎ書を読んで、前の修正を戻さなくなること
// - 記録の中身が正しいこと、決めたことが今も有効なこと
// - 本物の Claude Code が引き継ぎ書をどう読むか(自作の fixture と自作の関数が組み立てる文字列の形まで)
import { readFileSync, writeFileSync } from 'node:fs';
import { buildHandoff } from './after.ts';
import { checkNotes, parseNotes } from './notes.ts';
import { tasks } from './tasks.ts';

const id = process.argv[2] ?? '';
const task = tasks[id];
if (!task) {
  console.error(`タスク ${id} は tasks.ts にない`);
  process.exit(1);
}
const md = readFileSync('NOTES.md', 'utf8');
const problems = checkNotes(md);
if (problems.length > 0) {
  console.error(`NOTES.md に不備がある:\n${problems.join('\n')}`);
  process.exit(1);
}
writeFileSync('HANDOFF.md', buildHandoff(task, parseNotes(md)));

process.argv[2] は、コマンドの後ろに書いた最初の引数だ。readFileSync('NOTES.md', 'utf8') は、ファイルを文字列として読む('utf8' は文字の符号の種類)。node handoff.ts 5 と打てば '5' が入り、tasks.ts から五件目のタスクを選ぶ。記録に不備があれば、checkNotes の説明を出して、終了コード1で終わる。終了コードは、プログラムが終わるときに返す数で、0なら成功、0以外なら失敗だ。不備がなければ、HANDOFF.md を書き出す。

1
node handoff.ts 5

コメントに書いたことを、もう一度、自分の言葉で並べておく。

この引き継ぎ書を渡せば、AI が前の修正を戻さなくなる、とは言えない。今朝、直した引き継ぎ書で始めた二つの会話で、AI が整理券をキャッシュに戻さなかったのは、このときの一例だ。AI の振る舞いは、毎回同じとは限らない。

記録の中身が正しいことも、確かめていない。決めたことが間違っていれば、間違いをそのまま渡す。書き忘れた決定は、渡らない。会話の中で決めたのに記録に書かなかったことは、引き継ぎ書には入らない。書いたのは自分で、確かめるのも自分だ。

テストが確かめているのは、自作の fixture に対して、自作の関数が組み立てる文字列の形までだ。本物の Claude Code が引き継ぎ書をどう読むかは、確かめていない。/clear や @ の扱いが改訂で変われば、この手順は黙って効かなくなりうる。自動の圧縮の要約に何が残るかも、この記事では再現していない。fixture の頭には、参照した公式のページと日付を書いた。

スピーカーから、ざらついた音がして、放送の係の人が、マイクのテストを始めた。開門の放送のテストまで、あと二十分だ。

鷹宮さんが、鷹を拳に据えたまま、腰の袋の口を締めていた。次の現場へ行く支度らしかった。

自分は、植え込みの縁から腰を上げた。

「書いた紙、次の会話でも使い回していいんですか。前のを書き足して」

鷹宮さんは、手帳を閉じかけた手を止めた。

「毎回、書き直す」

「毎回?」

「現場ごとに、頁を新しくする」

それだけだった。理由は足さなかった。

毎回、書き直す。

HANDOFF.md は、育てていくファイルにしない。新しい会話を始めるたびに、記録から組み立て直して、使ったら消す。前の引き継ぎ書を書き足して使い回すと、いつのまにか、もう効いていない決定や、終わったタスクの次の一手が混ざってくる。組み立て直せば、そのたびに、記録のいまの中身だけが入る。

記録をそのまま @NOTES.md で読ませないのは、組み立てる途中で三つのことをしているからだ。自由に書いたメモを落とす。範囲外と次の一手を足す。理由の抜けや、貼ったコードをはじく。

指示書の CLAUDE.md に、HANDOFF.md を読み込ませる手もある。CLAUDE.md の中に @ でファイル名を書くと、そのファイルも一緒に読み込まれる。でも、公式のページに、それに当てはまる一文があった。

Imports help you organize a long file but don’t reduce its context cost, because imported files also load at launch.

読み込ませたファイルも、会話を始めるときに毎回一緒に載る。それでは、使い捨てにならない。終わったタスクの引き継ぎ書が、関係のない次の会話にまで、ずっとついてくる。

ただ、ずっと守る決まりもある。整理券の状態はキャッシュしない。これは、五番の票が片づいても、来年も再来年も変わらない。そういうものだけは、指示書に書く。指示書は、毎回の会話の始めに読み込まれる。公式のページの言葉では、こうだ。

Both are loaded at the start of every conversation.

(「Both」は、指示書の CLAUDE.md と、Claude Code が自分で書き残す記憶のことだ。2026年10月18日時点の公式の説明。)

指示書に、一行だけ足した。

1
- 整理券の状態はキャッシュせず、毎回読む(運休のあと、その回の整理券をすぐ使えなくするため。キャッシュの時間を縮めて間に合わせるのは、何秒でも駄目)

指示書は、長くすると守られなくなる。だから、ずっと守るものだけを短く足す。作業の途中のことは、記録に書いて、引き継ぎ書で渡して、終わったら消す。決めたことが変わったら、記録を直すのは自分だ。

昨夜の会話に出てきたものを、どこへ持っていくかで分けた。

昨夜の会話の中身を、行き先ごとに分けた図。上の灰の点線の札「昨夜の七件の会話には、決めたこと、やめたこと、やり直しの経緯、ログ、やめたコードが混ざっている。」から、紺の矢印が三つに分かれる。一つ目は紺の札「ずっと守る決まりだけは、指示書のCLAUDE.mdに一行で足す。」へ向かう。二つ目は紺の札「このタスクの決めたことと、やめたことの結論は、理由つきで一行ずつ記録に書く。」へ向かい、そこから紺の札「引き継ぎ書は、会話を始めるたびに記録から組み立て直し、使ったら消す。」へ続く。記録の札の横には、灰の点線の札「決めたことだけを渡した新しい会話では、AIが整理券のキャッシュを十秒に縮める案を出した。」が点線で添えてある。三つ目は紺で塗った「何回やり直したか、テストのログ、やめたコードは、次の会話へ持っていかない。」へ向かい、その下に灰の点線の札「持っていかないものは、gitと、空にする前の会話の中に残っている。」が点線で添えてある。指示書の札と引き継ぎ書の札からは紺の矢印が下りて、橙の破線の札「新しい会話のAIが、指示書と引き継ぎ書を読んで、次のタスクを始める。」に集まる

持っていく先は二つで、どちらも書くのは自分だ。持っていかないものも、消えたわけではない。要るときは、git と前の会話を開けばいい。

1タスク1セッションは、いつでも守る決まりでもない。公式のページにも、こうある。

Sometimes you should let context accumulate because you’re deep in one complex problem and the history is valuable.

一つの込み入った問題の途中なら、会話を積み重ねたほうがいいこともある。そのやりとり自体に価値があるからだ。でも、昨夜の七件は、関係のない七つのタスクだった。次からは、票一枚ごとに会話を一つにする。そして、昨夜の五件目のように、一つの票で二回直して直らなかったら、そこでも会話を空にして、学んだことを記録に書いてから始め直す。

鷹宮さんは、手帳の今日の頁を、根元から破り取った。

破った頁を裏返して、そこにボールペンで何か書き始めた。立ったまま、手帳を下敷きにして。四行、書いたように見えた。書き終えると、その一枚を、自分に差し出した。

「どうぞ」

自分は受け取った。鷹宮さんは、手帳の次の頁を開いて、いちばん上に何か書きながら、通用口のほうへ歩いていった。拳の上で、鷹が一度だけ羽を広げて、またたたんだ。次の現場の名前を書いていたんだと思う。読めなかった。

自分は、発券機の横のカウンターに、その一枚を置いた。

表には、今朝の記録が残っていた。フードコートの屋根。追い払った。観覧車の骨組み。追い払った。売店の屋根。追い払った。その下に、メリーゴーラウンドの屋根。放さない。電飾の線。

裏に、四行。

表と裏を、二回ずつ見比べた。表には、やったことと、やめたことが書いてある。裏には、それをどう任せるかが書いてある。表のどこにも、屋根に二回行かれたことは書いていなかった。

裏の、四行目の下が、一行ぶん空いていた。自分は、ボールペンで、そこに書いた。

スピーカーから、開門の放送のテストが流れ始めた。本日はご来園ありがとうございます。誰もいない入口に向かって、放送の声が、まっすぐ伸びていった。

放鷹カード

  • 合図: 1タスク1セッション。票一枚ごとに会話を一つ。始めるときに、記録から組み立てた引き継ぎ書を読ませる
  • 検め方: 引き継ぎ書に、決めたことと、やめたことの結論が、理由つきで一行ずつ入っているか。やり直しの経緯、ログ、やめたコードが入っていないか
  • 止めないもの: 決めたことと、その理由は、一つも落とさずに次の会話へ渡す。やめたことも、結論と理由を一行で渡す
  • 鷹のせいじゃなかった: 七件を一つの会話で続けた。要約されたなら整理された、と思った。会話を区切ったあとは、やめたことまで捨てた
  • 自分の言葉で: 持っていかないのは、何回やったか。持っていくのは、何をやめたか。引き継ぎ書は、会話を始めるたびに記録から組み立て直す
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource へ
Hugo で構築されています。
テーマ Stack は Jimmy によって設計されています。