throughline 0.16.18 → 0.16.19

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,23 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.19] — 2026-10-11
14
+
15
+ ### 修正
16
+
17
+ - Claude Codeの自動継続で、作業ツリー(`<repository>/.claude/worktrees/<名前>`)の後継をClaude Desktopが取り込んだ後にもう一度引き継ぐと、後継が元のrepositoryで立ち、前任の記憶が入らず、Desktopへも移らなかった([ADR 0049](docs/adr/0049-claude-handoff-uses-registered-project.md))。Windowsの実機(Claude Code 2.1.296)で2026-10-10 23:43に1回起きた。hookの`CLAUDE_PROJECT_DIR`が元のrepositoryを指し、会話を登録したproject(作業ツリー)と違っていた。
18
+ - 後継を立てるprojectを、会話が移った先(`relocated`)、会話を登録したproject(`sessions`。その場所が今もある時)、`CLAUDE_PROJECT_DIR`、hookのcwdの順で決める。
19
+ - 受領を確かめられなかった引き継ぎ(`unknown`・`handoff_delivery_unconfirmed`)でも、その後継がターンを終えたら、Claude Desktopへ移す。今までは裏の会話のまま残り、リモートコントロールからしか見えなかった。`state`と`error_code`は変えない。
20
+
21
+ ### 対応の範囲
22
+
23
+ | | macOS | Linux | Windows |
24
+ |---|---|---|---|
25
+ | Claude Code | 直した。単体試験と、切り離した置き場での本物のhookの命令(作業ツリーで登録した会話へ、`CLAUDE_PROJECT_DIR`を元のrepositoryにして`pre-compact`を渡し、後継が作業ツリーで立って記憶が合流する事。直す前の版は`project_mismatch`になる事)。**本物のClaude Desktopが作業ツリーの後継を取り込む場面は、macOSでは未確認** | 直した。単体試験と、切り離した置き場での本物のhookの命令。Claude Desktopへ移す処理はLinuxでは対象外(Claude Codeの`--desktop`が無い)なので、`unknown`の後継を移す直しは働かない | 直した。単体試験と、切り離した置き場での本物のhookの命令(`CLAUDE_PROJECT_DIR`は`C:/…`の形)。実機で起きたのはこのOS。**直した版での本物の引き継ぎは、次に起きた時に記録で確かめる** |
26
+ | Codex | 変更なし。projectはhookのcwdで決める。引き継ぎの部品も別 | 同じ | 同じ |
27
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
28
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
29
+
13
30
  ## [0.16.18] — 2026-10-11
14
31
 
15
32
  ### 変更
@@ -2289,7 +2306,8 @@ two attempts, instrument first instead of patching again.
2289
2306
 
2290
2307
  ---
2291
2308
 
2292
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.18...HEAD
2309
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.19...HEAD
2310
+ [0.16.19]: https://github.com/kitepon/Throughline/compare/v0.16.18...v0.16.19
2293
2311
  [0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
2294
2312
  [0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
2295
2313
  [0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
@@ -0,0 +1,49 @@
1
+ # ADR 0049: Claude の引き継ぎは、会話を登録した project で後継を立てる。受領を確かめられなかった後継も Desktop へ移す
2
+
3
+ 日付: 2026-10-11
4
+
5
+ ## Context
6
+
7
+ Claude Code の自動継続は、PreCompact の hook で、後継を立てる project を決める(ADR 0033)。決め方は、会話が別の project へ
8
+ 移っていれば移った先、そうでなければ hook の環境変数 `CLAUDE_PROJECT_DIR`、無ければ hook の cwd だった。後継の最初の指示は、
9
+ 前任と後継の project(`sessions.project_path`)が同じ時だけ、前任の記憶を合流させる。`sessions` の行は SessionStart の cwd で作る。
10
+
11
+ 2026-10-11、オーナーから「WindowsのClaudeの自動スルーラインで起動する新セッションってRemoteがONで立ち上がってるの?
12
+ なんか意図した挙動と違う気がする」と指摘があった。Windows の実機(Claude Code 2.1.296)の記録を読んだ。
13
+
14
+ - 0.16.16 より後の Claude の引き継ぎは5回。5回とも、後継はリモートコントロール付きで立っていた(ADR 0048)。
15
+ 4回は、後継がターンを終えた後に Claude Desktop へ移っていた(ADR 0043)。
16
+ - 1回(10/10 23:43)は、受領を確かめられないまま(`unknown`・`handoff_delivery_unconfirmed`)だった。後継は立って作業を
17
+ 続けていたが、Desktop へ移らず、裏の会話のまま残った。見えるのはリモートコントロールからだけだった。
18
+ - この回の前任は、作業ツリー(`<repository>/.claude/worktrees/<名前>`)で動く後継を、Claude Desktop が取り込んだ会話だった。
19
+ SessionStart の cwd は作業ツリーで、`sessions` の project も作業ツリーだった。PreCompact の時、`CLAUDE_PROJECT_DIR` は
20
+ 元の repository を指していた。transcript に `relocated` の行は無い。
21
+ - その結果、後継は元の repository(branch は `main`)で立った。前任と project が違うので、記憶は合流しなかった
22
+ (`merge_skip_reason: project_mismatch`)。合流しないと受領も残らないので、引き継ぎは `unknown` で終わった。
23
+ - Desktop へ移す処理は、受領が残った引き継ぎ(`sent`)の後継だけを対象にしていた。`unknown` の記録には後継の session id が
24
+ 無く、後継の Stop では何も起きなかった。
25
+
26
+ ## Decision
27
+
28
+ 1. PreCompact で後継を立てる project は、次の順で決める。会話が移った先(`relocated`)、会話を登録した project
29
+ (`sessions.project_path`。その場所が今もある時)、`CLAUDE_PROJECT_DIR`、hook の cwd。
30
+ 印(baton)・有効かどうかの判定・後継を立てる場所は、同じ値を使う。
31
+ 2. `sessions` を読むのは、自動圧縮で、自動継続が有効で、まだ引き継ぎの記録が無い時だけ。読めない時と、登録した場所が
32
+ もう無い時(作業ツリーを消した後)は、今までと同じ値で進める。
33
+ 3. 受領を確かめられなかった引き継ぎ(`unknown`、後継の短い ID だけが記録にある)でも、その後継がターンを終えたら、
34
+ Desktop へ移す。継続の指示は起動時の引数で渡してあるので、ターンを終えた後継は指示を受けている。
35
+ 後継の session id を記録へ入れる。`state` と `error_code` は変えない(記憶が合流しなかった事は、記録に残す)。
36
+ 4. 後継が立たなかった引き継ぎ(`failed`)は、今までどおり何もしない。
37
+
38
+ ## Consequences
39
+
40
+ - 作業ツリーの会話は、Desktop が取り込んだ後に引き継いでも、同じ作業ツリーで続く。記憶も合流する。
41
+ - 何かの理由で受領を確かめられなかった時も、後継は裏の会話のまま残らない。ただし、その後継に前任の記憶が入ったかは
42
+ 別の話で、`unknown` の記録を見て確かめる必要がある。
43
+ - 会話を起動した後で `CLAUDE_PROJECT_DIR` だけが変わる場面では、登録した project が優先される。会話を別の project へ
44
+ 移す場面は、今までどおり `relocated` の行で分かる。
45
+ - Stop の完了受領の project(`turn-processor`)は変えていない。Desktop が取り込んだ作業ツリーの会話の完了受領は、
46
+ 今までどおり `CLAUDE_PROJECT_DIR`(元の repository)へ出る。
47
+ - リモートコントロール付きで立てた後継は、Desktop へ移す時に止める(`claude stop`)。起動時のリモートの接続はそこで切れ、
48
+ 移った後は Claude Desktop 自身のリモートの設定に従う。ここは変えていない。
49
+ - Codex・Grok・Cursor は、hook の cwd で project を決める。この ADR の対象は Claude Code だけ。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.18",
3
+ "version": "0.16.19",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -249,6 +249,23 @@ function nativeProjectPath(projectPath, platform) {
249
249
  return (platform === 'win32' ? win32 : posix).resolve(projectPath);
250
250
  }
251
251
 
252
+ /**
253
+ * その会話を sessions へ登録した project(SessionStart の cwd)。行が無い時、その場所がもう無い時は null。
254
+ *
255
+ * 作業ツリーで動く後継を Claude Desktop が取り込むと、hook の CLAUDE_PROJECT_DIR は元の repository を指す
256
+ * (Windows、Claude Code 2.1.296 で実測)。そこで後継を立てると、後継は別の作業ツリーで始まり、前任と project が
257
+ * 違うので記憶も合流しない(ADR 0049)。登録した project で立てれば、前任と後継の project がそろう。
258
+ */
259
+ function registeredProjectPath(db, sessionId, platform) {
260
+ let registered;
261
+ // sessions を読めない時は、hook の環境から読んだ project で進める(今までと同じ動き)。
262
+ try { registered = db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId)?.project_path; }
263
+ catch { return null; }
264
+ if (typeof registered !== 'string' || !(posix.isAbsolute(registered) || win32.isAbsolute(registered))) return null;
265
+ const native = nativeProjectPath(registered, platform);
266
+ return existsSync(native) ? native : null;
267
+ }
268
+
252
269
  /**
253
270
  * PreCompact hook の本体。自動圧縮で、その project が有効な時だけ、印と記録を残して圧縮を止める。
254
271
  *
@@ -277,9 +294,15 @@ export function requestClaudeAutoHandoff({
277
294
  const existing = readClaudeAutoHandoff(sessionId, dir);
278
295
  // 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
279
296
  // 会話が別の project へ移っている時(Claude Desktop)は、移った先で行う。
280
- const projectPath = nativeProjectPath(
281
- claudeHostAdapter.completionProjectPath({
282
- cwd: payload.cwd ?? process.cwd(), env, transcriptPath: payload.transcript_path }), platform);
297
+ const relocated = readClaudeRelocatedCwd(payload.transcript_path);
298
+ const hookProjectPath = nativeProjectPath(
299
+ relocated ?? claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env }), platform);
300
+ let db = null;
301
+ const open = () => (db ??= openDb());
302
+ // 自動圧縮で、機能が有効な時だけ、会話を登録した project を読む(ADR 0049)。
303
+ const projectPath = payload.trigger === 'auto' && !existing && config.enabled && !relocated
304
+ ? registeredProjectPath(open(), sessionId, platform) ?? hookProjectPath
305
+ : hookProjectPath;
283
306
 
284
307
  if (payload.trigger !== 'auto') {
285
308
  // 人が /compact を選んだ。まだ止めていない記録は取り下げる。
@@ -298,15 +321,14 @@ export function requestClaudeAutoHandoff({
298
321
  }
299
322
 
300
323
  // /tl と同じ印。後継の最初の指示が、この会話を前任として合流させる。
301
- const db = openDb();
302
324
  // 会話が別の project へ移っている時(Claude Desktop)は、sessions の project も移った先にそろえる。
303
325
  // 合流は前任と後継の project が同じ時だけ行う。付け替えは Stop でも行うが、移った後に1回も Stop を
304
326
  // 通らないまま引き継ぐ会話があり、その時は後継へ記憶が入らなかった(0.15.2・0.15.3、macOS で実測)。
305
- if (readClaudeRelocatedCwd(payload.transcript_path)) {
306
- db.prepare('UPDATE sessions SET project_path = ? WHERE session_id = ? AND project_path <> ?')
327
+ if (relocated) {
328
+ open().prepare('UPDATE sessions SET project_path = ? WHERE session_id = ? AND project_path <> ?')
307
329
  .run(projectPath, sessionId, projectPath);
308
330
  }
309
- writeBaton(db, { projectPath, sessionId, now });
331
+ writeBaton(open(), { projectPath, sessionId, now });
310
332
  mkdirSync(dir, { recursive: true, mode: 0o700 });
311
333
  removeStale(dir, now);
312
334
  const handoffId = randomUUID();
@@ -753,10 +775,15 @@ export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHand
753
775
  if (!validSessionId(sessionId)) return null;
754
776
  // この会話自身が引き継ぎの途中なら、作業は次の後継が続ける。移すのは、最後に作業を終えた後継だけ。
755
777
  if (readJson(recordPath(sessionId, dir))) return null;
756
- const record = listClaudeAutoHandoffs({ dir }).find(item => item.successor?.session_id === sessionId &&
757
- item.state === 'sent' && item.desktop?.wanted && !item.desktop.state);
778
+ const waiting = listClaudeAutoHandoffs({ dir }).filter(item => item.desktop?.wanted && !item.desktop.state);
779
+ // 受領を確かめられなかった引き継ぎ(unknown)は、後継の session id が記録に無い。その後継がターンを終えたなら、
780
+ // 後継は動いている。裏の会話のまま残さず、同じように Desktop へ移す(ADR 0049)。
781
+ const record = waiting.find(item => item.state === 'sent' && item.successor?.session_id === sessionId) ??
782
+ waiting.find(item => item.state === 'unknown' && !item.successor?.session_id &&
783
+ typeof item.successor?.short_id === 'string' && sessionId.startsWith(item.successor.short_id));
758
784
  if (!record) return null;
759
- updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
785
+ updateRecord(record.source_session_id, { successor: { ...record.successor, session_id: sessionId },
786
+ desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
760
787
  try { await launch(record.source_session_id, { dir }); }
761
788
  catch {
762
789
  updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'failed', error_code: 'desktop_open_start_failed' } }, { dir });
@@ -97,6 +97,37 @@ test('PreCompact: 移っていない会話は、これまでどおり起動し
97
97
  assert.equal(result.projectPath, '/work/app');
98
98
  }));
99
99
 
100
+ test('PreCompact: CLAUDE_PROJECT_DIR が元の repository を指していても、会話を登録した作業ツリーで引き継ぐ(Claude Desktop が取り込んだ後継)', () => withDir(dir => {
101
+ // 作業ツリーで動く後継を Desktop が取り込むと、hook の cwd は作業ツリー、CLAUDE_PROJECT_DIR は元の repository になる
102
+ const repo = join(dir, 'repo');
103
+ const worktree = join(repo, '.claude', 'worktrees', 'core-ui');
104
+ mkdirSync(worktree, { recursive: true });
105
+ const registered = () => {
106
+ const db = makeBatonDb();
107
+ db.exec('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL)');
108
+ db.prepare('INSERT INTO sessions VALUES (?, ?)').run(SESSION, worktree);
109
+ return db;
110
+ };
111
+ const adopted = { env: { CLAUDE_PROJECT_DIR: repo }, payload: { cwd: worktree }, platform: process.platform };
112
+
113
+ const { result, db } = request(dir, { db: registered(), ...adopted });
114
+ assert.equal(result.status, 'requested');
115
+ assert.equal(result.projectPath, worktree, '後継は、前任が動いていた作業ツリーで立てる');
116
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, worktree);
117
+ assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(worktree).session_id, SESSION,
118
+ '印も作業ツリーに書く。後継は同じ project の前任だけを合流させる');
119
+
120
+ // 有効かどうかも、登録した project で決める
121
+ rmSync(join(dir, `${SESSION}.json`));
122
+ assert.equal(request(dir, { db: registered(), ...adopted, config: { enabled: true, projects: [repo] } }).result.reason, 'auto_handoff_disabled');
123
+ assert.equal(request(dir, { db: registered(), ...adopted, config: { enabled: true, projects: [worktree] } }).result.status, 'requested');
124
+
125
+ // 登録した場所がもう無い時(作業ツリーを消した後)は、hook の環境から読んだ project で進める
126
+ rmSync(join(dir, `${SESSION}.json`));
127
+ rmSync(worktree, { recursive: true });
128
+ assert.equal(request(dir, { db: registered(), ...adopted }).result.projectPath, repo);
129
+ }));
130
+
100
131
  test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
101
132
  const { result, db } = request(dir);
102
133
  assert.equal(result.status, 'requested');
@@ -725,8 +756,8 @@ test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、
725
756
  assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null);
726
757
  writeRecord(dir, SESSION, { desktop: { wanted: false, state: null } });
727
758
  assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '端末から始めた会話の後継は移さない');
728
- writeRecord(dir, SESSION, { state: 'unknown' });
729
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '受領を確かめられていない後継は移さない');
759
+ writeRecord(dir, SESSION, { state: 'failed', error_code: 'handoff_successor_launch_failed', successor: null });
760
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '後継が立たなかった引き継ぎでは動かない');
730
761
  // 対象の後継
731
762
  writeRecord(dir, SESSION, {});
732
763
  assert.equal(await requestClaudeDesktopOpen({ sessionId: SESSION, dir, launch }), null, '旧い会話自身の Stop では動かない');
@@ -748,6 +779,32 @@ test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、
748
779
  assert.deepEqual([readClaudeAutoHandoff(SESSION, dir).desktop.state, readClaudeAutoHandoff(SESSION, dir).desktop.error_code], ['failed', 'desktop_open_start_failed']);
749
780
  }));
750
781
 
782
+ test('Stop: 受領を確かめられなかった引き継ぎ(unknown)でも、その後継がターンを終えたら Desktop へ移す', () => withDir(async dir => {
783
+ const launched = [];
784
+ const launch = async (id) => { launched.push(id); };
785
+ // worker は、後継の最初の指示の受領を待ち切れなかった。記録にあるのは後継の短い ID だけ
786
+ const unconfirmed = { state: 'unknown', error_code: 'handoff_delivery_unconfirmed', accepted_at: null,
787
+ successor: { short_id: SUCCESSOR.slice(0, 8), session_id: null } };
788
+ writeRecord(dir, SESSION, unconfirmed);
789
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: 'bbbbbbbb-0000-4000-8000-000000000002', dir, launch }), null, '別の会話の Stop では動かない');
790
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
791
+ assert.deepEqual(launched, [SESSION]);
792
+ const record = readClaudeAutoHandoff(SESSION, dir);
793
+ assert.deepEqual(record.successor, { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR }, '移す process が使う後継の ID を記録へ入れる');
794
+ assert.deepEqual([record.state, record.error_code, record.desktop.state], ['unknown', 'handoff_delivery_unconfirmed', 'requested'],
795
+ '受領を確かめられなかった事は、記録に残したままにする');
796
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
797
+
798
+ // 移す process は、その記録で後継を止めて Desktop で開く
799
+ const calls = [];
800
+ const opened = await runClaudeDesktopOpen(SESSION, { dir, env: {}, pollMs: 10, idleTimeoutMs: 500,
801
+ spawn: (command, args) => { calls.push(args.slice(0, 2).join(' '));
802
+ return args[0] === 'agents' ? { status: 0, stdout: JSON.stringify([{ id: SUCCESSOR.slice(0, 8), kind: 'background', status: 'idle' }]) } : { status: 0, stdout: '' }; },
803
+ pty: (command, args) => { calls.push(args.join(' ')); return { status: 0, stdout: `Opening session ${SUCCESSOR} in Claude Desktop` }; } });
804
+ assert.deepEqual(calls, ['agents --json', `stop ${SUCCESSOR.slice(0, 8)}`, `--desktop --resume ${SUCCESSOR}`]);
805
+ assert.deepEqual([opened.state, opened.desktop.state], ['unknown', 'opened']);
806
+ }));
807
+
751
808
  test('Desktop で開く: 後継が手すきになるのを待って止め、擬似端末の中で claude --desktop --resume を呼ぶ', () => withDir(async dir => {
752
809
  const shortId = SUCCESSOR.slice(0, 8);
753
810
  const run = async ({ statuses, stopStatus = 0, opened, beforePoll = () => {} }) => {
@@ -1008,6 +1065,55 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
1008
1065
  assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['baton', record.handoff_id]);
1009
1066
  }));
1010
1067
 
1068
+ test('hook: 作業ツリーの後継を Claude Desktop が取り込んだ後(CLAUDE_PROJECT_DIR は元の repository)に引き継いでも、後継は作業ツリーで立ち、記憶が入る', () => withDir(async home => {
1069
+ // fox(Windows、Claude Code 2.1.296)で 2026-10-10 に起きた形。SessionStart の cwd は作業ツリー、
1070
+ // PreCompact の時の CLAUDE_PROJECT_DIR は元の repository だった。
1071
+ const repo = join(home, 'repo');
1072
+ const worktree = join(repo, '.claude', 'worktrees', 'core-ui');
1073
+ mkdirSync(worktree, { recursive: true });
1074
+ const transcriptPath = join(home, 'transcript.jsonl');
1075
+ const adopted = { ...fakeClaudePath(home), CLAUDE_PROJECT_DIR: repo, CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' };
1076
+ const hook = (command, payload) =>
1077
+ cli(home, [command], JSON.stringify({ session_id: SESSION, cwd: worktree, transcript_path: transcriptPath, ...payload }), adopted);
1078
+
1079
+ assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
1080
+ assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'resume' }).status, 0);
1081
+ writeFileSync(transcriptPath, jsonl([
1082
+ { ...user('画面の余白を直して', '2026-10-10T14:40:00Z'), entrypoint: 'claude-desktop', cwd: worktree },
1083
+ assistant('余白を直します。', '2026-10-10T14:40:02Z'),
1084
+ toolUse('toolu_9', 'Read', { file_path: join(worktree, 'ui.css') }),
1085
+ ]));
1086
+
1087
+ const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
1088
+ assert.equal(pre.status, 2, pre.stderr);
1089
+ const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_9', permission_mode: 'auto' });
1090
+ assert.equal(JSON.parse(stopped.stdout).continue, false);
1091
+ const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
1092
+ let handoff;
1093
+ for (let i = 0; i < 100 && (handoff = status()[0]).state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
1094
+ assert.equal(handoff.error_code, 'handoff_successor_launch_failed', '偽の claude で止まる。本物は起動しない');
1095
+ assert.equal(handoff.project_path, worktree, '後継を立てる場所は、前任が動いていた作業ツリー');
1096
+
1097
+ // 後継は作業ツリーで立ち、最初の指示(起動時に渡された継続の指示)を受ける
1098
+ const recordFile = join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.json`);
1099
+ const record = JSON.parse(readFileSync(recordFile, 'utf8'));
1100
+ writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
1101
+ const successorPayload = { session_id: SUCCESSOR, cwd: record.project_path, hook_event_name: 'SessionStart', source: 'startup' };
1102
+ assert.equal(cli(home, ['session-start'], JSON.stringify(successorPayload), fakeClaudePath(home)).status, 0);
1103
+ const first = cli(home, ['prompt-submit'], JSON.stringify({ ...successorPayload, hook_event_name: 'UserPromptSubmit',
1104
+ prompt: claudeContinuationInput(record) }), fakeClaudePath(home));
1105
+ assert.equal(first.status, 0, first.stderr);
1106
+ assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
1107
+ assert.match(first.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 画面の余白を直して\n/);
1108
+ assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.accepted`)), true, '最初の指示が受領を残す');
1109
+
1110
+ const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
1111
+ assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
1112
+ db.close();
1113
+ const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
1114
+ assert.deepEqual(decisions.filter(entry => entry.phase === 'prompt-submit').map(entry => [entry.merged, entry.merge_skip_reason ?? null]), [[true, null]]);
1115
+ }));
1116
+
1011
1117
  test('hook: 別の project へ移した会話(Claude Desktop)を、移った後に1回も Stop を通らないまま引き継いでも、後継へ記憶が入る', () => withDir(async home => {
1012
1118
  // Desktop は「フォルダなし」で始めた会話を project へ移す。SessionStart は移る前の場所で呼ばれ、
1013
1119
  // CLAUDE_PROJECT_DIR は移った後も移る前の場所のまま届く。