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 +61 -25
- package/bin/throughline.mjs +4 -0
- package/package.json +1 -1
- package/src/baton.mjs +83 -0
- package/src/baton.test.mjs +99 -0
- package/src/cli/install.mjs +7 -2
- package/src/db.mjs +15 -1
- package/src/prompt-submit.mjs +87 -0
- package/src/session-merger.mjs +38 -29
- package/src/session-merger.test.mjs +72 -41
- package/src/session-start.mjs +50 -12
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
|
|
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
|
-
##
|
|
84
|
+
## Explicit handoff via `/tl`
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
222
|
-
|
|
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
|
|
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
|
|
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
|
package/bin/throughline.mjs
CHANGED
|
@@ -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
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
|
+
});
|
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
|
}
|
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 =
|
|
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
|
+
});
|
package/src/session-merger.mjs
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* session-merger.mjs — 記憶張り替え + merged_into チェーン解決
|
|
3
3
|
*
|
|
4
4
|
* 用途:
|
|
5
|
-
* - SessionStart hook:
|
|
6
|
-
* - Stop
|
|
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.
|
|
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,
|
|
60
|
-
* @returns {{
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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('
|
|
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 =
|
|
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('
|
|
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 =
|
|
113
|
-
|
|
159
|
+
const result = mergeSpecificPredecessor(db, {
|
|
160
|
+
newSessionId: 'new',
|
|
161
|
+
predecessorId: 'newer',
|
|
162
|
+
now: 100,
|
|
163
|
+
});
|
|
114
164
|
|
|
115
|
-
|
|
116
|
-
|
|
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('
|
|
169
|
+
test('mergeSpecificPredecessor: updates new session updated_at to provided now', () => {
|
|
122
170
|
const db = makeDb();
|
|
123
|
-
|
|
124
|
-
insertSession(db, '
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
});
|
package/src/session-start.mjs
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
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 {
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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. 合流成立なら引き継ぎヘッダ付きで注入
|