throughline 0.3.25 → 0.4.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.
@@ -98,33 +98,54 @@ test('prompt-submit subprocess writes a /tl baton into an isolated DB', () => {
98
98
  }
99
99
  });
100
100
 
101
- test('save-inflight subprocess stores memo on the current project baton', () => {
101
+ test('prompt-submit subprocess writes a /clear baton (specific session marker)', () => {
102
102
  const home = makeTempHome();
103
103
  const project = makeTempProject();
104
104
  try {
105
- const baton = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
105
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
106
106
  home,
107
107
  cwd: project,
108
108
  input: JSON.stringify({
109
- session_id: 'old-session',
109
+ session_id: 'cleared-session',
110
110
  cwd: project,
111
- prompt: '/tl',
111
+ prompt: '/clear',
112
112
  }),
113
113
  });
114
- assert.equal(baton.status, 0, baton.stderr);
115
114
 
116
- const memo = 'Next: keep the handoff precise';
117
- const saved = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), 'save-inflight'], {
115
+ assert.equal(result.status, 0, result.stderr);
116
+
117
+ const db = openDb(home);
118
+ const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
119
+ assert.equal(row.project_path, project);
120
+ assert.equal(row.session_id, 'cleared-session');
121
+ db.close();
122
+ } finally {
123
+ rmSync(project, { recursive: true, force: true });
124
+ rmSync(home, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test('prompt-submit: /clear baton overwrites previous /tl baton in same project', () => {
129
+ const home = makeTempHome();
130
+ const project = makeTempProject();
131
+ try {
132
+ const tl = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
118
133
  home,
119
134
  cwd: project,
120
- input: memo,
135
+ input: JSON.stringify({ session_id: 'session-A', cwd: project, prompt: '/tl' }),
121
136
  });
122
- assert.equal(saved.status, 0, saved.stderr);
123
- assert.match(saved.stdout, /in-flight memo saved/);
137
+ assert.equal(tl.status, 0, tl.stderr);
138
+
139
+ const clear = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
140
+ home,
141
+ cwd: project,
142
+ input: JSON.stringify({ session_id: 'session-B', cwd: project, prompt: '/clear' }),
143
+ });
144
+ assert.equal(clear.status, 0, clear.stderr);
124
145
 
125
146
  const db = openDb(home);
126
- const row = db.prepare('SELECT memo_text FROM handoff_batons').get();
127
- assert.equal(row.memo_text, memo);
147
+ const row = db.prepare('SELECT session_id FROM handoff_batons').get();
148
+ assert.equal(row.session_id, 'session-B', 'most recent baton write wins');
128
149
  db.close();
129
150
  } finally {
130
151
  rmSync(project, { recursive: true, force: true });
@@ -132,6 +153,34 @@ test('save-inflight subprocess stores memo on the current project baton', () =>
132
153
  }
133
154
  });
134
155
 
156
+ test('prompt-submit: non-baton prompt does not write any baton', () => {
157
+ const home = makeTempHome();
158
+ const project = makeTempProject();
159
+ try {
160
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
161
+ home,
162
+ cwd: project,
163
+ input: JSON.stringify({
164
+ session_id: 'session-X',
165
+ cwd: project,
166
+ prompt: 'hello world',
167
+ }),
168
+ });
169
+
170
+ assert.equal(result.status, 0, result.stderr);
171
+
172
+ if (existsSync(join(home, '.throughline', 'throughline.db'))) {
173
+ const db = openDb(home);
174
+ const row = db.prepare('SELECT COUNT(*) AS n FROM handoff_batons').get();
175
+ assert.equal(row.n, 0, 'a normal prompt must not create any baton');
176
+ db.close();
177
+ }
178
+ } finally {
179
+ rmSync(project, { recursive: true, force: true });
180
+ rmSync(home, { recursive: true, force: true });
181
+ }
182
+ });
183
+
135
184
  test('session-start subprocess consumes baton and injects inherited resume context', () => {
136
185
  const home = makeTempHome();
137
186
  const project = makeTempProject();
@@ -157,11 +206,6 @@ test('session-start subprocess consumes baton and injects inherited resume conte
157
206
  (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
158
207
  VALUES ('old-session', 'old-session', 1, 'assistant', 'old assistant body', 4, 2)`,
159
208
  ).run();
160
- db.prepare(
161
- `UPDATE handoff_batons
162
- SET memo_text = 'handoff memo'
163
- WHERE project_path = ?`,
164
- ).run(project);
165
209
  db.close();
166
210
 
167
211
  const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
@@ -175,7 +219,6 @@ test('session-start subprocess consumes baton and injects inherited resume conte
175
219
  });
176
220
 
177
221
  assert.equal(started.status, 0, started.stderr);
178
- assert.match(started.stdout, /handoff memo/);
179
222
  assert.match(started.stdout, /old assistant body/);
180
223
 
181
224
  const after = openDb(home);
@@ -1,11 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * UserPromptSubmit hook — /tl スラッシュコマンド検出 + バトン書き込み
3
+ * UserPromptSubmit hook — /tl & /clear スラッシュコマンド検出 + バトン書き込み
4
4
  *
5
5
  * stdin: { session_id, cwd, prompt, hook_event_name, ... }
6
6
  *
7
7
  * 動作:
8
8
  * - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
9
+ * - prompt が /clear (単独 or /clear ... 形式) で始まっていれば、現セッションの
10
+ * session_id をバトンに書き込んで終了。
11
+ * (これにより SessionStart 側の findLatestClaudePredecessor heuristic に頼らず、
12
+ * 確定的に「/clear が打たれたセッション」を新セッションに引き継げる。複数
13
+ * VSCode ウィンドウ等で「最新更新セッション = clear されたセッション」が
14
+ * 成立しない multi-window シナリオで誤った前任を選ばないための確定的指名)
9
15
  * - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
10
16
  * - 本 hook は注入を一切行わない (SessionStart の引き継ぎ注入と二重にならないため)
11
17
  *
@@ -43,6 +49,18 @@ export function isBatonCommand(prompt) {
43
49
  return false;
44
50
  }
45
51
 
52
+ /**
53
+ * プロンプトが /clear バトン発動コマンドか判定する。
54
+ * 許容: "/clear", "/clear\n", "/clear 何か" (前後空白は trim 済み前提)
55
+ */
56
+ export function isClearCommand(prompt) {
57
+ if (typeof prompt !== 'string') return false;
58
+ const trimmed = prompt.trim();
59
+ if (trimmed === '/clear') return true;
60
+ if (trimmed.startsWith('/clear ') || trimmed.startsWith('/clear\n')) return true;
61
+ return false;
62
+ }
63
+
46
64
  export async function run() {
47
65
  let raw = '';
48
66
  await new Promise((resolve) => {
@@ -66,7 +84,10 @@ export async function run() {
66
84
  process.stderr.write(`[vscode-task] ${msg}\n`);
67
85
  }
68
86
 
69
- if (!isBatonCommand(prompt)) {
87
+ const tlMatch = isBatonCommand(prompt);
88
+ const clearMatch = !tlMatch && isClearCommand(prompt);
89
+
90
+ if (!tlMatch && !clearMatch) {
70
91
  process.exit(0);
71
92
  return;
72
93
  }
@@ -87,6 +108,7 @@ export async function run() {
87
108
  ts: new Date(now).toISOString(),
88
109
  session_id,
89
110
  project_path: projectPath,
111
+ trigger: tlMatch ? 'tl' : 'clear',
90
112
  });
91
113
 
92
114
  process.exit(0);
@@ -0,0 +1,66 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { isBatonCommand, isClearCommand } from './prompt-submit.mjs';
4
+
5
+ test('isBatonCommand: bare /tl', () => {
6
+ assert.equal(isBatonCommand('/tl'), true);
7
+ });
8
+
9
+ test('isBatonCommand: /tl with trailing newline', () => {
10
+ assert.equal(isBatonCommand('/tl\n'), true);
11
+ });
12
+
13
+ test('isBatonCommand: /tl with leading/trailing whitespace', () => {
14
+ assert.equal(isBatonCommand(' /tl '), true);
15
+ });
16
+
17
+ test('isBatonCommand: /tl with arguments', () => {
18
+ assert.equal(isBatonCommand('/tl some memo'), true);
19
+ });
20
+
21
+ test('isBatonCommand: rejects /tl-prefixed identifier', () => {
22
+ assert.equal(isBatonCommand('/tldr summary'), false);
23
+ });
24
+
25
+ test('isBatonCommand: rejects /clear', () => {
26
+ assert.equal(isBatonCommand('/clear'), false);
27
+ });
28
+
29
+ test('isBatonCommand: rejects empty / non-string', () => {
30
+ assert.equal(isBatonCommand(''), false);
31
+ assert.equal(isBatonCommand(null), false);
32
+ assert.equal(isBatonCommand(undefined), false);
33
+ assert.equal(isBatonCommand(42), false);
34
+ });
35
+
36
+ test('isClearCommand: bare /clear', () => {
37
+ assert.equal(isClearCommand('/clear'), true);
38
+ });
39
+
40
+ test('isClearCommand: /clear with trailing newline', () => {
41
+ assert.equal(isClearCommand('/clear\n'), true);
42
+ });
43
+
44
+ test('isClearCommand: /clear with leading/trailing whitespace', () => {
45
+ assert.equal(isClearCommand(' /clear '), true);
46
+ });
47
+
48
+ test('isClearCommand: /clear with arguments', () => {
49
+ assert.equal(isClearCommand('/clear something'), true);
50
+ });
51
+
52
+ test('isClearCommand: rejects /clear-prefixed identifier', () => {
53
+ assert.equal(isClearCommand('/cleared'), false);
54
+ assert.equal(isClearCommand('/clearcache'), false);
55
+ });
56
+
57
+ test('isClearCommand: rejects /tl', () => {
58
+ assert.equal(isClearCommand('/tl'), false);
59
+ });
60
+
61
+ test('isClearCommand: rejects empty / non-string', () => {
62
+ assert.equal(isClearCommand(''), false);
63
+ assert.equal(isClearCommand(null), false);
64
+ assert.equal(isClearCommand(undefined), false);
65
+ assert.equal(isClearCommand(42), false);
66
+ });
@@ -1,33 +1,32 @@
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
- * - フレーミングを「過去の記憶」から「中断した作業の再開」に変更する。
17
- * 冒頭と末尾の両方に current-work instruction を置き、長文 context 内でも
18
- * L1/L2 を現在タスク用の作業文脈として読むよう誘導する。
17
+ * - フレーミング: 「過去の記憶」ではなく「現在進行中の作業」として読ませる
18
+ * (Codex renderCodexRolloutMemoryPreview の写像)
19
19
  */
20
20
 
21
- import { buildHandoffRecord, N_RECENT_L2 } from './handoff-record.mjs';
21
+ import { buildHandoffRecord, formatTime, N_RECENT_L2 } from './handoff-record.mjs';
22
22
 
23
23
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
24
24
  `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
25
25
  `\n` +
26
26
  `**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
27
- `- 中断直前の in-flight メモ(前セッション末尾で Claude 自身が書いた「次の一手・方針・未解決・TODO」)\n` +
28
- `- 中断直前の思考 (最終ターンの extended thinking)\n` +
29
27
  `- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
30
28
  `- それ以前の要約 (L1)\n` +
29
+ `- L3 (ツール入出力・思考) の参照一覧 (本文は別途取り出す)\n` +
31
30
  `\n` +
32
31
  `応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
33
32
  `作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
@@ -37,48 +36,38 @@ const ACTIVE_WORK_READING_CONTRACT =
37
36
  `**読み方の契約:**\n` +
38
37
  `- これは単なる過去ログではなく、現在進行中の作業を再開するための active work context です。\n` +
39
38
  `- L2 は古い順に並んだ作業履歴です。後の発言・判断・TODO は前の仮説や作業方針を上書きし得ます。\n` +
40
- `- すべての L2 行を現在も正しい事実として扱わず、最新の L2、in-flight メモ、最終ターン thinking を優先して現在状態を推定してください。\n` +
41
- `- 不足している tool output / 詳細根拠が必要なときだけ、末尾の \`throughline detail <時刻>\` を使って L3 を取得してください。`;
39
+ `- すべての L2 行を現在も正しい事実として扱わず、最新の L2 を優先して現在状態を推定してください。\n` +
40
+ `- 不足している tool output / 詳細根拠が必要なときだけ、L3 references の \`throughline detail <時刻>\` を使って取得してください。`;
42
41
 
43
42
  const CONTINUATION_REMINDER =
44
- '**再開指示:** 上記の L1 / L2 / thinking / in-flight メモを、現在タスクに使う作業コンテキストとして扱ってください。' +
45
- '最新の L2、in-flight メモ、最終ターン thinking から次の一手を決め、中断地点から続行してください。' +
43
+ '**再開指示:** 上記の L1 / L2 を、現在タスクに使う作業コンテキストとして扱ってください。' +
44
+ '最新の L2 から次の一手を決め、中断地点から続行してください。' +
46
45
  '古い仮説や未完了 TODO は、後続の判断で上書きされ得ます。';
47
46
 
48
47
  const NORMAL_HEADER = '## Throughline: セッション記憶';
49
48
 
50
- const FOOTER_GUIDE =
51
- '---\n' +
52
- '**[Claude 向け — 記憶の使い方]**\n' +
53
- '上の L1 要約や L2 本文を読んで「具体的なコマンドやツール出力、ファイル内容を確認したい」と感じたら、' +
54
- '推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力・thinking)を取得してください。\n' +
55
- '- 単一時刻: `throughline detail 14:23:05`\n' +
56
- '- 時刻範囲: `throughline detail 14:23-14:30`\n' +
57
- '\n' +
58
- '返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
59
- 'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
60
-
61
49
  /**
62
- * L1+L2 注入テキストを組み立てる。
50
+ * L1 + L2 + L3 references 注入テキストを組み立てる。
63
51
  *
64
52
  * @param {import('node:sqlite').DatabaseSync} db
65
53
  * @param {{
66
54
  * sessionId: string,
67
55
  * isInheritance: boolean,
68
56
  * excludeOriginId?: string | null,
69
- * inflightMemo?: string | null,
57
+ * inflightMemo?: string | null, // 互換のため受け取るが新仕様では使用しない
70
58
  * }} params
71
59
  * @returns {string | null}
72
60
  */
73
61
  export function buildResumeContext(
74
62
  db,
75
- { sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
63
+ { sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
76
64
  ) {
65
+ // handoff-record は Codex 側でも使うので signature 維持。inflightMemo / latestThinking は
66
+ // 新仕様の注入テキストには使わない (L2 全文で十分という判断)。
77
67
  const record = buildHandoffRecord(db, {
78
68
  sessionId,
79
69
  isInheritance,
80
70
  excludeOriginId,
81
- inflightMemo,
82
71
  });
83
72
  if (!record) return null;
84
73
 
@@ -88,20 +77,6 @@ export function buildResumeContext(
88
77
  : NORMAL_HEADER;
89
78
  const lines = [header];
90
79
 
91
- if (record.memory.inflightMemo) {
92
- lines.push('');
93
- lines.push('### 中断直前の in-flight メモ(前セッションの Claude 自身による要約)');
94
- lines.push(record.memory.inflightMemo);
95
- }
96
-
97
- if (record.memory.latestThinking.length > 0) {
98
- lines.push('');
99
- lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
100
- for (const r of record.memory.latestThinking) {
101
- lines.push(`[${r.time}] ${r.text}`);
102
- }
103
- }
104
-
105
80
  if (record.memory.l1Summaries.length > 0) {
106
81
  lines.push('');
107
82
  lines.push('### それ以前の要約 (L1)');
@@ -121,8 +96,16 @@ export function buildResumeContext(
121
96
  }
122
97
  }
123
98
 
124
- lines.push('');
125
- lines.push(FOOTER_GUIDE);
99
+ if (record.references.l3.length > 0) {
100
+ lines.push('');
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}\``);
106
+ }
107
+ }
108
+
126
109
  lines.push('');
127
110
  lines.push(CONTINUATION_REMINDER);
128
111
 
@@ -65,7 +65,7 @@ function insertThinking(db, row) {
65
65
  ).run(row.session, row.origin, row.turn, row.text, row.createdAt, row.sourceId);
66
66
  }
67
67
 
68
- test('buildResumeContext: inheritance output order is memo -> thinking -> L1 -> L2 -> footer', () => {
68
+ test('buildResumeContext: inheritance output order is L1 -> L2 -> L3 refs -> reminder (no memo / no thinking)', () => {
69
69
  const db = makeDb();
70
70
 
71
71
  insertSkeleton(db, {
@@ -92,6 +92,7 @@ test('buildResumeContext: inheritance output order is memo -> thinking -> L1 ->
92
92
  text: 'recent assistant body',
93
93
  createdAt: 2100,
94
94
  });
95
+ // thinking は DB に書かれても、新仕様では注入テキストに出ない
95
96
  insertThinking(db, {
96
97
  session: 'new',
97
98
  origin: 'old',
@@ -104,32 +105,36 @@ test('buildResumeContext: inheritance output order is memo -> thinking -> L1 ->
104
105
  const text = buildResumeContext(db, {
105
106
  sessionId: 'new',
106
107
  isInheritance: true,
107
- inflightMemo: '**Next**: keep going',
108
+ // inflightMemo は互換のため受け取れるが、新仕様では注入テキストに使わない
109
+ inflightMemo: '**Next**: keep going (should NOT appear)',
108
110
  });
109
111
 
110
112
  assert.ok(text);
111
113
  assert.match(text, /^## Throughline: 中断した作業の再開/);
112
114
 
113
- const memoIdx = text.indexOf('### 中断直前の in-flight メモ');
114
- const thinkingIdx = text.indexOf('### 中断直前の思考');
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 → 再開指示
115
122
  const l1Idx = text.indexOf('### それ以前の要約 (L1)');
116
123
  const l2Idx = text.indexOf('### 現在進行中の作業履歴 (L2 / active work thread)');
117
- const footerIdx = text.indexOf('**[Claude 向け — 記憶の使い方]**');
124
+ const l3Idx = text.indexOf('### L3 詳細参照');
125
+ const reminderIdx = text.indexOf('**再開指示:**');
118
126
 
119
- assert.ok(memoIdx > 0, 'in-flight memo section should be present');
120
- assert.ok(thinkingIdx > memoIdx, 'thinking should follow in-flight memo');
121
- assert.ok(l1Idx > thinkingIdx, 'L1 should follow thinking');
127
+ assert.ok(l1Idx > 0, 'L1 section should be present');
122
128
  assert.ok(l2Idx > l1Idx, 'L2 should follow L1');
123
- assert.ok(footerIdx > l2Idx, 'footer should follow L2');
129
+ assert.ok(l3Idx > l2Idx, 'L3 references should follow L2');
130
+ assert.ok(reminderIdx > l3Idx, 'continuation reminder should follow L3 refs');
124
131
 
125
- assert.ok(text.includes('**Next**: keep going'));
126
- assert.ok(text.includes('latest thinking block'));
127
132
  assert.ok(text.includes('older L1 summary'));
128
133
  assert.ok(text.includes('[user]: recent user body'));
129
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}/);
130
137
  assert.match(text, /単なる過去ログではなく、現在進行中の作業/);
131
- assert.match(text, /後の行ほど現在状態に近く/);
132
- assert.match(text, /上記の L1 \/ L2 \/ thinking \/ in-flight メモを、現在タスクに使う作業コンテキスト/);
133
138
  });
134
139
 
135
140
  test('buildResumeContext: returns null when no memory rows or inflight memo exist', () => {
@@ -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';
@@ -27,6 +35,37 @@ import { join, dirname } from 'node:path';
27
35
  import { homedir } from 'node:os';
28
36
  import { pathToFileURL } from 'node:url';
29
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
+ }
68
+
30
69
  function logDecision(entry) {
31
70
  const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
32
71
  try {
@@ -57,11 +96,7 @@ export async function run() {
57
96
  const db = getDb();
58
97
  const now = Date.now();
59
98
 
60
- // 0. VSCode で開かれた新規プロジェクトに .vscode/tasks.json を自動プロビジョニング。
61
- // Stop hook 側にも同じ呼び出しがあるが、Stop が発火しない環境(応答中断・IDE 挙動差)
62
- // でも SessionStart は必ず走るので、新規プロジェクトでの自動起動を確実化する保険。
63
- // 冪等性は ensureMonitorTaskFile 側で保証されており、Stop/UserPromptSubmit と重複呼び
64
- // 出しされても安全。
99
+ // 0. VSCode tasks.json 自動プロビジョニング (冪等)
65
100
  try {
66
101
  ensureMonitorTaskFile({ cwd: projectPath, env: process.env });
67
102
  } catch (err) {
@@ -75,18 +110,40 @@ export async function run() {
75
110
  VALUES (?, ?, 'active', ?, ?)`,
76
111
  ).run(session_id, projectPath, now, now);
77
112
 
78
- // 2. バトン消費
113
+ // 2. baton 消費
79
114
  const baton = consumeBaton(db, { projectPath, now });
80
115
 
81
- 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
+
82
121
  if (baton.sessionId) {
83
- // バトンが指す session が既に他と merge 済みなら、その合流先末端を前任とする
122
+ // baton path
123
+ triggeredPath = 'baton';
84
124
  const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
85
125
  mergeResult = mergeSpecificPredecessor(db, {
86
126
  newSessionId: session_id,
87
127
  predecessorId,
88
128
  now,
89
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' };
90
147
  }
91
148
 
92
149
  logDecision({
@@ -94,22 +151,21 @@ export async function run() {
94
151
  source: source ?? null,
95
152
  session_id,
96
153
  project_path: projectPath,
154
+ triggered_path: triggeredPath,
155
+ auto_handoff_disabled: autoDisabled,
97
156
  baton_session_id: baton.sessionId ?? null,
98
157
  baton_age_ms: baton.ageMs ?? null,
99
158
  baton_skip_reason: baton.skipReason ?? null,
100
- baton_has_memo: Boolean(baton.memoText),
101
159
  merged: mergeResult.merged,
102
160
  merge_skip_reason: mergeResult.skipReason ?? null,
103
161
  predecessor_id: mergeResult.predecessorId ?? null,
104
162
  });
105
163
 
106
- // 3. 合流成立なら引き継ぎヘッダ付きで注入
107
- // バトンに付いていた in-flight メモも併せて先頭セクションに注入する
164
+ // 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
108
165
  if (mergeResult.merged) {
109
166
  const text = buildResumeContext(db, {
110
167
  sessionId: session_id,
111
168
  isInheritance: true,
112
- inflightMemo: baton.memoText ?? null,
113
169
  });
114
170
  if (text) {
115
171
  process.stdout.write(text + '\n');
@@ -1,42 +0,0 @@
1
- ---
2
- description: 現セッションの context trim 計画を dry-run する (Throughline)
3
- ---
4
-
5
- Throughline の context trim は、現時点では dry-run のみ有効です。自動 `/rewind` や自動 rollback / inject は、host primitive の検証が完了するまで実行してはいけません。
6
-
7
- まず、今この時点の「現在作業」を自分の文脈から思い出し、以下 4 項目を Markdown で整理してください。
8
-
9
- - **次の一手**: 今まさに何をやろうとしていたか(1-3 文、具体的に)
10
- - **現在の方針 / 仮説**: 追っているバグの原因、設計の方向性、調査中の観点など
11
- - **未解決の疑問**: 判断保留中の論点
12
- - **進行中 TODO**: 完了済みを除いた現行 TODO
13
-
14
- その Markdown を stdin として、次のコマンドを Bash ツールで実行し、結果をユーザーにそのまま要約してください。
15
-
16
- ```bash
17
- throughline trim --dry-run --host claude --memo-stdin $ARGUMENTS <<'EOF'
18
- **次の一手**: ...
19
- **現在の方針 / 仮説**: ...
20
- **未解決の疑問**: ...
21
- **進行中 TODO**:
22
- - ...
23
- EOF
24
- ```
25
-
26
- 出力には以下が含まれます。
27
-
28
- - 現セッションで Throughline が捕捉している turn 数
29
- - keep-recent 設定
30
- - rollback 候補 turn 数
31
- - host が自動 rollback / inject 対応かどうか
32
- - rollback 後に戻す curated memory preview
33
- - rollback 後に「今やっている作業」として戻す current-work memo
34
-
35
- `$ARGUMENTS` には `--keep-recent 20`、`--all`、`--session <id>` を渡せます。
36
-
37
- 重要:
38
-
39
- - dry-run の結果だけで自動 rollback しないでください。
40
- - Claude の既存 `/tl` baton handoff は変更しません。
41
- - L1/L2 だけでは「今やっている作業」として認識されにくいので、current-work memo を省略しないでください。
42
- - `Throughline Trim Dry-run` の `Automatic execution allowed` が `no` の場合は、実行手順ではなく「現状は dry-run / 手動案内まで」と説明してください。