throughline 0.5.0 → 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 (43) hide show
  1. package/.codex-sidecar.yml +5 -0
  2. package/CHANGELOG.md +47 -2
  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/{THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md → 10_transcript_injection_plan.md} +6 -6
  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/session-end-reasons.md +21 -0
  23. package/{docs/RAG → rag}/INDEX.md +20 -16
  24. package/src/baton.mjs +2 -2
  25. package/src/db.mjs +2 -2
  26. package/src/hook-entrypoints.test.mjs +102 -0
  27. package/src/package-files.test.mjs +1 -0
  28. package/src/prompt-submit.mjs +2 -2
  29. package/src/resume-context.mjs +1 -1
  30. package/src/session-merger.mjs +1 -1
  31. package/src/session-start.mjs +62 -3
  32. package/src/spike-transcript-writer.mjs +1 -1
  33. package/src/state-file.mjs +1 -1
  34. package/src/token-monitor.mjs +1 -1
  35. package/src/transcript-reader.mjs +71 -0
  36. package/src/turn-backfill.mjs +131 -0
  37. package/src/turn-backfill.test.mjs +213 -0
  38. package/src/turn-processor.mjs +28 -40
  39. /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
  40. /package/{docs/RAG/_raw/01-hooks → rag/01-hooks/raw}/hooks-reference-extract.md +0 -0
  41. /package/{docs/RAG/_raw/02-messages-api → rag/02-messages-api/raw}/messages-api-extract.md +0 -0
  42. /package/{docs/RAG/_raw/03-settings → rag/03-settings/raw}/sessions-extract.md +0 -0
  43. /package/{docs/RAG/_raw/04-skills → rag/04-skills/raw}/initialUserMessage-investigation.md +0 -0
@@ -7,17 +7,17 @@ This directory accumulates third-party specifications relevant to Throughline's
7
7
  ## Folder layout
8
8
 
9
9
  ```text
10
- docs/RAG/
10
+ rag/
11
11
  ├── INDEX.md (this file — synthesized findings, paths forward)
12
- └── _raw/ (verbatim spec extracts, kept close to source wording)
13
- ├── 01-hooks/
14
- │ └── hooks-reference-extract.md Claude Code hooks reference
15
- ├── 02-messages-api/
16
- │ └── messages-api-extract.md ← Anthropic Messages API spec
17
- ├── 03-settings/
18
- │ └── sessions-extract.md ← /clear, /compact, /resume behavior
19
- └── 04-skills/
20
- └── initialUserMessage-investigation.md ← deep-dive on the killer field
12
+ ├── 01-hooks/
13
+ ├── raw/hooks-reference-extract.md ← Claude Code hooks reference
14
+ │ └── raw/session-end-reasons.md SessionEnd reason enum + timeout 1.5s (2026-07-11 fetch)
15
+ ├── 02-messages-api/
16
+ │ └── raw/messages-api-extract.md ← Anthropic Messages API spec
17
+ ├── 03-settings/
18
+ │ └── raw/sessions-extract.md ← /clear, /compact, /resume behavior
19
+ └── 04-skills/
20
+ └── raw/initialUserMessage-investigation.md ← deep-dive on the killer field
21
21
  ```
22
22
 
23
23
  ---
@@ -33,7 +33,7 @@ docs/RAG/
33
33
  ### Finding 1: `additionalContext` is a system reminder, not a user message
34
34
 
35
35
  > "Claude Code wraps the string in a system reminder and inserts it into the conversation at the point where the hook fired. Claude reads the reminder on the next model request, but it **does not appear as a chat message** in the interface."
36
- > — [Hooks reference](_raw/01-hooks/hooks-reference-extract.md#what-additionalcontext-actually-does-critical)
36
+ > — [Hooks reference](01-hooks/raw/hooks-reference-extract.md#what-additionalcontext-actually-does-critical)
37
37
 
38
38
  → システムリマインダ = ブリーフィング扱い。モデルが「他人事」と感じる構造的原因。
39
39
 
@@ -41,13 +41,13 @@ docs/RAG/
41
41
 
42
42
  > "any non-JSON text written to stdout is added as context"
43
43
  > "Claude Code wraps the string in a system reminder"
44
- > — [Hooks reference](_raw/01-hooks/hooks-reference-extract.md#stdout)
44
+ > — [Hooks reference](01-hooks/raw/hooks-reference-extract.md#stdout)
45
45
 
46
46
  → 現行 Throughline v0.4.12 の stdout 注入はこの経路。`additionalContext` と同じカテゴリ = 同じ「他人事」問題。
47
47
 
48
48
  ### Finding 3: `initialUserMessage` exists in the schema, but is **HEADLESS-ONLY**
49
49
 
50
- Verified via [openclaude source](_raw/04-skills/initialUserMessage-investigation.md#the-critical-constraint-from-openclaude-source-comment):
50
+ Verified via [openclaude source](04-skills/raw/initialUserMessage-investigation.md#the-critical-constraint-from-openclaude-source-comment):
51
51
 
52
52
  ```text
53
53
  // SessionStart hooks can emit initialUserMessage — the first user turn for
@@ -56,19 +56,19 @@ Verified via [openclaude source](_raw/04-skills/initialUserMessage-investigation
56
56
 
57
57
  → Interactive mode (`/clear` シナリオ) では発火しない。我々の問題には使えない。
58
58
 
59
- **2026-05-24 実機確認**: real Claude Code (v2.1.145) で `~/.throughline/initial-user-message-test.flag` を立てて SessionStart hook を JSON 出力モードに切り替え、`hookSpecificOutput.initialUserMessage` に 8 hex tracer 入りメッセージを乗せて `/clear` 後の cleared-me に「過去発話の tracer を message history だけ見て返して」と尋ねた。ラン (2) 13:33 tracer `9220a79c` (session `0979ad20-…`) → モデル応答 **「ない」**。openclaude のソースコメントが real CC でも妥当であることを実機で確認。詳細: [docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md §6 Phase 0-6](../THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md#phase-0-6--hookspecificoutputinitialusermessage-経路-spike)
59
+ **2026-05-24 実機確認**: real Claude Code (v2.1.145) で `~/.throughline/initial-user-message-test.flag` を立てて SessionStart hook を JSON 出力モードに切り替え、`hookSpecificOutput.initialUserMessage` に 8 hex tracer 入りメッセージを乗せて `/clear` 後の cleared-me に「過去発話の tracer を message history だけ見て返して」と尋ねた。ラン (2) 13:33 tracer `9220a79c` (session `0979ad20-…`) → モデル応答 **「ない」**。openclaude のソースコメントが real CC でも妥当であることを実機で確認。詳細: [docs/10_transcript_injection_plan.md §6 Phase 0-6](../docs/10_transcript_injection_plan.md#phase-0-6--hookspecificoutputinitialusermessage-経路-spike)
60
60
 
61
61
  ### Finding 4: Messages API treats all messages[] entries equally
62
62
 
63
63
  > "When creating a new Message, you specify the prior conversational turns with the messages parameter, and the model then generates the next Message in the conversation."
64
- > — [Messages API](_raw/02-messages-api/messages-api-extract.md#no-differentiation-between-real--synthetic-messages-key)
64
+ > — [Messages API](02-messages-api/raw/messages-api-extract.md#no-differentiation-between-real--synthetic-messages-key)
65
65
 
66
66
  → もし messages[] に synthetic な過去 turn を入れられれば、モデルは「本物」と区別できない。問題は CC が messages[] を hook から制御させていないこと。
67
67
 
68
68
  ### Finding 5: `/clear` preserves the JSONL but resets in-memory state
69
69
 
70
70
  > "/clear: start fresh with an empty context. The previous conversation is saved and resumable"
71
- > — [Sessions](_raw/03-settings/sessions-extract.md#clear-behavior-key)
71
+ > — [Sessions](03-settings/raw/sessions-extract.md#clear-behavior-key)
72
72
 
73
73
  → CC は in-memory state を一次ソースに messages[] を構築。JSONL を外から書き換えても in-memory には反映されない (= Phase 0 / Phase 0-5 で実測確認済み)。
74
74
 
@@ -82,6 +82,10 @@ Verified via [openclaude source](_raw/04-skills/initialUserMessage-investigation
82
82
 
83
83
  [anthropics/claude-code/plugins/plugin-dev/skills/hook-development/SKILL.md](https://github.com/anthropics/claude-code/blob/main/plugins/plugin-dev/skills/hook-development/SKILL.md) は SessionStart hook の `hookSpecificOutput` の `additionalContext` も `initialUserMessage` も触れていない。Plugin 開発者向けの公式チュートリアルですら触れない = どちらも primary 経路として推奨されていない可能性。
84
84
 
85
+ ### Finding 8: SessionEnd has a `clear` reason; built-in `/clear` never reaches UserPromptSubmit (2026-07-11)
86
+
87
+ SessionEnd reason enum: `clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other`、default timeout 1.5s(/clear にも適用)— [session-end-reasons.md](01-hooks/raw/session-end-reasons.md)。実測: ビルトイン /clear はどのクライアントでも UserPromptSubmit に届かない(同一セッション /tl 対照実験 ×2 + VSCode 2.1.207)。VSCode は `source:"clear"` を送るが Desktop 2.1.205 は `source:"startup"`(クライアント実装差・バージョン交絡棄却済み)。→ Desktop の /clear 検知は SessionEnd(reason='clear') が唯一の hook 経路候補(実機検証は [docs/12](../docs/12_desktop_clear_handoff_plan.md) A Phase 1)。
88
+
85
89
  ---
86
90
 
87
91
  ## Throughline 仮説の見直し
package/src/baton.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * baton.mjs — 引き継ぎバトン管理
3
3
  *
4
- * バトン方式の設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
4
+ * バトン方式の設計 (docs/02_clear_auto_handoff_plan.md):
5
5
  * - 新仕様では `/clear` 自動引継ぎがデフォルト ON。バトンは「/clear 自動引継ぎを
6
6
  * 使わずに明示的に引き継ぎたい」ユーザーのための逃げ道。
7
7
  * - ユーザーが旧セッションで `/tl` スラッシュコマンドを打つ → UserPromptSubmit hook が
@@ -14,7 +14,7 @@
14
14
  * "startup" に潰される問題 (#49937) に対する明示意思マーカーとして導入。
15
15
  * 2026-05-08 時点で Claude Code 2.1.128 で source='clear' は reliable に
16
16
  * なったため auto path 中心の設計に変わったが、明示意思の signal として
17
- * baton 仕組み自体は残す。詳細は docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md。
17
+ * baton 仕組み自体は残す。詳細は docs/02_clear_auto_handoff_plan.md。
18
18
  */
19
19
 
20
20
  /**
package/src/db.mjs CHANGED
@@ -180,7 +180,7 @@ function initSchema(db) {
180
180
  // v5 → v6: handoff_batons テーブル追加(/tl スラッシュコマンドによる明示的引き継ぎ指名用)
181
181
  // - project_path ごとに最新 1 件のみ (PRIMARY KEY)
182
182
  // - SessionStart で読み出し、TTL 以内なら merge して DELETE
183
- // - docs/INHERITANCE_ON_CLEAR_ONLY.md 参照: 案 D (時間差) 撤去、バトン方式へ移行
183
+ // - docs/03_inheritance_on_clear_only.md 参照: 案 D (時間差) 撤去、バトン方式へ移行
184
184
  if (version < 6) {
185
185
  db.exec(`
186
186
  CREATE TABLE IF NOT EXISTS handoff_batons (
@@ -203,7 +203,7 @@ function initSchema(db) {
203
203
  }
204
204
 
205
205
  // v7 → v8: handoff_batons から memo_text 列を drop。
206
- // 新仕様 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md) で memo 廃止:
206
+ // 新仕様 (docs/02_clear_auto_handoff_plan.md) で memo 廃止:
207
207
  // - /clear 自動引継ぎ (SessionStart source='clear') + /tl baton (memo なし) の 2 経路に
208
208
  // - 注入は L1 + L2 + L3 refs のみ
209
209
  // - save-inflight CLI / updateBatonMemo 関数も併せて削除
@@ -370,6 +370,108 @@ test('process-turn subprocess stores L2 bodies and L3 details in an isolated DB'
370
370
  }
371
371
  });
372
372
 
373
+ test('process-turn subprocess backfills all completed logical turns from a multi-turn JSONL', () => {
374
+ const home = makeTempHome();
375
+ const project = makeTempProject();
376
+ const transcriptPath = join(project, 'transcript.jsonl');
377
+ try {
378
+ writeFileSync(
379
+ transcriptPath,
380
+ [
381
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'first request' }] } },
382
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'first answer' }] } },
383
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'second request' }] } },
384
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'second answer' }] } },
385
+ ].map((entry) => JSON.stringify(entry)).join('\n'),
386
+ 'utf8',
387
+ );
388
+ const result = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
389
+ home,
390
+ cwd: project,
391
+ input: JSON.stringify({ session_id: 'multi-turn-session', cwd: project, transcript_path: transcriptPath }),
392
+ });
393
+ assert.equal(result.status, 0, result.stderr);
394
+ const db = openDb(home);
395
+ assert.deepEqual(
396
+ db.prepare('SELECT role, text FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
397
+ [
398
+ { role: 'assistant', text: 'first answer' },
399
+ { role: 'user', text: 'first request' },
400
+ { role: 'assistant', text: 'second answer' },
401
+ { role: 'user', text: 'second request' },
402
+ ],
403
+ );
404
+ db.close();
405
+ } finally {
406
+ rmSync(project, { recursive: true, force: true });
407
+ rmSync(home, { recursive: true, force: true });
408
+ }
409
+ });
410
+
411
+ test('session-start backfills a derived predecessor transcript without a state file', () => {
412
+ const home = makeTempHome();
413
+ const project = makeTempProject();
414
+ const predecessorId = 'missing-stop-predecessor';
415
+ const derivedPath = join(
416
+ home,
417
+ '.claude',
418
+ 'projects',
419
+ `-${project.replace(/[/.]/g, '-').replace(/^-+/, '')}`,
420
+ `${predecessorId}.jsonl`,
421
+ );
422
+ try {
423
+ mkdirSync(dirname(derivedPath), { recursive: true });
424
+ writeFileSync(
425
+ derivedPath,
426
+ [
427
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'predecessor question' }] } },
428
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'predecessor answer' }] } },
429
+ ].map((entry) => JSON.stringify(entry)).join('\n'),
430
+ 'utf8',
431
+ );
432
+ const baton = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
433
+ home,
434
+ cwd: project,
435
+ input: JSON.stringify({ session_id: predecessorId, cwd: project, prompt: '/clear' }),
436
+ });
437
+ assert.equal(baton.status, 0, baton.stderr);
438
+ const db = openDb(home);
439
+ db.prepare(
440
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
441
+ VALUES (?, ?, 'active', 1, 1)`,
442
+ ).run(predecessorId, project);
443
+ db.close();
444
+
445
+ const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
446
+ home,
447
+ cwd: project,
448
+ input: JSON.stringify({ session_id: 'new-session', cwd: project, source: 'startup' }),
449
+ });
450
+ assert.equal(started.status, 0, started.stderr);
451
+ const after = openDb(home);
452
+ assert.deepEqual(
453
+ after
454
+ .prepare('SELECT session_id, origin_session_id, role, text FROM bodies ORDER BY turn_number, role')
455
+ .all()
456
+ .map((row) => ({ ...row })),
457
+ [
458
+ { session_id: 'new-session', origin_session_id: predecessorId, role: 'assistant', text: 'predecessor answer' },
459
+ { session_id: 'new-session', origin_session_id: predecessorId, role: 'user', text: 'predecessor question' },
460
+ ],
461
+ );
462
+ after.close();
463
+ const backfillLog = readFileSync(join(home, '.throughline', 'logs', 'backfill.log'), 'utf8')
464
+ .split('\n')
465
+ .filter((line) => line)
466
+ .map((line) => JSON.parse(line));
467
+ assert.ok(backfillLog.some((entry) => entry.hook === 'session-start'));
468
+ assert.equal(existsSync(join(home, '.throughline', 'state', `${predecessorId}.json`)), false);
469
+ } finally {
470
+ rmSync(project, { recursive: true, force: true });
471
+ rmSync(home, { recursive: true, force: true });
472
+ }
473
+ });
474
+
373
475
  // ---- Phase 0-5 spike (UserPromptSubmit) ----
374
476
 
375
477
  function seedMergedSession(home, sessionId, originId = 'orig-sess') {
@@ -12,6 +12,7 @@ test('npm package files include Claude and Codex agent surfaces', () => {
12
12
  '.claude/commands/',
13
13
  '.codex-sidecar.yml',
14
14
  'docs/',
15
+ 'rag/',
15
16
  'CHANGELOG.md',
16
17
  'README.md',
17
18
  'LICENSE',
@@ -20,9 +20,9 @@
20
20
  * 行を chain-reachable (= 直前の attachment uuid を parent に取る) で append する。
21
21
  * SessionStart 経路の spike (chain (a) = orphan) ではモデルに届かなかったため、
22
22
  * UserPromptSubmit 経路で chain (b) を成立させて再検証する。
23
- * docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-5 参照。
23
+ * docs/10_transcript_injection_plan.md Phase 0-5 参照。
24
24
  *
25
- * 設計背景: docs/INHERITANCE_ON_CLEAR_ONLY.md バトン方式
25
+ * 設計背景: docs/03_inheritance_on_clear_only.md バトン方式
26
26
  */
27
27
 
28
28
  import { getDb } from './db.mjs';
@@ -4,7 +4,7 @@
4
4
  * 呼び出し元:
5
5
  * - session-start.mjs (auto path / baton path どちらでも同じ注入)
6
6
  *
7
- * 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
7
+ * 設計 (docs/02_clear_auto_handoff_plan.md):
8
8
  * - 注入順: ヘッダ + 読み方 → 現在地アンカー → L1 要約 → L2 本文(一番下)
9
9
  * - 「現在地」アンカーは直前の user / assistant turn をヘッダ直下に再掲して
10
10
  * 最初の注意を最新ターンに固定する。L2 末尾アンカーは補強として残す。
@@ -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,7 +28,9 @@
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
35
  import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
34
36
  import { randomBytes } from 'node:crypto';
@@ -37,7 +39,7 @@ import { homedir } from 'node:os';
37
39
  import { pathToFileURL } from 'node:url';
38
40
 
39
41
  // SPIKE ONLY — Phase 0-2 / 0-4 検証用。marker file 削除で無効化される。
40
- // docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md §3 Phase 0-2 参照。
42
+ // docs/10_transcript_injection_plan.md §3 Phase 0-2 参照。
41
43
  const SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-inject.flag');
42
44
 
43
45
  // Phase 0-6: initialUserMessage が interactive モードで効くか実機検証する experimental switch。
@@ -175,6 +177,63 @@ export async function run() {
175
177
  // Phase 0-6: initialUserMessage test flag 存在時は JSON 出力に切り替え、
176
178
  // initialUserMessage が interactive モードで messages[] に乗るか実機検証する。
177
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
+
178
237
  const text = buildResumeContext(db, {
179
238
  sessionId: session_id,
180
239
  isInheritance: true,
@@ -210,7 +269,7 @@ export async function run() {
210
269
 
211
270
  // 5. SPIKE: marker file あり + merge 成立 + transcript_path あり の 3 条件で
212
271
  // L2 を user/assistant role 付きで transcript_path にも append する。
213
- // 本実装ではない (docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-2)。
272
+ // 本実装ではない (docs/10_transcript_injection_plan.md Phase 0-2)。
214
273
  //
215
274
  // tracer: 末尾 assistant 行に stdout 注入には含まれない一意トークンを付与する。
216
275
  // 次の /clear 後に Claude が tracer を再現できれば JSONL 経路はモデル可視。
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SPIKE ONLY — Phase 0-2 / 0-4 検証用。本実装ではない。
3
3
  *
4
- * docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-2 で
4
+ * docs/10_transcript_injection_plan.md Phase 0-2 で
5
5
  * 「`/clear` 直後の SessionStart hook 内で transcript_path に L2 を user/assistant
6
6
  * role 付きで append すると、Claude が次の short prompt の文脈として読むか」を実機検証する。
7
7
  *
@@ -5,7 +5,7 @@
5
5
  * 書き手: turn-processor (Claude Stop), codex-hook (Codex Stop)
6
6
  * 読み手: token-monitor
7
7
  *
8
- * 設計判断 (docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6):
8
+ * 設計判断 (docs/04_public_release_plan.md §4.5/4.6):
9
9
  * - ファイル単位分割で last-writer-wins 問題を解消
10
10
  * - updatedAt ベースで stale 判定(短命 hook process の PID には依存しない)
11
11
  * - projectPath は path.resolve → / → 末尾 / 除去 → Windows lowercase で正規化
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * VS Code の分割ターミナルなどで常時起動しておく。
11
11
  *
12
- * 設計: docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6
12
+ * 設計: docs/04_public_release_plan.md §4.5/4.6
13
13
  * - 状態ファイルはセッション単位 (~/.throughline/state/<session_id>.json)
14
14
  * - setInterval (1s) + mtime 差分検知で更新を捕捉
15
15
  * - updatedAt 降順ソート、先頭行を ▶ でハイライト
@@ -54,22 +54,93 @@ export function readTranscript(transcriptPath) {
54
54
  // user / assistant エントリのみ対象
55
55
  if (entry.type !== 'user' && entry.type !== 'assistant') continue;
56
56
 
57
+ // subagent の sidechain エントリは主会話ではないので除外。現行 CC は主 transcript に
58
+ // 書かない(400 transcript 実測ゼロ件)が、将来変更への安価な防御 (docs/12 B-1)
59
+ if (entry.isSidechain === true) continue;
60
+
57
61
  const msg = entry.message;
58
62
  if (!msg || !msg.role || msg.content == null) continue;
59
63
 
60
64
  const text = extractText(msg.content);
61
65
  if (!text) continue;
62
66
 
67
+ const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
63
68
  turns.push({
64
69
  role: msg.role,
65
70
  content: text,
66
71
  turn_number: turns.length,
72
+ timestamp: Number.isNaN(ts) ? null : ts,
67
73
  });
68
74
  }
69
75
 
70
76
  return turns;
71
77
  }
72
78
 
79
+ /**
80
+ * assistant テキスト断片が API 通知(junk)かを判定する。
81
+ * junk が論理ターン群の最終断片になると、通知を本文として保存し実回答を捨てる
82
+ * ことになるため、代表選択から除外する (docs/12 B-1 refuter 修正3)。
83
+ * パターンは実測で bodies に混入した通知に限定し、prefix 固定で偽陽性を避ける。
84
+ * @param {string} text
85
+ */
86
+ export function isJunkAssistantText(text) {
87
+ if (typeof text !== 'string') return false;
88
+ return (
89
+ text.startsWith("You've hit your session limit") ||
90
+ text.startsWith("You've reached your") ||
91
+ text.startsWith('API Error')
92
+ );
93
+ }
94
+
95
+ /**
96
+ * transcript を論理ターン群に分解する。
97
+ *
98
+ * 論理ターン群 = user テキストエントリ 1 件 + それに続く assistant テキスト断片群。
99
+ * 途中割り込み(plan 拒否・AskUserQuestion 応答等)は tool_result 内に埋まり
100
+ * readTranscript には不可視のため、1 群が複数 Stop・複数断片を含むのは日常パターン。
101
+ *
102
+ * representative = 群内最後の非 junk 断片。この index が bodies の turn_number になる
103
+ * (user 行・assistant 行とも同じ turn_number で保存する現行規約と同じ)。
104
+ * 全断片が junk の群、断片ゼロの群(assistant 本文が transcript に無い B-2 ケース)は
105
+ * 返さない。
106
+ *
107
+ * @param {string} transcriptPath
108
+ * @returns {Array<{
109
+ * user: {content: string, timestamp: number|null, turn_number: number},
110
+ * fragments: Array<{index: number, content: string, timestamp: number|null}>,
111
+ * representative: {index: number, content: string, timestamp: number|null},
112
+ * }>}
113
+ */
114
+ export function getLogicalTurnGroups(transcriptPath) {
115
+ const turns = readTranscript(transcriptPath);
116
+ const raw = [];
117
+ let current = null;
118
+ for (const t of turns) {
119
+ if (t.role === 'user') {
120
+ if (current) raw.push(current);
121
+ current = { user: t, fragments: [] };
122
+ } else if (t.role === 'assistant' && current) {
123
+ current.fragments.push({ index: t.turn_number, content: t.content, timestamp: t.timestamp });
124
+ }
125
+ }
126
+ if (current) raw.push(current);
127
+
128
+ const groups = [];
129
+ for (const g of raw) {
130
+ if (g.fragments.length === 0) continue;
131
+ let representative = null;
132
+ for (let i = g.fragments.length - 1; i >= 0; i--) {
133
+ if (!isJunkAssistantText(g.fragments[i].content)) {
134
+ representative = g.fragments[i];
135
+ break;
136
+ }
137
+ }
138
+ if (!representative) continue; // 全断片 junk
139
+ groups.push({ user: g.user, fragments: g.fragments, representative });
140
+ }
141
+ return groups;
142
+ }
143
+
73
144
  /**
74
145
  * ANSI エスケープシーケンスを除去する。
75
146
  * ツール出力(特に Bash)にしばしば含まれる色コードを剥がす。
@@ -0,0 +1,131 @@
1
+ /**
2
+ * turn-backfill.mjs — transcript 全体走査による L2 回収の共通ルーチン
3
+ *
4
+ * 従来の「Stop ごとに最後の 1 ペアのみ保存」は、Stop の空振り・不発が bodies の
5
+ * 永久穴になった(実測欠落率 Desktop 27% / VSCode 41%、docs/12 §6)。
6
+ * 本ルーチンは transcript の全論理ターン群を走査し、未捕捉の完了ターンを回収する。
7
+ * turn-processor(毎 Stop)と session-start(マージ直後の前任回収)が共用する。
8
+ *
9
+ * 設計は docs/12 Workstream B-1(refuter 修正 1/3/4/5 適用済み):
10
+ * - 群レベル dedup: 群の**どの断片 index も** bodies に無い群だけ挿入する。
11
+ * 部分捕捉済み群への再挿入は「同一 user 発話の重複ペア」を量産する
12
+ * (実測: 全 DB で 110 群が該当)ため、代表断片の差し替え回収はしない。
13
+ * - 代表断片 = 群内最後の非 junk 断片(getLogicalTurnGroups 側で選択済み)。
14
+ * - created_at は transcript エントリの timestamp。now を使うと一括回収行が
15
+ * 同一ミリ秒に潰れ、created_at 順ソート(handoff-record の L2 窓・現在地アンカー)
16
+ * の会話順が tie で不定化するため。timestamp 欠損時のみ now。
17
+ * - INSERT は 1 トランザクション(fsync 1 回、turn-processor の details と同型)。
18
+ */
19
+
20
+ import { appendFileSync, mkdirSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+ import { getLogicalTurnGroups } from './transcript-reader.mjs';
24
+
25
+ /**
26
+ * バックフィル回収実績を ~/.throughline/logs/backfill.log に 1 行 JSON で記録する。
27
+ * @param {object} entry
28
+ */
29
+ export function logBackfill(entry) {
30
+ const path = join(homedir(), '.throughline', 'logs', 'backfill.log');
31
+ try {
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
34
+ } catch (err) {
35
+ const msg = err instanceof Error ? err.message : 'unknown';
36
+ process.stderr.write(`[backfill-log] ${msg}\n`);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Claude Code の project-dir munging から session transcript path を決定的に導出する。
42
+ * macOS/Linux の Claude Code 規約を mirror する。Windows では呼び出し側が state file の
43
+ * transcriptPath に fallback する。
44
+ * @param {string} projectPath
45
+ * @param {string} sessionId
46
+ * @returns {string}
47
+ */
48
+ export function deriveTranscriptPath(projectPath, sessionId) {
49
+ const mungedProjectPath = `-${String(projectPath).replace(/[/.]/g, '-').replace(/^-+/, '')}`;
50
+ return join(homedir(), '.claude', 'projects', mungedProjectPath, `${sessionId}.jsonl`);
51
+ }
52
+
53
+ /**
54
+ * transcript の未捕捉完了ターンを bodies へ回収する。
55
+ *
56
+ * @param {import('node:sqlite').DatabaseSync} db
57
+ * @param {object} opts
58
+ * @param {string} opts.targetSessionId 書き込み先 session_id(merge 済みなら合流先)
59
+ * @param {string} opts.originSessionId transcript を所有する origin session_id
60
+ * @param {string|null|undefined} opts.transcriptPath
61
+ * @param {number} opts.now timestamp 欠損時の fallback epoch ms
62
+ * @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null}}
63
+ */
64
+ export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
65
+ const groups = getLogicalTurnGroups(transcriptPath);
66
+ if (groups.length === 0) {
67
+ return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null };
68
+ }
69
+
70
+ const existing = new Set(
71
+ db
72
+ .prepare('SELECT DISTINCT turn_number FROM bodies WHERE origin_session_id = ?')
73
+ .all(originSessionId)
74
+ .map((r) => r.turn_number),
75
+ );
76
+
77
+ const insertBody = db.prepare(
78
+ `INSERT OR IGNORE INTO bodies
79
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
80
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
81
+ );
82
+
83
+ let insertedTurns = 0;
84
+ let skippedExisting = 0;
85
+
86
+ db.exec('BEGIN');
87
+ try {
88
+ for (const g of groups) {
89
+ // 群のいずれかの断片 index が既に bodies にある = 部分捕捉済み群。
90
+ // 代表差し替えは重複ペアを生むので回収しない (refuter 修正1)。
91
+ if (g.fragments.some((f) => existing.has(f.index))) {
92
+ skippedExisting++;
93
+ continue;
94
+ }
95
+ const turnNumber = g.representative.index;
96
+ const assistantAt = g.representative.timestamp ?? now;
97
+ const userAt = g.user.timestamp ?? assistantAt;
98
+
99
+ insertBody.run(
100
+ targetSessionId,
101
+ originSessionId,
102
+ turnNumber,
103
+ 'user',
104
+ g.user.content,
105
+ Math.round(g.user.content.length / 4),
106
+ userAt,
107
+ );
108
+ insertBody.run(
109
+ targetSessionId,
110
+ originSessionId,
111
+ turnNumber,
112
+ 'assistant',
113
+ g.representative.content,
114
+ Math.round(g.representative.content.length / 4),
115
+ assistantAt,
116
+ );
117
+ insertedTurns++;
118
+ }
119
+ db.exec('COMMIT');
120
+ } catch (err) {
121
+ db.exec('ROLLBACK');
122
+ throw err;
123
+ }
124
+
125
+ return {
126
+ groups: groups.length,
127
+ insertedTurns,
128
+ skippedExisting,
129
+ lastTurnNumber: groups[groups.length - 1].representative.index,
130
+ };
131
+ }