throughline 0.1.0 → 0.2.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/README.md CHANGED
@@ -51,7 +51,7 @@ Start any Claude Code session and your turns will begin flowing into
51
51
 
52
52
  ---
53
53
 
54
- ## Three-layer memory model (schema v4)
54
+ ## Three-layer memory model (schema v6)
55
55
 
56
56
  | Layer | Name | Where it lives | Content | Cost per turn |
57
57
  | ----- | ---------- | --------------------- | ---------------------------------------------------------- | ------------- |
@@ -81,29 +81,49 @@ of tool inputs, tool outputs, and hook output captured at L3 for that turn.
81
81
 
82
82
  ---
83
83
 
84
- ## `/clear`-safe with memory rebonding
84
+ ## Explicit handoff via `/tl`
85
85
 
86
- When you run `/clear`, the conversation transcript is discarded, but the SQLite
87
- database is untouched. On the next session start:
86
+ Inheritance is **opt-in**, not automatic. When you want the next session to
87
+ pick up where this one left off, type `/tl` in the current session before you
88
+ `/clear` or open a new chat. Without `/tl`, new sessions start fresh — no
89
+ memory is carried over.
88
90
 
89
- 1. `SessionStart` hook fires with a new `session_id`
90
- 2. Throughline finds the previous session in the same project
91
- 3. It **rebonds** all `skeletons` / `bodies` / `details` rows from the previous
92
- session into the new session (via `UPDATE session_id = ?`) inside a
93
- `BEGIN IMMEDIATE` transaction
94
- 4. A handover banner is injected:
95
- `## Throughline: セッション記憶(N ターン引き継ぎ)`
96
-
97
- Each row keeps its **origin_session_id**, so memories accumulate through chains
98
- of `/clear` rather than being lost or overwritten:
91
+ The `/tl` slash command writes a **handoff baton** (the current `session_id`)
92
+ into the `handoff_batons` table. On the next `SessionStart`, the hook reads the
93
+ baton, and if it is less than **1 hour old**, merges that session's memory
94
+ into the new session using a deterministic `UPDATE session_id = ?` inside a
95
+ `BEGIN IMMEDIATE` transaction. The baton is consumed (deleted) atomically with
96
+ the merge, so it cannot fire twice.
99
97
 
100
98
  ```
101
- S1 (4 turns) -- /clear --> S2 (merges S1, adds 3 turns) -- /clear --> S3 (merges S2, adds 5 turns)
102
- origin=S1×4 origin=S1×4, S2×3, S3×5
99
+ Session A (type /tl) -----------> baton written
100
+ |
101
+ /clear |
102
+ | ▼
103
+ Session B ---- reads baton, merges A into B, deletes baton ---->
104
+ |
105
+ (type /tl again to hand off further)
103
106
  ```
104
107
 
105
- No time-window heuristic, no PID guessing, no ancestor walking. Just a
106
- deterministic UPDATE inside a SQLite transaction.
108
+ Why explicit baton instead of auto-inherit:
109
+
110
+ - **Zero false positives.** A parallel window, a VSCode restart, or a genuine
111
+ new task in the same repo won't accidentally inherit the previous session's
112
+ memory. Only an explicit `/tl` triggers inheritance.
113
+ - **VSCode extension compatibility.** The `SessionStart` hook's `source` field
114
+ is rewritten to `"startup"` by the Claude Code VSCode extension even after
115
+ `/clear` (see [issue #49937](https://github.com/anthropics/claude-code/issues/49937)),
116
+ so source-based detection is unreliable. A user-driven baton sidesteps this.
117
+ - **Deterministic.** No time-window heuristic, no PID guessing, no ancestor
118
+ walking. The user declares intent; the hook carries it out.
119
+
120
+ Each merged row keeps its `origin_session_id`, so repeated `/tl` handoffs
121
+ accumulate memory through chains:
122
+
123
+ ```
124
+ S1 (4 turns) --/tl,/clear--> S2 (merges S1, adds 3 turns) --/tl,/clear--> S3 (merges S2, adds 5 turns)
125
+ origin=S1×4 origin=S1×4, S2×3, S3×5
126
+ ```
107
127
 
108
128
  ---
109
129
 
@@ -165,8 +185,16 @@ you open the folder. Drop an equivalent config into your own project's
165
185
  | `throughline status` | Print DB statistics (sessions, skeletons, bodies, details) |
166
186
  | `throughline --version` | Print the installed version |
167
187
 
188
+ Slash commands (invoked by the user in Claude Code):
189
+
190
+ | Command | What it does |
191
+ | ------------- | ----------------------------------------------------------------- |
192
+ | `/tl` | Write a handoff baton so the next session inherits this one |
193
+ | `/sc-detail <time>` | Retrieve L2 body text and L3 tool I/O for a past turn |
194
+
168
195
  Hook subcommands (invoked by Claude Code, not by humans):
169
- `session-start` (SessionStart), `process-turn` (Stop).
196
+ `session-start` (SessionStart), `process-turn` (Stop),
197
+ `prompt-submit` (UserPromptSubmit — detects `/tl` and writes baton).
170
198
 
171
199
  ### `throughline detail` — for AI, not humans
172
200
 
@@ -210,16 +238,17 @@ plain `.mjs` files.
210
238
  └── <session_id>.json Per-session activity state for the monitor
211
239
  ```
212
240
 
213
- Schema v5:
241
+ Schema v6:
214
242
 
215
243
  - `sessions` — one row per `session_id`, with `project_path` and `merged_into`
216
244
  - `skeletons` — L1 one-liners, keyed by `(session_id, origin_session_id, turn, role)`
217
245
  - `bodies` — L2 verbatim text (user + assistant), same key shape
218
246
  - `details` — L3 records with `kind` column (`tool_input` / `tool_output` / `system` / `image`) and `source_id` for idempotent re-processing
247
+ - `handoff_batons` — one row per `project_path`, holding the `session_id` that `/tl` has nominated to hand off. Consumed and deleted by the next `SessionStart` if within the 1-hour TTL.
219
248
  - `injection_log` — audit trail of injection events
220
249
 
221
- All tables carry an `origin_session_id` so rebonded rows keep their lineage
222
- after a `/clear` chain.
250
+ All memory tables carry an `origin_session_id` so rebonded rows keep their
251
+ lineage across a chain of `/tl` handoffs.
223
252
 
224
253
  ---
225
254
 
@@ -287,9 +316,15 @@ unchanged here.
287
316
 
288
317
  **Database got corrupted / want a clean slate**
289
318
  Delete `~/.throughline/throughline.db` (and the `-shm` / `-wal` companion files)
290
- and `~/.throughline/state/*.json`. A fresh database with schema v4 is created on
319
+ and `~/.throughline/state/*.json`. A fresh database with schema v6 is created on
291
320
  the next hook fire.
292
321
 
322
+ **New session didn't inherit memory from the previous one**
323
+ This is the designed behavior — inheritance requires an explicit `/tl` in the
324
+ previous session. If you forgot to type it before `/clear`, the memory is still
325
+ in SQLite but won't auto-inject. You can still retrieve specific turns with
326
+ `/sc-detail <time>`.
327
+
293
328
  ---
294
329
 
295
330
  ## Development
@@ -297,7 +332,7 @@ the next hook fire.
297
332
  ```bash
298
333
  git clone https://github.com/kitepon-rgb/Throughline.git
299
334
  cd Throughline
300
- npm link # Put `throughline` on PATH
335
+ npm link # Put `throughline` on PATH (dev only)
301
336
  throughline install --project # Register hooks for this repo only
302
337
  node --test src/turn-processor.test.mjs src/session-merger.test.mjs
303
338
  ```
@@ -316,7 +351,8 @@ the folder in VS Code.
316
351
  ## Design docs
317
352
 
318
353
  - [`docs/L1_L2_L3_REDESIGN.md`](docs/L1_L2_L3_REDESIGN.md) — **current design
319
- spec** for the L1/L2/L3 differential layer model (schema v4). Authoritative.
354
+ spec** for the L1/L2/L3 differential layer model (schema v4 base + v5 L3
355
+ classification extension). Authoritative.
320
356
  - [`docs/PUBLIC_RELEASE_PLAN.md`](docs/PUBLIC_RELEASE_PLAN.md) — public release
321
357
  plan (CLI surface, package.json layout, § 0 fallback rule)
322
358
  - [`docs/archive/`](docs/archive/) — superseded design documents kept for
@@ -32,6 +32,9 @@ switch (cmd) {
32
32
  case 'session-start':
33
33
  await import('../src/session-start.mjs');
34
34
  break;
35
+ case 'prompt-submit':
36
+ await import('../src/prompt-submit.mjs');
37
+ break;
35
38
  case 'monitor':
36
39
  await import('../src/token-monitor.mjs');
37
40
  break;
@@ -74,5 +77,6 @@ Usage:
74
77
  Hook subcommands (called by Claude Code):
75
78
  throughline session-start SessionStart hook
76
79
  throughline process-turn Stop hook
80
+ throughline prompt-submit UserPromptSubmit hook (/tl baton writer)
77
81
  `);
78
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
package/src/baton.mjs ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * baton.mjs — 引き継ぎバトン管理
3
+ *
4
+ * バトン方式の設計 (docs/INHERITANCE_ON_CLEAR_ONLY.md):
5
+ * - ユーザーが旧セッションで /tl スラッシュコマンドを打つ → UserPromptSubmit hook が
6
+ * baton テーブルに (project_path, session_id, created_at) を INSERT OR REPLACE
7
+ * - 新セッションの SessionStart hook が baton を消費:
8
+ * TTL 1 時間以内 かつ session_id が自分自身でない → 前任として merge
9
+ * 期限切れ or 自己指名 → 破棄
10
+ * - 消費は atomic (BEGIN IMMEDIATE トランザクション内で SELECT + DELETE)
11
+ *
12
+ * なぜバトン方式か:
13
+ * - VSCode 拡張では SessionStart payload の source が /clear 後も "startup" に潰される
14
+ * ため source 値だけで /clear を識別できない (GitHub issue #49937)
15
+ * - 時間差ヒューリスティック (案 D) は誤爆の可能性があり、ユーザー明示の意思表示を
16
+ * 引き継ぎ発火の唯一の条件とする方が決定論的
17
+ */
18
+
19
+ /**
20
+ * バトン TTL (ミリ秒)。ユーザーが /tl を打ってから新セッション開始までの猶予。
21
+ * 超過したバトンは consumeBaton で破棄される(merge されない)。
22
+ */
23
+ export const BATON_TTL_MS = 60 * 60 * 1000; // 1 時間
24
+
25
+ /**
26
+ * 現在セッション (= /tl を発動したセッション) を次回 SessionStart で merge 対象に指名する。
27
+ * 同 project_path の既存バトンは上書きされる (INSERT OR REPLACE)。最新意図のみ有効。
28
+ *
29
+ * @param {import('node:sqlite').DatabaseSync} db
30
+ * @param {{ projectPath: string, sessionId: string, now?: number }} params
31
+ */
32
+ export function writeBaton(db, { projectPath, sessionId, now = Date.now() }) {
33
+ db.prepare(
34
+ `INSERT OR REPLACE INTO handoff_batons (project_path, session_id, created_at)
35
+ VALUES (?, ?, ?)`,
36
+ ).run(projectPath, sessionId, now);
37
+ }
38
+
39
+ /**
40
+ * 同 project_path のバトンを読み出して削除する (atomic)。
41
+ *
42
+ * 戻り値:
43
+ * - { sessionId, ageMs } : バトン存在 かつ TTL 以内
44
+ * - { sessionId: null, skipReason: 'expired', ageMs } : TTL 超過で破棄
45
+ * - { sessionId: null, skipReason: 'missing' } : バトン無し
46
+ *
47
+ * @param {import('node:sqlite').DatabaseSync} db
48
+ * @param {{ projectPath: string, now?: number, ttlMs?: number }} params
49
+ * @returns {{ sessionId: string | null, ageMs?: number, skipReason?: 'expired' | 'missing' }}
50
+ */
51
+ export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_TTL_MS }) {
52
+ db.exec('BEGIN IMMEDIATE');
53
+ try {
54
+ const row = db
55
+ .prepare(
56
+ `SELECT session_id, created_at FROM handoff_batons WHERE project_path = ?`,
57
+ )
58
+ .get(projectPath);
59
+
60
+ if (!row) {
61
+ db.exec('COMMIT');
62
+ return { sessionId: null, skipReason: 'missing' };
63
+ }
64
+
65
+ db.prepare('DELETE FROM handoff_batons WHERE project_path = ?').run(projectPath);
66
+ const ageMs = now - row.created_at;
67
+
68
+ if (ageMs > ttlMs) {
69
+ db.exec('COMMIT');
70
+ return { sessionId: null, skipReason: 'expired', ageMs };
71
+ }
72
+
73
+ db.exec('COMMIT');
74
+ return { sessionId: row.session_id, ageMs };
75
+ } catch (err) {
76
+ try {
77
+ db.exec('ROLLBACK');
78
+ } catch {
79
+ // ignore
80
+ }
81
+ throw err;
82
+ }
83
+ }
@@ -0,0 +1,99 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { writeBaton, consumeBaton, BATON_TTL_MS } from './baton.mjs';
5
+
6
+ function makeDb() {
7
+ const db = new DatabaseSync(':memory:');
8
+ db.exec(`
9
+ CREATE TABLE handoff_batons (
10
+ project_path TEXT PRIMARY KEY,
11
+ session_id TEXT NOT NULL,
12
+ created_at INTEGER NOT NULL
13
+ );
14
+ `);
15
+ return db;
16
+ }
17
+
18
+ test('BATON_TTL_MS default is 1 hour', () => {
19
+ assert.equal(BATON_TTL_MS, 60 * 60 * 1000);
20
+ });
21
+
22
+ test('writeBaton: inserts a fresh baton', () => {
23
+ const db = makeDb();
24
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
25
+ const row = db.prepare('SELECT * FROM handoff_batons').get();
26
+ assert.equal(row.project_path, '/proj');
27
+ assert.equal(row.session_id, 'S1');
28
+ assert.equal(row.created_at, 1000);
29
+ });
30
+
31
+ test('writeBaton: overwrites previous baton for same project_path', () => {
32
+ const db = makeDb();
33
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
34
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S2', now: 2000 });
35
+ const rows = db.prepare('SELECT * FROM handoff_batons').all();
36
+ assert.equal(rows.length, 1);
37
+ assert.equal(rows[0].session_id, 'S2');
38
+ assert.equal(rows[0].created_at, 2000);
39
+ });
40
+
41
+ test('writeBaton: separate project_paths coexist', () => {
42
+ const db = makeDb();
43
+ writeBaton(db, { projectPath: '/a', sessionId: 'A', now: 1000 });
44
+ writeBaton(db, { projectPath: '/b', sessionId: 'B', now: 1000 });
45
+ const rows = db.prepare('SELECT * FROM handoff_batons ORDER BY project_path').all();
46
+ assert.equal(rows.length, 2);
47
+ assert.equal(rows[0].project_path, '/a');
48
+ assert.equal(rows[1].project_path, '/b');
49
+ });
50
+
51
+ test('consumeBaton: returns sessionId and deletes when within TTL', () => {
52
+ const db = makeDb();
53
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
54
+ const result = consumeBaton(db, {
55
+ projectPath: '/proj',
56
+ now: 1000 + 30 * 60 * 1000, // 30 min後
57
+ ttlMs: BATON_TTL_MS,
58
+ });
59
+ assert.equal(result.sessionId, 'S1');
60
+ assert.equal(result.ageMs, 30 * 60 * 1000);
61
+
62
+ const rows = db.prepare('SELECT * FROM handoff_batons').all();
63
+ assert.equal(rows.length, 0, 'baton should be deleted after consumption');
64
+ });
65
+
66
+ test('consumeBaton: returns expired when age exceeds TTL, still deletes', () => {
67
+ const db = makeDb();
68
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
69
+ const result = consumeBaton(db, {
70
+ projectPath: '/proj',
71
+ now: 1000 + 2 * 60 * 60 * 1000, // 2 時間後
72
+ ttlMs: BATON_TTL_MS,
73
+ });
74
+ assert.equal(result.sessionId, null);
75
+ assert.equal(result.skipReason, 'expired');
76
+ assert.ok(result.ageMs > BATON_TTL_MS);
77
+
78
+ const rows = db.prepare('SELECT * FROM handoff_batons').all();
79
+ assert.equal(rows.length, 0, 'expired baton should still be deleted');
80
+ });
81
+
82
+ test('consumeBaton: returns missing when no baton exists', () => {
83
+ const db = makeDb();
84
+ const result = consumeBaton(db, { projectPath: '/proj', now: 1000 });
85
+ assert.equal(result.sessionId, null);
86
+ assert.equal(result.skipReason, 'missing');
87
+ });
88
+
89
+ test('consumeBaton: scopes per project_path (does not cross-consume)', () => {
90
+ const db = makeDb();
91
+ writeBaton(db, { projectPath: '/a', sessionId: 'A', now: 1000 });
92
+ const result = consumeBaton(db, { projectPath: '/b', now: 1000 });
93
+ assert.equal(result.sessionId, null);
94
+ assert.equal(result.skipReason, 'missing');
95
+
96
+ // /a のバトンは残っているはず
97
+ const rows = db.prepare("SELECT * FROM handoff_batons WHERE project_path = '/a'").all();
98
+ assert.equal(rows.length, 1);
99
+ });
@@ -19,6 +19,7 @@ import { homedir } from 'node:os';
19
19
  const SC_COMMANDS = [
20
20
  'throughline process-turn',
21
21
  'throughline session-start',
22
+ 'throughline prompt-submit',
22
23
  // 旧コマンド(アンインストール時に除去する)
23
24
  'throughline inject-context',
24
25
  'throughline capture-tool',
@@ -35,6 +36,9 @@ const SC_HOOKS = {
35
36
  Stop: {
36
37
  hooks: [{ type: 'command', command: 'throughline process-turn' }],
37
38
  },
39
+ UserPromptSubmit: {
40
+ hooks: [{ type: 'command', command: 'throughline prompt-submit' }],
41
+ },
38
42
  };
39
43
 
40
44
  function resolveSettingsPath(args) {
@@ -102,8 +106,9 @@ export async function run(args = []) {
102
106
  console.log(` ${settingsPath}`);
103
107
  console.log('');
104
108
  console.log('有効な hooks:');
105
- console.log(' SessionStart → throughline session-start (セッション記録・記憶張り替え・L1/L2 注入)');
106
- console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
109
+ console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
110
+ console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
111
+ console.log(' UserPromptSubmit → throughline prompt-submit (/tl バトン書き込み)');
107
112
  console.log('');
108
113
  console.log(' アンインストール: throughline uninstall');
109
114
  }
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 = 6;
13
13
 
14
14
  let _db = null;
15
15
 
@@ -177,6 +177,20 @@ 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
+
180
194
  if (version < CURRENT_VERSION) {
181
195
  db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
182
196
  }
@@ -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
+ });
@@ -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
  });
@@ -1,23 +1,40 @@
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
- * 【実機確認 (2026-04-15)】
8
- * SessionStart /clear 後も source="startup" で発火する。
9
- * (Windows + VSCode 拡張では source="clear" は来ないが hook 自体は発火)
10
- * source に依存せず、毎回「前任の張り替え候補」を探して合流させる。
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 参照)。
11
12
  *
12
13
  * 役割:
13
14
  * 1. sessions テーブルに新セッションを INSERT OR IGNORE
14
- * 2. 同プロジェクト内の最新非合流セッションを新セッションに張り替え (session-merger)
15
+ * 2. バトン消費 + 指名された前任を merge (session-merger.mjs)
15
16
  * 3. 合流成立なら L1+L2 を「引き継ぎヘッダ」付きで stdout 注入
17
+ * 4. 判定結果を ~/.throughline/logs/inheritance-decision.log に記録
16
18
  */
17
19
 
18
20
  import { getDb } from './db.mjs';
19
- import { mergePredecessorInto } from './session-merger.mjs';
21
+ import { consumeBaton } from './baton.mjs';
22
+ import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
20
23
  import { buildResumeContext } from './resume-context.mjs';
24
+ import { appendFileSync, mkdirSync } from 'node:fs';
25
+ import { join, dirname } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+
28
+ function logDecision(entry) {
29
+ const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
30
+ try {
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
33
+ } catch (err) {
34
+ const msg = err instanceof Error ? err.message : 'unknown';
35
+ process.stderr.write(`[session-start:decision-log] ${msg}\n`);
36
+ }
37
+ }
21
38
 
22
39
  async function main() {
23
40
  let raw = '';
@@ -30,7 +47,7 @@ async function main() {
30
47
  });
31
48
 
32
49
  const payload = JSON.parse(raw);
33
- const { session_id, cwd } = payload;
50
+ const { session_id, cwd, source } = payload;
34
51
 
35
52
  if (!session_id) throw new Error('Missing session_id in SessionStart payload');
36
53
 
@@ -44,10 +61,31 @@ async function main() {
44
61
  VALUES (?, ?, 'active', ?, ?)`,
45
62
  ).run(session_id, projectPath, now, now);
46
63
 
47
- // 2. 前任の張り替え
48
- const mergeResult = mergePredecessorInto(db, {
49
- newSessionId: session_id,
50
- projectPath,
64
+ // 2. バトン消費
65
+ const baton = consumeBaton(db, { projectPath, now });
66
+
67
+ let mergeResult = { merged: false, skipReason: 'no_baton' };
68
+ if (baton.sessionId) {
69
+ // バトンが指す session が既に他と merge 済みなら、その合流先末端を前任とする
70
+ const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
71
+ mergeResult = mergeSpecificPredecessor(db, {
72
+ newSessionId: session_id,
73
+ predecessorId,
74
+ now,
75
+ });
76
+ }
77
+
78
+ logDecision({
79
+ ts: new Date(now).toISOString(),
80
+ source: source ?? null,
81
+ session_id,
82
+ project_path: projectPath,
83
+ baton_session_id: baton.sessionId ?? null,
84
+ baton_age_ms: baton.ageMs ?? null,
85
+ baton_skip_reason: baton.skipReason ?? null,
86
+ merged: mergeResult.merged,
87
+ merge_skip_reason: mergeResult.skipReason ?? null,
88
+ predecessor_id: mergeResult.predecessorId ?? null,
51
89
  });
52
90
 
53
91
  // 3. 合流成立なら引き継ぎヘッダ付きで注入