throughline 0.3.24 → 0.4.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.
Files changed (116) hide show
  1. package/.claude/commands/tl.md +6 -21
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +632 -0
  4. package/README.ja.md +71 -46
  5. package/README.md +420 -76
  6. package/bin/throughline.mjs +169 -7
  7. package/codex/skills/throughline/SKILL.md +157 -0
  8. package/codex/skills/throughline/agents/openai.yaml +7 -0
  9. package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
  12. package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
  13. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  14. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  15. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  17. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  18. package/docs/archive/CONCEPT.md +476 -0
  19. package/docs/archive/EXPERIMENT.md +371 -0
  20. package/docs/archive/README.md +22 -0
  21. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  22. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  23. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  24. package/docs/throughline-handoff-context.example.json +57 -0
  25. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  26. package/package.json +6 -2
  27. package/src/baton.mjs +17 -45
  28. package/src/baton.test.mjs +4 -41
  29. package/src/cli/codex-capture.mjs +95 -0
  30. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  31. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  32. package/src/cli/codex-handoff-smoke.mjs +163 -0
  33. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  34. package/src/cli/codex-handoff-start.mjs +291 -0
  35. package/src/cli/codex-handoff-start.test.mjs +194 -0
  36. package/src/cli/codex-hook.mjs +276 -0
  37. package/src/cli/codex-hook.test.mjs +293 -0
  38. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  39. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  40. package/src/cli/codex-restore-smoke.mjs +357 -0
  41. package/src/cli/codex-restore-source-audit.mjs +304 -0
  42. package/src/cli/codex-resume.mjs +138 -0
  43. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  44. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  45. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  46. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  47. package/src/cli/codex-summarize.mjs +224 -0
  48. package/src/cli/codex-threads.mjs +89 -0
  49. package/src/cli/codex-visibility-smoke.mjs +196 -0
  50. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  51. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  52. package/src/cli/doctor.mjs +503 -1
  53. package/src/cli/doctor.test.mjs +542 -3
  54. package/src/cli/handoff-preview.mjs +78 -0
  55. package/src/cli/help.test.mjs +64 -0
  56. package/src/cli/install.mjs +226 -3
  57. package/src/cli/install.test.mjs +205 -4
  58. package/src/cli/trim.mjs +564 -0
  59. package/src/codex-app-server.mjs +1816 -0
  60. package/src/codex-app-server.test.mjs +512 -0
  61. package/src/codex-auto-refresh.mjs +194 -0
  62. package/src/codex-auto-refresh.test.mjs +182 -0
  63. package/src/codex-capture.mjs +235 -0
  64. package/src/codex-capture.test.mjs +393 -0
  65. package/src/codex-handoff-model-smoke.mjs +114 -0
  66. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  67. package/src/codex-handoff-smoke.mjs +124 -0
  68. package/src/codex-handoff-smoke.test.mjs +103 -0
  69. package/src/codex-handoff.mjs +331 -0
  70. package/src/codex-handoff.test.mjs +220 -0
  71. package/src/codex-host-primitive-audit.mjs +374 -0
  72. package/src/codex-host-primitive-audit.test.mjs +208 -0
  73. package/src/codex-restore-smoke.test.mjs +639 -0
  74. package/src/codex-restore-source-audit.mjs +1348 -0
  75. package/src/codex-restore-source-audit.test.mjs +623 -0
  76. package/src/codex-resume.test.mjs +242 -0
  77. package/src/codex-rollout-memory.mjs +711 -0
  78. package/src/codex-rollout-memory.test.mjs +610 -0
  79. package/src/codex-sidecar-cli.test.mjs +75 -0
  80. package/src/codex-sidecar.mjs +246 -0
  81. package/src/codex-sidecar.test.mjs +172 -0
  82. package/src/codex-summarize.test.mjs +143 -0
  83. package/src/codex-thread-identity.mjs +23 -0
  84. package/src/codex-thread-index.mjs +173 -0
  85. package/src/codex-thread-index.test.mjs +164 -0
  86. package/src/codex-usage.mjs +110 -0
  87. package/src/codex-usage.test.mjs +140 -0
  88. package/src/codex-visibility-smoke.test.mjs +222 -0
  89. package/src/codex-vscode-restore-smoke.mjs +206 -0
  90. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  91. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  92. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  93. package/src/db-schema.test.mjs +96 -0
  94. package/src/db.mjs +14 -1
  95. package/src/haiku-summarizer.mjs +267 -26
  96. package/src/haiku-summarizer.test.mjs +282 -0
  97. package/src/handoff-preview.test.mjs +108 -0
  98. package/src/handoff-record.mjs +294 -0
  99. package/src/handoff-record.test.mjs +226 -0
  100. package/src/hook-entrypoints.test.mjs +286 -0
  101. package/src/package-files.test.mjs +19 -0
  102. package/src/prompt-submit.mjs +9 -6
  103. package/src/resume-context.mjs +58 -171
  104. package/src/resume-context.test.mjs +177 -0
  105. package/src/session-start.mjs +85 -26
  106. package/src/state-file.mjs +50 -6
  107. package/src/state-file.test.mjs +50 -0
  108. package/src/token-monitor.mjs +14 -10
  109. package/src/token-monitor.test.mjs +27 -0
  110. package/src/trim-cli.test.mjs +1584 -0
  111. package/src/trim-model.mjs +584 -0
  112. package/src/trim-model.test.mjs +568 -0
  113. package/src/turn-processor.mjs +17 -10
  114. package/src/vscode-task.mjs +33 -10
  115. package/src/vscode-task.test.mjs +19 -9
  116. package/src/cli/save-inflight.mjs +0 -81
@@ -1,226 +1,113 @@
1
1
  /**
2
- * resume-context.mjs — 中断地点からの再開注入テキストを組み立てる共有モジュール
2
+ * resume-context.mjs — 引継ぎ注入テキストを組み立てる共有モジュール
3
3
  *
4
4
  * 呼び出し元:
5
- * - session-start.mjs (isInheritance=true, 引き継ぎヘッダ)
5
+ * - session-start.mjs (auto path / baton path どちらでも同じ注入)
6
6
  *
7
- * 設計(schema v7 対応):
8
- * - 注入順: ヘッダ [in-flight メモ][中断直前の思考] → L1 要約 → L2 本文 → フッタ
9
- * - in-flight メモ: /tl 発動時に現行 Claude が書いた「次の一手 / 方針 / 未解決 / TODO」
10
- * - 中断直前の思考: 最終ターンの assistant extended thinking (details kind='thinking')
7
+ * 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
8
+ * - 注入順: ヘッダ + Reading Contract → L1 要約 → L2 本文 → L3 refs 一覧 → Continuation Instruction
11
9
  * - 直近 N=20 ターンは bodies から L2 全文を注入
12
10
  * - それ以前は skeletons から L1 要約のみ注入
11
+ * - L3 references は具体的な `throughline detail <時刻>` コマンドの一覧として注入
12
+ * (本文は埋め込まず、参照のみ)
13
+ * - memo / thinking は注入しない (= L2 全文があれば最後の assistant turn 自体に
14
+ * 「次に何をしようとしていたか」が含まれる)
13
15
  * - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
14
- * - 末尾に /sc-detail <時刻> ガイドを追記
15
16
  * - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
16
- * - フレーミングを「過去の記憶」から「中断した作業の再開」に変更 (B 案)
17
+ * - フレーミング: 「過去の記憶」ではなく「現在進行中の作業」として読ませる
18
+ * (Codex 側 renderCodexRolloutMemoryPreview の写像)
17
19
  */
18
20
 
19
- const N_RECENT_L2 = 20;
21
+ import { buildHandoffRecord, formatTime, N_RECENT_L2 } from './handoff-record.mjs';
20
22
 
21
23
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
22
24
  `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
23
25
  `\n` +
24
26
  `**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
25
- `- 中断直前の in-flight メモ(前セッション末尾で Claude 自身が書いた「次の一手・方針・未解決・TODO」)\n` +
26
- `- 中断直前の思考 (最終ターンの extended thinking)\n` +
27
27
  `- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
28
28
  `- それ以前の要約 (L1)\n` +
29
+ `- L3 (ツール入出力・思考) の参照一覧 (本文は別途取り出す)\n` +
29
30
  `\n` +
30
31
  `応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
31
32
  `作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
32
33
 
33
- const NORMAL_HEADER = '## Throughline: セッション記憶';
34
-
35
- const FOOTER_GUIDE =
36
- '---\n' +
37
- '**[Claude 向け 記憶の使い方]**\n' +
38
- '上の L1 要約や L2 本文を読んで「具体的なコマンドやツール出力、ファイル内容を確認したい」と感じたら、' +
39
- '推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力・thinking)を取得してください。\n' +
40
- '- 単一時刻: `throughline detail 14:23:05`\n' +
41
- '- 時刻範囲: `throughline detail 14:23-14:30`\n' +
42
- '\n' +
43
- '返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
44
- 'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
45
-
46
- /**
47
- * Unix ms を HH:MM:SS 形式に変換する。
48
- */
49
- function formatTime(unixMs) {
50
- const d = new Date(unixMs);
51
- const hh = String(d.getHours()).padStart(2, '0');
52
- const mm = String(d.getMinutes()).padStart(2, '0');
53
- const ss = String(d.getSeconds()).padStart(2, '0');
54
- return `${hh}:${mm}:${ss}`;
55
- }
56
-
57
- /**
58
- * 最新ターン番号 (= 中断直前) の thinking ブロックを details から取り出す。
59
- * origin 除外がある場合はそれも考慮する。
60
- *
61
- * @param {import('node:sqlite').DatabaseSync} db
62
- * @param {string} sessionId
63
- * @param {string | null} excludeOriginId
64
- * @returns {Array<{ output_text: string, created_at: number }>}
65
- */
66
- function loadLatestThinking(db, sessionId, excludeOriginId) {
67
- const hasExclude = Boolean(excludeOriginId);
34
+ const ACTIVE_WORK_READING_CONTRACT =
35
+ `\n` +
36
+ `**読み方の契約:**\n` +
37
+ `- これは単なる過去ログではなく、現在進行中の作業を再開するための active work context です。\n` +
38
+ `- L2 は古い順に並んだ作業履歴です。後の発言・判断・TODO は前の仮説や作業方針を上書きし得ます。\n` +
39
+ `- すべての L2 行を現在も正しい事実として扱わず、最新の L2 を優先して現在状態を推定してください。\n` +
40
+ `- 不足している tool output / 詳細根拠が必要なときだけ、L3 references \`throughline detail <時刻>\` を使って取得してください。`;
68
41
 
69
- // 最新 (origin_session_id, turn_number) を bodies から特定
70
- const latestQuery = hasExclude
71
- ? `SELECT origin_session_id, turn_number, created_at
72
- FROM bodies
73
- WHERE session_id = ? AND origin_session_id != ? AND role = 'assistant'
74
- ORDER BY created_at DESC
75
- LIMIT 1`
76
- : `SELECT origin_session_id, turn_number, created_at
77
- FROM bodies
78
- WHERE session_id = ? AND role = 'assistant'
79
- ORDER BY created_at DESC
80
- LIMIT 1`;
42
+ const CONTINUATION_REMINDER =
43
+ '**再開指示:** 上記の L1 / L2 を、現在タスクに使う作業コンテキストとして扱ってください。' +
44
+ '最新の L2 から次の一手を決め、中断地点から続行してください。' +
45
+ '古い仮説や未完了 TODO は、後続の判断で上書きされ得ます。';
81
46
 
82
- let latest;
83
- try {
84
- latest = hasExclude
85
- ? db.prepare(latestQuery).get(sessionId, excludeOriginId)
86
- : db.prepare(latestQuery).get(sessionId);
87
- } catch {
88
- return [];
89
- }
90
- if (!latest) return [];
91
-
92
- // その (origin_session_id, turn_number) に紐づく kind='thinking' を取り出す
93
- try {
94
- const rows = db
95
- .prepare(
96
- `SELECT output_text, created_at FROM details
97
- WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND kind = 'thinking'
98
- ORDER BY created_at ASC`,
99
- )
100
- .all(sessionId, latest.origin_session_id, latest.turn_number);
101
- return rows.filter((r) => typeof r.output_text === 'string' && r.output_text.length > 0);
102
- } catch {
103
- return [];
104
- }
105
- }
47
+ const NORMAL_HEADER = '## Throughline: セッション記憶';
106
48
 
107
49
  /**
108
- * L1+L2 注入テキストを組み立てる。
50
+ * L1 + L2 + L3 references 注入テキストを組み立てる。
109
51
  *
110
52
  * @param {import('node:sqlite').DatabaseSync} db
111
53
  * @param {{
112
54
  * sessionId: string,
113
55
  * isInheritance: boolean,
114
56
  * excludeOriginId?: string | null,
115
- * inflightMemo?: string | null,
57
+ * inflightMemo?: string | null, // 互換のため受け取るが新仕様では使用しない
116
58
  * }} params
117
59
  * @returns {string | null}
118
60
  */
119
61
  export function buildResumeContext(
120
62
  db,
121
- { sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
63
+ { sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
122
64
  ) {
123
- if (!sessionId) return null;
124
-
125
- const hasExclude = Boolean(excludeOriginId);
126
-
127
- const bodiesQuery = hasExclude
128
- ? `SELECT origin_session_id, turn_number, role, text, created_at
129
- FROM bodies
130
- WHERE session_id = ? AND origin_session_id != ?
131
- ORDER BY created_at DESC
132
- LIMIT ?`
133
- : `SELECT origin_session_id, turn_number, role, text, created_at
134
- FROM bodies
135
- WHERE session_id = ?
136
- ORDER BY created_at DESC
137
- LIMIT ?`;
138
-
139
- const limitRows = N_RECENT_L2 * 2; // user/assistant の 2 ロール分
140
-
141
- let bodyRowsDesc = [];
142
- try {
143
- bodyRowsDesc = hasExclude
144
- ? db.prepare(bodiesQuery).all(sessionId, excludeOriginId, limitRows)
145
- : db.prepare(bodiesQuery).all(sessionId, limitRows);
146
- } catch {
147
- // bodies テーブル未作成(v3 DB)の場合は空
148
- bodyRowsDesc = [];
149
- }
150
- const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
151
-
152
- // 古い側の L1(bodies に既に含まれるターンを除いたもの)
153
- const bodySet = new Set(
154
- bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
155
- );
156
-
157
- const skelQuery = hasExclude
158
- ? `SELECT origin_session_id, turn_number, role, summary, created_at
159
- FROM skeletons
160
- WHERE session_id = ? AND origin_session_id != ?
161
- ORDER BY created_at ASC`
162
- : `SELECT origin_session_id, turn_number, role, summary, created_at
163
- FROM skeletons
164
- WHERE session_id = ?
165
- ORDER BY created_at ASC`;
166
-
167
- const allSkel = hasExclude
168
- ? db.prepare(skelQuery).all(sessionId, excludeOriginId)
169
- : db.prepare(skelQuery).all(sessionId);
170
-
171
- const l1Rows = allSkel.filter(
172
- (s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
173
- );
174
-
175
- const thinkingRows = loadLatestThinking(db, sessionId, excludeOriginId);
176
-
177
- if (
178
- bodyRows.length === 0 &&
179
- l1Rows.length === 0 &&
180
- thinkingRows.length === 0 &&
181
- !inflightMemo
182
- ) {
183
- return null;
184
- }
185
-
186
- const turnCount = bodyRows.length + l1Rows.length;
187
- const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
65
+ // handoff-record Codex 側でも使うので signature 維持。inflightMemo / latestThinking は
66
+ // 新仕様の注入テキストには使わない (L2 全文で十分という判断)。
67
+ const record = buildHandoffRecord(db, {
68
+ sessionId,
69
+ isInheritance,
70
+ excludeOriginId,
71
+ });
72
+ if (!record) return null;
73
+
74
+ const turnCount = record.stats.preservedContextRows;
75
+ const header = isInheritance
76
+ ? RESUME_HEADER_TEMPLATE(turnCount) + ACTIVE_WORK_READING_CONTRACT
77
+ : NORMAL_HEADER;
188
78
  const lines = [header];
189
79
 
190
- if (inflightMemo && inflightMemo.trim().length > 0) {
80
+ if (record.memory.l1Summaries.length > 0) {
191
81
  lines.push('');
192
- lines.push('### 中断直前の in-flight メモ(前セッションの Claude 自身による要約)');
193
- lines.push(inflightMemo.trim());
194
- }
195
-
196
- if (thinkingRows.length > 0) {
197
- lines.push('');
198
- lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
199
- for (const r of thinkingRows) {
200
- lines.push(`[${formatTime(r.created_at)}] ${r.output_text}`);
82
+ lines.push('### それ以前の要約 (L1)');
83
+ for (const r of record.memory.l1Summaries) {
84
+ if (!r.summary || r.summary === '(no content)') continue;
85
+ lines.push(`[${r.time}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
201
86
  }
202
87
  }
203
88
 
204
- if (l1Rows.length > 0) {
89
+ if (record.memory.recentBodies.length > 0) {
205
90
  lines.push('');
206
- lines.push('### それ以前の要約 (L1)');
207
- for (const r of l1Rows) {
208
- if (!r.summary || r.summary === '(no content)') continue;
209
- lines.push(`[${formatTime(r.created_at)}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
91
+ lines.push('### 現在進行中の作業履歴 (L2 / active work thread)');
92
+ lines.push('以下は古い順です。後の行ほど現在状態に近く、前の仮説を上書きし得ます。');
93
+ for (const r of record.memory.recentBodies) {
94
+ if (!r.text) continue;
95
+ lines.push(`[${r.time}] [${r.role}]: ${r.text}`);
210
96
  }
211
97
  }
212
98
 
213
- if (bodyRows.length > 0) {
99
+ if (record.references.l3.length > 0) {
214
100
  lines.push('');
215
- lines.push('### 直近のターン履歴 (L2)');
216
- for (const r of bodyRows) {
217
- if (!r.text) continue;
218
- lines.push(`[${formatTime(r.created_at)}] [${r.role}]: ${r.text}`);
101
+ lines.push('### L3 詳細参照 (本文は注入されていません)');
102
+ lines.push('必要なときだけ以下のコマンドで取得してください:');
103
+ for (const ref of record.references.l3) {
104
+ const time = formatTime(ref.createdAt);
105
+ lines.push(`- [${time}] ${ref.kind}: \`throughline detail ${time}\``);
219
106
  }
220
107
  }
221
108
 
222
109
  lines.push('');
223
- lines.push(FOOTER_GUIDE);
110
+ lines.push(CONTINUATION_REMINDER);
224
111
 
225
112
  return lines.join('\n');
226
113
  }
@@ -0,0 +1,177 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { buildResumeContext } from './resume-context.mjs';
5
+
6
+ function makeDb() {
7
+ const db = new DatabaseSync(':memory:');
8
+ db.exec(`
9
+ CREATE TABLE skeletons (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ session_id TEXT NOT NULL,
12
+ origin_session_id TEXT,
13
+ turn_number INTEGER NOT NULL,
14
+ role TEXT NOT NULL,
15
+ summary TEXT NOT NULL,
16
+ created_at INTEGER NOT NULL
17
+ );
18
+ CREATE TABLE bodies (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ session_id TEXT NOT NULL,
21
+ origin_session_id TEXT NOT NULL,
22
+ turn_number INTEGER NOT NULL,
23
+ role TEXT NOT NULL,
24
+ text TEXT NOT NULL,
25
+ token_count INTEGER,
26
+ created_at INTEGER NOT NULL
27
+ );
28
+ CREATE TABLE details (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ session_id TEXT NOT NULL,
31
+ origin_session_id TEXT,
32
+ turn_number INTEGER,
33
+ tool_name TEXT NOT NULL,
34
+ input_text TEXT,
35
+ output_text TEXT,
36
+ token_count INTEGER NOT NULL DEFAULT 0,
37
+ created_at INTEGER NOT NULL,
38
+ kind TEXT,
39
+ source_id TEXT
40
+ );
41
+ `);
42
+ return db;
43
+ }
44
+
45
+ function insertSkeleton(db, row) {
46
+ db.prepare(
47
+ `INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
48
+ VALUES (?, ?, ?, ?, ?, ?)`,
49
+ ).run(row.session, row.origin, row.turn, row.role, row.summary, row.createdAt);
50
+ }
51
+
52
+ function insertBody(db, row) {
53
+ db.prepare(
54
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
55
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
56
+ ).run(row.session, row.origin, row.turn, row.role, row.text, 1, row.createdAt);
57
+ }
58
+
59
+ function insertThinking(db, row) {
60
+ db.prepare(
61
+ `INSERT INTO details
62
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
63
+ token_count, created_at, kind, source_id)
64
+ VALUES (?, ?, ?, 'thinking', NULL, ?, 1, ?, 'thinking', ?)`,
65
+ ).run(row.session, row.origin, row.turn, row.text, row.createdAt, row.sourceId);
66
+ }
67
+
68
+ test('buildResumeContext: inheritance output order is L1 -> L2 -> L3 refs -> reminder (no memo / no thinking)', () => {
69
+ const db = makeDb();
70
+
71
+ insertSkeleton(db, {
72
+ session: 'new',
73
+ origin: 'old',
74
+ turn: 1,
75
+ role: 'assistant',
76
+ summary: 'older L1 summary',
77
+ createdAt: 1000,
78
+ });
79
+ insertBody(db, {
80
+ session: 'new',
81
+ origin: 'old',
82
+ turn: 2,
83
+ role: 'user',
84
+ text: 'recent user body',
85
+ createdAt: 2000,
86
+ });
87
+ insertBody(db, {
88
+ session: 'new',
89
+ origin: 'old',
90
+ turn: 2,
91
+ role: 'assistant',
92
+ text: 'recent assistant body',
93
+ createdAt: 2100,
94
+ });
95
+ // thinking は DB に書かれても、新仕様では注入テキストに出ない
96
+ insertThinking(db, {
97
+ session: 'new',
98
+ origin: 'old',
99
+ turn: 2,
100
+ text: 'latest thinking block',
101
+ createdAt: 2200,
102
+ sourceId: 'asst:thinking:0',
103
+ });
104
+
105
+ const text = buildResumeContext(db, {
106
+ sessionId: 'new',
107
+ isInheritance: true,
108
+ // inflightMemo は互換のため受け取れるが、新仕様では注入テキストに使わない
109
+ inflightMemo: '**Next**: keep going (should NOT appear)',
110
+ });
111
+
112
+ assert.ok(text);
113
+ assert.match(text, /^## Throughline: 中断した作業の再開/);
114
+
115
+ // 新仕様: memo / thinking セクションは注入されない
116
+ assert.ok(text.indexOf('### 中断直前の in-flight メモ') < 0, 'memo section should not be injected');
117
+ assert.ok(text.indexOf('### 中断直前の思考') < 0, 'thinking section should not be injected');
118
+ assert.ok(!text.includes('**Next**: keep going'), 'inflightMemo content should be ignored');
119
+ assert.ok(!text.includes('latest thinking block'), 'thinking text should not appear');
120
+
121
+ // 注入される順序: L1 → L2 → L3 refs → 再開指示
122
+ const l1Idx = text.indexOf('### それ以前の要約 (L1)');
123
+ const l2Idx = text.indexOf('### 現在進行中の作業履歴 (L2 / active work thread)');
124
+ const l3Idx = text.indexOf('### L3 詳細参照');
125
+ const reminderIdx = text.indexOf('**再開指示:**');
126
+
127
+ assert.ok(l1Idx > 0, 'L1 section should be present');
128
+ assert.ok(l2Idx > l1Idx, 'L2 should follow L1');
129
+ assert.ok(l3Idx > l2Idx, 'L3 references should follow L2');
130
+ assert.ok(reminderIdx > l3Idx, 'continuation reminder should follow L3 refs');
131
+
132
+ assert.ok(text.includes('older L1 summary'));
133
+ assert.ok(text.includes('[user]: recent user body'));
134
+ assert.ok(text.includes('[assistant]: recent assistant body'));
135
+ // L3 refs は detail コマンドのみ (本文は注入しない)
136
+ assert.match(text, /throughline detail \d{2}:\d{2}:\d{2}/);
137
+ assert.match(text, /単なる過去ログではなく、現在進行中の作業/);
138
+ });
139
+
140
+ test('buildResumeContext: returns null when no memory rows or inflight memo exist', () => {
141
+ const db = makeDb();
142
+ assert.equal(
143
+ buildResumeContext(db, { sessionId: 'empty', isInheritance: true }),
144
+ null,
145
+ );
146
+ });
147
+
148
+ test('buildResumeContext: excludeOriginId omits rows from the current origin', () => {
149
+ const db = makeDb();
150
+
151
+ insertBody(db, {
152
+ session: 'new',
153
+ origin: 'old',
154
+ turn: 1,
155
+ role: 'assistant',
156
+ text: 'old origin body',
157
+ createdAt: 1000,
158
+ });
159
+ insertBody(db, {
160
+ session: 'new',
161
+ origin: 'new',
162
+ turn: 1,
163
+ role: 'assistant',
164
+ text: 'current origin body',
165
+ createdAt: 2000,
166
+ });
167
+
168
+ const text = buildResumeContext(db, {
169
+ sessionId: 'new',
170
+ isInheritance: false,
171
+ excludeOriginId: 'new',
172
+ });
173
+
174
+ assert.ok(text);
175
+ assert.ok(text.includes('old origin body'));
176
+ assert.ok(!text.includes('current origin body'));
177
+ });
@@ -1,20 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * SessionStart hook — セッション登録 + バトン消費 + 引き継ぎ注入
3
+ * SessionStart hook — セッション登録 + 引き継ぎ判定 + 注入
4
4
  *
5
5
  * stdin: { session_id, source, cwd, transcript_path, hook_event_name }
6
6
  *
7
- * 【引き継ぎ条件 (バトン方式)】
8
- * ユーザーが旧セッションで /tl スラッシュコマンドを打つと UserPromptSubmit hook が
9
- * baton テーブルに session_id を書き込む。本 SessionStart hook はそれを TTL 1 時間以内
10
- * なら消費して merge + 引き継ぎヘッダ付き L1+L2 stdout 注入する。
11
- * バトンが無ければ / 期限切れなら何も引き継がない(docs/INHERITANCE_ON_CLEAR_ONLY.md 参照)。
7
+ * 【引き継ぎ条件 (2 経路)】 docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md
8
+ *
9
+ * 1. baton path: ユーザーが旧セッションで `/tl` を打つと UserPromptSubmit hook
10
+ * handoff_batons session_id を書く。本 hook TTL 1 時間以内に消費して
11
+ * 前任を merge + 引継ぎ stdout 注入。`source` 値関係なく発火。
12
+ * 2. auto path: `source='clear'` かつ env `THROUGHLINE_DISABLE_AUTO_HANDOFF` が
13
+ * `'1'` でない場合、同 project_path の最新 Claude unmerged session を
14
+ * 自動 merge して注入。
15
+ *
16
+ * 両方同時成立はしない (consumeBaton が先発、baton ありなら baton path、
17
+ * なければ source 判定)。env で OFF にしたユーザーは `/tl` を打ってから
18
+ * 新セッションスタートで baton path を使う。
12
19
  *
13
20
  * 役割:
14
21
  * 1. sessions テーブルに新セッションを INSERT OR IGNORE
15
- * 2. バトン消費 + 指名された前任を merge (session-merger.mjs)
16
- * 3. 合流成立なら L1+L2 を「引き継ぎヘッダ」付きで stdout 注入
17
- * 4. 判定結果を ~/.throughline/logs/inheritance-decision.log に記録
22
+ * 2. baton path 判定 (consumeBaton + mergeSpecificPredecessor)
23
+ * 3. baton 無し かつ source='clear' かつ env disable 無し → auto path 判定
24
+ * 4. 合流成立なら curated memory (L1+L2+L3 refs) を「引き継ぎヘッダ」付きで stdout 注入
25
+ * 5. 判定結果を ~/.throughline/logs/inheritance-decision.log に記録
18
26
  */
19
27
 
20
28
  import { getDb } from './db.mjs';
@@ -25,6 +33,38 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
25
33
  import { appendFileSync, mkdirSync } from 'node:fs';
26
34
  import { join, dirname } from 'node:path';
27
35
  import { homedir } from 'node:os';
36
+ import { pathToFileURL } from 'node:url';
37
+
38
+ const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
39
+
40
+ function isAutoHandoffDisabled(env) {
41
+ return env[ENV_DISABLE_AUTO_HANDOFF] === '1';
42
+ }
43
+
44
+ /**
45
+ * 同 project_path の最新 Claude unmerged session を返す (auto path 用 predecessor)。
46
+ * Codex session (`codex:*`) と現セッション自身は除外。
47
+ *
48
+ * @param {import('node:sqlite').DatabaseSync} db
49
+ * @param {string} projectPath
50
+ * @param {string} currentSessionId
51
+ * @returns {{ session_id: string } | null}
52
+ */
53
+ function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
54
+ return (
55
+ db
56
+ .prepare(
57
+ `SELECT session_id FROM sessions
58
+ WHERE lower(project_path) = lower(?)
59
+ AND merged_into IS NULL
60
+ AND session_id != ?
61
+ AND session_id NOT LIKE 'codex:%'
62
+ ORDER BY updated_at DESC
63
+ LIMIT 1`,
64
+ )
65
+ .get(projectPath, currentSessionId) ?? null
66
+ );
67
+ }
28
68
 
29
69
  function logDecision(entry) {
30
70
  const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
@@ -37,7 +77,7 @@ function logDecision(entry) {
37
77
  }
38
78
  }
39
79
 
40
- async function main() {
80
+ export async function run() {
41
81
  let raw = '';
42
82
  await new Promise((resolve) => {
43
83
  process.stdin.setEncoding('utf8');
@@ -56,11 +96,7 @@ async function main() {
56
96
  const db = getDb();
57
97
  const now = Date.now();
58
98
 
59
- // 0. VSCode で開かれた新規プロジェクトに .vscode/tasks.json を自動プロビジョニング。
60
- // Stop hook 側にも同じ呼び出しがあるが、Stop が発火しない環境(応答中断・IDE 挙動差)
61
- // でも SessionStart は必ず走るので、新規プロジェクトでの自動起動を確実化する保険。
62
- // 冪等性は ensureMonitorTaskFile 側で保証されており、Stop/UserPromptSubmit と重複呼び
63
- // 出しされても安全。
99
+ // 0. VSCode tasks.json 自動プロビジョニング (冪等)
64
100
  try {
65
101
  ensureMonitorTaskFile({ cwd: projectPath, env: process.env });
66
102
  } catch (err) {
@@ -74,18 +110,40 @@ async function main() {
74
110
  VALUES (?, ?, 'active', ?, ?)`,
75
111
  ).run(session_id, projectPath, now, now);
76
112
 
77
- // 2. バトン消費
113
+ // 2. baton 消費
78
114
  const baton = consumeBaton(db, { projectPath, now });
79
115
 
80
- let mergeResult = { merged: false, skipReason: 'no_baton' };
116
+ // 3. 引継ぎ判定
117
+ let mergeResult = { merged: false, skipReason: 'no_trigger' };
118
+ let triggeredPath = null;
119
+ const autoDisabled = isAutoHandoffDisabled(process.env);
120
+
81
121
  if (baton.sessionId) {
82
- // バトンが指す session が既に他と merge 済みなら、その合流先末端を前任とする
122
+ // baton path
123
+ triggeredPath = 'baton';
83
124
  const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
84
125
  mergeResult = mergeSpecificPredecessor(db, {
85
126
  newSessionId: session_id,
86
127
  predecessorId,
87
128
  now,
88
129
  });
130
+ } else if (source === 'clear' && !autoDisabled) {
131
+ // auto path: 同 project の最新 Claude unmerged session を自動 predecessor にする
132
+ triggeredPath = 'auto';
133
+ const predRow = findLatestClaudePredecessor(db, projectPath, session_id);
134
+ if (predRow?.session_id) {
135
+ const { target: predecessorId } = resolveMergeTarget(db, predRow.session_id);
136
+ mergeResult = mergeSpecificPredecessor(db, {
137
+ newSessionId: session_id,
138
+ predecessorId,
139
+ now,
140
+ });
141
+ } else {
142
+ mergeResult = { merged: false, skipReason: 'no_predecessor' };
143
+ }
144
+ } else if (source === 'clear' && autoDisabled) {
145
+ triggeredPath = 'auto-disabled';
146
+ mergeResult = { merged: false, skipReason: 'auto_handoff_disabled' };
89
147
  }
90
148
 
91
149
  logDecision({
@@ -93,22 +151,21 @@ async function main() {
93
151
  source: source ?? null,
94
152
  session_id,
95
153
  project_path: projectPath,
154
+ triggered_path: triggeredPath,
155
+ auto_handoff_disabled: autoDisabled,
96
156
  baton_session_id: baton.sessionId ?? null,
97
157
  baton_age_ms: baton.ageMs ?? null,
98
158
  baton_skip_reason: baton.skipReason ?? null,
99
- baton_has_memo: Boolean(baton.memoText),
100
159
  merged: mergeResult.merged,
101
160
  merge_skip_reason: mergeResult.skipReason ?? null,
102
161
  predecessor_id: mergeResult.predecessorId ?? null,
103
162
  });
104
163
 
105
- // 3. 合流成立なら引き継ぎヘッダ付きで注入
106
- // バトンに付いていた in-flight メモも併せて先頭セクションに注入する
164
+ // 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
107
165
  if (mergeResult.merged) {
108
166
  const text = buildResumeContext(db, {
109
167
  sessionId: session_id,
110
168
  isInheritance: true,
111
- inflightMemo: baton.memoText ?? null,
112
169
  });
113
170
  if (text) {
114
171
  process.stdout.write(text + '\n');
@@ -118,7 +175,9 @@ async function main() {
118
175
  process.exit(0);
119
176
  }
120
177
 
121
- main().catch((err) => {
122
- process.stderr.write(`[session-start] error: ${err.message}\n`);
123
- process.exit(1);
124
- });
178
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
179
+ run().catch((err) => {
180
+ process.stderr.write(`[session-start] error: ${err.message}\n`);
181
+ process.exit(1);
182
+ });
183
+ }