throughline 0.13.0 → 0.14.1

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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 呼び出し元:
5
5
  * - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
6
- * - claude-auto-handoff.mjs(自動圧縮の直後のSessionStartで同じ会話へ注入。ADR 0032)
6
+ * - handoff-executor.mjs(前任が自動継続で引き継いだ会話の、最初のUserPromptSubmit。ADR 0033)
7
7
  *
8
8
  * 設計 (docs/02_clear_auto_handoff_plan.md):
9
9
  * - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
@@ -58,25 +58,29 @@ const NORMAL_HEADER = '## Throughline: セッション記憶';
58
58
 
59
59
  const DEFAULT_ANCHOR_TITLE = '### 現在地 (直前のやりとり)';
60
60
 
61
- // 自動圧縮の直後に、同じ会話へ注入する時のヘッダ (ADR 0032)。/clear の引き継ぎと違い、
62
- // 作業の途中で、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
63
- const COMPACT_HEADER =
64
- `## Throughline: 自動圧縮後の継続用コンテキスト\n` +
61
+ // 自動継続で立てた新しい会話へ注入する時のヘッダ (ADR 0033)。/clear の引き継ぎと違い、
62
+ // 前の会話は作業の途中で止めてあり、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
63
+ const AUTO_CONTINUATION_HEADER = (projectPath) =>
64
+ `## Throughline: 自動継続の文脈\n` +
65
+ (projectPath ? `作業ディレクトリ: ${projectPath}\n` : '') +
65
66
  `\n` +
66
67
  `**読み方 (重要):**\n` +
67
- `- この会話は、たった今 Claude Code の自動圧縮で要約に置き換わりました。下記は Throughline が` +
68
- `圧縮の前に保存した **あなた自身とユーザーの会話の原文** です。\n` +
69
- `- 圧縮の要約と下記の記録が食い違う時は、下記の記録(元のユーザーの依頼・合意)を正としてください。\n` +
70
- `- 「現在地」のターンは **作業の途中** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
71
- `- 完了済みの操作を繰り返さないでください。直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
72
- '- **完了したターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
73
- `(該当ターンの本文+詳細を stdout に返します)`;
74
-
75
- const COMPACT_ANCHOR_TITLE = '### 現在地 (作業途中のターン)';
76
- // 作業途中のターンは Stop の前で、まだ bodies に無い。依頼の原文を残せる場所はここだけなので、
77
- // /clear のアンカー (600 字) より厚く取る。
78
- const IN_FLIGHT_USER_MAX_CHARS = 4_000;
68
+ `- 前の会話は文脈の上限に近づいたため、Throughline が自動圧縮の前に止めて、この新しい会話へ引き継ぎました。` +
69
+ `下記は、前の会話で **あなた自身とユーザーが交わした会話の原文** です。\n` +
70
+ `- 「現在地」のターンは **作業の途中で止めたもの** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
71
+ `- 「ここまでにしたこと」と「直前の対話」に載っている操作は **完了しています**。繰り返さないでください。` +
72
+ `未実行なのは「実行されなかった道具」だけです。そこから続けてください。\n` +
73
+ `- 直前の実行結果が分からない時は、作業ツリーや詳細を確かめてから続けてください。\n` +
74
+ '- **各ターンの詳細**(止めたターンを含む): **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
75
+ `(該当ターンの本文+道具の入出力を stdout に返します)`;
76
+
77
+ const AUTO_CONTINUATION_ANCHOR_TITLE = '### 現在地 (作業の途中で止めたターン)';
78
+ // 止めたターンは現在地にだけ載せ、L2 の一覧からは外す(同じ依頼を二度載せない)。
79
+ // /clear のアンカー (600 字) より厚く取る。3つの上限を足しても、ヘッダと案内を合わせて予算に収まる。
80
+ const IN_FLIGHT_USER_MAX_CHARS = 3_500;
79
81
  const IN_FLIGHT_ASSISTANT_MAX_CHARS = 1_500;
82
+ // 止めたターンでここまでにしたこと(発言と道具の呼び出し)。新しい側から、入るだけ載せる。
83
+ const IN_FLIGHT_STEPS_MAX_CHARS = 2_400;
80
84
 
81
85
  // v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
82
86
  // 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
@@ -500,7 +504,8 @@ function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxC
500
504
  const older = loadOlderTurnStats(db, {
501
505
  sessionId,
502
506
  excludeOriginId,
503
- windowKeys: new Set(turns.map((t) => t.turnKey)),
507
+ // 現在地にだけ載せたターン(自動継続の止めたターン)は、窓の外の古いターンに数えない。
508
+ windowKeys: new Set([...turns.map((t) => t.turnKey), ...(sections.anchorTurnKeys ?? [])]),
504
509
  });
505
510
 
506
511
  const guidanceLines = buildGuidanceLines({
@@ -549,60 +554,123 @@ function truncateInFlight(text, maxChars) {
549
554
  }
550
555
 
551
556
  /**
552
- * 自動圧縮の直後に、同じ会話へ注入するテキスト (ADR 0032)。
557
+ * 止めたターンでここまでにしたことを、現在地の行にする。
558
+ * 新しい側から予算に入るだけ載せ、載せなかった古い側は件数だけを1行にまとめる。
559
+ * @returns {{lines: string[], shownTexts: Set<string>}}
560
+ */
561
+ function renderInFlightSteps(inFlight, detailClock) {
562
+ const steps = [...(inFlight.steps ?? [])];
563
+ const earlier = inFlight.earlier_steps ?? null;
564
+ // 一覧の最後が止める直前の発言で、1行に詰めると元の文と変わる(改行がある・長い)時は、
565
+ // 一覧から外して、下の「止める直前のあなたの発言」に全文で載せる。
566
+ const last = steps.at(-1);
567
+ const lastFragment = inFlight.last_fragment;
568
+ if (last?.kind === 'text' && lastFragment && last.timestamp === lastFragment.timestamp &&
569
+ last.content !== lastFragment.content.trim()) {
570
+ steps.pop();
571
+ }
572
+ if (steps.length === 0 && !earlier) return { lines: [], shownTexts: new Set() };
573
+
574
+ const rendered = steps.map((step) =>
575
+ step.kind === 'text'
576
+ ? `- 発言: ${step.content}`
577
+ : `- 道具: ${step.name}${step.target ? ` ${step.target}` : ''}${step.failed ? ' → 失敗' : ''}`,
578
+ );
579
+ let budget = IN_FLIGHT_STEPS_MAX_CHARS;
580
+ let first = rendered.length;
581
+ while (first > 0 && budget - (rendered[first - 1].length + 1) >= 0) {
582
+ first -= 1;
583
+ budget -= rendered[first].length + 1;
584
+ }
585
+ const omitted = steps.slice(0, first);
586
+ const omittedTexts = (earlier?.texts ?? 0) + omitted.filter((step) => step.kind === 'text').length;
587
+ const omittedTools =
588
+ Object.values(earlier?.tools ?? {}).reduce((sum, count) => sum + count, 0) +
589
+ omitted.filter((step) => step.kind === 'tool').length;
590
+
591
+ const lines = [
592
+ `**このターンでここまでにしたこと**(古い順。どれも完了済み` +
593
+ (detailClock ? `。道具の入出力の全文: \`throughline detail ${detailClock}\`` : '') +
594
+ `):`,
595
+ ];
596
+ if (omittedTexts + omittedTools > 0) {
597
+ lines.push(`- (これより前に、発言 ${omittedTexts} 件・道具 ${omittedTools} 回)`);
598
+ }
599
+ lines.push(...rendered.slice(first));
600
+ return {
601
+ lines,
602
+ shownTexts: new Set(steps.slice(first).filter((step) => step.kind === 'text').map((step) => step.content)),
603
+ };
604
+ }
605
+
606
+ /**
607
+ * 自動継続で立てた新しい会話へ注入するテキスト (ADR 0033)。
553
608
  *
554
609
  * 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
555
- * DB の最新ターンではなく、transcript から読んだ作業途中のターンを載せる。作業途中のターンは
556
- * Stop の前で bodies に無いので、L2 セクションには出ない。
557
- *
558
- * 完了したターンが 1 つも無い会話(最初のターンの途中で圧縮)でも、現在地だけを返す。
610
+ * DB の最新ターンではなく、止めた時点に記録した作業途中のターンを載せる。依頼、ここまでにしたこと
611
+ * (発言と道具の呼び出し)、止める直前の発言、実行されなかった道具の順。止めたターンは L2 の一覧から外す。
559
612
  *
560
613
  * @param {import('node:sqlite').DatabaseSync} db
561
614
  * @param {{
562
- * sessionId: string,
615
+ * sessionId: string, 合流を終えた後継の session id
563
616
  * inFlight: {
564
617
  * user: {content: string, timestamp: number|null},
565
- * fragments: Array<{content: string, timestamp: number|null}>,
618
+ * last_fragment: {content: string, timestamp: number|null} | null,
619
+ * steps?: Array<{kind: 'text', content: string, timestamp: number|null} | {kind: 'tool', name: string, target: string, failed: boolean}>,
620
+ * earlier_steps?: {texts: number, tools: Record<string, number>} | null,
621
+ * stopped_tools?: Array<{name: string, target: string}>,
622
+ * stopped_tools_total?: number,
623
+ * turn?: {origin_session_id: string, turn_number: number, user_at: number, assistant_at: number|null} | null,
566
624
  * } | null,
567
- * inFlightUnreadable?: boolean, transcript に今の依頼がまだ書かれていない(hook の prompt_id と合わない)
625
+ * projectPath?: string | null, 前の会話の作業ディレクトリ。依頼の中の相対的な場所の基準として渡す
568
626
  * maxChars?: number,
569
627
  * }} params
570
628
  */
571
- export function buildCompactContinuationContext(
629
+ export function buildAutoContinuationContext(
572
630
  db,
573
- { sessionId, inFlight, inFlightUnreadable = false, maxChars = INJECTION_BUDGET_CHARS },
631
+ { sessionId, inFlight, projectPath = null, maxChars = INJECTION_BUDGET_CHARS },
574
632
  ) {
575
633
  const stored = buildResumeSections(db, { sessionId, isInheritance: true, handoffDisclosure: 'silent' });
576
634
  const stamp = (timestamp) => (timestamp == null ? '' : ` [${formatClock(timestamp)}]`);
577
635
  const anchorLines = [];
636
+ const turn = inFlight?.turn ?? null;
637
+ const turnKey = turn ? `${turn.origin_session_id}\x00${turn.turn_number}` : null;
578
638
  if (inFlight) {
579
639
  anchorLines.push(
580
640
  `**作業中のユーザー依頼**${stamp(inFlight.user.timestamp)}: ` +
581
641
  truncateInFlight(inFlight.user.content, IN_FLIGHT_USER_MAX_CHARS),
582
642
  );
583
- const lastFragment = inFlight.fragments.at(-1);
584
- if (lastFragment) {
643
+ const steps = renderInFlightSteps(inFlight, turn ? formatClock(turn.assistant_at ?? turn.user_at) : null);
644
+ anchorLines.push(...steps.lines);
645
+ // 一覧に同じ文がそのまま載っている時は、重ねて載せない。改行のある発言や長い発言は、ここに全文を載せる。
646
+ if (inFlight.last_fragment && !steps.shownTexts.has(inFlight.last_fragment.content.trim())) {
585
647
  anchorLines.push(
586
- `**圧縮直前のあなたの発言**${stamp(lastFragment.timestamp)}: ` +
587
- truncateInFlight(lastFragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
648
+ `**止める直前のあなたの発言**${stamp(inFlight.last_fragment.timestamp)}: ` +
649
+ truncateInFlight(inFlight.last_fragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
650
+ );
651
+ }
652
+ const stoppedTools = inFlight.stopped_tools ?? [];
653
+ if (stoppedTools.length > 0) {
654
+ const more = (inFlight.stopped_tools_total ?? stoppedTools.length) - stoppedTools.length;
655
+ anchorLines.push(
656
+ `**止める直前に呼ぼうとして、実行されなかった道具**: ` +
657
+ stoppedTools.map((tool) => `${tool.name}${tool.target ? ` ${tool.target}` : ''}`).join('、') +
658
+ (more > 0 ? `(ほか ${more} 件)` : ''),
588
659
  );
589
660
  }
590
- }
591
- if (!inFlight && inFlightUnreadable) {
592
- anchorLines.push(
593
- '**作業中のユーザー依頼**: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)',
594
- );
595
661
  }
596
662
  if (!stored && anchorLines.length === 0) return null;
597
663
 
598
664
  return renderBudgetedSections(
599
665
  db,
600
666
  {
601
- header: COMPACT_HEADER,
602
- anchorTitle: COMPACT_ANCHOR_TITLE,
667
+ header: AUTO_CONTINUATION_HEADER(projectPath),
668
+ anchorTitle: AUTO_CONTINUATION_ANCHOR_TITLE,
603
669
  anchorLines,
670
+ anchorTurnKeys: turnKey ? [turnKey] : [],
604
671
  l1Lines: [],
605
- l2Lines: stored?.l2Lines ?? [],
672
+ // 止めたターンは現在地に載せた。L2 の一覧には、それより前のターンだけを載せる。
673
+ l2Lines: (stored?.l2Lines ?? []).filter((line) => line.turnKey !== turnKey),
606
674
  },
607
675
  { sessionId, excludeOriginId: null, maxChars },
608
676
  );
@@ -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,58 +907,59 @@ 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\]: 先頭の指示ヰ+ …\(長いため中略 2510 字\)… ヰ+末尾の指示\n/);
961
+ assert.equal((result.text.match(/ヰ/g) ?? []).length, 3_490);
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/);
@@ -966,14 +967,70 @@ test('buildCompactContinuationContext: 長い依頼と多数のターンでも
966
967
  assert.match(result.text, /throughline recall --l2 --session S /);
967
968
  });
968
969
 
969
- test('buildCompactContinuationContext: 長い依頼は先頭と末尾を残す。今の依頼が記録から読めない時は、その旨だけを現在地に書く', () => {
970
+ test('buildAutoContinuationContext: 止めたターンでここまでにしたことと、実行されなかった道具を現在地に載せ、そのターンをL2に重ねない', () => {
970
971
  const db = makeDb();
971
- const long = buildCompactContinuationContext(db, {
972
+ insertBody(db, { session: 'S', origin: 'P0', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
973
+ insertBody(db, { session: 'S', origin: 'P0', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
974
+ // worker が取り込んだ止めたターン(発言を全部つないだ本文)
975
+ insertBody(db, { session: 'S', origin: 'P', turn: 7, role: 'user', text: '今の依頼', createdAt: 3_000 });
976
+ insertBody(db, { session: 'S', origin: 'P', turn: 7, role: 'assistant', text: 'a を直しました。\n\nb を読みます。', createdAt: 5_000 });
977
+ const result = buildAutoContinuationContext(db, {
972
978
  sessionId: 'S',
973
- inFlight: { user: { content: '先頭の指示' + 'あ'.repeat(6_000) + '末尾の指示', timestamp: null }, fragments: [] },
979
+ inFlight: {
980
+ user: { content: '今の依頼', timestamp: 3_000 },
981
+ last_fragment: { content: 'b を読みます。', timestamp: 5_000 },
982
+ steps: [
983
+ { kind: 'tool', name: 'Edit', target: '/work/a.mjs', failed: false },
984
+ { kind: 'tool', name: 'Bash', target: 'npm test', failed: true },
985
+ { kind: 'text', content: 'a を直しました。', timestamp: 4_000 },
986
+ { kind: 'text', content: 'b を読みます。', timestamp: 5_000 },
987
+ ],
988
+ earlier_steps: null,
989
+ stopped_tools: [{ name: 'Read', target: '/work/b.mjs' }, { name: 'Read', target: '/work/c.mjs' }],
990
+ stopped_tools_total: 3,
991
+ turn: { origin_session_id: 'P', turn_number: 7, user_at: 3_000, assistant_at: 5_000 },
992
+ },
974
993
  });
975
- assert.match(long.text, /\*\*作業中のユーザー依頼\*\*: 先頭の指示あ+ …\(長いため中略 2010 字\)… あ+末尾の指示\n/);
994
+ const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
995
+ assert.match(anchor, new RegExp(
996
+ '\\*\\*作業中のユーザー依頼\\*\\* \\[\\d\\d:\\d\\d:\\d\\d\\]: 今の依頼\\n' +
997
+ '\\*\\*このターンでここまでにしたこと\\*\\*(古い順。どれも完了済み。道具の入出力の全文: `throughline detail \\d\\d:\\d\\d:\\d\\d`):\\n' +
998
+ '- 道具: Edit /work/a\\.mjs\\n- 道具: Bash npm test → 失敗\\n- 発言: a を直しました。\\n- 発言: b を読みます。\\n' +
999
+ '\\*\\*止める直前に呼ぼうとして、実行されなかった道具\\*\\*: Read /work/b\\.mjs、Read /work/c\\.mjs(ほか 1 件)\\n'));
1000
+ assert.doesNotMatch(anchor, /止める直前のあなたの発言/, '一覧に同じ文が載っている発言は重ねない');
1001
+ const l2 = result.text.slice(result.text.indexOf('### 直前の対話'));
1002
+ assert.match(l2, /\[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
1003
+ assert.doesNotMatch(l2, /今の依頼|a を直しました/, '止めたターンは現在地だけに載せる');
1004
+ assert.equal(result.injectedL2Turns, 1);
1005
+ assert.equal(result.olderTurns, 0, '止めたターンを、窓の外の古いターンに数えない');
1006
+ });
976
1007
 
977
- const unreadable = buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null, inFlightUnreadable: true });
978
- assert.match(unreadable.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)\n/);
1008
+ test('buildAutoContinuationContext: 改行のある直前の発言は全文を載せ、多い手順は新しい側から入るだけ載せて残りは件数にする', () => {
1009
+ const db = makeDb();
1010
+ const steps = [];
1011
+ for (let n = 1; n <= 80; n++) {
1012
+ steps.push({ kind: 'tool', name: 'Read', target: `/work/file-${String(n).padStart(3, '0')}-${'x'.repeat(40)}.mjs`, failed: false });
1013
+ }
1014
+ steps.push({ kind: 'text', content: '方針: 1. 型を直す 2. 試験を足す', timestamp: 9_000 });
1015
+ const result = buildAutoContinuationContext(db, {
1016
+ sessionId: 'S',
1017
+ inFlight: {
1018
+ user: { content: '大きな依頼', timestamp: 1_000 },
1019
+ last_fragment: { content: '方針:\n1. 型を直す\n2. 試験を足す', timestamp: 9_000 },
1020
+ steps,
1021
+ earlier_steps: { texts: 4, tools: { Read: 30, Edit: 6 } },
1022
+ stopped_tools: [{ name: 'Edit', target: '/work/types.mjs' }],
1023
+ stopped_tools_total: 1,
1024
+ turn: null,
1025
+ },
1026
+ });
1027
+ assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
1028
+ assert.match(result.text, /\*\*このターンでここまでにしたこと\*\*(古い順。どれも完了済み):\n- (これより前に、発言 4 件・道具 (\d+) 回)\n- 道具: Read /);
1029
+ const omittedTools = Number(/これより前に、発言 4 件・道具 (\d+) 回/.exec(result.text)[1]);
1030
+ const shownTools = (result.text.match(/^- 道具: Read /gm) ?? []).length;
1031
+ assert.equal(omittedTools + shownTools, 36 + 80, '載せた分と件数にした分で全部');
1032
+ assert.ok(shownTools >= 20 && shownTools < 80, `${shownTools}`);
1033
+ assert.doesNotMatch(result.text, /- 発言: 方針/, '下に全文を載せる発言は、一覧に重ねない');
1034
+ assert.match(result.text, /- 道具: Read \/work\/file-080-x+\.mjs\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 方針:\n1\. 型を直す\n2\. 試験を足す\n\*\*止める直前に呼ぼうとして、実行されなかった道具\*\*: Edit \/work\/types\.mjs\n/);
979
1035
  });
1036
+
@@ -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
 
@@ -387,6 +387,28 @@ export function sliceCurrentTurnEntries(entries) {
387
387
  return entries.slice(userTextIdx, lastAssistantTextIdx + 1);
388
388
  }
389
389
 
390
+ /**
391
+ * 作業途中で止めたターンのエントリ範囲 (ADR 0033)。最後の user 本文の行から、transcript の末尾まで。
392
+ *
393
+ * sliceCurrentTurnEntries は最後の assistant 本文で切るので、その後に呼んだ道具が入らない。
394
+ * hook で止めたターンは、止めた道具の呼び出しと結果が末尾に並ぶ。それも同じターンとして返す。
395
+ *
396
+ * @param {Array<object>} entries readRawEntries の結果
397
+ * @returns {Array<object>}
398
+ */
399
+ export function sliceInFlightTurnEntries(entries) {
400
+ for (let i = entries.length - 1; i >= 0; i--) {
401
+ const e = entries[i];
402
+ if (entryKind(e) !== 'user' || isCompactSummaryEntry(e)) continue;
403
+ const blocks = e.message?.content;
404
+ const hasText = Array.isArray(blocks)
405
+ ? blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)
406
+ : typeof blocks === 'string' && blocks.length > 0;
407
+ if (hasText) return entries.slice(i);
408
+ }
409
+ return [];
410
+ }
411
+
390
412
  /**
391
413
  * 論理ターン内の全エントリから L3 (details) 用の生レコードを抽出する。
392
414
  *
@@ -10,6 +10,7 @@ import {
10
10
  getLogicalTurnGroups,
11
11
  readLatestUserGroup,
12
12
  sliceCurrentTurnEntries,
13
+ sliceInFlightTurnEntries,
13
14
  extractDetailBlocks,
14
15
  } from './transcript-reader.mjs';
15
16
  import { DETAIL_KIND } from './constants.mjs';
@@ -155,6 +156,25 @@ test('sliceCurrentTurnEntries: 最後の user text → 最後の assistant text
155
156
  assert.equal(slice[3].message.content[0].text, 'current response');
156
157
  });
157
158
 
159
+ test('readTranscript: 別の会話から届いた発言は、送り主の行と本文を残して定型の注意書きを落とす', () => {
160
+ const peer = [
161
+ 'Another Claude session sent a message:',
162
+ 'Throughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395',
163
+ '未完了の作業をそのまま継続してください。',
164
+ '',
165
+ '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.',
166
+ ].join('\n');
167
+ const path = writeTranscript([
168
+ { type: 'user', origin: { kind: 'peer' }, message: { role: 'user', content: peer } },
169
+ asstTextEntry('続けます'),
170
+ userEntry('This came from another Claude session という文を含む普通の発言'),
171
+ ]);
172
+ const turns = readTranscript(path);
173
+ assert.equal(turns[0].content,
174
+ 'Another Claude session sent a message:\nThroughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395\n未完了の作業をそのまま継続してください。');
175
+ assert.equal(turns[2].content, 'This came from another Claude session という文を含む普通の発言');
176
+ });
177
+
158
178
  /** Claude Code が圧縮の直後に書く要約行(実測 2.1.289: type=user、本文は文字列) */
159
179
  function compactSummaryEntry(text = 'This session is being continued from a previous conversation that ran out of context.') {
160
180
  return { type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: text } };
@@ -509,3 +529,26 @@ test('extractDetailBlocks: 複数ツール連続呼び出しを全て拾う', ()
509
529
  [DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT, DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT],
510
530
  );
511
531
  });
532
+
533
+ test('sliceInFlightTurnEntries: 最後の user 本文から末尾まで。最後の発言の後に呼んだ道具と、圧縮の要約行より前の本文も扱う', () => {
534
+ const entries = [
535
+ { type: 'user', message: { role: 'user', content: '前の依頼' } },
536
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '前の回答' }] } },
537
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: '今の依頼' }] } },
538
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '途中です。' }] } },
539
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] } },
540
+ { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'hook error' }] } },
541
+ { type: 'attachment', attachment: { type: 'hook_stopped_continuation' } },
542
+ ];
543
+ const slice = sliceInFlightTurnEntries(entries);
544
+ assert.equal(slice.length, 5);
545
+ assert.equal(slice[0].message.content[0].text, '今の依頼');
546
+ assert.equal(sliceCurrentTurnEntries(entries).length, 2, '完了したターン用の切り出しは、最後の発言で終わる');
547
+ assert.deepEqual(extractDetailBlocks(slice).map((d) => d.source_id), ['t1', 't1:result', null]);
548
+
549
+ // 圧縮の要約行はターンの始まりにしない
550
+ const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: '要約…' } };
551
+ assert.equal(sliceInFlightTurnEntries([entries[2], entries[3], summary, entries[4]]).length, 4);
552
+ assert.deepEqual(sliceInFlightTurnEntries([entries[1]]), []);
553
+ });
554
+