throughline 0.15.5 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,44 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.1] — 2026-10-07
14
+
15
+ ### 修正
16
+
17
+ - Claudeのターンの途中で会話記録(transcript)のフォルダが消され、Claude Codeが残りの行だけでファイルを作り直した時、Stopのhookが`Claude Stop transcript completion was not visible before deadline`で失敗し、`HOOK_PROCESS_TURN_FAILED`を数えていたのを直す([ADR 0038](docs/adr/0038-stop-flush-barrier-head-lost-transcript.md))。期限まで待って、transcriptに応答だけがあり利用者の発言が1つも無い時は、失敗に数えず、`backfill.log`に`skipped: "transcript_head_lost"`を残して終わる。ファイルが残らなかった時(`transcript_absent`、[ADR 0029](docs/adr/0029-stop-flush-barrier-absent-transcript.md))と同じ扱いにする。
18
+ - 利用者の発言が残っているのに完了が見えない時は、今までどおり失敗に数える。
19
+
20
+ ### 確認した範囲
21
+
22
+ - Linuxのコンテナ(0.15.5、Claude Code 2.1.292)で2026-10-07に起きた失敗の、transcriptとStopのpayloadを切り離した置き場で再生した。0.15.5は同じ文面で失敗して1回数え、この版は失敗に数えず`transcript_head_lost`を記録した。
23
+
24
+ ### 確認していない範囲
25
+
26
+ - 同じ朝に同じ試験の道具で数えられた残り11回の失敗は、transcriptが後で消されていて、同じ形だったかを直接は見ていない。11回とも、その会話の最後のStopで起きている。
27
+ - macOSとWindowsでは、この形の失敗を再現していない(足した試験はCIの3環境で走る)。
28
+
29
+ ## [0.16.0] — 2026-10-07
30
+
31
+ ### 変更
32
+
33
+ - 自動継続が立てる後継に、`<project 名>|<作業の概要>(自動引き継ぎ)`の名前を付ける([ADR 0037](docs/adr/0037-auto-handoff-successor-title.md))。一覧を見た時に、どのprojectの何の作業の続きかを読めるようにする。
34
+ - Claude Code: 今までの名前は`tl-<フォルダ名>-<引き継ぎIDの先頭8桁>`で、Claude Desktopの一覧にもそのまま出ていた。作業の概要は前任の会話の題(Claude Desktopの題、人が付けた題、Claude Codeが付けた題の順)から取り、題が無ければ止めた時点の依頼の最初の行を使う。
35
+ - Codex: 今までは後継タスクに名前を付けておらず、継続の指示(`Throughline自動継続 <ID> / <ID>`)がそのまま題として見えていた。前任のタスクの題を概要にして、後継を作った直後に`thread/name/set`で付ける。前任がこの版より前に立てた名前の無い後継なら、引き継ぎの記録をさかのぼって元のタスクの題を使う。
36
+ - 概要は40字で切る。引き継ぎを重ねても、project名と印は積み重ならない。AIは呼ばない。
37
+ - Codexで名前を付けられなかった時も、引き継ぎは止めない(理由はworkerのログに残る)。
38
+
39
+ ### 確認した範囲
40
+
41
+ - Linux(Claude Code 2.1.292、Haiku)の切り離した置き場で、本物の会話に自動圧縮と道具のhookを渡した。後継は`p1|read file fruit colors(自動引き継ぎ)`の名前で立ち、継続の指示を受け取った。その後継からもう一度引き継ぐと、3つ目も同じ名前になった。
42
+ - Linux(Codex 0.160.1)のapp-serverで、新しいスレッドに最初のターンの前に付けた名前が、ターンの後も残る。
43
+ - 公開するpackageを展開して、本物のworkerから後継を立て、`claude agents`に出る名前が渡した名前と一致することを3環境で見た(題は日本語・全角の記号・`&`・二重引用符を含む): Linux(Claude Code 2.1.292)、macOS(2.1.289)、Windows 11(2.1.292、PowerShellのshim経由、Node 24.20.0)。
44
+ - macOSのCodex Desktopに同梱のCodex(0.160.1)が`thread/name/set`を持つ。
45
+
46
+ ### 確認していない範囲
47
+
48
+ - Codex Desktopの実機での自動継続の通し(後継タスクが一覧に新しい名前で出るところ)。確かめたのは、同じ版のCodexのapp-serverで名前が付いて残ることまで。
49
+ - Claude Desktopの一覧に新しい名前が出るところ。今までの名前(`tl-…`)がDesktopの題としてそのまま出ていたことは、macOSの記録で見ている。
50
+
13
51
  ## [0.15.5] — 2026-10-07
14
52
 
15
53
  ### 修正
@@ -1854,7 +1892,9 @@ two attempts, instrument first instead of patching again.
1854
1892
 
1855
1893
  ---
1856
1894
 
1857
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.5...HEAD
1895
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.1...HEAD
1896
+ [0.16.1]: https://github.com/kitepon/Throughline/compare/v0.16.0...v0.16.1
1897
+ [0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
1858
1898
  [0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
1859
1899
  [0.15.4]: https://github.com/kitepon/Throughline/compare/v0.15.3...v0.15.4
1860
1900
  [0.15.3]: https://github.com/kitepon/Throughline/compare/v0.15.2...v0.15.3
package/README.ja.md CHANGED
@@ -29,7 +29,7 @@ throughline auto-handoff disable
29
29
 
30
30
  既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktopで実機確認済みです。VS Code・CLI・他OSの自動継続は未検証です。
31
31
 
32
- 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
32
+ 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
33
33
 
34
34
  記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
35
35
 
@@ -58,7 +58,7 @@ throughline auto-handoff disable --host claude
58
58
 
59
59
  1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
60
60
  2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
61
- 3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。モデル・推論強度・権限は旧い会話から引き継ぎます。
61
+ 3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
62
62
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
63
63
 
64
64
  続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Desktopへ自動で表示はしません。Desktopで開く時は、作業完了を確認して`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
package/README.md CHANGED
@@ -28,7 +28,7 @@ throughline auto-handoff disable
28
28
 
29
29
  既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktopで実機確認済みです。VS Code・CLI・他OSの自動継続は未検証です。
30
30
 
31
- 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
31
+ 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
32
32
 
33
33
  記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
34
34
 
@@ -57,7 +57,7 @@ throughline auto-handoff disable --host claude
57
57
 
58
58
  1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
59
59
  2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
60
- 3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。モデル・推論強度・権限は旧い会話から引き継ぎます。
60
+ 3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
61
61
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
62
62
 
63
63
  続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Desktopへ自動で表示はしません。Desktopで開く時は、作業完了を確認して`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
@@ -0,0 +1,47 @@
1
+ # ADR 0037: 自動継続の後継に「project 名|作業の概要(自動引き継ぎ)」の名前を付ける
2
+
3
+ 日付: 2026-10-07
4
+
5
+ ## Context
6
+
7
+ 自動継続が立てる後継は、一覧で何の project の何の続きかを読めなかった(2026-10-07、オーナーの指摘)。
8
+
9
+ - Claude Code: 後継を `claude --bg --name tl-<フォルダ名>-<引き継ぎ ID の先頭8桁>` で立てていた。Claude Desktop の一覧にも
10
+ この名前がそのまま題として出る(macOS で実測: `tl-tl-claude-probe-3eacc0af`)。作業の中身は名前に入らない。
11
+ - Codex: 後継タスクに名前を付けていなかった。Codex は名前の無いタスクを最初の user 発言で表示するので、継続の指示
12
+ (`Throughline自動継続 <引き継ぎ ID> / <配送 ID>`)がそのまま題として見える。macOS の実機で、自動継続が立てた
13
+ 後継4つに名前が無いことを `session_index.jsonl` で確かめた。project 名も作業の中身も読めない。
14
+
15
+ 前任の題は host が既に持っている。
16
+
17
+ - Claude Code は会話記録に題を書く。端末の会話は `ai-title`(Claude Code が付ける。変わるたびに行が増える)、
18
+ Claude Desktop の会話と人が名前を変えた会話は `custom-title`。`--name` で立てた会話も `custom-title` に入り、
19
+ `ai-title` は付かない(Linux 2.1.292、macOS の記録で確認)。
20
+ - Codex は `thread/read` が `name`(Desktop が付けた題、または人が付けた題)と `preview`(最初の user 発言)を返す。
21
+ 自動継続は、前任の状態を読む時にこの値を既に取っている(`runtime.title`)。名前は `thread/name/set` で付けられ、
22
+ 最初のターンの前に付けた名前はターンの後も残る(Linux 0.160.1 で確認)。書かれるのは `session_index.jsonl` だけで、
23
+ rollout には何も足されない。
24
+
25
+ ## Decision
26
+
27
+ 1. 後継の名前を `<project 名>|<作業の概要>(自動引き継ぎ)` にする。project 名は project の場所の最後のフォルダ名。
28
+ 概要が取れない時は `<project 名>(自動引き継ぎ)`。形は `src/auto-handoff-title.mjs` に1か所だけ置く。
29
+ 2. 作業の概要は前任の題から取る。AI は呼ばない(引き継ぎを遅らせず、後継を立てる前に失敗する所を増やさない)。
30
+ - Claude Code: 最後の `custom-title`、無ければ最後の `ai-title`、どちらも無ければ止めた時点の依頼の最初の行。
31
+ - Codex: 前任の `name`、無ければ `preview`。
32
+ 3. 概要は最初の行だけを1行にして、40字で切る。制御文字と二重引用符は落とす(名前は起動の引数として渡す。
33
+ Windows は PowerShell の shim を通る)。
34
+ 4. 前任がこの形の名前を持つ後継なら、中の概要だけを取り出して使う。引き継ぎを重ねても project 名と印は
35
+ 積み重ならない。継続の指示は概要にしない。Codex で前任が名前の無い後継(この版より前に立てたもの)の時は、
36
+ 引き継ぎの記録をさかのぼって元のタスクの題を使う。
37
+ 5. Claude Code へは `--name=<名前>` の形で渡す。フォルダ名が `-` で始まっても option として読まれない。
38
+ 6. Codex は、後継を作った直後、記憶を注入する前に `thread/name/set` を呼ぶ。名前は表示だけに使うので、
39
+ 断られても引き継ぎは止めず、worker のログに理由の code を残す。
40
+
41
+ ## Consequences
42
+
43
+ - 名前に引き継ぎ ID が入らなくなる。後継の ID は今までどおり記録(Claude は `successor.short_id`、Codex は
44
+ `target_thread_id`)と `auto-handoff status` にある。Throughline は名前で後継を探していない。
45
+ - 同じ会話から引き継ぎを重ねると、後継は同じ名前になる。一覧では新しい順で見分ける。
46
+ - 概要は前任の題を写すだけなので、題が作業の中身からずれていれば、名前もずれる。
47
+ - 記録の schema は変えない。
@@ -0,0 +1,53 @@
1
+ # ADR 0038: 始まりを失った transcript の Claude Stop は、hook 処理の失敗に数えない
2
+
3
+ 日付: 2026-10-07
4
+
5
+ ## Context
6
+
7
+ 2026-10-07 08:24:17 JST、BellTeam コンテナ(0.15.5)で、解決登録していた `HOOK_PROCESS_TURN_FAILED` が再発した
8
+ (7回目)。理由は `Claude Stop transcript completion was not visible before deadline`。
9
+
10
+ 失敗した会話は、`/tmp/ag-fvIcWA/proj` で動いた Claude Code(2.1.292)の試験用の会話。1つ目のターンの Stop は
11
+ 同じ会話の transcript を読んで 1 turn を保存している(08:23:59)。2つ目のターンの Stop が失敗した。
12
+
13
+ 残っていた transcript は 10 行で、最初の行は 08:24:08.262 の assistant(thinking だけ)、次が 08:24:15.621 の
14
+ 最後の応答だった。user の行は1つも無い。同じファイルの `turn_duration` は `messageCount: 29` と書いている。
15
+ transcript を置くフォルダ(`~/.claude/projects/-tmp-ag-fvIcWA-proj`)の作成時刻は 08:24:15.744 で、
16
+ 会話の作業フォルダ(`/tmp/ag-fvIcWA/proj`)は 08:24:15.861 に消されている。
17
+
18
+ 会話を起動した試験の道具は、回答が端末に出た直後に端末を閉じ、transcript のフォルダと作業フォルダを消す。
19
+ Claude Code は最後の応答の行(thinking と本文)をその後で書くので、フォルダごとファイルを作り直す。
20
+ Stop hook は応答の後に非同期で走り、応答だけのファイルを読んだ、と読める。
21
+
22
+ 同じ道具は同じ朝に続けて動き、08:24〜08:33 の間に同じ文面の失敗が 12 回数えられた(7回目〜18回目)。
23
+ transcript が残っていたのは最初の1回だけで、残りの11回は後でファイルが消されている。11回とも、
24
+ その会話の最後の Stop で、1つ前の Stop は保存できている。
25
+
26
+ ファイルが残らなかった時は ADR 0029 の `transcript_absent` になり、失敗に数えない。今回はファイルが
27
+ 作り直されていたので、ADR 0029 Decision 3(ファイルがあるのに完了が見えない時は失敗)に当たった。
28
+ どちらも「保存する元が、会話を起動した側に消された」で、違いは Claude Code が残りを書いた時刻だけ。
29
+
30
+ Throughline はターンを user の発言から数える(`readLatestLogicalTurnCompletions`)。user の発言が1つも無い
31
+ transcript からは、どのターンも作れない。Claude Code は会話の最初に必ず user の行を書くので、
32
+ assistant の発言があって user の発言が無い transcript は、始まりを失った物に限られる。
33
+
34
+ 控えた transcript と同じ Stop の payload を、切り離した置き場で再生した。0.15.5 は同じ文面で失敗し、
35
+ `HOOK_PROCESS_TURN_FAILED` を1回数えた。
36
+
37
+ ## Decision
38
+
39
+ 1. flush barrier は、期限まで待っても完了が見えず、transcript に assistant の発言があって user の発言が
40
+ 1つも無い時、`transcript_head_lost` を返す。失敗にしない。
41
+ 2. 確認は期限に達した時だけ行う。遅れて書かれる user の行は、今までどおり期限まで待つ。
42
+ 3. user の発言が1つでも残っている時と、発言が1つも無いファイル(書き始め)は、今までどおり明示失敗して
43
+ `HOOK_PROCESS_TURN_FAILED` を数える(ADR 0012 Decision 4、ADR 0029 Decision 3)。
44
+ 4. `transcript_head_lost` の Stop は、ADR 0029 Decision 4 と同じ扱いにする。`backfill.log` に
45
+ `{"hook":"stop",…,"skipped":"transcript_head_lost"}` を1行残し、終了 code 0 で終わる。
46
+ DB・state file・完了受領・runtime error store には何も書かない。
47
+
48
+ ## Consequences
49
+
50
+ - 始まりを消された会話の最後のターンは保存されない。保存する依頼がどこにも無いので、今までも保存できていない。
51
+ 変わるのは、失敗に数えるかどうかだけ。
52
+ - 見送った Stop は `backfill.log` の `skipped` で数えられる。
53
+ - transcript の途中だけが失われ、user の発言が残っている時は、今までどおり失敗に数える。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.15.5",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 自動継続が立てる後継(Claude の会話、Codex のタスク)の名前。
3
+ *
4
+ * 形は「project 名|作業の概要(自動引き継ぎ)」。一覧を見た時に、どの project の、何の作業の続きかを読める
5
+ * ようにする。作業の概要は前任の題から取り、AI は呼ばない。前任の題が取れない時は project 名と印だけにする。
6
+ */
7
+
8
+ export const AUTO_HANDOFF_TITLE_MARK = '自動引き継ぎ';
9
+ const SEPARATOR = '|';
10
+ const SUFFIX = `(${AUTO_HANDOFF_TITLE_MARK})`;
11
+ // 一覧の幅で末尾の印が切れにくい長さに、概要を抑える。
12
+ const SUMMARY_MAX_CHARS = 40;
13
+ // 継続の指示(Claude・Codex とも、この語と引き継ぎ ID で始まる)。名前の付いていない後継は、これが題として見える。
14
+ const CONTINUATION_INPUT_PATTERN = /^Throughline自動継続 [0-9a-f]{8}-/;
15
+
16
+ /** project の場所から、名前に使うフォルダ名を取る。Windows の場所を他の OS で読んでも同じ値にする。 */
17
+ export function autoHandoffProjectName(projectPath) {
18
+ if (typeof projectPath !== 'string') return '';
19
+ return oneLine(projectPath.split(/[\\/]+/).filter(Boolean).at(-1) ?? '');
20
+ }
21
+
22
+ function oneLine(text) {
23
+ // 起動の引数として渡すので、制御文字と改行を落とす。二重引用符は Windows の引数の受け渡しで崩れるので落とす。
24
+ return text.replace(/[\u0000-\u001f\u007f"]+/g, ' ').replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ function clip(text, maxChars) {
28
+ const chars = Array.from(text);
29
+ return chars.length <= maxChars ? text : `${chars.slice(0, maxChars - 1).join('').trimEnd()}…`;
30
+ }
31
+
32
+ /**
33
+ * 前任の題や依頼から、作業の概要を取る。前任がこの形の名前を持つ後継なら、中の概要だけを取り出す
34
+ * (引き継ぎを重ねても、project 名と印が積み重ならない)。概要にならない値は null。
35
+ */
36
+ export function autoHandoffSummaryOf(title) {
37
+ if (typeof title !== 'string') return null;
38
+ const firstLine = title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '';
39
+ if (!firstLine || CONTINUATION_INPUT_PATTERN.test(firstLine)) return null;
40
+ if (firstLine.endsWith(SUFFIX)) {
41
+ const body = firstLine.slice(0, -SUFFIX.length);
42
+ const at = body.indexOf(SEPARATOR);
43
+ const inner = at < 0 ? '' : body.slice(at + SEPARATOR.length).trim();
44
+ return inner ? clip(inner, SUMMARY_MAX_CHARS) : null;
45
+ }
46
+ return clip(firstLine, SUMMARY_MAX_CHARS);
47
+ }
48
+
49
+ /**
50
+ * 後継の名前を作る。
51
+ * @param {{projectPath: string, titles?: Array<string|null|undefined>}} input titles は概要の候補(先頭から使えるものを採る)
52
+ */
53
+ export function composeAutoHandoffTitle({ projectPath, titles = [] }) {
54
+ const project = autoHandoffProjectName(projectPath);
55
+ const summary = titles.map(autoHandoffSummaryOf).find(Boolean) ?? null;
56
+ return [project, summary].filter(Boolean).join(SEPARATOR) + SUFFIX;
57
+ }
@@ -0,0 +1,50 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { autoHandoffProjectName, autoHandoffSummaryOf, composeAutoHandoffTitle } from './auto-handoff-title.mjs';
4
+
5
+ test('名前は「project 名|作業の概要(自動引き継ぎ)」の形にする', () => {
6
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/Users/kite/Developer/BellTeam', titles: ['ASCからの連絡を確認'] }),
7
+ 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
8
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [null, '', ' ', '2つ目の候補'] }),
9
+ 'app|2つ目の候補(自動引き継ぎ)', '使える最初の候補を採る');
10
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app' }), 'app(自動引き継ぎ)', '概要が取れない時は project 名と印だけ');
11
+ });
12
+
13
+ test('project 名は場所の最後のフォルダ名。Windows の場所を他の OS で読んでも同じ', () => {
14
+ assert.equal(autoHandoffProjectName('C:\\Users\\kite_\\Developer\\Throughline'), 'Throughline');
15
+ assert.equal(autoHandoffProjectName('C:\\Users\\kite_\\tl-claude-probe\\'), 'tl-claude-probe');
16
+ assert.equal(autoHandoffProjectName('/srv/bellteam/bots/bot-220e0f1b/'), 'bot-220e0f1b');
17
+ assert.equal(autoHandoffProjectName(null), '');
18
+ });
19
+
20
+ test('前任がこの形の名前を持つ後継なら、中の概要だけを取り出す', () => {
21
+ const first = composeAutoHandoffTitle({ projectPath: '/work/app', titles: ['hookの失敗を直す'] });
22
+ assert.equal(autoHandoffSummaryOf(first), 'hookの失敗を直す');
23
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [first] }), first, '引き継ぎを重ねても同じ名前');
24
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/work/moved', titles: [first] }), 'moved|hookの失敗を直す(自動引き継ぎ)',
25
+ 'project 名は今の場所から付け直す');
26
+ assert.equal(autoHandoffSummaryOf('app(自動引き継ぎ)'), null, '概要の無い名前からは、概要を作らない');
27
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: ['app(自動引き継ぎ)', '元の依頼'] }),
28
+ 'app|元の依頼(自動引き継ぎ)');
29
+ });
30
+
31
+ test('継続の指示は概要にしない(名前の無い後継は、これが題として見える)', () => {
32
+ const claude = 'Throughline自動継続 89903aed-a0a8-486e-9932-a10ce526d2d5\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。';
33
+ const codex = 'Throughline自動継続 89903aed-a0a8-486e-9932-a10ce526d2d5 / b90f43ab-7056-4216-9737-a7430a6768ca\n注入された記憶と…';
34
+ assert.equal(autoHandoffSummaryOf(claude), null);
35
+ assert.equal(autoHandoffSummaryOf(codex), null);
36
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [codex, 'ASCからの連絡を確認'] }),
37
+ 'app|ASCからの連絡を確認(自動引き継ぎ)');
38
+ assert.equal(autoHandoffSummaryOf('Throughline自動継続の試験'), 'Throughline自動継続の試験', '人が付けた題は残す');
39
+ });
40
+
41
+ test('概要は最初の行を1行にして、長さを抑える', () => {
42
+ assert.equal(autoHandoffSummaryOf('\n 1行目の 依頼\t文 \n2行目'), '1行目の 依頼 文');
43
+ assert.equal(autoHandoffSummaryOf('say "hello" & exit'), 'say hello & exit', '二重引用符は落とす');
44
+ const long = autoHandoffSummaryOf('あ'.repeat(100));
45
+ assert.equal(Array.from(long).length, 40);
46
+ assert.ok(long.endsWith('…'));
47
+ assert.equal(autoHandoffSummaryOf(long), long, '切った概要は、もう一度通しても変わらない');
48
+ assert.equal(Array.from(autoHandoffSummaryOf(`${'😀'.repeat(50)}`)).length, 40, '絵文字を途中で割らない');
49
+ assert.equal(autoHandoffSummaryOf(undefined), null);
50
+ });
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
21
21
  import { homedir } from 'node:os';
22
- import { basename, dirname, join, posix, resolve, win32 } from 'node:path';
22
+ import { dirname, join, posix, resolve, win32 } from 'node:path';
23
23
  import { randomUUID } from 'node:crypto';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { setTimeout as delay } from 'node:timers/promises';
@@ -38,6 +38,7 @@ import { resolveMergeTarget } from './session-merger.mjs';
38
38
  import { writeBaton } from './baton.mjs';
39
39
  import { sameProjectPath } from './project-path.mjs';
40
40
  import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
41
+ import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
41
42
 
42
43
  export const CLAUDE_AUTO_HANDOFF_SCHEMA = 'throughline.claude-auto-handoff.v2';
43
44
  const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
@@ -262,6 +263,17 @@ function readLatestModel(transcriptPath) {
262
263
  return null;
263
264
  }
264
265
 
266
+ /**
267
+ * 止める会話の題。人が付けた題を先に読む(Claude Desktop の題と、`--name` で付いた後継の名前もこの行に入る)。
268
+ * 無ければ Claude Code が付けた題を読む。
269
+ */
270
+ function readSessionTitle(transcriptPath) {
271
+ const entries = readRawEntries(transcriptPath);
272
+ const latest = (type, key) => entries.findLast(entry => entry?.type === type &&
273
+ typeof entry[key] === 'string' && entry[key].trim())?.[key] ?? null;
274
+ return latest('custom-title', 'customTitle') ?? latest('ai-title', 'aiTitle');
275
+ }
276
+
265
277
  const CONTINUATION_MARKER = /Throughline自動継続 ([0-9a-f-]{36})/;
266
278
 
267
279
  // 後継へ渡す「このターンでここまでにしたこと」。1件の長さと件数を抑える。全文は取り込んだ details にある。
@@ -525,8 +537,15 @@ function readAcceptance(sessionId, handoffId, dir) {
525
537
  return acceptance?.handoff_id === handoffId && typeof acceptance.accepted_at === 'number' ? acceptance : null;
526
538
  }
527
539
 
540
+ /** 後継の名前。一覧で、どの project の何の作業の続きかを読めるようにする。概要は前任の題、無ければ元の依頼から取る。 */
541
+ function successorName(record) {
542
+ return composeAutoHandoffTitle({ projectPath: record.project_path,
543
+ titles: [record.transcript_path ? readSessionTitle(record.transcript_path) : null, record.in_flight?.user?.content] });
544
+ }
545
+
528
546
  function successorArgs(record) {
529
- const args = ['--bg', '--name', `tl-${basename(record.project_path)}-${record.handoff_id.slice(0, 8)}`];
547
+ // 名前は `--name=` の形で渡す。project のフォルダ名が `-` で始まっても、option として読まれない。
548
+ const args = ['--bg', `--name=${successorName(record)}`];
530
549
  const { model, effort, permission_mode: permissionMode } = record.settings ?? {};
531
550
  if (model) args.push('--model', model);
532
551
  if (effort) args.push('--effort', effort);
@@ -278,9 +278,10 @@ test('worker: 継続の指示を最初の指示として付けて後継を同じ
278
278
  assert.equal(calls.spawn.length, 1);
279
279
  const { command, args, options } = calls.spawn[0];
280
280
  assert.equal(command, 'claude');
281
- assert.deepEqual(args, ['--bg', '--name', `tl-app-${record.handoff_id.slice(0, 8)}`, '--effort', 'high',
281
+ assert.deepEqual(args, ['--bg', '--name=app(自動引き継ぎ)', '--effort', 'high',
282
282
  '--permission-mode', 'acceptEdits', '--settings', '{"worktree":{"bgIsolation":"none"}}', claudeContinuationInput(record)],
283
- '指示は起動時に渡す(外から送ると、権限のバイパス中の後継が承認まで止める)。モデルは transcript が無いので付かない');
283
+ '指示は起動時に渡す(外から送ると、権限のバイパス中の後継が承認まで止める)。モデルは transcript が無いので付かない。' +
284
+ '名前は、前任の題も依頼も読めないので project 名と印だけ');
284
285
  assert.match(args.at(-1), new RegExp(`^Throughline自動継続 ${record.handoff_id}\\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。`));
285
286
  assert.equal(options.cwd, '/work/app');
286
287
  assert.deepEqual(options.env, { PATH: '/bin', CLAUDE_CODE_USE_BEDROCK: '1' }, '会話ごとの環境変数は後継へ渡さない');
@@ -334,7 +335,8 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
334
335
  assert.equal(first.record.state, 'sent');
335
336
  assert.equal(first.record.successor.short_id, 'a41a97ae');
336
337
  assert.equal(first.record.settings.model, 'claude-opus-5');
337
- assert.deepEqual(first.calls[0].slice(3, 5), ['--model', 'claude-opus-5']);
338
+ assert.deepEqual(first.calls[0].slice(1, 4), ['--name=app|14個のファイルを順に読んで(自動引き継ぎ)', '--model', 'claude-opus-5'],
339
+ '前任に題が無い時は、元の依頼を名前の概要にする');
338
340
  assert.deepEqual(first.record.in_flight, {
339
341
  user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
340
342
  last_fragment: { content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
@@ -351,6 +353,8 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
351
353
  // 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
352
354
  const transcriptB = join(dir, 'b.jsonl');
353
355
  writeFileSync(transcriptB, jsonl([
356
+ // `--name` で立てた会話には、Claude Code が名前を題として書く
357
+ { type: 'custom-title', customTitle: first.calls[0][1].slice('--name='.length), sessionId: SUCCESSOR },
354
358
  user(claudeContinuationInput(first.record), '2026-10-04T03:01:00Z'),
355
359
  assistant('part09 を読みました。次に part10 を読みます。', '2026-10-04T03:01:20Z', 'claude-opus-5'),
356
360
  { type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_10', name: 'Read', input: {} }] } },
@@ -365,6 +369,39 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
365
369
  stopped_tools_total: 1,
366
370
  turn: null,
367
371
  });
372
+ assert.equal(second.calls[0][1], first.calls[0][1], '引き継ぎを重ねても、project 名と印は積み重ならない');
373
+ }));
374
+
375
+ test('worker: 後継の名前は project 名・前任の題・自動引き継ぎの印で作る。人が付けた題を Claude Code の題より先に使う', () => withDir(async dir => {
376
+ const nameFor = async (sessionId, titleLines) => {
377
+ const transcriptPath = join(dir, `${sessionId}.jsonl`);
378
+ writeFileSync(transcriptPath, jsonl([
379
+ user('14個のファイルを順に読んで', '2026-10-04T03:00:10Z'),
380
+ ...titleLines,
381
+ assistant('part04 を読みました。', '2026-10-04T03:00:12Z'),
382
+ ]));
383
+ const db = makeBatonDb();
384
+ requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
385
+ env: {}, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
386
+ await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath },
387
+ dir, launchWorker: async () => {} });
388
+ let name = null;
389
+ await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
390
+ spawn: (command, args) => { name = args[1]; successorStarts(dir, sessionId)(args); return launchedOk(); } });
391
+ return name;
392
+ };
393
+ // 端末の会話: Claude Code が付けた題(新しい方)
394
+ assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000001', [
395
+ { type: 'ai-title', aiTitle: 'ファイルの読み込み' }, { type: 'ai-title', aiTitle: '14ファイル順序読み込みと結果記録' },
396
+ ]), '--name=app|14ファイル順序読み込みと結果記録(自動引き継ぎ)');
397
+ // Claude Desktop の会話と、人が名前を変えた会話: 題は custom-title に入る
398
+ assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000002', [
399
+ { type: 'custom-title', customTitle: 'MCP共有ラッパー設計' }, { type: 'ai-title', aiTitle: '別の題' },
400
+ ]), '--name=app|MCP共有ラッパー設計(自動引き継ぎ)');
401
+ // 改行・二重引用符・長い題は、1行の引数として渡せる形にする
402
+ assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000003', [
403
+ { type: 'custom-title', customTitle: `"結果" を\n${'長'.repeat(60)}` },
404
+ ]), '--name=app|結果 を(自動引き継ぎ)');
368
405
  }));
369
406
 
370
407
  function makeMemoryDb() {
@@ -18,6 +18,7 @@ import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-aut
18
18
  import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
19
19
  settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
20
20
  import { resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
21
+ import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
21
22
 
22
23
  const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
23
24
  const stateRoot = () => join(homedir(), '.throughline', 'codex-auto-handoff');
@@ -120,6 +121,30 @@ async function sourceNativeState(operation, runtime) {
120
121
  }, runtime);
121
122
  }
122
123
 
124
+ /**
125
+ * 後継タスクの名前。一覧で、どの project の何の作業の続きかを読めるようにする。概要は前任の題から取る。
126
+ * 前任が名前の無い後継(題が継続の指示になっている)なら、引き継ぎをさかのぼって元のタスクの題を使う。
127
+ */
128
+ export function autoHandoffTargetName(db, operation) {
129
+ const titles = [];
130
+ for (let current = operation, depth = 0; current && depth < 20; depth++) {
131
+ titles.push(current.runtime?.title);
132
+ current = current.previous_handoff_id ? getAutoHandoff(db, current.previous_handoff_id) : null;
133
+ }
134
+ return composeAutoHandoffTitle({ projectPath: operation.project_path, titles });
135
+ }
136
+
137
+ /** 後継タスクに名前を付ける。名前は表示だけに使うので、付けられなくても引き継ぎは止めない(理由は worker のログに残す)。 */
138
+ export async function nameAutoHandoffTarget(request, targetId, name) {
139
+ try {
140
+ await request('thread/name/set', { threadId: targetId, name });
141
+ return true;
142
+ } catch (cause) {
143
+ process.stderr.write(`[auto-handoff] thread/name/set failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
144
+ return false;
145
+ }
146
+ }
147
+
123
148
  async function createTarget(db, operation, runtime) {
124
149
  const targetId = await withCodexReceiver(autoHandoffDeliveryProfile, { thread_id: operation.source_thread_id, codex_home: operation.codex_home },
125
150
  async request => {
@@ -133,6 +158,8 @@ async function createTarget(db, operation, runtime) {
133
158
  if (!CODEX_NATIVE_ID_PATTERN.test(targetId ?? '') || !sameProjectPath(start.cwd, operation.project_path)) throw error('handoff_target_response_invalid');
134
159
  updateAutoHandoff(db, operation.handoff_id, { target_thread_id: targetId, mutation_stage: 'inject' });
135
160
  }
161
+ // 最初のターンの前に付ける。付けないと、継続の指示がそのままタスクの題として見える。
162
+ await nameAutoHandoffTarget(request, targetId, autoHandoffTargetName(db, operation));
136
163
  if (!operation.runtime.targetMemoryInjected) {
137
164
  const text = renderAutoHandoffMemory(db, operation);
138
165
  await request('thread/inject_items', { threadId: targetId, items: [{ type: 'message', role: 'developer',
@@ -10,7 +10,8 @@ import { requestAutoHandoff, updateAutoHandoff, getAutoHandoff, freezeCodexMemor
10
10
  import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoffMemory,
11
11
  renderFrozenDetail } from './codex-auto-handoff-memory.mjs';
12
12
  import { readCodexHandoffState, settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
13
- import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary } from './codex-auto-handoff.mjs';
13
+ import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
14
+ autoHandoffTargetName, nameAutoHandoffTarget } from './codex-auto-handoff.mjs';
14
15
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
15
16
 
16
17
  async function withDb(fn) {
@@ -291,3 +292,30 @@ test('forkした子の先頭metadataを保持し、継承された親metadataで
291
292
  assert.equal(state.stoppedAt, state.turnStartAt);
292
293
  assert.throws(() => readCodexHandoffState(file, { threadId: 'parent' }), /handoff_thread_mismatch/);
293
294
  }));
295
+
296
+ test('後継タスクの名前は project 名・前任の題・自動引き継ぎの印で作り、名前の無い後継からは元のタスクの題をさかのぼる', async () => withDb(async db => {
297
+ const named = (operation, title) => updateAutoHandoff(db, operation.handoff_id, { runtime_json: { projectId: null, title } }).operation;
298
+ const a = named(request(db, 'A', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, 'ASCからの連絡を確認');
299
+ assert.equal(autoHandoffTargetName(db, a), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
300
+
301
+ // A の後継 B は、名前を付けない版が作った。題は継続の指示として見える
302
+ updateAutoHandoff(db, a.handoff_id, { target_thread_id: 'B' });
303
+ const b = named(request(db, 'B', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, continuationInput(a));
304
+ assert.equal(b.previous_handoff_id, a.handoff_id);
305
+ assert.equal(autoHandoffTargetName(db, b), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
306
+
307
+ // B の後継 C は、この形の名前を持つ。重ねても同じ名前になる
308
+ updateAutoHandoff(db, b.handoff_id, { target_thread_id: 'C' });
309
+ const c = named(request(db, 'C', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, autoHandoffTargetName(db, b));
310
+ assert.equal(autoHandoffTargetName(db, c), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
311
+
312
+ // 題がどこにも無い時は project 名と印だけ
313
+ const lone = named(request(db, 'D', { projectPath: 'C:\\Users\\kite_\\Throughline' }).operation, '');
314
+ assert.equal(autoHandoffTargetName(db, lone), 'Throughline(自動引き継ぎ)');
315
+
316
+ const sent = [];
317
+ assert.equal(await nameAutoHandoffTarget(async (method, params) => { sent.push([method, params]); return {}; }, 'T', 'BellTeam(自動引き継ぎ)'), true);
318
+ assert.deepEqual(sent, [['thread/name/set', { threadId: 'T', name: 'BellTeam(自動引き継ぎ)' }]]);
319
+ const rejected = async () => { throw Object.assign(new Error('unknown method'), { delivery_code: 'CODEX_RECEIVER_REJECTED' }); };
320
+ assert.equal(await nameAutoHandoffTarget(rejected, 'T', 'x'), false, '名前を付けられなくても、引き継ぎは止めない');
321
+ }));
@@ -98,6 +98,20 @@ test('Claude Stopで、transcriptのファイルが無い会話は失敗に数
98
98
  skipped: 'transcript_absent',
99
99
  }]);
100
100
 
101
+ // ターンの途中でtranscriptが消され、応答の行だけでファイルが作り直された。依頼が残っていないので、失敗に数えない(ADR 0038)。
102
+ const headLostPath = join(root, 'head-lost.jsonl');
103
+ writeFileSync(headLostPath, JSON.stringify({ type: 'assistant',
104
+ message: { role: 'assistant', stop_reason: 'end_turn', content: [{ type: 'text', text: 'PROBE_OK' }] } }) + '\n');
105
+ const headLost = stop({
106
+ session_id: 'a1b2c3d4-0000-4000-8000-000000000003', transcript_path: headLostPath, cwd: root,
107
+ hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'PROBE_OK',
108
+ });
109
+ assert.equal(headLost.status, 0, headLost.stderr);
110
+ assert.equal(existsSync(storePath), false, 'a transcript that lost its head must not be counted as a hook failure');
111
+ assert.equal(existsSync(join(root, '.throughline', 'logs', 'hook-failures.log')), false);
112
+ assert.deepEqual([readLog('backfill.log').at(-1).session_id, readLog('backfill.log').at(-1).skipped],
113
+ ['a1b2c3d4-0000-4000-8000-000000000003', 'transcript_head_lost']);
114
+
101
115
  // ファイルはあるのに、Stopが渡した応答がそこに無い。保存できなかった会話なので、失敗のまま数える。
102
116
  const presentPath = join(root, 'present.jsonl');
103
117
  writeFileSync(presentPath, JSON.stringify({ type: 'user', message: { role: 'user', content: 'request' } }) + '\n');
@@ -33,6 +33,7 @@
33
33
  import { getDb } from './db.mjs';
34
34
  import {
35
35
  readRawEntries,
36
+ readTranscript,
36
37
  readLatestLogicalTurnCompletions,
37
38
  sliceCurrentTurnEntries,
38
39
  extractDetailBlocks,
@@ -60,6 +61,12 @@ export const CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS = 25;
60
61
 
61
62
  const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
62
63
 
64
+ /** transcript に assistant の発言だけがあり、user の発言が1つも無い(会話の始まりを失っている)。 */
65
+ function isTranscriptHeadLost(transcriptPath) {
66
+ const turns = readTranscript(transcriptPath);
67
+ return turns.length > 0 && !turns.some(turn => turn.role === 'user');
68
+ }
69
+
63
70
  /**
64
71
  * Claude Stop payloadのassistant identityがtranscriptへ永続化されるまで待つ。
65
72
  * markerは本文ソースにせず、transcript可視化のbarrierにだけ使う。
@@ -77,6 +84,10 @@ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
77
84
  * 期限まで待ってもtranscriptのファイル自体が無い時は、失敗にせず `transcript_absent` を返す
78
85
  * (ADR 0029)。Claude Codeを `--no-session-persistence` で起動すると、hookへ `transcript_path` は
79
86
  * 渡るが、ファイルは最後まで作られない。ファイルがあるのに完了が見えない時は、今までどおり失敗にする。
87
+ *
88
+ * 期限まで待って、transcriptにassistantの発言だけがありuserの発言が1つも無い時は、失敗にせず
89
+ * `transcript_head_lost` を返す(ADR 0038)。ターンの途中でtranscriptが消されると、Claude Codeは
90
+ * 残りの行だけでファイルを作り直す。そのターンの依頼はどこにも残っていない。
80
91
  */
81
92
  export async function waitForClaudeStopTranscriptFlush({
82
93
  transcriptPath,
@@ -87,6 +98,7 @@ export async function waitForClaudeStopTranscriptFlush({
87
98
  readCompletions = readLatestLogicalTurnCompletions,
88
99
  isTurnCaptured = () => true,
89
100
  transcriptExists = existsSync,
101
+ transcriptHeadLost = isTranscriptHeadLost,
90
102
  now = Date.now,
91
103
  wait = delay,
92
104
  }) {
@@ -115,8 +127,9 @@ export async function waitForClaudeStopTranscriptFlush({
115
127
  }
116
128
  const remaining = deadline - now();
117
129
  if (remaining <= 0) {
118
- if (typeof transcriptPath === 'string' && transcriptPath && !transcriptExists(transcriptPath)) {
119
- return { status: 'transcript_absent' };
130
+ if (typeof transcriptPath === 'string' && transcriptPath) {
131
+ if (!transcriptExists(transcriptPath)) return { status: 'transcript_absent' };
132
+ if (transcriptHeadLost(transcriptPath)) return { status: 'transcript_head_lost' };
120
133
  }
121
134
  throw new Error('Claude Stop transcript completion was not visible before deadline');
122
135
  }
@@ -279,8 +292,8 @@ async function processStop() {
279
292
  lastAssistantMessage: last_assistant_message,
280
293
  isTurnCaptured: (fragmentTurnNumbers) => isLogicalTurnCaptured(db, origin, fragmentTurnNumbers),
281
294
  });
282
- if (flush.status === 'transcript_absent') {
283
- // 保存する元が無い。失敗には数えず、どの会話のStopを見送ったかを端末内に残す(ADR 0029)。
295
+ if (flush.status === 'transcript_absent' || flush.status === 'transcript_head_lost') {
296
+ // 保存する元が無い。失敗には数えず、どの会話のStopを見送ったかを端末内に残す(ADR 0029・0038)。
284
297
  logBackfill({
285
298
  ts: new Date().toISOString(),
286
299
  hook: 'stop',
@@ -288,7 +301,7 @@ async function processStop() {
288
301
  target,
289
302
  origin,
290
303
  transcript_path,
291
- skipped: 'transcript_absent',
304
+ skipped: flush.status,
292
305
  });
293
306
  process.exit(0);
294
307
  }
@@ -274,6 +274,46 @@ test('Claude Stop flush barrierは、期限までtranscriptのファイルが無
274
274
  }
275
275
  });
276
276
 
277
+ test('Claude Stop flush barrierは、期限までtranscriptにuserの発言が1つも無い時だけtranscript_head_lostを返す', async () => {
278
+ const root = mkdtempSync(join(tmpdir(), 'throughline-stop-head-lost-'));
279
+ const transcriptPath = join(root, 'head-lost.jsonl');
280
+ let elapsed = 0;
281
+ const clock = { now: () => elapsed, wait: async (milliseconds) => { elapsed += milliseconds; } };
282
+ const line = (entry) => JSON.stringify(entry);
283
+ const assistant = (content) => ({ type: 'assistant', message: { role: 'assistant', stop_reason: 'end_turn', content } });
284
+ try {
285
+ // ターンの途中でtranscriptのフォルダが消され、Claude Codeが残りの行だけでファイルを作り直した形(Linux 2.1.292で実測)。
286
+ const tail = [
287
+ line(assistant([{ type: 'thinking', thinking: '' }])),
288
+ line(assistant([{ type: 'text', text: '最後の応答' }])),
289
+ line({ type: 'system', subtype: 'stop_hook_summary', hookCount: 3 }),
290
+ line({ type: 'ai-title', aiTitle: '題' }),
291
+ ];
292
+ writeFileSync(transcriptPath, tail.join('\n') + '\n', 'utf8');
293
+ assert.deepEqual(await waitForClaudeStopTranscriptFlush({
294
+ transcriptPath, lastAssistantMessage: '最後の応答', timeoutMs: 30, intervalMs: 10, ...clock,
295
+ }), { status: 'transcript_head_lost' });
296
+ assert.equal(elapsed, 30, 'a user line that is written late must still get the whole deadline');
297
+
298
+ // userの発言が残っているのに完了が見えない時は、失敗のまま(保存できたはずの会話)。
299
+ writeFileSync(transcriptPath,
300
+ [line({ type: 'user', message: { role: 'user', content: '依頼' } }), ...tail].join('\n') + '\n', 'utf8');
301
+ elapsed = 0;
302
+ await assert.rejects(waitForClaudeStopTranscriptFlush({
303
+ transcriptPath, lastAssistantMessage: '別の応答', timeoutMs: 30, intervalMs: 10, ...clock,
304
+ }), /not visible before deadline/);
305
+
306
+ // 発言が1つも無いファイル(書き始め)は、先頭を失ったとは言えない。失敗のまま。
307
+ writeFileSync(transcriptPath, line({ type: 'mode', mode: 'normal' }) + '\n', 'utf8');
308
+ elapsed = 0;
309
+ await assert.rejects(waitForClaudeStopTranscriptFlush({
310
+ transcriptPath, lastAssistantMessage: '最後の応答', timeoutMs: 30, intervalMs: 10, ...clock,
311
+ }), /not visible before deadline/);
312
+ } finally {
313
+ rmSync(root, { recursive: true, force: true });
314
+ }
315
+ });
316
+
277
317
  test('Claude Stop flush barrierはmarkerなし旧payloadをone-shot互換へ残す', async () => {
278
318
  let reads = 0;
279
319
  const result = await waitForClaudeStopTranscriptFlush({