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/README.md CHANGED
@@ -51,17 +51,20 @@ 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 v7)
55
55
 
56
- | Layer | Name | Where it lives | Content | Cost per turn |
57
- | ----- | ---------- | --------------------- | ---------------------------------------------------------- | ------------- |
58
- | **L1** | Skeleton | injected when old | one-line Haiku-generated summary of the turn | ~10 tok |
59
- | **L2** | Body | injected when recent | user text + assistant reply, verbatim | full natural |
60
- | **L3** | Detail | SQLite only | tool I/O, system messages, images (on-demand via command) | heavy, retired |
56
+ | Layer | Name | Where it lives | Content | Cost per turn |
57
+ | ----- | ---------- | --------------------- | --------------------------------------------------------------------- | ------------- |
58
+ | **L1** | Skeleton | injected when old | one-line Haiku-generated summary of the turn | ~10 tok |
59
+ | **L2** | Body | injected when recent | user text + assistant reply, verbatim | full natural |
60
+ | **L3** | Detail | SQLite only | tool I/O, system messages, images, **extended thinking** (on-demand) | heavy, retired |
61
61
 
62
62
  The layers are **complementary and disjoint** — nothing is duplicated across
63
- them. Thinking blocks are discarded entirely (not stored at either layer) to
64
- match the stock Claude Code behavior.
63
+ them. Extended thinking blocks are stored at L3 (`kind='thinking'`) so the
64
+ next session can see *what the previous Claude was thinking* at the moment it
65
+ was interrupted, not just what it said aloud. On `SessionStart` the thinking
66
+ of the **final turn** is injected inline above the L2 history; older thinking
67
+ remains retrievable via `throughline detail <time>`.
65
68
 
66
69
  On `SessionStart`, Throughline rebuilds the context from SQLite and
67
70
  injects it as plain text:
@@ -81,30 +84,63 @@ of tool inputs, tool outputs, and hook output captured at L3 for that turn.
81
84
 
82
85
  ---
83
86
 
84
- ## `/clear`-safe with memory rebonding
87
+ ## Explicit handoff via `/tl` (with in-flight memo)
88
+
89
+ Inheritance is **opt-in**, not automatic. When you want the next session to
90
+ pick up where this one left off, type `/tl` in the current session before you
91
+ `/clear` or open a new chat. Without `/tl`, new sessions start fresh — no
92
+ memory is carried over.
93
+
94
+ The `/tl` slash command does two things:
95
+
96
+ 1. **Writes a handoff baton** (the current `session_id`) into the
97
+ `handoff_batons` table via the `UserPromptSubmit` hook.
98
+ 2. **Asks the current Claude to write an in-flight memo.** `/tl` instructs
99
+ Claude to summarize *what it was about to do next, its current hypothesis,
100
+ open questions, and in-progress TODOs*, then pipe that Markdown into
101
+ `throughline save-inflight`, which attaches it to the baton's `memo_text`
102
+ column. This captures the "currently thinking" state that plain transcript
103
+ replay cannot preserve.
104
+
105
+ On the next `SessionStart`, the hook reads the baton, and if it is less than
106
+ **1 hour old**, merges that session's memory into the new session using a
107
+ deterministic `UPDATE session_id = ?` inside a `BEGIN IMMEDIATE` transaction.
108
+ The baton is consumed (deleted) atomically with the merge, so it cannot fire
109
+ twice. The injected resume context is reframed as **"resuming an interrupted
110
+ task"** rather than *"reading past logs"*, and the in-flight memo plus the
111
+ final turn's extended thinking appear at the top so the new Claude picks up
112
+ mid-thought.
85
113
 
86
- When you run `/clear`, the conversation transcript is discarded, but the SQLite
87
- database is untouched. On the next session start:
114
+ ```
115
+ Session A (type /tl) -----------> baton written
116
+ |
117
+ /clear |
118
+ | ▼
119
+ Session B ---- reads baton, merges A into B, deletes baton ---->
120
+ |
121
+ (type /tl again to hand off further)
122
+ ```
88
123
 
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 ターン引き継ぎ)`
124
+ Why explicit baton instead of auto-inherit:
96
125
 
97
- Each row keeps its **origin_session_id**, so memories accumulate through chains
98
- of `/clear` rather than being lost or overwritten:
126
+ - **Zero false positives.** A parallel window, a VSCode restart, or a genuine
127
+ new task in the same repo won't accidentally inherit the previous session's
128
+ memory. Only an explicit `/tl` triggers inheritance.
129
+ - **VSCode extension compatibility.** The `SessionStart` hook's `source` field
130
+ is rewritten to `"startup"` by the Claude Code VSCode extension even after
131
+ `/clear` (see [issue #49937](https://github.com/anthropics/claude-code/issues/49937)),
132
+ so source-based detection is unreliable. A user-driven baton sidesteps this.
133
+ - **Deterministic.** No time-window heuristic, no PID guessing, no ancestor
134
+ walking. The user declares intent; the hook carries it out.
135
+
136
+ Each merged row keeps its `origin_session_id`, so repeated `/tl` handoffs
137
+ accumulate memory through chains:
99
138
 
100
139
  ```
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
140
+ S1 (4 turns) --/tl,/clear--> S2 (merges S1, adds 3 turns) --/tl,/clear--> S3 (merges S2, adds 5 turns)
141
+ origin=S1×4 origin=S1×4, S2×3, S3×5
103
142
  ```
104
143
 
105
- No time-window heuristic, no PID guessing, no ancestor walking. Just a
106
- deterministic UPDATE inside a SQLite transaction.
107
-
108
144
  ---
109
145
 
110
146
  ## Multi-session token monitor
@@ -161,12 +197,26 @@ you open the folder. Drop an equivalent config into your own project's
161
197
  | `throughline uninstall` | Remove Throughline hooks from the settings file |
162
198
  | `throughline monitor [--all] [--session <id>]` | Run the multi-session token monitor |
163
199
  | `throughline detail <time>` | Retrieve L2 body text and L3 tool I/O for a turn (see below) |
200
+ | `throughline save-inflight` | Called by `/tl` to attach an in-flight memo (stdin) to the current baton |
164
201
  | `throughline doctor` | Check Node version, hook registration, DB writability, PATH |
165
202
  | `throughline status` | Print DB statistics (sessions, skeletons, bodies, details) |
166
203
  | `throughline --version` | Print the installed version |
167
204
 
205
+ Slash commands (invoked by the user in Claude Code):
206
+
207
+ | Command | What it does |
208
+ | ------------- | ----------------------------------------------------------------- |
209
+ | `/tl` | Write a handoff baton + ask Claude to save an in-flight memo for the next session |
210
+ | `/sc-detail <time>` | Retrieve L2 body text and L3 tool I/O for a past turn |
211
+
212
+ > When `/tl` triggers, Claude will call `throughline save-inflight` via its
213
+ > Bash tool. Claude Code will prompt for permission the first time; add
214
+ > `Bash(throughline save-inflight:*)` to your allowlist to skip the prompt on
215
+ > subsequent `/tl` invocations.
216
+
168
217
  Hook subcommands (invoked by Claude Code, not by humans):
169
- `session-start` (SessionStart), `process-turn` (Stop).
218
+ `session-start` (SessionStart), `process-turn` (Stop),
219
+ `prompt-submit` (UserPromptSubmit — detects `/tl` and writes baton).
170
220
 
171
221
  ### `throughline detail` — for AI, not humans
172
222
 
@@ -210,16 +260,17 @@ plain `.mjs` files.
210
260
  └── <session_id>.json Per-session activity state for the monitor
211
261
  ```
212
262
 
213
- Schema v5:
263
+ Schema v7:
214
264
 
215
265
  - `sessions` — one row per `session_id`, with `project_path` and `merged_into`
216
266
  - `skeletons` — L1 one-liners, keyed by `(session_id, origin_session_id, turn, role)`
217
267
  - `bodies` — L2 verbatim text (user + assistant), same key shape
218
- - `details` — L3 records with `kind` column (`tool_input` / `tool_output` / `system` / `image`) and `source_id` for idempotent re-processing
268
+ - `details` — L3 records with `kind` column (`tool_input` / `tool_output` / `system` / `image` / `thinking`) and `source_id` for idempotent re-processing
269
+ - `handoff_batons` — one row per `project_path`, with `session_id`, `created_at`, and `memo_text` (the in-flight memo written by `save-inflight` after `/tl`). Consumed and deleted by the next `SessionStart` if within the 1-hour TTL.
219
270
  - `injection_log` — audit trail of injection events
220
271
 
221
- All tables carry an `origin_session_id` so rebonded rows keep their lineage
222
- after a `/clear` chain.
272
+ All memory tables carry an `origin_session_id` so rebonded rows keep their
273
+ lineage across a chain of `/tl` handoffs.
223
274
 
224
275
  ---
225
276
 
@@ -287,9 +338,15 @@ unchanged here.
287
338
 
288
339
  **Database got corrupted / want a clean slate**
289
340
  Delete `~/.throughline/throughline.db` (and the `-shm` / `-wal` companion files)
290
- and `~/.throughline/state/*.json`. A fresh database with schema v4 is created on
341
+ and `~/.throughline/state/*.json`. A fresh database with schema v7 is created on
291
342
  the next hook fire.
292
343
 
344
+ **New session didn't inherit memory from the previous one**
345
+ This is the designed behavior — inheritance requires an explicit `/tl` in the
346
+ previous session. If you forgot to type it before `/clear`, the memory is still
347
+ in SQLite but won't auto-inject. You can still retrieve specific turns with
348
+ `/sc-detail <time>`.
349
+
293
350
  ---
294
351
 
295
352
  ## Development
@@ -297,7 +354,7 @@ the next hook fire.
297
354
  ```bash
298
355
  git clone https://github.com/kitepon-rgb/Throughline.git
299
356
  cd Throughline
300
- npm link # Put `throughline` on PATH
357
+ npm link # Put `throughline` on PATH (dev only)
301
358
  throughline install --project # Register hooks for this repo only
302
359
  node --test src/turn-processor.test.mjs src/session-merger.test.mjs
303
360
  ```
@@ -316,7 +373,8 @@ the folder in VS Code.
316
373
  ## Design docs
317
374
 
318
375
  - [`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.
376
+ spec** for the L1/L2/L3 differential layer model (schema v4 base + v5 L3
377
+ classification extension). Authoritative.
320
378
  - [`docs/PUBLIC_RELEASE_PLAN.md`](docs/PUBLIC_RELEASE_PLAN.md) — public release
321
379
  plan (CLI surface, package.json layout, § 0 fallback rule)
322
380
  - [`docs/archive/`](docs/archive/) — superseded design documents kept for
@@ -32,12 +32,18 @@ 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;
38
41
  case 'detail':
39
42
  (await import('../src/sc-detail.mjs')).run(rest);
40
43
  break;
44
+ case 'save-inflight':
45
+ await (await import('../src/cli/save-inflight.mjs')).run();
46
+ break;
41
47
  case 'doctor':
42
48
  await (await import('../src/cli/doctor.mjs')).run();
43
49
  break;
@@ -63,16 +69,18 @@ async function showHelp() {
63
69
  console.log(`throughline v${version}
64
70
 
65
71
  Usage:
66
- throughline install Register hooks in ~/.claude/settings.json
67
- throughline uninstall Remove hooks
68
- throughline monitor Multi-session token monitor (use --all, --session <id>)
69
- throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
70
- throughline doctor Check environment
71
- throughline status Show DB statistics
72
- throughline --version Show version
72
+ throughline install Register hooks in ~/.claude/settings.json
73
+ throughline uninstall Remove hooks
74
+ throughline monitor Multi-session token monitor (use --all, --session <id>)
75
+ throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
76
+ throughline save-inflight Save in-flight memo (stdin) to the current /tl baton
77
+ throughline doctor Check environment
78
+ throughline status Show DB statistics
79
+ throughline --version Show version
73
80
 
74
81
  Hook subcommands (called by Claude Code):
75
82
  throughline session-start SessionStart hook
76
83
  throughline process-turn Stop hook
84
+ throughline prompt-submit UserPromptSubmit hook (/tl baton writer)
77
85
  `);
78
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,121 @@
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 の既存バトンがあれば session_id / created_at のみ上書き。
28
+ * v7 で追加された memo_text は保持する(連続した /tl → save-inflight の順番で
29
+ * 呼ばれた場合に、再度 /tl を打った時点で古い memo が消えないようにする)。
30
+ *
31
+ * @param {import('node:sqlite').DatabaseSync} db
32
+ * @param {{ projectPath: string, sessionId: string, now?: number }} params
33
+ */
34
+ export function writeBaton(db, { projectPath, sessionId, now = Date.now() }) {
35
+ db.prepare(
36
+ `INSERT INTO handoff_batons (project_path, session_id, created_at)
37
+ VALUES (?, ?, ?)
38
+ ON CONFLICT(project_path) DO UPDATE SET
39
+ session_id = excluded.session_id,
40
+ created_at = excluded.created_at`,
41
+ ).run(projectPath, sessionId, now);
42
+ }
43
+
44
+ /**
45
+ * 既存バトンの memo_text を更新する。バトンが存在しない場合は NOOP。
46
+ * /tl 発動後、現行セッションの Claude が `throughline save-inflight` CLI 経由で
47
+ * 呼び出す。memo_text は Markdown 形式の「次の一手 / 現在の方針 / 未解決 /
48
+ * 進行中 TODO」をまとめたテキスト。
49
+ *
50
+ * Windows 互換: ドライブレター(`C:` / `c:`)やパス区切りの差異で
51
+ * /tl 書き込み時と save-inflight 呼び出し時の project_path が一致しない
52
+ * ケースがあるため、SQLite の COLLATE NOCASE で大小無視で照合する。
53
+ *
54
+ * @param {import('node:sqlite').DatabaseSync} db
55
+ * @param {{ projectPath: string, memoText: string, now?: number }} params
56
+ * @returns {{ updated: boolean }}
57
+ */
58
+ export function updateBatonMemo(db, { projectPath, memoText }) {
59
+ const result = db
60
+ .prepare(
61
+ `UPDATE handoff_batons SET memo_text = ? WHERE project_path = ? COLLATE NOCASE`,
62
+ )
63
+ .run(memoText, projectPath);
64
+ return { updated: (result.changes ?? 0) > 0 };
65
+ }
66
+
67
+ /**
68
+ * 同 project_path のバトンを読み出して削除する (atomic)。
69
+ *
70
+ * 戻り値:
71
+ * - { sessionId, ageMs, memoText } : バトン存在 かつ TTL 以内
72
+ * - { sessionId: null, skipReason: 'expired', ageMs } : TTL 超過で破棄
73
+ * - { sessionId: null, skipReason: 'missing' } : バトン無し
74
+ *
75
+ * memoText は /tl 後に save-inflight で書き込まれた in-flight メモ。
76
+ * 未保存なら null。
77
+ *
78
+ * @param {import('node:sqlite').DatabaseSync} db
79
+ * @param {{ projectPath: string, now?: number, ttlMs?: number }} params
80
+ * @returns {{ sessionId: string | null, ageMs?: number, memoText?: string | null, skipReason?: 'expired' | 'missing' }}
81
+ */
82
+ export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_TTL_MS }) {
83
+ db.exec('BEGIN IMMEDIATE');
84
+ try {
85
+ // Windows 互換: ドライブレターの大小差を吸収するため COLLATE NOCASE
86
+ const row = db
87
+ .prepare(
88
+ `SELECT session_id, created_at, memo_text FROM handoff_batons WHERE project_path = ? COLLATE NOCASE`,
89
+ )
90
+ .get(projectPath);
91
+
92
+ if (!row) {
93
+ db.exec('COMMIT');
94
+ return { sessionId: null, skipReason: 'missing' };
95
+ }
96
+
97
+ db.prepare('DELETE FROM handoff_batons WHERE project_path = ? COLLATE NOCASE').run(
98
+ projectPath,
99
+ );
100
+ const ageMs = now - row.created_at;
101
+
102
+ if (ageMs > ttlMs) {
103
+ db.exec('COMMIT');
104
+ return { sessionId: null, skipReason: 'expired', ageMs };
105
+ }
106
+
107
+ db.exec('COMMIT');
108
+ return {
109
+ sessionId: row.session_id,
110
+ ageMs,
111
+ memoText: row.memo_text ?? null,
112
+ };
113
+ } catch (err) {
114
+ try {
115
+ db.exec('ROLLBACK');
116
+ } catch {
117
+ // ignore
118
+ }
119
+ throw err;
120
+ }
121
+ }
@@ -0,0 +1,144 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { writeBaton, consumeBaton, updateBatonMemo, 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
+ memo_text TEXT
14
+ );
15
+ `);
16
+ return db;
17
+ }
18
+
19
+ test('BATON_TTL_MS default is 1 hour', () => {
20
+ assert.equal(BATON_TTL_MS, 60 * 60 * 1000);
21
+ });
22
+
23
+ test('writeBaton: inserts a fresh baton', () => {
24
+ const db = makeDb();
25
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
26
+ const row = db.prepare('SELECT * FROM handoff_batons').get();
27
+ assert.equal(row.project_path, '/proj');
28
+ assert.equal(row.session_id, 'S1');
29
+ assert.equal(row.created_at, 1000);
30
+ });
31
+
32
+ test('writeBaton: overwrites previous baton for same project_path', () => {
33
+ const db = makeDb();
34
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
35
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S2', now: 2000 });
36
+ const rows = db.prepare('SELECT * FROM handoff_batons').all();
37
+ assert.equal(rows.length, 1);
38
+ assert.equal(rows[0].session_id, 'S2');
39
+ assert.equal(rows[0].created_at, 2000);
40
+ });
41
+
42
+ test('writeBaton: separate project_paths coexist', () => {
43
+ const db = makeDb();
44
+ writeBaton(db, { projectPath: '/a', sessionId: 'A', now: 1000 });
45
+ writeBaton(db, { projectPath: '/b', sessionId: 'B', now: 1000 });
46
+ const rows = db.prepare('SELECT * FROM handoff_batons ORDER BY project_path').all();
47
+ assert.equal(rows.length, 2);
48
+ assert.equal(rows[0].project_path, '/a');
49
+ assert.equal(rows[1].project_path, '/b');
50
+ });
51
+
52
+ test('consumeBaton: returns sessionId and deletes when within TTL', () => {
53
+ const db = makeDb();
54
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
55
+ const result = consumeBaton(db, {
56
+ projectPath: '/proj',
57
+ now: 1000 + 30 * 60 * 1000, // 30 min後
58
+ ttlMs: BATON_TTL_MS,
59
+ });
60
+ assert.equal(result.sessionId, 'S1');
61
+ assert.equal(result.ageMs, 30 * 60 * 1000);
62
+
63
+ const rows = db.prepare('SELECT * FROM handoff_batons').all();
64
+ assert.equal(rows.length, 0, 'baton should be deleted after consumption');
65
+ });
66
+
67
+ test('consumeBaton: returns expired when age exceeds TTL, still deletes', () => {
68
+ const db = makeDb();
69
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
70
+ const result = consumeBaton(db, {
71
+ projectPath: '/proj',
72
+ now: 1000 + 2 * 60 * 60 * 1000, // 2 時間後
73
+ ttlMs: BATON_TTL_MS,
74
+ });
75
+ assert.equal(result.sessionId, null);
76
+ assert.equal(result.skipReason, 'expired');
77
+ assert.ok(result.ageMs > BATON_TTL_MS);
78
+
79
+ const rows = db.prepare('SELECT * FROM handoff_batons').all();
80
+ assert.equal(rows.length, 0, 'expired baton should still be deleted');
81
+ });
82
+
83
+ test('consumeBaton: returns missing when no baton exists', () => {
84
+ const db = makeDb();
85
+ const result = consumeBaton(db, { projectPath: '/proj', now: 1000 });
86
+ assert.equal(result.sessionId, null);
87
+ assert.equal(result.skipReason, 'missing');
88
+ });
89
+
90
+ test('consumeBaton: scopes per project_path (does not cross-consume)', () => {
91
+ const db = makeDb();
92
+ writeBaton(db, { projectPath: '/a', sessionId: 'A', now: 1000 });
93
+ const result = consumeBaton(db, { projectPath: '/b', now: 1000 });
94
+ assert.equal(result.sessionId, null);
95
+ assert.equal(result.skipReason, 'missing');
96
+
97
+ // /a のバトンは残っているはず
98
+ const rows = db.prepare("SELECT * FROM handoff_batons WHERE project_path = '/a'").all();
99
+ assert.equal(rows.length, 1);
100
+ });
101
+
102
+ test('updateBatonMemo: writes memo_text into existing baton', () => {
103
+ const db = makeDb();
104
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
105
+ const result = updateBatonMemo(db, { projectPath: '/proj', memoText: '次の一手: X' });
106
+ assert.equal(result.updated, true);
107
+ const row = db.prepare('SELECT memo_text FROM handoff_batons').get();
108
+ assert.equal(row.memo_text, '次の一手: X');
109
+ });
110
+
111
+ test('updateBatonMemo: is a NOOP when no baton exists (no throw)', () => {
112
+ const db = makeDb();
113
+ const result = updateBatonMemo(db, { projectPath: '/missing', memoText: 'hello' });
114
+ assert.equal(result.updated, false);
115
+ });
116
+
117
+ test('writeBaton: preserves memo_text when same project_path is re-batoned', () => {
118
+ const db = makeDb();
119
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
120
+ updateBatonMemo(db, { projectPath: '/proj', memoText: 'preserved memo' });
121
+ // 同じ project_path に再度 /tl を打っても memo は残る
122
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S2', now: 2000 });
123
+ const row = db.prepare('SELECT * FROM handoff_batons').get();
124
+ assert.equal(row.session_id, 'S2');
125
+ assert.equal(row.created_at, 2000);
126
+ assert.equal(row.memo_text, 'preserved memo');
127
+ });
128
+
129
+ test('consumeBaton: returns memoText when set', () => {
130
+ const db = makeDb();
131
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
132
+ updateBatonMemo(db, { projectPath: '/proj', memoText: '中断メモ' });
133
+ const result = consumeBaton(db, { projectPath: '/proj', now: 1000 + 1000 });
134
+ assert.equal(result.sessionId, 'S1');
135
+ assert.equal(result.memoText, '中断メモ');
136
+ });
137
+
138
+ test('consumeBaton: returns memoText=null when not set', () => {
139
+ const db = makeDb();
140
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
141
+ const result = consumeBaton(db, { projectPath: '/proj', now: 1000 + 1000 });
142
+ assert.equal(result.sessionId, 'S1');
143
+ assert.equal(result.memoText, null);
144
+ });
@@ -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
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * throughline save-inflight — /tl 発動後、現行 Claude が中断地点の in-flight メモを
3
+ * stdin 経由で書き込む CLI。
4
+ *
5
+ * 使い方 (Claude Code の Bash ツールから):
6
+ * throughline save-inflight <<'EOF'
7
+ * **次の一手**: ...
8
+ * **現在の方針**: ...
9
+ * **未解決の疑問**: ...
10
+ * **進行中 TODO**: ...
11
+ * EOF
12
+ *
13
+ * 動作:
14
+ * 1. stdin を UTF-8 で全部読む
15
+ * 2. 空なら exit 1 (§0 フォールバック禁止 — サイレント成功しない)
16
+ * 3. cwd に対応する handoff_batons 行の memo_text を UPDATE
17
+ * 4. バトン未登録なら updated=false で警告して exit 1
18
+ *
19
+ * 呼び出し元: [.claude/commands/tl.md] が Claude に実行させる
20
+ */
21
+
22
+ import { getDb } from '../db.mjs';
23
+ import { updateBatonMemo } from '../baton.mjs';
24
+ import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
25
+ import { join, dirname } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+
28
+ function logInflight(entry) {
29
+ const path = join(homedir(), '.throughline', 'logs', 'inflight-memo.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(`[save-inflight:log] ${msg}\n`);
36
+ }
37
+ }
38
+
39
+ export async function run() {
40
+ let memoText;
41
+ try {
42
+ memoText = readFileSync(0, 'utf8').trim();
43
+ } catch (err) {
44
+ const msg = err instanceof Error ? err.message : 'unknown';
45
+ process.stderr.write(`[save-inflight] failed to read stdin: ${msg}\n`);
46
+ process.exit(1);
47
+ return;
48
+ }
49
+
50
+ if (!memoText) {
51
+ process.stderr.write(
52
+ '[save-inflight] stdin was empty. Provide the in-flight memo via stdin (here-doc or pipe).\n',
53
+ );
54
+ process.exit(1);
55
+ return;
56
+ }
57
+
58
+ const projectPath = process.cwd();
59
+ const db = getDb();
60
+ const { updated } = updateBatonMemo(db, { projectPath, memoText });
61
+
62
+ logInflight({
63
+ ts: new Date().toISOString(),
64
+ project_path: projectPath,
65
+ memo_length: memoText.length,
66
+ baton_updated: updated,
67
+ });
68
+
69
+ if (!updated) {
70
+ process.stderr.write(
71
+ `[save-inflight] no baton found for ${projectPath}. ` +
72
+ `Run /tl first so the baton exists, then save-inflight can attach the memo.\n`,
73
+ );
74
+ process.exit(1);
75
+ return;
76
+ }
77
+
78
+ process.stdout.write(
79
+ `[throughline] in-flight memo saved (${memoText.length} chars) for next session\n`,
80
+ );
81
+ }
package/src/constants.mjs CHANGED
@@ -10,6 +10,7 @@ export const DETAIL_KIND = Object.freeze({
10
10
  TOOL_OUTPUT: 'tool_output',
11
11
  SYSTEM: 'system',
12
12
  IMAGE: 'image',
13
+ THINKING: 'thinking',
13
14
  });
14
15
 
15
16
  /** 上記の値すべての Set(未知値判定に使う) */