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 +91 -33
- package/bin/throughline.mjs +15 -7
- package/package.json +1 -1
- package/src/baton.mjs +121 -0
- package/src/baton.test.mjs +144 -0
- package/src/cli/install.mjs +7 -2
- package/src/cli/save-inflight.mjs +81 -0
- package/src/constants.mjs +1 -0
- package/src/db.mjs +26 -1
- package/src/prompt-submit.mjs +87 -0
- package/src/resume-context.mjs +99 -21
- package/src/session-merger.mjs +38 -29
- package/src/session-merger.test.mjs +72 -41
- package/src/session-start.mjs +57 -13
- package/src/transcript-reader.mjs +14 -3
- package/src/transcript-reader.test.mjs +61 -3
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
|
|
54
|
+
## Three-layer memory model (schema v7)
|
|
55
55
|
|
|
56
|
-
| Layer | Name | Where it lives | Content
|
|
57
|
-
| ----- | ---------- | --------------------- |
|
|
58
|
-
| **L1** | Skeleton | injected when old | one-line Haiku-generated summary of the turn
|
|
59
|
-
| **L2** | Body | injected when recent | user text + assistant reply, verbatim
|
|
60
|
-
| **L3** | Detail | SQLite only | tool I/O, system messages, images (on-demand
|
|
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.
|
|
64
|
-
|
|
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
|
-
## `/
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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)
|
|
102
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
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
|
|
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
|
|
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
|
package/bin/throughline.mjs
CHANGED
|
@@ -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
|
|
67
|
-
throughline uninstall
|
|
68
|
-
throughline monitor
|
|
69
|
-
throughline detail <time>
|
|
70
|
-
throughline
|
|
71
|
-
throughline
|
|
72
|
-
throughline
|
|
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
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
|
+
});
|
package/src/cli/install.mjs
CHANGED
|
@@ -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
|
|
106
|
-
console.log(' Stop
|
|
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
|
+
}
|