throughline 0.16.28 → 0.16.30

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,37 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.30] — 2026-10-14
14
+
15
+ ### 修正
16
+
17
+ - **Codexのゴール(`/goal`)で動くタスクで、Codexが自分で入れる続行の指示が、利用者の発言として会話の記録(L2)に残っていた**([ADR 0061](docs/adr/0061-codex-internal-context-is-compacted-in-memory.md))。続行のターンの入力は、`<codex_internal_context source="goal">`で始まる約6,300字の定型文で、利用者の発言としてrolloutに入る。Windowsの実機のタスクでは、利用者の発言15行のうち7行がこれで、字数の87%を占めていた。引き継ぎの記憶に入る直近のターンが同じ定型文で埋まり、利用者が打った依頼が押し出される形だった。
18
+ - 直した後は、開く印(`source`つき)・利用者が決めたゴールの文(`<objective>`)・閉じる印だけを残し、定型文を落とす。ターンは落とさない。
19
+ - 閉じる印が無い物と、発言の途中に印がある物は、触らない。
20
+
21
+ ### 変わる動き
22
+
23
+ - ゴールで動くCodexのタスクで、続行のターンの利用者の発言が短い行になる。前の版が保存した長い行は、そのタスクの次の取り込みで置き換わる。
24
+ - Claude Code・Grok・Cursorの記録の読み方は変えていない。
25
+
26
+ ## [0.16.29] — 2026-10-14
27
+
28
+ ### 修正
29
+
30
+ - **Claude Codeで、作業中に届いた入力が、会話の記録(L2)に残っていなかった**([ADR 0060](docs/adr/0060-claude-mid-turn-input-and-host-meta-rows.md))。Claude Codeは、ターンが動いている間に届いた入力を、user行ではなく`attachment`の`queued_command`で書く。この製品はその行を読んでいなかった。Linuxの実機の4日ぶんの記録で、人の入力のuser行1,402に対して、途中で届いた人の入力は833通あり、記録に入っていたのは3通だった。
31
+ - 直した後は、途中で届いた入力を、届いた先のターンの依頼へ、届いた順に足す。前に見出しの1行(`(作業の途中で届いた入力)`)を置く。人が打った文と、別の会話から届いた発言が当たる。裏の作業の完了通知は足さない。
32
+ - 引き継ぎの記憶と、自動継続で後継へ運ぶ依頼にも入る。
33
+ - **Claude Codeがskillを読んだ時などに書く行を、利用者の発言として記録していた**(同じADR)。`isMeta: true`で`origin`の無いuser行。この行でターンを区切っていたので、その前に本文が無いと、元の依頼が記録から消え、skillの本文が利用者の発言として残った。道具の入出力(L3)も、その行より前の分が入らなかった。自動継続で止めたターンでは、後継へ運ぶ依頼がskillの本文になっていた。
34
+ - 直した後は、この行をターンの始まりにも本文にもしない。別の会話から届いた発言(`origin`を持つ)は、今までどおり。
35
+ - **Claude Codeで、ターンの終わりに待ち入力が2通以上続けて届いた時、最後の1通だけが記録されていた**(同じADR)。同じ番へ続けて届いたuser行(`turnPosition.promptIndex`が同じ)を、1つの依頼にまとめる。
36
+
37
+ ### 変わる動き
38
+
39
+ - Claude Codeの会話で、利用者の発言の行に、作業中に届いた入力が見出しつきで足される。見出しの1行は、この製品が足した文。
40
+ - skillの本文は、利用者の発言として記録されなくなる。道具の入出力には残る。
41
+ - 前の版が記録したターンは書き直さない。直しが効くのは、この版が取り込むターンから。行の番号は変わらないので、前の版の行と重なって増える事は無い。
42
+ - Codex・Grok・Cursorの記録の読み方は変えていない。
43
+
13
44
  ## [0.16.28] — 2026-10-14
14
45
 
15
46
  ### 修正
@@ -2558,7 +2589,9 @@ two attempts, instrument first instead of patching again.
2558
2589
 
2559
2590
  ---
2560
2591
 
2561
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.28...HEAD
2592
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.30...HEAD
2593
+ [0.16.30]: https://github.com/kitepon/Throughline/compare/v0.16.29...v0.16.30
2594
+ [0.16.29]: https://github.com/kitepon/Throughline/compare/v0.16.28...v0.16.29
2562
2595
  [0.16.28]: https://github.com/kitepon/Throughline/compare/v0.16.27...v0.16.28
2563
2596
  [0.16.27]: https://github.com/kitepon/Throughline/compare/v0.16.26...v0.16.27
2564
2597
  [0.16.26]: https://github.com/kitepon/Throughline/compare/v0.16.25...v0.16.26
@@ -56,3 +56,9 @@ turn の間に入力が2通たまり、turn が終わった時に2通が続け
56
56
  本物の hook の命令(`process-turn`)へ、合成した transcript を通した(切り離した HOME)。
57
57
  - 確かめていない: 本物の Claude Code が、待っていた入力を2通続けて書く場面での、この版の動き。狙って起こすには、
58
58
  動いている turn の間に入力を2通ためる必要がある。次に起きた時は、失敗が記録されない事で分かる。
59
+
60
+ ## 追記(2026-10-14、ADR 0060)
61
+
62
+ Context の「保存の欠けは無い」は、完了した turn の事だった。続けて届いた入力の1通目は、assistant 断片の無い group として
63
+ 保存されていなかった(コンテナの本物の transcript で 5 回)。ADR 0060 で、同じ `turnPosition.promptIndex` で続けて届いた
64
+ user 行を1つの依頼にまとめる様にした。assistant 断片の無い group を飛ばす作りは、そのまま残している。
@@ -0,0 +1,123 @@
1
+ # ADR 0060: Claude Code の、ターンの途中で届いた入力は依頼へ足し、host が書く meta の行ではターンを区切らない
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-14
5
+
6
+ ## Context
7
+
8
+ Claude Code の transcript から L2(`bodies`)を作る時、この製品は「本文を持つ user 行1つ + それに続く assistant の本文」を
9
+ 1つのターンとして読んでいた(`getLogicalTurnGroups`)。user 行なら何でもターンの始まりにし、`attachment` の行は L3 にだけ使っていた。
10
+
11
+ 2026-10-14、BellTeam のコンテナ(Linux、Claude Code 2.1.293・2.1.296、この製品は 0.16.27)の本物の transcript で、
12
+ 人の入力が L2 に入っていない形が3つ見つかった(本文は読んでいない。行の種類・印・長さ・時刻だけ)。
13
+
14
+ ### 1. ターンの途中で届いた入力
15
+
16
+ Claude Code は、ターンが動いている間に届いた入力を、user 行ではなく `attachment` の行で書く。
17
+
18
+ ```text
19
+ {"type":"attachment","attachment":{"type":"queued_command","prompt":…,"commandMode":"prompt","origin":{"kind":"human"},"humanTurn":true,…}}
20
+ ```
21
+
22
+ `prompt` は文字列か、text と image の block の配列。裏の作業の完了通知も同じ形で届く(`commandMode: "task-notification"`、
23
+ `origin.kind: "task-notification"`)。前後に `queue-operation` の `enqueue` と `remove` の行が並ぶ。ターンが終わるまで
24
+ 届けられなかった入力は、`dequeue` の後に普通の user 行になる(`promptSource: "queued"`)。
25
+
26
+ 10/10 からの 163 本で、人の入力の `queued_command` は 833 通あった(人の入力の user 行は 1,402)。L2 に入っていたのは 3 通
27
+ (同じ文が別の行にもあった物)。作業中に打った文、ほかの席から差し込まれた文が、記憶に残っていなかった。
28
+
29
+ ### 2. host が書く meta の user 行
30
+
31
+ Claude Code は、skill を読んだ時の本文や、端末で打った命令の注意書きを、`isMeta: true` の user 行で書く。`origin` は無い。
32
+ skill の行は、直後に `attachment` の `command_permissions` が続く。
33
+
34
+ この行をターンの始まりにしていたので、次の事が起きていた。
35
+
36
+ - その行より前に assistant の本文が無いと、元の依頼は「本文の無い群」になって保存されない。meta の行の本文
37
+ (skill の手順)が、利用者の発言として保存される。
38
+ - 本文があると、1つのターンが2つに割れる。
39
+ - L3 は「最後の user 本文の行」から切り出すので、meta の行より前の道具の入出力が入らない。
40
+ - 自動継続で止めたターンの依頼(`snapshotInFlight`)も「最後の user 本文」を読むので、後継へ運ぶ依頼が skill の本文になる。
41
+
42
+ 同じ 163 本で、`origin` の無い meta の行は 61 行(道具の後 27、人の入力の直後 11、meta の行の後 17、端末の命令の注意書き 6)。
43
+ 46 行が利用者の発言として L2 の群に入っていた。
44
+
45
+ 別の会話から届いた発言も `isMeta: true` だが、`origin.kind: "peer"` を持つ。届いた入力なので、今までどおりターンの始まりにする。
46
+
47
+ ### 3. ターンの終わりに続けて届いた待ち入力
48
+
49
+ ターンの間に入力が2通以上たまると、ターンが終わった時に、2通が続けて user 行になる。2行とも `promptSource: "queued"` で、
50
+ 同じ `turnPosition.promptIndex` を持つ(同じ番へ届いた印)。
51
+
52
+ 1通目は「assistant の本文が無い群」になり、保存されなかった。ADR 0055 は、この形で Stop が失敗する事を直した時に
53
+ 「保存の欠けは無い」と書いたが、欠けていたのは完了したターンではなく、1通目の入力だった。同じ 163 本で 5 回。
54
+
55
+ ### ほかの host
56
+
57
+ - Codex: ターンを host の開始と完了の印(`task_started`・`task_complete`)で区切り、その間の利用者の発言を全部持つ
58
+ (`codex-rollout-memory.mjs`)。同じ欠けは起きない作り。
59
+ - Grok: `chat_history.jsonl` に、途中で届いた入力を表す別の行は見つからなかった(80 本)。人の入力の行が本文を挟まずに
60
+ 続く形は 9 回あり、`prompt_index` は違っていた。止められたターンか、途中の入力かは分けていない。この ADR では扱わない。
61
+ - Cursor: 10 本を見て、同じ形の行は無かった。
62
+
63
+ ## Decision
64
+
65
+ 1. **ターンの途中で届いた入力は、届いた先のターンの依頼へ、届いた順に足す。** `attachment` の `queued_command` で、
66
+ `origin.kind` が `human` か `peer` の物。直前に返した user の行の本文の後ろへ、空行・見出しの1行
67
+ (`(作業の途中で届いた入力)`)・本文の順で足す。本文は user 行と同じ正規化を通す(端末の制御の除去、別の会話の注意書きの除去)。
68
+ - `turn_number` は進めない。`attachment` は今までも本文行に数えていない。保存済みの番号とずれない。
69
+ - 完了通知(`origin.kind: "task-notification"`)は足さない。そのターン自身の裏の作業の結果で、L3 に残っている。
70
+ - `origin` の無い物、`isSidechain: true` の物、足す先の user 行がまだ無い物は足さない。
71
+ - ターンの始まり方(`start`)は変えない。完了通知で始まったターンへ人の入力が届いても、`self` のまま。
72
+ 2. **`isMeta: true` で `origin.kind` の無い user 行は、ターンの始まりにも本文にもしない**(`isClaudeHostWrittenMetaEntry`)。
73
+ 圧縮の要約行(ADR 0032)と同じ扱い。`turn_number` は進める(その行を数えていた頃に保存した番号とずれない)。
74
+ L3 の切り出し(`sliceCurrentTurnEntries`・`sliceInFlightTurnEntries`)も、この行で区切らない。
75
+ 3. **同じ番へ続けて届いた user 行は、1つの依頼にまとめる。** 直前の user 行との間に assistant の本文が無く、両方が
76
+ `origin.kind` を持ち、`turnPosition.promptIndex` が同じ整数の時。空行でつなぐ。`turn_number`・時刻・`start`・`prompt_id` は
77
+ 1通目の物。`turn_number` は2通目の分も進める。
78
+ - `promptIndex` が違う行、印の無い行、`origin` の無い行(端末で打った命令)は、まとめない。止められて本文を書かなかった
79
+ ターンの依頼は、今までどおり保存されない。
80
+ 4. 判定は、host が transcript に書いた印(`type`・`isMeta`・`origin.kind`・`turnPosition`)だけで行う。本文は読まない。
81
+ 5. 保存済みのターンは書き直さない。直しが効くのは、この版が取り込むターンから。
82
+
83
+ ## Consequences
84
+
85
+ - 作業中に打った文と、ほかの席から差し込まれた文が、そのターンの利用者の発言として L2 に残る。引き継ぎの記憶と、
86
+ 自動継続で後継へ運ぶ依頼(`in_flight.user`)にも入る。
87
+ - 利用者の発言の行が長くなる。見出しの1行は、この製品が足した文で、利用者の文ではない。
88
+ - skill の本文は L2 に入らなくなる(L3 には `Skill` の道具の入出力として残る)。
89
+ - 前の版が取り込んだ会話では、meta の行で割れていた2つの群が1つにまとまる。`backfillBodies` は「群の断片の番号が1つでも
90
+ 保存済みなら回収しない」ので、行は増えない。前の版が落とした依頼と途中の入力は、さかのぼっては入らない。
91
+ - Stop の flush barrier(ADR 0026・0055)は `readTranscript` の上で動く。続けて届いた待ち入力は1つの群になるので、
92
+ 「本文の無い群を飛ばす」場面が減る。飛ばす作りは残す(`promptIndex` の無い版の Claude Code、止められたターン)。
93
+ - `auditor-context` と `caveat-context` の鮮度の照合は、transcript の最後の群の依頼と、保存済みの行の hash を比べる。
94
+ 前の版が取り込んだ最後のターンに途中の入力や meta の行があると、版を上げた直後の1回だけ合わない(次のターンからは合う)。
95
+ - 途中の入力がターンの最後の本文より後に書かれた時も、そのターンの依頼へ足す。Stop の取り込みがもう済んでいれば、
96
+ その入力は L2 に入らない(保存済みの行は書き直さない)。実物では見ていない。
97
+
98
+ ## 確かめた事・確かめていない事
99
+
100
+ - 確かめた:
101
+ - 単体試験(途中の入力の文字列と配列、完了通知・sidechain・印なしを足さない、別の会話からの文、足す先が無い時、meta の行、
102
+ `origin` を持つ meta の行、続けて届いた待ち入力、まとめない3つの形、L3 の切り出し、前の版が保存した行と重ねない事、
103
+ 止めたターンの取り込み)。直す前の版では、形を表す5本が落ちる。
104
+ - 本物の transcript へ、前の版と直した版の読み手を当てて比べた(DB は開かない。数だけ)。3つの OS とも、assistant の行の番号は
105
+ 全部同じで、直した版の代表の番号に、前の版に無い物は 0。
106
+
107
+ | | 本数 | 群 | 人の入力の user 行が群の依頼にある数 | 途中の人の入力が群の依頼にある数 | 利用者の発言になっていた meta の行 |
108
+ |---|---|---|---|---|---|
109
+ | Linux(コンテナ) | 163 | 1,661 → 1,634 | 1,385 → 1,401(1,402 のうち) | 3 → 832(833 のうち) | 46 → 0 |
110
+ | macOS | 103 | 1,265 → 1,246 | 905 → 910(926 のうち) | 0 → 121(122 のうち) | 36 → 0 |
111
+ | Windows | 108 | 922 → 899 | 324 → 335(369 のうち) | 2 → 111(119 のうち) | 51 → 0 |
112
+
113
+ 入らなかった途中の入力は、3つの OS とも全部「依頼へは足されたが、そのターンに assistant の本文が無い」物だった
114
+ (止められたターン。完了したターンの回収には出ない)。
115
+ - 別の会話からの文(`origin.kind: "peer"`)が、ターンの途中に `queued_command` で届く実物は、Windows に 187 通あった
116
+ (`commandMode: "prompt"`。頭は地の文か `<cross-session-message>`)。同じ扱いで依頼へ足される。
117
+ - 本物の Stop の入口(`process-turn`)へ、本物の transcript の写しを、使い捨ての HOME で通した。空の DB から(Linux 3 本、
118
+ macOS 4 本、Windows 2 本): 途中の人の入力の数だけ見出しが入り、2回目の Stop は 0 行。前の版で取り込んだ後に直した版で
119
+ 通す(Linux 3 本、macOS 4 本、Windows 1 本): 行は増えない。
120
+ - 確かめていない:
121
+ - 直した版を入れた端末で、本物の Claude Code が、途中の入力のあるターンを終えた時の L2。入れた後の追跡で読む。
122
+ - `turnPosition` を書かない版の Claude Code。その時は 3 が効かず、今までと同じ動きになる。`origin` を書かない版の
123
+ `queued_command`(macOS と Windows の古い行にある)は、足さない。
@@ -0,0 +1,63 @@
1
+ # ADR 0061: Codex が自分で起こしたターンの入力は、印とゴールの文だけを記憶に残す
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-14
5
+
6
+ ## Context
7
+
8
+ Codex は、ゴール(`/goal`)が実行中のタスクで、手すきになるたびに自分で次のターンを起こす(ADR 0059)。そのターンの入力は、
9
+ rollout に user の発言(`response_item` の `message`、`role: "user"`)として入る。本文は `<codex_internal_context source="goal">` で
10
+ 始まり `</codex_internal_context>` で終わる。中身は、続け方の決まりを書いた定型文で、利用者が決めたゴールの文を
11
+ `<objective>` … `</objective>` に持つ。Linux の実機の試験タスクで読んだ1通は 6,343 字で、ゴールの文はそのうち約 200 字だった。
12
+
13
+ この製品は、user の発言から `# AGENTS.md instructions` と `<hook_prompt` で始まる物だけを外していた。ゴールの続行の入力は、
14
+ 利用者の発言としてそのまま L2 に入っていた。
15
+
16
+ 2026-10-13 の Windows の実機のタスクでは、L2 の user 15 行のうち 7 行がこの入力で(各 6,293 字)、user の字数の 87% を占めていた
17
+ (行の数と長さだけを読んだ。本文は読んでいない)。ゴールで動かすタスクでは、引き継ぎの記憶に入る直近のターンの user の本文が、
18
+ 同じ定型文の繰り返しになる。記憶の字数の枠を定型文が使い、利用者が実際に打った依頼が押し出される。
19
+
20
+ ゴールそのものは、ADR 0059 で、引き継ぎの時に後継へ運んでいる。記憶に定型文を残す理由は無い。
21
+
22
+ ## Decision
23
+
24
+ 1. user の発言が `<codex_internal_context` の開く印で始まり、閉じる印 `</codex_internal_context>` を持つ時、開く印(属性ごと)・
25
+ `<objective>` … `</objective>`(あれば)・閉じる印だけを残す(`compactCodexInternalContext`)。ほかの中身は落とす。
26
+ - 開く印の `source` は、何のターンかを表すので残す。
27
+ - ゴールの文は、利用者が決めた文なので残す。そのターンが何を目指して動いたかが、記憶から読める。
28
+ - 閉じる印の後ろに文があれば、残す。
29
+ - 閉じる印が無い時は、何も落とさない。形が分からない物を推測で切らない。
30
+ - 発言の途中に印がある物(頭に別の文がある物)は、触らない。
31
+ 2. ターンは落とさない。user の行は短い行として残る。ターンの番号の数え方は変えない。
32
+ 3. 同じ正規化を、user の発言を読む3か所(`event_msg` の `user_message`、`response_item` の `message`、圧縮で置き換わった履歴)に
33
+ 通す。巻き戻しの照合は、3か所の本文を比べるので、揃えておく。
34
+ 4. 判定は印だけで行う。定型文の言い回しは読まない。Codex が定型文を変えても、印と `<objective>` が同じなら動く。
35
+
36
+ ## Consequences
37
+
38
+ - ゴールで動くタスクの L2 で、続行のターンの user の行が、約 6,300 字から「印 + ゴールの文」になる。
39
+ - Codex の取り込みは、タスクの bodies を毎回全部入れ直す。前の版が保存した長い行は、次の取り込みで短い行に置き換わる。
40
+ 本文が変わったターンの L1 の要約は消えて、作り直される(今までの決まりのまま)。
41
+ - 引き継ぎ済みの旧タスクは、取り込みが走らないので、前の版が保存した行のまま残る。
42
+ - `<objective>` の中身は、Codex が書いた形のまま残る(`&lt;` などの置き換えを戻さない)。
43
+ - Claude Code・Grok・Cursor には、この形の入力が無い。変えない。
44
+
45
+ ## 確かめた事・確かめていない事
46
+
47
+ - 確かめた:
48
+ - 単体試験(定型文を落とす、後ろの文を残す、ゴールの文が無い形、閉じる印が無い・頭に無い・似た名前の印を触らない、
49
+ rollout を通した時の3つのターン)。
50
+ - 本物の rollout へ、前の版と直した版の読み手を当てて比べた(DB は開かない。数だけ)。3つの OS とも、ターンの数と行の数は同じで、
51
+ 続行の入力でない user の行と assistant の行は1つも変わらない。
52
+
53
+ | | 続行の入力のある rollout | 続行の入力の行 | その字数 | 直した後の一番長い行 |
54
+ |---|---|---|---|---|
55
+ | Linux | 9 本(試験のタスク) | 17 → 17 | 107,635 → 5,491 | 323 字 |
56
+ | macOS | 9 本(本物 1 本と試験のタスク) | 21 → 21 | 132,486 → 6,248 | 536 字 |
57
+ | Windows | 7 本(本物 3 本と試験のタスク) | 2,069 → 2,069 | 13,020,355 → 579,482 | 691 字 |
58
+
59
+ - 取り込みの入口(`codex-capture`)へ、試験のタスクの rollout の写しを、使い捨ての HOME で、前の版 → 直した版 → 直した版の順に
60
+ 通した(3つの OS)。前の版が入れた長い行(6,335〜6,567 字)が、直した版の取り込みで短い行(323〜557 字)に置き換わり、行の数と
61
+ details は変わらない。もう1回通しても同じ。
62
+ - 確かめていない: `source` が `goal` 以外の `<codex_internal_context>`。実物を見ていない。同じ形(開く印と閉じる印)なら、
63
+ 印だけが残る。直した版を入れた端末で、本物の Codex がゴールの続行のターンを終えた時の L2 は、入れた後の追跡で読む。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.28",
3
+ "version": "0.16.30",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -383,7 +383,7 @@ function compactedPayloadToUserMessages(payload, timestamp) {
383
383
  .map((item) => ({
384
384
  time: timestamp ?? null,
385
385
  role: 'user',
386
- text: normalizeMessageText(messageContentToText(item.content)),
386
+ text: normalizeUserMessageText(messageContentToText(item.content)),
387
387
  }))
388
388
  .filter((message) => message.text.length > 0);
389
389
  }
@@ -485,7 +485,7 @@ function removeRolledBackTurns(activeTurns, count) {
485
485
 
486
486
  function eventPayloadToMemoryMessage(payload, timestamp) {
487
487
  if (payload.type === 'user_message') {
488
- const text = normalizeMessageText(payload.message);
488
+ const text = normalizeUserMessageText(payload.message);
489
489
  if (!text || shouldSkipUserMessage(text)) return null;
490
490
  return {
491
491
  time: timestamp ?? null,
@@ -509,7 +509,8 @@ function eventPayloadToMemoryMessage(payload, timestamp) {
509
509
 
510
510
  function responseItemToConversationMessage(payload, timestamp) {
511
511
  if (payload?.type !== 'message' || !['user', 'assistant', 'developer'].includes(payload.role)) return null;
512
- const text = normalizeMessageText(messageContentToText(payload.content));
512
+ const rawText = messageContentToText(payload.content);
513
+ const text = payload.role === 'user' ? normalizeUserMessageText(rawText) : normalizeMessageText(rawText);
513
514
  if (!text) return null;
514
515
  if (payload.role === 'developer' && !isThroughlineInjectedDeveloperMemory(text)) return null;
515
516
  if (payload.role === 'user' && shouldSkipUserMessage(text)) return null;
@@ -551,6 +552,35 @@ function normalizeMessageText(value) {
551
552
  return value.replace(/\s+/g, ' ').trim();
552
553
  }
553
554
 
555
+ /** user の発言の正規化。空白をまとめた後、Codex が自分で書いた文脈の定型文を落とす (ADR 0061)。 */
556
+ function normalizeUserMessageText(value) {
557
+ return compactCodexInternalContext(normalizeMessageText(value));
558
+ }
559
+
560
+ const CODEX_INTERNAL_CONTEXT_OPEN = /^<codex_internal_context(?:\s[^>]*)?>/;
561
+ const CODEX_INTERNAL_CONTEXT_CLOSE = '</codex_internal_context>';
562
+ const CODEX_INTERNAL_OBJECTIVE = /<objective>[\s\S]*?<\/objective>/;
563
+
564
+ /**
565
+ * Codex が自分で起こしたターンの入力(ゴールの続行の指示など)は、`<codex_internal_context …>` で包まれて
566
+ * user の発言として rollout に入る。中身は毎回ほぼ同じ約 6,000 字の定型文で、人の発言ではない。記憶には、
567
+ * 開く印(`source` が何のターンかを表す)、利用者が決めたゴールの文(`<objective>`)、閉じる印だけを残す。
568
+ * 閉じる印の後ろに文があれば、それは残す。閉じる印が無い時は、形が分からないので何も落とさない。
569
+ * @param {string} text 空白をまとめた後の user の発言
570
+ * @returns {string}
571
+ */
572
+ export function compactCodexInternalContext(text) {
573
+ if (typeof text !== 'string') return text;
574
+ const open = CODEX_INTERNAL_CONTEXT_OPEN.exec(text);
575
+ if (!open) return text;
576
+ const close = text.indexOf(CODEX_INTERNAL_CONTEXT_CLOSE, open[0].length);
577
+ if (close < 0) return text;
578
+ const objective = CODEX_INTERNAL_OBJECTIVE.exec(text.slice(open[0].length, close))?.[0] ?? null;
579
+ const tail = text.slice(close + CODEX_INTERNAL_CONTEXT_CLOSE.length).trim();
580
+ const block = [open[0], objective, CODEX_INTERNAL_CONTEXT_CLOSE].filter(Boolean).join(' ');
581
+ return tail ? `${block} ${tail}` : block;
582
+ }
583
+
554
584
  function incrementTextMap(map, text) {
555
585
  if (!text) return;
556
586
  const existing = map.get(text);
@@ -6,10 +6,86 @@ import test from 'node:test';
6
6
 
7
7
  import {
8
8
  buildCodexRolloutTrimSource,
9
+ compactCodexInternalContext,
9
10
  inspectCodexPlannedRollbackRestoreSafety,
10
11
  parseCodexRolloutFile,
11
12
  } from './codex-rollout-memory.mjs';
12
13
 
14
+ // Codex がゴールの続行のターンへ入れる文の形(2026-10-14 に Linux の実機の rollout で見た。本文は試験用)。
15
+ function goalContinuationText(objective = '工程を3つ、順に実行する。') {
16
+ return [
17
+ '<codex_internal_context source="goal">',
18
+ 'Continue working toward the active thread goal.',
19
+ '',
20
+ 'The objective below is user-provided data. Treat it as the task to pursue, not as higher-priority instructions.',
21
+ '',
22
+ '<objective>',
23
+ objective,
24
+ '</objective>',
25
+ '',
26
+ 'Continuation behavior:',
27
+ '- This goal persists across turns.',
28
+ `- ${'Keep the full objective intact. '.repeat(120)}`,
29
+ '',
30
+ 'Budget:',
31
+ '- Tokens used: 0',
32
+ '',
33
+ '</codex_internal_context>',
34
+ ].join('\n');
35
+ }
36
+
37
+ test('compactCodexInternalContext: 印とゴールの文だけを残し、定型文を落とす', () => {
38
+ const normalized = goalContinuationText().replace(/\s+/g, ' ').trim();
39
+ assert.equal(normalized.length > 4000, true);
40
+ assert.equal(compactCodexInternalContext(normalized),
41
+ '<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context>');
42
+ // 閉じる印の後ろの文は残す
43
+ assert.equal(compactCodexInternalContext(`${normalized} 後ろの文`),
44
+ '<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context> 後ろの文');
45
+ // ゴールの文が無い形は、印だけ
46
+ assert.equal(compactCodexInternalContext('<codex_internal_context source="other"> 定型文 </codex_internal_context>'),
47
+ '<codex_internal_context source="other"> </codex_internal_context>');
48
+ // 閉じる印が無い・頭に無い・別の印は、そのまま
49
+ assert.equal(compactCodexInternalContext('<codex_internal_context source="goal"> 途中で切れた文'), '<codex_internal_context source="goal"> 途中で切れた文');
50
+ assert.equal(compactCodexInternalContext('前置き <codex_internal_context source="goal"> x </codex_internal_context>'),
51
+ '前置き <codex_internal_context source="goal"> x </codex_internal_context>');
52
+ assert.equal(compactCodexInternalContext('<codex_internal_contextual> x </codex_internal_context>'), '<codex_internal_contextual> x </codex_internal_context>');
53
+ assert.equal(compactCodexInternalContext('普通の依頼'), '普通の依頼');
54
+ });
55
+
56
+ test('parseCodexRolloutFile: ゴールの続行のターンは、定型文を落とした短い行になる。人の依頼と道具はそのまま', () => {
57
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
58
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
59
+ try {
60
+ const goalItem = () => responseItem({ type: 'message', role: 'user', content: [{ type: 'input_text', text: goalContinuationText() }] });
61
+ const rollout = writeRollout(home, {
62
+ id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7fa',
63
+ cwd: project,
64
+ events: [
65
+ event('user_message', { message: '最初の依頼' }),
66
+ event('task_started'),
67
+ event('agent_message', { message: '最初の回答' }),
68
+ event('task_complete'),
69
+ event('task_started'),
70
+ goalItem(),
71
+ event('agent_message', { message: 'ゴールの続きの報告' }),
72
+ event('task_complete'),
73
+ event('task_started'),
74
+ goalItem(),
75
+ ],
76
+ });
77
+ const parsed = parseCodexRolloutFile(rollout);
78
+ assert.deepEqual(parsed.activeTurns.map((turn) => turn.messages.map((message) => [message.role, message.text.length < 200 ? message.text : `long:${message.text.length}`])), [
79
+ [['user', '最初の依頼'], ['assistant', '最初の回答']],
80
+ [['user', '<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context>'], ['assistant', 'ゴールの続きの報告']],
81
+ [['user', '<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context>']],
82
+ ]);
83
+ } finally {
84
+ rmSync(home, { recursive: true, force: true });
85
+ rmSync(project, { recursive: true, force: true });
86
+ }
87
+ });
88
+
13
89
  test('parseCodexRolloutFile: applies rollback events before rendering active work', () => {
14
90
  const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
15
91
  const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
@@ -5,7 +5,9 @@
5
5
  * 実際のフォーマット(確認済み):
6
6
  * {type: "user", message: {role: "user", content: [{type:"text", text:"..."}]}, ...}
7
7
  * {type: "assistant", message: {role: "assistant", content: [{type:"text", text:"..."}, {type:"thinking", ...}]}, ...}
8
- * 他に queue-operation, attachment, file-history-snapshot 等があるが無視する
8
+ * {type: "attachment", attachment: {type: "queued_command", prompt, origin: {kind}}, ...}
9
+ * ターンの途中で届いた入力。届いた先のターンの依頼へ足す (ADR 0060)
10
+ * 他に queue-operation, file-history-snapshot, ほかの attachment 等があるが、本文としては無視する
9
11
  */
10
12
 
11
13
  import { readFileSync, existsSync } from 'fs';
@@ -34,6 +36,59 @@ export function isCompactSummaryEntry(entry) {
34
36
  return entry?.isCompactSummary === true;
35
37
  }
36
38
 
39
+ /**
40
+ * Claude Code が自分で書く meta の user 行か(ADR 0060)。skill を読んだ時の本文、端末で打った命令の注意書きなど。
41
+ * type は user だが、人の発言でも、外から届いた入力でもない。ターンの始まりにも本文にもしない。
42
+ * 別の会話から届いた発言も `isMeta: true` だが、`origin.kind` を持つ。届いた入力なので、ここには入れない。
43
+ * @param {object} entry
44
+ */
45
+ export function isClaudeHostWrittenMetaEntry(entry) {
46
+ return entry?.type === 'user'
47
+ && entry.message !== null && typeof entry.message === 'object'
48
+ && entry.isMeta === true
49
+ && typeof entry.origin?.kind !== 'string';
50
+ }
51
+
52
+ /** ターンの始まりにも本文にもしない user 行か。圧縮の要約(ADR 0032)と、host が書いた meta の行(ADR 0060)。 */
53
+ function isHostWrittenClaudeUserEntry(entry) {
54
+ return isCompactSummaryEntry(entry) || isClaudeHostWrittenMetaEntry(entry);
55
+ }
56
+
57
+ // ターンの途中で届いた入力のうち、依頼へ足す物。人が打った文と、別の会話から届いた発言。
58
+ // 裏の作業の完了通知(task-notification)は、そのターン自身の作業の結果なので足さない。
59
+ const CLAUDE_MID_TURN_INPUT_ORIGINS = Object.freeze(['human', 'peer']);
60
+
61
+ /** ターンの途中で届いた入力を、依頼の後ろへ足す時の見出し。記憶を読む側が、最初の依頼と見分けられる様にする。 */
62
+ export const MID_TURN_INPUT_MARK = '(作業の途中で届いた入力)';
63
+
64
+ /**
65
+ * Claude Code が、動いているターンへ届けた入力の本文(ADR 0060)。当たらない行は null。
66
+ * 手すきの時に届けば user 行になる入力が、ターンの途中では `attachment` の `queued_command` で書かれる。
67
+ * @param {object} entry transcript JSONL の 1 行
68
+ * @returns {string|null}
69
+ */
70
+ function claudeMidTurnInputText(entry) {
71
+ if (entry?.type !== 'attachment' || entry.isSidechain === true) return null;
72
+ const attachment = entry.attachment;
73
+ if (attachment?.type !== 'queued_command') return null;
74
+ if (!CLAUDE_MID_TURN_INPUT_ORIGINS.includes(attachment.origin?.kind)) return null;
75
+ if (typeof attachment.prompt !== 'string' && !Array.isArray(attachment.prompt)) return null;
76
+ const text = normalizeTerminalText(compactClaudePeerMessage(extractText(attachment.prompt)));
77
+ return text ? text : null;
78
+ }
79
+
80
+ /**
81
+ * ターンの終わりに、待っていた入力が続けて user 行になった物か(ADR 0060)。Claude Code は、同じ番へ届けた
82
+ * user 行に同じ `turnPosition.promptIndex` を付ける。両方が `origin.kind` を持つ(届いた入力である)時だけ当たる。
83
+ */
84
+ function isSameClaudePromptDelivery(previous, entry) {
85
+ const index = entry.turnPosition?.promptIndex;
86
+ return Number.isSafeInteger(index)
87
+ && previous?.promptIndex === index
88
+ && typeof entry.origin?.kind === 'string'
89
+ && previous.hasOrigin === true;
90
+ }
91
+
37
92
  const GROK_HOST_WRITTEN_SYNTHETIC_REASONS = Object.freeze(['compaction_meta', 'system_reminder']);
38
93
 
39
94
  /**
@@ -72,9 +127,14 @@ export function readTranscript(transcriptPath) {
72
127
  const raw = readFileSync(transcriptPath, 'utf8');
73
128
 
74
129
  const turns = [];
75
- // turn_number は transcript 内の本文行の通し番号。圧縮の要約行は返さないが番号は進める。
76
- // 番号を詰めると、要約行を数えていた頃に保存した bodies の turn_number と食い違う。
130
+ // turn_number は transcript 内の本文行の通し番号。圧縮の要約行と host が書いた meta の行は返さないが、番号は進める。
131
+ // 番号を詰めると、その行を数えていた頃に保存した bodies の turn_number と食い違う。
77
132
  let position = 0;
133
+ // 直前に返した user の行。ターンの途中で届いた入力と、続けて届いた待ち入力を、この行の本文へ足す (ADR 0060)。
134
+ let currentUser = null;
135
+ // currentUser の後に assistant の本文が出たか。出た後の user 行は、次のターンの始まり。
136
+ let currentUserAnswered = false;
137
+ let currentUserDelivery = null;
78
138
  for (const line of raw.split('\n')) {
79
139
  const trimmed = line.trim();
80
140
  if (!trimmed) continue;
@@ -86,6 +146,13 @@ export function readTranscript(transcriptPath) {
86
146
  continue;
87
147
  }
88
148
 
149
+ // ターンの途中で届いた入力。本文行ではないので、番号は進めない。
150
+ const midTurnInput = claudeMidTurnInputText(entry);
151
+ if (midTurnInput !== null) {
152
+ if (currentUser) currentUser.content += `\n\n${MID_TURN_INPUT_MARK}\n${midTurnInput}`;
153
+ continue;
154
+ }
155
+
89
156
  // user / assistant エントリのみ対象。Claude は type、Cursor jsonl は role。
90
157
  const kind = entryKind(entry);
91
158
  if (kind !== 'user' && kind !== 'assistant') continue;
@@ -113,10 +180,18 @@ export function readTranscript(transcriptPath) {
113
180
  if (isCompactSummaryEntry(entry)) continue;
114
181
  // Grok の合成行も host が書いた行。番号は進める(数えていた頃に保存した turn_number と食い違わせない)。
115
182
  if (isGrokHostWrittenUserEntry(entry)) continue;
183
+ // Claude Code の meta の行も host が書いた行。ここで区切ると、その前の依頼が本文の無い群になって消える。
184
+ if (isClaudeHostWrittenMetaEntry(entry)) continue;
185
+
186
+ if (role === 'user' && currentUser && !currentUserAnswered && isSameClaudePromptDelivery(currentUserDelivery, entry)) {
187
+ // 待っていた入力が、同じ番へ続けて届いた。1つの依頼にまとめる。別の行にすると、手前の入力が本文の無い群になって消える。
188
+ currentUser.content += `\n\n${text}`;
189
+ continue;
190
+ }
116
191
 
117
192
  const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
118
193
  // grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
119
- turns.push({
194
+ const turn = {
120
195
  role,
121
196
  content: text,
122
197
  turn_number: turnNumber,
@@ -128,7 +203,18 @@ export function readTranscript(transcriptPath) {
128
203
  prompt_id: typeof entry.promptId === 'string' ? entry.promptId : null,
129
204
  }
130
205
  : {}),
131
- });
206
+ };
207
+ turns.push(turn);
208
+ if (role === 'user') {
209
+ currentUser = turn;
210
+ currentUserAnswered = false;
211
+ currentUserDelivery = {
212
+ promptIndex: entry.turnPosition?.promptIndex,
213
+ hasOrigin: typeof entry.origin?.kind === 'string',
214
+ };
215
+ } else {
216
+ currentUserAnswered = true;
217
+ }
132
218
  }
133
219
 
134
220
  return turns;
@@ -403,8 +489,8 @@ export function sliceCurrentTurnEntries(entries) {
403
489
  for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
404
490
  const e = entries[i];
405
491
  if (entryKind(e) !== 'user') continue;
406
- // 圧縮の要約行はターンの始まりではない。圧縮より前の tool 入出力も同じターンに含める。
407
- if (isCompactSummaryEntry(e)) continue;
492
+ // 圧縮の要約行と host が書いた meta の行は、ターンの始まりではない。その前の tool 入出力も同じターンに含める。
493
+ if (isHostWrittenClaudeUserEntry(e)) continue;
408
494
  const blocks = e.message?.content;
409
495
  if (Array.isArray(blocks)) {
410
496
  if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
@@ -433,7 +519,7 @@ export function sliceCurrentTurnEntries(entries) {
433
519
  export function sliceInFlightTurnEntries(entries) {
434
520
  for (let i = entries.length - 1; i >= 0; i--) {
435
521
  const e = entries[i];
436
- if (entryKind(e) !== 'user' || isCompactSummaryEntry(e)) continue;
522
+ if (entryKind(e) !== 'user' || isHostWrittenClaudeUserEntry(e)) continue;
437
523
  const blocks = e.message?.content;
438
524
  const hasText = Array.isArray(blocks)
439
525
  ? blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)
@@ -552,3 +552,162 @@ test('sliceInFlightTurnEntries: 最後の user 本文から末尾まで。最後
552
552
  assert.deepEqual(sliceInFlightTurnEntries([entries[1]]), []);
553
553
  });
554
554
 
555
+
556
+ // --- ADR 0060: Claude Code の、ターンの途中で届いた入力・host が書く meta の行・続けて届いた待ち入力 ---
557
+
558
+ function humanEntry(text, extra = {}) {
559
+ return { type: 'user', origin: { kind: 'human' }, promptSource: 'typed', message: { role: 'user', content: text }, ...extra };
560
+ }
561
+ function queuedCommandEntry(prompt, extra = {}) {
562
+ return {
563
+ type: 'attachment',
564
+ attachment: { type: 'queued_command', prompt, commandMode: 'prompt', origin: { kind: 'human' }, humanTurn: true, ...extra },
565
+ };
566
+ }
567
+ /** skill を読んだ時などに Claude Code が書く行。origin が無い。 */
568
+ function hostMetaEntry(text) {
569
+ return { type: 'user', isMeta: true, message: { role: 'user', content: [{ type: 'text', text }] } };
570
+ }
571
+
572
+ test('readTranscript: ターンの途中で届いた人の入力は、そのターンの依頼へ届いた順に足す。番号は進めない', () => {
573
+ const path = writeTranscript([
574
+ humanEntry('最初の依頼'),
575
+ asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
576
+ queuedCommandEntry('途中の追加1'),
577
+ userToolResultEntry('toolu_1', 'ok'),
578
+ asstTextEntry('途中の報告'),
579
+ queuedCommandEntry([{ type: 'text', text: '途中の追加2' }, { type: 'image', source: {} }]),
580
+ asstTextEntry('最終回答'),
581
+ humanEntry('次の依頼'),
582
+ asstTextEntry('次の回答'),
583
+ ]);
584
+ const turns = readTranscript(path);
585
+ assert.deepEqual(turns.map((t) => [t.role, t.turn_number]), [
586
+ ['user', 0], ['assistant', 1], ['assistant', 2], ['user', 3], ['assistant', 4],
587
+ ]);
588
+ assert.equal(turns[0].content,
589
+ '最初の依頼\n\n(作業の途中で届いた入力)\n途中の追加1\n\n(作業の途中で届いた入力)\n途中の追加2');
590
+ assert.equal(turns[0].start, 'prompt');
591
+ assert.equal(turns[3].content, '次の依頼');
592
+ const groups = getLogicalTurnGroups(path);
593
+ assert.equal(groups[0].representative.index, 2);
594
+ assert.equal(groups[0].user.content.startsWith('最初の依頼\n\n(作業の途中で届いた入力)\n途中の追加1'), true);
595
+ });
596
+
597
+ test('readTranscript: 途中で届いた完了通知と、subagent の中の入力は依頼へ足さない。別の会話からの文は注意書きを落として足す', () => {
598
+ const path = writeTranscript([
599
+ humanEntry('依頼'),
600
+ queuedCommandEntry('<task-notification>\n<status>completed</status>\n</task-notification>',
601
+ { commandMode: 'task-notification', origin: { kind: 'task-notification' }, humanTurn: undefined }),
602
+ { ...queuedCommandEntry('subagent への入力'), isSidechain: true },
603
+ queuedCommandEntry('印の無い入力', { origin: undefined }),
604
+ queuedCommandEntry('Another Claude session sent a message:\n本文\n\nThis came from another Claude session, not a human.',
605
+ { origin: { kind: 'peer' } }),
606
+ asstTextEntry('回答'),
607
+ ]);
608
+ assert.equal(readTranscript(path)[0].content,
609
+ '依頼\n\n(作業の途中で届いた入力)\nAnother Claude session sent a message:\n本文');
610
+ });
611
+
612
+ test('readTranscript: 依頼より前に書かれた途中の入力は、足す先が無いので捨てる', () => {
613
+ const path = writeTranscript([queuedCommandEntry('宙に浮いた入力'), asstTextEntry('回答')]);
614
+ assert.deepEqual(readTranscript(path).map((t) => [t.role, t.content]), [['assistant', '回答']]);
615
+ });
616
+
617
+ test('readTranscript: host が書いた meta の行(origin なし)は返さず、ターンも区切らない。番号は詰めない', () => {
618
+ const path = writeTranscript([
619
+ humanEntry('依頼'),
620
+ asstToolUseEntry('toolu_1', 'Skill', { skill: 'x' }),
621
+ userToolResultEntry('toolu_1', 'Launching skill: x'),
622
+ hostMetaEntry('Base directory for this skill: /x\n\n手順…'),
623
+ asstTextEntry('最終回答'),
624
+ ]);
625
+ assert.deepEqual(readTranscript(path).map((t) => [t.role, t.content, t.turn_number]), [
626
+ ['user', '依頼', 0],
627
+ ['assistant', '最終回答', 2],
628
+ ]);
629
+ const groups = getLogicalTurnGroups(path);
630
+ assert.equal(groups.length, 1);
631
+ assert.equal(groups[0].user.content, '依頼');
632
+ assert.equal(groups[0].representative.index, 2);
633
+ assert.equal(readLatestUserGroup(path).user.content, '依頼');
634
+ });
635
+
636
+ test('readTranscript: origin を持つ meta の行(別の会話から届いた発言)は、今までどおりターンの始まり', () => {
637
+ const path = writeTranscript([
638
+ humanEntry('依頼'),
639
+ asstTextEntry('回答'),
640
+ { type: 'user', isMeta: true, origin: { kind: 'peer', from: 'x' },
641
+ message: { role: 'user', content: 'Another Claude session sent a message:\n本文\n\nThis came from another Claude session.' } },
642
+ asstTextEntry('返事'),
643
+ ]);
644
+ assert.deepEqual(getLogicalTurnGroups(path).map((g) => [g.user.content, g.user.start]), [
645
+ ['依頼', 'prompt'],
646
+ ['Another Claude session sent a message:\n本文', 'unknown'],
647
+ ]);
648
+ });
649
+
650
+ test('readTranscript: ターンの終わりに続けて届いた待ち入力(同じ promptIndex)は、1つの依頼にまとめる', () => {
651
+ const queued = (text, promptIndex, id) => humanEntry(text,
652
+ { promptSource: 'queued', promptId: id, turnPosition: { promptIndex, turnIndex: promptIndex } });
653
+ const path = writeTranscript([
654
+ humanEntry('前の依頼', { promptId: 'p0', turnPosition: { promptIndex: 8, turnIndex: 8 } }),
655
+ asstTextEntry('前の回答'),
656
+ queued('待っていた1通目', 9, 'p1'),
657
+ { type: 'attachment', attachment: { type: 'hook_success', hookEvent: 'UserPromptSubmit', content: '' } },
658
+ queued('待っていた2通目', 9, 'p2'),
659
+ asstTextEntry('回答'),
660
+ queued('別の番の入力', 10, 'p3'),
661
+ asstTextEntry('次の回答'),
662
+ ]);
663
+ const turns = readTranscript(path);
664
+ assert.deepEqual(turns.map((t) => [t.role, t.content, t.turn_number]), [
665
+ ['user', '前の依頼', 0],
666
+ ['assistant', '前の回答', 1],
667
+ ['user', '待っていた1通目\n\n待っていた2通目', 2],
668
+ ['assistant', '回答', 4],
669
+ ['user', '別の番の入力', 5],
670
+ ['assistant', '次の回答', 6],
671
+ ]);
672
+ assert.equal(turns[2].prompt_id, 'p1');
673
+ assert.equal(getLogicalTurnGroups(path)[1].representative.index, 4);
674
+ });
675
+
676
+ test('readTranscript: promptIndex が違う・印が無い・間に本文がある user 行は、まとめない', () => {
677
+ const at = (promptIndex) => ({ turnPosition: { promptIndex, turnIndex: promptIndex } });
678
+ assert.deepEqual(readTranscript(writeTranscript([
679
+ humanEntry('止められた依頼', at(1)), humanEntry('打ち直した依頼', at(2)), asstTextEntry('回答'),
680
+ ])).map((t) => t.content), ['止められた依頼', '打ち直した依頼', '回答']);
681
+ assert.deepEqual(readTranscript(writeTranscript([
682
+ userEntry('印の無い行'), userEntry('印の無い行2'), asstTextEntry('回答'),
683
+ ])).map((t) => t.content), ['印の無い行', '印の無い行2', '回答']);
684
+ // 端末で打った命令の行(origin なし)は、同じ promptIndex を持っていても人の入力とまとめない
685
+ assert.deepEqual(readTranscript(writeTranscript([
686
+ { type: 'user', message: { role: 'user', content: '<local-command-stdout>x</local-command-stdout>' }, ...at(3) },
687
+ humanEntry('依頼', at(3)), asstTextEntry('回答'),
688
+ ])).map((t) => t.content), ['<local-command-stdout>x</local-command-stdout>', '依頼', '回答']);
689
+ });
690
+
691
+ test('sliceCurrentTurnEntries / sliceInFlightTurnEntries: host が書いた meta の行で区切らず、その前の道具も同じターンに含める', () => {
692
+ const entries = [
693
+ humanEntry('前の依頼'),
694
+ asstTextEntry('前の回答'),
695
+ humanEntry('今の依頼'),
696
+ asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
697
+ userToolResultEntry('toolu_1', 'before skill'),
698
+ asstToolUseEntry('toolu_2', 'Skill', { skill: 'x' }),
699
+ userToolResultEntry('toolu_2', 'Launching skill: x'),
700
+ hostMetaEntry('skill の本文'),
701
+ asstToolUseEntry('toolu_3', 'Write', { file_path: '/b' }),
702
+ userToolResultEntry('toolu_3', 'after skill'),
703
+ ];
704
+ const inFlight = sliceInFlightTurnEntries(entries);
705
+ assert.equal(inFlight[0].message.content, '今の依頼');
706
+ assert.deepEqual(
707
+ extractDetailBlocks(inFlight).filter((d) => d.kind === DETAIL_KIND.TOOL_INPUT).map((d) => d.tool_name),
708
+ ['Read', 'Skill', 'Write'],
709
+ );
710
+ const done = sliceCurrentTurnEntries([...entries, asstTextEntry('最終回答')]);
711
+ assert.equal(done[0].message.content, '今の依頼');
712
+ assert.equal(done.length, 9);
713
+ });
@@ -504,3 +504,63 @@ test('captureInFlightTurn: 発言が1つも無いターンは、user の行と
504
504
  );
505
505
  });
506
506
 
507
+
508
+ // --- ADR 0060: host が書いた meta の行でターンを区切っていた頃の行と、途中で届いた入力 ---
509
+
510
+ test('backfillBodies: meta の行で区切っていた頃に保存したターンは、群がまとまっても重ねて入れない', () => {
511
+ const meta = { type: 'user', isMeta: true, message: { role: 'user', content: [{ type: 'text', text: 'skill の本文' }] } };
512
+ const human = (text) => entry('user', text, undefined, { origin: { kind: 'human' } });
513
+ const seeded = (turns) => {
514
+ const db = makeDb();
515
+ const insert = db.prepare(`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, 1)`);
516
+ for (const [turn, user] of turns) {
517
+ insert.run('target', 'origin', turn, 'user', user);
518
+ insert.run('target', 'origin', turn, 'assistant', 'saved');
519
+ }
520
+ return db;
521
+ };
522
+ const run = (db, entries) => withData(entries, (path) =>
523
+ backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
524
+
525
+ // 前の版: [依頼, 途中の報告] と [skill の本文, 最終回答] の2群。今の版: 1群(断片 1 と 3)。
526
+ const both = seeded([[1, '依頼'], [3, 'skill の本文']]);
527
+ const split = [human('依頼'), entry('assistant', '途中の報告'), meta, entry('assistant', '最終回答')];
528
+ assert.deepEqual(pick(run(both, split)), { groups: 1, insertedTurns: 0, skippedExisting: 1 });
529
+ assert.equal(both.prepare('SELECT COUNT(*) AS n FROM bodies').get().n, 4);
530
+
531
+ // 前の版: 依頼は本文の無い群で消え、[skill の本文, 最終回答] だけが入っていた。
532
+ const lost = seeded([[2, 'skill の本文']]);
533
+ assert.deepEqual(pick(run(lost, [human('依頼'), meta, entry('assistant', '最終回答')])),
534
+ { groups: 1, insertedTurns: 0, skippedExisting: 1 });
535
+
536
+ // 新しい会話では、依頼が user の行になる。
537
+ const fresh = makeDb();
538
+ assert.deepEqual(pick(run(fresh, [human('依頼'), meta, entry('assistant', '最終回答')])),
539
+ { groups: 1, insertedTurns: 1, skippedExisting: 0 });
540
+ assert.deepEqual(fresh.prepare('SELECT turn_number, role, text, turn_start FROM bodies ORDER BY role DESC').all().map((r) => ({ ...r })), [
541
+ { turn_number: 2, role: 'user', text: '依頼', turn_start: 'prompt' },
542
+ { turn_number: 2, role: 'assistant', text: '最終回答', turn_start: null },
543
+ ]);
544
+ });
545
+
546
+ function pick(result) {
547
+ return { groups: result.groups, insertedTurns: result.insertedTurns, skippedExisting: result.skippedExisting };
548
+ }
549
+
550
+ test('backfillBodies / captureInFlightTurn: ターンの途中で届いた入力は、依頼の行へ入る', () => {
551
+ const steer = (prompt) => ({ type: 'attachment', attachment: { type: 'queued_command', prompt, origin: { kind: 'human' } } });
552
+ const entries = [
553
+ entry('user', '依頼', undefined, { origin: { kind: 'human' } }),
554
+ steer('追加の指示'),
555
+ entry('assistant', '回答'),
556
+ ];
557
+ const done = makeDb();
558
+ withData(entries, (path) => backfillBodies(done, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
559
+ assert.equal(done.prepare(`SELECT text FROM bodies WHERE role = 'user'`).get().text, '依頼\n\n(作業の途中で届いた入力)\n追加の指示');
560
+
561
+ const stopped = makeDb();
562
+ stopped.exec(`CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, origin_session_id TEXT, turn_number INTEGER,
563
+ tool_name TEXT, input_text TEXT, output_text TEXT, token_count INTEGER, created_at INTEGER, kind TEXT, source_id TEXT)`);
564
+ withData(entries.slice(0, 2), (path) => captureInFlightTurn(stopped, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
565
+ assert.equal(stopped.prepare(`SELECT text FROM bodies WHERE role = 'user'`).get().text, '依頼\n\n(作業の途中で届いた入力)\n追加の指示');
566
+ });