throughline 0.13.0 → 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,7 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
4
4
  import {
5
5
  buildResumeContext,
6
6
  buildBudgetedResumeContext,
7
- buildCompactContinuationContext,
7
+ buildAutoContinuationContext,
8
8
  INJECTION_BUDGET_CHARS,
9
9
  } from './resume-context.mjs';
10
10
 
@@ -907,73 +907,62 @@ test('budgeted: header and anchor always survive even under pressure', () => {
907
907
  assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
908
908
  });
909
909
 
910
- // --- 自動圧縮後の継続 (ADR 0032) ---
910
+ // --- 自動継続で立てた新しい会話への注入 (ADR 0033) ---
911
911
 
912
- test('buildCompactContinuationContext: 完了したターンが無くても、作業途中のターンだけで組み立てる', () => {
912
+ test('buildAutoContinuationContext: 保存済みのターンが無くても、止めた時点の依頼だけで組み立てる', () => {
913
913
  const db = makeDb();
914
- const result = buildCompactContinuationContext(db, {
914
+ const result = buildAutoContinuationContext(db, {
915
915
  sessionId: 'S',
916
- inFlight: { user: { content: '最初の依頼', timestamp: null }, fragments: [] },
916
+ inFlight: { user: { content: '最初の依頼', timestamp: null }, last_fragment: null },
917
917
  });
918
- assert.match(result.text, /^## Throughline: 自動圧縮後の継続用コンテキスト\n/);
919
- assert.match(result.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: 最初の依頼\n/);
920
- assert.doesNotMatch(result.text, /圧縮直前のあなたの発言/);
918
+ assert.match(result.text, /^## Throughline: 自動継続の文脈\n/);
919
+ assert.match(result.text, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\*: 最初の依頼\n/);
920
+ assert.doesNotMatch(result.text, /止める直前のあなたの発言/);
921
921
  assert.doesNotMatch(result.text, /### 直前の対話/);
922
922
  assert.equal(result.injectedL2Turns, 0);
923
- assert.equal(buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null }), null);
923
+ assert.equal(buildAutoContinuationContext(db, { sessionId: 'S', inFlight: null }), null);
924
924
  });
925
925
 
926
- test('buildCompactContinuationContext: 現在地は作業途中のターンで、保存済みのターンはL2に並び、宣言は求めない', () => {
926
+ test('buildAutoContinuationContext: 現在地は止めた時点のターンで、保存済みのターンはL2に並び、宣言は求めない', () => {
927
927
  const db = makeDb();
928
- insertBody(db, { session: 'S', origin: 'S', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
929
- insertBody(db, { session: 'S', origin: 'S', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
930
- const result = buildCompactContinuationContext(db, {
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
931
  sessionId: 'S',
932
932
  inFlight: {
933
933
  user: { content: '今の依頼', timestamp: 3_000 },
934
- fragments: [{ content: '途中1', timestamp: 4_000 }, { content: '途中2', timestamp: 5_000 }],
934
+ last_fragment: { content: '途中2', timestamp: 5_000 },
935
935
  },
936
936
  });
937
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, /前の依頼|途中1/);
938
+ assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 途中2\n/);
939
+ assert.doesNotMatch(anchor, /前の依頼/);
940
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/);
941
+ assert.doesNotMatch(result.text, /宣言|\/clear|圧縮の要約/);
942
+ assert.match(result.text, /止める直前に呼ぼうとした道具は実行されていません/);
942
943
  assert.equal(result.injectedL2Turns, 1);
943
944
  });
944
945
 
945
- test('buildCompactContinuationContext: 長い依頼と多数のターンでも予算内に収め、依頼は切り詰めを明示する', () => {
946
+ test('buildAutoContinuationContext: 長い依頼は先頭と末尾を残し、多数のターンでも予算内に収める', () => {
946
947
  const db = makeDb();
947
948
  for (let turn = 1; turn <= 12; turn++) {
948
- insertBody(db, { session: 'S', origin: 'S', turn, role: 'user', text: `依頼${turn} ` + 'あ'.repeat(600), createdAt: turn * 1_000 });
949
- insertBody(db, { session: 'S', origin: 'S', turn, role: 'assistant', text: `回答${turn} ` + 'い'.repeat(600), createdAt: turn * 1_000 + 500 });
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 });
950
951
  }
951
- const result = buildCompactContinuationContext(db, {
952
+ const result = buildAutoContinuationContext(db, {
952
953
  sessionId: 'S',
953
954
  inFlight: {
954
- user: { content: 'ヰ'.repeat(6_000), timestamp: 20_000 },
955
- fragments: [{ content: 'ヱ'.repeat(3_000), timestamp: 21_000 }],
955
+ user: { content: '先頭の指示' + 'ヰ'.repeat(6_000) + '末尾の指示', timestamp: 20_000 },
956
+ last_fragment: { content: 'ヱ'.repeat(3_000), timestamp: 21_000 },
956
957
  },
957
958
  });
958
959
  assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
959
- assert.equal((result.text.match(/ヰ/g) ?? []).length, 4_000);
960
+ assert.match(result.text, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 先頭の指示ヰ+ …\(長いため中略 2010 字\)… ヰ+末尾の指示\n/);
961
+ assert.equal((result.text.match(/ヰ/g) ?? []).length, 3_990);
960
962
  assert.equal((result.text.match(/ヱ/g) ?? []).length, 1_500);
961
- assert.match(result.text, /ヰ …\(長いため中略 2000 字\)… ヰ/);
962
963
  assert.match(result.text, /ヱ …\(長いため中略 1500 字\)… ヱ/);
963
964
  assert.ok(result.injectedL2Turns >= 1, '最新の保存済みターンは入る');
964
965
  assert.match(result.text, /回答12/);
965
966
  assert.equal(result.injectedL2Turns + result.remainingL2Turns, 12);
966
967
  assert.match(result.text, /throughline recall --l2 --session S /);
967
968
  });
968
-
969
- test('buildCompactContinuationContext: 長い依頼は先頭と末尾を残す。今の依頼が記録から読めない時は、その旨だけを現在地に書く', () => {
970
- const db = makeDb();
971
- const long = buildCompactContinuationContext(db, {
972
- sessionId: 'S',
973
- inFlight: { user: { content: '先頭の指示' + 'あ'.repeat(6_000) + '末尾の指示', timestamp: null }, fragments: [] },
974
- });
975
- assert.match(long.text, /\*\*作業中のユーザー依頼\*\*: 先頭の指示あ+ …\(長いため中略 2010 字\)… あ+末尾の指示\n/);
976
-
977
- const unreadable = buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null, inFlightUnreadable: true });
978
- assert.match(unreadable.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)\n/);
979
- });
@@ -47,12 +47,17 @@ const DEFINITIONS = Object.freeze({
47
47
  severity: 'high',
48
48
  template: 'Throughline Claude Stop hook processing failed',
49
49
  }),
50
- // 自動継続の印を残せなかった。圧縮と作業は続く(ADR 0032)。
50
+ // 自動継続の hook が失敗した。圧縮も道具も止まらず、作業は続く(ADR 0033)。
51
51
  HOOK_PRE_COMPACT_FAILED: Object.freeze({
52
52
  component: 'claude_pre_compact_hook',
53
53
  severity: 'warn',
54
54
  template: 'Throughline Claude PreCompact hook processing failed',
55
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
+ }),
56
61
  HOOK_CODEX_FAILED: Object.freeze({
57
62
  component: 'codex_hook',
58
63
  severity: 'high',
@@ -22,11 +22,11 @@
22
22
  * バトンの消費・merge・注入は最初の UserPromptSubmit (= 実体の証明) で行う。
23
23
  * 幽霊はプロンプトを発火しないため記憶を奪えない。
24
24
  *
25
- * 【自動圧縮後の継続】 ADR 0032
25
+ * 【自動継続の後継】 ADR 0033
26
26
  *
27
- * source='compact' は新しい会話ではなく、圧縮を終えた同じ会話。PreCompact hook が
28
- * 自動圧縮の印を残していた時だけ、作業途中のターンと直近の会話の原文を stdout へ書く。
29
- * host がそのまま作業を続けるので、次の UserPromptSubmit は待たない。
27
+ * 同じ project で後継の立ち上げ中の引き継ぎがある時だけ、この会話の受け口(inbox socket)を
28
+ * 控える。worker がそれを読んで、継続の指示を1通送る。記憶の注入は、その指示が届いた時の
29
+ * UserPromptSubmit(上の二相ハンドオフの第二相)が行う。
30
30
  */
31
31
 
32
32
  import { getDb } from './db.mjs';
@@ -41,7 +41,7 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
41
41
  import { logHookFailure } from './hook-failure-log.mjs';
42
42
  import { CLAUDE_HOST, hostAdapterForSessionId, hostOfSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
43
43
  import { executeFirstPromptHandoff } from './handoff-executor.mjs';
44
- import { consumeClaudeCompactRequest, buildClaudeCompactContinuation } from './claude-auto-handoff.mjs';
44
+ import { recordClaudeSuccessorTarget } from './claude-auto-handoff.mjs';
45
45
 
46
46
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
47
47
 
@@ -170,29 +170,9 @@ export async function run() {
170
170
  pending_registered: true,
171
171
  });
172
172
 
173
- // 自動圧縮の直後: 印があれば、圧縮前の記録を同じ会話へ注入する (ADR 0032)。
174
- // subagent の中で走った hook は、親の会話の印を消費しない。
175
- if (source === 'compact' && hostOfSessionId(session_id) === CLAUDE_HOST && !payload.agent_id) {
176
- const request = consumeClaudeCompactRequest({ sessionId: session_id });
177
- if (request) {
178
- const continuation = buildClaudeCompactContinuation(db, {
179
- sessionId: session_id,
180
- transcriptPath: transcript_path ?? request.transcript_path,
181
- promptId: payload.prompt_id ?? null,
182
- now,
183
- });
184
- if (continuation) process.stdout.write(continuation.text + '\n');
185
- logDecision({
186
- ts: new Date(now).toISOString(),
187
- phase: 'compact-continuation',
188
- session_id,
189
- project_path: request.project_path,
190
- transcript_path: transcript_path ?? request.transcript_path ?? null,
191
- requested_at: new Date(request.requested_at).toISOString(),
192
- injected: Boolean(continuation),
193
- ...(continuation?.stats ?? {}),
194
- });
195
- }
173
+ // 自動継続の後継として立ち上がった会話なら、worker が指示を送れるように受け口を控える (ADR 0033)。
174
+ if (hostOfSessionId(session_id) === CLAUDE_HOST) {
175
+ recordClaudeSuccessorTarget({ payload, env: process.env, now });
196
176
  }
197
177
 
198
178
  // Cursor は beforeSubmitPrompt が additional_context を持たない。
@@ -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) {
@@ -86,10 +86,10 @@ export function readTranscript(transcriptPath) {
86
86
  if (!role || rawContent == null) continue;
87
87
 
88
88
  const extracted = extractText(rawContent);
89
- // user 発言には端末の生出力 (貼り付け・Claude の task 通知) が入る。記憶には
90
- // 制御文字を落とした本文だけを残す。
89
+ // user 発言には端末の生出力 (貼り付け・Claude の task 通知) や、別の会話から届いた発言の
90
+ // 定型の注意書きが入る。記憶には制御文字と定型文を落とした本文だけを残す。
91
91
  const text = role === 'user'
92
- ? normalizeTerminalText(compactClaudeTaskNotification(extracted))
92
+ ? normalizeTerminalText(compactClaudePeerMessage(compactClaudeTaskNotification(extracted)))
93
93
  : extracted;
94
94
  if (!text) continue;
95
95
 
@@ -155,6 +155,25 @@ test('sliceCurrentTurnEntries: 最後の user text → 最後の assistant text
155
155
  assert.equal(slice[3].message.content[0].text, 'current response');
156
156
  });
157
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
+
158
177
  /** Claude Code が圧縮の直後に書く要約行(実測 2.1.289: type=user、本文は文字列) */
159
178
  function compactSummaryEntry(text = 'This session is being continued from a previous conversation that ran out of context.') {
160
179
  return { type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: text } };
@@ -59,15 +59,10 @@ export function deriveTranscriptPath(projectPath, sessionId) {
59
59
  * @param {string} opts.originSessionId transcript を所有する origin session_id
60
60
  * @param {string|null|undefined} opts.transcriptPath
61
61
  * @param {number} opts.now timestamp 欠損時の fallback epoch ms
62
- * @param {number|null} [opts.beforeUserTurnNumber] 指定した時は、user 行の番号がこれより前の群だけを回収する。
63
- * 作業途中のターンがある時点(自動圧縮の直後。ADR 0032)で呼ぶ時に、その群を除くために使う。
64
- * 途中の断片を代表として保存すると、群レベル dedup で最終回答が二度と入らない。
65
62
  * @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null, turnNumbers: number[]}}
66
63
  */
67
- export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now, beforeUserTurnNumber = null }) {
68
- const groups = getLogicalTurnGroups(transcriptPath).filter(
69
- (group) => beforeUserTurnNumber === null || group.user.turn_number < beforeUserTurnNumber,
70
- );
64
+ export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
65
+ const groups = getLogicalTurnGroups(transcriptPath);
71
66
  if (groups.length === 0) {
72
67
  return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] };
73
68
  }
@@ -242,28 +242,6 @@ test('backfillBodies: user row keeps the turn start and assistant row leaves it
242
242
  );
243
243
  });
244
244
 
245
- test('backfillBodies: beforeUserTurnNumber は作業途中のターンを回収から外す', () => {
246
- withData(
247
- [
248
- entry('user', 'done prompt', '2026-10-04T00:00:00Z'),
249
- entry('assistant', 'done answer', '2026-10-04T00:00:01Z'),
250
- entry('user', 'in-flight prompt', '2026-10-04T00:00:02Z'),
251
- entry('assistant', 'interim remark', '2026-10-04T00:00:03Z'),
252
- ],
253
- (path) => {
254
- const db = makeDb();
255
- const partial = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1, beforeUserTurnNumber: 2 });
256
- assert.equal(partial.insertedTurns, 1);
257
- assert.deepEqual(partial.turnNumbers, [1]);
258
- assert.deepEqual(db.prepare('SELECT text FROM bodies ORDER BY id').all().map((r) => r.text), ['done prompt', 'done answer']);
259
- // Stop で改めて全体を回収すると、作業途中だったターンが最終回答つきで入る
260
- const full = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1 });
261
- assert.equal(full.insertedTurns, 1);
262
- assert.equal(full.skippedExisting, 1);
263
- },
264
- );
265
- });
266
-
267
245
  test('backfillBodies: 圧縮の要約行を数えていた頃に保存したターンは、重複させずにそのまま残す', () => {
268
246
  const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'This session is being continued' } };
269
247
  withData(
@@ -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 でラップ。