throughline 0.1.0 → 0.3.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.
package/src/db.mjs CHANGED
@@ -9,7 +9,7 @@ import { join } from 'path';
9
9
 
10
10
  const DB_DIR = join(homedir(), '.throughline');
11
11
  const DB_PATH = join(DB_DIR, 'throughline.db');
12
- const CURRENT_VERSION = 5;
12
+ const CURRENT_VERSION = 7;
13
13
 
14
14
  let _db = null;
15
15
 
@@ -177,6 +177,31 @@ function initSchema(db) {
177
177
  `);
178
178
  }
179
179
 
180
+ // v5 → v6: handoff_batons テーブル追加(/tl スラッシュコマンドによる明示的引き継ぎ指名用)
181
+ // - project_path ごとに最新 1 件のみ (PRIMARY KEY)
182
+ // - SessionStart で読み出し、TTL 以内なら merge して DELETE
183
+ // - docs/INHERITANCE_ON_CLEAR_ONLY.md 参照: 案 D (時間差) 撤去、バトン方式へ移行
184
+ if (version < 6) {
185
+ db.exec(`
186
+ CREATE TABLE IF NOT EXISTS handoff_batons (
187
+ project_path TEXT PRIMARY KEY,
188
+ session_id TEXT NOT NULL,
189
+ created_at INTEGER NOT NULL
190
+ );
191
+ `);
192
+ }
193
+
194
+ // v6 → v7: handoff_batons に memo_text 列追加(/tl 発動時に現行 Claude 自身が
195
+ // 書き込む in-flight メモ。「次の一手」「現在の方針」「未解決」「進行中 TODO」
196
+ // の短い Markdown テキスト。次セッションの SessionStart が resume-context の
197
+ // 先頭に注入して「中断地点からの再開」感を復元する)
198
+ if (version < 7) {
199
+ const batonCols = db.prepare('PRAGMA table_info(handoff_batons)').all();
200
+ if (!batonCols.some((c) => c.name === 'memo_text')) {
201
+ db.exec('ALTER TABLE handoff_batons ADD COLUMN memo_text TEXT');
202
+ }
203
+ }
204
+
180
205
  if (version < CURRENT_VERSION) {
181
206
  db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
182
207
  }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UserPromptSubmit hook — /tl スラッシュコマンド検出 + バトン書き込み
4
+ *
5
+ * stdin: { session_id, cwd, prompt, hook_event_name, ... }
6
+ *
7
+ * 動作:
8
+ * - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
9
+ * - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
10
+ * - 本 hook は注入を一切行わない (SessionStart の引き継ぎ注入と二重にならないため)
11
+ *
12
+ * 設計背景: docs/INHERITANCE_ON_CLEAR_ONLY.md バトン方式
13
+ */
14
+
15
+ import { getDb } from './db.mjs';
16
+ import { writeBaton } from './baton.mjs';
17
+ import { appendFileSync, mkdirSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+
21
+ function logBaton(entry) {
22
+ const path = join(homedir(), '.throughline', 'logs', 'baton-write.log');
23
+ try {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
26
+ } catch (err) {
27
+ const msg = err instanceof Error ? err.message : 'unknown';
28
+ process.stderr.write(`[prompt-submit:log] ${msg}\n`);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * プロンプトが /tl バトン発動コマンドか判定する。
34
+ * 許容: "/tl", "/tl\n", "/tl 何か" (前後空白は trim 済み前提)
35
+ */
36
+ export function isBatonCommand(prompt) {
37
+ if (typeof prompt !== 'string') return false;
38
+ const trimmed = prompt.trim();
39
+ if (trimmed === '/tl') return true;
40
+ if (trimmed.startsWith('/tl ') || trimmed.startsWith('/tl\n')) return true;
41
+ return false;
42
+ }
43
+
44
+ async function main() {
45
+ let raw = '';
46
+ await new Promise((resolve) => {
47
+ process.stdin.setEncoding('utf8');
48
+ process.stdin.on('data', (chunk) => {
49
+ raw += chunk;
50
+ });
51
+ process.stdin.on('end', resolve);
52
+ });
53
+
54
+ const payload = JSON.parse(raw);
55
+ const { session_id, cwd, prompt } = payload;
56
+
57
+ if (!isBatonCommand(prompt)) {
58
+ process.exit(0);
59
+ return;
60
+ }
61
+
62
+ if (!session_id) {
63
+ process.stderr.write('[prompt-submit] missing session_id in payload\n');
64
+ process.exit(0);
65
+ return;
66
+ }
67
+
68
+ const projectPath = cwd ?? process.cwd();
69
+ const db = getDb();
70
+ const now = Date.now();
71
+
72
+ writeBaton(db, { projectPath, sessionId: session_id, now });
73
+
74
+ logBaton({
75
+ ts: new Date(now).toISOString(),
76
+ session_id,
77
+ project_path: projectPath,
78
+ });
79
+
80
+ process.exit(0);
81
+ }
82
+
83
+ main().catch((err) => {
84
+ const msg = err instanceof Error ? err.message : 'unknown';
85
+ process.stderr.write(`[prompt-submit] error: ${msg}\n`);
86
+ process.exit(1);
87
+ });
@@ -1,23 +1,34 @@
1
1
  /**
2
- * resume-context.mjs — L1+L2 の再注入テキストを組み立てる共有モジュール
2
+ * resume-context.mjs — 中断地点からの再開注入テキストを組み立てる共有モジュール
3
3
  *
4
4
  * 呼び出し元:
5
5
  * - session-start.mjs (isInheritance=true, 引き継ぎヘッダ)
6
6
  *
7
- * 新設計(schema v4):
7
+ * 設計(schema v7 対応):
8
+ * - 注入順: ヘッダ → [in-flight メモ] → [中断直前の思考] → L1 要約 → L2 本文 → フッタ
9
+ * - in-flight メモ: /tl 発動時に現行 Claude が書いた「次の一手 / 方針 / 未解決 / TODO」
10
+ * - 中断直前の思考: 最終ターンの assistant extended thinking (details kind='thinking')
8
11
  * - 直近 N=20 ターンは bodies から L2 全文を注入
9
12
  * - それ以前は skeletons から L1 要約のみ注入
10
- * - 各行頭に [HH:MM:SS] 時刻プレフィックス(bodies.created_at ベース、DB 永続)
13
+ * - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
11
14
  * - 末尾に /sc-detail <時刻> ガイドを追記
12
- * - judgments セクションは廃止
13
15
  * - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
16
+ * - フレーミングを「過去の記憶」から「中断した作業の再開」に変更 (B 案)
14
17
  */
15
18
 
16
19
  const N_RECENT_L2 = 20;
17
20
 
18
21
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
19
- `## Throughline: セッション記憶(${turnCount} ターン引き継ぎ)\n` +
20
- `**[Throughline] 前セッションの記憶を引き継ぎました。応答の冒頭で「前の記憶を ${turnCount} ターン引き継ぎました」とユーザーに報告してください。**`;
22
+ `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
23
+ `\n` +
24
+ `**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
25
+ `- 中断直前の in-flight メモ(前セッション末尾で Claude 自身が書いた「次の一手・方針・未解決・TODO」)\n` +
26
+ `- 中断直前の思考 (最終ターンの extended thinking)\n` +
27
+ `- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
28
+ `- それ以前の要約 (L1)\n` +
29
+ `\n` +
30
+ `応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
31
+ `作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
21
32
 
22
33
  const NORMAL_HEADER = '## Throughline: セッション記憶';
23
34
 
@@ -25,11 +36,11 @@ const FOOTER_GUIDE =
25
36
  '---\n' +
26
37
  '**[Claude 向け — 記憶の使い方]**\n' +
27
38
  '上の L1 要約や L2 本文を読んで「具体的なコマンドやツール出力、ファイル内容を確認したい」と感じたら、' +
28
- '推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力)を取得してください。\n' +
39
+ '推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力・thinking)を取得してください。\n' +
29
40
  '- 単一時刻: `throughline detail 14:23:05`\n' +
30
41
  '- 時刻範囲: `throughline detail 14:23-14:30`\n' +
31
42
  '\n' +
32
- '返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system 別にグループ化)。\n' +
43
+ '返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
33
44
  'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
34
45
 
35
46
  /**
@@ -44,29 +55,75 @@ function formatTime(unixMs) {
44
55
  }
45
56
 
46
57
  /**
47
- * 本文を 1 行にまとめる(改行は空白に畳む)。
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 }>}
48
65
  */
49
- function flattenText(text) {
50
- if (!text) return '';
51
- return text.replace(/\n+/g, ' ').trim();
66
+ function loadLatestThinking(db, sessionId, excludeOriginId) {
67
+ const hasExclude = Boolean(excludeOriginId);
68
+
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`;
81
+
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
+ }
52
105
  }
53
106
 
54
107
  /**
55
108
  * L1+L2 注入テキストを組み立てる。
56
109
  *
57
110
  * @param {import('node:sqlite').DatabaseSync} db
58
- * @param {{ sessionId: string, isInheritance: boolean, excludeOriginId?: string | null }} params
59
- * sessionId: 合流先 session_id (merge target)
60
- * excludeOriginId: 注入対象から除外する origin_session_id(= 現セッションの origin)
61
- * 指定すると「前任チェーンのターンのみ」を注入する
111
+ * @param {{
112
+ * sessionId: string,
113
+ * isInheritance: boolean,
114
+ * excludeOriginId?: string | null,
115
+ * inflightMemo?: string | null,
116
+ * }} params
62
117
  * @returns {string | null}
63
118
  */
64
- export function buildResumeContext(db, { sessionId, isInheritance, excludeOriginId = null }) {
119
+ export function buildResumeContext(
120
+ db,
121
+ { sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
122
+ ) {
65
123
  if (!sessionId) return null;
66
124
 
67
125
  const hasExclude = Boolean(excludeOriginId);
68
126
 
69
- // 直近 N 件の bodies を取得
70
127
  const bodiesQuery = hasExclude
71
128
  ? `SELECT origin_session_id, turn_number, role, text, created_at
72
129
  FROM bodies
@@ -92,7 +149,7 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
92
149
  }
93
150
  const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
94
151
 
95
- // 古い側の L1(bodies に既に含まれるターンを除いたもの)を skeletons から取得
152
+ // 古い側の L1(bodies に既に含まれるターンを除いたもの)
96
153
  const bodySet = new Set(
97
154
  bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
98
155
  );
@@ -115,7 +172,14 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
115
172
  (s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
116
173
  );
117
174
 
118
- if (bodyRows.length === 0 && l1Rows.length === 0) {
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
+ ) {
119
183
  return null;
120
184
  }
121
185
 
@@ -123,12 +187,26 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
123
187
  const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
124
188
  const lines = [header];
125
189
 
190
+ if (inflightMemo && inflightMemo.trim().length > 0) {
191
+ 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}`);
201
+ }
202
+ }
203
+
126
204
  if (l1Rows.length > 0) {
127
205
  lines.push('');
128
206
  lines.push('### それ以前の要約 (L1)');
129
207
  for (const r of l1Rows) {
130
208
  if (!r.summary || r.summary === '(no content)') continue;
131
- lines.push(`[${formatTime(r.created_at)}] ${flattenText(r.summary)}`);
209
+ lines.push(`[${formatTime(r.created_at)}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
132
210
  }
133
211
  }
134
212
 
@@ -2,10 +2,13 @@
2
2
  * session-merger.mjs — 記憶張り替え + merged_into チェーン解決
3
3
  *
4
4
  * 用途:
5
- * - SessionStart hook: mergePredecessorInto で前任セッションの L1/L2/L3 を新セッションに張り替える
6
- * - Stop / PostToolUse hook: resolveMergeTarget で「入力 session_id → 実書き込み先」を解決
5
+ * - SessionStart hook: バトンで指名された旧セッションを mergeSpecificPredecessor で新セッションに張り替え
6
+ * - Stop hook: resolveMergeTarget で「入力 session_id → 実書き込み先」を解決
7
7
  *
8
- * 設計背景: docs/SESSION_LINKING_DESIGN.md
8
+ * 設計背景: docs/SESSION_LINKING_DESIGN.md, docs/INHERITANCE_ON_CLEAR_ONLY.md (バトン方式採用)
9
+ *
10
+ * 旧実装 (案 D: 時間差ヒューリスティック / 自動前任選択) は撤去済み。
11
+ * 引き継ぎはユーザーが /tl を打って書いたバトンによる明示的指名のみで発火する。
9
12
  */
10
13
 
11
14
  const MAX_CHAIN_DEPTH = 10;
@@ -16,8 +19,6 @@ const MAX_CHAIN_DEPTH = 10;
16
19
  * @param {import('node:sqlite').DatabaseSync} db
17
20
  * @param {string} sessionId
18
21
  * @returns {{ target: string, origin: string }}
19
- * target: 実書き込み先 session_id(合流先)
20
- * origin: 入力 session_id そのもの(INSERT 時の origin_session_id に使う)
21
22
  */
22
23
  export function resolveMergeTarget(db, sessionId) {
23
24
  const origin = sessionId;
@@ -46,43 +47,53 @@ export function resolveMergeTarget(db, sessionId) {
46
47
  }
47
48
 
48
49
  /**
49
- * 同一プロジェクト内の最新非合流セッションを新セッションに張り替える。
50
+ * バトンで指名された特定の旧セッションを新セッションに張り替える。
50
51
  *
51
52
  * 実行順序(BEGIN IMMEDIATE トランザクション内):
52
- * 1. 前任候補 SELECT(同 project_path, session_id != new, merged_into IS NULL, 最新 updated_at)
53
+ * 1. 前任の妥当性チェック(存在する / 自分自身ではない / 既に合流済みでない / created_at が古い)
53
54
  * 2. skeletons / details / bodies の session_id を new に UPDATE
54
- * (bodies は schema v4 で追加された L2 テーブル。v3 DB でも UPDATE は no-op で害なし)
55
55
  * 3. 前任 sessions.merged_into = new
56
56
  * 4. 新セッション sessions.updated_at = now
57
57
  *
58
58
  * @param {import('node:sqlite').DatabaseSync} db
59
- * @param {{ newSessionId: string, projectPath: string }} params
60
- * @returns {{ merged: boolean, predecessorId?: string, rowCounts?: { sk: number, dt: number, bd: number } }}
59
+ * @param {{ newSessionId: string, predecessorId: string, now?: number }} params
60
+ * @returns {{
61
+ * merged: boolean,
62
+ * predecessorId?: string,
63
+ * rowCounts?: { sk: number, dt: number, bd: number },
64
+ * skipReason?: 'self_handoff' | 'predecessor_not_found' | 'already_merged' | 'predecessor_not_older',
65
+ * }}
61
66
  */
62
- export function mergePredecessorInto(db, { newSessionId, projectPath }) {
67
+ export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now = Date.now() }) {
68
+ if (newSessionId === predecessorId) {
69
+ return { merged: false, skipReason: 'self_handoff' };
70
+ }
71
+
63
72
  db.exec('BEGIN IMMEDIATE');
64
73
  try {
65
- // 時系列単調制約: 前任は新セッションより created_at が古いものに限る。
66
- // これにより merge chain は厳密に時系列順となり、循環参照が構造的に発生不可能になる。
67
- // (同時刻に複数 SessionStart が発火しても、自分自身より新しいセッションは選ばない)
68
74
  const pred = db
69
- .prepare(
70
- `SELECT session_id FROM sessions
71
- WHERE lower(project_path) = lower(?)
72
- AND session_id != ?
73
- AND merged_into IS NULL
74
- AND created_at < (SELECT created_at FROM sessions WHERE session_id = ?)
75
- ORDER BY updated_at DESC
76
- LIMIT 1`,
77
- )
78
- .get(projectPath, newSessionId, newSessionId);
75
+ .prepare('SELECT session_id, created_at, merged_into FROM sessions WHERE session_id = ?')
76
+ .get(predecessorId);
79
77
 
80
78
  if (!pred) {
81
79
  db.exec('COMMIT');
82
- return { merged: false };
80
+ return { merged: false, skipReason: 'predecessor_not_found' };
81
+ }
82
+ if (pred.merged_into) {
83
+ db.exec('COMMIT');
84
+ return { merged: false, skipReason: 'already_merged' };
83
85
  }
84
86
 
85
- const predecessorId = pred.session_id;
87
+ const self = db
88
+ .prepare('SELECT created_at FROM sessions WHERE session_id = ?')
89
+ .get(newSessionId);
90
+
91
+ // 時系列単調制約: 前任は新セッションより created_at が古いこと。
92
+ // バトンが自分より新しい session を指していたら(異常データ)merge しない。
93
+ if (self && pred.created_at >= self.created_at) {
94
+ db.exec('COMMIT');
95
+ return { merged: false, skipReason: 'predecessor_not_older' };
96
+ }
86
97
 
87
98
  const sk = db
88
99
  .prepare('UPDATE skeletons SET session_id = ? WHERE session_id = ?')
@@ -90,14 +101,12 @@ export function mergePredecessorInto(db, { newSessionId, projectPath }) {
90
101
  const dt = db
91
102
  .prepare('UPDATE details SET session_id = ? WHERE session_id = ?')
92
103
  .run(newSessionId, predecessorId);
93
- // bodies は schema v4 以降のみ存在。v3 DB では 0 changes で害なし
94
104
  let bd = { changes: 0 };
95
105
  try {
96
106
  bd = db
97
107
  .prepare('UPDATE bodies SET session_id = ? WHERE session_id = ?')
98
108
  .run(newSessionId, predecessorId);
99
109
  } catch (err) {
100
- // bodies テーブルが未作成の場合は無視(schema v3 DB 互換)
101
110
  if (!/no such table/i.test(err.message || '')) throw err;
102
111
  }
103
112
 
@@ -106,7 +115,7 @@ export function mergePredecessorInto(db, { newSessionId, projectPath }) {
106
115
  predecessorId,
107
116
  );
108
117
  db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(
109
- Date.now(),
118
+ now,
110
119
  newSessionId,
111
120
  );
112
121
 
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
- import { resolveMergeTarget, mergePredecessorInto } from './session-merger.mjs';
4
+ import { resolveMergeTarget, mergeSpecificPredecessor } from './session-merger.mjs';
5
5
 
6
6
  function makeDb() {
7
7
  const db = new DatabaseSync(':memory:');
@@ -81,7 +81,7 @@ test('resolveMergeTarget: detects cycle and throws', () => {
81
81
  assert.throws(() => resolveMergeTarget(db, 'A'), /cycle detected/);
82
82
  });
83
83
 
84
- test('mergePredecessorInto: picks older predecessor and moves rows', () => {
84
+ test('mergeSpecificPredecessor: moves rows from named predecessor to new session', () => {
85
85
  const db = makeDb();
86
86
  insertSession(db, 'old', 100);
87
87
  db.prepare(
@@ -90,7 +90,12 @@ test('mergePredecessorInto: picks older predecessor and moves rows', () => {
90
90
  ).run();
91
91
  insertSession(db, 'new', 200);
92
92
 
93
- const result = mergePredecessorInto(db, { newSessionId: 'new', projectPath: '/proj' });
93
+ const result = mergeSpecificPredecessor(db, {
94
+ newSessionId: 'new',
95
+ predecessorId: 'old',
96
+ now: 200,
97
+ });
98
+
94
99
  assert.equal(result.merged, true);
95
100
  assert.equal(result.predecessorId, 'old');
96
101
  assert.equal(result.rowCounts.sk, 1);
@@ -102,50 +107,76 @@ test('mergePredecessorInto: picks older predecessor and moves rows', () => {
102
107
  assert.equal(oldRow.merged_into, 'new');
103
108
  });
104
109
 
105
- test('mergePredecessorInto: does NOT pick a session newer than self (cycle prevention)', () => {
110
+ test('mergeSpecificPredecessor: self-handoff is refused', () => {
111
+ const db = makeDb();
112
+ insertSession(db, 'A', 100);
113
+
114
+ const result = mergeSpecificPredecessor(db, {
115
+ newSessionId: 'A',
116
+ predecessorId: 'A',
117
+ now: 100,
118
+ });
119
+
120
+ assert.equal(result.merged, false);
121
+ assert.equal(result.skipReason, 'self_handoff');
122
+ });
123
+
124
+ test('mergeSpecificPredecessor: predecessor not in sessions table', () => {
125
+ const db = makeDb();
126
+ insertSession(db, 'new', 200);
127
+
128
+ const result = mergeSpecificPredecessor(db, {
129
+ newSessionId: 'new',
130
+ predecessorId: 'nonexistent',
131
+ now: 200,
132
+ });
133
+
134
+ assert.equal(result.merged, false);
135
+ assert.equal(result.skipReason, 'predecessor_not_found');
136
+ });
137
+
138
+ test('mergeSpecificPredecessor: predecessor already merged into third session', () => {
139
+ const db = makeDb();
140
+ insertSession(db, 'old', 100, 'middle');
141
+ insertSession(db, 'middle', 150);
142
+ insertSession(db, 'new', 200);
143
+
144
+ const result = mergeSpecificPredecessor(db, {
145
+ newSessionId: 'new',
146
+ predecessorId: 'old',
147
+ now: 200,
148
+ });
149
+
150
+ assert.equal(result.merged, false);
151
+ assert.equal(result.skipReason, 'already_merged');
152
+ });
153
+
154
+ test('mergeSpecificPredecessor: refuses predecessor with created_at >= self', () => {
106
155
  const db = makeDb();
107
- // new session created at t=100
108
156
  insertSession(db, 'new', 100);
109
- // another session was created LATER at t=200 (e.g. a parallel window that started after)
110
157
  insertSession(db, 'newer', 200);
111
158
 
112
- const result = mergePredecessorInto(db, { newSessionId: 'new', projectPath: '/proj' });
113
- assert.equal(result.merged, false, 'should not merge a newer session into an older one');
159
+ const result = mergeSpecificPredecessor(db, {
160
+ newSessionId: 'new',
161
+ predecessorId: 'newer',
162
+ now: 100,
163
+ });
114
164
 
115
- const newerRow = db
116
- .prepare('SELECT merged_into FROM sessions WHERE session_id = ?')
117
- .get('newer');
118
- assert.equal(newerRow.merged_into, null);
165
+ assert.equal(result.merged, false);
166
+ assert.equal(result.skipReason, 'predecessor_not_older');
119
167
  });
120
168
 
121
- test('mergePredecessorInto: chronological monotonicity prevents cycles across 3 sessions', () => {
169
+ test('mergeSpecificPredecessor: updates new session updated_at to provided now', () => {
122
170
  const db = makeDb();
123
- // Sessions created in order: A (t=100), B (t=200), C (t=300)
124
- insertSession(db, 'A', 100);
125
- insertSession(db, 'B', 200);
126
- insertSession(db, 'C', 300);
127
-
128
- // Simulate SessionStart firing for B first, then C, then (accidentally) A again
129
- mergePredecessorInto(db, { newSessionId: 'B', projectPath: '/proj' });
130
- // B should have absorbed A
131
- assert.equal(
132
- db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('A').merged_into,
133
- 'B',
134
- );
135
-
136
- mergePredecessorInto(db, { newSessionId: 'C', projectPath: '/proj' });
137
- // C should have absorbed B (A is already merged, so not a candidate)
138
- assert.equal(
139
- db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('B').merged_into,
140
- 'C',
141
- );
142
-
143
- // Re-firing SessionStart for A must not create a cycle (A cannot absorb newer B or C)
144
- const redundant = mergePredecessorInto(db, { newSessionId: 'A', projectPath: '/proj' });
145
- assert.equal(redundant.merged, false);
146
-
147
- // Verify no cycle: resolveMergeTarget from any node terminates at C
148
- assert.equal(resolveMergeTarget(db, 'A').target, 'C');
149
- assert.equal(resolveMergeTarget(db, 'B').target, 'C');
150
- assert.equal(resolveMergeTarget(db, 'C').target, 'C');
171
+ insertSession(db, 'old', 100);
172
+ insertSession(db, 'new', 200);
173
+
174
+ mergeSpecificPredecessor(db, {
175
+ newSessionId: 'new',
176
+ predecessorId: 'old',
177
+ now: 500,
178
+ });
179
+
180
+ const newRow = db.prepare('SELECT updated_at FROM sessions WHERE session_id = ?').get('new');
181
+ assert.equal(newRow.updated_at, 500);
151
182
  });