throughline 0.3.25 → 0.4.1
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/.claude/commands/tl.md +6 -21
- package/CHANGELOG.md +88 -0
- package/README.ja.md +46 -58
- package/README.md +85 -81
- package/bin/throughline.mjs +9 -11
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +22 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +4 -1
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +304 -0
- package/package.json +1 -1
- package/src/baton.mjs +17 -45
- package/src/baton.test.mjs +4 -41
- package/src/cli/install.mjs +2 -2
- package/src/cli/install.test.mjs +1 -3
- package/src/db-schema.test.mjs +2 -3
- package/src/db.mjs +14 -1
- package/src/hook-entrypoints.test.mjs +61 -18
- package/src/prompt-submit.mjs +24 -2
- package/src/prompt-submit.test.mjs +66 -0
- package/src/resume-context.mjs +31 -48
- package/src/resume-context.test.mjs +18 -13
- package/src/session-start.mjs +77 -21
- package/.claude/commands/tl-trim.md +0 -42
- package/src/cli/save-inflight.mjs +0 -81
|
@@ -98,33 +98,54 @@ test('prompt-submit subprocess writes a /tl baton into an isolated DB', () => {
|
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
test('
|
|
101
|
+
test('prompt-submit subprocess writes a /clear baton (specific session marker)', () => {
|
|
102
102
|
const home = makeTempHome();
|
|
103
103
|
const project = makeTempProject();
|
|
104
104
|
try {
|
|
105
|
-
const
|
|
105
|
+
const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
106
106
|
home,
|
|
107
107
|
cwd: project,
|
|
108
108
|
input: JSON.stringify({
|
|
109
|
-
session_id: '
|
|
109
|
+
session_id: 'cleared-session',
|
|
110
110
|
cwd: project,
|
|
111
|
-
prompt: '/
|
|
111
|
+
prompt: '/clear',
|
|
112
112
|
}),
|
|
113
113
|
});
|
|
114
|
-
assert.equal(baton.status, 0, baton.stderr);
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
assert.equal(result.status, 0, result.stderr);
|
|
116
|
+
|
|
117
|
+
const db = openDb(home);
|
|
118
|
+
const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
|
|
119
|
+
assert.equal(row.project_path, project);
|
|
120
|
+
assert.equal(row.session_id, 'cleared-session');
|
|
121
|
+
db.close();
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(project, { recursive: true, force: true });
|
|
124
|
+
rmSync(home, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('prompt-submit: /clear baton overwrites previous /tl baton in same project', () => {
|
|
129
|
+
const home = makeTempHome();
|
|
130
|
+
const project = makeTempProject();
|
|
131
|
+
try {
|
|
132
|
+
const tl = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
118
133
|
home,
|
|
119
134
|
cwd: project,
|
|
120
|
-
input:
|
|
135
|
+
input: JSON.stringify({ session_id: 'session-A', cwd: project, prompt: '/tl' }),
|
|
121
136
|
});
|
|
122
|
-
assert.equal(
|
|
123
|
-
|
|
137
|
+
assert.equal(tl.status, 0, tl.stderr);
|
|
138
|
+
|
|
139
|
+
const clear = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
140
|
+
home,
|
|
141
|
+
cwd: project,
|
|
142
|
+
input: JSON.stringify({ session_id: 'session-B', cwd: project, prompt: '/clear' }),
|
|
143
|
+
});
|
|
144
|
+
assert.equal(clear.status, 0, clear.stderr);
|
|
124
145
|
|
|
125
146
|
const db = openDb(home);
|
|
126
|
-
const row = db.prepare('SELECT
|
|
127
|
-
assert.equal(row.
|
|
147
|
+
const row = db.prepare('SELECT session_id FROM handoff_batons').get();
|
|
148
|
+
assert.equal(row.session_id, 'session-B', 'most recent baton write wins');
|
|
128
149
|
db.close();
|
|
129
150
|
} finally {
|
|
130
151
|
rmSync(project, { recursive: true, force: true });
|
|
@@ -132,6 +153,34 @@ test('save-inflight subprocess stores memo on the current project baton', () =>
|
|
|
132
153
|
}
|
|
133
154
|
});
|
|
134
155
|
|
|
156
|
+
test('prompt-submit: non-baton prompt does not write any baton', () => {
|
|
157
|
+
const home = makeTempHome();
|
|
158
|
+
const project = makeTempProject();
|
|
159
|
+
try {
|
|
160
|
+
const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
161
|
+
home,
|
|
162
|
+
cwd: project,
|
|
163
|
+
input: JSON.stringify({
|
|
164
|
+
session_id: 'session-X',
|
|
165
|
+
cwd: project,
|
|
166
|
+
prompt: 'hello world',
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.equal(result.status, 0, result.stderr);
|
|
171
|
+
|
|
172
|
+
if (existsSync(join(home, '.throughline', 'throughline.db'))) {
|
|
173
|
+
const db = openDb(home);
|
|
174
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM handoff_batons').get();
|
|
175
|
+
assert.equal(row.n, 0, 'a normal prompt must not create any baton');
|
|
176
|
+
db.close();
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(project, { recursive: true, force: true });
|
|
180
|
+
rmSync(home, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
135
184
|
test('session-start subprocess consumes baton and injects inherited resume context', () => {
|
|
136
185
|
const home = makeTempHome();
|
|
137
186
|
const project = makeTempProject();
|
|
@@ -157,11 +206,6 @@ test('session-start subprocess consumes baton and injects inherited resume conte
|
|
|
157
206
|
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
158
207
|
VALUES ('old-session', 'old-session', 1, 'assistant', 'old assistant body', 4, 2)`,
|
|
159
208
|
).run();
|
|
160
|
-
db.prepare(
|
|
161
|
-
`UPDATE handoff_batons
|
|
162
|
-
SET memo_text = 'handoff memo'
|
|
163
|
-
WHERE project_path = ?`,
|
|
164
|
-
).run(project);
|
|
165
209
|
db.close();
|
|
166
210
|
|
|
167
211
|
const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
@@ -175,7 +219,6 @@ test('session-start subprocess consumes baton and injects inherited resume conte
|
|
|
175
219
|
});
|
|
176
220
|
|
|
177
221
|
assert.equal(started.status, 0, started.stderr);
|
|
178
|
-
assert.match(started.stdout, /handoff memo/);
|
|
179
222
|
assert.match(started.stdout, /old assistant body/);
|
|
180
223
|
|
|
181
224
|
const after = openDb(home);
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* UserPromptSubmit hook — /tl スラッシュコマンド検出 + バトン書き込み
|
|
3
|
+
* UserPromptSubmit hook — /tl & /clear スラッシュコマンド検出 + バトン書き込み
|
|
4
4
|
*
|
|
5
5
|
* stdin: { session_id, cwd, prompt, hook_event_name, ... }
|
|
6
6
|
*
|
|
7
7
|
* 動作:
|
|
8
8
|
* - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
|
|
9
|
+
* - prompt が /clear (単独 or /clear ... 形式) で始まっていれば、現セッションの
|
|
10
|
+
* session_id をバトンに書き込んで終了。
|
|
11
|
+
* (これにより SessionStart 側の findLatestClaudePredecessor heuristic に頼らず、
|
|
12
|
+
* 確定的に「/clear が打たれたセッション」を新セッションに引き継げる。複数
|
|
13
|
+
* VSCode ウィンドウ等で「最新更新セッション = clear されたセッション」が
|
|
14
|
+
* 成立しない multi-window シナリオで誤った前任を選ばないための確定的指名)
|
|
9
15
|
* - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
|
|
10
16
|
* - 本 hook は注入を一切行わない (SessionStart の引き継ぎ注入と二重にならないため)
|
|
11
17
|
*
|
|
@@ -43,6 +49,18 @@ export function isBatonCommand(prompt) {
|
|
|
43
49
|
return false;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
/**
|
|
53
|
+
* プロンプトが /clear バトン発動コマンドか判定する。
|
|
54
|
+
* 許容: "/clear", "/clear\n", "/clear 何か" (前後空白は trim 済み前提)
|
|
55
|
+
*/
|
|
56
|
+
export function isClearCommand(prompt) {
|
|
57
|
+
if (typeof prompt !== 'string') return false;
|
|
58
|
+
const trimmed = prompt.trim();
|
|
59
|
+
if (trimmed === '/clear') return true;
|
|
60
|
+
if (trimmed.startsWith('/clear ') || trimmed.startsWith('/clear\n')) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
export async function run() {
|
|
47
65
|
let raw = '';
|
|
48
66
|
await new Promise((resolve) => {
|
|
@@ -66,7 +84,10 @@ export async function run() {
|
|
|
66
84
|
process.stderr.write(`[vscode-task] ${msg}\n`);
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
|
|
87
|
+
const tlMatch = isBatonCommand(prompt);
|
|
88
|
+
const clearMatch = !tlMatch && isClearCommand(prompt);
|
|
89
|
+
|
|
90
|
+
if (!tlMatch && !clearMatch) {
|
|
70
91
|
process.exit(0);
|
|
71
92
|
return;
|
|
72
93
|
}
|
|
@@ -87,6 +108,7 @@ export async function run() {
|
|
|
87
108
|
ts: new Date(now).toISOString(),
|
|
88
109
|
session_id,
|
|
89
110
|
project_path: projectPath,
|
|
111
|
+
trigger: tlMatch ? 'tl' : 'clear',
|
|
90
112
|
});
|
|
91
113
|
|
|
92
114
|
process.exit(0);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isBatonCommand, isClearCommand } from './prompt-submit.mjs';
|
|
4
|
+
|
|
5
|
+
test('isBatonCommand: bare /tl', () => {
|
|
6
|
+
assert.equal(isBatonCommand('/tl'), true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('isBatonCommand: /tl with trailing newline', () => {
|
|
10
|
+
assert.equal(isBatonCommand('/tl\n'), true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('isBatonCommand: /tl with leading/trailing whitespace', () => {
|
|
14
|
+
assert.equal(isBatonCommand(' /tl '), true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('isBatonCommand: /tl with arguments', () => {
|
|
18
|
+
assert.equal(isBatonCommand('/tl some memo'), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('isBatonCommand: rejects /tl-prefixed identifier', () => {
|
|
22
|
+
assert.equal(isBatonCommand('/tldr summary'), false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('isBatonCommand: rejects /clear', () => {
|
|
26
|
+
assert.equal(isBatonCommand('/clear'), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('isBatonCommand: rejects empty / non-string', () => {
|
|
30
|
+
assert.equal(isBatonCommand(''), false);
|
|
31
|
+
assert.equal(isBatonCommand(null), false);
|
|
32
|
+
assert.equal(isBatonCommand(undefined), false);
|
|
33
|
+
assert.equal(isBatonCommand(42), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('isClearCommand: bare /clear', () => {
|
|
37
|
+
assert.equal(isClearCommand('/clear'), true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('isClearCommand: /clear with trailing newline', () => {
|
|
41
|
+
assert.equal(isClearCommand('/clear\n'), true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('isClearCommand: /clear with leading/trailing whitespace', () => {
|
|
45
|
+
assert.equal(isClearCommand(' /clear '), true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('isClearCommand: /clear with arguments', () => {
|
|
49
|
+
assert.equal(isClearCommand('/clear something'), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('isClearCommand: rejects /clear-prefixed identifier', () => {
|
|
53
|
+
assert.equal(isClearCommand('/cleared'), false);
|
|
54
|
+
assert.equal(isClearCommand('/clearcache'), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('isClearCommand: rejects /tl', () => {
|
|
58
|
+
assert.equal(isClearCommand('/tl'), false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('isClearCommand: rejects empty / non-string', () => {
|
|
62
|
+
assert.equal(isClearCommand(''), false);
|
|
63
|
+
assert.equal(isClearCommand(null), false);
|
|
64
|
+
assert.equal(isClearCommand(undefined), false);
|
|
65
|
+
assert.equal(isClearCommand(42), false);
|
|
66
|
+
});
|
package/src/resume-context.mjs
CHANGED
|
@@ -1,33 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resume-context.mjs —
|
|
2
|
+
* resume-context.mjs — 引継ぎ注入テキストを組み立てる共有モジュール
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
|
-
* - session-start.mjs (
|
|
5
|
+
* - session-start.mjs (auto path / baton path どちらでも同じ注入)
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* - 注入順: ヘッダ
|
|
9
|
-
* - in-flight メモ: /tl 発動時に現行 Claude が書いた「次の一手 / 方針 / 未解決 / TODO」
|
|
10
|
-
* - 中断直前の思考: 最終ターンの assistant extended thinking (details kind='thinking')
|
|
7
|
+
* 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
|
|
8
|
+
* - 注入順: ヘッダ + Reading Contract → L1 要約 → L2 本文 → L3 refs 一覧 → Continuation Instruction
|
|
11
9
|
* - 直近 N=20 ターンは bodies から L2 全文を注入
|
|
12
10
|
* - それ以前は skeletons から L1 要約のみ注入
|
|
11
|
+
* - L3 references は具体的な `throughline detail <時刻>` コマンドの一覧として注入
|
|
12
|
+
* (本文は埋め込まず、参照のみ)
|
|
13
|
+
* - memo / thinking は注入しない (= L2 全文があれば最後の assistant turn 自体に
|
|
14
|
+
* 「次に何をしようとしていたか」が含まれる)
|
|
13
15
|
* - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
|
|
14
|
-
* - 末尾に /sc-detail <時刻> ガイドを追記
|
|
15
16
|
* - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
* L1/L2 を現在タスク用の作業文脈として読むよう誘導する。
|
|
17
|
+
* - フレーミング: 「過去の記憶」ではなく「現在進行中の作業」として読ませる
|
|
18
|
+
* (Codex 側 renderCodexRolloutMemoryPreview の写像)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { buildHandoffRecord, N_RECENT_L2 } from './handoff-record.mjs';
|
|
21
|
+
import { buildHandoffRecord, formatTime, N_RECENT_L2 } from './handoff-record.mjs';
|
|
22
22
|
|
|
23
23
|
const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
24
24
|
`## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
|
|
25
25
|
`\n` +
|
|
26
26
|
`**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
|
|
27
|
-
`- 中断直前の in-flight メモ(前セッション末尾で Claude 自身が書いた「次の一手・方針・未解決・TODO」)\n` +
|
|
28
|
-
`- 中断直前の思考 (最終ターンの extended thinking)\n` +
|
|
29
27
|
`- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
|
|
30
28
|
`- それ以前の要約 (L1)\n` +
|
|
29
|
+
`- L3 (ツール入出力・思考) の参照一覧 (本文は別途取り出す)\n` +
|
|
31
30
|
`\n` +
|
|
32
31
|
`応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
|
|
33
32
|
`作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
|
|
@@ -37,48 +36,38 @@ const ACTIVE_WORK_READING_CONTRACT =
|
|
|
37
36
|
`**読み方の契約:**\n` +
|
|
38
37
|
`- これは単なる過去ログではなく、現在進行中の作業を再開するための active work context です。\n` +
|
|
39
38
|
`- L2 は古い順に並んだ作業履歴です。後の発言・判断・TODO は前の仮説や作業方針を上書きし得ます。\n` +
|
|
40
|
-
`- すべての L2 行を現在も正しい事実として扱わず、最新の L2
|
|
41
|
-
`- 不足している tool output /
|
|
39
|
+
`- すべての L2 行を現在も正しい事実として扱わず、最新の L2 を優先して現在状態を推定してください。\n` +
|
|
40
|
+
`- 不足している tool output / 詳細根拠が必要なときだけ、L3 references の \`throughline detail <時刻>\` を使って取得してください。`;
|
|
42
41
|
|
|
43
42
|
const CONTINUATION_REMINDER =
|
|
44
|
-
'**再開指示:** 上記の L1 / L2
|
|
45
|
-
'最新の L2
|
|
43
|
+
'**再開指示:** 上記の L1 / L2 を、現在タスクに使う作業コンテキストとして扱ってください。' +
|
|
44
|
+
'最新の L2 から次の一手を決め、中断地点から続行してください。' +
|
|
46
45
|
'古い仮説や未完了 TODO は、後続の判断で上書きされ得ます。';
|
|
47
46
|
|
|
48
47
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
49
48
|
|
|
50
|
-
const FOOTER_GUIDE =
|
|
51
|
-
'---\n' +
|
|
52
|
-
'**[Claude 向け — 記憶の使い方]**\n' +
|
|
53
|
-
'上の L1 要約や L2 本文を読んで「具体的なコマンドやツール出力、ファイル内容を確認したい」と感じたら、' +
|
|
54
|
-
'推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力・thinking)を取得してください。\n' +
|
|
55
|
-
'- 単一時刻: `throughline detail 14:23:05`\n' +
|
|
56
|
-
'- 時刻範囲: `throughline detail 14:23-14:30`\n' +
|
|
57
|
-
'\n' +
|
|
58
|
-
'返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
|
|
59
|
-
'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
|
|
60
|
-
|
|
61
49
|
/**
|
|
62
|
-
* L1+L2 注入テキストを組み立てる。
|
|
50
|
+
* L1 + L2 + L3 references 注入テキストを組み立てる。
|
|
63
51
|
*
|
|
64
52
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
65
53
|
* @param {{
|
|
66
54
|
* sessionId: string,
|
|
67
55
|
* isInheritance: boolean,
|
|
68
56
|
* excludeOriginId?: string | null,
|
|
69
|
-
* inflightMemo?: string | null,
|
|
57
|
+
* inflightMemo?: string | null, // 互換のため受け取るが新仕様では使用しない
|
|
70
58
|
* }} params
|
|
71
59
|
* @returns {string | null}
|
|
72
60
|
*/
|
|
73
61
|
export function buildResumeContext(
|
|
74
62
|
db,
|
|
75
|
-
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
|
|
63
|
+
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
|
|
76
64
|
) {
|
|
65
|
+
// handoff-record は Codex 側でも使うので signature 維持。inflightMemo / latestThinking は
|
|
66
|
+
// 新仕様の注入テキストには使わない (L2 全文で十分という判断)。
|
|
77
67
|
const record = buildHandoffRecord(db, {
|
|
78
68
|
sessionId,
|
|
79
69
|
isInheritance,
|
|
80
70
|
excludeOriginId,
|
|
81
|
-
inflightMemo,
|
|
82
71
|
});
|
|
83
72
|
if (!record) return null;
|
|
84
73
|
|
|
@@ -88,20 +77,6 @@ export function buildResumeContext(
|
|
|
88
77
|
: NORMAL_HEADER;
|
|
89
78
|
const lines = [header];
|
|
90
79
|
|
|
91
|
-
if (record.memory.inflightMemo) {
|
|
92
|
-
lines.push('');
|
|
93
|
-
lines.push('### 中断直前の in-flight メモ(前セッションの Claude 自身による要約)');
|
|
94
|
-
lines.push(record.memory.inflightMemo);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (record.memory.latestThinking.length > 0) {
|
|
98
|
-
lines.push('');
|
|
99
|
-
lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
|
|
100
|
-
for (const r of record.memory.latestThinking) {
|
|
101
|
-
lines.push(`[${r.time}] ${r.text}`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
80
|
if (record.memory.l1Summaries.length > 0) {
|
|
106
81
|
lines.push('');
|
|
107
82
|
lines.push('### それ以前の要約 (L1)');
|
|
@@ -121,8 +96,16 @@ export function buildResumeContext(
|
|
|
121
96
|
}
|
|
122
97
|
}
|
|
123
98
|
|
|
124
|
-
|
|
125
|
-
|
|
99
|
+
if (record.references.l3.length > 0) {
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push('### L3 詳細参照 (本文は注入されていません)');
|
|
102
|
+
lines.push('必要なときだけ以下のコマンドで取得してください:');
|
|
103
|
+
for (const ref of record.references.l3) {
|
|
104
|
+
const time = formatTime(ref.createdAt);
|
|
105
|
+
lines.push(`- [${time}] ${ref.kind}: \`throughline detail ${time}\``);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
126
109
|
lines.push('');
|
|
127
110
|
lines.push(CONTINUATION_REMINDER);
|
|
128
111
|
|
|
@@ -65,7 +65,7 @@ function insertThinking(db, row) {
|
|
|
65
65
|
).run(row.session, row.origin, row.turn, row.text, row.createdAt, row.sourceId);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
test('buildResumeContext: inheritance output order is
|
|
68
|
+
test('buildResumeContext: inheritance output order is L1 -> L2 -> L3 refs -> reminder (no memo / no thinking)', () => {
|
|
69
69
|
const db = makeDb();
|
|
70
70
|
|
|
71
71
|
insertSkeleton(db, {
|
|
@@ -92,6 +92,7 @@ test('buildResumeContext: inheritance output order is memo -> thinking -> L1 ->
|
|
|
92
92
|
text: 'recent assistant body',
|
|
93
93
|
createdAt: 2100,
|
|
94
94
|
});
|
|
95
|
+
// thinking は DB に書かれても、新仕様では注入テキストに出ない
|
|
95
96
|
insertThinking(db, {
|
|
96
97
|
session: 'new',
|
|
97
98
|
origin: 'old',
|
|
@@ -104,32 +105,36 @@ test('buildResumeContext: inheritance output order is memo -> thinking -> L1 ->
|
|
|
104
105
|
const text = buildResumeContext(db, {
|
|
105
106
|
sessionId: 'new',
|
|
106
107
|
isInheritance: true,
|
|
107
|
-
inflightMemo
|
|
108
|
+
// inflightMemo は互換のため受け取れるが、新仕様では注入テキストに使わない
|
|
109
|
+
inflightMemo: '**Next**: keep going (should NOT appear)',
|
|
108
110
|
});
|
|
109
111
|
|
|
110
112
|
assert.ok(text);
|
|
111
113
|
assert.match(text, /^## Throughline: 中断した作業の再開/);
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
// 新仕様: memo / thinking セクションは注入されない
|
|
116
|
+
assert.ok(text.indexOf('### 中断直前の in-flight メモ') < 0, 'memo section should not be injected');
|
|
117
|
+
assert.ok(text.indexOf('### 中断直前の思考') < 0, 'thinking section should not be injected');
|
|
118
|
+
assert.ok(!text.includes('**Next**: keep going'), 'inflightMemo content should be ignored');
|
|
119
|
+
assert.ok(!text.includes('latest thinking block'), 'thinking text should not appear');
|
|
120
|
+
|
|
121
|
+
// 注入される順序: L1 → L2 → L3 refs → 再開指示
|
|
115
122
|
const l1Idx = text.indexOf('### それ以前の要約 (L1)');
|
|
116
123
|
const l2Idx = text.indexOf('### 現在進行中の作業履歴 (L2 / active work thread)');
|
|
117
|
-
const
|
|
124
|
+
const l3Idx = text.indexOf('### L3 詳細参照');
|
|
125
|
+
const reminderIdx = text.indexOf('**再開指示:**');
|
|
118
126
|
|
|
119
|
-
assert.ok(
|
|
120
|
-
assert.ok(thinkingIdx > memoIdx, 'thinking should follow in-flight memo');
|
|
121
|
-
assert.ok(l1Idx > thinkingIdx, 'L1 should follow thinking');
|
|
127
|
+
assert.ok(l1Idx > 0, 'L1 section should be present');
|
|
122
128
|
assert.ok(l2Idx > l1Idx, 'L2 should follow L1');
|
|
123
|
-
assert.ok(
|
|
129
|
+
assert.ok(l3Idx > l2Idx, 'L3 references should follow L2');
|
|
130
|
+
assert.ok(reminderIdx > l3Idx, 'continuation reminder should follow L3 refs');
|
|
124
131
|
|
|
125
|
-
assert.ok(text.includes('**Next**: keep going'));
|
|
126
|
-
assert.ok(text.includes('latest thinking block'));
|
|
127
132
|
assert.ok(text.includes('older L1 summary'));
|
|
128
133
|
assert.ok(text.includes('[user]: recent user body'));
|
|
129
134
|
assert.ok(text.includes('[assistant]: recent assistant body'));
|
|
135
|
+
// L3 refs は detail コマンドのみ (本文は注入しない)
|
|
136
|
+
assert.match(text, /throughline detail \d{2}:\d{2}:\d{2}/);
|
|
130
137
|
assert.match(text, /単なる過去ログではなく、現在進行中の作業/);
|
|
131
|
-
assert.match(text, /後の行ほど現在状態に近く/);
|
|
132
|
-
assert.match(text, /上記の L1 \/ L2 \/ thinking \/ in-flight メモを、現在タスクに使う作業コンテキスト/);
|
|
133
138
|
});
|
|
134
139
|
|
|
135
140
|
test('buildResumeContext: returns null when no memory rows or inflight memo exist', () => {
|
package/src/session-start.mjs
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
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
|
-
* baton
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* 【引き継ぎ条件 (2 経路)】 docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md
|
|
8
|
+
*
|
|
9
|
+
* 1. baton path: ユーザーが旧セッションで `/tl` を打つと UserPromptSubmit hook が
|
|
10
|
+
* handoff_batons に session_id を書く。本 hook が TTL 1 時間以内に消費して
|
|
11
|
+
* 前任を merge + 引継ぎ stdout 注入。`source` 値関係なく発火。
|
|
12
|
+
* 2. auto path: `source='clear'` かつ env `THROUGHLINE_DISABLE_AUTO_HANDOFF` が
|
|
13
|
+
* `'1'` でない場合、同 project_path の最新 Claude unmerged session を
|
|
14
|
+
* 自動 merge して注入。
|
|
15
|
+
*
|
|
16
|
+
* 両方同時成立はしない (consumeBaton が先発、baton ありなら baton path、
|
|
17
|
+
* なければ source 判定)。env で OFF にしたユーザーは `/tl` を打ってから
|
|
18
|
+
* 新セッションスタートで baton path を使う。
|
|
12
19
|
*
|
|
13
20
|
* 役割:
|
|
14
21
|
* 1. sessions テーブルに新セッションを INSERT OR IGNORE
|
|
15
|
-
* 2.
|
|
16
|
-
* 3.
|
|
17
|
-
* 4.
|
|
22
|
+
* 2. baton path 判定 (consumeBaton + mergeSpecificPredecessor)
|
|
23
|
+
* 3. baton 無し かつ source='clear' かつ env disable 無し → auto path 判定
|
|
24
|
+
* 4. 合流成立なら curated memory (L1+L2+L3 refs) を「引き継ぎヘッダ」付きで stdout 注入
|
|
25
|
+
* 5. 判定結果を ~/.throughline/logs/inheritance-decision.log に記録
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
28
|
import { getDb } from './db.mjs';
|
|
@@ -27,6 +35,37 @@ import { join, dirname } from 'node:path';
|
|
|
27
35
|
import { homedir } from 'node:os';
|
|
28
36
|
import { pathToFileURL } from 'node:url';
|
|
29
37
|
|
|
38
|
+
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
39
|
+
|
|
40
|
+
function isAutoHandoffDisabled(env) {
|
|
41
|
+
return env[ENV_DISABLE_AUTO_HANDOFF] === '1';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 同 project_path の最新 Claude unmerged session を返す (auto path 用 predecessor)。
|
|
46
|
+
* Codex session (`codex:*`) と現セッション自身は除外。
|
|
47
|
+
*
|
|
48
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
49
|
+
* @param {string} projectPath
|
|
50
|
+
* @param {string} currentSessionId
|
|
51
|
+
* @returns {{ session_id: string } | null}
|
|
52
|
+
*/
|
|
53
|
+
function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
|
|
54
|
+
return (
|
|
55
|
+
db
|
|
56
|
+
.prepare(
|
|
57
|
+
`SELECT session_id FROM sessions
|
|
58
|
+
WHERE lower(project_path) = lower(?)
|
|
59
|
+
AND merged_into IS NULL
|
|
60
|
+
AND session_id != ?
|
|
61
|
+
AND session_id NOT LIKE 'codex:%'
|
|
62
|
+
ORDER BY updated_at DESC
|
|
63
|
+
LIMIT 1`,
|
|
64
|
+
)
|
|
65
|
+
.get(projectPath, currentSessionId) ?? null
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
function logDecision(entry) {
|
|
31
70
|
const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
|
|
32
71
|
try {
|
|
@@ -57,11 +96,7 @@ export async function run() {
|
|
|
57
96
|
const db = getDb();
|
|
58
97
|
const now = Date.now();
|
|
59
98
|
|
|
60
|
-
// 0. VSCode
|
|
61
|
-
// Stop hook 側にも同じ呼び出しがあるが、Stop が発火しない環境(応答中断・IDE 挙動差)
|
|
62
|
-
// でも SessionStart は必ず走るので、新規プロジェクトでの自動起動を確実化する保険。
|
|
63
|
-
// 冪等性は ensureMonitorTaskFile 側で保証されており、Stop/UserPromptSubmit と重複呼び
|
|
64
|
-
// 出しされても安全。
|
|
99
|
+
// 0. VSCode 用 tasks.json 自動プロビジョニング (冪等)
|
|
65
100
|
try {
|
|
66
101
|
ensureMonitorTaskFile({ cwd: projectPath, env: process.env });
|
|
67
102
|
} catch (err) {
|
|
@@ -75,18 +110,40 @@ export async function run() {
|
|
|
75
110
|
VALUES (?, ?, 'active', ?, ?)`,
|
|
76
111
|
).run(session_id, projectPath, now, now);
|
|
77
112
|
|
|
78
|
-
// 2.
|
|
113
|
+
// 2. baton 消費
|
|
79
114
|
const baton = consumeBaton(db, { projectPath, now });
|
|
80
115
|
|
|
81
|
-
|
|
116
|
+
// 3. 引継ぎ判定
|
|
117
|
+
let mergeResult = { merged: false, skipReason: 'no_trigger' };
|
|
118
|
+
let triggeredPath = null;
|
|
119
|
+
const autoDisabled = isAutoHandoffDisabled(process.env);
|
|
120
|
+
|
|
82
121
|
if (baton.sessionId) {
|
|
83
|
-
//
|
|
122
|
+
// baton path
|
|
123
|
+
triggeredPath = 'baton';
|
|
84
124
|
const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
|
|
85
125
|
mergeResult = mergeSpecificPredecessor(db, {
|
|
86
126
|
newSessionId: session_id,
|
|
87
127
|
predecessorId,
|
|
88
128
|
now,
|
|
89
129
|
});
|
|
130
|
+
} else if (source === 'clear' && !autoDisabled) {
|
|
131
|
+
// auto path: 同 project の最新 Claude unmerged session を自動 predecessor にする
|
|
132
|
+
triggeredPath = 'auto';
|
|
133
|
+
const predRow = findLatestClaudePredecessor(db, projectPath, session_id);
|
|
134
|
+
if (predRow?.session_id) {
|
|
135
|
+
const { target: predecessorId } = resolveMergeTarget(db, predRow.session_id);
|
|
136
|
+
mergeResult = mergeSpecificPredecessor(db, {
|
|
137
|
+
newSessionId: session_id,
|
|
138
|
+
predecessorId,
|
|
139
|
+
now,
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
mergeResult = { merged: false, skipReason: 'no_predecessor' };
|
|
143
|
+
}
|
|
144
|
+
} else if (source === 'clear' && autoDisabled) {
|
|
145
|
+
triggeredPath = 'auto-disabled';
|
|
146
|
+
mergeResult = { merged: false, skipReason: 'auto_handoff_disabled' };
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
logDecision({
|
|
@@ -94,22 +151,21 @@ export async function run() {
|
|
|
94
151
|
source: source ?? null,
|
|
95
152
|
session_id,
|
|
96
153
|
project_path: projectPath,
|
|
154
|
+
triggered_path: triggeredPath,
|
|
155
|
+
auto_handoff_disabled: autoDisabled,
|
|
97
156
|
baton_session_id: baton.sessionId ?? null,
|
|
98
157
|
baton_age_ms: baton.ageMs ?? null,
|
|
99
158
|
baton_skip_reason: baton.skipReason ?? null,
|
|
100
|
-
baton_has_memo: Boolean(baton.memoText),
|
|
101
159
|
merged: mergeResult.merged,
|
|
102
160
|
merge_skip_reason: mergeResult.skipReason ?? null,
|
|
103
161
|
predecessor_id: mergeResult.predecessorId ?? null,
|
|
104
162
|
});
|
|
105
163
|
|
|
106
|
-
//
|
|
107
|
-
// バトンに付いていた in-flight メモも併せて先頭セクションに注入する
|
|
164
|
+
// 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
|
|
108
165
|
if (mergeResult.merged) {
|
|
109
166
|
const text = buildResumeContext(db, {
|
|
110
167
|
sessionId: session_id,
|
|
111
168
|
isInheritance: true,
|
|
112
|
-
inflightMemo: baton.memoText ?? null,
|
|
113
169
|
});
|
|
114
170
|
if (text) {
|
|
115
171
|
process.stdout.write(text + '\n');
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: 現セッションの context trim 計画を dry-run する (Throughline)
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
Throughline の context trim は、現時点では dry-run のみ有効です。自動 `/rewind` や自動 rollback / inject は、host primitive の検証が完了するまで実行してはいけません。
|
|
6
|
-
|
|
7
|
-
まず、今この時点の「現在作業」を自分の文脈から思い出し、以下 4 項目を Markdown で整理してください。
|
|
8
|
-
|
|
9
|
-
- **次の一手**: 今まさに何をやろうとしていたか(1-3 文、具体的に)
|
|
10
|
-
- **現在の方針 / 仮説**: 追っているバグの原因、設計の方向性、調査中の観点など
|
|
11
|
-
- **未解決の疑問**: 判断保留中の論点
|
|
12
|
-
- **進行中 TODO**: 完了済みを除いた現行 TODO
|
|
13
|
-
|
|
14
|
-
その Markdown を stdin として、次のコマンドを Bash ツールで実行し、結果をユーザーにそのまま要約してください。
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
throughline trim --dry-run --host claude --memo-stdin $ARGUMENTS <<'EOF'
|
|
18
|
-
**次の一手**: ...
|
|
19
|
-
**現在の方針 / 仮説**: ...
|
|
20
|
-
**未解決の疑問**: ...
|
|
21
|
-
**進行中 TODO**:
|
|
22
|
-
- ...
|
|
23
|
-
EOF
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
出力には以下が含まれます。
|
|
27
|
-
|
|
28
|
-
- 現セッションで Throughline が捕捉している turn 数
|
|
29
|
-
- keep-recent 設定
|
|
30
|
-
- rollback 候補 turn 数
|
|
31
|
-
- host が自動 rollback / inject 対応かどうか
|
|
32
|
-
- rollback 後に戻す curated memory preview
|
|
33
|
-
- rollback 後に「今やっている作業」として戻す current-work memo
|
|
34
|
-
|
|
35
|
-
`$ARGUMENTS` には `--keep-recent 20`、`--all`、`--session <id>` を渡せます。
|
|
36
|
-
|
|
37
|
-
重要:
|
|
38
|
-
|
|
39
|
-
- dry-run の結果だけで自動 rollback しないでください。
|
|
40
|
-
- Claude の既存 `/tl` baton handoff は変更しません。
|
|
41
|
-
- L1/L2 だけでは「今やっている作業」として認識されにくいので、current-work memo を省略しないでください。
|
|
42
|
-
- `Throughline Trim Dry-run` の `Automatic execution allowed` が `no` の場合は、実行手順ではなく「現状は dry-run / 手動案内まで」と説明してください。
|