throughline 0.12.9 → 0.14.0

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.
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
4
4
  import {
5
5
  buildResumeContext,
6
6
  buildBudgetedResumeContext,
7
+ buildAutoContinuationContext,
7
8
  INJECTION_BUDGET_CHARS,
8
9
  } from './resume-context.mjs';
9
10
 
@@ -905,3 +906,63 @@ test('budgeted: header and anchor always survive even under pressure', () => {
905
906
  assert.match(budgeted.text, /^## Throughline: 直前スレッドの継続応答用コンテキスト/);
906
907
  assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
907
908
  });
909
+
910
+ // --- 自動継続で立てた新しい会話への注入 (ADR 0033) ---
911
+
912
+ test('buildAutoContinuationContext: 保存済みのターンが無くても、止めた時点の依頼だけで組み立てる', () => {
913
+ const db = makeDb();
914
+ const result = buildAutoContinuationContext(db, {
915
+ sessionId: 'S',
916
+ inFlight: { user: { content: '最初の依頼', timestamp: null }, last_fragment: null },
917
+ });
918
+ assert.match(result.text, /^## Throughline: 自動継続の文脈\n/);
919
+ assert.match(result.text, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\*: 最初の依頼\n/);
920
+ assert.doesNotMatch(result.text, /止める直前のあなたの発言/);
921
+ assert.doesNotMatch(result.text, /### 直前の対話/);
922
+ assert.equal(result.injectedL2Turns, 0);
923
+ assert.equal(buildAutoContinuationContext(db, { sessionId: 'S', inFlight: null }), null);
924
+ });
925
+
926
+ test('buildAutoContinuationContext: 現在地は止めた時点のターンで、保存済みのターンはL2に並び、宣言は求めない', () => {
927
+ const db = makeDb();
928
+ insertBody(db, { session: 'S', origin: 'P', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
929
+ insertBody(db, { session: 'S', origin: 'P', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
930
+ const result = buildAutoContinuationContext(db, {
931
+ sessionId: 'S',
932
+ inFlight: {
933
+ user: { content: '今の依頼', timestamp: 3_000 },
934
+ last_fragment: { content: '途中2', timestamp: 5_000 },
935
+ },
936
+ });
937
+ const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
938
+ assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 途中2\n/);
939
+ assert.doesNotMatch(anchor, /前の依頼/);
940
+ assert.match(result.text, /### 直前の対話 \(L2 \/ active work thread, 古い順\)\n\[\d\d:\d\d:\d\d\] \[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
941
+ assert.doesNotMatch(result.text, /宣言|\/clear|圧縮の要約/);
942
+ assert.match(result.text, /止める直前に呼ぼうとした道具は実行されていません/);
943
+ assert.equal(result.injectedL2Turns, 1);
944
+ });
945
+
946
+ test('buildAutoContinuationContext: 長い依頼は先頭と末尾を残し、多数のターンでも予算内に収める', () => {
947
+ const db = makeDb();
948
+ for (let turn = 1; turn <= 12; turn++) {
949
+ insertBody(db, { session: 'S', origin: 'P', turn, role: 'user', text: `依頼${turn} ` + 'あ'.repeat(600), createdAt: turn * 1_000 });
950
+ insertBody(db, { session: 'S', origin: 'P', turn, role: 'assistant', text: `回答${turn} ` + 'い'.repeat(600), createdAt: turn * 1_000 + 500 });
951
+ }
952
+ const result = buildAutoContinuationContext(db, {
953
+ sessionId: 'S',
954
+ inFlight: {
955
+ user: { content: '先頭の指示' + 'ヰ'.repeat(6_000) + '末尾の指示', timestamp: 20_000 },
956
+ last_fragment: { content: 'ヱ'.repeat(3_000), timestamp: 21_000 },
957
+ },
958
+ });
959
+ assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
960
+ assert.match(result.text, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 先頭の指示ヰ+ …\(長いため中略 2010 字\)… ヰ+末尾の指示\n/);
961
+ assert.equal((result.text.match(/ヰ/g) ?? []).length, 3_990);
962
+ assert.equal((result.text.match(/ヱ/g) ?? []).length, 1_500);
963
+ assert.match(result.text, /ヱ …\(長いため中略 1500 字\)… ヱ/);
964
+ assert.ok(result.injectedL2Turns >= 1, '最新の保存済みターンは入る');
965
+ assert.match(result.text, /回答12/);
966
+ assert.equal(result.injectedL2Turns + result.remainingL2Turns, 12);
967
+ assert.match(result.text, /throughline recall --l2 --session S /);
968
+ });
@@ -47,6 +47,17 @@ const DEFINITIONS = Object.freeze({
47
47
  severity: 'high',
48
48
  template: 'Throughline Claude Stop hook processing failed',
49
49
  }),
50
+ // 自動継続の hook が失敗した。圧縮も道具も止まらず、作業は続く(ADR 0033)。
51
+ HOOK_PRE_COMPACT_FAILED: Object.freeze({
52
+ component: 'claude_pre_compact_hook',
53
+ severity: 'warn',
54
+ template: 'Throughline Claude PreCompact hook processing failed',
55
+ }),
56
+ HOOK_PRE_TOOL_USE_FAILED: Object.freeze({
57
+ component: 'claude_pre_tool_use_hook',
58
+ severity: 'warn',
59
+ template: 'Throughline Claude PreToolUse hook processing failed',
60
+ }),
50
61
  HOOK_CODEX_FAILED: Object.freeze({
51
62
  component: 'codex_hook',
52
63
  severity: 'high',
@@ -21,6 +21,12 @@
21
21
  *
22
22
  * バトンの消費・merge・注入は最初の UserPromptSubmit (= 実体の証明) で行う。
23
23
  * 幽霊はプロンプトを発火しないため記憶を奪えない。
24
+ *
25
+ * 【自動継続の後継】 ADR 0033
26
+ *
27
+ * 同じ project で後継の立ち上げ中の引き継ぎがある時だけ、この会話の受け口(inbox socket)を
28
+ * 控える。worker がそれを読んで、継続の指示を1通送る。記憶の注入は、その指示が届いた時の
29
+ * UserPromptSubmit(上の二相ハンドオフの第二相)が行う。
24
30
  */
25
31
 
26
32
  import { getDb } from './db.mjs';
@@ -33,8 +39,9 @@ import { existsSync } from 'node:fs';
33
39
  import { pathToFileURL } from 'node:url';
34
40
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
41
  import { logHookFailure } from './hook-failure-log.mjs';
36
- import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
42
+ import { CLAUDE_HOST, hostAdapterForSessionId, hostOfSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
37
43
  import { executeFirstPromptHandoff } from './handoff-executor.mjs';
44
+ import { recordClaudeSuccessorTarget } from './claude-auto-handoff.mjs';
38
45
 
39
46
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
40
47
 
@@ -163,6 +170,11 @@ export async function run() {
163
170
  pending_registered: true,
164
171
  });
165
172
 
173
+ // 自動継続の後継として立ち上がった会話なら、worker が指示を送れるように受け口を控える (ADR 0033)。
174
+ if (hostOfSessionId(session_id) === CLAUDE_HOST) {
175
+ recordClaudeSuccessorTarget({ payload, env: process.env, now });
176
+ }
177
+
166
178
  // Cursor は beforeSubmitPrompt が additional_context を持たない。
167
179
  // 新規 composer 会話の sessionStart が公式の注入口なので、ここで第二相を実行する。
168
180
  // Claude の幽霊 SessionStart 問題はこの host では sessionStart = 会話作成のため適用しない。
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { readFileSync, existsSync } from 'fs';
12
12
  import { DETAIL_KIND } from './constants.mjs';
13
- import { compactClaudeTaskNotification } from './hosts/claude.mjs';
13
+ import { compactClaudePeerMessage, compactClaudeTaskNotification } from './hosts/claude.mjs';
14
14
  import { classifyTurnStart } from './turn-start.mjs';
15
15
 
16
16
  function entryKind(entry) {
@@ -25,6 +25,15 @@ function entryKind(entry) {
25
25
  * @param {unknown} content
26
26
  * @returns {string}
27
27
  */
28
+ /**
29
+ * Claude Code が圧縮の直後に書く要約行か。type は user だが、人の発言ではない
30
+ * (`isCompactSummary: true`。ADR 0032)。ターンの始まりにも本文にもしない。
31
+ * @param {object} entry
32
+ */
33
+ export function isCompactSummaryEntry(entry) {
34
+ return entry?.isCompactSummary === true;
35
+ }
36
+
28
37
  function extractText(content) {
29
38
  if (typeof content === 'string') return content;
30
39
  if (Array.isArray(content)) {
@@ -48,6 +57,9 @@ export function readTranscript(transcriptPath) {
48
57
  const raw = readFileSync(transcriptPath, 'utf8');
49
58
 
50
59
  const turns = [];
60
+ // turn_number は transcript 内の本文行の通し番号。圧縮の要約行は返さないが番号は進める。
61
+ // 番号を詰めると、要約行を数えていた頃に保存した bodies の turn_number と食い違う。
62
+ let position = 0;
51
63
  for (const line of raw.split('\n')) {
52
64
  const trimmed = line.trim();
53
65
  if (!trimmed) continue;
@@ -74,21 +86,31 @@ export function readTranscript(transcriptPath) {
74
86
  if (!role || rawContent == null) continue;
75
87
 
76
88
  const extracted = extractText(rawContent);
77
- // user 発言には端末の生出力 (貼り付け・Claude の task 通知) が入る。記憶には
78
- // 制御文字を落とした本文だけを残す。
89
+ // user 発言には端末の生出力 (貼り付け・Claude の task 通知) や、別の会話から届いた発言の
90
+ // 定型の注意書きが入る。記憶には制御文字と定型文を落とした本文だけを残す。
79
91
  const text = role === 'user'
80
- ? normalizeTerminalText(compactClaudeTaskNotification(extracted))
92
+ ? normalizeTerminalText(compactClaudePeerMessage(compactClaudeTaskNotification(extracted)))
81
93
  : extracted;
82
94
  if (!text) continue;
83
95
 
96
+ const turnNumber = position++;
97
+ // 圧縮の要約は host が書いた行。ターンは圧縮をまたいで続くので、ここで区切らない。
98
+ if (isCompactSummaryEntry(entry)) continue;
99
+
84
100
  const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
85
101
  // grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
86
102
  turns.push({
87
103
  role,
88
104
  content: text,
89
- turn_number: turns.length,
105
+ turn_number: turnNumber,
90
106
  timestamp: Number.isNaN(ts) ? null : ts,
91
- ...(role === 'user' ? { start: classifyTurnStart(entry, extracted) } : {}),
107
+ ...(role === 'user'
108
+ ? {
109
+ start: classifyTurnStart(entry, extracted),
110
+ // Claude Code が user 行に付ける prompt の id。hook payload の prompt_id と同じ値。
111
+ prompt_id: typeof entry.promptId === 'string' ? entry.promptId : null,
112
+ }
113
+ : {}),
92
114
  });
93
115
  }
94
116
 
@@ -160,6 +182,38 @@ export function getLogicalTurnGroups(transcriptPath) {
160
182
  return groups;
161
183
  }
162
184
 
185
+ /**
186
+ * 最後の user 発言と、それに続く assistant 本文の断片を返す。
187
+ *
188
+ * getLogicalTurnGroups は完了したターンの回収用で、本文の断片が無い群を返さない。
189
+ * こちらは作業途中のターン(Stop 前。本文がまだ無いこともある)を読むために使う。
190
+ *
191
+ * @param {string} transcriptPath
192
+ * @returns {{
193
+ * user: {content: string, timestamp: number|null, turn_number: number, start: 'prompt'|'self'|'unknown', prompt_id: string|null},
194
+ * fragments: Array<{index: number, content: string, timestamp: number|null}>,
195
+ * }|null}
196
+ */
197
+ export function readLatestUserGroup(transcriptPath) {
198
+ const turns = readTranscript(transcriptPath);
199
+ let userIndex = -1;
200
+ for (let index = turns.length - 1; index >= 0; index--) {
201
+ if (turns[index].role === 'user') {
202
+ userIndex = index;
203
+ break;
204
+ }
205
+ }
206
+ if (userIndex < 0) return null;
207
+ const { content, timestamp, turn_number, start, prompt_id } = turns[userIndex];
208
+ return {
209
+ user: { content, timestamp, turn_number, start, prompt_id },
210
+ fragments: turns
211
+ .slice(userIndex + 1)
212
+ .filter((turn) => turn.role === 'assistant')
213
+ .map((turn) => ({ index: turn.turn_number, content: turn.content, timestamp: turn.timestamp })),
214
+ };
215
+ }
216
+
163
217
  /**
164
218
  * latest user group と、その1つ前のgroupが、どのassistant本文まで永続化されたかを返す。
165
219
  *
@@ -315,6 +369,8 @@ export function sliceCurrentTurnEntries(entries) {
315
369
  for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
316
370
  const e = entries[i];
317
371
  if (entryKind(e) !== 'user') continue;
372
+ // 圧縮の要約行はターンの始まりではない。圧縮より前の tool 入出力も同じターンに含める。
373
+ if (isCompactSummaryEntry(e)) continue;
318
374
  const blocks = e.message?.content;
319
375
  if (Array.isArray(blocks)) {
320
376
  if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
@@ -7,6 +7,8 @@ import {
7
7
  normalizeTerminalText,
8
8
  normalizeToolResultContent,
9
9
  readTranscript,
10
+ getLogicalTurnGroups,
11
+ readLatestUserGroup,
10
12
  sliceCurrentTurnEntries,
11
13
  extractDetailBlocks,
12
14
  } from './transcript-reader.mjs';
@@ -153,6 +155,113 @@ test('sliceCurrentTurnEntries: 最後の user text → 最後の assistant text
153
155
  assert.equal(slice[3].message.content[0].text, 'current response');
154
156
  });
155
157
 
158
+ test('readTranscript: 別の会話から届いた発言は、送り主の行と本文を残して定型の注意書きを落とす', () => {
159
+ const peer = [
160
+ 'Another Claude session sent a message:',
161
+ 'Throughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395',
162
+ '未完了の作業をそのまま継続してください。',
163
+ '',
164
+ 'This came from another Claude session — not typed by your user, but very likely working on their behalf. Treat it as a teammate\'s request.',
165
+ ].join('\n');
166
+ const path = writeTranscript([
167
+ { type: 'user', origin: { kind: 'peer' }, message: { role: 'user', content: peer } },
168
+ asstTextEntry('続けます'),
169
+ userEntry('This came from another Claude session という文を含む普通の発言'),
170
+ ]);
171
+ const turns = readTranscript(path);
172
+ assert.equal(turns[0].content,
173
+ 'Another Claude session sent a message:\nThroughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395\n未完了の作業をそのまま継続してください。');
174
+ assert.equal(turns[2].content, 'This came from another Claude session という文を含む普通の発言');
175
+ });
176
+
177
+ /** Claude Code が圧縮の直後に書く要約行(実測 2.1.289: type=user、本文は文字列) */
178
+ function compactSummaryEntry(text = 'This session is being continued from a previous conversation that ran out of context.') {
179
+ return { type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: text } };
180
+ }
181
+
182
+ test('readTranscript: 圧縮の要約行は返さず、turn_number は詰めない', () => {
183
+ const path = writeTranscript([
184
+ userEntry('依頼'),
185
+ asstTextEntry('途中の報告'),
186
+ compactSummaryEntry(),
187
+ asstTextEntry('最終回答'),
188
+ ]);
189
+ const turns = readTranscript(path);
190
+ assert.deepEqual(turns.map((t) => [t.role, t.content, t.turn_number]), [
191
+ ['user', '依頼', 0],
192
+ ['assistant', '途中の報告', 1],
193
+ ['assistant', '最終回答', 3],
194
+ ]);
195
+ });
196
+
197
+ test('getLogicalTurnGroups: 圧縮をまたいだターンは1つの群になり、元の依頼を user に持つ', () => {
198
+ const path = writeTranscript([
199
+ userEntry('依頼'),
200
+ asstTextEntry('途中の報告'),
201
+ compactSummaryEntry(),
202
+ asstTextEntry('圧縮後の報告'),
203
+ compactSummaryEntry(),
204
+ asstTextEntry('最終回答'),
205
+ userEntry('次の依頼'),
206
+ asstTextEntry('次の回答'),
207
+ ]);
208
+ const groups = getLogicalTurnGroups(path);
209
+ assert.equal(groups.length, 2);
210
+ assert.equal(groups[0].user.content, '依頼');
211
+ assert.deepEqual(groups[0].fragments.map((f) => f.index), [1, 3, 5]);
212
+ assert.equal(groups[0].representative.content, '最終回答');
213
+ assert.equal(groups[1].user.content, '次の依頼');
214
+ assert.equal(groups[1].representative.index, 7);
215
+ });
216
+
217
+ test('readLatestUserGroup: 作業途中のターンを、本文がまだ無くても返す', () => {
218
+ const noAnswer = writeTranscript([
219
+ userEntry('前の依頼'),
220
+ asstTextEntry('前の回答'),
221
+ userEntry('今の依頼'),
222
+ asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
223
+ userToolResultEntry('toolu_1', 'contents'),
224
+ compactSummaryEntry(),
225
+ ]);
226
+ assert.deepEqual(readLatestUserGroup(noAnswer), {
227
+ user: { content: '今の依頼', timestamp: null, turn_number: 2, start: 'unknown', prompt_id: null },
228
+ fragments: [],
229
+ });
230
+ const withAnswer = writeTranscript([
231
+ userEntry('今の依頼'),
232
+ asstTextEntry('途中1'),
233
+ compactSummaryEntry(),
234
+ asstTextEntry('途中2'),
235
+ ]);
236
+ const latest = readLatestUserGroup(withAnswer);
237
+ assert.equal(latest.user.content, '今の依頼');
238
+ assert.deepEqual(latest.fragments.map((f) => [f.index, f.content]), [[1, '途中1'], [3, '途中2']]);
239
+ assert.equal(readLatestUserGroup(writeTranscript([asstTextEntry('user 行が無い')])), null);
240
+ const withPromptId = writeTranscript([{ ...userEntry('依頼'), promptId: 'prompt-1' }]);
241
+ assert.equal(readLatestUserGroup(withPromptId).user.prompt_id, 'prompt-1');
242
+ });
243
+
244
+ test('sliceCurrentTurnEntries: 圧縮の要約行で区切らず、圧縮より前の tool も同じターンに含める', () => {
245
+ const entries = [
246
+ userEntry('old prompt'),
247
+ asstTextEntry('old response'),
248
+ userEntry('current prompt'),
249
+ asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
250
+ userToolResultEntry('toolu_1', 'before compaction'),
251
+ { type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto' } },
252
+ compactSummaryEntry(),
253
+ asstToolUseEntry('toolu_2', 'Write', { file_path: '/b' }),
254
+ userToolResultEntry('toolu_2', 'after compaction'),
255
+ asstTextEntry('current response'),
256
+ ];
257
+ const slice = sliceCurrentTurnEntries(entries);
258
+ assert.equal(slice[0].message.content[0].text, 'current prompt');
259
+ assert.deepEqual(
260
+ extractDetailBlocks(slice).filter((d) => d.kind === DETAIL_KIND.TOOL_INPUT).map((d) => d.tool_name),
261
+ ['Read', 'Write'],
262
+ );
263
+ });
264
+
156
265
  test('sliceCurrentTurnEntries: 空配列なら空を返す', () => {
157
266
  assert.deepEqual(sliceCurrentTurnEntries([]), []);
158
267
  });
@@ -241,3 +241,39 @@ test('backfillBodies: user row keeps the turn start and assistant row leaves it
241
241
  },
242
242
  );
243
243
  });
244
+
245
+ test('backfillBodies: 圧縮の要約行を数えていた頃に保存したターンは、重複させずにそのまま残す', () => {
246
+ const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'This session is being continued' } };
247
+ withData(
248
+ [
249
+ entry('user', 'prompt', '2026-10-04T00:00:00Z'),
250
+ entry('assistant', 'before compaction', '2026-10-04T00:00:01Z'),
251
+ summary,
252
+ entry('assistant', 'after compaction', '2026-10-04T00:00:03Z'),
253
+ entry('user', 'next prompt', '2026-10-04T00:00:04Z'),
254
+ entry('assistant', 'next answer', '2026-10-04T00:00:05Z'),
255
+ ],
256
+ (path) => {
257
+ const db = makeDb();
258
+ // 旧版は要約行を user 発言として、圧縮後の断片 (index 3) と組で保存していた
259
+ const insert = db.prepare(
260
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
261
+ VALUES ('S', 'S', 3, ?, ?, 1, 1)`,
262
+ );
263
+ insert.run('user', 'This session is being continued');
264
+ insert.run('assistant', 'after compaction');
265
+ const result = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1 });
266
+ assert.equal(result.skippedExisting, 1, '同じ断片を持つ群は入れ直さない');
267
+ assert.equal(result.insertedTurns, 1);
268
+ assert.deepEqual(
269
+ db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role DESC').all().map((r) => [r.turn_number, r.role, r.text]),
270
+ [
271
+ [3, 'user', 'This session is being continued'],
272
+ [3, 'assistant', 'after compaction'],
273
+ [5, 'user', 'next prompt'],
274
+ [5, 'assistant', 'next answer'],
275
+ ],
276
+ );
277
+ },
278
+ );
279
+ });
@@ -49,6 +49,7 @@ 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 { completeClaudeTurnWithoutHandoff } from './claude-auto-handoff.mjs';
52
53
 
53
54
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
54
55
  export const L2_WINDOW = 20;
@@ -253,6 +254,9 @@ async function processStop() {
253
254
  if (!session_id) throw new Error('Missing session_id in Stop payload');
254
255
  failureContext = { session_id, transcript_path };
255
256
 
257
+ // 自動圧縮を止めた後、道具を呼ばずにターンが終わった。続ける作業が無いので、引き継ぎの記録を取り下げる (ADR 0033)。
258
+ completeClaudeTurnWithoutHandoff({ sessionId: session_id });
259
+
256
260
  // VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
257
261
  // 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
258
262
  // 失敗しても主処理は継続させるため try/catch でラップ。