throughline 0.4.12 → 0.6.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.
Files changed (45) hide show
  1. package/.codex-sidecar.yml +5 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.ja.md +37 -21
  4. package/README.md +47 -26
  5. package/docs/00_overview.md +34 -0
  6. package/docs/{L1_L2_L3_REDESIGN.md → 01_l1_l2_l3_redesign.md} +3 -3
  7. package/docs/{THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md → 02_clear_auto_handoff_plan.md} +6 -6
  8. package/docs/{INHERITANCE_ON_CLEAR_ONLY.md → 03_inheritance_on_clear_only.md} +3 -3
  9. package/docs/{PUBLIC_RELEASE_PLAN.md → 04_public_release_plan.md} +3 -3
  10. package/docs/{THROUGHLINE_CODEX_FIRST_ROADMAP.md → 05_codex_first_roadmap.md} +9 -9
  11. package/docs/{THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md → 06_codex_trim_rollback_fix_plan.md} +6 -6
  12. package/docs/{THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md → 07_codex_trim_implementation_plan.md} +10 -10
  13. package/docs/{THROUGHLINE_CODEX_DUAL_SUPPORT.md → 08_codex_dual_support.md} +8 -8
  14. package/docs/{throughline-rollback-context-trim-insight.md → 09_rollback_context_trim_insight.md} +5 -5
  15. package/docs/10_transcript_injection_plan.md +446 -0
  16. package/docs/{THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md → 11_codex_monitor_implementation_plan.md} +1 -1
  17. package/docs/12_desktop_clear_handoff_plan.md +215 -0
  18. package/docs/adr/0001-claude-primary-codex-adapter.md +22 -0
  19. package/docs/archive/README.md +3 -3
  20. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +3 -3
  21. package/package.json +2 -1
  22. package/rag/01-hooks/raw/hooks-reference-extract.md +250 -0
  23. package/rag/01-hooks/raw/session-end-reasons.md +21 -0
  24. package/rag/02-messages-api/raw/messages-api-extract.md +126 -0
  25. package/rag/03-settings/raw/sessions-extract.md +64 -0
  26. package/rag/04-skills/raw/initialUserMessage-investigation.md +101 -0
  27. package/rag/INDEX.md +164 -0
  28. package/src/baton.mjs +2 -2
  29. package/src/db.mjs +2 -2
  30. package/src/hook-entrypoints.test.mjs +390 -0
  31. package/src/package-files.test.mjs +1 -0
  32. package/src/prompt-submit.mjs +132 -5
  33. package/src/resume-context.mjs +23 -6
  34. package/src/resume-context.test.mjs +21 -5
  35. package/src/session-merger.mjs +1 -1
  36. package/src/session-start.mjs +155 -14
  37. package/src/spike-transcript-writer.mjs +196 -0
  38. package/src/spike-transcript-writer.test.mjs +298 -0
  39. package/src/state-file.mjs +1 -1
  40. package/src/token-monitor.mjs +1 -1
  41. package/src/transcript-reader.mjs +71 -0
  42. package/src/turn-backfill.mjs +131 -0
  43. package/src/turn-backfill.test.mjs +213 -0
  44. package/src/turn-processor.mjs +28 -40
  45. /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
@@ -5,7 +5,7 @@
5
5
  * - SessionStart hook: バトンで指名された旧セッションを mergeSpecificPredecessor で新セッションに張り替え
6
6
  * - Stop hook: resolveMergeTarget で「入力 session_id → 実書き込み先」を解決
7
7
  *
8
- * 設計背景: docs/SESSION_LINKING_DESIGN.md, docs/INHERITANCE_ON_CLEAR_ONLY.md (バトン方式採用)
8
+ * 設計背景: docs/archive/SESSION_LINKING_DESIGN.md, docs/03_inheritance_on_clear_only.md (バトン方式採用)
9
9
  *
10
10
  * 旧実装 (案 D: 時間差ヒューリスティック / 自動前任選択) は撤去済み。
11
11
  * 引き継ぎはユーザーが /tl を打って書いたバトンによる明示的指名のみで発火する。
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * stdin: { session_id, source, cwd, transcript_path, hook_event_name }
6
6
  *
7
- * 【引き継ぎ条件 (2 経路)】 docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md
7
+ * 【引き継ぎ条件 (2 経路)】 docs/02_clear_auto_handoff_plan.md
8
8
  *
9
9
  * 1. baton path: ユーザーが旧セッションで `/tl` を打つと UserPromptSubmit hook が
10
10
  * handoff_batons に session_id を書く。本 hook が TTL 1 時間以内に消費して
@@ -28,13 +28,37 @@
28
28
  import { getDb } from './db.mjs';
29
29
  import { consumeBaton } from './baton.mjs';
30
30
  import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
31
+ import { backfillBodies, deriveTranscriptPath, logBackfill } from './turn-backfill.mjs';
31
32
  import { buildResumeContext } from './resume-context.mjs';
33
+ import { readAllSessionStates } from './state-file.mjs';
32
34
  import { ensureMonitorTaskFile } from './vscode-task.mjs';
33
- import { appendFileSync, mkdirSync } from 'node:fs';
35
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
36
+ import { randomBytes } from 'node:crypto';
34
37
  import { join, dirname } from 'node:path';
35
38
  import { homedir } from 'node:os';
36
39
  import { pathToFileURL } from 'node:url';
37
40
 
41
+ // SPIKE ONLY — Phase 0-2 / 0-4 検証用。marker file 削除で無効化される。
42
+ // docs/10_transcript_injection_plan.md §3 Phase 0-2 参照。
43
+ const SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-inject.flag');
44
+
45
+ // Phase 0-6: initialUserMessage が interactive モードで効くか実機検証する experimental switch。
46
+ // flag 存在時、SessionStart hook は plain stdout の代わりに JSON 出力に切り替わり、
47
+ // hookSpecificOutput.initialUserMessage に tracer 入りメッセージを乗せる。
48
+ // openclaude の OSS 実装では「headless 専用」と記載されているが、real CC の挙動は未確認。
49
+ const INITIAL_USER_MESSAGE_TEST_FLAG = join(homedir(), '.throughline', 'initial-user-message-test.flag');
50
+
51
+ function logInitialUserMessageTest(entry) {
52
+ const path = join(homedir(), '.throughline', 'logs', 'initial-user-message-test.log');
53
+ try {
54
+ mkdirSync(dirname(path), { recursive: true });
55
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
56
+ } catch (err) {
57
+ const msg = err instanceof Error ? err.message : 'unknown';
58
+ process.stderr.write(`[session-start:initialUserMessage-test-log] ${msg}\n`);
59
+ }
60
+ }
61
+
38
62
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
39
63
 
40
64
  function isAutoHandoffDisabled(env) {
@@ -88,7 +112,7 @@ export async function run() {
88
112
  });
89
113
 
90
114
  const payload = JSON.parse(raw);
91
- const { session_id, cwd, source } = payload;
115
+ const { session_id, cwd, source, transcript_path } = payload;
92
116
 
93
117
  if (!session_id) throw new Error('Missing session_id in SessionStart payload');
94
118
 
@@ -146,11 +170,137 @@ export async function run() {
146
170
  mergeResult = { merged: false, skipReason: 'auto_handoff_disabled' };
147
171
  }
148
172
 
173
+ // 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
174
+ // 順序厳守: stdout flush を先に完了させてから spike 分岐へ進む。
175
+ // spike が throw しても stdout は既に attachment に保存されている。
176
+ //
177
+ // Phase 0-6: initialUserMessage test flag 存在時は JSON 出力に切り替え、
178
+ // initialUserMessage が interactive モードで messages[] に乗るか実機検証する。
179
+ if (mergeResult.merged) {
180
+ const predecessorId = mergeResult.predecessorId;
181
+ // /clear 直前ターンの取りこぼしを注入前に回収する。前任の transcript path は project path
182
+ // から決定的に導出する — state ファイルは Stop 不発の前任(まさに回収したい事例)では存在しないため補助。
183
+ const derivedTranscriptPath = deriveTranscriptPath(projectPath, predecessorId);
184
+ const stateTranscriptPath = readAllSessionStates().find(
185
+ (state) => state.sessionId === predecessorId,
186
+ )?.transcriptPath;
187
+ const predecessorTranscriptPath = existsSync(derivedTranscriptPath)
188
+ ? derivedTranscriptPath
189
+ : stateTranscriptPath && existsSync(stateTranscriptPath)
190
+ ? stateTranscriptPath
191
+ : null;
192
+
193
+ if (predecessorTranscriptPath) {
194
+ try {
195
+ const backfill = backfillBodies(db, {
196
+ targetSessionId: session_id,
197
+ originSessionId: predecessorId,
198
+ transcriptPath: predecessorTranscriptPath,
199
+ now,
200
+ });
201
+ logBackfill({
202
+ ts: new Date(now).toISOString(),
203
+ hook: 'session-start',
204
+ session_id,
205
+ target: session_id,
206
+ origin: predecessorId,
207
+ transcript_path: predecessorTranscriptPath,
208
+ groups: backfill.groups,
209
+ inserted_turns: backfill.insertedTurns,
210
+ skipped_existing: backfill.skippedExisting,
211
+ });
212
+ } catch (err) {
213
+ const message = err instanceof Error ? err.message : String(err);
214
+ process.stderr.write(`[session-start:backfill] ${message}\n`);
215
+ logBackfill({
216
+ ts: new Date(now).toISOString(),
217
+ hook: 'session-start',
218
+ session_id,
219
+ target: session_id,
220
+ origin: predecessorId,
221
+ transcript_path: predecessorTranscriptPath,
222
+ error: message,
223
+ });
224
+ }
225
+ } else {
226
+ logBackfill({
227
+ ts: new Date(now).toISOString(),
228
+ hook: 'session-start',
229
+ session_id,
230
+ target: session_id,
231
+ origin: predecessorId,
232
+ transcript_path: null,
233
+ skip_reason: 'no_transcript_path',
234
+ });
235
+ }
236
+
237
+ const text = buildResumeContext(db, {
238
+ sessionId: session_id,
239
+ isInheritance: true,
240
+ });
241
+ if (existsSync(INITIAL_USER_MESSAGE_TEST_FLAG)) {
242
+ // TEST MODE: emit JSON with initialUserMessage tracer. Plain stdout は出さない
243
+ // (= 通常 Throughline 案内文無し)。テスト 1 回限定。flag を削除すれば即復帰。
244
+ const tracer = randomBytes(4).toString('hex');
245
+ const initialMessage =
246
+ `[initial-user-tracer: ${tracer}]\n\n` +
247
+ `This text is being delivered via the SessionStart hook's ` +
248
+ `hookSpecificOutput.initialUserMessage field. If you can quote the 8-hex ` +
249
+ `tracer above when asked, it means initialUserMessage IS consumed in ` +
250
+ `interactive mode (not headless-only as openclaude documents).`;
251
+ const jsonOutput = JSON.stringify({
252
+ hookSpecificOutput: {
253
+ hookEventName: 'SessionStart',
254
+ initialUserMessage: initialMessage,
255
+ },
256
+ });
257
+ process.stdout.write(jsonOutput + '\n');
258
+ logInitialUserMessageTest({
259
+ ts: new Date(now).toISOString(),
260
+ session_id,
261
+ tracer,
262
+ mode: 'json-initial-user-message-only',
263
+ had_resume_context: Boolean(text),
264
+ });
265
+ } else if (text) {
266
+ process.stdout.write(text + '\n');
267
+ }
268
+ }
269
+
270
+ // 5. SPIKE: marker file あり + merge 成立 + transcript_path あり の 3 条件で
271
+ // L2 を user/assistant role 付きで transcript_path にも append する。
272
+ // 本実装ではない (docs/10_transcript_injection_plan.md Phase 0-2)。
273
+ //
274
+ // tracer: 末尾 assistant 行に stdout 注入には含まれない一意トークンを付与する。
275
+ // 次の /clear 後に Claude が tracer を再現できれば JSONL 経路はモデル可視。
276
+ let spikeResult = null;
277
+ const spikeMarkerExists = existsSync(SPIKE_MARKER_PATH);
278
+ if (spikeMarkerExists && mergeResult.merged && transcript_path) {
279
+ try {
280
+ const { spikeInject, generateSpikeTracer } = await import('./spike-transcript-writer.mjs');
281
+ const tracer = generateSpikeTracer();
282
+ spikeResult = spikeInject({
283
+ db,
284
+ targetJsonlPath: transcript_path,
285
+ newSessionId: session_id,
286
+ cwd: projectPath,
287
+ version: payload.version ?? '2.1.145',
288
+ gitBranch: payload.gitBranch ?? 'main',
289
+ tracer,
290
+ });
291
+ } catch (err) {
292
+ const msg = err instanceof Error ? err.message : 'unknown';
293
+ process.stderr.write(`[spike-inject] ${msg}\n`);
294
+ spikeResult = { error: msg };
295
+ }
296
+ }
297
+
149
298
  logDecision({
150
299
  ts: new Date(now).toISOString(),
151
300
  source: source ?? null,
152
301
  session_id,
153
302
  project_path: projectPath,
303
+ transcript_path: transcript_path ?? null,
154
304
  triggered_path: triggeredPath,
155
305
  auto_handoff_disabled: autoDisabled,
156
306
  baton_session_id: baton.sessionId ?? null,
@@ -159,19 +309,10 @@ export async function run() {
159
309
  merged: mergeResult.merged,
160
310
  merge_skip_reason: mergeResult.skipReason ?? null,
161
311
  predecessor_id: mergeResult.predecessorId ?? null,
312
+ spike_marker_exists: spikeMarkerExists,
313
+ spike_result: spikeResult,
162
314
  });
163
315
 
164
- // 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
165
- if (mergeResult.merged) {
166
- const text = buildResumeContext(db, {
167
- sessionId: session_id,
168
- isInheritance: true,
169
- });
170
- if (text) {
171
- process.stdout.write(text + '\n');
172
- }
173
- }
174
-
175
316
  process.exit(0);
176
317
  }
177
318
 
@@ -0,0 +1,196 @@
1
+ /**
2
+ * SPIKE ONLY — Phase 0-2 / 0-4 検証用。本実装ではない。
3
+ *
4
+ * docs/10_transcript_injection_plan.md Phase 0-2 で
5
+ * 「`/clear` 直後の SessionStart hook 内で transcript_path に L2 を user/assistant
6
+ * role 付きで append すると、Claude が次の short prompt の文脈として読むか」を実機検証する。
7
+ *
8
+ * 本実装ではない理由:
9
+ * - text content のみ復元 (tool_use / tool_result / thinking は割愛)
10
+ * - idempotency 簡易チェックのみ
11
+ * - 本実装 (Phase 1-1) では src/transcript-writer.mjs を別途作る
12
+ *
13
+ * tracer 経路: 注入した JSONL 行が**モデルの message 履歴に乗ったか**を切り分けるため、
14
+ * 最終 assistant 行末尾に **stdout 注入には含まれない一意トークン** を付与する。
15
+ * 次の /clear 後にユーザーがその合言葉の再現を求め、Claude が答えられれば JSONL 経路は
16
+ * モデル可視。答えられなければ JSONL は保持されてもメッセージ履歴に乗らない (孤立 chain
17
+ * 等が原因)。
18
+ *
19
+ * marker file `~/.throughline/spike-inject.flag` 削除で spike は無効化される。
20
+ */
21
+
22
+ import { readFileSync, existsSync, fsyncSync, openSync, closeSync, writeSync } from 'node:fs';
23
+ import { randomUUID, randomBytes } from 'node:crypto';
24
+ import { buildHandoffRecord } from './handoff-record.mjs';
25
+
26
+ /**
27
+ * targetJsonl の末尾行の uuid を返す。chain 設計案 (b) の親決定用。
28
+ * file が無い / 空 / uuid 持ち行が無い場合は null を返す。
29
+ */
30
+ function readLastUuid(path) {
31
+ if (!existsSync(path)) return null;
32
+ const text = readFileSync(path, 'utf8');
33
+ const lines = text.split('\n').filter((l) => l.trim());
34
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
35
+ try {
36
+ const o = JSON.parse(lines[i]);
37
+ if (typeof o.uuid === 'string') return o.uuid;
38
+ } catch {
39
+ // skip non-json line
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function buildUserLine({ b, parentUuid, newSessionId, cwd, version, gitBranch }) {
46
+ const uuid = randomUUID();
47
+ const ts = new Date(b.createdAt ?? Date.now()).toISOString();
48
+ const obj = {
49
+ parentUuid,
50
+ isSidechain: false,
51
+ promptId: randomUUID(),
52
+ type: 'user',
53
+ message: {
54
+ role: 'user',
55
+ content: [{ type: 'text', text: b.text ?? '' }],
56
+ },
57
+ uuid,
58
+ timestamp: ts,
59
+ permissionMode: 'auto',
60
+ userType: 'external',
61
+ entrypoint: 'claude-vscode',
62
+ cwd,
63
+ sessionId: newSessionId,
64
+ version,
65
+ gitBranch,
66
+ };
67
+ return { uuid, line: JSON.stringify(obj) };
68
+ }
69
+
70
+ function buildAssistantLine({ b, parentUuid, newSessionId, cwd, version, gitBranch, tracer, assistantModel }) {
71
+ const uuid = randomUUID();
72
+ const ts = new Date(b.createdAt ?? Date.now()).toISOString();
73
+ const baseText = b.text ?? '';
74
+ const text = tracer ? `${baseText}\n\n[spike-tracer: ${tracer}]` : baseText;
75
+ const obj = {
76
+ parentUuid,
77
+ isSidechain: false,
78
+ message: {
79
+ model: assistantModel,
80
+ id: `msg_spike_${uuid.slice(0, 8)}`,
81
+ type: 'message',
82
+ role: 'assistant',
83
+ content: [{ type: 'text', text }],
84
+ stop_reason: 'end_turn',
85
+ stop_sequence: null,
86
+ usage: { input_tokens: 0, output_tokens: 0 },
87
+ },
88
+ requestId: `req_spike_${uuid.slice(0, 8)}`,
89
+ type: 'assistant',
90
+ uuid,
91
+ timestamp: ts,
92
+ userType: 'external',
93
+ entrypoint: 'claude-vscode',
94
+ cwd,
95
+ sessionId: newSessionId,
96
+ version,
97
+ gitBranch,
98
+ };
99
+ return { uuid, line: JSON.stringify(obj) };
100
+ }
101
+
102
+ /**
103
+ * 末尾 assistant 行に埋める tracer を生成する。8 hex (32 bit)。
104
+ * stdout 注入には含まれない値である必要があるため、DB body text とは無相関に乱数生成する。
105
+ */
106
+ export function generateSpikeTracer() {
107
+ return randomBytes(4).toString('hex');
108
+ }
109
+
110
+ /**
111
+ * spike append. fsync 付きで JSONL に user/assistant 行を append する。
112
+ *
113
+ * @param {object} opts
114
+ * @param {string|null} [opts.tracer] 末尾 assistant 行に付与する一意トークン。
115
+ * 未指定なら付与しない (back-compat: 既存呼び出し用)。
116
+ * @returns {{
117
+ * appended: number,
118
+ * parentUuidStart: string|null,
119
+ * tracer: string|null,
120
+ * tracerAppendedAt: number|null,
121
+ * skipReason?: string
122
+ * }}
123
+ */
124
+ // Phase 0-5 retry: 偽モデル名 ('claude-throughline-spike') では Claude Code が messages[]
125
+ // 構築時にフィルタしている可能性があるため、デフォルトは実在 Claude モデル名にする。
126
+ const DEFAULT_SPIKE_ASSISTANT_MODEL = 'claude-opus-4-7';
127
+
128
+ export function spikeInject({
129
+ db,
130
+ targetJsonlPath,
131
+ newSessionId,
132
+ cwd,
133
+ version,
134
+ gitBranch,
135
+ tracer = null,
136
+ assistantModel = DEFAULT_SPIKE_ASSISTANT_MODEL,
137
+ }) {
138
+ const record = buildHandoffRecord(db, { sessionId: newSessionId, isInheritance: true });
139
+ if (!record || !record.memory?.recentBodies?.length) {
140
+ return {
141
+ appended: 0,
142
+ parentUuidStart: null,
143
+ tracer: null,
144
+ tracerAppendedAt: null,
145
+ skipReason: 'no_record_or_empty_l2',
146
+ };
147
+ }
148
+ const bodies = record.memory.recentBodies; // 古い順
149
+
150
+ // 末尾 assistant 行を 1 件特定 (assistant が必ず末尾とは限らないため後ろから探す)。
151
+ let lastAssistantIdx = -1;
152
+ for (let i = bodies.length - 1; i >= 0; i -= 1) {
153
+ if (bodies[i].role === 'assistant') {
154
+ lastAssistantIdx = i;
155
+ break;
156
+ }
157
+ }
158
+
159
+ let parentUuid = readLastUuid(targetJsonlPath);
160
+ const parentUuidStart = parentUuid;
161
+ const lines = [];
162
+ let tracerAppendedAt = null;
163
+ for (let i = 0; i < bodies.length; i += 1) {
164
+ const b = bodies[i];
165
+ const isLastAssistant = Boolean(tracer) && i === lastAssistantIdx;
166
+ const built = b.role === 'user'
167
+ ? buildUserLine({ b, parentUuid, newSessionId, cwd, version, gitBranch })
168
+ : buildAssistantLine({
169
+ b,
170
+ parentUuid,
171
+ newSessionId,
172
+ cwd,
173
+ version,
174
+ gitBranch,
175
+ tracer: isLastAssistant ? tracer : null,
176
+ assistantModel,
177
+ });
178
+ if (isLastAssistant) tracerAppendedAt = i;
179
+ lines.push(built.line);
180
+ parentUuid = built.uuid;
181
+ }
182
+ // sync write + fsync で hook 終了前に確実に flush
183
+ const fd = openSync(targetJsonlPath, 'a');
184
+ try {
185
+ writeSync(fd, lines.join('\n') + '\n');
186
+ fsyncSync(fd);
187
+ } finally {
188
+ closeSync(fd);
189
+ }
190
+ return {
191
+ appended: lines.length,
192
+ parentUuidStart,
193
+ tracer: tracer ?? null,
194
+ tracerAppendedAt,
195
+ };
196
+ }