throughline 0.15.0 → 0.15.2

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,42 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.15.2] — 2026-10-06
14
+
15
+ ### 修正
16
+
17
+ - Claude Desktopで「フォルダなし」で始めて、後から作業フォルダへ移した会話では、自動継続(`auto-handoff enable --host claude`)を有効にしていても引き継ぎが始まらない不具合を直す。Desktopは会話を移した後も、hookへ渡す`CLAUDE_PROJECT_DIR`を移る前の場所のままにする。Throughlineはその値で対象のprojectかどうかを決めていたので、自動圧縮の前のhookが`auto_handoff_disabled`で見送り、圧縮がそのまま走っていた。
18
+ - 会話が移った先は、Claude Codeがtranscriptへ書く`relocated`の行(`relocatedCwd`)から読む。移っている会話では、自動継続の有効判定と印(baton)のprojectを、移った先にする。
19
+ - 同じ会話のターン終了では、完了ターンの控えを移った先のprojectへ書き、`sessions.project_path`を移った先へ付け替える。これまでは移る前の場所のまま残り、移った先のprojectの過去の会話(`handoff-context`、`throughline detail`)にも`observer-read`にも、その会話が出なかった。
20
+ - 移っていない会話の扱いは変わらない。`relocated`の行が無ければ、これまでどおり`CLAUDE_PROJECT_DIR`、無ければhookの`cwd`を使う。
21
+
22
+ ### 確認した範囲
23
+
24
+ - macOS(Claude Desktop同梱の2.1.286が書いた本物のtranscriptの写し、移った後に自動圧縮が2回走った会話)。切り離したHOMEでhookを手で呼び、0.14.2は`pre-compact`が終了コード0・`auto_handoff_disabled`、`sessions.project_path`と控えは移る前の場所。この版は`pre-compact`が終了コード2(圧縮を止める)・`requested`、`sessions.project_path`と控えは移った先、`observer-read`は移った先のprojectで`snapshot`を返す。
25
+ - macOS、端末から始めた移っていない会話(Claude Code 2.1.289、Haiku 4.5)。この版で4回連続で引き継ぎ、14個を番号順に読み切って完了。自動圧縮は0回。
26
+
27
+ ### 確認していない範囲
28
+
29
+ - Claude Desktopの画面で、この版が通しで引き継ぐこと(旧い会話を止める所と、後継を立てる所)。Desktopの画面からの送信が要るため、hookを手で呼ぶ確認までにとどまる。
30
+ - Windows・Linuxで移した会話。`relocated`の行の読み取りは、Windowsの書き方の場所を単体の試験で確かめただけ。
31
+
32
+ ## [0.15.1] — 2026-10-05
33
+
34
+ ### 修正
35
+
36
+ - Claudeの席で、完了ターンの控えが256件に達した後、`observer-read`・`observer-wait`へ前に受け取った位置(`--after-cursor`)を渡すと、1ターン増えるたびに`resync_required`が返る不具合を直す([ADR 0035](docs/adr/0035-observer-cursor-prefix-only.md))。位置の検証が、位置に入っている控えの下限の番号と今の下限の番号を比べていた。上限に達すると1ターンごとに古い1件が落ちて下限が1上がるので、追いついている位置も必ず無効になった。下限はproject全体で1つなので、落ちた控えが別の古い会話のものでも同じだった。
37
+ - 位置の検証は、位置が指す会話の先頭から読んだ分が今も同じかどうか(`prefix_sha256`の照合)だけで行う。別の古い会話の控えが落ちても、今の会話の位置は有効なままで、`append`・`delta`が返る。
38
+ - 位置の形、snapshotの返し方、`resync_required`を終了コード0で返すことは変わらない。0.15.0以前が出した位置は、そのまま使える。
39
+
40
+ ### 残る制限
41
+
42
+ - 1つの会話だけで控えが256件を超えると、その会話の先頭が落ちるので、その会話の位置は今までどおり1ターンごとに`resync_required`になる。
43
+
44
+ ### 確認した範囲
45
+
46
+ - 古い会話の控えで上限まで埋まったprojectで、新しい会話のターンを1つずつ足す再現。0.15.0は2ターン目から毎回`resync_required`、0.15.1は毎回`append`と、足したターンだけの`delta`を返す。
47
+ - BellTeamのコンテナで、控えが上限に達している席(連番は329まで進み、保持は256件、会話は27本、最新の会話の控えは14件)の控えを一時ファイルへ写し、最新の会話へ3ターン足した。読み終えた位置を毎回取り直しても、0.15.0は3回とも`resync_required`、0.15.1は3回とも`append`を返す。控えはhashとsession idだけで、本文は含まない。
48
+
13
49
  ## [0.15.0] — 2026-10-05
14
50
 
15
51
  ### 追加
@@ -1754,7 +1790,9 @@ two attempts, instrument first instead of patching again.
1754
1790
 
1755
1791
  ---
1756
1792
 
1757
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.0...HEAD
1793
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.2...HEAD
1794
+ [0.15.2]: https://github.com/kitepon/Throughline/compare/v0.15.1...v0.15.2
1795
+ [0.15.1]: https://github.com/kitepon/Throughline/compare/v0.15.0...v0.15.1
1758
1796
  [0.15.0]: https://github.com/kitepon/Throughline/compare/v0.14.3...v0.15.0
1759
1797
  [0.14.3]: https://github.com/kitepon/Throughline/compare/v0.14.2...v0.14.3
1760
1798
  [0.14.2]: https://github.com/kitepon/Throughline/compare/v0.14.1...v0.14.2
@@ -31,6 +31,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
31
31
  1. **圧縮を止める**: `PreCompact` hook(`throughline pre-compact`、matcher 無し)が、`trigger: "auto"` で自動継続が
32
32
  有効な project の時だけ、`/tl` と同じ印(baton)と引き継ぎの記録を残し、exit code 2 で圧縮を止める。
33
33
  印の project は、会話を起動した場所(`CLAUDE_PROJECT_DIR`)を、その OS の書き方にそろえたもの(0.14.2)。
34
+ Claude Desktop で会話を別の project へ移している時は、transcript の `relocated` の行(`relocatedCwd`)が示す移った先を使う。
35
+ `CLAUDE_PROJECT_DIR` は移る前の場所のまま届くため(0.15.2)。
34
36
  hook の cwd は Bash の `cd` に追従するので使わない。後継は起動した場所で立ち、その cwd で印を探す。
35
37
  手動の `/compact`、無効な project、subagent の中の圧縮(payload に `agent_id`)、script から起動した会話
36
38
  (`CLAUDE_CODE_ENTRYPOINT` が `sdk-` で始まる)では止めない。
@@ -0,0 +1,49 @@
1
+ # ADR 0035: Observer の位置は、下限の番号を比べず、先頭からの照合だけで検証する
2
+
3
+ 日付: 2026-10-05
4
+
5
+ ## Context
6
+
7
+ ADR 0003 は、Claude の完了ターンの控え(project ごとに 256 件まで、`COMPLETED_TURN_RECEIPT_LIMIT`)から
8
+ 作る chain に位置(cursor)を結び、「保持の下限より古い位置は resync を求める」と決めた。位置には、
9
+ 発行した時点の控えの下限の番号(`history_floor`)、会話の chain の長さ(`length`)、先頭から `length` 件の
10
+ digest(`prefix_sha256`)が入る。
11
+
12
+ 実装は「位置の `history_floor` < 今の `history_floor`」なら `resync_required` にしていた。控えが 256 件に
13
+ 達すると、1 ターン増えるたびに古い 1 件が落ちて下限の番号が 1 上がる。そのため、追いついている位置も、
14
+ 次の 1 ターンで必ず無効になった。下限は project 全体で 1 つなので、落ちた控えが別の古い会話のものでも、
15
+ 今の会話の位置が無効になった。
16
+
17
+ BellTeam(0.14.3、2026-10-05)で、控えが 323 件ある席の読み出しが、ターン 10 回すべてで `resync_required` に
18
+ なった。BellTeam は `resync_required` のたびに今の位置から読み直していたので、その間に終わったターンの
19
+ 回答が画面に出なかった(同じ日に 2 回)。BellTeam のビスケットとトロニーが見つけた。
20
+
21
+ `history_floor` の比較が無くても、位置が指す会話の先頭の控えが落ちたことは分かる。先頭が k 件落ちると、
22
+ 今の chain の先頭から `length` 件は、発行した時の先頭から `length` 件と違う控えになり、`prefix_sha256` が
23
+ 合わない(各項目は控えの連番から作る `source_sha256` を含む)。chain が `length` より短くなった時も無効になる。
24
+
25
+ ## Decision
26
+
27
+ 1. 位置の検証から `history_floor` の比較を外す。位置が有効なのは、その会話の chain が `length` 件以上あり、
28
+ 先頭から `length` 件の digest が `prefix_sha256` と一致する時だけとする。`resolveObserverTurnFeed` と
29
+ `readObserverTurnPage`(`validateCursorCandidate`)の両方。
30
+ 2. 位置の形は変えない。`history_floor` は今までどおり入れる(発行した時点の控えの下限の番号)。
31
+ 検証には使わない。0.15.0 以前が出した位置は、そのまま使える。
32
+ 3. snapshot の返し方、`resync_required` を終了コード 0 で返すこと、page token の作りは変えない。
33
+
34
+ ## Consequences
35
+
36
+ - 別の古い会話の控えが落ちても、今の会話の位置は有効なままで、`append`・`delta` が返る。
37
+ - 位置が指す会話そのものの先頭が落ちた時は、今までどおり `resync_required` になる。1 つの会話だけで
38
+ 控えが 256 件を超えると、その会話の位置は 1 ターンごとに無効になる。これを無くすには、位置を
39
+ 先頭からの digest ではなく控えの連番に結ぶ必要があり、位置の作りが変わるので、別に決める。
40
+ - `history_floor` だけを書き換えた位置は、先頭からの照合が合えば受け入れる。位置は認可の鍵ではなく
41
+ (ADR 0003)、控えそのものと照合するので、読める範囲は変わらない。
42
+ - ADR 0003 の「cursor below `history_floor` … returns `resync_required`」は、この ADR で置き換える。
43
+ 「保持で失われたことを黙って隠さない」は、先頭からの照合で保つ。
44
+
45
+ ## 確認した範囲
46
+
47
+ - `src/observer-turn-feed.test.mjs`: 古い会話の控えで上限まで埋まった project で、新しい会話のターンを
48
+ 1 つずつ足し、毎回 `append` と、足したターンだけの `delta` が返ること。同じ会話の先頭が落ちた時は
49
+ `resync_required` になること。修理前のコードでは 2 件とも失敗する。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -197,8 +197,10 @@ export function requestClaudeAutoHandoff({
197
197
  const path = recordPath(sessionId, dir);
198
198
  const existing = readClaudeAutoHandoff(sessionId, dir);
199
199
  // 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
200
+ // 会話が別の project へ移っている時(Claude Desktop)は、移った先で行う。
200
201
  const projectPath = nativeProjectPath(
201
- claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env }), platform);
202
+ claudeHostAdapter.completionProjectPath({
203
+ cwd: payload.cwd ?? process.cwd(), env, transcriptPath: payload.transcript_path }), platform);
202
204
 
203
205
  if (payload.trigger !== 'auto') {
204
206
  // 人が /compact を選んだ。まだ止めていない記録は取り下げる。
@@ -63,6 +63,33 @@ function request(dir, overrides = {}) {
63
63
  return { result, db };
64
64
  }
65
65
 
66
+ test('PreCompact: 会話が別の project へ移っている時は、移った先で有効かどうかを決める(Claude Desktop)', () => withDir(dir => {
67
+ // Desktop は「フォルダなし」で始めた会話を後から project へ移す。CLAUDE_PROJECT_DIR は移る前のまま届く。
68
+ const scratch = '/Users/kite/Library/Application Support/Claude/scratch-workspaces/a/b/scratch-2026-10-05-ac3fa8';
69
+ const moved = '/Users/kite/tl-claude-probe';
70
+ const transcriptPath = join(dir, 'moved.jsonl');
71
+ writeFileSync(transcriptPath, jsonl([user('読んで', '2026-10-05T22:38:17.050Z'), assistant('読みました', '2026-10-05T22:39:18.926Z'),
72
+ { type: 'relocated', sessionId: SESSION, relocatedCwd: moved }]));
73
+ const onlyMoved = { enabled: true, projects: [moved] };
74
+
75
+ const { result, db } = request(dir, { config: onlyMoved, transcriptPath, env: { CLAUDE_PROJECT_DIR: scratch }, payload: { cwd: moved } });
76
+ assert.equal(result.status, 'requested');
77
+ assert.equal(result.block, true);
78
+ assert.equal(result.projectPath, moved);
79
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, moved);
80
+ assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(moved).session_id, SESSION);
81
+ }));
82
+
83
+ test('PreCompact: 移っていない会話は、これまでどおり起動した project で決める', () => withDir(dir => {
84
+ const transcriptPath = join(dir, 'plain.jsonl');
85
+ writeFileSync(transcriptPath, jsonl([user('読んで', '2026-10-05T22:38:17.050Z'), assistant('読みました', '2026-10-05T22:39:18.926Z')]));
86
+ const { result } = request(dir, { config: { enabled: true, projects: ['/work/app/sub'] }, transcriptPath,
87
+ env: { CLAUDE_PROJECT_DIR: '/work/app' }, payload: { cwd: '/work/app/sub' } });
88
+ assert.equal(result.status, 'skipped');
89
+ assert.equal(result.reason, 'auto_handoff_disabled');
90
+ assert.equal(result.projectPath, '/work/app');
91
+ }));
92
+
66
93
  test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
67
94
  const { result, db } = request(dir);
68
95
  assert.equal(result.status, 'requested');
@@ -863,6 +863,57 @@ test('process-turn subprocess backfills all completed logical turns from a multi
863
863
  }
864
864
  });
865
865
 
866
+ test('process-turn subprocess files a relocated Claude Desktop conversation under the project it moved to', () => {
867
+ // Desktop は「フォルダなし」で始めた会話を後から project へ移す。hook の CLAUDE_PROJECT_DIR は移る前のまま届く。
868
+ const home = makeTempHome();
869
+ const scratch = makeTempProject();
870
+ const moved = makeTempProject();
871
+ const transcriptPath = join(moved, 'transcript.jsonl');
872
+ const sessionId = '04bf5ab3-4b29-4b35-9b85-02a48eec51a1';
873
+ const entries = [
874
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'first request' }] } },
875
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'first answer' }] } },
876
+ ];
877
+ const stop = (lastAssistantMessage) => runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
878
+ home,
879
+ cwd: moved,
880
+ env: { CLAUDE_PROJECT_DIR: scratch },
881
+ input: JSON.stringify({ session_id: sessionId, cwd: moved, transcript_path: transcriptPath, last_assistant_message: lastAssistantMessage }),
882
+ });
883
+ try {
884
+ // 移る前: SessionStart が移る前の場所で行を作り、最初の Stop も移る前の場所へ受領を書く。
885
+ const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
886
+ home, cwd: scratch, input: JSON.stringify({ session_id: sessionId, cwd: scratch, source: 'startup', transcript_path: transcriptPath }),
887
+ });
888
+ assert.equal(started.status, 0, started.stderr);
889
+ writeFileSync(transcriptPath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
890
+ assert.equal(stop('first answer').status, 0);
891
+ let db = openDb(home);
892
+ assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId).project_path, scratch);
893
+ db.close();
894
+ assert.equal(readCompletedTurnReceiptSnapshot({ projectPath: scratch, env: childEnv(home) }).receipts.length, 1);
895
+
896
+ // 移った後の Stop: sessions の project を付け替え、全ターンの受領を移った先へ出す。
897
+ appendFileSync(transcriptPath, [
898
+ { type: 'relocated', sessionId, relocatedCwd: moved },
899
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'second request' }] } },
900
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'second answer' }] } },
901
+ ].map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
902
+ const second = stop('second answer');
903
+ assert.equal(second.status, 0, second.stderr);
904
+ db = openDb(home);
905
+ assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId).project_path, moved);
906
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM bodies').get().n, 4);
907
+ db.close();
908
+ const receipts = readCompletedTurnReceiptSnapshot({ projectPath: moved, env: childEnv(home) });
909
+ assert.deepEqual(receipts.receipts.map((entry) => entry.sequence), [1, 2]);
910
+ } finally {
911
+ rmSync(scratch, { recursive: true, force: true });
912
+ rmSync(moved, { recursive: true, force: true });
913
+ rmSync(home, { recursive: true, force: true });
914
+ }
915
+ });
916
+
866
917
  test('process-turn subprocess captures the completed turn when the next user entry lands right after Stop', () => {
867
918
  const home = makeTempHome();
868
919
  const project = makeTempProject();
@@ -4,7 +4,8 @@
4
4
  * Claude は Throughline の基準 host。hook payload は snake_case のまま届き、
5
5
  * 引き継ぎ注入は UserPromptSubmit hook の stdout でモデルへ渡る。
6
6
  */
7
- import { isAbsolute } from 'node:path';
7
+ import { readFileSync } from 'node:fs';
8
+ import { isAbsolute, posix, win32 } from 'node:path';
8
9
  import { CLAUDE_HOST, hostOfSessionId } from './identity.mjs';
9
10
 
10
11
  // Claude Code の背景 task 通知は user 発言として transcript に入る。識別子・出力 path・
@@ -36,6 +37,40 @@ export function compactClaudePeerMessage(text) {
36
37
  return note < 0 ? text : text.slice(0, note);
37
38
  }
38
39
 
40
+ // Claude Desktop は「フォルダなし」で始めた会話を、後から実在の project へ移せる。移すと transcript に
41
+ // `{"type":"relocated","relocatedCwd":"<移った先>"}` の行が入り、transcript 自体も移った先の project の
42
+ // 置き場へ移る。hook へ渡る CLAUDE_PROJECT_DIR は、移る前の場所のまま残る(2.1.286 で実測)。
43
+ const RELOCATED_MARKER = '"relocated"';
44
+
45
+ /**
46
+ * 会話が最後に移った先。移っていない会話、transcript が読めない時は null。
47
+ * @param {string | null | undefined} transcriptPath
48
+ * @returns {string | null}
49
+ */
50
+ export function readClaudeRelocatedCwd(transcriptPath) {
51
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
52
+ let raw;
53
+ try { raw = readFileSync(transcriptPath, 'utf8'); }
54
+ catch { return null; }
55
+ // 道具の出力が同じ文字列を含むことがある。行を JSON として読み、行そのものの type で確かめる。
56
+ for (let end = raw.length; end > 0;) {
57
+ const marker = raw.lastIndexOf(RELOCATED_MARKER, end - 1);
58
+ if (marker < 0) return null;
59
+ const lineStart = raw.lastIndexOf('\n', marker) + 1;
60
+ const newline = raw.indexOf('\n', marker);
61
+ const line = raw.slice(lineStart, newline < 0 ? raw.length : newline);
62
+ try {
63
+ const entry = JSON.parse(line);
64
+ const moved = entry?.type === 'relocated' ? entry.relocatedCwd : null;
65
+ if (typeof moved === 'string' && (posix.isAbsolute(moved) || win32.isAbsolute(moved))) return moved;
66
+ } catch {
67
+ // 書きかけの行。前の行を探す。
68
+ }
69
+ end = lineStart;
70
+ }
71
+ return null;
72
+ }
73
+
39
74
  export const claudeHostAdapter = Object.freeze({
40
75
  host: CLAUDE_HOST,
41
76
  matchesSessionId: (sessionId) => hostOfSessionId(sessionId) === CLAUDE_HOST,
@@ -45,7 +80,10 @@ export const claudeHostAdapter = Object.freeze({
45
80
  // 完了受領はセッションを起動した project に書く。hook payload の cwd は Bash の cd に
46
81
  // 追従するため、作業中に下位ディレクトリへ移ると起動 project の feed から漏れる。
47
82
  // Claude Code は hook の環境変数 CLAUDE_PROJECT_DIR に起動 project を渡す。
48
- completionProjectPath({ cwd, env }) {
83
+ // 会話が別の project へ移っている時は、移った先を使う(CLAUDE_PROJECT_DIR は移る前のまま)。
84
+ completionProjectPath({ cwd, env, transcriptPath }) {
85
+ const relocated = readClaudeRelocatedCwd(transcriptPath);
86
+ if (relocated) return relocated;
49
87
  const projectDir = env?.CLAUDE_PROJECT_DIR;
50
88
  return typeof projectDir === 'string' && isAbsolute(projectDir) ? projectDir : cwd;
51
89
  },
@@ -1,6 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { claudeHostAdapter } from './claude.mjs';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { claudeHostAdapter, readClaudeRelocatedCwd } from './claude.mjs';
4
7
  import { codexHostAdapter } from './codex.mjs';
5
8
  import { cursorHostAdapter } from './cursor.mjs';
6
9
  import { grokHostAdapter } from './grok.mjs';
@@ -18,3 +21,57 @@ test('Codex, Grok, and Cursor completion receipts keep the hook cwd', () => {
18
21
  assert.equal(adapter.completionProjectPath({ cwd: '/work/bot', env: { CLAUDE_PROJECT_DIR: '/elsewhere' } }), '/work/bot');
19
22
  }
20
23
  });
24
+
25
+ function withTranscript(lines, fn) {
26
+ const dir = mkdtempSync(join(tmpdir(), 'tl-claude-host-'));
27
+ const transcriptPath = join(dir, 'transcript.jsonl');
28
+ try {
29
+ writeFileSync(transcriptPath, lines.join('\n'), 'utf8');
30
+ return fn(transcriptPath);
31
+ } finally {
32
+ rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ }
35
+
36
+ // Claude Desktop 2.1.286 が書く行の形(Mac で実測)。順序は2通りある。
37
+ const relocatedLine = (cwd) => JSON.stringify({ type: 'relocated', sessionId: '04bf5ab3-4b29-4b35-9b85-02a48eec51a1', relocatedCwd: cwd });
38
+ const userLine = (text) => JSON.stringify({ type: 'user', message: { role: 'user', content: text } });
39
+
40
+ test('readClaudeRelocatedCwd: 移っていない会話と読めない transcript は null', () => {
41
+ assert.equal(readClaudeRelocatedCwd(null), null);
42
+ assert.equal(readClaudeRelocatedCwd(join(tmpdir(), 'tl-claude-host-missing', 'none.jsonl')), null);
43
+ withTranscript([userLine('hello'), ''], (path) => assert.equal(readClaudeRelocatedCwd(path), null));
44
+ });
45
+
46
+ test('readClaudeRelocatedCwd: 最後に移った先を返す', () => {
47
+ withTranscript([userLine('a'), relocatedLine('/Users/kite/first'), userLine('b'), relocatedLine('/Users/kite/tl-claude-probe'),
48
+ JSON.stringify({ type: 'agent-name', agentName: 'x' }), ''], (path) => {
49
+ assert.equal(readClaudeRelocatedCwd(path), '/Users/kite/tl-claude-probe');
50
+ });
51
+ withTranscript([relocatedLine('C:\\Users\\kite_\\proj')], (path) => {
52
+ assert.equal(readClaudeRelocatedCwd(path), 'C:\\Users\\kite_\\proj');
53
+ });
54
+ });
55
+
56
+ test('readClaudeRelocatedCwd: 道具の出力に同じ文字列があっても、行そのものの type だけを見る', () => {
57
+ const quoted = JSON.stringify({ type: 'user', message: { role: 'user', content: [
58
+ { type: 'tool_result', tool_use_id: 't1', content: relocatedLine('/quoted/in/output') }] } });
59
+ withTranscript([relocatedLine('/Users/kite/real'), quoted, '{"type":"relocated","relocatedCwd":"/partial'], (path) => {
60
+ assert.equal(readClaudeRelocatedCwd(path), '/Users/kite/real');
61
+ });
62
+ withTranscript([quoted, relocatedLine('relative/path'), JSON.stringify({ type: 'relocated' })], (path) => {
63
+ assert.equal(readClaudeRelocatedCwd(path), null);
64
+ });
65
+ });
66
+
67
+ test('Claude completion receipts follow the project the conversation moved to (Claude Desktop)', () => {
68
+ const scratch = '/Users/kite/Library/Application Support/Claude/scratch-workspaces/a/b/scratch-2026-10-05-ac3fa8';
69
+ withTranscript([userLine('a'), relocatedLine('/Users/kite/tl-claude-probe'), ''], (transcriptPath) => {
70
+ assert.equal(claudeHostAdapter.completionProjectPath({
71
+ cwd: '/Users/kite/tl-claude-probe/sub', env: { CLAUDE_PROJECT_DIR: scratch }, transcriptPath,
72
+ }), '/Users/kite/tl-claude-probe');
73
+ });
74
+ withTranscript([userLine('a'), ''], (transcriptPath) => {
75
+ assert.equal(claudeHostAdapter.completionProjectPath({ cwd: '/work/bot/sub', env: { CLAUDE_PROJECT_DIR: '/work/bot' }, transcriptPath }), '/work/bot');
76
+ });
77
+ });
@@ -47,7 +47,7 @@ export function resolveObserverTurnFeed({ projectPath, cursor = null, codexHome,
47
47
  ? emptyCandidate()
48
48
  : candidates.find((item) => item.host === prior.host && item.threadHash === prior.thread_sha256);
49
49
  if (!priorCandidate || prior.host !== null &&
50
- (prior.history_floor < priorCandidate.historyFloor || prior.length > priorCandidate.chain.length ||
50
+ (prior.length > priorCandidate.chain.length ||
51
51
  prefixDigest(priorCandidate.chain.slice(0, prior.length)) !== prior.prefix_sha256)) {
52
52
  return { schema: OBSERVER_CURSOR_SCHEMA, status: 'resync_required', afterCursor: cursor, throughCursor: null };
53
53
  }
@@ -254,7 +254,7 @@ function decodeCursorForRead(token, projectSha256) {
254
254
  function validateCursorCandidate(cursor, candidates) {
255
255
  if (cursor.host === null) return emptyCandidate();
256
256
  const current = candidates.find((item) => item.host === cursor.host && item.threadHash === cursor.thread_sha256);
257
- if (!current || cursor.history_floor < current.historyFloor || cursor.length > current.chain.length ||
257
+ if (!current || cursor.length > current.chain.length ||
258
258
  prefixDigest(current.chain.slice(0, cursor.length)) !== cursor.prefix_sha256) return null;
259
259
  return current;
260
260
  }
@@ -126,11 +126,16 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
126
126
  try {
127
127
  const options = box.receiptOptions;
128
128
  seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: options, targetSessionId: 'claude-session' });
129
+ const full = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
129
130
  writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: 'o257', userBody: 'user-body-257', assistantBody: 'assistant-body-257', completedAt: 257 }, options);
131
+ // 同じ会話の先頭の控えが落ちた時は、先頭からの照合が合わなくなるので resync(ADR 0035)
132
+ assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: full.throughCursor, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
130
133
  const latest = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
131
134
  const decoded = decodeObserverCursor(latest.throughCursor);
132
- const beforeFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
133
- assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: beforeFloor, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
135
+ assert.equal(decoded.history_floor, 2);
136
+ // 下限の番号は位置に入れたままだが、検証には使わない。先頭からの照合が合えば有効
137
+ const olderFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
138
+ assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: olderFloor, receiptOptions: options, codexHome: box.home }).status, 'unchanged');
134
139
  assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: `${latest.throughCursor}x`, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
135
140
  const oldVersion = `tlc1.${Buffer.from(JSON.stringify({ ...decoded, schema: 'throughline.observer_cursor.v0' })).toString('base64url')}`;
136
141
  assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: oldVersion, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
@@ -157,6 +162,51 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
157
162
  } finally { rmSync(box.root, { recursive: true, force: true }); }
158
163
  });
159
164
 
165
+ test('observer feed: 控えが上限に達して別の古い会話の控えが落ちても、今の会話の位置は有効なまま', () => {
166
+ const box = fixture();
167
+ const dbPath = join(box.root, 'throughline.db');
168
+ let db;
169
+ try {
170
+ // 古い会話の控えで上限まで埋まっている席で、新しい会話が始まる
171
+ seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: box.receiptOptions, targetSessionId: 'old-session' });
172
+ db = createProjectionDb(dbPath, box.project, 'live-session');
173
+ const read = (afterCursor) => readObserverTurnPage({
174
+ projectPath: box.project, afterCursor, codexHome: box.home, receiptOptions: box.receiptOptions, dbPath,
175
+ });
176
+ const floorOf = (cursor) => decodeObserverCursor(cursor).history_floor;
177
+
178
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-1', turn: 1, user: 'user-1', assistant: 'assistant-1', at: 1001 });
179
+ let cursor = read(null).throughCursor;
180
+ assert.equal(floorOf(cursor), 2);
181
+ for (let turn = 2; turn <= 5; turn++) {
182
+ // 1 ターンごとに古い会話の控えが1件落ち、控え全体の下限が1上がる
183
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: `live-${turn}`, turn, user: `user-${turn}`, assistant: `assistant-${turn}`, at: 1000 + turn });
184
+ assert.equal(resolveObserverTurnFeed({
185
+ projectPath: box.project, cursor, codexHome: box.home, receiptOptions: box.receiptOptions,
186
+ }).status, 'append');
187
+ const page = read(cursor);
188
+ assert.equal(page.status, 'delta');
189
+ assert.deepEqual(page.turns.map((item) => item.user), [`user-${turn}`]);
190
+ assert.equal(page.page.complete, true);
191
+ assert.equal(floorOf(page.throughCursor), turn + 1);
192
+ cursor = page.throughCursor;
193
+ }
194
+ // 追いついた位置は、次のターンが来るまで unchanged
195
+ assert.equal(resolveObserverTurnFeed({
196
+ projectPath: box.project, cursor, codexHome: box.home, receiptOptions: box.receiptOptions,
197
+ }).status, 'unchanged');
198
+ // 読み遅れた位置(2 ターン前)からも、残りを続きとして読める
199
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-6', turn: 6, user: 'user-6', assistant: 'assistant-6', at: 1006 });
200
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-7', turn: 7, user: 'user-7', assistant: 'assistant-7', at: 1007 });
201
+ const late = read(cursor);
202
+ assert.equal(late.status, 'delta');
203
+ assert.deepEqual(late.turns.map((item) => item.user), ['user-6', 'user-7']);
204
+ } finally {
205
+ db?.close();
206
+ rmSync(box.root, { recursive: true, force: true });
207
+ }
208
+ });
209
+
160
210
  test('observer feed: Codex pair hashes reuse DB capture role aggregation', () => {
161
211
  const box = fixture();
162
212
  try {
@@ -49,6 +49,8 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
49
49
  import { logHookFailure } from './hook-failure-log.mjs';
50
50
  import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
51
51
  import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
52
+ import { readClaudeRelocatedCwd } from './hosts/claude.mjs';
53
+ import { CLAUDE_HOST } from './hosts/identity.mjs';
52
54
  import { completeClaudeTurnWithoutHandoff } from './claude-auto-handoff.mjs';
53
55
 
54
56
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
@@ -316,6 +318,20 @@ async function processStop() {
316
318
  db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
317
319
  }
318
320
 
321
+ // 完了受領と sessions の project は、会話を起動した project に合わせる。会話が別の project へ
322
+ // 移っている時(Claude Desktop)は、移った先へ付け替える。付け替えないと、移った先の project の
323
+ // 過去の会話にも Observer feed にも、この会話が出ない。
324
+ const hostAdapter = hostAdapterForSessionId(session_id);
325
+ const relocatedProjectPath = hostAdapter.host === CLAUDE_HOST ? readClaudeRelocatedCwd(transcript_path) : null;
326
+ const completionProjectPath = relocatedProjectPath ?? hostAdapter.completionProjectPath({
327
+ cwd: cwd ?? process.cwd(),
328
+ env: process.env,
329
+ });
330
+ if (relocatedProjectPath) {
331
+ db.prepare('UPDATE sessions SET project_path = ? WHERE session_id IN (?, ?) AND project_path <> ?')
332
+ .run(relocatedProjectPath, session_id, target, relocatedProjectPath);
333
+ }
334
+
319
335
  // L2 = transcript 全体を論理ターン群で走査し、未捕捉の完了ターンを一括回収する。
320
336
  // 従来の「最後の 1 ペアのみ保存」は Stop の空振り・不発が永久穴になった (docs/12 B-1)。
321
337
  // user / assistant は「1 往復 = 1 ターン」として同じ turn_number(= 代表 assistant
@@ -349,10 +365,6 @@ async function processStop() {
349
365
  // 過去のStopでDBだけ回収済みだったpairもreceipt storeの冪等性で穴埋めする。
350
366
  // receipt failure は Stop hook の failure として上位へ伝播させる。L1/L3/usage は
351
367
  // receipt 後の派生処理なので、そこで失敗しても completed pair を取り消さない。
352
- const completionProjectPath = hostAdapterForSessionId(session_id).completionProjectPath({
353
- cwd: cwd ?? process.cwd(),
354
- env: process.env,
355
- });
356
368
  for (const completedTurnNumber of backfill.turnNumbers) {
357
369
  publishCapturedClaudeCompletionReceipt(db, {
358
370
  target,